1use std::collections::BTreeMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5
6use crate::ArtifactKind;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase", deny_unknown_fields)]
10pub struct PrepareArtifactUploadRequest {
11 #[serde(default)]
12 pub identity: crate::ArtifactIdentity,
13 #[serde(default)]
14 pub retention: crate::ArtifactRetention,
15 #[serde(
16 default,
17 skip_serializing_if = "Option::is_none",
18 deserialize_with = "crate::deserialize_optional_non_null"
19 )]
20 pub creation_id: Option<String>,
21 pub kind: ArtifactKind,
22 pub mime_type: String,
23 pub size_bytes: u64,
24 pub sha256: String,
25 #[serde(
26 default,
27 skip_serializing_if = "Option::is_none",
28 deserialize_with = "crate::deserialize_optional_non_null"
29 )]
30 pub duration_millis: Option<u64>,
31}
32
33impl PrepareArtifactUploadRequest {
34 pub fn validate(&self) -> Result<(), PrepareArtifactUploadValidationError> {
35 self.policy()
36 .validate_creation_id(self.creation_id.as_deref())
37 .map_err(|_| invalid("invalid artifact creation ID"))?;
38 validate_content_type(self.kind, &self.mime_type)?;
39 crate::validate_size_bytes(self.size_bytes).map_err(invalid)?;
40 validate_sha256(&self.sha256)?;
41 crate::validate_duration_millis(self.kind, self.duration_millis).map_err(invalid)?;
42 Ok(())
43 }
44}
45
46pub(super) fn validate_content_type(
47 kind: ArtifactKind,
48 mime_type: &str,
49) -> Result<(), PrepareArtifactUploadValidationError> {
50 crate::validate_mime_type(mime_type)
51 .map_err(|_| invalid("artifact upload MIME type is invalid"))?;
52 match kind {
53 ArtifactKind::Image if !mime_type.starts_with("image/") => {
54 return Err(invalid("artifact upload image MIME type is invalid"));
55 }
56 ArtifactKind::Audio if !mime_type.starts_with("audio/") => {
57 return Err(invalid("artifact upload audio MIME type is invalid"));
58 }
59 ArtifactKind::Video if !mime_type.starts_with("video/") => {
60 return Err(invalid("artifact upload video MIME type is invalid"));
61 }
62 ArtifactKind::File if mime_type.starts_with("video/") => {
63 return Err(invalid("video artifacts are not supported"));
64 }
65 _ => {}
66 }
67 Ok(())
68}
69
70pub(super) fn validate_sha256(sha256: &str) -> Result<(), PrepareArtifactUploadValidationError> {
71 if sha256.len() != 64
72 || !sha256
73 .bytes()
74 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
75 {
76 return Err(invalid(
77 "artifact upload SHA-256 must be 64 lowercase hexadecimal characters",
78 ));
79 }
80 Ok(())
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase", deny_unknown_fields)]
85pub struct PrepareArtifactUploadResponse {
86 #[serde(
87 default,
88 skip_serializing_if = "Option::is_none",
89 deserialize_with = "crate::deserialize_optional_non_null"
90 )]
91 pub host_identity: Option<crate::HostTlsIdentity>,
92 pub upload_id: String,
93 pub url: String,
94 #[serde(default)]
95 pub headers: BTreeMap<String, String>,
96 pub expires_at_unix_ms: u64,
97}
98
99impl PrepareArtifactUploadResponse {
100 pub fn validate(&self) -> Result<(), PrepareArtifactUploadValidationError> {
101 if self.upload_id.trim() != self.upload_id || self.upload_id.is_empty() {
102 return Err(invalid("artifact upload id is invalid"));
103 }
104 if !(self.url.starts_with("http://") || self.url.starts_with("https://")) {
105 return Err(invalid("artifact upload URL must use HTTP or HTTPS"));
106 }
107 if self.expires_at_unix_ms == 0 {
108 return Err(invalid("artifact upload expiration is invalid"));
109 }
110 Ok(())
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct PrepareArtifactUploadValidationError(&'static str);
116
117impl fmt::Display for PrepareArtifactUploadValidationError {
118 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
119 formatter.write_str(self.0)
120 }
121}
122
123impl std::error::Error for PrepareArtifactUploadValidationError {}
124
125pub(super) fn invalid(message: &'static str) -> PrepareArtifactUploadValidationError {
126 PrepareArtifactUploadValidationError(message)
127}
128
129impl PrepareArtifactUploadRequest {
130 pub const fn policy(&self) -> crate::ArtifactPolicy {
131 crate::ArtifactPolicy::new(self.identity, self.retention)
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn validates_upload_metadata() {
141 let request = PrepareArtifactUploadRequest {
142 identity: crate::ArtifactPolicy::AUTO_STD.identity,
143
144 retention: crate::ArtifactPolicy::AUTO_STD.retention,
145 creation_id: None,
146 kind: ArtifactKind::Audio,
147 mime_type: "audio/ogg".to_string(),
148 size_bytes: 42,
149 sha256: "a".repeat(64),
150 duration_millis: Some(1_500),
151 };
152 assert!(request.validate().is_ok());
153 }
154
155 #[test]
156 fn rejects_malformed_hashes_and_kind_mismatch() {
157 let mut request = PrepareArtifactUploadRequest {
158 identity: crate::ArtifactPolicy::AUTO_STD.identity,
159
160 retention: crate::ArtifactPolicy::AUTO_STD.retention,
161 creation_id: None,
162 kind: ArtifactKind::Audio,
163 mime_type: "image/png".to_string(),
164 size_bytes: 42,
165 sha256: "A".repeat(64),
166 duration_millis: Some(1_500),
167 };
168 assert!(request.validate().is_err());
169 request.mime_type = "audio/ogg".to_string();
170 assert!(request.validate().is_err());
171 }
172}