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