diff options
| author | tv <tv@krebsco.de> | 2026-11-08 14:09:25 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-08 15:10:05 +0100 |
| commit | 84a137b4db8d901848df8e06dc45dfca2206dbae (patch) | |
| tree | 0cece057bfab6ed625bb7272e47a88b65b95b6fb /src/access | |
| parent | f37303dddf5e622fef9e99383f5fb700ed641c06 (diff) | |
rules: an expired remake aborted by any member
Under a policy, a remake's decision was taken from its coordinator only.
A node aborting an expired remake whose coordinator is gone
(DELETE /admin/remakes, after abortableAt) was refused by its own
judgement (403), so such a remake could never be decided.
The decision is now also taken from a user who may write it (the
protocol writers), and an abort from any member once the remake expired
a grace period ago (abortableAt, by the coordinator's grace period in
the proposal). The rules have no clock of their own, so that is judged
by the time the change says it was made: the writer's word, so a member
could abort a remake early, as it could ask its coordinator to, but
never commit one. Before then, an abort stays a request the coordinator
applies.
Diffstat (limited to 'src/access')
| -rw-r--r-- | src/access/rules.rs | 105 | ||||
| -rw-r--r-- | src/access/screen.rs | 1 |
2 files changed, 105 insertions, 1 deletions
diff --git a/src/access/rules.rs b/src/access/rules.rs index cf2740a..a56e5ae 100644 --- a/src/access/rules.rs +++ b/src/access/rules.rs @@ -56,6 +56,8 @@ pub(crate) struct Change<'a> { pub(crate) paths: &'a BTreeSet<Path>, /// Its operations, where each is and what it does there (`inspect::Inspector::ops`). pub(crate) ops: &'a [Op], + /// When it was made (ms), as its writer says. + pub(crate) time: u64, } /// Why a change is refused. @@ -197,8 +199,13 @@ pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result // 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. + // Also by one who may write it; and an abort by any member, once the + // remake expired a grace period ago (`aborts_expired`). Some("decision") => match coordinator(&at, id) { - Some(coordinator) => coordinator == node, + Some(coordinator) => { + coordinator == node || user_may(path) || aborts_expired(&at, id, change) + } + None if user_may(path) => true, None => { change .paths @@ -284,6 +291,7 @@ pub(crate) fn judge_held( user: user.as_deref(), paths: &paths, ops: &ops, + time: u64::try_from(change.timestamp()).unwrap_or(0), }, ); Some(match judged { @@ -432,6 +440,38 @@ fn made_the_epoch(at: &At<'_>, epoch: Epoch, id: &str, node: &str) -> bool { == Some(node) } +/// Whether `change` aborts remake `id`, undecided at `at`, as any member may: once it +/// expired a grace period ago (`remake::proposal::abortable_at`, its coordinator's grace +/// period), by the change's time. That is its writer's word: a member could abort a +/// remake early, as it could ask its coordinator to; never commit one. +fn aborts_expired(at: &At<'_>, id: &str, change: &Change<'_>) -> bool { + if store::read_path(at, &meta::path(&[meta::REMAKES, id, "decision"])) + .ok() + .flatten() + .is_some() + { + return false; + } + let outcome = meta::path(&[meta::REMAKES, id, "decision", "outcome"]); + let mut outcomes = change.ops.iter().filter(|op| op.path == outcome).peekable(); + if outcomes.peek().is_none() + || !outcomes.all(|op| matches!(&op.action, Action::PutString(value) if value == "aborted")) + { + return false; + } + let Some(proposal) = store::read_path(at, &meta::path(&[meta::REMAKES, id, "proposal"])) + .ok() + .flatten() + else { + return false; + }; + let number = |key: &str| proposal.get(key).and_then(serde_json::Value::as_u64); + let (Some(settle_until), Some(grace)) = (number("settleUntil"), number("graceMs")) else { + return false; + }; + change.time >= settle_until.saturating_add(grace.saturating_mul(2)) +} + /// 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"])) @@ -514,6 +554,7 @@ mod tests { user, paths: &touched, ops: &[], + time: 0, }, ) } @@ -598,6 +639,7 @@ mod tests { user: None, paths: &touched, ops: &[], + time: 0, }, ) }; @@ -685,11 +727,67 @@ mod tests { user: None, paths: &touched, ops: &[], + time: 0, }, ); assert!(earlier.is_ok()); } + /// A remake's decision: its coordinator's, one who may write it; an abort, any + /// member's, once the remake expired a grace period ago, by the change's time. + #[test] + fn expired_remakes_aborted_by_any_member() { + let mut doc = swarm(true); + write( + &mut doc, + "_continuum/remakes/r4/proposal", + json!({"coordinator": "a", "settleUntil": 1_000, "graceMs": 100}), + ); + write( + &mut doc, + "_continuum/users/carol", + registry::entry("a", 2, &[(path("_continuum"), &[Right::Write][..])]), + ); + let decide = |user: Option<&str>, outcome: &str, time: u64| { + let ops = vec![ + Op { + path: path("_continuum/remakes/r4/decision"), + action: Action::MakeMap, + }, + Op { + path: path("_continuum/remakes/r4/decision/outcome"), + action: Action::PutString(outcome.to_owned()), + }, + Op { + path: path("_continuum/remakes/r4/decision/decidedAt"), + action: Action::Other, + }, + ]; + let deps = doc.get_heads(); + let touched: BTreeSet<Path> = ops.iter().map(|op| op.path.clone()).collect(); + judge( + &doc, + Epoch::GENESIS, + &Change { + hash: ChangeHash([9; 32]), + deps: &deps, + seen: &deps, + committer: Some("b"), + user, + paths: &touched, + ops: &ops, + time, + }, + ) + }; + // Expired at 1100; abortable by others a grace period later. + assert!(refused(decide(None, "aborted", 1_199))); + assert!(decide(None, "aborted", 1_200).is_ok()); + assert!(refused(decide(None, "committed", 5_000))); + // One who may write protocol data, any decision, any time. + assert!(decide(Some("carol"), "committed", 0).is_ok()); + } + /// A node that isn't a member may only register itself. #[test] fn strangers_register_themselves_only() { @@ -750,6 +848,7 @@ mod tests { user: Some("alice"), paths: &touched, ops: &[], + time: 0, }, ) }; @@ -812,6 +911,7 @@ mod tests { user: Some("alice"), paths: &touched, ops: &ops, + time: 0, }, ) }; @@ -902,6 +1002,7 @@ mod tests { user: Some("alice"), paths: &touched, ops: &ops, + time: 0, }, ) }; @@ -979,6 +1080,7 @@ mod tests { user: Some("bob"), paths: &touched, ops: &ops, + time: 0, }, ) }; @@ -1033,6 +1135,7 @@ mod tests { user: None, paths: &touched, ops: &[], + time: 0, }, ) }; diff --git a/src/access/screen.rs b/src/access/screen.rs index 37e3adf..a9450a6 100644 --- a/src/access/screen.rs +++ b/src/access/screen.rs @@ -414,6 +414,7 @@ pub(crate) fn apply( user: user.as_deref(), paths: &paths[index], ops: &ops[index], + time: u64::try_from(change.timestamp()).unwrap_or(0), }, ); match judged { |
