diff --git a/corpus/page-lifecycle/creation/README.md b/corpus/page-lifecycle/creation/README.md new file mode 100644 index 0000000000000000000000000000000000000000..fa19f5425ee7f7d144d312df11408a4f36baec8b --- /dev/null +++ b/corpus/page-lifecycle/creation/README.md @@ -0,0 +1,37 @@ +# Page creation + +`candidate` adds four pages to `../03-renamed/notebook/Lifecycle.one`: an appended +page without a title node, a prepended page with an empty title field, and two +appended pages titled `New 🦋 é`. All thirteen ordered page identities survive +each fresh OneNote 2010 reopen. Identical artifacts are relative symlinks. + +`cold` captures the first reopen. `native` adds a distinct body paragraph to each +duplicate-title page through `tools/native/created-pages.ps1`; `native/cold` +reopens that result in another cache. `followup` prefixes those bodies with +`Rust + ` and the titles with `Reviewed `; `followup/cold` reopens that result. +The retained `provenance.json` records the native capture inputs and clone removal. + +After building workspace examples, run `python -m unittest discover -s tools +-p test_page_creation.py -v`. It compares native XML content and character formats, +ordered identities, and complete active object graphs across each cold reopen. +These thirteen-page comparisons need no graph normalization. + +Export fresh Rust candidates by setting each variable to a new output directory: + +| Core integration test (`cargo test -p onestore --test page TEST -- --exact`) | Variable | +| --- | --- | +| `native_section_accepts_empty_and_titled_pages_with_preserved_history` | `ONESTORE_PAGE_CREATION_OUTPUT` | +| `native_changes_on_created_pages_accept_rust_followups` | `ONESTORE_PAGE_FOLLOWUP_OUTPUT` | +| `repeated_page_creation_crosses_root_fragments_counters_and_section_checkpoints` | `ONESTORE_PAGE_STRESS_OUTPUT` | + +The last test produces 521 pages, crosses a section revision checkpoint and +transaction-counter rollover, and checks that every unpublished candidate still +exposes the complete preceding page order. Its large native capture is local +evidence. Reproduce with `tools/native_runner.py INPUT OUTPUT --expected-pages 521 +--collect-notebook`, then `PYTHONPATH=tools python tools/verify_page_creation.py +INPUT OUTPUT`. That comparison permits only exact native materialization of a +previously absent section metadata copy; all other graph fields compare exactly. + +This core operation deliberately creates one optional title outline without +generated date/time text or template content. MS-ONE 2.2.50 permits a title with +one or two outlines. The fixture establishes this explicit empty-page contract. diff --git a/corpus/page-lifecycle/creation/candidate/Lifecycle.one b/corpus/page-lifecycle/creation/candidate/Lifecycle.one new file mode 100644 index 0000000000000000000000000000000000000000..3dac230dfcad0e261e9327e945c79ed84af025b7 Binary files /dev/null and b/corpus/page-lifecycle/creation/candidate/Lifecycle.one differ diff --git a/corpus/page-lifecycle/creation/cold/notebook/Lifecycle.one b/corpus/page-lifecycle/creation/cold/notebook/Lifecycle.one new file mode 100644 index 0000000000000000000000000000000000000000..776a303621a5dc0fe041cfb0f6bcfb29784f5212 Binary files /dev/null and b/corpus/page-lifecycle/creation/cold/notebook/Lifecycle.one differ diff --git a/corpus/page-lifecycle/creation/cold/read/hierarchy.xml b/corpus/page-lifecycle/creation/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..5f5f4541a71288ce6f039105b1bef2b6cc92bbce --- /dev/null +++ b/corpus/page-lifecycle/creation/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/cold/read/page-000.xml b/corpus/page-lifecycle/creation/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..b74db02740bf5f6664bec471f6ceb4edfa69e947 --- /dev/null +++ b/corpus/page-lifecycle/creation/cold/read/page-000.xml @@ -0,0 +1,4 @@ + +Body child 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/cold/read/page-001.xml b/corpus/page-lifecycle/creation/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..62991791c86033a1286a58fba2ca36558e3150e6 --- /dev/null +++ b/corpus/page-lifecycle/creation/cold/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/cold/read/page-002.xml b/corpus/page-lifecycle/creation/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..a4888c4c3a60c26e6a1e7527a818fa76c0000738 --- /dev/null +++ b/corpus/page-lifecycle/creation/cold/read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/cold/read/page-003.xml b/corpus/page-lifecycle/creation/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..0a4fa38b36604ea31858926fc28563e3d48fce5e --- /dev/null +++ b/corpus/page-lifecycle/creation/cold/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/cold/read/page-004.xml b/corpus/page-lifecycle/creation/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..36b3f48ded7416caa1a1a3493c5ce738d378dd59 --- /dev/null +++ b/corpus/page-lifecycle/creation/cold/read/page-004.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/cold/read/page-005.xml b/corpus/page-lifecycle/creation/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..2841ac8de01c2941a57d6a5781785ce87c58f233 --- /dev/null +++ b/corpus/page-lifecycle/creation/cold/read/page-005.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/cold/read/page-006.xml b/corpus/page-lifecycle/creation/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..9e25e11773ae0c3c673cf196ba3c8d353f9cf201 --- /dev/null +++ b/corpus/page-lifecycle/creation/cold/read/page-006.xml @@ -0,0 +1,4 @@ + +Body grandchild 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/cold/read/page-007.xml b/corpus/page-lifecycle/creation/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..d4d158edf3e7f04a47d7e6fe30985c6e43b4eba8 --- /dev/null +++ b/corpus/page-lifecycle/creation/cold/read/page-007.xml @@ -0,0 +1,4 @@ + +Body parent 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/cold/read/page-008.xml b/corpus/page-lifecycle/creation/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..8f25f7fb29c1de845d267ae0a7432186062da69a --- /dev/null +++ b/corpus/page-lifecycle/creation/cold/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/cold/read/page-009.xml b/corpus/page-lifecycle/creation/cold/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..9bb9dde27336654424657d5c9e644849d564507d --- /dev/null +++ b/corpus/page-lifecycle/creation/cold/read/page-009.xml @@ -0,0 +1,4 @@ + +Body duplicate 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/cold/read/page-010.xml b/corpus/page-lifecycle/creation/cold/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..265b775930e7d1769f636d1662b677a553696018 --- /dev/null +++ b/corpus/page-lifecycle/creation/cold/read/page-010.xml @@ -0,0 +1,4 @@ + +Body automatic 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/cold/read/page-011.xml b/corpus/page-lifecycle/creation/cold/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..7c03f6ae8fca64df7719f2b108cb98ceb5ba8e83 --- /dev/null +++ b/corpus/page-lifecycle/creation/cold/read/page-011.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/cold/read/page-012.xml b/corpus/page-lifecycle/creation/cold/read/page-012.xml new file mode 100644 index 0000000000000000000000000000000000000000..9890c342b59f523cac95cb9193722bad8b6ca62e --- /dev/null +++ b/corpus/page-lifecycle/creation/cold/read/page-012.xml @@ -0,0 +1,4 @@ + +Body trailing 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/followup/Lifecycle.one b/corpus/page-lifecycle/creation/followup/Lifecycle.one new file mode 100644 index 0000000000000000000000000000000000000000..b6f78fe9a2da590e540d55a842742eb36fe7d173 Binary files /dev/null and b/corpus/page-lifecycle/creation/followup/Lifecycle.one differ diff --git a/corpus/page-lifecycle/creation/followup/cold/notebook/Lifecycle.one b/corpus/page-lifecycle/creation/followup/cold/notebook/Lifecycle.one new file mode 100644 index 0000000000000000000000000000000000000000..206670d8221019c61e7133b8626a14c837a5d3f1 Binary files /dev/null and b/corpus/page-lifecycle/creation/followup/cold/notebook/Lifecycle.one differ diff --git a/corpus/page-lifecycle/creation/followup/cold/read/hierarchy.xml b/corpus/page-lifecycle/creation/followup/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..f7d9ebde7686be747df76426cc89501e37dcee14 --- /dev/null +++ b/corpus/page-lifecycle/creation/followup/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-000.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..4742bf7a49b14bfeaec07003c7b4f461063d324a --- /dev/null +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-000.xml @@ -0,0 +1,4 @@ + +Body child 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-001.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..a2161a614bca5619a0c11e013b97c1610daeb6c0 --- /dev/null +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-002.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..8ae4e38d376aafaf3afe54f4fc83bcb156f694ef --- /dev/null +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-003.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..8068f394f7b8bfa45dc97905c4c1f3e69ce07354 --- /dev/null +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-004.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..9fa30270c14770a10545f36ef6b13e509c2bb1e0 --- /dev/null +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-004.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-005.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..0edd9bcab1194c3d9d1e8e688c7975b2beebd093 --- /dev/null +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-005.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-006.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..4b64cee072f1fd557c142d26e335f3d8307cd2f1 --- /dev/null +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-006.xml @@ -0,0 +1,4 @@ + +Body parent 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-007.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..d4fad8b51dc23795d32144dea512a0308d6bb8f1 --- /dev/null +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-007.xml @@ -0,0 +1,4 @@ + +Body grandchild 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-008.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..d26253e67ff7df8de34f3f04b9c19fd71b09ee97 --- /dev/null +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-009.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..5457a4e122714c0a67224ed80bc9ec1a7aba3b4f --- /dev/null +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-009.xml @@ -0,0 +1,4 @@ + +Body duplicate 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-010.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..e756008ceb91813adc6f64b434c7f1a84ac5bf4a --- /dev/null +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-010.xml @@ -0,0 +1,4 @@ + +Body automatic 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-011.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..42ea81a0966b70ee621212a3552ae3ae90be0989 --- /dev/null +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-011.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-012.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-012.xml new file mode 100644 index 0000000000000000000000000000000000000000..9abc381b9155abd5962d1a4f8c41d4d78862067d --- /dev/null +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-012.xml @@ -0,0 +1,4 @@ + +Body trailing 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/cold/notebook/Lifecycle.one b/corpus/page-lifecycle/creation/native/cold/notebook/Lifecycle.one new file mode 120000 index 0000000000000000000000000000000000000000..0c82f1045162a6bde2dc5f5fe0881c0130aca14d --- /dev/null +++ b/corpus/page-lifecycle/creation/native/cold/notebook/Lifecycle.one @@ -0,0 +1 @@ +../../notebook/Lifecycle.one \ No newline at end of file diff --git a/corpus/page-lifecycle/creation/native/cold/read/hierarchy.xml b/corpus/page-lifecycle/creation/native/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..4836e778e6af3fff3797afe9cad4ffcee75bb427 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-000.xml b/corpus/page-lifecycle/creation/native/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..30e03f8c5d7c32095c10ab54d530f2834febf2c7 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-001.xml b/corpus/page-lifecycle/creation/native/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..686a57f9a0006bf55f61dffc6ef0b48bd1ff2c31 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/cold/read/page-001.xml @@ -0,0 +1,4 @@ + +Body grandchild 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-002.xml b/corpus/page-lifecycle/creation/native/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..bf6f1f3285ced61f86352de7d9720ce3902b66a5 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/cold/read/page-002.xml @@ -0,0 +1,4 @@ + +Body parent 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-003.xml b/corpus/page-lifecycle/creation/native/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..b4a0399dc12d1b401c358ef95898a6f7e7e9dd02 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/cold/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-004.xml b/corpus/page-lifecycle/creation/native/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..bdde6f6c2dafbe40a1af2fdc132ae2ad526a51b0 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/cold/read/page-004.xml @@ -0,0 +1,4 @@ + +Body child 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-005.xml b/corpus/page-lifecycle/creation/native/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..556edf47723978c3db8c7ed78d6d841ad73fdde9 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/cold/read/page-005.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-006.xml b/corpus/page-lifecycle/creation/native/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..a7314ac9046523033e145cb67980fbb75e4b4cd1 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/cold/read/page-006.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-007.xml b/corpus/page-lifecycle/creation/native/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..1af4f16e7bd0db14f4ba7f8d1679d336822795b3 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/cold/read/page-007.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-008.xml b/corpus/page-lifecycle/creation/native/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..7464cc7dc7fea87e99960b6ad14fb2cceff20be4 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/cold/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-009.xml b/corpus/page-lifecycle/creation/native/cold/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..c8d4a0a8ed5b1ad9a362afce806bc09ed4008621 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/cold/read/page-009.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-010.xml b/corpus/page-lifecycle/creation/native/cold/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..2ac049eeed39fea44ae2da23e392b7591f1b06b7 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/cold/read/page-010.xml @@ -0,0 +1,4 @@ + +Body trailing 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-011.xml b/corpus/page-lifecycle/creation/native/cold/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..43386377e0063d58ee24dd56f0f58a8a856daccd --- /dev/null +++ b/corpus/page-lifecycle/creation/native/cold/read/page-011.xml @@ -0,0 +1,4 @@ + +Body duplicate 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-012.xml b/corpus/page-lifecycle/creation/native/cold/read/page-012.xml new file mode 100644 index 0000000000000000000000000000000000000000..d8b4366fe460f248ae133fd9ec8fd8e3e7c70568 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/cold/read/page-012.xml @@ -0,0 +1,4 @@ + +Body automatic 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/notebook/Lifecycle.one b/corpus/page-lifecycle/creation/native/notebook/Lifecycle.one new file mode 100644 index 0000000000000000000000000000000000000000..b60dfff4780a6d8dd3826fc2a515033d2e5f0cae Binary files /dev/null and b/corpus/page-lifecycle/creation/native/notebook/Lifecycle.one differ diff --git a/corpus/page-lifecycle/creation/native/read/hierarchy.xml b/corpus/page-lifecycle/creation/native/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..46f7de2e422eaa62f29644d33dd752fb0527c358 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/native/read/page-000.xml b/corpus/page-lifecycle/creation/native/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..b8cab291fd9a7f09e47daa1b311825a5035b039e --- /dev/null +++ b/corpus/page-lifecycle/creation/native/read/page-000.xml @@ -0,0 +1,4 @@ + +Body trailing 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/read/page-001.xml b/corpus/page-lifecycle/creation/native/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..3015c50816186e839b87dc7dc7d59d73f27fc535 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/native/read/page-002.xml b/corpus/page-lifecycle/creation/native/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..d7f2c174e35a8ae7f7eb175293702d85748e8bf5 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/read/page-002.xml @@ -0,0 +1,4 @@ + +Body duplicate 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/read/page-003.xml b/corpus/page-lifecycle/creation/native/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..e9d43bf99f32856357e6bbd970d6bcc248cd6111 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/read/page-003.xml @@ -0,0 +1,4 @@ + +Body automatic 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/read/page-004.xml b/corpus/page-lifecycle/creation/native/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..8ed9596a931a9c3ce235802b6c4b981ca388cb89 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/read/page-004.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/native/read/page-005.xml b/corpus/page-lifecycle/creation/native/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..3b4a678ee36c1430ad8c7de33bb226d2b7ec9b0f --- /dev/null +++ b/corpus/page-lifecycle/creation/native/read/page-005.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/native/read/page-006.xml b/corpus/page-lifecycle/creation/native/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..4693795ee03c300bd507cb4737d8c9550f6d24bf --- /dev/null +++ b/corpus/page-lifecycle/creation/native/read/page-006.xml @@ -0,0 +1,4 @@ + +Body grandchild 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/read/page-007.xml b/corpus/page-lifecycle/creation/native/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..e818ae26b54fd7bd9b60249de373bceaaa087704 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/read/page-007.xml @@ -0,0 +1,4 @@ + +Body parent 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/read/page-008.xml b/corpus/page-lifecycle/creation/native/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..b4ce7a9d9152ced7d6a6790478ac895cf31e2af5 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/native/read/page-009.xml b/corpus/page-lifecycle/creation/native/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..5ef8f5a66daa4a3e9e8d60818bfff6dccb6a319f --- /dev/null +++ b/corpus/page-lifecycle/creation/native/read/page-009.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/native/read/page-010.xml b/corpus/page-lifecycle/creation/native/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..34434e16da993550ce35f3520fcfa45a2178e281 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/read/page-010.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/native/read/page-011.xml b/corpus/page-lifecycle/creation/native/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..b97f3354d80313f0e6a5c44e57a4b0a46c29f6c8 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/read/page-011.xml @@ -0,0 +1,4 @@ + +Body child 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/read/page-012.xml b/corpus/page-lifecycle/creation/native/read/page-012.xml new file mode 100644 index 0000000000000000000000000000000000000000..5873da638217cf192a44a47a46ceb3eb70532f23 --- /dev/null +++ b/corpus/page-lifecycle/creation/native/read/page-012.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/creation/provenance.json b/corpus/page-lifecycle/creation/provenance.json new file mode 100644 index 0000000000000000000000000000000000000000..09523b2d417a016d27dd110a7251ccc4491d729b --- /dev/null +++ b/corpus/page-lifecycle/creation/provenance.json @@ -0,0 +1,115 @@ +{ + "cold": { + "capture": "evidence/m10/page-create-cold", + "run": { + "notebook": "/Users/clo/dev/one/evidence/m10/page-create-candidate", + "expected_pages": 13, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } + }, + "machine": { + "name": "m6-4b25125e", + "hostname": "ONE-M6-4B25125E" + }, + "teardown": { + "absent": true + } + }, + "native": { + "capture": "evidence/m10/page-create-native-edit", + "run": { + "notebook": "/Users/clo/dev/one/evidence/m10/page-create-cold/notebook", + "expected_pages": 13, + "author": "tools/native/created-pages.ps1", + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "author.ps1": "f7d94cfa418a971c2911bb3f57b9a9991c9e0b91d0f7d9c7526ca7c8cd5adc42", + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } + }, + "machine": { + "name": "m6-71dafa89", + "hostname": "ONE-M6-71DAFA89" + }, + "teardown": { + "absent": true + } + }, + "native/cold": { + "capture": "evidence/m10/page-create-native-edit-cold", + "run": { + "notebook": "/Users/clo/dev/one/evidence/m10/page-create-native-edit/notebook", + "expected_pages": 13, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } + }, + "machine": { + "name": "m6-eabc83c1", + "hostname": "ONE-M6-EABC83C1" + }, + "teardown": { + "absent": true + } + }, + "followup/cold": { + "capture": "evidence/m10/page-create-rust-followup-cold", + "run": { + "notebook": "/Users/clo/dev/one/evidence/m10/page-create-rust-followup", + "expected_pages": 13, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } + }, + "machine": { + "name": "m6-5a91d9d1", + "hostname": "ONE-M6-5A91D9D1" + }, + "teardown": { + "absent": true + } + } +} diff --git a/crates/onestore/README.md b/crates/onestore/README.md index d4ae6e6a8e6a3a2eb81c0c2ec42fac14f1a6ec21..ef5de8c3d810f34f1293aae8bddf90ae6fef35f9 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -45,6 +45,7 @@ harness also accepts `--client-profile release`. | `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities | | `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | +| `PageCreation`, `PreparedEdit::create_page` | Add an empty top-level page and its section entry atomically, retaining page identities across retries | | `create_table_of_contents` | Create ordered section entries from filenames and file identities | | `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references | | `replace_text`, `commit_text`, `commit_file_text` | Replace a UTF-16 range across ordinary text runs; publish text, run boundaries and modification time together | @@ -73,6 +74,13 @@ sections retain their encrypted structure and payloads. With the optional AES-128/CBC, SHA-1 password wrappers into a borrowed document view. Incorrect passwords, unsupported protection profiles and work-limit failures remain distinct. The source stays encrypted; protected writes are rejected. +`PageCreation::new` appends, or inserts before the first page space of an existing +series. `Some("")` creates an empty title field; `None` omits the title node. +The page has no body outlines, generated date/time text or applied template. +Retain the intent to preserve its page, title and space identities; existing +identities require reconciliation before retry. Body insertion and title edits +use those identities through the existing APIs. [Native page-creation fixtures](../../corpus/page-lifecycle/creation/README.md) +cover duplicate Unicode titles, native edits and Rust follow-up edits. Insertions update child references, reference counts, modification times and automatic titles atomically. Paragraphs can be nested or inserted into table cells; outline coordinates use points. `Insertion::with_formatting` includes nonoverlapping UTF-16 diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs index 4e3d2507a46971d073f8375baddd705eae20a8ed..49aa9d68405c10697d372f37cebbd3b15110aa36 100644 --- a/crates/onestore/src/commit.rs +++ b/crates/onestore/src/commit.rs @@ -183,6 +183,14 @@ pub struct PreparedEdit<'a> { } impl<'a> PreparedEdit<'a> { + /// Creates a page and its section entry in one transaction, retaining the intent's identities. + pub fn create_page(source: &'a [u8], page: &crate::PageCreation) -> Result { + Ok(Self { + source, + written: page.apply(source)?, + }) + } + /// Moves or removes a subtree and normalizes its containers in one revision. pub fn tree( source: &'a [u8], diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs index c5e2f825aa45f9d0bdddb9e643ff4ab2451b2691..5334f874fc04fd30ede8640713bf77bdfe8749c3 100644 --- a/crates/onestore/src/lib.rs +++ b/crates/onestore/src/lib.rs @@ -12,6 +12,7 @@ mod formatting; mod insertion; mod objects; mod outline; +mod page; mod paragraph; mod properties; #[cfg(feature = "protected")] @@ -35,6 +36,7 @@ pub use formatting::TextAttribute; pub use insertion::Insertion; pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision}; pub use outline::OutlineEdit; +pub use page::PageCreation; pub use paragraph::{ParagraphJoin, ParagraphSplit}; pub use properties::{IdStream, Property, PropertySets, Value}; pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex}; diff --git a/crates/onestore/src/page.rs b/crates/onestore/src/page.rs new file mode 100644 index 0000000000000000000000000000000000000000..bf44af9cf36e99aa638570ab4770adf2108157c2 --- /dev/null +++ b/crates/onestore/src/page.rs @@ -0,0 +1,260 @@ +use crate::{ + Error, ExGuid, + create::{current_timestamps, properties, string}, + document::{Document, Kind}, + write::{PropertyObject, RevisionEdit, fresh_guid, write_revisions}, +}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, sync::Arc}; + +fn invalid(message: &'static str) -> Error { + Error { offset: 0, message } +} + +/// An empty top-level page with stable identities and creation time. +/// Retain the intent across retries; an existing page identity rejects duplicate creation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PageCreation { + guid: [u8; 16], + series_guid: [u8; 16], + before: Option, + title: Option, + author: String, + created: u32, +} + +impl PageCreation { + /// Appends a page, or inserts before the first page space of an existing series. + /// `Some("")` creates an empty title field; `None` creates a page without a title node. + /// The page has no body outlines or generated date/time text and does not apply a template. + pub fn new(before: Option, title: Option<&str>, author: &str) -> Result { + let page = Self { + guid: fresh_guid()?, + series_guid: fresh_guid()?, + before, + title: title.map(str::to_owned), + author: author.to_owned(), + created: current_timestamps()?.0, + }; + page.validate()?; + Ok(page) + } + + pub fn space(&self) -> ExGuid { + ExGuid { + guid: self.guid, + n: 1, + } + } + + pub fn object(&self) -> ExGuid { + ExGuid { + guid: self.guid, + n: 12, + } + } + + pub fn title_object(&self) -> Option { + self.title.as_ref().map(|_| ExGuid { + guid: self.guid, + n: 16, + }) + } + + fn validate(&self) -> Result<(), Error> { + if self.guid == [0; 16] + || self.series_guid == [0; 16] + || self.guid == self.series_guid + || self.author.contains('\0') + || self.before.is_some_and(|id| id.guid == [0; 16]) + || self + .title + .as_ref() + .is_some_and(|title| title.contains(['\0', '\r', '\n', '\u{fffc}', '\u{fddf}'])) + { + return Err(invalid( + "Use a new page identity, an existing page anchor, and ordinary single-line title text", + )); + } + Ok(()) + } + + pub(crate) fn apply(&self, source: &[u8]) -> Result, Error> { + self.validate()?; + write_revisions(source, |index| { + if index.spaces.contains_key(&self.space()) { + return Err(invalid( + "This page identity already exists; reconcile the original creation", + )); + } + let document = Document::parse(index)?; + document.pages()?; + let section = &document.spaces[&document.root]; + let view = §ion.revisions[§ion.contexts[&ExGuid::default()]]; + let section_id = view.roots[&1]; + let section_node = &view.nodes[§ion_id]; + if !matches!(section_node.kind, Kind::Section { .. }) + || section_node.extra[0] + .iter() + .any(|field| field.id == 0x88001cde) + { + return Err(invalid("Choose an editable section for the new page")); + } + let position = match self.before { + None => section_node.children.len(), + Some(before) => section_node + .children + .iter() + .position(|id| view.nodes[id].spaces.first() == Some(&before)) + .ok_or_else(|| { + invalid("Insert before the first page of an existing series, or append") + })?, + }; + let raw = index.resolve(document.root, section.contexts[&ExGuid::default()])?; + let id = |n| ExGuid { guid: self.guid, n }; + let reference = |n: u32| n.to_le_bytes().to_vec(); + let timestamp = ((u64::from(self.created) + 315532800 + 11644473600) * 10000000) + .to_le_bytes() + .to_vec(); + let modified = || (0x14001d7a, self.created.to_le_bytes().to_vec()); + let mut table = BTreeMap::from([(0, self.guid)]); + let mut metadata_id = id(1); + for (byte, salt) in metadata_id.guid.iter_mut().zip([ + 0x31, 0xc0, 0xa8, 0x22, 0, 0x36, 0xee, 0x42, 0xb7, 0x14, 0xd7, 0xac, 0xda, 0x24, + 0x35, 0xe8, + ]) { + *byte ^= salt; + } + table.insert(1, metadata_id.guid); + let table = Arc::new(table); + let object = |jcid, values: Vec<_>| -> Result<_, Error> { + Ok(PropertyObject { + jcid, + bytes: properties(&values)?, + global_ids: Arc::clone(&table), + }) + }; + let title = self.title.as_deref().unwrap_or_default().trim_start(); + let metadata = vec![ + (0x1c001c30, self.guid.to_vec()), + (0x1c001cf3, string(title)), + (0x14001d82, reference(40)), + (0x1400348b, reference(40)), + (0x14001dff, reference(1)), + (0x18001c65, timestamp.clone()), + ]; + let mut page = vec![ + modified(), + (0x1c001d75, string(&self.author)), + (0x1c001d3c, string("")), + ]; + if self.title.is_some() { + page.push((0x24001d5f, reference(13))); + } + let mut objects = BTreeMap::from([ + (id(10), object(0x60037, vec![(0x24001c1f, reference(12))])?), + (id(11), object(0x20030, metadata.clone())?), + (id(12), object(0x6000b, page)?), + ]); + if let Some(title) = &self.title { + for (n, jcid, values) in [ + ( + 13, + 0x6002c, + vec![ + modified(), + (0x24001c20, reference(14)), + (0x14001c14, 0_f32.to_le_bytes().to_vec()), + (0x14001c15, 0_f32.to_le_bytes().to_vec()), + ], + ), + ( + 14, + 0x6000c, + vec![ + modified(), + (0x24001c20, reference(15)), + (0x0c001c03, vec![1]), + ], + ), + ( + 15, + 0x6000d, + vec![ + modified(), + (0x24001c1f, reference(16)), + (0x0c001c03, vec![1]), + (0x20001d78, reference(17)), + (0x20001d79, reference(17)), + (0x14001d09, self.created.to_le_bytes().to_vec()), + (0x88001cb4, vec![]), + ], + ), + ( + 16, + 0x6000e, + vec![ + modified(), + (0x1c001c22, string(title)), + (0x24001e13, reference(19)), + (0x2000342c, reference(18)), + (0x10001cfe, 0x409_u16.to_le_bytes().to_vec()), + (0x88001cb4, vec![]), + ], + ), + (17, 0x120001, vec![(0x1c001d75, string(&self.author))]), + ( + 18, + 0x12004d, + vec![ + (0x1c00345a, string("PageTitle")), + (0x1c001c0a, string("Calibri")), + (0x10001c0b, 34_u16.to_le_bytes().to_vec()), + ], + ), + (19, 0x12004d, vec![(0x14001c3b, reference(0x409))]), + ] { + objects.insert(id(n), object(jcid, values)?); + } + } + let series = object( + 0x60008, + vec![ + (0x1c001c30, self.series_guid.to_vec()), + (0x18001c65, timestamp), + (0x2c001d63, reference(1)), + (0x24003442, reference(257)), + ], + )?; + if raw.objects.contains_key(&id(2)) || raw.objects.contains_key(&metadata_id) { + return Err(invalid("The new page's section identities already exist")); + } + let mut parent = PropertyObject::from_object(&raw.objects[§ion_id])?; + let mut children = section_node.children.clone(); + children.insert(position, id(2)); + let mut references = Vec::new(); + for child in children { + references.extend_from_slice(&parent.reference(child)?); + } + parent.set(&[(0x24001c20, &references)])?; + Ok(BTreeMap::from([ + ( + document.root, + RevisionEdit::Update(BTreeMap::from([ + (section_id, parent), + (id(2), series), + (metadata_id, object(0x20030, metadata)?), + ])), + ), + ( + self.space(), + RevisionEdit::Create { + roots: BTreeMap::from([(1, id(10)), (2, id(11))]), + objects, + }, + ), + ])) + }) + } +} diff --git a/crates/onestore/src/write.rs b/crates/onestore/src/write.rs index aea01dddb259348307d1dd7e34eefe1762710257..6f3b7b67ce8f5681d1597d3ef3245939a9c6490d 100644 --- a/crates/onestore/src/write.rs +++ b/crates/onestore/src/write.rs @@ -354,6 +354,14 @@ pub(crate) struct PropertyObject { pub global_ids: Arc>, } +pub(crate) enum RevisionEdit { + Update(BTreeMap), + Create { + roots: BTreeMap, + objects: BTreeMap, + }, +} + impl PropertyObject { pub fn from_object(object: &crate::Object<'_>) -> Result { let ObjectData::Properties(bytes) = object.data else { @@ -597,13 +605,49 @@ pub(crate) fn write_revision( message: "Object space has no active default revision", })?; let revision = index.resolve(space, rid)?; - Ok(BTreeMap::from([(space, edit(&revision)?)])) + Ok(BTreeMap::from([( + space, + RevisionEdit::Update(edit(&revision)?), + )])) }) } +fn append_fragment( + source: &[u8], + output: &mut Vec, + list: &crate::NodeList, + nodes: &[Vec], +) -> Result<(u32, usize)> { + let last_fragment = *list.fragments.last().unwrap(); + let list_start = usize::try_from(last_fragment.offset).unwrap(); + let list_id = u32::from_le_bytes(source[list_start + 8..list_start + 12].try_into().unwrap()); + let chunk = append_list(output, list_id, nodes)?; + let start = usize::try_from(chunk.offset).unwrap(); + let sequence = u32::try_from(list.fragments.len()).map_err(|_| Error { + offset: list_start, + message: "File-node fragment sequences are exhausted", + })?; + output[start + 12..start + 16].copy_from_slice(&sequence.to_le_bytes()); + let last_node = list.nodes.last().unwrap(); + let node_header = u32::from_le_bytes( + source[last_node.offset..last_node.offset + 4] + .try_into() + .unwrap(), + ); + let nodes_end = last_node.offset + usize::try_from((node_header >> 10) & 0x1fff).unwrap(); + let tail = list_start + usize::try_from(last_fragment.length).unwrap() - 20; + if tail - nodes_end >= 4 { + output[nodes_end..nodes_end + 4].copy_from_slice(&node(0xff, None, &[])?); + } + output[tail..tail + 8].copy_from_slice(&chunk.offset.to_le_bytes()); + output[tail + 8..tail + 12].copy_from_slice(&(chunk.length as u32).to_le_bytes()); + + Ok((list_id, list.nodes.len() + nodes.len())) +} + pub(crate) fn write_revisions( source: &[u8], - edit: impl FnOnce(&RevisionIndex<'_>) -> Result>>, + edit: impl FnOnce(&RevisionIndex<'_>) -> Result>, ) -> Result> { let store = Store::parse(source)?; let is_section = store.header.file_type == FileType::Section; @@ -617,25 +661,63 @@ pub(crate) fn write_revisions( index.validate_current()?; let changes = edit(&index)?; let mut output = source.to_vec(); - let maximum = store + let mut maximum = store .transaction_fragments .iter() .flat_map(|fragment| fragment.entries.chunks_exact(8)) .map(|entry| u32::from_le_bytes(entry[..4].try_into().unwrap())) .max() .unwrap(); + let mut allocate_list = || { + maximum = maximum.checked_add(1).ok_or(Error { + offset: 0, + message: "File-node list identities are exhausted", + })?; + Ok::<_, Error>(maximum) + }; let mut counts = Vec::new(); - for (space, mut replacements) in changes { - let rid = *index - .spaces - .get(&space) - .and_then(|space| space.labels.get(&(ExGuid::default(), 1))) - .ok_or(Error { - offset: 0, - message: "Object space has no active default revision", - })?; - let mut revision = index.resolve(space, rid)?; - let reachable = revision.reachable()?; + let mut root_nodes = Vec::new(); + for (space, change) in changes { + let (rid, mut revision, mut replacements) = match change { + RevisionEdit::Update(objects) => { + let rid = *index + .spaces + .get(&space) + .and_then(|space| space.labels.get(&(ExGuid::default(), 1))) + .ok_or(Error { + offset: 0, + message: "Object space has no active default revision", + })?; + (Some(rid), index.resolve(space, rid)?, objects) + } + RevisionEdit::Create { roots, objects } => { + if !is_section || space.guid == [0; 16] || index.spaces.contains_key(&space) { + return Err(Error { + offset: 0, + message: "Choose a new object-space identity in a section file", + }); + } + if roots.is_empty() || objects.is_empty() { + return Err(Error { + offset: 0, + message: "New object space needs roots and objects", + }); + } + ( + None, + crate::ResolvedRevision { + roots, + objects: BTreeMap::new(), + }, + objects, + ) + } + }; + let reachable = if rid.is_some() { + revision.reachable()? + } else { + BTreeSet::new() + }; for (id, replacement) in &replacements { if let Some(object) = revision.objects.get(id) { if !reachable.contains(id) { @@ -769,11 +851,10 @@ pub(crate) fn write_revisions( } // Native cold-open fails on long dependency chains; cap their depth at 512. - let checkpoint = std::iter::successors(Some(rid), |id| { - index.spaces[&space].revisions[id].dependency - }) - .nth(511) - .is_some(); + let checkpoint = rid.is_none() + || std::iter::successors(rid, |id| index.spaces[&space].revisions[id].dependency) + .nth(511) + .is_some(); let selected: Vec<_> = revision .objects .iter() @@ -821,13 +902,25 @@ pub(crate) fn write_revisions( }; let mut start = Vec::new(); new_rid.encode(&mut start); - if checkpoint { ExGuid::default() } else { rid }.encode(&mut start); + if checkpoint { + ExGuid::default() + } else { + rid.unwrap() + } + .encode(&mut start); if !is_section { start.extend_from_slice(&0_u64.to_le_bytes()); } start.extend_from_slice(&1_u32.to_le_bytes()); start.extend_from_slice(&0_u16.to_le_bytes()); - let mut manifest = vec![node(if is_section { 0x1e } else { 0x1b }, None, &start)?]; + let mut manifest = Vec::new(); + if rid.is_none() { + let mut payload = Vec::new(); + space.encode(&mut payload); + payload.extend_from_slice(&0_u32.to_le_bytes()); + manifest.push(node(0x14, None, &payload)?); + } + manifest.push(node(if is_section { 0x1e } else { 0x1b }, None, &start)?); for (table, objects) in groups { let mut payload = Vec::new(); let mut group = if is_section { @@ -941,14 +1034,7 @@ pub(crate) fn write_revisions( } if is_section { group.push(node(0xb8, None, &[])?); - let group_id = u32::try_from(counts.len()) - .ok() - .and_then(|n| n.checked_add(1)) - .and_then(|n| maximum.checked_add(n)) - .ok_or(Error { - offset: 0, - message: "File-node list identities are exhausted", - })?; + let group_id = allocate_list()?; let chunk = append_list(&mut output, group_id, &group)?; counts.push((group_id, group.len())); manifest.push(node(0xb0, Some(Reference::NodeList(chunk)), &payload)?); @@ -979,47 +1065,51 @@ pub(crate) fn write_revisions( } } manifest.push(node(0x1c, None, &[])?); - let root = store.list(store.header.root)?; - let space_node = root - .nodes - .iter() - .find(|node| node.id == 8 && node.fields(&store).exguid() == Ok(space)) - .ok_or(Error { - offset: 0, - message: "Object space is absent from the root list", - })?; - let space_list = space_node.referenced_list(&store)?; - let revision_node = space_list.iter().rfind(|node| node.id == 0x10).unwrap(); - let Some(Reference::NodeList(manifest_reference)) = revision_node.reference else { - unreachable!() - }; - let revision_list = store.list(manifest_reference)?; - let last_fragment = *revision_list.fragments.last().unwrap(); - let list_start = usize::try_from(last_fragment.offset).unwrap(); - let list_id = - u32::from_le_bytes(source[list_start + 8..list_start + 12].try_into().unwrap()); - let manifest_chunk = append_list(&mut output, list_id, &manifest)?; - let manifest_start = usize::try_from(manifest_chunk.offset).unwrap(); - let sequence = u32::try_from(revision_list.fragments.len()).map_err(|_| Error { - offset: list_start, - message: "File-node fragment sequences are exhausted", - })?; - output[manifest_start + 12..manifest_start + 16].copy_from_slice(&sequence.to_le_bytes()); - let last_node = revision_list.nodes.last().unwrap(); - let node_header = u32::from_le_bytes( - source[last_node.offset..last_node.offset + 4] - .try_into() - .unwrap(), - ); - let nodes_end = last_node.offset + usize::try_from((node_header >> 10) & 0x1fff).unwrap(); - let tail = list_start + usize::try_from(last_fragment.length).unwrap() - 20; - if tail - nodes_end >= 4 { - output[nodes_end..nodes_end + 4].copy_from_slice(&node(0xff, None, &[])?); + if rid.is_none() { + let list_id = allocate_list()?; + let chunk = append_list(&mut output, list_id, &manifest)?; + counts.push((list_id, manifest.len())); + let mut payload = Vec::new(); + space.encode(&mut payload); + let nodes = vec![ + node(0xc, None, &payload)?, + node(0x10, Some(Reference::NodeList(chunk)), &[])?, + ]; + let list_id = allocate_list()?; + let chunk = append_list(&mut output, list_id, &nodes)?; + counts.push((list_id, nodes.len())); + root_nodes.push(node(8, Some(Reference::NodeList(chunk)), &payload)?); + } else { + let root = store.list(store.header.root)?; + let space_node = root + .nodes + .iter() + .find(|node| node.id == 8 && node.fields(&store).exguid() == Ok(space)) + .ok_or(Error { + offset: 0, + message: "Object space is absent from the root list", + })?; + let space_list = space_node.referenced_list(&store)?; + let revision_node = space_list.iter().rfind(|node| node.id == 0x10).unwrap(); + let Some(Reference::NodeList(manifest_reference)) = revision_node.reference else { + unreachable!() + }; + let revision_list = store.list(manifest_reference)?; + counts.push(append_fragment( + source, + &mut output, + revision_list, + &manifest, + )?); } - output[tail..tail + 8].copy_from_slice(&manifest_chunk.offset.to_le_bytes()); - output[tail + 8..tail + 12].copy_from_slice(&(manifest_chunk.length as u32).to_le_bytes()); - - counts.push((list_id, revision_list.nodes.len() + manifest.len())); + } + if !root_nodes.is_empty() { + counts.push(append_fragment( + source, + &mut output, + store.list(store.header.root)?, + &root_nodes, + )?); } if counts.is_empty() { return Ok(source.to_vec()); diff --git a/crates/onestore/src/write/tests/multi.rs b/crates/onestore/src/write/tests/multi.rs index 191a5d88846f581bba983d8c2eac71cb0fad0e70..e3bec7129975a4c305656a2b0fdfda4fcda071c9 100644 --- a/crates/onestore/src/write/tests/multi.rs +++ b/crates/onestore/src/write/tests/multi.rs @@ -1,7 +1,7 @@ use crate::{ ExGuid, RevisionIndex, Store, document::{Document, Kind}, - write::{PropertyObject, write_revisions}, + write::{PropertyObject, RevisionEdit, write_revisions}, }; use std::collections::BTreeMap; @@ -90,7 +90,10 @@ fn nest(source: &[u8]) -> Vec { object.set(&[(0x24001c20, &children)])?; replacements.insert(root, object); changes.insert(index.root, replacements); - Ok(changes) + Ok(changes + .into_iter() + .map(|(sid, objects)| (sid, RevisionEdit::Update(objects))) + .collect()) }) .unwrap() } @@ -213,7 +216,7 @@ fn empty_batches_and_invalid_spaces_do_not_produce_an_edit() { assert!( write_revisions(SOURCE, |_| Ok(BTreeMap::from([( ExGuid::default(), - BTreeMap::new() + RevisionEdit::Update(BTreeMap::new()) )]))) .is_err() ); @@ -247,7 +250,10 @@ fn repeated_multi_space_edits_cross_counter_carries_and_checkpoint_each_space() object.set(&[(property, &step.to_le_bytes())])?; changes.insert(*sid, BTreeMap::from([(id, object)])); } - Ok(changes) + Ok(changes + .into_iter() + .map(|(sid, objects)| (sid, RevisionEdit::Update(objects))) + .collect()) }) .unwrap(); let store = Store::parse(&written).unwrap(); diff --git a/crates/onestore/tests/page.rs b/crates/onestore/tests/page.rs new file mode 100644 index 0000000000000000000000000000000000000000..f2ecc7d5c779d76dbca635b7252f8a0a01fa7393 --- /dev/null +++ b/crates/onestore/tests/page.rs @@ -0,0 +1,356 @@ +use onestore::{ + ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store, + document::{Document, Kind}, +}; + +#[path = "support/current.rs"] +mod current; +#[path = "support/disk.rs"] +mod disk; + +const SOURCE: &[u8] = + include_bytes!("../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one"); + +#[test] +fn native_section_accepts_empty_and_titled_pages_with_preserved_history() { + let original = Store::parse(SOURCE).unwrap(); + let old_index = RevisionIndex::parse(&original).unwrap(); + let original_pages = Document::parse(&old_index).unwrap().pages().unwrap(); + let mut expected = original_pages.clone(); + let mut source = SOURCE.to_vec(); + for (ordinal, title) in [None, Some(""), Some("New 🦋 é"), Some("New 🦋 é")] + .into_iter() + .enumerate() + { + let before = (ordinal == 1).then_some(original_pages[0].0); + let intent = PageCreation::new(before, title, "Rust author").unwrap(); + let serialized = serde_json::to_vec(&intent).unwrap(); + let retained = serde_json::from_slice(&serialized).unwrap(); + assert_eq!(intent, retained); + let prepared = PreparedEdit::create_page(&source, &retained).unwrap(); + let store = Store::parse(prepared.as_bytes()).unwrap(); + assert_eq!( + store.header.transaction_count, + original.header.transaction_count + ordinal as u32 + 1 + ); + assert_eq!(store.header.file_id, original.header.file_id); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + let document = Document::parse(&index).unwrap(); + let at = if ordinal == 1 { 0 } else { expected.len() }; + expected.insert(at, (intent.space(), intent.object())); + assert_eq!(document.pages().unwrap(), expected); + let view = &document.spaces[&intent.space()]; + let view = &view.revisions[&view.contexts[&ExGuid::default()]]; + assert!(view.nodes[&intent.object()].children.is_empty()); + assert_eq!( + view.nodes[&intent.object()].structure.len(), + usize::from(title.is_some()) + ); + let Kind::Metadata { + title: actual, + level, + } = &view.nodes[&view.roots[&2]].kind + else { + panic!() + }; + assert_eq!(actual.as_deref(), Some(title.unwrap_or_default())); + assert_eq!(*level, Some(1)); + if let Some(text) = intent.title_object() { + let Kind::RichText { + text, boilerplate, .. + } = &view.nodes[&text].kind + else { + panic!() + }; + assert_eq!(text, title.unwrap()); + assert!(!boilerplate); + } + for (sid, space) in &old_index.spaces { + for rid in space.revisions.keys() { + assert_eq!( + format!("{:?}", old_index.resolve(*sid, *rid).unwrap()), + format!("{:?}", index.resolve(*sid, *rid).unwrap()) + ); + } + if *sid != old_index.root { + assert_eq!(space.labels, index.spaces[sid].labels); + } + } + assert!(PreparedEdit::create_page(prepared.as_bytes(), &intent).is_err()); + source = prepared.as_bytes().to_vec(); + } + if let Some(output) = std::env::var_os("ONESTORE_PAGE_CREATION_OUTPUT") { + std::fs::create_dir(&output).unwrap(); + std::fs::write(std::path::Path::new(&output).join("Lifecycle.one"), &source).unwrap(); + } +} + +#[test] +fn created_pages_support_title_edits_and_body_insertion() { + let mut source = onestore::create_section("pages.one", "Original", "Author").unwrap(); + for title in [None, Some(""), Some("Explicit 🦋 é")] { + let intent = PageCreation::new(None, title, "Author").unwrap(); + source = PreparedEdit::create_page(&source, &intent) + .unwrap() + .as_bytes() + .to_vec(); + let insertion = + Insertion::outline(intent.object(), 36.0, 36.0, "Body 🦀 é", "Author").unwrap(); + source = PreparedEdit::insert(&source, intent.space(), &insertion) + .unwrap() + .as_bytes() + .to_vec(); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let view = &document.spaces[&intent.space()]; + let view = &view.revisions[&view.contexts[&ExGuid::default()]]; + let Kind::Metadata { title: actual, .. } = &view.nodes[&view.roots[&2]].kind else { + panic!() + }; + assert_eq!( + actual.as_deref(), + Some(title.filter(|s| !s.is_empty()).unwrap_or("Body 🦀 é")) + ); + if let Some(text) = intent.title_object() { + source = PreparedEdit::text( + &source, + intent.space(), + text, + 0..title.unwrap().encode_utf16().count() as u32, + "Renamed", + ) + .unwrap() + .as_bytes() + .to_vec(); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let view = &document.spaces[&intent.space()]; + let view = &view.revisions[&view.contexts[&ExGuid::default()]]; + let Kind::Metadata { title, .. } = &view.nodes[&view.roots[&2]].kind else { + panic!() + }; + assert_eq!(title.as_deref(), Some("Renamed")); + } + } +} + +#[test] +fn invalid_page_intents_and_nonleading_anchors_reject_before_publication() { + for title in ["a\nb", "\r", "\0", "\u{fffc}", "\u{fddf}"] { + assert!(PageCreation::new(None, Some(title), "Author").is_err()); + } + assert!(PageCreation::new(None, None, "\0").is_err()); + assert!(PageCreation::new(Some(ExGuid::default()), None, "Author").is_err()); + let source = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let pages = Document::parse(&index).unwrap().pages().unwrap(); + let intent = PageCreation::new(Some(pages[4].0), Some("Child"), "Author").unwrap(); + assert!(PreparedEdit::create_page(source, &intent).is_err()); + let mut value = serde_json::to_value(PageCreation::new(None, None, "Author").unwrap()).unwrap(); + value["guid"] = serde_json::to_value([0_u8; 16]).unwrap(); + let invalid: PageCreation = serde_json::from_value(value.clone()).unwrap(); + assert!(PreparedEdit::create_page(source, &invalid).is_err()); + value["unknown"] = true.into(); + assert!(serde_json::from_value::(value).is_err()); +} + +#[test] +fn new_space_and_section_entry_publish_as_one_complete_action() { + let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); + let intent = PageCreation::new(None, Some("New 🦋 é"), "Author").unwrap(); + let prepared = PreparedEdit::create_page(&source, &intent).unwrap(); + let old = current::current(&source); + let new = current::current(prepared.as_bytes()); + assert_eq!(new.len(), old.len() + 1); + for write_limit in [1, 17, 4096] { + let mut complete = disk::Disk { + visible: source.clone(), + durable: source.clone(), + operation: 0, + fail_at: None, + write_limit, + random: 1954, + }; + prepared.commit(&mut complete).unwrap(); + assert_eq!(complete.durable, prepared.as_bytes()); + for fail_at in 1..=complete.operation { + let mut interrupted = disk::Disk { + visible: source.clone(), + durable: source.clone(), + operation: 0, + fail_at: Some(fail_at), + write_limit, + random: 1954 + fail_at as u64, + }; + prepared.commit(&mut interrupted).unwrap_err(); + let observed = current::current(&interrupted.durable); + assert!( + observed == old || observed == new, + "{write_limit}:{fail_at}" + ); + } + } +} + +#[test] +fn repeated_page_creation_crosses_root_fragments_counters_and_section_checkpoints() { + let mut source = onestore::create_section("PageStress.one", "Original", "Author").unwrap(); + let original = source.clone(); + let original_store = Store::parse(&original).unwrap(); + let original_index = RevisionIndex::parse(&original_store).unwrap(); + let mut expected = Document::parse(&original_index).unwrap().pages().unwrap(); + let mut checkpoints = 0; + for step in 1..=520 { + let title = format!("Page {} 🦋 é", step % 7); + let intent = PageCreation::new( + None, + (step % 2 != 0).then_some(title.as_str()), + "Stress author", + ) + .unwrap(); + let prepared = PreparedEdit::create_page(&source, &intent).unwrap(); + let mut unpublished = prepared.as_bytes().to_vec(); + unpublished[96..100].copy_from_slice(&source[96..100]); + let old_store = Store::parse(&unpublished).unwrap(); + let old_index = RevisionIndex::parse(&old_store).unwrap(); + assert_eq!( + Document::parse(&old_index).unwrap().pages().unwrap(), + expected + ); + let store = Store::parse(prepared.as_bytes()).unwrap(); + assert_eq!(store.header.root, original_store.header.root); + assert_eq!( + store.header.transaction_count, + original_store.header.transaction_count + step + ); + let index = RevisionIndex::parse(&store).unwrap(); + assert_eq!( + index.spaces.len(), + original_index.spaces.len() + step as usize + ); + expected.push((intent.space(), intent.object())); + assert_eq!(Document::parse(&index).unwrap().pages().unwrap(), expected); + let section = &index.spaces[&index.root]; + let rid = section.labels[&(ExGuid::default(), 1)]; + checkpoints += usize::from(section.revisions[&rid].dependency.is_none()); + for (sid, space) in &original_index.spaces { + for rid in space.revisions.keys() { + assert_eq!( + format!("{:?}", original_index.resolve(*sid, *rid).unwrap()), + format!("{:?}", index.resolve(*sid, *rid).unwrap()) + ); + } + } + source = prepared.as_bytes().to_vec(); + } + assert_eq!(checkpoints, 1); + if let Some(output) = std::env::var_os("ONESTORE_PAGE_STRESS_OUTPUT") { + std::fs::create_dir(&output).unwrap(); + std::fs::write( + std::path::Path::new(&output).join("PageStress.one"), + &source, + ) + .unwrap(); + } +} + +#[test] +fn native_changes_on_created_pages_accept_rust_followups() { + let initial = include_bytes!( + "../../../corpus/page-lifecycle/creation/native/cold/notebook/Lifecycle.one" + ); + let store = Store::parse(initial).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let mut targets = Vec::new(); + for (sid, _) in document.pages().unwrap() { + let space = &document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let mut title = None; + let mut body = None; + for (id, node) in &view.nodes { + if let Kind::RichText { text, .. } = &node.kind { + if text == "New 🦋 é" { + title = Some(*id); + } + if text.starts_with("Native body ") { + body = Some(*id); + } + } + } + if let (Some(title), Some(body)) = (title, body) { + targets.push((sid, title, body)); + } + } + assert_eq!(targets.len(), 2); + let mut source = initial.to_vec(); + for (sid, title, body) in targets { + for (object, prefix) in [(body, "Rust + "), (title, "Reviewed ")] { + source = PreparedEdit::text(&source, sid, object, 0..0, prefix) + .unwrap() + .as_bytes() + .to_vec(); + } + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let view = &document.spaces[&sid]; + let view = &view.revisions[&view.contexts[&ExGuid::default()]]; + assert!( + matches!(&view.nodes[&title].kind, Kind::RichText { text, .. } if text == "Reviewed New 🦋 é") + ); + assert!( + matches!(&view.nodes[&body].kind, Kind::RichText { text, .. } if text.starts_with("Rust + Native body ")) + ); + assert!( + matches!(&view.nodes[&view.roots[&2]].kind, Kind::Metadata { title: Some(title), .. } if title == "Reviewed New 🦋 é") + ); + } + if let Some(output) = std::env::var_os("ONESTORE_PAGE_FOLLOWUP_OUTPUT") { + std::fs::create_dir(&output).unwrap(); + std::fs::write(std::path::Path::new(&output).join("Lifecycle.one"), &source).unwrap(); + } +} + +#[test] +fn page_creation_preserves_native_features_and_file_data() { + for source in [ + include_bytes!("../../../corpus/native-ink/20260905-ui/notebook/synthetic.one").as_slice(), + include_bytes!("../../../corpus/native-external-assets/notebook/synthetic.one").as_slice(), + include_bytes!("../../../corpus/m6/native-features-01/notebook/Features.one").as_slice(), + ] { + let before = Store::parse(source).unwrap(); + let old = RevisionIndex::parse(&before).unwrap(); + let original = Document::parse(&old).unwrap(); + let intent = PageCreation::new(None, Some("Preserved features"), "Author").unwrap(); + let prepared = PreparedEdit::create_page(source, &intent).unwrap(); + let after = Store::parse(prepared.as_bytes()).unwrap(); + let new = RevisionIndex::parse(&after).unwrap(); + let document = Document::parse(&new).unwrap(); + assert_eq!( + document.pages().unwrap().len(), + original.pages().unwrap().len() + 1 + ); + for (sid, space) in &old.spaces { + for rid in space.revisions.keys() { + let original = old.resolve(*sid, *rid).unwrap(); + let retained = new.resolve(*sid, *rid).unwrap(); + assert_eq!(format!("{original:?}"), format!("{retained:?}")); + for object in original.objects.values() { + if let Some(onestore::FileDataReference::Internal(id)) = + object.file_reference().unwrap() + { + assert_eq!(before.file_data(id).unwrap(), after.file_data(id).unwrap()); + } + } + } + if *sid != old.root { + assert_eq!(space.labels, new.spaces[sid].labels); + } + } + } +} diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 05afdb31cbee19b3952f956d79943754d5acb87a..13a8e908866d6a6d86414023b19f09314deaeb60 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -114,3 +114,10 @@ path = "fuzz_targets/offline_tree.rs" test = false doc = false bench = false + +[[bin]] +name = "page" +path = "fuzz_targets/page.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/page.rs b/fuzz/fuzz_targets/page.rs new file mode 100644 index 0000000000000000000000000000000000000000..36b68f756092379a905784e3ec5a61597cf04b08 --- /dev/null +++ b/fuzz/fuzz_targets/page.rs @@ -0,0 +1,138 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use onestore::{ + CommitState, ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store, + document::{Document, Kind}, +}; +use std::sync::LazyLock; + +#[path = "../../crates/onestore/tests/support/current.rs"] +mod current; +#[path = "../../crates/onestore/tests/support/disk.rs"] +mod disk; + +static SOURCE: LazyLock> = LazyLock::new(|| { + onestore::create_section("pages.one", "Original 🦀 é 東京", "Author").unwrap() +}); + +fuzz_target!(|input: &[u8]| { + if let Ok(intent) = serde_json::from_slice::(input) + && let Ok(prepared) = PreparedEdit::create_page(&SOURCE, &intent) + { + current::current(prepared.as_bytes()); + } + let mut persisted = SOURCE.clone(); + let mut caches = std::array::from_fn::<_, 12, _>(|_| SOURCE.clone()); + for step in input.chunks_exact(8).take(24) { + let actor = usize::from(step[0]) % caches.len(); + if step[1] % 3 == 0 { + caches[actor].clone_from(&persisted); + } + let source = &caches[actor]; + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let mut pages = document.pages().unwrap(); + let (sid, page) = pages[usize::from(step[2]) % pages.len()]; + let space = &document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let text = ["", "Same title", "é 🦋 東京", " spaces "][usize::from(step[3]) % 4]; + let titles: Vec<_> = view + .nodes + .iter() + .filter_map(|(id, node)| { + (node.extra[0].iter().any(|field| field.id == 0x88001cb4) + && matches!( + node.kind, + Kind::RichText { + boilerplate: false, + .. + } + )) + .then_some(*id) + }) + .collect(); + let (edit, title_update) = if step[1] & 8 != 0 && !titles.is_empty() { + let object = titles[0]; + let Kind::RichText { text: original, .. } = &view.nodes[&object].kind else { + unreachable!() + }; + ( + PreparedEdit::text( + source, + sid, + object, + 0..original.encode_utf16().count() as u32, + text, + ) + .unwrap(), + Some((sid, object, text)), + ) + } else if step[1] & 16 != 0 { + let intent = Insertion::outline(page, 36.0, 36.0, text, "Page fuzz").unwrap(); + ( + PreparedEdit::insert(source, sid, &intent).unwrap(), + Some((sid, intent.text_object(), text)), + ) + } else { + let before = (step[2] & 1 != 0).then_some(sid); + let title = (step[3] & 4 == 0).then_some(text); + let intent = PageCreation::new(before, title, "Page fuzz").unwrap(); + let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); + assert_eq!(intent, restored); + let edit = PreparedEdit::create_page(source, &restored).unwrap(); + let position = before.map_or(pages.len(), |sid| { + pages.iter().position(|p| p.0 == sid).unwrap() + }); + pages.insert(position, (intent.space(), intent.object())); + ( + edit, + intent + .title_object() + .map(|object| (intent.space(), object, text)), + ) + }; + let after_store = Store::parse(edit.as_bytes()).unwrap(); + let after_index = RevisionIndex::parse(&after_store).unwrap(); + let after_document = Document::parse(&after_index).unwrap(); + assert_eq!(after_document.pages().unwrap(), pages); + if let Some((sid, object, text)) = title_update { + let space = &after_document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + assert!( + matches!(&view.nodes[&object].kind, Kind::RichText { text: actual, .. } if actual == text) + ); + } + for (sid, space) in &index.spaces { + let rid = space.labels[&(ExGuid::default(), 1)]; + assert_eq!( + format!("{:?}", index.resolve(*sid, rid).unwrap()), + format!("{:?}", after_index.resolve(*sid, rid).unwrap()) + ); + } + let before = current::current(&persisted); + let after = current::current(edit.as_bytes()); + let mut disk = disk::Disk { + visible: persisted.clone(), + durable: persisted.clone(), + operation: 0, + fail_at: (step[4] != 0).then_some(usize::from(u16::from_le_bytes([step[4], step[5]]))), + write_limit: if step[6] & 1 == 0 { 17 } else { 4096 }, + random: u64::from(step[7]) + 1, + }; + let result = edit.commit(&mut disk); + let observed = current::current(&disk.durable); + match result { + Ok(()) => assert_eq!(observed, after), + Err(error) => { + assert!(observed == before || observed == after); + match error.state { + CommitState::NotCommitted => assert_eq!(observed, before), + CommitState::Committed => assert_eq!(observed, after), + CommitState::Unknown => {} + } + } + } + persisted = disk.durable; + } +}); diff --git a/tools/native/created-pages.ps1 b/tools/native/created-pages.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..b77bdeecbc2c657398800e2ec865444325fe0bae --- /dev/null +++ b/tools/native/created-pages.ps1 @@ -0,0 +1,35 @@ +param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost +$app = New-Object -ComObject OneNote.Application +$notebook = '' +try { + $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebook, 0) + $section = '' + $app.OpenHierarchy('Lifecycle.one', $notebook, [ref]$section, 0) + $deadline = [DateTime]::UtcNow.AddSeconds(30) + do { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$tree = $hierarchy + $pages = @($tree.SelectNodes('//*[local-name()="Page"]')) + if ($pages.Count -eq 13) { break } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $deadline) + $targets = @($pages | Where-Object { $_.GetAttribute('name').StartsWith('New ') }) + if ($pages.Count -ne 13 -or $targets.Count -ne 2) { throw 'Expected thirteen pages and two created title controls.' } + $inputs = Join-Path $Root 'inputs' + [void](New-Item -ItemType Directory -Path $inputs) + for ($i = 0; $i -lt $targets.Count; $i++) { + $pageId = [Security.SecurityElement]::Escape($targets[$i].GetAttribute('ID')) + $xml = "Native body $i after Rust page creation." + [IO.File]::WriteAllText((Join-Path $inputs ("native-$i.xml")), $xml, [Text.Encoding]::UTF8) + $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false) + } + $app.SyncHierarchy($notebook) +} finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + [GC]::Collect() + [GC]::WaitForPendingFinalizers() +} diff --git a/tools/test_page_creation.py b/tools/test_page_creation.py new file mode 100644 index 0000000000000000000000000000000000000000..5a0c777299347384402c658db116e7fa874901d5 --- /dev/null +++ b/tools/test_page_creation.py @@ -0,0 +1,48 @@ +import json +from pathlib import Path +import subprocess +from tempfile import TemporaryDirectory +import unittest + +from document_model import EXPORTER, ordered_pages, walk +from verify_page_creation import compare + +FIXTURE = Path(__file__).resolve().parent.parent / 'corpus/page-lifecycle/creation' + + +class PageCreationTest(unittest.TestCase): + def test_created_pages_and_both_writers_survive_native_cold_reopens(self): + for source, capture in [('candidate', 'cold'), ('native/notebook', 'native/cold'), ('followup', 'followup/cold')]: + with self.subTest(source=source): + compare(FIXTURE / source, FIXTURE / capture) + + def test_duplicate_titles_keep_distinct_pages_through_native_and_rust_edits(self): + with TemporaryDirectory() as temporary: + identities = None + for ordinal, path in enumerate(('candidate', 'native/cold/notebook', 'followup/cold/notebook')): + output = Path(temporary) / str(ordinal) + subprocess.run([EXPORTER, FIXTURE / path / 'Lifecycle.one', output], check=True) + document = json.loads((output / 'document.json').read_text()) + pages = list(ordered_pages(document)) + self.assertEqual(len(pages), 13) + current = [(sid, page) for sid, _, _, page in pages] + self.assertEqual(len(set(current)), 13) + if identities is None: + identities = current + self.assertEqual(identities, current) + for index, (_, _, view, page) in enumerate(pages[-2:]): + title = ('Reviewed ' if ordinal == 2 else '') + 'New 🦋 é' + self.assertEqual(view['nodes'][view['roots']['2']]['kind']['title'], title) + expected = [title] + if ordinal: + expected.append(('Rust + ' if ordinal == 2 else '') + f'Native body {index} after Rust page creation.') + self.assertEqual([node['kind']['text'] for _, node in walk(view, page) + if node['kind']['type'] == 'RichText'], expected) + for at, titled in [(0, True), (10, False)]: + _, _, view, page = pages[at] + self.assertEqual(bool(view['nodes'][page]['structure']), titled) + self.assertEqual(view['nodes'][page]['children'], []) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/verify_page_creation.py b/tools/verify_page_creation.py new file mode 100644 index 0000000000000000000000000000000000000000..01d3d6a533550f36ba45d1cdd2ee7d44ea2eb208 --- /dev/null +++ b/tools/verify_page_creation.py @@ -0,0 +1,73 @@ +"""Compare created pages with native XML and cold-reopened object graphs.""" +import argparse +from copy import deepcopy +import json +from pathlib import Path +import runpy +import shutil +import subprocess +from tempfile import TemporaryDirectory +from uuid import UUID + +from document_model import EXPORTER, ordered_pages, view + +compare_document = runpy.run_path(str(Path(__file__).with_name('verify-document.py')))['compare'] + + +def compare(source, capture): + files = list(source.glob('*.one')) + assert len(files) == 1 + with TemporaryDirectory() as temporary: + temporary = Path(temporary) + native = temporary / 'native/read' + shutil.copytree(capture / 'read', native) + notebook = temporary / 'notebook' + notebook.mkdir() + (notebook / files[0].name).symlink_to(files[0].resolve()) + compare_document(notebook, native) + models = [] + for ordinal, file in enumerate((files[0], capture / 'notebook' / files[0].name)): + output = temporary / str(ordinal) + subprocess.run([EXPORTER, file, output], check=True) + models.append(json.loads((output / 'document.json').read_text())) + before, after = models + pages = list(ordered_pages(before)) + assert [(sid, page) for sid, _, _, page in pages] == [ + (sid, page) for sid, _, _, page in ordered_pages(after)] + assert before['root'] == after['root'] + assert before['spaces'].keys() == after['spaces'].keys() + filled = 0 + for sid in before['spaces']: + _, original = view(before, sid) + _, observed = view(after, sid) + expected = deepcopy(original['nodes']) + if sid == before['root']: + for series_id in original['nodes'][original['roots']['1']]['children']: + series = expected[series_id] + existing = [p for p in series['extra'][0] if p['id'] == 0x24003442] + added = [p for p in observed['nodes'][series_id]['extra'][0] if p['id'] == 0x24003442] + if existing or not added: + continue + copies = [] + for page_sid in series['spaces']: + (_, _, page_view, _), = [p for p in pages if p[0] == page_sid] + # Native uses the space GUID when it differs from the page-node GUID. + guid = UUID(page_sid.split('}')[0].strip('{')).bytes_le + salt = UUID('22a8c031-3600-42ee-b714-d7acda2435e8').bytes_le + metadata = '{' + str(UUID(bytes_le=bytes(a ^ b for a, b in zip(guid, salt, strict=True)))).upper() + '},1' + assert metadata not in expected + expected[metadata] = page_view['nodes'][page_view['roots']['2']] + copies.append(metadata) + filled += 1 + series['extra'][0].append({'id': 0x24003442, 'value': {'Objects': copies}}) + assert original['roots'] == observed['roots'] + assert expected == observed['nodes'], sid + print(f'Passed: {len(pages)} ordered pages and preserved active graphs; {filled} missing section metadata copies filled natively') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('source', type=Path) + parser.add_argument('capture', type=Path) + args = parser.parse_args() + compare(args.source, args.capture)