OXYGENHUB

Coverage / crates/hub/src/backups.rs 90.3%

1326 of 1468 lines run, 247 of 464 functions · run #541 on main · run missed

1
//! Backups (docs/backups.md, oxygenhub#169): each user and organisation
2
//! copies its repositories, on a schedule, to a destination it controls.
3
//!
4
//! A repository is its store directory (`.oxy/`): packs and loose objects
5
//! that never change once written, and small files that do (refs, the
6
//! operation log, policies, signature indexes). A run copies each file
7
//! once, named by its BLAKE3 (`blobs/`), and writes a manifest of path to
8
//! hash per repository (`runs/<at>/repos/`), so only what changed travels
9
//! and any kept run can be restored byte for byte, signatures and all.
10
//! The large files are copied first; the walk is then repeated under the
11
//! repository's read lock, which pushes wait on, so each manifest is one
12
//! consistent moment. Beside each manifest go the hub's records for the
13
//! repository as JSON (never a secret value), the owner's records, and
14
//! last a `DONE` marker: a run without one was cut short and is ignored.
15
16
use crate::db::{self, Pool};
17
use crate::repos::RepoName;
18
use crate::Shared;
19
use anyhow::{bail, Context, Result};
20
use serde::{Deserialize, Serialize};
21
use std::collections::HashMap;
22
use std::path::{Path, PathBuf};
23
24
/// How often, as the settings offer it.
25
pub const EVERY: &[(&str, &str)] = &[("off", "Off"), ("hourly", "Every hour"), ("daily", "Every day")];
26
/// How long runs are kept, in days.
27
pub const KEEP: &[i64] = &[7, 30, 90];
28
29
/// What an owner backs up, where and how often.
30
#[derive(Clone, Debug, sqlx::FromRow, Serialize)]
31
pub struct Setting {
32
    pub owner: String,
33
    pub kind: String,
34
    pub every: String,
35
    pub destination: String,
36
    pub target: String,
37
    pub prefix: String,
38
    pub provider: String,
39
    pub keep_days: i64,
40
    pub updated_ms: i64,
41
    /// An S3 bucket's region (migration 0063).
42
    #[sqlx(default)]
43
    pub region: String,
44
}
45
46
impl Setting {
4718×
    pub fn off(owner: &str, kind: &str) -> Setting {
4818×
        Setting {
4918×
            owner: owner.into(),
5018×
            kind: kind.into(),
5118×
            every: "off".into(),
5218×
            destination: "dir".into(),
5318×
            target: String::new(),
5418×
            prefix: String::new(),
5518×
            provider: String::new(),
5618×
            keep_days: 30,
5718×
            updated_ms: 0,
5818×
            region: String::new(),
5918×
        }
6018×
    }
61
62
    /// Seconds between runs; none when off.
636×
    pub fn period_s(&self) -> Option<i64> {
646×
        match self.every.as_str() {
656×
            "hourly" => Some(3600),
664×
            "daily" => Some(86_400),
672×
            _ => None,
68
        }
696×
    }
70
}
71
72
/// One run, as the settings page and the API show it.
73
#[derive(Clone, Debug, sqlx::FromRow, Serialize)]
74
pub struct Run {
75
    pub id: i64,
76
    pub owner: String,
77
    pub started_ms: i64,
78
    pub finished_ms: Option<i64>,
79
    pub status: String,
80
    pub at: String,
81
    pub repos: i64,
82
    pub files: i64,
83
    pub sent: i64,
84
    pub bytes: i64,
85
    pub detail: String,
86
    pub trigger: String,
87
}
88
89165×
pub async fn setting(pool: &Pool, owner: &str) -> sqlx::Result<Option<Setting>> {
90165×
    sqlx::query_as("SELECT * FROM backup_settings WHERE owner = $1").bind(owner).fetch_optional(pool).await
91165×
}
92
9317×
pub async fn save(pool: &Pool, s: &Setting) -> sqlx::Result<()> {
9417×
    sqlx::query(
9517×
        "INSERT INTO backup_settings (owner, kind, every, destination, target, prefix, provider, keep_days, updated_ms, region) \
9617×
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT(owner) DO UPDATE SET kind = excluded.kind, every = excluded.every, \
9717×
         destination = excluded.destination, target = excluded.target, prefix = excluded.prefix, provider = excluded.provider, \
9817×
         keep_days = excluded.keep_days, updated_ms = excluded.updated_ms, region = excluded.region",
9917×
    )
10017×
    .bind(&s.owner)
10117×
    .bind(&s.kind)
10217×
    .bind(&s.every)
10317×
    .bind(&s.destination)
10417×
    .bind(&s.target)
10517×
    .bind(&s.prefix)
10617×
    .bind(&s.provider)
10717×
    .bind(s.keep_days)
10817×
    .bind(db::now_ms())
10917×
    .bind(&s.region)
11017×
    .execute(pool)
11117×
    .await?;
11217×
    Ok(())
11317×
}
114
11517×
pub async fn runs(pool: &Pool, owner: &str, limit: i64) -> sqlx::Result<Vec<Run>> {
11617×
    sqlx::query_as("SELECT * FROM backup_runs WHERE owner = $1 ORDER BY id DESC LIMIT $2").bind(owner).bind(limit).fetch_all(pool).await
11717×
}
118
119
/// What a repository may choose about its backup (§5): with its owner's,
120
/// left out, or at least hourly or daily whatever its owner's schedule.
121
pub const REPO_CHOICES: &[(&str, &str)] =
122
    &[("", "With its owner's backup"), ("hourly", "At least every hour"), ("daily", "At least every day"), ("off", "Left out")];
