OXYGENHUB
# e02bf0cd0533 oxygen / crates / oxy-server / tests / http.rs
16.3 KiB · Rust · last changed Bare repositories and an embeddable protocol server for Oxygenhub 1 month 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 { root: root.to_path_buf(), tokens, tls };
35
    std::thread::spawn(move || oxy_server::run_blocking(listener, cfg).unwrap());
36
    // Give the acceptor a moment; the first request otherwise races the bind.
37
    std::thread::sleep(std::time::Duration::from_millis(50));
38
    format!("{scheme}://127.0.0.1:{port}")
39
}
40
41
/// A self-signed certificate for 127.0.0.1 written to `dir`: (cert, key).
42
fn self_signed(dir: &Path) -> (std::path::PathBuf, std::path::PathBuf) {
43
    fs::create_dir_all(dir).unwrap();
44
    let ck = rcgen::generate_simple_self_signed(vec!["127.0.0.1".into(), "localhost".into()]).unwrap();
45
    let cert = dir.join("cert.pem");
46
    let key = dir.join("key.pem");
47
    fs::write(&cert, ck.cert.pem()).unwrap();
48
    fs::write(&key, ck.signing_key.serialize_pem()).unwrap();
49
    (cert, key)
50
}
51
52
#[test]
53
fn clone_push_pull_over_http() {
54
    hermetic();
55
    let dir = tempfile::tempdir().unwrap();
56
    let root = dir.path();
57
    let served = root.join("served");
58
    Repo::init(&served.join("origin")).unwrap();
59
    let base = start(&served, vec![]);
60
    let url = format!("{base}/origin");
61
62
    let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home"));
63
    let (key, _) = a.create_identity("Ada", "[email protected]", oxy_core::repo::IdentityScope::Repo).unwrap();
64
    write(&root.join("a"), "f.txt", b"hello\n");
65
    let h1 = a.commit("hello", &[]).unwrap();
66
    let r = a.push("origin", false).unwrap();
67
    assert!(r[0].ok, "{}", r[0].reason);
68
69
    let origin = Repo::open(&served.join("origin")).unwrap();
70
    assert_eq!(origin.bookmark_heads().unwrap(), vec![h1]);
71
    assert!(matches!(origin.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { key: k, sealed: true, .. } if k == key));
72
73
    let b = Repo::clone(&url, &root.join("b")).unwrap();
74
    assert_eq!(fs::read(root.join("b/f.txt")).unwrap(), b"hello\n");
75
    write(&root.join("b"), "g.txt", b"g\n");
76
    let h2 = b.commit("g", &[]).unwrap();
77
    assert!(b.push("origin", false).unwrap()[0].ok);
78
79
    let pr = a.pull("origin").unwrap();
80
    assert!(pr.changed && pr.checked_out);
81
    assert_eq!(fs::read(root.join("a/g.txt")).unwrap(), b"g\n");
82
    let mut heads = a.bookmark_heads().unwrap();
83
    heads.sort();
84
    let mut want = vec![h1, h2];
85
    want.sort();
86
    assert_eq!(heads, want);
87
88
    // Unknown repositories and names the server refuses are surfaced as errors.
89
    let err = Repo::clone(&format!("{base}/nope"), &root.join("c")).unwrap_err().to_string();
90
    assert!(err.contains("404"), "{err}");
91
    fs::create_dir_all(served.join(".hidden/.oxy")).unwrap();
92
    assert!(Repo::clone(&format!("{base}/.hidden"), &root.join("d")).unwrap_err().to_string().contains("404"));
93
}
94
95
#[test]
96
fn objects_endpoint_serves_lazy_blobs() {
97
    hermetic();
98
    let dir = tempfile::tempdir().unwrap();
99
    let root = dir.path();
100
    let served = root.join("served");
101
    let origin = Repo::init(&served.join("origin")).unwrap();
102
    let big: Vec<u8> = (0..300_000u32).map(|i| (i % 253) as u8).collect(); // has NUL bytes: opaque
103
    write(&served.join("origin"), "big.bin", &big);
104
    write(&served.join("origin"), "t.txt", b"t\n");
105
    origin.commit("big", &[]).unwrap();
106
    let base = start(&served, vec![]);
107
    let url = format!("{base}/origin");
108
    let a = Repo::clone_with(&url, &root.join("a"), Some(1000), &[]).unwrap();
109
    assert_eq!(fs::read(root.join("a/big.bin")).unwrap(), big, "fetched through the objects endpoint during checkout");
110
    assert!(!a.snapshot("status").unwrap().changed);
111
}
112
113
#[test]
114
fn push_requires_a_token_when_configured() {
115
    hermetic();
116
    let dir = tempfile::tempdir().unwrap();
117
    let root = dir.path();
118
    let served = root.join("served");
119
    Repo::init(&served.join("origin")).unwrap();
120
    let base = start(&served, vec!["s3cret".into()]);
121
    let url = format!("{base}/origin");
122
123
    // Reads are open.
124
    let mut a = Repo::clone(&url, &root.join("a")).unwrap();
125
    write(&root.join("a"), "f.txt", b"x\n");
126
    a.commit("x", &[]).unwrap();
127
    let err = a.push("origin", false).unwrap_err().to_string();
128
    assert!(err.contains("401"), "{err}");
129
130
    a.config.set("remote.origin.token", "wrong").unwrap();
131
    a.save_config().unwrap();
132
    assert!(a.push("origin", false).unwrap_err().to_string().contains("401"));
133
134
    a.config.set("remote.origin.token", "s3cret").unwrap();
135
    a.save_config().unwrap();
136
    assert!(a.push("origin", false).unwrap()[0].ok);
137
    let origin = Repo::open(&served.join("origin")).unwrap();
138
    assert_eq!(origin.bookmark_heads().unwrap().len(), 1);
139
}
140
141
#[test]
142
fn https_with_a_self_signed_certificate() {
143
    hermetic();
144
    let dir = tempfile::tempdir().unwrap();
145
    let root = dir.path();
146
    let served = root.join("served");
147
    Repo::init(&served.join("origin")).unwrap();
148
    let (cert, key) = self_signed(root);
149
    let base = start_with(&served, vec![], Some(oxy_server::TlsFiles { cert: cert.clone(), key }));
150
    let url = format!("{base}/origin");
151
152
    // Without trusting the certificate the clone fails; with `OXY_CA` it works.
153
    let err = Repo::clone(&url, &root.join("untrusted")).err().unwrap().to_string();
154
    assert!(err.contains("certificate") || err.contains("tls") || err.contains("TLS") || err.contains("handshake"), "{err}");
155
    std::env::set_var("OXY_CA", &cert);
156
    let mut a = Repo::clone(&url, &root.join("a")).unwrap();
157
    std::env::remove_var("OXY_CA");
158
    // Persist the trust for this remote so later commands need no env var.
159
    a.config.set("remote.origin.ca", cert.to_str().unwrap()).unwrap();
160
    a.save_config().unwrap();
161
    write(&root.join("a"), "f.txt", b"tls\n");
162
    let h = a.commit("over tls", &[]).unwrap();
163
    assert!(a.push("origin", false).unwrap()[0].ok);
164
    let origin = Repo::open(&served.join("origin")).unwrap();
165
    assert_eq!(origin.bookmark_heads().unwrap(), vec![h]);
166
    // A wrong CA is refused.
167
    let (other_cert, _) = self_signed(&root.join("other"));
168
    a.config.set("remote.origin.ca", other_cert.to_str().unwrap()).unwrap();
169
    a.save_config().unwrap();
170
    assert!(a.fetch("origin").is_err());
171
}
172
173
/// Eight clones push independent changes at the same moment. The server
174
/// serialises pushes per repository, so every push either lands or is
175
/// refused because the bookmark moved, and a pull-then-push retry lands it.
176
#[test]
177
fn concurrent_pushes_are_serialised() {
178
    hermetic();
179
    let dir = tempfile::tempdir().unwrap();
180
    let root = dir.path();
181
    let served = root.join("served");
182
    Repo::init(&served.join("origin")).unwrap();
183
    let base = start(&served, vec![]);
184
    let url = format!("{base}/origin");
185
    const N: usize = 8;
186
    let mut clones = Vec::new();
187
    for i in 0..N {
188
        let path = root.join(format!("c{i}"));
189
        Repo::clone(&url, &path).unwrap();
190
        clones.push(path);
191
    }
192
    let barrier = std::sync::Arc::new(std::sync::Barrier::new(N));
193
    let mut handles = Vec::new();
194
    for (i, path) in clones.into_iter().enumerate() {
195
        let barrier = barrier.clone();
196
        handles.push(std::thread::spawn(move || {
197
            let repo = Repo::open(&path).unwrap();
198
            write(&path, &format!("file-{i}.txt"), format!("from clone {i}\n").as_bytes());
199
            let h = repo.commit(&format!("change {i}"), &[]).unwrap();
200
            barrier.wait();
201
            // A push is refused either up front (the remote moved since the
202
            // clone's last fetch) or by the server's compare-and-swap; both
203
            // are answered by pulling and pushing again.
204
            for attempt in 0..(N * 4) {
205
                match repo.push("origin", false) {
206
                    Ok(r) if r.iter().all(|x| x.ok) => return (h, attempt),
207
                    Ok(_) => {}
208
                    Err(e) if e.to_string().contains("pull first") || e.to_string().contains("moved") => {}
209
                    Err(e) => panic!("clone {i}: {e}"),
210
                }
211
                repo.pull("origin").unwrap_or_else(|e| panic!("clone {i} pull: {e}"));
212
            }
213
            panic!("clone {i} never managed to push");
214
        }));
215
    }
216
    let mut hashes = Vec::new();
217
    let mut total_rejected = 0;
218
    for h in handles {
219
        let (hash, attempts) = h.join().unwrap();
220
        hashes.push(hash);
221
        total_rejected += attempts;
222
    }
223
    let origin = Repo::open(&served.join("origin")).unwrap();
224
    let mut heads = origin.bookmark_heads().unwrap();
225
    heads.sort();
226
    hashes.sort();
227
    assert_eq!(heads, hashes, "every independent change is a head");
228
    let snap = origin.load_state(&heads).unwrap().materialize();
229
    for i in 0..N {
230
        assert!(snap.files.contains_key(&format!("file-{i}.txt")));
231
    }
232
    assert!(!snap.is_conflicted());
233
    eprintln!("{total_rejected} pushes were refused for a moved bookmark and retried");
234
    // The operation log is a single chain: each accepted push builds on the last.
235
    let ops = origin.op_log().unwrap();
236
    assert!(ops.len() > N);
237
}
238
239
240
#[test]
241
fn a_bare_repository_is_served_and_browsed_without_a_working_copy() {
242
    hermetic();
243
    let dir = tempfile::tempdir().unwrap();
244
    let root = dir.path();
245
    let served = root.join("served");
246
    let hosted = Repo::init_bare(&served.join("lib")).unwrap();
247
    assert!(hosted.is_bare());
248
    assert!(hosted.working_copy().is_err(), "a bare repository has no working copy");
249
    assert!(!served.join("lib").join(".oxy").join("workspace").exists());
250
    let base = start(&served, vec![]);
251
    let url = format!("{base}/lib");
252
253
    // A client clones the empty repository, commits twice, and pushes.
254
    let work = root.join("work");
255
    let client = Repo::clone(&url, &work).unwrap();
256
    write(&work, "src/lib.rs", b"pub fn one() {}\n");
257
    client.snapshot("test").unwrap();
258
    let c1 = client.commit("one", &[]).unwrap();
259
    write(&work, "src/lib.rs", b"pub fn one() {}\npub fn two() {}\n");
260
    client.snapshot("test").unwrap();
261
    let c2 = client.commit("two", &[]).unwrap();
262
    let results = client.push("origin", false).unwrap();
263
    assert!(results.iter().all(|r| r.ok), "{results:?}");
264
265
    // The host reads history, files and blame from the bookmark's state alone.
266
    let hosted = Repo::open(&served.join("lib")).unwrap();
267
    assert!(hosted.is_bare());
268
    let heads = hosted.bookmark_heads().unwrap();
269
    assert_eq!(heads, vec![c2]);
270
    let log = hosted.log().unwrap();
271
    assert_eq!(log.iter().map(|e| e.hash).collect::<Vec<_>>(), vec![c2, c1]);
272
    assert!(log.iter().all(|e| !e.is_working_copy));
273
    assert_eq!(hosted.log_state(&[c1]).unwrap().len(), 1);
274
    assert_eq!(hosted.file_at("src/lib.rs", Some(&heads)).unwrap(), b"pub fn one() {}\npub fn two() {}\n");
275
    let blame = hosted.blame_in("src/lib.rs", &heads).unwrap();
276
    assert_eq!(blame.iter().map(|l| l.hash).collect::<Vec<_>>(), vec![c1, c2]);
277
    assert_eq!(hosted.log_for_path("src/lib.rs").unwrap().len(), 2);
278
    assert_eq!(hosted.diff_files(Some(&c2)).unwrap().len(), 1);
279
    assert!(matches!(hosted.verify_change(&c2).unwrap(), SignatureStatus::Unsigned | SignatureStatus::Valid { .. }));
280
281
    // Snapshotting a bare repository is refused rather than inventing a working copy.
282
    assert!(hosted.snapshot("test").is_err());
283
}
284
285
/// A host embeds the protocol with its own backend: names with an owner
286
/// segment, reads gated per repository, and a push hook.
287
#[test]
288
fn a_custom_backend_embeds_the_protocol() {
289
    use axum::extract::{Path as AxPath, State};
290
    use axum::http::{HeaderMap, Method, StatusCode};
291
    use axum::response::{IntoResponse, Response};
292
    use oxy_server::{Access, Backend, Endpoint, Failure, Server};
293
    use std::sync::{Arc, Mutex};
294
295
    struct Hub {
296
        root: std::path::PathBuf,
297
        pushes: Arc<Mutex<Vec<String>>>,
298
    }
299
    impl Backend for Hub {
300
        fn open(&self, name: &str) -> Result<Repo, Failure> {
301
            let Some((owner, repo)) = name.split_once('/') else { return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into())) };
302
            let dir = self.root.join(owner).join(repo);
303
            if !dir.join(".oxy").is_dir() {
304
                return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
305
            }
306
            Repo::open(&dir).map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
307
        }
