OXYGENHUB

Delegate rule on fetch and tags too eae595568832 resolution

A client pulling a reference attested by a delegate of an identity the
policy names now accepts it, as the receiving side already did; tag
attestations follow the same rule (spec §14.5).
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 21:32 UTC
Signature
✔ signed by Andy McCall
Hash
eae5955688324d78eb8ad267b9a81cacecb39a502e2f808ab80cbd36a04341a5
Depends on
119bbf50cb86 132b884b9a8d 5b9c6ad45f6e 75167672ebcd 7aa2dc9369e3 83e0677b07fe 84b9e9ac0ab4 96f7610fa666 982864068a59 e02bf0cd0533
Depended on by
ee130dd9eef2 e5d9b0513f3e f2f91ceb0e9f 7ba155f20aa0 f7510b63cceb cc7a00565161 3526d339519f
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
48 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified crates/oxy-core/src/protocol.rs +6 −1
…
189 unchanged lines
190 190
        if self.repo.is_unbound() {
191 191
            match self.claimed_id {
192 192
                Some(id) => self.repo.bind_id(id)?,
193 -
                None => return Err(RepoError::Msg("the remote is unbound and this client did not say which repository it is".into())),
193 +
                None => {
194 +
                    return Err(RepoError::Msg(
195 +
                        "the remote is unbound and this client did not say which repository it is"
196 +
                            .into(),
197 +
                    ))
198 +
                }
194 199
            }
195 200
        }
196 201
        self.repo.receive_push(bundle, updates, policy, tags)
…
790 unchanged lines
modified crates/oxy-core/src/repo/hosted.rs +302 −49
…
18 unchanged lines
19 19
    /// Files the edits do not name keep their content, conflict markers
20 20
    /// included, so an edit that removes a file's markers resolves that
