| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 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 |
|
| 25 |
pub const EVERY: &[(&str, &str)] = &[("off", "Off"), ("hourly", "Every hour"), ("daily", "Every day")]; |
| 26 |
|
| 27 |
pub const KEEP: &[i64] = &[7, 30, 90]; |
| 28 |
|
| 29 |
|
| 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 |
|
| 42 |
#[sqlx(default)] |
| 43 |
pub region: String, |
| 44 |
} |
| 45 |
|
| 46 |
impl Setting { |
| 47 |
pub fn off(owner: &str, kind: &str) -> Setting { |
| 48 |
Setting { |
| 49 |
owner: owner.into(), |
| 50 |
kind: kind.into(), |
| 51 |
every: "off".into(), |
| 52 |
destination: "dir".into(), |
| 53 |
target: String::new(), |
| 54 |
prefix: String::new(), |
| 55 |
provider: String::new(), |
| 56 |
keep_days: 30, |
| 57 |
updated_ms: 0, |
| 58 |
region: String::new(), |
| 59 |
} |
| 60 |
} |
| 61 |
|
| 62 |
|
| 63 |
pub fn period_s(&self) -> Option<i64> { |
| 64 |
match self.every.as_str() { |
| 65 |
"hourly" => Some(3600), |
| 66 |
"daily" => Some(86_400), |
| 67 |
_ => None, |
| 68 |
} |
| 69 |
} |
| 70 |
} |
| 71 |
|
| 72 |
|
| 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 |
|
| 89 |
pub async fn setting(pool: &Pool, owner: &str) -> sqlx::Result<Option<Setting>> { |
| 90 |
sqlx::query_as("SELECT * FROM backup_settings WHERE owner = $1").bind(owner).fetch_optional(pool).await |
| 91 |
} |
| 92 |
|
| 93 |
pub async fn save(pool: &Pool, s: &Setting) -> sqlx::Result<()> { |
| 94 |
sqlx::query( |
| 95 |
"INSERT INTO backup_settings (owner, kind, every, destination, target, prefix, provider, keep_days, updated_ms, region) \ |
| 96 |
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT(owner) DO UPDATE SET kind = excluded.kind, every = excluded.every, \ |
| 97 |
destination = excluded.destination, target = excluded.target, prefix = excluded.prefix, provider = excluded.provider, \ |
| 98 |
keep_days = excluded.keep_days, updated_ms = excluded.updated_ms, region = excluded.region", |
| 99 |
) |
| 100 |
.bind(&s.owner) |
| 101 |
.bind(&s.kind) |
| 102 |
.bind(&s.every) |
| 103 |
.bind(&s.destination) |
| 104 |
.bind(&s.target) |
| 105 |
.bind(&s.prefix) |
| 106 |
.bind(&s.provider) |
| 107 |
.bind(s.keep_days) |
| 108 |
.bind(db::now_ms()) |
| 109 |
.bind(&s.region) |
| 110 |
.execute(pool) |
| 111 |
.await?; |
| 112 |
Ok(()) |
| 113 |
} |
| 114 |
|
| 115 |
pub async fn runs(pool: &Pool, owner: &str, limit: i64) -> sqlx::Result<Vec<Run>> { |
| 116 |
sqlx::query_as("SELECT * FROM backup_runs WHERE owner = $1 ORDER BY id DESC LIMIT $2").bind(owner).bind(limit).fetch_all(pool).await |
| 117 |
} |
| 118 |
|
| 119 |
|
| 120 |
|
| 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 |
|
| 125 |
|
| 126 |
|
| 127 |
pub fn included(s: &Setting, row: &db::repos::RepoRow, by_hand: bool) -> bool { |
| 128 |
match row.backup.as_str() { |
| 129 |
"off" => false, |
| 130 |
"hourly" | "daily" => true, |
| 131 |
_ => by_hand || s.every != "off", |
| 132 |
} |
| 133 |
} |
| 134 |
|
| 135 |
|
| 136 |
|
| 137 |
pub fn period_with(s: &Setting, repos: &[db::repos::RepoRow]) -> Option<i64> { |
| 138 |
let own = repos.iter().filter_map(|r| match r.backup.as_str() { |
| 139 |
"hourly" => Some(3600), |
| 140 |
"daily" => Some(86_400), |
| 141 |
_ => None, |
| 142 |
}); |
| 143 |
s.period_s().into_iter().chain(own).min() |
| 144 |
} |
| 145 |
|
| 146 |
|
| 147 |
|
| 148 |
|
| 149 |
#[derive(Clone, Debug, Serialize)] |
| 150 |
pub struct Overview { |
| 151 |
pub setting: Setting, |
| 152 |
pub last: Option<Run>, |
| 153 |
pub failed_day: i64, |
| 154 |
|
| 155 |
pub repos: i64, |
| 156 |
} |
| 157 |
|
| 158 |
impl Overview { |
| 159 |
|
| 160 |
pub fn place(&self) -> String { |
| 161 |
place(&self.setting) |
| 162 |
} |
| 163 |
} |
| 164 |
|
| 165 |
|
| 166 |
pub fn place(s: &Setting) -> String { |
| 167 |
match s.destination.as_str() { |
| 168 |
"gcs" if s.prefix.is_empty() => format!("gs://{}", s.target), |
| 169 |
"gcs" => format!("gs://{}/{}", s.target, s.prefix), |
| 170 |
"s3" if s.prefix.is_empty() => format!("s3://{} ({})", s.target, s.region), |
| 171 |
"s3" => format!("s3://{}/{} ({})", s.target, s.prefix, s.region), |
| 172 |
_ => format!("{}/{}", s.target, s.owner), |
| 173 |
} |
| 174 |
} |
| 175 |
|
| 176 |
|
| 177 |
|
| 178 |
pub async fn overview(pool: &Pool) -> sqlx::Result<(Vec<Overview>, Vec<(String, i64)>)> { |
| 179 |
let settings: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings ORDER BY owner").fetch_all(pool).await?; |
| 180 |
let counts: Vec<(String, i64)> = |
| 181 |
sqlx::query_as("SELECT owner, COUNT(*) FROM repositories GROUP BY owner ORDER BY owner").fetch_all(pool).await?; |
| 182 |
let day_ago = db::now_ms() - 86_400_000; |
| 183 |
let mut out = Vec::new(); |
| 184 |
for s in settings { |
| 185 |
let last = runs(pool, &s.owner, 1).await?.into_iter().next(); |
| 186 |
let failed_day: i64 = |
| 187 |
sqlx::query_scalar("SELECT COUNT(*) FROM backup_runs WHERE owner = $1 AND status = 'failed' AND started_ms >= $2") |
| 188 |
.bind(&s.owner) |
| 189 |
.bind(day_ago) |
| 190 |
.fetch_one(pool) |
| 191 |
.await?; |
| 192 |
let repos = db::repos::list(pool).await?.iter().filter(|r| r.owner == s.owner && included(&s, r, false)).count() as i64; |
| 193 |
out.push(Overview { setting: s, last, failed_day, repos }); |
| 194 |
} |
| 195 |
let uncovered = counts.into_iter().filter(|(owner, _)| !out.iter().any(|o| o.setting.owner == *owner && o.repos > 0)).collect(); |
| 196 |
Ok((out, uncovered)) |
| 197 |
} |
| 198 |
|
| 199 |
|
| 200 |
|
| 201 |
|
| 202 |
pub trait Destination: Send + Sync { |
| 203 |
fn exists(&self, key: &str) -> Result<bool>; |
| 204 |
|
| 205 |
fn put(&self, key: &str, bytes: &[u8]) -> Result<()>; |
| 206 |
fn get(&self, key: &str) -> Result<Vec<u8>>; |
| 207 |
|
| 208 |
fn list(&self, prefix: &str) -> Result<Vec<String>>; |
| 209 |
|
| 210 |
fn remove(&self, key: &str) -> Result<()>; |
| 211 |
} |
| 212 |
|
| 213 |
|
| 214 |
pub struct Directory { |
| 215 |
pub root: PathBuf, |
| 216 |
} |
| 217 |
|
| 218 |
impl Directory { |
| 219 |
fn path(&self, key: &str) -> Result<PathBuf> { |
| 220 |
if key.split('/').any(|p| p == ".." || p == ".") || key.starts_with('/') { |
| 221 |
bail!("not a clean key: {key}"); |
| 222 |
} |
| 223 |
Ok(self.root.join(key)) |
| 224 |
} |
| 225 |
} |
| 226 |
|
| 227 |
impl Destination for Directory { |
| 228 |
fn exists(&self, key: &str) -> Result<bool> { |
| 229 |
Ok(self.path(key)?.exists()) |
| 230 |
} |
| 231 |
fn put(&self, key: &str, bytes: &[u8]) -> Result<()> { |
| 232 |
let p = self.path(key)?; |
| 233 |
std::fs::create_dir_all(p.parent().context("no parent")?)?; |
| 234 |
let tmp = p.with_extension(format!("part-{}", std::process::id())); |
| 235 |
std::fs::write(&tmp, bytes)?; |
| 236 |
std::fs::rename(&tmp, &p)?; |
| 237 |
Ok(()) |
| 238 |
} |
| 239 |
fn get(&self, key: &str) -> Result<Vec<u8>> { |
| 240 |
let p = self.path(key)?; |
| 241 |
std::fs::read(&p).with_context(|| format!("read {}", p.display())) |
| 242 |
} |
| 243 |
fn list(&self, prefix: &str) -> Result<Vec<String>> { |
| 244 |
let p = self.path(prefix)?; |
| 245 |
let mut out: Vec<String> = match std::fs::read_dir(&p) { |
| 246 |
Ok(d) => d.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(), |
| 247 |
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), |
| 248 |
Err(e) => return Err(e.into()), |
| 249 |
}; |
| 250 |
out.sort(); |
| 251 |
Ok(out) |
| 252 |
} |
| 253 |
fn remove(&self, key: &str) -> Result<()> { |
| 254 |
let p = self.path(key)?; |
| 255 |
if p.is_dir() { |
| 256 |
std::fs::remove_dir_all(p)?; |
| 257 |
} else if p.exists() { |
| 258 |
std::fs::remove_file(p)?; |
| 259 |
} |
| 260 |
Ok(()) |
| 261 |
} |
| 262 |
} |
| 263 |
|
| 264 |
|
| 265 |
|
| 266 |
|
| 267 |
|
| 268 |
|
| 269 |
pub struct Gcs { |
| 270 |
bucket: String, |
| 271 |
|
| 272 |
prefix: String, |
| 273 |
storage: String, |
| 274 |
sts: String, |
| 275 |
|
| 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 { |
| 285 |
pub fn new(app: &Shared, s: &Setting) -> Gcs { |
| 286 |
Gcs { |
| 287 |
bucket: s.target.clone(), |
| 288 |
prefix: s.prefix.trim_matches('/').to_string(), |
| 289 |
storage: app.config.backups.storage_url.trim_end_matches('/').to_string(), |
| 290 |
sts: app.config.backups.sts_url.trim_end_matches('/').to_string(), |
| 291 |
provider: s.provider.clone(), |
| 292 |
subject: format!("backup:{}", s.owner), |
| 293 |
app: app.clone(), |
| 294 |
client: reqwest::Client::builder().timeout(std::time::Duration::from_secs(300)).build().unwrap_or_default(), |
| 295 |
rt: tokio::runtime::Handle::current(), |
| 296 |
token: std::sync::Mutex::new(None), |
| 297 |
} |
| 298 |
} |
| 299 |
|
| 300 |
fn name(&self, key: &str) -> String { |
| 301 |
if self.prefix.is_empty() { |
| 302 |
key.to_string() |
| 303 |
} else { |
| 304 |
format!("{}/{key}", self.prefix) |
| 305 |
} |
| 306 |
} |
| 307 |
|
| 308 |
|
| 309 |
|
| 310 |
fn access(&self) -> Result<String> { |
| 311 |
let now = db::now_ms(); |
| 312 |
if let Some((t, until)) = self.token.lock().unwrap().as_ref() { |
| 313 |
if *until - now > 300_000 { |
| 314 |
return Ok(t.clone()); |
| 315 |
} |
| 316 |
} |
| 317 |
let (token, expires_in) = self.rt.block_on(async { |
| 318 |
let issuer = self.app.oidc().await?; |
| 319 |
|
| 320 |
let audience = format!("https:{}", self.provider); |
| 321 |
let jwt = issuer.mint_for(self.app.config.base_url(), &self.subject, &audience, db::now_ms(), 600); |
| 322 |
let res = self |
| 323 |
.client |
| 324 |
.post(format!("{}/v1/token", self.sts)) |
| 325 |
.json(&serde_json::json!({ |
| 326 |
"grantType": "urn:ietf:params:oauth:grant-type:token-exchange", |
| 327 |
"audience": self.provider, |
| 328 |
"scope": "https://www.googleapis.com/auth/devstorage.read_write", |
| 329 |
"requestedTokenType": "urn:ietf:params:oauth:token-type:access_token", |
| 330 |
"subjectToken": jwt, |
| 331 |
"subjectTokenType": "urn:ietf:params:oauth:token-type:jwt", |
| 332 |
})) |
| 333 |
.send() |
| 334 |
.await?; |
| 335 |
let status = res.status(); |
| 336 |
let body: serde_json::Value = res.json().await.unwrap_or_default(); |
| 337 |
if !status.is_success() { |
| 338 |
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, |
| 341 |
body["error_description"].as_str().or(body["error"].as_str()).unwrap_or("no reason given") |
| 342 |
); |
| 343 |
} |
| 344 |
let token = body["access_token"].as_str().context("the token exchange gave no access token")?.to_string(); |
| 345 |
anyhow::Ok((token, body["expires_in"].as_i64().unwrap_or(3600))) |
| 346 |
})?; |
| 347 |
*self.token.lock().unwrap() = Some((token.clone(), now + expires_in * 1000)); |
| 348 |
Ok(token) |
| 349 |
} |
| 350 |
|
| 351 |
fn object_url(&self, key: &str) -> Result<reqwest::Url> { |
| 352 |
let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?; |
| 353 |
u.path_segments_mut().map_err(|_| anyhow::anyhow!("bad storage URL"))?.push(&self.name(key)); |
| 354 |
Ok(u) |
| 355 |
} |
| 356 |
|
| 357 |
fn refused(&self, what: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error { |
| 358 |
let hint = if status == reqwest::StatusCode::FORBIDDEN || status == reqwest::StatusCode::UNAUTHORIZED { |
| 359 |
format!(" (grant roles/storage.objectAdmin on gs://{} to the principal …/subject/{})", self.bucket, self.subject) |
| 360 |
} else { |
| 361 |
String::new() |
| 362 |
}; |
| 363 |
anyhow::anyhow!("GCS {what}: {status}{hint}: {}", body.chars().take(200).collect::<String>()) |
| 364 |
} |
| 365 |
|
| 366 |
|
| 367 |
fn all_under(&self, full: &str) -> Result<Vec<String>> { |
| 368 |
let mut out = Vec::new(); |
| 369 |
let mut page: Option<String> = None; |
| 370 |
loop { |
| 371 |
let token = self.access()?; |
| 372 |
let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?; |
| 373 |
u.query_pairs_mut().append_pair("prefix", full).append_pair("fields", "items(name),nextPageToken"); |
| 374 |
if let Some(p) = &page { |
| 375 |
u.query_pairs_mut().append_pair("pageToken", p); |
| 376 |
} |
| 377 |
let body: serde_json::Value = self.rt.block_on(async { |
| 378 |
let res = self.client.get(u).bearer_auth(&token).send().await?; |
| 379 |
let status = res.status(); |
| 380 |
let text = res.text().await?; |
| 381 |
if !status.is_success() { |
| 382 |
return Err(self.refused("list", status, &text)); |
| 383 |
} |
| 384 |
anyhow::Ok(serde_json::from_str(&text)?) |
| 385 |
})?; |
| 386 |
out.extend(body["items"].as_array().into_iter().flatten().filter_map(|i| i["name"].as_str().map(String::from))); |
| 387 |
match body["nextPageToken"].as_str() { |
| 388 |
Some(p) => page = Some(p.to_string()), |
| 389 |
None => return Ok(out), |
| 390 |
} |
| 391 |
} |
| 392 |
} |
| 393 |
} |
| 394 |
|
| 395 |
|
| 396 |
|
| 397 |
|
| 398 |
|
| 399 |
pub struct S3 { |
| 400 |
bucket: String, |
| 401 |
prefix: String, |
| 402 |
region: String, |
| 403 |
|
| 404 |
base: reqwest::Url, |
| 405 |
sts: String, |
| 406 |
|
| 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 |
|
| 424 |
pub const AWS_AUDIENCE: &str = "sts.amazonaws.com"; |
| 425 |
|
| 426 |
|
| 427 |
|
| 428 |
pub fn aws_encode(s: &str, keep_slash: bool) -> String { |
| 429 |
let mut out = String::new(); |
| 430 |
for b in s.bytes() { |
| 431 |
match b { |
| 432 |
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), |
| 433 |
b'/' if keep_slash => out.push('/'), |
| 434 |
_ => out.push_str(&format!("%{b:02X}")), |
| 435 |
} |
| 436 |
} |
| 437 |
out |
| 438 |
} |
| 439 |
|
| 440 |
fn hmac(key: &[u8], data: &str) -> Vec<u8> { |
| 441 |
use hmac::Mac; |
| 442 |
let mut m = hmac::Hmac::<sha2::Sha256>::new_from_slice(key).expect("any key length"); |
| 443 |
m.update(data.as_bytes()); |
| 444 |
m.finalize().into_bytes().to_vec() |
| 445 |
} |
| 446 |
|
| 447 |
fn sha256_hex(data: &[u8]) -> String { |
| 448 |
use sha2::Digest; |
| 449 |
hex::encode(sha2::Sha256::digest(data)) |
| 450 |
} |
| 451 |
|
| 452 |
|
| 453 |
|
| 454 |
|
| 455 |
|
| 456 |
#[allow(clippy::too_many_arguments)] |
| 457 |
pub fn sigv4( |
| 458 |
method: &str, |
| 459 |
uri: &str, |
| 460 |
query: &[(String, String)], |
| 461 |
headers: &[(String, String)], |
| 462 |
payload: &str, |
| 463 |
(id, secret): (&str, &str), |
| 464 |
region: &str, |
| 465 |
service: &str, |
| 466 |
amz_date: &str, |
| 467 |
) -> String { |
| 468 |
let mut q: Vec<(String, String)> = query.iter().map(|(k, v)| (aws_encode(k, false), aws_encode(v, false))).collect(); |
| 469 |
q.sort(); |
| 470 |
let query = q.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&"); |
| 471 |
let mut h: Vec<(String, String)> = headers.iter().map(|(k, v)| (k.to_ascii_lowercase(), v.trim().to_string())).collect(); |
| 472 |
h.sort(); |
| 473 |
let canonical_headers: String = h.iter().map(|(k, v)| format!("{k}:{v}\n")).collect(); |
| 474 |
let signed = h.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>().join(";"); |
| 475 |
let request = format!("{method}\n{uri}\n{query}\n{canonical_headers}\n{signed}\n{payload}"); |
| 476 |
let date = &amz_date[..8]; |
| 477 |
let scope = format!("{date}/{region}/{service}/aws4_request"); |
| 478 |
let to_sign = format!("AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}", sha256_hex(request.as_bytes())); |
| 479 |
let key = hmac(&hmac(&hmac(&hmac(format!("AWS4{secret}").as_bytes(), date), region), service), "aws4_request"); |
| 480 |
let signature = hex::encode(hmac(&key, &to_sign)); |
| 481 |
format!("AWS4-HMAC-SHA256 Credential={id}/{scope}, SignedHeaders={signed}, Signature={signature}") |
| 482 |
} |
| 483 |
|
| 484 |
|
| 485 |
fn xml_text(xml: &str, tag: &str) -> Option<String> { |
| 486 |
xml_blocks(xml, tag).into_iter().next().map(xml_unescape) |
| 487 |
} |
| 488 |
|
| 489 |
|
| 490 |
fn xml_blocks<'a>(xml: &'a str, tag: &str) -> Vec<&'a str> { |
| 491 |
let (open, close) = (format!("<{tag}>"), format!("</{tag}>")); |
| 492 |
let mut out = Vec::new(); |
| 493 |
let mut rest = xml; |
| 494 |
while let Some(i) = rest.find(&open) { |
| 495 |
let after = &rest[i + open.len()..]; |
| 496 |
let Some(j) = after.find(&close) else { break }; |
| 497 |
out.push(&after[..j]); |
| 498 |
rest = &after[j + close.len()..]; |
| 499 |
} |
| 500 |
out |
| 501 |
} |
| 502 |
|
| 503 |
fn xml_unescape(s: &str) -> String { |
| 504 |
s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") |
| 505 |
} |
| 506 |
|
| 507 |
fn amz_date(ms: i64) -> String { |
| 508 |
let t = time::OffsetDateTime::from_unix_timestamp_nanos(ms as i128 * 1_000_000).unwrap_or(time::OffsetDateTime::UNIX_EPOCH); |
| 509 |
format!("{:04}{:02}{:02}T{:02}{:02}{:02}Z", t.year(), u8::from(t.month()), t.day(), t.hour(), t.minute(), t.second()) |
| 510 |
} |
| 511 |
|
| 512 |
impl S3 { |
| 513 |
pub fn new(app: &Shared, s: &Setting) -> S3 { |
| 514 |
let url = app.config.backups.s3_url.replace("{bucket}", &s.target).replace("{region}", &s.region); |
| 515 |
S3 { |
| 516 |
bucket: s.target.clone(), |
| 517 |
prefix: s.prefix.trim_matches('/').to_string(), |
| 518 |
region: s.region.clone(), |
| 519 |
base: reqwest::Url::parse(url.trim_end_matches('/')).unwrap_or_else(|_| reqwest::Url::parse("https://invalid.").unwrap()), |
| 520 |
sts: app.config.backups.aws_sts_url.trim_end_matches('/').to_string(), |
| 521 |
role: s.provider.clone(), |
| 522 |
subject: format!("backup:{}", s.owner), |
| 523 |
app: app.clone(), |
| 524 |
client: reqwest::Client::builder().timeout(std::time::Duration::from_secs(300)).build().unwrap_or_default(), |
| 525 |
rt: tokio::runtime::Handle::current(), |
| 526 |
creds: std::sync::Mutex::new(None), |
| 527 |
} |
| 528 |
} |
| 529 |
|
| 530 |
fn name(&self, key: &str) -> String { |
| 531 |
if self.prefix.is_empty() { |
| 532 |
key.to_string() |
| 533 |
} else { |
| 534 |
format!("{}/{key}", self.prefix) |
| 535 |
} |
| 536 |
} |
| 537 |
|
| 538 |
|
| 539 |
|
| 540 |
fn creds(&self) -> Result<Creds> { |
| 541 |
let now = db::now_ms(); |
| 542 |
if let Some(c) = self.creds.lock().unwrap().as_ref() { |
| 543 |
if c.until - now > 300_000 { |
| 544 |
return Ok(c.clone()); |
| 545 |
} |
| 546 |
} |
| 547 |
let session: String = format!("oxygenhub-{}", self.subject.trim_start_matches("backup:")) |
| 548 |
.chars() |
| 549 |
.filter(|c| c.is_ascii_alphanumeric() || "=,.@-_".contains(*c)) |
| 550 |
.take(64) |
| 551 |
.collect(); |
| 552 |
let c = self.rt.block_on(async { |
| 553 |
let issuer = self.app.oidc().await?; |
| 554 |
let jwt = issuer.mint_for(self.app.config.base_url(), &self.subject, AWS_AUDIENCE, db::now_ms(), 600); |
| 555 |
let res = self |
| 556 |
.client |
| 557 |
.post(format!("{}/", self.sts)) |
| 558 |
.form(&[ |
| 559 |
("Action", "AssumeRoleWithWebIdentity"), |
| 560 |
("Version", "2011-06-15"), |
| 561 |
("RoleArn", self.role.as_str()), |
| 562 |
("RoleSessionName", session.as_str()), |
| 563 |
("WebIdentityToken", jwt.as_str()), |
| 564 |
("DurationSeconds", "3600"), |
| 565 |
]) |
| 566 |
.send() |
| 567 |
.await?; |
| 568 |
let status = res.status(); |
| 569 |
let text = res.text().await.unwrap_or_default(); |
| 570 |
if !status.is_success() { |
| 571 |
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, |
| 575 |
xml_text(&text, "Message").unwrap_or_else(|| status.to_string()), |
| 576 |
self.subject |
| 577 |
); |
| 578 |
} |
| 579 |
let get = |t: &str| xml_text(&text, t).with_context(|| format!("AWS STS gave no {t}")); |
| 580 |
anyhow::Ok(Creds { id: get("AccessKeyId")?, secret: get("SecretAccessKey")?, token: get("SessionToken")?, until: now + 3_600_000 }) |
| 581 |
})?; |
| 582 |
*self.creds.lock().unwrap() = Some(c.clone()); |
| 583 |
Ok(c) |
| 584 |
} |
| 585 |
|
| 586 |
|
| 587 |
fn send( |
| 588 |
&self, |
| 589 |
method: reqwest::Method, |
| 590 |
key: Option<&str>, |
| 591 |
query: &[(String, String)], |
| 592 |
body: Vec<u8>, |
| 593 |
) -> Result<(reqwest::StatusCode, Vec<u8>)> { |
| 594 |
let c = self.creds()?; |
| 595 |
let base_path = self.base.path().trim_end_matches('/'); |
| 596 |
let uri = match key { |
| 597 |
Some(k) => format!("{base_path}/{}", aws_encode(&self.name(k), true)), |
| 598 |
None => format!("{base_path}/"), |
| 599 |
}; |
| 600 |
let host = match self.base.port() { |
| 601 |
Some(p) => format!("{}:{p}", self.base.host_str().unwrap_or_default()), |
| 602 |
None => self.base.host_str().unwrap_or_default().to_string(), |
| 603 |
}; |
| 604 |
let payload = sha256_hex(&body); |
| 605 |
let date = amz_date(db::now_ms()); |
| 606 |
let headers = vec![ |
| 607 |
("host".to_string(), host.clone()), |
| 608 |
("x-amz-content-sha256".to_string(), payload.clone()), |
| 609 |
("x-amz-date".to_string(), date.clone()), |
| 610 |
("x-amz-security-token".to_string(), c.token.clone()), |
| 611 |
]; |
| 612 |
let auth = sigv4(method.as_str(), &uri, query, &headers, &payload, (&c.id, &c.secret), &self.region, "s3", &date); |
| 613 |
let mut q: Vec<(String, String)> = query.iter().map(|(k, v)| (aws_encode(k, false), aws_encode(v, false))).collect(); |
| 614 |
q.sort(); |
| 615 |
let qs = q.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&"); |
| 616 |
let url = format!("{}://{host}{uri}{}{qs}", self.base.scheme(), if qs.is_empty() { "" } else { "?" }); |
| 617 |
self.rt.block_on(async { |
| 618 |
let mut req = self |
| 619 |
.client |
| 620 |
.request(method, &url) |
| 621 |
.header("authorization", auth) |
| 622 |
.header("x-amz-content-sha256", &payload) |
| 623 |
.header("x-amz-date", &date) |
| 624 |
.header("x-amz-security-token", &c.token); |
| 625 |
if !body.is_empty() { |
| 626 |
req = req.header("content-type", "application/octet-stream").body(body); |
| 627 |
} |
| 628 |
let res = req.send().await?; |
| 629 |
let status = res.status(); |
| 630 |
anyhow::Ok((status, res.bytes().await?.to_vec())) |
| 631 |
}) |
| 632 |
} |
| 633 |
|
| 634 |
fn refused(&self, what: &str, status: reqwest::StatusCode, body: &[u8]) -> anyhow::Error { |
| 635 |
let text = String::from_utf8_lossy(body); |
| 636 |
let hint = if status == reqwest::StatusCode::FORBIDDEN { |
| 637 |
format!(" (the role {} needs s3:GetObject, s3:PutObject, s3:DeleteObject and s3:ListBucket on {})", self.role, self.bucket) |
| 638 |
} else { |
| 639 |
String::new() |
| 640 |
}; |
| 641 |
anyhow::anyhow!("S3 {what}: {status}{hint}: {}", xml_text(&text, "Message").unwrap_or_else(|| text.chars().take(200).collect())) |
| 642 |
} |
| 643 |
|
| 644 |
|
| 645 |
fn listing(&self, full: &str, delimited: bool) -> Result<(Vec<String>, Vec<String>)> { |
| 646 |
let (mut keys, mut prefixes) = (Vec::new(), Vec::new()); |
| 647 |
let mut token: Option<String> = None; |
| 648 |
loop { |
| 649 |
let mut q = vec![("list-type".to_string(), "2".to_string()), ("prefix".to_string(), full.to_string())]; |
| 650 |
if delimited { |
| 651 |
q.push(("delimiter".to_string(), "/".to_string())); |
| 652 |
} |
| 653 |
if let Some(t) = &token { |
| 654 |
q.push(("continuation-token".to_string(), t.clone())); |
| 655 |
} |
| 656 |
let (status, body) = self.send(reqwest::Method::GET, None, &q, Vec::new())?; |
| 657 |
if !status.is_success() { |
| 658 |
return Err(self.refused("list", status, &body)); |
| 659 |
} |
| 660 |
let text = String::from_utf8_lossy(&body).into_owned(); |
| 661 |
for c in xml_blocks(&text, "Contents") { |
| 662 |
keys.extend(xml_text(c, "Key")); |
| 663 |
} |
| 664 |
for c in xml_blocks(&text, "CommonPrefixes") { |
| 665 |
prefixes.extend(xml_text(c, "Prefix")); |
| 666 |
} |
| 667 |
match (xml_text(&text, "IsTruncated").as_deref(), xml_text(&text, "NextContinuationToken")) { |
| 668 |
(Some("true"), Some(t)) => token = Some(t), |
| 669 |
_ => return Ok((keys, prefixes)), |
| 670 |
} |
| 671 |
} |
| 672 |
} |
| 673 |
} |
| 674 |
|
| 675 |
impl Destination for S3 { |
| 676 |
fn exists(&self, key: &str) -> Result<bool> { |
| 677 |
let (status, body) = self.send(reqwest::Method::HEAD, Some(key), &[], Vec::new())?; |
| 678 |
match status { |
| 679 |
s if s.is_success() => Ok(true), |
| 680 |
reqwest::StatusCode::NOT_FOUND => Ok(false), |
| 681 |
s => Err(self.refused("read", s, &body)), |
| 682 |
} |
| 683 |
} |
| 684 |
fn put(&self, key: &str, bytes: &[u8]) -> Result<()> { |
| 685 |
let (status, body) = self.send(reqwest::Method::PUT, Some(key), &[], bytes.to_vec())?; |
| 686 |
if !status.is_success() { |
| 687 |
return Err(self.refused("write", status, &body)); |
| 688 |
} |
| 689 |
Ok(()) |
| 690 |
} |
| 691 |
fn get(&self, key: &str) -> Result<Vec<u8>> { |
| 692 |
let (status, body) = self.send(reqwest::Method::GET, Some(key), &[], Vec::new())?; |
| 693 |
if !status.is_success() { |
| 694 |
return Err(self.refused(&format!("read {key}"), status, &body)); |
| 695 |
} |
| 696 |
Ok(body) |
| 697 |
} |
| 698 |
fn list(&self, prefix: &str) -> Result<Vec<String>> { |
| 699 |
let full = format!("{}/", self.name(prefix.trim_end_matches('/'))); |
| 700 |
let (keys, prefixes) = self.listing(&full, true)?; |
| 701 |
let mut out: std::collections::BTreeSet<String> = keys.iter().map(|k| k.trim_start_matches(&full).to_string()).collect(); |
| 702 |
out.extend(prefixes.iter().map(|p| p.trim_start_matches(&full).trim_end_matches('/').to_string())); |
| 703 |
Ok(out.into_iter().filter(|n| !n.is_empty()).collect()) |
| 704 |
} |
| 705 |
fn remove(&self, key: &str) -> Result<()> { |
| 706 |
let full = self.name(key); |
| 707 |
let (mut keys, _) = self.listing(&format!("{full}/"), false)?; |
| 708 |
keys.push(full); |
| 709 |
for k in keys { |
| 710 |
let rel = if self.prefix.is_empty() { k.clone() } else { k.trim_start_matches(&format!("{}/", self.prefix)).to_string() }; |
| 711 |
let (status, body) = self.send(reqwest::Method::DELETE, Some(&rel), &[], Vec::new())?; |
| 712 |
if !status.is_success() && status != reqwest::StatusCode::NOT_FOUND { |
| 713 |
return Err(self.refused("remove", status, &body)); |
| 714 |
} |
| 715 |
} |
| 716 |
Ok(()) |
| 717 |
} |
| 718 |
} |
| 719 |
|
| 720 |
impl Destination for Gcs { |
| 721 |
fn exists(&self, key: &str) -> Result<bool> { |
| 722 |
let token = self.access()?; |
| 723 |
let mut u = self.object_url(key)?; |
| 724 |
u.query_pairs_mut().append_pair("fields", "name"); |
| 725 |
self.rt.block_on(async { |
| 726 |
let res = self.client.get(u).bearer_auth(&token).send().await?; |
| 727 |
match res.status() { |
| 728 |
s if s.is_success() => Ok(true), |
| 729 |
reqwest::StatusCode::NOT_FOUND => Ok(false), |
| 730 |
s => Err(self.refused("read", s, &res.text().await.unwrap_or_default())), |
| 731 |
} |
| 732 |
}) |
| 733 |
} |
| 734 |
fn put(&self, key: &str, bytes: &[u8]) -> Result<()> { |
| 735 |
let token = self.access()?; |
| 736 |
let mut u = reqwest::Url::parse(&format!("{}/upload/storage/v1/b/{}/o", self.storage, self.bucket))?; |
| 737 |
u.query_pairs_mut().append_pair("uploadType", "media").append_pair("name", &self.name(key)); |
| 738 |
let body = bytes.to_vec(); |
| 739 |
self.rt.block_on(async { |
| 740 |
let res = self.client.post(u).bearer_auth(&token).header("content-type", "application/octet-stream").body(body).send().await?; |
| 741 |
let status = res.status(); |
| 742 |
if !status.is_success() { |
| 743 |
return Err(self.refused("write", status, &res.text().await.unwrap_or_default())); |
| 744 |
} |
| 745 |
Ok(()) |
| 746 |
}) |
| 747 |
} |
| 748 |
fn get(&self, key: &str) -> Result<Vec<u8>> { |
| 749 |
let token = self.access()?; |
| 750 |
let mut u = self.object_url(key)?; |
| 751 |
u.query_pairs_mut().append_pair("alt", "media"); |
| 752 |
self.rt.block_on(async { |
| 753 |
let res = self.client.get(u).bearer_auth(&token).send().await?; |
| 754 |
let status = res.status(); |
| 755 |
if !status.is_success() { |
| 756 |
return Err(self.refused(&format!("read {key}"), status, &res.text().await.unwrap_or_default())); |
| 757 |
} |
| 758 |
Ok(res.bytes().await?.to_vec()) |
| 759 |
}) |
| 760 |
} |
| 761 |
fn list(&self, prefix: &str) -> Result<Vec<String>> { |
| 762 |
let full = format!("{}/", self.name(prefix.trim_end_matches('/'))); |
| 763 |
let mut out = std::collections::BTreeSet::new(); |
| 764 |
let mut page: Option<String> = None; |
| 765 |
loop { |
| 766 |
let token = self.access()?; |
| 767 |
let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?; |
| 768 |
u.query_pairs_mut() |
| 769 |
.append_pair("prefix", &full) |
| 770 |
.append_pair("delimiter", "/") |
| 771 |
.append_pair("fields", "items(name),prefixes,nextPageToken"); |
| 772 |
if let Some(p) = &page { |
| 773 |
u.query_pairs_mut().append_pair("pageToken", p); |
| 774 |
} |
| 775 |
let body: serde_json::Value = self.rt.block_on(async { |
| 776 |
let res = self.client.get(u).bearer_auth(&token).send().await?; |
| 777 |
let status = res.status(); |
| 778 |
let text = res.text().await?; |
| 779 |
if !status.is_success() { |
| 780 |
return Err(self.refused("list", status, &text)); |
| 781 |
} |
| 782 |
anyhow::Ok(serde_json::from_str(&text)?) |
| 783 |
})?; |
| 784 |
for n in body["items"].as_array().into_iter().flatten().filter_map(|i| i["name"].as_str()) { |
| 785 |
out.insert(n.trim_start_matches(&full).to_string()); |
| 786 |
} |
| 787 |
for p in body["prefixes"].as_array().into_iter().flatten().filter_map(|p| p.as_str()) { |
| 788 |
out.insert(p.trim_start_matches(&full).trim_end_matches('/').to_string()); |
| 789 |
} |
| 790 |
match body["nextPageToken"].as_str() { |
| 791 |
Some(p) => page = Some(p.to_string()), |
| 792 |
None => return Ok(out.into_iter().filter(|n| !n.is_empty()).collect()), |
| 793 |
} |
| 794 |
} |
| 795 |
} |
| 796 |
fn remove(&self, key: &str) -> Result<()> { |
| 797 |
let mut names = self.all_under(&format!("{}/", self.name(key)))?; |
| 798 |
names.push(self.name(key)); |
| 799 |
for n in names { |
| 800 |
let token = self.access()?; |
| 801 |
let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?; |
| 802 |
u.path_segments_mut().map_err(|_| anyhow::anyhow!("bad storage URL"))?.push(&n); |
| 803 |
self.rt.block_on(async { |
| 804 |
let res = self.client.delete(u).bearer_auth(&token).send().await?; |
| 805 |
match res.status() { |
| 806 |
s if s.is_success() || s == reqwest::StatusCode::NOT_FOUND => Ok(()), |
| 807 |
s => Err(self.refused("delete", s, &res.text().await.unwrap_or_default())), |
| 808 |
} |
| 809 |
})?; |
| 810 |
} |
| 811 |
Ok(()) |
| 812 |
} |
| 813 |
} |
| 814 |
|
| 815 |
|
| 816 |
pub fn probe(dest: &dyn Destination) -> Result<()> { |
| 817 |
let marker = format!("probe {}", db::now_ms()); |
| 818 |
dest.put(".oxyhub-probe", marker.as_bytes())?; |
| 819 |
if dest.get(".oxyhub-probe")? != marker.as_bytes() { |
| 820 |
bail!("the probe read back differently"); |
| 821 |
} |
| 822 |
dest.remove(".oxyhub-probe") |
| 823 |
} |
| 824 |
|
| 825 |
|
| 826 |
pub fn destination(app: &Shared, s: &Setting) -> Result<Box<dyn Destination>> { |
| 827 |
match s.destination.as_str() { |
| 828 |
"dir" => { |
| 829 |
let allowed = &app.config.backups.directories; |
| 830 |
let target = PathBuf::from(&s.target); |
| 831 |
if !allowed.contains(&target) { |
| 832 |
bail!("{} is not a backup directory this hub allows (hub.toml [backups] directories)", s.target); |
| 833 |
} |
| 834 |
let mut root = target.join(&s.owner); |
| 835 |
if !s.prefix.is_empty() { |
| 836 |
root = root.join(s.prefix.trim_matches('/')); |
| 837 |
} |
| 838 |
Ok(Box::new(Directory { root })) |
| 839 |
} |
| 840 |
"gcs" => { |
| 841 |
if s.target.is_empty() || s.provider.is_empty() { |
| 842 |
bail!("a GCS destination needs its bucket and its workload identity provider"); |
| 843 |
} |
| 844 |
Ok(Box::new(Gcs::new(app, s))) |
| 845 |
} |
| 846 |
"s3" => { |
| 847 |
if s.target.is_empty() || s.provider.is_empty() || s.region.is_empty() { |
| 848 |
bail!("an S3 destination needs its bucket, its region and the IAM role the hub assumes"); |
| 849 |
} |
| 850 |
Ok(Box::new(S3::new(app, s))) |
| 851 |
} |
| 852 |
other => bail!("unknown destination {other:?}"), |
| 853 |
} |
| 854 |
} |
| 855 |
|
| 856 |
|
| 857 |
|
| 858 |
|
| 859 |
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] |
| 860 |
pub struct File { |
| 861 |
pub path: String, |
| 862 |
pub size: u64, |
| 863 |
|
| 864 |
|
| 865 |
pub mtime: i128, |
| 866 |
pub hash: String, |
| 867 |
} |
| 868 |
|
| 869 |
|
| 870 |
#[derive(Clone, Debug, Serialize, Deserialize)] |
| 871 |
pub struct Manifest { |
| 872 |
pub owner: String, |
| 873 |
pub name: String, |
| 874 |
|
| 875 |
pub kind: String, |
| 876 |
pub files: Vec<File>, |
| 877 |
} |
| 878 |
|
| 879 |
fn blob_key(hash: &str) -> String { |
| 880 |
format!("blobs/{}/{hash}", &hash[..2]) |
| 881 |
} |
| 882 |
|
| 883 |
|
| 884 |
fn immutable(path: &str) -> bool { |
| 885 |
path.starts_with(".oxy/objects/") && (path.contains("/pack-") || path.contains("/loose/")) |
| 886 |
} |
| 887 |
|
| 888 |
#[derive(Default)] |
| 889 |
struct Sent { |
| 890 |
files: i64, |
| 891 |
sent: i64, |
| 892 |
bytes: i64, |
| 893 |
} |
| 894 |
|
| 895 |
|
| 896 |
|
| 897 |
fn copy_dir(dest: &dyn Destination, dir: &Path, known: &HashMap<String, File>, only_immutable: bool, sent: &mut Sent) -> Result<Vec<File>> { |
| 898 |
let mut out = Vec::new(); |
| 899 |
let mut stack = vec![dir.to_path_buf()]; |
| 900 |
while let Some(d) = stack.pop() { |
| 901 |
let entries = match std::fs::read_dir(&d) { |
| 902 |
Ok(e) => e, |
| 903 |
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, |
| 904 |
Err(e) => return Err(e.into()), |
| 905 |
}; |
| 906 |
for e in entries.flatten() { |
| 907 |
let p = e.path(); |
| 908 |
let ft = e.file_type()?; |
| 909 |
if ft.is_dir() { |
| 910 |
stack.push(p); |
| 911 |
continue; |
| 912 |
} |
| 913 |
if !ft.is_file() { |
| 914 |
continue; |
| 915 |
} |
| 916 |
let rel = p.strip_prefix(dir).unwrap_or(&p).to_string_lossy().replace('\\', "/"); |
| 917 |
if only_immutable && !immutable(&rel) { |
| 918 |
continue; |
| 919 |
} |
| 920 |
|
| 921 |
let Ok(md) = std::fs::metadata(&p) else { continue }; |
| 922 |
let mtime = |
| 923 |
md.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_nanos() as i128).unwrap_or(0); |
| 924 |
let size = md.len(); |
| 925 |
sent.files += 1; |
| 926 |
if let Some(k) = known.get(&rel).filter(|k| k.size == size && k.mtime == mtime) { |
| 927 |
out.push(k.clone()); |
| 928 |
continue; |
| 929 |
} |
| 930 |
let Ok(bytes) = std::fs::read(&p) else { continue }; |
| 931 |
let hash = blake3::hash(&bytes).to_hex().to_string(); |
| 932 |
let key = blob_key(&hash); |
| 933 |
if !dest.exists(&key)? { |
| 934 |
dest.put(&key, &bytes)?; |
| 935 |
sent.sent += 1; |
| 936 |
sent.bytes += bytes.len() as i64; |
| 937 |
} |
| 938 |
out.push(File { path: rel, size, mtime, hash }); |
| 939 |
} |
| 940 |
} |
| 941 |
out.sort_by(|a, b| a.path.cmp(&b.path)); |
| 942 |
Ok(out) |
| 943 |
} |
| 944 |
|
| 945 |
|
| 946 |
|
| 947 |
|
| 948 |
fn private_table(t: &str) -> bool { |
| 949 |
["secret", "token", "session", "password", "totp", "recovery", "sqlx"].iter().any(|w| t.contains(w)) |
| 950 |
} |
| 951 |
|
| 952 |
fn private_column(c: &str) -> bool { |
| 953 |
["secret", "token", "password", "salt", "totp", "sealed", "hash_key"].iter().any(|w| c.contains(w)) |
| 954 |
} |
| 955 |
|
| 956 |
async fn tables(pool: &Pool) -> Result<Vec<String>> { |
| 957 |
let mut t: Vec<String> = |
| 958 |
sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").fetch_all(pool).await?; |
| 959 |
t.retain(|t| !private_table(t)); |
| 960 |
t.sort(); |
| 961 |
Ok(t) |
| 962 |
} |
| 963 |
|
| 964 |
async fn columns(pool: &Pool, table: &str) -> Result<Vec<String>> { |
| 965 |
Ok(sqlx::query_scalar(&format!("SELECT name FROM pragma_table_info('{table}')")).fetch_all(pool).await?) |
| 966 |
} |
| 967 |
|
| 968 |
|
| 969 |
|
| 970 |
async fn rows(pool: &Pool, table: &str, cond: &str) -> Result<serde_json::Value> { |
| 971 |
let cols = columns(pool, table).await?; |
| 972 |
let fields: Vec<String> = cols |
| 973 |
.iter() |
| 974 |
.filter(|c| !private_column(c)) |
| 975 |
.map(|c| format!("'{c}', CASE WHEN typeof(\"{c}\") = 'blob' THEN hex(\"{c}\") ELSE \"{c}\" END")) |
| 976 |
.collect(); |
| 977 |
if fields.is_empty() { |
| 978 |
return Ok(serde_json::json!([])); |
| 979 |
} |
| 980 |
let sql = format!("SELECT json_group_array(json_object({})) FROM \"{table}\" WHERE {cond}", fields.join(", ")); |
| 981 |
let text: String = sqlx::query_scalar(&sql).fetch_one(pool).await?; |
| 982 |
Ok(serde_json::from_str(&text)?) |
| 983 |
} |
| 984 |
|
| 985 |
|
| 986 |
|
| 987 |
|
| 988 |
|
| 989 |
async fn records(pool: &Pool, key: &str, id: i64) -> Result<serde_json::Value> { |
| 990 |
let all = tables(pool).await?; |
| 991 |
let mut conds: Vec<(String, String)> = Vec::new(); |
| 992 |
for t in &all { |
| 993 |
if columns(pool, t).await?.iter().any(|c| c == key) { |
| 994 |
conds.push((t.clone(), format!("\"{key}\" = {id}"))); |
| 995 |
} |
| 996 |
} |
| 997 |
for _ in 0..3 { |
| 998 |
let mut more = Vec::new(); |
| 999 |
for t in &all { |
| 1000 |
if conds.iter().any(|(c, _)| c == t) || more.iter().any(|(c, _): &(String, String)| c == t) { |
| 1001 |
continue; |
| 1002 |
} |
| 1003 |
let fks: Vec<(String, String, String)> = |
| 1004 |
sqlx::query_as(&format!("SELECT \"table\", \"from\", \"to\" FROM pragma_foreign_key_list('{t}')")).fetch_all(pool).await?; |
| 1005 |
if let Some((parent, from, to, pcond)) = fks |
| 1006 |
.iter() |
| 1007 |
.find_map(|(p, f, to)| conds.iter().find(|(c, _)| c == p).map(|(_, pc)| (p.clone(), f.clone(), to.clone(), pc.clone()))) |
| 1008 |
{ |
| 1009 |
more.push((t.clone(), format!("\"{from}\" IN (SELECT \"{to}\" FROM \"{parent}\" WHERE {pcond})"))); |
| 1010 |
} |
| 1011 |
} |
| 1012 |
if more.is_empty() { |
| 1013 |
break; |
| 1014 |
} |
| 1015 |
conds.extend(more); |
| 1016 |
} |
| 1017 |
let mut out = serde_json::Map::new(); |
| 1018 |
for (t, cond) in conds { |
| 1019 |
let r = rows(pool, &t, &cond).await?; |
| 1020 |
if r.as_array().is_some_and(|a| !a.is_empty()) { |
| 1021 |
out.insert(t, r); |
| 1022 |
} |
| 1023 |
} |
| 1024 |
Ok(serde_json::Value::Object(out)) |
| 1025 |
} |
| 1026 |
|
| 1027 |
|
| 1028 |
|
| 1029 |
const NAMES: &str = "_names"; |
| 1030 |
|
| 1031 |
|
| 1032 |
|
| 1033 |
|
| 1034 |
const NOT_RESTORED: [&str; 5] = ["search", "notifications", "deliveries", "flow_declines", NAMES]; |
| 1035 |
|
| 1036 |
async fn fk_list(pool: &Pool, table: &str) -> Result<Vec<(String, String, String)>> { |
| 1037 |
Ok(sqlx::query_as(&format!("SELECT \"table\", \"from\", \"to\" FROM pragma_foreign_key_list('{table}')")).fetch_all(pool).await?) |
| 1038 |
} |
| 1039 |
|
| 1040 |
|
| 1041 |
|
| 1042 |
async fn names_of(pool: &Pool, rec: &serde_json::Value) -> Result<serde_json::Value> { |
| 1043 |
let mut ids: HashMap<String, std::collections::BTreeSet<i64>> = HashMap::new(); |
| 1044 |
for (table, rows) in rec.as_object().into_iter().flatten() { |
| 1045 |
let Some(rows) = rows.as_array() else { continue }; |
| 1046 |
for (parent, from, _) in fk_list(pool, table).await? { |
| 1047 |
if ["accounts", "teams", "organisations"].contains(&parent.as_str()) { |
| 1048 |
ids.entry(parent).or_default().extend(rows.iter().filter_map(|r| r[&from].as_i64())); |
| 1049 |
} |
| 1050 |
} |
| 1051 |
} |
| 1052 |
let mut out = serde_json::Map::new(); |
| 1053 |
for (kind, sql) in [ |
| 1054 |
("accounts", "SELECT id, username FROM accounts WHERE id = $1"), |
| 1055 |
("organisations", "SELECT id, name FROM organisations WHERE id = $1"), |
| 1056 |
("teams", "SELECT t.id, o.name || '/' || t.name FROM teams t JOIN organisations o ON o.id = t.org_id WHERE t.id = $1"), |
| 1057 |
] { |
| 1058 |
let mut m = serde_json::Map::new(); |
| 1059 |
for id in ids.get(kind).into_iter().flatten() { |
| 1060 |
if let Some((id, name)) = sqlx::query_as::<_, (i64, String)>(sql).bind(id).fetch_optional(pool).await? { |
| 1061 |
m.insert(id.to_string(), name.into()); |
| 1062 |
} |
| 1063 |
} |
| 1064 |
out.insert(kind.into(), m.into()); |
| 1065 |
} |
| 1066 |
Ok(out.into()) |
| 1067 |
} |
| 1068 |
|
| 1069 |
|
| 1070 |
async fn id_by_name(pool: &Pool, kind: &str, name: &str) -> Result<Option<i64>> { |
| 1071 |
Ok(match kind { |
| 1072 |
"accounts" => sqlx::query_scalar("SELECT id FROM accounts WHERE username = $1").bind(name).fetch_optional(pool).await?, |
| 1073 |
"organisations" => sqlx::query_scalar("SELECT id FROM organisations WHERE name = $1").bind(name).fetch_optional(pool).await?, |
| 1074 |
"teams" => match name.split_once('/') { |
| 1075 |
Some((org, team)) => { |
| 1076 |
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") |
| 1077 |
.bind(org) |
| 1078 |
.bind(team) |
| 1079 |
.fetch_optional(pool) |
| 1080 |
.await? |
| 1081 |
} |
| 1082 |
None => None, |
| 1083 |
}, |
| 1084 |
_ => None, |
| 1085 |
}) |
| 1086 |
} |
| 1087 |
|
| 1088 |
|
| 1089 |
struct Col { |
| 1090 |
name: String, |
| 1091 |
ty: String, |
| 1092 |
notnull: bool, |
| 1093 |
default: Option<String>, |
| 1094 |
pk: i64, |
| 1095 |
} |
| 1096 |
|
| 1097 |
async fn table_cols(pool: &Pool, table: &str) -> Result<Vec<Col>> { |
| 1098 |
let rows: Vec<(String, String, i64, Option<String>, i64)> = |
| 1099 |
sqlx::query_as(&format!("SELECT name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('{table}')")) |
| 1100 |
.fetch_all(pool) |
| 1101 |
.await?; |
| 1102 |
Ok(rows.into_iter().map(|(name, ty, notnull, default, pk)| Col { name, ty, notnull: notnull != 0, default, pk }).collect()) |
| 1103 |
} |
| 1104 |
|
| 1105 |
|
| 1106 |
fn bind_json<'q>( |
| 1107 |
q: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>, |
| 1108 |
v: serde_json::Value, |
| 1109 |
blob: bool, |
| 1110 |
) -> sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>> { |
| 1111 |
match v { |
| 1112 |
serde_json::Value::Null => q.bind(None::<i64>), |
| 1113 |
serde_json::Value::Bool(b) => q.bind(b as i64), |
| 1114 |
serde_json::Value::Number(n) => match n.as_i64() { |
| 1115 |
Some(i) => q.bind(i), |
| 1116 |
None => q.bind(n.as_f64().unwrap_or(0.0)), |
| 1117 |
}, |
| 1118 |
serde_json::Value::String(s) if blob => q.bind(hex::decode(&s).unwrap_or_else(|_| s.into_bytes())), |
| 1119 |
serde_json::Value::String(s) => q.bind(s), |
| 1120 |
other => q.bind(other.to_string()), |
| 1121 |
} |
| 1122 |
} |
| 1123 |
|
| 1124 |
|
| 1125 |
|
| 1126 |
|
| 1127 |
|
| 1128 |
|
| 1129 |
|
| 1130 |
|
| 1131 |
|
| 1132 |
|
| 1133 |
async fn restore_records( |
| 1134 |
app: &Shared, |
| 1135 |
dest: &std::sync::Arc<dyn Destination>, |
| 1136 |
rec: &serde_json::Value, |
| 1137 |
old: i64, |
| 1138 |
repo: i64, |
| 1139 |
) -> Result<Vec<String>> { |
| 1140 |
let pool = &app.db; |
| 1141 |
let obj = rec.as_object().context("the records are not an object")?; |
| 1142 |
let mut by_name: HashMap<(String, i64), Option<i64>> = HashMap::new(); |
| 1143 |
for (kind, m) in obj.get(NAMES).and_then(|n| n.as_object()).into_iter().flatten() { |
| 1144 |
for (id, name) in m.as_object().into_iter().flatten() { |
| 1145 |
let (Ok(id), Some(name)) = (id.parse::<i64>(), name.as_str()) else { continue }; |
| 1146 |
by_name.insert((kind.clone(), id), id_by_name(pool, kind, name).await?); |
| 1147 |
} |
| 1148 |
} |
| 1149 |
let here: std::collections::HashSet<String> = tables(pool).await?.into_iter().collect(); |
| 1150 |
let mut notes = Vec::new(); |
| 1151 |
let mut todo: Vec<String> = Vec::new(); |
| 1152 |
for t in obj.keys().filter(|t| t.as_str() != "repositories" && !NOT_RESTORED.contains(&t.as_str())) { |
| 1153 |
if here.contains(t) { |
| 1154 |
todo.push(t.clone()); |
| 1155 |
} else { |
| 1156 |
notes.push(format!("{t}: not a table on this hub, left out")); |
| 1157 |
} |
| 1158 |
} |
| 1159 |
let mut fks: HashMap<String, Vec<(String, String, String)>> = HashMap::new(); |
| 1160 |
for t in &todo { |
| 1161 |
fks.insert(t.clone(), fk_list(pool, t).await?); |
| 1162 |
} |
| 1163 |
|
| 1164 |
let mut order = Vec::new(); |
| 1165 |
while !todo.is_empty() { |
| 1166 |
let mut ready: Vec<String> = |
| 1167 |
todo.iter().filter(|t| fks[*t].iter().all(|(p, _, _)| p == *t || !todo.contains(p))).cloned().collect(); |
| 1168 |
if ready.is_empty() { |
| 1169 |
ready.push(todo[0].clone()); |
| 1170 |
} |
| 1171 |
todo.retain(|t| !ready.contains(t)); |
| 1172 |
order.extend(ready); |
| 1173 |
} |
| 1174 |
let mut maps: HashMap<String, HashMap<i64, i64>> = HashMap::new(); |
| 1175 |
maps.insert("repositories".into(), [(old, repo)].into()); |
| 1176 |
let mut left_out: std::collections::BTreeMap<String, usize> = Default::default(); |
| 1177 |
let mut tx = pool.begin().await?; |
| 1178 |
for table in &order { |
| 1179 |
let cols = table_cols(pool, table).await?; |
| 1180 |
let keys: Vec<&str> = cols.iter().filter(|c| c.pk > 0).map(|c| c.name.as_str()).collect(); |
| 1181 |
let by_id = keys == ["id"] && cols.iter().any(|c| c.name == "id" && c.ty.eq_ignore_ascii_case("INTEGER")); |
| 1182 |
let verb = if keys == ["repo_id"] { |
| 1183 |
"INSERT OR REPLACE" |
| 1184 |
} else if by_id { |
| 1185 |
"INSERT" |
| 1186 |
} else { |
| 1187 |
"INSERT OR IGNORE" |
| 1188 |
}; |
| 1189 |
let mut rows: Vec<&serde_json::Map<String, serde_json::Value>> = |
| 1190 |
obj[table].as_array().into_iter().flatten().filter_map(|r| r.as_object()).collect(); |
| 1191 |
rows.sort_by_key(|r| r.get("id").and_then(|v| v.as_i64()).unwrap_or(0)); |
| 1192 |
'row: for row in rows { |
| 1193 |
let (mut names, mut values) = (Vec::new(), Vec::new()); |
| 1194 |
for c in &cols { |
| 1195 |
if by_id && c.name == "id" { |
| 1196 |
continue; |
| 1197 |
} |
| 1198 |
let mut v = row.get(&c.name).cloned().filter(|v| !v.is_null()); |
| 1199 |
if let Some((parent, _, _)) = fks[table].iter().find(|(_, from, _)| *from == c.name) { |
| 1200 |
if let Some(was) = v.as_ref().and_then(|v| v.as_i64()) { |
| 1201 |
let now = match parent.as_str() { |
| 1202 |
"accounts" | "teams" | "organisations" => by_name.get(&(parent.clone(), was)).copied().flatten(), |
| 1203 |
p => maps.get(p).and_then(|m| m.get(&was)).copied(), |
| 1204 |
}; |
| 1205 |
if now.is_none() && c.notnull { |
| 1206 |
*left_out.entry(table.clone()).or_default() += 1; |
| 1207 |
continue 'row; |
| 1208 |
} |
| 1209 |
v = now.map(serde_json::Value::from); |
| 1210 |
} |
| 1211 |
} |
| 1212 |
let blob = c.ty.to_ascii_uppercase().contains("BLOB"); |
| 1213 |
match v { |
| 1214 |
Some(v) => { |
| 1215 |
names.push(c.name.clone()); |
| 1216 |
values.push((v, blob)); |
| 1217 |
} |
| 1218 |
|
| 1219 |
None if c.notnull && c.default.is_none() => { |
| 1220 |
names.push(c.name.clone()); |
| 1221 |
let blank = if c.ty.to_ascii_uppercase().contains("INT") { serde_json::json!(0) } else { serde_json::json!("") }; |
| 1222 |
values.push((blank, false)); |
| 1223 |
} |
| 1224 |
None => {} |
| 1225 |
} |
| 1226 |
} |
| 1227 |
let sql = format!( |
| 1228 |
"{verb} INTO \"{table}\" ({}) VALUES ({})", |
| 1229 |
names.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join(", "), |
| 1230 |
(1..=names.len()).map(|i| format!("${i}")).collect::<Vec<_>>().join(", ") |
| 1231 |
); |
| 1232 |
let mut q = sqlx::query(&sql); |
| 1233 |
for (v, blob) in values { |
| 1234 |
q = bind_json(q, v, blob); |
| 1235 |
} |
| 1236 |
let done = q.execute(&mut *tx).await.with_context(|| format!("restore a row of {table}"))?; |
| 1237 |
if let (true, Some(was)) = (by_id, row.get("id").and_then(|v| v.as_i64())) { |
| 1238 |
maps.entry(table.clone()).or_default().insert(was, done.last_insert_rowid()); |
| 1239 |
} |
| 1240 |
} |
| 1241 |
} |
| 1242 |
tx.commit().await?; |
| 1243 |
for (table, n) in left_out { |
| 1244 |
notes.push(format!("{table}: {n} row(s) left out: they name accounts or rows this hub lacks")); |
| 1245 |
} |
| 1246 |
|
| 1247 |
for (kind, table) in [("issue", "issues"), ("proposal", "proposals")] { |
| 1248 |
let found: Vec<(i64, i64, String, String)> = |
| 1249 |
sqlx::query_as(&format!("SELECT id, number, title, body FROM {table} WHERE repo_id = $1")).bind(repo).fetch_all(pool).await?; |
| 1250 |
for (id, number, title, body) in found { |
| 1251 |
db::notify::index(pool, kind, repo, id, number, &title, &body).await?; |
| 1252 |
} |
| 1253 |
} |
| 1254 |
|
| 1255 |
let hooks: Vec<String> = |
| 1256 |
sqlx::query_scalar("SELECT url FROM hooks WHERE repo_id = $1 AND secret = ''").bind(repo).fetch_all(pool).await?; |
| 1257 |
for url in hooks { |
| 1258 |
notes.push(format!("webhook {url}: enter its secret again (Settings › Webhooks)")); |
| 1259 |
} |
| 1260 |
|
| 1261 |
for hash in obj.get("release_assets").and_then(|a| a.as_array()).into_iter().flatten().filter_map(|a| a["hash"].as_str()) { |
| 1262 |
if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { |
| 1263 |
continue; |
| 1264 |
} |
| 1265 |
let path = crate::web::releases::blob_path(app, hash); |
| 1266 |
if path.exists() { |
| 1267 |
continue; |
| 1268 |
} |
| 1269 |
let got = tokio::task::block_in_place(|| dest.get(&blob_key(hash))); |
| 1270 |
match got { |
| 1271 |
Ok(bytes) if blake3::hash(&bytes).to_hex().as_str() == hash => { |
| 1272 |
std::fs::create_dir_all(path.parent().context("no parent")?)?; |
| 1273 |
std::fs::write(&path, &bytes)?; |
| 1274 |
} |
| 1275 |
Ok(_) => notes.push(format!("release asset {}: the copy does not match its hash", &hash[..12])), |
| 1276 |
Err(_) => notes.push(format!("release asset {}: not in the backup", &hash[..12])), |
| 1277 |
} |
| 1278 |
} |
| 1279 |
Ok(notes) |
| 1280 |
} |
| 1281 |
|
| 1282 |
|
| 1283 |
|
| 1284 |
fn run_name(ms: i64) -> String { |
| 1285 |
let t = time::OffsetDateTime::from_unix_timestamp_nanos(ms as i128 * 1_000_000).unwrap_or(time::OffsetDateTime::UNIX_EPOCH); |
| 1286 |
format!("{:04}{:02}{:02}T{:02}{:02}{:02}Z", t.year(), u8::from(t.month()), t.day(), t.hour(), t.minute(), t.second()) |
| 1287 |
} |
| 1288 |
|
| 1289 |
|
| 1290 |
fn last_manifests(dest: &dyn Destination) -> HashMap<String, HashMap<String, File>> { |
| 1291 |
let mut out = HashMap::new(); |
| 1292 |
let Ok(at) = dest.get("LATEST").map(|b| String::from_utf8_lossy(&b).trim().to_string()) else { return out }; |
| 1293 |
for name in dest.list(&format!("runs/{at}/repos")).unwrap_or_default() { |
| 1294 |
if let Ok(m) = dest.get(&format!("runs/{at}/repos/{name}")).and_then(|b| Ok(serde_json::from_slice::<Manifest>(&b)?)) { |
| 1295 |
out.insert(format!("{}/{}", m.kind, m.name), m.files.into_iter().map(|f| (f.path.clone(), f)).collect()); |
| 1296 |
} |
| 1297 |
} |
| 1298 |
out |
| 1299 |
} |
| 1300 |
|
| 1301 |
|
| 1302 |
|
| 1303 |
pub async fn run(app: &Shared, owner: &str, trigger: &str) -> Result<Run> { |
| 1304 |
let s = setting(&app.db, owner).await?.with_context(|| format!("{owner} has no backup set up"))?; |
| 1305 |
let started = db::now_ms(); |
| 1306 |
let at = run_name(started); |
| 1307 |
let id: i64 = sqlx::query_scalar( |
| 1308 |
"INSERT INTO backup_runs (owner, started_ms, status, at, trigger) VALUES ($1, $2, 'running', $3, $4) RETURNING id", |
| 1309 |
) |
| 1310 |
.bind(owner) |
| 1311 |
.bind(started) |
| 1312 |
.bind(&at) |
| 1313 |
.bind(trigger) |
| 1314 |
.fetch_one(&app.db) |
| 1315 |
.await?; |
| 1316 |
let outcome = run_into(app, &s, &at, trigger == "now").await; |
| 1317 |
let (status, detail, sent) = match outcome { |
| 1318 |
Ok((sent, problems)) if problems.is_empty() => ("ok", String::new(), sent), |
| 1319 |
Ok((sent, problems)) => ("partial", problems.join("; "), sent), |
| 1320 |
Err(e) => ("failed", format!("{e:#}"), (0, Sent::default())), |
| 1321 |
}; |
| 1322 |
sqlx::query( |
| 1323 |
"UPDATE backup_runs SET finished_ms = $1, status = $2, detail = $3, repos = $4, files = $5, sent = $6, bytes = $7 WHERE id = $8", |
| 1324 |
) |
| 1325 |
.bind(db::now_ms()) |
| 1326 |
.bind(status) |
| 1327 |
.bind(&detail) |
| 1328 |
.bind(sent.0) |
| 1329 |
.bind(sent.1.files) |
| 1330 |
.bind(sent.1.sent) |
| 1331 |
.bind(sent.1.bytes) |
| 1332 |
.bind(id) |
| 1333 |
.execute(&app.db) |
| 1334 |
.await?; |
| 1335 |
let r: Run = sqlx::query_as("SELECT * FROM backup_runs WHERE id = $1").bind(id).fetch_one(&app.db).await?; |
| 1336 |
if status != "ok" { |
| 1337 |
notify_failure(app, &s, status, &detail).await; |
| 1338 |
} else { |
| 1339 |
notify_recovered(app, &s, &r).await; |
| 1340 |
} |
| 1341 |
Ok(r) |
| 1342 |
} |
| 1343 |
|
| 1344 |
async fn run_into(app: &Shared, s: &Setting, at: &str, by_hand: bool) -> Result<((i64, Sent), Vec<String>)> { |
| 1345 |
let dest: std::sync::Arc<dyn Destination> = destination(app, s)?.into(); |
| 1346 |
let known = { |
| 1347 |
let d = dest.clone(); |
| 1348 |
tokio::task::spawn_blocking(move || last_manifests(d.as_ref())).await? |
| 1349 |
}; |
| 1350 |
|
| 1351 |
let mine: Vec<db::repos::RepoRow> = |
| 1352 |
db::repos::list(&app.db).await?.into_iter().filter(|r| r.owner == s.owner && included(s, r, by_hand)).collect(); |
| 1353 |
let mut problems = Vec::new(); |
| 1354 |
let mut sent = Sent::default(); |
| 1355 |
let mut repos = 0i64; |
| 1356 |
for row in &mine { |
| 1357 |
let Some(name) = RepoName::new(&row.owner, &row.name) else { continue }; |
| 1358 |
for (kind, store) in [("repos", &app.repos), ("wikis", &app.wikis)] { |
| 1359 |
if !store.exists(&name) { |
| 1360 |
continue; |
| 1361 |
} |
| 1362 |
let dir = store.dir(&name); |
| 1363 |
let k = known.get(&format!("{kind}/{}", row.name)).cloned().unwrap_or_default(); |
| 1364 |
|
| 1365 |
|
| 1366 |
let (d1, dir1, k1) = (dest.clone(), dir.clone(), k.clone()); |
| 1367 |
let first = tokio::task::spawn_blocking(move || { |
| 1368 |
let mut s = Sent::default(); |
| 1369 |
copy_dir(d1.as_ref(), &dir1, &k1, true, &mut s).map(|f| (f, s)) |
| 1370 |
}) |
| 1371 |
.await?; |
| 1372 |
let mut k2 = k; |
| 1373 |
if let Ok((files, _)) = &first { |
| 1374 |
for f in files { |
| 1375 |
k2.insert(f.path.clone(), f.clone()); |
| 1376 |
} |
| 1377 |
} |
| 1378 |
let guard = store.read_guard(&name).await; |
| 1379 |
let (d2, dir2) = (dest.clone(), dir.clone()); |
| 1380 |
let second = tokio::task::spawn_blocking(move || { |
| 1381 |
let _guard = guard; |
| 1382 |
let mut s = Sent::default(); |
| 1383 |
copy_dir(d2.as_ref(), &dir2, &k2, false, &mut s).map(|f| (f, s)) |
| 1384 |
}) |
| 1385 |
.await?; |
| 1386 |
match second { |
| 1387 |
Ok((files, s2)) => { |
| 1388 |
let s1 = first.map(|(_, s)| s).unwrap_or_default(); |
| 1389 |
sent.files += s2.files; |
| 1390 |
sent.sent += s1.sent + s2.sent; |
| 1391 |
sent.bytes += s1.bytes + s2.bytes; |
| 1392 |
let m = Manifest { owner: row.owner.clone(), name: row.name.clone(), kind: kind.into(), files }; |
| 1393 |
let key = format!("runs/{at}/repos/{kind}-{}.json", row.name); |
| 1394 |
tokio::task::block_in_place(|| dest.put(&key, &serde_json::to_vec(&m)?))?; |
| 1395 |
if kind == "repos" { |
| 1396 |
repos += 1; |
| 1397 |
} |
| 1398 |
} |
| 1399 |
Err(e) => problems.push(format!("{}/{} ({kind}): {e:#}", row.owner, row.name)), |
| 1400 |
} |
| 1401 |
} |
| 1402 |
let rec = async { |
| 1403 |
let mut r = records(&app.db, "repo_id", row.id).await?; |
| 1404 |
|
| 1405 |
|
| 1406 |
|
| 1407 |
let own = rows(&app.db, "repositories", &format!("id = {}", row.id)).await?; |
| 1408 |
let names = names_of(&app.db, &r).await?; |
| 1409 |
if let Some(m) = r.as_object_mut() { |
| 1410 |
m.insert("repositories".into(), own); |
| 1411 |
m.insert(NAMES.into(), names); |
| 1412 |
} |
| 1413 |
anyhow::Ok(r) |
| 1414 |
}; |
| 1415 |
match rec.await { |
| 1416 |
Ok(r) => { |
| 1417 |
tokio::task::block_in_place(|| dest.put(&format!("runs/{at}/records/{}.json", row.name), &serde_json::to_vec(&r)?))?; |
| 1418 |
|
| 1419 |
|
| 1420 |
for hash in r["release_assets"].as_array().into_iter().flatten().filter_map(|a| a["hash"].as_str()) { |
| 1421 |
if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { |
| 1422 |
continue; |
| 1423 |
} |
| 1424 |
let path = crate::web::releases::blob_path(app, hash); |
| 1425 |
let key = blob_key(hash); |
| 1426 |
let put = tokio::task::block_in_place(|| -> Result<Option<i64>> { |
| 1427 |
if dest.exists(&key)? { |
| 1428 |
return Ok(None); |
| 1429 |
} |
| 1430 |
let bytes = std::fs::read(&path).with_context(|| format!("read release asset {}", &hash[..12]))?; |
| 1431 |
dest.put(&key, &bytes)?; |
| 1432 |
Ok(Some(bytes.len() as i64)) |
| 1433 |
}); |
| 1434 |
match put { |
| 1435 |
Ok(Some(n)) => { |
| 1436 |
sent.files += 1; |
| 1437 |
sent.sent += 1; |
| 1438 |
sent.bytes += n; |
| 1439 |
} |
| 1440 |
Ok(None) => sent.files += 1, |
| 1441 |
Err(e) => problems.push(format!("{}/{} release asset: {e:#}", row.owner, row.name)), |
| 1442 |
} |
| 1443 |
} |
| 1444 |
} |
| 1445 |
Err(e) => problems.push(format!("{}/{} records: {e:#}", row.owner, row.name)), |
| 1446 |
} |
| 1447 |
} |
| 1448 |
|
| 1449 |
let owner_rows = if s.kind == "org" { |
| 1450 |
match db::accounts::org_by_name(&app.db, &s.owner).await? { |
| 1451 |
Some(o) => records(&app.db, "org_id", o.id).await?, |
| 1452 |
None => serde_json::json!({}), |
| 1453 |
} |
| 1454 |
} else { |
| 1455 |
match db::accounts::account_by_name(&app.db, &s.owner).await? { |
| 1456 |
Some(a) => records(&app.db, "account_id", a.id).await?, |
| 1457 |
None => serde_json::json!({}), |
| 1458 |
} |
| 1459 |
}; |
| 1460 |
|
| 1461 |
|
| 1462 |
tokio::task::block_in_place(|| -> Result<()> { |
| 1463 |
dest.put(&format!("runs/{at}/owner.json"), &serde_json::to_vec(&owner_rows)?)?; |
| 1464 |
dest.put(&format!("runs/{at}/DONE"), at.as_bytes())?; |
| 1465 |
dest.put("LATEST", at.as_bytes())?; |
| 1466 |
prune(dest.as_ref(), s.keep_days) |
| 1467 |
})?; |
| 1468 |
Ok(((repos, sent), problems)) |
| 1469 |
} |
| 1470 |
|
| 1471 |
|
| 1472 |
|
| 1473 |
pub fn prune(dest: &dyn Destination, keep_days: i64) -> Result<()> { |
| 1474 |
let cutoff = run_name(db::now_ms() - keep_days.max(1) * 86_400_000); |
| 1475 |
let runs = dest.list("runs")?; |
| 1476 |
let whole: Vec<&String> = runs.iter().filter(|r| dest.exists(&format!("runs/{r}/DONE")).unwrap_or(false)).collect(); |
| 1477 |
let newest = whole.last().map(|s| s.to_string()); |
| 1478 |
let mut removed = false; |
| 1479 |
for r in &runs { |
| 1480 |
if r.as_str() < cutoff.as_str() && Some(r.to_string()) != newest { |
| 1481 |
dest.remove(&format!("runs/{r}"))?; |
| 1482 |
removed = true; |
| 1483 |
} |
| 1484 |
} |
| 1485 |
if !removed { |
| 1486 |
return Ok(()); |
| 1487 |
} |
| 1488 |
let mut wanted = std::collections::HashSet::new(); |
| 1489 |
for r in dest.list("runs")? { |
| 1490 |
for m in dest.list(&format!("runs/{r}/repos"))? { |
| 1491 |
if let Ok(m) = dest.get(&format!("runs/{r}/repos/{m}")).and_then(|b| Ok(serde_json::from_slice::<Manifest>(&b)?)) { |
| 1492 |
wanted.extend(m.files.into_iter().map(|f| f.hash)); |
| 1493 |
} |
| 1494 |
} |
| 1495 |
} |
| 1496 |
for shard in dest.list("blobs")? { |
| 1497 |
for b in dest.list(&format!("blobs/{shard}"))? { |
| 1498 |
if !wanted.contains(&b) { |
| 1499 |
dest.remove(&format!("blobs/{shard}/{b}"))?; |
| 1500 |
} |
| 1501 |
} |
| 1502 |
} |
| 1503 |
Ok(()) |
| 1504 |
} |
| 1505 |
|
| 1506 |
|
| 1507 |
|
| 1508 |
async fn told(app: &Shared, s: &Setting) -> Option<(Vec<(i64, db::notify::Reason)>, String)> { |
| 1509 |
let p = db::notify::Reason::Participating; |
| 1510 |
if s.kind == "org" { |
| 1511 |
let o = db::accounts::org_by_name(&app.db, &s.owner).await.ok().flatten()?; |
| 1512 |
let owners = db::accounts::org_members(&app.db, o.id) |
| 1513 |
.await |
| 1514 |
.ok()? |
| 1515 |
.into_iter() |
| 1516 |
.filter(|(_, r)| r == "owner") |
| 1517 |
.map(|(a, _)| (a.id, p)) |
| 1518 |
.collect(); |
| 1519 |
Some((owners, format!("/orgs/{}/settings/backups", s.owner))) |
| 1520 |
} else { |
| 1521 |
let a = db::accounts::account_by_name(&app.db, &s.owner).await.ok().flatten()?; |
| 1522 |
Some((vec![(a.id, p)], "/settings/backups".into())) |
| 1523 |
} |
| 1524 |
} |
| 1525 |
|
| 1526 |
|
| 1527 |
async fn status_before(app: &Shared, owner: &str) -> Option<String> { |
| 1528 |
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") |
| 1529 |
.bind(owner) |
| 1530 |
.fetch_optional(&app.db) |
| 1531 |
.await |
| 1532 |
.ok() |
| 1533 |
.flatten() |
| 1534 |
} |
| 1535 |
|
| 1536 |
|
| 1537 |
|
| 1538 |
async fn notify_failure(app: &Shared, s: &Setting, status: &str, detail: &str) { |
| 1539 |
if status_before(app, &s.owner).await.is_some_and(|b| b != "ok") { |
| 1540 |
return; |
| 1541 |
} |
| 1542 |
let Some((who, url)) = told(app, s).await else { return }; |
| 1543 |
let title = format!("Backup of {} {status}: {}", s.owner, detail.chars().take(160).collect::<String>()); |
| 1544 |
crate::notify::tell(app, "backup", &title, &url, "backups", None, who).await; |
| 1545 |
} |
| 1546 |
|
| 1547 |
|
| 1548 |
|
| 1549 |
async fn notify_recovered(app: &Shared, s: &Setting, run: &Run) { |
| 1550 |
if !status_before(app, &s.owner).await.is_some_and(|b| b != "ok") { |
| 1551 |
return; |
| 1552 |
} |
| 1553 |
let Some((who, url)) = told(app, s).await else { return }; |
| 1554 |
let title = format!("Backup of {} works again: {} repositories to {}", s.owner, run.repos, place(s)); |
| 1555 |
crate::notify::tell(app, "backup.recovered", &title, &url, "backups", None, who).await; |
| 1556 |
} |
| 1557 |
|
| 1558 |
|
| 1559 |
const SUMMARY_HOUR: u8 = 7; |
| 1560 |
const SUMMARY_SENT: &str = "backups.summary_day"; |
| 1561 |
|
| 1562 |
|
| 1563 |
|
| 1564 |
|
| 1565 |
pub async fn daily_summary(app: &Shared, now_ms: i64, force: bool) -> sqlx::Result<usize> { |
| 1566 |
let t = time::OffsetDateTime::from_unix_timestamp_nanos(now_ms as i128 * 1_000_000).unwrap_or(time::OffsetDateTime::UNIX_EPOCH); |
| 1567 |
let day = format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day()); |
| 1568 |
if !force && (t.hour() < SUMMARY_HOUR || db::accounts::setting(&app.db, SUMMARY_SENT).await?.as_deref() == Some(day.as_str())) { |
| 1569 |
return Ok(0); |
| 1570 |
} |
| 1571 |
db::accounts::set_setting(&app.db, SUMMARY_SENT, &day).await?; |
| 1572 |
let settings: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings WHERE updated_ms > 0").fetch_all(&app.db).await?; |
| 1573 |
let mut sent = 0; |
| 1574 |
for s in settings { |
| 1575 |
let runs: Vec<Run> = sqlx::query_as("SELECT * FROM backup_runs WHERE owner = $1 AND started_ms >= $2 ORDER BY id") |
| 1576 |
.bind(&s.owner) |
| 1577 |
.bind(now_ms - 86_400_000) |
| 1578 |
.fetch_all(&app.db) |
| 1579 |
.await?; |
| 1580 |
if runs.is_empty() { |
| 1581 |
continue; |
| 1582 |
} |
| 1583 |
let ok = runs.iter().filter(|r| r.status == "ok").count(); |
| 1584 |
let bytes: i64 = runs.iter().map(|r| r.bytes).sum(); |
| 1585 |
let repos = runs.iter().rev().find(|r| r.status == "ok").map_or(0, |r| r.repos); |
| 1586 |
let how = if ok == runs.len() { "all ok".to_string() } else { format!("{} not whole", runs.len() - ok) }; |
| 1587 |
let title = format!( |
| 1588 |
"Backups of {} today: {} runs, {how}, {repos} repositories, {:.1} MiB sent to {}", |
| 1589 |
s.owner, |
| 1590 |
runs.len(), |
| 1591 |
bytes as f64 / 1_048_576.0, |
| 1592 |
place(&s) |
| 1593 |
); |
| 1594 |
let Some((who, url)) = told(app, &s).await else { continue }; |
| 1595 |
crate::notify::tell(app, "backup.summary", &title, &url, "backups", None, who).await; |
| 1596 |
sent += 1; |
| 1597 |
} |
| 1598 |
Ok(sent) |
| 1599 |
} |
| 1600 |
|
| 1601 |
|
| 1602 |
|
| 1603 |
pub async fn scheduler(app: Shared) { |
| 1604 |
let _ = sqlx::query( |
| 1605 |
"UPDATE backup_runs SET status = 'failed', detail = 'the hub restarted during the run', finished_ms = $1 WHERE status = 'running'", |
| 1606 |
) |
| 1607 |
.bind(db::now_ms()) |
| 1608 |
.execute(&app.db) |
| 1609 |
.await; |
| 1610 |
loop { |
| 1611 |
tokio::time::sleep(std::time::Duration::from_secs(60)).await; |
| 1612 |
if let Err(e) = daily_summary(&app, db::now_ms(), false).await { |
| 1613 |
tracing::warn!("backup summary: {e}"); |
| 1614 |
} |
| 1615 |
for s in due(&app.db).await.unwrap_or_default() { |
| 1616 |
if let Err(e) = run(&app, &s.owner, "schedule").await { |
| 1617 |
tracing::warn!("backup of {}: {e:#}", s.owner); |
| 1618 |
} |
| 1619 |
} |
| 1620 |
} |
| 1621 |
} |
| 1622 |
|
| 1623 |
|
| 1624 |
|
| 1625 |
|
| 1626 |
|
| 1627 |
pub async fn due(pool: &Pool) -> sqlx::Result<Vec<Setting>> { |
| 1628 |
let all: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings WHERE updated_ms > 0").fetch_all(pool).await?; |
| 1629 |
let repos = db::repos::list(pool).await?; |
| 1630 |
let now = db::now_ms(); |
| 1631 |
let mut out = Vec::new(); |
| 1632 |
for s in all { |
| 1633 |
let theirs: Vec<db::repos::RepoRow> = repos.iter().filter(|r| r.owner == s.owner).cloned().collect(); |
| 1634 |
let Some(period) = period_with(&s, &theirs) else { continue }; |
| 1635 |
let last: Option<i64> = |
| 1636 |
sqlx::query_scalar("SELECT MAX(started_ms) FROM backup_runs WHERE owner = $1").bind(&s.owner).fetch_one(pool).await?; |
| 1637 |
if last.is_none_or(|l| now - l >= period * 1000) { |
| 1638 |
out.push(s); |
| 1639 |
} |
| 1640 |
} |
| 1641 |
Ok(out) |
| 1642 |
} |
| 1643 |
|
| 1644 |
|
| 1645 |
|
| 1646 |
|
| 1647 |
pub fn restore(dest: &dyn Destination, at: Option<&str>, repos_root: &Path, wikis_root: &Path) -> Result<Vec<String>> { |
| 1648 |
let at = match at { |
| 1649 |
Some(a) => a.to_string(), |
| 1650 |
None => String::from_utf8_lossy(&dest.get("LATEST").context("no whole run at this destination")?).trim().to_string(), |
| 1651 |
}; |
| 1652 |
if !dest.exists(&format!("runs/{at}/DONE"))? { |
| 1653 |
bail!("run {at} is not whole (no DONE): pick another with --run"); |
| 1654 |
} |
| 1655 |
let mut done = Vec::new(); |
| 1656 |
for name in dest.list(&format!("runs/{at}/repos"))? { |
| 1657 |
let m: Manifest = serde_json::from_slice(&dest.get(&format!("runs/{at}/repos/{name}"))?)?; |
| 1658 |
let root = if m.kind == "wikis" { wikis_root } else { repos_root }; |
| 1659 |
let dir = root.join(&m.owner).join(&m.name); |
| 1660 |
if dir.exists() { |
| 1661 |
bail!("{} exists: restore into an empty hub, or move it aside", dir.display()); |
| 1662 |
} |
| 1663 |
for f in &m.files { |
| 1664 |
let bytes = dest.get(&blob_key(&f.hash))?; |
| 1665 |
if blake3::hash(&bytes).to_hex().as_str() != f.hash { |
| 1666 |
bail!("{}/{} {}: the copy does not match its hash", m.owner, m.name, f.path); |
| 1667 |
} |
| 1668 |
let p = dir.join(&f.path); |
| 1669 |
std::fs::create_dir_all(p.parent().context("no parent")?)?; |
| 1670 |
std::fs::write(&p, &bytes)?; |
| 1671 |
} |
| 1672 |
done.push(format!("{}/{} ({})", m.owner, m.name, m.kind)); |
| 1673 |
} |
| 1674 |
Ok(done) |
| 1675 |
} |
| 1676 |
|
| 1677 |
|
| 1678 |
|
| 1679 |
|
| 1680 |
|
| 1681 |
|
| 1682 |
pub async fn restore_into_hub(app: &Shared, dest: std::sync::Arc<dyn Destination>, at: Option<String>) -> Result<Vec<String>> { |
| 1683 |
let (repos_root, wikis_root) = (app.repos.root().to_path_buf(), app.wikis.root().to_path_buf()); |
| 1684 |
let (d, a) = (dest.clone(), at.clone()); |
| 1685 |
let done = tokio::task::spawn_blocking(move || restore(d.as_ref(), a.as_deref(), &repos_root, &wikis_root)).await??; |
| 1686 |
let at = match at { |
| 1687 |
Some(a) => a, |
| 1688 |
None => String::from_utf8_lossy(&tokio::task::block_in_place(|| dest.get("LATEST"))?).trim().to_string(), |
| 1689 |
}; |
| 1690 |
let mut notes = Vec::new(); |
| 1691 |
for name in tokio::task::block_in_place(|| dest.list(&format!("runs/{at}/records")))? { |
| 1692 |
let rec: serde_json::Value = |
| 1693 |
serde_json::from_slice(&tokio::task::block_in_place(|| dest.get(&format!("runs/{at}/records/{name}")))?)?; |
| 1694 |
let Some(row) = rec["repositories"].as_array().and_then(|a| a.first()) else { continue }; |
| 1695 |
let (owner, repo) = (row["owner"].as_str().unwrap_or_default(), row["name"].as_str().unwrap_or_default()); |
| 1696 |
if owner.is_empty() || db::repos::get(&app.db, owner, repo).await?.is_some() { |
| 1697 |
continue; |
| 1698 |
} |
| 1699 |
let visibility = match row["visibility"].as_str() { |
| 1700 |
Some("private") => hub_api::Visibility::Private, |
| 1701 |
Some("internal") => hub_api::Visibility::Internal, |
| 1702 |
_ => hub_api::Visibility::Public, |
| 1703 |
}; |
| 1704 |
let made = db::repos::create(&app.db, owner, repo, row["description"].as_str().unwrap_or_default(), visibility).await?; |
| 1705 |
|
| 1706 |
|
| 1707 |
let cols = table_cols(&app.db, "repositories").await?; |
| 1708 |
let set: Vec<(&Col, serde_json::Value)> = cols |
| 1709 |
.iter() |
| 1710 |
.filter(|c| !["id", "owner", "name", "forked_from"].contains(&c.name.as_str())) |
| 1711 |
.filter_map(|c| row.get(&c.name).filter(|v| !v.is_null()).map(|v| (c, v.clone()))) |
| 1712 |
.collect(); |
| 1713 |
if !set.is_empty() { |
| 1714 |
let sql = format!( |
| 1715 |
"UPDATE repositories SET {} WHERE id = ${}", |
| 1716 |
set.iter().enumerate().map(|(i, (c, _))| format!("\"{}\" = ${}", c.name, i + 1)).collect::<Vec<_>>().join(", "), |
| 1717 |
set.len() + 1 |
| 1718 |
); |
| 1719 |
let mut q = sqlx::query(&sql); |
| 1720 |
for (c, v) in set { |
| 1721 |
q = bind_json(q, v, c.ty.to_ascii_uppercase().contains("BLOB")); |
| 1722 |
} |
| 1723 |
q.bind(made.id).execute(&app.db).await?; |
| 1724 |
} |
| 1725 |
let old = row["id"].as_i64().unwrap_or(-1); |
| 1726 |
for n in restore_records(app, &dest, &rec, old, made.id).await.with_context(|| format!("restore {owner}/{repo}'s records"))? { |
| 1727 |
notes.push(format!("note: {owner}/{repo}: {n}")); |
| 1728 |
} |
| 1729 |
} |
| 1730 |
let mut done = done; |
| 1731 |
done.extend(notes); |
| 1732 |
Ok(done) |
| 1733 |
} |
| 1734 |
|
| 1735 |
#[cfg(test)] |
| 1736 |
mod tests { |
| 1737 |
use super::*; |
| 1738 |
|
| 1739 |
#[test] |
| 1740 |
fn a_directory_destination_keeps_keys_inside_its_root() { |
| 1741 |
let d = tempfile::tempdir().unwrap(); |
| 1742 |
let dest = Directory { root: d.path().to_path_buf() }; |
| 1743 |
dest.put("runs/a/DONE", b"a").unwrap(); |
| 1744 |
assert_eq!(dest.get("runs/a/DONE").unwrap(), b"a"); |
| 1745 |
assert_eq!(dest.list("runs").unwrap(), vec!["a"]); |
| 1746 |
assert!(dest.put("../escape", b"x").is_err() && dest.put("/abs", b"x").is_err()); |
| 1747 |
dest.remove("runs").unwrap(); |
| 1748 |
assert!(dest.list("runs").unwrap().is_empty()); |
| 1749 |
} |
| 1750 |
|
| 1751 |
#[test] |
| 1752 |
fn files_are_sent_once_and_unchanged_ones_not_read_again() { |
| 1753 |
let src = tempfile::tempdir().unwrap(); |
| 1754 |
let out = tempfile::tempdir().unwrap(); |
| 1755 |
let dest = Directory { root: out.path().to_path_buf() }; |
| 1756 |
std::fs::create_dir_all(src.path().join(".oxy/objects/loose")).unwrap(); |
| 1757 |
std::fs::write(src.path().join(".oxy/objects/loose/aa"), b"object").unwrap(); |
| 1758 |
std::fs::write(src.path().join(".oxy/ops-head"), b"ref one").unwrap(); |
| 1759 |
let mut s = Sent::default(); |
| 1760 |
let files = copy_dir(&dest, src.path(), &HashMap::new(), false, &mut s).unwrap(); |
| 1761 |
assert_eq!((files.len(), s.sent), (2, 2)); |
| 1762 |
let known: HashMap<String, File> = files.iter().map(|f| (f.path.clone(), f.clone())).collect(); |
| 1763 |
let mut s = Sent::default(); |
| 1764 |
let again = copy_dir(&dest, src.path(), &known, false, &mut s).unwrap(); |
| 1765 |
assert_eq!((again, s.sent), (files.clone(), 0), "nothing new travels"); |
| 1766 |
|
| 1767 |
let mut s = Sent::default(); |
| 1768 |
let first = copy_dir(&dest, src.path(), &HashMap::new(), true, &mut s).unwrap(); |
| 1769 |
assert_eq!(first.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(), vec![".oxy/objects/loose/aa"]); |
| 1770 |
} |
| 1771 |
|
| 1772 |
#[test] |
| 1773 |
fn requests_are_signed_as_aws_documents() { |
| 1774 |
|
| 1775 |
|
| 1776 |
let empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; |
| 1777 |
let headers = vec![ |
| 1778 |
("Host".to_string(), "examplebucket.s3.amazonaws.com".to_string()), |
| 1779 |
("Range".to_string(), "bytes=0-9".to_string()), |
| 1780 |
("x-amz-content-sha256".to_string(), empty.to_string()), |
| 1781 |
("x-amz-date".to_string(), "20130524T000000Z".to_string()), |
| 1782 |
]; |
| 1783 |
let auth = sigv4( |
| 1784 |
"GET", |
| 1785 |
"/test.txt", |
| 1786 |
&[], |
| 1787 |
&headers, |
| 1788 |
empty, |
| 1789 |
("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"), |
| 1790 |
"us-east-1", |
| 1791 |
"s3", |
| 1792 |
"20130524T000000Z", |
| 1793 |
); |
| 1794 |
assert_eq!( |
| 1795 |
auth, |
| 1796 |
"AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, \ |
| 1797 |
SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, \ |
| 1798 |
Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41" |
| 1799 |
); |
| 1800 |
assert_eq!(aws_encode("runs/2026 x+y", true), "runs/2026%20x%2By"); |
| 1801 |
assert_eq!(aws_encode("a/b", false), "a%2Fb"); |
| 1802 |
assert_eq!(xml_text("<A><Key>runs/x&y</Key></A>", "Key").as_deref(), Some("runs/x&y")); |
| 1803 |
} |
| 1804 |
|
| 1805 |
#[test] |
| 1806 |
fn run_names_sort_by_time() { |
| 1807 |
assert_eq!(run_name(0), "19700101T000000Z"); |
| 1808 |
assert!(run_name(1_791_900_000_000) < run_name(1_791_903_600_000)); |
| 1809 |
} |
| 1810 |
} |