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