OXYGENHUB

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

686 of 693 lines run, 32 of 47 functions · run #341 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
    /// Apply a host-made update as if it had been pushed: the new change
242
    /// (with its signatures) if any, the identities it needs, and the
243
    /// reference update, all through `receive_push_report`, so the same
244
    /// authentication, compare-and-swap and policy checks apply.
24510×
    pub fn apply_hosted(
24610×
        &self,
24710×
        change: Option<(Change, Vec<Signature>)>,
24810×
        identities: &[Identity],
24910×
        update: RefUpdate,
25010×
    ) -> Result<PushReport> {
25110×
        let mut bundle = Bundle::default();
25217×
        for id in identities {
25317×
            bundle.objects.push((ObjectType::Identity, id.encode()));
25417×
        }
25510×
        if let Ok(st) = self.store.get_state(&update.new_state) {
25610×
            bundle.objects.push((ObjectType::State, st.encode()));
25710×
        }
25810×
        if let Some((c, sigs)) = change {
2599×
            bundle.changes.push((c, sigs));
2609×
        }
26110×
        self.receive_push_report(bundle, vec![update], None, vec![])
26210×
    }
263
}
264
265
#[cfg(test)]
266
mod tests {
267
    use super::*;
268
    use crate::identity::SecretKey;
269
2708×
    fn hermetic() {
271
        static ONCE: std::sync::Once = std::sync::Once::new();
272
        static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
2738×
        ONCE.call_once(|| {
2741×
            let d = tempfile::tempdir().unwrap();
2751×
            std::env::set_var("OXY_HOME", d.path());
2761×
            DIR.set(d).ok();
2771×
        });
2788×
    }
279
28010×
    fn write(root: &Path, rel: &str, content: &[u8]) {
28110×
        let p = root.join(rel);
28210×
        fs::create_dir_all(p.parent().unwrap()).unwrap();
28310×
        fs::write(p, content).unwrap();
28410×
    }
285
286
    /// A user repository pushed to a bare host, and a hub key the user
287
    /// has delegated to. Returns (host, user, hub key, hub secret).
2888×
    fn setup(
2898×
        root: &Path,
2908×
        with_policy: bool,
2918×
        delegate: bool,
2928×
    ) -> (Repo, Repo, KeyId, SecretKey, Identity) {
2938×
        hermetic();
2948×
        let host_dir = root.join("host");
2958×
        Repo::init_bare_unbound(&host_dir).unwrap();
2968×
        let work = root.join("work");
2978×
        let mut user = Repo::init(&work).unwrap();
2988×
        user.create_identity("Andy", "[email protected]", IdentityScope::Repo)
2998×
            .unwrap();
3008×
        user.config.set("core.sign", "change").unwrap();
3018×
        user.config
3028×
            .set("remote.origin.url", host_dir.to_str().unwrap())
3038×
            .unwrap();
3048×
        user.save_config().unwrap();
3058×
        let hub_sk = SecretKey::generate();
3068×
        let hub_id = hub_sk.identity("Hub", "hub@example", 1);
3078×
        let hub_key = hub_id.hash();
3088×
        if with_policy {
3094×
            user.policy_init().unwrap();
3104×
        }
3118×
        if delegate {
3123×
            user.delegate_add(hub_id.clone(), Scope::CHANGES | Scope::REFS, None)
3133×
                .unwrap();
3145×
        }
3158×
        write(&work, "a.txt", b"one\ntwo\n");
3168×
        user.snapshot("t").unwrap();
3178×
        user.commit("first", &[]).unwrap();
3188×
        let r = user.push("origin", false).unwrap();
3198×
        assert!(r.iter().all(|x| x.ok), "{r:?}");
3208×
        (
3218×
            Repo::open(&host_dir).unwrap(),
3228×
            user,
3238×
            hub_key,
3248×
            hub_sk,
3258×
            hub_id,
3268×
        )
3278×
    }
328
329
    #[test]
3301×
    fn a_host_edit_signed_by_a_delegate_lands() {
3311×
        let dir = tempfile::tempdir().unwrap();
3321×
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
3331×
        let author = user.config.author_key.unwrap();
3341×
        let heads = host.bookmark_heads().unwrap();
3351×
        let change = host
3361×
            .build_change(
3371×
                &heads,
3381×
                &[
3391×
                    FileEdit {
3401×
                        path: "a.txt".into(),
3411×
                        content: Some(b"one\ntwo\nthree\n".to_vec()),
3421×
                    },
3431×
                    FileEdit {
3441×
                        path: "docs/new.md".into(),
3451×
                        content: Some(b"# New\n".to_vec()),
3461×
                    },
3471×
                ],
3481×
                "Edit on the web",
3491×
                author,
350
            )
3511×
            .unwrap();
3521×
        assert_eq!(change.author, author);
3531×
        assert_eq!(change.kind, ChangeKind::Normal);
3541×
        let hash = change.hash();
3551×
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
3561×
        let mut new_heads = heads.clone();
3571×
        new_heads.push(hash);
3581×
        let update = host
3591×
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
3601×
            .unwrap();
3611×
        let ids = vec![hub_id, user.identity(&author).unwrap()];
3621×
        let report = host
3631×
            .apply_hosted(Some((change, vec![sig])), &ids, update)
3641×
            .unwrap();
3651×
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
3661×
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
3671×
        assert_eq!(
3681×
            host.file_at("a.txt", Some(&[hash])).unwrap(),
369
            b"one\ntwo\nthree\n"
370
        );
3711×
        assert_eq!(
3721×
            host.file_at("docs/new.md", Some(&[hash])).unwrap(),
373
            b"# New\n"
374
        );
375
        // Authored by the user, authenticated by the hub key as delegate.
3761×
        assert!(
3771×
            matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Valid { key, name, .. } if key == hub_key && name == "Hub")
378
        );
3791×
        let _ = new_heads;
380
        // Bad paths and empty edits are refused.
3811×
        assert!(host
3821×
            .build_change(
3831×
                &[hash],
3841×
                &[FileEdit {
3851×
                    path: "../x".into(),
3861×
                    content: Some(vec![])
3871×
                }],
3881×
                "m",
3891×
                author
3901×
            )
3911×
            .is_err());
3921×
        assert!(
3931×
            host.build_change(
3941×
                &[hash],
3951×
                &[FileEdit {
3961×
                    path: "a.txt".into(),
3971×
                    content: Some(b"one\ntwo\nthree\n".to_vec())
3981×
                }],
3991×
                "m",
4001×
                author
4011×
            )
4021×
            .is_err(),
403
            "no-op edits are refused"
404
        );
4051×
        assert!(
4061×
            host.build_change(
4071×
                &[hash],
4081×
                &[FileEdit {
4091×
                    path: "a.txt/b".into(),
4101×
                    content: Some(vec![])
4111×
                }],
4121×
                "m",
4131×
                author
4141×
            )
4151×
            .is_err(),
416
            "a file inside a file"
417
        );
4181×
        let rm = host
4191×
            .build_change(
4201×
                &[hash],
4211×
                &[FileEdit {
4221×
                    path: "docs".into(),
4231×
                    content: None,
4241×
                }],
4251×
                "Remove docs",
4261×
                author,
427
            )
4281×
            .unwrap();
4291×
        assert!(!rm.tree_ops.is_empty());
4301×
    }
