1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
|
//! Rules by path every node judges a change by, the same way, before it applies it
//! (`judge`): who may write which part of the document. A change is judged by what it
//! touches (`document::inspect`), by who made it (its committer, the node a signature
//! proves, `document::signatures`; and the user its message names, `User:`), and by the
//! document as its writer saw it: at its dependencies (`document::at`), so a later
//! revocation doesn't refuse what was allowed when it was written, and every node,
//! whatever it holds since, judges alike.
//!
//! * A change without dependencies is an epoch's root: the genesis change, the first a
//! document holds, or a remake's snapshot, whose hash is the epoch's ID. Nothing else
//! is judged of it: it writes the protocol data it starts with.
//! * Any other is its committer's, a member at its dependencies, or registering itself,
//! and not banned there (`access::bans`).
//! * Protocol data (`_continuum`, `document::meta`): a member's entry and its votes by
//! that member only (removing an entry also by a user who may write there); epochs
//! and the swarm ID in a root only; a remake's decision by the coordinator its
//! proposal names, the rest of a record by any member; a user's tokens revoked by that
//! user too (logging out); the rest by a user who may write there.
//! * User data: by a user who may write there.
//!
//! In a swarm without a user registry (`access::registry`), whatever a user would need,
//! any member may.
use std::collections::BTreeSet;
use crate::access::Right;
use crate::access::registry;
use crate::document::at::At;
use crate::document::inspect::Path;
use crate::document::meta;
use crate::document::replica::Epoch;
use crate::document::{ChangeHash, Document, store};
/// A change to judge, as its bytes say.
#[derive(Debug, Clone)]
pub(crate) struct Change<'a> {
pub(crate) hash: ChangeHash,
pub(crate) deps: &'a [ChangeHash],
/// The heads to judge it at: its dependencies, or, for those not applied yet, the
/// changes of the document they build on, where nothing between changed what rules
/// read (`access::screen`).
pub(crate) seen: &'a [ChangeHash],
/// The node that made it (its node ID), as its signature proves.
pub(crate) committer: Option<&'a str>,
/// The user it names (`User:`), if any.
pub(crate) user: Option<&'a str>,
/// What it touches (`inspect::Inspector`).
pub(crate) paths: &'a BTreeSet<Path>,
}
/// Why a change is refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Refusal {
/// Its dependencies aren't all in the document: it can't be judged yet.
Unready,
/// A rule refuses it: which, and what it touched.
Rule(String),
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Refusal::Unready => f.write_str("its dependencies aren't all here"),
Refusal::Rule(why) => f.write_str(why),
}
}
}
/// Judges `change`, about to be applied to `doc` (of `epoch`).
pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result<(), Refusal> {
if change.deps.is_empty() {
let root = if epoch.generation == 0 {
doc.get_heads().is_empty()
} else {
change.hash.0 == epoch.id
};
return if root {
Ok(())
} else {
Err(Refusal::Rule(
"a change without dependencies that isn't the epoch's root".to_owned(),
))
};
}
let at = At::new(doc, change.seen).ok_or(Refusal::Unready)?;
let refuse = |why: String| Err(Refusal::Rule(why));
let Some(node) = change.committer else {
return refuse("it names no node".to_owned());
};
if super::bans::banned(&at, node) {
return refuse(format!("{node} is banned"));
}
let own_entry = meta::path(&[meta::MEMBERS, node]);
let member = store::read_path(&at, &own_entry).ok().flatten().is_some();
let registering = change
.paths
.iter()
.all(|path| path.starts_with(&own_entry) || *path == meta::path(&[meta::MEMBERS]));
if !member && !registering {
return refuse(format!("{node} isn't a member"));
}
let registry = registry::exists(&at);
// Whether the user may write `path`: as the registry at its dependencies says, or,
// without one, any member.
let user_may = |path: &Path| match (registry, change.user) {
(false, _) => true,
(true, Some(user)) => registry::caps_of(&at, user).allows(Right::Write, path),
(true, None) => false,
};
for path in change.paths {
if !meta::is_protocol(path) {
if !user_may(path) {
return refuse(format!("{} may not write {}", who(change), shown(path)));
}
continue;
}
let part = path.get(1).map(String::as_str);
let owner = path.get(2).map(String::as_str);
let allowed = match (part, owner) {
// A member's entry, its votes: its own. Removing an entry, also a user's.
(Some(meta::MEMBERS), Some(owner)) => owner == node || user_may(path),
(Some(meta::VOTES), Some(owner)) => owner == node,
// What holds them, made by whichever member first writes in it.
(Some(meta::MEMBERS | meta::VOTES | meta::REMAKES), None) => true,
// Written by a root only.
(Some(meta::EPOCHS | meta::SWARM), _) => false,
// Logging out: a user revokes its own tokens (`POST /auth/logout`).
(Some(meta::USERS), Some(owner))
if path.get(3).map(String::as_str) == Some("tokensNotBefore")
&& change.user == Some(owner) =>
{
true
}
(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.
Some("decision") => match coordinator(&at, id) {
Some(coordinator) => coordinator == node,
None => {
change
.paths
.iter()
.any(|other| other == &meta::path(&[meta::REMAKES, id, "proposal"]))
|| change
.paths
.iter()
.any(|other| other == &meta::path(&[meta::REMAKES, id]))
}
},
_ => true,
},
_ => user_may(path),
};
if !allowed {
return refuse(format!("{} may not write {}", who(change), shown(path)));
}
}
Ok(())
}
/// Who wrote it, for a refusal.
fn who(change: &Change<'_>) -> String {
match (change.committer, change.user) {
(Some(node), Some(user)) => format!("{user} at {node}"),
(Some(node), None) => node.to_owned(),
(None, _) => "nobody".to_owned(),
}
}
fn shown(path: &Path) -> String {
format!("/{}", path.join("/"))
}
/// 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"]))
.ok()
.flatten()?;
proposal
.get("coordinator")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::UnixMillis;
use crate::document::replica::{Writer, transact};
use serde_json::json;
fn path(text: &str) -> Path {
text.split('/').map(str::to_owned).collect()
}
fn paths(texts: &[&str]) -> BTreeSet<Path> {
texts.iter().map(|text| path(text)).collect()
}
fn write(doc: &mut Document, at: &str, value: serde_json::Value) {
let writer = Writer {
node: "genesis",
user: None,
};
let at = path(at);
transact(doc, UnixMillis(1), &writer, |doc| {
store::write_path(doc, &at, &value)
})
.unwrap();
}
/// A swarm with members `a` and `b`, a remake proposed by `a`, and, if `users`,
/// user `alice` who may write `notes` and `bob` who may not write at all.
fn swarm(users: bool) -> Document {
let mut doc = Document::new();
write(&mut doc, "_continuum/swarm", json!("s"));
write(&mut doc, "_continuum/members/a", json!({"since": 1}));
write(&mut doc, "_continuum/members/b", json!({"since": 1}));
write(
&mut doc,
"_continuum/remakes/r1/proposal",
json!({"coordinator": "a"}),
);
if users {
let alice = registry::entry("a", 1, &[(path("notes"), &[Right::Write][..])]);
write(&mut doc, "_continuum/users/alice", alice);
write(
&mut doc,
"_continuum/users/bob",
registry::entry("a", 1, &[]),
);
}
doc
}
fn judged(
doc: &Document,
committer: &str,
user: Option<&str>,
touched: &[&str],
) -> Result<(), Refusal> {
let deps = doc.get_heads();
let touched = paths(touched);
judge(
doc,
Epoch::GENESIS,
&Change {
hash: ChangeHash([9; 32]),
deps: &deps,
seen: &deps,
committer: Some(committer),
user,
paths: &touched,
},
)
}
fn refused(result: Result<(), Refusal>) -> bool {
matches!(result, Err(Refusal::Rule(_)))
}
/// A member's entry and votes are its own; epochs and the swarm ID a root's.
#[test]
fn protocol_data() {
let doc = swarm(true);
assert!(judged(&doc, "a", None, &["_continuum/members/a/alive"]).is_ok());
assert!(refused(judged(
&doc,
"a",
None,
&["_continuum/members/b/alive"]
)));
assert!(judged(&doc, "b", None, &["_continuum/votes/b"]).is_ok());
assert!(refused(judged(&doc, "a", None, &["_continuum/votes/b"])));
assert!(refused(judged(&doc, "a", None, &["_continuum/swarm"])));
assert!(refused(judged(&doc, "a", None, &["_continuum/epochs/1"])));
// A remake's decision: its coordinator's; the rest of the record, any member's.
assert!(judged(&doc, "a", None, &["_continuum/remakes/r1/decision"]).is_ok());
assert!(refused(judged(
&doc,
"b",
None,
&["_continuum/remakes/r1/decision"]
)));
assert!(judged(&doc, "b", None, &["_continuum/remakes/r1/abortRequested"]).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()
);
assert!(refused(judged(
&doc,
"b",
None,
&["_continuum/remakes/r2/decision"]
)));
// The rest: a user's who may write there.
assert!(refused(judged(&doc, "a", None, &["_continuum/neighbours"])));
assert!(refused(judged(
&doc,
"a",
Some("alice"),
&["_continuum/neighbours"]
)));
// Logging out: its own tokens only.
let tokens = "_continuum/users/bob/tokensNotBefore";
assert!(judged(&doc, "a", Some("bob"), &[tokens]).is_ok());
assert!(refused(judged(&doc, "a", Some("alice"), &[tokens])));
}
/// A banned node's changes: refused, as the ban stood when they were made.
#[test]
fn banned_nodes() {
let mut doc = swarm(true);
let before = doc.get_heads();
write(&mut doc, "_continuum/banned/b", json!({"by": "alice"}));
assert!(refused(judged(
&doc,
"b",
None,
&["_continuum/members/b/alive"]
)));
let touched = paths(&["_continuum/members/b/alive"]);
let earlier = judge(
&doc,
Epoch::GENESIS,
&Change {
hash: ChangeHash([9; 32]),
deps: &before,
seen: &before,
committer: Some("b"),
user: None,
paths: &touched,
},
);
assert!(earlier.is_ok());
}
/// A node that isn't a member may only register itself.
#[test]
fn strangers_register_themselves_only() {
let doc = swarm(false);
assert!(
judged(
&doc,
"c",
None,
&[
"_continuum/members",
"_continuum/members/c",
"_continuum/members/c/alive"
]
)
.is_ok()
);
assert!(refused(judged(&doc, "c", None, &["notes/x"])));
assert!(refused(judged(&doc, "c", None, &["_continuum/members/a"])));
}
/// User data: as the registry at the change's dependencies says; without one, any
/// member's.
#[test]
fn user_data() {
let doc = swarm(true);
assert!(judged(&doc, "a", Some("alice"), &["notes/x"]).is_ok());
assert!(refused(judged(&doc, "a", Some("alice"), &["config"])));
assert!(refused(judged(&doc, "a", Some("bob"), &["notes/x"])));
assert!(refused(judged(&doc, "a", None, &["notes/x"])));
assert!(refused(judged(&doc, "a", Some("nobody"), &["notes/x"])));
let legacy = swarm(false);
assert!(judged(&legacy, "a", None, &["notes/x"]).is_ok());
assert!(judged(&legacy, "a", None, &["_continuum/neighbours"]).is_ok());
}
/// Judged by the registry its writer saw: a revocation since refuses nothing it
/// allowed then.
#[test]
fn judged_as_its_writer_saw_it() {
let mut doc = swarm(true);
let then = doc.get_heads();
write(
&mut doc,
"_continuum/users/alice",
registry::entry("a", 2, &[]),
);
let touched = paths(&["notes/x"]);
let change = |deps: &[ChangeHash]| {
judge(
&doc,
Epoch::GENESIS,
&Change {
hash: ChangeHash([9; 32]),
deps,
seen: deps,
committer: Some("a"),
user: Some("alice"),
paths: &touched,
},
)
};
assert!(change(&then).is_ok());
assert!(refused(change(&doc.get_heads())));
// Unknown dependencies: not judged yet.
assert_eq!(change(&[ChangeHash([7; 32])]), Err(Refusal::Unready));
}
/// Roots: the first change of a swarm's first epoch, a later epoch's snapshot.
#[test]
fn roots() {
let root = |doc: &Document, epoch: Epoch, hash: [u8; 32]| {
let touched = paths(&["_continuum/swarm"]);
judge(
doc,
epoch,
&Change {
hash: ChangeHash(hash),
deps: &[],
seen: &[],
committer: None,
user: None,
paths: &touched,
},
)
};
assert!(root(&Document::new(), Epoch::GENESIS, [1; 32]).is_ok());
assert!(refused(root(&swarm(false), Epoch::GENESIS, [1; 32])));
let epoch = Epoch {
generation: 3,
id: [5; 32],
};
assert!(root(&swarm(false), epoch, [5; 32]).is_ok());
assert!(refused(root(&swarm(false), epoch, [6; 32])));
}
}
|