OXYGENHUB
# 0588de69a84d oxygen / crates / oxy-server / tests / http.rs
45.2 KiB · Rust · last changed Fall back from a checkout's own token only when the hub does not know it (401), never when it refuses it for permission (refs #155) 7 days ago source blame history raw
1
//! End-to-end: a real server on a loopback port, clients over HTTP.
2
3
use oxy_core::repo::{Repo, SignatureStatus};
4
use std::fs;
5
use std::net::TcpListener;
6
use std::path::Path;
7
8
fn write(root: &Path, rel: &str, content: &[u8]) {
9
    let p = root.join(rel);
10
    fs::create_dir_all(p.parent().unwrap()).unwrap();
11
    fs::write(p, content).unwrap();
12
}
13
14
/// Point `$OXY_HOME` at a throwaway directory once per test process so no
15
/// test reads or writes the developer's real user configuration or keys.
16
fn hermetic() {
17
    static ONCE: std::sync::Once = std::sync::Once::new();
18
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
19
    ONCE.call_once(|| {
20
        let d = tempfile::tempdir().unwrap();
21
        std::env::set_var("OXY_HOME", d.path());
22
        DIR.set(d).ok();
23
    });
24
}
25
26
fn start(root: &Path, tokens: Vec<String>) -> String {
27
    start_with(root, tokens, None)
28
}
29
30
fn start_with(root: &Path, tokens: Vec<String>, tls: Option<oxy_server::TlsFiles>) -> String {
31
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
32
    let port = listener.local_addr().unwrap().port();
33
    let scheme = if tls.is_some() { "https" } else { "http" };
34
    let cfg = oxy_server::ServerConfig {
35
        root: root.to_path_buf(),
36
        tokens,
37
        tls,
38
    };
39
    std::thread::spawn(move || oxy_server::run_blocking(listener, cfg).unwrap());
40
    // Give the acceptor a moment; the first request otherwise races the bind.
41
    std::thread::sleep(std::time::Duration::from_millis(50));
42
    format!("{scheme}://127.0.0.1:{port}")
43
}
44
45
/// A self-signed certificate for 127.0.0.1 written to `dir`: (cert, key).
46
fn self_signed(dir: &Path) -> (std::path::PathBuf, std::path::PathBuf) {
47
    fs::create_dir_all(dir).unwrap();
48
    let ck =
49
        rcgen::generate_simple_self_signed(vec!["127.0.0.1".into(), "localhost".into()]).unwrap();
50
    let cert = dir.join("cert.pem");
51
    let key = dir.join("key.pem");
52
    fs::write(&cert, ck.cert.pem()).unwrap();
53
    fs::write(&key, ck.signing_key.serialize_pem()).unwrap();
54
    (cert, key)
55
}
56
57
#[test]
58
fn clone_push_pull_over_http() {
59
    hermetic();
60
    let dir = tempfile::tempdir().unwrap();
61
    let root = dir.path();
62
    let served = root.join("served");
63
    Repo::init(&served.join("origin")).unwrap();
64
    let base = start(&served, vec![]);
65
    let url = format!("{base}/origin");
66
67
    let mut a = Repo::clone(&url, &root.join("a"))
68
        .unwrap()
69
        .with_home(root.join("home"));
70
    let (key, _) = a
71
        .create_identity(
72
            "Ada",
73
            "[email protected]",
74
            oxy_core::repo::IdentityScope::Repo,
75
        )
76
        .unwrap();
77
    write(&root.join("a"), "f.txt", b"hello\n");
78
    let h1 = a.commit("hello", &[]).unwrap();
79
    let r = a.push("origin", false).unwrap();
80
    assert!(r[0].ok, "{}", r[0].reason);
81
82
    let origin = Repo::open(&served.join("origin")).unwrap();
83
    assert_eq!(origin.bookmark_heads().unwrap(), vec![h1]);
84
    assert!(
85
        matches!(origin.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { key: k, sealed: true, .. } if k == key)
86
    );
87
88
    let b = Repo::clone(&url, &root.join("b")).unwrap();
89
    assert_eq!(fs::read(root.join("b/f.txt")).unwrap(), b"hello\n");
90
    write(&root.join("b"), "g.txt", b"g\n");
91
    let h2 = b.commit("g", &[]).unwrap();
92
    assert!(b.push("origin", false).unwrap()[0].ok);
93
94
    let pr = a.pull("origin").unwrap();
95
    assert!(pr.changed && pr.checked_out);
96
    assert_eq!(fs::read(root.join("a/g.txt")).unwrap(), b"g\n");
97
    let mut heads = a.bookmark_heads().unwrap();
98
    heads.sort();
99
    let mut want = vec![h1, h2];
100
    want.sort();
101
    assert_eq!(heads, want);
102
103
    // Unknown repositories and names the server refuses are surfaced as errors.
104
    let err = Repo::clone(&format!("{base}/nope"), &root.join("c"))
105
        .err()
106
        .unwrap()
107
        .to_string();
108
    assert!(err.contains("404"), "{err}");
109
    fs::create_dir_all(served.join(".hidden/.oxy")).unwrap();
110
    assert!(Repo::clone(&format!("{base}/.hidden"), &root.join("d"))
111
        .err()
112
        .unwrap()
113
        .to_string()
114
        .contains("404"));
115
}
116
117
#[test]
118
fn objects_endpoint_serves_lazy_blobs() {
119
    hermetic();
120
    let dir = tempfile::tempdir().unwrap();
121
    let root = dir.path();
122
    let served = root.join("served");
123
    let origin = Repo::init(&served.join("origin")).unwrap();
124
    let big: Vec<u8> = (0..300_000u32).map(|i| (i % 253) as u8).collect(); // has NUL bytes: opaque
125
    write(&served.join("origin"), "big.bin", &big);
126
    write(&served.join("origin"), "t.txt", b"t\n");
127
    origin.commit("big", &[]).unwrap();
128
    let base = start(&served, vec![]);
129
    let url = format!("{base}/origin");
130
    let a = Repo::clone_with(&url, &root.join("a"), Some(1000), &[]).unwrap();
131
    assert_eq!(
132
        fs::read(root.join("a/big.bin")).unwrap(),
133
        big,
134
        "fetched through the objects endpoint during checkout"
135
    );
136
    assert!(!a.snapshot("status").unwrap().changed);
137
}
138
139
#[test]
140
fn push_requires_a_token_when_configured() {
141
    hermetic();
142
    let dir = tempfile::tempdir().unwrap();
143
    let root = dir.path();
144
    let served = root.join("served");
145
    Repo::init(&served.join("origin")).unwrap();
146
    let base = start(&served, vec!["s3cret".into()]);
147
    let url = format!("{base}/origin");
148
149
    // Reads are open.
150
    let mut a = Repo::clone(&url, &root.join("a")).unwrap();
151
    write(&root.join("a"), "f.txt", b"x\n");
152
    a.commit("x", &[]).unwrap();
153
    let err = a.push("origin", false).err().unwrap().to_string();
154
    assert!(err.contains("401"), "{err}");
155
156
    a.config.set("remote.origin.token", "wrong").unwrap();
157
    a.save_config().unwrap();
158
    assert!(a
159
        .push("origin", false)
160
        .err()
161
        .unwrap()
162
        .to_string()
163
        .contains("401"));
164
165
    a.config.set("remote.origin.token", "s3cret").unwrap();
166
    a.save_config().unwrap();
167
    assert!(a.push("origin", false).unwrap()[0].ok);
168
    let origin = Repo::open(&served.join("origin")).unwrap();
169
    assert_eq!(origin.bookmark_heads().unwrap().len(), 1);
170
}
171
172
#[test]
173
fn https_with_a_self_signed_certificate() {
174
    hermetic();
175
    let dir = tempfile::tempdir().unwrap();
176
    let root = dir.path();
177
    let served = root.join("served");
178
    Repo::init(&served.join("origin")).unwrap();
179
    let (cert, key) = self_signed(root);
180
    let base = start_with(
181
        &served,
182
        vec![],
183
        Some(oxy_server::TlsFiles {
184
            cert: cert.clone(),
185
            key,
186
        }),
187
    );
188
    let url = format!("{base}/origin");
189
190
    // Without trusting the certificate the clone fails; with `OXY_CA` it works.
191
    let err = Repo::clone(&url, &root.join("untrusted"))
192
        .err()
193
        .unwrap()
194
        .to_string();
195
    assert!(
196
        err.contains("certificate")
197
            || err.contains("tls")
198
            || err.contains("TLS")
199
            || err.contains("handshake"),
200
        "{err}"
201
    );
202
    std::env::set_var("OXY_CA", &cert);
203
    let mut a = Repo::clone(&url, &root.join("a")).unwrap();
204
    std::env::remove_var("OXY_CA");
205
    // Persist the trust for this remote so later commands need no env var.
206
    a.config
207
        .set("remote.origin.ca", cert.to_str().unwrap())
208
        .unwrap();
209
    a.save_config().unwrap();
210
    write(&root.join("a"), "f.txt", b"tls\n");
211
    let h = a.commit("over tls", &[]).unwrap();
212
    assert!(a.push("origin", false).unwrap()[0].ok);
213
    let origin = Repo::open(&served.join("origin")).unwrap();
214
    assert_eq!(origin.bookmark_heads().unwrap(), vec![h]);
215
    // A wrong CA is refused.
216
    let (other_cert, _) = self_signed(&root.join("other"));
217
    a.config
218
        .set("remote.origin.ca", other_cert.to_str().unwrap())
219
        .unwrap();
220
    a.save_config().unwrap();
221
    assert!(a.fetch("origin").is_err());
222
}
223
224
/// Eight clones push independent changes at the same moment. The server
225
/// serialises pushes per repository, so every push either lands or is
226
/// refused because the bookmark moved, and a pull-then-push retry lands it.
227
#[test]
228
fn concurrent_pushes_are_serialised() {
229
    hermetic();
230
    let dir = tempfile::tempdir().unwrap();
231
    let root = dir.path();
232
    let served = root.join("served");
233
    Repo::init(&served.join("origin")).unwrap();
234
    let base = start(&served, vec![]);
235
    let url = format!("{base}/origin");
236
    const N: usize = 8;
237
    let mut clones = Vec::new();
238
    for i in 0..N {
239
        let path = root.join(format!("c{i}"));
240
        Repo::clone(&url, &path).unwrap();
241
        clones.push(path);
242
    }
243
    let barrier = std::sync::Arc::new(std::sync::Barrier::new(N));
244
    let mut handles = Vec::new();
245
    for (i, path) in clones.into_iter().enumerate() {
246
        let barrier = barrier.clone();
247
        handles.push(std::thread::spawn(move || {
248
            let repo = Repo::open(&path).unwrap();
249
            write(
250
                &path,
251
                &format!("file-{i}.txt"),
252
                format!("from clone {i}\n").as_bytes(),
253
            );
254
            let h = repo.commit(&format!("change {i}"), &[]).unwrap();
255
            barrier.wait();
256
            // A push is refused either up front (the remote moved since the
257
            // clone's last fetch) or by the server's compare-and-swap; both
258
            // are answered by pulling and pushing again.
259
            for attempt in 0..(N * 4) {
260
                match repo.push("origin", false) {
261
                    Ok(r) if r.iter().all(|x| x.ok) => return (h, attempt),
262
                    Ok(_) => {}
263
                    Err(e)
264
                        if e.to_string().contains("pull first")
265
                            || e.to_string().contains("moved") => {}
266
                    Err(e) => panic!("clone {i}: {e}"),
267
                }
268
                repo.pull("origin")
269
                    .unwrap_or_else(|e| panic!("clone {i} pull: {e}"));
270
            }
271
            panic!("clone {i} never managed to push");
272
        }));
273
    }
274
    let mut hashes = Vec::new();
275
    let mut total_rejected = 0;
276
    for h in handles {
277
        let (hash, attempts) = h.join().unwrap();
278
        hashes.push(hash);
279
        total_rejected += attempts;
280
    }
281
    let origin = Repo::open(&served.join("origin")).unwrap();
282
    let mut heads = origin.bookmark_heads().unwrap();
283
    heads.sort();
284
    hashes.sort();
285
    assert_eq!(heads, hashes, "every independent change is a head");
286
    let snap = origin.load_state(&heads).unwrap().materialize();
287
    for i in 0..N {
288
        assert!(snap.files.contains_key(&format!("file-{i}.txt")));
289
    }
290
    assert!(!snap.is_conflicted());
291
    eprintln!("{total_rejected} pushes were refused for a moved bookmark and retried");
292
    // The operation log is a single chain: each accepted push builds on the last.
293
    let ops = origin.op_log().unwrap();
294
    assert!(ops.len() > N);
295
}
296
297
#[test]
298
fn a_bare_repository_is_served_and_browsed_without_a_working_copy() {
299
    hermetic();
300
    let dir = tempfile::tempdir().unwrap();
301
    let root = dir.path();
302
    let served = root.join("served");
303
    let hosted = Repo::init_bare(&served.join("lib")).unwrap();
304
    assert!(hosted.is_bare());
305
    assert!(
306
        hosted.working_copy().is_err(),
307
        "a bare repository has no working copy"
308
    );
309
    assert!(!served.join("lib").join(".oxy").join("workspace").exists());
310
    let base = start(&served, vec![]);
311
    let url = format!("{base}/lib");
312
313
    // A client clones the empty repository, commits twice, and pushes.
314
    let work = root.join("work");
315
    let client = Repo::clone(&url, &work).unwrap();
316
    write(&work, "src/lib.rs", b"pub fn one() {}\n");
317
    client.snapshot("test").unwrap();
318
    let c1 = client.commit("one", &[]).unwrap();
319
    write(&work, "src/lib.rs", b"pub fn one() {}\npub fn two() {}\n");
320
    client.snapshot("test").unwrap();
321
    let c2 = client.commit("two", &[]).unwrap();
322
    let results = client.push("origin", false).unwrap();
323
    assert!(results.iter().all(|r| r.ok), "{results:?}");
324
325
    // The host reads history, files and blame from the bookmark's state alone.
326
    let hosted = Repo::open(&served.join("lib")).unwrap();
327
    assert!(hosted.is_bare());
328
    let heads = hosted.bookmark_heads().unwrap();
329
    assert_eq!(heads, vec![c2]);
330
    let log = hosted.log().unwrap();
331
    assert_eq!(log.iter().map(|e| e.hash).collect::<Vec<_>>(), vec![c2, c1]);
332
    assert!(log.iter().all(|e| !e.is_working_copy));
333
    assert_eq!(hosted.log_state(&[c1]).unwrap().len(), 1);
334
    assert_eq!(
335
        hosted.file_at("src/lib.rs", Some(&heads)).unwrap(),
336
        b"pub fn one() {}\npub fn two() {}\n"
337
    );
338
    let blame = hosted.blame_in("src/lib.rs", &heads).unwrap();
339
    assert_eq!(
340
        blame.iter().map(|l| l.hash).collect::<Vec<_>>(),
341
        vec![c1, c2]
342
    );
343
    assert_eq!(hosted.log_for_path("src/lib.rs").unwrap().len(), 2);
344
    assert_eq!(hosted.diff_files(Some(&c2)).unwrap().len(), 1);
345
    assert!(matches!(
346
        hosted.verify_change(&c2).unwrap(),
347
        SignatureStatus::Unsigned | SignatureStatus::Valid { .. }
348
    ));
349
350
    // Snapshotting a bare repository is refused rather than inventing a working copy.
351
    assert!(hosted.snapshot("test").is_err());
352
}
353
354
/// A host embeds the protocol with its own backend: names with an owner
355
/// segment, reads gated per repository, and a push hook.
356
#[test]
357
fn a_custom_backend_embeds_the_protocol() {
358
    use axum::extract::{Path as AxPath, State};
359
    use axum::http::{HeaderMap, Method, StatusCode};
360
    use axum::response::{IntoResponse, Response};
361
    use oxy_server::{Access, Backend, Endpoint, Failure, Server};
362
    use std::sync::{Arc, Mutex};
363
364
    struct Hub {
365
        root: std::path::PathBuf,
366
        pushes: Arc<Mutex<Vec<String>>>,
367
    }
368
    impl Backend for Hub {
369
        fn open(&self, name: &str) -> Result<Repo, Failure> {
370
            let Some((owner, repo)) = name.split_once('/') else {
371
                return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
372
            };
373
            let dir = self.root.join(owner).join(repo);
374
            if !dir.join(".oxy").is_dir() {
375
                return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
376
            }
377
            Repo::open(&dir).map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
378
        }
379
        fn authorize(
380
            &self,
381
            name: &str,
382
            access: Access,
383
            headers: &HeaderMap,
384
        ) -> Result<(), Failure> {
385
            // `secret/*` is private: unknown to anyone without the token, even for reads.
386
            let token = oxy_server::bearer_token(headers);
387
            if name.starts_with("secret/") && token != Some("s3cret") {
388
                return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
389
            }
390
            if access == Access::Push && token.is_none() {
391
                return Err(Failure(StatusCode::UNAUTHORIZED, "sign in to push".into()));
392
            }
393
            Ok(())
394
        }
395
        fn after_push(
396
            &self,
397
            name: &str,
398
            report: &oxy_core::repo::PushReport,
399
            _headers: &HeaderMap,
400
        ) {
401
            self.pushes.lock().unwrap().push(format!(
402
                "{name}: {} ok, {} updated to {} head(s), {} imported",
403
                report.results.iter().filter(|r| r.ok).count(),
404
                report.updated.len(),
405
                report.updated.first().map(|u| u.heads.len()).unwrap_or(0),
406
                report.imported.len()
407
            ));
408
        }
409
    }
410
    async fn route(
411
        State(server): State<Arc<Server>>,
412
        AxPath((owner, repo, endpoint)): AxPath<(String, String, String)>,
413
        method: Method,
414
        headers: HeaderMap,
415
        body: axum::body::Bytes,
416
    ) -> Response {
417
        match Endpoint::parse(&endpoint) {
418
            Some(e) => {
419
                server
420
                    .handle(format!("{owner}/{repo}"), e, &method, &headers, body)
421
                    .await
422
            }
423
            None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(),
424
        }
425
    }
426
427
    hermetic();
428
    let dir = tempfile::tempdir().unwrap();
429
    let root = dir.path();
430
    Repo::init_bare(&root.join("repos/andy/app")).unwrap();
431
    Repo::init_bare(&root.join("repos/secret/plans")).unwrap();
432
    let pushes = Arc::new(Mutex::new(Vec::new()));
433
    let server = Server::new(Hub {
434
        root: root.join("repos"),
435
        pushes: pushes.clone(),
436
    });
437
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
438
    let port = listener.local_addr().unwrap().port();
439
    std::thread::spawn(move || {
440
        let rt = tokio::runtime::Builder::new_multi_thread()
441
            .enable_all()
442
            .build()
443
            .unwrap();
444
        rt.block_on(async move {
445
            listener.set_nonblocking(true).unwrap();
446
            let app = axum::Router::new()
447
                .route(
448
                    "/{owner}/{repo}/oxy/v1/{endpoint}",
449
                    axum::routing::any(route),
450
                )
451
                .with_state(server);
452
            axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app)
453
                .await
454
                .unwrap();
455
        });
456
    });
