OXYGENHUB
# 1db74949c876 oxygen / crates / oxy-core / src / repo / hosted.rs
39.3 KiB · Rust · last changed Fetch: carry attestation signers' identities; skip an unverifiable bookmark 17 days ago source blame history raw
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).
22
    pub fn build_change(
23
        &self,
24
        heads: &[ChangeHash],
25
        edits: &[FileEdit],
26
        message: &str,
27
        author: KeyId,
28
    ) -> Result<Change> {
29
        self.build_change_with(heads, edits, &[], message, author)
30
    }
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.
36
    pub fn build_pin_change(
37
        &self,
38
        heads: &[ChangeHash],
39
        path: &str,
40
        pin: &crate::subrepo::Pin,
41
        message: &str,
42
        author: KeyId,
43
    ) -> Result<Change> {
44
        self.build_change_with(
45
            heads,
46
            &[],
47
            &[(path.to_string(), pin.clone())],
48
            message,
49
            author,
50
        )
51
    }
52
53
    fn build_change_with(
54
        &self,
55
        heads: &[ChangeHash],
56
        edits: &[FileEdit],
57
        pins: &[(String, crate::subrepo::Pin)],
58
        message: &str,
59
        author: KeyId,
60
    ) -> Result<Change> {
61
        if message.trim().is_empty() {
62
            return fail("a change message is required");
63
        }
64
        if edits.is_empty() && pins.is_empty() {
65
            return fail("nothing to change");
66
        }
67
        let base = self.load_state(heads)?;
68
        let base_snapshot = base.materialize();
69
        let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
70
        for (path, f) in &base_snapshot.files {
71
            let entry = match (&f.kind, &f.content) {
72
                (NodeKind::Dir, _) | (_, FileContent::Dir) => continue,
73
                (kind, FileContent::Text { bytes, .. }) => {
74
                    FsEntry::new(*kind, f.mode.exec, bytes.clone())
75
                }
76
                (kind, FileContent::Opaque { blobs }) => FsEntry {
77
                    blob: blobs.first().copied(),
78
                    ..FsEntry::new(*kind, f.mode.exec, vec![])
79
                },
80
            };
81
            entries.insert(path.clone(), entry);
82
        }
83
        for e in edits {
84
            let path = e.path.trim_matches('/');
85
            if path.is_empty()
86
                || path
87
                    .split('/')
88
                    .any(|c| c.is_empty() || c == "." || c == ".." || c == OXY_DIR)
89
            {
90
                return fail(format!("{:?} is not a valid path", e.path));
91
            }
92
            match &e.content {
93
                Some(bytes) => {
94
                    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.
96
                    if base_snapshot
97
                        .files
98
                        .get(path)
99
                        .is_some_and(|f| f.kind == NodeKind::Dir)
100
                    {
101
                        return fail(format!("{path} is a directory"));
102
                    }
103
                    let mut parent = path;
104
                    while let Some((p, _)) = parent.rsplit_once('/') {
105
                        if base_snapshot
106
                            .files
107
                            .get(p)
108
                            .is_some_and(|f| f.kind != NodeKind::Dir)
109
                        {
110
                            return fail(format!("{p} is a file, not a directory"));
111
                        }
112
                        parent = p;
113
                    }
114
                    entries.insert(
115
                        path.to_string(),
116
                        FsEntry::new(NodeKind::File, exec, bytes.clone()),
117
                    );
118
                }
119
                None => {
120
                    if entries.remove(path).is_none() {
121
                        // Removing a directory removes everything below it.
122
                        let prefix = format!("{path}/");
123
                        let before = entries.len();
124
                        entries.retain(|p, _| !p.starts_with(&prefix));
125
                        if entries.len() == before {
126
                            return fail(format!("{path} does not exist"));
127
                        }
128
                    }
129
                }
130
            }
131
        }
132
        for (path, pin) in pins {
133
            let path = path.trim_matches('/');
134
            match base_snapshot.files.get(path) {
135
                Some(f) if f.kind == NodeKind::Subrepo => {}
136
                Some(_) => return fail(format!("{path} is not a subrepo")),
137
                None => return fail(format!("{path} does not exist")),
138
            }
139
            entries.insert(
140
                path.to_string(),
141
                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).
146
        let written: std::collections::HashSet<String> = edits
147
            .iter()
148
            .filter(|e| e.content.is_some())
149
            .map(|e| e.path.trim_matches('/').to_string())
150
            .chain(pins.iter().map(|(p, _)| p.trim_matches('/').to_string()))
151
            .collect();
152
        let built = self.build_from_entries_resolving(
153
            &base,
154
            &base_snapshot,
155
            HashMap::new(),
156
            &entries,
157
            &written,
158
        )?;
159
        if let Some(s) = built
160
            .statuses
161
            .iter()
162
            .find(|s| s.status == ChangeStatus::ConflictEdited)
163
        {
164
            return fail(format!("{} still contains conflict markers", s.path));
165
        }
166
        if built.tree_ops.is_empty() && built.edits.is_empty() {
167
            return fail("the edits change nothing");
168
        }
169
        let mut change = Change {
170
            version: crate::objects::CHANGE_VERSION,
171
            change_id: ChangeId::random(),
172
            deps: vec![],
173
            author,
174
            hlc: Hlc::now(),
175
            message: message.trim_end().to_string(),
176
            tree_ops: built.tree_ops,
177
            edits: built.edits,
178
            kind: if built.resolves {
179
                ChangeKind::Resolution
180
            } else {
181
                ChangeKind::Normal
182
            },
183
        };
184
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&change, &base);
185
        deps.extend(built.contributors.iter().copied());
186
        change.deps = deps.into_iter().collect();
187
        change.normalize();
188
        Ok(change)
189
    }
