OXYGENHUB

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

619 of 626 lines run, 28 of 40 functions · run #254 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×
        if message.trim().is_empty() {
300×
            return fail("a change message is required");
3116×
        }
3216×
        if edits.is_empty() {
330×
            return fail("nothing to change");
3416×
        }
3516×
        let base = self.load_state(heads)?;
3616×
        let base_snapshot = base.materialize();
3716×
        let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
3824×
        for (path, f) in &base_snapshot.files {
3924×
            let entry = match (&f.kind, &f.content) {
404×
                (NodeKind::Dir, _) | (_, FileContent::Dir) => continue,
4118×
                (kind, FileContent::Text { bytes, .. }) => {
4218×
                    FsEntry::new(*kind, f.mode.exec, bytes.clone())
43
                }
442×
                (kind, FileContent::Opaque { blobs }) => FsEntry {
452×
                    blob: blobs.first().copied(),
462×
                    ..FsEntry::new(*kind, f.mode.exec, vec![])
472×
                },
48
            };
4920×
            entries.insert(path.clone(), entry);
50
        }
5117×
        for e in edits {
5217×
            let path = e.path.trim_matches('/');
5317×
            if path.is_empty()
5417×
                || path
5517×
                    .split('/')
5619×
                    .any(|c| c.is_empty() || c == "." || c == ".." || c == OXY_DIR)
57
            {
581×
                return fail(format!("{:?} is not a valid path", e.path));
5916×
            }
6016×
            match &e.content {
6115×
                Some(bytes) => {
6215×
                    let exec = entries.get(path).map(|x| x.exec).unwrap_or(false);
63
                    // A file cannot sit where a directory is, or inside a file.
6415×
                    if base_snapshot
6515×
                        .files
6615×
                        .get(path)
6715×
                        .is_some_and(|f| f.kind == NodeKind::Dir)
68
                    {
690×
                        return fail(format!("{path} is a directory"));
7015×
                    }
7115×
                    let mut parent = path;
7216×
                    while let Some((p, _)) = parent.rsplit_once('/') {
732×
                        if base_snapshot
742×
                            .files
752×
                            .get(p)
762×
                            .is_some_and(|f| f.kind != NodeKind::Dir)
77
                        {
781×
                            return fail(format!("{p} is a file, not a directory"));
791×
                        }
801×
                        parent = p;
81
                    }
8214×
                    entries.insert(
8314×
                        path.to_string(),
8414×
                        FsEntry::new(NodeKind::File, exec, bytes.clone()),
85
                    );
86
                }
87
                None => {
881×
                    if entries.remove(path).is_none() {
89
                        // Removing a directory removes everything below it.
901×
                        let prefix = format!("{path}/");
911×
                        let before = entries.len();
922×
                        entries.retain(|p, _| !p.starts_with(&prefix));
931×
                        if entries.len() == before {
940×
                            return fail(format!("{path} does not exist"));
951×
                        }
960×
                    }
97
                }
98
            }
99
        }
100
        // A path the user writes settles its replace conflict, if any: they
101
        // chose that content (spec §9.3).
10214×
        let written: std::collections::HashSet<String> = edits
10314×
            .iter()
10415×
            .filter(|e| e.content.is_some())
10514×
            .map(|e| e.path.trim_matches('/').to_string())
10614×
            .collect();
10714×
        let built = self.build_from_entries_resolving(
10814×
            &base,
10914×
            &base_snapshot,
11014×
            HashMap::new(),
11114×
            &entries,
11214×
            &written,
1130×
        )?;
11414×
        if let Some(s) = built
11514×
            .statuses
11614×
            .iter()
11715×
            .find(|s| s.status == ChangeStatus::ConflictEdited)
118
        {
1191×
            return fail(format!("{} still contains conflict markers", s.path));
12013×
        }
12113×
        if built.tree_ops.is_empty() && built.edits.is_empty() {
1221×
            return fail("the edits change nothing");
12312×
        }
