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