OXYGENHUB
# 84b9e9ac0ab4 oxygen / crates / oxy-core / src / repo / hosted.rs
16.9 KiB · Rust · last changed Host API: changes from an edit set, delegate-signed reference updates 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(&self, heads: &[ChangeHash], edits: &[FileEdit], message: &str, author: KeyId) -> Result<Change> {
23
        if message.trim().is_empty() {
24
            return fail("a change message is required");
25
        }
26
        if edits.is_empty() {
27
            return fail("nothing to change");
28
        }
29
        let base = self.load_state(heads)?;
30
        let base_snapshot = base.materialize();
31
        let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
32
        for (path, f) in &base_snapshot.files {
33
            let entry = match (&f.kind, &f.content) {
34
                (NodeKind::Dir, _) | (_, FileContent::Dir) => continue,
35
                (kind, FileContent::Text { bytes, .. }) => FsEntry::new(*kind, f.mode.exec, bytes.clone()),
36
                (kind, FileContent::Opaque { blobs }) => FsEntry { kind: *kind, exec: f.mode.exec, content: vec![], blob: blobs.first().copied(), content_hash: None },
37
            };
38
            entries.insert(path.clone(), entry);
39
        }
40
        for e in edits {
41
            let path = e.path.trim_matches('/');
42
            if path.is_empty() || path.split('/').any(|c| c.is_empty() || c == "." || c == ".." || c == OXY_DIR) {
43
                return fail(format!("{:?} is not a valid path", e.path));
44
            }
45
            match &e.content {
46
                Some(bytes) => {
47
                    let exec = entries.get(path).map(|x| x.exec).unwrap_or(false);
48
                    // A file cannot sit where a directory is, or inside a file.
49
                    if base_snapshot.files.get(path).is_some_and(|f| f.kind == NodeKind::Dir) {
50
                        return fail(format!("{path} is a directory"));
51
                    }
52
                    let mut parent = path;
53
                    while let Some((p, _)) = parent.rsplit_once('/') {
54
                        if base_snapshot.files.get(p).is_some_and(|f| f.kind != NodeKind::Dir) {
55
                            return fail(format!("{p} is a file, not a directory"));
56
                        }
57
                        parent = p;
58
                    }
59
                    entries.insert(path.to_string(), FsEntry::new(NodeKind::File, exec, bytes.clone()));
60
                }
61
                None => {
62
                    if entries.remove(path).is_none() {
63
                        // Removing a directory removes everything below it.
64
                        let prefix = format!("{path}/");
65
                        let before = entries.len();
66
                        entries.retain(|p, _| !p.starts_with(&prefix));
67
                        if entries.len() == before {
68
                            return fail(format!("{path} does not exist"));
69
                        }
70
                    }
71
                }
72
            }
73
        }
74
        let built = self.build_from_entries(&base, &base_snapshot, HashMap::new(), &entries)?;
75
        if let Some(s) = built.statuses.iter().find(|s| s.status == ChangeStatus::ConflictEdited) {
76
            return fail(format!("{} still contains conflict markers", s.path));
77
        }
78
        if built.tree_ops.is_empty() && built.edits.is_empty() {
79
            return fail("the edits change nothing");
80
        }
81
        let mut change = Change {
82
            change_id: ChangeId::random(),
83
            deps: vec![],
84
            author,
85
            hlc: Hlc::now(),
86
            message: message.trim_end().to_string(),
87
            tree_ops: built.tree_ops,
88
            edits: built.edits,
89
            kind: if built.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
90
        };
91
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&change, &base);
92
        deps.extend(built.contributors.iter().copied());
93
        change.deps = deps.into_iter().collect();
94
        change.normalize();
95
        Ok(change)
96
    }
97
98
    /// A reference update of `bookmark` to `heads`, attested by `signer`
99
    /// when given (the attestation a push would carry, spec §14.4).
