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