Skip to main content

osdk_core/store/
link.rs

1//! Link-mode selection: how a file from the content-addressed store is
2//! materialized into an install directory.
3//!
4//! Priority: an explicit mode is honored (with graceful fallback on error);
5//! otherwise `Auto` picks the fastest same-filesystem option (hardlink, then
6//! reflink), and falls back to copy across filesystems. We never auto-select
7//! symlink: it breaks on Windows without privilege and confuses tools that
8//! `realpath` their own executable.
9
10use std::fmt;
11use std::path::Path;
12
13use serde::{Deserialize, Serialize};
14
15use crate::error::{Error, Result};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "lowercase")]
19pub enum LinkMode {
20    /// Same-fs hardlink first, else reflink, else copy.
21    #[default]
22    Auto,
23    /// `std::fs::hard_link` (fastest, zero extra space, same-fs only).
24    Hardlink,
25    /// Copy-on-write clone via `reflink-copy` (falls back to copy).
26    Reflink,
27    /// Plain byte copy (always works, uses full space).
28    Copy,
29    /// Symlink into the store (space-free but fragile; opt-in only).
30    Symlink,
31}
32
33impl fmt::Display for LinkMode {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        let s = match self {
36            LinkMode::Auto => "auto",
37            LinkMode::Hardlink => "hardlink",
38            LinkMode::Reflink => "reflink",
39            LinkMode::Copy => "copy",
40            LinkMode::Symlink => "symlink",
41        };
42        f.write_str(s)
43    }
44}
45
46impl std::str::FromStr for LinkMode {
47    type Err = Error;
48    fn from_str(s: &str) -> Result<Self> {
49        Ok(match s.to_ascii_lowercase().as_str() {
50            "auto" => LinkMode::Auto,
51            "hardlink" | "hard" => LinkMode::Hardlink,
52            "reflink" | "clone" | "cow" => LinkMode::Reflink,
53            "copy" => LinkMode::Copy,
54            "symlink" | "sym" => LinkMode::Symlink,
55            other => return Err(Error::config(format!("unknown link mode `{other}`"))),
56        })
57    }
58}
59
60/// Whether two paths live on the same filesystem (device id compare on Unix).
61/// On Windows we compare the volume prefix as a best-effort heuristic.
62pub fn same_filesystem(a: &Path, b: &Path) -> bool {
63    // Compare the nearest existing ancestors so this works before the target
64    // file is created.
65    let ea = nearest_existing(a);
66    let eb = nearest_existing(b);
67    match (ea, eb) {
68        (Some(pa), Some(pb)) => same_device(&pa, &pb),
69        _ => false,
70    }
71}
72
73fn nearest_existing(p: &Path) -> Option<std::path::PathBuf> {
74    let mut cur = Some(p);
75    while let Some(c) = cur {
76        if c.exists() {
77            return Some(c.to_path_buf());
78        }
79        cur = c.parent();
80    }
81    None
82}
83
84#[cfg(unix)]
85fn same_device(a: &Path, b: &Path) -> bool {
86    use std::os::unix::fs::MetadataExt;
87    match (std::fs::metadata(a), std::fs::metadata(b)) {
88        (Ok(ma), Ok(mb)) => ma.dev() == mb.dev(),
89        _ => false,
90    }
91}
92
93#[cfg(windows)]
94fn same_device(a: &Path, b: &Path) -> bool {
95    fn volume(p: &Path) -> Option<Vec<u16>> {
96        use std::os::windows::ffi::OsStrExt;
97        use windows_sys::Win32::Storage::FileSystem::GetVolumePathNameW;
98
99        let path = p
100            .canonicalize()
101            .unwrap_or_else(|_| p.to_path_buf())
102            .as_os_str()
103            .encode_wide()
104            .chain(std::iter::once(0))
105            .collect::<Vec<_>>();
106        let mut volume = vec![0u16; 32_768];
107        let ok = unsafe {
108            GetVolumePathNameW(
109                path.as_ptr(),
110                volume.as_mut_ptr(),
111                u32::try_from(volume.len()).ok()?,
112            )
113        };
114        if ok == 0 {
115            return None;
116        }
117        let length = volume.iter().position(|character| *character == 0)?;
118        volume.truncate(length);
119        Some(volume)
120    }
121    match (volume(a), volume(b)) {
122        (Some(x), Some(y)) => x == y,
123        _ => false,
124    }
125}
126
127/// Materialize `src` (a store blob) at `dst` using the requested mode.
128///
129/// `Auto` resolves to hardlink/reflink/copy based on same-fs detection, with
130/// automatic fallback if a chosen primitive is unsupported by the filesystem.
131/// Returns the mode actually used.
132pub fn materialize(src: &Path, dst: &Path, mode: LinkMode) -> Result<LinkMode> {
133    if let Some(parent) = dst.parent() {
134        std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
135    }
136    // Overwrite any stale target.
137    if dst.exists() || dst.symlink_metadata().is_ok() {
138        let _ = std::fs::remove_file(dst);
139    }
140
141    match mode {
142        LinkMode::Auto => {
143            let same_fs = same_filesystem(src, dst);
144            if same_fs {
145                if try_hardlink(src, dst).is_ok() {
146                    return Ok(LinkMode::Hardlink);
147                }
148                if try_reflink(src, dst)? {
149                    return Ok(LinkMode::Reflink);
150                }
151            } else if try_reflink(src, dst)? {
152                // Some filesystems support reflink across subvolumes.
153                return Ok(LinkMode::Reflink);
154            }
155            copy(src, dst)?;
156            Ok(LinkMode::Copy)
157        }
158        LinkMode::Hardlink => {
159            if try_hardlink(src, dst).is_ok() {
160                Ok(LinkMode::Hardlink)
161            } else {
162                // graceful fallback
163                copy(src, dst)?;
164                Ok(LinkMode::Copy)
165            }
166        }
167        LinkMode::Reflink => {
168            if try_reflink(src, dst)? {
169                Ok(LinkMode::Reflink)
170            } else {
171                copy(src, dst)?;
172                Ok(LinkMode::Copy)
173            }
174        }
175        LinkMode::Copy => {
176            copy(src, dst)?;
177            Ok(LinkMode::Copy)
178        }
179        LinkMode::Symlink => {
180            symlink_file(src, dst)?;
181            Ok(LinkMode::Symlink)
182        }
183    }
184}
185
186fn try_hardlink(src: &Path, dst: &Path) -> std::io::Result<()> {
187    std::fs::hard_link(src, dst)
188}
189
190/// Returns Ok(true) if reflink succeeded, Ok(false) if unsupported (caller
191/// should fall back). Propagates only unexpected IO errors.
192fn try_reflink(src: &Path, dst: &Path) -> Result<bool> {
193    match reflink_copy::reflink(src, dst) {
194        Ok(()) => Ok(true),
195        Err(e) if is_unsupported(&e) => Ok(false),
196        Err(e) => Err(Error::io(dst, e)),
197    }
198}
199
200fn is_unsupported(e: &std::io::Error) -> bool {
201    use std::io::ErrorKind::*;
202    matches!(e.kind(), Unsupported | InvalidInput)
203        // cross-device / not-supported errnos surface as Other on some platforms
204        || e.raw_os_error()
205            .map(|n| n == 18 /* EXDEV */ || n == 95 /* EOPNOTSUPP */ || n == 38 /* ENOSYS */)
206            .unwrap_or(false)
207}
208
209fn copy(src: &Path, dst: &Path) -> Result<()> {
210    std::fs::copy(src, dst).map_err(|e| Error::io(dst, e))?;
211    Ok(())
212}
213
214#[cfg(unix)]
215fn symlink_file(src: &Path, dst: &Path) -> Result<()> {
216    std::os::unix::fs::symlink(src, dst).map_err(|e| Error::io(dst, e))
217}
218
219#[cfg(windows)]
220fn symlink_file(src: &Path, dst: &Path) -> Result<()> {
221    std::os::windows::fs::symlink_file(src, dst).map_err(|e| Error::io(dst, e))
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use std::io::Write;
228
229    fn write(p: &Path, b: &[u8]) {
230        let mut f = std::fs::File::create(p).unwrap();
231        f.write_all(b).unwrap();
232    }
233
234    #[test]
235    fn same_fs_within_tempdir() {
236        let td = tempfile::tempdir().unwrap();
237        let a = td.path().join("a");
238        let b = td.path().join("sub/b");
239        std::fs::create_dir_all(b.parent().unwrap()).unwrap();
240        write(&a, b"x");
241        // both under same tempdir ⇒ same device
242        assert!(same_filesystem(&a, &b));
243    }
244
245    #[test]
246    fn auto_materialize_hardlinks_same_fs() {
247        let td = tempfile::tempdir().unwrap();
248        let src = td.path().join("blob");
249        write(&src, b"hello");
250        let dst = td.path().join("out/file");
251        let used = materialize(&src, &dst, LinkMode::Auto).unwrap();
252        // same tempdir ⇒ hardlink expected
253        assert_eq!(used, LinkMode::Hardlink);
254        assert_eq!(std::fs::read(&dst).unwrap(), b"hello");
255    }
256
257    #[test]
258    fn copy_mode_duplicates_content() {
259        let td = tempfile::tempdir().unwrap();
260        let src = td.path().join("blob");
261        write(&src, b"data");
262        let dst = td.path().join("copy");
263        let used = materialize(&src, &dst, LinkMode::Copy).unwrap();
264        assert_eq!(used, LinkMode::Copy);
265        assert_eq!(std::fs::read(&dst).unwrap(), b"data");
266    }
267
268    #[cfg(windows)]
269    #[test]
270    fn windows_volume_detection_uses_actual_volume_root() {
271        let temporary = tempfile::tempdir().unwrap();
272        let source = temporary.path().join("source");
273        let destination = temporary.path().join("nested/destination");
274        std::fs::create_dir_all(destination.parent().unwrap()).unwrap();
275        write(&source, b"volume");
276
277        assert!(same_filesystem(&source, &destination));
278    }
279
280    #[cfg(target_os = "linux")]
281    #[test]
282    fn auto_mode_falls_back_across_filesystems() {
283        let source_root = tempfile::tempdir().unwrap();
284        let Some(shared_memory) = std::path::Path::new("/dev/shm")
285            .is_dir()
286            .then(|| tempfile::tempdir_in("/dev/shm").ok())
287            .flatten()
288        else {
289            return;
290        };
291        let source = source_root.path().join("source");
292        let destination = shared_memory.path().join("destination");
293        std::fs::write(&source, b"cross-filesystem").unwrap();
294        if same_filesystem(source_root.path(), shared_memory.path()) {
295            return;
296        }
297
298        materialize(&source, &destination, LinkMode::Auto).unwrap();
299        assert_eq!(std::fs::read(destination).unwrap(), b"cross-filesystem");
300    }
301
302    #[test]
303    fn parse_link_mode() {
304        assert_eq!("auto".parse::<LinkMode>().unwrap(), LinkMode::Auto);
305        assert_eq!("hard".parse::<LinkMode>().unwrap(), LinkMode::Hardlink);
306        assert_eq!("cow".parse::<LinkMode>().unwrap(), LinkMode::Reflink);
307        assert!("bogus".parse::<LinkMode>().is_err());
308    }
309}