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}