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