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