12412×
        let mut change = Change {
125
            version: crate::objects::CHANGE_VERSION,
12612×
            change_id: ChangeId::random(),
12712×
            deps: vec![],
12812×
            author,
12912×
            hlc: Hlc::now(),
13012×
            message: message.trim_end().to_string(),
13112×
            tree_ops: built.tree_ops,
13212×
            edits: built.edits,
13312×
            kind: if built.resolves {
1343×
                ChangeKind::Resolution
135
            } else {
1369×
                ChangeKind::Normal
137
            },
138
        };
13912×
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&change, &base);
14012×
        deps.extend(built.contributors.iter().copied());
14112×
        change.deps = deps.into_iter().collect();
14212×
        change.normalize();
14312×
        Ok(change)
14416×
    }
145
146
    /// A reference update of `bookmark` to `heads`, attested by `signer`
147
    /// when given (the attestation a push would carry, spec §14.4).
14810×
    pub fn host_ref_update(
14910×
        &self,
15010×
        bookmark: &str,
15110×
        heads: &[ChangeHash],
15210×
        signer: Option<(&KeyId, &SecretKey)>,
15310×
    ) -> Result<RefUpdate> {
15410×
        let view = self.view()?;
15510×
        let empty = self.store.put_state(&StateObject::new(vec![]))?;
15610×
        let old_state = view.bookmarks.get(bookmark).copied().unwrap_or(empty);
15710×
        let new_state = self.store.put_state(&StateObject::new(heads.to_vec()))?;
15810×
        let attestation = match signer {
1599×
            Some((key, sk)) => {
1609×
                let serial = self
1619×
                    .latest_ref_attest(bookmark)?
1629×
                    .and_then(|(_, a)| match a.attest.body {
1639×
                        AttestBody::Ref { serial, .. } => Some(serial),
1640×
                        _ => None,
1659×
                    })
1669×
                    .map(|s| s + 1)
1679×
                    .unwrap_or(1);
1689×
                let attest = Attest {
1699×
                    repo: self.id()?,
1709×
                    hlc: Hlc::now(),
1719×
                    body: AttestBody::Ref {
1729×
                        name: bookmark.to_string(),
1739×
                        old_state,
1749×
                        new_state,
1759×
                        serial,
1769×
                    },
177
                };
1789×
                let r = attest.hash();
1799×
                let sig = sk.sign(Domain::Attest, key, &r);
1809×
                Some(SignedAttest {
1819×
                    attest,
1829×
                    sigs: vec![sig],
1839×
                })
184
            }
1851×
            None => None,
186
        };
18710×
        Ok(RefUpdate {
18810×
            name: bookmark.to_string(),
18910×
            old_state,
19010×
            new_state,
19110×
            force: false,
19210×
            attestation,
19310×
        })
19410×
    }
195
196
    /// Apply a host-made update as if it had been pushed: the new change
197
    /// (with its signatures) if any, the identities it needs, and the
198
    /// reference update, all through `receive_push_report`, so the same
199
    /// authentication, compare-and-swap and policy checks apply.
20010×
    pub fn apply_hosted(
20110×
        &self,
20210×
        change: Option<(Change, Vec<Signature>)>,
20310×
        identities: &[Identity],
20410×
        update: RefUpdate,
20510×
    ) -> Result<PushReport> {
20610×
        let mut bundle = Bundle::default();
20717×
        for id in identities {
20817×
            bundle.objects.push((ObjectType::Identity, id.encode()));
20917×
        }
21010×
        if let Ok(st) = self.store.get_state(&update.new_state) {
21110×
            bundle.objects.push((ObjectType::State, st.encode()));
21210×
        }
21310×
        if let Some((c, sigs)) = change {
2149×
            bundle.changes.push((c, sigs));
2159×
        }
21610×
        self.receive_push_report(bundle, vec![update], None, vec![])
21710×
    }
