OXYGENHUB

Coverage / crates/oxy-core/src/repo/hosted.rs 90.4%

797 of 882 lines run, 38 of 63 functions · run #403 on main · run missed

1
//! What a host (a hub) does to a repository without a workspace (hub spec
2
//! §6.9, §7.6): build a change from an edit set on any state, sign a
3
//! reference update with a key it holds, and apply both through the same
4
//! validation a received push gets. Nothing here bypasses policy.
5
6
use super::*;
7
use crate::identity::SecretKey;
8
9
/// One file of an edit set: new content, or `None` to remove the path.
10
#[derive(Clone, Debug, PartialEq, Eq)]
11
pub struct FileEdit {
12
    pub path: String,
13
    pub content: Option<Vec<u8>>,
14
}
15
16
impl Repo {
17
    /// Build (but do not store or sign) a change that turns the state with
18
    /// `heads` into that state with `edits` applied, authored by `author`.
19
    /// Files the edits do not name keep their content, conflict markers
20
    /// included, so an edit that removes a file's markers resolves that
21
    /// file's conflict (a resolution change, spec §9.6).
2216×
    pub fn build_change(
2316×
        &self,
2416×
        heads: &[ChangeHash],
2516×
        edits: &[FileEdit],
2616×
        message: &str,
2716×
        author: KeyId,
2816×
    ) -> Result<Change> {
2916×
        self.build_change_with(heads, edits, &[], message, author)
3016×
    }
31
32
    /// Build (but do not store or sign) a change that moves the subrepo at
33
    /// `path` in the state with `heads` to `pin`: a hub moving a consumer's
34
    /// pin for Diffusion (oxygenhub#148). The path must already be a
35
    /// subrepo; nothing else changes.
363×
    pub fn build_pin_change(
373×
        &self,
383×
        heads: &[ChangeHash],
393×
        path: &str,
403×
        pin: &crate::subrepo::Pin,
413×
        message: &str,
423×
        author: KeyId,
433×
    ) -> Result<Change> {
443×
        self.build_change_with(
453×
            heads,
463×
            &[],
473×
            &[(path.to_string(), pin.clone())],
483×
            message,
493×
            author,
50
        )
513×
    }
52
5319×
    fn build_change_with(
5419×
        &self,
5519×
        heads: &[ChangeHash],
5619×
        edits: &[FileEdit],
5719×
        pins: &[(String, crate::subrepo::Pin)],
5819×
        message: &str,
5919×
        author: KeyId,
6019×
    ) -> Result<Change> {
6119×
        if message.trim().is_empty() {
620×
            return fail("a change message is required");
6319×
        }
6419×
        if edits.is_empty() && pins.is_empty() {
650×
            return fail("nothing to change");
6619×
        }
6719×
        let base = self.load_state(heads)?;
6819×
        let base_snapshot = base.materialize();
6919×
        let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
7036×
        for (path, f) in &base_snapshot.files {
7136×
            let entry = match (&f.kind, &f.content) {
727×
                (NodeKind::Dir, _) | (_, FileContent::Dir) => continue,
7324×
                (kind, FileContent::Text { bytes, .. }) => {
7424×
                    FsEntry::new(*kind, f.mode.exec, bytes.clone())
75
                }
765×
                (kind, FileContent::Opaque { blobs }) => FsEntry {
775×
                    blob: blobs.first().copied(),
785×
                    ..FsEntry::new(*kind, f.mode.exec, vec![])
795×
                },
80
            };
8129×
            entries.insert(path.clone(), entry);
82
        }
8319×
        for e in edits {
8417×
            let path = e.path.trim_matches('/');
8517×
            if path.is_empty()
8617×
                || path
8717×
                    .split('/')
8819×
                    .any(|c| c.is_empty() || c == "." || c == ".." || c == OXY_DIR)
89
            {
901×
                return fail(format!("{:?} is not a valid path", e.path));
9116×
            }
9216×
            match &e.content {
9315×
                Some(bytes) => {
9415×
                    let exec = entries.get(path).map(|x| x.exec).unwrap_or(false);
95
                    // A file cannot sit where a directory is, or inside a file.
9615×
                    if base_snapshot
9715×
                        .files
9815×
                        .get(path)
9915×
                        .is_some_and(|f| f.kind == NodeKind::Dir)
100
                    {
1010×
                        return fail(format!("{path} is a directory"));
10215×
                    }
10315×
                    let mut parent = path;
10416×
                    while let Some((p, _)) = parent.rsplit_once('/') {
1052×
                        if base_snapshot
1062×
                            .files
1072×
                            .get(p)
1082×
                            .is_some_and(|f| f.kind != NodeKind::Dir)
109
                        {
1101×
                            return fail(format!("{p} is a file, not a directory"));
1111×
                        }
1121×
                        parent = p;
113
                    }
11414×
                    entries.insert(
11514×
                        path.to_string(),
11614×
                        FsEntry::new(NodeKind::File, exec, bytes.clone()),
117
                    );
118
                }
119
                None => {
1201×
                    if entries.remove(path).is_none() {
121
                        // Removing a directory removes everything below it.
1221×
                        let prefix = format!("{path}/");
1231×
                        let before = entries.len();
1242×
                        entries.retain(|p, _| !p.starts_with(&prefix));
1251×
                        if entries.len() == before {
1260×
                            return fail(format!("{path} does not exist"));
1271×
                        }
1280×
                    }
129
                }
130
            }
131
        }
13217×
        for (path, pin) in pins {
1333×
            let path = path.trim_matches('/');
1343×
            match base_snapshot.files.get(path) {
1352×
                Some(f) if f.kind == NodeKind::Subrepo => {}
1361×
                Some(_) => return fail(format!("{path} is not a subrepo")),
1371×
                None => return fail(format!("{path} does not exist")),
138
            }
1391×
            entries.insert(
1401×
                path.to_string(),
1411×
                FsEntry::new(NodeKind::Subrepo, false, pin.encode()),
142
            );
143
        }
144
        // A path the user writes settles its replace conflict, if any: they
145
        // chose that content (spec §9.3).
14615×
        let written: std::collections::HashSet<String> = edits
14715×
            .iter()
14815×
            .filter(|e| e.content.is_some())
14915×
            .map(|e| e.path.trim_matches('/').to_string())
15015×
            .chain(pins.iter().map(|(p, _)| p.trim_matches('/').to_string()))
15115×
            .collect();
15215×
        let built = self.build_from_entries_resolving(
15315×
            &base,
15415×
            &base_snapshot,
15515×
            HashMap::new(),
15615×
            &entries,
15715×
            &written,
1580×
        )?;
15915×
        if let Some(s) = built
16015×
            .statuses
16115×
            .iter()
16216×
            .find(|s| s.status == ChangeStatus::ConflictEdited)
163
        {
1641×
            return fail(format!("{} still contains conflict markers", s.path));
16514×
        }
16614×
        if built.tree_ops.is_empty() && built.edits.is_empty() {
1671×
            return fail("the edits change nothing");
16813×
        }
16913×
        let mut change = Change {
170
            version: crate::objects::CHANGE_VERSION,
17113×
            change_id: ChangeId::random(),
17213×
            deps: vec![],
17313×
            author,
17413×
            hlc: Hlc::now(),
17513×
            message: message.trim_end().to_string(),
17613×
            tree_ops: built.tree_ops,
17713×
            edits: built.edits,
17813×
            kind: if built.resolves {
1793×
                ChangeKind::Resolution
180
            } else {
18110×
                ChangeKind::Normal
182
            },
183
        };
18413×
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&change, &base);
18513×
        deps.extend(built.contributors.iter().copied());
18613×
        change.deps = deps.into_iter().collect();
18713×
        change.normalize();
18813×
        Ok(change)
18919×
    }