308
        fn authorize(&self, name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure> {
309
            // `secret/*` is private: unknown to anyone without the token, even for reads.
310
            let token = oxy_server::bearer_token(headers);
311
            if name.starts_with("secret/") && token != Some("s3cret") {
312
                return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
313
            }
314
            if access == Access::Push && token.is_none() {
315
                return Err(Failure(StatusCode::UNAUTHORIZED, "sign in to push".into()));
316
            }
317
            Ok(())
318
        }
319
        fn after_push(&self, name: &str, results: &[oxy_core::protocol::RefResult]) {
320
            self.pushes.lock().unwrap().push(format!("{name}: {}", results.iter().filter(|r| r.ok).count()));
321
        }
322
    }
323
    async fn route(State(server): State<Arc<Server>>, AxPath((owner, repo, endpoint)): AxPath<(String, String, String)>, method: Method, headers: HeaderMap, body: axum::body::Bytes) -> Response {
324
        match Endpoint::parse(&endpoint) {
325
            Some(e) => server.handle(format!("{owner}/{repo}"), e, &method, &headers, body).await,
326
            None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(),
327
        }
328
    }
329
330
    hermetic();
331
    let dir = tempfile::tempdir().unwrap();
332
    let root = dir.path();
333
    Repo::init_bare(&root.join("repos/andy/app")).unwrap();
