1use std::fmt;
4
5use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8mod put;
9mod resolve;
10mod upload;
11
12pub use put::{PutArtifactRequest, PutArtifactResponse, PutArtifactValidationError};
13
14pub use resolve::{
15 ArtifactDelivery, ResolveArtifactRequest, ResolveArtifactResponse, ResolveArtifactResponseError,
16};
17pub use upload::{
18 PrepareArtifactUploadRequest, PrepareArtifactUploadResponse,
19 PrepareArtifactUploadValidationError,
20};
21
22pub const ARTIFACT_URL_PREFIX: &str = "meow-artifact://v1/";
24const MAX_URI_LENGTH: usize = 2_048;
25const MAX_SEGMENT_LENGTH: usize = 256;
26const MAX_MIME_LENGTH: usize = 255;
27const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
28const MAX_DIMENSION: u32 = i32::MAX as u32;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
33pub enum ArtifactKind {
34 Image,
35 Audio,
36 Video,
37 File,
38}
39
40impl ArtifactKind {
41 #[must_use]
42 pub const fn code(self) -> &'static str {
43 match self {
44 Self::Image => "i",
45 Self::Audio => "a",
46 Self::Video => "v",
47 Self::File => "f",
48 }
49 }
50
51 pub fn from_code(value: &str) -> Result<Self, ArtifactReferenceError> {
52 match value {
53 "i" => Ok(Self::Image),
54 "a" => Ok(Self::Audio),
55 "v" => Ok(Self::Video),
56 "f" => Ok(Self::File),
57 _ => Err(invalid("unsupported artifact kind")),
58 }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct ArtifactMetadata {
65 kind: ArtifactKind,
66 mime_type: String,
67 size_bytes: u64,
68 width: Option<u32>,
69 height: Option<u32>,
70 duration_millis: Option<u64>,
71}
72
73impl ArtifactMetadata {
74 pub fn image(
75 mime_type: impl Into<String>,
76 size_bytes: usize,
77 width: u32,
78 height: u32,
79 ) -> Result<Self, ArtifactReferenceError> {
80 Self::build(
81 ArtifactKind::Image,
82 mime_type,
83 size_bytes,
84 Some(width),
85 Some(height),
86 None,
87 )
88 }
89
90 pub fn audio(
91 mime_type: impl Into<String>,
92 size_bytes: usize,
93 duration_millis: Option<u64>,
94 ) -> Result<Self, ArtifactReferenceError> {
95 Self::build(
96 ArtifactKind::Audio,
97 mime_type,
98 size_bytes,
99 None,
100 None,
101 duration_millis,
102 )
103 }
104
105 pub fn file(
106 mime_type: impl Into<String>,
107 size_bytes: usize,
108 ) -> Result<Self, ArtifactReferenceError> {
109 Self::build(ArtifactKind::File, mime_type, size_bytes, None, None, None)
110 }
111
112 pub fn video(
113 mime_type: impl Into<String>,
114 size_bytes: usize,
115 width: u32,
116 height: u32,
117 duration_millis: Option<u64>,
118 ) -> Result<Self, ArtifactReferenceError> {
119 Self::build(
120 ArtifactKind::Video,
121 mime_type,
122 size_bytes,
123 Some(width),
124 Some(height),
125 duration_millis,
126 )
127 }
128
129 fn build(
130 kind: ArtifactKind,
131 mime_type: impl Into<String>,
132 size_bytes: usize,
133 width: Option<u32>,
134 height: Option<u32>,
135 duration_millis: Option<u64>,
136 ) -> Result<Self, ArtifactReferenceError> {
137 let metadata = Self {
138 kind,
139 mime_type: mime_type.into(),
140 size_bytes: size_bytes as u64,
141 width,
142 height,
143 duration_millis,
144 };
145 metadata.validate()?;
146 Ok(metadata)
147 }
148
149 #[must_use]
150 pub const fn kind(&self) -> ArtifactKind {
151 self.kind
152 }
153
154 #[must_use]
155 pub fn mime_type(&self) -> &str {
156 &self.mime_type
157 }
158
159 #[must_use]
160 pub const fn size_bytes(&self) -> u64 {
161 self.size_bytes
162 }
163
164 #[must_use]
165 pub const fn width(&self) -> Option<u32> {
166 self.width
167 }
168
169 #[must_use]
170 pub const fn height(&self) -> Option<u32> {
171 self.height
172 }
173
174 #[must_use]
175 pub const fn duration_millis(&self) -> Option<u64> {
176 self.duration_millis
177 }
178
179 fn validate(&self) -> Result<(), ArtifactReferenceError> {
180 validate_mime_type(&self.mime_type)?;
181 if self.size_bytes == 0 || self.size_bytes > MAX_SAFE_INTEGER {
182 return Err(invalid("artifact size is invalid"));
183 }
184 match self.kind {
185 ArtifactKind::Image => {
186 if !self.mime_type.starts_with("image/") {
187 return Err(invalid("image artifact MIME type is invalid"));
188 }
189 if self
190 .width
191 .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
192 || self
193 .height
194 .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
195 || self.duration_millis.is_some()
196 {
197 return Err(invalid("image artifact metadata is invalid"));
198 }
199 }
200 ArtifactKind::Audio => {
201 if !self.mime_type.starts_with("audio/") {
202 return Err(invalid("audio artifact MIME type is invalid"));
203 }
204 if self.width.is_some()
205 || self.height.is_some()
206 || self
207 .duration_millis
208 .is_some_and(|value| value == 0 || value > MAX_SAFE_INTEGER)
209 {
210 return Err(invalid("audio artifact metadata is invalid"));
211 }
212 }
213 ArtifactKind::Video => {
214 if !self.mime_type.starts_with("video/")
215 || self
216 .width
217 .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
218 || self
219 .height
220 .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
221 || self
222 .duration_millis
223 .is_some_and(|value| value == 0 || value > MAX_SAFE_INTEGER)
224 {
225 return Err(invalid("video artifact metadata is invalid"));
226 }
227 }
228 ArtifactKind::File => {
229 if self.mime_type.starts_with("video/") {
230 return Err(invalid("video artifacts are not supported"));
231 }
232 if self.width.is_some() || self.height.is_some() || self.duration_millis.is_some() {
233 return Err(invalid("file artifact metadata is invalid"));
234 }
235 }
236 }
237 Ok(())
238 }
239}
240
241#[derive(Serialize, Deserialize)]
242#[serde(deny_unknown_fields)]
243struct RawArtifactMetadata {
244 k: String,
245 m: String,
246 s: u64,
247 #[serde(skip_serializing_if = "Option::is_none")]
248 w: Option<u32>,
249 #[serde(skip_serializing_if = "Option::is_none")]
250 h: Option<u32>,
251 #[serde(skip_serializing_if = "Option::is_none")]
252 d: Option<u64>,
253}
254
255impl Serialize for ArtifactMetadata {
256 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
257 where
258 S: Serializer,
259 {
260 RawArtifactMetadata {
261 k: self.kind.code().to_string(),
262 m: self.mime_type.clone(),
263 s: self.size_bytes,
264 w: self.width,
265 h: self.height,
266 d: self.duration_millis,
267 }
268 .serialize(serializer)
269 }
270}
271
272impl<'de> Deserialize<'de> for ArtifactMetadata {
273 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
274 where
275 D: Deserializer<'de>,
276 {
277 let raw = RawArtifactMetadata::deserialize(deserializer)?;
278 let metadata = Self {
279 kind: ArtifactKind::from_code(&raw.k).map_err(serde::de::Error::custom)?,
280 mime_type: raw.m,
281 size_bytes: raw.s,
282 width: raw.w,
283 height: raw.h,
284 duration_millis: raw.d,
285 };
286 metadata.validate().map_err(serde::de::Error::custom)?;
287 Ok(metadata)
288 }
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct ArtifactReference {
294 tenant_id: String,
295 scope_id: String,
296 sha256: String,
297 metadata: ArtifactMetadata,
298}
299
300impl ArtifactReference {
301 pub fn new(
302 tenant_id: impl Into<String>,
303 scope_id: impl Into<String>,
304 sha256: impl Into<String>,
305 metadata: ArtifactMetadata,
306 ) -> Result<Self, ArtifactReferenceError> {
307 let reference = Self {
308 tenant_id: tenant_id.into(),
309 scope_id: scope_id.into(),
310 sha256: sha256.into(),
311 metadata,
312 };
313 reference.validate()?;
314 Ok(reference)
315 }
316
317 pub fn parse(value: &str) -> Result<Self, ArtifactReferenceError> {
318 if value.len() > MAX_URI_LENGTH {
319 return Err(invalid("artifact URI is too long"));
320 }
321 let path = value
322 .strip_prefix(ARTIFACT_URL_PREFIX)
323 .ok_or_else(|| invalid("unsupported artifact URI"))?;
324 let segments = path.split('/').collect::<Vec<_>>();
325 if segments.len() != 4 {
326 return Err(invalid(
327 "artifact URI must contain tenant, scope, digest, and metadata",
328 ));
329 }
330 let metadata_bytes = URL_SAFE_NO_PAD
331 .decode(segments[3])
332 .map_err(|_| invalid("artifact metadata is not valid base64url"))?;
333 let metadata: ArtifactMetadata = serde_json::from_slice(&metadata_bytes)
334 .map_err(|_| invalid("artifact metadata is invalid"))?;
335 let reference = Self::new(segments[0], segments[1], segments[2], metadata)?;
336 if reference.uri()? != value {
337 return Err(invalid("artifact URI is not canonical"));
338 }
339 Ok(reference)
340 }
341
342 pub fn uri(&self) -> Result<String, ArtifactReferenceError> {
343 self.validate()?;
344 let metadata = serde_json::to_vec(&self.metadata)
345 .map_err(|_| invalid("artifact metadata cannot be encoded"))?;
346 Ok(format!(
347 "{ARTIFACT_URL_PREFIX}{}/{}/{}/{}",
348 self.tenant_id,
349 self.scope_id,
350 self.sha256,
351 URL_SAFE_NO_PAD.encode(metadata)
352 ))
353 }
354
355 #[must_use]
356 pub fn tenant_id(&self) -> &str {
357 &self.tenant_id
358 }
359
360 #[must_use]
361 pub fn scope_id(&self) -> &str {
362 &self.scope_id
363 }
364
365 #[must_use]
366 pub fn sha256(&self) -> &str {
367 &self.sha256
368 }
369
370 #[must_use]
371 pub const fn metadata(&self) -> &ArtifactMetadata {
372 &self.metadata
373 }
374
375 pub fn ensure_scope(
376 &self,
377 tenant_id: &str,
378 scope_id: &str,
379 ) -> Result<(), ArtifactReferenceError> {
380 if self.tenant_id != tenant_id || self.scope_id != scope_id {
381 return Err(ArtifactReferenceError::new(
382 ArtifactReferenceErrorKind::ScopeMismatch,
383 "artifact does not belong to the current scope",
384 ));
385 }
386 Ok(())
387 }
388
389 fn validate(&self) -> Result<(), ArtifactReferenceError> {
390 validate_segment(&self.tenant_id, "tenant")?;
391 validate_segment(&self.scope_id, "scope")?;
392 if self.sha256.len() != 64
393 || !self
394 .sha256
395 .bytes()
396 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
397 {
398 return Err(invalid("artifact sha256 is invalid"));
399 }
400 self.metadata.validate()
401 }
402}
403
404fn validate_segment(value: &str, name: &str) -> Result<(), ArtifactReferenceError> {
405 if value.is_empty()
406 || value.len() > MAX_SEGMENT_LENGTH
407 || value == "."
408 || value == ".."
409 || !value
410 .bytes()
411 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
412 {
413 return Err(invalid(format!("artifact {name} is not URL-safe")));
414 }
415 Ok(())
416}
417
418fn validate_mime_type(value: &str) -> Result<(), ArtifactReferenceError> {
419 if value.is_empty()
420 || value.len() > MAX_MIME_LENGTH
421 || value != value.trim()
422 || value.bytes().any(|byte| byte.is_ascii_uppercase())
423 {
424 return Err(invalid("artifact MIME type is invalid"));
425 }
426 let Some((media_type, subtype)) = value.split_once('/') else {
427 return Err(invalid("artifact MIME type is invalid"));
428 };
429 if media_type.is_empty()
430 || subtype.is_empty()
431 || subtype.contains('/')
432 || !value.bytes().all(|byte| {
433 byte.is_ascii_lowercase()
434 || byte.is_ascii_digit()
435 || matches!(
436 byte,
437 b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-' | b'/'
438 )
439 })
440 {
441 return Err(invalid("artifact MIME type is invalid"));
442 }
443 Ok(())
444}
445
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448pub enum ArtifactReferenceErrorKind {
449 InvalidReference,
450 ScopeMismatch,
451}
452
453#[derive(Debug, Clone, PartialEq, Eq)]
455pub struct ArtifactReferenceError {
456 kind: ArtifactReferenceErrorKind,
457 message: String,
458}
459
460impl ArtifactReferenceError {
461 fn new(kind: ArtifactReferenceErrorKind, message: impl Into<String>) -> Self {
462 Self {
463 kind,
464 message: message.into(),
465 }
466 }
467
468 #[must_use]
469 pub const fn kind(&self) -> ArtifactReferenceErrorKind {
470 self.kind
471 }
472
473 #[must_use]
474 pub fn message(&self) -> &str {
475 &self.message
476 }
477
478 #[must_use]
479 pub const fn is_scope_mismatch(&self) -> bool {
480 matches!(self.kind, ArtifactReferenceErrorKind::ScopeMismatch)
481 }
482}
483
484impl fmt::Display for ArtifactReferenceError {
485 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
486 formatter.write_str(&self.message)
487 }
488}
489
490impl std::error::Error for ArtifactReferenceError {}
491
492fn invalid(message: impl Into<String>) -> ArtifactReferenceError {
493 ArtifactReferenceError::new(ArtifactReferenceErrorKind::InvalidReference, message)
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 #[test]
501 fn scope_owned_reference_round_trips() {
502 let reference = ArtifactReference::new(
503 "tenant",
504 "scope",
505 "a".repeat(64),
506 ArtifactMetadata::image("image/jpeg", 100, 10, 10).unwrap(),
507 )
508 .unwrap();
509 assert_eq!(
510 reference.uri().unwrap(),
511 format!(
512 "meow-artifact://v1/tenant/scope/{}/eyJrIjoiaSIsIm0iOiJpbWFnZS9qcGVnIiwicyI6MTAwLCJ3IjoxMCwiaCI6MTB9",
513 "a".repeat(64)
514 )
515 );
516 assert_eq!(
517 ArtifactReference::parse(&reference.uri().unwrap()).unwrap(),
518 reference
519 );
520 }
521
522 #[test]
523 fn rejects_cross_scope_and_invalid_metadata() {
524 let reference = ArtifactReference::new(
525 "tenant",
526 "scope",
527 "a".repeat(64),
528 ArtifactMetadata::audio("audio/mpeg", 100, Some(1_000)).unwrap(),
529 )
530 .unwrap();
531
532 assert_eq!(
533 reference
534 .ensure_scope("tenant", "other")
535 .unwrap_err()
536 .kind(),
537 ArtifactReferenceErrorKind::ScopeMismatch
538 );
539 assert!(ArtifactMetadata::audio("image/png", 100, None).is_err());
540 assert!(ArtifactMetadata::file("video/mp4", 100).is_err());
541 assert!(ArtifactMetadata::video("video/mp4", 100, 1920, 1080, Some(1_000)).is_ok());
542 assert!(ArtifactMetadata::file("Application/PDF", 100).is_err());
543 }
544}