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