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, skip_serializing_if = "std::ops::Not::not")]
37 pub temporary: bool,
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.temporary != self.expires_at_unix_ms.is_some() || self.expires_at_unix_ms == Some(0)
58 {
59 return Err(invalid("artifact lifetime and expiration are inconsistent"));
60 }
61 let reference = ArtifactReference::parse(&self.uri)
62 .map_err(|_| invalid("artifact resolve URI is invalid"))?;
63 let metadata = reference.metadata();
64 if self.kind != metadata.kind()
65 || self.mime_type != metadata.mime_type()
66 || self.size_bytes != metadata.size_bytes()
67 || self.width != metadata.width()
68 || self.height != metadata.height()
69 || self.duration_millis != metadata.duration_millis()
70 {
71 return Err(invalid(
72 "artifact resolve metadata does not match its canonical reference",
73 ));
74 }
75 match &self.delivery {
76 ArtifactDelivery::DataUrl { data_url } => {
77 let prefix = format!("data:{};base64,", self.mime_type);
78 if !data_url.starts_with(&prefix) || data_url.len() == prefix.len() {
79 return Err(invalid("artifact resolve data URL is invalid"));
80 }
81 }
82 ArtifactDelivery::SignedUrl {
83 url,
84 expires_at_unix_ms,
85 } => {
86 if !(url.starts_with("https://") || url.starts_with("http://"))
87 || *expires_at_unix_ms == 0
88 {
89 return Err(invalid("artifact resolve signed URL is invalid"));
90 }
91 }
92 }
93 Ok(reference)
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct ResolveArtifactResponseError(&'static str);
99
100impl fmt::Display for ResolveArtifactResponseError {
101 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
102 formatter.write_str(self.0)
103 }
104}
105
106impl std::error::Error for ResolveArtifactResponseError {}
107
108fn invalid(message: &'static str) -> ResolveArtifactResponseError {
109 ResolveArtifactResponseError(message)
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 const FILE_URI: &str = "meow-artifact://v1/tenant/scope/cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc/eyJrIjoiZiIsIm0iOiJhcHBsaWNhdGlvbi9wZGYiLCJzIjozMH0";
117
118 #[test]
119 fn validates_response_against_reference() {
120 let response = ResolveArtifactResponse {
121 temporary: false,
122 expires_at_unix_ms: None,
123 host_identity: None,
124 uri: FILE_URI.to_string(),
125 kind: ArtifactKind::File,
126 mime_type: "application/pdf".to_string(),
127 size_bytes: 30,
128 width: None,
129 height: None,
130 duration_millis: None,
131 delivery: ArtifactDelivery::SignedUrl {
132 url: "https://example.test/file".to_string(),
133 expires_at_unix_ms: 1,
134 },
135 };
136
137 assert_eq!(response.validate().unwrap().uri().unwrap(), FILE_URI);
138 }
139
140 #[test]
141 fn rejects_response_metadata_drift() {
142 let response = ResolveArtifactResponse {
143 temporary: false,
144 expires_at_unix_ms: None,
145 host_identity: None,
146 uri: FILE_URI.to_string(),
147 kind: ArtifactKind::File,
148 mime_type: "text/plain".to_string(),
149 size_bytes: 30,
150 width: None,
151 height: None,
152 duration_millis: None,
153 delivery: ArtifactDelivery::DataUrl {
154 data_url: "data:text/plain;base64,eA==".to_string(),
155 },
156 };
157
158 assert!(response.validate().is_err());
159 }
160}