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 if self.size_bytes == 0 || self.size_bytes > MAX_SAFE_INTEGER {
187 return Err(invalid("artifact size is invalid"));
188 }
189 match self.kind {
190 ArtifactKind::Image => {
191 if !self.mime_type.starts_with("image/") {
192 return Err(invalid("image artifact MIME type is invalid"));
193 }
194 if self
195 .width
196 .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
197 || self
198 .height
199 .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
200 || self.duration_millis.is_some()
201 {
202 return Err(invalid("image artifact metadata is invalid"));
203 }
204 }
205 ArtifactKind::Audio => {
206 if !self.mime_type.starts_with("audio/") {
207 return Err(invalid("audio artifact MIME type is invalid"));
208 }
209 if self.width.is_some()
210 || self.height.is_some()
211 || self
212 .duration_millis
213 .is_some_and(|value| value == 0 || value > MAX_SAFE_INTEGER)
214 {
215 return Err(invalid("audio artifact metadata is invalid"));
216 }
217 }
218 ArtifactKind::Video => {
219 if !self.mime_type.starts_with("video/")
220 || self
221 .width
222 .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
223 || self
224 .height
225 .is_none_or(|value| value == 0 || value > MAX_DIMENSION)
226 || self
227 .duration_millis
228 .is_some_and(|value| value == 0 || value > MAX_SAFE_INTEGER)
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() || self.duration_millis.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(skip_serializing_if = "Option::is_none")]
253 w: Option<u32>,
254 #[serde(skip_serializing_if = "Option::is_none")]
255 h: Option<u32>,
256 #[serde(skip_serializing_if = "Option::is_none")]
257 d: Option<u64>,
258}
259
260impl Serialize for ArtifactMetadata {
261 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
262 where
263 S: Serializer,
264 {
265 RawArtifactMetadata {
266 k: self.kind.code().to_string(),
267 m: self.mime_type.clone(),
268 s: self.size_bytes,
269 w: self.width,
270 h: self.height,
271 d: self.duration_millis,
272 }
273 .serialize(serializer)
274 }
275}
276
277impl<'de> Deserialize<'de> for ArtifactMetadata {
278 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
279 where
280 D: Deserializer<'de>,
281 {
282 let raw = RawArtifactMetadata::deserialize(deserializer)?;
283 let metadata = Self {
284 kind: ArtifactKind::from_code(&raw.k).map_err(serde::de::Error::custom)?,
285 mime_type: raw.m,
286 size_bytes: raw.s,
287 width: raw.w,
288 height: raw.h,
289 duration_millis: raw.d,
290 };
291 metadata.validate().map_err(serde::de::Error::custom)?;
292 Ok(metadata)
293 }
294}
295
296#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct ArtifactReference {
299 tenant_id: String,
300 scope_id: String,
301 sha256: String,
302 lifecycle: ArtifactLifecycle,
303 creation_id: Option<String>,
304 metadata: ArtifactMetadata,
305}
306
307impl ArtifactReference {
308 pub fn new(
309 tenant_id: impl Into<String>,
310 scope_id: impl Into<String>,
311 sha256: impl Into<String>,
312 metadata: ArtifactMetadata,
313 ) -> Result<Self, ArtifactReferenceError> {
314 Self::with_lifecycle(
315 tenant_id,
316 scope_id,
317 sha256,
318 metadata,
319 ArtifactLifecycle::Std,
320 None,
321 )
322 }
323
324 pub fn with_lifecycle(
325 tenant_id: impl Into<String>,
326 scope_id: impl Into<String>,
327 sha256: impl Into<String>,
328 metadata: ArtifactMetadata,
329 lifecycle: ArtifactLifecycle,
330 creation_id: Option<String>,
331 ) -> Result<Self, ArtifactReferenceError> {
332 let reference = Self {
333 lifecycle,
334 creation_id,
335 tenant_id: tenant_id.into(),
336 scope_id: scope_id.into(),
337 sha256: sha256.into(),
338 metadata,
339 };
340 reference.validate()?;
341 Ok(reference)
342 }
343
344 pub fn parse(value: &str) -> Result<Self, ArtifactReferenceError> {
345 if value.len() > MAX_URI_LENGTH {
346 return Err(invalid("artifact URI is too long"));
347 }
348 let path = value
349 .strip_prefix(ARTIFACT_URL_PREFIX)
350 .ok_or_else(|| invalid("unsupported artifact URI"))?;
351 let segments = path.split('/').collect::<Vec<_>>();
352 if segments.len() != 6 {
353 return Err(invalid(
354 "artifact URI must contain tenant, scope, lifecycle, resource ID, digest, and metadata",
355 ));
356 }
357 let metadata_bytes = URL_SAFE_NO_PAD
358 .decode(segments[5])
359 .map_err(|_| invalid("artifact metadata is not valid base64url"))?;
360 let metadata: ArtifactMetadata = serde_json::from_slice(&metadata_bytes)
361 .map_err(|_| invalid("artifact metadata is invalid"))?;
362 let lifecycle = ArtifactLifecycle::from_code(segments[2])?;
363 let creation_id = if lifecycle == ArtifactLifecycle::Man {
364 Some(segments[3].to_owned())
365 } else {
366 if segments[3] != segments[4] {
367 return Err(invalid("artifact resource ID must equal its digest"));
368 }
369 None
370 };
371 let reference = Self::with_lifecycle(
372 segments[0],
373 segments[1],
374 segments[4],
375 metadata,
376 lifecycle,
377 creation_id,
378 )?;
379 if reference.uri()? != value {
380 return Err(invalid("artifact URI is not canonical"));
381 }
382 Ok(reference)
383 }
384
385 pub fn uri(&self) -> Result<String, ArtifactReferenceError> {
386 self.validate()?;
387 let metadata = serde_json::to_vec(&self.metadata)
388 .map_err(|_| invalid("artifact metadata cannot be encoded"))?;
389 Ok(format!(
390 "{ARTIFACT_URL_PREFIX}{}/{}/{}/{}/{}/{}",
391 self.tenant_id,
392 self.scope_id,
393 self.lifecycle.code(),
394 self.resource_id(),
395 self.sha256,
396 URL_SAFE_NO_PAD.encode(metadata)
397 ))
398 }
399
400 #[must_use]
401 pub fn tenant_id(&self) -> &str {
402 &self.tenant_id
403 }
404
405 #[must_use]
406 pub fn scope_id(&self) -> &str {
407 &self.scope_id
408 }
409
410 #[must_use]
411 pub fn sha256(&self) -> &str {
412 &self.sha256
413 }
414
415 #[must_use]
416 pub const fn metadata(&self) -> &ArtifactMetadata {
417 &self.metadata
418 }
419
420 pub const fn lifecycle(&self) -> ArtifactLifecycle {
421 self.lifecycle
422 }
423
424 pub fn resource_id(&self) -> &str {
425 self.creation_id.as_deref().unwrap_or(&self.sha256)
426 }
427
428 pub fn creation_id(&self) -> Option<&str> {
429 self.creation_id.as_deref()
430 }
431
432 pub fn ensure_scope(
433 &self,
434 tenant_id: &str,
435 scope_id: &str,
436 ) -> Result<(), ArtifactReferenceError> {
437 if self.tenant_id != tenant_id || self.scope_id != scope_id {
438 return Err(ArtifactReferenceError::new(
439 ArtifactReferenceErrorKind::ScopeMismatch,
440 "artifact does not belong to the current scope",
441 ));
442 }
443 Ok(())
444 }
445
446 fn validate(&self) -> Result<(), ArtifactReferenceError> {
447 self.lifecycle
448 .validate_creation_id(self.creation_id.as_deref())?;
449 if self.lifecycle == ArtifactLifecycle::Man && self.creation_id.is_none() {
450 return Err(invalid("manual artifact requires a creation ID"));
451 }
452 validate_segment(&self.tenant_id, "tenant")?;
453 validate_segment(&self.scope_id, "scope")?;
454 if self.sha256.len() != 64
455 || !self
456 .sha256
457 .bytes()
458 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
459 {
460 return Err(invalid("artifact sha256 is invalid"));
461 }
462 self.metadata.validate()
463 }
464}
465
466fn validate_segment(value: &str, name: &str) -> Result<(), ArtifactReferenceError> {
467 if value.is_empty()
468 || value.len() > MAX_SEGMENT_LENGTH
469 || value == "."
470 || value == ".."
471 || !value
472 .bytes()
473 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
474 {
475 return Err(invalid(format!("artifact {name} is not URL-safe")));
476 }
477 Ok(())
478}
479
480fn validate_mime_type(value: &str) -> Result<(), ArtifactReferenceError> {
481 if value.is_empty()
482 || value.len() > MAX_MIME_LENGTH
483 || value != value.trim()
484 || value.bytes().any(|byte| byte.is_ascii_uppercase())
485 {
486 return Err(invalid("artifact MIME type is invalid"));
487 }
488 let Some((media_type, subtype)) = value.split_once('/') else {
489 return Err(invalid("artifact MIME type is invalid"));
490 };
491 if media_type.is_empty()
492 || subtype.is_empty()
493 || subtype.contains('/')
494 || !value.bytes().all(|byte| {
495 byte.is_ascii_lowercase()
496 || byte.is_ascii_digit()
497 || matches!(
498 byte,
499 b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-' | b'/'
500 )
501 })
502 {
503 return Err(invalid("artifact MIME type is invalid"));
504 }
505 Ok(())
506}
507
508#[derive(Debug, Clone, Copy, PartialEq, Eq)]
510pub enum ArtifactReferenceErrorKind {
511 InvalidReference,
512 ScopeMismatch,
513}
514
515#[derive(Debug, Clone, PartialEq, Eq)]
517pub struct ArtifactReferenceError {
518 kind: ArtifactReferenceErrorKind,
519 message: String,
520}
521
522impl ArtifactReferenceError {
523 fn new(kind: ArtifactReferenceErrorKind, message: impl Into<String>) -> Self {
524 Self {
525 kind,
526 message: message.into(),
527 }
528 }
529
530 #[must_use]
531 pub const fn kind(&self) -> ArtifactReferenceErrorKind {
532 self.kind
533 }
534
535 #[must_use]
536 pub fn message(&self) -> &str {
537 &self.message
538 }
539
540 #[must_use]
541 pub const fn is_scope_mismatch(&self) -> bool {
542 matches!(self.kind, ArtifactReferenceErrorKind::ScopeMismatch)
543 }
544}
545
546impl fmt::Display for ArtifactReferenceError {
547 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
548 formatter.write_str(&self.message)
549 }
550}
551
552impl std::error::Error for ArtifactReferenceError {}
553
554fn invalid(message: impl Into<String>) -> ArtifactReferenceError {
555 ArtifactReferenceError::new(ArtifactReferenceErrorKind::InvalidReference, message)
556}
557
558pub const MAX_EXTERNAL_ARTIFACT_BYTES: usize = 1024 * 1024;
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564
565 #[test]
566 fn scope_owned_reference_round_trips() {
567 let reference = ArtifactReference::new(
568 "tenant",
569 "scope",
570 "a".repeat(64),
571 ArtifactMetadata::image("image/jpeg", 100, 10, 10).unwrap(),
572 )
573 .unwrap();
574 assert_eq!(
575 reference.uri().unwrap(),
576 format!(
577 "meow-artifact://v2/tenant/scope/std/{0}/{0}/eyJrIjoiaSIsIm0iOiJpbWFnZS9qcGVnIiwicyI6MTAwLCJ3IjoxMCwiaCI6MTB9",
578 "a".repeat(64)
579 )
580 );
581 assert_eq!(
582 ArtifactReference::parse(&reference.uri().unwrap()).unwrap(),
583 reference
584 );
585 }
586
587 #[test]
588 fn rejects_cross_scope_and_invalid_metadata() {
589 let reference = ArtifactReference::new(
590 "tenant",
591 "scope",
592 "a".repeat(64),
593 ArtifactMetadata::audio("audio/mpeg", 100, Some(1_000)).unwrap(),
594 )
595 .unwrap();
596
597 assert_eq!(
598 reference
599 .ensure_scope("tenant", "other")
600 .unwrap_err()
601 .kind(),
602 ArtifactReferenceErrorKind::ScopeMismatch
603 );
604 assert!(ArtifactMetadata::audio("image/png", 100, None).is_err());
605 assert!(ArtifactMetadata::file("video/mp4", 100).is_err());
606 assert!(ArtifactMetadata::video("video/mp4", 100, 1920, 1080, Some(1_000)).is_ok());
607 assert!(ArtifactMetadata::file("Application/PDF", 100).is_err());
608 }
609}
610
611#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
614#[serde(rename_all = "camelCase", deny_unknown_fields)]
615pub struct HostTlsIdentity {
616 pub host_id: String,
617 pub public_key: HostTlsPublicKey,
618}
619#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
620#[serde(deny_unknown_fields)]
621pub struct HostTlsPublicKey {
622 pub x: String,
623 pub y: String,
624}
625
626pub type DeleteArtifactRequest = ResolveArtifactRequest;