OXYGENHUB
# 417184c73f4e oxygenhub / crates / hub / tests / backups.rs
16.8 KiB · Rust · last changed feat: keyless GCS backups, and TEST (closes #169) 22 days ago source blame history raw
d7d18246 Andy McCall ↶ 1
//! Backups (docs/backups.md, oxygenhub#169), end to end: a real `oxy` push,
2
//! a backup to a directory, a second run that sends nothing new, and a
3
//! restore into a fresh hub that gives back the same repository, byte for
4
//! byte; and an owner's backup never holding another's repositories.
5
6
use oxy_core::repo::Repo;
7
use oxygenhub::backups::{self, Destination, Directory, Setting};
8
use oxygenhub::config::Config;
9
use oxygenhub::repos::RepoName;
10
use oxygenhub::{db, Shared};
11
use std::path::Path;
12
use std::sync::Arc;
13
14
fn hermetic() {
15
    static ONCE: std::sync::Once = std::sync::Once::new();
16
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
17
    ONCE.call_once(|| {
18
        let d = tempfile::tempdir().unwrap();
19
        std::env::set_var("OXY_HOME", d.path());
20
        DIR.set(d).ok();
21
    });
22
}
23
24
struct Hub {
25
    app: Shared,
26
    base: String,
27
    _dir: tempfile::TempDir,
28
}
29
30
async fn hub(backups: &Path) -> Hub {
35fea423 Andy McCall ↶ 31
    hub_with(backups, None).await
32
}
33
34
async fn hub_with(backups: &Path, google: Option<&str>) -> Hub {
d7d18246 Andy McCall ↶ 35
    hermetic();
36
    let dir = tempfile::tempdir().unwrap();
37
    let mut config = Config::load(dir.path()).unwrap();
38
    config.auth.push_tokens = vec!["site".into()];
39
    config.backups.directories = vec![backups.to_path_buf()];
35fea423 Andy McCall ↶ 40
    if let Some(g) = google {
41
        config.backups.sts_url = g.to_string();
42
        config.backups.storage_url = g.to_string();
43
    }
d7d18246 Andy McCall ↶ 44
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
45
    let port = listener.local_addr().unwrap().port();
46
    config.server.base_url = format!("http://127.0.0.1:{port}");
47
    let app = oxygenhub::build(config).await.unwrap();
48
    let router = oxygenhub::router(app.clone());
49
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
50
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
51
}
52
53
fn write(root: &Path, rel: &str, content: &str) {
54
    let p = root.join(rel);
55
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
56
    std::fs::write(p, content).unwrap();
57
}
58
59
async fn create(app: &Shared, owner: &str, name: &str, visibility: hub_api::Visibility) {
60
    app.repos.create(&RepoName::parse(&format!("{owner}/{name}")).unwrap(), None).unwrap();
61
    db::repos::create(&app.db, owner, name, "a demo", visibility).await.unwrap();
62
}
63
64
fn push(url: &str, work: &Path, files: &[(&str, &str)]) {
65
    let mut c = Repo::init(work).unwrap();
66
    c.config.set("remote.origin.url", url).unwrap();
67
    c.config.set("remote.origin.token", "site").unwrap();
68
    c.save_config().unwrap();
69
    for (f, t) in files {
70
        write(work, f, t);
71
    }
72
    c.snapshot("t").unwrap();
73
    c.commit("Some work", &[]).unwrap();
74
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
75
}
76
77
/// Every file under `dir`, with its bytes.
78
fn files(dir: &Path) -> Vec<(String, Vec<u8>)> {
79
    let mut out = Vec::new();
80
    let mut stack = vec![dir.to_path_buf()];
81
    while let Some(d) = stack.pop() {
82
        for e in std::fs::read_dir(&d).unwrap().flatten() {
83
            let p = e.path();
84
            if p.is_dir() {
85
                stack.push(p);
86
            } else {
87
                out.push((p.strip_prefix(dir).unwrap().to_string_lossy().into_owned(), std::fs::read(&p).unwrap()));
88
            }
89
        }
90
    }
91
    out.sort();
92
    out
93
}
94
95
#[tokio::test(flavor = "multi_thread")]
96
async fn a_backup_restores_the_same_repository_and_sends_only_what_changed() {
97
    let store = tempfile::tempdir().unwrap();
98
    let h = hub(store.path()).await;
99
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
100
    db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap();
101
    create(&h.app, "andy", "demo", hub_api::Visibility::Private).await;
102
    create(&h.app, "bob", "other", hub_api::Visibility::Public).await;
103
    let work = tempfile::tempdir().unwrap();
104
    push(&format!("{}/andy/demo", h.base), work.path(), &[("README.md", "hello\n"), ("src/main.rs", "fn main() {}\n")]);
105
    let other = tempfile::tempdir().unwrap();
106
    push(&format!("{}/bob/other", h.base), other.path(), &[("x.txt", "bob's\n")]);
107
108
    // Andy backs up to the directory the hub allows.
109
    let target = store.path().display().to_string();
110
    let mut s = Setting::off("andy", "user");
111
    s.every = "hourly".into();
112
    s.target = target.clone();
113
    backups::save(&h.app.db, &s).await.unwrap();
114
    let first = backups::run(&h.app, "andy", "now").await.unwrap();
115
    assert_eq!((first.status.as_str(), first.repos), ("ok", 1), "{}", first.detail);
116
    assert!(first.sent > 0 && first.bytes > 0);
117
    let dest = Directory { root: store.path().join("andy") };
118
    assert!(dest.exists(&format!("runs/{}/DONE", first.at)).unwrap());
119
    let records: serde_json::Value = serde_json::from_slice(&dest.get(&format!("runs/{}/records/demo.json", first.at)).unwrap()).unwrap();
120
    assert_eq!(records["repositories"][0]["visibility"], "private");
121
    let text = String::from_utf8_lossy(&dest.get(&format!("runs/{}/records/demo.json", first.at)).unwrap()).into_owned();
122
    assert!(!text.contains("bob/other") && !text.contains("\"other\""), "nothing of another owner");
123
    assert!(!store.path().join("bob").exists(), "bob set up no backup");
124
125
    // Nothing changed: the next run sends nothing new.
126
    tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
127
    let second = backups::run(&h.app, "andy", "now").await.unwrap();
128
    assert_eq!((second.status.as_str(), second.sent), ("ok", 0), "{}", second.detail);
129
130
    // A fresh hub, restored from the backup, has the same repository.
131
    let empty = tempfile::tempdir().unwrap();
132
    let fresh = hub(empty.path()).await;
133
    let dest: Arc<dyn Destination> = Arc::new(Directory { root: store.path().join("andy") });
134
    let done = backups::restore_into_hub(&fresh.app, dest, None).await.unwrap();
135
    assert_eq!(done, vec!["andy/demo (repos)"]);
136
    let name = RepoName::parse("andy/demo").unwrap();
137
    assert_eq!(files(&fresh.app.repos.dir(&name)), files(&h.app.repos.dir(&name)), "byte for byte");
138
    let row = db::repos::get(&fresh.app.db, "andy", "demo").await.unwrap().expect("listed on the hub");
139
    assert_eq!((row.visibility.as_str(), row.description.as_str()), ("private", "a demo"));
140
    let (a, b) = (h.app.repos.open(&name).unwrap(), fresh.app.repos.open(&name).unwrap());
141
    assert_eq!(
142
        a.bookmarks().unwrap().iter().map(|b| b.heads.clone()).collect::<Vec<_>>(),
143
        b.bookmarks().unwrap().iter().map(|b| b.heads.clone()).collect::<Vec<_>>()
144
    );
145
146
    // A directory the hub does not allow is refused.
147
    let mut bad = Setting::off("andy", "user");
148
    bad.every = "hourly".into();
149
    bad.target = "/tmp/somewhere-else".into();
150
    backups::save(&h.app.db, &bad).await.unwrap();
151
    let refused = backups::run(&h.app, "andy", "now").await.unwrap();
152
    assert_eq!(refused.status, "failed");
153
    assert!(refused.detail.contains("not a backup directory this hub allows"), "{}", refused.detail);
154
}
155
156
#[tokio::test(flavor = "multi_thread")]
157
async fn backups_fall_due_on_their_schedule() {
158
    let store = tempfile::tempdir().unwrap();
159
    let h = hub(store.path()).await;
160
    let mut s = Setting::off("andy", "user");
161
    s.target = store.path().display().to_string();
162
    backups::save(&h.app.db, &s).await.unwrap();
163
    assert!(backups::due(&h.app.db).await.unwrap().is_empty(), "off is never due");
164
    s.every = "hourly".into();
165
    backups::save(&h.app.db, &s).await.unwrap();
166
    assert_eq!(backups::due(&h.app.db).await.unwrap().len(), 1, "never run: due");
167
    sqlx::query("INSERT INTO backup_runs (owner, started_ms, status) VALUES ('andy', ?, 'ok')")
168
        .bind(db::now_ms())
169
        .execute(&h.app.db)
170
        .await
171
        .unwrap();
172
    assert!(backups::due(&h.app.db).await.unwrap().is_empty(), "ran just now");
35fea423 Andy McCall ↶ 173
}
174
175
// ------------------------------------------------------------ a fake Google
176
177
/// What the fake has seen and holds.
178
#[derive(Default)]
179
struct Google {
180
    /// Objects by bucket and name.
181
    objects: std::collections::BTreeMap<(String, String), Vec<u8>>,
182
    /// The subjects and audiences of the tokens exchanged.
183
    exchanged: Vec<(String, String)>,
184
}
185
186
type Shared2 = Arc<std::sync::Mutex<Google>>;
187
188
fn decode(s: &str) -> String {
189
    let b = s.as_bytes();
190
    let mut out = Vec::new();
191
    let mut i = 0;
192
    while i < b.len() {
193
        if b[i] == b'%' && i + 2 < b.len() {
194
            out.push(u8::from_str_radix(&s[i + 1..i + 3], 16).unwrap());
195
            i += 3;
196
        } else {
197
            out.push(b[i]);
198
            i += 1;
199
        }
200
    }
201
    String::from_utf8(out).unwrap()
202
}
203
204
fn query(uri: &axum::http::Uri, key: &str) -> Option<String> {
205
    uri.query()?.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| decode(v)))
