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 pub kind: ArtifactKind,
12 pub mime_type: String,
13 pub size_bytes: u64,
14 pub sha256: String,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub transform: Option<crate::ImageTransform>,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub duration_millis: Option<u64>,
19}
20
21impl PrepareArtifactUploadRequest {
22 pub fn validate(&self) -> Result<(), PrepareArtifactUploadValidationError> {
23 if let Some(transform) = &self.transform {
24 if self.kind != ArtifactKind::Image {
25 return Err(invalid("image transform requires IMAGE kind"));
26 }
27 transform.validate().map_err(invalid)?;
28 }
29 if self.mime_type.trim() != self.mime_type || self.mime_type.is_empty() {
30 return Err(invalid("artifact upload MIME type is invalid"));
31 }
32 match self.kind {
33 ArtifactKind::Image if !self.mime_type.starts_with("image/") => {
34 return Err(invalid("artifact upload image MIME type is invalid"));
35 }
36 ArtifactKind::Audio if !self.mime_type.starts_with("audio/") => {
37 return Err(invalid("artifact upload audio MIME type is invalid"));
38 }
39 ArtifactKind::Video if !self.mime_type.starts_with("video/") => {
40 return Err(invalid("artifact upload video MIME type is invalid"));
41 }
42 ArtifactKind::File if self.mime_type.starts_with("video/") => {
43 return Err(invalid("video artifacts are not supported"));
44 }
45 _ => {}
46 }
47 if self.size_bytes == 0 {
48 return Err(invalid("artifact upload content is empty"));
49 }
50 if self.sha256.len() != 64
51 || !self
52 .sha256
53 .bytes()
54 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
55 {
56 return Err(invalid(
57 "artifact upload SHA-256 must be 64 lowercase hexadecimal characters",
58 ));
59 }
60 if !matches!(self.kind, ArtifactKind::Audio | ArtifactKind::Video)
61 && self.duration_millis.is_some()
62 {
63 return Err(invalid(
64 "artifact upload duration is only valid for audio or video",
65 ));
66 }
67 if self.duration_millis == Some(0) {
68 return Err(invalid("artifact upload duration is invalid"));
69 }
70 Ok(())
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "camelCase", deny_unknown_fields)]
76pub struct PrepareArtifactUploadResponse {
77 pub upload_id: String,
78 pub url: String,
79 #[serde(default)]
80 pub headers: BTreeMap<String, String>,
81 pub expires_at_unix_ms: u64,
82}
83
84impl PrepareArtifactUploadResponse {
85 pub fn validate(&self) -> Result<(), PrepareArtifactUploadValidationError> {
86 if self.upload_id.trim() != self.upload_id || self.upload_id.is_empty() {
87 return Err(invalid("artifact upload id is invalid"));
88 }
89 if !(self.url.starts_with("http://") || self.url.starts_with("https://")) {
90 return Err(invalid("artifact upload URL must use HTTP or HTTPS"));
91 }
92 if self.expires_at_unix_ms == 0 {
93 return Err(invalid("artifact upload expiration is invalid"));
94 }
95 Ok(())
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct PrepareArtifactUploadValidationError(&'static str);
101
102impl fmt::Display for PrepareArtifactUploadValidationError {
103 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
104 formatter.write_str(self.0)
105 }
106}
107
108impl std::error::Error for PrepareArtifactUploadValidationError {}
109
110fn invalid(message: &'static str) -> PrepareArtifactUploadValidationError {
111 PrepareArtifactUploadValidationError(message)
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn validates_upload_metadata() {
120 let request = PrepareArtifactUploadRequest {
121 kind: ArtifactKind::Audio,
122 mime_type: "audio/ogg".to_string(),
123 size_bytes: 42,
124 sha256: "a".repeat(64),
125 duration_millis: Some(1_500),
126 transform: None,
127 };
128 assert!(request.validate().is_ok());
129 }
130
131 #[test]
132 fn rejects_malformed_hashes_and_kind_mismatch() {
133 let mut request = PrepareArtifactUploadRequest {
134 kind: ArtifactKind::Audio,
135 mime_type: "image/png".to_string(),
136 size_bytes: 42,
137 sha256: "A".repeat(64),
138 duration_millis: Some(1_500),
139 transform: None,
140 };
141 assert!(request.validate().is_err());
142 request.mime_type = "audio/ogg".to_string();
143 assert!(request.validate().is_err());
144 }
145}