218
}
219
220
#[cfg(test)]
221
mod tests {
222
    use super::*;
223
    use crate::identity::SecretKey;
224
2257×
    fn hermetic() {
226
        static ONCE: std::sync::Once = std::sync::Once::new();
227
        static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
2287×
        ONCE.call_once(|| {
2291×
            let d = tempfile::tempdir().unwrap();
2301×
            std::env::set_var("OXY_HOME", d.path());
2311×
            DIR.set(d).ok();
2321×
        });
2337×
    }
234
2359×
    fn write(root: &Path, rel: &str, content: &[u8]) {
2369×
        let p = root.join(rel);
2379×
        fs::create_dir_all(p.parent().unwrap()).unwrap();
2389×
        fs::write(p, content).unwrap();
2399×
    }
240
241
    /// A user repository pushed to a bare host, and a hub key the user
242
    /// has delegated to. Returns (host, user, hub key, hub secret).
2437×
    fn setup(
2447×
        root: &Path,
2457×
        with_policy: bool,
2467×
        delegate: bool,
2477×
    ) -> (Repo, Repo, KeyId, SecretKey, Identity) {
2487×
        hermetic();
2497×
        let host_dir = root.join("host");
2507×
        Repo::init_bare_unbound(&host_dir).unwrap();
2517×
        let work = root.join("work");
2527×
        let mut user = Repo::init(&work).unwrap();
2537×
        user.create_identity("Andy", "[email protected]", IdentityScope::Repo)
2547×
            .unwrap();
2557×
        user.config.set("core.sign", "change").unwrap();
2567×
        user.config
2577×
            .set("remote.origin.url", host_dir.to_str().unwrap())
2587×
            .unwrap();
2597×
        user.save_config().unwrap();
2607×
        let hub_sk = SecretKey::generate();
2617×
        let hub_id = hub_sk.identity("Hub", "hub@example", 1);
2627×
        let hub_key = hub_id.hash();
2637×
        if with_policy {
2643×
            user.policy_init().unwrap();
2654×
        }
2667×
        if delegate {
2673×
            user.delegate_add(hub_id.clone(), Scope::CHANGES | Scope::REFS, None)
2683×
                .unwrap();
2694×
        }
2707×
        write(&work, "a.txt", b"one\ntwo\n");
2717×
        user.snapshot("t").unwrap();
2727×
        user.commit("first", &[]).unwrap();
2737×
        let r = user.push("origin", false).unwrap();
2747×
        assert!(r.iter().all(|x| x.ok), "{r:?}");
2757×
        (
2767×
            Repo::open(&host_dir).unwrap(),
2777×
            user,
2787×
            hub_key,
2797×
            hub_sk,
2807×
            hub_id,
2817×
        )
2827×
    }
283
284
    #[test]
2851×
    fn a_host_edit_signed_by_a_delegate_lands() {
2861×
        let dir = tempfile::tempdir().unwrap();
2871×
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
2881×
        let author = user.config.author_key.unwrap();
2891×
        let heads = host.bookmark_heads().unwrap();
2901×
        let change = host
2911×
            .build_change(
2921×
                &heads,
2931×
                &[
2941×
                    FileEdit {
2951×
                        path: "a.txt".into(),
2961×
                        content: Some(b"one\ntwo\nthree\n".to_vec()),
2971×
                    },
2981×
                    FileEdit {
2991×
                        path: "docs/new.md".into(),
3001×
                        content: Some(b"# New\n".to_vec()),
3011×
                    },
3021×
                ],
3031×
                "Edit on the web",
3041×
                author,
305
            )
3061×
            .unwrap();
3071×
        assert_eq!(change.author, author);
3081×
        assert_eq!(change.kind, ChangeKind::Normal);
3091×
        let hash = change.hash();
3101×
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
3111×
        let mut new_heads = heads.clone();
3121×
        new_heads.push(hash);
3131×
        let update = host
3141×
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
3151×
            .unwrap();
3161×
        let ids = vec![hub_id, user.identity(&author).unwrap()];
3171×
        let report = host
3181×
            .apply_hosted(Some((change, vec![sig])), &ids, update)
3191×
            .unwrap();
3201×
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
3211×
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
3221×
        assert_eq!(
3231×
            host.file_at("a.txt", Some(&[hash])).unwrap(),
324
            b"one\ntwo\nthree\n"
325
        );
3261×
        assert_eq!(
3271×
            host.file_at("docs/new.md", Some(&[hash])).unwrap(),
328
            b"# New\n"
329
        );
330
        // Authored by the user, authenticated by the hub key as delegate.
3311×
        assert!(
3321×
            matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Valid { key, name, .. } if key == hub_key && name == "Hub")
333
        );
3341×
        let _ = new_heads;
335
        // Bad paths and empty edits are refused.
3361×
        assert!(host
3371×
            .build_change(
3381×
                &[hash],
3391×
                &[FileEdit {
3401×
                    path: "../x".into(),
3411×
                    content: Some(vec![])
3421×
                }],
3431×
                "m",
3441×
                author
3451×
            )
3461×
            .is_err());
3471×
        assert!(
3481×
            host.build_change(
3491×
                &[hash],
3501×
                &[FileEdit {
3511×
                    path: "a.txt".into(),
3521×
                    content: Some(b"one\ntwo\nthree\n".to_vec())
3531×
                }],
3541×
                "m",
3551×
                author
3561×
            )
3571×
            .is_err(),
358
            "no-op edits are refused"
359
        );
3601×
        assert!(
3611×
            host.build_change(
3621×
                &[hash],
3631×
                &[FileEdit {
3641×
                    path: "a.txt/b".into(),
3651×
                    content: Some(vec![])
3661×
                }],
3671×
                "m",
3681×
                author
3691×
            )
3701×
            .is_err(),
371
            "a file inside a file"
372
        );
3731×
        let rm = host
3741×
            .build_change(
3751×
                &[hash],
3761×
                &[FileEdit {
3771×
                    path: "docs".into(),
3781×
                    content: None,
3791×
                }],
3801×
                "Remove docs",
3811×
                author,
382
            )
3831×
            .unwrap();
3841×
        assert!(!rm.tree_ops.is_empty());
3851×
    }
