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