diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index 7f3caa73..9093f180 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -10,6 +10,7 @@ only in git history. Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0). +- A write at a store path that is a symbolic link, live or dangling, is refused with the new public `StoreLinkedError` (`error.downcast_ref::()`) before the lock and the replace, and writes nothing: `push`, `push_all`, `delete`, the exchanges, and a soft `push_all`, which used to skip a link and now refuses. Before, a write at a live link replaced the link with a regular file and a dangling link failed with `io::ErrorKind::NotFound`. The redb stores (`RedbMessagePackBackingStore`, `OwnedRedbMessagePackBackingStore`) refuse a link at `new`. Configure the link's target as the store path. Reads through a live link work. - A record whose payload carries unread slots is read-only. A payload with a slot past its type's declared slots, at any depth (record, nested record, list element, union arm, unit arm), still decodes, and every write or removal of its key is now refused before any store is touched: `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch` (the whole batch) and `delete`, so `update` too. The refusal is `SchemaConflictError` with the new `unread_slots`; its message names the type and both slot counts and never a slot value. Holders of such a record used to overwrite it and drop the extra slots silently. The payload nesting refusal still runs first. A held payload this build cannot read at all (an arm it lacks from a newer writer, or a corrupt record) is read-only too: every write or removal is refused with an error naming the type and key (a newer writer's record is read with that build; a corrupt one is restored from a backup or removed at the store itself, as no cache call removes it), and `is_writable` answers false. `load_soa` is a write of every held key it omits or changes, so it refuses to drop or replace a read-only record. `is_writable` also answers false for a key a store carries as a foreign record and for a type other than the one holding the key, as the writes do. Each write over a held key decodes the held payload once to ask (about 40% of a `put` of a 200,000-element record, measured in release); keys nothing holds pay nothing. - cultmesh-rs `pull_rudp_catalog_snapshot` applies a snapshot as one cache commit (`put_prepared_batch`), as C# does: a snapshot document for a key the node holds read-only, or any other refusal, applies nothing. It used to apply document by document, so a refusal ended the pull with the earlier documents kept. A snapshot whose types route to different backing stores is now refused whole, as a batch cannot span stores. C# `CultCache.Commit` refuses a cross-store commit the same way (`The batch spans A and B; a commit lands in one home store`), so the two agree; a test pins it. - `SchemaConflictError` gains the public field `unread_slots: Option`; code that builds it with a struct literal must set it (`None`). diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index 2d217083..06c530b1 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -384,9 +384,10 @@ an empty store. Any other failure to reach it, such as a symlink loop, a file where a parent directory should be, or a missing permission, is an I/O error. A store file that exists and holds zero bytes is `Undecodable`: no CultCache writer leaves one. A symbolic link whose target is gone is not a missing store: -reading it is an I/O error, so a read-then-write (`push`, `push_all`, `delete`, the exchanges) -fails before writing. Writes do not yet follow links: a write through a live link -replaces the link with a regular file and leaves its target alone. A refusal names no +reading it is an I/O error. A write at a store path that is a link, live or dangling, is +refused with `StoreLinkedError` (`error.downcast_ref::()`) before the +lock and the replace, so no write swaps a link for a regular file; configure the link's +target as the store path. Reads through a live link work. A refusal names no value read from the file; it names a record only by its key and schema id, and a header only when it has the shape `cultcache.store.v`. diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index e3e47e43..4d3b43cd 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -2027,6 +2027,7 @@ impl std::fmt::Debug for RedbMessagePackBackingStore { impl RedbMessagePackBackingStore { pub fn new(path: impl Into) -> Result { let path = path.into(); + refuse_linked(&path)?; if let Some(parent) = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) @@ -2400,6 +2401,7 @@ impl std::fmt::Debug for OwnedRedbMessagePackBackingStore { impl OwnedRedbMessagePackBackingStore { pub fn new(path: impl Into) -> Result { let path = path.into(); + refuse_linked(&path)?; if let Some(parent) = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) @@ -7994,6 +7996,25 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn a_redb_store_at_a_live_link_is_refused_before_it_opens_the_target() -> Result<()> { + let temp = tempfile::tempdir()?; + let volume = temp.path().join("volume"); + fs::create_dir(&volume)?; + let target = volume.join("store.redb"); + fs::write(&target, b"untouched")?; + let link = temp.path().join("link.redb"); + std::os::unix::fs::symlink(&target, &link)?; + let error = RedbMessagePackBackingStore::new(&link).unwrap_err(); + assert!(error.downcast_ref::().is_some(), "{error:#}"); + let error = OwnedRedbMessagePackBackingStore::new(&link).unwrap_err(); + assert!(error.downcast_ref::().is_some(), "{error:#}"); + assert!(fs::symlink_metadata(&link)?.file_type().is_symlink()); + assert_eq!(fs::read(&target)?, b"untouched"); + Ok(()) + } + #[cfg(unix)] fn dir_names(dir: &Path) -> Result> { let mut names = fs::read_dir(dir)?.map(|entry| Ok(entry?.file_name().to_string_lossy().into_owned())).collect::>>()?;