summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-26 04:13:11 +0100
committertv <tv@krebsco.de>2026-10-26 04:13:11 +0100
commitd668ce2ff8f308e54c1c7a2c145cb3479cab899d (patch)
tree851ceb314782a9428cac36d48fb5abc5805ead5f /src
parentc13cee08dedeb8e73560f9bf9f7154c9fa089e2d (diff)
move protocol paths to dedicated module
Diffstat (limited to 'src')
-rw-r--r--src/api/mod.rs7
-rw-r--r--src/document/meta.rs45
-rw-r--r--src/document/mod.rs1
-rw-r--r--src/remake/delta.rs19
-rw-r--r--src/remake/kind.rs25
-rw-r--r--src/remake/records.rs9
-rw-r--r--src/remake/snapshot.rs33
-rw-r--r--src/remake/switch.rs5
-rw-r--r--src/swarm/bootstrap.rs5
-rw-r--r--src/swarm/members.rs5
-rw-r--r--src/swarm/peer.rs4
11 files changed, 96 insertions, 62 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs
index affb9ef..bec7764 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -25,6 +25,7 @@ use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::api::auth::{AuthState, AuthenticatedUser, basic_auth};
+use crate::document::meta;
use crate::document::store::StoreError;
use crate::state::AppState;
@@ -117,13 +118,11 @@ fn parse_write_path(
method: &str,
) -> Result<Vec<String>, StatusCode> {
let path = parse_key_path(key)?;
- if path.first().map(String::as_str) != Some(crate::remake::snapshot::META_ROOT) {
+ if !meta::is_protocol(&path) {
return Ok(path);
}
// `_continuum` itself, epoch metadata and the swarm ID: nobody.
- let fixed = path.len() < 2
- || path[1] == crate::remake::snapshot::EPOCHS
- || path[1] == crate::remake::snapshot::SWARM;
+ let fixed = path.len() < 2 || path[1] == meta::EPOCHS || path[1] == meta::SWARM;
if fixed || !state.protocol_writers.contains(&user.0) {
warn!(user = %user.0, method, path = %key, "Refused a write to protocol data");
return Err(StatusCode::FORBIDDEN);
diff --git a/src/document/meta.rs b/src/document/meta.rs
new file mode 100644
index 0000000..482b421
--- /dev/null
+++ b/src/document/meta.rs
@@ -0,0 +1,45 @@
+//! The protocol's own data in the document: everything under `_continuum`. The names
+//! of its parts, and the paths to them, are defined here only.
+//!
+//! * `members/<pk>`: the member registry (`swarm::members`).
+//! * `forgotten/<pk>`: forget records (`swarm::peer`).
+//! * `remakes/<id>`: remake records (`remake::records`).
+//! * `votes/<pk>`: the votes register (`remake::records`).
+//! * `epochs/<generation>`: epoch metadata, written by snapshots (`remake::snapshot`).
+//! * `swarm`: the swarm ID, written once by the genesis node (`swarm::bootstrap`).
+
+/// The key every protocol path starts with.
+pub(crate) const ROOT: &str = "_continuum";
+pub(crate) const MEMBERS: &str = "members";
+pub(crate) const FORGOTTEN: &str = "forgotten";
+pub(crate) const REMAKES: &str = "remakes";
+pub(crate) const VOTES: &str = "votes";
+pub(crate) const EPOCHS: &str = "epochs";
+pub(crate) const SWARM: &str = "swarm";
+
+/// The store path `_continuum/<parts…>`.
+pub(crate) fn path(parts: &[&str]) -> Vec<String> {
+ std::iter::once(ROOT)
+ .chain(parts.iter().copied())
+ .map(str::to_owned)
+ .collect()
+}
+
+/// Whether a store path is protocol data (under `_continuum`).
+pub(crate) fn is_protocol(path: &[String]) -> bool {
+ path.first().is_some_and(|root| root == ROOT)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn paths_start_at_the_root() {
+ assert_eq!(path(&[]), vec!["_continuum"]);
+ assert_eq!(path(&[MEMBERS, "pk"]), vec!["_continuum", "members", "pk"]);
+ assert!(is_protocol(&path(&[VOTES])));
+ assert!(!is_protocol(&["users".to_owned()]));
+ assert!(!is_protocol(&[]));
+ }
+}
diff --git a/src/document/mod.rs b/src/document/mod.rs
index 0da1d67..1024859 100644
--- a/src/document/mod.rs
+++ b/src/document/mod.rs
@@ -2,5 +2,6 @@
//! (merged-view paths over JSON) the HTTP API and remakes work with.
pub(crate) mod file;
+pub(crate) mod meta;
pub mod replica;
pub mod store;
diff --git a/src/remake/delta.rs b/src/remake/delta.rs
index 59acc8c..ef6a60a 100644
--- a/src/remake/delta.rs
+++ b/src/remake/delta.rs
@@ -27,7 +27,7 @@ use automerge::{ActorId, AutoCommit, ChangeHash, PatchAction, Prop};
use tracing::warn;
use super::kind::{Translation, Translator};
-use super::snapshot::{EPOCHS, META_ROOT, REMAKES, VOTES};
+use crate::document::meta::{self, EPOCHS, REMAKES, VOTES};
use crate::document::store::{self, StoreError};
#[derive(Debug, Default, Clone)]
@@ -138,12 +138,12 @@ fn is_protocol_path(path: &[String]) -> bool {
let [root, part, ..] = path else {
return false;
};
- root == META_ROOT && [REMAKES, VOTES, EPOCHS].contains(&part.as_str())
+ root == meta::ROOT && [REMAKES, VOTES, EPOCHS].contains(&part.as_str())
}
/// True for the root and for `_continuum` itself.
fn contains_protocol_path(path: &[String]) -> bool {
- matches!(path, [] | [_]) && path.first().is_none_or(|root| root == META_ROOT)
+ matches!(path, [] | [_]) && path.first().is_none_or(|root| root == meta::ROOT)
}
/// What a late node has that a snapshot lacks.
@@ -464,7 +464,7 @@ mod tests {
let mut new = AutoCommit::new();
store::write_path(
&mut new,
- &[META_ROOT.into(), EPOCHS.into(), "1".into()],
+ &meta::path(&[EPOCHS, "1"]),
&json!({ "generation": 1 }),
)
.unwrap();
@@ -473,21 +473,16 @@ mod tests {
// The old epoch replaces `_continuum` wholesale and writes a remake record.
store::write_path(
&mut old,
- &[META_ROOT.into()],
+ &meta::path(&[]),
&json!({ "forgotten": { "pk": 1 } }),
)
.unwrap();
- store::write_path(
- &mut old,
- &[META_ROOT.into(), REMAKES.into(), "c".into()],
- &json!({ "x": 1 }),
- )
- .unwrap();
+ store::write_path(&mut old, &meta::path(&[REMAKES, "c"]), &json!({ "x": 1 })).unwrap();
old.commit();
let after = old.get_heads();
replay(&mut old, &base, &after, &mut new, &Translator::default());
- let meta = &view(&new)[META_ROOT];
+ let meta = &view(&new)[meta::ROOT];
assert_eq!(meta[EPOCHS]["1"]["generation"], json!(1));
assert_eq!(meta["forgotten"]["pk"], json!(1));
assert!(meta.get(REMAKES).is_none());
diff --git a/src/remake/kind.rs b/src/remake/kind.rs
index 42bdd88..aa9e978 100644
--- a/src/remake/kind.rs
+++ b/src/remake/kind.rs
@@ -25,7 +25,8 @@ use base64::engine::{Engine, general_purpose::STANDARD as BASE64};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
-use super::snapshot::{META_ROOT, canonical};
+use super::snapshot::canonical;
+use crate::document::meta;
use crate::document::store::{self, StoreError};
use crate::files::{FileKind, Files};
@@ -141,7 +142,7 @@ pub(crate) fn remake_genesis(
/// Paths owned by the protocol rather than by any kind.
pub(crate) fn is_reserved(path: &[String]) -> bool {
- path.first().is_some_and(|root| root == META_ROOT)
+ meta::is_protocol(path)
}
/// Compaction: the new document has the old one's content at the base, without its
@@ -254,9 +255,10 @@ impl RemakeKind for Replace {
let JsonValue::Object(document) = &spec.document else {
return Err("document must be a JSON object (the store root)".to_owned());
};
- if document.contains_key(META_ROOT) {
+ if document.contains_key(meta::ROOT) {
return Err(format!(
- "document must not contain the reserved key {META_ROOT:?}"
+ "document must not contain the reserved key {:?}",
+ meta::ROOT
));
}
@@ -283,7 +285,7 @@ impl RemakeKind for Replace {
return Ok(()); // propose only accepts objects
};
for (key, value) in document {
- if key != META_ROOT {
+ if key != meta::ROOT {
store::write_path(target, &[key], &value)?;
}
}
@@ -311,7 +313,8 @@ fn prepare_rules(specs: Vec<RuleSpec>) -> Result<JsonValue, String> {
};
if is_reserved(&from) || to.as_deref().is_some_and(is_reserved) {
return Err(format!(
- "rules must not touch the reserved key {META_ROOT:?}"
+ "rules must not touch the reserved key {:?}",
+ meta::ROOT
));
}
if rules.iter().any(|existing: &Rule| existing.from == from) {
@@ -528,7 +531,7 @@ mod tests {
));
// Protocol data is never translated.
assert_eq!(
- translator.translate(&path(&[META_ROOT, "forgotten", "pk"])),
+ translator.translate(&path(&[meta::ROOT, meta::FORGOTTEN, "pk"])),
Translation::Same
);
}
@@ -541,7 +544,7 @@ mod tests {
fn replace_validates_the_spec() {
use serde_json::json;
assert!(replace(json!({ "document": [] })).is_err());
- assert!(replace(json!({ "document": { META_ROOT: {} } })).is_err());
+ assert!(replace(json!({ "document": { (meta::ROOT): {} } })).is_err());
assert!(replace(json!({ "document": {}, "rules": [{ "from": "a" }] })).is_err());
assert!(
replace(json!({ "document": {}, "rules": [{ "from": "a", "to": "b", "drop": true }] }))
@@ -551,7 +554,7 @@ mod tests {
replace(json!({ "document": {}, "rules": [{ "from": "a//b", "to": "c" }] })).is_err()
);
assert!(
- replace(json!({ "document": {}, "rules": [{ "from": "a", "to": META_ROOT }] }))
+ replace(json!({ "document": {}, "rules": [{ "from": "a", "to": (meta::ROOT) }] }))
.is_err()
);
assert!(replace(json!({ "document": {}, "rules": [{ "from": "a", "drop": true }, { "from": ["a"], "to": "b" }] })).is_err());
@@ -602,7 +605,7 @@ mod tests {
source
.put(ROOT, "hits", automerge::ScalarValue::counter(3))
.unwrap();
- let meta = source.put_object(ROOT, META_ROOT, ObjType::Map).unwrap();
+ let meta = source.put_object(ROOT, meta::ROOT, ObjType::Map).unwrap();
source.put(&meta, "members", "elsewhere").unwrap();
let encoded = BASE64.encode(source.save());
@@ -632,7 +635,7 @@ mod tests {
let mut b = build();
assert_eq!(a.get_heads(), b.get_heads());
- assert!(a.get(ROOT, META_ROOT).unwrap().is_none());
+ assert!(a.get(ROOT, meta::ROOT).unwrap().is_none());
let (notes, notes_id) = a.get(ROOT, "notes").unwrap().unwrap();
assert!(matches!(notes, automerge::Value::Object(ObjType::Text)));
assert_eq!(a.text(&notes_id).unwrap(), "hello");
diff --git a/src/remake/records.rs b/src/remake/records.rs
index 82c61a1..119ff0c 100644
--- a/src/remake/records.rs
+++ b/src/remake/records.rs
@@ -34,7 +34,8 @@ use serde_json::Value as JsonValue;
use tracing::warn;
use super::kind::DEFAULT_KIND;
-use super::snapshot::{META_ROOT, REMAKES, SnapshotRequest, VOTES};
+use super::snapshot::SnapshotRequest;
+use crate::document::meta::{self, REMAKES, VOTES};
use crate::document::replica::Epoch;
use crate::document::store::{self, StoreError};
@@ -334,7 +335,7 @@ impl Record {
// --- document access ---------------------------------------------------------
fn records_dir() -> Vec<String> {
- vec![META_ROOT.to_owned(), REMAKES.to_owned()]
+ meta::path(&[REMAKES])
}
pub(crate) fn record_path(id: &str, part: &str) -> Vec<String> {
@@ -424,12 +425,12 @@ fn contested_decision(doc: &AutoCommit, id: &str) -> Option<Decision> {
}
fn register_path(voter: &str) -> Vec<String> {
- vec![META_ROOT.to_owned(), VOTES.to_owned(), voter.to_owned()]
+ meta::path(&[VOTES, voter])
}
/// The votes register; unreadable entries are skipped with a warning.
pub(crate) fn load_register(doc: &AutoCommit) -> Register {
- store::keys_at(doc, &[META_ROOT.to_owned(), VOTES.to_owned()])
+ store::keys_at(doc, &meta::path(&[VOTES]))
.unwrap_or_default()
.into_iter()
.filter_map(|voter| {
diff --git a/src/remake/snapshot.rs b/src/remake/snapshot.rs
index 20569e0..301b32f 100644
--- a/src/remake/snapshot.rs
+++ b/src/remake/snapshot.rs
@@ -30,17 +30,10 @@ use sha2::{Digest, Sha256};
use super::kind::{DEFAULT_KIND, RemakeKind, Translator, by_name};
use super::records::{EpochRecord, Policy};
+use crate::document::meta::{self, EPOCHS, REMAKES, VOTES};
use crate::document::replica::Epoch;
use crate::document::store::{self, StoreError};
-pub(crate) const META_ROOT: &str = "_continuum";
-pub(crate) const REMAKES: &str = "remakes";
-/// The votes register (`records`).
-pub(crate) const VOTES: &str = "votes";
-pub(crate) const EPOCHS: &str = "epochs";
-/// The swarm ID, written once by the genesis node (`swarm::bootstrap`).
-pub(crate) const SWARM: &str = "swarm";
-
/// Everything that determines a snapshot besides the base content.
pub(crate) struct SnapshotParams<'a> {
/// ID of the committed remake; seeds the snapshot actor.
@@ -230,7 +223,7 @@ pub(crate) fn verify_document(doc: &mut AutoCommit, epoch: Epoch) -> Result<Epoc
/// generation it has metadata for, whose snapshot change is the document's only change
/// without dependencies (verified). For a document loaded from a backup.
pub(crate) fn epoch_of(doc: &mut AutoCommit) -> Result<Epoch, String> {
- let generation = store::keys_at(doc, &[META_ROOT.to_owned(), EPOCHS.to_owned()])
+ let generation = store::keys_at(doc, &meta::path(&[EPOCHS]))
.map_err(|error| error.to_string())?
.iter()
.filter_map(|key| key.parse::<u64>().ok())
@@ -270,11 +263,7 @@ pub(crate) fn read_epoch_metadata(
/// Path of the metadata record for an epoch generation.
pub(crate) fn epoch_metadata_path(generation: u64) -> Vec<String> {
- vec![
- META_ROOT.to_owned(),
- EPOCHS.to_owned(),
- generation.to_string(),
- ]
+ meta::path(&[EPOCHS, generation.to_string().as_str()])
}
/// Builds the snapshot of `source` at `params.base_heads`.
@@ -303,17 +292,17 @@ pub(crate) fn build(
// Protocol data carries forward, whatever the kind: forget records, earlier epochs'
// metadata (rejoining nodes need it). Remake records and the votes register describe
// the old epoch's protocol and stay behind.
- match store::delete_path(&mut snapshot, &[META_ROOT.to_owned()], false) {
+ match store::delete_path(&mut snapshot, &meta::path(&[]), false) {
Ok(()) | Err(StoreError::NotFound) => {}
Err(error) => return Err(error.into()),
}
- let mut protocol_keys = store::keys_at(&base, &[META_ROOT.to_owned()])?;
+ let mut protocol_keys = store::keys_at(&base, &meta::path(&[]))?;
protocol_keys.sort();
for key in protocol_keys
.into_iter()
.filter(|key| key != REMAKES && key != VOTES)
{
- store::copy_path(&base, &mut snapshot, &[META_ROOT.to_owned(), key])?;
+ store::copy_path(&base, &mut snapshot, &meta::path(&[key.as_str()]))?;
}
let mut base_heads: Vec<String> = params.base_heads.iter().map(|h| h.to_string()).collect();
@@ -422,7 +411,7 @@ mod tests {
/// The user-visible view, without our own metadata.
fn user_view(doc: &AutoCommit) -> JsonValue {
let mut view = store::read_path(doc, &[]).unwrap().unwrap();
- view.as_object_mut().unwrap().remove(META_ROOT);
+ view.as_object_mut().unwrap().remove(meta::ROOT);
view
}
@@ -580,19 +569,19 @@ mod tests {
let mut a = doc(b"actor-a");
store::write_path(
&mut a,
- &[META_ROOT.into(), REMAKES.into(), "c-1".into()],
+ &meta::path(&[REMAKES, "c-1"]),
&json!({ "state": "committed" }),
)
.unwrap();
store::write_path(
&mut a,
- &[META_ROOT.into(), VOTES.into(), "pk".into()],
+ &meta::path(&[VOTES, "pk"]),
&json!({ "remake": "c-1", "at": 1 }),
)
.unwrap();
store::write_path(
&mut a,
- &[META_ROOT.into(), "forgotten".into(), "pk".into()],
+ &meta::path(&[meta::FORGOTTEN, "pk"]),
&json!({ "at": 1 }),
)
.unwrap();
@@ -602,7 +591,7 @@ mod tests {
let policy = json!({ "replayWindow": { "epochs": 1 } });
let snap = build(&mut a, &params(&heads, &policy)).unwrap();
- let meta = store::read_path(&snap.doc, &[META_ROOT.into()])
+ let meta = store::read_path(&snap.doc, &meta::path(&[]))
.unwrap()
.unwrap();
assert!(meta.get(REMAKES).is_none());
diff --git a/src/remake/switch.rs b/src/remake/switch.rs
index 9cc1ca0..65f296f 100644
--- a/src/remake/switch.rs
+++ b/src/remake/switch.rs
@@ -33,8 +33,9 @@ use super::kind::Translator;
use super::local::LocalStatus;
use super::quarantine;
use super::records::{self, EpochRecord, Record};
-use super::snapshot::{self, META_ROOT, REMAKES};
+use super::snapshot;
use super::vote;
+use crate::document::meta::{self, REMAKES};
use crate::document::replica::Epoch;
use crate::document::store;
use crate::swarm::sync::paths_related;
@@ -44,7 +45,7 @@ const TICK: Duration = Duration::from_secs(1);
pub(crate) async fn run_worker<H: Host>(host: H) {
info!("Starting remake worker");
- let records_dir = vec![META_ROOT.to_owned(), REMAKES.to_owned()];
+ let records_dir = meta::path(&[REMAKES]);
let mut changes = host.subscribe_changes();
let mut changes_closed = false;
let mut tick = tokio::time::interval(TICK);
diff --git a/src/swarm/bootstrap.rs b/src/swarm/bootstrap.rs
index 5e9ac06..ad1de61 100644
--- a/src/swarm/bootstrap.rs
+++ b/src/swarm/bootstrap.rs
@@ -25,11 +25,12 @@ use tracing::{debug, info, warn};
use uuid::Uuid;
use crate::document;
+use crate::document::meta::{self, SWARM};
use crate::document::replica::Epoch;
use crate::document::store;
use crate::files::{FileKind, Files};
use crate::force::Force;
-use crate::remake::snapshot::{self, META_ROOT, SWARM};
+use crate::remake::snapshot;
use crate::state::AppState;
use crate::swarm::peer::{remember_peer, send_hello_once};
@@ -139,7 +140,7 @@ pub(crate) async fn bootstrap(state: &AppState, seeds: &[String], start: Start)
}
fn swarm_id_path() -> Vec<String> {
- vec![META_ROOT.to_owned(), SWARM.to_owned()]
+ meta::path(&[SWARM])
}
/// The swarm ID the document names, if any.
diff --git a/src/swarm/members.rs b/src/swarm/members.rs
index 9b0de18..cba46f9 100644
--- a/src/swarm/members.rs
+++ b/src/swarm/members.rs
@@ -20,13 +20,12 @@ use automerge::AutoCommit;
use serde_json::{Value as JsonValue, json};
use tracing::{debug, info, warn};
+use crate::document::meta;
use crate::document::store::{self, StoreError};
use crate::state::AppState;
-const META_ROOT: &str = "_continuum";
-
pub(crate) fn members_dir() -> Vec<String> {
- vec![META_ROOT.to_owned(), "members".to_owned()]
+ meta::path(&[meta::MEMBERS])
}
pub(crate) fn member_path(pk: &str) -> Vec<String> {
diff --git a/src/swarm/peer.rs b/src/swarm/peer.rs
index 1240966..651e067 100644
--- a/src/swarm/peer.rs
+++ b/src/swarm/peer.rs
@@ -11,6 +11,7 @@ use tokio::sync::Mutex;
use tokio::time::{Instant, MissedTickBehavior};
use tracing::{debug, info, instrument, trace, warn};
+use crate::document::meta;
use crate::document::replica::{Epoch, EpochRelation};
use crate::document::store::{self, StoreError};
use crate::state::{AppState, HelloState, Liveness, PeerState, Standing};
@@ -20,13 +21,12 @@ use crate::transport::{self, ParsedMessage};
const PEER_TICK: Duration = Duration::from_millis(500);
-const META_ROOT: &str = "_continuum";
const FORGET_CHECK_INTERVAL: Duration = Duration::from_secs(5);
// --- forget records ----------------------------------------------------------
fn forgotten_dir() -> Vec<String> {
- vec![META_ROOT.to_owned(), "forgotten".to_owned()]
+ meta::path(&[meta::FORGOTTEN])
}
pub(crate) fn forgotten_path(pk: &str) -> Vec<String> {