123
124
/// Whether `row` is in its owner's run: never when it is left out, always
125
/// when it has its own schedule, else as its owner's schedule says (a run
126
/// asked for by hand takes every repository not left out).
12725×
pub fn included(s: &Setting, row: &db::repos::RepoRow, by_hand: bool) -> bool {
12825×
    match row.backup.as_str() {
12925×
        "off" => false,
13021×
        "hourly" | "daily" => true,
13118×
        _ => by_hand || s.every != "off",
132
    }
13325×
}
134
135
/// Seconds between an owner's runs: the shortest of its own schedule and
136
/// its repositories' own; none when nothing is scheduled.
1376×
pub fn period_with(s: &Setting, repos: &[db::repos::RepoRow]) -> Option<i64> {
1389×
    let own = repos.iter().filter_map(|r| match r.backup.as_str() {
1399×
        "hourly" => Some(3600),
1407×
        "daily" => Some(86_400),
1417×
        _ => None,
1429×
    });
1436×
    s.period_s().into_iter().chain(own).min()
1446×
}
145
146
/// One owner's backup as Admin › Backups shows it (§5): its setting, its
147
/// newest run, and how many runs failed in the last day. An administrator
148
/// sees that it ran, never what it wrote.
149
#[derive(Clone, Debug, Serialize)]
150
pub struct Overview {
151
    pub setting: Setting,
152
    pub last: Option<Run>,
153
    pub failed_day: i64,
154
    /// The repositories it covers now.
155
    pub repos: i64,
156
}
157
158
impl Overview {
159
    /// Where its runs go, as a person reads it.
1601×
    pub fn place(&self) -> String {
1611×
        place(&self.setting)
1621×
    }
163
}
164
165
/// Where a setting's runs go, as a person reads it.
1668×
pub fn place(s: &Setting) -> String {
1678×
    match s.destination.as_str() {
1688×
        "gcs" if s.prefix.is_empty() => format!("gs://{}", s.target),
1690×
        "gcs" => format!("gs://{}/{}", s.target, s.prefix),
1707×
        "s3" if s.prefix.is_empty() => format!("s3://{} ({})", s.target, s.region),
1710×
        "s3" => format!("s3://{}/{} ({})", s.target, s.prefix, s.region),
1727×
        _ => format!("{}/{}", s.target, s.owner),
173
    }
1748×
}
175
176
/// Every owner's backup (Admin › Backups), and the owners of repositories
177
/// that none covers, with how many each has.
1784×
pub async fn overview(pool: &Pool) -> sqlx::Result<(Vec<Overview>, Vec<(String, i64)>)> {
1794×
    let settings: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings ORDER BY owner").fetch_all(pool).await?;
1804×
    let counts: Vec<(String, i64)> =
1814×
        sqlx::query_as("SELECT owner, COUNT(*) FROM repositories GROUP BY owner ORDER BY owner").fetch_all(pool).await?;
1824×
    let day_ago = db::now_ms() - 86_400_000;
1834×
    let mut out = Vec::new();
1844×
    for s in settings {
1853×
        let last = runs(pool, &s.owner, 1).await?.into_iter().next();
1863×
        let failed_day: i64 =
1873×
            sqlx::query_scalar("SELECT COUNT(*) FROM backup_runs WHERE owner = $1 AND status = 'failed' AND started_ms >= $2")
1883×
                .bind(&s.owner)
1893×
                .bind(day_ago)
1903×
                .fetch_one(pool)
1913×
                .await?;
1926×
        let repos = db::repos::list_with_drops(pool).await?.iter().filter(|r| r.owner == s.owner && included(&s, r, false)).count() as i64;
1933×
        out.push(Overview { setting: s, last, failed_day, repos });
194
    }
1954×
    let uncovered = counts.into_iter().filter(|(owner, _)| !out.iter().any(|o| o.setting.owner == *owner && o.repos > 0)).collect();
1964×
    Ok((out, uncovered))
1974×
}
198
199
// ---------------------------------------------------------------- where
200
201
/// Where backups are written: keys are `/`-separated paths.
202
pub trait Destination: Send + Sync {
203
    fn exists(&self, key: &str) -> Result<bool>;
204
    /// Write `bytes` at `key`, whole or not at all.
205
    fn put(&self, key: &str, bytes: &[u8]) -> Result<()>;
206
    fn get(&self, key: &str) -> Result<Vec<u8>>;
207
    /// The names directly under `prefix` (files and "directories").
208
    fn list(&self, prefix: &str) -> Result<Vec<String>>;
209
    /// Remove `key` and everything under it.
210
    fn remove(&self, key: &str) -> Result<()>;
211
}
212
213
/// A directory on the hub's host.
214
pub struct Directory {
215
    pub root: PathBuf,
216
}
217
218
impl Directory {
219303×
    fn path(&self, key: &str) -> Result<PathBuf> {
220868×
        if key.split('/').any(|p| p == ".." || p == ".") || key.starts_with('/') {
2212×
            bail!("not a clean key: {key}");
222301×
        }
223301×
        Ok(self.root.join(key))
224303×
    }
225
}
226
227
impl Destination for Directory {
22898×
    fn exists(&self, key: &str) -> Result<bool> {
22998×
        Ok(self.path(key)?.exists())
23098×
    }
231107×
    fn put(&self, key: &str, bytes: &[u8]) -> Result<()> {
232107×
        let p = self.path(key)?;
233105×
        std::fs::create_dir_all(p.parent().context("no parent")?)?;
234105×
        let tmp = p.with_extension(format!("part-{}", std::process::id()));
235105×
        std::fs::write(&tmp, bytes)?;
236105×
        std::fs::rename(&tmp, &p)?;
237105×
        Ok(())
238107×
    }
23970×
    fn get(&self, key: &str) -> Result<Vec<u8>> {
24070×
        let p = self.path(key)?;
24170×
        std::fs::read(&p).with_context(|| format!("read {}", p.display()))
24270×
    }
24326×
    fn list(&self, prefix: &str) -> Result<Vec<String>> {
24426×
        let p = self.path(prefix)?;
24526×
        let mut out: Vec<String> = match std::fs::read_dir(&p) {
24637×
            Ok(d) => d.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(),
2471×
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
2480×
            Err(e) => return Err(e.into()),
249
        };
25026×
        out.sort();
25126×
        Ok(out)
25226×
    }
2532×
    fn remove(&self, key: &str) -> Result<()> {
2542×
        let p = self.path(key)?;
2552×
        if p.is_dir() {
2561×
            std::fs::remove_dir_all(p)?;
2571×
        } else if p.exists() {
2581×
            std::fs::remove_file(p)?;
2590×
        }
2602×
        Ok(())
2612×
    }
262
}
263
264
/// A Cloud Storage bucket, reached keylessly (docs/backups.md §3, §6):
265
/// the hub mints a short token as `backup:OWNER`, Google's STS exchanges
266
/// it for an access token through the owner's workload identity
267
/// provider, and that writes the bucket. No key exists anywhere; the
268
/// access token lasts an hour and is fetched again before it runs out.
269
pub struct Gcs {
270
    bucket: String,
271
    /// Keys go under this, when set.
272
    prefix: String,
273
    storage: String,
274
    sts: String,
275
    /// The provider's resource name, `//iam.googleapis.com/projects/…`.
276
    provider: String,
277
    subject: String,
278
    app: Shared,
279
    client: reqwest::Client,
280
    rt: tokio::runtime::Handle,
281
    token: std::sync::Mutex<Option<(String, i64)>>,
282
}
283
284
impl Gcs {
2855×
    pub fn new(app: &Shared, s: &Setting) -> Gcs {
2865×
        Gcs {
2875×
            bucket: s.target.clone(),
2885×
            prefix: s.prefix.trim_matches('/').to_string(),
2895×
            storage: app.config.backups.storage_url.trim_end_matches('/').to_string(),
2905×
            sts: app.config.backups.sts_url.trim_end_matches('/').to_string(),
2915×
            provider: s.provider.clone(),
2925×
            subject: format!("backup:{}", s.owner),
2935×
            app: app.clone(),
2945×
            client: reqwest::Client::builder().timeout(std::time::Duration::from_secs(300)).build().unwrap_or_default(),
2955×
            rt: tokio::runtime::Handle::current(),
2965×
            token: std::sync::Mutex::new(None),
2975×
        }
2985×
    }
299
30066×
    fn name(&self, key: &str) -> String {
30166×
        if self.prefix.is_empty() {
30266×
            key.to_string()
303
        } else {
3040×
            format!("{}/{key}", self.prefix)
305
        }
30666×
    }
307
308
    /// An access token, exchanged for a fresh one of the hub's when the
309
    /// last is within five minutes of running out.
31066×
    fn access(&self) -> Result<String> {
31166×
        let now = db::now_ms();
31266×
        if let Some((t, until)) = self.token.lock().unwrap().as_ref() {
31361×
            if *until - now > 300_000 {
31461×
                return Ok(t.clone());
3150×
            }
3165×
        }
3175×
        let (token, expires_in) = self.rt.block_on(async {
3185×
            let issuer = self.app.oidc().await?;
319
            // Google's default audience for a provider: its name over https.
3205×
            let audience = format!("https:{}", self.provider);
3215×
            let jwt = issuer.mint_for(self.app.config.base_url(), &self.subject, &audience, db::now_ms(), 600);
3225×
            let res = self
3235×
                .client
3245×
                .post(format!("{}/v1/token", self.sts))
3255×
                .json(&serde_json::json!({
3265×
                    "grantType": "urn:ietf:params:oauth:grant-type:token-exchange",
3275×
                    "audience": self.provider,
3285×
                    "scope": "https://www.googleapis.com/auth/devstorage.read_write",
3295×
                    "requestedTokenType": "urn:ietf:params:oauth:token-type:access_token",
3305×
                    "subjectToken": jwt,
3315×
                    "subjectTokenType": "urn:ietf:params:oauth:token-type:jwt",
3325×
                }))
3335×
                .send()
3345×
                .await?;
3355×
            let status = res.status();
3365×
            let body: serde_json::Value = res.json().await.unwrap_or_default();
3375×
            if !status.is_success() {
3380×
                bail!(
339
                    "Google's token exchange refused {}: {} (check the provider's issuer is this hub's address and its attribute condition admits backup:)",
340
                    self.subject,
3410×
                    body["error_description"].as_str().or(body["error"].as_str()).unwrap_or("no reason given")
342
                );
3435×
            }
3445×
            let token = body["access_token"].as_str().context("the token exchange gave no access token")?.to_string();
3455×
            anyhow::Ok((token, body["expires_in"].as_i64().unwrap_or(3600)))
3465×
        })?;
3475×
        *self.token.lock().unwrap() = Some((token.clone(), now + expires_in * 1000));
3485×
        Ok(token)
34966×
    }
350
35137×
    fn object_url(&self, key: &str) -> Result<reqwest::Url> {
35237×
        let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?;
35337×
        u.path_segments_mut().map_err(|_| anyhow::anyhow!("bad storage URL"))?.push(&self.name(key));
35437×
        Ok(u)
35537×
    }
356
3573×
    fn refused(&self, what: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
3583×
        let hint = if status == reqwest::StatusCode::FORBIDDEN || status == reqwest::StatusCode::UNAUTHORIZED {
3592×
            format!(" (grant roles/storage.objectAdmin on gs://{} to the principal …/subject/{})", self.bucket, self.subject)
360
        } else {
3611×
            String::new()
362
        };
3633×
        anyhow::anyhow!("GCS {what}: {status}{hint}: {}", body.chars().take(200).collect::<String>())
3643×
    }
365
366
    /// Every object name under `full` (no delimiter), for removal.
3671×
    fn all_under(&self, full: &str) -> Result<Vec<String>> {
3681×
        let mut out = Vec::new();
3691×
        let mut page: Option<String> = None;
370
        loop {
3711×
            let token = self.access()?;
3721×
            let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?;
3731×
            u.query_pairs_mut().append_pair("prefix", full).append_pair("fields", "items(name),nextPageToken");
3741×
            if let Some(p) = &page {
3750×
                u.query_pairs_mut().append_pair("pageToken", p);
3761×
            }
3771×
            let body: serde_json::Value = self.rt.block_on(async {
3781×
                let res = self.client.get(u).bearer_auth(&token).send().await?;
3791×
                let status = res.status();
3801×
                let text = res.text().await?;
3811×
                if !status.is_success() {
3820×
                    return Err(self.refused("list", status, &text));
3831×
                }
3841×
                anyhow::Ok(serde_json::from_str(&text)?)
3851×
            })?;
3861×
            out.extend(body["items"].as_array().into_iter().flatten().filter_map(|i| i["name"].as_str().map(String::from)));
3871×
            match body["nextPageToken"].as_str() {
3880×
                Some(p) => page = Some(p.to_string()),
3891×
                None => return Ok(out),
390
            }
391
        }
3921×
    }
393
}
394
395
/// An S3 bucket, written keylessly (§3): the hub's own short-lived token
396
/// (`sub = backup:OWNER`, audience `sts.amazonaws.com`) is exchanged at AWS
397
/// STS (AssumeRoleWithWebIdentity) for an hour's credentials of the role
398
/// the owner names, and they sign each request (Signature Version 4).
399
pub struct S3 {
400
    bucket: String,
401
    prefix: String,
402
    region: String,
403
    /// The bucket's endpoint: scheme, host and any path before keys.
404
    base: reqwest::Url,
405
    sts: String,
406
    /// The IAM role's ARN.
407
    role: String,
408
    subject: String,
409
    app: Shared,
410
    client: reqwest::Client,
411
    rt: tokio::runtime::Handle,
412
    creds: std::sync::Mutex<Option<Creds>>,
413
}
414
415
#[derive(Clone)]
416
struct Creds {
417
    id: String,
418
    secret: String,
419
    token: String,
420
    until: i64,
421
}
422
423
/// The audience AWS expects of a web identity token.
424
pub const AWS_AUDIENCE: &str = "sts.amazonaws.com";
425
426
/// Percent-encoding as AWS signs it: everything but unreserved characters,
427
/// and `/` too unless `keep_slash`.
428164×
pub fn aws_encode(s: &str, keep_slash: bool) -> String {
429164×
    let mut out = String::new();
4304746×
    for b in s.bytes() {
431238×
        match b {
4324428×
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
433168×
            b'/' if keep_slash => out.push('/'),
434150×
            _ => out.push_str(&format!("%{b:02X}")),
435
        }
436
    }
437164×
    out
438164×
}
439
440665×
fn hmac(key: &[u8], data: &str) -> Vec<u8> {
441
    use hmac::Mac;
442665×
    let mut m = hmac::Hmac::<sha2::Sha256>::new_from_slice(key).expect("any key length");
443665×
    m.update(data.as_bytes());
444665×
    m.finalize().into_bytes().to_vec()
445665×
}
446
447199×
fn sha256_hex(data: &[u8]) -> String {
448
    use sha2::Digest;
449199×
    hex::encode(sha2::Sha256::digest(data))
450199×
}
451
452
/// AWS Signature Version 4: the `Authorization` header for one request.
453
/// `uri` is the path as encoded on the wire, `query` the decoded pairs,
454
/// `headers` the signed ones (any case), `payload` the body's SHA-256 in
455
/// hex, `amz_date` as `20130524T000000Z`.
456
#[allow(clippy::too_many_arguments)]
457133×
pub fn sigv4(
458133×
    method: &str,
459133×
    uri: &str,
460133×
    query: &[(String, String)],
461133×
    headers: &[(String, String)],
462133×
    payload: &str,
463133×
    (id, secret): (&str, &str),
464133×
    region: &str,
465133×
    service: &str,
466133×
    amz_date: &str,
467133×
) -> String {
468133×
    let mut q: Vec<(String, String)> = query.iter().map(|(k, v)| (aws_encode(k, false), aws_encode(v, false))).collect();
469133×
    q.sort();
470133×
    let query = q.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&");
471532×
    let mut h: Vec<(String, String)> = headers.iter().map(|(k, v)| (k.to_ascii_lowercase(), v.trim().to_string())).collect();
472133×
    h.sort();
473532×
    let canonical_headers: String = h.iter().map(|(k, v)| format!("{k}:{v}\n")).collect();
474532×
    let signed = h.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>().join(";");
475133×
    let request = format!("{method}\n{uri}\n{query}\n{canonical_headers}\n{signed}\n{payload}");
476133×
    let date = &amz_date[..8];
477133×
    let scope = format!("{date}/{region}/{service}/aws4_request");
478133×
    let to_sign = format!("AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}", sha256_hex(request.as_bytes()));
479133×
    let key = hmac(&hmac(&hmac(&hmac(format!("AWS4{secret}").as_bytes(), date), region), service), "aws4_request");
480133×
    let signature = hex::encode(hmac(&key, &to_sign));
481133×
    format!("AWS4-HMAC-SHA256 Credential={id}/{scope}, SignedHeaders={signed}, Signature={signature}")
482133×
}
483
484
/// The text of the first `<tag>` in `xml`, unescaped.
48537×
fn xml_text(xml: &str, tag: &str) -> Option<String> {
48637×
    xml_blocks(xml, tag).into_iter().next().map(xml_unescape)
48737×
}
488
489
/// The inside of every `<tag>…</tag>` in `xml`.
49049×
fn xml_blocks<'a>(xml: &'a str, tag: &str) -> Vec<&'a str> {
49149×
    let (open, close) = (format!("<{tag}>"), format!("</{tag}>"));
49249×
    let mut out = Vec::new();
49349×
    let mut rest = xml;
49485×
    while let Some(i) = rest.find(&open) {
49536×
        let after = &rest[i + open.len()..];
49636×
        let Some(j) = after.find(&close) else { break };
49736×
        out.push(&after[..j]);
49836×
        rest = &after[j + close.len()..];
499
    }
50049×
    out
