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
Binary files /dev/null and b/corpus/protected-edit/candidate/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/protected-edit/candidate/notebook/synthetic.one b/corpus/protected-edit/candidate/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..dc901c224a10ac21d40ce5ac3143483201c4d5f8
Binary files /dev/null and b/corpus/protected-edit/candidate/notebook/synthetic.one differ
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
Binary files /dev/null and b/corpus/protected-edit/native-after/synthetic.one differ
diff --git a/crates/notebook/README.md b/crates/notebook/README.md
index 7e97726caf7588ffed76fc134b87a6915de9d8ad..0371f3ecdb671ae0ad48befe308b0b7d50ac5882 100644
--- a/crates/notebook/README.md
+++ b/crates/notebook/README.md
@@ -347,8 +347,11 @@ when the page itself was moved to another section. Other URLs and unknown
targets are `None`.
With the optional `protected` feature, `Notebook::unlock(path, password)`
-reads the pages of a `Locked` section for display; nothing is cached or
-written, and a wrong password is `Error::Protected(PasswordMismatch)`.
+reads the pages of a `Locked` section, and `Notebook::save_unlocked(path,
+password, space, page, author)` saves an edited one under the section's key,
+straight to the file: nothing of a protected section is cached or queued, a
+section changed since the read fails the save, and a wrong password is
+`Error::Protected(PasswordMismatch)`.
`Section::import_page(page, author)` copies a page, usually read from another
section, to the end of this one as a page creation and a save queued like the
diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs
index f926999b243f7e07192aebc2e3ed78d84cc9c8a5..4f36018af75e14d1ffe398d096b92b463ecb2dd0 100644
--- a/crates/notebook/src/session.rs
+++ b/crates/notebook/src/session.rs
@@ -643,6 +643,24 @@ impl Notebook {
.collect()
}
+ /// Saves a page read through `unlock`, stored under the section's key. The save goes
+ /// straight to the file and fails if the section changed since `unlock` read it;
+ /// nothing of a protected section is cached or queued.
+ #[cfg(feature = "protected")]
+ pub fn save_unlocked(
+ &self,
+ path: &str,
+ password: &str,
+ space: ExGuid,
+ page: &Page,
+ author: &str,
+ ) -> 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()