OXYGENHUB
# 7dc9f65b6546 oxygen / crates / oxy-core / src / repo / hosted.rs
21.0 KiB · Rust · last changed Replace conflicts are resolved by keeping a candidate 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
        // 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
            change_id: ChangeId::random(),
126
            deps: vec![],
127
            author,
128
            hlc: Hlc::now(),
129
            message: message.trim_end().to_string(),
130
            tree_ops: built.tree_ops,
131
            edits: built.edits,
132
            kind: if built.resolves {
133
                ChangeKind::Resolution
134
            } else {
135
                ChangeKind::Normal
136
            },
137
        };
138
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&change, &base);
139
        deps.extend(built.contributors.iter().copied());
140
        change.deps = deps.into_iter().collect();
141
        change.normalize();
142
        Ok(change)
143
    }
144
145
    /// A reference update of `bookmark` to `heads`, attested by `signer`
146
    /// when given (the attestation a push would carry, spec §14.4).
147
    pub fn host_ref_update(
148
        &self,
149
        bookmark: &str,
150
        heads: &[ChangeHash],
151
        signer: Option<(&KeyId, &SecretKey)>,
152
    ) -> Result<RefUpdate> {
153
        let view = self.view()?;
154
        let empty = self.store.put_state(&StateObject::new(vec![]))?;
155
        let old_state = view.bookmarks.get(bookmark).copied().unwrap_or(empty);
156
        let new_state = self.store.put_state(&StateObject::new(heads.to_vec()))?;
157
        let attestation = match signer {
158
            Some((key, sk)) => {
159
                let serial = self
160
                    .latest_ref_attest(bookmark)?
161
                    .and_then(|(_, a)| match a.attest.body {
162
                        AttestBody::Ref { serial, .. } => Some(serial),
163
                        _ => None,
164
                    })
165
                    .map(|s| s + 1)
166
                    .unwrap_or(1);
167
                let attest = Attest {
168
                    repo: self.id()?,
169
                    hlc: Hlc::now(),
170
                    body: AttestBody::Ref {
171
                        name: bookmark.to_string(),
172
                        old_state,
173
                        new_state,
174
                        serial,
175
                    },
176
                };
177
                let r = attest.hash();
178
                let sig = sk.sign(Domain::Attest, key, &r);
179
                Some(SignedAttest {
180
                    attest,
181
                    sigs: vec![sig],
182
                })
183
            }
184
            None => None,
185
        };
186
        Ok(RefUpdate {
187
            name: bookmark.to_string(),
188
            old_state,
189
            new_state,
190
            force: false,
191
            attestation,
192
        })
193
    }
194
195
    /// Apply a host-made update as if it had been pushed: the new change
196
    /// (with its signatures) if any, the identities it needs, and the
197
    /// reference update, all through `receive_push_report`, so the same
198
    /// authentication, compare-and-swap and policy checks apply.
199
    pub fn apply_hosted(
200
        &self,
201
        change: Option<(Change, Vec<Signature>)>,
202
        identities: &[Identity],
203
        update: RefUpdate,
204
    ) -> Result<PushReport> {
205
        let mut bundle = Bundle::default();
206
        for id in identities {
207
            bundle.objects.push((ObjectType::Identity, id.encode()));
208
        }
209
        if let Ok(st) = self.store.get_state(&update.new_state) {
210
            bundle.objects.push((ObjectType::State, st.encode()));
211
        }
212
        if let Some((c, sigs)) = change {
213
            bundle.changes.push((c, sigs));
214
        }
215
        self.receive_push_report(bundle, vec![update], None, vec![])
216
    }