457
    std::thread::sleep(std::time::Duration::from_millis(50));
458
    let base = format!("http://127.0.0.1:{port}");
459
460
    // Anonymous clone of the public repository works; the private one does not exist for outsiders.
461
    let work = root.join("work");
462
    let client = Repo::clone(&format!("{base}/andy/app"), &work).unwrap();
463
    assert!(Repo::clone(&format!("{base}/secret/plans"), &root.join("nope")).is_err());
464
465
    // A push needs a token; with one it lands and the hook sees it.
466
    write(&work, "a.txt", b"a\n");
467
    client.snapshot("test").unwrap();
468
    client.commit("a", &[]).unwrap();
469
    assert!(
470
        client.push("origin", false).is_err(),
471
        "anonymous push must be refused"
472
    );
473
    let mut client = client;
474
    client.config.set("remote.origin.token", "s3cret").unwrap();
475
    let results = client.push("origin", false).unwrap();
476
    assert!(results.iter().all(|r| r.ok), "{results:?}");
477
    assert_eq!(
478
        pushes.lock().unwrap().as_slice(),
479
        ["andy/app: 1 ok, 1 updated to 1 head(s), 1 imported"]
480
    );
481
482
    let hosted = Repo::open(&root.join("repos/andy/app")).unwrap();
