1use std::fmt;
4
5use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8mod policy;
9pub use policy::{ArtifactIdentity, ArtifactPolicy, ArtifactRetention};
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://v3/";
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 policy: ArtifactPolicy,
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_policy(
317 tenant_id,
318 scope_id,
319 sha256,
320 metadata,
321 ArtifactPolicy::AUTO_STD,
322 None,
323 )
324 }
325
326 pub fn with_policy(
327 tenant_id: impl Into<String>,
328 scope_id: impl Into<String>,
329 sha256: impl Into<String>,
330 metadata: ArtifactMetadata,
331 policy: ArtifactPolicy,
332 creation_id: Option<String>,
333 ) -> Result<Self, ArtifactReferenceError> {
334 let reference = Self {
335 policy,
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() != 7 {
355 return Err(invalid(
356 "artifact URI must contain tenant, scope, retention, identity, resource ID, digest, and metadata",
357 ));
358 }
359 let metadata_bytes = URL_SAFE_NO_PAD
360 .decode(segments[6])
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 policy = ArtifactPolicy::new(
365 ArtifactIdentity::from_code(segments[3])?,
366 ArtifactRetention::from_code(segments[2])?,
367 );
368 let creation_id = if policy.identity == ArtifactIdentity::Man {
369 Some(segments[4].to_owned())
370 } else {
371 if segments[4] != segments[5] {
372 return Err(invalid("artifact resource ID must equal its digest"));
373 }
374 None
375 };
376 let reference = Self::with_policy(
377 segments[0],
378 segments[1],
379 segments[5],
380 metadata,
381 policy,
382 creation_id,
383 )?;
384 if reference.uri()? != value {
385 return Err(invalid("artifact URI is not canonical"));
386 }
387 Ok(reference)
388 }
389
390 pub fn uri(&self) -> Result<String, ArtifactReferenceError> {
391 self.validate()?;
392 let metadata = serde_json::to_vec(&self.metadata)
393 .map_err(|_| invalid("artifact metadata cannot be encoded"))?;
394 Ok(format!(
395 "{ARTIFACT_URL_PREFIX}{}/{}/{}/{}/{}/{}/{}",
396 self.tenant_id,
397 self.scope_id,
398 self.policy.retention.code(),
399 self.policy.identity.code(),
400 self.resource_id(),
401 self.sha256,
402 URL_SAFE_NO_PAD.encode(metadata)
403 ))
404 }
405
406 #[must_use]
407 pub fn tenant_id(&self) -> &str {
408 &self.tenant_id
409 }
410
411 #[must_use]
412 pub fn scope_id(&self) -> &str {
413 &self.scope_id
414 }
415
416 #[must_use]
417 pub fn sha256(&self) -> &str {
418 &self.sha256
419 }
420
421 #[must_use]
422 pub const fn metadata(&self) -> &ArtifactMetadata {
423 &self.metadata
424 }
425
426 pub const fn policy(&self) -> ArtifactPolicy {
427 self.policy
428 }
429
430 pub fn resource_id(&self) -> &str {
431 self.creation_id.as_deref().unwrap_or(&self.sha256)
432 }
433
434 pub fn creation_id(&self) -> Option<&str> {
435 self.creation_id.as_deref()
436 }
437
438 pub fn ensure_scope(
439 &self,
440 tenant_id: &str,
441 scope_id: &str,
442 ) -> Result<(), ArtifactReferenceError> {
443 if self.tenant_id != tenant_id || self.scope_id != scope_id {
444 return Err(ArtifactReferenceError::new(
445 ArtifactReferenceErrorKind::ScopeMismatch,
446 "artifact does not belong to the current scope",
447 ));
448 }
449 Ok(())
450 }
451
452 fn validate(&self) -> Result<(), ArtifactReferenceError> {
453 self.policy
454 .validate_creation_id(self.creation_id.as_deref())?;
455 validate_segment(&self.tenant_id, "tenant")?;
456 validate_segment(&self.scope_id, "scope")?;
457 if self.sha256.len() != 64
458 || !self
459 .sha256
460 .bytes()
461 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
462 {
463 return Err(invalid("artifact sha256 is invalid"));
464 }
465 self.metadata.validate()
466 }
467}
468
469fn deserialize_optional_non_null<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
472where
473 D: Deserializer<'de>,
474 T: Deserialize<'de>,
475{
476 T::deserialize(deserializer).map(Some)
477}
478
479fn validate_size_bytes(size_bytes: u64) -> Result<(), &'static str> {
480 if size_bytes == 0 || size_bytes > MAX_SAFE_INTEGER {
481 return Err("artifact size is invalid");
482 }
483 Ok(())
484}
485
486fn validate_duration_millis(
487 kind: ArtifactKind,
488 duration_millis: Option<u64>,
489) -> Result<(), &'static str> {
490 if let Some(duration) = duration_millis {
491 if !matches!(kind, ArtifactKind::Audio | ArtifactKind::Video) {
492 return Err("artifact duration is only valid for audio or video");
493 }
494 if duration == 0 || duration > MAX_SAFE_INTEGER {
495 return Err("artifact duration is invalid");
496 }
497 }
498 Ok(())
499}
500
501fn validate_segment(value: &str, name: &str) -> Result<(), ArtifactReferenceError> {
502 if value.is_empty()
503 || value.len() > MAX_SEGMENT_LENGTH
504 || value == "."
505 || value == ".."
506 || !value
507 .bytes()
508 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
509 {
510 return Err(invalid(format!("artifact {name} is not URL-safe")));
511 }
512 Ok(())
513}
514
515fn validate_mime_type(value: &str) -> Result<(), ArtifactReferenceError> {
516 if value.is_empty()
517 || value.len() > MAX_MIME_LENGTH
518 || value != value.trim()
519 || value.bytes().any(|byte| byte.is_ascii_uppercase())
520 {
521 return Err(invalid("artifact MIME type is invalid"));
522 }
523 let Some((media_type, subtype)) = value.split_once('/') else {
524 return Err(invalid("artifact MIME type is invalid"));
525 };
526 if media_type.is_empty()
527 || subtype.is_empty()
528 || subtype.contains('/')
529 || !value.bytes().all(|byte| {
530 byte.is_ascii_lowercase()
531 || byte.is_ascii_digit()
532 || matches!(
533 byte,
534 b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-' | b'/'
535 )
536 })
537 {
538 return Err(invalid("artifact MIME type is invalid"));
539 }
540 Ok(())
541}
542
543#[derive(Debug, Clone, Copy, PartialEq, Eq)]
545pub enum ArtifactReferenceErrorKind {
546 InvalidReference,
547 ScopeMismatch,
548}
549
550#[derive(Debug, Clone, PartialEq, Eq)]
552pub struct ArtifactReferenceError {
553 kind: ArtifactReferenceErrorKind,
554 message: String,
555}
556
557impl ArtifactReferenceError {
558 fn new(kind: ArtifactReferenceErrorKind, message: impl Into<String>) -> Self {
559 Self {
560 kind,
561 message: message.into(),
562 }
563 }
564
565 #[must_use]
566 pub const fn kind(&self) -> ArtifactReferenceErrorKind {
567 self.kind
568 }
569
570 #[must_use]
571 pub fn message(&self) -> &str {
572 &self.message
573 }
574
575 #[must_use]
576 pub const fn is_scope_mismatch(&self) -> bool {
577 matches!(self.kind, ArtifactReferenceErrorKind::ScopeMismatch)
578 }
579}
580
581impl fmt::Display for ArtifactReferenceError {
582 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
583 formatter.write_str(&self.message)
584 }
585}
586
587impl std::error::Error for ArtifactReferenceError {}
588
589fn invalid(message: impl Into<String>) -> ArtifactReferenceError {
590 ArtifactReferenceError::new(ArtifactReferenceErrorKind::InvalidReference, message)
591}
592
593pub const MAX_EXTERNAL_ARTIFACT_BYTES: usize = 1024 * 1024;
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 #[test]
601 fn scope_owned_reference_round_trips() {
602 let reference = ArtifactReference::new(
603 "tenant",
604 "scope",
605 "a".repeat(64),
606 ArtifactMetadata::image("image/jpeg", 100, 10, 10).unwrap(),
607 )
608 .unwrap();
609 assert_eq!(
610 reference.uri().unwrap(),
611 format!(
612 "meow-artifact://v3/tenant/scope/std/auto/{0}/{0}/eyJrIjoiaSIsIm0iOiJpbWFnZS9qcGVnIiwicyI6MTAwLCJ3IjoxMCwiaCI6MTB9",
613 "a".repeat(64)
614 )
615 );
616 assert_eq!(
617 ArtifactReference::parse(&reference.uri().unwrap()).unwrap(),
618 reference
619 );
620 }
621
622 #[test]
623 fn rejects_cross_scope_and_invalid_metadata() {
624 let reference = ArtifactReference::new(
625 "tenant",
626 "scope",
627 "a".repeat(64),
628 ArtifactMetadata::audio("audio/mpeg", 100, Some(1_000)).unwrap(),
629 )
630 .unwrap();
631
632 assert_eq!(
633 reference
634 .ensure_scope("tenant", "other")
635 .unwrap_err()
636 .kind(),
637 ArtifactReferenceErrorKind::ScopeMismatch
638 );
639 assert!(ArtifactMetadata::audio("image/png", 100, None).is_err());
640 assert!(ArtifactMetadata::file("video/mp4", 100).is_err());
641 assert!(ArtifactMetadata::video("video/mp4", 100, 1920, 1080, Some(1_000)).is_ok());
642 assert!(ArtifactMetadata::file("Application/PDF", 100).is_err());
643 }
644}
645
646#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
649#[serde(rename_all = "camelCase", deny_unknown_fields)]
650pub struct HostTlsIdentity {
651 pub host_id: String,
652 pub public_key: HostTlsPublicKey,
653}
654#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
655#[serde(deny_unknown_fields)]
656pub struct HostTlsPublicKey {
657 pub x: String,
658 pub y: String,
659}
660
661pub type DeleteArtifactRequest = ResolveArtifactRequest;