190
191
    /// A reference update of `bookmark` to `heads`, attested by `signer`
192
    /// when given (the attestation a push would carry, spec §14.4).
193
    pub fn host_ref_update(
194
        &self,
195
        bookmark: &str,
196
        heads: &[ChangeHash],
197
        signer: Option<(&KeyId, &SecretKey)>,
198
    ) -> Result<RefUpdate> {
199
        let view = self.view()?;
200
        let empty = self.store.put_state(&StateObject::new(vec![]))?;
201
        let old_state = view.bookmarks.get(bookmark).copied().unwrap_or(empty);
202
        let new_state = self.store.put_state(&StateObject::new(heads.to_vec()))?;
203
        let attestation = match signer {
204
            Some((key, sk)) => {
205
                let serial = self
206
                    .latest_ref_attest(bookmark)?
207
                    .and_then(|(_, a)| match a.attest.body {
208
                        AttestBody::Ref { serial, .. } => Some(serial),
209
                        _ => None,
210
                    })
211
                    .map(|s| s + 1)
212
                    .unwrap_or(1);
213
                let attest = Attest {
214
                    repo: self.id()?,
215
                    hlc: Hlc::now(),
216
                    body: AttestBody::Ref {
217
                        name: bookmark.to_string(),
218
                        old_state,
219
                        new_state,
220
                        serial,
221
                    },
222
                };
223
                let r = attest.hash();
224
                let sig = sk.sign(Domain::Attest, key, &r);
225
                Some(SignedAttest {
226
                    attest,
227
                    sigs: vec![sig],
228
                })
229
            }
230
            None => None,
231
        };
232
        Ok(RefUpdate {
233
            name: bookmark.to_string(),
234
            old_state,
235
            new_state,
236
            force: false,
237
            attestation,
238
        })
239
    }
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.
245
    pub fn apply_hosted(
246
        &self,
247
        change: Option<(Change, Vec<Signature>)>,
248
        identities: &[Identity],
249
        update: RefUpdate,
250
    ) -> Result<PushReport> {
251
        let mut bundle = Bundle::default();
252
        for id in identities {
253
            bundle.objects.push((ObjectType::Identity, id.encode()));
254
        }
255
        if let Ok(st) = self.store.get_state(&update.new_state) {
256
            bundle.objects.push((ObjectType::State, st.encode()));
257
        }
258
        if let Some((c, sigs)) = change {
259
            bundle.changes.push((c, sigs));
260
        }
261
        self.receive_push_report(bundle, vec![update], None, vec![])
262
    }
