Skip to main content
← dvs documentation Rust API reference

dvs/files/
get.rs

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    // Check if target already exists and matches
47    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    // Store retrieved file's hashes in cache
90    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/// Result of getting a single file.
102#[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
116/// Gets files matching a glob pattern from DVS storage.
117///
118/// The pattern is matched against tracked files (paths in metadata folder).
119/// The pattern is adjusted based on cwd relative to repo root.
120pub 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    // Validate every path up front, we bail if any is invalid
133    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    /// An initialized DVS repo with a temporary backend, plus helpers to track and
228    /// retrieve files. Collapses the per-test setup boilerplate to `TestRepo::new()`.
229    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        /// Create `name` with `content` and track it, returning its path and metadata.
259        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        /// Run a real (non-dry-run) single-file `get`.
281        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        /// Overwrite the stored blob for `hashes` with `content`, defeating verification.
293        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            // Blobs are stored read-only; make it writable before overwriting.
302            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        // File still exists and matches - should return Present
333        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        // No compression so the corrupt blob still "decompresses" (copies) cleanly
348        // and reaches the hash-mismatch check.
349        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        // Stand in for the user's uncommitted local edits, then get must fail on the
354        // hash mismatch WITHOUT destroying the local file or leaking a tmp file.
355        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        // Track a.txt, then drop its local copy so a successful get would restore it.
374        repo.track("a.txt", b"a", Compression::Zstd);
375        fs::remove_file(repo.root.join("a.txt")).unwrap();
376
377        // A file that exists on disk but is not tracked by DVS.
378        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        // No partial success: the tracked file must not have been restored.
393        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        // Add files
407        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        // Verify correct files are tracked
429        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        // Delete tracked files
440        for name in expected_files {
441            fs::remove_file(repo.root.join(name)).unwrap();
442        }
443
444        // Get files back
445        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        // Verify files restored
458        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        // get_file should error on decompression or hash mismatch.
480        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        // Track two files, one of which will have its storage blob corrupted.
488        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        // Corrupt bad.txt's stored blob so its get fails while good.txt succeeds.
494        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        // Results are sorted by path: bad.txt fails, good.txt succeeds.
526        assert_eq!(results.len(), 2);
527        assert!(matches!(results[0].detail, GetDetail::Error { .. }));
528        assert!(matches!(results[1].detail, GetDetail::Success { .. }));
529
530        // on_done fired once per file, with `true` only for the success.
531        assert_eq!(done_calls.load(Ordering::SeqCst), 2);
532        assert_eq!(success_calls.load(Ordering::SeqCst), 1);
533    }
534}