431
432
    #[test]
4331×
    fn a_host_edit_by_a_key_that_is_not_a_delegate_is_refused() {
4341×
        let dir = tempfile::tempdir().unwrap();
4351×
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, false);
4361×
        let author = user.config.author_key.unwrap();
4371×
        let heads = host.bookmark_heads().unwrap();
4381×
        let change = host
4391×
            .build_change(
4401×
                &heads,
4411×
                &[FileEdit {
4421×
                    path: "a.txt".into(),
4431×
                    content: Some(b"x\n".to_vec()),
4441×
                }],
4451×
                "Edit",
4461×
                author,
447
            )
4481×
            .unwrap();
4491×
        let hash = change.hash();
4501×
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
4511×
        let update = host
4521×
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
4531×
            .unwrap();
4541×
        let report = host
4551×
            .apply_hosted(
4561×
                Some((change, vec![sig])),
4571×
                &[hub_id, user.identity(&author).unwrap()],
4581×
                update,
459
            )
4601×
            .unwrap();
461
        // Without a policy the host accepts unauthenticated changes, but the
462
        // signature does not verify as the author's: it stays unauthenticated.
4631×
        assert!(report.results.iter().all(|r| r.ok));
4641×
        assert!(
4651×
            matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Invalid(why) if why.contains("neither the author"))
466
        );
4671×
    }
468
469
    #[test]
4701×
    fn under_a_policy_a_delegate_may_update_the_reference_and_a_stranger_may_not() {
4711×
        let dir = tempfile::tempdir().unwrap();
4721×
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), true, true);
4731×
        assert!(host.current_policy().unwrap().is_some());