483
    assert_eq!(hosted.bookmark_heads().unwrap().len(), 1);
484
}
485
486
/// A hosted repository created unbound adopts the id of the first
487
/// repository pushed to it; before that it cannot be cloned or fetched.
488
#[test]
489
fn an_unbound_repository_adopts_the_first_pushers_id() {
490
    hermetic();
491
    let dir = tempfile::tempdir().unwrap();
492
    let root = dir.path();
493
    let served = root.join("served");
494
    let hosted = Repo::init_bare_unbound(&served.join("lib")).unwrap();
495
    assert!(hosted.is_unbound());
496
    assert_eq!(hosted.id().unwrap(), [0; 16]);
497
    let base = start(&served, vec![]);
498
    let url = format!("{base}/lib");
499
    assert!(Repo::clone(&url, &root.join("early"))
500
        .err()
501
        .unwrap()
502
        .to_string()
503
        .contains("unbound"));
504
505
    // An existing repository with its own id and history.
506
    let work = root.join("work");
507
    let mut local = Repo::init(&work).unwrap();
508
    let my_id = local.id().unwrap();
509
    write(&work, "a.txt", b"a\n");
510
    local.snapshot("test").unwrap();
511
    local.commit("a", &[]).unwrap();
512
    local.config.set("remote.origin.url", &url).unwrap();
513
    local.save_config().unwrap();
514
    assert!(
515
        local
516
            .fetch("origin")
517
            .err()
518
            .unwrap()
519
            .to_string()
520
            .contains("unbound"),
521
        "fetching an unbound remote is refused"
522
    );
523
    let results = local.push("origin", false).unwrap();
524
    assert!(results.iter().all(|r| r.ok), "{results:?}");
525
526
    let hosted = Repo::open(&served.join("lib")).unwrap();
527
    assert!(!hosted.is_unbound());
528
    assert_eq!(hosted.id().unwrap(), my_id, "bound to the pusher's id");
529
    assert_eq!(hosted.bookmark_heads().unwrap().len(), 1);
530
    // From now on it is an ordinary remote: clones share the id, other repositories are refused.
531
    let cloned = Repo::clone(&url, &root.join("late")).unwrap();
532
    assert_eq!(cloned.id().unwrap(), my_id);
533
    let mut other = Repo::init(&root.join("other")).unwrap();
534
    other.config.set("remote.origin.url", &url).unwrap();
535
    other.save_config().unwrap();
536
    write(&root.join("other"), "b.txt", b"b\n");
537
    other.snapshot("test").unwrap();
538
    other.commit("b", &[]).unwrap();
539
    assert!(other
540
        .push("origin", false)
541
        .err()
542
        .unwrap()
543
        .to_string()
544
        .contains("different repository"));
545
}
546
547
/// History made before an identity existed (zero author) is adopted by
548
/// the pusher's seal, so it passes a server that enforces a policy.
549
#[test]
550
fn anonymous_history_is_adopted_by_the_pushers_seal() {
551
    hermetic();
552
    let dir = tempfile::tempdir().unwrap();
553
    let root = dir.path();
554
    let served = root.join("served");
555
    Repo::init_bare_unbound(&served.join("app")).unwrap();
556
    let base = start(&served, vec![]);
557
    let url = format!("{base}/app");
558
559
    // Two anonymous changes, then an identity and a policy naming it.
560
    let work = root.join("work");
561
    let mut local = Repo::init(&work).unwrap();
562
    write(&work, "a.txt", b"a\n");
563
    local.snapshot("test").unwrap();
564
    let anon = local.commit("before identity", &[]).unwrap();
565
    local
566
        .create_identity(
567
            "Andy",
568
            "[email protected]",
569
            oxy_core::repo::IdentityScope::Repo,
570
        )
571
        .unwrap();
572
    local.policy_init().unwrap();
573
    write(&work, "b.txt", b"b\n");
574
    local.snapshot("test").unwrap();
575
    let mine = local.commit("after identity", &[]).unwrap();
576
    assert!(matches!(
577
        local.authenticate_change(&anon).unwrap(),
578
        SignatureStatus::Unsigned
579
    ));
580
    local.config.set("remote.origin.url", &url).unwrap();
581
    local.save_config().unwrap();
582
    let results = local.push("origin", false).unwrap();
583
    assert!(results.iter().all(|r| r.ok), "{results:?}");
584
585
    // The server holds the policy and both changes verify: sealed by the adopter.
586
    let hosted = Repo::open(&served.join("app")).unwrap();
587
    assert!(hosted.current_policy().unwrap().is_some());
588
    for h in [anon, mine] {
589
        assert!(
590
            matches!(hosted.authenticate_change(&h).unwrap(), SignatureStatus::Valid { sealed: true, name, .. } if name == "Andy"),
591
            "{h}"
592
        );
593
    }
594
    // A change that names another author still cannot be sealed by someone else.
595
    let stranger = root.join("stranger");
596
    let mut other = Repo::init(&stranger).unwrap();
597
    other
598
        .create_identity(
599
            "Mallory",
600
            "[email protected]",
601
            oxy_core::repo::IdentityScope::Repo,
602
        )
603
        .unwrap();
604
    write(&stranger, "c.txt", b"c\n");
605
    other.snapshot("test").unwrap();
606
    let theirs = other.commit("mallory's", &[]).unwrap();
607
    assert!(matches!(
608
        other.authenticate_change(&theirs).unwrap(),
609
        SignatureStatus::Unsigned
610
    ));
611
    let bundle = other.bundle_for(&[theirs]).unwrap();
612
    local.import_bundle(&bundle, false).unwrap();
613
    local.seal(&[theirs]).unwrap();
614
    assert!(
615
        matches!(local.authenticate_change(&theirs).unwrap(), SignatureStatus::Invalid(why) if why.contains("neither the author nor a delegate"))
616
    );
617
}
618
619
/// `oxy push --delete` over HTTP: a bare repository's bookmark goes, and
620
/// its default (the served repository's own bookmark) is refused by the
621
/// backend (spec §16.4).
622
#[test]
623
fn bookmarks_are_deleted_over_http_but_never_the_default() {
624
    hermetic();
625
    let dir = tempfile::tempdir().unwrap();
626
    let root = dir.path();
627
    let served = root.join("served");
628
    let hosted = Repo::init_bare(&served.join("lib")).unwrap();
629
    let base = start(&served, vec![]);
630
    let work = root.join("work");
631
    let client = Repo::clone(&format!("{base}/lib"), &work).unwrap();
632
    write(&work, "a.txt", b"a\n");
633
    client.commit("a", &[]).unwrap();
634
    assert!(client.push("origin", false).unwrap()[0].ok);
635
    client.bookmark_create("feature/x", None).unwrap();
636
    assert!(client.push_bookmark("origin", "feature/x", false).unwrap()[0].ok);
637
    let r = client.push_delete("origin", "feature/x").unwrap();
638
    assert!(r[0].ok, "{r:?}");
639
    assert!(!hosted.view().unwrap().bookmarks.contains_key("feature/x"));
640
    let r = client.push_delete("origin", "main").unwrap();
641
    assert!(
642
        !r[0].ok && r[0].reason.contains("default bookmark"),
643
        "{r:?}"
644
    );
645
    assert!(hosted.view().unwrap().bookmarks.contains_key("main"));
646
}
647
648
/// A push the server cannot store says which bookmark and what failed, so
649
/// it can be diagnosed from the client's message alone.
650
#[cfg(unix)]
651
#[test]
652
fn a_refused_push_names_the_bookmark_and_the_step() {
653
    hermetic();
654
    let dir = tempfile::tempdir().unwrap();
655
    let root = dir.path();
656
    let served = root.join("served");
657
    Repo::init_bare(&served.join("lib")).unwrap();
658
    // Break the server's attestation store for writing only: reading finds
659
    // nothing there, storing a new attestation fails.
660
    let attest = served.join("lib/.oxy/attest");
661
    fs::create_dir_all(&attest).unwrap();
662
    {
663
        use std::os::unix::fs::PermissionsExt;
664
        fs::set_permissions(&attest, fs::Permissions::from_mode(0o555)).unwrap();
665
    }
666
    let base = start(&served, vec![]);
667
    let work = root.join("work");
668
    let mut client = Repo::clone(&format!("{base}/lib"), &work)
669
        .unwrap()
670
        .with_home(root.join("home"));
671
    client
672
        .create_identity(
673
            "Ada",
674
            "[email protected]",
675
            oxy_core::repo::IdentityScope::Repo,
676
        )
677
        .unwrap();
678
    write(&work, "a.txt", b"a\n");
679
    client.commit("a", &[]).unwrap();
680
    client.bookmark_create("feature/x", None).unwrap();
681
    let r = client.push_bookmark("origin", "feature/x", false).unwrap();
682
    assert!(!r[0].ok);
683
    assert!(
684
        r[0].reason.contains("\"feature/x\"") && r[0].reason.contains("storing the attestation"),
685
        "{}",
686
        r[0].reason
687
    );
688
    {
689
        use std::os::unix::fs::PermissionsExt;
690
        fs::set_permissions(&attest, fs::Permissions::from_mode(0o755)).unwrap();
691
    }
692
}
693
694
/// oxygen#112: a subrepo checkout whose stored login for the hub is stale
695
/// pushes with the token its parent checkout holds for the same hub, once
696
/// the stale one is refused; a token the subrepo sets itself is never
697
/// swapped for another.
698
#[test]
699
fn a_refused_stored_token_falls_back_to_the_parents() {
700
    hermetic();
701
    let dir = tempfile::tempdir().unwrap();
702
    let root = dir.path();
703
    let served = root.join("served");
704
    Repo::init(&served.join("parent")).unwrap();
705
    Repo::init(&served.join("child")).unwrap();
706
    let base = start(&served, vec!["good".into()]);
707
708
    let work = root.join("work");
709
    let mut parent = Repo::clone(&format!("{base}/parent"), &work).unwrap();
710
    parent.config.set("remote.origin.token", "good").unwrap();
711
    parent.save_config().unwrap();
712
    // The subrepo's own home holds a stale login for this hub.
713
    let home = root.join("home");
714
    oxy_core::credentials::Credentials::at(&home)
715
        .save(&base, "stale", Some("andy"), None)
716
        .unwrap();
717
    let nested = work.join("deps").join("child");
718
    let mut child = Repo::clone(&format!("{base}/child"), &nested)
719
        .unwrap()
720
        .with_home(&home);
721
    let options = child.options_for_url(&format!("{base}/child"));
722
    assert_eq!(
723
        options.token.as_deref(),
724
        Some("stale"),
725
        "the stored login comes first"
726
    );
727
    assert_eq!(
728
        options.fallback_tokens,
729
        vec!["good".to_string()],
730
        "the parent's, to fall back on"
731
    );
732
    assert!(!options.fallback_on_401_only);
733
    write(&nested, "c.txt", b"child\n");
734
    child.commit("child work", &[]).unwrap();
735
    let pushed = child.push("origin", false).unwrap();
736
    assert!(pushed[0].ok, "{}", pushed[0].reason);
737
    assert_eq!(
738
        Repo::open(&served.join("child"))
739
            .unwrap()
740
            .bookmark_heads()
741
            .unwrap()
742
            .len(),
743
        1
744
    );
745
746
    // Its own token, if refused, falls back to this computer's sign-in,
747
    // never to the parent's (#155): stale too here, so the push fails and
748
    // says what to do.
749
    child.config.set("remote.origin.token", "wrong").unwrap();
750
    child.save_config().unwrap();
751
    let options = child.options_for_url(&format!("{base}/child"));
752
    assert_eq!(
753
        options.fallback_tokens,
754
        vec!["stale".to_string()],
755
        "the sign-in only"
756
    );
757
    assert!(
758
        options.fallback_on_401_only,
759
        "and only for a token the hub does not know: one refused for permission is not swapped"
760
    );
761
    write(&nested, "d.txt", b"more\n");
762
    child.commit("more", &[]).unwrap();
763
    let err = child.push("origin", false).err().unwrap().to_string();
764
    assert!(err.contains("401"), "{err}");
765
    let note = child.stale_token_note("origin", &err).expect("a note");
766
    assert!(
767
        note.contains("oxy auth login") && note.contains("oxy config --unset remote.origin.token"),
768
        "{note}"
769
    );
770
    assert!(child.stale_token_note("origin", "HTTP 500").is_none());
771
    // With a good sign-in, an expired checkout token no longer blocks.
772
    oxy_core::credentials::Credentials::at(&home)
773
        .save(&base, "good", Some("andy"), None)
774
        .unwrap();
775
    let child = Repo::open(&nested).unwrap().with_home(&home);
776
    let pushed = child.push("origin", false).unwrap();
777
    assert!(pushed[0].ok, "{}", pushed[0].reason);
778
}
779
780
/// A host refuses a push by what it adds (`Backend::vet_push`): the client
781
/// sees the host's reason, every line of it, and nothing is stored, bound
782
/// or moved; a push without that content then lands.
783
#[test]
784
fn a_host_refuses_a_push_by_its_content() {
785
    use axum::extract::{Path as AxPath, State};
786
    use axum::http::{HeaderMap, Method, StatusCode};
787
    use axum::response::Response;
788
    use oxy_core::objects::Edit;
789
    use oxy_core::protocol::{Bundle, RefUpdate};
790
    use oxy_server::{Access, Backend, DirBackend, Endpoint, Failure, Server};
791
    use std::sync::Arc;
792
793
    struct Vetting(DirBackend);
794
    impl Backend for Vetting {
795
        fn open(&self, name: &str) -> Result<Repo, Failure> {
796
            self.0.open(name)
797
        }
798
        fn authorize(
799
            &self,
800
            name: &str,
801
            access: Access,
802
            headers: &HeaderMap,
803
        ) -> Result<(), Failure> {
804
            self.0.authorize(name, access, headers)
805
        }
806
        fn vet_push(
807
            &self,
808
            _name: &str,
809
            _repo: &Repo,
810
            bundle: &Bundle,
811
            updates: &[RefUpdate],
812
            _headers: &HeaderMap,
813
        ) -> Result<(), Failure> {
814
            let adds_it = bundle.changes.iter().any(|(c, _)| {
815
                c.edits.iter().any(|e| match e {
816
                    Edit::Insert { lines, .. } => lines
817
                        .iter()
818
                        .any(|l| l.windows(11).any(|w| w == b"DO NOT PUSH")),
819
                    _ => false,
820
                })
821
            });
822
            if adds_it {
823
                return Err(Failure(
824
                    StatusCode::UNPROCESSABLE_ENTITY,
825
                    format!(
826
                        "refused: a change adds DO NOT PUSH\n  a.txt:2\nremove it and push {} again",
827
                        updates.first().map(|u| u.name.as_str()).unwrap_or("")
828
                    ),
829
                ));
830
            }
831
            Ok(())
832
        }
833
    }
834
    async fn route(
835
        State(server): State<Arc<Server>>,
836
        AxPath((repo, endpoint)): AxPath<(String, String)>,
837
        method: Method,
838
        headers: HeaderMap,
839
        body: axum::body::Bytes,
840
    ) -> Response {
841
        let e = Endpoint::parse(&endpoint).unwrap();
842
        server.handle(repo, e, &method, &headers, body).await
843
    }
844
845
    hermetic();
846
    let dir = tempfile::tempdir().unwrap();
847
    let root = dir.path();
848
    let served = root.join("served");
849
    let hosted = Repo::init_bare_unbound(&served.join("lib")).unwrap();
850
    let server = Server::new(Vetting(DirBackend {
851
        root: served.clone(),
852
        tokens: vec![],
853
    }));
854
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
855
    let port = listener.local_addr().unwrap().port();
856
    std::thread::spawn(move || {
857
        let rt = tokio::runtime::Builder::new_multi_thread()
858
            .enable_all()
859
            .build()
860
            .unwrap();
861
        rt.block_on(async move {
862
            listener.set_nonblocking(true).unwrap();
863
            let app = axum::Router::new()
864
                .route("/{repo}/oxy/v1/{endpoint}", axum::routing::any(route))
865
                .with_state(server);
866
            axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app)
867
                .await
868
                .unwrap();
869
        });
870
    });