190
191
    /// A reference update of `bookmark` to `heads`, attested by `signer`
192
    /// when given (the attestation a push would carry, spec §14.4).
19310×
    pub fn host_ref_update(
19410×
        &self,
19510×
        bookmark: &str,
19610×
        heads: &[ChangeHash],
19710×
        signer: Option<(&KeyId, &SecretKey)>,
19810×
    ) -> Result<RefUpdate> {
19910×
        let view = self.view()?;
20010×
        let empty = self.store.put_state(&StateObject::new(vec![]))?;
20110×
        let old_state = view.bookmarks.get(bookmark).copied().unwrap_or(empty);
20210×
        let new_state = self.store.put_state(&StateObject::new(heads.to_vec()))?;
20310×
        let attestation = match signer {
2049×
            Some((key, sk)) => {
2059×
                let serial = self
2069×
                    .latest_ref_attest(bookmark)?
2079×
                    .and_then(|(_, a)| match a.attest.body {
2089×
                        AttestBody::Ref { serial, .. } => Some(serial),
2090×
                        _ => None,
2109×
                    })
2119×
                    .map(|s| s + 1)
2129×
                    .unwrap_or(1);
2139×
                let attest = Attest {
2149×
                    repo: self.id()?,
2159×
                    hlc: Hlc::now(),
2169×
                    body: AttestBody::Ref {
2179×
                        name: bookmark.to_string(),
2189×
                        old_state,
2199×
                        new_state,
2209×
                        serial,
2219×
                    },
222
                };
2239×
                let r = attest.hash();
2249×
                let sig = sk.sign(Domain::Attest, key, &r);
2259×
                Some(SignedAttest {
2269×
                    attest,
2279×
                    sigs: vec![sig],
2289×
                })
229
            }
2301×
            None => None,
231
        };
23210×
        Ok(RefUpdate {
23310×
            name: bookmark.to_string(),
23410×
            old_state,
23510×
            new_state,
23610×
            force: false,
23710×
            attestation,
23810×
        })
23910×
    }
240
241
    /// Create tag `name` for the state of `heads` as a host does for a
242
    /// signed-in user (oxygenhub#295): attested by `signer` (a delegate of
243
    /// the user, or the host's own key), with `identities` it needs, and
244
    /// received as a pushed tag is, so the same policy checks apply.
2450×
    pub fn host_tag_create(
2460×
        &self,
2470×
        name: &str,
2480×
        heads: &[ChangeHash],
2490×
        message: &str,
2500×
        signer: (&KeyId, &SecretKey),
2510×
        identities: &[Identity],
2520×
    ) -> Result<PushReport> {
2530×
        crate::objects::validate_name(name)
2540×
            .map_err(|e| RepoError::Msg(format!("tag name: {e}")))?;
2550×
        let st = self.load_state_history(heads)?;
2560×
        let heads = st.maximal(heads);
2570×
        if heads.is_empty() {
2580×
            return fail("a tag needs at least one change");
2590×
        }
2600×
        let state_object = StateObject::new(heads.clone());
2610×
        let state = state_object.hash();
2620×
        let body = AttestBody::Tag {
2630×
            name: name.into(),
2640×
            state,
2650×
            message: message.into(),
2660×
        };
2670×
        let mut bundle = self.hosted_bundle(identities);
2680×
        bundle
2690×
            .objects
2700×
            .push((ObjectType::State, state_object.encode()));
2710×
        let t = crate::protocol::TagRef::new(
2720×
            name.into(),
2730×
            state,
2740×
            heads,
2750×
            Some(self.host_attest(body, signer)?),
276
        );
2770×
        self.receive_push_report(bundle, vec![], None, vec![t])
2780×
    }
279
280
    /// Withdraw tag `name` as a host does for a signed-in user
281
    /// (oxygenhub#301): an `untag` attestation by `signer`, received as a
282
    /// pushed withdrawal is.