4741×
        let author = user.config.author_key.unwrap();
4751×
        let heads = host.bookmark_heads().unwrap();
4761×
        let change = host
4771×
            .build_change(
4781×
                &heads,
4791×
                &[FileEdit {
4801×
                    path: "a.txt".into(),
4811×
                    content: Some(b"one\n".to_vec()),
4821×
                }],
4831×
                "Trim",
4841×
                author,
485
            )
4861×
            .unwrap();
4871×
        let hash = change.hash();
4881×
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
4891×
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
4901×
        let update = host
4911×
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
4921×
            .unwrap();
4931×
        let report = host
4941×
            .apply_hosted(Some((change, vec![sig])), &ids, update)
4951×
            .unwrap();
4961×
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
4971×
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
498
499
        // A stranger's key cannot move the reference, even with the user as author.
5001×
        let stranger = SecretKey::generate();
5011×
        let sid = stranger.identity("Mallory", "m@example", 1);
5021×
        let skey = sid.hash();
5031×
        let change2 = host
5041×
            .build_change(
5051×
                &[hash],
5061×
                &[FileEdit {
5071×
                    path: "a.txt".into(),
5081×
                    content: Some(b"evil\n".to_vec()),
5091×
                }],
5101×
                "Evil",
5111×
                author,
512
            )
5131×
            .unwrap();
5141×
        let h2 = change2.hash();
5151×
        let sig2 = stranger.sign(Domain::Change, &skey, &h2);
5161×
        let update = host
5171×
            .host_ref_update("main", &[h2], Some((&skey, &stranger)))
5181×
            .unwrap();
5191×
        let report = host
5201×
            .apply_hosted(Some((change2, vec![sig2])), &[sid], update)
5211×
            .unwrap();
5221×
        assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results);
5231×
        assert_eq!(
5241×
            host.bookmark_heads().unwrap(),
5251×
            vec![hash],
526
            "the reference did not move"
527
        );
528
        // An unsigned update is refused under a policy too.
5291×
        let update = host.host_ref_update("main", &[hash], None).unwrap();
5301×
        let report = host.apply_hosted(None, &[], update).unwrap();
5311×
        assert!(report.results.iter().all(|r| !r.ok));
5321×
    }
533
534
    /// Agent doc §2: an agent's key signs changes for its owner and nothing
535
    /// more. Its reference attestation is refused even though its owner's
536
    /// would pass, and a seal it makes authenticates nothing.
537
    #[test]
5381×
    fn a_changes_only_delegate_signs_changes_but_cannot_seal_or_move_a_reference() {
5391×
        let dir = tempfile::tempdir().unwrap();
5401×
        let (host, mut user, _, _, _) = setup(dir.path(), true, false);
5411×
        let agent_sk = SecretKey::generate();
5421×
        let agent_id = agent_sk.identity("aider on a laptop", "agent@example", 1);
5431×
        let agent = agent_id.hash();
5441×
        user.delegate_add(agent_id.clone(), Scope::CHANGES, None)
5451×
            .unwrap();
546
        // The successor identity reaches the host with the rotated policy.
5471×
        assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok));
5481×
        let author = user.config.author_key.unwrap();
5491×
        let ids = vec![agent_id.clone(), user.identity(&author).unwrap()];
5501×
        let heads = host.bookmark_heads().unwrap();
5511×
        let change = host
5521×
            .build_change(
5531×
                &heads,
5541×
                &[FileEdit {
5551×
                    path: "a.txt".into(),
5561×
                    content: Some(b"by the agent\n".to_vec()),
5571×
                }],
5581×
                "Agent edit",
5591×
                author,
560
            )
5611×
            .unwrap();
5621×
        let hash = change.hash();
5631×
        let sig = agent_sk.sign(Domain::Change, &agent, &hash);
564
        // Attested by the agent: refused, the reference stays.
5651×
        let update = host
5661×
            .host_ref_update("main", &[hash], Some((&agent, &agent_sk)))
5671×
            .unwrap();
5681×
        let report = host
5691×
            .apply_hosted(Some((change.clone(), vec![sig.clone()])), &ids, update)
5701×
            .unwrap();
5711×
        assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results);
5721×
        assert_eq!(host.bookmark_heads().unwrap(), heads);
573
        // Attested by the owner: the agent-signed change lands.
