//! What a host (a hub) does to a repository without a workspace (hub spec //! §6.9, §7.6): build a change from an edit set on any state, sign a //! reference update with a key it holds, and apply both through the same //! validation a received push gets. Nothing here bypasses policy. use super::*; use crate::identity::SecretKey; /// One file of an edit set: new content, or `None` to remove the path. #[derive(Clone, Debug, PartialEq, Eq)] pub struct FileEdit { pub path: String, pub content: Option>, } impl Repo { /// Build (but do not store or sign) a change that turns the state with /// `heads` into that state with `edits` applied, authored by `author`. /// Files the edits do not name keep their content, conflict markers /// included, so an edit that removes a file's markers resolves that /// file's conflict (a resolution change, spec §9.6). pub fn build_change( &self, heads: &[ChangeHash], edits: &[FileEdit], message: &str, author: KeyId, ) -> Result { self.build_change_with(heads, edits, &[], message, author) } /// Build (but do not store or sign) a change that moves the subrepo at /// `path` in the state with `heads` to `pin`: a hub moving a consumer's /// pin for Diffusion (oxygenhub#148). The path must already be a /// subrepo; nothing else changes. pub fn build_pin_change( &self, heads: &[ChangeHash], path: &str, pin: &crate::subrepo::Pin, message: &str, author: KeyId, ) -> Result { self.build_change_with( heads, &[], &[(path.to_string(), pin.clone())], message, author, ) } fn build_change_with( &self, heads: &[ChangeHash], edits: &[FileEdit], pins: &[(String, crate::subrepo::Pin)], message: &str, author: KeyId, ) -> Result { if message.trim().is_empty() { return fail("a change message is required"); } if edits.is_empty() && pins.is_empty() { return fail("nothing to change"); } let base = self.load_state(heads)?; let base_snapshot = base.materialize(); let mut entries: BTreeMap = BTreeMap::new(); for (path, f) in &base_snapshot.files { let entry = match (&f.kind, &f.content) { (NodeKind::Dir, _) | (_, FileContent::Dir) => continue, (kind, FileContent::Text { bytes, .. }) => { FsEntry::new(*kind, f.mode.exec, bytes.clone()) } (kind, FileContent::Opaque { blobs }) => FsEntry { blob: blobs.first().copied(), ..FsEntry::new(*kind, f.mode.exec, vec![]) }, }; entries.insert(path.clone(), entry); } for e in edits { let path = e.path.trim_matches('/'); if path.is_empty() || path .split('/') .any(|c| c.is_empty() || c == "." || c == ".." || c == OXY_DIR) { return fail(format!("{:?} is not a valid path", e.path)); } match &e.content { Some(bytes) => { let exec = entries.get(path).map(|x| x.exec).unwrap_or(false); // A file cannot sit where a directory is, or inside a file. if base_snapshot .files .get(path) .is_some_and(|f| f.kind == NodeKind::Dir) { return fail(format!("{path} is a directory")); } let mut parent = path; while let Some((p, _)) = parent.rsplit_once('/') { if base_snapshot .files .get(p) .is_some_and(|f| f.kind != NodeKind::Dir) { return fail(format!("{p} is a file, not a directory")); } parent = p; } entries.insert( path.to_string(), FsEntry::new(NodeKind::File, exec, bytes.clone()), ); } None => { if entries.remove(path).is_none() { // Removing a directory removes everything below it. let prefix = format!("{path}/"); let before = entries.len(); entries.retain(|p, _| !p.starts_with(&prefix)); if entries.len() == before { return fail(format!("{path} does not exist")); } } } } } for (path, pin) in pins { let path = path.trim_matches('/'); match base_snapshot.files.get(path) { Some(f) if f.kind == NodeKind::Subrepo => {} Some(_) => return fail(format!("{path} is not a subrepo")), None => return fail(format!("{path} does not exist")), } entries.insert( path.to_string(), FsEntry::new(NodeKind::Subrepo, false, pin.encode()), ); } // A path the user writes settles its replace conflict, if any: they // chose that content (spec §9.3). let written: std::collections::HashSet = edits .iter() .filter(|e| e.content.is_some()) .map(|e| e.path.trim_matches('/').to_string()) .chain(pins.iter().map(|(p, _)| p.trim_matches('/').to_string())) .collect(); let built = self.build_from_entries_resolving( &base, &base_snapshot, HashMap::new(), &entries, &written, )?; if let Some(s) = built .statuses .iter() .find(|s| s.status == ChangeStatus::ConflictEdited) { return fail(format!("{} still contains conflict markers", s.path)); } if built.tree_ops.is_empty() && built.edits.is_empty() { return fail("the edits change nothing"); } let mut change = Change { version: crate::objects::CHANGE_VERSION, change_id: ChangeId::random(), deps: vec![], author, hlc: Hlc::now(), message: message.trim_end().to_string(), tree_ops: built.tree_ops, edits: built.edits, kind: if built.resolves { ChangeKind::Resolution } else { ChangeKind::Normal }, }; let mut deps: BTreeSet = referenced_changes(&change, &base); deps.extend(built.contributors.iter().copied()); change.deps = deps.into_iter().collect(); change.normalize(); Ok(change) } /// A reference update of `bookmark` to `heads`, attested by `signer` /// when given (the attestation a push would carry, spec §14.4). pub fn host_ref_update( &self, bookmark: &str, heads: &[ChangeHash], signer: Option<(&KeyId, &SecretKey)>, ) -> Result { let view = self.view()?; let empty = self.store.put_state(&StateObject::new(vec![]))?; let old_state = view.bookmarks.get(bookmark).copied().unwrap_or(empty); let new_state = self.store.put_state(&StateObject::new(heads.to_vec()))?; let attestation = match signer { Some((key, sk)) => { let serial = self .latest_ref_attest(bookmark)? .and_then(|(_, a)| match a.attest.body { AttestBody::Ref { serial, .. } => Some(serial), _ => None, }) .map(|s| s + 1) .unwrap_or(1); let attest = Attest { repo: self.id()?, hlc: Hlc::now(), body: AttestBody::Ref { name: bookmark.to_string(), old_state, new_state, serial, }, }; let r = attest.hash(); let sig = sk.sign(Domain::Attest, key, &r); Some(SignedAttest { attest, sigs: vec![sig], }) } None => None, }; Ok(RefUpdate { name: bookmark.to_string(), old_state, new_state, force: false, attestation, }) } /// Create tag `name` for the state of `heads` as a host does for a /// signed-in user (oxygenhub#295): attested by `signer` (a delegate of /// the user, or the host's own key), with `identities` it needs, and /// received as a pushed tag is, so the same policy checks apply. pub fn host_tag_create( &self, name: &str, heads: &[ChangeHash], message: &str, signer: (&KeyId, &SecretKey), identities: &[Identity], ) -> Result { crate::objects::validate_name(name) .map_err(|e| RepoError::Msg(format!("tag name: {e}")))?; let st = self.load_state_history(heads)?; let heads = st.maximal(heads); if heads.is_empty() { return fail("a tag needs at least one change"); } let state_object = StateObject::new(heads.clone()); let state = state_object.hash(); let body = AttestBody::Tag { name: name.into(), state, message: message.into(), }; let mut bundle = self.hosted_bundle(identities); bundle .objects .push((ObjectType::State, state_object.encode())); let t = crate::protocol::TagRef::new( name.into(), state, heads, Some(self.host_attest(body, signer)?), ); self.receive_push_report(bundle, vec![], None, vec![t]) } /// Withdraw tag `name` as a host does for a signed-in user /// (oxygenhub#301): an `untag` attestation by `signer`, received as a /// pushed withdrawal is. pub fn host_tag_withdrawal( &self, name: &str, reason: &str, signer: (&KeyId, &SecretKey), identities: &[Identity], ) -> Result { let Some(state) = self.view()?.tags.get(name).copied() else { return fail(format!("no tag {name:?}")); }; let body = AttestBody::Untag { name: name.into(), state, reason: reason.into(), }; let t = crate::protocol::TagRef::new( name.into(), state, vec![], Some(self.host_attest(body, signer)?), ); self.receive_push_report(self.hosted_bundle(identities), vec![], None, vec![t]) } fn host_attest( &self, body: AttestBody, (key, sk): (&KeyId, &SecretKey), ) -> Result { let attest = Attest { repo: self.id()?, hlc: Hlc::now(), body, }; let sig = sk.sign(Domain::Attest, key, &attest.hash()); Ok(SignedAttest { attest, sigs: vec![sig], }) } fn hosted_bundle(&self, identities: &[Identity]) -> Bundle { let mut bundle = Bundle::default(); for id in identities { bundle.objects.push((ObjectType::Identity, id.encode())); } bundle } /// Apply a host-made update as if it had been pushed: the new change /// (with its signatures) if any, the identities it needs, and the /// reference update, all through `receive_push_report`, so the same /// authentication, compare-and-swap and policy checks apply. pub fn apply_hosted( &self, change: Option<(Change, Vec)>, identities: &[Identity], update: RefUpdate, ) -> Result { let mut bundle = Bundle::default(); for id in identities { bundle.objects.push((ObjectType::Identity, id.encode())); } if let Ok(st) = self.store.get_state(&update.new_state) { bundle.objects.push((ObjectType::State, st.encode())); } if let Some((c, sigs)) = change { bundle.changes.push((c, sigs)); } self.receive_push_report(bundle, vec![update], None, vec![]) } } #[cfg(test)] mod tests { use super::*; use crate::identity::SecretKey; fn hermetic() { static ONCE: std::sync::Once = std::sync::Once::new(); static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); ONCE.call_once(|| { let d = tempfile::tempdir().unwrap(); std::env::set_var("OXY_HOME", d.path()); DIR.set(d).ok(); }); } fn write(root: &Path, rel: &str, content: &[u8]) { let p = root.join(rel); fs::create_dir_all(p.parent().unwrap()).unwrap(); fs::write(p, content).unwrap(); } /// A user repository pushed to a bare host, and a hub key the user /// has delegated to. Returns (host, user, hub key, hub secret). fn setup( root: &Path, with_policy: bool, delegate: bool, ) -> (Repo, Repo, KeyId, SecretKey, Identity) { hermetic(); let host_dir = root.join("host"); Repo::init_bare_unbound(&host_dir).unwrap(); let work = root.join("work"); let mut user = Repo::init(&work).unwrap(); user.create_identity("Andy", "andy@example.com", IdentityScope::Repo) .unwrap(); user.config.set("core.sign", "change").unwrap(); user.config .set("remote.origin.url", host_dir.to_str().unwrap()) .unwrap(); user.save_config().unwrap(); let hub_sk = SecretKey::generate(); let hub_id = hub_sk.identity("Hub", "hub@example", 1); let hub_key = hub_id.hash(); if with_policy { user.policy_init().unwrap(); } if delegate { user.delegate_add(hub_id.clone(), Scope::CHANGES | Scope::REFS, None) .unwrap(); } write(&work, "a.txt", b"one\ntwo\n"); user.snapshot("t").unwrap(); user.commit("first", &[]).unwrap(); let r = user.push("origin", false).unwrap(); assert!(r.iter().all(|x| x.ok), "{r:?}"); ( Repo::open(&host_dir).unwrap(), user, hub_key, hub_sk, hub_id, ) } #[test] fn a_host_edit_signed_by_a_delegate_lands() { let dir = tempfile::tempdir().unwrap(); let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true); let author = user.config.author_key.unwrap(); let heads = host.bookmark_heads().unwrap(); let change = host .build_change( &heads, &[ FileEdit { path: "a.txt".into(), content: Some(b"one\ntwo\nthree\n".to_vec()), }, FileEdit { path: "docs/new.md".into(), content: Some(b"# New\n".to_vec()), }, ], "Edit on the web", author, ) .unwrap(); assert_eq!(change.author, author); assert_eq!(change.kind, ChangeKind::Normal); let hash = change.hash(); let sig = hub_sk.sign(Domain::Change, &hub_key, &hash); let mut new_heads = heads.clone(); new_heads.push(hash); let update = host .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk))) .unwrap(); let ids = vec![hub_id, user.identity(&author).unwrap()]; let report = host .apply_hosted(Some((change, vec![sig])), &ids, update) .unwrap(); assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results); assert_eq!(host.bookmark_heads().unwrap(), vec![hash]); assert_eq!( host.file_at("a.txt", Some(&[hash])).unwrap(), b"one\ntwo\nthree\n" ); assert_eq!( host.file_at("docs/new.md", Some(&[hash])).unwrap(), b"# New\n" ); // Authored by the user, authenticated by the hub key as delegate. assert!( matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Valid { key, name, .. } if key == hub_key && name == "Hub") ); let _ = new_heads; // Bad paths and empty edits are refused. assert!(host .build_change( &[hash], &[FileEdit { path: "../x".into(), content: Some(vec![]) }], "m", author ) .is_err()); assert!( host.build_change( &[hash], &[FileEdit { path: "a.txt".into(), content: Some(b"one\ntwo\nthree\n".to_vec()) }], "m", author ) .is_err(), "no-op edits are refused" ); assert!( host.build_change( &[hash], &[FileEdit { path: "a.txt/b".into(), content: Some(vec![]) }], "m", author ) .is_err(), "a file inside a file" ); let rm = host .build_change( &[hash], &[FileEdit { path: "docs".into(), content: None, }], "Remove docs", author, ) .unwrap(); assert!(!rm.tree_ops.is_empty()); } #[test] fn a_host_edit_by_a_key_that_is_not_a_delegate_is_refused() { let dir = tempfile::tempdir().unwrap(); let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, false); let author = user.config.author_key.unwrap(); let heads = host.bookmark_heads().unwrap(); let change = host .build_change( &heads, &[FileEdit { path: "a.txt".into(), content: Some(b"x\n".to_vec()), }], "Edit", author, ) .unwrap(); let hash = change.hash(); let sig = hub_sk.sign(Domain::Change, &hub_key, &hash); let update = host .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk))) .unwrap(); let report = host .apply_hosted( Some((change, vec![sig])), &[hub_id, user.identity(&author).unwrap()], update, ) .unwrap(); // Without a policy the host accepts unauthenticated changes, but the // signature does not verify as the author's: it stays unauthenticated. assert!(report.results.iter().all(|r| r.ok)); assert!( matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Invalid(why) if why.contains("neither the author")) ); } #[test] fn under_a_policy_a_delegate_may_update_the_reference_and_a_stranger_may_not() { let dir = tempfile::tempdir().unwrap(); let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), true, true); assert!(host.current_policy().unwrap().is_some()); let author = user.config.author_key.unwrap(); let heads = host.bookmark_heads().unwrap(); let change = host .build_change( &heads, &[FileEdit { path: "a.txt".into(), content: Some(b"one\n".to_vec()), }], "Trim", author, ) .unwrap(); let hash = change.hash(); let sig = hub_sk.sign(Domain::Change, &hub_key, &hash); let ids = vec![hub_id.clone(), user.identity(&author).unwrap()]; let update = host .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk))) .unwrap(); let report = host .apply_hosted(Some((change, vec![sig])), &ids, update) .unwrap(); assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results); assert_eq!(host.bookmark_heads().unwrap(), vec![hash]); // A stranger's key cannot move the reference, even with the user as author. let stranger = SecretKey::generate(); let sid = stranger.identity("Mallory", "m@example", 1); let skey = sid.hash(); let change2 = host .build_change( &[hash], &[FileEdit { path: "a.txt".into(), content: Some(b"evil\n".to_vec()), }], "Evil", author, ) .unwrap(); let h2 = change2.hash(); let sig2 = stranger.sign(Domain::Change, &skey, &h2); let update = host .host_ref_update("main", &[h2], Some((&skey, &stranger))) .unwrap(); let report = host .apply_hosted(Some((change2, vec![sig2])), &[sid], update) .unwrap(); assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results); assert_eq!( host.bookmark_heads().unwrap(), vec![hash], "the reference did not move" ); // An unsigned update is refused under a policy too. let update = host.host_ref_update("main", &[hash], None).unwrap(); let report = host.apply_hosted(None, &[], update).unwrap(); assert!(report.results.iter().all(|r| !r.ok)); } /// Agent doc §2: an agent's key signs changes for its owner and nothing /// more. Its reference attestation is refused even though its owner's /// would pass, and a seal it makes authenticates nothing. #[test] fn a_changes_only_delegate_signs_changes_but_cannot_seal_or_move_a_reference() { let dir = tempfile::tempdir().unwrap(); let (host, mut user, _, _, _) = setup(dir.path(), true, false); let agent_sk = SecretKey::generate(); let agent_id = agent_sk.identity("aider on a laptop", "agent@example", 1); let agent = agent_id.hash(); user.delegate_add(agent_id.clone(), Scope::CHANGES, None) .unwrap(); // The successor identity reaches the host with the rotated policy. assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok)); let author = user.config.author_key.unwrap(); let ids = vec![agent_id.clone(), user.identity(&author).unwrap()]; let heads = host.bookmark_heads().unwrap(); let change = host .build_change( &heads, &[FileEdit { path: "a.txt".into(), content: Some(b"by the agent\n".to_vec()), }], "Agent edit", author, ) .unwrap(); let hash = change.hash(); let sig = agent_sk.sign(Domain::Change, &agent, &hash); // Attested by the agent: refused, the reference stays. let update = host .host_ref_update("main", &[hash], Some((&agent, &agent_sk))) .unwrap(); let report = host .apply_hosted(Some((change.clone(), vec![sig.clone()])), &ids, update) .unwrap(); assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results); assert_eq!(host.bookmark_heads().unwrap(), heads); // Attested by the owner: the agent-signed change lands. let (owner, owner_sk) = user.secret_key().unwrap().unwrap(); let update = host .host_ref_update("main", &[hash], Some((&owner, &owner_sk))) .unwrap(); let report = host .apply_hosted(Some((change, vec![sig])), &ids, update) .unwrap(); assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results); assert_eq!(host.bookmark_heads().unwrap(), vec![hash]); assert!(matches!( host.authenticate_change(&hash).unwrap(), SignatureStatus::Valid { key, sealed: false, .. } if key == agent )); // A seal by the agent is outside its scope. let change2 = host .build_change( &[hash], &[FileEdit { path: "a.txt".into(), content: Some(b"sealed by the agent\n".to_vec()), }], "Sealed", author, ) .unwrap(); let h2 = host.store().put_change(&change2).unwrap(); let seal = host .store() .put_attest(&Attest::seal(host.id().unwrap(), vec![h2])) .unwrap(); host.store() .put_signature(&seal, &agent_sk.sign(Domain::Attest, &agent, &seal)) .unwrap(); host.store().index_seal(&h2, &seal).unwrap(); assert!(matches!( host.authenticate_change(&h2).unwrap(), SignatureStatus::Invalid(e) if e.contains("may not seal") )); } /// Agent doc §2 end to end from the command line's point of view: the /// agent's key is a changes-only delegate, and `author.signer` makes /// the owner's checkout sign with it while `author.key` stays the /// owner, so a commit carries the owner as author and the agent's /// signature, and a reference attestation by the agent is refused. #[test] fn an_agent_signs_its_owner_s_changes_through_author_signer() { let dir = tempfile::tempdir().unwrap(); let (host, mut user, _, _, _) = setup(dir.path(), true, false); let owner = user.config.author_key.unwrap(); // The agent makes its own key in its own home; the owner delegates. let agent_home = dir.path().join("agent-home"); let mut agent_repo = Repo::init(&dir.path().join("agent-work")) .unwrap() .with_home(agent_home.clone()); let (agent, agent_id) = agent_repo .create_identity("aider on a laptop", "agent@example", IdentityScope::User) .unwrap(); user.delegate_add(agent_id.clone(), Scope::CHANGES, None) .unwrap(); assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok)); host.refresh().unwrap(); let owner = { let _ = owner; user.config.author_key.unwrap() }; // The owner's checkout, as the agent runs it: the agent's keys, // the owner as author, the agent as signer. // (The signer is the agent's own setting, in its user config: the // checkout's `author.key` is the owner's, in the repository.) let work = dir.path().join("work"); let mut as_agent = Repo::open(&work).unwrap().with_home(agent_home); as_agent .set_user_config("author.signer", &agent.to_hex()) .unwrap(); as_agent.set_user_config("core.sign", "change").unwrap(); assert_eq!(as_agent.config.author_key, Some(owner)); assert_eq!(as_agent.signing_key(), Some(agent)); write(&work, "a.txt", b"by the agent\n"); as_agent.snapshot("t").unwrap(); let h = as_agent.commit("Agent: edit a", &[]).unwrap(); let meta = as_agent.store().get_change_meta(&h).unwrap(); assert_eq!(meta.author, owner, "the owner stays the author"); assert!(matches!( as_agent.verify_change(&h).unwrap(), SignatureStatus::Valid { key, .. } if key == agent )); assert_eq!(as_agent.revset("delegated()").unwrap().hashes, vec![h]); // The agent cannot push: its attestation of the reference is // refused under the policy. The owner can. let r = as_agent.push("origin", false).unwrap(); host.refresh().unwrap(); assert!(r.iter().all(|x| !x.ok), "{r:?}"); assert!(!host.bookmark_heads().unwrap().contains(&h)); // The owner's own handle (its key directory was fixed at `setup`; // the environment's `OXY_HOME` may have moved since), which reads // the head again to see the agent's commit. user.refresh().unwrap(); let r = user.push("origin", false).unwrap(); host.refresh().unwrap(); assert!(r.iter().all(|x| x.ok), "{r:?}"); assert!(host.bookmark_heads().unwrap().contains(&h)); assert!(matches!( host.authenticate_change(&h).unwrap(), SignatureStatus::Valid { key, .. } if key == agent )); } /// oxygenhub#129: a delegate that may move references makes a bookmark /// at its owner's change, signing no change of its own. The push /// carries its identity, so the host can check the move (it was /// refused as "unknown key" until the delegate had pushed a change). #[test] fn a_delegate_s_first_push_of_a_bookmark_carries_its_identity() { let dir = tempfile::tempdir().unwrap(); let (host, mut user, _, _, _) = setup(dir.path(), true, false); let agent_home = dir.path().join("agent-home"); let mut agent_repo = Repo::init(&dir.path().join("agent-work")) .unwrap() .with_home(agent_home.clone()); let (agent, agent_id) = agent_repo .create_identity("agent", "agent@example", IdentityScope::User) .unwrap(); user.delegate_add(agent_id, Scope::CHANGES | Scope::REFS, None) .unwrap(); assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok)); let work = dir.path().join("work"); let mut as_agent = Repo::open(&work).unwrap().with_home(agent_home); as_agent .set_user_config("author.signer", &agent.to_hex()) .unwrap(); assert!( host.store().get_identity(&agent).is_err(), "the host has never seen the agent" ); let heads = as_agent.bookmark_heads().unwrap(); as_agent.bookmark_create("feature", Some(&heads)).unwrap(); let r = as_agent.push_bookmark("origin", "feature", false).unwrap(); assert!(r.iter().all(|x| x.ok), "{r:?}"); assert!( host.store().get_identity(&agent).is_ok(), "its identity came with the move" ); } /// oxygenhub#266: a bookmark whose latest attestation a delegate made, /// signing no change, is fetched by a third party. The fetch bundle /// carries the delegate's identity even when it asks for no change, /// so a client as it was (import the bundle, then check each /// bookmark's attestation) verifies the move instead of failing the /// whole fetch with "unknown key". #[test] fn a_bookmark_attested_by_a_key_that_signed_no_change_is_fetched() { let dir = tempfile::tempdir().unwrap(); let (host, mut user, _, _, _) = setup(dir.path(), true, false); let agent_home = dir.path().join("agent-home"); let mut agent_repo = Repo::init(&dir.path().join("agent-work")) .unwrap() .with_home(agent_home.clone()); let (agent, agent_id) = agent_repo .create_identity("agent", "agent@example", IdentityScope::User) .unwrap(); user.delegate_add(agent_id, Scope::CHANGES | Scope::REFS, None) .unwrap(); assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok)); let mut as_agent = Repo::open(&dir.path().join("work")) .unwrap() .with_home(agent_home); as_agent .set_user_config("author.signer", &agent.to_hex()) .unwrap(); let heads = as_agent.bookmark_heads().unwrap(); as_agent.bookmark_create("feature", Some(&heads)).unwrap(); let r = as_agent.push_bookmark("origin", "feature", false).unwrap(); assert!(r.iter().all(|x| x.ok), "{r:?}"); host.refresh().unwrap(); let (_, sa) = host.latest_ref_attest("feature").unwrap().unwrap(); assert_eq!(sa.sigs[0].key, agent, "the agent attested the move"); // What a fetch that names no change brings: the agent's identity. let remote = crate::protocol::LocalRemote::open(&dir.path().join("host")).unwrap(); let carries = |b: &Bundle| { b.objects .iter() .any(|(t, body)| *t == ObjectType::Identity && hash_object(*t, body) == agent) }; assert!(carries( &crate::protocol::Remote::fetch(&remote, &[], None).unwrap() )); assert!(carries(&host.bundle_for_filtered(&[], None).unwrap())); // A third party's clone takes both bookmarks, verified. let theirs = Repo::clone( dir.path().join("host").to_str().unwrap(), &dir.path().join("theirs"), ) .unwrap(); let tracked = theirs.view().unwrap().remotes["origin"].clone(); assert_eq!( tracked.get("feature"), host.view().unwrap().bookmarks.get("feature") ); assert!(theirs.store().get_identity(&agent).is_ok()); let report = theirs.fetch("origin").unwrap(); assert!(report.skipped.is_empty(), "{:?}", report.skipped); } /// Make `bookmark`'s latest attestation on `host` one signed as `key` /// with `sk`: by a key the host never saw (as a host that did not send /// the signer's identity looked), or a bad signature. fn reattest(host: &Repo, bookmark: &str, key: KeyId, sk: &SecretKey) { let state = host.view().unwrap().bookmarks[bookmark]; let (_, last) = host.latest_ref_attest(bookmark).unwrap().unwrap(); let AttestBody::Ref { old_state, serial, .. } = last.attest.body else { panic!("not a ref attestation") }; let attest = Attest { repo: host.id().unwrap(), hlc: Hlc::now(), body: AttestBody::Ref { name: bookmark.into(), old_state, new_state: state, serial: serial + 1, }, }; let r = host.store().put_attest(&attest).unwrap(); host.store() .put_signature(&r, &sk.sign(Domain::Attest, &key, &attest.hash())) .unwrap(); host.set_ref_attest(bookmark, &r).unwrap(); } /// oxygenhub#266: a bookmark whose attestation a client cannot verify /// (its signer unknown, or its signature bad) is left as it was, named /// with why, and the rest are fetched. The current bookmark is not /// skipped: a clone, fetch or pull that would take it fails. #[test] fn an_unverifiable_bookmark_is_skipped_but_never_the_current_one() { let dir = tempfile::tempdir().unwrap(); let (host, user, _, _, _) = setup(dir.path(), false, false); let author = user.config.author_key.unwrap(); let heads = user.bookmark_heads().unwrap(); for b in ["unknown", "forged", "fine"] { user.bookmark_create(b, Some(&heads)).unwrap(); let r = user.push_bookmark("origin", b, false).unwrap(); assert!(r.iter().all(|x| x.ok), "{r:?}"); } host.refresh().unwrap(); let stranger_sk = SecretKey::generate(); let stranger = stranger_sk.identity("s", "s@example", 1).hash(); reattest(&host, "unknown", stranger, &stranger_sk); reattest(&host, "forged", author, &stranger_sk); let url = dir.path().join("host").to_string_lossy().to_string(); let (theirs, skipped) = Repo::clone_reporting(&url, &dir.path().join("theirs"), None, &[], None).unwrap(); let names: Vec<&str> = skipped.iter().map(|(n, _)| n.as_str()).collect(); assert_eq!(names, ["forged", "unknown"]); assert!(skipped[1].1.contains("unknown key"), "{skipped:?}"); assert!(skipped[1].1.contains("origin/unknown"), "{skipped:?}"); let tracked = theirs.view().unwrap().remotes["origin"].clone(); assert!(tracked.contains_key("main") && tracked.contains_key("fine")); assert!(!tracked.contains_key("unknown") && !tracked.contains_key("forged")); assert_eq!(theirs.config.bookmark, "main"); assert_eq!(theirs.fetch("origin").unwrap().skipped.len(), 2); assert_eq!(theirs.pull("origin").unwrap().skipped.len(), 2); // The current bookmark's attestation unverifiable: a fetch and a // pull fail, and so does a fresh clone. reattest(&host, "main", stranger, &stranger_sk); for err in [ theirs.fetch("origin").unwrap_err(), theirs.pull("origin").unwrap_err(), Repo::clone(&url, &dir.path().join("again")) .err() .expect("the clone fails"), ] { let err = err.to_string(); assert!( err.contains("attestation for origin/main: unknown key"), "{err}" ); } } /// A delegation past its expiry authenticates nothing made after it, /// and the delegator may revoke the key; a stranger's key, they may not. #[test] fn an_expired_delegation_is_refused_and_the_delegator_may_revoke() { let dir = tempfile::tempdir().unwrap(); let (_, mut user, _, _, _) = setup(dir.path(), false, false); let agent_sk = SecretKey::generate(); let agent_id = agent_sk.identity("agent", "agent@example", 1); let agent = agent_id.hash(); let yesterday = Hlc::now().wall_ms - 24 * 3600 * 1000; user.delegate_add(agent_id, Scope::CHANGES, Some(yesterday)) .unwrap(); user.config.set("core.sign", "seal").unwrap(); user.save_config().unwrap(); write(&dir.path().join("work"), "a.txt", b"later\n"); user.snapshot("t").unwrap(); let h = user.commit("after the delegation ended", &[]).unwrap(); user.store() .put_signature(&h, &agent_sk.sign(Domain::Change, &agent, &h)) .unwrap(); let status = user.verify_change(&h).unwrap(); assert!( matches!(&status, SignatureStatus::Invalid(e) if e.contains("expired")), "{status:?}" ); // No policy here, so the user is no root: only delegation lets them revoke. let stranger = SecretKey::generate().identity("x", "x@example", 1).hash(); assert!(user.revoke_identity(&stranger, "").is_err()); assert!(user.revoke_identity(&agent, "session over").is_ok()); assert!(user.is_revoked(&agent).unwrap()); let r = user.delegate_remove(&agent).unwrap(); assert!(user.identity(&r.new).unwrap().delegates.is_empty()); } #[test] fn a_host_edit_that_removes_markers_resolves_the_conflict() { let dir = tempfile::tempdir().unwrap(); let (host, mut user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true); let author = user.config.author_key.unwrap(); let base = host.bookmark_heads().unwrap(); // Two independent edits of the same line: a conflict in the union. let a = host .build_change( &base, &[FileEdit { path: "a.txt".into(), content: Some(b"ONE\ntwo\n".to_vec()), }], "A", author, ) .unwrap(); let b = host .build_change( &base, &[FileEdit { path: "a.txt".into(), content: Some(b"uno\ntwo\n".to_vec()), }], "B", author, ) .unwrap(); let (ha, hb) = (a.hash(), b.hash()); let ids = vec![hub_id.clone(), user.identity(&author).unwrap()]; for (c, h) in [(a, ha), (b, hb)] { let sig = hub_sk.sign(Domain::Change, &hub_key, &h); let mut heads = host.bookmark_heads().unwrap(); heads.retain(|x| !base.contains(x)); heads.push(h); let update = host .host_ref_update("main", &heads, Some((&hub_key, &hub_sk))) .unwrap(); let r = host .apply_hosted(Some((c, vec![sig])), &ids, update) .unwrap(); assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results); } let heads = host.bookmark_heads().unwrap(); assert_eq!(heads.len(), 2); let marked = String::from_utf8(host.file_at("a.txt", Some(&heads)).unwrap()).unwrap(); assert!(marked.contains("<<<<<<< oxy conflict"), "{marked}"); // Saving with markers left in is refused; saving without them resolves. assert!(host .build_change( &heads, &[FileEdit { path: "a.txt".into(), content: Some(format!("{marked}x\n").into_bytes()) }], "half", author ) .is_err()); let res = host .build_change( &heads, &[FileEdit { path: "a.txt".into(), content: Some(b"One\ntwo\n".to_vec()), }], "Resolve", author, ) .unwrap(); assert_eq!(res.kind, ChangeKind::Resolution); assert!( res.deps.contains(&ha) && res.deps.contains(&hb), "a resolution depends on both sides" ); let hr = res.hash(); let sig = hub_sk.sign(Domain::Change, &hub_key, &hr); let update = host .host_ref_update("main", &[hr], Some((&hub_key, &hub_sk))) .unwrap(); let r = host .apply_hosted(Some((res, vec![sig])), &ids, update) .unwrap(); assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results); assert_eq!(host.file_at("a.txt", Some(&[hr])).unwrap(), b"One\ntwo\n"); assert!(!host .load_state(&[hr]) .unwrap() .materialize() .is_conflicted()); let _ = &mut user; } }