50149×
}
502
50330×
fn xml_unescape(s: &str) -> String {
50430×
    s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
50530×
}
506
50766×
fn amz_date(ms: i64) -> String {
50866×
    let t = time::OffsetDateTime::from_unix_timestamp_nanos(ms as i128 * 1_000_000).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
50966×
    format!("{:04}{:02}{:02}T{:02}{:02}{:02}Z", t.year(), u8::from(t.month()), t.day(), t.hour(), t.minute(), t.second())
51066×
}
511
512
impl S3 {
5135×
    pub fn new(app: &Shared, s: &Setting) -> S3 {
5145×
        let url = app.config.backups.s3_url.replace("{bucket}", &s.target).replace("{region}", &s.region);
515
        S3 {
5165×
            bucket: s.target.clone(),
5175×
            prefix: s.prefix.trim_matches('/').to_string(),
5185×
            region: s.region.clone(),
5195×
            base: reqwest::Url::parse(url.trim_end_matches('/')).unwrap_or_else(|_| reqwest::Url::parse("https://invalid.").unwrap()),
5205×
            sts: app.config.backups.aws_sts_url.trim_end_matches('/').to_string(),
5215×
            role: s.provider.clone(),
5225×
            subject: format!("backup:{}", s.owner),
5235×
            app: app.clone(),
5245×
            client: reqwest::Client::builder().timeout(std::time::Duration::from_secs(300)).build().unwrap_or_default(),
5255×
            rt: tokio::runtime::Handle::current(),
5265×
            creds: std::sync::Mutex::new(None),
527
        }
5285×
    }
529
53066×
    fn name(&self, key: &str) -> String {
53166×
        if self.prefix.is_empty() {
5320×
            key.to_string()
533
        } else {
53466×
            format!("{}/{key}", self.prefix)
535
        }
53666×
    }
537
538
    /// Credentials of the owner's role, exchanged again when the last are
539
    /// within five minutes of running out.
54066×
    fn creds(&self) -> Result<Creds> {
54166×
        let now = db::now_ms();
54266×
        if let Some(c) = self.creds.lock().unwrap().as_ref() {
54361×
            if c.until - now > 300_000 {
54461×
                return Ok(c.clone());
5450×
            }
5465×
        }
5475×
        let session: String = format!("oxygenhub-{}", self.subject.trim_start_matches("backup:"))
5485×
            .chars()
54969×
            .filter(|c| c.is_ascii_alphanumeric() || "=,.@-_".contains(*c))
5505×
            .take(64)
5515×
            .collect();
5525×
        let c = self.rt.block_on(async {
5535×
            let issuer = self.app.oidc().await?;
5545×
            let jwt = issuer.mint_for(self.app.config.base_url(), &self.subject, AWS_AUDIENCE, db::now_ms(), 600);
5555×
            let res = self
5565×
                .client
5575×
                .post(format!("{}/", self.sts))
5585×
                .form(&[
5595×
                    ("Action", "AssumeRoleWithWebIdentity"),
5605×
                    ("Version", "2011-06-15"),
5615×
                    ("RoleArn", self.role.as_str()),
5625×
                    ("RoleSessionName", session.as_str()),
5635×
                    ("WebIdentityToken", jwt.as_str()),
5645×
                    ("DurationSeconds", "3600"),
5655×
                ])
5665×
                .send()
5675×
                .await?;
5685×
            let status = res.status();
5695×
            let text = res.text().await.unwrap_or_default();
5705×
            if !status.is_success() {
5710×
                bail!(
572
                    "AWS STS refused {} for {}: {} (check the role's trust policy: this hub's address as its OIDC provider, audience {AWS_AUDIENCE}, and sub {})",
573
                    self.subject,
574
                    self.role,
5750×
                    xml_text(&text, "Message").unwrap_or_else(|| status.to_string()),
576
                    self.subject
577
                );
5785×
            }
57915×
            let get = |t: &str| xml_text(&text, t).with_context(|| format!("AWS STS gave no {t}"));
5805×
            anyhow::Ok(Creds { id: get("AccessKeyId")?, secret: get("SecretAccessKey")?, token: get("SessionToken")?, until: now + 3_600_000 })
5815×
        })?;
5825×
        *self.creds.lock().unwrap() = Some(c.clone());
5835×
        Ok(c)
58466×
    }
585
586
    /// One signed request: `key` is an object's (none for the bucket).
58766×
    fn send(
58866×
        &self,
58966×
        method: reqwest::Method,
59066×
        key: Option<&str>,
59166×
        query: &[(String, String)],
59266×
        body: Vec<u8>,
59366×
    ) -> Result<(reqwest::StatusCode, Vec<u8>)> {
59466×
        let c = self.creds()?;
59566×
        let base_path = self.base.path().trim_end_matches('/');
59666×
        let uri = match key {
59760×
            Some(k) => format!("{base_path}/{}", aws_encode(&self.name(k), true)),
5986×
            None => format!("{base_path}/"),
599
        };
60066×
        let host = match self.base.port() {
60166×
            Some(p) => format!("{}:{p}", self.base.host_str().unwrap_or_default()),
6020×
            None => self.base.host_str().unwrap_or_default().to_string(),
603
        };
60466×
        let payload = sha256_hex(&body);
60566×
        let date = amz_date(db::now_ms());
60666×
        let headers = vec![
60766×
            ("host".to_string(), host.clone()),
60866×
            ("x-amz-content-sha256".to_string(), payload.clone()),
60966×
            ("x-amz-date".to_string(), date.clone()),
61066×
            ("x-amz-security-token".to_string(), c.token.clone()),
611
        ];
61266×
        let auth = sigv4(method.as_str(), &uri, query, &headers, &payload, (&c.id, &c.secret), &self.region, "s3", &date);
61366×
        let mut q: Vec<(String, String)> = query.iter().map(|(k, v)| (aws_encode(k, false), aws_encode(v, false))).collect();
61466×
        q.sort();
61566×
        let qs = q.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&");
61666×
        let url = format!("{}://{host}{uri}{}{qs}", self.base.scheme(), if qs.is_empty() { "" } else { "?" });
61766×
        let method_has_body = method == reqwest::Method::PUT;
61866×
        self.rt.block_on(async {
61966×
            let mut req = self
62066×
                .client
62166×
                .request(method, &url)
62266×
                .header("authorization", auth)
62366×
                .header("x-amz-content-sha256", &payload)
62466×
                .header("x-amz-date", &date)
62566×
                .header("x-amz-security-token", &c.token);
62666×
            if !body.is_empty() {
62721×
                req = req.header("content-type", "application/octet-stream");
62845×
            }
629
            // Always the length, an empty body's too: S3 and GCS refuse
630
            // an upload without one (411 Length Required).
63166×
            if method_has_body {
63222×
                req = req.header("content-length", body.len().to_string()).body(body);
63344×
            }
63466×
            let res = req.send().await?;
63566×
            let status = res.status();
63666×
            anyhow::Ok((status, res.bytes().await?.to_vec()))
63766×
        })
63866×
    }
639
6403×
    fn refused(&self, what: &str, status: reqwest::StatusCode, body: &[u8]) -> anyhow::Error {
6413×
        let text = String::from_utf8_lossy(body);
6423×
        let hint = if status == reqwest::StatusCode::FORBIDDEN {
6432×
            format!(" (the role {} needs s3:GetObject, s3:PutObject, s3:DeleteObject and s3:ListBucket on {})", self.role, self.bucket)
644
        } else {
6451×
            String::new()
646
        };
6473×
        anyhow::anyhow!("S3 {what}: {status}{hint}: {}", xml_text(&text, "Message").unwrap_or_else(|| text.chars().take(200).collect()))
6483×
    }
649
650
    /// The keys under `full` (`delimited`: and the next level's prefixes).
6516×
    fn listing(&self, full: &str, delimited: bool) -> Result<(Vec<String>, Vec<String>)> {
6526×
        let (mut keys, mut prefixes) = (Vec::new(), Vec::new());
6536×
        let mut token: Option<String> = None;
654
        loop {
6556×
            let mut q = vec![("list-type".to_string(), "2".to_string()), ("prefix".to_string(), full.to_string())];
6566×
            if delimited {
6575×
                q.push(("delimiter".to_string(), "/".to_string()));
6585×
            }
6596×
            if let Some(t) = &token {
6600×
                q.push(("continuation-token".to_string(), t.clone()));
6616×
            }
6626×
            let (status, body) = self.send(reqwest::Method::GET, None, &q, Vec::new())?;
6636×
            if !status.is_success() {
6640×
                return Err(self.refused("list", status, &body));
6656×
            }
6666×
            let text = String::from_utf8_lossy(&body).into_owned();
6676×
            for c in xml_blocks(&text, "Contents") {
6683×
                keys.extend(xml_text(c, "Key"));
6693×
            }
6706×
            for c in xml_blocks(&text, "CommonPrefixes") {
6713×
                prefixes.extend(xml_text(c, "Prefix"));
6723×
            }
6736×
            match (xml_text(&text, "IsTruncated").as_deref(), xml_text(&text, "NextContinuationToken")) {
6740×
                (Some("true"), Some(t)) => token = Some(t),
6756×
                _ => return Ok((keys, prefixes)),
676
            }
677
        }
6786×
    }
679
}
680
681
impl Destination for S3 {
68216×
    fn exists(&self, key: &str) -> Result<bool> {
68316×
        let (status, body) = self.send(reqwest::Method::HEAD, Some(key), &[], Vec::new())?;
68410×
        match status {
68516×
            s if s.is_success() => Ok(true),
68610×
            reqwest::StatusCode::NOT_FOUND => Ok(false),
6870×
            s => Err(self.refused("read", s, &body)),
688
        }
68916×
    }
69022×
    fn put(&self, key: &str, bytes: &[u8]) -> Result<()> {
69122×
        let (status, body) = self.send(reqwest::Method::PUT, Some(key), &[], bytes.to_vec())?;
69222×
        if !status.is_success() {
6931×
            return Err(self.refused("write", status, &body));
69421×
        }
69521×
        Ok(())
69622×
    }
69721×
    fn get(&self, key: &str) -> Result<Vec<u8>> {
69821×
        let (status, body) = self.send(reqwest::Method::GET, Some(key), &[], Vec::new())?;
69921×
        if !status.is_success() {
7002×
            return Err(self.refused(&format!("read {key}"), status, &body));
70119×
        }
70219×
        Ok(body)
70321×
    }
7045×
    fn list(&self, prefix: &str) -> Result<Vec<String>> {
7055×
        let full = format!("{}/", self.name(prefix.trim_end_matches('/')));
7065×
        let (keys, prefixes) = self.listing(&full, true)?;
7075×
        let mut out: std::collections::BTreeSet<String> = keys.iter().map(|k| k.trim_start_matches(&full).to_string()).collect();
7085×
        out.extend(prefixes.iter().map(|p| p.trim_start_matches(&full).trim_end_matches('/').to_string()));
7096×
        Ok(out.into_iter().filter(|n| !n.is_empty()).collect())
7105×
    }
7111×
    fn remove(&self, key: &str) -> Result<()> {
7121×
        let full = self.name(key);
7131×
        let (mut keys, _) = self.listing(&format!("{full}/"), false)?;
7141×
        keys.push(full);
7151×
        for k in keys {
7161×
            let rel = if self.prefix.is_empty() { k.clone() } else { k.trim_start_matches(&format!("{}/", self.prefix)).to_string() };
7171×
            let (status, body) = self.send(reqwest::Method::DELETE, Some(&rel), &[], Vec::new())?;
7181×
            if !status.is_success() && status != reqwest::StatusCode::NOT_FOUND {
7190×
                return Err(self.refused("remove", status, &body));
7201×
            }
721
        }
7221×
        Ok(())
7231×
    }
724
}
725
726
impl Destination for Gcs {
72716×
    fn exists(&self, key: &str) -> Result<bool> {
72816×
        let token = self.access()?;
72916×
        let mut u = self.object_url(key)?;
73016×
        u.query_pairs_mut().append_pair("fields", "name");
73116×
        self.rt.block_on(async {
73216×
            let res = self.client.get(u).bearer_auth(&token).send().await?;
73316×
            match res.status() {
73416×
                s if s.is_success() => Ok(true),
73510×
                reqwest::StatusCode::NOT_FOUND => Ok(false),
7360×
                s => Err(self.refused("read", s, &res.text().await.unwrap_or_default())),
737
            }
73816×
        })
73916×
    }
74022×
    fn put(&self, key: &str, bytes: &[u8]) -> Result<()> {
74122×
        let token = self.access()?;
74222×
        let mut u = reqwest::Url::parse(&format!("{}/upload/storage/v1/b/{}/o", self.storage, self.bucket))?;
74322×
        u.query_pairs_mut().append_pair("uploadType", "media").append_pair("name", &self.name(key));
74422×
        let body = bytes.to_vec();
74522×
        self.rt.block_on(async {
746
            // The length always, an empty object's too: Google refuses an
747
            // upload without one (411 Length Required).
74822×
            let res = self
74922×
                .client
75022×
                .post(u)
75122×
                .bearer_auth(&token)
75222×
                .header("content-type", "application/octet-stream")
75322×
                .header("content-length", body.len().to_string())
75422×
                .body(body)
75522×
                .send()
75622×
                .await?;
75722×
            let status = res.status();
75822×
            if !status.is_success() {
7591×
                return Err(self.refused("write", status, &res.text().await.unwrap_or_default()));
76021×
            }
76121×
            Ok(())
76222×
        })
76322×
    }
76421×
    fn get(&self, key: &str) -> Result<Vec<u8>> {
76521×
        let token = self.access()?;
76621×
        let mut u = self.object_url(key)?;
76721×
        u.query_pairs_mut().append_pair("alt", "media");
76821×
        self.rt.block_on(async {
76921×
            let res = self.client.get(u).bearer_auth(&token).send().await?;
77021×
            let status = res.status();
77121×
            if !status.is_success() {
7722×
                return Err(self.refused(&format!("read {key}"), status, &res.text().await.unwrap_or_default()));
77319×
            }
77419×
            Ok(res.bytes().await?.to_vec())
77521×
        })
77621×
    }
7775×
    fn list(&self, prefix: &str) -> Result<Vec<String>> {
7785×
        let full = format!("{}/", self.name(prefix.trim_end_matches('/')));
7795×
        let mut out = std::collections::BTreeSet::new();
7805×
        let mut page: Option<String> = None;
781
        loop {
7825×
            let token = self.access()?;
7835×
            let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?;
7845×
            u.query_pairs_mut()
7855×
                .append_pair("prefix", &full)
7865×
                .append_pair("delimiter", "/")
7875×
                .append_pair("fields", "items(name),prefixes,nextPageToken");
7885×
            if let Some(p) = &page {
7890×
                u.query_pairs_mut().append_pair("pageToken", p);
7905×
            }
7915×
            let body: serde_json::Value = self.rt.block_on(async {
7925×
                let res = self.client.get(u).bearer_auth(&token).send().await?;
7935×
                let status = res.status();
7945×
                let text = res.text().await?;
7955×
                if !status.is_success() {
7960×
                    return Err(self.refused("list", status, &text));
7975×
                }
7985×
                anyhow::Ok(serde_json::from_str(&text)?)
7995×
            })?;
8005×
            for n in body["items"].as_array().into_iter().flatten().filter_map(|i| i["name"].as_str()) {
8013×
                out.insert(n.trim_start_matches(&full).to_string());
8023×
            }
8035×
            for p in body["prefixes"].as_array().into_iter().flatten().filter_map(|p| p.as_str()) {
8043×
                out.insert(p.trim_start_matches(&full).trim_end_matches('/').to_string());
8053×
            }
8065×
            match body["nextPageToken"].as_str() {
8070×
                Some(p) => page = Some(p.to_string()),
8086×
                None => return Ok(out.into_iter().filter(|n| !n.is_empty()).collect()),
809
            }
810
        }
8115×
    }
8121×
    fn remove(&self, key: &str) -> Result<()> {
8131×
        let mut names = self.all_under(&format!("{}/", self.name(key)))?;
8141×
        names.push(self.name(key));
8151×
        for n in names {
8161×
            let token = self.access()?;
8171×
            let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?;
8181×
            u.path_segments_mut().map_err(|_| anyhow::anyhow!("bad storage URL"))?.push(&n);
8191×
            self.rt.block_on(async {
8201×
                let res = self.client.delete(u).bearer_auth(&token).send().await?;
8211×
                match res.status() {
8221×
                    s if s.is_success() || s == reqwest::StatusCode::NOT_FOUND => Ok(()),
8230×
                    s => Err(self.refused("delete", s, &res.text().await.unwrap_or_default())),
824
                }
8251×
            })?;
826
        }
8271×
        Ok(())
8281×
    }
829
}
830
831
/// Check a destination end to end: write a probe, read it back, remove it.
8323×
pub fn probe(dest: &dyn Destination) -> Result<()> {
8333×
    let marker = format!("probe {}", db::now_ms());
8343×
    dest.put(".oxyhub-probe", marker.as_bytes())?;
8353×
    if dest.get(".oxyhub-probe")? != marker.as_bytes() {
8360×
        bail!("the probe read back differently");
8373×
    }
8383×
    dest.remove(".oxyhub-probe")
8393×
}
840
841
/// The owner's destination, from its setting and the site's rules.
84221×
pub fn destination(app: &Shared, s: &Setting) -> Result<Box<dyn Destination>> {
84321×
    match s.destination.as_str() {
84421×
        "dir" => {
84511×
            let allowed = &app.config.backups.directories;
84611×
            let target = PathBuf::from(&s.target);
84711×
            if !allowed.contains(&target) {
8481×
                bail!("{} is not a backup directory this hub allows (hub.toml [backups] directories)", s.target);
84910×
            }
85010×
            let mut root = target.join(&s.owner);
85110×
            if !s.prefix.is_empty() {
8520×
                root = root.join(s.prefix.trim_matches('/'));
85310×
            }
85410×
            Ok(Box::new(Directory { root }))
855
        }
85610×
        "gcs" => {
8575×
            if s.target.is_empty() || s.provider.is_empty() {
8580×
                bail!("a GCS destination needs its bucket and its workload identity provider");
8595×
            }
8605×
            Ok(Box::new(Gcs::new(app, s)))
861
        }
8625×
        "s3" => {
8635×
            if s.target.is_empty() || s.provider.is_empty() || s.region.is_empty() {
8640×
                bail!("an S3 destination needs its bucket, its region and the IAM role the hub assumes");
8655×
            }
8665×
            Ok(Box::new(S3::new(app, s)))
867
        }
8680×
        other => bail!("unknown destination {other:?}"),
869
    }
87021×
}
871
872
// ---------------------------------------------------------------- what
873
874
/// One file of a repository's store, in a manifest.
875
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
876
pub struct File {
877
    pub path: String,
878
    pub size: u64,
879
    /// Its modification time in nanoseconds: unchanged size and time mean
880
    /// unchanged content, so it is not hashed again.
881
    pub mtime: i128,
882
    pub hash: String,
883
}
884
885
/// A repository at one run.
886
#[derive(Clone, Debug, Serialize, Deserialize)]
887
pub struct Manifest {
888
    pub owner: String,
889
    pub name: String,
890
    /// `repos` or `wikis`.
891
    pub kind: String,
892
    pub files: Vec<File>,
893
}
894
895
/// A repository's name as one key segment: a drop's `drops/ID` becomes
896
/// `drops~ID` (#280), so a run's manifests and records stay one level deep;
897
/// the files inside keep the real name, which restore uses.
89832×
fn key_name(name: &str) -> String {
89932×
    name.replace('/', "~")
90032×
}
901
902165×
fn blob_key(hash: &str) -> String {
903165×
    format!("blobs/{}/{hash}", &hash[..2])
904165×
}
905
906
/// The large files that never change once written come first.
907166×
fn immutable(path: &str) -> bool {
908166×
    path.starts_with(".oxy/objects/") && (path.contains("/pack-") || path.contains("/loose/"))
909166×
}
910
911
#[derive(Default)]
912
struct Sent {
913
    files: i64,
914
    sent: i64,
915
    bytes: i64,
916
}
917
918
/// Walk `dir`, sending each file the destination lacks; `known` says which
919
/// paths are unchanged since the last run (and their hashes).
92035×
fn copy_dir(dest: &dyn Destination, dir: &Path, known: &HashMap<String, File>, only_immutable: bool, sent: &mut Sent) -> Result<Vec<File>> {
92135×
    let mut out = Vec::new();
92235×
    let mut stack = vec![dir.to_path_buf()];
923339×
    while let Some(d) = stack.pop() {
924304×
        let entries = match std::fs::read_dir(&d) {
925304×
            Ok(e) => e,
9260×
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
9270×
            Err(e) => return Err(e.into()),
928
        };
929603×
        for e in entries.flatten() {
930603×
            let p = e.path();
931603×
            let ft = e.file_type()?;
932603×
            if ft.is_dir() {
933269×
                stack.push(p);
934269×
                continue;
935334×
            }
936334×
            if !ft.is_file() {
9370×
                continue;
938334×
            }
939334×
            let rel = p.strip_prefix(dir).unwrap_or(&p).to_string_lossy().replace('\\', "/");
940334×
            if only_immutable && !immutable(&rel) {
94197×
                continue;
942237×
            }
943
            // A file packed away while the walk ran is simply gone.
944237×
            let Ok(md) = std::fs::metadata(&p) else { continue };
945237×
            let mtime =
946237×
                md.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_nanos() as i128).unwrap_or(0);
947237×
            let size = md.len();
948237×
            sent.files += 1;
949237×
            if let Some(k) = known.get(&rel).filter(|k| k.size == size && k.mtime == mtime) {
950134×
                out.push(k.clone());
951134×
                continue;
952103×
            }
953103×
            let Ok(bytes) = std::fs::read(&p) else { continue };
954103×
            let hash = blake3::hash(&bytes).to_hex().to_string();
955103×
            let key = blob_key(&hash);
956103×
            if !dest.exists(&key)? {
95771×
                dest.put(&key, &bytes)?;
95871×
                sent.sent += 1;
95971×
                sent.bytes += bytes.len() as i64;
96032×
            }
961103×
            out.push(File { path: rel, size, mtime, hash });
962
        }
963
    }
