1use std::fs::{self, File, OpenOptions};
7use std::io::{Read, Write};
8use std::path::{Path, PathBuf};
9
10use fs2::FileExt;
11
12use crate::error::{Result, StorageError};
13use crate::threads::ThreadStorage;
14
15mod types;
16
17pub use types::{
18 AttachmentFileType, DocumentConversion, ObjectFileContext, ObjectManifest, ObjectRemote,
19 ReverseImageSearchCache, StoredImage, OBJECT_MANIFEST_SCHEMA_VERSION,
20};
21
22const OBJECT_MANIFEST_FILE: &str = "manifest.json";
23const CACHE_DIR: &str = "cache";
24const DOCUMENT_CONVERSIONS_DIR: &str = "document-conversions";
25const OBJECT_MANIFEST_LOCK_FILE: &str = "manifest.lock";
26
27pub struct ObjectManifestLock {
28 file: File,
29}
30
31impl Drop for ObjectManifestLock {
32 fn drop(&mut self) {
33 let _ = FileExt::unlock(&self.file);
34 }
35}
36
37fn normalize_extension(extension: &str) -> String {
38 let normalized = extension
39 .trim()
40 .trim_start_matches('.')
41 .to_ascii_lowercase();
42 if normalized.is_empty() {
43 "bin".to_string()
44 } else {
45 normalized
46 }
47}
48
49fn validate_hash(hash: &str) -> Result<()> {
50 if hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
51 Ok(())
52 } else {
53 Err(StorageError::InvalidHash)
54 }
55}
56
57fn classify_extension(extension: &str) -> AttachmentFileType {
58 match extension {
59 "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" => AttachmentFileType::ImageUpload,
60 "pdf" => AttachmentFileType::DocumentUpload,
61 _ => AttachmentFileType::TextLocal,
62 }
63}
64
65fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
66 reject_symlink_or_non_regular(path)?;
67 let parent = path.parent().ok_or(StorageError::InvalidHash)?;
68 ensure_private_directory(parent)?;
69 let file_name = path
70 .file_name()
71 .and_then(|value| value.to_str())
72 .unwrap_or("data");
73 let temporary = path.with_file_name(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4()));
74 let result = (|| -> Result<()> {
75 let mut options = OpenOptions::new();
76 options.write(true).create_new(true);
77 #[cfg(unix)]
78 {
79 use std::os::unix::fs::OpenOptionsExt;
80 options.mode(0o600);
81 }
82 let mut file = options.open(&temporary)?;
83 file.write_all(contents)?;
84 file.sync_all()?;
85 drop(file);
86
87 crate::secure_file::replace_file(&temporary, path)?;
88 set_private_file_permissions(path)?;
89 crate::secure_file::sync_parent(parent)?;
90 Ok(())
91 })();
92 if result.is_err() {
93 let _ = fs::remove_file(temporary);
94 }
95 result
96}
97
98fn reject_symlink_or_non_regular(path: &Path) -> Result<()> {
99 match fs::symlink_metadata(path) {
100 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
101 Err(StorageError::KeyStore(format!(
102 "refusing unsafe CAS metadata target: {}",
103 path.display()
104 )))
105 }
106 Ok(_) => Ok(()),
107 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
108 Err(error) => Err(error.into()),
109 }
110}
111
112fn ensure_private_directory(path: &Path) -> Result<()> {
113 let metadata = fs::symlink_metadata(path)?;
114 if metadata.file_type().is_symlink() || !metadata.is_dir() {
115 return Err(StorageError::KeyStore(format!(
116 "refusing unsafe CAS directory: {}",
117 path.display()
118 )));
119 }
120 #[cfg(unix)]
121 {
122 use std::os::unix::fs::PermissionsExt;
123 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
124 }
125 Ok(())
126}
127
128fn set_private_file_permissions(path: &Path) -> Result<()> {
129 #[cfg(unix)]
130 {
131 use std::os::unix::fs::PermissionsExt;
132 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
133 }
134 Ok(())
135}
136
137impl ThreadStorage {
138 pub fn store_image(&self, bytes: &[u8], explicit_tone: Option<String>) -> Result<StoredImage> {
143 if bytes.is_empty() {
144 return Err(StorageError::EmptyImage);
145 }
146
147 let hash = blake3::hash(bytes).to_hex().to_string();
148 self.store_object(bytes, &hash, "png", explicit_tone)
149 }
150
151 pub fn store_image_from_path(
153 &self,
154 path: &str,
155 explicit_tone: Option<String>,
156 ) -> Result<StoredImage> {
157 let mut file = File::open(path)?;
158 let mut buffer = Vec::new();
159 file.read_to_end(&mut buffer)?;
160 let extension = Path::new(path)
161 .extension()
162 .and_then(|value| value.to_str())
163 .unwrap_or("png");
164 let hash = blake3::hash(&buffer).to_hex().to_string();
165 self.store_object(&buffer, &hash, extension, explicit_tone)
166 }
167
168 pub fn store_file(
170 &self,
171 bytes: &[u8],
172 extension: &str,
173 explicit_tone: Option<String>,
174 ) -> Result<StoredImage> {
175 let hash = blake3::hash(bytes).to_hex().to_string();
176 self.store_object(bytes, &hash, extension, explicit_tone)
177 }
178
179 fn store_object(
180 &self,
181 bytes: &[u8],
182 hash: &str,
183 extension: &str,
184 explicit_tone: Option<String>,
185 ) -> Result<StoredImage> {
186 let extension = normalize_extension(extension);
187 let object_dir = self.object_dir(hash)?;
188 let existing_path = self.find_object_blob(hash).ok();
189 let manifest_path = object_dir.join(OBJECT_MANIFEST_FILE);
190 let new_file_context = if manifest_path.exists() {
191 None
192 } else {
193 let file_type = classify_extension(&extension);
194 let file_brief = if file_type == AttachmentFileType::TextLocal {
195 Some(std::str::from_utf8(bytes)?.to_string())
196 } else {
197 None
198 };
199 Some(ObjectFileContext {
200 file_type,
201 image_tone: None,
202 file_brief,
203 })
204 };
205 fs::create_dir_all(&object_dir)?;
206 let file_path = existing_path
207 .clone()
208 .unwrap_or_else(|| object_dir.join(format!("{hash}.{extension}")));
209 if existing_path.is_none() {
210 let mut file = File::create(&file_path)?;
211 file.write_all(bytes)?;
212 }
213
214 let mut manifest = if manifest_path.exists() {
215 self.load_object_manifest(hash)?
216 } else {
217 ObjectManifest::new(new_file_context.expect("new object context must exist"))
218 };
219
220 if manifest.file_context.file_type == AttachmentFileType::ImageUpload {
221 let tone = explicit_tone
222 .as_deref()
223 .map(str::trim)
224 .filter(|value| !value.is_empty())
225 .map(str::to_string)
226 .or_else(|| manifest.file_context.image_tone.clone())
227 .unwrap_or_else(|| "dark".to_string());
228 manifest.file_context.image_tone = Some(tone);
229 }
230 self.save_object_manifest(hash, &manifest)?;
231
232 Ok(StoredImage {
233 hash: hash.to_string(),
234 path: file_path.to_string_lossy().to_string(),
235 tone: manifest.file_context.image_tone,
236 })
237 }
238
239 pub fn object_dir(&self, hash: &str) -> Result<PathBuf> {
240 validate_hash(hash)?;
241 let prefix = hash.get(..2).ok_or(StorageError::InvalidHash)?;
242 Ok(self.objects_dir.join(prefix).join(hash))
243 }
244
245 fn document_conversion_path(
246 &self,
247 source_hash: &str,
248 source_extension: &str,
249 ) -> Result<PathBuf> {
250 validate_hash(source_hash)?;
251 let source_extension = normalize_extension(source_extension);
252 if !matches!(source_extension.as_str(), "docx" | "xlsx" | "pptx") {
253 return Err(StorageError::InvalidDocumentConversion(
254 "source extension must be docx, xlsx, or pptx".to_string(),
255 ));
256 }
257 let prefix = source_hash.get(..2).ok_or(StorageError::InvalidHash)?;
258 let config_root = self.objects_dir.parent().ok_or(StorageError::NoDataDir)?;
259 Ok(config_root
260 .join(CACHE_DIR)
261 .join(DOCUMENT_CONVERSIONS_DIR)
262 .join(prefix)
263 .join(format!("{source_hash}.{source_extension}.json")))
264 }
265
266 pub fn load_document_conversion(
267 &self,
268 source_hash: &str,
269 source_extension: &str,
270 ) -> Result<Option<DocumentConversion>> {
271 let path = self.document_conversion_path(source_hash, source_extension)?;
272 if !path.exists() {
273 return Ok(None);
274 }
275 let conversion = serde_json::from_slice::<DocumentConversion>(&fs::read(path)?)?;
276 validate_hash(&conversion.source_hash)?;
277 validate_hash(&conversion.pdf_hash)?;
278 let expected_extension = normalize_extension(source_extension);
279 if !conversion.source_hash.eq_ignore_ascii_case(source_hash)
280 || conversion.source_extension != expected_extension
281 {
282 return Err(StorageError::InvalidDocumentConversion(
283 "conversion receipt does not match its source identity".to_string(),
284 ));
285 }
286 Ok(Some(conversion))
287 }
288
289 pub fn save_document_conversion(&self, conversion: &DocumentConversion) -> Result<()> {
290 validate_hash(&conversion.source_hash)?;
291 validate_hash(&conversion.pdf_hash)?;
292 if conversion.recipe.trim().is_empty() {
293 return Err(StorageError::InvalidDocumentConversion(
294 "conversion recipe cannot be empty".to_string(),
295 ));
296 }
297 let path =
298 self.document_conversion_path(&conversion.source_hash, &conversion.source_extension)?;
299 let parent = path
300 .parent()
301 .ok_or_else(|| StorageError::InvalidDocumentConversion("invalid path".to_string()))?;
302 fs::create_dir_all(parent)?;
303 atomic_write(&path, serde_json::to_vec_pretty(conversion)?.as_slice())
304 }
305
306 pub fn object_manifest_path(&self, hash: &str) -> Result<PathBuf> {
307 Ok(self.object_dir(hash)?.join(OBJECT_MANIFEST_FILE))
308 }
309
310 pub fn find_object_blob(&self, hash: &str) -> Result<PathBuf> {
311 let object_dir = self.object_dir(hash)?;
312 let entries =
313 fs::read_dir(&object_dir).map_err(|_| StorageError::ImageNotFound(hash.to_string()))?;
314 for entry in entries {
315 let path = entry?.path();
316 let is_blob = path.is_file()
317 && path.file_stem().and_then(|value| value.to_str()) == Some(hash)
318 && path.file_name().and_then(|value| value.to_str()) != Some(OBJECT_MANIFEST_FILE);
319 if is_blob {
320 return Ok(path);
321 }
322 }
323 Err(StorageError::ImageNotFound(hash.to_string()))
324 }
325
326 pub fn load_object_manifest(&self, hash: &str) -> Result<ObjectManifest> {
327 let path = self.object_manifest_path(hash)?;
328 reject_symlink_or_non_regular(&path)?;
329 let json = fs::read_to_string(path)?;
330 let manifest: ObjectManifest = serde_json::from_str(&json).map_err(|error| {
331 StorageError::KeyStore(format!("malformed-object-manifest: {error}"))
332 })?;
333 manifest.validate().map_err(|error| {
334 StorageError::KeyStore(format!("malformed-object-manifest: {error}"))
335 })?;
336 Ok(manifest)
337 }
338
339 pub fn save_object_manifest(&self, hash: &str, manifest: &ObjectManifest) -> Result<()> {
340 manifest.validate().map_err(|error| {
341 StorageError::KeyStore(format!("malformed-object-manifest: {error}"))
342 })?;
343 let path = self.object_manifest_path(hash)?;
344 let parent = path.parent().ok_or(StorageError::InvalidHash)?;
345 fs::create_dir_all(parent)?;
346 atomic_write(&path, serde_json::to_string_pretty(manifest)?.as_bytes())
347 }
348
349 pub fn lock_object_manifest(&self, hash: &str) -> Result<ObjectManifestLock> {
350 let object_dir = self.object_dir(hash)?;
351 fs::create_dir_all(&object_dir)?;
352 ensure_private_directory(&object_dir)?;
353 let lock_path = object_dir.join(OBJECT_MANIFEST_LOCK_FILE);
354 reject_symlink_or_non_regular(&lock_path)?;
355 let mut options = OpenOptions::new();
356 options.read(true).write(true).create(true);
357 #[cfg(unix)]
358 {
359 use std::os::unix::fs::OpenOptionsExt;
360 options.mode(0o600);
361 }
362 let file = options.open(&lock_path)?;
363 set_private_file_permissions(&lock_path)?;
364 file.lock_exclusive()?;
365 Ok(ObjectManifestLock { file })
366 }
367
368 pub fn has_object_remotes(&self) -> Result<bool> {
369 if !self.objects_dir.exists() {
370 return Ok(false);
371 }
372 for prefix in fs::read_dir(&self.objects_dir)? {
373 let prefix = prefix?.path();
374 if !prefix.is_dir() {
375 continue;
376 }
377 for object in fs::read_dir(prefix)? {
378 let object = object?.path();
379 let Some(hash) = object.file_name().and_then(|value| value.to_str()) else {
380 continue;
381 };
382 if validate_hash(hash).is_err() {
383 continue;
384 }
385 let manifest_path = object.join(OBJECT_MANIFEST_FILE);
386 if !manifest_path.exists() {
387 continue;
388 }
389 if !self.load_object_manifest(hash)?.object_remotes.is_empty() {
390 return Ok(true);
391 }
392 }
393 }
394 Ok(false)
395 }
396
397 pub fn get_image_path(&self, hash: &str) -> Result<String> {
399 self.find_object_blob(hash)
400 .map(|path| path.to_string_lossy().to_string())
401 }
402
403 pub fn get_image_tone(&self, hash: &str) -> Option<String> {
405 self.load_object_manifest(hash)
406 .ok()
407 .and_then(|manifest| manifest.file_context.image_tone)
408 }
409
410 pub fn get_reverse_image_search_cache(
411 &self,
412 hash: &str,
413 ) -> Result<Option<ReverseImageSearchCache>> {
414 self.load_object_manifest(hash)
415 .map(|manifest| manifest.reverse_image_search)
416 }
417
418 pub fn save_reverse_image_search_cache(
419 &self,
420 hash: &str,
421 imgbb_url: String,
422 google_lens_url: String,
423 ) -> Result<()> {
424 let _lock = self.lock_object_manifest(hash)?;
425 let mut manifest = self.load_object_manifest(hash)?;
426 manifest.reverse_image_search = Some(ReverseImageSearchCache {
427 imgbb_url,
428 google_lens_url,
429 created_at: chrono::Utc::now(),
430 });
431 self.save_object_manifest(hash, &manifest)
432 }
433}