Skip to main content
← dvs documentation Rust API reference

dvs/files/
metadata.rs

1use std::path::Path;
2
3use anyhow::{Result, bail};
4use fs_err as fs;
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8use crate::audit::{AuditEntry, AuditFile};
9use crate::{Backend, Compression, DvsPaths, Hashes, Outcome};
10
11/// The dvs metadata for a given file
12#[derive(Debug, Serialize, Deserialize, Clone)]
13pub struct FileMetadata {
14    pub hashes: Hashes,
15    pub size: u64,
16    pub created_by: String,
17    pub add_time: jiff::Timestamp,
18    pub compression: Compression,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub message: Option<String>,
21}
22
23impl PartialEq for FileMetadata {
24    fn eq(&self, other: &Self) -> bool {
25        self.hashes == other.hashes && self.size == other.size
26    }
27}
28
29impl FileMetadata {
30    pub fn from_hashes(
31        hashes: Hashes,
32        size: u64,
33        compression: Compression,
34        message: Option<String>,
35    ) -> Self {
36        Self {
37            hashes,
38            size,
39            created_by: whoami::username().unwrap_or_else(|_| "unknown".to_string()),
40            add_time: jiff::Timestamp::now(),
41            compression,
42            message,
43        }
44    }
45
46    pub fn from_file(
47        path: impl AsRef<Path>,
48        compression: Compression,
49        message: Option<String>,
50    ) -> Result<Self> {
51        if !path.as_ref().is_file() {
52            bail!("Path {} is not a file", path.as_ref().display());
53        }
54
55        let (hashes, size) = Hashes::compute_from_path(path.as_ref(), &[])?;
56        let created_by = whoami::username()?;
57        let add_time = jiff::Timestamp::now();
58
59        Ok(Self {
60            hashes,
61            size,
62            created_by,
63            add_time,
64            message,
65            compression,
66        })
67    }
68
69    /// Returns whether the file already existed in the dvs folder and therefore is an update
70    /// and the compressed size if applicable.
71    /// Copies the source file to storage and saves metadata atomically (both succeed or neither).
72    pub fn save(
73        &self,
74        operation_id: Uuid,
75        source_file: impl AsRef<Path>,
76        backend: &dyn Backend,
77        paths: &DvsPaths,
78        relative_path: impl AsRef<Path>,
79        on_bytes: Option<&(dyn Fn(u64) + Send + Sync)>,
80    ) -> Result<(Outcome, Option<u64>)> {
81        let dvs_file_path = paths.metadata_path(relative_path.as_ref());
82        let dvs_file_exists = dvs_file_path.is_file();
83        let storage_exists = backend.exists(&self.hashes)?;
84
85        log::debug!(
86            "Saving {}: metadata_exists={}, storage_exists={}",
87            relative_path.as_ref().display(),
88            dvs_file_exists,
89            storage_exists
90        );
91
92        if dvs_file_exists && storage_exists {
93            // we read the file anyway to make sure it's not 2 files having the same hash
94            let existing: FileMetadata = serde_json::from_reader(fs::File::open(&dvs_file_path)?)?;
95            if existing == *self {
96                log::debug!(
97                    "File {} is already in sync",
98                    relative_path.as_ref().display()
99                );
100                return Ok((Outcome::Present, None));
101            }
102        }
103
104        // We do an atomic update, either everything works or we error
105        // 1. Create metadata dirs first
106        if let Some(parent) = dvs_file_path.parent() {
107            fs::create_dir_all(parent)?;
108        }
109
110        // 2. Store file to backend if it doesn't already exist
111        let (storage_res, stored_size) = if storage_exists {
112            (Ok(()), None)
113        } else {
114            match backend.store(
115                &self.hashes,
116                source_file.as_ref(),
117                self.compression,
118                on_bytes,
119            ) {
120                Ok(size) => (Ok(()), Some(size)),
121                Err(e) => (Err(e), None),
122            }
123        };
124
125        // 3. Then metadata
126        let old_metadata_content = fs::read(&dvs_file_path).ok();
127        log::debug!("Writing metadata to {}", dvs_file_path.display());
128        let metadata_res = fs::write(
129            &dvs_file_path,
130            serde_json::to_string_pretty(self).expect("valid json"),
131        );
132
133        match (storage_res, metadata_res) {
134            (Ok(_), Ok(_)) => {
135                let audit_entry = AuditEntry::new_add(
136                    operation_id,
137                    AuditFile {
138                        path: relative_path.as_ref().to_path_buf(),
139                        hashes: self.hashes.clone(),
140                    },
141                    self.compression,
142                );
143                if let Err(e) = backend.log_audit(&audit_entry) {
144                    log::error!("Failed to write audit log {audit_entry:?}: {e}");
145                }
146                Ok((Outcome::Copied, stored_size))
147            }
148            (Err(e), Ok(_)) => {
149                log::warn!(
150                    "Storage failed, rolling back metadata for {}",
151                    relative_path.as_ref().display()
152                );
153                if let Some(old) = old_metadata_content {
154                    let _ = fs::write(&dvs_file_path, &old);
155                } else {
156                    let _ = fs::remove_file(&dvs_file_path);
157                }
158                Err(e)
159            }
160            (Ok(_), Err(_)) => {
161                log::warn!(
162                    "Metadata write failed, rolling back storage for {}",
163                    relative_path.as_ref().display()
164                );
165                if let Some(old) = old_metadata_content {
166                    let _ = fs::write(&dvs_file_path, &old);
167                } else {
168                    let _ = fs::remove_file(&dvs_file_path);
169                }
170                // Remove the blob only if this call actually stored it
171                if stored_size.is_some() {
172                    let _ = backend.remove(&self.hashes);
173                }
174                bail!("Failed to write metadata file: {dvs_file_path:?}")
175            }
176            (Err(e), Err(_)) => {
177                log::warn!(
178                    "Both storage and metadata failed, rolling back for {}",
179                    relative_path.as_ref().display()
180                );
181                if let Some(old) = old_metadata_content {
182                    let _ = fs::write(&dvs_file_path, &old);
183                } else {
184                    let _ = fs::remove_file(&dvs_file_path);
185                }
186                bail!("Failed to write metadata file: {dvs_file_path:?}: {e}")
187            }
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::testutil::{create_file, create_temp_git_repo, init_dvs_repo};
196
197    fn make_paths(root: &Path, config: &crate::config::Config) -> DvsPaths {
198        DvsPaths::new(
199            root.to_path_buf(),
200            root.to_path_buf(),
201            config.metadata_folder_name(),
202        )
203        .unwrap()
204    }
205
206    #[test]
207    fn file_metadata_from_file_creates_hashes_and_message() {
208        let (_tmp, root) = create_temp_git_repo();
209        let file_path = create_file(&root, "test.txt", b"hello world");
210
211        let metadata = FileMetadata::from_file(
212            &file_path,
213            Compression::Zstd,
214            Some("test message".to_string()),
215        )
216        .unwrap();
217
218        assert_eq!(metadata.hashes.blake3.len(), 64);
219        assert_eq!(metadata.size, 11);
220        assert_eq!(metadata.message, Some("test message".to_string()));
221    }
222
223    /// Locks in the `.dvs` JSON wire format for `add_time`: RFC 3339 with a `Z`
224    /// suffix (UTC). Changing `FileMetadata.add_time` from `String` to
225    /// `jiff::Timestamp` must keep the serialized form identical so existing
226    /// `.dvs` files round-trip unchanged.
227    #[test]
228    fn file_metadata_add_time_serde_roundtrip_rfc3339() {
229        let blake3 = "a".repeat(64);
230        let json = format!(
231            r#"{{
232                "hashes": {{"blake3": "{blake3}"}},
233                "size": 11,
234                "created_by": "tester",
235                "add_time": "2024-01-02T03:04:05Z",
236                "compression": "none"
237            }}"#
238        );
239        let meta: FileMetadata =
240            serde_json::from_str(&json).expect("parse FileMetadata with RFC 3339 timestamp");
241        assert_eq!(meta.add_time.to_string(), "2024-01-02T03:04:05Z");
242
243        let reserialized = serde_json::to_string(&meta).expect("serialize FileMetadata");
244        assert!(
245            reserialized.contains("\"add_time\":\"2024-01-02T03:04:05Z\""),
246            "add_time must serialize as RFC 3339 string; got: {reserialized}"
247        );
248    }
249
250    #[test]
251    fn file_metadata_from_nonexistent_file_fails() {
252        let tmp = tempfile::tempdir().unwrap();
253        let result =
254            FileMetadata::from_file(tmp.path().join("nonexistent.txt"), Compression::Zstd, None);
255        assert!(result.is_err());
256    }
257
258    #[test]
259    fn save_local_creates_storage_and_metadata() {
260        let (_tmp, root) = create_temp_git_repo();
261        let (config, dvs_dir) = init_dvs_repo(&root);
262        let backend = config.backend();
263        let paths = make_paths(&root, &config);
264        let file_path = create_file(&root, "data.bin", b"binary data");
265
266        let metadata = FileMetadata::from_file(&file_path, Compression::Zstd, None).unwrap();
267        let (outcome, stored_size) = metadata
268            .save(
269                Uuid::new_v4(),
270                &file_path,
271                backend,
272                &paths,
273                "data.bin",
274                None,
275            )
276            .unwrap();
277
278        assert_eq!(outcome, Outcome::Copied);
279        assert!(stored_size.is_some());
280        // Metadata file should exist
281        assert!(dvs_dir.join("data.bin.dvs").is_file());
282        assert!(backend.exists(&metadata.hashes).unwrap());
283    }
284
285    #[test]
286    fn save_local_returns_present_when_already_stored() {
287        let (_tmp, root) = create_temp_git_repo();
288        let (config, _dvs_dir) = init_dvs_repo(&root);
289        let backend = config.backend();
290        let paths = make_paths(&root, &config);
291        let file_path = create_file(&root, "data.bin", b"binary data");
292
293        let metadata = FileMetadata::from_file(&file_path, Compression::Zstd, None).unwrap();
294        metadata
295            .save(
296                Uuid::new_v4(),
297                &file_path,
298                backend,
299                &paths,
300                "data.bin",
301                None,
302            )
303            .unwrap();
304
305        // Second save should return Present
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        assert_eq!(outcome, Outcome::Present);
317        assert!(stored_size.is_none());
318    }
319}