21 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> {
22 +
    pub fn build_change(
23 +
        &self,
24 +
        heads: &[ChangeHash],
25 +
        edits: &[FileEdit],
26 +
        message: &str,
27 +
        author: KeyId,
28 +
    ) -> Result<Change> {
23 29
        if message.trim().is_empty() {
24 30
            return fail("a change message is required");
25 31
        }
…
6 unchanged lines
32 38
        for (path, f) in &base_snapshot.files {
33 39
            let entry = match (&f.kind, &f.content) {
34 40
                (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 },
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 +
                },
37 51
            };
38 52
            entries.insert(path.clone(), entry);
39 53
        }
40 54
        for e in edits {
41 55
            let path = e.path.trim_matches('/');
42 -
            if path.is_empty() || path.split('/').any(|c| c.is_empty() || c == "." || c == ".." || c == OXY_DIR) {
56 +
            if path.is_empty()
57 +
                || path
58 +
                    .split('/')
59 +
                    .any(|c| c.is_empty() || c == "." || c == ".." || c == OXY_DIR)
60 +
            {
43 61
                return fail(format!("{:?} is not a valid path", e.path));
44 62
            }
45 63
            match &e.content {
46 64
                Some(bytes) => {
47 65
                    let exec = entries.get(path).map(|x| x.exec).unwrap_or(false);
48 66
                    // 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) {
67 +
                    if base_snapshot
68 +
                        .files
69 +
                        .get(path)
70 +
                        .is_some_and(|f| f.kind == NodeKind::Dir)
71 +
                    {
50 72
                        return fail(format!("{path} is a directory"));
51 73
                    }
52 74
                    let mut parent = path;
53 75
                    while let Some((p, _)) = parent.rsplit_once('/') {
54 -
                        if base_snapshot.files.get(p).is_some_and(|f| f.kind != NodeKind::Dir) {
76 +
                        if base_snapshot
77 +
                            .files
78 +
                            .get(p)
79 +
                            .is_some_and(|f| f.kind != NodeKind::Dir)
80 +
                        {
55 81
                            return fail(format!("{p} is a file, not a directory"));
56 82
                        }
57 83
                        parent = p;
58 84
                    }
59 -
                    entries.insert(path.to_string(), FsEntry::new(NodeKind::File, exec, bytes.clone()));
85 +
                    entries.insert(
86 +
                        path.to_string(),
87 +
                        FsEntry::new(NodeKind::File, exec, bytes.clone()),
88 +
                    );
60 89
                }
61 90
                None => {
62 91
                    if entries.remove(path).is_none() {
…
9 unchanged lines
72 101
            }
73 102
        }
74 103
        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) {
104 +
        if let Some(s) = built
105 +
            .statuses
106 +
            .iter()
107 +
            .find(|s| s.status == ChangeStatus::ConflictEdited)
108 +
        {
76 109
            return fail(format!("{} still contains conflict markers", s.path));
77 110
        }
78 111
        if built.tree_ops.is_empty() && built.edits.is_empty() {
…
7 unchanged lines
86 119
            message: message.trim_end().to_string(),
87 120
            tree_ops: built.tree_ops,
88 121
            edits: built.edits,
89 -
            kind: if built.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
122 +
            kind: if built.resolves {
123 +
                ChangeKind::Resolution
124 +
            } else {
125 +
                ChangeKind::Normal
126 +
            },
90 127
        };
91 128
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&change, &base);
92 129
        deps.extend(built.contributors.iter().copied());
…
4 unchanged lines
97 134
98 135
    /// A reference update of `bookmark` to `heads`, attested by `signer`
99 136
    /// 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> {
137 +
    pub fn host_ref_update(
138 +
        &self,
139 +
        bookmark: &str,
140 +
        heads: &[ChangeHash],
141 +
        signer: Option<(&KeyId, &SecretKey)>,
142 +
    ) -> Result<RefUpdate> {
101 143
        let view = self.view()?;
102 144
        let empty = self.store.put_state(&StateObject::new(vec![]))?;
103 145
        let old_state = view.bookmarks.get(bookmark).copied().unwrap_or(empty);
…
11 unchanged lines
115 157
                let attest = Attest {
116 158
                    repo: self.id()?,
117 159
                    hlc: Hlc::now(),
118 -
                    body: AttestBody::Ref { name: bookmark.to_string(), old_state, new_state, serial },
160 +
                    body: AttestBody::Ref {
161 +
                        name: bookmark.to_string(),
162 +
                        old_state,
163 +
                        new_state,
164 +
                        serial,
165 +
                    },
119 166
                };
120 167
                let r = attest.hash();
121 168
                let sig = sk.sign(Domain::Attest, key, &r);
122 -
                Some(SignedAttest { attest, sigs: vec![sig] })
169 +
                Some(SignedAttest {
170 +
                    attest,
171 +
                    sigs: vec![sig],
172 +
                })
123 173
            }
124 174
            None => None,
125 175
        };
126 -
        Ok(RefUpdate { name: bookmark.to_string(), old_state, new_state, force: false, attestation })
176 +
        Ok(RefUpdate {
177 +
            name: bookmark.to_string(),
178 +
            old_state,
179 +
            new_state,
180 +
            force: false,
181 +
            attestation,
182 +
        })
127 183
    }
128 184
129 185
    /// Apply a host-made update as if it had been pushed: the new change
130 186
    /// (with its signatures) if any, the identities it needs, and the
131 187
    /// reference update, all through `receive_push_report`, so the same
132 188
    /// 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> {
189 +
    pub fn apply_hosted(
190 +
        &self,
191 +
        change: Option<(Change, Vec<Signature>)>,
192 +
        identities: &[Identity],
193 +
        update: RefUpdate,
194 +
    ) -> Result<PushReport> {
134 195
        let mut bundle = Bundle::default();
135 196
        for id in identities {
136 197
            bundle.objects.push((ObjectType::Identity, id.encode()));
…
31 unchanged lines
168 229
169 230
    /// A user repository pushed to a bare host, and a hub key the user
170 231
    /// 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) {
232 +
    fn setup(
233 +
        root: &Path,
234 +
        with_policy: bool,
235 +
        delegate: bool,
236 +
    ) -> (Repo, Repo, KeyId, SecretKey, Identity) {
172 237
        hermetic();
173 238
        let host_dir = root.join("host");
174 239
        Repo::init_bare_unbound(&host_dir).unwrap();
175 240
        let work = root.join("work");
176 241
        let mut user = Repo::init(&work).unwrap();
177 -
        user.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap();
242 +
        user.create_identity("Andy", "[email protected]", IdentityScope::Repo)
243 +
            .unwrap();
178 244
        user.config.set("core.sign", "change").unwrap();
179 -
        user.config.set("remote.origin.url", host_dir.to_str().unwrap()).unwrap();
245 +
        user.config
246 +
            .set("remote.origin.url", host_dir.to_str().unwrap())
247 +
            .unwrap();
180 248
        user.save_config().unwrap();
181 249
        let hub_sk = SecretKey::generate();
182 250
        let hub_id = hub_sk.identity("Hub", "hub@example", 1);
…
9 unchanged lines
192 260
        user.commit("first", &[]).unwrap();
193 261
        let r = user.push("origin", false).unwrap();
194 262
        assert!(r.iter().all(|x| x.ok), "{r:?}");
195 -
        (Repo::open(&host_dir).unwrap(), user, hub_key, hub_sk, hub_id)
263 +
        (
264 +
            Repo::open(&host_dir).unwrap(),
265 +
            user,
266 +
            hub_key,
267 +
            hub_sk,
268 +
            hub_id,
269 +
        )
196 270
    }
197 271
198 272
    #[test]
…
2 unchanged lines
201 275
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
202 276
        let author = user.config.author_key.unwrap();
203 277
        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();
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();
205 295
        assert_eq!(change.author, author);
206 296
        assert_eq!(change.kind, ChangeKind::Normal);
207 297
        let hash = change.hash();
208 298
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
209 299
        let mut new_heads = heads.clone();
210 300
        new_heads.push(hash);
211 -
        let update = host.host_ref_update("main", &[hash], Some((&hub_key, &hub_sk))).unwrap();
301 +
        let update = host
302 +
            .host_ref_update("main", &[hash], Some((&hub_key, &hub_sk)))
303 +
            .unwrap();
212 304
        let ids = vec![hub_id, user.identity(&author).unwrap()];
213 -
        let report = host.apply_hosted(Some((change, vec![sig])), &ids, update).unwrap();
305 +
        let report = host
306 +
            .apply_hosted(Some((change, vec![sig])), &ids, update)
307 +
            .unwrap();
214 308
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
215 309
        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");
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 +
        );
218 318
        // 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"));
319 +
        assert!(
320 +
            matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Valid { key, name, .. } if key == hub_key && name == "Hub")
321 +
        );
220 322
        let _ = new_heads;
221 323
        // 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();
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();
226 372
        assert!(!rm.tree_ops.is_empty());
227 373
    }
228 374
…
3 unchanged lines
232 378
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, false);
233 379
        let author = user.config.author_key.unwrap();