386
387
    #[test]
3881×
    fn a_host_edit_by_a_key_that_is_not_a_delegate_is_refused() {
3891×
        let dir = tempfile::tempdir().unwrap();
3901×
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, false);
3911×
        let author = user.config.author_key.unwrap();
3921×
        let heads = host.bookmark_heads().unwrap();
3931×
        let change = host
3941×
            .build_change(
3951×
                &heads,
3961×
                &[FileEdit {
3971×
                    path: "a.txt".into(),
3981×
                    content: Some(b"x\n".to_vec()),
3991×
                }],
4001×
                "Edit",
4011×
                author,
402
            )
4031×
            .unwrap();
4041×
        let hash = change.hash();
4051×
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
4061×
        let update = host
4071×
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
4081×
            .unwrap();
4091×
        let report = host
4101×
            .apply_hosted(
4111×
                Some((change, vec![sig])),
4121×
                &[hub_id, user.identity(&author).unwrap()],
4131×
                update,
414
            )
4151×
            .unwrap();
416
        // Without a policy the host accepts unauthenticated changes, but the
417
        // signature does not verify as the author's: it stays unauthenticated.
4181×
        assert!(report.results.iter().all(|r| r.ok));
4191×
        assert!(
4201×
            matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Invalid(why) if why.contains("neither the author"))
421
        );
4221×
    }
423
424
    #[test]
4251×
    fn under_a_policy_a_delegate_may_update_the_reference_and_a_stranger_may_not() {
4261×
        let dir = tempfile::tempdir().unwrap();
4271×
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), true, true);
4281×
        assert!(host.current_policy().unwrap().is_some());
