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