5741×
        let (owner, owner_sk) = user.secret_key().unwrap().unwrap();
5751×
        let update = host
5761×
            .host_ref_update("main", &[hash], Some((&owner, &owner_sk)))
5771×
            .unwrap();
5781×
        let report = host
5791×
            .apply_hosted(Some((change, vec![sig])), &ids, update)
5801×
            .unwrap();
5811×
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
5821×
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
5831×
        assert!(matches!(
5841×
            host.authenticate_change(&hash).unwrap(),
5851×
            SignatureStatus::Valid { key, sealed: false, .. } if key == agent
586
        ));
587
        // A seal by the agent is outside its scope.
5881×
        let change2 = host
5891×
            .build_change(
5901×
                &[hash],
5911×
                &[FileEdit {
5921×
                    path: "a.txt".into(),
5931×
                    content: Some(b"sealed by the agent\n".to_vec()),
5941×
                }],
5951×
                "Sealed",
5961×
                author,
597
            )
5981×
            .unwrap();
5991×
        let h2 = host.store().put_change(&change2).unwrap();
6001×
        let seal = host
6011×
            .store()
6021×
            .put_attest(&Attest::seal(host.id().unwrap(), vec![h2]))
6031×
            .unwrap();
6041×
        host.store()
6051×
            .put_signature(&seal, &agent_sk.sign(Domain::Attest, &agent, &seal))
6061×
            .unwrap();
6071×
        host.store().index_seal(&h2, &seal).unwrap();
6081×
        assert!(matches!(
6091×
            host.authenticate_change(&h2).unwrap(),
6101×
            SignatureStatus::Invalid(e) if e.contains("may not seal")
611
        ));
6121×
    }
613
614
    /// Agent doc §2 end to end from the command line's point of view: the
615
    /// agent's key is a changes-only delegate, and `author.signer` makes
616
    /// the owner's checkout sign with it while `author.key` stays the
617
    /// owner, so a commit carries the owner as author and the agent's
618
    /// signature, and a reference attestation by the agent is refused.
619
    #[test]
6201×
    fn an_agent_signs_its_owner_s_changes_through_author_signer() {
6211×
        let dir = tempfile::tempdir().unwrap();
6221×
        let (host, mut user, _, _, _) = setup(dir.path(), true, false);
6231×
        let owner = user.config.author_key.unwrap();
624
        // The agent makes its own key in its own home; the owner delegates.
6251×
        let agent_home = dir.path().join("agent-home");
6261×
        let mut agent_repo = Repo::init(&dir.path().join("agent-work"))
6271×
            .unwrap()
6281×
            .with_home(agent_home.clone());
6291×
        let (agent, agent_id) = agent_repo
6301×
            .create_identity("aider on a laptop", "agent@example", IdentityScope::User)
6311×
            .unwrap();
6321×
        user.delegate_add(agent_id.clone(), Scope::CHANGES, None)
6331×
            .unwrap();
6341×
        assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok));
6351×
        host.refresh().unwrap();
6361×
        let owner = {
6371×
            let _ = owner;
6381×
            user.config.author_key.unwrap()
639
        };
640
        // The owner's checkout, as the agent runs it: the agent's keys,
641
        // the owner as author, the agent as signer.
642
        // (The signer is the agent's own setting, in its user config: the
643
        // checkout's `author.key` is the owner's, in the repository.)
6441×
        let work = dir.path().join("work");
6451×
        let mut as_agent = Repo::open(&work).unwrap().with_home(agent_home);
6461×
        as_agent
6471×
            .set_user_config("author.signer", &agent.to_hex())
6481×
            .unwrap();
6491×
        as_agent.set_user_config("core.sign", "change").unwrap();
6501×
        assert_eq!(as_agent.config.author_key, Some(owner));
6511×
        assert_eq!(as_agent.signing_key(), Some(agent));
6521×
        write(&work, "a.txt", b"by the agent\n");
6531×
        as_agent.snapshot("t").unwrap();
6541×
        let h = as_agent.commit("Agent: edit a", &[]).unwrap();
6551×
        let meta = as_agent.store().get_change_meta(&h).unwrap();
6561×
        assert_eq!(meta.author, owner, "the owner stays the author");
6571×
        assert!(matches!(
6581×
            as_agent.verify_change(&h).unwrap(),
6591×
            SignatureStatus::Valid { key, .. } if key == agent
660
        ));
6611×
        assert_eq!(as_agent.revset("delegated()").unwrap().hashes, vec![h]);
