Commit e1ffd4eb2d
e1ffd4eb2dc611ab865cbe9a4742b35276271686
parent: c96da98345
Unsigned ci/pages: success ci/test: success
cmc <hello@cleberg.net> · 2026-10-18 20:47 UTC
Write the cache manifest in a deterministic order (!14)
`<out>/.orgo-cache.json` was the one file in the output tree that was not
byte-reproducible: `Manifest.pages` and both `DepGraph` edge maps were
`HashMap`/`HashSet`, so serde wrote their entries in map iteration order and
two builds of the same input with the same binary disagreed on ordering.
BTreeMap/BTreeSet throughout, plus `Ord` on `TargetId`. No schema change — same
field names, same JSON types, object keys and array elements merely ordered —
and `CACHE_FORMAT_VERSION` stays at 7. Checked against a manifest written by
0.24.1: the new binary loads it and reports `0 rendered, 2 cached`.
New test `cache_manifest_is_byte_identical_across_builds` builds a 24-page
cross-linked site four times with `--no-cache` and compares manifest bytes. It
fails before this change. No existing test asserted on manifest contents; the
byte-equivalence helper in `tests/incremental.rs` skips `.orgo-cache.json`,
which is why this went unnoticed.
This makes the whole output tree reproducible. Three `--no-cache` builds of
`docs/` into separate directories are now identical under `diff -r`, manifest
included, and an incremental rebuild over a populated cache produces the same
20 files byte for byte. Nothing else in the tree varies: the other hash inputs
(config, templates, site structure, resolved links) were already sorted before
hashing, and `Config` contains no unordered maps.
Layout: unified · split
src/incremental.rs
+7 −6
| @@ -17,7 +17,7 @@ |
| 17 | 17 | //! invalidates pages that *link into* a changed file's targets, so a renamed/removed |
| 18 | 18 | //! heading invalidates the pages that link to it, not just the file that owns it. |
| 19 | 19 | |
| 20 | | use std::collections::{HashMap, HashSet}; |
| 20 | use std::collections::{BTreeMap, BTreeSet, HashSet}; |
| 21 | 21 | |
| 22 | 22 | use camino::{Utf8Path, Utf8PathBuf}; |
| 23 | 23 | use serde::{Deserialize, Serialize}; |
| @@ -101,8 +101,8 @@ pub fn template_hash(sources: &[(String, String)]) -> Hash { |
| 101 | 101 | /// `uses`: page → targets it resolved a link to (the invalidation-critical edges). |
| 102 | 102 | #[derive(Debug, Default, Clone, Serialize, Deserialize)] |
| 103 | 103 | pub struct DepGraph { |
| 104 | | pub defines: HashMap<Utf8PathBuf, HashSet<TargetId>>, |
| 105 | | pub uses: HashMap<Utf8PathBuf, HashSet<TargetId>>, |
| 104 | pub defines: BTreeMap<Utf8PathBuf, BTreeSet<TargetId>>, |
| 105 | pub uses: BTreeMap<Utf8PathBuf, BTreeSet<TargetId>>, |
| 106 | 106 | } |
| 107 | 107 | |
| 108 | 108 | impl DepGraph { |
| @@ -129,7 +129,7 @@ impl DepGraph { |
| 129 | 129 | /// hash and therefore the page's `render_key`. Deterministic: targets are sorted. |
| 130 | 130 | pub fn resolved_links_hash( |
| 131 | 131 | from: &Utf8Path, |
| 132 | | used: &HashSet<TargetId>, |
| 132 | used: &BTreeSet<TargetId>, |
| 133 | 133 | symbols: &SymbolTable, |
| 134 | 134 | ) -> Hash { |
| 135 | 135 | let mut pairs: Vec<(String, String)> = used |
| @@ -187,12 +187,13 @@ pub struct PageRecord { |
| 187 | 187 | |
| 188 | 188 | /// The on-disk cache manifest (spec §4.5). Serialized as JSON (human-diffable; the |
| 189 | 189 | /// cache is an optimization, never a correctness dependency — a `--no-cache`/`clean` |
| 190 | | /// run always produces byte-identical output). |
| 190 | /// run always produces byte-identical output). Ordered maps throughout, so the manifest |
| 191 | /// is itself byte-reproducible rather than written in map iteration order. |
| 191 | 192 | #[derive(Debug, Default, Serialize, Deserialize)] |
| 192 | 193 | pub struct Manifest { |
| 193 | 194 | pub format_version: u32, |
| 194 | 195 | pub config_hash: Option<Hash>, |
| 195 | | pub pages: HashMap<Utf8PathBuf, PageRecord>, |
| 196 | pub pages: BTreeMap<Utf8PathBuf, PageRecord>, |
| 196 | 197 | pub graph: DepGraph, |
| 197 | 198 | } |
| 198 | 199 | |
src/index.rs
+1 −1
| @@ -13,7 +13,7 @@ use crate::util::{heading_anchor, output_path, plain_text}; |
| 13 | 13 | /// |
| 14 | 14 | /// `Serialize`/`Deserialize` so the dependency graph (defines/uses edges) round-trips |
| 15 | 15 | /// through the on-disk cache manifest (spec §4.5). |
| 16 | | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] |
| 16 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] |
| 17 | 17 | pub enum TargetId { |
| 18 | 18 | Id(String), |
| 19 | 19 | CustomId(String), |
src/site.rs
+5 −5
| @@ -9,7 +9,7 @@ |
| 9 | 9 | //! `--no-cache` forces a full rebuild; the cache is never a correctness dependency, so a |
| 10 | 10 | //! full rebuild and an incremental rebuild produce byte-identical output. |
| 11 | 11 | |
| 12 | | use std::collections::{HashMap, HashSet}; |
| 12 | use std::collections::{BTreeSet, HashMap, HashSet}; |
| 13 | 13 | use std::fs; |
| 14 | 14 | |
| 15 | 15 | use anyhow::{Context, Result}; |
| @@ -111,8 +111,8 @@ struct PagePrep { |
| 111 | 111 | title: String, |
| 112 | 112 | content_hash: ContentHash, |
| 113 | 113 | resolved: crate::resolve::ResolvedDoc, |
| 114 | | used: HashSet<TargetId>, |
| 115 | | defines: HashSet<TargetId>, |
| 114 | used: BTreeSet<TargetId>, |
| 115 | defines: BTreeSet<TargetId>, |
| 116 | 116 | broken: Vec<TargetId>, |
| 117 | 117 | diagnostics: Vec<Diagnostic>, |
| 118 | 118 | nav: Vec<NavItem>, |
| @@ -752,9 +752,9 @@ fn prepare_pages( |
| 752 | 752 | .par_iter() |
| 753 | 753 | .map(|doc| { |
| 754 | 754 | let out = resolve(doc, &symbols); |
| 755 | | let used: HashSet<TargetId> = out.used_targets.iter().cloned().collect(); |
| 755 | let used: BTreeSet<TargetId> = out.used_targets.iter().cloned().collect(); |
| 756 | 756 | let broken: Vec<TargetId> = out.broken.iter().map(|b| b.target.clone()).collect(); |
| 757 | | let defines: HashSet<TargetId> = document_targets(doc).into_iter().collect(); |
| 757 | let defines: BTreeSet<TargetId> = document_targets(doc).into_iter().collect(); |
| 758 | 758 | |
| 759 | 759 | let output = output_path(&doc.source_path, &doc.keywords); |
| 760 | 760 | |
tests/incremental.rs
+48
| @@ -542,3 +542,51 @@ fn a_previous_build_output_is_not_copied_as_assets() { |
| 542 | 542 | "the earlier site must not be nested inside the new one" |
| 543 | 543 | ); |
| 544 | 544 | } |
| 545 | |
| 546 | /// The cache manifest is a file in the output tree, so it is covered by the same |
| 547 | /// reproducibility rule as the pages: two builds of the same input write the same bytes. |
| 548 | /// Serializing the per-file records and the dependency graph out of unordered maps put |
| 549 | /// them in iteration order, which varies run to run. |
| 550 | #[test] |
| 551 | fn cache_manifest_is_byte_identical_across_builds() { |
| 552 | let root = tmpdir("manifest-stable"); |
| 553 | let src = root.join("src"); |
| 554 | std::fs::create_dir_all(src.join("deep")).unwrap(); |
| 555 | |
| 556 | // Enough cross-linked pages, across two directories, that an unordered map comes out |
| 557 | // in a different order run to run. |
| 558 | for i in 0..24 { |
| 559 | let body = format!( |
| 560 | "#+TITLE: Page {i}\n#+SLUG: page-{i}\n\n\ |
| 561 | See [[#anchor-{next}][the next page]].\n\n\ |
| 562 | * Heading {i}\n:PROPERTIES:\n:CUSTOM_ID: anchor-{i}\n:END:\n", |
| 563 | next = (i + 1) % 24 |
| 564 | ); |
| 565 | let dir = if i % 3 == 0 { src.join("deep") } else { src.clone() }; |
| 566 | std::fs::write(dir.join(format!("p{i}.org")), body).unwrap(); |
| 567 | } |
| 568 | |
| 569 | let build = |out: &Utf8PathBuf| { |
| 570 | build_site( |
| 571 | &src, |
| 572 | out, |
| 573 | &BuildOptions { |
| 574 | no_cache: true, |
| 575 | ..Default::default() |
| 576 | }, |
| 577 | ) |
| 578 | .unwrap(); |
| 579 | std::fs::read(manifest_path(out)).unwrap() |
| 580 | }; |
| 581 | |
| 582 | let first = build(&root.join("first")); |
| 583 | for _ in 0..3 { |
| 584 | let again = build(&tmpdir("manifest-stable-again").join("out")); |
| 585 | // `assert!` rather than `assert_eq!`: a mismatch would otherwise dump both |
| 586 | // manifests as byte vectors. |
| 587 | assert!( |
| 588 | first == again, |
| 589 | "the cache manifest must be byte-identical across builds" |
| 590 | ); |
| 591 | } |
| 592 | } |