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 pub uri: String,
37 pub kind: ArtifactKind,
38 pub mime_type: String,
39 pub size_bytes: u64,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub width: Option<u32>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub height: Option<u32>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub duration_millis: Option<u64>,
46 pub delivery: ArtifactDelivery,
47}
48
49impl ResolveArtifactResponse {
50 pub fn validate(&self) -> Result<ArtifactReference, ResolveArtifactResponseError> {
51 let reference = ArtifactReference::parse(&self.uri)
52 .map_err(|_| invalid("artifact resolve URI is invalid"))?;
53 let metadata = reference.metadata();
54 if self.kind != metadata.kind()
55 || self.mime_type != metadata.mime_type()
56 || self.size_bytes != metadata.size_bytes()
57 || self.width != metadata.width()
58 || self.height != metadata.height()
59 || self.duration_millis != metadata.duration_millis()
60 {
61 return Err(invalid(
62 "artifact resolve metadata does not match its canonical reference",
63 ));
64 }
65 match &self.delivery {
66 ArtifactDelivery::DataUrl { data_url } => {
67 let prefix = format!("data:{};base64,", self.mime_type);
68 if !data_url.starts_with(&prefix) || data_url.len() == prefix.len() {
69 return Err(invalid("artifact resolve data URL is invalid"));
70 }
71 }
72 ArtifactDelivery::SignedUrl {
73 url,
74 expires_at_unix_ms,
75 } => {
76 if !(url.starts_with("https://") || url.starts_with("http://"))
77 || *expires_at_unix_ms == 0
78 {
79 return Err(invalid("artifact resolve signed URL is invalid"));
80 }
81 }
82 }
83 Ok(reference)
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ResolveArtifactResponseError(&'static str);
89
90impl fmt::Display for ResolveArtifactResponseError {
91 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
92 formatter.write_str(self.0)
93 }
94}
95
96impl std::error::Error for ResolveArtifactResponseError {}
97
98fn invalid(message: &'static str) -> ResolveArtifactResponseError {
99 ResolveArtifactResponseError(message)
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 const FILE_URI: &str = "meow-artifact://v1/tenant/scope/cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc/eyJrIjoiZiIsIm0iOiJhcHBsaWNhdGlvbi9wZGYiLCJzIjozMH0";
107
108 #[test]
109 fn validates_response_against_reference() {
110 let response = ResolveArtifactResponse {
111 uri: FILE_URI.to_string(),
112 kind: ArtifactKind::File,
113 mime_type: "application/pdf".to_string(),
114 size_bytes: 30,
115 width: None,
116 height: None,
117 duration_millis: None,
118 delivery: ArtifactDelivery::SignedUrl {
119 url: "https://example.test/file".to_string(),
120 expires_at_unix_ms: 1,
121 },
122 };
123
124 assert_eq!(response.validate().unwrap().uri().unwrap(), FILE_URI);
125 }
126
127 #[test]
128 fn rejects_response_metadata_drift() {
129 let response = ResolveArtifactResponse {
130 uri: FILE_URI.to_string(),
131 kind: ArtifactKind::File,
132 mime_type: "text/plain".to_string(),
133 size_bytes: 30,
134 width: None,
135 height: None,
136 duration_millis: None,
137 delivery: ArtifactDelivery::DataUrl {
138 data_url: "data:text/plain;base64,eA==".to_string(),
139 },
140 };
141
142 assert!(response.validate().is_err());
143 }
144}