4291×
        let author = user.config.author_key.unwrap();
4301×
        let heads = host.bookmark_heads().unwrap();
4311×
        let change = host
4321×
            .build_change(
4331×
                &heads,
4341×
                &[FileEdit {
4351×
                    path: "a.txt".into(),
4361×
                    content: Some(b"one\n".to_vec()),
4371×
                }],
4381×
                "Trim",
4391×
                author,
440
            )
4411×
            .unwrap();
4421×
        let hash = change.hash();
4431×
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
4441×
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
4451×
        let update = host
4461×
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
4471×
            .unwrap();
4481×
        let report = host
4491×
            .apply_hosted(Some((change, vec![sig])), &ids, update)
4501×
            .unwrap();
4511×
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
4521×
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
453
454
        // A stranger's key cannot move the reference, even with the user as author.
4551×
        let stranger = SecretKey::generate();
4561×
        let sid = stranger.identity("Mallory", "m@example", 1);
4571×
        let skey = sid.hash();
4581×
        let change2 = host
4591×
            .build_change(
4601×
                &[hash],
4611×
                &[FileEdit {
4621×
                    path: "a.txt".into(),
4631×
                    content: Some(b"evil\n".to_vec()),
4641×
                }],
4651×
                "Evil",
4661×
                author,
467
            )
4681×
            .unwrap();
4691×
        let h2 = change2.hash();
4701×
        let sig2 = stranger.sign(Domain::Change, &skey, &h2);
4711×
        let update = host
4721×
            .host_ref_update("main", &[h2], Some((&skey, &stranger)))
4731×
            .unwrap();
4741×
        let report = host
4751×
            .apply_hosted(Some((change2, vec![sig2])), &[sid], update)
4761×
            .unwrap();
4771×
        assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results);
4781×
        assert_eq!(
4791×
            host.bookmark_heads().unwrap(),
4801×
            vec![hash],
481
            "the reference did not move"
482
        );
483
        // An unsigned update is refused under a policy too.
4841×
        let update = host.host_ref_update("main", &[hash], None).unwrap();
4851×
        let report = host.apply_hosted(None, &[], update).unwrap();
4861×
        assert!(report.results.iter().all(|r| !r.ok));
4871×
    }
488
489
    /// Agent doc §2: an agent's key signs changes for its owner and nothing
490
    /// more. Its reference attestation is refused even though its owner's
491
    /// would pass, and a seal it makes authenticates nothing.
492
    #[test]
4931×
    fn a_changes_only_delegate_signs_changes_but_cannot_seal_or_move_a_reference() {
4941×
        let dir = tempfile::tempdir().unwrap();
4951×
        let (host, mut user, _, _, _) = setup(dir.path(), true, false);
4961×
        let agent_sk = SecretKey::generate();
4971×
        let agent_id = agent_sk.identity("aider on a laptop", "agent@example", 1);
4981×
        let agent = agent_id.hash();
4991×
        user.delegate_add(agent_id.clone(), Scope::CHANGES, None)
5001×
            .unwrap();
501
        // The successor identity reaches the host with the rotated policy.
5021×
        assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok));
5031×
        let author = user.config.author_key.unwrap();
5041×
        let ids = vec![agent_id.clone(), user.identity(&author).unwrap()];
5051×
        let heads = host.bookmark_heads().unwrap();
5061×
        let change = host
5071×
            .build_change(
5081×
                &heads,
5091×
                &[FileEdit {
5101×
                    path: "a.txt".into(),
5111×
                    content: Some(b"by the agent\n".to_vec()),
5121×
                }],
5131×
                "Agent edit",
5141×
                author,
515
            )
5161×
            .unwrap();
5171×
        let hash = change.hash();
5181×
        let sig = agent_sk.sign(Domain::Change, &agent, &hash);
