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        if self.size_bytes == 0 || self.size_bytes > MAX_SAFE_INTEGER {
187            return Err(invalid("artifact size is invalid"));
188        }
189        match self.kind {
190            ArtifactKind::Image => {
191                if !self.mime_type.starts_with("image/") {
192                    return Err(invalid("image artifact MIME type is invalid"));
193                }
194                if self
195                    .width
196                    .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
197                    || self
198                        .height
199                        .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
200                    || self.duration_millis.is_some()
201                {
202                    return Err(invalid("image artifact metadata is invalid"));
203                }
204            }
205            ArtifactKind::Audio => {
206                if !self.mime_type.starts_with("audio/") {
207                    return Err(invalid("audio artifact MIME type is invalid"));
208                }
209                if self.width.is_some()
210                    || self.height.is_some()
211                    || self
212                        .duration_millis
213                        .is_some_and(|value| value == 0 || value > MAX_SAFE_INTEGER)
214                {
215                    return Err(invalid("audio artifact metadata is invalid"));
216                }
217            }
218            ArtifactKind::Video => {
219                if !self.mime_type.starts_with("video/")
220                    || self
221                        .width
222                        .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
223                    || self
224                        .height
225                        .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
226                    || self
227                        .duration_millis
228                        .is_some_and(|value| value == 0 || value > MAX_SAFE_INTEGER)
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() || self.duration_millis.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(skip_serializing_if = "Option::is_none")]
253    w: Option<u32>,
254    #[serde(skip_serializing_if = "Option::is_none")]
255    h: Option<u32>,
256    #[serde(skip_serializing_if = "Option::is_none")]
257    d: Option<u64>,
258}
259
260impl Serialize for ArtifactMetadata {
261    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
262    where
263        S: Serializer,
264    {
265        RawArtifactMetadata {
266            k: self.kind.code().to_string(),
267            m: self.mime_type.clone(),
268            s: self.size_bytes,
269            w: self.width,
270            h: self.height,
271            d: self.duration_millis,
272        }
273        .serialize(serializer)
274    }
275}
276
277impl<'de> Deserialize<'de> for ArtifactMetadata {
278    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
279    where
280        D: Deserializer<'de>,
281    {
282        let raw = RawArtifactMetadata::deserialize(deserializer)?;
283        let metadata = Self {
284            kind: ArtifactKind::from_code(&raw.k).map_err(serde::de::Error::custom)?,
285            mime_type: raw.m,
286            size_bytes: raw.s,
287            width: raw.w,
288            height: raw.h,
289            duration_millis: raw.d,
290        };
291        metadata.validate().map_err(serde::de::Error::custom)?;
292        Ok(metadata)
293    }
294}
295
296/// Canonical, scope-owned, content-addressed artifact identity.
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct ArtifactReference {
299    tenant_id: String,
300    scope_id: String,
301    sha256: String,
302    lifecycle: ArtifactLifecycle,
303    creation_id: Option<String>,
304    metadata: ArtifactMetadata,
305}
306
307impl ArtifactReference {
308    pub fn new(
309        tenant_id: impl Into<String>,
310        scope_id: impl Into<String>,
311        sha256: impl Into<String>,
312        metadata: ArtifactMetadata,
313    ) -> Result<Self, ArtifactReferenceError> {
314        Self::with_lifecycle(
315            tenant_id,
316            scope_id,
317            sha256,
318            metadata,
319            ArtifactLifecycle::Std,
320            None,
321        )
322    }
323
324    pub fn with_lifecycle(
325        tenant_id: impl Into<String>,
326        scope_id: impl Into<String>,
327        sha256: impl Into<String>,
328        metadata: ArtifactMetadata,
329        lifecycle: ArtifactLifecycle,
330        creation_id: Option<String>,
331    ) -> Result<Self, ArtifactReferenceError> {
332        let reference = Self {
333            lifecycle,
334            creation_id,
335            tenant_id: tenant_id.into(),
336            scope_id: scope_id.into(),
337            sha256: sha256.into(),
338            metadata,
339        };
340        reference.validate()?;
341        Ok(reference)
342    }
343
344    pub fn parse(value: &str) -> Result<Self, ArtifactReferenceError> {
345        if value.len() > MAX_URI_LENGTH {
346            return Err(invalid("artifact URI is too long"));
347        }
348        let path = value
349            .strip_prefix(ARTIFACT_URL_PREFIX)
350            .ok_or_else(|| invalid("unsupported artifact URI"))?;
351        let segments = path.split('/').collect::<Vec<_>>();
352        if segments.len() != 6 {
353            return Err(invalid(
354                "artifact URI must contain tenant, scope, lifecycle, resource ID, digest, and metadata",
355            ));
356        }
357        let metadata_bytes = URL_SAFE_NO_PAD
358            .decode(segments[5])
359            .map_err(|_| invalid("artifact metadata is not valid base64url"))?;
360        let metadata: ArtifactMetadata = serde_json::from_slice(&metadata_bytes)
361            .map_err(|_| invalid("artifact metadata is invalid"))?;
362        let lifecycle = ArtifactLifecycle::from_code(segments[2])?;
363        let creation_id = if lifecycle == ArtifactLifecycle::Man {
364            Some(segments[3].to_owned())
365        } else {
366            if segments[3] != segments[4] {
367                return Err(invalid("artifact resource ID must equal its digest"));
368            }
369            None
370        };
371        let reference = Self::with_lifecycle(
372            segments[0],
373            segments[1],
374            segments[4],
375            metadata,
376            lifecycle,
377            creation_id,
378        )?;
379        if reference.uri()? != value {
380            return Err(invalid("artifact URI is not canonical"));
381        }
382        Ok(reference)
383    }
384
385    pub fn uri(&self) -> Result<String, ArtifactReferenceError> {
386        self.validate()?;
387        let metadata = serde_json::to_vec(&self.metadata)
388            .map_err(|_| invalid("artifact metadata cannot be encoded"))?;
389        Ok(format!(
390            "{ARTIFACT_URL_PREFIX}{}/{}/{}/{}/{}/{}",
391            self.tenant_id,
392            self.scope_id,
393            self.lifecycle.code(),
394            self.resource_id(),
395            self.sha256,
396            URL_SAFE_NO_PAD.encode(metadata)
397        ))
398    }
399
400    #[must_use]
401    pub fn tenant_id(&self) -> &str {
402        &self.tenant_id
403    }
404
405    #[must_use]
406    pub fn scope_id(&self) -> &str {
407        &self.scope_id
408    }
409
410    #[must_use]
411    pub fn sha256(&self) -> &str {
412        &self.sha256
413    }
414
415    #[must_use]
416    pub const fn metadata(&self) -> &ArtifactMetadata {
417        &self.metadata
418    }
419
420    pub const fn lifecycle(&self) -> ArtifactLifecycle {
421        self.lifecycle
422    }
423
424    pub fn resource_id(&self) -> &str {
425        self.creation_id.as_deref().unwrap_or(&self.sha256)
426    }
427
428    pub fn creation_id(&self) -> Option<&str> {
429        self.creation_id.as_deref()
430    }
431
432    pub fn ensure_scope(
433        &self,
434        tenant_id: &str,
435        scope_id: &str,
436    ) -> Result<(), ArtifactReferenceError> {
437        if self.tenant_id != tenant_id || self.scope_id != scope_id {
438            return Err(ArtifactReferenceError::new(
439                ArtifactReferenceErrorKind::ScopeMismatch,
440                "artifact does not belong to the current scope",
441            ));
442        }
443        Ok(())
444    }
445
446    fn validate(&self) -> Result<(), ArtifactReferenceError> {
447        self.lifecycle
448            .validate_creation_id(self.creation_id.as_deref())?;
449        if self.lifecycle == ArtifactLifecycle::Man && self.creation_id.is_none() {
450            return Err(invalid("manual artifact requires a creation ID"));
451        }
452        validate_segment(&self.tenant_id, "tenant")?;
453        validate_segment(&self.scope_id, "scope")?;
454        if self.sha256.len() != 64
455            || !self
456                .sha256
457                .bytes()
458                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
459        {
460            return Err(invalid("artifact sha256 is invalid"));
461        }
462        self.metadata.validate()
463    }
464}
465
466fn validate_segment(value: &str, name: &str) -> Result<(), ArtifactReferenceError> {
467    if value.is_empty()
468        || value.len() > MAX_SEGMENT_LENGTH
469        || value == "."
470        || value == ".."
471        || !value
472            .bytes()
473            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
474    {
475        return Err(invalid(format!("artifact {name} is not URL-safe")));
476    }
477    Ok(())
478}
479
480fn validate_mime_type(value: &str) -> Result<(), ArtifactReferenceError> {
481    if value.is_empty()
482        || value.len() > MAX_MIME_LENGTH
483        || value != value.trim()
484        || value.bytes().any(|byte| byte.is_ascii_uppercase())
485    {
486        return Err(invalid("artifact MIME type is invalid"));
487    }
488    let Some((media_type, subtype)) = value.split_once('/') else {
489        return Err(invalid("artifact MIME type is invalid"));
490    };
491    if media_type.is_empty()
492        || subtype.is_empty()
493        || subtype.contains('/')
494        || !value.bytes().all(|byte| {
495            byte.is_ascii_lowercase()
496                || byte.is_ascii_digit()
497                || matches!(
498                    byte,
499                    b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-' | b'/'
500                )
501        })
502    {
503        return Err(invalid("artifact MIME type is invalid"));
504    }
505    Ok(())
506}
507
508/// Stable category for reference validation failures.
509#[derive(Debug, Clone, Copy, PartialEq, Eq)]
510pub enum ArtifactReferenceErrorKind {
511    InvalidReference,
512    ScopeMismatch,
513}
514
515/// Validation error returned for malformed or cross-scope references.
516#[derive(Debug, Clone, PartialEq, Eq)]
517pub struct ArtifactReferenceError {
518    kind: ArtifactReferenceErrorKind,
519    message: String,
520}
521
522impl ArtifactReferenceError {
523    fn new(kind: ArtifactReferenceErrorKind, message: impl Into<String>) -> Self {
524        Self {
525            kind,
526            message: message.into(),
527        }
528    }
529
530    #[must_use]
531    pub const fn kind(&self) -> ArtifactReferenceErrorKind {
532        self.kind
533    }
534
535    #[must_use]
536    pub fn message(&self) -> &str {
537        &self.message
538    }
539
540    #[must_use]
541    pub const fn is_scope_mismatch(&self) -> bool {
542        matches!(self.kind, ArtifactReferenceErrorKind::ScopeMismatch)
543    }
544}
545
546impl fmt::Display for ArtifactReferenceError {
547    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
548        formatter.write_str(&self.message)
549    }
550}
551
552impl std::error::Error for ArtifactReferenceError {}
553
554fn invalid(message: impl Into<String>) -> ArtifactReferenceError {
555    ArtifactReferenceError::new(ArtifactReferenceErrorKind::InvalidReference, message)
556}
557
558/// Public upload/import policy, not a limit on internally generated artifacts.
559pub const MAX_EXTERNAL_ARTIFACT_BYTES: usize = 1024 * 1024;
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564
565    #[test]
566    fn scope_owned_reference_round_trips() {
567        let reference = ArtifactReference::new(
568            "tenant",
569            "scope",
570            "a".repeat(64),
571            ArtifactMetadata::image("image/jpeg", 100, 10, 10).unwrap(),
572        )
573        .unwrap();
574        assert_eq!(
575            reference.uri().unwrap(),
576            format!(
577                "meow-artifact://v2/tenant/scope/std/{0}/{0}/eyJrIjoiaSIsIm0iOiJpbWFnZS9qcGVnIiwicyI6MTAwLCJ3IjoxMCwiaCI6MTB9",
578                "a".repeat(64)
579            )
580        );
581        assert_eq!(
582            ArtifactReference::parse(&reference.uri().unwrap()).unwrap(),
583            reference
584        );
585    }
586
587    #[test]
588    fn rejects_cross_scope_and_invalid_metadata() {
589        let reference = ArtifactReference::new(
590            "tenant",
591            "scope",
592            "a".repeat(64),
593            ArtifactMetadata::audio("audio/mpeg", 100, Some(1_000)).unwrap(),
594        )
595        .unwrap();
596
597        assert_eq!(
598            reference
599                .ensure_scope("tenant", "other")
600                .unwrap_err()
601                .kind(),
602            ArtifactReferenceErrorKind::ScopeMismatch
603        );
604        assert!(ArtifactMetadata::audio("image/png", 100, None).is_err());
605        assert!(ArtifactMetadata::file("video/mp4", 100).is_err());
606        assert!(ArtifactMetadata::video("video/mp4", 100, 1920, 1080, Some(1_000)).is_ok());
607        assert!(ArtifactMetadata::file("Application/PDF", 100).is_err());
608    }
609}
610
611/// TLS identity of a local file endpoint, delivered over the authenticated business channel.
612/// It grants no Host management authority. Public cloud URLs omit this field.
613#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
614#[serde(rename_all = "camelCase", deny_unknown_fields)]
615pub struct HostTlsIdentity {
616    pub host_id: String,
617    pub public_key: HostTlsPublicKey,
618}
619#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
620#[serde(deny_unknown_fields)]
621pub struct HostTlsPublicKey {
622    pub x: String,
623    pub y: String,
624}
625
626/// Lifecycle operations use the same canonical, scope-checked URI as resolve.
627pub type DeleteArtifactRequest = ResolveArtifactRequest;