forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
14.6 KiB
1//! Backup tests. Everything lives under `tempfile` directories. Repos are small.
2
3use std::os::unix::fs::PermissionsExt;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use forge_core::model::Visibility;
8use forge_core::{Store, store::snapshot::verify_snapshot};
9use forge_git::GitEnv;
10use forge_git::repo::{init_bare, repo_path};
11use sha2::{Digest, Sha256};
12
13use super::{
14 BackupOptions, BackupSource, DB_FILE, KEY_FILE, MANIFEST_FILE, Manifest, Scope, create_backup,
15 verify_backup,
16};
17
18struct 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
27fn 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.
42async 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
108fn 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
116fn 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
121fn 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.
130fn 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
139fn mode(path: &Path) -> u32 {
140 std::fs::metadata(path).expect("meta").permissions().mode() & 0o777
141}
142
143fn assert_clean(problems: &[String]) {
144 assert!(problems.is_empty(), "unexpected problems: {problems:?}");
145}
146
147#[tokio::test]
148async 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]
226async 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]
249async 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]
280async 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]
316async 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]
338async 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]
357async 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]
398async 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]
407async 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]
424async 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}