263
}
264
265
#[cfg(test)]
266
mod tests {
267
    use super::*;
268
    use crate::identity::SecretKey;
269
270
    fn hermetic() {
271
        static ONCE: std::sync::Once = std::sync::Once::new();
272
        static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
273
        ONCE.call_once(|| {
274
            let d = tempfile::tempdir().unwrap();
275
            std::env::set_var("OXY_HOME", d.path());
276
            DIR.set(d).ok();
277
        });
278
    }
279
280
    fn write(root: &Path, rel: &str, content: &[u8]) {
281
        let p = root.join(rel);
282
        fs::create_dir_all(p.parent().unwrap()).unwrap();
283
        fs::write(p, content).unwrap();
284
    }
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).
288
    fn setup(
289
        root: &Path,
290
        with_policy: bool,
291
        delegate: bool,
292
    ) -> (Repo, Repo, KeyId, SecretKey, Identity) {
293
        hermetic();
294
        let host_dir = root.join("host");
295
        Repo::init_bare_unbound(&host_dir).unwrap();
296
        let work = root.join("work");
297
        let mut user = Repo::init(&work).unwrap();
298
        user.create_identity("Andy", "[email protected]", IdentityScope::Repo)
299
            .unwrap();
300
        user.config.set("core.sign", "change").unwrap();
301
        user.config
302
            .set("remote.origin.url", host_dir.to_str().unwrap())
303
            .unwrap();
304
        user.save_config().unwrap();
305
        let hub_sk = SecretKey::generate();
306
        let hub_id = hub_sk.identity("Hub", "hub@example", 1);
307
        let hub_key = hub_id.hash();
308
        if with_policy {
309
            user.policy_init().unwrap();
310
        }
311
        if delegate {
312
            user.delegate_add(hub_id.clone(), Scope::CHANGES | Scope::REFS, None)
313
                .unwrap();
314
        }
315
        write(&work, "a.txt", b"one\ntwo\n");
316
        user.snapshot("t").unwrap();
317
        user.commit("first", &[]).unwrap();
318
        let r = user.push("origin", false).unwrap();
319
        assert!(r.iter().all(|x| x.ok), "{r:?}");
320
        (
321
            Repo::open(&host_dir).unwrap(),
322
            user,
323
            hub_key,
324
            hub_sk,
325
            hub_id,
326
        )
327
    }
328
329
    #[test]
330
    fn a_host_edit_signed_by_a_delegate_lands() {
331
        let dir = tempfile::tempdir().unwrap();
332
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
333
        let author = user.config.author_key.unwrap();
334
        let heads = host.bookmark_heads().unwrap();
335
        let change = host
336
            .build_change(
337
                &heads,
338
                &[
339
                    FileEdit {
340
                        path: "a.txt".into(),
341
                        content: Some(b"one\ntwo\nthree\n".to_vec()),
342
                    },
343
                    FileEdit {
344
                        path: "docs/new.md".into(),
345
                        content: Some(b"# New\n".to_vec()),
346
                    },
347
                ],
348
                "Edit on the web",
349
                author,
350
            )
351
            .unwrap();
352
        assert_eq!(change.author, author);
353
        assert_eq!(change.kind, ChangeKind::Normal);
354
        let hash = change.hash();
355
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
356
        let mut new_heads = heads.clone();
357
        new_heads.push(hash);
358
        let update = host
359
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
360
            .unwrap();
361
        let ids = vec![hub_id, user.identity(&author).unwrap()];
362
        let report = host
363
            .apply_hosted(Some((change, vec![sig])), &ids, update)
364
            .unwrap();
365
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
366
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
367
        assert_eq!(
368
            host.file_at("a.txt", Some(&[hash])).unwrap(),
369
            b"one\ntwo\nthree\n"
370
        );
371
        assert_eq!(
372
            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.
376
        assert!(
377
            matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Valid { key, name, .. } if key == hub_key && name == "Hub")
378
        );
379
        let _ = new_heads;
380
        // Bad paths and empty edits are refused.
381
        assert!(host
382
            .build_change(
383
                &[hash],
384
                &[FileEdit {
385
                    path: "../x".into(),
386
                    content: Some(vec![])
387
                }],
388
                "m",
389
                author
390
            )
391
            .is_err());
392
        assert!(
393
            host.build_change(
394
                &[hash],
395
                &[FileEdit {
396
                    path: "a.txt".into(),
397
                    content: Some(b"one\ntwo\nthree\n".to_vec())
398
                }],
399
                "m",
400
                author
401
            )
402
            .is_err(),
403
            "no-op edits are refused"
404
        );
405
        assert!(
406
            host.build_change(
407
                &[hash],
408
                &[FileEdit {
409
                    path: "a.txt/b".into(),
410
                    content: Some(vec![])
411
                }],
412
                "m",
413
                author
414
            )
415
            .is_err(),
416
            "a file inside a file"
417
        );
418
        let rm = host
419
            .build_change(
420
                &[hash],
421
                &[FileEdit {
422
                    path: "docs".into(),
423
                    content: None,
424
                }],
425
                "Remove docs",
426
                author,
427
            )
428
            .unwrap();
429
        assert!(!rm.tree_ops.is_empty());
430
    }
431
432
    #[test]
433
    fn a_host_edit_by_a_key_that_is_not_a_delegate_is_refused() {
434
        let dir = tempfile::tempdir().unwrap();
435
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, false);
436
        let author = user.config.author_key.unwrap();
437
        let heads = host.bookmark_heads().unwrap();
