From d49d07e9caadf06785efb079e72d75531cbc2dca Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 3 Sep 2026 03:07:53 -0400 Subject: [PATCH] test(didbot-pds): pin the on-disk format the layout stamp does not cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refused open must leave the log and the blobs untouched, or a rollback is a deletion that also returned an error; and the directory's own names — `blobs/`, a DID's escaping, a CID's rendering — are on-disk format with no stamp behind them, which the startup sweep acts on. Co-Authored-By: Claude Opus 5 (1M context) --- crates/didbot-pds/tests/upgrade.rs | 294 +++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 crates/didbot-pds/tests/upgrade.rs diff --git a/crates/didbot-pds/tests/upgrade.rs b/crates/didbot-pds/tests/upgrade.rs new file mode 100644 index 00000000..bc9f945e --- /dev/null +++ b/crates/didbot-pds/tests/upgrade.rs @@ -0,0 +1,294 @@ +//! What a binary does with a data directory another binary wrote. +//! +//! Every deploy of this server is this situation: the container is new and +//! the volume under `--data` is not. The two questions that decides are what +//! a newer binary does with an older directory, and — the one a rollback +//! asks — what an older binary does with a newer one. `crate::layout` +//! answers both the same way, by refusing, because [`didbot_pds::layout`] +//! compares a *hash* of the log's entry shape and inequality carries no +//! direction. That is the right answer for a rollback as long as refusing +//! costs nothing, which is what the first test here is about. +//! +//! # The half the stamp does not cover +//! +//! `didbot_pds::layout::SHAPE` is hashed from `ENTRY_SHAPE`: the variants of +//! `Entry` and the field names each writes. Everything else about the +//! directory is outside it — which files exist, what they are called, and +//! where a blob's bytes land under `blobs/`. That last one is not a +//! cosmetic gap. `FileBlobStore::sweep` treats the replayed index as the +//! authority over the disk and deletes any account directory or file whose +//! *name* it did not expect, so a change to `didbot_pds::blobs::did_path` or +//! to how a CID is rendered would make the first boot after the upgrade +//! delete every blob in the deployment, with a stamp that matched all the +//! way through. +//! +//! `the_data_directory_layout_is_pinned` is what stands in for the stamp +//! there. It is not a restatement of a constant: it pins the actual names on +//! disk after a real provision, a real record and a real upload, so a +//! renamed directory, an added file, a changed DID escaping and a changed +//! CID rendering each fail it. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use didbot_dns::LoopbackDns; +use didbot_identity::Zone; +use didbot_pds::{ + BlobRef, BlobStore, Durable, FileAccountStore, ProvisionRequest, Provisioner, Registry, Swap, + BLOB_DIR, +}; +use didbot_pds::layout::{LayoutError, Stamp, LAYOUT, SHAPE, STAMP_FILE}; +use didbot_pds::wal::{WalError, LOG_FILE}; +use serde_json::json; +use time::Duration; + +const ZONE_HOST: &str = "agents.localhost"; +const SCROBBLE: &str = "com.vibescrobble.scrobble"; + +/// Ten bytes, so the blob it makes hashes to one fixed CID this file can +/// name — and so the whole fixture directory stays under a kilobyte. +const BLOB_BYTES: &[u8] = b"quernstone"; + +fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("didbot-upgrade-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +type Pds = Provisioner>; + +fn boot(dir: &Path) -> (Durable, Pds) { + let durable = Durable::open(dir, Duration::days(30)).expect("the log should open"); + let pds = Provisioner::new( + "did:web:owner.example", + Zone::delegated("localhost", ZONE_HOST).expect("a test zone"), + "http://localhost:3600".to_string(), + LoopbackDns::new(), + durable.accounts(), + ) + .with_record_store(durable.records()) + .with_blob_store(durable.blobs()) + .with_commit_history(durable.history()); + (durable, pds) +} + +fn upload(pds: &impl Registry, did: &str, bytes: &[u8]) -> BlobRef { + let mut upload = pds + .begin_blob(did, "text/plain", Some(bytes.len() as u64)) + .expect("a blob upload should open"); + upload.write(bytes).expect("a chunk should write"); + upload.commit().expect("the upload should commit") +} + +/// A directory holding one of everything that reaches the disk: an account, +/// a record, and a blob. Answers the account's DID and the blob's reference. +fn populate(dir: &Path) -> (String, BlobRef) { + let (durable, pds) = boot(dir); + let did = pds + .provision(ProvisionRequest::new("shearwater", None)) + .expect("provisioning should succeed") + .account + .did; + let reference = upload(&pds, did.as_str(), BLOB_BYTES); + pds.put_record( + did.as_str(), + SCROBBLE, + None, + json!({"text": "weftling", "createdAt": "2026-01-01T00:00:00Z"}), + &Swap::default(), + ) + .expect("a record should write"); + durable.wal().sync().expect("the log should flush"); + drop(durable); + (did.as_str().to_owned(), reference) +} + +/// Every path under `dir`, relative and sorted, directories included. +fn manifest(dir: &Path) -> BTreeSet { + fn walk(root: &Path, at: &Path, into: &mut BTreeSet) { + let Ok(entries) = std::fs::read_dir(at) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let relative = path + .strip_prefix(root) + .expect("everything walked is under the root") + .to_string_lossy() + .into_owned(); + let is_dir = path.is_dir(); + into.insert(if is_dir { + format!("{relative}/") + } else { + relative + }); + if is_dir { + walk(root, &path, into); + } + } + } + let mut paths = BTreeSet::new(); + walk(dir, dir, &mut paths); + paths +} + +// --------------------------------------------------------------------------- +// A directory another binary wrote +// --------------------------------------------------------------------------- + +/// The property a rollback rests on: a refusal costs nothing. +/// +/// Deploy a version that changes the log's entry shape, let it write, then +/// roll back. The old binary meets a stamp it does not recognise and stops — +/// which is only a safe answer if stopping happens *before* anything on the +/// disk has been touched. Two things could have been touched by the time the +/// check ran under a different ordering, and both are unrecoverable: replay +/// truncates the log at the first frame it cannot read, and the startup +/// sweep deletes every blob the replayed index does not name. So the +/// assertions that matter here are not that the error came back, they are +/// that `pds.wal` is byte-for-byte what it was and the blob is still on +/// disk. +#[test] +fn a_stamp_from_another_binary_is_refused_before_the_directory_is_touched() { + let dir = scratch("foreign-stamp"); + let (did, reference) = populate(&dir); + + let log = dir.join(LOG_FILE); + let before = std::fs::read(&log).expect("the log is readable"); + let blob = dir + .join(BLOB_DIR) + .join(didbot_pds::blobs::did_path(&did)) + .join(&reference.cid); + assert!(blob.is_file(), "the fixture should have written a blob"); + + // What the other binary left behind. `SHAPE` is a hash of the entry + // shape, so any change to it lands somewhere else in the space; this + // stands in for one without needing to be a real one. + let foreign = Stamp { + layout: LAYOUT + 1, + shape: SHAPE ^ 0x5eed, + }; + std::fs::write( + dir.join(STAMP_FILE), + serde_json::to_string(&foreign).expect("a stamp serializes"), + ) + .expect("the stamp should write"); + + let err = Durable::open(&dir, Duration::days(30)).expect_err("a foreign stamp is refused"); + + // The load-bearing half. A refusal that happens after the log has been + // replayed or the blobs swept is not a refusal, it is a deletion that + // also returned an error. + assert_eq!( + std::fs::read(&log).expect("the log is still readable"), + before, + "a refused open must not have touched the log" + ); + assert!( + blob.is_file(), + "a refused open must not have swept the blobs" + ); + + assert!( + matches!( + err, + WalError::Layout(LayoutError::Mismatch { + found_layout, + .. + }) if found_layout == LAYOUT + 1 + ), + "{err}" + ); + // A container that exits on boot during a deploy is an outage, so the + // one line it gets has to say what is wrong and what to do about it. + let rendered = err.to_string(); + for expected in ["delete the directory", "check out the build that wrote it"] { + assert!(rendered.contains(expected), "{rendered}"); + } + + // And the refusal is not a one-time state: the directory still opens + // under the binary that wrote it, which is what makes rolling forward + // again the way out. + std::fs::write( + dir.join(STAMP_FILE), + serde_json::to_string(&Stamp { + layout: LAYOUT, + shape: SHAPE, + }) + .expect("a stamp serializes"), + ) + .expect("the stamp should write"); + let durable = Durable::open(&dir, Duration::days(30)).expect("its own stamp opens"); + assert!( + durable + .blobs() + .fetch(&did, &reference.cid) + .is_ok(), + "the blob survived the refused open" + ); + drop(durable); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// The on-disk names, which `pds.layout` does not cover. +/// +/// The stamp is hashed from the log's *entry shape* and nothing else, so +/// every path here is a piece of on-disk format with no stamp behind it. A +/// binary that renamed `blobs/`, changed `didbot_pds::blobs::did_path`'s +/// escaping, rendered a CID differently, or started writing a new file into +/// the directory would open an older directory with a matching stamp and a +/// clean bill of health — and, for the three that move a blob's path, +/// `FileBlobStore::sweep` would delete the bytes it no longer recognises on +/// that first boot. +/// +/// So this pins the names. A change here is a real on-disk format change: +/// either it needs an entry in `didbot_pds::layout::ENTRY_SHAPE` to move the +/// stamp with it, or it needs the migration this project does not have. +#[test] +fn the_data_directory_layout_is_pinned() { + let dir = scratch("manifest"); + let (did, reference) = populate(&dir); + + // Deterministic because the account is `did:web:.` and the + // blob is ten fixed bytes: naming them literally is the point, since a + // change to either is what the sweep would act on. + assert_eq!(did, "did:web:shearwater.agents.localhost"); + assert_eq!( + reference.cid, "bafkreih2fxeo453tle5v67nikccodt7v2ta3xpjz7rcdb2ksr64cgzfn2q", + "the CID a blob's filename is, rendered the way this binary renders it" + ); + + // Reopened, because the sweep only runs on a boot that found a log and + // it is the sweep that clears `.incoming/`. + drop(boot(&dir).0); + + let found = manifest(&dir); + let expected: BTreeSet = [ + "blobs/", + "blobs/did%3Aweb%3Ashearwater.agents.localhost/", + // The upload above. + "blobs/did%3Aweb%3Ashearwater.agents.localhost/\ + bafkreih2fxeo453tle5v67nikccodt7v2ta3xpjz7rcdb2ksr64cgzfn2q", + // The avatar provisioning generates, which is a blob like any other + // and is derived from the account's name, so it is fixed too. + "blobs/did%3Aweb%3Ashearwater.agents.localhost/\ + bafkreider4kujw57c5jfjxnswcqsp64u45l73rl5a4xqmzq3gi4qtxrtmi", + "pds.layout", + "pds.lock", + "pds.wal", + ] + .into_iter() + .map(str::to_owned) + .collect(); + assert_eq!( + found, expected, + "the data directory's on-disk names changed. `pds.layout` is hashed from the log's \ + entry shape and covers none of this, so an older directory would open under this \ + binary with a matching stamp — and a blob whose path moved would be deleted by the \ + startup sweep as a file the index does not name." + ); + + let _ = std::fs::remove_dir_all(&dir); +} -- 2.51.2