Skip to main content

squigit_storage/cas/
mod.rs

1// Copyright 2026 a7mddra
2// SPDX-License-Identifier: Apache-2.0
3
4//! Content-addressable storage for images and generic files.
5
6use 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    /// Store image bytes using content-addressable storage.
139    ///
140    /// Returns the hash and path to the stored image.
141    /// If the image already exists with the same hash, returns the existing path.
142    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    /// Store an image from a file path.
152    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    /// Store a generic file using content-addressable storage, preserving the extension.
169    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    /// Get the canonical blob path by hash.
398    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    /// Get the cached tone for a stored image by hash.
404    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}