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