OXYGENHUB
# 7ba155f20aa0 oxygen / crates / oxy-core / src / repo / hosted.rs
25.7 KiB · Rust · last changed Scoped, expiring delegates, and revocation by the delegator 1 month 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
        let built = self.build_from_entries(&base, &base_snapshot, HashMap::new(), &entries)?;
101
        if let Some(s) = built
102
            .statuses
103
            .iter()
104
            .find(|s| s.status == ChangeStatus::ConflictEdited)
105
        {
106
            return fail(format!("{} still contains conflict markers", s.path));
107
        }
108
        if built.tree_ops.is_empty() && built.edits.is_empty() {
109
            return fail("the edits change nothing");
110
        }
111
        let mut change = Change {
112
            change_id: ChangeId::random(),
113
            deps: vec![],
114
            author,
115
            hlc: Hlc::now(),
116
            message: message.trim_end().to_string(),
117
            tree_ops: built.tree_ops,
118
            edits: built.edits,
119
            kind: if built.resolves {
120
                ChangeKind::Resolution
121
            } else {
122
                ChangeKind::Normal
123
            },
124
        };
125
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&change, &base);
126
        deps.extend(built.contributors.iter().copied());
127
        change.deps = deps.into_iter().collect();
128
        change.normalize();
129
        Ok(change)
130
    }
131
132
    /// A reference update of `bookmark` to `heads`, attested by `signer`
133
    /// when given (the attestation a push would carry, spec §14.4).
134
    pub fn host_ref_update(
135
        &self,
136
        bookmark: &str,
137
        heads: &[ChangeHash],
138
        signer: Option<(&KeyId, &SecretKey)>,
139
    ) -> Result<RefUpdate> {
140
        let view = self.view()?;
141
        let empty = self.store.put_state(&StateObject::new(vec![]))?;
142
        let old_state = view.bookmarks.get(bookmark).copied().unwrap_or(empty);
143
        let new_state = self.store.put_state(&StateObject::new(heads.to_vec()))?;
144
        let attestation = match signer {
145
            Some((key, sk)) => {
146
                let serial = self
147
                    .latest_ref_attest(bookmark)?
148
                    .and_then(|(_, a)| match a.attest.body {
149
                        AttestBody::Ref { serial, .. } => Some(serial),
150
                        _ => None,
151
                    })
152
                    .map(|s| s + 1)
153
                    .unwrap_or(1);
154
                let attest = Attest {
155
                    repo: self.id()?,
156
                    hlc: Hlc::now(),
157
                    body: AttestBody::Ref {
158
                        name: bookmark.to_string(),
159
                        old_state,
160
                        new_state,
161
                        serial,
162
                    },
163
                };
164
                let r = attest.hash();
165
                let sig = sk.sign(Domain::Attest, key, &r);
166
                Some(SignedAttest {
167
                    attest,
168
                    sigs: vec![sig],
169
                })
170
            }
171
            None => None,
172
        };
173
        Ok(RefUpdate {
174
            name: bookmark.to_string(),
175
            old_state,
176
            new_state,
177
            force: false,
178
            attestation,
179
        })
180
    }
181
182
    /// Apply a host-made update as if it had been pushed: the new change
183
    /// (with its signatures) if any, the identities it needs, and the
184
    /// reference update, all through `receive_push_report`, so the same
185
    /// authentication, compare-and-swap and policy checks apply.
186
    pub fn apply_hosted(
187
        &self,
188
        change: Option<(Change, Vec<Signature>)>,
189
        identities: &[Identity],
190
        update: RefUpdate,
191
    ) -> Result<PushReport> {
192
        let mut bundle = Bundle::default();
193
        for id in identities {
194
            bundle.objects.push((ObjectType::Identity, id.encode()));
195
        }
196
        if let Ok(st) = self.store.get_state(&update.new_state) {
197
            bundle.objects.push((ObjectType::State, st.encode()));
198
        }
199
        if let Some((c, sigs)) = change {
200
            bundle.changes.push((c, sigs));
201
        }
202
        self.receive_push_report(bundle, vec![update], None, vec![])
203
    }