2830×
    pub fn host_tag_withdrawal(
2840×
        &self,
2850×
        name: &str,
2860×
        reason: &str,
2870×
        signer: (&KeyId, &SecretKey),
2880×
        identities: &[Identity],
2890×
    ) -> Result<PushReport> {
2900×
        let Some(state) = self.view()?.tags.get(name).copied() else {
2910×
            return fail(format!("no tag {name:?}"));
292
        };
2930×
        let body = AttestBody::Untag {
2940×
            name: name.into(),
2950×
            state,
2960×
            reason: reason.into(),
2970×
        };
2980×
        let t = crate::protocol::TagRef::new(
2990×
            name.into(),
3000×
            state,
3010×
            vec![],
3020×
            Some(self.host_attest(body, signer)?),
303
        );
3040×
        self.receive_push_report(self.hosted_bundle(identities), vec![], None, vec![t])
3050×
    }
306
3070×
    fn host_attest(
3080×
        &self,
3090×
        body: AttestBody,
3100×
        (key, sk): (&KeyId, &SecretKey),
3110×
    ) -> Result<SignedAttest> {
3120×
        let attest = Attest {
3130×
            repo: self.id()?,
3140×
            hlc: Hlc::now(),
3150×
            body,
316
        };
3170×
        let sig = sk.sign(Domain::Attest, key, &attest.hash());
3180×
        Ok(SignedAttest {
3190×
            attest,
3200×
            sigs: vec![sig],
3210×
        })
3220×
    }
323
3240×
    fn hosted_bundle(&self, identities: &[Identity]) -> Bundle {
3250×
        let mut bundle = Bundle::default();
3260×
        for id in identities {
3270×
            bundle.objects.push((ObjectType::Identity, id.encode()));
3280×
        }
3290×
        bundle
3300×
    }
331
332
    /// Apply a host-made update as if it had been pushed: the new change
333
    /// (with its signatures) if any, the identities it needs, and the
334
    /// reference update, all through `receive_push_report`, so the same
335
    /// authentication, compare-and-swap and policy checks apply.
33610×
    pub fn apply_hosted(
33710×
        &self,
33810×
        change: Option<(Change, Vec<Signature>)>,
33910×
        identities: &[Identity],
34010×
        update: RefUpdate,
34110×
    ) -> Result<PushReport> {
34210×
        let mut bundle = Bundle::default();
34317×
        for id in identities {
34417×
            bundle.objects.push((ObjectType::Identity, id.encode()));
34517×
        }
34610×
        if let Ok(st) = self.store.get_state(&update.new_state) {
34710×
            bundle.objects.push((ObjectType::State, st.encode()));
34810×
        }
34910×
        if let Some((c, sigs)) = change {
3509×
            bundle.changes.push((c, sigs));
3519×
        }
35210×
        self.receive_push_report(bundle, vec![update], None, vec![])
35310×
    }
354
}
355
356
#[cfg(test)]
357
mod tests {
358
    use super::*;
359
    use crate::identity::SecretKey;
360
36110×
    fn hermetic() {
362
        static ONCE: std::sync::Once = std::sync::Once::new();
363
        static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
36410×
        ONCE.call_once(|| {
36510×
            let d = tempfile::tempdir().unwrap();
36610×
            std::env::set_var("OXY_HOME", d.path());
36710×
            DIR.set(d).ok();
36810×
        });
36910×
    }
370
37112×
    fn write(root: &Path, rel: &str, content: &[u8]) {
37212×
        let p = root.join(rel);
37312×
        fs::create_dir_all(p.parent().unwrap()).unwrap();
37412×
        fs::write(p, content).unwrap();
37512×
    }
376
377
    /// A user repository pushed to a bare host, and a hub key the user
378
    /// has delegated to. Returns (host, user, hub key, hub secret).
37910×
    fn setup(
38010×
        root: &Path,
38110×
        with_policy: bool,
38210×
        delegate: bool,
38310×
    ) -> (Repo, Repo, KeyId, SecretKey, Identity) {
38410×
        hermetic();
38510×
        let host_dir = root.join("host");
38610×
        Repo::init_bare_unbound(&host_dir).unwrap();
38710×
        let work = root.join("work");
38810×
        let mut user = Repo::init(&work).unwrap();
38910×
        user.create_identity("Andy", "[email protected]", IdentityScope::Repo)
39010×
            .unwrap();
39110×
        user.config.set("core.sign", "change").unwrap();
39210×
        user.config
39310×
            .set("remote.origin.url", host_dir.to_str().unwrap())
39410×
            .unwrap();
39510×
        user.save_config().unwrap();
39610×
        let hub_sk = SecretKey::generate();
39710×
        let hub_id = hub_sk.identity("Hub", "hub@example", 1);
39810×
        let hub_key = hub_id.hash();
39910×
        if with_policy {
4005×
            user.policy_init().unwrap();
4015×
        }
40210×
        if delegate {
4033×
            user.delegate_add(hub_id.clone(), Scope::CHANGES | Scope::REFS, None)
4043×
                .unwrap();
4057×
        }
40610×
        write(&work, "a.txt", b"one\ntwo\n");
40710×
        user.snapshot("t").unwrap();
40810×
        user.commit("first", &[]).unwrap();
40910×
        let r = user.push("origin", false).unwrap();
41010×
        assert!(r.iter().all(|x| x.ok), "{r:?}");
41110×
        (
41210×
            Repo::open(&host_dir).unwrap(),
41310×
            user,
41410×
            hub_key,
41510×
            hub_sk,
41610×
            hub_id,
41710×
        )
41810×
    }
419
420
    #[test]
4211×
    fn a_host_edit_signed_by_a_delegate_lands() {
4221×
        let dir = tempfile::tempdir().unwrap();
4231×
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
4241×
        let author = user.config.author_key.unwrap();
4251×
        let heads = host.bookmark_heads().unwrap();
4261×
        let change = host
4271×
            .build_change(
4281×
                &heads,
4291×
                &[
4301×
                    FileEdit {
4311×
                        path: "a.txt".into(),
4321×
                        content: Some(b"one\ntwo\nthree\n".to_vec()),
4331×
                    },
4341×
                    FileEdit {
4351×
                        path: "docs/new.md".into(),
4361×
                        content: Some(b"# New\n".to_vec()),
4371×
                    },
4381×
                ],
4391×
                "Edit on the web",
4401×
                author,
441
            )
4421×
            .unwrap();
4431×
        assert_eq!(change.author, author);
4441×
        assert_eq!(change.kind, ChangeKind::Normal);
4451×
        let hash = change.hash();
4461×
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
4471×
        let mut new_heads = heads.clone();
4481×
        new_heads.push(hash);
4491×
        let update = host
4501×
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
4511×
            .unwrap();
4521×
        let ids = vec![hub_id, user.identity(&author).unwrap()];
4531×
        let report = host
4541×
            .apply_hosted(Some((change, vec![sig])), &ids, update)
4551×
            .unwrap();
4561×
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
4571×
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
4581×
        assert_eq!(
4591×
            host.file_at("a.txt", Some(&[hash])).unwrap(),
460
            b"one\ntwo\nthree\n"
461
        );
4621×
        assert_eq!(
4631×
            host.file_at("docs/new.md", Some(&[hash])).unwrap(),
464
            b"# New\n"
465
        );
466
        // Authored by the user, authenticated by the hub key as delegate.
4671×
        assert!(
4681×
            matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Valid { key, name, .. } if key == hub_key && name == "Hub")
469
        );
4701×
        let _ = new_heads;
471
        // Bad paths and empty edits are refused.
4721×
        assert!(host
4731×
            .build_change(
4741×
                &[hash],
4751×
                &[FileEdit {
4761×
                    path: "../x".into(),
4771×
                    content: Some(vec![])
4781×
                }],
4791×
                "m",
4801×
                author
4811×
            )
4821×
            .is_err());
4831×
        assert!(
4841×
            host.build_change(
4851×
                &[hash],
4861×
                &[FileEdit {
4871×
                    path: "a.txt".into(),
4881×
                    content: Some(b"one\ntwo\nthree\n".to_vec())
4891×
                }],
4901×
                "m",
4911×
                author
4921×
            )
4931×
            .is_err(),
494
            "no-op edits are refused"
495
        );
4961×
        assert!(
4971×
            host.build_change(
4981×
                &[hash],
4991×
                &[FileEdit {
5001×
                    path: "a.txt/b".into(),
5011×
                    content: Some(vec![])
5021×
                }],
5031×
                "m",
5041×
                author
5051×
            )
5061×
            .is_err(),
507
            "a file inside a file"
508
        );
5091×
        let rm = host
5101×
            .build_change(
5111×
                &[hash],
5121×
                &[FileEdit {
5131×
                    path: "docs".into(),
5141×
                    content: None,
5151×
                }],
5161×
                "Remove docs",
5171×
                author,
518
            )
5191×
            .unwrap();
5201×
        assert!(!rm.tree_ops.is_empty());
5211×
    }
