1use std::path::{Path, PathBuf};
2use std::sync::Mutex;
3
4use crate::cache::{HashCache, try_open_cache};
5use crate::files::metadata::FileMetadata;
6use crate::progress::OnFileStart;
7use crate::utils::get_threadpool;
8use crate::{Backend, Compression, DvsPaths, Outcome, cache};
9use anyhow::{Context, Result, bail};
10use fs_err as fs;
11use rayon::prelude::*;
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15fn get_file(
16 backend: &dyn Backend,
17 paths: &DvsPaths,
18 relative_path: impl AsRef<Path>,
19 cache: Option<&Mutex<HashCache>>,
20 dry_run: bool,
21 on_bytes: Option<&(dyn Fn(u64) + Send + Sync)>,
22) -> Result<(Outcome, u64)> {
23 log::debug!("Retrieving file: {}", relative_path.as_ref().display());
24 let dvs_file_path = paths.metadata_path(relative_path.as_ref());
25 if !dvs_file_path.is_file() {
26 bail!(
27 "File {} is not tracked by DVS",
28 relative_path.as_ref().display()
29 );
30 }
31
32 let metadata: FileMetadata = serde_json::from_reader(fs::File::open(&dvs_file_path)?)?;
33 log::debug!(
34 "Read metadata for {}: {}",
35 relative_path.as_ref().display(),
36 metadata.hashes
37 );
38
39 if !backend.exists(&metadata.hashes)? {
40 bail!("Storage file missing for hash: {}", metadata.hashes);
41 }
42
43 let target_path = paths.file_path(relative_path.as_ref());
44 let rel_str = relative_path.as_ref().to_string_lossy();
45
46 if target_path.is_file() {
48 let (hashes, size) = cache::hashes_for_file(&target_path, &rel_str, cache)?;
49
50 if hashes == metadata.hashes && size == metadata.size {
51 log::debug!(
52 "File {} already present locally and matches",
53 relative_path.as_ref().display()
54 );
55 return Ok((Outcome::Present, metadata.size));
56 }
57 }
58
59 if dry_run {
60 return Ok((Outcome::Copied, metadata.size));
61 }
62
63 let tmp_path = target_path.with_extension(format!("dvs-tmp.{}", Uuid::new_v4()));
64 log::debug!(
65 "Copying {} from storage to temp file {}",
66 metadata.hashes,
67 tmp_path.display()
68 );
69
70 let result = (|| {
71 let retrieved = backend
72 .retrieve(&metadata.hashes, &tmp_path, metadata.compression, on_bytes)
73 .with_context(|| format!("Failed to retrieve {}", relative_path.as_ref().display()))?;
74 if !retrieved {
75 bail!("Storage file missing for hash: {}", metadata.hashes);
76 }
77 let actual = FileMetadata::from_file(&tmp_path, Compression::None, None)?;
78 if actual.hashes != metadata.hashes {
79 bail!("Retrieved file does not match expected hash");
80 }
81 fs::rename(&tmp_path, &target_path)?;
82 Ok(actual)
83 })();
84 if result.is_err() {
85 let _ = fs::remove_file(&tmp_path);
86 }
87 let actual = result?;
88
89 if let Some(mtx) = cache {
91 if let Ok(stat) = cache::FileStat::from_path(&target_path) {
92 if let Err(e) = mtx.lock().unwrap().insert(&rel_str, &stat, &actual.hashes) {
93 log::warn!("Cache store failed after get for {rel_str}: {e}");
94 }
95 }
96 }
97
98 Ok((Outcome::Copied, metadata.size))
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct GetResult {
104 pub path: PathBuf,
105 #[serde(flatten)]
106 pub detail: GetDetail,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(untagged)]
111pub enum GetDetail {
112 Success { outcome: Outcome, size: u64 },
113 Error { error: String },
114}
115
116pub fn get_files(
121 files: Vec<PathBuf>,
122 paths: &DvsPaths,
123 backend: &dyn Backend,
124 dry_run: bool,
125 on_file_start: Option<&OnFileStart>,
126) -> Result<Vec<GetResult>> {
127 let matched_paths = paths.validate_for_get(&files);
128 if matched_paths.is_empty() {
129 return Ok(Vec::new());
130 }
131
132 let invalid = matched_paths
134 .iter()
135 .filter_map(|(path, status)| {
136 status
137 .reason()
138 .map(|reason| format!(" {}: {reason}", path.display()))
139 })
140 .collect::<Vec<_>>();
141 if !invalid.is_empty() {
142 bail!(
143 "Refusing to get, the following paths cannot be retrieved:\n{}",
144 invalid.join("\n")
145 );
146 }
147
148 let tracked_paths = matched_paths
149 .into_iter()
150 .map(|(path, _)| path)
151 .collect::<Vec<_>>();
152
153 let pool = get_threadpool(tracked_paths.len())?;
154 let cache = try_open_cache(paths);
155
156 let mut results: Vec<GetResult> = pool.install(|| {
157 tracked_paths
158 .into_par_iter()
159 .map(|relative_path| {
160 let file_size = {
161 let meta_path = paths.metadata_path(&relative_path);
162 fs::File::open(&meta_path)
163 .ok()
164 .and_then(|f| serde_json::from_reader::<_, FileMetadata>(f).ok())
165 .map(|m| m.size)
166 .unwrap_or(0)
167 };
168 let file_progress = on_file_start.map(|f| f(&relative_path, file_size));
169 let on_bytes = file_progress.as_ref().map(|fp| &*fp.on_bytes);
170
171 let result = match get_file(
172 backend,
173 paths,
174 &relative_path,
175 cache.as_ref(),
176 dry_run,
177 on_bytes,
178 ) {
179 Ok((outcome, size)) => {
180 log::info!(
181 "Successfully retrieved {} ({:?})",
182 relative_path.display(),
183 outcome
184 );
185 GetResult {
186 path: relative_path,
187 detail: GetDetail::Success { outcome, size },
188 }
189 }
190 Err(e) => {
191 log::warn!("Failed to get {}: {e}", relative_path.display());
192 GetResult {
193 path: relative_path,
194 detail: GetDetail::Error {
195 error: e.to_string(),
196 },
197 }
198 }
199 };
200 if let Some(fp) = &file_progress {
201 (fp.on_done)(matches!(result.detail, GetDetail::Success { .. }));
202 }
203 result
204 })
205 .collect()
206 });
207 results.sort_by(|a, b| a.path.cmp(&b.path));
208
209 Ok(results)
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use crate::Hashes;
216 use crate::add_files;
217 use crate::config::Config;
218 use crate::files::add::AddDetail;
219 use crate::files::status::get_status;
220 use crate::progress::FileProgress;
221 use crate::testutil::{create_file, create_temp_git_repo, init_dvs_repo};
222 use std::sync::Arc;
223 use std::sync::atomic::{AtomicUsize, Ordering};
224 use tempfile::TempDir;
225 use uuid::Uuid;
226
227 struct TestRepo {
230 _tmp: TempDir,
231 root: PathBuf,
232 config: Config,
233 paths: DvsPaths,
234 }
235
236 impl TestRepo {
237 fn new() -> Self {
238 let (_tmp, root) = create_temp_git_repo();
239 let (config, _dvs_dir) = init_dvs_repo(&root);
240 let paths =
241 DvsPaths::new(root.clone(), root.clone(), config.metadata_folder_name()).unwrap();
242 Self {
243 _tmp,
244 root,
245 config,
246 paths,
247 }
248 }
249
250 fn backend(&self) -> &dyn Backend {
251 self.config.backend()
252 }
253
254 fn cache(&self) -> Mutex<HashCache> {
255 Mutex::new(HashCache::open(&self.paths.cache_folder().join("dvs.db")).unwrap())
256 }
257
258 fn track(
260 &self,
261 name: &str,
262 content: &[u8],
263 compression: Compression,
264 ) -> (PathBuf, FileMetadata) {
265 let file_path = create_file(&self.root, name, content);
266 let metadata = FileMetadata::from_file(&file_path, compression, None).unwrap();
267 metadata
268 .save(
269 Uuid::new_v4(),
270 &file_path,
271 self.backend(),
272 &self.paths,
273 name,
274 None,
275 )
276 .unwrap();
277 (file_path, metadata)
278 }
279
280 fn get(&self, name: &str) -> Result<(Outcome, u64)> {
282 get_file(
283 self.backend(),
284 &self.paths,
285 name,
286 Some(&self.cache()),
287 false,
288 None,
289 )
290 }
291
292 fn corrupt_blob(&self, hashes: &Hashes, content: &[u8]) {
294 let hash = hashes.get_blake3();
295 let blob = self
296 .backend()
297 .local_path()
298 .unwrap()
299 .join(&hash[..2])
300 .join(&hash[2..]);
301 let mut perms = fs::metadata(&blob).unwrap().permissions();
303 #[allow(clippy::permissions_set_readonly_false)]
304 perms.set_readonly(false);
305 fs::set_permissions(&blob, perms).unwrap();
306 fs::write(&blob, content).unwrap();
307 }
308 }
309
310 #[test]
311 fn get_file_retrieves_from_storage() {
312 let repo = TestRepo::new();
313 let (file_path, _) = repo.track("retrieve.txt", b"stored content", Compression::None);
314
315 fs::remove_file(&file_path).unwrap();
316 assert!(!file_path.exists());
317
318 let (outcome, _) = repo.get("retrieve.txt").unwrap();
319 assert_eq!(outcome, Outcome::Copied);
320 assert_eq!(fs::read(&file_path).unwrap(), b"stored content");
321 assert!(
322 !fs::metadata(&file_path).unwrap().permissions().readonly(),
323 "restored file inherited the blob's read-only mode"
324 );
325 }
326
327 #[test]
328 fn get_file_returns_present_when_already_current() {
329 let repo = TestRepo::new();
330 repo.track("present.txt", b"content", Compression::Zstd);
331
332 let (outcome, _) = repo.get("present.txt").unwrap();
334 assert_eq!(outcome, Outcome::Present);
335 }
336
337 #[test]
338 fn get_file_fails_for_untracked_file() {
339 let repo = TestRepo::new();
340 let err = repo.get("untracked.txt").unwrap_err().to_string();
341 assert!(err.contains("not tracked"), "unexpected error: {err}");
342 }
343
344 #[test]
345 fn get_file_preserves_target_when_stored_blob_is_corrupt() {
346 let repo = TestRepo::new();
347 let (file_path, metadata) =
350 repo.track("precious.txt", b"original content", Compression::None);
351 repo.corrupt_blob(&metadata.hashes, b"CORRUPTED BLOB BYTES");
352
353 fs::write(&file_path, b"my uncommitted edits").unwrap();
356 let err = repo.get("precious.txt").unwrap_err().to_string();
357 assert!(
358 err.contains("does not match expected hash"),
359 "unexpected error: {err}"
360 );
361 assert_eq!(fs::read(&file_path).unwrap(), b"my uncommitted edits");
362 let tmp_left = fs::read_dir(&repo.root)
363 .unwrap()
364 .flatten()
365 .any(|e| e.file_name().to_string_lossy().contains("dvs-tmp"));
366 assert!(!tmp_left);
367 }
368
369 #[test]
370 fn get_files_aborts_batch_when_any_path_not_tracked() {
371 let repo = TestRepo::new();
372
373 repo.track("a.txt", b"a", Compression::Zstd);
375 fs::remove_file(repo.root.join("a.txt")).unwrap();
376
377 create_file(&repo.root, "untracked.txt", b"hello");
379
380 let err = get_files(
381 vec!["a.txt".into(), "untracked.txt".into()],
382 &repo.paths,
383 repo.backend(),
384 false,
385 None,
386 )
387 .unwrap_err()
388 .to_string();
389 assert!(err.contains("not tracked"), "unexpected error: {err}");
390 assert!(err.contains("untracked.txt"), "unexpected error: {err}");
391
392 assert!(
394 !repo.root.join("a.txt").exists(),
395 "no files should be retrieved when one is not tracked"
396 );
397 }
398
399 fn run_add_get_roundtrip(file_paths: Vec<PathBuf>, expected_files: &[&str]) {
400 let repo = TestRepo::new();
401
402 create_file(&repo.root, "a.txt", b"a");
403 create_file(&repo.root, "b.txt", b"b");
404 create_file(&repo.root, "c.csv", b"c");
405
406 let results = add_files(
408 file_paths.clone(),
409 &repo.paths,
410 repo.backend(),
411 None,
412 Compression::Zstd,
413 false,
414 None,
415 )
416 .unwrap();
417 assert_eq!(results.len(), expected_files.len());
418 for result in &results {
419 assert!(matches!(
420 result.detail,
421 AddDetail::Success {
422 outcome: Outcome::Copied,
423 ..
424 }
425 ));
426 }
427
428 let statuses = get_status(&repo.paths, None, None).unwrap();
430 assert_eq!(statuses.len(), expected_files.len());
431 let tracked_names: Vec<_> = statuses.iter().map(|s| s.path.to_str().unwrap()).collect();
432 for expected in expected_files {
433 assert!(
434 tracked_names.contains(expected),
435 "Expected {expected} to be tracked"
436 );
437 }
438
439 for name in expected_files {
441 fs::remove_file(repo.root.join(name)).unwrap();
442 }
443
444 let results = get_files(file_paths, &repo.paths, repo.backend(), false, None).unwrap();
446 assert_eq!(results.len(), expected_files.len());
447 for result in &results {
448 assert!(matches!(
449 result.detail,
450 GetDetail::Success {
451 outcome: Outcome::Copied,
452 size: _,
453 }
454 ));
455 }
456
457 for name in expected_files {
459 assert!(
460 repo.root.join(name).exists(),
461 "Expected {name} to be restored"
462 );
463 }
464 }
465
466 #[test]
467 fn add_get_roundtrip_with_explicit_paths() {
468 let paths: Vec<PathBuf> = vec!["a.txt".into(), "c.csv".into()];
469 run_add_get_roundtrip(paths, &["a.txt", "c.csv"]);
470 }
471
472 #[test]
473 fn get_file_errors_on_corrupted_storage() {
474 let repo = TestRepo::new();
475 let (file_path, metadata) = repo.track("data.txt", b"original content", Compression::Zstd);
476 fs::remove_file(&file_path).unwrap();
477 repo.corrupt_blob(&metadata.hashes, b"corrupted content");
478
479 assert!(repo.get("data.txt").is_err());
481 }
482
483 #[test]
484 fn get_files_invokes_on_done_for_each_file() {
485 let repo = TestRepo::new();
486
487 let (bad_path, bad_meta) = repo.track("bad.txt", b"bad content", Compression::Zstd);
489 let (good_path, _) = repo.track("good.txt", b"good content", Compression::Zstd);
490 fs::remove_file(&bad_path).unwrap();
491 fs::remove_file(&good_path).unwrap();
492
493 repo.corrupt_blob(&bad_meta.hashes, b"corrupted content");
495
496 let done_calls = Arc::new(AtomicUsize::new(0));
497 let success_calls = Arc::new(AtomicUsize::new(0));
498 let on_file_start = {
499 let done_calls = Arc::clone(&done_calls);
500 let success_calls = Arc::clone(&success_calls);
501 move |_path: &Path, _size: u64| {
502 let done_calls = Arc::clone(&done_calls);
503 let success_calls = Arc::clone(&success_calls);
504 FileProgress {
505 on_bytes: Box::new(|_| {}),
506 on_done: Box::new(move |success| {
507 done_calls.fetch_add(1, Ordering::SeqCst);
508 if success {
509 success_calls.fetch_add(1, Ordering::SeqCst);
510 }
511 }),
512 }
513 }
514 };
515
516 let results = get_files(
517 vec!["bad.txt".into(), "good.txt".into()],
518 &repo.paths,
519 repo.backend(),
520 false,
521 Some(&on_file_start),
522 )
523 .unwrap();
524
525 assert_eq!(results.len(), 2);
527 assert!(matches!(results[0].detail, GetDetail::Error { .. }));
528 assert!(matches!(results[1].detail, GetDetail::Success { .. }));
529
530 assert_eq!(done_calls.load(Ordering::SeqCst), 2);
532 assert_eq!(success_calls.load(Ordering::SeqCst), 1);
533 }
534}