204
}
205
206
#[cfg(test)]
207
mod tests {
208
    use super::*;
209
    use crate::identity::SecretKey;
210
211
    fn hermetic() {
212
        static ONCE: std::sync::Once = std::sync::Once::new();
213
        static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
214
        ONCE.call_once(|| {
215
            let d = tempfile::tempdir().unwrap();
216
            std::env::set_var("OXY_HOME", d.path());
217
            DIR.set(d).ok();
218
        });
219
    }
220
221
    fn write(root: &Path, rel: &str, content: &[u8]) {
222
        let p = root.join(rel);
223
        fs::create_dir_all(p.parent().unwrap()).unwrap();
224
        fs::write(p, content).unwrap();
225
    }
226
227
    /// A user repository pushed to a bare host, and a hub key the user
228
    /// has delegated to. Returns (host, user, hub key, hub secret).
229
    fn setup(
230
        root: &Path,
231
        with_policy: bool,
232
        delegate: bool,
233
    ) -> (Repo, Repo, KeyId, SecretKey, Identity) {
234
        hermetic();
235
        let host_dir = root.join("host");
236
        Repo::init_bare_unbound(&host_dir).unwrap();
237
        let work = root.join("work");
238
        let mut user = Repo::init(&work).unwrap();
239
        user.create_identity("Andy", "[email protected]", IdentityScope::Repo)
240
            .unwrap();
241
        user.config.set("core.sign", "change").unwrap();
242
        user.config
243
            .set("remote.origin.url", host_dir.to_str().unwrap())
244
            .unwrap();
245
        user.save_config().unwrap();
246
        let hub_sk = SecretKey::generate();
247
        let hub_id = hub_sk.identity("Hub", "hub@example", 1);
248
        let hub_key = hub_id.hash();
249
        if with_policy {
250
            user.policy_init().unwrap();
251
        }
252
        if delegate {
253
            user.delegate_add(hub_id.clone(), Scope::CHANGES | Scope::REFS, None)
254
                .unwrap();
255
        }
256
        write(&work, "a.txt", b"one\ntwo\n");
257
        user.snapshot("t").unwrap();
258
        user.commit("first", &[]).unwrap();
259
        let r = user.push("origin", false).unwrap();
260
        assert!(r.iter().all(|x| x.ok), "{r:?}");
261
        (
262
            Repo::open(&host_dir).unwrap(),
263
            user,
264
            hub_key,
265
            hub_sk,
266
            hub_id,
267
        )
268
    }
269
270
    #[test]
271
    fn a_host_edit_signed_by_a_delegate_lands() {
272
        let dir = tempfile::tempdir().unwrap();
273
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
274
        let author = user.config.author_key.unwrap();
275
        let heads = host.bookmark_heads().unwrap();
276
        let change = host
277
            .build_change(
278
                &heads,
279
                &[
280
                    FileEdit {
281
                        path: "a.txt".into(),
282
                        content: Some(b"one\ntwo\nthree\n".to_vec()),
283
                    },
284
                    FileEdit {
285
                        path: "docs/new.md".into(),
286
                        content: Some(b"# New\n".to_vec()),
287
                    },
288
                ],
289
                "Edit on the web",
290
                author,
291
            )
292
            .unwrap();
293
        assert_eq!(change.author, author);
294
        assert_eq!(change.kind, ChangeKind::Normal);
295
        let hash = change.hash();
296
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
297
        let mut new_heads = heads.clone();
298
        new_heads.push(hash);
299
        let update = host
300
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
301
            .unwrap();
302
        let ids = vec![hub_id, user.identity(&author).unwrap()];
303
        let report = host
304
            .apply_hosted(Some((change, vec![sig])), &ids, update)
305
            .unwrap();
306
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
307
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
308
        assert_eq!(
309
            host.file_at("a.txt", Some(&[hash])).unwrap(),
310
            b"one\ntwo\nthree\n"
311
        );
312
        assert_eq!(
313
            host.file_at("docs/new.md", Some(&[hash])).unwrap(),
314
            b"# New\n"
315
        );
316
        // Authored by the user, authenticated by the hub key as delegate.
317
        assert!(
318
            matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Valid { key, name, .. } if key == hub_key && name == "Hub")
319
        );
320
        let _ = new_heads;
321
        // Bad paths and empty edits are refused.
322
        assert!(host
323
            .build_change(
324
                &[hash],
325
                &[FileEdit {
326
                    path: "../x".into(),
327
                    content: Some(vec![])
328
                }],
329
                "m",
330
                author
331
            )
332
            .is_err());
333
        assert!(
334
            host.build_change(
335
                &[hash],
336
                &[FileEdit {
337
                    path: "a.txt".into(),
338
                    content: Some(b"one\ntwo\nthree\n".to_vec())
339
                }],
340
                "m",
341
                author
342
            )
343
            .is_err(),
344
            "no-op edits are refused"
345
        );
346
        assert!(
347
            host.build_change(
348
                &[hash],
349
                &[FileEdit {
350
                    path: "a.txt/b".into(),
351
                    content: Some(vec![])
352
                }],
353
                "m",
354
                author
355
            )
356
            .is_err(),
357
            "a file inside a file"
358
        );
359
        let rm = host
360
            .build_change(
361
                &[hash],
362
                &[FileEdit {
363
                    path: "docs".into(),
364
                    content: None,
365
                }],
366
                "Remove docs",
367
                author,
368
            )
369
            .unwrap();
370
        assert!(!rm.tree_ops.is_empty());
371
    }