522
523
    #[test]
5241×
    fn a_host_edit_by_a_key_that_is_not_a_delegate_is_refused() {
5251×
        let dir = tempfile::tempdir().unwrap();
5261×
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, false);
5271×
        let author = user.config.author_key.unwrap();
5281×
        let heads = host.bookmark_heads().unwrap();
5291×
        let change = host
5301×
            .build_change(
5311×
                &heads,
5321×
                &[FileEdit {
5331×
                    path: "a.txt".into(),
5341×
                    content: Some(b"x\n".to_vec()),
5351×
                }],
5361×
                "Edit",
5371×
                author,
538
            )
5391×
            .unwrap();
5401×
        let hash = change.hash();
5411×
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
5421×
        let update = host
5431×
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
5441×
            .unwrap();
5451×
        let report = host
5461×
            .apply_hosted(
5471×
                Some((change, vec![sig])),
5481×
                &[hub_id, user.identity(&author).unwrap()],
5491×
                update,
550
            )
5511×
            .unwrap();
552
        // Without a policy the host accepts unauthenticated changes, but the
553
        // signature does not verify as the author's: it stays unauthenticated.
5541×
        assert!(report.results.iter().all(|r| r.ok));
5551×
        assert!(
5561×
            matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Invalid(why) if why.contains("neither the author"))
557
        );
5581×
    }
559
560
    #[test]
5611×
    fn under_a_policy_a_delegate_may_update_the_reference_and_a_stranger_may_not() {
5621×
        let dir = tempfile::tempdir().unwrap();
5631×
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), true, true);
5641×
        assert!(host.current_policy().unwrap().is_some());
5651×
        let author = user.config.author_key.unwrap();
5661×
        let heads = host.bookmark_heads().unwrap();
5671×
        let change = host
5681×
            .build_change(
5691×
                &heads,
5701×
                &[FileEdit {
5711×
                    path: "a.txt".into(),
5721×
                    content: Some(b"one\n".to_vec()),
5731×
                }],
5741×
                "Trim",
5751×
                author,
576
            )
5771×
            .unwrap();
5781×
        let hash = change.hash();
5791×
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
5801×
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
5811×
        let update = host
5821×
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
5831×
            .unwrap();
5841×
        let report = host
5851×
            .apply_hosted(Some((change, vec![sig])), &ids, update)
5861×
            .unwrap();
5871×
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
5881×
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
589
590
        // A stranger's key cannot move the reference, even with the user as author.
5911×
        let stranger = SecretKey::generate();
5921×
        let sid = stranger.identity("Mallory", "m@example", 1);
5931×
        let skey = sid.hash();
5941×
        let change2 = host
5951×
            .build_change(
5961×
                &[hash],
5971×
                &[FileEdit {
5981×
                    path: "a.txt".into(),
5991×
                    content: Some(b"evil\n".to_vec()),
6001×
                }],
6011×
                "Evil",
6021×
                author,
603
            )
6041×
            .unwrap();
6051×
        let h2 = change2.hash();
6061×
        let sig2 = stranger.sign(Domain::Change, &skey, &h2);
6071×
        let update = host
6081×
            .host_ref_update("main", &[h2], Some((&skey, &stranger)))
