Tahinli/forge
public0 watching
★ 0
| 1 | //! Backup tests. Everything lives under `tempfile` directories. Repos are small. |
| 2 | |
| 3 | use std::os::unix::fs::PermissionsExt; |
| 4 | use std::path::{Path, PathBuf}; |
| 5 | use std::process::Command; |
| 6 | |
| 7 | use forge_core::model::Visibility; |
| 8 | use forge_core::{Store, store::snapshot::verify_snapshot}; |
| 9 | use forge_git::GitEnv; |
| 10 | use forge_git::repo::{init_bare, repo_path}; |
| 11 | use sha2::{Digest, Sha256}; |
| 12 | |
| 13 | use super::{ |
| 14 | BackupOptions, BackupSource, DB_FILE, KEY_FILE, MANIFEST_FILE, Manifest, Scope, create_backup, |
| 15 | verify_backup, |
| 16 | }; |
| 17 | |
| 18 | struct Fixture { |
| 19 | dir: tempfile::TempDir, |
| 20 | source: BackupSource, |
| 21 | git: GitEnv, |
| 22 | data_dir: PathBuf, |
| 23 | demo_id: String, |
| 24 | empty_id: String, |
| 25 | } |
| 26 | |
| 27 | fn git_in(dir: &Path, args: &[&str]) { |
| 28 | let out = Command::new("git") |
| 29 | .current_dir(dir) |
| 30 | .env("GIT_AUTHOR_NAME", "t") |
| 31 | .env("GIT_AUTHOR_EMAIL", "t@example.invalid") |
| 32 | .env("GIT_COMMITTER_NAME", "t") |
| 33 | .env("GIT_COMMITTER_EMAIL", "t@example.invalid") |
| 34 | .args(args) |
| 35 | .output() |
| 36 | .expect("run git"); |
| 37 | assert!(out.status.success(), "git {args:?}: {out:?}"); |
| 38 | } |
| 39 | |
| 40 | /// A store with one user, a repo that has commits, a branch and a tag, and an empty repo. The |
| 41 | /// repos are laid out where the server would put them. |
| 42 | async fn fixture() -> Fixture { |
| 43 | let dir = tempfile::tempdir().expect("tempdir"); |
| 44 | let data_dir = dir.path().join("data"); |
| 45 | std::fs::create_dir_all(&data_dir).expect("data dir"); |
| 46 | let store = Store::open(&data_dir.join("forge.db")).await.expect("open"); |
| 47 | let alice = store |
| 48 | .create_user("Alice", None, "hash", false) |
| 49 | .await |
| 50 | .expect("user"); |
| 51 | let git = GitEnv { |
| 52 | git_bin: "git".into(), |
| 53 | data_dir: data_dir.clone(), |
| 54 | }; |
| 55 | |
| 56 | let demo = store |
| 57 | .create_repo(&alice.id, "demo", "", Visibility::Private, "main") |
| 58 | .await |
| 59 | .expect("demo"); |
| 60 | let demo_path = repo_path(&data_dir, &demo.id).expect("path"); |
| 61 | let work = dir.path().join("work"); |
| 62 | std::fs::create_dir_all(&work).expect("work"); |
| 63 | git_in(&work, &["init", "-q", "-b", "main"]); |
| 64 | git_in(&work, &["commit", "-q", "--allow-empty", "-m", "one"]); |
| 65 | git_in(&work, &["commit", "-q", "--allow-empty", "-m", "two"]); |
| 66 | git_in(&work, &["branch", "feature"]); |
| 67 | git_in(&work, &["tag", "v1"]); |
| 68 | std::fs::create_dir_all(demo_path.parent().expect("parent")).expect("mkdir"); |
| 69 | let dest = demo_path.to_str().expect("utf8").to_string(); |
| 70 | git_in( |
| 71 | dir.path(), |
| 72 | &[ |
| 73 | "clone", |
| 74 | "-q", |
| 75 | "--bare", |
| 76 | "--no-hardlinks", |
| 77 | work.to_str().expect("utf8"), |
| 78 | &dest, |
| 79 | ], |
| 80 | ); |
| 81 | |
| 82 | let empty = store |
| 83 | .create_repo(&alice.id, "empty", "", Visibility::Private, "main") |
| 84 | .await |
| 85 | .expect("empty"); |
| 86 | init_bare( |
| 87 | &git, |
| 88 | &repo_path(&data_dir, &empty.id).expect("path"), |
| 89 | "main", |
| 90 | ) |
| 91 | .await |
| 92 | .expect("init empty"); |
| 93 | |
| 94 | Fixture { |
| 95 | source: BackupSource { |
| 96 | store, |
| 97 | git: git.clone(), |
| 98 | base_url: "https://forge.example.invalid".into(), |
| 99 | }, |
| 100 | git, |
| 101 | data_dir, |
| 102 | demo_id: demo.id, |
| 103 | empty_id: empty.id, |
| 104 | dir, |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | fn opts(out: &Path, include_key: bool, scope: Scope) -> BackupOptions { |
| 109 | BackupOptions { |
| 110 | out: out.to_path_buf(), |
| 111 | include_key, |
| 112 | scope, |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | fn read_manifest(set: &Path) -> Manifest { |
| 117 | let bytes = std::fs::read(set.join(MANIFEST_FILE)).expect("manifest"); |
| 118 | serde_json::from_slice(&bytes).expect("parse manifest") |
| 119 | } |
| 120 | |
| 121 | fn write_manifest(set: &Path, m: &Manifest) { |
| 122 | std::fs::write( |
| 123 | set.join(MANIFEST_FILE), |
| 124 | serde_json::to_vec_pretty(m).expect("json"), |
| 125 | ) |
| 126 | .expect("write manifest"); |
| 127 | } |
| 128 | |
| 129 | /// The only entry in `out` (the finished set). Asserts there is exactly one. |
| 130 | fn only_entry(out: &Path) -> PathBuf { |
| 131 | let entries: Vec<PathBuf> = std::fs::read_dir(out) |
| 132 | .expect("read out") |
| 133 | .map(|e| e.expect("entry").path()) |
| 134 | .collect(); |
| 135 | assert_eq!(entries.len(), 1, "expected one entry in out: {entries:?}"); |
| 136 | entries.into_iter().next().expect("one entry") |
| 137 | } |
| 138 | |
| 139 | fn mode(path: &Path) -> u32 { |
| 140 | std::fs::metadata(path).expect("meta").permissions().mode() & 0o777 |
| 141 | } |
| 142 | |
| 143 | fn assert_clean(problems: &[String]) { |
| 144 | assert!(problems.is_empty(), "unexpected problems: {problems:?}"); |
| 145 | } |
| 146 | |
| 147 | #[tokio::test] |
| 148 | async fn end_to_end_set_verifies_with_correct_contents_and_modes() { |
| 149 | let fx = fixture().await; |
| 150 | let out = fx.dir.path().join("out"); |
| 151 | let summary = create_backup(&fx.source, &opts(&out, false, Scope::All)) |
| 152 | .await |
| 153 | .expect("backup"); |
| 154 | |
| 155 | assert_eq!(summary.repos, 2); |
| 156 | assert_eq!(summary.bundles, 1); |
| 157 | assert_eq!(summary.empty_repos, 1); |
| 158 | assert_eq!( |
| 159 | summary.schema_version, |
| 160 | Some(forge_core::store::migrate::latest_schema_version()) |
| 161 | ); |
| 162 | assert!(!summary.key_included); |
| 163 | assert!( |
| 164 | summary |
| 165 | .path |
| 166 | .file_name() |
| 167 | .and_then(|n| n.to_str()) |
| 168 | .is_some_and(|n| n.starts_with("forge-backup-")), |
| 169 | "{:?}", |
| 170 | summary.path |
| 171 | ); |
| 172 | // Only the finished set is in out: no .partial- leftovers and no scratch space. |
| 173 | assert_eq!(only_entry(&out), summary.path); |
| 174 | |
| 175 | let manifest = read_manifest(&summary.path); |
| 176 | assert_eq!(manifest.format, 1); |
| 177 | assert_eq!(manifest.scope, Scope::All); |
| 178 | assert!(!manifest.key_included); |
| 179 | let demo = manifest |
| 180 | .repos |
| 181 | .iter() |
| 182 | .find(|r| r.id == fx.demo_id) |
| 183 | .expect("demo listed"); |
| 184 | assert_eq!(demo.owner, "Alice"); |
| 185 | assert_eq!(demo.name, "demo"); |
| 186 | assert_eq!( |
| 187 | demo.bundle.as_deref(), |
| 188 | Some(format!("repos/{}.bundle", fx.demo_id).as_str()) |
| 189 | ); |
| 190 | let empty = manifest |
| 191 | .repos |
| 192 | .iter() |
| 193 | .find(|r| r.id == fx.empty_id) |
| 194 | .expect("empty listed"); |
| 195 | assert_eq!(empty.bundle, None, "an empty repo has a null bundle"); |
| 196 | assert_eq!(empty.refs_sha256, hex::encode(Sha256::digest(b""))); |
| 197 | |
| 198 | let names: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect(); |
| 199 | assert!(names.contains(&DB_FILE), "{names:?}"); |
| 200 | assert!( |
| 201 | names.contains(&format!("repos/{}.bundle", fx.demo_id).as_str()), |
| 202 | "{names:?}" |
| 203 | ); |
| 204 | assert!(!names.iter().any(|n| n.starts_with("keys/")), "{names:?}"); |
| 205 | |
| 206 | assert_eq!(mode(&summary.path), 0o700); |
| 207 | assert_eq!(mode(&summary.path.join(MANIFEST_FILE)), 0o600); |
| 208 | assert_eq!(mode(&summary.path.join(DB_FILE)), 0o600); |
| 209 | assert_eq!(mode(&summary.path.join("repos")), 0o700); |
| 210 | |
| 211 | let report = verify_backup(&summary.path, &fx.git).await.expect("verify"); |
| 212 | assert_clean(&report.problems); |
| 213 | assert!(report.is_ok()); |
| 214 | assert_eq!(report.bundles_verified, 1); |
| 215 | assert_eq!(report.empty_repos, 1); |
| 216 | assert_eq!(report.repos, 2); |
| 217 | assert_eq!( |
| 218 | report.schema_version, |
| 219 | Some(forge_core::store::migrate::latest_schema_version()) |
| 220 | ); |
| 221 | // Scratch space is gone after verification. |
| 222 | assert_eq!(only_entry(&out), summary.path); |
| 223 | } |
| 224 | |
| 225 | #[tokio::test] |
| 226 | async fn one_flipped_bundle_byte_fails_the_sha_check() { |
| 227 | let fx = fixture().await; |
| 228 | let out = fx.dir.path().join("out"); |
| 229 | let summary = create_backup(&fx.source, &opts(&out, false, Scope::All)) |
| 230 | .await |
| 231 | .expect("backup"); |
| 232 | let bundle = summary.path.join(format!("repos/{}.bundle", fx.demo_id)); |
| 233 | let mut bytes = std::fs::read(&bundle).expect("read bundle"); |
| 234 | let mid = bytes.len() / 2; |
| 235 | bytes[mid] ^= 0xff; |
| 236 | std::fs::write(&bundle, &bytes).expect("tamper"); |
| 237 | |
| 238 | let report = verify_backup(&summary.path, &fx.git).await.expect("verify"); |
| 239 | assert!(!report.is_ok()); |
| 240 | let needle = format!("repos/{}.bundle: sha256 mismatch", fx.demo_id); |
| 241 | assert!( |
| 242 | report.problems.iter().any(|p| p.contains(&needle)), |
| 243 | "{:?}", |
| 244 | report.problems |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | #[tokio::test] |
| 249 | async fn tampered_db_fails_the_sha_check() { |
| 250 | let fx = fixture().await; |
| 251 | let out = fx.dir.path().join("out"); |
| 252 | let summary = create_backup(&fx.source, &opts(&out, false, Scope::All)) |
| 253 | .await |
| 254 | .expect("backup"); |
| 255 | let db = summary.path.join(DB_FILE); |
| 256 | let mut bytes = std::fs::read(&db).expect("read db"); |
| 257 | bytes.push(0); |
| 258 | std::fs::write(&db, &bytes).expect("tamper"); |
| 259 | |
| 260 | let report = verify_backup(&summary.path, &fx.git).await.expect("verify"); |
| 261 | assert!( |
| 262 | report |
| 263 | .problems |
| 264 | .iter() |
| 265 | .any(|p| p.starts_with("db/forge.db: size mismatch")), |
| 266 | "{:?}", |
| 267 | report.problems |
| 268 | ); |
| 269 | assert!( |
| 270 | report |
| 271 | .problems |
| 272 | .iter() |
| 273 | .any(|p| p.starts_with("db/forge.db: sha256 mismatch")), |
| 274 | "{:?}", |
| 275 | report.problems |
| 276 | ); |
| 277 | } |
| 278 | |
| 279 | #[tokio::test] |
| 280 | async fn a_damaged_db_fails_even_when_the_manifest_is_rewritten_to_match() { |
| 281 | let fx = fixture().await; |
| 282 | let out = fx.dir.path().join("out"); |
| 283 | let summary = create_backup(&fx.source, &opts(&out, false, Scope::All)) |
| 284 | .await |
| 285 | .expect("backup"); |
| 286 | let db = summary.path.join(DB_FILE); |
| 287 | let bytes = std::fs::read(&db).expect("read db"); |
| 288 | std::fs::write(&db, &bytes[..bytes.len() / 2]).expect("truncate"); |
| 289 | |
| 290 | // Make the manifest agree with the damaged file, so only the DB checks can catch it. |
| 291 | let mut m = read_manifest(&summary.path); |
| 292 | let (new_bytes, new_sha) = { |
| 293 | let data = std::fs::read(&db).expect("read"); |
| 294 | (data.len() as u64, hex::encode(Sha256::digest(&data))) |
| 295 | }; |
| 296 | for f in &mut m.files { |
| 297 | if f.path == DB_FILE { |
| 298 | f.bytes = new_bytes; |
| 299 | f.sha256 = new_sha.clone(); |
| 300 | } |
| 301 | } |
| 302 | write_manifest(&summary.path, &m); |
| 303 | |
| 304 | let report = verify_backup(&summary.path, &fx.git).await.expect("verify"); |
| 305 | assert!( |
| 306 | report |
| 307 | .problems |
| 308 | .iter() |
| 309 | .any(|p| p.starts_with("database snapshot failed verification")), |
| 310 | "{:?}", |
| 311 | report.problems |
| 312 | ); |
| 313 | } |
| 314 | |
| 315 | #[tokio::test] |
| 316 | async fn manifest_repo_list_must_match_the_db_snapshot() { |
| 317 | let fx = fixture().await; |
| 318 | let out = fx.dir.path().join("out"); |
| 319 | let summary = create_backup(&fx.source, &opts(&out, false, Scope::All)) |
| 320 | .await |
| 321 | .expect("backup"); |
| 322 | let mut m = read_manifest(&summary.path); |
| 323 | m.repos.retain(|r| r.id != fx.empty_id); |
| 324 | write_manifest(&summary.path, &m); |
| 325 | |
| 326 | let report = verify_backup(&summary.path, &fx.git).await.expect("verify"); |
| 327 | assert!( |
| 328 | report |
| 329 | .problems |
| 330 | .iter() |
| 331 | .any(|p| p.contains("does not match the database snapshot")), |
| 332 | "{:?}", |
| 333 | report.problems |
| 334 | ); |
| 335 | } |
| 336 | |
| 337 | #[tokio::test] |
| 338 | async fn unlisted_file_in_the_set_is_reported() { |
| 339 | let fx = fixture().await; |
| 340 | let out = fx.dir.path().join("out"); |
| 341 | let summary = create_backup(&fx.source, &opts(&out, false, Scope::All)) |
| 342 | .await |
| 343 | .expect("backup"); |
| 344 | std::fs::write(summary.path.join("repos/stray.txt"), b"x").expect("write"); |
| 345 | let report = verify_backup(&summary.path, &fx.git).await.expect("verify"); |
| 346 | assert!( |
| 347 | report |
| 348 | .problems |
| 349 | .iter() |
| 350 | .any(|p| p.contains("unlisted file in the set: repos/stray.txt")), |
| 351 | "{:?}", |
| 352 | report.problems |
| 353 | ); |
| 354 | } |
| 355 | |
| 356 | #[tokio::test] |
| 357 | async fn key_is_excluded_by_default_and_included_on_request() { |
| 358 | let fx = fixture().await; |
| 359 | let key_bytes = [9u8; 32]; |
| 360 | let key_path = fx.data_dir.join("forge.key"); |
| 361 | std::fs::write(&key_path, key_bytes).expect("key"); |
| 362 | std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).expect("mode"); |
| 363 | |
| 364 | let plain_out = fx.dir.path().join("plain"); |
| 365 | let plain = create_backup(&fx.source, &opts(&plain_out, false, Scope::All)) |
| 366 | .await |
| 367 | .expect("backup"); |
| 368 | assert!(!plain.key_included); |
| 369 | assert!(!plain.path.join("keys").exists(), "no keys dir by default"); |
| 370 | let m = read_manifest(&plain.path); |
| 371 | assert!(!m.key_included); |
| 372 | assert!(!m.files.iter().any(|f| f.path == KEY_FILE)); |
| 373 | assert_clean( |
| 374 | &verify_backup(&plain.path, &fx.git) |
| 375 | .await |
| 376 | .expect("verify") |
| 377 | .problems, |
| 378 | ); |
| 379 | |
| 380 | let keyed_out = fx.dir.path().join("keyed"); |
| 381 | let keyed = create_backup(&fx.source, &opts(&keyed_out, true, Scope::All)) |
| 382 | .await |
| 383 | .expect("backup"); |
| 384 | assert!(keyed.key_included); |
| 385 | let copied = std::fs::read(keyed.path.join(KEY_FILE)).expect("copied key"); |
| 386 | assert_eq!(copied, key_bytes); |
| 387 | assert_eq!(mode(&keyed.path.join(KEY_FILE)), 0o600); |
| 388 | assert!(read_manifest(&keyed.path).key_included); |
| 389 | assert_clean( |
| 390 | &verify_backup(&keyed.path, &fx.git) |
| 391 | .await |
| 392 | .expect("verify") |
| 393 | .problems, |
| 394 | ); |
| 395 | } |
| 396 | |
| 397 | #[tokio::test] |
| 398 | async fn include_key_without_a_key_fails_and_leaves_nothing() { |
| 399 | let fx = fixture().await; |
| 400 | let out = fx.dir.path().join("out"); |
| 401 | let err = create_backup(&fx.source, &opts(&out, true, Scope::All)).await; |
| 402 | assert!(err.is_err()); |
| 403 | assert_eq!(std::fs::read_dir(&out).expect("out").count(), 0); |
| 404 | } |
| 405 | |
| 406 | #[tokio::test] |
| 407 | async fn failure_in_a_repo_leaves_no_partial_directory() { |
| 408 | let fx = fixture().await; |
| 409 | let out = fx.dir.path().join("out"); |
| 410 | // A repo with no HEAD is not a repository, so reading its refs fails mid-backup. |
| 411 | let broken = repo_path(&fx.data_dir, &fx.demo_id).expect("path"); |
| 412 | std::fs::remove_file(broken.join("HEAD")).expect("break repo"); |
| 413 | |
| 414 | let result = create_backup(&fx.source, &opts(&out, false, Scope::All)).await; |
| 415 | assert!(result.is_err(), "backup must fail"); |
| 416 | let left: Vec<String> = std::fs::read_dir(&out) |
| 417 | .expect("out") |
| 418 | .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned()) |
| 419 | .collect(); |
| 420 | assert!(left.is_empty(), "nothing may be left behind: {left:?}"); |
| 421 | } |
| 422 | |
| 423 | #[tokio::test] |
| 424 | async fn repos_only_and_db_only_sets_verify() { |
| 425 | let fx = fixture().await; |
| 426 | |
| 427 | let repos_out = fx.dir.path().join("repos-only"); |
| 428 | let repos = create_backup(&fx.source, &opts(&repos_out, false, Scope::ReposOnly)) |
| 429 | .await |
| 430 | .expect("repos-only"); |
| 431 | assert!(!repos.path.join("db").exists(), "no db in a repos-only set"); |
| 432 | assert!(repos.path.join("repos").is_dir()); |
| 433 | assert_eq!(repos.bundles, 1); |
| 434 | assert_clean( |
| 435 | &verify_backup(&repos.path, &fx.git) |
| 436 | .await |
| 437 | .expect("verify") |
| 438 | .problems, |
| 439 | ); |
| 440 | |
| 441 | let db_out = fx.dir.path().join("db-only"); |
| 442 | let db = create_backup(&fx.source, &opts(&db_out, false, Scope::DbOnly)) |
| 443 | .await |
| 444 | .expect("db-only"); |
| 445 | assert!(!db.path.join("repos").exists(), "no repos in a db-only set"); |
| 446 | assert_eq!(db.bundles, 0); |
| 447 | let report = verify_backup(&db.path, &fx.git).await.expect("verify"); |
| 448 | assert_clean(&report.problems); |
| 449 | assert_eq!(report.repos, 0); |
| 450 | let snap = verify_snapshot(&db.path.join(DB_FILE)).await.expect("snap"); |
| 451 | assert_eq!( |
| 452 | snap.repos.len(), |
| 453 | 2, |
| 454 | "the snapshot itself still holds the repos" |
| 455 | ); |
| 456 | } |