100
    pub fn host_ref_update(&self, bookmark: &str, heads: &[ChangeHash], signer: Option<(&KeyId, &SecretKey)>) -> Result<RefUpdate> {
101
        let view = self.view()?;
102
        let empty = self.store.put_state(&StateObject::new(vec![]))?;
103
        let old_state = view.bookmarks.get(bookmark).copied().unwrap_or(empty);
104
        let new_state = self.store.put_state(&StateObject::new(heads.to_vec()))?;
105
        let attestation = match signer {
106
            Some((key, sk)) => {
107
                let serial = self
108
                    .latest_ref_attest(bookmark)?
109
                    .and_then(|(_, a)| match a.attest.body {
110
                        AttestBody::Ref { serial, .. } => Some(serial),
111
                        _ => None,
112
                    })
113
                    .map(|s| s + 1)
114
                    .unwrap_or(1);
115
                let attest = Attest {
116
                    repo: self.id()?,
117
                    hlc: Hlc::now(),
118
                    body: AttestBody::Ref { name: bookmark.to_string(), old_state, new_state, serial },
119
                };
120
                let r = attest.hash();
121
                let sig = sk.sign(Domain::Attest, key, &r);
122
                Some(SignedAttest { attest, sigs: vec![sig] })
123
            }
124
            None => None,
125
        };
126
        Ok(RefUpdate { name: bookmark.to_string(), old_state, new_state, force: false, attestation })
127
    }
128
129
    /// Apply a host-made update as if it had been pushed: the new change
130
    /// (with its signatures) if any, the identities it needs, and the
131
    /// reference update, all through `receive_push_report`, so the same
132
    /// authentication, compare-and-swap and policy checks apply.
133
    pub fn apply_hosted(&self, change: Option<(Change, Vec<Signature>)>, identities: &[Identity], update: RefUpdate) -> Result<PushReport> {
134
        let mut bundle = Bundle::default();
135
        for id in identities {
136
            bundle.objects.push((ObjectType::Identity, id.encode()));
137
        }
138
        if let Ok(st) = self.store.get_state(&update.new_state) {
139
            bundle.objects.push((ObjectType::State, st.encode()));
140
        }
141
        if let Some((c, sigs)) = change {
142
            bundle.changes.push((c, sigs));
143
        }
144
        self.receive_push_report(bundle, vec![update], None, vec![])
145
    }