519
        // Attested by the agent: refused, the reference stays.
5201×
        let update = host
5211×
            .host_ref_update("main", &[hash], Some((&agent, &agent_sk)))
5221×
            .unwrap();
5231×
        let report = host
5241×
            .apply_hosted(Some((change.clone(), vec![sig.clone()])), &ids, update)
5251×
            .unwrap();
5261×
        assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results);
5271×
        assert_eq!(host.bookmark_heads().unwrap(), heads);
528
        // Attested by the owner: the agent-signed change lands.
5291×
        let (owner, owner_sk) = user.secret_key().unwrap().unwrap();
5301×
        let update = host
5311×
            .host_ref_update("main", &[hash], Some((&owner, &owner_sk)))
5321×
            .unwrap();
5331×
        let report = host
5341×
            .apply_hosted(Some((change, vec![sig])), &ids, update)
5351×
            .unwrap();
5361×
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
5371×
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
5381×
        assert!(matches!(
5391×
            host.authenticate_change(&hash).unwrap(),
5401×
            SignatureStatus::Valid { key, sealed: false, .. } if key == agent
541
        ));
542
        // A seal by the agent is outside its scope.
5431×
        let change2 = host
5441×
            .build_change(
5451×
                &[hash],
5461×
                &[FileEdit {
5471×
                    path: "a.txt".into(),
5481×
                    content: Some(b"sealed by the agent\n".to_vec()),
5491×
                }],
5501×
                "Sealed",
5511×
                author,
552
            )
5531×
            .unwrap();
5541×
        let h2 = host.store().put_change(&change2).unwrap();
5551×
        let seal = host
5561×
            .store()
5571×
            .put_attest(&Attest::seal(host.id().unwrap(), vec![h2]))
5581×
            .unwrap();
5591×
        host.store()
5601×
            .put_signature(&seal, &agent_sk.sign(Domain::Attest, &agent, &seal))
5611×
            .unwrap();
5621×
        host.store().index_seal(&h2, &seal).unwrap();
5631×
        assert!(matches!(
5641×
            host.authenticate_change(&h2).unwrap(),
5651×
            SignatureStatus::Invalid(e) if e.contains("may not seal")
566
        ));
5671×
    }
568
569
    /// Agent doc §2 end to end from the command line's point of view: the
570
    /// agent's key is a changes-only delegate, and `author.signer` makes
571
    /// the owner's checkout sign with it while `author.key` stays the
572
    /// owner, so a commit carries the owner as author and the agent's
573
    /// signature, and a reference attestation by the agent is refused.
574
    #[test]
5751×
    fn an_agent_signs_its_owner_s_changes_through_author_signer() {
5761×
        let dir = tempfile::tempdir().unwrap();
5771×
        let (host, mut user, _, _, _) = setup(dir.path(), true, false);
5781×
        let owner = user.config.author_key.unwrap();
579
        // The agent makes its own key in its own home; the owner delegates.
5801×
        let agent_home = dir.path().join("agent-home");
5811×
        let mut agent_repo = Repo::init(&dir.path().join("agent-work"))
5821×
            .unwrap()
5831×
            .with_home(agent_home.clone());
5841×
        let (agent, agent_id) = agent_repo
5851×
            .create_identity("aider on a laptop", "agent@example", IdentityScope::User)
5861×
            .unwrap();
5871×
        user.delegate_add(agent_id.clone(), Scope::CHANGES, None)
5881×
            .unwrap();
5891×
        assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok));
5901×
        let owner = {
5911×
            let _ = owner;
5921×
            user.config.author_key.unwrap()
593
        };
594
        // The owner's checkout, as the agent runs it: the agent's keys,
595
        // the owner as author, the agent as signer.
596
        // (The signer is the agent's own setting, in its user config: the
597
        // checkout's `author.key` is the owner's, in the repository.)
5981×
        let work = dir.path().join("work");
