Skip to content

Commit a1bfbc8

Browse files
authored
restore: preserve hardlinks on restore (#492)
This is the simplest (and probably least efficient) way to address #16 This issue **has been open since 2022**, and it seems potentially like a way to have very confusing BUGS if you backup this folder: ```sh wstein@lite:/tmp$ mkdir link wstein@lite:/tmp$ cd link/ wstein@lite:/tmp/link$ ls wstein@lite:/tmp/link$ echo "foo" > A.txt wstein@lite:/tmp/link$ ln A.txt B.txt wstein@lite:/tmp/link$ echo "bar" >> B.txt wstein@lite:/tmp/link$ more A.txt foo bar wstein@lite:/tmp/link$ more B.txt foo bar ``` then restore it, and suddenly it behave very differently after the restore. I'm building software where rustic backup/restore has to actually work well -- it's not just for emergency recovery, but part of the normal lifecycle of user data. I ran some big tests of backup/restore, and only after fixing this hard link issue was the filesystem restored properly. Fortunately, restore already records inode, device id, and link count metadata for files. But the restore path recreated every file as an independent plain file, so **hardlinks were silently de-linked on restore**. This PR adds a post-restore hardlink pass keyed by the stored `(device_id, inode)` identity. After file contents and metadata are restored, sibling paths in each hardlink group are replaced with hardlinks to a canonical restored path. Also add a test using the existing backup fixture that contains a hardlink pair. Obviously, it could make good sense to close this if: - you don't like that it isn't the globally optimal way to solve this problem (since it restores each linked file, then combines them, rather than restoring only one). It was just the minimal way to do this that at the end preserves the links. - I didn't worry at all about non-POSIX If you close this, it would probably be good though to mention clearly on the main rustic README page that hardlinks silently break on backup --> restore, as it can lead to major bugs for users, and it's only something they might find via careful testing / debugging of errors popping up later (that was the case for me). "hard link" isn't mentioned anywhere on main rustic README or issue tracker (it's in this rustic_core issue). For anybody who does need this, I put a release here for linux: https://github.com/sagemathinc/rustic/releases Thanks!
1 parent 910b42e commit a1bfbc8

3 files changed

Lines changed: 159 additions & 0 deletions

File tree

crates/core/src/backend/local_destination.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ pub enum LocalDestinationErrorKind {
102102
filename: PathBuf,
103103
source: std::io::Error,
104104
},
105+
/// failed to create hardlink from `{source_path:?}` to `{filename:?}` with `{source:?}`
106+
HardLinkingFailed {
107+
source_path: PathBuf,
108+
filename: PathBuf,
109+
source: std::io::Error,
110+
},
105111
}
106112

107113
pub(crate) type LocalDestinationResult<T> = Result<T, LocalDestinationErrorKind>;
@@ -791,4 +797,37 @@ impl LocalDestination {
791797
.map_err(LocalDestinationErrorKind::CouldNotWriteToBuffer)?;
792798
Ok(())
793799
}
800+
801+
/// Create a hardlink `item` pointing to `source_item`, both relative to the base path.
802+
///
803+
/// # Arguments
804+
///
805+
/// * `source_item` - The already-restored file to link to
806+
/// * `item` - The path to create as a hardlink
807+
///
808+
/// # Errors
809+
///
810+
/// * If the new hardlink does not have a parent directory.
811+
/// * If the directory could not be created.
812+
/// * If the hardlink could not be created.
813+
pub(crate) fn hard_link(
814+
&self,
815+
source_item: impl AsRef<Path>,
816+
item: impl AsRef<Path>,
817+
) -> LocalDestinationResult<()> {
818+
let source_path = self.path(source_item);
819+
let filename = self.path(item);
820+
let dir = filename
821+
.parent()
822+
.ok_or_else(|| LocalDestinationErrorKind::FileDoesNotHaveParent(filename.clone()))?;
823+
fs::create_dir_all(dir).map_err(LocalDestinationErrorKind::DirectoryCreationFailed)?;
824+
fs::hard_link(&source_path, &filename).map_err(|err| {
825+
LocalDestinationErrorKind::HardLinkingFailed {
826+
source_path,
827+
filename,
828+
source: err,
829+
}
830+
})?;
831+
Ok(())
832+
}
794833
}

crates/core/src/commands/restore.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ pub struct RestoreStats {
9090
pub dirs: FileDirStats,
9191
}
9292

