steid

@jamesgill /

Rust27.8 KBCode·Blame·History·Raw
1//! Backup adapters: a consistent database snapshot, and the `tar.gz` it travels in.
2//!
3//! Two ports, one file, because they are two halves of one file format and reading them
4//! apart would mean reading the layout twice.
5//!
6//! **The archive work is blocking.** `tar` and `flate2` are synchronous, and gzipping a
7//! repository directory is real CPU rather than a wait, so every one of these runs
8//! inside `spawn_blocking` rather than pretending to be async on the runtime's threads.
9
10use std::{
11 fs::{self, File},
12 io::{self, Read, Write},
13 path::{Path, PathBuf},
14 sync::{Arc, Mutex},
15 time::{SystemTime, UNIX_EPOCH},
16};
17
18use flate2::{Compression, read::GzDecoder, write::GzEncoder};
19use sqlx::SqlitePool;
20
21use crate::application::{
22 backup::BackupManifest,
23 port::{
24 BACKUP_DATA_PREFIX, BACKUP_DATABASE, BACKUP_MANIFEST, BackupArchive, BackupError,
25 BackupPlan, DatabaseSnapshot, RestorePlan,
26 },
27};
28
29/// Where the live SQLite database file is, read out of a connection string.
30///
31/// `sqlite:steid.db?mode=rwc`, `sqlite://steid.db`, and a bare path all name a file;
32/// `sqlite::memory:` names nothing, which is `None` rather than an error — an in-memory
33/// instance genuinely has no file to snapshot, and saying so is more useful than
34/// inventing a path called `:memory:`.
35#[must_use]
36pub fn database_file(url: &str) -> Option<PathBuf> {
37 let rest = url
38 .strip_prefix("sqlite://")
39 .or_else(|| url.strip_prefix("sqlite:"))
40 .unwrap_or(url);
41
42 // Everything from the first `?` is sqlx's own options — `mode=rwc` and friends —
43 // and is not part of the filename.
44 let path = rest.split('?').next().unwrap_or_default();
45
46 (!path.is_empty() && path != ":memory:").then(|| PathBuf::from(path))
47}
48
49/// `VACUUM INTO`, which is SQLite's own answer to "copy this while it is in use".
50#[derive(Debug, Clone)]
51pub struct SqliteDatabaseSnapshot {
52 pool: SqlitePool,
53 /// The live file, for [`replace`](DatabaseSnapshot::replace). Never read when only
54 /// taking a snapshot — that goes through the pool, which is the point.
55 database: PathBuf,
56}
57
58impl SqliteDatabaseSnapshot {
59 pub fn new(pool: SqlitePool, database: impl Into<PathBuf>) -> Self {
60 Self {
61 pool,
62 database: database.into(),
63 }
64 }
65}
66
67impl DatabaseSnapshot for SqliteDatabaseSnapshot {
68 async fn snapshot(&self, workspace: &Path) -> Result<PathBuf, BackupError> {
69 let destination = workspace.join(format!("steid-snapshot-{}.db", uuid::Uuid::new_v4()));
70
71 let target = destination
72 .to_str()
73 .ok_or_else(|| BackupError::backend("the workspace path is not valid UTF-8"))?;
74
75 // Bound rather than interpolated: the path is ours, but a filename reaching a
76 // SQL statement by string concatenation is how the next one gets there too.
77 // `VACUUM INTO` refuses a destination that already exists, which is why the
78 // filename carries a fresh UUID rather than being a fixed name in a fresh
79 // directory — a workspace reused by mistake fails loudly instead of silently
80 // packing yesterday's database.
81 sqlx::query("vacuum into ?")
82 .bind(target)
83 .execute(&self.pool)
84 .await
85 .map_err(BackupError::backend)?;
86
87 Ok(destination)
88 }
89
90 async fn replace(&self, source: &Path) -> Result<(), BackupError> {
91 // Nothing may hold the file across the swap. Closing a clone closes the pool
92 // every other adapter in this process shares, which is the intent: after this
93 // line the process has no database, and the only thing it may do is finish
94 // restoring and exit.
95 self.pool.close().await;
96
97 let source = source.to_path_buf();
98 let database = self.database.clone();
99
100 blocking(move || {
101 // The journal siblings go first. A `-wal` left beside a replaced database
102 // belongs to the database that is no longer there, and SQLite would apply
103 // it — which is corruption, not staleness.
104 for suffix in ["-wal", "-shm"] {
105 let sibling = sibling(&database, suffix);
106 if sibling.exists() {
107 fs::remove_file(&sibling).map_err(BackupError::backend)?;
108 }
109 }
110
111 move_file(&source, &database)
112 })
113 .await
114 }
115}
116
117/// One backup as a gzipped tar.
118///
119/// The layout is the port's contract: `manifest.json`, `steid.db`, and the data
120/// directory under `data/`.
121#[derive(Debug, Clone, Copy, Default)]
122pub struct TarGzBackupArchive;
123
124impl TarGzBackupArchive {
125 #[must_use]
126 pub fn new() -> Self {
127 Self
128 }
129}
130
131impl BackupArchive for TarGzBackupArchive {
132 async fn pack(&self, plan: BackupPlan) -> Result<u64, BackupError> {
133 blocking(move || pack(&plan)).await
134 }
135
136 async fn manifest(&self, archive: &Path) -> Result<BackupManifest, BackupError> {
137 let archive = archive.to_path_buf();
138 blocking(move || read_manifest(&archive)).await
139 }
140
141 async fn unpack(&self, plan: RestorePlan) -> Result<(), BackupError> {
142 blocking(move || unpack(&plan)).await
143 }
144}
145
146/// Runs synchronous filesystem work off the runtime's threads.
147async fn blocking<T, F>(work: F) -> Result<T, BackupError>
148where
149 F: FnOnce() -> Result<T, BackupError> + Send + 'static,
150 T: Send + 'static,
151{
152 tokio::task::spawn_blocking(work)
153 .await
154 .map_err(BackupError::backend)?
155}
156
157/// `steid.db` → `steid.db-wal`.
158fn sibling(database: &Path, suffix: &str) -> PathBuf {
159 let mut name = database.as_os_str().to_os_string();
160 name.push(suffix);
161 PathBuf::from(name)
162}
163
164/// Moves a file, falling back to a copy when the two ends are on different filesystems.
165///
166/// A backup destination on a mounted volume is the ordinary case rather than the exotic
167/// one, and `rename` answers `EXDEV` there.
168fn move_file(source: &Path, destination: &Path) -> Result<(), BackupError> {
169 if let Some(parent) = destination.parent() {
170 fs::create_dir_all(parent).map_err(BackupError::backend)?;
171 }
172
173 match fs::rename(source, destination) {
174 Ok(()) => Ok(()),
175 Err(_) => {
176 fs::copy(source, destination).map_err(BackupError::backend)?;
177 // Best effort: the copy is what matters, and a scratch file left behind is
178 // the caller's workspace to clean up anyway.
179 let _ = fs::remove_file(source);
180 Ok(())
181 }
182 }
183}
184
185/// Writes the archive, and says how large it came out.
186///
187/// Built beside the destination as `<name>.partial` and renamed at the end, so an
188/// interrupted backup leaves something obviously unfinished rather than a truncated
189/// `.tar.gz` a later restore would trust.
190fn pack(plan: &BackupPlan) -> Result<u64, BackupError> {
191 if plan.destination.exists() {
192 return Err(BackupError::refused(format!(
193 "{} already exists",
194 plan.destination.display()
195 )));
196 }
197
198 if let Some(parent) = plan
199 .destination
200 .parent()
201 .filter(|parent| !parent.as_os_str().is_empty())
202 {
203 fs::create_dir_all(parent).map_err(BackupError::backend)?;
204 }
205
206 let partial = sibling(&plan.destination, ".partial");
207 let file = File::create(&partial).map_err(BackupError::backend)?;
208 let mut builder = tar::Builder::new(GzEncoder::new(file, Compression::default()));
209 // Never follow a symlink out of the data directory: with this off, a link is
210 // archived as a link, and `unpack` refuses one outright.
211 builder.follow_symlinks(false);
212
213 let manifest = serde_json::to_vec_pretty(&plan.manifest).map_err(BackupError::backend)?;
214 let mut header = tar::Header::new_gnu();
215 header.set_size(manifest.len() as u64);
216 header.set_mode(0o600);
217 header.set_mtime(epoch_seconds());
218 header.set_cksum();
219 builder
220 .append_data(&mut header, BACKUP_MANIFEST, manifest.as_slice())
221 .map_err(BackupError::backend)?;
222
223 builder
224 .append_path_with_name(&plan.database, BACKUP_DATABASE)
225 .map_err(BackupError::backend)?;
226
227 append_data_dir(&mut builder, &plan.data_dir)?;
228
229 let encoder = builder.into_inner().map_err(BackupError::backend)?;
230 let mut file = encoder.finish().map_err(BackupError::backend)?;
231 file.flush().map_err(BackupError::backend)?;
232 drop(file);
233
234 let bytes = fs::metadata(&partial).map_err(BackupError::backend)?.len();
235 fs::rename(&partial, &plan.destination).map_err(BackupError::backend)?;
236
237 Ok(bytes)
238}
239
240fn epoch_seconds() -> u64 {
241 SystemTime::now()
242 .duration_since(UNIX_EPOCH)
243 .map(|since| since.as_secs())
244 .unwrap_or_default()
245}
246
247/// Adds the whole data directory under `data/`.
248///
249/// Walked by hand rather than with `append_dir_all`, for three rules that walk cannot
250/// express:
251///
252/// - **Symlinks are skipped**, not followed and not stored. A link inside a bare
253/// repository is not something git puts there, and following one is the only way a
254/// path outside the data directory could get into the archive.
255/// - **Top-level dot-directories are skipped.** A handle is `[a-z0-9-]` and can never
256/// start with a dot, so nothing here is a repository — and the backup's own scratch
257/// directory lives at `<data_dir>/.backup`, which must not end up inside the archive
258/// it is being built in.
259/// - **Anything that is neither a file nor a directory is skipped**, with a line saying
260/// so, rather than failing the backup over a stray socket or device node.
261fn append_data_dir<W: Write>(
262 builder: &mut tar::Builder<W>,
263 data_dir: &Path,
264) -> Result<(), BackupError> {
265 // The root, always — a restore of an instance with no repositories must still make
266 // the directory.
267 let mut root = tar::Header::new_gnu();
268 root.set_entry_type(tar::EntryType::Directory);
269 root.set_size(0);
270 root.set_mode(0o750);
271 root.set_mtime(epoch_seconds());
272 root.set_cksum();
273 builder
274 .append_data(&mut root, format!("{BACKUP_DATA_PREFIX}/"), io::empty())
275 .map_err(BackupError::backend)?;
276
277 if !data_dir.is_dir() {
278 return Ok(());
279 }
280
281 walk(builder, data_dir, data_dir, true)
282}
283
284fn walk<W: Write>(
285 builder: &mut tar::Builder<W>,
286 data_dir: &Path,
287 dir: &Path,
288 top: bool,
289) -> Result<(), BackupError> {
290 let mut entries: Vec<PathBuf> = fs::read_dir(dir)
291 .map_err(BackupError::backend)?
292 .map(|entry| entry.map(|entry| entry.path()))
293 .collect::<Result<_, _>>()
294 .map_err(BackupError::backend)?;
295
296 // Directory order is filesystem order, which differs between machines. Sorting makes
297 // two backups of the same state produce the same entry order, which is what lets
298 // anyone compare them.
299 entries.sort();
300
301 for path in entries {
302 let name = path
303 .file_name()
304 .and_then(|name| name.to_str())
305 .ok_or_else(|| {
306 BackupError::backend(format!("{} is not a valid UTF-8 name", path.display()))
307 })?;
308
309 if top && name.starts_with('.') {
310 continue;
311 }
312
313 let metadata = fs::symlink_metadata(&path).map_err(BackupError::backend)?;
314
315 let relative = path.strip_prefix(data_dir).map_err(BackupError::backend)?;
316 let entry = format!("{BACKUP_DATA_PREFIX}/{}", relative.to_string_lossy());
317
318 if metadata.is_symlink() {
319 eprintln!("steid: backup skipped the symlink {}", path.display());
320 continue;
321 }
322
323 if metadata.is_dir() {
324 builder
325 .append_dir(format!("{entry}/"), &path)
326 .map_err(BackupError::backend)?;
327 walk(builder, data_dir, &path, false)?;
328 } else if metadata.is_file() {
329 let mut file = File::open(&path).map_err(BackupError::backend)?;
330 builder
331 .append_file(&entry, &mut file)
332 .map_err(BackupError::backend)?;
333 } else {
334 eprintln!(
335 "steid: backup skipped {}, which is not a file",
336 path.display()
337 );
338 }
339 }
340
341 Ok(())
342}
343
344/// The manifest, without extracting anything else.
345fn read_manifest(archive: &Path) -> Result<BackupManifest, BackupError> {
346 /// Enough for a manifest many times over, and not enough for a hostile archive to
347 /// make this allocate. It holds three numbers and two short strings.
348 const MAX: u64 = 64 * 1024;
349
350 for entry in open(archive)?.entries().map_err(BackupError::backend)? {
351 let mut entry = entry.map_err(BackupError::backend)?;
352 let path = entry.path().map_err(BackupError::backend)?.into_owned();
353
354 if path != Path::new(BACKUP_MANIFEST) {
355 continue;
356 }
357
358 let mut json = String::new();
359 entry
360 .by_ref()
361 .take(MAX)
362 .read_to_string(&mut json)
363 .map_err(|error| {
364 BackupError::malformed(format!("the manifest is unreadable: {error}"))
365 })?;
366
367 return serde_json::from_str(&json).map_err(|error| {
368 BackupError::malformed(format!("the manifest does not parse: {error}"))
369 });
370 }
371
372 Err(BackupError::malformed(
373 "there is no manifest.json in it, so it is not a Steid backup",
374 ))
375}
376
377fn open(archive: &Path) -> Result<tar::Archive<GzDecoder<File>>, BackupError> {
378 let file = File::open(archive).map_err(|error| {
379 BackupError::malformed(format!(
380 "{} could not be opened: {error}",
381 archive.display()
382 ))
383 })?;
384
385 Ok(tar::Archive::new(GzDecoder::new(file)))
386}
387
388/// Extracts an archive, refusing anything it did not write.
389///
390/// Every entry is checked before it is written, and the checks are the security of the
391/// whole feature: an archive is a file somebody hands the process, and `tar` has been a
392/// path-traversal primitive for forty years. An entry may only be a regular file or a
393/// directory, may only sit at `manifest.json`, `steid.db` or under `data/`, may not be
394/// absolute, and may not contain `..` — so no entry can name a path outside the two
395/// destinations, whatever the archive says.
396fn unpack(plan: &RestorePlan) -> Result<(), BackupError> {
397 let mut database = false;
398
399 for entry in open(&plan.archive)?
400 .entries()
401 .map_err(BackupError::backend)?
402 {
403 let mut entry = entry.map_err(BackupError::backend)?;
404 let path = entry.path().map_err(BackupError::backend)?.into_owned();
405
406 let kind = entry.header().entry_type();
407 if !(kind.is_file() || kind.is_dir()) {
408 return Err(BackupError::malformed(format!(
409 "{} is a {kind:?}, and a backup holds only files and directories",
410 path.display()
411 )));
412 }
413
414 let Some(relative) = safe(&path) else {
415 return Err(BackupError::malformed(format!(
416 "{} is not a path a backup may contain",
417 path.display()
418 )));
419 };
420
421 let destination = if relative == Path::new(BACKUP_MANIFEST) {
422 // Already read, and not something the instance keeps.
423 continue;
424 } else if relative == Path::new(BACKUP_DATABASE) {
425 database = true;
426 plan.database.clone()
427 } else if let Ok(rest) = relative.strip_prefix(BACKUP_DATA_PREFIX) {
428 plan.data_dir.join(rest)
429 } else {
430 return Err(BackupError::malformed(format!(
431 "{} is not part of a Steid backup",
432 path.display()
433 )));
434 };
435
436 if kind.is_dir() {
437 fs::create_dir_all(&destination).map_err(BackupError::backend)?;
438 continue;
439 }
440
441 // The use case established that the instance is empty; anything here means it
442 // was wrong, and overwriting is the one outcome a restore must never have.
443 if destination.exists() {
444 return Err(BackupError::refused(format!(
445 "{} already exists",
446 destination.display()
447 )));
448 }
449
450 if let Some(parent) = destination.parent() {
451 fs::create_dir_all(parent).map_err(BackupError::backend)?;
452 }
453
454 entry.unpack(&destination).map_err(BackupError::backend)?;
455 }
456
457 if !database {
458 return Err(BackupError::malformed(
459 "there is no steid.db in it, so it is not a Steid backup",
460 ));
461 }
462
463 Ok(())
464}
465
466/// An entry path, if it is one a backup may legally contain.
467///
468/// Relative, no `..`, no root, no Windows prefix, and no empty components. `None` means
469/// refuse — never "sanitise and carry on", because a path that needed sanitising was
470/// written by something that is not Steid.
471fn safe(path: &Path) -> Option<&Path> {
472 use std::path::Component;
473
474 let ordinary = path
475 .components()
476 .all(|component| matches!(component, Component::Normal(_)));
477
478 (ordinary && path.components().next().is_some()).then_some(path)
479}
480
481/// The in-memory [`DatabaseSnapshot`], for use-case tests.
482///
483/// Touches no disk: it hands back a path inside the workspace it was given and records
484/// what it was asked to do, which is all a use case's decisions can be checked against.
485#[derive(Debug, Default, Clone)]
486pub struct InMemoryDatabaseSnapshot {
487 taken: Arc<Mutex<u64>>,
488 installed: Arc<Mutex<Vec<PathBuf>>>,
489}
490
491impl InMemoryDatabaseSnapshot {
492 #[must_use]
493 pub fn new() -> Self {
494 Self::default()
495 }
496
497 /// How many snapshots were taken.
498 #[must_use]
499 pub fn taken(&self) -> u64 {
500 *self.taken.lock().expect("lock poisoned")
501 }
502
503 /// Every file that was installed as the live database, in order.
504 #[must_use]
505 pub fn installed(&self) -> Vec<PathBuf> {
506 self.installed.lock().expect("lock poisoned").clone()
507 }
508}
509
510impl DatabaseSnapshot for InMemoryDatabaseSnapshot {
511 async fn snapshot(&self, workspace: &Path) -> Result<PathBuf, BackupError> {
512 let mut taken = self.taken.lock().expect("lock poisoned");
513 *taken += 1;
514
515 Ok(workspace.join(format!("snapshot-{taken}.db")))
516 }
517
518 async fn replace(&self, source: &Path) -> Result<(), BackupError> {
519 self.installed
520 .lock()
521 .expect("lock poisoned")
522 .push(source.to_path_buf());
523
524 Ok(())
525 }
526}
527
528/// The in-memory [`BackupArchive`], for use-case tests.
529///
530/// Archives are entries in a map keyed by their path, so a backup taken through one
531/// handle can be restored through a clone of it — which is how a test copies an archive
532/// from one instance to another without a filesystem.
533#[derive(Debug, Default, Clone)]
534pub struct InMemoryBackupArchive {
535 manifests: Arc<Mutex<Vec<(PathBuf, BackupManifest)>>>,
536 packed: Arc<Mutex<Vec<BackupPlan>>>,
537 unpacked: Arc<Mutex<Vec<RestorePlan>>>,
538}
539
540impl InMemoryBackupArchive {
541 #[must_use]
542 pub fn new() -> Self {
543 Self::default()
544 }
545
546 /// Puts an archive there without going through [`pack`](BackupArchive::pack), for
547 /// testing what a restore does with a manifest it did not write.
548 pub fn plant(&self, path: impl Into<PathBuf>, manifest: BackupManifest) {
549 self.manifests
550 .lock()
551 .expect("lock poisoned")
552 .push((path.into(), manifest));
553 }
554
555 #[must_use]
556 pub fn packed(&self) -> Vec<BackupPlan> {
557 self.packed.lock().expect("lock poisoned").clone()
558 }
559
560 #[must_use]
561 pub fn unpacked(&self) -> Vec<RestorePlan> {
562 self.unpacked.lock().expect("lock poisoned").clone()
563 }
564}
565
566impl BackupArchive for InMemoryBackupArchive {
567 async fn pack(&self, plan: BackupPlan) -> Result<u64, BackupError> {
568 self.plant(plan.destination.clone(), plan.manifest.clone());
569 self.packed.lock().expect("lock poisoned").push(plan);
570
571 Ok(1024)
572 }
573
574 async fn manifest(&self, archive: &Path) -> Result<BackupManifest, BackupError> {
575 self.manifests
576 .lock()
577 .expect("lock poisoned")
578 .iter()
579 .find(|(path, _)| path == archive)
580 .map(|(_, manifest)| manifest.clone())
581 .ok_or_else(|| BackupError::malformed(format!("no archive at {}", archive.display())))
582 }
583
584 async fn unpack(&self, plan: RestorePlan) -> Result<(), BackupError> {
585 // Reads the manifest first, so a plan naming an archive that is not there fails
586 // here exactly as it would on disk.
587 self.manifest(&plan.archive).await?;
588 self.unpacked.lock().expect("lock poisoned").push(plan);
589
590 Ok(())
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597 use crate::application::backup::BackupCounts;
598
599 fn manifest() -> BackupManifest {
600 BackupManifest {
601 version: "0.4.0".to_owned(),
602 taken_at: "2026-09-08T14:25:30Z".to_owned(),
603 counts: BackupCounts {
604 users: 1,
605 organisations: 1,
606 repositories: 2,
607 },
608 }
609 }
610
611 /// A data directory with one bare-repository-shaped tree in it.
612 fn fixture(root: &Path) -> PathBuf {
613 let data = root.join("repos");
614 fs::create_dir_all(data.join("james/steid.git/refs/heads")).expect("mkdir");
615 fs::write(data.join("james/steid.git/HEAD"), b"ref: refs/heads/main\n").expect("write");
616 fs::write(data.join("james/steid.git/refs/heads/main"), b"abc123\n").expect("write");
617 data
618 }
619
620 fn plan(root: &Path, data: &Path) -> BackupPlan {
621 let database = root.join("snapshot.db");
622 fs::write(&database, b"SQLite format 3\0not really").expect("write");
623
624 BackupPlan {
625 destination: root.join("out.tar.gz"),
626 database,
627 data_dir: data.to_path_buf(),
628 manifest: manifest(),
629 }
630 }
631
632 #[test]
633 fn a_database_url_names_a_file() {
634 assert_eq!(
635 database_file("sqlite:/var/lib/steid/steid.db?mode=rwc"),
636 Some(PathBuf::from("/var/lib/steid/steid.db"))
637 );
638 assert_eq!(
639 database_file("sqlite://steid.db"),
640 Some(PathBuf::from("steid.db"))
641 );
642 assert_eq!(database_file("steid.db"), Some(PathBuf::from("steid.db")));
643 assert_eq!(database_file("sqlite::memory:"), None);
644 }
645
646 #[test]
647 fn an_archive_round_trips_the_database_and_every_repository() {
648 let root = tempfile::tempdir().expect("tempdir");
649 let data = fixture(root.path());
650 let plan = plan(root.path(), &data);
651
652 let bytes = pack(&plan).expect("should pack");
653 assert!(bytes > 0);
654 assert_eq!(
655 read_manifest(&plan.destination).expect("manifest"),
656 manifest()
657 );
658
659 let into = root.path().join("restored");
660 unpack(&RestorePlan {
661 archive: plan.destination.clone(),
662 database: into.join("steid.db"),
663 data_dir: into.join("repos"),
664 })
665 .expect("should unpack");
666
667 assert_eq!(
668 fs::read(into.join("steid.db")).expect("read"),
669 b"SQLite format 3\0not really"
670 );
671 assert_eq!(
672 fs::read_to_string(into.join("repos/james/steid.git/HEAD")).expect("read"),
673 "ref: refs/heads/main\n"
674 );
675 assert!(into.join("repos/james/steid.git/refs/heads/main").is_file());
676 }
677
678 #[test]
679 fn an_empty_data_directory_still_restores_as_a_directory() {
680 let root = tempfile::tempdir().expect("tempdir");
681 let data = root.path().join("repos");
682 fs::create_dir_all(&data).expect("mkdir");
683
684 let plan = plan(root.path(), &data);
685 pack(&plan).expect("should pack");
686
687 let into = root.path().join("restored");
688 unpack(&RestorePlan {
689 archive: plan.destination.clone(),
690 database: into.join("steid.db"),
691 data_dir: into.join("repos"),
692 })
693 .expect("should unpack");
694
695 assert!(into.join("repos").is_dir());
696 }
697
698 #[test]
699 fn no_entry_names_a_path_outside_the_data_directory() {
700 let root = tempfile::tempdir().expect("tempdir");
701 let data = fixture(root.path());
702
703 // A secret next door, and a symlink from inside the data directory to it: the
704 // one way a walk could pull a path in from outside.
705 let secret = root.path().join("secret.txt");
706 fs::write(&secret, b"not yours").expect("write");
707 std::os::unix::fs::symlink(&secret, data.join("james/escape")).expect("symlink");
708 // And the backup's own scratch directory, which sits inside the data directory
709 // and must not end up inside the archive being built in it.
710 fs::create_dir_all(data.join(".backup/whatever")).expect("mkdir");
711 fs::write(data.join(".backup/whatever/big.db"), b"scratch").expect("write");
712
713 let plan = plan(root.path(), &data);
714 pack(&plan).expect("should pack");
715
716 let names = entry_names(&plan.destination);
717
718 for name in &names {
719 let path = Path::new(name);
720 assert!(
721 safe(path).is_some(),
722 "{name} is not a path a restore would accept"
723 );
724 assert!(
725 name == BACKUP_MANIFEST
726 || name == BACKUP_DATABASE
727 || name.starts_with(&format!("{BACKUP_DATA_PREFIX}/")),
728 "{name} is outside the archive's three destinations"
729 );
730 }
731
732 assert!(
733 !names.iter().any(|name| name.contains("escape")),
734 "the symlink was archived: {names:?}"
735 );
736 assert!(
737 !names.iter().any(|name| name.contains(".backup")),
738 "the scratch directory was archived: {names:?}"
739 );
740 }
741
742 #[test]
743 fn a_restore_refuses_an_entry_that_climbs_out() {
744 assert!(safe(Path::new("data/james/steid.git/HEAD")).is_some());
745 assert!(safe(Path::new("../etc/passwd")).is_none());
746 assert!(safe(Path::new("data/../../etc/passwd")).is_none());
747 assert!(safe(Path::new("/etc/passwd")).is_none());
748 assert!(safe(Path::new("")).is_none());
749 }
750
751 #[test]
752 fn a_restore_refuses_to_overwrite_what_is_already_there() {
753 let root = tempfile::tempdir().expect("tempdir");
754 let data = fixture(root.path());
755 let plan = plan(root.path(), &data);
756 pack(&plan).expect("should pack");
757
758 let into = root.path().join("restored");
759 fs::create_dir_all(into.join("repos/james/steid.git")).expect("mkdir");
760 fs::write(into.join("repos/james/steid.git/HEAD"), b"mine\n").expect("write");
761
762 let refused = unpack(&RestorePlan {
763 archive: plan.destination.clone(),
764 database: into.join("steid.db"),
765 data_dir: into.join("repos"),
766 });
767
768 assert!(
769 matches!(refused, Err(BackupError::Refused(_))),
770 "{refused:?}"
771 );
772 assert_eq!(
773 fs::read_to_string(into.join("repos/james/steid.git/HEAD")).expect("read"),
774 "mine\n",
775 "the existing file was left alone"
776 );
777 }
778
779 #[test]
780 fn something_that_is_not_a_backup_is_said_to_be_one() {
781 let root = tempfile::tempdir().expect("tempdir");
782 let not = root.path().join("holiday.tar.gz");
783 fs::write(&not, b"not a gzip at all").expect("write");
784
785 assert!(matches!(
786 read_manifest(&not),
787 Err(BackupError::Malformed(_) | BackupError::Backend(_))
788 ));
789 }
790
791 #[test]
792 fn packing_refuses_to_overwrite_an_existing_archive() {
793 let root = tempfile::tempdir().expect("tempdir");
794 let data = fixture(root.path());
795 let plan = plan(root.path(), &data);
796 fs::write(&plan.destination, b"someone else's").expect("write");
797
798 assert!(matches!(pack(&plan), Err(BackupError::Refused(_))));
799 }
800
801 /// Every entry name in an archive, in order.
802 fn entry_names(archive: &Path) -> Vec<String> {
803 open(archive)
804 .expect("open")
805 .entries()
806 .expect("entries")
807 .map(|entry| {
808 entry
809 .expect("entry")
810 .path()
811 .expect("path")
812 .to_string_lossy()
813 .into_owned()
814 })
815 .collect()
816 }
817}