871
    std::thread::sleep(std::time::Duration::from_millis(50));
872
    let url = format!("http://127.0.0.1:{port}/lib");
873
874
    let work = root.join("work");
875
    let mut local = Repo::init(&work).unwrap();
876
    local.config.set("remote.origin.url", &url).unwrap();
877
    local.save_config().unwrap();
878
    write(&work, "a.txt", b"one\nDO NOT PUSH\n");
879
    local.snapshot("test").unwrap();
880
    let bad = local.commit("a", &[]).unwrap();
881
    let err = local.push("origin", false).err().unwrap().to_string();
882
    assert!(
883
        err.contains("HTTP 422")
884
            && err.contains(
885
                "refused: a change adds DO NOT PUSH\n  a.txt:2\nremove it and push main again"
886
            )
887
            && !err.contains("hint:"),
888
        "{err}"
889
    );
890
    let hosted_now = Repo::open(&served.join("lib")).unwrap();
891
    assert!(hosted_now.is_unbound(), "a refused push binds nothing");
892
    assert!(hosted_now.bookmark_heads().unwrap().is_empty());
893
    assert!(
894
        hosted_now.store().get_change(&bad).is_err(),
895
        "nothing stored"
896
    );
897
    drop(hosted);
898
899
    // Without it, the push lands.
