Merge origin/main (cultcache-rs 0.3.1, the whole-store writer fix) into the C2a landing branch

The stamp functions (Rust refuse_unread_format, TS refuseUnreadFormat) are the single owner of the stamp rule, accept v1 and v3, and are called only from the one reader every whole-store write runs under the lock. The fix's header peek (TS refuseUnreadStoreBytes, peekHeader) is deleted. Its tests that v3 is refused and that a stampless file is healed are dropped; the reversal is a Breaking line in the Rust and TS CHANGELOGs. The 0xff-after-stamp test now runs over v1 and v3.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-23 00:44:59 +02:00
commit f54472e5fe
6 changed files with 80 additions and 22 deletions

View file

@ -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.

View file

@ -1,6 +1,6 @@
[package]
name = "cultcache-rs"
version = "0.3.0"
version = "0.3.1"
edition = "2024"
license = "MIT"
publish = false

View file

@ -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.
///

View file

@ -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<digits>`.
- 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.

View file

@ -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;
}
/**

View file

@ -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(), []);