964504×
    out.sort_by(|a, b| a.path.cmp(&b.path));
96535×
    Ok(out)
96635×
}
967
968
// ---------------------------------------------------------------- records
969
970
/// Tables and columns never written: secrets, credentials, sessions.
9713885×
fn private_table(t: &str) -> bool {
97225974×
    ["secret", "token", "session", "password", "totp", "recovery", "sqlx"].iter().any(|w| t.contains(w))
9733885×
}
974
9758933×
fn private_column(c: &str) -> bool {
97662055×
    ["secret", "token", "password", "salt", "totp", "sealed", "hash_key"].iter().any(|w| c.contains(w))
9778933×
}
978
97937×
async fn tables(pool: &Pool) -> Result<Vec<String>> {
98037×
    let mut t = catalog::tables(pool).await?;
9813885×
    t.retain(|t| !private_table(t));
98237×
    t.sort();
98337×
    Ok(t)
98437×
}
985
9863038×
async fn columns(pool: &Pool, table: &str) -> Result<Vec<String>> {
9873038×
    Ok(table_cols(pool, table).await?.into_iter().map(|c| c.name).collect())
9883038×
}
989
990
/// The rows of `table` matching `cond`, as a JSON array, private columns
991
/// left out, blobs as hex and flags as 0 or 1, whichever engine wrote them,
992
/// so a run from either restores into either.
9931037×
async fn rows(pool: &Pool, table: &str, cond: &str) -> Result<serde_json::Value> {
9948933×
    let cols: Vec<Col> = table_cols(pool, table).await?.into_iter().filter(|c| !private_column(&c.name)).collect();
9951037×
    if cols.is_empty() {
9960×
        return Ok(serde_json::json!([]));
9971037×
    }
9981037×
    let text: String = sqlx::query_scalar(&catalog::rows_sql(table, &cols, cond)).fetch_one(pool).await?;
9991037×
    Ok(serde_json::from_str(&text)?)
10001037×
}
1001
1002
/// What backups ask of the database's own catalog, per engine. Column
1003
/// types are given in SQLite's words (INTEGER, TEXT, BLOB, REAL) plus
1004
/// BOOLEAN, which only Postgres has.
1005
#[cfg(not(feature = "postgres"))]
1006
mod catalog {
1007
    use super::{Col, Pool, Result};
1008
100937×
    pub async fn tables(pool: &Pool) -> Result<Vec<String>> {
101037×
        Ok(sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").fetch_all(pool).await?)
101137×
    }
1012
10134098×
    pub async fn cols(pool: &Pool, table: &str) -> Result<Vec<Col>> {
10144098×
        let rows: Vec<(String, String, i64, Option<String>, i64)> =
10154098×
            sqlx::query_as(&format!("SELECT name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('{table}')"))
10164098×
                .fetch_all(pool)
10174098×
                .await?;
101833543×
        Ok(rows.into_iter().map(|(name, ty, notnull, default, pk)| Col { name, ty, notnull: notnull != 0, default, pk }).collect())
10194098×
    }
1020
1021
    /// `(parent table, column, parent column)` for each reference.
10224076×
    pub async fn fks(pool: &Pool, table: &str) -> Result<Vec<(String, String, String)>> {
10234076×
        Ok(sqlx::query_as(&format!("SELECT \"table\", \"from\", \"to\" FROM pragma_foreign_key_list('{table}')")).fetch_all(pool).await?)
10244076×
    }
1025
10261037×
    pub fn rows_sql(table: &str, cols: &[Col], cond: &str) -> String {
10271037×
        let fields: Vec<String> =
10288841×
            cols.iter().map(|c| format!("'{0}', CASE WHEN typeof(\"{0}\") = 'blob' THEN hex(\"{0}\") ELSE \"{0}\" END", c.name)).collect();
10291037×
        format!("SELECT json_group_array(json_object({})) FROM \"{table}\" WHERE {cond}", fields.join(", "))
10301037×
    }
1031
1032
    /// How a restored row goes in when its key is taken: `INSERT …` then this.
103320×
    pub fn insert(verb: super::Verb, _cols: &[String]) -> (&'static str, String) {
103420×
        match verb {
10351×
            super::Verb::Replace => ("INSERT OR REPLACE", String::new()),
103617×
            super::Verb::New => ("INSERT", String::new()),
10372×
            super::Verb::Ignore => ("INSERT OR IGNORE", String::new()),
1038
        }
103920×
    }
1040
}
1041
1042
#[cfg(feature = "postgres")]
1043
mod catalog {
1044
    use super::{Col, Pool, Result};
1045
1046
    pub async fn tables(pool: &Pool) -> Result<Vec<String>> {
1047
        Ok(sqlx::query_scalar(
1048
            "SELECT CAST(relname AS TEXT) FROM pg_class WHERE relkind = 'r' AND relnamespace = CAST(current_schema() AS regnamespace)",
1049
        )
1050
        .fetch_all(pool)
1051
        .await?)
1052
    }
1053
1054
    /// Generated columns (the search index's) are left out: they are made,
1055
    /// never restored.
1056
    pub async fn cols(pool: &Pool, table: &str) -> Result<Vec<Col>> {
1057
        // pg_catalog, not information_schema: many times faster.
1058
        let rows: Vec<(String, String, bool, Option<String>, i64)> = sqlx::query_as(
1059
            "SELECT CAST(a.attname AS TEXT), format_type(a.atttypid, a.atttypmod), a.attnotnull, pg_get_expr(d.adbin, d.adrelid), \
1060
               CAST(COALESCE((SELECT array_position(k.conkey, a.attnum) FROM pg_constraint k WHERE k.conrelid = t.oid AND k.contype = 'p'), 0) AS BIGINT) \
1061
             FROM pg_attribute a JOIN pg_class t ON t.oid = a.attrelid \
1062
             LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum \
1063
             WHERE t.relname = $1 AND t.relnamespace = CAST(current_schema() AS regnamespace) \
1064
               AND a.attnum > 0 AND NOT a.attisdropped AND a.attgenerated = '' ORDER BY a.attnum",
1065
        )
1066
        .bind(table)
1067
        .fetch_all(pool)
1068
        .await?;
1069
        Ok(rows
1070
            .into_iter()
1071
            .map(|(name, ty, notnull, default, pk)| {
1072
                let ty = match ty.as_str() {
1073
                    "bigint" | "integer" | "smallint" => "INTEGER",
1074
                    "bytea" => "BLOB",
1075
                    "boolean" => "BOOLEAN",
1076
                    "double precision" | "real" => "REAL",
1077
                    _ => "TEXT",
1078
                };
1079
                // An identity column's default is the sequence, as SQLite's
1080
                // rowid: none to give.
1081
                let default = default.filter(|d| !d.starts_with("nextval("));
1082
                Col { name, ty: ty.to_string(), notnull, default, pk }
1083
            })
1084
            .collect())
1085
    }
1086
1087
    /// `(parent table, column, parent column)` for each reference.
1088
    pub async fn fks(pool: &Pool, table: &str) -> Result<Vec<(String, String, String)>> {
1089
        Ok(sqlx::query_as(
1090
            "SELECT CAST(p.relname AS TEXT), CAST(a.attname AS TEXT), CAST(pa.attname AS TEXT) FROM pg_constraint k \
1091
             JOIN pg_class t ON t.oid = k.conrelid JOIN pg_class p ON p.oid = k.confrelid \
1092
             JOIN pg_attribute a ON a.attrelid = k.conrelid AND a.attnum = k.conkey[1] \
1093
             JOIN pg_attribute pa ON pa.attrelid = k.confrelid AND pa.attnum = k.confkey[1] \
1094
             WHERE k.contype = 'f' AND t.relname = $1 AND t.relnamespace = CAST(current_schema() AS regnamespace)",
1095
        )
1096
        .bind(table)
1097
        .fetch_all(pool)
1098
        .await?)
1099
    }
1100
1101
    pub fn rows_sql(table: &str, cols: &[Col], cond: &str) -> String {
1102
        let fields: Vec<String> = cols
1103
            .iter()
1104
            .map(|c| match c.ty.as_str() {
1105
                "BLOB" => format!("upper(encode(\"{0}\", 'hex')) AS \"{0}\"", c.name),
1106
                "BOOLEAN" => format!("CASE WHEN \"{0}\" THEN 1 ELSE 0 END AS \"{0}\"", c.name),
1107
                _ => format!("\"{0}\"", c.name),
1108
            })
1109
            .collect();
1110
        format!("SELECT CAST(COALESCE(json_agg(r), '[]') AS TEXT) FROM (SELECT {} FROM \"{table}\" WHERE {cond}) r", fields.join(", "))
1111
    }
1112
1113
    /// How a restored row goes in when its key is taken: `INSERT …` then this.
1114
    pub fn insert(verb: super::Verb, cols: &[String]) -> (&'static str, String) {
1115
        match verb {
1116
            super::Verb::Replace => {
1117
                let set: Vec<String> = cols.iter().filter(|c| *c != "repo_id").map(|c| format!("\"{c}\" = excluded.\"{c}\"")).collect();
1118
                let tail = if set.is_empty() {
1119
                    " ON CONFLICT (repo_id) DO NOTHING".to_string()
1120
                } else {
1121
                    format!(" ON CONFLICT (repo_id) DO UPDATE SET {}", set.join(", "))
1122
                };
1123
                ("INSERT", tail)
1124
            }
1125
            super::Verb::New => ("INSERT", String::new()),
1126
            super::Verb::Ignore => ("INSERT", " ON CONFLICT DO NOTHING".to_string()),
1127
        }
1128
    }
1129
}
1130
1131
/// How restored rows meet the rows already there.
1132
#[derive(Clone, Copy)]
1133
enum Verb {
1134
    /// One row per repository: the restored one wins.
1135
    Replace,
1136
    /// A row with an id of its own, given a new one.
1137
    New,
1138
    /// Kept as it is when already there.
1139
    Ignore,
1140
}
1141
1142
/// Every row about something: the tables with column `key` equal to `id`,
1143
/// then, three levels deep, the tables whose foreign keys point at rows
1144
/// already taken (an issue's comments, a proposal's reviews and their
1145
/// threads).
114631×
async fn records(pool: &Pool, key: &str, id: i64) -> Result<serde_json::Value> {
114731×
    let all = tables(pool).await?;
114831×
    let mut conds: Vec<(String, String)> = Vec::new();
11493038×
    for t in &all {
115020088×
        if columns(pool, t).await?.iter().any(|c| c == key) {
1151717×
            conds.push((t.clone(), format!("\"{key}\" = {id}")));
11522321×
        }
1153
    }
115431×
    for _ in 0..3 {
115563×
        let mut more = Vec::new();
11566174×
        for t in &all {
1157170629×
            if conds.iter().any(|(c, _)| c == t) || more.iter().any(|(c, _): &(String, String)| c == t) {
11582141×
                continue;
11594033×
            }
11604033×
            let fks = catalog::fks(pool, t).await?;
11614033×
            if let Some((parent, from, to, pcond)) = fks
11624033×
                .iter()
116388136×
                .find_map(|(p, f, to)| conds.iter().find(|(c, _)| c == p).map(|(_, pc)| (p.clone(), f.clone(), to.clone(), pc.clone())))
1164304×
            {
1165304×
                more.push((t.clone(), format!("\"{from}\" IN (SELECT \"{to}\" FROM \"{parent}\" WHERE {pcond})")));
11663729×
            }
1167
        }
116863×
        if more.is_empty() {
116931×
            break;
117032×
        }
117132×
        conds.extend(more);
1172
    }
117331×
    let mut out = serde_json::Map::new();
11741021×
    for (t, cond) in conds {
11751021×
        let r = rows(pool, &t, &cond).await?;
11761021×
        if r.as_array().is_some_and(|a| !a.is_empty()) {
117729×
            out.insert(t, r);
1178992×
        }
1179
    }
118031×
    Ok(serde_json::Value::Object(out))
118131×
}
1182
1183
/// The key of a run's records naming the accounts, teams and organisations
1184
/// their rows point at, by id: `{"accounts": {"7": "andy"}, …}`.
1185
const NAMES: &str = "_names";
1186
1187
/// Tables a restore leaves out: the search index (rebuilt from what is
1188
/// restored), and what is passing or personal rather than the
1189
/// repository's: inboxes, webhook deliveries, runners' declines.
1190
const NOT_RESTORED: [&str; 5] = ["search", "notifications", "deliveries", "flow_declines", NAMES];
1191
119243×
async fn fk_list(pool: &Pool, table: &str) -> Result<Vec<(String, String, String)>> {
119343×
    catalog::fks(pool, table).await
119443×
}
1195
1196
/// The names behind the account, team and organisation ids that `rec`'s
1197
/// rows point at, so a restore on another hub finds them again by name.
119816×
async fn names_of(pool: &Pool, rec: &serde_json::Value) -> Result<serde_json::Value> {
119916×
    let mut ids: HashMap<String, std::collections::BTreeSet<i64>> = HashMap::new();
120026×
    for (table, rows) in rec.as_object().into_iter().flatten() {
120126×
        let Some(rows) = rows.as_array() else { continue };
120248×
        for (parent, from, _) in fk_list(pool, table).await? {
120348×
            if ["accounts", "teams", "organisations"].contains(&parent.as_str()) {
120420×
                ids.entry(parent).or_default().extend(rows.iter().filter_map(|r| r[&from].as_i64()));
120529×
            }
1206
        }
1207
    }
120816×
    let mut out = serde_json::Map::new();
120948×
    for (kind, sql) in [
121016×
        ("accounts", "SELECT id, username FROM accounts WHERE id = $1"),
121116×
        ("organisations", "SELECT id, name FROM organisations WHERE id = $1"),
121216×
        ("teams", "SELECT t.id, o.name || '/' || t.name FROM teams t JOIN organisations o ON o.id = t.org_id WHERE t.id = $1"),
121316×
    ] {
121448×
        let mut m = serde_json::Map::new();
121548×
        for id in ids.get(kind).into_iter().flatten() {
12162×
            if let Some((id, name)) = sqlx::query_as::<_, (i64, String)>(sql).bind(id).fetch_optional(pool).await? {
12172×
                m.insert(id.to_string(), name.into());
12182×
            }
1219
        }
122048×
        out.insert(kind.into(), m.into());
1221
    }
122216×
    Ok(out.into())
122316×
}
1224
1225
/// The id `name` has on this hub as one of `_names`' kinds.
12262×
async fn id_by_name(pool: &Pool, kind: &str, name: &str) -> Result<Option<i64>> {
12272×
    Ok(match kind {
12282×
        "accounts" => sqlx::query_scalar("SELECT id FROM accounts WHERE username = $1").bind(name).fetch_optional(pool).await?,
12290×
        "organisations" => sqlx::query_scalar("SELECT id FROM organisations WHERE name = $1").bind(name).fetch_optional(pool).await?,
12300×
        "teams" => match name.split_once('/') {
12310×
            Some((org, team)) => {
12320×
                sqlx::query_scalar("SELECT t.id FROM teams t JOIN organisations o ON o.id = t.org_id WHERE o.name = $1 AND t.name = $2")
12330×
                    .bind(org)
12340×
                    .bind(team)
12350×
                    .fetch_optional(pool)
12360×
                    .await?
1237
            }
12380×
            None => None,
1239
        },
12400×
        _ => None,
1241
    })
12422×
}
1243
1244
/// One column of a table on this hub.
1245
struct Col {
1246
    name: String,
1247
    ty: String,
1248
    notnull: bool,
1249
    default: Option<String>,
1250
    pk: i64,
1251
}
1252
12534098×
async fn table_cols(pool: &Pool, table: &str) -> Result<Vec<Col>> {
12544098×
    catalog::cols(pool, table).await
12554098×
}
1256
1257
/// Bind one value of a backed-up row to a column of type `ty`: blobs were
1258
/// written as hex, flags as 0 or 1 (a Postgres BOOLEAN takes them as such).
1259299×
fn bind_json<'q>(
1260299×
    q: sqlx::query::Query<'q, crate::db::Db, <crate::db::Db as sqlx::Database>::Arguments<'q>>,
1261299×
    v: serde_json::Value,
1262299×
    ty: &str,
1263299×
) -> sqlx::query::Query<'q, crate::db::Db, <crate::db::Db as sqlx::Database>::Arguments<'q>> {
1264299×
    let blob = ty.eq_ignore_ascii_case("BLOB");
1265299×
    if ty == "BOOLEAN" {
12660×
        return match v {
12670×
            serde_json::Value::Null => q.bind(None::<bool>),
12680×
            serde_json::Value::Bool(b) => q.bind(b),
12690×
            serde_json::Value::Number(n) => q.bind(n.as_f64().unwrap_or(0.0) != 0.0),
12700×
            other => q.bind(matches!(other.as_str(), Some("1" | "true"))),
1271
        };
1272299×
    }
1273169×
    match v {
12740×
        serde_json::Value::Null => q.bind(None::<i64>),
12750×
        serde_json::Value::Bool(b) => q.bind(b as i64),
1276130×
        serde_json::Value::Number(n) => match n.as_i64() {
1277130×
            Some(i) => q.bind(i),
12780×
            None => q.bind(n.as_f64().unwrap_or(0.0)),
1279
        },
12800×
        serde_json::Value::String(s) if blob => q.bind(hex::decode(&s).unwrap_or_else(|_| s.into_bytes())),
1281169×
        serde_json::Value::String(s) => q.bind(s),
12820×
        other => q.bind(other.to_string()),
1283
    }
1284299×
}
1285
1286
/// Restore one repository's records from a run into this hub, the
1287
/// repository being `repo` here and `old` in the backup (docs/backups.md
1288
/// §7): its issues, proposals, labels, milestones, releases and their
1289
/// assets, checks, Flow runs and the rest, each table after the tables it
1290
/// points at, every id mapped to its new one and every account, team and
1291
/// organisation found again by name. A row pointing at an account this
1292
/// hub lacks keeps a blank reference where it may, and is left out where
1293
/// it may not. What needs re-entering (webhook secrets, never backed up)
1294
/// and what was left out come back as notes.
12956×
async fn restore_records(
12966×
    app: &Shared,
12976×
    dest: &std::sync::Arc<dyn Destination>,
12986×
    rec: &serde_json::Value,
12996×
    old: i64,
13006×
    repo: i64,
13016×
) -> Result<Vec<String>> {
13026×
    let pool = &app.db;
13036×
    let obj = rec.as_object().context("the records are not an object")?;
13046×
    let mut by_name: HashMap<(String, i64), Option<i64>> = HashMap::new();
130518×
    for (kind, m) in obj.get(NAMES).and_then(|n| n.as_object()).into_iter().flatten() {
130618×
        for (id, name) in m.as_object().into_iter().flatten() {
13072×
            let (Ok(id), Some(name)) = (id.parse::<i64>(), name.as_str()) else { continue };
13082×
            by_name.insert((kind.clone(), id), id_by_name(pool, kind, name).await?);
1309
        }
1310
    }
13116×
    let here: std::collections::HashSet<String> = tables(pool).await?.into_iter().collect();
13126×
    let mut notes = Vec::new();
13136×
    let mut todo: Vec<String> = Vec::new();
131430×
    for t in obj.keys().filter(|t| t.as_str() != "repositories" && !NOT_RESTORED.contains(&t.as_str())) {
131517×
        if here.contains(t) {
131617×
            todo.push(t.clone());
131717×
        } else {
13180×
            notes.push(format!("{t}: not a table on this hub, left out"));
13190×
        }
1320
    }
13216×
    let mut fks: HashMap<String, Vec<(String, String, String)>> = HashMap::new();
132217×
    for t in &todo {
132317×
        fks.insert(t.clone(), fk_list(pool, t).await?);
1324
    }
1325
    // Each table after the tables of the records it points at.
13266×
    let mut order = Vec::new();
132712×
    while !todo.is_empty() {
13286×
        let mut ready: Vec<String> =
132947×
            todo.iter().filter(|t| fks[*t].iter().all(|(p, _, _)| p == *t || !todo.contains(p))).cloned().collect();
13306×
        if ready.is_empty() {
13310×
            ready.push(todo[0].clone());
13326×
        }
133327×
        todo.retain(|t| !ready.contains(t));
13346×
        order.extend(ready);
1335
    }
13366×
    let mut maps: HashMap<String, HashMap<i64, i64>> = HashMap::new();
13376×
    maps.insert("repositories".into(), [(old, repo)].into());
13386×
    let mut left_out: std::collections::BTreeMap<String, usize> = Default::default();
13396×
    let mut tx = pool.begin().await?;
134017×
    for table in &order {
134117×
        let cols = table_cols(pool, table).await?;
1342157×
        let keys: Vec<&str> = cols.iter().filter(|c| c.pk > 0).map(|c| c.name.as_str()).collect();
134317×
        let by_id = keys == ["id"] && cols.iter().any(|c| c.name == "id" && c.ty.eq_ignore_ascii_case("INTEGER"));
134417×
        let verb = if keys == ["repo_id"] {
13451×
            Verb::Replace
134616×
        } else if by_id {
134714×
            Verb::New
1348
        } else {
13492×
            Verb::Ignore
1350
        };
135117×
        let mut rows: Vec<&serde_json::Map<String, serde_json::Value>> =
135221×
            obj[table].as_array().into_iter().flatten().filter_map(|r| r.as_object()).collect();
135317×
        rows.sort_by_key(|r| r.get("id").and_then(|v| v.as_i64()).unwrap_or(0));
135421×
        'row: for row in rows {
135521×
            let (mut names, mut values) = (Vec::new(), Vec::new());
1356174×
            for c in &cols {
1357174×
                if by_id && c.name == "id" {
135817×
                    continue;
1359157×
                }
1360157×
                let mut v = row.get(&c.name).cloned().filter(|v| !v.is_null());
1361301×
                if let Some((parent, _, _)) = fks[table].iter().find(|(_, from, _)| *from == c.name) {
136236×
                    if let Some(was) = v.as_ref().and_then(|v| v.as_i64()) {
136331×
                        let now = match parent.as_str() {
136431×
                            "accounts" | "teams" | "organisations" => by_name.get(&(parent.clone(), was)).copied().flatten(),
136524×
                            p => maps.get(p).and_then(|m| m.get(&was)).copied(),
1366
                        };
136731×
                        if now.is_none() && c.notnull {
13681×
                            *left_out.entry(table.clone()).or_default() += 1;
13691×
                            continue 'row;
137030×
                        }
137130×
                        v = now.map(serde_json::Value::from);
13725×
                    }
1373121×
                }
137412×
                match v {
1375144×
                    Some(v) => {
1376144×
                        names.push(c.name.clone());
1377144×
                        values.push((v, c.ty.as_str()));
1378144×
                    }
1379
                    // Never backed up (a secret): blank here, to enter again.
138012×
                    None if c.notnull && c.default.is_none() => {
13810×
                        names.push(c.name.clone());
13820×
                        let blank = if c.ty.to_ascii_uppercase().contains("INT") || c.ty == "BOOLEAN" {
13830×
                            serde_json::json!(0)
1384
                        } else {
13850×
                            serde_json::json!("")
1386
                        };
13870×
                        values.push((blank, c.ty.as_str()));
1388
                    }
138912×
                    None => {}
1390
                }
1391
            }
1392
            // A row with a new id says what it is (a plain INSERT, so one row).
139320×
            let (insert, tail) = catalog::insert(verb, &names);
139420×
            let sql = format!(
1395
                "{insert} INTO \"{table}\" ({}) VALUES ({}){tail}{}",
1396143×
                names.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join(", "),
1397143×
                (1..=names.len()).map(|i| format!("${i}")).collect::<Vec<_>>().join(", "),
139820×
                if by_id { " RETURNING id" } else { "" }
1399
            );
140020×
            let mut q = sqlx::query(&sql);
1401143×
            for (v, ty) in values {
1402143×
                q = bind_json(q, v, ty);
1403143×
            }
140420×
            let context = || format!("restore a row of {table}");
140520×
            if by_id {
140617×
                let made: i64 = sqlx::Row::try_get(&q.fetch_one(&mut *tx).await.with_context(context)?, 0)?;
140717×
                if let Some(was) = row.get("id").and_then(|v| v.as_i64()) {
140817×
                    maps.entry(table.clone()).or_default().insert(was, made);
140917×
                }
1410
            } else {
14113×
                q.execute(&mut *tx).await.with_context(context)?;
1412
            }
1413
        }
1414
    }