662
        // The agent cannot push: its attestation of the reference is
663
        // refused under the policy. The owner can.
6641×
        let r = as_agent.push("origin", false).unwrap();
6651×
        host.refresh().unwrap();
6661×
        assert!(r.iter().all(|x| !x.ok), "{r:?}");
6671×
        assert!(!host.bookmark_heads().unwrap().contains(&h));
668
        // The owner's own handle (its key directory was fixed at `setup`;
669
        // the environment's `OXY_HOME` may have moved since), which reads
670
        // the head again to see the agent's commit.
6711×
        user.refresh().unwrap();
6721×
        let r = user.push("origin", false).unwrap();
6731×
        host.refresh().unwrap();
6741×
        assert!(r.iter().all(|x| x.ok), "{r:?}");
6751×
        assert!(host.bookmark_heads().unwrap().contains(&h));
6761×
        assert!(matches!(
6771×
            host.authenticate_change(&h).unwrap(),
6781×
            SignatureStatus::Valid { key, .. } if key == agent
679
        ));
6801×
    }
681
682
    /// oxygenhub#129: a delegate that may move references makes a bookmark
683
    /// at its owner's change, signing no change of its own. The push
684
    /// carries its identity, so the host can check the move (it was
685
    /// refused as "unknown key" until the delegate had pushed a change).
686
    #[test]
6871×
    fn a_delegate_s_first_push_of_a_bookmark_carries_its_identity() {
6881×
        let dir = tempfile::tempdir().unwrap();
6891×
        let (host, mut user, _, _, _) = setup(dir.path(), true, false);
6901×
        let agent_home = dir.path().join("agent-home");
6911×
        let mut agent_repo = Repo::init(&dir.path().join("agent-work"))
6921×
            .unwrap()
6931×
            .with_home(agent_home.clone());
6941×
        let (agent, agent_id) = agent_repo
6951×
            .create_identity("agent", "agent@example", IdentityScope::User)
6961×
            .unwrap();
6971×
        user.delegate_add(agent_id, Scope::CHANGES | Scope::REFS, None)
6981×
            .unwrap();
6991×
        assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok));
7001×
        let work = dir.path().join("work");
7011×
        let mut as_agent = Repo::open(&work).unwrap().with_home(agent_home);
7021×
        as_agent
7031×
            .set_user_config("author.signer", &agent.to_hex())
7041×
            .unwrap();
7051×
        assert!(
7061×
            host.store().get_identity(&agent).is_err(),
707
            "the host has never seen the agent"
708
        );
7091×
        let heads = as_agent.bookmark_heads().unwrap();
7101×
        as_agent.bookmark_create("feature", Some(&heads)).unwrap();
7111×
        let r = as_agent.push_bookmark("origin", "feature", false).unwrap();
7121×
        assert!(r.iter().all(|x| x.ok), "{r:?}");
7131×
        assert!(
7141×
            host.store().get_identity(&agent).is_ok(),
715
            "its identity came with the move"
716
        );
7171×
    }
718
719
    /// A delegation past its expiry authenticates nothing made after it,
720
    /// and the delegator may revoke the key; a stranger's key, they may not.
721
    #[test]
7221×
    fn an_expired_delegation_is_refused_and_the_delegator_may_revoke() {
7231×
        let dir = tempfile::tempdir().unwrap();
7241×
        let (_, mut user, _, _, _) = setup(dir.path(), false, false);
7251×
        let agent_sk = SecretKey::generate();
7261×
        let agent_id = agent_sk.identity("agent", "agent@example", 1);
7271×
        let agent = agent_id.hash();
7281×
        let yesterday = Hlc::now().wall_ms - 24 * 3600 * 1000;
7291×
        user.delegate_add(agent_id, Scope::CHANGES, Some(yesterday))
7301×
            .unwrap();
7311×
        user.config.set("core.sign", "seal").unwrap();
7321×
        user.save_config().unwrap();
7331×
        write(&dir.path().join("work"), "a.txt", b"later\n");
7341×
        user.snapshot("t").unwrap();
7351×
        let h = user.commit("after the delegation ended", &[]).unwrap();
7361×
        user.store()
7371×
            .put_signature(&h, &agent_sk.sign(Domain::Change, &agent, &h))
7381×
            .unwrap();
7391×
        let status = user.verify_change(&h).unwrap();
7401×
        assert!(
7411×
            matches!(&status, SignatureStatus::Invalid(e) if e.contains("expired")),
742
            "{status:?}"
743
        );
744
        // No policy here, so the user is no root: only delegation lets them revoke.
7451×
        let stranger = SecretKey::generate().identity("x", "x@example", 1).hash();
7461×
        assert!(user.revoke_identity(&stranger, "").is_err());
7471×
        assert!(user.revoke_identity(&agent, "session over").is_ok());
7481×
        assert!(user.is_revoked(&agent).unwrap());
7491×
        let r = user.delegate_remove(&agent).unwrap();
7501×
        assert!(user.identity(&r.new).unwrap().delegates.is_empty());
7511×
    }
