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
8#[macro_use]
9mod object_message;
10
11mod policy;
12pub use policy::{ArtifactIdentity, ArtifactPolicy, ArtifactRetention};
13mod media;
14mod put;
15mod resolve;
16mod stream_upload;
17mod upload;
18
19pub use stream_upload::{
20    ArtifactUploadReceipt, CancelArtifactUploadRequest, CancelArtifactUploadResponse,
21    CompleteArtifactUploadRequest, CompleteArtifactUploadResponse, StartArtifactUploadRequest,
22    StartArtifactUploadResponse,
23};
24
25pub use media::{ArtifactUri, ImportArtifactRequest, MediaReference};
26
27pub use put::{PutArtifactRequest, PutArtifactResponse, PutArtifactValidationError};
28
29pub use resolve::{
30    ArtifactDelivery, ResolveArtifactRequest, ResolveArtifactResponse, ResolveArtifactResponseError,
31};
32pub use upload::{
33    PrepareArtifactUploadRequest, PrepareArtifactUploadResponse,
34    PrepareArtifactUploadValidationError,
35};
36
37/// Prefix of the version 3 artifact URI format.
38pub const ARTIFACT_URL_PREFIX: &str = "meow-artifact://v3/";
39const MAX_URI_LENGTH: usize = 2_048;
40const MAX_SEGMENT_LENGTH: usize = 256;
41const MAX_MIME_LENGTH: usize = 255;
42const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
43const MAX_DIMENSION: u32 = i32::MAX as u32;
44
45/// Logical media kind encoded in an artifact reference.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
48pub enum ArtifactKind {
49    Image,
50    Audio,
51    Video,
52    File,
53}
54
55impl ArtifactKind {
56    #[must_use]
57    pub const fn code(self) -> &'static str {
58        match self {
59            Self::Image => "i",
60            Self::Audio => "a",
61            Self::Video => "v",
62            Self::File => "f",
63        }
64    }
65
66    pub fn from_code(value: &str) -> Result<Self, ArtifactReferenceError> {
67        match value {
68            "i" => Ok(Self::Image),
69            "a" => Ok(Self::Audio),
70            "v" => Ok(Self::Video),
71            "f" => Ok(Self::File),
72            _ => Err(invalid("unsupported artifact kind")),
73        }
74    }
75}
76
77/// Immutable metadata encoded into an artifact URI.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ArtifactMetadata {
80    kind: ArtifactKind,
81    mime_type: String,
82    size_bytes: u64,
83    width: Option<u32>,
84    height: Option<u32>,
85    duration_millis: Option<u64>,
86}
87
88impl ArtifactMetadata {
89    pub fn image(
90        mime_type: impl Into<String>,
91        size_bytes: usize,
92        width: u32,
93        height: u32,
94    ) -> Result<Self, ArtifactReferenceError> {
95        Self::build(
96            ArtifactKind::Image,
97            mime_type,
98            size_bytes,
99            Some(width),
100            Some(height),
101            None,
102        )
103    }
104
105    pub fn audio(
106        mime_type: impl Into<String>,
107        size_bytes: usize,
108        duration_millis: Option<u64>,
109    ) -> Result<Self, ArtifactReferenceError> {
110        Self::build(
111            ArtifactKind::Audio,
112            mime_type,
113            size_bytes,
114            None,
115            None,
116            duration_millis,
117        )
118    }
119
120    pub fn file(
121        mime_type: impl Into<String>,
122        size_bytes: usize,
123    ) -> Result<Self, ArtifactReferenceError> {
124        Self::build(ArtifactKind::File, mime_type, size_bytes, None, None, None)
125    }
126
127    pub fn video(
128        mime_type: impl Into<String>,
129        size_bytes: usize,
130        width: u32,
131        height: u32,
132        duration_millis: Option<u64>,
133    ) -> Result<Self, ArtifactReferenceError> {
134        Self::build(
135            ArtifactKind::Video,
136            mime_type,
137            size_bytes,
138            Some(width),
139            Some(height),
140            duration_millis,
141        )
142    }
143
144    fn build(
145        kind: ArtifactKind,
146        mime_type: impl Into<String>,
147        size_bytes: usize,
148        width: Option<u32>,
149        height: Option<u32>,
150        duration_millis: Option<u64>,
151    ) -> Result<Self, ArtifactReferenceError> {
152        let metadata = Self {
153            kind,
154            mime_type: mime_type.into(),
155            size_bytes: size_bytes as u64,
156            width,
157            height,
158            duration_millis,
159        };
160        metadata.validate()?;
161        Ok(metadata)
162    }
163
164    #[must_use]
165    pub const fn kind(&self) -> ArtifactKind {
166        self.kind
167    }
168
169    #[must_use]
170    pub fn mime_type(&self) -> &str {
171        &self.mime_type
172    }
173
174    #[must_use]
175    pub const fn size_bytes(&self) -> u64 {
176        self.size_bytes
177    }
178
179    #[must_use]
180    pub const fn width(&self) -> Option<u32> {
181        self.width
182    }
183
184    #[must_use]
185    pub const fn height(&self) -> Option<u32> {
186        self.height
187    }
188
189    #[must_use]
190    pub const fn duration_millis(&self) -> Option<u64> {
191        self.duration_millis
192    }
193
194    fn validate(&self) -> Result<(), ArtifactReferenceError> {
195        validate_mime_type(&self.mime_type)?;
196        validate_size_bytes(self.size_bytes).map_err(invalid)?;
197        validate_duration_millis(self.kind, self.duration_millis).map_err(invalid)?;
198        match self.kind {
199            ArtifactKind::Image => {
200                if !self.mime_type.starts_with("image/") {
201                    return Err(invalid("image artifact MIME type is invalid"));
202                }
203                if self
204                    .width
205                    .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
206                    || self
207                        .height
208                        .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
209                {
210                    return Err(invalid("image artifact metadata is invalid"));
211                }
212            }
213            ArtifactKind::Audio => {
214                if !self.mime_type.starts_with("audio/") {
215                    return Err(invalid("audio artifact MIME type is invalid"));
216                }
217                if self.width.is_some() || self.height.is_some() {
218                    return Err(invalid("audio artifact metadata is invalid"));
219                }
220            }
221            ArtifactKind::Video => {
222                if !self.mime_type.starts_with("video/")
223                    || self
224                        .width
225                        .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
226                    || self
227                        .height
228                        .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
229                {
230                    return Err(invalid("video artifact metadata is invalid"));
231                }
232            }
233            ArtifactKind::File => {
234                if self.mime_type.starts_with("video/") {
235                    return Err(invalid("video artifacts are not supported"));
236                }
237                if self.width.is_some() || self.height.is_some() {
238                    return Err(invalid("file artifact metadata is invalid"));
239                }
240            }
241        }
242        Ok(())
243    }
244}
245
246#[derive(Serialize, Deserialize)]
247#[serde(deny_unknown_fields)]
248struct RawArtifactMetadata {
249    k: String,
250    m: String,
251    s: u64,
252    #[serde(
253        default,
254        skip_serializing_if = "Option::is_none",
255        deserialize_with = "deserialize_optional_non_null"
256    )]
257    w: Option<u32>,
258    #[serde(
259        default,
260        skip_serializing_if = "Option::is_none",
261        deserialize_with = "deserialize_optional_non_null"
262    )]
263    h: Option<u32>,
264    #[serde(
265        default,
266        skip_serializing_if = "Option::is_none",
267        deserialize_with = "deserialize_optional_non_null"
268    )]
269    d: Option<u64>,
270}
271
272impl Serialize for ArtifactMetadata {
273    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
274    where
275        S: Serializer,
276    {
277        RawArtifactMetadata {
278            k: self.kind.code().to_string(),
279            m: self.mime_type.clone(),
280            s: self.size_bytes,
281            w: self.width,
282            h: self.height,
283            d: self.duration_millis,
284        }
285        .serialize(serializer)
286    }
287}
288
289impl<'de> Deserialize<'de> for ArtifactMetadata {
290    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
291    where
292        D: Deserializer<'de>,
293    {
294        let raw = RawArtifactMetadata::deserialize(deserializer)?;
295        let metadata = Self {
296            kind: ArtifactKind::from_code(&raw.k).map_err(serde::de::Error::custom)?,
297            mime_type: raw.m,
298            size_bytes: raw.s,
299            width: raw.w,
300            height: raw.h,
301            duration_millis: raw.d,
302        };
303        metadata.validate().map_err(serde::de::Error::custom)?;
304        Ok(metadata)
305    }
306}
307
308/// Canonical, scope-owned, content-addressed artifact identity.
309#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct ArtifactReference {
311    tenant_id: String,
312    scope_id: String,
313    sha256: String,
314    policy: ArtifactPolicy,
315    creation_id: Option<String>,
316    metadata: ArtifactMetadata,
317}
318
319impl ArtifactReference {
320    pub fn new(
321        tenant_id: impl Into<String>,
322        scope_id: impl Into<String>,
323        sha256: impl Into<String>,
324        metadata: ArtifactMetadata,
325    ) -> Result<Self, ArtifactReferenceError> {
326        Self::with_policy(
327            tenant_id,
328            scope_id,
329            sha256,
330            metadata,
331            ArtifactPolicy::AUTO_STD,
332            None,
333        )
334    }
335
336    pub fn with_policy(
337        tenant_id: impl Into<String>,
338        scope_id: impl Into<String>,
339        sha256: impl Into<String>,
340        metadata: ArtifactMetadata,
341        policy: ArtifactPolicy,
342        creation_id: Option<String>,
343    ) -> Result<Self, ArtifactReferenceError> {
344        let reference = Self {
345            policy,
346            creation_id,
347            tenant_id: tenant_id.into(),
348            scope_id: scope_id.into(),
349            sha256: sha256.into(),
350            metadata,
351        };
352        reference.validate()?;
353        Ok(reference)
354    }
355
356    pub fn parse(value: &str) -> Result<Self, ArtifactReferenceError> {
357        if value.len() > MAX_URI_LENGTH {
358            return Err(invalid("artifact URI is too long"));
359        }
360        let path = value
361            .strip_prefix(ARTIFACT_URL_PREFIX)
362            .ok_or_else(|| invalid("unsupported artifact URI"))?;
363        let segments = path.split('/').collect::<Vec<_>>();
364        if segments.len() != 7 {
365            return Err(invalid(
366                "artifact URI must contain tenant, scope, retention, identity, resource ID, digest, and metadata",
367            ));
368        }
369        let metadata_bytes = URL_SAFE_NO_PAD
370            .decode(segments[6])
371            .map_err(|_| invalid("artifact metadata is not valid base64url"))?;
372        let metadata: ArtifactMetadata = serde_json::from_slice(&metadata_bytes)
373            .map_err(|_| invalid("artifact metadata is invalid"))?;
374        let policy = ArtifactPolicy::new(
375            ArtifactIdentity::from_code(segments[3])?,
376            ArtifactRetention::from_code(segments[2])?,
377        );
378        let creation_id = if policy.identity == ArtifactIdentity::Man {
379            Some(segments[4].to_owned())
380        } else {
381            if segments[4] != segments[5] {
382                return Err(invalid("artifact resource ID must equal its digest"));
383            }
384            None
385        };
386        let reference = Self::with_policy(
387            segments[0],
388            segments[1],
389            segments[5],
390            metadata,
391            policy,
392            creation_id,
393        )?;
394        if reference.uri()? != value {
395            return Err(invalid("artifact URI is not canonical"));
396        }
397        Ok(reference)
398    }
399
400    pub fn uri(&self) -> Result<String, ArtifactReferenceError> {
401        self.validate()?;
402        let metadata = serde_json::to_vec(&self.metadata)
403            .map_err(|_| invalid("artifact metadata cannot be encoded"))?;
404        Ok(format!(
405            "{ARTIFACT_URL_PREFIX}{}/{}/{}/{}/{}/{}/{}",
406            self.tenant_id,
407            self.scope_id,
408            self.policy.retention.code(),
409            self.policy.identity.code(),
410            self.resource_id(),
411            self.sha256,
412            URL_SAFE_NO_PAD.encode(metadata)
413        ))
414    }
415
416    #[must_use]
417    pub fn tenant_id(&self) -> &str {
418        &self.tenant_id
419    }
420
421    #[must_use]
422    pub fn scope_id(&self) -> &str {
423        &self.scope_id
424    }
425
426    #[must_use]
427    pub fn sha256(&self) -> &str {
428        &self.sha256
429    }
430
431    #[must_use]
432    pub const fn metadata(&self) -> &ArtifactMetadata {
433        &self.metadata
434    }
435
436    pub const fn policy(&self) -> ArtifactPolicy {
437        self.policy
438    }
439
440    pub fn resource_id(&self) -> &str {
441        self.creation_id.as_deref().unwrap_or(&self.sha256)
442    }
443
444    pub fn creation_id(&self) -> Option<&str> {
445        self.creation_id.as_deref()
446    }
447
448    pub fn ensure_scope(
449        &self,
450        tenant_id: &str,
451        scope_id: &str,
452    ) -> Result<(), ArtifactReferenceError> {
453        if self.tenant_id != tenant_id || self.scope_id != scope_id {
454            return Err(ArtifactReferenceError::new(
455                ArtifactReferenceErrorKind::ScopeMismatch,
456                "artifact does not belong to the current scope",
457            ));
458        }
459        Ok(())
460    }
461
462    fn validate(&self) -> Result<(), ArtifactReferenceError> {
463        self.policy
464            .validate_creation_id(self.creation_id.as_deref())?;
465        validate_segment(&self.tenant_id, "tenant")?;
466        validate_segment(&self.scope_id, "scope")?;
467        if self.sha256.len() != 64
468            || !self
469                .sha256
470                .bytes()
471                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
472        {
473            return Err(invalid("artifact sha256 is invalid"));
474        }
475        self.metadata.validate()
476    }
477}
478
479// Serde's default Option handling conflates omission with null. Wire fields are optional,
480// but an explicitly supplied value must deserialize as T, matching the published schemas.
481fn deserialize_optional_non_null<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
482where
483    D: Deserializer<'de>,
484    T: Deserialize<'de>,
485{
486    T::deserialize(deserializer).map(Some)
487}
488
489fn validate_size_bytes(size_bytes: u64) -> Result<(), &'static str> {
490    if size_bytes == 0 || size_bytes > MAX_SAFE_INTEGER {
491        return Err("artifact size is invalid");
492    }
493    Ok(())
494}
495
496fn validate_duration_millis(
497    kind: ArtifactKind,
498    duration_millis: Option<u64>,
499) -> Result<(), &'static str> {
500    if let Some(duration) = duration_millis {
501        if !matches!(kind, ArtifactKind::Audio | ArtifactKind::Video) {
502            return Err("artifact duration is only valid for audio or video");
503        }
504        if duration == 0 || duration > MAX_SAFE_INTEGER {
505            return Err("artifact duration is invalid");
506        }
507    }
508    Ok(())
509}
510
511fn validate_segment(value: &str, name: &str) -> Result<(), ArtifactReferenceError> {
512    if value.is_empty()
513        || value.len() > MAX_SEGMENT_LENGTH
514        || value == "."
515        || value == ".."
516        || !value
517            .bytes()
518            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
519    {
520        return Err(invalid(format!("artifact {name} is not URL-safe")));
521    }
522    Ok(())
523}
524
525fn validate_mime_type(value: &str) -> Result<(), ArtifactReferenceError> {
526    if value.is_empty()
527        || value.len() > MAX_MIME_LENGTH
528        || value != value.trim()
529        || value.bytes().any(|byte| byte.is_ascii_uppercase())
530    {
531        return Err(invalid("artifact MIME type is invalid"));
532    }
533    let Some((media_type, subtype)) = value.split_once('/') else {
534        return Err(invalid("artifact MIME type is invalid"));
535    };
536    if media_type.is_empty()
537        || subtype.is_empty()
538        || subtype.contains('/')
539        || !value.bytes().all(|byte| {
540            byte.is_ascii_lowercase()
541                || byte.is_ascii_digit()
542                || matches!(
543                    byte,
544                    b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-' | b'/'
545                )
546        })
547    {
548        return Err(invalid("artifact MIME type is invalid"));
549    }
550    Ok(())
551}
552
553/// Stable category for reference validation failures.
554#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555pub enum ArtifactReferenceErrorKind {
556    InvalidReference,
557    ScopeMismatch,
558}
559
560/// Validation error returned for malformed or cross-scope references.
561#[derive(Debug, Clone, PartialEq, Eq)]
562pub struct ArtifactReferenceError {
563    kind: ArtifactReferenceErrorKind,
564    message: String,
565}
566
567impl ArtifactReferenceError {
568    fn new(kind: ArtifactReferenceErrorKind, message: impl Into<String>) -> Self {
569        Self {
570            kind,
571            message: message.into(),
572        }
573    }
574
575    #[must_use]
576    pub const fn kind(&self) -> ArtifactReferenceErrorKind {
577        self.kind
578    }
579
580    #[must_use]
581    pub fn message(&self) -> &str {
582        &self.message
583    }
584
585    #[must_use]
586    pub const fn is_scope_mismatch(&self) -> bool {
587        matches!(self.kind, ArtifactReferenceErrorKind::ScopeMismatch)
588    }
589}
590
591impl fmt::Display for ArtifactReferenceError {
592    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
593        formatter.write_str(&self.message)
594    }
595}
596
597impl std::error::Error for ArtifactReferenceError {}
598
599fn invalid(message: impl Into<String>) -> ArtifactReferenceError {
600    ArtifactReferenceError::new(ArtifactReferenceErrorKind::InvalidReference, message)
601}
602
603/// Public upload/import policy, not a limit on internally generated artifacts.
604pub const MAX_EXTERNAL_ARTIFACT_BYTES: usize = 1024 * 1024;
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn scope_owned_reference_round_trips() {
612        let reference = ArtifactReference::new(
613            "tenant",
614            "scope",
615            "a".repeat(64),
616            ArtifactMetadata::image("image/jpeg", 100, 10, 10).unwrap(),
617        )
618        .unwrap();
619        assert_eq!(
620            reference.uri().unwrap(),
621            format!(
622                "meow-artifact://v3/tenant/scope/std/auto/{0}/{0}/eyJrIjoiaSIsIm0iOiJpbWFnZS9qcGVnIiwicyI6MTAwLCJ3IjoxMCwiaCI6MTB9",
623                "a".repeat(64)
624            )
625        );
626        assert_eq!(
627            ArtifactReference::parse(&reference.uri().unwrap()).unwrap(),
628            reference
629        );
630    }
631
632    #[test]
633    fn rejects_cross_scope_and_invalid_metadata() {
634        let reference = ArtifactReference::new(
635            "tenant",
636            "scope",
637            "a".repeat(64),
638            ArtifactMetadata::audio("audio/mpeg", 100, Some(1_000)).unwrap(),
639        )
640        .unwrap();
641
642        assert_eq!(
643            reference
644                .ensure_scope("tenant", "other")
645                .unwrap_err()
646                .kind(),
647            ArtifactReferenceErrorKind::ScopeMismatch
648        );
649        assert!(ArtifactMetadata::audio("image/png", 100, None).is_err());
650        assert!(ArtifactMetadata::file("video/mp4", 100).is_err());
651        assert!(ArtifactMetadata::video("video/mp4", 100, 1920, 1080, Some(1_000)).is_ok());
652        assert!(ArtifactMetadata::file("Application/PDF", 100).is_err());
653    }
654}
655
656object_message! {
657/// TLS identity of a local file endpoint, delivered over the authenticated business channel.
658/// It grants no Host management authority. Public cloud URLs omit this field.
659pub struct HostTlsIdentity {
660    pub host_id: String,
661    pub public_key: HostTlsPublicKey,
662}
663}
664object_message! {
665pub struct HostTlsPublicKey {
666    pub x: String,
667    pub y: String,
668}
669}
670
671/// Lifecycle operations use the same canonical, scope-checked URI as resolve.
672pub type DeleteArtifactRequest = ResolveArtifactRequest;