438
        let change = host
439
            .build_change(
440
                &heads,
441
                &[FileEdit {
442
                    path: "a.txt".into(),
443
                    content: Some(b"x\n".to_vec()),
444
                }],
445
                "Edit",
446
                author,
447
            )
448
            .unwrap();
449
        let hash = change.hash();
450
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
451
        let update = host
452
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
453
            .unwrap();
454
        let report = host
455
            .apply_hosted(
456
                Some((change, vec![sig])),
457
                &[hub_id, user.identity(&author).unwrap()],
458
                update,
459
            )
460
            .unwrap();
461
        // Without a policy the host accepts unauthenticated changes, but the
462
        // signature does not verify as the author's: it stays unauthenticated.
463
        assert!(report.results.iter().all(|r| r.ok));
464
        assert!(
465
            matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Invalid(why) if why.contains("neither the author"))
466
        );
467
    }
468
469
    #[test]
470
    fn under_a_policy_a_delegate_may_update_the_reference_and_a_stranger_may_not() {
471
        let dir = tempfile::tempdir().unwrap();
472
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), true, true);
473
        assert!(host.current_policy().unwrap().is_some());
474
        let author = user.config.author_key.unwrap();
475
        let heads = host.bookmark_heads().unwrap();
476
        let change = host
477
            .build_change(
478
                &heads,
479
                &[FileEdit {
480
                    path: "a.txt".into(),
481
                    content: Some(b"one\n".to_vec()),
482
                }],
483
                "Trim",
484
                author,
485
            )
486
            .unwrap();
487
        let hash = change.hash();
488
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
489
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
490
        let update = host
491
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
492
            .unwrap();
493
        let report = host
494
            .apply_hosted(Some((change, vec![sig])), &ids, update)
495
            .unwrap();
496
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
497
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
498
499
        // A stranger's key cannot move the reference, even with the user as author.
500
        let stranger = SecretKey::generate();
501
        let sid = stranger.identity("Mallory", "m@example", 1);
502
        let skey = sid.hash();
503
        let change2 = host
504
            .build_change(
505
                &[hash],
506
                &[FileEdit {
507
                    path: "a.txt".into(),
508
                    content: Some(b"evil\n".to_vec()),
509
                }],
510
                "Evil",
511
                author,
512
            )
513
            .unwrap();
514
        let h2 = change2.hash();
515
        let sig2 = stranger.sign(Domain::Change, &skey, &h2);
516
        let update = host
517
            .host_ref_update("main", &[h2], Some((&skey, &stranger)))
518
            .unwrap();
519
        let report = host
520
            .apply_hosted(Some((change2, vec![sig2])), &[sid], update)
521
            .unwrap();
522
        assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results);
523
        assert_eq!(
524
            host.bookmark_heads().unwrap(),
525
            vec![hash],
526
            "the reference did not move"
527
        );
528
        // An unsigned update is refused under a policy too.
529
        let update = host.host_ref_update("main", &[hash], None).unwrap();
530
        let report = host.apply_hosted(None, &[], update).unwrap();
531
        assert!(report.results.iter().all(|r| !r.ok));
532
    }
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]
538
    fn a_changes_only_delegate_signs_changes_but_cannot_seal_or_move_a_reference() {
539
        let dir = tempfile::tempdir().unwrap();
540
        let (host, mut user, _, _, _) = setup(dir.path(), true, false);
541
        let agent_sk = SecretKey::generate();
542
        let agent_id = agent_sk.identity("aider on a laptop", "agent@example", 1);
543
        let agent = agent_id.hash();
544
        user.delegate_add(agent_id.clone(), Scope::CHANGES, None)
545
            .unwrap();
546
        // The successor identity reaches the host with the rotated policy.
547
        assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok));
548
        let author = user.config.author_key.unwrap();
549
        let ids = vec![agent_id.clone(), user.identity(&author).unwrap()];
550
        let heads = host.bookmark_heads().unwrap();
551
        let change = host
552
            .build_change(
553
                &heads,
554
                &[FileEdit {
555
                    path: "a.txt".into(),
556
                    content: Some(b"by the agent\n".to_vec()),
557
                }],
558
                "Agent edit",
559
                author,
560
            )
561
            .unwrap();
562
        let hash = change.hash();
563
        let sig = agent_sk.sign(Domain::Change, &agent, &hash);
564
        // Attested by the agent: refused, the reference stays.
565
        let update = host
566
            .host_ref_update("main", &[hash], Some((&agent, &agent_sk)))
567
            .unwrap();
568
        let report = host
569
            .apply_hosted(Some((change.clone(), vec![sig.clone()])), &ids, update)