752
753
    #[test]
7541×
    fn a_host_edit_that_removes_markers_resolves_the_conflict() {
7551×
        let dir = tempfile::tempdir().unwrap();
7561×
        let (host, mut user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
7571×
        let author = user.config.author_key.unwrap();
7581×
        let base = host.bookmark_heads().unwrap();
759
        // Two independent edits of the same line: a conflict in the union.
7601×
        let a = host
7611×
            .build_change(
7621×
                &base,
7631×
                &[FileEdit {
7641×
                    path: "a.txt".into(),
7651×
                    content: Some(b"ONE\ntwo\n".to_vec()),
7661×
                }],
7671×
                "A",
7681×
                author,
769
            )
7701×
            .unwrap();
7711×
        let b = host
7721×
            .build_change(
7731×
                &base,
7741×
                &[FileEdit {
7751×
                    path: "a.txt".into(),
7761×
                    content: Some(b"uno\ntwo\n".to_vec()),
7771×
                }],
7781×
                "B",
7791×
                author,
780
            )
7811×
            .unwrap();
7821×
        let (ha, hb) = (a.hash(), b.hash());
7831×
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
7842×
        for (c, h) in [(a, ha), (b, hb)] {
7852×
            let sig = hub_sk.sign(Domain::Change, &hub_key, &h);
7862×
            let mut heads = host.bookmark_heads().unwrap();
7872×
            heads.retain(|x| !base.contains(x));
7882×
            heads.push(h);
7892×
            let update = host
7902×
                .host_ref_update("main", &heads, Some((&hub_key, &hub_sk)))
7912×
                .unwrap();
7922×
            let r = host
7932×
                .apply_hosted(Some((c, vec![sig])), &ids, update)
7942×
                .unwrap();
7952×
            assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
796
        }
7971×
        let heads = host.bookmark_heads().unwrap();
7981×
        assert_eq!(heads.len(), 2);
7991×
        let marked = String::from_utf8(host.file_at("a.txt", Some(&heads)).unwrap()).unwrap();
8001×
        assert!(marked.contains("<<<<<<< oxy conflict"), "{marked}");
801
        // Saving with markers left in is refused; saving without them resolves.
8021×
        assert!(host
8031×
            .build_change(
8041×
                &heads,
8051×
                &[FileEdit {
8061×
                    path: "a.txt".into(),
8071×
                    content: Some(format!("{marked}x\n").into_bytes())
8081×
                }],
8091×
                "half",
8101×
                author
8111×
            )
8121×
            .is_err());
8131×
        let res = host
8141×
            .build_change(
8151×
                &heads,
8161×
                &[FileEdit {
8171×
                    path: "a.txt".into(),
8181×
                    content: Some(b"One\ntwo\n".to_vec()),
8191×
                }],
8201×
                "Resolve",
8211×
                author,
822
            )
8231×
            .unwrap();
8241×
        assert_eq!(res.kind, ChangeKind::Resolution);
8251×
        assert!(
8261×
            res.deps.contains(&ha) && res.deps.contains(&hb),
827
            "a resolution depends on both sides"
828
        );
8291×
        let hr = res.hash();
8301×
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hr);
8311×
        let update = host
8321×
            .host_ref_update("main", &[hr], Some((&hub_key, &hub_sk)))
8331×
            .unwrap();
8341×
        let r = host
8351×
            .apply_hosted(Some((res, vec![sig])), &ids, update)
8361×
            .unwrap();
8371×
        assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
8381×
        assert_eq!(host.file_at("a.txt", Some(&[hr])).unwrap(), b"One\ntwo\n");
8391×
        assert!(!host
8401×
            .load_state(&[hr])
8411×
            .unwrap()
8421×
            .materialize()
8431×
            .is_conflicted());
8441×
        let _ = &mut user;
8451×
    }
846
}