206
}
207
208
/// Google's STS and Cloud Storage, as much as backups use: a token
209
/// exchange that admits `backup:` subjects for the provider's audience,
210
/// and buckets that admit only their owner's token (bucket `OWNER-backup`).
211
async fn fake_google() -> (String, Shared2) {
212
    use axum::extract::{Request, State};
213
    use axum::http::StatusCode;
214
    use axum::response::IntoResponse;
215
    let state: Shared2 = Default::default();
216
    async fn handle(State(g): State<Shared2>, req: Request) -> axum::response::Response {
217
        let (parts, body) = req.into_parts();
218
        let bytes = http_body_util::BodyExt::collect(body).await.unwrap().to_bytes();
219
        let path = parts.uri.path().to_string();
220
        if path == "/v1/token" {
221
            let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
222
            let jwt = v["subjectToken"].as_str().unwrap();
223
            let payload = jwt.split('.').nth(1).unwrap();
224
            let claims: serde_json::Value = serde_json::from_slice(&base64url(payload)).unwrap();
225
            let (sub, aud) = (claims["sub"].as_str().unwrap().to_string(), claims["aud"].as_str().unwrap().to_string());
226
            let provider = v["audience"].as_str().unwrap();
227
            g.lock().unwrap().exchanged.push((sub.clone(), aud.clone()));
228
            if !sub.starts_with("backup:") || aud != format!("https:{provider}") {
229
                return (
230
                    StatusCode::BAD_REQUEST,
231
                    axum::Json(serde_json::json!({"error": "invalid_grant", "error_description": "not admitted"})),
232
                )
233
                    .into_response();
234
            }
235
            return axum::Json(serde_json::json!({ "access_token": format!("tok-{sub}"), "expires_in": 3600 })).into_response();
236
        }
237
        let auth = parts.headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
238
        let segs: Vec<&str> = path.split('/').collect();
239
        // /storage/v1/b/{bucket}/o[/{name}]  or  /upload/storage/v1/b/{bucket}/o
240
        let upload = segs.get(1) == Some(&"upload");
241
        let at = if upload { 5 } else { 4 };
242
        let bucket = segs.get(at).unwrap_or(&"").to_string();
243
        let owner = bucket.trim_end_matches("-backup");
244
        if auth != format!("Bearer tok-backup:{owner}") {
245
            return (StatusCode::FORBIDDEN, "caller does not have storage.objects access").into_response();
246
        }
247
        let mut g = g.lock().unwrap();
248
        if upload {
249
            let name = query(&parts.uri, "name").unwrap();
250
            g.objects.insert((bucket, name), bytes.to_vec());
251
            return axum::Json(serde_json::json!({})).into_response();
252
        }
253
        let name = segs.get(at + 2).map(|n| decode(n));
254
        match (parts.method.as_str(), name) {
255
            ("GET", Some(n)) => match g.objects.get(&(bucket, n.clone())) {
256
                Some(b) if query(&parts.uri, "alt").as_deref() == Some("media") => b.clone().into_response(),
257
                Some(_) => axum::Json(serde_json::json!({ "name": n })).into_response(),
258
                None => StatusCode::NOT_FOUND.into_response(),
259
            },
260
            ("DELETE", Some(n)) => {
261
                g.objects.remove(&(bucket, n));
262
                StatusCode::NO_CONTENT.into_response()
263
            }
264
            ("GET", None) => {
265
                let prefix = query(&parts.uri, "prefix").unwrap_or_default();
266
                let delimited = query(&parts.uri, "delimiter").is_some();
267
                let (mut items, mut prefixes) = (Vec::new(), std::collections::BTreeSet::new());
268
                for (b, n) in g.objects.keys() {
269
                    if *b != bucket || !n.starts_with(&prefix) {
270
                        continue;
271
                    }
272
                    let rest = &n[prefix.len()..];
273
                    match rest.find('/') {
274
                        Some(i) if delimited => {
275
                            prefixes.insert(format!("{prefix}{}", &rest[..=i]));
276
                        }
277
                        _ => items.push(serde_json::json!({ "name": n })),
278
                    }
279
                }
280
                axum::Json(serde_json::json!({ "items": items, "prefixes": prefixes })).into_response()
281
            }
282
            _ => StatusCode::METHOD_NOT_ALLOWED.into_response(),
283
        }
284
    }
285
    let app = axum::Router::new().fallback(handle).with_state(state.clone());
286
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
287
    let port = listener.local_addr().unwrap().port();
288
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
289
    (format!("http://127.0.0.1:{port}"), state)
290
}
291
292
fn base64url(s: &str) -> Vec<u8> {
293
    let table = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
294
    let mut bits = 0u32;
295
    let mut n = 0;
296
    let mut out = Vec::new();
297
    for c in s.bytes() {
298
        let v = table.iter().position(|t| *t == c).unwrap() as u32;
299
        bits = (bits << 6) | v;
300
        n += 6;
301
        if n >= 8 {
302
            n -= 8;
303
            out.push((bits >> n) as u8);
304
            bits &= (1 << n) - 1;
305
        }
306
    }
307
    out
308
}
309
310
const PROVIDER: &str = "//iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/oxygenhub/providers/oxygenhub-io";
311
312
fn gcs(owner: &str, bucket: &str) -> Setting {
313
    let mut s = Setting::off(owner, "user");
314
    s.every = "hourly".into();
315
    s.destination = "gcs".into();
316
    s.target = bucket.into();
317
    s.provider = PROVIDER.into();
318
    s
319
}
320
321
#[tokio::test(flavor = "multi_thread")]
322
async fn a_gcs_backup_needs_no_key_and_restores_the_same_repository() {
323
    let (google, seen) = fake_google().await;
324
    let store = tempfile::tempdir().unwrap();
325
    let h = hub_with(store.path(), Some(&google)).await;
326
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
327
    db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap();
328
    create(&h.app, "andy", "demo", hub_api::Visibility::Public).await;
329
    let work = tempfile::tempdir().unwrap();
330
    push(&format!("{}/andy/demo", h.base), work.path(), &[("README.md", "hello\n")]);
331
332
    backups::save(&h.app.db, &gcs("andy", "andy-backup")).await.unwrap();
333
    let first = backups::run(&h.app, "andy", "now").await.unwrap();
334
    assert_eq!((first.status.as_str(), first.repos), ("ok", 1), "{}", first.detail);
335
    {
336
        let g = seen.lock().unwrap();
337
        assert!(g.objects.keys().any(|(b, n)| b == "andy-backup" && n == &format!("runs/{}/DONE", first.at)));
338
        assert!(g.objects.keys().all(|(b, _)| b == "andy-backup"));
339
        let (sub, aud) = g.exchanged.first().unwrap();
340
        assert_eq!(
341
            (sub.as_str(), aud.as_str()),
342
            ("backup:andy", format!("https:{PROVIDER}").as_str()),
343
            "the hub signs in as the owner, for the provider"
344
        );
345
    }
346
    tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
347
    let second = backups::run(&h.app, "andy", "now").await.unwrap();
348
    assert_eq!((second.status.as_str(), second.sent), ("ok", 0), "{}", second.detail);
349
    // The probe writes, reads and removes; the bucket is as it was.
350
    let before = seen.lock().unwrap().objects.len();
351
    let dest = backups::destination(&h.app, &gcs("andy", "andy-backup")).unwrap();
352
    tokio::task::spawn_blocking(move || backups::probe(dest.as_ref())).await.unwrap().unwrap();
353
    assert_eq!(seen.lock().unwrap().objects.len(), before);
354
355
    // Restored from the bucket into a fresh hub: the same repository.
356
    let empty = tempfile::tempdir().unwrap();
357
    let fresh = hub_with(empty.path(), Some(&google)).await;
358
    let dest: Arc<dyn Destination> = backups::destination(&fresh.app, &gcs("andy", "andy-backup")).unwrap().into();
359
    assert_eq!(backups::restore_into_hub(&fresh.app, dest, None).await.unwrap(), vec!["andy/demo (repos)"]);
360
    let name = RepoName::parse("andy/demo").unwrap();
361
    assert_eq!(files(&fresh.app.repos.dir(&name)), files(&h.app.repos.dir(&name)), "byte for byte");
362
363
    // Bob cannot write Andy's bucket: Google refuses his token, and the
364
    // run says what to grant.
365
    backups::save(&h.app.db, &gcs("bob", "andy-backup")).await.unwrap();
366
    let refused = backups::run(&h.app, "bob", "now").await.unwrap();
367
    assert_eq!(refused.status, "failed");
368
    assert!(refused.detail.contains("roles/storage.objectAdmin") && refused.detail.contains("backup:bob"), "{}", refused.detail);
d7d18246 Andy McCall ↶ 369
}