14156×
    tx.commit().await?;
14166×
    for (table, n) in left_out {
14171×
        notes.push(format!("{table}: {n} row(s) left out: they name accounts or rows this hub lacks"));
14181×
    }
1419
    // Search finds what came back.
142012×
    for (kind, table) in [("issue", "issues"), ("proposal", "proposals")] {
142112×
        let found: Vec<(i64, i64, String, String)> =
142212×
            sqlx::query_as(&format!("SELECT id, number, title, body FROM {table} WHERE repo_id = $1")).bind(repo).fetch_all(pool).await?;
142312×
        for (id, number, title, body) in found {
14242×
            db::notify::index(pool, kind, repo, id, number, &title, &body).await?;
1425
        }
1426
    }
1427
    // Webhook secrets were never written: they are entered again.
14286×
    let hooks: Vec<String> =
14296×
        sqlx::query_scalar("SELECT url FROM hooks WHERE repo_id = $1 AND secret = ''").bind(repo).fetch_all(pool).await?;
14306×
    for url in hooks {
14311×
        notes.push(format!("webhook {url}: enter its secret again (Settings › Webhooks)"));
14321×
    }
1433
    // Release assets, each checked against its name.
14346×
    for hash in obj.get("release_assets").and_then(|a| a.as_array()).into_iter().flatten().filter_map(|a| a["hash"].as_str()) {
143564×
        if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
14360×
            continue;
14371×
        }