234 380
        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();
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();
236 392
        let hash = change.hash();
237 393
        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();
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();
240 404
        // Without a policy the host accepts unauthenticated changes, but the
241 405
        // signature does not verify as the author's: it stays unauthenticated.
242 406
        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")));
407 +
        assert!(
408 +
            matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Invalid(why) if why.contains("neither the author"))
409 +
        );
244 410
    }
245 411
246 412
    #[test]
…
3 unchanged lines
250 416
        assert!(host.current_policy().unwrap().is_some());
251 417
        let author = user.config.author_key.unwrap();
252 418
        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();
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();
254 430
        let hash = change.hash();
255 431
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
256 432
        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();
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();
259 439
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
260 440
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
261 441
…
1 unchanged lines
263 443
        let stranger = SecretKey::generate();
264 444
        let sid = stranger.identity("Mallory", "m@example", 1);
265 445
        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();
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();
267 457
        let h2 = change2.hash();
268 458
        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();
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();
271 465
        assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results);
272 -
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash], "the reference did not move");
466 +
        assert_eq!(
467 +
            host.bookmark_heads().unwrap(),
468 +
            vec![hash],
469 +
            "the reference did not move"
470 +
        );
273 471
        // An unsigned update is refused under a policy too.
274 472
        let update = host.host_ref_update("main", &[hash], None).unwrap();
275 473
        let report = host.apply_hosted(None, &[], update).unwrap();
…
7 unchanged lines
283 481
        let author = user.config.author_key.unwrap();
284 482
        let base = host.bookmark_heads().unwrap();
285 483
        // 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();
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();
288 506
        let (ha, hb) = (a.hash(), b.hash());
289 507
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
290 508
        for (c, h) in [(a, ha), (b, hb)] {
…
1 unchanged lines
292 510
            let mut heads = host.bookmark_heads().unwrap();
293 511
            heads.retain(|x| !base.contains(x));
294 512
            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();
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();
297 519
            assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
298 520
        }
299 521
        let heads = host.bookmark_heads().unwrap();
…
1 unchanged lines
301 523
        let marked = String::from_utf8(host.file_at("a.txt", Some(&heads)).unwrap()).unwrap();
302 524
        assert!(marked.contains("<<<<<<< oxy conflict"), "{marked}");