900
    write(&work, "a.txt", b"one\n");
901
    local.snapshot("test").unwrap();
902
    local.amend(None).unwrap();
903
    let results = local.push("origin", false).unwrap();
904
    assert!(results.iter().all(|r| r.ok), "{results:?}");
905
    assert_eq!(
906
        Repo::open(&served.join("lib"))
907
            .unwrap()
908
            .bookmark_heads()
909
            .unwrap()
910
            .len(),
911
        1
912
    );
913
}
914
915
/// A host adds a note to a reference a push moved (`Backend::push_notes`):
916
/// the client gets it as that reference's result, accepted as before,
917
/// and the host's `after_push` sees it too; a reference with no note
918
/// says nothing more.
919
#[test]
920
fn a_host_adds_a_note_to_an_accepted_reference() {
921
    use axum::extract::{Path as AxPath, State};
922
    use axum::http::{HeaderMap, Method};
923
    use axum::response::Response;
924
    use oxy_core::repo::PushReport;
925
    use oxy_server::{Access, Backend, DirBackend, Endpoint, Failure, Server};
926
    use std::sync::{Arc, Mutex};
927
928
    struct Noting(DirBackend, Arc<Mutex<Vec<String>>>);
929
    impl Backend for Noting {
930
        fn open(&self, name: &str) -> Result<Repo, Failure> {
931
            self.0.open(name)
932
        }
933
        fn authorize(
934
            &self,
935
            name: &str,
936
            access: Access,
937
            headers: &HeaderMap,
938
        ) -> Result<(), Failure> {
939
            self.0.authorize(name, access, headers)
940
        }
941
        fn push_notes(
942
            &self,
943
            _name: &str,
944
            report: &PushReport,
945
            _headers: &HeaderMap,
946
        ) -> Vec<(String, String)> {
947
            report
948
                .updated
949
                .iter()
950
                .filter(|u| u.name == "feature")
951
                .map(|u| (u.name.clone(), "its proposal #3 has landed".to_string()))
952
                .collect()
953
        }
954
        fn after_push(&self, _name: &str, report: &PushReport, _headers: &HeaderMap) {
955
            let mut seen = self.1.lock().unwrap();
956
            seen.extend(report.results.iter().map(|r| r.reason.clone()));
957
        }
958
    }
959
    async fn route(
960
        State(server): State<Arc<Server>>,
961
        AxPath((repo, endpoint)): AxPath<(String, String)>,
962
        method: Method,
963
        headers: HeaderMap,
964
        body: axum::body::Bytes,
965
    ) -> Response {
966
        let e = Endpoint::parse(&endpoint).unwrap();
967
        server.handle(repo, e, &method, &headers, body).await
968
    }
969
970
    hermetic();
971
    let dir = tempfile::tempdir().unwrap();
972
    let root = dir.path();
973
    let served = root.join("served");
974
    Repo::init_bare_unbound(&served.join("lib")).unwrap();
975
    let seen = Arc::new(Mutex::new(Vec::new()));
976
    let server = Server::new(Noting(
977
        DirBackend {
978
            root: served.clone(),
979
            tokens: vec![],
980
        },
981
        seen.clone(),
982
    ));
983
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
984
    let port = listener.local_addr().unwrap().port();
985
    std::thread::spawn(move || {
986
        let rt = tokio::runtime::Builder::new_multi_thread()
987
            .enable_all()
988
            .build()
989
            .unwrap();
990
        rt.block_on(async move {
991
            listener.set_nonblocking(true).unwrap();
992
            let app = axum::Router::new()
993
                .route("/{repo}/oxy/v1/{endpoint}", axum::routing::any(route))
994
                .with_state(server);
995
            axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app)
996
                .await
997
                .unwrap();
998
        });
999
    });