570
            .unwrap();
571
        assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results);
572
        assert_eq!(host.bookmark_heads().unwrap(), heads);
573
        // Attested by the owner: the agent-signed change lands.
574
        let (owner, owner_sk) = user.secret_key().unwrap().unwrap();
575
        let update = host
576
            .host_ref_update("main", &[hash], Some((&owner, &owner_sk)))
577
            .unwrap();
578
        let report = host
579
            .apply_hosted(Some((change, vec![sig])), &ids, update)
580
            .unwrap();
581
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
582
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
583
        assert!(matches!(
584
            host.authenticate_change(&hash).unwrap(),
585
            SignatureStatus::Valid { key, sealed: false, .. } if key == agent
586
        ));
587
        // A seal by the agent is outside its scope.
588
        let change2 = host
589
            .build_change(
590
                &[hash],
591
                &[FileEdit {
592
                    path: "a.txt".into(),
593
                    content: Some(b"sealed by the agent\n".to_vec()),
594
                }],
595
                "Sealed",
596
                author,
597
            )
598
            .unwrap();
599
        let h2 = host.store().put_change(&change2).unwrap();
600
        let seal = host
601
            .store()
602
            .put_attest(&Attest::seal(host.id().unwrap(), vec![h2]))
603
            .unwrap();
604
        host.store()
605
            .put_signature(&seal, &agent_sk.sign(Domain::Attest, &agent, &seal))
606
            .unwrap();
607
        host.store().index_seal(&h2, &seal).unwrap();
608
        assert!(matches!(
609
            host.authenticate_change(&h2).unwrap(),
610
            SignatureStatus::Invalid(e) if e.contains("may not seal")
611
        ));
612
    }
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]
620
    fn an_agent_signs_its_owner_s_changes_through_author_signer() {
621
        let dir = tempfile::tempdir().unwrap();
622
        let (host, mut user, _, _, _) = setup(dir.path(), true, false);
623
        let owner = user.config.author_key.unwrap();
624
        // The agent makes its own key in its own home; the owner delegates.
625
        let agent_home = dir.path().join("agent-home");
626
        let mut agent_repo = Repo::init(&dir.path().join("agent-work"))
627
            .unwrap()
628
            .with_home(agent_home.clone());
629
        let (agent, agent_id) = agent_repo
630
            .create_identity("aider on a laptop", "agent@example", IdentityScope::User)
631
            .unwrap();
632
        user.delegate_add(agent_id.clone(), Scope::CHANGES, None)
633
            .unwrap();
634
        assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok));
635
        host.refresh().unwrap();
636
        let owner = {
637
            let _ = owner;
638
            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.)
644
        let work = dir.path().join("work");
645
        let mut as_agent = Repo::open(&work).unwrap().with_home(agent_home);
646
        as_agent
647
            .set_user_config("author.signer", &agent.to_hex())
648
            .unwrap();
649
        as_agent.set_user_config("core.sign", "change").unwrap();
650
        assert_eq!(as_agent.config.author_key, Some(owner));
651
        assert_eq!(as_agent.signing_key(), Some(agent));
652
        write(&work, "a.txt", b"by the agent\n");
653
        as_agent.snapshot("t").unwrap();
654
        let h = as_agent.commit("Agent: edit a", &[]).unwrap();
655
        let meta = as_agent.store().get_change_meta(&h).unwrap();
656
        assert_eq!(meta.author, owner, "the owner stays the author");
657
        assert!(matches!(
658
            as_agent.verify_change(&h).unwrap(),
659
            SignatureStatus::Valid { key, .. } if key == agent
660
        ));
661
        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.
664
        let r = as_agent.push("origin", false).unwrap();
665
        host.refresh().unwrap();
666
        assert!(r.iter().all(|x| !x.ok), "{r:?}");
667
        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.
671
        user.refresh().unwrap();
672
        let r = user.push("origin", false).unwrap();
673
        host.refresh().unwrap();
674
        assert!(r.iter().all(|x| x.ok), "{r:?}");
675
        assert!(host.bookmark_heads().unwrap().contains(&h));
676
        assert!(matches!(
677
            host.authenticate_change(&h).unwrap(),
678
            SignatureStatus::Valid { key, .. } if key == agent
679
        ));