372
373
    #[test]
374
    fn a_host_edit_by_a_key_that_is_not_a_delegate_is_refused() {
375
        let dir = tempfile::tempdir().unwrap();
376
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, false);
377
        let author = user.config.author_key.unwrap();
378
        let heads = host.bookmark_heads().unwrap();
379
        let change = host
380
            .build_change(
381
                &heads,
382
                &[FileEdit {
383
                    path: "a.txt".into(),
384
                    content: Some(b"x\n".to_vec()),
385
                }],
386
                "Edit",
387
                author,
388
            )
389
            .unwrap();
390
        let hash = change.hash();
391
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
392
        let update = host
393
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
394
            .unwrap();
395
        let report = host
396
            .apply_hosted(
397
                Some((change, vec![sig])),
398
                &[hub_id, user.identity(&author).unwrap()],
399
                update,
400
            )
401
            .unwrap();
402
        // Without a policy the host accepts unauthenticated changes, but the
403
        // signature does not verify as the author's: it stays unauthenticated.
404
        assert!(report.results.iter().all(|r| r.ok));
405
        assert!(
406
            matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Invalid(why) if why.contains("neither the author"))
407
        );
408
    }
409
410
    #[test]
411
    fn under_a_policy_a_delegate_may_update_the_reference_and_a_stranger_may_not() {
412
        let dir = tempfile::tempdir().unwrap();
413
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), true, true);
414
        assert!(host.current_policy().unwrap().is_some());
415
        let author = user.config.author_key.unwrap();
416
        let heads = host.bookmark_heads().unwrap();
417
        let change = host
418
            .build_change(
419
                &heads,
420
                &[FileEdit {
421
                    path: "a.txt".into(),
422
                    content: Some(b"one\n".to_vec()),
423
                }],
424
                "Trim",
425
                author,
426
            )
427
            .unwrap();
428
        let hash = change.hash();
429
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
430
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
431
        let update = host
432
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
433
            .unwrap();
434
        let report = host
435
            .apply_hosted(Some((change, vec![sig])), &ids, update)
436
            .unwrap();
437
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
438
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
439
440
        // A stranger's key cannot move the reference, even with the user as author.
441
        let stranger = SecretKey::generate();
442
        let sid = stranger.identity("Mallory", "m@example", 1);
443
        let skey = sid.hash();
444
        let change2 = host
445
            .build_change(
446
                &[hash],
447
                &[FileEdit {
448
                    path: "a.txt".into(),
449
                    content: Some(b"evil\n".to_vec()),
450
                }],
451
                "Evil",
452
                author,
453
            )
454
            .unwrap();
455
        let h2 = change2.hash();
456
        let sig2 = stranger.sign(Domain::Change, &skey, &h2);
457
        let update = host
458
            .host_ref_update("main", &[h2], Some((&skey, &stranger)))
459
            .unwrap();
460
        let report = host
461
            .apply_hosted(Some((change2, vec![sig2])), &[sid], update)
462
            .unwrap();
463
        assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results);
464
        assert_eq!(
465
            host.bookmark_heads().unwrap(),
466
            vec![hash],
467
            "the reference did not move"
468
        );
469
        // An unsigned update is refused under a policy too.
470
        let update = host.host_ref_update("main", &[hash], None).unwrap();
471
        let report = host.apply_hosted(None, &[], update).unwrap();
472
        assert!(report.results.iter().all(|r| !r.ok));
473
    }
