forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
21.7 KiB
1//! Restores a backup set into a fresh data directory. See `docs/backup.md` for the procedure.
2//!
3//! The set is verified in full before anything is written. The target must be missing or empty.
4//! Nothing is deleted on failure: a half-restored directory is left in place for inspection, and
5//! the error says so.
6
7use std::path::{Path, PathBuf};
8
9use forge_core::{Error, Result, Store};
10use forge_git::GitCommand;
11use forge_git::GitEnv;
12use forge_git::repo::{init_bare, repo_path};
13use forge_git::setup::{write_hook_shims, write_managed_gitconfig};
14
15use super::{
16 DB_FILE, FileEntry, KEY_FILE, MANIFEST_FILE, Manifest, Scope, ensure_private_dir_all,
17 hash_file, io_err, set_private, verify_backup,
18};
19
20/// What a restore produced. `warnings` holds things the operator must act on.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct RestoreReport {
23 pub data_dir: PathBuf,
24 /// Where the metadata database was written. Point `database` in the config here.
25 pub database: PathBuf,
26 /// Repos restored from bundles.
27 pub repos: usize,
28 /// Repos recreated empty.
29 pub empty_repos: usize,
30 /// True when `forge.key` came from the set. False means sealed values cannot be decrypted.
31 pub key_restored: bool,
32 pub warnings: Vec<String>,
33}
34
35/// Restores the full backup set at `set` into `new_data_dir`.
36///
37/// `git_bin` is the git executable to run. Refuses (before writing anything) when the set fails
38/// [`verify_backup`], when it is not a full set, or when `new_data_dir` exists and is not empty.
39pub async fn restore_backup(
40 set: &Path,
41 new_data_dir: &Path,
42 git_bin: &Path,
43) -> Result<RestoreReport> {
44 let set = std::path::absolute(set).map_err(io_err)?;
45 let target = std::path::absolute(new_data_dir).map_err(io_err)?;
46
47 let set_parent = set
48 .parent()
49 .ok_or_else(|| Error::Invalid("the backup set has no parent directory".into()))?
50 .to_path_buf();
51 let verify_git = GitEnv {
52 git_bin: git_bin.to_path_buf(),
53 data_dir: set_parent,
54 };
55 let report = verify_backup(&set, &verify_git).await?;
56 if !report.is_ok() {
57 let shown: Vec<&str> = report.problems.iter().take(5).map(String::as_str).collect();
58 return Err(Error::Invalid(format!(
59 "refusing to restore {}: it failed verification ({} problem(s)): {}",
60 set.display(),
61 report.problems.len(),
62 shown.join("; ")
63 )));
64 }
65
66 let manifest: Manifest =
67 serde_json::from_slice(&std::fs::read(set.join(MANIFEST_FILE)).map_err(io_err)?)
68 .map_err(|e| Error::Invalid(format!("manifest.json does not parse: {e}")))?;
69 if manifest.scope != Scope::All {
70 return Err(Error::Invalid(format!(
71 "restore needs a full set (scope all); this set is {:?}",
72 manifest.scope
73 )));
74 }
75
76 check_target(&set, &target)?;
77 ensure_private_dir_all(&target)?;
78
79 let git = GitEnv {
80 git_bin: git_bin.to_path_buf(),
81 data_dir: target.clone(),
82 };
83 let mut warnings = Vec::new();
84
85 // The database comes first: default branches are read from it.
86 let db_entry = entry_for(&manifest, DB_FILE)?;
87 let database = target.join("forge.db");
88 copy_verified(&set.join(DB_FILE), &database, db_entry)?;
89 let store = Store::open(&database).await?;
90
91 let mut repos = 0;
92 let mut empty_repos = 0;
93 let mut dirs_to_lock = Vec::new();
94 for entry in &manifest.repos {
95 let path = repo_path(&target, &entry.id)?;
96 let default_branch = store
97 .repo_by_id(&entry.id)
98 .await?
99 .ok_or_else(|| {
100 Error::Invalid(format!(
101 "repo {} is in the manifest but not in the restored database",
102 entry.id
103 ))
104 })?
105 .default_branch;
106 init_bare(&git, &path, &default_branch).await?;
107 dirs_to_lock.push(path.clone());
108
109 match &entry.bundle {
110 Some(rel) => {
111 let bundle = set.join(rel);
112 let bundle_str = bundle
113 .to_str()
114 .ok_or_else(|| Error::Invalid("bundle path is not UTF-8".into()))?;
115 GitCommand::new(&git, "fetch")
116 .git_dir(&path)
117 .arg("--quiet")
118 .user_arg(bundle_str)?
119 .user_arg("+refs/*:refs/*")?
120 .status()
121 .await?;
122 repos += 1;
123 }
124 None => empty_repos += 1,
125 }
126
127 let head = format!("refs/heads/{default_branch}");
128 GitCommand::new(&git, "symbolic-ref")
129 .git_dir(&path)
130 .arg("HEAD")
131 .user_arg(&head)?
132 .status()
133 .await?;
134
135 let refs = forge_git::backup::refs_checksum(&git, &path).await?;
136 if refs != entry.refs_sha256 {
137 return Err(Error::Conflict(format!(
138 "repo {}: refs after restore ({refs}) do not match the manifest ({})",
139 entry.id, entry.refs_sha256
140 )));
141 }
142 GitCommand::new(&git, "fsck")
143 .git_dir(&path)
144 .arg("--full")
145 .status()
146 .await?;
147
148 // The wiki comes back the same way as the repo, from its own bundle, if the set has one.
149 if let Some(wiki) = &entry.wiki {
150 let wiki_repo = forge_git::repo::wiki_path(&target, &entry.id)?;
151 init_bare(&git, &wiki_repo, forge_git::wiki::WIKI_BRANCH).await?;
152 dirs_to_lock.push(wiki_repo.clone());
153 if let Some(rel) = &wiki.bundle {
154 let bundle = set.join(rel);
155 let bundle_str = bundle
156 .to_str()
157 .ok_or_else(|| Error::Invalid("bundle path is not UTF-8".into()))?;
158 GitCommand::new(&git, "fetch")
159 .git_dir(&wiki_repo)
160 .arg("--quiet")
161 .user_arg(bundle_str)?
162 .user_arg("+refs/*:refs/*")?
163 .status()
164 .await?;
165 }
166 let refs = forge_git::backup::refs_checksum(&git, &wiki_repo).await?;
167 if refs != wiki.refs_sha256 {
168 return Err(Error::Conflict(format!(
169 "repo {} wiki: refs after restore ({refs}) do not match the manifest ({})",
170 entry.id, wiki.refs_sha256
171 )));
172 }
173 GitCommand::new(&git, "fsck")
174 .git_dir(&wiki_repo)
175 .arg("--full")
176 .status()
177 .await?;
178 }
179 }
180 drop(store);
181
182 write_managed_gitconfig(&target)?;
183 let forge_bin = std::env::current_exe()?.canonicalize()?;
184 write_hook_shims(&target, &forge_bin)?;
185
186 let key_restored = if manifest.key_included {
187 copy_verified(
188 &set.join(KEY_FILE),
189 &target.join("forge.key"),
190 entry_for(&manifest, KEY_FILE)?,
191 )?;
192 true
193 } else {
194 warnings.push(
195 "NO INSTANCE KEY WAS RESTORED. The backup was made without --include-key. \
196 Sealed values (2FA secrets, webhook secrets and similar) cannot be decrypted \
197 until the original forge.key is copied to the data dir. Do not start the server \
198 before that: it would create a new, empty key and the sealed data would stay \
199 unreadable."
200 .into(),
201 );
202 false
203 };
204
205 lock_down_dirs(&target, &dirs_to_lock)?;
206
207 Ok(RestoreReport {
208 data_dir: target,
209 database,
210 repos,
211 empty_repos,
212 key_restored,
213 warnings,
214 })
215}
216
217/// The target must not be inside the set, and must be missing or an empty directory.
218fn check_target(set: &Path, target: &Path) -> Result<()> {
219 if target.starts_with(set) {
220 return Err(Error::Invalid(format!(
221 "the restore target {} is inside the backup set",
222 target.display()
223 )));
224 }
225 match std::fs::symlink_metadata(target) {
226 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
227 Err(e) => Err(io_err(e)),
228 Ok(meta) if !meta.is_dir() => Err(Error::Conflict(format!(
229 "the restore target exists and is not a directory: {}",
230 target.display()
231 ))),
232 Ok(_) => {
233 let mut entries = std::fs::read_dir(target).map_err(io_err)?;
234 if entries.next().is_some() {
235 return Err(Error::Conflict(format!(
236 "refusing to restore into a non-empty directory: {}",
237 target.display()
238 )));
239 }
240 Ok(())
241 }
242 }
243}
244
245fn entry_for<'a>(manifest: &'a Manifest, path: &str) -> Result<&'a FileEntry> {
246 manifest
247 .files
248 .iter()
249 .find(|f| f.path == path)
250 .ok_or_else(|| Error::Invalid(format!("the manifest does not list {path}")))
251}
252
253/// Copies `src` to a new private file at `dst`, fsyncs it, and checks the copy against the
254/// manifest entry. A mismatch is an error and the copy is left for inspection.
255fn copy_verified(src: &Path, dst: &Path, expected: &FileEntry) -> Result<()> {
256 std::fs::copy(src, dst).map_err(io_err)?;
257 set_private(dst)?;
258 std::fs::File::open(dst)
259 .map_err(io_err)?
260 .sync_all()
261 .map_err(io_err)?;
262 let (bytes, sha) = hash_file(dst)?;
263 if bytes != expected.bytes || sha != expected.sha256 {
264 return Err(Error::Conflict(format!(
265 "{} does not match the manifest after copying to {}",
266 expected.path,
267 dst.display()
268 )));
269 }
270 Ok(())
271}
272
273/// Sets 0700 on the data dir, the `repos` tree above each restored repo, and each repo dir.
274fn lock_down_dirs(target: &Path, repo_dirs: &[PathBuf]) -> Result<()> {
275 use std::os::unix::fs::PermissionsExt;
276 let mut dirs: Vec<PathBuf> = vec![target.to_path_buf(), target.join("repos")];
277 for repo in repo_dirs {
278 let mut cur = repo.as_path();
279 while let Some(parent) = cur.parent() {
280 if parent == target.join("repos") || parent == target {
281 break;
282 }
283 dirs.push(parent.to_path_buf());
284 cur = parent;
285 }
286 dirs.push(repo.clone());
287 }
288 dirs.sort();
289 dirs.dedup();
290 for dir in dirs {
291 if dir.is_dir() {
292 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
293 .map_err(io_err)?;
294 }
295 }
296 Ok(())
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::backup::{BackupOptions, BackupSource, create_backup};
303 use forge_core::model::Visibility;
304 use std::os::unix::fs::PermissionsExt;
305 use std::process::Command;
306
307 struct Source {
308 dir: tempfile::TempDir,
309 data_dir: PathBuf,
310 source: BackupSource,
311 demo_id: String,
312 empty_id: String,
313 }
314
315 fn git_in(dir: &Path, args: &[&str]) {
316 let out = Command::new("git")
317 .current_dir(dir)
318 .env("GIT_AUTHOR_NAME", "t")
319 .env("GIT_AUTHOR_EMAIL", "t@example.invalid")
320 .env("GIT_COMMITTER_NAME", "t")
321 .env("GIT_COMMITTER_EMAIL", "t@example.invalid")
322 .args(args)
323 .output()
324 .expect("run git");
325 assert!(out.status.success(), "git {args:?}: {out:?}");
326 }
327
328 fn git_out(dir: &Path, args: &[&str]) -> String {
329 let out = Command::new("git")
330 .current_dir(dir)
331 .args(args)
332 .output()
333 .expect("run git");
334 assert!(out.status.success(), "git {args:?}: {out:?}");
335 String::from_utf8(out.stdout).expect("utf8")
336 }
337
338 /// A data dir with a user, an issue, a repo with commits/branch/tag, and an empty repo.
339 async fn source() -> Source {
340 let dir = tempfile::tempdir().expect("tempdir");
341 let data_dir = dir.path().join("data");
342 std::fs::create_dir_all(&data_dir).expect("data dir");
343 let store = Store::open(&data_dir.join("forge.db")).await.expect("open");
344 let alice = store
345 .create_user("Alice", None, "hash", false)
346 .await
347 .expect("user");
348 let git = GitEnv {
349 git_bin: "git".into(),
350 data_dir: data_dir.clone(),
351 };
352 let demo = store
353 .create_repo(&alice.id, "demo", "", Visibility::Private, "main")
354 .await
355 .expect("demo");
356 let demo_path = repo_path(&data_dir, &demo.id).expect("path");
357 let work = dir.path().join("work");
358 std::fs::create_dir_all(&work).expect("work");
359 git_in(&work, &["init", "-q", "-b", "main"]);
360 git_in(&work, &["commit", "-q", "--allow-empty", "-m", "one"]);
361 git_in(&work, &["commit", "-q", "--allow-empty", "-m", "two"]);
362 git_in(&work, &["branch", "feature"]);
363 git_in(&work, &["tag", "v1"]);
364 std::fs::create_dir_all(demo_path.parent().expect("parent")).expect("mkdir");
365 let dest = demo_path.to_str().expect("utf8").to_string();
366 git_in(
367 dir.path(),
368 &[
369 "clone",
370 "-q",
371 "--bare",
372 "--no-hardlinks",
373 work.to_str().expect("utf8"),
374 &dest,
375 ],
376 );
377 let empty = store
378 .create_repo(&alice.id, "empty", "", Visibility::Private, "main")
379 .await
380 .expect("empty");
381 init_bare(
382 &git,
383 &repo_path(&data_dir, &empty.id).expect("path"),
384 "main",
385 )
386 .await
387 .expect("init empty");
388 store
389 .create_issue(&demo.id, &alice.id, "First issue", "body")
390 .await
391 .expect("issue");
392 std::fs::write(data_dir.join("forge.key"), [7u8; 32]).expect("key");
393 std::fs::set_permissions(
394 data_dir.join("forge.key"),
395 std::fs::Permissions::from_mode(0o600),
396 )
397 .expect("key mode");
398
399 Source {
400 source: BackupSource {
401 store,
402 git,
403 base_url: "https://forge.example.invalid".into(),
404 },
405 data_dir,
406 demo_id: demo.id,
407 empty_id: empty.id,
408 dir,
409 }
410 }
411
412 fn git_bin() -> PathBuf {
413 PathBuf::from("git")
414 }
415
416 fn opts(out: &Path, include_key: bool) -> BackupOptions {
417 BackupOptions {
418 out: out.to_path_buf(),
419 include_key,
420 scope: Scope::All,
421 }
422 }
423
424 fn for_each_ref(dir: &Path) -> String {
425 git_out(dir, &["for-each-ref", "--format=%(objectname) %(refname)"])
426 }
427
428 #[tokio::test]
429 async fn round_trip_restores_store_repos_hooks_and_config() {
430 let src = source().await;
431 let out = src.dir.path().join("out");
432 let summary = create_backup(&src.source, &opts(&out, true))
433 .await
434 .expect("backup");
435 let target = src.dir.path().join("fresh");
436 let report = restore_backup(&summary.path, &target, &git_bin())
437 .await
438 .expect("restore");
439 assert!(report.key_restored);
440 assert!(report.warnings.is_empty());
441 assert_eq!(report.repos, 1);
442 assert_eq!(report.empty_repos, 1);
443
444 // Metadata: user and issue are present.
445 let restored = Store::open(&target.join("forge.db"))
446 .await
447 .expect("open restored");
448 assert!(
449 restored
450 .user_by_username("alice")
451 .await
452 .expect("q")
453 .is_some()
454 );
455 let issue = restored
456 .issue_by_number(&src.demo_id, 1)
457 .await
458 .expect("query")
459 .expect("issue 1 exists after restore");
460 assert_eq!(issue.title, "First issue");
461 drop(restored);
462
463 // Repos: same refs, correct HEAD, clean fsck.
464 for id in [&src.demo_id, &src.empty_id] {
465 let before = repo_path(&src.data_dir, id).expect("src path");
466 let after = repo_path(&target, id).expect("dst path");
467 assert_eq!(for_each_ref(&before), for_each_ref(&after), "refs of {id}");
468 assert_eq!(
469 git_out(&after, &["symbolic-ref", "HEAD"]).trim(),
470 "refs/heads/main"
471 );
472 git_in(&after, &["fsck", "--full"]);
473 }
474
475 // Managed config and hooks, and the key with 0600.
476 assert!(target.join("gitconfig").is_file());
477 assert!(target.join("hooks/pre-receive").is_file());
478 assert!(target.join("hooks/post-receive").is_file());
479 let key_mode = std::fs::metadata(target.join("forge.key"))
480 .expect("key")
481 .permissions()
482 .mode()
483 & 0o777;
484 assert_eq!(key_mode, 0o600);
485 assert_eq!(
486 std::fs::read(target.join("forge.key")).expect("key bytes"),
487 vec![7u8; 32]
488 );
489 let dir_mode = std::fs::metadata(&target)
490 .expect("meta")
491 .permissions()
492 .mode()
493 & 0o777;
494 assert_eq!(dir_mode, 0o700);
495 }
496
497 #[tokio::test]
498 async fn round_trip_restores_the_wiki_from_its_own_bundle() {
499 let src = source().await;
500 let wiki = forge_git::repo::wiki_path(&src.data_dir, &src.demo_id).expect("wiki path");
501 forge_git::wiki::ensure_wiki(&src.source.git, &wiki)
502 .await
503 .expect("create wiki");
504 forge_git::wiki::commit_page(
505 &src.source.git,
506 &wiki,
507 None,
508 &forge_git::wiki::PageEdit {
509 name: "Home",
510 content: b"# Home\n",
511 message: "Add home",
512 author_name: "alice",
513 author_email: "alice@example.invalid",
514 },
515 )
516 .await
517 .expect("commit page");
518
519 let out = src.dir.path().join("out");
520 let summary = create_backup(&src.source, &opts(&out, true))
521 .await
522 .expect("backup");
523 let manifest = std::fs::read(summary.path.join(MANIFEST_FILE)).expect("manifest");
524 let manifest: crate::backup::Manifest =
525 serde_json::from_slice(&manifest).expect("parse manifest");
526 let demo = manifest
527 .repos
528 .iter()
529 .find(|r| r.id == src.demo_id)
530 .expect("demo listed");
531 assert!(demo.wiki.is_some(), "the set records the wiki");
532
533 let target = src.dir.path().join("fresh");
534 let report = restore_backup(&summary.path, &target, &git_bin())
535 .await
536 .expect("restore");
537 assert!(report.warnings.is_empty(), "{:?}", report.warnings);
538
539 let restored = forge_git::repo::wiki_path(&target, &src.demo_id).expect("restored path");
540 assert_eq!(for_each_ref(&wiki), for_each_ref(&restored), "wiki refs");
541 assert_eq!(
542 forge_git::wiki::read_page(&restored, "Home").expect("read page"),
543 Some("# Home\n".to_string())
544 );
545 git_in(&restored, &["fsck", "--full"]);
546 }
547
548 #[tokio::test]
549 async fn restore_refuses_a_non_empty_target() {
550 let src = source().await;
551 let out = src.dir.path().join("out");
552 let summary = create_backup(&src.source, &opts(&out, false))
553 .await
554 .expect("backup");
555 let target = src.dir.path().join("busy");
556 std::fs::create_dir_all(&target).expect("mkdir");
557 std::fs::write(target.join("keep.txt"), b"user data").expect("write");
558 let err = restore_backup(&summary.path, &target, &git_bin()).await;
559 assert!(matches!(err, Err(Error::Conflict(_))), "{err:?}");
560 assert_eq!(
561 std::fs::read(target.join("keep.txt")).expect("kept"),
562 b"user data"
563 );
564 assert!(!target.join("forge.db").exists());
565 }
566
567 #[tokio::test]
568 async fn restore_refuses_a_tampered_set_and_writes_nothing() {
569 let src = source().await;
570 let out = src.dir.path().join("out");
571 let summary = create_backup(&src.source, &opts(&out, false))
572 .await
573 .expect("backup");
574 let bundle = summary.path.join(format!("repos/{}.bundle", src.demo_id));
575 let mut bytes = std::fs::read(&bundle).expect("bundle");
576 let last = bytes.len() - 1;
577 bytes[last] ^= 0xff;
578 std::fs::write(&bundle, bytes).expect("tamper");
579
580 let target = src.dir.path().join("fresh");
581 let err = restore_backup(&summary.path, &target, &git_bin()).await;
582 assert!(matches!(err, Err(Error::Invalid(_))), "{err:?}");
583 assert!(
584 !target.exists(),
585 "a refused restore must not create the target"
586 );
587 }
588
589 #[tokio::test]
590 async fn restore_without_key_warns() {
591 let src = source().await;
592 let out = src.dir.path().join("out");
593 let summary = create_backup(&src.source, &opts(&out, false))
594 .await
595 .expect("backup");
596 let target = src.dir.path().join("fresh");
597 let report = restore_backup(&summary.path, &target, &git_bin())
598 .await
599 .expect("restore");
600 assert!(!report.key_restored);
601 assert_eq!(report.warnings.len(), 1);
602 assert!(report.warnings[0].contains("NO INSTANCE KEY"));
603 assert!(!target.join("forge.key").exists());
604 }
605
606 #[tokio::test]
607 async fn restore_refuses_a_repos_only_set() {
608 let src = source().await;
609 let out = src.dir.path().join("out");
610 let summary = create_backup(
611 &src.source,
612 &BackupOptions {
613 out: out.clone(),
614 include_key: false,
615 scope: Scope::ReposOnly,
616 },
617 )
618 .await
619 .expect("backup");
620 let target = src.dir.path().join("fresh");
621 let err = restore_backup(&summary.path, &target, &git_bin()).await;
622 assert!(matches!(err, Err(Error::Invalid(_))), "{err:?}");
623 assert!(!target.exists());
624 }
625}