authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-06 15:46:15-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-06 15:46:15-07:00
logb253974a925fe86b3f2ee31e969477fe2ae43fd3
treece9c5aaf243c3542d3dfbba629438deaabb2d199
parent98814e04e30acd58e15833964b0a216cf36b7a5b
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: offline support and real smb client


107 files changed, 17685 insertions(+), 1960 deletions(-)

API-AUDIT.md deleted-69
...@@ -1,69 +0,0 @@
1# Public API audit
2
3The current API is reasonable for a Rust reader/canvas prototype and the supported
4text-edit diagnostic tool. It is an experimental interoperability API, not yet a
5stable application SDK. The workspace move preserves every public name and its
6behavior; this audit adds contract documentation without introducing wrappers or
7changing serialization.
8
9## Consumer boundary
10
11```text
12owned snapshot bytes
13 └─ Store::parse committed storage; checksum diagnostics
14 └─ RevisionIndex::parse revision identities and dependencies
15 └─ Document::parse semantic views; checksum damage rejected
16 ├─ pages() active section pages in stored order
17 └─ text_runs() text with inherited formatting and links
18
19snapshot + typed identities + UTF-16 range + replacement
20 └─ commit_file_text lock → compare snapshot → append → flush
21 └─ Result<(), CommitError> success or explicit publication state
22```
23
24A document represents one `.one` or `.onetoc2` file, not a notebook directory.
25The directory, section ordering, native references and report assets are currently
26assembled by the tooling. A second crate can use the semantic model directly and
27keep its layout/cache state separate. Mutating the public model changes only the
28inspection view; writers reparse the supplied snapshot and enforce their own
29invariants.
30
31## Findings and decisions
32
33| Area | Assessment | Recommendation |
34| --- | --- | --- |
35| Read/write separation | Good: parsed views cannot accidentally save themselves. Snapshot comparison rejects stale writes before publication. | Keep explicit commit operations; do not add a mutable document plus generic `save()`. |
36| Commit outcomes | Good: `NotCommitted`, `Unknown` and `Committed` distinguish retry behavior. An error can still mean the edit persisted. | Keep this distinction at every UI/FFI boundary. Variant documentation now states the caller action. |
37| Borrowing and lifetimes | The model owns decoded strings but borrows payloads. Consumers must retain its source snapshot/store. An application object holding both bytes and borrowed views would require a different ownership design. | Let the canvas prototype determine whether its actual access pattern needs an owned model. Do not add a self-referential owner or duplicate document DTO in advance. |
38| Semantic error classification | `Error` exposes diagnostic text and an offset, with no typed reason. I/O failures already have `ErrorKind`; semantic distinctions such as unsupported edits and temporarily missing contexts require text matching today. | Before adding automatic semantic recovery, choose a small typed classification based on the recovery actions it needs. A detailed variant for every parser message would enlarge the compatibility burden. |
39| Editable text discovery | Readable text is broader than editable text: hidden fields, hyperlinks, equations, generated content, conflicts and protected content can be readable but rejected for writing. Currently callers can try `replace_text` and inspect its result; candidate discovery duplicates some checks in the random-edit example. | For the diagnostic tool, obtain eligibility from the writer's actual validation. If a public eligibility API is added, share that validation rather than maintain a second list of rules. |
40| Identity transport | `ExGuid` is typed, ordered, hashable and serializes to its display string. It has no `FromStr` or `Deserialize`. Existing tools retain or look up typed IDs instead of parsing them. | A Rust canvas can keep typed IDs. A JSON editing endpoint should either map strings back to IDs from its snapshot or justify a canonical parser with round-trip tests. |
41| Raw storage exports | `Header`, nodes, references, property arenas and both revision layers are public. They are useful for diagnostics, but expose implementation details to consumers. | Keep them available during interoperability work; have the UI depend on `onestore::document` plus edit functions. Moving them into a separate module now would create churn without an established consumer requirement. |
42| Public fields and enums | Inspection fields are mutable and enums are exhaustive. Downstream code can depend on the exact shape. | Treat the current Rust and JSON shapes as experimental. Whether to restrict construction or allow future enum variants is an API-evolution tradeoff to decide with the first consumer, not an assumed stability promise. |
43| Document boundaries | `pages()` excludes conflicts/history, returns no visible pages for encryption, and rejects TOC files. All referenced contexts remain in the model. | These boundaries are now documented on the method. Readers must inspect the root kind when distinguishing a locked section from an empty section. |
44| Units and defaults | Coordinates are points, edit/run offsets are UTF-16 units, Time32 values use the 1980 epoch, and `Format` preserves absence separately from explicit false. | Keep the native distinctions; renderers should use resolved runs and convert offsets explicitly when crossing into UTF-8 or browser selection APIs. |
45| In-memory replacements | `replace_text` and `replace_property_bytes` return complete byte images without locking or persistence. Overwriting a live file with those bytes would bypass the commit protocol. | Their documentation now directs existing-file updates to the corresponding commit functions. Raw scalar editing still requires the caller to maintain document semantics. |
46| Scope and dependencies | The library stays native and synchronous, with no network runtime, renderer, process management or platform UI dependency. Internal writer helpers remain crate-private. | Keep the core boundary. Put diagnostic serving and rendering in consumers; expose a C ABI only against concrete embedding needs. |
47
48The two decisions worth reviewing before the diagnostic tool are semantic error
49classification and edit eligibility. Neither requires a broad API redesign.
50The lifetime/ownership question can be informed by the separate canvas prototype.
51
52## Verification
53
54- Root workspace tests: `evidence/m8/workspace-tests.log`.
55- Clippy and formatting: `workspace-clippy.log`, `workspace-format.log`.
56- Rustdoc and compiled documentation example: `workspace-docs.log`,
57 `workspace-doctests.log`.
58- All eight cargo-fuzz targets build against the relocated library:
59 `workspace-fuzz-build.log`.
60- Python verification tools: 36 tests in `workspace-python.log`.
61- An independent crate under `evidence/m8/api-consumer` uses only public APIs to
62 create a section, traverse pages/runs, serialize identities, commit a Unicode
63 edit, reject a stale snapshot through typed commit/I/O state, and reopen the
64 exact resulting text. Output: `evidence/m8/api-consumer.log`.
65
66The audit examined exported declarations and their implementations in storage,
67revision resolution, document interpretation, creation, editing and persistence.
68It does not freeze the API or turn the earlier native compatibility evidence into
69a new platform guarantee.
Cargo.lock+843-1
...@@ -2,12 +2,101 @@...@@ -2,12 +2,101 @@
2# It is not intended for manual editing.2# It is not intended for manual editing.
3version = 43version = 4
44
5[[package]]
6name = "aead"
7version = "0.6.1"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
10dependencies = [
11 "crypto-common",
12 "inout",
13]
14
15[[package]]
16name = "aes"
17version = "0.9.3"
18source = "registry+https://github.com/rust-lang/crates.io-index"
19checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
20dependencies = [
21 "cipher",
22 "cpubits",
23 "cpufeatures",
24]
25
26[[package]]
27name = "aes-gcm"
28version = "0.11.1"
29source = "registry+https://github.com/rust-lang/crates.io-index"
30checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f"
31dependencies = [
32 "aead",
33 "aes",
34 "cipher",
35 "ctr",
36 "ctutils",
37 "ghash",
38]
39
40[[package]]
41name = "async-trait"
42version = "0.1.92"
43source = "registry+https://github.com/rust-lang/crates.io-index"
44checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
45dependencies = [
46 "proc-macro2",
47 "quote",
48 "syn 3.0.5",
49]
50
5[[package]]51[[package]]
6name = "bitflags"52name = "bitflags"
7version = "2.13.1"53version = "2.13.1"
8source = "registry+https://github.com/rust-lang/crates.io-index"54source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"55checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
1056
57[[package]]
58name = "block-buffer"
59version = "0.12.1"
60source = "registry+https://github.com/rust-lang/crates.io-index"
61checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
62dependencies = [
63 "hybrid-array",
64]
65
66[[package]]
67name = "bumpalo"
68version = "3.20.3"
69source = "registry+https://github.com/rust-lang/crates.io-index"
70checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
71
72[[package]]
73name = "bytes"
74version = "1.12.1"
75source = "registry+https://github.com/rust-lang/crates.io-index"
76checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
77
78[[package]]
79name = "cc"
80version = "1.4.5"
81source = "registry+https://github.com/rust-lang/crates.io-index"
82checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
83dependencies = [
84 "find-msvc-tools",
85 "shlex",
86]
87
88[[package]]
89name = "ccm"
90version = "0.6.0-rc.3"
91source = "registry+https://github.com/rust-lang/crates.io-index"
92checksum = "4edea5ea70a1285565ac264767613d6c88351a9a0557e7af793a0942590baaed"
93dependencies = [
94 "aead",
95 "cipher",
96 "ctr",
97 "subtle",
98]
99
11[[package]]100[[package]]
12name = "cfg-if"101name = "cfg-if"
13version = "1.0.4"102version = "1.0.4"
...@@ -20,6 +109,175 @@ version = "0.2.2"...@@ -20,6 +109,175 @@ version = "0.2.2"
20source = "registry+https://github.com/rust-lang/crates.io-index"109source = "registry+https://github.com/rust-lang/crates.io-index"
21checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"110checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
22111
112[[package]]
113name = "cipher"
114version = "0.5.2"
115source = "registry+https://github.com/rust-lang/crates.io-index"
116checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
117dependencies = [
118 "block-buffer",
119 "crypto-common",
120 "inout",
121]
122
123[[package]]
124name = "cmac"
125version = "0.8.0"
126source = "registry+https://github.com/rust-lang/crates.io-index"
127checksum = "ac78aa94ce13e432b332a4d1bf2eff167d3a2520188ee05b337180a42fd2e62e"
128dependencies = [
129 "cipher",
130 "dbl",
131 "digest",
132]
133
134[[package]]
135name = "cmov"
136version = "0.5.4"
137source = "registry+https://github.com/rust-lang/crates.io-index"
138checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
139
140[[package]]
141name = "const-oid"
142version = "0.10.2"
143source = "registry+https://github.com/rust-lang/crates.io-index"
144checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
145
146[[package]]
147name = "cpubits"
148version = "0.1.1"
149source = "registry+https://github.com/rust-lang/crates.io-index"
150checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
151
152[[package]]
153name = "cpufeatures"
154version = "0.3.1"
155source = "registry+https://github.com/rust-lang/crates.io-index"
156checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
157dependencies = [
158 "libc",
159]
160
161[[package]]
162name = "crypto-common"
163version = "0.2.2"
164source = "registry+https://github.com/rust-lang/crates.io-index"
165checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
166dependencies = [
167 "getrandom",
168 "hybrid-array",
169 "rand_core",
170]
171
172[[package]]
173name = "ctr"
174version = "0.10.1"
175source = "registry+https://github.com/rust-lang/crates.io-index"
176checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
177dependencies = [
178 "cipher",
179]
180
181[[package]]
182name = "ctutils"
183version = "0.4.2"
184source = "registry+https://github.com/rust-lang/crates.io-index"
185checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
186dependencies = [
187 "cmov",
188]
189
190[[package]]
191name = "dbl"
192version = "0.5.0"
193source = "registry+https://github.com/rust-lang/crates.io-index"
194checksum = "f0d7a944e61df464668c5f51f56cc667396a8821434273112948ea0b66e405d7"
195dependencies = [
196 "hybrid-array",
197]
198
199[[package]]
200name = "digest"
201version = "0.11.3"
202source = "registry+https://github.com/rust-lang/crates.io-index"
203checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
204dependencies = [
205 "block-buffer",
206 "const-oid",
207 "crypto-common",
208 "ctutils",
209]
210
211[[package]]
212name = "equivalent"
213version = "1.0.2"
214source = "registry+https://github.com/rust-lang/crates.io-index"
215checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
216
217[[package]]
218name = "errno"
219version = "0.3.14"
220source = "registry+https://github.com/rust-lang/crates.io-index"
221checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
222dependencies = [
223 "libc",
224 "windows-sys",
225]
226
227[[package]]
228name = "fallible-iterator"
229version = "0.3.0"
230source = "registry+https://github.com/rust-lang/crates.io-index"
231checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
232
233[[package]]
234name = "fallible-streaming-iterator"
235version = "0.1.9"
236source = "registry+https://github.com/rust-lang/crates.io-index"
237checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
238
239[[package]]
240name = "fastrand"
241version = "2.5.0"
242source = "registry+https://github.com/rust-lang/crates.io-index"
243checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
244
245[[package]]
246name = "find-msvc-tools"
247version = "0.1.12"
248source = "registry+https://github.com/rust-lang/crates.io-index"
249checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
250
251[[package]]
252name = "foldhash"
253version = "0.2.0"
254source = "registry+https://github.com/rust-lang/crates.io-index"
255checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
256
257[[package]]
258name = "futures-core"
259version = "0.3.34"
260source = "registry+https://github.com/rust-lang/crates.io-index"
261checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
262
263[[package]]
264name = "futures-task"
265version = "0.3.34"
266source = "registry+https://github.com/rust-lang/crates.io-index"
267checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
268
269[[package]]
270name = "futures-util"
271version = "0.3.34"
272source = "registry+https://github.com/rust-lang/crates.io-index"
273checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
274dependencies = [
275 "futures-core",
276 "futures-task",
277 "pin-project-lite",
278 "slab",
279]
280
23[[package]]281[[package]]
24name = "getrandom"282name = "getrandom"
25version = "0.4.3"283version = "0.4.3"
...@@ -29,6 +287,80 @@ dependencies = [...@@ -29,6 +287,80 @@ dependencies = [
29 "cfg-if",287 "cfg-if",
30 "libc",288 "libc",
31 "r-efi",289 "r-efi",
290 "rand_core",
291]
292
293[[package]]
294name = "ghash"
295version = "0.6.0"
296source = "registry+https://github.com/rust-lang/crates.io-index"
297checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
298dependencies = [
299 "polyval",
300]
301
302[[package]]
303name = "hashbrown"
304version = "0.16.1"
305source = "registry+https://github.com/rust-lang/crates.io-index"
306checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
307dependencies = [
308 "foldhash",
309]
310
311[[package]]
312name = "hashbrown"
313version = "0.17.1"
314source = "registry+https://github.com/rust-lang/crates.io-index"
315checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
316dependencies = [
317 "foldhash",
318]
319
320[[package]]
321name = "hashlink"
322version = "0.12.1"
323source = "registry+https://github.com/rust-lang/crates.io-index"
324checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
325dependencies = [
326 "hashbrown 0.17.1",
327]
328
329[[package]]
330name = "hmac"
331version = "0.13.0"
332source = "registry+https://github.com/rust-lang/crates.io-index"
333checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
334dependencies = [
335 "digest",
336]
337
338[[package]]
339name = "hybrid-array"
340version = "0.4.14"
341source = "registry+https://github.com/rust-lang/crates.io-index"
342checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
343dependencies = [
344 "typenum",
345]
346
347[[package]]
348name = "indexmap"
349version = "2.14.2"
350source = "registry+https://github.com/rust-lang/crates.io-index"
351checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
352dependencies = [
353 "equivalent",
354 "hashbrown 0.17.1",
355]
356
357[[package]]
358name = "inout"
359version = "0.2.2"
360source = "registry+https://github.com/rust-lang/crates.io-index"
361checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
362dependencies = [
363 "hybrid-array",
32]364]
33365
34[[package]]366[[package]]
...@@ -37,12 +369,73 @@ version = "1.0.18"...@@ -37,12 +369,73 @@ version = "1.0.18"
37source = "registry+https://github.com/rust-lang/crates.io-index"369source = "registry+https://github.com/rust-lang/crates.io-index"
38checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"370checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
39371
372[[package]]
373name = "js-sys"
374version = "0.3.105"
375source = "registry+https://github.com/rust-lang/crates.io-index"
376checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
377dependencies = [
378 "cfg-if",
379 "wasm-bindgen",
380]
381
40[[package]]382[[package]]
41name = "libc"383name = "libc"
42version = "0.2.189"384version = "0.2.189"
43source = "registry+https://github.com/rust-lang/crates.io-index"385source = "registry+https://github.com/rust-lang/crates.io-index"
44checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"386checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
45387
388[[package]]
389name = "libsqlite3-sys"
390version = "0.38.2"
391source = "registry+https://github.com/rust-lang/crates.io-index"
392checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
393dependencies = [
394 "cc",
395 "pkg-config",
396 "vcpkg",
397]
398
399[[package]]
400name = "linux-raw-sys"
401version = "0.12.1"
402source = "registry+https://github.com/rust-lang/crates.io-index"
403checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
404
405[[package]]
406name = "log"
407version = "0.4.34"
408source = "registry+https://github.com/rust-lang/crates.io-index"
409checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
410
411[[package]]
412name = "lz4_flex"
413version = "0.13.1"
414source = "registry+https://github.com/rust-lang/crates.io-index"
415checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
416dependencies = [
417 "twox-hash",
418]
419
420[[package]]
421name = "md-5"
422version = "0.11.0"
423source = "registry+https://github.com/rust-lang/crates.io-index"
424checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
425dependencies = [
426 "cfg-if",
427 "digest",
428]
429
430[[package]]
431name = "md4"
432version = "0.11.0"
433source = "registry+https://github.com/rust-lang/crates.io-index"
434checksum = "bd76fb0fd6b2e4be62a73f8e0858ca97f81babcb1af322dcaca196f735f17f80"
435dependencies = [
436 "digest",
437]
438
46[[package]]439[[package]]
47name = "md5"440name = "md5"
48version = "0.8.1"441version = "0.8.1"
...@@ -55,6 +448,17 @@ version = "2.8.3"...@@ -55,6 +448,17 @@ version = "2.8.3"
55source = "registry+https://github.com/rust-lang/crates.io-index"448source = "registry+https://github.com/rust-lang/crates.io-index"
56checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"449checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
57450
451[[package]]
452name = "mio"
453version = "1.2.3"
454source = "registry+https://github.com/rust-lang/crates.io-index"
455checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
456dependencies = [
457 "libc",
458 "wasi",
459 "windows-sys",
460]
461
58[[package]]462[[package]]
59name = "nix"463name = "nix"
60version = "0.31.3"464version = "0.31.3"
...@@ -67,6 +471,34 @@ dependencies = [...@@ -67,6 +471,34 @@ dependencies = [
67 "libc",471 "libc",
68]472]
69473
474[[package]]
475name = "num_enum"
476version = "0.7.6"
477source = "registry+https://github.com/rust-lang/crates.io-index"
478checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
479dependencies = [
480 "num_enum_derive",
481 "rustversion",
482]
483
484[[package]]
485name = "num_enum_derive"
486version = "0.7.6"
487source = "registry+https://github.com/rust-lang/crates.io-index"
488checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
489dependencies = [
490 "proc-macro-crate",
491 "proc-macro2",
492 "quote",
493 "syn 2.0.119",
494]
495
496[[package]]
497name = "once_cell"
498version = "1.21.4"
499source = "registry+https://github.com/rust-lang/crates.io-index"
500checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
501
70[[package]]502[[package]]
71name = "onestore"503name = "onestore"
72version = "0.1.0"504version = "0.1.0"
...@@ -78,6 +510,80 @@ dependencies = [...@@ -78,6 +510,80 @@ dependencies = [
78 "serde_json",510 "serde_json",
79]511]
80512
513[[package]]
514name = "onestore-diagnostic"
515version = "0.1.0"
516dependencies = [
517 "onestore",
518 "serde",
519 "serde_json",
520]
521
522[[package]]
523name = "onestore-offline"
524version = "0.1.0"
525dependencies = [
526 "onestore",
527 "onestore-smb",
528 "rusqlite",
529 "serde",
530 "serde_json",
531 "tempfile",
532 "thiserror",
533]
534
535[[package]]
536name = "onestore-smb"
537version = "0.1.0"
538dependencies = [
539 "onestore",
540 "serde_json",
541 "smb2",
542 "tokio",
543]
544
545[[package]]
546name = "pbkdf2"
547version = "0.13.0"
548source = "registry+https://github.com/rust-lang/crates.io-index"
549checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
550dependencies = [
551 "digest",
552 "hmac",
553]
554
555[[package]]
556name = "pin-project-lite"
557version = "0.2.17"
558source = "registry+https://github.com/rust-lang/crates.io-index"
559checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
560
561[[package]]
562name = "pkg-config"
563version = "0.3.34"
564source = "registry+https://github.com/rust-lang/crates.io-index"
565checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
566
567[[package]]
568name = "polyval"
569version = "0.7.3"
570source = "registry+https://github.com/rust-lang/crates.io-index"
571checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
572dependencies = [
573 "cpubits",
574 "cpufeatures",
575 "universal-hash",
576]
577
578[[package]]
579name = "proc-macro-crate"
580version = "3.5.0"
581source = "registry+https://github.com/rust-lang/crates.io-index"
582checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
583dependencies = [
584 "toml_edit",
585]
586
81[[package]]587[[package]]
82name = "proc-macro2"588name = "proc-macro2"
83version = "1.0.107"589version = "1.0.107"
...@@ -102,6 +608,56 @@ version = "6.0.0"...@@ -102,6 +608,56 @@ version = "6.0.0"
102source = "registry+https://github.com/rust-lang/crates.io-index"608source = "registry+https://github.com/rust-lang/crates.io-index"
103checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"609checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
104610
611[[package]]
612name = "rand_core"
613version = "0.10.1"
614source = "registry+https://github.com/rust-lang/crates.io-index"
615checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
616
617[[package]]
618name = "rsqlite-vfs"
619version = "0.1.1"
620source = "registry+https://github.com/rust-lang/crates.io-index"
621checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
622dependencies = [
623 "hashbrown 0.16.1",
624 "thiserror",
625]
626
627[[package]]
628name = "rusqlite"
629version = "0.40.2"
630source = "registry+https://github.com/rust-lang/crates.io-index"
631checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
632dependencies = [
633 "bitflags",
634 "fallible-iterator",
635 "fallible-streaming-iterator",
636 "hashlink",
637 "libsqlite3-sys",
638 "smallvec",
639 "sqlite-wasm-rs",
640]
641
642[[package]]
643name = "rustix"
644version = "1.1.4"
645source = "registry+https://github.com/rust-lang/crates.io-index"
646checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
647dependencies = [
648 "bitflags",
649 "errno",
650 "libc",
651 "linux-raw-sys",
652 "windows-sys",
653]
654
655[[package]]
656name = "rustversion"
657version = "1.0.23"
658source = "registry+https://github.com/rust-lang/crates.io-index"
659checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
660
105[[package]]661[[package]]
106name = "serde"662name = "serde"
107version = "1.0.229"663version = "1.0.229"
...@@ -129,7 +685,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"...@@ -129,7 +685,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
129dependencies = [685dependencies = [
130 "proc-macro2",686 "proc-macro2",
131 "quote",687 "quote",
132 "syn",688 "syn 3.0.5",
133]689]
134690
135[[package]]691[[package]]
...@@ -145,6 +701,112 @@ dependencies = [...@@ -145,6 +701,112 @@ dependencies = [
145 "zmij",701 "zmij",
146]702]
147703
704[[package]]
705name = "sha1"
706version = "0.11.0"
707source = "registry+https://github.com/rust-lang/crates.io-index"
708checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
709dependencies = [
710 "cfg-if",
711 "cpufeatures",
712 "digest",
713]
714
715[[package]]
716name = "sha2"
717version = "0.11.0"
718source = "registry+https://github.com/rust-lang/crates.io-index"
719checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
720dependencies = [
721 "cfg-if",
722 "cpufeatures",
723 "digest",
724]
725
726[[package]]
727name = "shlex"
728version = "2.0.1"
729source = "registry+https://github.com/rust-lang/crates.io-index"
730checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
731
732[[package]]
733name = "slab"
734version = "0.4.12"
735source = "registry+https://github.com/rust-lang/crates.io-index"
736checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
737
738[[package]]
739name = "smallvec"
740version = "1.16.0"
741source = "registry+https://github.com/rust-lang/crates.io-index"
742checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
743
744[[package]]
745name = "smb2"
746version = "0.21.0"
747source = "registry+https://github.com/rust-lang/crates.io-index"
748checksum = "9ef4f20cff3d39a131335d17df6146eada7851d7705252f2765b8717a0f52db8"
749dependencies = [
750 "aes",
751 "aes-gcm",
752 "async-trait",
753 "ccm",
754 "cmac",
755 "digest",
756 "futures-util",
757 "getrandom",
758 "hmac",
759 "log",
760 "lz4_flex",
761 "md-5",
762 "md4",
763 "num_enum",
764 "pbkdf2",
765 "sha1",
766 "sha2",
767 "thiserror",
768 "tokio",
769]
770
771[[package]]
772name = "socket2"
773version = "0.6.5"
774source = "registry+https://github.com/rust-lang/crates.io-index"
775checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
776dependencies = [
777 "libc",
778 "windows-sys",
779]
780
781[[package]]
782name = "sqlite-wasm-rs"
783version = "0.5.5"
784source = "registry+https://github.com/rust-lang/crates.io-index"
785checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
786dependencies = [
787 "cc",
788 "js-sys",
789 "rsqlite-vfs",
790 "wasm-bindgen",
791]
792
793[[package]]
794name = "subtle"
795version = "2.6.1"
796source = "registry+https://github.com/rust-lang/crates.io-index"
797checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
798
799[[package]]
800name = "syn"
801version = "2.0.119"
802source = "registry+https://github.com/rust-lang/crates.io-index"
803checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
804dependencies = [
805 "proc-macro2",
806 "quote",
807 "unicode-ident",
808]
809
148[[package]]810[[package]]
149name = "syn"811name = "syn"
150version = "3.0.5"812version = "3.0.5"
...@@ -156,12 +818,192 @@ dependencies = [...@@ -156,12 +818,192 @@ dependencies = [
156 "unicode-ident",818 "unicode-ident",
157]819]
158820
821[[package]]
822name = "tempfile"
823version = "3.27.0"
824source = "registry+https://github.com/rust-lang/crates.io-index"
825checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
826dependencies = [
827 "fastrand",
828 "getrandom",
829 "once_cell",
830 "rustix",
831 "windows-sys",
832]
833
834[[package]]
835name = "thiserror"
836version = "2.0.20"
837source = "registry+https://github.com/rust-lang/crates.io-index"
838checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
839dependencies = [
840 "thiserror-impl",
841]
842
843[[package]]
844name = "thiserror-impl"
845version = "2.0.20"
846source = "registry+https://github.com/rust-lang/crates.io-index"
847checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
848dependencies = [
849 "proc-macro2",
850 "quote",
851 "syn 3.0.5",
852]
853
854[[package]]
855name = "tokio"
856version = "1.53.1"
857source = "registry+https://github.com/rust-lang/crates.io-index"
858checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
859dependencies = [
860 "bytes",
861 "libc",
862 "mio",
863 "pin-project-lite",
864 "socket2",
865 "windows-sys",
866]
867
868[[package]]
869name = "toml_datetime"
870version = "1.1.1+spec-1.1.0"
871source = "registry+https://github.com/rust-lang/crates.io-index"
872checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
873dependencies = [
874 "serde_core",
875]
876
877[[package]]
878name = "toml_edit"
879version = "0.25.13+spec-1.1.0"
880source = "registry+https://github.com/rust-lang/crates.io-index"
881checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
882dependencies = [
883 "indexmap",
884 "toml_datetime",
885 "toml_parser",
886 "winnow",
887]
888
889[[package]]
890name = "toml_parser"
891version = "1.1.3+spec-1.1.0"
892source = "registry+https://github.com/rust-lang/crates.io-index"
893checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
894dependencies = [
895 "winnow",
896]
897
898[[package]]
899name = "twox-hash"
900version = "2.1.4"
901source = "registry+https://github.com/rust-lang/crates.io-index"
902checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a"
903
904[[package]]
905name = "typenum"
906version = "1.20.1"
907source = "registry+https://github.com/rust-lang/crates.io-index"
908checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
909
159[[package]]910[[package]]
160name = "unicode-ident"911name = "unicode-ident"
161version = "1.0.24"912version = "1.0.24"
162source = "registry+https://github.com/rust-lang/crates.io-index"913source = "registry+https://github.com/rust-lang/crates.io-index"
163checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"914checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
164915
916[[package]]
917name = "universal-hash"
918version = "0.6.1"
919source = "registry+https://github.com/rust-lang/crates.io-index"
920checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
921dependencies = [
922 "crypto-common",
923 "ctutils",
924]
925
926[[package]]
927name = "vcpkg"
928version = "0.2.15"
929source = "registry+https://github.com/rust-lang/crates.io-index"
930checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
931
932[[package]]
933name = "wasi"
934version = "0.11.1+wasi-snapshot-preview1"
935source = "registry+https://github.com/rust-lang/crates.io-index"
936checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
937
938[[package]]
939name = "wasm-bindgen"
940version = "0.2.128"
941source = "registry+https://github.com/rust-lang/crates.io-index"
942checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
943dependencies = [
944 "cfg-if",
945 "once_cell",
946 "rustversion",
947 "wasm-bindgen-macro",
948 "wasm-bindgen-shared",
949]
950
951[[package]]
952name = "wasm-bindgen-macro"
953version = "0.2.128"
954source = "registry+https://github.com/rust-lang/crates.io-index"
955checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
956dependencies = [
957 "quote",
958 "wasm-bindgen-macro-support",
959]
960
961[[package]]
962name = "wasm-bindgen-macro-support"
963version = "0.2.128"
964source = "registry+https://github.com/rust-lang/crates.io-index"
965checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
966dependencies = [
967 "bumpalo",
968 "proc-macro2",
969 "quote",
970 "syn 3.0.5",
971 "wasm-bindgen-shared",
972]
973
974[[package]]
975name = "wasm-bindgen-shared"
976version = "0.2.128"
977source = "registry+https://github.com/rust-lang/crates.io-index"
978checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
979dependencies = [
980 "unicode-ident",
981]
982
983[[package]]
984name = "windows-link"
985version = "0.2.1"
986source = "registry+https://github.com/rust-lang/crates.io-index"
987checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
988
989[[package]]
990name = "windows-sys"
991version = "0.61.2"
992source = "registry+https://github.com/rust-lang/crates.io-index"
993checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
994dependencies = [
995 "windows-link",
996]
997
998[[package]]
999name = "winnow"
1000version = "1.0.4"
1001source = "registry+https://github.com/rust-lang/crates.io-index"
1002checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
1003dependencies = [
1004 "memchr",
1005]
1006
165[[package]]1007[[package]]
166name = "zmij"1008name = "zmij"
167version = "1.0.23"1009version = "1.0.23"
FEATURES.md deleted-29
...@@ -1,29 +0,0 @@
1# Milestone 6 feature matrix
2
3The matrix follows MS-ONE's document families, including features absent from the
4personal corpus. Each row requires an independently authored native fixture,
5Rust interpretation checks, and a review-report inspection before acceptance.
6A retained opaque payload is evidence of preservation, not interpreted coverage.
7
8| Family | Required cases | Specification Evidence |
9| --- | --- | --- --- |
10| Notebook structure | Multiple notebooks, section groups, section ordering/colors, duplicate names, empty sections | 2.2.14–18, 2.2.91–96 [Native feature controls](corpus/m6/native-features-01/ORACLE.md); [Rust scale](evidence/m6/rust-scale-final-01.log) |
11| Pages | Titles/alternate titles, order, subpages, duplicate text/titles, authors/timestamps, RTL | 2.2.19, 2.2.29–31, 2.2.58, 2.3.74 [Direction/origin controls](corpus/m6/native-page-direction-03); [private review](evidence/m6/private-current-report-05/qa/review.json) |
12| Outline hierarchy | Positioned outlines, nesting, outline groups, indentation, collapsed/hidden content | 2.2.20–23, 2.3.8, 2.3.18–19 [Structure](corpus/m6/native-structure-01/ORACLE.md); [saved collapse](corpus/m6/rust-collapse-control-01/ORACLE.md) |
13| Text | ASCII/Unicode preference, surrogate pairs, combining marks, mixed scripts, empty/long paragraphs | 2.1.4, 2.2.5, 2.2.23, 2.2.89 [100 native histories](evidence/m6/native-histories-independent-03.log); [break controls](corpus/m6/native-break-controls-02) |
14| Character formatting | Mixed runs, font/size/color/highlight, bold/italic/underline/strike, super/subscript, language | 2.2.43–45, 2.2.76–77, 2.3.9–16 [Native probes](corpus/m6/native-probes-01/ORACLE.md); [private native comparison](evidence/m6/private-current-compare-01.log) |
15| Paragraph formatting | Named styles, alignment, spacing, RTL, style inheritance | 2.2.44, 2.2.80, 2.2.83, 2.3.81–83 [Structure](corpus/m6/native-structure-01/ORACLE.md); [feature report inspection](evidence/m6/features-report-05/qa/review.json) |
16| Lists | Bullets, numbering, restarts, mixed indentation, custom fonts/formats | 2.2.25, 2.2.57, 2.3.20, 2.3.43 [Structure](corpus/m6/native-structure-01/ORACLE.md); [numbering controls](corpus/m6/native-features-01/ORACLE.md) |
17| Tables | Multiple rows/columns, nested content, widths, shading, borders, locked columns | 2.2.26–28, 2.2.66, 2.2.70, 2.2.97 [Widths/locks](corpus/m6/native-structure-01/ORACLE.md); [RTL](corpus/m6/native-page-direction-03); [shading limitation](corpus/m6/cell-shading-control-01/ORACLE.md) |
18| Links | External/internal links, formatted labels, embedded text-run data | 2.2.78, 2.2.82, 2.2.90, 2.3.75–77 [Native link controls](corpus/m6/native-link-controls-01); [empty-label regression](corpus/m6/native-empty-link-01) |
19| Images | Multiple formats, sizes, alt text, filenames, internal/external containers, background/printout images | 2.2.24, 2.2.36, 2.2.59, 2.2.75, 2.2.79 [Native formats/roles](corpus/m6/native-features-01/ORACLE.md); [browser inspection](evidence/m6/features-report-05/qa/review.json) |
20| Files and media | Attachments, original filenames, recording identifiers, audio/video payloads and associated text | 2.2.32–33, 2.2.60–61, 2.2.71–72 [Native attachment/recording controls](corpus/m6/native-features-01/ORACLE.md) |
21| Ink and embedded objects | Strokes, placement, native payload/export comparison, math and embedded text-run objects | Native ink corpus; 2.3.79–80 [Native ink](corpus/native-ink/cold-ui-ink); [math](corpus/m6/native-math-01/ORACLE.md): payload/run preservation, opaque rendering |
22| Tags and tasks | Standard/custom tags, multiple tags, checked/unchecked state, task status/dates | 2.1.9, 2.2.41–42, 2.2.84–88, 2.3.85–96 [Native task controls](corpus/m6/native-features-01/ORACLE.md); [private tags](evidence/m6/private-current-compare-01.log) |
23| History and recovery | Version pages/contexts, recycle-bin pages, conflicts and their source relationships | 2.1.1–2, 2.1.17, 2.2.34–40, 2.2.86 [Private history review](evidence/m6/private-current-report-05/qa/review.json); [live conflicts/recovery](corpus/m6/live-collaboration-15/ORACLE.md) |
24| Protection and unknowns | Locked section identity/ciphertext, unknown JCIDs/properties, malformed structures | MS-ONESTORE encryption; MS-ONE 2.1.5, 2.1.12 [Existing corpus checks](evidence/m6/corpus-regression-01.log); [document fuzzing](evidence/m6/document-public-fuzz-08.log): ciphertext/raw data preserved |
25| Scale and interactions | Rust/native large notebooks, many pages/objects/assets, repeated identities/text, mixed feature histories | All applicable rows [Independent native histories](evidence/m6/native-histories-independent-03.log); [Rust scale](evidence/m6/rust-scale-final-01.log) |
26
27[M6-ACCEPTANCE.md](M6-ACCEPTANCE.md) records the native builds, comparisons,
28report inspections, regression campaigns and interpretation boundaries. `evidence/m6/spec-objects.json`
29is the current object-definition inventory extracted from the supplied markdown.
M6-ACCEPTANCE.md deleted-102
...@@ -1,102 +0,0 @@
1# Milestone 6 verification
2
3The deliverable is a read-only document model and reading report. Its acceptance
4boundary is the copied personal notebook: automated native comparisons plus a
5page-by-page review with no known easily spottable content failures. Native PDF
6references preserve access to the original canvas arrangement.
7
8## Personal notebook
9
10[The current report](evidence/m6/private-current-report-05/index.html) includes the
11user-approved newer `Video.one`. [Its source manifest](corpus/private/current-source-manifest.json)
12identifies that snapshot; [the original frozen manifest](corpus/private/source-manifest.json)
13remains separate. The original files are never edited by the library tests.
14
15| Check | Evidence |
16| --- | --- |
17| 26 current/recycle pages, 109 tags, 188,560 explicit character-format comparisons | [Fresh-cache comparison](evidence/m6/private-current-compare-01.log) |
18| 18 historical pages, including all three versions of the updated Video page | [Album](evidence/m6/private-history-final-01.log), [Video](evidence/m6/private-video-current-history-cold-compare-01.log), [deleted page](evidence/m6/private-deleted-history-final-01.log) |
19| All 45 report pages reviewed: ordinary, historical, recycle-bin and default template | [Review record](evidence/m6/private-current-report-05/qa/review.json) |
20| Frozen copy, approved current copy and live sources match their respective manifests | [Integrity check](evidence/m6/source-integrity-current-01.json) |
21
22The updated current page was inspected through eight browser viewports and two
23native PDF pages. Unchanged page bodies inherit their earlier review only after
24an exact rendered-content comparison. All exported image payloads decode.
25History labels use America/Los_Angeles. Seventeen historical dates match native
26UI evidence; copying the sole deleted-page version changes its native displayed
27date, so the report retains the source revision timestamp and records that
28normalization separately.
29
30## Independent and adversarial checks
31
32| Campaign | Result and evidence |
33| --- | --- |
34| Native edit histories | [100 histories / 2,000 independently specified operations](evidence/m6/native-histories-independent-03.log); [101 resulting pages compared](evidence/m6/native-histories-final-02.log) |
35| Shrinking | [Seed 21](evidence/m6/history-shrink-21/minimal.json) reduced from 20 operations to 3 in 37 fresh-clone replays; [empty-link regression](corpus/m6/native-empty-link-01) |
36| Rust scale generation | [128 sections / 2,097,152 Unicode scalars](evidence/m6/rust-scale-final-01.log), including reverse section ordering after native import |
37| Document fuzzing | [65,736 runs / 121 seconds](evidence/m6/document-public-fuzz-08.log) after the collapse-field change; prior [100,398-run campaign](evidence/m6/document-public-fuzz-07.log) and [11,423 private-seed runs](evidence/m6/document-private-fuzz-03.log) |
38| Rust checks | [All targets](evidence/m6/rust-regression-06.log), [clippy](evidence/m6/clippy-final-04.log) |
39| Native/report tooling | [18 tests](evidence/m6/tool-regression-15.log), including deliberate oracle failures and TIFF/native pixel parity |
40| Existing writer and fault cases | [Writer](evidence/m6/writer-regression-01.log), [collaboration/FLUSH regression](evidence/m6/collaboration-regression-02.log) |
41| Expanded live matrix | [Seven passing cases](corpus/m6/live-collaboration-15/ORACLE.md), [fresh-cache comparison](evidence/m6/live-collaboration-cold-compare-15.log), [native conflict view](corpus/m6/live-collaboration-cold-15/conflict.png) |
42
43The document fuzzer mutates property streams inside native files and repairs
44object checksums, reaching document interpretation beyond header rejection.
45It traverses referenced historical revisions and resolves text runs. Native
46histories have a separate expected-operation model; matching two views derived
47from the Rust decoder is not counted as independent verification.
48
49The live matrix covers disjoint edits, competing edits, Samba restart, per-client
50transport loss, whole-file lock contention, OneNote process termination and abrupt
51VM termination. The process/VM cases recover already server-persisted edits.
52They do not establish unsynchronized-cache durability or physical power-loss
53safety. Earlier stage-5 cases separately cover lost successful SMB FLUSH replies.
54
55## Interpretation and oracle boundaries
56
57- Native XML omits partial black highlights. Native PDF rectangles supply a
58 separate check; the reader retains the stored highlight.
59- RTL native XML column order differs from physical left-to-right storage order.
60 Independent PDF coordinates establish the conversion. Page-origin translation,
61 locked/unlocked column widths and printout borders have isolated controls.
62- A saved paragraph collapse default differs from a client's transient expanded
63 view. [The native UI control](corpus/m6/native-expanded-control-ui-01/ORACLE.md)
64 and [Rust-authored control](corpus/m6/rust-collapse-control-01/ORACLE.md) distinguish them.
65- Ink, structured equations, encrypted content and unknown properties retain
66 payloads or source identities. Preservation is not decryption or interpretation.
67 Structured equation runs expose a native-reference placeholder; ordinary inline
68 math text remains readable.
69- TIFF browser previews match OneNote's exported pixels while original TIFF bytes
70 remain available. Native image conversion is not a license to replace source data.
71- [Cell shading](corpus/m6/cell-shading-control-01/ORACLE.md) follows the documented
72 stored COLORREF value. A fresh OneNote 2010 cache opens the control but omits
73 shading from XML and renders the cell white; native rendering parity for that
74 property is not claimed.
75
76Verification uses OneNote 2010 build 14.0.4763.1000 in disposable Windows 7 clones.
77The earlier storage/collaboration corpus used build 14.0.7015.1000 on the physical
78machine. Each run records its environment and cleanup. These finite campaigns
79support this milestone's review boundary, not a claim of universal compatibility
80or absence of bugs.
81
82## Reproduce
83
84Use a new output directory for each native capture or report:
85
86```sh
87cargo test --all-targets
88cargo clippy --all-targets -- -D warnings
89cargo build --example document
90PYTHONPATH=tools python3 -m unittest discover -s tools -p 'test_*.py'
91python3 tools/native_runner.py COPIED_NOTEBOOK NEW_CAPTURE --pdf
92python3 tools/verify-document.py COPIED_NOTEBOOK NEW_CAPTURE/read
93python3 tools/notebook_report.py COPIED_NOTEBOOK NEW_REPORT \
94 --native NEW_CAPTURE/read --timezone America/Los_Angeles
95cargo +nightly fuzz run document -- -max_total_time=120 -max_len=512 -rss_limit_mb=2048
96python3 tools/native_collaboration.py NEW_CAPTURE --linux OWNED_LAB_NAME
97```
98
99Python native comparison/report tests require Pillow and pdfplumber. Historical
100report references use repeated `--versions CAPTURE_DIRECTORY` arguments after
101`verify-document.py --versions CAPTURE_DIRECTORY/version-ui.json` has checked
102source hashes and associated the native copies with stored revisions.
MILESTONE6.md deleted-183
...@@ -1,183 +0,0 @@
1# Milestone 6: a reviewable OneNote document model
2
3Completed milestone; verification and review artifacts are recorded in
4[M6-ACCEPTANCE.md](M6-ACCEPTANCE.md). Clover chose
5extracting a real notebook into a readable document model as the first hands-on
6result. This milestone combines document-format work with repeatable native
7verification; completion requires no known easily spottable bugs in her notebook
8after automated native comparisons and an independent page-by-page inspection.
9Her review should find subtle design issues, not serve as the basic QA pass.
10
11## What Clover receives
12
13A local, read-only review report generated from the copied personal notebook,
14with notebook/section/page navigation, readable content, extracted media, and a
15document-structure view. The report exposes paragraph nesting and ordering,
16text-run styles, outline coordinates, tables, links, tags, and source identities.
17It accompanies a machine-readable model and an asset directory, so the result is
18useful to future applications as well as inspectable by a person.
19
20The report presents a readable interpretation of the document. It does not claim
21to reproduce OneNote's layout engine. Coordinate values and native reference
22captures let Clover assess spatial information without depending on a new canvas
23renderer. Ink, recording data, encrypted sections, and unrecognized structures
24must remain explicitly accounted for, with retained payloads or source references
25as appropriate. Unsupported interpretation must never turn into silent omission.
26
27Feedback should be attached to identifiable pages/objects and answer:
28
29- Does the section/page hierarchy match the notebook, including subpages and order?
30- Does the representation preserve meaningful paragraph/list/table structure?
31- Are text, formatting, links, images, attachments, and tags correctly associated?
32- Where does spatial arrangement communicate something the readable view loses?
33- Which opaque content prevents this model from supporting a useful reader?
34
35The review ends with concrete corrections or priorities for the document model.
36Editing API design follows that feedback; no GUI editor is part of this milestone.
37
38## Sequence and gates
39
40| Step | Deliverable | Gate before proceeding |
41| --- | --- | --- |
42| 1. Repeatable native oracle | Automated fresh-clone runs using the existing VM controllers, named disposable notebooks, bounded captures, and unconditional teardown | Existing reader/writer native cases replay successfully; intentional content corruption causes a failed comparison; a failed run retains a reproducible artifact bundle and leaves no owned VM running |
43| 2. Document semantics | A typed view over the resolved store graph, independent of COM and filesystem transport | Every interpreted feature has a native fixture and assertions for its semantic invariants; unknown and encrypted content is represented explicitly |
44| 3. Real-notebook review | Machine-readable export, assets, and a human-readable report derived from the same model | All copied sections/pages are accounted for, source hashes remain unchanged, and supported content matches newly captured native evidence |
45| 4. Adversarial validation | Seeded native edit histories, parser fuzzing, and replay of existing commit/concurrency cases | The feature/failure matrix passes, discrepancies have regression cases, and the acceptance report distinguishes exact matches, allowed native normalization, and opaque content |
46
47## Document scope
48
49Start with notebook and section structure, ordered pages/subpages, titles, outlines,
50paragraphs, and Unicode text runs. Add character/paragraph formatting, list and
51indentation semantics, tables, hyperlinks, images, attachments, and note tags.
52Expose conflict pages and recycle-bin membership separately from ordinary pages;
53retain their relationship to their source page/section. Account for observed
54unrecognized page-like metadata instead of filtering it out of the report.
55
56MS-ONE text-run boundaries use character positions with rules distinct from Rust
57UTF-8 byte indices. Fixtures must cover surrogate pairs, combining marks, RTL text,
58mixed formatting, empty paragraphs, repeated text, and embedded objects. Titles,
59cached title strings, styles, and layout metadata need explicit interpretation;
60generic scalar decoding alone cannot establish those relationships.
61
62Keep document interpretation above `onestore`'s revision/property graph. Retain
63access to raw identities and values for unknown properties. Add types only where
64they express document semantics or prevent invalid interpretation; keep the initial
65export/API provisional until Clover has reviewed actual notebook content.
66
67## Independent verification
68
69```text
70Authored operation history ──→ expected document semantics
71 │
72 └─→ real OneNote edit/save → captured .one + native XML/payloads
73 │
74 └─→ Rust document model
75 │
76 compare all three ─┘
77
78Existing Rust scalar edit → fresh native read/edit/save → Rust reread
79```
80
81Native capture runs use a fresh clone/cache for each independent acceptance case.
82Tests of a continuing collaboration session deliberately retain that session's
83cache, then use a separate fresh verifier after synchronization. Completion must
84be observed through file/content state, not inferred from an arbitrary sleep or a
85successful asynchronous COM call. COM IDs are cache identities, not persistent
86notebook identities.
87
88Compare page relationships/order, paragraph boundaries, text runs/styles, table
89cells, positions, links, and payload bytes. Hashes establish artifact integrity;
90they do not establish semantic correctness. Native XML supplies an independent
91view of supported content. Selected UI captures cover facts COM omits, especially
92conflicts and visual interpretation. Whole-file byte equality after a native save
93is not the oracle because OneNote rewrites metadata and representation.
94
95Build native-only control cases for observed normalization, including the previous
96table-width and ink z-order changes. Comparison exceptions require specific
97evidence and a narrow assertion; broad removal of timestamps, IDs, or geometry
98must not conceal meaningful changes.
99
100Use a small independent test model for generated operations; expected values must
101not be reconstructed by the same Rust decoder being checked. Shrink a failed
102history by removing operations and simplifying their parameters, replaying each
103candidate from a clean starting state. Preserve seed, operation history, input and
104output files, model diff, native XML/payloads, app/server versions, and relevant
105trace excerpts. A regression must reproduce the observed discrepancy before its
106fix can be accepted.
107
108Build a specification-derived feature matrix, including features absent from the
109personal and stock notebooks. Produce large synthetic notebooks both through Rust
110and through real OneNote; compare independent generation paths and exercise size,
111ordering, repetition, and mixed-feature interactions. Any unsupported feature must
112be explicitly accounted for and investigated, not silently omitted from coverage.
113
114Initial randomized target: 100 reproducible native histories of roughly 20
115operations, spanning the supported feature matrix. This is a campaign budget,
116not a statistical safety claim. Native-generated files then seed fast local
117reader fuzzing. Continue the existing stateful short-I/O/partial-persistence
118commit tests; run the existing collaboration scenarios with two Windows clients
119and the disposable Linux server. Process termination, Samba restart, transport
120loss, and abrupt VM termination are distinct fault cases and must be reported as
121such. VM termination does not simulate physical host power loss.
122
123## Tooling findings and setup work
124
125The implementation is in `tools/w7`. Windows instances use sparse qcow2 overlays
126on a sealed base, unique hostnames/MACs/control targets, and an authenticated agent.
127Linux instances also use overlays. A VDE network connects Windows clients to one
128Linux Samba appliance at `192.168.77.1`; the Mac has forwarded SSH and SMB ports.
129The MCP's up/down/status operations are enough for ordinary fresh-clone tests.
130Reuse those controllers from the test runner rather than implementing another
131QEMU lifecycle. Add a narrow controlled-crash facility only when fault cases need
132it, with ownership checks and restart/recovery of the same overlay.
133
134Start with two Windows clients and one Linux server. Windows uses x86 CPU
135emulation on this host; the suggested 25-client capacity has not been measured.
136Increase concurrency from measured test throughput and resource use. One Linux
137lab address means server restart/configuration tests require exclusive ownership
138of that server; ordinary cases can use separate notebook directories.
139
140The scout confirmed:
141
142- Windows 7 clone `m6scout`, PowerShell 5.1.14409.1005, OneNote 14.0.4763.1000.
143 The earlier physical-machine corpus used OneNote 14.0.7015.1000. Record both
144 builds' evidence separately; success on one must not be silently attributed to
145 the other.
146- The clone desktop was 800×600, so existing 1280×720 AHK coordinates cannot be
147 reused without setting and verifying display configuration.
148- Z: mapped to `\\10.0.0.1\agent` (zenith). The disposable Linux share is a
149 different destination, `\\192.168.77.1\agent`. Parameterize shared paths rather
150 than reusing the old A: convention or remapping Z:.
151- Linux reported Samba 4.22.10 and an ext4 data filesystem. Windows read a
152 Linux-created marker and Linux read a Windows-created marker through that share.
153- A fresh native cache opened the template-free Rust notebook and returned its
154 expected paragraph through COM. The captured XML is under
155 `evidence/milestone6-scout/page.xml`. Cache reset remains necessary when reusing
156 file identities within a clone.
157- Procmon started through `win7_spawn` and wrote a PML capture. The unfiltered
158 short capture grew to about 126 MB; filter by test process/path and bound capture
159 duration before using it in a campaign. Trace contents were not analyzed in this
160 scout, and the transient PML was removed with the clone.
161- All six VM-tooling tests passed with the scout running. One test assumed the
162 first control port was free; it now checks that the registered target points to
163 the allocated port, while retaining the separate uniqueness assertion.
164- Both scout VMs were stopped and deleted after the smoke checks. Neither the
165 sealed images nor the personal notebook were edited.
166
167Existing native scripts assume a parked personal Windows profile, fixed A: paths,
168and specific display coordinates. Adapt their orchestration for disposable clones
169while retaining the guards used for the physical laptop. Windows base identity,
170Linux package/server versions, and relevant configuration must accompany every
171run; cloud-init currently installs packages at clone creation time.
172
173## Completion and review boundary
174
175The milestone is ready after the copied notebook passes automated comparison and
176an independent page-by-page inspection with no known easily spottable bugs.
177Corrections found during that inspection need reproductions and regression checks
178before handoff. Clover's subsequent review addresses subtle representation and
179design decisions. Deliver the report, model,
180assets, acceptance matrix, and reproduction command together. Preserve originals
181and export evidence before automatically deleting all machines created by the
182run. The next decision is which document operations to expose for editing based
183on that review, rather than another general approval to continue infrastructure.
MILESTONE7.md deleted-429
...@@ -1,429 +0,0 @@
1# Milestone 7: concurrent editing
2
3Acceptance gates passed for the tested macOS Rust, OneNote 2010 and Linux Samba
4configuration. The edited notebook has fresh native references and an HTML
5review; independent operation histories verify overlapping clients, retained
6conflicts and the final notebook after native application closure.
7
8## Gates
9
101. Document edits: publish text and dependent run boundaries atomically; check
11 Unicode boundaries, preserve untouched objects and opaque properties, reject
12 unsupported edits before writing. Validate native round trips before racing.
132. Random-edit CLI: select a page and seed, record the exact intended operation,
14 source identity and commit outcome. Operate on disposable notebook copies.
153. Local concurrency: independent reader/writer processes, synchronized starts,
16 stale snapshots, lock contention, randomized delays and interrupted commits.
17 Verify every successful observation and explain every accepted edit.
184. Native concurrency: grow from three native clients plus multiple Rust
19 processes to larger measured workloads. Mix disjoint and same-paragraph
20 changes, ordinary reads, synchronization, disconnects and restarts. Prove
21 operation overlap from recorded intervals; do not count open idle clients.
225. Adversarial replay: retain seeds, operation logs and snapshots; shrink failures
23 and add regressions. Compare the independent operation model with current
24 native content and explicitly reachable conflicts, then cold-reopen results.
256. Review and cleanup: inspect the edited report and native references, rerun
26 prior gates, verify original source hashes, delete owned machines, and record
27 observed client counts, operations, faults and the limits of the evidence.
28
29Disjoint acknowledged changes must survive. Competing changes must be accounted
30for by their observed ordering or accessible conflict content; historical bytes
31alone do not prove recovery. Unknown commit outcomes require rereading before a
32retry. Native COM page snapshots must not replay untouched stale containers.
33
34Crash tests distinguish application cache state, server-persisted state and
35storage durability. No finite campaign proves all schedules or hardware
36power-loss behavior.
37
38## Replay
39
40Use fresh output directories and Linux VM names; the native harness deletes its
41owned machines on exit. The Linux lab address allows one server run at a time.
42
43```sh
44python3 tools/native_collaboration.py evidence/m7/new-stress --linux new-stress --stress-clients 3 --stress-operations 150 --sync-every 0 --rust-writers 4 --rust-readers 3 --edit --seed 913
45python3 tools/native_runner.py evidence/m7/new-stress/stress-closed/notebook evidence/m7/new-cold --expected-pages 1
46python3 tools/verify-document.py evidence/m7/new-stress/stress-closed/notebook evidence/m7/new-cold/read
47python3 tools/native_stress.py evidence/m7/new-stress evidence/m7/new-cold/read
48python3 tools/native_collaboration.py evidence/m7/new-conflict --linux new-conflict --conflict-clients 3 --rust-writers 3 --rust-readers 2 --stress-operations 5 --seed 917
49cargo +nightly fuzz run edit_text -- -max_total_time=300 -max_len=128 -rss_limit_mb=2048
50```
51
52## Evidence
53
54- `evidence/m7/text-edit-native-02`: a length-changing Unicode edit reopened in
55 a fresh OneNote VM; 297 explicit formatting comparisons passed on one page.
56- `evidence/m7/local-concurrency-02`: ten Rust writers and six readers;
57 600 acknowledged commits and 8,101 checked observations. The final notebook
58 reopened in a fresh native cache (`local-concurrency-native-02`).
59- `evidence/m7/edit-text-multi-fuzz-03.log`: 31,394 stateful executions with
60 six independently stale writer snapshots and interrupted publication. This
61 simulates interleavings; native concurrency is a separate gate.
62- `evidence/m7/native-concurrency-01` and `native-concurrency-02`: three native
63 clients, four Rust writers and three Rust readers exposed a macOS SMB lock
64 failure. Native COM acknowledgments were not durable synchronization: the
65 stalled server copy did not contain those cached native edits. These runs
66 failed acceptance and all of their machines were deleted.
67- `evidence/m7/lock-race-default`: a notebook-free, four-process reproducer
68 detected overlapping exclusive holders using an independent local marker,
69 plus stranded locks when switching read-only and read/write opens. Explicit
70 unlock alone did not fix it. Uniform access modes still violated exclusion.
71- `evidence/m7/lock-race-atomic`: atomic open-and-lock passed all three access
72 modes without overlap. The macOS file adapter now uses that operation.
73- `evidence/m7/smb-rust-only-04`: the formerly stalled workload completed all
74 120 commits, 940 verified reads and 203 overlapping writer calls after that
75 change. Broader acceptance remains active; this does not establish a native
76 multi-client pass.
77
78- `evidence/m7/lock-race-atomic-16`: sixteen processes, 4,516 acquired locks
79 across read-only, read/write and alternating access; no marker overlap.
80- `evidence/m7/smb-rust-only-05`: ten writers and six readers completed 1,000
81 commits and 14,913 verified observations. The saved result subsequently
82 failed fresh-cache native acceptance (`smb-concurrency-native-05`): OneNote
83 returned zero pages after five minutes. The dependency-checkpoint fix and subsequent native checks below explain
84 and resolve this failure; Rust parsing alone was insufficient acceptance.
85- `evidence/m7/native-concurrency-03`: all 90 native edits and 120 Rust edits
86 converged, but the overlap gate correctly rejected the run. Native calls
87 began 4.5–5.4 seconds after the first Rust commit; Rust finished at 2.7
88 seconds. The shared-file start marker was delayed. The harness now waits
89 for each native client's first-edit acknowledgment before releasing Rust.
90- `evidence/m7/local-stateful-01`: eight writers and five readers, 800 random
91 length-changing Unicode replacements, 9,820 verified observations. The
92 independent oracle applies recorded UTF-16 edit intents to each committed
93 state and checks all reader observations against the resulting history.
94- `evidence/m7/edit-text-multi-fuzz-04.log`: 26,377 renewed stateful fuzz
95 executions completed without a failure.
96- `evidence/m7/native-concurrency-04`: all 90 native edits and 120 Rust edits
97 converged, but overlap remained zero. Rust's first successful commit occurred
98 10.21 seconds after its start signal, following thousands of busy reads while
99 native clients synchronized after every edit. Atomic open requests deny-all
100 sharing on this mount; native open handles can postpone Rust admission.
101- `evidence/m7/native-threshold-02` and `native-threshold-03`: a fresh section
102 containing the same final text opened normally. The long-history section
103 opened after shortening revision dependencies while preserving its resolved
104 graph; coalescing file-node fragments alone did not help. Tested histories
105 through 676 transactions opened; 701 and longer failed. This bounds an
106 observed compatibility failure, not a documented format limit.
107- The writer now appends an independent current-object snapshot before dependency
108 depth would exceed 512, retaining all older revisions. Section snapshots reuse
109 immutable property and attachment declarations; TOC snapshots remap CompactIDs
110 into a complete table. `checkpoint-boundaries-01.log` covers two boundaries in
111 both native section and TOC fixtures, with every historical revision checked.
112 `checkpoint-publication-01.log` checks interrupted text publication at the
113 boundary against complete old/new text and formatting states.
114- `evidence/m7/local-checkpoint-01`: ten writers and six readers completed
115 1,500 randomized Unicode replacements, 22,995 checked observations and 11,966
116 overlapping writer calls. `checkpoint-native-01` cold-opened its saved result;
117 native text exactly matched the independently replayed edit history.
118- `evidence/m7/edit-text-checkpoint-fuzz-05.log`: 4,558 stateful executions in
119 302 seconds, including cached snapshots immediately before a full checkpoint,
120 with interrupted publication and character/formatting oracles. No failure.
121 `checkpoint-regression-02.log`, `checkpoint-clippy-01.log`, and
122 `tool-regression-03.log` record passing Rust checks and 22 Python tests.
123- `evidence/m7/checkpoint-attachment-native-01`: a native section retained its
124 text, images, attachment and formatting through 2,052 scalar edits and repeated
125 snapshots. Fresh-cache reading passed 483 explicit character-format checks;
126 all native page XML matched the earlier reference except page modification
127 time, recorded separately in `retention.json`.
128- `evidence/m7/checkpoint-toc-native-01`: the native two-page notebook reopened
129 after 1,026 TOC edits crossing two dependency checkpoints. The generator and
130 input/output hashes are retained in `evidence/m7/checkpoint-fixtures`.
131- `checkpoint-toc-native-02` repeated the TOC check with native PDFs: two pages,
132 two tags and 3,562 explicit character-format comparisons passed. PDF geometry
133 verified the existing black-highlight case that native XML omits.
134- `native-concurrency-05` established 633 clock-bounded native/Rust call overlaps
135 under background synchronization. Its original oracle incorrectly treated
136 transaction numbers as permanent across native renumbering. Replaying intent
137 content validated all 800 Rust commits and 6,370 reads through one counter
138 decrease. The run stopped before explicit native convergence; after cleanup,
139 one native paragraph lacked its final three cache-acknowledged edits. It is
140 not an acceptance pass. The content-history oracle now rejects branches,
141 missing acknowledgements, partial observations and reads inconsistent with
142 recorded real-time bounds; its regressions are in `test_native_stress.py`.
143- `native-concurrency-06`: three native writers, four Rust writers and three
144 Rust readers passed the overlapping background-sync gate. All 600 native edits
145 and 800 Rust commits converged in every native client and the shared file;
146 7,252 Rust reads matched the intent history, with 609 conservatively
147 clock-bounded native/Rust call overlaps. A separate cold-cache capture follows
148 in `native-concurrency-cold-06`.
149- `random-edit-smb-01.json` and `random-edit-smb-01.one`: the CLI created a
150 separate edited file on Samba, with byte equality verified directly at the
151 server. Examples share the commit adapter's standard-fsync fallback when
152 macOS full-sync is unsupported. Rust commit/edit regressions and clippy passed
153 (`flush-regression-01.log`, `flush-clippy-01.log`).
154- `native-concurrency-cold-06`: a separate fresh OneNote cache retained all
155 1,400 intended edits from the successful mixed run. Native comparison passed
156 54,330 explicit character-format checks, and exact paragraph text matched
157 the independently replayed operations. All associated machines were deleted.
158
159- `native-concurrency-07`: five native writers, eight Rust writers and five Rust
160 readers completed 1,000 native edits and 1,600 Rust commits, with 21,152 checked
161 reads and 296 clock-bounded native/Rust call overlaps. All six paragraphs
162 converged. `native-concurrency-cold-07` independently retained all 2,600 intended
163 edits and passed 95,034 explicit format comparisons. Every owned VM was deleted.
164- `edit-text-checkpoint-fuzz-06.log`: 10,596 stateful executions in 901 seconds,
165 without failure. `edit-text-insertion-fuzz-07.log`: 4,639 executions in 301
166 seconds including legacy and empty text properties, without failure.
167- Random edits on the private corpus exposed unsupported legacy Unicode
168 promotion and absent initial text properties. The writer now adds Unicode
169 text while preserving the original legacy property, as MS-ONE 2.2.23 permits.
170 `text-insertion-boundaries-02.log` checks native fixtures, unrelated objects,
171 historical revisions, short writes and interrupted publication.
172- A title edit previously left navigation caches stale. Text and cached titles
173 now publish in one revision and transaction; `title-atomicity-04.log` and
174 `title-regression-02.log` check rename/clear, history and interrupted short
175 writes. `private-random-native-01/title-verification.log` demonstrates that
176 the strengthened independent verifier rejects the earlier faulty copy.
177 CachedTitleStringFromPage's mandatory empty value applies to nonempty Unicode
178 titles; original legacy titles may retain it. The writer's modified-title
179 checks enforce that condition without rejecting untouched legacy content.
180- `edit-text-title-fuzz-08.log`: 7,383 executions in 301 seconds, adding a native
181 title fixture and assertions relating title text, metadata and alternate title
182 after every persisted observation. No failure. The later single-title-object
183 admission guard was separately covered by `title-regression-02.log`.
184- `title-empty-native-01`: a cleared title cold-opened successfully; two pages,
185 two tags and 3,394 explicit format checks passed, with four native-PDF highlight
186 checks. OneNote regenerated its empty page name from body text during opening;
187 this check establishes text/format retention, not stored automatic-title parity.
188- `private-random-campaign-04`: five seeded edits on each of 26 current pages,
189 130 total. An independent UTF-16 splice model checks all resolved styles,
190 unrelated nodes, metadata, contexts, historical revisions and payload hashes
191 after every edit. All ten frozen source files remained byte-identical.
192 `private-random-native-02/verification-02.log` records a fresh-cache pass on
193 all 26 pages, 109 tags and 186,325 explicit format comparisons, with five native
194 PDF highlight checks. The capture VM was deleted.
195- `title-all-targets-01.log` records passing Rust all-target tests;
196 `title-clippy-01.log` has no warnings. `random-native-oracles-01.log` records
197 30 passing Python tests, including content-chain replacement histories and
198 deliberate title-cache damage.
199- `private-random-report-04`: the edited copy is rendered with all 45 stored
200 pages and 26 current native references. Browser inspection covered the edited
201 Video page; all 2,357 local links resolve (`link-check.json`). The original
202 source remains untouched.
203- `native-random-08`: randomized replacements and bold/italic changes exposed
204 a test-driver error. On its second edit, Win7's framework left OneNote's
205 `&#129408;` entity undecoded, so the script treated entity spelling as text.
206 The independent intent oracle rejected that history. This run is not a pass;
207 all four machines were deleted. `tools/native/text.ps1` now decodes supplementary
208 numeric entities before the framework decoder, preserving escaped literals.
209 `native-text-test-01/failure-artifacts/text-result.json` records six passing
210 Windows decoder regressions. Its unrelated native-capture phase was correctly
211 stopped by the profile-isolation guard because this text-only author script
212 had not initialized a test profile; the VM was deleted.
213- `automatic-title-native-01`: 18 application-authored automatic-title cases
214 captured trimming, empty paragraphs, formatting, entities and long text.
215 Input and stored metadata are retained with the native page names. Additional
216 Unicode-boundary and outline-order cases are being measured before extending
217 automatic-title updates.
218- `automatic-title-native-02` captured ten more application-authored cases.
219 Automatic names select an outline by position (top before bottom, then left),
220 use the first text line, and trim whitespace. The 255-UTF-16-unit boundary
221 includes a complete supplementary character when it starts at unit 254,
222 yielding 256 units. Both fixture sets pass native comparison: 19/11 pages and
223 11,680/5,304 explicit format checks. The first set also establishes OneNote's
224 XML omission of an otherwise empty outline containing only ASCII spaces;
225 the oracle permits that case while still rejecting omitted text, lists or tags.
226 These are measured compatibility rules, not a completed automatic-title writer.
227- `title-checkpoint-atomicity-02.log` passes interrupted multi-object title
228 publication both on the native fixture and at a full dependency checkpoint,
229 using 17/257-byte short writes respectively.
230- `native-random-09` replayed seed 912 after the native decoder fix: three
231 original OneNote writers, four Rust writers and three Rust readers completed
232 450 native replacements with bold/italic changes and 600 Rust Unicode
233 replacements. All four paragraphs converged; 8,564 reads matched the
234 independently chained replacement intents, with 260 clock-bounded native/Rust
235 call overlaps. The final native formatting matched each actor's last recorded
236 intent. All three Windows VMs and the Linux server were deleted.
237- `native-random-cold-09`: a separate fresh cache retained the exact final
238 text produced by all 1,050 replacement intents. Native comparison passed
239 1,974 explicit format checks; an independent replay also checked 456 character
240 bold/italic values against the recorded native editing intent. The cold VM
241 was deleted.
242- `random-noop-01` records seed 301 replacing ` café ` with itself and producing
243 identical output. The CLI now turns an identical replacement into an insertion.
244 `test_random_edit_campaign.py` verifies both new-file and in-place operation;
245 `tool-regression-06.log` records 31 passing Python tests and
246 `final-clippy-04.log` is clean.
247
248- `automatic-title-native-03`: twelve additional native cases distinguish
249 explicit titles (full length, leading whitespace removed, trailing whitespace
250 retained, first line) from automatic body summaries (trimmed and bounded).
251 Empty lines/paragraphs/outlines fall through to later text; table cells supply
252 automatic titles. All 13 captured pages pass 9,744 native format comparisons.
253- The writer now publishes automatic navigation metadata when body text changes
254 and when an explicit title is cleared, choosing body outlines by position.
255 New sections use the same bounded automatic-title encoding. No public API was
256 added. `automatic-title-regression-03.log` passes edit/writer regressions;
257 `automatic-title-edit-tests-01.log` covers native line/UTF-16 boundaries and
258 multi-object interrupted publication. Historical objects and all non-title
259 metadata fields remain checked independently.
260- `automatic-title-edits-01` applies 26 independently checked edits to the 13-page
261 native fixture. `automatic-title-edits-native-01` then passes fresh-cache text,
262 cached navigation title and 6,992 character-format comparisons. Its VM was
263 deleted. `title-empty-native-02` independently confirms the corrected cleared
264 title's navigation label, two pages, two tags and 3,394 format checks, with four
265 PDF black-highlight checks; its VM was deleted.
266- `edit-text-automatic-title-fuzz-09.log`: 6,550 stateful executions in 301 seconds
267 after automatic-title changes, with no failure. `automatic-title-all-targets-01.log`
268 passes all Rust targets; `automatic-title-clippy-01.log` has no warnings.
269- `private-random-campaign-05` passes another 130 edits against the whole-document
270 and payload-preservation oracle. All ten frozen source files remained unchanged.
271- `private-random-native-03` cold-opens that edited copy: 26 pages, 109 tags,
272 186,325 format checks, five PDF highlight checks, and cached navigation titles
273 pass. Its VM was deleted. `private-random-report-05` contains the edited pages
274 and fresh native references; all 2,357 local links in 46 HTML files resolve.
275- `automatic-title-native-04/05` establish RTL outline ordering by descending
276 x anchor (width does not affect it), reversed RTL table-cell order, skipped
277 attachments, and whitespace trimming after UTF-16 truncation. The two public
278 fixtures in `corpus/m7/automatic-titles` retain native provenance. Ten direct
279 body-edit regressions cover the RTL/attachment selection rules.
280- `automatic-title-edits-02/03` apply 21/18 independently checked edits to these
281 fixtures. Fresh native captures pass seven/six pages, cached navigation labels,
282 and 2,064/6,288 format checks. Both VMs were deleted. All Rust targets and
283 clippy pass in `automatic-title-all-targets-02.log` and
284 `automatic-title-clippy-03.log`.
285- `native-random-10` repeats the ten-client replacement workload with seed 913:
286 450 native edits, 600 Rust commits, 5,595 reads, and 466 clock-bounded native/Rust
287 call overlaps. All four paragraphs converge. `native-random-cold-10` passes
288 1,740 format comparisons; intent replay independently checks all 1,050 edits
289 and 392 native bold/italic character values. All associated VMs were deleted.
290- `tools/native_stress.py RUN CAPTURE/read` now replays saved editing intents
291 directly against fresh native XML, including exact paragraph multiplicity and
292 native formatting intent. `tool-regression-08.log` records 33 passing tests,
293 including rejection of missing/extra content, incorrect formatting, lost edits
294 and split surrogate pairs.
295- `recovery-01` repeats all seven shared-notebook scenarios after the writer
296 changes: disjoint edits, reachable competing edits, server restart, transport
297 reconnect, lock contention, application restart and VM restart. Both Windows
298 clients and the Linux server were deleted. `recovery-cold-01` opens the final
299 saved notebook in a new cache and passes text, navigation and 641 character
300 format checks; its VM was deleted.
301- The conflict oracle follows current page-manifest references only.
302 `conflict-oracle-regressions-01.log` checks the native offline-edit fixture and
303 rejects treating unreferenced history or detached object spaces as recovery.
304 `tool-regression-09.log` records 34 passing Python tests.
305- `edit-text-rtl-fuzz-10.log` adds the native RTL title and non-title body
306 candidates to the stateful interrupted-edit workload: 4,531 executions in
307 301 seconds, no failure.
308- `mixed-conflict-01` FAILS acceptance. Three native clients edited the same
309 paragraph offline while three Rust writers committed 15 appends and two Rust
310 readers observed them. After reconnection, all three native alternatives were
311 reachable, but the final Rust text was absent from the current page and its
312 conflict pages. The pre-reconnect notebook and every convergence snapshot are
313 retained. All three Windows VMs and the Linux server were deleted. Rust edits
314 changed the text object's modification time while ancestor paragraph/outline/
315 page times stayed unchanged; native edits changed those ancestors too. That
316 difference is a hypothesis for investigation, not an established cause.
317- `mixed-conflict-02` reduces the workload to one native client, two Rust writers,
318 one reader and two Rust commits. Both competing results remain reachable; this
319 reduction does not reproduce the failure. Its Windows and Linux VMs were deleted.
320- `mixed-conflict-cold-01` independently opens the failed final notebook in a
321 fresh cache. The native conflict UI shows only the three native alternatives;
322 clipboard captures recover the exact Unicode text of both conflict pages.
323 The Rust result is also absent from all exported stored revision objects in
324 the last convergence snapshot (`mixed-conflict-01/lost-edit.json`). Native
325 comparison passes 200 format checks on the surviving main page; that reader
326 agreement does not validate retention of the missing edits. The VM was deleted.
327- `mixed-conflict-03` tests ancestor modification timestamps with the original
328 three-native/three-Rust-writer workload, using the separate
329 `evidence/m7/ancestor-timestamp-probe.py`. The first reconnect capture referenced
330 three conflict spaces without default revisions, so the strict reader stopped
331 the run. After client teardown, two references remained unresolved. All VMs
332 were deleted. `mixed-conflict-cold-03` nevertheless opens the saved file in a
333 fresh OneNote cache with the full Rust text; its VM was deleted. This is an
334 inconclusive experiment, not acceptance of a timestamp fix.
335- The native checkpoint observer now preserves each distinct incomplete snapshot
336 and retries missing document contexts for at most two minutes. Other parse
337 errors still fail immediately, and retention still requires every competing
338 result to be reachable. This allows the next experiment to distinguish an
339 intermediate cross-space save from a persistent missing conflict.
340- `mixed-conflict-04` repeats the timestamp experiment with that observer. One
341 incomplete snapshot resolves, then all three native alternatives and the full
342 15-commit Rust result are reachable. All Windows and Linux VMs were deleted.
343- Text edits now publish existing ancestor modification timestamps in the same
344 transaction as text, run boundaries and navigation caches. Preservation tests
345 independently follow raw object references and compare every unrelated field;
346 interrupted title/checkpoint publication also checks all modification times.
347 `ancestor-edit-regressions-04.log`, `ancestor-all-targets-01.log`,
348 `ancestor-clippy-01.log` and `ancestor-tool-regressions-01.log` pass.
349 Native acceptance of the atomic implementation is recorded below, separately
350 from the timestamp experiment.
351- `mixed-conflict-05` stopped before any Rust commit: disconnecting native NICs
352 stranded an existing SMB handle and correctly excluded Rust. The setup now
353 holds the file's exclusive lock while disconnecting clients. Rust readers
354 back off during contention. All owned VMs were deleted; the final stopped
355 clone's removal is recorded in `cleanup-confirmed.json`.
356- `private-random-campaign-06` applies 130 edits with seed 918 and verifies all
357 ten frozen source files unchanged. Fresh native capture
358 `private-random-native-04/verification-02.log` passes 26 pages, 109 tags,
359 188,956 character-format comparisons and five black-highlight paragraphs.
360 Its PDF maps a rendered combining-accent glyph to a space. The PDF oracle
361 checks the uniquely located paragraph and the unhighlighted glyph's inline
362 region; missing ordinary text, ambiguous matches and black rectangles in that
363 region fail. `tool-regression-14.log` passes all 35 Python tests.
364 `private-random-report-06` associates all 26 current pages with the fresh
365 native references; all 2,357 local links resolve. Its VM was deleted.
366- `edit-text-ancestor-fuzz-11.log` exercises stateful edits and interrupted
367 publication after ancestor timestamp propagation: 4,236 runs in 301 seconds,
368 no failure.
369- `mixed-conflict-06` passes with the atomic implementation: three native
370 alternatives and the full 15-commit Rust result remain reachable after all
371 three native clients close. A transient incomplete save resolves before
372 acceptance. All Windows and Linux VMs were deleted. `mixed-conflict-cold-06`
373 opens the closed result in a fresh cache; native conflict UI clipboard
374 captures independently match all four intended results exactly. That VM was
375 deleted too. The ordinary page comparison passes separately; its zero
376 explicit format checks do not substitute for the four intent comparisons.
377- The native clone cleanup now handles the VM helper's `SystemExit` on shutdown
378 timeout, terminates the owned VM and deletes its overlay. The regression
379 checks deletion and the teardown record; `tool-regression-17.log` passes all
380 36 Python tests, and the private native comparison still passes.
381- `native-random-11` never reached notebook editing: YAML parsed its all-digit
382 WAN MAC address as a sexagesimal integer, so cloud-init could not configure
383 the interface. The failed boot log is preserved and its VM was deleted.
384 Quoting both MAC addresses fixes the seed. `linux-lab-regression-01.log`
385 passes the two lifecycle tests; `native-random-12` reuses the same VM name
386 and deterministic MAC after deletion and reaches ready Windows clients.
387- `native-random-12` passes seed 918 with the ancestor fix: three native
388 writers, four Rust writers and three Rust readers; 450 native edits, 600
389 Rust commits, 2,471 checked reads and 428 clock-bounded overlapping native/Rust
390 calls. All four intended paragraphs converge and native formatting matches
391 the recorded operations. All task-owned Windows and Linux VMs were deleted.
392- `native-random-cold-12` independently reopens that result and compares every
393 recorded native/Rust edit with the native XML. Its VM was deleted.
394- `same-second-build` is an isolated controlled-clock build with one recorded
395 source substitution: a text edit uses its baseline element timestamp.
396 `same-second-equivalence/result.json` confirms that its resolved revision
397 exactly matches a production-library edit completed in the same actual
398 second. The production clock and library are unchanged. `mixed-conflict-07`
399 uses that build to test baseline-equal timestamps with three native writers,
400 three Rust writers and two Rust readers.
401- `mixed-conflict-07` passes: the four page-content element timestamps remain
402 exactly equal to the cached baseline across all 15 Rust commits, and all
403 four competing results survive reconnect and application closure. The three
404 Windows VMs and Linux server were deleted. This distinguishes valid equal
405 timestamps from the inconsistent descendant/ancestor times in the original
406 failing writer; it does not require inventing future timestamps.
407- `mixed-conflict-cold-07` independently opens the same-second result and
408 captures exact Unicode clipboard text from the main page and all three
409 native conflict pages. All four results match the recorded intent, and the
410 VM was deleted. Conflict retention now counts duplicate text instead of
411 collapsing it into a set: `exact-retention.json` rechecks both successful
412 closed notebooks against the exact four-result multiset.
413- `native-random-13` passes seed 919 with twelve clients: four native writers,
414 five Rust writers and three Rust readers; 600 native edits, 750 Rust commits,
415 1,903 checked reads and 456 clock-bounded overlapping native/Rust calls.
416 All five intended paragraphs converge. The harness now captures
417 `stress-closed` after every native client closes; fresh-cache acceptance uses
418 this snapshot instead of the earlier live checkpoint.
419- `native-random-cold-13` opens that closed snapshot in a fresh native cache:
420 all 1,350 recorded edits match exactly across five paragraphs, with 1,672
421 explicit character-format comparisons and 390 checks against the native
422 writers' formatting intents. Its VM and all campaign VMs were deleted.
423- Final cleanup records are `final-teardown-check.json` and
424 `final-source-check.json`: 21 owned Windows clone identities and five Linux
425 VM identities are absent, and all ten frozen private source files retain
426 their hashes. `tool-regression-19.log` passes 36 Python tests. The HTML review
427 is `private-random-report-06/index.html`; its 26 current pages link to the
428 verified native references. Physical power-loss and unsynchronized native
429 cache durability remain outside these guarantees.
MILESTONE8.md deleted-84
...@@ -1,84 +0,0 @@
1# Workspace, crash recovery and diagnostic editing
2
3Goal resumed after the user accepted the workspace split and public API audit.
4API boundary changes remain authorized when justified by implementation needs.
5
6## Acceptance gates
7
81. **Workspace:** move the existing library, tests and examples into
9 `crates/onestore`, preserve the root corpus and example executable paths,
10 and pass Rust, Python and fuzz build checks. A sibling crate can consume
11 the library without reaching into its source directory.
122. **Public API audit:** review exported types, ownership, validation, edit
13 contracts and consumer ergonomics; verify a separate crate can read and edit
14 through public APIs; present [API-AUDIT.md](API-AUDIT.md) before continuing.
153. **Storage interruption:** verify durable images after every write/flush
16 boundary, including dropped unflushed writes, partial persistence, counter
17 rollover and revision checkpoints. Reopen the persisted image, continue
18 editing it, retain reproducible failures, and independently validate a
19 representative image matrix with OneNote.
204. **Abrupt VM stops:** stop disposable Windows clients and the Samba server
21 without guest shutdown during normal concurrent editing. Preserve disks,
22 restart the same machines, compare acknowledged operations and reachable
23 conflicts, and cold-open the recovered server notebook in fresh OneNote.
24 Track native cache acceptance separately from server durability.
255. **Diagnostic editor:** extend the HTML reading report with supported text
26 edits on a task-owned notebook copy. Edits use the Rust library, reject stale
27 snapshots, preserve unrelated content and expose ambiguous commit outcomes
28 without automatic replay. Verify browser interaction, two-reader stale-edit
29 handling, Unicode and native round trips. This is a diagnostic interface;
30 canvas rendering and product UI remain separate work.
316. **Closure:** rerun affected checks, review the implementation for unnecessary
32 state and abstractions, verify source hashes, retain evidence and replay
33 commands, delete owned VMs, and provide the running diagnostic tool.
34
35Abrupt VM stops discard guest memory while the host remains powered. Simulated
36storage loss exercises the library's ordered-flush contract; neither establishes
37the physical drive's behavior during actual host power loss.
38
39## Evidence
40
41Evidence belongs under `evidence/m8/`. Original notebooks are never edited.
42
43### Workspace and API review checkpoint
44
45The workspace gate passed: 56 Rust integration tests (one intentional local
46fuzz-seed generator ignored), 36 Python tests, all example builds, all eight fuzz
47target builds, Clippy, formatting, rustdoc and its compiled example. The external
48API consumer also passes. Logs are listed in [API-AUDIT.md](API-AUDIT.md).
49
50The user accepted the public API audit and authorized continuation. The checkpoint
51changed source locations and documented existing contracts, preserving signatures
52and runtime behavior. No VMs were started for that checkpoint.
53
54### Storage interruption campaign
55
56`power-loss-02` passed 2,508 persisted-image checks and subsequent edits across
57native Unicode text, 255→256 and 65535→65536 counter rollover, an attachment page,
58and a pending 512-revision checkpoint. Each write/flush boundary is exercised with
59six persistence policies, including complete loss of unflushed writes and partial,
60reordered persistence. Comparisons cover every resolved current object, including
61raw properties and payloads. Acknowledged publication must select the complete
62new state; earlier cuts may select only the complete old or new state.
63
64`power-loss-03` repeats the same checks with exact source and failure-image
65retention added to the harness. `edit-text-fuzz-01.log` records 4,327 stateful
66inputs over 302 seconds without failure. The 22 retained boundary images from
67`power-loss-02` are undergoing fresh OneNote captures in `power-native-01`.
68
69Replay:
70
71```sh
72cargo run --release -p onestore --example power_loss -- /new/matrix-directory
73python3 tools/power_loss_native.py /new/matrix-directory /new/native-directory
74cargo +nightly fuzz run edit_text -- -max_total_time=300 -timeout=60 -max_len=128
75```
76
77The TOC extension (`power-toc-01`) passes another 918 persisted images and
78subsequent color edits, including counter rollover and a pending checkpoint.
79`power-loss-04` passes the combined 3,426-image matrix with exact source/failure
80retention after the harness refactor. The native gate uses the 22 retained images
81from `power-loss-02` and 13 from `power-toc-01`; captures verify those exact source
82hashes. All 22 section captures in `power-native-01` passed. The TOC captures in
83`power-toc-native-01` also compare OneNote's notebook color to the persisted value
84and are still running at this checkpoint.
PROGRESS.md deleted-227
...@@ -1,227 +0,0 @@
1# OneNote interoperability
2
3Target: an embeddable Rust library that reads and writes revision stores,
4preserves unedited content, and collaborates with OneNote 2010 over SMB.
5
6| Stage | Acceptance evidence | State |
7| --- | --- | --- |
8| 1. Corpus | Native single-operation fixtures, private corpus integrity, independent cold reopen, semantic assertions, reproducible provenance | Passed current corpus gate |
9| 2. Storage | Committed revision/object resolution, opaque preservation, malformed-input tests and fuzzing against native fixtures | Passed current corpus gate |
10| 3. Writer | Create and edit through the native open/edit/save/read loop without collateral changes | Passed current corpus gate |
11| 4. Durability | Injected write/flush failures, interrupted commits, acknowledged persistence, native recovery | Passed implemented scalar-commit gate |
12| 5. Collaboration | Observed locks and I/O, deliberate contention, competing edits and reconnect convergence | Passed tested scalar collaboration gate |
13
14The personal source is `/Volumes/clover/Documents/OneNote`. It is read only.
15`corpus/private/original` holds the copied baseline; `source-manifest.json` records
16SHA-256 hashes and source modification times. Native automation must operate on
17disposable copies, with the personal Windows registry and cache parked first.
18
19Wayback already contained `C:\one-tests\profile-original.reg` and
20`C:\one-tests\profile-original-cache` at task start. Native tests used an isolated
21profile with UnfiledNotesSection at `C:\one-tests\Loose.one`. After the stage-5
22captures, Restore returned the original registry and cache to their active paths;
23Status confirmed restoration, with test backups parked and OneNote closed.
24The primary native run is `corpus/native/20260905-05`, generated by OneNote
2514.0.7015.1000. Its nine snapshots were independently opened from fresh caches
26under `cold-05-*`. Text, styles, outline position, table cells, image bytes, and
27attachment bytes pass `python3 tools/verify-corpus.py`. Transactions in the
28synthetic section grow from 4 to 28 across the captured operations.
29
30`corpus/native-ink` records two native mouse-drawn strokes and their cold-open
31binary XML payloads. `evidence/stage1/native-ink.png` shows the page.
32`corpus/native-delete` records a page before deletion and its cold-open recovery
33from the notebook recycle bin. The AHK ink recipe requires the displayed
341280x720 maximized OneNote layout; semantic XML verifies that strokes survived.
35
36The private corpus has 26 pages including two recycle-bin pages. A fresh read
37can return partial page content while loading; the reader now checks the final
38hierarchy against all captured page IDs. `corpus/private/exact-native` passed an
39independent cold read with 26 expected pages. It includes images, ink, tags,
40lists, OCR, and media. All ten original source hashes and modification times
41were rechecked unchanged after native testing. COM IDs change across cache
42resets and must not serve as the file-identity oracle.
43
44`corpus/native-encrypted` records native password protection and a fresh-cache
45unlock with password `fictitious-only`. The cold XML and attachment bytes pass
46the corpus verifier. OneNote's UI introduced one leading empty paragraph;
47that paragraph is retained in the fixture oracle. `read.ps1 -UseCurrentCache`
48captures the manually unlocked test session; the caller closes its UI afterward.
49
50The corpus is a starting interoperability gate, not a complete feature matrix.
51Shared-file conflicts now have native evidence in the stage-5 corpus; ordinary
52user-visible page version history is outside this corpus gate.
53
54The Rust reader follows committed transactions, resolves revision dependencies,
55global IDs, roles, contexts, roots, reference-count overrides, and file payloads.
56Checks cover object and object-space cycles, missing targets, immutable data,
57MD5 hashes, override CRCs, and reference counts. All revisions in the ten private
58files pass the current unencrypted checks. The encrypted fixture retains opaque
59ciphertext and explicitly refuses property traversal.
60
61Native compatibility findings:
62- Transaction CRCs accumulate across prior sentinel entries and fragment links
63 are excluded. Native transaction fragments can leave four bytes after the link.
64- Reference counts include repeated references from the same source object.
65- Table-of-contents override CRCs include preceding object declaration counts,
66 although that format does not use object groups.
67- Encryption-key containers can have zero padding after the footer when their
68 compressed chunk references round the size to eight bytes.
69
70Coverage-guided runs recorded under `evidence/stage2` completed 5,529,107 storage
71inputs, 6,537,029 property inputs, and 2,998,472 revision inputs without crashes.
72These are bounded initial runs, not durability or interoperability proofs.
73An independent 100,000-level property nesting test checks parse and drop safety.
74The extended revision run completed 20,718,981 inputs in 601 seconds with a
75262,144-byte input limit and no crashes. `tools/verify-reader.py` independently
76matches 26 private pages and 581 nonempty text objects by page, three hyperlink
77targets, 18 image payloads byte for byte, and three native GIF-to-PNG conversions
78pixel for pixel. It requires Pillow. Whitespace-only paragraphs and boilerplate
79date/time text remain in the raw graph but are outside this text comparison.
80One page-like object space has undocumented metadata JCID 0x0002003E; it remains
81in the raw graph and is excluded from the ordinary-page inventory.
82
83Specs in `resources/md` are the implementation references. File-format conformance,
84actual OneNote acceptance, and SMB durability are separate checks.
85
86The first stage-3 encoding of `replace_property_bytes` appended new chunks,
87replaced the affected list path with fresh list identities, copied committed
88transaction entries, and published new header references. Every original byte
89outside the header remained unchanged. The stage-4 append protocol below
90superseded that encoding; the stage-3 fixtures retain its native acceptance.
91
92`corpus/writer` records the first native cycle: Rust replaces the plain-text
93fixture with `Portable plain text...`; OneNote reads that text from a fresh cache;
94OneNote changes it back to `Fictitious plain text.` and saves; Rust reads and edits
95that file again; a second fresh native read returns `Portable plain text...`.
96The native test uses equal-length text. Storage tests also replace the scalar
97with lengths 0 through 40 and re-resolve every prior revision unchanged.
98
99Stage 3 now includes template-free `create_section` and `create_table_of_contents`.
100The first creates one page with one plain-text paragraph and an explicit author;
101the second records ordered section filenames and their file identities. Native
102fresh-cache reads verify Unicode including a surrogate pair, both newly created
103notebook files, native editing/saving of the created section, and a subsequent
104longer Rust text replacement. `replace_property_bytes` also edits `.onetoc2`
105scalars; OneNote independently confirms the requested notebook color.
106`tools/verify-writer.py` verifies the captured native semantics and artifact hashes.
107
108The writer preserves unknown data and every prior revision. Native XML comparison
109of the ink fixture retains formatting, positions, tables, image data, two ink
110strokes, and attachment bytes; the changed outline height is allowed to reflow.
111Creation initially exposed two malformed sequences: an object group needs its
112following dependency-override node, and a root-space selector must follow that
113space's declaration. The reader now rejects both. An isolated byte-order probe
114changed a rejected empty section into a native-readable one. Failed creation
115experiments live under evidence rather than the accepted writer corpus.
116
117The native capture script now filters exact `.one` extensions: Windows wildcard
118matching had also matched `.onetoc2` and created an extra empty section. Old
119captures retain that provenance; newer reads verify one section and one page.
120Writer fuzzing completed 2,386,509 scalar inputs and 2,900,483 template-free
121section inputs without crashes. A subsequent combined section/TOC run covers
122variable section counts and reference graphs (statistics in evidence/stage3).
123The corpus gates describe these tested operations, not every MS-ONE feature.
124
125Stage 4 begins with a concrete failure: directly copying the standalone writer's
126header over a live file leaves 82 of 1025 prefix-tear states invalid on the small
127native text fixture. `examples/header_faults.rs` reproduces this. Standalone
128serialization must not be mistaken for an in-place commit protocol.
129
130The stage-4 writer now extends the existing revision-manifest list and transaction
131log instead of replacing their header pointers. Original committed nodes, property
132blobs, and prior revisions remain unchanged; unused fragment tails and log capacity
133are populated. Restoring the previous header resolves the previous graph. This
134encoding now passes independent native reads; the earlier stage-3 evidence used
135the standalone encoding.
136
137`commit_property_bytes` accepts a storage implementation through `CommitIo`.
138Its caller must supply OneNote-compatible exclusion and durable ordered flushes.
139A locked snapshot comparison precedes writes. Data and metadata flush before a
140one-byte publication write. At counter-byte rollover the log includes empty
141transactions up to the largest intermediate count; a flush of the highest changed
142counter byte precedes cleanup of lower bytes. An interrupted cleanup still resolves
143the new revision. Failures distinguish NotCommitted, Unknown, and Committed.
144The in-memory crash model exercises short reads/writes, every I/O failure point,
145and arbitrary subsets of unflushed byte changes for ordinary commits and 255→256.
146It passes, including log-fragment rollover; this is not yet a native crash test.
147Updated append scalar fuzzing completed 465,817 inputs in 181 seconds, no crashes.
148The combined section/TOC creation fuzz run completed 350,293 inputs in 181 seconds.
149
150The Windows laptop became reachable again with no Procmon or OneNote process;
151the user confirmed its battery had run out. The personal profile and cache
152remained isolated. Procmon 4.1 produced no trace, and
153the native agent is not elevated. Shared testing continued without that driver.
154`corpus/append/round-01` records independent cold native reads of plain and complex
155appended edits, TOC color, counter 255→256 and 65535→65536, their interrupted
156cleanup states, and all eight combinations of the tested numeric metadata tears.
157OneNote reads the expected old/new text, preserves the complex fixture's full XML
158and attachment bytes, and can edit/save after an interrupted counter cleanup.
159The manifest records artifact hashes; `tools/verify-writer.py` checks the captures.
160
161The crash model now includes read failures and has a shared implementation used by
162`fuzz/fuzz_targets/commit.rs`. Stateful fuzzing completed 21,506 inputs in 302
163seconds with no crash, retrying from partially persisted files at ordinary and
164255→256 commits. Full Rust tests, clippy, original corpus integrity, the private
165reader oracle, and native writer comparisons pass.
166
167`commit_file_property` acquires a whole-file filesystem lock and serializes calls
168inside the process because macOS SMB flock is reentrant within a process. POSIX
169byte-range locking returned ENOTSUP on this mount. Flock produced a server-visible
170exclusive 0+UINT64_MAX lock, blocked a Windows read, and rejected a second Mac
171process's lock. Rust sync_all uses F_FULLFSYNC on macOS; when that extension is
172unsupported, the adapter uses standard fsync. Apple's SMB source routes it through
173the ordinary SMB flush path. A real committed edit through the mount passed a
174subsequent independent cold OneNote read. This depends on the filesystem/server
175honoring flush and exclusion; it does not claim physical server power-loss testing.
176Native shared activity exposed read locks at 0xFFFFFFFB and write locks at
1770xFFFFFFFD in `evidence/stage4/shared-native-locks-02.log`.
178
179Stage 5 acceptance is captured in `corpus/collaboration/round-01`, with artifact
180hashes and `tools/verify-collaboration.py`. Each final notebook was independently
181reopened from a fresh native cache on OneNote 2010 14.0.7015.1000.
182
183| Scenario | Observed result |
184| --- | --- |
185| Whole-file lock contention | The native edit/sync calls completed while the file remained byte-identical under the lock; the saved edit appeared after release and survived cold reopen |
186| Different paragraphs | The main page contained both Rust's Unicode edit and the native outline edit |
187| Same paragraph | Rust text remained on the main page; native competing text survived in a conflict object space |
188| Native offline edit | With A: disconnected, the server retained the old text; another SMB session committed Rust text; native reconnect preserved both versions |
189| Lost preparation FLUSH reply | `NotCommitted`; native cold read retained the old paragraph |
190| Lost publication FLUSH reply | `Unknown`; native cold read recovered the new paragraph |
191| Lost counter-cleanup FLUSH reply | `Committed`; native cold read recovered the new paragraph after 255→511→256 publication |
192
193The dedicated loopback SMB session recorded server-visible whole-file locks,
194actual write ranges/counter values, and successful FLUSH replies withheld before
195client delivery. The trace verifies a successful flush of counter 511 before
196cleanup to 256. macOS revoked the disconnected file handles; the adapter returned
197errors rather than treating reconnection as acknowledgement. The proxy was stopped
198and its temporary mount directory removed after capture. A: was restored to its
199existing `\\zenith.miku-sun.ts.net\agent` mapping; the unrelated Z: mapping and
200personal macOS mounts were left in place.
201
202OneNote's COM hierarchy omits conflict pages. The reader resolves their metadata
203JCID 0x20038 and retained text; the offline fixture additionally includes a native
204UI screenshot showing the conflicting edit. The full-page COM update harness
205produced a redundant conflict copy even for different paragraphs. Its first merge
206also changed one table column from 39.14614105224609 to 38.61000061035156 points
207and one ink z-order from 7 to 6. The verifier binds those exact changes and checks
208retained image/ink payloads, table contents/styles, attachment bytes, quick styles,
209and outline positions; it does not claim pixel-identical native layout.
210
211An unlocked snapshot caught during native saving referenced an object space that
212had not yet been written. It is retained as `corpus/malformed/native-inflight.one`.
213`read_file` now shares the commit exclusion, and writer preflight validates the
214whole current graph. A regression verifies that even a no-op request against this
215intermediate graph fails before any storage I/O. Diagnostic inventory and edit
216selection also validate the graph before traversing it.
217
218Final coverage-guided revision fuzzing, seeded with native collaboration and
219transport-recovery files, completed 1,453,528 inputs in 122 seconds without a crash
220(608 MB peak RSS). The final stateful commit run completed 4,992 inputs in 91
221seconds without a crash (483 MB peak RSS), following the earlier 21,506-input run.
222All Rust targets, clippy, the corpus/private reader oracles, writer comparisons,
223and collaboration verification pass. The ten personal source files still match
224their original hashes, sizes, and modification times. This completes the bounded
225stage-5 scalar collaboration gate on the tested Windows/macOS/Samba combination;
226physical power-loss durability and other client/server implementations are not
227established by these protocol-failure captures.
README.md deleted-208
...@@ -1,208 +0,0 @@
1# onestore
2
3An experimental native Rust library for OneNote revision stores (`.one` and
4`.onetoc2`). It reads committed object graphs, creates a small notebook without
5a template, appends scalar-property and text edits with a recoverable commit protocol,
6and interprets MS-ONE document structure, formatting, media, and historical pages.
7The storage gates are recorded in [PROGRESS.md](PROGRESS.md); document-model
8verification is recorded in [M6-ACCEPTANCE.md](M6-ACCEPTANCE.md). Concurrent-editing
9verification is recorded in [MILESTONE7.md](MILESTONE7.md).
10
11Text edits publish ancestor modification timestamps with the changed content.
12Omitting those timestamps caused acknowledged edits to disappear during native
13conflict merging. The repaired eight-client replay retains all four competing
14results after reconnection, application closure and fresh-cache native inspection;
15see `mixed-conflict-06` in the milestone evidence. Use disposable copies for
16notebook editing.
17
18## Workspace
19
20`crates/onestore` contains the library, examples and integration tests. New Rust
21prototypes belong in sibling directories under `crates/` and depend on
22`onestore = { path = "../onestore" }`. The root manifest discovers these crates.
23The consumer boundary and API tradeoffs are recorded in [API-AUDIT.md](API-AUDIT.md).
24Shared native fixtures, specifications, evidence and Python/VM tools stay at the
25repository root; `fuzz/` remains an independent cargo-fuzz workspace.
26
27Run Cargo commands from the root. Select `-p onestore` when working only on the
28library, or `--workspace` for checks across all crates. Example binary paths
29remain `target/debug/examples/…` for the native verification tools.
30
31## Supported surface
32
33| API | Contract |
34| --- | --- |
35| `Store`, `RevisionIndex`, `ResolvedRevision` | Parse storage, resolve revisions and reference graphs, expose roots and objects |
36| `PropertySets`, `Object::references` | Decode properties and ID streams while retaining raw values |
37| `Object::file_reference`, `Store::file_data` | Identify internal/external payloads and read internal payload bytes |
38| `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities |
39| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |
40| `create_table_of_contents` | Create ordered section entries from filenames and file identities |
41| `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references |
42| `replace_text`, `commit_text`, `commit_file_text` | Replace a UTF-16 range within one ordinary text run; publish text, run boundaries and modification time together |
43| `read_file` | Read a snapshot under whole-file exclusion |
44| `commit_file_property` | Lock, compare the source snapshot, append and flush, then publish the revision |
45| `CommitIo`, `commit_property_bytes` | Supply another storage backend with equivalent exclusion and ordered durability |
46
47Scalar edits accept encoded values and require the caller to maintain MS-ONE
48semantics. Text edits maintain run boundaries, inherit the insertion run's formatting,
49and promote legacy text to Unicode when needed. Explicit and body-derived
50navigation titles update in the same transaction; unsupported fields,
51protected objects and split surrogate pairs are
52rejected before writing. Appended snapshots cap revision dependency depth at 512
53while retaining historical revisions. TOC snapshots can remap encoded CompactIDs
54without changing their resolved references. Password-protected
55sections retain their encrypted structure and payloads; the library does not
56derive password keys or decrypt their pages.
57External `.onebin` references identify payloads for the caller to obtain. Cloud
58FSSHTTP synchronization and a C ABI are outside the implemented surface.
59
60## Try it
61
62Requires Rust 1.97 or later for the verified build. Examples create new destinations
63and refuse to overwrite them. The Python report requires Pillow.
64
65```sh
66cargo run --example create_notebook -- /tmp/one-demo 'Hello from Rust.' 'Example Author'
67cargo run --example inventory -- /tmp/one-demo/synthetic.one
68cargo run --example inspect -- /tmp/one-demo/synthetic.one
69cargo run --example document -- /tmp/one-demo/synthetic.one /tmp/one-model
70python3 tools/notebook_report.py /tmp/one-demo /tmp/one-report --timezone America/Los_Angeles
71```
72
73A seeded text edit on a disposable copy records its page, UTF-16 range,
74replacement and outcome as JSON:
75
76```sh
77cargo run --example random_edit -- /tmp/one-demo/synthetic.one /tmp/edited.one 42
78```
79
80The report contains readable pages, document JSON, assets, source identities and
81coordinates. It preserves paragraph nesting, lists, tables, links and tags.
82Historical contexts, recycle-bin pages and default templates are represented
83separately. Native ink and structured equations retain their source data and
84appear explicitly as opaque content. The report is a reading view; its native
85PDF references supply the original canvas layout.
86
87Read and validate a snapshot before interpreting its graph:
88
89```rust,no_run
90use onestore::{read_file, RevisionIndex, Store};
91
92fn main() -> Result<(), Box<dyn std::error::Error>> {
93 let bytes = read_file("notebook/synthetic.one")?;
94 let store = Store::parse(&bytes)?;
95 if !store.checksum_mismatches.is_empty() {
96 return Err("Transaction checksum damage".into());
97 }
98 let index = RevisionIndex::parse(&store)?;
99 index.validate_current()?;
100 Ok(())
101}
102```
103
104`Store::parse` exposes checksum mismatches for diagnostic readers; the writer
105rejects them. The resolved graph borrows the snapshot. Select the object-space ID,
106object ID, and property from that graph, then pass those IDs, the same snapshot,
107and the replacement's encoded bytes to `commit_file_property`. The
108`edit_property` example demonstrates selection by JCID/property/expected bytes,
109with optional explicit IDs when conflict copies contain identical text.
110
111## Commit behavior
112
113```text
114exclusive lock → exact snapshot comparison
115 → append data → flush
116 → prepare header metadata → flush
117 → publish transaction counter → flush
118 → finish counter rollover → flush → unlock
119```
120
121Stale snapshots fail before writing. Live readers must use equivalent exclusion.
122Native conflict creation can still expose cross-space references before their
123targets are saved; such snapshots must be rejected and reread while synchronization
124proceeds. `read_file` and `commit_file_property` serialize within the process because
125macOS SMB locks can be reentrant. The lock is nonblocking across processes;
126contention requires a fresh read and a later retry.
127
128| Error state | Meaning and caller action |
129| --- | --- |
130| `NotCommitted` | This edit was not published. Preparation bytes may exist. Reread before retrying. |
131| `Unknown` | Publication may have persisted despite the error. Reread and resolve the intended edit before retrying. |
132| `Committed` | Publication was durably acknowledged; counter cleanup or lock release failed. Reopen the committed result instead of replaying the edit. |
133
134At counter rollover, empty transactions make intermediate published counts valid.
135The highest changed byte is flushed before lower bytes are cleaned up. The
136255→256 and 65535→65536 boundaries and interrupted cleanup states have independent
137native acceptance captures. A no-op still flushes; a failed flush has an unknown
138durability outcome.
139
140The filesystem adapter uses whole-file locking. On macOS it acquires the lock
141as part of opening the file (`O_EXLOCK | O_NONBLOCK`): separate open and `flock`
142calls allowed overlapping exclusive holders and stranded server locks under
143multi-process SMB contention. `tools/smb_lock_race.py` reproduces that failure
144without notebook parsing or writing. On the tested macOS SMB mount,
145POSIX byte-range locks returned `ENOTSUP`; whole-file locks excluded native
146OneNote's lock ranges. `sync_all` falls back to `fsync` on macOS only when
147`F_FULLFSYNC` is unsupported. Successful SMB FLUSH replies were observed on the
148wire. Correctness requires the backend to honor exclusion and ordered flushes.
149The evidence covers transport failures, not physical server power loss or every
150filesystem's lock implementation.
151
152## Verification
153
154```sh
155cargo test --all-targets
156cargo clippy --all-targets -- -D warnings
157python3 tools/verify-corpus.py
158python3 tools/verify-reader.py # requires Pillow and the local private corpus
159python3 tools/verify-writer.py
160python3 tools/verify-collaboration.py
161python3 tools/verify-document.py /path/to/copied/notebook /path/to/native/read
162```
163
164The frozen private corpus is excluded from version control. Its verifier checks 26 pages,
165581 text objects, hyperlink targets, exact image bytes, and native image conversions.
166Synthetic corpus manifests bind binary fixtures to independent native XML and
167attachment captures. `verify-corpus.py` also requires that private corpus.
168
169Storage tests exercise malformed references, deep properties, historical revision
170preservation, short I/O, stale snapshots, counter tears, and every injected I/O
171failure point at ordinary and rollover commits. The crash model persists arbitrary
172subsets of unflushed bytes and is shared with the stateful commit fuzzer.
173
174```sh
175cargo +nightly fuzz run revisions -- -max_total_time=120 -max_len=262144 -rss_limit_mb=2048
176cargo +nightly fuzz run commit -- -max_total_time=300 -max_len=4096 -rss_limit_mb=2048
177```
178
179Fuzz targets cover storage, properties, revisions, scalar edits, creation, and
180multi-edit interrupted commits. Seed the revision target with native `.one` files
181using symlinks under `fuzz/corpus/revisions`; empty seed directories mostly exercise
182header rejection. Bounded run counts and native findings live in `PROGRESS.md`.
183
184The document fuzzer mutates native property streams, repairs their checksums, and
185traverses every retained revision and resolved text run. Its public seeds live in
186the source target; private seeds are supplied only at runtime. Native edit-history
187tests compare independently generated operations, OneNote XML and the Rust model;
188failed histories can be replayed and shrunk in fresh disposable clones. The
189document feature matrix is in [FEATURES.md](FEATURES.md), and the milestone's
190acceptance contract is in [MILESTONE6.md](MILESTONE6.md).
191
192The stage-5 gate uses OneNote 2010 build 14.0.7015.1000 on Windows 7, a macOS SMB
193mount, and Samba on zenith. [The collaboration corpus](corpus/collaboration/round-01)
194captures native lock contention, different-paragraph merging, same-paragraph
195conflicts, offline editing/reconnection, and lost successful FLUSH replies at
196preparation, publication, and counter cleanup. Each final notebook was reopened
197from a fresh native cache. Competing text survives as native conflict pages;
198OneNote's COM hierarchy omits those pages, so the offline case also includes a
199native UI capture. Full-page COM updates produced an extra conflict copy during
200the disjoint case and two recorded geometry changes; the verifier checks those
201exact changes as well as retained image, ink, table, and attachment content.
202
203`tools/native/profile.ps1` parks/restores the personal native profile and cache;
204`cold.ps1`, `read.ps1`, and `collaborate.ps1` operate on disposable test roots.
205`tools/zenith-locks` observes server locks. `tools/smb-proxy.py` traces and interrupts
206a dedicated loopback test session; its control JSON selects the successful response
207and occurrence to withhold. The captured trace and result files are the regression
208oracle; replaying the native experiments requires the supplied Windows/share setup.
crates/onestore-diagnostic/Cargo.toml created+10
...@@ -0,0 +1,10 @@
1[package]
2name = "onestore-diagnostic"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7[dependencies]
8onestore = { path = "../onestore" }
9serde = { version = "1.0.229", features = ["derive"] }
10serde_json = "1.0.151"
crates/onestore-diagnostic/src/main.rs created+107
...@@ -0,0 +1,107 @@
1use onestore::{ExGuid, Insertion, PreparedEdit, TextAttribute};
2use serde::Deserialize;
3use serde_json::{Value, json};
4use std::{
5 env, fs,
6 io::{self, Write},
7};
8
9#[derive(Deserialize)]
10#[serde(deny_unknown_fields)]
11struct Edit {
12 space: ExGuid,
13 object: ExGuid,
14 action: Action,
15}
16
17#[derive(Deserialize)]
18#[serde(tag = "type", deny_unknown_fields)]
19enum Action {
20 Text {
21 start: u32,
22 end: u32,
23 replacement: String,
24 },
25 Format {
26 start: u32,
27 end: u32,
28 attributes: Vec<TextAttribute>,
29 },
30 Paragraph {
31 before: Option<ExGuid>,
32 text: String,
33 author: String,
34 },
35 Outline {
36 x: f32,
37 y: f32,
38 text: String,
39 author: String,
40 },
41}
42
43fn run() -> Result<Value, Box<dyn std::error::Error>> {
44 let args: Vec<_> = env::args_os().skip(1).collect();
45 if args.len() != 3
46 || !["snapshot", "check", "commit"]
47 .iter()
48 .any(|mode| args[0] == *mode)
49 {
50 return Err("Usage: onestore-diagnostic snapshot FILE NEW_SNAPSHOT | check SNAPSHOT - | commit FILE SNAPSHOT; edits arrive as JSON on stdin".into());
51 }
52 if args[0] == "snapshot" {
53 let bytes = onestore::read_file(&args[1])?;
54 let mut file = fs::OpenOptions::new()
55 .write(true)
56 .create_new(true)
57 .open(&args[2])?;
58 file.write_all(&bytes)?;
59 file.sync_all()?;
60 return Ok(json!({"ok": true, "bytes": bytes.len()}));
61 }
62 let check = args[0] == "check";
63 if check && args[2] != "-" {
64 return Err("The check command requires '-' as its final argument".into());
65 }
66 let bytes = fs::read(if check { &args[1] } else { &args[2] })?;
67 let edit: Edit = serde_json::from_reader(io::stdin().lock())?;
68 let sid = edit.space;
69 let oid = edit.object;
70 let prepared = match edit.action {
71 Action::Text {
72 start,
73 end,
74 replacement,
75 } => PreparedEdit::text(&bytes, sid, oid, start..end, &replacement)?,
76 Action::Format {
77 start,
78 end,
79 attributes,
80 } => PreparedEdit::format(&bytes, sid, oid, start..end, &attributes)?,
81 Action::Paragraph {
82 before,
83 text,
84 author,
85 } => PreparedEdit::insert(
86 &bytes,
87 sid,
88 &Insertion::paragraph(oid, before, &text, &author)?,
89 )?,
90 Action::Outline { x, y, text, author } => {
91 PreparedEdit::insert(&bytes, sid, &Insertion::outline(oid, x, y, &text, &author)?)?
92 }
93 };
94 if check {
95 return Ok(json!({"ok": true}));
96 }
97 Ok(match prepared.commit_file(&args[1]) {
98 Ok(()) => json!({"ok": true, "state": "Committed"}),
99 Err(error) => json!({"ok": false, "state": format!("{:?}", error.state),
100 "kind": format!("{:?}", error.error.kind()), "error": error.error.to_string()}),
101 })
102}
103
104fn main() {
105 let result = run().unwrap_or_else(|error| json!({"ok": false, "state": "NotCommitted", "kind": "Input", "error": error.to_string()}));
106 println!("{result}");
107}
crates/onestore-offline/Cargo.toml created+23
...@@ -0,0 +1,23 @@
1[package]
2name = "onestore-offline"
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 }
13rusqlite = { version = "=0.40.2", features = ["bundled"] }
14thiserror = "2"
15serde = { version = "1", features = ["derive"] }
16serde_json = "1"
17
18[dev-dependencies]
19tempfile = "3"
20
21[[example]]
22name = "smb_offline_client"
23required-features = ["smb"]
crates/onestore-offline/README.md created+191
...@@ -0,0 +1,191 @@
1# onestore-offline
2
3Durable local editing for a OneNote file, in an optional sibling crate. The current
4foundation stores a complete working image and typed text, insertion and formatting intents in a local
5SQLite database. `sync_once` provides a reconciliation step and `start_sync` owns
6automatic polling and reconnects. Local success does not
7acknowledge publication to a shared notebook.
8
9```no_run
10use onestore::ExGuid;
11use onestore_offline::Replica;
12# fn example(path: &std::path::Path, source: &[u8], space: ExGuid, text: ExGuid)
13# -> Result<(), Box<dyn std::error::Error>> {
14// `space` and `text` are identities from the supplied section's document model.
15let cache = Replica::create(path, source)?;
16let snapshot = cache.snapshot()?;
17let local_id = cache.edit_text(&snapshot, space, text, 0..0, "Offline edit ")?;
18drop(cache);
19
20let reopened = Replica::open(path)?;
21let current = reopened.snapshot()?;
22let pending = reopened.pending()?;
23assert_eq!(pending.last().map(|edit| edit.id), local_id);
24# Ok(())
25# }
26```
27
28`insert` accepts the core library's `Insertion` value and durably retains its
29object identities. Keep that value across retries; its `text_object()` identifies
30the new text for subsequent offline edits. Pending entries expose
31`Operation::Text(TextEdit)`, `Operation::Insert(Insertion)` or
32`Operation::Format(FormatEdit)` through their
33`operation` field. Synchronization applies these in queue order, so an inserted
34outline can precede its paragraphs and their later edits. Missing anchors or
35existing insertion identities preserve a conflict and the complete local image.
36
37`rebase_paragraph_conflict(id, local, remote, parent, before)` and
38`rebase_outline_conflict(id, local, remote, page, x, y)` accept reviewed replacement
39placements for the oldest insertion conflict. They preserve creation time, text,
40author and object identities, keeping later edits attached to their original targets.
41The images must still match the cache; the new placement must be valid in the remote
42image. Paragraph intents cannot become outlines or vice versa. Pending edits and
43uncertain publication attempts cannot be repositioned through conflict review.
44
45```no_run
46use onestore::{ExGuid, Insertion, TextAttribute};
47use onestore_offline::{EditStatus, Replica};
48# fn add_outline(cache: &Replica, space: ExGuid, page: ExGuid)
49# -> Result<(), Box<dyn std::error::Error>> {
50let outline = Insertion::outline(page, 144.0, 216.0, "Offline outline", "Author")?;
51let id = cache.insert(&cache.snapshot()?, space, &outline)?;
52cache.format(
53 &cache.snapshot()?, space, outline.text_object(), 0..7,
54 &[TextAttribute::Bold(true)],
55)?;
56if let Some(id) = id {
57 // A running worker may already have advanced this state.
58 match cache.status(id)? {
59 Some(EditStatus::Published { revision }) => println!("{revision}"),
60 state => println!("{state:?}"),
61 }
62}
63# Ok(())
64# }
65```
66
67`format` accepts the core `TextAttribute` slice and a UTF-16 range. Its durable intent
68retains the observed text and selected attribute values. Remote text changes must
69leave an unambiguous range; independent remote attributes merge, while competing
70values preserve `FormattingChanged`. A remote value that already matches the
71requested value is accepted. Enabling superscript or subscript also checks the
72opposite attribute that the operation clears. If the remote image already satisfies
73the whole operation, guarded confirmation still precedes a durable receipt.
74
75Recognized version-one through version-three caches migrate transactionally to the typed
76queue. The migration retains images, local IDs, publication attempts, conflicts,
77receipts and the autoincrement sequence; it does not reuse acknowledged IDs when
78the pending queue is empty.
79
80Share one `Replica` between application threads. Each edit compares its supplied
81snapshot under the cache transaction; stale snapshots return `Io(ResourceBusy)`.
82The intent and its resulting image commit together. No-op edits return `None`.
83Keep the cache on a local filesystem: the connection holds exclusive ownership
84between transactions, and a second open fails busy. No network wait occurs in a
85local edit. After a database error, reopen and inspect the durable state before
86retrying.
87
88`sync_once(&mut remote)` processes the oldest pending edit through a `Remote`
89implementation, returning its ID and `EditStatus`. A durable `Published` receipt
90survives reopening. Publication attempts are recorded before network I/O; a retained
91attempted revision requires comparison, flushing and refreshed header version
92metadata before acknowledgement. If a formatting attempt's revision is missing,
93the complete requested effect can instead be confirmed on a uniquely aligned
94range; its receipt identifies that confirmed current revision. Otherwise the
95missing attempt remains `AwaitingConfirmation`. Neither path replays an uncertain
96publication. Overlapping or ambiguous edits retain `Conflict` status, their complete
97local image and the last observed remote image returned by `remote_snapshot`.
98Transport errors return `Error::Remote` or `Error::RemoteIo`; inspect `status(id)`
99after the error to distinguish a retained attempt from a pending edit or receipt.
100The error return does not roll back a locally acknowledged intent.
101
102Rebasing accepts only character mappings shared by every minimum insertion/deletion
103alignment. UTF-16 ranges must preserve Unicode scalar boundaries. A bounded
104alignment search also leaves a conflict when it cannot establish a unique mapping.
105The current operation processes one queue head; while edits remain, `snapshot`
106preserves the complete local working image. An empty queue can refresh from the
107remote image. Synchronization holds a separate owner lock, so local edits can
108continue during network waits; competing synchronization calls return `WouldBlock`.
109
110`rebase_conflict(id, local, remote, range)` lets a caller explicitly place the
111oldest conflicting text or formatting intent at a reviewed UTF-16 range in the remote image.
112The supplied images must still match `snapshot()` and `remote_snapshot()`.
113It preserves the original replacement or requested attributes, intent ID, complete local working image
114and every later intent; the selected range and remote paragraph become the
115intent's new comparison base in one local transaction. Formatting also captures
116the reviewed attribute values as its new precondition. This operation performs
117no network I/O, clears the conflict to `Pending`, and wakes the worker.
118Publication still reads the latest remote image and uses exact guarded comparison;
119another overlapping remote edit can produce a new conflict. An uncertain attempt
120cannot be rebased, and selecting text that already equals the replacement does
121not create a publication acknowledgement.
122
123With the optional `smb` feature, `SmbRemote::new(client, path, limit)` binds an
124`onestore_smb::Client` to one share-relative file and snapshot limit. Remote identity uses the logical root
125object space, which survives the tested native compaction that replaces the file ID.
126
127An `Arc<Replica>` can own one background worker. Supply a connection factory, poll
128interval and observer; successful publications drain immediately, durable local
129edits wake the worker, and `wake()` requests an immediate reachability retry.
130Transport failures discard the old connection and retry through the factory;
131Read contention and `NotCommitted` operations with `WouldBlock` or `ResourceBusy`
132reuse the connection. Contended `NotCommitted` operations use randomized backoff,
133capped at one second, to separate competing retry cycles. Cancellation interrupts this delay; local wake notifications
134remain coalesced until its end.
135`RemoteIo` distinguishes connection/read failures from
136local `Io` errors. Cache/document errors stop the worker. Observers receive every
137attempt's result on the worker thread, including unchanged conflict/uncertain
138statuses; durable edit state remains available through `status`.
139
140```no_run
141# #[cfg(feature = "smb")]
142# fn example(cache: std::sync::Arc<onestore_offline::Replica>, username: String, password: String)
143# -> Result<(), Box<dyn std::error::Error>> {
144use onestore_offline::SmbRemote;
145use onestore_smb::{Client, Credentials};
146use std::time::Duration;
147
148let worker = cache.start_sync(
149 Duration::from_secs(2),
150 move || {
151 Client::connect(
152 "server:445", "notes",
153 Credentials { username: &username, password: &password, domain: "" },
154 Duration::from_secs(5),
155 ).map(|client| SmbRemote::new(client, "Personal/Video.one", 64 * 1024 * 1024))
156 },
157 |result| {
158 if let Err(error) = result { eprintln!("{error}"); }
159 },
160)?;
161// Retain `worker` while synchronization should run; local edits wake it automatically.
162worker.stop()?;
163# Ok(())
164# }
165```
166
167`stop()` cancels future steps and joins the worker, returning a fatal cache error
168or worker panic. Dropping it requests cancellation without waiting. An in-flight
169step completes before releasing ownership; a replacement worker cannot start
170while the old one still owns the replica. Remote operations and callbacks must
171have bounded execution times if shutdown latency matters. A dropped worker can
172briefly retain the cache; use `stop()` before requiring an immediate reopen.
173Cancellation and application restart retain pending edits and publication attempts.
174Credentials belong to the factory, not the cache database.
175
176Creation refuses existing paths. Opening recognizes the application identity and
177schema version, validates database integrity and both notebook images, and rejects
178unsupported journal modes without converting them. Failed initialization preserves
179the file for inspection. SQLite uses DELETE journaling, EXTRA synchronization and
180fullfsync; each required setting is queried back.
181
182The cache and its pending intents contain notebook content. The core `onestore`
183crate stays independent of SQLite and network runtimes. Device and simulator
184examples compile and link for iOS; recorded process-interruption tests do not
185establish physical power-loss durability. Evidence is tracked in [Milestone 9](../../evidence/MILESTONE9.md).
186
187The SMB-enabled `smb_offline_client` example is an owned-lab workload for
188`tools/native_collaboration.py --offline --embedded-smb`. It separates local
189acknowledgements, remote publication attempts, persisted receipts and cache reopen
190checks. Its append-specific conflict review policy lives in the test client;
191the library continues to preserve conflicts requiring an explicit decision.
crates/onestore-offline/examples/cache_probe.rs created+230
...@@ -0,0 +1,230 @@
1use onestore::{
2 ExGuid, Insertion, RevisionIndex, Store, TextAttribute,
3 document::{Document, Kind},
4};
5use onestore_offline::Replica;
6use std::{
7 io::{self, BufRead, Write},
8 path::Path,
9};
10
11fn content(bytes: &[u8]) -> (ExGuid, ExGuid, String) {
12 let store = Store::parse(bytes).unwrap();
13 assert!(store.checksum_mismatches.is_empty());
14 let index = RevisionIndex::parse(&store).unwrap();
15 index.validate_current().unwrap();
16 let document = Document::parse(&index).unwrap();
17 document
18 .spaces
19 .iter()
20 .find_map(|(sid, space)| {
21 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
22 revision
23 .nodes
24 .iter()
25 .find_map(|(oid, node)| match &node.kind {
26 Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())),
27 _ => None,
28 })
29 })
30 .unwrap()
31}
32
33fn payload(operation: u64, size: usize) -> String {
34 format!("{operation}:🦀{}", "x".repeat(size))
35}
36
37fn main() -> Result<(), Box<dyn std::error::Error>> {
38 let args: Vec<_> = std::env::args().collect();
39 let mode = &args[1];
40 let path = Path::new(&args[2]);
41 let operation_kind =
42 std::env::var("ONESTORE_CACHE_PROBE_OPERATION").unwrap_or_else(|_| "text".into());
43 assert!(matches!(
44 operation_kind.as_str(),
45 "text" | "insert" | "format"
46 ));
47 let size = match std::env::var("ONESTORE_CACHE_PROBE_BYTES") {
48 Ok(value) => value.parse::<usize>()?,
49 Err(std::env::VarError::NotPresent) => 2 * 1024 * 1024,
50 Err(error) => return Err(error.into()),
51 };
52 assert!(size > 0 && size <= 2 * 1024 * 1024);
53 let seed = std::env::var_os("ONESTORE_CACHE_PROBE_SOURCE")
54 .map(std::fs::read)
55 .transpose()?;
56 if mode == "init" {
57 let source = match seed {
58 Some(source) => source,
59 None => onestore::create_section(
60 "cache.one",
61 &if operation_kind == "format" {
62 payload(0, size)
63 } else {
64 "Base".into()
65 },
66 "Fixture",
67 )?,
68 };
69 Replica::create(path, &source)?;
70 return Ok(());
71 }
72 let cache = Replica::open(path)?;
73 if mode == "read" {
74 let snapshot = cache.snapshot()?;
75 let base = cache.remote_snapshot()?;
76 let (sid, target, mut expected) = content(&base);
77 let store = Store::parse(&snapshot)?;
78 assert!(store.checksum_mismatches.is_empty());
79 let index = RevisionIndex::parse(&store)?;
80 index.validate_current()?;
81 let document = Document::parse(&index)?;
82 let space = &document.spaces[&sid];
83 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
84 let mut operations = Vec::new();
85 let mut ids = Vec::new();
86 let mut font_size = None;
87 for pending in cache.pending()? {
88 let operation = match pending.operation {
89 onestore_offline::Operation::Text(edit) => {
90 assert_eq!(operation_kind, "text");
91 assert_eq!(edit.object, target);
92 assert_eq!(edit.before, expected);
93 assert_eq!(
94 edit.range,
95 0..u32::try_from(expected.encode_utf16().count())?
96 );
97 let operation: u64 = edit.replacement.split_once(':').unwrap().0.parse()?;
98 expected = payload(operation, size);
99 assert_eq!(edit.replacement, expected);
100 operation
101 }
102 onestore_offline::Operation::Insert(insertion) => {
103 assert_eq!(operation_kind, "insert");
104 let Kind::RichText { text, .. } =
105 &revision.nodes[&insertion.text_object()].kind
106 else {
107 panic!("Missing inserted text")
108 };
109 let operation: u64 = text.split_once(':').unwrap().0.parse()?;
110 assert_eq!(*text, payload(operation, size));
111 assert_eq!(serde_json::to_value(&insertion)?["text"], *text);
112 let outline = &revision.nodes[&insertion.object()];
113 assert!(matches!(outline.kind, Kind::Outline { .. }));
114 assert_eq!(
115 (outline.layout.x, outline.layout.y),
116 (Some(144.0), Some(operation as f32 * 72.0))
117 );
118 let (_, page) = document
119 .pages()?
120 .into_iter()
121 .find(|(space, _)| *space == sid)
122 .unwrap();
123 assert!(revision.nodes[&page].children.contains(&insertion.object()));
124 operation
125 }
126 onestore_offline::Operation::Format(edit) => {
127 assert_eq!(operation_kind, "format");
128 assert_eq!(edit.object, target);
129 assert_eq!(edit.before, expected);
130 assert_eq!(
131 edit.range,
132 0..u32::try_from(expected.encode_utf16().count())?
133 );
134 let [TextAttribute::FontSize(value)] = edit.attributes.as_slice() else {
135 panic!("Unexpected formatting intent")
136 };
137 assert!((7.0..=130.0).contains(value) && value.fract() == 0.0);
138 font_size = Some(*value);
139 *value as u64 - 6
140 }
141 };
142 assert!(operations.last().is_none_or(|last| *last < operation));
143 assert!(ids.last().is_none_or(|last| *last < pending.id));
144 operations.push(operation);
145 ids.push(pending.id);
146 }
147 assert!(matches!(&revision.nodes[&target].kind,Kind::RichText{text,..} if *text==expected));
148 if let Some(font_size) = font_size {
149 assert!(
150 revision
151 .text_runs(target)?
152 .iter()
153 .all(|run| run.format.font_size == Some(font_size))
154 );
155 }
156 if operations.is_empty() {
157 assert!(
158 snapshot == base,
159 "An empty local queue changed its working image"
160 );
161 }
162 if let Some(output) = args.get(3) {
163 std::fs::write(output, &snapshot)?;
164 }
165 println!(
166 "{}",
167 serde_json::json!({"operations": operations, "ids": ids, "section_bytes": snapshot.len(), "complete_payloads": true})
168 );
169 return Ok(());
170 }
171 assert_eq!(mode, "edit");
172 assert!(
173 matches!(Replica::open(path), Err(onestore_offline::Error::Database(error)) if error.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy))
174 );
175 println!("ready");
176 io::stdout().flush()?;
177 let input = io::stdin();
178 let mut lines = input.lock().lines();
179 let instruction = lines.next().unwrap()?;
180 let (operation, acknowledgement) = instruction.split_once(' ').unwrap();
181 let operation: u64 = operation.parse()?;
182 let source = cache.snapshot()?;
183 let (sid, oid, text) = content(&source);
184 let replacement = payload(operation, size);
185 println!("editing {operation}");
186 io::stdout().flush()?;
187 let id = match operation_kind.as_str() {
188 "text" => cache.edit_text(
189 &source,
190 sid,
191 oid,
192 0..text.encode_utf16().count().try_into()?,
193 &replacement,
194 )?,
195 "insert" => {
196 let store = Store::parse(&source)?;
197 let index = RevisionIndex::parse(&store)?;
198 let document = Document::parse(&index)?;
199 let (sid, page) = document.pages()?[0];
200 let insertion = Insertion::outline(
201 page,
202 144.0,
203 operation as f32 * 72.0,
204 &replacement,
205 "Fixture",
206 )?;
207 cache.insert(&source, sid, &insertion)?
208 }
209 "format" => {
210 assert!((1..=124).contains(&operation));
211 cache.format(
212 &source,
213 sid,
214 oid,
215 0..text.encode_utf16().count().try_into()?,
216 &[TextAttribute::FontSize(6.0 + operation as f32)],
217 )?
218 }
219 _ => unreachable!(),
220 }
221 .unwrap();
222 if acknowledgement == "unack" {
223 println!("durable {operation} {id}");
224 } else {
225 println!("ack {operation} {id}");
226 }
227 io::stdout().flush()?;
228 lines.next().transpose()?;
229 Ok(())
230}
crates/onestore-offline/examples/recovery_probe.rs created+160
...@@ -0,0 +1,160 @@
1mod support {
2 pub mod view;
3}
4use support::view::view;
5
6use onestore::{CommitError, CommitIo, PreparedEdit};
7use onestore_offline::{EditStatus, Remote, Replica};
8use serde_json::json;
9use std::{
10 env,
11 fs::{self, File, OpenOptions},
12 io::{self, Write},
13 os::unix::fs::FileExt,
14 path::Path,
15};
16
17fn phase(name: &str) {
18 println!("{}", json!({"event":"phase", "name":name}));
19 io::stdout().flush().unwrap();
20 if env::var("ONESTORE_RECOVERY_PAUSE").ok().as_deref() == Some(name) {
21 let mut line = String::new();
22 assert!(
23 io::stdin().read_line(&mut line).unwrap() > 0,
24 "Controller closed a paused operation"
25 );
26 }
27}
28
29struct Disk {
30 file: File,
31 writes: usize,
32 flushes: usize,
33}
34
35impl CommitIo for Disk {
36 fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> {
37 self.file.read_at(output, offset)
38 }
39 fn write_at(&mut self, offset: u64, data: &[u8]) -> io::Result<usize> {
40 self.writes += 1;
41 println!(
42 "{}",
43 json!({"event":"write", "number":self.writes, "offset":offset, "bytes":data.len()})
44 );
45 phase(&format!("write-{}-before", self.writes));
46 let result = self.file.write_at(data, offset);
47 phase(&format!("write-{}-after", self.writes));
48 result
49 }
50 fn flush(&mut self) -> io::Result<()> {
51 self.flushes += 1;
52 phase(&format!("flush-{}-before", self.flushes));
53 let result = self.file.sync_all();
54 phase(&format!("flush-{}-after", self.flushes));
55 result
56 }
57}
58
59impl Remote for Disk {
60 fn read(&mut self) -> io::Result<Vec<u8>> {
61 phase("read-before");
62 let result = onestore::read_snapshot(
63 |offset, output| self.file.read_at(output, offset),
64 256 * 1024 * 1024,
65 )
66 .and_then(|snapshot| snapshot.ok_or_else(|| io::ErrorKind::WouldBlock.into()));
67 phase("read-after");
68 result
69 }
70 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
71 phase("publish-before");
72 let result = edit.commit(self);
73 phase("publish-after");
74 result
75 }
76 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
77 phase("confirm-before");
78 let result = onestore::confirm_snapshot(self, snapshot);
79 phase("confirm-after");
80 result
81 }
82}
83
84fn report(cache: &Replica, root: &Path) -> Result<(), Box<dyn std::error::Error>> {
85 let local = view(&cache.snapshot()?)?;
86 let remote = view(&fs::read(root.join("remote.one"))?)?;
87 let (status, revision) = match cache.status(1)? {
88 Some(EditStatus::Pending) => ("pending", None),
89 Some(EditStatus::AwaitingConfirmation { revision }) => {
90 ("uncertain", Some(revision.to_string()))
91 }
92 Some(EditStatus::Published { revision }) => ("published", Some(revision.to_string())),
93 Some(EditStatus::Conflict(_)) => ("conflict", None),
94 None => ("missing", None),
95 };
96 println!(
97 "{}",
98 json!({"event":"state", "status":status, "revision":revision,
99 "local_text":local.text, "remote_text":remote.text, "remote_revision":remote.revision.to_string(),
100 "pending":cache.pending()?.iter().map(|pending|match &pending.operation {
101 onestore_offline::Operation::Text(edit) => json!({"id":pending.id,"before":edit.before,"replacement":edit.replacement,"range":[edit.range.start,edit.range.end]}),
102 operation => json!({"id":pending.id,"operation":operation}),
103 }).collect::<Vec<_>>() })
104 );
105 Ok(())
106}
107
108fn main() -> Result<(), Box<dyn std::error::Error>> {
109 let args: Vec<_> = env::args().skip(1).collect();
110 let mode = args
111 .first()
112 .ok_or("Expected init|inspect|sync DIRECTORY [SOURCE]")?;
113 let root = Path::new(args.get(1).ok_or("Missing owned directory")?);
114 if mode == "init" && args.len() == 3 {
115 let source = fs::read(&args[2])?;
116 let target = view(&source)?;
117 fs::create_dir(root)?;
118 let mut remote = OpenOptions::new()
119 .write(true)
120 .create_new(true)
121 .open(root.join("remote.one"))?;
122 remote.write_all(&source)?;
123 remote.sync_all()?;
124 drop(remote);
125 let cache = Replica::create(root.join("cache.sqlite"), &source)?;
126 let at = u32::try_from(target.text.encode_utf16().count())?;
127 assert_eq!(
128 cache.edit_text(
129 &source,
130 target.space,
131 target.object,
132 at..at,
133 " [offline-recovery]"
134 )?,
135 Some(1)
136 );
137 phase("local-after");
138 report(&cache, root)?;
139 } else if args.len() == 2 && ["inspect", "sync"].contains(&mode.as_str()) {
140 let cache = Replica::open(root.join("cache.sqlite"))?;
141 if mode == "sync" {
142 let mut disk = Disk {
143 file: OpenOptions::new()
144 .read(true)
145 .write(true)
146 .open(root.join("remote.one"))?,
147 writes: 0,
148 flushes: 0,
149 };
150 phase("sync-before");
151 let result = cache.sync_once(&mut disk);
152 phase("sync-after");
153 result?;
154 }
155 report(&cache, root)?;
156 } else {
157 return Err("Expected init|inspect|sync DIRECTORY [SOURCE]".into());
158 }
159 Ok(())
160}
crates/onestore-offline/examples/smb_offline_client.rs created+593
...@@ -0,0 +1,593 @@
1#[path = "../../onestore/examples/support/concurrent.rs"]
2mod concurrent;
3
4use onestore::{
5 CommitError, CommitState, ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, TextAttribute,
6 document::Document,
7};
8use onestore_offline::{EditStatus, Error, Remote, Replica, SmbRemote};
9use onestore_smb::{Client, Credentials};
10use serde_json::json;
11use std::{
12 env,
13 io::{self, Write},
14 path::{Path, PathBuf},
15 sync::Arc,
16 thread,
17 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
18};
19
20mod support {
21 pub mod view;
22}
23use concurrent::document_view;
24use support::view::view;
25
26fn now() -> u128 {
27 SystemTime::now()
28 .duration_since(UNIX_EPOCH)
29 .unwrap()
30 .as_micros()
31}
32
33#[derive(Clone)]
34enum Pause {
35 Outage(PathBuf),
36 FormatReply(PathBuf),
37}
38
39struct Traced {
40 remote: SmbRemote,
41 before: Option<(String, Option<serde_json::Value>)>,
42 pause: Option<Pause>,
43 documents: bool,
44}
45
46impl Remote for Traced {
47 fn read(&mut self) -> io::Result<Vec<u8>> {
48 let started = now();
49 let bytes = self.remote.read()?;
50 let observed = view(&bytes).map_err(|error| io::Error::other(error.to_string()))?;
51 let documents = if self.documents {
52 Some(document_view(&bytes).map_err(io::Error::other)?)
53 } else {
54 None
55 };
56 println!(
57 "{}",
58 json!({"event":"read", "started_us":started, "finished_us":now(), "text":observed.text, "documents":documents})
59 );
60 self.before = Some((observed.text, documents));
61 Ok(bytes)
62 }
63 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
64 let after = view(edit.as_bytes()).map_err(|error| CommitError {
65 state: CommitState::NotCommitted,
66 error: io::Error::other(error.to_string()),
67 })?;
68 let documents = if self.documents {
69 Some(document_view(edit.as_bytes()).map_err(|error| CommitError {
70 state: CommitState::NotCommitted,
71 error: io::Error::other(error),
72 })?)
73 } else {
74 None
75 };
76 let changes = if let Some(after) = &documents {
77 let before = self
78 .before
79 .as_ref()
80 .and_then(|(_, documents)| documents.as_ref())
81 .ok_or_else(|| CommitError {
82 state: CommitState::NotCommitted,
83 error: io::Error::other("Document publication has no observed source"),
84 })?;
85 Some(
86 after
87 .as_object()
88 .unwrap()
89 .iter()
90 .filter(|(id, value)| before.get(*id) != Some(*value))
91 .map(|(id, value)| (id.clone(), value.clone()))
92 .collect::<serde_json::Map<_, _>>(),
93 )
94 } else {
95 None
96 };
97 let pause = match &self.pause {
98 Some(Pause::Outage(marker)) => Some((
99 marker,
100 marker.parent().unwrap().join("offline-outage-resumed"),
101 "outage",
102 )),
103 Some(Pause::FormatReply(marker))
104 if changes.as_ref().is_some_and(|changes| {
105 changes.len() == 1
106 && self
107 .before
108 .as_ref()
109 .and_then(|(_, before)| before.as_ref())
110 .is_some_and(|before| changes.keys().all(|id| before.get(id).is_some()))
111 }) =>
112 {
113 Some((marker, marker.with_extension("resume"), "format"))
114 }
115 _ => None,
116 };
117 if let Some((marker, resumed, kind)) = pause
118 && !resumed.exists()
119 {
120 std::fs::write(marker, after.revision.to_string()).map_err(|error| CommitError {
121 state: CommitState::NotCommitted,
122 error,
123 })?;
124 println!(
125 "{}",
126 json!({"event":"publication_paused", "revision":after.revision.to_string(), "kind":kind, "at_us":now()})
127 );
128 let deadline = Instant::now() + Duration::from_secs(120);
129 while !resumed.exists() {
130 if Instant::now() >= deadline {
131 return Err(CommitError {
132 state: CommitState::NotCommitted,
133 error: io::Error::new(
134 io::ErrorKind::TimedOut,
135 "Offline publication barrier timed out",
136 ),
137 });
138 }
139 thread::sleep(Duration::from_millis(10));
140 }
141 }
142 let started = now();
143 let result = self.remote.publish(edit);
144 let finished = now();
145 println!(
146 "{}",
147 json!({"event":"remote_attempt", "started_us":started, "finished_us":finished,
148 "revision":after.revision.to_string(), "space":after.space.to_string(), "object":after.object.to_string(), "before":self.before.as_ref().map(|(text,_)|text), "after":after.text,
149 "state":format!("{:?}", result.as_ref().map_or_else(|error| error.state, |_| CommitState::Committed)),
150 "documents":documents, "document_changes":changes})
151 );
152 if result
153 .as_ref()
154 .is_err_and(|error| error.state == CommitState::Unknown)
155 && let Some(Pause::FormatReply(marker)) = &self.pause
156 && marker.with_extension("isolate").exists()
157 {
158 std::fs::write(marker.with_extension("isolate"), serde_json::to_vec(&json!({"space":after.space.to_string(), "revision":after.revision.to_string(), "after_us":finished})).unwrap())
159 .map_err(|error| CommitError { state:CommitState::Unknown, error })?;
160 println!(
161 "{}",
162 json!({"event":"confirmation_paused", "revision":after.revision.to_string(), "at_us":now()})
163 );
164 let deadline = Instant::now() + Duration::from_secs(120);
165 while !marker.with_extension("confirmation-resume").exists() {
166 if Instant::now() >= deadline {
167 return Err(CommitError {
168 state: CommitState::Unknown,
169 error: io::Error::new(
170 io::ErrorKind::TimedOut,
171 "Offline confirmation barrier timed out",
172 ),
173 });
174 }
175 thread::sleep(Duration::from_millis(10));
176 }
177 }
178 result
179 }
180 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
181 let captured = (|| -> Result<_, Box<dyn std::error::Error>> {
182 let store = Store::parse(snapshot)?;
183 let index = RevisionIndex::parse(&store)?;
184 let revisions = index
185 .spaces
186 .iter()
187 .map(|(id, space)| {
188 (
189 id.to_string(),
190 space
191 .revisions
192 .keys()
193 .map(ToString::to_string)
194 .collect::<Vec<_>>(),
195 )
196 })
197 .collect::<std::collections::BTreeMap<_, _>>();
198 let current = index
199 .spaces
200 .iter()
201 .filter_map(|(id, space)| {
202 space
203 .labels
204 .get(&(ExGuid::default(), 1))
205 .map(|revision| (id.to_string(), revision.to_string()))
206 })
207 .collect::<std::collections::BTreeMap<_, _>>();
208 let path = env::var_os("ONESTORE_OFFLINE_CONFIRM_DIR").map(|directory| {
209 PathBuf::from(directory).join(format!("{}-{}.one", std::process::id(), now()))
210 });
211 if let Some(path) = &path {
212 std::fs::OpenOptions::new()
213 .write(true)
214 .create_new(true)
215 .open(path)?
216 .write_all(snapshot)?;
217 }
218 Ok((revisions, current, path))
219 })();
220 let (revisions, current, capture) = captured.map_err(|error| CommitError {
221 state: CommitState::NotCommitted,
222 error: io::Error::other(error.to_string()),
223 })?;
224 let started = now();
225 let result = self.remote.confirm(snapshot);
226 println!(
227 "{}",
228 json!({"event":"remote_confirm", "started_us":started, "finished_us":now(), "revisions":revisions, "current_revisions":current, "capture":capture.as_ref().and_then(|path| path.file_name()).map(|name| name.to_string_lossy()), "text":self.before.as_ref().map(|(text,_)|text),
229 "state":format!("{:?}", result.as_ref().map_or_else(|error| error.state, |_| CommitState::Committed)), "error":result.as_ref().err().map(|error|error.error.to_string())})
230 );
231 result
232 }
233}
234
235fn tokens(text: &str) -> Option<Vec<&str>> {
236 let mut remaining = text.strip_prefix("Concurrent edits:")?;
237 let mut tokens = Vec::new();
238 while !remaining.is_empty() {
239 let body = remaining.strip_prefix(" [w")?;
240 let (token, tail) = body.split_once(']')?;
241 let (actor, operation) = token.split_once(':')?;
242 if actor.is_empty()
243 || operation.is_empty()
244 || !actor
245 .bytes()
246 .chain(operation.bytes())
247 .all(|b| b.is_ascii_digit())
248 || tokens.contains(&token)
249 {
250 return None;
251 }
252 tokens.push(token);
253 remaining = tail;
254 }
255 Some(tokens)
256}
257
258// This owned workload explicitly resolves append conflicts after all retained tokens.
259fn append_position(before: &str, current: &str, token: &str) -> Option<u32> {
260 let original = tokens(before)?;
261 let present = tokens(current)?;
262 let mut retained = present.iter();
263 for token in original {
264 retained.find(|&&candidate| candidate == token)?;
265 }
266 if current.contains(token) {
267 return None;
268 }
269 u32::try_from(current.encode_utf16().count()).ok()
270}
271
272fn queue_document(
273 cache: &Replica,
274 actor: &str,
275 operation: usize,
276 parent: Option<ExGuid>,
277 deadline: Instant,
278) -> Result<(ExGuid, [u64; 2]), Box<dyn std::error::Error>> {
279 let source = cache.snapshot()?;
280 let store = Store::parse(&source)?;
281 let index = RevisionIndex::parse(&store)?;
282 let document = Document::parse(&index)?;
283 let (space, page) = document.pages()?[0];
284 let text = format!("Document {actor}:{operation} 🦀");
285 let insertion = if operation.is_multiple_of(2) {
286 let column: u32 = actor
287 .strip_prefix('w')
288 .ok_or("Missing writer number")?
289 .parse()?;
290 Insertion::outline(
291 page,
292 144.0 + column as f32 * 240.0,
293 144.0 + operation as f32 * 72.0,
294 &text,
295 "Offline document writer",
296 )?
297 } else {
298 Insertion::paragraph(
299 parent.ok_or("Missing prior outline")?,
300 None,
301 &text,
302 "Offline document writer",
303 )?
304 };
305 let parent = if operation.is_multiple_of(2) {
306 insertion.object()
307 } else {
308 parent.unwrap()
309 };
310 let range = 1..u32::try_from(text.encode_utf16().count())? - 2;
311 let attributes = [
312 TextAttribute::Bold(true),
313 TextAttribute::FontSize(18.0 + (operation % 9) as f32),
314 TextAttribute::Color(Some([0x12, 0x34, 0x56])),
315 ];
316 let mut ids = [0; 2];
317 for (step, id) in ids.iter_mut().enumerate() {
318 loop {
319 if Instant::now() >= deadline {
320 return Err("Document queue timed out; cache retained".into());
321 }
322 let source = cache.snapshot()?;
323 let started = now();
324 let result = if step == 0 {
325 cache.insert(&source, space, &insertion)
326 } else {
327 cache.format(
328 &source,
329 space,
330 insertion.text_object(),
331 range.clone(),
332 &attributes,
333 )
334 };
335 match result {
336 Ok(Some(acknowledged)) => {
337 *id = acknowledged;
338 println!(
339 "{}",
340 json!({"event":"local_document_commit","id":acknowledged,"operation":operation,"kind":if step==0 {"insert"} else {"format"},"space":space.to_string(),"object":insertion.text_object().to_string(),"text":text,"insertion":if step==0 {Some(&insertion)} else {None},"range":[range.start,range.end],"attributes":attributes,"started_us":started,"finished_us":now()})
341 );
342 break;
343 }
344 Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {}
345 other => return Err(format!("Unexpected document queue result: {other:?}").into()),
346 }
347 }
348 }
349 Ok((parent, ids))
350}
351
352fn main() -> Result<(), Box<dyn std::error::Error>> {
353 let args: Vec<_> = env::args().skip(1).collect();
354 if args.len() != 7
355 || !["read", "write"].contains(&args[0].as_str())
356 || !args[2].bytes().all(|b| b.is_ascii_alphanumeric())
357 {
358 return Err("Expected read|write FILE ACTOR OPERATIONS START_FILE STOP_FILE SEED".into());
359 }
360 let documents = env::var_os("ONESTORE_OFFLINE_DOCUMENTS").is_some();
361 let address = env::var("ONESTORE_SMB_LAB")?;
362 let share = env::var("ONESTORE_SMB_SHARE")?;
363 let initial = Client::connect(
364 &address,
365 &share,
366 Credentials::default(),
367 Duration::from_secs(5),
368 )?;
369 if args[0] == "read" {
370 return concurrent::run(
371 &args,
372 |path| initial.read(path, 256 * 1024 * 1024),
373 |_, _, _, _, _, _| unreachable!(),
374 );
375 }
376 let timeout: u64 = env::var("ONESTORE_CLIENT_TIMEOUT_MS")
377 .unwrap_or_else(|_| "600000".into())
378 .parse()?;
379 let deadline = Instant::now()
380 .checked_add(Duration::from_millis(timeout))
381 .ok_or("Invalid timeout")?;
382 let operations: usize = args[3].parse()?;
383 let mut seed: u64 = args[6].parse()?;
384 if operations == 0 {
385 return Err("Expected positive operations".into());
386 }
387 let source = initial.read(&args[1], 256 * 1024 * 1024)?;
388 drop(initial);
389 let cache_path = Path::new(&args[4])
390 .parent()
391 .ok_or("Missing workload directory")?
392 .join(format!("{}.sqlite", args[2]));
393 let cache = Arc::new(Replica::create(&cache_path, &source)?);
394 println!(
395 "{}",
396 json!({"event":"ready", "pid":std::process::id(), "actor":args[2], "offline":true, "document_operations":documents})
397 );
398 while !Path::new(&args[4]).exists() {
399 if Instant::now() >= deadline {
400 return Err("Start barrier timed out".into());
401 }
402 thread::sleep(Duration::from_millis(5));
403 }
404 let path = args[1].clone();
405 let outage = env::var_os("ONESTORE_OFFLINE_OUTAGE_DIR").map(PathBuf::from);
406 let pause = outage
407 .as_ref()
408 .map(|directory| Pause::Outage(directory.join(format!("offline-paused-{}", args[2]))))
409 .or_else(|| {
410 env::var_os("ONESTORE_OFFLINE_FORMAT_REPLY_DIR").map(|directory| {
411 Pause::FormatReply(
412 PathBuf::from(directory).join(format!("offline-paused-{}", args[2])),
413 )
414 })
415 });
416 let (fatal_tx, fatal_rx) = std::sync::mpsc::channel();
417 let worker = cache.start_sync(Duration::from_millis(50), move || {
418 let client = Client::connect(&address, &share, Credentials::default(), Duration::from_secs(5))?;
419 println!("{}", json!({"event":"transport_connected", "at_us":now()}));
420 Ok(Traced { remote: SmbRemote::new(client, &path, 256 * 1024 * 1024), before:None, pause:pause.clone(), documents })
421 }, move |result| {
422 if let Err(error) = result {
423 println!("{}", json!({"event":"sync_error", "error":error.to_string(), "at_us":now()}));
424 if !matches!(error, Error::RemoteIo(_) | Error::Remote(_)) && !matches!(error, Error::Io(error) if error.kind() == io::ErrorKind::WouldBlock) {
425 let _ = fatal_tx.send(error.to_string());
426 }
427 }
428 })?;
429 let mut ids = Vec::new();
430 let mut document_ids = std::collections::BTreeSet::new();
431 let mut document_parent = None;
432 let result = (|| -> Result<(), Box<dyn std::error::Error>> {
433 let mut generated = 0;
434 let mut received = 0;
435 loop {
436 match fatal_rx.try_recv() {
437 Ok(error) => return Err(error.into()),
438 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
439 return Err("Worker exited before completion".into());
440 }
441 Err(std::sync::mpsc::TryRecvError::Empty) => {}
442 }
443 while received < ids.len() {
444 match cache.status(ids[received])? {
445 Some(EditStatus::Published { revision }) => {
446 println!(
447 "{}",
448 json!({"event":if document_ids.contains(&ids[received]) {"document_receipt"} else {"remote_receipt"}, "id":ids[received], "revision":revision.to_string(), "at_us":now()})
449 );
450 received += 1;
451 }
452 Some(_) => break,
453 None => return Err("Local intent disappeared".into()),
454 }
455 }
456 if Instant::now() >= deadline {
457 return Err("Offline workload timed out; cache retained".into());
458 }
459 let pending = cache.pending()?;
460 let capacity = if outage
461 .as_ref()
462 .is_some_and(|directory| !directory.join("offline-outage-down").exists())
463 {
464 1
465 } else if documents && outage.is_some() {
466 24
467 } else {
468 8
469 };
470 if generated < operations && pending.len() < capacity {
471 let source = cache.snapshot()?;
472 let target = view(&source)?;
473 let at = u32::try_from(target.text.encode_utf16().count())?;
474 let token = format!(" [{}:{}]", args[2], generated);
475 let started = now();
476 match cache.edit_text(&source, target.space, target.object, at..at, &token) {
477 Ok(Some(id)) => {
478 println!(
479 "{}",
480 json!({"event":"local_commit", "id":id, "operation":generated, "space":target.space.to_string(), "object":target.object.to_string(), "before":target.text, "token":token, "started_us":started, "finished_us":now()})
481 );
482 ids.push(id);
483 if documents {
484 let (parent, added) = queue_document(
485 &cache,
486 &args[2],
487 generated,
488 document_parent,
489 deadline,
490 )?;
491 document_parent = Some(parent);
492 document_ids.extend(added);
493 ids.extend(added);
494 }
495 generated += 1;
496 }
497 Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {}
498 other => return Err(format!("Unexpected local result: {other:?}").into()),
499 }
500 }
501 if let Some(intent) = pending.first()
502 && matches!(cache.status(intent.id)?, Some(EditStatus::Conflict(_)))
503 {
504 let local = cache.snapshot()?;
505 let remote = cache.remote_snapshot()?;
506 let current = view(&remote)?;
507 let onestore_offline::Operation::Text(edit) = &intent.operation else {
508 return Err("Expected text probe intents".into());
509 };
510 let at = append_position(&edit.before, &current.text, &edit.replacement)
511 .ok_or("Append model disagrees with retained history")?;
512 match cache.rebase_conflict(intent.id, &local, &remote, at..at) {
513 Ok(()) => println!(
514 "{}",
515 json!({"event":"reviewed_append", "id":intent.id, "before":edit.before, "remote":current.text, "token":edit.replacement, "at_us":now()})
516 ),
517 Err(Error::Io(error))
518 if [
519 io::ErrorKind::ResourceBusy,
520 io::ErrorKind::WouldBlock,
521 io::ErrorKind::InvalidInput,
522 ]
523 .contains(&error.kind()) => {}
524 Err(error) => return Err(error.into()),
525 }
526 }
527 if generated == operations && received == ids.len() {
528 if !cache.pending()?.is_empty() {
529 return Err("Acknowledged queue did not drain".into());
530 }
531 break;
532 }
533 seed = seed
534 .wrapping_mul(6364136223846793005)
535 .wrapping_add(1442695040888963407);
536 thread::sleep(Duration::from_millis(1 + (seed >> 32) % 7));
537 }
538 Ok(())
539 })();
540 let stopped = worker.stop();
541 result?;
542 stopped?;
543 drop(cache);
544 let reopened = Replica::open(&cache_path)?;
545 if !reopened.pending()?.is_empty() {
546 return Err("Pending edits reappeared after reopen".into());
547 }
548 for id in ids {
549 let Some(EditStatus::Published { revision }) = reopened.status(id)? else {
550 return Err("Receipt did not survive reopen".into());
551 };
552 println!(
553 "{}",
554 json!({"event":if document_ids.contains(&id) {"reopened_document_receipt"} else {"reopened_receipt"}, "id":id, "revision":revision.to_string()})
555 );
556 }
557 println!(
558 "{}",
559 json!({"event":"done", "operations":operations, "at_us":now()})
560 );
561 Ok(())
562}
563
564#[test]
565fn append_review_requires_a_unique_ordered_history_and_an_absent_new_token() {
566 assert_eq!(
567 append_position(
568 "Concurrent edits: [w0:0]",
569 "Concurrent edits: [w1:0] [w0:0]",
570 " [w0:1]"
571 ),
572 Some(31)
573 );
574 for current in [
575 "Concurrent edits:",
576 "Concurrent edits: [w0:0] [w0:0]",
577 "Concurrent edits: [w0:1] [w0:0]",
578 "Concurrent edits: changed [w0:0]",
579 ] {
580 assert_eq!(
581 append_position("Concurrent edits: [w0:0]", current, " [w0:1]"),
582 None
583 );
584 }
585 assert_eq!(
586 append_position(
587 "Concurrent edits: [w0:0] [w1:0]",
588 "Concurrent edits: [w1:0] [w0:0]",
589 " [w0:1]"
590 ),
591 None
592 );
593}
crates/onestore-offline/examples/support/view.rs created+57
...@@ -0,0 +1,57 @@
1use onestore::{
2 ExGuid, RevisionIndex, Store,
3 document::{Document, Kind},
4};
5
6pub struct View {
7 pub space: ExGuid,
8 pub object: ExGuid,
9 pub revision: ExGuid,
10 pub text: String,
11}
12
13pub fn view(bytes: &[u8]) -> Result<View, Box<dyn std::error::Error>> {
14 let store = Store::parse(bytes)?;
15 if !store.checksum_mismatches.is_empty() {
16 return Err("Transaction checksum damage".into());
17 }
18 let index = RevisionIndex::parse(&store)?;
19 index.validate_current()?;
20 let document = Document::parse(&index)?;
21 let mut found = Vec::new();
22 for (sid, page) in document.pages()? {
23 let space = &document.spaces[&sid];
24 let rid = space.contexts[&ExGuid::default()];
25 let revision = &space.revisions[&rid];
26 let mut pending = vec![page];
27 let mut seen = std::collections::BTreeSet::new();
28 while let Some(oid) = pending.pop() {
29 if !seen.insert(oid) {
30 continue;
31 }
32 let node = &revision.nodes[&oid];
33 pending.extend(
34 node.children
35 .iter()
36 .chain(&node.content)
37 .chain(&node.structure)
38 .copied(),
39 );
40 if let Kind::RichText { text, .. } = &node.kind
41 && text.starts_with("Concurrent edits:")
42 {
43 revision.text_runs(oid)?;
44 found.push(View {
45 space: sid,
46 object: oid,
47 revision: rid,
48 text: text.clone(),
49 });
50 }
51 }
52 }
53 if found.len() != 1 {
54 return Err("Expected one concurrent-edit paragraph".into());
55 }
56 Ok(found.pop().unwrap())
57}
crates/onestore-offline/src/formatting.rs created+204
...@@ -0,0 +1,204 @@
1use super::*;
2use onestore::TextAttribute;
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5use std::collections::BTreeMap;
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8#[serde(deny_unknown_fields)]
9struct Span {
10 end: u32,
11 values: Vec<Value>,
12}
13
14/// A formatting intent and the text/attribute values observed before local publication.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct FormatEdit {
18 pub object: ExGuid,
19 pub before: String,
20 pub range: Range<u32>,
21 pub attributes: Vec<TextAttribute>,
22 observed: Vec<Span>,
23}
24
25fn fields(attributes: &[TextAttribute]) -> BTreeMap<&'static str, Value> {
26 let mut fields = BTreeMap::new();
27 for attribute in attributes {
28 let (name, value) = match attribute {
29 TextAttribute::Bold(v) => ("bold", json!(v)),
30 TextAttribute::Italic(v) => ("italic", json!(v)),
31 TextAttribute::Underline(v) => ("underline", json!(v)),
32 TextAttribute::Strike(v) => ("strike", json!(v)),
33 TextAttribute::Superscript(v) => {
34 if *v {
35 fields.insert("subscript", json!(false));
36 }
37 ("superscript", json!(v))
38 }
39 TextAttribute::Subscript(v) => {
40 if *v {
41 fields.insert("superscript", json!(false));
42 }
43 ("subscript", json!(v))
44 }
45 TextAttribute::Font(v) => ("font", json!(v)),
46 TextAttribute::FontSize(v) => ("font_size", json!(v)),
47 TextAttribute::Color(v) | TextAttribute::Highlight(v) => (
48 if matches!(attribute, TextAttribute::Color(_)) {
49 "color"
50 } else {
51 "highlight"
52 },
53 json!(v.map_or(0xff000000, |[r, g, b]| u32::from_le_bytes([r, g, b, 0]))),
54 ),
55 };
56 fields.insert(name, value);
57 }
58 fields
59}
60
61fn observe(
62 source: &[u8],
63 space: ExGuid,
64 object: ExGuid,
65 range: Range<u32>,
66 fields: &BTreeMap<&str, Value>,
67) -> Result<Vec<Span>> {
68 let store = Store::parse(source)?;
69 let index = RevisionIndex::parse(&store)?;
70 let document = Document::parse(&index)?;
71 let space = &document.spaces[&space];
72 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
73 let mut spans: Vec<Span> = Vec::new();
74 let mut start = 0;
75 for run in revision.text_runs(object)? {
76 let end =
77 start + u32::try_from(run.text.encode_utf16().count()).map_err(io::Error::other)?;
78 if (start < range.end && range.start < end) || (start == 0 && end == 0 && range == (0..0)) {
79 let format = serde_json::to_value(run.format).map_err(io::Error::other)?;
80 let values = fields
81 .iter()
82 .map(|(name, desired)| {
83 let value = &format[*name];
84 if value.is_null() && desired.is_boolean() {
85 json!(false)
86 } else if value.is_null() && matches!(*name, "color" | "highlight") {
87 json!(0xff000000_u32)
88 } else {
89 value.clone()
90 }
91 })
92 .collect::<Vec<_>>();
93 let end = end.min(range.end) - range.start;
94 if let Some(last) = spans.last_mut().filter(|s| s.values == values) {
95 last.end = end;
96 } else {
97 spans.push(Span { end, values });
98 }
99 }
100 start = end;
101 }
102 Ok(spans)
103}
104
105impl Replica {
106 /// Durably records a visual-formatting change and its observed attribute values.
107 /// Independent remote attributes can merge; competing values preserve a conflict.
108 pub fn format(
109 &self,
110 source: &[u8],
111 space: ExGuid,
112 object: ExGuid,
113 range: Range<u32>,
114 attributes: &[TextAttribute],
115 ) -> Result<Option<u64>> {
116 let (edit, prepared) = FormatEdit::capture(source, space, object, range, attributes)?;
117 self.record(source, space, Operation::Format(edit), &prepared)
118 }
119}
120
121impl FormatEdit {
122 pub(crate) fn capture<'a>(
123 source: &'a [u8],
124 space: ExGuid,
125 object: ExGuid,
126 range: Range<u32>,
127 attributes: &[TextAttribute],
128 ) -> Result<(Self, PreparedEdit<'a>)> {
129 let prepared = PreparedEdit::format(source, space, object, range.clone(), attributes)?;
130 let before = paragraph(source, space, object)?.ok_or_else(|| {
131 io::Error::new(
132 io::ErrorKind::InvalidData,
133 "Prepared formatting has no text target",
134 )
135 })?;
136 let observed = observe(source, space, object, range.clone(), &fields(attributes))?;
137 let edit = Self {
138 object,
139 before,
140 range,
141 attributes: attributes.to_vec(),
142 observed,
143 };
144 Ok((edit, prepared))
145 }
146
147 pub(crate) fn prepare<'a>(
148 &self,
149 snapshot: &'a [u8],
150 space: ExGuid,
151 ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> {
152 let Some(text) = paragraph(snapshot, space, self.object)? else {
153 return Ok(Err(ConflictKind::TargetUnavailable));
154 };
155 let Some(range) = rebase::rebase(&self.before, &text, self.range.clone()) else {
156 return Ok(Err(ConflictKind::TextChanged));
157 };
158 let prepared = match PreparedEdit::format(
159 snapshot,
160 space,
161 self.object,
162 range.clone(),
163 &self.attributes,
164 ) {
165 Ok(prepared) => prepared,
166 Err(_) => return Ok(Err(ConflictKind::UnsupportedEdit)),
167 };
168 let desired = fields(&self.attributes);
169 let observed = observe(snapshot, space, self.object, range.clone(), &desired)?;
170 let wanted: Vec<_> = desired.values().collect();
171 for spans in [&self.observed, &observed] {
172 if spans.is_empty()
173 || (!range.is_empty() && spans[0].end == 0)
174 || spans.last().unwrap().end != range.end - range.start
175 || spans.windows(2).any(|s| s[0].end >= s[1].end)
176 || spans.iter().any(|s| s.values.len() != wanted.len())
177 {
178 return Ok(Err(ConflictKind::UnsupportedEdit));
179 }
180 }
181 let (mut before, mut after) = (0, 0);
182 while before < self.observed.len() && after < observed.len() {
183 let old = &self.observed[before];
184 let current = &observed[after];
185 if old
186 .values
187 .iter()
188 .zip(&current.values)
189 .zip(&wanted)
190 .any(|((old, new), wanted)| new != old && new != *wanted)
191 {
192 return Ok(Err(ConflictKind::FormattingChanged));
193 }
194 let end = old.end.min(current.end);
195 if old.end == end {
196 before += 1;
197 }
198 if current.end == end {
199 after += 1;
200 }
201 }
202 Ok(Ok(prepared))
203 }
204}
crates/onestore-offline/src/lib.rs created+362
...@@ -0,0 +1,362 @@
1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use onestore::{
5 ExGuid, Insertion, PreparedEdit, RevisionIndex, Store,
6 document::{Document, Kind},
7};
8use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};
9use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Duration};
10
11mod formatting;
12mod rebase;
13mod schema;
14pub use formatting::FormatEdit;
15mod sync;
16pub use sync::{ConflictKind, EditStatus, Remote};
17mod worker;
18pub use worker::SyncWorker;
19#[cfg(feature = "smb")]
20mod smb;
21#[cfg(feature = "smb")]
22pub use smb::SmbRemote;
23
24#[derive(Debug, thiserror::Error)]
25pub enum Error {
26 #[error(transparent)]
27 Database(#[from] rusqlite::Error),
28 #[error(transparent)]
29 Io(#[from] io::Error),
30 #[error(transparent)]
31 Document(#[from] onestore::Error),
32 #[error(transparent)]
33 Remote(#[from] onestore::CommitError),
34 #[error(transparent)]
35 RemoteIo(io::Error),
36}
37
38type Result<T> = std::result::Result<T, Error>;
39
40const APPLICATION_ID: u32 = 0x4f4e454f;
41const SCHEMA_VERSION: u32 = 4;
42
43/// Text and its observed precondition, retained across cache reopen and rebasing.
44#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct TextEdit {
47 pub object: ExGuid,
48 pub before: String,
49 pub range: Range<u32>,
50 pub replacement: String,
51}
52
53#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
54#[serde(deny_unknown_fields)]
55pub enum Operation {
56 Text(TextEdit),
57 Insert(Insertion),
58 Format(FormatEdit),
59}
60
61/// A locally acknowledged intent; its ID remains stable across cache reopen.
62#[derive(Debug, Clone, PartialEq)]
63pub struct PendingEdit {
64 pub id: u64,
65 pub space: ExGuid,
66 pub operation: Operation,
67}
68
69/// Owns one local cache. Share this handle between threads; a second open fails busy.
70/// SQLite's exclusive connection retains ownership between local transactions.
71pub struct Replica {
72 connection: Mutex<Connection>,
73 synchronization: Mutex<()>,
74 worker: Mutex<std::sync::Weak<worker::Signal>>,
75}
76
77impl Replica {
78 /// Seeds a new cache from a validated notebook image, refusing any existing path.
79 /// An initialization error preserves the created file for inspection.
80 pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> {
81 validate(source)?;
82 let mut options = OpenOptions::new();
83 options.read(true).write(true).create_new(true);
84 #[cfg(unix)]
85 {
86 use std::os::unix::fs::OpenOptionsExt;
87 options.mode(0o600);
88 }
89 drop(options.open(path.as_ref())?);
90 Self::connect(path.as_ref(), Some(source))
91 }
92
93 /// Reopens an existing cache and its durable pending edits without network access.
94 /// Unrecognized databases and unsupported journal modes are rejected without conversion.
95 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
96 Self::connect(path.as_ref(), None)
97 }
98
99 fn connect(path: &Path, source: Option<&[u8]>) -> Result<Self> {
100 let mut connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?;
101 connection.busy_timeout(Duration::ZERO)?;
102 connection.execute_batch(
103 "PRAGMA locking_mode=EXCLUSIVE; PRAGMA synchronous=EXTRA; PRAGMA fullfsync=ON; PRAGMA foreign_keys=ON;",
104 )?;
105 for (name, expected) in [("locking_mode", "exclusive"), ("journal_mode", "delete")] {
106 let actual: String = connection.pragma_query_value(None, name, |row| row.get(0))?;
107 if actual != expected {
108 return Err(io::Error::new(
109 io::ErrorKind::InvalidData,
110 "Unsupported cache locking or journal mode",
111 )
112 .into());
113 }
114 }
115 for (name, expected) in [("synchronous", 3), ("fullfsync", 1), ("foreign_keys", 1)] {
116 let actual: i64 = connection.pragma_query_value(None, name, |row| row.get(0))?;
117 if actual != expected {
118 return Err(io::Error::new(
119 io::ErrorKind::Unsupported,
120 "Required cache synchronization is unavailable",
121 )
122 .into());
123 }
124 }
125 let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?;
126 let application: u32 =
127 transaction.pragma_query_value(None, "application_id", |row| row.get(0))?;
128 let version: u32 =
129 transaction.pragma_query_value(None, "user_version", |row| row.get(0))?;
130 if let Some(source) = source {
131 let tables: i64 =
132 transaction
133 .query_row("SELECT count(*) FROM sqlite_schema", [], |row| row.get(0))?;
134 if application != 0 || version != 0 || tables != 0 {
135 return Err(io::Error::new(
136 io::ErrorKind::AlreadyExists,
137 "Cache initialization found an existing database",
138 )
139 .into());
140 }
141 transaction.pragma_update(None, "application_id", APPLICATION_ID)?;
142 transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?;
143 transaction.execute_batch(
144 "
145 CREATE TABLE replica (
146 id INTEGER PRIMARY KEY CHECK(id=1),
147 base BLOB NOT NULL,
148 working BLOB NOT NULL
149 ) STRICT;
150 ",
151 )?;
152 schema::create(&transaction)?;
153 transaction.execute("INSERT INTO replica VALUES (1, ?1, ?1)", [source])?;
154 } else {
155 if application != APPLICATION_ID || !(1..=SCHEMA_VERSION).contains(&version) {
156 return Err(io::Error::new(
157 io::ErrorKind::InvalidData,
158 "Unrecognized cache or unsupported schema version",
159 )
160 .into());
161 }
162 let integrity: String =
163 transaction.query_row("PRAGMA quick_check", [], |row| row.get(0))?;
164 if integrity != "ok" {
165 return Err(io::Error::new(
166 io::ErrorKind::InvalidData,
167 "Cache integrity check failed",
168 )
169 .into());
170 }
171 let (base, working): (Vec<u8>, Vec<u8>) = transaction.query_row(
172 "SELECT base, working FROM replica WHERE id=1",
173 [],
174 |row| Ok((row.get(0)?, row.get(1)?)),
175 )?;
176 if validate(&base)? != validate(&working)? {
177 return Err(io::Error::new(
178 io::ErrorKind::InvalidData,
179 "Cache images belong to different documents",
180 )
181 .into());
182 }
183 if version < SCHEMA_VERSION {
184 schema::migrate(&transaction, version)?;
185 transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?;
186 }
187 pending(&transaction)?;
188 }
189 transaction.commit()?;
190 Ok(Self {
191 connection: Mutex::new(connection),
192 synchronization: Mutex::new(()),
193 worker: Mutex::new(std::sync::Weak::new()),
194 })
195 }
196
197 /// Returns the latest complete locally committed image, including pending edits.
198 pub fn snapshot(&self) -> Result<Vec<u8>> {
199 let connection = self
200 .connection
201 .lock()
202 .map_err(|_| io::Error::other("Cache owner panicked"))?;
203 Ok(
204 connection.query_row("SELECT working FROM replica WHERE id=1", [], |row| {
205 row.get(0)
206 })?,
207 )
208 }
209
210 pub fn pending(&self) -> Result<Vec<PendingEdit>> {
211 let connection = self
212 .connection
213 .lock()
214 .map_err(|_| io::Error::other("Cache owner panicked"))?;
215 pending(&connection)
216 }
217
218 /// Atomically records an intent and its resulting local image; returns its durable ID.
219 /// Unchanged text returns `None`. A stale image returns `Io(ResourceBusy)`.
220 /// After a database error, reopen and inspect the cache before retrying the edit.
221 pub fn edit_text(
222 &self,
223 source: &[u8],
224 space: ExGuid,
225 object: ExGuid,
226 range: Range<u32>,
227 replacement: &str,
228 ) -> Result<Option<u64>> {
229 let edit = PreparedEdit::text(source, space, object, range.clone(), replacement)?;
230 let before = paragraph(source, space, object)?.ok_or_else(|| {
231 io::Error::new(
232 io::ErrorKind::InvalidData,
233 "Prepared edit has no text target",
234 )
235 })?;
236 self.record(
237 source,
238 space,
239 Operation::Text(TextEdit {
240 object,
241 before,
242 range,
243 replacement: replacement.to_owned(),
244 }),
245 &edit,
246 )
247 }
248
249 /// Durably queues a validated insertion with its stable object identities.
250 /// Uses the same snapshot and local-acknowledgement contract as `edit_text`.
251 pub fn insert(
252 &self,
253 source: &[u8],
254 space: ExGuid,
255 insertion: &Insertion,
256 ) -> Result<Option<u64>> {
257 let edit = PreparedEdit::insert(source, space, insertion)?;
258 self.record(source, space, Operation::Insert(insertion.clone()), &edit)
259 }
260
261 fn record(
262 &self,
263 source: &[u8],
264 space: ExGuid,
265 operation: Operation,
266 edit: &PreparedEdit<'_>,
267 ) -> Result<Option<u64>> {
268 let mut connection = self
269 .connection
270 .lock()
271 .map_err(|_| io::Error::other("Cache owner panicked"))?;
272 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
273 let current: Vec<u8> =
274 transaction.query_row("SELECT working FROM replica WHERE id=1", [], |row| {
275 row.get(0)
276 })?;
277 if current != source {
278 return Err(io::Error::new(
279 io::ErrorKind::ResourceBusy,
280 "The local snapshot changed before this edit",
281 )
282 .into());
283 }
284 if edit.as_bytes() == source {
285 return Ok(None);
286 }
287 transaction.execute(
288 "INSERT INTO edits(space, operation) VALUES (?1, ?2)",
289 params![
290 space.to_string(),
291 serde_json::to_string(&operation).map_err(io::Error::other)?
292 ],
293 )?;
294 let id = u64::try_from(transaction.last_insert_rowid()).map_err(io::Error::other)?;
295 transaction.execute(
296 "UPDATE replica SET working=?1 WHERE id=1",
297 [edit.as_bytes()],
298 )?;
299 transaction.commit()?;
300 drop(connection);
301 self.wake_sync();
302 Ok(Some(id))
303 }
304
305 fn wake_sync(&self) {
306 if let Ok(worker) = self.worker.lock()
307 && let Some(worker) = worker.upgrade()
308 {
309 worker.wake();
310 }
311 }
312}
313
314fn paragraph(source: &[u8], space: ExGuid, object: ExGuid) -> Result<Option<String>> {
315 let store = Store::parse(source)?;
316 let index = RevisionIndex::parse(&store)?;
317 let document = Document::parse(&index)?;
318 let node = document
319 .spaces
320 .get(&space)
321 .and_then(|space| {
322 space
323 .contexts
324 .get(&ExGuid::default())
325 .and_then(|revision| space.revisions.get(revision))
326 })
327 .and_then(|revision| revision.nodes.get(&object));
328 Ok(match node.map(|node| &node.kind) {
329 Some(Kind::RichText { text, .. }) => Some(text.clone()),
330 _ => None,
331 })
332}
333
334fn validate(source: &[u8]) -> Result<ExGuid> {
335 let store = Store::parse(source)?;
336 if !store.checksum_mismatches.is_empty() {
337 return Err(io::Error::new(
338 io::ErrorKind::InvalidData,
339 "Notebook transaction checksum damage",
340 )
341 .into());
342 }
343 let index = RevisionIndex::parse(&store)?;
344 index.validate_current()?;
345 Document::parse(&index)?;
346 Ok(index.root)
347}
348
349fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> {
350 let mut query = connection.prepare("SELECT id, space, operation FROM edits ORDER BY id")?;
351 let mut rows = query.query([])?;
352 let mut edits = Vec::new();
353 while let Some(row) = rows.next()? {
354 edits.push(PendingEdit {
355 id: u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?,
356 space: row.get::<_, String>(1)?.parse()?,
357 operation: serde_json::from_str(&row.get::<_, String>(2)?)
358 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
359 });
360 }
361 Ok(edits)
362}
crates/onestore-offline/src/rebase.rs created+297
...@@ -0,0 +1,297 @@
1use std::ops::Range;
2
3const INFINITY: u32 = 1 << 30;
4const MAX_CELLS: usize = 4_000_000;
5
6struct Matrix {
7 band: usize,
8 values: Vec<u32>,
9}
10
11impl Matrix {
12 fn get(&self, row: usize, column: usize) -> u32 {
13 let Some(column) = column
14 .checked_add(self.band)
15 .and_then(|at| at.checked_sub(row))
16 else {
17 return INFINITY;
18 };
19 let width = self.band * 2 + 1;
20 if column >= width {
21 return INFINITY;
22 }
23 self.values
24 .get(row * width + column)
25 .copied()
26 .unwrap_or(INFINITY)
27 }
28
29 fn build(before: &[char], after: &[char], band: usize) -> Self {
30 let width = band * 2 + 1;
31 let mut matrix = Self {
32 band,
33 values: vec![INFINITY; (before.len() + 1) * width],
34 };
35 for row in 0..=before.len() {
36 for column in row.saturating_sub(band)..=after.len().min(row + band) {
37 let mut cost = if row == 0 && column == 0 { 0 } else { INFINITY };
38 if row > 0 {
39 cost = cost.min(matrix.get(row - 1, column) + 1);
40 }
41 if column > 0 {
42 cost = cost.min(matrix.get(row, column - 1) + 1);
43 }
44 if row > 0 && column > 0 && before[row - 1] == after[column - 1] {
45 cost = cost.min(matrix.get(row - 1, column - 1));
46 }
47 matrix.values[row * width + column + band - row] = cost;
48 }
49 }
50 matrix
51 }
52}
53
54/// Requires the same mapping in every minimum insertion/deletion alignment.
55pub(crate) fn rebase(before: &str, after: &str, range: Range<u32>) -> Option<Range<u32>> {
56 if range.start > range.end {
57 return None;
58 }
59 let mut a: Vec<_> = before.chars().collect();
60 let offsets: Vec<_> = std::iter::once(0)
61 .chain(a.iter().scan(0_u32, |at, character| {
62 *at = at.checked_add(character.len_utf16() as u32)?;
63 Some(*at)
64 }))
65 .collect();
66 let local =
67 offsets.binary_search(&range.start).ok()?..offsets.binary_search(&range.end).ok()?;
68 if before == after {
69 return Some(range);
70 }
71 let mut b: Vec<_> = after.chars().collect();
72 let (n, m) = (a.len(), b.len());
73 let mut band = n.abs_diff(m).max(1);
74 let forward = loop {
75 let width = band.checked_mul(2)?.checked_add(1)?;
76 if (n + 1).checked_mul(width)? > MAX_CELLS {
77 return None;
78 }
79 let matrix = Matrix::build(&a, &b, band);
80 if matrix.get(n, m) <= band as u32 {
81 break matrix;
82 }
83 band *= 2;
84 };
85 let distance = forward.get(n, m);
86 a.reverse();
87 b.reverse();
88 let backward = Matrix::build(&a, &b, band);
89 a.reverse();
90 b.reverse();
91 let position = |index: usize| {
92 let mut matched = None;
93 for column in index.saturating_sub(band)..=m.min(index + band) {
94 let cost = forward.get(index, column);
95 if cost + 1 + backward.get(n - index - 1, m - column) == distance {
96 return None;
97 }
98 if b.get(column) == Some(&a[index])
99 && cost + backward.get(n - index - 1, m - column - 1) == distance
100 {
101 if matched.is_some() {
102 return None;
103 }
104 matched = Some(column);
105 }
106 }
107 matched
108 };
109 let mapped = if local.is_empty() {
110 if local.start > 0 {
111 position(local.start - 1)?;
112 }
113 if local.start < n {
114 position(local.start)?;
115 }
116 let mut boundary = None;
117 for column in local.start.saturating_sub(band)..=m.min(local.start + band) {
118 if forward.get(local.start, column) + backward.get(n - local.start, m - column)
119 == distance
120 {
121 if boundary.is_some() {
122 return None;
123 }
124 boundary = Some(column);
125 }
126 }
127 let at = boundary?;
128 at..at
129 } else {
130 let start = position(local.start)?;
131 for index in local.start + 1..local.end {
132 if position(index)? != start + index - local.start {
133 return None;
134 }
135 }
136 start..start + local.len()
137 };
138 let start = b[..mapped.start]
139 .iter()
140 .map(|character| character.len_utf16())
141 .sum::<usize>();
142 let end = start
143 + b[mapped]
144 .iter()
145 .map(|character| character.len_utf16())
146 .sum::<usize>();
147 Some(u32::try_from(start).ok()?..u32::try_from(end).ok()?)
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 fn optimal_paths(a: &[char], b: &[char]) -> Vec<Vec<(usize, usize)>> {
155 fn visit(
156 a: &[char],
157 b: &[char],
158 path: &mut Vec<(usize, usize)>,
159 cost: usize,
160 best: &mut usize,
161 found: &mut Vec<Vec<(usize, usize)>>,
162 ) {
163 if cost > *best {
164 return;
165 }
166 let (i, j) = *path.last().unwrap();
167 if (i, j) == (a.len(), b.len()) {
168 if cost < *best {
169 found.clear();
170 *best = cost;
171 }
172 found.push(path.clone());
173 return;
174 }
175 for (next_i, next_j, charge) in [(i + 1, j + 1, 0), (i + 1, j, 1), (i, j + 1, 1)] {
176 if next_i > a.len() || next_j > b.len() || (charge == 0 && a[i] != b[j]) {
177 continue;
178 }
179 path.push((next_i, next_j));
180 visit(a, b, path, cost + charge, best, found);
181 path.pop();
182 }
183 }
184 let mut found = Vec::new();
185 let mut best = usize::MAX;
186 visit(a, b, &mut vec![(0, 0)], 0, &mut best, &mut found);
187 found
188 }
189
190 #[test]
191 fn bounded_alignment_agrees_with_exhaustive_paths_for_every_small_unicode_edit() {
192 let mut words = vec![String::new()];
193 for length in 1..=4 {
194 for bits in 0..1 << length {
195 words.push(
196 (0..length)
197 .map(|bit| if bits & (1 << bit) == 0 { 'a' } else { '🦀' })
198 .collect(),
199 );
200 }
201 }
202 let mut cases = 0;
203 for before in &words {
204 let a: Vec<_> = before.chars().collect();
205 for after in &words {
206 let b: Vec<_> = after.chars().collect();
207 let paths = optimal_paths(&a, &b);
208 for start in 0..=a.len() {
209 for end in start..=a.len() {
210 let mut expected = None;
211 let mut valid = true;
212 for path in &paths {
213 let mapping: Vec<_> = (0..a.len())
214 .map(|i| {
215 path.windows(2).find_map(|edge| {
216 (edge[0].0 == i && edge[1] == (i + 1, edge[0].1 + 1))
217 .then_some(edge[0].1)
218 })
219 })
220 .collect();
221 let candidate = if start == end {
222 let vertices: Vec<_> = path
223 .iter()
224 .filter(|(i, _)| *i == start)
225 .map(|(_, j)| *j)
226 .collect();
227 if (start > 0 && mapping[start - 1].is_none())
228 || (start < a.len() && mapping[start].is_none())
229 || vertices.len() != 1
230 {
231 None
232 } else {
233 Some(vertices[0]..vertices[0])
234 }
235 } else if let Some(first) = mapping[start] {
236 (start..end)
237 .all(|i| mapping[i] == Some(first + i - start))
238 .then_some(first..first + end - start)
239 } else {
240 None
241 };
242 let Some(candidate) = candidate else {
243 valid = false;
244 break;
245 };
246 if expected.as_ref().is_some_and(|old| *old != candidate) {
247 valid = false;
248 break;
249 }
250 expected = Some(candidate);
251 }
252 let expected = if valid {
253 expected.map(|range| {
254 b[..range.start].iter().map(|c| c.len_utf16() as u32).sum()
255 ..b[..range.end].iter().map(|c| c.len_utf16() as u32).sum()
256 })
257 } else {
258 None
259 };
260 let range = a[..start].iter().map(|c| c.len_utf16() as u32).sum()
261 ..a[..end].iter().map(|c| c.len_utf16() as u32).sum();
262 assert_eq!(
263 rebase(before, after, range),
264 expected,
265 "{before:?} -> {after:?}, characters {start}..{end}"
266 );
267 cases += 1;
268 }
269 }
270 }
271 }
272 assert_eq!(cases, 10881);
273 }
274
275 #[test]
276 fn maps_disjoint_unicode_edits_and_rejects_ambiguous_or_overlapping_changes() {
277 assert_eq!(rebase("ab🦀cd", "Xab🦀cYd", 2..4), Some(3..5));
278 assert_eq!(rebase("abc", "XabcY", 1..2), Some(2..3));
279 assert_eq!(rebase("abc", "XabcY", 1..1), Some(2..2));
280 assert_eq!(rebase("abc", "abcX", 3..3), None);
281 assert_eq!(rebase("abc", "ac", 1..2), None);
282 assert_eq!(rebase("abc", "", 1..1), None);
283 assert_eq!(rebase("aaaa", "aaaaa", 1..2), None);
284 assert_eq!(rebase("ab🦀cd", "ab🦀cd", 3..4), None);
285 assert_eq!(rebase("abc", "abc", 4..4), None);
286 }
287
288 #[test]
289 fn long_paragraphs_with_small_remote_changes_use_a_narrow_band() {
290 let text = format!("{}🦀{}", "a".repeat(8192), "b".repeat(8192));
291 assert_eq!(
292 rebase(&text, &format!("X{text}Y"), 8192..8194),
293 Some(8193..8195)
294 );
295 assert_eq!(rebase(&"a".repeat(8192), &"b".repeat(8192), 1..2), None);
296 }
297}
crates/onestore-offline/src/schema.rs created+119
...@@ -0,0 +1,119 @@
1use super::*;
2use rusqlite::{OptionalExtension, Transaction};
3
4const CONFLICTS: &str = "CREATE TABLE conflicts (
5 edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE,
6 kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 3)
7) STRICT;";
8
9pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> {
10 transaction.execute_batch(
11 "CREATE TABLE edits (
12 id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0),
13 space TEXT NOT NULL,
14 operation TEXT NOT NULL
15 ) STRICT;
16 CREATE TABLE attempt (
17 id INTEGER PRIMARY KEY CHECK(id=1),
18 edit_id INTEGER NOT NULL UNIQUE REFERENCES edits(id) ON DELETE CASCADE,
19 revision TEXT NOT NULL
20 ) STRICT;
21 CREATE TABLE receipts (
22 edit_id INTEGER PRIMARY KEY CHECK(edit_id>0),
23 revision TEXT NOT NULL
24 ) STRICT;",
25 )?;
26 transaction.execute_batch(CONFLICTS)?;
27 Ok(())
28}
29
30pub(crate) fn migrate(transaction: &Transaction<'_>, version: u32) -> Result<()> {
31 if version == 3 {
32 transaction.execute_batch("ALTER TABLE conflicts RENAME TO old_conflicts;")?;
33 transaction.execute_batch(CONFLICTS)?;
34 transaction.execute_batch(
35 "INSERT INTO conflicts SELECT * FROM old_conflicts; DROP TABLE old_conflicts;",
36 )?;
37 return Ok(());
38 }
39
40 let mut query = transaction.prepare(
41 "SELECT id, space, object, before_text, start, end, replacement FROM edits ORDER BY id",
42 )?;
43 let mut rows = query.query([])?;
44 let mut edits = Vec::new();
45 while let Some(row) = rows.next()? {
46 edits.push(PendingEdit {
47 id: u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?,
48 space: row.get::<_, String>(1)?.parse()?,
49 operation: Operation::Text(TextEdit {
50 object: row.get::<_, String>(2)?.parse()?,
51 before: row.get(3)?,
52 range: row.get(4)?..row.get(5)?,
53 replacement: row.get(6)?,
54 }),
55 });
56 }
57 drop(rows);
58 drop(query);
59 let sequence: i64 = transaction
60 .query_row(
61 "SELECT seq FROM sqlite_sequence WHERE name='edits'",
62 [],
63 |row| row.get(0),
64 )
65 .optional()?
66 .unwrap_or(0);
67 let (mut attempts, mut conflicts, mut receipts) = (Vec::new(), Vec::new(), Vec::new());
68 if version == 2 {
69 let mut query = transaction.prepare("SELECT edit_id, revision FROM attempt")?;
70 attempts = query
71 .query_map([], |row| {
72 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
73 })?
74 .collect::<std::result::Result<_, _>>()?;
75 let mut query = transaction.prepare("SELECT edit_id, kind FROM conflicts")?;
76 conflicts = query
77 .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))?
78 .collect::<std::result::Result<_, _>>()?;
79 let mut query = transaction.prepare("SELECT edit_id, revision FROM receipts")?;
80 receipts = query
81 .query_map([], |row| {
82 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
83 })?
84 .collect::<std::result::Result<_, _>>()?;
85 transaction
86 .execute_batch("DROP TABLE attempt; DROP TABLE conflicts; DROP TABLE receipts;")?;
87 }
88 transaction.execute_batch("DROP TABLE edits;")?;
89 create(transaction)?;
90 for edit in edits {
91 transaction.execute(
92 "INSERT INTO edits(id,space,operation) VALUES (?1,?2,?3)",
93 params![
94 i64::try_from(edit.id).map_err(io::Error::other)?,
95 edit.space.to_string(),
96 serde_json::to_string(&edit.operation).map_err(io::Error::other)?
97 ],
98 )?;
99 }
100 // A drained queue must not reuse IDs belonging to existing durable receipts.
101 transaction.execute("DELETE FROM sqlite_sequence WHERE name='edits'", [])?;
102 transaction.execute(
103 "INSERT INTO sqlite_sequence(name,seq) VALUES ('edits',?1)",
104 [sequence],
105 )?;
106 for (id, revision) in attempts {
107 transaction.execute(
108 "INSERT INTO attempt VALUES (1,?1,?2)",
109 params![id, revision],
110 )?;
111 }
112 for (id, kind) in conflicts {
113 transaction.execute("INSERT INTO conflicts VALUES (?1,?2)", params![id, kind])?;
114 }
115 for (id, revision) in receipts {
116 transaction.execute("INSERT INTO receipts VALUES (?1,?2)", params![id, revision])?;
117 }
118 Ok(())
119}
crates/onestore-offline/src/smb.rs created+36
...@@ -0,0 +1,36 @@
1use crate::Remote;
2use onestore::{CommitError, PreparedEdit};
3use onestore_smb::Client;
4use std::io;
5
6/// Binds every reconciliation operation to one share-relative file and read limit.
7/// Connection loss retires the client; reconnect before subsequent sync attempts.
8pub struct SmbRemote {
9 client: Client,
10 path: String,
11 limit: usize,
12}
13
14impl SmbRemote {
15 pub fn new(client: Client, path: impl Into<String>, limit: usize) -> Self {
16 Self {
17 client,
18 path: path.into(),
19 limit,
20 }
21 }
22}
23
24impl Remote for SmbRemote {
25 fn read(&mut self) -> io::Result<Vec<u8>> {
26 self.client.read(&self.path, self.limit)
27 }
28
29 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
30 self.client.commit_prepared(&self.path, edit)
31 }
32
33 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
34 self.client.confirm_snapshot(&self.path, snapshot)
35 }
36}
crates/onestore-offline/src/sync.rs created+458
...@@ -0,0 +1,458 @@
1use super::*;
2use onestore::{CommitError, CommitState};
3use rusqlite::OptionalExtension;
4use std::sync::{MutexGuard, TryLockError};
5
6/// A single remote file with fresh reads and native-compatible guarded publication.
7/// Errors retain publication state; confirmation compares, flushes, and notifies cached readers.
8pub trait Remote {
9 fn read(&mut self) -> io::Result<Vec<u8>>;
10 fn publish(&mut self, edit: &PreparedEdit<'_>) -> std::result::Result<(), CommitError>;
11 fn confirm(&mut self, snapshot: &[u8]) -> std::result::Result<(), CommitError>;
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15#[repr(i64)]
16pub enum ConflictKind {
17 TextChanged = 0,
18 TargetUnavailable = 1,
19 UnsupportedEdit = 2,
20 FormattingChanged = 3,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum EditStatus {
25 Pending,
26 AwaitingConfirmation {
27 revision: ExGuid,
28 },
29 Conflict(ConflictKind),
30 /// Revision containing the confirmed effect; it can differ from a retired attempted revision.
31 Published {
32 revision: ExGuid,
33 },
34}
35
36impl Replica {
37 /// Returns a durable receipt or the persisted state of a locally acknowledged edit.
38 pub fn status(&self, id: u64) -> Result<Option<EditStatus>> {
39 let id = i64::try_from(id).map_err(io::Error::other)?;
40 let connection = self
41 .connection
42 .lock()
43 .map_err(|_| io::Error::other("Cache owner panicked"))?;
44 if let Some(revision) = connection
45 .query_row(
46 "SELECT revision FROM receipts WHERE edit_id=?1",
47 [id],
48 |row| row.get::<_, String>(0),
49 )
50 .optional()?
51 {
52 return Ok(Some(EditStatus::Published {
53 revision: revision.parse()?,
54 }));
55 }
56 let record: Option<(Option<String>, Option<i64>)> = connection.query_row(
57 "SELECT attempt.revision, conflicts.kind FROM edits LEFT JOIN attempt ON attempt.edit_id=edits.id LEFT JOIN conflicts ON conflicts.edit_id=edits.id WHERE edits.id=?1", [id], |row| Ok((row.get(0)?,row.get(1)?))).optional()?;
58 Ok(match record {
59 None => None,
60 Some((Some(revision), _)) => Some(EditStatus::AwaitingConfirmation {
61 revision: revision.parse()?,
62 }),
63 Some((None, Some(kind))) => Some(EditStatus::Conflict(match kind {
64 0 => ConflictKind::TextChanged,
65 1 => ConflictKind::TargetUnavailable,
66 2 => ConflictKind::UnsupportedEdit,
67 3 => ConflictKind::FormattingChanged,
68 _ => {
69 return Err(io::Error::new(
70 io::ErrorKind::InvalidData,
71 "Unknown cached conflict kind",
72 )
73 .into());
74 }
75 })),
76 Some((None, None)) => Some(EditStatus::Pending),
77 })
78 }
79
80 /// The last observed remote image, retained alongside the complete local working image.
81 /// Observation alone does not acknowledge any pending edit's remote durability.
82 pub fn remote_snapshot(&self) -> Result<Vec<u8>> {
83 let connection = self
84 .connection
85 .lock()
86 .map_err(|_| io::Error::other("Cache owner panicked"))?;
87 Ok(connection.query_row("SELECT base FROM replica WHERE id=1", [], |row| row.get(0))?)
88 }
89
90 /// Reconciles one pending edit, or refreshes the working image when the queue is empty.
91 /// Network I/O holds synchronization ownership without holding the cache mutex.
92 /// Uncertain edits are never replayed; retired formatting may confirm its complete observed effect.
93 pub fn sync_once(&self, remote: &mut impl Remote) -> Result<Option<(u64, EditStatus)>> {
94 let _owner = self.sync_owner()?;
95 let snapshot = remote.read().map_err(Error::RemoteIo)?;
96 let identity = validate(&snapshot)?;
97 let (intent, attempted) = {
98 let mut connection = self
99 .connection
100 .lock()
101 .map_err(|_| io::Error::other("Cache owner panicked"))?;
102 let transaction =
103 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
104 let base: Vec<u8> =
105 transaction
106 .query_row("SELECT base FROM replica WHERE id=1", [], |row| row.get(0))?;
107 let base_store = Store::parse(&base)?;
108 if RevisionIndex::parse(&base_store)?.root != identity {
109 return Err(io::Error::new(
110 io::ErrorKind::InvalidInput,
111 "Remote snapshot belongs to another document",
112 )
113 .into());
114 }
115 let Some(intent) = pending(&transaction)?.into_iter().next() else {
116 transaction.execute(
117 "UPDATE replica SET base=?1, working=?1 WHERE id=1",
118 [&snapshot],
119 )?;
120 transaction.commit()?;
121 return Ok(None);
122 };
123 let attempted = transaction
124 .query_row(
125 "SELECT revision FROM attempt WHERE edit_id=?1",
126 [i64::try_from(intent.id).map_err(io::Error::other)?],
127 |row| row.get::<_, String>(0),
128 )
129 .optional()?;
130 transaction.execute("UPDATE replica SET base=?1 WHERE id=1", [&snapshot])?;
131 transaction.commit()?;
132 (intent, attempted)
133 };
134 if let Some(revision) = attempted {
135 let mut revision = revision.parse::<ExGuid>()?;
136 let store = Store::parse(&snapshot)?;
137 let index = RevisionIndex::parse(&store)?;
138 if !index
139 .spaces
140 .get(&intent.space)
141 .is_some_and(|space| space.revisions.contains_key(&revision))
142 {
143 let satisfied = match &intent.operation {
144 Operation::Format(edit) => edit
145 .prepare(&snapshot, intent.space)?
146 .is_ok_and(|prepared| prepared.as_bytes() == snapshot),
147 _ => false,
148 };
149 if !satisfied {
150 return Ok(Some((
151 intent.id,
152 EditStatus::AwaitingConfirmation { revision },
153 )));
154 }
155 revision = index.spaces[&intent.space].labels[&(ExGuid::default(), 1)];
156 }
157 if let Err(error) = remote.confirm(&snapshot) {
158 if error.state == CommitState::Committed {
159 self.acknowledge(intent.id, revision, &snapshot)?;
160 }
161 return Err(error.into());
162 }
163 self.acknowledge(intent.id, revision, &snapshot)?;
164 return Ok(Some((intent.id, EditStatus::Published { revision })));
165 }
166 let candidate = match &intent.operation {
167 Operation::Format(edit) => edit.prepare(&snapshot, intent.space)?,
168 Operation::Insert(insertion) => {
169 PreparedEdit::insert(&snapshot, intent.space, insertion)
170 .map_err(|_| ConflictKind::UnsupportedEdit)
171 }
172 Operation::Text(edit) => paragraph(&snapshot, intent.space, edit.object)?
173 .ok_or(ConflictKind::TargetUnavailable)
174 .and_then(|text| {
175 crate::rebase::rebase(&edit.before, &text, edit.range.clone())
176 .ok_or(ConflictKind::TextChanged)
177 })
178 .and_then(|range| {
179 PreparedEdit::text(
180 &snapshot,
181 intent.space,
182 edit.object,
183 range,
184 &edit.replacement,
185 )
186 .map_err(|_| ConflictKind::UnsupportedEdit)
187 }),
188 };
189 let prepared = match candidate {
190 Ok(prepared) => prepared,
191 Err(kind) => {
192 let connection = self
193 .connection
194 .lock()
195 .map_err(|_| io::Error::other("Cache owner panicked"))?;
196 connection.execute("INSERT INTO conflicts(edit_id, kind) VALUES (?1, ?2) ON CONFLICT(edit_id) DO UPDATE SET kind=excluded.kind", params![i64::try_from(intent.id).map_err(io::Error::other)?, kind as i64])?;
197 return Ok(Some((intent.id, EditStatus::Conflict(kind))));
198 }
199 };
200 if prepared.as_bytes() == snapshot {
201 let store = Store::parse(&snapshot)?;
202 let index = RevisionIndex::parse(&store)?;
203 let revision = index.spaces[&intent.space].labels[&(ExGuid::default(), 1)];
204 if let Err(error) = remote.confirm(&snapshot) {
205 if error.state == CommitState::Committed {
206 self.acknowledge(intent.id, revision, &snapshot)?;
207 }
208 return Err(error.into());
209 }
210 self.acknowledge(intent.id, revision, &snapshot)?;
211 return Ok(Some((intent.id, EditStatus::Published { revision })));
212 }
213 let store = Store::parse(prepared.as_bytes())?;
214 let index = RevisionIndex::parse(&store)?;
215 let revision = index.spaces[&intent.space].labels[&(ExGuid::default(), 1)];
216 {
217 let mut connection = self
218 .connection
219 .lock()
220 .map_err(|_| io::Error::other("Cache owner panicked"))?;
221 let transaction =
222 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
223 transaction.execute(
224 "DELETE FROM conflicts WHERE edit_id=?1",
225 [i64::try_from(intent.id).map_err(io::Error::other)?],
226 )?;
227 transaction.execute(
228 "INSERT INTO attempt(id, edit_id, revision) VALUES (1, ?1, ?2)",
229 params![
230 i64::try_from(intent.id).map_err(io::Error::other)?,
231 revision.to_string()
232 ],
233 )?;
234 transaction.commit()?;
235 }
236 match remote.publish(&prepared) {
237 Ok(()) => {}
238 Err(error) if error.state == CommitState::NotCommitted => {
239 let connection = self
240 .connection
241 .lock()
242 .map_err(|_| io::Error::other("Cache owner panicked"))?;
243 connection.execute(
244 "DELETE FROM attempt WHERE edit_id=?1",
245 [i64::try_from(intent.id).map_err(io::Error::other)?],
246 )?;
247 return Err(error.into());
248 }
249 Err(error) if error.state == CommitState::Committed => {
250 self.acknowledge(intent.id, revision, prepared.as_bytes())?;
251 return Err(error.into());
252 }
253 Err(error) => return Err(error.into()),
254 }
255 self.acknowledge(intent.id, revision, prepared.as_bytes())?;
256 Ok(Some((intent.id, EditStatus::Published { revision })))
257 }
258
259 /// Places the oldest text or formatting conflict at a reviewed remote UTF-16 range.
260 /// The requested replacement/attributes, local image, intent ID and later edits are preserved.
261 /// Both supplied images must match `snapshot` and `remote_snapshot`; stale review
262 /// returns `Io(ResourceBusy)`. Uncertain publication attempts cannot be rebased.
263 pub fn rebase_conflict(
264 &self,
265 id: u64,
266 local: &[u8],
267 remote: &[u8],
268 range: Range<u32>,
269 ) -> Result<()> {
270 self.resolve_conflict(id, local, remote, |intent| {
271 Ok(match intent.operation {
272 Operation::Text(mut edit) => {
273 let prepared = PreparedEdit::text(
274 remote,
275 intent.space,
276 edit.object,
277 range.clone(),
278 &edit.replacement,
279 )?;
280 if prepared.as_bytes() == remote {
281 return Err(io::Error::new(
282 io::ErrorKind::InvalidInput,
283 "The selected range already contains the replacement",
284 )
285 .into());
286 }
287 edit.before =
288 paragraph(remote, intent.space, edit.object)?.ok_or_else(|| {
289 io::Error::new(
290 io::ErrorKind::InvalidData,
291 "The remote text target is unavailable",
292 )
293 })?;
294 edit.range = range;
295 Operation::Text(edit)
296 }
297 Operation::Format(edit) => Operation::Format(
298 FormatEdit::capture(
299 remote,
300 intent.space,
301 edit.object,
302 range,
303 &edit.attributes,
304 )?
305 .0,
306 ),
307 Operation::Insert(_) => {
308 return Err(io::Error::new(
309 io::ErrorKind::InvalidInput,
310 "Insertion conflicts require a placement, not a text range",
311 )
312 .into());
313 }
314 })
315 })
316 }
317
318 /// Repositions the oldest paragraph insertion conflict against reviewed cache images.
319 /// Object identities and dependent edits are retained; uncertain attempts cannot be moved.
320 pub fn rebase_paragraph_conflict(
321 &self,
322 id: u64,
323 local: &[u8],
324 remote: &[u8],
325 parent: ExGuid,
326 before: Option<ExGuid>,
327 ) -> Result<()> {
328 self.resolve_conflict(id, local, remote, |intent| {
329 let Operation::Insert(insertion) = intent.operation else {
330 return Err(io::Error::new(
331 io::ErrorKind::InvalidInput,
332 "Select a paragraph insertion conflict",
333 )
334 .into());
335 };
336 let insertion = insertion.reposition_paragraph(parent, before)?;
337 PreparedEdit::insert(remote, intent.space, &insertion)?;
338 Ok(Operation::Insert(insertion))
339 })
340 }
341
342 /// Repositions the oldest outline insertion conflict against reviewed cache images.
343 /// Object identities and dependent edits are retained; uncertain attempts cannot be moved.
344 pub fn rebase_outline_conflict(
345 &self,
346 id: u64,
347 local: &[u8],
348 remote: &[u8],
349 page: ExGuid,
350 x: f32,
351 y: f32,
352 ) -> Result<()> {
353 self.resolve_conflict(id, local, remote, |intent| {
354 let Operation::Insert(insertion) = intent.operation else {
355 return Err(io::Error::new(
356 io::ErrorKind::InvalidInput,
357 "Select an outline insertion conflict",
358 )
359 .into());
360 };
361 let insertion = insertion.reposition_outline(page, x, y)?;
362 PreparedEdit::insert(remote, intent.space, &insertion)?;
363 Ok(Operation::Insert(insertion))
364 })
365 }
366
367 fn resolve_conflict(
368 &self,
369 id: u64,
370 local: &[u8],
371 remote: &[u8],
372 update: impl FnOnce(PendingEdit) -> Result<Operation>,
373 ) -> Result<()> {
374 let owner = self.sync_owner()?;
375 let intent = self
376 .pending()?
377 .into_iter()
378 .next()
379 .filter(|intent| intent.id == id)
380 .ok_or_else(|| {
381 io::Error::new(
382 io::ErrorKind::InvalidInput,
383 "Only the oldest conflict can be rebased",
384 )
385 })?;
386 let operation = update(intent)?;
387 let mut connection = self
388 .connection
389 .lock()
390 .map_err(|_| io::Error::other("Cache owner panicked"))?;
391 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
392 let (base, working): (Vec<u8>, Vec<u8>) =
393 transaction.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| {
394 Ok((row.get(0)?, row.get(1)?))
395 })?;
396 if working != local || base != remote {
397 return Err(io::Error::new(
398 io::ErrorKind::ResourceBusy,
399 "The reviewed cache images changed",
400 )
401 .into());
402 }
403 let id = i64::try_from(id).map_err(io::Error::other)?;
404 let eligible: bool = transaction.query_row(
405 "SELECT EXISTS(SELECT 1 FROM conflicts WHERE edit_id=?1) AND NOT EXISTS(SELECT 1 FROM attempt)",
406 [id], |row| row.get(0),
407 )?;
408 if !eligible {
409 return Err(io::Error::new(
410 io::ErrorKind::InvalidInput,
411 "The edit is not an unattempted conflict",
412 )
413 .into());
414 }
415 transaction.execute(
416 "UPDATE edits SET operation=?1 WHERE id=?2",
417 params![
418 serde_json::to_string(&operation).map_err(io::Error::other)?,
419 id
420 ],
421 )?;
422 transaction.execute("DELETE FROM conflicts WHERE edit_id=?1", [id])?;
423 transaction.commit()?;
424 drop(connection);
425 drop(owner);
426 self.wake_sync();
427 Ok(())
428 }
429
430 fn sync_owner(&self) -> Result<MutexGuard<'_, ()>> {
431 Ok(self
432 .synchronization
433 .try_lock()
434 .map_err(|error| match error {
435 TryLockError::WouldBlock => io::Error::from(io::ErrorKind::WouldBlock),
436 TryLockError::Poisoned(_) => {
437 io::Error::other("Synchronization owner panicked; reopen the cache")
438 }
439 })?)
440 }
441
442 fn acknowledge(&self, id: u64, revision: ExGuid, snapshot: &[u8]) -> Result<()> {
443 let id = i64::try_from(id).map_err(io::Error::other)?;
444 let mut connection = self
445 .connection
446 .lock()
447 .map_err(|_| io::Error::other("Cache owner panicked"))?;
448 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
449 transaction.execute(
450 "INSERT INTO receipts(edit_id, revision) VALUES (?1, ?2)",
451 params![id, revision.to_string()],
452 )?;
453 transaction.execute("DELETE FROM edits WHERE id=?1", [id])?;
454 transaction.execute("UPDATE replica SET base=?1, working=CASE WHEN EXISTS(SELECT 1 FROM edits) THEN working ELSE ?1 END WHERE id=1", [snapshot])?;
455 transaction.commit()?;
456 Ok(())
457 }
458}
crates/onestore-offline/src/worker.rs created+167
...@@ -0,0 +1,167 @@
1use super::*;
2use std::{
3 collections::hash_map::RandomState,
4 hash::BuildHasher,
5 sync::{
6 Arc,
7 atomic::{AtomicBool, Ordering},
8 mpsc::{self, SyncSender},
9 },
10 thread::{self, JoinHandle},
11 time::Instant,
12};
13
14pub(super) struct Signal {
15 stopped: AtomicBool,
16 sender: SyncSender<()>,
17}
18
19impl Signal {
20 pub(super) fn wake(&self) {
21 // One retained notification covers edits that arrive during network I/O.
22 let _ = self.sender.try_send(());
23 }
24}
25
26/// Owns automatic reconciliation. Dropping requests cancellation without blocking.
27/// The in-flight sync step finishes before ownership is released; `stop` waits for it.
28pub struct SyncWorker {
29 signal: Arc<Signal>,
30 thread: Option<JoinHandle<Result<()>>>,
31}
32
33impl SyncWorker {
34 /// Requests a retry, for example after a network reachability change.
35 /// A pending contention backoff finishes before processing the notification.
36 pub fn wake(&self) {
37 self.signal.wake();
38 }
39
40 /// Cancels future steps and waits for the current step and callback to finish.
41 /// A stopped worker leaves pending edits and uncertain attempts in the cache.
42 /// Call outside the worker's own callback, which cannot join its calling thread.
43 pub fn stop(mut self) -> Result<()> {
44 self.signal.stopped.store(true, Ordering::Release);
45 self.signal.wake();
46 self.thread
47 .take()
48 .expect("Worker owns its thread")
49 .join()
50 .map_err(|_| io::Error::other("Synchronization worker panicked"))?
51 }
52}
53
54impl Drop for SyncWorker {
55 fn drop(&mut self) {
56 self.signal.stopped.store(true, Ordering::Release);
57 self.signal.wake();
58 }
59}
60
61impl Replica {
62 /// Starts one worker, reconnecting through `connect` after transport failures.
63 /// Local edits wake it; `interval` controls idle polling and transport retries.
64 /// Contended operations returning `NotCommitted` also back off by up to one second.
65 /// `observe` runs on the worker after each attempt, including connection errors.
66 /// Cache/document errors stop the worker; inspect them through `observe` or `stop`.
67 /// Remote calls and callbacks must be bounded for `stop` to have bounded latency.
68 pub fn start_sync<R, F, O>(
69 self: &Arc<Self>,
70 interval: Duration,
71 mut connect: F,
72 mut observe: O,
73 ) -> io::Result<SyncWorker>
74 where
75 R: Remote + 'static,
76 F: FnMut() -> io::Result<R> + Send + 'static,
77 O: FnMut(&Result<Option<(u64, EditStatus)>>) + Send + 'static,
78 {
79 if interval.is_zero() || Instant::now().checked_add(interval).is_none() {
80 return Err(io::Error::new(
81 io::ErrorKind::InvalidInput,
82 "Synchronization interval must be positive and representable",
83 ));
84 }
85 let mut owner = self
86 .worker
87 .lock()
88 .map_err(|_| io::Error::other("Synchronization worker registration panicked"))?;
89 if owner.upgrade().is_some() {
90 return Err(io::ErrorKind::WouldBlock.into());
91 }
92 let (sender, receiver) = mpsc::sync_channel(1);
93 let signal = Arc::new(Signal {
94 stopped: AtomicBool::new(false),
95 sender,
96 });
97 let replica = Arc::clone(self);
98 let worker_signal = Arc::clone(&signal);
99 let thread = thread::Builder::new()
100 .name("onestore-sync".into())
101 .spawn(move || {
102 let mut remote = None;
103 let jitter = RandomState::new();
104 let mut contention = 0_u32;
105 while !worker_signal.stopped.load(Ordering::Acquire) {
106 let result = match remote.as_mut() {
107 Some(remote) => replica.sync_once(remote),
108 None => match connect() {
109 Ok(connected) => {
110 remote = Some(connected);
111 continue;
112 }
113 Err(error) => Err(Error::RemoteIo(error)),
114 },
115 };
116 observe(&result);
117 match result {
118 Ok(Some((_, EditStatus::Published { .. }))) => {
119 contention = 0;
120 continue;
121 }
122 Ok(None) => contention = 0,
123 Ok(_) => {}
124 Err(Error::Remote(onestore::CommitError {
125 state: onestore::CommitState::NotCommitted,
126 ref error,
127 })) if matches!(
128 error.kind(),
129 io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy
130 ) =>
131 {
132 contention = contention.saturating_add(1);
133 let ceiling = (50_u64 << contention.min(5)).min(1000);
134 let until = Instant::now()
135 + Duration::from_millis(jitter.hash_one(contention) % ceiling);
136 // Local wakes must not keep competing writers in the same retry phase.
137 while !worker_signal.stopped.load(Ordering::Acquire) {
138 let Some(remaining) = until.checked_duration_since(Instant::now())
139 else {
140 break;
141 };
142 let _ = receiver.recv_timeout(remaining);
143 }
144 continue;
145 }
146 Err(Error::RemoteIo(ref error))
147 if matches!(
148 error.kind(),
149 io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy
150 ) => {}
151 Err(Error::RemoteIo(_) | Error::Remote(_)) => remote = None,
152 Err(Error::Io(ref error)) if error.kind() == io::ErrorKind::WouldBlock => {}
153 Err(error) => return Err(error),
154 }
155 if !worker_signal.stopped.load(Ordering::Acquire) {
156 let _ = receiver.recv_timeout(interval);
157 }
158 }
159 Ok(())
160 })?;
161 *owner = Arc::downgrade(&signal);
162 Ok(SyncWorker {
163 signal,
164 thread: Some(thread),
165 })
166 }
167}
crates/onestore-offline/tests/cache.rs created+685
...@@ -0,0 +1,685 @@
1use onestore::{
2 ExGuid, RevisionIndex, Store,
3 document::{Document, Kind},
4};
5use onestore_offline::{Error, Replica};
6use std::{
7 collections::BTreeSet,
8 fs,
9 io::ErrorKind,
10 sync::Barrier,
11 time::{Duration, Instant},
12};
13
14fn target(source: &[u8]) -> (ExGuid, ExGuid, String) {
15 let store = Store::parse(source).unwrap();
16 assert!(store.checksum_mismatches.is_empty());
17 let index = RevisionIndex::parse(&store).unwrap();
18 index.validate_current().unwrap();
19 let doc = Document::parse(&index).unwrap();
20 doc.spaces
21 .iter()
22 .find_map(|(sid, space)| {
23 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
24 revision
25 .nodes
26 .iter()
27 .find_map(|(oid, node)| match &node.kind {
28 Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())),
29 _ => None,
30 })
31 })
32 .unwrap()
33}
34
35#[test]
36fn cache_reopen_preserves_exact_images_and_intents() {
37 let dir = tempfile::tempdir().unwrap();
38 let path = dir.path().join("section.sqlite");
39 let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap();
40 let replica = Replica::create(&path, &source).unwrap();
41 assert_eq!(replica.snapshot().unwrap(), source);
42 assert!(replica.pending().unwrap().is_empty());
43 let (sid, oid, _) = target(&source);
44 assert_eq!(
45 replica.edit_text(&source, sid, oid, 0..0, "").unwrap(),
46 None
47 );
48 assert_eq!(
49 replica.edit_text(&source, sid, oid, 0..4, "café").unwrap(),
50 None
51 );
52 let first = replica
53 .edit_text(&source, sid, oid, 5..7, "🐈 日本語")
54 .unwrap()
55 .unwrap();
56 let edited = replica.snapshot().unwrap();
57 assert_eq!(target(&edited).2, "café 🐈 日本語");
58 let intents = replica.pending().unwrap();
59 assert_eq!(intents.len(), 1);
60 assert_eq!(intents[0].id, first);
61 let onestore_offline::Operation::Text(first_edit) = &intents[0].operation else {
62 panic!()
63 };
64 assert_eq!(first_edit.before, "café 🦀");
65 assert_eq!(first_edit.range, 5..7);
66 assert_eq!(first_edit.replacement, "🐈 日本語");
67 assert_eq!((intents[0].space, first_edit.object), (sid, oid));
68 drop(replica);
69 let replica = Replica::open(&path).unwrap();
70 assert_eq!(replica.snapshot().unwrap(), edited);
71 assert_eq!(replica.pending().unwrap(), intents);
72 let second = replica
73 .edit_text(&edited, sid, oid, 0..0, "Recovered ")
74 .unwrap()
75 .unwrap();
76 assert!(second > first);
77 let onestore_offline::Operation::Text(second_edit) = &replica.pending().unwrap()[1].operation
78 else {
79 panic!()
80 };
81 assert_eq!(second_edit.before, "café 🐈 日本語");
82}
83
84#[test]
85fn failed_edits_preserve_both_intent_queue_and_working_image() {
86 let dir = tempfile::tempdir().unwrap();
87 let path = dir.path().join("section.sqlite");
88 let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap();
89 let replica = Replica::create(&path, &source).unwrap();
90 let (sid, oid, _) = target(&source);
91 for (range, replacement) in [(6..7, "X"), (0..u32::MAX, "X"), (0..1, "\n")] {
92 assert!(
93 replica
94 .edit_text(&source, sid, oid, range, replacement)
95 .is_err()
96 );
97 assert_eq!(replica.snapshot().unwrap(), source);
98 assert!(replica.pending().unwrap().is_empty());
99 }
100 drop(replica);
101 let connection = rusqlite::Connection::open(&path).unwrap();
102 connection.execute_batch("CREATE TRIGGER fail_image BEFORE UPDATE ON replica BEGIN SELECT RAISE(ABORT, 'Injected image update failure'); END;").unwrap();
103 drop(connection);
104 let replica = Replica::open(&path).unwrap();
105 assert!(matches!(
106 replica.edit_text(&source, sid, oid, 0..0, "lost? "),
107 Err(Error::Database(_))
108 ));
109 drop(replica);
110 let replica = Replica::open(&path).unwrap();
111 assert_eq!(replica.snapshot().unwrap(), source);
112 assert!(replica.pending().unwrap().is_empty());
113}
114
115#[test]
116fn ownership_and_foreign_file_rejection_preserve_existing_data() {
117 let dir = tempfile::tempdir().unwrap();
118 let path = dir.path().join("section.sqlite");
119 assert!(Replica::open(&path).is_err());
120 assert!(!path.exists());
121 assert!(Replica::create(&path, b"invalid").is_err());
122 assert!(!path.exists());
123 let source = onestore::create_section("section.one", "Owned", "Fixture").unwrap();
124 let replica = Replica::create(&path, &source).unwrap();
125 for _ in 0..3 {
126 assert!(
127 matches!(Replica::open(&path), Err(Error::Database(error)) if error.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy))
128 );
129 assert!(
130 matches!(Replica::create(&path, &source), Err(Error::Io(error)) if error.kind() == ErrorKind::AlreadyExists)
131 );
132 assert_eq!(replica.snapshot().unwrap(), source);
133 }
134 drop(replica);
135 for sql in [
136 "PRAGMA application_id=0",
137 "PRAGMA application_id=1330529615; PRAGMA user_version=99",
138 ] {
139 let connection = rusqlite::Connection::open(&path).unwrap();
140 connection.execute_batch(sql).unwrap();
141 drop(connection);
142 let before = fs::read(&path).unwrap();
143 assert!(Replica::open(&path).is_err());
144 assert_eq!(fs::read(&path).unwrap(), before);
145 }
146 let foreign = dir.path().join("foreign.sqlite");
147 let connection = rusqlite::Connection::open(&foreign).unwrap();
148 connection
149 .execute_batch(
150 "CREATE TABLE unrelated (value TEXT); INSERT INTO unrelated VALUES ('preserve');",
151 )
152 .unwrap();
153 drop(connection);
154 let before = fs::read(&foreign).unwrap();
155 assert!(Replica::open(&foreign).is_err());
156 assert_eq!(fs::read(&foreign).unwrap(), before);
157 let incomplete = dir.path().join("incomplete.sqlite");
158 fs::write(&incomplete, []).unwrap();
159 assert!(Replica::open(&incomplete).is_err());
160 assert_eq!(fs::read(&incomplete).unwrap(), b"");
161}
162
163#[test]
164fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement() {
165 let dir = tempfile::tempdir().unwrap();
166 let path = dir.path().join("section.sqlite");
167 let source = onestore::create_section("section.one", "Shared café 🦀", "Fixture").unwrap();
168 let replica = Replica::create(&path, &source).unwrap();
169 let (sid, oid, _) = target(&source);
170 let barrier = Barrier::new(12);
171 let deadline = Instant::now() + Duration::from_secs(60);
172 let outcomes = std::thread::scope(|scope| {
173 let handles: Vec<_> = (0..12)
174 .map(|writer| {
175 let (replica, source, barrier) = (&replica, &source, &barrier);
176 scope.spawn(move || {
177 barrier.wait();
178 let first =
179 replica.edit_text(source, sid, oid, 0..0, &format!("[initial-{writer}] "));
180 let mut ids = Vec::new();
181 let first_won = match first {
182 Ok(Some(id)) => {
183 ids.push(id);
184 true
185 }
186 Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy => false,
187 other => panic!("Unexpected first edit: {other:?}"),
188 };
189 for edit in 0..20 {
190 loop {
191 assert!(
192 Instant::now() < deadline,
193 "Writer {writer} stopped progressing at {edit}"
194 );
195 let source = replica.snapshot().unwrap();
196 match replica.edit_text(
197 &source,
198 sid,
199 oid,
200 0..0,
201 &format!("[{writer}-{edit}] "),
202 ) {
203 Ok(Some(id)) => {
204 ids.push(id);
205 break;
206 }
207 Err(Error::Io(error))
208 if error.kind() == ErrorKind::ResourceBusy => {}
209 other => panic!("Unexpected edit: {other:?}"),
210 }
211 }
212 }
213 (first_won, ids)
214 })
215 })
216 .collect();
217 handles
218 .into_iter()
219 .map(|handle| handle.join().unwrap())
220 .collect::<Vec<_>>()
221 });
222 assert_eq!(outcomes.iter().filter(|(won, _)| *won).count(), 1);
223 let ids: BTreeSet<_> = outcomes.into_iter().flat_map(|(_, ids)| ids).collect();
224 assert_eq!(ids.len(), 241);
225 let final_bytes = replica.snapshot().unwrap();
226 let content = target(&final_bytes).2;
227 let mut expected = "Shared café 🦀".to_owned();
228 for pending in replica.pending().unwrap() {
229 let onestore_offline::Operation::Text(edit) = pending.operation else {
230 panic!()
231 };
232 assert_eq!(edit.before, expected);
233 assert_eq!(edit.range, 0..0);
234 expected.insert_str(0, &edit.replacement);
235 }
236 assert_eq!(content, expected);
237 for writer in 0..12 {
238 for edit in 0..20 {
239 assert_eq!(content.matches(&format!("[{writer}-{edit}] ")).count(), 1);
240 }
241 }
242 assert_eq!(
243 replica
244 .pending()
245 .unwrap()
246 .iter()
247 .map(|edit| edit.id)
248 .collect::<BTreeSet<_>>(),
249 ids
250 );
251 assert!(
252 matches!(replica.edit_text(&source, sid, oid, 0..0, ""), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy)
253 );
254 drop(replica);
255 let reopened = Replica::open(&path).unwrap();
256 assert_eq!(reopened.snapshot().unwrap(), final_bytes);
257 assert_eq!(reopened.pending().unwrap().len(), 241);
258}
259
260#[test]
261fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {
262 let dir = tempfile::tempdir().unwrap();
263 let path = dir.path().join("model.sqlite");
264 let mut text = "ab🚀ab🦀 é repeated repeated".to_owned();
265 let mut source = onestore::create_section("model.one", &text, "Fixture").unwrap();
266 let mut replica = Replica::create(&path, &source).unwrap();
267 let (space, object, _) = target(&source);
268 let mut random = 911_u64;
269 let mut next = || {
270 random ^= random << 13;
271 random ^= random >> 7;
272 random ^= random << 17;
273 random
274 };
275 let mut intents = Vec::new();
276 for step in 0..1024 {
277 let boundaries: Vec<_> = text
278 .char_indices()
279 .map(|(at, _)| at)
280 .chain([text.len()])
281 .collect();
282 let first = boundaries[next() as usize % boundaries.len()];
283 let last = boundaries[next() as usize % boundaries.len()];
284 let bytes = first.min(last)..first.max(last);
285 let range = u32::try_from(text[..bytes.start].encode_utf16().count()).unwrap()
286 ..u32::try_from(text[..bytes.end].encode_utf16().count()).unwrap();
287 let replacement = ["", "🐈", "日本語", "repeated", "é", "ab🦀ab"][next() as usize % 6];
288 let mut expected = text.clone();
289 expected.replace_range(bytes, replacement);
290 let acknowledgement = replica
291 .edit_text(&source, space, object, range.clone(), replacement)
292 .unwrap();
293 if let Some(id) = acknowledgement {
294 assert_ne!(text, expected);
295 assert!(
296 intents
297 .last()
298 .is_none_or(|edit: &onestore_offline::PendingEdit| edit.id < id)
299 );
300 intents.push(onestore_offline::PendingEdit {
301 id,
302 space,
303 operation: onestore_offline::Operation::Text(onestore_offline::TextEdit {
304 object,
305 before: text,
306 range,
307 replacement: replacement.into(),
308 }),
309 });
310 assert!(
311 matches!(replica.edit_text(&source, space, object, 0..0, "stale"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy)
312 );
313 } else {
314 assert_eq!(text, expected);
315 }
316 text = expected;
317 source = replica.snapshot().unwrap();
318 assert_eq!(target(&source).2, text, "seed 911, step {step}");
319 if step % 37 == 0 {
320 drop(replica);
321 replica = Replica::open(&path).unwrap();
322 assert_eq!(replica.snapshot().unwrap(), source);
323 assert_eq!(replica.pending().unwrap(), intents);
324 }
325 }
326 assert!(
327 intents.len() > 512,
328 "The history checkpoint boundary was not exercised"
329 );
330 drop(replica);
331 let replica = Replica::open(&path).unwrap();
332 assert_eq!(replica.pending().unwrap(), intents);
333 assert_eq!(replica.snapshot().unwrap(), source);
334}
335
336#[test]
337#[ignore = "migrates a fresh copy of a retained version-two or version-three cache"]
338fn migrate_retained_cache_copy() {
339 use onestore_offline::{EditStatus, Operation, PendingEdit, TextEdit};
340 let source = std::path::PathBuf::from(std::env::var_os("ONESTORE_MIGRATION_SOURCE").unwrap());
341 let output = std::path::PathBuf::from(std::env::var_os("ONESTORE_MIGRATION_OUTPUT").unwrap());
342 assert!(source.is_absolute() && output.is_absolute());
343 let mut original = fs::File::open(&source).unwrap();
344 let mut destination = fs::OpenOptions::new()
345 .write(true)
346 .create_new(true)
347 .open(&output)
348 .unwrap();
349 std::io::copy(&mut original, &mut destination).unwrap();
350 destination.sync_all().unwrap();
351 drop(destination);
352 let db = rusqlite::Connection::open(&output).unwrap();
353 let version = db
354 .pragma_query_value(None, "user_version", |r| r.get::<_, u32>(0))
355 .unwrap();
356 assert!(matches!(version, 2 | 3));
357 if std::env::var_os("ONESTORE_MIGRATION_ROLLBACK").is_some() {
358 assert_eq!(version, 2);
359 db.pragma_update(None, "foreign_keys", false).unwrap();
360 db.execute(
361 "INSERT INTO attempt VALUES (1,999999,'{00000001-0000-0000-0000-000000000000},1')",
362 [],
363 )
364 .unwrap();
365 drop(db);
366 let before = fs::read(&output).unwrap();
367 assert!(
368 matches!(Replica::open(&output), Err(Error::Database(rusqlite::Error::SqliteFailure(error, _))) if error.extended_code == 787)
369 );
370 assert_eq!(fs::read(&output).unwrap(), before);
371 let db = rusqlite::Connection::open(&output).unwrap();
372 assert_eq!(
373 db.pragma_query_value(None, "user_version", |r| r.get::<_, u32>(0))
374 .unwrap(),
375 2
376 );
377 println!("migration: rollback preserved {} bytes", before.len());
378 return;
379 }
380 if std::env::var_os("ONESTORE_MIGRATION_CONFLICT").is_some() {
381 db.execute("INSERT INTO conflicts SELECT min(id),0 FROM edits", [])
382 .unwrap();
383 }
384 let (base, working): (Vec<u8>, Vec<u8>) = db
385 .query_row("SELECT base,working FROM replica", [], |r| {
386 Ok((r.get(0)?, r.get(1)?))
387 })
388 .unwrap();
389 let sequence: i64 = db
390 .query_row(
391 "SELECT seq FROM sqlite_sequence WHERE name='edits'",
392 [],
393 |r| r.get(0),
394 )
395 .unwrap();
396 let pending: Vec<PendingEdit> =
397 if version == 2 {
398 let mut query = db
399 .prepare("SELECT id,space,object,before_text,start,end,replacement FROM edits ORDER BY id")
400 .unwrap();
401 let pending: Vec<PendingEdit> = query
402 .query_map([], |r| {
403 Ok(PendingEdit {
404 id: u64::try_from(r.get::<_, i64>(0)?).unwrap(),
405 space: r.get::<_, String>(1)?.parse().unwrap(),
406 operation: Operation::Text(TextEdit {
407 object: r.get::<_, String>(2)?.parse().unwrap(),
408 before: r.get(3)?,
409 range: r.get(4)?..r.get(5)?,
410 replacement: r.get(6)?,
411 }),
412 })
413 })
414 .unwrap()
415 .collect::<Result<_, _>>()
416 .unwrap();
417 drop(query);
418 pending
419 } else {
420 let mut query = db
421 .prepare("SELECT id,space,operation FROM edits ORDER BY id")
422 .unwrap();
423 query
424 .query_map([], |r| {
425 Ok(PendingEdit {
426 id: u64::try_from(r.get::<_, i64>(0)?).unwrap(),
427 space: r.get::<_, String>(1)?.parse().unwrap(),
428 operation: serde_json::from_str(&r.get::<_, String>(2)?).unwrap(),
429 })
430 })
431 .unwrap()
432 .collect::<Result<_, _>>()
433 .unwrap()
434 };
435 let mut states = std::collections::BTreeMap::new();
436 for edit in &pending {
437 states.insert(edit.id, EditStatus::Pending);
438 }
439 for table in ["receipts", "attempt"] {
440 let mut query = db
441 .prepare(&format!("SELECT edit_id,revision FROM {table}"))
442 .unwrap();
443 for row in query
444 .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
445 .unwrap()
446 {
447 let (id, rid) = row.unwrap();
448 let revision = rid.parse().unwrap();
449 states.insert(
450 u64::try_from(id).unwrap(),
451 if table == "receipts" {
452 EditStatus::Published { revision }
453 } else {
454 EditStatus::AwaitingConfirmation { revision }
455 },
456 );
457 }
458 }
459 let mut query = db.prepare("SELECT edit_id,kind FROM conflicts").unwrap();
460 for row in query
461 .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, u32>(1)?)))
462 .unwrap()
463 {
464 let (id, kind) = row.unwrap();
465 assert_eq!(kind, 0);
466 states.insert(
467 u64::try_from(id).unwrap(),
468 EditStatus::Conflict(onestore_offline::ConflictKind::TextChanged),
469 );
470 }
471 drop(query);
472 drop(db);
473 let cache = Replica::open(&output).unwrap();
474 assert_eq!(cache.snapshot().unwrap(), working);
475 assert_eq!(cache.remote_snapshot().unwrap(), base);
476 assert_eq!(cache.pending().unwrap(), pending);
477 for (id, status) in &states {
478 assert_eq!(cache.status(*id).unwrap(), Some(*status));
479 }
480 drop(cache);
481 let cache = Replica::open(&output).unwrap();
482 assert_eq!(cache.pending().unwrap(), pending);
483 let store = Store::parse(&working).unwrap();
484 let index = RevisionIndex::parse(&store).unwrap();
485 let doc = Document::parse(&index).unwrap();
486 let (sid, page) = doc.pages().unwrap()[0];
487 let insertion = onestore::Insertion::outline(
488 page,
489 144.0,
490 720.0,
491 "After cache migration",
492 "Migration author",
493 )
494 .unwrap();
495 let next = cache.insert(&working, sid, &insertion).unwrap().unwrap();
496 assert_eq!(next, u64::try_from(sequence + 1).unwrap());
497 let updated = cache.snapshot().unwrap();
498 drop(cache);
499 let cache = Replica::open(&output).unwrap();
500 assert_eq!(cache.snapshot().unwrap(), updated);
501 assert_eq!(
502 cache.pending().unwrap().last().unwrap().operation,
503 Operation::Insert(insertion)
504 );
505 println!(
506 "migration: {}",
507 serde_json::json!({"pending":pending.len(),"retained_statuses":states.len(),"next_id":next,"base_bytes":base.len(),"working_bytes":working.len()})
508 );
509}
510
511#[test]
512fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {
513 use onestore::Insertion;
514 let directory = tempfile::tempdir().unwrap();
515 let path = directory.path().join("parallel.sqlite");
516 let source = onestore::create_section("parallel.one", "Original", "Author").unwrap();
517 let store = Store::parse(&source).unwrap();
518 let index = RevisionIndex::parse(&store).unwrap();
519 let doc = Document::parse(&index).unwrap();
520 let (sid, page) = doc.pages().unwrap()[0];
521 let cache = Replica::create(&path, &source).unwrap();
522 let barrier = Barrier::new(12);
523 let deadline = Instant::now() + Duration::from_secs(90);
524 let all = std::thread::scope(|scope| {
525 let handles: Vec<_> = (0..12)
526 .map(|client| {
527 let (cache, barrier) = (&cache, &barrier);
528 scope.spawn(move || {
529 let outline = Insertion::outline(
530 page,
531 72.0,
532 144.0 + client as f32 * 72.0,
533 &format!("Client {client}"),
534 "Author",
535 )
536 .unwrap();
537 let mut ids = Vec::new();
538 let mut objects = Vec::new();
539 barrier.wait();
540 for sequence in 0..4 {
541 let insertion = if sequence == 0 {
542 outline.clone()
543 } else {
544 Insertion::paragraph(
545 outline.object(),
546 None,
547 &format!("Paragraph {client}:{sequence}"),
548 "Author",
549 )
550 .unwrap()
551 };
552 loop {
553 assert!(
554 Instant::now() < deadline,
555 "Client {client} stopped at insertion {sequence}"
556 );
557 let snapshot = cache.snapshot().unwrap();
558 match cache.insert(&snapshot, sid, &insertion) {
559 Ok(Some(id)) => {
560 ids.push(id);
561 objects.push(insertion.text_object());
562 break;
563 }
564 Err(Error::Io(e)) if e.kind() == ErrorKind::ResourceBusy => {}
565 other => panic!("{other:?}"),
566 }
567 }
568 if sequence == 0 {
569 continue;
570 }
571 loop {
572 assert!(
573 Instant::now() < deadline,
574 "Client {client} stopped at text {sequence}"
575 );
576 let snapshot = cache.snapshot().unwrap();
577 match cache.edit_text(
578 &snapshot,
579 sid,
580 insertion.text_object(),
581 0..0,
582 "Edited ",
583 ) {
584 Ok(Some(id)) => {
585 ids.push(id);
586 break;
587 }
588 Err(Error::Io(e)) if e.kind() == ErrorKind::ResourceBusy => {}
589 other => panic!("{other:?}"),
590 }
591 }
592 }
593 (ids, objects)
594 })
595 })
596 .collect();
597 handles
598 .into_iter()
599 .map(|h| h.join().unwrap())
600 .collect::<Vec<_>>()
601 });
602 let ids: BTreeSet<_> = all
603 .iter()
604 .flat_map(|(ids, _)| ids.iter().copied())
605 .collect();
606 assert_eq!(ids.len(), 84);
607 let snapshot = cache.snapshot().unwrap();
608 drop(cache);
609 let cache = Replica::open(&path).unwrap();
610 assert_eq!(cache.snapshot().unwrap(), snapshot);
611 assert_eq!(
612 cache
613 .pending()
614 .unwrap()
615 .iter()
616 .map(|e| e.id)
617 .collect::<BTreeSet<_>>(),
618 ids
619 );
620 let store = Store::parse(&snapshot).unwrap();
621 let index = RevisionIndex::parse(&store).unwrap();
622 let doc = Document::parse(&index).unwrap();
623 let s = &doc.spaces[&sid];
624 let v = &s.revisions[&s.contexts[&ExGuid::default()]];
625 for (client, (_, objects)) in all.iter().enumerate() {
626 for (sequence, id) in objects.iter().enumerate() {
627 let wanted = if sequence == 0 {
628 format!("Client {client}")
629 } else {
630 format!("Edited Paragraph {client}:{sequence}")
631 };
632 assert!(matches!(&v.nodes[id].kind,Kind::RichText{text,..} if *text==wanted));
633 }
634 }
635}
636
637#[test]
638fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {
639 for operation in ["Text", "Insert", "Format"] {
640 let directory = tempfile::tempdir().unwrap();
641 let path = directory.path().join("unknown.sqlite");
642 let source = onestore::create_section("unknown.one", "Original", "Author").unwrap();
643 let cache = Replica::create(&path, &source).unwrap();
644 let (sid, oid, _) = target(&source);
645 if operation == "Insert" {
646 let store = Store::parse(&source).unwrap();
647 let index = RevisionIndex::parse(&store).unwrap();
648 let doc = Document::parse(&index).unwrap();
649 let (_, page) = doc.pages().unwrap()[0];
650 let insertion =
651 onestore::Insertion::outline(page, 144.0, 144.0, "Inserted", "Author").unwrap();
652 cache.insert(&source, sid, &insertion).unwrap();
653 } else if operation == "Text" {
654 cache.edit_text(&source, sid, oid, 0..0, "New ").unwrap();
655 } else {
656 cache
657 .format(
658 &source,
659 sid,
660 oid,
661 0..4,
662 &[onestore::TextAttribute::Bold(true)],
663 )
664 .unwrap();
665 }
666 drop(cache);
667 let db = rusqlite::Connection::open(&path).unwrap();
668 let encoded: String = db
669 .query_row("SELECT operation FROM edits", [], |r| r.get(0))
670 .unwrap();
671 let mut value: serde_json::Value = serde_json::from_str(&encoded).unwrap();
672 value[operation]["future_option"] = true.into();
673 db.execute("UPDATE edits SET operation=?1", [value.to_string()])
674 .unwrap();
675 if operation != "Format" {
676 db.execute_batch("DROP TABLE conflicts; CREATE TABLE conflicts (edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 2)) STRICT; PRAGMA user_version=3;").unwrap();
677 }
678 drop(db);
679 let before = fs::read(&path).unwrap();
680 assert!(
681 matches!(Replica::open(&path),Err(Error::Io(error))if error.kind()==ErrorKind::InvalidData)
682 );
683 assert_eq!(fs::read(&path).unwrap(), before);
684 }
685}
crates/onestore-offline/tests/sync.rs created+2618
...@@ -0,0 +1,2618 @@
1use onestore::{
2 CommitError, CommitIo, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store,
3 document::{Document, Kind},
4};
5use onestore_offline::{ConflictKind, EditStatus, Error, Remote, Replica};
6use std::io;
7
8#[derive(Clone, Copy, Default)]
9enum Fault {
10 #[default]
11 None,
12 Before,
13 UnknownBefore,
14 UnknownAfter,
15 Committed,
16 PanicBefore,
17 PanicAfter,
18 Confirm,
19 ConfirmCommitted,
20}
21
22struct Server {
23 visible: Vec<u8>,
24 durable: Vec<u8>,
25 fault: Fault,
26 publications: usize,
27 confirmations: usize,
28}
29
30impl Server {
31 fn new(source: &[u8]) -> Self {
32 Self {
33 visible: source.to_vec(),
34 durable: source.to_vec(),
35 fault: Fault::None,
36 publications: 0,
37 confirmations: 0,
38 }
39 }
40}
41
42fn failure(state: CommitState) -> CommitError {
43 CommitError {
44 state,
45 error: io::Error::from(io::ErrorKind::ConnectionAborted),
46 }
47}
48
49impl CommitIo for Server {
50 fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> {
51 let offset = usize::try_from(offset).unwrap();
52 let size = output.len().min(self.visible.len().saturating_sub(offset));
53 if size > 0 {
54 output[..size].copy_from_slice(&self.visible[offset..offset + size]);
55 }
56 Ok(size)
57 }
58 fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> {
59 let offset = usize::try_from(offset).unwrap();
60 self.visible
61 .resize(self.visible.len().max(offset + bytes.len()), 0);
62 self.visible[offset..offset + bytes.len()].copy_from_slice(bytes);
63 Ok(bytes.len())
64 }
65 fn flush(&mut self) -> io::Result<()> {
66 self.durable.clone_from(&self.visible);
67 Ok(())
68 }
69}
70
71impl Remote for Server {
72 fn read(&mut self) -> io::Result<Vec<u8>> {
73 Ok(self.visible.clone())
74 }
75 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
76 self.publications += 1;
77 let fault = std::mem::take(&mut self.fault);
78 match fault {
79 Fault::Before => return Err(failure(CommitState::NotCommitted)),
80 Fault::UnknownBefore => return Err(failure(CommitState::Unknown)),
81 Fault::PanicBefore => panic!("Terminated before remote I/O"),
82 _ => {}
83 }
84 let old = self.durable.clone();
85 edit.commit(self)?;
86 match fault {
87 Fault::UnknownAfter => {
88 self.durable = old;
89 Err(failure(CommitState::Unknown))
90 }
91 Fault::Committed => Err(failure(CommitState::Committed)),
92 Fault::PanicAfter => panic!("Terminated after remote publication"),
93 _ => Ok(()),
94 }
95 }
96 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
97 self.confirmations += 1;
98 if matches!(self.fault, Fault::Confirm) {
99 self.fault = Fault::None;
100 return Err(failure(CommitState::Unknown));
101 }
102 onestore::confirm_snapshot(self, snapshot)?;
103 if matches!(self.fault, Fault::ConfirmCommitted) {
104 self.fault = Fault::None;
105 return Err(failure(CommitState::Committed));
106 }
107 Ok(())
108 }
109}
110
111fn text(source: &[u8]) -> (ExGuid, ExGuid, String) {
112 let store = Store::parse(source).unwrap();
113 assert!(store.checksum_mismatches.is_empty());
114 let index = RevisionIndex::parse(&store).unwrap();
115 index.validate_current().unwrap();
116 let doc = Document::parse(&index).unwrap();
117 doc.spaces
118 .iter()
119 .find_map(|(sid, space)| {
120 space.revisions[&space.contexts[&ExGuid::default()]]
121 .nodes
122 .iter()
123 .find_map(|(oid, node)| match &node.kind {
124 Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())),
125 _ => None,
126 })
127 })
128 .unwrap()
129}
130
131#[test]
132fn rebases_multiple_disjoint_remote_changes_and_persists_the_remote_receipt() {
133 let dir = tempfile::tempdir().unwrap();
134 let path = dir.path().join("cache.sqlite");
135 let source = onestore::create_section("sync.one", "ab🦀cd", "Fixture").unwrap();
136 let (sid, oid, _) = text(&source);
137 let cache = Replica::create(&path, &source).unwrap();
138 let id = cache
139 .edit_text(&source, sid, oid, 2..4, "🐈")
140 .unwrap()
141 .unwrap();
142 let remote = onestore::replace_text(&source, sid, oid, 0..6, "Xab🦀cYd").unwrap();
143 let mut server = Server::new(&remote);
144 let outcome = cache.sync_once(&mut server).unwrap().unwrap();
145 assert_eq!(outcome.0, id);
146 assert!(matches!(outcome.1, EditStatus::Published { .. }));
147 assert_eq!(text(&server.durable).2, "Xab🐈cYd");
148 assert_eq!(cache.snapshot().unwrap(), server.durable);
149 assert!(cache.pending().unwrap().is_empty());
150 drop(cache);
151 let cache = Replica::open(&path).unwrap();
152 assert_eq!(cache.status(id).unwrap(), Some(outcome.1));
153 assert_eq!(cache.sync_once(&mut server).unwrap(), None);
154 assert_eq!(server.publications, 1);
155 assert_eq!(cache.status(id + 1).unwrap(), None);
156 let source = cache.snapshot().unwrap();
157 let next = cache
158 .edit_text(&source, sid, oid, 0..0, "Later ")
159 .unwrap()
160 .unwrap();
161 assert!(next > id);
162}
163
164#[test]
165fn overlapping_changes_preserve_both_images_and_survive_restart() {
166 let dir = tempfile::tempdir().unwrap();
167 let path = dir.path().join("cache.sqlite");
168 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
169 let (sid, oid, _) = text(&source);
170 let cache = Replica::create(&path, &source).unwrap();
171 let id = cache
172 .edit_text(&source, sid, oid, 1..2, "L")
173 .unwrap()
174 .unwrap();
175 let local = cache.snapshot().unwrap();
176 let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap();
177 let mut server = Server::new(&remote);
178 assert_eq!(
179 cache.sync_once(&mut server).unwrap(),
180 Some((id, EditStatus::Conflict(ConflictKind::TextChanged)))
181 );
182 assert_eq!(server.publications, 0);
183 drop(cache);
184 let cache = Replica::open(&path).unwrap();
185 assert_eq!(cache.snapshot().unwrap(), local);
186 assert_eq!(cache.remote_snapshot().unwrap(), remote);
187 assert_eq!(cache.pending().unwrap().len(), 1);
188 assert_eq!(
189 cache.status(id).unwrap(),
190 Some(EditStatus::Conflict(ConflictKind::TextChanged))
191 );
192}
193
194#[test]
195fn lost_replies_and_process_termination_never_blindly_replay_an_attempt() {
196 for fault in [
197 Fault::UnknownBefore,
198 Fault::UnknownAfter,
199 Fault::PanicBefore,
200 Fault::PanicAfter,
201 ] {
202 let dir = tempfile::tempdir().unwrap();
203 let path = dir.path().join("cache.sqlite");
204 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
205 let (sid, oid, _) = text(&source);
206 let cache = Replica::create(&path, &source).unwrap();
207 let id = cache
208 .edit_text(&source, sid, oid, 0..0, "Once ")
209 .unwrap()
210 .unwrap();
211 let mut server = Server::new(&source);
212 server.fault = fault;
213 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
214 cache.sync_once(&mut server)
215 }));
216 let attempted = cache.status(id).unwrap().unwrap();
217 assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. }));
218 drop(cache);
219 let cache = Replica::open(&path).unwrap();
220 let result = cache.sync_once(&mut server).unwrap().unwrap();
221 if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) {
222 assert!(matches!(result.1, EditStatus::Published { .. }));
223 assert_eq!(text(&server.durable).2, "Once abc");
224 assert_eq!(server.confirmations, 1);
225 assert!(cache.pending().unwrap().is_empty());
226 } else {
227 assert_eq!(result.1, attempted);
228 assert_eq!(cache.pending().unwrap().len(), 1);
229 assert_eq!(server.confirmations, 0);
230 assert_eq!(server.durable, source);
231 }
232 assert_eq!(server.publications, 1);
233 }
234}
235
236#[test]
237fn failed_confirmation_does_not_promote_visible_bytes_to_a_receipt() {
238 let dir = tempfile::tempdir().unwrap();
239 let path = dir.path().join("cache.sqlite");
240 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
241 let (sid, oid, _) = text(&source);
242 let cache = Replica::create(&path, &source).unwrap();
243 let id = cache
244 .edit_text(&source, sid, oid, 0..0, "Once ")
245 .unwrap()
246 .unwrap();
247 let mut server = Server::new(&source);
248 server.fault = Fault::UnknownAfter;
249 assert!(cache.sync_once(&mut server).is_err());
250 server.fault = Fault::Confirm;
251 assert!(cache.sync_once(&mut server).is_err());
252 assert_eq!(server.durable, source);
253 assert!(matches!(
254 cache.status(id).unwrap(),
255 Some(EditStatus::AwaitingConfirmation { .. })
256 ));
257 assert!(matches!(
258 cache.sync_once(&mut server).unwrap(),
259 Some((_, EditStatus::Published { .. }))
260 ));
261 assert_eq!(server.publications, 1);
262 assert_eq!(server.confirmations, 2);
263 assert_eq!(server.visible, server.durable);
264}
265
266#[test]
267fn confirmation_cleanup_failure_still_records_a_durable_receipt() {
268 let dir = tempfile::tempdir().unwrap();
269 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
270 let (sid, oid, _) = text(&source);
271 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();
272 let id = cache
273 .edit_text(&source, sid, oid, 0..0, "Once ")
274 .unwrap()
275 .unwrap();
276 let mut server = Server::new(&source);
277 server.fault = Fault::UnknownAfter;
278 assert!(cache.sync_once(&mut server).is_err());
279 server.fault = Fault::ConfirmCommitted;
280 assert!(
281 matches!(cache.sync_once(&mut server), Err(Error::Remote(error)) if error.state == CommitState::Committed)
282 );
283 assert!(matches!(
284 cache.status(id).unwrap(),
285 Some(EditStatus::Published { .. })
286 ));
287 assert!(cache.pending().unwrap().is_empty());
288 assert_eq!(server.visible, server.durable);
289 assert_eq!(server.publications, 1);
290 assert_eq!(server.confirmations, 1);
291}
292
293#[test]
294fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts() {
295 for fault in [Fault::Before, Fault::Committed] {
296 let dir = tempfile::tempdir().unwrap();
297 let path = dir.path().join("cache.sqlite");
298 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
299 let (sid, oid, _) = text(&source);
300 let cache = Replica::create(&path, &source).unwrap();
301 let id = cache
302 .edit_text(&source, sid, oid, 0..0, "Once ")
303 .unwrap()
304 .unwrap();
305 let mut server = Server::new(&source);
306 server.fault = fault;
307 assert!(matches!(
308 cache.sync_once(&mut server),
309 Err(Error::Remote(_))
310 ));
311 if matches!(fault, Fault::Before) {
312 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
313 assert!(matches!(
314 cache.sync_once(&mut server).unwrap(),
315 Some((_, EditStatus::Published { .. }))
316 ));
317 assert_eq!(server.publications, 2);
318 } else {
319 assert!(matches!(
320 cache.status(id).unwrap(),
321 Some(EditStatus::Published { .. })
322 ));
323 assert_eq!(cache.sync_once(&mut server).unwrap(), None);
324 assert_eq!(server.publications, 1);
325 }
326 assert_eq!(text(&server.durable).2, "Once abc");
327 }
328}
329
330#[test]
331fn database_failures_before_and_after_publication_preserve_recovery_state() {
332 for table in ["attempt", "receipts"] {
333 let dir = tempfile::tempdir().unwrap();
334 let path = dir.path().join("cache.sqlite");
335 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
336 let (sid, oid, _) = text(&source);
337 let cache = Replica::create(&path, &source).unwrap();
338 let id = cache
339 .edit_text(&source, sid, oid, 0..0, "Once ")
340 .unwrap()
341 .unwrap();
342 drop(cache);
343 let db = rusqlite::Connection::open(&path).unwrap();
344 db.execute_batch(&format!("CREATE TRIGGER interrupted BEFORE INSERT ON {table} BEGIN SELECT RAISE(ABORT,'Injected cache failure'); END;")).unwrap();
345 drop(db);
346 let cache = Replica::open(&path).unwrap();
347 let mut server = Server::new(&source);
348 assert!(matches!(
349 cache.sync_once(&mut server),
350 Err(Error::Database(_))
351 ));
352 assert_eq!(server.publications, usize::from(table == "receipts"));
353 assert_eq!(cache.pending().unwrap().len(), 1);
354 drop(cache);
355 let db = rusqlite::Connection::open(&path).unwrap();
356 db.execute_batch("DROP TRIGGER interrupted").unwrap();
357 drop(db);
358 let cache = Replica::open(&path).unwrap();
359 assert!(matches!(
360 cache.sync_once(&mut server).unwrap(),
361 Some((_, EditStatus::Published { .. }))
362 ));
363 assert!(matches!(
364 cache.status(id).unwrap(),
365 Some(EditStatus::Published { .. })
366 ));
367 assert_eq!(server.publications, 1);
368 assert_eq!(server.confirmations, usize::from(table == "receipts"));
369 assert_eq!(text(&server.durable).2, "Once abc");
370 }
371}
372
373#[test]
374fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmation() {
375 struct Paused {
376 server: Server,
377 phase: &'static str,
378 entered: std::sync::mpsc::Sender<()>,
379 resume: std::sync::mpsc::Receiver<()>,
380 }
381 impl Paused {
382 fn wait(&self, phase: &str) {
383 if self.phase == phase {
384 self.entered.send(()).unwrap();
385 self.resume
386 .recv_timeout(std::time::Duration::from_secs(5))
387 .unwrap();
388 }
389 }
390 }
391 impl Remote for Paused {
392 fn read(&mut self) -> io::Result<Vec<u8>> {
393 self.wait("read");
394 self.server.read()
395 }
396 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
397 self.wait("publish");
398 self.server.publish(edit)
399 }
400 fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> {
401 self.wait("confirm");
402 self.server.confirm(source)
403 }
404 }
405 for phase in ["read", "publish", "confirm"] {
406 let dir = tempfile::tempdir().unwrap();
407 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
408 let (sid, oid, _) = text(&source);
409 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();
410 let first = cache
411 .edit_text(&source, sid, oid, 0..0, "First ")
412 .unwrap()
413 .unwrap();
414 let mut server = Server::new(&source);
415 if phase == "confirm" {
416 server.fault = Fault::UnknownAfter;
417 assert!(cache.sync_once(&mut server).is_err());
418 }
419 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
420 let (resume_tx, resume_rx) = std::sync::mpsc::channel();
421 let mut paused = Paused {
422 server,
423 phase,
424 entered: entered_tx,
425 resume: resume_rx,
426 };
427 let mut server = std::thread::scope(|scope| {
428 let running = scope.spawn(|| {
429 let result = cache.sync_once(&mut paused);
430 assert!(
431 matches!(result, Ok(Some((id, EditStatus::Published { .. }))) if id == first)
432 );
433 paused.server
434 });
435 entered_rx
436 .recv_timeout(std::time::Duration::from_secs(5))
437 .unwrap();
438 assert!(
439 matches!(cache.sync_once(&mut Server::new(&source)),Err(Error::Io(error)) if error.kind()==io::ErrorKind::WouldBlock)
440 );
441 assert!(
442 matches!(cache.rebase_conflict(first, &[], &[], 0..0), Err(Error::Io(error)) if error.kind() == io::ErrorKind::WouldBlock)
443 );
444 let started = std::time::Instant::now();
445 let handles: Vec<_> = (0..12)
446 .map(|writer| {
447 let cache = &cache;
448 scope.spawn(move || {
449 loop {
450 let snapshot = cache.snapshot().unwrap();
451 match cache.edit_text(
452 &snapshot,
453 sid,
454 oid,
455 0..0,
456 &format!("[{writer}] "),
457 ) {
458 Ok(Some(id)) => break id,
459 Err(Error::Io(error))
460 if error.kind() == io::ErrorKind::ResourceBusy => {}
461 other => panic!("Unexpected local outcome {other:?}"),
462 }
463 }
464 })
465 })
466 .collect();
467 let ids: std::collections::BTreeSet<_> = handles
468 .into_iter()
469 .map(|handle| handle.join().unwrap())
470 .collect();
471 assert_eq!(ids.len(), 12);
472 assert!(
473 started.elapsed() < std::time::Duration::from_secs(2),
474 "Local edits waited for remote {phase}"
475 );
476 resume_tx.send(()).unwrap();
477 running.join().unwrap()
478 });
479 assert_eq!(cache.pending().unwrap().len(), 12);
480 let expected = text(&cache.snapshot().unwrap()).2;
481 for _ in 0..12 {
482 assert!(matches!(
483 cache.sync_once(&mut server).unwrap(),
484 Some((_, EditStatus::Published { .. }))
485 ));
486 }
487 assert!(cache.pending().unwrap().is_empty());
488 assert_eq!(server.publications, 13);
489 assert_eq!(text(&server.durable).2, expected);
490 assert_eq!(cache.snapshot().unwrap(), server.durable);
491 }
492}
493
494#[test]
495fn unrelated_remote_files_never_replace_a_local_cache() {
496 let dir = tempfile::tempdir().unwrap();
497 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
498 let (sid, oid, _) = text(&source);
499 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();
500 let other = onestore::create_section("other.one", "abc", "Fixture").unwrap();
501 let mut server = Server::new(&other);
502 for pending in [false, true] {
503 if pending {
504 cache.edit_text(&source, sid, oid, 0..0, "Local ").unwrap();
505 }
506 let before = cache.snapshot().unwrap();
507 assert!(
508 matches!(cache.sync_once(&mut server),Err(Error::Io(error)) if error.kind()==io::ErrorKind::InvalidInput)
509 );
510 assert_eq!(cache.snapshot().unwrap(), before);
511 assert_eq!(cache.remote_snapshot().unwrap(), source);
512 assert_eq!(server.publications, 0);
513 }
514}
515
516#[test]
517fn version_one_cache_migration_preserves_images_intents_and_local_ids() {
518 let dir = tempfile::tempdir().unwrap();
519 let path = dir.path().join("cache.sqlite");
520 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
521 let (sid, oid, _) = text(&source);
522 let cache = Replica::create(&path, &source).unwrap();
523 let id = cache
524 .edit_text(&source, sid, oid, 0..0, "Local ")
525 .unwrap()
526 .unwrap();
527 let snapshot = cache.snapshot().unwrap();
528 let pending = cache.pending().unwrap();
529 drop(cache);
530 let db = rusqlite::Connection::open(&path).unwrap();
531 db.execute_batch(
532 "DROP TABLE attempt; DROP TABLE conflicts; DROP TABLE receipts; DROP TABLE edits;
533 CREATE TABLE edits (
534 id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), space TEXT NOT NULL,
535 object TEXT NOT NULL, before_text TEXT NOT NULL,
536 start INTEGER NOT NULL CHECK(start BETWEEN 0 AND 4294967295),
537 end INTEGER NOT NULL CHECK(end BETWEEN start AND 4294967295), replacement TEXT NOT NULL
538 ) STRICT; PRAGMA user_version=1;",
539 )
540 .unwrap();
541 db.execute("INSERT INTO edits(id,space,object,before_text,start,end,replacement) VALUES (?1,?2,?3,'abc',0,0,'Local ')",rusqlite::params![i64::try_from(id).unwrap(),sid.to_string(),oid.to_string()]).unwrap();
542 drop(db);
543 let cache = Replica::open(&path).unwrap();
544 assert_eq!(cache.snapshot().unwrap(), snapshot);
545 assert_eq!(cache.pending().unwrap(), pending);
546 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
547 let mut server = Server::new(&source);
548 assert!(matches!(
549 cache.sync_once(&mut server).unwrap(),
550 Some((_, EditStatus::Published { .. }))
551 ));
552 drop(cache);
553 let db = rusqlite::Connection::open(&path).unwrap();
554 assert_eq!(
555 db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))
556 .unwrap(),
557 4
558 );
559}
560
561#[test]
562fn reviewed_conflict_rebase_preserves_twelve_dependent_edits_and_survives_reopen() {
563 let dir = tempfile::tempdir().unwrap();
564 let path = dir.path().join("cache.sqlite");
565 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
566 let (sid, oid, _) = text(&source);
567 let cache = Replica::create(&path, &source).unwrap();
568 let first = cache
569 .edit_text(&source, sid, oid, 1..2, "L")
570 .unwrap()
571 .unwrap();
572 for n in 0..12 {
573 cache
574 .edit_text(
575 &cache.snapshot().unwrap(),
576 sid,
577 oid,
578 0..0,
579 &format!("[{n}] "),
580 )
581 .unwrap();
582 }
583 let local = cache.snapshot().unwrap();
584 let pending = cache.pending().unwrap();
585 let remote = onestore::replace_text(&source, sid, oid, 0..3, "aRcZ").unwrap();
586 let mut server = Server::new(&remote);
587 assert_eq!(
588 cache.sync_once(&mut server).unwrap(),
589 Some((first, EditStatus::Conflict(ConflictKind::TextChanged)))
590 );
591 cache.rebase_conflict(first, &local, &remote, 1..2).unwrap();
592 assert_eq!(cache.snapshot().unwrap(), local);
593 assert_eq!(cache.remote_snapshot().unwrap(), remote);
594 let rebased = cache.pending().unwrap();
595 assert_eq!(&rebased[1..], &pending[1..]);
596 assert_eq!(rebased[0].id, first);
597 let onestore_offline::Operation::Text(updated) = &rebased[0].operation else {
598 panic!()
599 };
600 let onestore_offline::Operation::Text(previous) = &pending[0].operation else {
601 panic!()
602 };
603 assert_eq!(updated.replacement, previous.replacement);
604 assert_eq!(updated.before, "aRcZ");
605 assert_eq!(cache.status(first).unwrap(), Some(EditStatus::Pending));
606 drop(cache);
607 let cache = Replica::open(&path).unwrap();
608 assert_eq!(cache.pending().unwrap(), rebased);
609 assert_eq!(cache.snapshot().unwrap(), local);
610 for intent in &pending {
611 assert!(
612 matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == intent.id)
613 );
614 }
615 assert_eq!(server.publications, 13);
616 assert_eq!(text(&server.durable).2, text(&local).2 + "Z");
617 assert_eq!(cache.snapshot().unwrap(), server.durable);
618 assert!(cache.pending().unwrap().is_empty());
619}
620
621#[test]
622fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states() {
623 let dir = tempfile::tempdir().unwrap();
624 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
625 let (sid, oid, _) = text(&source);
626 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();
627 let id = cache
628 .edit_text(&source, sid, oid, 1..2, "L")
629 .unwrap()
630 .unwrap();
631 let local = cache.snapshot().unwrap();
632 assert!(
633 matches!(cache.rebase_conflict(id, &local, &source, 1..2), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput)
634 );
635 let remote = onestore::replace_text(&source, sid, oid, 0..3, "🦀Rc").unwrap();
636 let mut server = Server::new(&remote);
637 assert!(matches!(
638 cache.sync_once(&mut server).unwrap(),
639 Some((_, EditStatus::Conflict(_)))
640 ));
641 let pending = cache.pending().unwrap();
642 for range in [1..2, 100..101] {
643 assert!(matches!(
644 cache.rebase_conflict(id, &local, &remote, range),
645 Err(Error::Document(_))
646 ));
647 assert_eq!(cache.pending().unwrap(), pending);
648 }
649 assert!(
650 matches!(cache.rebase_conflict(id + 1, &local, &remote, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput)
651 );
652 cache.edit_text(&local, sid, oid, 0..0, "Later ").unwrap();
653 let changed = cache.snapshot().unwrap();
654 assert!(
655 matches!(cache.rebase_conflict(id, &local, &remote, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy)
656 );
657 let new_remote = onestore::replace_text(&remote, sid, oid, 2..3, "Q").unwrap();
658 server.visible = new_remote.clone();
659 server.durable = new_remote;
660 assert!(matches!(
661 cache.sync_once(&mut server).unwrap(),
662 Some((_, EditStatus::Conflict(_)))
663 ));
664 assert!(
665 matches!(cache.rebase_conflict(id, &changed, &remote, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy)
666 );
667 assert_eq!(cache.snapshot().unwrap(), changed);
668 assert_eq!(cache.pending().unwrap()[0], pending[0]);
669 assert_eq!(server.publications, 0);
670
671 let current = cache.remote_snapshot().unwrap();
672 cache.rebase_conflict(id, &changed, &current, 2..3).unwrap();
673 server.fault = Fault::UnknownBefore;
674 assert!(
675 matches!(cache.sync_once(&mut server), Err(Error::Remote(error)) if error.state == CommitState::Unknown)
676 );
677 let attempted = cache.status(id).unwrap();
678 let pending = cache.pending().unwrap();
679 assert!(
680 matches!(cache.rebase_conflict(id, &changed, &current, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput)
681 );
682 assert_eq!(cache.status(id).unwrap(), attempted);
683 assert_eq!(cache.pending().unwrap(), pending);
684 assert_eq!(server.publications, 1);
685}
686
687#[test]
688fn failure_between_rebase_and_conflict_clear_rolls_back_the_entire_resolution() {
689 let dir = tempfile::tempdir().unwrap();
690 let path = dir.path().join("cache.sqlite");
691 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
692 let (sid, oid, _) = text(&source);
693 let cache = Replica::create(&path, &source).unwrap();
694 let id = cache
695 .edit_text(&source, sid, oid, 1..2, "L")
696 .unwrap()
697 .unwrap();
698 let remote = onestore::replace_text(&source, sid, oid, 0..3, "XaRc").unwrap();
699 let mut server = Server::new(&remote);
700 cache.sync_once(&mut server).unwrap();
701 let local = cache.snapshot().unwrap();
702 let pending = cache.pending().unwrap();
703 let status = cache.status(id).unwrap();
704 drop(cache);
705 let connection = rusqlite::Connection::open(&path).unwrap();
706 connection.execute_batch("CREATE TRIGGER fail_clear BEFORE DELETE ON conflicts BEGIN SELECT RAISE(ABORT, 'test conflict clear failure'); END;").unwrap();
707 drop(connection);
708 let cache = Replica::open(&path).unwrap();
709 assert!(matches!(
710 cache.rebase_conflict(id, &local, &remote, 2..3),
711 Err(Error::Database(_))
712 ));
713 drop(cache);
714 let cache = Replica::open(&path).unwrap();
715 assert_eq!(cache.pending().unwrap(), pending);
716 assert_eq!(cache.status(id).unwrap(), status);
717 assert_eq!(cache.snapshot().unwrap(), local);
718 assert_eq!(cache.remote_snapshot().unwrap(), remote);
719 assert_eq!(server.publications, 0);
720}
721
722#[test]
723fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replacement() {
724 let dir = tempfile::tempdir().unwrap();
725 let original: Vec<_> = "abcdefghij🦀klmnop".chars().collect();
726 let mut seed = 911_u64;
727 for case in 0..64 {
728 seed ^= seed << 13;
729 seed ^= seed >> 7;
730 seed ^= seed << 17;
731 let at = seed as usize % original.len();
732 let prefix = "[".repeat((seed >> 8) as usize % 5);
733 let suffix = "]".repeat((seed >> 16) as usize % 5);
734 let start: u32 = original[..at].iter().map(|ch| ch.len_utf16() as u32).sum();
735 let end = start + original[at].len_utf16() as u32;
736 let source = onestore::create_section(
737 "resolve.one",
738 &original.iter().collect::<String>(),
739 "Fixture",
740 )
741 .unwrap();
742 let (sid, oid, _) = text(&source);
743 let cache = Replica::create(dir.path().join(format!("{case}.sqlite")), &source).unwrap();
744 let id = cache
745 .edit_text(&source, sid, oid, start..end, "λ🦊μ")
746 .unwrap()
747 .unwrap();
748 let dependent = cache
749 .edit_text(
750 &cache.snapshot().unwrap(),
751 sid,
752 oid,
753 start + 1..start + 3,
754 "🐕",
755 )
756 .unwrap()
757 .unwrap();
758 let mut remote_text = original.clone();
759 remote_text.splice(at..at + 1, "Ω🐈π".chars());
760 let remote_text = prefix.clone() + &remote_text.iter().collect::<String>() + &suffix;
761 let remote = onestore::replace_text(
762 &source,
763 sid,
764 oid,
765 0..original.iter().map(|ch| ch.len_utf16() as u32).sum(),
766 &remote_text,
767 )
768 .unwrap();
769 let mut server = Server::new(&remote);
770 assert!(
771 matches!(
772 cache.sync_once(&mut server).unwrap(),
773 Some((_, EditStatus::Conflict(_)))
774 ),
775 "case {case}, seed {seed}"
776 );
777 let at_remote = start + prefix.len() as u32;
778 cache
779 .rebase_conflict(
780 id,
781 &cache.snapshot().unwrap(),
782 &remote,
783 at_remote..at_remote + 4,
784 )
785 .unwrap();
786 for expected in [id, dependent] {
787 assert!(
788 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected),
789 "case {case}, seed {seed}"
790 );
791 }
792 let mut expected = original.clone();
793 expected.splice(at..at + 1, "λ🐕μ".chars());
794 let expected = prefix + &expected.iter().collect::<String>() + &suffix;
795 assert_eq!(
796 text(&server.durable).2,
797 expected,
798 "case {case}, seed {seed}"
799 );
800 assert_eq!(server.publications, 2);
801 assert!(cache.pending().unwrap().is_empty());
802 }
803}
804
805#[test]
806fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement() {
807 struct ChangedAfterRead {
808 server: Server,
809 change: Option<Vec<u8>>,
810 }
811 impl Remote for ChangedAfterRead {
812 fn read(&mut self) -> io::Result<Vec<u8>> {
813 let snapshot = self.server.read()?;
814 if let Some(changed) = self.change.take() {
815 self.server.visible = changed.clone();
816 self.server.durable = changed;
817 }
818 Ok(snapshot)
819 }
820 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
821 self.server.publish(edit)
822 }
823 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
824 self.server.confirm(snapshot)
825 }
826 }
827 for after_read in [false, true] {
828 let dir = tempfile::tempdir().unwrap();
829 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
830 let (sid, oid, _) = text(&source);
831 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();
832 let id = cache
833 .edit_text(&source, sid, oid, 1..2, "L")
834 .unwrap()
835 .unwrap();
836 let local = cache.snapshot().unwrap();
837 let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap();
838 let mut remote = ChangedAfterRead {
839 server: Server::new(&remote),
840 change: None,
841 };
842 assert!(matches!(
843 cache.sync_once(&mut remote).unwrap(),
844 Some((_, EditStatus::Conflict(_)))
845 ));
846 cache
847 .rebase_conflict(id, &local, &cache.remote_snapshot().unwrap(), 1..2)
848 .unwrap();
849 let changed = onestore::replace_text(&remote.server.visible, sid, oid, 1..2, "Q").unwrap();
850 if after_read {
851 remote.change = Some(changed.clone());
852 } else {
853 remote.server.visible = changed.clone();
854 remote.server.durable = changed.clone();
855 }
856 if after_read {
857 assert!(
858 matches!(cache.sync_once(&mut remote), Err(Error::Remote(error)) if error.state == CommitState::NotCommitted)
859 );
860 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
861 }
862 assert_eq!(
863 cache.sync_once(&mut remote).unwrap(),
864 Some((id, EditStatus::Conflict(ConflictKind::TextChanged)))
865 );
866 assert_eq!(remote.server.durable, changed);
867 assert_eq!(remote.server.visible, changed);
868 assert_eq!(remote.server.publications, usize::from(after_read));
869 assert_eq!(cache.snapshot().unwrap(), local);
870 let onestore_offline::Operation::Text(edit) = &cache.pending().unwrap()[0].operation else {
871 panic!()
872 };
873 assert_eq!(edit.replacement, "L");
874 }
875}
876
877mod worker {
878 use super::*;
879 use std::{
880 sync::{Arc, Mutex, mpsc},
881 time::{Duration, Instant},
882 };
883
884 #[derive(Clone)]
885 struct Shared(Arc<Mutex<Server>>);
886
887 impl Remote for Shared {
888 fn read(&mut self) -> io::Result<Vec<u8>> {
889 self.0.lock().unwrap().read()
890 }
891 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
892 self.0.lock().unwrap().publish(edit)
893 }
894 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
895 self.0.lock().unwrap().confirm(snapshot)
896 }
897 }
898
899 #[test]
900 fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() {
901 struct Session {
902 shared: Shared,
903 fail_read: bool,
904 }
905 impl Remote for Session {
906 fn read(&mut self) -> io::Result<Vec<u8>> {
907 if self.fail_read {
908 return Err(io::ErrorKind::ConnectionReset.into());
909 }
910 self.shared.read()
911 }
912 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
913 self.shared.publish(edit)
914 }
915 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
916 self.shared.confirm(snapshot)
917 }
918 }
919 let dir = tempfile::tempdir().unwrap();
920 let path = dir.path().join("cache.sqlite");
921 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
922 let (sid, oid, _) = text(&source);
923 let cache = Arc::new(Replica::create(&path, &source).unwrap());
924 let id = cache
925 .edit_text(&source, sid, oid, 1..2, "🦀")
926 .unwrap()
927 .unwrap();
928 let mut server = Server::new(&source);
929 server.fault = Fault::UnknownAfter;
930 let server = Arc::new(Mutex::new(server));
931 let shared = Shared(Arc::clone(&server));
932 let (connected_tx, connected_rx) = mpsc::channel();
933 let (observed_tx, observed_rx) = mpsc::channel();
934 let mut connections = 0;
935 let worker = cache
936 .start_sync(
937 Duration::from_millis(10),
938 move || {
939 connections += 1;
940 connected_tx.send(connections).unwrap();
941 if connections <= 2 {
942 return Err(io::ErrorKind::ConnectionRefused.into());
943 }
944 Ok(Session {
945 shared: shared.clone(),
946 fail_read: connections == 3,
947 })
948 },
949 move |result| {
950 observed_tx
951 .send(result.as_ref().copied().map_err(|error| error.to_string()))
952 .unwrap();
953 },
954 )
955 .unwrap();
956 let mut errors = 0;
957 let published = loop {
958 match observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() {
959 Err(_) => errors += 1,
960 Ok(Some((actual, status @ EditStatus::Published { .. }))) => {
961 assert_eq!(actual, id);
962 break status;
963 }
964 other => panic!("Unexpected result: {other:?}"),
965 }
966 };
967 worker.stop().unwrap();
968 assert_eq!(errors, 4);
969 assert_eq!(connected_rx.try_iter().collect::<Vec<_>>(), [1, 2, 3, 4, 5]);
970 let server = server.lock().unwrap();
971 assert_eq!(server.publications, 1);
972 assert_eq!(server.confirmations, 1);
973 assert_eq!(text(&server.durable).2, "a🦀c");
974 drop(cache);
975 let cache = Replica::open(&path).unwrap();
976 assert_eq!(cache.status(id).unwrap(), Some(published));
977 assert_eq!(cache.snapshot().unwrap(), server.durable);
978 }
979
980 #[test]
981 fn local_edits_wake_an_idle_worker_and_coalesced_notifications_drain_twelve_writers() {
982 let dir = tempfile::tempdir().unwrap();
983 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
984 let (sid, oid, _) = text(&source);
985 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
986 let server = Arc::new(Mutex::new(Server::new(&source)));
987 let shared = Shared(Arc::clone(&server));
988 let (observed_tx, observed_rx) = mpsc::channel();
989 let (resume_tx, resume_rx) = mpsc::channel();
990 let mut first = true;
991 let worker = cache
992 .start_sync(
993 Duration::from_secs(3600),
994 move || Ok(shared.clone()),
995 move |result| {
996 observed_tx.send(*result.as_ref().unwrap()).unwrap();
997 if first {
998 first = false;
999 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1000 }
1001 },
1002 )
1003 .unwrap();
1004 assert_eq!(
1005 observed_rx.recv_timeout(Duration::from_secs(5)).unwrap(),
1006 None
1007 );
1008 let started = Instant::now();
1009 let edits = std::thread::scope(|scope| {
1010 (0..12)
1011 .map(|writer| {
1012 let cache = &cache;
1013 scope.spawn(move || {
1014 let replacement = format!("[{writer}] ");
1015 loop {
1016 let snapshot = cache.snapshot().unwrap();
1017 match cache.edit_text(&snapshot, sid, oid, 0..0, &replacement) {
1018 Ok(Some(id)) => break (id, replacement),
1019 Err(Error::Io(error))
1020 if error.kind() == io::ErrorKind::ResourceBusy => {}
1021 other => panic!("Unexpected local outcome: {other:?}"),
1022 }
1023 }
1024 })
1025 })
1026 .collect::<Vec<_>>()
1027 .into_iter()
1028 .map(|join| join.join().unwrap())
1029 .collect::<std::collections::BTreeMap<_, _>>()
1030 });
1031 resume_tx.send(()).unwrap();
1032 let mut published = std::collections::BTreeSet::new();
1033 while published.len() < 12 {
1034 if let Some((id, status)) = observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() {
1035 assert!(matches!(status, EditStatus::Published { .. }), "{status:?}");
1036 assert!(published.insert(id));
1037 }
1038 }
1039 assert!(
1040 started.elapsed() < Duration::from_secs(5),
1041 "Edits waited for the hourly poll"
1042 );
1043 worker.stop().unwrap();
1044 assert_eq!(published, edits.keys().copied().collect());
1045 let expected = edits.values().rev().cloned().collect::<String>() + "abc";
1046 let server = server.lock().unwrap();
1047 assert_eq!(text(&server.durable).2, expected);
1048 assert_eq!(server.publications, 12);
1049 assert_eq!(cache.snapshot().unwrap(), server.durable);
1050 assert!(cache.pending().unwrap().is_empty());
1051 }
1052
1053 #[test]
1054 fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recovery_is_recorded()
1055 {
1056 struct Paused {
1057 shared: Shared,
1058 entered: mpsc::Sender<()>,
1059 resume: mpsc::Receiver<()>,
1060 }
1061 impl Remote for Paused {
1062 fn read(&mut self) -> io::Result<Vec<u8>> {
1063 self.shared.read()
1064 }
1065 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
1066 self.entered.send(()).unwrap();
1067 self.resume.recv_timeout(Duration::from_secs(5)).unwrap();
1068 self.shared.publish(edit)
1069 }
1070 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
1071 self.shared.confirm(snapshot)
1072 }
1073 }
1074 let dir = tempfile::tempdir().unwrap();
1075 let path = dir.path().join("cache.sqlite");
1076 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
1077 let (sid, oid, _) = text(&source);
1078 let cache = Arc::new(Replica::create(&path, &source).unwrap());
1079 let id = cache
1080 .edit_text(&source, sid, oid, 0..0, "L ")
1081 .unwrap()
1082 .unwrap();
1083 let mut server = Server::new(&source);
1084 server.fault = Fault::UnknownAfter;
1085 let server = Arc::new(Mutex::new(server));
1086 let shared = Shared(Arc::clone(&server));
1087 let (entered_tx, entered_rx) = mpsc::channel();
1088 let (resume_tx, resume_rx) = mpsc::channel();
1089 let mut session = Some(Paused {
1090 shared,
1091 entered: entered_tx,
1092 resume: resume_rx,
1093 });
1094 let worker = cache
1095 .start_sync(
1096 Duration::from_secs(3600),
1097 move || Ok(session.take().unwrap()),
1098 |_| {},
1099 )
1100 .unwrap();
1101 entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1102 let stopped = Instant::now();
1103 drop(worker);
1104 assert!(stopped.elapsed() < Duration::from_millis(500));
1105 let shared = Shared(Arc::clone(&server));
1106 let start = cache.start_sync(
1107 Duration::from_secs(3600),
1108 move || Ok(shared.clone()),
1109 |_| {},
1110 );
1111 assert!(matches!(start, Err(error) if error.kind() == io::ErrorKind::WouldBlock));
1112 let weak = Arc::downgrade(&cache);
1113 drop(cache);
1114 resume_tx.send(()).unwrap();
1115 while weak.upgrade().is_some() {
1116 assert!(stopped.elapsed() < Duration::from_secs(5));
1117 std::thread::sleep(Duration::from_millis(1));
1118 }
1119 let cache = Arc::new(Replica::open(&path).unwrap());
1120 assert!(matches!(
1121 cache.status(id).unwrap(),
1122 Some(EditStatus::AwaitingConfirmation { .. })
1123 ));
1124 assert_eq!(server.lock().unwrap().publications, 1);
1125 let shared = Shared(Arc::clone(&server));
1126 let (tx, rx) = mpsc::channel();
1127 let worker = cache
1128 .start_sync(
1129 Duration::from_secs(3600),
1130 move || Ok(shared.clone()),
1131 move |result| {
1132 tx.send(*result.as_ref().unwrap()).unwrap();
1133 },
1134 )
1135 .unwrap();
1136 assert!(
1137 matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
1138 );
1139 worker.stop().unwrap();
1140 let server = server.lock().unwrap();
1141 assert_eq!(server.publications, 1);
1142 assert_eq!(server.confirmations, 1);
1143 assert_eq!(text(&server.durable).2, "L abc");
1144 }
1145
1146 #[test]
1147 fn cache_failures_stop_retries_and_return_the_error_without_remote_publication() {
1148 let dir = tempfile::tempdir().unwrap();
1149 let path = dir.path().join("cache.sqlite");
1150 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
1151 let (sid, oid, _) = text(&source);
1152 let cache = Replica::create(&path, &source).unwrap();
1153 let id = cache
1154 .edit_text(&source, sid, oid, 0..0, "L ")
1155 .unwrap()
1156 .unwrap();
1157 drop(cache);
1158 let connection = rusqlite::Connection::open(&path).unwrap();
1159 connection.execute_batch("CREATE TRIGGER fail_attempt BEFORE INSERT ON attempt BEGIN SELECT RAISE(ABORT, 'test cache write failure'); END;").unwrap();
1160 drop(connection);
1161 let cache = Arc::new(Replica::open(&path).unwrap());
1162 let server = Arc::new(Mutex::new(Server::new(&source)));
1163 let shared = Shared(Arc::clone(&server));
1164 let (tx, rx) = mpsc::channel();
1165 let worker = cache
1166 .start_sync(
1167 Duration::from_millis(1),
1168 move || Ok(shared.clone()),
1169 move |result| {
1170 tx.send(matches!(result, Err(Error::Database(_)))).unwrap();
1171 },
1172 )
1173 .unwrap();
1174 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap());
1175 assert!(matches!(worker.stop(), Err(Error::Database(_))));
1176 assert!(rx.try_iter().next().is_none());
1177 assert_eq!(server.lock().unwrap().publications, 0);
1178 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
1179 assert_eq!(text(&cache.snapshot().unwrap()).2, "L abc");
1180 }
1181
1182 #[test]
1183 fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() {
1184 for uncertain in [false, true] {
1185 let dir = tempfile::tempdir().unwrap();
1186 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
1187 let (sid, oid, _) = text(&source);
1188 let cache =
1189 Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
1190 let id = cache
1191 .edit_text(&source, sid, oid, 1..2, "L")
1192 .unwrap()
1193 .unwrap();
1194 let local = cache.snapshot().unwrap();
1195 let mut server = if uncertain {
1196 Server::new(&source)
1197 } else {
1198 Server::new(&onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap())
1199 };
1200 if uncertain {
1201 server.fault = Fault::UnknownBefore;
1202 }
1203 let server = Arc::new(Mutex::new(server));
1204 let shared = Shared(Arc::clone(&server));
1205 let (tx, rx) = mpsc::channel();
1206 let worker = cache
1207 .start_sync(
1208 Duration::from_millis(10),
1209 move || Ok(shared.clone()),
1210 move |result| {
1211 tx.send(result.as_ref().copied().map_err(|_| ())).unwrap();
1212 },
1213 )
1214 .unwrap();
1215 if uncertain {
1216 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err());
1217 }
1218 let mut previous = None;
1219 let started = Instant::now();
1220 for _ in 0..5 {
1221 let (actual, status) = rx
1222 .recv_timeout(Duration::from_secs(5))
1223 .unwrap()
1224 .unwrap()
1225 .unwrap();
1226 assert_eq!(actual, id);
1227 if uncertain {
1228 assert!(matches!(status, EditStatus::AwaitingConfirmation { .. }));
1229 } else {
1230 assert_eq!(status, EditStatus::Conflict(ConflictKind::TextChanged));
1231 }
1232 if let Some(previous) = previous {
1233 assert_eq!(previous, status);
1234 }
1235 previous = Some(status);
1236 }
1237 assert!(
1238 started.elapsed() >= Duration::from_millis(30),
1239 "Worker spun instead of waiting between retries"
1240 );
1241 worker.stop().unwrap();
1242 assert_eq!(cache.snapshot().unwrap(), local);
1243 assert_eq!(cache.pending().unwrap().len(), 1);
1244 let server = server.lock().unwrap();
1245 assert_eq!(server.publications, usize::from(uncertain));
1246 assert_eq!(server.confirmations, 0);
1247 }
1248 }
1249
1250 #[test]
1251 fn reachability_notification_retries_without_waiting_for_the_poll() {
1252 let dir = tempfile::tempdir().unwrap();
1253 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
1254 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
1255 let (tx, rx) = mpsc::channel();
1256 let worker = cache
1257 .start_sync(
1258 Duration::from_secs(3600),
1259 || -> io::Result<Shared> { Err(io::ErrorKind::NotConnected.into()) },
1260 move |result| {
1261 tx.send(matches!(result, Err(Error::RemoteIo(_)))).unwrap();
1262 },
1263 )
1264 .unwrap();
1265 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap());
1266 worker.wake();
1267 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap());
1268 worker.stop().unwrap();
1269 assert!(rx.try_iter().next().is_none());
1270 }
1271
1272 #[test]
1273 fn cancellation_during_connect_does_not_read_or_report_a_false_refresh() {
1274 let dir = tempfile::tempdir().unwrap();
1275 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
1276 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
1277 let (entered_tx, entered_rx) = mpsc::channel();
1278 let (resume_tx, resume_rx) = mpsc::channel();
1279 let (tx, rx) = mpsc::channel();
1280 // An invalid image makes any unexpected read observable as an error callback.
1281 let shared = Shared(Arc::new(Mutex::new(Server::new(&[]))));
1282 let worker = cache
1283 .start_sync(
1284 Duration::from_secs(3600),
1285 move || {
1286 entered_tx.send(()).unwrap();
1287 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1288 Ok(shared.clone())
1289 },
1290 move |_| {
1291 tx.send(()).unwrap();
1292 },
1293 )
1294 .unwrap();
1295 entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1296 drop(worker);
1297 let weak = Arc::downgrade(&cache);
1298 drop(cache);
1299 resume_tx.send(()).unwrap();
1300 let started = Instant::now();
1301 while weak.upgrade().is_some() {
1302 assert!(started.elapsed() < Duration::from_secs(5));
1303 std::thread::sleep(Duration::from_millis(1));
1304 }
1305 assert_eq!(
1306 rx.recv_timeout(Duration::from_secs(5)),
1307 Err(mpsc::RecvTimeoutError::Disconnected)
1308 );
1309 }
1310
1311 #[test]
1312 fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() {
1313 let dir = tempfile::tempdir().unwrap();
1314 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
1315 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
1316 for interval in [Duration::ZERO, Duration::MAX] {
1317 assert!(
1318 matches!(cache.start_sync(interval, || -> io::Result<Shared> { panic!("Unexpected connection") }, |_| {}), Err(error) if error.kind() == io::ErrorKind::InvalidInput)
1319 );
1320 }
1321 let shared = Shared(Arc::new(Mutex::new(Server::new(&source))));
1322 let first = shared.clone();
1323 let (tx, rx) = mpsc::channel();
1324 let worker = cache
1325 .start_sync(
1326 Duration::from_secs(3600),
1327 move || Ok(first.clone()),
1328 move |_| {
1329 tx.send(()).unwrap();
1330 panic!("Test observer panic");
1331 },
1332 )
1333 .unwrap();
1334 rx.recv_timeout(Duration::from_secs(5)).unwrap();
1335 assert!(matches!(worker.stop(), Err(Error::Io(_))));
1336 let (tx, rx) = mpsc::channel();
1337 let worker = cache
1338 .start_sync(
1339 Duration::from_secs(3600),
1340 move || Ok(shared.clone()),
1341 move |result| {
1342 tx.send(*result.as_ref().unwrap()).unwrap();
1343 },
1344 )
1345 .unwrap();
1346 assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), None);
1347 worker.stop().unwrap();
1348 assert_eq!(cache.snapshot().unwrap(), source);
1349 }
1350
1351 #[test]
1352 fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() {
1353 let dir = tempfile::tempdir().unwrap();
1354 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
1355 let (sid, oid, _) = text(&source);
1356 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
1357 let id = cache
1358 .edit_text(&source, sid, oid, 1..2, "L")
1359 .unwrap()
1360 .unwrap();
1361 let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap();
1362 let server = Arc::new(Mutex::new(Server::new(&remote)));
1363 let shared = Shared(Arc::clone(&server));
1364 let (tx, rx) = mpsc::channel();
1365 let (resume_tx, resume_rx) = mpsc::channel();
1366 let mut first = true;
1367 let worker = cache
1368 .start_sync(
1369 Duration::from_secs(3600),
1370 move || Ok(shared.clone()),
1371 move |result| {
1372 tx.send(*result.as_ref().unwrap()).unwrap();
1373 if first {
1374 first = false;
1375 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1376 }
1377 },
1378 )
1379 .unwrap();
1380 assert_eq!(
1381 rx.recv_timeout(Duration::from_secs(5)).unwrap(),
1382 Some((id, EditStatus::Conflict(ConflictKind::TextChanged)))
1383 );
1384 cache
1385 .rebase_conflict(
1386 id,
1387 &cache.snapshot().unwrap(),
1388 &cache.remote_snapshot().unwrap(),
1389 1..2,
1390 )
1391 .unwrap();
1392 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
1393 resume_tx.send(()).unwrap();
1394 assert!(
1395 matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
1396 );
1397 worker.stop().unwrap();
1398 assert!(cache.pending().unwrap().is_empty());
1399 let server = server.lock().unwrap();
1400 assert_eq!(server.publications, 1);
1401 assert_eq!(text(&server.durable).2, "aLc");
1402 assert_eq!(cache.snapshot().unwrap(), server.durable);
1403 }
1404
1405 #[test]
1406 fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() {
1407 struct Busy {
1408 server: Server,
1409 reads: usize,
1410 writes: usize,
1411 }
1412 impl Remote for Busy {
1413 fn read(&mut self) -> io::Result<Vec<u8>> {
1414 self.reads += 1;
1415 match self.reads {
1416 1 => Err(io::ErrorKind::WouldBlock.into()),
1417 2 => Err(io::ErrorKind::ResourceBusy.into()),
1418 _ => self.server.read(),
1419 }
1420 }
1421 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
1422 self.writes += 1;
1423 match self.writes {
1424 1 | 2 => Err(CommitError {
1425 state: CommitState::NotCommitted,
1426 error: if self.writes == 1 {
1427 io::ErrorKind::WouldBlock
1428 } else {
1429 io::ErrorKind::ResourceBusy
1430 }
1431 .into(),
1432 }),
1433 _ => self.server.publish(edit),
1434 }
1435 }
1436 fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> {
1437 self.server.confirm(source)
1438 }
1439 }
1440 let dir = tempfile::tempdir().unwrap();
1441 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
1442 let (sid, oid, _) = text(&source);
1443 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
1444 let id = cache
1445 .edit_text(&source, sid, oid, 0..0, "L ")
1446 .unwrap()
1447 .unwrap();
1448 let mut remote = Some(Busy {
1449 server: Server::new(&source),
1450 reads: 0,
1451 writes: 0,
1452 });
1453 let (tx, rx) = mpsc::channel();
1454 let worker = cache
1455 .start_sync(
1456 Duration::from_millis(1),
1457 move || Ok(remote.take().expect("Contention caused a reconnect")),
1458 move |result| {
1459 tx.send(result.as_ref().copied().map_err(|error| error.to_string()))
1460 .unwrap();
1461 },
1462 )
1463 .unwrap();
1464 for _ in 0..4 {
1465 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err());
1466 }
1467 assert!(
1468 matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap().unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
1469 );
1470 worker.stop().unwrap();
1471 assert_eq!(text(&cache.snapshot().unwrap()).2, "L abc");
1472 assert!(cache.pending().unwrap().is_empty());
1473 }
1474 #[test]
1475 fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() {
1476 struct BusyOnce(Server);
1477 impl Remote for BusyOnce {
1478 fn read(&mut self) -> io::Result<Vec<u8>> {
1479 self.0.read()
1480 }
1481 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
1482 let server = &mut self.0;
1483 if server.publications == 0 {
1484 server.publications += 1;
1485 return Err(CommitError {
1486 state: CommitState::NotCommitted,
1487 error: io::ErrorKind::ResourceBusy.into(),
1488 });
1489 }
1490 server.publish(edit)
1491 }
1492 fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> {
1493 self.0.confirm(source)
1494 }
1495 }
1496 let dir = tempfile::tempdir().unwrap();
1497 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
1498 let (sid, oid, _) = text(&source);
1499 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
1500 let first = cache
1501 .edit_text(&source, sid, oid, 0..0, "L ")
1502 .unwrap()
1503 .unwrap();
1504 let mut remote = Some(BusyOnce(Server::new(&source)));
1505 let observed = Arc::clone(&cache);
1506 let (tx, rx) = mpsc::channel();
1507 let mut failed = false;
1508 let worker = cache
1509 .start_sync(
1510 Duration::from_secs(3600),
1511 move || Ok(remote.take().expect("Contention must retain the session")),
1512 move |result| match result {
1513 Err(Error::Remote(error)) if error.state == CommitState::NotCommitted => {
1514 assert!(!failed);
1515 failed = true;
1516 let source = observed.snapshot().unwrap();
1517 let second = observed
1518 .edit_text(&source, sid, oid, 0..0, "Q ")
1519 .unwrap()
1520 .unwrap();
1521 tx.send((second, None)).unwrap();
1522 }
1523 Ok(Some((id, status))) => tx.send((*id, Some(*status))).unwrap(),
1524 Ok(None) => {}
1525 other => panic!("Unexpected worker result: {other:?}"),
1526 },
1527 )
1528 .unwrap();
1529 let (second, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap();
1530 assert_eq!(status, None);
1531 for expected in [first, second] {
1532 let (id, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap();
1533 assert_eq!(id, expected);
1534 assert!(matches!(status, Some(EditStatus::Published { .. })));
1535 }
1536 worker.stop().unwrap();
1537 assert!(cache.pending().unwrap().is_empty());
1538 assert_eq!(text(&cache.snapshot().unwrap()).2, "Q L abc");
1539 }
1540}
1541
1542#[test]
1543fn offline_insertions_survive_reopen_rebase_and_dependent_text_edits() {
1544 use onestore::Insertion;
1545 let dir = tempfile::tempdir().unwrap();
1546 let path = dir.path().join("insert.sqlite");
1547 let source = onestore::create_section("insert.one", "Original", "Author").unwrap();
1548 let (sid, original, _) = text(&source);
1549 let store = Store::parse(&source).unwrap();
1550 let index = RevisionIndex::parse(&store).unwrap();
1551 let doc = Document::parse(&index).unwrap();
1552 let (_, page) = doc.pages().unwrap()[0];
1553 let cache = Replica::create(&path, &source).unwrap();
1554 let outline =
1555 Insertion::outline(page, 72.0, 144.0, "Offline outline", "Offline author").unwrap();
1556 let first = cache.insert(&source, sid, &outline).unwrap().unwrap();
1557 let snapshot = cache.snapshot().unwrap();
1558 let paragraph = Insertion::paragraph(
1559 outline.object(),
1560 None,
1561 "Offline paragraph 🦀",
1562 "Offline author",
1563 )
1564 .unwrap();
1565 let second = cache.insert(&snapshot, sid, &paragraph).unwrap().unwrap();
1566 let third = cache
1567 .edit_text(
1568 &cache.snapshot().unwrap(),
1569 sid,
1570 paragraph.text_object(),
1571 0..0,
1572 "Edited ",
1573 )
1574 .unwrap()
1575 .unwrap();
1576 let local = cache.snapshot().unwrap();
1577 let pending = cache.pending().unwrap();
1578 drop(cache);
1579 let cache = Replica::open(&path).unwrap();
1580 assert_eq!(cache.snapshot().unwrap(), local);
1581 assert_eq!(cache.pending().unwrap(), pending);
1582 let remote = onestore::replace_text(&source, sid, original, 0..0, "Remote ").unwrap();
1583 let mut server = Server::new(&remote);
1584 for id in [first, second, third] {
1585 assert!(
1586 matches!(cache.sync_once(&mut server).unwrap(),Some((observed,EditStatus::Published{..})) if observed==id)
1587 );
1588 }
1589 assert_eq!(server.publications, 3);
1590 assert!(cache.pending().unwrap().is_empty());
1591 assert_eq!(cache.snapshot().unwrap(), server.durable);
1592 let store = Store::parse(&server.durable).unwrap();
1593 let index = RevisionIndex::parse(&store).unwrap();
1594 let doc = Document::parse(&index).unwrap();
1595 let s = &doc.spaces[&sid];
1596 let v = &s.revisions[&s.contexts[&ExGuid::default()]];
1597 for (id, wanted) in [
1598 (original, "Remote Original"),
1599 (outline.text_object(), "Offline outline"),
1600 (paragraph.text_object(), "Edited Offline paragraph 🦀"),
1601 ] {
1602 assert!(matches!(&v.nodes[&id].kind,Kind::RichText{text,..} if text==wanted));
1603 }
1604 assert_eq!(
1605 v.nodes[&outline.object()].children.last(),
1606 Some(&paragraph.object())
1607 );
1608}
1609
1610#[test]
1611fn uncertain_insertions_reconcile_the_original_revision_without_duplicate_objects() {
1612 use onestore::Insertion;
1613 for fault in [
1614 Fault::Before,
1615 Fault::UnknownBefore,
1616 Fault::UnknownAfter,
1617 Fault::PanicBefore,
1618 Fault::PanicAfter,
1619 Fault::Committed,
1620 ] {
1621 let dir = tempfile::tempdir().unwrap();
1622 let path = dir.path().join("uncertain-insert.sqlite");
1623 let source = onestore::create_section("insert.one", "Original", "Author").unwrap();
1624 let store = Store::parse(&source).unwrap();
1625 let index = RevisionIndex::parse(&store).unwrap();
1626 let doc = Document::parse(&index).unwrap();
1627 let (sid, page) = doc.pages().unwrap()[0];
1628 let cache = Replica::create(&path, &source).unwrap();
1629 let insertion =
1630 Insertion::outline(page, 144.0, 144.0, "Uncertain insertion", "Author").unwrap();
1631 let id = cache.insert(&source, sid, &insertion).unwrap().unwrap();
1632 let local = cache.snapshot().unwrap();
1633 let mut server = Server::new(&source);
1634 server.fault = fault;
1635 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1636 cache.sync_once(&mut server)
1637 }));
1638 drop(cache);
1639 let cache = Replica::open(&path).unwrap();
1640 assert_eq!(server.publications, 1);
1641 if matches!(fault, Fault::UnknownBefore | Fault::PanicBefore) {
1642 let status = cache.status(id).unwrap();
1643 assert!(matches!(
1644 status,
1645 Some(EditStatus::AwaitingConfirmation { .. })
1646 ));
1647 for _ in 0..5 {
1648 assert_eq!(
1649 cache.sync_once(&mut server).unwrap(),
1650 status.map(|state| (id, state))
1651 );
1652 }
1653 assert_eq!(server.publications, 1);
1654 assert_eq!(server.durable, source);
1655 assert_eq!(cache.snapshot().unwrap(), local);
1656 } else {
1657 if !matches!(fault, Fault::Committed) {
1658 assert!(matches!(
1659 cache.sync_once(&mut server).unwrap(),
1660 Some((_, EditStatus::Published { .. }))
1661 ));
1662 }
1663 assert_eq!(
1664 server.publications,
1665 if matches!(fault, Fault::Before) { 2 } else { 1 }
1666 );
1667 assert_eq!(
1668 server.confirmations,
1669 usize::from(matches!(fault, Fault::UnknownAfter | Fault::PanicAfter))
1670 );
1671 let store = Store::parse(&server.durable).unwrap();
1672 let index = RevisionIndex::parse(&store).unwrap();
1673 let doc = Document::parse(&index).unwrap();
1674 let s = &doc.spaces[&sid];
1675 let v = &s.revisions[&s.contexts[&ExGuid::default()]];
1676 assert_eq!(v.nodes.values().filter(|node|matches!(&node.kind,Kind::RichText{text,..} if text=="Uncertain insertion")).count(),1);
1677 assert!(v.nodes.contains_key(&insertion.object()));
1678 assert!(cache.pending().unwrap().is_empty());
1679 }
1680 }
1681}
1682
1683#[test]
1684fn offline_formatting_rebases_text_and_merges_independent_attributes() {
1685 use onestore::TextAttribute as A;
1686 let directory = tempfile::tempdir().unwrap();
1687 let path = directory.path().join("format.sqlite");
1688 let source = onestore::create_section("format.one", "ab🦀cd", "Author").unwrap();
1689 let (sid, id, _) = text(&source);
1690 let cache = Replica::create(&path, &source).unwrap();
1691 let local = cache
1692 .format(&source, sid, id, 2..4, &[A::Bold(true)])
1693 .unwrap()
1694 .unwrap();
1695 let pending = cache.pending().unwrap();
1696 drop(cache);
1697 let cache = Replica::open(&path).unwrap();
1698 assert_eq!(cache.pending().unwrap(), pending);
1699 let moved = PreparedEdit::text(&source, sid, id, 0..0, "Prefix ").unwrap();
1700 let styled =
1701 PreparedEdit::format(moved.as_bytes(), sid, id, 9..11, &[A::Italic(true)]).unwrap();
1702 let mut server = Server::new(styled.as_bytes());
1703 assert!(
1704 matches!(cache.sync_once(&mut server).unwrap(),Some((observed,EditStatus::Published{..}))if observed==local)
1705 );
1706 let store = Store::parse(&server.durable).unwrap();
1707 let index = RevisionIndex::parse(&store).unwrap();
1708 let doc = Document::parse(&index).unwrap();
1709 let space = &doc.spaces[&sid];
1710 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
1711 let runs = view.text_runs(id).unwrap();
1712 let selected = runs.iter().find(|r| r.text == "🦀").unwrap();
1713 assert_eq!(selected.format.bold, Some(true));
1714 assert_eq!(selected.format.italic, Some(true));
1715 assert_eq!(
1716 runs.iter().map(|r| r.text).collect::<String>(),
1717 "Prefix ab🦀cd"
1718 );
1719}
1720
1721#[test]
1722fn competing_font_changes_remain_preserved_conflicts() {
1723 use onestore::TextAttribute as A;
1724 let directory = tempfile::tempdir().unwrap();
1725 let path = directory.path().join("conflict.sqlite");
1726 let source = onestore::create_section("format.one", "abcdef", "Author").unwrap();
1727 let (sid, id, _) = text(&source);
1728 let cache = Replica::create(&path, &source).unwrap();
1729 let local_id = cache
1730 .format(&source, sid, id, 1..5, &[A::FontSize(14.0)])
1731 .unwrap()
1732 .unwrap();
1733 let local = cache.snapshot().unwrap();
1734 let pending = cache.pending().unwrap();
1735 let remote = PreparedEdit::format(&source, sid, id, 2..4, &[A::FontSize(18.0)]).unwrap();
1736 let mut server = Server::new(remote.as_bytes());
1737 for _ in 0..3 {
1738 assert_eq!(
1739 cache.sync_once(&mut server).unwrap(),
1740 Some((
1741 local_id,
1742 EditStatus::Conflict(ConflictKind::FormattingChanged)
1743 ))
1744 );
1745 }
1746 assert_eq!(server.publications, 0);
1747 assert_eq!(server.confirmations, 0);
1748 assert_eq!(cache.snapshot().unwrap(), local);
1749 assert_eq!(cache.pending().unwrap(), pending);
1750 drop(cache);
1751 let cache = Replica::open(&path).unwrap();
1752 assert_eq!(
1753 cache.status(local_id).unwrap(),
1754 Some(EditStatus::Conflict(ConflictKind::FormattingChanged))
1755 );
1756 assert_eq!(cache.snapshot().unwrap(), local);
1757}
1758
1759#[test]
1760fn independently_satisfied_formatting_requires_confirmation_before_a_receipt() {
1761 use onestore::TextAttribute as A;
1762 let directory = tempfile::tempdir().unwrap();
1763 let path = directory.path().join("satisfied.sqlite");
1764 let source = onestore::create_section("format.one", "abcdef", "Author").unwrap();
1765 let (sid, id, _) = text(&source);
1766 let cache = Replica::create(&path, &source).unwrap();
1767 let local_id = cache
1768 .format(&source, sid, id, 1..5, &[A::Bold(true)])
1769 .unwrap()
1770 .unwrap();
1771 let remote = PreparedEdit::format(&source, sid, id, 1..5, &[A::Bold(true)]).unwrap();
1772 let mut server = Server::new(remote.as_bytes());
1773 server.durable = source.clone();
1774 server.fault = Fault::Confirm;
1775 assert!(
1776 matches!(cache.sync_once(&mut server),Err(Error::Remote(e))if e.state==CommitState::Unknown)
1777 );
1778 assert_eq!(cache.status(local_id).unwrap(), Some(EditStatus::Pending));
1779 assert_eq!(server.publications, 0);
1780 assert_eq!(server.durable, source);
1781 drop(cache);
1782 let cache = Replica::open(&path).unwrap();
1783 assert!(
1784 matches!(cache.sync_once(&mut server).unwrap(),Some((id,EditStatus::Published{..}))if id==local_id)
1785 );
1786 assert_eq!(server.publications, 0);
1787 assert_eq!(server.confirmations, 2);
1788 assert_ne!(server.durable, source);
1789 assert!(cache.pending().unwrap().is_empty());
1790}
1791
1792#[test]
1793fn retired_format_attempts_require_the_complete_durable_effect_without_replay() {
1794 use onestore::TextAttribute as A;
1795 for (complete, fault) in [
1796 (true, Fault::None),
1797 (true, Fault::Confirm),
1798 (true, Fault::ConfirmCommitted),
1799 (false, Fault::None),
1800 ] {
1801 let directory = tempfile::tempdir().unwrap();
1802 let path = directory.path().join("retired-format.sqlite");
1803 let source = onestore::create_section("format.one", "abcdef", "Author").unwrap();
1804 let (sid, object, _) = text(&source);
1805 let cache = Replica::create(&path, &source).unwrap();
1806 let id = cache
1807 .format(
1808 &source,
1809 sid,
1810 object,
1811 1..5,
1812 &[A::Bold(true), A::FontSize(18.0)],
1813 )
1814 .unwrap()
1815 .unwrap();
1816 let local = cache.snapshot().unwrap();
1817 let mut server = Server::new(&source);
1818 server.fault = Fault::UnknownAfter;
1819 assert!(cache.sync_once(&mut server).is_err());
1820 let attempted = cache.status(id).unwrap();
1821 let Some(EditStatus::AwaitingConfirmation { revision: retired }) = attempted else {
1822 panic!()
1823 };
1824 drop(cache);
1825 let prefix = PreparedEdit::text(&source, sid, object, 0..0, "prefix ").unwrap();
1826 let mut attributes = vec![A::Bold(true), A::Italic(true)];
1827 if complete {
1828 attributes.push(A::FontSize(18.0));
1829 }
1830 server.visible = PreparedEdit::format(prefix.as_bytes(), sid, object, 8..12, &attributes)
1831 .unwrap()
1832 .as_bytes()
1833 .to_vec();
1834 let store = Store::parse(&server.visible).unwrap();
1835 let index = RevisionIndex::parse(&store).unwrap();
1836 let current = index.spaces[&sid].labels[&(ExGuid::default(), 1)];
1837 assert!(!index.spaces[&sid].revisions.contains_key(&retired));
1838 let cache = Replica::open(&path).unwrap();
1839 server.fault = fault;
1840 let result = cache.sync_once(&mut server);
1841 if !complete {
1842 assert_eq!(result.unwrap(), attempted.map(|s| (id, s)));
1843 assert_eq!(server.confirmations, 0);
1844 assert_eq!(cache.snapshot().unwrap(), local);
1845 assert_eq!(server.durable, source);
1846 } else {
1847 if matches!(fault, Fault::Confirm) {
1848 assert!(matches!(result, Err(Error::Remote(e)) if e.state == CommitState::Unknown));
1849 assert_eq!(cache.status(id).unwrap(), attempted);
1850 assert_eq!(cache.snapshot().unwrap(), local);
1851 assert_eq!(server.durable, source);
1852 let complete = server.visible.clone();
1853 server.visible =
1854 PreparedEdit::format(&complete, sid, object, 8..12, &[A::Bold(false)])
1855 .unwrap()
1856 .as_bytes()
1857 .to_vec();
1858 assert_eq!(
1859 cache.sync_once(&mut server).unwrap(),
1860 attempted.map(|s| (id, s))
1861 );
1862 assert_eq!(server.confirmations, 1);
1863 assert_eq!(cache.snapshot().unwrap(), local);
1864 server.visible = complete;
1865 cache.sync_once(&mut server).unwrap();
1866 } else if matches!(fault, Fault::ConfirmCommitted) {
1867 assert!(
1868 matches!(result, Err(Error::Remote(e)) if e.state == CommitState::Committed)
1869 );
1870 } else {
1871 assert_eq!(
1872 result.unwrap(),
1873 Some((id, EditStatus::Published { revision: current }))
1874 );
1875 }
1876 assert_ne!(current, retired);
1877 assert_eq!(
1878 cache.status(id).unwrap(),
1879 Some(EditStatus::Published { revision: current })
1880 );
1881 assert!(cache.pending().unwrap().is_empty());
1882 assert_eq!(text(&server.durable).2, "prefix abcdef");
1883 assert_ne!(server.durable, source);
1884 drop(cache);
1885 let cache = Replica::open(&path).unwrap();
1886 assert_eq!(
1887 cache.status(id).unwrap(),
1888 Some(EditStatus::Published { revision: current })
1889 );
1890 }
1891 assert_eq!(server.publications, 1);
1892 }
1893}
1894
1895#[test]
1896fn uncertain_formatting_keeps_the_original_attempt_and_never_replays() {
1897 use onestore::TextAttribute as A;
1898 for fault in [Fault::UnknownBefore, Fault::UnknownAfter] {
1899 let directory = tempfile::tempdir().unwrap();
1900 let path = directory.path().join("unknown-format.sqlite");
1901 let source = onestore::create_section("format.one", "abcdef", "Author").unwrap();
1902 let (sid, id, _) = text(&source);
1903 let cache = Replica::create(&path, &source).unwrap();
1904 let local_id = cache
1905 .format(&source, sid, id, 1..5, &[A::Bold(true)])
1906 .unwrap()
1907 .unwrap();
1908 let mut server = Server::new(&source);
1909 server.fault = fault;
1910 assert!(
1911 matches!(cache.sync_once(&mut server),Err(Error::Remote(e))if e.state==CommitState::Unknown)
1912 );
1913 let status = cache.status(local_id).unwrap();
1914 drop(cache);
1915 let cache = Replica::open(&path).unwrap();
1916 if matches!(fault, Fault::UnknownBefore) {
1917 for _ in 0..4 {
1918 assert_eq!(
1919 cache.sync_once(&mut server).unwrap(),
1920 status.map(|s| (local_id, s))
1921 );
1922 }
1923 assert_eq!(server.confirmations, 0);
1924 } else {
1925 assert!(matches!(
1926 cache.sync_once(&mut server).unwrap(),
1927 Some((_, EditStatus::Published { .. }))
1928 ));
1929 assert_eq!(server.confirmations, 1);
1930 }
1931 assert_eq!(server.publications, 1);
1932 }
1933}
1934
1935#[test]
1936fn reviewed_format_conflicts_preserve_dependent_edits_and_recheck_later_remote_changes() {
1937 use onestore::TextAttribute as A;
1938 for changed_again in [false, true] {
1939 let directory = tempfile::tempdir().unwrap();
1940 let path = directory.path().join("review-format.sqlite");
1941 let source = onestore::create_section("format.one", "abcdef", "Author").unwrap();
1942 let (sid, id, _) = text(&source);
1943 let cache = Replica::create(&path, &source).unwrap();
1944 let first = cache
1945 .format(&source, sid, id, 1..5, &[A::FontSize(14.0)])
1946 .unwrap()
1947 .unwrap();
1948 let second = cache
1949 .edit_text(&cache.snapshot().unwrap(), sid, id, 2..2, "X")
1950 .unwrap()
1951 .unwrap();
1952 let local = cache.snapshot().unwrap();
1953 let pending = cache.pending().unwrap();
1954 let remote = PreparedEdit::format(&source, sid, id, 1..5, &[A::FontSize(18.0)]).unwrap();
1955 let mut server = Server::new(remote.as_bytes());
1956 assert_eq!(
1957 cache.sync_once(&mut server).unwrap(),
1958 Some((first, EditStatus::Conflict(ConflictKind::FormattingChanged)))
1959 );
1960 assert!(
1961 matches!(cache.rebase_conflict(first,&source,remote.as_bytes(),1..5),Err(Error::Io(e))if e.kind()==io::ErrorKind::ResourceBusy)
1962 );
1963 cache
1964 .rebase_conflict(first, &local, remote.as_bytes(), 1..5)
1965 .unwrap();
1966 assert_eq!(cache.snapshot().unwrap(), local);
1967 assert_eq!(cache.pending().unwrap()[1], pending[1]);
1968 drop(cache);
1969 let cache = Replica::open(&path).unwrap();
1970 if changed_again {
1971 let newer =
1972 PreparedEdit::format(&server.visible, sid, id, 1..5, &[A::FontSize(20.0)]).unwrap();
1973 server = Server::new(newer.as_bytes());
1974 assert_eq!(
1975 cache.sync_once(&mut server).unwrap(),
1976 Some((first, EditStatus::Conflict(ConflictKind::FormattingChanged)))
1977 );
1978 assert_eq!(server.publications, 0);
1979 assert_eq!(cache.snapshot().unwrap(), local);
1980 } else {
1981 for expected in [first, second] {
1982 assert!(
1983 matches!(cache.sync_once(&mut server).unwrap(),Some((id,EditStatus::Published{..}))if id==expected)
1984 );
1985 }
1986 assert_eq!(text(&server.durable).2, "abXcdef");
1987 assert!(cache.pending().unwrap().is_empty());
1988 }
1989 }
1990}
1991
1992#[test]
1993fn superscript_reconciliation_checks_the_implicit_subscript_change() {
1994 use onestore::TextAttribute as A;
1995 let directory = tempfile::tempdir().unwrap();
1996 let source = onestore::create_section("script.one", "abc", "Author").unwrap();
1997 let (sid, id, _) = text(&source);
1998 let cache = Replica::create(directory.path().join("script.sqlite"), &source).unwrap();
1999 let local = cache
2000 .format(&source, sid, id, 0..3, &[A::Superscript(true)])
2001 .unwrap()
2002 .unwrap();
2003 let remote = PreparedEdit::format(&source, sid, id, 0..3, &[A::Subscript(true)]).unwrap();
2004 let mut server = Server::new(remote.as_bytes());
2005 assert_eq!(
2006 cache.sync_once(&mut server).unwrap(),
2007 Some((local, EditStatus::Conflict(ConflictKind::FormattingChanged)))
2008 );
2009 assert_eq!(server.publications, 0);
2010}
2011
2012#[test]
2013fn seeded_formatting_reconciliation_matches_a_character_model() {
2014 use onestore::TextAttribute as A;
2015 #[derive(Clone, Debug, PartialEq)]
2016 struct Style {
2017 size: f32,
2018 color: u32,
2019 bold: bool,
2020 italic: bool,
2021 }
2022 let mut conflicts = 0;
2023 let mut published = 0;
2024 let mut satisfied_parts = 0;
2025 for seed in 1_u64..=128 {
2026 let mut random = seed;
2027 let mut next = || {
2028 random = random
2029 .wrapping_mul(6364136223846793005)
2030 .wrapping_add(1442695040888963407);
2031 random >> 32
2032 };
2033 let directory = tempfile::tempdir().unwrap();
2034 let path = directory.path().join("model.sqlite");
2035 let mut source =
2036 onestore::create_section("model.one", "abcdefghijklmnopqrstuvwxyz012345", "Author")
2037 .unwrap();
2038 let (sid, object, original_text) = text(&source);
2039 let mut baseline = vec![
2040 Style {
2041 size: 11.0,
2042 color: 0xff000000,
2043 bold: false,
2044 italic: false
2045 };
2046 32
2047 ];
2048 for _ in 0..6 {
2049 let start = next() as usize % 32;
2050 let end = start + 1 + next() as usize % (32 - start);
2051 let size = [12.0, 14.0, 18.0][next() as usize % 3];
2052 let color: u32 = [0xabcdef, 0x987654, 0xff000000][next() as usize % 3];
2053 let bold = next() % 2 == 0;
2054 source = PreparedEdit::format(
2055 &source,
2056 sid,
2057 object,
2058 start as u32..end as u32,
2059 &[
2060 A::FontSize(size),
2061 A::Color((color != 0xff000000).then(|| {
2062 let b = color.to_le_bytes();
2063 [b[0], b[1], b[2]]
2064 })),
2065 A::Bold(bold),
2066 ],
2067 )
2068 .unwrap()
2069 .as_bytes()
2070 .to_vec();
2071 for style in &mut baseline[start..end] {
2072 style.size = size;
2073 style.color = color;
2074 style.bold = bold;
2075 }
2076 }
2077 let start = next() as usize % 24;
2078 let end = start + 2 + next() as usize % (31 - start);
2079 let attribute = match seed % 3 {
2080 0 => A::FontSize(21.0),
2081 1 => A::Color(Some([0x12, 0x34, 0x56])),
2082 _ => A::Bold(!baseline[start].bold),
2083 };
2084 let cache = Replica::create(&path, &source).unwrap();
2085 let id = cache
2086 .format(
2087 &source,
2088 sid,
2089 object,
2090 start as u32..end as u32,
2091 std::slice::from_ref(&attribute),
2092 )
2093 .unwrap()
2094 .unwrap();
2095 let local = cache.snapshot().unwrap();
2096 let mut remote = source.clone();
2097 let mut expected = baseline.clone();
2098 for step in 0..8 {
2099 let left = next() as usize % 32;
2100 let right = left + 1 + next() as usize % (32 - left);
2101 let update = if step % 3 == 0 {
2102 attribute.clone()
2103 } else {
2104 match next() % 4 {
2105 0 => A::FontSize([11.0, 14.0, 18.0, 21.0][next() as usize % 4]),
2106 1 => A::Color(Some([0x99, 0x88, 0x77])),
2107 2 => A::Bold(next() % 2 == 0),
2108 _ => A::Italic(true),
2109 }
2110 };
2111 remote = PreparedEdit::format(
2112 &remote,
2113 sid,
2114 object,
2115 left as u32..right as u32,
2116 std::slice::from_ref(&update),
2117 )
2118 .unwrap()
2119 .as_bytes()
2120 .to_vec();
2121 for style in &mut expected[left..right] {
2122 match update {
2123 A::FontSize(value) => style.size = value,
2124 A::Color(Some([r, g, b])) => style.color = u32::from_le_bytes([r, g, b, 0]),
2125 A::Bold(value) => style.bold = value,
2126 A::Italic(value) => style.italic = value,
2127 _ => unreachable!(),
2128 }
2129 }
2130 }
2131 let conflict = (start..end).any(|i| match attribute {
2132 A::FontSize(value) => expected[i].size != baseline[i].size && expected[i].size != value,
2133 A::Color(_) => expected[i].color != baseline[i].color && expected[i].color != 0x563412,
2134 A::Bold(value) => expected[i].bold != baseline[i].bold && expected[i].bold != value,
2135 _ => unreachable!(),
2136 });
2137 drop(cache);
2138 let cache = Replica::open(&path).unwrap();
2139 let mut server = Server::new(&remote);
2140 let result = cache.sync_once(&mut server).unwrap().unwrap();
2141 assert_eq!(result.0, id, "seed {seed}");
2142 if conflict {
2143 conflicts += 1;
2144 assert_eq!(
2145 result.1,
2146 EditStatus::Conflict(ConflictKind::FormattingChanged),
2147 "seed {seed}"
2148 );
2149 assert_eq!(server.publications, 0, "seed {seed}");
2150 assert_eq!(cache.snapshot().unwrap(), local, "seed {seed}");
2151 continue;
2152 }
2153 published += 1;
2154 assert!(
2155 matches!(result.1, EditStatus::Published { .. }),
2156 "seed {seed}: {result:?}"
2157 );
2158 for style in &mut expected[start..end] {
2159 match attribute {
2160 A::FontSize(value) => {
2161 satisfied_parts += usize::from(style.size == value);
2162 style.size = value;
2163 }
2164 A::Color(_) => {
2165 satisfied_parts += usize::from(style.color == 0x563412);
2166 style.color = 0x563412;
2167 }
2168 A::Bold(value) => {
2169 satisfied_parts += usize::from(style.bold == value);
2170 style.bold = value;
2171 }
2172 _ => unreachable!(),
2173 }
2174 }
2175 assert_eq!(text(&server.durable).2, original_text, "seed {seed}");
2176 let store = Store::parse(&server.durable).unwrap();
2177 let index = RevisionIndex::parse(&store).unwrap();
2178 let doc = Document::parse(&index).unwrap();
2179 let space = &doc.spaces[&sid];
2180 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
2181 let actual: Vec<_> = revision
2182 .text_runs(object)
2183 .unwrap()
2184 .iter()
2185 .flat_map(|run| {
2186 std::iter::repeat_n(
2187 Style {
2188 size: run.format.font_size.unwrap(),
2189 color: run.format.color.unwrap_or(0xff000000),
2190 bold: run.format.bold.unwrap_or(false),
2191 italic: run.format.italic.unwrap_or(false),
2192 },
2193 run.text.chars().count(),
2194 )
2195 })
2196 .collect();
2197 assert_eq!(actual, expected, "seed {seed}");
2198 drop(cache);
2199 let cache = Replica::open(&path).unwrap();
2200 assert_eq!(cache.status(id).unwrap(), Some(result.1));
2201 let snapshot = cache.snapshot().unwrap();
2202 assert_eq!(snapshot.len(), server.durable.len(), "seed {seed}");
2203 assert!(
2204 snapshot
2205 .iter()
2206 .zip(&server.durable)
2207 .enumerate()
2208 .all(|(offset, (a, b))| a == b || (212..252).contains(&offset)),
2209 "seed {seed}: only confirmation version metadata may change"
2210 );
2211 assert_eq!(cache.sync_once(&mut server).unwrap(), None);
2212 assert!(
2213 cache.snapshot().unwrap() == server.durable,
2214 "seed {seed}: refreshed snapshot"
2215 );
2216 }
2217 assert!(
2218 conflicts >= 16 && published >= 32 && satisfied_parts >= 128,
2219 "conflicts={conflicts}, published={published}, already desired characters={satisfied_parts}"
2220 );
2221 println!(
2222 "128 seeds: {conflicts} conflicts, {published} publications, {satisfied_parts} already desired characters"
2223 );
2224}
2225
2226#[test]
2227fn inserted_empty_text_retains_formatting_and_dependent_text_across_sync() {
2228 use onestore::{Insertion, TextAttribute as A};
2229 let directory = tempfile::tempdir().unwrap();
2230 let path = directory.path().join("empty.sqlite");
2231 let source = onestore::create_section("empty.one", "Original", "Author").unwrap();
2232 let store = Store::parse(&source).unwrap();
2233 let index = RevisionIndex::parse(&store).unwrap();
2234 let doc = Document::parse(&index).unwrap();
2235 let (sid, page) = doc.pages().unwrap()[0];
2236 let insertion = Insertion::outline(page, 144.0, 144.0, "", "Author").unwrap();
2237 let cache = Replica::create(&path, &source).unwrap();
2238 let first = cache.insert(&source, sid, &insertion).unwrap().unwrap();
2239 let second = cache
2240 .format(
2241 &cache.snapshot().unwrap(),
2242 sid,
2243 insertion.text_object(),
2244 0..0,
2245 &[A::Bold(true), A::FontSize(18.0)],
2246 )
2247 .unwrap()
2248 .unwrap();
2249 let third = cache
2250 .edit_text(
2251 &cache.snapshot().unwrap(),
2252 sid,
2253 insertion.text_object(),
2254 0..0,
2255 "Typed 🦀",
2256 )
2257 .unwrap()
2258 .unwrap();
2259 let pending = cache.pending().unwrap();
2260 drop(cache);
2261 let cache = Replica::open(&path).unwrap();
2262 assert_eq!(cache.pending().unwrap(), pending);
2263 let mut server = Server::new(&source);
2264 for id in [first, second, third] {
2265 let result = cache.sync_once(&mut server).unwrap();
2266 assert!(
2267 matches!(result,Some((actual,EditStatus::Published{..}))if actual==id),
2268 "{result:?}"
2269 );
2270 }
2271 let store = Store::parse(&server.durable).unwrap();
2272 let index = RevisionIndex::parse(&store).unwrap();
2273 let doc = Document::parse(&index).unwrap();
2274 let space = &doc.spaces[&sid];
2275 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
2276 let runs = revision.text_runs(insertion.text_object()).unwrap();
2277 assert_eq!(runs.iter().map(|r| r.text).collect::<String>(), "Typed 🦀");
2278 assert!(
2279 runs.iter()
2280 .all(|r| r.format.bold == Some(true) && r.format.font_size == Some(18.0))
2281 );
2282 assert_eq!(cache.snapshot().unwrap(), server.durable);
2283}
2284
2285#[test]
2286fn every_visual_attribute_rebases_with_an_independent_remote_attribute() {
2287 use onestore::TextAttribute as A;
2288 use serde_json::json;
2289 let mut cases = Vec::new();
2290 for value in [false, true] {
2291 cases.extend([
2292 (A::Bold(!value), A::Bold(value), "bold", json!(value)),
2293 (A::Italic(!value), A::Italic(value), "italic", json!(value)),
2294 (
2295 A::Underline(!value),
2296 A::Underline(value),
2297 "underline",
2298 json!(value),
2299 ),
2300 (A::Strike(!value), A::Strike(value), "strike", json!(value)),
2301 (
2302 A::Superscript(!value),
2303 A::Superscript(value),
2304 "superscript",
2305 json!(value),
2306 ),
2307 (
2308 A::Subscript(!value),
2309 A::Subscript(value),
2310 "subscript",
2311 json!(value),
2312 ),
2313 ]);
2314 }
2315 cases.extend([
2316 (
2317 A::Font("Georgia".into()),
2318 A::Font("Arial".into()),
2319 "font",
2320 json!("Arial"),
2321 ),
2322 (
2323 A::FontSize(11.0),
2324 A::FontSize(18.0),
2325 "font_size",
2326 json!(18.0),
2327 ),
2328 (
2329 A::Color(None),
2330 A::Color(Some([1, 2, 3])),
2331 "color",
2332 json!(0x030201),
2333 ),
2334 (
2335 A::Color(Some([1, 2, 3])),
2336 A::Color(None),
2337 "color",
2338 json!(0xff000000_u32),
2339 ),
2340 (
2341 A::Highlight(None),
2342 A::Highlight(Some([4, 5, 6])),
2343 "highlight",
2344 json!(0x060504),
2345 ),
2346 (
2347 A::Highlight(Some([4, 5, 6])),
2348 A::Highlight(None),
2349 "highlight",
2350 json!(0xff000000_u32),
2351 ),
2352 ]);
2353 for (baseline, desired, field, value) in cases {
2354 let directory = tempfile::tempdir().unwrap();
2355 let path = directory.path().join("attribute.sqlite");
2356 let source = onestore::create_section("attribute.one", "abc", "Author").unwrap();
2357 let (sid, object, _) = text(&source);
2358 let source = PreparedEdit::format(&source, sid, object, 0..3, &[baseline])
2359 .unwrap()
2360 .as_bytes()
2361 .to_vec();
2362 let cache = Replica::create(&path, &source).unwrap();
2363 let id = cache
2364 .format(&source, sid, object, 0..3, &[desired])
2365 .unwrap()
2366 .unwrap();
2367 let moved = PreparedEdit::text(&source, sid, object, 0..0, "Z").unwrap();
2368 let (other, other_field, other_value) = if field == "font_size" {
2369 (A::Italic(true), "italic", json!(true))
2370 } else {
2371 (A::FontSize(22.0), "font_size", json!(22.0))
2372 };
2373 let remote = PreparedEdit::format(moved.as_bytes(), sid, object, 1..4, &[other]).unwrap();
2374 let mut server = Server::new(remote.as_bytes());
2375 drop(cache);
2376 let cache = Replica::open(&path).unwrap();
2377 let result = cache.sync_once(&mut server).unwrap();
2378 assert!(
2379 matches!(result,Some((observed,EditStatus::Published{..}))if observed==id),
2380 "{field}={value}: {result:?}"
2381 );
2382 assert_eq!(text(&server.durable).2, "Zabc");
2383 let store = Store::parse(&server.durable).unwrap();
2384 let index = RevisionIndex::parse(&store).unwrap();
2385 let doc = Document::parse(&index).unwrap();
2386 let space = &doc.spaces[&sid];
2387 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
2388 let mut position = 0;
2389 for run in revision.text_runs(object).unwrap() {
2390 let format = serde_json::to_value(run.format).unwrap();
2391 for _ in run.text.chars() {
2392 if position > 0 {
2393 assert_eq!(format[field], value, "{field}, character {position}");
2394 assert_eq!(
2395 format[other_field], other_value,
2396 "{field}, character {position}"
2397 );
2398 }
2399 position += 1;
2400 }
2401 }
2402 assert_eq!(position, 4);
2403 }
2404}
2405
2406#[test]
2407fn reviewed_insertion_placements_preserve_identity_and_dependent_operations() {
2408 use onestore::{Insertion, TextAttribute as A};
2409 use onestore_offline::Operation;
2410 for outline_case in [false, true] {
2411 let directory = tempfile::tempdir().unwrap();
2412 let path = directory.path().join("placement.sqlite");
2413 let base = onestore::create_section("placement.one", "Original", "Author").unwrap();
2414 let (sid, _, _) = text(&base);
2415 let store = Store::parse(&base).unwrap();
2416 let index = RevisionIndex::parse(&store).unwrap();
2417 let doc = Document::parse(&index).unwrap();
2418 let (_, page) = doc.pages().unwrap()[0];
2419 let space = &doc.spaces[&sid];
2420 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
2421 let parent = *view.nodes[&page]
2422 .children
2423 .iter()
2424 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
2425 .unwrap();
2426 let anchor = Insertion::paragraph(parent, None, "Temporary anchor", "Author").unwrap();
2427 let source = PreparedEdit::insert(&base, sid, &anchor)
2428 .unwrap()
2429 .as_bytes()
2430 .to_vec();
2431 let insertion = if outline_case {
2432 Insertion::outline(page, 144.0, 144.0, "Offline", "Author").unwrap()
2433 } else {
2434 Insertion::paragraph(parent, Some(anchor.object()), "Offline", "Author").unwrap()
2435 };
2436 let cache = Replica::create(&path, &source).unwrap();
2437 let first = cache.insert(&source, sid, &insertion).unwrap().unwrap();
2438 let second = cache
2439 .format(
2440 &cache.snapshot().unwrap(),
2441 sid,
2442 insertion.text_object(),
2443 0..7,
2444 &[A::Bold(true)],
2445 )
2446 .unwrap()
2447 .unwrap();
2448 let third = cache
2449 .edit_text(
2450 &cache.snapshot().unwrap(),
2451 sid,
2452 insertion.text_object(),
2453 7..7,
2454 " 🦀",
2455 )
2456 .unwrap()
2457 .unwrap();
2458 let local = cache.snapshot().unwrap();
2459 let pending = cache.pending().unwrap();
2460 let mut server = Server::new(&base);
2461 if outline_case {
2462 drop(cache);
2463 let db = rusqlite::Connection::open(&path).unwrap();
2464 db.execute(
2465 "INSERT INTO conflicts VALUES (?1,2)",
2466 [i64::try_from(first).unwrap()],
2467 )
2468 .unwrap();
2469 db.execute("UPDATE replica SET base=?1", [&base]).unwrap();
2470 drop(db);
2471 } else {
2472 assert_eq!(
2473 cache.sync_once(&mut server).unwrap(),
2474 Some((first, EditStatus::Conflict(ConflictKind::UnsupportedEdit)))
2475 );
2476 drop(cache);
2477 }
2478 let cache = Replica::open(&path).unwrap();
2479 if outline_case {
2480 assert!(
2481 cache
2482 .rebase_paragraph_conflict(first, &local, &base, parent, None)
2483 .is_err()
2484 );
2485 assert!(
2486 cache
2487 .rebase_outline_conflict(first, &local, &base, page, f32::NAN, 288.0)
2488 .is_err()
2489 );
2490 assert!(
2491 matches!(cache.rebase_outline_conflict(first,&source,&base,page,288.0,360.0),Err(Error::Io(e))if e.kind()==io::ErrorKind::ResourceBusy)
2492 );
2493 cache
2494 .rebase_outline_conflict(first, &local, &base, page, 288.0, 360.0)
2495 .unwrap();
2496 } else {
2497 assert!(
2498 cache
2499 .rebase_outline_conflict(first, &local, &base, page, 288.0, 360.0)
2500 .is_err()
2501 );
2502 assert!(
2503 cache
2504 .rebase_paragraph_conflict(first, &local, &base, parent, Some(anchor.object()))
2505 .is_err()
2506 );
2507 assert!(
2508 matches!(cache.rebase_paragraph_conflict(first,&source,&base,parent,None),Err(Error::Io(e))if e.kind()==io::ErrorKind::ResourceBusy)
2509 );
2510 cache
2511 .rebase_paragraph_conflict(first, &local, &base, parent, None)
2512 .unwrap();
2513 }
2514 assert_eq!(cache.snapshot().unwrap(), local);
2515 assert_eq!(cache.pending().unwrap()[1..], pending[1..]);
2516 let Operation::Insert(rebased) = cache.pending().unwrap().remove(0).operation else {
2517 panic!()
2518 };
2519 assert_eq!(rebased.object(), insertion.object());
2520 assert_eq!(rebased.text_object(), insertion.text_object());
2521 assert_eq!(cache.status(first).unwrap(), Some(EditStatus::Pending));
2522 drop(cache);
2523 let cache = Replica::open(&path).unwrap();
2524 for id in [first, second, third] {
2525 let result = cache.sync_once(&mut server).unwrap();
2526 assert!(
2527 matches!(result,Some((actual,EditStatus::Published{..}))if actual==id),
2528 "{result:?}"
2529 );
2530 }
2531 let store = Store::parse(&server.durable).unwrap();
2532 let index = RevisionIndex::parse(&store).unwrap();
2533 let doc = Document::parse(&index).unwrap();
2534 let space = &doc.spaces[&sid];
2535 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
2536 let runs = view.text_runs(insertion.text_object()).unwrap();
2537 assert_eq!(
2538 runs.iter().map(|r| r.text).collect::<String>(),
2539 "Offline 🦀"
2540 );
2541 assert!(runs.iter().all(|r| r.format.bold == Some(true)));
2542 if outline_case {
2543 assert_eq!(
2544 (
2545 view.nodes[&insertion.object()].layout.x,
2546 view.nodes[&insertion.object()].layout.y
2547 ),
2548 (Some(288.0), Some(360.0))
2549 );
2550 } else {
2551 assert_eq!(
2552 view.nodes[&parent].children.last(),
2553 Some(&insertion.object())
2554 );
2555 }
2556 assert!(!view.nodes.contains_key(&anchor.object()));
2557 assert_eq!(server.publications, 3);
2558 assert!(cache.pending().unwrap().is_empty());
2559 assert_eq!(cache.snapshot().unwrap(), server.durable);
2560 }
2561}
2562
2563#[test]
2564fn placement_reviews_cannot_replace_pending_or_uncertain_attempts() {
2565 use onestore::Insertion;
2566 for outline_case in [false, true] {
2567 let directory = tempfile::tempdir().unwrap();
2568 let path = directory.path().join("attempt-placement.sqlite");
2569 let source = onestore::create_section("placement.one", "Original", "Author").unwrap();
2570 let store = Store::parse(&source).unwrap();
2571 let index = RevisionIndex::parse(&store).unwrap();
2572 let doc = Document::parse(&index).unwrap();
2573 let (sid, page) = doc.pages().unwrap()[0];
2574 let space = &doc.spaces[&sid];
2575 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
2576 let parent = *view.nodes[&page]
2577 .children
2578 .iter()
2579 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
2580 .unwrap();
2581 let insertion = if outline_case {
2582 Insertion::outline(page, 144.0, 144.0, "Offline", "Author").unwrap()
2583 } else {
2584 Insertion::paragraph(parent, None, "Offline", "Author").unwrap()
2585 };
2586 let cache = Replica::create(&path, &source).unwrap();
2587 let id = cache.insert(&source, sid, &insertion).unwrap().unwrap();
2588 let local = cache.snapshot().unwrap();
2589 let pending = cache.pending().unwrap();
2590 let mut server = Server::new(&source);
2591 for attempted in [false, true] {
2592 if attempted {
2593 server.fault = Fault::UnknownBefore;
2594 assert!(
2595 matches!(cache.sync_once(&mut server),Err(Error::Remote(e))if e.state==CommitState::Unknown)
2596 );
2597 }
2598 let state = cache.status(id).unwrap();
2599 let result = if outline_case {
2600 cache.rebase_outline_conflict(id, &local, &source, page, 288.0, 360.0)
2601 } else {
2602 cache.rebase_paragraph_conflict(id, &local, &source, parent, None)
2603 };
2604 assert!(matches!(result,Err(Error::Io(e))if e.kind()==io::ErrorKind::InvalidInput));
2605 assert_eq!(cache.pending().unwrap(), pending);
2606 assert_eq!(cache.status(id).unwrap(), state);
2607 assert_eq!(cache.snapshot().unwrap(), local);
2608 }
2609 drop(cache);
2610 let cache = Replica::open(&path).unwrap();
2611 assert_eq!(cache.pending().unwrap(), pending);
2612 assert!(matches!(
2613 cache.sync_once(&mut server).unwrap(),
2614 Some((_, EditStatus::AwaitingConfirmation { .. }))
2615 ));
2616 assert_eq!(server.publications, 1);
2617 }
2618}
crates/onestore-smb/Cargo.toml created+13
...@@ -0,0 +1,13 @@
1[package]
2name = "onestore-smb"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7[dependencies]
8onestore = { path = "../onestore" }
9smb2 = "=0.21.0"
10tokio = { version = "1", features = ["rt-multi-thread", "time"] }
11
12[dev-dependencies]
13serde_json = "1"
crates/onestore-smb/README.md created+43
...@@ -0,0 +1,43 @@
1# onestore-smb
2
3Optional blocking SMB access for OneNote sections and table-of-contents files.
4The core `onestore` crate remains independent of network runtimes. This is an
5experimental Rust API with native interoperability evidence in the repository's
6[Milestone 9](../../evidence/MILESTONE9.md).
7
8```no_run
9use onestore_smb::{Client, Credentials};
10use std::time::Duration;
11
12let client = Client::connect(
13 "server:445",
14 "notes",
15 Credentials { username: "user", password: "password", domain: "" },
16 Duration::from_secs(5),
17)?;
18let snapshot = client.read("Personal/Video.one", 64 * 1024 * 1024)?;
19let store = onestore::Store::parse(&snapshot)?;
20let revisions = onestore::RevisionIndex::parse(&store)?;
21let document = onestore::document::Document::parse(&revisions)?;
22# Ok::<(), Box<dyn std::error::Error>>(())
23```
24
25Paths are relative to the share. The read limit bounds the complete physical
26snapshot. Call from a background thread outside a Tokio runtime. Use identities
27from the document and the same snapshot with `Client::commit_text` or
28`Client::commit_property_bytes`; their errors retain `onestore::CommitState`.
29`PreparedEdit::{text,insert,format}` separate preparation from I/O: inspect the immutable image
30and persist the intended revision identity before `Client::commit_prepared`.
31`Client::confirm_snapshot` compares and flushes an observed image, then refreshes
32its header version metadata without adding a revision. The caller must first
33establish which intents that image contains and reread before another commit.
34
35Readers use shared native guards while writers publish under native write-open
36and byte-lock exclusion. Maintenance is excluded during each operation; pathname
37identity is checked after acquiring the guards. Connection loss retires the
38client. Reconnect for subsequent operations, and reconcile an `Unknown` edit
39before retrying it. The transport does not automatically replay requests.
40
41Device and simulator builds link for iOS. Native acceptance uses disposable
42OneNote 2010 clients and Samba; it does not establish on-device execution or
43physical power-loss durability.
crates/onestore-smb/examples/smb_concurrent_client.rs created+22
...@@ -0,0 +1,22 @@
1#[path = "../../onestore/examples/support/concurrent.rs"]
2mod concurrent;
3
4use onestore_smb::{Client, Credentials};
5use std::{env, time::Duration};
6
7fn main() -> Result<(), Box<dyn std::error::Error>> {
8 let client = Client::connect(
9 &env::var("ONESTORE_SMB_LAB")?,
10 &env::var("ONESTORE_SMB_SHARE")?,
11 Credentials::default(),
12 Duration::from_secs(10),
13 )?;
14 let args: Vec<_> = env::args().skip(1).collect();
15 concurrent::run(
16 &args,
17 |path| client.read(path, 256 * 1024 * 1024),
18 |path, source, space, object, range, replacement| {
19 client.commit_text(path, source, space, object, range, replacement)
20 },
21 )
22}
crates/onestore-smb/examples/smb_reconnect_client.rs created+267
...@@ -0,0 +1,267 @@
1#[path = "../../onestore/examples/support/concurrent.rs"]
2mod concurrent;
3
4use onestore::{
5 CommitError, CommitState, ExGuid, RevisionIndex, Store,
6 document::{Document, Kind},
7};
8use onestore_smb::{Client, Credentials};
9use serde_json::json;
10use std::{
11 cell::RefCell,
12 env, io, thread,
13 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
14};
15
16fn paragraph(
17 bytes: &[u8],
18 space: ExGuid,
19 object: ExGuid,
20) -> Result<String, Box<dyn std::error::Error>> {
21 let store = Store::parse(bytes)?;
22 let index = RevisionIndex::parse(&store)?;
23 index.validate_current()?;
24 let document = Document::parse(&index)?;
25 let space = &document.spaces[&space];
26 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
27 let Kind::RichText { text, .. } = &revision.nodes[&object].kind else {
28 return Err("The append target is no longer text.".into());
29 };
30 Ok(text.clone())
31}
32
33// Only the owned append workload guarantees unique tokens that no writer removes.
34fn retained(before: &str, token: &str, current: &str, state: CommitState) -> io::Result<bool> {
35 let count = current.matches(token).count();
36 if count == 0 && state != CommitState::Committed && current.starts_with(before) {
37 return Ok(false);
38 }
39 if count == 1
40 && state != CommitState::NotCommitted
41 && current.starts_with(&format!("{before}{token}"))
42 {
43 return Ok(true);
44 }
45 Err(io::Error::other(
46 "The append history contradicts the commit outcome.",
47 ))
48}
49
50fn main() -> Result<(), Box<dyn std::error::Error>> {
51 let args: Vec<_> = env::args().skip(1).collect();
52 if args
53 .first()
54 .is_none_or(|mode| !["read", "write"].contains(&mode.as_str()))
55 {
56 return Err("Reconnect testing requires the append-only workload.".into());
57 }
58 let address = env::var("ONESTORE_SMB_LAB")?;
59 let share = env::var("ONESTORE_SMB_SHARE")?;
60 let client = RefCell::new(Some(Client::connect(
61 &address,
62 &share,
63 Credentials::default(),
64 Duration::from_secs(5),
65 )?));
66 let read = |path: &str| {
67 let mut session = client.borrow_mut();
68 if session.is_none() {
69 match Client::connect(
70 &address,
71 &share,
72 Credentials::default(),
73 Duration::from_secs(5),
74 ) {
75 Ok(fresh) => {
76 *session = Some(fresh);
77 println!(
78 "{}",
79 json!({"event":"transport_connected", "at_us":SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_micros()})
80 );
81 }
82 Err(error) => {
83 println!(
84 "{}",
85 json!({"event":"transport_connect_error","error":error.to_string()})
86 );
87 return Err(io::ErrorKind::WouldBlock.into());
88 }
89 }
90 }
91 let started = SystemTime::now()
92 .duration_since(UNIX_EPOCH)
93 .unwrap()
94 .as_micros();
95 match session.as_ref().unwrap().read(path, 256 * 1024 * 1024) {
96 Ok(bytes) => {
97 if args.get(2).is_some_and(|actor| actor == "r0")
98 && let Some(folder) = env::var_os("ONESTORE_OFFLINE_FORMAT_REPLY_DIR")
99 {
100 let folder = std::path::PathBuf::from(folder);
101 let captured = folder.join("offline-retired.one");
102 if !captured.exists()
103 && let Ok(marker) = std::fs::read(folder.join("offline-paused-w0.isolate"))
104 && let Ok(watched) = serde_json::from_slice::<serde_json::Value>(&marker)
105 && watched["after_us"]
106 .as_u64()
107 .is_some_and(|after| started > u128::from(after))
108 {
109 let sid: ExGuid = watched["space"]
110 .as_str()
111 .ok_or_else(|| io::Error::other("Missing watched space"))?
112 .parse()
113 .map_err(io::Error::other)?;
114 let rid: ExGuid = watched["revision"]
115 .as_str()
116 .ok_or_else(|| io::Error::other("Missing watched revision"))?
117 .parse()
118 .map_err(io::Error::other)?;
119 let store = Store::parse(&bytes).map_err(io::Error::other)?;
120 let index = RevisionIndex::parse(&store).map_err(io::Error::other)?;
121 if index
122 .spaces
123 .get(&sid)
124 .is_some_and(|space| !space.revisions.contains_key(&rid))
125 {
126 index.validate_current().map_err(io::Error::other)?;
127 std::fs::write(captured.with_extension("tmp"), &bytes)?;
128 std::fs::rename(captured.with_extension("tmp"), captured)?;
129 println!(
130 "{}",
131 json!({"event":"revision_retired", "space":sid.to_string(), "revision":rid.to_string(), "started_us":started, "finished_us":SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_micros()})
132 );
133 }
134 }
135 }
136 Ok(bytes)
137 }
138 Err(error)
139 if matches!(
140 error.kind(),
141 io::ErrorKind::Other | io::ErrorKind::TimedOut | io::ErrorKind::NotConnected
142 ) =>
143 {
144 println!(
145 "{}",
146 json!({"event":"transport_read_error","error":error.to_string()})
147 );
148 *session = None;
149 Err(io::ErrorKind::WouldBlock.into())
150 }
151 result => result,
152 }
153 };
154 concurrent::run(
155 &args,
156 read,
157 |path, source, space, object, range, replacement| {
158 let outcome = client.borrow().as_ref().unwrap().commit_text(
159 path,
160 source,
161 space,
162 object,
163 range,
164 replacement,
165 );
166 let Err(error) = outcome else {
167 return Ok(());
168 };
169 if error.state == CommitState::NotCommitted
170 && matches!(
171 error.error.kind(),
172 io::ErrorKind::WouldBlock
173 | io::ErrorKind::ResourceBusy
174 | io::ErrorKind::PermissionDenied
175 | io::ErrorKind::NotFound
176 )
177 {
178 return Err(error);
179 }
180 println!(
181 "{}",
182 json!({"event":"transport_commit_error","state":format!("{:?}",error.state),"token":replacement,"error":error.error.to_string()})
183 );
184 *client.borrow_mut() = None;
185 let deadline = Instant::now() + Duration::from_secs(60);
186 loop {
187 if Instant::now() >= deadline {
188 return Err(error);
189 }
190 let current = match read(path) {
191 Ok(bytes) => bytes,
192 Err(retry) if retry.kind() == io::ErrorKind::WouldBlock => {
193 thread::sleep(Duration::from_millis(100));
194 continue;
195 }
196 Err(_) => return Err(error),
197 };
198 let published = paragraph(source, space, object)
199 .and_then(|before| {
200 Ok(retained(
201 &before,
202 replacement,
203 &paragraph(&current, space, object)?,
204 error.state,
205 )?)
206 })
207 .map_err(|failure| CommitError {
208 state: error.state,
209 error: io::Error::other(failure.to_string()),
210 })?;
211 if published {
212 let confirmation = client.borrow().as_ref().unwrap().commit_text(
213 path,
214 &current,
215 space,
216 object,
217 0..0,
218 "",
219 );
220 if let Err(failure) = confirmation
221 && failure.state != CommitState::Committed
222 {
223 println!(
224 "{}",
225 json!({"event":"transport_confirmation_error","state":format!("{:?}", failure.state),"error":failure.error.to_string()})
226 );
227 *client.borrow_mut() = None;
228 thread::sleep(Duration::from_millis(100));
229 continue;
230 }
231 println!(
232 "{}",
233 json!({"event":"transport_reconciled","token":replacement,"published":true,"flush_confirmed":true})
234 );
235 return Ok(());
236 }
237 println!(
238 "{}",
239 json!({"event":"transport_reconciled","token":replacement,"published":false})
240 );
241 return Err(CommitError {
242 state: CommitState::NotCommitted,
243 error: io::ErrorKind::ResourceBusy.into(),
244 });
245 }
246 },
247 )
248}
249
250#[test]
251fn uncertain_append_requires_one_retained_token_and_its_predecessor() {
252 for state in [CommitState::Unknown, CommitState::Committed] {
253 assert!(retained("before", " [w0:0]", "before [w0:0] [w1:0]", state).unwrap());
254 }
255 for state in [CommitState::Unknown, CommitState::NotCommitted] {
256 assert!(!retained("before", " [w0:0]", "before [w1:0]", state).unwrap());
257 }
258 for (current, state) in [
259 ("before [w0:0] [w0:0]", CommitState::Unknown),
260 ("changed [w0:0]", CommitState::Unknown),
261 ("befor", CommitState::Unknown),
262 ("before", CommitState::Committed),
263 ("before [w0:0]", CommitState::NotCommitted),
264 ] {
265 assert!(retained("before", " [w0:0]", current, state).is_err());
266 }
267}
crates/onestore-smb/src/lib.rs created+466
...@@ -0,0 +1,466 @@
1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use onestore::{CommitError, CommitIo, CommitState, ExGuid};
5use smb2::{
6 Session, Tree,
7 client::connection::{Connection, NegotiatedParams},
8 msg::{
9 close::{CloseRequest, CloseResponse},
10 create::{
11 CreateDisposition, CreateRequest, CreateResponse, ImpersonationLevel, ShareAccess,
12 },
13 flush::{FlushRequest, FlushResponse},
14 lock::{LockElement, LockRequest, LockResponse},
15 query_info::{InfoType, QueryInfoRequest, QueryInfoResponse},
16 read::{ReadRequest, ReadResponse},
17 write::{WriteRequest, WriteResponse},
18 },
19 pack::{Pack, ReadCursor, Unpack},
20 types::{
21 Command, CreditCharge, Dialect, FileId, OplockLevel,
22 flags::{Capabilities, FileAccessMask},
23 },
24};
25use std::{io, ops::Range, sync::Mutex, time::Duration};
26use tokio::runtime::{Handle, Runtime};
27
28#[derive(Default)]
29pub struct Credentials<'a> {
30 pub username: &'a str,
31 pub password: &'a str,
32 pub domain: &'a str,
33}
34
35/// Blocking connection with no automatic request replay or cached file contents.
36/// Call from a background thread outside a Tokio runtime.
37/// Paths are relative to the share; both `/` and `\` are separators.
38pub struct Client {
39 connection: Mutex<Option<Connection>>,
40 tree: Tree,
41 timeout: Duration,
42 runtime: Mutex<Option<Runtime>>,
43}
44
45impl Client {
46 pub fn connect(
47 address: &str,
48 share: &str,
49 credentials: Credentials<'_>,
50 timeout: Duration,
51 ) -> io::Result<Self> {
52 if Handle::try_current().is_ok() || timeout.is_zero() {
53 return Err(io::ErrorKind::InvalidInput.into());
54 }
55 let runtime = tokio::runtime::Builder::new_multi_thread()
56 .worker_threads(1)
57 .enable_all()
58 .build()?;
59 let (connection, tree) = runtime
60 .block_on(async {
61 tokio::time::timeout(timeout, async {
62 let mut connection = Connection::connect(address, timeout).await?;
63 connection.set_compression_requested(false);
64 connection.negotiate().await?;
65 Session::setup(
66 &mut connection,
67 credentials.username,
68 credentials.password,
69 credentials.domain,
70 )
71 .await?;
72 let tree = Tree::connect(&mut connection, share).await?;
73 Ok::<_, smb2::Error>((connection, tree))
74 })
75 .await
76 })
77 .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))?
78 .map_err(io::Error::other)?;
79 Ok(Self {
80 connection: Mutex::new(Some(connection)),
81 tree,
82 timeout,
83 runtime: Mutex::new(Some(runtime)),
84 })
85 }
86
87 fn retire(&self) {
88 if let Some(connection) = self
89 .connection
90 .lock()
91 .unwrap_or_else(|error| error.into_inner())
92 .take()
93 {
94 connection.mark_dead();
95 }
96 if let Some(runtime) = self
97 .runtime
98 .lock()
99 .unwrap_or_else(|error| error.into_inner())
100 .take()
101 {
102 runtime.shutdown_background();
103 }
104 }
105
106 fn request<T: Unpack>(&self, command: Command, body: impl Pack) -> io::Result<T> {
107 self.request_with(command, |_| (body, CreditCharge(1)))
108 }
109
110 fn request_with<T: Unpack, B: Pack>(
111 &self,
112 command: Command,
113 prepare: impl FnOnce(&Connection) -> (B, CreditCharge),
114 ) -> io::Result<T> {
115 if Handle::try_current().is_ok() {
116 return Err(io::ErrorKind::InvalidInput.into());
117 }
118 let connection = self
119 .connection
120 .lock()
121 .map_err(|_| io::ErrorKind::Other)?
122 .clone()
123 .ok_or(io::ErrorKind::NotConnected)?;
124 let frame = {
125 let runtime = self.runtime.lock().map_err(|_| io::ErrorKind::Other)?;
126 let (body, charge) = prepare(&connection);
127 runtime
128 .as_ref()
129 .ok_or(io::ErrorKind::NotConnected)?
130 .block_on(async {
131 tokio::time::timeout(
132 self.timeout,
133 connection.execute_with_credits(
134 command,
135 &body,
136 Some(self.tree.tree_id),
137 charge,
138 ),
139 )
140 .await
141 })
142 };
143 let frame = frame
144 .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))
145 .and_then(|result| result.map_err(io::Error::other))
146 .inspect_err(|_| self.retire())?;
147 if frame.header.command != command {
148 self.retire();
149 return Err(io::ErrorKind::InvalidData.into());
150 }
151 if frame.header.status.0 != 0 {
152 let kind = match frame.header.status.0 {
153 0xc0000043 | 0xc0000054 | 0xc0000055 => io::ErrorKind::WouldBlock,
154 0xc0000011 => io::ErrorKind::UnexpectedEof,
155 0xc0000034 | 0xc000003a => io::ErrorKind::NotFound,
156 _ => io::ErrorKind::Other,
157 };
158 return Err(io::Error::new(
159 kind,
160 smb2::Error::Protocol {
161 status: frame.header.status,
162 command,
163 },
164 ));
165 }
166 T::unpack(&mut ReadCursor::new(&frame.body)).map_err(|error| {
167 self.retire();
168 io::Error::new(io::ErrorKind::InvalidData, error)
169 })
170 }
171
172 fn open(&self, path: &str, write: bool) -> io::Result<File<'_>> {
173 if path.is_empty() || path.contains('\0') || path.encode_utf16().count() > 32767 {
174 return Err(io::ErrorKind::InvalidInput.into());
175 }
176 let response: CreateResponse = self.request(
177 Command::Create,
178 CreateRequest {
179 requested_oplock_level: OplockLevel::None,
180 impersonation_level: ImpersonationLevel::Impersonation,
181 desired_access: FileAccessMask::new(if write { 0xc0000000 } else { 0x80000000 }),
182 file_attributes: 0,
183 share_access: ShareAccess(if write { 5 } else { 7 }),
184 create_disposition: CreateDisposition::FileOpen,
185 create_options: 0x42,
186 name: smb2::encode_path(&path.replace('\\', "/")),
187 create_contexts: Vec::new(),
188 },
189 )?;
190 Ok(File {
191 client: self,
192 id: Some(response.file_id),
193 })
194 }
195
196 /// Reads one bounded, consistent snapshot; contention returns WouldBlock.
197 pub fn read(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
198 let mut file = self.open(path, false)?.coordinate(path, false)?;
199 let result = onestore::read_snapshot(|offset, output| file.read_at(offset, output), limit)
200 .and_then(|snapshot| snapshot.ok_or_else(|| io::ErrorKind::WouldBlock.into()));
201 let closed = file.close();
202 let snapshot = result?;
203 closed?;
204 Ok(snapshot)
205 }
206
207 pub fn commit_text(
208 &self,
209 path: &str,
210 source: &[u8],
211 space: ExGuid,
212 object: ExGuid,
213 range: Range<u32>,
214 replacement: &str,
215 ) -> Result<(), CommitError> {
216 self.commit(path, |file| {
217 onestore::commit_text(file, source, space, object, range, replacement)
218 })
219 }
220
221 pub fn commit_property_bytes(
222 &self,
223 path: &str,
224 source: &[u8],
225 space: ExGuid,
226 object: ExGuid,
227 property: u32,
228 value: &[u8],
229 ) -> Result<(), CommitError> {
230 self.commit(path, |file| {
231 onestore::commit_property_bytes(file, source, space, object, property, value)
232 })
233 }
234
235 /// Publishes a prepared edit using the same native writer coordination as text commits.
236 pub fn commit_prepared(
237 &self,
238 path: &str,
239 edit: &onestore::PreparedEdit<'_>,
240 ) -> Result<(), CommitError> {
241 self.commit(path, |file| edit.commit(file))
242 }
243
244 /// Confirms an observed snapshot's durability under native writer coordination.
245 pub fn confirm_snapshot(&self, path: &str, source: &[u8]) -> Result<(), CommitError> {
246 self.commit(path, |file| onestore::confirm_snapshot(file, source))
247 }
248
249 fn commit(
250 &self,
251 path: &str,
252 operation: impl FnOnce(&mut File<'_>) -> Result<(), CommitError>,
253 ) -> Result<(), CommitError> {
254 let mut file = self
255 .open(path, true)
256 .and_then(|file| file.coordinate(path, true))
257 .map_err(|error| CommitError {
258 state: CommitState::NotCommitted,
259 error,
260 })?;
261 let result = operation(&mut file);
262 let closed = file.close();
263 result?;
264 closed.map_err(|error| CommitError {
265 state: CommitState::Committed,
266 error,
267 })
268 }
269}
270
271impl Drop for Client {
272 fn drop(&mut self) {
273 self.retire();
274 }
275}
276
277struct File<'a> {
278 client: &'a Client,
279 id: Option<FileId>,
280}
281impl File<'_> {
282 fn coordinate(self, path: &str, write: bool) -> io::Result<Self> {
283 self.lock(0xfffffffb, 0x11)?;
284 if write {
285 self.lock(0xfffffffd, 0x12)?;
286 }
287 let current = self.client.open(path, false)?;
288 let same = self.identity()? == current.identity()?;
289 current.close()?;
290 if !same {
291 return Err(io::ErrorKind::ResourceBusy.into());
292 }
293 Ok(self)
294 }
295
296 fn lock(&self, offset: u64, flags: u32) -> io::Result<()> {
297 let _: LockResponse = self.client.request(
298 Command::Lock,
299 LockRequest {
300 file_id: self.id.unwrap(),
301 lock_sequence: 0,
302 locks: vec![LockElement {
303 offset,
304 length: 1,
305 flags,
306 }],
307 },
308 )?;
309 Ok(())
310 }
311
312 fn identity(&self) -> io::Result<(u64, u32)> {
313 let index: QueryInfoResponse = self.client.request(
314 Command::QueryInfo,
315 QueryInfoRequest {
316 info_type: InfoType::File,
317 file_info_class: 6,
318 output_buffer_length: 8,
319 additional_information: 0,
320 flags: 0,
321 file_id: self.id.unwrap(),
322 input_buffer: Vec::new(),
323 },
324 )?;
325 let index = u64::from_le_bytes(
326 index
327 .output_buffer
328 .try_into()
329 .map_err(|_| io::ErrorKind::InvalidData)?,
330 );
331 if index == 0 {
332 return Err(io::ErrorKind::Unsupported.into());
333 }
334 let volume: QueryInfoResponse = self.client.request(
335 Command::QueryInfo,
336 QueryInfoRequest {
337 info_type: InfoType::Filesystem,
338 file_info_class: 1,
339 output_buffer_length: 1024,
340 additional_information: 0,
341 flags: 0,
342 file_id: self.id.unwrap(),
343 input_buffer: Vec::new(),
344 },
345 )?;
346 let serial = volume
347 .output_buffer
348 .get(8..12)
349 .ok_or(io::ErrorKind::InvalidData)?;
350 Ok((index, u32::from_le_bytes(serial.try_into().unwrap())))
351 }
352
353 fn close(mut self) -> io::Result<()> {
354 self.release()
355 }
356
357 fn release(&mut self) -> io::Result<()> {
358 if let Some(file_id) = self.id.take() {
359 let result: io::Result<CloseResponse> = self
360 .client
361 .request(Command::Close, CloseRequest { file_id, flags: 0 });
362 if result.is_err() {
363 self.client.retire();
364 }
365 result?;
366 }
367 Ok(())
368 }
369}
370impl Drop for File<'_> {
371 fn drop(&mut self) {
372 let _ = self.release();
373 }
374}
375fn read_size(params: &NegotiatedParams, credits: u16, requested: usize) -> usize {
376 let budget = if params.dialect != Dialect::Smb2_0_2
377 && params.capabilities.contains(Capabilities::LARGE_MTU)
378 {
379 usize::from(credits.max(1)) * 65536
380 } else {
381 65536
382 };
383 requested
384 .min(params.max_read_size as usize)
385 .min(budget)
386 .min(1024 * 1024)
387}
388
389impl CommitIo for File<'_> {
390 fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> {
391 if output.is_empty() {
392 return Ok(0);
393 }
394 let mut size = 0;
395 let response: io::Result<ReadResponse> =
396 self.client.request_with(Command::Read, |connection| {
397 size = read_size(
398 &connection
399 .params()
400 .expect("connected SMB session is negotiated"),
401 connection.credits(),
402 output.len(),
403 );
404 (
405 ReadRequest {
406 file_id: self.id.unwrap(),
407 offset,
408 length: size as u32,
409 minimum_count: 1,
410 flags: 0,
411 padding: 0,
412 channel: 0,
413 remaining_bytes: 0,
414 read_channel_info: Vec::new(),
415 },
416 CreditCharge(size.div_ceil(65536) as u16),
417 )
418 });
419 let response = match response {
420 Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(0),
421 result => result?,
422 };
423 if response.data.len() > size {
424 self.client.retire();
425 return Err(io::ErrorKind::InvalidData.into());
426 }
427 output[..response.data.len()].copy_from_slice(&response.data);
428 Ok(response.data.len())
429 }
430 fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> {
431 if bytes.is_empty() {
432 return Ok(0);
433 }
434 let size = bytes.len().min(65536);
435 let response: WriteResponse = self.client.request(
436 Command::Write,
437 WriteRequest {
438 file_id: self.id.unwrap(),
439 offset,
440 data: bytes[..size].to_vec(),
441 data_offset: 112,
442 flags: 1,
443 channel: 0,
444 remaining_bytes: 0,
445 write_channel_info_offset: 0,
446 write_channel_info_length: 0,
447 },
448 )?;
449 if response.count as usize > size {
450 return Err(io::ErrorKind::InvalidData.into());
451 }
452 Ok(response.count as usize)
453 }
454 fn flush(&mut self) -> io::Result<()> {
455 let _: FlushResponse = self.client.request(
456 Command::Flush,
457 FlushRequest {
458 file_id: self.id.unwrap(),
459 },
460 )?;
461 Ok(())
462 }
463}
464
465#[cfg(test)]
466mod tests;
crates/onestore-smb/src/tests.rs created+340
...@@ -0,0 +1,340 @@
1use super::*;
2use onestore::{
3 RevisionIndex, Store,
4 document::{Document, Kind},
5};
6use std::{
7 fs,
8 time::{Instant, SystemTime, UNIX_EPOCH},
9};
10
11mod faults;
12
13fn client() -> Client {
14 Client::connect(
15 &std::env::var("ONESTORE_SMB_LAB").unwrap(),
16 "agent",
17 Credentials::default(),
18 Duration::from_secs(5),
19 )
20 .unwrap()
21}
22fn create(client: &Client, path: &str, bytes: &[u8]) {
23 let response: CreateResponse = client
24 .request(
25 Command::Create,
26 CreateRequest {
27 requested_oplock_level: OplockLevel::None,
28 impersonation_level: ImpersonationLevel::Impersonation,
29 desired_access: FileAccessMask::new(0xc0000000),
30 file_attributes: 0,
31 share_access: ShareAccess(7),
32 create_disposition: CreateDisposition::FileCreate,
33 create_options: 0x42,
34 name: path.to_owned(),
35 create_contexts: Vec::new(),
36 },
37 )
38 .unwrap();
39 let mut file = File {
40 client,
41 id: Some(response.file_id),
42 };
43 let mut offset = 0;
44 while offset < bytes.len() {
45 offset += file.write_at(offset as u64, &bytes[offset..]).unwrap();
46 }
47 file.flush().unwrap();
48 file.close().unwrap();
49}
50fn text(bytes: &[u8]) -> (ExGuid, ExGuid, String) {
51 let store = Store::parse(bytes).unwrap();
52 assert!(store.checksum_mismatches.is_empty());
53 let index = RevisionIndex::parse(&store).unwrap();
54 index.validate_current().unwrap();
55 let doc = Document::parse(&index).unwrap();
56 doc.spaces
57 .iter()
58 .find_map(|(sid, space)| {
59 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
60 revision
61 .nodes
62 .iter()
63 .find_map(|(oid, node)| match &node.kind {
64 Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())),
65 _ => None,
66 })
67 })
68 .unwrap()
69}
70#[test]
71#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]
72fn live_coordination() {
73 let writer = client();
74 let path = format!(
75 "adapter-{}.one",
76 SystemTime::now()
77 .duration_since(UNIX_EPOCH)
78 .unwrap()
79 .as_nanos()
80 );
81 let source = onestore::create_section(&path, "Before café 🦀", "Fixture").unwrap();
82 create(&writer, &path, &source);
83 assert_eq!(writer.read(&path, 1 << 20).unwrap(), source);
84 let readers: Vec<_> = (0..12).map(|_| client()).collect();
85 let mut held: Vec<_> = readers
86 .iter()
87 .map(|client| {
88 client
89 .open(&path, false)
90 .unwrap()
91 .coordinate(&path, false)
92 .unwrap()
93 })
94 .collect();
95 let (sid, oid, before) = text(&source);
96 let replacement = "After café 🦀";
97 writer
98 .commit_text(
99 &path,
100 &source,
101 sid,
102 oid,
103 0..before.encode_utf16().count() as u32,
104 replacement,
105 )
106 .unwrap();
107 let after = writer.read(&path, 1 << 20).unwrap();
108 assert_eq!(text(&after).2, replacement);
109 for file in &mut held {
110 let snapshot = onestore::read_snapshot(|offset, out| file.read_at(offset, out), 1 << 20)
111 .unwrap()
112 .unwrap();
113 assert_eq!(snapshot, after);
114 }
115 let error = writer
116 .commit_text(&path, &source, sid, oid, 0..1, "X")
117 .unwrap_err();
118 assert_eq!(error.state, CommitState::NotCommitted);
119 assert_eq!(error.error.kind(), io::ErrorKind::ResourceBusy);
120 assert_eq!(writer.read(&path, 1 << 20).unwrap(), after);
121 drop(held);
122
123 let stale = writer.open(&path, true).unwrap();
124 let maintenance = client();
125 let guard = maintenance.open(&path, false).unwrap();
126 let _: LockResponse = maintenance
127 .request(
128 Command::Lock,
129 LockRequest {
130 file_id: guard.id.unwrap(),
131 lock_sequence: 0,
132 locks: vec![
133 LockElement {
134 offset: 0xfffffffc,
135 length: 1,
136 flags: 0x12,
137 },
138 LockElement {
139 offset: 0xffffeffc,
140 length: 4096,
141 flags: 0x12,
142 },
143 ],
144 },
145 )
146 .unwrap();
147 let replacement_path = format!("{path}.replacement");
148 create(&maintenance, &replacement_path, &source);
149 let mut connection = maintenance
150 .connection
151 .lock()
152 .unwrap()
153 .as_ref()
154 .unwrap()
155 .clone();
156 maintenance
157 .runtime
158 .lock()
159 .unwrap()
160 .as_ref()
161 .unwrap()
162 .block_on(
163 maintenance
164 .tree
165 .rename(&mut connection, &path, &format!("{path}.old")),
166 )
167 .unwrap();
168 maintenance
169 .runtime
170 .lock()
171 .unwrap()
172 .as_ref()
173 .unwrap()
174 .block_on(
175 maintenance
176 .tree
177 .rename(&mut connection, &replacement_path, &path),
178 )
179 .unwrap();
180 drop(connection);
181 guard.close().unwrap();
182 let error = stale.coordinate(&path, true).err().unwrap();
183 assert_eq!(error.kind(), io::ErrorKind::ResourceBusy);
184 assert_eq!(writer.read(&path, 1 << 20).unwrap(), source);
185
186 let retiring = client();
187 let file = retiring
188 .open(&path, true)
189 .unwrap()
190 .coordinate(&path, true)
191 .unwrap();
192 retiring.retire();
193 assert_eq!(
194 retiring.read(&path, 1 << 20).unwrap_err().kind(),
195 io::ErrorKind::NotConnected
196 );
197 drop(file);
198 let deadline = Instant::now() + Duration::from_secs(5);
199 loop {
200 match writer
201 .open(&path, true)
202 .and_then(|file| file.coordinate(&path, true))
203 {
204 Ok(file) => {
205 file.close().unwrap();
206 break;
207 }
208 Err(error)
209 if error.kind() == io::ErrorKind::WouldBlock && Instant::now() < deadline =>
210 {
211 std::thread::sleep(Duration::from_millis(10))
212 }
213 Err(error) => panic!("retired connection retained locks: {error}"),
214 }
215 }
216
217 let mut unfinished = writer
218 .open(&path, true)
219 .unwrap()
220 .coordinate(&path, true)
221 .unwrap();
222 assert_eq!(
223 unfinished
224 .write_at(source.len() as u64, b"unpublished")
225 .unwrap(),
226 11
227 );
228 unfinished.flush().unwrap();
229 unfinished.close().unwrap();
230 let snapshot = writer.read(&path, 1 << 20).unwrap();
231 assert_eq!(snapshot.len(), source.len() + 11);
232 assert_eq!(text(&snapshot).2, before);
233 writer
234 .commit_text(
235 &path,
236 &snapshot,
237 sid,
238 oid,
239 0..before.encode_utf16().count() as u32,
240 "Recovered café 🦀",
241 )
242 .unwrap();
243 let recovered = writer.read(&path, 1 << 20).unwrap();
244 assert_eq!(text(&recovered).2, "Recovered café 🦀");
245 let spare = client();
246 tokio::runtime::Builder::new_current_thread()
247 .build()
248 .unwrap()
249 .block_on(async {
250 drop(spare);
251 });
252 let output = std::env::var("ONESTORE_SMB_EVIDENCE").unwrap();
253 fs::write(std::path::Path::new(&output).join("source.one"), &source).unwrap();
254 fs::write(std::path::Path::new(&output).join("committed.one"), &after).unwrap();
255 fs::write(
256 std::path::Path::new(&output).join("recovered.one"),
257 &recovered,
258 )
259 .unwrap();
260 println!(
261 "{}",
262 serde_json::json!({"path":path,"readers":12,"committed_while_readers_held":true,"fresh_reads":12,"stale_snapshot_rejected":true,"replaced_handle_rejected":true,"retirement_releases_locks":true,"unpublished_tail_recovered":true,"drop_inside_runtime":true})
263 );
264}
265
266#[test]
267#[ignore = "requires an owned Samba fixture and maintenance controller"]
268fn live_reader_hold() {
269 let client = client();
270 let path = std::env::var("ONESTORE_SMB_PATH").unwrap();
271 let output = std::path::PathBuf::from(std::env::var("ONESTORE_SMB_HOLD").unwrap());
272 assert!(!output.join("ready").exists() && !output.join("release").exists());
273 let mut file = client
274 .open(&path, false)
275 .unwrap()
276 .coordinate(&path, false)
277 .unwrap();
278 fs::write(output.join("ready"), b"held").unwrap();
279 let deadline = Instant::now() + Duration::from_secs(300);
280 let mut accepted = 0;
281 let mut retries = 0;
282 while !output.join("release").exists() {
283 assert!(
284 Instant::now() < deadline,
285 "maintenance controller timed out"
286 );
287 match onestore::read_snapshot(|offset, out| file.read_at(offset, out), 256 << 20).unwrap() {
288 Some(_) => accepted += 1,
289 None => retries += 1,
290 }
291 std::thread::sleep(Duration::from_millis(5));
292 }
293 file.close().unwrap();
294 assert!(accepted > 0);
295 fs::write(
296 output.join("released.json"),
297 serde_json::to_vec(&serde_json::json!({"accepted": accepted, "retries": retries})).unwrap(),
298 )
299 .unwrap();
300}
301
302#[test]
303fn read_limits_respect_negotiation_and_available_credits() {
304 for dialect in Dialect::ALL {
305 for large_mtu in [false, true] {
306 for max_read_size in [65536, 65537, 131072, 1048576, u32::MAX] {
307 let params = NegotiatedParams {
308 dialect: *dialect,
309 max_read_size,
310 max_write_size: 65536,
311 max_transact_size: 65536,
312 server_guid: Default::default(),
313 signing_required: false,
314 capabilities: Capabilities(if large_mtu {
315 Capabilities::LARGE_MTU
316 } else {
317 0
318 }),
319 gmac_negotiated: false,
320 cipher: None,
321 compression_supported: false,
322 };
323 for credits in [0, 1, 2, 3, 15, 16, 17, u16::MAX] {
324 for requested in [1, 1024, 65535, 65536, 65537, 131072, 1048576, usize::MAX] {
325 let size = read_size(&params, credits, requested);
326 assert!(size > 0 && size <= requested && size <= max_read_size as usize);
327 assert!(size <= 1048576);
328 assert!(size.div_ceil(65536) <= usize::from(credits.max(1)));
329 if *dialect == Dialect::Smb2_0_2 || !large_mtu {
330 assert!(size <= 65536);
331 } else if credits >= 16 && max_read_size >= 1048576 && requested >= 1048576
332 {
333 assert_eq!(size, 1048576);
334 }
335 }
336 }
337 }
338 }
339 }
340}
crates/onestore-smb/src/tests/faults.rs created+444
...@@ -0,0 +1,444 @@
1use super::*;
2use serde_json::{Value, json};
3use std::{collections::BTreeMap, path::Path, process::Child};
4
5#[derive(Clone)]
6struct Snapshot {
7 content: BTreeMap<ExGuid, String>,
8 modified: BTreeMap<(ExGuid, ExGuid), u32>,
9}
10
11fn snapshot(bytes: &[u8]) -> Snapshot {
12 let store = Store::parse(bytes).unwrap();
13 assert!(store.checksum_mismatches.is_empty());
14 let index = RevisionIndex::parse(&store).unwrap();
15 index.validate_current().unwrap();
16 Document::parse(&index).unwrap();
17 let mut content = BTreeMap::new();
18 let mut modified = BTreeMap::new();
19 for (sid, space) in &index.spaces {
20 let revision = index
21 .resolve(*sid, space.labels[&(ExGuid::default(), 1)])
22 .unwrap();
23 let mut objects = BTreeMap::new();
24 for (oid, object) in &revision.objects {
25 if let Some(onestore::FileDataReference::Internal(guid)) =
26 object.file_reference().unwrap()
27 {
28 store.file_data(guid).unwrap();
29 }
30 let data = match object.data {
31 onestore::ObjectData::Properties(bytes) => {
32 let mut properties = onestore::PropertySets::parse(bytes).unwrap();
33 for property in &mut properties.sets[0] {
34 if property.id == 0x14001d7a {
35 let onestore::Value::Bytes(value) = property.value else {
36 panic!()
37 };
38 assert!(
39 modified
40 .insert(
41 (*sid, *oid),
42 u32::from_le_bytes(value.try_into().unwrap())
43 )
44 .is_none()
45 );
46 property.value = onestore::Value::Bytes(&[0; 4]);
47 }
48 }
49 format!("{properties:?}")
50 }
51 other => format!("{other:?}"),
52 };
53 objects.insert(
54 *oid,
55 (
56 object.jcid,
57 object.reference_count,
58 data,
59 format!("{:?}", object.references().unwrap()),
60 ),
61 );
62 }
63 content.insert(*sid, format!("{:?} {objects:?}", revision.roots));
64 }
65 Snapshot { content, modified }
66}
67
68fn stamp() -> u32 {
69 (SystemTime::now()
70 .duration_since(UNIX_EPOCH)
71 .unwrap()
72 .as_secs()
73 - 315532800)
74 .try_into()
75 .unwrap()
76}
77
78fn published(
79 actual: &Snapshot,
80 old: &Snapshot,
81 new: &Snapshot,
82 time: std::ops::RangeInclusive<u32>,
83) -> bool {
84 if actual.content == old.content {
85 assert_eq!(
86 actual.modified, old.modified,
87 "Unpublished modification time changed"
88 );
89 return false;
90 }
91 assert_eq!(
92 actual.content, new.content,
93 "Partial or unexpected publication"
94 );
95 assert!(actual.modified.keys().eq(new.modified.keys()));
96 for (key, value) in &actual.modified {
97 if old.modified.get(key) != new.modified.get(key) {
98 assert!(
99 time.contains(value),
100 "Modification time outside the commit interval"
101 );
102 } else {
103 assert_eq!(
104 *value, new.modified[key],
105 "Unrelated modification time changed"
106 );
107 }
108 }
109 true
110}
111
112#[test]
113fn publication_oracle_bounds_changed_timestamps_and_preserves_others() {
114 let id = ExGuid::default();
115 let other = ExGuid { n: 1, ..id };
116 let old = Snapshot {
117 content: BTreeMap::from([(id, "old".into())]),
118 modified: BTreeMap::from([((id, id), 10), ((id, other), 7)]),
119 };
120 let new = Snapshot {
121 content: BTreeMap::from([(id, "new".into())]),
122 modified: BTreeMap::from([((id, id), 20), ((id, other), 7)]),
123 };
124 let mut actual = new.clone();
125 actual.modified.insert((id, id), 30);
126 assert!(published(&actual, &old, &new, 25..=35));
127 assert!(!published(&old, &old, &new, 25..=35));
128 for (key, value) in [((id, id), 24), ((id, id), 36), ((id, other), 30)] {
129 let mut changed = actual.clone();
130 changed.modified.insert(key, value);
131 assert!(std::panic::catch_unwind(|| published(&changed, &old, &new, 25..=35)).is_err());
132 }
133 actual.content = old.content.clone();
134 assert!(std::panic::catch_unwind(|| published(&actual, &old, &new, 25..=35)).is_err());
135}
136
137struct Proxy(Child);
138impl Drop for Proxy {
139 fn drop(&mut self) {
140 let _ = self.0.kill();
141 let _ = self.0.wait();
142 }
143}
144
145fn records(output: &Path) -> Vec<Value> {
146 let data = fs::read_to_string(output.join("proxy.jsonl")).unwrap();
147 data.rsplit_once('\n')
148 .map_or("", |(complete, _)| complete)
149 .lines()
150 .map(|line| serde_json::from_str(line).unwrap())
151 .collect()
152}
153
154fn configure(output: &Path, state: Value) -> usize {
155 fs::write(output.join("control.tmp"), state.to_string()).unwrap();
156 fs::rename(output.join("control.tmp"), output.join("control.json")).unwrap();
157 let deadline = Instant::now() + Duration::from_secs(5);
158 loop {
159 let events = records(output);
160 if let Some(index) = events
161 .iter()
162 .rposition(|event| event.get("control") == Some(&state))
163 {
164 return index + 1;
165 }
166 assert!(
167 Instant::now() < deadline,
168 "proxy did not acknowledge its control state"
169 );
170 std::thread::sleep(Duration::from_millis(5));
171 }
172}
173
174#[test]
175#[ignore = "requires an owned Samba share and a new ONESTORE_SMB_EVIDENCE directory"]
176fn live_message_loss() {
177 let output = std::path::PathBuf::from(std::env::var("ONESTORE_SMB_EVIDENCE").unwrap());
178 fs::create_dir(&output).unwrap();
179 fs::create_dir(output.join("interrupted")).unwrap();
180 fs::create_dir(output.join("recovered")).unwrap();
181 fs::create_dir(output.join("source")).unwrap();
182 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();
183 let (host, port) = address.rsplit_once(':').unwrap();
184 let mut proxy = Proxy(
185 std::process::Command::new("python3")
186 .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tools/smb-proxy.py"))
187 .arg(output.join("control.json"))
188 .args(["--port", "0", "--server", host, "--server-port", port])
189 .stdout(fs::File::create(output.join("proxy.jsonl")).unwrap())
190 .stderr(fs::File::create(output.join("proxy.stderr")).unwrap())
191 .spawn()
192 .unwrap(),
193 );
194 let deadline = Instant::now() + Duration::from_secs(5);
195 let port = loop {
196 if let Some(port) = records(&output)
197 .iter()
198 .find_map(|event| event["listening"].as_u64())
199 {
200 break port;
201 }
202 assert!(proxy.0.try_wait().unwrap().is_none());
203 assert!(Instant::now() < deadline, "proxy did not start");
204 std::thread::sleep(Duration::from_millis(5));
205 };
206 let proxied = format!("127.0.0.1:{port}");
207 let observer = client();
208 let prefix = SystemTime::now()
209 .duration_since(UNIX_EPOCH)
210 .unwrap()
211 .as_nanos();
212 let fixtures = [
213 (
214 "chunked",
215 onestore::create_section("fault.one", "Before café 🦀", "Fault test").unwrap(),
216 ),
217 (
218 "carry",
219 fs::read(
220 Path::new(env!("CARGO_MANIFEST_DIR"))
221 .join("../../corpus/append/round-01/tx-255/notebook/synthetic.one"),
222 )
223 .unwrap(),
224 ),
225 ];
226 let mut results = Vec::new();
227 for (fixture, source) in fixtures {
228 fs::write(
229 output.join("source").join(format!("{fixture}.one")),
230 &source,
231 )
232 .unwrap();
233 let (sid, oid, before) = text(&source);
234 let after = if fixture == "chunked" {
235 "After café 🦀 ".repeat(6000)
236 } else {
237 "After café 🦀".to_owned()
238 };
239 let suffix = " [reconnected]";
240 fs::write(
241 output.join(format!("{fixture}-intent.json")),
242 serde_json::to_vec(&json!({"before":before,"after":after,"suffix":suffix})).unwrap(),
243 )
244 .unwrap();
245 let old = snapshot(&source);
246 let deadline = Instant::now() + Duration::from_secs(2);
247 while old.modified.values().any(|value| *value >= stamp()) {
248 assert!(
249 Instant::now() < deadline,
250 "source timestamps did not precede the test"
251 );
252 std::thread::sleep(Duration::from_millis(5));
253 }
254 let expected = onestore::replace_text(
255 &source,
256 sid,
257 oid,
258 0..before.encode_utf16().count() as u32,
259 &after,
260 )
261 .unwrap();
262 let new = snapshot(&expected);
263 let baseline_path = format!("fault-{prefix}-{fixture}-baseline.one");
264 create(&observer, &baseline_path, &source);
265 configure(&output, json!({"phase":format!("{fixture}-setup")}));
266 let baseline = Client::connect(
267 &proxied,
268 "agent",
269 Credentials::default(),
270 Duration::from_secs(5),
271 )
272 .unwrap();
273 let start = configure(&output, json!({"phase":format!("{fixture}-baseline")}));
274 let began = stamp();
275 baseline
276 .commit_text(
277 &baseline_path,
278 &source,
279 sid,
280 oid,
281 0..before.encode_utf16().count() as u32,
282 &after,
283 )
284 .unwrap();
285 let events = records(&output);
286 let mut occurrences = BTreeMap::new();
287 let mut cuts = Vec::new();
288 for event in &events[start..] {
289 let Some(direction) = event["direction"].as_str() else {
290 continue;
291 };
292 if event["status"] == "0x103" {
293 continue;
294 }
295 let command = event["command"].as_u64().unwrap();
296 let status = event["status"].as_str().map(str::to_owned);
297 let count = occurrences
298 .entry((direction.to_owned(), command, status.clone()))
299 .or_insert(0);
300 *count += 1;
301 cuts.push((direction.to_owned(), command, status, *count));
302 }
303 assert!(
304 cuts.iter()
305 .any(|(direction, command, _, count)| direction == "response"
306 && *command == 7
307 && *count == 3)
308 );
309 assert!(published(
310 &snapshot(&observer.read(&baseline_path, 1 << 20).unwrap()),
311 &old,
312 &new,
313 began..=stamp()
314 ));
315 drop(baseline);
316 for (case, (direction, command, status, occurrence)) in cuts.iter().enumerate() {
317 let name = format!("{fixture}-{case:03}");
318 let path = format!("fault-{prefix}-{name}.one");
319 create(&observer, &path, &source);
320 configure(&output, json!({"phase":format!("{name}-setup")}));
321 let interrupted = Client::connect(
322 &proxied,
323 "agent",
324 Credentials::default(),
325 Duration::from_secs(5),
326 )
327 .unwrap();
328 let start = configure(
329 &output,
330 json!({"phase":name, "direction":direction, "cut":command, "status":status, "occurrence":occurrence}),
331 );
332 let began = stamp();
333 let error = interrupted
334 .commit_text(
335 &path,
336 &source,
337 sid,
338 oid,
339 0..before.encode_utf16().count() as u32,
340 &after,
341 )
342 .unwrap_err();
343 assert_eq!(
344 interrupted.read(&path, 1 << 20).unwrap_err().kind(),
345 io::ErrorKind::NotConnected
346 );
347 let events = records(&output);
348 assert_eq!(
349 events[start..]
350 .iter()
351 .filter(|event| event.get("cut").is_some())
352 .count(),
353 1
354 );
355 assert!(
356 !events
357 .iter()
358 .any(|event| event.get("trace_error").is_some())
359 );
360 let fresh = client();
361 let deadline = Instant::now() + Duration::from_secs(5);
362 loop {
363 match fresh
364 .open(&path, true)
365 .and_then(|file| file.coordinate(&path, true))
366 {
367 Ok(file) => {
368 file.close().unwrap();
369 break;
370 }
371 Err(error)
372 if error.kind() == io::ErrorKind::WouldBlock
373 && Instant::now() < deadline =>
374 {
375 std::thread::sleep(Duration::from_millis(5))
376 }
377 Err(error) => panic!("{name}: retired session retained exclusion: {error}"),
378 }
379 }
380 let saved = fresh.read(&path, 1 << 20).unwrap();
381 fs::write(
382 output.join("interrupted").join(format!("{name}.one")),
383 &saved,
384 )
385 .unwrap();
386 let visible = published(&snapshot(&saved), &old, &new, began..=stamp());
387 match error.state {
388 CommitState::NotCommitted => assert!(!visible, "{name}"),
389 CommitState::Committed => assert!(visible, "{name}"),
390 CommitState::Unknown => {}
391 }
392 if !visible {
393 fresh
394 .commit_text(
395 &path,
396 &saved,
397 sid,
398 oid,
399 0..before.encode_utf16().count() as u32,
400 &after,
401 )
402 .unwrap();
403 }
404 let saved = fresh.read(&path, 1 << 20).unwrap();
405 assert!(
406 published(&snapshot(&saved), &old, &new, began..=stamp()),
407 "{name}: replay did not publish"
408 );
409 let end = after.encode_utf16().count() as u32;
410 fresh
411 .commit_text(&path, &saved, sid, oid, end..end, suffix)
412 .unwrap();
413 let recovered = fresh.read(&path, 1 << 20).unwrap();
414 assert_eq!(text(&recovered).2, format!("{after}{suffix}"));
415 fs::write(
416 output.join("recovered").join(format!("{name}.one")),
417 recovered,
418 )
419 .unwrap();
420 results.push(json!({"case":name,"path":path,"direction":direction,"command":command,"status":status,"occurrence":occurrence,
421 "state":format!("{:?}",error.state),"visible":if visible {"after"} else {"before"},
422 "retired_session_rejected":true,"exclusion_released":true,"fresh_commit_succeeded":true}));
423 fs::write(
424 output.join("results.json"),
425 serde_json::to_vec_pretty(&results).unwrap(),
426 )
427 .unwrap();
428 }
429 }
430 for state in ["NotCommitted", "Unknown", "Committed"] {
431 assert!(results.iter().any(|result| result["state"] == state));
432 }
433 assert!(
434 results
435 .iter()
436 .any(|result| result["state"] == "Unknown" && result["visible"] == "before")
437 );
438 assert!(
439 results
440 .iter()
441 .any(|result| result["state"] == "Unknown" && result["visible"] == "after")
442 );
443 println!("{} message-loss cases passed", results.len());
444}
crates/onestore/README.md created+242
...@@ -0,0 +1,242 @@
1# onestore
2
3An experimental native Rust library for OneNote revision stores (`.one` and
4`.onetoc2`). It reads committed object graphs, creates a small notebook without
5a template, appends property, text, paragraph, outline and formatting edits with a
6recoverable commit protocol, and interprets MS-ONE document structure, formatting, media, and historical pages.
7The storage gates are recorded in [PROGRESS.md](../../evidence/PROGRESS.md); document-model
8verification is recorded in [M6-ACCEPTANCE.md](../../evidence/M6-ACCEPTANCE.md). Concurrent-editing
9verification is recorded in [MILESTONE7.md](../../evidence/MILESTONE7.md). Crash recovery and the
10read/write HTML diagnostic editor are recorded in [MILESTONE8.md](../../evidence/MILESTONE8.md).
11Embedded SMB coordination, durable offline editing and document-growth acceptance
12are recorded in [MILESTONE9.md](../../evidence/MILESTONE9.md#document-writer-and-offline-acceptance).
13
14Use disposable copies for notebook editing. Header version notification now
15follows durable transaction publication, fixing a native cached-reader race.
16The [lost-reply acceptance](../../evidence/MILESTONE9.md#lost-reply-acceptance-with-version-notification-published-last)
17records the failure, reduced regression model, twelve-client repeat and cold
18OneNote verification of all 3200 editing intents.
19
20## Workspace
21
22`crates/onestore` contains the library, examples and integration tests. New Rust
23prototypes belong in sibling directories under `crates/` and depend on
24`onestore = { path = "../onestore" }`. The root manifest discovers these crates.
25The consumer boundary and API tradeoffs are recorded in [API-AUDIT.md](../../evidence/API-AUDIT.md).
26`crates/onestore-diagnostic` backs the [HTML diagnostic editor](../../evidence/DIAGNOSTIC.md).
27[`onestore-smb`](../onestore-smb/README.md) provides optional embedded network
28access; [`onestore-offline`](../onestore-offline/README.md) provides local
29SQLite persistence and reconnect reconciliation for text, insertion and formatting.
30Shared native fixtures, specifications, evidence and Python/VM tools stay at the
31repository root; `fuzz/` remains an independent cargo-fuzz workspace.
32
33Run Cargo commands from the root. Select `-p onestore` when working only on the
34library, or `--workspace` for checks across all crates. Example binary paths
35remain `target/debug/examples/…` for the native verification tools. The collaboration
36harness also accepts `--client-profile release`.
37
38## Supported surface
39
40| API | Contract |
41| --- | --- |
42| `Store`, `RevisionIndex`, `ResolvedRevision` | Parse storage, resolve revisions and reference graphs, expose roots and objects |
43| `PropertySets`, `Object::references` | Decode properties and ID streams while retaining raw values |
44| `Object::file_reference`, `Store::file_data` | Identify internal/external payloads and read internal payload bytes |
45| `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities |
46| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |
47| `create_table_of_contents` | Create ordered section entries from filenames and file identities |
48| `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references |
49| `replace_text`, `commit_text`, `commit_file_text` | Replace a UTF-16 range within one ordinary text run; publish text, run boundaries and modification time together |
50| `Insertion`, `PreparedEdit::insert` | Insert paragraphs into editable containers or positioned outlines into a page, retaining intent identities across rebases |
51| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
52| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
53| `read_file` | Read a snapshot under whole-file exclusion |
54| `read_snapshot` | Read a validated snapshot through fresh positioned I/O while the caller excludes maintenance |
55| `commit_file_property` | Lock, compare the source snapshot, append and flush, then publish the revision |
56| `CommitIo`, `commit_property_bytes` | Supply another storage backend with equivalent exclusion and ordered durability |
57
58Scalar edits accept encoded values and require the caller to maintain MS-ONE
59semantics. Text edits maintain run boundaries, inherit the insertion run's formatting,
60and promote legacy text to Unicode when needed. Explicit and body-derived
61navigation titles update in the same transaction; unsupported fields,
62protected objects and split surrogate pairs are
63rejected before writing. Appended snapshots cap revision dependency depth at 512
64while retaining historical revisions. TOC snapshots can remap encoded CompactIDs
65without changing their resolved references. Password-protected
66sections retain their encrypted structure and payloads; the library does not
67derive password keys or decrypt their pages.
68Insertions update child references, reference counts, modification times and automatic
69titles atomically. Paragraphs can be nested or inserted into table cells; outline
70coordinates use points. Retain the `Insertion` value for rebasing: creating another
71value creates different object identities. Duplicate insertion identities require
72reconciliation. Formatting accepts explicit attributes, preserves inherited values,
73and gives retired immutable styles zero current references while retaining history.
74Generated fields, protected targets and unsupported run-data boundary changes are
75rejected before publication. Local caches expose text, insertion and formatting edits;
76the [document-writer acceptance](../../evidence/MILESTONE9.md#document-writer-and-offline-acceptance)
77includes twelve mixed native/Rust clients, outages, lost replies and native revision retirement.
78
79External `.onebin` references identify payloads for the caller to obtain. Cloud
80FSSHTTP synchronization and a C ABI are outside the implemented surface.
81
82## Try it
83
84Requires Rust 1.97 or later for the verified build. Examples create new destinations
85and refuse to overwrite them. The Python tools require Python 3.10 or later and Pillow.
86
87```sh
88cargo run --example create_notebook -- /tmp/one-demo 'Hello from Rust.' 'Example Author'
89cargo run --example inventory -- /tmp/one-demo/synthetic.one
90cargo run --example inspect -- /tmp/one-demo/synthetic.one
91cargo run --example document -- /tmp/one-demo/synthetic.one /tmp/one-model
92python3 tools/notebook_report.py /tmp/one-demo /tmp/one-report --timezone America/Los_Angeles
93```
94
95A seeded text edit on a disposable copy records its page, UTF-16 range,
96replacement and outcome as JSON:
97
98```sh
99cargo run --example random_edit -- /tmp/one-demo/synthetic.one /tmp/edited.one 42
100```
101
102The report contains readable pages, document JSON, assets, source identities and
103coordinates. It preserves paragraph nesting, lists, tables, links and tags.
104Historical contexts, recycle-bin pages and default templates are represented
105separately. Native ink and structured equations retain their source data and
106appear explicitly as opaque content. The report is a reading view; its native
107PDF references supply the original canvas layout.
108
109Read and validate a snapshot before interpreting its graph:
110
111```rust,no_run
112use onestore::{read_file, RevisionIndex, Store};
113
114fn main() -> Result<(), Box<dyn std::error::Error>> {
115 let bytes = read_file("notebook/synthetic.one")?;
116 let store = Store::parse(&bytes)?;
117 if !store.checksum_mismatches.is_empty() {
118 return Err("Transaction checksum damage".into());
119 }
120 let index = RevisionIndex::parse(&store)?;
121 index.validate_current()?;
122 Ok(())
123}
124```
125
126`Store::parse` exposes checksum mismatches for diagnostic readers; the writer
127rejects them. The resolved graph borrows the snapshot. Select the object-space ID,
128object ID, and property from that graph, then pass those IDs, the same snapshot,
129and the replacement's encoded bytes to `commit_file_property`. The
130`edit_property` example demonstrates selection by JCID/property/expected bytes,
131with optional explicit IDs when conflict copies contain identical text.
132
133## Commit behavior
134
135```text
136exclusive lock → exact snapshot comparison
137 → append data → flush
138 → prepare header metadata → flush
139 → publish transaction counter → flush
140 → finish counter rollover → flush
141 → notify cached readers → flush → unlock
142```
143
144Stale snapshots fail before writing. Live readers must use equivalent exclusion.
145Native conflict creation can still expose cross-space references before their
146targets are saved; such snapshots must be rejected and reread while synchronization
147proceeds. `read_file` and `commit_file_property` serialize within the process because
148macOS SMB locks can be reentrant. The lock is nonblocking across processes;
149contention requires a fresh read and a later retry.
150
151| Error state | Meaning and caller action |
152| --- | --- |
153| `NotCommitted` | This edit was not published. Preparation bytes may exist. Reread before retrying. |
154| `Unknown` | Publication may have persisted despite the error. Reread and resolve the intended edit before retrying. |
155| `Committed` | Publication was durably acknowledged; counter cleanup or lock release failed. Reopen the committed result instead of replaying the edit. |
156
157At counter rollover, empty transactions make intermediate published counts valid.
158The highest changed byte is flushed before lower bytes are cleaned up. The
159255→256 and 65535→65536 boundaries and interrupted cleanup states have independent
160native acceptance captures. A no-op still flushes; a failed flush has an unknown
161durability outcome.
162
163The filesystem adapter uses whole-file locking. On macOS it acquires the lock
164as part of opening the file (`O_EXLOCK | O_NONBLOCK`): separate open and `flock`
165calls allowed overlapping exclusive holders and stranded server locks under
166multi-process SMB contention. `tools/smb_lock_race.py` reproduces that failure
167without notebook parsing or writing. On the tested macOS SMB mount,
168POSIX byte-range locks returned `ENOTSUP`; whole-file locks excluded native
169OneNote's lock ranges. This adapter serializes readers and writers during each
170operation; it does not reproduce native reader/writer concurrency. The
171[locking audit](../../evidence/LOCKING.md) records native coordination bytes, write-open share
172modes, and a working macOS SMB-specific byte-range lock probe. `sync_all` falls
173back to `fsync` on macOS only when `F_FULLFSYNC` is unsupported. Successful SMB FLUSH replies were observed on the
174wire. Correctness requires the backend to honor exclusion and ordered flushes.
175The evidence covers transport failures, not physical server power loss or every
176filesystem's lock implementation.
177
178For shared network notebooks, use the optional
179[`onestore-smb`](../onestore-smb/README.md) crate. It uses native share modes,
180shared reader guards, writer exclusion and fresh pathname identity checks without
181an OS-mounted share. Its [coordination acceptance](../../evidence/MILESTONE9.md) covers native
182maintenance, mixed readers/writers, reconnects and uncertain publication. The
183filesystem adapter retains its conservative locking; mounted-path freshness
184across native replacement is not established by that exclusion.
185
186## Verification
187
188```sh
189cargo test --all-targets
190cargo clippy --all-targets -- -D warnings
191python3 tools/verify-corpus.py
192python3 tools/verify-reader.py # requires Pillow and the local private corpus
193python3 tools/verify-writer.py
194python3 tools/verify-collaboration.py
195python3 tools/verify-document.py /path/to/copied/notebook /path/to/native/read
196```
197
198The frozen private corpus is excluded from version control. Its verifier checks 26 pages,
199581 text objects, hyperlink targets, exact image bytes, and native image conversions.
200Synthetic corpus manifests bind binary fixtures to independent native XML and
201attachment captures. `verify-corpus.py` also requires that private corpus.
202
203Storage tests exercise malformed references, deep properties, historical revision
204preservation, short I/O, stale snapshots, counter tears, and every injected I/O
205failure point at ordinary and rollover commits. The crash model persists arbitrary
206subsets of unflushed bytes and is shared with the stateful commit fuzzer.
207
208```sh
209cargo +nightly fuzz run revisions -- -max_total_time=120 -max_len=262144 -rss_limit_mb=2048
210cargo +nightly fuzz run commit -- -max_total_time=300 -max_len=4096 -rss_limit_mb=2048
211```
212
213Fuzz targets cover storage, properties, revisions, scalar edits, creation, and
214multi-edit interrupted commits. Seed the revision target with native `.one` files
215using symlinks under `fuzz/corpus/revisions`; empty seed directories mostly exercise
216header rejection. Bounded run counts and native findings live in `PROGRESS.md`.
217
218The document fuzzer mutates native property streams, repairs their checksums, and
219traverses every retained revision and resolved text run. Its public seeds live in
220the source target; private seeds are supplied only at runtime. Native edit-history
221tests compare independently generated operations, OneNote XML and the Rust model;
222failed histories can be replayed and shrunk in fresh disposable clones. The
223document feature matrix is in [FEATURES.md](../../evidence/FEATURES.md), and the milestone's
224acceptance contract is in [MILESTONE6.md](../../evidence/MILESTONE6.md).
225
226The stage-5 gate uses OneNote 2010 build 14.0.7015.1000 on Windows 7, a macOS SMB
227mount, and Samba on zenith. [The collaboration corpus](../../corpus/collaboration/round-01)
228captures native lock contention, different-paragraph merging, same-paragraph
229conflicts, offline editing/reconnection, and lost successful FLUSH replies at
230preparation, publication, and counter cleanup. Each final notebook was reopened
231from a fresh native cache. Competing text survives as native conflict pages;
232OneNote's COM hierarchy omits those pages, so the offline case also includes a
233native UI capture. Full-page COM updates produced an extra conflict copy during
234the disjoint case and two recorded geometry changes; the verifier checks those
235exact changes as well as retained image, ink, table, and attachment content.
236
237`tools/native/profile.ps1` parks/restores the personal native profile and cache;
238`cold.ps1`, `read.ps1`, and `collaborate.ps1` operate on disposable test roots.
239`tools/zenith-locks` observes server locks. `tools/smb-proxy.py` traces and interrupts
240a dedicated loopback test session; its control JSON selects the successful response
241and occurrence to withhold. The captured trace and result files are the regression
242oracle; replaying the native experiments requires the supplied Windows/share setup.
crates/onestore/examples/concurrent_client.rs+10-193
...@@ -1,18 +1,9 @@...@@ -1,18 +1,9 @@
1#[path = "support/concurrent.rs"]
2mod concurrent;
1#[path = "../src/flush.rs"]3#[path = "../src/flush.rs"]
2mod flush;4mod flush;
35
4use onestore::{6use std::{env, fs, io::Write};
5 CommitState, ExGuid, RevisionIndex, Store,
6 document::{Document, Kind},
7};
8use serde_json::json;
9use std::{
10 env, fs,
11 io::{self, Write},
12 path::Path,
13 thread,
14 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
15};
167
17fn main() -> Result<(), Box<dyn std::error::Error>> {8fn main() -> Result<(), Box<dyn std::error::Error>> {
18 let args: Vec<_> = env::args().skip(1).collect();9 let args: Vec<_> = env::args().skip(1).collect();
...@@ -27,185 +18,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -27,185 +18,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
27 flush::flush(&file)?;18 flush::flush(&file)?;
28 return Ok(());19 return Ok(());
29 }20 }
30 if args.len() != 7 || !["read", "write", "edit"].contains(&args[0].as_str()) {21 concurrent::run(
31 return Err("Usage: concurrent_client init FILE | read|write|edit FILE ACTOR OPERATIONS START_FILE STOP_FILE SEED".into());22 &args,
32 }23 |path| onestore::read_file(path),
33 let mut random: u64 = args[6].parse()?;24 |path, source, space, object, range, replacement| {
34 let operations: usize = args[3].parse()?;25 onestore::commit_file_text(path, source, space, object, range, replacement)
35 if operations == 0 {26 },
36 return Err("Choose at least one operation.".into());27 )
37 }
38 let deadline = Instant::now() + Duration::from_secs(600);
39 let mut output = io::stdout().lock();
40 let mut log = |event: serde_json::Value| -> io::Result<()> {
41 writeln!(output, "{event}")?;
42 output.flush()
43 };
44 log(json!({"event": "ready", "pid": std::process::id(), "actor": args[2]}))?;
45 while !Path::new(&args[4]).exists() {
46 if Instant::now() > deadline {
47 return Err("Start barrier timed out.".into());
48 }
49 thread::sleep(Duration::from_millis(5));
50 }
51 let mut completed = 0;
52 let mut attempts = 0;
53 while completed < operations || (args[0] == "read" && !Path::new(&args[5]).exists()) {
54 if Instant::now() > deadline {
55 return Err("Concurrent client timed out.".into());
56 }
57 attempts += 1;
58 random = random
59 .wrapping_mul(6364136223846793005)
60 .wrapping_add(1442695040888963407);
61 thread::sleep(Duration::from_millis((random >> 32) % 7));
62 let started = SystemTime::now().duration_since(UNIX_EPOCH)?.as_micros();
63 let source = match onestore::read_file(&args[1]) {
64 Ok(source) => source,
65 Err(error)
66 if [
67 io::ErrorKind::WouldBlock,
68 io::ErrorKind::PermissionDenied,
69 io::ErrorKind::NotFound,
70 ]
71 .contains(&error.kind()) =>
72 {
73 log(
74 json!({"event": "read_busy", "attempt": attempts, "kind": format!("{:?}", error.kind())}),
75 )?;
76 thread::sleep(Duration::from_millis(100));
77 continue;
78 }
79 Err(error) => {
80 log(
81 json!({"event": "read_error", "attempt": attempts, "kind": format!("{:?}", error.kind())}),
82 )?;
83 return Err(error.into());
84 }
85 };
86 let preserve = |error: onestore::Error| {
87 let path = Path::new(&args[4])
88 .parent()
89 .unwrap()
90 .join(format!("invalid-{}-{attempts}.one", std::process::id()));
91 if let Err(failure) = fs::write(&path, &source) {
92 eprintln!(
93 "Could not save invalid snapshot {}: {failure}",
94 path.display()
95 );
96 }
97 error
98 };
99 let store = Store::parse(&source).map_err(preserve)?;
100 if !store.checksum_mismatches.is_empty() {
101 return Err(preserve(onestore::Error {
102 offset: store.checksum_mismatches[0],
103 message: "A reader observed transaction checksum damage.",
104 })
105 .into());
106 }
107 let index = RevisionIndex::parse(&store).map_err(preserve)?;
108 index.validate_current().map_err(preserve)?;
109 let document = Document::parse(&index).map_err(preserve)?;
110 let mut targets = Vec::new();
111 for (sid, page) in document.pages().map_err(preserve)? {
112 let space = &document.spaces[&sid];
113 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
114 let mut pending = vec![page];
115 let mut seen = std::collections::BTreeSet::new();
116 while let Some(oid) = pending.pop() {
117 if !seen.insert(oid) {
118 continue;
119 }
120 let node = &revision.nodes[&oid];
121 pending.extend(
122 node.children
123 .iter()
124 .chain(&node.content)
125 .chain(&node.structure)
126 .copied(),
127 );
128 if let Kind::RichText { text, .. } = &node.kind
129 && text.starts_with("Concurrent edits:")
130 {
131 revision.text_runs(oid).map_err(preserve)?;
132 targets.push((sid, oid, text));
133 }
134 }
135 }
136 let [(sid, oid, text)] = targets.as_slice() else {
137 return Err(preserve(onestore::Error {
138 offset: 0,
139 message: "Expected one concurrent-edit paragraph.",
140 })
141 .into());
142 };
143 let read_finished = SystemTime::now().duration_since(UNIX_EPOCH)?.as_micros();
144 log(
145 json!({"event": "read", "attempt": attempts, "started_us": started, "finished_us": read_finished,
146 "transaction": store.header.transaction_count, "text": text}),
147 )?;
148 if args[0] == "read" {
149 completed += 1;
150 continue;
151 }
152 let token = format!(" [{}:{}]", args[2], completed);
153 let offset = u32::try_from(text.encode_utf16().count())?;
154 let mut range = offset..offset;
155 let mut replacement = token.clone();
156 if args[0] == "edit" {
157 let prefix = "Concurrent edits:";
158 let mut boundaries = vec![u32::try_from(prefix.encode_utf16().count())?];
159 for character in text[prefix.len()..].chars() {
160 boundaries.push(boundaries.last().unwrap() + character.len_utf16() as u32);
161 }
162 let first = ((random >> 16) % boundaries.len() as u64) as usize;
163 let second = ((random >> 40) % boundaries.len() as u64) as usize;
164 range = boundaries[first.min(second)]..boundaries[first.max(second)];
165 replacement = format!(" café 🦀{token}");
166 }
167 log(
168 json!({"event": "intent", "attempt": attempts, "operation": completed,
169 "source_transaction": store.header.transaction_count, "before": text,
170 "range": [range.start, range.end], "replacement": replacement, "token": token}),
171 )?;
172 thread::sleep(Duration::from_millis((random >> 48) % 13));
173 let commit_started = SystemTime::now().duration_since(UNIX_EPOCH)?.as_micros();
174 let result = onestore::commit_file_text(&args[1], &source, *sid, *oid, range, &replacement);
175 let finished = SystemTime::now().duration_since(UNIX_EPOCH)?.as_micros();
176 match result {
177 Ok(()) => {
178 log(
179 json!({"event": "commit", "attempt": attempts, "operation": completed, "token": token,
180 "started_us": commit_started, "finished_us": finished, "source_transaction": store.header.transaction_count}),
181 )?;
182 completed += 1;
183 }
184 Err(error)
185 if error.state == CommitState::NotCommitted
186 && [
187 io::ErrorKind::WouldBlock,
188 io::ErrorKind::ResourceBusy,
189 io::ErrorKind::PermissionDenied,
190 io::ErrorKind::NotFound,
191 ]
192 .contains(&error.error.kind()) =>
193 {
194 log(
195 json!({"event": "retry", "attempt": attempts, "started_us": commit_started,
196 "finished_us": finished, "kind": format!("{:?}", error.error.kind())}),
197 )?;
198 }
199 Err(error) => {
200 log(
201 json!({"event": "commit_error", "attempt": attempts, "operation": completed,
202 "token": token, "state": format!("{:?}", error.state), "kind": format!("{:?}", error.error.kind()),
203 "started_us": commit_started, "finished_us": finished}),
204 )?;
205 return Err(error.into());
206 }
207 }
208 }
209 log(json!({"event": "done", "completed": completed, "attempts": attempts}))?;
210 Ok(())
211}28}
crates/onestore/examples/document.rs+19-7
...@@ -2,7 +2,22 @@ use onestore::{...@@ -2,7 +2,22 @@ use onestore::{
2 FileDataReference, RevisionIndex, Store,2 FileDataReference, RevisionIndex, Store,
3 document::{Document, Kind},3 document::{Document, Kind},
4};4};
5use std::{collections::BTreeSet, env, fs, io, path::PathBuf};5use std::{
6 collections::BTreeSet,
7 env, fs,
8 io::{self, BufWriter, Write},
9 path::{Path, PathBuf},
10};
11
12fn write_json(
13 path: impl AsRef<Path>,
14 value: &impl serde::Serialize,
15) -> Result<(), Box<dyn std::error::Error>> {
16 let mut output = BufWriter::new(fs::File::create(path)?);
17 serde_json::to_writer(&mut output, value)?;
18 output.flush()?;
19 Ok(())
20}
621
7fn main() -> Result<(), Box<dyn std::error::Error>> {22fn main() -> Result<(), Box<dyn std::error::Error>> {
8 let mut args = env::args_os().skip(1);23 let mut args = env::args_os().skip(1);
...@@ -38,7 +53,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -38,7 +53,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
38 assets.push(serde_json::json!({"reference": FileDataReference::Internal(*guid), "path": path}));53 assets.push(serde_json::json!({"reference": FileDataReference::Internal(*guid), "path": path}));
39 }54 }
40 }55 }
41 serde_json::to_writer(fs::File::create(destination.join("assets.json"))?, &assets)?;56 write_json(destination.join("assets.json"), &assets)?;
42 let mut text = std::collections::BTreeMap::new();57 let mut text = std::collections::BTreeMap::new();
43 for (sid, space) in &document.spaces {58 for (sid, space) in &document.spaces {
44 let mut revisions = std::collections::BTreeMap::new();59 let mut revisions = std::collections::BTreeMap::new();
...@@ -53,11 +68,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -53,11 +68,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
53 }68 }
54 text.insert(*sid, revisions);69 text.insert(*sid, revisions);
55 }70 }
56 serde_json::to_writer(fs::File::create(destination.join("text.json"))?, &text)?;71 write_json(destination.join("text.json"), &text)?;
57 serde_json::to_writer(72 write_json(destination.join("document.json"), &document)?;
58 fs::File::create(destination.join("document.json"))?,
59 &document,
60 )?;
61 } else {73 } else {
62 serde_json::to_writer(io::stdout().lock(), &document)?;74 serde_json::to_writer(io::stdout().lock(), &document)?;
63 }75 }
crates/onestore/examples/insert.rs created+52
...@@ -0,0 +1,52 @@
1#[path = "../src/flush.rs"]
2mod flush;
3
4use onestore::{Insertion, PreparedEdit};
5use std::{fs, io::Write};
6
7fn main() -> Result<(), Box<dyn std::error::Error>> {
8 let args: Vec<_> = std::env::args().skip(1).collect();
9 let usage = "Usage: insert paragraph INPUT OUTPUT|--in-place SPACE PARENT BEFORE|- TEXT AUTHOR\n insert outline INPUT OUTPUT|--in-place SPACE PAGE X Y TEXT AUTHOR";
10 let insertion = match args.first().map(String::as_str) {
11 Some("paragraph") if args.len() == 8 => Insertion::paragraph(
12 args[4].parse()?,
13 if args[5] == "-" {
14 None
15 } else {
16 Some(args[5].parse()?)
17 },
18 &args[6],
19 &args[7],
20 )?,
21 Some("outline") if args.len() == 9 => Insertion::outline(
22 args[4].parse()?,
23 args[5].parse()?,
24 args[6].parse()?,
25 &args[7],
26 &args[8],
27 )?,
28 _ => return Err(usage.into()),
29 };
30 let source = onestore::read_file(&args[1])?;
31 let prepared = PreparedEdit::insert(&source, args[3].parse()?, &insertion)?;
32 let mut record = serde_json::json!({"intent": insertion, "object": insertion.object(), "text_object": insertion.text_object()});
33 if args[2] == "--in-place" {
34 if let Err(error) = prepared.commit_file(&args[1]) {
35 record["state"] = format!("{:?}", error.state).into();
36 record["error"] = error.error.to_string().into();
37 println!("{record}");
38 std::process::exit(2);
39 }
40 record["state"] = "Committed".into();
41 } else {
42 let mut file = fs::OpenOptions::new()
43 .write(true)
44 .create_new(true)
45 .open(&args[2])?;
46 file.write_all(prepared.as_bytes())?;
47 flush::flush(&file)?;
48 record["state"] = "Created".into();
49 }
50 println!("{record}");
51 Ok(())
52}
crates/onestore/examples/maintenance_fixture.rs created+63
...@@ -0,0 +1,63 @@
1use onestore::{
2 ExGuid, RevisionIndex, Store,
3 document::{Document, Kind},
4};
5use std::{fs, io, path::PathBuf};
6
7fn main() -> Result<(), Box<dyn std::error::Error>> {
8 let mut args = std::env::args_os().skip(1);
9 let output = PathBuf::from(
10 args.next()
11 .ok_or(io::Error::from(io::ErrorKind::InvalidInput))?,
12 );
13 let current = args
14 .next()
15 .map(|value| value.into_string())
16 .transpose()
17 .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?;
18 if args.next().is_some() {
19 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
20 }
21 fs::create_dir(&output)?;
22 let mut bytes = onestore::create_section("synthetic.one", "Maintenance baseline.", "Fixture")?;
23 for revision in 0..25 {
24 let store = Store::parse(&bytes)?;
25 let index = RevisionIndex::parse(&store)?;
26 let document = Document::parse(&index)?;
27 let (sid, oid, end) = document
28 .spaces
29 .iter()
30 .find_map(|(sid, space)| {
31 space.revisions[&space.contexts[&ExGuid::default()]]
32 .nodes
33 .iter()
34 .find_map(|(oid, node)| {
35 if let Kind::RichText { text, .. } = &node.kind {
36 return Some((*sid, *oid, text.encode_utf16().count() as u32));
37 }
38 None
39 })
40 })
41 .ok_or(io::Error::from(io::ErrorKind::InvalidData))?;
42 let text = if revision == 24 {
43 current
44 .clone()
45 .unwrap_or_else(|| "Maintenance current.".to_owned())
46 } else {
47 format!("Revision {revision}: {}", "x".repeat(65536))
48 };
49 bytes = onestore::replace_text(&bytes, sid, oid, 0..end, &text)?;
50 }
51 let store = Store::parse(&bytes)?;
52 let toc = onestore::create_table_of_contents(
53 "Open Notebook.onetoc2",
54 &[("synthetic.one", store.header.file_id)],
55 )?;
56 fs::write(output.join("synthetic.one"), &bytes)?;
57 fs::write(output.join("Open Notebook.onetoc2"), toc)?;
58 println!(
59 "{}",
60 serde_json::json!({"bytes":bytes.len(),"transactions":store.header.transaction_count})
61 );
62 Ok(())
63}
crates/onestore/examples/power_loss.rs+17-64
...@@ -1,74 +1,27 @@...@@ -1,74 +1,27 @@
1#[path = "../tests/support/current.rs"]
2mod current;
3use current::current;
4
5#[path = "../tests/support/trace.rs"]
6mod trace;
7use trace::{Event, Trace};
8
1#[path = "../tests/support/checkpoint.rs"]9#[path = "../tests/support/checkpoint.rs"]
2mod checkpoint;10mod checkpoint;
311
4use onestore::{12use onestore::{
5 CommitIo, ExGuid, RevisionIndex, Store,13 ExGuid, RevisionIndex, Store,
6 document::{Document, Kind},14 document::{Document, Kind},
7};15};
8use std::{collections::BTreeMap, fs, io, path::PathBuf};16use std::{collections::BTreeMap, fs, path::PathBuf};
9
10enum Event {
11 Write(usize, Vec<u8>),
12 Flush,
13}
14
15struct Trace {
16 bytes: Vec<u8>,
17 events: Vec<Event>,
18}
19
20impl CommitIo for Trace {
21 fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> {
22 let offset = offset as usize;
23 let count = output.len().min(self.bytes.len().saturating_sub(offset));
24 output[..count].copy_from_slice(&self.bytes[offset..offset + count]);
25 Ok(count)
26 }
27 fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> {
28 let offset = offset as usize;
29 let count = bytes.len().min(4096);
30 self.bytes.resize(self.bytes.len().max(offset + count), 0);
31 self.bytes[offset..offset + count].copy_from_slice(&bytes[..count]);
32 self.events
33 .push(Event::Write(offset, bytes[..count].to_vec()));
34 Ok(count)
35 }
36 fn flush(&mut self) -> io::Result<()> {
37 self.events.push(Event::Flush);
38 Ok(())
39 }
40}
41
42fn current(bytes: &[u8]) -> BTreeMap<ExGuid, String> {
43 let store = Store::parse(bytes).unwrap();
44 assert!(store.checksum_mismatches.is_empty());
45 let index = RevisionIndex::parse(&store).unwrap();
46 index.validate_current().unwrap();
47 Document::parse(&index).unwrap();
48 index
49 .spaces
50 .iter()
51 .map(|(sid, space)| {
52 let rid = space.labels[&(ExGuid::default(), 1)];
53 let revision = index.resolve(*sid, rid).unwrap();
54 for object in revision.objects.values() {
55 if let Some(onestore::FileDataReference::Internal(guid)) =
56 object.file_reference().unwrap()
57 {
58 store.file_data(guid).unwrap();
59 }
60 }
61 (*sid, format!("{revision:?}"))
62 })
63 .collect()
64}
6517
66fn main() -> Result<(), Box<dyn std::error::Error>> {18fn main() -> Result<(), Box<dyn std::error::Error>> {
67 let destination = PathBuf::from(19 let mut args = std::env::args_os().skip(1);
68 std::env::args_os()20 let destination = PathBuf::from(args.next().ok_or("Provide a new evidence directory")?);
69 .nth(1)21 let option = args.next();
70 .ok_or("Provide a new evidence directory")?,22 if option.as_deref().is_some_and(|flag| flag != "--toc") || args.next().is_some() {
71 );23 return Err("Usage: power_loss NEW_DIRECTORY [--toc]".into());
24 }
72 fs::create_dir(&destination)?;25 fs::create_dir(&destination)?;
73 let fixtures = [26 let fixtures = [
74 (27 (
...@@ -107,7 +60,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -107,7 +60,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
107 let mut records = Vec::new();60 let mut records = Vec::new();
108 let mut saved = BTreeMap::new();61 let mut saved = BTreeMap::new();
109 for (name, path) in fixtures {62 for (name, path) in fixtures {
110 if std::env::args().nth(2).as_deref() == Some("--toc") && !name.starts_with("toc") {63 if option.is_some() && !name.starts_with("toc") {
111 continue;64 continue;
112 }65 }
113 println!("Checking {name}");66 println!("Checking {name}");
crates/onestore/examples/support/concurrent.rs created+258
...@@ -0,0 +1,258 @@
1use onestore::{
2 CommitState, ExGuid, RevisionIndex, Store,
3 document::{Document, Kind},
4};
5use serde_json::json;
6use std::{
7 fs,
8 io::{self, Write},
9 path::Path,
10 thread,
11 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
12};
13
14pub fn document_view(bytes: &[u8]) -> Result<serde_json::Value, onestore::Error> {
15 let store = Store::parse(bytes)?;
16 let index = RevisionIndex::parse(&store)?;
17 index.validate_current()?;
18 let document = Document::parse(&index)?;
19 let mut texts = serde_json::Map::new();
20 for (sid, _) in document.pages()? {
21 let space = &document.spaces[&sid];
22 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
23 for (id, node) in &revision.nodes {
24 if let Kind::RichText { text, .. } = &node.kind
25 && text.starts_with("Document w")
26 {
27 let runs=revision.text_runs(*id)?.into_iter().map(|run|json!({"text":run.text,"bold":run.format.bold.unwrap_or(false),"size":run.format.font_size,"color":run.format.color.unwrap_or(0xff000000)})).collect::<Vec<_>>();
28 texts.insert(id.to_string(), json!({"text":text,"runs":runs}));
29 }
30 }
31 }
32 Ok(texts.into())
33}
34
35pub fn run(
36 args: &[String],
37 mut read: impl FnMut(&str) -> io::Result<Vec<u8>>,
38 mut commit: impl FnMut(
39 &str,
40 &[u8],
41 ExGuid,
42 ExGuid,
43 std::ops::Range<u32>,
44 &str,
45 ) -> Result<(), onestore::CommitError>,
46) -> Result<(), Box<dyn std::error::Error>> {
47 if args.len() != 7 || !["read", "write", "edit"].contains(&args[0].as_str()) {
48 return Err(
49 "Expected read|write|edit FILE ACTOR OPERATIONS START_FILE STOP_FILE SEED.".into(),
50 );
51 }
52 let mut random: u64 = args[6].parse()?;
53 let operations: usize = args[3].parse()?;
54 if operations == 0 {
55 return Err("Choose at least one operation.".into());
56 }
57 let timeout = match std::env::var("ONESTORE_CLIENT_TIMEOUT_MS") {
58 Ok(value) => value.parse::<u64>()?,
59 Err(std::env::VarError::NotPresent) => 600_000,
60 Err(error) => return Err(error.into()),
61 };
62 if timeout == 0 {
63 return Err("Choose a positive client timeout.".into());
64 }
65 let deadline = Instant::now()
66 .checked_add(Duration::from_millis(timeout))
67 .ok_or("Client timeout exceeds the clock range.")?;
68 let mut output = io::stdout().lock();
69 let mut log = |event: serde_json::Value| -> io::Result<()> {
70 writeln!(output, "{event}")?;
71 output.flush()
72 };
73 log(json!({"event": "ready", "pid": std::process::id(), "actor": args[2]}))?;
74 while !Path::new(&args[4]).exists() {
75 if Instant::now() > deadline {
76 return Err("Start barrier timed out.".into());
77 }
78 thread::sleep(Duration::from_millis(5));
79 }
80 let documents = std::env::var_os("ONESTORE_OFFLINE_DOCUMENTS").is_some();
81 let maintenance = std::env::var_os("ONESTORE_MAINTENANCE_DIR").map(std::path::PathBuf::from);
82 let mut completed = 0;
83 let mut attempts = 0;
84 while completed < operations || (args[0] == "read" && !Path::new(&args[5]).exists()) {
85 if Instant::now() > deadline {
86 return Err("Concurrent client timed out.".into());
87 }
88 if let Some(control) = &maintenance
89 && !control.join("resume").exists()
90 && ((args[0] != "read" && completed == operations / 2)
91 || (args[0] == "read" && control.join("pause").exists()))
92 {
93 fs::write(control.join(format!("paused-{}", args[2])), b"paused")?;
94 log(json!({"event": "paused", "completed": completed}))?;
95 while !control.join("resume").exists() {
96 if Instant::now() > deadline {
97 return Err("Maintenance pause timed out.".into());
98 }
99 thread::sleep(Duration::from_millis(10));
100 }
101 log(json!({"event": "resumed", "completed": completed}))?;
102 }
103 attempts += 1;
104 random = random
105 .wrapping_mul(6364136223846793005)
106 .wrapping_add(1442695040888963407);
107 thread::sleep(Duration::from_millis((random >> 32) % 7));
108 let started = SystemTime::now().duration_since(UNIX_EPOCH)?.as_micros();
109 let source = match read(&args[1]) {
110 Ok(source) => source,
111 Err(error)
112 if [
113 io::ErrorKind::WouldBlock,
114 io::ErrorKind::ResourceBusy,
115 io::ErrorKind::PermissionDenied,
116 io::ErrorKind::NotFound,
117 ]
118 .contains(&error.kind()) =>
119 {
120 log(
121 json!({"event": "read_busy", "attempt": attempts, "kind": format!("{:?}", error.kind())}),
122 )?;
123 thread::sleep(Duration::from_millis(100));
124 continue;
125 }
126 Err(error) => {
127 log(
128 json!({"event": "read_error", "attempt": attempts, "kind": format!("{:?}", error.kind())}),
129 )?;
130 return Err(error.into());
131 }
132 };
133 let preserve = |error: onestore::Error| {
134 let path = Path::new(&args[4])
135 .parent()
136 .unwrap()
137 .join(format!("invalid-{}-{attempts}.one", std::process::id()));
138 if let Err(failure) = fs::write(&path, &source) {
139 eprintln!(
140 "Could not save invalid snapshot {}: {failure}",
141 path.display()
142 );
143 }
144 error
145 };
146 let store = Store::parse(&source).map_err(preserve)?;
147 if !store.checksum_mismatches.is_empty() {
148 return Err(preserve(onestore::Error {
149 offset: store.checksum_mismatches[0],
150 message: "A reader observed transaction checksum damage.",
151 })
152 .into());
153 }
154 let index = RevisionIndex::parse(&store).map_err(preserve)?;
155 index.validate_current().map_err(preserve)?;
156 let document = Document::parse(&index).map_err(preserve)?;
157 let mut targets = Vec::new();
158 for (sid, page) in document.pages().map_err(preserve)? {
159 let space = &document.spaces[&sid];
160 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
161 let mut pending = vec![page];
162 let mut seen = std::collections::BTreeSet::new();
163 while let Some(oid) = pending.pop() {
164 if !seen.insert(oid) {
165 continue;
166 }
167 let node = &revision.nodes[&oid];
168 pending.extend(
169 node.children
170 .iter()
171 .chain(&node.content)
172 .chain(&node.structure)
173 .copied(),
174 );
175 if let Kind::RichText { text, .. } = &node.kind
176 && text.starts_with("Concurrent edits:")
177 {
178 revision.text_runs(oid).map_err(preserve)?;
179 targets.push((sid, oid, text));
180 }
181 }
182 }
183 let [(sid, oid, text)] = targets.as_slice() else {
184 return Err(preserve(onestore::Error {
185 offset: 0,
186 message: "Expected one concurrent-edit paragraph.",
187 })
188 .into());
189 };
190 let read_finished = SystemTime::now().duration_since(UNIX_EPOCH)?.as_micros();
191 log(
192 json!({"event": "read", "attempt": attempts, "started_us": started, "finished_us": read_finished,
193 "transaction": store.header.transaction_count, "text": text, "documents":if documents {Some(document_view(&source).map_err(preserve)?)}else{None}}),
194 )?;
195 if args[0] == "read" {
196 completed += 1;
197 continue;
198 }
199 let token = format!(" [{}:{}]", args[2], completed);
200 let offset = u32::try_from(text.encode_utf16().count())?;
201 let mut range = offset..offset;
202 let mut replacement = token.clone();
203 if args[0] == "edit" {
204 let prefix = "Concurrent edits:";
205 let mut boundaries = vec![u32::try_from(prefix.encode_utf16().count())?];
206 for character in text[prefix.len()..].chars() {
207 boundaries.push(boundaries.last().unwrap() + character.len_utf16() as u32);
208 }
209 let first = ((random >> 16) % boundaries.len() as u64) as usize;
210 let second = ((random >> 40) % boundaries.len() as u64) as usize;
211 range = boundaries[first.min(second)]..boundaries[first.max(second)];
212 replacement = format!(" café 🦀{token}");
213 }
214 log(
215 json!({"event": "intent", "attempt": attempts, "operation": completed,
216 "source_transaction": store.header.transaction_count, "before": text,
217 "range": [range.start, range.end], "replacement": replacement, "token": token}),
218 )?;
219 thread::sleep(Duration::from_millis((random >> 48) % 13));
220 let commit_started = SystemTime::now().duration_since(UNIX_EPOCH)?.as_micros();
221 let result = commit(&args[1], &source, *sid, *oid, range, &replacement);
222 let finished = SystemTime::now().duration_since(UNIX_EPOCH)?.as_micros();
223 match result {
224 Ok(()) => {
225 log(
226 json!({"event": "commit", "attempt": attempts, "operation": completed, "token": token,
227 "started_us": commit_started, "finished_us": finished, "source_transaction": store.header.transaction_count}),
228 )?;
229 completed += 1;
230 }
231 Err(error)
232 if error.state == CommitState::NotCommitted
233 && [
234 io::ErrorKind::WouldBlock,
235 io::ErrorKind::ResourceBusy,
236 io::ErrorKind::PermissionDenied,
237 io::ErrorKind::NotFound,
238 ]
239 .contains(&error.error.kind()) =>
240 {
241 log(
242 json!({"event": "retry", "attempt": attempts, "started_us": commit_started,
243 "finished_us": finished, "kind": format!("{:?}", error.error.kind())}),
244 )?;
245 }
246 Err(error) => {
247 log(
248 json!({"event": "commit_error", "attempt": attempts, "operation": completed,
249 "token": token, "state": format!("{:?}", error.state), "kind": format!("{:?}", error.error.kind()),
250 "started_us": commit_started, "finished_us": finished}),
251 )?;
252 return Err(error.into());
253 }
254 }
255 }
256 log(json!({"event": "done", "completed": completed, "attempts": attempts}))?;
257 Ok(())
258}
crates/onestore/src/commit.rs+147-35
...@@ -153,14 +153,113 @@ pub fn commit_text(...@@ -153,14 +153,113 @@ pub fn commit_text(
153 range: std::ops::Range<u32>,153 range: std::ops::Range<u32>,
154 replacement: &str,154 replacement: &str,
155) -> Result<(), CommitError> {155) -> Result<(), CommitError> {
156 let written =156 PreparedEdit::text(source, space, object, range, replacement)
157 crate::replace_text(source, space, object, range, replacement).map_err(|error| {157 .map_err(|error| CommitError {
158 CommitError {158 state: CommitState::NotCommitted,
159 state: CommitState::NotCommitted,159 error: io::Error::new(ErrorKind::InvalidData, error),
160 error: io::Error::new(ErrorKind::InvalidData, error),160 })?
161 }161 .commit(io)
162}
163
164/// An immutable writer-generated transition tied to its original snapshot.
165/// Persist intended revision identities from `as_bytes` before publishing an offline edit.
166/// Missing identities after native maintenance do not prove an edit was never published.
167pub struct PreparedEdit<'a> {
168 source: &'a [u8],
169 written: Vec<u8>,
170}
171
172impl<'a> PreparedEdit<'a> {
173 /// Prepares an insertion and its dependent metadata in one revision, without I/O.
174 pub fn insert(
175 source: &'a [u8],
176 space: ExGuid,
177 insertion: &crate::Insertion,
178 ) -> Result<Self, crate::Error> {
179 Ok(Self {
180 source,
181 written: insertion.apply(source, space)?,
182 })
183 }
184
185 /// Validates and prepares a text edit without I/O, with `replace_text` semantics.
186 pub fn text(
187 source: &'a [u8],
188 space: ExGuid,
189 object: ExGuid,
190 range: std::ops::Range<u32>,
191 replacement: &str,
192 ) -> Result<Self, crate::Error> {
193 Ok(Self {
194 source,
195 written: crate::replace_text(source, space, object, range, replacement)?,
196 })
197 }
198
199 /// Changes character formatting over a UTF-16 range, preserving unselected runs and styles.
200 /// A zero-length range sets the insertion style only when the paragraph is empty.
201 /// Fields, associated run objects and boundaries splitting preserved run data are rejected.
202 pub fn format(
203 source: &'a [u8],
204 space: ExGuid,
205 object: ExGuid,
206 range: std::ops::Range<u32>,
207 attributes: &[crate::TextAttribute],
208 ) -> Result<Self, crate::Error> {
209 Ok(Self {
210 source,
211 written: crate::formatting::format_text(source, space, object, range, attributes)?,
212 })
213 }
214
215 /// The exact complete image this edit will publish; identities do not regenerate on commit.
216 /// Do not overwrite a live notebook with this image; use `commit` under exclusion.
217 pub fn as_bytes(&self) -> &[u8] {
218 &self.written
219 }
220
221 /// Publishes these prepared bytes after comparing the entire original snapshot.
222 /// The caller must retain OneNote-compatible exclusion through the returned outcome.
223 pub fn commit(&self, io: &mut impl CommitIo) -> Result<(), CommitError> {
224 commit_bytes(io, self.source, &self.written)
225 }
226
227 /// Publishes these exact bytes through the conservative whole-file filesystem adapter.
228 /// A changed source returns ResourceBusy; an uncertain outcome must be reconciled before replay.
229 #[cfg(any(unix, windows))]
230 pub fn commit_file(&self, path: impl AsRef<Path>) -> Result<(), CommitError> {
231 let mut io = FileIo::open(path, true).map_err(|error| CommitError {
232 state: CommitState::NotCommitted,
233 error,
162 })?;234 })?;
163 commit_bytes(io, source, &written)235 let result = self.commit(&mut io);
236 io.finish(result)
237 }
238}
239
240/// Compares and flushes a snapshot, then refreshes its header version metadata.
241/// No revision is added; reread before using the snapshot for another physical commit.
242/// The caller must hold OneNote-compatible exclusion and independently establish which
243/// intents the snapshot contains. A successful read alone is not a durable acknowledgement.
244pub fn confirm_snapshot(io: &mut impl CommitIo, source: &[u8]) -> Result<(), CommitError> {
245 let mut state = CommitState::NotCommitted;
246 let result = (|| -> io::Result<()> {
247 let header = crate::Header::parse(source).map_err(io::Error::other)?;
248 let generation = header
249 .generation
250 .checked_add(1)
251 .ok_or(ErrorKind::InvalidData)?;
252 let mut version = [0; 40];
253 version[..16].copy_from_slice(&crate::write::fresh_guid().map_err(io::Error::other)?);
254 version[16..24].copy_from_slice(&generation.to_le_bytes());
255 version[24..].copy_from_slice(&crate::write::fresh_guid().map_err(io::Error::other)?);
256 compare_snapshot(io, source)?;
257 state = CommitState::Unknown;
258 io.flush()?;
259 write_all(io, 212, &version)?;
260 io.flush()
261 })();
262 result.map_err(|error| CommitError { state, error })
164}263}
165264
166/// The caller must hold OneNote-compatible exclusion for the entire operation.265/// The caller must hold OneNote-compatible exclusion for the entire operation.
...@@ -221,7 +320,7 @@ fn write_all(io: &mut impl CommitIo, mut offset: usize, mut bytes: &[u8]) -> io:...@@ -221,7 +320,7 @@ fn write_all(io: &mut impl CommitIo, mut offset: usize, mut bytes: &[u8]) -> io:
221}320}
222321
223/// Publishes a scalar revision after checking the locked file against its snapshot.322/// Publishes a scalar revision after checking the locked file against its snapshot.
224/// Unknown outcomes require rereading; Committed errors affect counter cleanup or lock release.323/// Unknown outcomes require rereading; Committed errors affect lock release.
225pub fn commit_property_bytes(324pub fn commit_property_bytes(
226 io: &mut impl CommitIo,325 io: &mut impl CommitIo,
227 source: &[u8],326 source: &[u8],
...@@ -240,6 +339,40 @@ pub fn commit_property_bytes(...@@ -240,6 +339,40 @@ pub fn commit_property_bytes(
240 commit_bytes(io, source, &written)339 commit_bytes(io, source, &written)
241}340}
242341
342fn compare_snapshot(io: &mut impl CommitIo, source: &[u8]) -> io::Result<()> {
343 let capacity = source.len().clamp(1, 1024 * 1024);
344 let mut buffer = Vec::new();
345 buffer
346 .try_reserve_exact(capacity)
347 .map_err(io::Error::other)?;
348 buffer.resize(capacity, 0);
349 let mut offset = 0;
350 while offset < source.len() {
351 let size = buffer.len().min(source.len() - offset);
352 let count = match io.read_at(offset as u64, &mut buffer[..size]) {
353 Err(error) if error.kind() == ErrorKind::Interrupted => continue,
354 result => result?,
355 };
356 if count == 0 {
357 return Err(io::Error::from(ErrorKind::UnexpectedEof));
358 }
359 if count > size || buffer[..count] != source[offset..offset + count] {
360 return Err(io::Error::new(
361 ErrorKind::ResourceBusy,
362 "The locked file differs from the edit snapshot",
363 ));
364 }
365 offset += count;
366 }
367 if io.read_at(source.len() as u64, &mut buffer[..1])? != 0 {
368 return Err(io::Error::new(
369 ErrorKind::ResourceBusy,
370 "The locked file grew after the edit snapshot",
371 ));
372 }
373 Ok(())
374}
375
243pub(crate) fn commit_bytes(376pub(crate) fn commit_bytes(
244 io: &mut impl CommitIo,377 io: &mut impl CommitIo,
245 source: &[u8],378 source: &[u8],
...@@ -247,31 +380,7 @@ pub(crate) fn commit_bytes(...@@ -247,31 +380,7 @@ pub(crate) fn commit_bytes(
247) -> Result<(), CommitError> {380) -> Result<(), CommitError> {
248 let mut state = CommitState::NotCommitted;381 let mut state = CommitState::NotCommitted;
249 let result = (|| -> io::Result<()> {382 let result = (|| -> io::Result<()> {
250 let mut buffer = [0; 65536];383 compare_snapshot(io, source)?;
251 let mut offset = 0;
252 while offset < source.len() {
253 let size = buffer.len().min(source.len() - offset);
254 let count = match io.read_at(offset as u64, &mut buffer[..size]) {
255 Err(error) if error.kind() == ErrorKind::Interrupted => continue,
256 result => result?,
257 };
258 if count == 0 {
259 return Err(io::Error::from(ErrorKind::UnexpectedEof));
260 }
261 if count > size || buffer[..count] != source[offset..offset + count] {
262 return Err(io::Error::new(
263 ErrorKind::ResourceBusy,
264 "The locked file differs from the edit snapshot",
265 ));
266 }
267 offset += count;
268 }
269 if io.read_at(source.len() as u64, &mut buffer[..1])? != 0 {
270 return Err(io::Error::new(
271 ErrorKind::ResourceBusy,
272 "The locked file grew after the edit snapshot",
273 ));
274 }
275 if written == source {384 if written == source {
276 state = CommitState::Unknown;385 state = CommitState::Unknown;
277 io.flush()?;386 io.flush()?;
...@@ -291,17 +400,20 @@ pub(crate) fn commit_bytes(...@@ -291,17 +400,20 @@ pub(crate) fn commit_bytes(
291 write_all(io, start, &written[start..offset])?;400 write_all(io, start, &written[start..offset])?;
292 }401 }
293 io.flush()?;402 io.flush()?;
294 write_all(io, 100, &written[100..1024])?;403 write_all(io, 100, &written[100..212])?;
404 write_all(io, 252, &written[252..1024])?;
295 io.flush()?;405 io.flush()?;
296 let highest = (96..100).rfind(|at| source[*at] != written[*at]).unwrap();406 let highest = (96..100).rfind(|at| source[*at] != written[*at]).unwrap();
297 state = CommitState::Unknown;407 state = CommitState::Unknown;
298 write_all(io, highest, &written[highest..highest + 1])?;408 write_all(io, highest, &written[highest..highest + 1])?;
299 io.flush()?;409 io.flush()?;
300 state = CommitState::Committed;
301 if highest > 96 {410 if highest > 96 {
302 write_all(io, 96, &written[96..highest])?;411 write_all(io, 96, &written[96..highest])?;
303 io.flush()?;412 io.flush()?;
304 }413 }
414 // Native readers cache the version GUID without rechecking the transaction count.
415 write_all(io, 212, &written[212..252])?;
416 io.flush()?;
305 Ok(())417 Ok(())
306 })();418 })();
307 result.map_err(|error| CommitError { state, error })419 result.map_err(|error| CommitError { state, error })
crates/onestore/src/create.rs+11-7
...@@ -8,7 +8,7 @@ use std::time::{SystemTime, UNIX_EPOCH};...@@ -8,7 +8,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
88
9type Result<T> = std::result::Result<T, Error>;9type Result<T> = std::result::Result<T, Error>;
1010
11fn string(value: &str) -> Vec<u8> {11pub(crate) fn string(value: &str) -> Vec<u8> {
12 value12 value
13 .encode_utf16()13 .encode_utf16()
14 .chain([0])14 .chain([0])
...@@ -16,7 +16,15 @@ fn string(value: &str) -> Vec<u8> {...@@ -16,7 +16,15 @@ fn string(value: &str) -> Vec<u8> {
16 .collect()16 .collect()
17}17}
1818
19fn properties(values: &[(u32, Vec<u8>)]) -> Result<Vec<u8>> {19pub(crate) fn default_text_style() -> Vec<(u32, Vec<u8>)> {
20 vec![
21 (0x14001c3b, 0x409_u32.to_le_bytes().to_vec()),
22 (0x1c001c0a, string("Calibri")),
23 (0x10001c0b, 22_u16.to_le_bytes().to_vec()),
24 ]
25}
26
27pub(crate) fn properties(values: &[(u32, Vec<u8>)]) -> Result<Vec<u8>> {
20 let mut streams: [Vec<u8>; 3] = std::array::from_fn(|_| Vec::new());28 let mut streams: [Vec<u8>; 3] = std::array::from_fn(|_| Vec::new());
21 let mut fields = Vec::new();29 let mut fields = Vec::new();
22 for (id, value) in values {30 for (id, value) in values {
...@@ -239,11 +247,7 @@ pub fn create_section(file_name: &str, text: &str, author: &str) -> Result<Vec<u...@@ -239,11 +247,7 @@ pub fn create_section(file_name: &str, text: &str, author: &str) -> Result<Vec<u
239 NewObject {247 NewObject {
240 id: 27,248 id: 27,
241 jcid: 0x12004d,249 jcid: 0x12004d,
242 properties: vec![250 properties: default_text_style(),
243 (0x14001c3b, id(0x409)),
244 (0x1c001c0a, string("Calibri")),
245 (0x10001c0b, 22_u16.to_le_bytes().to_vec()),
246 ],
247 },251 },
248 ],252 ],
249 },253 },
crates/onestore/src/document.rs+1-1
...@@ -747,7 +747,7 @@ impl<'a> Document<'a> {...@@ -747,7 +747,7 @@ impl<'a> Document<'a> {
747}747}
748748
749impl<'a> Element<'a> {749impl<'a> Element<'a> {
750 fn parse(object: &Object<'a>, store: &Store<'a>) -> Result<Self> {750 pub(crate) fn parse(object: &Object<'a>, store: &Store<'a>) -> Result<Self> {
751 let empty = |kind| Self {751 let empty = |kind| Self {
752 jcid: object.jcid,752 jcid: object.jcid,
753 children: vec![],753 children: vec![],
crates/onestore/src/edit.rs+145-93
...@@ -58,38 +58,7 @@ pub fn replace_text(...@@ -58,38 +58,7 @@ pub fn replace_text(
58 .into_iter()58 .into_iter()
59 .filter_map(|(sid, page)| (sid == space).then_some(page))59 .filter_map(|(sid, page)| (sid == space).then_some(page))
60 .collect();60 .collect();
61 let mut pending: Vec<_> = pages.iter().map(|page| (*page, false)).collect();61 let parents = editable_parents(revision, &pages, object)?;
62 let mut seen = std::collections::BTreeSet::new();
63 let mut parents = std::collections::BTreeMap::<_, Vec<_>>::new();
64 let mut editable = false;
65 while let Some((id, read_only)) = pending.pop() {
66 if !seen.insert((id, read_only)) {
67 continue;
68 }
69 let element = revision
70 .nodes
71 .get(&id)
72 .ok_or_else(|| invalid("Page content is unavailable"))?;
73 let read_only = read_only || element.extra[0].iter().any(|field| field.id == 0x88001cde);
74 if id == object {
75 if read_only {
76 return Err(invalid("This page or its content is read-only"));
77 }
78 editable = true;
79 }
80 for child in element
81 .children
82 .iter()
83 .chain(&element.content)
84 .chain(&element.structure)
85 {
86 parents.entry(*child).or_default().push(id);
87 pending.push((*child, read_only));
88 }
89 }
90 if !editable {
91 return Err(invalid("Select text on an active editable page"));
92 }
93 let node = revision62 let node = revision
94 .nodes63 .nodes
95 .get(&object)64 .get(&object)
...@@ -219,7 +188,7 @@ pub fn replace_text(...@@ -219,7 +188,7 @@ pub fn replace_text(
219 let mut edits = vec![crate::write::ObjectEdit {188 let mut edits = vec![crate::write::ObjectEdit {
220 object,189 object,
221 updates: &updates,190 updates: &updates,
222 insert,191 inserts: insert.as_slice(),
223 }];192 }];
224 // Native conflict merges can discard descendant edits when ancestor timestamps stay stale.193 // Native conflict merges can discard descendant edits when ancestor timestamps stay stale.
225 let modified_update = [(0x14001d7a, modified.as_slice())];194 let modified_update = [(0x14001d7a, modified.as_slice())];
...@@ -233,18 +202,143 @@ pub fn replace_text(...@@ -233,18 +202,143 @@ pub fn replace_text(
233 edits.push(crate::write::ObjectEdit {202 edits.push(crate::write::ObjectEdit {
234 object: id,203 object: id,
235 updates: &modified_update,204 updates: &modified_update,
236 insert: None,205 inserts: &[],
237 });206 });
238 }207 }
239 pending.extend(parents.get(&id).into_iter().flatten().copied());208 pending.extend(parents.get(&id).into_iter().flatten().copied());
240 }209 }
210 let Some((page, automatic, title_text)) =
211 page_title(revision, &pages, Some((object, &changed)))?
212 else {
213 return crate::write::replace_objects(source, space, &edits);
214 };
215 let Kind::Page {
216 alternate_title, ..
217 } = &revision.nodes[&page].kind
218 else {
219 unreachable!()
220 };
221 let metadata = revision
222 .roots
223 .get(&2)
224 .ok_or_else(|| invalid("Page title metadata is unavailable"))?;
225 let Kind::Metadata { title, .. } = &revision.nodes[metadata].kind else {
226 return Err(invalid("Page title metadata is unavailable"));
227 };
228 let cached: Vec<_> = title_text
229 .encode_utf16()
230 .chain([0])
231 .flat_map(u16::to_le_bytes)
232 .collect();
233 let metadata_update = [(0x1c001cf3, cached.as_slice())];
234 edits.push(crate::write::ObjectEdit {
235 object: *metadata,
236 updates: if title.is_some() {
237 &metadata_update
238 } else {
239 &[]
240 },
241 inserts: if title.is_none() {
242 &metadata_update
243 } else {
244 &[]
245 },
246 });
247 let mut alternate_update = vec![(
248 0x1c001d3c,
249 if automatic {
250 cached.as_slice()
251 } else {
252 &[0u8, 0][..]
253 },
254 )];
255 if revision.nodes[&page].modified.is_some() {
256 alternate_update.push(modified_update[0]);
257 }
258 edits.retain(|edit| edit.object != page);
259 edits.push(crate::write::ObjectEdit {
260 object: page,
261 updates: if alternate_title.is_some() {
262 &alternate_update
263 } else {
264 &alternate_update[1..]
265 },
266 inserts: if alternate_title.is_none() {
267 &alternate_update[..1]
268 } else {
269 &[]
270 },
271 });
272 crate::write::replace_objects(source, space, &edits)
273}
274
275pub(crate) fn editable_parents(
276 revision: &crate::document::Revision<'_>,
277 pages: &[ExGuid],
278 object: ExGuid,
279) -> Result<std::collections::BTreeMap<ExGuid, Vec<ExGuid>>, Error> {
280 let invalid = |message| Error { offset: 0, message };
281 let mut pending: Vec<_> = pages.iter().map(|page| (*page, false)).collect();
282 let mut seen = std::collections::BTreeSet::new();
283 let mut parents = std::collections::BTreeMap::<_, Vec<_>>::new();
284 let mut editable = false;
285 while let Some((id, read_only)) = pending.pop() {
286 if !seen.insert((id, read_only)) {
287 continue;
288 }
289 let element = revision
290 .nodes
291 .get(&id)
292 .ok_or_else(|| invalid("Page content is unavailable"))?;
293 let read_only = read_only || element.extra[0].iter().any(|field| field.id == 0x88001cde);
294 if id == object {
295 if read_only {
296 return Err(invalid("This page or its content is read-only"));
297 }
298 editable = true;
299 }
300 for child in element
301 .children
302 .iter()
303 .chain(&element.content)
304 .chain(&element.structure)
305 {
306 parents.entry(*child).or_default().push(id);
307 pending.push((*child, read_only));
308 }
309 }
310 if !editable {
311 return Err(invalid("Select content on an active editable page"));
312 }
313 Ok(parents)
314}
315
316pub(crate) fn page_title(
317 revision: &crate::document::Revision<'_>,
318 pages: &[ExGuid],
319 text_update: Option<(ExGuid, &str)>,
320) -> Result<Option<(ExGuid, bool, String)>, Error> {
321 let invalid = |message| Error { offset: 0, message };
322 let mut pending = pages.to_vec();
323 let mut seen = std::collections::BTreeSet::new();
324 while let Some(id) = pending.pop() {
325 if !seen.insert(id) {
326 continue;
327 }
328 let node = &revision.nodes[&id];
329 pending.extend(
330 node.children
331 .iter()
332 .chain(&node.content)
333 .chain(&node.structure)
334 .copied(),
335 );
336 }
241 let titles: Vec<_> = revision337 let titles: Vec<_> = revision
242 .nodes338 .nodes
243 .iter()339 .iter()
244 .filter_map(|(id, node)| {340 .filter_map(|(id, node)| {
245 if !(seen.contains(&(*id, false)) || seen.contains(&(*id, true)))341 if !seen.contains(id) || !node.extra[0].iter().any(|field| field.id == 0x88001cb4) {
246 || !node.extra[0].iter().any(|field| field.id == 0x88001cb4)
247 {
248 return None;342 return None;
249 }343 }
250 match &node.kind {344 match &node.kind {
...@@ -260,8 +354,8 @@ pub fn replace_text(...@@ -260,8 +354,8 @@ pub fn replace_text(
260 let title_text = match titles.as_slice() {354 let title_text = match titles.as_slice() {
261 [] => "",355 [] => "",
262 [(id, text)] => {356 [(id, text)] => {
263 if *id == object {357 if let Some((_, changed)) = text_update.filter(|(object, _)| object == id) {
264 changed.as_str()358 changed
265 } else {359 } else {
266 text360 text
267 }361 }
...@@ -269,18 +363,13 @@ pub fn replace_text(...@@ -269,18 +363,13 @@ pub fn replace_text(
269 _ => return Err(invalid("Title editing requires a single title text object")),363 _ => return Err(invalid("Title editing requires a single title text object")),
270 };364 };
271 let automatic = title_line(title_text).is_empty();365 let automatic = title_line(title_text).is_empty();
272 if !automatic && titles[0].0 != object {366 if !automatic && text_update.is_none_or(|(id, _)| titles[0].0 != id) {
273 return crate::write::replace_objects(source, space, &edits);367 return Ok(None);
274 }368 }
275 let [page] = pages.as_slice() else {369 let [page] = pages else {
276 return Err(invalid("Title editing requires a single active page"));370 return Err(invalid("Title editing requires a single active page"));
277 };371 };
278 let Kind::Page {372 let Kind::Page { rtl, .. } = &revision.nodes[page].kind else {
279 alternate_title,
280 rtl,
281 ..
282 } = &revision.nodes[page].kind
283 else {
284 unreachable!()373 unreachable!()
285 };374 };
286 let mut title_text = title_line(title_text);375 let mut title_text = title_line(title_text);
...@@ -312,7 +401,13 @@ pub fn replace_text(...@@ -312,7 +401,13 @@ pub fn replace_text(
312 ..401 ..
313 } = &node.kind402 } = &node.kind
314 {403 {
315 title_text = automatic_title(if id == object { &changed } else { text });404 title_text = automatic_title(
405 if let Some((_, changed)) = text_update.filter(|(object, _)| *object == id) {
406 changed
407 } else {
408 text
409 },
410 );
316 if !title_text.is_empty() {411 if !title_text.is_empty() {
317 break;412 break;
318 }413 }
...@@ -326,48 +421,5 @@ pub fn replace_text(...@@ -326,48 +421,5 @@ pub fn replace_text(
326 pending.extend(node.structure.iter().rev().copied());421 pending.extend(node.structure.iter().rev().copied());
327 }422 }
328 }423 }
329 let metadata = revision424 Ok(Some((*page, automatic, title_text.to_owned())))
330 .roots
331 .get(&2)
332 .ok_or_else(|| invalid("Page title metadata is unavailable"))?;
333 let Kind::Metadata { title, .. } = &revision.nodes[metadata].kind else {
334 return Err(invalid("Page title metadata is unavailable"));
335 };
336 let cached: Vec<_> = title_text
337 .encode_utf16()
338 .chain([0])
339 .flat_map(u16::to_le_bytes)
340 .collect();
341 let metadata_update = [(0x1c001cf3, cached.as_slice())];
342 edits.push(crate::write::ObjectEdit {
343 object: *metadata,
344 updates: if title.is_some() {
345 &metadata_update
346 } else {
347 &[]
348 },
349 insert: title.is_none().then_some(metadata_update[0]),
350 });
351 let mut alternate_update = vec![(
352 0x1c001d3c,
353 if automatic {
354 cached.as_slice()
355 } else {
356 &[0u8, 0][..]
357 },
358 )];
359 if revision.nodes[page].modified.is_some() {
360 alternate_update.push(modified_update[0]);
361 }
362 edits.retain(|edit| edit.object != *page);
363 edits.push(crate::write::ObjectEdit {
364 object: *page,
365 updates: if alternate_title.is_some() {
366 &alternate_update
367 } else {
368 &alternate_update[1..]
369 },
370 insert: alternate_title.is_none().then_some(alternate_update[0]),
371 });
372 crate::write::replace_objects(source, space, &edits)
373}425}
crates/onestore/src/formatting.rs created+288
...@@ -0,0 +1,288 @@
1use crate::{
2 Error, ExGuid, PropertySets, RevisionIndex, Store,
3 create::{current_timestamps, properties, string},
4 document::{Document, Kind},
5 edit::editable_parents,
6 write::{PropertyObject, fresh_guid, write_revision},
7};
8use serde::{Deserialize, Serialize};
9use std::{
10 collections::{BTreeMap, BTreeSet},
11 ops::Range,
12 sync::Arc,
13};
14
15fn invalid(message: &'static str) -> Error {
16 Error { offset: 0, message }
17}
18
19/// An explicit character-format change; omitted attributes retain their current values.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub enum TextAttribute {
22 Bold(bool),
23 Italic(bool),
24 Underline(bool),
25 Strike(bool),
26 /// Enabling superscript clears subscript.
27 Superscript(bool),
28 /// Enabling subscript clears superscript.
29 Subscript(bool),
30 Font(String),
31 /// Points, from 6 through 130 in half-point increments.
32 /// OneNote 2010 clamps larger sizes despite the specification allowing 144.
33 FontSize(f32),
34 /// RGB, or None for automatic text color.
35 Color(Option<[u8; 3]>),
36 /// RGB, or None to clear highlighting.
37 Highlight(Option<[u8; 3]>),
38}
39
40impl TextAttribute {
41 fn property(&self) -> Result<(u32, Vec<u8>), Error> {
42 let boolean = |id, value: bool| (id | (u32::from(value) << 31), Vec::new());
43 Ok(match self {
44 Self::Bold(value) => boolean(0x08001c04, *value),
45 Self::Italic(value) => boolean(0x08001c05, *value),
46 Self::Underline(value) => boolean(0x08001c06, *value),
47 Self::Strike(value) => boolean(0x08001c07, *value),
48 Self::Superscript(value) => boolean(0x08001c08, *value),
49 Self::Subscript(value) => boolean(0x08001c09, *value),
50 Self::Font(font) => {
51 if font.is_empty() || font.contains('\0') {
52 return Err(invalid("Font names must be nonempty and contain no NUL"));
53 }
54 (0x1c001c0a, string(font))
55 }
56 Self::FontSize(points) => {
57 if !points.is_finite()
58 || !(6.0..=130.0).contains(points)
59 || (points * 2.0).fract() != 0.0
60 {
61 return Err(invalid(
62 "Font size must be 6 to 130 points in half-point increments",
63 ));
64 }
65 (0x10001c0b, ((*points * 2.0) as u16).to_le_bytes().to_vec())
66 }
67 Self::Color(color) | Self::Highlight(color) => (
68 if matches!(self, Self::Color(_)) {
69 0x14001c0c
70 } else {
71 0x14001c0d
72 },
73 color
74 .map_or(0xff000000, |[r, g, b]| u32::from_le_bytes([r, g, b, 0]))
75 .to_le_bytes()
76 .to_vec(),
77 ),
78 })
79 }
80}
81
82pub(crate) fn format_text(
83 source: &[u8],
84 space: ExGuid,
85 object: ExGuid,
86 range: Range<u32>,
87 attributes: &[TextAttribute],
88) -> Result<Vec<u8>, Error> {
89 if attributes.is_empty() || range.start > range.end {
90 return Err(invalid(
91 "Select a text range and at least one formatting attribute",
92 ));
93 }
94 let mut values = Vec::new();
95 let mut seen = BTreeSet::new();
96 for attribute in attributes {
97 let (id, value) = attribute.property()?;
98 if !seen.insert(id & 0x7fffffff) {
99 return Err(invalid("Specify each formatting attribute once"));
100 }
101 values.push((id, value));
102 }
103 if attributes.contains(&TextAttribute::Superscript(true))
104 && attributes.contains(&TextAttribute::Subscript(true))
105 {
106 return Err(invalid("Text cannot be both superscript and subscript"));
107 }
108 // Setting either script position clears its mutually exclusive counterpart.
109 for (set, opposite) in [(0x88001c08, 0x08001c09), (0x88001c09, 0x08001c08)] {
110 if values.iter().any(|(id, _)| *id == set) && !seen.contains(&opposite) {
111 values.push((opposite, Vec::new()));
112 }
113 }
114 let store = Store::parse(source)?;
115 let index = RevisionIndex::parse(&store)?;
116 index.validate_current()?;
117 let document = Document::parse(&index)?;
118 let semantic = document
119 .spaces
120 .get(&space)
121 .ok_or_else(|| invalid("The active page is unavailable"))?;
122 let rid = semantic.contexts[&ExGuid::default()];
123 let view = &semantic.revisions[&rid];
124 let pages: Vec<_> = document
125 .pages()?
126 .into_iter()
127 .filter_map(|(sid, page)| (sid == space).then_some(page))
128 .collect();
129 let parents = editable_parents(view, &pages, object)?;
130 let node = &view.nodes[&object];
131 let Kind::RichText {
132 text,
133 runs,
134 boilerplate,
135 ..
136 } = &node.kind
137 else {
138 return Err(invalid("Select a rich-text object"));
139 };
140 if *boilerplate {
141 return Err(invalid(
142 "Generated title fields cannot be formatted as ordinary text",
143 ));
144 }
145 let (mut valid_start, mut valid_end) = (range.start == 0, range.end == 0);
146 let mut offset = 0;
147 for character in text.chars() {
148 offset += character.len_utf16() as u32;
149 valid_start |= offset == range.start;
150 valid_end |= offset == range.end;
151 }
152 if !valid_start || !valid_end {
153 return Err(invalid(
154 "The format range splits a surrogate pair or exceeds the text",
155 ));
156 }
157 if range.is_empty() && !text.is_empty() {
158 return Err(invalid(
159 "Select characters, or an empty paragraph's insertion style",
160 ));
161 }
162 let resolved = view.text_runs(object)?;
163 let mut segments = Vec::new();
164 for (i, run) in runs.iter().enumerate() {
165 let selected = if text.is_empty() {
166 true
167 } else {
168 run.start < range.end && range.start < run.end
169 };
170 if selected {
171 let format = &resolved[i].format;
172 if [
173 format.hidden,
174 format.hyperlink,
175 format.math,
176 format.embedded_object,
177 ]
178 .contains(&Some(true))
179 || resolved[i].text.contains(['\u{fffc}', '\u{fddf}'])
180 {
181 return Err(invalid(
182 "This format range contains a field or embedded data",
183 ));
184 }
185 if run.start < range.start {
186 segments.push((i, range.start, false));
187 }
188 segments.push((i, run.end.min(range.end), true));
189 if range.end < run.end {
190 segments.push((i, run.end, false));
191 }
192 } else {
193 segments.push((i, run.end, false));
194 }
195 }
196 let modified = current_timestamps()?.0.to_le_bytes();
197 write_revision(source, space, |raw| {
198 let mut target = PropertyObject::from_object(&raw.objects[&object])?;
199 let fields = PropertySets::parse(&target.bytes)?;
200 if fields.sets[0].iter().any(|p| p.id == 0x24003458) {
201 return Err(invalid("This text object contains associated run objects"));
202 }
203 if segments.len() != runs.len() && fields.sets[0].iter().any(|p| p.id == 0x40003499) {
204 return Err(invalid(
205 "Formatting boundaries cannot split preserved run data",
206 ));
207 }
208 let mut styles = BTreeMap::new();
209 let mut changed = BTreeMap::new();
210 let mut references = Vec::new();
211 let mut ends = Vec::new();
212 let mut updated = false;
213 let changes: Vec<_> = values
214 .iter()
215 .map(|(id, value)| (*id, value.as_slice()))
216 .collect();
217 for &(i, end, selected) in &segments {
218 let previous = runs[i].format;
219 let key = (previous, selected);
220 let id = if let Some(id) = styles.get(&key) {
221 *id
222 } else if let Some(id) = previous.filter(|_| !selected) {
223 id
224 } else {
225 let mut style = match previous {
226 Some(id) => PropertyObject::from_object(&raw.objects[&id])?,
227 None => PropertyObject {
228 jcid: 0x12004d,
229 bytes: properties(&[])?,
230 global_ids: Arc::new(BTreeMap::new()),
231 },
232 };
233 if selected {
234 style.set(&changes)?;
235 }
236 if let Some(id) = previous.filter(|id| {
237 raw.objects[id].data == crate::ObjectData::Properties(&style.bytes)
238 }) {
239 styles.insert(key, id);
240 id
241 } else {
242 if !PropertySets::parse(&style.bytes)?.sets[0]
243 .iter()
244 .any(|p| p.id == 0x14001c3b)
245 {
246 style.set(&[(
247 0x14001c3b,
248 &resolved[i].format.language.unwrap_or(0x409).to_le_bytes(),
249 )])?;
250 }
251 let id = ExGuid {
252 guid: fresh_guid()?,
253 n: 1,
254 };
255 style.reference(id)?;
256 changed.insert(id, style);
257 styles.insert(key, id);
258 id
259 }
260 };
261 updated |= selected && previous != Some(id);
262 references.extend_from_slice(&target.reference(id)?);
263 ends.extend_from_slice(&end.to_le_bytes());
264 }
265 if !updated {
266 return Ok(BTreeMap::new());
267 }
268 ends.truncate(ends.len() - 4);
269 target.set(&[
270 (0x24001e13, &references),
271 (0x1c001e12, &ends),
272 (0x14001d7a, &modified),
273 ])?;
274 changed.insert(object, target);
275 let mut pending = parents.get(&object).cloned().unwrap_or_default();
276 let mut ancestors = BTreeSet::new();
277 while let Some(id) = pending.pop() {
278 if !ancestors.insert(id) {
279 continue;
280 }
281 let mut ancestor = PropertyObject::from_object(&raw.objects[&id])?;
282 ancestor.set(&[(0x14001d7a, &modified)])?;
283 changed.insert(id, ancestor);
284 pending.extend(parents.get(&id).into_iter().flatten().copied());
285 }
286 Ok(changed)
287 })
288}
crates/onestore/src/insertion.rs created+343
...@@ -0,0 +1,343 @@
1use crate::{
2 Error, ExGuid, Object, ObjectData, RevisionIndex, Store,
3 create::{current_timestamps, default_text_style, properties, string},
4 document::{Document, Element, Kind},
5 edit::{editable_parents, page_title},
6 write::{PropertyObject, fresh_guid, write_revision},
7};
8use serde::{Deserialize, Serialize};
9use std::{
10 collections::{BTreeMap, BTreeSet},
11 sync::Arc,
12};
13
14fn invalid(message: &'static str) -> Error {
15 Error { offset: 0, message }
16}
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20enum Placement {
21 Paragraph { before: Option<ExGuid> },
22 Outline { x: f32, y: f32 },
23}
24
25/// A paragraph or outline insertion with stable object identities and creation time.
26/// Retain this intent across rebases; constructing another intent allocates different identities.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct Insertion {
30 guid: [u8; 16],
31 parent: ExGuid,
32 placement: Placement,
33 text: String,
34 author: String,
35 created: u32,
36}
37
38impl Insertion {
39 /// Inserts before a direct child, or appends when `before` is None.
40 /// The parent must be an editable outline, paragraph, outline group or table cell.
41 /// Carriage returns represent soft line breaks; line feeds and embedded-field markers are rejected.
42 pub fn paragraph(
43 parent: ExGuid,
44 before: Option<ExGuid>,
45 text: &str,
46 author: &str,
47 ) -> Result<Self, Error> {
48 Self::new(parent, Placement::Paragraph { before }, text, author)
49 }
50
51 /// Adds an outline to an editable page at coordinates measured in points.
52 pub fn outline(page: ExGuid, x: f32, y: f32, text: &str, author: &str) -> Result<Self, Error> {
53 Self::new(page, Placement::Outline { x, y }, text, author)
54 }
55
56 /// Changes a paragraph intent's placement while retaining its identities, text and author.
57 pub fn reposition_paragraph(
58 &self,
59 parent: ExGuid,
60 before: Option<ExGuid>,
61 ) -> Result<Self, Error> {
62 if !matches!(self.placement, Placement::Paragraph { .. }) {
63 return Err(invalid("An outline intent cannot become a paragraph"));
64 }
65 let mut intent = self.clone();
66 intent.parent = parent;
67 intent.placement = Placement::Paragraph { before };
68 intent.validate()?;
69 Ok(intent)
70 }
71
72 /// Changes an outline intent's placement while retaining its identities, text and author.
73 pub fn reposition_outline(&self, page: ExGuid, x: f32, y: f32) -> Result<Self, Error> {
74 if !matches!(self.placement, Placement::Outline { .. }) {
75 return Err(invalid("A paragraph intent cannot become an outline"));
76 }
77 let mut intent = self.clone();
78 intent.parent = page;
79 intent.placement = Placement::Outline { x, y };
80 intent.validate()?;
81 Ok(intent)
82 }
83
84 fn new(parent: ExGuid, placement: Placement, text: &str, author: &str) -> Result<Self, Error> {
85 let intent = Self {
86 guid: fresh_guid()?,
87 parent,
88 placement,
89 text: text.to_owned(),
90 author: author.to_owned(),
91 created: current_timestamps()?.0,
92 };
93 intent.validate()?;
94 Ok(intent)
95 }
96
97 /// Identity of the new paragraph, or the new outline for an outline insertion.
98 pub fn object(&self) -> ExGuid {
99 ExGuid {
100 guid: self.guid,
101 n: 1,
102 }
103 }
104
105 /// Identity of the insertion's ordinary rich-text object.
106 pub fn text_object(&self) -> ExGuid {
107 ExGuid {
108 guid: self.guid,
109 n: 2,
110 }
111 }
112
113 fn validate(&self) -> Result<(), Error> {
114 if self.guid == [0; 16]
115 || self.parent.guid == [0; 16]
116 || self.text.contains(['\0', '\n', '\u{fffc}', '\u{fddf}'])
117 || self.author.contains('\0')
118 {
119 return Err(invalid(
120 "Use an editable parent, ordinary paragraph text and a valid author",
121 ));
122 }
123 if let Placement::Outline { x, y } = self.placement
124 && (!x.is_finite() || !y.is_finite())
125 {
126 return Err(invalid("Outline coordinates must be finite"));
127 }
128 Ok(())
129 }
130
131 pub(crate) fn apply(&self, source: &[u8], space: ExGuid) -> Result<Vec<u8>, Error> {
132 self.validate()?;
133 let store = Store::parse(source)?;
134 let index = RevisionIndex::parse(&store)?;
135 index.validate_current()?;
136 let mut document = Document::parse(&index)?;
137 let pages: Vec<_> = document
138 .pages()?
139 .into_iter()
140 .filter_map(|(sid, id)| (sid == space).then_some(id))
141 .collect();
142 let [page] = pages.as_slice() else {
143 return Err(invalid("Insertion requires a single active page"));
144 };
145 let semantic_space = document
146 .spaces
147 .remove(&space)
148 .ok_or_else(|| invalid("The active page is unavailable"))?;
149 let rid = semantic_space.contexts[&ExGuid::default()];
150 let mut view = semantic_space
151 .revisions
152 .into_iter()
153 .find_map(|(id, revision)| (id == rid).then_some(revision))
154 .unwrap();
155 let parents = editable_parents(&view, &pages, self.parent)?;
156 let parent = &view.nodes[&self.parent];
157 let position = match self.placement {
158 Placement::Paragraph { before } => {
159 if !matches!(
160 parent.kind,
161 Kind::Outline { .. }
162 | Kind::Paragraph { .. }
163 | Kind::OutlineGroup
164 | Kind::Cell { .. }
165 ) {
166 return Err(invalid(
167 "Select an outline, paragraph, outline group or table cell",
168 ));
169 }
170 if let Some(id) = before {
171 parent
172 .children
173 .iter()
174 .position(|child| *child == id)
175 .ok_or_else(|| {
176 invalid("The insertion anchor is no longer a direct child")
177 })?
178 } else {
179 parent.children.len()
180 }
181 }
182 Placement::Outline { .. } => {
183 if self.parent != *page || !matches!(parent.kind, Kind::Page { .. }) {
184 return Err(invalid("Select an active page for the new outline"));
185 }
186 parent.children.len()
187 }
188 };
189 let mut ancestors = BTreeSet::new();
190 let mut pending = vec![self.parent];
191 while let Some(id) = pending.pop() {
192 if !ancestors.insert(id) {
193 continue;
194 }
195 if matches!(view.nodes[&id].kind, Kind::Title) {
196 return Err(invalid(
197 "Title containers do not accept ordinary paragraphs",
198 ));
199 }
200 pending.extend(parents.get(&id).into_iter().flatten().copied());
201 }
202 let modified = current_timestamps()?.0.to_le_bytes();
203 let paragraph_n = if matches!(self.placement, Placement::Outline { .. }) {
204 3
205 } else {
206 1
207 };
208 let table = Arc::new(BTreeMap::from([(0, self.guid)]));
209 let reference = |n: u32| n.to_le_bytes().to_vec();
210 let mut new = BTreeMap::new();
211 for (n, jcid, values) in [
212 (
213 paragraph_n,
214 0x6000d,
215 vec![
216 (0x14001d7a, modified.to_vec()),
217 (0x14001d09, self.created.to_le_bytes().to_vec()),
218 (0x0c001c03, vec![1]),
219 (0x24001c1f, reference(2)),
220 (0x20001d78, reference(4)),
221 (0x20001d79, reference(4)),
222 ],
223 ),
224 (
225 2,
226 0x6000e,
227 vec![
228 (0x14001d7a, modified.to_vec()),
229 (0x1c001c22, string(&self.text)),
230 (0x24001e13, reference(5)),
231 (0x10001cfe, 0x409_u16.to_le_bytes().to_vec()),
232 ],
233 ),
234 (4, 0x120001, vec![(0x1c001d75, string(&self.author))]),
235 (5, 0x12004d, default_text_style()),
236 ] {
237 new.insert(
238 ExGuid { guid: self.guid, n },
239 PropertyObject {
240 jcid,
241 bytes: properties(&values)?,
242 global_ids: Arc::clone(&table),
243 },
244 );
245 }
246 if let Placement::Outline { x, y } = self.placement {
247 new.insert(
248 self.object(),
249 PropertyObject {
250 jcid: 0x6000c,
251 global_ids: table,
252 bytes: properties(&[
253 (0x14001d7a, modified.to_vec()),
254 (0x24001c20, reference(3)),
255 (0x0c001c03, vec![1]),
256 (0x1c001c12, vec![1, 0, 0, 0, 0, 0, 0, 0]),
257 (0x14001c14, (x / 36.0).to_le_bytes().to_vec()),
258 (0x14001c15, (y / 36.0).to_le_bytes().to_vec()),
259 (0x14001c1b, 13_f32.to_le_bytes().to_vec()),
260 (0x14001c1c, 0.6_f32.to_le_bytes().to_vec()),
261 ])?,
262 },
263 );
264 }
265 let raw = index.resolve(space, rid)?;
266 if new.keys().any(|id| raw.objects.contains_key(id)) {
267 return Err(invalid(
268 "An insertion identity is already present; reconcile the existing edit",
269 ));
270 }
271 // Drop the semantic view before moving the property bytes it borrows.
272 let title = {
273 view.nodes
274 .get_mut(&self.parent)
275 .unwrap()
276 .children
277 .insert(position, self.object());
278 for (id, object) in &new {
279 view.nodes.insert(
280 *id,
281 Element::parse(
282 &Object {
283 jcid: object.jcid,
284 reference_count: 0,
285 data: ObjectData::Properties(&object.bytes),
286 global_ids: Arc::clone(&object.global_ids),
287 },
288 &store,
289 )?,
290 );
291 }
292 let title = page_title(&view, &pages, None)?;
293 drop(view);
294 title
295 };
296 write_revision(source, space, |raw| {
297 let mut changed = new;
298 for id in &ancestors {
299 let mut object = PropertyObject::from_object(&raw.objects[id])?;
300 object.set(&[(0x14001d7a, &modified)])?;
301 changed.insert(*id, object);
302 }
303 let parent = changed.get_mut(&self.parent).unwrap();
304 let properties = crate::PropertySets::parse(&parent.bytes)?;
305 let existing = properties.sets[0].iter().find(|p| p.id == 0x24001c20);
306 let mut ids = match existing.map(|p| &p.value) {
307 Some(crate::Value::References { compact_ids, .. }) => compact_ids.to_vec(),
308 None => Vec::new(),
309 _ => return Err(invalid("The parent has an invalid child list")),
310 };
311 let child = parent.reference(self.object())?;
312 if position > ids.len() / 4 {
313 return Err(invalid("The parent has an invalid child list"));
314 }
315 ids.splice(position * 4..position * 4, child);
316 parent.set(&[(0x24001c20, &ids)])?;
317 if matches!(self.placement, Placement::Paragraph { .. }) {
318 let properties = crate::PropertySets::parse(&parent.bytes)?;
319 if !properties.sets[0].iter().any(|p| p.id == 0x0c001c03) {
320 parent.set(&[(0x0c001c03, &[1])])?;
321 }
322 }
323 if let Some((page, automatic, title)) = title {
324 let metadata = raw
325 .roots
326 .get(&2)
327 .ok_or_else(|| invalid("Page title metadata is unavailable"))?;
328 if raw.objects[metadata].jcid != 0x20030 {
329 return Err(invalid("Page title metadata is unavailable"));
330 }
331 let title = string(&title);
332 let mut metadata_object = PropertyObject::from_object(&raw.objects[metadata])?;
333 metadata_object.set(&[(0x1c001cf3, &title)])?;
334 changed.insert(*metadata, metadata_object);
335 changed
336 .get_mut(&page)
337 .unwrap()
338 .set(&[(0x1c001d3c, if automatic { &title } else { &[0, 0] })])?;
339 }
340 Ok(changed)
341 })
342 }
343}
crates/onestore/src/lib.rs+11-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1#![forbid(unsafe_code)]1#![forbid(unsafe_code)]
2#![doc = include_str!("../../../README.md")]2#![doc = include_str!("../README.md")]
33
4mod bytes;4mod bytes;
5mod commit;5mod commit;
...@@ -8,20 +8,29 @@ pub mod document;...@@ -8,20 +8,29 @@ pub mod document;
8mod edit;8mod edit;
9mod files;9mod files;
10mod flush;10mod flush;
11mod formatting;
12mod insertion;
11mod objects;13mod objects;
12mod properties;14mod properties;
13mod revisions;15mod revisions;
16mod snapshot;
14mod store;17mod store;
15mod write;18mod write;
1619
17pub use commit::{CommitError, CommitIo, CommitState, commit_property_bytes, commit_text};20pub use commit::{
21 CommitError, CommitIo, CommitState, PreparedEdit, commit_property_bytes, commit_text,
22 confirm_snapshot,
23};
18#[cfg(any(unix, windows))]24#[cfg(any(unix, windows))]
19pub use commit::{commit_file_property, commit_file_text, read_file};25pub use commit::{commit_file_property, commit_file_text, read_file};
20pub use create::{create_section, create_table_of_contents};26pub use create::{create_section, create_table_of_contents};
21pub use edit::replace_text;27pub use edit::replace_text;
22pub use files::FileDataReference;28pub use files::FileDataReference;
29pub use formatting::TextAttribute;
30pub use insertion::Insertion;
23pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision};31pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision};
24pub use properties::{IdStream, Property, PropertySets, Value};32pub use properties::{IdStream, Property, PropertySets, Value};
25pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex};33pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex};
34pub use snapshot::read_snapshot;
26pub use store::{Chunk, Error, FileType, Header, Node, NodeList, Reference, Store};35pub use store::{Chunk, Error, FileType, Header, Node, NodeList, Reference, Store};
27pub use write::replace_property_bytes;36pub use write::replace_property_bytes;
crates/onestore/src/objects.rs+14-9
...@@ -81,6 +81,19 @@ impl Object<'_> {...@@ -81,6 +81,19 @@ impl Object<'_> {
8181
82impl ResolvedRevision<'_> {82impl ResolvedRevision<'_> {
83 pub fn reachable(&self) -> Result<BTreeSet<ExGuid>> {83 pub fn reachable(&self) -> Result<BTreeSet<ExGuid>> {
84 let incoming = self.reference_counts()?;
85 for (id, count) in &incoming {
86 if self.objects[id].reference_count != *count {
87 return Err(Error {
88 offset: 0,
89 message: "Stored object reference count disagrees with the reachable graph",
90 });
91 }
92 }
93 Ok(incoming.into_keys().collect())
94 }
95
96 pub(crate) fn reference_counts(&self) -> Result<BTreeMap<ExGuid, u32>> {
84 let mut pending: Vec<_> = self.roots.values().copied().collect();97 let mut pending: Vec<_> = self.roots.values().copied().collect();
85 let mut incoming = BTreeMap::<ExGuid, u32>::new();98 let mut incoming = BTreeMap::<ExGuid, u32>::new();
86 for id in &pending {99 for id in &pending {
...@@ -137,15 +150,7 @@ impl ResolvedRevision<'_> {...@@ -137,15 +150,7 @@ impl ResolvedRevision<'_> {
137 message: "Object references form a cycle",150 message: "Object references form a cycle",
138 });151 });
139 }152 }
140 for (id, count) in &incoming {153 Ok(incoming)
141 if self.objects[id].reference_count != *count {
142 return Err(Error {
143 offset: 0,
144 message: "Stored object reference count disagrees with the reachable graph",
145 });
146 }
147 }
148 Ok(edges.into_keys().collect())
149 }154 }
150}155}
151156
crates/onestore/src/revisions.rs+44
...@@ -5,10 +5,45 @@ type Result<T> = std::result::Result<T, Error>;...@@ -5,10 +5,45 @@ type Result<T> = std::result::Result<T, Error>;
55
6#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]6#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
7pub struct ExGuid {7pub struct ExGuid {
8 /// GUID bytes in Microsoft's mixed-endian order.
8 pub guid: [u8; 16],9 pub guid: [u8; 16],
9 pub n: u32,10 pub n: u32,
10}11}
1112
13impl std::str::FromStr for ExGuid {
14 type Err = Error;
15
16 /// Parses the display form, accepting either hexadecimal letter case.
17 fn from_str(value: &str) -> Result<Self> {
18 let invalid = || Error {
19 offset: 0,
20 message: "Invalid extended GUID",
21 };
22 let (guid_text, extension) = value.split_once(',').ok_or_else(invalid)?;
23 if guid_text.len() != 38 || !guid_text.is_ascii() || !(40..=49).contains(&value.len()) {
24 return Err(invalid());
25 }
26 let mut guid = [0; 16];
27 for (byte, at) in guid
28 .iter_mut()
29 .zip([1, 3, 5, 7, 10, 12, 15, 17, 20, 22, 25, 27, 29, 31, 33, 35])
30 {
31 *byte = u8::from_str_radix(&guid_text[at..at + 2], 16).map_err(|_| invalid())?;
32 }
33 guid[..4].reverse();
34 guid[4..6].reverse();
35 guid[6..8].reverse();
36 let id = Self {
37 guid,
38 n: extension.parse().map_err(|_| invalid())?,
39 };
40 if (id.guid == [0; 16] && id.n != 0) || !id.to_string().eq_ignore_ascii_case(value) {
41 return Err(invalid());
42 }
43 Ok(id)
44 }
45}
46
12impl fmt::Display for ExGuid {47impl fmt::Display for ExGuid {
13 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
14 let g = &self.guid;49 let g = &self.guid;
...@@ -40,6 +75,15 @@ impl serde::Serialize for ExGuid {...@@ -40,6 +75,15 @@ impl serde::Serialize for ExGuid {
40 }75 }
41}76}
4277
78impl<'de> serde::Deserialize<'de> for ExGuid {
79 fn deserialize<D: serde::Deserializer<'de>>(
80 deserializer: D,
81 ) -> std::result::Result<Self, D::Error> {
82 let value = <String as serde::Deserialize>::deserialize(deserializer)?;
83 value.parse().map_err(serde::de::Error::custom)
84 }
85}
86
43impl Cursor<'_> {87impl Cursor<'_> {
44 pub(crate) fn exguid(&mut self) -> Result<ExGuid> {88 pub(crate) fn exguid(&mut self) -> Result<ExGuid> {
45 let id = ExGuid {89 let id = ExGuid {
crates/onestore/src/snapshot.rs created+76
...@@ -0,0 +1,76 @@
1use crate::{RevisionIndex, Store};
2use std::io;
3
4fn read_exact(
5 read: &mut impl FnMut(u64, &mut [u8]) -> io::Result<usize>,
6 mut offset: u64,
7 mut output: &mut [u8],
8) -> io::Result<()> {
9 while !output.is_empty() {
10 match read(offset, output) {
11 Ok(0) => return Err(io::ErrorKind::UnexpectedEof.into()),
12 Ok(count) if count <= output.len() => {
13 offset += count as u64;
14 output = &mut output[count..];
15 }
16 Ok(_) => return Err(io::ErrorKind::InvalidData.into()),
17 Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
18 Err(error) => return Err(error),
19 }
20 }
21 Ok(())
22}
23
24/// Reads a bounded snapshot; the caller must provide fresh I/O and exclude in-place maintenance.
25/// Includes unpublished trailing bytes so subsequent commits can validate the physical file.
26pub fn read_snapshot(
27 mut read: impl FnMut(u64, &mut [u8]) -> io::Result<usize>,
28 limit: usize,
29) -> io::Result<Option<Vec<u8>>> {
30 let mut header = [0; 1024];
31 read_exact(&mut read, 0, &mut header)?;
32 let length = u64::from_le_bytes(header[196..204].try_into().unwrap());
33 let length = usize::try_from(length)
34 .ok()
35 .filter(|length| (1024..=limit).contains(length))
36 .ok_or(io::ErrorKind::InvalidData)?;
37 let mut bytes = Vec::new();
38 bytes.try_reserve_exact(length).map_err(io::Error::other)?;
39 bytes.extend_from_slice(&header);
40 bytes.resize(length, 0);
41 if let Err(error) = read_exact(&mut read, 1024, &mut bytes[1024..]) {
42 // Native writers can shorten unused storage before publishing the new expected length.
43 return if error.kind() == io::ErrorKind::UnexpectedEof {
44 Ok(None)
45 } else {
46 Err(error)
47 };
48 }
49 let mut tail = [0; 65536];
50 loop {
51 let size = (limit - bytes.len()).clamp(1, tail.len());
52 match read(bytes.len() as u64, &mut tail[..size]) {
53 Ok(0) => break,
54 Ok(count) if count <= size && count <= limit - bytes.len() => {
55 bytes.try_reserve_exact(count).map_err(io::Error::other)?;
56 bytes.extend_from_slice(&tail[..count]);
57 }
58 Ok(_) => return Err(io::ErrorKind::InvalidData.into()),
59 Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
60 Err(error) => return Err(error),
61 }
62 }
63 let mut after = [0; 1024];
64 read_exact(&mut read, 0, &mut after)?;
65 if header != after {
66 return Ok(None);
67 }
68 let parsed = Store::parse(&bytes).and_then(|store| {
69 if !store.checksum_mismatches.is_empty() {
70 return Ok(false);
71 }
72 RevisionIndex::parse(&store)?.validate_current()?;
73 Ok(true)
74 });
75 Ok(matches!(parsed, Ok(true)).then_some(bytes))
76}
crates/onestore/src/store.rs+1-1
...@@ -82,7 +82,7 @@ pub struct Header {...@@ -82,7 +82,7 @@ pub struct Header {
82}82}
8383
84impl Header {84impl Header {
85 fn parse(data: &[u8]) -> Result<Self> {85 pub(crate) fn parse(data: &[u8]) -> Result<Self> {
86 let mut c = Cursor {86 let mut c = Cursor {
87 bytes: data,87 bytes: data,
88 offset: 0,88 offset: 0,
crates/onestore/src/write.rs+355-90
...@@ -4,7 +4,13 @@ use crate::{...@@ -4,7 +4,13 @@ use crate::{
4 store::{crc, transaction_crc},4 store::{crc, transaction_crc},
5};5};
66
7use std::collections::{BTreeMap, BTreeSet};7use std::{
8 collections::{BTreeMap, BTreeSet},
9 sync::Arc,
10};
11
12#[cfg(test)]
13mod tests;
814
9type Result<T> = std::result::Result<T, Error>;15type Result<T> = std::result::Result<T, Error>;
1016
...@@ -98,109 +104,183 @@ fn compact(id: ExGuid, table: &BTreeMap<u32, [u8; 16]>) -> Result<[u8; 4]> {...@@ -98,109 +104,183 @@ fn compact(id: ExGuid, table: &BTreeMap<u32, [u8; 16]>) -> Result<[u8; 4]> {
98 Ok(((index << 8) | id.n).to_le_bytes())104 Ok(((index << 8) | id.n).to_le_bytes())
99}105}
100106
107fn field_length(property: &crate::Property<'_>, set_lengths: &[usize]) -> usize {
108 match &property.value {
109 Value::NoData => 0,
110 Value::Bytes(bytes) => bytes.len() + usize::from(property.id >> 26 & 31 == 7) * 4,
111 Value::References { .. } => usize::from(property.id >> 26 & 1 != 0) * 4,
112 Value::Sets(children) => {
113 let prefix = if property.id >> 26 & 31 == 16 {
114 if children.is_empty() { 4 } else { 8 }
115 } else {
116 0
117 };
118 prefix + children.clone().map(|i| set_lengths[i]).sum::<usize>()
119 }
120 }
121}
122
101fn patch_properties(123fn patch_properties(
102 blob: &[u8],124 blob: &[u8],
103 updates: &[(u32, &[u8])],125 updates: &[(u32, &[u8])],
104 insert: Option<(u32, &[u8])>,126 inserts: &[(u32, &[u8])],
105) -> Result<Vec<u8>> {127) -> Result<Vec<u8>> {
106 let properties = PropertySets::parse(blob)?;128 let properties = PropertySets::parse(blob)?;
129 let root = &properties.sets[0];
130 let ids = properties.root_ids.as_ptr().addr() - blob.as_ptr().addr();
131 let ids_end = ids + properties.root_ids.len();
132 let body_end = blob.len() - properties.padding.len();
133 let mut set_lengths = vec![0; properties.sets.len()];
134 for (i, set) in properties.sets.iter().enumerate().rev() {
135 set_lengths[i] = 2
136 + set.len() * 4
137 + set
138 .iter()
139 .map(|p| field_length(p, &set_lengths))
140 .sum::<usize>();
141 }
142 let mut offsets = Vec::with_capacity(root.len());
143 let mut offset = ids_end;
144 for property in root {
145 offsets.push(offset);
146 offset += field_length(property, &set_lengths);
147 }
107 let mut patches = Vec::new();148 let mut patches = Vec::new();
108 for (i, &(property, value)) in updates.iter().chain(insert.iter()).enumerate() {149 let mut added_ids = Vec::new();
109 if updates[..i.min(updates.len())]150 let mut added_fields = Vec::new();
110 .iter()151 let mut added_references = Vec::new();
111 .any(|(id, _)| *id == property)152 let object_header = u32::from_le_bytes(blob[..4].try_into().unwrap());
112 {153 let mut object_count = i64::from(object_header & 0xffffff);
154 let mut seen = BTreeSet::new();
155 for (i, &(property, value)) in updates.iter().chain(inserts).enumerate() {
156 if !seen.insert(property & 0x7fffffff) {
113 return Err(Error {157 return Err(Error {
114 offset: 0,158 offset: 0,
115 message: "Duplicate property update",159 message: "Duplicate property update",
116 });160 });
117 }161 }
118 let kind = (property >> 26) & 0x1f;162 let kind = (property >> 26) & 31;
119 if !(3..=7).contains(&kind) {163 let valid = match kind {
120 return Err(Error {164 2 => value.is_empty(),
121 offset: 0,165 3..=6 => value.len() == 1 << (kind - 3),
122 message: "Property does not contain scalar bytes",166 7 => value.len() < 0x40000000,
123 });167 8 => value.len() == 4,
124 }168 9 => value.len().is_multiple_of(4) && value.len() / 4 <= 0xffffff,
125 if (kind == 7 && value.len() >= 0x40000000) || (kind != 7 && value.len() != 1 << (kind - 3))169 _ => {
126 {170 return Err(Error {
171 offset: 0,
172 message: "Property type cannot be patched",
173 });
174 }
175 };
176 if !valid || (kind != 2 && property & 0x80000000 != 0) {
127 return Err(Error {177 return Err(Error {
128 offset: 0,178 offset: 0,
129 message: "Replacement has an invalid property length",179 message: "Replacement has an invalid property value",
130 });180 });
131 }181 }
132 let matches: Vec<_> = properties.sets[0]182 let matches: Vec<_> = root
133 .iter()183 .iter()
134 .filter(|candidate| candidate.id == property)184 .enumerate()
185 .filter(|(_, p)| p.id & 0x7fffffff == property & 0x7fffffff)
135 .collect();186 .collect();
136 let adding = i == updates.len();187 let adding = i >= updates.len();
137 if matches.len() != usize::from(!adding) {188 if matches.len() != usize::from(!adding) {
138 return Err(Error {189 return Err(Error {
139 offset: 0,190 offset: 0,
140 message: "Property is missing or duplicated",191 message: "Property is missing or duplicated",
141 });192 });
142 }193 }
143 let previous = if adding {
144 None
145 } else {
146 let Value::Bytes(previous) = matches[0].value else {
147 return Err(Error {
148 offset: 0,
149 message: "Property does not contain scalar bytes",
150 });
151 };
152 if previous == value {
153 continue;
154 }
155 Some(previous)
156 };
157 let mut encoded = Vec::new();194 let mut encoded = Vec::new();
158 if kind == 7 {195 match kind {
159 encoded.extend_from_slice(&u32::try_from(value.len()).unwrap().to_le_bytes());196 7 => encoded.extend_from_slice(&(value.len() as u32).to_le_bytes()),
197 9 => encoded.extend_from_slice(&(value.len() as u32 / 4).to_le_bytes()),
198 _ => {}
199 }
200 if (3..=7).contains(&kind) {
201 encoded.extend_from_slice(value);
160 }202 }
161 encoded.extend_from_slice(value);203 if adding {
162 if let Some(previous) = previous {204 added_ids.extend_from_slice(&property.to_le_bytes());
163 let start = previous.as_ptr().addr() - blob.as_ptr().addr();205 added_fields.extend_from_slice(&encoded);
164 patches.push((206 if kind >= 8 {
165 start - if kind == 7 { 4 } else { 0 },207 object_count += (value.len() / 4) as i64;
166 start + previous.len(),208 added_references.extend_from_slice(value);
167 encoded,209 }
168 ));
169 } else {210 } else {
170 let count = u16::try_from(properties.sets[0].len() + 1).map_err(|_| Error {211 let (index, previous) = matches[0];
171 offset: 0,212 if kind == 2 && previous.id != property {
172 message: "Root property count exceeds the format limit",213 patches.push((
173 })?;214 ids + index * 4,
174 let ids = properties.root_ids.as_ptr().addr() - blob.as_ptr().addr();215 ids + index * 4 + 4,
175 patches.push((ids - 2, ids, count.to_le_bytes().to_vec()));216 index,
176 let end = ids + properties.root_ids.len();217 property.to_le_bytes().to_vec(),
177 patches.push((end, end, property.to_le_bytes().to_vec()));218 ));
178 let end = blob.len() - properties.padding.len();219 }
179 patches.push((end, end, encoded));220 let start = offsets[index];
221 let end = start + field_length(previous, &set_lengths);
222 if blob[start..end] != encoded {
223 patches.push((start, end, index, encoded));
224 }
225 if let Value::References { compact_ids, .. } = previous.value {
226 object_count += (value.len() / 4) as i64 - (compact_ids.len() / 4) as i64;
227 if compact_ids != value {
228 let start = compact_ids.as_ptr().addr() - blob.as_ptr().addr();
229 patches.push((start, start + compact_ids.len(), index, value.to_vec()));
230 }
231 }
180 }232 }
181 }233 }
234 if !(0..=0xffffff).contains(&object_count) {
235 return Err(Error {
236 offset: 0,
237 message: "Object reference stream exceeds the format limit",
238 });
239 }
240 let header = (object_header & 0xff000000) | object_count as u32;
241 if header != object_header {
242 patches.push((0, 4, 0, header.to_le_bytes().to_vec()));
243 }
244 if !added_references.is_empty() {
245 let end = 4 + (object_header as usize & 0xffffff) * 4;
246 patches.push((end, end, usize::MAX, added_references));
247 }
248 if !inserts.is_empty() {
249 let count = u16::try_from(root.len() + inserts.len()).map_err(|_| Error {
250 offset: ids - 2,
251 message: "Root property count exceeds the format limit",
252 })?;
253 patches.push((ids - 2, ids, 0, count.to_le_bytes().to_vec()));
254 patches.push((ids_end, ids_end, usize::MAX - 1, added_ids));
255 patches.push((body_end, body_end, usize::MAX, added_fields));
256 }
182 if patches.is_empty() {257 if patches.is_empty() {
183 return Ok(blob.to_vec());258 return Ok(blob.to_vec());
184 }259 }
185 patches.sort_by_key(|(start, end, _)| (*start, *end));260 patches.sort_by_key(|(start, end, order, _)| (*start, *end, *order));
186 let mut changed = Vec::new();261 let mut changed = Vec::new();
187 let mut cursor = 0;262 let mut cursor = 0;
188 for (start, end, value) in patches {263 for (start, end, _, value) in patches {
264 if start < cursor {
265 return Err(Error {
266 offset: start,
267 message: "Property patches overlap",
268 });
269 }
189 changed.extend_from_slice(&blob[cursor..start]);270 changed.extend_from_slice(&blob[cursor..start]);
190 changed.extend_from_slice(&value);271 changed.extend_from_slice(&value);
191 cursor = end;272 cursor = end;
192 }273 }
193 changed.extend_from_slice(&blob[cursor..blob.len() - properties.padding.len()]);274 changed.extend_from_slice(&blob[cursor..body_end]);
194 changed.resize(changed.len().next_multiple_of(8), 0);275 changed.resize(changed.len().next_multiple_of(8), 0);
195 PropertySets::parse(&changed)?;276 PropertySets::parse(&changed)?;
196
197 Ok(changed)277 Ok(changed)
198}278}
199279
200pub(crate) struct ObjectEdit<'a> {280pub(crate) struct ObjectEdit<'a> {
201 pub object: ExGuid,281 pub object: ExGuid,
202 pub updates: &'a [(u32, &'a [u8])],282 pub updates: &'a [(u32, &'a [u8])],
203 pub insert: Option<(u32, &'a [u8])>,283 pub inserts: &'a [(u32, &'a [u8])],
204}284}
205285
206/// Replaces a root-level scalar byte property in the default active revision.286/// Replaces a root-level scalar byte property in the default active revision.
...@@ -213,13 +293,19 @@ pub fn replace_property_bytes(...@@ -213,13 +293,19 @@ pub fn replace_property_bytes(
213 property: u32,293 property: u32,
214 value: &[u8],294 value: &[u8],
215) -> Result<Vec<u8>> {295) -> Result<Vec<u8>> {
296 if !(3..=7).contains(&((property >> 26) & 31)) {
297 return Err(Error {
298 offset: 0,
299 message: "Property does not contain scalar bytes",
300 });
301 }
216 replace_objects(302 replace_objects(
217 source,303 source,
218 space,304 space,
219 &[ObjectEdit {305 &[ObjectEdit {
220 object: object_id,306 object: object_id,
221 updates: &[(property, value)],307 updates: &[(property, value)],
222 insert: None,308 inserts: &[],
223 }],309 }],
224 )310 )
225}311}
...@@ -228,6 +314,96 @@ pub(crate) fn replace_objects(...@@ -228,6 +314,96 @@ pub(crate) fn replace_objects(
228 source: &[u8],314 source: &[u8],
229 space: ExGuid,315 space: ExGuid,
230 edits: &[ObjectEdit<'_>],316 edits: &[ObjectEdit<'_>],
317) -> Result<Vec<u8>> {
318 write_revision(source, space, |revision| {
319 let mut changed = BTreeMap::new();
320 for edit in edits {
321 if changed.contains_key(&edit.object) {
322 return Err(Error {
323 offset: 0,
324 message: "Duplicate object edit",
325 });
326 }
327 let object = revision.objects.get(&edit.object).ok_or(Error {
328 offset: 0,
329 message: "Object is absent from the active revision",
330 })?;
331 let ObjectData::Properties(blob) = object.data else {
332 return Err(Error {
333 offset: 0,
334 message: "Object does not contain editable properties",
335 });
336 };
337 changed.insert(
338 edit.object,
339 PropertyObject {
340 jcid: object.jcid,
341 bytes: patch_properties(blob, edit.updates, edit.inserts)?,
342 global_ids: Arc::clone(&object.global_ids),
343 },
344 );
345 }
346 Ok(changed)
347 })
348}
349
350pub(crate) struct PropertyObject {
351 pub jcid: u32,
352 pub bytes: Vec<u8>,
353 pub global_ids: Arc<BTreeMap<u32, [u8; 16]>>,
354}
355
356impl PropertyObject {
357 pub fn from_object(object: &crate::Object<'_>) -> Result<Self> {
358 let ObjectData::Properties(bytes) = object.data else {
359 return Err(Error {
360 offset: 0,
361 message: "Object does not contain editable properties",
362 });
363 };
364 Ok(Self {
365 jcid: object.jcid,
366 bytes: bytes.to_vec(),
367 global_ids: Arc::clone(&object.global_ids),
368 })
369 }
370
371 pub fn set(&mut self, values: &[(u32, &[u8])]) -> Result<()> {
372 let properties = PropertySets::parse(&self.bytes)?;
373 let (updates, inserts): (Vec<_>, Vec<_>) = values.iter().copied().partition(|(id, _)| {
374 properties.sets[0]
375 .iter()
376 .any(|p| p.id & 0x7fffffff == id & 0x7fffffff)
377 });
378 self.bytes = patch_properties(&self.bytes, &updates, &inserts)?;
379 Ok(())
380 }
381
382 pub fn reference(&mut self, id: ExGuid) -> Result<[u8; 4]> {
383 if !self.global_ids.values().any(|guid| *guid == id.guid) {
384 let mut index = 0;
385 for key in self.global_ids.keys() {
386 if *key != index {
387 break;
388 }
389 index += 1;
390 }
391 if index >= 0xffffff || id.guid == [0; 16] {
392 return Err(Error {
393 offset: 0,
394 message: "Object identity cannot be added to the global ID table",
395 });
396 }
397 Arc::make_mut(&mut self.global_ids).insert(index, id.guid);
398 }
399 compact(id, &self.global_ids)
400 }
401}
402
403pub(crate) fn write_revision(
404 source: &[u8],
405 space: ExGuid,
406 edit: impl FnOnce(&crate::ResolvedRevision<'_>) -> Result<BTreeMap<ExGuid, PropertyObject>>,
231) -> Result<Vec<u8>> {407) -> Result<Vec<u8>> {
232 let store = Store::parse(source)?;408 let store = Store::parse(source)?;
233 let is_section = store.header.file_type == FileType::Section;409 let is_section = store.header.file_type == FileType::Section;
...@@ -247,46 +423,136 @@ pub(crate) fn replace_objects(...@@ -247,46 +423,136 @@ pub(crate) fn replace_objects(
247 offset: 0,423 offset: 0,
248 message: "Object space has no active default revision",424 message: "Object space has no active default revision",
249 })?;425 })?;
250 let revision = index.resolve(space, rid)?;426 let mut revision = index.resolve(space, rid)?;
251 let reachable = revision.reachable()?;427 let reachable = revision.reachable()?;
252 let mut changed = BTreeMap::new();428 let mut replacements = edit(&revision)?;
253 for (i, edit) in edits.iter().enumerate() {429 for (id, replacement) in &replacements {
254 if edits[..i]430 if let Some(object) = revision.objects.get(id) {
255 .iter()431 if !reachable.contains(id) {
256 .any(|previous| previous.object == edit.object)432 return Err(Error {
257 {433 offset: 0,
434 message: "Object is not reachable in the active revision",
435 });
436 }
437 if object.jcid & 0x100000 != 0 {
438 return Err(Error {
439 offset: 0,
440 message: "Read-only object requires a new identity",
441 });
442 }
443 if replacement.jcid != object.jcid || !matches!(object.data, ObjectData::Properties(_))
444 {
445 return Err(Error {
446 offset: 0,
447 message: "An existing object's type cannot be changed",
448 });
449 }
450 } else if !is_section {
258 return Err(Error {451 return Err(Error {
259 offset: 0,452 offset: 0,
260 message: "Duplicate object edit",453 message: "New objects require a section file",
261 });454 });
262 }455 }
263 if !reachable.contains(&edit.object) {456 if replacement.jcid & 0x20000 == 0 || replacement.global_ids.keys().any(|i| *i > 0xffffff) {
264 return Err(Error {457 return Err(Error {
265 offset: 0,458 offset: 0,
266 message: "Object is not reachable in the active revision",459 message: "Invalid property object declaration",
267 });460 });
268 }461 }
269 let object = &revision.objects[&edit.object];462 compact(*id, &replacement.global_ids)?;
270 if object.jcid & 0x100000 != 0 {463 PropertySets::parse(&replacement.bytes)?;
271 return Err(Error {464 }
272 offset: 0,465 // Native coalescing of duplicate readonly styles can leave dangling references.
273 message: "Read-only object requires a new identity",466 let mut aliases = BTreeMap::new();
274 });467 for (id, replacement) in &replacements {
468 if revision.objects.contains_key(id)
469 || replacement.jcid & 0x100000 == 0
470 || PropertySets::parse(&replacement.bytes)?
471 .sets
472 .iter()
473 .flatten()
474 .any(|p| matches!(p.value, Value::References { .. }))
475 {
476 continue;
275 }477 }
276 let ObjectData::Properties(blob) = object.data else {478 let existing = revision.objects.iter().find_map(|(other, object)| {
277 return Err(Error {479 (reachable.contains(other)
278 offset: 0,480 && object.jcid == replacement.jcid
279 message: "Object does not contain editable properties",481 && object.data == ObjectData::Properties(&replacement.bytes))
280 });482 .then_some(*other)
281 };483 });
282 let bytes = patch_properties(blob, edit.updates, edit.insert)?;484 let existing = existing.or_else(|| {
283 if bytes != blob {485 replacements.range(..id).find_map(|(other, object)| {
284 changed.insert(edit.object, bytes);486 (object.jcid == replacement.jcid && object.bytes == replacement.bytes)
487 .then_some(*aliases.get(other).unwrap_or(other))
488 })
489 });
490 if let Some(existing) = existing {
491 aliases.insert(*id, existing);
285 }492 }
286 }493 }
287 if changed.is_empty() {494 for id in aliases.keys() {
495 replacements.remove(id);
496 }
497 for object in replacements.values_mut() {
498 let mut remapped = Vec::new();
499 for property in PropertySets::parse(&object.bytes)?.sets.iter().flatten() {
500 if let Value::References {
501 stream: crate::IdStream::Objects,
502 compact_ids,
503 } = property.value
504 {
505 for bytes in compact_ids.chunks_exact(4) {
506 let offset = bytes.as_ptr().addr() - object.bytes.as_ptr().addr();
507 let id = crate::bytes::Cursor { bytes, offset }.compact(&object.global_ids)?;
508 if let Some(existing) = aliases.get(&id) {
509 remapped.push((offset, *existing));
510 }
511 }
512 }
513 }
514 for (offset, id) in remapped {
515 let reference = object.reference(id)?;
516 object.bytes[offset..offset + 4].copy_from_slice(&reference);
517 }
518 }
519 replacements.retain(|id, replacement| {
520 !revision.objects.get(id).is_some_and(|object| {
521 object.data == ObjectData::Properties(&replacement.bytes)
522 && object.global_ids == replacement.global_ids
523 })
524 });
525 if replacements.is_empty() {
288 return Ok(source.to_vec());526 return Ok(source.to_vec());
289 }527 }
528 let mut changed: BTreeSet<_> = replacements.keys().copied().collect();
529 for (id, replacement) in &replacements {
530 revision.objects.insert(
531 *id,
532 crate::Object {
533 jcid: replacement.jcid,
534 reference_count: 0,
535 data: ObjectData::Properties(&replacement.bytes),
536 global_ids: Arc::clone(&replacement.global_ids),
537 },
538 );
539 }
540 let incoming = revision.reference_counts()?;
541 if replacements.keys().any(|id| !incoming.contains_key(id)) {
542 return Err(Error {
543 offset: 0,
544 message: "Edited object is not reachable in the resulting revision",
545 });
546 }
547 for (id, object) in &mut revision.objects {
548 if reachable.contains(id) || incoming.contains_key(id) {
549 let count = incoming.get(id).copied().unwrap_or(0);
550 if object.reference_count != count {
551 object.reference_count = count;
552 changed.insert(*id);
553 }
554 }
555 }
290556
291 // Native cold-open fails on long dependency chains; cap their depth at 512.557 // Native cold-open fails on long dependency chains; cap their depth at 512.
292 let checkpoint = std::iter::successors(Some(rid), |id| {558 let checkpoint = std::iter::successors(Some(rid), |id| {
...@@ -297,7 +563,7 @@ pub(crate) fn replace_objects(...@@ -297,7 +563,7 @@ pub(crate) fn replace_objects(
297 let selected: Vec<_> = revision563 let selected: Vec<_> = revision
298 .objects564 .objects
299 .iter()565 .iter()
300 .filter(|(id, _)| checkpoint || changed.contains_key(id))566 .filter(|(id, _)| checkpoint || changed.contains(id))
301 .collect();567 .collect();
302 let toc_table = if checkpoint && !is_section {568 let toc_table = if checkpoint && !is_section {
303 if selected.iter().any(|(_, object)| {569 if selected.iter().any(|(_, object)| {
...@@ -398,8 +664,7 @@ pub(crate) fn replace_objects(...@@ -398,8 +664,7 @@ pub(crate) fn replace_objects(
398 }664 }
399 group.push(node(0x73, None, &declaration)?);665 group.push(node(0x73, None, &declaration)?);
400 }666 }
401 ObjectData::Properties(previous) => {667 ObjectData::Properties(bytes) => {
402 let bytes = changed.get(&id).map(Vec::as_slice).unwrap_or(previous);
403 let references = object.references()?;668 let references = object.references()?;
404 let flags = u8::from(!references.objects.is_empty())669 let flags = u8::from(!references.objects.is_empty())
405 | (u8::from(670 | (u8::from(
...@@ -422,7 +687,7 @@ pub(crate) fn replace_objects(...@@ -422,7 +687,7 @@ pub(crate) fn replace_objects(
422 }687 }
423 }688 }
424 append(&mut output, &mapped)?689 append(&mut output, &mapped)?
425 } else if changed.contains_key(&id) {690 } else if replacements.contains_key(&id) {
426 append(&mut output, bytes)?691 append(&mut output, bytes)?
427 } else {692 } else {
428 Chunk {693 Chunk {
crates/onestore/src/write/tests.rs created+680
...@@ -0,0 +1,680 @@
1use super::patch_properties;
2use crate::{PropertySets, Value, create::properties};
3
4#[test]
5fn document_insertions_and_formatting_respect_readonly_ancestors() {
6 use super::{PropertyObject, write_revision};
7 use crate::{ExGuid, Insertion, PreparedEdit, RevisionIndex, Store};
8 use std::collections::BTreeMap;
9 let source = crate::create_section("readonly.one", "Original", "Author").unwrap();
10 let store = Store::parse(&source).unwrap();
11 let index = RevisionIndex::parse(&store).unwrap();
12 let (sid, page, outline, paragraph) = index
13 .spaces
14 .iter()
15 .find_map(|(sid, s)| {
16 let raw = index
17 .resolve(*sid, s.labels[&(ExGuid::default(), 1)])
18 .unwrap();
19 let by_type = |jcid| {
20 raw.objects
21 .iter()
22 .find_map(|(id, o)| (o.jcid == jcid).then_some(*id))
23 };
24 Some((
25 *sid,
26 by_type(0x6000b)?,
27 by_type(0x6000c)?,
28 by_type(0x6000d)?,
29 ))
30 })
31 .unwrap();
32 for blocked in [page, outline, paragraph] {
33 let protected = write_revision(&source, sid, |raw| {
34 let mut object = PropertyObject::from_object(&raw.objects[&blocked])?;
35 object.set(&[(0x88001cde, &[])])?;
36 Ok(BTreeMap::from([(blocked, object)]))
37 })
38 .unwrap();
39 let child = Insertion::paragraph(paragraph, None, "Nested", "Author").unwrap();
40 assert!(PreparedEdit::insert(&protected, sid, &child).is_err());
41 let raw = index
42 .resolve(sid, index.spaces[&sid].labels[&(ExGuid::default(), 1)])
43 .unwrap();
44 let text = raw
45 .objects
46 .iter()
47 .find_map(|(id, object)| (object.jcid == 0x6000e).then_some(*id))
48 .unwrap();
49 assert!(
50 PreparedEdit::format(
51 &protected,
52 sid,
53 text,
54 1..3,
55 &[crate::TextAttribute::Bold(true)]
56 )
57 .is_err()
58 );
59 if blocked == page {
60 let outline = Insertion::outline(page, 36.0, 36.0, "Outline", "Author").unwrap();
61 assert!(PreparedEdit::insert(&protected, sid, &outline).is_err());
62 }
63 }
64}
65
66fn add_paragraph(source: &[u8], number: u32) -> Vec<u8> {
67 use super::{PropertyObject, compact, write_revision};
68 use crate::{ExGuid, ObjectData, RevisionIndex, Store};
69 use std::{collections::BTreeMap, sync::Arc};
70 let store = Store::parse(source).unwrap();
71 let index = RevisionIndex::parse(&store).unwrap();
72 let space = index
73 .spaces
74 .iter()
75 .find_map(|(sid, s)| {
76 let revision = index
77 .resolve(*sid, s.labels[&(ExGuid::default(), 1)])
78 .unwrap();
79 revision
80 .objects
81 .values()
82 .any(|o| o.jcid == 0x6000c)
83 .then_some(*sid)
84 })
85 .unwrap();
86 write_revision(source, space, |revision| {
87 let (&outline_id, outline) = revision
88 .objects
89 .iter()
90 .find(|(_, o)| o.jcid == 0x6000c)
91 .unwrap();
92 let (&author, _) = revision
93 .objects
94 .iter()
95 .find(|(_, o)| o.jcid == 0x120001)
96 .unwrap();
97 let (&style, _) = revision
98 .objects
99 .iter()
100 .find(|(_, o)| o.jcid == 0x12004d)
101 .unwrap();
102 let mut guid = [0x69; 16];
103 guid[..4].copy_from_slice(&number.to_le_bytes());
104 let paragraph = ExGuid { guid, n: 1 };
105 let text = ExGuid { guid, n: 2 };
106 let mut table = (*outline.global_ids).clone();
107 for guid in [guid, author.guid, style.guid] {
108 if !table.values().any(|previous| *previous == guid) {
109 table.insert(table.last_key_value().map_or(0, |(i, _)| i + 1), guid);
110 }
111 }
112 let table = Arc::new(table);
113 let ObjectData::Properties(blob) = outline.data else {
114 unreachable!()
115 };
116 let parsed = PropertySets::parse(blob).unwrap();
117 let Value::References { compact_ids, .. } = parsed.sets[0]
118 .iter()
119 .find(|p| p.id == 0x24001c20)
120 .unwrap()
121 .value
122 else {
123 unreachable!()
124 };
125 let mut children = compact_ids.to_vec();
126 children.extend_from_slice(&compact(paragraph, &table).unwrap());
127 let modified = crate::create::current_timestamps()?.0.to_le_bytes();
128 let mut changed = BTreeMap::new();
129 changed.insert(
130 outline_id,
131 PropertyObject {
132 jcid: outline.jcid,
133 bytes: patch_properties(
134 blob,
135 &[(0x24001c20, &children), (0x14001d7a, &modified)],
136 &[],
137 )?,
138 global_ids: Arc::clone(&table),
139 },
140 );
141 changed.insert(
142 paragraph,
143 PropertyObject {
144 jcid: 0x6000d,
145 bytes: properties(&[
146 (0x14001d7a, modified.to_vec()),
147 (0x14001d09, modified.to_vec()),
148 (0x0c001c03, vec![1]),
149 (0x24001c1f, compact(text, &table)?.to_vec()),
150 (0x20001d78, compact(author, &table)?.to_vec()),
151 (0x20001d79, compact(author, &table)?.to_vec()),
152 ])?,
153 global_ids: Arc::clone(&table),
154 },
155 );
156 changed.insert(
157 text,
158 PropertyObject {
159 jcid: 0x6000e,
160 bytes: properties(&[
161 (0x14001d7a, modified.to_vec()),
162 (
163 0x1c001c22,
164 format!("Paragraph {number}\0")
165 .encode_utf16()
166 .flat_map(u16::to_le_bytes)
167 .collect(),
168 ),
169 (0x24001e13, compact(style, &table)?.to_vec()),
170 ])?,
171 global_ids: table,
172 },
173 );
174 Ok(changed)
175 })
176 .unwrap()
177}
178
179fn restyle(source: &[u8]) -> Vec<u8> {
180 use super::{PropertyObject, compact, write_revision};
181 use crate::{ExGuid, ObjectData, RevisionIndex, Store};
182 use std::{collections::BTreeMap, sync::Arc};
183 let store = Store::parse(source).unwrap();
184 let index = RevisionIndex::parse(&store).unwrap();
185 let space = index
186 .spaces
187 .iter()
188 .find_map(|(sid, s)| {
189 let revision = index
190 .resolve(*sid, s.labels[&(ExGuid::default(), 1)])
191 .unwrap();
192 revision
193 .objects
194 .values()
195 .any(|o| o.jcid == 0x6000c)
196 .then_some(*sid)
197 })
198 .unwrap();
199 let style = ExGuid {
200 guid: [0x74; 16],
201 n: 1,
202 };
203 write_revision(source, space, |revision| {
204 let previous = revision
205 .objects
206 .values()
207 .find(|o| o.jcid == 0x12004d)
208 .unwrap();
209 let ObjectData::Properties(blob) = previous.data else {
210 unreachable!()
211 };
212 let mut table = (*previous.global_ids).clone();
213 table.insert(table.last_key_value().unwrap().0 + 1, style.guid);
214 let mut changed = BTreeMap::from([(
215 style,
216 PropertyObject {
217 jcid: previous.jcid,
218 bytes: patch_properties(blob, &[], &[(0x88001c04, &[])])?,
219 global_ids: Arc::new(table),
220 },
221 )]);
222 for (id, text) in revision.objects.iter().filter(|(_, o)| o.jcid == 0x6000e) {
223 let ObjectData::Properties(blob) = text.data else {
224 unreachable!()
225 };
226 let mut table = (*text.global_ids).clone();
227 table.insert(table.last_key_value().unwrap().0 + 1, style.guid);
228 changed.insert(
229 *id,
230 PropertyObject {
231 jcid: text.jcid,
232 bytes: patch_properties(blob, &[(0x24001e13, &compact(style, &table)?)], &[])?,
233 global_ids: Arc::new(table),
234 },
235 );
236 }
237 Ok(changed)
238 })
239 .unwrap()
240}
241
242#[test]
243fn replaced_readonly_styles_retain_history_with_zero_current_references() {
244 use crate::{ExGuid, RevisionIndex, Store};
245 let source = add_paragraph(
246 &crate::create_section("style.one", "Original", "Author").unwrap(),
247 1,
248 );
249 let changed = restyle(&source);
250 let store = Store::parse(&changed).unwrap();
251 let index = RevisionIndex::parse(&store).unwrap();
252 index.validate_current().unwrap();
253 let mut styles = Vec::new();
254 for (sid, s) in &index.spaces {
255 let revision = index
256 .resolve(*sid, s.labels[&(ExGuid::default(), 1)])
257 .unwrap();
258 for (id, object) in &revision.objects {
259 if object.jcid == 0x12004d {
260 styles.push((object.reference_count, id.guid));
261 }
262 }
263 }
264 styles.sort();
265 assert_eq!(styles.len(), 2);
266 assert_eq!(styles[0].0, 0);
267 assert_eq!(styles[1], (2, [0x74; 16]));
268 let document = crate::document::Document::parse(&index).unwrap();
269 let mut count = 0;
270 for space in document.spaces.values() {
271 for revision in space.revisions.values() {
272 for (id, node) in &revision.nodes {
273 if matches!(node.kind, crate::document::Kind::RichText { .. }) {
274 for run in revision.text_runs(*id).unwrap() {
275 assert_eq!(run.format.bold, Some(true));
276 }
277 count += 1;
278 }
279 }
280 }
281 }
282 assert_eq!(count, 2);
283}
284
285#[test]
286#[ignore = "Writes cold-native candidates to ONESTORE_GROWTH_OUTPUT"]
287fn export_native_growth_candidates() {
288 let output = std::path::PathBuf::from(std::env::var_os("ONESTORE_GROWTH_OUTPUT").unwrap());
289 std::fs::create_dir(&output).unwrap();
290 let mut source = crate::create_section("growth.one", "Original", "Author").unwrap();
291 for number in 1..=24 {
292 source = add_paragraph(&source, number);
293 }
294 std::fs::write(output.join("growth.one"), &source).unwrap();
295 std::fs::write(output.join("restyled.one"), restyle(&source)).unwrap();
296}
297
298#[test]
299fn atomic_graph_growth_preserves_history_and_counts_shared_readonly_objects() {
300 use crate::{ExGuid, RevisionIndex, Store};
301 let initial = crate::create_section("growth.one", "Original", "Author").unwrap();
302 let mut source = initial.clone();
303 for number in 1..=24 {
304 source = add_paragraph(&source, number);
305 let store = Store::parse(&source).unwrap();
306 let index = RevisionIndex::parse(&store).unwrap();
307 index.validate_current().unwrap();
308 assert!(store.checksum_mismatches.is_empty());
309 assert_eq!(store.header.transaction_count, number + 1);
310 let mut text_count = 0;
311 for (sid, s) in &index.spaces {
312 let revision = index
313 .resolve(*sid, s.labels[&(ExGuid::default(), 1)])
314 .unwrap();
315 let reachable = revision.reachable().unwrap();
316 for id in reachable {
317 let object = &revision.objects[&id];
318 match object.jcid {
319 0x6000e => text_count += 1,
320 0x120001 => assert_eq!(object.reference_count, (number + 1) * 2),
321 0x12004d => assert_eq!(object.reference_count, number + 1),
322 _ => {}
323 }
324 }
325 }
326 assert_eq!(text_count, number + 1);
327 }
328 let initial_store = Store::parse(&initial).unwrap();
329 let initial_index = RevisionIndex::parse(&initial_store).unwrap();
330 let store = Store::parse(&source).unwrap();
331 let index = RevisionIndex::parse(&store).unwrap();
332 for (sid, s) in &initial_index.spaces {
333 let rid = s.labels[&(ExGuid::default(), 1)];
334 let before = initial_index.resolve(*sid, rid).unwrap();
335 let after = index.resolve(*sid, rid).unwrap();
336 assert_eq!(before.roots, after.roots);
337 for (id, object) in &before.objects {
338 assert_eq!(object.data, after.objects[id].data);
339 assert_eq!(object.reference_count, after.objects[id].reference_count);
340 }
341 }
342}
343
344#[test]
345fn changed_graphs_reject_cycles_dangling_and_unreachable_additions() {
346 use super::{PropertyObject, compact, write_revision};
347 use crate::{ExGuid, ObjectData, RevisionIndex, Store};
348 use std::{collections::BTreeMap, sync::Arc};
349 let source = crate::create_section("invalid.one", "Original", "Author").unwrap();
350 let store = Store::parse(&source).unwrap();
351 let index = RevisionIndex::parse(&store).unwrap();
352 for (sid, s) in &index.spaces {
353 let revision = index
354 .resolve(*sid, s.labels[&(ExGuid::default(), 1)])
355 .unwrap();
356 let Some((&oid, outline)) = revision.objects.iter().find(|(_, o)| o.jcid == 0x6000c) else {
357 continue;
358 };
359 let ObjectData::Properties(blob) = outline.data else {
360 unreachable!()
361 };
362 for target in [
363 oid,
364 ExGuid {
365 guid: oid.guid,
366 n: 255,
367 },
368 ] {
369 assert!(
370 write_revision(&source, *sid, |_| Ok(BTreeMap::from([(
371 oid,
372 PropertyObject {
373 jcid: outline.jcid,
374 bytes: patch_properties(
375 blob,
376 &[(0x24001c20, &compact(target, &outline.global_ids)?)],
377 &[]
378 )?,
379 global_ids: Arc::clone(&outline.global_ids),
380 }
381 )])))
382 .is_err()
383 );
384 }
385 let orphan = ExGuid {
386 guid: oid.guid,
387 n: 255,
388 };
389 assert!(
390 write_revision(&source, *sid, |_| Ok(BTreeMap::from([(
391 orphan,
392 PropertyObject {
393 jcid: 0x6000e,
394 bytes: properties(&[])?,
395 global_ids: Arc::clone(&outline.global_ids),
396 }
397 )])))
398 .is_err()
399 );
400 }
401}
402
403#[test]
404fn property_splices_match_independently_encoded_flat_sets() {
405 let mut seed = 0x749391acb66327d5_u64;
406 let mut next = || {
407 seed ^= seed << 13;
408 seed ^= seed >> 7;
409 seed ^= seed << 17;
410 seed
411 };
412 for case in 0..20_000 {
413 let mut original = Vec::new();
414 let mut expected = Vec::new();
415 let mut updates = Vec::new();
416 let mut inserts = Vec::new();
417 for index in 0..next() % 30 {
418 let kind = 2 + (next() % 8) as u32;
419 let mut id = (kind << 26) | index as u32;
420 let length = |random: u64| match kind {
421 2 => 0,
422 3..=6 => 1 << (kind - 3),
423 7 => (random % 20) as usize,
424 8 => 4,
425 9 => (random % 5) as usize * 4,
426 _ => unreachable!(),
427 };
428 let value = (0..length(next()))
429 .map(|_| next() as u8)
430 .collect::<Vec<_>>();
431 if kind == 2 && next() & 1 != 0 {
432 id |= 0x80000000;
433 }
434 original.push((id, value.clone()));
435 if next() & 1 != 0 {
436 if kind == 2 {
437 id ^= 0x80000000;
438 }
439 let value = (0..length(next()))
440 .map(|_| next() as u8)
441 .collect::<Vec<_>>();
442 expected.push((id, value.clone()));
443 updates.push((id, value));
444 } else {
445 expected.push((id, value));
446 }
447 }
448 for index in 0..next() % 6 {
449 let kind = [2, 7, 8, 9][(next() % 4) as usize];
450 let mut id = (kind << 26) | (100 + index as u32);
451 if kind == 2 && next() & 1 != 0 {
452 id |= 0x80000000;
453 }
454 let length = match kind {
455 2 => 0,
456 7 => next() as usize % 10,
457 8 => 4,
458 9 => (next() as usize % 4) * 4,
459 _ => unreachable!(),
460 };
461 let value = (0..length).map(|_| next() as u8).collect::<Vec<_>>();
462 inserts.push((id, value.clone()));
463 expected.push((id, value));
464 }
465 updates.reverse();
466 let updates: Vec<_> = updates
467 .iter()
468 .map(|(id, value)| (*id, value.as_slice()))
469 .collect();
470 let inserts: Vec<_> = inserts
471 .iter()
472 .map(|(id, value)| (*id, value.as_slice()))
473 .collect();
474 let original = properties(&original).unwrap();
475 let actual = patch_properties(&original, &updates, &inserts).unwrap();
476 assert_eq!(actual, properties(&expected).unwrap(), "case {case}");
477 }
478}
479
480#[test]
481fn nested_fields_and_other_reference_streams_remain_byte_exact() {
482 let mut original = 0x40000003_u32.to_le_bytes().to_vec();
483 original.extend_from_slice(&[1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0]);
484 original.extend_from_slice(&0x40000001_u32.to_le_bytes());
485 original.extend_from_slice(&[4, 0, 0, 0]);
486 original.extend_from_slice(&1_u32.to_le_bytes());
487 original.extend_from_slice(&[5, 0, 0, 0]);
488 original.extend_from_slice(&4_u16.to_le_bytes());
489 for id in [0x24000001_u32, 0x40000002, 0x24000003, 0x1c000004] {
490 original.extend_from_slice(&id.to_le_bytes());
491 }
492 original.extend_from_slice(&1_u32.to_le_bytes());
493 let nested_start = original.len();
494 original.extend_from_slice(&1_u32.to_le_bytes());
495 original.extend_from_slice(&0x44003456_u32.to_le_bytes());
496 original.extend_from_slice(&4_u16.to_le_bytes());
497 for id in [0x20000001_u32, 0x28000002, 0x30000003, 0x1c000004] {
498 original.extend_from_slice(&id.to_le_bytes());
499 }
500 original.extend_from_slice(&3_u32.to_le_bytes());
501 original.extend_from_slice(&[91, 92, 93]);
502 let nested_end = original.len();
503 original.extend_from_slice(&1_u32.to_le_bytes());
504 original.extend_from_slice(&2_u32.to_le_bytes());
505 original.extend_from_slice(&[94, 95]);
506 original.resize(original.len().next_multiple_of(8), 0);
507 let changed = patch_properties(
508 &original,
509 &[
510 (0x24000003, &[]),
511 (0x1c000004, &[96, 97, 98, 99]),
512 (0x24000001, &[6, 0, 0, 0, 7, 0, 0, 0]),
513 ],
514 &[(0x24000005, &[8, 0, 0, 0]), (0x88000006, &[])],
515 )
516 .unwrap();
517 let parsed = PropertySets::parse(&changed).unwrap();
518 let previous = PropertySets::parse(&original).unwrap();
519 assert_eq!(parsed.sets[1], previous.sets[1]);
520 let nested = &original[nested_start..nested_end];
521 assert_eq!(
522 changed
523 .windows(nested.len())
524 .filter(|bytes| *bytes == nested)
525 .count(),
526 1
527 );
528 assert_eq!(
529 &changed[4..20],
530 &[6, 0, 0, 0, 7, 0, 0, 0, 2, 0, 0, 0, 8, 0, 0, 0]
531 );
532 assert_eq!(&changed[20..36], &original[16..32]);
533 assert_eq!(parsed.sets[0][3].value, Value::Bytes(&[96, 97, 98, 99]));
534}
535
536#[test]
537fn deep_property_splices_do_not_use_the_call_stack() {
538 let mut bytes = 0x80000000_u32.to_le_bytes().to_vec();
539 for _ in 0..100_000 {
540 bytes.extend_from_slice(&1_u16.to_le_bytes());
541 bytes.extend_from_slice(&0x44000001_u32.to_le_bytes());
542 }
543 bytes.extend_from_slice(&0_u16.to_le_bytes());
544 let changed = patch_properties(&bytes, &[], &[(0x88000002, &[])]).unwrap();
545 let parsed = PropertySets::parse(&changed).unwrap();
546 assert_eq!(parsed.sets.len(), 100_001);
547 assert_eq!(parsed.sets[0].len(), 2);
548 assert_eq!(
549 &changed[14..changed.len() - parsed.padding.len()],
550 &bytes[10..]
551 );
552}
553
554#[test]
555fn invalid_property_splices_are_rejected() {
556 let bytes = properties(&[(0x08000001, vec![]), (0x24000002, vec![])]).unwrap();
557 for (updates, inserts) in [
558 (vec![(0x88000001, &[][..]), (0x08000001, &[])], vec![]),
559 (vec![(0x14000003, &[0; 4][..])], vec![]),
560 (vec![(0x24000002, &[0; 3][..])], vec![]),
561 (vec![(0x88000001, &[1][..])], vec![]),
562 (vec![], vec![(0x88000001, &[][..])]),
563 (vec![], vec![(0x20000003, &[][..])]),
564 ] {
565 assert!(patch_properties(&bytes, &updates, &inserts).is_err());
566 }
567}
568
569#[test]
570fn character_formatting_preserves_inheritance_and_associated_data() {
571 use super::{PropertyObject, write_revision};
572 use crate::{
573 ExGuid, PreparedEdit, RevisionIndex, Store, TextAttribute,
574 document::{Document, Kind},
575 };
576 use std::collections::BTreeMap;
577 let source = crate::create_section("inherited.one", "abcdef", "Author").unwrap();
578 let store = Store::parse(&source).unwrap();
579 let index = RevisionIndex::parse(&store).unwrap();
580 let (sid, text) = index
581 .spaces
582 .iter()
583 .find_map(|(sid, s)| {
584 let raw = index
585 .resolve(*sid, s.labels[&(ExGuid::default(), 1)])
586 .unwrap();
587 raw.objects
588 .iter()
589 .find_map(|(id, o)| (o.jcid == 0x6000e).then_some((*sid, *id)))
590 })
591 .unwrap();
592 for variant in 0..7 {
593 let fixture = write_revision(&source, sid, |raw| {
594 let mut target = PropertyObject::from_object(&raw.objects[&text])?;
595 target.bytes = properties(&[
596 (0x1c001c22, crate::create::string("abcdef")),
597 (0x14001d7a, vec![0; 4]),
598 (0x1c001c0a, crate::create::string("Georgia")),
599 (0x88001c05, Vec::new()),
600 ])?;
601 match variant {
602 1 | 4 | 5 | 6 => {
603 let flag = match variant {
604 1 => 0x88001e16,
605 4 => 0x88001e14,
606 5 => 0x88003401,
607 _ => 0x88001e22,
608 };
609 target.set(&[(flag, &[])])?;
610 }
611 2 => {
612 let author = raw
613 .objects
614 .iter()
615 .find_map(|(id, o)| (o.jcid == 0x120001).then_some(*id))
616 .unwrap();
617 let reference = target.reference(author)?;
618 target.set(&[(0x24003458, &reference)])?;
619 }
620 3 => {
621 // An unknown nested run property must survive a style-only edit byte for byte.
622 let parsed = PropertySets::parse(&target.bytes)?;
623 let at = parsed.root_ids.as_ptr().addr() - target.bytes.as_ptr().addr();
624 let count = parsed.sets[0].len();
625 let end = target.bytes.len() - parsed.padding.len();
626 let mut bytes = target.bytes[..end].to_vec();
627 bytes[at - 2..at].copy_from_slice(&((count + 1) as u16).to_le_bytes());
628 bytes.splice(at + count * 4..at + count * 4, 0x40003499_u32.to_le_bytes());
629 bytes.extend_from_slice(&1_u32.to_le_bytes());
630 bytes.extend_from_slice(&0x44001234_u32.to_le_bytes());
631 bytes.extend_from_slice(&1_u16.to_le_bytes());
632 bytes.extend_from_slice(&0x14001234_u32.to_le_bytes());
633 bytes.extend_from_slice(&0xdeadbeef_u32.to_le_bytes());
634 bytes.resize(bytes.len().next_multiple_of(8), 0);
635 target.bytes = bytes;
636 }
637 _ => {}
638 }
639 Ok(BTreeMap::from([(text, target)]))
640 })
641 .unwrap();
642 let edit = PreparedEdit::format(&fixture, sid, text, 0..6, &[TextAttribute::Bold(true)]);
643 if matches!(variant, 1 | 2 | 4 | 5 | 6) {
644 assert!(edit.is_err());
645 continue;
646 }
647 let edit = edit.unwrap();
648 let store = Store::parse(edit.as_bytes()).unwrap();
649 let index = RevisionIndex::parse(&store).unwrap();
650 let doc = Document::parse(&index).unwrap();
651 let s = &doc.spaces[&sid];
652 let view = &s.revisions[&s.contexts[&ExGuid::default()]];
653 let runs = view.text_runs(text).unwrap();
654 assert_eq!(runs.len(), 1);
655 assert_eq!(runs[0].format.font.as_deref(), Some("Georgia"));
656 assert_eq!(runs[0].format.italic, Some(true));
657 assert_eq!(runs[0].format.bold, Some(true));
658 if variant == 3 {
659 let Kind::RichText { runs, .. } = &view.nodes[&text].kind else {
660 panic!()
661 };
662 let data = &view.nodes[&text].extra[runs[0].extra_set.unwrap()];
663 assert_eq!(data.len(), 1);
664 assert_eq!(data[0].id, 0x14001234);
665 assert!(
666 PreparedEdit::format(&fixture, sid, text, 1..3, &[TextAttribute::Bold(true)])
667 .is_err()
668 );
669 let previous = Store::parse(&fixture).unwrap();
670 let previous = RevisionIndex::parse(&previous).unwrap();
671 let previous_doc = Document::parse(&previous).unwrap();
672 let s = &previous_doc.spaces[&sid];
673 let previous_view = &s.revisions[&s.contexts[&ExGuid::default()]];
674 assert_eq!(
675 format!("{:?}", view.nodes[&text].extra),
676 format!("{:?}", previous_view.nodes[&text].extra)
677 );
678 }
679 }
680}
crates/onestore/tests/edit.rs+201-4
...@@ -2,6 +2,8 @@...@@ -2,6 +2,8 @@
2mod checkpoint;2mod checkpoint;
3#[path = "support/disk.rs"]3#[path = "support/disk.rs"]
4mod disk;4mod disk;
5#[path = "support/trace.rs"]
6mod trace;
57
6use disk::Disk;8use disk::Disk;
7use onestore::{9use onestore::{
...@@ -44,6 +46,201 @@ fn text_runs(source: &[u8], sid: ExGuid, oid: ExGuid) -> serde_json::Value {...@@ -44,6 +46,201 @@ fn text_runs(source: &[u8], sid: ExGuid, oid: ExGuid) -> serde_json::Value {
44 .unwrap()46 .unwrap()
45}47}
4648
49fn assert_refreshed(source: &[u8], confirmed: &[u8]) {
50 assert_eq!(&source[..212], &confirmed[..212]);
51 assert_eq!(&source[252..], &confirmed[252..]);
52 let before = Store::parse(source).unwrap().header;
53 let after = Store::parse(confirmed).unwrap().header;
54 assert_ne!(before.version_id, after.version_id);
55 assert_ne!(before.deny_read_id, after.deny_read_id);
56 assert_eq!(after.generation, before.generation + 1);
57}
58
59#[test]
60fn prepared_publication_preserves_its_identity_through_every_io_failure() {
61 let source =
62 onestore::create_section("prepared.one", "Fictitious: café 🦀", "Fixture").unwrap();
63 let (sid, oid) = target(&source);
64 let source = checkpoint::pending(&source, sid, oid, 0x14001d7a);
65 let edit = onestore::PreparedEdit::text(&source, sid, oid, 0..0, "Prepared 🐈 ").unwrap();
66 let store = Store::parse(edit.as_bytes()).unwrap();
67 let index = RevisionIndex::parse(&store).unwrap();
68 let planned = index.spaces[&sid].labels[&(ExGuid::default(), 1)];
69 let persisted: ExGuid =
70 serde_json::from_str(&serde_json::to_string(&planned).unwrap()).unwrap();
71 let before_store = Store::parse(&source).unwrap();
72 let before_index = RevisionIndex::parse(&before_store).unwrap();
73 assert!(!before_index.spaces[&sid].revisions.contains_key(&persisted));
74 let before = text_runs(&source, sid, oid);
75 let after = text_runs(edit.as_bytes(), sid, oid);
76 for write_limit in [17, 4096] {
77 let disk = |fail_at| Disk {
78 visible: source.clone(),
79 durable: source.clone(),
80 operation: 0,
81 fail_at,
82 write_limit,
83 random: 911,
84 };
85 let mut success = disk(None);
86 edit.commit(&mut success).unwrap();
87 assert_eq!(success.durable, edit.as_bytes());
88 let operations = success.operation;
89 let error = edit.commit(&mut success).unwrap_err();
90 assert_eq!(error.state, CommitState::NotCommitted);
91 assert_eq!(error.error.kind(), std::io::ErrorKind::ResourceBusy);
92 assert_eq!(success.durable, edit.as_bytes());
93 for at in 1..=operations {
94 let mut interrupted = disk(Some(at));
95 let error = edit.commit(&mut interrupted).unwrap_err();
96 let observed = text_runs(&interrupted.durable, sid, oid);
97 let store = Store::parse(&interrupted.durable).unwrap();
98 let index = RevisionIndex::parse(&store).unwrap();
99 let present = index.spaces[&sid].revisions.contains_key(&persisted);
100 assert_eq!(observed, if present { &after } else { &before }.clone());
101 match error.state {
102 CommitState::NotCommitted => assert!(!present),
103 CommitState::Committed => assert!(present),
104 CommitState::Unknown => {}
105 }
106 if present {
107 let snapshot = interrupted.durable.clone();
108 interrupted.visible.clone_from(&snapshot);
109 interrupted.fail_at = None;
110 interrupted.write_limit = 17;
111 onestore::confirm_snapshot(&mut interrupted, &snapshot).unwrap();
112 assert_refreshed(&snapshot, &interrupted.durable);
113 }
114 }
115 }
116}
117
118#[test]
119fn snapshot_confirmation_needs_no_surviving_edit_target() {
120 let mut disk = Disk {
121 visible: SOURCE.to_vec(),
122 durable: Vec::new(),
123 operation: 0,
124 fail_at: None,
125 write_limit: 17,
126 random: 911,
127 };
128 assert!(
129 onestore::replace_text(SOURCE, ExGuid::default(), ExGuid::default(), 0..0, "").is_err()
130 );
131 onestore::confirm_snapshot(&mut disk, SOURCE).unwrap();
132 assert_refreshed(SOURCE, &disk.durable);
133 let flush = disk.operation;
134 for (failure, state) in [
135 (1, CommitState::NotCommitted),
136 (flush, CommitState::Unknown),
137 ] {
138 disk.visible = SOURCE.to_vec();
139 disk.durable.clear();
140 disk.operation = 0;
141 disk.fail_at = Some(failure);
142 assert_eq!(
143 onestore::confirm_snapshot(&mut disk, SOURCE)
144 .unwrap_err()
145 .state,
146 state
147 );
148 }
149}
150
151#[test]
152fn confirmation_notifies_cached_readers_after_interrupted_version_publication() {
153 let source =
154 onestore::create_section("confirmation.one", "Fictitious: before", "Fixture").unwrap();
155 let (sid, oid) = target(&source);
156 let mut snapshot = onestore::replace_text(&source, sid, oid, 0..0, "Recovered ").unwrap();
157 snapshot[212..252].copy_from_slice(&source[212..252]);
158 let expected = text_runs(&snapshot, sid, oid);
159 assert_ne!(expected, text_runs(&source, sid, oid));
160 for write_limit in [1, 17, 40] {
161 let disk = |fail_at| Disk {
162 visible: snapshot.clone(),
163 durable: snapshot.clone(),
164 operation: 0,
165 fail_at,
166 write_limit,
167 random: 911,
168 };
169 let mut success = disk(None);
170 onestore::confirm_snapshot(&mut success, &snapshot).unwrap();
171 assert_refreshed(&snapshot, &success.durable);
172 for failure in 1..=success.operation {
173 let mut interrupted = disk(Some(failure));
174 let error = onestore::confirm_snapshot(&mut interrupted, &snapshot).unwrap_err();
175 assert_ne!(error.state, CommitState::Committed);
176 assert_eq!(text_runs(&interrupted.durable, sid, oid), expected);
177 assert_eq!(&interrupted.durable[..212], &snapshot[..212]);
178 assert_eq!(&interrupted.durable[252..], &snapshot[252..]);
179 }
180 }
181}
182
183#[test]
184fn confirming_visible_text_requires_flush_without_another_revision() {
185 let (sid, oid) = target(SOURCE);
186 let visible = onestore::replace_text(SOURCE, sid, oid, 0..0, "Recovered ").unwrap();
187 let mut disk = Disk {
188 visible: visible.clone(),
189 durable: SOURCE.to_vec(),
190 operation: 0,
191 fail_at: None,
192 write_limit: 0,
193 random: 1,
194 };
195 onestore::commit_text(&mut disk, &visible, sid, oid, 0..0, "").unwrap();
196 assert_eq!(disk.visible, visible);
197 assert_eq!(disk.durable, visible);
198 let flush = disk.operation;
199 for (failure, state) in [
200 (1, CommitState::NotCommitted),
201 (flush, CommitState::Unknown),
202 ] {
203 disk.durable = SOURCE.to_vec();
204 disk.operation = 0;
205 disk.fail_at = Some(failure);
206 let error = onestore::commit_text(&mut disk, &visible, sid, oid, 0..0, "").unwrap_err();
207 assert_eq!(error.state, state);
208 assert_eq!(disk.visible, visible);
209 }
210}
211
212#[test]
213fn confirmation_rejects_changed_or_truncated_physical_tail_before_any_write() {
214 let (sid, oid) = target(SOURCE);
215 let mut source = SOURCE.to_vec();
216 source.resize(3 * 1024 * 1024 + 131, 0);
217 let mut disk = trace::Trace {
218 bytes: source.clone(),
219 events: Vec::new(),
220 };
221 onestore::commit_text(&mut disk, &source, sid, oid, 0..0, "").unwrap();
222 assert_eq!(disk.events.len(), 1);
223 if let trace::Event::Write(offset, bytes) = &disk.events[0] {
224 panic!("Confirmation wrote {} bytes at {offset}", bytes.len());
225 }
226 for changed in [65535, 65536, 1048575, 1048576, 2097152, source.len() - 1] {
227 disk.bytes.clone_from(&source);
228 disk.bytes[changed] ^= 1;
229 disk.events.clear();
230 let error = onestore::commit_text(&mut disk, &source, sid, oid, 0..0, "").unwrap_err();
231 assert_eq!(error.state, CommitState::NotCommitted);
232 assert_eq!(error.error.kind(), std::io::ErrorKind::ResourceBusy);
233 assert!(disk.events.is_empty());
234 }
235 for length in [source.len() - 1, source.len() + 1] {
236 disk.bytes.clone_from(&source);
237 disk.bytes.resize(length, 0);
238 let error = onestore::commit_text(&mut disk, &source, sid, oid, 0..0, "").unwrap_err();
239 assert_eq!(error.state, CommitState::NotCommitted);
240 assert!(disk.events.is_empty());
241 }
242}
243
47fn assert_other_objects_preserved(244fn assert_other_objects_preserved(
48 before: &onestore::ResolvedRevision<'_>,245 before: &onestore::ResolvedRevision<'_>,
49 after: &onestore::ResolvedRevision<'_>,246 after: &onestore::ResolvedRevision<'_>,
...@@ -398,6 +595,8 @@ fn title_text_and_navigation_caches_publish_together() {...@@ -398,6 +595,8 @@ fn title_text_and_navigation_caches_publish_together() {
398 for (source, write_limit) in [(source.as_slice(), 17), (checkpoint.as_slice(), 257)] {595 for (source, write_limit) in [(source.as_slice(), 17), (checkpoint.as_slice(), 257)] {
399 let before = state(source);596 let before = state(source);
400 for replacement in ["Renamed 🦀 日本語", ""] {597 for replacement in ["Renamed 🦀 日本語", ""] {
598 let edit =
599 onestore::PreparedEdit::text(source, sid, *oid, 0..end, replacement).unwrap();
401 let disk = |fail_at| Disk {600 let disk = |fail_at| Disk {
402 visible: source.to_vec(),601 visible: source.to_vec(),
403 durable: source.to_vec(),602 durable: source.to_vec(),
...@@ -407,7 +606,7 @@ fn title_text_and_navigation_caches_publish_together() {...@@ -407,7 +606,7 @@ fn title_text_and_navigation_caches_publish_together() {
407 random: 42,606 random: 42,
408 };607 };
409 let mut success = disk(None);608 let mut success = disk(None);
410 onestore::commit_text(&mut success, source, sid, *oid, 0..end, replacement).unwrap();609 edit.commit(&mut success).unwrap();
411 let after = state(&success.durable);610 let after = state(&success.durable);
412 assert_eq!(after[0]["text"], replacement);611 assert_eq!(after[0]["text"], replacement);
413 assert_eq!(612 assert_eq!(
...@@ -445,9 +644,7 @@ fn title_text_and_navigation_caches_publish_together() {...@@ -445,9 +644,7 @@ fn title_text_and_navigation_caches_publish_together() {
445 assert_other_objects_preserved(&old, &current, *oid);644 assert_other_objects_preserved(&old, &current, *oid);
446 for at in source.len().div_ceil(193)..=success.operation {645 for at in source.len().div_ceil(193)..=success.operation {
447 let mut interrupted = disk(Some(at));646 let mut interrupted = disk(Some(at));
448 let error =647 let error = edit.commit(&mut interrupted).unwrap_err();
449 onestore::commit_text(&mut interrupted, source, sid, *oid, 0..end, replacement)
450 .unwrap_err();
451 let observed = state(&interrupted.durable);648 let observed = state(&interrupted.durable);
452 match error.state {649 match error.state {
453 CommitState::NotCommitted => assert_eq!(observed, before),650 CommitState::NotCommitted => assert_eq!(observed, before),
crates/onestore/tests/formatting.rs created+407
...@@ -0,0 +1,407 @@
1#[path = "support/current.rs"]
2mod current;
3#[path = "support/disk.rs"]
4mod disk;
5use onestore::{
6 ExGuid, PreparedEdit, RevisionIndex, Store, TextAttribute as A,
7 document::{Document, Kind},
8};
9use serde_json::{Value, json};
10fn target(source: &[u8]) -> (ExGuid, ExGuid) {
11 let store = Store::parse(source).unwrap();
12 let index = RevisionIndex::parse(&store).unwrap();
13 let doc = Document::parse(&index).unwrap();
14 let (sid, page) = doc.pages().unwrap()[0];
15 let space = &doc.spaces[&sid];
16 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
17 let mut pending = view.nodes[&page].children.clone();
18 while let Some(id) = pending.pop() {
19 let n = &view.nodes[&id];
20 if matches!(n.kind, Kind::RichText { .. }) {
21 return (sid, id);
22 }
23 pending.extend(&n.children);
24 pending.extend(&n.content);
25 }
26 panic!("Missing text")
27}
28fn characters(source: &[u8], sid: ExGuid, id: ExGuid) -> Vec<(char, Value)> {
29 let store = Store::parse(source).unwrap();
30 let index = RevisionIndex::parse(&store).unwrap();
31 index.validate_current().unwrap();
32 let doc = Document::parse(&index).unwrap();
33 let s = &doc.spaces[&sid];
34 let view = &s.revisions[&s.contexts[&ExGuid::default()]];
35 view.text_runs(id)
36 .unwrap()
37 .into_iter()
38 .flat_map(|r| {
39 let format = serde_json::to_value(r.format).unwrap();
40 r.text.chars().map(move |c| (c, format.clone()))
41 })
42 .collect()
43}
44#[test]
45fn overlapping_unicode_format_edits_match_an_independent_character_model() {
46 let text = "abcdefgh 東京 🦀 café\rSecond\tline";
47 let original = onestore::create_section("format.one", text, "Author").unwrap();
48 let (sid, id) = target(&original);
49 for seed in 1..=16_u64 {
50 let mut rng = seed;
51 let mut source = original.clone();
52 let mut expected = characters(&source, sid, id);
53 let offsets: Vec<u32> = std::iter::once(0)
54 .chain(text.chars().scan(0, |n, c| {
55 *n += c.len_utf16() as u32;
56 Some(*n)
57 }))
58 .collect();
59 for step in 0..24 {
60 let mut next = || {
61 rng ^= rng << 13;
62 rng ^= rng >> 7;
63 rng ^= rng << 17;
64 rng
65 };
66 let start = next() as usize % expected.len();
67 let end = start + 1 + next() as usize % (expected.len() - start);
68 let enabled = next() & 1 != 0;
69 let (attribute, key, value) = match step % 9 {
70 0 => (A::Bold(enabled), "bold", json!(enabled)),
71 1 => (A::Italic(enabled), "italic", json!(enabled)),
72 2 => (A::Underline(enabled), "underline", json!(enabled)),
73 3 => (A::Strike(enabled), "strike", json!(enabled)),
74 4 => (A::Font("Arial".into()), "font", json!("Arial")),
75 5 => (A::FontSize(13.5), "font_size", json!(13.5)),
76 6 => (
77 A::Color(Some([0x24, 0x68, 0xac])),
78 "color",
79 json!(0xac6824_u32),
80 ),
81 7 => (A::Highlight(None), "highlight", json!(0xff000000_u32)),
82 _ => (
83 A::Highlight(Some([0, 255, 0])),
84 "highlight",
85 json!(0x00ff00_u32),
86 ),
87 };
88 let edit = PreparedEdit::format(
89 &source,
90 sid,
91 id,
92 offsets[start]..offsets[end],
93 std::slice::from_ref(&attribute),
94 )
95 .unwrap();
96 for (_, style) in &mut expected[start..end] {
97 style[key] = value.clone();
98 }
99 assert_eq!(
100 characters(edit.as_bytes(), sid, id),
101 expected,
102 "seed {seed}, step {step}"
103 );
104 assert_eq!(
105 PreparedEdit::format(
106 edit.as_bytes(),
107 sid,
108 id,
109 offsets[start]..offsets[end],
110 &[attribute]
111 )
112 .unwrap()
113 .as_bytes(),
114 edit.as_bytes()
115 );
116 let old_store = Store::parse(&source).unwrap();
117 let old = RevisionIndex::parse(&old_store).unwrap();
118 let new_store = Store::parse(edit.as_bytes()).unwrap();
119 let new = RevisionIndex::parse(&new_store).unwrap();
120 let rid = old.spaces[&sid].labels[&(ExGuid::default(), 1)];
121 assert_eq!(
122 format!("{:?}", old.resolve(sid, rid).unwrap()),
123 format!("{:?}", new.resolve(sid, rid).unwrap())
124 );
125 source = edit.as_bytes().to_vec();
126 }
127 }
128}
129#[test]
130fn script_positions_are_exclusive_and_explicit_false_overrides_true() {
131 let source = onestore::create_section("script.one", "abc", "Author").unwrap();
132 let (sid, id) = target(&source);
133 let superscript = PreparedEdit::format(
134 &source,
135 sid,
136 id,
137 0..3,
138 &[A::Superscript(true), A::Bold(true)],
139 )
140 .unwrap();
141 let subscript = PreparedEdit::format(
142 superscript.as_bytes(),
143 sid,
144 id,
145 1..2,
146 &[A::Subscript(true), A::Bold(false)],
147 )
148 .unwrap();
149 let chars = characters(subscript.as_bytes(), sid, id);
150 assert_eq!(chars[0].1["superscript"], true);
151 assert_eq!(chars[0].1["subscript"], false);
152 assert_eq!(chars[0].1["bold"], true);
153 assert_eq!(chars[1].1["superscript"], false);
154 assert_eq!(chars[1].1["subscript"], true);
155 assert_eq!(chars[1].1["bold"], false);
156 assert_eq!(chars[0].1, chars[2].1);
157}
158#[test]
159fn empty_paragraph_style_is_used_by_later_text_edits() {
160 let source = onestore::create_section("empty.one", "", "Author").unwrap();
161 let (sid, id) = target(&source);
162 let formatted = PreparedEdit::format(
163 &source,
164 sid,
165 id,
166 0..0,
167 &[A::Italic(true), A::FontSize(18.0)],
168 )
169 .unwrap();
170 let filled = PreparedEdit::text(formatted.as_bytes(), sid, id, 0..0, "Added 🦀").unwrap();
171 for (_, format) in characters(filled.as_bytes(), sid, id) {
172 assert_eq!(format["italic"], true);
173 assert_eq!(format["font_size"], 18.0);
174 }
175}
176#[test]
177fn invalid_ranges_attributes_and_fields_are_rejected() {
178 let source = onestore::create_section("invalid.one", "a🦀b", "Author").unwrap();
179 let (sid, id) = target(&source);
180 for (start, end) in [(2, 3), (1, 2), (0, 8), (2, 2), (3, 1), (1, 1)] {
181 let range = start..end;
182 assert!(PreparedEdit::format(&source, sid, id, range, &[A::Bold(true)]).is_err());
183 }
184 for attributes in [
185 vec![],
186 vec![A::Bold(true), A::Bold(false)],
187 vec![A::Superscript(true), A::Subscript(true)],
188 vec![A::Font("".into())],
189 vec![A::Font("a\0b".into())],
190 ] {
191 assert!(PreparedEdit::format(&source, sid, id, 0..4, &attributes).is_err());
192 }
193 for size in [f32::NAN, f32::INFINITY, 0.0, 5.5, 130.5, 144.0, 144.5, 12.1] {
194 assert!(PreparedEdit::format(&source, sid, id, 0..4, &[A::FontSize(size)]).is_err());
195 }
196 assert!(PreparedEdit::format(&source, sid, ExGuid::default(), 0..4, &[A::Bold(true)]).is_err());
197}
198#[test]
199fn formatting_publication_faults_preserve_complete_old_or_new_styles() {
200 let source = onestore::create_section("atomic.one", "Before 🦀 after", "Author").unwrap();
201 let (sid, id) = target(&source);
202 let edit = PreparedEdit::format(
203 &source,
204 sid,
205 id,
206 2..10,
207 &[A::Bold(true), A::Color(Some([8, 64, 128]))],
208 )
209 .unwrap();
210 let before = current::current(&source);
211 let after = current::current(edit.as_bytes());
212 for write_limit in [17, 4096] {
213 let disk = |fail_at| disk::Disk {
214 visible: source.clone(),
215 durable: source.clone(),
216 operation: 0,
217 fail_at,
218 write_limit,
219 random: 946,
220 };
221 let mut success = disk(None);
222 edit.commit(&mut success).unwrap();
223 assert_eq!(success.durable, edit.as_bytes());
224 for at in 1..=success.operation {
225 let mut interrupted = disk(Some(at));
226 let failure = edit.commit(&mut interrupted).unwrap_err();
227 let actual = current::current(&interrupted.durable);
228 assert!(actual == before || actual == after, "operation {at}");
229 if failure.state == onestore::CommitState::NotCommitted {
230 assert_eq!(actual, before);
231 }
232 }
233 }
234}
235
236#[test]
237#[ignore = "exports public-API formatting candidates for cold native validation"]
238fn export_native_formatting_candidates() {
239 use std::{fs, path::PathBuf};
240 let output = PathBuf::from(std::env::var_os("ONESTORE_FORMAT_OUTPUT").unwrap());
241 assert!(output.is_absolute());
242 fs::create_dir(&output).unwrap();
243 let source = onestore::create_section(
244 "format.one",
245 "Before café 東京 🦀 after",
246 "Formatting author",
247 )
248 .unwrap();
249 let (sid, id) = target(&source);
250 let mut manifest = Vec::new();
251 let mut save =
252 |name: &str, source: &[u8], sid, id, range: std::ops::Range<u32>, attributes: &[A]| {
253 let prepared =
254 PreparedEdit::format(source, sid, id, range.clone(), attributes).unwrap();
255 fs::write(output.join(format!("{name}.one")), prepared.as_bytes()).unwrap();
256 manifest.push(
257 json!({"name":name,"space":sid,"object":id,"range":range,"attributes":attributes}),
258 );
259 prepared.as_bytes().to_vec()
260 };
261 save(
262 "partial-boolean",
263 &source,
264 sid,
265 id,
266 2..18,
267 &[
268 A::Bold(true),
269 A::Italic(true),
270 A::Underline(true),
271 A::Strike(true),
272 ],
273 );
274 let colored = save(
275 "partial-font-color",
276 &source,
277 sid,
278 id,
279 3..18,
280 &[
281 A::Font("Arial".into()),
282 A::FontSize(13.5),
283 A::Color(Some([24, 96, 160])),
284 A::Highlight(Some([255, 255, 0])),
285 ],
286 );
287 save(
288 "clear-color",
289 &colored,
290 sid,
291 id,
292 6..14,
293 &[A::Color(None), A::Highlight(None)],
294 );
295 let scripted = save("subscript", &source, sid, id, 0..23, &[A::Subscript(true)]);
296 save(
297 "superscript",
298 &scripted,
299 sid,
300 id,
301 6..18,
302 &[A::Superscript(true)],
303 );
304 let bold = save(
305 "bold",
306 &source,
307 sid,
308 id,
309 0..23,
310 &[
311 A::Bold(true),
312 A::Italic(true),
313 A::Underline(true),
314 A::Strike(true),
315 ],
316 );
317 save(
318 "clear-boolean",
319 &bold,
320 sid,
321 id,
322 7..14,
323 &[
324 A::Bold(false),
325 A::Italic(false),
326 A::Underline(false),
327 A::Strike(false),
328 ],
329 );
330 let empty = onestore::create_section("empty.one", "", "Author").unwrap();
331 let (empty_sid, empty_id) = target(&empty);
332 let formatted = PreparedEdit::format(
333 &empty,
334 empty_sid,
335 empty_id,
336 0..0,
337 &[A::FontSize(18.0), A::Italic(true)],
338 )
339 .unwrap();
340 let typed = PreparedEdit::text(
341 formatted.as_bytes(),
342 empty_sid,
343 empty_id,
344 0..0,
345 "Typed café 🦀",
346 )
347 .unwrap();
348 fs::write(output.join("empty-then-type.one"), typed.as_bytes()).unwrap();
349 let native = include_bytes!("../../../corpus/native-ink/20260905-ui/notebook/synthetic.one");
350 let store = Store::parse(native).unwrap();
351 let index = RevisionIndex::parse(&store).unwrap();
352 let document = Document::parse(&index).unwrap();
353 let (native_sid, native_id) = document
354 .spaces
355 .iter()
356 .find_map(|(sid, s)| {
357 let r = &s.revisions[&s.contexts[&ExGuid::default()]];
358 r.nodes.iter().find_map(|(id, n)| {
359 matches!(&n.kind,Kind::RichText{text,..} if text.starts_with("Fictitious:" ))
360 .then_some((*sid, *id))
361 })
362 })
363 .unwrap();
364 save(
365 "native-cross-runs",
366 native,
367 native_sid,
368 native_id,
369 2..26,
370 &[A::Bold(false), A::Italic(true), A::Underline(true)],
371 );
372 save(
373 "native-partial",
374 native,
375 native_sid,
376 native_id,
377 3..8,
378 &[A::FontSize(14.0), A::Color(Some([16, 112, 48]))],
379 );
380 save(
381 "native-font",
382 native,
383 native_sid,
384 native_id,
385 0..27,
386 &[A::Font("Arial".into())],
387 );
388 save("small-font", &source, sid, id, 0..23, &[A::FontSize(6.0)]);
389 save("large-font", &source, sid, id, 0..23, &[A::FontSize(130.0)]);
390 for points in [129.5, 130.0] {
391 save(
392 &format!("font-boundary-{points}"),
393 &source,
394 sid,
395 id,
396 0..23,
397 &[A::FontSize(points)],
398 );
399 }
400 fs::write(output.join("basic-baseline.one"), &source).unwrap();
401 fs::write(output.join("native-baseline.one"), native).unwrap();
402 fs::write(
403 output.join("manifest.json"),
404 serde_json::to_vec_pretty(&manifest).unwrap(),
405 )
406 .unwrap();
407}
crates/onestore/tests/insertion.rs created+377
...@@ -0,0 +1,377 @@
1#[path = "support/checkpoint.rs"]
2mod checkpoint;
3#[path = "support/current.rs"]
4mod current;
5#[path = "support/disk.rs"]
6mod disk;
7
8use onestore::{
9 ExGuid, Insertion, PreparedEdit, RevisionIndex, Store,
10 document::{Document, Kind},
11};
12
13fn targets(source: &[u8]) -> (ExGuid, ExGuid, ExGuid, ExGuid, ExGuid) {
14 let store = Store::parse(source).unwrap();
15 let index = RevisionIndex::parse(&store).unwrap();
16 let document = Document::parse(&index).unwrap();
17 let (sid, page) = document.pages().unwrap()[0];
18 let space = &document.spaces[&sid];
19 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
20 let outline = view.nodes[&page]
21 .children
22 .iter()
23 .copied()
24 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
25 .unwrap();
26 let paragraph = view.nodes[&outline].children[0];
27 let text = view.nodes[&paragraph].content[0];
28 (sid, page, outline, paragraph, text)
29}
30
31#[test]
32fn paragraph_and_outline_insertions_publish_metadata_and_references_together() {
33 let source = onestore::create_section("insertion.one", "Original", "Original author").unwrap();
34 let (sid, page, outline, paragraph, original_text) = targets(&source);
35 for (intent, expected_parent, expected_x, expected_y) in [
36 (
37 Insertion::paragraph(outline, Some(paragraph), "First 🦀\rSecond", "New author")
38 .unwrap(),
39 outline,
40 None,
41 None,
42 ),
43 (
44 Insertion::outline(page, 144.0, 18.0, "First 🦀\rSecond", "New author").unwrap(),
45 page,
46 Some(144.0),
47 Some(18.0),
48 ),
49 ] {
50 let prepared = PreparedEdit::insert(&source, sid, &intent).unwrap();
51 let store = Store::parse(prepared.as_bytes()).unwrap();
52 assert_eq!(
53 store.header.transaction_count,
54 Store::parse(&source).unwrap().header.transaction_count + 1
55 );
56 let index = RevisionIndex::parse(&store).unwrap();
57 index.validate_current().unwrap();
58 let document = Document::parse(&index).unwrap();
59 let space = &document.spaces[&sid];
60 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
61 assert!(
62 view.nodes[&expected_parent]
63 .children
64 .contains(&intent.object())
65 );
66 assert_eq!(view.nodes[&intent.object()].layout.x, expected_x);
67 assert_eq!(view.nodes[&intent.object()].layout.y, expected_y);
68 assert!(
69 matches!(&view.nodes[&original_text].kind, Kind::RichText { text, .. } if text == "Original")
70 );
71 assert!(
72 matches!(&view.nodes[&intent.text_object()].kind, Kind::RichText { text, .. } if text == "First 🦀\rSecond")
73 );
74 assert!(
75 matches!(&view.nodes[&page].kind, Kind::Page { alternate_title, .. } if alternate_title.as_deref() == Some("First 🦀"))
76 );
77 assert!(
78 matches!(&view.nodes[&view.roots[&2]].kind, Kind::Metadata { title, .. } if title.as_deref() == Some("First 🦀"))
79 );
80 assert!(
81 view.nodes
82 .values()
83 .any(|node| matches!(&node.kind, Kind::Author { name } if name.as_deref() == Some("New author")))
84 );
85 let runs = view.text_runs(intent.text_object()).unwrap();
86 assert_eq!(runs.len(), 1);
87 assert_eq!(runs[0].format.font.as_deref(), Some("Calibri"));
88 assert_eq!(runs[0].format.font_size, Some(11.0));
89 let previous_store = Store::parse(&source).unwrap();
90 let previous = RevisionIndex::parse(&previous_store).unwrap();
91 for (old_sid, old_space) in &previous.spaces {
92 for rid in old_space.revisions.keys() {
93 assert_eq!(
94 format!("{:?}", previous.resolve(*old_sid, *rid).unwrap()),
95 format!("{:?}", index.resolve(*old_sid, *rid).unwrap())
96 );
97 }
98 }
99 }
100}
101
102#[test]
103fn repeated_insertions_and_formatting_share_immutable_objects() {
104 let mut source = onestore::create_section("shared.one", "Original", "Same author").unwrap();
105 let (sid, _, outline, _, _) = targets(&source);
106 let mut texts = Vec::new();
107 for _ in 0..12 {
108 let insertion =
109 Insertion::paragraph(outline, None, "Repeated paragraph", "Same author").unwrap();
110 source = PreparedEdit::insert(&source, sid, &insertion)
111 .unwrap()
112 .as_bytes()
113 .to_vec();
114 source = PreparedEdit::format(
115 &source,
116 sid,
117 insertion.text_object(),
118 1..8,
119 &[
120 onestore::TextAttribute::Bold(true),
121 onestore::TextAttribute::FontSize(20.0),
122 ],
123 )
124 .unwrap()
125 .as_bytes()
126 .to_vec();
127 texts.push(insertion.text_object());
128 }
129 let store = Store::parse(&source).unwrap();
130 let index = RevisionIndex::parse(&store).unwrap();
131 index.validate_current().unwrap();
132 let raw = index
133 .resolve(sid, index.spaces[&sid].labels[&(ExGuid::default(), 1)])
134 .unwrap();
135 let mut unique = std::collections::BTreeSet::new();
136 let mut counts = Vec::new();
137 for id in raw.reachable().unwrap() {
138 let object = &raw.objects[&id];
139 if object.jcid & 0x100000 == 0 {
140 continue;
141 }
142 let onestore::ObjectData::Properties(bytes) = object.data else {
143 panic!()
144 };
145 assert!(
146 unique.insert((object.jcid, bytes.to_vec())),
147 "Duplicate immutable object"
148 );
149 counts.push((object.jcid, object.reference_count));
150 }
151 counts.sort();
152 assert_eq!(counts, [(0x120001, 26), (0x12004d, 12), (0x12004d, 25)]);
153 let document = Document::parse(&index).unwrap();
154 let space = &document.spaces[&sid];
155 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
156 for text in texts {
157 let runs = view.text_runs(text).unwrap();
158 assert_eq!(runs.len(), 3);
159 assert_eq!(runs[1].format.bold, Some(true));
160 assert_eq!(runs[1].format.font_size, Some(20.0));
161 }
162}
163
164#[test]
165fn serialized_insertions_rebase_with_the_same_objects_and_preserve_remote_edits() {
166 let source = onestore::create_section("rebase.one", "Original", "Author").unwrap();
167 let (sid, _, outline, paragraph, text) = targets(&source);
168 let intent = Insertion::paragraph(outline, Some(paragraph), "Inserted", "Author").unwrap();
169 let encoded = serde_json::to_vec(&intent).unwrap();
170 let restored: Insertion = serde_json::from_slice(&encoded).unwrap();
171 assert_eq!(intent.object(), restored.object());
172 assert_eq!(intent.text_object(), restored.text_object());
173 let original_preparation = PreparedEdit::insert(&source, sid, &intent).unwrap();
174 let remote = PreparedEdit::text(&source, sid, text, 0..0, "Remote ").unwrap();
175 let updated = PreparedEdit::insert(remote.as_bytes(), sid, &restored).unwrap();
176 let store = Store::parse(updated.as_bytes()).unwrap();
177 let index = RevisionIndex::parse(&store).unwrap();
178 let document = Document::parse(&index).unwrap();
179 let space = &document.spaces[&sid];
180 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
181 assert_eq!(
182 view.nodes[&outline].children,
183 [restored.object(), paragraph]
184 );
185 assert!(
186 matches!(&view.nodes[&text].kind, Kind::RichText { text, .. } if text == "Remote Original")
187 );
188 assert!(
189 matches!(&view.nodes[&restored.text_object()].kind, Kind::RichText { text, .. } if text == "Inserted")
190 );
191 assert!(PreparedEdit::insert(original_preparation.as_bytes(), sid, &restored).is_err());
192 assert!(PreparedEdit::insert(updated.as_bytes(), sid, &restored).is_err());
193}
194
195#[test]
196fn repositioning_preserves_intent_identity_and_kind() {
197 let source = onestore::create_section("placement.one", "Original", "Author").unwrap();
198 let (sid, page, outline, paragraph, _) = targets(&source);
199 let p = Insertion::paragraph(outline, Some(paragraph), "Inserted", "Author").unwrap();
200 let o = Insertion::outline(page, 144.0, 144.0, "Inserted", "Author").unwrap();
201 assert!(p.reposition_outline(page, 72.0, 72.0).is_err());
202 assert!(o.reposition_paragraph(outline, None).is_err());
203 assert!(p.reposition_paragraph(ExGuid::default(), None).is_err());
204 assert!(o.reposition_outline(page, f32::NAN, 72.0).is_err());
205 for (original, moved) in [
206 (&p, p.reposition_paragraph(outline, None).unwrap()),
207 (&o, o.reposition_outline(page, 288.0, 360.0).unwrap()),
208 ] {
209 let mut before = serde_json::to_value(original).unwrap();
210 let mut after = serde_json::to_value(&moved).unwrap();
211 for name in ["parent", "placement"] {
212 before.as_object_mut().unwrap().remove(name);
213 after.as_object_mut().unwrap().remove(name);
214 }
215 assert_eq!(before, after);
216 assert_eq!(original.object(), moved.object());
217 assert_eq!(original.text_object(), moved.text_object());
218 let restored: Insertion =
219 serde_json::from_value(serde_json::to_value(&moved).unwrap()).unwrap();
220 let prepared = PreparedEdit::insert(&source, sid, &restored).unwrap();
221 let store = Store::parse(prepared.as_bytes()).unwrap();
222 let index = RevisionIndex::parse(&store).unwrap();
223 index.validate_current().unwrap();
224 let doc = Document::parse(&index).unwrap();
225 let space = &doc.spaces[&sid];
226 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
227 if original == &p {
228 assert_eq!(view.nodes[&outline].children, [paragraph, moved.object()]);
229 } else {
230 assert_eq!(
231 (
232 view.nodes[&moved.object()].layout.x,
233 view.nodes[&moved.object()].layout.y
234 ),
235 (Some(288.0), Some(360.0))
236 );
237 }
238 }
239}
240
241#[test]
242#[cfg(any(unix, windows))]
243fn filesystem_insertion_uses_the_prepared_identity_and_rejects_stale_replay() {
244 use std::{fs, io::Write};
245 let source = onestore::create_section("file.one", "Original", "Author").unwrap();
246 let (sid, _, outline, _, _) = targets(&source);
247 let intent = Insertion::paragraph(outline, None, "File insertion", "Author").unwrap();
248 let path = std::env::temp_dir().join(format!("onestore-insertion-{}.one", intent.object()));
249 let mut file = fs::OpenOptions::new()
250 .write(true)
251 .create_new(true)
252 .open(&path)
253 .unwrap();
254 file.write_all(&source).unwrap();
255 file.sync_all().unwrap();
256 drop(file);
257 let edit = PreparedEdit::insert(&source, sid, &intent).unwrap();
258 let result = edit.commit_file(&path);
259 let written = onestore::read_file(&path).unwrap();
260 let repeated = edit.commit_file(&path).unwrap_err();
261 fs::remove_file(path).unwrap();
262 result.unwrap();
263 assert_eq!(written, edit.as_bytes());
264 assert_eq!(repeated.state, onestore::CommitState::NotCommitted);
265 assert_eq!(repeated.error.kind(), std::io::ErrorKind::ResourceBusy);
266}
267
268#[test]
269fn anchors_targets_serialized_identities_and_text_are_validated_before_publication() {
270 let source = onestore::create_section("invalid.one", "Original", "Author").unwrap();
271 let (sid, page, outline, paragraph, text) = targets(&source);
272 assert!(Insertion::outline(page, f32::NAN, 0.0, "Text", "Author").is_err());
273 assert!(Insertion::outline(page, 0.0, f32::INFINITY, "Text", "Author").is_err());
274 for content in ["a\0b", "a\nb", "a\u{fffc}b", "a\u{fddf}b"] {
275 assert!(Insertion::paragraph(outline, None, content, "Author").is_err());
276 }
277 assert!(Insertion::paragraph(outline, None, "Text", "a\0b").is_err());
278 for intent in [
279 Insertion::paragraph(outline, Some(text), "Text", "Author").unwrap(),
280 Insertion::paragraph(text, None, "Text", "Author").unwrap(),
281 Insertion::paragraph(page, None, "Text", "Author").unwrap(),
282 Insertion::outline(paragraph, 0.0, 0.0, "Text", "Author").unwrap(),
283 ] {
284 assert!(PreparedEdit::insert(&source, sid, &intent).is_err());
285 }
286 let intent = Insertion::paragraph(outline, None, "Text", "Author").unwrap();
287 let created = PreparedEdit::insert(&source, sid, &intent).unwrap();
288 let before_missing =
289 Insertion::paragraph(outline, Some(intent.object()), "Anchored", "Author").unwrap();
290 assert!(PreparedEdit::insert(&source, sid, &before_missing).is_err());
291 assert!(PreparedEdit::insert(created.as_bytes(), sid, &before_missing).is_ok());
292 let mut encoded = serde_json::to_value(&intent).unwrap();
293 encoded["parent"] = serde_json::to_value(intent.object()).unwrap();
294 let collision: Insertion = serde_json::from_value(encoded).unwrap();
295 assert!(PreparedEdit::insert(created.as_bytes(), sid, &collision).is_err());
296}
297
298#[test]
299fn insertions_into_nested_paragraphs_and_native_table_cells_preserve_structure() {
300 let source = onestore::create_section("nested.one", "Original", "Author").unwrap();
301 let (sid, _, outline, paragraph, _) = targets(&source);
302 let child = Insertion::paragraph(paragraph, None, "Nested", "Author").unwrap();
303 let changed = PreparedEdit::insert(&source, sid, &child).unwrap();
304 let store = Store::parse(changed.as_bytes()).unwrap();
305 let index = RevisionIndex::parse(&store).unwrap();
306 let document = Document::parse(&index).unwrap();
307 let space = &document.spaces[&sid];
308 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
309 assert_eq!(view.nodes[&outline].children, [paragraph]);
310 assert_eq!(view.nodes[&paragraph].children, [child.object()]);
311 let source = include_bytes!(
312 "../../../corpus/native/20260905-05/snapshots/07-table/notebook/synthetic.one"
313 );
314 let store = Store::parse(source).unwrap();
315 let index = RevisionIndex::parse(&store).unwrap();
316 let document = Document::parse(&index).unwrap();
317 let (sid, cell) = document
318 .spaces
319 .iter()
320 .find_map(|(sid, space)| {
321 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
322 view.nodes.iter().find_map(|(id, node)| {
323 matches!(node.kind, Kind::Cell { .. }).then_some((*sid, *id))
324 })
325 })
326 .unwrap();
327 let insertion = Insertion::paragraph(cell, None, "Added to cell", "Author").unwrap();
328 let changed = PreparedEdit::insert(source, sid, &insertion).unwrap();
329 current::current(changed.as_bytes());
330}
331
332#[test]
333fn insertion_publication_faults_expose_only_complete_graphs_and_title_caches() {
334 let original = onestore::create_section("atomic.one", "Original", "Author").unwrap();
335 let (sid, _, _, _, text) = targets(&original);
336 let checkpoint = checkpoint::pending(&original, sid, text, 0x14001d7a);
337 for source in [
338 original.as_slice(),
339 include_bytes!("../../../corpus/append/round-01/tx-255/notebook/synthetic.one"),
340 &checkpoint,
341 ] {
342 let (sid, page, outline, paragraph, _) = targets(source);
343 for intent in [
344 Insertion::paragraph(outline, Some(paragraph), "First 🦀", "New author").unwrap(),
345 Insertion::outline(page, 0.0, 0.0, "First 🦀", "New author").unwrap(),
346 ] {
347 let edit = PreparedEdit::insert(source, sid, &intent).unwrap();
348 let before = current::current(source);
349 let after = current::current(edit.as_bytes());
350 for write_limit in [17, 4096] {
351 let disk = |fail_at| disk::Disk {
352 visible: source.to_vec(),
353 durable: source.to_vec(),
354 operation: 0,
355 fail_at,
356 write_limit,
357 random: 945,
358 };
359 let mut successful = disk(None);
360 edit.commit(&mut successful).unwrap();
361 assert_eq!(successful.durable, edit.as_bytes());
362 for at in 1..=successful.operation {
363 let mut interrupted = disk(Some(at));
364 let failure = edit.commit(&mut interrupted).unwrap_err();
365 let state = current::current(&interrupted.durable);
366 assert!(state == before || state == after, "interruption {at}");
367 if failure.state == onestore::CommitState::NotCommitted {
368 assert_eq!(state, before);
369 }
370 if failure.state == onestore::CommitState::Committed {
371 assert_eq!(state, after);
372 }
373 }
374 }
375 }
376 }
377}
crates/onestore/tests/revisions.rs+62
...@@ -3,6 +3,68 @@ use std::fs;...@@ -3,6 +3,68 @@ use std::fs;
33
4const TABLE: &str = "../../corpus/native/20260905-05/snapshots/07-table/notebook/synthetic.one";4const TABLE: &str = "../../corpus/native/20260905-05/snapshots/07-table/notebook/synthetic.one";
55
6#[test]
7fn persisted_identities_preserve_native_byte_order_and_canonical_form() {
8 let text = "{00112233-4455-6677-8899-AABBCCDDEEFF},42";
9 let id: ExGuid = text.parse().unwrap();
10 assert_eq!(
11 id.guid,
12 [
13 0x33, 0x22, 0x11, 0, 0x55, 0x44, 0x77, 0x66, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
14 0xff
15 ]
16 );
17 assert_eq!(id.n, 42);
18 assert_eq!(text.to_lowercase().parse::<ExGuid>().unwrap(), id);
19 assert_eq!(id.to_string(), text);
20 let mut random = 7_u64;
21 for n in 0..1024 {
22 let guid = std::array::from_fn(|_| {
23 random = random.wrapping_mul(6364136223846793005).wrapping_add(1);
24 random.to_le_bytes()[0]
25 });
26 let id = ExGuid { guid, n };
27 assert_eq!(id.to_string().parse::<ExGuid>().unwrap(), id);
28 assert_eq!(
29 serde_json::from_str::<ExGuid>(&serde_json::to_string(&id).unwrap()).unwrap(),
30 id
31 );
32 }
33 for id in [
34 ExGuid::default(),
35 ExGuid {
36 guid: [255; 16],
37 n: u32::MAX,
38 },
39 ] {
40 assert_eq!(id.to_string().parse::<ExGuid>().unwrap(), id);
41 }
42 for at in 0..text.len() {
43 let mut changed = text.as_bytes().to_vec();
44 changed[at] = b'?';
45 assert!(
46 String::from_utf8(changed)
47 .unwrap()
48 .parse::<ExGuid>()
49 .is_err()
50 );
51 }
52 for changed in [
53 text.replace(",42", ",+42"),
54 text.replace(",42", ",042"),
55 text.replace(",42", ",4294967296"),
56 text.replace(",42", ",-1"),
57 text.replace("00", "+0"),
58 text.replace("00", "é"),
59 "{00000000-0000-0000-0000-000000000000},1".to_owned(),
60 format!(" {text}"),
61 format!("{text} "),
62 ] {
63 assert!(changed.parse::<ExGuid>().is_err(), "{changed}");
64 assert!(serde_json::from_str::<ExGuid>(&serde_json::to_string(&changed).unwrap()).is_err());
65 }
66}
67
6#[test]68#[test]
7fn native_encryption_remains_opaque() {69fn native_encryption_remains_opaque() {
8 let bytes =70 let bytes =
crates/onestore/tests/shared_snapshot.rs created+420
...@@ -0,0 +1,420 @@
1#[path = "support/current.rs"]
2mod current;
3use current::current;
4
5#[path = "support/checkpoint.rs"]
6mod checkpoint;
7#[path = "support/trace.rs"]
8mod trace;
9
10use onestore::read_snapshot;
11use onestore::{
12 ExGuid, RevisionIndex, Store,
13 document::{Document, Kind},
14};
15use std::{fs, io};
16use trace::{Event, Trace};
17
18fn target(bytes: &[u8]) -> (ExGuid, ExGuid, u32) {
19 let store = Store::parse(bytes).unwrap();
20 let index = RevisionIndex::parse(&store).unwrap();
21 let doc = Document::parse(&index).unwrap();
22 doc.spaces
23 .iter()
24 .find_map(|(sid, space)| {
25 space.revisions[&space.contexts[&ExGuid::default()]]
26 .nodes
27 .iter()
28 .find_map(|(oid, node)| {
29 if let Kind::RichText { text, .. } = &node.kind
30 && (text.starts_with("Fictitious") || text.starts_with("Transaction"))
31 {
32 return Some((*sid, *oid, text.encode_utf16().count() as u32));
33 }
34 None
35 })
36 })
37 .unwrap()
38}
39
40#[test]
41fn version_cached_readers_cannot_miss_a_completed_publication() {
42 for path in [
43 "native/20260905-05/snapshots/03-format-unicode/notebook/synthetic.one",
44 "append/round-01/tx-255/notebook/synthetic.one",
45 ] {
46 let source = fs::read(format!("../../corpus/{path}")).unwrap();
47 let (sid, oid, end) = target(&source);
48 let mut trace = Trace {
49 bytes: source.clone(),
50 events: Vec::new(),
51 };
52 onestore::commit_text(&mut trace, &source, sid, oid, end..end, " [cached reader]").unwrap();
53 let final_content = current(&trace.bytes);
54 let mut visible = source;
55 for event in &trace.events {
56 let Event::Write(offset, bytes) = event else {
57 continue;
58 };
59 visible.resize(visible.len().max(offset + bytes.len()), 0);
60 for (index, byte) in bytes.iter().enumerate() {
61 visible[offset + index] = *byte;
62 if (212..252).contains(&(offset + index)) {
63 let cached_content = current(&visible);
64 let refreshed = if visible[212..252] == trace.bytes[212..252] {
65 cached_content
66 } else {
67 current(&trace.bytes)
68 };
69 assert_eq!(
70 refreshed,
71 final_content,
72 "{path}: cached header at {}",
73 offset + index
74 );
75 }
76 }
77 }
78 }
79}
80
81#[test]
82fn published_snapshots_survive_interleaved_commit_io() {
83 let cases = [
84 (
85 "unicode",
86 "native/20260905-05/snapshots/03-format-unicode/notebook/synthetic.one",
87 ),
88 (
89 "attachment",
90 "native/20260905-05/snapshots/06-attachment/notebook/synthetic.one",
91 ),
92 (
93 "rollover-256",
94 "append/round-01/tx-255/notebook/synthetic.one",
95 ),
96 (
97 "rollover-65536",
98 "append/round-01/tx-65535/notebook/synthetic.one",
99 ),
100 (
101 "checkpoint",
102 "native/20260905-05/snapshots/03-format-unicode/notebook/synthetic.one",
103 ),
104 ];
105 for (name, path) in cases {
106 let mut source = fs::read(format!("../../corpus/{path}")).unwrap();
107 let (sid, oid, _) = target(&source);
108 if name == "checkpoint" {
109 source = checkpoint::pending(&source, sid, oid, 0x14001d7a);
110 }
111 let (_, _, end) = target(&source);
112 let before = current(&source);
113 let mut trace = Trace {
114 bytes: source.clone(),
115 events: Vec::new(),
116 };
117 onestore::commit_text(&mut trace, &source, sid, oid, end..end, " [reader café 🦀]")
118 .unwrap();
119 let after = current(&trace.bytes);
120 assert_ne!(before, after);
121 let mut writes = Vec::new();
122 for event in &trace.events {
123 if let Event::Write(offset, bytes) = event {
124 let piece = if *offset < 1024 { 17 } else { 4096 };
125 writes.extend(
126 bytes
127 .chunks(piece)
128 .enumerate()
129 .map(|(i, bytes)| (offset + i * piece, bytes)),
130 );
131 }
132 }
133 let mut accepted = [0; 2];
134 let mut retried = 0;
135 let mut interleaved = 0;
136 for run in 0..writes.len() + 1 + 512 {
137 let paused = run <= writes.len();
138 let mut step = if paused { run } else { 0 };
139 let mut visible = source.clone();
140 for &(offset, bytes) in &writes[..step] {
141 visible.resize(visible.len().max(offset + bytes.len()), 0);
142 visible[offset..offset + bytes.len()].copy_from_slice(bytes);
143 }
144 let mut seed = run as u64 + 1;
145 let mut read_calls = 0;
146 let mut overlapped = false;
147 let read_limit = [17, 193, 4096, 65536][run % 4];
148 let result = read_snapshot(
149 |offset, output| {
150 seed ^= seed << 13;
151 seed ^= seed >> 7;
152 seed ^= seed << 17;
153 if !paused {
154 let count = if seed.is_multiple_of(11) {
155 writes.len()
156 } else {
157 (seed % 4) as usize
158 };
159 let end = writes.len().min(step + count);
160 for &(at, bytes) in &writes[step..end] {
161 visible.resize(visible.len().max(at + bytes.len()), 0);
162 visible[at..at + bytes.len()].copy_from_slice(bytes);
163 }
164 overlapped |= read_calls > 0 && end > step;
165 step = end;
166 }
167 read_calls += 1;
168 let offset = offset as usize;
169 let count = output
170 .len()
171 .min(read_limit)
172 .min(visible.len().saturating_sub(offset));
173 if count != 0 {
174 output[..count].copy_from_slice(&visible[offset..offset + count]);
175 }
176 Ok(count)
177 },
178 trace.bytes.len(),
179 );
180 interleaved += usize::from(overlapped);
181 if paused && (run == 0 || run == writes.len()) {
182 assert!(
183 matches!(&result, Ok(Some(_))),
184 "{name}: quiescent run {run}"
185 );
186 }
187 match result {
188 Ok(Some(bytes)) => {
189 let checked = std::panic::catch_unwind(|| {
190 let observed = current(&bytes);
191 assert!(
192 observed == before || observed == after,
193 "{name}: run {run}, write step {step}"
194 );
195 if run == writes.len() {
196 assert_eq!(observed, after);
197 }
198 observed == after
199 });
200 match checked {
201 Ok(new) => accepted[usize::from(new)] += 1,
202 Err(failure) => {
203 let time = std::time::SystemTime::now()
204 .duration_since(std::time::UNIX_EPOCH)
205 .unwrap()
206 .as_nanos();
207 let path = std::path::PathBuf::from(format!(
208 "../../evidence/m9/read-interleaving-failure-{name}-{run}-{time}"
209 ));
210 fs::create_dir_all(&path).unwrap();
211 fs::write(path.join("source.one"), &source).unwrap();
212 fs::write(path.join("observed.one"), &bytes).unwrap();
213 fs::write(
214 path.join("replay.json"),
215 serde_json::to_vec(&serde_json::json!({
216 "run": run, "read_limit": read_limit, "writes": writes,
217 }))
218 .unwrap(),
219 )
220 .unwrap();
221 eprintln!("Replay: {}", path.display());
222 std::panic::resume_unwind(failure);
223 }
224 }
225 }
226 Ok(None) => retried += 1,
227 Err(error)
228 if matches!(
229 error.kind(),
230 io::ErrorKind::UnexpectedEof | io::ErrorKind::InvalidData
231 ) =>
232 {
233 retried += 1
234 }
235 Err(error) => panic!("{name}: run {run}: {error}"),
236 }
237 }
238 assert!(
239 accepted[0] > 0 && accepted[1] > 0 && interleaved > 0 && retried > 0,
240 "{name}"
241 );
242 println!(
243 "{name}: old={}, new={}, retry={retried}, overlap={interleaved}",
244 accepted[0], accepted[1]
245 );
246 }
247}
248
249#[test]
250fn snapshot_rejects_short_io_and_unbounded_allocation() {
251 let source = onestore::create_section("test.one", "read", "test").unwrap();
252 let mut calls = 0;
253 let result = read_snapshot(
254 |offset, output| {
255 calls += 1;
256 if calls == 1 {
257 return Err(io::ErrorKind::Interrupted.into());
258 }
259 let offset = offset as usize;
260 let count = output.len().min(7).min(source.len().saturating_sub(offset));
261 if count != 0 {
262 output[..count].copy_from_slice(&source[offset..offset + count]);
263 }
264 Ok(count)
265 },
266 source.len(),
267 )
268 .unwrap()
269 .unwrap();
270 assert_eq!(current(&source), current(&result));
271 assert!(calls > source.len() / 7);
272 let error = read_snapshot(|_, output| Ok(output.len() + 1), source.len()).unwrap_err();
273 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
274 let error = read_snapshot(|_, _| Ok(0), source.len()).unwrap_err();
275 assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof);
276 let error = read_snapshot(
277 |_, output| {
278 output.copy_from_slice(&source[..1024]);
279 Ok(1024)
280 },
281 1023,
282 )
283 .unwrap_err();
284 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
285}
286
287#[test]
288fn native_tail_truncation_before_header_update_is_retried() {
289 let compact = onestore::create_section("test.one", "Transaction retained", "test").unwrap();
290 let mut expanded = compact.clone();
291 expanded.resize(compact.len() + 216, 0);
292 let length = expanded.len() as u64;
293 expanded[196..204].copy_from_slice(&length.to_le_bytes());
294 assert_eq!(current(&expanded), current(&compact));
295 for block in [17, 193, 65536] {
296 for trigger in [0, 1024usize.div_ceil(block)] {
297 let mut visible = expanded.clone();
298 let mut calls = 0;
299 let result = read_snapshot(
300 |offset, output| {
301 if calls == trigger {
302 visible.truncate(compact.len());
303 }
304 calls += 1;
305 let offset = offset as usize;
306 let count = output
307 .len()
308 .min(block)
309 .min(visible.len().saturating_sub(offset));
310 if count != 0 {
311 output[..count].copy_from_slice(&visible[offset..offset + count]);
312 }
313 Ok(count)
314 },
315 expanded.len(),
316 )
317 .unwrap();
318 assert!(
319 result.is_none(),
320 "accepted a snapshot with a stale expected length"
321 );
322 }
323 }
324 let result = read_snapshot(
325 |offset, output| {
326 let offset = offset as usize;
327 let count = output.len().min(compact.len().saturating_sub(offset));
328 if count != 0 {
329 output[..count].copy_from_slice(&compact[offset..offset + count]);
330 }
331 Ok(count)
332 },
333 expanded.len(),
334 )
335 .unwrap()
336 .unwrap();
337 assert_eq!(result, compact);
338}
339
340#[test]
341fn unpublished_append_remains_available_for_retry() {
342 let source = onestore::create_section("test.one", "Transaction before", "test").unwrap();
343 let (sid, oid, end) = target(&source);
344 let mut trace = Trace {
345 bytes: source.clone(),
346 events: Vec::new(),
347 };
348 onestore::commit_text(&mut trace, &source, sid, oid, end..end, " abandoned").unwrap();
349 let Event::Write(offset, append) = &trace.events[0] else {
350 panic!()
351 };
352 assert_eq!(*offset, source.len());
353 for length in [1, 17, append.len()] {
354 let mut persisted = source.clone();
355 persisted.extend_from_slice(&append[..length]);
356 let snapshot = read_snapshot(
357 |offset, output| {
358 let offset = offset as usize;
359 let count = output.len().min(persisted.len().saturating_sub(offset));
360 output[..count].copy_from_slice(&persisted[offset..offset + count]);
361 Ok(count)
362 },
363 persisted.len(),
364 )
365 .unwrap()
366 .unwrap();
367 assert_eq!(snapshot, persisted);
368 assert_eq!(current(&snapshot), current(&source));
369 let mut retry = Trace {
370 bytes: persisted,
371 events: Vec::new(),
372 };
373 onestore::commit_text(&mut retry, &snapshot, sid, oid, end..end, " retry").unwrap();
374 assert_ne!(current(&retry.bytes), current(&source));
375 }
376}
377
378#[test]
379fn header_comparison_cannot_replace_maintenance_exclusion() {
380 let mut source = onestore::create_section("test.one", "AAAA BBBB", "test").unwrap();
381 source[212..228].fill(9);
382 source[236..252].fill(7);
383 let encoded: Vec<_> = "AAAA BBBB"
384 .encode_utf16()
385 .flat_map(u16::to_le_bytes)
386 .collect();
387 let offset = source
388 .windows(encoded.len())
389 .position(|bytes| bytes == encoded)
390 .unwrap();
391 let mut changed = source.clone();
392 let replacement: Vec<_> = "ZZZZ YYYY"
393 .encode_utf16()
394 .flat_map(u16::to_le_bytes)
395 .collect();
396 changed[offset..offset + replacement.len()].copy_from_slice(&replacement);
397 let before = current(&source);
398 let after = current(&changed);
399 assert_ne!(before, after);
400 let mut visible = &source;
401 let result = read_snapshot(
402 |at, output| {
403 let at = at as usize;
404 if at >= offset + 8 {
405 visible = &changed;
406 }
407 let count = output.len().min(2).min(visible.len().saturating_sub(at));
408 if count != 0 {
409 output[..count].copy_from_slice(&visible[at..at + count]);
410 }
411 Ok(count)
412 },
413 source.len(),
414 )
415 .unwrap()
416 .unwrap();
417 let hybrid = current(&result);
418 assert_ne!(hybrid, before);
419 assert_ne!(hybrid, after);
420}
crates/onestore/tests/support/current.rs created+26
...@@ -0,0 +1,26 @@
1use onestore::{ExGuid, RevisionIndex, Store, document::Document};
2use std::collections::BTreeMap;
3
4pub fn current(bytes: &[u8]) -> BTreeMap<ExGuid, String> {
5 let store = Store::parse(bytes).unwrap();
6 assert!(store.checksum_mismatches.is_empty());
7 let index = RevisionIndex::parse(&store).unwrap();
8 index.validate_current().unwrap();
9 Document::parse(&index).unwrap();
10 index
11 .spaces
12 .iter()
13 .map(|(sid, space)| {
14 let rid = space.labels[&(ExGuid::default(), 1)];
15 let revision = index.resolve(*sid, rid).unwrap();
16 for object in revision.objects.values() {
17 if let Some(onestore::FileDataReference::Internal(guid)) =
18 object.file_reference().unwrap()
19 {
20 store.file_data(guid).unwrap();
21 }
22 }
23 (*sid, format!("{revision:?}"))
24 })
25 .collect()
26}
crates/onestore/tests/support/trace.rs created+36
...@@ -0,0 +1,36 @@
1use onestore::CommitIo;
2use std::io;
3
4pub enum Event {
5 Write(usize, Vec<u8>),
6 Flush,
7}
8
9pub struct Trace {
10 pub bytes: Vec<u8>,
11 pub events: Vec<Event>,
12}
13
14impl CommitIo for Trace {
15 fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> {
16 let offset = offset as usize;
17 let count = output.len().min(self.bytes.len().saturating_sub(offset));
18 if count != 0 {
19 output[..count].copy_from_slice(&self.bytes[offset..offset + count]);
20 }
21 Ok(count)
22 }
23 fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> {
24 let offset = offset as usize;
25 let count = bytes.len().min(4096);
26 self.bytes.resize(self.bytes.len().max(offset + count), 0);
27 self.bytes[offset..offset + count].copy_from_slice(&bytes[..count]);
28 self.events
29 .push(Event::Write(offset, bytes[..count].to_vec()));
30 Ok(count)
31 }
32 fn flush(&mut self) -> io::Result<()> {
33 self.events.push(Event::Flush);
34 Ok(())
35 }
36}
readme.md created+1
...@@ -0,0 +1 @@
1# Snowbound — Freeform note taking
tools/codex_usage_report.example.json created+18
...@@ -0,0 +1,18 @@
1{
2 "threadIds": ["01a078fb-d5d2-7ad0-97f0-d14db4e4c94e"],
3 "sources": [
4 "/Users/clo/.codex/sessions/2026/09/06/rollout-2026-09-06T16-09-25-01a078fb-d5d2-7ad0-97f0-d14db4e4c94e.jsonl"
5 ],
6 "boundaries": [
7 {
8 "name": "Planning discussion",
9 "kind": "discussion",
10 "end": "2026-09-07T01:28:56Z"
11 },
12 {
13 "name": "Canvas goal",
14 "kind": "goal",
15 "start": "2026-09-07T01:28:56Z"
16 }
17 ]
18}
tools/codex_usage_report.mjs created+129
...@@ -0,0 +1,129 @@
1#!/usr/bin/env node
2
3import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4import { homedir } from "node:os";
5import { dirname, join, resolve } from "node:path";
6
7const rates = { input: 10, cached: 1, output: 50 };
8
9function usageCost(usage) {
10 return ((usage.input_tokens - usage.cached_input_tokens) * rates.input
11 + usage.cached_input_tokens * rates.cached
12 + usage.output_tokens * rates.output) / 1_000_000;
13}
14
15function duration(ms) {
16 const seconds = Math.round(ms / 1000);
17 const hours = Math.floor(seconds / 3600);
18 const minutes = Math.floor((seconds % 3600) / 60);
19 const remainder = seconds % 60;
20 return hours ? `${hours}h ${minutes}m` : minutes ? `${minutes}m ${remainder}s` : `${remainder}s`;
21}
22
23function unionDuration(intervals) {
24 const merged = [];
25 for (const interval of intervals.sort((a, b) => a[0] - b[0])) {
26 const previous = merged.at(-1);
27 if (previous && interval[0] <= previous[1]) previous[1] = Math.max(previous[1], interval[1]);
28 else merged.push([...interval]);
29 }
30 return merged.reduce((total, [start, end]) => total + end - start, 0);
31}
32
33async function filesBelow(root) {
34 const entries = await readdir(root, { withFileTypes: true });
35 const nested = await Promise.all(entries.map((entry) => {
36 const path = join(root, entry.name);
37 return entry.isDirectory() ? filesBelow(path) : entry.name.endsWith(".jsonl") ? [path] : [];
38 }));
39 return nested.flat();
40}
41
42async function loadEvents(paths) {
43 const events = [];
44 for (const path of paths) {
45 for (const line of (await readFile(path, "utf8")).trim().split("\n")) {
46 if (!line) continue;
47 try { events.push(JSON.parse(line)); } catch { /* Ignore a partially-written final line. */ }
48 }
49 }
50 return events;
51}
52
53function snapshotBefore(records, time) {
54 return records.filter((record) => Date.parse(record.timestamp) <= time).at(-1)?.payload.thread_token_usage;
55}
56
57function taskRows(events, start, end) {
58 const tasks = new Map();
59 for (const event of events) {
60 if (event.type !== "event_msg" || !["task_started", "task_complete"].includes(event.payload.type)) continue;
61 const prior = tasks.get(event.payload.turn_id) ?? {};
62 tasks.set(event.payload.turn_id, { ...prior, ...event.payload });
63 }
64 return [...tasks.values()].filter((task) => task.started_at >= start && task.started_at < end && task.duration_ms);
65}
66
67function toolIntervals(events, start, end) {
68 return events.flatMap((event) => {
69 const item = event.type === "event_msg" && event.payload.type === "item_completed" ? event.payload.item : undefined;
70 if (!item || !["CommandExecution", "McpToolCall"].includes(item.type)) return [];
71 const milliseconds = (item.duration?.secs ?? 0) * 1000 + (item.duration?.nanos ?? 0) / 1_000_000;
72 const finished = Date.parse(event.timestamp);
73 return milliseconds && finished >= start && finished < end ? [[finished - milliseconds, finished]] : [];
74 });
75}
76
77function segment(events, records, boundary, previous, finalTime) {
78 const start = boundary.start ? Date.parse(boundary.start) : -Infinity;
79 const end = boundary.end ? Date.parse(boundary.end) : finalTime;
80 const tasks = taskRows(events, start, end);
81 const tools = toolIntervals(events, start, end);
82 const finish = snapshotBefore(records, end);
83 const begin = previous ?? snapshotBefore(records, start);
84 return {
85 name: boundary.name,
86 kind: boundary.kind ?? "segment",
87 cost: finish ? usageCost(finish) - (begin ? usageCost(begin) : 0) : null,
88 durationMs: tasks.reduce((total, task) => total + task.duration_ms, 0),
89 apiTtftMs: tasks.reduce((total, task) => total + (task.time_to_first_token_ms ?? 0), 0),
90 toolWallMs: unionDuration(tools),
91 toolCoreMs: tools.reduce((total, [startTime, endTime]) => total + endTime - startTime, 0),
92 };
93}
94
95function chart(segments) {
96 const maximum = Math.max(...segments.map((segment) => segment.cost ?? 0), 1);
97 return segments.map((segment) => `<div class="row"><span>${escapeHtml(segment.name)}</span><div class="bar"><i style="width:${(segment.cost ?? 0) / maximum * 100}%"></i></div><b>${segment.cost === null ? "—" : `$${segment.cost.toFixed(2)}`}</b></div>`).join("\n");
98}
99
100function escapeHtml(value) {
101 return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
102}
103
104function html(report) {
105 const rows = report.segments.map((segment) => `<tr><td>${escapeHtml(segment.name)}</td><td>${segment.kind}</td><td>${segment.cost === null ? "—" : `$${segment.cost.toFixed(2)}`}</td><td>${duration(segment.durationMs)}</td><td>${duration(segment.toolWallMs)}</td><td>${duration(segment.toolCoreMs)}</td><td>≥${duration(segment.apiTtftMs)}</td></tr>`).join("\n");
106 return `<!doctype html><meta charset="utf-8"><title>Codex usage report</title><style>body{font:14px system-ui;margin:40px;max-width:900px;color:#202124}table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:8px;border-bottom:1px solid #ddd}.row{display:grid;grid-template-columns:220px 1fr 90px;gap:12px;align-items:center;margin:8px 0}.bar{background:#edf0f2;height:14px;border-radius:8px;overflow:hidden}.bar i{display:block;height:100%;background:#3874cb}</style><h1>Codex usage report</h1><p>API-equivalent rate: Astra standard ($10/M uncached input, $1/M cached input, $50/M output). Tool core time counts parallel calls separately; API TTFT is a lower bound.</p><h2>Cost</h2>${chart(report.segments)}<h2>Segments</h2><table><thead><tr><th>Segment</th><th>Type</th><th>Cost</th><th>Duration</th><th>Tool wall</th><th>Tool core</th><th>Recorded API</th></tr></thead><tbody>${rows}</tbody></table>`;
107}
108
109function usage() {
110 console.error("Usage: node tools/codex_usage_report.mjs report.json [output.html]");
111 process.exit(1);
112}
113
114const [configPath, outputPath] = process.argv.slice(2);
115if (!configPath) usage();
116const config = JSON.parse(await readFile(resolve(configPath), "utf8"));
117const codexHome = config.codexHome ?? join(homedir(), ".codex");
118const paths = config.sources ?? (await Promise.all(["sessions", "archived_sessions"].map((name) => filesBelow(join(codexHome, name))))).flat();
119const events = await loadEvents(paths);
120const selected = events.filter((event) => event.type !== "token_usage_record" || config.threadIds.includes(event.payload.thread_id));
121const records = selected.filter((event) => event.type === "token_usage_record").sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
122if (!records.length) throw new Error("No token records matched config.threadIds.");
123const finalTime = Date.parse(records.at(-1).timestamp);
124const segments = config.boundaries.map((boundary, index) => segment(selected, records, boundary, index ? snapshotBefore(records, Date.parse(config.boundaries[index - 1].end ?? new Date(finalTime).toISOString())) : undefined, finalTime));
125const report = { threadIds: config.threadIds, total: usageCost(records.at(-1).payload.thread_token_usage), segments };
126if (outputPath) {
127 await mkdir(dirname(resolve(outputPath)), { recursive: true });
128 await writeFile(resolve(outputPath), html(report));
129} else console.log(JSON.stringify(report, null, 2));
tools/concurrent_rust.py+15-5
...@@ -70,21 +70,31 @@ def verify(logs, initial_transaction, writers, operations, edit=False):...@@ -70,21 +70,31 @@ def verify(logs, initial_transaction, writers, operations, edit=False):
7070
7171
72@contextmanager72@contextmanager
73def running_clients(output, source, writers, readers, operations, seed, timeout=600, edit=False):73def running_clients(output, source, writers, readers, operations, seed, timeout=600, edit=False, executable=CLIENT, environment=None, reader_executable=None):
74 if not 0 < timeout < 2**64 / 1000:
75 raise ValueError('Choose a finite positive client timeout.')
76 timeout_ms = int(timeout * 1000)
77 if not 0 < timeout_ms < 2**64:
78 raise ValueError('Client timeout does not fit the subprocess clock.')
79 environment = {**(os.environ if environment is None else environment), 'ONESTORE_CLIENT_TIMEOUT_MS': str(timeout_ms)}
74 start, stop = output / 'start', output / 'stop'80 start, stop = output / 'start', output / 'stop'
75 (output / 'clients.json').write_text(json.dumps({'writers': writers, 'readers': readers, 'operations': operations,81 manifest = {'writers': writers, 'readers': readers, 'operations': operations,
76 'seed': seed, 'edit': edit, 'client_sha256': hashlib.sha256(CLIENT.read_bytes()).hexdigest()}, indent=2))82 'seed': seed, 'edit': edit, 'timeout_ms': timeout_ms, 'client_sha256': hashlib.sha256(executable.read_bytes()).hexdigest(), 'executable': str(executable)}
83 if reader_executable is not None:
84 manifest.update(reader_executable=str(reader_executable), reader_sha256=hashlib.sha256(reader_executable.read_bytes()).hexdigest())
85 (output / 'clients.json').write_text(json.dumps(manifest, indent=2))
77 processes = {}86 processes = {}
78 streams = []87 streams = []
79 try:88 try:
80 for mode, count in [('write', writers), ('read', readers)]:89 for mode, count in [('write', writers), ('read', readers)]:
90 client = reader_executable if mode == 'read' and reader_executable is not None else executable
81 for i in range(count):91 for i in range(count):
82 actor = mode[0] + str(i)92 actor = mode[0] + str(i)
83 out = (output / f'{actor}.jsonl').open('w')93 out = (output / f'{actor}.jsonl').open('w')
84 err = (output / f'{actor}.stderr').open('w')94 err = (output / f'{actor}.stderr').open('w')
85 streams.extend([out, err])95 streams.extend([out, err])
86 processes[actor] = subprocess.Popen([CLIENT, 'edit' if edit and mode == 'write' else mode, source, actor, str(operations), start, stop,96 processes[actor] = subprocess.Popen([client, 'edit' if edit and mode == 'write' else mode, source, actor, str(operations), start, stop,
87 str(seed + i + (10000 if mode == 'read' else 0))], stdout=out, stderr=err)97 str(seed + i + (10000 if mode == 'read' else 0))], stdout=out, stderr=err, env=environment)
88 deadline = time.monotonic() + timeout98 deadline = time.monotonic() + timeout
89 while not all((output / f'{actor}.jsonl').stat().st_size for actor in processes):99 while not all((output / f'{actor}.jsonl').stat().st_size for actor in processes):
90 if any(p.poll() is not None for p in processes.values()) or time.monotonic() > deadline:100 if any(p.poll() is not None for p in processes.values()) or time.monotonic() > deadline:
tools/crash_recovery.py+15
...@@ -1,12 +1,22 @@...@@ -1,12 +1,22 @@
1"""Independent append-intent accounting across an abrupt storage interruption."""1"""Independent append-intent accounting across an abrupt storage interruption."""
2from collections import Counter2from collections import Counter
3import re3import re
4from document_model import ordered_pages, walk
5
6
7def active_text(model):
8 (_, _, revision, page), = ordered_pages(model)
9 text, = [node['kind']['text'] for _, node in walk(revision, page)
10 if node['kind']['type'] == 'RichText' and node['kind']['text'].startswith('Concurrent edits:')]
11 return text
412
513
6def verify_text(baseline, current, logs):14def verify_text(baseline, current, logs):
7 events = [event for rows in logs.values() for event in rows]15 events = [event for rows in logs.values() for event in rows]
8 intents = {event['token'] for event in events if event['event'] == 'intent'}16 intents = {event['token'] for event in events if event['event'] == 'intent'}
9 possible = set()17 possible = set()
18 versions = {baseline}
19 predecessors = set()
10 for rows in logs.values():20 for rows in logs.values():
11 outcomes = {event['attempt']: event for event in rows if event['event'] in ('commit', 'retry', 'commit_error')}21 outcomes = {event['attempt']: event for event in rows if event['event'] in ('commit', 'retry', 'commit_error')}
12 for event in rows:22 for event in rows:
...@@ -14,12 +24,17 @@ def verify_text(baseline, current, logs):...@@ -14,12 +24,17 @@ def verify_text(baseline, current, logs):
14 outcome = outcomes.get(event['attempt'])24 outcome = outcomes.get(event['attempt'])
15 if outcome is None or outcome['event'] == 'commit' or (outcome['event'] == 'commit_error' and outcome['state'] != 'NotCommitted'):25 if outcome is None or outcome['event'] == 'commit' or (outcome['event'] == 'commit_error' and outcome['state'] != 'NotCommitted'):
16 possible.add(event['token'])26 possible.add(event['token'])
27 assert event['replacement'] == event['token'] and event['range'] == [len(event['before'].encode('utf-16-le')) // 2] * 2
28 predecessors.add(event['before'])
29 versions.add(event['before'] + event['token'])
30 assert predecessors <= versions, 'A publication does not follow recorded history'
17 acknowledged = [event for event in events if event['event'] == 'commit' or31 acknowledged = [event for event in events if event['event'] == 'commit' or
18 (event['event'] == 'commit_error' and event['state'] == 'Committed')]32 (event['event'] == 'commit_error' and event['state'] == 'Committed')]
19 assert len({event['token'] for event in acknowledged}) == len(acknowledged), 'An edit was acknowledged twice'33 assert len({event['token'] for event in acknowledged}) == len(acknowledged), 'An edit was acknowledged twice'
2034
21 def tokens(text):35 def tokens(text):
22 assert text.startswith(baseline), 'Previously retained content changed'36 assert text.startswith(baseline), 'Previously retained content changed'
37 assert text in versions, 'Text is not a recorded publication result'
23 suffix = text[len(baseline):]38 suffix = text[len(baseline):]
24 found = re.findall(r' \[w\d+:\d+\]', suffix)39 found = re.findall(r' \[w\d+:\d+\]', suffix)
25 assert ''.join(found) == suffix, 'Unexpected or partially persisted text'40 assert ''.join(found) == suffix, 'Unexpected or partially persisted text'
tools/diagnostic/editor.css created+24
...@@ -0,0 +1,24 @@
1.diagnostic{position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:-30px -40px 24px;padding:12px 20px;background:#f2f6fa;border-bottom:1px solid #aab9c8;font:13px/1.5 system-ui}
2.diagnostic button,.edit-actions button{font:inherit;padding:6px 12px;cursor:pointer}
3.diagnostic [role=status]{flex-basis:100%}
4.diagnostic [role=status]:empty{display:none}
5.editing [data-text-object]{cursor:pointer;outline:1px dashed #5182b1;outline-offset:2px}
6.editing [data-text-object]:hover,.editing [data-text-object]:focus{outline:2px solid #175bb2}
7.editing [data-text-object]:empty::before{content:'Empty text';color:#666;font:12px system-ui}
8dialog{width:min(720px,90vw);max-height:90vh;overflow:auto;border:1px solid #8a9aab;border-radius:6px;padding:24px;color:#000;background:#fff;font:15px/1.5 system-ui}
9dialog::backdrop{background:#0005}
10dialog h2{margin:0 0 12px}dialog label{display:block;margin-top:16px;font-weight:600}
11dialog textarea{display:block;width:100%;font:16px/1.6 system-ui;resize:vertical;min-height:140px}
12dialog [hidden]{display:none}
13dialog fieldset{margin:16px 0;padding:12px;border:1px solid #c1ccd6}
14dialog fieldset label{margin-top:8px}
15dialog input,dialog select,.diagnostic select{font:inherit;padding:4px;max-width:100%}
16dialog select{display:block}
17.format-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
18#coordinates:not([hidden]){display:flex;gap:20px}
19#coordinates input{width:120px}
20#format-range{margin:0}
21dialog input[type=color]{width:64px;height:32px;vertical-align:middle}
22.edit-actions{display:flex;justify-content:flex-end;gap:12px;margin-top:20px}
23button:disabled{cursor:default}
24@media(max-width:750px){.diagnostic{margin:-20px -20px 20px}}
tools/diagnostic/editor.js created+246
...@@ -0,0 +1,246 @@
1const route = location.pathname.match(/^\/g\/(\d+)\/report\/(page-\d+\.html)$/);
2const bar = document.createElement('aside');
3bar.className = 'diagnostic';
4bar.innerHTML = `<strong>Notebook copy</strong>
5 <button id="edit-mode" type="button" aria-pressed="false">Edit text</button>
6 <label>Text action <select id="text-action"><option value="text">Replace</option><option value="format">Format</option></select></label>
7 <button id="add-paragraph" type="button">Add paragraph</button>
8 <button id="add-outline" type="button">Add outline</button>
9 <a href="/latest">Latest notebook</a>
10 <span id="editor-status" role="status"></span>`;
11document.querySelector('main').prepend(bar);
12const status = document.querySelector('#editor-status');
13const mode = document.querySelector('#edit-mode');
14for (const control of bar.querySelectorAll('button, select')) control.disabled = !route;
15const dialog = document.createElement('dialog');
16dialog.setAttribute('aria-labelledby', 'edit-heading');
17dialog.innerHTML = `<form><h2 id="edit-heading">Edit text</h2>
18 <p id="edit-hint"></p>
19 <fieldset id="placement"><legend>Paragraph placement</legend>
20 <label>Parent container <select id="parent"></select></label>
21 <label>Insert before <select id="before"></select></label>
22 </fieldset>
23 <fieldset id="coordinates"><legend>Outline position</legend>
24 <label>X (points) <input id="outline-x" type="number" step="any" value="144"></label>
25 <label>Y (points) <input id="outline-y" type="number" step="any" value="144"></label>
26 </fieldset>
27 <label for="replacement">Text</label><textarea id="replacement" rows="6"></textarea>
28 <label id="author-label">Author <input id="author" value="Diagnostic"></label>
29 <fieldset id="formatting"><legend>Character formatting</legend>
30 <p id="format-range" role="status"></p>
31 <div class="format-grid"></div>
32 <label>Font <input id="font" placeholder="Keep current font"></label>
33 <label>Size (points) <input id="font-size" type="number" min="6" max="130" step="0.5" placeholder="Keep current size"></label>
34 <label for="color-mode">Text color</label><select id="color-mode"><option value="keep">Keep</option><option value="clear">Automatic</option><option value="set">Set color</option></select><input id="color" type="color" value="#123456" aria-label="Text color value">
35 <label for="highlight-mode">Highlight</label><select id="highlight-mode"><option value="keep">Keep</option><option value="clear">Clear</option><option value="set">Set color</option></select><input id="highlight" type="color" value="#ffff00" aria-label="Highlight color value">
36 </fieldset>
37 <p id="save-status" role="status"></p>
38 <a id="inspect-latest" target="_blank" rel="noopener" hidden>Open latest page</a>
39 <div class="edit-actions"><button type="button" id="cancel-edit">Cancel</button><button id="save-edit">Save</button></div>
40 </form>`;
41document.body.append(dialog);
42for (const name of ['Bold', 'Italic', 'Underline', 'Strike', 'Superscript', 'Subscript']) {
43 const label = document.createElement('label');
44 label.textContent = name === 'Strike' ? 'Strikethrough' : name;
45 const select = document.createElement('select');
46 select.dataset.attribute = name;
47 for (const [value, text] of [['', 'Keep'], ['true', 'On'], ['false', 'Off']]) select.add(new Option(text, value));
48 label.append(select);
49 dialog.querySelector('.format-grid').append(label);
50}
51const draft = document.querySelector('#replacement');
52const save = document.querySelector('#save-edit');
53const cancel = document.querySelector('#cancel-edit');
54const message = document.querySelector('#save-status');
55const inspect = document.querySelector('#inspect-latest');
56const parent = document.querySelector('#parent');
57const before = document.querySelector('#before');
58let selected = null;
59let opening = false;
60let submitting = false;
61let initial = '';
62
63mode.addEventListener('click', () => {
64 const editing = document.body.classList.toggle('editing');
65 mode.setAttribute('aria-pressed', String(editing));
66 mode.textContent = editing ? 'Stop editing' : 'Edit text';
67 for (const element of document.querySelectorAll('[data-text-object]')) {
68 if (editing) {
69 element.tabIndex = 0;
70 element.setAttribute('role', 'button');
71 element.setAttribute('aria-label', 'Edit text: ' + element.textContent);
72 } else {
73 element.removeAttribute('tabindex');
74 element.removeAttribute('role');
75 element.removeAttribute('aria-label');
76 }
77 }
78 status.textContent = editing ? 'Select a text run.' : '';
79});
80
81function showEdit(selection, text) {
82 dialog.querySelector('form').reset();
83 selected = selection;
84 const action = selection.action;
85 const titles = {text: 'Replace text', format: 'Format text', paragraph: 'Add paragraph', outline: 'Add outline'};
86 document.querySelector('#edit-heading').textContent = titles[action];
87 document.querySelector('#edit-hint').textContent = {
88 text: 'Replacement text keeps this run’s formatting.',
89 format: 'Select part of the text or leave the whole run selected. Unchanged attributes keep their current values.',
90 paragraph: 'Append to a container or insert before one of its children. Line breaks stay inside the new paragraph.',
91 outline: 'Add a text container to this page. Line breaks stay inside its first paragraph.'
92 }[action];
93 for (const [id, visible] of [['placement', action === 'paragraph'], ['coordinates', action === 'outline'], ['formatting', action === 'format']]) {
94 const field = document.getElementById(id);
95 field.hidden = !visible;
96 field.disabled = !visible;
97 }
98 document.querySelector('#author-label').hidden = !['paragraph', 'outline'].includes(action);
99 draft.value = text;
100 draft.readOnly = action === 'format';
101 save.disabled = false;
102 cancel.disabled = false;
103 inspect.hidden = true;
104 message.textContent = '';
105 status.textContent = '';
106 dialog.showModal();
107 draft.focus();
108 draft.setSelectionRange(0, text.length);
109 document.querySelector('#format-range').textContent = 'Selected: ' + text.length + ' UTF-16 units';
110 initial = JSON.stringify(requestBody());
111}
112
113async function openEdit(element) {
114 if (opening) return;
115 opening = true;
116 status.textContent = 'Checking…';
117 const selection = {generation: Number(route[1]), page: route[2],
118 object: element.dataset.textObject, run: Number(element.dataset.run)};
119 try {
120 const result = await (await fetch('/api/run?' + new URLSearchParams(selection))).json();
121 if (!result.ok) {
122 status.textContent = 'Edit not supported. ' + result.error;
123 return;
124 }
125 showEdit({...selection, action: document.querySelector('#text-action').value}, result.text);
126 } catch {
127 status.textContent = 'Unable to check this text. Reconnect to the diagnostic server.';
128 } finally {
129 opening = false;
130 }
131}
132
133for (const action of ['paragraph', 'outline']) document.querySelector('#add-' + action).addEventListener('click', async () => {
134 if (opening) return;
135 opening = true;
136 status.textContent = 'Loading…';
137 const selection = {generation: Number(route[1]), page: route[2], action};
138 try {
139 const result = await (await fetch('/api/page?' + new URLSearchParams(selection))).json();
140 if (!result.ok) throw new Error(result.error);
141 if (action === 'paragraph' && !result.targets.length) throw new Error('Add an outline before adding a paragraph.');
142 parent.replaceChildren(...result.targets.map(target => new Option(target.label, target.object)));
143 parent.onchange = () => {
144 const target = result.targets.find(target => target.object === parent.value);
145 before.replaceChildren(new Option('Append at end', ''), ...(target?.children || []).map(child => new Option(child.label, child.object)));
146 };
147 parent.onchange();
148 showEdit({...selection, object: result.object}, '');
149 } catch (error) {
150 status.textContent = 'Unable to add content. ' + error.message;
151 } finally {
152 opening = false;
153 }
154});
155
156function requestBody() {
157 const body = {...selected};
158 if (body.action === 'text') body.replacement = draft.value;
159 if (body.action === 'format') {
160 body.start = draft.selectionStart;
161 body.end = draft.selectionEnd;
162 body.attributes = [...dialog.querySelectorAll('[data-attribute]')].filter(select => select.value !== '')
163 .map(select => ({[select.dataset.attribute]: select.value === 'true'}));
164 const font = document.querySelector('#font').value;
165 const size = document.querySelector('#font-size').value;
166 if (font) body.attributes.push({Font: font});
167 if (size) body.attributes.push({FontSize: Number(size)});
168 for (const [field, attribute] of [['color', 'Color'], ['highlight', 'Highlight']]) {
169 const mode = document.getElementById(field + '-mode').value;
170 if (mode !== 'keep') body.attributes.push({[attribute]: mode === 'clear' ? null : document.getElementById(field).value.slice(1).match(/../g).map(hex => parseInt(hex, 16))});
171 }
172 }
173 if (['paragraph', 'outline'].includes(body.action)) {
174 body.text = draft.value.replace(/\n/g, '\r');
175 body.author = document.querySelector('#author').value;
176 if (body.action === 'paragraph') {
177 body.object = parent.value;
178 body.before = before.value || null;
179 } else {
180 body.x = document.querySelector('#outline-x').valueAsNumber;
181 body.y = document.querySelector('#outline-y').valueAsNumber;
182 }
183 }
184 return body;
185}
186
187draft.addEventListener('select', () => {
188 document.querySelector('#format-range').textContent = 'Selected: ' + (draft.selectionEnd - draft.selectionStart) + ' UTF-16 units';
189});
190document.addEventListener('click', event => {
191 const element = event.target.closest('[data-text-object]');
192 if (element && document.body.classList.contains('editing')) {
193 event.preventDefault();
194 openEdit(element);
195 }
196});
197document.addEventListener('keydown', event => {
198 if (event.target.matches('[data-text-object]') && document.body.classList.contains('editing') && ['Enter', ' '].includes(event.key)) {
199 event.preventDefault();
200 openEdit(event.target);
201 }
202});
203cancel.addEventListener('click', () => dialog.close());
204dialog.addEventListener('cancel', event => { if (submitting) event.preventDefault(); });
205dialog.addEventListener('close', () => { selected = null; });
206window.addEventListener('beforeunload', event => {
207 if (selected && JSON.stringify(requestBody()) !== initial) event.preventDefault();
208});
209dialog.querySelector('form').addEventListener('submit', async event => {
210 event.preventDefault();
211 if (save.disabled || !selected) return;
212 const body = requestBody();
213 save.disabled = true;
214 cancel.disabled = true;
215 submitting = true;
216 for (const control of dialog.querySelectorAll('input, select, textarea')) control.disabled = true;
217 message.textContent = 'Saving…';
218 let result;
219 try {
220 result = await (await fetch('/api/save', {method: 'POST', headers: {
221 'Content-Type': 'application/json', 'X-OneNote-Diagnostic': '1'
222 }, body: JSON.stringify(body)})).json();
223 } catch {
224 result = {ok: false, state: 'Unknown'};
225 }
226 submitting = false;
227 cancel.disabled = false;
228 for (const control of dialog.querySelectorAll('input, select, textarea')) control.disabled = false;
229 if (result.ok) {
230 selected = null;
231 location.assign(result.location);
232 return;
233 }
234 inspect.href = '/latest?' + new URLSearchParams({generation: selected.generation, page: selected.page});
235 inspect.hidden = false;
236 if (result.state === 'Unknown') {
237 message.textContent = 'Save outcome unknown. Keep this draft and inspect the latest page before trying again.';
238 } else if (result.state === 'Committed') {
239 message.textContent = 'Saved, but cleanup or refresh did not finish. Inspect the latest page; do not save this draft again.';
240 } else if (result.kind === 'ResourceBusy') {
241 message.textContent = 'This section changed. Open the latest page before editing again. Your draft is still here.';
242 } else {
243 message.textContent = 'Unable to save. ' + (result.error || result.report_error || 'Check this edit and try again.');
244 save.disabled = false;
245 }
246});
tools/native/network.ps1+5-1
...@@ -3,6 +3,10 @@ Set-StrictMode -Version Latest...@@ -3,6 +3,10 @@ Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'3$ErrorActionPreference = 'Stop'
4$adapter = @(Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.MACAddress -eq $LabMac })4$adapter = @(Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.MACAddress -eq $LabMac })
5if ($adapter.Count -ne 1) { throw 'The clone lab adapter is ambiguous.' }5if ($adapter.Count -ne 1) { throw 'The clone lab adapter is ambiguous.' }
6$enabled = $adapter[0].EnableDHCP()
7if ($enabled.ReturnValue -notin @(0, 1)) {
8 throw ('Unable to enable DHCP on the clone lab adapter; result ' + $enabled.ReturnValue)
9}
6$release = $adapter[0].ReleaseDHCPLease()10$release = $adapter[0].ReleaseDHCPLease()
7$result = $adapter[0].RenewDHCPLease()11$result = $adapter[0].RenewDHCPLease()
8$deadline = [DateTime]::UtcNow.AddSeconds(60)12$deadline = [DateTime]::UtcNow.AddSeconds(60)
...@@ -12,5 +16,5 @@ do {...@@ -12,5 +16,5 @@ do {
12 if ($addresses.Count -eq 1) { break }16 if ($addresses.Count -eq 1) { break }
13 Start-Sleep -Milliseconds 25017 Start-Sleep -Milliseconds 250
14} while ([DateTime]::UtcNow -lt $deadline)18} while ([DateTime]::UtcNow -lt $deadline)
15if ($addresses.Count -ne 1) { throw ('The clone did not receive a lab IPv4 address; renewal result ' + $result.ReturnValue) }19if ($addresses.Count -ne 1) { throw ('The clone did not receive a lab IPv4 address; renewal result ' + $result.ReturnValue + '; adapter ' + ($adapter[0] | Select-Object MACAddress,DHCPEnabled,IPEnabled,IPAddress | ConvertTo-Json -Compress)) }
16@{mac=$LabMac;address=$addresses[0];releaseResult=$release.ReturnValue;renewalResult=$result.ReturnValue} | ConvertTo-Json -Compress20@{mac=$LabMac;address=$addresses[0];releaseResult=$release.ReturnValue;renewalResult=$result.ReturnValue} | ConvertTo-Json -Compress
tools/native/probe.ps1+34-8
...@@ -3,33 +3,57 @@ Set-StrictMode -Version Latest...@@ -3,33 +3,57 @@ Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'3$ErrorActionPreference = 'Stop'
4$results = New-Object Collections.Generic.List[object]4$results = New-Object Collections.Generic.List[object]
5$index = 05$index = 0
6function Record-Phase([string]$Phase) {
7 @{ name=$file.BaseName; phase=$Phase; utc=[DateTime]::UtcNow.ToString('o') } |
8 ConvertTo-Json -Compress | Add-Content "$Root\progress.jsonl" -Encoding UTF8
9}
6foreach ($file in @(Get-ChildItem "$Root\inputs" -Filter '*.one' | Sort-Object Name)) {10foreach ($file in @(Get-ChildItem "$Root\inputs" -Filter '*.one' | Sort-Object Name)) {
7 $case = "C:\one-tests\runs\probe-$index"
8 $output = "$Root\results\$($file.BaseName)"11 $output = "$Root\results\$($file.BaseName)"
9 New-Item -ItemType Directory "$case\notebook", $output -Force | Out-Null12 New-Item -ItemType Directory $output -Force | Out-Null
10 Copy-Item $file.FullName "$case\notebook\synthetic.one"13 Record-Phase 'cold-reset'
11 Get-Process ONENOTE -ErrorAction SilentlyContinue | ForEach-Object {14 $cold = $false
12 Stop-Process -InputObject $_ -Force15 for ($attempt = 0; $attempt -lt 5; $attempt++) {
13 if (-not $_.WaitForExit(10000)) { throw 'OneNote did not exit before the cache reset.' }16 $case = "C:\one-tests\runs\probe-$index-$attempt"
17 New-Item -ItemType Directory "$case\notebook" -Force | Out-Null
18 Copy-Item $file.FullName "$case\notebook\synthetic.one"
19 Get-Process ONENOTE -ErrorAction SilentlyContinue | ForEach-Object {
20 Stop-Process -InputObject $_ -Force -ErrorAction SilentlyContinue
21 if (-not $_.WaitForExit(10000)) { throw 'OneNote did not exit before the cache reset.' }
22 }
23 Start-Sleep -Milliseconds 250
24 try { & "$PSScriptRoot\cold-current.ps1" -Root $case -CloneHost $CloneHost }
25 catch {
26 if ($_.Exception.Message -ne 'Close OneNote before resetting its test cache.') { throw }
27 continue
28 }
29 if (-not (Get-Process ONENOTE -ErrorAction SilentlyContinue)) { $cold = $true; break }
14 }30 }
15 & "$PSScriptRoot\cold-current.ps1" -Root $case -CloneHost $CloneHost31 if (-not $cold) { throw 'OneNote kept reopening during the cache reset.' }
32 Record-Phase 'create-application'
16 $app = New-Object -ComObject OneNote.Application33 $app = New-Object -ComObject OneNote.Application
17 $notebook = ''; $section = ''; $hierarchy = ''; $failure = $null; $pages = @()34 $notebook = ''; $section = ''; $hierarchy = ''; $failure = $null; $pages = @()
18 $started = [DateTime]::UtcNow35 $started = [DateTime]::UtcNow
19 try {36 try {
37 Record-Phase 'open-notebook'
20 $app.OpenHierarchy("$case\notebook", '', [ref]$notebook, 0)38 $app.OpenHierarchy("$case\notebook", '', [ref]$notebook, 0)
39 Record-Phase 'open-section'
21 $app.OpenHierarchy("$case\notebook\synthetic.one", '', [ref]$section, 0)40 $app.OpenHierarchy("$case\notebook\synthetic.one", '', [ref]$section, 0)
22 $deadline = [DateTime]::UtcNow.AddSeconds(30)41 $deadline = [DateTime]::UtcNow.AddSeconds(30)
23 do {42 do {
43 Record-Phase 'get-hierarchy'
24 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)44 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
25 [xml]$tree = $hierarchy45 [xml]$tree = $hierarchy
26 $pages = @($tree.SelectNodes('//*[local-name()="Page"]'))46 $pages = @($tree.SelectNodes('//*[local-name()="Page"]'))
27 if ($pages.Count) { break }47 if ($pages.Count -and $tree.DocumentElement.GetAttribute('areAllPagesAvailable') -ne 'false') { break }
28 Start-Sleep -Milliseconds 25048 Start-Sleep -Milliseconds 250
29 } while ([DateTime]::UtcNow -lt $deadline)49 } while ([DateTime]::UtcNow -lt $deadline)
50 if (-not $pages.Count -or $tree.DocumentElement.GetAttribute('areAllPagesAvailable') -eq 'false') {
51 throw 'The section did not finish loading its pages.'
52 }
30 $n = 053 $n = 0
31 foreach ($page in $pages) {54 foreach ($page in $pages) {
32 $content = ''55 $content = ''
56 Record-Phase "get-page-$n"
33 $app.GetPageContent($page.GetAttribute('ID'), [ref]$content, 1, 1)57 $app.GetPageContent($page.GetAttribute('ID'), [ref]$content, 1, 1)
34 [IO.File]::WriteAllText("$output\page-$n.xml", $content, [Text.Encoding]::UTF8)58 [IO.File]::WriteAllText("$output\page-$n.xml", $content, [Text.Encoding]::UTF8)
35 $n++59 $n++
...@@ -41,11 +65,13 @@ foreach ($file in @(Get-ChildItem "$Root\inputs" -Filter '*.one' | Sort-Object N...@@ -41,11 +65,13 @@ foreach ($file in @(Get-ChildItem "$Root\inputs" -Filter '*.one' | Sort-Object N
41 seconds=([DateTime]::UtcNow - $started).TotalSeconds;65 seconds=([DateTime]::UtcNow - $started).TotalSeconds;
42 source_sha256=(Get-FileHash $file.FullName).Hash.ToLowerInvariant() })66 source_sha256=(Get-FileHash $file.FullName).Hash.ToLowerInvariant() })
43 $results | ConvertTo-Json -Depth 5 | Set-Content "$Root\results.json" -Encoding UTF867 $results | ConvertTo-Json -Depth 5 | Set-Content "$Root\results.json" -Encoding UTF8
68 Record-Phase 'close-notebook'
44 try { if ($notebook) { $app.CloseNotebook($notebook, $false) } } catch {}69 try { if ($notebook) { $app.CloseNotebook($notebook, $false) } } catch {}
45 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)70 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
46 $app = $null71 $app = $null
47 [GC]::Collect()72 [GC]::Collect()
48 [GC]::WaitForPendingFinalizers()73 [GC]::WaitForPendingFinalizers()
74 Record-Phase 'complete'
49 }75 }
50 $index++76 $index++
51}77}
tools/native/stress.ps1+8
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1function Invoke-Stress($app, $section, $command, $output, $shared) {1function Invoke-Stress($app, $section, $command, $output, $shared) {
2 $hierarchy = ''2 $hierarchy = ''
3 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)3 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
4 [IO.File]::WriteAllText("$output\hierarchy.xml", $hierarchy, [Text.Encoding]::UTF8)
4 [xml]$tree = $hierarchy5 [xml]$tree = $hierarchy
5 $pages = @($tree.SelectNodes('//*[local-name()="Page"]'))6 $pages = @($tree.SelectNodes('//*[local-name()="Page"]'))
6 if ($pages.Count -ne 1) { throw 'Expected one stress-test page.' }7 if ($pages.Count -ne 1) { throw 'Expected one stress-test page.' }
...@@ -23,6 +24,13 @@ function Invoke-Stress($app, $section, $command, $output, $shared) {...@@ -23,6 +24,13 @@ function Invoke-Stress($app, $section, $command, $output, $shared) {
23 Start-Sleep -Milliseconds 2024 Start-Sleep -Milliseconds 20
24 }25 }
25 for ($i = 0; $i -lt $command.operations; $i++) {26 for ($i = 0; $i -lt $command.operations; $i++) {
27 if (($command.PSObject.Properties.Name -contains 'maintenance') -and $command.maintenance -and $i -eq [Math]::Floor($command.operations / 2)) {
28 [IO.File]::WriteAllText("$shared\maintenance-paused-n$($command.actor)", 'paused')
29 while (-not (Test-Path "$shared\maintenance-resume")) {
30 if ([DateTime]::UtcNow -gt $deadline) { throw 'Maintenance pause timed out.' }
31 Start-Sleep -Milliseconds 100
32 }
33 }
26 Start-Sleep -Milliseconds $random.Next(10, 100)34 Start-Sleep -Milliseconds $random.Next(10, 100)
27 $started = [DateTime]::UtcNow.Ticks35 $started = [DateTime]::UtcNow.Ticks
28 $content = ''36 $content = ''
tools/native_collaboration.py+121-27
...@@ -47,20 +47,27 @@ def verify_final_state(model):...@@ -47,20 +47,27 @@ def verify_final_state(model):
47 return {'main_space': sid, 'conflict_space': conflict_sid, 'competing_edits': sorted(edits)}47 return {'main_space': sid, 'conflict_space': conflict_sid, 'competing_edits': sorted(edits)}
4848
4949
50def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1, rust_writers=4, rust_readers=3, edit=False, seed=710, conflict_clients=0, abrupt=False):50def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1, rust_writers=4, rust_readers=3, edit=False, seed=710, conflict_clients=0, abrupt=False, embedded_smb=False, maintenance=False, fixture=None, disconnect=False, client_timeout=600, offline=False, offline_outage=False, offline_lost_reply=False, client_profile="debug", document_operations=False, record_writes=False, offline_client_reply=False):
51 if fixture is not None and not stress_clients:
52 raise ValueError('Use a fixture with stress mode.')
51 if linux_vm.instance_path(server).exists():53 if linux_vm.instance_path(server).exists():
52 raise ValueError('Choose a new Linux VM name; existing machines are not owned by this run.')54 raise ValueError('Choose a new Linux VM name; existing machines are not owned by this run.')
53 output = output.resolve()55 output = output.resolve()
54 output.mkdir(parents=True, exist_ok=False)56 output.mkdir(parents=True, exist_ok=False)
55 mount = output / 'mount'57 mount = output / 'mount'
56 mount.mkdir()58 mount.mkdir()
57 mounted = False
58 scripts = output / 'scripts'59 scripts = output / 'scripts'
59 scripts.mkdir()60 scripts.mkdir()
60 for name in ('cold.ps1', 'collaborate.ps1', 'network.ps1', 'stress.ps1', 'text.ps1'):61 for name in ('cold.ps1', 'collaborate.ps1', 'network.ps1', 'stress.ps1', 'text.ps1'):
61 shutil.copyfile(ROOT / 'tools/native' / name, scripts / name)62 shutil.copyfile(ROOT / 'tools/native' / name, scripts / name)
62 (output / 'run.json').write_text(json.dumps({'server': server, 'stress_clients': stress_clients, 'conflict_clients': conflict_clients, 'stress_operations': stress_operations, 'sync_every': sync_every, 'rust_writers': rust_writers, 'rust_readers': rust_readers, 'edit': edit, 'seed': seed, 'abrupt': abrupt,63 harness = ('native_collaboration.py', 'native_maintenance.py', 'native_disconnect.py', 'native_stress.py', 'offline_history.py', 'offline_document_history.py', 'offline_outage.py', 'verify_offline.py', 'concurrent_rust.py', 'native_runner.py',
63 'harness_sha256': {name: hashlib.sha256((ROOT / 'tools' / name).read_bytes()).hexdigest() for name in ('native_collaboration.py', 'native_stress.py', 'concurrent_rust.py', 'native_runner.py')}, 'scripts': {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in scripts.iterdir()}}, indent=2))64 'crash_recovery.py', 'smb-proxy.py', 'verify_smb_overlap.py', 'w7/crash.py', 'w7/vm.py', 'w7/linux_vm.py')
65 for name in harness:
66 saved = output / 'harness' / name
67 saved.parent.mkdir(parents=True, exist_ok=True)
68 shutil.copyfile(ROOT / 'tools' / name, saved)
69 (output / 'run.json').write_text(json.dumps({'server': server, 'stress_clients': stress_clients, 'conflict_clients': conflict_clients, 'stress_operations': stress_operations, 'sync_every': sync_every, 'rust_writers': rust_writers, 'rust_readers': rust_readers, 'edit': edit, 'seed': seed, 'abrupt': abrupt, 'embedded_smb': embedded_smb, 'maintenance': maintenance, 'fixture': str(fixture) if fixture is not None else None,
70 'disconnect': disconnect, 'client_timeout': client_timeout, 'client_profile': client_profile, 'offline': offline, 'document_operations': document_operations, 'record_writes': record_writes, 'offline_outage': offline_outage, 'offline_lost_reply': offline_lost_reply, 'offline_client_reply': offline_client_reply, 'harness_sha256': {name: hashlib.sha256((output / 'harness' / name).read_bytes()).hexdigest() for name in harness}, 'scripts': {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in scripts.iterdir()}}, indent=2))
6471
65 def ssh(text):72 def ssh(text):
66 result = linux_vm.run_ssh(server, text, timeout=90)73 result = linux_vm.run_ssh(server, text, timeout=90)
...@@ -76,18 +83,39 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,...@@ -76,18 +83,39 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,
76 linux_vm.wait_instance(server, 600)83 linux_vm.wait_instance(server, 600)
77 config = linux_vm.load_instance(server)84 config = linux_vm.load_instance(server)
78 (output / 'linux.json').write_text(json.dumps(config, indent=2))85 (output / 'linux.json').write_text(json.dumps(config, indent=2))
79 def reconnect_mount():86 if embedded_smb:
80 nonlocal mounted87 subprocess.run(linux_vm.ssh_argv(server, 'cat > /tmp/smb-proxy.py'),
88 input=(output / 'harness/smb-proxy.py').read_bytes(), check=True)
89 ssh("sudo sed -i '/^\\[global\\]/a smb ports = 1445' /etc/samba/smb.conf && sudo systemctl restart smbd")
90 subprocess.run(linux_vm.ssh_argv(server, 'cat > /tmp/smb-control.json'),
91 input=json.dumps({'record_writes': record_writes}).encode(), check=True)
92 ssh("sudo sh -c 'nohup python3 /tmp/smb-proxy.py /tmp/smb-control.json --port 445 --bind 0.0.0.0 --server 127.0.0.1 --server-port 1445 > /tmp/smb-trace.jsonl 2>&1 < /dev/null &'")
93 ssh("sleep 1; sudo ss -ltn | grep ':445 '")
94 os.environ['ONESTORE_SMB_LAB'] = f'127.0.0.1:{config["samba_port"]}'
95 os.environ['ONESTORE_SMB_SHARE'] = 'agent'
96 def unmount(force=False):
97 for options in ([['-f']] if force else [[], ['-f']]):
98 if not os.path.ismount(mount): return
99 result = subprocess.run(['/sbin/umount', *options, str(mount)], capture_output=True, text=True, timeout=60)
100 with (output / 'unmounts.jsonl').open('a') as log:
101 log.write(json.dumps({'force': bool(options), 'exit': result.returncode, 'stderr': result.stderr}) + '\n')
81 if os.path.ismount(mount):102 if os.path.ismount(mount):
82 subprocess.run(['/sbin/umount', str(mount)], check=True)103 raise RuntimeError('The owned SMB mount remains attached; preserve its server until it is unmounted.')
83 mounted = False104 def reconnect_mount():
105 unmount()
84 subprocess.run(['/sbin/mount_smbfs', '-N', f'//guest@127.0.0.1:{config["samba_port"]}/agent', mount], check=True, stdin=subprocess.DEVNULL)106 subprocess.run(['/sbin/mount_smbfs', '-N', f'//guest@127.0.0.1:{config["samba_port"]}/agent', mount], check=True, stdin=subprocess.DEVNULL)
85 mounted = True
86 ssh('mkdir /srv/agent/m6-collaboration')107 ssh('mkdir /srv/agent/m6-collaboration')
87 source = ROOT / 'corpus/native-ink/cold-ui-ink/notebook'108 source = ROOT / 'corpus/native-ink/cold-ui-ink/notebook'
88 if stress_clients or conflict_clients or abrupt:109 if stress_clients or conflict_clients or abrupt:
89 source = output / 'input'110 source = output / 'input'
90 subprocess.run([ROOT / 'target/debug/examples/create_notebook', source, 'Concurrent edits:', 'Concurrency test'], check=True)111 if fixture is not None:
112 source.mkdir()
113 for name in ('synthetic.one', 'Open Notebook.onetoc2'):
114 shutil.copyfile(Path(fixture) / name, source / name)
115 elif maintenance:
116 subprocess.run([ROOT / 'target/debug/examples/maintenance_fixture', source, 'Concurrent edits:'], check=True)
117 else:
118 subprocess.run([ROOT / 'target/debug/examples/create_notebook', source, 'Concurrent edits:', 'Concurrency test'], check=True)
91 with tarfile.open(output / 'input.tar', 'w', dereference=True) as archive:119 with tarfile.open(output / 'input.tar', 'w', dereference=True) as archive:
92 for name in ('synthetic.one', 'Open Notebook.onetoc2'):120 for name in ('synthetic.one', 'Open Notebook.onetoc2'):
93 archive.add(source / name, arcname=name)121 archive.add(source / name, arcname=name)
...@@ -127,8 +155,7 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,...@@ -127,8 +155,7 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,
127 # Joining workers before stack exit retains ownership even if another boot fails.155 # Joining workers before stack exit retains ownership even if another boot fails.
128 with ThreadPoolExecutor(max_workers=len(labels)) as pool:156 with ThreadPoolExecutor(max_workers=len(labels)) as pool:
129 names = dict(zip(labels, pool.map(start_clone, labels)))157 names = dict(zip(labels, pool.map(start_clone, labels)))
130 clients = []158 def prepare_client(label):
131 for label in labels:
132 folder = output / label159 folder = output / label
133 name = names[label]160 name = names[label]
134 for local in scripts.iterdir():161 for local in scripts.iterdir():
...@@ -139,10 +166,13 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,...@@ -139,10 +166,13 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,
139 command(name, 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\\one-tests\\network.ps1 -LabMac ' + vm.lab_mac(name), folder)166 command(name, 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\\one-tests\\network.ps1 -LabMac ' + vm.lab_mac(name), folder)
140 command(name, 'ipconfig', folder)167 command(name, 'ipconfig', folder)
141 command(name, 'dir \\\\192.168.77.1\\agent\\m6-collaboration', folder)168 command(name, 'dir \\\\192.168.77.1\\agent\\m6-collaboration', folder)
142 clients.append({'name': name, 'folder': folder, 'sequence': 0})169 client = {'name': name, 'folder': folder, 'sequence': 0}
143 start_controller(clients[-1])170 start_controller(client)
144 command(name, 'ipconfig', folder)171 command(name, 'ipconfig', folder)
145 print('Collaboration ready:', label, name, flush=True)172 print('Collaboration ready:', label, name, flush=True)
173 return client
174 with ThreadPoolExecutor(max_workers=len(labels)) as pool:
175 clients = list(pool.map(prepare_client, labels))
146176
147 def action(client, action, wait=True, **parameters):177 def action(client, action, wait=True, **parameters):
148 client['sequence'] += 1178 client['sequence'] += 1
...@@ -166,10 +196,19 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,...@@ -166,10 +196,19 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,
166 time.sleep(.5)196 time.sleep(.5)
167 else: raise TimeoutError(f'Native command {sequence} did not complete')197 else: raise TimeoutError(f'Native command {sequence} did not complete')
168 if action == 'snapshot':198 if action == 'snapshot':
169 destination = client['folder'] / f'snapshot-{sequence:04}.xml'199 hierarchy = client['folder'] / f'hierarchy-{sequence:04}.xml'
170 result = windows.do_get(remote + '\\page-0.xml', destination, client['name'])200 result = windows.do_get(remote + '\\hierarchy.xml', hierarchy, client['name'])
171 if result.get('error'): raise RuntimeError(result['error'])201 if result.get('error'): raise RuntimeError(result['error'])
172 return texts(ET.parse(destination).getroot())202 xml = hierarchy.read_text(encoding='utf-8-sig').strip()
203 if xml == '<?xml version="1.0"?>': return []
204 pages = [node for node in ET.fromstring(xml).iter() if node.tag.endswith('}Page')]
205 observed = []
206 for i in range(len(pages)):
207 destination = client['folder'] / f'snapshot-{sequence:04}-{i}.xml'
208 result = windows.do_get(remote + f'\\page-{i}.xml', destination, client['name'])
209 if result.get('error'): raise RuntimeError(result['error'])
210 observed.extend(texts(ET.parse(destination).getroot()))
211 return observed
173212
174 def wait_text(client, expected):213 def wait_text(client, expected):
175 deadline = time.monotonic() + 120214 deadline = time.monotonic() + 120
...@@ -201,6 +240,7 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,...@@ -201,6 +240,7 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,
201 return stream.read()240 return stream.read()
202241
203 def checkpoint(label):242 def checkpoint(label):
243 if embedded_smb: reconnect_mount()
204 deadline, previous, incomplete = time.monotonic() + 120, None, 0244 deadline, previous, incomplete = time.monotonic() + 120, None, 0
205 while time.monotonic() < deadline:245 while time.monotonic() < deadline:
206 snapshot = {name: snapshot_file(name) for name in ('synthetic.one', 'Open Notebook.onetoc2')}246 snapshot = {name: snapshot_file(name) for name in ('synthetic.one', 'Open Notebook.onetoc2')}
...@@ -228,7 +268,7 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,...@@ -228,7 +268,7 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,
228268
229 if abrupt and not conflict_clients:269 if abrupt and not conflict_clients:
230 from concurrent_rust import running_clients270 from concurrent_rust import running_clients
231 from crash_recovery import verify_text271 from crash_recovery import active_text, verify_text
232 import crash272 import crash
233273
234 action(clients[0], 'prepare-stress', clients=len(clients))274 action(clients[0], 'prepare-stress', clients=len(clients))
...@@ -244,6 +284,8 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,...@@ -244,6 +284,8 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,
244 changed = native_text[i] + ' Before server stop.'284 changed = native_text[i] + ' Before server stop.'
245 action(client, 'edit', expected=native_text[i], text=changed)285 action(client, 'edit', expected=native_text[i], text=changed)
246 native_text[i] = changed286 native_text[i] = changed
287 for client in clients: action(client, 'sync')
288 for client in clients: wait_text(client, native_text)
247 deadline = time.monotonic() + 60289 deadline = time.monotonic() + 60
248 while True:290 while True:
249 commits = sum(line.count('"event":"commit"') for path in folder.glob('w*.jsonl') for line in path.read_text().splitlines())291 commits = sum(line.count('"event":"commit"') for path in folder.glob('w*.jsonl') for line in path.read_text().splitlines())
...@@ -254,6 +296,8 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,...@@ -254,6 +296,8 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,
254 assert all(p.poll() is None for p in processes.values()), 'A client stopped before the planned interruption'296 assert all(p.poll() is None for p in processes.values()), 'A client stopped before the planned interruption'
255 (output / 'server-crash.json').write_text(json.dumps(crash.stop('linux', server), indent=2))297 (output / 'server-crash.json').write_text(json.dumps(crash.stop('linux', server), indent=2))
256 for client in clients: vm.qmp(client['name'], 'set_link', {'name': 'lab', 'up': False})298 for client in clients: vm.qmp(client['name'], 'set_link', {'name': 'lab', 'up': False})
299 for process in processes.values(): process.terminate()
300 unmount(force=True)
257 raise InterruptedError('Recorded server interruption')301 raise InterruptedError('Recorded server interruption')
258 except InterruptedError:302 except InterruptedError:
259 pass303 pass
...@@ -267,11 +311,12 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,...@@ -267,11 +311,12 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,
267 subprocess.run(linux_vm.ssh_argv(server, 'cat /srv/agent/m6-collaboration/synthetic.one'), stdout=stream, check=True, timeout=60)311 subprocess.run(linux_vm.ssh_argv(server, 'cat /srv/agent/m6-collaboration/synthetic.one'), stdout=stream, check=True, timeout=60)
268 subprocess.run([ROOT / 'target/debug/examples/document', recovered / 'synthetic.one', recovered / 'model'], check=True)312 subprocess.run([ROOT / 'target/debug/examples/document', recovered / 'synthetic.one', recovered / 'model'], check=True)
269 model = json.loads((recovered / 'model/document.json').read_text())313 model = json.loads((recovered / 'model/document.json').read_text())
270 text, = [text for values in reachable_page_text(model).values() for text in values if text.startswith('Concurrent edits:')]314 text = active_text(model)
271 retained = verify_text('Concurrent edits:', text, logs)315 retained = verify_text('Concurrent edits:', text, logs)
272 (output / 'server-retention.json').write_text(json.dumps(retained, indent=2))316 (output / 'server-retention.json').write_text(json.dumps(retained, indent=2))
273 reconnect_mount()317 reconnect_mount()
274 for client in clients: vm.qmp(client['name'], 'set_link', {'name': 'lab', 'up': True})318 for client in clients: vm.qmp(client['name'], 'set_link', {'name': 'lab', 'up': True})
319 for client in clients: action(client, 'sync')
275 for client in clients: wait_text(client, [text, *native_text])320 for client in clients: wait_text(client, [text, *native_text])
276 checkpoint('server-recovered')321 checkpoint('server-recovered')
277322
...@@ -302,6 +347,14 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,...@@ -302,6 +347,14 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,
302 (output / 'native-cache-result.json').write_text(json.dumps({'cache_acknowledged': pending_text, 'retained_after_abrupt_stop': survived, 'server_durability_acknowledged': False}, indent=2))347 (output / 'native-cache-result.json').write_text(json.dumps({'cache_acknowledged': pending_text, 'retained_after_abrupt_stop': survived, 'server_durability_acknowledged': False}, indent=2))
303 for client in clients: wait_text(client, [text, *native_text])348 for client in clients: wait_text(client, [text, *native_text])
304 checkpoint('client-recovered')349 checkpoint('client-recovered')
350 recovered_model = json.loads((output / 'client-recovered/model/document.json').read_text())
351 recovered_pages = reachable_page_text(recovered_model)
352 main_space, *conflict_spaces = recovered_pages
353 known = {'Concurrent edits:', *[f'Native {i}:' for i in range(len(clients))], *native_text, pending_text}
354 conflicts = {sid: recovered_pages[sid] for sid in conflict_spaces}
355 assert all(value in known or (value.endswith(']') and text.startswith(value))
356 for values in conflicts.values() for value in values), 'A recovered conflict contains unrecorded content'
357 (output / 'cache-conflicts.json').write_text(json.dumps(conflicts, indent=2))
305358
306 continued = output / 'rust-continued'359 continued = output / 'rust-continued'
307 continued.mkdir()360 continued.mkdir()
...@@ -310,7 +363,7 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,...@@ -310,7 +363,7 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,
310 continuation = {actor: [json.loads(line) for line in (continued / f'{actor}.jsonl').read_text().splitlines()] for actor in processes}363 continuation = {actor: [json.loads(line) for line in (continued / f'{actor}.jsonl').read_text().splitlines()] for actor in processes}
311 checkpoint('continued')364 checkpoint('continued')
312 model = json.loads((output / 'continued/model/document.json').read_text())365 model = json.loads((output / 'continued/model/document.json').read_text())
313 final_text, = [value for values in reachable_page_text(model).values() for value in values if value.startswith('Concurrent edits:')]366 final_text = active_text(model)
314 result = verify_text(text, final_text, continuation)367 result = verify_text(text, final_text, continuation)
315 for client in clients: wait_text(client, [final_text, *native_text])368 for client in clients: wait_text(client, [final_text, *native_text])
316 (output / 'result.json').write_text(json.dumps({'server': retained, 'continuation': result, 'native_cache_retained': survived, 'expected_text': sorted([final_text, *native_text])}, indent=2))369 (output / 'result.json').write_text(json.dumps({'server': retained, 'continuation': result, 'native_cache_retained': survived, 'expected_text': sorted([final_text, *native_text])}, indent=2))
...@@ -370,7 +423,7 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,...@@ -370,7 +423,7 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,
370 for client in clients: action(client, 'sync')423 for client in clients: action(client, 'sync')
371 elif stress_clients:424 elif stress_clients:
372 from native_stress import exercise425 from native_stress import exercise
373 exercise(output, shared, clients, action, wait_action, wait_text, checkpoint, stress_operations, sync_every, rust_writers, rust_readers, edit, seed)426 exercise(output, shared, clients, action, wait_action, wait_text, checkpoint, stress_operations, sync_every, rust_writers, rust_readers, edit, seed, embedded_smb)
374 else:427 else:
375 a, b = clients428 a, b = clients
376 original = 'Fictitious: café, 東京, مرحبا'429 original = 'Fictitious: café, 東京, مرحبا'
...@@ -450,8 +503,9 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,...@@ -450,8 +503,9 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,
450 if abrupt and not conflict_clients:503 if abrupt and not conflict_clients:
451 checkpoint('crash-closed')504 checkpoint('crash-closed')
452 model = json.loads((output / 'crash-closed/model/document.json').read_text())505 model = json.loads((output / 'crash-closed/model/document.json').read_text())
453 actual = sorted(value for values in reachable_page_text(model).values() for value in values)506 actual = reachable_page_text(model)
454 assert actual == sorted([final_text, *native_text]), 'Application closure changed recovered edits'507 assert actual.pop(main_space) == sorted([final_text, *native_text]), 'Application closure changed recovered edits'
508 assert actual == conflicts, 'Application closure changed recovered conflict pages'
455 elif stress_clients:509 elif stress_clients:
456 checkpoint('stress-closed')510 checkpoint('stress-closed')
457 elif conflict_clients:511 elif conflict_clients:
...@@ -476,9 +530,13 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,...@@ -476,9 +530,13 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,
476 (failure / 'capture-error.txt').write_text(str(error))530 (failure / 'capture-error.txt').write_text(str(error))
477 raise531 raise
478 finally:532 finally:
479 if mounted:533 if embedded_smb and linux_vm.instance_path(server).exists() and linux_vm.running(server):
480 result = subprocess.run(['/sbin/umount', str(mount)], capture_output=True, text=True)534 try:
481 (output / 'unmount.json').write_text(json.dumps({'exit': result.returncode, 'stderr': result.stderr}))535 with (output / 'smb-trace.jsonl').open('wb') as trace:
536 subprocess.run(linux_vm.ssh_argv(server, 'cat /tmp/smb-trace.jsonl'), stdout=trace, check=True, timeout=30)
537 except Exception as error:
538 (output / 'trace-error.txt').write_text(str(error))
539 if os.path.ismount(mount): unmount()
482 if linux_vm.instance_path(server).exists():540 if linux_vm.instance_path(server).exists():
483 try:541 try:
484 if linux_vm.running(server): linux_vm.shutdown(server, 60)542 if linux_vm.running(server): linux_vm.shutdown(server, 60)
...@@ -488,6 +546,20 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,...@@ -488,6 +546,20 @@ def replay(output, server, stress_clients=0, stress_operations=30, sync_every=1,
488 while linux_vm.running(server) and time.monotonic() < deadline: time.sleep(.1)546 while linux_vm.running(server) and time.monotonic() < deadline: time.sleep(.1)
489 linux_vm.delete_instance(server)547 linux_vm.delete_instance(server)
490 (output / 'teardown.json').write_text(json.dumps({'linux_absent': not linux_vm.instance_path(server).exists()}, indent=2))548 (output / 'teardown.json').write_text(json.dumps({'linux_absent': not linux_vm.instance_path(server).exists()}, indent=2))
549 if embedded_smb:
550 from verify_smb_overlap import verify
551 with (output / 'smb-trace.jsonl').open() as trace:
552 overlap = verify(json.loads(line) for line in trace)
553 (output / 'overlap.json').write_text(json.dumps(overlap, indent=2))
554 if offline_lost_reply:
555 from offline_outage import verify_lost_reply
556 (output / 'offline-lost-reply-verification.json').write_text(json.dumps(verify_lost_reply(output), indent=2))
557 if offline_outage:
558 from offline_outage import verify_outage
559 (output / 'offline-outage-verification.json').write_text(json.dumps(verify_outage(output), indent=2))
560 if disconnect:
561 from native_disconnect import verify_disconnect
562 (output / 'disconnect-verification.json').write_text(json.dumps(verify_disconnect(output), indent=2))
491563
492564
493if __name__ == '__main__':565if __name__ == '__main__':
...@@ -503,7 +575,29 @@ if __name__ == '__main__':...@@ -503,7 +575,29 @@ if __name__ == '__main__':
503 parser.add_argument('--edit', action='store_true', help='Use random text replacements in every writer.')575 parser.add_argument('--edit', action='store_true', help='Use random text replacements in every writer.')
504 parser.add_argument('--seed', type=int, default=710)576 parser.add_argument('--seed', type=int, default=710)
505 parser.add_argument('--abrupt', action='store_true', help='Abrupt server and client stops with preserved-disk recovery and intent accounting.')577 parser.add_argument('--abrupt', action='store_true', help='Abrupt server and client stops with preserved-disk recovery and intent accounting.')
578 parser.add_argument('--embedded-smb', action='store_true', help='Run Rust stress clients through the embedded SMB adapter.')
579 parser.add_argument('--maintenance', action='store_true', help='Pause the workload for the owned maintenance controller.')
580 parser.add_argument('--offline', action='store_true', help='Use durable local queues and traced offline workers for Rust writers.')
581 parser.add_argument('--document-operations', action='store_true', help='Queue paragraph/outline creation and formatting alongside offline text edits.')
582 parser.add_argument('--record-writes', action='store_true', help='Retain owned lab write payloads for revision replay.')
583 parser.add_argument('--offline-lost-reply', action='store_true', help='Drop a publication reply and require confirmation of its original revision.')
584 parser.add_argument('--offline-client-reply', action='store_true', help='Disconnect only the formatting writer and require peer publication before it reconciles.')
585 parser.add_argument('--offline-outage', action='store_true', help='Queue local edits during an owned SMB outage, then require recovery.')
586 parser.add_argument('--disconnect', action='store_true', help='Interrupt and reconnect the embedded append workload twice.')
587 parser.add_argument('--client-profile', choices=('debug', 'release'), default='debug', help='Cargo build profile for Rust stress clients')
588 parser.add_argument('--client-timeout', type=float, default=600, help='Maximum seconds for the Rust workload, including its start barrier.')
589 parser.add_argument('--fixture', type=Path, help='Copy this fixture directory into the disposable stress notebook.')
506 args = parser.parse_args()590 args = parser.parse_args()
591 if not 0 < args.client_timeout < 2**64 / 1000: parser.error('Choose a finite positive client timeout.')
592 if args.maintenance and not (args.embedded_smb and args.stress_clients): parser.error('--maintenance requires embedded SMB stress mode.')
593 if args.document_operations and not args.offline: parser.error('--document-operations requires --offline.')
594 if args.record_writes and not args.embedded_smb: parser.error('--record-writes requires --embedded-smb.')
595 if args.offline_client_reply and not (args.offline_lost_reply and args.document_operations): parser.error('--offline-client-reply requires --offline-lost-reply and --document-operations.')
596 if args.offline_lost_reply and (not args.offline or args.offline_outage or args.sync_every or args.stress_operations < 8): parser.error('--offline-lost-reply requires --offline, --sync-every 0, at least eight operations and no --offline-outage.')
597 if args.offline_outage and (not args.offline or args.sync_every or args.stress_operations < 8): parser.error('--offline-outage requires --offline, --sync-every 0 and at least eight operations.')
598 if args.offline and (not args.embedded_smb or not args.stress_clients or args.edit or args.disconnect or args.maintenance): parser.error('--offline requires embedded append stress without disconnect or maintenance.')
599 if args.embedded_smb and not args.stress_clients: parser.error('--embedded-smb requires --stress-clients.')
600 if args.disconnect and (not args.embedded_smb or args.edit or args.maintenance or args.sync_every): parser.error('--disconnect requires embedded append stress with --sync-every 0 and no maintenance.')
507 if args.rust_writers < 2 or args.rust_readers < 1: parser.error('Use at least two Rust writers and one reader.')601 if args.rust_writers < 2 or args.rust_readers < 1: parser.error('Use at least two Rust writers and one reader.')
508 if args.sync_every < 0 or args.stress_operations <= 0: parser.error('Use a nonnegative sync interval and positive operation count.')602 if args.sync_every < 0 or args.stress_operations <= 0: parser.error('Use a nonnegative sync interval and positive operation count.')
509 if args.stress_clients and args.stress_clients < 3: parser.error('Stress mode requires at least three native clients.')603 if args.stress_clients and args.stress_clients < 3: parser.error('Stress mode requires at least three native clients.')
...@@ -511,4 +605,4 @@ if __name__ == '__main__':...@@ -511,4 +605,4 @@ if __name__ == '__main__':
511 if args.abrupt and (args.stress_clients or args.edit): parser.error('Abrupt recovery uses append intents or the offline-conflict workload.')605 if args.abrupt and (args.stress_clients or args.edit): parser.error('Abrupt recovery uses append intents or the offline-conflict workload.')
512 def interrupted(_signal, _frame): raise KeyboardInterrupt606 def interrupted(_signal, _frame): raise KeyboardInterrupt
513 signal.signal(signal.SIGTERM, interrupted)607 signal.signal(signal.SIGTERM, interrupted)
514 replay(args.output, args.linux, args.stress_clients, args.stress_operations, args.sync_every, args.rust_writers, args.rust_readers, args.edit, args.seed, args.conflict_clients, args.abrupt)608 replay(args.output, args.linux, args.stress_clients, args.stress_operations, args.sync_every, args.rust_writers, args.rust_readers, args.edit, args.seed, args.conflict_clients, args.abrupt, args.embedded_smb, args.maintenance, args.fixture, args.disconnect, args.client_timeout, args.offline, args.offline_outage, args.offline_lost_reply, args.client_profile, args.document_operations, args.record_writes, args.offline_client_reply)
tools/native_disconnect.py created+137
...@@ -0,0 +1,137 @@
1"""Interrupt an owned mixed append workload and require progress after reconnection."""
2import json
3import shlex
4import subprocess
5import time
6
7from native_runner import windows
8import linux_vm
9from verify_smb_overlap import verify
10
11
12def interrupt(output, clients, sequences, processes):
13 config = json.loads((output / 'run.json').read_text())
14 server = config['server']
15 samples = []
16 subprocess.run(linux_vm.ssh_argv(server, 'cat > /tmp/verify_smb_overlap.py'),
17 input=(output / 'harness/verify_smb_overlap.py').read_bytes(), check=True)
18
19 def counts(transport=False):
20 result = {}
21 for actor in processes:
22 data = (output / 'rust' / (actor + '.jsonl')).read_text()
23 events = [json.loads(line) for line in data[:data.rfind('\n') + 1].splitlines()]
24 names = ('transport_read_error', 'transport_commit_error') if transport else ('commit' if actor.startswith('w') else 'read',)
25 result[actor] = sum(event['event'] in names for event in events)
26 return result
27
28 def wait_for(predicate, message):
29 deadline = time.monotonic() + 90
30 while not predicate():
31 assert all(process.poll() is None for process in processes.values()), 'A Rust client exited during the interruption campaign'
32 if time.monotonic() > deadline: raise TimeoutError(message)
33 time.sleep(.1)
34
35 def ssh(command):
36 result = linux_vm.run_ssh(server, command, timeout=15)
37 with (output / 'disconnect-server.jsonl').open('a') as stream:
38 stream.write(json.dumps({'command': command, 'exit': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr}) + '\n')
39 result.check_returncode()
40 return result.stdout
41
42 def phase(control):
43 text = json.dumps(control)
44 ssh("printf '%s' '" + text + "' > /tmp/smb-control.tmp && mv /tmp/smb-control.tmp /tmp/smb-control.json")
45 wait_for(lambda: text in ssh("grep -F '\"control\":' /tmp/smb-trace.jsonl | tail -n 1"), 'Proxy did not acknowledge the disconnect phase')
46
47 previous = {actor: 0 for actor in processes}
48 for cycle in range(2):
49 wait_for(lambda: all(value >= previous[actor] + 3 for actor, value in counts().items()), 'Clients made no progress before the interruption')
50 before = counts()
51 native = []
52 for actor, (client, sequence) in enumerate(zip(clients, sequences)):
53 capture = output / f'disconnect-{cycle}-n{actor}.jsonl'
54 result = windows.do_get(f'C:\\one-tests\\runs\\capture\\outbox\\{sequence}\\events.jsonl', capture, client['name'])
55 assert not result.get('error'), result
56 data = capture.read_text(encoding='utf-8-sig')
57 rows = [json.loads(line) for line in data[:data.rfind('\n') + 1].splitlines()]
58 assert 0 < len(rows) < config['stress_operations'], 'A native writer was inactive before the interruption'
59 native.append(len(rows))
60 before_errors = counts(transport=True)
61 assert all(process.poll() is None for process in processes.values()), 'A Rust client finished before the interruption'
62 try:
63 phase({'phase': f'disconnect-{cycle}', 'cut': 9, 'peer': '10.0.2.2', 'offset': 96,
64 'direction': 'request' if cycle == 0 else 'response'})
65 wait_for(lambda: f'"phase": "disconnect-{cycle}"' in ssh("grep -F '\"control\":' /tmp/smb-trace.jsonl | tail -n 1")
66 and int(ssh("grep -c '\"cut\": {' /tmp/smb-trace.jsonl || true").strip()) == cycle + 1,
67 'The planned write interruption did not occur')
68 time.sleep(3)
69 finally:
70 phase({'phase': f'reconnected-{cycle}'})
71 wait_for(lambda: all(value >= before[actor] + 3 for actor, value in counts().items()), 'A client failed to progress after reconnecting')
72 script = '\n'.join([
73 'import json', 'from verify_smb_overlap import verify, PendingOverlap',
74 "data = open('/tmp/smb-trace.jsonl').read()",
75 "events = [json.loads(line) for line in data[:data.rfind('\\n') + 1].splitlines()]",
76 'try:', f" result = verify(events, phase='reconnected-{cycle}')",
77 "except PendingOverlap: result = None", 'print(json.dumps(result))'])
78 wait_for(lambda: json.loads(ssh('cd /tmp && python3 -c ' + shlex.quote(script))) is not None,
79 'Native and Rust guarded I/O did not overlap after reconnection')
80 previous = counts()
81 samples.append({'cycle': cycle, 'before': before, 'after': previous, 'native_before': native,
82 'before_errors': before_errors, 'after_errors': counts(transport=True)})
83 (output / 'disconnect-progress.json').write_text(json.dumps(samples, indent=2))
84
85
86def verify_disconnect(output):
87 config = json.loads((output / 'run.json').read_text())
88 assert config['stress_clients'] + config['rust_writers'] + config['rust_readers'] >= 12
89 samples = json.loads((output / 'disconnect-progress.json').read_text())
90 assert [sample['cycle'] for sample in samples] == [0, 1]
91 actors = {f'w{i}' for i in range(config['rust_writers'])} | {f'r{i}' for i in range(config['rust_readers'])}
92 events = [json.loads(line) for line in (output / 'smb-trace.jsonl').read_text().splitlines()]
93 assert sum('cut' in event for event in events) == 2, 'Unexpected number of connection interruptions'
94 errors = {}
95 progress = {}
96 progress_limit = 120
97 started = (output / 'rust/start').stat().st_mtime_ns // 1000
98 stopped = (output / 'rust/stop').stat().st_mtime_ns // 1000
99
100 def max_gap(actor, times, units):
101 assert len(times) > 1, 'A client made no progress'
102 gaps = [(end - begin) / units for begin, end in zip(times, times[1:])]
103 assert all(0 <= gap <= progress_limit for gap in gaps), f'{actor}: client progress stalled or went backwards'
104 return max(gaps)
105
106 for i in range(config['stress_clients']):
107 rows = [json.loads(line) for line in (output / f'n{i}/stress-events.jsonl').read_text(encoding='utf-8-sig').splitlines()]
108 assert len(rows) == config['stress_operations'], 'A native writer did not finish'
109 progress[f'n{i}'] = max_gap(f'n{i}', [rows[0]['update_started_ticks'], *[row['updated_ticks'] for row in rows]], 10**7)
110 uncertain = set()
111 for actor in sorted(actors):
112 rows = [json.loads(line) for line in (output / 'rust' / (actor + '.jsonl')).read_text().splitlines()]
113 errors[actor] = sum(row['event'] in ('transport_read_error', 'transport_commit_error') for row in rows)
114 assert errors[actor] and any(row['event'] == 'transport_connected' for row in rows), 'A Rust client did not exercise reconnection'
115 times = [started, *[row['finished_us'] for row in rows if row['event'] == ('commit' if actor.startswith('w') else 'read')]]
116 if actor.startswith('r') and len(times) > 1: times.append(max(times[-1], stopped))
117 progress[actor] = max_gap(actor, times, 10**6)
118 pending = None
119 for row in rows:
120 if row['event'] == 'transport_commit_error': pending = row
121 if row['event'] != 'transport_reconciled': continue
122 assert pending is not None and pending['token'] == row['token']
123 if row['published']: assert row.get('flush_confirmed'), 'Visible recovery lacks a durable acknowledgement'
124 if pending['state'] == 'Unknown': uncertain.add('after' if row['published'] else 'before')
125 pending = None
126 assert uncertain == {'before', 'after'}, 'The mixed workload did not resolve both uncertain outcomes'
127 overlap = []
128 for sample in samples:
129 assert all(set(sample[field]) == actors for field in ('before', 'after', 'before_errors', 'after_errors'))
130 assert all(sample['after'][actor] >= value + 3 for actor, value in sample['before'].items())
131 assert all(sample['after_errors'][actor] > value for actor, value in sample['before_errors'].items()), 'A client did not encounter this interruption'
132 assert len(sample['native_before']) == config['stress_clients']
133 assert all(0 < value < config['stress_operations'] for value in sample['native_before'])
134 end = next((i for i, event in enumerate(events) if event.get('control', {}).get('phase') == f'disconnect-{sample["cycle"] + 1}'), len(events))
135 overlap.append(verify(events[:end], phase=f'reconnected-{sample["cycle"]}'))
136 return {'interruptions': 2, 'transport_errors': errors, 'uncertain_outcomes': sorted(uncertain), 'resumed_overlap': overlap,
137 'max_progress_gap_seconds': progress, 'progress_limit_seconds': progress_limit}
tools/native_maintenance.py created+210
...@@ -0,0 +1,210 @@
1#!/usr/bin/env python3
2"""Compact a shared section between two halves of a twelve-client editing run."""
3import argparse
4import base64
5import json
6import os
7from pathlib import Path
8import signal
9import subprocess
10import sys
11import time
12import xml.etree.ElementTree as ET
13
14from native_runner import ROOT, windows
15import linux_vm
16from verify_smb_overlap import verify
17
18
19def maintenance_locks(events):
20 pending, files, peers = {}, {}, {}
21 phase, attempts = None, []
22 for event in events:
23 assert not event.get('trace_error') and not event.get('encrypted')
24 phase = event.get('control', {}).get('phase', phase)
25 connection = event.get('connection')
26 if event.get('opened'): peers[connection] = event['peer'][0]
27 if event.get('closed'):
28 files = {key: value for key, value in files.items() if key[0] != connection}
29 if 'command' not in event: continue
30 key = connection, event['message']
31 if event['direction'] == 'request':
32 pending[key] = event, phase
33 continue
34 if event['status'] == '0x103': continue
35 pair = pending.pop(key, None)
36 if pair is None: continue
37 request, issued = pair
38 command = request['command']
39 if command == 5 and event['status'] == '0x0':
40 files[connection, event['file_id']] = request['path'].replace('\\', '/').lower()
41 elif command == 6:
42 files.pop((connection, request['file_id']), None)
43 elif command == 10 and peers[connection].startswith('192.168.77.'):
44 path = files.get((connection, request['file_id']), '')
45 if path != 'm6-collaboration/synthetic.one': continue
46 for offset, length, flags in request['locks']:
47 if (offset, length) == (0xffffeffc, 4096) and flags & 3 == 2:
48 attempts.append({'phase': issued, 'status': event['status'], 'connection': connection,
49 'message': event['message'], 'flags': flags})
50 assert any(a['phase'] == 'maintenance-held' and a['status'] in ('0xc0000054', '0xc0000055') for a in attempts), 'No section maintenance conflict observed while the reader held its guard'
51 assert any(a['phase'] == 'maintenance-released' and a['status'] == '0x0' for a in attempts), 'No section maintenance guard acquired after reader release'
52 return attempts
53
54
55def run(output, server, fixture, operations, seed):
56 output = output.resolve()
57 assert not output.exists(), 'Choose a new output directory'
58 output.parent.mkdir(parents=True, exist_ok=True)
59 guardian = None
60 with output.with_suffix('.log').open('x') as log:
61 process = subprocess.Popen([sys.executable, ROOT / 'tools/native_collaboration.py', output,
62 '--linux', server, '--stress-clients', '4', '--rust-writers', '4', '--rust-readers', '4',
63 '--stress-operations', str(operations), '--seed', str(seed), '--edit', '--embedded-smb',
64 '--maintenance', '--fixture', fixture], stdout=log, stderr=subprocess.STDOUT)
65
66 def wait_for(predicate, timeout, message):
67 deadline = time.monotonic() + timeout
68 while not predicate():
69 if process.poll() is not None: raise RuntimeError(f'Workload exited with {process.returncode}: {message}')
70 if guardian is not None and guardian.poll() is not None and guardian.returncode != 0:
71 raise RuntimeError('The reader guardian failed')
72 if time.monotonic() > deadline: raise TimeoutError(message)
73 time.sleep(.2)
74
75 def ssh(command):
76 result = linux_vm.run_ssh(server, command, timeout=30)
77 with (output / 'maintenance-server.jsonl').open('a') as stream:
78 stream.write(json.dumps({'command': command, 'exit': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr}) + '\n')
79 result.check_returncode()
80 return result.stdout
81
82 def phase(name):
83 ssh('printf \'{"phase":"' + name + '"}\' > /tmp/smb-control.json')
84 wait_for(lambda: name in ssh(f'grep -F \'"control": {{"phase": "{name}"}}\' /tmp/smb-trace.jsonl || true'),
85 10, 'Proxy did not acknowledge the maintenance phase')
86
87 def stat():
88 inode, size = ssh("stat -c '%i %s' /srv/agent/m6-collaboration/synthetic.one").split()
89 return {'inode': int(inode), 'size': int(size)}
90
91 def ui(name, script):
92 (output / f'{name}.ahk').write_text(script)
93 result = windows.do_exec(script, target=target, timeout_ms=60000, shot_delay_ms=500)
94 screenshot = result.pop('png_b64', None)
95 if screenshot: (output / f'{name}.png').write_bytes(base64.b64decode(screenshot))
96 (output / f'{name}.json').write_text(json.dumps(result, indent=2))
97 if result.get('error') or result.get('exit') != 0: raise RuntimeError(str(result))
98
99 try:
100 wait_for(lambda: (output / 'maintenance-ready').exists(), 900, 'Native clients did not reach the initial checkpoint')
101 (output / 'maintenance-start').touch()
102 shared, rust = output / 'mount/m6-collaboration', output / 'rust'
103 wait_for(lambda: all((shared / f'maintenance-paused-n{i}').exists() and (rust / f'paused-w{i}').exists() for i in range(4)),
104 300, 'Writers did not reach the maintenance barrier')
105 (rust / 'pause').touch()
106 wait_for(lambda: all((rust / f'paused-r{i}').exists() for i in range(4)), 60, 'Readers did not pause')
107 target = json.loads((output / 'n0/machine.json').read_text())['name']
108 page = ET.parse(sorted((output / 'n0').glob('snapshot-*.xml'))[-1]).getroot().attrib['ID']
109 ui('maintenance-options', f'''if A_ScreenWidth != 800 || A_ScreenHeight != 600
110 throw Error("The maintenance controller requires an 800 by 600 desktop.")
111ComObject("OneNote.Application").NavigateTo("{page}", "", false)
112WinWait("ahk_exe ONENOTE.EXE", , 10)
113WinActivate("ahk_exe ONENOTE.EXE")
114WinWaitActive("ahk_exe ONENOTE.EXE", , 10)
115Send("!ft")
116WinWait("OneNote Options", , 10)
117WinActivate("OneNote Options")
118WinWaitActive("OneNote Options", , 10)
119Sleep(1000)
120CoordMode("Mouse", "Screen")
121Click(74, 134)
122Sleep(1000)
123''')
124 hold = output / 'guardian'
125 hold.mkdir()
126 config = json.loads((output / 'linux.json').read_text())
127 with (hold / 'run.log').open('w') as guardian_log:
128 guardian = subprocess.Popen(['cargo', 'test', '-p', 'onestore-smb', 'live_reader_hold', '--', '--ignored', '--nocapture'],
129 cwd=ROOT, stdout=guardian_log, stderr=subprocess.STDOUT,
130 env={**os.environ, 'ONESTORE_SMB_LAB': f'127.0.0.1:{config["samba_port"]}',
131 'ONESTORE_SMB_PATH': 'm6-collaboration/synthetic.one', 'ONESTORE_SMB_HOLD': str(hold)})
132 wait_for(lambda: (hold / 'ready').exists(), 60, 'Reader guard was not acquired')
133 before = stat()
134 phase('maintenance-held')
135 ui('maintenance-denied', '''CoordMode("Mouse", "Screen")
136WinActivate("OneNote Options")
137WinWaitActive("OneNote Options", , 10)
138Click(254, 440)
139if !WinWait("Microsoft OneNote ahk_class #32770", , 30)
140 throw Error("The maintenance conflict dialog did not appear.")
141FileAppend(WinGetText("Microsoft OneNote ahk_class #32770"), "*")
142''')
143 held = stat()
144 assert held == before, 'Section changed while the reader held its maintenance exclusion guard'
145 (hold / 'release').touch()
146 wait_for(lambda: guardian.poll() is not None, 30, 'Reader guardian did not release')
147 assert guardian.returncode == 0
148 assert json.loads((hold / 'released.json').read_text())['accepted'] > 0
149 phase('maintenance-released')
150 ui('maintenance-dismiss', '''WinActivate("Microsoft OneNote ahk_class #32770")
151ControlClick("Button1", "Microsoft OneNote ahk_class #32770")
152if !WinWaitClose("Microsoft OneNote ahk_class #32770", , 10)
153 throw Error("The maintenance conflict dialog did not close.")
154''')
155 replacements = []
156 for attempt in range(5):
157 ui(f'maintenance-optimize-{attempt}', '''CoordMode("Mouse", "Screen")
158WinActivate("OneNote Options")
159WinWaitActive("OneNote Options", , 10)
160Click(254, 440)
161Sleep(3000)
162if WinExist("Microsoft OneNote ahk_class #32770") {
163 FileAppend(WinGetText("Microsoft OneNote ahk_class #32770"), "*")
164 ControlClick("Button1", "Microsoft OneNote ahk_class #32770")
165 if !WinWaitClose("Microsoft OneNote ahk_class #32770", , 10)
166 throw Error("The maintenance conflict dialog did not close.")
167}
168''')
169 replacements.append(stat())
170 if replacements[-1]['inode'] != before['inode'] and replacements[-1]['size'] < before['size']: break
171 time.sleep(2)
172 (output / 'maintenance-stat.json').write_text(json.dumps({'before': before, 'held': held, 'attempts': replacements}, indent=2))
173 assert replacements[-1]['inode'] != before['inode'] and replacements[-1]['size'] < before['size'], 'Native optimization did not replace and shrink the section'
174 ui('maintenance-options-close', '''WinActivate("OneNote Options")
175CoordMode("Mouse", "Screen")
176Click(663, 533)
177WinWaitClose("OneNote Options", , 10)
178''')
179 phase('maintenance-resumed')
180 (rust / 'resume').touch()
181 (shared / 'maintenance-resume').touch()
182 wait_for(lambda: process.poll() is not None, 600, 'Post-maintenance workload did not complete')
183 assert process.returncode == 0, 'Mixed workload failed after maintenance'
184 events = [json.loads(line) for line in (output / 'smb-trace.jsonl').read_text().splitlines()]
185 result = {'maintenance_locks': maintenance_locks(events), 'resumed_overlap': verify(events, phase='maintenance-resumed')}
186 (output / 'maintenance-verification.json').write_text(json.dumps(result, indent=2))
187 finally:
188 if guardian is not None and guardian.poll() is None:
189 (output / 'guardian/release').touch()
190 try: guardian.wait(timeout=30)
191 except subprocess.TimeoutExpired:
192 guardian.terminate()
193 guardian.wait(timeout=30)
194 if process.poll() is None:
195 process.terminate()
196 process.wait(timeout=180)
197
198
199if __name__ == '__main__':
200 parser = argparse.ArgumentParser(description=__doc__)
201 parser.add_argument('output', type=Path)
202 parser.add_argument('--linux', required=True)
203 parser.add_argument('--fixture', type=Path, required=True)
204 parser.add_argument('--operations', type=int, default=40)
205 parser.add_argument('--seed', type=int, default=908)
206 args = parser.parse_args()
207 if args.operations < 4 or args.operations % 2: parser.error('Use an even operation count of at least four.')
208 def interrupted(_signal, _frame): raise KeyboardInterrupt
209 signal.signal(signal.SIGTERM, interrupted)
210 run(args.output, args.linux, args.fixture.resolve(), args.operations, args.seed)
tools/native_probe.py+6-3
...@@ -9,11 +9,14 @@ import zipfile...@@ -9,11 +9,14 @@ import zipfile
9from native_runner import ROOT, clone, command, windows, collect_artifacts9from native_runner import ROOT, clone, command, windows, collect_artifacts
1010
1111
12def run(inputs, output):12def run(inputs, output, scripts=None):
13 output.mkdir(parents=True, exist_ok=False)13 output.mkdir(parents=True, exist_ok=False)
14 files = sorted(inputs.glob('*.one'))14 files = sorted(inputs.glob('*.one'))
15 assert files, 'No section candidates'15 assert files, 'No section candidates'
16 scripts = [ROOT / 'tools/native/cold.ps1', ROOT / 'tools/native/probe.ps1']16 if scripts is None:
17 (output / 'scripts').mkdir()
18 scripts = [output / 'scripts' / name for name in ('cold.ps1', 'probe.ps1')]
19 for path in scripts: path.write_bytes((ROOT / 'tools/native' / path.name).read_bytes())
17 (output / 'run.json').write_text(json.dumps({'inputs': {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in files},20 (output / 'run.json').write_text(json.dumps({'inputs': {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in files},
18 'scripts': {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in scripts}}, indent=2))21 'scripts': {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in scripts}}, indent=2))
19 archive = output / 'inputs.zip'22 archive = output / 'inputs.zip'
...@@ -28,7 +31,7 @@ def run(inputs, output):...@@ -28,7 +31,7 @@ def run(inputs, output):
28 if result.get('error'): raise RuntimeError(result['error'])31 if result.get('error'): raise RuntimeError(result['error'])
29 command(name, r'powershell -NoProfile -Command "Expand-Archive C:\one-tests\inputs.zip C:\one-tests\runs\capture\inputs"', output)32 command(name, r'powershell -NoProfile -Command "Expand-Archive C:\one-tests\inputs.zip C:\one-tests\runs\capture\inputs"', output)
30 command(name, r'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\one-tests\probe.ps1 -Root C:\one-tests\runs\capture -CloneHost ONE-' + name.upper(), output, (len(files) * 60 + 120) * 1000)33 command(name, r'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\one-tests\probe.ps1 -Root C:\one-tests\runs\capture -CloneHost ONE-' + name.upper(), output, (len(files) * 60 + 120) * 1000)
31 collect_artifacts(name, output, r'results, C:\one-tests\runs\capture\results.json')34 collect_artifacts(name, output, r'results, C:\one-tests\runs\capture\results.json, C:\one-tests\runs\capture\progress.jsonl')
32 finally:35 finally:
33 archive.unlink(missing_ok=True)36 archive.unlink(missing_ok=True)
3437
tools/native_stress.py+125-43
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1"""Overlapping native and Rust editing histories on one shared section."""1"""Overlapping native and Rust editing histories on one shared section."""
2import json2import json
3import os
3import time4import time
45
5from native_runner import windows6from native_runner import windows
...@@ -7,28 +8,36 @@ from concurrent_rust import running_clients...@@ -7,28 +8,36 @@ from concurrent_rust import running_clients
7from document_model import ordered_pages, walk8from document_model import ordered_pages, walk
89
910
10def edit_history(logs, operations, edit=False):11def edit_history(logs, operations, edit=False, *, partial=False, offline=False):
11 links = {}12 if offline:
12 for actor, events in logs.items():13 assert not edit, 'Offline acceptance currently uses append intents'
13 assert events[0]['event'] == 'ready' and events[-1]['event'] == 'done', 'Incomplete client log'14 from offline_history import publication_links
14 if not actor.startswith('w'): continue15 links = publication_links(logs, operations, partial)
15 intents = {event['attempt']: event for event in events if event['event'] == 'intent'}16 else:
16 commits = [event for event in events if event['event'] == 'commit']17 links = {}
17 assert sorted(event['operation'] for event in commits) == list(range(operations)), 'Missing or duplicate Rust acknowledgements'18 for actor, events in logs.items():
18 for event in commits:19 assert events and events[0]['event'] == 'ready', 'Missing client start'
19 intent = intents[event['attempt']]20 assert partial or events[-1]['event'] == 'done', 'Incomplete client log'
20 token = f' [{actor}:{event["operation"]}]'21 if not actor.startswith('w'): continue
21 offset = len(intent['before'].encode('utf-16-le')) // 222 intents = {event['attempt']: event for event in events if event['event'] == 'intent'}
22 assert event['token'] == intent['token'] == token, 'Acknowledgement differs from intended edit'23 commits = [event for event in events if event['event'] == 'commit']
23 assert intent['replacement'] == (' café 🦀' if edit else '') + token, 'Unexpected replacement text'24 assert len(commits) <= operations, 'Unexpected Rust acknowledgement'
24 assert intent['operation'] == event['operation'] and intent['source_transaction'] == event['source_transaction'], 'Acknowledgement used another intent'25 expected = len(commits) if partial else operations
25 start, end = intent['range']26 assert sorted(event['operation'] for event in commits) == list(range(expected)), 'Missing or duplicate Rust acknowledgements'
26 assert len('Concurrent edits:') <= start <= end <= offset, 'Invalid edit range'27 for event in commits:
27 if not edit: assert start == end == offset, 'Expected an append intent'28 intent = intents[event['attempt']]
28 units = intent['before'].encode('utf-16-le')29 token = f' [{actor}:{event["operation"]}]'
29 after = units[:start * 2].decode('utf-16-le') + intent['replacement'] + units[end * 2:].decode('utf-16-le')30 offset = len(intent['before'].encode('utf-16-le')) // 2
30 assert intent['before'] not in links, 'Acknowledged Rust edits branched from the same content'31 assert event['token'] == intent['token'] == token, 'Acknowledgement differs from intended edit'
31 links[intent['before']] = event, after32 assert intent['replacement'] == (' café 🦀' if edit else '') + token, 'Unexpected replacement text'
33 assert intent['operation'] == event['operation'] and intent['source_transaction'] == event['source_transaction'], 'Acknowledgement used another intent'
34 start, end = intent['range']
35 assert len('Concurrent edits:') <= start <= end <= offset, 'Invalid edit range'
36 if not edit: assert start == end == offset, 'Expected an append intent'
37 units = intent['before'].encode('utf-16-le')
38 after = units[:start * 2].decode('utf-16-le') + intent['replacement'] + units[end * 2:].decode('utf-16-le')
39 assert intent['before'] not in links, 'Acknowledged Rust edits branched from the same content'
40 links[intent['before']] = event, after
32 text = 'Concurrent edits:'41 text = 'Concurrent edits:'
33 versions = {text: 0}42 versions = {text: 0}
34 ordered = []43 ordered = []
...@@ -79,10 +88,15 @@ def verify_capture(output, capture):...@@ -79,10 +88,15 @@ def verify_capture(output, capture):
79 operations, edit = config['stress_operations'], config['edit']88 operations, edit = config['stress_operations'], config['edit']
80 actors = [f'w{i}' for i in range(config['rust_writers'])] + [f'r{i}' for i in range(config['rust_readers'])]89 actors = [f'w{i}' for i in range(config['rust_writers'])] + [f'r{i}' for i in range(config['rust_readers'])]
81 logs = {actor: [json.loads(line) for line in (output / 'rust' / f'{actor}.jsonl').read_text().splitlines()] for actor in actors}90 logs = {actor: [json.loads(line) for line in (output / 'rust' / f'{actor}.jsonl').read_text().splitlines()] for actor in actors}
82 commits, rust_text = edit_history(logs, operations, edit)91 commits, rust_text = edit_history(logs, operations, edit, offline=config.get('offline', False))
83 native = [[json.loads(line) for line in (output / f'n{i}' / 'stress-events.jsonl').read_text(encoding='utf-8-sig').splitlines()]92 native = [[json.loads(line) for line in (output / f'n{i}' / 'stress-events.jsonl').read_text(encoding='utf-8-sig').splitlines()]
84 for i in range(config['stress_clients'])]93 for i in range(config['stress_clients'])]
85 expected = [rust_text, *(native_history(events, i, operations, edit) for i, events in enumerate(native))]94 expected = [rust_text, *(native_history(events, i, operations, edit) for i, events in enumerate(native))]
95 documents = {}
96 if config.get('document_operations'):
97 from offline_document_history import document_history
98 documents = document_history(logs, operations)
99 expected.extend(document['text'] for document in documents.values())
86 page_file, = capture.glob('page-*.xml')100 page_file, = capture.glob('page-*.xml')
87 page = ET.parse(page_file).getroot()101 page = ET.parse(page_file).getroot()
88 paragraphs = native_characters(page, page.findall('one:Outline', ns))102 paragraphs = native_characters(page, page.findall('one:Outline', ns))
...@@ -95,16 +109,31 @@ def verify_capture(output, capture):...@@ -95,16 +109,31 @@ def verify_capture(output, capture):
95 for field in ('bold', 'italic'):109 for field in ('bold', 'italic'):
96 assert bool(style.get(field)) == events[-1][field], 'Fresh native formatting differs from its last recorded editing intent'110 assert bool(style.get(field)) == events[-1][field], 'Fresh native formatting differs from its last recorded editing intent'
97 checks += 1111 checks += 1
98 return {'rust_intents': len(commits), 'native_intents': sum(map(len, native)),112 if documents:
113 from offline_document_history import verify_native
114 checks += verify_native(paragraphs, documents)
115 return {'rust_intents': len(commits), 'document_intents': len(documents)*2, 'native_intents': sum(map(len, native)),
99 'exact_paragraphs': len(expected), 'native_intended_format_checks': checks}116 'exact_paragraphs': len(expected), 'native_intended_format_checks': checks}
100117
101118
102def exercise(output, shared, clients, action, wait_action, wait_text, checkpoint, operations, sync_every, rust_writers, rust_readers, edit=False, seed=710):119def exercise(output, shared, clients, action, wait_action, wait_text, checkpoint, operations, sync_every, rust_writers, rust_readers, edit=False, seed=710, embedded_smb=False):
120 wait_text(clients[0], ['Concurrent edits:'])
103 action(clients[0], 'prepare-stress', clients=len(clients))121 action(clients[0], 'prepare-stress', clients=len(clients))
104 prefixes = [f'Native {i}:' for i in range(len(clients))]122 prefixes = [f'Native {i}:' for i in range(len(clients))]
105 for client in clients:123 for client in clients:
106 wait_text(client, ['Concurrent edits:', *prefixes])124 wait_text(client, ['Concurrent edits:', *prefixes])
107 checkpoint('stress-initial')125 checkpoint('stress-initial')
126 config = json.loads((output / 'run.json').read_text())
127 maintenance = config.get('maintenance', False)
128 disconnect = config.get('disconnect', False)
129 offline_outage = config.get('offline_outage', False)
130 offline_lost_reply = config.get('offline_lost_reply', False)
131 if maintenance:
132 (output / 'maintenance-ready').touch()
133 deadline = time.monotonic() + 300
134 while not (output / 'maintenance-start').exists():
135 if time.monotonic() > deadline: raise TimeoutError('Maintenance controller did not start the workload')
136 time.sleep(.1)
108 clocks = []137 clocks = []
109 for client in clients:138 for client in clients:
110 before = time.time_ns() // 1000139 before = time.time_ns() // 1000
...@@ -113,7 +142,7 @@ def exercise(output, shared, clients, action, wait_action, wait_text, checkpoint...@@ -113,7 +142,7 @@ def exercise(output, shared, clients, action, wait_action, wait_text, checkpoint
113 native = result['utc_us']142 native = result['utc_us']
114 clocks.append({'native_minus_host_us': [native - after - 15625, native - before + 15625]})143 clocks.append({'native_minus_host_us': [native - after - 15625, native - before + 15625]})
115 (output / 'clocks.json').write_text(json.dumps(clocks, indent=2))144 (output / 'clocks.json').write_text(json.dumps(clocks, indent=2))
116 sequences = [action(client, 'stress', wait=False, actor=i, prefix=prefixes[i], operations=operations, seed=seed + 10000 + i, sync_every=sync_every, edit=edit)145 sequences = [action(client, 'stress', wait=False, actor=i, prefix=prefixes[i], operations=operations, seed=seed + 10000 + i, sync_every=sync_every, edit=edit, maintenance=maintenance)
117 for i, client in enumerate(clients)]146 for i, client in enumerate(clients)]
118 for client, sequence in zip(clients, sequences):147 for client, sequence in zip(clients, sequences):
119 deadline = time.monotonic() + 60148 deadline = time.monotonic() + 60
...@@ -125,18 +154,63 @@ def exercise(output, shared, clients, action, wait_action, wait_text, checkpoint...@@ -125,18 +154,63 @@ def exercise(output, shared, clients, action, wait_action, wait_text, checkpoint
125 folder = output / 'rust'154 folder = output / 'rust'
126 folder.mkdir()155 folder.mkdir()
127 start = folder / 'start'156 start = folder / 'start'
128 with running_clients(folder, shared / 'synthetic.one', rust_writers, rust_readers, operations, seed, edit=edit) as processes:157 from concurrent_rust import ROOT
129 (shared / 'stress-start').write_text('start')158 binaries = ROOT / 'target' / config.get('client_profile', 'debug') / 'examples'
159 source = 'm6-collaboration\\synthetic.one' if embedded_smb else shared / 'synthetic.one'
160 executable = binaries / ('smb_concurrent_client' if embedded_smb else 'concurrent_client')
161 if disconnect: executable = binaries / 'smb_reconnect_client'
162 if config.get('offline'): executable = binaries / 'smb_offline_client'
163 environment = {**os.environ, 'ONESTORE_MAINTENANCE_DIR': str(folder)} if maintenance else None
164 reader_executable = None
165 if offline_outage:
166 environment = {**os.environ, 'ONESTORE_OFFLINE_OUTAGE_DIR': str(folder)}
167 reader_executable = binaries / 'smb_reconnect_client'
168 if offline_lost_reply:
169 captures = folder / 'confirmations'
170 captures.mkdir()
171 environment = {**os.environ, 'ONESTORE_OFFLINE_CONFIRM_DIR': str(captures)}
172 reader_executable = binaries / 'smb_reconnect_client'
173 if config.get('document_operations'):
174 environment = {**(environment or os.environ), 'ONESTORE_OFFLINE_DOCUMENTS': '1'}
175 if offline_lost_reply:
176 environment['ONESTORE_OFFLINE_FORMAT_REPLY_DIR'] = str(folder)
177 try:
178 with running_clients(folder, source, rust_writers, rust_readers, operations, seed, timeout=config.get('client_timeout', 600), edit=edit, executable=executable, environment=environment, reader_executable=reader_executable) as processes:
179 (shared / 'stress-start').write_text('start')
180 for client, sequence in zip(clients, sequences):
181 deadline = time.monotonic() + 60
182 while time.monotonic() < deadline:
183 result = windows.do_cmd(f'if exist C:\\one-tests\\runs\\capture\\outbox\\{sequence}\\editing echo editing', target=client['name'])
184 if 'editing' in result.get('stdout', ''): break
185 time.sleep(.1)
186 else: raise TimeoutError('Native stress client did not acknowledge its first edit')
187 start.touch()
188 if disconnect:
189 from native_disconnect import interrupt
190 interrupt(output, clients, sequences, processes)
191 if offline_outage or offline_lost_reply:
192 from offline_outage import interrupt
193 interrupt(output, clients, sequences, processes)
194 if embedded_smb:
195 import linux_vm
196 server = json.loads((output / 'run.json').read_text())['server']
197 with (output / 'server-locks.jsonl').open('w') as trace:
198 for _ in range(100):
199 captured = linux_vm.run_ssh(server, 'sudo smbstatus --byterange --json', timeout=5)
200 captured.check_returncode()
201 trace.write(json.dumps(json.loads(captured.stdout)) + '\n')
202 trace.flush()
203 time.sleep(.1)
204 for client, sequence in zip(clients, sequences):
205 wait_action(client, sequence, 'stress')
206 finally:
130 for client, sequence in zip(clients, sequences):207 for client, sequence in zip(clients, sequences):
131 deadline = time.monotonic() + 60208 local = client['folder'] / 'stress-events.jsonl'
132 while time.monotonic() < deadline:209 try:
133 result = windows.do_cmd(f'if exist C:\\one-tests\\runs\\capture\\outbox\\{sequence}\\editing echo editing', target=client['name'])210 result = windows.do_get(f'C:\\one-tests\\runs\\capture\\outbox\\{sequence}\\events.jsonl', local, client['name'])
134 if 'editing' in result.get('stdout', ''): break211 except Exception as error:
135 time.sleep(.1)212 result = {'error': str(error)}
136 else: raise TimeoutError('Native stress client did not acknowledge its first edit')213 (client['folder'] / 'stress-capture.json').write_text(json.dumps(result, indent=2))
137 start.touch()
138 for client, sequence in zip(clients, sequences):
139 wait_action(client, sequence, 'stress')
140 for client, clock in zip(clients, clocks):214 for client, clock in zip(clients, clocks):
141 before = time.time_ns() // 1000215 before = time.time_ns() // 1000
142 native = windows.do_health(client['name'])['utc_us']216 native = windows.do_health(client['name'])['utc_us']
...@@ -144,23 +218,31 @@ def exercise(output, shared, clients, action, wait_action, wait_text, checkpoint...@@ -144,23 +218,31 @@ def exercise(output, shared, clients, action, wait_action, wait_text, checkpoint
144 clock['after_native_minus_host_us'] = [native - after - 15625, native - before + 15625]218 clock['after_native_minus_host_us'] = [native - after - 15625, native - before + 15625]
145 (output / 'clocks.json').write_text(json.dumps(clocks, indent=2))219 (output / 'clocks.json').write_text(json.dumps(clocks, indent=2))
146 rust = {actor: [json.loads(line) for line in (folder / f'{actor}.jsonl').read_text().splitlines()] for actor in processes}220 rust = {actor: [json.loads(line) for line in (folder / f'{actor}.jsonl').read_text().splitlines()] for actor in processes}
147 commits, expected_rust = edit_history(rust, operations, edit)221 commits, expected_rust = edit_history(rust, operations, edit, offline=config.get('offline', False))
148 assert len(commits) == rust_writers * operations, 'Missing Rust acknowledgements'222 assert len(commits) == rust_writers * operations, 'Missing Rust acknowledgements'
149 native_events = []223 native_events = []
150 for i, (client, sequence) in enumerate(zip(clients, sequences)):224 for client in clients:
151 local = client['folder'] / 'stress-events.jsonl'225 local = client['folder'] / 'stress-events.jsonl'
152 result = windows.do_get(f'C:\\one-tests\\runs\\capture\\outbox\\{sequence}\\events.jsonl', local, client['name'])
153 if result.get('error'): raise RuntimeError(str(result))
154 events = [json.loads(line) for line in local.read_text(encoding='utf-8-sig').splitlines()]226 events = [json.loads(line) for line in local.read_text(encoding='utf-8-sig').splitlines()]
155 prefixes[i] = native_history(events, i, operations, edit)
156 native_events.append(events)227 native_events.append(events)
228 prefixes = [native_history(events, i, operations, edit) for i, events in enumerate(native_events)]
157 expected = [expected_rust, *prefixes]229 expected = [expected_rust, *prefixes]
230 documents = {}
231 if config.get('document_operations'):
232 from offline_document_history import document_history
233 documents = document_history(rust, operations)
234 expected.extend(document['text'] for document in documents.values())
158 for client in clients: wait_text(client, expected)235 for client in clients: wait_text(client, expected)
159 checkpoint('stress-final')236 checkpoint('stress-final')
160 model = json.loads((output / 'stress-final/model/document.json').read_text())237 model = json.loads((output / 'stress-final/model/document.json').read_text())
238 for _, _, revision, _ in ordered_pages(model):
239 assert not revision['nodes'][revision['roots']['1']]['spaces'], 'Disjoint edits created conflict pages'
161 paragraphs = [n['kind']['text'] for _, _, revision, page in ordered_pages(model)240 paragraphs = [n['kind']['text'] for _, _, revision, page in ordered_pages(model)
162 for _, n in walk(revision, page) if n['kind']['type'] == 'RichText']241 for _, n in walk(revision, page) if n['kind']['type'] == 'RichText']
163 assert sorted(paragraphs) == sorted(expected), 'Final shared state lost, duplicated or added unrecorded content'242 assert sorted(paragraphs) == sorted(expected), 'Final shared state lost, duplicated or added unrecorded content'
243 if documents:
244 from offline_document_history import verify_model
245 verify_model(model, documents)
164 if edit:246 if edit:
165 resolved = json.loads((output / 'stress-final/model/text.json').read_text())247 resolved = json.loads((output / 'stress-final/model/text.json').read_text())
166 for i, events in enumerate(native_events):248 for i, events in enumerate(native_events):
...@@ -180,7 +262,7 @@ def exercise(output, shared, clients, action, wait_action, wait_text, checkpoint...@@ -180,7 +262,7 @@ def exercise(output, shared, clients, action, wait_action, wait_text, checkpoint
180 earliest_end = (native['updated_ticks'] - 621355968000000000) // 10 - high262 earliest_end = (native['updated_ticks'] - 621355968000000000) // 10 - high
181 overlap += sum(max(latest_start, rust['started_us']) < min(earliest_end, rust['finished_us']) for rust in commits)263 overlap += sum(max(latest_start, rust['started_us']) < min(earliest_end, rust['finished_us']) for rust in commits)
182 result = {'native_writers': len(clients), 'rust_writers': rust_writers, 'rust_readers': rust_readers,264 result = {'native_writers': len(clients), 'rust_writers': rust_writers, 'rust_readers': rust_readers,
183 'sync_every': sync_every, 'edit': edit, 'seed': seed, 'native_edits': operations * len(clients), 'rust_commits': len(commits), 'rust_reads': len(reads),265 'sync_every': sync_every, 'edit': edit, 'seed': seed, 'offline': config.get('offline', False), 'native_edits': operations * len(clients), 'rust_commits': len(commits), 'document_commits': len(documents)*2, 'rust_reads': len(reads),
184 'clock_bounded_native_rust_call_overlaps': overlap, 'converged_paragraphs': len(expected)}266 'clock_bounded_native_rust_call_overlaps': overlap, 'converged_paragraphs': len(expected)}
185 (output / 'result.json').write_text(json.dumps(result, indent=2))267 (output / 'result.json').write_text(json.dumps(result, indent=2))
186 assert overlap, 'No native/Rust call overlap established within clock uncertainty'268 assert overlap, 'No native/Rust call overlap established within clock uncertainty'
tools/notebook_editor.py created+280
...@@ -0,0 +1,280 @@
1#!/usr/bin/env python3
2"""Serve the HTML diagnostic editor on a new notebook copy."""
3import argparse
4import hashlib
5from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6import json
7import mimetypes
8import os
9from pathlib import Path
10import re
11import shutil
12import subprocess
13from threading import Lock
14import time
15from urllib.parse import parse_qs, urlsplit
16import uuid
17
18from document_model import walk
19from notebook_report import generate
20
21ROOT = Path(__file__).resolve().parent.parent
22BRIDGE = ROOT / 'target/debug/onestore-diagnostic'
23
24
25def bridge(mode, source, destination, edit=None):
26 try:
27 result = subprocess.run([BRIDGE, mode, source, destination],
28 input=json.dumps(edit) if edit is not None else None,
29 capture_output=True, text=True, timeout=120)
30 if result.returncode:
31 raise RuntimeError(result.stderr or f'Diagnostic process exited {result.returncode}')
32 return json.loads(result.stdout)
33 except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error:
34 return {'ok': False, 'state': 'Unknown' if mode == 'commit' else 'NotCommitted',
35 'kind': 'Process', 'error': str(error)}
36
37
38class Session:
39 def __init__(self, source, output):
40 source = source.resolve(strict=True)
41 self.output = output.resolve()
42 if self.output.is_relative_to(source):
43 raise ValueError('Choose a session directory outside the source notebook.')
44 self.output.mkdir(parents=True, exist_ok=False)
45 self.lock = Lock()
46 hashes = {p.relative_to(source).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest()
47 for p in source.rglob('*') if p.is_file()}
48 shutil.copytree(source, self.output / 'notebook')
49 for name, digest in hashes.items():
50 copied = self.output / 'notebook' / name
51 if hashlib.sha256(copied.read_bytes()).hexdigest() != digest or hashlib.sha256((source / name).read_bytes()).hexdigest() != digest:
52 raise ValueError('The source changed during copying; start a fresh session.')
53 copied.chmod(copied.stat().st_mode | 0o600)
54 (self.output / 'source.json').write_text(json.dumps({'root': str(source), 'sha256': hashes}, indent=2))
55 (self.output / 'g').mkdir()
56 (self.output / 'objects').mkdir()
57 self.snapshot()
58
59 def snapshot(self):
60 number = max((int(p.name) for p in (self.output / 'g').iterdir() if p.name.isdecimal()), default=-1) + 1
61 pending = self.output / 'g' / (str(number) + '-' + uuid.uuid4().hex + '.building')
62 source = pending / 'snapshot'
63 source.mkdir(parents=True)
64 for path in sorted((self.output / 'notebook').rglob('*')):
65 if path.suffix.lower() not in ('.one', '.onetoc2'): continue
66 saved = source / path.relative_to(self.output / 'notebook')
67 saved.parent.mkdir(parents=True, exist_ok=True)
68 result = bridge('snapshot', path, saved)
69 if not result['ok']: raise RuntimeError(result['error'])
70 digest = hashlib.sha256(saved.read_bytes()).hexdigest()
71 blob = self.output / 'objects' / digest
72 if blob.exists(): saved.unlink()
73 else:
74 saved.rename(blob)
75 blob.chmod(0o444)
76 os.link(blob, saved)
77 previous = self.output / 'g' / str(number - 1) / 'report' if number else None
78 generate(source, pending / 'report', editable=True, previous=previous)
79 pending.rename(self.output / 'g' / str(number))
80 return number
81
82 def page(self, generation, page):
83 if type(generation) is not int or generation < 0:
84 raise ValueError('Choose a page from this report.')
85 folder = self.output / 'g' / str(generation)
86 pages = json.loads((folder / 'report/pages.json').read_text())
87 row = next(p for p in pages if p['report'] == page)
88 if row['category'] != 'Page':
89 raise ValueError('Choose an active page. Conflicts, templates, deleted pages and history are read-only here.')
90 sources = json.loads((folder / 'report/source.json').read_text())
91 index = next(i for i, source in enumerate(sources) if source['path'] == row['section'])
92 model = folder / 'report/model' / str(index)
93 document = json.loads((model / 'document.json').read_text())
94 revision = document['spaces'][row['space']]['revisions'][row['revision']]
95 return row, folder / 'snapshot' / row['section'], revision, model
96
97 def selection(self, generation, page, oid, run):
98 if type(run) is not int or run < 0:
99 raise ValueError('Choose a text run from this report.')
100 row, source, revision, model = self.page(generation, page)
101 node = next(node for key, node in walk(revision, row['object']) if key == oid)
102 if node['kind']['type'] != 'RichText': raise ValueError('Choose a text run.')
103 selected = node['kind']['runs'][run]
104 text = json.loads((model / 'text.json').read_text())[row['space']][row['revision']][oid][run]['text']
105 request = {'space': row['space'], 'object': oid,
106 'action': {'type': 'Text', 'start': selected['start'], 'end': selected['end'], 'replacement': text}}
107 return row, source, request
108
109 def location(self, generation, row=None):
110 page = 'index.html'
111 if row:
112 pages = json.loads((self.output / 'g' / str(generation) / 'report/pages.json').read_text())
113 page = next((p['report'] for p in pages if (p['section'], p['space'], p['object'], p['context']) ==
114 (row['section'], row['space'], row['object'], row['context'])), page)
115 return f'/g/{generation}/report/{page}'
116
117 def save(self, data):
118 fields = {'text': {'run', 'replacement'}, 'format': {'run', 'start', 'end', 'attributes'},
119 'paragraph': {'before', 'text', 'author'}, 'outline': {'x', 'y', 'text', 'author'}}
120 action = data.get('action')
121 if action not in fields or set(data) != {'generation', 'page', 'object', 'action'} | fields[action]:
122 raise ValueError('Choose text, formatting, a paragraph or an outline to save.')
123 if action in ('text', 'format'):
124 row, source, edit = self.selection(data['generation'], data['page'], data['object'], data['run'])
125 selected = edit['action']
126 if action == 'text':
127 if not isinstance(data['replacement'], str): raise ValueError('Enter replacement text.')
128 selected['replacement'] = data['replacement']
129 else:
130 if (type(data['start']) is not int or type(data['end']) is not int
131 or not 0 <= data['start'] <= data['end'] <= selected['end'] - selected['start']):
132 raise ValueError('Select text within this run.')
133 edit['action'] = {'type': 'Format', 'start': selected['start'] + data['start'],
134 'end': selected['start'] + data['end'], 'attributes': data['attributes']}
135 else:
136 row, source, revision, _ = self.page(data['generation'], data['page'])
137 if data['object'] not in {oid for oid, _ in walk(revision, row['object'])}:
138 raise ValueError('Choose a container on this page.')
139 edit = {'space': row['space'], 'object': data['object'],
140 'action': {'type': action.title(), **{key: data[key] for key in fields[action]}}}
141 operation = uuid.uuid4().hex
142 result = {'ok': False, 'state': 'NotCommitted'}
143 try:
144 with (self.output / 'operations.jsonl').open('a') as log:
145 intent = {'event': 'intent', 'operation': operation, 'started_ms': time.time_ns() // 1000000,
146 'selection': {key: data[key] for key in ('generation', 'page', 'object', 'action')},
147 'edit': edit, 'section': row['section']}
148 log.write(json.dumps(intent, ensure_ascii=True) + '\n')
149 log.flush(); os.fsync(log.fileno())
150 result = {'ok': False, 'state': 'Unknown'}
151 result = bridge('commit', self.output / 'notebook' / row['section'], source, edit)
152 log.write(json.dumps({'event': 'outcome', 'operation': operation, 'finished_ms': time.time_ns() // 1000000, **result}) + '\n')
153 log.flush(); os.fsync(log.fileno())
154 except Exception as error:
155 result = {**result, 'ok': False, 'error': str(error)}
156 try:
157 generation = self.snapshot()
158 result['location'] = self.location(generation, row)
159 except Exception as error:
160 result = {**result, 'ok': False, 'report_error': str(error)}
161 return result
162
163
164class Handler(BaseHTTPRequestHandler):
165 def redirect(self, location):
166 self.send_response(302)
167 self.send_header('Location', location)
168 self.send_header('Cache-Control', 'no-store')
169 self.send_header('Content-Length', '0')
170 self.end_headers()
171
172 def reply(self, status, value):
173 content = json.dumps(value, ensure_ascii=True).encode()
174 self.send_response(status)
175 self.send_header('Content-Type', 'application/json')
176 self.send_header('Content-Length', str(len(content)))
177 self.send_header('Cache-Control', 'no-store')
178 self.end_headers()
179 self.wfile.write(content)
180
181 def do_GET(self):
182 session = self.server.session
183 url = urlsplit(self.path)
184 try:
185 if url.path == '/':
186 latest = max(int(p.name) for p in (session.output / 'g').iterdir() if p.name.isdecimal())
187 self.redirect(session.location(latest))
188 return
189 if url.path in ('/api/run', '/api/page', '/latest'):
190 query = parse_qs(url.query, strict_parsing=True)
191 if url.path == '/latest':
192 row = session.page(int(query['generation'][0]), query['page'][0])[0] if query else None
193 with session.lock:
194 self.redirect(session.location(session.snapshot(), row))
195 return
196 if url.path == '/api/page':
197 row, _, revision, _ = session.page(int(query['generation'][0]), query['page'][0])
198 def label(oid):
199 node = revision['nodes'][oid]
200 text = ' '.join(n['kind']['text'] for _, n in walk(revision, oid) if n['kind']['type'] == 'RichText')
201 return node['kind']['type'] + (' · ' + text[:80] if text else '')
202 targets = []
203 pending = [row['object']]
204 while pending:
205 oid = pending.pop()
206 node = revision['nodes'][oid]
207 if node['kind']['type'] == 'Title': continue
208 if node['kind']['type'] in ('Outline', 'Paragraph', 'OutlineGroup', 'Cell'):
209 targets.append({'object': oid, 'label': label(oid),
210 'children': [{'object': child, 'label': label(child)} for child in node['children']]})
211 pending.extend(reversed(node['structure'] + node['content'] + node['children']))
212 self.reply(200, {'ok': True, 'object': row['object'], 'targets': targets})
213 return
214 row, source, edit = session.selection(int(query['generation'][0]), query['page'][0], query['object'][0], int(query['run'][0]))
215 result = bridge('check', source, '-', edit)
216 self.reply(200 if result['ok'] else 422, {**result, 'text': edit['action']['replacement']})
217 return
218 if url.path in ('/editor.js', '/editor.css'):
219 path = ROOT / 'tools/diagnostic' / url.path[1:]
220 else:
221 match = re.fullmatch(r'/g/(\d+)/report/(.+)', url.path)
222 if not match: raise FileNotFoundError()
223 root = session.output / 'g' / match[1] / 'report'
224 path = (root / match[2]).resolve(strict=True)
225 if not path.is_relative_to(root): raise FileNotFoundError()
226 with path.open('rb') as file:
227 self.send_response(200)
228 self.send_header('Content-Type', mimetypes.guess_type(path)[0] or 'application/octet-stream')
229 self.send_header('Content-Length', str(os.fstat(file.fileno()).st_size))
230 self.send_header('Cache-Control', 'no-store')
231 self.end_headers()
232 shutil.copyfileobj(file, self.wfile)
233 except (ValueError, KeyError, IndexError, StopIteration) as error:
234 self.reply(422, {'ok': False, 'error': str(error) or 'The selected text is unavailable.'})
235 except OSError:
236 self.reply(404, {'ok': False, 'error': 'Report unavailable. Open the notebook index.'})
237 except Exception as error:
238 self.reply(503, {'ok': False, 'error': str(error)})
239
240 def do_POST(self):
241 session = self.server.session
242 host = self.headers.get('Host')
243 allowed = {f'127.0.0.1:{self.server.server_port}', f'localhost:{self.server.server_port}'}
244 if host not in allowed or self.headers.get('Origin', 'http://' + host) != 'http://' + host or self.headers.get('X-OneNote-Diagnostic') != '1':
245 self.reply(403, {'ok': False, 'error': 'Open this editor on its local address.'})
246 return
247 if self.path != '/api/save':
248 self.reply(404, {'ok': False, 'error': 'Unknown diagnostic action.'})
249 return
250 try:
251 length = int(self.headers.get('Content-Length', '0'))
252 if not 0 < length <= 65536 or self.headers.get('Content-Type') != 'application/json':
253 raise ValueError('Send a JSON edit smaller than 64 KiB.')
254 data = json.loads(self.rfile.read(length))
255 with session.lock:
256 result = session.save(data)
257 status = 200 if result['ok'] else 409 if result.get('kind') == 'ResourceBusy' else 422 if result.get('kind') in ('InvalidData', 'Input') else 503
258 self.reply(status, result)
259 except (ValueError, KeyError, IndexError, StopIteration, TypeError) as error:
260 self.reply(422, {'ok': False, 'state': 'NotCommitted', 'error': str(error) or 'The selected text is unavailable.'})
261 except Exception as error:
262 self.reply(503, {'ok': False, 'state': 'Unknown' if self.path == '/api/save' else 'NotCommitted', 'error': str(error)})
263
264
265if __name__ == '__main__':
266 parser = argparse.ArgumentParser(description=__doc__)
267 parser.add_argument('source', type=Path)
268 parser.add_argument('session', type=Path)
269 parser.add_argument('--port', type=int, default=8782)
270 args = parser.parse_args()
271 server = ThreadingHTTPServer(('127.0.0.1', args.port), Handler)
272 server.session = Session(args.source, args.session)
273 print(f'Diagnostic editor: http://127.0.0.1:{server.server_port}/', flush=True)
274 print(f'Editable notebook copy: {server.session.output / "notebook"}', flush=True)
275 try:
276 server.serve_forever()
277 except KeyboardInterrupt:
278 pass
279 finally:
280 server.server_close()
tools/notebook_report.py+37-12
...@@ -6,6 +6,7 @@ from datetime import datetime, timedelta, timezone...@@ -6,6 +6,7 @@ from datetime import datetime, timedelta, timezone
6import hashlib6import hashlib
7from html import escape as esc7from html import escape as esc
8import json8import json
9import os
9from io import BytesIO10from io import BytesIO
10from pathlib import Path, PureWindowsPath11from pathlib import Path, PureWindowsPath
11from PIL import Image12from PIL import Image
...@@ -64,13 +65,16 @@ def css(fmt):...@@ -64,13 +65,16 @@ def css(fmt):
64 return ';'.join(rules)65 return ';'.join(rules)
6566
6667
67def html_page(title, nav, body):68def html_page(title, nav, body, editable=False):
69 policy = "default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; media-src 'self'; base-uri 'none'"
70 if editable:
71 policy += "; script-src 'self'; connect-src 'self'"
68 return '''<!doctype html><html lang="en"><meta charset="utf-8">72 return '''<!doctype html><html lang="en"><meta charset="utf-8">
69<meta name="viewport" content="width=device-width,initial-scale=1">73<meta name="viewport" content="width=device-width,initial-scale=1">
70<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self'; base-uri 'none'">74<meta http-equiv="Content-Security-Policy" content="''' + policy + '''">
71<title>''' + esc(title) + '''</title><style>75<title>''' + esc(title) + '''</title><style>
72*{box-sizing:border-box}body{margin:0;color:#000;background:#fff;font:15px/1.5 system-ui,sans-serif}a{color:#175bb2}nav{position:fixed;inset:0 auto 0 0;width:240px;overflow:auto;background:#f5f6f7;padding:20px}nav a{display:block;padding:3px 0;overflow-wrap:anywhere}nav h2{font-size:14px;margin:20px 0 4px}main{margin-left:240px;padding:30px 40px;max-width:1250px}h1{font-size:28px;line-height:1.2}h2{font-size:18px}p{margin:6px 0}.outline{margin:24px 0;border-top:1px solid #d9dde2;padding-top:12px}.location,.meta{font:12px/1.5 system-ui;color:#666;margin:6px 0}.paragraph{min-height:1.3em;position:relative;overflow-wrap:anywhere}.nested{margin-left:24px}.text{white-space:pre-wrap}.tag{display:inline-block;font:12px system-ui;padding:2px 5px;border:1px solid #aaa;border-radius:3px;margin-right:5px}.list-marker{display:inline-block;min-width:22px;margin-left:-22px}.listed{margin-left:22px}img.content{max-width:100%;height:auto;vertical-align:top}figure{margin:12px 0}figcaption{font-size:12px;color:#666}table{border-collapse:collapse;margin:8px 0;max-width:100%}td{border:1px solid #bbb;padding:5px 8px;vertical-align:top;min-width:30px}table.no-borders td{border-color:transparent}.opaque{border:1px dashed #9aa2ad;padding:12px;margin:12px 0;background:#f7f8fa}details{margin:14px 0}summary{cursor:pointer;font:13px system-ui}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:11px/1.4 ui-monospace,monospace;max-height:560px;overflow:auto}.page-link{padding-left:12px}code{font-size:12px}.references a{margin-right:14px}@media(max-width:750px){nav{position:static;width:auto;max-height:220px}main{margin:0;padding:20px}}@media print{nav{display:none}main{margin:0}details{display:none}}76*{box-sizing:border-box}body{margin:0;color:#000;background:#fff;font:15px/1.5 system-ui,sans-serif}a{color:#175bb2}nav{position:fixed;inset:0 auto 0 0;width:240px;overflow:auto;background:#f5f6f7;padding:20px}nav a{display:block;padding:3px 0;overflow-wrap:anywhere}nav h2{font-size:14px;margin:20px 0 4px}main{margin-left:240px;padding:30px 40px;max-width:1250px}h1{font-size:28px;line-height:1.2}h2{font-size:18px}p{margin:6px 0}.outline{margin:24px 0;border-top:1px solid #d9dde2;padding-top:12px}.location,.meta{font:12px/1.5 system-ui;color:#666;margin:6px 0}.paragraph{min-height:1.3em;position:relative;overflow-wrap:anywhere}.nested{margin-left:24px}.text{white-space:pre-wrap}.tag{display:inline-block;font:12px system-ui;padding:2px 5px;border:1px solid #aaa;border-radius:3px;margin-right:5px}.list-marker{display:inline-block;min-width:22px;margin-left:-22px}.listed{margin-left:22px}img.content{max-width:100%;height:auto;vertical-align:top}figure{margin:12px 0}figcaption{font-size:12px;color:#666}table{border-collapse:collapse;margin:8px 0;max-width:100%}td{border:1px solid #bbb;padding:5px 8px;vertical-align:top;min-width:30px}table.no-borders td{border-color:transparent}.opaque{border:1px dashed #9aa2ad;padding:12px;margin:12px 0;background:#f7f8fa}details{margin:14px 0}summary{cursor:pointer;font:13px system-ui}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:11px/1.4 ui-monospace,monospace;max-height:560px;overflow:auto}.page-link{padding-left:12px}code{font-size:12px}.references a{margin-right:14px}@media(max-width:750px){nav{position:static;width:auto;max-height:220px}main{margin:0;padding:20px}}@media print{nav{display:none}main{margin:0}details{display:none}}
73</style><nav>''' + nav + '</nav><main>' + body + '</main></html>'77</style>''' + ('<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>' if editable else '') + '<nav>' + nav + '</nav><main>' + body + '</main></html>'
7478
7579
76class Page:80class Page:
...@@ -117,7 +121,7 @@ class Page:...@@ -117,7 +121,7 @@ class Page:
117 body = ''121 body = ''
118 structured_math = any(c in kind['text'] for c in '\ufdd0\ufdee\ufdef')122 structured_math = any(c in kind['text'] for c in '\ufdd0\ufdee\ufdef')
119 equation = False123 equation = False
120 for run in self.text[oid]:124 for run_index, run in enumerate(self.text[oid]):
121 if run['format']['hidden']:125 if run['format']['hidden']:
122 continue126 continue
123 if structured_math and run['format'].get('math'):127 if structured_math and run['format'].get('math'):
...@@ -129,7 +133,7 @@ class Page:...@@ -129,7 +133,7 @@ class Page:
129 style = {**run['format'], **tag_format}133 style = {**run['format'], **tag_format}
130 if (style['superscript'] or style['subscript']) and style['font_size'] is not None:134 if (style['superscript'] or style['subscript']) and style['font_size'] is not None:
131 style['font_size'] *= 2 / 3135 style['font_size'] *= 2 / 3
132 fragment = '<span style="' + esc(css(style)) + '">' + esc(run['text']) + '</span>'136 fragment = '<span data-text-object="' + esc(oid) + '" data-run="' + str(run_index) + '" style="' + esc(css(style)) + '">' + esc(run['text']) + '</span>'
133 if run['format']['superscript']:137 if run['format']['superscript']:
134 fragment = '<sup>' + fragment + '</sup>'138 fragment = '<sup>' + fragment + '</sup>'
135 if run['format']['subscript']:139 if run['format']['subscript']:
...@@ -262,19 +266,28 @@ class Page:...@@ -262,19 +266,28 @@ class Page:
262 return '<div data-object="' + esc(oid) + '">' + body + '</div>' if typ not in ('RichText', 'Row', 'Cell') else body266 return '<div data-object="' + esc(oid) + '">' + body + '</div>' if typ not in ('RichText', 'Row', 'Cell') else body
263267
264268
265def generate(source, destination, native=None, versions=(), zone=timezone.utc):269def generate(source, destination, native=None, versions=(), zone=timezone.utc, editable=False, previous=None):
266 source = source.resolve(strict=True)270 source = source.resolve(strict=True)
267 if destination.resolve().is_relative_to(source):271 if destination.resolve().is_relative_to(source):
268 raise ValueError('Choose an export directory outside the source notebook.')272 raise ValueError('Choose an export directory outside the source notebook.')
269 destination.mkdir(parents=True, exist_ok=False)273 destination.mkdir(parents=True, exist_ok=False)
270 (destination / 'model').mkdir(); (destination / 'assets').mkdir()274 (destination / 'model').mkdir(); (destination / 'assets').mkdir()
275 cached = {row['path']: (row['sha256'], previous / 'model' / str(index))
276 for index, row in enumerate(json.loads((previous / 'source.json').read_text()))} if previous else {}
271 sections = []; tocs = {}; manifest = []277 sections = []; tocs = {}; manifest = []
272 for index, path in enumerate(sorted(p for p in source.rglob('*') if p.suffix.lower() in ('.one', '.onetoc2'))):278 for index, path in enumerate(sorted(p for p in source.rglob('*') if p.suffix.lower() in ('.one', '.onetoc2'))):
273 relative = path.relative_to(source)279 relative = path.relative_to(source)
274 before = path.read_bytes()280 before = path.read_bytes()
275 manifest.append({'path': relative.as_posix(), 'sha256': hashlib.sha256(before).hexdigest(), 'bytes': len(before)})281 manifest.append({'path': relative.as_posix(), 'sha256': hashlib.sha256(before).hexdigest(), 'bytes': len(before)})
276 exported = destination / 'model' / str(index)282 exported = destination / 'model' / str(index)
277 subprocess.run([EXPORTER, path, exported], check=True)283 reusable = cached.get(relative.as_posix())
284 reused = reusable is not None and reusable[0] == manifest[-1]['sha256']
285 if reused:
286 exported.mkdir()
287 for name in ('document.json', 'text.json', 'assets.json'):
288 os.link(reusable[1] / name, exported / name)
289 else:
290 subprocess.run([EXPORTER, path, exported], check=True)
278 document = json.loads((exported / 'document.json').read_text())291 document = json.loads((exported / 'document.json').read_text())
279 if path.read_bytes() != before:292 if path.read_bytes() != before:
280 raise ValueError('A source file changed during export.')293 raise ValueError('A source file changed during export.')
...@@ -291,6 +304,18 @@ def generate(source, destination, native=None, versions=(), zone=timezone.utc):...@@ -291,6 +304,18 @@ def generate(source, destination, native=None, versions=(), zone=timezone.utc):
291 }304 }
292 rows = json.loads((exported / 'assets.json').read_text())305 rows = json.loads((exported / 'assets.json').read_text())
293 for asset in rows:306 for asset in rows:
307 reference = json.dumps(asset['reference'], sort_keys=True)
308 if reused:
309 name = Path(asset['path']).name
310 assets[reference] = 'assets/' + name
311 if not (destination / 'assets' / name).exists():
312 os.link(previous / 'assets' / name, destination / 'assets' / name)
313 preview = name + '.png'
314 if name.endswith('.tiff') and reference in image_references:
315 if not (destination / 'assets' / preview).exists():
316 os.link(previous / 'assets' / preview, destination / 'assets' / preview)
317 previews['assets/' + name] = 'assets/' + preview
318 continue
294 original = exported / asset['path']; data = original.read_bytes()319 original = exported / asset['path']; data = original.read_bytes()
295 extension = '.png' if data.startswith(b'\x89PNG') else '.jpg' if data.startswith(b'\xff\xd8') else '.gif' if data.startswith(b'GIF8') else '.bmp' if data.startswith(b'BM') else '.tiff' if data.startswith((b'II*\0', b'MM\0*')) else '.bin'320 extension = '.png' if data.startswith(b'\x89PNG') else '.jpg' if data.startswith(b'\xff\xd8') else '.gif' if data.startswith(b'GIF8') else '.bmp' if data.startswith(b'BM') else '.tiff' if data.startswith((b'II*\0', b'MM\0*')) else '.bin'
296 name = hashlib.sha256(data).hexdigest() + extension321 name = hashlib.sha256(data).hexdigest() + extension
...@@ -300,7 +325,6 @@ def generate(source, destination, native=None, versions=(), zone=timezone.utc):...@@ -300,7 +325,6 @@ def generate(source, destination, native=None, versions=(), zone=timezone.utc):
300 else:325 else:
301 original.rename(target)326 original.rename(target)
302 asset['path'] = '../../assets/' + name327 asset['path'] = '../../assets/' + name
303 reference = json.dumps(asset['reference'], sort_keys=True)
304 assets[reference] = 'assets/' + name328 assets[reference] = 'assets/' + name
305 if extension == '.tiff' and reference in image_references:329 if extension == '.tiff' and reference in image_references:
306 preview = name + '.png'330 preview = name + '.png'
...@@ -309,8 +333,9 @@ def generate(source, destination, native=None, versions=(), zone=timezone.utc):...@@ -309,8 +333,9 @@ def generate(source, destination, native=None, versions=(), zone=timezone.utc):
309 raise ValueError('Multipage TIFF requires frame interpretation before report generation.')333 raise ValueError('Multipage TIFF requires frame interpretation before report generation.')
310 image.convert('RGBA').save(destination / 'assets' / preview)334 image.convert('RGBA').save(destination / 'assets' / preview)
311 previews['assets/' + name] = 'assets/' + preview335 previews['assets/' + name] = 'assets/' + preview
312 (exported / 'assets').rmdir()336 if not reused:
313 (exported / 'assets.json').write_text(json.dumps(rows, indent=2))337 (exported / 'assets').rmdir()
338 (exported / 'assets.json').write_text(json.dumps(rows, indent=2))
314 if path.suffix.lower() == '.one':339 if path.suffix.lower() == '.one':
315 sections.append({'path': relative, 'export': exported.relative_to(destination), 'document': document,340 sections.append({'path': relative, 'export': exported.relative_to(destination), 'document': document,
316 'text': json.loads((exported / 'text.json').read_text()), 'assets': assets, 'previews': previews})341 'text': json.loads((exported / 'text.json').read_text()), 'assets': assets, 'previews': previews})
...@@ -443,14 +468,14 @@ def generate(source, destination, native=None, versions=(), zone=timezone.utc):...@@ -443,14 +468,14 @@ def generate(source, destination, native=None, versions=(), zone=timezone.utc):
443 body += page.render(oid)468 body += page.render(oid)
444 body += '<details><summary>Document structure and source identities</summary><pre>' + esc(json.dumps(page.space, indent=2, ensure_ascii=False)) + '</pre></details>'469 body += '<details><summary>Document structure and source identities</summary><pre>' + esc(json.dumps(page.space, indent=2, ensure_ascii=False)) + '</pre></details>'
445 body += '<p class="references"><a href="' + section['export'].as_posix() + '/document.json">Document JSON</a><a href="' + section['export'].as_posix() + '/assets.json">Asset references</a></p>'470 body += '<p class="references"><a href="' + section['export'].as_posix() + '/document.json">Document JSON</a><a href="' + section['export'].as_posix() + '/assets.json">Asset references</a></p>'
446 (destination / filename).write_text(html_page(title, nav, body))471 (destination / filename).write_text(html_page(title, nav, body, editable and category == 'Page'))
447 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)})472 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)})
448 (destination / 'source.json').write_text(json.dumps(manifest, indent=2))473 (destination / 'source.json').write_text(json.dumps(manifest, indent=2))
449 (destination / 'pages.json').write_text(json.dumps(accounting, indent=2, ensure_ascii=False))474 (destination / 'pages.json').write_text(json.dumps(accounting, indent=2, ensure_ascii=False))
450 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>'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>'
451 if locked_sections:476 if locked_sections:
452 intro += '<p>Page counts are unavailable for locked sections: ' + esc(', '.join(locked_sections)) + '.</p>'477 intro += '<p>Page counts are unavailable for locked sections: ' + esc(', '.join(locked_sections)) + '.</p>'
453 (destination / 'index.html').write_text(html_page('Notebook review', nav, intro))478 (destination / 'index.html').write_text(html_page('Notebook review', nav, intro, editable))
454479
455480
456if __name__ == '__main__':481if __name__ == '__main__':
tools/offline_cache_crash.py created+130
...@@ -0,0 +1,130 @@
1#!/usr/bin/env python3
2"""Interrupt owned offline-cache writers and verify every retained intent and image."""
3import argparse
4import hashlib
5import json
6import os
7from pathlib import Path
8import select
9import signal
10import subprocess
11import time
12
13
14def run(binary, output):
15 output.mkdir(parents=True, exist_ok=False)
16 cache = output / "cache.sqlite"
17 source = os.environ.get("ONESTORE_CACHE_PROBE_SOURCE")
18 source_hash = hashlib.sha256(Path(source).read_bytes()).hexdigest() if source else None
19 payload_bytes = int(os.environ.get("ONESTORE_CACHE_PROBE_BYTES", 2 * 1024 * 1024))
20 assert 0 < payload_bytes <= 2 * 1024 * 1024
21 (output / "run.json").write_text(json.dumps({
22 "binary": str(binary),
23 "binary_sha256": hashlib.sha256(binary.read_bytes()).hexdigest(),
24 "controller_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
25 "payload_bytes": payload_bytes,
26 "operation": os.environ.get("ONESTORE_CACHE_PROBE_OPERATION", "text"),
27 "source": source,
28 "source_sha256": source_hash,
29 }, indent=2))
30 subprocess.run([binary, "init", cache], check=True, timeout=30)
31 retained = []
32 retained_ids = []
33 results = []
34 delays = [None, "ack", "unack", "journal", "database-write", 0, .001, .01, .02, .04, .08, .16, .32, .64, 1.28, 2.56, None, "ack"]
35 for operation, delay in enumerate(delays, 1):
36 with (output / f"owner-{operation}.stderr").open("w") as stderr:
37 owner = subprocess.Popen([binary, "edit", cache], stdin=subprocess.PIPE,
38 stdout=subprocess.PIPE, stderr=stderr, text=True, bufsize=1)
39 try:
40 def expect(prefix):
41 if not select.select([owner.stdout], [], [], 60)[0]:
42 raise TimeoutError(prefix)
43 actual = owner.stdout.readline().strip()
44 assert actual == prefix or actual.startswith(prefix + " "), (prefix, actual, owner.poll())
45 return actual
46
47 expect("ready")
48 contender = subprocess.run([binary, "read", cache], capture_output=True, text=True, timeout=15)
49 (output / f"contender-{operation}.stderr").write_text(contender.stderr)
50 assert contender.returncode != 0 and "DatabaseBusy" in contender.stderr, contender
51 before_write = cache.stat().st_mtime_ns
52 owner.stdin.write(f"{operation} {'unack' if delay == 'unack' else 'ack'}\n")
53 owner.stdin.flush()
54 expect(f"editing {operation}")
55 acknowledgement = None
56 journal_observation = None
57 if delay == "ack":
58 acknowledgement = expect(f"ack {operation}")
59 elif delay == "unack":
60 expect(f"durable {operation}")
61 elif delay in ("journal", "database-write"):
62 deadline = time.monotonic() + 10
63 journal = cache.with_name(cache.name + "-journal")
64 while time.monotonic() < deadline:
65 try:
66 with journal.open("rb") as active:
67 header = active.read(28)
68 size = journal.stat().st_size
69 database_changed = cache.stat().st_mtime_ns != before_write
70 if (header[:8] == bytes.fromhex("d9d505f920a163d7") and
71 (delay == "journal" or database_changed)):
72 journal_observation = {"header": header.hex(), "bytes": size,
73 "database_changed": database_changed}
74 break
75 except FileNotFoundError:
76 pass
77 time.sleep(.0001)
78 assert journal_observation, f"No active {delay} phase observed"
79 elif delay is not None:
80 time.sleep(delay)
81 owner.kill()
82 assert owner.wait(timeout=10) == -signal.SIGKILL, "Writer did not terminate at the requested process cut"
83 extra = owner.stdout.read()
84 if f"ack {operation} " in extra:
85 acknowledgement = extra.strip()
86 read = subprocess.run([binary, "read", cache], check=True, capture_output=True,
87 text=True, timeout=60)
88 actual = json.loads(read.stdout)
89 operations = actual["operations"]
90 ids = actual["ids"]
91 assert operations in (retained, retained + [operation]), (retained, operation, actual)
92 assert ids[:len(retained_ids)] == retained_ids, (retained_ids, actual)
93 if acknowledgement:
94 assert operations[-1] == operation
95 assert ids[-1] == int(acknowledgement.split()[2])
96 if delay == "unack":
97 assert operations[-1] == operation and acknowledgement is None
98 assert actual["complete_payloads"]
99 results.append({"operation": operation, "delay": delay,
100 "process_exit": owner.returncode,
101 "acknowledged": acknowledgement is not None,
102 "retained": operation in operations,
103 "retained_operations": operations, "ids": ids,
104 "section_bytes": actual["section_bytes"],
105 "complete_payloads": True, "exclusive_owner": True,
106 "journal_observation": journal_observation})
107 retained, retained_ids = operations, ids
108 (output / "results.json").write_text(json.dumps(results, indent=2))
109 print(json.dumps(results[-1]), flush=True)
110 finally:
111 if owner.poll() is None:
112 owner.kill()
113 owner.wait(timeout=10)
114 owner.stdin.close()
115 owner.stdout.close()
116 assert any(row["acknowledged"] for row in results)
117 assert any(row["retained"] and not row["acknowledged"] for row in results)
118 assert any(not row["retained"] for row in results)
119 subprocess.run([binary, "read", cache, output / "recovered.one"], check=True, timeout=60)
120 if source:
121 assert hashlib.sha256(Path(source).read_bytes()).hexdigest() == source_hash
122 print(f"Passed {len(results)} offline-cache process interruptions", flush=True)
123
124
125if __name__ == "__main__":
126 parser = argparse.ArgumentParser(description=__doc__)
127 parser.add_argument("output", type=Path)
128 parser.add_argument("--binary", type=Path, default=Path(__file__).resolve().parents[1] / "target/release/examples/cache_probe")
129 args = parser.parse_args()
130 run(args.binary.resolve(), args.output.resolve())
tools/offline_document_history.py created+163
...@@ -0,0 +1,163 @@
1"""Account for offline document intents, receipts, reader states and native formatting."""
2import uuid
3
4from document_model import ordered_pages, walk
5
6
7def identity(insertion, extension):
8 return '{' + str(uuid.UUID(bytes_le=bytes(insertion['guid']))).upper() + '},' + str(extension)
9
10
11def characters(observed):
12 actual = [(char, run['bold'], run['size'], run['color'])
13 for run in observed['runs'] for char in run['text']]
14 assert ''.join(row[0] for row in actual) == observed['text'], 'Document runs omit or duplicate text'
15 return actual
16
17
18def document_history(logs, operations):
19 documents = {}
20 for actor, events in logs.items():
21 if not actor.startswith('w'): continue
22 assert events[0].get('document_operations') is True, 'Writer omitted document operations'
23 edits = [row for row in events if row['event'] == 'local_document_commit']
24 assert [(row['operation'], row['kind']) for row in edits] == [
25 (i, kind) for i in range(operations) for kind in ('insert', 'format')], 'Missing or duplicate document intent'
26 ids = [row['id'] for row in edits]
27 assert ids == sorted(set(ids)), 'Document intent IDs are duplicated or unordered'
28 assert not set(ids) & {row['id'] for row in events if row['event'] == 'local_commit'}, 'Text and document intents share an ID'
29 receipts = [row for row in events if row['event'] == 'document_receipt']
30 reopened = [row for row in events if row['event'] == 'reopened_document_receipt']
31 assert [row['id'] for row in receipts] == ids, 'Document receipt inventory differs'
32 assert [(r['id'], r['revision']) for r in reopened] == [(r['id'], r['revision']) for r in receipts], 'Document receipts changed across reopen'
33 linked = {}
34 for intent, receipt in zip(edits, receipts, strict=True):
35 attempts = [row for row in events if row['event'] == 'remote_attempt' and row['revision'] == receipt['revision']
36 and set(row.get('document_changes') or {}) == {intent['object']}]
37 if not attempts and intent['kind'] == 'format':
38 attempts = [row for row in events if row['event'] == 'remote_attempt' and row['state'] == 'Unknown'
39 and set(row.get('document_changes') or {}) == {intent['object']}]
40 assert len(attempts) == 1, 'Document receipt lacks one publication attempt'
41 attempt, = attempts
42 assert attempt['state'] in ('Committed', 'Unknown'), 'Receipt identifies an unpublished document operation'
43 assert intent['object'] in attempt['documents'] and attempt['document_changes'] == {intent['object']: attempt['documents'][intent['object']]}, 'Document publication changed another target'
44 successful = [row for row in events if row['event'] == 'remote_attempt'
45 and row['state'] in ('Committed', 'Unknown')
46 and row.get('document_changes') == attempt['document_changes']]
47 assert successful == [attempt], 'Document intent was published or attempted uncertainly more than once'
48 assert intent['started_us'] <= attempt['started_us'] <= attempt['finished_us'] <= receipt['at_us'] and intent['started_us'] <= intent['finished_us'] <= receipt['at_us'], 'Document acknowledgement order is invalid'
49 if attempt['state'] == 'Unknown':
50 confirmed, observed = False, None
51 for row in events:
52 if row['event'] == 'read': observed = row
53 if (row['event'] != 'remote_confirm' or row['state'] != 'Committed'
54 or receipt['revision'] not in row.get('revisions', {}).get(intent['space'], [])
55 or not attempt['finished_us'] <= row['started_us'] <= row['finished_us'] <= receipt['at_us']):
56 continue
57 if receipt['revision'] != attempt['revision']:
58 assert intent['kind'] == 'format' and attempt['revision'] not in row['revisions'][intent['space']], 'Replacement receipt did not retire the original attempt'
59 assert row.get('current_revisions', {}).get(intent['space']) == receipt['revision'], 'Effect receipt does not identify the confirmed current revision'
60 assert observed and observed['finished_us'] <= row['started_us'] and observed['text'] == row['text']
61 assert observed.get('documents', {}).get(intent['object']) == attempt['document_changes'][intent['object']], 'Effect confirmation differs from the uncertain formatting intent'
62 confirmed = True
63 assert confirmed, 'Uncertain document publication lacks confirmation'
64 else:
65 assert receipt['revision'] == attempt['revision']
66 linked[intent['id']] = {**attempt, 'acknowledged_us': receipt['at_us'], 'receipt_revision': receipt['revision']}
67 for inserted, formatted in zip(edits[::2], edits[1::2], strict=True):
68 number = inserted['operation']
69 text = f'Document {actor}:{number} 🦀'
70 insertion = inserted['insertion']
71 target = identity(insertion, 2)
72 assert target == inserted['object'] == formatted['object'], 'Dependent formatting addresses another object'
73 assert inserted['space'] == formatted['space'] and inserted['text'] == formatted['text'] == insertion['text'] == text
74 assert insertion['author'] == 'Offline document writer'
75 if number % 2 == 0:
76 assert insertion['placement'] == {'Outline': {'x': 144 + int(actor[1:]) * 240, 'y': 144 + number * 72}}, 'Outline placement differs from intent'
77 else:
78 assert insertion['placement'] == {'Paragraph': {'before': None}}
79 assert insertion['parent'] == identity(edits[(number-1)*2]['insertion'], 1), 'Paragraph lost its outline parent'
80 assert formatted['range'] == [1, len(text.encode('utf-16-le')) // 2 - 2]
81 assert formatted['attributes'] == [{'Bold': True}, {'FontSize': 18 + number % 9}, {'Color': [18, 52, 86]}]
82 old = [(char, False, 11, 0xff000000) for char in text]
83 new = [(char, True, 18 + number % 9, 0x563412) if 0 < i < len(text)-1 else old[i] for i, char in enumerate(text)]
84 assert target not in documents, 'Two insertion intents share an object identity'
85 created, changed = linked[inserted['id']], linked[formatted['id']]
86 assert created['finished_us'] <= changed['started_us'], 'Formatting preceded its insertion'
87 assert characters(created['documents'][target]) == old, 'Insertion publication differs from its local intent'
88 assert characters(changed['documents'][target]) == new, 'Formatting publication differs from its local intent'
89 documents[target] = {'text': text, 'insertion': insertion, 'space': inserted['space'],
90 'old': old, 'new': new, 'insert': created, 'format': changed}
91 assert documents, 'No document operations were recorded'
92 for actor, events in logs.items():
93 previous = {}
94 reads = [row for row in events if row['event'] in ('read', 'document_read') and row.get('documents') is not None]
95 assert reads, f'{actor} did not observe document snapshots'
96 assert any(row['documents'] for row in reads), f'{actor} never observed a created document object'
97 for read in reads:
98 observed = read['documents']
99 assert set(previous) <= set(observed) <= set(documents), 'Reader lost an object or observed an unrecorded insertion'
100 for target, document in documents.items():
101 if read['started_us'] > document['insert']['acknowledged_us']:
102 assert target in observed, 'Reader missed an acknowledged insertion'
103 if target not in observed: continue
104 assert read['finished_us'] >= document['insert']['started_us'], 'Reader observed a future insertion'
105 actual = characters(observed[target])
106 assert actual in (document['old'], document['new']), 'Reader observed partial or invented formatting'
107 formatted = actual == document['new']
108 assert not previous.get(target, False) or formatted, 'Reader reverted acknowledged formatting'
109 if read['started_us'] > document['format']['acknowledged_us']:
110 assert formatted, 'Reader missed acknowledged formatting'
111 if formatted:
112 assert read['finished_us'] >= document['format']['started_us'], 'Reader observed future formatting'
113 previous[target] = formatted
114 return documents
115
116
117def verify_model(model, documents):
118 children = {}
119 for document in documents.values():
120 insertion = document['insertion']
121 if 'Outline' in insertion['placement']:
122 children[identity(insertion, 1)] = [identity(insertion, 3)]
123 for document in documents.values():
124 insertion = document['insertion']
125 if 'Paragraph' in insertion['placement']:
126 children[insertion['parent']].append(identity(insertion, 1))
127 found = set()
128 for sid, _, revision, page in ordered_pages(model):
129 nodes = revision['nodes']
130 for target, node in walk(revision, page):
131 if target not in documents: continue
132 assert target not in found, 'Inserted text is reachable twice'
133 found.add(target)
134 expected = documents[target]
135 insertion = expected['insertion']
136 assert sid == expected['space'] and node['kind']['text'] == expected['text']
137 object_id = identity(insertion, 1)
138 assert object_id in nodes[insertion['parent']]['children'], 'Insertion lost its parent'
139 paragraph = identity(insertion, 3) if 'Outline' in insertion['placement'] else object_id
140 assert nodes[paragraph]['content'] == [target], 'Inserted paragraph content changed'
141 if 'Outline' in insertion['placement']:
142 position = insertion['placement']['Outline']
143 assert all(nodes[object_id]['layout'][key] == position[key] for key in ('x', 'y')), 'Outline coordinates changed'
144 assert nodes[object_id]['children'] == children[object_id], 'Inserted paragraph order changed'
145 assert found == set(documents), 'Final model omitted an inserted object'
146
147
148def verify_native(paragraphs, documents):
149 from PIL import ImageColor
150 by_text = {''.join(char for char, _ in paragraph): paragraph for paragraph in paragraphs}
151 checks = 0
152 for document in documents.values():
153 actual = by_text[document['text']]
154 for (char, style), (wanted, bold, size, color) in zip(actual, document['new'], strict=True):
155 assert char == wanted and bool(style.get('bold')) == bold, 'Native text or bold differs from intent'
156 assert style.get('font_size', 11) == size, 'Native font size differs from intent'
157 native_color = style.get('color', 'automatic')
158 if color == 0xff000000:
159 assert native_color in ('automatic', None), 'Native automatic color changed'
160 else:
161 assert ImageColor.getrgb(native_color) == (18, 52, 86), 'Native color differs from intent'
162 checks += 3
163 return checks
tools/offline_history.py created+73
...@@ -0,0 +1,73 @@
1"""Verify local intent acknowledgements separately from the remote publication chain."""
2import re
3
4
5def tokens(text):
6 assert text.startswith('Concurrent edits:'), 'Unexpected append prefix'
7 tail = text[len('Concurrent edits:'):]
8 found = re.findall(r' \[w[0-9]+:[0-9]+\]', tail)
9 assert ''.join(found) == tail and len(set(found)) == len(found), 'Malformed or duplicated append history'
10 return found
11
12
13def publication_links(logs, operations, partial=False):
14 local = {}
15 for actor, events in logs.items():
16 assert events and events[0]['event'] == 'ready', 'Missing client start'
17 assert partial or events[-1]['event'] == 'done', 'Incomplete client log'
18 if not actor.startswith('w'): continue
19 assert events[0].get('offline') is True, 'Expected an offline writer'
20 edits = [event for event in events if event['event'] == 'local_commit']
21 assert [event['operation'] for event in edits] == list(range(len(edits))), 'Missing or duplicate local acknowledgement'
22 assert len(edits) <= operations and (partial or len(edits) == operations), 'Local operation count differs'
23 assert len({event['id'] for event in edits}) == len(edits), 'Duplicate local intent ID'
24 assert [event['id'] for event in edits] == sorted(event['id'] for event in edits), 'Local IDs went backwards'
25 for event in edits:
26 token = f' [{actor}:{event["operation"]}]'
27 assert event['token'] == token and token not in local, 'Local token differs from its operation'
28 assert event['started_us'] <= event['finished_us'], 'Invalid local acknowledgement interval'
29 prior = tokens(event['before'])
30 own = [item for item in prior if item.startswith(f' [{actor}:')]
31 assert own == [f' [{actor}:{i}]' for i in range(event['operation'])], 'Local view lost or invented its own edit'
32 local[token] = event
33 for event in local.values():
34 for token in tokens(event['before']):
35 assert token in local and local[token]['started_us'] <= event['finished_us'], 'Local view invented a future token'
36 links = {}
37 seen_revisions = set()
38 for actor, events in logs.items():
39 if not actor.startswith('w'): continue
40 edits = {event['id']: event for event in events if event['event'] == 'local_commit'}
41 receipts = [event for event in events if event['event'] == 'remote_receipt']
42 assert len({event['id'] for event in receipts}) == len(receipts), 'Duplicate remote receipt'
43 assert set(event['id'] for event in receipts) <= set(edits), 'Receipt lacks a local intent'
44 assert partial or len(receipts) == len(edits), 'Local success lacks remote acknowledgement'
45 reopened = [event for event in events if event['event'] == 'reopened_receipt']
46 if not partial:
47 assert [(event['id'], event['revision']) for event in reopened] == [(event['id'], event['revision']) for event in receipts], 'Receipt changed across reopen'
48 for receipt in receipts:
49 intent = edits[receipt['id']]
50 attempts = [event for event in events if event['event'] == 'remote_attempt' and event['revision'] == receipt['revision']]
51 assert len(attempts) == 1, 'Receipt does not identify one publication attempt'
52 attempt, = attempts
53 assert attempt['state'] in ('Committed', 'Unknown'), 'Receipt identifies a proven-unpublished attempt'
54 if attempt['state'] == 'Unknown':
55 assert all(field in attempt and field in intent for field in ('space', 'object')), 'Uncertain target identity is missing'
56 assert attempt['space'] == intent['space'] and attempt['object'] == intent['object'], 'Confirmation identifies another target'
57 confirmed = [event for event in events if event['event'] == 'remote_confirm'
58 and event['state'] == 'Committed'
59 and receipt['revision'] in event.get('revisions', {}).get(intent['space'], [])
60 and event.get('text', '').startswith(attempt['after'])
61 and attempt['finished_us'] <= event['started_us'] <= event['finished_us'] <= receipt['at_us']]
62 assert confirmed, 'Uncertain publication lacks a successful retained-revision confirmation'
63 assert not any(event['event'] == 'remote_attempt' and event['started_us'] >= attempt['finished_us']
64 and event['after'] == event['before'] + intent['token'] for event in events), 'An uncertain intent was replayed'
65 assert receipt['revision'] not in seen_revisions, 'A revision was acknowledged twice'
66 seen_revisions.add(receipt['revision'])
67 assert intent['started_us'] <= attempt['started_us'] <= attempt['finished_us'] <= receipt['at_us'], 'Receipt precedes its publication'
68 assert attempt['after'] == attempt['before'] + intent['token'], 'Remote publication differs from local intent'
69 tokens(attempt['after'])
70 assert attempt['before'] not in links, 'Remote publications branched from the same content'
71 event = {**attempt, 'event': 'commit', 'operation': intent['operation'], 'token': intent['token'], 'finished_us': receipt['at_us']}
72 links[attempt['before']] = event, attempt['after']
73 return links
tools/offline_outage.py created+313
...@@ -0,0 +1,313 @@
1"""Require durable local progress during a confirmed outage of the owned SMB proxy."""
2import hashlib
3import json
4from pathlib import Path
5import shlex
6import time
7
8from native_runner import windows
9import linux_vm
10from verify_smb_overlap import verify
11
12
13def interrupt(output, clients, sequences, processes):
14 config = json.loads((output / 'run.json').read_text())
15 folder = output / 'rust'
16 samples = {}
17
18 def logs():
19 result = {}
20 for actor in processes:
21 text = (folder / f'{actor}.jsonl').read_text()
22 result[actor] = [json.loads(line) for line in text[:text.rfind('\n') + 1].splitlines()]
23 return result
24
25 def wait_for(predicate, message, timeout=90):
26 deadline = time.monotonic() + timeout
27 while not predicate():
28 assert all(p.poll() in (None, 0) for p in processes.values()), 'A client failed during the offline outage'
29 if time.monotonic() > deadline: raise TimeoutError(message)
30 time.sleep(.05)
31
32 def ssh(command):
33 result = linux_vm.run_ssh(config['server'], command, timeout=15)
34 with (output / 'offline-outage-server.jsonl').open('a') as stream:
35 stream.write(json.dumps({'command': command, 'exit': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr}) + '\n')
36 result.check_returncode()
37 return result.stdout
38
39 def phase(control):
40 text = json.dumps(control)
41 ssh("printf '%s' " + shlex.quote(text) + ' > /tmp/smb-control.tmp && mv /tmp/smb-control.tmp /tmp/smb-control.json')
42 wait_for(lambda: text in ssh("grep -F '\"control\":' /tmp/smb-trace.jsonl | tail -n 1"), 'Proxy did not acknowledge the outage phase')
43
44 def native_counts(label):
45 counts = []
46 for actor, (client, sequence) in enumerate(zip(clients, sequences)):
47 capture = output / f'offline-{label}-n{actor}.jsonl'
48 result = windows.do_get(f'C:\\one-tests\\runs\\capture\\outbox\\{sequence}\\events.jsonl', capture, client['name'])
49 assert not result.get('error'), result
50 text = capture.read_text(encoding='utf-8-sig')
51 rows = [json.loads(line) for line in text[:text.rfind('\n') + 1].splitlines()]
52 assert 0 < len(rows) < config['stress_operations'], 'Native client was inactive during the outage campaign'
53 counts.append(len(rows))
54 return counts
55
56 writers = [actor for actor in processes if actor.startswith('w')]
57 readers = [actor for actor in processes if actor.startswith('r')]
58 if config.get('offline_lost_reply'):
59 isolated = config.get('offline_client_reply', False)
60 def counts():
61 return {actor: sum(row['event'] == ('remote_receipt' if actor in writers else 'read') for row in rows)
62 for actor, rows in logs().items()}
63 if config.get('document_operations'):
64 wait_for(lambda: all((folder / f'offline-paused-{actor}').exists() for actor in writers)
65 and all(counts()[actor] > 0 for actor in readers), 'Clients did not reach the formatting publication barrier')
66 else:
67 wait_for(lambda: all(value >= 3 for value in counts().values()), 'Clients made no progress before the reply cut')
68 samples['before'] = counts()
69 samples['native_before'] = native_counts('reply-before')
70 try:
71 control = {'phase': 'offline-reply-cut', 'cut': 9, 'peer': '10.0.2.2', 'offset': 96, 'direction': 'response'}
72 if isolated:
73 control['scope'] = 'connection'
74 (folder / 'offline-paused-w0.isolate').touch()
75 phase(control)
76 if config.get('document_operations'):
77 samples['format_released_us'] = time.time_ns() // 1000
78 (folder / 'offline-paused-w0.resume').touch()
79 wait_for(lambda: int(ssh("grep -c '\"cut\": {' /tmp/smb-trace.jsonl || true").strip()) == 1,
80 'The publication reply was not interrupted')
81 samples['down_started_us'] = time.time_ns() // 1000
82 if isolated:
83 wait_for(lambda: any(row['event'] == 'confirmation_paused' for row in logs()['w0']),
84 'The disconnected writer did not retain its uncertain publication')
85 for actor in writers[1:]: (folder / f'offline-paused-{actor}.resume').touch()
86 wait_for(lambda: all(value >= samples['before'][actor] + 3 for actor, value in counts().items() if actor != 'w0'),
87 'Peers did not advance while the writer was disconnected')
88 wait_for(lambda: (folder / 'offline-retired.one').exists(),
89 'Native maintenance did not retire the isolated revision')
90 time.sleep(3)
91 if isolated: samples['during'] = counts()
92 samples['native_during'] = native_counts('reply-during')
93 finally:
94 samples['up_started_us'] = time.time_ns() // 1000
95 phase({'phase': 'offline-reply-reconnected'})
96 if config.get('document_operations'):
97 for actor in writers: (folder / f'offline-paused-{actor}.resume').touch()
98 if isolated: (folder / 'offline-paused-w0.confirmation-resume').touch()
99 (output / 'offline-lost-reply-progress.json').write_text(json.dumps(samples, indent=2))
100 wait_for(lambda: all(value >= samples['before'][actor] + 3 for actor, value in counts().items()),
101 'A client failed to progress after the lost publication reply', timeout=120)
102 samples['after'] = counts()
103 (output / 'offline-lost-reply-progress.json').write_text(json.dumps(samples, indent=2))
104 return
105 wait_for(lambda: all((folder / f'offline-paused-{actor}').exists() for actor in writers)
106 and (not config.get('document_operations') or all(
107 sum(row['event'] == 'local_document_commit' for row in logs()[actor]) == 2 for actor in writers))
108 and all(any(row['event'] == 'read' for row in logs()[actor]) for actor in readers),
109 'Clients did not reach the pre-publication outage barrier')
110 samples['native_before'] = native_counts('before')
111 samples['reader_errors_before'] = {actor: sum(row['event'] == 'transport_read_error' for row in logs()[actor]) for actor in readers}
112 try:
113 phase({'phase': 'offline-down', 'mode': 'down'})
114 samples['down_started_us'] = time.time_ns() // 1000
115 (folder / 'offline-outage-down').touch()
116 wait_for(lambda: all(sum(row['event'] == 'local_commit' for row in events) == 8 for actor, events in logs().items() if actor in writers)
117 and (not config.get('document_operations') or all(
118 sum(row['event'] == 'local_document_commit' for row in logs()[actor]) == 16 for actor in writers))
119 and all(sum(row['event'] == 'transport_read_error' for row in logs()[actor]) > samples['reader_errors_before'][actor] for actor in readers),
120 'Local queues or disconnected readers failed to progress during the outage')
121 time.sleep(3)
122 samples['native_during'] = native_counts('during')
123 samples['up_started_us'] = time.time_ns() // 1000
124 finally:
125 phase({'phase': 'offline-reconnected'})
126 (folder / 'offline-outage-resumed').touch()
127 (output / 'offline-outage-progress.json').write_text(json.dumps(samples, indent=2))
128 wait_for(lambda: all(sum(row['event'] == 'remote_receipt' for row in logs()[actor]) >= 3 for actor in writers)
129 and all(sum(row['event'] == 'read' and row['started_us'] > samples['up_started_us'] for row in logs()[actor]) >= 3 for actor in readers),
130 'A client failed to progress after the offline outage')
131
132
133def native_progress(output, config, sample):
134 down, up = sample['down_started_us'], sample['up_started_us']
135 assert len(sample['native_before']) == len(sample['native_during']) == config['stress_clients']
136 native = list(zip(sample['native_before'], sample['native_during']))
137 assert all(0 < before < during < config['stress_operations'] for before, during in native), 'Native local edits did not advance during the outage'
138 clocks = json.loads((output / 'clocks.json').read_text())
139 native_inside = []
140 assert len(clocks) == config['stress_clients']
141 for index, clock in enumerate(clocks):
142 low = min(clock['native_minus_host_us'][0], clock['after_native_minus_host_us'][0])
143 high = max(clock['native_minus_host_us'][1], clock['after_native_minus_host_us'][1])
144 rows = [json.loads(line) for line in (output / f'n{index}/stress-events.jsonl').read_text(encoding='utf-8-sig').splitlines()]
145 inside = sum((row['update_started_ticks'] - 621355968000000000) // 10 - high > down
146 and (row['updated_ticks'] - 621355968000000000) // 10 - low < up for row in rows)
147 assert inside, 'Native timestamps do not establish local edits inside the confirmed outage'
148 native_inside.append(inside)
149 return native_inside
150
151
152def verify_outage(output):
153 config = json.loads((output / 'run.json').read_text())
154 assert config['offline'] and config['offline_outage'] and config['embedded_smb']
155 assert config['stress_clients'] + config['rust_writers'] + config['rust_readers'] >= 12
156 sample = json.loads((output / 'offline-outage-progress.json').read_text())
157 down, up = sample['down_started_us'], sample['up_started_us']
158 assert up - down >= 3_000_000, 'Confirmed outage lasted less than three seconds'
159 native_inside = native_progress(output, config, sample)
160 queues, reconnects = {}, {}
161 for mode, count in [('w', config['rust_writers']), ('r', config['rust_readers'])]:
162 for index in range(count):
163 actor = f'{mode}{index}'
164 events = [json.loads(line) for line in (output / 'rust' / f'{actor}.jsonl').read_text().splitlines()]
165 assert events[0]['event'] == 'ready' and events[-1]['event'] == 'done'
166 connected = [row for row in events if row['event'] == 'transport_connected']
167 assert any(row['at_us'] > up for row in connected), 'No fresh transport after outage'
168 reconnects[actor] = len(connected)
169 if mode == 'r':
170 assert sum(row['event'] == 'transport_read_error' for row in events) > sample['reader_errors_before'][actor]
171 assert sum(row['event'] == 'read' and row['started_us'] > up for row in events) >= 3
172 continue
173 local = [row for row in events if row['event'] == 'local_commit']
174 assert len(local) == config['stress_operations']
175 assert local[0]['finished_us'] < down
176 queued = [row for row in local if down < row['started_us'] <= row['finished_us'] < up]
177 assert [row['operation'] for row in queued] == list(range(1, 8)), 'Seven local edits were not accepted while SMB was down'
178 queues[actor] = len(queued)
179 paused = [row for row in events if row['event'] == 'publication_paused']
180 assert len(paused) == 1 and paused[0]['at_us'] < down
181 attempts = [row for row in events if row['event'] == 'remote_attempt']
182 assert attempts and all(row['started_us'] > up for row in attempts), 'Publication escaped the outage barrier'
183 assert attempts[0]['state'] == 'NotCommitted' and attempts[0]['revision'] == paused[0]['revision'], 'Disconnected pre-I/O attempt was not safely rejected'
184 assert all(row['state'] != 'Unknown' for row in attempts), 'Unexpected uncertain publication requires separate recovery evidence'
185 receipts = [row for row in events if row['event'] == 'remote_receipt']
186 assert len(receipts) == config['stress_operations'] and all(row['at_us'] > up for row in receipts)
187 if config.get('document_operations'):
188 edits = [row for row in events if row['event'] == 'local_document_commit']
189 assert [(row['operation'], row['kind']) for row in edits] == [
190 (operation, kind) for operation in range(config['stress_operations']) for kind in ('insert', 'format')]
191 assert all(row['finished_us'] < down for row in edits[:2]), 'Initial document edits missed the outage barrier'
192 queued = [row for row in edits if down < row['started_us'] <= row['finished_us'] < up]
193 assert [(row['operation'], row['kind']) for row in queued] == [
194 (operation, kind) for operation in range(1, 8) for kind in ('insert', 'format')], 'Document edits did not persist during the outage'
195 queues[actor] += len(queued)
196 receipts = [row for row in events if row['event'] == 'document_receipt']
197 assert len(receipts) == config['stress_operations'] * 2 and all(row['at_us'] > up for row in receipts)
198 trace = [json.loads(line) for line in (output / 'smb-trace.jsonl').read_text().splitlines()]
199 controls = [row['control'] for row in trace if row.get('control', {}).get('phase', '').startswith('offline-')]
200 assert controls == [{'phase': 'offline-down', 'mode': 'down'}, {'phase': 'offline-reconnected'}], 'Unexpected outage control sequence'
201 return {'outages': 1, 'confirmed_down_seconds': (up - down) / 1_000_000,
202 'local_edits_while_down': queues, 'transport_connection_events': reconnects,
203 'native_local_edits_inside_confirmed_outage': native_inside,
204 'resumed_guarded_io_overlap': verify(trace, phase='offline-reconnected')}
205
206
207def verify_lost_reply(output):
208 from offline_history import publication_links, tokens
209 config = json.loads((output / 'run.json').read_text())
210 assert config['offline'] and config['offline_lost_reply'] and config['embedded_smb']
211 assert config['stress_clients'] + config['rust_writers'] + config['rust_readers'] >= 12
212 sample = json.loads((output / 'offline-lost-reply-progress.json').read_text())
213 isolated = config.get('offline_client_reply', False)
214 assert sample['up_started_us'] - sample['down_started_us'] >= 3_000_000
215 actors = [*(f'w{i}' for i in range(config['rust_writers'])), *(f'r{i}' for i in range(config['rust_readers']))]
216 assert set(sample['before']) == set(sample['after']) == set(actors)
217 assert all(sample['after'][actor] >= sample['before'][actor] + 3 for actor in actors)
218 if not config.get('document_operations'):
219 assert all(sample['before'][actor] >= 3 for actor in actors)
220 logs = {actor: [json.loads(line) for line in (output / 'rust' / f'{actor}.jsonl').read_text().splitlines()] for actor in actors}
221 publication_links(logs, config['stress_operations'])
222 documents = {}
223 if config.get('document_operations'):
224 from offline_document_history import document_history
225 documents = document_history(logs, config['stress_operations'])
226 for actor, rows in logs.items():
227 if isolated and actor != 'w0': continue
228 assert any(row['event'] == 'transport_connected' and row['at_us'] > sample['up_started_us'] for row in rows), f'{actor} did not reconnect'
229 unknown = [(actor, row) for actor, rows in logs.items() for row in rows if row['event'] == 'remote_attempt' and row['state'] == 'Unknown']
230 assert len(unknown) == 1, 'The reply cut did not establish exactly one uncertain publication'
231 actor, attempt = unknown[0]
232 assert attempt['started_us'] < sample['down_started_us'], 'Uncertain attempt started after the reply cut'
233 receipts = [row for row in logs[actor] if row['event'] in ('remote_receipt', 'document_receipt') and row['revision'] == attempt['revision']]
234 if not receipts and documents:
235 target, = attempt['document_changes']
236 confirmed_revision = documents[target]['format']['receipt_revision']
237 receipts = [row for row in logs[actor] if row['event'] == 'document_receipt' and row['revision'] == confirmed_revision]
238 assert len(receipts) == 1 and receipts[0]['at_us'] > sample['up_started_us']
239 peer_progress = {}
240 if isolated:
241 assert sample['during']['w0'] == sample['before']['w0']
242 retired, = [row for row in logs['r0'] if row['event'] == 'revision_retired']
243 assert retired['space'] == attempt['space'] and retired['revision'] == attempt['revision']
244 assert sample['down_started_us'] < retired['started_us'] <= retired['finished_us'] < sample['up_started_us']
245 assert receipts[0]['revision'] != attempt['revision'], 'The client-disconnect gate did not exercise confirmation after revision retirement'
246 paused, = [row for row in logs['w0'] if row['event'] == 'confirmation_paused']
247 assert paused['revision'] == attempt['revision'] and attempt['finished_us'] <= paused['at_us'] < sample['up_started_us']
248 for peer, rows in logs.items():
249 if peer == 'w0': continue
250 assert sample['during'][peer] >= sample['before'][peer] + 3
251 if peer.startswith('w'):
252 progress = [row for row in rows if row['event'] == 'remote_attempt' and row['state'] == 'Committed'
253 and sample['down_started_us'] < row['started_us'] <= row['finished_us'] < sample['up_started_us']]
254 else:
255 progress = [row for row in rows if row['event'] == 'read'
256 and sample['down_started_us'] < row['started_us'] <= row['finished_us'] < sample['up_started_us']]
257 assert len(progress) >= 3, f'{peer} has insufficient completed I/O while the writer was disconnected'
258 peer_progress[peer] = len(progress)
259 if config.get('document_operations'):
260 assert actor == 'w0' and sample['format_released_us'] <= attempt['started_us']
261 intent, = [row for row in logs[actor] if row['event'] == 'local_document_commit' and row['id'] == receipts[0]['id']]
262 assert intent['kind'] == 'format', 'The interrupted publication was not formatting'
263 for writer in (f'w{i}' for i in range(config['rust_writers'])):
264 paused = [row for row in logs[writer] if row['event'] == 'publication_paused']
265 assert len(paused) == 1 and paused[0]['kind'] == 'format' and paused[0]['at_us'] < sample['format_released_us']
266 paused, = [row for row in logs[actor] if row['event'] == 'publication_paused']
267 if paused['revision'] != attempt['revision']:
268 prior, = [row for row in logs[actor] if row['event'] == 'remote_attempt' and row['revision'] == paused['revision']]
269 assert prior['state'] == 'NotCommitted' and sample['format_released_us'] <= prior['started_us'] <= prior['finished_us'] <= attempt['started_us'], 'Paused revision was replaced without proving it unpublished'
270 captures = {}
271 for rows in logs.values():
272 for row in rows:
273 if row['event'] != 'remote_confirm': continue
274 name = row['capture']
275 assert Path(name).name == name and name not in captures
276 tokens(row['text'])
277 assert row['started_us'] > sample['up_started_us']
278 captures[name] = {'sha256': hashlib.sha256((output / 'rust/confirmations' / name).read_bytes()).hexdigest(), 'state': row['state']}
279 assert captures and set(captures) == {path.name for path in (output / 'rust/confirmations').glob('*.one')}
280 trace = [json.loads(line) for line in (output / 'smb-trace.jsonl').read_text().splitlines()]
281 controls = [row['control'] for row in trace if row.get('control', {}).get('phase', '').startswith('offline-')]
282 expected = {'phase': 'offline-reply-cut', 'cut': 9, 'peer': '10.0.2.2', 'offset': 96, 'direction': 'response'}
283 if isolated: expected['scope'] = 'connection'
284 assert controls == [expected, {'phase': 'offline-reply-reconnected'}]
285 cuts = [row['cut'] for row in trace if 'cut' in row]
286 assert len(cuts) == 1 and cuts[0]['direction'] == 'response' and cuts[0]['command'] == 9 and cuts[0]['status'] == '0x0'
287 request, = [row for row in trace if row.get('direction') == 'request' and row['connection'] == cuts[0]['connection'] and row['message'] == cuts[0]['message']]
288 assert request['offset'] == 96 and request['command'] == 9
289 native_writes = 0
290 if isolated:
291 peers = {row['connection']: row['peer'][0] for row in trace if row.get('opened')}
292 pending, files = {}, {}
293 for row in trace:
294 if row.get('command') not in (5, 6, 9): continue
295 key = row['connection'], row['message']
296 if row['direction'] == 'request':
297 pending[key] = row
298 if row['command'] == 6: files.pop((key[0], row['file_id']), None)
299 elif row['status'] == '0x0' and key in pending:
300 request = pending.pop(key)
301 if row['command'] == 5:
302 files[key[0], row['file_id']] = request['path'].lower()
303 elif (row['command'] == 9 and peers[key[0]].startswith('192.168.77.') and row.get('written', 0) > 0
304 and files.get((key[0], request['file_id']), '').endswith('synthetic.one')
305 and sample['down_started_us'] < request['time'] * 1_000_000 <= row['time'] * 1_000_000 < sample['up_started_us']):
306 native_writes += 1
307 assert native_writes, 'No successful native writes while the isolated writer awaited reconciliation'
308 return {'reply_cuts': 1, 'uncertain_actor': actor, 'attempted_revision': attempt['revision'], 'confirmed_revision': receipts[0]['revision'],
309 'peer_operations_during_client_disconnect': peer_progress,
310 'native_writes_during_client_disconnect': native_writes,
311 'retired_snapshot_sha256': hashlib.sha256((output / 'rust/offline-retired.one').read_bytes()).hexdigest() if isolated else None,
312 'confirmation_snapshots': captures, 'native_local_edits_inside_confirmed_outage': native_progress(output, config, sample),
313 'resumed_guarded_io_overlap': verify(trace, phase='offline-reply-reconnected')}
tools/offline_publication_crash.py created+237
...@@ -0,0 +1,237 @@
1#!/usr/bin/env python3
2"""Kill owned processes across local-cache/remote-file publication boundaries."""
3import argparse
4import hashlib
5import json
6import os
7from pathlib import Path
8import queue
9import shutil
10import signal
11import sqlite3
12import subprocess
13import threading
14import time
15
16ROOT = Path(__file__).resolve().parent.parent
17BINARY = ROOT / 'target/debug/examples/recovery_probe'
18TOKEN = ' [offline-recovery]'
19
20
21def run_command(binary, args, output):
22 result = subprocess.run([str(binary), *map(str, args)], capture_output=True, text=True, timeout=60,
23 env={**os.environ, 'ONESTORE_RECOVERY_PAUSE': ''})
24 output.with_suffix('.jsonl').write_text(result.stdout)
25 output.with_suffix('.stderr').write_text(result.stderr)
26 result.check_returncode()
27 return [json.loads(line) for line in result.stdout.splitlines()]
28
29
30def kill_at(binary, args, phase, output, receipt_window=None):
31 events = queue.Queue()
32 database = Path(args[1]) / 'cache.sqlite'
33 journal = database.with_name(database.name + '-journal')
34 def header():
35 try:
36 with journal.open('rb') as stream: return stream.read(8)
37 except FileNotFoundError: return b''
38 with output.with_suffix('.jsonl').open('w') as log, output.with_suffix('.stderr').open('w') as error:
39 process = subprocess.Popen([str(binary), *map(str, args)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=error, text=True,
40 env={**os.environ, 'ONESTORE_RECOVERY_PAUSE': phase})
41 def collect():
42 try:
43 for line in process.stdout:
44 log.write(line)
45 log.flush()
46 events.put(json.loads(line))
47 finally:
48 events.put(None)
49 reader = threading.Thread(target=collect)
50 reader.start()
51 try:
52 deadline = time.monotonic() + 60
53 while True:
54 event = events.get(timeout=max(0, deadline-time.monotonic()))
55 assert event is not None, f'Process exited before {phase}'
56 if event.get('event') == 'phase' and event['name'] == phase:
57 if receipt_window is not None:
58 assert args[0] == 'sync' and phase == 'publish-after'
59 assert receipt_window in ('journal', 'database')
60 before_mtime = database.stat().st_mtime_ns
61 process.stdin.write('\n')
62 process.stdin.flush()
63 deadline = time.monotonic() + 5
64 while True:
65 if header() == bytes.fromhex('d9d505f920a163d7') and (receipt_window == 'journal' or database.stat().st_mtime_ns != before_mtime): break
66 assert process.poll() is None, 'Receipt transaction finished before the requested cut'
67 assert time.monotonic() < deadline, 'Receipt transaction window was not observed'
68 time.sleep(.0001)
69 process.kill()
70 break
71 assert process.wait(timeout=10) == -signal.SIGKILL, 'Expected an actual process kill'
72 proof = {'pid': process.pid, 'exit': process.returncode, 'phase': phase}
73 if receipt_window is not None:
74 proof.update(journal_header_after_kill=header().hex(), database_changed=database.stat().st_mtime_ns != before_mtime, receipt_window=receipt_window)
75 assert proof['journal_header_after_kill'] == 'd9d505f920a163d7', 'Receipt transaction committed before the process died'
76 assert receipt_window != 'database' or proof['database_changed']
77 output.with_suffix('.cut.json').write_text(json.dumps(proof, indent=2))
78 finally:
79 if process.poll() is None: process.kill()
80 process.wait(timeout=10)
81 process.stdin.close()
82 reader.join(timeout=10)
83 assert not reader.is_alive(), 'Trace reader did not finish'
84 process.stdout.close()
85
86
87def state(rows, original):
88 found = [row for row in rows if row['event'] == 'state']
89 assert len(found) == 1, 'Missing independent post-reopen state'
90 result, = found
91 assert result['local_text'] == original + TOKEN, 'Locally acknowledged text was lost or duplicated'
92 assert result['remote_text'] in [original, original+TOKEN], 'Remote current text is partial, duplicated or invented'
93 assert result['status'] in ('pending', 'uncertain', 'published'), 'Intent disappeared or became an unexplained conflict'
94 if result['status'] == 'pending':
95 assert result['revision'] is None, 'Unattempted intent acquired a publication identity'
96 else:
97 assert isinstance(result['revision'], str) and result['revision'], 'Attempted identity was lost'
98 assert (result['revision'] == result['remote_revision']) == (result['remote_text'] == original+TOKEN), 'Publication identity disagrees with the visible effect'
99 if result['status'] == 'published':
100 assert result['pending'] == [] and result['remote_text'] == original+TOKEN
101 assert result['revision'] == result['remote_revision'], 'Receipt identifies another remote revision'
102 else:
103 assert len(result['pending']) == 1, 'Unacknowledged intent was lost or duplicated'
104 pending, = result['pending']
105 assert pending['id'] == 1 and pending['before'] == original and pending['replacement'] == TOKEN
106 at = len(original.encode('utf-16-le')) // 2
107 assert pending['range'] == [at, at], 'Durable intent range changed'
108 return result
109
110
111def save_image(output, data):
112 digest = hashlib.sha256(data).hexdigest()
113 path = output / 'images' / f'{digest}.one'
114 if not path.exists(): path.write_bytes(data)
115 return digest
116
117
118def confirmation_only(events, before, after):
119 assert not any(row['event'] == 'phase' and row['name'] == 'publish-before' for row in events)
120 assert all(row['offset'] == 212 and row['bytes'] == 40 for row in events if row['event'] == 'write'), 'Recovery republished the remote edit'
121 assert before[:212] == after[:212] and before[252:] == after[252:], 'Confirmation changed content or the transaction count'
122
123
124def prepare_run(source, output):
125 output.mkdir(parents=True, exist_ok=False)
126 (output / 'images').mkdir()
127 binary = output / 'recovery_probe'
128 shutil.copyfile(BINARY, binary)
129 binary.chmod(0o755)
130 shutil.copyfile(__file__, output / Path(__file__).name)
131 source_hash = hashlib.sha256(source.read_bytes()).hexdigest()
132 (output / 'run.json').write_text(json.dumps({'source': str(source), 'source_sha256': source_hash,
133 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), 'controller_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}, indent=2))
134 return binary, source_hash
135
136
137def run(source, output):
138 binary, source_hash = prepare_run(source, output)
139 baseline = output / 'baseline'
140 initialized = run_command(binary, ['init', baseline, source], output / 'baseline-init')
141 original = next(row['remote_text'] for row in initialized if row['event'] == 'state')
142 state(initialized, original)
143 published = run_command(binary, ['sync', baseline], output / 'baseline-sync')
144 assert state(published, original)['status'] == 'published'
145 phases = [row['name'] for row in published if row['event'] == 'phase']
146 assert len(phases) == len(set(phases)), 'Baseline has ambiguous phase names'
147 assert 'publish-after' in phases and any(name.startswith('write-') for name in phases)
148 confirmation = output / 'confirmation-baseline'
149 run_command(binary, ['init', confirmation, source], output / 'confirmation-init')
150 kill_at(binary, ['sync', confirmation], 'publish-after', output / 'confirmation-setup')
151 confirmed = run_command(binary, ['sync', confirmation], output / 'confirmation-sync')
152 confirmation_phases = [row['name'] for row in confirmed if row['event'] == 'phase']
153 assert len(confirmation_phases) == len(set(confirmation_phases)) and 'confirm-after' in confirmation_phases
154 assert state(confirmed, original)['status'] == 'published'
155 cases = [('local-after', False), *((phase, False) for phase in phases), *((phase, True) for phase in confirmation_phases)]
156 results = []
157 for index, (phase, confirmation) in enumerate(cases):
158 folder = output / f'case-{index:02}'
159 trace = output / f'case-{index:02}-kill'
160 if phase == 'local-after':
161 kill_at(binary, ['init', folder, source], phase, trace)
162 else:
163 run_command(binary, ['init', folder, source], output / f'case-{index:02}-init')
164 if confirmation:
165 kill_at(binary, ['sync', folder], 'publish-after', output / f'case-{index:02}-setup')
166 kill_at(binary, ['sync', folder], phase, trace)
167 before = state(run_command(binary, ['inspect', folder], output / f'case-{index:02}-inspect'), original)
168 before_bytes = (folder / 'remote.one').read_bytes()
169 first = run_command(binary, ['sync', folder], output / f'case-{index:02}-recover')
170 after = state(first, original)
171 if before['status'] == 'uncertain' and before['remote_text'] == original:
172 assert after['status'] == 'uncertain' and after['revision'] == before['revision'], 'Absent uncertain attempt was replayed'
173 assert not any(row['event'] == 'write' for row in first)
174 else:
175 assert after['status'] == 'published'
176 if before['status'] != 'pending':
177 assert after['revision'] == before['revision'], 'Recovery published another revision'
178 confirmation_only(first, before_bytes, (folder / 'remote.one').read_bytes())
179 remote_hash = hashlib.sha256((folder / 'remote.one').read_bytes()).hexdigest()
180 repeated = run_command(binary, ['sync', folder], output / f'case-{index:02}-repeat')
181 assert state(repeated, original) == after, 'Repeated recovery changed durable intent state'
182 assert not any(row['event'] == 'write' for row in repeated), 'Repeated recovery published again'
183 assert hashlib.sha256((folder / 'remote.one').read_bytes()).hexdigest() == remote_hash
184 remote_image = save_image(output, (folder / 'remote.one').read_bytes())
185 connection = sqlite3.connect(folder / 'cache.sqlite')
186 try:
187 assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)]
188 assert connection.execute('PRAGMA foreign_key_check').fetchall() == []
189 local_image = save_image(output, connection.execute('SELECT working FROM replica WHERE id=1').fetchone()[0])
190 finally:
191 connection.close()
192 result = {'case': index, 'phase': phase, 'during_confirmation': confirmation, 'before_status': before['status'], 'after_status': after['status'],
193 'visible_before_recovery': before['remote_text'] != original, 'remote_image': remote_image, 'local_image': local_image,
194 'remote_text': after['remote_text'], 'local_text': after['local_text']}
195 results.append(result)
196 (output / 'results.json').write_text(json.dumps(results, indent=2))
197 print(json.dumps({key: value for key, value in result.items() if not key.endswith('_text')}), flush=True)
198 assert hashlib.sha256(source.read_bytes()).hexdigest() == source_hash, 'The frozen source changed'
199 summary = {'cases': len(results), 'process_kills': 1 + len(results) + sum(confirmation for _, confirmation in cases), 'uncertain_absent_preserved': sum(row['after_status']=='uncertain' for row in results),
200 'durable_receipts': sum(row['after_status']=='published' for row in results), 'unique_images': len(list((output/'images').glob('*.one')))}
201 (output/'summary.json').write_text(json.dumps(summary, indent=2))
202 print(json.dumps(summary), flush=True)
203
204
205def receipt_windows(source, output):
206 binary, source_hash = prepare_run(source, output)
207 results = []
208 for window in ('journal', 'database'):
209 folder = output / window
210 initialized = run_command(binary, ['init', folder, source], output / (window+'-init'))
211 original = next(row['remote_text'] for row in initialized if row['event'] == 'state')
212 state(initialized, original)
213 kill_at(binary, ['sync', folder], 'publish-after', output / (window+'-kill'), receipt_window=window)
214 before = state(run_command(binary, ['inspect', folder], output / (window+'-inspect')), original)
215 assert before['status'] == 'uncertain' and before['remote_text'] == original+TOKEN, 'Hot-journal recovery lost the pending confirmation'
216 before_bytes = (folder / 'remote.one').read_bytes()
217 recovered = run_command(binary, ['sync', folder], output / (window+'-recover'))
218 after = state(recovered, original)
219 assert after['status'] == 'published' and before['revision'] == after['revision']
220 confirmation_only(recovered, before_bytes, (folder / 'remote.one').read_bytes())
221 repeated = run_command(binary, ['sync', folder], output / (window+'-repeat'))
222 assert state(repeated, original) == after and not any(row['event']=='write' for row in repeated)
223 digest = save_image(output, (folder/'remote.one').read_bytes())
224 results.append({'receipt_window':window, 'remote_image':digest, 'remote_text':after['remote_text'], 'revision':after['revision']})
225 print(json.dumps({'receipt_window':window, 'status':after['status'], 'remote_image':digest}), flush=True)
226 assert hashlib.sha256(source.read_bytes()).hexdigest() == source_hash
227 (output/'results.json').write_text(json.dumps(results, indent=2))
228 (output/'summary.json').write_text(json.dumps({'process_kills':2, 'recovered_receipts':2, 'republished_edits':0}, indent=2))
229
230
231if __name__ == '__main__':
232 parser = argparse.ArgumentParser(description=__doc__)
233 parser.add_argument('source', type=Path)
234 parser.add_argument('output', type=Path)
235 parser.add_argument('--receipt-windows', action='store_true')
236 args = parser.parse_args()
237 (receipt_windows if args.receipt_windows else run)(args.source.resolve(), args.output.resolve())
tools/smb-proxy.py+81-14
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1#!/usr/bin/env python31#!/usr/bin/env python3
2"""Trace a dedicated test SMB session and cut selected responses before delivery."""2"""Trace a dedicated test SMB session and cut selected requests or responses."""
3import argparse3import argparse
4import asyncio4import asyncio
5import json5import json
...@@ -8,28 +8,44 @@ import struct...@@ -8,28 +8,44 @@ import struct
8import time8import time
99
1010
11def header_fields(offset, data):
12 result = {}
13 for name, start, length in [('transactions', 96, 4), ('version', 212, 16),
14 ('generation', 228, 8), ('deny_read', 236, 16)]:
15 if offset <= start and start + length <= offset + len(data):
16 value = data[start - offset:start - offset + length]
17 result[name] = value.hex() if length == 16 else int.from_bytes(value, 'little')
18 return result
19
20
11async def main():21async def main():
12 parser = argparse.ArgumentParser(description=__doc__)22 parser = argparse.ArgumentParser(description=__doc__)
13 parser.add_argument('control', type=Path)23 parser.add_argument('control', type=Path)
14 parser.add_argument('--port', type=int, default=11445)24 parser.add_argument('--port', type=int, default=11445)
15 parser.add_argument('--server', default='10.0.0.1')25 parser.add_argument('--server', default='10.0.0.1')
26 parser.add_argument('--server-port', type=int, default=445)
27 parser.add_argument('--bind', default='127.0.0.1')
16 args = parser.parse_args()28 args = parser.parse_args()
17 writers = set()29 writers = set()
18 state = {'mode': 'up'}30 state = {'mode': 'up'}
19 previous = None31 previous = None
20 blocked = False32 blocked = False
21 matched = 033 matched = 0
34 connections = 0
35 record_writes = False
2236
23 def record(**fields):37 def record(**fields):
24 print(json.dumps({'time': time.time(), **fields}), flush=True)38 print(json.dumps({'time': time.time(), **fields}), flush=True)
2539
26 async def controls():40 async def controls():
27 nonlocal state, previous, blocked, matched41 nonlocal state, previous, blocked, matched, record_writes
28 while True:42 while True:
29 try:43 try:
30 raw = args.control.read_bytes()44 raw = args.control.read_bytes()
31 if raw != previous:45 if raw != previous:
32 state = json.loads(raw)46 state = json.loads(raw)
47 if 'record_writes' in state:
48 record_writes = bool(state['record_writes'])
33 previous, matched = raw, 049 previous, matched = raw, 0
34 blocked = state.get('mode') == 'down'50 blocked = state.get('mode') == 'down'
35 record(control=state)51 record(control=state)
...@@ -41,13 +57,16 @@ async def main():...@@ -41,13 +57,16 @@ async def main():
41 await asyncio.sleep(0.05)57 await asyncio.sleep(0.05)
4258
43 async def connection(client, client_writer):59 async def connection(client, client_writer):
44 nonlocal blocked, matched60 nonlocal blocked, matched, connections
61 connections += 1
62 connection_id = connections
63 record(connection=connection_id, peer=client_writer.get_extra_info("peername"), opened=True)
45 if blocked:64 if blocked:
46 client_writer.close()65 client_writer.close()
47 return66 return
48 server_writer = None67 server_writer = None
49 try:68 try:
50 server, server_writer = await asyncio.open_connection(args.server, 445)69 server, server_writer = await asyncio.open_connection(args.server, args.server_port)
51 writers.update((client_writer, server_writer))70 writers.update((client_writer, server_writer))
5271
53 async def forward(reader, writer, direction):72 async def forward(reader, writer, direction):
...@@ -58,25 +77,68 @@ async def main():...@@ -58,25 +77,68 @@ async def main():
58 at = 077 at = 0
59 while frame[at:at+4] == b'\xfeSMB':78 while frame[at:at+4] == b'\xfeSMB':
60 command = struct.unpack_from('<H', frame, at+12)[0]79 command = struct.unpack_from('<H', frame, at+12)[0]
61 entry = {'direction': direction, 'command': command,80 entry = {'connection': connection_id, 'direction': direction, 'command': command,
62 'message': struct.unpack_from('<Q', frame, at+24)[0]}81 'message': struct.unpack_from('<Q', frame, at+24)[0],
82 'credit_charge': struct.unpack_from('<H', frame, at+6)[0],
83 'credits': struct.unpack_from('<H', frame, at+14)[0]}
63 if direction == 'response':84 if direction == 'response':
64 entry['status'] = hex(struct.unpack_from('<I', frame, at+8)[0])85 entry['status'] = hex(struct.unpack_from('<I', frame, at+8)[0])
65 elif command in (8, 9):86 elif command in (8, 9):
66 entry['length'], entry['offset'] = struct.unpack_from('<IQ', frame, at+68)87 entry['length'], entry['offset'] = struct.unpack_from('<IQ', frame, at+68)
67 if command == 9 and entry['offset'] <= 96 and entry['offset'] + entry['length'] >= 100:88 if command == 9:
68 data_offset = struct.unpack_from('<H', frame, at+66)[0]89 data_offset = struct.unpack_from('<H', frame, at+66)[0]
69 entry['transactions'] = struct.unpack_from('<I', frame, at+data_offset+96-entry['offset'])[0]90 data = frame[at+data_offset:at+data_offset+entry['length']]
91 entry.update(header_fields(entry['offset'], data))
92 if record_writes:
93 entry['data'] = data.hex()
70 elif command == 10:94 elif command == 10:
71 count = struct.unpack_from('<H', frame, at+66)[0]95 count = struct.unpack_from('<H', frame, at+66)[0]
72 entry['locks'] = [struct.unpack_from('<QQI', frame, at+88+24*i) for i in range(count)]96 entry['locks'] = [struct.unpack_from('<QQI', frame, at+88+24*i) for i in range(count)]
97 if command == 18 and (direction == 'request' or entry['status'] == '0x0'):
98 size = struct.unpack_from('<H', frame, at+64)[0]
99 entry['oplock_body'] = frame[at+64:at+64+size].hex()
100 if direction == 'request':
101 if command in (6, 7, 8, 9, 10):
102 offset = 80 if command in (8, 9) else 72
103 entry['file_id'] = frame[at+offset:at+offset+16].hex()
104 elif command == 17:
105 entry['info_type'], entry['info_class'] = struct.unpack_from('<BB', frame, at+66)
106 if record_writes:
107 entry['file_id'] = frame[at+80:at+96].hex()
108 length, offset = struct.unpack_from('<IH', frame, at+68)
109 entry['data'] = frame[at+offset:at+offset+length].hex()
110 elif command == 5:
111 entry['oplock'] = frame[at+67]
112 entry['access'] = struct.unpack_from('<I', frame, at+88)[0]
113 entry['share'] = struct.unpack_from('<I', frame, at+96)[0]
114 offset, length = struct.unpack_from('<HH', frame, at+108)
115 entry['path'] = frame[at+offset:at+offset+length].decode('utf-16-le')
116 elif command == 5 and entry['status'] == '0x0':
117 entry['oplock'] = frame[at+66]
118 entry['file_id'] = frame[at+128:at+144].hex()
119 elif command == 9 and entry['status'] == '0x0':
120 entry['written'] = struct.unpack_from('<I', frame, at+68)[0]
121 if direction == 'request':
122 requests[entry['message']] = entry
123 request = entry
124 else:
125 request = requests.get(entry['message'], {})
126 if entry['status'] != '0x103': requests.pop(entry['message'], None)
127 if command == 8 and entry['status'] == '0x0' and request.get('offset', 252) < 252:
128 data_offset = frame[at+66]
129 length = struct.unpack_from('<I', frame, at+68)[0]
130 entry.update(header_fields(request['offset'], frame[at+data_offset:at+data_offset+length]))
73 record(**entry)131 record(**entry)
74 if state.get('cut') == command and direction == 'response' and entry['status'] == state.get('status', '0x0'):132 if (state.get('cut') == command and direction == state.get('direction', 'response')
133 and (direction == 'request' or entry['status'] == state.get('status', '0x0'))
134 and ('peer' not in state or state['peer'] == client_writer.get_extra_info('peername')[0])
135 and ('offset' not in state or state['offset'] == request.get('offset'))):
75 matched += 1136 matched += 1
76 if matched == state.get('occurrence', 1):137 if matched == state.get('occurrence', 1):
77 blocked = True138 local = state.get('scope') == 'connection'
139 blocked = not local
78 record(cut=entry)140 record(cut=entry)
79 for stream in list(writers):141 for stream in (client_writer, server_writer) if local else list(writers):
80 stream.transport.abort()142 stream.transport.abort()
81 return143 return
82 next_command = struct.unpack_from('<I', frame, at+20)[0]144 next_command = struct.unpack_from('<I', frame, at+20)[0]
...@@ -88,23 +150,28 @@ async def main():...@@ -88,23 +150,28 @@ async def main():
88 writer.write(prefix + frame)150 writer.write(prefix + frame)
89 await writer.drain()151 await writer.drain()
90152
153 requests = {}
91 tasks = [asyncio.create_task(forward(client, server_writer, 'request')),154 tasks = [asyncio.create_task(forward(client, server_writer, 'request')),
92 asyncio.create_task(forward(server, client_writer, 'response'))]155 asyncio.create_task(forward(server, client_writer, 'response'))]
93 done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)156 done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
94 for task in pending:157 for task in pending:
95 task.cancel()158 task.cancel()
96 await asyncio.gather(*tasks, return_exceptions=True)159 results = await asyncio.gather(*tasks, return_exceptions=True)
160 for result in results:
161 if isinstance(result, Exception) and not isinstance(result, (OSError, asyncio.IncompleteReadError)):
162 record(connection=connection_id, trace_error=repr(result))
97 except (OSError, asyncio.IncompleteReadError) as error:163 except (OSError, asyncio.IncompleteReadError) as error:
98 record(error=str(error))164 record(error=str(error))
99 finally:165 finally:
166 record(connection=connection_id, closed=True)
100 for writer in (client_writer, server_writer):167 for writer in (client_writer, server_writer):
101 if writer:168 if writer:
102 writers.discard(writer)169 writers.discard(writer)
103 writer.close()170 writer.close()
104171
105 server = await asyncio.start_server(connection, '127.0.0.1', args.port)172 server = await asyncio.start_server(connection, args.bind, args.port)
106 async with server:173 async with server:
107 record(listening=args.port)174 record(listening=server.sockets[0].getsockname()[1])
108 await asyncio.gather(server.serve_forever(), controls())175 await asyncio.gather(server.serve_forever(), controls())
109176
110177
tools/smb_faults.py created+82
...@@ -0,0 +1,82 @@
1#!/usr/bin/env python3
2"""Run the embedded transport message-loss matrix on an owned Samba VM."""
3import argparse
4import hashlib
5import json
6import os
7from pathlib import Path
8import shutil
9import signal
10import subprocess
11import time
12
13from native_runner import ROOT
14import linux_vm
15
16
17def run(output, server):
18 if linux_vm.instance_path(server).exists():
19 raise ValueError('Choose a new Linux VM name.')
20 output = output.resolve()
21 output.mkdir(parents=True, exist_ok=False)
22 sources = ['tools/smb_faults.py', 'tools/smb-proxy.py', 'Cargo.lock',
23 'crates/onestore-smb/src/lib.rs', 'crates/onestore-smb/src/tests.rs',
24 'crates/onestore-smb/src/tests/faults.rs', 'crates/onestore/src/commit.rs',
25 'crates/onestore/src/snapshot.rs']
26 for name in sources:
27 target = output / 'harness' / name
28 target.parent.mkdir(parents=True, exist_ok=True)
29 shutil.copyfile(ROOT / name, target)
30 (output / 'run.json').write_text(json.dumps({'server': server,
31 'source_sha256': {name: hashlib.sha256((output / 'harness' / name).read_bytes()).hexdigest() for name in sources}}, indent=2))
32 process = None
33 try:
34 linux_vm.create_instance(server)
35 linux_vm.launch(server)
36 linux_vm.wait_instance(server, 600)
37 config = linux_vm.load_instance(server)
38 (output / 'linux.json').write_text(json.dumps(config, indent=2))
39 with (output / 'test.log').open('w') as log:
40 process = subprocess.Popen(['cargo', 'test', '-p', 'onestore-smb', 'live_message_loss', '--', '--ignored', '--nocapture'],
41 cwd=ROOT, stdout=log, stderr=subprocess.STDOUT, start_new_session=True,
42 env={**os.environ, 'ONESTORE_SMB_LAB': f'127.0.0.1:{config["samba_port"]}',
43 'ONESTORE_SMB_EVIDENCE': str(output / 'cases')})
44 if process.wait(timeout=900) != 0: raise RuntimeError('The message-loss matrix failed; inspect test.log.')
45 hashes = linux_vm.run_ssh(server, 'sha256sum /srv/agent/fault-*.one', timeout=30)
46 hashes.check_returncode()
47 (output / 'server-sha256.txt').write_text(hashes.stdout)
48 hashes = {Path(path).name: digest for digest, path in (line.split() for line in hashes.stdout.splitlines())}
49 results = json.loads((output / 'cases/results.json').read_text())
50 for result in results:
51 captured = output / 'cases/recovered' / (result['case'] + '.one')
52 assert hashlib.sha256(captured.read_bytes()).hexdigest() == hashes[result['path']], 'Recovered bytes differ from the independent server read'
53 (output / 'verification.json').write_text(json.dumps({'cases': len(results), 'server_hashes_match': True}, indent=2))
54 finally:
55 if process is not None and process.poll() is None:
56 os.killpg(process.pid, signal.SIGTERM)
57 process.wait(timeout=30)
58 if linux_vm.instance_path(server).exists():
59 if linux_vm.running(server):
60 try:
61 with (output / 'server.tar').open('wb') as archive:
62 subprocess.run(linux_vm.ssh_argv(server, 'tar cf - -C /srv/agent .'), stdout=archive, check=True, timeout=30)
63 result = linux_vm.run_ssh(server, 'sudo smbstatus --byterange --json', timeout=10)
64 (output / 'server-locks.json').write_text(result.stdout)
65 finally:
66 try: linux_vm.shutdown(server, 60)
67 finally:
68 if linux_vm.running(server): linux_vm.qmp(server, 'quit')
69 deadline = time.monotonic() + 10
70 while linux_vm.running(server) and time.monotonic() < deadline: time.sleep(.1)
71 linux_vm.delete_instance(server)
72 (output / 'teardown.json').write_text(json.dumps({'linux_absent': not linux_vm.instance_path(server).exists()}, indent=2))
73
74
75if __name__ == '__main__':
76 parser = argparse.ArgumentParser(description=__doc__)
77 parser.add_argument('output', type=Path)
78 parser.add_argument('--linux', required=True)
79 args = parser.parse_args()
80 def interrupted(_signal, _frame): raise KeyboardInterrupt
81 signal.signal(signal.SIGTERM, interrupted)
82 run(args.output, args.linux)
tools/test_concurrent_rust.py+36-1
...@@ -1,9 +1,44 @@...@@ -1,9 +1,44 @@
1import copy1import copy
2import json
3from pathlib import Path
4import sys
5import tempfile
2import unittest6import unittest
3from concurrent_rust import verify7from concurrent_rust import running_clients, verify
48
59
6class OracleTests(unittest.TestCase):10class OracleTests(unittest.TestCase):
11 def test_timeout_and_environment_reach_every_subprocess(self):
12 with tempfile.TemporaryDirectory() as directory:
13 output = Path(directory)
14 executable = output / 'client'
15 executable.write_text(f'#!{sys.executable}\nimport json, os, sys, time\nprint(json.dumps(dict(os.environ)), flush=True)\nwhile not os.path.exists(sys.argv[5]): time.sleep(.001)\n')
16 executable.chmod(0o700)
17 with running_clients(output, 'unused.one', 2, 1, 1, 7, timeout=1.25,
18 executable=executable, environment={'KEPT': 'value'}):
19 (output / 'start').touch()
20 for actor in ['w0', 'w1', 'r0']:
21 environment = json.loads((output / f'{actor}.jsonl').read_text())
22 self.assertEqual(environment['ONESTORE_CLIENT_TIMEOUT_MS'], '1250')
23 self.assertEqual(environment['KEPT'], 'value')
24 self.assertEqual(json.loads((output / 'clients.json').read_text())['timeout_ms'], 1250)
25
26 def test_distinct_reader_binary_is_launched_and_recorded(self):
27 with tempfile.TemporaryDirectory() as directory:
28 output = Path(directory)
29 for mode in ('writer', 'reader'):
30 executable = output / mode
31 executable.write_text(f'#!{sys.executable}\nimport os, sys, time\nprint({mode!r}, flush=True)\nwhile not os.path.exists(sys.argv[5]): time.sleep(.001)\n')
32 executable.chmod(0o700)
33 with running_clients(output, 'unused.one', 2, 1, 1, 7,
34 executable=output / 'writer', reader_executable=output / 'reader'):
35 (output / 'start').touch()
36 self.assertEqual((output / 'w0.jsonl').read_text().strip(), 'writer')
37 self.assertEqual((output / 'r0.jsonl').read_text().strip(), 'reader')
38 import hashlib
39 manifest = json.loads((output / 'clients.json').read_text())
40 self.assertEqual(manifest['reader_sha256'], hashlib.sha256((output / 'reader').read_bytes()).hexdigest())
41
7 def setUp(self):42 def setUp(self):
8 self.logs = {43 self.logs = {
9 'w0': [{'event': 'ready'}, {'event': 'commit', 'source_transaction': 1,44 'w0': [{'event': 'ready'}, {'event': 'commit', 'source_transaction': 1,
tools/test_crash_recovery.py+25-4
...@@ -1,15 +1,34 @@...@@ -1,15 +1,34 @@
1import copy1import copy
2import json
3from pathlib import Path
4import subprocess
2import unittest5import unittest
36
4from crash_recovery import verify_text7from crash_recovery import active_text, verify_text
8from document_model import EXPORTER, ordered_pages, view, walk
59
610
7class RecoveryOracleTest(unittest.TestCase):11class RecoveryOracleTest(unittest.TestCase):
12 def test_current_page_is_distinguished_from_native_conflict_content(self):
13 source = Path(__file__).resolve().parent.parent / 'corpus/collaboration/round-01/offline/notebook/synthetic.one'
14 model = json.loads(subprocess.check_output([EXPORTER, source]))
15 (sid, _, revision, page), = ordered_pages(model)
16 main = next(node for _, node in walk(revision, page) if node['kind']['type'] == 'RichText')
17 main['kind']['text'] = 'Concurrent edits: current'
18 manifest = revision['nodes'][revision['roots']['1']]
19 self.assertTrue(manifest['spaces'])
20 for conflict in manifest['spaces']:
21 self.assertNotEqual(sid, conflict)
22 _, older = view(model, conflict)
23 node = next(node for node in older['nodes'].values() if node['kind']['type'] == 'RichText')
24 node['kind']['text'] = 'Concurrent edits: older'
25 self.assertEqual(active_text(model), 'Concurrent edits: current')
26
8 def test_accounting_and_corruption_controls(self):27 def test_accounting_and_corruption_controls(self):
9 logs = {'w0': [28 logs = {'w0': [
10 {'event': 'intent', 'token': ' [w0:0]', 'attempt': 1},29 {'event': 'intent', 'token': ' [w0:0]', 'replacement': ' [w0:0]', 'before': 'Base', 'range': [4, 4], 'attempt': 1},
11 {'event': 'commit', 'token': ' [w0:0]', 'attempt': 1, 'started_us': 5, 'finished_us': 10},30 {'event': 'commit', 'token': ' [w0:0]', 'attempt': 1, 'started_us': 5, 'finished_us': 10},
12 {'event': 'intent', 'token': ' [w0:1]', 'attempt': 2},31 {'event': 'intent', 'token': ' [w0:1]', 'replacement': ' [w0:1]', 'before': 'Base [w0:0]', 'range': [11, 11], 'attempt': 2},
13 {'event': 'commit_error', 'token': ' [w0:1]', 'attempt': 2, 'state': 'Unknown', 'started_us': 18, 'finished_us': 20},32 {'event': 'commit_error', 'token': ' [w0:1]', 'attempt': 2, 'state': 'Unknown', 'started_us': 18, 'finished_us': 20},
14 {'event': 'read', 'text': 'Base [w0:0]', 'started_us': 15, 'finished_us': 17},33 {'event': 'read', 'text': 'Base [w0:0]', 'started_us': 15, 'finished_us': 17},
15 ]}34 ]}
...@@ -28,8 +47,10 @@ class RecoveryOracleTest(unittest.TestCase):...@@ -28,8 +47,10 @@ class RecoveryOracleTest(unittest.TestCase):
28 verify_text('Base', 'Base [w0:0]', committed_error)47 verify_text('Base', 'Base [w0:0]', committed_error)
29 rejected = copy.deepcopy(logs)48 rejected = copy.deepcopy(logs)
30 rejected['w0'][3]['state'] = 'NotCommitted'49 rejected['w0'][3]['state'] = 'NotCommitted'
31 with self.assertRaisesRegex(AssertionError, 'definitively uncommitted'):50 with self.assertRaisesRegex(AssertionError, 'recorded publication'):
32 verify_text('Base', 'Base [w0:0] [w0:1]', rejected)51 verify_text('Base', 'Base [w0:0] [w0:1]', rejected)
52 with self.assertRaisesRegex(AssertionError, 'recorded publication'):
53 verify_text('Base', 'Base [w0:1] [w0:0]', logs)
33 future = copy.deepcopy(logs)54 future = copy.deepcopy(logs)
34 future['w0'][1]['started_us'] = 1855 future['w0'][1]['started_us'] = 18
35 with self.assertRaisesRegex(AssertionError, 'future edit'):56 with self.assertRaisesRegex(AssertionError, 'future edit'):
tools/test_native_disconnect.py created+143
...@@ -0,0 +1,143 @@
1import copy
2import json
3import os
4from pathlib import Path
5import tempfile
6from types import SimpleNamespace
7import unittest
8from unittest.mock import patch
9
10from native_disconnect import interrupt, verify_disconnect
11
12
13class DisconnectOracle(unittest.TestCase):
14 def setUp(self):
15 temporary = tempfile.TemporaryDirectory()
16 self.addCleanup(temporary.cleanup)
17 self.root = Path(temporary.name)
18 (self.root / 'rust').mkdir()
19 self.config = {'stress_clients': 4, 'rust_writers': 4, 'rust_readers': 4, 'stress_operations': 80}
20 for name, stamp in [('start', 0), ('stop', 10**9)]:
21 path = self.root / 'rust' / name
22 path.touch()
23 os.utime(path, ns=(stamp, stamp))
24 for i in range(4):
25 folder = self.root / f'n{i}'
26 folder.mkdir()
27 (folder / 'stress-events.jsonl').write_text('\n'.join(json.dumps({
28 'update_started_ticks': j * 10**7, 'updated_ticks': (j + 1) * 10**7}) for j in range(80)))
29 actors = [f'{role}{i}' for role in ('w', 'r') for i in range(4)]
30 self.samples = [{'cycle': i, 'before': dict.fromkeys(actors, 3 + 3 * i),
31 'after': dict.fromkeys(actors, 6 + 3 * i), 'native_before': [5, 6, 7, 8],
32 'before_errors': dict.fromkeys(actors, i), 'after_errors': dict.fromkeys(actors, i + 1)}
33 for i in range(2)]
34 for actor in actors:
35 (self.root / 'rust' / (actor + '.jsonl')).write_text('\n'.join(json.dumps({'event': event}) for event in
36 ['ready', 'transport_read_error', 'transport_connected', 'transport_read_error', 'transport_connected', 'done']))
37 with (self.root / 'rust' / (actor + '.jsonl')).open('a') as stream:
38 stream.write('\n' + json.dumps({'event': 'commit' if actor.startswith('w') else 'read', 'finished_us': 1000000}))
39 for actor, published in [('w0', False), ('w1', True)]:
40 with (self.root / 'rust' / (actor + '.jsonl')).open('a') as stream:
41 stream.write('\n' + json.dumps({'event': 'transport_commit_error', 'state': 'Unknown', 'token': actor}) + '\n')
42 stream.write(json.dumps({'event': 'transport_reconciled', 'published': published, 'flush_confirmed': published, 'token': actor}) + '\n')
43 (self.root / 'smb-trace.jsonl').write_text('{"cut":{}}\n{"control":{"phase":"reconnected-0"}}\n{"control":{"phase":"disconnect-1"}}\n{"cut":{}}\n{"control":{"phase":"reconnected-1"}}\n')
44
45 def check(self):
46 (self.root / 'run.json').write_text(json.dumps(self.config))
47 (self.root / 'disconnect-progress.json').write_text(json.dumps(self.samples))
48 with patch('native_disconnect.verify', return_value={'guarded': True}) as overlap:
49 result = verify_disconnect(self.root)
50 self.assertEqual([call.kwargs['phase'] for call in overlap.call_args_list], ['reconnected-0', 'reconnected-1'])
51 self.assertEqual([len(call.args[0]) for call in overlap.call_args_list], [2, 5])
52 return result
53
54 def test_both_interruptions_require_post_reconnect_overlap(self):
55 self.assertEqual(self.check()['interruptions'], 2)
56
57 def test_stalled_and_backward_clients_are_rejected(self):
58 for actor in ['w3', 'r2']:
59 path = self.root / 'rust' / (actor + '.jsonl')
60 original = path.read_text()
61 for stamp in [120000001, -1]:
62 path.write_text(original.replace('"finished_us": 1000000', f'"finished_us": {stamp}'))
63 with self.assertRaisesRegex(AssertionError, 'progress stalled or went backwards'): self.check()
64 path.write_text(original)
65 path = self.root / 'n2/stress-events.jsonl'
66 rows = [json.loads(line) for line in path.read_text().splitlines()]
67 rows[30]['updated_ticks'] += 121 * 10**7
68 path.write_text('\n'.join(map(json.dumps, rows)))
69 with self.assertRaisesRegex(AssertionError, 'n2: client progress'): self.check()
70
71 def test_reader_progress_must_cover_the_stop_barrier(self):
72 stamp = 121000001000
73 os.utime(self.root / 'rust/stop', ns=(stamp, stamp))
74 with self.assertRaisesRegex(AssertionError, 'r0: client progress'): self.check()
75
76 def test_one_idle_or_unaffected_client_is_rejected(self):
77 original = copy.deepcopy(self.samples)
78 for field, value in [('after', 3), ('after_errors', 0)]:
79 self.samples = copy.deepcopy(original)
80 self.samples[0][field]['r3'] = value
81 with self.assertRaises(AssertionError): self.check()
82
83 def test_missing_actor_is_rejected(self):
84 for sample in self.samples:
85 for field in ('before', 'after', 'before_errors', 'after_errors'): del sample[field]['r3']
86 with self.assertRaises(AssertionError): self.check()
87
88 def test_completed_native_writer_is_rejected(self):
89 self.samples[0]['native_before'][2] = 80
90 with self.assertRaises(AssertionError): self.check()
91
92 def test_wrong_cut_count_is_rejected(self):
93 (self.root / 'smb-trace.jsonl').write_text('{"cut":{}}\n')
94 with self.assertRaises(AssertionError): self.check()
95
96 def test_missing_reconnection_is_rejected(self):
97 (self.root / 'rust/r2.jsonl').write_text('{"event":"transport_read_error"}\n')
98 with self.assertRaises(AssertionError): self.check()
99
100 def test_visibility_without_flush_confirmation_is_rejected(self):
101 path = self.root / 'rust/w1.jsonl'
102 path.write_text(path.read_text().replace('"flush_confirmed": true', '"flush_confirmed": false'))
103 with self.assertRaisesRegex(AssertionError, 'durable acknowledgement'): self.check()
104
105 def test_one_uncertain_outcome_is_insufficient(self):
106 path = self.root / 'rust/w0.jsonl'
107 path.write_text(path.read_text().replace('"state": "Unknown"', '"state": "NotCommitted"'))
108 with self.assertRaisesRegex(AssertionError, 'both uncertain outcomes'): self.check()
109
110 def test_failed_cut_observation_restores_the_connection(self):
111 (self.root / 'run.json').write_text(json.dumps({**self.config, 'server': 'owned-test'}))
112 (self.root / 'rust/w0.jsonl').write_text('{"event":"commit"}\n' * 3)
113 controls = []
114 (self.root / 'harness').mkdir()
115 (self.root / 'harness/verify_smb_overlap.py').write_text('test oracle')
116
117 def ssh(server, command, timeout):
118 self.assertEqual(server, 'owned-test')
119 if command.startswith('printf'):
120 controls.append(json.loads(command[command.index('{'):command.index('}') + 1]))
121 output = ''
122 elif (failed_phase_ack and controls[-1]['phase'] == 'disconnect-0') or command.startswith('grep -c'):
123 raise RuntimeError('Fault observation failed')
124 else: output = json.dumps({'control': controls[-1]})
125 return SimpleNamespace(stdout=output, stderr='', returncode=0, check_returncode=lambda: None)
126
127 def capture(remote, destination, name):
128 destination.write_text('{"operation":0}\n')
129 return {'error': None}
130
131 for failed_phase_ack in [False, True]:
132 controls.clear()
133 with self.subTest(failed_phase_ack=failed_phase_ack), \
134 patch('native_disconnect.linux_vm.run_ssh', side_effect=ssh), \
135 patch('native_disconnect.linux_vm.ssh_argv', return_value=['ssh', 'owned-test']), \
136 patch('native_disconnect.subprocess.run'), \
137 patch('native_disconnect.windows.do_get', side_effect=capture):
138 with self.assertRaisesRegex(RuntimeError, 'Fault observation failed'):
139 interrupt(self.root, [{'name': 'native'}], [1], {'w0': SimpleNamespace(poll=lambda: None)})
140 self.assertEqual([control['phase'] for control in controls], ['disconnect-0', 'reconnected-0'])
141
142
143if __name__ == '__main__': unittest.main()
tools/test_native_maintenance.py created+53
...@@ -0,0 +1,53 @@
1import unittest
2
3from native_maintenance import maintenance_locks
4
5
6class Maintenance(unittest.TestCase):
7 def history(self, path='m6-collaboration/synthetic.one', host='192.168.77.2', status='0xc0000055'):
8 events = [{'connection': 1, 'opened': True, 'peer': [host, 1]}]
9 def exchange(command, status='0x0', **fields):
10 message = len(events)
11 events.extend([
12 {'connection': 1, 'direction': 'request', 'command': command, 'message': message, **fields},
13 {'connection': 1, 'direction': 'response', 'command': command, 'message': message, 'status': status, 'file_id': 'file'},
14 ])
15 exchange(5, path=path)
16 for phase, result in [('maintenance-held', status), ('maintenance-released', '0x0')]:
17 events.append({'control': {'phase': phase}})
18 exchange(10, result, file_id='file', locks=[(0xffffeffc, 4096, 0x12)])
19 return events
20
21 def test_section_denied_then_acquired(self):
22 self.assertEqual(len(maintenance_locks(self.history())), 2)
23
24 def test_toc_compaction_does_not_count(self):
25 with self.assertRaises(AssertionError):
26 maintenance_locks(self.history(path='m6-collaboration/Open Notebook.onetoc2'))
27
28 def test_rust_maintenance_does_not_count(self):
29 with self.assertRaises(AssertionError):
30 maintenance_locks(self.history(host='10.0.2.2'))
31
32 def test_unrelated_error_does_not_prove_exclusion(self):
33 with self.assertRaises(AssertionError):
34 maintenance_locks(self.history(status='0xc0000001'))
35
36 def test_delayed_response_uses_request_phase(self):
37 events = self.history()
38 events.insert(-1, {'control': {'phase': 'maintenance-resumed'}})
39 self.assertEqual(len(maintenance_locks(events)), 2)
40 events = self.history()
41 events.insert(-2, {'control': {'phase': 'maintenance-resumed'}})
42 with self.assertRaises(AssertionError):
43 maintenance_locks(events)
44
45 def test_shared_or_short_lock_is_not_maintenance(self):
46 for lock in [(0xffffeffc, 4096, 0x11), (0xfffffffc, 1, 0x12)]:
47 events = self.history()
48 for event in events:
49 if 'locks' in event: event['locks'] = [lock]
50 with self.assertRaises(AssertionError): maintenance_locks(events)
51
52
53if __name__ == '__main__': unittest.main()
tools/test_native_stress.py+48
...@@ -1,9 +1,12 @@...@@ -1,9 +1,12 @@
1import copy1import copy
2from contextlib import contextmanager
2import json3import json
3from pathlib import Path4from pathlib import Path
4import subprocess5import subprocess
5import tempfile6import tempfile
6import unittest7import unittest
8from unittest.mock import patch
9import native_stress
7from native_stress import edit_history, native_history, verify_capture10from native_stress import edit_history, native_history, verify_capture
8from native_collaboration import reachable_page_text11from native_collaboration import reachable_page_text
9from document_model import DEFAULT_CONTEXT12from document_model import DEFAULT_CONTEXT
...@@ -50,6 +53,19 @@ class NativeHistoryTests(unittest.TestCase):...@@ -50,6 +53,19 @@ class NativeHistoryTests(unittest.TestCase):
50 self.logs['r0'] = [{'event': 'ready'}, {'event': 'read', 'text': text,53 self.logs['r0'] = [{'event': 'ready'}, {'event': 'read', 'text': text,
51 'started_us': 40, 'finished_us': 50}, {'event': 'done'}]54 'started_us': 40, 'finished_us': 50}, {'event': 'done'}]
5255
56 def test_incomplete_capture_requires_explicit_retention_mode(self):
57 self.logs['w0'].pop()
58 with self.assertRaisesRegex(AssertionError, 'Incomplete client log'):
59 edit_history(self.logs, 2)
60 self.assertEqual(len(edit_history(self.logs, 2, partial=True)[0]), 2)
61 self.logs['w1'][2]['operation'] = 1
62 with self.assertRaisesRegex(AssertionError, 'acknowledgements'):
63 edit_history(self.logs, 2, partial=True)
64 self.logs['w1'][2]['operation'] = 0
65 self.logs['w1'].pop(2)
66 with self.assertRaisesRegex(AssertionError, 'reader observed'):
67 edit_history(self.logs, 2, partial=True)
68
53 def test_counter_renumbering_preserves_one_content_history(self):69 def test_counter_renumbering_preserves_one_content_history(self):
54 commits, text = edit_history(self.logs, 1)70 commits, text = edit_history(self.logs, 1)
55 self.assertEqual(len(commits), 2)71 self.assertEqual(len(commits), 2)
...@@ -136,3 +152,35 @@ class NativeHistoryTests(unittest.TestCase):...@@ -136,3 +152,35 @@ class NativeHistoryTests(unittest.TestCase):
136 for changed in (body + native, body.replace(native, ''), body.replace('bold', 'normal')):152 for changed in (body + native, body.replace(native, ''), body.replace('bold', 'normal')):
137 page.write_text(f'<Page xmlns="http://schemas.microsoft.com/office/onenote/2010/onenote"><Outline>{changed}</Outline></Page>')153 page.write_text(f'<Page xmlns="http://schemas.microsoft.com/office/onenote/2010/onenote"><Outline>{changed}</Outline></Page>')
138 with self.assertRaises(AssertionError): verify_capture(root, root)154 with self.assertRaises(AssertionError): verify_capture(root, root)
155
156
157class FailureArtifacts(unittest.TestCase):
158 def test_failed_clients_preserve_native_logs_without_masking_the_failure(self):
159 @contextmanager
160 def failed_clients(*args, **kwargs):
161 yield {}
162 raise RuntimeError('Rust client exited')
163
164 with tempfile.TemporaryDirectory() as directory:
165 output = Path(directory)
166 (output / 'run.json').write_text(json.dumps({'maintenance': False}))
167 shared = output / 'shared'
168 shared.mkdir()
169 clients = [{'name': f'n{i}', 'folder': output / f'n{i}'} for i in range(2)]
170 for client in clients: client['folder'].mkdir()
171
172 def capture(remote, local, name):
173 self.assertTrue(remote.endswith('\\outbox\\7\\events.jsonl'))
174 if name == 'n0': raise OSError('Native client unavailable')
175 local.write_text('{"operation":0}\n')
176 return {'error': None}
177
178 with patch.object(native_stress, 'running_clients', failed_clients), \
179 patch.object(native_stress.windows, 'do_health', return_value={'utc_us': 0}), \
180 patch.object(native_stress.windows, 'do_cmd', return_value={'stdout': 'ready editing'}), \
181 patch.object(native_stress.windows, 'do_get', side_effect=capture):
182 with self.assertRaisesRegex(RuntimeError, 'Rust client exited'):
183 native_stress.exercise(output, shared, clients, lambda *a, **kw: 7,
184 lambda *a: None, lambda *a: None, lambda *a: None, 40, 1, 4, 4)
185 self.assertEqual(json.loads((output / 'n0/stress-capture.json').read_text())['error'], 'Native client unavailable')
186 self.assertEqual((output / 'n1/stress-events.jsonl').read_text(), '{"operation":0}\n')
tools/test_notebook_editor.py created+237
...@@ -0,0 +1,237 @@
1import json
2from pathlib import Path
3import tempfile
4from threading import Thread
5import unittest
6from unittest.mock import patch
7from urllib.error import HTTPError
8from urllib.parse import urlencode
9from urllib.request import Request, urlopen
10
11from document_model import view, walk
12import notebook_editor as editor
13from random_edit_campaign import export, verify
14
15
16class EditorTest(unittest.TestCase):
17 def setUp(self):
18 self.temporary = tempfile.TemporaryDirectory()
19 self.addCleanup(self.temporary.cleanup)
20 source = editor.ROOT / 'corpus/native/20260905-05/snapshots/06-attachment/notebook'
21 self.session = editor.Session(source, Path(self.temporary.name) / 'session')
22 self.server = editor.ThreadingHTTPServer(('127.0.0.1', 0), editor.Handler)
23 self.server.session = self.session
24 self.thread = Thread(target=self.server.serve_forever)
25 self.thread.start()
26 self.addCleanup(self.stop)
27 self.url = f'http://127.0.0.1:{self.server.server_port}'
28 self.file = self.session.output / 'notebook/synthetic.one'
29 self.before = export(self.file)
30 self.row = json.loads((self.session.output / 'g/0/report/pages.json').read_text())[0]
31 _, revision = view(self.before[0], self.row['space'])
32 self.oid, node = next((oid, node) for oid, node in walk(revision, self.row['object'])
33 if node['kind']['type'] == 'RichText' and node['kind']['text'].startswith('Fictitious'))
34 self.selection = {'generation': 0, 'page': self.row['report'], 'object': self.oid, 'run': 0, 'action': 'text'}
35 self.text = self.before[1][self.row['space']][self.row['revision']][self.oid][0]['text']
36
37 def stop(self):
38 self.server.shutdown()
39 self.thread.join()
40 self.server.server_close()
41
42 def request(self, path, data=None):
43 request = Request(self.url + path, data=json.dumps(data).encode() if data is not None else None,
44 headers={'Content-Type': 'application/json', 'X-OneNote-Diagnostic': '1'})
45 try:
46 response = urlopen(request)
47 except HTTPError as error:
48 response = error
49 with response:
50 return response.status, json.loads(response.read())
51
52 def test_unicode_stale_snapshot_and_unrelated_content(self):
53 status, checked = self.request('/api/run?' + urlencode(self.selection))
54 self.assertEqual((status, checked['ok'], checked['text']), (200, True, self.text))
55 replacement = self.text + ' café 🦀 e\u0301 <diagnostic>'
56 status, saved = self.request('/api/save', {**self.selection, 'replacement': replacement})
57 self.assertEqual((status, saved['state'], saved['ok']), (200, 'Committed', True))
58 intent, outcome = [json.loads(s) for s in (self.session.output / 'operations.jsonl').read_text().splitlines()]
59 edit = intent['edit']
60 verify(self.before, export(self.file), {'page': self.row['object'], 'space': edit['space'], 'object': self.oid,
61 'range': [edit['action']['start'], edit['action']['end']], 'replacement': replacement, 'run_start': edit['action']['start'],
62 'run_before': self.text, 'started_ms': intent['started_ms'], 'finished_ms': outcome['finished_ms']})
63 after = self.file.read_bytes()
64 fresh = self.session.output / 'fresh-report'
65 editor.generate(self.session.output / 'g/1/snapshot', fresh, editable=True)
66 cached = self.session.output / 'g/1/report'
67 self.assertEqual({p.relative_to(fresh): p.read_bytes() for p in fresh.rglob('*') if p.is_file()},
68 {p.relative_to(cached): p.read_bytes() for p in cached.rglob('*') if p.is_file()})
69 status, stale = self.request('/api/save', {**self.selection, 'replacement': 'another draft'})
70 self.assertEqual((status, stale['state'], stale['kind']), (409, 'NotCommitted', 'ResourceBusy'))
71 self.assertEqual(self.file.read_bytes(), after)
72 self.assertEqual((self.session.output / 'g/0/snapshot/synthetic.one').read_bytes(),
73 (editor.ROOT / 'corpus/native/20260905-05/snapshots/06-attachment/notebook/synthetic.one').read_bytes())
74 self.assertEqual((self.session.output / 'g/1/snapshot/synthetic.one').stat().st_ino,
75 (self.session.output / 'g/2/snapshot/synthetic.one').stat().st_ino)
76 with urlopen(self.url + saved['location']) as response:
77 html = response.read().decode()
78 self.assertIn('&lt;diagnostic&gt;', html)
79 self.assertNotIn('<diagnostic>', html)
80
81 def test_invalid_edits_and_selection_never_write(self):
82 before = self.file.read_bytes()
83 for changes in [{'replacement': 'first\nsecond'}, {'replacement': '\ud800'},
84 {'run': -1}, {'generation': True}, {'object': 'missing'}, {'extra': 'field'}]:
85 status, result = self.request('/api/save', {**self.selection, 'replacement': 'changed', **changes})
86 self.assertEqual((status, result['state']), (422, 'NotCommitted'))
87 self.assertEqual(self.file.read_bytes(), before)
88
89 def test_unknown_response_does_not_replay_a_real_commit(self):
90 original = editor.bridge
91 calls = []
92 def uncertain(mode, *args):
93 result = original(mode, *args)
94 if mode == 'commit':
95 calls.append(result)
96 self.assertTrue(result['ok'])
97 return {'ok': False, 'state': 'Unknown', 'error': 'Simulated lost outcome'}
98 return result
99 with patch.object(editor, 'bridge', side_effect=uncertain):
100 status, result = self.request('/api/save', {**self.selection, 'replacement': self.text + ' once'})
101 self.assertEqual((status, result['state'], len(calls)), (503, 'Unknown', 1))
102 after = export(self.file)
103 rid, _ = view(after[0], self.row['space'])
104 self.assertEqual(after[1][self.row['space']][rid][self.oid][0]['text'], self.text + ' once')
105 status, stale = self.request('/api/save', {**self.selection, 'replacement': self.text + ' twice'})
106 self.assertEqual((status, stale['kind']), (409, 'ResourceBusy'))
107
108 def test_refresh_failure_preserves_committed_outcome(self):
109 with patch.object(self.session, 'snapshot', side_effect=OSError('Report storage unavailable')):
110 status, result = self.request('/api/save', {**self.selection, 'replacement': self.text + ' saved'})
111 self.assertEqual((status, result['state'], result['ok']), (503, 'Committed', False))
112 self.assertIn('Report storage', result['report_error'])
113 after = export(self.file)
114 rid, _ = view(after[0], self.row['space'])
115 self.assertEqual(after[1][self.row['space']][rid][self.oid][0]['text'], self.text + ' saved')
116 with urlopen(self.url + '/latest?' + urlencode(self.selection)) as response:
117 self.assertIn(' saved', response.read().decode())
118
119 def test_document_actions_preserve_placement_unicode_and_unselected_formatting(self):
120 status, page = self.request('/api/page?' + urlencode(self.selection))
121 self.assertEqual(status, 200)
122 outline = next(target for target in page['targets'] if target['label'].startswith('Outline'))
123 base = {'generation': 0, 'page': self.row['report'], 'object': outline['object'], 'action': 'paragraph',
124 'before': outline['children'][0]['object'], 'text': 'A🦀 café\rsecond line', 'author': 'Diagnostic test'}
125 status, saved = self.request('/api/save', base)
126 self.assertEqual((status, saved['state']), (200, 'Committed'))
127 after = export(self.file)
128 _, revision = view(after[0], self.row['space'])
129 children = revision['nodes'][outline['object']]['children']
130 self.assertEqual(children[1], base['before'])
131 oid, node = next((key, node) for key, node in walk(revision, children[0]) if node['kind']['type'] == 'RichText')
132 self.assertEqual(node['kind']['text'], base['text'])
133 self.assertEqual(self.before[2], after[2])
134 attrs = [{'Bold': True}, {'Italic': True}, {'Underline': True}, {'Strike': True},
135 {'Superscript': True}, {'Subscript': False}, {'Font': 'Arial'}, {'FontSize': 20.5},
136 {'Color': [18, 52, 86]}, {'Highlight': [255, 255, 0]}]
137 selected = {'generation': 1, 'page': saved['location'].split('/')[-1], 'object': oid,
138 'run': 0, 'action': 'format', 'start': 1, 'end': 3, 'attributes': attrs}
139 status, formatted = self.request('/api/save', selected)
140 self.assertEqual((status, formatted['state']), (200, 'Committed'))
141 after_format = export(self.file)
142 rid, _ = view(after_format[0], self.row['space'])
143 runs = after_format[1][self.row['space']][rid][oid]
144 self.assertEqual([run['text'] for run in runs], ['A', '🦀', ' café\rsecond line'])
145 original_rid, _ = view(after[0], self.row['space'])
146 original_format = after[1][self.row['space']][original_rid][oid][0]['format']
147 self.assertEqual(runs[0]['format'], original_format)
148 self.assertEqual(runs[2]['format'], original_format)
149 for key, value in {'bold': True, 'italic': True, 'underline': True, 'strike': True,
150 'superscript': True, 'subscript': False, 'font': 'Arial', 'font_size': 20.5,
151 'color': 0x563412, 'highlight': 0xffff}.items():
152 self.assertEqual(runs[1]['format'][key], value, key)
153 status, cleared = self.request('/api/save', {**selected, 'generation': 2, 'run': 1, 'start': 0, 'end': 2,
154 'attributes': [{'Subscript': True}, {'Color': None}, {'Highlight': None}]})
155 self.assertEqual(status, 200)
156 cleared_model = export(self.file)
157 rid, _ = view(cleared_model[0], self.row['space'])
158 fmt = cleared_model[1][self.row['space']][rid][oid][1]['format']
159 self.assertEqual((fmt['superscript'], fmt['subscript'], fmt['color'], fmt['highlight']), (False, True, 0xff000000, 0xff000000))
160 status, added = self.request('/api/save', {'generation': 3, 'page': selected['page'], 'action': 'outline',
161 'object': self.row['object'], 'x': 216.5, 'y': 360,
162 'text': 'New outline 🦀', 'author': 'Diagnostic test'})
163 self.assertEqual(status, 200)
164 _, revision = view(export(self.file)[0], self.row['space'])
165 new_outline = revision['nodes'][revision['nodes'][self.row['object']]['children'][-1]]
166 self.assertEqual(new_outline['kind']['type'], 'Outline')
167 self.assertEqual((new_outline['layout']['x'], new_outline['layout']['y']), (216.5, 360))
168 committed = self.file.read_bytes()
169 for request in (base, selected):
170 status, stale = self.request('/api/save', request)
171 self.assertEqual((status, stale['state'], stale['kind']), (409, 'NotCommitted', 'ResourceBusy'))
172 self.assertEqual(self.file.read_bytes(), committed)
173
174 def test_document_rejections_do_not_publish(self):
175 before = self.file.read_bytes()
176 common = {'generation': 0, 'page': self.row['report'], 'object': self.oid}
177 requests = [
178 {**common, 'action': 'format', 'run': 0, 'start': 0, 'end': 1, 'attributes': []},
179 {**common, 'action': 'format', 'run': 0, 'start': True, 'end': 1, 'attributes': [{'Bold': True}]},
180 {**common, 'action': 'format', 'run': 0, 'start': 0, 'end': 99999, 'attributes': [{'Bold': True}]},
181 {**common, 'action': 'format', 'run': 0, 'start': 0, 'end': 1, 'attributes': [{'FontSize': 144}]},
182 {**common, 'action': 'format', 'run': 0, 'start': 0, 'end': 1, 'attributes': [{'Bold': True}, {'Bold': False}]},
183 {**common, 'action': 'outline', 'x': 1, 'y': 1, 'text': 'No page parent', 'author': 'test'},
184 {**common, 'action': 'paragraph', 'before': None, 'text': 'No paragraph parent', 'author': 'test'},
185 ]
186 for request in requests:
187 status, result = self.request('/api/save', request)
188 self.assertEqual((status, result['state']), (422, 'NotCommitted'))
189 self.assertEqual(self.file.read_bytes(), before)
190
191 def test_uncertain_insertion_has_one_publication_and_a_stale_retry(self):
192 request = {'generation': 0, 'page': self.row['report'], 'object': self.row['object'],
193 'action': 'outline', 'x': 144, 'y': 288, 'text': 'Only once 🦀', 'author': 'test'}
194 original = editor.bridge
195 def uncertain(mode, *args):
196 result = original(mode, *args)
197 return {'ok': False, 'state': 'Unknown'} if mode == 'commit' and result['ok'] else result
198 with patch.object(editor, 'bridge', side_effect=uncertain):
199 status, result = self.request('/api/save', request)
200 self.assertEqual((status, result['state']), (503, 'Unknown'))
201 status, result = self.request('/api/save', request)
202 self.assertEqual((status, result['kind']), (409, 'ResourceBusy'))
203 _, revision = view(export(self.file)[0], self.row['space'])
204 self.assertEqual(sum(n['kind'].get('text') == 'Only once 🦀' for _, n in walk(revision, self.row['object'])), 1)
205
206 def test_generated_fields_are_read_only(self):
207 source = editor.ROOT / 'corpus/m6/native-structure-01/notebook'
208 session = editor.Session(source, Path(self.temporary.name) / 'fields')
209 self.server.session = session
210 pages = json.loads((session.output / 'g/0/report/pages.json').read_text())
211 document = export(source / 'synthetic.one')[0]
212 for row in pages:
213 _, revision = view(document, row['space'])
214 fields = [oid for oid, node in walk(revision, row['object'])
215 if node['kind']['type'] == 'RichText' and node['kind']['boilerplate']]
216 if fields: break
217 self.assertTrue(fields)
218 status, result = self.request('/api/run?' + urlencode({'generation': 0, 'page': row['report'], 'object': fields[0], 'run': 0}))
219 self.assertEqual((status, result['ok']), (422, False))
220
221 def test_templates_keep_reader_only_controls(self):
222 session = editor.Session(editor.ROOT / 'corpus/m6/native-template-controls-01/notebook',
223 Path(self.temporary.name) / 'template')
224 self.server.session = session
225 pages = json.loads((session.output / 'g/0/report/pages.json').read_text())
226 protected = [row for row in pages if row['category'] == 'Default page template']
227 self.assertTrue(protected)
228 for row in protected:
229 status, result = self.request('/api/run?' + urlencode({'generation': 0, 'page': row['report'], 'object': row['object'], 'run': 0}))
230 self.assertEqual(status, 422)
231 self.assertIn('active page', result['error'])
232 html = (session.output / 'g/0/report' / row['report']).read_text()
233 self.assertNotIn('src="/editor.js"', html)
234
235
236if __name__ == '__main__':
237 unittest.main()
tools/test_notebook_report.py+33
...@@ -4,7 +4,9 @@ import json...@@ -4,7 +4,9 @@ import json
4from pathlib import Path4from pathlib import Path
5import re5import re
6from tempfile import TemporaryDirectory6from tempfile import TemporaryDirectory
7import shutil
7import unittest8import unittest
9from unittest.mock import patch
8import xml.etree.ElementTree as ET10import xml.etree.ElementTree as ET
911
10from PIL import Image12from PIL import Image
...@@ -32,6 +34,37 @@ class NotebookReportTest(unittest.TestCase):...@@ -32,6 +34,37 @@ class NotebookReportTest(unittest.TestCase):
32 references = [a['path'] for p in (output / 'model').glob('*/assets.json') for a in json.loads(p.read_text())]34 references = [a['path'] for p in (output / 'model').glob('*/assets.json') for a in json.loads(p.read_text())]
33 self.assertIn('../../assets/' + original.name, references)35 self.assertIn('../../assets/' + original.name, references)
3436
37 def test_reused_models_and_assets_match_a_fresh_report_after_source_order_changes(self):
38 fixture = Path(__file__).resolve().parent.parent / 'corpus/m6/native-features-01/notebook'
39 with TemporaryDirectory() as temporary:
40 root = Path(temporary)
41 source = root / 'notebook'
42 shutil.copytree(fixture, source)
43 generate(source, root / 'first')
44 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')):
46 generate(source, root / 'same', previous=root / 'first')
47 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
51 with patch('notebook_report.subprocess.run', wraps=original) as run:
52 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'])
54 generate(source, root / 'fresh')
55 self.assertEqual({p.relative_to(root / 'fresh'): p.read_bytes() for p in (root / 'fresh').rglob('*') if p.is_file()},
56 {p.relative_to(root / 'changed'): p.read_bytes() for p in (root / 'changed').rglob('*') if p.is_file()})
57 for name, contents in before.items():
58 self.assertEqual((root / 'first' / name).read_bytes(), contents)
59 old_index = next(i for i, row in enumerate(json.loads((root / 'first/source.json').read_text())) if row['path'] == 'synthetic.one')
60 new_index = next(i for i, row in enumerate(json.loads((root / 'changed/source.json').read_text())) if row['path'] == 'synthetic.one')
61 self.assertEqual((root / f'first/model/{old_index}/document.json').stat().st_ino,
62 (root / f'changed/model/{new_index}/document.json').stat().st_ino)
63 (source / 'a.one').unlink()
64 with patch('notebook_report.subprocess.run', side_effect=AssertionError('Unchanged model exported again')):
65 generate(source, root / 'deleted', previous=root / 'changed')
66 self.assertEqual(before, {p.relative_to(root / 'deleted'): p.read_bytes() for p in (root / 'deleted').rglob('*') if p.is_file()})
67
3568
36if __name__ == '__main__':69if __name__ == '__main__':
37 unittest.main()70 unittest.main()
tools/test_offline_confirmation.py created+88
...@@ -0,0 +1,88 @@
1import hashlib
2import json
3from pathlib import Path
4import tempfile
5import unittest
6from unittest.mock import patch
7
8from verify_offline_confirmation import verify
9
10
11class ConfirmationOracle(unittest.TestCase):
12 def test_native_images_must_match_the_confirmation_and_preserved_native_prefix(self):
13 with tempfile.TemporaryDirectory() as folder:
14 root = Path(folder)
15 cold = root / 'cold'
16 (root / 'rust/confirmations').mkdir(parents=True)
17 (root / 'n0').mkdir()
18 (cold / 'results/123-456').mkdir(parents=True)
19 (root / 'run.json').write_text(json.dumps(dict(rust_writers=1, rust_readers=0, stress_clients=1, stress_operations=1)))
20 rows = [dict(event='ready', pid=123), dict(event='remote_attempt', state='Unknown', revision='revision', space='space'),
21 dict(event='remote_confirm', capture='123-456.one', revisions={'space': ['revision']}, text='Concurrent edits: [w0:0]')]
22 (root / 'rust/w0.jsonl').write_text('\n'.join(map(json.dumps, rows)))
23 (root / 'n0/stress-events.jsonl').write_text(json.dumps(dict(operation=0, token=' [n0:0]', before='Native 0:')))
24 snapshot = root / 'rust/confirmations/123-456.one'
25 snapshot.write_bytes(b'captured snapshot')
26 sha = hashlib.sha256(snapshot.read_bytes()).hexdigest()
27 (cold / 'run.json').write_text(json.dumps(dict(inputs={'123-456.one': sha})))
28 result = dict(name='123-456', error=None, pages=1, source_sha256=sha, seconds=1)
29 (cold / 'results.json').write_text(json.dumps(result))
30 (cold / 'teardown.json').write_text(json.dumps(dict(absent=True)))
31 page = cold / 'results/123-456/page-0.xml'
32 def xml(text, native):
33 return f'<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote"><one:Outline><one:OEChildren><one:OE><one:T>{text}</one:T></one:OE><one:OE><one:T>{native}</one:T></one:OE></one:OEChildren></one:Outline></one:Page>'
34 page.write_text(xml('Concurrent edits: [w0:0]', 'Native 0:'))
35 with patch('verify_offline_confirmation.publication_links') as ledger:
36 self.assertEqual(verify(root, cold)['validated_paragraphs'], 2)
37 self.assertTrue(ledger.called)
38 config = json.loads((root / 'run.json').read_text())
39 config['stress_operations'] = 2
40 (root / 'run.json').write_text(json.dumps(config))
41 with self.assertRaisesRegex(AssertionError, 'Missing native acknowledgements'):
42 verify(root, cold)
43 self.assertFalse(verify(root, cold, partial=True)['complete_workload'])
44 config['stress_operations'] = 1
45 (root / 'run.json').write_text(json.dumps(config))
46 for text, native in [('Concurrent edits:', 'Native 0:'), ('Concurrent edits: [w0:0]', 'Native 0: [n0:1]')]:
47 page.write_text(xml(text, native))
48 with self.assertRaises(AssertionError): verify(root, cold)
49 page.write_text(xml('Concurrent edits: [w0:0]', 'Native 0: [n0:0]'))
50 self.assertEqual(verify(root, cold)['native_images'], 1)
51 snapshot.write_bytes(b'changed')
52 with self.assertRaises(AssertionError): verify(root, cold)
53 snapshot.write_bytes(b'captured snapshot')
54 config['document_operations'] = True
55 (root / 'run.json').write_text(json.dumps(config))
56 document = dict(text='x', runs=[dict(text='x', bold=True, size=18, color=0x563412)])
57 rows[1]['document_changes'] = {'target': document}
58 rows[2]['started_us'] = 200
59 observed = dict(event='read', finished_us=100, text=rows[2]['text'], documents={'target': document})
60 rows.insert(2, observed)
61 (root / 'rust/w0.jsonl').write_text('\n'.join(map(json.dumps, rows)))
62 markup = '<one:OE><one:T><![CDATA[<span style="font-weight:bold;font-size:18pt;color:#123456">x</span>]]></one:T></one:OE>'
63 content = xml('Concurrent edits: [w0:0]', 'Native 0:').replace('</one:OEChildren>', markup + '</one:OEChildren>')
64 page.write_text(content)
65 with patch('offline_document_history.document_history', return_value={'target': {'text': 'x', 'format': {'receipt_revision': 'revision'}}}) as documents:
66 result = verify(root, cold)
67 self.assertEqual(result['validated_paragraphs'], 3)
68 self.assertEqual(result['native_intended_format_checks'], 3)
69 documents.assert_called_once_with({'w0': rows}, 1)
70 rows[3]['revisions'] = {'space': ['current']}
71 rows[3]['current_revisions'] = {'space': 'current'}
72 documents.return_value['target']['format']['receipt_revision'] = 'current'
73 (root / 'rust/w0.jsonl').write_text('\n'.join(map(json.dumps, rows)))
74 self.assertEqual(verify(root, cold)['confirmed_revision'], 'current')
75 rows[3]['current_revisions'] = {'space': 'unrelated'}
76 (root / 'rust/w0.jsonl').write_text('\n'.join(map(json.dumps, rows)))
77 with self.assertRaisesRegex(AssertionError, 'current effect-confirmation'): verify(root, cold)
78 rows[3]['current_revisions'] = {'space': 'current'}
79 (root / 'rust/w0.jsonl').write_text('\n'.join(map(json.dumps, rows)))
80 page.write_text(content.replace('18pt', '19pt'))
81 with self.assertRaisesRegex(AssertionError, 'font size'): verify(root, cold)
82 page.write_text(content)
83 observed['documents'] = {}
84 (root / 'rust/w0.jsonl').write_text('\n'.join(map(json.dumps, rows)))
85 with self.assertRaisesRegex(AssertionError, 'omitted'): verify(root, cold)
86
87
88if __name__ == '__main__': unittest.main()
tools/test_offline_document_history.py created+151
...@@ -0,0 +1,151 @@
1import copy
2import unittest
3import uuid
4from unittest.mock import patch
5
6from offline_document_history import document_history, identity, verify_model, verify_native
7
8
9class DocumentHistoryTests(unittest.TestCase):
10 def setUp(self):
11 self.logs = {'w0': [{'event': 'ready', 'document_operations': True}]}
12 events = self.logs['w0']
13 observed = {}
14 previous = None
15 for operation in range(2):
16 insertion = {'guid': list(uuid.UUID(int=operation+1).bytes_le), 'text': f'Document w0:{operation} 🦀',
17 'parent': 'page' if operation == 0 else identity(previous, 1), 'author': 'Offline document writer',
18 'placement': {'Outline': {'x': 144, 'y': 144}} if operation == 0 else {'Paragraph': {'before': None}}}
19 previous = insertion
20 target = identity(insertion, 2)
21 for step, kind in enumerate(('insert', 'format')):
22 timestamp = 10 + operation*30 + step*10
23 local_id = operation*3 + step + 2
24 event = {'event': 'local_document_commit', 'id': local_id, 'operation': operation, 'kind': kind,
25 'space': 'space', 'object': target, 'text': insertion['text'], 'insertion': insertion if step == 0 else None,
26 'range': [1, len(insertion['text'].encode('utf-16-le'))//2-2],
27 'attributes': [{'Bold': True}, {'FontSize': 18+operation}, {'Color': [18, 52, 86]}],
28 'started_us': timestamp-2, 'finished_us': timestamp-1}
29 events.append(event)
30 runs = []
31 for index, char in enumerate(insertion['text']):
32 selected = kind == 'format' and 0 < index < len(insertion['text'])-1
33 runs.append({'text': char, 'bold': selected, 'size': 18+operation if selected else 11,
34 'color': 0x563412 if selected else 0xff000000})
35 observed[target] = {'text': insertion['text'], 'runs': runs}
36 revision = f'revision-{local_id}'
37 events.append({'event': 'remote_attempt', 'revision': revision, 'state': 'Committed',
38 'document_changes': {target: copy.deepcopy(observed[target])},
39 'documents': copy.deepcopy(observed), 'started_us': timestamp, 'finished_us': timestamp+1})
40 events.append({'event': 'document_receipt', 'id': local_id, 'revision': revision, 'at_us': timestamp+2})
41 events.extend({'event': 'reopened_document_receipt', 'id': row['id'], 'revision': row['revision']}
42 for row in list(events) if row['event'] == 'document_receipt')
43 read = {'event': 'read', 'started_us': 100, 'finished_us': 101, 'documents': observed}
44 events.extend([read, {'event': 'done'}])
45 self.logs['r0'] = [{'event': 'ready'}, copy.deepcopy(read), {'event': 'done'}]
46
47 def test_document_receipts_and_reader_states_match_the_intents(self):
48 documents = document_history(self.logs, 2)
49 self.assertEqual(len(documents), 2)
50 paragraphs = [[(char, {'bold': bold, 'font_size': size, 'color': 'automatic' if color == 0xff000000 else '#123456'})
51 for char, bold, size, color in row['new']] for row in documents.values()]
52 self.assertEqual(verify_native(paragraphs, documents), sum(len(row['new'])*3 for row in documents.values()))
53 paragraphs[0][1][1]['font_size'] = 19
54 with self.assertRaisesRegex(AssertionError, 'font size'): verify_native(paragraphs, documents)
55
56 def test_missing_intents_receipts_or_reopen_records_are_rejected(self):
57 for name in ('local_document_commit', 'document_receipt', 'reopened_document_receipt', 'remote_attempt'):
58 logs = copy.deepcopy(self.logs)
59 events = logs['w0']
60 events.remove(next(row for row in events if row['event'] == name))
61 with self.subTest(event=name), self.assertRaises(AssertionError): document_history(logs, 2)
62
63 def test_retired_format_receipt_requires_current_revision_and_exact_observed_effect(self):
64 events = self.logs['w0']
65 attempt = next(row for row in events if row['event'] == 'remote_attempt' and row['revision'] == 'revision-3')
66 attempt['state'] = 'Unknown'
67 receipt = next(row for row in events if row['event'] == 'document_receipt' and row['id'] == 3)
68 receipt.update(revision='current-revision', at_us=25)
69 next(row for row in events if row['event'] == 'reopened_document_receipt' and row['id'] == 3)['revision'] = receipt['revision']
70 read = dict(event='read', started_us=21, finished_us=22, text='Concurrent edits:', documents=copy.deepcopy(attempt['documents']))
71 confirmation = dict(event='remote_confirm', started_us=23, finished_us=24, state='Committed', text=read['text'],
72 revisions={'space': ['current-revision']}, current_revisions={'space': 'current-revision'})
73 index = events.index(receipt)
74 events[index:index] = [read, confirmation]
75 result = document_history(self.logs, 2)
76 target, = attempt['document_changes']
77 self.assertEqual(result[target]['format']['receipt_revision'], 'current-revision')
78 for field, value in [('current_revisions', {'space': 'unrelated'}),
79 ('revisions', {'space': ['revision-3', 'current-revision']}),
80 ('state', 'NotCommitted')]:
81 original = confirmation[field]
82 confirmation[field] = value
83 with self.subTest(field=field), self.assertRaises(AssertionError): document_history(self.logs, 2)
84 confirmation[field] = original
85 read['documents'][target]['runs'][1]['size'] = 12
86 with self.assertRaisesRegex(AssertionError, 'differs from the uncertain formatting intent'): document_history(self.logs, 2)
87
88 def test_model_rejects_reordered_or_reparented_insertions(self):
89 documents = document_history(self.logs, 2)
90 first, second = [row['insertion'] for row in documents.values()]
91 outline, paragraph, appended = identity(first, 1), identity(first, 3), identity(second, 1)
92 nodes = {key: {'structure': [], 'content': [], 'children': [], 'kind': {}, 'layout': {}}
93 for key in ['page', outline, paragraph, appended, *documents]}
94 nodes['page']['children'] = [outline]
95 nodes[outline].update(children=[paragraph, appended], layout={'x': 144, 'y': 144})
96 for parent, (target, document) in zip([paragraph, appended], documents.items(), strict=True):
97 nodes[parent]['content'] = [target]
98 nodes[target]['kind'] = {'text': document['text']}
99 revision = {'nodes': nodes}
100 with patch('offline_document_history.ordered_pages', return_value=[('space', 'revision', revision, 'page')]):
101 verify_model({}, documents)
102 nodes[outline]['children'].reverse()
103 with self.assertRaisesRegex(AssertionError, 'order'): verify_model({}, documents)
104 nodes[outline]['children'] = [paragraph]
105 nodes['page']['children'].append(appended)
106 with self.assertRaises(AssertionError): verify_model({}, documents)
107 nodes[outline]['children'] = [paragraph, appended]
108 nodes['page']['children'] = [outline]
109 nodes[paragraph]['content'].append(identity(second, 2))
110 with self.assertRaisesRegex(AssertionError, 'content'): verify_model({}, documents)
111
112 def test_wrong_attributes_targets_and_unconfirmed_receipts_are_rejected(self):
113 for event, field, value in [('local_document_commit', 'object', 'wrong'),
114 ('local_document_commit', 'text', 'changed'),
115 ('document_receipt', 'revision', 'wrong'),
116 ('remote_attempt', 'state', 'Unknown'),
117 ('remote_attempt', 'state', 'NotCommitted')]:
118 logs = copy.deepcopy(self.logs)
119 next(row for row in logs['w0'] if row['event'] == event)[field] = value
120 with self.subTest(event=event, field=field), self.assertRaises(AssertionError): document_history(logs, 2)
121 for field, value in [('attributes', [{'Bold': False}]), ('range', [0, 1])]:
122 logs = copy.deepcopy(self.logs)
123 next(row for row in logs['w0'] if row.get('kind') == 'format')[field] = value
124 with self.subTest(field=field), self.assertRaises(AssertionError): document_history(logs, 2)
125
126 def test_readers_cannot_lose_revert_or_invent_document_content(self):
127 for mutation in ('missing', 'partial', 'future', 'reverted'):
128 logs = copy.deepcopy(self.logs)
129 read = logs['r0'][1]
130 target = next(iter(read['documents']))
131 if mutation == 'missing': del read['documents'][target]
132 elif mutation == 'partial': read['documents'][target]['runs'][1]['bold'] = False
133 elif mutation == 'future': read.update(started_us=0, finished_us=1)
134 else:
135 old = copy.deepcopy(read)
136 old.update(started_us=102, finished_us=103)
137 for run in old['documents'][target]['runs']:
138 run.update(bold=False, size=11, color=0xff000000)
139 logs['r0'].insert(2, old)
140 with self.subTest(mutation=mutation), self.assertRaises(AssertionError): document_history(logs, 2)
141
142 def test_an_uncertain_document_attempt_cannot_be_replayed_under_another_revision(self):
143 logs = copy.deepcopy(self.logs)
144 first = copy.deepcopy(next(row for row in logs['w0'] if row['event'] == 'remote_attempt'))
145 first.update(state='Unknown', revision='earlier-uncertain', started_us=8, finished_us=9)
146 logs['w0'].insert(2, first)
147 with self.assertRaisesRegex(AssertionError, 'more than once'): document_history(logs, 2)
148
149
150if __name__ == '__main__':
151 unittest.main()
tools/test_offline_history.py created+156
...@@ -0,0 +1,156 @@
1import copy
2import unittest
3from native_stress import edit_history
4
5
6class OfflineHistoryTests(unittest.TestCase):
7 def setUp(self):
8 base = 'Concurrent edits:'
9 self.logs = {}
10 for actor, started, before in [('w1', 5, base), ('w0', 10, base + ' [w1:0]')]:
11 token = f' [{actor}:0]'
12 self.logs[actor] = [
13 {'event': 'ready', 'offline': True},
14 {'event': 'local_commit', 'id': 1, 'operation': 0, 'before': base, 'token': token, 'started_us': 1, 'finished_us': 2},
15 {'event': 'read', 'text': before, 'started_us': started - 1, 'finished_us': started},
16 {'event': 'remote_attempt', 'revision': actor, 'before': before, 'after': before + token, 'state': 'Committed', 'started_us': started, 'finished_us': started + 1},
17 {'event': 'remote_receipt', 'id': 1, 'revision': actor, 'at_us': started + 2},
18 {'event': 'reopened_receipt', 'id': 1, 'revision': actor},
19 {'event': 'done'},
20 ]
21 self.logs['r0'] = [{'event': 'ready'}, {'event': 'read', 'text': base + ' [w1:0] [w0:0]', 'started_us': 14, 'finished_us': 15}, {'event': 'done'}]
22
23 def test_private_local_branches_require_one_separate_remote_history(self):
24 commits, text = edit_history(self.logs, 1, offline=True)
25 self.assertEqual([event['token'] for event in commits], [' [w1:0]', ' [w0:0]'])
26 self.assertEqual(text, self.logs['r0'][1]['text'])
27
28 def test_false_receipts_lost_local_intents_and_changed_reopen_state_fail(self):
29 for index, field, value in [(1, 'id', 2), (1, 'token', ' [w0:9]'), (3, 'state', 'Unknown'),
30 (3, 'after', 'Concurrent edits: [w1:0]'), (4, 'at_us', 0),
31 (5, 'revision', 'changed'), (3, 'revision', 'missing')]:
32 with self.subTest(index=index, field=field):
33 logs = copy.deepcopy(self.logs)
34 logs['w0'][index][field] = value
35 with self.assertRaises(AssertionError): edit_history(logs, 1, offline=True)
36 for event in ('local_commit', 'remote_receipt', 'remote_attempt', 'reopened_receipt'):
37 logs = copy.deepcopy(self.logs)
38 logs['w0'] = [item for item in logs['w0'] if item['event'] != event]
39 with self.assertRaises(AssertionError): edit_history(logs, 1, offline=True)
40
41 def test_stale_reads_future_local_tokens_and_duplicate_acknowledgements_fail(self):
42 logs = copy.deepcopy(self.logs)
43 logs['r0'][1]['text'] = 'Concurrent edits:'
44 with self.assertRaises(AssertionError): edit_history(logs, 1, offline=True)
45 logs = copy.deepcopy(self.logs)
46 logs['w0'][1]['before'] += ' [w2:0]'
47 with self.assertRaises(AssertionError): edit_history(logs, 1, offline=True)
48 for event in ('local_commit', 'remote_receipt', 'remote_attempt', 'reopened_receipt'):
49 logs = copy.deepcopy(self.logs)
50 copied = next(item for item in logs['w0'] if item['event'] == event)
51 logs['w0'].insert(-1, copy.deepcopy(copied))
52 with self.assertRaises(AssertionError): edit_history(logs, 1, offline=True)
53
54 def test_uncertain_receipt_requires_the_original_target_revision_and_successful_confirmation(self):
55 rows = self.logs['w0']
56 intent = next(row for row in rows if row['event'] == 'local_commit')
57 attempt = next(row for row in rows if row['event'] == 'remote_attempt')
58 receipt = next(row for row in rows if row['event'] == 'remote_receipt')
59 intent.update(space='page-space', object='paragraph')
60 attempt.update(space='page-space', object='paragraph', state='Unknown')
61 receipt['at_us'] = 14
62 confirmation = dict(event='remote_confirm', state='Committed', started_us=12, finished_us=13,
63 revisions={'page-space': [attempt['revision']]}, text=attempt['after'])
64 rows.insert(4, confirmation)
65 self.assertEqual(len(edit_history(self.logs, 1, offline=True)[0]), 2)
66 original = copy.deepcopy(self.logs)
67 for field, value in [('state', 'NotCommitted'), ('state', 'Unknown'), ('finished_us', 15),
68 ('revisions', {'other-space': ['w0']}), ('revisions', {'page-space': ['wrong']}), ('text', 'Concurrent edits:')]:
69 self.logs = copy.deepcopy(original)
70 next(row for row in self.logs['w0'] if row['event'] == 'remote_confirm')[field] = value
71 with self.subTest(field=field, value=value), self.assertRaises(AssertionError): edit_history(self.logs, 1, offline=True)
72 self.logs = copy.deepcopy(original)
73 replay = dict(attempt, revision='another-attempt', state='NotCommitted', started_us=11, finished_us=12)
74 self.logs['w0'].insert(4, replay)
75 with self.assertRaisesRegex(AssertionError, 'replayed'): edit_history(self.logs, 1, offline=True)
76
77 def test_partial_capture_does_not_promote_pending_local_success(self):
78 logs = copy.deepcopy(self.logs)
79 logs['w0'] = logs['w0'][:2]
80 logs['r0'] = [{'event': 'ready'}]
81 commits, text = edit_history(logs, 1, offline=True, partial=True)
82 self.assertEqual(len(commits), 1)
83 self.assertEqual(text, 'Concurrent edits: [w1:0]')
84 with self.assertRaises(AssertionError): edit_history(logs, 1, offline=True)
85
86
87class OfflineLedgerTests(unittest.TestCase):
88 def test_independent_ledger_queue_depth_and_progress_checks(self):
89 import json
90 import os
91 from pathlib import Path
92 import sqlite3
93 import tempfile
94 from verify_offline import verify
95 with tempfile.TemporaryDirectory() as folder:
96 output = Path(folder)
97 (output / 'rust').mkdir()
98 (output / 'run.json').write_text(json.dumps({'offline': True, 'embedded_smb': True, 'edit': False, 'stress_clients': 4, 'rust_writers': 4, 'rust_readers': 4, 'stress_operations': 2}))
99 (output / 'rust/stop').touch()
100 os.utime(output / 'rust/stop', ns=(0, 0))
101 (output / 'rust/start').touch()
102 os.utime(output / 'rust/start', ns=(0, 0))
103 logs = {f'w{i}': [{'event': 'ready', 'offline': True}] for i in range(4)}
104 for actor, events in logs.items():
105 before = 'Concurrent edits:'
106 for op in range(2):
107 token = f' [{actor}:{op}]'
108 events.append({'event': 'local_commit', 'id': op+1, 'operation': op, 'token': token, 'before': before, 'started_us': 1+op*2, 'finished_us': 2+op*2})
109 before += token
110 before = 'Concurrent edits:'
111 timestamp = 100
112 for op in range(2):
113 for actor, events in logs.items():
114 token, revision = f' [{actor}:{op}]', f'{actor}-{op}'
115 events.append({'event': 'remote_attempt', 'before': before, 'after': before+token, 'revision': revision, 'state': 'Committed', 'started_us': timestamp, 'finished_us': timestamp+1})
116 events.append({'event': 'remote_receipt', 'id': op+1, 'revision': revision, 'at_us': timestamp+2})
117 before += token
118 timestamp += 10
119 for actor, events in logs.items():
120 events.extend({'event': 'reopened_receipt', 'id': op+1, 'revision': f'{actor}-{op}'} for op in range(2))
121 events.append({'event': 'done'})
122 connection = sqlite3.connect(output / 'rust' / f'{actor}.sqlite')
123 connection.executescript('CREATE TABLE receipts(edit_id INTEGER, revision TEXT); CREATE TABLE edits(id INTEGER); CREATE TABLE attempt(id INTEGER); CREATE TABLE conflicts(id INTEGER); CREATE TABLE replica(id INTEGER, base BLOB, working BLOB);')
124 connection.executemany('INSERT INTO receipts VALUES (?,?)', [(op+1, f'{actor}-{op}') for op in range(2)])
125 connection.execute('INSERT INTO replica VALUES (1, ?, ?)', (b'opaque image', b'opaque image'))
126 connection.commit()
127 connection.close()
128 for i in range(4):
129 logs[f'r{i}'] = [{'event': 'ready'}, {'event': 'read', 'text': before, 'started_us': timestamp, 'finished_us': timestamp+1}, {'event': 'done'}]
130 (output / f'n{i}').mkdir()
131 prefix = f'Native {i}:'
132 native = []
133 for op in range(2):
134 token = f' [n{i}:{op}]'
135 native.append({'operation': op, 'token': token, 'before': prefix, 'updated_ticks': op*10_000_000})
136 prefix += token
137 (output / f'n{i}/stress-events.jsonl').write_text('\n'.join(json.dumps(event) for event in native))
138 for actor, events in logs.items():
139 (output / 'rust' / f'{actor}.jsonl').write_text('\n'.join(json.dumps(event) for event in events))
140 result = verify(output, max_gap=2)
141 self.assertEqual(result['remote_publications'], 8)
142 self.assertEqual(result['queued_before_publication_lower_bound'], {f'w{i}': 2 for i in range(4)})
143 with self.assertRaisesRegex(AssertionError, 'progress exceeded'): verify(output, max_gap=.5)
144 os.utime(output / 'rust/stop', ns=(3_000_000_000, 3_000_000_000))
145 with self.assertRaisesRegex(AssertionError, 'progress exceeded'): verify(output, max_gap=2)
146 os.utime(output / 'rust/stop', ns=(0, 0))
147 connection = sqlite3.connect(output / 'rust/w0.sqlite')
148 for sql, undo in [("UPDATE receipts SET revision='wrong' WHERE edit_id=1", "UPDATE receipts SET revision='w0-0' WHERE edit_id=1"),
149 ('INSERT INTO attempt VALUES (1)', 'DELETE FROM attempt'),
150 ("UPDATE replica SET working=X'00'", "UPDATE replica SET working=base")]:
151 connection.execute(sql)
152 connection.commit()
153 with self.assertRaises(AssertionError): verify(output)
154 connection.execute(undo)
155 connection.commit()
156 connection.close()
tools/test_offline_outage.py created+216
...@@ -0,0 +1,216 @@
1import copy
2import json
3from pathlib import Path
4import tempfile
5import unittest
6from unittest.mock import patch
7
8from offline_outage import verify_outage, verify_lost_reply
9
10
11class OutageOracle(unittest.TestCase):
12 def setUp(self):
13 temporary = tempfile.TemporaryDirectory()
14 self.addCleanup(temporary.cleanup)
15 self.root = Path(temporary.name)
16 (self.root / 'rust').mkdir()
17 self.config = dict(offline=True, offline_outage=True, embedded_smb=True, stress_clients=4, rust_writers=4, rust_readers=4, stress_operations=8)
18 self.sample = dict(down_started_us=1_000_000, up_started_us=5_000_000, native_before=[1]*4, native_during=[4]*4, reader_errors_before={f'r{i}': 0 for i in range(4)})
19 (self.root / 'clocks.json').write_text(json.dumps([dict(native_minus_host_us=[-10, 10], after_native_minus_host_us=[-10, 10])]*4))
20 for i in range(4):
21 (self.root / f'n{i}').mkdir()
22 (self.root / f'n{i}/stress-events.jsonl').write_text(json.dumps(dict(update_started_ticks=621355968020000000, updated_ticks=621355968030000000)))
23 self.logs = {}
24 for i in range(4):
25 self.logs[f'w{i}'] = [dict(event='ready'), dict(event='transport_connected', at_us=0),
26 dict(event='publication_paused', revision=f'w{i}', at_us=100),
27 *[dict(event='local_commit', operation=j, started_us=10 if j == 0 else 2_000_000+j, finished_us=20 if j == 0 else 2_000_010+j) for j in range(8)],
28 dict(event='remote_attempt', started_us=6_000_000, state='NotCommitted', revision=f'w{i}'),
29 dict(event='transport_connected', at_us=6_000_001),
30 *[dict(event='remote_receipt', at_us=7_000_000+j) for j in range(8)], dict(event='done')]
31 self.logs[f'r{i}'] = [dict(event='ready'), dict(event='transport_read_error'), dict(event='transport_connected', at_us=6_000_000),
32 *[dict(event='read', started_us=7_000_000+j, finished_us=7_000_001+j) for j in range(3)], dict(event='done')]
33 self.trace = [dict(control=dict(phase='offline-down', mode='down')), dict(control=dict(phase='offline-reconnected'))]
34
35 def verify(self):
36 (self.root / 'run.json').write_text(json.dumps(self.config))
37 (self.root / 'offline-outage-progress.json').write_text(json.dumps(self.sample))
38 (self.root / 'smb-trace.jsonl').write_text('\n'.join(map(json.dumps, self.trace)))
39 for actor, events in self.logs.items():
40 (self.root / 'rust' / f'{actor}.jsonl').write_text('\n'.join(map(json.dumps, events)))
41 with patch('offline_outage.verify', return_value={'guarded_pairs': 1}) as overlap:
42 result = verify_outage(self.root)
43 overlap.assert_called_once_with(self.trace, phase='offline-reconnected')
44 return result
45
46 def test_confirmed_outage_requires_local_and_native_progress_and_fresh_sessions(self):
47 self.assertEqual(self.verify()['local_edits_while_down'], {f'w{i}': 7 for i in range(4)})
48 original = copy.deepcopy(self.logs)
49 for event, field, value in [('local_commit', 'started_us', 0), ('publication_paused', 'at_us', 3_000_000),
50 ('remote_attempt', 'state', 'Unknown'), ('remote_attempt', 'started_us', 3_000_000),
51 ('remote_attempt', 'revision', 'other'), ('remote_receipt', 'at_us', 0)]:
52 self.logs = copy.deepcopy(original)
53 row = next(row for row in self.logs['w0'] if row['event'] == event and (event != 'local_commit' or row['operation'] == 1))
54 row[field] = value
55 with self.subTest(event=event, field=field), self.assertRaises(AssertionError): self.verify()
56 for actor, event in [('r0', 'transport_connected'), ('r0', 'transport_read_error'), ('r0', 'read'), ('w0', 'publication_paused')]:
57 self.logs = copy.deepcopy(original)
58 self.logs[actor] = [row for row in self.logs[actor] if row['event'] != event]
59 with self.subTest(actor=actor, event=event), self.assertRaises(AssertionError): self.verify()
60
61 def test_lost_reply_requires_one_original_revision_receipt_and_captured_confirmation(self):
62 self.config.update(offline_outage=False, offline_lost_reply=True)
63 self.sample.update(before={actor: 3 for actor in self.logs}, after={actor: 6 for actor in self.logs})
64 attempt = next(row for row in self.logs['w0'] if row['event'] == 'remote_attempt')
65 attempt.update(state='Unknown', started_us=500_000, finished_us=1_100_000)
66 receipt = next(row for row in self.logs['w0'] if row['event'] == 'remote_receipt')
67 receipt['revision'] = attempt['revision']
68 for row in self.logs['w0']:
69 if row['event'] == 'remote_receipt' and row is not receipt: row['revision'] = 'other'
70 folder = self.root / 'rust/confirmations'
71 folder.mkdir()
72 (folder / 'confirmation.one').write_bytes(b'owned captured image')
73 confirmation = dict(event='remote_confirm', state='Committed', started_us=6_000_000, finished_us=6_000_001,
74 text='Concurrent edits: [w0:0]', capture='confirmation.one')
75 self.logs['w0'].insert(-1, confirmation)
76 self.trace = [dict(control=dict(phase='offline-reply-cut', cut=9, peer='10.0.2.2', offset=96, direction='response')),
77 dict(direction='request', command=9, offset=96, connection=1, message=2),
78 dict(cut=dict(direction='response', command=9, status='0x0', connection=1, message=2)),
79 dict(control=dict(phase='offline-reply-reconnected'))]
80 def check():
81 (self.root / 'run.json').write_text(json.dumps(self.config))
82 (self.root / 'offline-lost-reply-progress.json').write_text(json.dumps(self.sample))
83 (self.root / 'smb-trace.jsonl').write_text('\n'.join(map(json.dumps, self.trace)))
84 for actor, rows in self.logs.items():
85 (self.root / 'rust' / f'{actor}.jsonl').write_text('\n'.join(map(json.dumps, rows)))
86 with patch('offline_history.publication_links') as ledger, patch('offline_document_history.document_history') as documents, patch('offline_outage.verify', return_value={'guarded_pairs': 1}) as overlap:
87 if self.config.get('offline_client_reply'):
88 documents.return_value = {'target': {'format': {'receipt_revision': 'effect-revision'}}}
89 result = verify_lost_reply(self.root)
90 ledger.assert_called_once_with(self.logs, self.config['stress_operations'])
91 if self.config.get('document_operations'):
92 documents.assert_called_once_with(self.logs, self.config['stress_operations'])
93 else:
94 documents.assert_not_called()
95 overlap.assert_called_once_with(self.trace, phase='offline-reply-reconnected')
96 return result
97 self.assertEqual(check()['confirmed_revision'], 'w0')
98 self.config['document_operations'] = True
99 self.sample['format_released_us'] = 200_000
100 receipt['event'] = 'document_receipt'
101 receipt['id'] = 17
102 intent = dict(event='local_document_commit', id=17, kind='format')
103 self.logs['w0'].insert(-1, intent)
104 for actor, rows in self.logs.items():
105 if actor.startswith('w'):
106 next(row for row in rows if row['event'] == 'publication_paused')['kind'] = 'format'
107 self.assertEqual(check()['confirmed_revision'], 'w0')
108 intent['kind'] = 'insert'
109 with self.assertRaisesRegex(AssertionError, 'not formatting'): check()
110 intent['kind'] = 'format'
111 self.sample['format_released_us'] = 900_000
112 with self.assertRaises(AssertionError): check()
113 self.sample['format_released_us'] = 200_000
114 paused = next(row for row in self.logs['w0'] if row['event'] == 'publication_paused')
115 paused['revision'] = 'stale-prepared-revision'
116 prior = dict(event='remote_attempt', revision=paused['revision'], state='NotCommitted',
117 started_us=200_001, finished_us=200_002)
118 self.logs['w0'].insert(-1, prior)
119 self.assertEqual(check()['confirmed_revision'], 'w0')
120 prior['finished_us'] = 600_000
121 with self.assertRaisesRegex(AssertionError, 'proving it unpublished'): check()
122 self.logs['w0'].remove(prior)
123 paused['revision'] = attempt['revision']
124 self.config['offline_client_reply'] = True
125 receipt['revision'] = 'effect-revision'
126 attempt.update(space='space', document_changes={'target': {}})
127 retirement = dict(event='revision_retired', space='space', revision=attempt['revision'], started_us=2_200_000, finished_us=2_300_000)
128 self.logs['r0'].append(retirement)
129 (self.root / 'rust/offline-retired.one').write_bytes(b'retired revision snapshot')
130 self.sample['during'] = {actor: 3 if actor == 'w0' else 6 for actor in self.logs}
131 self.trace[0]['control']['scope'] = 'connection'
132 barrier = dict(event='confirmation_paused', revision=attempt['revision'], at_us=1_200_000)
133 self.logs['w0'].append(barrier)
134 peer_rows = []
135 for actor, rows in self.logs.items():
136 if actor == 'w0': continue
137 for i in range(3):
138 row = dict(event='remote_attempt' if actor.startswith('w') else 'read', state='Committed',
139 started_us=2_000_000+i, finished_us=2_000_010+i)
140 rows.append(row)
141 peer_rows.append((rows, row))
142 wire = [dict(connection=7, opened=True, peer=['192.168.77.12', 445]),
143 dict(connection=7, message=2, command=5, direction='request', path='owned/synthetic.one'),
144 dict(connection=7, message=2, command=5, direction='response', status='0x0', file_id='native-file'),
145 dict(connection=7, message=3, command=9, direction='request', time=2.0, file_id='native-file'),
146 dict(connection=7, message=3, command=9, direction='response', time=2.1, status='0x0', written=4)]
147 self.trace.extend(wire)
148 result = check()
149 self.assertEqual(result['native_writes_during_client_disconnect'], 1)
150 self.assertEqual(result['peer_operations_during_client_disconnect'], {actor: 3 for actor in self.logs if actor != 'w0'})
151 peer_rows[0][1]['finished_us'] = 6_000_000
152 with self.assertRaisesRegex(AssertionError, 'insufficient completed I/O'): check()
153 peer_rows[0][1]['finished_us'] = 2_000_010
154 wire[-1]['time'] = 6.0
155 with self.assertRaisesRegex(AssertionError, 'No successful native writes'): check()
156 wire[-1]['time'] = 2.1
157 barrier['revision'] = 'other'
158 with self.assertRaises(AssertionError): check()
159 self.logs['w0'].remove(barrier)
160 self.logs['r0'].remove(retirement)
161 receipt['revision'] = attempt['revision']
162 for rows, row in peer_rows: rows.remove(row)
163 del self.trace[-len(wire):]
164 del self.trace[0]['control']['scope']
165 self.config['offline_client_reply'] = False
166 self.config['document_operations'] = False
167 receipt['event'] = 'remote_receipt'
168 self.logs['w0'].remove(intent)
169 attempt['state'] = 'Committed'
170 with self.assertRaises(AssertionError): check()
171 attempt['state'] = 'Unknown'
172 self.trace[1]['offset'] = 100
173 with self.assertRaises(AssertionError): check()
174 self.trace[1]['offset'] = 96
175 (folder / 'unrecorded.one').write_bytes(b'extra')
176 with self.assertRaises(AssertionError): check()
177
178 def test_document_outage_requires_both_local_operations_and_delayed_receipts(self):
179 self.config['document_operations'] = True
180 for actor, events in self.logs.items():
181 if not actor.startswith('w'): continue
182 events[-1:-1] = [dict(event='local_document_commit', operation=operation, kind=kind,
183 started_us=10 if operation == 0 else 2_100_000+operation,
184 finished_us=20 if operation == 0 else 2_100_010+operation)
185 for operation in range(8) for kind in ('insert', 'format')]
186 events[-1:-1] = [dict(event='document_receipt', at_us=7_100_000+i) for i in range(16)]
187 self.assertEqual(self.verify()['local_edits_while_down'], {f'w{i}': 21 for i in range(4)})
188 original = copy.deepcopy(self.logs)
189 for event, field, value in [('local_document_commit', 'finished_us', 6_000_000),
190 ('local_document_commit', 'kind', 'insert'),
191 ('document_receipt', 'at_us', 0)]:
192 self.logs = copy.deepcopy(original)
193 row = next(row for row in self.logs['w0'] if row['event'] == event
194 and (event != 'local_document_commit' or row['operation'] == 1 and row['kind'] == 'format'))
195 row[field] = value
196 with self.subTest(event=event, field=field), self.assertRaises(AssertionError): self.verify()
197
198 def test_native_edits_outside_confirmed_window_fail(self):
199 (self.root / 'n0/stress-events.jsonl').write_text(json.dumps(dict(update_started_ticks=621355968000000000, updated_ticks=621355968000000100)))
200 with self.assertRaisesRegex(AssertionError, 'timestamps'): self.verify()
201
202 def test_native_stall_short_outage_and_wrong_control_fail(self):
203 self.sample['native_before'].append(1)
204 with self.assertRaises(AssertionError): self.verify()
205 self.sample['native_before'].pop()
206 self.sample['native_during'][0] = 1
207 with self.assertRaises(AssertionError): self.verify()
208 self.sample['native_during'][0] = 4
209 self.sample['up_started_us'] = 1_000_001
210 with self.assertRaises(AssertionError): self.verify()
211 self.sample['up_started_us'] = 5_000_000
212 self.trace[0]['control'].pop('mode')
213 with self.assertRaises(AssertionError): self.verify()
214
215
216if __name__ == '__main__': unittest.main()
tools/test_offline_publication_crash.py created+81
...@@ -0,0 +1,81 @@
1import copy
2import hashlib
3import json
4from pathlib import Path
5import tempfile
6import unittest
7from unittest.mock import patch
8from offline_publication_crash import state, TOKEN, confirmation_only
9from verify_offline_recovery import verify
10
11
12class RecoveryOracleTests(unittest.TestCase):
13 def test_confirmation_cannot_hide_revision_or_content_writes(self):
14 source = bytes(1024)
15 changed = bytearray(source)
16 changed[212:252] = bytes(range(40))
17 events = [{'event':'write', 'offset':212, 'bytes':40}]
18 confirmation_only(events, source, changed)
19 for offset in (96, 211, 252, 1023):
20 bad = changed.copy()
21 bad[offset] ^= 1
22 with self.assertRaises(AssertionError): confirmation_only(events, source, bad)
23 for event in ({'event':'write','offset':96,'bytes':1}, {'event':'phase','name':'publish-before'}):
24 with self.assertRaises(AssertionError): confirmation_only([*events,event], source, changed)
25
26 def test_text_acknowledgements_and_revision_identity_must_agree(self):
27 original = 'Concurrent edits: 🦀'
28 at = len(original.encode('utf-16-le')) // 2
29 row = {'event': 'state', 'status': 'uncertain', 'revision': 'new', 'remote_revision': 'old',
30 'local_text': original+TOKEN, 'remote_text': original,
31 'pending': [{'id': 1, 'before': original, 'replacement': TOKEN, 'range': [at, at]}]}
32 self.assertEqual(state([row], original), row)
33 for field, value in [('revision', None), ('revision', 'old'), ('local_text', original),
34 ('remote_text', original+TOKEN+TOKEN), ('status', 'missing'), ('pending', [])]:
35 changed = copy.deepcopy(row)
36 changed[field] = value
37 with self.subTest(field=field):
38 with self.assertRaises(AssertionError): state([changed], original)
39 for field, value in [('id', 2), ('before', 'other'), ('replacement', 'other'), ('range', [at-1, at-1])]:
40 changed = copy.deepcopy(row)
41 changed['pending'][0][field] = value
42 with self.assertRaises(AssertionError): state([changed], original)
43 row.update(status='published', remote_revision='new', remote_text=original+TOKEN, pending=[])
44 self.assertEqual(state([row], original), row)
45 for field, value in [('revision', 'old'), ('remote_text', original), ('pending', [{'id': 1}])]:
46 changed = copy.deepcopy(row)
47 changed[field] = value
48 with self.assertRaises(AssertionError): state([changed], original)
49
50 def test_native_inventory_hashes_errors_and_content_are_independently_checked(self):
51 with tempfile.TemporaryDirectory() as folder:
52 run, cold, source = [Path(folder)/name for name in ('run', 'cold', 'source.xml')]
53 (run/'images').mkdir(parents=True)
54 cold.mkdir()
55 data = b'owned fixture bytes'
56 digest = hashlib.sha256(data).hexdigest()
57 (run/'images'/f'{digest}.one').write_bytes(data)
58 expected = ['Concurrent edits:'+TOKEN, 'Other paragraph']
59 (run/'results.json').write_text(json.dumps([{'remote_image': digest, 'remote_text': expected[0]}]))
60 (cold/'run.json').write_text(json.dumps({'inputs': {digest+'.one': digest}}))
61 record = {'name': digest, 'source_sha256': digest, 'error': None, 'pages': 1, 'seconds': 1}
62 (cold/'results.json').write_text(json.dumps(record))
63 (cold/'teardown.json').write_text(json.dumps({'absent': True}))
64 (cold/'results'/digest).mkdir(parents=True)
65 (cold/'results'/digest/'page-0.xml').touch()
66 def content(path):
67 return ['Concurrent edits:', 'Other paragraph'] if path == source else expected
68 with patch('verify_offline_recovery.paragraphs', side_effect=content):
69 self.assertEqual(verify(run, cold, source)['exact_images'], 1)
70 for key, value in [('source_sha256', 'wrong'), ('error', 'failed'), ('pages', 0), ('name', 'wrong')]:
71 (cold/'results.json').write_text(json.dumps({**record, key:value}))
72 with self.subTest(key=key):
73 with self.assertRaises(AssertionError): verify(run, cold, source)
74 (cold/'results.json').write_text(json.dumps([record, record]))
75 with self.assertRaises(AssertionError): verify(run, cold, source)
76 (cold/'results.json').write_text(json.dumps(record))
77 expected[0] = 'Concurrent edits:'
78 with self.assertRaises(AssertionError): verify(run, cold, source)
79 expected[0] += TOKEN
80 (run/'images'/f'{digest}.one').write_bytes(b'changed')
81 with self.assertRaises(AssertionError): verify(run, cold, source)
tools/test_smb_overlap.py created+144
...@@ -0,0 +1,144 @@
1import unittest
2
3from verify_smb_overlap import verify
4
5
6class Overlap(unittest.TestCase):
7 def history(self, serialized=False, failed_write=False, writer_reads=False):
8 events = [{'connection': 1, 'opened': True, 'peer': ['10.0.2.2', 1]},
9 {'connection': 2, 'opened': True, 'peer': ['192.168.77.2', 2]}]
10 def exchange(connection, command, status='0x0', **fields):
11 message = len(events)
12 request = {'connection': connection, 'command': command, 'message': message,
13 'direction': 'request', **fields}
14 response = {'connection': connection, 'command': command, 'message': message,
15 'direction': 'response', 'status': status, 'file_id': str(connection)}
16 events.extend((request, response))
17 for connection in (1, 2):
18 exchange(connection, 5, path='synthetic.one', access=0xc0000000 if connection == 2 or writer_reads else 0x80000000)
19 exchange(connection, 10, file_id=str(connection), locks=[(0xfffffffb, 1, 0x11)])
20 if serialized:
21 exchange(1, 8, file_id='1', length=32)
22 exchange(1, 6, file_id='1')
23 exchange(2, 10, file_id='2', locks=[(0xfffffffd, 1, 0x12)])
24 if not serialized:
25 exchange(1, 8, file_id='1', length=32)
26 exchange(2, 9, status='0xc0000054' if failed_write else '0x0', file_id='2', length=32)
27 exchange(2, 6, file_id='2')
28 events.append({'connection': 3, 'opened': True, 'peer': ['10.0.2.2', 3]})
29 exchange(3, 5, path='synthetic.one', access=0xc0000000)
30 exchange(3, 10, file_id='3', locks=[(0xfffffffb, 1, 0x11), (0xfffffffd, 1, 0x12)])
31 if not serialized:
32 exchange(1, 8, file_id='1', length=32)
33 exchange(3, 9, file_id='3', length=32)
34 return events
35
36 def test_active_read_and_native_write(self):
37 result = verify(self.history())
38 self.assertEqual(result['active_native_writer_pairs'], 1)
39 self.assertEqual(result['active_rust_writer_pairs'], 1)
40 self.assertEqual(result['overlapping_reads'], 2)
41 self.assertEqual(result['overlapping_writes'], 2)
42
43 def test_progress_before_resume_does_not_satisfy_after_gate(self):
44 events = self.history()
45 events.append({'control': {'phase': 'resumed'}})
46 with self.assertRaises(AssertionError):
47 verify(events, phase='resumed')
48 with self.assertRaises(AssertionError):
49 verify(self.history(), phase='resumed')
50 self.assertEqual(verify([{'control': {'phase': 'resumed'}}, *self.history()], phase='resumed')['active_rust_writer_pairs'], 1)
51
52 def test_native_only_traffic_does_not_count(self):
53 events = self.history()
54 for event in events:
55 if event.get('opened'): event['peer'][0] = '192.168.77.' + str(event['connection'] + 1)
56 with self.assertRaises(AssertionError):
57 verify(events)
58
59 def test_delayed_io_responses_do_not_count_as_resumed_requests(self):
60 events = self.history()
61 end = next(i for i, event in enumerate(events) if event.get('direction') == 'request' and event['command'] == 6)
62 responses = [event for event in events[:end] if event.get('direction') == 'response' and event['command'] in (8, 9)]
63 events = [event for event in events if event not in responses]
64 events[end - len(responses):end - len(responses)] = [{'control': {'phase': 'resumed'}}, *responses]
65 self.assertEqual(verify(events)['active_native_writer_pairs'], 1)
66 with self.assertRaises(AssertionError):
67 verify(events, phase='resumed')
68
69 def test_serialized_calls_do_not_count(self):
70 with self.assertRaises(AssertionError):
71 verify(self.history(serialized=True))
72
73 def test_failed_write_does_not_count(self):
74 with self.assertRaises(AssertionError):
75 verify(self.history(failed_write=True))
76
77 def test_writers_validation_reads_do_not_count(self):
78 with self.assertRaises(AssertionError):
79 verify(self.history(writer_reads=True))
80
81 def test_lock_failure_does_not_count(self):
82 events = self.history()
83 for event in events:
84 if event.get('direction') == 'response' and event['command'] == 10:
85 event['status'] = '0xc0000055'
86 with self.assertRaises(AssertionError):
87 verify(events)
88
89 def test_separate_lock_lifetimes_do_not_combine(self):
90 events = self.history()
91 inserted = []
92 for message, flags in [(1000, 4), (1001, 0x12)]:
93 inserted.extend([
94 {'connection': 3, 'direction': 'request', 'command': 10, 'message': message,
95 'file_id': '3', 'locks': [(0xfffffffd, 1, flags)]},
96 {'connection': 3, 'direction': 'response', 'command': 10, 'message': message,
97 'status': '0x0'},
98 ])
99 events[-2:-2] = inserted
100 with self.assertRaises(AssertionError):
101 verify(events)
102
103 def test_renamed_path_does_not_combine_file_versions(self):
104 events = self.history()
105 events[-2:-2] = [
106 {'connection': 3, 'direction': 'request', 'command': 17, 'message': 1000,
107 'info_type': 1, 'info_class': 10},
108 {'connection': 3, 'direction': 'response', 'command': 17, 'message': 1000, 'status': '0x0'},
109 ]
110 with self.assertRaises(AssertionError):
111 verify(events)
112
113 def test_open_response_crossing_rename_is_not_attributed(self):
114 events = self.history()
115 at = next(i for i, event in enumerate(events) if event.get('connection') == 3
116 and event.get('command') == 5 and event['direction'] == 'response')
117 inserted = [
118 {'connection': 4, 'opened': True, 'peer': ['192.168.77.4', 4]},
119 {'connection': 4, 'direction': 'request', 'command': 17, 'message': 1000,
120 'info_type': 1, 'info_class': 10},
121 {'connection': 4, 'direction': 'response', 'command': 17, 'message': 1000, 'status': '0x0'},
122 ]
123 for message, command, fields in [
124 (1001, 5, {'path': 'synthetic.one', 'access': 0x80000000}),
125 (1002, 10, {'file_id': '1', 'locks': [(0xfffffffb, 1, 0x11)]}),
126 ]:
127 inserted.extend([
128 {'connection': 1, 'direction': 'request', 'command': command, 'message': message, **fields},
129 {'connection': 1, 'direction': 'response', 'command': command, 'message': message,
130 'status': '0x0', 'file_id': '1'},
131 ])
132 events[at:at] = inserted
133 with self.assertRaises(AssertionError):
134 verify(events)
135
136 def test_connection_close_ends_guard(self):
137 events = self.history()
138 events.insert(-2, {'connection': 1, 'closed': True})
139 with self.assertRaises(AssertionError):
140 verify(events)
141
142
143if __name__ == '__main__':
144 unittest.main()
tools/test_smb_proxy.py created+196
...@@ -0,0 +1,196 @@
1import importlib.util
2import asyncio
3import json
4from pathlib import Path
5import socket
6import struct
7import sys
8import tempfile
9import unittest
10
11spec = importlib.util.spec_from_file_location('smb_proxy', Path(__file__).with_name('smb-proxy.py'))
12proxy = importlib.util.module_from_spec(spec)
13spec.loader.exec_module(proxy)
14
15
16class HeaderTrace(unittest.TestCase):
17 def test_fields_follow_complete_payload_ranges(self):
18 header = bytearray(1024)
19 header[96:100] = (257).to_bytes(4, 'little')
20 header[212:228] = bytes(range(16))
21 header[228:236] = (999).to_bytes(8, 'little')
22 header[236:252] = bytes(range(16, 32))
23 expected = {'transactions': 257, 'version': bytes(range(16)).hex(),
24 'generation': 999, 'deny_read': bytes(range(16, 32)).hex()}
25 self.assertEqual(proxy.header_fields(0, header), expected)
26 self.assertEqual(proxy.header_fields(212, header[212:252]), {k:v for k,v in expected.items() if k != 'transactions'})
27 self.assertEqual(proxy.header_fields(96, header[96:99]), {})
28 self.assertEqual(proxy.header_fields(100, header[100:212]), {})
29 self.assertEqual(proxy.header_fields(228, header[228:251]), {'generation':999})
30 self.assertEqual(proxy.header_fields(252, bytes(1024)), {})
31
32
33class WriteTrace(unittest.IsolatedAsyncioTestCase):
34 async def test_opt_in_payload_and_partial_write_response_are_traced(self):
35 frames = []
36
37 async def respond(reader, writer):
38 try:
39 for _ in range(3):
40 prefix = await reader.readexactly(4)
41 frame = await reader.readexactly(int.from_bytes(prefix[1:], 'big'))
42 frames.append(frame)
43 reply = bytearray(80)
44 reply[:64] = frame[:64]
45 struct.pack_into('<I', reply, 16, 1)
46 struct.pack_into('<I', reply, 68, 3)
47 writer.write(len(reply).to_bytes(4, 'big') + reply)
48 await writer.drain()
49 finally:
50 writer.close()
51 await writer.wait_closed()
52
53 server = await asyncio.start_server(respond, '127.0.0.1', 0)
54 server_port = server.sockets[0].getsockname()[1]
55 with socket.socket() as reservation:
56 reservation.bind(('127.0.0.1', 0))
57 proxy_port = reservation.getsockname()[1]
58 with tempfile.TemporaryDirectory() as directory:
59 control = Path(directory) / 'control.json'
60 control.write_text('{}')
61 process = await asyncio.create_subprocess_exec(
62 sys.executable, str(Path(proxy.__file__)), str(control),
63 '--port', str(proxy_port), '--server', '127.0.0.1', '--server-port', str(server_port),
64 stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
65 records = []
66 writer = None
67 try:
68 while not any('control' in row for row in records):
69 records.append(json.loads(await asyncio.wait_for(process.stdout.readline(), 5)))
70 reader, writer = await asyncio.open_connection('127.0.0.1', proxy_port)
71 sent = []
72 for message, command in enumerate((9, 9, 17)):
73 if message == 1:
74 control.write_text('{"record_writes":true}')
75 while not any(row.get('control', {}).get('record_writes') for row in records):
76 records.append(json.loads(await asyncio.wait_for(process.stdout.readline(), 5)))
77 if message == 2:
78 control.write_text('{}')
79 while True:
80 row = json.loads(await asyncio.wait_for(process.stdout.readline(), 5))
81 records.append(row)
82 if row.get('control') == {}:
83 break
84 data = b'abcde' if command == 9 else (4096).to_bytes(8, 'little')
85 offset = 112 if command == 9 else 96
86 frame = bytearray(offset)
87 frame[:4] = b'\xfeSMB'
88 struct.pack_into('<H', frame, 12, command)
89 struct.pack_into('<Q', frame, 24, message)
90 frame[80:96] = bytes(range(16))
91 if command == 9:
92 struct.pack_into('<HIQ', frame, 66, offset, len(data), 4096)
93 else:
94 struct.pack_into('<BBIH', frame, 66, 1, 20, len(data), offset)
95 frame += data
96 sent.append(bytes(frame))
97 writer.write(len(frame).to_bytes(4, 'big') + frame)
98 await writer.drain()
99 prefix = await asyncio.wait_for(reader.readexactly(4), 5)
100 await asyncio.wait_for(reader.readexactly(int.from_bytes(prefix[1:], 'big')), 5)
101 while not any(row.get('direction') == 'response' and row['message'] == message for row in records):
102 records.append(json.loads(await asyncio.wait_for(process.stdout.readline(), 5)))
103 self.assertEqual(frames, sent)
104 requests = [row for row in records if row.get('direction') == 'request']
105 self.assertNotIn('data', requests[0])
106 self.assertEqual(requests[1]['data'], b'abcde'.hex())
107 self.assertEqual(requests[1]['offset'], 4096)
108 self.assertEqual(requests[2]['data'], (4096).to_bytes(8, 'little').hex())
109 self.assertEqual(requests[2]['file_id'], bytes(range(16)).hex())
110 self.assertEqual([row['written'] for row in records if row.get('direction') == 'response' and row['command'] == 9], [3, 3])
111 finally:
112 if writer is not None:
113 writer.close()
114 await writer.wait_closed()
115 if process.returncode is None:
116 process.terminate()
117 await asyncio.wait_for(process.wait(), 5)
118 server.close()
119 await server.wait_closed()
120
121 async def test_connection_cut_keeps_existing_and_new_peers_usable(self):
122 async def respond(reader, writer):
123 try:
124 while True:
125 prefix = await reader.readexactly(4)
126 frame = await reader.readexactly(int.from_bytes(prefix[1:], 'big'))
127 reply = bytearray(80)
128 reply[:64] = frame[:64]
129 struct.pack_into('<I', reply, 16, 1)
130 struct.pack_into('<I', reply, 68, 4)
131 writer.write(len(reply).to_bytes(4, 'big') + reply)
132 await writer.drain()
133 except (OSError, asyncio.IncompleteReadError):
134 pass
135 finally:
136 writer.close()
137 await writer.wait_closed()
138
139 async def send(writer, message):
140 frame = bytearray(116)
141 frame[:4] = b'\xfeSMB'
142 struct.pack_into('<H', frame, 12, 9)
143 struct.pack_into('<Q', frame, 24, message)
144 struct.pack_into('<HIQ', frame, 66, 112, 4, 96)
145 writer.write(len(frame).to_bytes(4, 'big') + frame)
146 await writer.drain()
147
148 async def receive(reader):
149 prefix = await asyncio.wait_for(reader.readexactly(4), 5)
150 return await asyncio.wait_for(reader.readexactly(int.from_bytes(prefix[1:], 'big')), 5)
151
152 for scope in ('connection', 'all'):
153 with self.subTest(scope=scope), tempfile.TemporaryDirectory() as directory:
154 server = await asyncio.start_server(respond, '127.0.0.1', 0)
155 control = Path(directory) / 'control.json'
156 control.write_text('{}')
157 process = await asyncio.create_subprocess_exec(
158 sys.executable, str(Path(proxy.__file__)), str(control), '--port', '0',
159 '--server', '127.0.0.1', '--server-port', str(server.sockets[0].getsockname()[1]),
160 stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
161 clients, records = [], []
162 try:
163 while not any('control' in row for row in records):
164 records.append(json.loads(await asyncio.wait_for(process.stdout.readline(), 5)))
165 port, = [row['listening'] for row in records if 'listening' in row]
166 for index in range(2):
167 reader, writer = await asyncio.open_connection('127.0.0.1', port)
168 clients.append((reader, writer))
169 await send(writer, index)
170 self.assertEqual(struct.unpack_from('<I', await receive(reader), 68)[0], 4)
171 setting = dict(cut=9, offset=96, scope=scope)
172 control.write_text(json.dumps(setting))
173 while not any(row.get('control') == setting for row in records):
174 records.append(json.loads(await asyncio.wait_for(process.stdout.readline(), 5)))
175 await send(clients[0][1], 2)
176 with self.assertRaises(asyncio.IncompleteReadError): await receive(clients[0][0])
177 while not any('cut' in row for row in records):
178 records.append(json.loads(await asyncio.wait_for(process.stdout.readline(), 5)))
179 cut, = [row['cut'] for row in records if 'cut' in row]
180 self.assertEqual((cut['direction'], cut['status'], cut['written']), ('response', '0x0', 4))
181 reader, writer = await asyncio.open_connection('127.0.0.1', port)
182 clients.append((reader, writer))
183 for reader, writer in clients[1:]:
184 if scope == 'connection':
185 await send(writer, 3)
186 self.assertEqual(struct.unpack_from('<I', await receive(reader), 68)[0], 4)
187 else:
188 with self.assertRaises(asyncio.IncompleteReadError): await receive(reader)
189 finally:
190 for _, writer in clients:
191 writer.close()
192 await writer.wait_closed()
193 if process.returncode is None: process.terminate()
194 await asyncio.wait_for(process.wait(), 5)
195 server.close()
196 await server.wait_closed()
tools/test_verify_smb_faults.py created+97
...@@ -0,0 +1,97 @@
1import hashlib
2import json
3from pathlib import Path
4import tempfile
5import unittest
6import xml.etree.ElementTree as ET
7
8from native_xml import ns
9from verify_smb_faults import verify
10
11
12class NativeFaultOracle(unittest.TestCase):
13 def setUp(self):
14 temporary = tempfile.TemporaryDirectory()
15 self.addCleanup(temporary.cleanup)
16 self.root = Path(temporary.name)
17 self.cases = self.root / 'cases'
18 self.worker = self.root / 'captures/worker-0'
19 self.worker.mkdir(parents=True)
20 self.records = [{'case': 'sample-000', 'visible': 'before'},
21 {'case': 'sample-001', 'visible': 'after'}]
22 self.results = []
23 for role, names in [('source', ['sample']), ('interrupted', ['sample-000', 'sample-001']),
24 ('recovered', ['sample-000', 'sample-001'])]:
25 (self.cases / role).mkdir(parents=True)
26 for name in names:
27 content = (role + name).encode()
28 (self.cases / role / (name + '.one')).write_bytes(content)
29 name = role + '-' + name
30 self.results.append({'name': name, 'error': None, 'pages': 1,
31 'source_sha256': hashlib.sha256(content).hexdigest()})
32 replacement = 'before'
33 if name == 'interrupted-sample-001': replacement = 'after'
34 if role == 'recovered': replacement = 'after again'
35 self.page(name, ['Title', replacement, 'Unrelated café 🦀'])
36 (self.cases / 'sample-intent.json').write_text(json.dumps({'before': 'before', 'after': 'after', 'suffix': ' again'}))
37 self.save()
38
39 def save(self):
40 (self.cases / 'results.json').write_text(json.dumps(self.records))
41 (self.worker / 'results.json').write_text(json.dumps(self.results))
42
43 def page(self, name, paragraphs):
44 page = ET.Element('{' + ns['one'] + '}Page')
45 outline = ET.SubElement(page, '{' + ns['one'] + '}Outline')
46 for text in paragraphs:
47 ET.SubElement(outline, '{' + ns['one'] + '}T').text = text
48 output = self.worker / 'results' / name
49 output.mkdir(parents=True, exist_ok=True)
50 ET.ElementTree(page).write(output / 'page-0.xml', encoding='utf-8')
51
52 def check(self):
53 return verify(self.root, self.root / 'captures')
54
55 def test_complete_before_and_after_outcomes(self):
56 self.assertEqual(self.check(), {'cases': 2, 'cold_native_opens': 5, 'exact_native_text': True})
57
58 def test_unrelated_or_partial_edits_are_rejected(self):
59 for paragraphs in [['Title', 'after again', 'Changed'], ['Title', 'afte', 'Unrelated café 🦀'],
60 ['Title', 'after again again', 'Unrelated café 🦀']]:
61 with self.subTest(paragraphs=paragraphs):
62 self.page('recovered-sample-000', paragraphs)
63 with self.assertRaises(AssertionError): self.check()
64
65 def test_wrong_publication_outcome_is_rejected(self):
66 self.records[0]['visible'] = 'after'
67 self.save()
68 with self.assertRaises(AssertionError): self.check()
69
70 def test_input_hash_is_checked(self):
71 (self.cases / 'interrupted/sample-000.one').write_bytes(b'changed')
72 with self.assertRaises(AssertionError): self.check()
73
74 def test_missing_and_duplicate_captures_are_rejected(self):
75 original = self.results[:]
76 for rows in [original[:-1], original + original[:1]]:
77 self.results = rows
78 self.save()
79 with self.assertRaises(AssertionError): self.check()
80
81 def test_extra_artifacts_are_rejected(self):
82 (self.cases / 'interrupted/extra.one').write_bytes(b'extra')
83 with self.assertRaises(AssertionError): self.check()
84
85 def test_duplicate_cases_are_rejected(self):
86 self.records.append(self.records[0])
87 self.save()
88 with self.assertRaises(AssertionError): self.check()
89
90 def test_native_failure_and_wrong_page_count_are_rejected(self):
91 for error, pages in [('Open failed', 1), (None, 0), (None, 2)]:
92 self.results[0].update(error=error, pages=pages)
93 self.save()
94 with self.assertRaises(AssertionError): self.check()
95
96
97if __name__ == '__main__': unittest.main()
tools/verify-collaboration.py+1-1
...@@ -27,7 +27,7 @@ for name, text in expected.items():...@@ -27,7 +27,7 @@ for name, text in expected.items():
27 captured = pages(corpus / 'native' / f'stage5-cold-{name}' / 'read')27 captured = pages(corpus / 'native' / f'stage5-cold-{name}' / 'read')
28 assert len(captured) == 1 and texts(captured[0]) == text, name28 assert len(captured) == 1 and texts(captured[0]) == text, name
2929
30subprocess.run(['cargo', 'build', '--quiet', '--example', 'inventory'], cwd=root, check=True)30subprocess.run(['cargo', 'build', '-p', 'onestore', '--quiet', '--example', 'inventory'], cwd=root, check=True)
31for case, main, conflict in [31for case, main, conflict in [
32 ('live', 'Rust same paragraph.', 'Native same paragraph.'),32 ('live', 'Rust same paragraph.', 'Native same paragraph.'),
33 ('offline', 'Rust edited during the native outage.', 'Native edited while disconnected.'),33 ('offline', 'Rust edited during the native outage.', 'Native edited while disconnected.'),
tools/verify-reader.py+1-1
...@@ -12,7 +12,7 @@ root = Path(__file__).resolve().parent.parent...@@ -12,7 +12,7 @@ root = Path(__file__).resolve().parent.parent
12private = root / 'corpus/private'12private = root / 'corpus/private'
13paths = sorted((private / 'original').rglob('*.one'))13paths = sorted((private / 'original').rglob('*.one'))
14output = subprocess.check_output(14output = subprocess.check_output(
15 ['cargo', 'run', '--quiet', '--example', 'inventory', '--', *map(str, paths)],15 ['cargo', 'run', '-p', 'onestore', '--quiet', '--example', 'inventory', '--', *map(str, paths)],
16 cwd=root, text=True,16 cwd=root, text=True,
17)17)
18records = [line.split('\t') for line in output.splitlines()]18records = [line.split('\t') for line in output.splitlines()]
tools/verify-writer.py+2-2
...@@ -28,7 +28,7 @@ for before, after in [...@@ -28,7 +28,7 @@ for before, after in [
28 source = before.read_bytes()28 source = before.read_bytes()
29 assert after.read_bytes()[1024:len(source)] == source[1024:]29 assert after.read_bytes()[1024:len(source)] == source[1024:]
3030
31native_saved = subprocess.check_output(['cargo', 'run', '--quiet', '--example', 'inventory', '--', str(corpus / 'native-edit-02/notebook/synthetic.one')], cwd=root, text=True)31native_saved = subprocess.check_output(['cargo', 'run', '-p', 'onestore', '--quiet', '--example', 'inventory', '--', str(corpus / 'native-edit-02/notebook/synthetic.one')], cwd=root, text=True)
32assert [bytes.fromhex(row.split('\t')[2]).decode('ascii') for row in native_saved.splitlines() if row.startswith('ascii\t')] == ['Fictitious plain text.']32assert [bytes.fromhex(row.split('\t')[2]).decode('ascii') for row in native_saved.splitlines() if row.startswith('ascii\t')] == ['Fictitious plain text.']
3333
34before = pages(root / 'corpus/native-ink/cold-ui-ink/read')[0]34before = pages(root / 'corpus/native-ink/cold-ui-ink/read')[0]
...@@ -93,6 +93,6 @@ after = pages(append / 'native/append-01-complex/read')[0]...@@ -93,6 +93,6 @@ after = pages(append / 'native/append-01-complex/read')[0]
93assert fingerprint(normalized(before)) == fingerprint(normalized(after))93assert fingerprint(normalized(before)) == fingerprint(normalized(after))
94assert next((root / 'corpus/native-ink/cold-ui-ink/read').glob('*.attachment')).read_bytes() == next((append / 'native/append-01-complex/read').glob('*.attachment')).read_bytes()94assert next((root / 'corpus/native-ink/cold-ui-ink/read').glob('*.attachment')).read_bytes() == next((append / 'native/append-01-complex/read').glob('*.attachment')).read_bytes()
95assert ET.parse(append / 'native/append-01-toc/read/hierarchy.xml').getroot().get('color') == '#336699'95assert ET.parse(append / 'native/append-01-toc/read/hierarchy.xml').getroot().get('color') == '#336699'
96saved = subprocess.check_output(['cargo', 'run', '--quiet', '--example', 'inventory', '--', str(append / 'native-after-511.one')], cwd=root, text=True)96saved = subprocess.check_output(['cargo', 'run', '-p', 'onestore', '--quiet', '--example', 'inventory', '--', str(append / 'native-after-511.one')], cwd=root, text=True)
97assert [bytes.fromhex(row.split('\t')[2]).decode('ascii') for row in saved.splitlines() if row.startswith('ascii\t')] == ['Native after interrupted cleanup.']97assert [bytes.fromhex(row.split('\t')[2]).decode('ascii') for row in saved.splitlines() if row.startswith('ascii\t')] == ['Native after interrupted cleanup.']
98print('Passed: append encoding, native metadata and counter recovery, post-recovery native save, and filesystem-backed SMB commit')98print('Passed: append encoding, native metadata and counter recovery, post-recovery native save, and filesystem-backed SMB commit')
tools/verify_offline.py created+71
...@@ -0,0 +1,71 @@
1#!/usr/bin/env python3
2"""Audit completed offline/native runs, including the independent SQLite receipt ledger."""
3import argparse
4import hashlib
5import json
6from pathlib import Path
7import sqlite3
8
9from native_stress import edit_history, native_history
10
11
12def verify(output, max_gap=120):
13 config = json.loads((output / 'run.json').read_text())
14 assert config['offline'] and config['embedded_smb'] and not config['edit']
15 assert config['stress_clients'] + config['rust_writers'] + config['rust_readers'] >= 12
16 actors = [*(f'w{i}' for i in range(config['rust_writers'])), *(f'r{i}' for i in range(config['rust_readers']))]
17 logs = {actor: [json.loads(line) for line in (output / 'rust' / f'{actor}.jsonl').read_text().splitlines()] for actor in actors}
18 commits, text = edit_history(logs, config['stress_operations'], offline=True)
19 documents = {}
20 if config.get('document_operations'):
21 from offline_document_history import document_history
22 documents = document_history(logs, config['stress_operations'])
23 started = (output / 'rust/start').stat().st_mtime_ns // 1000
24 stopped = (output / 'rust/stop').stat().st_mtime_ns // 1000
25 progress, queues, caches = {}, {}, {}
26 for actor, events in logs.items():
27 times = [event['at_us'] for event in events if event['event'] in ('remote_receipt', 'document_receipt')] if actor.startswith('w') else [event['finished_us'] for event in events if event['event'] == 'read']
28 assert times and times == sorted(times), 'Client progress is absent or went backwards'
29 if actor.startswith('r'): times.append(max(times[-1], stopped))
30 progress[actor] = max(b-a for a, b in zip([started, *times], times)) / 1_000_000
31 if not actor.startswith('w'): continue
32 edits = {event['id']: event for event in events if event['event'] in ('local_commit', 'local_document_commit')}
33 receipts = {event['id']: event for event in events if event['event'] in ('remote_receipt', 'document_receipt')}
34 attempts = {event['revision']: event for event in events if event['event'] == 'remote_attempt'}
35 publication_starts = {id: documents[edit['object']][edit['kind']]['started_us'] if edit['event'] == 'local_document_commit'
36 else attempts[receipts[id]['revision']]['started_us'] for id, edit in edits.items()}
37 queues[actor] = max(sum(edit['finished_us'] <= at < publication_starts[id] for id, edit in edits.items()) for at in [edit['finished_us'] for edit in edits.values()])
38 assert queues[actor] >= 2, 'Writer did not establish a durable local queue before publication'
39 path = output / 'rust' / f'{actor}.sqlite'
40 connection = sqlite3.connect(path.resolve().as_uri() + '?mode=ro', uri=True)
41 try:
42 assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)], 'Cache integrity failed'
43 assert connection.execute('PRAGMA foreign_key_check').fetchall() == [], 'Cache foreign keys failed'
44 for table in ('edits', 'attempt', 'conflicts'):
45 assert connection.execute(f'SELECT count(*) FROM {table}').fetchone() == (0,), 'Completed cache retains unresolved state'
46 persisted = dict(connection.execute('SELECT edit_id, revision FROM receipts'))
47 assert persisted == {id: event['revision'] for id, event in receipts.items()}, 'SQLite receipts differ from observed acknowledgements'
48 base, working = connection.execute('SELECT base, working FROM replica WHERE id=1').fetchone()
49 assert base == working, 'A drained cache retained a divergent local branch'
50 caches[actor] = {'receipts': len(persisted), 'image_bytes': len(base), 'image_sha256': hashlib.sha256(base).hexdigest(), 'database_sha256': hashlib.sha256(path.read_bytes()).hexdigest()}
51 finally:
52 connection.close()
53 for i in range(config['stress_clients']):
54 events = [json.loads(line) for line in (output / f'n{i}/stress-events.jsonl').read_text(encoding='utf-8-sig').splitlines()]
55 native_history(events, i, config['stress_operations'], False)
56 times = [event['updated_ticks'] for event in events]
57 assert times == sorted(times)
58 progress[f'n{i}'] = max((b-a for a, b in zip(times, times[1:])), default=0) / 10_000_000
59 assert all(gap <= max_gap for gap in progress.values()), f'Client progress exceeded {max_gap}s: {progress}'
60 return {'remote_publications': len(commits), 'document_publications': len(documents)*2, 'remote_text_sha256': hashlib.sha256(text.encode()).hexdigest(), 'maximum_progress_gap_seconds': progress,
61 'queued_before_publication_lower_bound': queues, 'reviewed_placements': sum(event['event'] == 'reviewed_append' for events in logs.values() for event in events), 'caches': caches}
62
63
64if __name__ == '__main__':
65 parser = argparse.ArgumentParser(description=__doc__)
66 parser.add_argument('output', type=Path)
67 parser.add_argument('--max-gap', type=float, default=120)
68 args = parser.parse_args()
69 result = verify(args.output, args.max_gap)
70 (args.output / 'offline-verification.json').write_text(json.dumps(result, indent=2))
71 print(json.dumps(result, indent=2))
tools/verify_offline_confirmation.py created+95
...@@ -0,0 +1,95 @@
1#!/usr/bin/env python3
2"""Compare cold native reads of confirmation snapshots with the recorded editing history."""
3import argparse
4import hashlib
5import json
6from pathlib import Path
7import xml.etree.ElementTree as ET
8
9from native_format import native_characters
10from native_stress import native_history
11from native_xml import ns
12from offline_history import publication_links
13
14
15def verify(output, cold, *, partial=False):
16 config = json.loads((output / 'run.json').read_text())
17 actors = [*(f'w{i}' for i in range(config['rust_writers'])), *(f'r{i}' for i in range(config['rust_readers']))]
18 logs = {actor: [json.loads(line) for line in (output / 'rust' / f'{actor}.jsonl').read_text().splitlines()] for actor in actors}
19 publication_links(logs, config['stress_operations'], partial=partial)
20 documents = {}
21 if config.get('document_operations'):
22 from offline_document_history import document_history
23 assert not partial, 'Document confirmation audit requires a complete workload'
24 documents = document_history(logs, config['stress_operations'])
25 unknown = [row for rows in logs.values() for row in rows if row['event'] == 'remote_attempt' and row['state'] == 'Unknown']
26 assert len(unknown) == 1
27 attempt, = unknown
28 confirmations = {}
29 for actor, rows in logs.items():
30 observed = None
31 for row in rows:
32 if row['event'] == 'read': observed = row
33 if row['event'] != 'remote_confirm': continue
34 name = row['capture']
35 assert Path(name).name == name and name.startswith(str(rows[0]['pid']) + '-')
36 assert name not in confirmations
37 if attempt['revision'] not in row['revisions'].get(attempt['space'], []):
38 assert documents and row.get('current_revisions', {}).get(attempt['space']) in row['revisions'].get(attempt['space'], []), 'Retired attempt lacks a current effect-confirmation revision'
39 if documents:
40 assert observed and observed['text'] == row['text'] and observed['finished_us'] <= row['started_us']
41 assert isinstance(observed.get('documents'), dict)
42 assert all(observed['documents'].get(target) == value for target, value in attempt['document_changes'].items()), 'Confirmation omitted the uncertain document change'
43 confirmations[name] = row, observed['documents'] if documents else {}
44 assert confirmations
45 manifest = json.loads((cold / 'run.json').read_text())['inputs']
46 assert set(manifest) == set(confirmations)
47 assert set(manifest) == {path.name for path in (output / 'rust/confirmations').glob('*.one')}
48 results = json.loads((cold / 'results.json').read_text(encoding='utf-8-sig'))
49 if isinstance(results, dict): results = [results]
50 assert len(results) == len(confirmations) and len({row['name'] for row in results}) == len(results)
51 native = []
52 for i in range(config['stress_clients']):
53 rows = [json.loads(line) for line in (output / f'n{i}/stress-events.jsonl').read_text(encoding='utf-8-sig').splitlines()]
54 assert len(rows) <= config['stress_operations']
55 native_history(rows, i, len(rows) if partial else config['stress_operations'], False)
56 native.append({f'Native {i}:', *(row['before'] + row['token'] for row in rows)})
57 checks = format_checks = 0
58 for result in results:
59 name = result['name'] + '.one'
60 assert result['error'] is None and result['pages'] == 1
61 assert result['source_sha256'] == manifest[name] == hashlib.sha256((output / 'rust/confirmations' / name).read_bytes()).hexdigest()
62 page = ET.parse(cold / 'results' / result['name'] / 'page-0.xml').getroot()
63 formatted = native_characters(page, page.findall('one:Outline', ns))
64 paragraphs = [''.join(c for c, _ in paragraph) for paragraph in formatted]
65 confirmation, observed = confirmations[name]
66 expected = confirmation['text']
67 assert len(paragraphs) == len(native) + 1 + len(observed) and paragraphs.count(expected) == 1
68 paragraphs.remove(expected)
69 if observed:
70 from offline_document_history import characters, verify_native
71 expected_documents = {target: {'text': documents[target]['text'], 'new': characters(value)} for target, value in observed.items()}
72 format_checks += verify_native(formatted, expected_documents)
73 for document in expected_documents.values():
74 assert paragraphs.count(document['text']) == 1, 'Confirmation duplicated a document paragraph'
75 paragraphs.remove(document['text'])
76 for index, versions in enumerate(native):
77 selected = [text for text in paragraphs if text.startswith(f'Native {index}:')]
78 assert len(selected) == 1 and selected[0] in versions, 'Native confirmation image contains an unrecorded edit or loses a prefix'
79 checks += len(native) + 1 + len(observed)
80 assert json.loads((cold / 'teardown.json').read_text()) == {'absent': True}
81 confirmed_revision = documents[next(iter(attempt['document_changes']))]['format']['receipt_revision'] if documents else attempt['revision']
82 return {'complete_workload': not partial, 'attempted_revision': attempt['revision'], 'confirmed_revision': confirmed_revision, 'native_images': len(results), 'exact_rust_paragraphs': len(results),
83 'validated_paragraphs': checks, 'native_intended_format_checks': format_checks,
84 'maximum_native_export_seconds': max(row['seconds'] for row in results)}
85
86
87if __name__ == '__main__':
88 parser = argparse.ArgumentParser(description=__doc__)
89 parser.add_argument('run', type=Path)
90 parser.add_argument('cold', type=Path)
91 parser.add_argument('--partial', action='store_true', help='Validate preserved confirmations from an interrupted workload')
92 args = parser.parse_args()
93 result = verify(args.run, args.cold, partial=args.partial)
94 (args.run / 'confirmation-cold-verification.json').write_text(json.dumps(result, indent=2))
95 print(json.dumps(result, indent=2))
tools/verify_offline_recovery.py created+56
...@@ -0,0 +1,56 @@
1#!/usr/bin/env python3
2"""Compare every interrupted/recovered image with an independent cold native export."""
3import argparse
4import hashlib
5import json
6from pathlib import Path
7import xml.etree.ElementTree as ET
8from native_format import native_characters
9from native_xml import ns
10
11
12def paragraphs(path):
13 root = ET.parse(path).getroot()
14 return [''.join(char for char, _ in text) for text in native_characters(root, root.findall('one:Outline', ns))]
15
16
17def verify(run, cold, source_capture):
18 original = paragraphs(source_capture)
19 target, = [text for text in original if text.startswith('Concurrent edits:')]
20 other = [text for text in original if text != target]
21 expected = {}
22 for case in json.loads((run/'results.json').read_text()):
23 for side in ('remote', 'local'):
24 if side+'_image' not in case: continue
25 digest, text = case[side+'_image'], case[side+'_text']
26 assert text in (target, target+' [offline-recovery]'), 'Recovery oracle is unrelated to the native source'
27 wanted = sorted([text, *other])
28 if digest in expected: assert expected[digest] == wanted, 'One image has contradictory expected states'
29 expected[digest] = wanted
30 inputs = json.loads((cold/'run.json').read_text())['inputs']
31 assert inputs == {digest+'.one': digest for digest in expected}, 'Cold input inventory differs from recovery images'
32 records = json.loads((cold/'results.json').read_text(encoding='utf-8-sig'))
33 if isinstance(records, dict): records = [records]
34 assert len(records) == len(expected) and {record['name'] for record in records} == set(expected), 'Cold results omit or duplicate an image'
35 for record in records:
36 digest = record['name']
37 assert record['source_sha256'] == digest, 'Native input hash differs'
38 assert hashlib.sha256((run/'images'/f'{digest}.one').read_bytes()).hexdigest() == digest, 'Retained image changed'
39 assert record['error'] is None and record['pages'] == 1, f'Native open failed: {record}'
40 pages = list((cold/'results'/digest).glob('page-*.xml'))
41 assert len(pages) == 1
42 assert sorted(paragraphs(pages[0])) == expected[digest], f'Native content differs for {digest}'
43 assert json.loads((cold/'teardown.json').read_text())['absent'], 'Cold VM remains'
44 return {'exact_images':len(records), 'exact_paragraphs':sum(map(len, expected.values())), 'native_errors':0,
45 'maximum_native_export_seconds':max(record['seconds'] for record in records)}
46
47
48if __name__ == '__main__':
49 parser = argparse.ArgumentParser(description=__doc__)
50 parser.add_argument('run', type=Path)
51 parser.add_argument('cold', type=Path)
52 parser.add_argument('source_capture', type=Path)
53 args = parser.parse_args()
54 result = verify(args.run, args.cold, args.source_capture)
55 (args.run/'cold-verification.json').write_text(json.dumps(result, indent=2))
56 print(json.dumps(result, indent=2))
tools/verify_smb_faults.py created+81
...@@ -0,0 +1,81 @@
1#!/usr/bin/env python3
2"""Cold-open every SMB fault artifact and compare its complete native text."""
3import argparse
4from concurrent.futures import ThreadPoolExecutor
5import hashlib
6import json
7from pathlib import Path
8import signal
9import xml.etree.ElementTree as ET
10
11from native_format import native_characters
12from native_xml import ns
13from native_probe import ROOT, run as probe
14
15
16def verify(root, captures):
17 cases = root / 'cases'
18 records = json.loads((cases / 'results.json').read_text())
19 assert records and len({record['case'] for record in records}) == len(records), 'Missing or duplicate fault cases'
20 packets = {f'{role}-{path.stem}': path for role in ('source', 'interrupted', 'recovered')
21 for path in (cases / role).glob('*.one')}
22 expected_names = {f'{role}-{record["case"]}' for record in records for role in ('interrupted', 'recovered')}
23 fixtures = {record['case'].rsplit('-', 1)[0] for record in records}
24 expected_names.update(f'source-{fixture}' for fixture in fixtures)
25 assert set(packets) == expected_names, 'Artifact inventory differs from completed cases'
26 observed = {}
27 for worker in captures.glob('worker-*'):
28 result = json.loads((worker / 'results.json').read_text(encoding='utf-8-sig'))
29 for record in result if isinstance(result, list) else [result]:
30 name = record['name']
31 assert name in packets and name not in observed, 'Unexpected or duplicate native result'
32 assert record['error'] is None and record['pages'] == 1, f'{name}: native open failed'
33 assert record['source_sha256'] == hashlib.sha256(packets[name].read_bytes()).hexdigest(), f'{name}: native input differs'
34 page, = (worker / 'results' / name).glob('page-*.xml')
35 page = ET.parse(page).getroot()
36 containers = page.findall('one:Title', ns) + page.findall('one:Outline', ns)
37 observed[name] = [''.join(char for char, _ in paragraph) for paragraph in native_characters(page, containers)]
38 assert set(observed) == expected_names, 'Missing native captures'
39 for record in records:
40 fixture = record['case'].rsplit('-', 1)[0]
41 intent = json.loads((cases / f'{fixture}-intent.json').read_text())
42 baseline = observed[f'source-{fixture}']
43 assert baseline.count(intent['before']) == 1, 'The native source lacks a unique editing target'
44 for role in ('interrupted', 'recovered'):
45 replacement = intent[record['visible']] if role == 'interrupted' else intent['after'] + intent['suffix']
46 expected = [replacement if text == intent['before'] else text for text in baseline]
47 assert observed[f'{role}-{record["case"]}'] == expected, f'{role}-{record["case"]}: native content differs from the recorded outcome'
48 return {'cases': len(records), 'cold_native_opens': len(observed), 'exact_native_text': True}
49
50
51def run(root, output):
52 assert json.loads((root / 'verification.json').read_text())['server_hashes_match']
53 output.mkdir(parents=True, exist_ok=False)
54 (output / 'scripts').mkdir()
55 scripts = [output / 'scripts' / name for name in ('cold.ps1', 'probe.ps1')]
56 for path in scripts: path.write_bytes((ROOT / 'tools/native' / path.name).read_bytes())
57 packets = [(role, path) for role in ('source', 'interrupted', 'recovered')
58 for path in sorted((root / 'cases' / role).glob('*.one'))]
59 workers = min(8, len(packets))
60 inputs = [output / f'input-{i}' for i in range(workers)]
61 for path in inputs: path.mkdir()
62 for i, (role, path) in enumerate(packets):
63 (inputs[i % workers] / f'{role}-{path.name}').symlink_to(path.resolve())
64 def capture(i): probe(inputs[i], output / f'worker-{i}', scripts)
65 with ThreadPoolExecutor(max_workers=workers) as pool:
66 list(pool.map(capture, range(workers)))
67 result = verify(root, output)
68 (output / 'verification.json').write_text(json.dumps(result, indent=2))
69 print(json.dumps(result), flush=True)
70
71
72if __name__ == '__main__':
73 parser = argparse.ArgumentParser(description=__doc__)
74 parser.add_argument('run', type=Path)
75 parser.add_argument('captures', type=Path)
76 parser.add_argument('--verify-only', action='store_true')
77 args = parser.parse_args()
78 def interrupted(_signal, _frame): raise KeyboardInterrupt
79 signal.signal(signal.SIGTERM, interrupted)
80 if args.verify_only: print(json.dumps(verify(args.run.resolve(), args.captures.resolve()), indent=2))
81 else: run(args.run.resolve(), args.captures.resolve())
tools/verify_smb_overlap.py created+123
...@@ -0,0 +1,123 @@
1"""Verify successful I/O inside overlapping SMB reader and writer guards."""
2import json
3from collections import Counter
4
5
6class PendingOverlap(AssertionError):
7 pass
8
9
10def renames(request):
11 return request['command'] == 17 and request.get('info_type') == 1 and request.get('info_class') == 10
12
13
14def verify(events, phase=None):
15 pending, files, peers, pairs = {}, {}, {}, {}
16 progress = Counter()
17 path_epoch = 0
18 active_since = -1 if phase is None else None
19 for index, event in enumerate(events):
20 assert not event.get('trace_error'), 'Trace collection failed'
21 assert not event.get('encrypted'), 'Encrypted traffic cannot establish I/O overlap'
22 if phase is not None and event.get('control', {}).get('phase') == phase:
23 active_since = index
24 connection = event.get('connection')
25 if event.get('opened'):
26 peers[connection] = event['peer'][0]
27 if event.get('closed'):
28 files = {key: value for key, value in files.items() if key[0] != connection}
29 if 'command' not in event:
30 continue
31 message = connection, event['message']
32 if event['direction'] == 'request':
33 if renames(event):
34 files.clear()
35 path_epoch += 1
36 pending[message] = index, event, path_epoch
37 if event['command'] == 6:
38 files.pop((connection, event['file_id']), None)
39 elif event['command'] == 10:
40 file = files.get((connection, event['file_id']))
41 if file:
42 for offset, length, flags in event['locks']:
43 if flags & 4:
44 file['locks'] = {at: lock for at, lock in file['locks'].items()
45 if not offset <= at < offset + length}
46 continue
47 if event['status'] == '0x103':
48 continue
49 if message not in pending and event['command'] in (0, 18):
50 continue
51 request_index, request, request_epoch = pending.pop(message)
52 if renames(request):
53 files.clear()
54 path_epoch += 1
55 if event['status'] != '0x0':
56 continue
57 command = event['command']
58 if command == 5:
59 if request_epoch != path_epoch or any(renames(request) for _, request, _ in pending.values()):
60 continue
61 files[connection, event['file_id']] = {
62 'path': request['path'].lower(), 'access': request['access'],
63 'locks': {},
64 }
65 continue
66 if command not in (8, 9, 10):
67 continue
68 key = connection, request['file_id']
69 if key not in files:
70 continue
71 file = files[key]
72 if command == 10:
73 for offset, length, flags in request['locks']:
74 if length != 1 or offset not in (0xfffffffb, 0xfffffffd):
75 continue
76 if flags & 4:
77 file['locks'].pop(offset, None)
78 else:
79 file['locks'][offset] = index, flags & 3
80 continue
81 if active_since is None or request_index <= active_since or not file['path'].endswith('synthetic.one') or request['length'] == 0:
82 continue
83 native = peers[connection].startswith('192.168.77.')
84 progress[('native' if native else 'host', connection, 'read' if command == 8 else 'write')] += 1
85 for other_key, other in files.items():
86 if key[0] == other_key[0] or file['path'] != other['path']:
87 continue
88 reader, writer = (file, other) if command == 8 else (other, file)
89 reader_key, writer_key = (key, other_key) if command == 8 else (other_key, key)
90 reader_lock = reader['locks'].get(0xfffffffb)
91 writer_lock = writer['locks'].get(0xfffffffd)
92 # Only read-only handles distinguish a reader from a writer's own validation reads.
93 if reader['access'] & 0x40000002 or not reader_lock or not writer_lock:
94 continue
95 if reader_lock[1] != 1 or writer_lock[1] != 2:
96 continue
97 if request_index <= max(reader_lock[0], writer_lock[0]):
98 continue
99 pair = reader_key + (reader_lock[0],) + writer_key + (writer_lock[0],)
100 observed = pairs.setdefault(pair, {'read': 0, 'write': 0,
101 'reader_peer': peers[reader_key[0]], 'writer_peer': peers[writer_key[0]]})
102 observed['read' if command == 8 else 'write'] += 1
103 assert active_since is not None, 'Requested trace phase was not observed'
104 both = [pair for pair in pairs.values() if pair['read'] and pair['write']]
105 native_writer_pairs = [pair for pair in both if pair['writer_peer'].startswith('192.168.77.')
106 and not pair['reader_peer'].startswith('192.168.77.')]
107 rust_writer_pairs = [pair for pair in both if not pair['writer_peer'].startswith('192.168.77.')]
108 if not both: raise PendingOverlap('No reader/writer guard pair performed both successful reads and writes while overlapping')
109 if not native_writer_pairs: raise PendingOverlap('No active Rust reader overlapped successful native writes')
110 if not rust_writer_pairs: raise PendingOverlap('No active reader overlapped successful Rust writes')
111 return {'active_guard_pairs': len(both), 'active_native_writer_pairs': len(native_writer_pairs),
112 'active_rust_writer_pairs': len(rust_writer_pairs),
113 'overlapping_reads': sum(pair['read'] for pair in both),
114 'overlapping_writes': sum(pair['write'] for pair in both),
115 'connections': [{'kind': kind, 'connection': connection, 'operation': operation, 'count': count}
116 for (kind, connection, operation), count in sorted(progress.items())]}
117
118
119if __name__ == '__main__':
120 import sys
121 with open(sys.argv[1]) as stream:
122 result = verify(json.loads(line) for line in stream)
123 print(json.dumps(result, indent=2))