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