93+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
94+
struct HardlinkKey {
95+
device_id: u64,
96+
inode: u64,
97+
}
98+
9399
/// Restore the repository to the given destination.
94100
///
95101
/// # Type Parameters
@@ -350,7 +356,11 @@ fn restore_metadata(
350356
dest: &LocalDestination,
351357
) -> RusticResult<()> {
352358
let mut dir_stack = Vec::new();
359+
let mut hardlinks = BTreeMap::<HardlinkKey, Vec<PathBuf>>::new();
353360
while let Some((path, node)) = node_streamer.next().transpose()? {
361+
if let Some(key) = hardlink_key(&node) {
362+
hardlinks.entry(key).or_default().push(path.clone());
363+
}
354364
match node.node_type {
355365
NodeType::Dir => {
356366
// set metadata for all non-parent paths in stack
@@ -373,6 +383,57 @@ fn restore_metadata(
373383
set_metadata(dest, opts, &path, &node);
374384
}
375385

386+
restore_hardlinks(dest, &hardlinks)?;
387+
388+
Ok(())
389+
}
390+
391+
fn hardlink_key(node: &Node) -> Option<HardlinkKey> {
392+
matches!(node.node_type, NodeType::File)
393+
.then_some(HardlinkKey {
394+
device_id: node.meta.device_id,
395+
inode: node.meta.inode,
396+
})
397+
.filter(|key| node.meta.links > 1 && key.device_id != 0 && key.inode != 0)
398+
}
399+
400+
fn restore_hardlinks(
401+
dest: &LocalDestination,
402+
hardlinks: &BTreeMap<HardlinkKey, Vec<PathBuf>>,
403+
) -> RusticResult<()> {
404+
for paths in hardlinks.values() {
405+
if paths.len() < 2 {
406+
continue;
407+
}
408+
409+
let canonical = &paths[0];
410+
for path in paths.iter().skip(1) {
411+
debug!(
412+
"restoring hardlink {} -> {}",
413+
path.display(),
414+
canonical.display()
415+
);
416+
let full_path = dest.path(path);
417+
dest.remove_file(&full_path).map_err(|err| {
418+
RusticError::with_source(
419+
ErrorKind::InputOutput,
420+
"Failed to remove the file `{path}` before recreating its hardlink.",
421+
err,
422+
)
423+
.attach_context("path", path.display().to_string())
424+
})?;
425+
dest.hard_link(canonical, path).map_err(|err| {
426+
RusticError::with_source(
427+
ErrorKind::InputOutput,
428+
"Failed to recreate the hardlink `{path}` from `{canonical}`.",
429+
err,
430+
)
431+
.attach_context("path", path.display().to_string())
432+
.attach_context("canonical", canonical.display().to_string())
433+
})?;
434+
}
435+
}
436+
376437
Ok(())
377438
}
378439

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,60 @@
1+
use std::{fs, path::PathBuf, str::FromStr};
12

3+
#[cfg(not(windows))]
4+
use std::os::unix::fs::MetadataExt;
5+
6+
use anyhow::Result;
7+
use pretty_assertions::assert_eq;
8+
use rstest::rstest;
9+
use tempfile::tempdir;
10+
11+
use rustic_core::{
12+
BackupOptions, LocalDestination, LsOptions, RestoreOptions, repofile::SnapshotFile,
13+
};
14+
15+
use super::{RepoOpen, TestSource, set_up_repo, tar_gz_testdata};
16+
17+
#[rstest]
18+
#[cfg(not(windows))]
19+
fn test_restore_preserves_hardlinks(
20+
tar_gz_testdata: Result<TestSource>,
21+
set_up_repo: Result<RepoOpen>,
22+
) -> Result<()> {
23+
let (source, repo) = (tar_gz_testdata?, set_up_repo?.to_indexed_ids()?);
24+
25+
let opts = BackupOptions::default().as_path(PathBuf::from_str("test")?);
26+
let _snapshot = repo.backup(&opts, &source.path_list(), SnapshotFile::default())?;
27+
28+
let repo = repo.to_indexed()?;
29+
let node = repo.node_from_snapshot_path("latest", |_| true)?;
30+
let ls_opts = LsOptions::default();
31+
let ls = repo.ls(&node, &ls_opts)?;
32+
33+
let restore_dir = tempdir()?;
34+
let dest = LocalDestination::new(
35+
restore_dir
36+
.path()
37+
.to_str()
38+
.expect("restore path is valid utf-8"),
39+
true,
40+
!node.is_dir(),
41+
)?;
42+
let restore_opts = RestoreOptions::default();
43+
let plan = repo.prepare_restore(&restore_opts, ls.clone(), &dest, false)?;
44+
repo.restore(plan, &restore_opts, ls, &dest)?;
45+
46+
let hardlink = restore_dir.path().join("test/0/tests/testfile-hardlink");
47+
let linked = restore_dir.path().join("test/0/tests/testfile");
48+
let symlink = restore_dir.path().join("test/0/tests/testfile-symlink");
49+
50+
let hardlink_meta = fs::metadata(&hardlink)?;
51+
let linked_meta = fs::metadata(&linked)?;
52+
assert_eq!(hardlink_meta.dev(), linked_meta.dev());
53+
assert_eq!(hardlink_meta.ino(), linked_meta.ino());
54+
assert_eq!(hardlink_meta.nlink(), 2);
55+
assert_eq!(linked_meta.nlink(), 2);
56+
assert_eq!(fs::read_to_string(&hardlink)?, fs::read_to_string(&linked)?);
57+
assert_eq!(fs::read_link(&symlink)?, PathBuf::from("testfile"));
58+
59+
Ok(())
60+
}

0 commit comments

Comments
 (0)