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