303 525
        // 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();
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();
306 548
        assert_eq!(res.kind, ChangeKind::Resolution);
307 -
        assert!(res.deps.contains(&ha) && res.deps.contains(&hb), "a resolution depends on both sides");
549 +
        assert!(
550 +
            res.deps.contains(&ha) && res.deps.contains(&hb),
551 +
            "a resolution depends on both sides"
552 +
        );
308 553
        let hr = res.hash();
309 554
        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();
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();
312 561
        assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
313 562
        assert_eq!(host.file_at("a.txt", Some(&[hr])).unwrap(), b"One\ntwo\n");
314 -
        assert!(!host.load_state(&[hr]).unwrap().materialize().is_conflicted());
563 +
        assert!(!host
564 +
            .load_state(&[hr])
565 +
            .unwrap()
566 +
            .materialize()
567 +
            .is_conflicted());
315 568
        let _ = &mut user;
316 569
    }
317 570
}
modified crates/oxy-core/src/repo/sync.rs +10 −3
…
416 unchanged lines
417 417
    /// rule lists is authorized as that identity (spec §14.5): the identity
418 418
    /// named the delegate, and the policy names the identity. Revoked keys
419 419
    /// authorize nothing either way.
420 -
    fn delegate_authorized(&self, p: &crate::policy::Policy, signer: &KeyId, reference: &str) -> bool {
420 +
    pub(crate) fn delegate_authorized(
421 +
        &self,
422 +
        p: &crate::policy::Policy,
423 +
        signer: &KeyId,
424 +
        reference: &str,
425 +
    ) -> bool {
421 426
        if p.is_revoked(signer) {
422 427
            return false;
423 428
        }
424 -
        let Some(rule) = p.rule_for(reference) else { return false };
429 +
        let Some(rule) = p.rule_for(reference) else {
430 +
            return false;
431 +
        };
425 432
        rule.keys.iter().any(|k| {
426 433
            !p.is_revoked(k)
427 434
                && p.identity(k)
…
526 unchanged lines
954 961
                    .first()
955 962
                    .map(|s| s.key)
956 963
                    .ok_or_else(|| RepoError::Msg("attestation carries no signature".into()))?;
957 -
                if !p.authorizes(&signer, &r.name) {
964 +
                if !p.authorizes(&signer, &r.name) && !self.delegate_authorized(p, &signer, &r.name) {
958 965
                    return fail(format!(
959 966
                        "{remote_name}/{}: attested by {}, which the policy does not authorize",
960 967
                        r.name,
…
485 unchanged lines
modified crates/oxy-core/src/repo/tags.rs +2 −1
…
209 unchanged lines
210 210
                .map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
211 211
            verify_signature(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?;
212 212
            if let Some(p) = policy {
213 -
                if !p.authorizes(&sig.key, &format!("tags/{}", t.name)) {
213 +
                let reference = format!("tags/{}", t.name);
214 +
                if !p.authorizes(&sig.key, &reference) && !self.delegate_authorized(p, &sig.key, &reference) {
214 215
                    return Err(format!(
215 216
                        "key {} may not create tags/{} under this repository's policy",
216 217
                        sig.key.short(12),
…
25 unchanged lines
modified docs/plan.md +1 −1
…
88 unchanged lines
89 89
- [ ] `oxy seal <hash>..` and `--proposal URL` (hub 0.3).
90 90
- [ ] `oxy proposal show|land URL` (hub 0.3).
91 91
- [x] Delegate-signed changes from an edit set without a workspace: `Repo::build_change(heads, edits, message, author)`, `host_ref_update(bookmark, heads, signer)`, `apply_hosted(change, identities, update)` through `receive_push_report`; delegates of a rule's identities may sign reference attestations (spec §14.5, Draft 0.8g).
92 -
<<<<<<< oxy conflict 1 of 1, side 1 of 2: deleted by change 2018f517 "Host API: changes from an edit set, delegate-signed reference updates"
92 +
- [x] `Repo::log_window` and the shared `oxy_core::lanes::layout`; `State` is `Send + Sync` (closure cache behind a mutex) so hosts can cache loaded states.
93 93
-------
94 94
- [ ] Delegate-signed changes from an edit set without a workspace (hub 0.6).
95 95
======= oxy conflict 1 of 1, side 2 of 2: change d99fc50e "State is Send + Sync; shared lane layout; log_window"
…
7 unchanged lines