334
    Repo::init_bare(&root.join("repos/secret/plans")).unwrap();
335
    let pushes = Arc::new(Mutex::new(Vec::new()));
336
    let server = Server::new(Hub { root: root.join("repos"), pushes: pushes.clone() });
337
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
338
    let port = listener.local_addr().unwrap().port();
339
    std::thread::spawn(move || {
340
        let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap();
341
        rt.block_on(async move {
342
            listener.set_nonblocking(true).unwrap();
343
            let app = axum::Router::new().route("/{owner}/{repo}/oxy/v1/{endpoint}", axum::routing::any(route)).with_state(server);
344
            axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app).await.unwrap();
345
        });
346
    });
347
    std::thread::sleep(std::time::Duration::from_millis(50));
348
    let base = format!("http://127.0.0.1:{port}");
349
350
    // Anonymous clone of the public repository works; the private one does not exist for outsiders.
351
    let work = root.join("work");
352
    let client = Repo::clone(&format!("{base}/andy/app"), &work).unwrap();
353
    assert!(Repo::clone(&format!("{base}/secret/plans"), &root.join("nope")).is_err());
354
355
    // A push needs a token; with one it lands and the hook sees it.
356
    write(&work, "a.txt", b"a\n");
357
    client.snapshot("test").unwrap();
358
    client.commit("a", &[]).unwrap();
359
    assert!(client.push("origin", false).is_err(), "anonymous push must be refused");
360
    let mut client = client;
361
    client.config.set("remote.origin.token", "s3cret").unwrap();
362
    let results = client.push("origin", false).unwrap();
363
    assert!(results.iter().all(|r| r.ok), "{results:?}");
364
    assert_eq!(pushes.lock().unwrap().as_slice(), ["andy/app: 1"]);
365
366
    let hosted = Repo::open(&root.join("repos/andy/app")).unwrap();
367
    assert_eq!(hosted.bookmark_heads().unwrap().len(), 1);
368
}