680
    }
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]
687
    fn a_delegate_s_first_push_of_a_bookmark_carries_its_identity() {
688
        let dir = tempfile::tempdir().unwrap();
689
        let (host, mut user, _, _, _) = setup(dir.path(), true, false);
690
        let agent_home = dir.path().join("agent-home");
691
        let mut agent_repo = Repo::init(&dir.path().join("agent-work"))
692
            .unwrap()
693
            .with_home(agent_home.clone());
694
        let (agent, agent_id) = agent_repo
695
            .create_identity("agent", "agent@example", IdentityScope::User)
696
            .unwrap();
697
        user.delegate_add(agent_id, Scope::CHANGES | Scope::REFS, None)
698
            .unwrap();
699
        assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok));
700
        let work = dir.path().join("work");
701
        let mut as_agent = Repo::open(&work).unwrap().with_home(agent_home);
702
        as_agent
703
            .set_user_config("author.signer", &agent.to_hex())
704
            .unwrap();
705
        assert!(
706
            host.store().get_identity(&agent).is_err(),
707
            "the host has never seen the agent"
708
        );
709
        let heads = as_agent.bookmark_heads().unwrap();
710
        as_agent.bookmark_create("feature", Some(&heads)).unwrap();
711
        let r = as_agent.push_bookmark("origin", "feature", false).unwrap();
712
        assert!(r.iter().all(|x| x.ok), "{r:?}");
713
        assert!(
714
            host.store().get_identity(&agent).is_ok(),
715
            "its identity came with the move"
716
        );
717
    }
718
719
    /// oxygenhub#266: a bookmark whose latest attestation a delegate made,
720
    /// signing no change, is fetched by a third party. The fetch bundle
721
    /// carries the delegate's identity even when it asks for no change,
722
    /// so a client as it was (import the bundle, then check each
723
    /// bookmark's attestation) verifies the move instead of failing the
724
    /// whole fetch with "unknown key".
725
    #[test]
726
    fn a_bookmark_attested_by_a_key_that_signed_no_change_is_fetched() {
727
        let dir = tempfile::tempdir().unwrap();
728
        let (host, mut user, _, _, _) = setup(dir.path(), true, false);
729
        let agent_home = dir.path().join("agent-home");
730
        let mut agent_repo = Repo::init(&dir.path().join("agent-work"))
731
            .unwrap()
732
            .with_home(agent_home.clone());
733
        let (agent, agent_id) = agent_repo
734
            .create_identity("agent", "agent@example", IdentityScope::User)
735
            .unwrap();
736
        user.delegate_add(agent_id, Scope::CHANGES | Scope::REFS, None)
737
            .unwrap();
738
        assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok));
739
        let mut as_agent = Repo::open(&dir.path().join("work"))
740
            .unwrap()
741
            .with_home(agent_home);
742
        as_agent
743
            .set_user_config("author.signer", &agent.to_hex())
744
            .unwrap();
745
        let heads = as_agent.bookmark_heads().unwrap();
746
        as_agent.bookmark_create("feature", Some(&heads)).unwrap();
747
        let r = as_agent.push_bookmark("origin", "feature", false).unwrap();
748
        assert!(r.iter().all(|x| x.ok), "{r:?}");
749
        host.refresh().unwrap();
750
        let (_, sa) = host.latest_ref_attest("feature").unwrap().unwrap();
751
        assert_eq!(sa.sigs[0].key, agent, "the agent attested the move");
752
753
        // What a fetch that names no change brings: the agent's identity.
754
        let remote = crate::protocol::LocalRemote::open(&dir.path().join("host")).unwrap();
755
        let carries = |b: &Bundle| {
756
            b.objects
757
                .iter()
758
                .any(|(t, body)| *t == ObjectType::Identity && hash_object(*t, body) == agent)
759
        };
760
        assert!(carries(
761
            &crate::protocol::Remote::fetch(&remote, &[], None).unwrap()
762
        ));
763
        assert!(carries(&host.bundle_for_filtered(&[], None).unwrap()));
764
765
        // A third party's clone takes both bookmarks, verified.
766
        let theirs = Repo::clone(
767
            dir.path().join("host").to_str().unwrap(),
768
            &dir.path().join("theirs"),
769
        )
770
        .unwrap();
771
        let tracked = theirs.view().unwrap().remotes["origin"].clone();
772
        assert_eq!(
773
            tracked.get("feature"),
774
            host.view().unwrap().bookmarks.get("feature")
775
        );
776
        assert!(theirs.store().get_identity(&agent).is_ok());
777
        let report = theirs.fetch("origin").unwrap();
778
        assert!(report.skipped.is_empty(), "{:?}", report.skipped);
779
    }
780
781
    /// Make `bookmark`'s latest attestation on `host` one signed as `key`
782
    /// with `sk`: by a key the host never saw (as a host that did not send
783
    /// the signer's identity looked), or a bad signature.
