authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-07 17:20:27-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-07 17:20:27-07:00
logbca7120d8a96e56b4ae7c6c76d1e82ccef0c5d21
treea25ecdab015dd5946ed165c9a8e53179e074c8f8
parent2c75dd41e5584f3d01c9253e50bdebec6623740f
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: discover notebook identities and order over local and SMB roots

Share notebook traversal with the diagnostic reader. Preserve stale TOC references, encrypted sections and unreadable graph state without treating them as empty content. Storage inspection retains reader coordination while edit validation stays strict. Validated native hierarchies including the copied personal notebook, local/SMB catalog parity, rename and duplicate identity cases, report/editor regressions, live reader coordination, and Apple device/simulator linking. Assisted-by: gpt-6-astra

25 files changed, 1246 insertions(+), 43 deletions(-)

Cargo.lock+13
......@@ -515,10 +515,23 @@ name = "onestore-diagnostic"
515515version = "0.1.0"
516516dependencies = [
517517 "onestore",
518 "onestore-notebook",
518519 "serde",
519520 "serde_json",
520521]
521522
523[[package]]
524name = "onestore-notebook"
525version = "0.1.0"
526dependencies = [
527 "onestore",
528 "onestore-smb",
529 "serde",
530 "serde_json",
531 "tempfile",
532 "thiserror",
533]
534
522535[[package]]
523536name = "onestore-offline"
524537version = "0.1.0"
crates/onestore-diagnostic/Cargo.toml+1
......@@ -6,5 +6,6 @@ publish = false
66
77[dependencies]
88onestore = { path = "../onestore" }
9onestore-notebook = { path = "../onestore-notebook" }
910serde = { version = "1.0.229", features = ["derive"] }
1011serde_json = "1.0.151"
crates/onestore-diagnostic/src/main.rs+23-4
......@@ -40,14 +40,26 @@ enum Action {
4040 },
4141}
4242
43fn run() -> Result<Value, Box<dyn std::error::Error>> {
44 let args: Vec<_> = env::args_os().skip(1).collect();
43fn run(args: &[std::ffi::OsString]) -> Result<Value, Box<dyn std::error::Error>> {
44 if let [mode, root] = args
45 && mode == "catalog"
46 {
47 let catalog = onestore_notebook::discover(
48 &mut onestore_notebook::Local::open(root)?,
49 onestore_notebook::Limits {
50 entries: 100_000,
51 bytes_per_file: 256 * 1024 * 1024,
52 depth: 64,
53 },
54 )?;
55 return Ok(json!({"ok": true, "catalog": catalog}));
56 }
4557 if args.len() != 3
4658 || !["snapshot", "check", "commit"]
4759 .iter()
4860 .any(|mode| args[0] == *mode)
4961 {
50 return Err("Usage: onestore-diagnostic snapshot FILE NEW_SNAPSHOT | check SNAPSHOT - | commit FILE SNAPSHOT; edits arrive as JSON on stdin".into());
62 return Err("Usage: onestore-diagnostic catalog ROOT | snapshot FILE NEW_SNAPSHOT | check SNAPSHOT - | commit FILE SNAPSHOT; edits arrive as JSON on stdin".into());
5163 }
5264 if args[0] == "snapshot" {
5365 let bytes = onestore::read_file(&args[1])?;
......@@ -102,6 +114,13 @@ fn run() -> Result<Value, Box<dyn std::error::Error>> {
102114}
103115
104116fn main() {
105 let result = run().unwrap_or_else(|error| json!({"ok": false, "state": "NotCommitted", "kind": "Input", "error": error.to_string()}));
117 let args: Vec<_> = env::args_os().skip(1).collect();
118 let result = run(&args).unwrap_or_else(|error| {
119 let mut result = json!({"ok": false, "kind": "Input", "error": error.to_string()});
120 if args.first().is_some_and(|mode| mode == "commit") {
121 result["state"] = json!("NotCommitted");
122 }
123 result
124 });
106125 println!("{result}");
107126}
crates/onestore-notebook/Cargo.toml created+18
......@@ -0,0 +1,18 @@
1[package]
2name = "onestore-notebook"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7[features]
8smb = ["dep:onestore-smb"]
9
10[dependencies]
11onestore = { path = "../onestore" }
12onestore-smb = { path = "../onestore-smb", optional = true }
13serde = { version = "1", features = ["derive"] }
14thiserror = "2"
15
16[dev-dependencies]
17serde_json = "1"
18tempfile = "3"
crates/onestore-notebook/README.md created+18
......@@ -0,0 +1,18 @@
1# onestore-notebook
2
3Read-only notebook discovery over a caller-supplied root. This experimental
4sibling crate keeps directory traversal out of the single-file storage parser.
5
6Discovery returns ordered sections and nested groups with file identities and
7share-relative paths. Section display-name overrides remain distinct from file
8names. TOC references whose identities are absent from the directory remain
9inspectable; a cached filename never substitutes for an identity match.
10Encrypted sections and valid storage with an unreadable document graph retain
11their identity as `Locked` or `Unreadable`, without being presented as empty pages.
12Malformed storage, failed reads and ambiguous identities reject the discovery.
13
14Each file read must be a consistent, bounded snapshot. The result is an
15observation across multiple files, not an atomic notebook transaction or
16authorization to publish an edit. Refresh rejects observed topology changes and
17duplicate physical files with the same logical identity. Retain the last accepted
18catalog if discovery fails; a connection failure does not mean files were deleted.
crates/onestore-notebook/examples/discover.rs created+40
......@@ -0,0 +1,40 @@
1use onestore_notebook::{Limits, Local, discover};
2
3fn main() -> Result<(), Box<dyn std::error::Error>> {
4 let args: Vec<_> = std::env::args_os().skip(1).collect();
5 let limits = Limits {
6 entries: 100_000,
7 bytes_per_file: 256 * 1024 * 1024,
8 depth: 64,
9 };
10 let catalog = match args.as_slice() {
11 [root] => discover(&mut Local::open(root)?, limits)?,
12 #[cfg(feature = "smb")]
13 [flag, address, share, root] if flag == "--smb" => {
14 use onestore_smb::{Client, Credentials};
15 let username = std::env::var("ONESTORE_SMB_USERNAME").unwrap_or_default();
16 let password = std::env::var("ONESTORE_SMB_PASSWORD").unwrap_or_default();
17 let domain = std::env::var("ONESTORE_SMB_DOMAIN").unwrap_or_default();
18 let client = Client::connect(
19 address.to_str().ok_or("Use a UTF-8 server address")?,
20 share.to_str().ok_or("Use a UTF-8 share name")?,
21 Credentials {
22 username: &username,
23 password: &password,
24 domain: &domain,
25 },
26 std::time::Duration::from_secs(5),
27 )?;
28 discover(
29 &mut onestore_notebook::Smb::new(
30 &client,
31 root.to_str().ok_or("Use a UTF-8 notebook path")?,
32 )?,
33 limits,
34 )?
35 }
36 _ => return Err("Provide ROOT, or --smb ADDRESS SHARE ROOT with the smb feature".into()),
37 };
38 serde_json::to_writer_pretty(std::io::stdout().lock(), &catalog)?;
39 Ok(())
40}
crates/onestore-notebook/src/lib.rs created+381
......@@ -0,0 +1,381 @@
1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use onestore::{
5 ExGuid, FileType, RevisionIndex, Store,
6 document::{Document, Kind},
7};
8use serde::Serialize;
9use std::{
10 collections::{BTreeMap, BTreeSet},
11 io,
12};
13
14mod source;
15pub use source::Local;
16#[cfg(feature = "smb")]
17pub use source::Smb;
18
19#[derive(Debug, Serialize)]
20pub struct Section {
21 pub path: String,
22 /// Header.guidFile, also used by FileIdentityGuid in a parent TOC.
23 pub file_id: [u8; 16],
24 pub state: SectionState,
25}
26
27#[derive(Debug, Serialize)]
28pub enum SectionState {
29 Readable {
30 /// An explicit SectionDisplayName; otherwise use the current filename without its extension.
31 name: Option<String>,
32 },
33 Locked,
34 Unreadable(onestore::Error),
35}
36
37#[derive(Debug, Serialize)]
38pub struct Folder {
39 pub path: String,
40 pub toc: Option<Toc>,
41 pub sections: Vec<Section>,
42 pub groups: Vec<Folder>,
43}
44
45#[derive(Debug, Serialize)]
46pub struct Toc {
47 pub filename: String,
48 pub file_id: [u8; 16],
49 /// Stale or unavailable TOC references; these are not inferred active sections.
50 pub unresolved: Vec<TocReference>,
51}
52
53#[derive(Debug, Serialize)]
54pub struct TocReference {
55 /// Native TOCs can retain an identity after removing its cached filename.
56 pub filename: Option<String>,
57 pub file: [u8; 16],
58 pub order: u32,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
62pub enum EntryKind {
63 File,
64 Directory,
65 Other,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
69pub struct Entry {
70 pub name: String,
71 pub kind: EntryKind,
72}
73
74/// Rooted file access. Paths are relative, UTF-8, and use `/` between components.
75pub trait Source {
76 /// Return the complete immediate directory or an error, never a truncated success.
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.
79 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>>;
80}
81
82pub struct Limits {
83 pub entries: usize,
84 pub bytes_per_file: usize,
85 pub depth: usize,
86}
87
88#[derive(Debug, thiserror::Error)]
89pub enum Error {
90 #[error("Cannot read {path}: {error}")]
91 Io {
92 path: String,
93 #[source]
94 error: io::Error,
95 },
96 #[error("Invalid notebook file {path}: {error}")]
97 Document {
98 path: String,
99 #[source]
100 error: onestore::Error,
101 },
102 #[error("Discovery limit exceeded at {path}")]
103 Limit { path: String },
104 #[error("Directory changed during discovery: {path}")]
105 Changed { path: String },
106 #[error("Invalid or unsupported directory entry: {path}")]
107 Entry { path: String },
108 #[error("File identity occurs at both {first} and {second}")]
109 DuplicateIdentity {
110 file: [u8; 16],
111 first: String,
112 second: String,
113 },
114}
115
116/// Discovers the complete rooted directory within caller-specified work and size limits.
117pub fn discover(source: &mut impl Source, limits: Limits) -> Result<Folder, Error> {
118 let mut remaining = limits.entries;
119 let mut identities = BTreeMap::new();
120 scan(source, "", &limits, 0, &mut remaining, &mut identities)
121}
122
123fn scan(
124 source: &mut impl Source,
125 path: &str,
126 limits: &Limits,
127 depth: usize,
128 remaining: &mut usize,
129 identities: &mut BTreeMap<[u8; 16], String>,
130) -> Result<Folder, Error> {
131 if depth > limits.depth {
132 return Err(Error::Limit { path: path.into() });
133 }
134 let mut listing = source
135 .entries(path, *remaining)
136 .map_err(|error| Error::Io {
137 path: path.into(),
138 error,
139 })?;
140 *remaining = remaining
141 .checked_sub(listing.len())
142 .ok_or_else(|| Error::Limit { path: path.into() })?;
143 listing.sort();
144 let mut names = BTreeSet::new();
145 for entry in &listing {
146 if !component(&entry.name) || !names.insert(&entry.name) {
147 return Err(Error::Entry {
148 path: join(path, &entry.name),
149 });
150 }
151 }
152 let mut result = Folder {
153 path: path.into(),
154 toc: None,
155 sections: Vec::new(),
156 groups: Vec::new(),
157 };
158 for entry in &listing {
159 let child = join(path, &entry.name);
160 if entry.kind == EntryKind::Directory {
161 result.groups.push(scan(
162 source,
163 &child,
164 limits,
165 depth + 1,
166 remaining,
167 identities,
168 )?);
169 continue;
170 }
171 let lower = entry.name.to_ascii_lowercase();
172 let expected = if lower.ends_with(".one") {
173 FileType::Section
174 } else if lower.ends_with(".onetoc2") {
175 FileType::TableOfContents
176 } else {
177 continue;
178 };
179 if entry.kind != EntryKind::File
180 || (expected == FileType::TableOfContents && result.toc.is_some())
181 {
182 return Err(Error::Entry { path: child });
183 }
184 let bytes = source
185 .read(&child, limits.bytes_per_file)
186 .map_err(|error| Error::Io {
187 path: child.clone(),
188 error,
189 })?;
190 if bytes.len() > limits.bytes_per_file {
191 return Err(Error::Limit { path: child });
192 }
193 let store = Store::parse(&bytes).map_err(|error| Error::Document {
194 path: child.clone(),
195 error,
196 })?;
197 if store.header.file_type != expected || !store.checksum_mismatches.is_empty() {
198 return Err(Error::Document {
199 path: child,
200 error: onestore::Error {
201 offset: 0,
202 message: "Unexpected file type or checksum mismatch",
203 },
204 });
205 }
206 let file_id = store.header.file_id;
207 let parsed = (|| {
208 let index = RevisionIndex::parse(&store)?;
209 let document = Document::parse(&index)?;
210 let revision = document
211 .spaces
212 .get(&document.root)
213 .and_then(|space| {
214 space
215 .contexts
216 .get(&ExGuid::default())
217 .and_then(|id| space.revisions.get(id))
218 })
219 .ok_or(onestore::Error {
220 offset: 0,
221 message: "Missing active notebook root revision",
222 })?;
223 if expected == FileType::Section {
224 if revision
225 .roots
226 .get(&1)
227 .and_then(|id| revision.nodes.get(id))
228 .is_some_and(|node| matches!(node.kind, Kind::Encrypted { .. }))
229 {
230 result.sections.push(Section {
231 path: child.clone(),
232 file_id,
233 state: SectionState::Locked,
234 });
235 return Ok(());
236 }
237 index.validate_current()?;
238 document.pages()?;
239 let name = revision
240 .roots
241 .get(&2)
242 .and_then(|id| revision.nodes.get(id))
243 .and_then(|node| match &node.kind {
244 Kind::SectionMetadata { name, .. } => name.clone(),
245 _ => None,
246 });
247 result.sections.push(Section {
248 path: child.clone(),
249 file_id,
250 state: SectionState::Readable { name },
251 });
252 } else {
253 index.validate_current()?;
254 let node = revision.roots.get(&1).and_then(|id| revision.nodes.get(id));
255 let Some(Kind::Toc { entries, .. }) = node.map(|node| &node.kind) else {
256 return Err(onestore::Error {
257 offset: 0,
258 message: "Missing notebook TOC root",
259 });
260 };
261 let mut seen = BTreeSet::new();
262 let mut unresolved = Vec::new();
263 for id in entries {
264 let Some(Kind::Toc {
265 filename,
266 identity: Some(file),
267 order: Some(order),
268 ..
269 }) = revision.nodes.get(id).map(|node| &node.kind)
270 else {
271 return Err(onestore::Error {
272 offset: 0,
273 message: "Incomplete notebook TOC reference",
274 });
275 };
276 if filename.as_deref().is_some_and(|name| !component(name))
277 || !seen.insert(*file)
278 {
279 return Err(onestore::Error {
280 offset: 0,
281 message: "Invalid or duplicated notebook TOC reference",
282 });
283 }
284 unresolved.push(TocReference {
285 filename: filename.clone(),
286 file: *file,
287 order: *order,
288 });
289 }
290 result.toc = Some(Toc {
291 filename: entry.name.clone(),
292 file_id,
293 unresolved,
294 });
295 }
296 Ok(())
297 })();
298 if let Err(error) = parsed {
299 if expected == FileType::Section {
300 result.sections.push(Section {
301 path: child.clone(),
302 file_id,
303 state: SectionState::Unreadable(error),
304 });
305 } else {
306 return Err(Error::Document { path: child, error });
307 }
308 }
309 if let Some(first) = identities.insert(file_id, child.clone()) {
310 return Err(Error::DuplicateIdentity {
311 file: file_id,
312 first,
313 second: child,
314 });
315 }
316 }
317 let order: BTreeMap<_, _> = result
318 .toc
319 .iter()
320 .flat_map(|toc| &toc.unresolved)
321 .map(|entry| (entry.file, entry.order))
322 .collect();
323 result.sections.sort_by(|a, b| {
324 (order.get(&a.file_id).copied().unwrap_or(u32::MAX), &a.path)
325 .cmp(&(order.get(&b.file_id).copied().unwrap_or(u32::MAX), &b.path))
326 });
327 result.groups.sort_by(|a, b| {
328 (
329 a.toc
330 .as_ref()
331 .and_then(|toc| order.get(&toc.file_id).copied())
332 .unwrap_or(u32::MAX),
333 &a.path,
334 )
335 .cmp(&(
336 b.toc
337 .as_ref()
338 .and_then(|toc| order.get(&toc.file_id).copied())
339 .unwrap_or(u32::MAX),
340 &b.path,
341 ))
342 });
343 let present: BTreeSet<_> = result
344 .sections
345 .iter()
346 .map(|section| section.file_id)
347 .chain(
348 result
349 .groups
350 .iter()
351 .filter_map(|group| group.toc.as_ref().map(|toc| toc.file_id)),
352 )
353 .collect();
354 if let Some(toc) = &mut result.toc {
355 toc.unresolved
356 .retain(|entry| !present.contains(&entry.file));
357 }
358 let mut observed = source
359 .entries(path, listing.len())
360 .map_err(|error| Error::Io {
361 path: path.into(),
362 error,
363 })?;
364 observed.sort();
365 if observed != listing {
366 return Err(Error::Changed { path: path.into() });
367 }
368 Ok(result)
369}
370
371fn component(name: &str) -> bool {
372 !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
373}
374
375fn join(parent: &str, name: &str) -> String {
376 if parent.is_empty() {
377 name.into()
378 } else {
379 format!("{parent}/{name}")
380 }
381}
crates/onestore-notebook/src/source.rs created+117
......@@ -0,0 +1,117 @@
1use super::{Entry, EntryKind, Source, component};
2use std::{
3 io,
4 path::{Path, PathBuf},
5};
6
7/// A canonical local root; symbolic-link entries are not traversed.
8pub struct Local {
9 root: PathBuf,
10}
11
12impl Local {
13 pub fn open(root: impl AsRef<Path>) -> io::Result<Self> {
14 let root = root.as_ref().canonicalize()?;
15 if !root.is_dir() {
16 return Err(io::ErrorKind::NotADirectory.into());
17 }
18 Ok(Self { root })
19 }
20
21 fn path(&self, relative: &str) -> io::Result<PathBuf> {
22 if !relative.is_empty() && !relative.split('/').all(component) {
23 return Err(io::ErrorKind::InvalidInput.into());
24 }
25 let path = self.root.join(relative).canonicalize()?;
26 if !path.starts_with(&self.root) {
27 return Err(io::ErrorKind::PermissionDenied.into());
28 }
29 Ok(path)
30 }
31}
32
33impl Source for Local {
34 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
35 let mut entries = Vec::new();
36 for entry in std::fs::read_dir(self.path(path)?)? {
37 let entry = entry?;
38 if entries.len() == limit {
39 return Err(io::ErrorKind::FileTooLarge.into());
40 }
41 let name = entry
42 .file_name()
43 .into_string()
44 .map_err(|_| io::ErrorKind::InvalidData)?;
45 let kind = entry.file_type()?;
46 entries.push(Entry {
47 name,
48 kind: if kind.is_dir() {
49 EntryKind::Directory
50 } else if kind.is_file() {
51 EntryKind::File
52 } else {
53 EntryKind::Other
54 },
55 });
56 }
57 Ok(entries)
58 }
59
60 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
61 onestore::read_file_limited(self.path(path)?, limit)
62 }
63}
64
65#[cfg(feature = "smb")]
66/// A share-relative root on an existing blocking SMB connection.
67pub struct Smb<'a> {
68 client: &'a onestore_smb::Client,
69 root: String,
70}
71
72#[cfg(feature = "smb")]
73impl<'a> Smb<'a> {
74 pub fn new(client: &'a onestore_smb::Client, root: &str) -> io::Result<Self> {
75 let root = root.replace('\\', "/");
76 if !root.is_empty() && !root.split('/').all(component) {
77 return Err(io::ErrorKind::InvalidInput.into());
78 }
79 Ok(Self { client, root })
80 }
81
82 fn path(&self, relative: &str) -> io::Result<String> {
83 if !relative.is_empty() && !relative.split('/').all(component) {
84 return Err(io::ErrorKind::InvalidInput.into());
85 }
86 Ok(if relative.is_empty() {
87 self.root.clone()
88 } else {
89 super::join(&self.root, relative)
90 })
91 }
92}
93
94#[cfg(feature = "smb")]
95impl Source for Smb<'_> {
96 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
97 Ok(self
98 .client
99 .read_dir(&self.path(path)?, limit)?
100 .into_iter()
101 .map(|entry| Entry {
102 name: entry.name,
103 kind: if entry.attributes & 0x400 != 0 {
104 EntryKind::Other
105 } else if entry.attributes & 0x10 != 0 {
106 EntryKind::Directory
107 } else {
108 EntryKind::File
109 },
110 })
111 .collect())
112 }
113
114 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
115 self.client.read_storage(&self.path(path)?, limit)
116 }
117}
crates/onestore-notebook/tests/discovery.rs created+280
......@@ -0,0 +1,280 @@
1use onestore_notebook::{Entry, Error, Limits, Local, Source, discover};
2use std::{fs, io, path::Path};
3
4fn limits() -> Limits {
5 Limits {
6 entries: 1000,
7 bytes_per_file: 16 * 1024 * 1024,
8 depth: 16,
9 }
10}
11
12fn fixture(root: &Path) -> Vec<u8> {
13 let section = onestore::create_section("one.one", "Retained 🦀", "Fixture").unwrap();
14 fs::write(root.join("one.one"), &section).unwrap();
15 let identity = onestore::Store::parse(&section).unwrap().header.file_id;
16 let toc = onestore::create_table_of_contents("Open Notebook.onetoc2", &[("one.one", identity)])
17 .unwrap();
18 fs::write(root.join("Open Notebook.onetoc2"), toc).unwrap();
19 section
20}
21
22#[test]
23fn rename_preserves_identity_and_does_not_trust_a_stale_cached_filename() {
24 let root = tempfile::tempdir().unwrap();
25 let section = fixture(root.path());
26 let mut source = Local::open(root.path()).unwrap();
27 let before = discover(&mut source, limits()).unwrap();
28 fs::rename(
29 root.path().join("one.one"),
30 root.path().join("Renamed 🦀.ONE"),
31 )
32 .unwrap();
33 let after = discover(&mut source, limits()).unwrap();
34 assert_eq!(before.sections[0].file_id, after.sections[0].file_id);
35 assert_eq!(after.sections[0].path, "Renamed 🦀.ONE");
36 assert!(after.toc.as_ref().unwrap().unresolved.is_empty());
37 assert_eq!(
38 fs::read(root.path().join("Renamed 🦀.ONE")).unwrap(),
39 section
40 );
41 fs::write(
42 root.path().join("one.one"),
43 onestore::create_section("one.one", "Different", "Fixture").unwrap(),
44 )
45 .unwrap();
46 let with_replacement = discover(&mut source, limits()).unwrap();
47 assert_eq!(
48 with_replacement.sections[0].file_id,
49 before.sections[0].file_id
50 );
51 assert_eq!(with_replacement.sections[0].path, "Renamed 🦀.ONE");
52 assert_ne!(
53 with_replacement.sections[1].file_id,
54 before.sections[0].file_id
55 );
56 fs::remove_file(root.path().join("Renamed 🦀.ONE")).unwrap();
57 let absent = discover(&mut source, limits()).unwrap();
58 assert_eq!(absent.toc.as_ref().unwrap().unresolved.len(), 1);
59 assert_eq!(
60 absent.toc.as_ref().unwrap().unresolved[0].file,
61 before.sections[0].file_id
62 );
63 assert_ne!(
64 absent.sections[0].file_id,
65 absent.toc.as_ref().unwrap().unresolved[0].file
66 );
67}
68
69#[test]
70fn copied_identities_are_ambiguous_even_across_different_groups() {
71 let root = tempfile::tempdir().unwrap();
72 fixture(root.path());
73 fs::create_dir(root.path().join("group")).unwrap();
74 fs::copy(
75 root.path().join("one.one"),
76 root.path().join("group/other.one"),
77 )
78 .unwrap();
79 assert!(
80 matches!(discover(&mut Local::open(root.path()).unwrap(), limits()),
81 Err(Error::DuplicateIdentity { first, second, .. })
82 if [first.as_str(), second.as_str()].contains(&"one.one")
83 && [first.as_str(), second.as_str()].contains(&"group/other.one"))
84 );
85}
86
87#[test]
88fn locked_and_unreadable_sections_retain_their_storage_identity() {
89 let root = tempfile::tempdir().unwrap();
90 for (name, fixture) in [
91 (
92 "locked.one",
93 "native-encrypted/encrypted-01/notebook/synthetic.one",
94 ),
95 (
96 "stub.one",
97 "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one",
98 ),
99 ] {
100 fs::copy(
101 Path::new("../../corpus").join(fixture),
102 root.path().join(name),
103 )
104 .unwrap();
105 }
106 let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
107 assert!(catalog.toc.is_none());
108 assert!(matches!(
109 catalog.sections[0].state,
110 onestore_notebook::SectionState::Locked
111 ));
112 assert!(matches!(
113 catalog.sections[1].state,
114 onestore_notebook::SectionState::Unreadable(_)
115 ));
116 for section in catalog.sections {
117 let bytes = fs::read(root.path().join(section.path)).unwrap();
118 assert_eq!(
119 section.file_id,
120 onestore::Store::parse(&bytes).unwrap().header.file_id
121 );
122 }
123}
124
125#[test]
126fn a_group_rename_retains_toc_identity_and_parent_order() {
127 let root = tempfile::tempdir().unwrap();
128 let native = Path::new("../../corpus/m6/native-features-01/notebook");
129 for relative in [
130 "Open Notebook.onetoc2",
131 "Group A/Open Notebook.onetoc2",
132 "Group A/Duplicate.one",
133 "Group B/Open Notebook.onetoc2",
134 "Group B/Nested/Open Notebook.onetoc2",
135 "Group B/Nested/Duplicate.one",
136 ] {
137 let target = root.path().join(relative);
138 fs::create_dir_all(target.parent().unwrap()).unwrap();
139 fs::copy(native.join(relative), target).unwrap();
140 }
141 let before = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
142 assert_eq!(before.groups[0].path, "Group A");
143 fs::rename(root.path().join("Group A"), root.path().join("Renamed 🦀")).unwrap();
144 let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
145 assert_eq!(catalog.groups[0].path, "Renamed 🦀");
146 assert_eq!(
147 catalog.groups[0].toc.as_ref().unwrap().file_id,
148 before.groups[0].toc.as_ref().unwrap().file_id
149 );
150 assert_eq!(catalog.groups[1].path, "Group B");
151 assert_eq!(
152 catalog.groups[0].sections[0].path,
153 "Renamed 🦀/Duplicate.one"
154 );
155 assert_eq!(
156 serde_json::to_value(&catalog.toc.unwrap().unresolved).unwrap(),
157 serde_json::to_value(&before.toc.unwrap().unresolved).unwrap()
158 );
159}
160
161#[test]
162fn limits_and_incomplete_files_do_not_produce_a_catalog() {
163 let root = tempfile::tempdir().unwrap();
164 fixture(root.path());
165 let mut source = Local::open(root.path()).unwrap();
166 assert!(
167 discover(
168 &mut source,
169 Limits {
170 entries: 1,
171 ..limits()
172 }
173 )
174 .is_err()
175 );
176 assert!(
177 discover(
178 &mut source,
179 Limits {
180 bytes_per_file: 8,
181 ..limits()
182 }
183 )
184 .is_err()
185 );
186 fs::create_dir(root.path().join("group")).unwrap();
187 assert!(matches!(
188 discover(
189 &mut source,
190 Limits {
191 depth: 0,
192 ..limits()
193 }
194 ),
195 Err(Error::Limit { .. })
196 ));
197 fs::write(root.path().join("one.one"), b"unfinished").unwrap();
198 assert!(
199 matches!(discover(&mut source, limits()), Err(Error::Document { path, .. }) if path == "one.one")
200 );
201}
202
203#[test]
204fn a_failed_refresh_retains_the_previous_catalog_as_an_independent_value() {
205 struct Changing {
206 source: Local,
207 reads: usize,
208 fail: bool,
209 }
210 impl Source for Changing {
211 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
212 self.reads += 1;
213 let mut entries = self.source.entries(path, limit)?;
214 if self.reads == 2 {
215 if self.fail {
216 return Err(io::ErrorKind::ConnectionAborted.into());
217 }
218 entries[0].name.push_str(".renamed");
219 }
220 Ok(entries)
221 }
222 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
223 self.source.read(path, limit)
224 }
225 }
226 let root = tempfile::tempdir().unwrap();
227 fixture(root.path());
228 let mut source = Local::open(root.path()).unwrap();
229 let catalog = discover(&mut source, limits()).unwrap();
230 let before = serde_json::to_value(&catalog).unwrap();
231 for fail in [false, true] {
232 let mut changing = Changing {
233 source: Local::open(root.path()).unwrap(),
234 reads: 0,
235 fail,
236 };
237 let error = discover(&mut changing, limits()).unwrap_err();
238 if fail {
239 assert!(
240 matches!(error, Error::Io { error, .. } if error.kind() == io::ErrorKind::ConnectionAborted)
241 );
242 } else {
243 assert!(matches!(error, Error::Changed { .. }));
244 }
245 assert_eq!(serde_json::to_value(&catalog).unwrap(), before);
246 }
247}
248
249#[test]
250fn local_access_stays_inside_the_selected_root() {
251 let root = tempfile::tempdir().unwrap();
252 fixture(root.path());
253 let mut source = Local::open(root.path()).unwrap();
254 for path in [
255 "../one.one",
256 "/one.one",
257 "x/../one.one",
258 "one.one\0",
259 "x\\one.one",
260 ] {
261 assert_eq!(
262 source.read(path, 100).unwrap_err().kind(),
263 io::ErrorKind::InvalidInput
264 );
265 }
266 #[cfg(unix)]
267 {
268 let other = tempfile::tempdir().unwrap();
269 fs::write(other.path().join("other.one"), b"outside").unwrap();
270 std::os::unix::fs::symlink(other.path().join("other.one"), root.path().join("link.one"))
271 .unwrap();
272 assert_eq!(
273 source.read("link.one", 100).unwrap_err().kind(),
274 io::ErrorKind::PermissionDenied
275 );
276 assert!(
277 matches!(discover(&mut source, limits()), Err(Error::Entry { path }) if path == "link.one")
278 );
279 }
280}
crates/onestore-smb/src/lib.rs+19-1
......@@ -200,8 +200,26 @@ impl Client {
200200
201201 /// Reads one bounded, consistent snapshot; contention returns WouldBlock.
202202 pub fn read(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
203 self.read_with(path, |file| {
204 onestore::read_snapshot(|offset, output| file.read_at(offset, output), limit)
205 })
206 }
207
208 /// Reads consistent storage, including encrypted or incomplete document graphs.
209 /// Storage validation alone does not establish edit readiness.
210 pub fn read_storage(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
211 self.read_with(path, |file| {
212 onestore::read_storage_snapshot(|offset, output| file.read_at(offset, output), limit)
213 })
214 }
215
216 fn read_with(
217 &self,
218 path: &str,
219 snapshot: impl FnOnce(&mut File<'_>) -> io::Result<Option<Vec<u8>>>,
220 ) -> io::Result<Vec<u8>> {
203221 let mut file = self.open(path, false)?.coordinate(path, false)?;
204 let result = onestore::read_snapshot(|offset, output| file.read_at(offset, output), limit)
222 let result = snapshot(&mut file)
205223 .and_then(|snapshot| snapshot.ok_or_else(|| io::ErrorKind::WouldBlock.into()));
206224 let closed = file.close();
207225 let snapshot = result?;
crates/onestore-smb/src/tests.rs+39-2
......@@ -149,8 +149,9 @@ fn text(bytes: &[u8]) -> (ExGuid, ExGuid, String) {
149149 .unwrap()
150150}
151151#[test]
152#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]
152#[ignore = "requires disposable Samba and an existing ONESTORE_SMB_EVIDENCE directory"]
153153fn live_coordination() {
154 let output = std::env::var("ONESTORE_SMB_EVIDENCE").unwrap();
154155 let writer = client();
155156 let path = format!(
156157 "adapter-{}.one",
......@@ -330,7 +331,6 @@ fn live_coordination() {
330331 .block_on(async {
331332 drop(spare);
332333 });
333 let output = std::env::var("ONESTORE_SMB_EVIDENCE").unwrap();
334334 fs::write(std::path::Path::new(&output).join("source.one"), &source).unwrap();
335335 fs::write(std::path::Path::new(&output).join("committed.one"), &after).unwrap();
336336 fs::write(
......@@ -344,6 +344,43 @@ fn live_coordination() {
344344 );
345345}
346346
347#[test]
348#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]
349fn live_storage_inspection() {
350 let reader = client();
351 for (index, fixture) in [
352 "native-encrypted/encrypted-01/notebook/synthetic.one",
353 "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one",
354 ]
355 .into_iter()
356 .enumerate()
357 {
358 let source = fs::read(format!("../../corpus/{fixture}")).unwrap();
359 let path = format!(
360 "inspection-{index}-{}.one",
361 SystemTime::now()
362 .duration_since(UNIX_EPOCH)
363 .unwrap()
364 .as_nanos()
365 );
366 create(&reader, &path, &source);
367 assert_eq!(reader.read_storage(&path, source.len()).unwrap(), source);
368 assert_eq!(
369 reader.read(&path, source.len()).unwrap_err().kind(),
370 io::ErrorKind::WouldBlock
371 );
372 let maintenance = client();
373 let guard = maintenance.open(&path, false).unwrap();
374 guard.lock(0xfffffffb, 0x12).unwrap();
375 assert_eq!(
376 reader.read_storage(&path, source.len()).unwrap_err().kind(),
377 io::ErrorKind::WouldBlock
378 );
379 guard.close().unwrap();
380 assert_eq!(reader.read_storage(&path, source.len()).unwrap(), source);
381 }
382}
383
347384#[test]
348385#[ignore = "requires an owned Samba fixture and maintenance controller"]
349386fn live_reader_hold() {
crates/onestore/README.md+1
......@@ -89,6 +89,7 @@ cargo run --example create_notebook -- /tmp/one-demo 'Hello from Rust.' 'Example
8989cargo run --example inventory -- /tmp/one-demo/synthetic.one
9090cargo run --example inspect -- /tmp/one-demo/synthetic.one
9191cargo run --example document -- /tmp/one-demo/synthetic.one /tmp/one-model
92cargo build -p onestore-diagnostic
9293python3 tools/notebook_report.py /tmp/one-demo /tmp/one-report --timezone America/Los_Angeles
9394```
9495
crates/onestore/src/commit.rs+14-1
......@@ -75,12 +75,25 @@ impl Drop for FileIo {
7575/// Native writers can expose incomplete graphs to unlocked filesystem reads.
7676#[cfg(any(unix, windows))]
7777pub fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
78 read_file_limited(path, usize::MAX)
79}
80
81/// Reads under whole-file exclusion, rejecting a snapshot larger than the byte limit.
82/// A size failure returns `FileTooLarge` without a partial snapshot.
83#[cfg(any(unix, windows))]
84pub fn read_file_limited(path: impl AsRef<Path>, limit: usize) -> io::Result<Vec<u8>> {
85 let count = u64::try_from(limit)
86 .map_err(|_| ErrorKind::InvalidInput)?
87 .saturating_add(1);
7888 let mut io = FileIo::open(path, false)?;
7989 let mut bytes = Vec::new();
80 let result = io.file.read_to_end(&mut bytes);
90 let result = (&mut io.file).take(count).read_to_end(&mut bytes);
8191 let released = io.release();
8292 result?;
8393 released?;
94 if bytes.len() > limit {
95 return Err(ErrorKind::FileTooLarge.into());
96 }
8497 Ok(bytes)
8598}
8699
crates/onestore/src/lib.rs+2-2
......@@ -22,7 +22,7 @@ pub use commit::{
2222 confirm_snapshot,
2323};
2424#[cfg(any(unix, windows))]
25pub use commit::{commit_file_property, commit_file_text, read_file};
25pub use commit::{commit_file_property, commit_file_text, read_file, read_file_limited};
2626pub use create::{create_section, create_table_of_contents};
2727pub use edit::replace_text;
2828pub use files::FileDataReference;
......@@ -31,6 +31,6 @@ pub use insertion::Insertion;
3131pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision};
3232pub use properties::{IdStream, Property, PropertySets, Value};
3333pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex};
34pub use snapshot::read_snapshot;
34pub use snapshot::{read_snapshot, read_storage_snapshot};
3535pub use store::{Chunk, Error, FileType, Header, Node, NodeList, Reference, Store};
3636pub use write::replace_property_bytes;
crates/onestore/src/snapshot.rs+21-1
......@@ -24,8 +24,28 @@ fn read_exact(
2424/// Reads a bounded snapshot; the caller must provide fresh I/O and exclude in-place maintenance.
2525/// Includes unpublished trailing bytes so subsequent commits can validate the physical file.
2626pub fn read_snapshot(
27 read: impl FnMut(u64, &mut [u8]) -> io::Result<usize>,
28 limit: usize,
29) -> io::Result<Option<Vec<u8>>> {
30 snapshot(read, limit, |store| {
31 RevisionIndex::parse(store)?.validate_current()
32 })
33}
34
35/// Reads stable storage and checksums without requiring traversable property references.
36/// Used to inspect encrypted or incomplete documents; this does not establish edit readiness.
37/// The caller must provide fresh I/O and exclude in-place maintenance, as for `read_snapshot`.
38pub fn read_storage_snapshot(
39 read: impl FnMut(u64, &mut [u8]) -> io::Result<usize>,
40 limit: usize,
41) -> io::Result<Option<Vec<u8>>> {
42 snapshot(read, limit, |_| Ok(()))
43}
44
45fn snapshot(
2746 mut read: impl FnMut(u64, &mut [u8]) -> io::Result<usize>,
2847 limit: usize,
48 validate: impl FnOnce(&Store<'_>) -> Result<(), crate::Error>,
2949) -> io::Result<Option<Vec<u8>>> {
3050 let mut header = [0; 1024];
3151 read_exact(&mut read, 0, &mut header)?;
......@@ -69,7 +89,7 @@ pub fn read_snapshot(
6989 if !store.checksum_mismatches.is_empty() {
7090 return Ok(false);
7191 }
72 RevisionIndex::parse(&store)?.validate_current()?;
92 validate(&store)?;
7393 Ok(true)
7494 });
7595 Ok(matches!(parsed, Ok(true)).then_some(bytes))
crates/onestore/src/store.rs+1-1
......@@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet};
33use std::fmt;
44use std::ops::Range;
55
6#[derive(Debug, Clone, PartialEq, Eq)]
6#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
77pub struct Error {
88 /// Parser byte offset; zero also represents errors without a byte location.
99 pub offset: usize,
crates/onestore/tests/commit.rs+37
......@@ -222,3 +222,40 @@ fn check_crashes(source: &[u8], osid: ExGuid, oid: ExGuid, property: u32, value:
222222 }
223223 }
224224}
225#[test]
226#[cfg(any(unix, windows))]
227fn bounded_file_reads_reject_partial_images_and_release_the_owner() {
228 use std::io::Write;
229 let path = std::env::temp_dir().join(format!(
230 "onestore-bounded-{}-{}",
231 std::process::id(),
232 std::time::SystemTime::now()
233 .duration_since(std::time::UNIX_EPOCH)
234 .unwrap()
235 .as_nanos()
236 ));
237 let mut file = fs::File::options()
238 .write(true)
239 .create_new(true)
240 .open(&path)
241 .unwrap();
242 assert!(onestore::read_file_limited(&path, 0).unwrap().is_empty());
243 let bytes: Vec<_> = (0..10_000).map(|i| (i % 251) as u8).collect();
244 file.write_all(&bytes).unwrap();
245 drop(file);
246 for limit in [0, 1, 100, bytes.len() - 1] {
247 assert_eq!(
248 onestore::read_file_limited(&path, limit)
249 .unwrap_err()
250 .kind(),
251 std::io::ErrorKind::FileTooLarge
252 );
253 assert_eq!(
254 onestore::read_file_limited(&path, bytes.len()).unwrap(),
255 bytes
256 );
257 }
258 assert_eq!(onestore::read_file(&path).unwrap(), bytes);
259 assert_eq!(fs::read(&path).unwrap(), bytes);
260 fs::remove_file(path).unwrap();
261}
crates/onestore/tests/shared_snapshot.rs+61
......@@ -15,6 +15,67 @@ use onestore::{
1515use std::{fs, io};
1616use trace::{Event, Trace};
1717
18#[test]
19fn storage_inspection_preserves_opaque_images_without_claiming_edit_readiness() {
20 for path in [
21 "native-encrypted/encrypted-01/notebook/synthetic.one",
22 "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one",
23 "malformed/native-inflight.one",
24 ] {
25 let source = fs::read(format!("../../corpus/{path}")).unwrap();
26 for block in [1, 17, 65536] {
27 let mut read = |offset: u64, output: &mut [u8]| {
28 let offset = usize::try_from(offset).unwrap();
29 let count = output
30 .len()
31 .min(block)
32 .min(source.len().saturating_sub(offset));
33 output[..count].copy_from_slice(&source[offset..offset + count]);
34 Ok(count)
35 };
36 assert_eq!(
37 onestore::read_storage_snapshot(&mut read, source.len()).unwrap(),
38 Some(source.clone())
39 );
40 assert!(read_snapshot(&mut read, source.len()).unwrap().is_none());
41 }
42 let mut headers = 0;
43 let changed = onestore::read_storage_snapshot(
44 |offset, output| {
45 let offset = usize::try_from(offset).unwrap();
46 let count = output.len().min(source.len().saturating_sub(offset));
47 output[..count].copy_from_slice(&source[offset..offset + count]);
48 if offset == 0 {
49 headers += 1;
50 if headers == 2 {
51 output[0] ^= 1;
52 }
53 }
54 Ok(count)
55 },
56 source.len(),
57 )
58 .unwrap();
59 assert!(changed.is_none());
60 let mut broken = source.clone();
61 let offset = usize::try_from(Store::parse(&source).unwrap().header.root.offset).unwrap();
62 broken[offset] ^= 1;
63 assert!(
64 onestore::read_storage_snapshot(
65 |offset, output| {
66 let offset = usize::try_from(offset).unwrap();
67 let count = output.len().min(broken.len().saturating_sub(offset));
68 output[..count].copy_from_slice(&broken[offset..offset + count]);
69 Ok(count)
70 },
71 broken.len()
72 )
73 .unwrap()
74 .is_none()
75 );
76 }
77}
78
1879fn target(bytes: &[u8]) -> (ExGuid, ExGuid, u32) {
1980 let store = Store::parse(bytes).unwrap();
2081 let index = RevisionIndex::parse(&store).unwrap();
tools/document_model.py+1
......@@ -3,6 +3,7 @@ import os
33from pathlib import Path
44
55EXPORTER = Path(os.environ.get('ONESTORE_DOCUMENT', Path(__file__).resolve().parent.parent / 'target/debug/examples/document'))
6BRIDGE = Path(__file__).resolve().parent.parent / 'target/debug/onestore-diagnostic'
67
78DEFAULT_CONTEXT = '{00000000-0000-0000-0000-000000000000},0'
89
tools/native/toc-controls.ps1 created+33
......@@ -0,0 +1,33 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
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 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebook, 0)
9 foreach ($name in @('Removed.one', 'Retired.one')) {
10 $section = ''
11 $app.OpenHierarchy($name, $notebook, [ref]$section, 3)
12 $page = ''
13 $app.CreateNewPage($section, [ref]$page, 2)
14 $xml = '<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="' + $page + '"><one:Outline><one:OEChildren><one:OE><one:T>' + $name + ' fixture.</one:T></one:OE></one:OEChildren></one:Outline></one:Page>'
15 $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false)
16 }
17 $app.SyncHierarchy($notebook)
18 $app.CloseNotebook($notebook, $false)
19 Copy-Item (Join-Path $Root 'notebook') (Join-Path $Root 'before') -Recurse
20 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebook, 0)
21 foreach ($name in @('Removed.one', 'Retired.one')) {
22 $section = ''
23 $app.OpenHierarchy($name, $notebook, [ref]$section, 0)
24 $app.DeleteHierarchy($section, [DateTime]::MinValue, ($name -eq 'Retired.one'))
25 }
26 $app.SyncHierarchy($notebook)
27} finally {
28 if ($notebook) { $app.CloseNotebook($notebook, $false) }
29 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
30 $app = $null
31 [GC]::Collect()
32 [GC]::WaitForPendingFinalizers()
33}
tools/native_runner.py+5-3
......@@ -116,7 +116,7 @@ def clone(output):
116116 (output / 'teardown.json').write_text(json.dumps({'absent': not vm.instance_path(name).exists()}) + '\n')
117117
118118
119def capture(notebook, output, expected_pages=-1, author=None, screenshots=False, pdf=False, author_timeout=600, inspect=False):
119def capture(notebook, output, expected_pages=-1, author=None, screenshots=False, pdf=False, author_timeout=600, inspect=False, collect_notebook=False):
120120 notebook = notebook.resolve(strict=True)
121121 if not notebook.is_dir():
122122 raise ValueError('Choose a notebook directory.')
......@@ -133,6 +133,7 @@ def capture(notebook, output, expected_pages=-1, author=None, screenshots=False,
133133 'notebook': str(notebook), 'expected_pages': expected_pages, 'author': str(author) if author else None,
134134 'author_timeout_seconds': author_timeout,
135135 'inspect': inspect,
136 'collect_notebook': collect_notebook,
136137 'base': json.loads(vm.BASE_MANIFEST.read_text()),
137138 'scripts': {name: hashlib.sha256((scripts / name).read_bytes()).hexdigest()
138139 for name in sorted(p.name for p in scripts.iterdir())},
......@@ -165,7 +166,7 @@ def capture(notebook, output, expected_pages=-1, author=None, screenshots=False,
165166 raise RuntimeError(result['error'])
166167 command(name, 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-%s' % name.upper(), output, author_timeout * 1000)
167168 command(name, 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-%s -ExpectedPages %d%s' % (name.upper(), expected_pages, (' -UseCurrentCache' if author else '') + (' -Pdf' if pdf else '') + (' -KeepOpen' if screenshots or inspect else '')), output, 600000)
168 collect_artifacts(name, output, '*' if author else 'read')
169 collect_artifacts(name, output, '*' if author or collect_notebook else 'read')
169170 if screenshots:
170171 for page in sorted((output / 'read').glob('page-*.xml')):
171172 page_id = ET.parse(page).getroot().attrib['ID']
......@@ -208,6 +209,7 @@ if __name__ == '__main__':
208209 parser.add_argument('--pdf', action='store_true')
209210 parser.add_argument('--screenshots', action='store_true')
210211 parser.add_argument('--inspect', action='store_true', help='Keep the clone open for inspection until an output/finish file appears, up to 30 minutes.')
212 parser.add_argument('--collect-notebook', action='store_true', help='Retain the VM notebook alongside its native read capture.')
211213 parser.add_argument('--author', type=Path, help='Run a fixture-authoring PowerShell script in the clone before capture.')
212214 parser.add_argument('--author-timeout', type=int, default=600, help='Authoring deadline in seconds.')
213215 args = parser.parse_args()
......@@ -216,5 +218,5 @@ if __name__ == '__main__':
216218 signal.signal(signal.SIGTERM, interrupted)
217219 if args.author_timeout <= 0:
218220 parser.error('The authoring deadline must be positive.')
219 capture(args.notebook, args.output, args.expected_pages, args.author, args.screenshots, args.pdf, args.author_timeout, args.inspect)
221 capture(args.notebook, args.output, args.expected_pages, args.author, args.screenshots, args.pdf, args.author_timeout, args.inspect, args.collect_notebook)
220222 print('Captured native evidence in', args.output)
tools/notebook_editor.py+1-2
......@@ -15,11 +15,10 @@ import time
1515from urllib.parse import parse_qs, urlsplit
1616import uuid
1717
18from document_model import walk
18from document_model import BRIDGE, walk
1919from notebook_report import generate
2020
2121ROOT = Path(__file__).resolve().parent.parent
22BRIDGE = ROOT / 'target/debug/onestore-diagnostic'
2322
2423
2524def bridge(mode, source, destination, edit=None):
tools/notebook_report.py+28-20
......@@ -16,7 +16,7 @@ import subprocess
1616from urllib.parse import urlsplit
1717from zoneinfo import ZoneInfo
1818
19from document_model import DEFAULT_CONTEXT, EXPORTER, ordered_pages, version_pages, view, walk
19from document_model import BRIDGE, DEFAULT_CONTEXT, EXPORTER, ordered_pages, version_pages, view, walk
2020
2121ROOT = Path(__file__).resolve().parent.parent
2222
......@@ -274,11 +274,27 @@ def generate(source, destination, native=None, versions=(), zone=timezone.utc, e
274274 (destination / 'model').mkdir(); (destination / 'assets').mkdir()
275275 cached = {row['path']: (row['sha256'], previous / 'model' / str(index))
276276 for index, row in enumerate(json.loads((previous / 'source.json').read_text()))} if previous else {}
277 sections = []; tocs = {}; manifest = []
278 for index, path in enumerate(sorted(p for p in source.rglob('*') if p.suffix.lower() in ('.one', '.onetoc2'))):
279 relative = path.relative_to(source)
277 result = json.loads(subprocess.check_output([BRIDGE, 'catalog', source], timeout=120))
278 if not result['ok']: raise ValueError(result['error'])
279 catalog = result['catalog']
280 (destination / 'catalog.json').write_text(json.dumps(catalog, indent=2, ensure_ascii=False))
281 files = []; catalog_sections = {}
282 def collect(folder):
283 if folder['toc'] is not None: files.append(Path(folder['path']) / folder['toc']['filename'])
284 for section in folder['sections']:
285 relative = Path(section['path'])
286 files.append(relative); catalog_sections[relative] = section
287 for group in folder['groups']: collect(group)
288 collect(catalog)
289 sections = []; unavailable = []; manifest = []
290 for index, relative in enumerate(sorted(files)):
291 path = source / relative
280292 before = path.read_bytes()
281293 manifest.append({'path': relative.as_posix(), 'sha256': hashlib.sha256(before).hexdigest(), 'bytes': len(before)})
294 state = catalog_sections.get(relative, {}).get('state', {})
295 if isinstance(state, dict) and 'Unreadable' in state:
296 unavailable.append((relative, state['Unreadable']))
297 continue
282298 exported = destination / 'model' / str(index)
283299 reusable = cached.get(relative.as_posix())
284300 reused = reusable is not None and reusable[0] == manifest[-1]['sha256']
......@@ -291,9 +307,6 @@ def generate(source, destination, native=None, versions=(), zone=timezone.utc, e
291307 document = json.loads((exported / 'document.json').read_text())
292308 if path.read_bytes() != before:
293309 raise ValueError('A source file changed during export.')
294 if path.suffix.lower() == '.onetoc2':
295 _, root = view(document, document['root'])
296 tocs[relative.parent] = [root['nodes'][oid]['kind'] for oid in root['nodes'][root['roots']['1']]['kind']['entries']]
297310 assets = {}
298311 previews = {}
299312 image_references = {
......@@ -339,20 +352,13 @@ def generate(source, destination, native=None, versions=(), zone=timezone.utc, e
339352 if path.suffix.lower() == '.one':
340353 sections.append({'path': relative, 'export': exported.relative_to(destination), 'document': document,
341354 'text': json.loads((exported / 'text.json').read_text()), 'assets': assets, 'previews': previews})
342 def order(section):
343 path = section['path']; entries = tocs.get(path.parent, [])
344 entry = next((entry for entry in entries if entry['identity'] == section['document']['file_id']), None)
345 result = []; parent = Path('.')
346 for part in path.parts[:-1]:
347 group = next((item for item in tocs.get(parent, []) if item['filename'] == part), None)
348 result.append((group['order'] if group and group['order'] is not None else 0xffffffff, part))
349 parent /= part
350 result.append((entry['order'] if entry and entry['order'] is not None else 0xffffffff, path.name))
351 return result
352 sections.sort(key=order)
355 order = {path: index for index, path in enumerate(catalog_sections)}
356 sections.sort(key=lambda section: order[section['path']])
353357 pages = []; locked_sections = []; histories = {}; nav = '<a href="index.html">Notebook review</a>'
354358 for section in sections:
355 name = section['path'].stem
359 state = catalog_sections[section['path']]['state']
360 name = state.get('Readable', {}).get('name') if isinstance(state, dict) else None
361 if name is None: name = section['path'].stem
356362 nav += '<h2>' + esc(str(section['path'].parent) + ' / ' + name) + '</h2>'
357363 _, root_space = view(section['document'], section['document']['root'])
358364 if root_space['nodes'][root_space['roots']['1']]['kind']['type'] == 'Encrypted':
......@@ -472,9 +478,11 @@ def generate(source, destination, native=None, versions=(), zone=timezone.utc, e
472478 accounting.append({'section': str(section['path']), 'ordinal': ordinal, 'space': sid, 'revision': rid, 'object': oid, 'title': title, 'report': filename, 'category': category, 'context': context, 'version_modified': version['modified'] if version else None, 'source_report': source_page, 'native_reference': reference.as_posix() if reference else None, 'rendered': dict(page.counts)})
473479 (destination / 'source.json').write_text(json.dumps(manifest, indent=2))
474480 (destination / 'pages.json').write_text(json.dumps(accounting, indent=2, ensure_ascii=False))
475 intro = '<h1>Notebook review</h1><p>' + str(len(sections)) + ' sections · ' + str(len(pages)) + ' stored pages</p><p>Readable content follows the stored object order. Outline positions are shown in points. The document structure retains properties and identities that the readable view does not interpret.</p><p><a href="source.json">Source hashes</a> · <a href="pages.json">Page inventory</a></p>'
481 intro = '<h1>Notebook review</h1><p>' + str(len(sections) + len(unavailable)) + ' sections · ' + str(len(pages)) + ' stored pages</p><p>Readable content follows the stored object order. Outline positions are shown in points. The document structure retains properties and identities that the readable view does not interpret.</p><p><a href="source.json">Source hashes</a> · <a href="pages.json">Page inventory</a> · <a href="catalog.json">Notebook catalog</a></p>'
476482 if locked_sections:
477483 intro += '<p>Page counts are unavailable for locked sections: ' + esc(', '.join(locked_sections)) + '.</p>'
484 for path, error in unavailable:
485 intro += '<p>Cannot read ' + esc(str(path)) + ': ' + esc(error['message']) + ' (offset ' + str(error['offset']) + ').</p>'
478486 (destination / 'index.html').write_text(html_page('Notebook review', nav, intro, editable))
479487
480488
tools/test_notebook_discovery.py created+67
......@@ -0,0 +1,67 @@
1"""Compare library discovery with retained native OneNote hierarchy captures."""
2import json
3import os
4from pathlib import Path
5import subprocess
6import unittest
7import xml.etree.ElementTree as ET
8
9ROOT = Path(__file__).resolve().parent.parent
10
11
12class NativeDiscovery(unittest.TestCase):
13 def test_native_hierarchies(self):
14 fixtures = ['m6/native-features-01', 'native-encrypted/cold-encrypted-02',
15 'native-delete/cold-deletion-05']
16 if os.environ.get('ONESTORE_NOTEBOOK_NATIVE'):
17 fixtures.append(str(Path(os.environ['ONESTORE_NOTEBOOK_NATIVE']).resolve()))
18 for fixture in fixtures:
19 with self.subTest(fixture=fixture):
20 source = ROOT / 'corpus' / fixture
21 catalog = json.loads(subprocess.check_output(
22 [str(ROOT / 'target/debug/examples/discover'), str(source / 'notebook')]))
23 hierarchy = ET.parse(source / 'read/hierarchy.xml').getroot()
24 notebook = next(node for node in hierarchy.iter() if node.tag.endswith('}Notebook'))
25
26 def path(value):
27 return '/'.join(part for part in value.replace('\\', '/').split('/') if part)
28
29 prefix = path(notebook.get('path'))
30
31 def native(node):
32 sections = []
33 groups = []
34 for child in node:
35 if child.tag.endswith('}Section'):
36 relative = path(child.get('path'))[len(prefix) + 1:]
37 name = child.get('name')
38 if Path(relative).parts[-2:] == ('OneNote_RecycleBin', 'OneNote_DeletedPages.one'):
39 self.assertEqual(name, 'Deleted Pages')
40 name = 'OneNote_DeletedPages'
41 sections.append((relative, name))
42 elif child.tag.endswith('}SectionGroup'):
43 groups.append(native(child))
44 return {'path': path(node.get('path'))[len(prefix) + 1:], 'sections': sections, 'groups': groups}
45
46 def actual(folder):
47 sections = []
48 for section in folder['sections']:
49 state = section['state']
50 name = state.get('Readable', {}).get('name') if isinstance(state, dict) else None
51 sections.append((section['path'], Path(section['path']).stem if name is None else name))
52 return {'path': folder['path'], 'sections': sections,
53 'groups': [actual(group) for group in folder['groups']]}
54
55 self.assertEqual(actual(catalog), native(notebook))
56 if fixture == 'm6/native-features-01':
57 self.assertEqual(len(catalog['toc']['unresolved']), 1)
58 self.assertEqual(catalog['toc']['unresolved'][0]['filename'], 'synthetic.one')
59 elif fixture == 'native-encrypted/cold-encrypted-02':
60 states = {section['path']: section['state'] for section in catalog['sections']}
61 self.assertEqual(states['synthetic.one'], 'Locked')
62 self.assertEqual(states['Open Notebook.one']['Unreadable']['message'],
63 'Object space has no revision manifest list')
64
65
66if __name__ == '__main__':
67 unittest.main()
tools/test_notebook_report.py+25-6
......@@ -5,15 +5,31 @@ from pathlib import Path
55import re
66from tempfile import TemporaryDirectory
77import shutil
8import subprocess
89import unittest
910from unittest.mock import patch
1011import xml.etree.ElementTree as ET
1112
1213from PIL import Image
1314from notebook_report import generate
15from document_model import EXPORTER
1416
1517
1618class NotebookReportTest(unittest.TestCase):
19 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'
21 with TemporaryDirectory() as temporary:
22 root = Path(temporary)
23 generate(fixture, root / 'first')
24 generate(fixture, root / 'cached', previous=root / 'first')
25 index = (root / 'first/index.html').read_text()
26 self.assertIn('2 sections', index)
27 self.assertIn('Locked section', index)
28 self.assertIn('Cannot read Open Notebook.one', index)
29 self.assertIn('Object space has no revision manifest list', index)
30 self.assertEqual(json.loads((root / 'first/pages.json').read_text()), [])
31 self.assertEqual(index, (root / 'cached/index.html').read_text())
32
1733 def test_tiff_preview_matches_native_pixels_and_retains_original_bytes(self):
1834 fixture = Path(__file__).resolve().parent.parent / 'corpus/m6/native-features-01'
1935 with TemporaryDirectory() as temporary:
......@@ -42,15 +58,18 @@ class NotebookReportTest(unittest.TestCase):
4258 shutil.copytree(fixture, source)
4359 generate(source, root / 'first')
4460 before = {p.relative_to(root / 'first'): p.read_bytes() for p in (root / 'first').rglob('*') if p.is_file()}
45 with patch('notebook_report.subprocess.run', side_effect=AssertionError('Unchanged model exported again')):
61 def cached_run(command, *args, **kwargs):
62 self.assertNotEqual(command[0], EXPORTER, 'Unchanged model exported again')
63 return original(command, *args, **kwargs)
64
65 original = subprocess.run
66 with patch('notebook_report.subprocess.run', side_effect=cached_run):
4667 generate(source, root / 'same', previous=root / 'first')
4768 self.assertEqual(before, {p.relative_to(root / 'same'): p.read_bytes() for p in (root / 'same').rglob('*') if p.is_file()})
48 shutil.copyfile(source / 'synthetic.one', source / 'a.one')
49 import notebook_report
50 original = notebook_report.subprocess.run
69 subprocess.run([EXPORTER.parent / 'create_section', source / 'a.one', 'Independent section', 'Fixture'], check=True)
5170 with patch('notebook_report.subprocess.run', wraps=original) as run:
5271 generate(source, root / 'changed', previous=root / 'first')
53 self.assertEqual([Path(call.args[0][1]).name for call in run.call_args_list], ['a.one'])
72 self.assertEqual([Path(call.args[0][1]).name for call in run.call_args_list if call.args[0][0] == EXPORTER], ['a.one'])
5473 generate(source, root / 'fresh')
5574 self.assertEqual({p.relative_to(root / 'fresh'): p.read_bytes() for p in (root / 'fresh').rglob('*') if p.is_file()},
5675 {p.relative_to(root / 'changed'): p.read_bytes() for p in (root / 'changed').rglob('*') if p.is_file()})
......@@ -61,7 +80,7 @@ class NotebookReportTest(unittest.TestCase):
6180 self.assertEqual((root / f'first/model/{old_index}/document.json').stat().st_ino,
6281 (root / f'changed/model/{new_index}/document.json').stat().st_ino)
6382 (source / 'a.one').unlink()
64 with patch('notebook_report.subprocess.run', side_effect=AssertionError('Unchanged model exported again')):
83 with patch('notebook_report.subprocess.run', side_effect=cached_run):
6584 generate(source, root / 'deleted', previous=root / 'changed')
6685 self.assertEqual(before, {p.relative_to(root / 'deleted'): p.read_bytes() for p in (root / 'deleted').rglob('*') if p.is_file()})
6786