1000
    std::thread::sleep(std::time::Duration::from_millis(50));
1001
    let url = format!("http://127.0.0.1:{port}/lib");
1002
1003
    let work = root.join("work");
1004
    let mut local = Repo::init(&work).unwrap();
1005
    local.config.set("remote.origin.url", &url).unwrap();
1006
    local.save_config().unwrap();
1007
    write(&work, "a.txt", b"one\n");
1008
    local.snapshot("test").unwrap();
1009
    let base = local.commit("a", &[]).unwrap();
1010
    let results = local.push("origin", false).unwrap();
1011
    assert!(
1012
        results.iter().all(|r| r.ok && r.reason.is_empty()),
1013
        "{results:?}"
1014
    );
1015
    local.bookmark_create("feature", Some(&[base])).unwrap();
1016
    local.bookmark_switch("feature", false).unwrap();
1017
    write(&work, "a.txt", b"one\ntwo\n");
1018
    local.snapshot("test").unwrap();
1019
    local.commit("two", &[]).unwrap();
1020
    let results = local.push_bookmark("origin", "feature", false).unwrap();
1021
    let feature = results.iter().find(|r| r.name == "feature").unwrap();
1022
    assert!(feature.ok, "{results:?}");
1023
    assert_eq!(feature.reason, "its proposal #3 has landed");
