Skip to main content

oximemo_core/
assets.rs

1//! Image asset storage for the vault.
2//!
3//! Images live as real files under `<vault>/assets/<blake3hex16>.<ext>`, keyed
4//! by a content hash of their bytes so identical images dedup automatically.
5//! Memos reference them with the app-relative `oximg://<name>` scheme, which the
6//! Tauri shell resolves to the file (and browser-dev mode resolves to an
7//! IndexedDB blob) — see the frontend `lib/assets.ts`.
8//!
9//! Only the file store is touched (no index, no lock): assets are content
10//! addressed, so concurrent writers that produce the same bytes collide on the
11//! same filename harmlessly, and a partial write is simply overwritten.
12
13use serde::{Deserialize, Serialize};
14use std::collections::HashSet;
15
16use time::OffsetDateTime;
17
18use crate::error::{CoreError, Result};
19
20/// File-name stem length for a content-hashed asset (first 16 hex chars of a
21/// blake3 digest). 16 hex = 64 bits — ample collision resistance for a single
22/// user's image library, and short enough to stay readable in raw markdown.
23pub const HASH_LEN: usize = 16;
24
25/// Extensions the WKWebView can render inline. HEIC/RAW are excluded on
26/// purpose; convert upstream (or add a converter) before widening this set.
27pub const ALLOWED_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"];
28
29/// A reference returned to the frontend after saving an image. `url` is the
30/// exact string to drop into markdown (`oximg://<name>`); `name` is the bare
31/// `<hash>.<ext>` used by the gallery and GC.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct AssetRef {
34    pub url: String,
35    pub name: String,
36}
37
38/// One row of the gallery: a discoverable asset plus its size and mtime.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct AssetInfo {
41    pub name: String,
42    pub url: String,
43    pub ext: String,
44    pub bytes: u64,
45    #[serde(with = "time::serde::rfc3339")]
46    pub modified: OffsetDateTime,
47}
48
49/// Normalize an extension to lowercase ASCII without a leading dot, or reject
50/// anything outside the whitelist.
51pub fn normalize_ext(ext: &str) -> Result<&'static str> {
52    let e = ext.trim().trim_start_matches('.').to_ascii_lowercase();
53    let allowed = ALLOWED_EXTS
54        .iter()
55        .copied()
56        .find(|a| *a == e)
57        .ok_or_else(|| CoreError::AssetRejected(format!("unsupported extension: .{e}")))?;
58    Ok(allowed)
59}
60
61/// Content-hash raw image bytes to a bare asset name (`<hex16>.<ext>`).
62pub fn asset_name(bytes: &[u8], ext: &str) -> String {
63    let hex = blake3::hash(bytes).to_hex();
64    let stem = &hex.as_str()[..HASH_LEN];
65    format!("{stem}.{ext}")
66}
67
68/// Strict validator for a served asset name. Permits only `<hex16>.<allowed>`
69/// — no path separators, no `..`, no query/fragment. The protocol handler and
70/// the GC both gate on this.
71pub fn valid_name(name: &str) -> bool {
72    let Some((stem, ext)) = name.split_once('.') else {
73        return false;
74    };
75    stem.len() == HASH_LEN
76        && stem.bytes().all(|b| b.is_ascii_hexdigit())
77        && ALLOWED_EXTS.contains(&ext)
78        && !name.contains('/')
79        && !name.contains('\\')
80}
81
82/// Content-Type for a whitelisted extension.
83pub fn mime_for_ext(ext: &str) -> &'static str {
84    match ext {
85        "png" => "image/png",
86        "jpg" | "jpeg" => "image/jpeg",
87        "gif" => "image/gif",
88        "webp" => "image/webp",
89        _ => "application/octet-stream",
90    }
91}
92
93/// The extension portion of a validated asset name (without the dot), or `None`
94/// if the name is malformed.
95pub fn ext_of(name: &str) -> Option<&'static str> {
96    let ext = name.split('.').nth(1)?;
97    ALLOWED_EXTS.iter().copied().find(|a| *a == ext)
98}
99
100/// Extract every `oximg://<name>` reference from a memo body. Used by the GC to
101/// decide which assets are still live. `OXIMG_RE` is deliberately permissive on
102/// the markdown wrapper (`![alt](…)`, bare URL, or `](…)`-less fragments) so a
103/// reference survives even if a user hand-edits the alt text.
104pub fn refs_in_body(body: &str) -> HashSet<String> {
105    let mut out = HashSet::new();
106    let mut rest = body;
107    while let Some(start) = rest.find("oximg://") {
108        rest = &rest[start + "oximg://".len()..];
109        // Canonical form is `oximg://localhost/<name>` (host is `localhost` so
110        // the name lands in the path, per RFC 3986 / Tauri's macOS origin).
111        // Tolerate a bare `oximg://<name>` too by skipping an optional host.
112        if let Some(after) = rest.strip_prefix("localhost/") {
113            rest = after;
114        }
115        // The name runs until the first char that cannot belong to it — i.e.
116        // anything that is not alphanumeric or `.` (parens, spaces, `#`, `?`,
117        // slashes). Extension letters like `png` are alphabetic, so they must
118        // be included; `valid_name` then rejects malformed candidates.
119        let end = rest
120            .find(|c: char| !(c.is_ascii_alphanumeric() || c == '.'))
121            .unwrap_or(rest.len());
122        let candidate = &rest[..end];
123        if valid_name(candidate) {
124            out.insert(candidate.to_string());
125        }
126        rest = &rest[end..];
127    }
128    out
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn normalize_rejects_unknown() {
137        assert_eq!(normalize_ext("PNG").unwrap(), "png");
138        assert!(normalize_ext("heic").is_err());
139        assert!(normalize_ext("").is_err());
140    }
141
142    #[test]
143    fn name_round_trips_validation() {
144        let bytes = b"hello";
145        let name = asset_name(bytes, "png");
146        assert!(valid_name(&name));
147        assert_eq!(ext_of(&name), Some("png"));
148        assert_eq!(name.split('.').next().unwrap().len(), HASH_LEN);
149    }
150
151    #[test]
152    fn valid_name_rejects_traversal() {
153        assert!(!valid_name("../etc/passwd"));
154        assert!(!valid_name("abc.png"));
155        assert!(!valid_name("deadbeefdeadbeef.exe"));
156        assert!(!valid_name("deadbeefdeadbeef.png/../../x"));
157        assert!(valid_name("deadbeefdeadbeef.png"));
158    }
159
160    #[test]
161    fn refs_extract_markdown_and_bare() {
162        let body = "see ![shot](oximg://localhost/deadbeefdeadbeef.png) and \
163                    oximg://localhost/cafef00dcafef00d.gif trailing";
164        let refs = refs_in_body(body);
165        assert_eq!(refs.len(), 2);
166        assert!(refs.contains("deadbeefdeadbeef.png"));
167        assert!(refs.contains("cafef00dcafef00d.gif"));
168    }
169
170    #[test]
171    fn refs_ignore_width_fragment_and_query() {
172        // `#w=400` must terminate the name, not be absorbed into it.
173        let body = "![](oximg://localhost/deadbeefdeadbeef.png#w=400)";
174        let refs = refs_in_body(body);
175        assert!(refs.contains("deadbeefdeadbeef.png"));
176        assert_eq!(refs.len(), 1);
177    }
178
179    #[test]
180    fn mime_mapping() {
181        assert_eq!(mime_for_ext("png"), "image/png");
182        assert_eq!(mime_for_ext("jpeg"), "image/jpeg");
183        assert_eq!(mime_for_ext("webp"), "image/webp");
184    }
185
186    #[test]
187    fn dedup_is_content_addressed() {
188        let bytes = b"identical";
189        assert_eq!(asset_name(bytes, "png"), asset_name(bytes, "png"));
190    }
191}