5991×
        let mut as_agent = Repo::open(&work).unwrap().with_home(agent_home);
6001×
        as_agent
6011×
            .set_user_config("author.signer", &agent.to_hex())
6021×
            .unwrap();
6031×
        as_agent.set_user_config("core.sign", "change").unwrap();
6041×
        assert_eq!(as_agent.config.author_key, Some(owner));
6051×
        assert_eq!(as_agent.signing_key(), Some(agent));
6061×
        write(&work, "a.txt", b"by the agent\n");
6071×
        as_agent.snapshot("t").unwrap();
6081×
        let h = as_agent.commit("Agent: edit a", &[]).unwrap();
6091×
        let meta = as_agent.store().get_change_meta(&h).unwrap();
6101×
        assert_eq!(meta.author, owner, "the owner stays the author");
6111×
        assert!(matches!(
6121×
            as_agent.verify_change(&h).unwrap(),
6131×
            SignatureStatus::Valid { key, .. } if key == agent
614
        ));
6151×
        assert_eq!(as_agent.revset("delegated()").unwrap().hashes, vec![h]);
616
        // The agent cannot push: its attestation of the reference is
617
        // refused under the policy. The owner can.
6181×
        let r = as_agent.push("origin", false).unwrap();
6191×
        assert!(r.iter().all(|x| !x.ok), "{r:?}");
6201×
        assert!(!host.bookmark_heads().unwrap().contains(&h));
621
        // The owner's own handle (its key directory was fixed at `setup`;
622
        // the environment's `OXY_HOME` may have moved since).
6231×
        let r = user.push("origin", false).unwrap();
6241×
        assert!(r.iter().all(|x| x.ok), "{r:?}");
6251×
        assert!(host.bookmark_heads().unwrap().contains(&h));
6261×
        assert!(matches!(
6271×
            host.authenticate_change(&h).unwrap(),
6281×
            SignatureStatus::Valid { key, .. } if key == agent
629
        ));
6301×
    }
631
632
    /// A delegation past its expiry authenticates nothing made after it,
633
    /// and the delegator may revoke the key; a stranger's key, they may not.
634
    #[test]
6351×
    fn an_expired_delegation_is_refused_and_the_delegator_may_revoke() {
6361×
        let dir = tempfile::tempdir().unwrap();
6371×
        let (_, mut user, _, _, _) = setup(dir.path(), false, false);
6381×
        let agent_sk = SecretKey::generate();
6391×
        let agent_id = agent_sk.identity("agent", "agent@example", 1);
6401×
        let agent = agent_id.hash();
6411×
        let yesterday = Hlc::now().wall_ms - 24 * 3600 * 1000;
6421×
        user.delegate_add(agent_id, Scope::CHANGES, Some(yesterday))
6431×
            .unwrap();
6441×
        user.config.set("core.sign", "seal").unwrap();
6451×
        user.save_config().unwrap();
6461×
        write(&dir.path().join("work"), "a.txt", b"later\n");
6471×
        user.snapshot("t").unwrap();
6481×
        let h = user.commit("after the delegation ended", &[]).unwrap();
6491×
        user.store()
6501×
            .put_signature(&h, &agent_sk.sign(Domain::Change, &agent, &h))
6511×
            .unwrap();
6521×
        let status = user.verify_change(&h).unwrap();
6531×
        assert!(
6541×
            matches!(&status, SignatureStatus::Invalid(e) if e.contains("expired")),
655
            "{status:?}"
656
        );
657
        // No policy here, so the user is no root: only delegation lets them revoke.
6581×
        let stranger = SecretKey::generate().identity("x", "x@example", 1).hash();
6591×
        assert!(user.revoke_identity(&stranger, "").is_err());
6601×
        assert!(user.revoke_identity(&agent, "session over").is_ok());
6611×
        assert!(user.is_revoked(&agent).unwrap());
6621×
        let r = user.delegate_remove(&agent).unwrap();
6631×
        assert!(user.identity(&r.new).unwrap().delegates.is_empty());
6641×
    }