14381×
        let path = crate::web::releases::blob_path(app, hash);
14391×
        if path.exists() {
14400×
            continue;
14411×
        }
14421×
        let got = tokio::task::block_in_place(|| dest.get(&blob_key(hash)));
14431×
        match got {
14441×
            Ok(bytes) if blake3::hash(&bytes).to_hex().as_str() == hash => {
14451×
                std::fs::create_dir_all(path.parent().context("no parent")?)?;
14461×
                std::fs::write(&path, &bytes)?;
1447
            }
14480×
            Ok(_) => notes.push(format!("release asset {}: the copy does not match its hash", &hash[..12])),
14490×
            Err(_) => notes.push(format!("release asset {}: not in the backup", &hash[..12])),
1450
        }
1451
    }
14526×
    Ok(notes)
14536×
}
1454
1455
// ---------------------------------------------------------------- a run
1456
145732×
fn run_name(ms: i64) -> String {
145832×
    let t = time::OffsetDateTime::from_unix_timestamp_nanos(ms as i128 * 1_000_000).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
145932×
    format!("{:04}{:02}{:02}T{:02}{:02}{:02}Z", t.year(), u8::from(t.month()), t.day(), t.hour(), t.minute(), t.second())
146032×
}
1461
1462
/// The last whole run's manifests, by `kind/name`.
146315×
fn last_manifests(dest: &dyn Destination) -> HashMap<String, HashMap<String, File>> {
146415×
    let mut out = HashMap::new();
146515×
    let Ok(at) = dest.get("LATEST").map(|b| String::from_utf8_lossy(&b).trim().to_string()) else { return out };
14667×
    for name in dest.list(&format!("runs/{at}/repos")).unwrap_or_default() {
14677×
        if let Ok(m) = dest.get(&format!("runs/{at}/repos/{name}")).and_then(|b| Ok(serde_json::from_slice::<Manifest>(&b)?)) {
146875×
            out.insert(format!("{}/{}", m.kind, m.name), m.files.into_iter().map(|f| (f.path.clone(), f)).collect());
14690×
        }
1470
    }
14716×
    out
147215×
}
1473
1474
/// Back up `owner` now, as its setting says. The run is recorded whatever
1475
/// happens; a failure notifies the owner once until a run succeeds.
147616×
pub async fn run(app: &Shared, owner: &str, trigger: &str) -> Result<Run> {
147716×
    let s = setting(&app.db, owner).await?.with_context(|| format!("{owner} has no backup set up"))?;
147816×
    let started = db::now_ms();
147916×
    let at = run_name(started);
148016×
    let id: i64 = sqlx::query_scalar(
148116×
        "INSERT INTO backup_runs (owner, started_ms, status, at, trigger) VALUES ($1, $2, 'running', $3, $4) RETURNING id",
148216×
    )
148316×
    .bind(owner)
148416×
    .bind(started)
148516×
    .bind(&at)
148616×
    .bind(trigger)
148716×
    .fetch_one(&app.db)
148816×
    .await?;
148916×
    let outcome = run_into(app, &s, &at, trigger == "now").await;
149016×
    let (status, detail, sent) = match outcome {
149113×
        Ok((sent, problems)) if problems.is_empty() => ("ok", String::new(), sent),
14920×
        Ok((sent, problems)) => ("partial", problems.join("; "), sent),
14933×
        Err(e) => ("failed", format!("{e:#}"), (0, Sent::default())),
1494
    };
149516×
    sqlx::query(
149616×
        "UPDATE backup_runs SET finished_ms = $1, status = $2, detail = $3, repos = $4, files = $5, sent = $6, bytes = $7 WHERE id = $8",
149716×
    )
149816×
    .bind(db::now_ms())
149916×
    .bind(status)
150016×
    .bind(&detail)
150116×
    .bind(sent.0)
150216×
    .bind(sent.1.files)
150316×
    .bind(sent.1.sent)
150416×
    .bind(sent.1.bytes)
150516×
    .bind(id)
150616×
    .execute(&app.db)
150716×
    .await?;
150816×
    let r: Run = sqlx::query_as("SELECT * FROM backup_runs WHERE id = $1").bind(id).fetch_one(&app.db).await?;
150916×
    if status != "ok" {
15103×
        notify_failure(app, &s, status, &detail).await;
1511
    } else {
151213×
        notify_recovered(app, &s, &r).await;
1513
    }
151416×
    Ok(r)
151516×
}
1516
151716×
async fn run_into(app: &Shared, s: &Setting, at: &str, by_hand: bool) -> Result<((i64, Sent), Vec<String>)> {
151816×
    let dest: std::sync::Arc<dyn Destination> = destination(app, s)?.into();
151915×
    let known = {
152015×
        let d = dest.clone();
152115×
        tokio::task::spawn_blocking(move || last_manifests(d.as_ref())).await?
1522
    };
1523
    // Its repositories and drops (#280), but those left out (§5).
152415×
    let mine: Vec<db::repos::RepoRow> =
152526×
        db::repos::list_with_drops(&app.db).await?.into_iter().filter(|r| r.owner == s.owner && included(s, r, by_hand)).collect();
152615×
    let mut problems = Vec::new();
152715×
    let mut sent = Sent::default();
152815×
    let mut repos = 0i64;
152916×
    for row in &mine {
153016×
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
153132×
        for (kind, store) in [("repos", &app.repos), ("wikis", &app.wikis)] {
153232×
            if !store.exists(&name) {
153316×
                continue;
153416×
            }
153516×
            let dir = store.dir(&name);
153616×
            let k = known.get(&format!("{kind}/{}", row.name)).cloned().unwrap_or_default();
1537
            // The large, unchanging files without the lock, then the whole
1538
            // walk again under the read lock: pushes wait for that moment.
153916×
            let (d1, dir1, k1) = (dest.clone(), dir.clone(), k.clone());
154016×
            let first = tokio::task::spawn_blocking(move || {
154116×
                let mut s = Sent::default();
154216×
                copy_dir(d1.as_ref(), &dir1, &k1, true, &mut s).map(|f| (f, s))
154316×
            })
154416×
            .await?;
154516×
            let mut k2 = k;
154616×
            if let Ok((files, _)) = &first {
154768×
                for f in files {
154868×
                    k2.insert(f.path.clone(), f.clone());
154968×
                }
15500×
            }
1551
            // Writers wait while the last pass copies (readers carry on),
1552
            // so the refs copied name only objects copied (#249).
155316×
            let guard = store.write_guard(&name).await;
155416×
            let (d2, dir2) = (dest.clone(), dir.clone());
155516×
            let second = tokio::task::spawn_blocking(move || {
155616×
                let _guard = guard;
155716×
                let mut s = Sent::default();
155816×
                copy_dir(d2.as_ref(), &dir2, &k2, false, &mut s).map(|f| (f, s))
155916×
            })
156016×
            .await?;
156116×
            match second {
156216×
                Ok((files, s2)) => {
156316×
                    let s1 = first.map(|(_, s)| s).unwrap_or_default();
156416×
                    sent.files += s2.files;
156516×
                    sent.sent += s1.sent + s2.sent;
156616×
                    sent.bytes += s1.bytes + s2.bytes;
156716×
                    let m = Manifest { owner: row.owner.clone(), name: row.name.clone(), kind: kind.into(), files };
156816×
                    let key = format!("runs/{at}/repos/{kind}-{}.json", key_name(&row.name));
156916×
                    tokio::task::block_in_place(|| dest.put(&key, &serde_json::to_vec(&m)?))?;
157016×
                    if kind == "repos" {
157116×
                        repos += 1;
157216×
                    }
1573
                }
15740×
                Err(e) => problems.push(format!("{}/{} ({kind}): {e:#}", row.owner, row.name)),
1575
            }
1576
        }
157716×
        let rec = async {
157816×
            let mut r = records(&app.db, "repo_id", row.id).await?;
1579
            // The repository's own row, which restore recreates first, and
1580
            // the names behind the accounts, teams and organisations its
1581
            // rows point at, which another hub finds them by.
158216×
            let own = rows(&app.db, "repositories", &format!("id = {}", row.id)).await?;
158316×
            let names = names_of(&app.db, &r).await?;
158416×
            if let Some(m) = r.as_object_mut() {
158516×
                m.insert("repositories".into(), own);
158616×
                m.insert(NAMES.into(), names);
158716×
            }
158816×
            anyhow::Ok(r)
158916×
        };
159016×
        match rec.await {
159116×
            Ok(r) => {
159216×
                tokio::task::block_in_place(|| {
159316×
                    dest.put(&format!("runs/{at}/records/{}.json", key_name(&row.name)), &serde_json::to_vec(&r)?)
159416×
                })?;
1595
                // Release assets live beside the stores, named by their
1596
                // BLAKE3: each sent once, as the stores' files are.
159716×
                for hash in r["release_assets"].as_array().into_iter().flatten().filter_map(|a| a["hash"].as_str()) {
159864×
                    if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
15990×
                        continue;
16001×
                    }
16011×
                    let path = crate::web::releases::blob_path(app, hash);
16021×
                    let key = blob_key(hash);
16031×
                    let put = tokio::task::block_in_place(|| -> Result<Option<i64>> {
16041×
                        if dest.exists(&key)? {
16050×
                            return Ok(None);
16061×
                        }
16071×
                        let bytes = std::fs::read(&path).with_context(|| format!("read release asset {}", &hash[..12]))?;
16081×
                        dest.put(&key, &bytes)?;
16091×
                        Ok(Some(bytes.len() as i64))
16101×
                    });
16111×
                    match put {
16121×
                        Ok(Some(n)) => {
16131×
                            sent.files += 1;
16141×
                            sent.sent += 1;
16151×
                            sent.bytes += n;
16161×
                        }
16170×
                        Ok(None) => sent.files += 1,
16180×
                        Err(e) => problems.push(format!("{}/{} release asset: {e:#}", row.owner, row.name)),
1619
                    }
1620
                }
1621
            }
16220×
            Err(e) => problems.push(format!("{}/{} records: {e:#}", row.owner, row.name)),
1623
        }
1624
    }