217
}
218
219
#[cfg(test)]
220
mod tests {
221
    use super::*;
222
    use crate::identity::SecretKey;
223
224
    fn hermetic() {
225
        static ONCE: std::sync::Once = std::sync::Once::new();
226
        static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
227
        ONCE.call_once(|| {
228
            let d = tempfile::tempdir().unwrap();
229
            std::env::set_var("OXY_HOME", d.path());
230
            DIR.set(d).ok();
231
        });
232
    }
233
234
    fn write(root: &Path, rel: &str, content: &[u8]) {
235
        let p = root.join(rel);
236
        fs::create_dir_all(p.parent().unwrap()).unwrap();
237
        fs::write(p, content).unwrap();
238
    }
239
240
    /// A user repository pushed to a bare host, and a hub key the user
241
    /// has delegated to. Returns (host, user, hub key, hub secret).
242
    fn setup(
243
        root: &Path,
244
        with_policy: bool,
245
        delegate: bool,
246
    ) -> (Repo, Repo, KeyId, SecretKey, Identity) {
247
        hermetic();
248
        let host_dir = root.join("host");
249
        Repo::init_bare_unbound(&host_dir).unwrap();
250
        let work = root.join("work");
251
        let mut user = Repo::init(&work).unwrap();
252
        user.create_identity("Andy", "[email protected]", IdentityScope::Repo)
253
            .unwrap();
254
        user.config.set("core.sign", "change").unwrap();
255
        user.config
256
            .set("remote.origin.url", host_dir.to_str().unwrap())
257
            .unwrap();
258
        user.save_config().unwrap();
259
        let hub_sk = SecretKey::generate();
260
        let hub_id = hub_sk.identity("Hub", "hub@example", 1);
261
        let hub_key = hub_id.hash();
262
        if with_policy {
263
            user.policy_init().unwrap();
264
        }
265
        if delegate {
266
            user.delegate_add(hub_id.clone()).unwrap();
267
        }
268
        write(&work, "a.txt", b"one\ntwo\n");
269
        user.snapshot("t").unwrap();
270
        user.commit("first", &[]).unwrap();
271
        let r = user.push("origin", false).unwrap();
272
        assert!(r.iter().all(|x| x.ok), "{r:?}");
273
        (
274
            Repo::open(&host_dir).unwrap(),
275
            user,
276
            hub_key,
277
            hub_sk,
278
            hub_id,
279
        )
280
    }
281
282
    #[test]
283
    fn a_host_edit_signed_by_a_delegate_lands() {
284
        let dir = tempfile::tempdir().unwrap();
285
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
286
        let author = user.config.author_key.unwrap();
287
        let heads = host.bookmark_heads().unwrap();
288
        let change = host
289
            .build_change(
290
                &heads,
291
                &[
292
                    FileEdit {
293
                        path: "a.txt".into(),
294
                        content: Some(b"one\ntwo\nthree\n".to_vec()),
295
                    },
296
                    FileEdit {
297
                        path: "docs/new.md".into(),
298
                        content: Some(b"# New\n".to_vec()),
299
                    },
300
                ],
301
                "Edit on the web",
302
                author,
303
            )
304
            .unwrap();
305
        assert_eq!(change.author, author);
306
        assert_eq!(change.kind, ChangeKind::Normal);
307
        let hash = change.hash();
308
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
309
        let mut new_heads = heads.clone();
310
        new_heads.push(hash);
311
        let update = host
312
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
313
            .unwrap();
314
        let ids = vec![hub_id, user.identity(&author).unwrap()];
315
        let report = host
316
            .apply_hosted(Some((change, vec![sig])), &ids, update)
317
            .unwrap();
318
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
319
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
320
        assert_eq!(
321
            host.file_at("a.txt", Some(&[hash])).unwrap(),
322
            b"one\ntwo\nthree\n"
323
        );
324
        assert_eq!(
325
            host.file_at("docs/new.md", Some(&[hash])).unwrap(),
326
            b"# New\n"
327
        );
328
        // Authored by the user, authenticated by the hub key as delegate.
329
        assert!(
330
            matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Valid { key, name, .. } if key == hub_key && name == "Hub")
331
        );
332
        let _ = new_heads;
333
        // Bad paths and empty edits are refused.
334
        assert!(host
335
            .build_change(
336
                &[hash],
337
                &[FileEdit {
338
                    path: "../x".into(),
339
                    content: Some(vec![])
340
                }],
341
                "m",
342
                author
343
            )
344
            .is_err());
345
        assert!(
346
            host.build_change(
347
                &[hash],
348
                &[FileEdit {
349
                    path: "a.txt".into(),
350
                    content: Some(b"one\ntwo\nthree\n".to_vec())
351
                }],
352
                "m",
353
                author
354
            )
355
            .is_err(),
356
            "no-op edits are refused"
357
        );
358
        assert!(
359
            host.build_change(
360
                &[hash],
361
                &[FileEdit {
362
                    path: "a.txt/b".into(),
363
                    content: Some(vec![])
364
                }],
365
                "m",
366
                author
367
            )
368
            .is_err(),
369
            "a file inside a file"
370
        );
371
        let rm = host
372
            .build_change(
373
                &[hash],
374
                &[FileEdit {
375
                    path: "docs".into(),
376
                    content: None,
377
                }],
378
                "Remove docs",
379
                author,
380
            )
381
            .unwrap();
382
        assert!(!rm.tree_ops.is_empty());
383
    }