146
}
147
148
#[cfg(test)]
149
mod tests {
150
    use super::*;
151
    use crate::identity::SecretKey;
152
153
    fn hermetic() {
154
        static ONCE: std::sync::Once = std::sync::Once::new();
155
        static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
156
        ONCE.call_once(|| {
157
            let d = tempfile::tempdir().unwrap();
158
            std::env::set_var("OXY_HOME", d.path());
159
            DIR.set(d).ok();
160
        });
161
    }
162
163
    fn write(root: &Path, rel: &str, content: &[u8]) {
164
        let p = root.join(rel);
165
        fs::create_dir_all(p.parent().unwrap()).unwrap();
166
        fs::write(p, content).unwrap();
167
    }
168
169
    /// A user repository pushed to a bare host, and a hub key the user
170
    /// has delegated to. Returns (host, user, hub key, hub secret).
171
    fn setup(root: &Path, with_policy: bool, delegate: bool) -> (Repo, Repo, KeyId, SecretKey, Identity) {
172
        hermetic();
173
        let host_dir = root.join("host");
174
        Repo::init_bare_unbound(&host_dir).unwrap();
175
        let work = root.join("work");
176
        let mut user = Repo::init(&work).unwrap();
177
        user.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap();
178
        user.config.set("core.sign", "change").unwrap();
179
        user.config.set("remote.origin.url", host_dir.to_str().unwrap()).unwrap();
180
        user.save_config().unwrap();
181
        let hub_sk = SecretKey::generate();
182
        let hub_id = hub_sk.identity("Hub", "hub@example", 1);
183
        let hub_key = hub_id.hash();
184
        if with_policy {
185
            user.policy_init().unwrap();
186
        }
187
        if delegate {
188
            user.delegate_add(hub_id.clone()).unwrap();
189
        }
190
        write(&work, "a.txt", b"one\ntwo\n");
191
        user.snapshot("t").unwrap();
192
        user.commit("first", &[]).unwrap();
193
        let r = user.push("origin", false).unwrap();
194
        assert!(r.iter().all(|x| x.ok), "{r:?}");
195
        (Repo::open(&host_dir).unwrap(), user, hub_key, hub_sk, hub_id)
196
    }
197
198
    #[test]
199
    fn a_host_edit_signed_by_a_delegate_lands() {
200
        let dir = tempfile::tempdir().unwrap();
201
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
202
        let author = user.config.author_key.unwrap();
203
        let heads = host.bookmark_heads().unwrap();
204
        let change = host.build_change(&heads, &[FileEdit { path: "a.txt".into(), content: Some(b"one\ntwo\nthree\n".to_vec()) }, FileEdit { path: "docs/new.md".into(), content: Some(b"# New\n".to_vec()) }], "Edit on the web", author).unwrap();
205
        assert_eq!(change.author, author);
206
        assert_eq!(change.kind, ChangeKind::Normal);
207
        let hash = change.hash();
208
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
209
        let mut new_heads = heads.clone();
210
        new_heads.push(hash);
211
        let update = host.host_ref_update("main", &[hash], Some((&hub_key, &hub_sk))).unwrap();
212
        let ids = vec![hub_id, user.identity(&author).unwrap()];
213
        let report = host.apply_hosted(Some((change, vec![sig])), &ids, update).unwrap();
214
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
215
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
216
        assert_eq!(host.file_at("a.txt", Some(&[hash])).unwrap(), b"one\ntwo\nthree\n");
217
        assert_eq!(host.file_at("docs/new.md", Some(&[hash])).unwrap(), b"# New\n");
218
        // Authored by the user, authenticated by the hub key as delegate.
219
        assert!(matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Valid { key, name, .. } if key == hub_key && name == "Hub"));
220
        let _ = new_heads;
221
        // Bad paths and empty edits are refused.
222
        assert!(host.build_change(&[hash], &[FileEdit { path: "../x".into(), content: Some(vec![]) }], "m", author).is_err());
223
        assert!(host.build_change(&[hash], &[FileEdit { path: "a.txt".into(), content: Some(b"one\ntwo\nthree\n".to_vec()) }], "m", author).is_err(), "no-op edits are refused");
224
        assert!(host.build_change(&[hash], &[FileEdit { path: "a.txt/b".into(), content: Some(vec![]) }], "m", author).is_err(), "a file inside a file");
225
        let rm = host.build_change(&[hash], &[FileEdit { path: "docs".into(), content: None }], "Remove docs", author).unwrap();
226
        assert!(!rm.tree_ops.is_empty());
227
    }
228
229
    #[test]
230
    fn a_host_edit_by_a_key_that_is_not_a_delegate_is_refused() {
231
        let dir = tempfile::tempdir().unwrap();
232
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, false);
233
        let author = user.config.author_key.unwrap();
234
        let heads = host.bookmark_heads().unwrap();
235
        let change = host.build_change(&heads, &[FileEdit { path: "a.txt".into(), content: Some(b"x\n".to_vec()) }], "Edit", author).unwrap();
236
        let hash = change.hash();
237
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
238
        let update = host.host_ref_update("main", &[hash], Some((&hub_key, &hub_sk))).unwrap();
239
        let report = host.apply_hosted(Some((change, vec![sig])), &[hub_id, user.identity(&author).unwrap()], update).unwrap();
240
        // Without a policy the host accepts unauthenticated changes, but the
241
        // signature does not verify as the author's: it stays unauthenticated.
242
        assert!(report.results.iter().all(|r| r.ok));
243
        assert!(matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Invalid(why) if why.contains("neither the author")));
244
    }
245
246
    #[test]
247
    fn under_a_policy_a_delegate_may_update_the_reference_and_a_stranger_may_not() {
248
        let dir = tempfile::tempdir().unwrap();
249
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), true, true);
250
        assert!(host.current_policy().unwrap().is_some());
251
        let author = user.config.author_key.unwrap();
252
        let heads = host.bookmark_heads().unwrap();
253
        let change = host.build_change(&heads, &[FileEdit { path: "a.txt".into(), content: Some(b"one\n".to_vec()) }], "Trim", author).unwrap();
