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 identity: crate::ArtifactIdentity,
38    #[serde(default)]
39    pub retention: crate::ArtifactRetention,
40    #[serde(
41        default,
42        skip_serializing_if = "Option::is_none",
43        deserialize_with = "crate::deserialize_optional_non_null"
44    )]
45    pub expires_at_unix_ms: Option<u64>,
46    #[serde(
47        default,
48        skip_serializing_if = "Option::is_none",
49        deserialize_with = "crate::deserialize_optional_non_null"
50    )]
51    pub host_identity: Option<crate::HostTlsIdentity>,
52    pub uri: String,
53    pub kind: ArtifactKind,
54    pub mime_type: String,
55    pub size_bytes: u64,
56    #[serde(
57        default,
58        skip_serializing_if = "Option::is_none",
59        deserialize_with = "crate::deserialize_optional_non_null"
60    )]
61    pub width: Option<u32>,
62    #[serde(
63        default,
64        skip_serializing_if = "Option::is_none",
65        deserialize_with = "crate::deserialize_optional_non_null"
66    )]
67    pub height: Option<u32>,
68    #[serde(
69        default,
70        skip_serializing_if = "Option::is_none",
71        deserialize_with = "crate::deserialize_optional_non_null"
72    )]
73    pub duration_millis: Option<u64>,
74    pub delivery: ArtifactDelivery,
75}
76
77impl ResolveArtifactResponse {
78    pub fn validate(&self) -> Result<ArtifactReference, ResolveArtifactResponseError> {
79        if self.policy().is_temporary() != self.expires_at_unix_ms.is_some()
80            || self.expires_at_unix_ms == Some(0)
81        {
82            return Err(invalid("artifact lifetime and expiration are inconsistent"));
83        }
84        let reference = ArtifactReference::parse(&self.uri)
85            .map_err(|_| invalid("artifact resolve URI is invalid"))?;
86        let metadata = reference.metadata();
87        if self.policy() != reference.policy()
88            || self.kind != metadata.kind()
89            || self.mime_type != metadata.mime_type()
90            || self.size_bytes != metadata.size_bytes()
91            || self.width != metadata.width()
92            || self.height != metadata.height()
93            || self.duration_millis != metadata.duration_millis()
94        {
95            return Err(invalid(
96                "artifact resolve metadata does not match its canonical reference",
97            ));
98        }
99        match &self.delivery {
100            ArtifactDelivery::DataUrl { data_url } => {
101                let prefix = format!("data:{};base64,", self.mime_type);
102                if !data_url.starts_with(&prefix) || data_url.len() == prefix.len() {
103                    return Err(invalid("artifact resolve data URL is invalid"));
104                }
105            }
106            ArtifactDelivery::SignedUrl {
107                url,
108                expires_at_unix_ms,
109            } => {
110                if !(url.starts_with("https://") || url.starts_with("http://"))
111                    || *expires_at_unix_ms == 0
112                {
113                    return Err(invalid("artifact resolve signed URL is invalid"));
114                }
115            }
116        }
117        Ok(reference)
118    }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct ResolveArtifactResponseError(&'static str);
123
124impl fmt::Display for ResolveArtifactResponseError {
125    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
126        formatter.write_str(self.0)
127    }
128}
129
130impl std::error::Error for ResolveArtifactResponseError {}
131
132fn invalid(message: &'static str) -> ResolveArtifactResponseError {
133    ResolveArtifactResponseError(message)
134}
135
136impl ResolveArtifactResponse {
137    pub const fn policy(&self) -> crate::ArtifactPolicy {
138        crate::ArtifactPolicy::new(self.identity, self.retention)
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    const FILE_URI: &str = "meow-artifact://v3/tenant/scope/std/auto/cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc/cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc/eyJrIjoiZiIsIm0iOiJhcHBsaWNhdGlvbi9wZGYiLCJzIjozMH0";
147
148    #[test]
149    fn validates_response_against_reference() {
150        let response = ResolveArtifactResponse {
151            identity: crate::ArtifactPolicy::AUTO_STD.identity,
152
153            retention: crate::ArtifactPolicy::AUTO_STD.retention,
154            expires_at_unix_ms: None,
155            host_identity: None,
156            uri: FILE_URI.to_string(),
157            kind: ArtifactKind::File,
158            mime_type: "application/pdf".to_string(),
159            size_bytes: 30,
160            width: None,
161            height: None,
162            duration_millis: None,
163            delivery: ArtifactDelivery::SignedUrl {
164                url: "https://example.test/file".to_string(),
165                expires_at_unix_ms: 1,
166            },
167        };
168
169        assert_eq!(response.validate().unwrap().uri().unwrap(), FILE_URI);
170    }
171
172    #[test]
173    fn rejects_response_metadata_drift() {
174        let response = ResolveArtifactResponse {
175            identity: crate::ArtifactPolicy::AUTO_STD.identity,
176
177            retention: crate::ArtifactPolicy::AUTO_STD.retention,
178            expires_at_unix_ms: None,
179            host_identity: None,
180            uri: FILE_URI.to_string(),
181            kind: ArtifactKind::File,
182            mime_type: "text/plain".to_string(),
183            size_bytes: 30,
184            width: None,
185            height: None,
186            duration_millis: None,
187            delivery: ArtifactDelivery::DataUrl {
188                data_url: "data:text/plain;base64,eA==".to_string(),
189            },
190        };
191
192        assert!(response.validate().is_err());
193    }
194}