diff --git a/corpus/table-growth/README.md b/corpus/table-growth/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d71fe1b8d9208018bd61aad243997c45985842b4 --- /dev/null +++ b/corpus/table-growth/README.md @@ -0,0 +1,8 @@ +# Table growth + +`candidate/notebook` is `native/20260905-05` after `a_text_edit_stores_the_table_entries_it_names` +(`ONESTORE_TABLE_EXPORT`) appended words to a paragraph of every page. Each rewritten +object group stores only the global id table entries its objects name, so the stored +indices have gaps where the source revision's table named GUIDs the edit does not use. +`cold` is OneNote 2010's cold read of it from a fresh clone; OneNote left the file as +written. diff --git a/corpus/table-growth/candidate/notebook/Open Notebook.onetoc2 b/corpus/table-growth/candidate/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..a33d3bfd210f844e3db618e648ac898adeee09c6 Binary files /dev/null and b/corpus/table-growth/candidate/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/table-growth/candidate/notebook/synthetic.one b/corpus/table-growth/candidate/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..8eb209c5f5083a04423be023e9ad4d5125ef4d33 Binary files /dev/null and b/corpus/table-growth/candidate/notebook/synthetic.one differ diff --git a/corpus/table-growth/cold/commands.jsonl b/corpus/table-growth/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..8893c45740ddd1c767d2ab26e9acd34491931144 --- /dev/null +++ b/corpus/table-growth/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-6162C217 -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/table-growth/cold/machine.json b/corpus/table-growth/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..bee592ada4b229be5548ec95aa387b2b7fdfc7de --- /dev/null +++ b/corpus/table-growth/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-6162c217", "hostname": "ONE-M6-6162C217"} diff --git a/corpus/table-growth/cold/notebook/Open Notebook.onetoc2 b/corpus/table-growth/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..a33d3bfd210f844e3db618e648ac898adeee09c6 Binary files /dev/null and b/corpus/table-growth/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/table-growth/cold/notebook/synthetic.one b/corpus/table-growth/cold/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..8eb209c5f5083a04423be023e9ad4d5125ef4d33 Binary files /dev/null and b/corpus/table-growth/cold/notebook/synthetic.one differ diff --git a/corpus/table-growth/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment b/corpus/table-growth/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment new file mode 100644 index 0000000000000000000000000000000000000000..66b6151e91f90df6a61a19ef2f4447da278e07b2 --- /dev/null +++ b/corpus/table-growth/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment @@ -0,0 +1 @@ +Fictitious attachment for native corpus. \ No newline at end of file diff --git a/corpus/table-growth/cold/read/environment.json b/corpus/table-growth/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..e5b11de4c42b83cd944d0715a488c8e0085e4110 --- /dev/null +++ b/corpus/table-growth/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-6162C217", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/table-growth/cold/read/hierarchy.xml b/corpus/table-growth/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..822dc11be592167eb3313b9346ccdae45fa17db1 --- /dev/null +++ b/corpus/table-growth/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/table-growth/cold/read/page-000.xml b/corpus/table-growth/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..bf1a56b3a78d6ecc8650f8af1220b40d6c579bfb --- /dev/null +++ b/corpus/table-growth/cold/read/page-000.xml @@ -0,0 +1,9 @@ + +Fictitious: café, 東京, مرحبا and a few more words]]>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA +AAAASUVORK5CYII= diff --git a/corpus/table-growth/cold/read/payloads.json b/corpus/table-growth/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..8ca5f91620fbeeeced9de312b5661b60431951a2 --- /dev/null +++ b/corpus/table-growth/cold/read/payloads.json @@ -0,0 +1,10 @@ +[ + { + "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", + "name": "fictitious-attachment.txt", + "object": "{DE1684DA-6810-4671-8EAF-CD3C3735DDC5}{11}{B0}", + "kind": "InsertedFile", + "page": "{5759809C-B0D2-45F2-8836-0F65B4195B50}{1}{B0}", + "bytes": 43 + } +] \ No newline at end of file diff --git a/corpus/table-growth/cold/run.json b/corpus/table-growth/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..ad0e4f9185c3c09c0088cfc44e4d66a0b7364e8f --- /dev/null +++ b/corpus/table-growth/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/m5/tables/notebook", + "expected_pages": 1, + "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" + } +} diff --git a/corpus/table-growth/cold/scripts/cold.ps1 b/corpus/table-growth/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/table-growth/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/table-growth/cold/scripts/read.ps1 b/corpus/table-growth/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/table-growth/cold/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/table-growth/cold/source.json b/corpus/table-growth/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..9eb5dd19f602ec69733d3f0d450cac0431e96167 --- /dev/null +++ b/corpus/table-growth/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 4816, + "sha256": "b2f857961b76258a1582b402c048b340e89962257681426b3bfc58df8e0adbfa", + "mtime_ns": 1788585005062916740 + }, + { + "path": "synthetic.one", + "bytes": 25392, + "sha256": "9acb9ab3c06daa698c55ca48bc2c100aba4609e9f546839a0aeb99db68296033", + "mtime_ns": 1789887306088599720 + } +] diff --git a/corpus/table-growth/cold/teardown.json b/corpus/table-growth/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/table-growth/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 90b37cc4d67c08f711224afc9273858290938e88..930e821b6c816c43a00c904400408fd3c1e30daf 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -348,10 +348,11 @@ when the page itself was moved to another section. Other URLs and unknown targets are `None`. With the optional `protected` feature, `Notebook::unlock(path, password)` -reads the pages of a `Locked` section, and `Notebook::save_unlocked(path, -password, space, page, author)` saves an edited one under the section's key, -straight to the file: nothing of a protected section is cached or queued, a -section changed since the read fails the save, and a wrong password is +reads a `Locked` section as `Unlocked { pages, .. }`, and +`Notebook::save_unlocked(path, password, &mut unlocked, space, page, author)` +saves an edited page under the section's key, straight to the file: nothing of +a protected section is cached or queued, a section written since `unlocked` +was read fails the save, and a wrong password is `Error::Protected(PasswordMismatch)`. `Section::import_page(page, author)` copies a page, usually read from another diff --git a/crates/notebook/examples/cache_probe.rs b/crates/notebook/examples/cache_probe.rs index e891b5fb00af48044cf05260213e0d34bbcbc847..f4cc24d21d3d0aeca47d816a700e41af600c6fb7 100644 --- a/crates/notebook/examples/cache_probe.rs +++ b/crates/notebook/examples/cache_probe.rs @@ -110,8 +110,9 @@ fn main() -> Result<(), Box> { !intent.before.objects.iter().any(|o| o.id() == object.id()) }) .collect(); - let [PageObject::Outline(outline)] = added.as_slice() else { - panic!("Expected one added outline") + // A save that replaced a pending one carries its outlines too. + let Some(PageObject::Outline(outline)) = added.last() else { + panic!("Expected an added outline") }; let text = outline.paragraphs[0].text().unwrap().text.text(); let operation: u64 = text.split_once(':').unwrap().0.parse()?; diff --git a/crates/notebook/src/assets.rs b/crates/notebook/src/assets.rs index d176734210a5156d5d41c39184272841ab989c3f..91f8a5b32f0d429f1ea4e5be248ca15e522b3dd6 100644 --- a/crates/notebook/src/assets.rs +++ b/crates/notebook/src/assets.rs @@ -79,8 +79,9 @@ pub(crate) fn key(filename: &str) -> Result { } fn referenced(connection: &Connection, key: &str) -> Result { - let (base, working) = crate::images::both(connection)?; - for image in [working, base] { + // One image at a time: the working image usually answers. + for read in [crate::images::working, crate::images::base] { + let image = read(connection)?; let store = Store::parse(&image)?; let index = RevisionIndex::parse(&store)?; let document = Document::parse(&index)?; diff --git a/crates/notebook/src/images.rs b/crates/notebook/src/images.rs index 87b2c6111b2eefb5acf39dc8b8fee69074866ca5..bf1e41265409a934469dd33e26afbe7b851e3ba1 100644 --- a/crates/notebook/src/images.rs +++ b/crates/notebook/src/images.rs @@ -46,10 +46,12 @@ fn restore(mut base: Vec, patch: &[u8]) -> Result> { Err(damaged().into()) }; }; - base.resize( - usize::try_from(u64::from_le_bytes(*length)).map_err(|_| damaged())?, - 0, - ); + // Every block past the base is a run, so a whole patch bounds the image. + let length = usize::try_from(u64::from_le_bytes(*length)) + .ok() + .filter(|length| *length <= base.len() + patch.len()) + .ok_or_else(damaged)?; + base.resize(length, 0); while let Some((header, rest)) = runs.split_first_chunk::<16>() { let offset = usize::try_from(u64::from_le_bytes(header[..8].try_into().unwrap())) .map_err(|_| damaged())?; @@ -83,6 +85,21 @@ pub(crate) fn both(connection: &Connection) -> Result<(Vec, Vec)> { Ok((base, working)) } +/// The working image's length, without restoring it. +pub(crate) fn working_length(connection: &Connection) -> Result { + Ok(connection.query_row( + "SELECT length(base), substr(working, 1, 8) FROM replica WHERE id=1", + [], + |row| { + let header: Vec = row.get(1)?; + Ok(match header.first_chunk::<8>() { + Some(length) => u64::from_le_bytes(*length), + None => row.get::<_, i64>(0)? as u64, + }) + }, + )?) +} + pub(crate) fn working(connection: &Connection) -> Result> { let (base, patch): (Vec, Vec) = connection.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| { @@ -141,6 +158,9 @@ mod tests { } assert!(difference(&base, &base).is_empty()); assert!(difference(&base, &edited).len() < 3 * BLOCK); + let mut huge = difference(&base, &edited); + huge[..8].copy_from_slice(&u64::MAX.to_le_bytes()); + assert!(restore(base.clone(), &huge).is_err()); let patch = difference(&base, &edited); for cut in 1..patch.len() { if let Ok(image) = restore(base.clone(), &patch[..cut]) { diff --git a/crates/notebook/src/recovery.rs b/crates/notebook/src/recovery.rs index 499aefd668821636be875d7b95e793795bcf2663..8d1b28c808528026e99ff249bde794e92feaa7be 100644 --- a/crates/notebook/src/recovery.rs +++ b/crates/notebook/src/recovery.rs @@ -144,7 +144,7 @@ fn summary(connection: &Connection) -> Result { [], |row| Ok((unsigned(row, 0)?, unsigned(row, 1)?)), )?; - let working_bytes = crate::images::working(connection)?.len() as u64; + let working_bytes = crate::images::working_length(connection)?; Ok(connection.query_row( "SELECT (SELECT count(*) FROM edits), (SELECT count(*) FROM conflicts), (SELECT count(*) FROM attempt), (SELECT count(*) FROM receipts), diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 4f36018af75e14d1ffe398d096b92b463ecb2dd0..84842e5dbeda6faa3342228bd00393d1751c13b1 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -91,6 +91,14 @@ pub trait Storage: Send + Sync { ) -> Result<()>; } +/// A password-protected section as `Notebook::unlock` read it. +#[cfg(feature = "protected")] +pub struct Unlocked { + pub pages: Vec<(ExGuid, Page)>, + /// The stored section the pages came from, which a save must still find in place. + snapshot: Vec, +} + /// A mounted notebook directory. struct Directory(PathBuf); @@ -622,43 +630,55 @@ impl Notebook { Ok(None) } - /// The pages of a password-protected section, for reading: nothing is cached or - /// written, and the decoded buffers go when the pages have been built. + /// The pages of a password-protected section: nothing is cached, and the decoded + /// buffers go when the pages have been built. #[cfg(feature = "protected")] - pub fn unlock(&self, path: &str, password: &str) -> Result> { + pub fn unlock(&self, path: &str, password: &str) -> Result { let path = self.section_path(path)?.path.clone(); - let bytes = self.storage.read(&path)?; - let store = Store::parse(&bytes)?; - let index = RevisionIndex::parse(&store)?; - let unlocked = onestore::protected::UnlockedSection::open( - &index, - password, - onestore::protected::Limits::default(), - )?; - let document = unlocked.document()?; - document - .pages()? - .into_iter() - .map(|(space, _)| Ok((space, Page::from_space(&document, space)?))) - .collect() + let snapshot = self.storage.read(&path)?; + let pages = { + let store = Store::parse(&snapshot)?; + let index = RevisionIndex::parse(&store)?; + let unlocked = onestore::protected::UnlockedSection::open( + &index, + password, + onestore::protected::Limits::default(), + )?; + let document = unlocked.document()?; + document + .pages()? + .into_iter() + .map(|(space, _)| Ok((space, Page::from_space(&document, space)?))) + .collect::>()? + }; + Ok(Unlocked { pages, snapshot }) } - /// Saves a page read through `unlock`, stored under the section's key. The save goes - /// straight to the file and fails if the section changed since `unlock` read it; - /// nothing of a protected section is cached or queued. + /// Saves an edited page of `unlocked` under the section's key, straight to the file: + /// nothing of a protected section is cached or queued. A section written since + /// `unlocked` was read fails the save; unlock again for its pages. #[cfg(feature = "protected")] pub fn save_unlocked( &self, path: &str, password: &str, + unlocked: &mut Unlocked, space: ExGuid, page: &Page, author: &str, ) -> Result<()> { let path = self.section_path(path)?.path.clone(); - let bytes = self.storage.read(&path)?; - let edit = onestore::PreparedEdit::page_protected(&bytes, password, space, page, author)?; - self.storage.commit(&path, &edit) + let edit = onestore::PreparedEdit::page_protected( + &unlocked.snapshot, + password, + space, + page, + author, + )?; + self.storage.commit(&path, &edit)?; + let written = edit.as_bytes().to_vec(); + unlocked.snapshot = written; + Ok(()) } /// Opens a section of a mounted notebook by its catalog path. diff --git a/crates/notebook/src/sync.rs b/crates/notebook/src/sync.rs index 037b91b75c9fde1e9dec8d339d98d864bd911b37..ebd12be546cb0de0d75541a3ba5645cc42a974c9 100644 --- a/crates/notebook/src/sync.rs +++ b/crates/notebook/src/sync.rs @@ -225,6 +225,8 @@ impl Replica { self.acknowledge(intent.id, revision, &snapshot)?; return Ok(Some((intent.id, EditStatus::Published { revision }))); } + // Once acknowledged this image is the base, which polls trust without validating. + validate(prepared.as_bytes())?; let store = Store::parse(prepared.as_bytes())?; let index = RevisionIndex::parse(&store)?; let revision = index.active(intent.space)?; diff --git a/crates/notebook/tests/protected.rs b/crates/notebook/tests/protected.rs index 15d8864e358903324cb14de813d5972c3bbe601e..fc929e2d4425a6f7d974fe03537a03b2f3ba4757 100644 --- a/crates/notebook/tests/protected.rs +++ b/crates/notebook/tests/protected.rs @@ -17,7 +17,7 @@ fn a_locked_section_unlocks_for_reading() { let notebook = Notebook::open(root.join("notebook"), temporary.path()).unwrap(); let section = ¬ebook.catalog().sections[0]; assert!(matches!(section.state, SectionState::Locked)); - let pages = notebook.unlock(§ion.path, password).unwrap(); + let pages = notebook.unlock(§ion.path, password).unwrap().pages; assert_eq!(pages.len(), 11); assert!(pages.iter().all(|(_, page)| !page.title.is_empty())); assert!(matches!( @@ -57,7 +57,8 @@ fn an_unlocked_page_is_saved_under_the_section_key() { .unwrap() .path .clone(); - let (space, mut page) = notebook.unlock(§ion, password).unwrap().remove(0); + let mut unlocked = notebook.unlock(§ion, password).unwrap(); + let (space, mut page) = unlocked.pages[0].clone(); let paragraphs = page .objects .iter_mut() @@ -95,15 +96,22 @@ fn an_unlocked_page_is_saved_under_the_section_key() { )) .unwrap(); assert!(matches!( - notebook.save_unlocked(§ion, "wrong", space, &page, "Rust"), + notebook.save_unlocked(§ion, "wrong", &mut unlocked, space, &page, "Rust"), Err(notebook::Error::Protected( onestore::protected::Error::PasswordMismatch )) )); + let mut stale = notebook.unlock(§ion, password).unwrap(); notebook - .save_unlocked(§ion, password, space, &page, "Rust") + .save_unlocked(§ion, password, &mut unlocked, space, &page, "Rust") .unwrap(); - let (_, stored) = notebook.unlock(§ion, password).unwrap().remove(0); + // A view read before that save no longer matches the file. + assert!( + notebook + .save_unlocked(§ion, password, &mut stale, space, &page, "Rust") + .is_err() + ); + let (_, stored) = notebook.unlock(§ion, password).unwrap().pages.remove(0); assert!(stored.objects == page.objects); if let Some(export) = std::env::var_os("ONESTORE_PROTECTED_EXPORT") { let export = Path::new(&export); diff --git a/crates/onestore/src/objects.rs b/crates/onestore/src/objects.rs index c2546e7960f9aa8b97dc71323ffa500a00e5c531..d9e222669e66a16f1b9980ab60176c2174f0a6b2 100644 --- a/crates/onestore/src/objects.rs +++ b/crates/onestore/src/objects.rs @@ -335,7 +335,6 @@ impl<'a> RevisionIndex<'a> { }); } table = Arc::new(GlobalIds::new()); - defining.clear(); defining_table = true; } 0x24..=0x26 => { @@ -382,9 +381,12 @@ impl<'a> RevisionIndex<'a> { } _ => unreachable!(), } - if defining[start..] - .iter() - .any(|(index, guid)| *index >= 0xffffff || *guid == [0; 16]) + // More entries than indices repeats one; checked here so imports + // cannot grow the list without bound. + if defining.len() > 0xffffff + || defining[start..] + .iter() + .any(|(index, guid)| *index >= 0xffffff || *guid == [0; 16]) { return Err(Error { offset: node.offset, @@ -407,9 +409,8 @@ impl<'a> RevisionIndex<'a> { message: "Invalid or repeated global ID entry", }); } - let mut guids: Vec<_> = defining.iter().map(|(_, guid)| *guid).collect(); - guids.sort_unstable(); - if guids.windows(2).any(|pair| pair[0] == pair[1]) { + defining.sort_unstable_by_key(|(_, guid)| *guid); + if defining.windows(2).any(|pair| pair[0].1 == pair[1].1) { return Err(Error { offset: node.offset, message: "Global ID table repeats a GUID", diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs index d1ae945263914f74833e831c410b1a93d66656a3..dccfc64e42300787552ceca13c1f40e507afa32c 100644 --- a/crates/onestore/src/page/write.rs +++ b/crates/onestore/src/page/write.rs @@ -2987,12 +2987,21 @@ pub(crate) fn squash( if !live.contains(id) { continue; } - if before.objects.get(id).is_some_and(|previous| { - previous.jcid == object.jcid - && previous.data == object.data - && previous.global_ids == object.global_ids - }) { - continue; + // Stored tables keep the entries an object names, so those decide. + if let Some(previous) = before.objects.get(id) + && previous.jcid == object.jcid + && previous.data == object.data + { + let entries = match object.data { + ObjectData::Properties(bytes) => crate::write::table_entries(bytes)?, + _ => BTreeSet::new(), + }; + if entries + .iter() + .all(|entry| previous.global_ids.get(entry) == object.global_ids.get(entry)) + { + continue; + } } let id = rename.get(id).copied().unwrap_or(*id); let mut replacement = match object.data { diff --git a/crates/onestore/src/protected/crypto.rs b/crates/onestore/src/protected/crypto.rs index b25e027b7aa2c47e986cca4ff33553705bdef534..fef881d6e82f13387a652a6e1af9fecaf23959e5 100644 --- a/crates/onestore/src/protected/crypto.rs +++ b/crates/onestore/src/protected/crypto.rs @@ -228,7 +228,9 @@ impl Key { crate::properties::reference_streams(&mut c)?; let prefix = c.offset; let padding = (16 - (2 + clear.len() - prefix) % 16) % 16; - let mut body = Zeroizing::new((padding as u16).to_le_bytes().to_vec()); + // Sized once: a buffer abandoned by growth would keep plaintext. + let mut body = Zeroizing::new(Vec::with_capacity(2 + clear.len() - prefix + padding)); + body.extend_from_slice(&(padding as u16).to_le_bytes()); body.extend_from_slice(&clear[prefix..]); let length = body.len() + padding; body.resize(length, 0); @@ -248,7 +250,8 @@ impl Key { if clear.is_empty() { return Vec::new(); } - let mut output = (clear.len() as u64).to_le_bytes().to_vec(); + let mut output = Vec::with_capacity((8 + clear.len()).next_multiple_of(16)); + output.extend_from_slice(&(clear.len() as u64).to_le_bytes()); output.extend_from_slice(clear); output.resize(output.len().next_multiple_of(16), 0); encrypt(&self.value, &self.file_iv, &mut output); diff --git a/crates/onestore/src/protected/mod.rs b/crates/onestore/src/protected/mod.rs index 9e8d38cb931f44897dec0ec1e7779fc784c95140..1c7a44ee47be7884bfa4db3477846f411d10b153 100644 --- a/crates/onestore/src/protected/mod.rs +++ b/crates/onestore/src/protected/mod.rs @@ -83,7 +83,7 @@ struct Decoded<'a> { /// Owns decoded buffers until dropped; returned views cannot outlive this owner. /// /// Passwords are not retained. Dropping this owner clears its derived key and -/// decoded buffers. Owned strings or exports made from a `Document` are separate +/// decoded buffers; a protected save works on ordinary buffers, which are not cleared. Owned strings or exports made from a `Document` are separate /// caller-owned copies and must be disposed of by the caller when locking. /// Opening never rewrites the source image or changes its protection state. /// @@ -302,15 +302,19 @@ impl UnlockedSection<'_> { extension, } => { let text = |bytes: &[u8]| { - String::from_utf16_lossy( + String::from_utf16( &bytes .chunks_exact(2) .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) .collect::>(), ) + .map_err(|_| crate::Error { + offset: 0, + message: "Invalid UTF-16 file-data declaration", + }) }; let mut copy = - PropertyObject::file(*id, &text(reference), &text(extension))?; + PropertyObject::file(*id, &text(reference)?, &text(extension)?)?; copy.jcid = object.jcid; copy.global_ids = std::sync::Arc::clone(&object.global_ids); copy @@ -336,7 +340,7 @@ impl UnlockedSection<'_> { }; let store = crate::Store::parse(&scaffold)?; let placeholder = identity(RevisionIndex::parse(&store)?.root); - for at in 0..scaffold.len() - 20 { + for at in 0..=scaffold.len() - 20 { if scaffold[at..at + 20] == placeholder { scaffold[at..at + 20].copy_from_slice(&identity(self.index.root)); } @@ -398,14 +402,32 @@ pub(crate) fn write_page( let store = crate::Store::parse(source)?; let index = RevisionIndex::parse(&store)?; let unlocked = UnlockedSection::open(&index, password, Limits::default())?; + if unlocked.keys.len() != 1 { + return Err(Error::Unsupported); + } let twin = unlocked.twin()?; let applied = Zeroizing::new(crate::page::write::write_page(&twin, space, page, author)?); + // Squash skips the spaces the source lacks as the twin's scaffold; the writers added none. + let spaces = |image: &[u8]| -> Result> { + let store = crate::Store::parse(image)?; + Ok(RevisionIndex::parse(&store)?.spaces.into_keys().collect()) + }; + if spaces(&twin)? != spaces(&applied)? { + return Err(invalid("Page edits cannot create object spaces")); + } let written = crate::page::write::squash(source, &applied, &BTreeMap::new(), Some(&unlocked))?; let store = crate::Store::parse(&written)?; UnlockedSection::open(&RevisionIndex::parse(&store)?, password, Limits::default())?; Ok(written) } +impl UnlockedSection<'_> { + /// The section key; `write_page` admits sections with exactly one. + fn key(&self) -> &crypto::Key { + self.keys.values().next().expect("one section key") + } +} + impl crate::write::Protection for UnlockedSection<'_> { fn resolve( &self, @@ -423,20 +445,21 @@ impl crate::write::Protection for UnlockedSection<'_> { } fn seal_property(&self, clear: &[u8]) -> std::result::Result, crate::Error> { - let [key] = self.keys.values().collect::>()[..] else { - return Err(crate::Error { - offset: 0, - message: "Protected writing needs one section key", - }); + let random = crate::Error { + offset: 0, + message: "System random source failed", }; - let failed = |message| crate::Error { offset: 0, message }; let mut iv = [0; 16]; - getrandom::fill(&mut iv).map_err(|_| failed("System random source failed"))?; - key.seal_property(clear, iv) - .map_err(|_| failed("Malformed property object")) + getrandom::fill(&mut iv).map_err(|_| random)?; + self.key() + .seal_property(clear, iv) + .map_err(|error| match error { + Error::Invalid(error) => error, + _ => random, + }) } fn seal_file(&self, clear: &[u8]) -> Vec { - self.keys.values().next().unwrap().seal_file(clear) + self.key().seal_file(clear) } } diff --git a/crates/onestore/src/write.rs b/crates/onestore/src/write.rs index ee0bbd413e10291cbe6f01babb4e634b03c04237..e31daef641e874ed30ad2f87bbeee6f108d73fb1 100644 --- a/crates/onestore/src/write.rs +++ b/crates/onestore/src/write.rs @@ -104,6 +104,21 @@ fn compact(id: ExGuid, table: &BTreeMap) -> Result<[u8; 4]> { Ok(((index << 8) | id.n).to_le_bytes()) } +/// The global id table entries a property object's references name. +pub(crate) fn table_entries(bytes: &[u8]) -> Result> { + let mut entries = BTreeSet::new(); + for property in PropertySets::parse(bytes)?.sets.iter().flatten() { + if let Value::References { compact_ids, .. } = property.value { + entries.extend( + compact_ids + .chunks_exact(4) + .map(|id| u32::from_le_bytes(id.try_into().unwrap()) >> 8), + ); + } + } + Ok(entries) +} + fn field_length(property: &crate::Property<'_>, set_lengths: &[usize]) -> usize { match &property.value { Value::NoData => 0, @@ -734,14 +749,21 @@ pub(crate) fn write_revision_with_payloads( payloads: &[([u8; 16], &[u8])], edit: impl FnOnce(&crate::ResolvedRevision<'_>) -> Result>, ) -> Result> { - write_revisions_with_payloads(source, payloads, |index| { - let rid = index.active(space)?; - let revision = index.resolve(space, rid)?; + write_revisions_with_payloads(source, payloads, update(space, edit)) +} + +/// One space's active revision edited into its update. +fn update( + space: ExGuid, + edit: impl FnOnce(&crate::ResolvedRevision<'_>) -> Result>, +) -> impl FnOnce(&RevisionIndex<'_>) -> Result> { + move |index| { + let revision = index.resolve(space, index.active(space)?)?; Ok(BTreeMap::from([( space, RevisionEdit::Update(edit(&revision)?), )])) - }) + } } fn append_fragment( @@ -853,13 +875,7 @@ pub(crate) fn write_revision_on( space: ExGuid, edit: impl FnOnce(&crate::ResolvedRevision<'_>) -> Result>, ) -> Result> { - let output = build_on(index, &[], None, |index| { - let revision = index.resolve(space, index.active(space)?)?; - Ok(BTreeMap::from([( - space, - RevisionEdit::Update(edit(&revision)?), - )])) - })?; + let output = build_on(index, &[], None, update(space, edit))?; check(&output, true)?; Ok(output) } @@ -1079,14 +1095,24 @@ fn build_on( object.bytes[offset..offset + 4].copy_from_slice(&reference); } } - replacements.retain(|id, replacement| { - // Detached objects must pass the final reachability check even when unchanged. - !reachable.contains(id) - || !revision.objects.get(id).is_some_and(|object| { - object.data == ObjectData::Properties(&replacement.bytes) - && object.global_ids == replacement.global_ids - }) - }); + // Detached objects must pass the final reachability check even when unchanged. Equal + // bytes are the same object when the table entries they name agree: stored tables + // keep only those. + let mut unchanged = Vec::new(); + for (id, replacement) in &replacements { + if let Some(object) = revision.objects.get(id) + && reachable.contains(id) + && object.data == ObjectData::Properties(&replacement.bytes) + && table_entries(&replacement.bytes)? + .iter() + .all(|entry| object.global_ids.get(entry) == replacement.global_ids.get(entry)) + { + unchanged.push(*id); + } + } + for id in unchanged { + replacements.remove(&id); + } if replacements.is_empty() { continue; } @@ -1244,7 +1270,19 @@ fn build_on( } else { vec![node(0x21, None, &[0])?] }; + // A table read from a long-lived page names every session that edited it; the + // group stores the entries its objects use. + let mut used = BTreeSet::new(); + for (id, object) in &objects { + used.insert(u32::from_le_bytes(compact(*id, table)?) >> 8); + if let ObjectData::Properties(bytes) = object.data { + used.extend(table_entries(bytes)?); + } + } for (id, guid) in table { + if is_section && !used.contains(id) { + continue; + } let mut entry = id.to_le_bytes().to_vec(); entry.extend_from_slice(guid); group.push(node(0x24, None, &entry)?); diff --git a/crates/onestore/tests/edit.rs b/crates/onestore/tests/edit.rs index bbdb74c6c04fb287317563965f133fd02e71c9c1..1f4a5c1fe718c41c7f73e4edd51aef21c19b08a0 100644 --- a/crates/onestore/tests/edit.rs +++ b/crates/onestore/tests/edit.rs @@ -264,7 +264,13 @@ fn assert_other_objects_preserved( } let current = &after.objects[id]; assert_eq!(object.jcid, current.jcid); - assert_eq!(object.global_ids, current.global_ids); + // A rewritten object keeps the table entries it names. + assert!( + current + .global_ids + .iter() + .all(|(entry, guid)| object.global_ids.get(entry) == Some(guid)) + ); let mut pending = vec![*id]; let mut visited = std::collections::BTreeSet::new(); while let Some(next) = pending.pop() { diff --git a/crates/onestore/tests/table_growth.rs b/crates/onestore/tests/table_growth.rs new file mode 100644 index 0000000000000000000000000000000000000000..d57186f5aab72dea0d3a5a1308d49f9dae067450 --- /dev/null +++ b/crates/onestore/tests/table_growth.rs @@ -0,0 +1,101 @@ +//! A rewritten object stores the global id table entries it names, not the table of the +//! revision it was read from. + +use onestore::{ + PreparedEdit, RevisionIndex, Store, + document::Document, + page::{Page, PageObject, Paragraph}, +}; + +const SOURCE: &[u8] = include_bytes!("../../../corpus/native/20260905-05/notebook/synthetic.one"); + +fn pages(bytes: &[u8]) -> Vec<(onestore::ExGuid, Page)> { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| (space, Page::from_space(&document, space).unwrap())) + .collect() +} + +/// Table entries per object group written past `from`, as `(entries, highest index + 1)`. +fn tables(bytes: &[u8], from: usize) -> Vec<(usize, u32)> { + let store = Store::parse(bytes).unwrap(); + store + .lists + .values() + .filter(|list| list.nodes.first().is_some_and(|node| node.offset >= from)) + .map(|list| { + let entries: Vec = list + .nodes + .iter() + .filter(|node| node.id == 0x24) + .map(|node| u32::from_le_bytes(node.payload[..4].try_into().unwrap())) + .collect(); + (entries.len(), entries.iter().max().map_or(0, |max| max + 1)) + }) + .filter(|(entries, _)| *entries > 0) + .collect() +} + +/// `ONESTORE_TABLE_EXPORT` names a directory receiving the edited notebook for a cold reopen. +#[test] +fn a_text_edit_stores_the_table_entries_it_names() { + let mut bytes = SOURCE.to_vec(); + let mut sparse = false; + let mut edited = Vec::new(); + for (space, mut page) in pages(SOURCE) { + let Some(text) = page + .objects + .iter_mut() + .filter_map(|object| match object { + PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs), + _ => None, + }) + .flatten() + .find_map(|paragraph| paragraph.text_mut()) + else { + continue; + }; + let format = text.text.format_at(0).unwrap().clone(); + text.text + .append(Paragraph::new(" and a few more words".to_owned(), format)) + .unwrap(); + let written = PreparedEdit::page(&bytes, space, &page, "Rust") + .unwrap() + .as_bytes() + .to_vec(); + for (entries, span) in tables(&written, bytes.len()) { + sparse |= (entries as u32) < span; + } + assert_eq!( + PreparedEdit::page(&written, space, &page, "Rust") + .unwrap() + .as_bytes(), + written + ); + bytes = written; + edited.push((space, page)); + } + // The fixture's pages were written over several sessions, so a text edit names a + // subset of its revision's table and the stored indices have gaps. + assert!(sparse); + let stored = pages(&bytes); + for (space, page) in &edited { + let (_, stored) = stored.iter().find(|(id, _)| id == space).unwrap(); + assert!(stored.objects == page.objects); + } + if let Some(export) = std::env::var_os("ONESTORE_TABLE_EXPORT") { + let export = std::path::Path::new(&export); + std::fs::create_dir_all(export).unwrap(); + std::fs::write(export.join("synthetic.one"), &bytes).unwrap(); + std::fs::copy( + "../../corpus/native/20260905-05/notebook/Open Notebook.onetoc2", + export.join("Open Notebook.onetoc2"), + ) + .unwrap(); + } +} diff --git a/crates/onestore/tests/writer.rs b/crates/onestore/tests/writer.rs index ffd7efc1a76a06a530a57d236f90cd1e9e51aab8..72f7619da77b191bf78333e2e0e836fc09aa15f4 100644 --- a/crates/onestore/tests/writer.rs +++ b/crates/onestore/tests/writer.rs @@ -55,7 +55,11 @@ fn scalar_edit_appends_a_revision_and_preserves_every_prior_object() { let same = &unchanged.objects[&id]; assert_eq!(object.jcid, same.jcid); assert_eq!(object.reference_count, same.reference_count); - assert_eq!(object.global_ids, same.global_ids); + assert!( + same.global_ids + .iter() + .all(|(entry, guid)| object.global_ids.get(entry) == Some(guid)) + ); assert_eq!(object.data, same.data); } } @@ -403,7 +407,11 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() { assert_eq!(a.object_spaces, b.object_spaces); assert_eq!(a.contexts, b.contexts); if section { - assert_eq!(object.global_ids, same.global_ids); + assert!( + same.global_ids + .iter() + .all(|(entry, guid)| object.global_ids.get(entry) == Some(guid)) + ); if *id != oid { assert_eq!(object.data, same.data); } @@ -437,7 +445,11 @@ fn checkpoints_bound_dependencies_and_preserve_native_objects_and_history() { let same = &preserved.objects[&id]; assert_eq!(object.jcid, same.jcid); assert_eq!(object.reference_count, same.reference_count); - assert_eq!(object.global_ids, same.global_ids); + assert!( + same.global_ids + .iter() + .all(|(entry, guid)| object.global_ids.get(entry) == Some(guid)) + ); assert_eq!(object.data, same.data); } } diff --git a/tools/test_table_growth.py b/tools/test_table_growth.py new file mode 100644 index 0000000000000000000000000000000000000000..ddfe1e1f5cc4c2784f724c3dfa5c43acaa1374c0 --- /dev/null +++ b/tools/test_table_growth.py @@ -0,0 +1,24 @@ +from pathlib import Path +import runpy +import shutil +from tempfile import TemporaryDirectory +import unittest + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / 'corpus/table-growth' +compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] + + +class TableGrowthTest(unittest.TestCase): + def test_onenote_reads_object_groups_whose_id_tables_have_gaps(self): + with TemporaryDirectory() as temporary: + native = Path(temporary) / 'read' + shutil.copytree(FIXTURE / 'cold/read', native) + compare(FIXTURE / 'candidate/notebook', native) + self.assertIn('and a few more words', (native / 'page-000.xml').read_text(encoding='utf-8-sig')) + self.assertEqual((FIXTURE / 'candidate/notebook/synthetic.one').read_bytes(), + (FIXTURE / 'cold/notebook/synthetic.one').read_bytes()) + + +if __name__ == '__main__': + unittest.main()