Skip to main content

artifact_api/
resolve.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{ArtifactKind, ArtifactReference};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "camelCase", deny_unknown_fields)]
9pub struct ResolveArtifactRequest {
10    pub uri: String,
11}
12
13impl ResolveArtifactRequest {
14    pub fn reference(&self) -> Result<ArtifactReference, crate::ArtifactReferenceError> {
15        ArtifactReference::parse(&self.uri)
16    }
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE", deny_unknown_fields)]
21pub enum ArtifactDelivery {
22    DataUrl {
23        #[serde(rename = "dataUrl")]
24        data_url: String,
25    },
26    SignedUrl {
27        url: String,
28        #[serde(rename = "expiresAtUnixMs")]
29        expires_at_unix_ms: u64,
30    },
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase", deny_unknown_fields)]
35pub struct ResolveArtifactResponse {
36    #[serde(default)]
37    pub lifecycle: crate::ArtifactLifecycle,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub expires_at_unix_ms: Option<u64>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub host_identity: Option<crate::HostTlsIdentity>,
42    pub uri: String,
43    pub kind: ArtifactKind,
44    pub mime_type: String,
45    pub size_bytes: u64,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub width: Option<u32>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub height: Option<u32>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub duration_millis: Option<u64>,
52    pub delivery: ArtifactDelivery,
53}
54
55impl ResolveArtifactResponse {
56    pub fn validate(&self) -> Result<ArtifactReference, ResolveArtifactResponseError> {
57        if self.lifecycle.is_temporary() != self.expires_at_unix_ms.is_some()
58            || self.expires_at_unix_ms == Some(0)
59        {
60            return Err(invalid("artifact lifetime and expiration are inconsistent"));
61        }
62        let reference = ArtifactReference::parse(&self.uri)
63            .map_err(|_| invalid("artifact resolve URI is invalid"))?;
64        let metadata = reference.metadata();
65        if self.lifecycle != reference.lifecycle()
66            || self.kind != metadata.kind()
67            || self.mime_type != metadata.mime_type()
68            || self.size_bytes != metadata.size_bytes()
69            || self.width != metadata.width()
70            || self.height != metadata.height()
71            || self.duration_millis != metadata.duration_millis()
72        {
73            return Err(invalid(
74                "artifact resolve metadata does not match its canonical reference",
75            ));
76        }
77        match &self.delivery {
78            ArtifactDelivery::DataUrl { data_url } => {
79                let prefix = format!("data:{};base64,", self.mime_type);
80                if !data_url.starts_with(&prefix) || data_url.len() == prefix.len() {
81                    return Err(invalid("artifact resolve data URL is invalid"));
82                }
83            }
84            ArtifactDelivery::SignedUrl {
85                url,
86                expires_at_unix_ms,
87            } => {
88                if !(url.starts_with("https://") || url.starts_with("http://"))
89                    || *expires_at_unix_ms == 0
90                {
91                    return Err(invalid("artifact resolve signed URL is invalid"));
92                }
93            }
94        }
95        Ok(reference)
96    }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct ResolveArtifactResponseError(&'static str);
101
102impl fmt::Display for ResolveArtifactResponseError {
103    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
104        formatter.write_str(self.0)
105    }
106}
107
108impl std::error::Error for ResolveArtifactResponseError {}
109
110fn invalid(message: &'static str) -> ResolveArtifactResponseError {
111    ResolveArtifactResponseError(message)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    const FILE_URI: &str = "meow-artifact://v2/tenant/scope/std/cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc/cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc/eyJrIjoiZiIsIm0iOiJhcHBsaWNhdGlvbi9wZGYiLCJzIjozMH0";
119
120    #[test]
121    fn validates_response_against_reference() {
122        let response = ResolveArtifactResponse {
123            lifecycle: crate::ArtifactLifecycle::Std,
124            expires_at_unix_ms: None,
125            host_identity: None,
126            uri: FILE_URI.to_string(),
127            kind: ArtifactKind::File,
128            mime_type: "application/pdf".to_string(),
129            size_bytes: 30,
130            width: None,
131            height: None,
132            duration_millis: None,
133            delivery: ArtifactDelivery::SignedUrl {
134                url: "https://example.test/file".to_string(),
135                expires_at_unix_ms: 1,
136            },
137        };
138
139        assert_eq!(response.validate().unwrap().uri().unwrap(), FILE_URI);
140    }
141
142    #[test]
143    fn rejects_response_metadata_drift() {
144        let response = ResolveArtifactResponse {
145            lifecycle: crate::ArtifactLifecycle::Std,
146            expires_at_unix_ms: None,
147            host_identity: None,
148            uri: FILE_URI.to_string(),
149            kind: ArtifactKind::File,
150            mime_type: "text/plain".to_string(),
151            size_bytes: 30,
152            width: None,
153            height: None,
154            duration_millis: None,
155            delivery: ArtifactDelivery::DataUrl {
156                data_url: "data:text/plain;base64,eA==".to_string(),
157            },
158        };
159
160        assert!(response.validate().is_err());
161    }
162}