384
385
    #[test]
386
    fn a_host_edit_by_a_key_that_is_not_a_delegate_is_refused() {
387
        let dir = tempfile::tempdir().unwrap();
388
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, false);
389
        let author = user.config.author_key.unwrap();
390
        let heads = host.bookmark_heads().unwrap();
391
        let change = host
392
            .build_change(
393
                &heads,
394
                &[FileEdit {
395
                    path: "a.txt".into(),
396
                    content: Some(b"x\n".to_vec()),
397
                }],
398
                "Edit",
399
                author,
400
            )
401
            .unwrap();
402
        let hash = change.hash();
403
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
404
        let update = host
405
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
406
            .unwrap();
407
        let report = host
408
            .apply_hosted(
409
                Some((change, vec![sig])),
410
                &[hub_id, user.identity(&author).unwrap()],
411
                update,
412
            )
413
            .unwrap();
414
        // Without a policy the host accepts unauthenticated changes, but the
415
        // signature does not verify as the author's: it stays unauthenticated.
416
        assert!(report.results.iter().all(|r| r.ok));
417
        assert!(
418
            matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Invalid(why) if why.contains("neither the author"))
419
        );
420
    }
421
422
    #[test]
423
    fn under_a_policy_a_delegate_may_update_the_reference_and_a_stranger_may_not() {
424
        let dir = tempfile::tempdir().unwrap();
425
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), true, true);
426
        assert!(host.current_policy().unwrap().is_some());
427
        let author = user.config.author_key.unwrap();
428
        let heads = host.bookmark_heads().unwrap();
429
        let change = host
430
            .build_change(
431
                &heads,
432
                &[FileEdit {
433
                    path: "a.txt".into(),
434
                    content: Some(b"one\n".to_vec()),
435
                }],
436
                "Trim",
437
                author,
438
            )
439
            .unwrap();
440
        let hash = change.hash();
441
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
442
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
443
        let update = host
444
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
445
            .unwrap();
446
        let report = host
447
            .apply_hosted(Some((change, vec![sig])), &ids, update)
448
            .unwrap();
449
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
450
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
451
452
        // A stranger's key cannot move the reference, even with the user as author.
453
        let stranger = SecretKey::generate();
454
        let sid = stranger.identity("Mallory", "m@example", 1);
455
        let skey = sid.hash();
456
        let change2 = host
457
            .build_change(
458
                &[hash],
459
                &[FileEdit {
460
                    path: "a.txt".into(),
461
                    content: Some(b"evil\n".to_vec()),
462
                }],
463
                "Evil",
464
                author,
465
            )
466
            .unwrap();
467
        let h2 = change2.hash();
468
        let sig2 = stranger.sign(Domain::Change, &skey, &h2);
469
        let update = host
470
            .host_ref_update("main", &[h2], Some((&skey, &stranger)))
471
            .unwrap();
