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#[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 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 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 if let Some(parent) = dvs_file_path.parent() {
107 fs::create_dir_all(parent)?;
108 }
109
110 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 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 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 #[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 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 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}