| author | |
| committer | |
| log | 30d02b72f6a5b8dd8b72cea593bdc9c883afea1e |
| tree | c22ca0a9fd436a9f93add677af1fcc6103aaa068 |
| parent | bca7120d8a96e56b4ae7c6c76d1e82ccef0c5d21 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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-astra31 files changed, 749 insertions(+), 92 deletions(-)
corpus/native-external-assets/README.md created+16| ... | ... | @@ -0,0 +1,16 @@ |
| 1 | # External payload fixture | |
| 2 | ||
| 3 | OneNote 2010 authored an empty attachment and a 1 KiB attachment with | |
| 4 | `tools/native/external-assets.ps1`. The retained native section is in `native/`. | |
| 5 | The fixture generator converts all file-data declarations into the documented | |
| 6 | external representation while retaining their identities and exact payloads. | |
| 7 | The `read/` capture is an independent fresh-cache OneNote reopen; `payloads.json` | |
| 8 | contains the native attachment hashes. `provenance.json` records source hashes. | |
| 9 | ||
| 10 | ```sh | |
| 11 | cargo run -p onestore-notebook --example externalize_fixture -- corpus/native-external-assets/native/synthetic.one /tmp/external-notebook | |
| 12 | python3 tools/native_runner.py /tmp/external-notebook /tmp/external-native --expected-pages 3 --collect-notebook | |
| 13 | ``` | |
| 14 | ||
| 15 | The generator creates a new destination and requires reserved fragment space. | |
| 16 | It 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 createdcorpus/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 | 7 | share-relative paths. Section display-name overrides remain distinct from file |
| 8 | 8 | names. TOC references whose identities are absent from the directory remain |
| 9 | 9 | inspectable; a cached filename never substitutes for an identity match. |
| 10 | Reserved `_onefiles` directories are excluded from section-group traversal. | |
| 10 | 11 | Encrypted sections and valid storage with an unreadable document graph retain |
| 11 | 12 | their identity as `Locked` or `Unreadable`, without being presented as empty pages. |
| 12 | 13 | Malformed storage, failed reads and ambiguous identities reject the discovery. |
| ... | ... | @@ -16,3 +17,9 @@ observation across multiple files, not an atomic notebook transaction or |
| 16 | 17 | authorization to publish an edit. Refresh rejects observed topology changes and |
| 17 | 18 | duplicate physical files with the same logical identity. Retain the last accepted |
| 18 | 19 | catalog 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 | |
| 22 | selected section's sibling `_onefiles` folder and returns exact bounded bytes. | |
| 23 | Missing files, permissions and size failures retain their I/O error kinds; an | |
| 24 | empty payload is a successful empty buffer. Embedded payloads remain available | |
| 25 | directly from the core document model. |
crates/onestore-notebook/examples/document.rs created+115| ... | ... | @@ -0,0 +1,115 @@ |
| 1 | use onestore::{ | |
| 2 | FileDataReference, RevisionIndex, Store, | |
| 3 | document::{Document, Kind}, | |
| 4 | }; | |
| 5 | use std::{ | |
| 6 | borrow::Cow, | |
| 7 | collections::BTreeSet, | |
| 8 | env, fs, | |
| 9 | io::{self, BufWriter, Write}, | |
| 10 | path::{Path, PathBuf}, | |
| 11 | }; | |
| 12 | ||
| 13 | fn 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 | ||
| 23 | fn 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. | |
| 2 | use onestore::{FileDataReference, RevisionIndex, Store}; | |
| 3 | use std::{fs, path::PathBuf}; | |
| 4 | fn 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 | 77 | fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>>; |
| 78 | 78 | /// Return a consistent file snapshot, rejecting images larger than the byte limit. |
| 79 | 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. | |
| 88 | pub 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 | } |
| 81 | 121 | |
| 82 | 122 | pub struct Limits { |
| ... | ... | @@ -113,7 +153,8 @@ pub enum Error { |
| 113 | 153 | }, |
| 114 | 154 | } |
| 115 | 155 | |
| 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. | |
| 117 | 158 | pub fn discover(source: &mut impl Source, limits: Limits) -> Result<Folder, Error> { |
| 118 | 159 | let mut remaining = limits.entries; |
| 119 | 160 | let mut identities = BTreeMap::new(); |
| ... | ... | @@ -158,6 +199,9 @@ fn scan( |
| 158 | 199 | for entry in &listing { |
| 159 | 200 | let child = join(path, &entry.name); |
| 160 | 201 | if entry.kind == EntryKind::Directory { |
| 202 | if entry.name.to_ascii_lowercase().ends_with("_onefiles") { | |
| 203 | continue; | |
| 204 | } | |
| 161 | 205 | result.groups.push(scan( |
| 162 | 206 | source, |
| 163 | 207 | &child, |
crates/onestore-notebook/src/source.rs+4| ... | ... | @@ -114,4 +114,8 @@ impl Source for Smb<'_> { |
| 114 | 114 | fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> { |
| 115 | 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 @@ |
| 1 | use onestore_notebook::{Error, Local, read_external_asset}; | |
| 2 | use std::{fs, io}; | |
| 3 | ||
| 4 | #[test] | |
| 5 | fn 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] | |
| 36 | fn 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] | |
| 77 | fn 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"] | |
| 115 | fn 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 | 47 | directory names or sizes. Missing paths, denied access and non-directory paths |
| 48 | 48 | have distinct I/O error kinds. |
| 49 | 49 | |
| 50 | `Client::read_asset(path, byte_limit)` reads an external payload under a read-only | |
| 51 | share handle that excludes writes and deletion. Empty files succeed; limits, | |
| 52 | interrupted reads and failed close never return partial bytes. This payload read | |
| 53 | does not parse a OneStore header or acquire its reader-coordination bytes. | |
| 54 | ||
| 50 | 55 | `python3 tools/test_smb_directory.py VM OUTPUT` checks a caller-owned disposable |
| 51 | 56 | Linux lab VM against its filesystem listing and interrupts directory requests, |
| 52 | 57 | responses 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 | 175 | } |
| 176 | 176 | |
| 177 | 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 | 182 | if path.is_empty() || path.contains('\0') || path.encode_utf16().count() > 32767 { |
| 179 | 183 | return Err(io::ErrorKind::InvalidInput.into()); |
| 180 | 184 | } |
| ... | ... | @@ -185,7 +189,7 @@ impl Client { |
| 185 | 189 | impersonation_level: ImpersonationLevel::Impersonation, |
| 186 | 190 | desired_access: FileAccessMask::new(if write { 0xc0000000 } else { 0x80000000 }), |
| 187 | 191 | file_attributes: 0, |
| 188 | share_access: ShareAccess(if write { 5 } else { 7 }), | |
| 192 | share_access: ShareAccess(sharing), | |
| 189 | 193 | create_disposition: CreateDisposition::FileOpen, |
| 190 | 194 | create_options: 0x42, |
| 191 | 195 | name: smb2::encode_path(&path.replace('\\', "/")), |
| ... | ... | @@ -198,6 +202,30 @@ impl Client { |
| 198 | 202 | }) |
| 199 | 203 | } |
| 200 | 204 | |
| 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 | 229 | /// Reads one bounded, consistent snapshot; contention returns WouldBlock. |
| 202 | 230 | pub fn read(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> { |
| 203 | 231 | self.read_with(path, |file| { |
crates/onestore-smb/src/tests.rs+49| ... | ... | @@ -381,6 +381,55 @@ fn live_storage_inspection() { |
| 381 | 381 | } |
| 382 | 382 | } |
| 383 | 383 | |
| 384 | #[test] | |
| 385 | #[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] | |
| 386 | fn 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 | 433 | #[test] |
| 385 | 434 | #[ignore = "requires an owned Samba fixture and maintenance controller"] |
| 386 | 435 | fn live_reader_hold() { |
crates/onestore-smb/src/tests/faults.rs+77-10| ... | ... | @@ -171,14 +171,7 @@ fn configure(output: &Path, state: Value) -> usize { |
| 171 | 171 | } |
| 172 | 172 | } |
| 173 | 173 | |
| 174 | #[test] | |
| 175 | #[ignore = "requires an owned Samba share and a new ONESTORE_SMB_EVIDENCE directory"] | |
| 176 | fn 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(); | |
| 174 | fn proxy(output: &Path) -> (Proxy, String) { | |
| 182 | 175 | let address = std::env::var("ONESTORE_SMB_LAB").unwrap(); |
| 183 | 176 | let (host, port) = address.rsplit_once(':').unwrap(); |
| 184 | 177 | let mut proxy = Proxy( |
| ... | ... | @@ -193,7 +186,7 @@ fn live_message_loss() { |
| 193 | 186 | ); |
| 194 | 187 | let deadline = Instant::now() + Duration::from_secs(5); |
| 195 | 188 | let port = loop { |
| 196 | if let Some(port) = records(&output) | |
| 189 | if let Some(port) = records(output) | |
| 197 | 190 | .iter() |
| 198 | 191 | .find_map(|event| event["listening"].as_u64()) |
| 199 | 192 | { |
| ... | ... | @@ -203,7 +196,81 @@ fn live_message_loss() { |
| 203 | 196 | assert!(Instant::now() < deadline, "proxy did not start"); |
| 204 | 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"] | |
| 204 | fn 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"] | |
| 267 | fn 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 | 274 | let observer = client(); |
| 208 | 275 | let prefix = SystemTime::now() |
| 209 | 276 | .duration_since(UNIX_EPOCH) |
crates/onestore/examples/document.rs deleted-77| ... | ... | @@ -1,77 +0,0 @@ |
| 1 | use onestore::{ | |
| 2 | FileDataReference, RevisionIndex, Store, | |
| 3 | document::{Document, Kind}, | |
| 4 | }; | |
| 5 | use std::{ | |
| 6 | collections::BTreeSet, | |
| 7 | env, fs, | |
| 8 | io::{self, BufWriter, Write}, | |
| 9 | path::{Path, PathBuf}, | |
| 10 | }; | |
| 11 | ||
| 12 | fn 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 | ||
| 22 | fn 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 | 49 | offset: 0, |
| 50 | 50 | message: "Invalid UTF-16 file-data reference", |
| 51 | 51 | })?; |
| 52 | text.parse().map(Some) | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | impl std::str::FromStr for FileDataReference { | |
| 57 | type Err = Error; | |
| 58 | ||
| 59 | fn from_str(text: &str) -> Result<Self, Error> { | |
| 52 | 60 | let parsed = if let Some(name) = text.strip_prefix("<file>") { |
| 53 | 61 | name.strip_suffix(".onebin") |
| 54 | 62 | .and_then(guid) |
| ... | ... | @@ -63,7 +71,7 @@ impl Object<'_> { |
| 63 | 71 | } else { |
| 64 | 72 | None |
| 65 | 73 | }; |
| 66 | parsed.map(Some).ok_or(Error { | |
| 74 | parsed.ok_or(Error { | |
| 67 | 75 | offset: 0, |
| 68 | 76 | message: "Invalid file-data reference syntax", |
| 69 | 77 | }) |
tools/native/external-assets.ps1 created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '', [int[]]$Lengths = @(0, 1024)) | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | & "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost | |
| 5 | $app = New-Object -ComObject OneNote.Application | |
| 6 | $notebook = '' | |
| 7 | try { | |
| 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 | 61 | source = pending / 'snapshot' |
| 62 | 62 | source.mkdir(parents=True) |
| 63 | 63 | for path in sorted((self.output / 'notebook').rglob('*')): |
| 64 | if path.suffix.lower() not in ('.one', '.onetoc2'): continue | |
| 64 | if path.suffix.lower() not in ('.one', '.onetoc2', '.onebin'): continue | |
| 65 | 65 | saved = source / path.relative_to(self.output / 'notebook') |
| 66 | 66 | saved.parent.mkdir(parents=True, exist_ok=True) |
| 67 | 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 | 298 | exported = destination / 'model' / str(index) |
| 299 | 299 | reusable = cached.get(relative.as_posix()) |
| 300 | 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 | 303 | if reused: |
| 302 | 304 | exported.mkdir() |
| 303 | 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 | 319 | } |
| 318 | 320 | rows = json.loads((exported / 'assets.json').read_text()) |
| 319 | 321 | for asset in rows: |
| 322 | if asset['path'] is None: continue | |
| 320 | 323 | reference = json.dumps(asset['reference'], sort_keys=True) |
| 321 | 324 | if reused: |
| 322 | 325 | name = Path(asset['path']).name |
tools/test_notebook_discovery.py+1-1| ... | ... | @@ -12,7 +12,7 @@ ROOT = Path(__file__).resolve().parent.parent |
| 12 | 12 | class NativeDiscovery(unittest.TestCase): |
| 13 | 13 | def test_native_hierarchies(self): |
| 14 | 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 | 16 | if os.environ.get('ONESTORE_NOTEBOOK_NATIVE'): |
| 17 | 17 | fixtures.append(str(Path(os.environ['ONESTORE_NOTEBOOK_NATIVE']).resolve())) |
| 18 | 18 | for fixture in fixtures: |
tools/test_notebook_report.py+35| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import base64 |
| 2 | 2 | from io import BytesIO |
| 3 | 3 | import json |
| 4 | import hashlib | |
| 4 | 5 | from pathlib import Path |
| 5 | 6 | import re |
| 6 | 7 | from tempfile import TemporaryDirectory |
| ... | ... | @@ -16,6 +17,40 @@ from document_model import EXPORTER |
| 16 | 17 | |
| 17 | 18 | |
| 18 | 19 | class 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 | 54 | def test_locked_and_unreadable_sections_remain_visible_in_cached_reports(self): |
| 20 | 55 | fixture = Path(__file__).resolve().parent.parent / 'corpus/native-encrypted/cold-encrypted-02/notebook' |
| 21 | 56 | with TemporaryDirectory() as temporary: |