Skip to main content

artifact_api/
upload.rs

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}