6091×
            .unwrap();
6101×
        let report = host
6111×
            .apply_hosted(Some((change2, vec![sig2])), &[sid], update)
6121×
            .unwrap();
6131×
        assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results);
6141×
        assert_eq!(
6151×
            host.bookmark_heads().unwrap(),
6161×
            vec![hash],
617
            "the reference did not move"
618
        );
619
        // An unsigned update is refused under a policy too.
6201×
        let update = host.host_ref_update("main", &[hash], None).unwrap();
6211×
        let report = host.apply_hosted(None, &[], update).unwrap();
6221×
        assert!(report.results.iter().all(|r| !r.ok));
6231×
    }
624
625
    /// Agent doc §2: an agent's key signs changes for its owner and nothing
626
    /// more. Its reference attestation is refused even though its owner's
627
    /// would pass, and a seal it makes authenticates nothing.
628
    #[test]
6291×
    fn a_changes_only_delegate_signs_changes_but_cannot_seal_or_move_a_reference() {
6301×
        let dir = tempfile::tempdir().unwrap();
6311×
        let (host, mut user, _, _, _) = setup(dir.path(), true, false);
6321×
        let agent_sk = SecretKey::generate();
6331×
        let agent_id = agent_sk.identity("aider on a laptop", "agent@example", 1);
6341×
        let agent = agent_id.hash();
6351×
        user.delegate_add(agent_id.clone(), Scope::CHANGES, None)
6361×
            .unwrap();
637
        // The successor identity reaches the host with the rotated policy.
6381×
        assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok));
6391×
        let author = user.config.author_key.unwrap();
6401×
        let ids = vec![agent_id.clone(), user.identity(&author).unwrap()];
6411×
        let heads = host.bookmark_heads().unwrap();
6421×
        let change = host
6431×
            .build_change(
6441×
                &heads,
6451×
                &[FileEdit {
6461×
                    path: "a.txt".into(),
6471×
                    content: Some(b"by the agent\n".to_vec()),
6481×
                }],
6491×
                "Agent edit",
6501×
                author,
651
            )
6521×
            .unwrap();
6531×
        let hash = change.hash();
6541×
        let sig = agent_sk.sign(Domain::Change, &agent, &hash);
655
        // Attested by the agent: refused, the reference stays.
6561×
        let update = host
6571×
            .host_ref_update("main", &[hash], Some((&agent, &agent_sk)))
6581×
            .unwrap();
6591×
        let report = host
6601×
            .apply_hosted(Some((change.clone(), vec![sig.clone()])), &ids, update)
6611×
            .unwrap();
6621×
        assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results);
6631×
        assert_eq!(host.bookmark_heads().unwrap(), heads);
664
        // Attested by the owner: the agent-signed change lands.
6651×
        let (owner, owner_sk) = user.secret_key().unwrap().unwrap();
6661×
        let update = host
6671×
            .host_ref_update("main", &[hash], Some((&owner, &owner_sk)))
6681×
            .unwrap();
6691×
        let report = host
6701×
            .apply_hosted(Some((change, vec![sig])), &ids, update)
6711×
            .unwrap();
6721×
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
6731×
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
6741×
        assert!(matches!(
6751×
            host.authenticate_change(&hash).unwrap(),
6761×
            SignatureStatus::Valid { key, sealed: false, .. } if key == agent
677
        ));
678
        // A seal by the agent is outside its scope.
6791×
        let change2 = host
6801×
            .build_change(
6811×
                &[hash],
6821×
                &[FileEdit {
6831×
                    path: "a.txt".into(),
6841×
                    content: Some(b"sealed by the agent\n".to_vec()),
6851×
                }],
6861×
                "Sealed",
6871×
                author,
688
            )
6891×
            .unwrap();
6901×
        let h2 = host.store().put_change(&change2).unwrap();
6911×
        let seal = host
6921×
            .store()
6931×
            .put_attest(&Attest::seal(host.id().unwrap(), vec![h2]))
6941×
            .unwrap();
6951×
        host.store()
6961×
            .put_signature(&seal, &agent_sk.sign(Domain::Attest, &agent, &seal))
6971×
            .unwrap();
6981×
        host.store().index_seal(&h2, &seal).unwrap();
6991×
        assert!(matches!(
7001×
            host.authenticate_change(&h2).unwrap(),
7011×
            SignatureStatus::Invalid(e) if e.contains("may not seal")
702
        ));
7031×
    }
704
705
    /// Agent doc §2 end to end from the command line's point of view: the
706
    /// agent's key is a changes-only delegate, and `author.signer` makes
707
    /// the owner's checkout sign with it while `author.key` stays the
708
    /// owner, so a commit carries the owner as author and the agent's
709
    /// signature, and a reference attestation by the agent is refused.
710
    #[test]
7111×
    fn an_agent_signs_its_owner_s_changes_through_author_signer() {
7121×
        let dir = tempfile::tempdir().unwrap();
7131×
        let (host, mut user, _, _, _) = setup(dir.path(), true, false);
7141×
        let owner = user.config.author_key.unwrap();
715
        // The agent makes its own key in its own home; the owner delegates.
7161×
        let agent_home = dir.path().join("agent-home");
7171×
        let mut agent_repo = Repo::init(&dir.path().join("agent-work"))
7181×
            .unwrap()
7191×
            .with_home(agent_home.clone());
7201×
        let (agent, agent_id) = agent_repo
7211×
            .create_identity("aider on a laptop", "agent@example", IdentityScope::User)
7221×
            .unwrap();
7231×
        user.delegate_add(agent_id.clone(), Scope::CHANGES, None)
7241×
            .unwrap();
7251×
        assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok));
7261×
        host.refresh().unwrap();
7271×
        let owner = {
7281×
            let _ = owner;
7291×
            user.config.author_key.unwrap()
730
        };
731
        // The owner's checkout, as the agent runs it: the agent's keys,
732
        // the owner as author, the agent as signer.
