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