Skip to main content

artifact_api/
lib.rs

1//! Stable, storage-independent artifact references shared across mHome runtimes.
2
3use std::fmt;
4
5use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8mod resolve;
9
10pub use resolve::{
11    ArtifactDelivery, ResolveArtifactRequest, ResolveArtifactResponse, ResolveArtifactResponseError,
12};
13
14/// Prefix of the version 1 artifact URI format.
15pub const ARTIFACT_URL_PREFIX: &str = "meow-artifact://v1/";
16const MAX_URI_LENGTH: usize = 2_048;
17const MAX_SEGMENT_LENGTH: usize = 256;
18const MAX_MIME_LENGTH: usize = 255;
19const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
20const MAX_DIMENSION: u32 = i32::MAX as u32;
21
22/// Logical media kind encoded in an artifact reference.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
25pub enum ArtifactKind {
26    Image,
27    Audio,
28    File,
29}
30
31impl ArtifactKind {
32    #[must_use]
33    pub const fn code(self) -> &'static str {
34        match self {
35            Self::Image => "i",
36            Self::Audio => "a",
37            Self::File => "f",
38        }
39    }
40
41    pub fn from_code(value: &str) -> Result<Self, ArtifactReferenceError> {
42        match value {
43            "i" => Ok(Self::Image),
44            "a" => Ok(Self::Audio),
45            "f" => Ok(Self::File),
46            _ => Err(invalid("unsupported artifact kind")),
47        }
48    }
49}
50
51/// Immutable metadata encoded into an artifact URI.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ArtifactMetadata {
54    kind: ArtifactKind,
55    mime_type: String,
56    size_bytes: u64,
57    width: Option<u32>,
58    height: Option<u32>,
59    duration_millis: Option<u64>,
60}
61
62impl ArtifactMetadata {
63    pub fn image(
64        mime_type: impl Into<String>,
65        size_bytes: usize,
66        width: u32,
67        height: u32,
68    ) -> Result<Self, ArtifactReferenceError> {
69        Self::build(
70            ArtifactKind::Image,
71            mime_type,
72            size_bytes,
73            Some(width),
74            Some(height),
75            None,
76        )
77    }
78
79    pub fn audio(
80        mime_type: impl Into<String>,
81        size_bytes: usize,
82        duration_millis: Option<u64>,
83    ) -> Result<Self, ArtifactReferenceError> {
84        Self::build(
85            ArtifactKind::Audio,
86            mime_type,
87            size_bytes,
88            None,
89            None,
90            duration_millis,
91        )
92    }
93
94    pub fn file(
95        mime_type: impl Into<String>,
96        size_bytes: usize,
97    ) -> Result<Self, ArtifactReferenceError> {
98        Self::build(ArtifactKind::File, mime_type, size_bytes, None, None, None)
99    }
100
101    fn build(
102        kind: ArtifactKind,
103        mime_type: impl Into<String>,
104        size_bytes: usize,
105        width: Option<u32>,
106        height: Option<u32>,
107        duration_millis: Option<u64>,
108    ) -> Result<Self, ArtifactReferenceError> {
109        let metadata = Self {
110            kind,
111            mime_type: mime_type.into(),
112            size_bytes: size_bytes as u64,
113            width,
114            height,
115            duration_millis,
116        };
117        metadata.validate()?;
118        Ok(metadata)
119    }
120
121    #[must_use]
122    pub const fn kind(&self) -> ArtifactKind {
123        self.kind
124    }
125
126    #[must_use]
127    pub fn mime_type(&self) -> &str {
128        &self.mime_type
129    }
130
131    #[must_use]
132    pub const fn size_bytes(&self) -> u64 {
133        self.size_bytes
134    }
135
136    #[must_use]
137    pub const fn width(&self) -> Option<u32> {
138        self.width
139    }
140
141    #[must_use]
142    pub const fn height(&self) -> Option<u32> {
143        self.height
144    }
145
146    #[must_use]
147    pub const fn duration_millis(&self) -> Option<u64> {
148        self.duration_millis
149    }
150
151    fn validate(&self) -> Result<(), ArtifactReferenceError> {
152        validate_mime_type(&self.mime_type)?;
153        if self.size_bytes == 0 || self.size_bytes > MAX_SAFE_INTEGER {
154            return Err(invalid("artifact size is invalid"));
155        }
156        match self.kind {
157            ArtifactKind::Image => {
158                if !self.mime_type.starts_with("image/") {
159                    return Err(invalid("image artifact MIME type is invalid"));
160                }
161                if self
162                    .width
163                    .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
164                    || self
165                        .height
166                        .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
167                    || self.duration_millis.is_some()
168                {
169                    return Err(invalid("image artifact metadata is invalid"));
170                }
171            }
172            ArtifactKind::Audio => {
173                if !self.mime_type.starts_with("audio/") {
174                    return Err(invalid("audio artifact MIME type is invalid"));
175                }
176                if self.width.is_some()
177                    || self.height.is_some()
178                    || self
179                        .duration_millis
180                        .is_some_and(|value| value == 0 || value > MAX_SAFE_INTEGER)
181                {
182                    return Err(invalid("audio artifact metadata is invalid"));
183                }
184            }
185            ArtifactKind::File => {
186                if self.mime_type.starts_with("video/") {
187                    return Err(invalid("video artifacts are not supported"));
188                }
189                if self.width.is_some() || self.height.is_some() || self.duration_millis.is_some() {
190                    return Err(invalid("file artifact metadata is invalid"));
191                }
192            }
193        }
194        Ok(())
195    }
196}
197
198#[derive(Serialize, Deserialize)]
199#[serde(deny_unknown_fields)]
200struct RawArtifactMetadata {
201    k: String,
202    m: String,
203    s: u64,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    w: Option<u32>,
206    #[serde(skip_serializing_if = "Option::is_none")]
207    h: Option<u32>,
208    #[serde(skip_serializing_if = "Option::is_none")]
209    d: Option<u64>,
210}
211
212impl Serialize for ArtifactMetadata {
213    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
214    where
215        S: Serializer,
216    {
217        RawArtifactMetadata {
218            k: self.kind.code().to_string(),
219            m: self.mime_type.clone(),
220            s: self.size_bytes,
221            w: self.width,
222            h: self.height,
223            d: self.duration_millis,
224        }
225        .serialize(serializer)
226    }
227}
228
229impl<'de> Deserialize<'de> for ArtifactMetadata {
230    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
231    where
232        D: Deserializer<'de>,
233    {
234        let raw = RawArtifactMetadata::deserialize(deserializer)?;
235        let metadata = Self {
236            kind: ArtifactKind::from_code(&raw.k).map_err(serde::de::Error::custom)?,
237            mime_type: raw.m,
238            size_bytes: raw.s,
239            width: raw.w,
240            height: raw.h,
241            duration_millis: raw.d,
242        };
243        metadata.validate().map_err(serde::de::Error::custom)?;
244        Ok(metadata)
245    }
246}
247
248/// Canonical, scope-owned, content-addressed artifact identity.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct ArtifactReference {
251    tenant_id: String,
252    scope_id: String,
253    sha256: String,
254    metadata: ArtifactMetadata,
255}
256
257impl ArtifactReference {
258    pub fn new(
259        tenant_id: impl Into<String>,
260        scope_id: impl Into<String>,
261        sha256: impl Into<String>,
262        metadata: ArtifactMetadata,
263    ) -> Result<Self, ArtifactReferenceError> {
264        let reference = Self {
265            tenant_id: tenant_id.into(),
266            scope_id: scope_id.into(),
267            sha256: sha256.into(),
268            metadata,
269        };
270        reference.validate()?;
271        Ok(reference)
272    }
273
274    pub fn parse(value: &str) -> Result<Self, ArtifactReferenceError> {
275        if value.len() > MAX_URI_LENGTH {
276            return Err(invalid("artifact URI is too long"));
277        }
278        let path = value
279            .strip_prefix(ARTIFACT_URL_PREFIX)
280            .ok_or_else(|| invalid("unsupported artifact URI"))?;
281        let segments = path.split('/').collect::<Vec<_>>();
282        if segments.len() != 4 {
283            return Err(invalid(
284                "artifact URI must contain tenant, scope, digest, and metadata",
285            ));
286        }
287        let metadata_bytes = URL_SAFE_NO_PAD
288            .decode(segments[3])
289            .map_err(|_| invalid("artifact metadata is not valid base64url"))?;
290        let metadata: ArtifactMetadata = serde_json::from_slice(&metadata_bytes)
291            .map_err(|_| invalid("artifact metadata is invalid"))?;
292        let reference = Self::new(segments[0], segments[1], segments[2], metadata)?;
293        if reference.uri()? != value {
294            return Err(invalid("artifact URI is not canonical"));
295        }
296        Ok(reference)
297    }
298
299    pub fn uri(&self) -> Result<String, ArtifactReferenceError> {
300        self.validate()?;
301        let metadata = serde_json::to_vec(&self.metadata)
302            .map_err(|_| invalid("artifact metadata cannot be encoded"))?;
303        Ok(format!(
304            "{ARTIFACT_URL_PREFIX}{}/{}/{}/{}",
305            self.tenant_id,
306            self.scope_id,
307            self.sha256,
308            URL_SAFE_NO_PAD.encode(metadata)
309        ))
310    }
311
312    #[must_use]
313    pub fn tenant_id(&self) -> &str {
314        &self.tenant_id
315    }
316
317    #[must_use]
318    pub fn scope_id(&self) -> &str {
319        &self.scope_id
320    }
321
322    #[must_use]
323    pub fn sha256(&self) -> &str {
324        &self.sha256
325    }
326
327    #[must_use]
328    pub const fn metadata(&self) -> &ArtifactMetadata {
329        &self.metadata
330    }
331
332    pub fn ensure_scope(
333        &self,
334        tenant_id: &str,
335        scope_id: &str,
336    ) -> Result<(), ArtifactReferenceError> {
337        if self.tenant_id != tenant_id || self.scope_id != scope_id {
338            return Err(ArtifactReferenceError::new(
339                ArtifactReferenceErrorKind::ScopeMismatch,
340                "artifact does not belong to the current scope",
341            ));
342        }
343        Ok(())
344    }
345
346    fn validate(&self) -> Result<(), ArtifactReferenceError> {
347        validate_segment(&self.tenant_id, "tenant")?;
348        validate_segment(&self.scope_id, "scope")?;
349        if self.sha256.len() != 64
350            || !self
351                .sha256
352                .bytes()
353                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
354        {
355            return Err(invalid("artifact sha256 is invalid"));
356        }
357        self.metadata.validate()
358    }
359}
360
361fn validate_segment(value: &str, name: &str) -> Result<(), ArtifactReferenceError> {
362    if value.is_empty()
363        || value.len() > MAX_SEGMENT_LENGTH
364        || value == "."
365        || value == ".."
366        || !value
367            .bytes()
368            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
369    {
370        return Err(invalid(format!("artifact {name} is not URL-safe")));
371    }
372    Ok(())
373}
374
375fn validate_mime_type(value: &str) -> Result<(), ArtifactReferenceError> {
376    if value.is_empty()
377        || value.len() > MAX_MIME_LENGTH
378        || value != value.trim()
379        || value.bytes().any(|byte| byte.is_ascii_uppercase())
380    {
381        return Err(invalid("artifact MIME type is invalid"));
382    }
383    let Some((media_type, subtype)) = value.split_once('/') else {
384        return Err(invalid("artifact MIME type is invalid"));
385    };
386    if media_type.is_empty()
387        || subtype.is_empty()
388        || subtype.contains('/')
389        || !value.bytes().all(|byte| {
390            byte.is_ascii_lowercase()
391                || byte.is_ascii_digit()
392                || matches!(
393                    byte,
394                    b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-' | b'/'
395                )
396        })
397    {
398        return Err(invalid("artifact MIME type is invalid"));
399    }
400    Ok(())
401}
402
403/// Stable category for reference validation failures.
404#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405pub enum ArtifactReferenceErrorKind {
406    InvalidReference,
407    ScopeMismatch,
408}
409
410/// Validation error returned for malformed or cross-scope references.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct ArtifactReferenceError {
413    kind: ArtifactReferenceErrorKind,
414    message: String,
415}
416
417impl ArtifactReferenceError {
418    fn new(kind: ArtifactReferenceErrorKind, message: impl Into<String>) -> Self {
419        Self {
420            kind,
421            message: message.into(),
422        }
423    }
424
425    #[must_use]
426    pub const fn kind(&self) -> ArtifactReferenceErrorKind {
427        self.kind
428    }
429
430    #[must_use]
431    pub fn message(&self) -> &str {
432        &self.message
433    }
434
435    #[must_use]
436    pub const fn is_scope_mismatch(&self) -> bool {
437        matches!(self.kind, ArtifactReferenceErrorKind::ScopeMismatch)
438    }
439}
440
441impl fmt::Display for ArtifactReferenceError {
442    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
443        formatter.write_str(&self.message)
444    }
445}
446
447impl std::error::Error for ArtifactReferenceError {}
448
449fn invalid(message: impl Into<String>) -> ArtifactReferenceError {
450    ArtifactReferenceError::new(ArtifactReferenceErrorKind::InvalidReference, message)
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn scope_owned_reference_round_trips() {
459        let reference = ArtifactReference::new(
460            "tenant",
461            "scope",
462            "a".repeat(64),
463            ArtifactMetadata::image("image/jpeg", 100, 10, 10).unwrap(),
464        )
465        .unwrap();
466        assert_eq!(
467            reference.uri().unwrap(),
468            format!(
469                "meow-artifact://v1/tenant/scope/{}/eyJrIjoiaSIsIm0iOiJpbWFnZS9qcGVnIiwicyI6MTAwLCJ3IjoxMCwiaCI6MTB9",
470                "a".repeat(64)
471            )
472        );
473        assert_eq!(
474            ArtifactReference::parse(&reference.uri().unwrap()).unwrap(),
475            reference
476        );
477    }
478
479    #[test]
480    fn rejects_cross_scope_and_invalid_metadata() {
481        let reference = ArtifactReference::new(
482            "tenant",
483            "scope",
484            "a".repeat(64),
485            ArtifactMetadata::audio("audio/mpeg", 100, Some(1_000)).unwrap(),
486        )
487        .unwrap();
488
489        assert_eq!(
490            reference
491                .ensure_scope("tenant", "other")
492                .unwrap_err()
493                .kind(),
494            ArtifactReferenceErrorKind::ScopeMismatch
495        );
496        assert!(ArtifactMetadata::audio("image/png", 100, None).is_err());
497        assert!(ArtifactMetadata::file("video/mp4", 100).is_err());
498        assert!(ArtifactMetadata::file("Application/PDF", 100).is_err());
499    }
500}