//! 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 { if message.trim().is_empty() { return fail("a change message is required"); } if edits.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")); } } } } } // 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()) .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 { 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, }) } /// 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()).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)); } #[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; } }