1024
    assert!(seen
1025
        .lock()
1026
        .unwrap()
1027
        .iter()
1028
        .any(|r| r == "its proposal #3 has landed"));
1029
}
1030
1031
/// oxygen#131: oxy 0.5.0 and older authorize a tag only by a key the
1032
/// policy lists (or its delegate or predecessor) and fail the whole clone
1033
/// on any other, so a client that does not declare `certified-tags` is
1034
/// never sent a tag that only a short-lived key's certificate authorizes.
1035
/// It clones the rest without error; a current client still gets the tag;
1036
/// and an old client that holds such a tag pushes without trouble.
1037
#[test]
1038
fn a_client_without_certified_tags_never_sees_a_tag_only_a_certificate_authorizes() {
1039
    use axum::extract::{Path as AxPath, State};
1040
    use axum::http::{HeaderMap, Method, StatusCode};
1041
    use axum::response::{IntoResponse, Response};
1042
    use oxy_core::cert::{Certificate, SignedCertificate};
1043
    use oxy_core::identity::{Identity, Scope, SecretKey};
1044
    use oxy_core::protocol::{wire, RemoteOptions, FEATURES_HEADER};
1045
    use oxy_core::repo::IdentityScope;
1046
    use oxy_server::{Access, Backend, Endpoint, Failure, Server};
1047
    use std::sync::Arc;
1048
1049
    // A host that countersigns when it accepted a certified key's tag, as
1050
    // a hub does.
1051
    struct Host {
1052
        root: std::path::PathBuf,
1053
        acceptor: (Identity, SecretKey),
1054
    }
1055
    impl Backend for Host {
1056
        fn open(&self, name: &str) -> Result<Repo, Failure> {
1057
            Repo::open(&self.root.join(name))
1058
                .map(|r| r.with_acceptor(self.acceptor.0.clone(), self.acceptor.1.clone()))
1059
                .map_err(|e| Failure(StatusCode::NOT_FOUND, e.to_string()))
1060
        }
1061
        fn authorize(&self, _: &str, _: Access, _: &HeaderMap) -> Result<(), Failure> {
1062
            Ok(())
1063
        }
1064
    }
1065
    // `/new/...` as clients send it; `/old/...` without the features
1066
    // header, as oxy 0.5.0 sends it.
1067
    async fn route(
1068
        State(server): State<Arc<Server>>,
1069
        AxPath((era, repo, endpoint)): AxPath<(String, String, String)>,
1070
        method: Method,
1071
        mut headers: HeaderMap,
1072
        body: axum::body::Bytes,
1073
    ) -> Response {
1074
        if era == "old" {
1075
            headers.remove(FEATURES_HEADER);
1076
        }
1077
        match Endpoint::parse(&endpoint) {
1078
            Some(e) => server.handle(repo, e, &method, &headers, body).await,
1079
            None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(),
1080
        }
1081
    }
1082
1083
    hermetic();
1084
    let dir = tempfile::tempdir().unwrap();
1085
    let root = dir.path();
1086
    let served = root.join("served");
1087
    Repo::init(&served.join("origin")).unwrap();
1088
    let hub_sk = SecretKey::generate();
1089
    let hub = hub_sk.identity("hub", "[email protected]", 1);
1090
    let server = Server::new(Host {
1091
        root: served.clone(),
1092
        acceptor: (hub.clone(), hub_sk.clone()),
1093
    });
1094
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1095
    let port = listener.local_addr().unwrap().port();
1096
    std::thread::spawn(move || {
1097
        let rt = tokio::runtime::Builder::new_multi_thread()
1098
            .enable_all()
1099
            .build()
1100
            .unwrap();
1101
        rt.block_on(async move {
1102
            listener.set_nonblocking(true).unwrap();
1103
            let app = axum::Router::new()
1104
                .route("/{era}/{repo}/oxy/v1/{endpoint}", axum::routing::any(route))
1105
                .with_state(server);
1106
            axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app)
1107
                .await
1108
                .unwrap();
1109
        });
1110
    });