1625
    // The owner itself.
162615×
    let owner_rows = if s.kind == "org" {
16270×
        match db::accounts::org_by_name(&app.db, &s.owner).await? {
16280×
            Some(o) => records(&app.db, "org_id", o.id).await?,
16290×
            None => serde_json::json!({}),
1630
        }
1631
    } else {
163215×
        match db::accounts::account_by_name(&app.db, &s.owner).await? {
163315×
            Some(a) => records(&app.db, "account_id", a.id).await?,
16340×
            None => serde_json::json!({}),
1635
        }
1636
    };
1637
    // The blocking calls of a destination (GCS drives its client on this
1638
    // runtime) run where blocking is allowed.
163915×
    tokio::task::block_in_place(|| -> Result<()> {
164015×
        dest.put(&format!("runs/{at}/owner.json"), &serde_json::to_vec(&owner_rows)?)?;
164113×
        dest.put(&format!("runs/{at}/DONE"), at.as_bytes())?;
164213×
        dest.put("LATEST", at.as_bytes())?;
164313×
        prune(dest.as_ref(), s.keep_days)
164415×
    })?;
164513×
    Ok(((repos, sent), problems))
164616×
}
1647
1648
/// Drop whole runs older than `keep_days`, and the blobs no kept run
1649
/// names. The newest whole run is always kept.
165013×
pub fn prune(dest: &dyn Destination, keep_days: i64) -> Result<()> {
165113×
    let cutoff = run_name(db::now_ms() - keep_days.max(1) * 86_400_000);
165213×
    let runs = dest.list("runs")?;
165320×
    let whole: Vec<&String> = runs.iter().filter(|r| dest.exists(&format!("runs/{r}/DONE")).unwrap_or(false)).collect();
165413×
    let newest = whole.last().map(|s| s.to_string());
165513×
    let mut removed = false;
165620×
    for r in &runs {
165720×
        if r.as_str() < cutoff.as_str() && Some(r.to_string()) != newest {
16580×
            dest.remove(&format!("runs/{r}"))?;
16590×
            removed = true;
166020×
        }
1661
    }
166213×
    if !removed {
166313×
        return Ok(());
16640×
    }
16650×
    let mut wanted = std::collections::HashSet::new();
16660×
    for r in dest.list("runs")? {
16670×
        for m in dest.list(&format!("runs/{r}/repos"))? {
16680×
            if let Ok(m) = dest.get(&format!("runs/{r}/repos/{m}")).and_then(|b| Ok(serde_json::from_slice::<Manifest>(&b)?)) {
16690×
                wanted.extend(m.files.into_iter().map(|f| f.hash));
16700×
            }
1671
        }
1672
    }
16730×
    for shard in dest.list("blobs")? {
16740×
        for b in dest.list(&format!("blobs/{shard}"))? {
16750×
            if !wanted.contains(&b) {
16760×
                dest.remove(&format!("blobs/{shard}/{b}"))?;
16770×
            }
1678
        }
1679
    }
16800×
    Ok(())
168113×
}
1682
1683
/// Who hears about `s`'s backups, and where they look: the user, or the
1684
/// organisation's owners.
16855×
async fn told(app: &Shared, s: &Setting) -> Option<(Vec<(i64, db::notify::Reason)>, String)> {
16865×
    let p = db::notify::Reason::Participating;
16875×
    if s.kind == "org" {
16880×
        let o = db::accounts::org_by_name(&app.db, &s.owner).await.ok().flatten()?;
16890×
        let owners = db::accounts::org_members(&app.db, o.id)
16900×
            .await
16910×
            .ok()?
16920×
            .into_iter()
16930×
            .filter(|(_, r)| r == "owner")
16940×
            .map(|(a, _)| (a.id, p))
16950×
            .collect();
16960×
        Some((owners, format!("/orgs/{}/settings/backups", s.owner)))
1697
    } else {
16985×
        let a = db::accounts::account_by_name(&app.db, &s.owner).await.ok().flatten()?;
16995×
        Some((vec![(a.id, p)], "/settings/backups".into()))
1700
    }
17015×
}
1702
1703
/// The status of `owner`'s finished run before the newest.
170416×
async fn status_before(app: &Shared, owner: &str) -> Option<String> {
170516×
    sqlx::query_scalar("SELECT status FROM backup_runs WHERE owner = $1 AND finished_ms IS NOT NULL ORDER BY id DESC LIMIT 1 OFFSET 1")
170616×
        .bind(owner)
170716×
        .fetch_optional(&app.db)
170816×
        .await
170916×
        .ok()
171016×
        .flatten()
171116×
}
1712
1713
/// A run that did not finish whole tells the owner (inbox and email), once
1714
/// per spell of failures: not when the run before failed too.
17153×
async fn notify_failure(app: &Shared, s: &Setting, status: &str, detail: &str) {
17163×
    if status_before(app, &s.owner).await.is_some_and(|b| b != "ok") {
17170×
        return;
17183×
    }
17193×
    let Some((who, url)) = told(app, s).await else { return };
17203×
    let title = format!("Backup of {} {status}: {}", s.owner, detail.chars().take(160).collect::<String>());
17213×
    crate::notify::tell(app, "backup", &title, &url, "backups", None, who).await;
17223×
}
1723
1724
/// A whole run after one that was not: the owner hears it works again
1725
/// (oxygenhub#183).
172613×
async fn notify_recovered(app: &Shared, s: &Setting, run: &Run) {
172713×
    if !status_before(app, &s.owner).await.is_some_and(|b| b != "ok") {
172812×
        return;
17291×
    }
17301×
    let Some((who, url)) = told(app, s).await else { return };
17311×
    let title = format!("Backup of {} works again: {} repositories to {}", s.owner, run.repos, place(s));
17321×
    crate::notify::tell(app, "backup.recovered", &title, &url, "backups", None, who).await;
173313×
}
1734
1735
/// The hour (UTC) each day's backup summary goes out.
1736
const SUMMARY_HOUR: u8 = 7;
1737
const SUMMARY_SENT: &str = "backups.summary_day";
1738
1739
/// One message a day per owner (oxygenhub#183): the last day's runs, how
1740
/// they went and what they sent. Sent at the first scheduler tick from
1741
/// SUMMARY_HOUR (UTC), once a day. How many owners were told.
17421×
pub async fn daily_summary(app: &Shared, now_ms: i64, force: bool) -> sqlx::Result<usize> {
17431×
    let t = time::OffsetDateTime::from_unix_timestamp_nanos(now_ms as i128 * 1_000_000).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
17441×
    let day = format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day());
17451×
    if !force && (t.hour() < SUMMARY_HOUR || db::accounts::setting(&app.db, SUMMARY_SENT).await?.as_deref() == Some(day.as_str())) {
17460×
        return Ok(0);
17471×
    }
17481×
    db::accounts::set_setting(&app.db, SUMMARY_SENT, &day).await?;
17491×
    let settings: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings WHERE updated_ms > 0").fetch_all(&app.db).await?;
17501×
    let mut sent = 0;
17511×
    for s in settings {
17521×
        let runs: Vec<Run> = sqlx::query_as("SELECT * FROM backup_runs WHERE owner = $1 AND started_ms >= $2 ORDER BY id")
17531×
            .bind(&s.owner)
17541×
            .bind(now_ms - 86_400_000)
17551×
            .fetch_all(&app.db)
17561×
            .await?;
17571×
        if runs.is_empty() {
17580×
            continue;
17591×
        }
17602×
        let ok = runs.iter().filter(|r| r.status == "ok").count();
17611×
        let bytes: i64 = runs.iter().map(|r| r.bytes).sum();
17621×
        let repos = runs.iter().rev().find(|r| r.status == "ok").map_or(0, |r| r.repos);
17631×
        let how = if ok == runs.len() { "all ok".to_string() } else { format!("{} not whole", runs.len() - ok) };
17641×
        let title = format!(
1765
            "Backups of {} today: {} runs, {how}, {repos} repositories, {:.1} MiB sent to {}",
1766
            s.owner,
17671×
            runs.len(),
17681×
            bytes as f64 / 1_048_576.0,
17691×
            place(&s)
1770
        );
17711×
        let Some((who, url)) = told(app, &s).await else { continue };
17721×
        crate::notify::tell(app, "backup.summary", &title, &url, "backups", None, who).await;
17731×
        sent += 1;
1774
    }
