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