784
    fn reattest(host: &Repo, bookmark: &str, key: KeyId, sk: &SecretKey) {
785
        let state = host.view().unwrap().bookmarks[bookmark];
786
        let (_, last) = host.latest_ref_attest(bookmark).unwrap().unwrap();
787
        let AttestBody::Ref {
788
            old_state, serial, ..
789
        } = last.attest.body
790
        else {
791
            panic!("not a ref attestation")
792
        };
793
        let attest = Attest {
794
            repo: host.id().unwrap(),
795
            hlc: Hlc::now(),
796
            body: AttestBody::Ref {
797
                name: bookmark.into(),
798
                old_state,
799
                new_state: state,
800
                serial: serial + 1,
801
            },
802
        };
803
        let r = host.store().put_attest(&attest).unwrap();
804
        host.store()
805
            .put_signature(&r, &sk.sign(Domain::Attest, &key, &attest.hash()))
806
            .unwrap();
807
        host.set_ref_attest(bookmark, &r).unwrap();
808
    }
809
810
    /// oxygenhub#266: a bookmark whose attestation a client cannot verify
811
    /// (its signer unknown, or its signature bad) is left as it was, named
812
    /// with why, and the rest are fetched. The current bookmark is not
813
    /// skipped: a clone, fetch or pull that would take it fails.
814
    #[test]
815
    fn an_unverifiable_bookmark_is_skipped_but_never_the_current_one() {
816
        let dir = tempfile::tempdir().unwrap();
817
        let (host, user, _, _, _) = setup(dir.path(), false, false);
818
        let author = user.config.author_key.unwrap();
819
        let heads = user.bookmark_heads().unwrap();
820
        for b in ["unknown", "forged", "fine"] {
821
            user.bookmark_create(b, Some(&heads)).unwrap();
822
            let r = user.push_bookmark("origin", b, false).unwrap();
823
            assert!(r.iter().all(|x| x.ok), "{r:?}");
824
        }
825
        host.refresh().unwrap();
826
        let stranger_sk = SecretKey::generate();
827
        let stranger = stranger_sk.identity("s", "s@example", 1).hash();
828
        reattest(&host, "unknown", stranger, &stranger_sk);
829
        reattest(&host, "forged", author, &stranger_sk);
830
831
        let url = dir.path().join("host").to_string_lossy().to_string();
832
        let (theirs, skipped) =
833
            Repo::clone_reporting(&url, &dir.path().join("theirs"), None, &[], None).unwrap();
834
        let names: Vec<&str> = skipped.iter().map(|(n, _)| n.as_str()).collect();
835
        assert_eq!(names, ["forged", "unknown"]);
836
        assert!(skipped[1].1.contains("unknown key"), "{skipped:?}");
837
        assert!(skipped[1].1.contains("origin/unknown"), "{skipped:?}");
838
        let tracked = theirs.view().unwrap().remotes["origin"].clone();
839
        assert!(tracked.contains_key("main") && tracked.contains_key("fine"));
840
        assert!(!tracked.contains_key("unknown") && !tracked.contains_key("forged"));
841
        assert_eq!(theirs.config.bookmark, "main");
842
        assert_eq!(theirs.fetch("origin").unwrap().skipped.len(), 2);
843
        assert_eq!(theirs.pull("origin").unwrap().skipped.len(), 2);
844
845
        // The current bookmark's attestation unverifiable: a fetch and a
846
        // pull fail, and so does a fresh clone.
847
        reattest(&host, "main", stranger, &stranger_sk);
848
        for err in [
849
            theirs.fetch("origin").unwrap_err(),
850
            theirs.pull("origin").unwrap_err(),
851
            Repo::clone(&url, &dir.path().join("again"))
852
                .err()
853
                .expect("the clone fails"),
854
        ] {
855
            let err = err.to_string();
856
            assert!(
857
                err.contains("attestation for origin/main: unknown key"),
858
                "{err}"
859
            );
860
        }
861
    }
862
863
    /// A delegation past its expiry authenticates nothing made after it,
864
    /// and the delegator may revoke the key; a stranger's key, they may not.
865
    #[test]