17751×
    Ok(sent)
17761×
}
1777
1778
/// Run each owner's backup when it is due, one at a time. A run left
1779
/// "running" by a restart is marked failed first.
1780205×
pub async fn scheduler(app: Shared) {
1781205×
    let _ = sqlx::query(
1782205×
        "UPDATE backup_runs SET status = 'failed', detail = 'the hub restarted during the run', finished_ms = $1 WHERE status = 'running'",
1783
    )
1784205×
    .bind(db::now_ms())
1785205×
    .execute(&app.db)
1786205×
    .await;
1787
    loop {
1788205×
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
17890×
        if let Err(e) = daily_summary(&app, db::now_ms(), false).await {
17900×
            tracing::warn!("backup summary: {e}");
17910×
        }
17920×
        for s in due(&app.db).await.unwrap_or_default() {
17930×
            if let Err(e) = run(&app, &s.owner, "schedule").await {
17940×
                tracing::warn!("backup of {}: {e:#}", s.owner);
17950×
            }
1796
        }
1797
    }
1798
}
1799
1800
/// The owners whose next run is due now: by their own schedule, or by
1801
/// the shortest schedule one of their repositories chose (§5). A run takes
1802
/// every included repository and sends only what changed, so each run
1803
/// stays whole and a restore point has everything.
18046×
pub async fn due(pool: &Pool) -> sqlx::Result<Vec<Setting>> {
18056×
    let all: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings WHERE updated_ms > 0").fetch_all(pool).await?;
18066×
    let repos = db::repos::list(pool).await?;
18076×
    let now = db::now_ms();
18086×
    let mut out = Vec::new();
18096×
    for s in all {
18109×
        let theirs: Vec<db::repos::RepoRow> = repos.iter().filter(|r| r.owner == s.owner).cloned().collect();
18116×
        let Some(period) = period_with(&s, &theirs) else { continue };
18125×
        let last: Option<i64> =
18135×
            sqlx::query_scalar("SELECT MAX(started_ms) FROM backup_runs WHERE owner = $1").bind(&s.owner).fetch_one(pool).await?;
18145×
        if last.is_none_or(|l| now - l >= period * 1000) {
18153×
            out.push(s);
18163×
        }
1817
    }
18186×
    Ok(out)
18196×
}
1820
1821
/// Restore every repository of the whole run `at` (the latest when none)
1822
/// from `dest` into `repos_root/OWNER/NAME` and `wikis_root/…`, refusing to
1823
/// overwrite one that exists. Each file is checked against its hash.
18245×
pub fn restore(dest: &dyn Destination, at: Option<&str>, repos_root: &Path, wikis_root: &Path) -> Result<Vec<String>> {
18255×
    let at = match at {
18260×
        Some(a) => a.to_string(),
18275×
        None => String::from_utf8_lossy(&dest.get("LATEST").context("no whole run at this destination")?).trim().to_string(),
1828
    };
18295×
    if !dest.exists(&format!("runs/{at}/DONE"))? {
18300×
        bail!("run {at} is not whole (no DONE): pick another with --run");
18315×
    }
18325×
    let mut done = Vec::new();
18336×
    for name in dest.list(&format!("runs/{at}/repos"))? {
18346×
        let m: Manifest = serde_json::from_slice(&dest.get(&format!("runs/{at}/repos/{name}"))?)?;
18356×
        let root = if m.kind == "wikis" { wikis_root } else { repos_root };
18366×
        let dir = root.join(&m.owner).join(&m.name);
18376×
        if dir.exists() {
18380×
            bail!("{} exists: restore into an empty hub, or move it aside", dir.display());
18396×
        }
184060×
        for f in &m.files {
184160×
            let bytes = dest.get(&blob_key(&f.hash))?;
184260×
            if blake3::hash(&bytes).to_hex().as_str() != f.hash {
18430×
                bail!("{}/{} {}: the copy does not match its hash", m.owner, m.name, f.path);
184460×
            }
184560×
            let p = dir.join(&f.path);
184660×
            std::fs::create_dir_all(p.parent().context("no parent")?)?;
184760×
            std::fs::write(&p, &bytes)?;
1848
        }
18496×
        done.push(format!("{}/{} ({})", m.owner, m.name, m.kind));
1850
    }
18515×
    Ok(done)
18525×
}
1853
1854
/// Restore the whole run `at` (the latest when none) into this hub: each
1855
/// repository's and wiki's store byte for byte, then its row and settings,
1856
/// then its records (issues, proposals, releases and the rest), so it shows
1857
/// on the hub as it was (docs/backups.md §7). What was restored, then any
1858
/// notes (`note: …`) on what needs entering again or was left out.
18595×
pub async fn restore_into_hub(app: &Shared, dest: std::sync::Arc<dyn Destination>, at: Option<String>) -> Result<Vec<String>> {
18605×
    let (repos_root, wikis_root) = (app.repos.root().to_path_buf(), app.wikis.root().to_path_buf());
18615×
    let (d, a) = (dest.clone(), at.clone());
18625×
    let done = tokio::task::spawn_blocking(move || restore(d.as_ref(), a.as_deref(), &repos_root, &wikis_root)).await??;
18635×
    let at = match at {
18640×
        Some(a) => a,
18655×
        None => String::from_utf8_lossy(&tokio::task::block_in_place(|| dest.get("LATEST"))?).trim().to_string(),
1866
    };
18675×
    let mut notes = Vec::new();
18686×
    for name in tokio::task::block_in_place(|| dest.list(&format!("runs/{at}/records")))? {
18696×
        let rec: serde_json::Value =
18706×
            serde_json::from_slice(&tokio::task::block_in_place(|| dest.get(&format!("runs/{at}/records/{name}")))?)?;
18716×
        let Some(row) = rec["repositories"].as_array().and_then(|a| a.first()) else { continue };
18726×
        let (owner, repo) = (row["owner"].as_str().unwrap_or_default(), row["name"].as_str().unwrap_or_default());
18736×
        if owner.is_empty() || db::repos::get(&app.db, owner, repo).await?.is_some() {
18740×
            continue;
18756×
        }
18766×
        let visibility = match row["visibility"].as_str() {
18776×
            Some("private") => hub_api::Visibility::Private,
18784×
            Some("internal") => hub_api::Visibility::Internal,
18794×
            _ => hub_api::Visibility::Public,
1880
        };
18816×
        let made = db::repos::create(&app.db, owner, repo, row["description"].as_str().unwrap_or_default(), visibility).await?;
1882
        // Its settings as they were: every column the row kept but its
1883
        // name, its id and a fork's parent (another hub's id).
18846×
        let cols = table_cols(&app.db, "repositories").await?;
18856×
        let set: Vec<(&Col, serde_json::Value)> = cols
18866×
            .iter()
1887180×
            .filter(|c| !["id", "owner", "name", "forked_from"].contains(&c.name.as_str()))
1888156×
            .filter_map(|c| row.get(&c.name).filter(|v| !v.is_null()).map(|v| (c, v.clone())))
18896×
            .collect();
18906×
        if !set.is_empty() {
18916×
            let sql = format!(
1892
                "UPDATE repositories SET {} WHERE id = ${}",
1893156×
                set.iter().enumerate().map(|(i, (c, _))| format!("\"{}\" = ${}", c.name, i + 1)).collect::<Vec<_>>().join(", "),
18946×
                set.len() + 1
1895
            );
18966×
            let mut q = sqlx::query(&sql);
1897156×
            for (c, v) in set {
1898156×
                q = bind_json(q, v, &c.ty);
1899156×
            }
19006×
            q.bind(made.id).execute(&app.db).await?;
19010×
        }
19026×
        let old = row["id"].as_i64().unwrap_or(-1);
19036×
        for n in restore_records(app, &dest, &rec, old, made.id).await.with_context(|| format!("restore {owner}/{repo}'s records"))? {
19042×
            notes.push(format!("note: {owner}/{repo}: {n}"));
19052×
        }
1906
    }
19075×
    let mut done = done;
19085×
    done.extend(notes);
19095×
    Ok(done)
19105×
}
1911
1912
#[cfg(test)]
1913
mod tests {
1914
    use super::*;
1915
1916
    #[test]
19171×
    fn a_directory_destination_keeps_keys_inside_its_root() {
19181×
        let d = tempfile::tempdir().unwrap();
19191×
        let dest = Directory { root: d.path().to_path_buf() };
19201×
        dest.put("runs/a/DONE", b"a").unwrap();
19211×
        assert_eq!(dest.get("runs/a/DONE").unwrap(), b"a");
19221×
        assert_eq!(dest.list("runs").unwrap(), vec!["a"]);
19231×
        assert!(dest.put("../escape", b"x").is_err() && dest.put("/abs", b"x").is_err());
19241×
        dest.remove("runs").unwrap();
19251×
        assert!(dest.list("runs").unwrap().is_empty());
19261×
    }
1927
1928
    #[test]
19291×
    fn files_are_sent_once_and_unchanged_ones_not_read_again() {
19301×
        let src = tempfile::tempdir().unwrap();
19311×
        let out = tempfile::tempdir().unwrap();
19321×
        let dest = Directory { root: out.path().to_path_buf() };
19331×
        std::fs::create_dir_all(src.path().join(".oxy/objects/loose")).unwrap();
19341×
        std::fs::write(src.path().join(".oxy/objects/loose/aa"), b"object").unwrap();
19351×
        std::fs::write(src.path().join(".oxy/ops-head"), b"ref one").unwrap();
19361×
        let mut s = Sent::default();
19371×
        let files = copy_dir(&dest, src.path(), &HashMap::new(), false, &mut s).unwrap();
19381×
        assert_eq!((files.len(), s.sent), (2, 2));
19392×
        let known: HashMap<String, File> = files.iter().map(|f| (f.path.clone(), f.clone())).collect();
19401×
        let mut s = Sent::default();
19411×
        let again = copy_dir(&dest, src.path(), &known, false, &mut s).unwrap();
19421×
        assert_eq!((again, s.sent), (files.clone(), 0), "nothing new travels");
1943
        // The first pass takes only the files that never change.
19441×
        let mut s = Sent::default();
19451×
        let first = copy_dir(&dest, src.path(), &HashMap::new(), true, &mut s).unwrap();
19461×
        assert_eq!(first.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(), vec![".oxy/objects/loose/aa"]);
19471×
    }
1948
1949
    #[test]
19501×
    fn requests_are_signed_as_aws_documents() {
1951
        // AWS's own example (S3 API reference, "Signature calculations":
1952
        // GET Object), with its published signature. Its documented example
1953
        // keys are put together at run time, so this file is not itself a
1954
        // push protection or history scan finding (#277).
19551×
        let key_id = format!("{}{}", "AKIA", "IOSFODNN7EXAMPLE");
19561×
        let secret = format!("{}{}", "wJalrXUtnFEMI/K7MDENG/", "bPxRfiCYEXAMPLEKEY");
19571×
        let empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
19581×
        let headers = vec![
19591×
            ("Host".to_string(), "examplebucket.s3.amazonaws.com".to_string()),
19601×
            ("Range".to_string(), "bytes=0-9".to_string()),
19611×
            ("x-amz-content-sha256".to_string(), empty.to_string()),
19621×
            ("x-amz-date".to_string(), "20130524T000000Z".to_string()),
1963
        ];
19641×
        let auth =
19651×
            sigv4("GET", "/test.txt", &[], &headers, empty, (key_id.as_str(), secret.as_str()), "us-east-1", "s3", "20130524T000000Z");
19661×
        assert_eq!(
1967
            auth,
19681×
            format!(
1969
                "AWS4-HMAC-SHA256 Credential={key_id}/20130524/us-east-1/s3/aws4_request, \
1970
                 SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, \
1971
                 Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
1972
            )
1973
        );
19741×
        assert_eq!(aws_encode("runs/2026 x+y", true), "runs/2026%20x%2By");
19751×
        assert_eq!(aws_encode("a/b", false), "a%2Fb");
19761×
        assert_eq!(xml_text("<A><Key>runs/x&amp;y</Key></A>", "Key").as_deref(), Some("runs/x&y"));
19771×
    }
1978
1979
    #[test]
19801×
    fn run_names_sort_by_time() {
19811×
        assert_eq!(run_name(0), "19700101T000000Z");
19821×
        assert!(run_name(1_791_900_000_000) < run_name(1_791_903_600_000));
19831×
    }
1984
}