733
        // (The signer is the agent's own setting, in its user config: the
734
        // checkout's `author.key` is the owner's, in the repository.)
7351×
        let work = dir.path().join("work");
7361×
        let mut as_agent = Repo::open(&work).unwrap().with_home(agent_home);
7371×
        as_agent
7381×
            .set_user_config("author.signer", &agent.to_hex())
7391×
            .unwrap();
7401×
        as_agent.set_user_config("core.sign", "change").unwrap();
7411×
        assert_eq!(as_agent.config.author_key, Some(owner));
7421×
        assert_eq!(as_agent.signing_key(), Some(agent));
7431×
        write(&work, "a.txt", b"by the agent\n");
7441×
        as_agent.snapshot("t").unwrap();
7451×
        let h = as_agent.commit("Agent: edit a", &[]).unwrap();
7461×
        let meta = as_agent.store().get_change_meta(&h).unwrap();
7471×
        assert_eq!(meta.author, owner, "the owner stays the author");
7481×
        assert!(matches!(
7491×
            as_agent.verify_change(&h).unwrap(),
7501×
            SignatureStatus::Valid { key, .. } if key == agent
751
        ));
7521×
        assert_eq!(as_agent.revset("delegated()").unwrap().hashes, vec![h]);
753
        // The agent cannot push: its attestation of the reference is
754
        // refused under the policy. The owner can.
7551×
        let r = as_agent.push("origin", false).unwrap();
7561×
        host.refresh().unwrap();
7571×
        assert!(r.iter().all(|x| !x.ok), "{r:?}");
7581×
        assert!(!host.bookmark_heads().unwrap().contains(&h));
759
        // The owner's own handle (its key directory was fixed at `setup`;
760
        // the environment's `OXY_HOME` may have moved since), which reads
761
        // the head again to see the agent's commit.
7621×
        user.refresh().unwrap();
7631×
        let r = user.push("origin", false).unwrap();
7641×
        host.refresh().unwrap();
7651×
        assert!(r.iter().all(|x| x.ok), "{r:?}");
7661×
        assert!(host.bookmark_heads().unwrap().contains(&h));
7671×
        assert!(matches!(
7681×
            host.authenticate_change(&h).unwrap(),
7691×
            SignatureStatus::Valid { key, .. } if key == agent
770
        ));
7711×
    }
772
773
    /// oxygenhub#129: a delegate that may move references makes a bookmark
774
    /// at its owner's change, signing no change of its own. The push
775
    /// carries its identity, so the host can check the move (it was
776
    /// refused as "unknown key" until the delegate had pushed a change).
777
    #[test]
7781×
    fn a_delegate_s_first_push_of_a_bookmark_carries_its_identity() {
7791×
        let dir = tempfile::tempdir().unwrap();
7801×
        let (host, mut user, _, _, _) = setup(dir.path(), true, false);
7811×
        let agent_home = dir.path().join("agent-home");
7821×
        let mut agent_repo = Repo::init(&dir.path().join("agent-work"))
7831×
            .unwrap()
7841×
            .with_home(agent_home.clone());
7851×
        let (agent, agent_id) = agent_repo
7861×
            .create_identity("agent", "agent@example", IdentityScope::User)
7871×
            .unwrap();
7881×
        user.delegate_add(agent_id, Scope::CHANGES | Scope::REFS, None)
7891×
            .unwrap();
7901×
        assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok));
7911×
        let work = dir.path().join("work");
7921×
        let mut as_agent = Repo::open(&work).unwrap().with_home(agent_home);
7931×
        as_agent
7941×
            .set_user_config("author.signer", &agent.to_hex())
7951×
            .unwrap();
7961×
        assert!(
7971×
            host.store().get_identity(&agent).is_err(),
798
            "the host has never seen the agent"
799
        );
8001×
        let heads = as_agent.bookmark_heads().unwrap();
8011×
        as_agent.bookmark_create("feature", Some(&heads)).unwrap();
8021×
        let r = as_agent.push_bookmark("origin", "feature", false).unwrap();
8031×
        assert!(r.iter().all(|x| x.ok), "{r:?}");
8041×
        assert!(
8051×
            host.store().get_identity(&agent).is_ok(),
806
            "its identity came with the move"
807
        );
8081×
    }
809
810
    /// oxygenhub#266: a bookmark whose latest attestation a delegate made,
811
    /// signing no change, is fetched by a third party. The fetch bundle
812
    /// carries the delegate's identity even when it asks for no change,
813
    /// so a client as it was (import the bundle, then check each
814
    /// bookmark's attestation) verifies the move instead of failing the
815
    /// whole fetch with "unknown key".
816
    #[test]
8171×
    fn a_bookmark_attested_by_a_key_that_signed_no_change_is_fetched() {
8181×
        let dir = tempfile::tempdir().unwrap();
8191×
        let (host, mut user, _, _, _) = setup(dir.path(), true, false);
8201×
        let agent_home = dir.path().join("agent-home");
8211×
        let mut agent_repo = Repo::init(&dir.path().join("agent-work"))
8221×
            .unwrap()
8231×
            .with_home(agent_home.clone());
8241×
        let (agent, agent_id) = agent_repo
8251×
            .create_identity("agent", "agent@example", IdentityScope::User)
8261×
            .unwrap();
8271×
        user.delegate_add(agent_id, Scope::CHANGES | Scope::REFS, None)
8281×
            .unwrap();
8291×
        assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok));
8301×
        let mut as_agent = Repo::open(&dir.path().join("work"))
8311×
            .unwrap()
8321×
            .with_home(agent_home);
8331×
        as_agent
8341×
            .set_user_config("author.signer", &agent.to_hex())
8351×
            .unwrap();
8361×
        let heads = as_agent.bookmark_heads().unwrap();
8371×
        as_agent.bookmark_create("feature", Some(&heads)).unwrap();
8381×
        let r = as_agent.push_bookmark("origin", "feature", false).unwrap();
