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