aboutsummaryrefslogtreecommitdiffstats
path: root/src/access/rules.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-08 13:54:07 +0000
committertv <tv@krebsco.de>2026-11-08 15:04:04 +0100
commit5fe7653a2e86bfbee0bcbe536bb74287ab47498f (patch)
tree88bffac257742671dd9103ceff5304c88aa74b82 /src/access/rules.rs
parent1769f30038c62c999cfb91be0868c740fb1c0eba (diff)
rules: a carried remake record taken from the coordinator its snapshot names
Review finding 7, second part: a record carried into a new epoch whole (proposal and decision in one change) was taken from any member, for any remake ID. A member could write a forged record, decision included, for a fresh ID in a single change. The snapshot's epoch metadata now names the remake's coordinator, as the quorum built it. The carried-record exception holds only in a later epoch, only for the remake that made it, and only from the coordinator its snapshot names, where there is no record yet. Wire version 43: the snapshot, and so the epoch ID, changes. The review suggested carrying the record inside the snapshot instead. The snapshot can't hold the decision, which names the snapshot's own hash. So the snapshot vouches for who may carry the record instead.
Diffstat (limited to 'src/access/rules.rs')
-rw-r--r--src/access/rules.rs97
1 files changed, 75 insertions, 22 deletions
diff --git a/src/access/rules.rs b/src/access/rules.rs
index 2746cb3..cf2740a 100644
--- a/src/access/rules.rs
+++ b/src/access/rules.rs
@@ -194,8 +194,9 @@ pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result
// A node forgotten while alive cancels its own record (`swarm::peer::forget`).
(Some(meta::FORGOTTEN), Some(owner)) => owner == node || user_may(path),
(Some(meta::REMAKES), Some(id)) => match path.get(3).map(String::as_str) {
- // Its coordinator's; or a record carried into a new epoch whole, by the
- // node that coordinated it (`remake::switch`): its proposal written with it.
+ // Its coordinator's; or the record of the remake that made the epoch,
+ // carried into it whole (its proposal written with it) by the coordinator
+ // its snapshot names (`remake::switch`), where there is none.
Some("decision") => match coordinator(&at, id) {
Some(coordinator) => coordinator == node,
None => {
@@ -203,10 +204,11 @@ pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result
.paths
.iter()
.any(|other| other == &meta::path(&[meta::REMAKES, id, "proposal"]))
- || change
- .paths
- .iter()
- .any(|other| other == &meta::path(&[meta::REMAKES, id]))
+ && store::read_path(&at, &meta::path(&[meta::REMAKES, id]))
+ .ok()
+ .flatten()
+ .is_none()
+ && made_the_epoch(&at, epoch, id, node)
}
},
// The record, made where there is none, and its proposal, written where
@@ -410,6 +412,26 @@ fn shown(path: &Path) -> String {
format!("/{}", path.join("/"))
}
+/// Whether remake `id`, coordinated by `node`, made `epoch`: as its snapshot's metadata
+/// says (`remake::snapshot`), at `at`. Never genesis.
+fn made_the_epoch(at: &At<'_>, epoch: Epoch, id: &str, node: &str) -> bool {
+ if epoch.generation == 0 {
+ return false;
+ }
+ let generation = epoch.generation.to_string();
+ let Some(metadata) = store::read_path(at, &meta::path(&[meta::EPOCHS, &generation]))
+ .ok()
+ .flatten()
+ else {
+ return false;
+ };
+ metadata.get("remakeId").and_then(serde_json::Value::as_str) == Some(id)
+ && metadata
+ .get("coordinator")
+ .and_then(serde_json::Value::as_str)
+ == Some(node)
+}
+
/// The coordinator remake `id`'s proposal names, at `at`.
fn coordinator(at: &At<'_>, id: &str) -> Option<String> {
let proposal = store::read_path(at, &meta::path(&[meta::REMAKES, id, "proposal"]))
@@ -544,24 +566,55 @@ mod tests {
assert!(refused(judged(&doc, "b", None, &["_continuum/members"])));
assert!(refused(judged(&doc, "b", None, &["_continuum/remakes/r1"])));
assert!(judged(&doc, "b", None, &["_continuum/votes"]).is_ok());
- // A record carried into a new epoch whole: its proposal with its decision.
- assert!(
- judged(
- &doc,
- "b",
- None,
- &[
- "_continuum/remakes/r2",
- "_continuum/remakes/r2/proposal",
- "_continuum/remakes/r2/decision"
- ]
- )
- .is_ok()
+ // A record carried into a new epoch whole, its proposal with its decision: only
+ // the record of the remake that made the epoch, by its coordinator, where there
+ // is none.
+ let whole = [
+ "_continuum/remakes/r2",
+ "_continuum/remakes/r2/proposal",
+ "_continuum/remakes/r2/decision",
+ ];
+ assert!(refused(judged(&doc, "b", None, &whole)));
+ let mut later = doc.fork();
+ write(
+ &mut later,
+ "_continuum/epochs/1",
+ json!({"generation": 1, "remakeId": "r2", "coordinator": "b"}),
);
- assert!(refused(judged(
- &doc,
+ let in_epoch_one = |doc: &Document, committer: &str, touched: &[&str]| {
+ let deps = doc.get_heads();
+ let touched = paths(touched);
+ judge(
+ doc,
+ Epoch {
+ generation: 1,
+ id: [7; 32],
+ },
+ &Change {
+ hash: ChangeHash([9; 32]),
+ deps: &deps,
+ seen: &deps,
+ committer: Some(committer),
+ user: None,
+ paths: &touched,
+ ops: &[],
+ },
+ )
+ };
+ assert!(in_epoch_one(&later, "b", &whole).is_ok());
+ assert!(refused(in_epoch_one(&later, "a", &whole)));
+ assert!(refused(in_epoch_one(
+ &later,
+ "b",
+ &[
+ "_continuum/remakes/r3",
+ "_continuum/remakes/r3/proposal",
+ "_continuum/remakes/r3/decision"
+ ]
+ )));
+ assert!(refused(in_epoch_one(
+ &later,
"b",
- None,
&["_continuum/remakes/r2/decision"]
)));
// The rest: a user's who may write there.