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