8391×
        assert!(r.iter().all(|x| x.ok), "{r:?}");
8401×
        host.refresh().unwrap();
8411×
        let (_, sa) = host.latest_ref_attest("feature").unwrap().unwrap();
8421×
        assert_eq!(sa.sigs[0].key, agent, "the agent attested the move");
843
844
        // What a fetch that names no change brings: the agent's identity.
8451×
        let remote = crate::protocol::LocalRemote::open(&dir.path().join("host")).unwrap();
8462×
        let carries = |b: &Bundle| {
8472×
            b.objects
8482×
                .iter()
8492×
                .any(|(t, body)| *t == ObjectType::Identity && hash_object(*t, body) == agent)
8502×
        };
8511×
        assert!(carries(
8521×
            &crate::protocol::Remote::fetch(&remote, &[], None).unwrap()
8531×
        ));
8541×
        assert!(carries(&host.bundle_for_filtered(&[], None).unwrap()));
855
856
        // A third party's clone takes both bookmarks, verified.
8571×
        let theirs = Repo::clone(
8581×
            dir.path().join("host").to_str().unwrap(),
8591×
            &dir.path().join("theirs"),
860
        )
8611×
        .unwrap();
8621×
        let tracked = theirs.view().unwrap().remotes["origin"].clone();
8631×
        assert_eq!(
8641×
            tracked.get("feature"),
8651×
            host.view().unwrap().bookmarks.get("feature")
866
        );
8671×
        assert!(theirs.store().get_identity(&agent).is_ok());
8681×
        let report = theirs.fetch("origin").unwrap();
8691×
        assert!(report.skipped.is_empty(), "{:?}", report.skipped);
8701×
    }
871
872
    /// Make `bookmark`'s latest attestation on `host` one signed as `key`
873
    /// with `sk`: by a key the host never saw (as a host that did not send
874
    /// the signer's identity looked), or a bad signature.
8753×
    fn reattest(host: &Repo, bookmark: &str, key: KeyId, sk: &SecretKey) {
8763×
        let state = host.view().unwrap().bookmarks[bookmark];
8773×
        let (_, last) = host.latest_ref_attest(bookmark).unwrap().unwrap();
878
        let AttestBody::Ref {
8793×
            old_state, serial, ..
8803×
        } = last.attest.body
881
        else {
8820×
            panic!("not a ref attestation")
883
        };
8843×
        let attest = Attest {
8853×
            repo: host.id().unwrap(),
8863×
            hlc: Hlc::now(),
8873×
            body: AttestBody::Ref {
8883×
                name: bookmark.into(),
8893×
                old_state,
8903×
                new_state: state,
8913×
                serial: serial + 1,
8923×
            },
8933×
        };
8943×
        let r = host.store().put_attest(&attest).unwrap();
8953×
        host.store()
8963×
            .put_signature(&r, &sk.sign(Domain::Attest, &key, &attest.hash()))
8973×
            .unwrap();
8983×
        host.set_ref_attest(bookmark, &r).unwrap();
8993×
    }
900
901
    /// oxygenhub#266: a bookmark whose attestation a client cannot verify
902
    /// (its signer unknown, or its signature bad) is left as it was, named
903
    /// with why, and the rest are fetched. The current bookmark is not
904
    /// skipped: a clone, fetch or pull that would take it fails.
905
    #[test]
9061×
    fn an_unverifiable_bookmark_is_skipped_but_never_the_current_one() {
9071×
        let dir = tempfile::tempdir().unwrap();
9081×
        let (host, user, _, _, _) = setup(dir.path(), false, false);
9091×
        let author = user.config.author_key.unwrap();
9101×
        let heads = user.bookmark_heads().unwrap();
9113×
        for b in ["unknown", "forged", "fine"] {
9123×
            user.bookmark_create(b, Some(&heads)).unwrap();
9133×
            let r = user.push_bookmark("origin", b, false).unwrap();
9143×
            assert!(r.iter().all(|x| x.ok), "{r:?}");
915
        }
9161×
        host.refresh().unwrap();
9171×
        let stranger_sk = SecretKey::generate();
9181×
        let stranger = stranger_sk.identity("s", "s@example", 1).hash();
9191×
        reattest(&host, "unknown", stranger, &stranger_sk);
9201×
        reattest(&host, "forged", author, &stranger_sk);
921
9221×
        let url = dir.path().join("host").to_string_lossy().to_string();
9231×
        let (theirs, skipped) =
9241×
            Repo::clone_reporting(&url, &dir.path().join("theirs"), None, &[], None).unwrap();
9252×
        let names: Vec<&str> = skipped.iter().map(|(n, _)| n.as_str()).collect();
9261×
        assert_eq!(names, ["forged", "unknown"]);
9271×
        assert!(skipped[1].1.contains("unknown key"), "{skipped:?}");
9281×
        assert!(skipped[1].1.contains("origin/unknown"), "{skipped:?}");
9291×
        let tracked = theirs.view().unwrap().remotes["origin"].clone();
9301×
        assert!(tracked.contains_key("main") && tracked.contains_key("fine"));
9311×
        assert!(!tracked.contains_key("unknown") && !tracked.contains_key("forged"));
9321×
        assert_eq!(theirs.config.bookmark, "main");
9331×
        assert_eq!(theirs.fetch("origin").unwrap().skipped.len(), 2);
9341×
        assert_eq!(theirs.pull("origin").unwrap().skipped.len(), 2);
935
936
        // The current bookmark's attestation unverifiable: a fetch and a
937
        // pull fail, and so does a fresh clone.
9381×
        reattest(&host, "main", stranger, &stranger_sk);
9393×
        for err in [
9401×
            theirs.fetch("origin").unwrap_err(),
9411×
            theirs.pull("origin").unwrap_err(),
9421×
            Repo::clone(&url, &dir.path().join("again"))
9431×
                .err()
9441×
                .expect("the clone fails"),
9451×
        ] {
9463×
            let err = err.to_string();
9473×
            assert!(
9483×
                err.contains("attestation for origin/main: unknown key"),
949
                "{err}"
950
            );
951
        }
9521×
    }
