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