665
666
    #[test]
6671×
    fn a_host_edit_that_removes_markers_resolves_the_conflict() {
6681×
        let dir = tempfile::tempdir().unwrap();
6691×
        let (host, mut user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
6701×
        let author = user.config.author_key.unwrap();
6711×
        let base = host.bookmark_heads().unwrap();
672
        // Two independent edits of the same line: a conflict in the union.
6731×
        let a = host
6741×
            .build_change(
6751×
                &base,
6761×
                &[FileEdit {
6771×
                    path: "a.txt".into(),
6781×
                    content: Some(b"ONE\ntwo\n".to_vec()),
6791×
                }],
6801×
                "A",
6811×
                author,
682
            )
6831×
            .unwrap();
6841×
        let b = host
6851×
            .build_change(
6861×
                &base,
6871×
                &[FileEdit {
6881×
                    path: "a.txt".into(),
6891×
                    content: Some(b"uno\ntwo\n".to_vec()),
6901×
                }],
6911×
                "B",
6921×
                author,
693
            )
6941×
            .unwrap();
6951×
        let (ha, hb) = (a.hash(), b.hash());
6961×
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
6972×
        for (c, h) in [(a, ha), (b, hb)] {
6982×
            let sig = hub_sk.sign(Domain::Change, &hub_key, &h);
6992×
            let mut heads = host.bookmark_heads().unwrap();
7002×
            heads.retain(|x| !base.contains(x));
7012×
            heads.push(h);
7022×
            let update = host
7032×
                .host_ref_update("main", &heads, Some((&hub_key, &hub_sk)))
7042×
                .unwrap();
7052×
            let r = host
7062×
                .apply_hosted(Some((c, vec![sig])), &ids, update)
7072×
                .unwrap();
7082×
            assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
709
        }
7101×
        let heads = host.bookmark_heads().unwrap();
7111×
        assert_eq!(heads.len(), 2);
7121×
        let marked = String::from_utf8(host.file_at("a.txt", Some(&heads)).unwrap()).unwrap();
7131×
        assert!(marked.contains("<<<<<<< oxy conflict"), "{marked}");
714
        // Saving with markers left in is refused; saving without them resolves.
7151×
        assert!(host
7161×
            .build_change(
7171×
                &heads,
7181×
                &[FileEdit {
7191×
                    path: "a.txt".into(),
7201×
                    content: Some(format!("{marked}x\n").into_bytes())
7211×
                }],
7221×
                "half",
7231×
                author
7241×
            )
7251×
            .is_err());
7261×
        let res = host
7271×
            .build_change(
7281×
                &heads,
7291×
                &[FileEdit {
7301×
                    path: "a.txt".into(),
7311×
                    content: Some(b"One\ntwo\n".to_vec()),
7321×
                }],
7331×
                "Resolve",
7341×
                author,
735
            )
7361×
            .unwrap();
7371×
        assert_eq!(res.kind, ChangeKind::Resolution);
7381×
        assert!(
7391×
            res.deps.contains(&ha) && res.deps.contains(&hb),
740
            "a resolution depends on both sides"
741
        );
7421×
        let hr = res.hash();
7431×
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hr);
7441×
        let update = host
7451×
            .host_ref_update("main", &[hr], Some((&hub_key, &hub_sk)))
7461×
            .unwrap();
7471×
        let r = host
7481×
            .apply_hosted(Some((res, vec![sig])), &ids, update)
7491×
            .unwrap();
7501×
        assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
7511×
        assert_eq!(host.file_at("a.txt", Some(&[hr])).unwrap(), b"One\ntwo\n");
7521×
        assert!(!host
7531×
            .load_state(&[hr])
7541×
            .unwrap()
7551×
            .materialize()
7561×
            .is_conflicted());
7571×
        let _ = &mut user;
7581×
    }
759
}