866
    fn an_expired_delegation_is_refused_and_the_delegator_may_revoke() {
867
        let dir = tempfile::tempdir().unwrap();
868
        let (_, mut user, _, _, _) = setup(dir.path(), false, false);
869
        let agent_sk = SecretKey::generate();
870
        let agent_id = agent_sk.identity("agent", "agent@example", 1);
871
        let agent = agent_id.hash();
872
        let yesterday = Hlc::now().wall_ms - 24 * 3600 * 1000;
873
        user.delegate_add(agent_id, Scope::CHANGES, Some(yesterday))
874
            .unwrap();
875
        user.config.set("core.sign", "seal").unwrap();
876
        user.save_config().unwrap();
877
        write(&dir.path().join("work"), "a.txt", b"later\n");
878
        user.snapshot("t").unwrap();
879
        let h = user.commit("after the delegation ended", &[]).unwrap();
880
        user.store()
881
            .put_signature(&h, &agent_sk.sign(Domain::Change, &agent, &h))
882
            .unwrap();
883
        let status = user.verify_change(&h).unwrap();
884
        assert!(
885
            matches!(&status, SignatureStatus::Invalid(e) if e.contains("expired")),
886
            "{status:?}"
887
        );
888
        // No policy here, so the user is no root: only delegation lets them revoke.
889
        let stranger = SecretKey::generate().identity("x", "x@example", 1).hash();
890
        assert!(user.revoke_identity(&stranger, "").is_err());
891
        assert!(user.revoke_identity(&agent, "session over").is_ok());
892
        assert!(user.is_revoked(&agent).unwrap());
893
        let r = user.delegate_remove(&agent).unwrap();
894
        assert!(user.identity(&r.new).unwrap().delegates.is_empty());
895
    }
896
897
    #[test]
898
    fn a_host_edit_that_removes_markers_resolves_the_conflict() {
899
        let dir = tempfile::tempdir().unwrap();
900
        let (host, mut user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
901
        let author = user.config.author_key.unwrap();
902
        let base = host.bookmark_heads().unwrap();
903
        // Two independent edits of the same line: a conflict in the union.
904
        let a = host
905
            .build_change(
906
                &base,
907
                &[FileEdit {
908
                    path: "a.txt".into(),
909
                    content: Some(b"ONE\ntwo\n".to_vec()),
910
                }],
911
                "A",
912
                author,
913
            )
914
            .unwrap();
915
        let b = host
916
            .build_change(
917
                &base,
918
                &[FileEdit {
919
                    path: "a.txt".into(),
920
                    content: Some(b"uno\ntwo\n".to_vec()),
921
                }],
922
                "B",
923
                author,
924
            )
925
            .unwrap();
926
        let (ha, hb) = (a.hash(), b.hash());
927
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
928
        for (c, h) in [(a, ha), (b, hb)] {
929
            let sig = hub_sk.sign(Domain::Change, &hub_key, &h);
930
            let mut heads = host.bookmark_heads().unwrap();
931
            heads.retain(|x| !base.contains(x));
932
            heads.push(h);
933
            let update = host
934
                .host_ref_update("main", &heads, Some((&hub_key, &hub_sk)))
935
                .unwrap();
936
            let r = host
937
                .apply_hosted(Some((c, vec![sig])), &ids, update)
938
                .unwrap();
939
            assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
940
        }
941
        let heads = host.bookmark_heads().unwrap();
942
        assert_eq!(heads.len(), 2);
943
        let marked = String::from_utf8(host.file_at("a.txt", Some(&heads)).unwrap()).unwrap();
944
        assert!(marked.contains("<<<<<<< oxy conflict"), "{marked}");
945
        // Saving with markers left in is refused; saving without them resolves.
946
        assert!(host
947
            .build_change(
948
                &heads,
949
                &[FileEdit {
950
                    path: "a.txt".into(),
951
                    content: Some(format!("{marked}x\n").into_bytes())
952
                }],
953
                "half",
954
                author
955
            )
956
            .is_err());
957
        let res = host
958
            .build_change(
959
                &heads,
960
                &[FileEdit {
961
                    path: "a.txt".into(),
962
                    content: Some(b"One\ntwo\n".to_vec()),
963
                }],
964
                "Resolve",
965
                author,
966
            )
967
            .unwrap();
968
        assert_eq!(res.kind, ChangeKind::Resolution);
969
        assert!(
970
            res.deps.contains(&ha) && res.deps.contains(&hb),
971
            "a resolution depends on both sides"
972
        );
973
        let hr = res.hash();
974
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hr);
975
        let update = host
976
            .host_ref_update("main", &[hr], Some((&hub_key, &hub_sk)))
977
            .unwrap();
978
        let r = host
979
            .apply_hosted(Some((res, vec![sig])), &ids, update)
980
            .unwrap();
981
        assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
982
        assert_eq!(host.file_at("a.txt", Some(&[hr])).unwrap(), b"One\ntwo\n");
983
        assert!(!host
984
            .load_state(&[hr])
985
            .unwrap()
986
            .materialize()
987
            .is_conflicted());
988
        let _ = &mut user;
989
    }
990
}