authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-07 18:42:27-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-07 18:42:27-07:00
log30d02b72f6a5b8dd8b72cea593bdc9c883afea1e
treec22ca0a9fd436a9f93add677af1fcc6103aaa068
parentbca7120d8a96e56b4ae7c6c76d1e82ccef0c5d21
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: resolve external notebook payloads with bounded SMB reads

Read documented onefiles payloads through the shared notebook service. Keep empty files distinct from missing data, reject partial reads and failed close, and prevent stale diagnostic exports when only an external payload changes. Move the document exporter beside its directory resolver. Native OneNote independently reopened generated external references and extracted exact empty, 1 KiB and 20 MiB attachments. Validated local/SMB parity, ten payload interruption cases, all 362 publication-loss cases with independent server hashes, workspace/report tests, Clippy and Apple target linking. Assisted-by: gpt-6-astra

31 files changed, 749 insertions(+), 92 deletions(-)

corpus/native-external-assets/README.md created+16
...@@ -0,0 +1,16 @@
1# External payload fixture
2
3OneNote 2010 authored an empty attachment and a 1 KiB attachment with
4`tools/native/external-assets.ps1`. The retained native section is in `native/`.
5The fixture generator converts all file-data declarations into the documented
6external representation while retaining their identities and exact payloads.
7The `read/` capture is an independent fresh-cache OneNote reopen; `payloads.json`
8contains the native attachment hashes. `provenance.json` records source hashes.
9
10```sh
11cargo run -p onestore-notebook --example externalize_fixture -- corpus/native-external-assets/native/synthetic.one /tmp/external-notebook
12python3 tools/native_runner.py /tmp/external-notebook /tmp/external-native --expected-pages 3 --collect-notebook
13```
14
15The generator creates a new destination and requires reserved fragment space.
16It is a fixture converter, not a concurrent publication API.
corpus/native-external-assets/native/synthetic.one created
Binary files /dev/null and b/corpus/native-external-assets/native/synthetic.one differ
corpus/native-external-assets/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/native-external-assets/notebook/Open Notebook.onetoc2 differ
corpus/native-external-assets/notebook/synthetic.one created
Binary files /dev/null and b/corpus/native-external-assets/notebook/synthetic.one differ
corpus/native-external-assets/notebook/synthetic_onefiles/04DC5FD1-10FD-4404-A37C-D035399F9D9E.onebin created
Binary files /dev/null and b/corpus/native-external-assets/notebook/synthetic_onefiles/04DC5FD1-10FD-4404-A37C-D035399F9D9E.onebin differ
corpus/native-external-assets/notebook/synthetic_onefiles/15575137-1653-47BC-9C05-78420499168B.onebin created
Binary files /dev/null and b/corpus/native-external-assets/notebook/synthetic_onefiles/15575137-1653-47BC-9C05-78420499168B.onebin differ
corpus/native-external-assets/notebook/synthetic_onefiles/C5EABC1E-5091-4685-BAC1-2C785D6046DB.onebin created
corpus/native-external-assets/provenance.json created+23
...@@ -0,0 +1,23 @@
1{
2 "native_author": "tools/native/external-assets.ps1",
3 "generator": "crates/onestore-notebook/examples/externalize_fixture.rs",
4 "native_capture": "evidence/m10/external-assets-small-native",
5 "cold_capture": "evidence/m10/external-assets-small-cold",
6 "source_sha256": {
7 "native/synthetic.one": "3a4dcb0b6f9e46044a3cdc663ae505878911c2a7255a7787c55ae2c71a7f4fd6",
8 "read/page-002.xml": "4104b7f1b2a8bcc98c5ce73c335d365ae67d565a72f9da2ba78304dd7226944f",
9 "read/page-001.xml": "8c1a503344f3d6893a3df1a3ff5d51d591c0a61e04101c360aa1546d386f5a15",
10 "read/page-000.xml": "6796df2c0594a3263416dfbd1210ec412e0694d30115869f3ceab768c95a3e32",
11 "read/hierarchy.xml": "4a1207f82678cd2020ee175979074601bd9f85c102bd68e5df34c54e37616574",
12 "read/payloads.json": "0b31ae5e576ba7ee5eab7c9a62d8f673659a76865ad07f9e75f45458c06a7e65",
13 "read/environment.json": "1cd7ab391d85fe504344cd63bd786f969770ce09aef0008623eaf0abb880e655",
14 "notebook/Open Notebook.onetoc2": "8fec3cc6f72a9e0fff45ce604f62c184b0bb63d077018f90086b31396c8500b0",
15 "notebook/synthetic.one": "7343061e2a44c67b5f306c747e707fc1b99d126c51db510cc010885c2b016ca2",
16 "notebook/synthetic_onefiles/15575137-1653-47BC-9C05-78420499168B.onebin": "38b0469c7f0cddba0fa7739b44061117b2621bfe477348150cfc98396f41daf1",
17 "notebook/synthetic_onefiles/C5EABC1E-5091-4685-BAC1-2C785D6046DB.onebin": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
18 "notebook/synthetic_onefiles/04DC5FD1-10FD-4404-A37C-D035399F9D9E.onebin": "2bce1ba628720664be4b9fdd77aae0678e5f0f3f02fc6ff641ec879094f6a404"
19 },
20 "cold_teardown": {
21 "absent": true
22 }
23}
\ No newline at end of file
corpus/native-external-assets/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-3BB259AD",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/native-external-assets/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{0EC3B20E-242C-4796-9C75-FA9263E4B53C}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-08T01:58:45.000Z" color="#B49EDE"><one:Section name="synthetic" ID="{6936E4B1-EE51-08FC-24EA-C42E76D8CE85}{1}{B0}" path="C:\one-tests\runs\capture\notebook\synthetic.one" lastModifiedTime="2026-09-08T01:58:45.000Z" color="#8AA8E4"><one:Page ID="{0895AAA7-E512-03D9-0023-94EDB3ECC902}{1}{B0}" name="Native before 🦀" dateTime="2026-09-07T23:43:30.000Z" lastModifiedTime="2026-09-08T01:58:45.000Z" pageLevel="1"/><one:Page ID="{2001177A-A50B-470C-A3B4-C397629432DC}{1}{B0}" name="Attachment 0" dateTime="2026-09-08T01:55:22.000Z" lastModifiedTime="2026-09-08T01:58:45.000Z" pageLevel="1"/><one:Page ID="{5B886F89-E2CC-4184-AF00-42A8CB7EEDBF}{1}{B0}" name="Attachment 1024" dateTime="2026-09-08T01:55:23.000Z" lastModifiedTime="2026-09-08T01:58:45.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/native-external-assets/read/page-000.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{0895AAA7-E512-03D9-0023-94EDB3ECC902}{1}{B0}" name="Native before 🦀" dateTime="2026-09-07T23:43:30.000Z" lastModifiedTime="2026-09-08T01:58:45.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Fixture" lastModifiedBy="Fixture" lastModifiedTime="2026-09-07T23:43:30.000Z" objectID="{6936E4B1-EE51-08FC-24EA-C42E76D8CE85}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="136.0371551513672" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-07T23:43:30.000Z" lastModifiedTime="2026-09-07T23:43:30.000Z" objectID="{6936E4B1-EE51-08FC-24EA-C42E76D8CE85}{24}{B0}" alignment="left"><one:T><![CDATA[Native before &#129408;]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/native-external-assets/read/page-001.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{2001177A-A50B-470C-A3B4-C397629432DC}{1}{B0}" name="Attachment 0" dateTime="2026-09-08T01:55:22.000Z" lastModifiedTime="2026-09-08T01:58:45.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-08T01:55:23.000Z" lastModifiedTime="2026-09-08T01:55:23.000Z" objectID="{068F783A-8A63-4688-A2AC-32EB81A6EA31}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Attachment 0]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T01:55:23.000Z" objectID="{068F783A-8A63-4688-A2AC-32EB81A6EA31}{39}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="72.0" height="63.0"/><one:OEChildren><one:OE creationTime="2026-09-08T01:55:23.000Z" lastModifiedTime="2026-09-08T01:55:23.000Z" objectID="{068F783A-8A63-4688-A2AC-32EB81A6EA31}{40}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\c5eabc1e-5091-4685-bac1-2c785d6046db.bin" pathSource="C:\one-tests\runs\capture\inputs\0.bin" preferredName="0.bin"/></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/native-external-assets/read/page-002.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{5B886F89-E2CC-4184-AF00-42A8CB7EEDBF}{1}{B0}" name="Attachment 1024" dateTime="2026-09-08T01:55:23.000Z" lastModifiedTime="2026-09-08T01:58:45.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-08T01:55:23.000Z" lastModifiedTime="2026-09-08T01:55:23.000Z" objectID="{F876120D-4126-473B-9D5F-F961FAF66D65}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Attachment 1024]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-08T01:55:23.000Z" objectID="{F876120D-4126-473B-9D5F-F961FAF66D65}{39}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="72.0" height="63.0"/><one:OEChildren><one:OE creationTime="2026-09-08T01:55:23.000Z" lastModifiedTime="2026-09-08T01:55:23.000Z" objectID="{F876120D-4126-473B-9D5F-F961FAF66D65}{40}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\04dc5fd1-10fd-4404-a37c-d035399f9d9e.bin" pathSource="C:\one-tests\runs\capture\inputs\1024.bin" preferredName="1024.bin"/></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/native-external-assets/read/payloads.json created+18
...@@ -0,0 +1,18 @@
1[
2 {
3 "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
4 "name": "0.bin",
5 "object": "{068F783A-8A63-4688-A2AC-32EB81A6EA31}{40}{B0}",
6 "kind": "InsertedFile",
7 "page": "{2001177A-A50B-470C-A3B4-C397629432DC}{1}{B0}",
8 "bytes": 0
9 },
10 {
11 "sha256": "2bce1ba628720664be4b9fdd77aae0678e5f0f3f02fc6ff641ec879094f6a404",
12 "name": "1024.bin",
13 "object": "{F876120D-4126-473B-9D5F-F961FAF66D65}{40}{B0}",
14 "kind": "InsertedFile",
15 "page": "{5B886F89-E2CC-4184-AF00-42A8CB7EEDBF}{1}{B0}",
16 "bytes": 1024
17 }
18]
\ No newline at end of file
crates/onestore-notebook/README.md+7
...@@ -7,6 +7,7 @@ Discovery returns ordered sections and nested groups with file identities and...@@ -7,6 +7,7 @@ Discovery returns ordered sections and nested groups with file identities and
7share-relative paths. Section display-name overrides remain distinct from file7share-relative paths. Section display-name overrides remain distinct from file
8names. TOC references whose identities are absent from the directory remain8names. TOC references whose identities are absent from the directory remain
9inspectable; a cached filename never substitutes for an identity match.9inspectable; a cached filename never substitutes for an identity match.
10Reserved `_onefiles` directories are excluded from section-group traversal.
10Encrypted sections and valid storage with an unreadable document graph retain11Encrypted sections and valid storage with an unreadable document graph retain
11their identity as `Locked` or `Unreadable`, without being presented as empty pages.12their identity as `Locked` or `Unreadable`, without being presented as empty pages.
12Malformed storage, failed reads and ambiguous identities reject the discovery.13Malformed storage, failed reads and ambiguous identities reject the discovery.
...@@ -16,3 +17,9 @@ observation across multiple files, not an atomic notebook transaction or...@@ -16,3 +17,9 @@ observation across multiple files, not an atomic notebook transaction or
16authorization to publish an edit. Refresh rejects observed topology changes and17authorization to publish an edit. Refresh rejects observed topology changes and
17duplicate physical files with the same logical identity. Retain the last accepted18duplicate physical files with the same logical identity. Retain the last accepted
18catalog if discovery fails; a connection failure does not mean files were deleted.19catalog if discovery fails; a connection failure does not mean files were deleted.
20
21`read_external_asset` resolves a validated UUID `.onebin` filename beneath the
22selected section's sibling `_onefiles` folder and returns exact bounded bytes.
23Missing files, permissions and size failures retain their I/O error kinds; an
24empty payload is a successful empty buffer. Embedded payloads remain available
25directly from the core document model.
crates/onestore-notebook/examples/document.rs created+115
...@@ -0,0 +1,115 @@
1use onestore::{
2 FileDataReference, RevisionIndex, Store,
3 document::{Document, Kind},
4};
5use std::{
6 borrow::Cow,
7 collections::BTreeSet,
8 env, fs,
9 io::{self, BufWriter, Write},
10 path::{Path, PathBuf},
11};
12
13fn write_json(
14 path: impl AsRef<Path>,
15 value: &impl serde::Serialize,
16) -> Result<(), Box<dyn std::error::Error>> {
17 let mut output = BufWriter::new(fs::File::create(path)?);
18 serde_json::to_writer(&mut output, value)?;
19 output.flush()?;
20 Ok(())
21}
22
23fn main() -> Result<(), Box<dyn std::error::Error>> {
24 let mut args = env::args_os().skip(1);
25 let path = args.next().ok_or("Provide a .one or .onetoc2 file.")?;
26 let destination = args.next().map(PathBuf::from);
27 if args.next().is_some() {
28 return Err("Provide a source file and an optional new export directory.".into());
29 }
30 let source_path = PathBuf::from(path);
31 let bytes = onestore::read_file(&source_path)?;
32 let store = Store::parse(&bytes)?;
33 let index = RevisionIndex::parse(&store)?;
34 let document = Document::parse(&index)?;
35 if let Some(destination) = destination {
36 fs::create_dir(&destination)?;
37 fs::create_dir(destination.join("assets"))?;
38 let parent = source_path
39 .parent()
40 .filter(|parent| !parent.as_os_str().is_empty())
41 .unwrap_or_else(|| Path::new("."));
42 let mut source = onestore_notebook::Local::open(parent)?;
43 let section = source_path
44 .file_name()
45 .and_then(|name| name.to_str())
46 .ok_or("Use a UTF-8 section filename")?;
47 let mut assets = Vec::new();
48 let mut seen = BTreeSet::new();
49 for node in document
50 .spaces
51 .values()
52 .flat_map(|s| s.revisions.values())
53 .flat_map(|r| r.nodes.values())
54 {
55 if let Kind::File {
56 reference, payload, ..
57 } = &node.kind
58 && seen.insert(serde_json::to_string(reference)?)
59 {
60 let data = match reference {
61 FileDataReference::Internal(_) => {
62 Cow::Borrowed(payload.ok_or("Missing embedded file data")?)
63 }
64 FileDataReference::External(filename) => {
65 match onestore_notebook::read_external_asset(
66 &mut source,
67 section,
68 filename,
69 256 * 1024 * 1024,
70 ) {
71 Ok(bytes) => Cow::Owned(bytes),
72 Err(error) => {
73 let kind = match &error {
74 onestore_notebook::Error::Io { error, .. } => {
75 format!("{:?}", error.kind())
76 }
77 _ => "InvalidData".into(),
78 };
79 assets.push(serde_json::json!({"reference":reference,"path":null,"error":{"kind":kind,"message":error.to_string()}}));
80 continue;
81 }
82 }
83 }
84 FileDataReference::Invalid => {
85 assets.push(serde_json::json!({"reference":reference,"path":null,"error":{"kind":"InvalidData","message":"The document marks this payload as unavailable"}}));
86 continue;
87 }
88 };
89 let path = format!("assets/{}.bin", assets.len());
90 fs::write(destination.join(&path), data)?;
91 assets.push(serde_json::json!({"reference":reference,"path":path}));
92 }
93 }
94 write_json(destination.join("assets.json"), &assets)?;
95 let mut text = std::collections::BTreeMap::new();
96 for (sid, space) in &document.spaces {
97 let mut revisions = std::collections::BTreeMap::new();
98 for (rid, revision) in &space.revisions {
99 let mut objects = std::collections::BTreeMap::new();
100 for (oid, node) in &revision.nodes {
101 if matches!(node.kind, Kind::RichText { .. }) {
102 objects.insert(*oid, revision.text_runs(*oid)?);
103 }
104 }
105 revisions.insert(*rid, objects);
106 }
107 text.insert(*sid, revisions);
108 }
109 write_json(destination.join("text.json"), &text)?;
110 write_json(destination.join("document.json"), &document)?;
111 } else {
112 serde_json::to_writer(io::stdout().lock(), &document)?;
113 }
114 Ok(())
115}
crates/onestore-notebook/examples/externalize_fixture.rs created+101
...@@ -0,0 +1,101 @@
1//! Builds a new external-payload test fixture from a native capture with reserved fragment space.
2use onestore::{FileDataReference, RevisionIndex, Store};
3use std::{fs, path::PathBuf};
4fn main() -> Result<(), Box<dyn std::error::Error>> {
5 let args: Vec<_> = std::env::args_os().skip(1).collect();
6 if args.len() != 2 {
7 return Err("Provide a native source section and a new fixture directory".into());
8 }
9 let source = fs::read(&args[0])?;
10 let destination = PathBuf::from(&args[1]);
11 fs::create_dir(&destination)?;
12 fs::create_dir(destination.join("synthetic_onefiles"))?;
13 let store = Store::parse(&source)?;
14 assert!(store.checksum_mismatches.is_empty());
15 RevisionIndex::parse(&store)?.validate_current()?;
16 let mut output = source.clone();
17 let mut changed = 0;
18 for list in store.lists.values() {
19 for chunk in &list.fragments {
20 let start = chunk.offset as usize;
21 let end = start + chunk.length as usize - 20;
22 let mut body = Vec::new();
23 let mut touched = false;
24 for node in list
25 .nodes
26 .iter()
27 .filter(|node| node.offset >= start + 16 && node.offset < end)
28 {
29 let raw = u32::from_le_bytes(source[node.offset..node.offset + 4].try_into()?);
30 let size = ((raw >> 10) & 0x1fff) as usize;
31 let mut encoded = source[node.offset..node.offset + size].to_vec();
32 if matches!(node.id, 0x72 | 0x73) {
33 let offset = 4 + 4 + 4 + if node.id == 0x72 { 1 } else { 4 };
34 let count =
35 u32::from_le_bytes(encoded[offset..offset + 4].try_into()?) as usize;
36 let text = String::from_utf16(
37 &encoded[offset + 4..offset + 4 + count * 2]
38 .chunks_exact(2)
39 .map(|v| u16::from_le_bytes(v.try_into().unwrap()))
40 .collect::<Vec<_>>(),
41 )?;
42 if let FileDataReference::Internal(guid) = text.parse()? {
43 let filename = format!(
44 "{}.onebin",
45 text.strip_prefix("<ifndf>{")
46 .unwrap()
47 .strip_suffix('}')
48 .unwrap()
49 );
50 let payload = store.file_data(guid)?;
51 let path = destination.join("synthetic_onefiles").join(&filename);
52 if path.exists() {
53 assert_eq!(fs::read(&path)?, payload);
54 } else {
55 fs::write(path, payload)?;
56 }
57 let reference = format!("<file>{filename}");
58 let data: Vec<_> = reference
59 .encode_utf16()
60 .flat_map(u16::to_le_bytes)
61 .collect();
62 encoded.splice(offset + 4..offset + 4 + count * 2, data.iter().copied());
63 encoded[offset..offset + 4]
64 .copy_from_slice(&(data.len() as u32 / 2).to_le_bytes());
65 assert!(encoded.len() <= 0x1fff);
66 let header = (raw & !(0x1fff << 10)) | ((encoded.len() as u32) << 10);
67 encoded[..4].copy_from_slice(&header.to_le_bytes());
68 changed += 1;
69 touched = true;
70 }
71 }
72 body.extend(encoded);
73 }
74 if touched {
75 assert!(
76 body.len() + 4 <= end - start - 16,
77 "Native fragment has insufficient reserved space"
78 );
79 body.extend_from_slice(&(0xff_u32 | (4 << 10)).to_le_bytes());
80 output[start + 16..end].fill(0);
81 output[start + 16..start + 16 + body.len()].copy_from_slice(&body);
82 }
83 }
84 }
85 assert!(changed > 0);
86 let converted = Store::parse(&output)?;
87 assert!(converted.checksum_mismatches.is_empty());
88 RevisionIndex::parse(&converted)?.validate_current()?;
89 fs::write(destination.join("synthetic.one"), &output)?;
90 fs::write(
91 destination.join("Open Notebook.onetoc2"),
92 onestore::create_table_of_contents(
93 "Open Notebook.onetoc2",
94 &[("synthetic.one", converted.header.file_id)],
95 )?,
96 )?;
97 println!(
98 "Converted {changed} file-data declarations without changing payload identities or bytes"
99 );
100 Ok(())
101}
crates/onestore-notebook/src/lib.rs+45-1
...@@ -77,6 +77,46 @@ pub trait Source {...@@ -77,6 +77,46 @@ pub trait Source {
77 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>>;77 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>>;
78 /// Return a consistent file snapshot, rejecting images larger than the byte limit.78 /// Return a consistent file snapshot, rejecting images larger than the byte limit.
79 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>>;79 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>>;
80 /// Reads an external payload with the same completeness and size guarantees.
81 fn read_asset(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
82 self.read(path, limit)
83 }
84}
85
86/// Reads an external file-data reference from the section's sibling `_onefiles` folder.
87/// `NotFound` in `Error::Io` is distinct from a successfully read zero-byte payload.
88pub fn read_external_asset(
89 source: &mut impl Source,
90 section: &str,
91 filename: &str,
92 limit: usize,
93) -> Result<Vec<u8>, Error> {
94 let (stem, extension) = section.rsplit_once('.').ok_or_else(|| Error::Entry {
95 path: section.into(),
96 })?;
97 if !extension.eq_ignore_ascii_case("one")
98 || !section.split('/').all(component)
99 || stem.ends_with('/')
100 || stem.is_empty()
101 {
102 return Err(Error::Entry {
103 path: section.into(),
104 });
105 }
106 format!("<file>{filename}")
107 .parse::<onestore::FileDataReference>()
108 .map_err(|_| Error::Entry {
109 path: filename.into(),
110 })?;
111 let path = format!("{stem}_onefiles/{filename}");
112 let bytes = source.read_asset(&path, limit).map_err(|error| Error::Io {
113 path: path.clone(),
114 error,
115 })?;
116 if bytes.len() > limit {
117 return Err(Error::Limit { path });
118 }
119 Ok(bytes)
80}120}
81121
82pub struct Limits {122pub struct Limits {
...@@ -113,7 +153,8 @@ pub enum Error {...@@ -113,7 +153,8 @@ pub enum Error {
113 },153 },
114}154}
115155
116/// Discovers the complete rooted directory within caller-specified work and size limits.156/// Discovers rooted notebook topology within caller-specified work and size limits.
157/// Reserved `_onefiles` directories contain payloads, not section groups.
117pub fn discover(source: &mut impl Source, limits: Limits) -> Result<Folder, Error> {158pub fn discover(source: &mut impl Source, limits: Limits) -> Result<Folder, Error> {
118 let mut remaining = limits.entries;159 let mut remaining = limits.entries;
119 let mut identities = BTreeMap::new();160 let mut identities = BTreeMap::new();
...@@ -158,6 +199,9 @@ fn scan(...@@ -158,6 +199,9 @@ fn scan(
158 for entry in &listing {199 for entry in &listing {
159 let child = join(path, &entry.name);200 let child = join(path, &entry.name);
160 if entry.kind == EntryKind::Directory {201 if entry.kind == EntryKind::Directory {
202 if entry.name.to_ascii_lowercase().ends_with("_onefiles") {
203 continue;
204 }
161 result.groups.push(scan(205 result.groups.push(scan(
162 source,206 source,
163 &child,207 &child,
crates/onestore-notebook/src/source.rs+4
...@@ -114,4 +114,8 @@ impl Source for Smb<'_> {...@@ -114,4 +114,8 @@ impl Source for Smb<'_> {
114 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {114 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
115 self.client.read_storage(&self.path(path)?, limit)115 self.client.read_storage(&self.path(path)?, limit)
116 }116 }
117
118 fn read_asset(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
119 self.client.read_asset(&self.path(path)?, limit)
120 }
117}121}
crates/onestore-notebook/tests/assets.rs created+162
...@@ -0,0 +1,162 @@
1use onestore_notebook::{Error, Local, read_external_asset};
2use std::{fs, io};
3
4#[test]
5fn nested_external_payloads_are_exact_and_empty_is_distinct_from_missing() {
6 let root = tempfile::tempdir().unwrap();
7 let directory = root.path().join("Group 🦀/Section é_onefiles");
8 fs::create_dir_all(&directory).unwrap();
9 let filename = "2a83ae62-6754-4383-8e2b-4033ff3cfba1.onebin";
10 let mut source = Local::open(root.path()).unwrap();
11 let section = "Group 🦀/Section é.ONE";
12 assert!(
13 matches!(read_external_asset(&mut source, section, filename, 0),
14 Err(Error::Io { error, .. }) if error.kind() == io::ErrorKind::NotFound)
15 );
16 fs::write(directory.join(filename), []).unwrap();
17 assert!(
18 read_external_asset(&mut source, section, filename, 0)
19 .unwrap()
20 .is_empty()
21 );
22 let bytes: Vec<_> = (0..65537).map(|index| (index % 251) as u8).collect();
23 fs::write(directory.join(filename), &bytes).unwrap();
24 assert!(
25 matches!(read_external_asset(&mut source, section, filename, bytes.len()-1),
26 Err(Error::Io { error, .. }) if error.kind() == io::ErrorKind::FileTooLarge)
27 );
28 assert_eq!(
29 read_external_asset(&mut source, section, filename, bytes.len()).unwrap(),
30 bytes
31 );
32 assert_eq!(fs::read(directory.join(filename)).unwrap(), bytes);
33}
34
35#[test]
36fn invalid_asset_locations_fail_before_accessing_the_source() {
37 struct Unused;
38 impl onestore_notebook::Source for Unused {
39 fn entries(&mut self, _: &str, _: usize) -> io::Result<Vec<onestore_notebook::Entry>> {
40 panic!("Unexpected enumeration")
41 }
42 fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> {
43 panic!("Unexpected read")
44 }
45 }
46 let filename = "2a83ae62-6754-4383-8e2b-4033ff3cfba1.onebin";
47 for section in [
48 "",
49 ".one",
50 "Group/.one",
51 "/Section.one",
52 "../Section.one",
53 "Group/../Section.one",
54 "Group\\Section.one",
55 "Section.onetoc2",
56 ] {
57 assert!(matches!(
58 read_external_asset(&mut Unused, section, filename, 100),
59 Err(Error::Entry { .. })
60 ));
61 }
62 for name in [
63 "",
64 "attachment.png",
65 "../2a83ae62-6754-4383-8e2b-4033ff3cfba1.onebin",
66 "2a83ae62-6754-4383-8e2b-4033ff3cfba1.onebin:other",
67 "<invfdo>",
68 ] {
69 assert!(matches!(
70 read_external_asset(&mut Unused, "Section.one", name, 100),
71 Err(Error::Entry { .. })
72 ));
73 }
74}
75
76#[test]
77fn reserved_payload_directories_are_not_notebook_groups() {
78 let root = tempfile::tempdir().unwrap();
79 fs::write(
80 root.path().join("Section.ONE"),
81 onestore::create_section("Section.one", "Fixture", "Author").unwrap(),
82 )
83 .unwrap();
84 for name in ["section_onefiles", "orphan_onefiles", "ordinary"] {
85 fs::create_dir(root.path().join(name)).unwrap();
86 }
87 fs::write(
88 root.path().join("section_onefiles/unfinished.onebin"),
89 b"payload",
90 )
91 .unwrap();
92 let catalog = onestore_notebook::discover(
93 &mut Local::open(root.path()).unwrap(),
94 onestore_notebook::Limits {
95 entries: 4,
96 bytes_per_file: 1 << 20,
97 depth: 1,
98 },
99 )
100 .unwrap();
101 assert_eq!(catalog.sections.len(), 1);
102 assert_eq!(
103 catalog
104 .groups
105 .iter()
106 .map(|group| group.path.as_str())
107 .collect::<Vec<_>>(),
108 ["ordinary"]
109 );
110}
111
112#[test]
113#[cfg(feature = "smb")]
114#[ignore = "requires a disposable Samba mirror of corpus/native-external-assets/notebook at ONESTORE_SMB_NOTEBOOK"]
115fn live_external_payloads() {
116 use onestore::{
117 FileDataReference, RevisionIndex, Store,
118 document::{Document, Kind},
119 };
120 let root = std::path::Path::new("../../corpus/native-external-assets/notebook");
121 let bytes = fs::read(root.join("synthetic.one")).unwrap();
122 let store = Store::parse(&bytes).unwrap();
123 let index = RevisionIndex::parse(&store).unwrap();
124 let document = Document::parse(&index).unwrap();
125 let client = onestore_smb::Client::connect(
126 &std::env::var("ONESTORE_SMB_LAB").unwrap(),
127 "agent",
128 onestore_smb::Credentials::default(),
129 std::time::Duration::from_secs(5),
130 )
131 .unwrap();
132 let mut remote =
133 onestore_notebook::Smb::new(&client, &std::env::var("ONESTORE_SMB_NOTEBOOK").unwrap())
134 .unwrap();
135 let mut local = Local::open(root).unwrap();
136 let mut seen = std::collections::BTreeSet::new();
137 for node in document
138 .spaces
139 .values()
140 .flat_map(|space| space.revisions.values())
141 .flat_map(|revision| revision.nodes.values())
142 {
143 if let Kind::File {
144 reference: FileDataReference::External(filename),
145 ..
146 } = &node.kind
147 && seen.insert(filename)
148 {
149 let expected = fs::read(root.join("synthetic_onefiles").join(filename)).unwrap();
150 assert_eq!(
151 read_external_asset(&mut local, "synthetic.one", filename, expected.len()).unwrap(),
152 expected
153 );
154 assert_eq!(
155 read_external_asset(&mut remote, "synthetic.one", filename, expected.len())
156 .unwrap(),
157 expected
158 );
159 }
160 }
161 assert_eq!(seen.len(), 3);
162}
crates/onestore-smb/README.md+5
...@@ -47,6 +47,11 @@ are rejected with `ResourceBusy`. Notebook identities come from the files, not...@@ -47,6 +47,11 @@ are rejected with `ResourceBusy`. Notebook identities come from the files, not
47directory names or sizes. Missing paths, denied access and non-directory paths47directory names or sizes. Missing paths, denied access and non-directory paths
48have distinct I/O error kinds.48have distinct I/O error kinds.
4949
50`Client::read_asset(path, byte_limit)` reads an external payload under a read-only
51share handle that excludes writes and deletion. Empty files succeed; limits,
52interrupted reads and failed close never return partial bytes. This payload read
53does not parse a OneStore header or acquire its reader-coordination bytes.
54
50`python3 tools/test_smb_directory.py VM OUTPUT` checks a caller-owned disposable55`python3 tools/test_smb_directory.py VM OUTPUT` checks a caller-owned disposable
51Linux lab VM against its filesystem listing and interrupts directory requests,56Linux lab VM against its filesystem listing and interrupts directory requests,
52responses and close. It creates synthetic files in that VM; the caller retains57responses and close. It creates synthetic files in that VM; the caller retains
crates/onestore-smb/src/lib.rs+29-1
...@@ -175,6 +175,10 @@ impl Client {...@@ -175,6 +175,10 @@ impl Client {
175 }175 }
176176
177 fn open(&self, path: &str, write: bool) -> io::Result<File<'_>> {177 fn open(&self, path: &str, write: bool) -> io::Result<File<'_>> {
178 self.open_shared(path, write, if write { 5 } else { 7 })
179 }
180
181 fn open_shared(&self, path: &str, write: bool, sharing: u32) -> io::Result<File<'_>> {
178 if path.is_empty() || path.contains('\0') || path.encode_utf16().count() > 32767 {182 if path.is_empty() || path.contains('\0') || path.encode_utf16().count() > 32767 {
179 return Err(io::ErrorKind::InvalidInput.into());183 return Err(io::ErrorKind::InvalidInput.into());
180 }184 }
...@@ -185,7 +189,7 @@ impl Client {...@@ -185,7 +189,7 @@ impl Client {
185 impersonation_level: ImpersonationLevel::Impersonation,189 impersonation_level: ImpersonationLevel::Impersonation,
186 desired_access: FileAccessMask::new(if write { 0xc0000000 } else { 0x80000000 }),190 desired_access: FileAccessMask::new(if write { 0xc0000000 } else { 0x80000000 }),
187 file_attributes: 0,191 file_attributes: 0,
188 share_access: ShareAccess(if write { 5 } else { 7 }),192 share_access: ShareAccess(sharing),
189 create_disposition: CreateDisposition::FileOpen,193 create_disposition: CreateDisposition::FileOpen,
190 create_options: 0x42,194 create_options: 0x42,
191 name: smb2::encode_path(&path.replace('\\', "/")),195 name: smb2::encode_path(&path.replace('\\', "/")),
...@@ -198,6 +202,30 @@ impl Client {...@@ -198,6 +202,30 @@ impl Client {
198 })202 })
199 }203 }
200204
205 /// Reads a bounded external payload while denying concurrent writes and deletion.
206 /// Empty files succeed; limits, sharing contention and failed close return no payload.
207 pub fn read_asset(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
208 let mut file = self.open_shared(path, false, 1)?;
209 let mut bytes = Vec::new();
210 let mut block = [0; 65536];
211 loop {
212 let count = (limit - bytes.len()).min(block.len() - 1) + 1;
213 let read = file.read_at(
214 u64::try_from(bytes.len()).map_err(|_| io::ErrorKind::InvalidInput)?,
215 &mut block[..count],
216 )?;
217 if read == 0 {
218 break;
219 }
220 bytes.extend_from_slice(&block[..read]);
221 if bytes.len() > limit {
222 return Err(io::ErrorKind::FileTooLarge.into());
223 }
224 }
225 file.close()?;
226 Ok(bytes)
227 }
228
201 /// Reads one bounded, consistent snapshot; contention returns WouldBlock.229 /// Reads one bounded, consistent snapshot; contention returns WouldBlock.
202 pub fn read(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> {230 pub fn read(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
203 self.read_with(path, |file| {231 self.read_with(path, |file| {
crates/onestore-smb/src/tests.rs+49
...@@ -381,6 +381,55 @@ fn live_storage_inspection() {...@@ -381,6 +381,55 @@ fn live_storage_inspection() {
381 }381 }
382}382}
383383
384#[test]
385#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]
386fn live_assets() {
387 let reader = client();
388 let writer = client();
389 for size in [0, 1, 65535, 65536, 65537, 1048577] {
390 let bytes: Vec<_> = (0..size).map(|index| (index % 251) as u8).collect();
391 let path = format!(
392 "asset-{size}-{}.onebin",
393 SystemTime::now()
394 .duration_since(UNIX_EPOCH)
395 .unwrap()
396 .as_nanos()
397 );
398 create(&writer, &path, &bytes);
399 assert_eq!(reader.read_asset(&path, size).unwrap(), bytes);
400 if size != 0 {
401 assert_eq!(
402 reader.read_asset(&path, size - 1).unwrap_err().kind(),
403 io::ErrorKind::FileTooLarge
404 );
405 assert_eq!(reader.read_asset(&path, size).unwrap(), bytes);
406 }
407 let writing = writer.open(&path, true).unwrap();
408 assert_eq!(
409 reader.read_asset(&path, size).unwrap_err().kind(),
410 io::ErrorKind::WouldBlock
411 );
412 writing.close().unwrap();
413 let asset = reader.open_shared(&path, false, 1).unwrap();
414 assert_eq!(
415 writer.open(&path, true).err().unwrap().kind(),
416 io::ErrorKind::WouldBlock
417 );
418 let other = writer.open_shared(&path, false, 1).unwrap();
419 other.close().unwrap();
420 asset.close().unwrap();
421 writer.open(&path, true).unwrap().close().unwrap();
422 assert_eq!(reader.read_asset(&path, size).unwrap(), bytes);
423 }
424 assert_eq!(
425 reader
426 .read_asset("absent-payload.onebin", 0)
427 .unwrap_err()
428 .kind(),
429 io::ErrorKind::NotFound
430 );
431}
432
384#[test]433#[test]
385#[ignore = "requires an owned Samba fixture and maintenance controller"]434#[ignore = "requires an owned Samba fixture and maintenance controller"]
386fn live_reader_hold() {435fn live_reader_hold() {
crates/onestore-smb/src/tests/faults.rs+77-10
...@@ -171,14 +171,7 @@ fn configure(output: &Path, state: Value) -> usize {...@@ -171,14 +171,7 @@ fn configure(output: &Path, state: Value) -> usize {
171 }171 }
172}172}
173173
174#[test]174fn proxy(output: &Path) -> (Proxy, String) {
175#[ignore = "requires an owned Samba share and a new ONESTORE_SMB_EVIDENCE directory"]
176fn live_message_loss() {
177 let output = std::path::PathBuf::from(std::env::var("ONESTORE_SMB_EVIDENCE").unwrap());
178 fs::create_dir(&output).unwrap();
179 fs::create_dir(output.join("interrupted")).unwrap();
180 fs::create_dir(output.join("recovered")).unwrap();
181 fs::create_dir(output.join("source")).unwrap();
182 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();175 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();
183 let (host, port) = address.rsplit_once(':').unwrap();176 let (host, port) = address.rsplit_once(':').unwrap();
184 let mut proxy = Proxy(177 let mut proxy = Proxy(
...@@ -193,7 +186,7 @@ fn live_message_loss() {...@@ -193,7 +186,7 @@ fn live_message_loss() {
193 );186 );
194 let deadline = Instant::now() + Duration::from_secs(5);187 let deadline = Instant::now() + Duration::from_secs(5);
195 let port = loop {188 let port = loop {
196 if let Some(port) = records(&output)189 if let Some(port) = records(output)
197 .iter()190 .iter()
198 .find_map(|event| event["listening"].as_u64())191 .find_map(|event| event["listening"].as_u64())
199 {192 {
...@@ -203,7 +196,81 @@ fn live_message_loss() {...@@ -203,7 +196,81 @@ fn live_message_loss() {
203 assert!(Instant::now() < deadline, "proxy did not start");196 assert!(Instant::now() < deadline, "proxy did not start");
204 std::thread::sleep(Duration::from_millis(5));197 std::thread::sleep(Duration::from_millis(5));
205 };198 };
206 let proxied = format!("127.0.0.1:{port}");199 (proxy, format!("127.0.0.1:{port}"))
200}
201
202#[test]
203#[ignore = "requires an owned Samba share and a new ONESTORE_SMB_EVIDENCE directory"]
204fn live_asset_loss() {
205 let output = std::path::PathBuf::from(std::env::var("ONESTORE_SMB_EVIDENCE").unwrap());
206 fs::create_dir(&output).unwrap();
207 let (_proxy, address) = proxy(&output);
208 let observer = client();
209 let bytes: Vec<_> = (0..1048577).map(|index| (index % 251) as u8).collect();
210 let path = format!(
211 "asset-loss-{}.onebin",
212 SystemTime::now()
213 .duration_since(UNIX_EPOCH)
214 .unwrap()
215 .as_nanos()
216 );
217 create(&observer, &path, &bytes);
218 for (command, occurrence) in [(8, 1), (8, 9), (8, 17), (8, 18), (6, 1)] {
219 for direction in ["request", "response"] {
220 let reader = Client::connect(
221 &address,
222 "agent",
223 Credentials::default(),
224 Duration::from_secs(5),
225 )
226 .unwrap();
227 // Response occurrences count only replies with the selected status.
228 let begin = configure(
229 &output,
230 json!({"cut":command,"occurrence":if command == 8 && occurrence == 18 && direction == "response" { 1 } else { occurrence },
231 "direction":direction,"status":if command == 8 && occurrence == 18 { "0xc0000011" } else { "0x0" }}),
232 );
233 assert!(
234 reader.read_asset(&path, bytes.len()).is_err(),
235 "{command}/{occurrence}/{direction}"
236 );
237 assert_eq!(
238 reader.read_asset(&path, bytes.len()).unwrap_err().kind(),
239 io::ErrorKind::NotConnected
240 );
241 assert_eq!(
242 records(&output)[begin..]
243 .iter()
244 .filter(|row| row.get("cut").is_some())
245 .count(),
246 1
247 );
248 configure(
249 &output,
250 json!({"phase":format!("{command}-{occurrence}-{direction}-reconnected")}),
251 );
252 let reconnected = Client::connect(
253 &address,
254 "agent",
255 Credentials::default(),
256 Duration::from_secs(5),
257 )
258 .unwrap();
259 assert_eq!(reconnected.read_asset(&path, bytes.len()).unwrap(), bytes);
260 }
261 }
262 assert_eq!(observer.read_asset(&path, bytes.len()).unwrap(), bytes);
263}
264
265#[test]
266#[ignore = "requires an owned Samba share and a new ONESTORE_SMB_EVIDENCE directory"]
267fn live_message_loss() {
268 let output = std::path::PathBuf::from(std::env::var("ONESTORE_SMB_EVIDENCE").unwrap());
269 fs::create_dir(&output).unwrap();
270 fs::create_dir(output.join("interrupted")).unwrap();
271 fs::create_dir(output.join("recovered")).unwrap();
272 fs::create_dir(output.join("source")).unwrap();
273 let (_proxy, proxied) = proxy(&output);
207 let observer = client();274 let observer = client();
208 let prefix = SystemTime::now()275 let prefix = SystemTime::now()
209 .duration_since(UNIX_EPOCH)276 .duration_since(UNIX_EPOCH)
crates/onestore/examples/document.rs deleted-77
...@@ -1,77 +0,0 @@
1use onestore::{
2 FileDataReference, RevisionIndex, Store,
3 document::{Document, Kind},
4};
5use std::{
6 collections::BTreeSet,
7 env, fs,
8 io::{self, BufWriter, Write},
9 path::{Path, PathBuf},
10};
11
12fn write_json(
13 path: impl AsRef<Path>,
14 value: &impl serde::Serialize,
15) -> Result<(), Box<dyn std::error::Error>> {
16 let mut output = BufWriter::new(fs::File::create(path)?);
17 serde_json::to_writer(&mut output, value)?;
18 output.flush()?;
19 Ok(())
20}
21
22fn main() -> Result<(), Box<dyn std::error::Error>> {
23 let mut args = env::args_os().skip(1);
24 let path = args.next().ok_or("Provide a .one or .onetoc2 file.")?;
25 let destination = args.next().map(PathBuf::from);
26 if args.next().is_some() {
27 return Err("Provide a source file and an optional new export directory.".into());
28 }
29 let bytes = onestore::read_file(path)?;
30 let store = Store::parse(&bytes)?;
31 let index = RevisionIndex::parse(&store)?;
32 let document = Document::parse(&index)?;
33 if let Some(destination) = destination {
34 fs::create_dir(&destination)?;
35 fs::create_dir(destination.join("assets"))?;
36 let mut assets = Vec::new();
37 let mut seen = BTreeSet::new();
38 for node in document
39 .spaces
40 .values()
41 .flat_map(|s| s.revisions.values())
42 .flat_map(|r| r.nodes.values())
43 {
44 if let Kind::File {
45 reference: FileDataReference::Internal(guid),
46 payload: Some(data),
47 ..
48 } = &node.kind
49 && seen.insert(guid)
50 {
51 let path = format!("assets/{}.bin", assets.len());
52 fs::write(destination.join(&path), data)?;
53 assets.push(serde_json::json!({"reference": FileDataReference::Internal(*guid), "path": path}));
54 }
55 }
56 write_json(destination.join("assets.json"), &assets)?;
57 let mut text = std::collections::BTreeMap::new();
58 for (sid, space) in &document.spaces {
59 let mut revisions = std::collections::BTreeMap::new();
60 for (rid, revision) in &space.revisions {
61 let mut objects = std::collections::BTreeMap::new();
62 for (oid, node) in &revision.nodes {
63 if matches!(node.kind, Kind::RichText { .. }) {
64 objects.insert(*oid, revision.text_runs(*oid)?);
65 }
66 }
67 revisions.insert(*rid, objects);
68 }
69 text.insert(*sid, revisions);
70 }
71 write_json(destination.join("text.json"), &text)?;
72 write_json(destination.join("document.json"), &document)?;
73 } else {
74 serde_json::to_writer(io::stdout().lock(), &document)?;
75 }
76 Ok(())
77}
crates/onestore/src/files.rs+9-1
...@@ -49,6 +49,14 @@ impl Object<'_> {...@@ -49,6 +49,14 @@ impl Object<'_> {
49 offset: 0,49 offset: 0,
50 message: "Invalid UTF-16 file-data reference",50 message: "Invalid UTF-16 file-data reference",
51 })?;51 })?;
52 text.parse().map(Some)
53 }
54}
55
56impl std::str::FromStr for FileDataReference {
57 type Err = Error;
58
59 fn from_str(text: &str) -> Result<Self, Error> {
52 let parsed = if let Some(name) = text.strip_prefix("<file>") {60 let parsed = if let Some(name) = text.strip_prefix("<file>") {
53 name.strip_suffix(".onebin")61 name.strip_suffix(".onebin")
54 .and_then(guid)62 .and_then(guid)
...@@ -63,7 +71,7 @@ impl Object<'_> {...@@ -63,7 +71,7 @@ impl Object<'_> {
63 } else {71 } else {
64 None72 None
65 };73 };
66 parsed.map(Some).ok_or(Error {74 parsed.ok_or(Error {
67 offset: 0,75 offset: 0,
68 message: "Invalid file-data reference syntax",76 message: "Invalid file-data reference syntax",
69 })77 })
tools/native/external-assets.ps1 created+34
...@@ -0,0 +1,34 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '', [int[]]$Lengths = @(0, 1024))
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5$app = New-Object -ComObject OneNote.Application
6$notebook = ''
7try {
8 $inputs = Join-Path $Root 'inputs'
9 New-Item -ItemType Directory -Path $inputs | Out-Null
10 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebook, 0)
11 $section = ''
12 $app.OpenHierarchy('synthetic.one', $notebook, [ref]$section, 0)
13 foreach ($length in $Lengths) {
14 $path = Join-Path $inputs ($length.ToString() + '.bin')
15 $stream = [IO.File]::Create($path)
16 try {
17 $block = New-Object byte[] 1024
18 for ($i = 0; $i -lt $block.Length; $i++) { $block[$i] = [byte]($i % 251) }
19 for ($written = 0; $written -lt $length; $written += $block.Length) { $stream.Write($block, 0, [Math]::Min($block.Length, $length - $written)) }
20 } finally { $stream.Dispose() }
21 $page = ''
22 $app.CreateNewPage($section, [ref]$page, 0)
23 $xml = '<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="' + $page + '"><one:Title><one:OE><one:T>Attachment ' + $length + '</one:T></one:OE></one:Title><one:Outline><one:OEChildren><one:OE><one:InsertedFile pathSource="' + $path + '" preferredName="' + $length + '.bin"/></one:OE></one:OEChildren></one:Outline></one:Page>'
24 $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false)
25 }
26 $app.SyncHierarchy($notebook)
27
28} finally {
29 if ($notebook) { $app.CloseNotebook($notebook, $false) }
30 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
31 $app = $null
32 [GC]::Collect()
33 [GC]::WaitForPendingFinalizers()
34}
tools/notebook_editor.py+1-1
...@@ -61,7 +61,7 @@ class Session:...@@ -61,7 +61,7 @@ class Session:
61 source = pending / 'snapshot'61 source = pending / 'snapshot'
62 source.mkdir(parents=True)62 source.mkdir(parents=True)
63 for path in sorted((self.output / 'notebook').rglob('*')):63 for path in sorted((self.output / 'notebook').rglob('*')):
64 if path.suffix.lower() not in ('.one', '.onetoc2'): continue64 if path.suffix.lower() not in ('.one', '.onetoc2', '.onebin'): continue
65 saved = source / path.relative_to(self.output / 'notebook')65 saved = source / path.relative_to(self.output / 'notebook')
66 saved.parent.mkdir(parents=True, exist_ok=True)66 saved.parent.mkdir(parents=True, exist_ok=True)
67 result = bridge('snapshot', path, saved)67 result = bridge('snapshot', path, saved)
tools/notebook_report.py+3
...@@ -298,6 +298,8 @@ def generate(source, destination, native=None, versions=(), zone=timezone.utc, e...@@ -298,6 +298,8 @@ def generate(source, destination, native=None, versions=(), zone=timezone.utc, e
298 exported = destination / 'model' / str(index)298 exported = destination / 'model' / str(index)
299 reusable = cached.get(relative.as_posix())299 reusable = cached.get(relative.as_posix())
300 reused = reusable is not None and reusable[0] == manifest[-1]['sha256']300 reused = reusable is not None and reusable[0] == manifest[-1]['sha256']
301 if reused and any('External' in asset['reference'] for asset in json.loads((reusable[1] / 'assets.json').read_text())):
302 reused = False
301 if reused:303 if reused:
302 exported.mkdir()304 exported.mkdir()
303 for name in ('document.json', 'text.json', 'assets.json'):305 for name in ('document.json', 'text.json', 'assets.json'):
...@@ -317,6 +319,7 @@ def generate(source, destination, native=None, versions=(), zone=timezone.utc, e...@@ -317,6 +319,7 @@ def generate(source, destination, native=None, versions=(), zone=timezone.utc, e
317 }319 }
318 rows = json.loads((exported / 'assets.json').read_text())320 rows = json.loads((exported / 'assets.json').read_text())
319 for asset in rows:321 for asset in rows:
322 if asset['path'] is None: continue
320 reference = json.dumps(asset['reference'], sort_keys=True)323 reference = json.dumps(asset['reference'], sort_keys=True)
321 if reused:324 if reused:
322 name = Path(asset['path']).name325 name = Path(asset['path']).name
tools/test_notebook_discovery.py+1-1
...@@ -12,7 +12,7 @@ ROOT = Path(__file__).resolve().parent.parent...@@ -12,7 +12,7 @@ ROOT = Path(__file__).resolve().parent.parent
12class NativeDiscovery(unittest.TestCase):12class NativeDiscovery(unittest.TestCase):
13 def test_native_hierarchies(self):13 def test_native_hierarchies(self):
14 fixtures = ['m6/native-features-01', 'native-encrypted/cold-encrypted-02',14 fixtures = ['m6/native-features-01', 'native-encrypted/cold-encrypted-02',
15 'native-delete/cold-deletion-05']15 'native-delete/cold-deletion-05', 'native-external-assets']
16 if os.environ.get('ONESTORE_NOTEBOOK_NATIVE'):16 if os.environ.get('ONESTORE_NOTEBOOK_NATIVE'):
17 fixtures.append(str(Path(os.environ['ONESTORE_NOTEBOOK_NATIVE']).resolve()))17 fixtures.append(str(Path(os.environ['ONESTORE_NOTEBOOK_NATIVE']).resolve()))
18 for fixture in fixtures:18 for fixture in fixtures:
tools/test_notebook_report.py+35
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import base641import base64
2from io import BytesIO2from io import BytesIO
3import json3import json
4import hashlib
4from pathlib import Path5from pathlib import Path
5import re6import re
6from tempfile import TemporaryDirectory7from tempfile import TemporaryDirectory
...@@ -16,6 +17,40 @@ from document_model import EXPORTER...@@ -16,6 +17,40 @@ from document_model import EXPORTER
1617
1718
18class NotebookReportTest(unittest.TestCase):19class NotebookReportTest(unittest.TestCase):
20 def test_external_assets_match_native_bytes_and_refresh_without_a_section_edit(self):
21 from notebook_editor import Session
22 fixture = Path(__file__).resolve().parent.parent / 'corpus/native-external-assets'
23 with TemporaryDirectory() as temporary:
24 root = Path(temporary)
25 source = root / 'source'
26 shutil.copytree(fixture / 'notebook', source)
27 session = Session(source, root / 'session')
28 first = root / 'session/g/0/report'
29 section = next(p.parent for p in (first / 'model').glob('*/assets.json')
30 if json.loads(p.read_text()))
31 rows = json.loads((section / 'assets.json').read_text())
32 expected = {row['sha256'] for row in json.loads((fixture / 'read/payloads.json').read_text(encoding='utf-8-sig'))}
33 actual = {hashlib.sha256((section / row['path']).read_bytes()).hexdigest() for row in rows}
34 self.assertTrue(expected <= actual)
35 for payload in source.rglob('*.onebin'):
36 self.assertEqual(payload.read_bytes(), (root / 'session/g/0/snapshot' / payload.relative_to(source)).read_bytes())
37 payload, = [p for p in source.rglob('*.onebin') if p.stat().st_size == 1024]
38 reference = {'External': payload.name}
39 old, = [row for row in rows if row['reference'] == reference]
40 previous_bytes = (section / old['path']).read_bytes()
41 payload.unlink()
42 generate(source, root / 'missing', previous=first)
43 missing, = [row for p in (root / 'missing/model').glob('*/assets.json')
44 for row in json.loads(p.read_text()) if row['reference'] == reference]
45 self.assertIsNone(missing['path'])
46 self.assertEqual(missing['error']['kind'], 'NotFound')
47 payload.write_bytes(b'Changed external payload')
48 generate(source, root / 'changed', previous=root / 'missing')
49 changed, = [(p.parent, row) for p in (root / 'changed/model').glob('*/assets.json')
50 for row in json.loads(p.read_text()) if row['reference'] == reference]
51 self.assertEqual((changed[0] / changed[1]['path']).read_bytes(), payload.read_bytes())
52 self.assertEqual((section / old['path']).read_bytes(), previous_bytes)
53
19 def test_locked_and_unreadable_sections_remain_visible_in_cached_reports(self):54 def test_locked_and_unreadable_sections_remain_visible_in_cached_reports(self):
20 fixture = Path(__file__).resolve().parent.parent / 'corpus/native-encrypted/cold-encrypted-02/notebook'55 fixture = Path(__file__).resolve().parent.parent / 'corpus/native-encrypted/cold-encrypted-02/notebook'
21 with TemporaryDirectory() as temporary:56 with TemporaryDirectory() as temporary: