1use 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 #[default]
22 Auto,
23 Hardlink,
25 Reflink,
27 Copy,
29 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
60pub fn same_filesystem(a: &Path, b: &Path) -> bool {
63 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
127pub 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 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 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 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
190fn 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 || e.raw_os_error()
205 .map(|n| n == 18 || n == 95 || n == 38 )
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 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 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}