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 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    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub host_identity: Option<crate::HostTlsIdentity>,
71    pub upload_id: String,
72    pub url: String,
73    #[serde(default)]
74    pub headers: BTreeMap<String, String>,
75    pub expires_at_unix_ms: u64,
76}
77
78impl PrepareArtifactUploadResponse {
79    pub fn validate(&self) -> Result<(), PrepareArtifactUploadValidationError> {
80        if self.upload_id.trim() != self.upload_id || self.upload_id.is_empty() {
81            return Err(invalid("artifact upload id is invalid"));
82        }
83        if !(self.url.starts_with("http://") || self.url.starts_with("https://")) {
84            return Err(invalid("artifact upload URL must use HTTP or HTTPS"));
85        }
86        if self.expires_at_unix_ms == 0 {
87            return Err(invalid("artifact upload expiration is invalid"));
88        }
89        Ok(())
90    }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct PrepareArtifactUploadValidationError(&'static str);
95
96impl fmt::Display for PrepareArtifactUploadValidationError {
97    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98        formatter.write_str(self.0)
99    }
100}
101
102impl std::error::Error for PrepareArtifactUploadValidationError {}
103
104fn invalid(message: &'static str) -> PrepareArtifactUploadValidationError {
105    PrepareArtifactUploadValidationError(message)
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn validates_upload_metadata() {
114        let request = PrepareArtifactUploadRequest {
115            kind: ArtifactKind::Audio,
116            mime_type: "audio/ogg".to_string(),
117            size_bytes: 42,
118            sha256: "a".repeat(64),
119            duration_millis: Some(1_500),
120        };
121        assert!(request.validate().is_ok());
122    }
123
124    #[test]
125    fn rejects_malformed_hashes_and_kind_mismatch() {
126        let mut request = PrepareArtifactUploadRequest {
127            kind: ArtifactKind::Audio,
128            mime_type: "image/png".to_string(),
129            size_bytes: 42,
130            sha256: "A".repeat(64),
131            duration_millis: Some(1_500),
132        };
133        assert!(request.validate().is_err());
134        request.mime_type = "audio/ogg".to_string();
135        assert!(request.validate().is_err());
136    }
137}