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