254
        let hash = change.hash();
255
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
256
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
257
        let update = host.host_ref_update("main", &[hash], Some((&hub_key, &hub_sk))).unwrap();
258
        let report = host.apply_hosted(Some((change, vec![sig])), &ids, update).unwrap();
259
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
260
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
261
262
        // A stranger's key cannot move the reference, even with the user as author.
263
        let stranger = SecretKey::generate();
264
        let sid = stranger.identity("Mallory", "m@example", 1);
265
        let skey = sid.hash();
266
        let change2 = host.build_change(&[hash], &[FileEdit { path: "a.txt".into(), content: Some(b"evil\n".to_vec()) }], "Evil", author).unwrap();
267
        let h2 = change2.hash();
268
        let sig2 = stranger.sign(Domain::Change, &skey, &h2);
269
        let update = host.host_ref_update("main", &[h2], Some((&skey, &stranger))).unwrap();
270
        let report = host.apply_hosted(Some((change2, vec![sig2])), &[sid], update).unwrap();
271
        assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results);
272
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash], "the reference did not move");
273
        // An unsigned update is refused under a policy too.
274
        let update = host.host_ref_update("main", &[hash], None).unwrap();
275
        let report = host.apply_hosted(None, &[], update).unwrap();
276
        assert!(report.results.iter().all(|r| !r.ok));
277
    }
278
279
    #[test]
280
    fn a_host_edit_that_removes_markers_resolves_the_conflict() {
281
        let dir = tempfile::tempdir().unwrap();
282
        let (host, mut user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
283
        let author = user.config.author_key.unwrap();
284
        let base = host.bookmark_heads().unwrap();
285
        // Two independent edits of the same line: a conflict in the union.
286
        let a = host.build_change(&base, &[FileEdit { path: "a.txt".into(), content: Some(b"ONE\ntwo\n".to_vec()) }], "A", author).unwrap();
287
        let b = host.build_change(&base, &[FileEdit { path: "a.txt".into(), content: Some(b"uno\ntwo\n".to_vec()) }], "B", author).unwrap();
288
        let (ha, hb) = (a.hash(), b.hash());
289
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
290
        for (c, h) in [(a, ha), (b, hb)] {
291
            let sig = hub_sk.sign(Domain::Change, &hub_key, &h);
292
            let mut heads = host.bookmark_heads().unwrap();
293
            heads.retain(|x| !base.contains(x));
294
            heads.push(h);
295
            let update = host.host_ref_update("main", &heads, Some((&hub_key, &hub_sk))).unwrap();
296
            let r = host.apply_hosted(Some((c, vec![sig])), &ids, update).unwrap();
297
            assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
298
        }
299
        let heads = host.bookmark_heads().unwrap();
300
        assert_eq!(heads.len(), 2);
301
        let marked = String::from_utf8(host.file_at("a.txt", Some(&heads)).unwrap()).unwrap();
302
        assert!(marked.contains("<<<<<<< oxy conflict"), "{marked}");
303
        // Saving with markers left in is refused; saving without them resolves.
304
        assert!(host.build_change(&heads, &[FileEdit { path: "a.txt".into(), content: Some(format!("{marked}x\n").into_bytes()) }], "half", author).is_err());
305
        let res = host.build_change(&heads, &[FileEdit { path: "a.txt".into(), content: Some(b"One\ntwo\n".to_vec()) }], "Resolve", author).unwrap();
306
        assert_eq!(res.kind, ChangeKind::Resolution);
307
        assert!(res.deps.contains(&ha) && res.deps.contains(&hb), "a resolution depends on both sides");
308
        let hr = res.hash();
309
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hr);
310
        let update = host.host_ref_update("main", &[hr], Some((&hub_key, &hub_sk))).unwrap();
311
        let r = host.apply_hosted(Some((res, vec![sig])), &ids, update).unwrap();
312
        assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
313
        assert_eq!(host.file_at("a.txt", Some(&[hr])).unwrap(), b"One\ntwo\n");
314
        assert!(!host.load_state(&[hr]).unwrap().materialize().is_conflicted());
315
        let _ = &mut user;
316
    }
317
}