1use std::fmt;
4
5use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8mod lifecycle;
9pub use lifecycle::ArtifactLifecycle;
10mod media;
11mod put;
12mod resolve;
13mod upload;
14
15pub use media::{ArtifactUri, ImportArtifactRequest, MediaReference};
16
17pub use put::{PutArtifactRequest, PutArtifactResponse, PutArtifactValidationError};
18
19pub use resolve::{
20 ArtifactDelivery, ResolveArtifactRequest, ResolveArtifactResponse, ResolveArtifactResponseError,
21};
22pub use upload::{
23 PrepareArtifactUploadRequest, PrepareArtifactUploadResponse,
24 PrepareArtifactUploadValidationError,
25};
26
27pub const ARTIFACT_URL_PREFIX: &str = "meow-artifact://v2/";
29const MAX_URI_LENGTH: usize = 2_048;
30const MAX_SEGMENT_LENGTH: usize = 256;
31const MAX_MIME_LENGTH: usize = 255;
32const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
33const MAX_DIMENSION: u32 = i32::MAX as u32;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
38pub enum ArtifactKind {
39 Image,
40 Audio,
41 Video,
42 File,
43}
44
45impl ArtifactKind {
46 #[must_use]
47 pub const fn code(self) -> &'static str {
48 match self {
49 Self::Image => "i",
50 Self::Audio => "a",
51 Self::Video => "v",
52 Self::File => "f",
53 }
54 }
55
56 pub fn from_code(value: &str) -> Result<Self, ArtifactReferenceError> {
57 match value {
58 "i" => Ok(Self::Image),
59 "a" => Ok(Self::Audio),
60 "v" => Ok(Self::Video),
61 "f" => Ok(Self::File),
62 _ => Err(invalid("unsupported artifact kind")),
63 }
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ArtifactMetadata {
70 kind: ArtifactKind,
71 mime_type: String,
72 size_bytes: u64,
73 width: Option<u32>,
74 height: Option<u32>,
75 duration_millis: Option<u64>,
76}
77
78impl ArtifactMetadata {
79 pub fn image(
80 mime_type: impl Into<String>,
81 size_bytes: usize,
82 width: u32,
83 height: u32,
84 ) -> Result<Self, ArtifactReferenceError> {
85 Self::build(
86 ArtifactKind::Image,
87 mime_type,
88 size_bytes,
89 Some(width),
90 Some(height),
91 None,
92 )
93 }
94
95 pub fn audio(
96 mime_type: impl Into<String>,
97 size_bytes: usize,
98 duration_millis: Option<u64>,
99 ) -> Result<Self, ArtifactReferenceError> {
100 Self::build(
101 ArtifactKind::Audio,
102 mime_type,
103 size_bytes,
104 None,
105 None,
106 duration_millis,
107 )
108 }
109
110 pub fn file(
111 mime_type: impl Into<String>,
112 size_bytes: usize,
113 ) -> Result<Self, ArtifactReferenceError> {
114 Self::build(ArtifactKind::File, mime_type, size_bytes, None, None, None)
115 }
116
117 pub fn video(
118 mime_type: impl Into<String>,
119 size_bytes: usize,
120 width: u32,
121 height: u32,
122 duration_millis: Option<u64>,
123 ) -> Result<Self, ArtifactReferenceError> {
124 Self::build(
125 ArtifactKind::Video,
126 mime_type,
127 size_bytes,
128 Some(width),
129 Some(height),
130 duration_millis,
131 )
132 }
133
134 fn build(
135 kind: ArtifactKind,
136 mime_type: impl Into<String>,
137 size_bytes: usize,
138 width: Option<u32>,
139 height: Option<u32>,
140 duration_millis: Option<u64>,
141 ) -> Result<Self, ArtifactReferenceError> {
142 let metadata = Self {
143 kind,
144 mime_type: mime_type.into(),
145 size_bytes: size_bytes as u64,
146 width,
147 height,
148 duration_millis,
149 };
150 metadata.validate()?;
151 Ok(metadata)
152 }
153
154 #[must_use]
155 pub const fn kind(&self) -> ArtifactKind {
156 self.kind
157 }
158
159 #[must_use]
160 pub fn mime_type(&self) -> &str {
161 &self.mime_type
162 }
163
164 #[must_use]
165 pub const fn size_bytes(&self) -> u64 {
166 self.size_bytes
167 }
168
169 #[must_use]
170 pub const fn width(&self) -> Option<u32> {
171 self.width
172 }
173
174 #[must_use]
175 pub const fn height(&self) -> Option<u32> {
176 self.height
177 }
178
179 #[must_use]
180 pub const fn duration_millis(&self) -> Option<u64> {
181 self.duration_millis
182 }
183
184 fn validate(&self) -> Result<(), ArtifactReferenceError> {
185 validate_mime_type(&self.mime_type)?;
186 validate_size_bytes(self.size_bytes).map_err(invalid)?;
187 validate_duration_millis(self.kind, self.duration_millis).map_err(invalid)?;
188 match self.kind {
189 ArtifactKind::Image => {
190 if !self.mime_type.starts_with("image/") {
191 return Err(invalid("image artifact MIME type is invalid"));
192 }
193 if self
194 .width
195 .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
196 || self
197 .height
198 .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
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() || self.height.is_some() {
208 return Err(invalid("audio artifact metadata is invalid"));
209 }
210 }
211 ArtifactKind::Video => {
212 if !self.mime_type.starts_with("video/")
213 || self
214 .width
215 .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
216 || self
217 .height
218 .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
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() {
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(
243 default,
244 skip_serializing_if = "Option::is_none",
245 deserialize_with = "deserialize_optional_non_null"
246 )]
247 w: Option<u32>,
248 #[serde(
249 default,
250 skip_serializing_if = "Option::is_none",
251 deserialize_with = "deserialize_optional_non_null"
252 )]
253 h: Option<u32>,
254 #[serde(
255 default,
256 skip_serializing_if = "Option::is_none",
257 deserialize_with = "deserialize_optional_non_null"
258 )]
259 d: Option<u64>,
260}
261
262impl Serialize for ArtifactMetadata {
263 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
264 where
265 S: Serializer,
266 {
267 RawArtifactMetadata {
268 k: self.kind.code().to_string(),
269 m: self.mime_type.clone(),
270 s: self.size_bytes,
271 w: self.width,
272 h: self.height,
273 d: self.duration_millis,
274 }
275 .serialize(serializer)
276 }
277}
278
279impl<'de> Deserialize<'de> for ArtifactMetadata {
280 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
281 where
282 D: Deserializer<'de>,
283 {
284 let raw = RawArtifactMetadata::deserialize(deserializer)?;
285 let metadata = Self {
286 kind: ArtifactKind::from_code(&raw.k).map_err(serde::de::Error::custom)?,
287 mime_type: raw.m,
288 size_bytes: raw.s,
289 width: raw.w,
290 height: raw.h,
291 duration_millis: raw.d,
292 };
293 metadata.validate().map_err(serde::de::Error::custom)?;
294 Ok(metadata)
295 }
296}
297
298#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct ArtifactReference {
301 tenant_id: String,
302 scope_id: String,
303 sha256: String,
304 lifecycle: ArtifactLifecycle,
305 creation_id: Option<String>,
306 metadata: ArtifactMetadata,
307}
308
309impl ArtifactReference {
310 pub fn new(
311 tenant_id: impl Into<String>,
312 scope_id: impl Into<String>,
313 sha256: impl Into<String>,
314 metadata: ArtifactMetadata,
315 ) -> Result<Self, ArtifactReferenceError> {
316 Self::with_lifecycle(
317 tenant_id,
318 scope_id,
319 sha256,
320 metadata,
321 ArtifactLifecycle::Std,
322 None,
323 )
324 }
325
326 pub fn with_lifecycle(
327 tenant_id: impl Into<String>,
328 scope_id: impl Into<String>,
329 sha256: impl Into<String>,
330 metadata: ArtifactMetadata,
331 lifecycle: ArtifactLifecycle,
332 creation_id: Option<String>,
333 ) -> Result<Self, ArtifactReferenceError> {
334 let reference = Self {
335 lifecycle,
336 creation_id,
337 tenant_id: tenant_id.into(),
338 scope_id: scope_id.into(),
339 sha256: sha256.into(),
340 metadata,
341 };
342 reference.validate()?;
343 Ok(reference)
344 }
345
346 pub fn parse(value: &str) -> Result<Self, ArtifactReferenceError> {
347 if value.len() > MAX_URI_LENGTH {
348 return Err(invalid("artifact URI is too long"));
349 }
350 let path = value
351 .strip_prefix(ARTIFACT_URL_PREFIX)
352 .ok_or_else(|| invalid("unsupported artifact URI"))?;
353 let segments = path.split('/').collect::<Vec<_>>();
354 if segments.len() != 6 {
355 return Err(invalid(
356 "artifact URI must contain tenant, scope, lifecycle, resource ID, digest, and metadata",
357 ));
358 }
359 let metadata_bytes = URL_SAFE_NO_PAD
360 .decode(segments[5])
361 .map_err(|_| invalid("artifact metadata is not valid base64url"))?;
362 let metadata: ArtifactMetadata = serde_json::from_slice(&metadata_bytes)
363 .map_err(|_| invalid("artifact metadata is invalid"))?;
364 let lifecycle = ArtifactLifecycle::from_code(segments[2])?;
365 let creation_id = if lifecycle == ArtifactLifecycle::Man {
366 Some(segments[3].to_owned())
367 } else {
368 if segments[3] != segments[4] {
369 return Err(invalid("artifact resource ID must equal its digest"));
370 }
371 None
372 };
373 let reference = Self::with_lifecycle(
374 segments[0],
375 segments[1],
376 segments[4],
377 metadata,
378 lifecycle,
379 creation_id,
380 )?;
381 if reference.uri()? != value {
382 return Err(invalid("artifact URI is not canonical"));
383 }
384 Ok(reference)
385 }
386
387 pub fn uri(&self) -> Result<String, ArtifactReferenceError> {
388 self.validate()?;
389 let metadata = serde_json::to_vec(&self.metadata)
390 .map_err(|_| invalid("artifact metadata cannot be encoded"))?;
391 Ok(format!(
392 "{ARTIFACT_URL_PREFIX}{}/{}/{}/{}/{}/{}",
393 self.tenant_id,
394 self.scope_id,
395 self.lifecycle.code(),
396 self.resource_id(),
397 self.sha256,
398 URL_SAFE_NO_PAD.encode(metadata)
399 ))
400 }
401
402 #[must_use]
403 pub fn tenant_id(&self) -> &str {
404 &self.tenant_id
405 }
406
407 #[must_use]
408 pub fn scope_id(&self) -> &str {
409 &self.scope_id
410 }
411
412 #[must_use]
413 pub fn sha256(&self) -> &str {
414 &self.sha256
415 }
416
417 #[must_use]
418 pub const fn metadata(&self) -> &ArtifactMetadata {
419 &self.metadata
420 }
421
422 pub const fn lifecycle(&self) -> ArtifactLifecycle {
423 self.lifecycle
424 }
425
426 pub fn resource_id(&self) -> &str {
427 self.creation_id.as_deref().unwrap_or(&self.sha256)
428 }
429
430 pub fn creation_id(&self) -> Option<&str> {
431 self.creation_id.as_deref()
432 }
433
434 pub fn ensure_scope(
435 &self,
436 tenant_id: &str,
437 scope_id: &str,
438 ) -> Result<(), ArtifactReferenceError> {
439 if self.tenant_id != tenant_id || self.scope_id != scope_id {
440 return Err(ArtifactReferenceError::new(
441 ArtifactReferenceErrorKind::ScopeMismatch,
442 "artifact does not belong to the current scope",
443 ));
444 }
445 Ok(())
446 }
447
448 fn validate(&self) -> Result<(), ArtifactReferenceError> {
449 self.lifecycle
450 .validate_creation_id(self.creation_id.as_deref())?;
451 validate_segment(&self.tenant_id, "tenant")?;
452 validate_segment(&self.scope_id, "scope")?;
453 if self.sha256.len() != 64
454 || !self
455 .sha256
456 .bytes()
457 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
458 {
459 return Err(invalid("artifact sha256 is invalid"));
460 }
461 self.metadata.validate()
462 }
463}
464
465fn deserialize_optional_non_null<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
468where
469 D: Deserializer<'de>,
470 T: Deserialize<'de>,
471{
472 T::deserialize(deserializer).map(Some)
473}
474
475fn validate_size_bytes(size_bytes: u64) -> Result<(), &'static str> {
476 if size_bytes == 0 || size_bytes > MAX_SAFE_INTEGER {
477 return Err("artifact size is invalid");
478 }
479 Ok(())
480}
481
482fn validate_duration_millis(
483 kind: ArtifactKind,
484 duration_millis: Option<u64>,
485) -> Result<(), &'static str> {
486 if let Some(duration) = duration_millis {
487 if !matches!(kind, ArtifactKind::Audio | ArtifactKind::Video) {
488 return Err("artifact duration is only valid for audio or video");
489 }
490 if duration == 0 || duration > MAX_SAFE_INTEGER {
491 return Err("artifact duration is invalid");
492 }
493 }
494 Ok(())
495}
496
497fn validate_segment(value: &str, name: &str) -> Result<(), ArtifactReferenceError> {
498 if value.is_empty()
499 || value.len() > MAX_SEGMENT_LENGTH
500 || value == "."
501 || value == ".."
502 || !value
503 .bytes()
504 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
505 {
506 return Err(invalid(format!("artifact {name} is not URL-safe")));
507 }
508 Ok(())
509}
510
511fn validate_mime_type(value: &str) -> Result<(), ArtifactReferenceError> {
512 if value.is_empty()
513 || value.len() > MAX_MIME_LENGTH
514 || value != value.trim()
515 || value.bytes().any(|byte| byte.is_ascii_uppercase())
516 {
517 return Err(invalid("artifact MIME type is invalid"));
518 }
519 let Some((media_type, subtype)) = value.split_once('/') else {
520 return Err(invalid("artifact MIME type is invalid"));
521 };
522 if media_type.is_empty()
523 || subtype.is_empty()
524 || subtype.contains('/')
525 || !value.bytes().all(|byte| {
526 byte.is_ascii_lowercase()
527 || byte.is_ascii_digit()
528 || matches!(
529 byte,
530 b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-' | b'/'
531 )
532 })
533 {
534 return Err(invalid("artifact MIME type is invalid"));
535 }
536 Ok(())
537}
538
539#[derive(Debug, Clone, Copy, PartialEq, Eq)]
541pub enum ArtifactReferenceErrorKind {
542 InvalidReference,
543 ScopeMismatch,
544}
545
546#[derive(Debug, Clone, PartialEq, Eq)]
548pub struct ArtifactReferenceError {
549 kind: ArtifactReferenceErrorKind,
550 message: String,
551}
552
553impl ArtifactReferenceError {
554 fn new(kind: ArtifactReferenceErrorKind, message: impl Into<String>) -> Self {
555 Self {
556 kind,
557 message: message.into(),
558 }
559 }
560
561 #[must_use]
562 pub const fn kind(&self) -> ArtifactReferenceErrorKind {
563 self.kind
564 }
565
566 #[must_use]
567 pub fn message(&self) -> &str {
568 &self.message
569 }
570
571 #[must_use]
572 pub const fn is_scope_mismatch(&self) -> bool {
573 matches!(self.kind, ArtifactReferenceErrorKind::ScopeMismatch)
574 }
575}
576
577impl fmt::Display for ArtifactReferenceError {
578 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
579 formatter.write_str(&self.message)
580 }
581}
582
583impl std::error::Error for ArtifactReferenceError {}
584
585fn invalid(message: impl Into<String>) -> ArtifactReferenceError {
586 ArtifactReferenceError::new(ArtifactReferenceErrorKind::InvalidReference, message)
587}
588
589pub const MAX_EXTERNAL_ARTIFACT_BYTES: usize = 1024 * 1024;
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595
596 #[test]
597 fn scope_owned_reference_round_trips() {
598 let reference = ArtifactReference::new(
599 "tenant",
600 "scope",
601 "a".repeat(64),
602 ArtifactMetadata::image("image/jpeg", 100, 10, 10).unwrap(),
603 )
604 .unwrap();
605 assert_eq!(
606 reference.uri().unwrap(),
607 format!(
608 "meow-artifact://v2/tenant/scope/std/{0}/{0}/eyJrIjoiaSIsIm0iOiJpbWFnZS9qcGVnIiwicyI6MTAwLCJ3IjoxMCwiaCI6MTB9",
609 "a".repeat(64)
610 )
611 );
612 assert_eq!(
613 ArtifactReference::parse(&reference.uri().unwrap()).unwrap(),
614 reference
615 );
616 }
617
618 #[test]
619 fn rejects_cross_scope_and_invalid_metadata() {
620 let reference = ArtifactReference::new(
621 "tenant",
622 "scope",
623 "a".repeat(64),
624 ArtifactMetadata::audio("audio/mpeg", 100, Some(1_000)).unwrap(),
625 )
626 .unwrap();
627
628 assert_eq!(
629 reference
630 .ensure_scope("tenant", "other")
631 .unwrap_err()
632 .kind(),
633 ArtifactReferenceErrorKind::ScopeMismatch
634 );
635 assert!(ArtifactMetadata::audio("image/png", 100, None).is_err());
636 assert!(ArtifactMetadata::file("video/mp4", 100).is_err());
637 assert!(ArtifactMetadata::video("video/mp4", 100, 1920, 1080, Some(1_000)).is_ok());
638 assert!(ArtifactMetadata::file("Application/PDF", 100).is_err());
639 }
640}
641
642#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
645#[serde(rename_all = "camelCase", deny_unknown_fields)]
646pub struct HostTlsIdentity {
647 pub host_id: String,
648 pub public_key: HostTlsPublicKey,
649}
650#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
651#[serde(deny_unknown_fields)]
652pub struct HostTlsPublicKey {
653 pub x: String,
654 pub y: String,
655}
656
657pub type DeleteArtifactRequest = ResolveArtifactRequest;