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 policy;
9pub use policy::{ArtifactIdentity, ArtifactPolicy, ArtifactRetention};
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 3 artifact URI format.
28pub const ARTIFACT_URL_PREFIX: &str = "meow-artifact://v3/";
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    policy: ArtifactPolicy,
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_policy(
317            tenant_id,
318            scope_id,
319            sha256,
320            metadata,
321            ArtifactPolicy::AUTO_STD,
322            None,
323        )
324    }
325
326    pub fn with_policy(
327        tenant_id: impl Into<String>,
328        scope_id: impl Into<String>,
329        sha256: impl Into<String>,
330        metadata: ArtifactMetadata,
331        policy: ArtifactPolicy,
332        creation_id: Option<String>,
333    ) -> Result<Self, ArtifactReferenceError> {
334        let reference = Self {
335            policy,
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() != 7 {
355            return Err(invalid(
356                "artifact URI must contain tenant, scope, retention, identity, resource ID, digest, and metadata",
357            ));
358        }
359        let metadata_bytes = URL_SAFE_NO_PAD
360            .decode(segments[6])
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 policy = ArtifactPolicy::new(
365            ArtifactIdentity::from_code(segments[3])?,
366            ArtifactRetention::from_code(segments[2])?,
367        );
368        let creation_id = if policy.identity == ArtifactIdentity::Man {
369            Some(segments[4].to_owned())
370        } else {
371            if segments[4] != segments[5] {
372                return Err(invalid("artifact resource ID must equal its digest"));
373            }
374            None
375        };
376        let reference = Self::with_policy(
377            segments[0],
378            segments[1],
379            segments[5],
380            metadata,
381            policy,
382            creation_id,
383        )?;
384        if reference.uri()? != value {
385            return Err(invalid("artifact URI is not canonical"));
386        }
387        Ok(reference)
388    }
389
390    pub fn uri(&self) -> Result<String, ArtifactReferenceError> {
391        self.validate()?;
392        let metadata = serde_json::to_vec(&self.metadata)
393            .map_err(|_| invalid("artifact metadata cannot be encoded"))?;
394        Ok(format!(
395            "{ARTIFACT_URL_PREFIX}{}/{}/{}/{}/{}/{}/{}",
396            self.tenant_id,
397            self.scope_id,
398            self.policy.retention.code(),
399            self.policy.identity.code(),
400            self.resource_id(),
401            self.sha256,
402            URL_SAFE_NO_PAD.encode(metadata)
403        ))
404    }
405
406    #[must_use]
407    pub fn tenant_id(&self) -> &str {
408        &self.tenant_id
409    }
410
411    #[must_use]
412    pub fn scope_id(&self) -> &str {
413        &self.scope_id
414    }
415
416    #[must_use]
417    pub fn sha256(&self) -> &str {
418        &self.sha256
419    }
420
421    #[must_use]
422    pub const fn metadata(&self) -> &ArtifactMetadata {
423        &self.metadata
424    }
425
426    pub const fn policy(&self) -> ArtifactPolicy {
427        self.policy
428    }
429
430    pub fn resource_id(&self) -> &str {
431        self.creation_id.as_deref().unwrap_or(&self.sha256)
432    }
433
434    pub fn creation_id(&self) -> Option<&str> {
435        self.creation_id.as_deref()
436    }
437
438    pub fn ensure_scope(
439        &self,
440        tenant_id: &str,
441        scope_id: &str,
442    ) -> Result<(), ArtifactReferenceError> {
443        if self.tenant_id != tenant_id || self.scope_id != scope_id {
444            return Err(ArtifactReferenceError::new(
445                ArtifactReferenceErrorKind::ScopeMismatch,
446                "artifact does not belong to the current scope",
447            ));
448        }
449        Ok(())
450    }
451
452    fn validate(&self) -> Result<(), ArtifactReferenceError> {
453        self.policy
454            .validate_creation_id(self.creation_id.as_deref())?;
455        validate_segment(&self.tenant_id, "tenant")?;
456        validate_segment(&self.scope_id, "scope")?;
457        if self.sha256.len() != 64
458            || !self
459                .sha256
460                .bytes()
461                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
462        {
463            return Err(invalid("artifact sha256 is invalid"));
464        }
465        self.metadata.validate()
466    }
467}
468
469// Serde's default Option handling conflates omission with null. Wire fields are optional,
470// but an explicitly supplied value must deserialize as T, matching the published schemas.
471fn deserialize_optional_non_null<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
472where
473    D: Deserializer<'de>,
474    T: Deserialize<'de>,
475{
476    T::deserialize(deserializer).map(Some)
477}
478
479fn validate_size_bytes(size_bytes: u64) -> Result<(), &'static str> {
480    if size_bytes == 0 || size_bytes > MAX_SAFE_INTEGER {
481        return Err("artifact size is invalid");
482    }
483    Ok(())
484}
485
486fn validate_duration_millis(
487    kind: ArtifactKind,
488    duration_millis: Option<u64>,
489) -> Result<(), &'static str> {
490    if let Some(duration) = duration_millis {
491        if !matches!(kind, ArtifactKind::Audio | ArtifactKind::Video) {
492            return Err("artifact duration is only valid for audio or video");
493        }
494        if duration == 0 || duration > MAX_SAFE_INTEGER {
495            return Err("artifact duration is invalid");
496        }
497    }
498    Ok(())
499}
500
501fn validate_segment(value: &str, name: &str) -> Result<(), ArtifactReferenceError> {
502    if value.is_empty()
503        || value.len() > MAX_SEGMENT_LENGTH
504        || value == "."
505        || value == ".."
506        || !value
507            .bytes()
508            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
509    {
510        return Err(invalid(format!("artifact {name} is not URL-safe")));
511    }
512    Ok(())
513}
514
515fn validate_mime_type(value: &str) -> Result<(), ArtifactReferenceError> {
516    if value.is_empty()
517        || value.len() > MAX_MIME_LENGTH
518        || value != value.trim()
519        || value.bytes().any(|byte| byte.is_ascii_uppercase())
520    {
521        return Err(invalid("artifact MIME type is invalid"));
522    }
523    let Some((media_type, subtype)) = value.split_once('/') else {
524        return Err(invalid("artifact MIME type is invalid"));
525    };
526    if media_type.is_empty()
527        || subtype.is_empty()
528        || subtype.contains('/')
529        || !value.bytes().all(|byte| {
530            byte.is_ascii_lowercase()
531                || byte.is_ascii_digit()
532                || matches!(
533                    byte,
534                    b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-' | b'/'
535                )
536        })
537    {
538        return Err(invalid("artifact MIME type is invalid"));
539    }
540    Ok(())
541}
542
543/// Stable category for reference validation failures.
544#[derive(Debug, Clone, Copy, PartialEq, Eq)]
545pub enum ArtifactReferenceErrorKind {
546    InvalidReference,
547    ScopeMismatch,
548}
549
550/// Validation error returned for malformed or cross-scope references.
551#[derive(Debug, Clone, PartialEq, Eq)]
552pub struct ArtifactReferenceError {
553    kind: ArtifactReferenceErrorKind,
554    message: String,
555}
556
557impl ArtifactReferenceError {
558    fn new(kind: ArtifactReferenceErrorKind, message: impl Into<String>) -> Self {
559        Self {
560            kind,
561            message: message.into(),
562        }
563    }
564
565    #[must_use]
566    pub const fn kind(&self) -> ArtifactReferenceErrorKind {
567        self.kind
568    }
569
570    #[must_use]
571    pub fn message(&self) -> &str {
572        &self.message
573    }
574
575    #[must_use]
576    pub const fn is_scope_mismatch(&self) -> bool {
577        matches!(self.kind, ArtifactReferenceErrorKind::ScopeMismatch)
578    }
579}
580
581impl fmt::Display for ArtifactReferenceError {
582    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
583        formatter.write_str(&self.message)
584    }
585}
586
587impl std::error::Error for ArtifactReferenceError {}
588
589fn invalid(message: impl Into<String>) -> ArtifactReferenceError {
590    ArtifactReferenceError::new(ArtifactReferenceErrorKind::InvalidReference, message)
591}
592
593/// Public upload/import policy, not a limit on internally generated artifacts.
594pub const MAX_EXTERNAL_ARTIFACT_BYTES: usize = 1024 * 1024;
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    #[test]
601    fn scope_owned_reference_round_trips() {
602        let reference = ArtifactReference::new(
603            "tenant",
604            "scope",
605            "a".repeat(64),
606            ArtifactMetadata::image("image/jpeg", 100, 10, 10).unwrap(),
607        )
608        .unwrap();
609        assert_eq!(
610            reference.uri().unwrap(),
611            format!(
612                "meow-artifact://v3/tenant/scope/std/auto/{0}/{0}/eyJrIjoiaSIsIm0iOiJpbWFnZS9qcGVnIiwicyI6MTAwLCJ3IjoxMCwiaCI6MTB9",
613                "a".repeat(64)
614            )
615        );
616        assert_eq!(
617            ArtifactReference::parse(&reference.uri().unwrap()).unwrap(),
618            reference
619        );
620    }
621
622    #[test]
623    fn rejects_cross_scope_and_invalid_metadata() {
624        let reference = ArtifactReference::new(
625            "tenant",
626            "scope",
627            "a".repeat(64),
628            ArtifactMetadata::audio("audio/mpeg", 100, Some(1_000)).unwrap(),
629        )
630        .unwrap();
631
632        assert_eq!(
633            reference
634                .ensure_scope("tenant", "other")
635                .unwrap_err()
636                .kind(),
637            ArtifactReferenceErrorKind::ScopeMismatch
638        );
639        assert!(ArtifactMetadata::audio("image/png", 100, None).is_err());
640        assert!(ArtifactMetadata::file("video/mp4", 100).is_err());
641        assert!(ArtifactMetadata::video("video/mp4", 100, 1920, 1080, Some(1_000)).is_ok());
642        assert!(ArtifactMetadata::file("Application/PDF", 100).is_err());
643    }
644}
645
646/// TLS identity of a local file endpoint, delivered over the authenticated business channel.
647/// It grants no Host management authority. Public cloud URLs omit this field.
648#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
649#[serde(rename_all = "camelCase", deny_unknown_fields)]
650pub struct HostTlsIdentity {
651    pub host_id: String,
652    pub public_key: HostTlsPublicKey,
653}
654#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
655#[serde(deny_unknown_fields)]
656pub struct HostTlsPublicKey {
657    pub x: String,
658    pub y: String,
659}
660
661/// Lifecycle operations use the same canonical, scope-checked URI as resolve.
662pub type DeleteArtifactRequest = ResolveArtifactRequest;