472
        let report = host
473
            .apply_hosted(Some((change2, vec![sig2])), &[sid], update)
474
            .unwrap();
475
        assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results);
476
        assert_eq!(
477
            host.bookmark_heads().unwrap(),
478
            vec![hash],
479
            "the reference did not move"
480
        );
481
        // An unsigned update is refused under a policy too.
482
        let update = host.host_ref_update("main", &[hash], None).unwrap();
483
        let report = host.apply_hosted(None, &[], update).unwrap();
484
        assert!(report.results.iter().all(|r| !r.ok));
485
    }
486
487
    #[test]
488
    fn a_host_edit_that_removes_markers_resolves_the_conflict() {
489
        let dir = tempfile::tempdir().unwrap();
490
        let (host, mut user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
491
        let author = user.config.author_key.unwrap();
492
        let base = host.bookmark_heads().unwrap();
493
        // Two independent edits of the same line: a conflict in the union.
494
        let a = host
495
            .build_change(
496
                &base,
497
                &[FileEdit {
498
                    path: "a.txt".into(),
499
                    content: Some(b"ONE\ntwo\n".to_vec()),
500
                }],
501
                "A",
502
                author,
503
            )
504
            .unwrap();
505
        let b = host
506
            .build_change(
507
                &base,
508
                &[FileEdit {
509
                    path: "a.txt".into(),
510
                    content: Some(b"uno\ntwo\n".to_vec()),
511
                }],
512
                "B",
513
                author,
514
            )
515
            .unwrap();
516
        let (ha, hb) = (a.hash(), b.hash());
517
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
518
        for (c, h) in [(a, ha), (b, hb)] {
519
            let sig = hub_sk.sign(Domain::Change, &hub_key, &h);
520
            let mut heads = host.bookmark_heads().unwrap();
521
            heads.retain(|x| !base.contains(x));
522
            heads.push(h);
523
            let update = host
524
                .host_ref_update("main", &heads, Some((&hub_key, &hub_sk)))
525
                .unwrap();
526
            let r = host
527
                .apply_hosted(Some((c, vec![sig])), &ids, update)
528
                .unwrap();
529
            assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
530
        }
531
        let heads = host.bookmark_heads().unwrap();
532
        assert_eq!(heads.len(), 2);
533
        let marked = String::from_utf8(host.file_at("a.txt", Some(&heads)).unwrap()).unwrap();
534
        assert!(marked.contains("<<<<<<< oxy conflict"), "{marked}");
535
        // Saving with markers left in is refused; saving without them resolves.
536
        assert!(host
537
            .build_change(
538
                &heads,
539
                &[FileEdit {
540
                    path: "a.txt".into(),
541
                    content: Some(format!("{marked}x\n").into_bytes())
542
                }],
543
                "half",
544
                author
545
            )
546
            .is_err());
547
        let res = host
548
            .build_change(
549
                &heads,
550
                &[FileEdit {
551
                    path: "a.txt".into(),
552
                    content: Some(b"One\ntwo\n".to_vec()),
553
                }],
554
                "Resolve",
555
                author,
556
            )
557
            .unwrap();
558
        assert_eq!(res.kind, ChangeKind::Resolution);
559
        assert!(
560
            res.deps.contains(&ha) && res.deps.contains(&hb),
561
            "a resolution depends on both sides"
562
        );
563
        let hr = res.hash();
564
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hr);
565
        let update = host
566
            .host_ref_update("main", &[hr], Some((&hub_key, &hub_sk)))
567
            .unwrap();
568
        let r = host
569
            .apply_hosted(Some((res, vec![sig])), &ids, update)
570
            .unwrap();
571
        assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
572
        assert_eq!(host.file_at("a.txt", Some(&[hr])).unwrap(), b"One\ntwo\n");
573
        assert!(!host
574
            .load_state(&[hr])
575
            .unwrap()
576
            .materialize()
577
            .is_conflicted());
578
        let _ = &mut user;
579
    }
580
}