From 860f9341c754f84c8ce5d06a103d2d1fd39fefdb Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 19 Sep 2026 18:55:46 -0700 Subject: [PATCH] feat: save page edits to password-protected sections PreparedEdit::page_protected and Notebook::save_unlocked publish a page-model edit under the section key. The page writer runs unchanged on a plaintext twin of the unlocked section (every labelled revision and payload under its own identity); the revisions the twin gained are squashed onto the real file with objects sealed under fresh IVs and payloads under the file IV. OneNote 2010 unlocks and reads the result and keeps editing it (corpus/protected-edit). Its follow-up revisions showed that a revision with a dependency carries no key node; the reader accepts that and the writer matches. Assisted-by: claude-fable-5.1 --- corpus/protected-edit/README.md | 13 ++ .../candidate/notebook/Open Notebook.onetoc2 | Bin 0 -> 4936 bytes .../candidate/notebook/synthetic.one | Bin 0 -> 20648 bytes ...0b7c57eb3b6110a06aa2114e06d69a7.attachment | 1 + .../candidate/read/environment.json | 7 + .../candidate/read/hierarchy.xml | 2 + .../candidate/read/page-000.xml | 9 + .../candidate/read/payloads.json | 10 + .../protected-edit/native-after/synthetic.one | Bin 0 -> 26408 bytes crates/notebook/README.md | 7 +- crates/notebook/src/session.rs | 18 ++ crates/notebook/tests/protected.rs | 87 ++++++++ crates/onestore/README.md | 8 +- crates/onestore/src/commit.rs | 16 ++ crates/onestore/src/page/write.rs | 25 ++- crates/onestore/src/protected/crypto.rs | 57 +++++- crates/onestore/src/protected/mod.rs | 185 +++++++++++++++++- crates/onestore/src/revisions.rs | 9 +- crates/onestore/src/write.rs | 140 +++++++++++-- crates/onestore/tests/protected.rs | 129 ++++++++++++ fuzz/Cargo.toml | 7 + fuzz/fuzz_targets/protected_write.rs | 69 +++++++ tools/test_protected_edit.py | 45 +++++ 23 files changed, 804 insertions(+), 40 deletions(-) create mode 100644 corpus/protected-edit/README.md create mode 100644 corpus/protected-edit/candidate/notebook/Open Notebook.onetoc2 create mode 100644 corpus/protected-edit/candidate/notebook/synthetic.one create mode 100644 corpus/protected-edit/candidate/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment create mode 100644 corpus/protected-edit/candidate/read/environment.json create mode 100644 corpus/protected-edit/candidate/read/hierarchy.xml create mode 100644 corpus/protected-edit/candidate/read/page-000.xml create mode 100644 corpus/protected-edit/candidate/read/payloads.json create mode 100644 corpus/protected-edit/native-after/synthetic.one create mode 100644 fuzz/fuzz_targets/protected_write.rs create mode 100644 tools/test_protected_edit.py diff --git a/corpus/protected-edit/README.md b/corpus/protected-edit/README.md new file mode 100644 index 0000000000000000000000000000000000000000..21d294a63600c317ebb157826b4e0d042aeecc67 --- /dev/null +++ b/corpus/protected-edit/README.md @@ -0,0 +1,13 @@ +# Protected page edit + +`candidate/notebook` is `native-encrypted/encrypted-01` after +`Notebook::save_unlocked` appended text to the first paragraph and added a +paragraph, exported by `an_unlocked_page_is_saved_under_the_section_key` +(`ONESTORE_PROTECTED_EXPORT`). `candidate/read` is OneNote 2010's COM read from a +fresh clone with an empty cache after unlocking the section in its UI with the +fixture password (`../native-encrypted/manifest.json`). + +`native-after/synthetic.one` is the same section after OneNote then typed +" Native edit after Rust." into the positioned outline and saved. Its revisions +that depend on an earlier revision carry no key node (`0x7c`); only revisions +without a dependency name the key. diff --git a/corpus/protected-edit/candidate/notebook/Open Notebook.onetoc2 b/corpus/protected-edit/candidate/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..3757c08bbbcd0eadbc8f0548068c44684f9b21dd GIT binary patch literal 4936 zcmZ3e|KIuM`!b(-j|5(YE0x7J#4gqT(JSk=YNPqbC`J7Q1~g!Qcb0(k#IK%9FNjZk zdt%)UX`nnZhAICK1Ryp$OaMy5#LrC%m#!ZJs4|T)xJt%`;p2`#e;xhrUAP|GZKsdc~mM@485*LDsfy8Z+ z?=1%Lxu9aE*&@jxK1|J2U9Y7ewhUBWySLB?#8-#%EtGG8n4@4c1V%$(fI3 z2=L&jPrfhZcLB)0Mq7CxYI3j{<8a5sXaUovs}-G* z7M9ZLy}oeg4vEXhCxW!({{jpR zU>Tx`2tKG%76t}b{Q(qdfQmLSGVGIK5Sco46;J><+&~Tmh0UR7g*#(qemg9CcI`sC zTjf5GIJWTmu-Y3vY*c}c2dV}GSa^8fIkO|!Z=&0-lAfsg&wjr_#yJ`=^qyp70{N2# zXGnm24{}f6nn{&c60UgdTUX&cfAh*IAOoSdJ?Z1C zbu}<|f{cQ>Q{AihP5qf|{>wFMB8>Mu*angV#hX79H!MDY@dk3aHxmmNP?i;|%WE_l ze!*PswRqy1mDbB$X9~nz`m}J86UazxzEN2H;L>l*R0dK$OZgvA~^F^3}sP&>49MF=1lH?VR6m>$5T4HLLb_$I@k0xB0EsRZij1{Q`XvJ5gifn`QEkOq|mWH6)x$xMc120ey+phzlE4&(wL1q8r^ l2eucKCQ_T5g0N|6kPMDNC+a0N(c%f z(nyEYKSLbP@pA6{|L?oc^MB#&XT9^znp(5onYCutto_x%BH&v0J>ACzhQ4iF37u+f z)LUFO`WmT~K8`N(r+E>-1cmzyOoE$Kex!V8+;q32~k? z==Ua53+bW`@4mx&P|ZX9O6gM>h~FzGtW+8R)39Bb|KRW{ni|9~z;uM1 zhaybFcL*w~yp}M%SAR|V#VeTJtM9|oH3rjr{n0IC#KJTVfbI5=70JRh?rz#$;p!-) zAJTXT030_B0Db_lJfFOCCd}XC9cCVZ3QX_ujEqumix#Bu0MPBm^dN@qFaS6JfN?iw z+Kpj-W&p5#EZGEQ5d99nIq+}ez@Gkxa~6h!f1~6{fhNzw_@AxG_mhu%{DEs221>X6 zH$gCuu>p;Cqp4>IV`Nl$Iu*tl8Ne0G0`{L0tPYUE6K7&^qv(1cdy$ZZW(L5UAE@Yd1j=k8Krh@yLb8d$1NP%b zo`0?;>tjSm{Bf-S??UkyQ8n5h_ZQ~tbNWum73xi;Dsgfdoqp)2|0u5F^Ir2{{%0D{Ki@b*@AhW`EypYakcL6j3 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 H-Ov6X`N_Oi literal 0 HcmV?d00001 diff --git a/corpus/protected-edit/candidate/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment b/corpus/protected-edit/candidate/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment new file mode 100644 index 0000000000000000000000000000000000000000..66b6151e91f90df6a61a19ef2f4447da278e07b2 --- /dev/null +++ b/corpus/protected-edit/candidate/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment @@ -0,0 +1 @@ +Fictitious attachment for native corpus. \ No newline at end of file diff --git a/corpus/protected-edit/candidate/read/environment.json b/corpus/protected-edit/candidate/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..72d528e800c26fa9676aa56590f514f284b54f3d --- /dev/null +++ b/corpus/protected-edit/candidate/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-F02GATE", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/protected-edit/candidate/read/hierarchy.xml b/corpus/protected-edit/candidate/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..956fb8c3c6befceb98ff9743ba2abd12bade9088 --- /dev/null +++ b/corpus/protected-edit/candidate/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-edit/candidate/read/page-000.xml b/corpus/protected-edit/candidate/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..23c95a03d81023e6f6cc16107559ec409c999d72 --- /dev/null +++ b/corpus/protected-edit/candidate/read/page-000.xml @@ -0,0 +1,9 @@ + +Fictitious: café, 東京, مرحبا and edited under its key]]>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA +AAAASUVORK5CYII= diff --git a/corpus/protected-edit/candidate/read/payloads.json b/corpus/protected-edit/candidate/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..627d31bc43c3370a0ffc5de4b3ee6007cb9e0762 --- /dev/null +++ b/corpus/protected-edit/candidate/read/payloads.json @@ -0,0 +1,10 @@ +[ + { + "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", + "name": "fictitious-attachment.txt", + "object": "{6899E045-AC46-0B2E-1C63-C98811ADBC94}{32}{B0}", + "kind": "InsertedFile", + "page": "{2A0ED059-C349-09FD-0D81-ED36E47D6F2A}{14}{B0}", + "bytes": 43 + } +] \ No newline at end of file diff --git a/corpus/protected-edit/native-after/synthetic.one b/corpus/protected-edit/native-after/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..0caa86dd598140aa762d8251b80aae0a936c32b4 GIT binary patch literal 26408 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 Result<()> { + let path = self.section_path(path)?.path.clone(); + let bytes = self.storage.read(&path)?; + let edit = onestore::PreparedEdit::page_protected(&bytes, password, space, page, author)?; + self.storage.commit(&path, &edit) + } + /// Opens a section of a mounted notebook by its catalog path. pub fn section(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result
{ let path = self.section_path(path)?.path.clone(); diff --git a/crates/notebook/tests/protected.rs b/crates/notebook/tests/protected.rs index 9fe75e76a21ce67bce41efb8e85eb0d7eeaf9c9d..15d8864e358903324cb14de813d5972c3bbe601e 100644 --- a/crates/notebook/tests/protected.rs +++ b/crates/notebook/tests/protected.rs @@ -27,3 +27,90 @@ fn a_locked_section_unlocks_for_reading() { )) )); } + +/// An unlocked page is edited and saved under the section's key, then reads back with the +/// same password. `ONESTORE_PROTECTED_EXPORT` names a directory receiving the notebook for +/// a cold reopen in OneNote. +#[test] +fn an_unlocked_page_is_saved_under_the_section_key() { + use onestore::page::{PageObject, Paragraph, text::new_id}; + let root = Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../corpus/native-encrypted" + )); + let manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(root.join("manifest.json")).unwrap()).unwrap(); + let password = manifest["password"].as_str().unwrap(); + let temporary = tempfile::tempdir().unwrap(); + let copy = temporary.path().join("notebook"); + std::fs::create_dir(©).unwrap(); + for entry in std::fs::read_dir(root.join("encrypted-01/notebook")).unwrap() { + let entry = entry.unwrap(); + std::fs::copy(entry.path(), copy.join(entry.file_name())).unwrap(); + } + let notebook = Notebook::open(©, temporary.path().join("cache")).unwrap(); + let section = notebook + .catalog() + .sections + .iter() + .find(|section| matches!(section.state, SectionState::Locked)) + .unwrap() + .path + .clone(); + let (space, mut page) = notebook.unlock(§ion, password).unwrap().remove(0); + let paragraphs = page + .objects + .iter_mut() + .find_map(|object| match object { + PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs), + _ => None, + }) + .unwrap(); + let at = paragraphs.iter().position(|p| p.text().is_some()).unwrap(); + let mut added = paragraphs[at].clone(); + added.id = new_id().unwrap(); + added.style = None; + added.lists.clear(); + added.tags.clear(); + let text = added.text_mut().unwrap(); + text.id = new_id().unwrap(); + let format = text.text.format_at(0).unwrap().clone(); + text.text = Paragraph::new( + "Written while protected 🦀".to_owned(), + onestore::document::Format { + font: Some("Calibri".into()), + font_size: Some(11.0), + language: Some(1033), + ..Default::default() + }, + ); + paragraphs.insert(at + 1, added); + paragraphs[at] + .text_mut() + .unwrap() + .text + .append(Paragraph::new( + " and edited under its key".to_owned(), + format, + )) + .unwrap(); + assert!(matches!( + notebook.save_unlocked(§ion, "wrong", space, &page, "Rust"), + Err(notebook::Error::Protected( + onestore::protected::Error::PasswordMismatch + )) + )); + notebook + .save_unlocked(§ion, password, space, &page, "Rust") + .unwrap(); + let (_, stored) = notebook.unlock(§ion, password).unwrap().remove(0); + assert!(stored.objects == page.objects); + if let Some(export) = std::env::var_os("ONESTORE_PROTECTED_EXPORT") { + let export = Path::new(&export); + std::fs::create_dir_all(export).unwrap(); + for entry in std::fs::read_dir(©).unwrap() { + let entry = entry.unwrap(); + std::fs::copy(entry.path(), export.join(entry.file_name())).unwrap(); + } + } +} diff --git a/crates/onestore/README.md b/crates/onestore/README.md index 249ded674288292f417d54906bac30ebd5a23b0c..88c1eee232e4759bebe8df67b31a5785ff3d8106 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -82,7 +82,9 @@ sections retain their encrypted structure and payloads. With the optional `protected` feature, `protected::UnlockedSection` opens native OneNote 2010 AES-128/CBC, SHA-1 password wrappers into a borrowed document view. Incorrect passwords, unsupported protection profiles and work-limit failures remain distinct. -The source stays encrypted; protected writes are rejected. +The source stays encrypted. `PreparedEdit::page_protected` publishes a page-model +edit stored under the section's key (fresh IV per object, payloads under the file +IV); the other writers reject protected sections. `PageCreation::new` appends, or inserts before the first page space of an existing series. `Some("")` creates an empty title field; `None` omits the title node. The page has no body outlines, generated date/time text or applied template. @@ -337,8 +339,8 @@ drop(unlocked); # } ``` -This example requires `features = ["protected"]`. The owner retains no password or -key after opening. Its source-buffer views cannot outlive it; copies of parsed +This example requires `features = ["protected"]`. The owner retains no password; +its derived key is cleared on drop. Its source-buffer views cannot outlive it; copies of parsed strings, serialized models and exports have their own lifetimes. These copies are plaintext, and dropping the unlock owner does not clear them. CBC has no general ciphertext-authentication guarantee; native read-only hashes and model validation diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs index d78b98d3d3bd80940cb23420fb8e3c7cbb0908a2..2a325fcc92d319016dffdb7d17ebe89a362a0468 100644 --- a/crates/onestore/src/commit.rs +++ b/crates/onestore/src/commit.rs @@ -261,6 +261,22 @@ impl<'a> PreparedEdit<'a> { }) } + /// `page` for a password-protected section: the revision is stored under the section's + /// key. The model must come from this snapshot unlocked with `password`. + #[cfg(feature = "protected")] + pub fn page_protected( + source: &'a [u8], + password: &str, + space: ExGuid, + page: &crate::page::Page, + author: &str, + ) -> Result { + Ok(Self { + source, + written: crate::protected::write_page(source, password, space, page, author)?, + }) + } + /// Creates a page and its section entry in one transaction, retaining the intent's identities. pub fn create_page(source: &'a [u8], page: &crate::PageCreation) -> Result { Ok(Self { diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs index 7fa8eecf5328521938fff47b9ff021d274dafff9..9b9a829421cd23890116a4c139a2c2de7d6e3b61 100644 --- a/crates/onestore/src/page/write.rs +++ b/crates/onestore/src/page/write.rs @@ -361,7 +361,7 @@ pub(crate) fn write_page( if lowering.image == source { return Ok(lowering.image); } - squash(source, &lowering.image, &lowering.alias) + squash(source, &lowering.image, &lowering.alias, None) } /// Direct children of every container, in model order, plus lookups by identity. @@ -2909,11 +2909,13 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result, + protection: Option<&dyn crate::write::Protection>, ) -> Result, Error> { let rename: BTreeMap = alias .iter() @@ -2939,17 +2941,24 @@ fn squash( payloads.push((guid, applied_store.file_data(guid)?)); } } - crate::write::write_revisions_with_payloads(source, &payloads, |index| { + let edit = |index: &RevisionIndex<'_>| { let mut changes = BTreeMap::new(); for sid in applied_index.spaces.keys() { let Some(space) = index.spaces.get(sid) else { + // The twin's scaffold spaces are not the section's. + if protection.is_some() { + continue; + } return Err(invalid("Page edits cannot create object spaces")); }; let after_rid = applied_index.active(*sid)?; if space.labels.get(&(ExGuid::default(), 1)) == Some(&after_rid) { continue; } - let before = index.resolve_active(*sid)?; + let before = match protection { + Some(protection) => protection.resolve(*sid, index.active(*sid)?)?, + None => index.resolve_active(*sid)?, + }; let after = applied_index.resolve(*sid, after_rid)?; if before.roots != after.roots { return Err(invalid("Page edits cannot change revision roots")); @@ -3008,7 +3017,11 @@ fn squash( changes.insert(*sid, RevisionEdit::Update(changed)); } Ok(changes) - }) + }; + match protection { + Some(_) => crate::write::append_revisions(source, &payloads, protection, edit), + None => crate::write::write_revisions_with_payloads(source, &payloads, edit), + } } fn remap(object: &mut PropertyObject, rename: &BTreeMap) -> Result<(), Error> { diff --git a/crates/onestore/src/protected/crypto.rs b/crates/onestore/src/protected/crypto.rs index 6018240d23e9249c8c4204d75748152ab8db3a4a..b25e027b7aa2c47e986cca4ff33553705bdef534 100644 --- a/crates/onestore/src/protected/crypto.rs +++ b/crates/onestore/src/protected/crypto.rs @@ -1,5 +1,5 @@ use super::{Error, Result, invalid}; -use aes::cipher::{BlockModeDecrypt, KeyIvInit, block_padding::NoPadding}; +use aes::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyIvInit, block_padding::NoPadding}; use base64::{Engine, engine::general_purpose::STANDARD}; use sha1::{Digest, Sha1}; use subtle::ConstantTimeEq; @@ -81,6 +81,13 @@ fn decrypt(key: &[u8; 16], iv: &[u8; 16], bytes: &mut [u8]) -> Result<()> { Ok(()) } +fn encrypt(key: &[u8; 16], iv: &[u8; 16], bytes: &mut [u8]) { + let length = bytes.len(); + cbc::Encryptor::::new(key.into(), iv.into()) + .encrypt_padded::(bytes, length) + .expect("block aligned"); +} + impl Key { pub(super) fn open(data: &[u8], password: &str, rounds: &mut u64) -> Result { if data.len() > 65536 || password.len() > 65536 { @@ -212,6 +219,42 @@ impl Key { Ok(output) } + /// The stored form of a plaintext property object, the inverse of `property`. + pub(super) fn seal_property(&self, clear: &[u8], iv: [u8; 16]) -> Result> { + let mut c = crate::bytes::Cursor { + bytes: clear, + offset: 0, + }; + crate::properties::reference_streams(&mut c)?; + let prefix = c.offset; + let padding = (16 - (2 + clear.len() - prefix) % 16) % 16; + let mut body = Zeroizing::new((padding as u16).to_le_bytes().to_vec()); + body.extend_from_slice(&clear[prefix..]); + let length = body.len() + padding; + body.resize(length, 0); + getrandom::fill(&mut body[length - padding..]) + .map_err(|_| invalid("System random source failed"))?; + encrypt(&self.value, &iv, &mut body); + let mut output = clear[..prefix].to_vec(); + output.extend_from_slice(&((16 + body.len()) as u32).to_le_bytes()); + output.extend_from_slice(&iv); + output.extend_from_slice(&body); + output.resize(output.len().next_multiple_of(8), 0); + Ok(output) + } + + /// The stored form of a file payload, the inverse of `file`. + pub(super) fn seal_file(&self, clear: &[u8]) -> Vec { + if clear.is_empty() { + return Vec::new(); + } + let mut output = (clear.len() as u64).to_le_bytes().to_vec(); + output.extend_from_slice(clear); + output.resize(output.len().next_multiple_of(16), 0); + encrypt(&self.value, &self.file_iv, &mut output); + output + } + pub(super) fn file(&self, input: &[u8]) -> Result>> { if input.is_empty() { return Ok(Zeroizing::new(Vec::new())); @@ -312,7 +355,17 @@ mod tests { crate::properties::reference_streams(&mut cursor).unwrap(); let length = u32::from_le_bytes(cursor.read().unwrap()) as usize; let end = cursor.offset + length; - assert!(key.property(bytes).is_ok()); + let iv = bytes[cursor.offset..cursor.offset + 16].try_into().unwrap(); + // Native padding is arbitrary; it only reaches the last block. + let sealed = key + .seal_property(&key.property(bytes).unwrap(), iv) + .unwrap(); + assert_eq!(sealed.len(), bytes.len()); + assert_eq!(sealed[..end - 16], bytes[..end - 16]); + assert_eq!( + *key.property(&sealed).unwrap(), + *key.property(bytes).unwrap() + ); assert_eq!( *key.property(bytes).unwrap(), *spaced.property(bytes).unwrap() diff --git a/crates/onestore/src/protected/mod.rs b/crates/onestore/src/protected/mod.rs index ee0a7141683095af3076256e5c1004c51b4a325a..9e8d38cb931f44897dec0ec1e7779fc784c95140 100644 --- a/crates/onestore/src/protected/mod.rs +++ b/crates/onestore/src/protected/mod.rs @@ -75,9 +75,14 @@ impl Default for Limits { } } +struct Decoded<'a> { + stored: &'a [u8], + clear: Zeroizing>, +} + /// Owns decoded buffers until dropped; returned views cannot outlive this owner. /// -/// Passwords and derived keys are not retained. Dropping this owner clears its +/// Passwords are not retained. Dropping this owner clears its derived key and /// decoded buffers. Owned strings or exports made from a `Document` are separate /// caller-owned copies and must be disposed of by the caller when locking. /// Opening never rewrites the source image or changes its protection state. @@ -93,8 +98,10 @@ impl Default for Limits { /// ``` pub struct UnlockedSection<'a> { index: &'a RevisionIndex<'a>, - objects: BTreeMap<(ExGuid, usize), Zeroizing>>, + /// By object space and stored address. + objects: BTreeMap<(ExGuid, usize), Decoded<'a>>, files: BTreeMap<[u8; 16], Zeroizing>>, + keys: BTreeMap<&'a [u8], crypto::Key>, } impl<'a> UnlockedSection<'a> { @@ -111,6 +118,7 @@ impl<'a> UnlockedSection<'a> { index, objects: BTreeMap::new(), files: BTreeMap::new(), + keys: BTreeMap::new(), }; let mut keys = BTreeMap::new(); let mut file_keys = BTreeMap::new(); @@ -141,10 +149,9 @@ impl<'a> UnlockedSection<'a> { if !revision.encrypted { return Err(Error::Unsupported); } - let node = revision - .nodes - .first() - .ok_or_else(|| invalid("Missing encryption key node"))?; + let Some(node) = revision.nodes.first().filter(|node| node.id == 0x7c) else { + continue; + }; let Some(Reference::Data(chunk)) = node.reference else { return Err(invalid("Missing encryption key reference")); }; @@ -194,7 +201,13 @@ impl<'a> UnlockedSection<'a> { )); } } - result.objects.insert(identity, decoded); + result.objects.insert( + identity, + Decoded { + stored: bytes, + clear: decoded, + }, + ); } ObjectData::File { .. } => { if let Some(FileDataReference::Internal(guid)) = @@ -226,7 +239,7 @@ impl<'a> UnlockedSection<'a> { } } } - drop(keys); + result.keys = keys; for (space, info) in &index.spaces { for rid in info.labels.values().copied().collect::>() { result.resolve(*space, rid)?.reachable()?; @@ -251,7 +264,7 @@ impl<'a> UnlockedSection<'a> { offset: 0, message: "Protected object was not decoded", })?; - object.data = ObjectData::Properties(decoded); + object.data = ObjectData::Properties(&decoded.clear); } } Ok(revision) @@ -273,3 +286,157 @@ impl<'a> UnlockedSection<'a> { ) } } + +impl UnlockedSection<'_> { + /// A plaintext section holding every object space's current revision and payloads + /// under their own identities, for writers that read ordinary sections. + fn twin(&self) -> std::result::Result>, crate::Error> { + use crate::write::{PropertyObject, RevisionEdit, append_revisions}; + let copy = |space: ExGuid, revision: ExGuid| { + let revision = self.resolve(space, revision)?; + let mut objects = BTreeMap::new(); + for (id, object) in &revision.objects { + let copy = match object.data { + ObjectData::File { + reference, + extension, + } => { + let text = |bytes: &[u8]| { + String::from_utf16_lossy( + &bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(), + ) + }; + let mut copy = + PropertyObject::file(*id, &text(reference), &text(extension))?; + copy.jcid = object.jcid; + copy.global_ids = std::sync::Arc::clone(&object.global_ids); + copy + } + _ => PropertyObject::from_object(object)?, + }; + objects.insert(*id, copy); + } + Ok::<_, crate::Error>((revision.roots, objects)) + }; + let payloads: Vec<_> = self + .files + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect(); + let current = (ExGuid::default(), 1); + // The scaffold's root space takes the section's identity, then its content. + let mut scaffold = crate::create_section("twin.one", "", "")?; + let identity = |id: ExGuid| { + let mut bytes = Vec::new(); + id.encode(&mut bytes); + bytes + }; + let store = crate::Store::parse(&scaffold)?; + let placeholder = identity(RevisionIndex::parse(&store)?.root); + for at in 0..scaffold.len() - 20 { + if scaffold[at..at + 20] == placeholder { + scaffold[at..at + 20].copy_from_slice(&identity(self.index.root)); + } + } + let mut twin = Zeroizing::new(append_revisions(&scaffold, &payloads, None, |_| { + let mut spaces = BTreeMap::new(); + for id in self.index.spaces.keys() { + let (roots, objects) = copy(*id, self.index.active(*id)?)?; + let edit = if *id == self.index.root { + RevisionEdit::Label { + context: current.0, + role: current.1, + roots, + objects, + } + } else { + RevisionEdit::Create { roots, objects } + }; + spaces.insert(*id, edit); + } + Ok(spaces) + })?); + // One revision per call and space: the remaining labels follow in rounds. + for round in 0.. { + let mut spaces = BTreeMap::new(); + for (id, space) in &self.index.spaces { + let label = space.labels.iter().filter(|(label, _)| **label != current); + if let Some(((context, role), revision)) = label.clone().nth(round) { + let (roots, objects) = copy(*id, *revision)?; + spaces.insert( + *id, + RevisionEdit::Label { + context: *context, + role: *role, + roots, + objects, + }, + ); + } + } + if spaces.is_empty() { + break; + } + twin = Zeroizing::new(append_revisions(&twin, &[], None, |_| Ok(spaces))?); + } + Ok(twin) + } +} + +/// An edited page of a protected section as one stored revision per changed space, the +/// protected counterpart of [`crate::PreparedEdit::page`]. +pub(crate) fn write_page( + source: &[u8], + password: &str, + space: ExGuid, + page: &crate::page::Page, + author: &str, +) -> Result> { + let store = crate::Store::parse(source)?; + let index = RevisionIndex::parse(&store)?; + let unlocked = UnlockedSection::open(&index, password, Limits::default())?; + let twin = unlocked.twin()?; + let applied = Zeroizing::new(crate::page::write::write_page(&twin, space, page, author)?); + let written = crate::page::write::squash(source, &applied, &BTreeMap::new(), Some(&unlocked))?; + let store = crate::Store::parse(&written)?; + UnlockedSection::open(&RevisionIndex::parse(&store)?, password, Limits::default())?; + Ok(written) +} + +impl crate::write::Protection for UnlockedSection<'_> { + fn resolve( + &self, + space: ExGuid, + revision: ExGuid, + ) -> std::result::Result, crate::Error> { + self.resolve(space, revision) + } + + fn stored(&self, clear: &[u8]) -> Option<&[u8]> { + self.objects + .values() + .find(|decoded| std::ptr::eq(decoded.clear.as_ptr(), clear.as_ptr())) + .map(|decoded| decoded.stored) + } + + fn seal_property(&self, clear: &[u8]) -> std::result::Result, crate::Error> { + let [key] = self.keys.values().collect::>()[..] else { + return Err(crate::Error { + offset: 0, + message: "Protected writing needs one section key", + }); + }; + let failed = |message| crate::Error { offset: 0, message }; + let mut iv = [0; 16]; + getrandom::fill(&mut iv).map_err(|_| failed("System random source failed"))?; + key.seal_property(clear, iv) + .map_err(|_| failed("Malformed property object")) + } + + fn seal_file(&self, clear: &[u8]) -> Vec { + self.keys.values().next().unwrap().seal_file(clear) + } +} diff --git a/crates/onestore/src/revisions.rs b/crates/onestore/src/revisions.rs index 3ffbcee3e6aff1e89ceba5436280aa9e8cfc2fe2..7e9b7e49b4767245409e2b1034f85cca441aa302 100644 --- a/crates/onestore/src/revisions.rs +++ b/crates/onestore/src/revisions.rs @@ -286,8 +286,11 @@ impl<'a> RevisionIndex<'a> { }); } } - if (encoding == 2) - != body.first().is_some_and(|node| node.id == 0x7c) + // A protected revision names its key unless it inherits the + // key of the revision it depends on. + let keyed = body.first().is_some_and(|node| node.id == 0x7c); + if keyed && encoding != 2 + || !keyed && encoding == 2 && dependency.is_none() { return Err(Error { offset: node.offset, @@ -304,7 +307,7 @@ impl<'a> RevisionIndex<'a> { message: "Object space mixes encrypted and unencrypted revisions", }); } - if encoding == 2 { + if keyed { let key = &body[0]; let Some(Reference::Data(chunk)) = key.reference else { return Err(Error { diff --git a/crates/onestore/src/write.rs b/crates/onestore/src/write.rs index e9266a1735575b317ae1e04a743f6a30bc30add8..06f8621b819c07207ba254e024cbdc2de916a261 100644 --- a/crates/onestore/src/write.rs +++ b/crates/onestore/src/write.rs @@ -374,6 +374,14 @@ pub(crate) enum RevisionEdit { roots: BTreeMap, objects: BTreeMap, }, + /// A complete revision of an existing space under a context and role, without history. + #[cfg(feature = "protected")] + Label { + context: ExGuid, + role: u32, + roots: BTreeMap, + objects: BTreeMap, + }, } impl PropertyObject { @@ -698,6 +706,16 @@ impl PropertyObject { } } +/// A password-protected section's unlocked view, through which its revisions are +/// read as plaintext and written back in their stored form. +pub(crate) trait Protection { + fn resolve(&self, space: ExGuid, revision: ExGuid) -> Result>; + /// The stored bytes a resolved object's plaintext was decoded from. + fn stored(&self, clear: &[u8]) -> Option<&[u8]>; + fn seal_property(&self, clear: &[u8]) -> Result>; + fn seal_file(&self, clear: &[u8]) -> Vec; +} + pub(crate) fn write_revision( source: &[u8], space: ExGuid, @@ -769,6 +787,22 @@ pub(crate) fn write_revisions_with_payloads( source: &[u8], payloads: &[([u8; 16], &[u8])], edit: impl FnOnce(&RevisionIndex<'_>) -> Result>, +) -> Result> { + let store = Store::parse(source)?; + RevisionIndex::parse(&store)?.validate_current()?; + let output = append_revisions(source, payloads, None, edit)?; + let store = Store::parse(&output)?; + RevisionIndex::parse(&store)?.validate_current()?; + Ok(output) +} + +/// `write_revisions_with_payloads` without validating that current revisions are +/// complete: a protected section is validated by unlocking it, through `protection`. +pub(crate) fn append_revisions( + source: &[u8], + payloads: &[([u8; 16], &[u8])], + protection: Option<&dyn Protection>, + edit: impl FnOnce(&RevisionIndex<'_>) -> Result>, ) -> Result> { let store = Store::parse(source)?; let is_section = store.header.file_type == FileType::Section; @@ -779,7 +813,10 @@ pub(crate) fn write_revisions_with_payloads( }); } let index = RevisionIndex::parse(&store)?; - index.validate_current()?; + let resolve = |space, rid| match protection { + Some(protection) => protection.resolve(space, rid), + None => index.resolve(space, rid), + }; let changes = edit(&index)?; let mut output = source.to_vec(); // Native files reserve 1 KiB per transaction-log fragment; a fragment that ends the file @@ -810,10 +847,12 @@ pub(crate) fn write_revisions_with_payloads( let mut counts = Vec::new(); let mut root_nodes = Vec::new(); for (space, change) in changes { - let (rid, mut revision, mut replacements) = match change { + let new_space = matches!(change, RevisionEdit::Create { .. }); + let current = (ExGuid::default(), 1_u32); + let (rid, label, mut revision, mut replacements) = match change { RevisionEdit::Update(objects) => { let rid = index.active(space)?; - (Some(rid), index.resolve(space, rid)?, objects) + (Some(rid), current, resolve(space, rid)?, objects) } RevisionEdit::Create { roots, objects } => { if !is_section || space.guid == [0; 16] || index.spaces.contains_key(&space) { @@ -830,6 +869,30 @@ pub(crate) fn write_revisions_with_payloads( } ( None, + current, + crate::ResolvedRevision { + roots, + objects: BTreeMap::new(), + }, + objects, + ) + } + #[cfg(feature = "protected")] + RevisionEdit::Label { + context, + role, + roots, + objects, + } => { + if !is_section || role > 0xffff || !index.spaces.contains_key(&space) { + return Err(Error { + offset: 0, + message: "Choose a label in a section's object space", + }); + } + ( + None, + (context, role), crate::ResolvedRevision { roots, objects: BTreeMap::new(), @@ -1062,16 +1125,45 @@ pub(crate) fn write_revisions_with_payloads( if !is_section { start.extend_from_slice(&0_u64.to_le_bytes()); } - start.extend_from_slice(&1_u32.to_le_bytes()); - start.extend_from_slice(&0_u16.to_le_bytes()); + start.extend_from_slice(&label.1.to_le_bytes()); + start.extend_from_slice(&(if protection.is_some() { 2_u16 } else { 0 }).to_le_bytes()); + let contextual = label.0 != ExGuid::default(); + if contextual { + label.0.encode(&mut start); + } let mut manifest = Vec::new(); - if rid.is_none() { + if new_space { let mut payload = Vec::new(); space.encode(&mut payload); payload.extend_from_slice(&0_u32.to_le_bytes()); manifest.push(node(0x14, None, &payload)?); } - manifest.push(node(if is_section { 0x1e } else { 0x1b }, None, &start)?); + manifest.push(node( + match (is_section, contextual) { + (true, true) => 0x1f, + (true, false) => 0x1e, + (false, _) => 0x1b, + }, + None, + &start, + )?); + // A dependent revision inherits its key, as OneNote writes it. + if protection.is_some() && checkpoint { + let key = index + .spaces + .get(&space) + .and_then(|space| { + space + .revisions + .values() + .find_map(|revision| revision.nodes.first().filter(|node| node.id == 0x7c)) + }) + .ok_or(Error { + offset: 0, + message: "Protected revisions continue a protected object space", + })?; + manifest.push(node(0x7c, key.reference, key.payload)?); + } for (table, objects) in groups { let mut payload = Vec::new(); let mut group = if is_section { @@ -1139,14 +1231,26 @@ pub(crate) fn write_revisions_with_payloads( } append(&mut output, &mapped)? } else if replacements.contains_key(&id) { - append(&mut output, bytes)? + match protection { + Some(protection) => { + append(&mut output, &protection.seal_property(bytes)?)? + } + None => append(&mut output, bytes)?, + } } else { + let stored = match protection { + Some(protection) => protection.stored(bytes).ok_or(Error { + offset: 0, + message: "Protected object has no stored form", + })?, + None => bytes, + }; Chunk { offset: u64::try_from( - bytes.as_ptr().addr() - source.as_ptr().addr(), + stored.as_ptr().addr() - source.as_ptr().addr(), ) .unwrap(), - length: u64::try_from(bytes.len()).unwrap(), + length: u64::try_from(stored.len()).unwrap(), } }; // A table-of-contents object is declared when the revision is a @@ -1164,7 +1268,13 @@ pub(crate) fn write_revisions_with_payloads( declaration.extend_from_slice(&object.reference_count.to_le_bytes()); let readonly = object.jcid & 0x100000 != 0; if readonly { - declaration.extend_from_slice(&md5::compute(bytes).0); + // A protected declaration hashes the plaintext as aligned. + let mut hash = md5::Context::new(); + hash.consume(bytes); + if protection.is_some() { + hash.consume(&[0; 7][..(8 - bytes.len() % 8) % 8]); + } + declaration.extend_from_slice(&hash.finalize().0); } group.push(node( if is_section { @@ -1219,7 +1329,7 @@ pub(crate) fn write_revisions_with_payloads( } } manifest.push(node(0x1c, None, &[])?); - if rid.is_none() { + if new_space { let list_id = allocate_list()?; let chunk = append_list(&mut output, list_id, &manifest)?; counts.push((list_id, manifest.len())); @@ -1269,6 +1379,8 @@ pub(crate) fn write_revisions_with_payloads( 0xe7, 0x16, 0xe3, 0xbd, 0x65, 0x26, 0x11, 0x45, 0xa4, 0xc4, 0x8d, 0x4d, 0x0b, 0x7a, 0x9e, 0xac, ]; + let sealed = protection.map(|protection| protection.seal_file(payload)); + let payload = sealed.as_deref().unwrap_or(*payload); blob.extend_from_slice(&(payload.len() as u64).to_le_bytes()); blob.extend_from_slice(&[0; 12]); blob.extend_from_slice(payload); @@ -1410,8 +1522,6 @@ pub(crate) fn write_revisions_with_payloads( message: "File generation counter is exhausted", })?; output[228..236].copy_from_slice(&generation.to_le_bytes()); - let written = Store::parse(&output)?; - let written_index = RevisionIndex::parse(&written)?; - written_index.validate_current()?; + RevisionIndex::parse(&Store::parse(&output)?)?; Ok(output) } diff --git a/crates/onestore/tests/protected.rs b/crates/onestore/tests/protected.rs index 9edd8e4ab4f3ce0e1a69238740f157b04e7d324b..5f676566a198e5ea31a33d50a6c003d331c5ba09 100644 --- a/crates/onestore/tests/protected.rs +++ b/crates/onestore/tests/protected.rs @@ -108,3 +108,132 @@ fn limits_and_password_bytes_are_explicit() { Err(Error::Unsupported) )); } + +/// The first text paragraph of every page gains text; the written file stays protected, +/// opens with the same password and reads back as the edited model. +#[test] +fn a_protected_page_edit_is_stored_under_the_section_key() { + use onestore::page::{Page, PageObject, Paragraph}; + for (root, notebook) in [ + ("native-encrypted", "encrypted-01/notebook/synthetic.one"), + ("native-protected-boundaries", "notebook/synthetic.one"), + ] { + let root = Path::new("../../corpus").join(root); + let manifest: serde_json::Value = + serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap(); + let password = manifest["password"].as_str().unwrap(); + let mut bytes = fs::read(root.join(notebook)).unwrap(); + let pages = |bytes: &[u8]| -> Vec<(onestore::ExGuid, Page)> { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + assert!(Document::parse(&index).unwrap().pages().unwrap().is_empty()); + let unlocked = UnlockedSection::open(&index, password, Limits::default()).unwrap(); + let document = unlocked.document().unwrap(); + document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| (space, Page::from_space(&document, space).unwrap())) + .collect() + }; + let mut expected = pages(&bytes); + let mut edited = 0; + for (space, page) in &mut expected { + let paragraphs = page.objects.iter_mut().find_map(|object| match object { + PageObject::Outline(outline) + if outline.paragraphs.iter().any(|p| p.text().is_some()) => + { + Some(&mut outline.paragraphs) + } + _ => None, + }); + let Some(paragraphs) = paragraphs else { + continue; + }; + edited += 1; + let at = paragraphs.iter().position(|p| p.text().is_some()).unwrap(); + let mut file = paragraphs[at].clone(); + file.id = onestore::page::text::new_id().unwrap(); + file.lists.clear(); + file.tags.clear(); + file.style = None; + file.format = Default::default(); + file.content = + onestore::page::ParagraphContent::Attachment(onestore::page::Attachment { + id: onestore::page::text::new_id().unwrap(), + filename: "sealed.txt".into(), + source_path: None, + size: Some([24.0, 24.0]), + bytes: Some(std::sync::Arc::from(&b"A payload that stays sealed"[..])), + preview: None, + recording: None, + }); + paragraphs.insert(at + 1, file); + let text = paragraphs[at].text_mut().unwrap(); + let format = text.text.format_at(0).unwrap().clone(); + text.text + .append(Paragraph::new(" still protected".to_owned(), format)) + .unwrap(); + let edit = + onestore::PreparedEdit::page_protected(&bytes, password, *space, page, "Rust") + .unwrap(); + assert!(matches!( + onestore::PreparedEdit::page_protected(&bytes, "wrong", *space, page, "Rust"), + Err(Error::PasswordMismatch) + )); + let written = edit.as_bytes().to_vec(); + for clear in [&b" still protected"[..], b"stays sealed"] { + assert!(!written.windows(clear.len()).any(|w| w == clear)); + } + let revisions = |bytes: &[u8]| { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index + .spaces + .iter() + .map(|(id, space)| (*id, space.revisions.len())) + .collect::>() + }; + let grown: Vec<_> = revisions(&bytes) + .into_iter() + .zip(revisions(&written)) + .filter(|(before, after)| before != after) + .map(|(before, _)| before.0) + .collect(); + assert_eq!(grown, [*space]); + bytes = written; + } + assert!(edited > 0); + let stored = pages(&bytes); + assert_eq!(stored.len(), expected.len()); + for ((_, stored), (_, expected)) in stored.iter().zip(&expected) { + assert_eq!(stored.objects, expected.objects); + } + } +} + +/// OneNote's revisions that depend on an earlier one carry no key node of their own. +#[test] +fn a_native_revision_inherits_the_key_of_its_dependency() { + let bytes = fs::read("../../corpus/protected-edit/native-after/synthetic.one").unwrap(); + let manifest: serde_json::Value = + serde_json::from_slice(&fs::read("../../corpus/native-encrypted/manifest.json").unwrap()) + .unwrap(); + let store = Store::parse(&bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + assert!(index.spaces.values().any(|space| { + space.revisions.values().any(|revision| { + revision.encrypted && revision.nodes.first().is_none_or(|node| node.id != 0x7c) + }) + })); + let unlocked = UnlockedSection::open( + &index, + manifest["password"].as_str().unwrap(), + Limits::default(), + ) + .unwrap(); + let document = unlocked.document().unwrap(); + let (space, _) = document.pages().unwrap()[0]; + let page = onestore::page::Page::from_space(&document, space).unwrap(); + assert!(format!("{:?}", page.objects).contains("Native edit after Rust.")); +} diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 643170fddd8efa442a2289a43102a41b808724a8..e1057c999b35a12c9fcb5c69eb8e0ba8bd108ed0 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -136,3 +136,10 @@ path = "fuzz_targets/page_model.rs" test = false doc = false bench = false + +[[bin]] +name = "protected_write" +path = "fuzz_targets/protected_write.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/protected_write.rs b/fuzz/fuzz_targets/protected_write.rs new file mode 100644 index 0000000000000000000000000000000000000000..d53ab85164e265629325de058942dbee7f27555b --- /dev/null +++ b/fuzz/fuzz_targets/protected_write.rs @@ -0,0 +1,69 @@ +#![no_main] +//! Chains page edits on a protected section: every written image unlocks with the same +//! password, reads back as the edited model and stores none of the new text in clear. +use libfuzzer_sys::fuzz_target; +use onestore::{ + ExGuid, PreparedEdit, RevisionIndex, Store, + page::{Page, PageObject, Paragraph, text::Edit}, + protected::{Limits, UnlockedSection}, +}; + +const SOURCE: &[u8] = + include_bytes!("../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one"); +const PASSWORD: &str = "fictitious-only"; + +fn page(bytes: &[u8]) -> (ExGuid, Page) { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let unlocked = UnlockedSection::open(&index, PASSWORD, Limits::default()).unwrap(); + let document = unlocked.document().unwrap(); + let (space, _) = document.pages().unwrap()[0]; + (space, Page::from_space(&document, space).unwrap()) +} + +fuzz_target!(|data: &[u8]| { + let mut bytes = SOURCE.to_vec(); + for step in data.chunks(12).take(4) { + if step.len() < 4 { + return; + } + let (space, mut after) = page(&bytes); + let mut texts: Vec<_> = after + .objects + .iter_mut() + .filter_map(|object| match object { + PageObject::Outline(outline) => Some(&mut outline.paragraphs), + _ => None, + }) + .flatten() + .filter_map(|paragraph| paragraph.text_mut()) + .collect(); + let count = texts.len(); + let text = &mut texts[usize::from(step[0]) % count].text; + let end = text.utf16_offset(text.text().len()).unwrap(); + let start = u32::from(step[1]) % (end + 1); + let stop = (start + u32::from(step[2]) % 8).min(end); + // Marked so its absence from the stored bytes is checkable. + let inserted = format!("\u{1f512}sealed\u{1f512}{}", String::from_utf8_lossy(&step[3..]).replace('\0', "")); + let format = text.format_at(start.min(end.saturating_sub(1))).unwrap().clone(); + let edit = Edit { + range: start..stop, + replacement: Paragraph::new(inserted.clone(), format), + }; + let (Ok(_), Ok(_)) = (text.byte_offset(start), text.byte_offset(stop)) else { + return; + }; + if text.apply(edit).is_err() { + return; + } + let Ok(edit) = PreparedEdit::page_protected(&bytes, PASSWORD, space, &after, "Fuzz") else { + return; + }; + let written = edit.as_bytes().to_vec(); + let clear: Vec = inserted.encode_utf16().flat_map(u16::to_le_bytes).collect(); + assert!(!written[bytes.len()..].windows(clear.len()).any(|w| w == clear)); + let (_, stored) = page(&written); + assert!(stored.objects == after.objects); + bytes = written; + } +}); diff --git a/tools/test_protected_edit.py b/tools/test_protected_edit.py new file mode 100644 index 0000000000000000000000000000000000000000..afa2994f982c85a4753c90df62e63bb9f1d78107 --- /dev/null +++ b/tools/test_protected_edit.py @@ -0,0 +1,45 @@ +import json +from pathlib import Path +import runpy +import shutil +import subprocess +from tempfile import TemporaryDirectory +import unittest + +from document_model import EXPORTER + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / 'corpus/protected-edit' +compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] + + +class ProtectedEditTest(unittest.TestCase): + def setUp(self): + self.temporary = TemporaryDirectory() + self.root = Path(self.temporary.name) + self.password = self.root / 'password' + manifest = json.loads((ROOT / 'corpus/native-encrypted/manifest.json').read_text()) + self.password.write_text(manifest['password']) + + def tearDown(self): + self.temporary.cleanup() + + def test_onenote_unlocks_and_reads_the_page_saved_under_the_section_key(self): + native = self.root / 'read' + shutil.copytree(FIXTURE / 'candidate/read', native) + compare(FIXTURE / 'candidate/notebook', native, password_file=self.password) + page = (native / 'page-000.xml').read_text(encoding='utf-8-sig') + self.assertIn('and edited under its key', page) + self.assertIn('Written while protected', page) + + def test_the_native_edit_that_followed_unlocks_with_both_edits(self): + output = self.root / 'export' + subprocess.run([EXPORTER, FIXTURE / 'native-after/synthetic.one', output, + '--password-file', self.password], check=True, capture_output=True) + text = (output / 'text.json').read_text() + for expected in ['Native edit after Rust.', 'Written while protected', 'and edited under its key']: + self.assertIn(expected, text) + + +if __name__ == '__main__': + unittest.main() -- 2.54.0