953
954
    /// A delegation past its expiry authenticates nothing made after it,
955
    /// and the delegator may revoke the key; a stranger's key, they may not.
956
    #[test]
9571×
    fn an_expired_delegation_is_refused_and_the_delegator_may_revoke() {
9581×
        let dir = tempfile::tempdir().unwrap();
9591×
        let (_, mut user, _, _, _) = setup(dir.path(), false, false);
9601×
        let agent_sk = SecretKey::generate();
9611×
        let agent_id = agent_sk.identity("agent", "agent@example", 1);
9621×
        let agent = agent_id.hash();
9631×
        let yesterday = Hlc::now().wall_ms - 24 * 3600 * 1000;
9641×
        user.delegate_add(agent_id, Scope::CHANGES, Some(yesterday))
9651×
            .unwrap();
9661×
        user.config.set("core.sign", "seal").unwrap();
9671×
        user.save_config().unwrap();
9681×
        write(&dir.path().join("work"), "a.txt", b"later\n");
9691×
        user.snapshot("t").unwrap();
9701×
        let h = user.commit("after the delegation ended", &[]).unwrap();
9711×
        user.store()
9721×
            .put_signature(&h, &agent_sk.sign(Domain::Change, &agent, &h))
9731×
            .unwrap();
9741×
        let status = user.verify_change(&h).unwrap();
9751×
        assert!(
9761×
            matches!(&status, SignatureStatus::Invalid(e) if e.contains("expired")),
977
            "{status:?}"
978
        );
979
        // No policy here, so the user is no root: only delegation lets them revoke.
9801×
        let stranger = SecretKey::generate().identity("x", "x@example", 1).hash();
9811×
        assert!(user.revoke_identity(&stranger, "").is_err());
9821×
        assert!(user.revoke_identity(&agent, "session over").is_ok());
9831×
        assert!(user.is_revoked(&agent).unwrap());
9841×
        let r = user.delegate_remove(&agent).unwrap();
9851×
        assert!(user.identity(&r.new).unwrap().delegates.is_empty());
9861×
    }
987
988
    #[test]
9891×
    fn a_host_edit_that_removes_markers_resolves_the_conflict() {
9901×
        let dir = tempfile::tempdir().unwrap();
9911×
        let (host, mut user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
9921×
        let author = user.config.author_key.unwrap();
9931×
        let base = host.bookmark_heads().unwrap();
994
        // Two independent edits of the same line: a conflict in the union.
9951×
        let a = host
9961×
            .build_change(
9971×
                &base,
9981×
                &[FileEdit {
9991×
                    path: "a.txt".into(),
10001×
                    content: Some(b"ONE\ntwo\n".to_vec()),
10011×
                }],
10021×
                "A",
10031×
                author,
1004
            )
10051×
            .unwrap();
10061×
        let b = host
10071×
            .build_change(
10081×
                &base,
10091×
                &[FileEdit {
10101×
                    path: "a.txt".into(),
10111×
                    content: Some(b"uno\ntwo\n".to_vec()),
10121×
                }],
10131×
                "B",
10141×
                author,
1015
            )
10161×
            .unwrap();
10171×
        let (ha, hb) = (a.hash(), b.hash());
10181×
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
10192×
        for (c, h) in [(a, ha), (b, hb)] {
10202×
            let sig = hub_sk.sign(Domain::Change, &hub_key, &h);
10212×
            let mut heads = host.bookmark_heads().unwrap();
10222×
            heads.retain(|x| !base.contains(x));
10232×
            heads.push(h);
10242×
            let update = host
10252×
                .host_ref_update("main", &heads, Some((&hub_key, &hub_sk)))
10262×
                .unwrap();
10272×
            let r = host
10282×
                .apply_hosted(Some((c, vec![sig])), &ids, update)
10292×
                .unwrap();
10302×
            assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
1031
        }
10321×
        let heads = host.bookmark_heads().unwrap();
10331×
        assert_eq!(heads.len(), 2);
10341×
        let marked = String::from_utf8(host.file_at("a.txt", Some(&heads)).unwrap()).unwrap();
10351×
        assert!(marked.contains("<<<<<<< oxy conflict"), "{marked}");
1036
        // Saving with markers left in is refused; saving without them resolves.
10371×
        assert!(host
10381×
            .build_change(
10391×
                &heads,
10401×
                &[FileEdit {
10411×
                    path: "a.txt".into(),
10421×
                    content: Some(format!("{marked}x\n").into_bytes())
10431×
                }],
10441×
                "half",
10451×
                author
10461×
            )
10471×
            .is_err());
10481×
        let res = host
10491×
            .build_change(
10501×
                &heads,
10511×
                &[FileEdit {
10521×
                    path: "a.txt".into(),
10531×
                    content: Some(b"One\ntwo\n".to_vec()),
10541×
                }],
10551×
                "Resolve",
10561×
                author,
1057
            )
10581×
            .unwrap();
10591×
        assert_eq!(res.kind, ChangeKind::Resolution);
10601×
        assert!(
10611×
            res.deps.contains(&ha) && res.deps.contains(&hb),
1062
            "a resolution depends on both sides"
1063
        );
10641×
        let hr = res.hash();
10651×
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hr);
10661×
        let update = host
10671×
            .host_ref_update("main", &[hr], Some((&hub_key, &hub_sk)))
10681×
            .unwrap();
10691×
        let r = host
10701×
            .apply_hosted(Some((res, vec![sig])), &ids, update)
10711×
            .unwrap();
10721×
        assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
10731×
        assert_eq!(host.file_at("a.txt", Some(&[hr])).unwrap(), b"One\ntwo\n");
10741×
        assert!(!host
10751×
            .load_state(&[hr])
10761×
            .unwrap()
10771×
            .materialize()
10781×
            .is_conflicted());
10791×
        let _ = &mut user;
10801×
    }
1081
}