474
475
    /// Agent doc §2: an agent's key signs changes for its owner and nothing
476
    /// more. Its reference attestation is refused even though its owner's
477
    /// would pass, and a seal it makes authenticates nothing.
478
    #[test]
479
    fn a_changes_only_delegate_signs_changes_but_cannot_seal_or_move_a_reference() {
480
        let dir = tempfile::tempdir().unwrap();
481
        let (host, mut user, _, _, _) = setup(dir.path(), true, false);
482
        let agent_sk = SecretKey::generate();
483
        let agent_id = agent_sk.identity("aider on a laptop", "agent@example", 1);
484
        let agent = agent_id.hash();
485
        user.delegate_add(agent_id.clone(), Scope::CHANGES, None)
486
            .unwrap();
487
        // The successor identity reaches the host with the rotated policy.
488
        assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok));
489
        let author = user.config.author_key.unwrap();
490
        let ids = vec![agent_id.clone(), user.identity(&author).unwrap()];
491
        let heads = host.bookmark_heads().unwrap();
492
        let change = host
493
            .build_change(
494
                &heads,
495
                &[FileEdit {
496
                    path: "a.txt".into(),
497
                    content: Some(b"by the agent\n".to_vec()),
498
                }],
499
                "Agent edit",
500
                author,
501
            )
502
            .unwrap();
503
        let hash = change.hash();
504
        let sig = agent_sk.sign(Domain::Change, &agent, &hash);
505
        // Attested by the agent: refused, the reference stays.
506
        let update = host
507
            .host_ref_update("main", &[hash], Some((&agent, &agent_sk)))
508
            .unwrap();
509
        let report = host
510
            .apply_hosted(Some((change.clone(), vec![sig.clone()])), &ids, update)
511
            .unwrap();
512
        assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results);
513
        assert_eq!(host.bookmark_heads().unwrap(), heads);
514
        // Attested by the owner: the agent-signed change lands.
515
        let (owner, owner_sk) = user.secret_key().unwrap().unwrap();
516
        let update = host
517
            .host_ref_update("main", &[hash], Some((&owner, &owner_sk)))
518
            .unwrap();
519
        let report = host
520
            .apply_hosted(Some((change, vec![sig])), &ids, update)
521
            .unwrap();
522
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
523
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
524
        assert!(matches!(
525
            host.authenticate_change(&hash).unwrap(),
526
            SignatureStatus::Valid { key, sealed: false, .. } if key == agent
527
        ));
528
        // A seal by the agent is outside its scope.
529
        let change2 = host
530
            .build_change(
531
                &[hash],
532
                &[FileEdit {
533
                    path: "a.txt".into(),
534
                    content: Some(b"sealed by the agent\n".to_vec()),
535
                }],
536
                "Sealed",
537
                author,
538
            )
539
            .unwrap();
540
        let h2 = host.store().put_change(&change2).unwrap();
541
        let seal = host
542
            .store()
543
            .put_attest(&Attest::seal(host.id().unwrap(), vec![h2]))
544
            .unwrap();
545
        host.store()
546
            .put_signature(&seal, &agent_sk.sign(Domain::Attest, &agent, &seal))
547
            .unwrap();
548
        host.store().index_seal(&h2, &seal).unwrap();
549
        assert!(matches!(
550
            host.authenticate_change(&h2).unwrap(),
551
            SignatureStatus::Invalid(e) if e.contains("may not seal")
552
        ));
553
    }
554
555
    /// A delegation past its expiry authenticates nothing made after it,
556
    /// and the delegator may revoke the key; a stranger's key, they may not.
557
    #[test]
