Skip to main content
← dvs documentation Rust API reference

dvs/files/
metadata.rs

1use std::path::Path;
2
3use anyhow::{Result, anyhow, bail};
4use fs_err as fs;
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8use crate::error::{Error, ErrorCode};
9use crate::paths::ProjectPath;
10use crate::{Backend, Compression, DvsPaths, Hashes, Outcome, StoreRequest, StoreResult};
11
12/// The dvs metadata for a given file
13#[derive(Debug, Serialize, Deserialize, Clone)]
14pub struct FileMetadata {
15    pub hashes: Hashes,
16    pub size: u64,
17    pub created_by: String,
18    pub add_time: jiff::Timestamp,
19    pub compression: Compression,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub message: Option<String>,
22}
23
24impl PartialEq for FileMetadata {
25    fn eq(&self, other: &Self) -> bool {
26        self.hashes == other.hashes && self.size == other.size
27    }
28}
29
30impl FileMetadata {
31    pub fn from_hashes(
32        hashes: Hashes,
33        size: u64,
34        compression: Compression,
35        message: Option<String>,
36    ) -> Self {
37        Self {
38            hashes,
39            size,
40            created_by: whoami::username().unwrap_or_else(|_| "unknown".to_string()),
41            add_time: jiff::Timestamp::now(),
42            compression,
43            message,
44        }
45    }
46
47    pub fn from_file(
48        path: impl AsRef<Path>,
49        compression: Compression,
50        message: Option<String>,
51    ) -> Result<Self> {
52        if !path.as_ref().is_file() {
53            bail!("Path {} is not a file", path.as_ref().display());
54        }
55
56        let (hashes, size) = Hashes::compute_from_path(path.as_ref(), &[])?;
57        let created_by = whoami::username()?;
58        let add_time = jiff::Timestamp::now();
59
60        Ok(Self {
61            hashes,
62            size,
63            created_by,
64            add_time,
65            message,
66            compression,
67        })
68    }
69
70    /// Writes the metadata file, creating its directory if needed.
71    pub fn write(&self, paths: &DvsPaths, relative_path: impl AsRef<Path>) -> Result<()> {
72        let dvs_file_path = paths.metadata_path(relative_path.as_ref());
73        if let Some(parent) = dvs_file_path.parent() {
74            fs::create_dir_all(parent)?;
75        }
76        log::debug!("Writing metadata to {}", dvs_file_path.display());
77        fs::write(
78            &dvs_file_path,
79            serde_json::to_string_pretty(self).expect("valid json"),
80        )?;
81        Ok(())
82    }
83
84    /// Whether storage has the content and the metadata file already says
85    /// exactly this, so saving would change nothing.
86    pub(crate) fn is_saved(
87        &self,
88        backend: &Backend,
89        paths: &DvsPaths,
90        relative_path: &Path,
91    ) -> Result<bool> {
92        let dvs_file_path = paths.metadata_path(relative_path);
93        let dvs_file_exists = dvs_file_path.is_file();
94        // Only check if file exists on the server if it exists locally
95        // Backend will dedup anyway
96        let storage_exists = dvs_file_exists && backend.exists(&self.hashes)?;
97
98        log::debug!(
99            "Saving {}: metadata_exists={}, storage_exists={}",
100            relative_path.display(),
101            dvs_file_exists,
102            storage_exists
103        );
104
105        if dvs_file_exists && storage_exists {
106            // we read the file anyway to make sure it's not 2 files having the same hash
107            let existing: FileMetadata = serde_json::from_reader(fs::File::open(&dvs_file_path)?)
108                .map_err(|e| {
109                Error::new(
110                    ErrorCode::BadMetadata,
111                    format!("cannot read {}: {e}", dvs_file_path.display()),
112                )
113            })?;
114            if existing == *self {
115                log::debug!("File {} is already in sync", relative_path.display());
116                return Ok(true);
117            }
118        }
119        Ok(false)
120    }
121
122    /// Returns whether the file already existed in the dvs folder and therefore is an update
123    /// and what storage reported, if it stored anything.
124    /// Copies the source file to storage and saves metadata atomically (both succeed or neither).
125    pub fn save(
126        &mut self,
127        operation_id: Uuid,
128        source_file: impl AsRef<Path>,
129        backend: &Backend,
130        paths: &DvsPaths,
131        relative_path: impl AsRef<Path>,
132        on_bytes: Option<&(dyn Fn(u64) + Send + Sync)>,
133    ) -> Result<(Outcome, Option<StoreResult>)> {
134        if self.is_saved(backend, paths, relative_path.as_ref())? {
135            return Ok((Outcome::Present, None));
136        }
137
138        let stored = self.store_and_write(
139            operation_id,
140            source_file,
141            backend,
142            paths,
143            relative_path,
144            on_bytes,
145        )?;
146        Ok((Outcome::Copied, Some(stored)))
147    }
148
149    /// Stores the file then writes the metadata, atomically: either both succeed or
150    /// neither is left behind. Returns what storage reported.
151    pub fn store_and_write(
152        &mut self,
153        operation_id: Uuid,
154        source_file: impl AsRef<Path>,
155        backend: &Backend,
156        paths: &DvsPaths,
157        relative_path: impl AsRef<Path>,
158        on_bytes: Option<&(dyn Fn(u64) + Send + Sync)>,
159    ) -> Result<StoreResult> {
160        let dvs_file_path = paths.metadata_path(relative_path.as_ref());
161
162        // 1. Store file to backend. The backend records the add in its audit log
163        // and dedups the blob internally if it is already present.
164        let path = ProjectPath::from_path(relative_path.as_ref()).map_err(|e| anyhow!("{e}"))?;
165        let store_res = backend.store(StoreRequest {
166            hashes: &self.hashes,
167            source: source_file.as_ref(),
168            compression: self.compression,
169            path,
170            operation_id,
171            message: self.message.as_deref(),
172            on_bytes,
173        });
174
175        // The server compresses with the project's setting, which may not be the one
176        // we asked for
177        if let Ok(res) = &store_res {
178            self.compression = res.compression;
179        }
180
181        // 2. Then metadata
182        let old_metadata_content = fs::read(&dvs_file_path).ok();
183        let metadata_res = self.write(paths, relative_path.as_ref());
184
185        match (store_res, metadata_res) {
186            (Ok(res), Ok(_)) => Ok(res),
187            (Err(e), Ok(_)) => {
188                log::warn!(
189                    "Storage failed, rolling back metadata for {}",
190                    relative_path.as_ref().display()
191                );
192                if let Some(old) = old_metadata_content {
193                    let _ = fs::write(&dvs_file_path, &old);
194                } else {
195                    let _ = fs::remove_file(&dvs_file_path);
196                }
197                Err(e)
198            }
199            (Ok(res), Err(_)) => {
200                log::warn!(
201                    "Metadata write failed, rolling back storage for {}",
202                    relative_path.as_ref().display()
203                );
204                if let Some(old) = old_metadata_content {
205                    let _ = fs::write(&dvs_file_path, &old);
206                } else {
207                    let _ = fs::remove_file(&dvs_file_path);
208                }
209                // Remove the blob only if this call actually created it
210                if res.created {
211                    let _ = backend.remove(&self.hashes);
212                }
213                bail!("Failed to write metadata file: {dvs_file_path:?}")
214            }
215            (Err(e), Err(_)) => {
216                log::warn!(
217                    "Both storage and metadata failed, rolling back for {}",
218                    relative_path.as_ref().display()
219                );
220                if let Some(old) = old_metadata_content {
221                    let _ = fs::write(&dvs_file_path, &old);
222                } else {
223                    let _ = fs::remove_file(&dvs_file_path);
224                }
225                bail!("Failed to write metadata file: {dvs_file_path:?}: {e}")
226            }
227        }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::testutil::{create_file, create_temp_git_repo, init_dvs_repo};
235
236    fn make_paths(root: &Path, config: &crate::config::Config) -> DvsPaths {
237        DvsPaths::new(
238            root.to_path_buf(),
239            root.to_path_buf(),
240            config.metadata_folder_name(),
241        )
242        .unwrap()
243    }
244
245    #[test]
246    fn file_metadata_from_file_creates_hashes_and_message() {
247        let (_tmp, root) = create_temp_git_repo();
248        let file_path = create_file(&root, "test.txt", b"hello world");
249
250        let metadata = FileMetadata::from_file(
251            &file_path,
252            Compression::Zstd,
253            Some("test message".to_string()),
254        )
255        .unwrap();
256
257        assert_eq!(metadata.hashes.blake3.len(), 64);
258        assert_eq!(metadata.size, 11);
259        assert_eq!(metadata.message, Some("test message".to_string()));
260    }
261
262    /// Locks in the `.dvs` JSON wire format for `add_time`: RFC 3339 with a `Z`
263    /// suffix (UTC). Changing `FileMetadata.add_time` from `String` to
264    /// `jiff::Timestamp` must keep the serialized form identical so existing
265    /// `.dvs` files round-trip unchanged.
266    #[test]
267    fn file_metadata_add_time_serde_roundtrip_rfc3339() {
268        let blake3 = "a".repeat(64);
269        let json = format!(
270            r#"{{
271                "hashes": {{"blake3": "{blake3}"}},
272                "size": 11,
273                "created_by": "tester",
274                "add_time": "2024-01-02T03:04:05Z",
275                "compression": "none"
276            }}"#
277        );
278        let meta: FileMetadata =
279            serde_json::from_str(&json).expect("parse FileMetadata with RFC 3339 timestamp");
280        assert_eq!(meta.add_time.to_string(), "2024-01-02T03:04:05Z");
281
282        let reserialized = serde_json::to_string(&meta).expect("serialize FileMetadata");
283        assert!(
284            reserialized.contains("\"add_time\":\"2024-01-02T03:04:05Z\""),
285            "add_time must serialize as RFC 3339 string; got: {reserialized}"
286        );
287    }
288
289    #[test]
290    fn file_metadata_from_nonexistent_file_fails() {
291        let tmp = tempfile::tempdir().unwrap();
292        let result =
293            FileMetadata::from_file(tmp.path().join("nonexistent.txt"), Compression::Zstd, None);
294        assert!(result.is_err());
295    }
296
297    #[test]
298    fn save_local_creates_storage_and_metadata() {
299        let (_tmp, root) = create_temp_git_repo();
300        let (config, dvs_dir) = init_dvs_repo(&root);
301        let backend = config.backend();
302        let paths = make_paths(&root, &config);
303        let file_path = create_file(&root, "data.bin", b"binary data");
304
305        let mut metadata = FileMetadata::from_file(&file_path, Compression::Zstd, None).unwrap();
306        let (outcome, stored_size) = metadata
307            .save(
308                Uuid::new_v4(),
309                &file_path,
310                backend,
311                &paths,
312                "data.bin",
313                None,
314            )
315            .unwrap();
316
317        assert_eq!(outcome, Outcome::Copied);
318        assert!(stored_size.is_some());
319        // Metadata file should exist
320        assert!(dvs_dir.join("data.bin.dvs").is_file());
321        assert!(backend.exists(&metadata.hashes).unwrap());
322    }
323
324    #[test]
325    fn save_reports_an_unreadable_record_as_bad_metadata() {
326        let (_tmp, root) = create_temp_git_repo();
327        let (config, dvs_dir) = init_dvs_repo(&root);
328        let backend = config.backend();
329        let paths = make_paths(&root, &config);
330        let file_path = create_file(&root, "data.bin", b"binary data");
331        let mut metadata = FileMetadata::from_file(&file_path, Compression::Zstd, None).unwrap();
332        metadata
333            .save(
334                Uuid::new_v4(),
335                &file_path,
336                backend,
337                &paths,
338                "data.bin",
339                None,
340            )
341            .unwrap();
342        let conflicted = "<<<<<<< HEAD\n{}\n=======\n{}\n>>>>>>> theirs\n";
343        fs::write(dvs_dir.join("data.bin.dvs"), conflicted).unwrap();
344
345        let error = metadata
346            .save(
347                Uuid::new_v4(),
348                &file_path,
349                backend,
350                &paths,
351                "data.bin",
352                None,
353            )
354            .unwrap_err();
355        assert_eq!(
356            crate::backend_error_code(&error),
357            Some(ErrorCode::BadMetadata)
358        );
359    }
360
361    #[test]
362    fn save_local_returns_present_when_already_stored() {
363        let (_tmp, root) = create_temp_git_repo();
364        let (config, _dvs_dir) = init_dvs_repo(&root);
365        let backend = config.backend();
366        let paths = make_paths(&root, &config);
367        let file_path = create_file(&root, "data.bin", b"binary data");
368
369        let mut metadata = FileMetadata::from_file(&file_path, Compression::Zstd, None).unwrap();
370        metadata
371            .save(
372                Uuid::new_v4(),
373                &file_path,
374                backend,
375                &paths,
376                "data.bin",
377                None,
378            )
379            .unwrap();
380
381        // Second save should return Present
382        let (outcome, stored_size) = metadata
383            .save(
384                Uuid::new_v4(),
385                &file_path,
386                backend,
387                &paths,
388                "data.bin",
389                None,
390            )
391            .unwrap();
392        assert_eq!(outcome, Outcome::Present);
393        assert!(stored_size.is_none());
394    }
395
396    #[test]
397    fn save_local_updates_metadata_when_content_matches_different_file() {
398        // - Add file A with content "foo" (hash H1)
399        // - Add file B with content "bar" (hash H2)
400        // - Change file B's content to "foo" (now hash H1)
401        // - Run `add` on B
402        // => B's metadata is updated to hash H1
403        let (_tmp, root) = create_temp_git_repo();
404        let (config, dvs_dir) = init_dvs_repo(&root);
405        let backend = config.backend();
406        let paths = make_paths(&root, &config);
407
408        // Add file A with content "foo" (hash H1)
409        let file_a = create_file(&root, "a.txt", b"foo");
410        let mut metadata_a = FileMetadata::from_file(&file_a, Compression::Zstd, None).unwrap();
411        metadata_a
412            .save(Uuid::new_v4(), &file_a, backend, &paths, "a.txt", None)
413            .unwrap();
414        let hash_h1 = metadata_a.hashes.blake3.clone();
415
416        // Add file B with content "bar" (hash H2)
417        let file_b = create_file(&root, "b.txt", b"bar");
418        let mut metadata_b = FileMetadata::from_file(&file_b, Compression::Zstd, None).unwrap();
419        metadata_b
420            .save(Uuid::new_v4(), &file_b, backend, &paths, "b.txt", None)
421            .unwrap();
422        let hash_h2 = metadata_b.hashes.blake3.clone();
423        assert_ne!(hash_h1, hash_h2);
424
425        // Change file B's content to "foo" (now hash H1)
426        fs::write(&file_b, b"foo").unwrap();
427
428        // Run add on B with new content
429        let mut metadata_b_new = FileMetadata::from_file(&file_b, Compression::Zstd, None).unwrap();
430        assert_eq!(metadata_b_new.hashes.blake3, hash_h1);
431
432        metadata_b_new
433            .save(Uuid::new_v4(), &file_b, backend, &paths, "b.txt", None)
434            .unwrap();
435
436        // Verify metadata was updated
437        let dvs_file = dvs_dir.join("b.txt.dvs");
438        let stored: FileMetadata =
439            serde_json::from_reader(fs::File::open(&dvs_file).unwrap()).unwrap();
440
441        assert_eq!(
442            stored.hashes.blake3, hash_h1,
443            "Metadata should be updated to new hash"
444        );
445    }
446}