diff --git a/AGENTS.md b/AGENTS.md index 99cad280..e8065bba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,10 +21,19 @@ review bots read and write at the same time, written in Rust. the HTTP routes (`http.rs`) and the command line's commands that the API offers only translate into its calls, so neither duplicates the other. `sync.rs` shares records with a remote (`remote.rs`), which answers the - same calls over the SDK or, for a local path, in-process. + same calls over the SDK or, for a local path, in-process. Every call + takes a `Caller`, authenticated by `auth.rs`; `permissions.rs` filters + what it reads and checks what it writes, by the rights `policy.rs` + resolves from `policy.toml`. - `tools/xtask`: development tasks, run with `cargo xtask`, such as timing origin against git (`just bench-git`). +## API changes + +Every server feature lives in `OriginService`; transports like HTTP only +translate requests. A new HTTP endpoint also takes a line in the SDK's +`endpoints!` macro, and `just openapi` to update `docs/openapi.json`. + ## Testing & Benchmarks Performance is a primary goal, so benchmarks matter as much as tests. A change @@ -32,9 +41,10 @@ to a hot path (hashing, storage, snapshots, transfer) should come with a benchmark, and its numbers in the pull request. - `just bench`: the divan micro-benchmarks (encoding, store writes, - snapshots, input digests and promotion, landing, reading events, the - server's request latency and long-poll wakeups, syncing a 20,000-file - tree over HTTP, and a fetch that brings nothing from a long history). + snapshots, input digests and promotion, landing, permission checks, + reading events, the server's request latency and long-poll wakeups, + syncing a 20,000-file tree over HTTP, and a fetch that brings nothing + from a long history). - `just bench-git`: times origin against git on a 20,000-file tree with hyperfine, and rewrites `docs/benchmarks.md`. diff --git a/Cargo.lock b/Cargo.lock index bef447f8..7319b4fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -860,6 +860,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "blake3", "clap", "divan", "getrandom", diff --git a/README.md b/README.md index d844cfd5..5c7f04b8 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ origin remote [] # show or set the remote: http://host:port/< origin push [...] # share changes and the ones below them; lands nothing origin fetch [--target ] [--change ] # new acceptances, and the records of known changes origin pull # fetch, then move the working copy onto its target's tip +origin whoami | permissions # who the remote thinks you are, and what you may do there +origin token issue --scope "write src" [--ttl 1h] # a short-lived token for an agent ``` Landing rebases a change onto the target's tip, file by file, checks the @@ -110,9 +112,64 @@ Changes are named by a unique prefix of their id, or `@` for the working copy's. A revision is `@`, a change (for its head), or a unique prefix of the revision's id. Your name and node key live in `$ORIGIN_CONFIG_DIR`, or else `origin` in `$XDG_CONFIG_HOME` or `~/.config`: set `user = ""` -in its `config.toml` if `$USER` isn't the name you want. See +in its `config.toml` if `$USER` isn't the name you want, and the token to +send each server under `[tokens]`, by its URL +(`"http://127.0.0.1:7878" = ""`), or in `$ORIGIN_TOKEN`. See [the design](docs/design.md) for the model. +## Permissions + +What each user may do lives in `policy.toml`, so changing it is a landing +on `policy`, which only `admin` may make: + +```toml +[grants] # capabilities: annotate, land, admin +"group:maintainers" = ["admin", "land *", "annotate review"] +"group:runners" = ["annotate ci.*"] + +[[paths]] # path access: none, read or write +path = "secrets" +grants = { all = "none", "group:maintainers" = "write" } + +[[paths]] # CI's checks: only maintainers redefine them +path = "checks.toml" +grants = { all = "read", "group:maintainers" = "write" } +``` + +Under a path, the most specific rule that mentions you decides, and paths +no rule mentions you for are writable. Without `[grants]`, everyone lands +on every target but `policy` and issues every kind of statement. Content +you may not read never reaches your clone: those files are missing from +the working copy and stay as they were in your revisions, and fetching +restores them once you may read them. Revisions that only change what you +can't read, and the statements about them, are hidden from you, and you +may not issue statements about them, nor promote their results. Reading +nothing hides the repository whole. You may only push revisions that +change paths you write, and land what changes only paths you write. You +may take over someone else's change, and hand it on: where you can't +write, your revision must do exactly what theirs did, the same files +changed from the same contents to the same ones, with exactly the same +conflicts unresolved, on the same change below, and a new change lists +there only those of the revision it's on. Only a new change gets a root +revision. Anyone who reads a change, even with a token that only reads, +may still copy its head as it is: the change then diverges until someone +converges it again. The statements you push need their kind's `annotate`, promotions +included, but for the reserved `core.*` kinds, which come only from whom +core names: `core.abandon` from the change's creator, who made its root +revision, or an `admin`, so you can't abandon a change you took over; +`core.conflict` from a revision's author, or a node. Each user of a clone, +and each scope of their tokens, keeps their own rights on the remote, as +of their last fetch, so workspaces of one clone can act for different +users, or agents. + +`origin token issue --scope ... --ttl ` asks the remote +for a token acting for you with a narrower scope, such as `write src`, +`read /`, `annotate ci.*`, `land main` or `admin`; the server verifies it +without storing it. A server's operator signs one for any principal with +`origin token issue --config origin-server.toml --as user:agent-1 --scope +...`. A token never grants more than its principal's rights, lasts at most +7 days, and a token issued with a token expires with it. + `converge` merges the files of a change's heads. A text file two heads changed holds both versions between conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`), and a binary one keeps one side. A `core.conflict` statement @@ -142,14 +199,19 @@ key = "node.key" # its secret key, created if missing [repos] demo = "repos/demo" + +[tokens] # static tokens, by principal +"user:alice" = "a long random secret" ``` The HTTP API, under `/api/v1`, puts records in batches, if the changes they revise are where the client saw them, and fetches them by id, walks snapshots for the trees and blobs a client lacks (the only way files leave a server), in parts of at most 64 MiB each way, compares the records of -changes, lists statements, revisions, targets and their logs, lands -changes and targets, and streams +changes, lists statements, +revisions, targets and their logs, lists the directories and reads the +files of a target's tip or any revision, without its history, issues +scoped tokens, lands changes and targets, and streams events by long-poll: `GET /api/v1/repos//events?cursor=&wait=30s` answers as soon as there are events after the cursor, with a JSON batch, the next cursor, and the @@ -157,8 +219,9 @@ generation of the op log it's of: pass it back as `generation`, and a cursor of another log, such as from before the repository was restored, is refused as expired. [`docs/openapi.json`](docs/openapi.json) describes it, and -[`origin-client`](crates/origin-client) is its Rust SDK. Nothing -authenticates callers yet. +[`origin-client`](crates/origin-client) is its Rust SDK. Callers send +`Authorization: Bearer `; without one, they are anonymous, and only +read what everyone may. ## Development diff --git a/apps/origin-src/Cargo.toml b/apps/origin-src/Cargo.toml index c4bc359a..273e930a 100644 --- a/apps/origin-src/Cargo.toml +++ b/apps/origin-src/Cargo.toml @@ -18,12 +18,14 @@ fixture = [] [dependencies] anyhow.workspace = true axum.workspace = true +blake3.workspace = true clap.workspace = true getrandom.workspace = true origin-client.workspace = true origin-platform.workspace = true origin-shared.workspace = true serde.workspace = true +serde_json.workspace = true similar.workspace = true thiserror.workspace = true tokio.workspace = true @@ -38,7 +40,6 @@ zstd.workspace = true divan.workspace = true # The benchmarks build their working trees with the fixtures. origin-src = { workspace = true, features = ["fixture"] } -serde_json.workspace = true tempfile.workspace = true [[bench]] @@ -49,6 +50,10 @@ harness = false name = "land" harness = false +[[bench]] +name = "permissions" +harness = false + [[bench]] name = "server" harness = false diff --git a/apps/origin-src/benches/annotations.rs b/apps/origin-src/benches/annotations.rs index a15505d8..3329e234 100644 --- a/apps/origin-src/benches/annotations.rs +++ b/apps/origin-src/benches/annotations.rs @@ -182,7 +182,7 @@ fn promotions_cached(bencher: Bencher<'_, '_>) { let mut annotations = Annotations::open(&store).unwrap(); assert_eq!( annotations - .promotions(RecordId::Revision(target), &issuer) + .promotions(RecordId::Revision(target), &issuer, |_| Ok(true)) .unwrap() .len(), 9 @@ -193,7 +193,7 @@ fn promotions_cached(bencher: Bencher<'_, '_>) { .with_inputs(|| Annotations::new(&store, index.clone())) .bench_local_refs(|annotations| { annotations - .promotions(RecordId::Revision(target), &issuer) + .promotions(RecordId::Revision(target), &issuer, |_| Ok(true)) .unwrap() }); } @@ -214,7 +214,7 @@ fn promotions_uncached(bencher: Bencher<'_, '_>) { }) .bench_local_refs(|annotations| { annotations - .promotions(RecordId::Revision(target), &issuer) + .promotions(RecordId::Revision(target), &issuer, |_| Ok(true)) .unwrap() }); } diff --git a/apps/origin-src/benches/land.rs b/apps/origin-src/benches/land.rs index 33af14c9..1b1c51e9 100644 --- a/apps/origin-src/benches/land.rs +++ b/apps/origin-src/benches/land.rs @@ -93,7 +93,7 @@ impl Repo { author: "user:bench".parse().unwrap(), } .encode(); - let description = EncodedObject::blob(b""); + let description = EncodedObject::attachment(b""); self.store.write_all(&[content, tree, description]).unwrap(); self.store.write_all(&[revision]).unwrap(); change diff --git a/apps/origin-src/benches/permissions.rs b/apps/origin-src/benches/permissions.rs new file mode 100644 index 00000000..9395d23b --- /dev/null +++ b/apps/origin-src/benches/permissions.rs @@ -0,0 +1,216 @@ +//! The cost of permission checks on a 20k-file tree: walking `main`'s tip +//! for a clone, and landing a change editing one file, each as the owner, +//! who isn't checked, as a user under a policy without path rules, and as +//! one whom path rules withhold a directory from and keep from writing +//! another. Landings sync, so run on a real disk. + +use std::sync::{Arc, LazyLock}; + +use divan::Bencher; +use origin_shared::api::{FetchSnapshot, LandRequest}; +use origin_shared::landing::{Landing, Proposal}; +use origin_shared::pack::Pack; +use origin_shared::{ + AcceptanceId, Base, ChangeId, EncodedObject, EntryKind, EntryName, NodeKey, Record, RecordId, + Revision, TargetName, Tree, TreeEntry, TreeId, +}; +use origin_src::fixture::FixtureTree; +use origin_src::policy::Policy; +use origin_src::service::{Caller, Node, OriginService}; +use origin_src::worktree::{StatCache, Worktree}; + +fn main() { + divan::main(); +} + +const TREE: FixtureTree = FixtureTree::LARGE; + +/// Rules sparing the benchmark's edits, in `dir0`, but withholding `dir3` +/// and keeping `dir5/sub1` read-only, from everyone but `user:ada`. +const RULES: &str = r#" +[[paths]] +path = "dir3" +grants = { all = "none", "user:ada" = "write" } + +[[paths]] +path = "dir5/sub1" +grants = { all = "read", "user:ada" = "write" } +"#; + +/// Who calls. +#[derive(Clone, Copy, Debug)] +enum Who { + /// The node's own user. + Owner, + /// A user, under a policy without path rules. + NoRules, + /// A user, under [`RULES`]. + Rules, +} + +/// A repository whose `main` holds the fixture, under a policy without path +/// rules, and one under [`RULES`]. +struct Setup { + _dirs: [tempfile::TempDir; 2], + open: OriginService, + ruled: OriginService, +} + +static SETUP: LazyLock = LazyLock::new(|| { + let files = tempfile::tempdir().unwrap(); + TREE.write(files.path()).unwrap(); + let repos = tempfile::tempdir().unwrap(); + let node = Node { + name: "hosted".parse().unwrap(), + key: Arc::new(NodeKey::from_bytes(&[7; 32])), + }; + let initial = Policy::initial(&node.name, &node.key.public()); + let create = |name: &str, policy: &str| { + OriginService::create(repos.path().join(name), node.clone(), policy).unwrap() + }; + let open = create("open", &initial); + let ruled = create("ruled", &format!("{initial}{RULES}")); + for service in [&open, &ruled] { + let (tree, _) = Worktree::new(files.path(), service.store()) + .snapshot(&StatCache::default()) + .unwrap(); + let change = put(service, tree); + land(service, &Caller::Owner, change); + } + Setup { + _dirs: [files, repos], + open, + ruled, + } +}); + +impl Setup { + fn service(&self, who: Who) -> &OriginService { + match who { + Who::Owner | Who::NoRules => &self.open, + Who::Rules => &self.ruled, + } + } +} + +impl Who { + fn caller(self) -> Caller { + match self { + Self::Owner => Caller::Owner, + Self::NoRules | Self::Rules => Caller::Principal { + principal: "user:bench".parse().unwrap(), + scope: None, + }, + } + } +} + +/// The tip of `main`. +fn tip(service: &OriginService) -> AcceptanceId { + service + .store() + .head(&TargetName::MAIN) + .unwrap() + .unwrap() + .acceptance +} + +/// Puts a revision of a new change with `snapshot` on `main`'s tip. +fn put(service: &OriginService, snapshot: TreeId) -> ChangeId { + let description = EncodedObject::attachment(b"bench"); + let revision = Revision { + change: ChangeId::random().unwrap(), + predecessors: Vec::new(), + base: Base::Acceptance(tip(service)), + deps: Vec::new(), + snapshot, + description: description.id().into(), + author: "user:bench".parse().unwrap(), + }; + let pack = Pack { + objects: vec![description, revision.encode()], + }; + service.put_records(&Caller::Owner, pack).unwrap(); + revision.change +} + +fn land(service: &OriginService, caller: &Caller, change: ChangeId) { + let request = LandRequest { + proposal: Proposal::Change(change), + onto: None, + }; + let landed = service.land(caller, &request).unwrap(); + assert!(matches!(landed.landings[..], [Landing::Landed { .. }])); +} + +/// The whole snapshot of `main`'s tip, as a clone fetches it. +#[divan::bench(args = [Who::Owner, Who::NoRules, Who::Rules], sample_count = 10)] +fn walk(bencher: Bencher<'_, '_>, who: Who) { + let setup = &*SETUP; + let service = setup.service(who); + let request = FetchSnapshot { + of: RecordId::Acceptance(tip(service)), + have: Vec::new(), + paths: Vec::new(), + skip: 0, + }; + let caller = who.caller(); + bencher.bench(|| service.snapshot(&caller, &request).unwrap()); +} + +/// Landing a change that edits one file, made on the tip. +#[divan::bench(args = [Who::Owner, Who::NoRules, Who::Rules], sample_count = 20)] +fn land_one_edit(bencher: Bencher<'_, '_>, who: Who) { + let setup = &*SETUP; + let service = setup.service(who); + let store = service.store(); + let mut version = 0_u64; + bencher + .with_inputs(|| { + version += 1; + let state = store.get(tip(service)).unwrap().body().state; + let content = EncodedObject::blob(format!("version {version}\n").as_bytes()); + let file = TreeEntry { + kind: EntryKind::File, + id: content.id(), + }; + store.write_all(&[content]).unwrap(); + let snapshot = replace(service, state, &["dir0", "sub0", "file0.rs"], file); + put(service, snapshot) + }) + .bench_local_values(|change| land(service, &who.caller(), change)); +} + +/// `tree`, with `entry` at the path of `components` instead, writing the +/// trees on the way. +fn replace(service: &OriginService, tree: TreeId, components: &[&str], entry: TreeEntry) -> TreeId { + let store = service.store(); + let mut tree: Tree = store.get(tree).unwrap(); + let (name, rest) = components.split_first().unwrap(); + let entry = if rest.is_empty() { + entry + } else { + let subtree = tree.get(name).unwrap().subtree().unwrap(); + TreeEntry { + kind: EntryKind::Tree, + id: replace(service, subtree, rest, entry).into(), + } + }; + tree.insert(EntryName::new(*name).unwrap(), entry); + let encoded = tree.encode(); + store.write_all(std::slice::from_ref(&encoded)).unwrap(); + encoded.id().into() +} + +/// The walk alone, which the read check filters, without reading the +/// objects it lists, on the repository with rules: unchecked, or checked +/// against the user's rights. +#[divan::bench(args = [false, true], sample_count = 20)] +fn walk_ids(bencher: Bencher<'_, '_>, checked: bool) { + let service = &SETUP.ruled; + let store = service.store(); + let state = store.get(tip(service)).unwrap().body().state; + let rights = service.rights(&Who::Rules.caller()).unwrap(); + let access = checked.then_some(&rights.paths); + bencher.bench(|| store.added(state, &[], &[], access).unwrap().len()); +} diff --git a/apps/origin-src/benches/server.rs b/apps/origin-src/benches/server.rs index 80568719..0105e244 100644 --- a/apps/origin-src/benches/server.rs +++ b/apps/origin-src/benches/server.rs @@ -15,9 +15,10 @@ use origin_shared::api::{EventsQuery, FetchRecords, LandRequest, List}; use origin_shared::landing::{Landing, Proposal}; use origin_shared::pack::Pack; use origin_shared::{ - Base, BlobId, ChangeId, EncodedObject, EntryKind, EntryName, Kind, NodeKey, Period, Record, - RecordId, Revision, RevisionId, Statement, TargetName, Tree, TreeEntry, Verdict, + AttachmentId, Base, ChangeId, EncodedObject, EntryKind, EntryName, Kind, NodeKey, Period, + Record, RecordId, Revision, RevisionId, Statement, TargetName, Tree, TreeEntry, Verdict, }; +use origin_src::auth::{Credentials, StaticTokens}; use origin_src::http::Api; use origin_src::policy::Policy; use origin_src::server::Repositories; @@ -72,7 +73,7 @@ impl Hosted { }, ); let tree = tree.encode(); - let description = EncodedObject::blob(b""); + let description = EncodedObject::attachment(b""); let mut change = [0; 16]; change[..8].copy_from_slice(&n.to_le_bytes()); let revision = Revision { @@ -81,7 +82,7 @@ impl Hosted { base: Base::Acceptance(tip), deps: Vec::new(), snapshot: tree.id().into(), - description: BlobId::from(description.id()), + description: AttachmentId::from(description.id()), author: "user:bench".parse().unwrap(), } .encode(); @@ -97,7 +98,9 @@ impl Hosted { /// server and the SDK's handle on the repository. fn serve(&self) -> (Runtime, Repo) { let runtime = Runtime::new().unwrap(); - let mut repositories = Repositories::default(); + let tokens: StaticTokens = toml::from_str("\"node:ci\" = \"bench\"").unwrap(); + let credentials = Credentials::new(self.node.clone(), Box::new(tokens)); + let mut repositories = Repositories::new(credentials); repositories.insert("demo".parse().unwrap(), self.service()); let url = runtime.block_on(async { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -105,7 +108,9 @@ impl Hosted { tokio::spawn(async move { axum::serve(listener, Api::router(repositories)).await }); format!("http://{address}") }); - let repo = origin_client::Client::new(&url).repo("demo".parse().unwrap()); + let repo = origin_client::Client::new(&url) + .with_token("bench") + .repo("demo".parse().unwrap()); (runtime, repo) } } diff --git a/apps/origin-src/benches/sync.rs b/apps/origin-src/benches/sync.rs index f3a33fd8..b2efd1e2 100644 --- a/apps/origin-src/benches/sync.rs +++ b/apps/origin-src/benches/sync.rs @@ -15,9 +15,10 @@ use origin_shared::api::LandRequest; use origin_shared::landing::{Landing, Proposal}; use origin_shared::pack::Pack; use origin_shared::{ - Base, ChangeId, EncodedObject, Kind, NodeKey, Record, RecordId, Revision, Statement, + Base, ChangeId, EncodedObject, Kind, NodeKey, Principal, Record, RecordId, Revision, Statement, TargetName, TreeId, Verdict, }; +use origin_src::auth::{Credentials, StaticTokens}; use origin_src::fixture::FixtureTree; use origin_src::http::Api; use origin_src::policy::Policy; @@ -52,8 +53,8 @@ struct Setup { /// The tip of `main`, the whole fixture, and its snapshot. tip: Base, snapshot: TreeId, - /// Edits only the server has. - theirs: Vec, + /// Revisions of the server's, each with its own edits. + theirs: Vec, client: Store, /// Edits only the client has. ours: Vec, @@ -79,18 +80,31 @@ static SETUP: LazyLock = LazyLock::new(|| { }; let snapshot = snapshot_into(service.store(), None); let tip = land(&service, snapshot); + let mut records = Vec::new(); let theirs = (1..=u64::from(SAMPLES)) - .map(|version| snapshot_into(service.store(), Some(version))) + .map(|version| { + let snapshot = snapshot_into(service.store(), Some(version)); + let (description, revision) = revision(tip, snapshot); + let id = RecordId::Revision(revision.id().into()); + records.extend([description.clone(), revision.clone()]); + let pack = Pack { + objects: vec![description, revision], + }; + service.put_records(&Caller::Owner, pack).unwrap(); + id + }) .collect(); - let (runtime, url) = serve(service); + let (runtime, url) = serve(service, &node); let store = Store::new(client.path().join(".origin"), Native::default()); store.init().unwrap(); - let remote = Remote::open(&url, node.clone()).unwrap(); - let sync = Sync::new(&store, &remote, "laptop".parse().unwrap()); + let remote = Remote::open(&url, node.clone(), Some("bench".to_owned())).unwrap(); + let sync = Sync::new(&store, &remote, "laptop".parse().unwrap(), bench_user()); sync.fetch_chains(&[TargetName::MAIN]).unwrap(); - sync.need(snapshot, &[]).unwrap(); + sync.need(tip.into(), &[]).unwrap(); + // The client knows the records, as a fetch would have brought them. + store.write_all(&records).unwrap(); // Versions the server's edits never reach. let ours = (1..=u64::from(SAMPLES)) .map(|version| snapshot_into(&store, Some(1_000 + version))) @@ -108,11 +122,12 @@ static SETUP: LazyLock = LazyLock::new(|| { } }); -/// Serves `service` over HTTP on localhost as `demo`, on a runtime of its -/// own. -fn serve(service: OriginService) -> (Runtime, RemoteUrl) { +/// Serves `service` over HTTP on localhost as `demo`, where `user:bench`'s +/// token is `bench`, on a runtime of its own. +fn serve(service: OriginService, node: &Node) -> (Runtime, RemoteUrl) { let runtime = Runtime::new().unwrap(); - let mut repositories = Repositories::default(); + let tokens: StaticTokens = toml::from_str("\"user:bench\" = \"bench\"").unwrap(); + let mut repositories = Repositories::new(Credentials::new(node.clone(), Box::new(tokens))); repositories.insert("demo".parse().unwrap(), service); let base = runtime.block_on(async { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -151,7 +166,7 @@ fn land(service: &OriginService, snapshot: TreeId) -> Base { /// A revision of a new change with `snapshot`, on `base`, and its /// description. fn revision(base: Base, snapshot: TreeId) -> (EncodedObject, EncodedObject) { - let description = EncodedObject::blob(b"bench"); + let description = EncodedObject::attachment(b"bench"); let revision = Revision { change: ChangeId::random().unwrap(), predecessors: Vec::new(), @@ -167,10 +182,15 @@ fn revision(base: Base, snapshot: TreeId) -> (EncodedObject, EncodedObject) { impl Setup { fn remote(&self) -> Remote { - Remote::open(&self.url, self.node.clone()).unwrap() + Remote::open(&self.url, self.node.clone(), Some("bench".to_owned())).unwrap() } } +/// The user the benchmarks push as. +fn bench_user() -> Principal { + "user:bench".parse().unwrap() +} + /// The snapshot a clone fetches: every file, into an empty repository. #[divan::bench(sample_count = 10)] fn clone_snapshot(bencher: Bencher<'_, '_>) { @@ -181,11 +201,18 @@ fn clone_snapshot(bencher: Bencher<'_, '_>) { let dir = tempfile::tempdir().unwrap(); let store = Store::new(dir.path().join(".origin"), Native::default()); store.init().unwrap(); + // A clone fetches the chain first. + let Base::Acceptance(tip) = setup.tip else { + unreachable!("the tip is an acceptance") + }; + store + .write_all(&[setup.client.read(tip.into()).unwrap()]) + .unwrap(); (dir, store) }) .bench_local_refs(|(_, store)| { - let sync = Sync::new(store, &remote, "laptop".parse().unwrap()); - assert!(sync.need(setup.snapshot, &[]).unwrap()); + let sync = Sync::new(store, &remote, "laptop".parse().unwrap(), bench_user()); + assert!(sync.need(setup.tip.into(), &[]).unwrap()); }); } @@ -195,12 +222,17 @@ fn clone_snapshot(bencher: Bencher<'_, '_>) { fn fetch_100_edited(bencher: Bencher<'_, '_>) { let setup = &*SETUP; let remote = setup.remote(); - let sync = Sync::new(&setup.client, &remote, "laptop".parse().unwrap()); + let sync = Sync::new( + &setup.client, + &remote, + "laptop".parse().unwrap(), + bench_user(), + ); let next = AtomicUsize::new(0); bencher .with_inputs(|| setup.theirs[next.fetch_add(1, Ordering::Relaxed) % SAMPLES as usize]) - .bench_local_values(|snapshot| { - sync.need(snapshot, &[setup.snapshot]).unwrap(); + .bench_local_values(|revision| { + sync.need(revision, &[setup.snapshot]).unwrap(); }); } @@ -210,7 +242,12 @@ fn fetch_100_edited(bencher: Bencher<'_, '_>) { fn push_100_edited(bencher: Bencher<'_, '_>) { let setup = &*SETUP; let remote = setup.remote(); - let sync = Sync::new(&setup.client, &remote, "laptop".parse().unwrap()); + let sync = Sync::new( + &setup.client, + &remote, + "laptop".parse().unwrap(), + bench_user(), + ); let next = AtomicUsize::new(0); bencher .with_inputs(|| { @@ -250,7 +287,7 @@ static HISTORY_SETUP: LazyLock = LazyLock::new(|| { let service = OriginService::create(server.path().join("demo"), node.clone(), &policy).unwrap(); let tip = service.store().head(&TargetName::MAIN).unwrap().unwrap(); let state = service.store().get(tip.acceptance).unwrap().body().state; - let description = EncodedObject::blob(b"bench"); + let description = EncodedObject::attachment(b"bench"); let mut objects = vec![description.clone()]; let mut changes = Vec::new(); for change in 0..HISTORY { @@ -264,14 +301,14 @@ static HISTORY_SETUP: LazyLock = LazyLock::new(|| { deps: Vec::new(), snapshot: state, description: description.id().into(), - author: "user:bench".parse().unwrap(), + author: bench_user(), } .encode(); let statement = Statement { subject: RecordId::Revision(revision.id().into()), kind: Kind::new("ci.test").unwrap(), verdict: Some(Verdict::Pass), - issuer: "user:bench".parse().unwrap(), + issuer: bench_user(), inputs: None, payload: None, supersedes: None, @@ -284,12 +321,12 @@ static HISTORY_SETUP: LazyLock = LazyLock::new(|| { service .put_records(&Caller::Owner, Pack { objects }) .unwrap(); - let (runtime, url) = serve(service); + let (runtime, url) = serve(service, &node); let store = Store::new(client.path().join(".origin"), Native::default()); store.init().unwrap(); - let remote = Remote::open(&url, node).unwrap(); - let sync = Sync::new(&store, &remote, "laptop".parse().unwrap()); + let remote = Remote::open(&url, node, Some("bench".to_owned())).unwrap(); + let sync = Sync::new(&store, &remote, "laptop".parse().unwrap(), bench_user()); let fetch = sync.fetch(&[TargetName::MAIN], &changes, None).unwrap(); assert_eq!(fetch.fetched.revisions, HISTORY as usize); let cursor = fetch.cursor; @@ -308,7 +345,12 @@ static HISTORY_SETUP: LazyLock = LazyLock::new(|| { #[divan::bench(sample_count = 20)] fn fetch_unchanged(bencher: Bencher<'_, '_>) { let history = &*HISTORY_SETUP; - let sync = Sync::new(&history.client, &history.remote, "laptop".parse().unwrap()); + let sync = Sync::new( + &history.client, + &history.remote, + "laptop".parse().unwrap(), + bench_user(), + ); bencher.bench_local(|| { let fetch = sync.fetch(&[], &[], Some(&history.cursor)).unwrap(); assert_eq!(fetch.fetched, Fetched::default()); @@ -320,7 +362,12 @@ fn fetch_unchanged(bencher: Bencher<'_, '_>) { #[divan::bench(sample_count = 10)] fn fetch_unchanged_without_cursor(bencher: Bencher<'_, '_>) { let history = &*HISTORY_SETUP; - let sync = Sync::new(&history.client, &history.remote, "laptop".parse().unwrap()); + let sync = Sync::new( + &history.client, + &history.remote, + "laptop".parse().unwrap(), + bench_user(), + ); bencher.bench_local(|| { let fetch = sync.fetch(&[], &[], None).unwrap(); assert_eq!(fetch.fetched, Fetched::default()); diff --git a/apps/origin-src/src/annotations/mod.rs b/apps/origin-src/src/annotations/mod.rs index dc98e31a..cec226ec 100644 --- a/apps/origin-src/src/annotations/mod.rs +++ b/apps/origin-src/src/annotations/mod.rs @@ -3,7 +3,7 @@ //! //! Core interprets only the reserved kinds: [`Kind::ABANDON`], //! [`Kind::CONFLICT`] and [`Kind::DIVERGENT`], and only from the issuers -//! [`Store::authorizes`] names. Every other kind belongs to whoever issues +//! [`RevisionView::authorizes`] names. Every other kind belongs to whoever issues //! it. Supersession counts only within one issuer: a statement claiming to //! supersede another issuer's is issued like any other, and the one it //! names still stands. @@ -16,7 +16,8 @@ use std::collections::HashSet; use origin_platform::{Native, Platform}; use origin_shared::api::Annotation; use origin_shared::{ - ChangeId, EncodedObject, Kind, Principal, Record, RecordId, RevisionId, Statement, StatementId, + ChangeId, EncodedObject, Kind, Principal, Record, RecordId, Revision, RevisionId, Statement, + StatementId, }; use crate::index::Index; @@ -223,7 +224,7 @@ impl<'a, P: Platform> Annotations<'a, P> { .iter() .map(|head| head.to_hex() + "\n") .collect::(); - let payload = EncodedObject::blob(list.as_bytes()); + let payload = EncodedObject::attachment(list.as_bytes()); let statement = Statement { subject: RecordId::Revision(heads[0]), kind: Kind::DIVERGENT, @@ -238,13 +239,24 @@ impl<'a, P: Platform> Annotations<'a, P> { } } -impl Store

{ +/// Where the revisions are read that decide who may issue a reserved kind: +/// a store, or a batch being put along with what its store has, so a +/// statement can be about a revision of the same batch. +pub trait RevisionView { + /// The revision `id`. + /// + /// # Errors + /// + /// Fails if it cannot be found or read. + fn revision(&self, id: RevisionId) -> Result; + /// Whether `statement`'s issuer may issue a statement of its kind, if /// that kind is reserved: /// /// - `core.abandon`, about a revision, only the change's creator: the /// author of its first revision, so no one who took it over, or - /// rebased it, gives it up for them; + /// rebased it, gives it up for them; or an `admin`, which + /// [`Policy::authorizes`](crate::policy::Policy::authorizes) adds; /// - `core.conflict`, a node, as a landing queue that could not rebase /// the record, or the author of the revision it's about, listing /// the conflicts its files still have; @@ -255,8 +267,8 @@ impl Store

{ /// /// # Errors /// - /// Fails if a revision cannot be read. - pub fn authorizes(&self, statement: &Statement) -> Result { + /// Fails if the revision it's about cannot be read. + fn authorizes(&self, statement: &Statement) -> Result { let issuer = &statement.issuer; if !statement.kind.is_reserved() { return Ok(true); @@ -272,7 +284,7 @@ impl Store

{ Ok(if *kind == Kind::ABANDON { self.created(subject, issuer)? } else if *kind == Kind::CONFLICT { - is_node || self.get(subject)?.author == *issuer + is_node || self.revision(subject)?.author == *issuer } else { *kind == Kind::DIVERGENT && is_node }) @@ -281,6 +293,11 @@ impl Store

{ /// Whether `author` created the change of `revision`: made each /// revision it evolves from that evolves from none. Records being /// immutable, there is always one, and no one adds another later. + /// History this view lacks leaves it unknown: no. + /// + /// # Errors + /// + /// Fails if a revision cannot be read. fn created(&self, revision: RevisionId, author: &Principal) -> Result { let mut seen = HashSet::new(); let mut next = vec![revision]; @@ -288,7 +305,11 @@ impl Store

{ if !seen.insert(id) { continue; } - let revision = self.get(id)?; + let revision = match self.revision(id) { + Ok(revision) => revision, + Err(StoreError::NotFound(_)) => return Ok(false), + Err(error) => return Err(error), + }; if revision.predecessors.is_empty() && revision.author != *author { return Ok(false); } @@ -298,6 +319,12 @@ impl Store

{ } } +impl RevisionView for Store

{ + fn revision(&self, id: RevisionId) -> Result { + self.get(id) + } +} + /// A failure to issue a statement. #[derive(Debug, thiserror::Error)] pub enum AnnotateError { @@ -330,7 +357,7 @@ pub enum AnnotateError { #[cfg(test)] mod tests { use origin_shared::{ - Base, BlobId, Digest, Inputs, ObjectId, RepoPath, Revision, RevisionId, Verdict, + AttachmentId, Base, Digest, Inputs, ObjectId, RepoPath, Revision, RevisionId, Verdict, }; use super::*; @@ -361,7 +388,7 @@ mod tests { base: Base::Acceptance(ObjectId::from_bytes([0; 32]).into()), deps: Vec::new(), snapshot: build(store, files), - description: BlobId::from(EncodedObject::blob(b"").id()), + description: AttachmentId::from(EncodedObject::attachment(b"").id()), author: principal("user:alice"), } .encode(); @@ -413,7 +440,7 @@ mod tests { let promoter = principal("node:queue"); Annotations::open(store) .unwrap() - .promote(RecordId::Revision(revision), &promoter) + .promote(RecordId::Revision(revision), &promoter, |_| Ok(true)) .unwrap() .into_iter() .map(|(_, statement)| { @@ -501,7 +528,7 @@ mod tests { assert_eq!(about.len(), 1); assert_eq!(about[0].statement.kind, Kind::DIVERGENT); let heads = store - .try_blob(about[0].statement.payload.unwrap().into()) + .try_attachment(about[0].statement.payload.unwrap()) .unwrap() .unwrap(); let mut expected = [left, right]; diff --git a/apps/origin-src/src/annotations/promotion.rs b/apps/origin-src/src/annotations/promotion.rs index fec5d6fb..91be5765 100644 --- a/apps/origin-src/src/annotations/promotion.rs +++ b/apps/origin-src/src/annotations/promotion.rs @@ -18,7 +18,7 @@ use std::collections::HashSet; use origin_platform::Platform; use origin_shared::api::Annotation; -use origin_shared::{Principal, Record, RecordId, Statement, StatementId, TreeId}; +use origin_shared::{Principal, Record, RecordId, Statement, StatementId}; use super::Annotations; use crate::store::{Store, StoreError}; @@ -155,10 +155,12 @@ impl Annotations<'_, P> { } /// The statements promoting results to `subject`, as `issuer` would - /// issue them, without writing them, sorted by kind: for each + /// issue them for a caller who `sees` some records, without writing + /// them, sorted by kind: for each /// [promotion group](Statement::promotion_group), the latest statement - /// about another record with the same input digest as the subject, - /// unless its issuer superseded it. Groups whose kind and issuer the + /// about another record the caller sees with the same input digest as + /// the subject, unless its issuer superseded it. So a promotion never + /// copies a result, and its payload, that the caller can't read. Groups whose kind and issuer the /// subject already has a result of, directly or promoted, are skipped: /// a result that ran there wins. A statement has no snapshot, so /// nothing is promoted to one. @@ -171,6 +173,7 @@ impl Annotations<'_, P> { &mut self, subject: RecordId, issuer: &Principal, + mut sees: impl FnMut(RecordId) -> Result, ) -> Result, StoreError> { let Some(snapshot) = self.store.snapshot_of(subject)? else { return Ok(Vec::new()); @@ -202,7 +205,7 @@ impl Annotations<'_, P> { continue; } let statement = self.store.get(id)?; - if self.superseded_by(id, &statement)?.is_none() { + if self.superseded_by(id, &statement)?.is_none() && sees(statement.subject)? { promotions.push(Statement { subject, issuer: issuer.clone(), @@ -220,9 +223,10 @@ impl Annotations<'_, P> { } /// Issues the [`promotions`](Self::promotions) to `subject` as - /// `issuer`, returning them. A promotion refers to its original's - /// payload without writing it, so it [holds the objects](Store::hold_objects) - /// from finding the originals to writing the promotions. + /// `issuer`, for a caller who `sees` some records, returning them. A + /// promotion refers to its original's payload without writing it, so + /// it [holds the objects](Store::hold_objects) from finding the + /// originals to writing the promotions. /// /// # Errors /// @@ -232,9 +236,10 @@ impl Annotations<'_, P> { &mut self, subject: RecordId, issuer: &Principal, + sees: impl FnMut(RecordId) -> Result, ) -> Result, StoreError> { let _held = self.store.hold_objects()?; - let promotions = self.promotions(subject, issuer)?; + let promotions = self.promotions(subject, issuer, sees)?; let encoded: Vec<_> = promotions.iter().map(Statement::encode).collect(); self.store.write_all(&encoded)?; Ok(encoded @@ -246,21 +251,6 @@ impl Annotations<'_, P> { } impl Store

{ - /// The snapshot of a record a statement is about, which its input - /// digest is taken from. Statements have none. - /// - /// # Errors - /// - /// Fails if the record cannot be read. - pub fn snapshot_of(&self, subject: RecordId) -> Result, StoreError> { - Ok(match subject { - RecordId::Snapshot(id) => Some(id), - RecordId::Revision(id) => Some(self.get(id)?.snapshot), - RecordId::Acceptance(id) => Some(self.get(id)?.body().state), - RecordId::Statement(_) => None, - }) - } - /// Checks the claim `statement`, `id`, makes. A statement that promotes /// none makes its own. A promotion must name an original the store has /// that is [promotable](Statement::is_promotable), match its kind, diff --git a/apps/origin-src/src/annotations/retention.rs b/apps/origin-src/src/annotations/retention.rs index cccfd31c..7ea5be3d 100644 --- a/apps/origin-src/src/annotations/retention.rs +++ b/apps/origin-src/src/annotations/retention.rs @@ -23,12 +23,14 @@ pub struct Dropped { impl Annotations<'_, P> { /// Deletes the payloads whose retention ended by `now`, counted from /// when this node stored their statement. A payload stays while any - /// statement still keeps it, or a revision or a tree refers to the same - /// blob, such as an empty file and an empty payload. Statements stay. - /// It waits for the writers [holding objects](crate::store::Store::hold_objects), - /// which landings on `policy` are too, then reads the rules from - /// `retention` and the index again, so a writer never refers to a - /// payload deleted under it, and a policy landed meanwhile applies. + /// statement still keeps it, or a revision's description is the same + /// attachment, such as an empty description and an empty payload. + /// Attachments never share an id with a file, so no snapshot is read. + /// Statements stay. It waits for the writers [holding + /// objects](crate::store::Store::hold_objects), which landings on + /// `policy` are too, then reads the rules from `retention` and the + /// index again, so a writer never refers to a payload deleted under it, + /// and a policy landed meanwhile applies. /// Without rules, it neither waits nor reads anything: a policy landed /// meanwhile could only have dropped payloads that this run keeps. /// @@ -52,20 +54,13 @@ impl Annotations<'_, P> { self.index = Index::open(self.store)?; let mut expired = HashSet::new(); let mut live: HashSet = HashSet::new(); - let mut roots = Vec::new(); for &record in self.index.arrivals() { match record { RecordId::Revision(id) => { - let revision = self.store.get(id)?; - roots.push(revision.snapshot); - live.insert(revision.description.into()); + live.insert(self.store.get(id)?.description.into()); } - RecordId::Acceptance(id) => roots.push(self.store.get(id)?.body().state), RecordId::Statement(id) => { let statement = self.store.get(id)?; - if let RecordId::Snapshot(tree) = statement.subject { - roots.push(tree); - } let Some(payload) = statement.payload else { continue; }; @@ -82,13 +77,9 @@ impl Annotations<'_, P> { live.insert(payload.into()); } } - RecordId::Snapshot(_) => {} + RecordId::Acceptance(_) | RecordId::Snapshot(_) => {} } } - if expired.is_empty() { - return Ok(Dropped::default()); - } - self.store.reach(roots, &mut live)?; let mut doomed = Vec::new(); for &id in expired.difference(&live) { if self.store.contains(id)? { @@ -108,7 +99,7 @@ mod tests { use std::time::Duration; use origin_platform::Native; - use origin_shared::{EncodedObject, Verdict}; + use origin_shared::{EncodedObject, Record, Revision, Verdict}; use super::*; use crate::annotations::tests::{FILES, check, issue, revision, store}; @@ -119,7 +110,7 @@ mod tests { let (_dir, store) = store(); let first = revision(&store, 1, &[], &[("empty", "")]); let with_payload = |kind: &str, payload: &[u8]| { - let payload = EncodedObject::blob(payload); + let payload = EncodedObject::attachment(payload); let mut statement = check(first, kind, Verdict::Pass, "node:ci", None); statement.payload = Some(payload.id().into()); let annotations = Annotations::open(&store).unwrap(); @@ -129,7 +120,7 @@ mod tests { (id, payload.id()) }; let (log_statement, log) = with_payload("ci.test", b"a long log"); - // The same blob as the empty file, which must stay. + // The same attachment as the empty description, which must stay. let (_, empty) = with_payload("ci.lint", b""); let (_, review) = with_payload("review", b"looks good"); let (_, shared) = with_payload("ci.bench", b"shared"); @@ -188,7 +179,7 @@ mod tests { fn collection_waits_for_writers_holding_objects() { let (_dir, store) = store(); let first = revision(&store, 1, &[], FILES); - let payload = EncodedObject::blob(b"a log, and a file"); + let payload = EncodedObject::attachment(b"a log, and a description"); let mut statement = check(first, "ci.test", Verdict::Pass, "node:ci", None); statement.payload = Some(payload.id().into()); Annotations::open(&store) @@ -199,8 +190,9 @@ mod tests { .unwrap() .retention; - // A writer finds the expired payload stored as a file it snapshots, - // so it doesn't write it, then writes the revision referring to it. + // A writer finds the expired payload stored as the description it + // writes, so it doesn't write it, then writes the revision referring + // to it. let held = store.hold_objects().unwrap(); assert_eq!(store.write_all(std::slice::from_ref(&payload)).unwrap(), 0); let (done, collected) = std::sync::mpsc::channel(); @@ -215,7 +207,11 @@ mod tests { }); thread::sleep(Duration::from_millis(100)); assert!(collected.try_recv().is_err(), "collection waits"); - revision(&store, 2, &[], &[("log", "a log, and a file")]); + let reusing = Revision { + description: payload.id().into(), + ..store.get(first).unwrap() + }; + store.write_all(&[reusing.encode()]).unwrap(); drop(held); assert_eq!(collected.recv().unwrap(), Dropped::default()); }); @@ -226,7 +222,7 @@ mod tests { fn promotions_and_collection_exclude_each_other() { let (_dir, store) = store(); let first = revision(&store, 1, &[], FILES); - let payload = EncodedObject::blob(b"a log to promote"); + let payload = EncodedObject::attachment(b"a log to promote"); let inputs = Some((&["src"][..], false)); let mut result = check(first, "ci.test", Verdict::Pass, "node:ci", inputs); result.payload = Some(payload.id().into()); @@ -245,7 +241,7 @@ mod tests { let issuer = "node:queue".parse().unwrap(); let promotions = Annotations::open(&store) .unwrap() - .promote(second, &issuer) + .promote(second, &issuer, |_| Ok(true)) .unwrap(); done.send(promotions.len()).unwrap(); }); diff --git a/apps/origin-src/src/auth.rs b/apps/origin-src/src/auth.rs new file mode 100644 index 00000000..80e2b3eb --- /dev/null +++ b/apps/origin-src/src/auth.rs @@ -0,0 +1,151 @@ +//! Authenticating callers of the HTTP API by their bearer token: a token +//! the server signed, scoped and short-lived (see [`Token`]), or a static +//! one from its configuration, checked by an [`Authenticator`]. + +use std::collections::BTreeMap; +use std::fmt; + +use origin_shared::Principal; +use origin_shared::access::{InvalidToken, Token}; +use serde::Deserialize; + +use crate::permissions::Scope; +use crate::service::{Caller, Node}; + +/// Turns static credentials into principals. The server configuration's +/// tokens are one implementation; external identity providers can be +/// others. +pub trait Authenticator: Send + Sync + fmt::Debug { + /// The principal a bearer token belongs to, or `None` if no one has it. + fn authenticate(&self, token: &str) -> Option; +} + +/// Static tokens, from the server configuration: +/// +/// ```toml +/// [tokens] +/// "user:alice" = "a long random secret" +/// "node:ci-1" = "another one" +/// ``` +#[derive(Default, Deserialize)] +pub struct StaticTokens(BTreeMap); + +impl fmt::Debug for StaticTokens { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Never the secrets. + f.debug_list().entries(self.0.keys()).finish() + } +} + +impl Authenticator for StaticTokens { + fn authenticate(&self, token: &str) -> Option { + // Comparing hashes, which compare in constant time, keeps response + // times from revealing how much of a token matched. + let token = blake3::hash(token.as_bytes()); + self.0 + .iter() + .find(|(_, known)| blake3::hash(known.as_bytes()) == token) + .map(|(principal, _)| principal.clone()) + } +} + +/// How a server tells its callers apart: its node, which verifies the +/// tokens it signed, and its static credentials. +#[derive(Debug)] +pub struct Credentials { + node: Node, + authenticator: Box, +} + +impl Credentials { + /// Callers of `node`, whose static credentials `authenticator` checks. + #[must_use] + pub fn new(node: Node, authenticator: Box) -> Self { + Self { + node, + authenticator, + } + } + + /// The caller an `Authorization` header's value stands for: anonymous + /// without one. + /// + /// # Errors + /// + /// Fails if it isn't a bearer token, or no valid one. + pub fn caller(&self, authorization: Option<&str>) -> Result { + let Some(authorization) = authorization else { + return Ok(Caller::Anonymous); + }; + let token = authorization + .strip_prefix("Bearer ") + .ok_or(InvalidToken::Malformed)?; + if Token::is_signed(token) { + let token = Token::verify(token, &self.node.key.public())?; + return Ok(Caller::Principal { + principal: token.principal, + scope: Some(Scope { + capabilities: token.scope, + expires: token.expires, + }), + }); + } + let principal = self + .authenticator + .authenticate(token) + .ok_or(InvalidToken::Unknown)?; + Ok(Caller::Principal { + principal, + scope: None, + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use origin_shared::NodeKey; + + use super::*; + + #[test] + fn tells_callers_apart_by_token() { + let tokens: StaticTokens = + toml::from_str("\"user:alice\" = \"a-secret\"\n\"node:ci\" = \"c-secret\"\n").unwrap(); + let node = Node { + name: "hosted".parse().unwrap(), + key: Arc::new(NodeKey::from_bytes(&[5; 32])), + }; + let token = Token::new( + "user:bob".parse().unwrap(), + vec!["write src".parse().unwrap()], + Duration::from_mins(1), + ); + let signed = token.sign(&node.key); + let credentials = Credentials::new(node, Box::new(tokens)); + let caller = |header: Option<&str>| credentials.caller(header); + let principal = |caller: Caller| caller.principal().map(ToString::to_string); + + assert_eq!(caller(None), Ok(Caller::Anonymous)); + assert_eq!( + principal(caller(Some("Bearer a-secret")).unwrap()).as_deref(), + Some("user:alice") + ); + assert_eq!( + principal(caller(Some("Bearer c-secret")).unwrap()).as_deref(), + Some("node:ci") + ); + let Ok(Caller::Principal { principal, scope }) = caller(Some(&format!("Bearer {signed}"))) + else { + panic!("a signed token"); + }; + assert_eq!(principal.to_string(), "user:bob"); + let scope = scope.unwrap(); + assert_eq!(scope.capabilities[0].to_string(), "write src"); + assert_eq!(scope.expires, token.expires, "it carries its expiry"); + assert!(caller(Some("Bearer a-secre")).is_err()); + assert!(caller(Some("a-secret")).is_err(), "not a bearer token"); + } +} diff --git a/apps/origin-src/src/cli.rs b/apps/origin-src/src/cli.rs index 1499ea1e..ef22947d 100644 --- a/apps/origin-src/src/cli.rs +++ b/apps/origin-src/src/cli.rs @@ -9,8 +9,11 @@ use std::str::FromStr; use anyhow::{Context, Result}; use clap::error::ErrorKind; use clap::{Args, CommandFactory, Parser, Subcommand}; +use origin_shared::access::Capability; use origin_shared::policy::KindPattern; -use origin_shared::{ChangeId, Digest, Inputs, Kind, RepoPath, TargetName, Verdict}; +use origin_shared::{ + ChangeId, Digest, Inputs, Kind, Period, Principal, RepoPath, TargetName, Verdict, +}; use crate::config::UserConfig; use crate::oplog::Actor; @@ -54,6 +57,9 @@ enum Command { /// Manage the repositories a server hosts. #[command(subcommand)] Repo(RepoCommand), + /// Manage tokens. + #[command(subcommand)] + Token(TokenCommand), #[command(flatten)] Workspace(WorkspaceCommand), } @@ -71,6 +77,29 @@ enum RepoCommand { }, } +#[derive(Debug, Subcommand)] +enum TokenCommand { + /// Issue a short-lived token with a narrower scope, for an agent or a + /// CI runner: the remote signs one acting for you, or, with + /// `--config`, a server signs one for anyone. + Issue { + /// A capability: `read `, `write `, `annotate `, + /// `land `, `land *` or `admin`. Repeat it for several. + #[arg(long, required = true)] + scope: Vec, + /// How long it lasts, such as `1h` or `7d`, the most a token lasts. + #[arg(long, default_value = "1h")] + ttl: Period, + /// Sign it with the key of the server this configuration file + /// describes, instead of asking the remote. + #[arg(short, long, requires = "principal")] + config: Option, + /// Who it acts for, with `--config`, such as `user:agent-1`. + #[arg(long = "as", requires = "config")] + principal: Option, + }, +} + /// Commands run in the repository containing the current directory. Those /// that read the working copy snapshot it first. Changes are named by a /// unique prefix of their id, or `@` for the working copy's. @@ -206,6 +235,11 @@ enum WorkspaceCommand { /// change went, or onto its target's new tip, with the edits made /// since. Pull, + /// Show who the remote thinks you are. + Whoami, + /// Show what the remote lets you do: the paths you read and write, the + /// statements you issue, and the targets you land on. + Permissions, /// Share changes with the remote, with the changes below them: their /// revisions, files and statements. Lands nothing. Push { @@ -487,7 +521,22 @@ impl Cli { Command::Repo(RepoCommand::Create { path, config }) => { return ServerConfig::load(&config)?.create_repo(&path); } - Command::Init { .. } | Command::Clone { .. } | Command::Workspace(_) => {} + Command::Token(TokenCommand::Issue { + scope, + ttl, + config: Some(config), + principal: Some(principal), + }) => { + println!( + "{}", + ServerConfig::load(&config)?.issue_token(principal, scope, ttl.0)? + ); + return Ok(()); + } + Command::Init { .. } + | Command::Clone { .. } + | Command::Token(_) + | Command::Workspace(_) => {} } let config = UserConfig::load()?; // The op log records the command line, without the program's name, @@ -506,6 +555,10 @@ impl Cli { Workspace::clone(&url, path.as_deref(), &target, config, &actor)?; } Command::Workspace(command) => command.run(config, &actor)?, + Command::Token(TokenCommand::Issue { scope, ttl, .. }) => { + let cwd = env::current_dir().context("cannot read the current directory")?; + Workspace::discover(&cwd, config, &actor)?.issue_token(scope, ttl)?; + } Command::Serve { .. } | Command::Repo(_) => unreachable!("run above"), } Ok(()) @@ -564,6 +617,8 @@ impl WorkspaceCommand { Self::Remote { url } => workspace.set_remote(url.as_deref())?, Self::Fetch { target, change } => workspace.fetch(&target, &change)?, Self::Pull => workspace.pull()?, + Self::Whoami => workspace.whoami()?, + Self::Permissions => workspace.permissions()?, Self::Push { changes } => workspace.push(&changes)?, Self::Watch { kind, diff --git a/apps/origin-src/src/config.rs b/apps/origin-src/src/config.rs index d60f1fce..b75161fa 100644 --- a/apps/origin-src/src/config.rs +++ b/apps/origin-src/src/config.rs @@ -3,11 +3,14 @@ //! `~/.config`: //! //! - `config.toml`: `user = "alice"`, who authors revisions (by default -//! `$USER`), and `node = "laptop"`, this node's name (by default the -//! user's). +//! `$USER`), `node = "laptop"`, this node's name (by default the +//! user's), and `[tokens]`, the token to send each server, by its URL: +//! `"http://127.0.0.1:7878" = "a long random secret"`. `$ORIGIN_TOKEN` +//! overrides them. //! - `node.key`: this node's secret ed25519 key, created by the first //! `origin init`, readable only by its owner. +use std::collections::BTreeMap; use std::env; use std::fs::{self, OpenOptions}; use std::io::{self, Write}; @@ -25,6 +28,17 @@ pub struct UserConfig { dir: PathBuf, user: PrincipalName, node: PrincipalName, + tokens: Tokens, +} + +/// The tokens to send servers, by URL, which never show in debug output. +#[derive(Clone, Default, Deserialize)] +struct Tokens(BTreeMap); + +impl std::fmt::Debug for Tokens { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_list().entries(self.0.keys()).finish() + } } /// `config.toml`. @@ -33,6 +47,8 @@ pub struct UserConfig { struct ConfigFile { user: Option, node: Option, + #[serde(default)] + tokens: Tokens, } impl UserConfig { @@ -73,7 +89,27 @@ impl UserConfig { .map(name) .transpose()? .unwrap_or_else(|| user.clone()); - Ok(Self { dir, user, node }) + let tokens = file + .tokens + .0 + .into_iter() + .map(|(server, token)| (server.trim_end_matches('/').to_owned(), token)) + .collect(); + Ok(Self { + dir, + user, + node, + tokens: Tokens(tokens), + }) + } + + /// The token to send the server at `base`, such as + /// `http://127.0.0.1:7878`: `$ORIGIN_TOKEN`, or the one configured. + #[must_use] + pub fn token(&self, base: &str) -> Option { + env::var("ORIGIN_TOKEN") + .ok() + .or_else(|| self.tokens.0.get(base.trim_end_matches('/')).cloned()) } /// The user, who authors revisions and issues statements. diff --git a/apps/origin-src/src/conflict.rs b/apps/origin-src/src/conflict.rs index f07fbc14..02c1b509 100644 --- a/apps/origin-src/src/conflict.rs +++ b/apps/origin-src/src/conflict.rs @@ -82,6 +82,13 @@ impl Conflicts { self.0.push(conflicted); } + /// Those of these conflicts whose path `keep` keeps. + #[must_use] + pub fn only(mut self, keep: impl Fn(&RepoPath) -> bool) -> Self { + self.0.retain(|conflicted| keep(&conflicted.path)); + self + } + /// Whether there are none. #[must_use] pub fn is_empty(&self) -> bool { @@ -163,7 +170,7 @@ impl Conflicts { list.push_str(conflicted.path.as_str()); list.push('\0'); } - let payload = EncodedObject::blob(list.as_bytes()); + let payload = EncodedObject::attachment(list.as_bytes()); let statement = Statement { subject, kind: Kind::CONFLICT, @@ -192,7 +199,7 @@ impl Conflicts { return Ok(Self::default()); }; let content = store - .try_blob(payload.into())? + .try_attachment(payload)? .ok_or(StoreError::NotFound(payload.into()))?; let conflicted = String::from_utf8_lossy(&content) .split_terminator('\0') @@ -222,16 +229,31 @@ impl Conflicts { store: &Store

, claims: &[Claim], revision: &Revision, + ) -> Result { + Self::latest(store, claims.iter().map(Claim::statement), revision) + } + + /// The unresolved conflicts of `revision`, as [`Conflicts::of`] finds + /// them, from `statements` about it, in arrival order. + /// + /// # Errors + /// + /// Fails like [`Conflicts::of`]. + pub fn latest<'s, P: Platform>( + store: &Store

, + statements: impl IntoIterator, + revision: &Revision, ) -> Result { if matches!(revision.author, Principal::Node(_)) { return Ok(Self::default()); } - let Some(claim) = claims.iter().rev().find(|claim| { - claim.statement().kind == Kind::CONFLICT && claim.statement().issuer == revision.author - }) else { + let latest = statements.into_iter().filter(|statement| { + statement.kind == Kind::CONFLICT && statement.issuer == revision.author + }); + let Some(statement) = latest.last() else { return Ok(Self::default()); }; - Self::listed(store, claim.statement())?.remaining(store, revision.snapshot) + Self::listed(store, statement)?.remaining(store, revision.snapshot) } /// Those of these conflicts `snapshot` still has: where it has the diff --git a/apps/origin-src/src/events.rs b/apps/origin-src/src/events.rs index f5d51c9f..7959b931 100644 --- a/apps/origin-src/src/events.rs +++ b/apps/origin-src/src/events.rs @@ -79,6 +79,29 @@ impl Wakeup { } } +/// Which events a reader takes: those a [`Filter`] passes, or a closure +/// accepts, such as one that also checks what a caller sees. +pub trait Accept { + /// Whether the reader takes `event`. + /// + /// # Errors + /// + /// Fails if what decides cannot be read. + fn accept(&mut self, event: &EventKind) -> Result; +} + +impl Accept for &Filter { + fn accept(&mut self, event: &EventKind) -> Result { + Ok(self.matches(event)) + } +} + +impl Result> Accept for F { + fn accept(&mut self, event: &EventKind) -> Result { + self(event) + } +} + impl<'a, P: Platform> Events<'a, P> { /// How often [`Events::wait`] reads the op log. pub const POLL: Duration = Duration::from_millis(50); @@ -110,14 +133,19 @@ impl<'a, P: Platform> Events<'a, P> { self.store.oplog().settled() } - /// The events after `cursor` that pass `filter`, oldest first: those + /// The events after `cursor` that `accept` takes, oldest first: those /// of whole entries, until there are `limit` or more. /// /// # Errors /// /// Fails with [`StoreError::Expired`] if the op log no longer keeps the /// entries after `cursor`, or if it or a record cannot be read. - pub fn after(&self, cursor: u64, filter: &Filter, limit: usize) -> Result { + pub fn after( + &self, + cursor: u64, + mut accept: impl Accept, + limit: usize, + ) -> Result { let mut page = Page { events: Vec::new(), next: cursor, @@ -131,15 +159,14 @@ impl<'a, P: Platform> Events<'a, P> { let settled = oplog.settled()?; let mut waiting = None; for entry in read.entries.iter().take_while(|entry| entry.seq <= settled) { - page.events.extend( - self.derive(entry, &mut waiting)? - .into_iter() - .filter(|kind| filter.matches(kind)) - .map(|kind| Event { + for kind in self.derive(entry, &mut waiting)? { + if accept.accept(&kind)? { + page.events.push(Event { cursor: entry.seq, kind, - }), - ); + }); + } + } page.next = entry.seq; if page.events.len() >= limit { break; @@ -158,7 +185,7 @@ impl<'a, P: Platform> Events<'a, P> { pub fn wait( &self, cursor: u64, - filter: &Filter, + mut accept: impl Accept, limit: usize, timeout: Duration, ) -> Result { @@ -167,7 +194,7 @@ impl<'a, P: Platform> Events<'a, P> { loop { // Read before the op log, so no write after it is missed. let seen = self.wakeup.map(Wakeup::writes); - let page = self.after(cursor, filter, limit)?; + let page = self.after(cursor, |event: &EventKind| accept.accept(event), limit)?; let left = timeout.saturating_sub(start.elapsed()); if !page.events.is_empty() || left.is_zero() { return Ok(page); @@ -263,7 +290,10 @@ impl<'a, P: Platform> Events<'a, P> { author: revision.author, }), // Its event comes once a target advances to it. - Object::Acceptance(_) | Object::Blob(_) | Object::Tree(_) => None, + Object::Acceptance(_) + | Object::Blob(_) + | Object::Tree(_) + | Object::Attachment(_) => None, }); } Ok(events) diff --git a/apps/origin-src/src/http.rs b/apps/origin-src/src/http.rs index 01579d67..24ce041d 100644 --- a/apps/origin-src/src/http.rs +++ b/apps/origin-src/src/http.rs @@ -2,8 +2,10 @@ //! calls on the repository they name. Every route is documented with //! `utoipa`, which generates the OpenAPI document; `origin-client` is the //! Rust SDK for it. +//! +//! Callers authenticate with `Authorization: Bearer ` (see +//! [`Credentials`](crate::auth::Credentials)); without it, they are anonymous. -use std::convert::Infallible; use std::sync::Arc; use axum::Json; @@ -14,10 +16,12 @@ use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Response}; use origin_shared::ChangeId; use origin_shared::Head; +use origin_shared::access::{InvalidToken, Rights}; use origin_shared::api::{ - Annotation, ApiError, ErrorCode, EventsQuery, FetchRecords, FetchSnapshot, Inventory, - InventoryRequest, LandRequest, Landings, LogEntry, LogQuery, MORE, NewTarget, PutQuery, - RepoName, RepoSummary, Revisions, StatementsQuery, Stored, TargetInfo, + Annotation, ApiError, BrowseQuery, ErrorCode, EventsQuery, FetchRecords, FetchSnapshot, + Inventory, InventoryRequest, IssuedToken, LandRequest, Landings, Listing, LogEntry, LogQuery, + MORE, NewTarget, PutQuery, RepoName, RepoSummary, Revisions, StatementsQuery, Stored, + TargetInfo, TokenRequest, }; use origin_shared::event::Page; use origin_shared::landing::Pending; @@ -65,6 +69,10 @@ impl Api { .routes(routes!(land)) .routes(routes!(landings)) .routes(routes!(events)) + .routes(routes!(whoami)) + .routes(routes!(issue_token)) + .routes(routes!(tree)) + .routes(routes!(file)) .routes(routes!(openapi_document)) } @@ -81,15 +89,23 @@ impl Api { } } -/// Every request is anonymous: nothing authenticates callers yet. +/// The caller of a request, by its bearer token. impl FromRequestParts> for Caller { - type Rejection = Infallible; + type Rejection = HttpError; fn from_request_parts( - _parts: &mut Parts, - _repositories: &Arc, - ) -> impl Future> + Send { - std::future::ready(Ok(Self::Anonymous)) + parts: &mut Parts, + repositories: &Arc, + ) -> impl Future> + Send { + let authorization = parts + .headers + .get(header::AUTHORIZATION) + .map(|value| value.to_str().map_err(|_| InvalidToken::Malformed)) + .transpose(); + let caller = authorization + .and_then(|authorization| repositories.credentials().caller(authorization)) + .map_err(HttpError::Unauthorized); + std::future::ready(caller) } } @@ -109,9 +125,14 @@ async fn call( } /// A failed request, rendered as an [`ApiError`]. -enum HttpError { +pub enum HttpError { + /// The service refused or failed. Service(ServiceError), + /// The request's pack is invalid. Pack(PackError), + /// The caller's credentials are invalid. + Unauthorized(InvalidToken), + /// The service panicked. Panicked(String), } @@ -124,6 +145,7 @@ impl IntoResponse for HttpError { } Self::Service(error) => (error.code(), error.to_string()), Self::Pack(error) => (ErrorCode::BadRequest, format!("invalid pack: {error}")), + Self::Unauthorized(error) => (ErrorCode::Unauthorized, error.to_string()), Self::Panicked(message) => { tracing::error!(%message, "request panicked"); (ErrorCode::Internal, "internal server error".to_owned()) @@ -134,6 +156,8 @@ impl IntoResponse for HttpError { ErrorCode::NotFound => StatusCode::NOT_FOUND, ErrorCode::Conflict => StatusCode::CONFLICT, ErrorCode::Expired => StatusCode::GONE, + ErrorCode::Unauthorized => StatusCode::UNAUTHORIZED, + ErrorCode::Forbidden => StatusCode::FORBIDDEN, ErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR, }; (status, Json(ApiError { code, message })).into_response() @@ -155,15 +179,25 @@ impl utoipa::PartialSchema for Binary { impl utoipa::ToSchema for Binary {} -/// Lists the repositories on the server. +/// Lists the repositories on the server that the caller sees. #[utoipa::path( get, path = "/api/v1/repos", operation_id = "list_repos", - responses((status = 200, description = "The repositories, by name", body = Vec)) + responses( + (status = 200, description = "The repositories, by name", body = Vec), + (status = 401, description = "The token is invalid or expired", body = ApiError) + ) )] -async fn list_repos(State(repositories): Repos, caller: Caller) -> Json> { - Json(repositories.list(&caller)) +async fn list_repos( + State(repositories): Repos, + caller: Caller, +) -> Result>, HttpError> { + tokio::task::spawn_blocking(move || repositories.list(&caller)) + .await + .map_err(|error| HttpError::Panicked(error.to_string()))? + .map(Json) + .map_err(HttpError::Service) } /// A part of a fetch, as a response: its pack, with the [`MORE`] header if @@ -494,6 +528,112 @@ async fn events( .map(Json) } +/// Tells the caller who they are and what they may do in the repository. +#[utoipa::path( + get, + path = "/api/v1/repos/{repo}/whoami", + operation_id = "whoami", + params(("repo" = String, Path, description = "The repository")), + responses( + (status = 200, description = "The caller's rights", body = Rights), + (status = 401, description = "The token is invalid or expired", body = ApiError), + (status = 404, description = "The repository does not exist", body = ApiError) + ) +)] +async fn whoami( + repos: Repos, + Path(repo): Path, + caller: Caller, +) -> Result, HttpError> { + call(repos, repo, caller, OriginService::whoami) + .await + .map(Json) +} + +/// Issues a token acting for the caller, with a scope narrowing their +/// rights, signed by the server, which verifies it without storing it. +#[utoipa::path( + post, + path = "/api/v1/repos/{repo}/tokens", + operation_id = "issue_token", + params(("repo" = String, Path, description = "The repository whose policy the scope is checked against")), + request_body = TokenRequest, + responses( + (status = 200, description = "The token", body = IssuedToken), + (status = 403, description = "The caller isn't signed in, or the scope reaches beyond their rights", body = ApiError), + (status = 404, description = "The repository does not exist", body = ApiError) + ) +)] +async fn issue_token( + repos: Repos, + Path(repo): Path, + caller: Caller, + Json(request): Json, +) -> Result, HttpError> { + call(repos, repo, caller, move |service, caller| { + service.issue_token(caller, &request) + }) + .await + .map(Json) +} + +/// Lists a directory of a snapshot: a target's tip, or a revision's or an +/// acceptance's. Withheld entries keep their place. +#[utoipa::path( + get, + path = "/api/v1/repos/{repo}/tree", + operation_id = "tree", + params(("repo" = String, Path, description = "The repository"), BrowseQuery), + responses( + (status = 200, description = "The directory's entries", body = Listing), + (status = 400, description = "The path is a file, or the record has no snapshot", body = ApiError), + (status = 403, description = "The caller reads nothing under the path", body = ApiError), + (status = 404, description = "The repository, the target, the record or the path does not exist", body = ApiError) + ) +)] +async fn tree( + repos: Repos, + Path(repo): Path, + caller: Caller, + Query(query): Query, +) -> Result, HttpError> { + call(repos, repo, caller, move |service, caller| { + service.tree(caller, &query) + }) + .await + .map(Json) +} + +/// Reads a file of a snapshot, as `tree` finds it. +#[utoipa::path( + get, + path = "/api/v1/repos/{repo}/file", + operation_id = "file", + params(("repo" = String, Path, description = "The repository"), BrowseQuery), + responses( + (status = 200, description = "The file's content", content_type = "application/octet-stream", body = Binary), + (status = 400, description = "The path is a directory, or the record has no snapshot", body = ApiError), + (status = 403, description = "The caller may not read the file", body = ApiError), + (status = 404, description = "The repository, the target, the record or the path does not exist", body = ApiError) + ) +)] +async fn file( + repos: Repos, + Path(repo): Path, + caller: Caller, + Query(query): Query, +) -> Result { + let content = call(repos, repo, caller, move |service, caller| { + service.file(caller, &query) + }) + .await?; + Ok(( + [(header::CONTENT_TYPE, "application/octet-stream")], + content, + ) + .into_response()) +} + /// The OpenAPI document of this API. #[utoipa::path( get, diff --git a/apps/origin-src/src/index.rs b/apps/origin-src/src/index.rs index 3845606c..199b2dd5 100644 --- a/apps/origin-src/src/index.rs +++ b/apps/origin-src/src/index.rs @@ -603,7 +603,7 @@ impl Entry { pub(crate) fn of(object: &EncodedObject) -> Result, DecodeError> { let id = object.id(); Ok(match object.kind()? { - ObjectKind::Blob | ObjectKind::Tree => None, + ObjectKind::Blob | ObjectKind::Tree | ObjectKind::Attachment => None, ObjectKind::Revision => { let revision: Revision = object.decode_as()?; Some(Self::Revision { @@ -915,7 +915,7 @@ mod tests { use std::thread; use origin_platform::Native; - use origin_shared::{Base, BlobId, Inputs, Kind, Record, Tree, Verdict}; + use origin_shared::{AttachmentId, Base, Inputs, Kind, Record, Tree, Verdict}; use super::*; use crate::oplog::FramePart; @@ -939,7 +939,7 @@ mod tests { base: Base::Acceptance(ObjectId::from_bytes([0; 32]).into()), deps: Vec::new(), snapshot: Tree::default().encode().id().into(), - description: BlobId::from(EncodedObject::blob(seed.as_bytes()).id()), + description: AttachmentId::from(EncodedObject::attachment(seed.as_bytes()).id()), author: "user:alice".parse().unwrap(), } .encode() diff --git a/apps/origin-src/src/lib.rs b/apps/origin-src/src/lib.rs index 092361e6..f61ef6f0 100644 --- a/apps/origin-src/src/lib.rs +++ b/apps/origin-src/src/lib.rs @@ -3,6 +3,7 @@ pub mod ancestry; pub mod annotations; +pub mod auth; pub mod cli; pub mod config; pub mod conflict; @@ -14,6 +15,7 @@ pub mod http; pub mod index; pub mod oplog; pub mod output; +pub mod permissions; pub mod policy; pub mod queue; pub mod remote; diff --git a/apps/origin-src/src/permissions.rs b/apps/origin-src/src/permissions.rs new file mode 100644 index 00000000..b0169d47 --- /dev/null +++ b/apps/origin-src/src/permissions.rs @@ -0,0 +1,1672 @@ +//! Enforcing what callers may do, as their [`Rights`] say: what they read +//! of a repository's records, and what the records they put may hold. +//! +//! **Reading.** A caller who reads no path doesn't see the repository at +//! all. Otherwise they see its targets and acceptances, every revision that +//! changes a path they read, or changes nothing, and the statements about +//! what they see. Snapshots withhold the content of the paths they don't +//! read (see [`Store::added`]). +//! +//! **Writing.** Anonymous callers only read. A record put is attributed to +//! its caller: a revision's author and a statement's issuer must be them. +//! A statement's kind must be one they annotate, a promotion's too, but +//! for the reserved kinds, whose authors the [policy](crate::policy::Policy::authorizes) +//! names for every caller; and they must see what it's about, and for a promotion, its original: so no +//! one copies a result they can't read. +//! +//! A revision must extend its change's history: only a new change gets a +//! root, and a successor evolves only from revisions of its change, which +//! they see. So no one makes a change diverge with a root of their own, +//! or becomes one of its creators, who [abandon](crate::annotations::RevisionView::authorizes) it. +//! Where they may not write, a revision has an *effect*: each file it +//! changes there, from its base's entry to its own. A root has none. A +//! successor has exactly each predecessor's, on the same change below if +//! it's on one, and lists exactly their unresolved conflicts there in its +//! `core.conflict` (see [`conflict`](crate::conflict)), which only a +//! `core.conflict` supersedes; a root lists those of the revision it's on. +//! So whatever base it picks, a successor never reverts, replaces or +//! resolves someone else's edits where its writer may not, nor adds a +//! conflict there, nor brings a stack that would land with it; a rebase +//! onto a newer tip keeps the effect, and one who takes a change over edits +//! what they write and hands it on. A revision may only hold +//! content they supply or that a snapshot it refers to holds at the same +//! path (see [`Store::unsupplied`]): so no one reads a file by placing its +//! id where they may read. Nor an attachment: a record that carries one +//! the repository has, as a description or a payload, brings it, but for +//! a promotion, which carries its original's. The records of a batch are +//! read before the store's (see [`Batch`]), so a batch may hold a stack of +//! revisions and statements about them; those it brings anew are the +//! caller's to see, but not one the store had. Landings check what they change on the target, in the +//! [queue](crate::queue). + +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::iter; + +use origin_platform::Platform; +use origin_shared::access::{Capability, PathAccess, Rights}; +use origin_shared::event::EventKind; +use origin_shared::{ + Base, ChangeId, Kind, Object, ObjectId, Principal, RecordId, RepoPath, Revision, RevisionId, + Statement, StatementId, TargetName, TreeId, +}; + +use crate::annotations::{Annotations, Claim, RevisionView}; +use crate::conflict::Conflicts; +use crate::store::{Store, StoreError}; +use crate::tree::FileChange; + +/// Who calls the service. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Caller { + /// The node's own user, through the command line or a remote on a + /// local path: they may do everything. + Owner, + /// A client of a server that didn't authenticate. + Anonymous, + /// An authenticated client of a server. + Principal { + /// Who they are. + principal: Principal, + /// What their token narrows their rights to, if it's a scoped one. + scope: Option, + }, +} + +/// What a scoped token narrows its bearer's rights to, and until when. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Scope { + /// The capabilities it keeps of its bearer's rights. + pub capabilities: Vec, + /// When it expires, in seconds since the Unix epoch: a token issued + /// with it expires no later. + pub expires: u64, +} + +impl Caller { + /// Who the caller is, unless the owner or anonymous. + #[must_use] + pub fn principal(&self) -> Option<&Principal> { + match self { + Self::Principal { principal, .. } => Some(principal), + Self::Owner | Self::Anonymous => None, + } + } +} + +/// What a caller sees of a repository's records, remembering what it +/// decided. +pub struct Reader<'a, P> { + store: &'a Store

, + /// What the caller may do with each path. + paths: &'a PathAccess, + seen: HashMap, +} + +impl<'a, P: Platform> Reader<'a, P> { + /// What a caller who may do `paths` sees of `store`. + pub fn new(store: &'a Store

, paths: &'a PathAccess) -> Self { + Self { + store, + paths, + seen: HashMap::new(), + } + } + + /// Whether the caller sees the repository: they read some path. + #[must_use] + pub fn sees_repository(&self) -> bool { + self.paths.reads_under(&RepoPath::root()) + } + + /// Whether the caller sees every record. + fn sees_all(&self) -> bool { + self.paths.reads_all() + } + + /// Whether the caller sees `record`, as the module explains. A record + /// this node lacks, or whose base it lacks, is not seen. + /// + /// # Errors + /// + /// Fails if a record or tree cannot be read. + pub fn sees(&mut self, record: RecordId) -> Result { + if self.sees_all() { + return Ok(true); + } + if !self.sees_repository() { + return Ok(false); + } + if let Some(&seen) = self.seen.get(&record) { + return Ok(seen); + } + let seen = match record { + RecordId::Revision(id) => self.sees_revision(id), + RecordId::Statement(id) => match self.store.get(id) { + Ok(statement) => self.sees(statement.subject), + Err(error) => Err(error), + }, + RecordId::Acceptance(_) | RecordId::Snapshot(_) => Ok(true), + }; + let seen = match seen { + Err(StoreError::NotFound(_)) => false, + seen => seen?, + }; + self.seen.insert(record, seen); + Ok(seen) + } + + fn sees_revision(&self, id: RevisionId) -> Result { + let revision: Revision = self.store.get(id)?; + let changes = self + .store + .diff(self.store.base_snapshot(revision.base)?, revision.snapshot)?; + Ok(changes.is_empty() || changes.iter().any(|change| self.paths.reads(&change.path))) + } + + /// Whether the caller sees what `event` is about. + /// + /// # Errors + /// + /// Fails like [`Reader::sees`]. + pub fn sees_event(&mut self, event: &EventKind) -> Result { + match event { + EventKind::CheckRequested { subject, .. } => self.sees(*subject), + event => self.sees(event.record()), + } + } +} + +/// The records a batch puts, read before those of the store it's put in, +/// so a record may refer to another of the same batch. +pub struct Batch<'a, P> { + store: &'a Store

, + /// Every object of the batch. + supplied: HashSet, + revisions: HashMap, + /// How many revisions of each change evolve from none. + roots: HashMap, + statements: HashMap, + /// The statements about each record, in batch order. + about: HashMap>, +} + +impl<'a, P: Platform> Batch<'a, P> { + /// The batch of `supplied`, whose records `decoded` are, put in `store`. + pub fn new( + store: &'a Store

, + supplied: HashSet, + decoded: &'a [(ObjectId, Object)], + ) -> Self { + let mut batch = Self { + store, + supplied, + revisions: HashMap::new(), + roots: HashMap::new(), + statements: HashMap::new(), + about: HashMap::new(), + }; + for (id, object) in decoded { + match object { + Object::Revision(revision) => { + batch.revisions.insert((*id).into(), revision); + if revision.predecessors.is_empty() { + *batch.roots.entry(revision.change).or_default() += 1; + } + } + Object::Statement(statement) => { + batch.statements.insert((*id).into(), statement); + batch + .about + .entry(statement.subject) + .or_default() + .push(statement); + } + _ => {} + } + } + batch + } + + /// Whether the batch holds `record`. + fn holds(&self, record: RecordId) -> bool { + self.supplied.contains(&record.into()) + } + + /// The statement `id`. + fn statement(&self, id: StatementId) -> Result { + match self.statements.get(&id) { + Some(&statement) => Ok(statement.clone()), + None => self.store.get(id), + } + } +} + +impl RevisionView for Batch<'_, P> { + fn revision(&self, id: RevisionId) -> Result { + match self.revisions.get(&id) { + Some(&revision) => Ok(revision.clone()), + None => self.store.get(id), + } + } +} + +/// Checks the records of a batch an authenticated caller puts, as the +/// module explains. +pub struct Writer<'a, P> { + batch: &'a Batch<'a, P>, + /// The statements and revisions of the store, as its index knows them. + annotations: &'a Annotations<'a, P>, + principal: &'a Principal, + rights: &'a Rights, + reader: Reader<'a, P>, + effects: Effects<'a, P, Batch<'a, P>>, +} + +impl<'a, P: Platform> Writer<'a, P> { + /// Checks `batch` for `principal`, with `rights`, finding what the + /// store has through `annotations`. + #[must_use] + pub fn new( + batch: &'a Batch<'a, P>, + annotations: &'a Annotations<'a, P>, + principal: &'a Principal, + rights: &'a Rights, + ) -> Self { + Self { + batch, + annotations, + principal, + rights, + reader: Reader::new(batch.store, &rights.paths), + effects: Effects::new(batch.store, batch, &rights.paths), + } + } + + /// Whether the caller sees `record`: the batch's new records are + /// theirs, but one the store had only if they saw it already. + fn sees(&mut self, record: RecordId) -> Result { + let new = self.batch.holds(record) && !self.batch.store.contains(record.into())?; + Ok(new || self.reader.sees(record)?) + } + + /// Checks that `attachment`, which a `record` carries, comes with the + /// batch if the store has it, unless the caller reads every path: so no + /// one reads an attachment by naming its id. + fn brings(&self, record: &'static str, attachment: ObjectId) -> Result<(), PutError> { + if self.rights.paths.reads_all() + || self.batch.supplied.contains(&attachment) + || !self.batch.store.contains(attachment)? + { + return Ok(()); + } + Err(Forbidden::UnsuppliedAttachment { record, attachment }.into()) + } + + /// Whether the caller may write every path, so no effect is checked. + fn writes_all(&self) -> bool { + self.rights.paths.writes_all() + } + + /// Checks a statement put. + /// + /// # Errors + /// + /// Fails if the caller isn't its issuer, may not issue its kind, or + /// doesn't see what it's about or the original it promotes; if it + /// replaces a `core.conflict` about a revision, by being one or + /// superseding one, that doesn't list exactly the conflicts the + /// revision carries where they may not write; or if a record cannot be + /// read. + pub fn statement(&mut self, statement: &Statement) -> Result<(), PutError> { + if statement.issuer != *self.principal { + return Err(Forbidden::NotYours { + record: "statement", + by: statement.issuer.clone(), + caller: self.principal.clone(), + } + .into()); + } + // Reserved kinds have authors of their own, which the service + // checks for every caller. + if !statement.kind.is_reserved() && !self.rights.may_annotate(&statement.kind) { + return Err(Forbidden::Annotate(statement.kind.clone()).into()); + } + let original = statement.promoted_from.map(RecordId::Statement); + for record in iter::once(statement.subject).chain(original) { + if !self.sees(record)? { + return Err(PutError::Unseen(record)); + } + } + // A promotion carries its original's, which they see. + if original.is_none() + && let Some(payload) = statement.payload + { + self.brings("statement", payload.into())?; + } + if !self.writes_all() { + self.keeps_listing(statement)?; + } + Ok(()) + } + + /// Checks that `statement`, if it replaces its issuer's `core.conflict` + /// about a revision, by being one or superseding one, lists exactly the + /// conflicts the revision carries where the caller may not write. + fn keeps_listing(&self, statement: &Statement) -> Result<(), PutError> { + let superseded = match statement.supersedes { + Some(id) => match self.batch.statement(id) { + Ok(superseded) => Some(superseded), + Err(StoreError::NotFound(_)) => None, + Err(error) => return Err(error.into()), + }, + None => None, + }; + let replaced = iter::once(statement) + .chain(&superseded) + .filter(|replaced| replaced.kind == Kind::CONFLICT); + for replaced in replaced { + let RecordId::Revision(id) = replaced.subject else { + continue; + }; + let revision = self.batch.revision(id)?; + let listed = + if statement.kind == Kind::CONFLICT && statement.subject == replaced.subject { + Conflicts::latest(self.batch.store, [statement], &revision)? + } else { + Conflicts::default() + }; + self.keeps_conflicts(&revision, &listed)?; + } + Ok(()) + } + + /// Checks a revision put, whose objects are written. + /// + /// # Errors + /// + /// Fails if the caller isn't its author; it adds a root to a change + /// that has revisions, or evolves from a revision of another change; + /// they don't see a revision it evolves from; it has an effect they may + /// not have; or it holds content they neither supplied nor share with + /// a snapshot it refers to; or if a tree cannot be read. + pub fn revision(&mut self, id: RevisionId, revision: &Revision) -> Result<(), PutError> { + if revision.author != *self.principal { + return Err(Forbidden::NotYours { + record: "revision", + by: revision.author.clone(), + caller: self.principal.clone(), + } + .into()); + } + let roots = self.batch.roots.get(&revision.change).copied(); + let stored = !self + .annotations + .index() + .revisions(revision.change) + .is_empty(); + if revision.predecessors.is_empty() && (stored || roots > Some(1)) { + return Err(Forbidden::History(revision.change).into()); + } + let mut references = vec![self.effects.base_snapshot(revision.base)?]; + for &id in &revision.predecessors { + if !self.sees(RecordId::Revision(id))? { + return Err(PutError::Unseen(RecordId::Revision(id))); + } + let predecessor = self.batch.revision(id)?; + if predecessor.change != revision.change { + return Err(Forbidden::History(revision.change).into()); + } + references.push(predecessor.snapshot); + } + if let Some(forbidden) = self.effects.check(revision)? { + return Err(forbidden.into()); + } + if !self.writes_all() { + let listed = self.conflicts(id, revision)?; + self.keeps_conflicts(revision, &listed)?; + } + self.brings("revision", revision.description.into())?; + if !self.rights.paths.reads_all() { + let store = self.batch.store; + let unsupplied = + store.unsupplied(revision.snapshot, &references, &self.batch.supplied)?; + if let Some(path) = unsupplied { + return Err(Forbidden::Unsupplied { revision: id, path }.into()); + } + } + Ok(()) + } + + /// The unresolved conflicts of `revision`, `id`, as its author's latest + /// `core.conflict` about it lists them, the store's then the batch's. + fn conflicts(&self, id: RevisionId, revision: &Revision) -> Result { + let subject = RecordId::Revision(id); + let stored = self.annotations.claims(subject)?; + let batched = self + .batch + .about + .get(&subject) + .into_iter() + .flatten() + .copied(); + let statements = stored.iter().map(Claim::statement).chain(batched); + Conflicts::latest(self.batch.store, statements, revision) + } + + /// Checks that `listed`, the unresolved conflicts of `revision`, are + /// exactly those it carries where the caller may not write: its + /// predecessors', or its base revision's for a root, where `revision` + /// still has the entry written. Only a writer of a path resolves its + /// conflict, or adds one. + fn keeps_conflicts(&self, revision: &Revision, listed: &Conflicts) -> Result<(), PutError> { + let from = match (&revision.predecessors[..], revision.base) { + ([], Base::Revision(base)) => vec![base], + (predecessors, _) => predecessors.to_vec(), + }; + let unwritable = |path: &RepoPath| !self.rights.paths.writes(path); + let mut carried = BTreeSet::new(); + for id in from { + let from = self.batch.revision(id)?; + let kept = self + .conflicts(id, &from)? + .only(unwritable) + .remaining(self.batch.store, revision.snapshot)?; + carried.extend(kept.paths()); + } + let listed: BTreeSet = listed + .clone() + .only(unwritable) + .paths() + .into_iter() + .collect(); + if let Some(path) = carried.difference(&listed).next() { + return Err(Forbidden::Resolve(path.clone()).into()); + } + if let Some(path) = listed.difference(&carried).next() { + return Err(Forbidden::Conflict(path.clone()).into()); + } + Ok(()) + } +} + +/// What revisions do where their writer may not write, as the module +/// explains: the service checks it of every revision a signed-in caller +/// puts, and a client of those it pushes, before sending anything. +pub struct Effects<'a, P, V> { + store: &'a Store

, + /// Where revisions are read: a batch's, then the store's. + revisions: &'a V, + /// What the writer may do with each path. + paths: &'a PathAccess, +} + +impl<'a, P: Platform, V: RevisionView> Effects<'a, P, V> { + /// The effects of revisions of `store`, read from `revisions`, for a + /// writer who may do `paths`. + pub fn new(store: &'a Store

, revisions: &'a V, paths: &'a PathAccess) -> Self { + Self { + store, + revisions, + paths, + } + } + + /// The snapshot of `base`. + fn base_snapshot(&self, base: Base) -> Result { + match base { + Base::Revision(id) => Ok(self.revisions.revision(id)?.snapshot), + Base::Acceptance(_) => self.store.base_snapshot(base), + } + } + + /// The effect of `revision`: each file it changes where the writer + /// may not write, from its base's entry to its own, sorted by path. + fn effect(&self, revision: &Revision) -> Result, StoreError> { + let base = self.base_snapshot(revision.base)?; + let mut changes = self.store.diff(base, revision.snapshot)?; + changes.retain(|change| !self.paths.writes(&change.path)); + Ok(changes) + } + + /// What the writer may not do of what `revision` does, if anything: a + /// root may have no effect, and a successor has exactly each + /// predecessor's, on the same change below, if it's on one. + /// + /// # Errors + /// + /// Fails if a revision or tree cannot be read. + pub fn check(&self, revision: &Revision) -> Result, StoreError> { + if self.paths.writes_all() { + return Ok(None); + } + let ours = self.effect(revision)?; + if revision.predecessors.is_empty() { + return Ok(ours + .first() + .map(|change| Forbidden::Write(change.path.clone()))); + } + let below = match revision.base { + Base::Revision(below) => Some(self.revisions.revision(below)?.change), + Base::Acceptance(_) => None, + }; + for &id in &revision.predecessors { + let predecessor = self.revisions.revision(id)?; + if let Some(below) = below { + let theirs = match predecessor.base { + Base::Revision(theirs) => Some(self.revisions.revision(theirs)?.change), + Base::Acceptance(_) => None, + }; + if theirs != Some(below) { + return Ok(Some(Forbidden::Below(below))); + } + } + let theirs = self.effect(&predecessor)?; + // Both are sorted by path: the first that differs is a path one + // lacks, or changes otherwise. + let differs = ours + .iter() + .zip(&theirs) + .find(|(ours, theirs)| ours != theirs) + .map(|(ours, theirs)| ours.path.clone().min(theirs.path.clone())) + .or_else(|| { + let longer = if ours.len() > theirs.len() { + &ours + } else { + &theirs + }; + let path = longer.get(ours.len().min(theirs.len())); + path.map(|change| change.path.clone()) + }); + if let Some(path) = differs { + return Ok(Some(Forbidden::Write(path))); + } + } + Ok(None) + } +} + +/// Something a caller may not do. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum Forbidden { + /// An anonymous caller tried to write. + #[error("anonymous callers only read: sign in with a token")] + Anonymous, + /// The owner has no principal to issue a token for. + #[error("only a signed-in caller gets a token, for themselves")] + NoPrincipal, + /// A record put is attributed to someone else. + #[error("the {record} is by {by}, but you are {caller}")] + NotYours { + /// The kind of record. + record: &'static str, + /// Who it names. + by: Principal, + /// The caller. + caller: Principal, + }, + /// A statement of a kind the caller may not issue. + #[error("you may not issue `{0}` statements")] + Annotate(Kind), + /// A change to a path the caller may not write. + #[error("you may not change `{0}`")] + Write(RepoPath), + /// A conflict at a path the caller may not write, which a revision or + /// statement of theirs would resolve. + #[error("you may not resolve the conflict at `{0}`")] + Resolve(RepoPath), + /// A conflict at a path the caller may not write, which a revision or + /// statement of theirs would list though none it evolves from does. + #[error("you may not add a conflict at `{0}`")] + Conflict(RepoPath), + /// A revision that would add a root to a change with revisions, or + /// evolve from a revision of another change. + #[error( + "a new revision of change {} must evolve from its revisions, and only from them", + .0.short() + )] + History(ChangeId), + /// A successor moved onto a change it wasn't on, which would then land + /// with it. + #[error("you may not move a change you took over onto change {}", .0.short())] + Below(ChangeId), + /// A path the caller may not read. + #[error("you may not read `{0}`")] + Read(RepoPath), + /// A revision holds content its writer didn't supply, at a path where + /// no snapshot it refers to has it. + #[error( + "revision {} holds content at `{path}` that it neither brings nor shares with a \ + snapshot it refers to", + revision.short() + )] + Unsupplied { + /// The revision. + revision: RevisionId, + /// The path. + path: RepoPath, + }, + /// A record carries an attachment the repository has, as a revision's + /// description or a statement's payload, that its writer didn't bring. + #[error("the {record} carries attachment {attachment}, which it doesn't bring")] + UnsuppliedAttachment { + /// The kind of record. + record: &'static str, + /// The attachment. + attachment: ObjectId, + }, + /// A target the caller may not land on. + #[error("you may not land on `{0}`")] + Land(TargetName), + /// A token's scope reaches beyond its issuer's rights. + #[error("a token may only narrow your rights, which don't include `{0}`")] + Scope(Capability), +} + +/// A failure to check a record put. +#[derive(Debug, thiserror::Error)] +pub enum PutError { + /// The caller may not put it. + #[error(transparent)] + Forbidden(#[from] Forbidden), + /// A statement is about a record the caller doesn't see, or promotes + /// one: to them, it doesn't exist. + #[error("record {0} not found")] + Unseen(RecordId), + /// A record or tree cannot be read. + #[error(transparent)] + Store(#[from] StoreError), +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use origin_platform::Native; + use origin_shared::api::{FetchSnapshot, LandRequest}; + use origin_shared::landing::{Landing, Proposal}; + use origin_shared::pack::Pack; + use origin_shared::{ + AttachmentId, Base, ChangeId, EncodedObject, EntryKind, EntryName, NodeKey, ObjectKind, + Record, Revision, Statement, Tree, TreeEntry, TreeId, + }; + + use super::*; + use crate::conflict::Conflicts; + use crate::queue::tests::Repo; + use crate::service::{Node, OriginService, ServiceError}; + use crate::tree::Snapshots; + use crate::tree::tests::build; + + /// Ada writes everything and changes the rules. Eve reads all but + /// `secrets`, writes only `src`, and issues reviews and checks. Both + /// land on `main`. + const RULES: &str = r#" +[targets.main] +authority = "node:queue" + +[grants] +"user:ada" = ["admin", "land *"] +"user:eve" = ["land *", "annotate review", "annotate ci.*"] + +[[paths]] +path = "/" +grants = { all = "read", "user:ada" = "write" } + +[[paths]] +path = "src" +grants = { "user:eve" = "write" } + +[[paths]] +path = "secrets" +grants = { all = "none", "user:ada" = "write" } +"#; + + /// The repository, with `main` holding a README, a library, a secret + /// and a protected file, and its service. + struct Fixture { + repo: Repo, + service: OriginService, + } + + impl Fixture { + fn new() -> Self { + let repo = Repo::new(RULES); + repo.change_on_main( + 1, + &[ + ("README.md", "hello"), + ("src/lib.rs", "fn a() {}"), + ("secrets/key", "hunter2"), + ("protected/file", "keep"), + ], + ); + repo.land(1); + let node = Node { + name: "queue".parse().unwrap(), + key: Arc::new(NodeKey::from_bytes(&[7; 32])), + }; + let service = OriginService::new(repo.store.clone(), node); + Self { repo, service } + } + + fn tip(&self, target: &TargetName) -> Base { + Base::Acceptance(self.repo.tip(target).acceptance) + } + + fn state(&self, target: &TargetName) -> TreeId { + self.repo.store.base_snapshot(self.tip(target)).unwrap() + } + + /// The objects of a snapshot of `files`, and its id, without + /// writing them. + fn objects(files: &[(&str, &str)]) -> (TreeId, Vec) { + let dir = tempfile::tempdir().unwrap(); + let scratch = Store::new(dir.path().join(".origin"), Native::default()); + scratch.init().unwrap(); + let blobs: Vec<_> = files + .iter() + .map(|(_, content)| EncodedObject::blob(content.as_bytes())) + .collect(); + scratch.write_all(&blobs).unwrap(); + let tree = build(&scratch, files); + (tree, scratch.read_all(&scratch.ids().unwrap()).unwrap()) + } + + /// A revision of `change` by `author` with `snapshot` on `base`, and + /// its description. + fn revision( + change: u8, + author: &str, + base: Base, + predecessors: Vec, + snapshot: TreeId, + ) -> (RevisionId, Vec) { + let description = EncodedObject::attachment(b"work"); + let revision = Revision { + change: ChangeId::from_bytes([change; 16]), + predecessors, + base, + deps: Vec::new(), + snapshot, + description: description.id().into(), + author: author.parse().unwrap(), + } + .encode(); + (revision.id().into(), vec![description, revision]) + } + + fn put(&self, caller: &Caller, objects: Vec) -> Result<(), ServiceError> { + self.service + .put_records(caller, Pack { objects }) + .map(|_| ()) + } + + fn land(&self, caller: &Caller, proposal: Proposal) -> Result, ServiceError> { + let request = LandRequest { + proposal, + onto: Some(TargetName::MAIN), + }; + Ok(self.service.land(caller, &request)?.landings) + } + + /// What `main` holds at `path`. + fn at_main(&self, path: &str) -> Option { + Snapshots::new(&self.repo.store) + .entry_at(self.state(&TargetName::MAIN), &RepoPath::new(path).unwrap()) + .unwrap() + } + } + + fn caller(principal: &str) -> Caller { + Caller::Principal { + principal: principal.parse().unwrap(), + scope: None, + } + } + + fn forbidden(result: Result) -> Forbidden { + match result { + Err( + ServiceError::Forbidden(forbidden) + | ServiceError::Land(crate::queue::LandError::Forbidden(forbidden)), + ) => forbidden, + other => panic!("expected a refusal, got {other:?}"), + } + } + + fn path(path: &str) -> RepoPath { + RepoPath::new(path).unwrap() + } + + #[test] + fn records_never_carry_a_file_as_their_attachment() { + let fixture = Fixture::new(); + let eve = caller("user:eve"); + let secret = fixture.at_main("secrets/key").unwrap().id; + + // A revision naming the secret's blob as its description is refused: + // descriptions are attachments, which no file is. + let base = fixture.tip(&TargetName::MAIN); + let crafted = Revision { + change: ChangeId::from_bytes([2; 16]), + predecessors: Vec::new(), + base, + deps: Vec::new(), + snapshot: fixture.state(&TargetName::MAIN), + description: AttachmentId::from(secret), + author: "user:eve".parse().unwrap(), + } + .encode(); + let error = fixture.put(&eve, vec![crafted.clone()]).unwrap_err(); + assert!( + matches!( + error, + ServiceError::WrongKind { + kind: ObjectKind::Attachment, + .. + } + ), + "{error}" + ); + let statement = Statement { + subject: RecordId::Revision(fixture.repo.change_on_main(3, &[])), + kind: Kind::new("review").unwrap(), + verdict: None, + issuer: "user:eve".parse().unwrap(), + inputs: None, + payload: Some(AttachmentId::from(secret)), + supersedes: None, + promoted_from: None, + } + .encode(); + let error = fixture.put(&eve, vec![statement]).unwrap_err(); + assert!( + matches!( + error, + ServiceError::WrongKind { + kind: ObjectKind::Attachment, + .. + } + ), + "{error}" + ); + + // And had one got into the store, fetching it never brings the file. + fixture + .repo + .store + .write_all(std::slice::from_ref(&crafted)) + .unwrap(); + let fetched = fixture.service.records(&eve, &[crafted.id()]).unwrap(); + assert_eq!(fetched.pack.objects, [crafted]); + } + + #[test] + fn pushes_cannot_place_unreadable_objects_where_they_read() { + let fixture = Fixture::new(); + let eve = caller("user:eve"); + let base = fixture.tip(&TargetName::MAIN); + let state = fixture.state(&TargetName::MAIN); + let secret = fixture.at_main("secrets/key").unwrap(); + let store = &fixture.repo.store; + + // `main`, with the secret's blob placed in `src`, which eve writes. + let mut src: Tree = store + .get(fixture.at_main("src").unwrap().subtree().unwrap()) + .unwrap(); + src.insert(EntryName::new("stolen").unwrap(), secret); + let src = src.encode(); + let mut root: Tree = store.get(state).unwrap(); + let entry = TreeEntry { + kind: EntryKind::Tree, + id: src.id(), + }; + root.insert(EntryName::new("src").unwrap(), entry); + let root = root.encode(); + let (id, records) = Fixture::revision(2, "user:eve", base, Vec::new(), root.id().into()); + let mut objects = vec![src, root]; + objects.extend(records); + let error = forbidden(fixture.put(&eve, objects)); + assert_eq!( + error, + Forbidden::Unsupplied { + revision: id, + path: path("src/stolen"), + } + ); + assert!(!store.contains(id.into()).unwrap()); + + // Nor walk a tree within a snapshot as if it were one. + let request = FetchSnapshot { + of: RecordId::Snapshot(fixture.at_main("secrets").unwrap().subtree().unwrap()), + have: Vec::new(), + paths: Vec::new(), + skip: 0, + }; + assert!(matches!( + fixture.service.snapshot(&eve, &request), + Err(ServiceError::NotASnapshot(_)) + )); + + // Content eve brings herself is hers to place. + let mut files = vec![("README.md", "hello"), ("src/lib.rs", "fn a() {}")]; + files.push(("src/own.rs", "hunter2")); + let (tree, mut objects) = Fixture::objects(&files); + // The paths eve can't read stay as they are. + let (tree, trees) = { + let mut root: Tree = objects + .iter() + .find(|object| object.id() == tree.into()) + .unwrap() + .decode_as() + .unwrap(); + for kept in ["secrets", "protected"] { + root.insert( + EntryName::new(kept).unwrap(), + fixture.at_main(kept).unwrap(), + ); + } + let root = root.encode(); + (TreeId::from(root.id()), vec![root]) + }; + objects.extend(trees); + let (_, records) = Fixture::revision(3, "user:eve", base, Vec::new(), tree); + objects.extend(records); + fixture.put(&eve, objects).unwrap(); + } + + #[test] + fn landings_never_change_what_the_submitter_cannot_write() { + let fixture = Fixture::new(); + let (ada, eve) = (caller("user:ada"), caller("user:eve")); + let first = fixture.tip(&TargetName::MAIN); + let old_state = fixture.state(&TargetName::MAIN); + let repo = &fixture.repo; + repo.change_on_main(2, &[("protected/file", "v2")]); + repo.land(2); + + // A revision made on an old acceptance doesn't revert what changed + // since: only its own changes land. + let mut files = vec![ + ("README.md", "hello"), + ("src/lib.rs", "fn a() {}"), + ("secrets/key", "hunter2"), + ("protected/file", "keep"), + ]; + files.push(("src/new.rs", "fn b() {}")); + let (old_base, _) = (repo.revision(3, &[], first, &files), old_state); + let landed = fixture + .land(&eve, Proposal::Change(ChangeId::from_bytes([3; 16]))) + .unwrap(); + assert!( + matches!(landed[..], [Landing::Landed { .. }]), + "{old_base} {landed:?}" + ); + let v2 = EncodedObject::blob(b"v2").id(); + assert_eq!(fixture.at_main("protected/file").unwrap().id, v2); + + // A revision someone else made that deletes the file: eve can't + // land it, ada can. + files.retain(|(path, _)| *path != "protected/file"); + repo.revision(4, &[], fixture.tip(&TargetName::MAIN), &files); + let change = Proposal::Change(ChangeId::from_bytes([4; 16])); + assert_eq!( + forbidden(fixture.land(&eve, change.clone())), + Forbidden::Write(path("protected/file")) + ); + assert!(fixture.at_main("protected/file").is_some()); + + // Nor smuggle the deletion in through another target. + let side = TargetName::new("side").unwrap(); + repo.queue() + .create_target(&side, &TargetName::MAIN) + .unwrap(); + repo.queue() + .land_change( + &mut repo.annotations(), + ChangeId::from_bytes([4; 16]), + &side, + ) + .unwrap(); + let from_side = Proposal::Target(side); + assert_eq!( + forbidden(fixture.land(&eve, from_side.clone())), + Forbidden::Write(path("protected/file")) + ); + let landed = fixture.land(&ada, from_side).unwrap(); + assert!(matches!(landed[..], [Landing::Landed { .. }]), "{landed:?}"); + assert!(fixture.at_main("protected/file").is_none()); + } + + #[test] + fn converging_cannot_smuggle_others_changes() { + let fixture = Fixture::new(); + let eve = caller("user:eve"); + let repo = &fixture.repo; + let base = fixture.tip(&TargetName::MAIN); + let files = [ + ("README.md", "hello"), + ("src/lib.rs", "fn a() {}"), + ("secrets/key", "hunter2"), + ]; + let first = repo.revision( + 5, + &[], + base, + &[&files[..], &[("protected/file", "keep")]].concat(), + ); + // Ada's head edits the protected file, eve's head `src`. + let ada_head = repo.revision( + 5, + &[first], + base, + &[&files[..], &[("protected/file", "ada")]].concat(), + ); + let mut eves = files.to_vec(); + eves[1] = ("src/lib.rs", "fn eve() {}"); + eves.push(("protected/file", "keep")); + let eve_head = repo.revision(5, &[first], base, &eves); + + // Eve converges them, merging ada's edit in. + let merged = repo + .store + .merge( + Some(repo.store.get(first).unwrap().snapshot), + repo.store.get(eve_head).unwrap().snapshot, + repo.store.get(ada_head).unwrap().snapshot, + ) + .unwrap(); + let (id, records) = + Fixture::revision(5, "user:eve", base, vec![ada_head, eve_head], merged.tree); + let mut objects = merged.trees.clone(); + objects.extend(records.clone()); + assert_eq!( + forbidden(fixture.put(&eve, objects)), + Forbidden::Write(path("protected/file")) + ); + + // Had someone else written it, eve still couldn't land it. + repo.store.write_all(&merged.trees).unwrap(); + repo.store.write_all(&records).unwrap(); + assert_eq!( + repo.annotations() + .index() + .heads(ChangeId::from_bytes([5; 16])), + [id] + ); + assert_eq!( + forbidden(fixture.land(&eve, Proposal::Change(ChangeId::from_bytes([5; 16])))), + Forbidden::Write(path("protected/file")) + ); + } + + /// `main`'s files, with `edits`. + fn main_with(edits: &[(&'static str, &'static str)]) -> Vec<(&'static str, &'static str)> { + let main = [ + ("README.md", "hello"), + ("src/lib.rs", "fn a() {}"), + ("secrets/key", "hunter2"), + ("protected/file", "keep"), + ]; + main.iter() + .map(|&(at, content)| { + let edit = edits.iter().find(|(edited, _)| *edited == at); + edit.copied().unwrap_or((at, content)) + }) + .collect() + } + + #[test] + fn successors_have_their_predecessors_effect_where_their_writer_cannot_write() { + let fixture = Fixture::new(); + let (eve, repo) = (caller("user:eve"), &fixture.repo); + let base = fixture.tip(&TargetName::MAIN); + let succeed = |predecessors: Vec, base, files: &[(&str, &str)]| { + let (tree, mut objects) = Fixture::objects(files); + let (id, records) = Fixture::revision(30, "user:eve", base, predecessors, tree); + objects.extend(records); + fixture.put(&eve, objects).map(|()| id) + }; + + // A successor of ada's change of the README and the library never + // undoes the README, which eve doesn't write. + let edits = [("README.md", "ada"), ("src/lib.rs", "fn ada() {}")]; + let adas = repo.revision(30, &[], base, &main_with(&edits)); + let undone = main_with(&[("src/lib.rs", "fn ada() {}")]); + assert_eq!( + forbidden(succeed(vec![adas], base, &undone)), + Forbidden::Write(path("README.md")) + ); + + // Eve takes the change over, keeping the README as ada changed it, + // and changes the library, which she writes. + let edits = [("README.md", "ada"), ("src/lib.rs", "fn eve() {}")]; + let taken = succeed(vec![adas], base, &main_with(&edits)).unwrap(); + + // Rebased onto a tip where the protected file moved, the successor + // differs from its predecessor there, as their bases do: that's + // fine. + repo.change_on_main(31, &[("protected/file", "newer")]); + repo.land(31); + let tip = fixture.tip(&TargetName::MAIN); + let rebased = [&edits[..], &[("protected/file", "newer")]].concat(); + let rebased = succeed(vec![taken], tip, &main_with(&rebased)).unwrap(); + + // Two successors with that effect converge into one. + let edits = [("README.md", "ada"), ("src/lib.rs", "fn other() {}")]; + let other = succeed(vec![taken], base, &main_with(&edits)).unwrap(); + let edits = [("README.md", "ada"), ("src/lib.rs", "fn both() {}")]; + succeed(vec![rebased, other], base, &main_with(&edits)).unwrap(); + let landed = fixture + .land( + &caller("user:ada"), + Proposal::Change(ChangeId::from_bytes([30; 16])), + ) + .unwrap(); + assert!(matches!(landed[..], [Landing::Landed { .. }]), "{landed:?}"); + assert_eq!( + fixture.at_main("README.md").unwrap().id, + EncodedObject::blob(b"ada").id() + ); + } + + /// The probes of the third review: a successor that keeps ada's files, + /// but on a base that already has her edits, so it changes nothing. + #[test] + fn no_base_makes_a_successor_drop_its_predecessors_effect() { + let fixture = Fixture::new(); + let repo = &fixture.repo; + let reader = scoped("user:eve", &["read /"], in_a_week()); + let base = fixture.tip(&TargetName::MAIN); + let state = fixture.state(&TargetName::MAIN); + let edits = [("README.md", "ada"), ("src/lib.rs", "fn ada() {}")]; + let adas = repo.revision(40, &[], base, &main_with(&edits)); + let snapshot = repo.store.get(adas).unwrap().snapshot; + let succeed = |base, predecessors| { + let (id, records) = Fixture::revision(40, "user:eve", base, predecessors, snapshot); + fixture.put(&reader, records).map(|()| id) + }; + + // On ada's head itself. + assert_eq!( + forbidden(succeed(Base::Revision(adas), vec![adas])), + Forbidden::Below(ChangeId::from_bytes([40; 16])) + ); + // On a revision of another change, of eve's, on ada's head. + let (on_adas, records) = + Fixture::revision(41, "user:eve", Base::Revision(adas), Vec::new(), snapshot); + fixture.put(&reader, records).unwrap(); + assert_eq!( + forbidden(succeed(Base::Revision(on_adas), vec![adas])), + Forbidden::Below(ChangeId::from_bytes([41; 16])) + ); + // On a tip where another change landed the same files. + repo.change_on_main(42, &edits); + repo.land(42); + let tip = fixture.tip(&TargetName::MAIN); + assert_eq!( + forbidden(succeed(tip, vec![adas])), + Forbidden::Write(path("README.md")) + ); + + // Taking the head over as it is is fine, but chains nothing back: + // the effect stays ada's. + let taken = succeed(base, vec![adas]).unwrap(); + let (_, records) = Fixture::revision(40, "user:eve", base, vec![taken], state); + assert_eq!( + forbidden(fixture.put(&reader, records)), + Forbidden::Write(path("README.md")) + ); + } + + #[test] + fn changes_keep_their_history() { + let fixture = Fixture::new(); + let repo = &fixture.repo; + let reader = scoped("user:eve", &["read /"], in_a_week()); + let base = fixture.tip(&TargetName::MAIN); + let state = fixture.state(&TargetName::MAIN); + let change = ChangeId::from_bytes([43; 16]); + let adas = repo.revision(43, &[], base, &main_with(&[("README.md", "ada")])); + let snapshot = repo.store.get(adas).unwrap().snapshot; + + // No second root, which would make the change diverge, and eve + // one of its creators. + let (_, records) = Fixture::revision(43, "user:eve", base, Vec::new(), state); + assert_eq!( + forbidden(fixture.put(&reader, records)), + Forbidden::History(change) + ); + // Nor a successor evolving from a root of another change. + let (eves, records) = Fixture::revision(44, "user:eve", base, Vec::new(), state); + fixture.put(&reader, records).unwrap(); + let (_, records) = Fixture::revision(43, "user:eve", base, vec![adas, eves], snapshot); + assert_eq!( + forbidden(fixture.put(&reader, records)), + Forbidden::History(change) + ); + // Nor two roots of a new change in one batch. + let (_, mut records) = Fixture::revision(45, "user:ada", base, Vec::new(), state); + records.extend(Fixture::revision(45, "user:ada", base, Vec::new(), snapshot).1); + assert_eq!( + forbidden(fixture.put(&caller("user:ada"), records)), + Forbidden::History(ChangeId::from_bytes([45; 16])) + ); + + // So ada still created hers, and abandons it. + let abandon = statement(RecordId::Revision(adas), "core.abandon", "user:ada").encode(); + fixture.put(&caller("user:ada"), vec![abandon]).unwrap(); + let landed = fixture.land(&caller("user:ada"), Proposal::Change(change)); + assert!( + matches!( + landed, + Err(ServiceError::Land(crate::queue::LandError::Abandoned(_))) + ), + "{landed:?}" + ); + } + + #[test] + fn successors_resolve_no_conflict_their_writer_cannot_write() { + let fixture = Fixture::new(); + let (eve, repo) = (caller("user:eve"), &fixture.repo); + let base = fixture.tip(&TargetName::MAIN); + // Ada's revision holds a conflict in the README, and lists it. + let marked = main_with(&[("README.md", "<<<<<<< ours")]); + let adas = repo.revision(46, &[], base, &marked); + let snapshot = repo.store.get(adas).unwrap().snapshot; + let readme = Snapshots::new(&repo.store) + .entry_at(snapshot, &path("README.md")) + .unwrap(); + let conflicts = Conflicts::new(vec![crate::conflict::Conflicted { + path: path("README.md"), + written: readme.map(|entry| entry.id), + }]); + let listing = |subject, issuer: &str, supersedes| { + let (payload, statement) = conflicts.statement( + RecordId::Revision(subject), + issuer.parse().unwrap(), + supersedes, + ); + (payload, statement.encode()) + }; + let (payload, adas_listing) = listing(adas, "user:ada", None); + repo.store.write_all(&[payload, adas_listing]).unwrap(); + + // Eve takes it over as it is: without the conflict, she'd resolve it. + let (taken, records) = Fixture::revision(46, "user:eve", base, vec![adas], snapshot); + assert_eq!( + forbidden(fixture.put(&eve, records.clone())), + Forbidden::Resolve(path("README.md")) + ); + let (payload, kept) = listing(taken, "user:eve", None); + let kept_id = StatementId::from(kept.id()); + fixture + .put(&eve, [vec![payload, kept], records].concat()) + .unwrap(); + + // Nor does she supersede that list with one without it, or with + // anything else. + let (payload, empty) = Conflicts::default().statement( + RecordId::Revision(taken), + "user:eve".parse().unwrap(), + Some(kept_id), + ); + assert_eq!( + forbidden(fixture.put(&eve, vec![payload, empty.encode()])), + Forbidden::Resolve(path("README.md")) + ); + let review = Statement { + supersedes: Some(kept_id), + ..statement(RecordId::Revision(taken), "review", "user:eve") + }; + assert_eq!( + forbidden(fixture.put(&eve, vec![review.encode()])), + Forbidden::Resolve(path("README.md")) + ); + let landed = fixture.land( + &caller("user:ada"), + Proposal::Change(ChangeId::from_bytes([46; 16])), + ); + assert!( + matches!( + landed, + Err(ServiceError::Land( + crate::queue::LandError::Unresolved { .. } + )) + ), + "{landed:?}" + ); + } + + #[test] + fn revisions_add_no_conflict_their_writer_cannot_write() { + let fixture = Fixture::new(); + let (eve, base) = (caller("user:eve"), fixture.tip(&TargetName::MAIN)); + let mallory = scoped("user:mallory", &["read /"], in_a_week()); + let change = ChangeId::from_bytes([47; 16]); + let (tree, mut objects) = Fixture::objects(&main_with(&[("src/lib.rs", "fn eve() {}")])); + let (eves, records) = Fixture::revision(47, "user:eve", base, Vec::new(), tree); + objects.extend(records); + fixture.put(&eve, objects).unwrap(); + let listing = |subject, issuer: &str| { + let readme = fixture.at_main("README.md").map(|entry| entry.id); + let conflicts = Conflicts::new(vec![crate::conflict::Conflicted { + path: path("README.md"), + written: readme, + }]); + let (payload, statement) = + conflicts.statement(RecordId::Revision(subject), issuer.parse().unwrap(), None); + vec![payload, statement.encode()] + }; + // Mallory, who only reads, takes eve's change over as it is, and + // lists a conflict at the README, which eve doesn't write: then + // eve could never land it, nor resolve it. + let (mallorys, mut records) = Fixture::revision(47, "user:mallory", base, vec![eves], tree); + records.extend(listing(mallorys, "user:mallory")); + assert_eq!( + forbidden(fixture.put(&mallory, records)), + Forbidden::Conflict(path("README.md")) + ); + // Nor does a root list one its base doesn't have. + let (root, mut records) = Fixture::revision(48, "user:eve", base, Vec::new(), tree); + records.extend(listing(root, "user:eve")); + assert_eq!( + forbidden(fixture.put(&eve, records)), + Forbidden::Conflict(path("README.md")) + ); + let landed = fixture.land(&eve, Proposal::Change(change)).unwrap(); + assert!(matches!(landed[..], [Landing::Landed { .. }]), "{landed:?}"); + + // A root on a revision carries its conflicts. + let marked = main_with(&[("README.md", "<<<<<<< ours")]); + let adas = fixture.repo.revision(49, &[], base, &marked); + let snapshot = fixture.repo.store.get(adas).unwrap().snapshot; + let readme = Snapshots::new(&fixture.repo.store) + .entry_at(snapshot, &path("README.md")) + .unwrap(); + let conflicts = Conflicts::new(vec![crate::conflict::Conflicted { + path: path("README.md"), + written: readme.map(|entry| entry.id), + }]); + let carry = |subject, issuer: &str| { + let (payload, statement) = + conflicts.statement(RecordId::Revision(subject), issuer.parse().unwrap(), None); + vec![payload, statement.encode()] + }; + fixture + .repo + .store + .write_all(&carry(adas, "user:ada")) + .unwrap(); + let on_adas = Base::Revision(adas); + let (root, records) = Fixture::revision(50, "user:eve", on_adas, Vec::new(), snapshot); + assert_eq!( + forbidden(fixture.put(&eve, records.clone())), + Forbidden::Resolve(path("README.md")) + ); + fixture + .put(&eve, [records, carry(root, "user:eve")].concat()) + .unwrap(); + } + + #[test] + fn only_those_who_see_a_revision_succeed_it() { + let fixture = Fixture::new(); + let adas = fixture + .repo + .change_on_main(30, &[("secrets/key", "rotated")]); + let base = fixture.tip(&TargetName::MAIN); + // Eve, who doesn't read `secrets`, makes the head of ada's change + // her own, changing nothing, to abandon it. + let state = fixture.state(&TargetName::MAIN); + let (eves, mut objects) = Fixture::revision(30, "user:eve", base, vec![adas], state); + objects.push(statement(RecordId::Revision(eves), "core.abandon", "user:eve").encode()); + let blind = scoped("user:eve", &[], in_a_week()); + assert!(fixture.put(&blind, objects.clone()).is_err()); + let put = fixture.put(&caller("user:eve"), objects); + assert!( + matches!(put, Err(ServiceError::UnknownRecord(record)) if record == RecordId::Revision(adas)), + "{put:?}" + ); + let change = Proposal::Change(ChangeId::from_bytes([30; 16])); + let landed = fixture.land(&caller("user:ada"), change).unwrap(); + assert!(matches!(landed[..], [Landing::Landed { .. }]), "{landed:?}"); + } + + #[test] + fn tokens_narrowed_out_of_admin_abandon_nothing() { + let fixture = Fixture::new(); + let base = fixture.tip(&TargetName::MAIN); + let state = fixture.state(&TargetName::MAIN); + let (eves, records) = Fixture::revision(31, "user:eve", base, Vec::new(), state); + fixture.put(&caller("user:eve"), records).unwrap(); + let abandon = statement(RecordId::Revision(eves), "core.abandon", "user:ada").encode(); + let reader = scoped("user:ada", &["read /"], in_a_week()); + let refused = fixture.put(&reader, vec![abandon.clone()]).unwrap_err(); + assert_eq!(refused.code(), origin_shared::api::ErrorCode::Forbidden); + let admin = scoped("user:ada", &["admin", "read /"], in_a_week()); + fixture.put(&admin, vec![abandon]).unwrap(); + } + + /// A statement by `issuer` of `kind` about `subject`. + fn statement(subject: RecordId, kind: &str, issuer: &str) -> Statement { + Statement { + subject, + kind: Kind::new(kind).unwrap(), + verdict: None, + issuer: issuer.parse().unwrap(), + inputs: None, + payload: None, + supersedes: None, + promoted_from: None, + } + } + + /// `caller`, with a token narrowing them to `scope`, expiring at + /// `expires`. + fn scoped(principal: &str, scope: &[&str], expires: u64) -> Caller { + Caller::Principal { + principal: principal.parse().unwrap(), + scope: Some(Scope { + capabilities: scope.iter().map(|c| c.parse().unwrap()).collect(), + expires, + }), + } + } + + /// A week from now, in seconds since the Unix epoch. + fn in_a_week() -> u64 { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH); + (now.unwrap() + OriginService::MAX_TOKEN_TTL).as_secs() + } + + #[test] + fn statements_are_attributed_to_their_issuer() { + let fixture = Fixture::new(); + let revision = RecordId::Revision(fixture.repo.change_on_main(2, &[])); + let statement = |issuer: &str, kind: &str| statement(revision, kind, issuer).encode(); + let eve = caller("user:eve"); + assert!(matches!( + forbidden(fixture.put(&eve, vec![statement("user:ada", "review")])), + Forbidden::NotYours { .. } + )); + fixture + .put(&eve, vec![statement("user:eve", "review")]) + .unwrap(); + let scoped = scoped("user:eve", &["annotate ci.*"], in_a_week()); + assert_eq!( + forbidden(fixture.put(&scoped, vec![statement("user:eve", "lint")])), + Forbidden::Annotate(Kind::new("lint").unwrap()) + ); + assert_eq!( + forbidden(fixture.put(&Caller::Anonymous, vec![statement("user:eve", "x")])), + Forbidden::Anonymous + ); + } + + #[test] + fn statements_are_about_what_their_issuer_sees_and_annotates() { + let fixture = Fixture::new(); + let repo = &fixture.repo; + let hidden = RecordId::Revision(repo.change_on_main(2, &[("secrets/key", "new")])); + let visible = RecordId::Revision(repo.change_on_main(3, &[("src/lib.rs", "fn b() {}")])); + let eve = caller("user:eve"); + let unseen = |result: Result<(), ServiceError>| match result { + Err(ServiceError::UnknownRecord(record)) => record, + other => panic!("expected an unknown record, got {other:?}"), + }; + assert_eq!( + unseen(fixture.put(&eve, vec![statement(hidden, "review", "user:eve").encode()])), + hidden + ); + + // Ada's results about both, which every revision of `main` shares. + let result = |subject| { + Statement { + verdict: Some(origin_shared::Verdict::Pass), + inputs: Some(origin_shared::Inputs { + paths: vec![path("README.md")], + definition: origin_shared::Digest::of(b"test"), + toolchain: origin_shared::Digest::of(b""), + external: false, + }), + ..statement(subject, "ci.test", "user:ada") + } + .encode() + }; + let (of_hidden, of_visible) = (result(hidden), result(visible)); + repo.store + .write_all(&[of_hidden.clone(), of_visible.clone()]) + .unwrap(); + let mine = RecordId::Revision(repo.change_on_main(4, &[("src/lib.rs", "fn c() {}")])); + let promotion = |original: &EncodedObject| { + Statement { + subject: mine, + issuer: "user:eve".parse().unwrap(), + promoted_from: Some(original.id().into()), + ..original.decode_as().unwrap() + } + .encode() + }; + + // Promoting claims nothing new, but still needs the kind. + let reviewer = scoped("user:eve", &["annotate review"], in_a_week()); + assert_eq!( + forbidden(fixture.put(&reviewer, vec![promotion(&of_visible)])), + Forbidden::Annotate(Kind::new("ci.test").unwrap()) + ); + // And copies no result eve can't read. + assert_eq!( + unseen(fixture.put(&eve, vec![promotion(&of_hidden)])), + RecordId::Statement(of_hidden.id().into()) + ); + fixture.put(&eve, vec![promotion(&of_visible)]).unwrap(); + + // Nor does she review one, with a statement about it. + let review = |result: &EncodedObject| { + let subject = RecordId::Statement(result.id().into()); + statement(subject, "review", "user:eve").encode() + }; + assert_eq!( + unseen(fixture.put(&eve, vec![review(&of_hidden)])), + RecordId::Statement(of_hidden.id().into()) + ); + fixture.put(&eve, vec![review(&of_visible)]).unwrap(); + } + + #[test] + fn reserved_kinds_come_from_their_authors_whatever_the_caller_annotates() { + let fixture = Fixture::new(); + let theirs = RecordId::Revision(fixture.repo.change_on_main(2, &[])); + let base = fixture.tip(&TargetName::MAIN); + let (mine, records) = Fixture::revision( + 3, + "user:eve", + base, + Vec::new(), + fixture.state(&TargetName::MAIN), + ); + let abandon = |subject| statement(subject, "core.abandon", "user:eve").encode(); + let eve = caller("user:eve"); + let error = fixture.put(&eve, vec![abandon(theirs)]).unwrap_err(); + assert!( + matches!(&error, ServiceError::Statement { source, .. } + if matches!(**source, crate::annotations::AnnotateError::Reserved { .. })), + "{error}" + ); + assert_eq!(error.code(), origin_shared::api::ErrorCode::Forbidden); + // Eve abandons her own change, put with it, though she annotates + // no `core.*`. + let mut batch = records; + batch.push(abandon(RecordId::Revision(mine))); + fixture.put(&eve, batch).unwrap(); + } + + #[test] + fn batches_hold_stacks_and_statements_about_them() { + let fixture = Fixture::new(); + let main = fixture.tip(&TargetName::MAIN); + let files = [ + ("README.md", "hello"), + ("src/lib.rs", "fn a() {}"), + ("secrets/key", "hunter2"), + ("protected/file", "keep"), + ]; + let (lower_tree, mut objects) = + Fixture::objects(&[&files[..], &[("src/a.rs", "a")]].concat()); + let (lower, records) = Fixture::revision(2, "user:eve", main, Vec::new(), lower_tree); + objects.extend(records); + let (upper_tree, more) = + Fixture::objects(&[&files[..], &[("src/a.rs", "a"), ("src/b.rs", "b")]].concat()); + objects.extend(more); + let (upper, records) = + Fixture::revision(3, "user:eve", Base::Revision(lower), Vec::new(), upper_tree); + objects.extend(records); + objects.push(statement(RecordId::Revision(upper), "review", "user:eve").encode()); + fixture.put(&caller("user:eve"), objects).unwrap(); + } + + #[test] + fn callers_who_read_nothing_see_nothing() { + let fixture = Fixture::new(); + let revision = fixture.repo.change_on_main(2, &[]); + let blind = scoped("user:eve", &["annotate review"], in_a_week()); + let service = &fixture.service; + assert!(!service.sees_repository(&blind).unwrap()); + let records = service.records(&blind, &[revision.into()]).unwrap(); + assert!( + records.pack.objects.is_empty(), + "not even a revision changing nothing" + ); + assert_eq!(service.targets(&blind).unwrap(), []); + let log = origin_shared::api::LogQuery { + target: TargetName::MAIN, + since: 0, + limit: None, + }; + assert!(matches!( + service.log(&blind, &log), + Err(ServiceError::UnknownTarget(_)) + )); + assert_eq!(service.log(&caller("user:eve"), &log).unwrap().len(), 2); + } + + #[test] + fn tokens_issued_from_a_token_expire_with_it() { + let fixture = Fixture::new(); + let expires = in_a_week() - 6 * 24 * 3600; + let parent = scoped("user:eve", &["annotate review", "land *"], expires); + let request = origin_shared::api::TokenRequest { + scope: vec!["annotate review".parse().unwrap()], + ttl: origin_shared::Period(OriginService::MAX_TOKEN_TTL), + }; + let issued = fixture.service.issue_token(&parent, &request).unwrap(); + assert_eq!(issued.expires, expires); + let issued = fixture + .service + .issue_token(&caller("user:eve"), &request) + .unwrap(); + assert!(issued.expires > expires, "a static token's last a week"); + } +} diff --git a/apps/origin-src/src/policy.rs b/apps/origin-src/src/policy.rs index cd794c6c..9f380688 100644 --- a/apps/origin-src/src/policy.rs +++ b/apps/origin-src/src/policy.rs @@ -21,15 +21,27 @@ //! [retention] //! "ci.*" = "90d" //! -//! [[paths]] +//! [grants] # capabilities, but path access +//! "group:maintainers" = ["admin", "land *", "annotate review"] +//! "group:runners" = ["annotate ci.*"] +//! +//! [[paths]] # path access: none, read or write //! path = "secrets" //! grants = { all = "none", "group:maintainers" = "write" } //! ``` //! //! A target without a table of its own has the authority of `policy`, and //! requires nothing. Every name a policy refers to must be defined in it, -//! and unknown keys are errors, so a typo never drops a requirement. Path -//! rules are parsed and checked, but not enforced yet. +//! and unknown keys are errors, so a typo never drops a requirement. +//! +//! What each caller may do, their [`Rights`], comes from the last two +//! tables. Under a path, the most specific `[[paths]]` rule that mentions +//! someone decides, and paths no rule mentions them for are writable. A +//! policy without `[grants]` lets everyone land on every target but +//! `policy` and issue every kind of statement. With `[grants]`, each has +//! only what it grants them. Only `admin` lands on `policy`, so no one +//! grants themselves rights they don't have, and no one is `admin` until +//! the policy says so: the node's own user, on its command line, is. use std::collections::BTreeMap; use std::fmt; @@ -37,14 +49,16 @@ use std::str::FromStr; use std::time::Duration; use origin_platform::Platform; +use origin_shared::access::{Capability, Level, PathAccess, Rights, Targets}; use origin_shared::policy::{KindPattern, Principals, Requirement}; use origin_shared::{ AcceptanceBody, AcceptanceId, EncodedObject, EntryKind, EntryName, InvalidName, InvalidPeriod, - Kind, NodePublicKey, Period, Principal, PrincipalName, Record, RepoPath, TargetName, Tree, - TreeEntry, TreeId, + Kind, NodePublicKey, Period, Principal, PrincipalName, Record, RepoPath, Statement, TargetName, + Tree, TreeEntry, TreeId, }; use serde::Deserialize; +use crate::annotations::RevisionView; use crate::store::{Store, StoreError}; /// The policy of a repository. @@ -56,6 +70,8 @@ pub struct Policy { targets: BTreeMap, /// How long payloads are kept. pub retention: Retention, + /// `[grants]`, if the policy has the table. + grants: Option)>>, paths: Vec, } @@ -72,7 +88,7 @@ pub struct PathRule { /// The file or directory the rule covers, with everything under it. pub path: RepoPath, /// What each grantee may do there. - pub grants: BTreeMap, + pub grants: BTreeMap, } /// Who a path rule grants access to. @@ -84,17 +100,6 @@ pub enum Grantee { Principals(Principals), } -/// What a path rule grants. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Access { - /// Nothing, not even the content. - None, - /// Reading the content. - Read, - /// Proposing revisions that change it. - Write, -} - impl Policy { /// The file holding the policy, at the root of the `policy` target. pub const FILE: &str = "policy.toml"; @@ -231,6 +236,107 @@ impl Policy { pub fn paths(&self) -> &[PathRule] { &self.paths } + + /// Whether `grantee` covers `principal`, if there's one: `all` covers + /// anonymous callers too. + fn covers(&self, grantee: &Grantee, principal: Option<&Principal>) -> bool { + match (grantee, principal) { + (Grantee::All, _) => true, + (Grantee::Principals(principals), Some(principal)) => { + self.admits(principals, principal) + } + (Grantee::Principals(_), None) => false, + } + } + + /// Whether `statement`'s issuer may issue a statement of its kind: as + /// [`Store::authorizes`] says, or for `core.abandon`, if the policy + /// makes them `admin`. + /// + /// # Errors + /// + /// Fails if a revision cannot be read. + pub fn authorizes( + &self, + revisions: &impl RevisionView, + statement: &Statement, + ) -> Result { + let admin = self.rights(Some(&statement.issuer)).admin; + Self::authorizes_admin(revisions, statement, admin) + } + + /// [`Policy::authorizes`], for an issuer who is `admin` or not: as the + /// token they put the statement with narrows their rights, say. + /// + /// # Errors + /// + /// Fails if a revision cannot be read. + pub fn authorizes_admin( + revisions: &impl RevisionView, + statement: &Statement, + admin: bool, + ) -> Result { + if statement.kind == Kind::ABANDON && statement.promoted_from.is_none() && admin { + return Ok(true); + } + revisions.authorizes(statement) + } + + /// What `principal`, or an anonymous caller, may do, as the module + /// explains. Anonymous callers never write. + #[must_use] + pub fn rights(&self, principal: Option<&Principal>) -> Rights { + let mut rules: BTreeMap = BTreeMap::new(); + for rule in &self.paths { + let granted = rule + .grants + .iter() + .filter(|(grantee, _)| self.covers(grantee, principal)) + .map(|(_, &level)| level) + .max(); + if let Some(level) = granted { + let at = rules.entry(rule.path.clone()).or_insert(level); + *at = (*at).max(level); + } + } + let mut paths = PathAccess::new(Level::Write, rules); + if principal.is_none() { + paths = paths.min(&PathAccess::uniform(Level::Read)); + } + let mut rights = Rights { + principal: principal.cloned(), + groups: principal.map_or_else(Vec::new, |principal| { + self.groups + .iter() + .filter(|(_, members)| members.contains(principal)) + .map(|(group, _)| group.clone()) + .collect() + }), + scope: None, + paths, + annotate: vec![KindPattern::Any], + land: vec![Targets::Any], + admin: false, + }; + if let Some(grants) = &self.grants { + rights.annotate.clear(); + rights.land.clear(); + let granted = grants + .iter() + .filter(|(grantee, _)| self.covers(grantee, principal)) + .flat_map(|(_, capabilities)| capabilities); + for capability in granted { + match capability { + Capability::Annotate(kinds) => rights.annotate.push(kinds.clone()), + Capability::Land(targets) => rights.land.push(targets.clone()), + Capability::Admin => rights.admin = true, + // Refused when parsing: paths are in `[[paths]]`. + Capability::Read(_) | Capability::Write(_) => {} + } + } + } + rights + } } /// `policy.toml` as written, which [`Policy::try_from`] parses and checks. @@ -245,6 +351,7 @@ struct PolicyFile { targets: BTreeMap, #[serde(default)] retention: Retention, + grants: Option>>, #[serde(default)] paths: Vec, } @@ -317,6 +424,7 @@ impl TryFrom for Policy { groups, targets: BTreeMap::new(), retention: file.retention, + grants: None, paths: Vec::new(), }; for (name, target) in file.targets { @@ -346,18 +454,34 @@ impl TryFrom for Policy { .targets .insert(name, TargetRules { authority, require }); } + if let Some(grants) = file.grants { + let grants = grants + .into_iter() + .map(|(grantee, capabilities)| { + let grantee = policy.grantee(PolicyFile::parse("grantee", grantee)?)?; + let capabilities = capabilities + .into_iter() + .map( + |capability| match PolicyFile::parse("capability", capability)? { + Capability::Read(path) | Capability::Write(path) => { + Err(RuleError::PathGrant(path)) + } + capability => Ok(capability), + }, + ) + .collect::>()?; + Ok((grantee, capabilities)) + }) + .collect::>()?; + policy.grants = Some(grants); + } for rule in file.paths { let grants = rule .grants .into_iter() - .map(|(grantee, access)| { - let grantee = match PolicyFile::parse("grantee", grantee)? { - Grantee::Principals(principals) => { - Grantee::Principals(policy.check(principals)?) - } - Grantee::All => Grantee::All, - }; - Ok((grantee, PolicyFile::parse("access", access)?)) + .map(|(grantee, level)| { + let grantee = policy.grantee(PolicyFile::parse("grantee", grantee)?)?; + Ok((grantee, PolicyFile::parse("access", level)?)) }) .collect::>()?; policy.paths.push(PathRule { @@ -392,10 +516,29 @@ impl Policy { \n\ [targets.main]\n\ authority = \"node:{node}\"\n\ - # require = [{{ kind = \"ci.test\", verdict = \"pass\", issuer = \"group:runners\" }}]\n" + # require = [{{ kind = \"ci.test\", verdict = \"pass\", issuer = \"group:runners\" }}]\n\ + \n\ + # Who may land, annotate, and change this policy (`admin`). Without\n\ + # [grants], everyone lands and annotates, and no one is admin\n\ + # remotely.\n\ + # [grants]\n\ + # \"user:alice\" = [\"admin\", \"land *\"]\n\ + \n\ + # Who may read and write paths: everyone writes everything by default.\n\ + # [[paths]]\n\ + # path = \"secrets\"\n\ + # grants = {{ all = \"none\", \"user:alice\" = \"write\" }}\n" ) } + /// Checks that the group `grantee` names, if any, is defined. + fn grantee(&self, grantee: Grantee) -> Result { + match grantee { + Grantee::Principals(principals) => Ok(Grantee::Principals(self.check(principals)?)), + Grantee::All => Ok(Grantee::All), + } + } + /// Checks that the group `principals` names, if any, is defined. fn check(&self, principals: Principals) -> Result { match &principals { @@ -421,19 +564,6 @@ impl FromStr for Grantee { } } -impl FromStr for Access { - type Err = String; - - fn from_str(input: &str) -> Result { - match input { - "none" => Ok(Self::None), - "read" => Ok(Self::Read), - "write" => Ok(Self::Write), - _ => Err(format!("`{input}` is not `none`, `read` or `write`")), - } - } -} - /// An invalid rule in `policy.toml`. #[derive(Debug, PartialEq, Eq, thiserror::Error)] pub enum RuleError { @@ -466,6 +596,9 @@ pub enum RuleError { /// A rule names a group `[groups]` doesn't define. #[error("group:{0} is not defined in [groups]")] UnknownGroup(PrincipalName), + /// `[grants]` grants path access, which `[[paths]]` rules set. + #[error("[grants] cannot grant access to `{0}`: add a [[paths]] rule")] + PathGrant(RepoPath), } /// How long the payloads of each kind of statement are kept, measured from @@ -621,11 +754,53 @@ mod tests { [targets.main]\nauthority = \"node:hosted\"\nrequire = [\n\ {{ kind = \"ci.test\", verdict = \"pass\", issuer = \"group:runners\" }},\n\ {{ kind = \"review\", verdict = \"pass\", issuer = \"user:bob\" }},\n]\n\ + [grants]\n\"group:maintainers\" = [\"admin\", \"land *\"]\n\ + \"group:runners\" = [\"annotate ci.*\"]\nall = [\"land main\"]\n\ [[paths]]\npath = \"secrets\"\n\ - grants = {{ all = \"none\", \"group:maintainers\" = \"write\" }}\n" + grants = {{ all = \"none\", \"group:maintainers\" = \"write\" }}\n\ + [[paths]]\npath = \"/\"\n\ + grants = {{ all = \"read\", \"node:ci-1\" = \"write\" }}\n" ) } + #[test] + fn rights_come_from_grants_and_paths() { + let policy: Policy = toml::from_str(&full_policy()).unwrap(); + let path = |path: &str| RepoPath::new(path).unwrap(); + let alice = policy.rights(Some(&principal("user:alice"))); + assert_eq!(alice.groups, [PrincipalName::new("maintainers").unwrap()]); + assert!(alice.admin && alice.may_land(&TargetName::POLICY)); + assert!(alice.may_land(&TargetName::new("release").unwrap())); + assert!(!alice.may_annotate(&Kind::new("ci.test").unwrap())); + assert_eq!(alice.paths.level(&path("secrets/key")), Level::Write); + assert_eq!(alice.paths.level(&path("src")), Level::Read, "`all` at `/`"); + + let ci = policy.rights(Some(&principal("node:ci-1"))); + assert!(ci.may_annotate(&Kind::new("ci.test").unwrap())); + assert!(ci.may_land(&TargetName::MAIN), "`all` lands on `main`"); + assert!(!ci.may_land(&TargetName::POLICY) && !ci.admin); + assert_eq!(ci.paths.level(&path("src")), Level::Write); + assert_eq!(ci.paths.level(&path("secrets/key")), Level::None); + + let anonymous = policy.rights(None); + assert_eq!(anonymous.paths.level(&path("src")), Level::Read); + assert!(!anonymous.paths.reads(&path("secrets"))); + + // Without rules, everyone writes everything and lands but on + // `policy`, and no one is admin. + let open = Policy::default().rights(Some(&principal("user:bob"))); + assert!(open.paths.writes_all()); + assert!(open.may_land(&TargetName::MAIN) && !open.admin); + assert!(open.may_annotate(&Kind::new("review").unwrap())); + assert!(!Policy::default().rights(None).paths.writes(&path("x"))); + + let error = toml::from_str::("[grants]\nall = [\"write src\"]\n").unwrap_err(); + assert!( + error.to_string().contains("add a [[paths]] rule"), + "{error}" + ); + } + fn principal(principal: &str) -> Principal { principal.parse().unwrap() } @@ -664,11 +839,11 @@ mod tests { assert_eq!(policy.authority(&other), Some(&hosted)); assert_eq!(policy.requirements(&other), []); - let [rule] = policy.paths() else { - panic!("one path rule") + let [rule, _] = policy.paths() else { + panic!("two path rules") }; assert_eq!(rule.path, RepoPath::new("secrets").unwrap()); - assert_eq!(rule.grants.get(&Grantee::All), Some(&Access::None)); + assert_eq!(rule.grants.get(&Grantee::All), Some(&Level::None)); } #[test] diff --git a/apps/origin-src/src/queue.rs b/apps/origin-src/src/queue.rs index ec0eea8e..31a97678 100644 --- a/apps/origin-src/src/queue.rs +++ b/apps/origin-src/src/queue.rs @@ -17,9 +17,12 @@ //! statement about the revision lists the paths, and the change waits, //! blocked, until a new revision of it merges. //! 3. **Policy.** The policy in force now must name this node as the -//! target's authority. On `policy`, that's the policy of the acceptance -//! before, never the one landing ([`Policy::governing`]). Each of the -//! target's requirements needs a [claim](crate::annotations::Claim) +//! target's authority, and the submitter must write every path the +//! landing changes on the target, whatever it lands: a revision made on +//! an old acceptance, or another target, never reverts or deletes what +//! they may not change. On `policy`, the policy is the acceptance +//! before's, never the one landing's ([`Policy::governing`]). Each of +//! the target's requirements needs a [claim](crate::annotations::Claim) //! about the revision that lands: results with the same input digest //! are promoted to it first, and a valid promotion claims what its //! original does, as its original's issuer. @@ -48,6 +51,7 @@ use std::path::PathBuf; use std::slice; use origin_platform::{DurableWrite, LockMode, LockedFile, Native, Platform}; +use origin_shared::access::{Level, PathAccess}; use origin_shared::landing::{Blocked, Landing, Pending, Proposal}; use origin_shared::policy::Requirement; use origin_shared::{ @@ -60,6 +64,7 @@ use crate::ancestry::Ancestry; use crate::annotations::{AnnotateError, Annotations, Claim}; use crate::conflict::Conflicts; use crate::oplog::Effect; +use crate::permissions::{Forbidden, Reader}; use crate::policy::{Policy, PolicyError}; use crate::store::{Store, StoreError}; @@ -69,6 +74,8 @@ pub struct Queue<'a, P = Native> { store: &'a Store

, key: &'a NodeKey, node: PrincipalName, + /// What the submitter may do with each path. + submitter: PathAccess, } /// Landings onto one target, holding its queue's lock. @@ -107,7 +114,22 @@ impl<'a, P: Platform> Queue<'a, P> { /// The queues of `store`, run by the node `node`, which signs with /// `key`. pub fn new(store: &'a Store

, key: &'a NodeKey, node: PrincipalName) -> Self { - Self { store, key, node } + Self { + store, + key, + node, + submitter: PathAccess::uniform(Level::Write), + } + } + + /// The queues, landing for a submitter who may do `paths`: everything, + /// by default. + #[must_use] + pub fn submitted_by(self, paths: PathAccess) -> Self { + Self { + submitter: paths, + ..self + } } /// This node, which issues the statements and revisions the queue @@ -447,7 +469,9 @@ impl Session<'_, '_, P> { let claims = self.annotations.claims(RecordId::Revision(head))?; for claim in &claims { let statement = claim.statement(); - if statement.kind == Kind::ABANDON && self.store().authorizes(statement)? { + if statement.kind == Kind::ABANDON + && self.policy.authorizes(self.store(), statement)? + { return Err(LandError::Abandoned(change)); } } @@ -549,6 +573,13 @@ impl Session<'_, '_, P> { if !candidate.trees.is_empty() { store.write_all(&candidate.trees)?; } + if !self.queue.submitter.writes_all() { + for change in store.diff(self.state, candidate.state)? { + if !self.queue.submitter.writes(&change.path) { + return Err(Forbidden::Write(change.path).into()); + } + } + } // What a landing on `policy` lands governs the next one, which // this node must still be the authority of. let landed_policy = if self.onto == TargetName::POLICY { @@ -571,9 +602,13 @@ impl Session<'_, '_, P> { // Promotion reads the revision. store.write_all(&records)?; records.clear(); - let promotions = self - .annotations - .promotions(candidate.subject, &self.queue.principal())?; + // Only from what the submitter sees. + let mut reader = Reader::new(store, &self.queue.submitter); + let promotions = self.annotations.promotions( + candidate.subject, + &self.queue.principal(), + |record| reader.sees(record), + )?; let mut claims = self.annotations.claims(candidate.subject)?; for promotion in promotions { let encoded = promotion.encode(); @@ -904,6 +939,9 @@ pub enum LandError { /// The policy in force cannot be read. #[error(transparent)] Policy(#[from] PolicyError), + /// The submitter may not change what the landing changes. + #[error(transparent)] + Forbidden(#[from] Forbidden), /// A conflict cannot be recorded. #[error(transparent)] Annotate(#[from] AnnotateError), @@ -917,7 +955,7 @@ pub(crate) mod tests { use std::sync::Mutex; use std::thread; - use origin_shared::{BlobId, Digest, Inputs, Statement, Verdict}; + use origin_shared::{AttachmentId, Digest, Inputs, Statement, Verdict}; use super::*; use crate::conflict::Conflicted; @@ -995,7 +1033,7 @@ pub(crate) mod tests { base, deps: Vec::new(), snapshot: self.files(files), - description: BlobId::from(EncodedObject::blob(b"").id()), + description: AttachmentId::from(EncodedObject::attachment(b"").id()), author: "user:ada".parse().unwrap(), } .encode(); @@ -1238,6 +1276,35 @@ pub(crate) mod tests { assert_eq!(promoted.issuer, "node:queue".parse().unwrap()); } + #[test] + fn promotes_only_what_the_submitter_sees() { + let repo = Repo::new( + "[targets.main]\nauthority = \"node:queue\"\n\ + require = [{ kind = \"ci.test\", verdict = \"pass\", issuer = \"node:ci\" }]\n", + ); + // A result about a revision changing only `secrets`, whose `src` is + // the docs change's. + let hidden = repo.change_on_main(1, &[("secrets/key", "hunter2")]); + repo.annotations() + .issue(&ci_test(hidden, "node:ci"), None) + .unwrap(); + repo.change_on_main(2, &[("docs/guide.md", "# Guide")]); + let secrets = RepoPath::new("secrets").unwrap(); + let blind = PathAccess::new(Level::Write, [(secrets, Level::None)]); + let waiting = repo + .queue() + .submitted_by(blind) + .land_change( + &mut repo.annotations(), + ChangeId::from_bytes([2; 16]), + &TargetName::MAIN, + ) + .unwrap(); + assert!(matches!(waiting[..], [Landing::Pending(_)]), "{waiting:?}"); + // Whoever reads `secrets` lands it with the result promoted. + assert!(matches!(repo.land(2)[..], [Landing::Landed { .. }])); + } + #[test] fn refuses_divergent_and_abandoned_changes() { let repo = Repo::new("[targets.main]\nauthority = \"node:queue\"\n"); @@ -1491,7 +1558,7 @@ pub(crate) mod tests { base: tip, deps: Vec::new(), snapshot: repo.files(&[("policy.toml", &requiring)]), - description: BlobId::from(EncodedObject::blob(b"").id()), + description: AttachmentId::from(EncodedObject::attachment(b"").id()), author: "user:ada".parse().unwrap(), } .encode(); @@ -1601,7 +1668,11 @@ pub(crate) mod tests { let id = annotations.issue(&original, None).unwrap(); Annotations::open(&repo.store) .unwrap() - .promote(RecordId::Revision(new), &"node:queue".parse().unwrap()) + .promote( + RecordId::Revision(new), + &"node:queue".parse().unwrap(), + |_| Ok(true), + ) .unwrap(); // The same passing result runs again, replacing the one promoted. let rerun = Statement { @@ -1618,9 +1689,13 @@ pub(crate) mod tests { #[test] fn only_the_creator_of_a_change_abandons_it() { - let repo = Repo::new("[targets.main]\nauthority = \"node:queue\"\n"); + let repo = Repo::new( + "[targets.main]\nauthority = \"node:queue\"\n\ + [grants]\nall = [\"land *\", \"annotate *\"]\n\"user:root\" = [\"admin\"]\n", + ); let revision = repo.change_on_main(1, &[("file", "work")]); - let abandon = |issuer: &str| Statement { + let given_up = repo.change_on_main(2, &[("other", "work")]); + let abandon = |issuer: &str, revision| Statement { subject: RecordId::Revision(revision), kind: Kind::ABANDON, verdict: None, @@ -1631,9 +1706,24 @@ pub(crate) mod tests { promoted_from: None, }; repo.store - .write_all(&[abandon("user:eve").encode()]) + .write_all(&[ + abandon("user:eve", revision).encode(), + abandon("user:root", given_up).encode(), + ]) .unwrap(); assert!(matches!(repo.land(1)[..], [Landing::Landed { .. }])); + let error = repo + .queue() + .land_change( + &mut repo.annotations(), + ChangeId::from_bytes([2; 16]), + &TargetName::MAIN, + ) + .unwrap_err(); + assert!( + matches!(error, LandError::Abandoned(_)), + "an admin may: {error}" + ); } #[test] diff --git a/apps/origin-src/src/remote.rs b/apps/origin-src/src/remote.rs index e9abffb9..180e2be0 100644 --- a/apps/origin-src/src/remote.rs +++ b/apps/origin-src/src/remote.rs @@ -12,14 +12,17 @@ use std::path::{self, Path, PathBuf}; use origin_client::Client; use origin_shared::Head; +use origin_shared::access::Rights; use origin_shared::api::{ - ErrorCode, EventsQuery, FetchRecords, FetchSnapshot, Inventory, InventoryRequest, LandRequest, - Landings, LogEntry, LogQuery, NewTarget, PutQuery, RepoName, Stored, TargetInfo, + BrowseQuery, ErrorCode, EventsQuery, FetchRecords, FetchSnapshot, Inventory, InventoryRequest, + IssuedToken, LandRequest, Landings, Listing, LogEntry, LogQuery, NewTarget, PutQuery, RepoName, + Stored, TargetInfo, TokenRequest, }; use origin_shared::event::Page; use origin_shared::landing::Pending; use origin_shared::pack::{Pack, Part}; +use crate::config::UserConfig; use crate::service::{Caller, Node, OriginService, ServiceError}; use crate::workspace::Workspace; @@ -68,8 +71,8 @@ calls! { fn targets() -> Vec = targets(), targets(); /// The acceptances of a target, oldest first. fn log(query: &LogQuery) -> Vec = log(query), log(query); - /// Records by id, each followed by the blob it carries: those up to the - /// last record of the part, if it stops short. + /// Records by id, each followed by the attachment it carries: those up + /// to the last record of the part, if it stops short. fn records(request: &FetchRecords) -> Part = fetch_records(request), records(&request.ids); /// The trees and blobs of a snapshot that the request's snapshots lack, /// in parts. @@ -91,25 +94,41 @@ calls! { fn create_target(request: &NewTarget) -> Head = create_target(request), create_target(request); /// The events after a cursor, waiting for some. fn events(query: &EventsQuery) -> Page = events(query), events(query); + /// Who the remote thinks the user is, and what they may do there. + fn whoami() -> Rights = whoami(), whoami(); + /// Issues a token acting for the user, with a narrower scope. + fn issue_token(request: &TokenRequest) -> IssuedToken = + issue_token(request), issue_token(request); + /// Lists a directory of a snapshot. + fn tree(query: &BrowseQuery) -> Listing = tree(query), tree(query); + /// Reads a file of a snapshot. + fn file(query: &BrowseQuery) -> Vec = file(query), file(query); } impl Remote { /// Opens the remote at `url`: an `http://` URL whose last path segment - /// names the repository, or the path of a repository, which `node` - /// serves in-process. + /// names the repository, called with `token` if there's one, or the + /// path of a repository, which `node` serves in-process, to its owner. /// /// # Errors /// /// Fails if the URL names no repository, or the path holds none. - pub fn open(url: &RemoteUrl, node: Node) -> Result { + pub fn open(url: &RemoteUrl, node: Node, token: Option) -> Result { match url { - RemoteUrl::Http { base, repo } => Ok(Self::Http { - runtime: tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(RemoteError::Runtime)?, - repo: Client::new(base).repo(repo.clone()), - }), + RemoteUrl::Http { base, repo } => { + let client = Client::new(base); + let client = match token { + Some(token) => client.with_token(token), + None => client, + }; + Ok(Self::Http { + runtime: tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(RemoteError::Runtime)?, + repo: client.repo(repo.clone()), + }) + } RemoteUrl::Path(path) => { // A working copy's repository is in its `.origin`. let meta = path.join(Workspace::META_DIR); @@ -137,6 +156,16 @@ pub enum RemoteUrl { } impl RemoteUrl { + /// The token the user of `config` sends this remote, if any: none to a + /// local path, which serves them as its owner. + #[must_use] + pub fn token(&self, config: &UserConfig) -> Option { + match self { + Self::Http { base, .. } => config.token(base), + Self::Path(_) => None, + } + } + /// Parses a remote URL: `http://host[:port]/`, or a path, /// made absolute, so the URL works from anywhere. /// diff --git a/apps/origin-src/src/server.rs b/apps/origin-src/src/server.rs index acb3e1df..da03dcd9 100644 --- a/apps/origin-src/src/server.rs +++ b/apps/origin-src/src/server.rs @@ -14,10 +14,12 @@ use std::thread; use std::time::Duration; use anyhow::Context; -use origin_shared::PrincipalName; +use origin_shared::access::{Capability, Token}; use origin_shared::api::{RepoName, RepoSummary}; +use origin_shared::{Principal, PrincipalName}; use serde::Deserialize; +use crate::auth::{Credentials, StaticTokens}; use crate::config::KeyFile; use crate::http::Api; use crate::policy::Policy; @@ -33,9 +35,14 @@ use crate::store::Store; /// /// [repos] /// origin = "repos/origin" # name = path +/// +/// [tokens] # static tokens, by principal +/// "user:alice" = "a long random secret" /// ``` /// -/// Paths are relative to the file. +/// Paths are relative to the file. Callers without a token are anonymous, +/// and only read; the server also takes the scoped tokens it signed (see +/// [`ServerConfig::issue_token`]). #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct ServerConfig { @@ -49,6 +56,9 @@ pub struct ServerConfig { /// The repositories to serve, by name. #[serde(default)] pub repos: BTreeMap, + /// Static tokens. + #[serde(default)] + pub tokens: StaticTokens, } impl ServerConfig { @@ -111,6 +121,26 @@ impl ServerConfig { Ok(()) } + /// `origin token issue --config --as `: signs a + /// token acting for `principal` with `scope`, valid for `ttl`, at most + /// [`OriginService::MAX_TOKEN_TTL`] as any token, with the server's key, + /// and returns it. The token acts with the principal's rights in each + /// repository, narrowed to the scope, so an operator can hand agents + /// identities of their own. + /// + /// # Errors + /// + /// Fails if the key cannot be read or generated. + pub fn issue_token( + &self, + principal: Principal, + scope: Vec, + ttl: Duration, + ) -> anyhow::Result { + let ttl = ttl.min(OriginService::MAX_TOKEN_TTL); + Ok(Token::new(principal, scope, ttl).sign(&self.node()?.key)) + } + /// `origin serve`: serves the repositories until interrupted, logging /// requests to stderr. Prints the address it listens on, which tells /// callers that chose port 0 where to connect. @@ -127,7 +157,8 @@ impl ServerConfig { .with_writer(std::io::stderr) .init(); let node = self.node()?; - let repositories = Repositories::open(self.repos, &node)?; + let credentials = Credentials::new(node.clone(), Box::new(self.tokens)); + let repositories = Repositories::open(self.repos, &node, credentials)?; repositories.compact_in_background(); let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -151,31 +182,49 @@ impl ServerConfig { } /// The repositories a server hosts, each served by the server's node and -/// kept open while it runs. -#[derive(Debug, Default)] +/// kept open while it runs, and how it tells its callers apart. +#[derive(Debug)] pub struct Repositories { repos: BTreeMap, + credentials: Credentials, } impl Repositories { /// How often [`Repositories::compact_in_background`] checks. const COMPACT_EVERY: Duration = Duration::from_mins(1); + /// No repositories yet, for callers `credentials` tells apart. + #[must_use] + pub fn new(credentials: Credentials) -> Self { + Self { + repos: BTreeMap::new(), + credentials, + } + } + /// Opens the repositories at the given paths, served by `node`. /// /// # Errors /// /// Fails if one has no repository. - pub fn open(repos: BTreeMap, node: &Node) -> anyhow::Result { - let repos = repos - .into_iter() - .map(|(name, path)| { - let service = OriginService::open(path, node.clone()) - .with_context(|| format!("cannot serve repository `{name}`"))?; - Ok((name, service)) - }) - .collect::>()?; - Ok(Self { repos }) + pub fn open( + repos: BTreeMap, + node: &Node, + credentials: Credentials, + ) -> anyhow::Result { + let mut repositories = Self::new(credentials); + for (name, path) in repos { + let service = OriginService::open(path, node.clone()) + .with_context(|| format!("cannot serve repository `{name}`"))?; + repositories.insert(name, service); + } + Ok(repositories) + } + + /// How the server tells its callers apart. + #[must_use] + pub fn credentials(&self) -> &Credentials { + &self.credentials } /// Compacts each repository once enough loose objects pile up in it @@ -217,23 +266,55 @@ impl Repositories { self.repos.insert(name, service); } - /// Lists the repositories, by name. - #[must_use] - pub fn list(&self, _caller: &Caller) -> Vec { - self.repos - .keys() - .map(|name| RepoSummary { name: name.clone() }) - .collect() + /// Lists the repositories the caller sees, by name. + /// + /// # Errors + /// + /// Fails if a repository's policy cannot be read. + pub fn list(&self, caller: &Caller) -> Result, ServiceError> { + let mut seen = Vec::new(); + for (name, service) in &self.repos { + if service.sees_repository(caller)? { + seen.push(RepoSummary { name: name.clone() }); + } + } + Ok(seen) } - /// The repository `name`. + /// The repository `name`, if the caller sees it: invisible repositories + /// don't exist. /// /// # Errors /// - /// Fails if the server hosts none by that name. - pub fn get(&self, _caller: &Caller, name: &RepoName) -> Result<&OriginService, ServiceError> { - self.repos - .get(name) - .ok_or_else(|| ServiceError::UnknownRepository(name.clone())) + /// Fails if the server hosts none by that name the caller sees, or its + /// policy cannot be read. + pub fn get(&self, caller: &Caller, name: &RepoName) -> Result<&OriginService, ServiceError> { + match self.repos.get(name) { + Some(service) if service.sees_repository(caller)? => Ok(service), + _ => Err(ServiceError::UnknownRepository(name.clone())), + } + } +} + +#[cfg(test)] +mod tests { + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::*; + + #[test] + fn tokens_signed_with_the_key_last_at_most_a_week() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("origin-server.toml"); + fs::write(&path, "listen = \"127.0.0.1:0\"\nnode = \"hosted\"\n").unwrap(); + let config = ServerConfig::load(&path).unwrap(); + let month = Duration::from_hours(30 * 24); + let text = config + .issue_token("user:agent".parse().unwrap(), Vec::new(), month) + .unwrap(); + let token = Token::verify(&text, &config.node().unwrap().key.public()).unwrap(); + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); + let cap = (now + OriginService::MAX_TOKEN_TTL).as_secs(); + assert!(token.expires <= cap && token.expires + 60 > cap); } } diff --git a/apps/origin-src/src/service.rs b/apps/origin-src/src/service.rs index a8801d9a..307477db 100644 --- a/apps/origin-src/src/service.rs +++ b/apps/origin-src/src/service.rs @@ -14,8 +14,9 @@ //! Landings on one target still go one at a time, through the target's //! queue lock. //! -//! Every method takes the [`Caller`]. Nothing is checked yet: every caller -//! may read and write everything. +//! Every method takes the [`Caller`], and does only what their [`Rights`] +//! allow, as [`permissions`](crate::permissions) explains: what each +//! caller reads is filtered, and what they write is checked. use std::collections::hash_map::Entry; use std::collections::{HashMap, HashSet}; @@ -25,35 +26,31 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::Duration; use origin_platform::Native; +use origin_shared::access::{PathAccess, Rights, Token}; use origin_shared::api::{ - Annotation, ErrorCode, EventsQuery, Expected, FetchSnapshot, Inventory, InventoryRequest, - LandRequest, Landings, LogEntry, LogQuery, NewTarget, RevisionEntry, Revisions, Stored, - TargetInfo, + Annotation, At, BrowseQuery, ErrorCode, EventsQuery, Expected, FetchSnapshot, Inventory, + InventoryRequest, IssuedToken, LandRequest, Landings, Listed, Listing, LogEntry, LogQuery, + NewTarget, RevisionEntry, Revisions, Stored, TargetInfo, TokenRequest, }; -use origin_shared::event::{Filter, Page}; +use origin_shared::event::{EventKind, Filter, Page}; use origin_shared::landing::{Pending, Proposal}; use origin_shared::object::DecodeError; use origin_shared::pack::{Pack, Packer, Part}; use origin_shared::{ - Base, ChangeId, Head, NodeKey, Object, ObjectId, ObjectKind, Principal, PrincipalName, - RecordId, Statement, StatementId, TargetName, TreeId, + AcceptanceId, Base, ChangeId, EncodedObject, Head, Kind, NodeKey, Object, ObjectId, ObjectKind, + Principal, PrincipalName, RecordId, RepoPath, Statement, StatementId, TargetName, TreeEntry, + TreeId, }; use crate::annotations::{AnnotateError, Annotations}; use crate::events::{Events, Wakeup}; use crate::index::Index; +pub use crate::permissions::Caller; +use crate::permissions::{Batch, Forbidden, PutError, Reader, Writer}; use crate::policy::{Policy, PolicyError}; use crate::queue::{LandError, Queue}; use crate::store::{Store, StoreError}; - -/// Who calls the service. Permissions will decide what each may do by it. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Caller { - /// The node's own user, through the command line. - Owner, - /// A client of a server that didn't authenticate. - Anonymous, -} +use crate::tree::Snapshots; /// A node: its name, and the key it signs acceptances with as the /// authority of the targets its repositories' policies name it for. A @@ -80,6 +77,9 @@ pub struct OriginService { node: Node, /// The record indexes kept open, but those in use: see the module. indexes: Mutex>, + /// The policy in force, and the acceptance of `policy` holding it, once + /// read. + policy: Mutex)>>, wakeup: Wakeup, /// The most bytes a fetch's answer holds: [`Pack::LIMIT`], but in tests. pack_limit: usize, @@ -89,6 +89,9 @@ impl OriginService { /// The most events a page holds, and how many by default. const MAX_EVENTS: u32 = 1024; + /// The longest a token issued lasts. + pub const MAX_TOKEN_TTL: Duration = Duration::from_hours(7 * 24); + /// The longest an event long-poll waits. pub const MAX_WAIT: Duration = Duration::from_mins(1); @@ -102,6 +105,7 @@ impl OriginService { store, node, indexes: Mutex::default(), + policy: Mutex::default(), wakeup: Wakeup::default(), pack_limit: Pack::LIMIT, } @@ -193,35 +197,147 @@ impl OriginService { self.indexes.lock().unwrap_or_else(PoisonError::into_inner) } + /// The policy in force, read again only once `policy` moved. + fn policy(&self) -> Result, ServiceError> { + let mut kept = self.policy.lock().unwrap_or_else(PoisonError::into_inner); + let head = self.store.head(&TargetName::POLICY)?; + if let (Some(head), Some((at, policy))) = (head, &*kept) + && head.acceptance == *at + { + return Ok(Arc::clone(policy)); + } + let (at, policy) = match Policy::in_force(&self.store) { + Ok((at, policy)) => (at, Arc::new(policy)), + Err(PolicyError::Missing) => return Ok(Arc::new(Policy::default())), + Err(error) => return Err(error.into()), + }; + *kept = Some((at, Arc::clone(&policy))); + Ok(policy) + } + + /// What `caller` may do, under the policy in force. + /// + /// # Errors + /// + /// Fails if the policy cannot be read. + pub fn rights(&self, caller: &Caller) -> Result { + Ok(match caller { + Caller::Owner => Rights::all(), + Caller::Anonymous => self.policy()?.rights(None), + Caller::Principal { principal, scope } => { + let rights = self.policy()?.rights(Some(principal)); + match scope { + Some(scope) => rights.narrowed(scope.capabilities.clone()), + None => rights, + } + } + }) + } + + /// Whether the caller sees the repository at all: they read some path. + /// Those who don't see none of its records, targets or events. + /// + /// # Errors + /// + /// Fails if the policy cannot be read. + pub fn sees_repository(&self, caller: &Caller) -> Result { + Ok(self.rights(caller)?.paths.reads_under(&RepoPath::root())) + } + + /// `whoami`: what the caller may do, and who they are. + /// + /// # Errors + /// + /// Fails if the policy cannot be read. + pub fn whoami(&self, caller: &Caller) -> Result { + self.rights(caller) + } + + /// Issues a token acting for the caller, with the request's scope, which + /// their rights must cover, for its `ttl`, at most + /// [`OriginService::MAX_TOKEN_TTL`], and never past the expiry of the + /// caller's own token, if it's a scoped one: so no token renews itself. + /// + /// # Errors + /// + /// Fails if the caller isn't signed in, or the scope reaches beyond + /// their rights. + pub fn issue_token( + &self, + caller: &Caller, + request: &TokenRequest, + ) -> Result { + let Some(principal) = caller.principal() else { + return Err(Forbidden::NoPrincipal.into()); + }; + let rights = self.rights(caller)?; + if let Some(beyond) = request + .scope + .iter() + .find(|&capability| !rights.cover(std::slice::from_ref(capability))) + { + return Err(Forbidden::Scope(beyond.clone()).into()); + } + let mut token = Token::new( + principal.clone(), + request.scope.clone(), + request.ttl.0.min(Self::MAX_TOKEN_TTL), + ); + if let Caller::Principal { + scope: Some(scope), .. + } = caller + { + token.expires = token.expires.min(scope.expires); + } + Ok(IssuedToken { + token: token.sign(&self.node.key), + principal: token.principal, + expires: token.expires, + }) + } + /// The records `ids` name, in their order, as a pack: revisions, - /// statements and acceptances, each followed by the blob it carries, if - /// the repository has it: a revision's description, a statement's - /// payload. Snapshots only leave through [`OriginService::snapshot`]. - /// The pack stops short at [`Pack::LIMIT`], before a record that - /// doesn't fit with its blob: the rest comes from asking for the ids - /// after the last record it has. + /// statements and acceptances the caller sees, the others left out, + /// each followed by the attachment it carries, if the repository has + /// it: a revision's description, a statement's payload. Snapshots only + /// leave through [`OriginService::snapshot`]. The pack stops short at + /// [`Pack::LIMIT`], before a record that doesn't fit with its + /// attachment: the rest comes from asking for the ids after the last + /// record it has. /// /// # Errors /// - /// Fails if the repository doesn't have one, one is a blob or a tree, or - /// it cannot read one. - pub fn records(&self, _caller: &Caller, ids: &[ObjectId]) -> Result { + /// Fails if the repository doesn't have one, one is a blob, a tree or + /// an attachment, or it cannot read one. + pub fn records(&self, caller: &Caller, ids: &[ObjectId]) -> Result { + let rights = self.rights(caller)?; + let mut reader = Reader::new(&self.store, &rights.paths); let mut packer = Packer::new(self.pack_limit); for &id in ids { let object = self.store.read(id)?; - let carried = match object + let (record, carried) = match object .decode() .map_err(|source| StoreError::Decode { id, source })? { - Object::Blob(_) | Object::Tree(_) => return Err(ServiceError::NotARecord(id)), - Object::Revision(revision) => Some(revision.description.into()), - Object::Statement(statement) => statement.payload.map(Into::into), - Object::Acceptance(_) => None, + Object::Blob(_) | Object::Tree(_) | Object::Attachment(_) => { + return Err(ServiceError::NotARecord(id)); + } + Object::Revision(revision) => { + (RecordId::Revision(id.into()), Some(revision.description)) + } + Object::Statement(statement) => (RecordId::Statement(id.into()), statement.payload), + Object::Acceptance(_) => (RecordId::Acceptance(id.into()), None), }; + if !reader.sees(record)? { + continue; + } + // Only an attachment goes with a record: never a blob, a file's + // content, whose id a record names instead. let carried = carried - .map(|id| self.store.try_read(id)) + .map(|id| self.store.try_read(id.into())) .transpose()? - .flatten(); + .flatten() + .filter(|object| object.kind().ok() == Some(ObjectKind::Attachment)); let group: Vec<_> = iter::once(object).chain(carried).collect(); if !packer.fits(&group) { return Ok(Part { @@ -237,36 +353,63 @@ impl OriginService { }) } - /// The objects of the snapshot the request names that the caller lacks, - /// as a pack, parents before children: those none of the snapshots it - /// has holds at the same path, under the paths it asks for, but the - /// first `skip`. See [`Store::added`]. The pack stops short at + /// The snapshot of a record the caller sees, a revision or an + /// acceptance, if it is one. + fn snapshot_of( + &self, + reader: &mut Reader<'_, Native>, + record: RecordId, + ) -> Result { + let unknown = || ServiceError::UnknownRecord(record); + let snapshot = match record { + RecordId::Revision(id) => self.store.get(id).map(|revision| revision.snapshot), + RecordId::Acceptance(id) => { + self.store.get(id).map(|acceptance| acceptance.body().state) + } + RecordId::Statement(_) | RecordId::Snapshot(_) => { + return Err(ServiceError::NotASnapshot(record)); + } + }; + let snapshot = match snapshot { + // Not a record of the kind it's named as. + Err(StoreError::NotFound(_) | StoreError::Decode { .. }) => return Err(unknown()), + snapshot => snapshot?, + }; + if !reader.sees(record)? { + return Err(unknown()); + } + Ok(snapshot) + } + + /// The objects of the snapshot of the record the request names that the + /// caller lacks, as a pack, parents before children: those none of the + /// snapshots it has holds at the same path, under the paths it asks + /// for, but the first `skip`. The content of paths the caller may not + /// read is withheld. See [`Store::added`]. The pack stops short at /// [`Pack::LIMIT`]: the rest comes from asking again, skipping what /// came. /// /// # Errors /// - /// Fails if the repository doesn't have the snapshot, or cannot read - /// it. - pub fn snapshot( - &self, - _caller: &Caller, - request: &FetchSnapshot, - ) -> Result { - let is_tree = |id: TreeId| -> Result { - let object = self.store.try_read(id.into())?; - Ok(object.is_some_and(|object| object.kind().ok() == Some(ObjectKind::Tree))) - }; - if !is_tree(request.snapshot)? { - return Err(ServiceError::UnknownSnapshot(request.snapshot)); - } + /// Fails if the record isn't a revision or an acceptance the caller + /// sees, or the repository cannot read its snapshot. + pub fn snapshot(&self, caller: &Caller, request: &FetchSnapshot) -> Result { + let rights = self.rights(caller)?; + let snapshot = + self.snapshot_of(&mut Reader::new(&self.store, &rights.paths), request.of)?; let mut have = Vec::new(); for &id in &request.have { - if is_tree(id)? { + let object = self.store.try_read(id.into())?; + if object.is_some_and(|object| object.kind().ok() == Some(ObjectKind::Tree)) { have.push(id); } } - let added = self.store.added(request.snapshot, &have, &request.paths)?; + let added = self.store.added( + snapshot, + &have, + &request.paths, + self.filter(&rights, request.of)?, + )?; let skip = usize::try_from(request.skip).unwrap_or(usize::MAX); let ids: Vec = added .iter() @@ -288,75 +431,197 @@ impl OriginService { }) } - /// What the repository holds of each change: its revisions and heads, - /// and the statements about them, by id. A change it never heard of - /// has none. + /// What a walk of the snapshot of `record` withholds from a caller with + /// `rights`, if anything. Everyone who sees the repository reads the + /// snapshots of `policy`: what each may do, and the keys that sign + /// acceptances, so they can verify every target. + fn filter<'r>( + &self, + rights: &'r Rights, + record: RecordId, + ) -> Result, ServiceError> { + if rights.paths.reads_all() { + return Ok(None); + } + if let RecordId::Acceptance(id) = record + && self.store.get(id)?.body().target == TargetName::POLICY.id() + { + return Ok(None); + } + Ok(Some(&rights.paths)) + } + + /// What `query` names, a directory or file of a snapshot the caller + /// sees, the snapshot, and what it withholds from the caller. + fn browse( + &self, + caller: &Caller, + query: &BrowseQuery, + ) -> Result<(Option, TreeId, TreeEntry), ServiceError> { + let rights = self.rights(caller)?; + let record = match &query.at { + At::Target(target) => { + let head = self + .store + .head(target)? + .ok_or_else(|| ServiceError::UnknownTarget(target.clone()))?; + RecordId::Acceptance(head.acceptance) + } + &At::Record(record) => record, + }; + let snapshot = self.snapshot_of(&mut Reader::new(&self.store, &rights.paths), record)?; + let withheld = self.filter(&rights, record)?.cloned(); + let entry = Snapshots::new(&self.store) + .entry_at(snapshot, &query.path)? + .ok_or_else(|| ServiceError::NoSuchPath(query.path.clone()))?; + if withheld + .as_ref() + .is_some_and(|access| !access.reads_entry(&query.path, &entry)) + { + return Err(Forbidden::Read(query.path.clone()).into()); + } + Ok((withheld, snapshot, entry)) + } + + /// Lists a directory of a snapshot: of a target's tip, or of a revision + /// or an acceptance the caller sees. Entries whose content is withheld + /// from the caller keep their place, marked. + /// + /// # Errors + /// + /// Fails if the target or record doesn't exist or isn't seen, the path + /// isn't a directory, the caller reads nothing under it, or a tree + /// cannot be read. + pub fn tree(&self, caller: &Caller, query: &BrowseQuery) -> Result { + let (withheld, snapshot, entry) = self.browse(caller, query)?; + let tree = entry + .subtree() + .ok_or_else(|| ServiceError::NotADirectory(query.path.clone()))?; + let entries = self + .store + .get(tree)? + .iter() + .map(|(name, &entry)| Listed { + name: name.clone(), + kind: entry.kind, + id: entry.id, + withheld: withheld + .as_ref() + .is_some_and(|access| !access.reads_entry(&query.path.join(name), &entry)), + }) + .collect(); + Ok(Listing { + snapshot, + path: query.path.clone(), + entries, + }) + } + + /// The content of a file of a snapshot, as [`OriginService::tree`] + /// finds it, which the caller must read. /// /// # Errors /// - /// Fails if the index cannot be read. + /// Fails like [`OriginService::tree`], or if the path isn't a file. + pub fn file(&self, caller: &Caller, query: &BrowseQuery) -> Result, ServiceError> { + let (_, _, entry) = self.browse(caller, query)?; + if entry.kind.is_tree() { + return Err(ServiceError::NotAFile(query.path.clone())); + } + Ok(self + .store + .try_blob(entry.id)? + .ok_or(StoreError::NotFound(entry.id))?) + } + + /// What the repository holds of each change that the caller sees: its + /// revisions and heads, and the statements about them, by id. A change + /// it never heard of has none. + /// + /// # Errors + /// + /// Fails if the index or a record cannot be read. pub fn inventory( &self, - _caller: &Caller, + caller: &Caller, request: &InventoryRequest, ) -> Result, ServiceError> { + let rights = self.rights(caller)?; + let mut reader = Reader::new(&self.store, &rights.paths); self.annotations(|annotations| { let index = annotations.index(); - Ok(request - .changes - .iter() - .map(|&change| { - let revisions = index.revisions(change).to_vec(); - let mut subjects: Vec<_> = - revisions.iter().copied().map(RecordId::Revision).collect(); - let mut statements = Vec::new(); - while let Some(subject) = subjects.pop() { - for &statement in index.statements(subject) { - statements.push(statement); - subjects.push(RecordId::Statement(statement)); - } + let mut inventories = Vec::new(); + for &change in &request.changes { + let mut revisions = Vec::new(); + for &id in index.revisions(change) { + if reader.sees(RecordId::Revision(id))? { + revisions.push(id); } - Inventory { - change, - heads: index.heads(change), - revisions, - statements, + } + let mut heads = index.heads(change); + heads.retain(|head| revisions.contains(head)); + let mut subjects: Vec<_> = + revisions.iter().copied().map(RecordId::Revision).collect(); + let mut statements = Vec::new(); + while let Some(subject) = subjects.pop() { + for &statement in index.statements(subject) { + statements.push(statement); + subjects.push(RecordId::Statement(statement)); } - }) - .collect()) + } + inventories.push(Inventory { + change, + heads, + revisions, + statements, + }); + } + Ok(inventories) }) } - /// Writes the objects of `pack`: snapshots, revisions and statements. - /// Each must be well formed, and the repository must have, or the - /// pack hold, everything it needs, of the kind it names: a tree's - /// entries, trees or blobs as each says, a revision's snapshot, a tree, - /// its description, base and dependencies, a statement's subject and - /// the statements it supersedes or promotes. So the repository never - /// holds a record it cannot use. Acceptances are - /// refused: only a target's authority writes them, by landing. The - /// blobs and trees are durable before the records that refer to them, - /// statements before the revisions they're about, and promotions come - /// last, each checked against its original by - /// [`Store::claim`], once the original and both subjects are stored. - /// Issues `core.divergent` for the changes the revisions made diverge. + /// Writes the objects of `pack`: snapshots, attachments, revisions and + /// statements. Each must be well formed, and the repository must have, + /// or the pack hold, everything it needs, of the kind it names: a + /// tree's entries, trees or blobs as each says, a revision's snapshot, + /// a tree, its description, an attachment, its base and dependencies, a + /// statement's subject and the statements it supersedes or promotes. + /// So the repository never holds a record it cannot use. A payload must + /// be an attachment too, if the repository has it, and a + /// `core.conflict`'s, its list, must be there. Acceptances are + /// refused: only a target's authority writes them, by landing. A + /// statement of a reserved kind must come from whom + /// [`Policy::authorizes`] names, whoever puts it. The records a caller + /// other than the owner puts must be theirs, about what they see, and + /// may only change what they write, as + /// [`permissions`](crate::permissions) explains. Records are read from + /// the pack before the repository, so a pack may hold a stack. The blobs and trees + /// are durable before the records that refer to them, statements before + /// the revisions they're about, and promotions come last, each checked + /// against its original by [`Store::claim`], + /// once the original and both subjects are stored. Issues + /// `core.divergent` for the changes the revisions made diverge. /// /// # Errors /// - /// Fails if an object is malformed, an acceptance, or needs an object - /// neither the pack nor the repository has, or one of another kind, if - /// a promotion doesn't match its original, which leaves the other - /// records stored, or if the objects cannot be written. + /// Fails if the caller is anonymous, an object is malformed, an + /// acceptance, or needs an object neither the pack nor the repository + /// has, or one of another kind, if the caller may not put a record, or + /// doesn't see what a statement is about, if a reserved kind's issuer + /// may not issue it, if a promotion doesn't match its original, which + /// leaves the other records stored, or if the objects cannot be + /// written. pub fn put_records(&self, caller: &Caller, pack: Pack) -> Result { self.push(caller, pack, &[]) } /// Puts `pack` like [`OriginService::put_records`], if each change of - /// `expect` has the heads it names when its records are published: a - /// compare-and-swap, which every other publication of revisions waits - /// for, puts and the landing queue's rebases alike. So of two pushes - /// made from the same view of a change, or a push and a rebase, the - /// second is refused instead of making it diverge. + /// `expect` has the heads it names, of those the caller sees, when its + /// records are published: a compare-and-swap, which every other + /// publication of revisions waits for, puts and the landing queue's + /// rebases alike. So of two pushes made from the same view of a change, + /// or a push and a rebase, the second is refused instead of making it + /// diverge. /// /// # Errors /// @@ -364,10 +629,13 @@ impl OriginService { /// `expect` moved, which publishes no record, only snapshots. pub fn push( &self, - _caller: &Caller, + caller: &Caller, pack: Pack, expect: &[Expected], ) -> Result { + if *caller == Caller::Anonymous { + return Err(Forbidden::Anonymous.into()); + } // Objects this finds stored stay until what refers to them is. let _held = self.store.hold_objects()?; let held: HashMap> = pack @@ -385,13 +653,14 @@ impl OriginService { let mut statements = Vec::new(); let mut revisions = Vec::new(); let mut promotions = Vec::new(); + let mut decoded = Vec::new(); let mut changes = HashSet::new(); for object in pack.objects { let id = object.id(); - let decoded = object + let record = object .decode() .map_err(|source| ServiceError::Malformed { id, source })?; - for (needed, kind) in Dependencies::of(&decoded).all() { + for (needed, kind) in Dependencies::of(&record).all() { match held.get(&needed) { Some(&held) if held != Some(kind) => { return Err(ServiceError::WrongKind { id, needed, kind }); @@ -411,19 +680,31 @@ impl OriginService { None => return Err(ServiceError::Missing { id, needed }), } } - match decoded { + Self::check_payload(id, &record, |id| match held.get(&id) { + Some(&kind) => Ok(kind), + None => Ok(self + .store + .try_read(id)? + .and_then(|object| object.kind().ok())), + })?; + match record { Object::Acceptance(_) => return Err(ServiceError::Acceptance(id.into())), - Object::Blob(_) | Object::Tree(_) => snapshots.push(object), - Object::Revision(revision) => { + Object::Blob(_) | Object::Tree(_) | Object::Attachment(_) => snapshots.push(object), + Object::Revision(ref revision) => { if !revision.predecessors.is_empty() { changes.insert(revision.change); } revisions.push(object); + decoded.push((id, record)); } - Object::Statement(statement) if statement.promoted_from.is_some() => { - promotions.push((object, statement)); + Object::Statement(ref statement) if statement.promoted_from.is_some() => { + promotions.push((object, statement.clone())); + decoded.push((id, record)); + } + Object::Statement(_) => { + statements.push(object); + decoded.push((id, record)); } - Object::Statement(_) => statements.push(object), } } self.check_kinds(&stored)?; @@ -432,11 +713,12 @@ impl OriginService { if !snapshots.is_empty() { new += self.store.write_all(&snapshots)?; } + self.check_writes(caller, &held, &decoded)?; // Held until the records are published, so nothing else moves the // changes in between: see `Store::lock_pushes`. let _pushing = if !revisions.is_empty() || !expect.is_empty() { let lock = self.store.lock_pushes()?; - self.check_heads(expect)?; + self.check_heads(caller, expect)?; Some(lock) } else { None @@ -449,21 +731,7 @@ impl OriginService { new += self.store.write_all(&layer)?; } } - // An original is never a promotion, so it's stored by now. - let mut checked = Vec::new(); - for (object, statement) in promotions { - let id = object.id().into(); - self.store - .claim(id, statement)? - .map_err(|invalid| ServiceError::Statement { - id, - source: Box::new(invalid.into()), - })?; - checked.push(object); - } - if !checked.is_empty() { - new += self.store.write_all(&checked)?; - } + new += self.put_promotions(promotions)?; if !changes.is_empty() { self.annotations(|annotations| { for &change in &changes { @@ -476,21 +744,72 @@ impl OriginService { Ok(Stored { new: new as u64 }) } - /// Checks that each change of `expect` has the heads it names. - fn check_heads(&self, expect: &[Expected]) -> Result<(), ServiceError> { + /// Checks that the caller may put the records of `decoded`, which a + /// batch of the objects `held` brings: anyone's statements of a + /// reserved kind must come from whom [`Policy::authorizes`] names, and + /// those of a caller other than the owner must be theirs, as + /// [`permissions`](crate::permissions) explains. + fn check_writes( + &self, + caller: &Caller, + held: &HashMap>, + decoded: &[(ObjectId, Object)], + ) -> Result<(), ServiceError> { + let policy = self.policy()?; + let rights = self.rights(caller)?; + let batch = Batch::new(&self.store, held.keys().copied().collect(), decoded); + let Some(principal) = caller.principal() else { + return self.check_records(&batch, decoded, None, |issuer| { + policy.rights(Some(issuer)).admin + }); + }; + // A signed-in caller's token may narrow them out of `admin`. self.annotations(|annotations| { - for expected in expect { - let heads = annotations.index().heads(expected.change); - let now: HashSet<_> = heads.iter().collect(); - if now != expected.heads.iter().collect() { - return Err(ServiceError::Moved { - change: expected.change, - heads, - }); + let writer = Writer::new(&batch, annotations, principal, &rights); + self.check_records(&batch, decoded, Some(writer), |_| rights.admin) + }) + } + + /// Checks the records of `batch`, `decoded`, as + /// [`OriginService::check_writes`] explains, with `writer`'s checks for + /// a signed-in caller, whose statements' issuers `admin` says are + /// `admin` or not. + fn check_records( + &self, + batch: &Batch<'_, Native>, + decoded: &[(ObjectId, Object)], + mut writer: Option>, + admin: impl Fn(&Principal) -> bool, + ) -> Result<(), ServiceError> { + for (id, record) in decoded { + // Re-putting a record changes nothing. + if self.store.contains(*id)? { + continue; + } + match record { + Object::Revision(revision) => { + if let Some(writer) = &mut writer { + writer.revision((*id).into(), revision)?; + } } + Object::Statement(statement) => { + if !Policy::authorizes_admin(batch, statement, admin(&statement.issuer))? { + return Err(ServiceError::Statement { + id: (*id).into(), + source: Box::new(AnnotateError::Reserved { + kind: statement.kind.clone(), + issuer: statement.issuer.clone(), + }), + }); + } + if let Some(writer) = &mut writer { + writer.statement(statement)?; + } + } + _ => {} } - Ok(()) - }) + } + Ok(()) } /// Checks that each object of `stored`, which the repository has, is of @@ -513,35 +832,125 @@ impl OriginService { checked.into_iter().collect() } + /// Checks that the payload `record`, `id`, carries, if any, is an + /// attachment, as far as `kind` knows the object: the repository may + /// lack it, as a payload is a detail, but for a `core.conflict`'s, the + /// list of conflicts that reading it needs (see + /// [`Conflicts::listed`](crate::conflict::Conflicts::listed)). + fn check_payload( + id: ObjectId, + record: &Object, + kind: impl Fn(ObjectId) -> Result, StoreError>, + ) -> Result<(), ServiceError> { + let Object::Statement(statement) = record else { + return Ok(()); + }; + let Some(payload) = statement.payload else { + return Ok(()); + }; + let needed = payload.into(); + match kind(needed)? { + None if statement.kind == Kind::CONFLICT => Err(ServiceError::Missing { id, needed }), + Some(kind) if kind != ObjectKind::Attachment => Err(ServiceError::WrongKind { + id, + needed, + kind: ObjectKind::Attachment, + }), + _ => Ok(()), + } + } + + /// Checks that each change of `expect` has the heads it names, of those + /// the caller sees. + fn check_heads(&self, caller: &Caller, expect: &[Expected]) -> Result<(), ServiceError> { + let rights = self.rights(caller)?; + let mut reader = Reader::new(&self.store, &rights.paths); + self.annotations(|annotations| { + for expected in expect { + let mut heads = Vec::new(); + for head in annotations.index().heads(expected.change) { + if reader.sees(RecordId::Revision(head))? { + heads.push(head); + } + } + let now: HashSet<_> = heads.iter().collect(); + if now != expected.heads.iter().collect() { + return Err(ServiceError::Moved { + change: expected.change, + heads, + }); + } + } + Ok(()) + }) + } + + /// Writes the `promotions` [`OriginService::put_records`] puts, once + /// [`Store::claim`] accepts each, returning how many were new. Their + /// originals are never promotions, so they're stored by now. + fn put_promotions( + &self, + promotions: Vec<(EncodedObject, Statement)>, + ) -> Result { + let mut checked = Vec::new(); + for (object, statement) in promotions { + let id = object.id().into(); + self.store + .claim(id, statement)? + .map_err(|invalid| ServiceError::Statement { + id, + source: Box::new(invalid.into()), + })?; + checked.push(object); + } + if checked.is_empty() { + return Ok(0); + } + Ok(self.store.write_all(&checked)?) + } + /// The statements about `subject`, in the order the repository received - /// them. + /// them: none if the caller doesn't see it. /// /// # Errors /// /// Fails if the index or a statement cannot be read. pub fn statements( &self, - _caller: &Caller, + caller: &Caller, subject: RecordId, ) -> Result, ServiceError> { + let rights = self.rights(caller)?; + if !Reader::new(&self.store, &rights.paths).sees(subject)? { + return Ok(Vec::new()); + } self.annotations(|annotations| Ok(annotations.about(subject)?)) } - /// The revisions of `change`, and its heads. + /// The revisions of `change` the caller sees, and its heads among them. /// /// # Errors /// - /// Fails if the repository has no revision of the change, or cannot - /// read one. - pub fn revisions(&self, _caller: &Caller, change: ChangeId) -> Result { - let (ids, heads) = self.annotations(|annotations| { + /// Fails if the repository has no revision of the change the caller + /// sees, or cannot read one. + pub fn revisions(&self, caller: &Caller, change: ChangeId) -> Result { + let rights = self.rights(caller)?; + let mut reader = Reader::new(&self.store, &rights.paths); + let (mut ids, mut heads) = self.annotations(|annotations| { let index = annotations.index(); Ok((index.revisions(change).to_vec(), index.heads(change))) })?; - if ids.is_empty() { + let mut seen = Vec::with_capacity(ids.len()); + for id in ids.drain(..) { + if reader.sees(RecordId::Revision(id))? { + seen.push(id); + } + } + heads.retain(|head| seen.contains(head)); + if seen.is_empty() { return Err(ServiceError::UnknownChange(change)); } - let revisions = ids + let revisions = seen .into_iter() .map(|id| { Ok(RevisionEntry { @@ -554,12 +963,16 @@ impl OriginService { } /// Every target, by name, with its tip, and its authority and - /// requirements under the policy in force. + /// requirements under the policy in force: none, if the caller doesn't + /// see the repository. /// /// # Errors /// /// Fails if the targets or the policy cannot be read. - pub fn targets(&self, _caller: &Caller) -> Result, ServiceError> { + pub fn targets(&self, caller: &Caller) -> Result, ServiceError> { + if !self.sees_repository(caller)? { + return Ok(Vec::new()); + } let policy = Policy::load(&self.store)?; let mut names = self.store.targets()?; names.sort(); @@ -583,11 +996,11 @@ impl OriginService { /// /// # Errors /// - /// Fails if the target doesn't exist, or its log or an acceptance - /// cannot be read. - pub fn log(&self, _caller: &Caller, query: &LogQuery) -> Result, ServiceError> { + /// Fails if the target doesn't exist, or the caller doesn't see the + /// repository, or its log or an acceptance cannot be read. + pub fn log(&self, caller: &Caller, query: &LogQuery) -> Result, ServiceError> { let target = &query.target; - if self.store.head(target)?.is_none() { + if !self.sees_repository(caller)? || self.store.head(target)?.is_none() { return Err(ServiceError::UnknownTarget(target.clone())); } let mut ids = self.store.log_since(target, query.since)?; @@ -607,16 +1020,29 @@ impl OriginService { /// Lands what `request` proposes, with this node as the authority: a /// change with the changes below it the target doesn't have yet, or /// another target's tip. A change lands by default on the target its - /// stack is on. + /// stack is on. The caller must be signed in, may land on the target, + /// and must write every path the landing changes on it. /// /// # Errors /// /// Fails if the change or a target doesn't exist, a target was given - /// no target to land on, this node isn't the target's authority, the - /// change can't land as it is (see [`LandError`]), or the records - /// cannot be read or written. - pub fn land(&self, _caller: &Caller, request: &LandRequest) -> Result { - let queue = Queue::new(&self.store, &self.node.key, self.node.name.clone()); + /// no target to land on, the caller may not land there, this node isn't + /// the target's authority, the change can't land as it is (see + /// [`LandError`]), or the records cannot be read or written. + pub fn land(&self, caller: &Caller, request: &LandRequest) -> Result { + if *caller == Caller::Anonymous { + return Err(Forbidden::Anonymous.into()); + } + let rights = self.rights(caller)?; + let may_land = |onto: &TargetName| { + if rights.may_land(onto) { + Ok(()) + } else { + Err(ServiceError::from(Forbidden::Land(onto.clone()))) + } + }; + let queue = Queue::new(&self.store, &self.node.key, self.node.name.clone()) + .submitted_by(rights.paths.clone()); let landed = self.annotations(|annotations| { let landed = match &request.proposal { Proposal::Change(change) => { @@ -624,11 +1050,13 @@ impl OriginService { Some(onto) => onto.clone(), None => self.target_of(annotations.index(), *change)?, }; + may_land(&onto)?; let landings = queue.land_change(annotations, *change, &onto)?; Landings { onto, landings } } Proposal::Target(source) => { let onto = request.onto.clone().ok_or(ServiceError::NoTarget)?; + may_land(&onto)?; let landing = queue.land_target(annotations, source, &onto)?; Landings { onto, @@ -660,50 +1088,68 @@ impl OriginService { Ok(self.store.stack_target(head)?) } - /// Every landing that waits, on every target, and why. + /// Every landing that waits, on every target, and why: those about what + /// the caller sees. /// /// # Errors /// - /// Fails if the queues cannot be read. - pub fn landings(&self, _caller: &Caller) -> Result, ServiceError> { - Ok(Queue::pending(&self.store)?) + /// Fails if the queues or a record cannot be read. + pub fn landings(&self, caller: &Caller) -> Result, ServiceError> { + let rights = self.rights(caller)?; + let mut reader = Reader::new(&self.store, &rights.paths); + let mut seen = Vec::new(); + for pending in Queue::pending(&self.store)? { + if reader.sees(pending.subject)? { + seen.push(pending); + } + } + Ok(seen) } /// Creates a target at the tip of another, with this node as its - /// authority. + /// authority. The caller must be signed in, and may land on it. /// /// # Errors /// - /// Fails if the name is taken, the other target doesn't exist, the - /// policy wouldn't make this node the new target's authority, or the - /// records cannot be written. + /// Fails if the caller may not land on the target, the name is taken, + /// the other target doesn't exist, the policy wouldn't make this node + /// the new target's authority, or the records cannot be written. pub fn create_target( &self, - _caller: &Caller, + caller: &Caller, request: &NewTarget, ) -> Result { + if *caller == Caller::Anonymous { + return Err(Forbidden::Anonymous.into()); + } + if !self.rights(caller)?.may_land(&request.name) { + return Err(Forbidden::Land(request.name.clone()).into()); + } let queue = Queue::new(&self.store, &self.node.key, self.node.name.clone()); let head = queue.create_target(&request.name, &request.from)?; self.wakeup.notify(); Ok(head) } - /// The events after the query's cursor that pass its filter, waiting - /// up to its `wait`, at most [`OriginService::MAX_WAIT`], for some to - /// come. Without a cursor, only events from now on come. + /// The events after the query's cursor that pass its filter, about what + /// the caller sees, waiting up to its `wait`, at most + /// [`OriginService::MAX_WAIT`], for some to come. Without a cursor, only + /// events from now on come. /// /// # Errors /// /// Fails if the op log no longer keeps the events after the cursor, or /// isn't of the generation the query names, or it or a record cannot /// be read. - pub fn events(&self, _caller: &Caller, query: &EventsQuery) -> Result { + pub fn events(&self, caller: &Caller, query: &EventsQuery) -> Result { let generation = self.store.oplog().generation()?; if let Some(theirs) = &query.generation && *theirs != generation { return Err(ServiceError::OtherLog(theirs.clone())); } + let rights = self.rights(caller)?; + let mut reader = Reader::new(&self.store, &rights.paths); let events = Events::new(&self.store).waking_on(&self.wakeup); let cursor = match query.cursor { Some(cursor) => cursor, @@ -718,7 +1164,8 @@ impl OriginService { .unwrap_or(Self::MAX_EVENTS) .min(Self::MAX_EVENTS) as usize; let wait = query.wait.map_or(Duration::ZERO, |wait| wait.0); - let page = events.wait(cursor, &filter, limit, wait.min(Self::MAX_WAIT))?; + let accept = |event: &EventKind| Ok(filter.matches(event) && reader.sees_event(event)?); + let page = events.wait(cursor, accept, limit, wait.min(Self::MAX_WAIT))?; Ok(Page { generation, ..page }) } } @@ -734,14 +1181,14 @@ impl OriginService { pub(crate) struct Dependencies { /// Revisions, statements and acceptances. pub records: Vec<(ObjectId, ObjectKind)>, - /// Trees and blobs. + /// Trees, blobs and attachments. pub content: Vec<(ObjectId, ObjectKind)>, } impl Dependencies { pub(crate) fn of(object: &Object) -> Self { match object { - Object::Blob(_) | Object::Acceptance(_) => Self::default(), + Object::Blob(_) | Object::Attachment(_) | Object::Acceptance(_) => Self::default(), Object::Tree(tree) => Self { records: Vec::new(), content: tree @@ -765,7 +1212,7 @@ impl Dependencies { records, content: vec![ (revision.snapshot.into(), ObjectKind::Tree), - (revision.description.into(), ObjectKind::Blob), + (revision.description.into(), ObjectKind::Attachment), ], } } @@ -818,9 +1265,52 @@ pub enum ServiceError { /// The repository has no such snapshot. #[error("snapshot {0} not found")] UnknownSnapshot(TreeId), - /// A blob or a tree was fetched as a record. + /// A blob, a tree or an attachment was fetched as a record. #[error("{0} is not a record: fetch snapshots by walking them")] NotARecord(ObjectId), + /// The repository has no such record, or the caller doesn't see it. + #[error("record {0} not found")] + UnknownRecord(RecordId), + /// A snapshot was asked of a record that has none. + #[error("{0} has no snapshot: name a revision or an acceptance")] + NotASnapshot(RecordId), + /// A snapshot has nothing at a path. + #[error("nothing is at `{0}`")] + NoSuchPath(RepoPath), + /// A path listed is a file. + #[error("`{0}` is not a directory")] + NotADirectory(RepoPath), + /// A path read is a directory. + #[error("`{0}` is not a file")] + NotAFile(RepoPath), + /// An object put refers to one as an object of another kind: a + /// snapshot that isn't a tree, a file that is one, a description that + /// isn't an attachment. + #[error("object {id} refers to {needed} as a {kind}, which it is not")] + WrongKind { + /// The object put. + id: ObjectId, + /// What it refers to. + needed: ObjectId, + /// The kind it must be. + kind: ObjectKind, + }, + /// A push expected a change's heads to be where they no longer are. + #[error( + "change {} moved on the remote since it was fetched: run `origin fetch`, then \ + `origin converge {}` if it diverged", + change.short(), + change.short() + )] + Moved { + /// The change. + change: ChangeId, + /// Its heads now. + heads: Vec, + }, + /// The caller may not do this. + #[error(transparent)] + Forbidden(#[from] Forbidden), /// A target was proposed for landing without a target to land on. #[error("landing a target needs a target to land on")] NoTarget, @@ -845,35 +1335,12 @@ pub enum ServiceError { /// What it needs. needed: ObjectId, }, - /// An object put refers to one as an object of another kind: a - /// snapshot that isn't a tree, a file that is one. - #[error("object {id} refers to {needed} as a {kind}, which it is not")] - WrongKind { - /// The object put. - id: ObjectId, - /// What it refers to. - needed: ObjectId, - /// The kind it must be. - kind: ObjectKind, - }, - /// A push expected a change's heads to be where they no longer are. - #[error( - "change {} moved on the remote since it was fetched: run `origin fetch`, then \ - `origin converge {}` if it diverged", - change.short(), - change.short() - )] - Moved { - /// The change. - change: ChangeId, - /// Its heads now. - heads: Vec, - }, /// An events cursor is of another op log than the repository's. #[error("the cursor is of another op log, generation {0}: read from the end")] OtherLog(String), /// A statement put isn't one the repository accepts: a promotion that - /// doesn't match its original. + /// doesn't match its original, or a reserved kind from an issuer who + /// may not issue it. #[error("statement {} cannot be put", .id.short())] Statement { /// The statement. @@ -892,6 +1359,16 @@ pub enum ServiceError { Store(#[from] StoreError), } +impl From for ServiceError { + fn from(error: PutError) -> Self { + match error { + PutError::Forbidden(error) => Self::Forbidden(error), + PutError::Unseen(record) => Self::UnknownRecord(record), + PutError::Store(error) => Self::Store(error), + } + } +} + impl ServiceError { /// What kind of failure it is, for clients to tell apart. #[must_use] @@ -900,17 +1377,24 @@ impl ServiceError { Self::UnknownRepository(_) | Self::UnknownChange(_) | Self::UnknownTarget(_) - | Self::UnknownSnapshot(_) => ErrorCode::NotFound, + | Self::UnknownSnapshot(_) + | Self::UnknownRecord(_) + | Self::NoSuchPath(_) => ErrorCode::NotFound, Self::NoTarget | Self::NotARecord(_) + | Self::NotASnapshot(_) + | Self::NotADirectory(_) + | Self::NotAFile(_) + | Self::WrongKind { .. } | Self::Malformed { .. } | Self::Acceptance(_) - | Self::Missing { .. } - | Self::WrongKind { .. } => ErrorCode::BadRequest, + | Self::Missing { .. } => ErrorCode::BadRequest, Self::Statement { source, .. } => match &**source { AnnotateError::Store(error) => Self::store_code(error), + AnnotateError::Reserved { .. } => ErrorCode::Forbidden, _ => ErrorCode::BadRequest, }, + Self::Forbidden(_) => ErrorCode::Forbidden, Self::Moved { .. } => ErrorCode::Conflict, Self::OtherLog(_) => ErrorCode::Expired, Self::Land(error) => match error { @@ -925,6 +1409,7 @@ impl ServiceError { | LandError::Abandoned(_) | LandError::Unresolved { .. } | LandError::InvalidPolicy(_) => ErrorCode::Conflict, + LandError::Forbidden(_) => ErrorCode::Forbidden, LandError::Store(error) => Self::store_code(error), LandError::Policy(_) | LandError::Annotate(_) => ErrorCode::Internal, }, @@ -949,15 +1434,16 @@ mod tests { use super::*; - #[test] - fn pushes_publish_statements_before_the_revisions_they_are_about() { - let repo = crate::queue::tests::Repo::new("[targets.main]\nauthority = \"node:queue\"\n"); + /// A service of `repo`, and the objects that put a revision with a + /// conflict: its description, the conflict's payload, the revision and + /// the conflict. + fn conflicted(repo: &crate::queue::tests::Repo) -> (OriginService, [EncodedObject; 4]) { let node = Node { name: "queue".parse().unwrap(), key: Arc::new(NodeKey::from_bytes(&[7; 32])), }; let service = OriginService::new(repo.store.clone(), node); - let description = EncodedObject::blob(b""); + let description = EncodedObject::attachment(b""); let revision = origin_shared::Revision { change: origin_shared::ChangeId::from_bytes([1; 16]), predecessors: Vec::new(), @@ -974,10 +1460,18 @@ mod tests { }]); let subject = RecordId::Revision(revision.id().into()); let (payload, conflict) = conflicts.statement(subject, "user:ada".parse().unwrap(), None); - let conflict = conflict.encode(); + (service, [description, payload, revision, conflict.encode()]) + } + + #[test] + fn pushes_publish_statements_before_the_revisions_they_are_about() { + let repo = crate::queue::tests::Repo::new("[targets.main]\nauthority = \"node:queue\"\n"); + let (service, objects) = conflicted(&repo); + let [_, _, revision, conflict] = objects.clone(); + let subject = RecordId::Revision(revision.id().into()); let oplog = repo.store.oplog(); let before = oplog.last_seq().unwrap(); - let objects = vec![description, payload, revision, conflict.clone()]; + let objects = objects.to_vec(); service .put_records(&Caller::Owner, Pack { objects }) .unwrap(); @@ -997,6 +1491,31 @@ mod tests { assert!(written(conflict) < written(subject)); } + #[test] + fn conflicts_come_with_the_list_they_carry() { + let repo = crate::queue::tests::Repo::new("[targets.main]\nauthority = \"node:queue\"\n"); + let (service, [description, payload, revision, conflict]) = conflicted(&repo); + let without = vec![description, revision, conflict.clone()]; + let refused = service.put_records( + &Caller::Owner, + Pack { + objects: without.clone(), + }, + ); + assert!( + matches!(refused, Err(ServiceError::Missing { id, needed }) + if id == conflict.id() && needed == payload.id()), + "{refused:?}" + ); + assert!(!repo.store.contains(conflict.id()).unwrap()); + // Stored already, the list needn't come again. + repo.store.write_all(&[payload]).unwrap(); + service + .put_records(&Caller::Owner, Pack { objects: without }) + .unwrap(); + assert!(repo.store.contains(conflict.id()).unwrap()); + } + #[test] fn calls_using_the_index_do_not_wait_for_each_other() { let dir = tempfile::tempdir().unwrap(); diff --git a/apps/origin-src/src/store/mod.rs b/apps/origin-src/src/store/mod.rs index b72b7276..511f1d8e 100644 --- a/apps/origin-src/src/store/mod.rs +++ b/apps/origin-src/src/store/mod.rs @@ -35,8 +35,9 @@ use std::thread; use std::time::{Duration, SystemTime}; use origin_platform::{DurableWrite, LockMode, LockedFile, Native, Platform, WritableError}; +use origin_shared::access::PathAccess; use origin_shared::object::{DecodeError, IdMismatch}; -use origin_shared::{EncodedObject, Head, ObjectId, TargetName, TypedId}; +use origin_shared::{AttachmentId, EncodedObject, Head, ObjectId, TargetName, TypedId}; use crate::index::Entry; use crate::oplog::{Actor, Effect}; @@ -55,6 +56,9 @@ pub struct Store

{ packs: Arc, /// Who the op log says writes. pub(crate) actor: Actor, + /// What this repository may read of its remote's snapshots, if the + /// remote withholds some paths: see [`Store::with_withheld`]. + pub(crate) readable: Option>, } impl Store

{ @@ -75,6 +79,7 @@ impl Store

{ platform, packs: Arc::default(), actor: Actor::default(), + readable: None, } } @@ -84,6 +89,19 @@ impl Store

{ Self { actor, ..self } } + /// The store of a repository whose remote withholds the content of the + /// paths `readable` doesn't read: their trees keep the entries, but the + /// store lacks what they point to. Tree operations then never read + /// them: diffs skip them, merges compare them by id, and walks leave + /// them out (see [`crate::tree`]). + #[must_use] + pub fn with_withheld(self, readable: PathAccess) -> Self { + Self { + readable: (!readable.reads_all()).then(|| Arc::new(readable)), + ..self + } + } + /// Who the op log says writes. #[must_use] pub fn actor(&self) -> &Actor { @@ -510,6 +528,25 @@ impl Store

{ Ok(Some(content.to_vec())) } + /// Reads an attachment's content, or `None` if the store does not have + /// it. + /// + /// # Errors + /// + /// Fails if the object cannot be read, is corrupt, or isn't an + /// attachment, such as a blob whose id a record names as its + /// attachment. + pub fn try_attachment(&self, id: AttachmentId) -> Result>, StoreError> { + let id = id.into(); + let Some(object) = self.try_read(id)? else { + return Ok(None); + }; + let content = object + .attachment_content() + .map_err(|source| StoreError::Decode { id, source })?; + Ok(Some(content.to_vec())) + } + /// Reads an object the store must have. /// /// # Errors diff --git a/apps/origin-src/src/sync.rs b/apps/origin-src/src/sync.rs index 5323d013..bcd18ca3 100644 --- a/apps/origin-src/src/sync.rs +++ b/apps/origin-src/src/sync.rs @@ -20,13 +20,16 @@ //! are all the changes this node knows compared. //! - Snapshots don't come with records: [`Sync::need`] fetches one when //! something reads it, such as a checkout, so a clone fetches one -//! snapshot, its target's tip. A snapshot is marked while it's fetched -//! ([`Store::fetching`]), so one whose fetch was interrupted is fetched -//! again rather than taken for whole. -//! - **Pushing** shares revisions, with their snapshots, and statements. It -//! never lands anything. It first compares each change's heads with the -//! remote's, and refuses before uploading anything when the remote has a -//! revision this node lacks: the change is stale. The remote checks the +//! snapshot, its target's tip. The remote withholds the content of the +//! paths the user may not read: the trees keep their place, and nothing +//! reads them (see [`Store::with_withheld`]). A snapshot is marked while +//! it's fetched ([`Store::fetching`]), so one whose fetch was interrupted +//! is fetched again rather than taken for whole. +//! - **Pushing** shares revisions, with their snapshots, and the user's +//! statements. It never lands anything. It first compares each change's +//! heads with the remote's, and refuses before uploading anything when +//! the remote has a revision this node lacks: the change is stale, or a +//! revision changes a path the user may not write. The remote checks the //! heads again as it publishes the revisions, so a push racing another //! is refused too. Revisions only go to a remote that has the acceptance //! they're based on. Each object goes after those it needs: snapshots @@ -50,6 +53,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::{iter, mem}; use origin_platform::Native; +use origin_shared::access::{Level, PathAccess}; use origin_shared::api::{ ErrorCode, EventsQuery, Expected, FetchRecords, FetchSnapshot, Inventory, InventoryRequest, List, LogQuery, PutQuery, @@ -59,12 +63,13 @@ use origin_shared::object::{BadSignature, DecodeError}; use origin_shared::pack::{Pack, Packer, Part}; use origin_shared::{ Acceptance, AcceptanceId, Base, ChangeId, EncodedObject, Head, ObjectId, ObjectKind, Principal, - PrincipalName, Record, RecordId, Revision, RevisionId, Source, Statement, StatementId, - TargetName, TreeId, + PrincipalName, Record, RecordId, RepoPath, Revision, RevisionId, Source, Statement, + StatementId, TargetName, TreeId, }; use crate::annotations::Annotations; use crate::index::Index; +use crate::permissions::{Effects, Forbidden}; use crate::policy::{Policy, PolicyError}; use crate::remote::{Remote, RemoteError}; use crate::service::Dependencies; @@ -76,6 +81,10 @@ pub struct Sync<'a> { remote: &'a Remote, /// This node, which flags the divergence fetched revisions reveal. node: Principal, + /// The user, whose statements pushes share. + user: Principal, + /// What the user may write on the remote, as of the last fetch. + writes: PathAccess, /// The most bytes a push's request holds: [`Pack::LIMIT`], but in /// tests. pack_limit: usize, @@ -144,7 +153,7 @@ pub struct Pushed { /// Every object sent: those, and the blobs and trees of their /// snapshots the remote lacked. pub objects: usize, - /// The statements that stay here: they need a record the remote + /// The user's statements that stay here: they need a record the remote /// neither has nor got, such as a statement they supersede about /// another change, or the original they promote. pub unsent: Vec, @@ -356,16 +365,29 @@ impl<'a> Sync<'a> { /// server sends. const EVENTS: u32 = 1024; - /// Syncs `store` with `remote`, as the node `node`. - pub fn new(store: &'a Store, remote: &'a Remote, node: PrincipalName) -> Self { + /// Syncs `store` with `remote`, as the node `node`, for `user`, who + /// writes everything until [`Sync::writing`] says otherwise. + pub fn new(store: &'a Store, remote: &'a Remote, node: PrincipalName, user: Principal) -> Self { Self { store, remote, node: Principal::Node(node), + user, + writes: PathAccess::uniform(Level::Write), pack_limit: Pack::LIMIT, } } + /// Syncs knowing the user may only write `paths`, so pushes that would + /// be refused fail before sending anything. + #[must_use] + pub fn writing(self, paths: PathAccess) -> Self { + Self { + writes: paths, + ..self + } + } + /// Syncs pushing requests of at most `limit` bytes. #[cfg(test)] fn with_pack_limit(self, limit: usize) -> Self { @@ -575,7 +597,7 @@ impl<'a> Sync<'a> { }; // Small, and the policy in force once it's the tip. if chain.target == TargetName::POLICY { - self.need(body.state, &[])?; + self.fetch_snapshot(RecordId::Acceptance(*id), body.state, &[])?; } let governing = Policy::governing(*id, body) .map_err(|error| unverified(Unverified::Policy(error)))?; @@ -592,7 +614,7 @@ impl<'a> Sync<'a> { self.on_policy_chain(governing)? .ok_or_else(|| unverified(Unverified::OffChain(governing)))? }; - self.need(state, &[])?; + self.fetch_snapshot(RecordId::Acceptance(governing), state, &[])?; let policy = Policy::of_snapshot(self.store, state)? .ok_or(unverified(Unverified::NoPolicy))?; vacant.insert(policy) @@ -883,20 +905,56 @@ impl<'a> Sync<'a> { Ok(objects) } - /// Fetches the trees and blobs of `snapshot`, unless the store holds it - /// whole, skipping what the snapshots `have`, which it holds whole, - /// hold at the same path. Returns whether it fetched. + /// Fetches the trees and blobs of the snapshot of `record`, a revision + /// or an acceptance this node has, unless the store holds it whole, + /// skipping what the snapshots `have`, which it holds whole, hold at + /// the same path. Returns whether it fetched. /// /// # Errors /// - /// Fails if the remote doesn't have the snapshot, or the remote or the - /// store fails. - pub fn need(&self, snapshot: TreeId, have: &[TreeId]) -> Result { + /// Fails if the record has no snapshot, the remote doesn't have it, or + /// the remote or the store fails. + pub fn need(&self, record: RecordId, have: &[TreeId]) -> Result { + let snapshot = self + .store + .snapshot_of(record)? + .ok_or(SyncError::NoSnapshot(record))?; + self.fetch_snapshot(record, snapshot, have) + } + + /// Fetches the whole snapshot of `record` again, whatever the store + /// holds: what the store lacks of a snapshot it has the root of, which + /// reading it found. + /// + /// # Errors + /// + /// Fails like [`Sync::need`]. + pub fn repair(&self, record: RecordId) -> Result<(), SyncError> { + let snapshot = self + .store + .snapshot_of(record)? + .ok_or(SyncError::NoSnapshot(record))?; + let request = FetchSnapshot { + of: record, + have: Vec::new(), + paths: Vec::new(), + skip: 0, + }; + self.fetch_parts(request, Some(snapshot)) + } + + /// [`Sync::need`], given the snapshot of `record`. + fn fetch_snapshot( + &self, + record: RecordId, + snapshot: TreeId, + have: &[TreeId], + ) -> Result { if self.store.holds(snapshot)? { return Ok(false); } let request = FetchSnapshot { - snapshot, + of: record, have: self.store.held_whole(have.iter().copied())?, paths: Vec::new(), skip: 0, @@ -905,21 +963,22 @@ impl<'a> Sync<'a> { Ok(true) } - /// Fetches `snapshot` whole again, whatever the store holds: what the - /// store lacks of a snapshot it has the root of, which reading it - /// found. + /// Fetches what the snapshot of `record` holds under `paths`, whatever + /// the store has: files a checkout lacks, such as those whose content + /// was withheld until the user could read them. /// /// # Errors /// - /// Fails like [`Sync::need`]. - pub fn repair(&self, snapshot: TreeId) -> Result<(), SyncError> { + /// Fails if the remote doesn't have the record, or the remote or the + /// store fails. + pub fn need_paths(&self, record: RecordId, paths: Vec) -> Result<(), SyncError> { let request = FetchSnapshot { - snapshot, + of: record, have: Vec::new(), - paths: Vec::new(), + paths, skip: 0, }; - self.fetch_parts(request, Some(snapshot)) + self.fetch_parts(request, None) } /// Fetches what `request` asks for, part after part, in one batch. With @@ -987,6 +1046,7 @@ impl<'a> Sync<'a> { { self.check_shared(*id, revision, base)?; } + self.check_writes(revision)?; } // Snapshots and attachments first, then revisions, then statements. @@ -994,10 +1054,7 @@ impl<'a> Sync<'a> { let mut seen = HashSet::new(); for (id, revision) in &revisions { // The remote has these snapshots, or gets them first. - let mut have = vec![match revision.base { - Base::Acceptance(base) => self.store.get(base)?.body().state, - Base::Revision(base) => self.store.get(base)?.snapshot, - }]; + let mut have = vec![self.store.base_snapshot(revision.base)?]; for &predecessor in &revision.predecessors { if shared.contains(&predecessor.into()) { have.push(self.store.get(predecessor)?.snapshot); @@ -1007,7 +1064,7 @@ impl<'a> Sync<'a> { // Children first: a tree goes after what it holds. for placed in self .store - .added(revision.snapshot, &have, &[])? + .added(revision.snapshot, &have, &[], None)? .into_iter() .rev() { @@ -1124,10 +1181,12 @@ impl<'a> Sync<'a> { .collect() } - /// The statements about the revisions of `changes`, and about + /// The user's statements about the revisions of `changes`, and about /// statements about them, that the remote lacks, each after those of /// them it needs, adding each to `shared`; and those that need a record - /// that isn't in `shared`, which stay. + /// that isn't in `shared`, which stay. Others' statements are theirs to + /// share, and this node's own, like divergence, the remote finds + /// itself. fn statements( &self, index: &Index, @@ -1150,7 +1209,10 @@ impl<'a> Sync<'a> { if shared.contains(&id.into()) { continue; } - waiting.push((id, self.store.get(id)?)); + let statement = self.store.get(id)?; + if statement.issuer == self.user { + waiting.push((id, statement)); + } } } let mut ordered = Vec::new(); @@ -1235,6 +1297,15 @@ impl<'a> Sync<'a> { Ok(()) } + /// Checks that `revision` has an effect the user may give it (see + /// [`Effects`]). + fn check_writes(&self, revision: &Revision) -> Result<(), SyncError> { + match Effects::new(self.store, self.store, &self.writes).check(revision)? { + Some(forbidden) => Err(forbidden.into()), + None => Ok(()), + } + } + /// `changes`, and every change below their revisions, following their /// bases and dependencies. fn stack(&self, index: &Index, changes: &[ChangeId]) -> Result, SyncError> { @@ -1333,6 +1404,12 @@ pub enum SyncError { /// The remote has no such target. #[error("the remote has no target `{0}`")] UnknownTarget(TargetName), + /// A snapshot was needed of a record that has none. + #[error("{0} has no snapshot")] + NoSnapshot(RecordId), + /// A push would change what the user may not. + #[error("the remote would refuse the push, as of your rights when you last fetched")] + Forbidden(#[from] Forbidden), /// The change has no revision to push. #[error("change {} has no revisions", .0.short())] UnknownChange(ChangeId), @@ -1409,7 +1486,7 @@ mod tests { use origin_platform::Native; use origin_shared::event::Filter; - use origin_shared::{AcceptanceBody, Kind, NodeKey, RepoPath, Verdict}; + use origin_shared::{AcceptanceBody, Kind, NodeKey, Verdict}; use super::*; use crate::events::Events; @@ -1452,7 +1529,12 @@ mod tests { } fn sync(&self) -> Sync<'_> { - Sync::new(&self.store, &self.remote, "laptop".parse().unwrap()) + Sync::new( + &self.store, + &self.remote, + "laptop".parse().unwrap(), + "user:ada".parse().unwrap(), + ) } /// Writes a revision of `change` with `files` on `base`, with every @@ -1468,7 +1550,7 @@ mod tests { .iter() .map(|(_, content)| EncodedObject::blob(content.as_bytes())) .collect(); - let description = EncodedObject::blob(b"work"); + let description = EncodedObject::attachment(b"work"); let revision = Revision { change: ChangeId::from_bytes([change; 16]), predecessors: predecessors.to_vec(), @@ -1540,8 +1622,9 @@ mod tests { !client.store.contains(state.into()).unwrap(), "snapshots are lazy" ); - assert!(client.sync().need(state, &[]).unwrap()); - assert!(!client.sync().need(state, &[]).unwrap(), "once"); + let tip = RecordId::Acceptance(server.tip(&TargetName::MAIN).acceptance); + assert!(client.sync().need(tip, &[]).unwrap()); + assert!(!client.sync().need(tip, &[]).unwrap(), "once"); assert_eq!(client.store.diff(state, state).unwrap(), []); assert_eq!(client.sync().fetch_chains(&[]).unwrap(), []); } @@ -1882,16 +1965,17 @@ mod tests { statements: 1 } ); - let snapshot = other.store.get(first).unwrap().snapshot; + let record = RecordId::Revision(first); let request = FetchSnapshot { - snapshot, + of: record, have: Vec::new(), paths: Vec::new(), skip: 0, }; let part = other.remote.snapshot(&request).unwrap(); assert!(part.more && part.pack.objects.len() < 10, "{part:?}"); - assert!(other.sync().need(snapshot, &[]).unwrap()); + assert!(other.sync().need(record, &[]).unwrap()); + let snapshot = other.store.snapshot_of(record).unwrap().unwrap(); let empty = build(&other.store, &[]); assert_eq!(other.store.diff(empty, snapshot).unwrap().len(), 20); } @@ -1906,7 +1990,8 @@ mod tests { .sync() .fetch_changes(&[ChangeId::from_bytes([1; 16])]) .unwrap(); - let snapshot = client.store.get(revision).unwrap().snapshot; + let record = RecordId::Revision(revision); + let snapshot = client.store.snapshot_of(record).unwrap().unwrap(); let empty = build(&client.store, &[]); // A fetch cut short once the root was published, not what it holds. @@ -1918,7 +2003,7 @@ mod tests { batch.commit().unwrap(); drop(fetching); assert!(!client.store.holds(snapshot).unwrap()); - assert!(client.sync().need(snapshot, &[]).unwrap(), "fetched again"); + assert!(client.sync().need(record, &[]).unwrap(), "fetched again"); assert!(client.store.holds(snapshot).unwrap()); assert_eq!(client.store.diff(empty, snapshot).unwrap().len(), 1); @@ -1929,7 +2014,7 @@ mod tests { .unwrap(); client.store.remove(&[src.id]).unwrap(); assert!(client.store.diff(empty, snapshot).is_err()); - client.sync().repair(snapshot).unwrap(); + client.sync().repair(record).unwrap(); assert_eq!(client.store.diff(empty, snapshot).unwrap().len(), 1); } @@ -2155,7 +2240,7 @@ mod tests { base: main_tip(&client.store), deps: Vec::new(), snapshot: build(&client.store, &[("a", "gone")]), - description: EncodedObject::blob(b"").id().into(), + description: EncodedObject::attachment(b"").id().into(), author: "user:ada".parse().unwrap(), }; client @@ -2165,15 +2250,15 @@ mod tests { let wire = |objects: Vec| Pack { objects }.encode().len(); let content = client .store - .added(revision.snapshot, &[], &[]) + .added(revision.snapshot, &[], &[], None) .unwrap() .iter() .map(|placed| client.store.read(placed.entry.id).unwrap()) .collect(); let room = Pack::LIMIT - wire(content) - - wire(vec![revision.encode(), EncodedObject::blob(b"")]); - let description = EncodedObject::blob(&vec![b'x'; room]); + - wire(vec![revision.encode(), EncodedObject::attachment(b"")]); + let description = EncodedObject::attachment(&vec![b'x'; room]); revision.description = description.id().into(); let revision = revision.encode(); client diff --git a/apps/origin-src/src/tree.rs b/apps/origin-src/src/tree.rs index 7ea9d1f4..055cbf02 100644 --- a/apps/origin-src/src/tree.rs +++ b/apps/origin-src/src/tree.rs @@ -1,4 +1,9 @@ //! Comparing trees, merging them, and looking up what they hold. +//! +//! In a repository whose remote withholds some paths (see +//! [`Store::with_withheld`]), nothing here reads what it lacks there: +//! diffs skip withheld paths, merges compare them by id, and walks leave +//! them out. use std::collections::hash_map::Entry; use std::collections::{HashMap, HashSet}; @@ -7,9 +12,10 @@ use std::io; use std::path::PathBuf; use origin_platform::Platform; +use origin_shared::access::{Level, PathAccess}; use origin_shared::{ - Digest, EncodedObject, EntryKind, EntryName, Inputs, ObjectId, Record, RepoPath, Tree, - TreeEntry, TreeId, + Base, Digest, EncodedObject, EntryKind, EntryName, Inputs, ObjectId, Record, RecordId, + RepoPath, Tree, TreeEntry, TreeId, }; use crate::conflict::{Conflicted, Conflicts}; @@ -27,16 +33,45 @@ pub struct FileChange { } impl Store

{ + /// What this repository may not read at or under `dir`, if anything: + /// tree operations check paths only while it's some. + fn withheld_under(&self, dir: &RepoPath) -> Option<&PathAccess> { + Self::unsettled(self.readable.as_deref(), dir) + } + + /// Whether a tree operation may read the entry at `path` of a + /// directory whose withheld paths are `withheld`: a file it reads, or a + /// directory it reads something under. + fn readable(withheld: Option<&PathAccess>, path: &RepoPath, entry: &TreeEntry) -> bool { + withheld.is_none_or(|access| access.reads_entry(path, entry)) + } + /// Lists the files that differ between two trees, sorted by path. /// Subtrees with equal ids are skipped without being read, so the cost - /// follows the size of the change. + /// follows the size of the change. Withheld paths are skipped. /// /// # Errors /// /// Fails if a tree that differs cannot be read. pub fn diff(&self, old: TreeId, new: TreeId) -> Result, StoreError> { + self.diff_at(&RepoPath::root(), Some(old), Some(new)) + } + + /// Lists the files that differ between two versions of the directory + /// `dir`, like [`Store::diff`], `None` standing for one that doesn't + /// exist. + /// + /// # Errors + /// + /// Fails if a tree that differs cannot be read. + pub fn diff_at( + &self, + dir: &RepoPath, + old: Option, + new: Option, + ) -> Result, StoreError> { let mut changes = Vec::new(); - self.diff_dir(&RepoPath::root(), Some(old), Some(new), &mut changes)?; + self.diff_dir(dir, old, new, &mut changes)?; Ok(changes) } @@ -56,19 +91,23 @@ impl Store

{ read(old)?.unwrap_or_default(), read(new)?.unwrap_or_default(), ); + let withheld = self.withheld_under(dir); let mut names: Vec<&EntryName> = old.iter().chain(new.iter()).map(|(name, _)| name).collect(); names.sort_unstable(); names.dedup(); for name in names { + let path = dir.join(name); + let readable = |entry: Option| { + entry.filter(|entry| Self::readable(withheld, &path, entry)) + }; let (old, new) = ( - old.get(name.as_str()).copied(), - new.get(name.as_str()).copied(), + readable(old.get(name.as_str()).copied()), + readable(new.get(name.as_str()).copied()), ); if old == new { continue; } - let path = dir.join(name); // A name can hold a file on one side and a directory on the // other: the file part is a change, the directory part is // diffed against an empty tree. @@ -123,7 +162,8 @@ impl Store

{ /// `ours`. A path is a file, so two sides that changed different files /// of a directory merge cleanly. Without a base, every path either side /// has counts as changed. Subtrees with equal ids are never read, so the - /// cost follows the size of the changes. + /// cost follows the size of the changes. A withheld directory both + /// sides changed is a conflict, as a file is. /// /// # Errors /// @@ -248,15 +288,18 @@ impl Store

{ .collect(); names.sort_unstable(); names.dedup(); + let withheld = self.withheld_under(dir); let mut merged = Tree::default(); for name in names { let [base, ours, theirs] = [&base, &ours, &theirs].map(|tree| tree.get(name.as_str()).copied()); + let readable = withheld.is_none_or(|access| access.reads_under(&dir.join(name))); let entry = if ours == theirs || base == theirs { ours } else if base == ours { theirs - } else if base != Some(Self::UNKNOWN) + } else if readable + && base != Some(Self::UNKNOWN) && ours.is_none_or(|ours| ours.kind.is_tree()) && theirs.is_none_or(|theirs| theirs.kind.is_tree()) { @@ -338,6 +381,99 @@ impl Store

{ } } +impl Store

{ + /// The working copy's files, the tree `files`, with what `kept`, the + /// revision it was, holds at the paths withheld from this repository: + /// files the user cannot read are never in the working copy, and stay + /// in its snapshots as they were. Returns the tree, and the trees it + /// made, which the store may not have yet. Only the directories on the + /// way to withheld paths are read. + /// + /// # Errors + /// + /// Fails if a tree on the way cannot be read. + pub fn keep_withheld( + &self, + files: TreeId, + kept: TreeId, + ) -> Result<(TreeId, Vec), StoreError> { + let mut trees = Vec::new(); + let tree = self.keep_dir(&RepoPath::root(), Some(files), Some(kept), &mut trees)?; + let tree = tree.unwrap_or_else(|| { + let empty = Tree::default().encode(); + let id = empty.id().into(); + trees.push(empty); + id + }); + Ok((tree, trees)) + } + + /// Keeps the withheld paths of `kept` in `files`, two versions of `dir`, + /// `None` standing for one that doesn't exist, returning the result, or + /// `None` if it's empty. + fn keep_dir( + &self, + dir: &RepoPath, + files: Option, + kept: Option, + trees: &mut Vec, + ) -> Result, StoreError> { + let Some(access) = self.withheld_under(dir) else { + return Ok(files); + }; + if files == kept { + return Ok(files); + } + let read = |id: Option| id.map(|id| self.get(id)).transpose(); + let (on_disk, held): (Tree, Tree) = ( + read(files)?.unwrap_or_default(), + read(kept)?.unwrap_or_default(), + ); + let mut names: Vec<&EntryName> = on_disk + .iter() + .chain(held.iter()) + .map(|(name, _)| name) + .collect(); + names.sort_unstable(); + names.dedup(); + let mut merged = Tree::default(); + for name in names { + let path = dir.join(name); + let file = on_disk.get(name.as_str()).copied(); + let kept = held.get(name.as_str()).copied(); + let is_tree = + |entry: Option| entry.is_some_and(|entry| entry.kind.is_tree()); + let entry = if is_tree(file) || file.is_none() && is_tree(kept) { + // A directory: keep what's withheld under it. + if access.reads_under(&path) { + let subtree = |entry: Option| entry.and_then(TreeEntry::subtree); + self.keep_dir(&path, subtree(file), subtree(kept), trees)? + .map(|tree| TreeEntry { + kind: EntryKind::Tree, + id: tree.into(), + }) + } else { + kept + } + } else if access.reads(&path) { + file + } else { + kept + }; + if let Some(entry) = entry { + merged.insert(name.clone(), entry); + } + } + if merged.is_empty() { + return Ok(None); + } + let encoded = merged.encode(); + let id = encoded.id().into(); + trees.push(encoded); + Ok(Some(id)) + } +} + /// Looks up the entries of snapshots by path, reading each tree once, /// however many snapshots share it. #[derive(Debug)] @@ -401,38 +537,32 @@ impl<'a, P: Platform> Snapshots<'a, P> { } impl Store

{ - /// Adds every object the trees `roots` reach to `reached`: the trees - /// themselves, their subtrees and their files. Trees already in - /// `reached` are not read again, and those the store doesn't have, as - /// after a lazy clone, are skipped. + /// The snapshot `base` names: an acceptance's state or a revision's + /// snapshot. /// /// # Errors /// - /// Fails if a tree cannot be read. - pub fn reach( - &self, - roots: impl IntoIterator, - reached: &mut HashSet, - ) -> Result<(), StoreError> { - let mut pending: Vec = roots.into_iter().collect(); - while let Some(id) = pending.pop() { - if !reached.insert(id.into()) { - continue; - } - let tree: Tree = match self.get(id) { - Err(StoreError::NotFound(_)) => continue, - tree => tree?, - }; - for (_, entry) in &tree { - match entry.subtree() { - Some(subtree) => pending.push(subtree), - None => { - reached.insert(entry.id); - } - } - } - } - Ok(()) + /// Fails if the record cannot be read. + pub fn base_snapshot(&self, base: Base) -> Result { + Ok(match base { + Base::Acceptance(id) => self.get(id)?.body().state, + Base::Revision(id) => self.get(id)?.snapshot, + }) + } + + /// The snapshot of a record: a revision's, an acceptance's state, or a + /// snapshot itself. Statements have none. + /// + /// # Errors + /// + /// Fails if the record cannot be read. + pub fn snapshot_of(&self, record: RecordId) -> Result, StoreError> { + Ok(match record { + RecordId::Snapshot(id) => Some(id), + RecordId::Revision(id) => Some(self.get(id)?.snapshot), + RecordId::Acceptance(id) => Some(self.get(id)?.body().state), + RecordId::Statement(_) => None, + }) } } @@ -549,7 +679,9 @@ impl Store

{ /// The objects of `snapshot` that none of the snapshots `have` holds at /// the same path, parents before children: what someone holding `have` /// lacks of it. With `paths`, only those under them, and the trees on - /// the way. Subtrees a `have` holds whole are skipped without being + /// the way. With `access`, only what it reads, and the trees on the way + /// to it: the rest keeps its place in the trees, but its content is + /// withheld. Subtrees a `have` holds whole are skipped without being /// read, so the cost follows the size of the change. Objects appear /// once, at their first path. /// @@ -561,34 +693,106 @@ impl Store

{ snapshot: TreeId, have: &[TreeId], paths: &[RepoPath], + access: Option<&PathAccess>, ) -> Result, StoreError> { let mut added = Vec::new(); let mut seen = HashSet::new(); - self.add_dir( - &RepoPath::root(), - snapshot, - have, - &Scope(paths), - &mut seen, - &mut added, - )?; + let walk = Walk { + scope: Scope(paths), + seen: &mut seen, + added: &mut added, + }; + // What this repository lacks, it cannot give. + let access = access.or(self.readable.as_deref()); + let root = RepoPath::root(); + walk.dir(self, &root, snapshot, have, Self::unsettled(access, &root))?; Ok(added) } - fn add_dir( + /// `access`, if what it allows at and under `path` isn't settled to + /// reading: walks only check paths while it isn't. + fn unsettled<'a>(access: Option<&'a PathAccess>, path: &RepoPath) -> Option<&'a PathAccess> { + access.filter(|access| access.settled(path).is_none_or(|level| level < Level::Read)) + } + + /// The first path of `snapshot` whose object is neither in `supplied` + /// nor at the same path of one of the snapshots `references`, if any. + /// A revision may only hold content its writer supplied, or that a + /// snapshot it refers to holds at the same path: then every path of it + /// holds what someone who could read that path put there. Subtrees a + /// reference holds whole are skipped without being read. + /// + /// # Errors + /// + /// Fails if a tree of `snapshot` or of a reference cannot be read. + pub fn unsupplied( + &self, + snapshot: TreeId, + references: &[TreeId], + supplied: &HashSet, + ) -> Result, StoreError> { + self.unsupplied_dir(&RepoPath::root(), snapshot, references, supplied) + } + + fn unsupplied_dir( &self, dir: &RepoPath, tree: TreeId, + references: &[TreeId], + supplied: &HashSet, + ) -> Result, StoreError> { + if references.contains(&tree) { + return Ok(None); + } + if !supplied.contains(&tree.into()) { + return Ok(Some(dir.clone())); + } + let references: Vec = references + .iter() + .map(|&id| self.get(id)) + .collect::>()?; + for (name, &entry) in &self.get(tree)? { + let path = dir.join(name); + let held = references.iter().filter_map(|tree| tree.get(name.as_str())); + if held.clone().any(|held| *held == entry) { + continue; + } + let unsupplied = match entry.subtree() { + Some(subtree) => { + let held: Vec<_> = held.filter_map(|held| held.subtree()).collect(); + self.unsupplied_dir(&path, subtree, &held, supplied)? + } + None => (!supplied.contains(&entry.id)).then_some(path), + }; + if unsupplied.is_some() { + return Ok(unsupplied); + } + } + Ok(None) + } +} + +/// A walk of [`Store::added`]. +struct Walk<'w> { + scope: Scope<'w>, + seen: &'w mut HashSet, + added: &'w mut Vec, +} + +impl Walk<'_> { + fn dir( + mut self, + store: &Store

, + dir: &RepoPath, + tree: TreeId, have: &[TreeId], - scope: &Scope<'_>, - seen: &mut HashSet, - added: &mut Vec, - ) -> Result<(), StoreError> { + access: Option<&PathAccess>, + ) -> Result { if have.contains(&tree) { - return Ok(()); + return Ok(self); } - if seen.insert(tree.into()) { - added.push(Placed { + if self.seen.insert(tree.into()) { + self.added.push(Placed { path: dir.clone(), entry: TreeEntry { kind: EntryKind::Tree, @@ -598,12 +802,15 @@ impl Store

{ } let have: Vec = have .iter() - .map(|&id| self.get(id)) + .map(|&id| store.get(id)) .collect::>()?; - for (name, &entry) in &self.get(tree)? { + for (name, &entry) in &store.get(tree)? { let path = dir.join(name); - let covered = scope.covers(&path); - if !(covered || entry.kind.is_tree() && scope.leads_in(&path)) { + let covered = self.scope.covers(&path); + if !(covered || entry.kind.is_tree() && self.scope.leads_in(&path)) { + continue; + } + if !Store::

::readable(access, &path, &entry) { continue; } let held = have.iter().filter_map(|tree| tree.get(name.as_str())); @@ -613,13 +820,14 @@ impl Store

{ match entry.subtree() { Some(subtree) => { let held: Vec<_> = held.filter_map(|held| held.subtree()).collect(); - self.add_dir(&path, subtree, &held, scope, seen, added)?; + let access = Store::

::unsettled(access, &path); + self = self.dir(store, &path, subtree, &held, access)?; } - None if seen.insert(entry.id) => added.push(Placed { path, entry }), + None if self.seen.insert(entry.id) => self.added.push(Placed { path, entry }), None => {} } } - Ok(()) + Ok(self) } } @@ -782,31 +990,31 @@ pub(crate) mod tests { }; assert_eq!( - paths(store.added(new, &[old], &[]).unwrap()), + paths(store.added(new, &[old], &[], None).unwrap()), ["/", "docs", "docs/copy", "edit", "edit/b", "file"], "`file/now-dir` has the content of `docs/copy`, listed first" ); assert_eq!( - store.added(new, &[], &[]).unwrap().len(), + store.added(new, &[], &[], None).unwrap().len(), 8, "5 trees, and 5 files of 3 contents" ); - assert_eq!(store.added(new, &[new], &[]).unwrap(), []); + assert_eq!(store.added(new, &[new], &[], None).unwrap(), []); let empty = build(&store, &[]); assert_eq!( - paths(store.added(new, &[empty, old], &[]).unwrap()), - paths(store.added(new, &[old], &[]).unwrap()), + paths(store.added(new, &[empty, old], &[], None).unwrap()), + paths(store.added(new, &[old], &[], None).unwrap()), "any have holding an object is enough" ); let only = |path: &str| [RepoPath::new(path).unwrap()]; assert_eq!( - paths(store.added(new, &[], &only("edit/b")).unwrap()), + paths(store.added(new, &[], &only("edit/b"), None).unwrap()), ["/", "edit", "edit/b"], "the trees on the way, then the path" ); assert_eq!( - paths(store.added(new, &[old], &only("same")).unwrap()), + paths(store.added(new, &[old], &only("same"), None).unwrap()), ["/"] ); } @@ -942,10 +1150,91 @@ pub(crate) mod tests { for missing in ["nope", "src/nope", "README/inside"] { assert_eq!(at(missing), None, "{missing}"); } + } +} + +#[cfg(test)] +mod withheld_tests { + use origin_platform::Native; + use origin_shared::access::{Level, PathAccess}; + + use super::tests::build; + use super::*; + + fn path(path: &str) -> RepoPath { + RepoPath::new(path).unwrap() + } + + /// Snapshots of `files` with each version of `secrets/key`, in a store + /// that lacks `secrets`, which its remote withholds. + fn withheld(keys: &[&str]) -> (tempfile::TempDir, Store, Vec) { + let dir = tempfile::tempdir().unwrap(); + let store = Store::new(dir.path().join(".origin"), Native::default()); + store.init().unwrap(); + let snapshots: Vec<_> = keys + .iter() + .map(|key| build(&store, &[("src/lib.rs", "lib"), ("secrets/key", key)])) + .collect(); + let secrets: Vec = snapshots + .iter() + .map(|&snapshot| { + Snapshots::new(&store) + .entry_at(snapshot, &path("secrets")) + .unwrap() + .unwrap() + .id + }) + .collect(); + store.remove(&secrets).unwrap(); + let readable = PathAccess::new(Level::Write, [(path("secrets"), Level::None)]); + (dir, store.with_withheld(readable), snapshots) + } + + #[test] + fn withheld_paths_are_never_read() { + let (_dir, store, snapshots) = withheld(&["a", "b", "c"]); + let [base, ours, theirs] = snapshots[..] else { + unreachable!() + }; + assert_eq!( + store.diff(base, ours).unwrap(), + [], + "only the secret differs" + ); + let merge = store.merge(Some(base), ours, theirs).unwrap(); + assert_eq!( + merge.conflicts.paths(), + [path("secrets")], + "compared by id, as a file" + ); + assert_eq!(store.merge(Some(base), base, theirs).unwrap().tree, theirs); + let walked = store.added(ours, &[], &[], None).unwrap(); + assert!( + walked + .iter() + .all(|placed| !placed.path.starts_with(&path("secrets"))) + ); + } + + #[test] + fn snapshots_keep_withheld_paths_as_they_were() { + let (_dir, store, snapshots) = withheld(&["a"]); + let kept = snapshots[0]; + // The working copy has `src`, edited, and nothing of `secrets`. + let files = build(&store, &[("src/lib.rs", "edited"), ("new", "n")]); + let (tree, trees) = store.keep_withheld(files, kept).unwrap(); + store.write_all(&trees).unwrap(); + let changes = store.diff(kept, tree).unwrap(); + let paths: Vec<_> = changes.iter().map(|change| change.path.as_str()).collect(); + assert_eq!(paths, ["new", "src/lib.rs"]); + let secrets = |tree| { + Snapshots::new(&store) + .entry_at(tree, &path("secrets")) + .unwrap() + }; + assert_eq!(secrets(tree), secrets(kept), "the secret stays as it was"); - let mut reached = HashSet::new(); - store.reach([root], &mut reached).unwrap(); - assert_eq!(reached.len(), 4, "two trees and two files"); - assert!(reached.contains(&file("lib").id)); + let unchanged = build(&store, &[("src/lib.rs", "lib")]); + assert_eq!(store.keep_withheld(unchanged, kept).unwrap().0, kept); } } diff --git a/apps/origin-src/src/workspace/access.rs b/apps/origin-src/src/workspace/access.rs new file mode 100644 index 00000000..2d1fcb6c --- /dev/null +++ b/apps/origin-src/src/workspace/access.rs @@ -0,0 +1,380 @@ +//! What the remote lets the user do: `whoami`, `permissions` and `token +//! issue`, and the paths it withholds. +//! +//! Every clone and fetch records the user's rights on the remote in the +//! repository's `.origin/access/`, in a file of their own, kept by who the +//! remote says they are and their token's scope, so the users, and the +//! tokens of one user, whose workspaces share the repository never check +//! their work against one another's rights, and a new token with the same +//! scope keeps what they knew. Until a user syncs, the paths withheld +//! are those the cloner may not read. The paths they may not read are +//! withheld: their content never reaches this repository, so they're +//! never in the working copy, and snapshots keep them as they were (see +//! [`Store::with_withheld`]). Pushes that change a path they may not write +//! fail before sending anything, as of the user's own rights, which a push +//! asks the remote for if they never synced. When the remote lets them +//! read more, a fetch restores the files they can read now. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use origin_shared::access::{Capability, PathAccess, Rights}; +use origin_shared::api::TokenRequest; +use origin_shared::{Base, Digest, Period, RepoPath}; + +use super::{Workspace, WorkspaceError}; +use crate::config::UserConfig; +use crate::remote::RemoteUrl; +use crate::store::Store; +use crate::tree::{FileChange, Snapshots}; +use crate::worktree::Worktree; + +impl Workspace { + /// The directory in the repository's `.origin` holding what each user + /// who synced with the remote knows of it, in a directory of their own, + /// named by the hash of the remote's URL and their + /// [holder name](Workspace::holder_name): their rights there, `rights`, + /// as JSON, and the remote's event cursor as of their last fetch, which + /// skips the events their rights hide. `tokens/` holds that name + /// for a token, by the hash of the remote's URL and the token, so a new + /// token for the same user, with the same scope, keeps their cursor. `clone/rights` + /// holds the rights of whoever cloned the repository, for the users + /// who haven't synced yet. + const ACCESS: &str = "access"; + + /// The directory of [`Workspace::ACCESS`] naming the bearers of tokens. + const TOKENS: &str = "tokens"; + + /// The file of [`Workspace::ACCESS`] naming who the remote at `url` + /// says the user of `config`, with their token, is. + fn token_file(store: &Store, url: &RemoteUrl, config: &UserConfig) -> PathBuf { + let token = url.token(config).unwrap_or_default(); + let token = Digest::of(format!("{url}\n{token}").as_bytes()); + let tokens = store.root().join(Self::ACCESS).join(Self::TOKENS); + tokens.join(token.to_hex()) + } + + /// What [`Workspace::ACCESS`] calls the user who has `rights`: who the + /// remote says they are, or an anonymous user, and their token's scope, + /// if it's a scoped one, sorted. + fn holder_name(rights: &Rights) -> String { + let principal = rights.principal.as_ref(); + let mut name = principal.map_or_else(|| "anonymous".to_owned(), ToString::to_string); + if let Some(scope) = &rights.scope { + let mut scope: Vec<_> = scope.iter().map(ToString::to_string).collect(); + scope.sort_unstable(); + scope.dedup(); + name = format!("{name} [{}]", scope.join(", ")); + } + name + } + + /// The directory of [`Workspace::ACCESS`] of the user it calls `name` + /// (see [`Workspace::holder_name`]), on the remote at `url`. + fn principal_dir(store: &Store, url: &RemoteUrl, name: &str) -> PathBuf { + let user = Digest::of(format!("{url}\n{name}").as_bytes()); + store.root().join(Self::ACCESS).join(user.to_hex()) + } + + /// The directory of [`Workspace::ACCESS`] of the user of `config`, for + /// the remote at `url`, once the remote said who they are. + fn user_dir( + store: &Store, + url: &RemoteUrl, + config: &UserConfig, + ) -> Result, WorkspaceError> { + let path = Self::token_file(store, url, config); + match fs::read_to_string(&path) { + Ok(name) => Ok(Some(Self::principal_dir(store, url, name.trim_end()))), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(source) => Err(WorkspaceError::io(&path, source)), + } + } + + /// The file in the directory of [`Workspace::ACCESS`] of the user of + /// `config` named `name`, if the repository has a remote, and the + /// user synced with it. + pub(super) fn user_file( + store: &Store, + config: &UserConfig, + name: &str, + ) -> Result, WorkspaceError> { + let Some(url) = Self::remote_url(store)? else { + return Ok(None); + }; + Ok(Self::user_dir(store, &url, config)?.map(|dir| dir.join(name))) + } + + /// The rights a file of [`Workspace::ACCESS`] holds, if it exists. + fn read_access(path: &Path) -> Result, WorkspaceError> { + let content = match fs::read(path) { + Ok(content) => content, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(source) => return Err(WorkspaceError::io(path, source)), + }; + serde_json::from_slice(&content) + .map(Some) + .map_err(|_| WorkspaceError::Malformed(path.to_owned())) + } + + /// The rights on the remote of the user of `config`, as of their last + /// sync, if they synced with it. + fn own_access(store: &Store, config: &UserConfig) -> Result, WorkspaceError> { + match Self::user_file(store, config, "rights")? { + Some(file) => Self::read_access(&file), + None => Ok(None), + } + } + + /// The rights on the remote of the user of `config`, as of their last + /// sync, or else as of the clone, if the repository has a remote. + pub(super) fn access( + store: &Store, + config: &UserConfig, + ) -> Result, WorkspaceError> { + if Self::remote_url(store)?.is_none() { + return Ok(None); + } + match Self::own_access(store, config)? { + Some(rights) => Ok(Some(rights)), + None => Self::read_access(&store.root().join(Self::ACCESS).join("clone/rights")), + } + } + + /// The rights on the remote of the user of `config` that a push is + /// checked against: as of their last sync, or, if they never synced, + /// as the remote says now, never as of the clone, which another user + /// may have made. + pub(super) fn push_access(&mut self) -> Result, WorkspaceError> { + if Self::remote_url(&self.store)?.is_some() + && Self::own_access(&self.store, &self.config)?.is_none() + { + self.refresh_access()?; + } + Self::own_access(&self.store, &self.config) + } + + /// `store`, withholding what the remote does from the user of `config`. + pub(super) fn withholding(store: Store, config: &UserConfig) -> Result { + Ok(match Self::access(&store, config)? { + Some(rights) => store.with_withheld(rights.paths), + None => store, + }) + } + + /// Records the rights of the user of `config` on the remote at `url` in + /// `store`, and as the clone's, if `cloning`. + pub(super) fn save_access( + store: &Store, + url: &RemoteUrl, + config: &UserConfig, + rights: &Rights, + cloning: bool, + ) -> Result<(), WorkspaceError> { + let json = serde_json::to_vec_pretty(rights).expect("rights serialize to JSON"); + let name = Self::holder_name(rights); + let write = store.durable_write(); + let mut dirs = vec![Self::principal_dir(store, url, &name)]; + if cloning { + dirs.push(store.root().join(Self::ACCESS).join("clone")); + } + dirs.into_iter() + .try_for_each(|dir| write.stage(dir.join("rights"), &json)) + .and_then(|()| { + let file = Self::token_file(store, url, config); + write.stage(file, format!("{name}\n").as_bytes()) + }) + .and_then(|()| write.commit()) + .map_err(crate::store::StoreError::from)?; + Ok(()) + } + + /// Records the user's rights on the remote, and withholds what they may + /// not read from now on. Restores the files of the working copy they + /// read now but didn't before. + pub(super) fn refresh_access(&mut self) -> Result<(), WorkspaceError> { + let rights = self.require_remote()?.whoami()?; + let url = Self::remote_url(&self.store)?.ok_or(WorkspaceError::NoRemote)?; + let name = Self::holder_name(&rights); + let dir = Self::principal_dir(&self.store, &url, &name); + // What the user's cursor was read with, and what is withheld now. + let saved = Self::read_access(&dir.join("rights"))?; + let withheld = Self::access(&self.store, &self.config)?; + let known = Self::user_dir(&self.store, &url, &self.config)?.as_ref() == Some(&dir); + if known && saved.as_ref() == Some(&rights) { + return Ok(()); + } + Self::save_access(&self.store, &url, &self.config, &rights, false)?; + if saved.as_ref() != Some(&rights) { + // What the user didn't see before has no events since the + // cursor. + self.forget_cursor()?; + } + if withheld.as_ref() == Some(&rights) { + return Ok(()); + } + self.store = self.store.clone().with_withheld(rights.paths.clone()); + match withheld { + Some(old) => self.restore_readable(&old.paths, &rights.paths), + None => Ok(()), + } + } + + /// Fetches and writes the files of the working copy under the paths + /// `now` reads and `before` didn't. + fn restore_readable( + &self, + before: &PathAccess, + now: &PathAccess, + ) -> Result<(), WorkspaceError> { + let mut paths: Vec = before + .rules() + .chain(now.rules()) + .map(|(path, _)| path.clone()) + .filter(|path| now.reads(path) && !before.reads(path)) + .collect(); + paths.sort_unstable(); + paths.dedup_by(|inner, outer| inner.starts_with(outer)); + if paths.is_empty() { + return Ok(()); + } + let snapshot = self.store.get(self.revision)?.snapshot; + // The working copy kept what it couldn't read as the acceptance at + // the bottom of its stack has it, which the remote has too. + let mut base = self.store.get(self.revision)?.base; + while let Base::Revision(below) = base { + base = self.store.get(below)?.base; + } + self.sync(self.require_remote()?) + .need_paths(base.into(), paths.clone())?; + let mut restored = Vec::new(); + for path in &paths { + let at = Snapshots::new(&self.store).entry_at(snapshot, path)?; + let (tree, file) = match at { + Some(entry) => (entry.subtree(), entry.file()), + None => continue, + }; + if let Some(file) = file { + restored.push(FileChange { + path: path.clone(), + old: None, + new: Some(file), + }); + } + restored.extend(self.store.diff_at(path, None, tree)?); + } + // A restored file never replaces one the user wrote. + restored + .retain(|change| fs::symlink_metadata(self.root.join(change.path.as_str())).is_err()); + Ok(Worktree::new(&self.root, &self.store).checkout(&restored, &[], None)?) + } + + /// `origin whoami`: prints who the remote thinks the user is. + /// + /// # Errors + /// + /// Fails if the remote cannot be reached. + pub fn whoami(&self) -> Result<(), WorkspaceError> { + let Some(remote) = self.remote()? else { + println!("{}, the owner of this repository", self.config.user()); + return Ok(()); + }; + let rights = remote.whoami()?; + match &rights.principal { + None if rights.paths.writes_all() => { + println!("The owner: the remote is a local repository."); + } + None => println!("Anonymous: set a token for the remote to sign in."), + Some(principal) if rights.groups.is_empty() => println!("{principal}"), + Some(principal) => { + let groups: Vec<_> = rights.groups.iter().map(ToString::to_string).collect(); + println!("{principal} (groups: {})", groups.join(", ")); + } + } + if let Some(scope) = &rights.scope { + let scope: Vec<_> = scope.iter().map(ToString::to_string).collect(); + println!("Token scope: {}", scope.join(", ")); + } + Ok(()) + } + + /// `origin permissions`: prints what the user may do on the remote. + /// + /// # Errors + /// + /// Fails if the remote cannot be reached. + pub fn permissions(&self) -> Result<(), WorkspaceError> { + let rights = match self.remote()? { + Some(remote) => remote.whoami()?, + None => Rights::all(), + }; + for (path, level) in rights.paths.rules() { + println!("{level:<5} {path}"); + } + let list = |items: Vec| { + if items.is_empty() { + "nothing".to_owned() + } else { + items.join(", ") + } + }; + println!( + "annotate: {}", + list(rights.annotate.iter().map(ToString::to_string).collect()) + ); + println!( + "land: {}", + list(rights.land.iter().map(ToString::to_string).collect()) + ); + println!("admin: {}", if rights.admin { "yes" } else { "no" }); + Ok(()) + } + + /// `origin token issue`: asks the remote for a token acting for the + /// user, with `scope`, valid for `ttl`, and prints it. + /// + /// # Errors + /// + /// Fails if there's no remote, it cannot be reached, or it refuses: + /// the scope must be part of the user's rights. + pub fn issue_token(&self, scope: Vec, ttl: Period) -> Result<(), WorkspaceError> { + let issued = self + .require_remote()? + .issue_token(&TokenRequest { scope, ttl })?; + let left = Duration::from_secs(issued.expires).saturating_sub( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(), + ); + println!("{}", issued.token); + eprintln!( + "Acts for {} for {} minutes: send it as ORIGIN_TOKEN, or add it to [tokens] in \ + config.toml", + issued.principal, + left.as_secs().div_ceil(60) + ); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use origin_shared::access::Level; + + use super::*; + + #[test] + fn rights_round_trip_through_the_access_file() { + let rights = Rights { + paths: PathAccess::new( + Level::Read, + [(RepoPath::new("secrets").unwrap(), Level::None)], + ), + ..Rights::all() + }; + let json = serde_json::to_vec(&rights).unwrap(); + assert_eq!(serde_json::from_slice::(&json).unwrap(), rights); + } +} diff --git a/apps/origin-src/src/workspace/annotate.rs b/apps/origin-src/src/workspace/annotate.rs index a4c30da1..63440580 100644 --- a/apps/origin-src/src/workspace/annotate.rs +++ b/apps/origin-src/src/workspace/annotate.rs @@ -8,8 +8,8 @@ use std::path::{Path, PathBuf}; use std::time::SystemTime; use origin_shared::{ - BlobId, EncodedObject, Inputs, Kind, Principal, RecordId, Revision, RevisionId, Statement, - StatementId, Verdict, + AttachmentId, EncodedObject, Inputs, Kind, Principal, RecordId, Revision, RevisionId, + Statement, StatementId, Verdict, }; use super::{WorkingCopy, Workspace, WorkspaceError}; @@ -73,18 +73,18 @@ pub enum Payload { } impl Payload { - /// The blob holding the details. - fn blob(&self) -> Result { + /// The attachment holding the details. + fn attachment(&self) -> Result { match self { - Self::Text(text) => Ok(EncodedObject::blob(text.as_bytes())), + Self::Text(text) => Ok(EncodedObject::attachment(text.as_bytes())), Self::File(path) if path == Path::new("-") => { - EncodedObject::read_blob(io::stdin().lock(), 0) + EncodedObject::read_attachment(io::stdin().lock(), 0) .map_err(|source| WorkspaceError::io(path, source)) } Self::File(path) => File::open(path) .and_then(|file| { let len = file.metadata()?.len(); - EncodedObject::read_blob(file, usize::try_from(len).unwrap_or(0)) + EncodedObject::read_attachment(file, usize::try_from(len).unwrap_or(0)) }) .map_err(|source| WorkspaceError::io(path, source)), } @@ -147,7 +147,7 @@ impl Workspace { (None, Some(id)) if !self.store.get(id)?.withdraws() => Some(id), (None, _) => return Err(self.nothing_to_withdraw(&annotations, kind, subject)?), }; - let payload = payload.map(Payload::blob).transpose()?; + let payload = payload.map(Payload::attachment).transpose()?; let statement = Statement { subject, supersedes, @@ -311,8 +311,8 @@ impl Workspace { /// A payload's first line if it's text, its size otherwise, or that this /// repository doesn't have it: retention may have dropped it, or it /// never came here. - fn payload_summary(&self, payload: BlobId) -> Result { - let Some(content) = self.store.try_blob(payload.into())? else { + fn payload_summary(&self, payload: AttachmentId) -> Result { + let Some(content) = self.store.try_attachment(payload)? else { return Ok("unavailable here".to_owned()); }; Ok( @@ -335,7 +335,10 @@ impl Workspace { let working_copy = self.refresh()?; let revision = working_copy.find_revision(rev)?; let mut annotations = Annotations::new(&self.store, working_copy.index); - let promoted = annotations.promote(RecordId::Revision(revision), &self.node_principal())?; + let promoted = + annotations.promote(RecordId::Revision(revision), &self.node_principal(), |_| { + Ok(true) + })?; if promoted.is_empty() { println!("Nothing to promote to revision {}", revision.short()); } diff --git a/apps/origin-src/src/workspace/land.rs b/apps/origin-src/src/workspace/land.rs index 21a30cfd..bde414f2 100644 --- a/apps/origin-src/src/workspace/land.rs +++ b/apps/origin-src/src/workspace/land.rs @@ -26,7 +26,8 @@ use std::{mem, slice}; use origin_shared::api::{LandRequest, Landings, NewTarget}; use origin_shared::landing::{Blocked, Landing, Pending, Proposal}; use origin_shared::{ - Base, ChangeId, EncodedObject, Record, Revision, RevisionId, Source, TargetName, TreeId, + Base, ChangeId, EncodedObject, Record, RecordId, Revision, RevisionId, Source, TargetName, + TreeId, }; use super::{Behind, WorkingCopy, Workspace, WorkspaceError}; @@ -290,8 +291,8 @@ impl Workspace { .store .head(target)? .ok_or_else(|| WorkspaceError::UnknownTarget(target.clone()))?; + self.need(&[RecordId::Acceptance(tip.acceptance)])?; let state = self.store.get(tip.acceptance)?.body().state; - self.need(&[state])?; let tip_label = format!("{target} #{}", tip.seq); let merge = self.store.merge_marked( Some(base), @@ -299,7 +300,7 @@ impl Workspace { state, [Self::OURS, &tip_label], )?; - let description = EncodedObject::blob(description.as_bytes()); + let description = EncodedObject::attachment(description.as_bytes()); let revision = Revision { change: ChangeId::random().map_err(WorkspaceError::Random)?, predecessors: Vec::new(), @@ -402,8 +403,8 @@ impl Workspace { .store .head(onto)? .ok_or_else(|| WorkspaceError::UnknownTarget(onto.clone()))?; + self.need(&[RecordId::Acceptance(tip.acceptance)])?; let state = self.store.get(tip.acceptance)?.body().state; - self.need(&[state])?; let current = &working_copy.revision; let tip_label = format!("{onto} #{}", tip.seq); let merge = self.store.merge_marked( diff --git a/apps/origin-src/src/workspace/mod.rs b/apps/origin-src/src/workspace/mod.rs index 36bad9db..a23bd130 100644 --- a/apps/origin-src/src/workspace/mod.rs +++ b/apps/origin-src/src/workspace/mod.rs @@ -8,12 +8,16 @@ //! workspace the revision the working copy is //! state the stat cache of the last snapshot //! moving the move in progress, if a checkout is +//! lock held by the command working in the working copy +//! remote, access the remote, and what it lets the user do //! ``` //! //! A repository can have several working copies, its workspaces, which -//! share its store and op log. The others have only `workspace`, `state` -//! and `moving` in their `.origin`, and a `repository` file naming the main -//! one's. +//! share its store and op log. The others have only `workspace`, `state`, +//! `moving` and `lock` in their `.origin`, and a `repository` file naming +//! the main one's. Commands in different workspaces run at once; in one, +//! each waits for the one before, except `watch`, which never touches the +//! working copy, and `workspace rm`, which opens the workspace it removes. //! //! The working copy is always on a revision of the current change. Every //! command that reads it snapshots it first. The files the snapshot finds @@ -63,6 +67,7 @@ //! which publishes `moving` again first: a crash then finishes taking the //! files back, not the move that failed. +mod access; mod annotate; mod land; mod ops; @@ -78,10 +83,10 @@ use std::mem; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; -use origin_platform::{LockedFile, Native}; +use origin_platform::{LockMode, LockedFile, Native}; use origin_shared::api::{LogQuery, RevisionEntry, Revisions}; use origin_shared::{ - AcceptanceId, Base, BlobId, ChangeId, EncodedObject, Kind, Principal, Record, RecordId, + AcceptanceId, AttachmentId, Base, ChangeId, EncodedObject, Kind, Principal, Record, RecordId, RepoPath, Revision, RevisionId, Source, Statement, StatementId, TargetId, TargetName, Tree, TreeId, }; @@ -118,6 +123,9 @@ pub struct Workspace { unreadable_move: Option, /// The repository's remote, once opened: see [`Workspace::remote`]. remote: OnceLock>, + /// Held while the command that opened the working copy works in it, + /// so only one at a time does: see [`Workspace::open`]. + lock: Option, } /// The working copy as of a snapshot. @@ -235,6 +243,10 @@ impl Workspace { /// in progress: see [`Moving`]. const MOVING: &str = "moving"; + /// The file in [`Workspace::META_DIR`] held locked by the command + /// working in the working copy. + const LOCK: &str = "lock"; + /// `origin init`: creates a repository in `root`, with the `policy` /// target naming this node the authority of `policy` and `main`, an /// empty `main`, and a first change on top of it. @@ -259,7 +271,7 @@ impl Workspace { let policy = Policy::initial(config.node(), &key.public()); let main = Queue::new(&store, &key, config.node().clone()).init(&policy)?; let empty_tree = Tree::default().encode(); - let empty_description = EncodedObject::blob(b""); + let empty_description = EncodedObject::attachment(b""); let revision = Revision { change: ChangeId::random().map_err(WorkspaceError::Random)?, @@ -303,10 +315,19 @@ impl Workspace { /// Opens the working copy at `root`: the main one, whose `.origin` holds /// the store, or another workspace, whose `.origin/repository` names - /// the main one's. Finishes a move a crash cut short first. + /// the main one's. Waits for the command working in it, if any, then + /// finishes a move a crash cut short. fn open(root: &Path, config: UserConfig, actor: &Actor) -> Result { let root = &Self::canonical(root)?; let meta = root.join(Self::META_DIR); + // Held until the command is done, so `moving` is only ever found + // once the move it records was cut short. + let lock = LockedFile::open( + &Native::default(), + &meta.join(Self::LOCK), + LockMode::Exclusive, + ) + .map_err(StoreError::from)?; let repository = meta.join(Self::REPOSITORY); let store = match Self::read_path(&repository) { Ok(store) if store.is_dir() => store, @@ -319,8 +340,11 @@ impl Workspace { let revision = Self::read_pointer(root)?.ok_or_else(|| WorkspaceError::Incomplete(root.to_owned()))?; let store = Store::open_writable(store, Native::default())?; - let store = store.with_actor(actor.in_workspace(root)); - let mut workspace = Self::at(root.to_owned(), store, config, revision); + let store = Self::withholding(store.with_actor(actor.in_workspace(root)), &config)?; + let mut workspace = Self { + lock: Some(lock), + ..Self::at(root.to_owned(), store, config, revision) + }; workspace.finish_move()?; Ok(workspace) } @@ -336,6 +360,7 @@ impl Workspace { unlogged: None, unreadable_move: None, remote: OnceLock::new(), + lock: None, } } @@ -457,7 +482,7 @@ impl Workspace { let revision: Revision = self.store.get(self.revision)?; let path = self.meta(Self::STATE); let loaded = StatCache::load(&path); - let (files, cache) = Worktree::new(&self.root, &self.store).snapshot(&loaded)?; + let (found, cache) = Worktree::new(&self.root, &self.store).snapshot(&loaded)?; let cache = match cache { Some(cache) => { cache @@ -467,6 +492,11 @@ impl Workspace { } None => loaded, }; + // What the user cannot read stays as the revision has it. + let (files, trees) = self.store.keep_withheld(found, revision.snapshot)?; + if !trees.is_empty() { + self.store.write_all(&trees)?; + } let mut index = Index::open(&self.store)?; let behind = self.behind(&index, self.revision, &revision)?; let mut conflicts = self.conflicts(&mut index, self.revision, &revision)?; @@ -760,11 +790,14 @@ impl Workspace { ) -> Result<(), WorkspaceError> { // Before changing any file. self.may_move()?; - // Fetched first, so a fetch that fails moves nothing. + // Fetched first, so a fetch that fails moves nothing. A new + // revision is made of what's here. let changes = if revision.snapshot == working_copy.files { Vec::new() + } else if unwritten.is_empty() { + self.changes(working_copy.files, RecordId::Revision(to))? } else { - self.changes(working_copy.files, revision.snapshot)? + self.store.diff(working_copy.files, revision.snapshot)? }; // The files stay as they are: there's nothing to finish. if changes.is_empty() { @@ -825,23 +858,45 @@ impl Workspace { Ok(Worktree::new(&self.root, &self.store).restore(&changes)?) } - /// The changes that make the files of `from` those of `to`, after - /// fetching what the repository lacks of `to`. A move asks for them - /// before it publishes `moving`, so a fetch that fails moves nothing. - fn changes(&self, from: TreeId, to: TreeId) -> Result, WorkspaceError> { + /// The changes that make the files of `from` those of the snapshot of + /// `to`, a revision or an acceptance, after fetching what the + /// repository lacks of it. The paths the remote withholds stay as they + /// are. A move asks for them before it publishes `moving`, so a fetch + /// that fails moves nothing. + fn changes(&self, from: TreeId, to: RecordId) -> Result, WorkspaceError> { self.need(&[to])?; - Ok(match self.store.diff(from, to) { + let tree = self + .store + .snapshot_of(to)? + .ok_or(SyncError::NoSnapshot(to))?; + let changes = match self.store.diff(from, tree) { // A snapshot can lack trees its root names, if its fetch was // cut short before fetches were marked: fetch it whole again. Err(StoreError::NotFound(missing)) => match self.remote()? { Some(remote) => { self.sync(remote).repair(to)?; - self.store.diff(from, to)? + self.store.diff(from, tree)? } None => return Err(StoreError::NotFound(missing).into()), }, changes => changes?, - }) + }; + // A clone may lack files its snapshots name: those it could not + // read when it fetched them. + if Self::remote_url(&self.store)?.is_some() { + let mut missing = Vec::new(); + for change in &changes { + if let Some(new) = change.new + && !self.store.contains(new.id)? + { + missing.push(change.path.clone()); + } + } + if !missing.is_empty() { + self.sync(self.require_remote()?).need_paths(to, missing)?; + } + } + Ok(changes) } /// Makes the files match a snapshot, given `changes`, how they differ @@ -915,7 +970,11 @@ impl Workspace { /// cannot be read. pub fn diff(&mut self) -> Result<(), WorkspaceError> { let working_copy = self.snapshot()?; - self.print_diff(working_copy.revision.base, working_copy.files) + self.print_diff( + working_copy.id, + working_copy.revision.base, + working_copy.files, + ) } /// `origin show []`: prints a change's head, or each of them if @@ -966,15 +1025,21 @@ impl Workspace { println!(" {line}"); } println!(); - self.print_diff(revision.base, files)?; + self.print_diff(id, revision.base, files)?; } Ok(()) } - /// Prints the changes `snapshot` makes to `base`, line by line. - fn print_diff(&self, base: Base, snapshot: TreeId) -> Result<(), WorkspaceError> { + /// Prints the changes `snapshot`, of revision `id`, makes to `base`, + /// line by line. + fn print_diff( + &self, + id: RevisionId, + base: Base, + snapshot: TreeId, + ) -> Result<(), WorkspaceError> { let base = self.base_tree(base)?; - self.need(&[snapshot])?; + self.need(&[RecordId::Revision(id)])?; for change in self.store.diff(base, snapshot)? { print!("{}", FileDiff::new(&self.store, &change)?); } @@ -992,7 +1057,7 @@ impl Workspace { let working_copy = self.record()?; let mut working_copy = self.following(working_copy)?; self.log_draft(&working_copy); - let description = EncodedObject::blob(description.as_bytes()); + let description = EncodedObject::attachment(description.as_bytes()); let revision = Revision { change: ChangeId::random().map_err(WorkspaceError::Random)?, predecessors: Vec::new(), @@ -1030,7 +1095,7 @@ impl Workspace { let working_copy = self.record()?; let mut working_copy = self.following(working_copy)?; self.log_draft(&working_copy); - let description = EncodedObject::blob(description.as_bytes()); + let description = EncodedObject::attachment(description.as_bytes()); let revision = Revision { predecessors: vec![working_copy.id], description: description.id().into(), @@ -1104,10 +1169,10 @@ impl Workspace { /// /// # Errors /// - /// Fails if the change cannot be found or diverged, the user didn't - /// create it, so readers would ignore the statement, or the records - /// cannot be written. Then no abandonment is written and nothing is - /// checked out. + /// Fails if the change cannot be found or diverged, the user neither + /// created it nor is an `admin`, so readers would ignore the statement, + /// or the records cannot be written. Then no abandonment is written and + /// nothing is checked out. pub fn abandon(&mut self, change: Option<&str>, reason: &str) -> Result<(), WorkspaceError> { let mut working_copy = self.refresh()?; let index = &working_copy.index; @@ -1116,7 +1181,7 @@ impl Workspace { None => working_copy.revision.change, }; let head = Self::single_head(index, change)?; - let reason = EncodedObject::blob(reason.as_bytes()); + let reason = EncodedObject::attachment(reason.as_bytes()); let statement = Statement { subject: RecordId::Revision(head), kind: Kind::ABANDON, @@ -1127,7 +1192,8 @@ impl Workspace { supersedes: None, promoted_from: None, }; - if !self.store.authorizes(&statement)? { + // As readers count it. + if !Policy::load(&self.store)?.authorizes(&self.store, &statement)? { return Err(AnnotateError::Reserved { kind: statement.kind, issuer: statement.issuer, @@ -1142,7 +1208,7 @@ impl Workspace { } let abandoned: Revision = self.store.get(head)?; - let empty = EncodedObject::blob(b""); + let empty = EncodedObject::attachment(b""); let revision = Revision { change: ChangeId::random().map_err(WorkspaceError::Random)?, predecessors: Vec::new(), @@ -1192,11 +1258,8 @@ impl Workspace { heads[heads.len() - 1] }; let kept_revision: Revision = self.store.get(kept)?; - let snapshots = heads - .iter() - .map(|&head| Ok(self.store.get(head)?.snapshot)) - .collect::, WorkspaceError>>()?; - self.need(&snapshots)?; + let records: Vec<_> = heads.iter().copied().map(RecordId::Revision).collect(); + self.need(&records)?; let base = self.merge_base(&heads)?; let mut snapshot = kept_revision.snapshot; let mut merged = Conflicts::default(); @@ -1407,14 +1470,13 @@ impl Workspace { self.text(revision.description) } - /// A blob's content, as text. - fn text(&self, blob: BlobId) -> Result { - let id = blob.into(); - let blob = self.store.read(id)?; - let content = blob - .blob_content() - .map_err(|source| StoreError::Decode { id, source })?; - Ok(String::from_utf8_lossy(content).into_owned()) + /// An attachment's content, as text. + fn text(&self, attachment: AttachmentId) -> Result { + let content = self + .store + .try_attachment(attachment)? + .ok_or(StoreError::NotFound(attachment.into()))?; + Ok(String::from_utf8_lossy(&content).into_owned()) } /// The first line of a revision's description. @@ -1452,16 +1514,24 @@ impl Workspace { } /// Who abandoned a revision, and why: only those who may, as - /// [`Store::authorizes`] says. + /// [`Policy::authorizes`] says, as the landing queue counts them. fn abandonments( &self, index: &Index, revision: RevisionId, ) -> Result, WorkspaceError> { let mut abandonments = Vec::new(); + let mut policy = None; for &id in index.statements(RecordId::Revision(revision)) { let statement = self.store.get(id)?; - if statement.kind == Kind::ABANDON && self.store.authorizes(&statement)? { + if statement.kind != Kind::ABANDON { + continue; + } + let policy = match &policy { + Some(policy) => policy, + None => policy.insert(Policy::load(&self.store)?), + }; + if policy.authorizes(&self.store, &statement)? { let reason = match statement.payload { Some(payload) => self.text(payload)?, None => String::new(), @@ -1494,12 +1564,8 @@ impl Workspace { /// The snapshot a revision was made on top of, fetched if the /// repository lacks it. fn base_tree(&self, base: Base) -> Result { - let tree = match base { - Base::Acceptance(id) => self.store.get(id)?.body().state, - Base::Revision(id) => self.store.get(id)?.snapshot, - }; - self.need(&[tree])?; - Ok(tree) + self.need(&[base.into()])?; + Ok(self.store.base_snapshot(base)?) } fn describe_base(&self, base: Base) -> Result { diff --git a/apps/origin-src/src/workspace/ops.rs b/apps/origin-src/src/workspace/ops.rs index 2dbb4139..6a35d020 100644 --- a/apps/origin-src/src/workspace/ops.rs +++ b/apps/origin-src/src/workspace/ops.rs @@ -218,12 +218,14 @@ impl Workspace { /// Fails if the cursor expired, the remote fails, or the op log or a /// record cannot be read. pub fn watch( - &self, + &mut self, kinds: Vec, subjects: Vec, since: Option, local: bool, ) -> Result<(), WorkspaceError> { + // It never touches the working copy, so others may meanwhile. + self.lock = None; let remote = if local { None } else { self.remote()? }; let service = self.service(None)?; let mut query = EventsQuery { diff --git a/apps/origin-src/src/workspace/registry.rs b/apps/origin-src/src/workspace/registry.rs index 66bd3f8b..cf247986 100644 --- a/apps/origin-src/src/workspace/registry.rs +++ b/apps/origin-src/src/workspace/registry.rs @@ -3,8 +3,8 @@ //! //! The main working copy's `.origin/workspaces/` has a file for each other //! workspace, named by the hash of its root and holding that root. Records -//! are immutable, and only target chains and the index are locked, so -//! commands run concurrently in different workspaces. +//! are immutable, and only target chains, the index and each working copy +//! are locked, so commands run concurrently in different workspaces. //! //! Creating a workspace registers it, checks out its files, and only then //! publishes its `.origin/workspace`, so a crash never leaves partial files @@ -19,7 +19,7 @@ use std::os::unix::ffi::OsStrExt; use std::path::{self, Path, PathBuf}; use origin_platform::DurableWrite; -use origin_shared::{ChangeId, Digest, EncodedObject, Record, Revision, Tree}; +use origin_shared::{ChangeId, Digest, EncodedObject, Record, RecordId, Revision, Tree}; use super::{Workspace, WorkspaceError}; use crate::oplog::Why; @@ -49,7 +49,7 @@ impl Workspace { let head = Self::single_head(&working_copy.index, change)?; (head, self.store.get(head)?, Vec::new()) } else { - let description = EncodedObject::blob(b""); + let description = EncodedObject::attachment(b""); let base = working_copy.revision.base; let revision = Revision { change: ChangeId::random().map_err(WorkspaceError::Random)?, @@ -73,7 +73,7 @@ impl Workspace { let store = store.with_actor(self.store.actor().in_workspace(&root)); let mut workspace = Self::at(root, store, self.config.clone(), id); let empty = Tree::default().encode().id().into(); - let changes = workspace.changes(empty, revision.snapshot)?; + let changes = workspace.changes(empty, RecordId::Revision(id))?; workspace.checkout(&changes, None)?; let repository = Self::path_bytes(self.store.root()); workspace.write_durably(workspace.meta(Self::REPOSITORY), &repository)?; @@ -115,7 +115,10 @@ impl Workspace { /// Fails if `path` is the main working copy or no workspace, if the /// workspace is behind its change with edits, which no revision holds /// then, or if it cannot be snapshotted or deleted. - pub fn remove_workspace(&self, path: &Path) -> Result<(), WorkspaceError> { + pub fn remove_workspace(&mut self, path: &Path) -> Result<(), WorkspaceError> { + // It works in the workspace it removes, which may be this one, and + // opening it takes its lock. + self.lock = None; let root = Self::canonical(path)?; if root == self.main_root() { return Err(WorkspaceError::RemoveMain(root)); diff --git a/apps/origin-src/src/workspace/remote.rs b/apps/origin-src/src/workspace/remote.rs index 274cfc1a..1dfeffa6 100644 --- a/apps/origin-src/src/workspace/remote.rs +++ b/apps/origin-src/src/workspace/remote.rs @@ -9,12 +9,12 @@ //! Syncing never changes files under a running workspace: `pull` is a //! fetch, then a move of the working copy. //! -//! The remote's event cursor as of the last fetch, and the generation of -//! the op log it's of, are kept in `.origin/remote-cursor`, so the next -//! fetch only compares the changes -//! that moved since (see [`Sync::moved`]). Every way this repository learns -//! of a change brings all the remote has of it, so the changes it knows are -//! whole as of that cursor. +//! The remote's event cursor as of each user's last fetch, and the +//! generation of the op log it's of, are kept with their rights (see +//! [`Workspace::access`]), so their next fetch only compares the changes +//! that moved since (see [`Sync::moved`]). Every way this repository +//! learns of a change brings all the remote has of it, so the changes it +//! knows are whole as of that cursor. use std::fs; use std::io; @@ -24,7 +24,7 @@ use std::sync::OnceLock; use origin_platform::Native; use origin_shared::{ - Base, ChangeId, EncodedObject, Record, Revision, RevisionId, TargetName, Tree, TreeId, + Base, ChangeId, EncodedObject, Record, RecordId, Revision, RevisionId, TargetName, Tree, }; use super::{Workspace, WorkspaceError}; @@ -74,7 +74,7 @@ impl Workspace { .take_while(|dir| dir.symlink_metadata().is_err()) .last() .map(Path::to_owned); - let remote = Remote::open(&url, Self::node_of(&config)?)?; + let remote = Self::open_remote(&url, &config)?; let cloned = Self::clone_into(root.clone(), &url, remote, target, config, actor); if cloned.is_err() { // Best effort: the error says what went wrong. @@ -112,17 +112,20 @@ impl Workspace { let meta = root.join(Self::META_DIR); let store = Store::new(meta, Native::default()).with_actor(actor.in_workspace(&root)); store.init()?; - let sync = Sync::new(&store, &remote, config.node().clone()); + let rights = remote.whoami()?; + Self::save_access(&store, url, &config, &rights, true)?; + let store = store.with_withheld(rights.paths); + let sync = Sync::new(&store, &remote, config.node().clone(), config.user()); let cursor = sync.moved(None)?.cursor; sync.fetch_chains(slice::from_ref(target))?; let tip = store .head(target)? .ok_or_else(|| WorkspaceError::UnknownTarget(target.clone()))?; let state = store.get(tip.acceptance)?.body().state; - sync.need(state, &[])?; + sync.need(RecordId::Acceptance(tip.acceptance), &[])?; let empty_tree = Tree::default().encode(); - let empty_description = EncodedObject::blob(b""); + let empty_description = EncodedObject::attachment(b""); let revision = Revision { change: ChangeId::random().map_err(WorkspaceError::Random)?, predecessors: Vec::new(), @@ -142,7 +145,8 @@ impl Workspace { )?; workspace.save_cursor(&cursor)?; workspace.remote = OnceLock::from(Some(remote)); - let changes = workspace.changes(empty_tree.id().into(), state)?; + let changes = + workspace.changes(empty_tree.id().into(), RecordId::Acceptance(tip.acceptance))?; workspace.checkout(&changes, None)?; workspace.publish_pointer(None, id, Why::Command)?; println!( @@ -155,15 +159,11 @@ impl Workspace { Ok(workspace) } - /// The file in the repository's `.origin` holding the remote's event - /// cursor as of the last fetch. - const CURSOR: &str = "remote-cursor"; - - /// The remote's event cursor as of the last fetch, if any, written - /// ` `. One that cannot be read is forgotten: the - /// next fetch compares every change. + /// The remote's event cursor as of the user's last fetch, if any, + /// written ` `. One that cannot be read is + /// forgotten: the next fetch compares every change. fn cursor(&self) -> Option { - let path = self.store.root().join(Self::CURSOR); + let path = Self::user_file(&self.store, &self.config, "cursor").ok()??; let text = fs::read_to_string(path).ok()?; let (generation, next) = text.trim_end().split_once(' ')?; Some(Cursor { @@ -176,15 +176,20 @@ impl Workspace { /// or none, is as good, only slower to fetch from, and one torn by a /// crash doesn't parse, or reads from earlier. fn save_cursor(&self, cursor: &Cursor) -> Result<(), WorkspaceError> { - let path = self.store.root().join(Self::CURSOR); + let Some(path) = Self::user_file(&self.store, &self.config, "cursor")? else { + return Ok(()); + }; let text = format!("{} {}\n", cursor.generation, cursor.next); fs::write(&path, text).map_err(|source| WorkspaceError::io(&path, source)) } - /// Forgets the remote's event cursor: the next fetch compares every - /// change, for a new remote, or once the user sees more of it. + /// Forgets the remote's event cursor as of the user's last fetch: the + /// next fetch compares every change, for a new remote, or once the user + /// sees more of it. pub(super) fn forget_cursor(&self) -> Result<(), WorkspaceError> { - let path = self.store.root().join(Self::CURSOR); + let Some(path) = Self::user_file(&self.store, &self.config, "cursor")? else { + return Ok(()); + }; match fs::remove_file(&path) { Err(error) if error.kind() != io::ErrorKind::NotFound => { Err(WorkspaceError::io(&path, error)) @@ -193,9 +198,9 @@ impl Workspace { } } - /// The URL of the repository's remote, if it has one. - fn remote_url(&self) -> Result, WorkspaceError> { - let path = self.store.root().join(Self::REMOTE); + /// The URL of the remote of the repository in `store`, if it has one. + pub(super) fn remote_url(store: &Store) -> Result, WorkspaceError> { + let path = store.root().join(Self::REMOTE); match fs::read_to_string(&path) { Ok(url) => Ok(Some(RemoteUrl::parse(url.trim_end())?)), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), @@ -203,26 +208,41 @@ impl Workspace { } } + /// Opens the remote at `url` for the user of `config`, with their + /// token for it, if they have one. + fn open_remote(url: &RemoteUrl, config: &UserConfig) -> Result { + Ok(Remote::open( + url, + Self::node_of(config)?, + url.token(config), + )?) + } + /// The repository's remote, opened the first time it's needed, if it /// has one. pub(super) fn remote(&self) -> Result, WorkspaceError> { if let Some(remote) = self.remote.get() { return Ok(remote.as_ref()); } - let remote = match self.remote_url()? { - Some(url) => Some(Remote::open(&url, self.node()?)?), + let remote = match Self::remote_url(&self.store)? { + Some(url) => Some(Self::open_remote(&url, &self.config)?), None => None, }; Ok(self.remote.get_or_init(|| remote).as_ref()) } - /// Syncs with `remote`, as this node. + /// Syncs with `remote`, as this node, for its user. pub(super) fn sync<'s>(&'s self, remote: &'s Remote) -> Sync<'s> { - Sync::new(&self.store, remote, self.config.node().clone()) + Sync::new( + &self.store, + remote, + self.config.node().clone(), + self.config.user(), + ) } /// The repository's remote, which the command needs. - fn require_remote(&self) -> Result<&Remote, WorkspaceError> { + pub(super) fn require_remote(&self) -> Result<&Remote, WorkspaceError> { self.remote()?.ok_or(WorkspaceError::NoRemote) } @@ -234,7 +254,7 @@ impl Workspace { /// read or written. pub fn set_remote(&self, url: Option<&str>) -> Result<(), WorkspaceError> { let Some(url) = url else { - match self.remote_url()? { + match Self::remote_url(&self.store)? { Some(url) => println!("{url}"), None => println!("No remote: set one with `origin remote `."), } @@ -261,10 +281,11 @@ impl Workspace { /// Fails if there's no remote, it lacks a target or change asked for, /// or the remote or the store fails. pub fn fetch( - &self, + &mut self, targets: &[TargetName], changes: &[ChangeId], ) -> Result<(), WorkspaceError> { + self.refresh_access()?; let remote = self.require_remote()?; let since = self.cursor(); let Fetch { @@ -320,7 +341,12 @@ impl Workspace { .map(|change| working_copy.find_change(change)) .collect::>()? }; - let pushed = self.sync(self.require_remote()?).push(&changes)?; + let rights = self.push_access()?; + let mut sync = self.sync(self.require_remote()?); + if let Some(rights) = rights { + sync = sync.writing(rights.paths); + } + let pushed = sync.push(&changes)?; for statement in &pushed.unsent { println!( "Statement {} stays here: it needs a statement the remote doesn't have", @@ -338,25 +364,30 @@ impl Workspace { Ok(()) } - /// Fetches the snapshots of `trees` the repository lacks, if it has a - /// remote: a clone only holds the snapshots it read. Skips what the - /// working copy's revision and its base hold. Without a remote, reading - /// a missing snapshot fails later. - pub(super) fn need(&self, trees: &[TreeId]) -> Result<(), WorkspaceError> { - if self.store.held_whole(trees.iter().copied())?.len() == trees.len() { + /// Fetches the snapshots of `records`, revisions and acceptances, that + /// the repository lacks, if it has a remote: a clone only holds the + /// snapshots it read. Skips what the working copy's revision and its + /// base hold. Without a remote, reading a missing snapshot fails later. + pub(super) fn need(&self, records: &[RecordId]) -> Result<(), WorkspaceError> { + let mut lacking = Vec::new(); + for &record in records { + if let Some(snapshot) = self.store.snapshot_of(record)? + && !self.store.holds(snapshot)? + { + lacking.push(record); + } + } + if lacking.is_empty() { return Ok(()); } let Some(remote) = self.remote()? else { return Ok(()); }; let current = self.store.get(self.revision)?; - let base = match current.base { - Base::Acceptance(id) => self.store.get(id)?.body().state, - Base::Revision(id) => self.store.get(id)?.snapshot, - }; + let base = self.store.base_snapshot(current.base)?; let sync = self.sync(remote); - for &tree in trees { - sync.need(tree, &[current.snapshot, base])?; + for record in lacking { + sync.need(record, &[current.snapshot, base])?; } Ok(()) } diff --git a/apps/origin-src/tests/cli.rs b/apps/origin-src/tests/cli.rs index 516dc383..dc4e4e8d 100644 --- a/apps/origin-src/tests/cli.rs +++ b/apps/origin-src/tests/cli.rs @@ -312,7 +312,7 @@ fn a_revision_is_published_only_after_its_conflicts() { let issuer = revision.author.clone(); let described = Revision { predecessors: vec![converged], - description: EncodedObject::blob(b"Described").id().into(), + description: EncodedObject::attachment(b"Described").id().into(), ..revision } .encode(); @@ -517,7 +517,7 @@ fn init_with_a_malformed_key_leaves_no_repository() { } #[test] -fn only_the_creator_of_a_change_abandons_it() { +fn only_the_creator_of_a_change_or_admins_abandon_it() { let repo = Repo::new(); repo.write("file", "ada's\n"); repo.ok(&["describe", "Ada's change"]); @@ -540,6 +540,19 @@ fn only_the_creator_of_a_change_abandons_it() { assert_eq!(repo.ok(&["statements"]), statements); fs::write(&config, "user = \"ada\"\n").unwrap(); assert!(!repo.ok(&["show", &change]).contains("bandoned")); + + // Once the policy makes eve an admin, she may. + repo.ok(&["new", "--on", "policy"]); + let policy = repo + .read("policy.toml") + .replace("\n# [grants]\n", "\n[grants]\n\"user:eve\" = [\"admin\"]\n"); + repo.write("policy.toml", &policy); + repo.ok(&["land"]); + fs::write(&config, "user = \"eve\"\n").unwrap(); + repo.ok(&["abandon", &change, "--reason", "Not mine"]); + fs::write(&config, "user = \"ada\"\n").unwrap(); + let show = repo.ok(&["show", &change]); + assert!(show.contains("Abandoned by user:eve: Not mine\n"), "{show}"); } #[test] @@ -724,7 +737,7 @@ fn statements_about_statements_show_under_them() { fn a_payload_the_repository_lacks_is_unavailable() { let repo = Repo::new(); repo.ok(&["annotate", "@", "review", "--pass", "-m", "Looks good"]); - let hex = EncodedObject::blob(b"Looks good").id().to_hex(); + let hex = EncodedObject::attachment(b"Looks good").id().to_hex(); fs::remove_file(repo.path(&format!( ".origin/objects/loose/{}/{}", &hex[..2], @@ -1093,6 +1106,13 @@ fn removing_a_workspace_keeps_its_work() { .contains("+not snapshotted yet\n") ); + // A workspace removes itself. + let own = others.path().join("own"); + let change = repo.workspace(&own, &[]); + let removed = repo.ok_in(&own, &["workspace", "rm", "."]); + assert!(removed.starts_with(&format!("Kept its working copy: {change} "))); + assert!(!own.exists()); + assert!( repo.fails(&["workspace", "rm", "."]) .contains("is the main working copy") @@ -1419,6 +1439,19 @@ fn commands_in_different_workspaces_run_concurrently() { } } +#[test] +fn commands_in_one_workspace_wait_for_each_other() { + let repo = Repo::new(); + // As a command working in the working copy holds it. + let lock = fs::File::create(repo.path(".origin/lock")).unwrap(); + lock.lock().unwrap(); + let (mut status, _) = repo.spawn(&["status"]); + thread::sleep(Duration::from_millis(500)); + assert!(status.try_wait().unwrap().is_none(), "it waits"); + drop(lock); + assert!(status.wait().unwrap().success()); +} + #[test] fn workspaces_land_their_own_changes() { let repo = Repo::new(); diff --git a/apps/origin-src/tests/common/mod.rs b/apps/origin-src/tests/common/mod.rs index 4bbb0063..8c5dfeef 100644 --- a/apps/origin-src/tests/common/mod.rs +++ b/apps/origin-src/tests/common/mod.rs @@ -15,15 +15,25 @@ use std::thread; use std::time::{Duration, Instant}; /// A user, with a configuration directory, and so a node key, of their -/// own. Clones share it. +/// own, and the token they send servers, if any. Clones share it. #[derive(Clone)] pub struct User { config: Arc, + token: Option, } impl User { - /// A user named `name`, whose node is named after them. + /// A user named `name`, whose node is named after them, with the token + /// [`Server`] knows them by. pub fn new(name: &str) -> Self { + Self { + token: Some(Server::token(name)), + ..Self::anonymous(name) + } + } + + /// A user named `name` without a token: servers don't know them. + pub fn anonymous(name: &str) -> Self { let config = tempfile::tempdir().unwrap(); fs::write( config.path().join("config.toml"), @@ -32,6 +42,15 @@ impl User { .unwrap(); Self { config: Arc::new(config), + token: None, + } + } + + /// The user, sending `token` instead. + pub fn with_token(&self, token: &str) -> Self { + Self { + token: Some(token.to_owned()), + ..self.clone() } } @@ -46,7 +65,11 @@ impl User { command .args(args) .current_dir(cwd) - .env("ORIGIN_CONFIG_DIR", self.config()); + .env("ORIGIN_CONFIG_DIR", self.config()) + .env_remove("ORIGIN_TOKEN"); + if let Some(token) = &self.token { + command.env("ORIGIN_TOKEN", token); + } command } } @@ -260,7 +283,8 @@ impl Repo { } /// A running `origin serve` hosting one repository, `demo`, which its node, -/// `hosted`, orders. Killed when dropped. +/// `hosted`, orders. Each of [`Server::USERS`] signs in with +/// [`Server::token`]. Killed when dropped. pub struct Server { dir: tempfile::TempDir, child: Child, @@ -269,12 +293,28 @@ pub struct Server { } impl Server { + /// The users the server knows. + pub const USERS: [&str; 4] = ["ada", "bob", "carol", "alice"]; + + /// The static token of `user`. + pub fn token(user: &str) -> String { + format!("token-{user}") + } + pub fn start() -> Self { let dir = tempfile::tempdir().unwrap(); let config = dir.path().join("origin-server.toml"); + let tokens: Vec<_> = Self::USERS + .iter() + .map(|user| format!("\"user:{user}\" = \"{}\"", Self::token(user))) + .collect(); + let tokens = tokens.join("\n"); fs::write( &config, - "listen = \"127.0.0.1:0\"\nnode = \"hosted\"\n[repos]\ndemo = \"repos/demo\"\n", + format!( + "listen = \"127.0.0.1:0\"\nnode = \"hosted\"\n[repos]\ndemo = \"repos/demo\"\n\ + [tokens]\n{tokens}" + ), ) .unwrap(); let origin = |args: &[&str]| { @@ -318,6 +358,49 @@ impl Server { pub fn url(&self) -> String { format!("{}/demo", self.base) } + + /// Adds `rules` to `demo`'s `policy.toml`, landing them on `policy` as + /// the server's own user, who runs its node, through the repository's + /// path. + pub fn add_policy(&self, rules: &str) { + let operator = User::anonymous("operator"); + fs::write( + operator.config().join("config.toml"), + "user = \"operator\"\nnode = \"hosted\"\n", + ) + .unwrap(); + fs::copy( + self.dir().join("node.key"), + operator.config().join("node.key"), + ) + .unwrap(); + let path = self.dir().join("repos/demo"); + let policy = Repo::clone(&operator, path.to_str().unwrap(), &["--target", "policy"]); + let mut toml = policy.read("policy.toml"); + toml.push_str(rules); + policy.write("policy.toml", &toml); + policy.ok(&["describe", "Change the rules"]); + let landed = policy.ok(&["land"]); + assert!(landed.contains("Landed change"), "{landed}"); + } + + /// Signs a token for `principal` with `scope` with the server's key. + pub fn issue_token(&self, principal: &str, scope: &[&str]) -> String { + let mut command = Command::new(env!("CARGO_BIN_EXE_origin")); + command + .args(["token", "issue", "--as", principal, "--config"]) + .arg(self.dir().join("origin-server.toml")); + for capability in scope { + command.args(["--scope", capability]); + } + let output = command.output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() + } } impl Drop for Server { diff --git a/apps/origin-src/tests/http.rs b/apps/origin-src/tests/http.rs index 7c38d1c6..6753991a 100644 --- a/apps/origin-src/tests/http.rs +++ b/apps/origin-src/tests/http.rs @@ -1,39 +1,52 @@ //! The HTTP API, driven through the SDK against a real server: every SDK //! method, over the server's own repositories. +use std::collections::BTreeMap; use std::slice; use std::sync::Arc; use std::time::{Duration, Instant}; use origin_client::{Client, Error, Repo}; use origin_shared::api::{ - ErrorCode, EventsQuery, Expected, FetchRecords, FetchSnapshot, InventoryRequest, LandRequest, - List, LogQuery, NewTarget, PutQuery, RepoName, StatementsQuery, + BrowseQuery, ErrorCode, EventsQuery, Expected, FetchRecords, FetchSnapshot, InventoryRequest, + LandRequest, List, LogQuery, NewTarget, PutQuery, RepoName, StatementsQuery, TokenRequest, }; use origin_shared::event::EventKind; use origin_shared::landing::{Blocked, Landing, Proposal}; use origin_shared::pack::Pack; use origin_shared::{ - Base, BlobId, ChangeId, Digest, EncodedObject, EntryKind, EntryName, Inputs, Kind, NodeKey, - Period, Record, RecordId, RepoPath, Revision, RevisionId, Source, Statement, TargetName, Tree, - TreeEntry, TreeId, Verdict, + AttachmentId, Base, ChangeId, Digest, EncodedObject, EntryKind, EntryName, Inputs, Kind, + NodeKey, Period, Record, RecordId, RepoPath, Revision, RevisionId, Source, Statement, + TargetName, Tree, TreeEntry, TreeId, Verdict, }; +use origin_src::auth::{Credentials, StaticTokens}; use origin_src::http::Api; use origin_src::policy::Policy; use origin_src::server::Repositories; use origin_src::service::{Node, OriginService}; /// A server hosting `demo`, whose policy requires nothing, and `strict`, -/// whose `main` requires a passing `ci.test` from `user:ci`. The server's -/// node, `hosted`, orders both. +/// whose `main` requires a passing `ci.test` from `user:ci`, and where +/// only `user:ci` issues `ci.*` statements, `secrets` is withheld from +/// everyone but `user:ada`, and only she lands. The server's node, +/// `hosted`, orders both. `user:ada` and `user:ci` have static tokens. struct Server { _dir: tempfile::TempDir, + /// The server's base URL. + base: String, client: Client, + /// `demo` and `strict`, as `user:ada`. demo: Repo, strict: Repo, + /// `strict`, as `user:ci`. + ci: Repo, + /// `strict`, anonymously. + anonymous: Repo, } impl Server { + const TOKENS: &str = "\"user:ada\" = \"ada-token\"\n\"user:ci\" = \"ci-token\"\n"; + async fn start() -> Self { let dir = tempfile::tempdir().unwrap(); let node = Node { @@ -44,10 +57,15 @@ impl Server { let strict = format!( "[nodes]\nhosted = \"{}\"\n[targets.policy]\nauthority = \"node:hosted\"\n\ [targets.main]\nauthority = \"node:hosted\"\n\ - require = [{{ kind = \"ci.test\", verdict = \"pass\", issuer = \"user:ci\" }}]\n", + require = [{{ kind = \"ci.test\", verdict = \"pass\", issuer = \"user:ci\" }}]\n\ + [grants]\n\"user:ada\" = [\"land *\", \"annotate review\"]\n\ + \"user:ci\" = [\"annotate ci.*\"]\n\ + [[paths]]\npath = \"/\"\ngrants = {{ all = \"read\", \"user:ada\" = \"write\" }}\n\ + [[paths]]\npath = \"secrets\"\ngrants = {{ all = \"none\", \"user:ada\" = \"write\" }}\n", node.key.public() ); - let mut repositories = Repositories::default(); + let tokens: StaticTokens = toml::from_str(Self::TOKENS).unwrap(); + let mut repositories = Repositories::new(Credentials::new(node.clone(), Box::new(tokens))); for (name, policy) in [("demo", open), ("strict", strict)] { let service = OriginService::create(dir.path().join(name), node.clone(), &policy).unwrap(); @@ -57,11 +75,16 @@ impl Server { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, Api::router(repositories)).await }); - let client = Client::new(&format!("http://{address}")); + let anonymous = Client::new(&format!("http://{address}")); + let client = anonymous.clone().with_token("ada-token"); + let ci = anonymous.clone().with_token("ci-token"); Self { _dir: dir, + base: format!("http://{address}"), demo: client.repo("demo".parse().unwrap()), strict: client.repo("strict".parse().unwrap()), + ci: ci.repo("strict".parse().unwrap()), + anonymous: anonymous.repo("strict".parse().unwrap()), client, } } @@ -97,14 +120,14 @@ impl Made { }) .collect(); let tree = tree.encode(); - let description = EncodedObject::blob(b"A change"); + let description = EncodedObject::attachment(b"A change"); let revision = Revision { change, predecessors, base, deps: Vec::new(), snapshot: tree.id().into(), - description: BlobId::from(description.id()), + description: AttachmentId::from(description.id()), author: "user:ada".parse().unwrap(), } .encode(); @@ -113,6 +136,57 @@ impl Made { Self { id, objects } } + /// A revision whose files may be in directories, such as + /// `src/lib.rs`. + fn nested(change: ChangeId, base: Base, files: &[(&str, &str)]) -> Self { + let mut objects = Vec::new(); + let tree = Self::tree(files, &mut objects); + let description = EncodedObject::attachment(b"A change"); + let revision = Revision { + change, + predecessors: Vec::new(), + base, + deps: Vec::new(), + snapshot: tree, + description: description.id().into(), + author: "user:ada".parse().unwrap(), + } + .encode(); + let id = revision.id().into(); + objects.extend([description, revision]); + Self { id, objects } + } + + /// The tree of `files`, pushing its objects, children first. + fn tree(files: &[(&str, &str)], objects: &mut Vec) -> TreeId { + let mut dirs: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new(); + let mut tree = Tree::default(); + for &(path, content) in files { + if let Some((dir, rest)) = path.split_once('/') { + dirs.entry(dir).or_default().push((rest, content)); + continue; + } + let blob = EncodedObject::blob(content.as_bytes()); + let entry = TreeEntry { + kind: EntryKind::File, + id: blob.id(), + }; + tree.insert(EntryName::new(path).unwrap(), entry); + objects.push(blob); + } + for (dir, files) in dirs { + let entry = TreeEntry { + kind: EntryKind::Tree, + id: Self::tree(&files, objects).into(), + }; + tree.insert(EntryName::new(dir).unwrap(), entry); + } + let encoded = tree.encode(); + let id = encoded.id().into(); + objects.push(encoded); + id + } + fn pack(&self) -> Pack { Pack { objects: self.objects.clone(), @@ -130,12 +204,12 @@ async fn main_tip(repo: &Repo) -> Base { Base::Acceptance(main.head.acceptance) } -fn ci_test(subject: RevisionId) -> EncodedObject { +fn ci_test(subject: RevisionId, issuer: &str) -> EncodedObject { Statement { subject: RecordId::Revision(subject), kind: Kind::new("ci.test").unwrap(), verdict: Some(Verdict::Pass), - issuer: "user:ci".parse().unwrap(), + issuer: issuer.parse().unwrap(), inputs: None, payload: None, supersedes: None, @@ -265,26 +339,29 @@ async fn puts_refuse_objects_named_as_another_kind() { // A revision whose snapshot is a blob, in the batch. let blob = EncodedObject::blob(b"not a tree"); - let description = EncodedObject::blob(b"A change"); + let description = EncodedObject::attachment(b"A change"); let revision = Revision { change: change(1), predecessors: Vec::new(), base, deps: Vec::new(), snapshot: blob.id().into(), - description: BlobId::from(description.id()), + description: description.id().into(), author: "user:ada".parse().unwrap(), } .encode(); - let objects = vec![blob, description, revision]; + let objects = vec![blob.clone(), description, revision]; assert_eq!(refused(objects).await, ErrorCode::BadRequest); // Trees naming a stored blob as a directory, and a stored tree as a // file. - let made = Made::revision(change(2), base, Vec::new(), &[("lib.rs", "fn a() {}")]); + let made = Made::nested(change(2), base, &[("src/lib.rs", "fn a() {}")]); repo.put_records(&made.pack()).await.unwrap(); - let (file, tree) = (made.objects[0].id(), made.objects[1].id()); - for (kind, id) in [(EntryKind::Tree, file), (EntryKind::File, tree)] { + let src = made.objects[1].id(); + for (kind, id) in [ + (EntryKind::Tree, made.objects[0].id()), + (EntryKind::File, src), + ] { let tree: Tree = [(EntryName::new("x").unwrap(), TreeEntry { kind, id })] .into_iter() .collect(); @@ -294,9 +371,10 @@ async fn puts_refuse_objects_named_as_another_kind() { // One stored object needed as two kinds, the right one last: a // revision's snapshot and description, and a tree's directory and // file. - let stored = EncodedObject::blob(b"stored"); + let attachment = EncodedObject::attachment(b"stored"); + let blob = made.objects[0].clone(); repo.put_records(&Pack { - objects: vec![stored.clone()], + objects: vec![attachment.clone()], }) .await .unwrap(); @@ -305,14 +383,14 @@ async fn puts_refuse_objects_named_as_another_kind() { predecessors: Vec::new(), base, deps: Vec::new(), - snapshot: stored.id().into(), - description: BlobId::from(stored.id()), + snapshot: attachment.id().into(), + description: attachment.id().into(), author: "user:ada".parse().unwrap(), }; assert_eq!(refused(vec![both.encode()]).await, ErrorCode::BadRequest); let entry = |kind| TreeEntry { kind, - id: stored.id(), + id: blob.id(), }; let tree: Tree = [ (EntryName::new("a").unwrap(), entry(EntryKind::Tree)), @@ -373,7 +451,7 @@ async fn promotions_must_match_their_original() { subject: RecordId::Revision(made.id), kind: Kind::new("ci.test").unwrap(), verdict: Some(Verdict::Pass), - issuer: "user:ci".parse().unwrap(), + issuer: "user:ada".parse().unwrap(), inputs: Some(Inputs { paths: vec!["a.txt".parse().unwrap()], definition: Digest::of(b"test"), @@ -386,7 +464,6 @@ async fn promotions_must_match_their_original() { }; let promotion = Statement { subject: RecordId::Revision(same.id), - issuer: "user:eve".parse().unwrap(), promoted_from: Some(original.encode().id().into()), ..original.clone() }; @@ -426,7 +503,7 @@ async fn fetches_snapshots_by_walking_them() { }; let snapshot = |have: Vec, paths: &[&str]| FetchSnapshot { - snapshot: tree.id().into(), + of: RecordId::Revision(made.id), have, paths: paths .iter() @@ -450,7 +527,7 @@ async fn fetches_snapshots_by_walking_them() { repo.put_records(&edited.pack()).await.unwrap(); let changed = repo .fetch_snapshot(&FetchSnapshot { - snapshot: edited.objects[2].id().into(), + of: RecordId::Revision(edited.id), have: vec![tree.id().into()], paths: Vec::new(), skip: 0, @@ -462,14 +539,24 @@ async fn fetches_snapshots_by_walking_them() { [edited.objects[2].clone(), edited.objects[1].clone()], "the new tree and the edited file" ); - let missing = FetchSnapshot { - snapshot: description.id().into(), + let subtree = FetchSnapshot { + of: RecordId::Snapshot(tree.id().into()), have: Vec::new(), paths: Vec::new(), skip: 0, }; + let error = repo.fetch_snapshot(&subtree).await.unwrap_err(); + assert_eq!( + code(error), + ErrorCode::BadRequest, + "only a record's snapshot, not a tree within one" + ); + let missing = FetchSnapshot { + of: RecordId::Revision(description.id().into()), + ..subtree + }; let error = repo.fetch_snapshot(&missing).await.unwrap_err(); - assert_eq!(code(error), ErrorCode::NotFound, "a blob is no snapshot"); + assert_eq!(code(error), ErrorCode::NotFound); } #[tokio::test] @@ -478,7 +565,7 @@ async fn reads_statements_and_revisions() { let repo = &server.demo; let base = main_tip(repo).await; let first = Made::revision(change(1), base, vec![], &[("a.txt", "1")]); - let statement = ci_test(first.id); + let statement = ci_test(first.id, "user:ada"); let mut pack = first.pack(); pack.objects.push(statement.clone()); repo.put_records(&pack).await.unwrap(); @@ -648,11 +735,22 @@ async fn landings_wait_for_evidence() { "{page:?}" ); - repo.put_records(&Pack { - objects: vec![ci_test(made.id)], - }) - .await - .unwrap(); + // Only `user:ci` issues them, and only as itself. + let forged = Pack { + objects: vec![ci_test(made.id, "user:ci")], + }; + assert_eq!( + code(repo.put_records(&forged).await.unwrap_err()), + ErrorCode::Forbidden + ); + let own = Pack { + objects: vec![ci_test(made.id, "user:ada")], + }; + assert_eq!( + code(repo.put_records(&own).await.unwrap_err()), + ErrorCode::Forbidden + ); + server.ci.put_records(&forged).await.unwrap(); let landed = repo.land(&land).await.unwrap(); assert!(matches!(landed.landings[..], [Landing::Landed { .. }])); assert_eq!(repo.landings().await.unwrap(), []); @@ -705,6 +803,269 @@ async fn events_long_poll() { ); } +/// Lands a change of `user:ada` with `files` on `strict`'s `main`, with the +/// `ci.test` it requires. +async fn land_on_strict(server: &Server, seed: u8, files: &[(&str, &str)]) -> Made { + let made = Made::nested(change(seed), main_tip(&server.strict).await, files); + server.strict.put_records(&made.pack()).await.unwrap(); + server + .ci + .put_records(&Pack { + objects: vec![ci_test(made.id, "user:ci")], + }) + .await + .unwrap(); + let land = LandRequest { + proposal: Proposal::Change(change(seed)), + onto: None, + }; + let landed = server.strict.land(&land).await.unwrap(); + assert!( + matches!(landed.landings[..], [Landing::Landed { .. }]), + "{landed:?}" + ); + made +} + +#[tokio::test] +async fn tells_callers_what_they_may_do_and_issues_narrower_tokens() { + let server = Server::start().await; + let path = |path: &str| RepoPath::new(path).unwrap(); + let ada = server.strict.whoami().await.unwrap(); + assert_eq!(ada.principal, Some("user:ada".parse().unwrap())); + assert!(ada.paths.writes(&path("secrets/key")) && ada.may_land(&TargetName::MAIN)); + let anonymous = server.anonymous.whoami().await.unwrap(); + assert_eq!(anonymous.principal, None); + assert!(anonymous.paths.reads(&path("README.md"))); + assert!(!anonymous.paths.reads(&path("secrets/key"))); + let stranger = Client::new(&server.base).with_token("nope"); + assert_eq!( + code(stranger.repos().await.unwrap_err()), + ErrorCode::Unauthorized + ); + + // Ada hands an agent a token that only writes `src`. + let scope = |scope: &[&str]| TokenRequest { + scope: scope + .iter() + .map(|capability| capability.parse().unwrap()) + .collect(), + ttl: Period(Duration::from_hours(1)), + }; + let issued = server + .strict + .issue_token(&scope(&["write src"])) + .await + .unwrap(); + assert_eq!(issued.principal, "user:ada".parse().unwrap()); + let agent = Client::new(&server.base) + .with_token(issued.token) + .repo("strict".parse().unwrap()); + let rights = agent.whoami().await.unwrap(); + assert!(rights.paths.writes(&path("src/lib.rs"))); + assert!(!rights.paths.reads(&path("README.md"))); + assert!(rights.land.is_empty() && !rights.admin); + // Its token only narrows: what it lacks, it cannot pass on. + for wider in [&["write /"][..], &["land main"], &["read secrets"]] { + let error = agent.issue_token(&scope(wider)).await.unwrap_err(); + assert_eq!(code(error), ErrorCode::Forbidden, "{wider:?}"); + } + assert!(agent.issue_token(&scope(&["write src/net"])).await.is_ok()); + let error = server + .anonymous + .issue_token(&scope(&["read /"])) + .await + .unwrap_err(); + assert_eq!(code(error), ErrorCode::Forbidden); + + // The agent proposes changes to `src`, and nothing else, and lands + // nothing. + let base = main_tip(&server.strict).await; + let in_src = Made::nested(change(1), base, &[("src/lib.rs", "fn a() {}")]); + agent.put_records(&in_src.pack()).await.unwrap(); + let outside = Made::nested(change(2), base, &[("README.md", "hi")]); + let error = agent.put_records(&outside.pack()).await.unwrap_err(); + assert_eq!(code(error), ErrorCode::Forbidden); + let land = LandRequest { + proposal: Proposal::Change(change(1)), + onto: None, + }; + assert_eq!( + code(agent.land(&land).await.unwrap_err()), + ErrorCode::Forbidden + ); + // Anonymous callers only read. + let error = server + .anonymous + .put_records(&in_src.pack()) + .await + .unwrap_err(); + assert_eq!(code(error), ErrorCode::Forbidden); +} + +#[tokio::test] +async fn browses_snapshots_without_their_history() { + let server = Server::start().await; + let made = land_on_strict( + &server, + 1, + &[ + ("README.md", "hello"), + ("secrets/key", "hunter2"), + ("src/lib.rs", "fn a() {}"), + ], + ) + .await; + let at = |at: &str, path: &str| BrowseQuery { + at: at.parse().unwrap(), + path: RepoPath::new(path).unwrap(), + }; + let root = server.strict.tree(&at("main", "/")).await.unwrap(); + let names: Vec<_> = root + .entries + .iter() + .map(|entry| (entry.name.as_str(), entry.kind, entry.withheld)) + .collect(); + assert_eq!( + names, + [ + ("README.md", EntryKind::File, false), + ("secrets", EntryKind::Tree, false), + ("src", EntryKind::Tree, false), + ] + ); + let revision = format!("revision:{}", made.id); + assert_eq!( + server + .strict + .file(&at(&revision, "secrets/key")) + .await + .unwrap(), + b"hunter2" + ); + + // Others see that the secret is there, but not what it is. + let anonymous = &server.anonymous; + let root = anonymous.tree(&at("main", "")).await.unwrap(); + assert!(root.entries[1].withheld, "{root:?}"); + assert_eq!( + anonymous.file(&at("main", "README.md")).await.unwrap(), + b"hello" + ); + for path in ["secrets/key", "secrets"] { + let error = anonymous.file(&at("main", path)).await.unwrap_err(); + assert_eq!(code(error), ErrorCode::Forbidden, "{path}"); + } + let error = anonymous.tree(&at("main", "secrets")).await.unwrap_err(); + assert_eq!(code(error), ErrorCode::Forbidden); + let error = anonymous.tree(&at("main", "README.md")).await.unwrap_err(); + assert_eq!(code(error), ErrorCode::BadRequest); + let error = anonymous.file(&at("main", "missing")).await.unwrap_err(); + assert_eq!(code(error), ErrorCode::NotFound); + let error = anonymous.tree(&at("nowhere", "")).await.unwrap_err(); + assert_eq!(code(error), ErrorCode::NotFound); +} + +#[tokio::test] +async fn reads_only_what_the_caller_may() { + let server = Server::start().await; + let public = land_on_strict( + &server, + 1, + &[("README.md", "hello"), ("secrets/key", "hunter2")], + ) + .await; + let secret = Made::nested( + change(2), + main_tip(&server.strict).await, + &[("README.md", "hello"), ("secrets/key", "rotated")], + ); + server.strict.put_records(&secret.pack()).await.unwrap(); + let anonymous = &server.anonymous; + + // The walk withholds the secret, but keeps its place in the tree. + let walked = anonymous + .fetch_snapshot(&FetchSnapshot { + of: RecordId::Revision(public.id), + have: Vec::new(), + paths: Vec::new(), + skip: 0, + }) + .await + .unwrap(); + let ids: Vec<_> = walked.pack.objects.iter().map(EncodedObject::id).collect(); + let hunter2 = EncodedObject::blob(b"hunter2").id(); + assert!(!ids.contains(&hunter2), "the secret's content is withheld"); + assert_eq!(ids.len(), 2, "the root and README.md, not `secrets`'s tree"); + let root: Tree = walked.pack.objects[0].decode_as().unwrap(); + assert!(root.get("secrets").is_some()); + + // A revision that only changes what they can't read is hidden, with + // everything about it. + let records = anonymous + .fetch_records(&FetchRecords { + ids: vec![public.id.into(), secret.id.into()], + }) + .await + .unwrap(); + assert_eq!(records.pack.objects[0].id(), public.id.into()); + assert!( + records + .pack + .objects + .iter() + .all(|object| object.id() != secret.id.into()) + ); + let error = anonymous.revisions(change(2)).await.unwrap_err(); + assert_eq!(code(error), ErrorCode::NotFound); + let query = StatementsQuery { + subject: RecordId::Revision(secret.id), + }; + server + .strict + .put_records(&Pack { + objects: vec![review(secret.id)], + }) + .await + .unwrap(); + assert_eq!(server.strict.statements(&query).await.unwrap().len(), 1); + assert_eq!(anonymous.statements(&query).await.unwrap(), []); + let error = anonymous + .fetch_snapshot(&FetchSnapshot { + of: RecordId::Revision(secret.id), + have: Vec::new(), + paths: Vec::new(), + skip: 0, + }) + .await + .unwrap_err(); + assert_eq!(code(error), ErrorCode::NotFound); + let everything = EventsQuery { + cursor: Some(0), + ..EventsQuery::default() + }; + let hidden = |page: &origin_shared::event::Page| { + page.events.iter().any(|event| { + matches!(event.kind, + EventKind::RevisionCreated { revision, .. } if revision == secret.id) + || matches!(event.kind, + EventKind::StatementIssued { subject, .. } if subject == RecordId::Revision(secret.id)) + }) + }; + assert!(hidden(&server.strict.events(&everything).await.unwrap())); + assert!(!hidden(&anonymous.events(&everything).await.unwrap())); +} + +/// A passing review from `user:ada` about `subject`. +fn review(subject: RevisionId) -> EncodedObject { + Statement { + kind: Kind::new("review").unwrap(), + issuer: "user:ada".parse().unwrap(), + ..ci_test(subject, "user:ada").decode_as().unwrap() + } + .encode() +} + #[tokio::test] async fn serves_its_openapi_document() { let server = Server::start().await; diff --git a/apps/origin-src/tests/permissions.rs b/apps/origin-src/tests/permissions.rs new file mode 100644 index 00000000..61415531 --- /dev/null +++ b/apps/origin-src/tests/permissions.rs @@ -0,0 +1,309 @@ +//! End-to-end tests of permissions: what users see of a server's +//! repository, and what they may change, through the real binary. + +mod common; + +use std::fs; + +use common::{Repo, Server, User}; + +/// Everyone reads, alice writes everything and changes the rules, bob +/// writes `src` and `policy.toml`, but isn't admin, both land, and only +/// alice sees `secrets`. +const RULES: &str = r#" +[grants] +"user:alice" = ["admin", "land *"] +"user:bob" = ["land *"] + +[[paths]] +path = "/" +grants = { all = "read", "user:alice" = "write" } + +[[paths]] +path = "src" +grants = { "user:bob" = "write" } + +[[paths]] +path = "policy.toml" +grants = { "user:bob" = "write" } + +[[paths]] +path = "secrets" +grants = { all = "none", "user:alice" = "write" } +"#; + +/// A server with [`RULES`], and alice's clone, where she landed a README, +/// a library and a secret. +fn server() -> (Server, Repo) { + let server = Server::start(); + server.add_policy(RULES); + let alice = Repo::clone(&User::new("alice"), &server.url(), &[]); + alice.write("README.md", "hello\n"); + alice.write("src/lib.rs", "fn lib() {}\n"); + alice.write("secrets/key", "hunter2\n"); + alice.ok(&["describe", "First"]); + alice.ok(&["push"]); + alice.ok(&["land"]); + (server, alice) +} + +#[test] +fn users_see_and_change_only_what_they_may() { + let (server, alice) = server(); + assert_eq!(alice.ok(&["whoami"]), "user:alice\n"); + + // Bob gets everything but the secret, and the working copy is clean. + let bob = Repo::clone(&User::new("bob"), &server.url(), &[]); + assert_eq!(bob.read("src/lib.rs"), "fn lib() {}\n"); + assert!(!bob.path("secrets/key").exists()); + assert!(bob.ok(&["status"]).ends_with("No changes.\n")); + + // Bob may change `src`, and the secret survives his landing. + bob.write("src/lib.rs", "fn lib() { bob() }\n"); + bob.ok(&["describe", "Bob's"]); + bob.ok(&["push"]); + bob.ok(&["land"]); + alice.ok(&["pull"]); + assert_eq!(alice.read("src/lib.rs"), "fn lib() { bob() }\n"); + assert_eq!(alice.read("secrets/key"), "hunter2\n"); + + // Changes to files bob may not write fail before reaching the server. + bob.write("README.md", "bob was here\n"); + let error = bob.fails(&["push"]); + assert!(error.contains("you may not change `README.md`"), "{error}"); + + // Anonymous users read what everyone may, and change nothing. + let anonymous = Repo::clone(&User::anonymous("eve"), &server.url(), &[]); + assert_eq!(anonymous.read("README.md"), "hello\n"); + assert!(!anonymous.path("secrets").exists()); + assert!(anonymous.ok(&["whoami"]).starts_with("Anonymous")); + let permissions = anonymous.ok(&["permissions"]); + assert!( + permissions.contains("read /\nnone secrets\n"), + "{permissions}" + ); +} + +#[test] +fn readable_files_sharing_a_secret_files_content_are_kept() { + let (server, alice) = server(); + alice.write("public", "hunter2\n"); + alice.ok(&["push"]); + alice.ok(&["land"]); + + let bob = Repo::clone(&User::new("bob"), &server.url(), &[]); + assert_eq!(bob.read("public"), "hunter2\n"); + assert!(!bob.path("secrets/key").exists()); + assert!(bob.ok(&["status"]).ends_with("No changes.\n")); +} + +#[test] +fn granting_read_access_restores_withheld_files() { + let (server, _alice) = server(); + let bob = Repo::clone(&User::new("bob"), &server.url(), &[]); + assert!(!bob.path("secrets/key").exists()); + + // Bob can't change the rules: writing `policy.toml` isn't enough, only + // an admin lands on `policy`. + let rules = Repo::clone(&User::new("bob"), &server.url(), &["--target", "policy"]); + let mut policy = rules.read("policy.toml"); + policy.push_str("\n[[paths]]\npath = \"secrets/key\"\ngrants = { \"user:bob\" = \"read\" }\n"); + rules.write("policy.toml", &policy); + rules.ok(&["describe", "Let bob read the key"]); + rules.ok(&["push"]); + let error = rules.fails(&["land"]); + assert!(error.contains("you may not land on `policy`"), "{error}"); + + // Alice can. + let admin = Repo::clone(&User::new("alice"), &server.url(), &["--target", "policy"]); + admin.write("policy.toml", &policy); + admin.ok(&["describe", "Let bob read the key"]); + admin.ok(&["push"]); + admin.ok(&["land"]); + + bob.ok(&["fetch"]); + assert_eq!(bob.read("secrets/key"), "hunter2\n"); + assert!(bob.ok(&["status"]).ends_with("No changes.\n")); +} + +#[test] +fn agents_act_with_scoped_tokens() { + let (server, alice) = server(); + let output = alice.run( + alice.root(), + &[ + "token", + "issue", + "--scope", + "read README.md", + "--scope", + "write src", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let token = String::from_utf8(output.stdout).unwrap(); + + // The agent acts for alice, but only reads and writes what its token + // allows: it lands nothing. + let agent = Repo::clone( + &User::new("alice").with_token(token.trim()), + &server.url(), + &[], + ); + assert_eq!(agent.read("README.md"), "hello\n"); + assert!( + !agent.path("secrets").exists(), + "alice reads it, her token doesn't" + ); + let whoami = agent.ok(&["whoami"]); + assert_eq!( + whoami, + "user:alice\nToken scope: read README.md, write src\n" + ); + agent.write("src/lib.rs", "fn lib() { agent() }\n"); + agent.ok(&["describe", "The agent's work"]); + agent.ok(&["push"]); + let error = agent.fails(&["land"]); + assert!(error.contains("you may not land on `main`"), "{error}"); + agent.write("README.md", "agent was here\n"); + let error = agent.fails(&["push"]); + assert!(error.contains("you may not change `README.md`"), "{error}"); + let error = agent.fails(&["token", "issue", "--scope", "write /"]); + assert!(error.contains("which don't include `write /`"), "{error}"); + + // An operator signs tokens for identities of their own: they act with + // the rights the policy gives them. + let token = server.issue_token("user:agent-1", &["read /"]); + let other = Repo::clone( + &User::anonymous("agent-1").with_token(&token), + &server.url(), + &[], + ); + assert_eq!(other.read("README.md"), "hello\n"); + assert!(other.ok(&["whoami"]).starts_with("user:agent-1\n")); +} + +#[test] +fn users_with_other_rights_take_changes_over() { + let (server, alice) = server(); + // Alice changes the README and the library, and hands the change to + // bob, who writes the library alone. + alice.ok(&["new", "Shared work"]); + alice.write("README.md", "hello, bob\n"); + alice.write("src/lib.rs", "fn lib() { alice() }\n"); + alice.ok(&["push"]); + let change = alice.full_change(); + let bob = Repo::clone(&User::new("bob"), &server.url(), &[]); + bob.ok(&["fetch", "--change", &change]); + bob.ok(&["edit", &change]); + bob.write("src/lib.rs", "fn lib() { bob() }\n"); + bob.ok(&["push", &change]); + + // He can't give it up for her: she created it. + let error = bob.fails(&["abandon", "--reason", "Mine now"]); + assert!(error.contains("may not issue `core.abandon`"), "{error}"); + + // She lands it as he left it. + alice.ok(&["fetch"]); + alice.ok(&["land", &change]); + alice.ok(&["pull"]); + assert_eq!(alice.read("README.md"), "hello, bob\n"); + assert_eq!(alice.read("src/lib.rs"), "fn lib() { bob() }\n"); +} + +#[test] +fn users_sharing_a_repository_keep_their_own_rights() { + let (_server, alice) = server(); + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path().join("bob"); + alice.workspace(&workspace, &[]); + let bob = |args: &[&str]| User::new("bob").command(&workspace, args).output().unwrap(); + assert!(bob(&["fetch"]).status.success()); + + // Bob's rights, which his fetch saved, aren't alice's. + alice.write("README.md", "hello, alice\n"); + alice.ok(&["push"]); + fs::write(workspace.join("README.md"), "hello, bob\n").unwrap(); + let pushed = bob(&["push"]); + let error = String::from_utf8_lossy(&pushed.stderr); + assert!(error.contains("you may not change `README.md`"), "{error}"); +} + +#[test] +fn users_who_never_synced_push_with_their_own_rights() { + let server = Server::start(); + server.add_policy(RULES); + // Bob, who writes `src` alone, clones; alice, in a workspace of his + // clone, pushes a README before she ever fetches. + let bob = Repo::clone(&User::new("bob"), &server.url(), &[]); + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path().join("alice"); + let alice = User::new("alice"); + let path = workspace.to_str().unwrap(); + let made = alice + .command(bob.root(), &["workspace", "new", path]) + .output(); + assert!(made.unwrap().status.success()); + let alice = |args: &[&str]| alice.command(&workspace, args).output().unwrap(); + fs::write(workspace.join("README.md"), "hello\n").unwrap(); + assert!(alice(&["describe", "Readme"]).status.success()); + let pushed = alice(&["push"]); + assert!( + pushed.status.success(), + "{}", + String::from_utf8_lossy(&pushed.stderr) + ); + + // Her rights are kept as hers, and those of each scope of her tokens + // apart: a new token with the same scope keeps them. + let access = bob.path(".origin/access"); + let kept = || fs::read_dir(&access).unwrap().count(); + let fetch = |scope: &[&str]| { + let token = server.issue_token("user:alice", scope); + let rotated = User::new("alice").with_token(&token); + let fetched = rotated.command(&workspace, &["fetch"]).output().unwrap(); + assert!(fetched.status.success()); + kept() + }; + let before = kept(); + let scoped = fetch(&["read /", "write /"]); + assert_eq!(scoped, before + 1); + assert_eq!(fetch(&["write /", "read /"]), scoped); +} + +#[test] +fn tokens_of_one_user_keep_their_own_rights() { + let (server, alice) = server(); + let dir = tempfile::tempdir().unwrap(); + // An agent of alice's, whose token reads and writes `src` alone, + // fetches in a workspace of her clone. + let token = server.issue_token("user:alice", &["read src", "write src"]); + let agent = User::new("alice").with_token(&token); + let workspace = dir.path().join("agent"); + alice.workspace(&workspace, &[]); + let fetched = agent.command(&workspace, &["fetch"]).output().unwrap(); + assert!(fetched.status.success()); + + // Alice's rights are still hers: a new workspace of hers has every + // file, and her pushes are checked as she may write. + let own = dir.path().join("own"); + alice.workspace(&own, &[]); + assert_eq!( + fs::read_to_string(own.join("secrets/key")).unwrap(), + "hunter2\n" + ); + fs::write(own.join("README.md"), "hello, alice\n").unwrap(); + let pushed = User::new("alice") + .command(&own, &["push"]) + .output() + .unwrap(); + assert!( + pushed.status.success(), + "{}", + String::from_utf8_lossy(&pushed.stderr) + ); +} diff --git a/apps/origin-src/tests/sync.rs b/apps/origin-src/tests/sync.rs index 99572a34..106f63c3 100644 --- a/apps/origin-src/tests/sync.rs +++ b/apps/origin-src/tests/sync.rs @@ -208,6 +208,7 @@ fn a_move_that_cannot_fetch_moves_nothing() { let url = origin.root().to_str().unwrap(); let clone = Repo::clone(&ada, url, &[]); clone.ok(&["fetch", "--change", &change]); + let own = clone.change(); // The remote is gone while `edit` needs the change's files from it. let away = origin.root().with_extension("away"); @@ -221,4 +222,9 @@ fn a_move_that_cannot_fetch_moves_nothing() { fs::rename(&away, origin.root()).unwrap(); clone.ok(&["edit", &change]); assert_eq!(clone.read("x"), "x\n"); + + // A move that needs nothing from it works without it. + fs::rename(origin.root(), &away).unwrap(); + clone.ok(&["edit", &own]); + assert_eq!(clone.read("notes"), "mine\n"); } diff --git a/crates/origin-client/src/lib.rs b/crates/origin-client/src/lib.rs index 68523f5f..c815c350 100644 --- a/crates/origin-client/src/lib.rs +++ b/crates/origin-client/src/lib.rs @@ -5,7 +5,7 @@ //! use origin_shared::api::RepoName; //! //! # async fn example() -> Result<(), Box> { -//! let client = Client::new("http://127.0.0.1:7878"); +//! let client = Client::new("http://127.0.0.1:7878").with_token("a long random secret"); //! let repo = client.repo(RepoName::new("demo")?); //! for target in repo.targets().await? { //! println!("{} is at #{}", target.name, target.head.seq); @@ -18,10 +18,12 @@ //! the two cannot disagree on a contract. The server also describes its API //! in `docs/openapi.json`, for other languages. +use origin_shared::access::Rights; use origin_shared::api::{ - Annotation, ApiError, EventsQuery, FetchRecords, FetchSnapshot, Inventory, InventoryRequest, - LandRequest, Landings, LogEntry, LogQuery, MORE, NewTarget, PutQuery, RepoName, RepoSummary, - Revisions, StatementsQuery, Stored, TargetInfo, + Annotation, ApiError, BrowseQuery, EventsQuery, FetchRecords, FetchSnapshot, Inventory, + InventoryRequest, IssuedToken, LandRequest, Landings, Listing, LogEntry, LogQuery, MORE, + NewTarget, PutQuery, RepoName, RepoSummary, Revisions, StatementsQuery, Stored, TargetInfo, + TokenRequest, }; use origin_shared::event::Page; use origin_shared::landing::Pending; @@ -66,16 +68,16 @@ endpoints! { } impl Repo { - /// Fetches records by id, in the order asked, each followed by the - /// blob it carries if the server has it: a revision's description, - /// a statement's payload. A part that stops short holds those up to - /// the last record it has. + /// Fetches the records the caller sees by id, in the order asked, + /// each followed by the attachment it carries if the server has + /// it: a revision's description, a statement's payload. A part + /// that stops short holds those up to the last record it has. fn fetch_records(request: &FetchRecords) -> Part = POST "records/fetch", json(request) => part; - /// Fetches the trees and blobs of a snapshot that none of the - /// request's snapshots holds at the same path, parents first. A - /// part that stops short is followed by asking again, skipping the - /// objects it holds. + /// Fetches the trees and blobs of a record's snapshot that none of + /// the request's snapshots holds at the same path, parents first, + /// but the content the caller may not read. A part that stops short + /// is followed by asking again, skipping the objects it holds. fn fetch_snapshot(request: &FetchSnapshot) -> Part = POST "snapshots/fetch", json(request) => part; /// Puts a batch of objects: snapshots, revisions and statements, @@ -110,16 +112,38 @@ endpoints! { /// The events after a cursor, waiting up to the query's `wait` for /// some: pass the page's `next` as the next query's cursor. fn events(query: &EventsQuery) -> Page = GET "events", query(query) => json; + /// Who the server thinks the caller is, and what they may do. + fn whoami() -> Rights = GET "whoami" => json; + /// Issues a token acting for the caller, with a narrower scope. + fn issue_token(request: &TokenRequest) -> IssuedToken = + POST "tokens", json(request) => json; + /// Lists a directory of a target's tip, or of a revision's or an + /// acceptance's snapshot. + fn tree(query: &BrowseQuery) -> Listing = GET "tree", query(query) => json; + /// Reads a file of a snapshot, as `tree` finds it. + fn file(query: &BrowseQuery) -> Vec = GET "file", query(query) => bytes; } } /// A connection to an origin server. Cloning it is cheap, and clones share /// their connections. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct Client { http: reqwest::Client, /// The API's base URL, ending with a `/`. api: String, + /// The bearer token every request carries, if any. + token: Option, +} + +impl std::fmt::Debug for Client { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never the token. + f.debug_struct("Client") + .field("api", &self.api) + .field("authenticated", &self.token.is_some()) + .finish_non_exhaustive() + } } impl Client { @@ -129,6 +153,18 @@ impl Client { Self { http: reqwest::Client::new(), api: format!("{}/api/v1/", url.trim_end_matches('/')), + token: None, + } + } + + /// The client, authenticating every request with `token`: a static + /// one from the server's configuration, or one it issued. Without one, + /// the server treats requests as anonymous, which only read. + #[must_use] + pub fn with_token(self, token: impl Into) -> Self { + Self { + token: Some(token.into()), + ..self } } @@ -143,7 +179,11 @@ impl Client { /// Starts a request to `path`, relative to the API's base URL. fn request(&self, method: Method, path: &str) -> RequestBuilder { - self.http.request(method, format!("{}{path}", self.api)) + let request = self.http.request(method, format!("{}{path}", self.api)); + match &self.token { + Some(token) => request.bearer_auth(token), + None => request, + } } } @@ -194,6 +234,10 @@ impl Response { let pack = Pack::decode(&self.0.bytes().await?)?; Ok(Part { pack, more }) } + + async fn bytes(self) -> Result, Error> { + Ok(self.0.bytes().await?.to_vec()) + } } /// A failed request. diff --git a/crates/origin-shared/src/access.rs b/crates/origin-shared/src/access.rs new file mode 100644 index 00000000..6adcd695 --- /dev/null +++ b/crates/origin-shared/src/access.rs @@ -0,0 +1,758 @@ +//! What a caller may do: the capabilities a policy grants and tokens narrow, +//! the [`Rights`] they resolve to, and the scoped [`Token`]s a server signs. +//! +//! | Capability | Written | Allows | +//! |---|---|---| +//! | read | `read src` | reading the content under a path | +//! | write | `write src` | proposing revisions that change a path | +//! | annotate | `annotate ci.*` | issuing statements of matching kinds | +//! | land | `land main`, `land *` | submitting to a target's queue | +//! | admin | `admin` | landing on `policy` | +//! +//! Writing a path implies reading it. `land *` covers every target but +//! `policy`: changing the rules always takes `admin`. + +use std::collections::BTreeMap; +use std::fmt; +use std::str::FromStr; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::id::hex; +use crate::object::{DecodeError, Field, Reader, Signature, fields}; +use crate::policy::KindPattern; +use crate::{ + InvalidName, Kind, NodeKey, NodePublicKey, Principal, PrincipalName, RepoPath, TargetName, + TreeEntry, +}; + +/// What a caller may do with a path. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ToSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum Level { + /// Nothing: the content is withheld. + None, + /// Read the content. + Read, + /// Read it, and propose revisions that change it. + Write, +} + +impl FromStr for Level { + type Err = String; + + fn from_str(level: &str) -> Result { + match level { + "none" => Ok(Self::None), + "read" => Ok(Self::Read), + "write" => Ok(Self::Write), + _ => Err(format!("`{level}` is not `none`, `read` or `write`")), + } + } +} + +impl fmt::Display for Level { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.pad(match self { + Self::None => "none", + Self::Read => "read", + Self::Write => "write", + }) + } +} + +/// What a caller may do with every path: the level of the most specific +/// path it lists at or above it. The root is always listed. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde( + try_from = "BTreeMap", + into = "BTreeMap" +)] +#[schema(value_type = BTreeMap)] +pub struct PathAccess(BTreeMap); + +impl PathAccess { + /// The same level everywhere. + #[must_use] + pub fn uniform(level: Level) -> Self { + Self([(RepoPath::root(), level)].into()) + } + + /// `default` everywhere, but under the paths of `rules`, where the most + /// specific one decides. + #[must_use] + pub fn new(default: Level, rules: impl IntoIterator) -> Self { + let mut access = Self::uniform(default); + access.0.extend(rules); + access + } + + /// The level at `path`. + #[must_use] + pub fn level(&self, path: &RepoPath) -> Level { + self.0 + .iter() + .filter(|(rule, _)| path.starts_with(rule)) + .max_by_key(|(rule, _)| rule.as_str().len()) + .map_or(Level::None, |(_, &level)| level) + } + + /// Whether the caller reads `path`. + #[must_use] + pub fn reads(&self, path: &RepoPath) -> bool { + self.level(path) >= Level::Read + } + + /// Whether the caller writes `path`. + #[must_use] + pub fn writes(&self, path: &RepoPath) -> bool { + self.level(path) >= Level::Write + } + + /// The level of everything at and under `path`, if it's the same + /// everywhere there: no rule is under it. + #[must_use] + pub fn settled(&self, path: &RepoPath) -> Option { + let below = self + .0 + .keys() + .any(|rule| rule != path && rule.starts_with(path)); + (!below).then(|| self.level(path)) + } + + /// Whether the level is at least `level` at `path` and everywhere under + /// it. + #[must_use] + pub fn everywhere_under(&self, path: &RepoPath, level: Level) -> bool { + self.level(path) >= level + && self + .0 + .iter() + .all(|(rule, &at)| !rule.starts_with(path) || at >= level) + } + + /// Whether the caller reads `path` or something under it: a directory + /// on the way to what they read. + #[must_use] + pub fn reads_under(&self, path: &RepoPath) -> bool { + self.reads(path) + || self + .0 + .iter() + .any(|(rule, &level)| level >= Level::Read && rule.starts_with(path)) + } + + /// Whether the caller reads the entry at `path`: a file they read, or a + /// directory they read something under. + #[must_use] + pub fn reads_entry(&self, path: &RepoPath, entry: &TreeEntry) -> bool { + if entry.kind.is_tree() { + self.reads_under(path) + } else { + self.reads(path) + } + } + + /// Whether the caller reads everything. + #[must_use] + pub fn reads_all(&self) -> bool { + self.0.values().all(|&level| level >= Level::Read) + } + + /// Whether the caller writes everything. + #[must_use] + pub fn writes_all(&self) -> bool { + self.0.values().all(|&level| level >= Level::Write) + } + + /// What both `self` and `other` allow, path by path. + #[must_use] + pub fn min(&self, other: &Self) -> Self { + // Between a path of either and the next one under it, both are + // constant, so the minimum is too. + let paths = self.0.keys().chain(other.0.keys()); + Self( + paths + .map(|path| (path.clone(), self.level(path).min(other.level(path)))) + .collect(), + ) + } + + /// The listed paths and their levels, sorted by path. + pub fn rules(&self) -> impl Iterator { + self.0.iter().map(|(path, &level)| (path, level)) + } +} + +impl TryFrom> for PathAccess { + type Error = &'static str; + + fn try_from(rules: BTreeMap) -> Result { + if rules.contains_key(&RepoPath::root()) { + Ok(Self(rules)) + } else { + Err("path access must list the root, `/`") + } + } +} + +impl From for BTreeMap { + fn from(access: PathAccess) -> Self { + access.0 + } +} + +/// Targets a `land` capability covers. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Targets { + /// `*`: every target but `policy`. + Any, + /// One target, never `policy`. + One(TargetName), +} + +impl Targets { + /// Whether it covers `target`. + #[must_use] + pub fn covers(&self, target: &TargetName) -> bool { + match self { + Self::Any => *target != TargetName::POLICY, + Self::One(one) => one == target, + } + } + + /// The targets both cover, if any. + fn and(&self, other: &Self) -> Option { + match (self, other) { + (Self::Any, narrower) | (narrower, Self::Any) => Some(narrower.clone()), + (Self::One(a), Self::One(b)) => (a == b).then(|| self.clone()), + } + } +} + +impl FromStr for Targets { + type Err = String; + + fn from_str(input: &str) -> Result { + match format!("land {input}").parse()? { + Capability::Land(targets) => Ok(targets), + _ => unreachable!("`land` parses as a land capability"), + } + } +} + +impl fmt::Display for Targets { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Any => f.write_str("*"), + Self::One(target) => target.fmt(f), + } + } +} + +/// One capability, written as the module's table shows. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Capability { + /// Reading the content under a path. + Read(RepoPath), + /// Proposing revisions that change a path, and reading it. + Write(RepoPath), + /// Issuing statements of matching kinds. + Annotate(KindPattern), + /// Submitting to the queues of targets. + Land(Targets), + /// Landing on `policy`. + Admin, +} + +impl FromStr for Capability { + type Err = String; + + fn from_str(input: &str) -> Result { + let (verb, argument) = input.split_once(' ').unwrap_or((input, "")); + let argument = argument.trim(); + let invalid = |reason: &dyn fmt::Display| format!("invalid capability `{input}`: {reason}"); + let path = || RepoPath::new(argument).map_err(|error| invalid(&error)); + match verb { + "admin" if argument.is_empty() => Ok(Self::Admin), + "read" => path().map(Self::Read), + "write" => path().map(Self::Write), + "annotate" => argument + .parse() + .map(Self::Annotate) + .map_err(|error: InvalidName| invalid(&error)), + "land" if argument == "*" => Ok(Self::Land(Targets::Any)), + "land" if argument == TargetName::POLICY.as_str() => { + Err(invalid(&"landing on `policy` is `admin`")) + } + "land" => argument + .parse() + .map(|target| Self::Land(Targets::One(target))) + .map_err(|error: InvalidName| invalid(&error)), + _ => Err(invalid( + &"expected `read `, `write `, `annotate `, \ + `land ` or `admin`", + )), + } + } +} + +impl fmt::Display for Capability { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Read(path) => write!(f, "read {path}"), + Self::Write(path) => write!(f, "write {path}"), + Self::Annotate(kinds) => write!(f, "annotate {kinds}"), + Self::Land(targets) => write!(f, "land {targets}"), + Self::Admin => f.write_str("admin"), + } + } +} + +/// Encoded as the text it displays as. +impl Field for Capability { + fn write(&self, out: &mut Vec) { + Reader::write_str(&self.to_string(), out); + } + + fn read(reader: &mut Reader<'_>) -> Result { + reader + .prefixed_str()? + .parse() + .map_err(|_| DecodeError::NonCanonical("capability")) + } +} + +/// What a caller may do, resolved from the policy in force and narrowed by +/// their token's scope. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct Rights { + /// Who the caller is: `None` for an anonymous caller, or the node's own + /// user. + pub principal: Option, + /// The policy's groups the caller is a member of. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub groups: Vec, + /// The token's scope, if the caller came with a scoped token. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option>, + /// What they may do with each path. + pub paths: PathAccess, + /// The kinds of statements they may issue. + pub annotate: Vec, + /// The targets they may land on, but `policy`. + pub land: Vec, + /// Whether they may land on `policy`. + pub admin: bool, +} + +impl Rights { + /// Every right: the node's own user's. + #[must_use] + pub fn all() -> Self { + Self { + principal: None, + groups: Vec::new(), + scope: None, + paths: PathAccess::uniform(Level::Write), + annotate: vec![KindPattern::Any], + land: vec![Targets::Any], + admin: true, + } + } + + /// Whether they may issue statements of `kind`. + #[must_use] + pub fn may_annotate(&self, kind: &Kind) -> bool { + self.annotate.iter().any(|pattern| pattern.matches(kind)) + } + + /// Whether they may land on `target`: `admin` for `policy`. + #[must_use] + pub fn may_land(&self, target: &TargetName) -> bool { + if *target == TargetName::POLICY { + return self.admin; + } + self.land.iter().any(|targets| targets.covers(target)) + } + + /// These rights, narrowed to what `scope` allows. Nothing a scope + /// holds adds a right. + #[must_use] + pub fn narrowed(self, scope: Vec) -> Self { + let mut paths = Vec::new(); + let (mut annotate, mut land, mut admin) = (Vec::new(), Vec::new(), false); + for capability in &scope { + match capability { + Capability::Read(path) => paths.push((path.clone(), Level::Read)), + Capability::Write(path) => paths.push((path.clone(), Level::Write)), + Capability::Annotate(kinds) => annotate.extend( + self.annotate + .iter() + .filter_map(|granted| granted.and(kinds)), + ), + Capability::Land(targets) => { + land.extend(self.land.iter().filter_map(|granted| granted.and(targets))); + } + Capability::Admin => admin = self.admin, + } + } + Self { + paths: self.paths.min(&Self::scope_paths(&paths)), + annotate, + land, + admin, + scope: Some(scope), + ..self + } + } + + /// The path access a scope's `read` and `write` capabilities give on + /// their own: under each path, the highest level a capability covering + /// it grants. + fn scope_paths(granted: &[(RepoPath, Level)]) -> PathAccess { + let level = |path: &RepoPath| { + granted + .iter() + .filter(|(at, _)| path.starts_with(at)) + .map(|&(_, level)| level) + .max() + .unwrap_or(Level::None) + }; + let rules: Vec<_> = granted + .iter() + .map(|(path, _)| (path.clone(), level(path))) + .collect(); + PathAccess::new(level(&RepoPath::root()), rules) + } + + /// Whether every capability of `scope` is one of these rights: a token + /// these rights issue may only narrow them. + #[must_use] + pub fn cover(&self, scope: &[Capability]) -> bool { + scope.iter().all(|capability| match capability { + Capability::Read(path) => self.paths.everywhere_under(path, Level::Read), + Capability::Write(path) => self.paths.everywhere_under(path, Level::Write), + Capability::Annotate(kinds) => self + .annotate + .iter() + .any(|granted| granted.and(kinds).as_ref() == Some(kinds)), + Capability::Land(targets) => self + .land + .iter() + .any(|granted| granted.and(targets).as_ref() == Some(targets)), + Capability::Admin => self.admin, + }) + } +} + +impl KindPattern { + /// The kinds both patterns match, if any: patterns are nested or + /// disjoint. + fn and(&self, other: &Self) -> Option { + let covers = |wide: &Self, narrow: &Self| match (wide, narrow) { + (Self::Any, _) => true, + (Self::Prefix(_), Self::Any) | (Self::Exact(_), Self::Any | Self::Prefix(_)) => false, + (Self::Prefix(prefix), Self::Prefix(kind) | Self::Exact(kind)) => { + kind == prefix || wide.matches(kind) + } + (Self::Exact(a), Self::Exact(b)) => a == b, + }; + if covers(self, other) { + Some(other.clone()) + } else if covers(other, self) { + Some(self.clone()) + } else { + None + } + } +} + +/// A short-lived token a server signs with its node key, so it verifies +/// without being stored: who it acts for, what it may do, and until when. +/// It acts with the rights its principal has under the policy in force, +/// narrowed to its scope. +/// +/// Written `origin1..`, both in hex: the claims are the +/// fields in record encoding, and the signature covers them after a domain +/// prefix, so no other signed bytes can pass for a token. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Token { + /// Who it acts for. + pub principal: Principal, + /// What it may do. + pub scope: Vec, + /// When it expires, in seconds since the Unix epoch. + pub expires: u64, +} + +fields!(Token { + principal, + scope, + expires, +}); + +impl Token { + /// The prefix of every token. + const PREFIX: &str = "origin1."; + + /// What the signature covers before the claims. + const DOMAIN: &[u8] = b"origin token 1\0"; + + /// A token for `principal` with `scope`, valid for `ttl` from now. + #[must_use] + pub fn new(principal: Principal, scope: Vec, ttl: Duration) -> Self { + Self { + principal, + scope, + expires: Self::now().saturating_add(ttl.as_secs()), + } + } + + fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_secs()) + } + + fn signed_bytes(claims: &[u8]) -> Vec { + [Self::DOMAIN, claims].concat() + } + + /// Signs the token with `key`, as its text. + #[must_use] + pub fn sign(&self, key: &NodeKey) -> String { + let mut claims = Vec::new(); + self.write(&mut claims); + let signature = key.sign(&Self::signed_bytes(&claims)); + format!( + "{}{}.{}", + Self::PREFIX, + hex::string(&claims), + hex::string(&signature.to_bytes()) + ) + } + + /// Whether `text` looks like a signed token rather than a static one. + #[must_use] + pub fn is_signed(text: &str) -> bool { + text.starts_with(Self::PREFIX) + } + + /// The token `text` holds, if `key` signed it and it hasn't expired. + /// + /// # Errors + /// + /// Fails if it's malformed, `key` didn't sign it, or it expired. + pub fn verify(text: &str, key: &NodePublicKey) -> Result { + let (claims, signature) = text + .strip_prefix(Self::PREFIX) + .and_then(|rest| rest.split_once('.')) + .ok_or(InvalidToken::Malformed)?; + let claims = hex::bytes(claims).ok_or(InvalidToken::Malformed)?; + let signature = hex::decode::<{ ed25519_dalek::SIGNATURE_LENGTH }>(signature) + .map(|bytes| Signature::from_bytes(&bytes)) + .ok_or(InvalidToken::Malformed)?; + if !key.verifies(&Self::signed_bytes(&claims), &signature) { + return Err(InvalidToken::Signature); + } + let mut reader = Reader::new(&claims); + let token = Self::read(&mut reader)?; + reader.finish()?; + if token.expires <= Self::now() { + return Err(InvalidToken::Expired); + } + Ok(token) + } +} + +/// A token that doesn't verify. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum InvalidToken { + /// It isn't a token. + #[error("the token is malformed")] + Malformed, + /// Its signature verifies, but its claims don't decode: a server of + /// another version signed it. + #[error("the token's claims are malformed")] + Claims(#[from] DecodeError), + /// This server didn't sign it. + #[error("the token wasn't signed by this server")] + Signature, + /// It expired. + #[error("the token expired")] + Expired, + /// No one has this static token. + #[error("the token is unknown")] + Unknown, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn path(path: &str) -> RepoPath { + RepoPath::new(path).unwrap() + } + + fn capabilities(scope: &[&str]) -> Vec { + scope + .iter() + .map(|capability| capability.parse().unwrap()) + .collect() + } + + /// Everyone reads, `secrets` is withheld, but `secrets/public`. + fn access() -> PathAccess { + PathAccess::new( + Level::Read, + [ + (path("secrets"), Level::None), + (path("secrets/public"), Level::Read), + (path("src"), Level::Write), + ], + ) + } + + #[test] + fn the_most_specific_path_decides() { + let access = access(); + assert_eq!(access.level(&path("README.md")), Level::Read); + assert_eq!(access.level(&path("src/lib.rs")), Level::Write); + assert_eq!(access.level(&path("secrets/key")), Level::None); + assert_eq!(access.level(&path("secrets/public/a")), Level::Read); + assert_eq!(access.level(&path("srcx")), Level::Read, "whole components"); + assert!(access.reads_under(&path("secrets"))); + assert!(!access.reads_under(&path("secrets/other"))); + assert_eq!(access.settled(&path("src")), Some(Level::Write)); + assert_eq!(access.settled(&path("secrets")), None); + assert!(!access.reads_all() && !access.writes_all()); + } + + #[test] + fn minimums_hold_path_by_path() { + let scope = PathAccess::new(Level::None, [(path("src/net"), Level::Write)]); + let both = access().min(&scope); + assert_eq!(both.level(&path("src/net/a.rs")), Level::Write); + assert_eq!(both.level(&path("src/lib.rs")), Level::None); + assert_eq!(both.level(&path("secrets/public/a")), Level::None); + } + + #[test] + fn scopes_only_narrow() { + let rights = Rights { + principal: Some("user:ada".parse().unwrap()), + groups: Vec::new(), + scope: None, + paths: access(), + annotate: vec!["ci.*".parse().unwrap()], + land: vec![Targets::One(TargetName::MAIN)], + admin: false, + }; + let narrowed = rights.clone().narrowed(capabilities(&[ + "write /", + "annotate *", + "annotate ci.test", + "land *", + "admin", + ])); + assert_eq!( + narrowed.paths, rights.paths, + "writing everything adds nothing" + ); + assert!(narrowed.may_annotate(&Kind::new("ci.test").unwrap())); + assert!( + narrowed.may_annotate(&Kind::new("ci.lint").unwrap()), + "`*` and `ci.*`" + ); + assert!(!narrowed.may_annotate(&Kind::new("review").unwrap())); + assert!(narrowed.may_land(&TargetName::MAIN)); + assert!(!narrowed.may_land(&TargetName::new("release").unwrap())); + assert!(!narrowed.admin); + + let agent = rights.clone().narrowed(capabilities(&["write src"])); + assert!(agent.paths.writes(&path("src/lib.rs"))); + assert!(!agent.paths.reads(&path("README.md"))); + assert!(agent.annotate.is_empty() && agent.land.is_empty()); + + assert!(rights.cover(&capabilities(&[ + "write src", + "read docs", + "annotate ci.test", + "land main" + ]))); + for wider in [ + "write /", + "read secrets", + "annotate review", + "land *", + "admin", + ] { + assert!(!rights.cover(&capabilities(&[wider])), "{wider}"); + } + } + + #[test] + fn capabilities_parse_as_they_display() { + for valid in [ + "read /", + "write src/net", + "annotate ci.*", + "land main", + "land *", + "admin", + ] { + assert_eq!(valid.parse::().unwrap().to_string(), valid); + } + for invalid in [ + "land policy", + "admin x", + "delete src", + "annotate CI", + "read a//b", + ] { + assert!(invalid.parse::().is_err(), "{invalid}"); + } + } + + #[test] + fn tokens_verify_only_against_their_signer_until_they_expire() { + let key = NodeKey::from_bytes(&[3; 32]); + let token = Token::new( + "user:ada".parse().unwrap(), + capabilities(&["write src", "land main"]), + Duration::from_mins(5), + ); + let text = token.sign(&key); + assert!(Token::is_signed(&text)); + assert_eq!(Token::verify(&text, &key.public()), Ok(token.clone())); + let other = NodeKey::from_bytes(&[4; 32]).public(); + assert_eq!(Token::verify(&text, &other), Err(InvalidToken::Signature)); + + // Editing the claims, here to act for the node of the same name, + // breaks the signature. + let forged = text.replacen("origin1.01", "origin1.02", 1); + assert_ne!(forged, text); + assert_eq!( + Token::verify(&forged, &key.public()), + Err(InvalidToken::Signature) + ); + assert_eq!( + Token::verify("origin1.zz", &key.public()), + Err(InvalidToken::Malformed) + ); + + let expired = Token { + expires: Token::now() - 1, + ..token + }; + assert_eq!( + Token::verify(&expired.sign(&key), &key.public()), + Err(InvalidToken::Expired) + ); + } +} diff --git a/crates/origin-shared/src/api.rs b/crates/origin-shared/src/api.rs index 85cb2e11..475c0f75 100644 --- a/crates/origin-shared/src/api.rs +++ b/crates/origin-shared/src/api.rs @@ -7,12 +7,14 @@ use std::str::FromStr; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; +use crate::access::Capability; use crate::landing::{Landing, Proposal}; use crate::policy::{KindPattern, Requirement}; use crate::text::Period; use crate::{ - Acceptance, AcceptanceId, ChangeId, Head, ObjectId, PrincipalName, RecordId, RepoPath, - Revision, RevisionId, Statement, StatementId, TargetName, TreeId, + Acceptance, AcceptanceId, ChangeId, EntryKind, EntryName, Head, ObjectId, Principal, + PrincipalName, RecordId, RepoPath, Revision, RevisionId, Statement, StatementId, TargetName, + TreeId, }; /// The name of a repository on a server: ASCII letters, digits, `.`, `_` @@ -101,24 +103,30 @@ pub struct RepoSummary { pub const MORE: &str = "origin-more"; /// Asks for records by id: revisions, statements and acceptances. Each -/// comes with the blob it carries, if the repository has it: a revision's -/// description, a statement's payload. Snapshots are fetched with -/// [`FetchSnapshot`] instead. +/// comes with the attachment it carries, if the repository has it: a +/// revision's description, a statement's payload. Snapshots are fetched +/// with [`FetchSnapshot`] instead. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub struct FetchRecords { - /// The records, returned in this order, each followed by its blob. A - /// response that stops short, with the [`MORE`] header, holds those up - /// to the last record it has: ask again for those after it. + /// The records, returned in this order, each followed by its + /// attachment. Those the caller may not read are left out. A response + /// that stops short, with the [`MORE`] header, holds those up to the + /// last record it has: ask again for those after it. pub ids: Vec, } /// Asks for the objects of a snapshot that the caller lacks: its trees and /// blobs, walked from its root. This is the only way blobs and trees leave -/// a repository, so what each caller may read can be checked by path. +/// a repository, so what each caller may read is checked by path: the +/// content of paths they may not read is withheld, though it keeps its +/// place in the trees. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub struct FetchSnapshot { - /// The snapshot. - pub snapshot: TreeId, + /// The record whose snapshot it is: a revision or an acceptance. Only a + /// record's snapshot is walked, never a tree within one, whose paths + /// would be read as others. + #[schema(value_type = String)] + pub of: RecordId, /// Snapshots the caller holds whole. Whatever one of them holds at the /// same path is skipped, so only what changed since comes. Those the /// repository doesn't have are ignored. @@ -410,6 +418,104 @@ where } } +/// Asks a server for a token with a narrower scope, for an agent or a CI +/// runner. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct TokenRequest { + /// What it may do: a part of what the caller may do. + pub scope: Vec, + /// How long it lasts, such as `1h`. + #[schema(value_type = String)] + pub ttl: Period, +} + +/// A token a server issued. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct IssuedToken { + /// The token, to send as `Authorization: Bearer `. + pub token: String, + /// Who it acts for. + pub principal: Principal, + /// When it expires, in seconds since the Unix epoch. + pub expires: u64, +} + +/// A snapshot to browse: a target's tip, `main`, or a record's, +/// `revision:` or `acceptance:`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum At { + /// The tip of a target. + Target(TargetName), + /// The snapshot of a revision or an acceptance. + Record(RecordId), +} + +impl FromStr for At { + type Err = String; + + fn from_str(input: &str) -> Result { + if input.contains(':') { + input + .parse() + .map(Self::Record) + .map_err(|error: crate::ParseIdError| error.to_string()) + } else { + input + .parse() + .map(Self::Target) + .map_err(|error: crate::InvalidName| error.to_string()) + } + } +} + +impl fmt::Display for At { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Target(target) => target.fmt(f), + Self::Record(record) => record.fmt(f), + } + } +} + +/// Asks for a directory or a file of a snapshot. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] +pub struct BrowseQuery { + /// The snapshot: a target, such as `main`, or `revision:` or + /// `acceptance:`. + #[param(value_type = String)] + pub at: At, + /// The directory or file: the root by default. + #[serde(default)] + #[param(value_type = Option)] + pub path: RepoPath, +} + +/// A directory of a snapshot. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct Listing { + /// The snapshot's root tree. + pub snapshot: TreeId, + /// The directory. + pub path: RepoPath, + /// Its entries, sorted by name. + pub entries: Vec, +} + +/// An entry of a [`Listing`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct Listed { + /// Its name. + pub name: EntryName, + /// A file, an executable, a symlink or a directory. + pub kind: EntryKind, + /// Its object. + pub id: ObjectId, + /// Whether its content is withheld from the caller: it keeps its + /// place, but can't be read. + pub withheld: bool, +} + /// What went wrong with a request. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub struct ApiError { @@ -441,6 +547,10 @@ pub enum ErrorCode { Conflict, /// The event cursor is older than anything the server still keeps. Expired, + /// The credentials are invalid or expired. + Unauthorized, + /// The caller may not do this. + Forbidden, /// The server failed. Internal, } diff --git a/crates/origin-shared/src/id.rs b/crates/origin-shared/src/id.rs index 6cd50313..36b2954c 100644 --- a/crates/origin-shared/src/id.rs +++ b/crates/origin-shared/src/id.rs @@ -123,8 +123,11 @@ ids! { } object_ids! { - /// Identifies a blob: file contents, a description or a payload. + /// Identifies a blob: a file's content. BlobId; + /// Identifies an attachment: a revision's description or a statement's + /// payload. + AttachmentId; /// Identifies a tree, the root of which is a snapshot. TreeId => Tree; /// Identifies a revision. @@ -215,6 +218,19 @@ pub(crate) mod hex { } } + /// Parses an even number of hex digits, in either case. + pub fn bytes(input: &str) -> Option> { + let digit = |c: u8| char::from(c).to_digit(16); + let (pairs, rest) = input.as_bytes().as_chunks::<2>(); + if !rest.is_empty() { + return None; + } + pairs + .iter() + .map(|&[high, low]| u8::try_from(digit(high)? << 4 | digit(low)?).ok()) + .collect() + } + /// Parses exactly `2 * N` hex digits, in either case. pub fn decode(input: &str) -> Option<[u8; N]> { let input = input.as_bytes(); diff --git a/crates/origin-shared/src/lib.rs b/crates/origin-shared/src/lib.rs index 23a5239f..d4bbf22f 100644 --- a/crates/origin-shared/src/lib.rs +++ b/crates/origin-shared/src/lib.rs @@ -1,6 +1,7 @@ //! Types shared by every origin component: the object model, the wire //! formats, and the API contracts. +pub mod access; pub mod api; pub mod event; mod id; @@ -15,8 +16,8 @@ mod target; mod text; pub use id::{ - AcceptanceId, BlobId, ChangeId, Digest, ObjectId, ParseIdError, RevisionId, StatementId, - TargetId, TreeId, TypedId, + AcceptanceId, AttachmentId, BlobId, ChangeId, Digest, ObjectId, ParseIdError, RevisionId, + StatementId, TargetId, TreeId, TypedId, }; pub use name::InvalidName; pub use object::{ diff --git a/crates/origin-shared/src/object/acceptance.rs b/crates/origin-shared/src/object/acceptance.rs index b826492f..2dde8c4f 100644 --- a/crates/origin-shared/src/object/acceptance.rs +++ b/crates/origin-shared/src/object/acceptance.rs @@ -192,6 +192,21 @@ impl NodeKey { } /// Never prints the secret key. +impl NodeKey { + /// Signs `bytes`, which must start with a domain of their own, so they + /// never pass for anything else this key signs. + pub(crate) fn sign(&self, bytes: &[u8]) -> Signature { + self.0.sign(bytes) + } +} + +impl NodePublicKey { + /// Whether `signature` is this key's over `bytes`. + pub(crate) fn verifies(&self, bytes: &[u8], signature: &Signature) -> bool { + self.0.verify_strict(bytes, signature).is_ok() + } +} + impl fmt::Debug for NodeKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_tuple("NodeKey").field(&self.public()).finish() diff --git a/crates/origin-shared/src/object/mod.rs b/crates/origin-shared/src/object/mod.rs index 4567e347..10310c42 100644 --- a/crates/origin-shared/src/object/mod.rs +++ b/crates/origin-shared/src/object/mod.rs @@ -6,7 +6,10 @@ //! its own hash domain: objects of different kinds never share an id, and a //! record of one kind can never be mistaken for another. //! -//! - Blob: the content, as is. +//! - Blob: a file's content, as is. +//! - Attachment: a revision's description or a statement's payload, as is. +//! It is a kind of its own, so no record can carry a file's content as +//! its attachment: whoever may read a record may not read every file. //! - Tree: its entries, see [`Tree`]. //! - Revision, statement and acceptance: their fields in declaration order. //! Integers are little endian, ids their raw bytes, names and paths a @@ -40,7 +43,7 @@ mod tree; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[repr(u8)] pub enum ObjectKind { - /// File contents, a description or a payload. + /// A file's content. Blob = 1, /// A directory listing. Tree = 2, @@ -50,6 +53,8 @@ pub enum ObjectKind { Statement = 4, /// One landing on a target. Acceptance = 5, + /// A revision's description or a statement's payload. + Attachment = 6, } impl ObjectKind { @@ -60,6 +65,7 @@ impl ObjectKind { 3 => Ok(Self::Revision), 4 => Ok(Self::Statement), 5 => Ok(Self::Acceptance), + 6 => Ok(Self::Attachment), _ => Err(DecodeError::UnknownObjectKind(byte)), } } @@ -73,11 +79,12 @@ impl fmt::Display for ObjectKind { Self::Revision => "revision", Self::Statement => "statement", Self::Acceptance => "acceptance", + Self::Attachment => "attachment", }) } } -/// An object with structure: every kind but blobs. +/// An object with structure: every kind but blobs and attachments. pub trait Record: Field { /// The kind every encoding of this record starts with. const KIND: ObjectKind; @@ -96,8 +103,10 @@ macro_rules! object { /// A decoded object. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Object { - /// File contents, a description or a payload. + /// A file's content. Blob(Vec), + /// A revision's description or a statement's payload. + Attachment(Vec), $(#[doc = concat!("A ", stringify!($record), ".")] $record($record),)* } @@ -107,6 +116,7 @@ macro_rules! object { pub fn kind(&self) -> ObjectKind { match self { Self::Blob(_) => ObjectKind::Blob, + Self::Attachment(_) => ObjectKind::Attachment, $(Self::$record(_) => $record::KIND,)* } } @@ -116,6 +126,7 @@ macro_rules! object { pub fn encode(&self) -> EncodedObject { match self { Self::Blob(content) => EncodedObject::blob(content), + Self::Attachment(content) => EncodedObject::attachment(content), $(Self::$record(record) => record.encode(),)* } } @@ -130,6 +141,7 @@ macro_rules! object { pub fn decode(&self) -> Result { match self.kind()? { ObjectKind::Blob => Ok(Object::Blob(self.bytes[1..].to_vec())), + ObjectKind::Attachment => Ok(Object::Attachment(self.bytes[1..].to_vec())), $(ObjectKind::$record => self.decode_as().map(Object::$record),)* } } @@ -169,9 +181,24 @@ impl EncodedObject { /// # Errors /// /// Fails if reading fails. - pub fn read_blob(mut reader: impl Read, len_hint: usize) -> io::Result { + pub fn read_blob(reader: impl Read, len_hint: usize) -> io::Result { + Self::read_raw(ObjectKind::Blob, reader, len_hint) + } + + /// Encodes an attachment, reading its content to the end, like + /// [`EncodedObject::read_blob`]. + /// + /// # Errors + /// + /// Fails if reading fails. + pub fn read_attachment(reader: impl Read, len_hint: usize) -> io::Result { + Self::read_raw(ObjectKind::Attachment, reader, len_hint) + } + + /// Encodes content read to the end as is, as an object of `kind`. + fn read_raw(kind: ObjectKind, mut reader: impl Read, len_hint: usize) -> io::Result { let mut bytes = Vec::with_capacity(len_hint + 1); - bytes.push(ObjectKind::Blob as u8); + bytes.push(kind as u8); reader.read_to_end(&mut bytes)?; Ok(Self::hash(bytes)) } @@ -179,8 +206,19 @@ impl EncodedObject { /// Encodes a blob from its content. #[must_use] pub fn blob(content: &[u8]) -> Self { + Self::raw(ObjectKind::Blob, content) + } + + /// Encodes an attachment from its content. + #[must_use] + pub fn attachment(content: &[u8]) -> Self { + Self::raw(ObjectKind::Attachment, content) + } + + /// Encodes content as is, as an object of `kind`. + fn raw(kind: ObjectKind, content: &[u8]) -> Self { let mut bytes = Vec::with_capacity(content.len() + 1); - bytes.push(ObjectKind::Blob as u8); + bytes.push(kind as u8); bytes.extend_from_slice(content); Self::hash(bytes) } @@ -230,7 +268,22 @@ impl EncodedObject { /// /// Fails if the object is not a blob. pub fn blob_content(&self) -> Result<&[u8], DecodeError> { - self.expect_kind(ObjectKind::Blob)?; + self.content(ObjectKind::Blob) + } + + /// An attachment's content, without copying it. + /// + /// # Errors + /// + /// Fails if the object is not an attachment: a blob, a file's content, + /// is never read as one. + pub fn attachment_content(&self) -> Result<&[u8], DecodeError> { + self.content(ObjectKind::Attachment) + } + + /// The content of an object of `kind` stored as is. + fn content(&self, kind: ObjectKind) -> Result<&[u8], DecodeError> { + self.expect_kind(kind)?; Ok(&self.bytes[1..]) } diff --git a/crates/origin-shared/src/object/revision.rs b/crates/origin-shared/src/object/revision.rs index 05e2ebf7..436e3bdd 100644 --- a/crates/origin-shared/src/object/revision.rs +++ b/crates/origin-shared/src/object/revision.rs @@ -1,5 +1,5 @@ use super::{ObjectKind, Record, fields, tagged}; -use crate::{AcceptanceId, BlobId, ChangeId, Principal, RevisionId, TreeId}; +use crate::{AcceptanceId, AttachmentId, ChangeId, Principal, RecordId, RevisionId, TreeId}; /// One immutable version of a change. /// @@ -21,7 +21,7 @@ pub struct Revision { /// The root tree of the files. pub snapshot: TreeId, /// The intent, written for humans and agents. - pub description: BlobId, + pub description: AttachmentId, /// Who wrote the revision. pub author: Principal, } @@ -56,3 +56,12 @@ tagged!(Base { Acceptance = 1, Revision = 2, }); + +impl From for RecordId { + fn from(base: Base) -> Self { + match base { + Base::Acceptance(id) => Self::Acceptance(id), + Base::Revision(id) => Self::Revision(id), + } + } +} diff --git a/crates/origin-shared/src/object/statement.rs b/crates/origin-shared/src/object/statement.rs index 86eb0cd2..8b2b352f 100644 --- a/crates/origin-shared/src/object/statement.rs +++ b/crates/origin-shared/src/object/statement.rs @@ -6,7 +6,7 @@ use std::str::FromStr; use super::{DecodeError, Field, ObjectKind, Reader, Record, fields, tagged}; use crate::name::names; use crate::{ - AcceptanceId, BlobId, Digest, InvalidName, ObjectId, ParseIdError, Principal, RepoPath, + AcceptanceId, AttachmentId, Digest, InvalidName, ObjectId, ParseIdError, Principal, RepoPath, RevisionId, StatementId, TreeEntry, TreeId, }; @@ -30,7 +30,7 @@ pub struct Statement { /// with the same inputs. pub inputs: Option, /// Free-form details, defined by whoever owns the kind. - pub payload: Option, + pub payload: Option, /// A statement from the same issuer that this one replaces. pub supersedes: Option, /// The statement this one promotes: its kind, verdict, inputs and diff --git a/crates/origin-shared/src/object/tree.rs b/crates/origin-shared/src/object/tree.rs index c33226fd..043ec2d2 100644 --- a/crates/origin-shared/src/object/tree.rs +++ b/crates/origin-shared/src/object/tree.rs @@ -96,6 +96,20 @@ impl fmt::Display for EntryKind { } } +impl std::str::FromStr for EntryKind { + type Err = String; + + fn from_str(kind: &str) -> Result { + match kind { + "file" => Ok(Self::File), + "executable" => Ok(Self::Executable), + "symlink" => Ok(Self::Symlink), + "directory" => Ok(Self::Tree), + _ => Err(format!("invalid entry kind `{kind}`")), + } + } +} + /// One entry of a tree. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct TreeEntry { diff --git a/crates/origin-shared/src/text.rs b/crates/origin-shared/src/text.rs index bb8f7ddb..b8490596 100644 --- a/crates/origin-shared/src/text.rs +++ b/crates/origin-shared/src/text.rs @@ -9,11 +9,13 @@ use std::time::Duration; use utoipa::openapi::RefOr; use utoipa::openapi::schema::{ObjectBuilder, Schema, Type}; +use crate::access::{Capability, Targets}; +use crate::api::At; use crate::policy::{KindPattern, Principals}; use crate::{ - AcceptanceId, BlobId, ChangeId, Digest, EntryName, Kind, NodePublicKey, ObjectId, Principal, - PrincipalName, RecordId, RepoPath, RevisionId, StatementId, TargetId, TargetName, TreeId, - Verdict, + AcceptanceId, AttachmentId, BlobId, ChangeId, Digest, EntryKind, EntryName, Kind, + NodePublicKey, ObjectId, Principal, PrincipalName, RecordId, RepoPath, RevisionId, StatementId, + TargetId, TargetName, TreeId, Verdict, }; /// Serializes each type as the string it displays as, deserializes it by @@ -53,6 +55,7 @@ const ID: &str = "48fc721fbbc172e0925fa27af1671de225ba927134802998b10a1568a18865 as_text! { ObjectId => ID; BlobId => ID; + AttachmentId => ID; TreeId => ID; RevisionId => ID; StatementId => ID; @@ -72,6 +75,10 @@ as_text! { Principals => "group:runners"; KindPattern => "ci.*"; Period => "30s"; + Capability => "write src"; + Targets => "main"; + At => "main"; + EntryKind => "file"; } /// A length of time, written as a whole number of seconds (`s`), minutes diff --git a/docs/openapi.json b/docs/openapi.json index 1e9db4e8..f75ec7d2 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -29,7 +29,7 @@ }, "/api/v1/repos": { "get": { - "summary": "Lists the repositories on the server.", + "summary": "Lists the repositories on the server that the caller sees.", "operationId": "list_repos", "responses": { "200": { @@ -44,6 +44,16 @@ } } } + }, + "401": { + "description": "The token is invalid or expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } } } } @@ -255,6 +265,83 @@ } } }, + "/api/v1/repos/{repo}/file": { + "get": { + "summary": "Reads a file of a snapshot, as `tree` finds it.", + "operationId": "file", + "parameters": [ + { + "name": "repo", + "in": "path", + "description": "The repository", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "at", + "in": "query", + "description": "The snapshot: a target, such as `main`, or `revision:` or\n`acceptance:`.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "description": "The directory or file: the root by default.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The file's content", + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/Binary" + } + } + } + }, + "400": { + "description": "The path is a directory, or the record has no snapshot", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "The caller may not read the file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "The repository, the target, the record or the path does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/repos/{repo}/land": { "post": { "summary": "Submits a change, or a target's tip, to a target's landing queue, run\nby this server.", @@ -791,6 +878,191 @@ } } } + }, + "/api/v1/repos/{repo}/tokens": { + "post": { + "summary": "Issues a token acting for the caller, with a scope narrowing their\nrights, signed by the server, which verifies it without storing it.", + "operationId": "issue_token", + "parameters": [ + { + "name": "repo", + "in": "path", + "description": "The repository whose policy the scope is checked against", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssuedToken" + } + } + } + }, + "403": { + "description": "The caller isn't signed in, or the scope reaches beyond their rights", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "The repository does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/repos/{repo}/tree": { + "get": { + "summary": "Lists a directory of a snapshot: a target's tip, or a revision's or an\nacceptance's. Withheld entries keep their place.", + "operationId": "tree", + "parameters": [ + { + "name": "repo", + "in": "path", + "description": "The repository", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "at", + "in": "query", + "description": "The snapshot: a target, such as `main`, or `revision:` or\n`acceptance:`.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "description": "The directory or file: the root by default.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The directory's entries", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Listing" + } + } + } + }, + "400": { + "description": "The path is a file, or the record has no snapshot", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "The caller reads nothing under the path", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "The repository, the target, the record or the path does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/repos/{repo}/whoami": { + "get": { + "summary": "Tells the caller who they are and what they may do in the repository.", + "operationId": "whoami", + "parameters": [ + { + "name": "repo", + "in": "path", + "description": "The repository", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The caller's rights", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Rights" + } + } + } + }, + "401": { + "description": "The token is invalid or expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "The repository does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } } }, "components": { @@ -935,6 +1207,12 @@ } } }, + "AttachmentId": { + "type": "string", + "examples": [ + "48fc721fbbc172e0925fa27af1671de225ba927134802998b10a1568a188652b" + ] + }, "Base": { "oneOf": [ { @@ -970,12 +1248,6 @@ "type": "string", "contentMediaType": "application/octet-stream" }, - "BlobId": { - "type": "string", - "examples": [ - "48fc721fbbc172e0925fa27af1671de225ba927134802998b10a1568a188652b" - ] - }, "Blocked": { "oneOf": [ { @@ -1027,6 +1299,12 @@ ], "description": "Why a landing waits." }, + "Capability": { + "type": "string", + "examples": [ + "write src" + ] + }, "ChangeId": { "type": "string", "examples": [ @@ -1039,6 +1317,18 @@ "48fc721fbbc172e0925fa27af1671de225ba927134802998b10a1568a188652b" ] }, + "EntryKind": { + "type": "string", + "examples": [ + "file" + ] + }, + "EntryName": { + "type": "string", + "examples": [ + "main.rs" + ] + }, "ErrorCode": { "type": "string", "description": "The kind of an [`ApiError`].", @@ -1047,6 +1337,8 @@ "not_found", "conflict", "expired", + "unauthorized", + "forbidden", "internal" ] }, @@ -1218,7 +1510,7 @@ }, "FetchRecords": { "type": "object", - "description": "Asks for records by id: revisions, statements and acceptances. Each\ncomes with the blob it carries, if the repository has it: a revision's\ndescription, a statement's payload. Snapshots are fetched with\n[`FetchSnapshot`] instead.", + "description": "Asks for records by id: revisions, statements and acceptances. Each\ncomes with the attachment it carries, if the repository has it: a\nrevision's description, a statement's payload. Snapshots are fetched\nwith [`FetchSnapshot`] instead.", "required": [ "ids" ], @@ -1228,15 +1520,15 @@ "items": { "$ref": "#/components/schemas/ObjectId" }, - "description": "The records, returned in this order, each followed by its blob. A\nresponse that stops short, with the [`MORE`] header, holds those up\nto the last record it has: ask again for those after it." + "description": "The records, returned in this order, each followed by its\nattachment. Those the caller may not read are left out. A response\nthat stops short, with the [`MORE`] header, holds those up to the\nlast record it has: ask again for those after it." } } }, "FetchSnapshot": { "type": "object", - "description": "Asks for the objects of a snapshot that the caller lacks: its trees and\nblobs, walked from its root. This is the only way blobs and trees leave\na repository, so what each caller may read can be checked by path.", + "description": "Asks for the objects of a snapshot that the caller lacks: its trees and\nblobs, walked from its root. This is the only way blobs and trees leave\na repository, so what each caller may read is checked by path: the\ncontent of paths they may not read is withheld, though it keeps its\nplace in the trees.", "required": [ - "snapshot" + "of" ], "properties": { "have": { @@ -1246,6 +1538,10 @@ }, "description": "Snapshots the caller holds whole. Whatever one of them holds at the\nsame path is skipped, so only what changed since comes. Those the\nrepository doesn't have are ignored." }, + "of": { + "type": "string", + "description": "The record whose snapshot it is: a revision or an acceptance. Only a\nrecord's snapshot is walked, never a tree within one, whose paths\nwould be read as others." + }, "paths": { "type": "array", "items": { @@ -1258,10 +1554,6 @@ "format": "int64", "description": "Skip this many of the objects, which earlier parts brought: a\nresponse that stops short has the [`MORE`] header.", "minimum": 0 - }, - "snapshot": { - "$ref": "#/components/schemas/TreeId", - "description": "The snapshot." } } }, @@ -1369,12 +1661,43 @@ } } }, + "IssuedToken": { + "type": "object", + "description": "A token a server issued.", + "required": [ + "token", + "principal", + "expires" + ], + "properties": { + "expires": { + "type": "integer", + "format": "int64", + "description": "When it expires, in seconds since the Unix epoch.", + "minimum": 0 + }, + "principal": { + "$ref": "#/components/schemas/Principal", + "description": "Who it acts for." + }, + "token": { + "type": "string", + "description": "The token, to send as `Authorization: Bearer `." + } + } + }, "Kind": { "type": "string", "examples": [ "ci.test" ] }, + "KindPattern": { + "type": "string", + "examples": [ + "ci.*" + ] + }, "LandRequest": { "type": "object", "description": "Asks the queue to land something.", @@ -1483,6 +1806,69 @@ } } }, + "Level": { + "type": "string", + "description": "What a caller may do with a path.", + "enum": [ + "none", + "read", + "write" + ] + }, + "Listed": { + "type": "object", + "description": "An entry of a [`Listing`].", + "required": [ + "name", + "kind", + "id", + "withheld" + ], + "properties": { + "id": { + "$ref": "#/components/schemas/ObjectId", + "description": "Its object." + }, + "kind": { + "$ref": "#/components/schemas/EntryKind", + "description": "A file, an executable, a symlink or a directory." + }, + "name": { + "$ref": "#/components/schemas/EntryName", + "description": "Its name." + }, + "withheld": { + "type": "boolean", + "description": "Whether its content is withheld from the caller: it keeps its\nplace, but can't be read." + } + } + }, + "Listing": { + "type": "object", + "description": "A directory of a snapshot.", + "required": [ + "snapshot", + "path", + "entries" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Listed" + }, + "description": "Its entries, sorted by name." + }, + "path": { + "$ref": "#/components/schemas/RepoPath", + "description": "The directory." + }, + "snapshot": { + "$ref": "#/components/schemas/TreeId", + "description": "The snapshot's root tree." + } + } + }, "LogEntry": { "type": "object", "description": "One acceptance of a target's log.", @@ -1552,6 +1938,16 @@ } } }, + "PathAccess": { + "type": "object", + "description": "What a caller may do with every path: the level of the most specific\npath it lists at or above it. The root is always listed.", + "additionalProperties": { + "$ref": "#/components/schemas/Level" + }, + "propertyNames": { + "type": "string" + } + }, "Pending": { "type": "object", "description": "A landing that waits, kept in the queue to be listed and retried.", @@ -1715,7 +2111,7 @@ "description": "The exact revisions this one requires: the changes below it in a\nstack." }, "description": { - "$ref": "#/components/schemas/BlobId", + "$ref": "#/components/schemas/AttachmentId", "description": "The intent, written for humans and agents." }, "predecessors": { @@ -1779,6 +2175,68 @@ } } }, + "Rights": { + "type": "object", + "description": "What a caller may do, resolved from the policy in force and narrowed by\ntheir token's scope.", + "required": [ + "paths", + "annotate", + "land", + "admin" + ], + "properties": { + "admin": { + "type": "boolean", + "description": "Whether they may land on `policy`." + }, + "annotate": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KindPattern" + }, + "description": "The kinds of statements they may issue." + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PrincipalName" + }, + "description": "The policy's groups the caller is a member of." + }, + "land": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Targets" + }, + "description": "The targets they may land on, but `policy`." + }, + "paths": { + "$ref": "#/components/schemas/PathAccess", + "description": "What they may do with each path." + }, + "principal": { + "oneOf": [ + { + "$ref": "#/components/schemas/Principal", + "description": "Who the caller is: `None` for an anonymous caller, or the node's own\nuser." + }, + { + "type": "null" + } + ] + }, + "scope": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/Capability" + }, + "description": "The token's scope, if the caller came with a scoped token." + } + } + }, "Source": { "oneOf": [ { @@ -1841,7 +2299,7 @@ "payload": { "oneOf": [ { - "$ref": "#/components/schemas/BlobId", + "$ref": "#/components/schemas/AttachmentId", "description": "Free-form details, defined by whoever owns the kind." }, { @@ -1958,6 +2416,33 @@ "main" ] }, + "Targets": { + "type": "string", + "examples": [ + "main" + ] + }, + "TokenRequest": { + "type": "object", + "description": "Asks a server for a token with a narrower scope, for an agent or a CI\nrunner.", + "required": [ + "scope", + "ttl" + ], + "properties": { + "scope": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Capability" + }, + "description": "What it may do: a part of what the caller may do." + }, + "ttl": { + "type": "string", + "description": "How long it lasts, such as `1h`." + } + } + }, "TreeId": { "type": "string", "examples": [ diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs index 3649b29f..d7c4c8e5 100644 --- a/tools/xtask/src/main.rs +++ b/tools/xtask/src/main.rs @@ -428,19 +428,29 @@ impl Bench { } } -/// An `origin serve` hosting one empty repository, stopped when dropped. +/// An `origin serve` hosting one empty repository, which the bench user, +/// signed in with [`Server::TOKEN`], writes to. Stopped when dropped. struct Server { child: Child, url: String, } impl Server { + /// The bench user's token. + const TOKEN: &str = "bench-secret"; + + /// Starts the server, and adds its token to the bench user's + /// configuration in `config`. fn start(origin: &Path, base: &Path, config: &Path) -> Result { let dir = base.join("server"); fs::create_dir_all(&dir)?; fs::write( dir.join("origin-server.toml"), - "listen = \"127.0.0.1:0\"\nnode = \"hosted\"\n[repos]\ndemo = \"demo\"\n", + format!( + "listen = \"127.0.0.1:0\"\nnode = \"hosted\"\n[repos]\ndemo = \"demo\"\n\ + [tokens]\n\"user:bench\" = \"{}\"\n", + Self::TOKEN + ), )?; run(Command::new(origin) .args(["repo", "create", "demo"]) @@ -470,6 +480,12 @@ impl Server { .trim() .strip_prefix("Listening on ") .with_context(|| format!("unexpected server output: {line}"))?; + let user = config.join("config.toml"); + let toml = fs::read_to_string(&user)?; + fs::write( + &user, + format!("{toml}[tokens]\n\"{base}\" = \"{}\"\n", Self::TOKEN), + )?; server.url = format!("{base}/demo"); Ok(server) }