558
    fn an_expired_delegation_is_refused_and_the_delegator_may_revoke() {
559
        let dir = tempfile::tempdir().unwrap();
560
        let (_, mut user, _, _, _) = setup(dir.path(), false, false);
561
        let agent_sk = SecretKey::generate();
562
        let agent_id = agent_sk.identity("agent", "agent@example", 1);
563
        let agent = agent_id.hash();
564
        let yesterday = Hlc::now().wall_ms - 24 * 3600 * 1000;
565
        user.delegate_add(agent_id, Scope::CHANGES, Some(yesterday))
566
            .unwrap();
567
        user.config.set("core.sign", "seal").unwrap();
568
        user.save_config().unwrap();
569
        write(&dir.path().join("work"), "a.txt", b"later\n");
570
        user.snapshot("t").unwrap();
571
        let h = user.commit("after the delegation ended", &[]).unwrap();
572
        user.store()
573
            .put_signature(&h, &agent_sk.sign(Domain::Change, &agent, &h))
574
            .unwrap();
575
        let status = user.verify_change(&h).unwrap();
576
        assert!(
577
            matches!(&status, SignatureStatus::Invalid(e) if e.contains("expired")),
578
            "{status:?}"
579
        );
580
        // No policy here, so the user is no root: only delegation lets them revoke.
581
        let stranger = SecretKey::generate().identity("x", "x@example", 1).hash();
582
        assert!(user.revoke_identity(&stranger, "").is_err());
583
        assert!(user.revoke_identity(&agent, "session over").is_ok());
584
        assert!(user.is_revoked(&agent).unwrap());
585
        let r = user.delegate_remove(&agent).unwrap();
586
        assert!(user.identity(&r.new).unwrap().delegates.is_empty());
587
    }
588
589
    #[test]
590
    fn a_host_edit_that_removes_markers_resolves_the_conflict() {
591
        let dir = tempfile::tempdir().unwrap();
592
        let (host, mut user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
593
        let author = user.config.author_key.unwrap();
594
        let base = host.bookmark_heads().unwrap();
595
        // Two independent edits of the same line: a conflict in the union.
596
        let a = host
597
            .build_change(
598
                &base,
599
                &[FileEdit {
600
                    path: "a.txt".into(),
601
                    content: Some(b"ONE\ntwo\n".to_vec()),
602
                }],
603
                "A",
604
                author,
605
            )
606
            .unwrap();
607
        let b = host
608
            .build_change(
609
                &base,
610
                &[FileEdit {
611
                    path: "a.txt".into(),
612
                    content: Some(b"uno\ntwo\n".to_vec()),
613
                }],
614
                "B",
615
                author,
616
            )
617
            .unwrap();
618
        let (ha, hb) = (a.hash(), b.hash());
619
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
620
        for (c, h) in [(a, ha), (b, hb)] {
621
            let sig = hub_sk.sign(Domain::Change, &hub_key, &h);
622
            let mut heads = host.bookmark_heads().unwrap();
623
            heads.retain(|x| !base.contains(x));
624
            heads.push(h);
625
            let update = host
626
                .host_ref_update("main", &heads, Some((&hub_key, &hub_sk)))
627
                .unwrap();
628
            let r = host
629
                .apply_hosted(Some((c, vec![sig])), &ids, update)
630
                .unwrap();
631
            assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
632
        }
633
        let heads = host.bookmark_heads().unwrap();
634
        assert_eq!(heads.len(), 2);
635
        let marked = String::from_utf8(host.file_at("a.txt", Some(&heads)).unwrap()).unwrap();
636
        assert!(marked.contains("<<<<<<< oxy conflict"), "{marked}");
637
        // Saving with markers left in is refused; saving without them resolves.
638
        assert!(host
639
            .build_change(
640
                &heads,
641
                &[FileEdit {
642
                    path: "a.txt".into(),
643
                    content: Some(format!("{marked}x\n").into_bytes())
644
                }],
645
                "half",
646
                author
647
            )
648
            .is_err());
649
        let res = host
650
            .build_change(
651
                &heads,
652
                &[FileEdit {
653
                    path: "a.txt".into(),
654
                    content: Some(b"One\ntwo\n".to_vec()),
655
                }],
656
                "Resolve",
657
                author,
658
            )
659
            .unwrap();
660
        assert_eq!(res.kind, ChangeKind::Resolution);
661
        assert!(
662
            res.deps.contains(&ha) && res.deps.contains(&hb),
663
            "a resolution depends on both sides"
664
        );
665
        let hr = res.hash();
666
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hr);
667
        let update = host
668
            .host_ref_update("main", &[hr], Some((&hub_key, &hub_sk)))
669
            .unwrap();
670
        let r = host
671
            .apply_hosted(Some((res, vec![sig])), &ids, update)
672
            .unwrap();
673
        assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
674
        assert_eq!(host.file_at("a.txt", Some(&[hr])).unwrap(), b"One\ntwo\n");
675
        assert!(!host
676
            .load_state(&[hr])
677
            .unwrap()
678
            .materialize()
679
            .is_conflicted());
680
        let _ = &mut user;
681
    }
682
}