1111
    std::thread::sleep(std::time::Duration::from_millis(50));
1112
    let new_url = format!("http://127.0.0.1:{port}/new/origin");
1113
    let old_url = format!("http://127.0.0.1:{port}/old/origin");
1114
1115
    // Ada's policy grants the hub's certified agents; her own tag v0 is
1116
    // by a key the policy lists.
1117
    let mut a = Repo::clone(&new_url, &root.join("a"))
1118
        .unwrap()
1119
        .with_home(root.join("home-a"));
1120
    a.create_identity("Ada", "[email protected]", IdentityScope::User)
1121
        .unwrap();
1122
    write(&root.join("a"), "a.txt", b"a\n");
1123
    a.commit("a", &[]).unwrap();
1124
    a.policy_init().unwrap();
1125
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1126
    a.policy_grant_subject(
1127
        "agent:claude@ada",
1128
        hub.clone(),
1129
        Scope::CHANGES | Scope::REFS,
1130
        Some("*"),
1131
    )
1132
    .unwrap();
1133
    let heads = a.bookmark_heads().unwrap();
1134
    a.tag_create("v0", &heads, "zero").unwrap();
1135
    let r = a.push("origin", false).unwrap();
1136
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1137
1138
    // A keyless agent's tag v1, by a key the hub certified for ten minutes.
1139
    let now = oxy_core::objects::Hlc::now().wall_ms;
1140
    let mut c = Repo::clone(&new_url, &root.join("claude"))
1141
        .unwrap()
1142
        .with_home(root.join("home-claude"));
1143
    c.store().put_identity(&hub).unwrap();
1144
    let (_, id) = c
1145
        .create_identity("claude", "[email protected]", IdentityScope::User)
1146
        .unwrap();
1147
    let cert = SignedCertificate::sign(
1148
        Certificate {
1149
            key: id,
1150
            issuer: hub.hash(),
1151
            subject: "agent:claude@ada".into(),
1152
            claims: vec![],
1153
            scope: Scope::CHANGES | Scope::REFS,
1154
            not_before: now - 60_000,
1155
            expires: now + 600_000,
1156
            grant: None,
1157
        },
1158
        &hub,
1159
        &hub_sk,
1160
    );
1161
    c.store().put_certificate(&cert).unwrap();
1162
    c.tag_create("v1", &heads, "one").unwrap();
1163
    let r = c.push("origin", false).unwrap();
1164
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1165
1166
    // The refs answer: without the header, v1 is left out; with it, there.
1167
    let tags = |bytes: Vec<u8>| -> Vec<String> {
1168
        let (_, refs) = wire::decode_refs(&bytes).unwrap();
1169
        refs.tags.into_iter().map(|t| t.name).collect()
1170
    };
1171
    let bare =
1172
        oxy_core::protocol::http_get(&format!("{new_url}/oxy/v1/refs"), &RemoteOptions::default())
1173
            .unwrap();
1174
    assert_eq!(tags(bare), ["v0"], "no Oxy-Features header");
1175
    let remote = oxy_core::protocol::open_remote(&new_url, &RemoteOptions::default()).unwrap();
1176
    let mut current: Vec<String> = remote
1177
        .refs()
1178
        .unwrap()
1179
        .tags
1180
        .into_iter()
1181
        .map(|t| t.name)
1182
        .collect();
1183
    current.sort();
1184
    assert_eq!(
1185
        current,
1186
        ["v0", "v1"],
1187
        "a current client declares certified-tags"
1188
    );
1189
1190
    // An old-era client clones without error and sees no v1.
1191
    let old = Repo::clone(&old_url, &root.join("old")).unwrap();
1192
    assert_eq!(old.tag_heads("v0").unwrap(), Some(heads.clone()));
1193
    assert_eq!(old.tag_heads("v1").unwrap(), None);
1194
    let rep = old.fetch("origin").unwrap();
1195
    assert!(rep.skipped.is_empty(), "{rep:?}");
1196
1197
    // A current client adopts v1 by its certificate at the hub's acceptance.
1198
    let b = Repo::clone(&new_url, &root.join("b")).unwrap();
1199
    assert_eq!(b.tag_heads("v1").unwrap(), Some(heads.clone()));
1200
1201
    // A client holding v1 that pushes where v1 is hidden from it sends v1
1202
    // again: the host already has it, so it is taken as it is, and the
1203
    // bookmark moves.
1204
    let r = a.fetch("origin").unwrap();
1205
    assert!(r.skipped.is_empty(), "{r:?}");
1206
    assert_eq!(a.tag_heads("v1").unwrap(), Some(heads.clone()));
1207
    a.config.set("remote.origin.url", &old_url).unwrap();
1208
    write(&root.join("a"), "b.txt", b"b\n");
1209
    a.commit("b", &[]).unwrap();
1210
    let r = a.push("origin", false).unwrap();
1211
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1212
    assert!(r.iter().any(|x| x.name == "tags/v1"), "{r:?}");
1213
1214
    // `oxy serve` applies the same rule.
1215
    let base = start(&served, vec![]);
1216
    let bare = oxy_core::protocol::http_get(
1217
        &format!("{base}/origin/oxy/v1/refs"),
1218
        &RemoteOptions::default(),
1219
    )
1220
    .unwrap();
1221
    assert_eq!(tags(bare), ["v0"], "oxy serve, no header");
1222
    let remote =
1223
        oxy_core::protocol::open_remote(&format!("{base}/origin"), &RemoteOptions::default())
1224
            .unwrap();
1225
    assert_eq!(
1226
        remote.refs().unwrap().tags.len(),
1227
        2,
1228
        "oxy serve, current client"
1229
    );
1230
}