diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index 94d23cbd..f73df370 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -9,4 +9,11 @@ only in git history. ### Breaking - A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name. -- A write over a file the reader refuses (a zero-byte file, a truncated store, a stamp this runtime does not read) refuses with `CultCacheStoreUnreadable` and leaves the file as it is. A whole-store write used to replace such a file. +- A write over a file the reader refuses (a zero-byte file, a truncated store, a stamp this runtime does not read) refuses with `CultCacheStoreUnreadable` and leaves the file as it is. A whole-store write used to replace such a file. This reverses 0.3.1, where `push_all` replaced a file with no readable stamp (healing it); the stamp rule now has one owner, the reader every write asks under the lock, and it accepts v1 and v3 (0.3.1 refused v3). + +## [0.3.1] + +### Fixed +- `push_all` reads the store's header under the lock and refuses a format this runtime does + not read, with `CultCacheStoreUnreadable` `UnsupportedFormat`, leaving the file as it was. + A file with no readable stamp is still replaced, so a whole write heals it. diff --git a/packages/cultcache-rs/Cargo.toml b/packages/cultcache-rs/Cargo.toml index 9610aeb6..7954370c 100644 --- a/packages/cultcache-rs/Cargo.toml +++ b/packages/cultcache-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cultcache-rs" -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "MIT" publish = false diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 2878171b..b77f4971 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1361,31 +1361,39 @@ impl SingleFileMessagePackBackingStore { .context(format!("failed to read {}", self.path.display()))); } }; - let unreadable = |kind, detail: anyhow::Error| { - // The detail is rebuilt as text that names no value read from the bytes. The - // decoder reports a short read as an io::Error, and dropping it keeps an - // io::Error in the chain meaning only that the file itself could not be read. - anyhow!(describe_undecodable(&detail)).context(CultCacheStoreUnreadable { - path: self.path.clone(), - kind, - }) - }; let head = store_header(&bytes); - match head.first { - Some(header) if header.starts_with("cultcache.store.") && readable_store_format(&header).is_none() => Err(unreadable( + self.refuse_unread_format(head.first.as_deref())?; + head.whole + .and_then(|()| decode_store_file(&bytes, head.first.as_deref())) + .map_err(|error| self.unreadable(CultCacheStoreUnreadableKind::Undecodable, error)) + } + + // The one owner of the stamp rule, asked only by the one reader: a cultcache.store.* header this runtime does not read + // (it reads v1 and v3) is refused by name, judged from the header alone, so a newer store cut short is still a newer + // store. Every write over a file asks that reader first, so a whole-store write judges a stamp as a read does. + fn refuse_unread_format(&self, header: Option<&str>) -> Result<()> { + match header { + Some(header) if header.starts_with("cultcache.store.") && readable_store_format(header).is_none() => Err(self.unreadable( CultCacheStoreUnreadableKind::UnsupportedFormat, anyhow!( "its header is {}; this runtime reads {STORE_FORMAT_V1:?} and {STORE_FORMAT_ELEMENT_IDS:?} only", - describe_header(&header) + describe_header(header) ), )), - first => head - .whole - .and_then(|()| decode_store_file(&bytes, first.as_deref())) - .map_err(|error| unreadable(CultCacheStoreUnreadableKind::Undecodable, error)), + _ => Ok(()), } } + fn unreadable(&self, kind: CultCacheStoreUnreadableKind, detail: anyhow::Error) -> anyhow::Error { + // The detail is rebuilt as text that names no value read from the bytes. The + // decoder reports a short read as an io::Error, and dropping it keeps an + // io::Error in the chain meaning only that the file itself could not be read. + anyhow!(describe_undecodable(&detail)).context(CultCacheStoreUnreadable { + path: self.path.clone(), + kind, + }) + } + /// Reads one filesystem snapshot without creating or opening the sibling /// CultCache lock file. /// diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index 808a0ec7..ca6b87cc 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following - A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name. - A single-file store refuses a zero-byte file as `StoreUnreadableError`; only a path with nothing at it is an empty store. A header this runtime does not read is refused, and the refusal echoes the header only as `cultcache.store.v`. - A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `pushAll` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. +- This reverses the 0.14.2 `pushAll` fix in two ways. A full `pushAll` over a file with no readable stamp no longer replaces it (healing it): the reader refuses it, and the file is left as it is. And a `cultcache.store.v3` stamp is read, not refused. The stamp rule has one owner, `refuseUnreadFormat`, asked only by the one reader that every write consults under the lock. - A dangling symbolic link at the store path is an I/O error with code `ENOENT`, not an empty store. ### Added @@ -38,6 +39,13 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following - Opening a legacy store that needs healing rewrites it under the lock, from a read taken under the lock. +### Fixed +- A full `pushAll` reads the store's header under the lock first and refuses a store format + this runtime does not read (the same error a read gives), leaving the file as it was. A + file with no readable stamp is still replaced, so a whole write heals it. Soft `pushAll` + is unchanged. The stamp is decoded by the decoder a read uses, so a stamp followed by bytes + that are not UTF-8 is refused by a whole write as a read refuses it. + ### Dependencies - `koffi` 3.3.0, loaded only on the first write. diff --git a/packages/cultcache-ts/src/store-format.ts b/packages/cultcache-ts/src/store-format.ts index c9573e81..910d1a90 100644 --- a/packages/cultcache-ts/src/store-format.ts +++ b/packages/cultcache-ts/src/store-format.ts @@ -22,13 +22,17 @@ export function isStoreSnapshot(decoded: unknown): decoded is [StoreFormat, ...u return false; } - if (decoded[0] !== STORE_FORMAT_VERSION && decoded[0] !== STORE_FORMAT_ELEMENT_IDS) { + refuseUnreadFormat(decoded[0]); + return true; +} + +/** The one verdict on a stamp: this runtime reads v1 and v3, and any other `cultcache.store.*` header is refused by name. */ +function refuseUnreadFormat(header: string): void { + if (header !== STORE_FORMAT_VERSION && header !== STORE_FORMAT_ELEMENT_IDS) { throw new Error( - `CultCache store format ${describeHeader(decoded[0])} is not one this runtime reads; it reads "${STORE_FORMAT_VERSION}" and "${STORE_FORMAT_ELEMENT_IDS}" only.`, + `CultCache store format ${describeHeader(header)} is not one this runtime reads; it reads "${STORE_FORMAT_VERSION}" and "${STORE_FORMAT_ELEMENT_IDS}" only.`, ); } - - return true; } /** diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 1e493988..9d804400 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -2083,6 +2083,37 @@ test("SingleFileMessagePackBackingStore refusal says what was found and names ev } }); +test("a stamp this runtime reads, followed by bytes that are not UTF-8, gets the same refusal from a read and from a whole write", async () => { + const envelope = { key: "probe", type: "probe", payload: documentPayloadFromBytes(new Uint8Array([0x80])), storedAt: "2026-10-22T00:00:00Z" }; + for (const header of ["cultcache.store.v1", "cultcache.store.v3"]) { + const stamp = [...Buffer.from(header, "utf8")]; + const forms = { fixstr: [0x91, 0xa0 | (stamp.length + 1), ...stamp, 0xff], str8: [0x91, 0xd9, stamp.length + 1, ...stamp, 0xff] }; + for (const [name, bytes] of Object.entries(forms)) { + const file = join(await mkdtemp(join(tmpdir(), "cultcache-stamp-")), "store.msgpack"); + await writeFile(file, new Uint8Array(bytes)); + const store = new SingleFileMessagePackBackingStore(file); + await assert.rejects(() => store.pullAll(), /not one this runtime reads/u, `${header} ${name} read`); + await assert.rejects(() => store.pushAll([envelope]), /not one this runtime reads/u, `${header} ${name} pushAll`); + assert.deepEqual([...(await readFile(file))], bytes, `${header} ${name} left as it was`); + } + } +}); + +test("pushAll leaves a store it reads alone when soft, and replaces one it reads otherwise", async () => { + const vector = await readFile(resolve(cultLibRoot, "tests", "vectors", "document-variants-c2a", "v3-base.msgpack")); + const envelope = { key: "probe", type: "probe", payload: documentPayloadFromBytes(new Uint8Array([0x80])), storedAt: "2026-10-22T00:00:00Z" }; + const existing = join(await mkdtemp(join(tmpdir(), "cultcache-pushall-soft-")), "store.msgpack"); + await writeFile(existing, vector); + await new SingleFileMessagePackBackingStore(existing).pushAll([envelope], { soft: true }); + assert.equal(Buffer.compare(await readFile(existing), vector), 0); + + const own = join(await mkdtemp(join(tmpdir(), "cultcache-pushall-own-")), "store.msgpack"); + const store = new SingleFileMessagePackBackingStore(own); + await store.pushAll([envelope]); + await store.pushAll([{ ...envelope, key: "second" }]); + assert.deepEqual((await store.pullAll()).map((entry) => entry.key), ["second"]); +}); + test("SingleFileMessagePackBackingStore reads only a missing store as empty", async () => { const dir = await mkdtemp(join(tmpdir(), "cultcache-absent-")); assert.deepEqual(await new SingleFileMessagePackBackingStore(join(dir, "store.cc")).pullAll(), []);