1use std::collections::BTreeSet;
30
31use chrono::{DateTime, Utc};
32use serde::{de, Deserialize, Deserializer, Serialize};
33use sha2::{Digest, Sha256};
34
35use crate::error::WeightsError;
36
37pub const CARD_VERSION_V1: &str = "1";
40
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
47#[serde(transparent)]
48pub struct StringSet(BTreeSet<String>);
49
50impl<'de> Deserialize<'de> for StringSet {
51 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
52 where
53 D: Deserializer<'de>,
54 {
55 let items = Vec::<String>::deserialize(deserializer)?;
56 let mut set = BTreeSet::new();
57 for item in items {
58 if item.trim().is_empty() {
59 return Err(de::Error::custom(
60 "string set entries must be non-empty strings",
61 ));
62 }
63 if item.trim() != item {
64 return Err(de::Error::custom(
65 "string set entries must not contain surrounding whitespace",
66 ));
67 }
68 if set.contains(&item) {
69 return Err(de::Error::custom(format!(
70 "duplicate string set entry {item:?}"
71 )));
72 }
73 set.insert(item);
74 }
75 Ok(Self(set))
76 }
77}
78
79impl StringSet {
80 pub fn new<I, S>(items: I) -> Self
83 where
84 I: IntoIterator<Item = S>,
85 S: Into<String>,
86 {
87 Self(items.into_iter().map(Into::into).collect())
88 }
89
90 #[must_use]
92 pub fn covers(&self, subset: &StringSet) -> bool {
93 subset.0.is_subset(&self.0)
94 }
95
96 #[must_use]
98 pub fn intersects(&self, other: &StringSet) -> bool {
99 self.0.intersection(&other.0).next().is_some()
100 }
101
102 #[must_use]
104 pub fn contains(&self, item: &str) -> bool {
105 self.0.contains(item)
106 }
107
108 pub fn iter(&self) -> impl Iterator<Item = &str> {
110 self.0.iter().map(String::as_str)
111 }
112
113 #[must_use]
115 pub fn len(&self) -> usize {
116 self.0.len()
117 }
118
119 #[must_use]
121 pub fn is_empty(&self) -> bool {
122 self.0.is_empty()
123 }
124
125 #[must_use]
127 pub fn as_set(&self) -> &BTreeSet<String> {
128 &self.0
129 }
130
131 fn validate_entries(&self, field: &'static str) -> Result<(), WeightsError> {
132 for item in &self.0 {
133 if item.trim().is_empty() {
134 return Err(WeightsError::SchemaRejected(format!(
135 "{field} entries must be non-empty strings"
136 )));
137 }
138 if item.trim() != item {
139 return Err(WeightsError::SchemaRejected(format!(
140 "{field} entries must not contain surrounding whitespace"
141 )));
142 }
143 }
144 Ok(())
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct ModelCard {
158 pub card_version: String,
161 pub weights_hash: String,
164 pub allowed_capability_set: StringSet,
167 pub banned_tools: StringSet,
169 pub training_data_class: String,
171 pub issuer: String,
173 pub issued_at: DateTime<Utc>,
175 pub expires_at: DateTime<Utc>,
177}
178
179impl ModelCard {
180 pub fn new(
190 weights_hash: impl Into<String>,
191 allowed_capability_set: StringSet,
192 banned_tools: StringSet,
193 training_data_class: impl Into<String>,
194 issuer: impl Into<String>,
195 issued_at: DateTime<Utc>,
196 expires_at: DateTime<Utc>,
197 ) -> Result<Self, WeightsError> {
198 let card = Self {
199 card_version: CARD_VERSION_V1.to_string(),
200 weights_hash: weights_hash.into(),
201 allowed_capability_set,
202 banned_tools,
203 training_data_class: training_data_class.into(),
204 issuer: issuer.into(),
205 issued_at,
206 expires_at,
207 };
208 card.validate()?;
209 Ok(card)
210 }
211
212 pub fn validate(&self) -> Result<(), WeightsError> {
215 if self.card_version != CARD_VERSION_V1 {
216 return Err(WeightsError::SchemaRejected(format!(
217 "card_version must be {CARD_VERSION_V1:?}, got {:?}",
218 self.card_version
219 )));
220 }
221 if !is_lowercase_sha256_hex(&self.weights_hash) {
222 return Err(WeightsError::SchemaRejected(format!(
223 "weights_hash must be 64 lowercase hex chars, got {:?}",
224 self.weights_hash
225 )));
226 }
227 self.allowed_capability_set
228 .validate_entries("allowed_capability_set")?;
229 self.banned_tools.validate_entries("banned_tools")?;
230 validate_required_text_field(&self.training_data_class, "training_data_class")?;
231 validate_required_text_field(&self.issuer, "issuer")?;
232 if self.expires_at < self.issued_at {
233 return Err(WeightsError::SchemaRejected(format!(
234 "expires_at ({}) precedes issued_at ({})",
235 self.expires_at, self.issued_at,
236 )));
237 }
238 Ok(())
239 }
240
241 pub fn require_live(&self, now: DateTime<Utc>) -> Result<(), WeightsError> {
243 if now < self.expires_at {
244 Ok(())
245 } else {
246 Err(WeightsError::Expired {
247 expires_at: self.expires_at,
248 now,
249 })
250 }
251 }
252
253 pub fn to_canonical_json(&self) -> Result<Vec<u8>, WeightsError> {
256 chio_core_types::canonical::canonical_json_bytes(self)
257 .map_err(|err| WeightsError::Encoding(format!("canonical-json encode: {err}")))
258 }
259
260 pub fn from_canonical_json(bytes: &[u8]) -> Result<Self, WeightsError> {
263 let card: Self = serde_json::from_slice(bytes)
264 .map_err(|err| WeightsError::Encoding(format!("canonical-json decode: {err}")))?;
265 card.validate()?;
266 let canonical = card.to_canonical_json()?;
267 if canonical.as_slice() != bytes {
268 return Err(WeightsError::Encoding(
269 "model card bytes are not RFC 8785 canonical JSON".to_string(),
270 ));
271 }
272 Ok(card)
273 }
274}
275
276#[must_use]
280pub fn weights_hash_of(bytes: &[u8]) -> String {
281 let digest = Sha256::digest(bytes);
282 hex::encode(digest)
283}
284
285#[inline]
286fn is_lowercase_hex_byte(b: u8) -> bool {
287 matches!(b, b'0'..=b'9' | b'a'..=b'f')
288}
289
290#[inline]
291fn is_lowercase_sha256_hex(value: &str) -> bool {
292 value.len() == 64 && value.bytes().all(is_lowercase_hex_byte)
293}
294
295fn validate_required_text_field(value: &str, field: &'static str) -> Result<(), WeightsError> {
296 if value.trim().is_empty() {
297 return Err(WeightsError::MissingField(field));
298 }
299 if value.trim() != value {
300 return Err(WeightsError::SchemaRejected(format!(
301 "{field} must not contain surrounding whitespace"
302 )));
303 }
304 Ok(())
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use chrono::TimeZone;
311
312 fn fixed_issued_at() -> DateTime<Utc> {
313 match Utc.with_ymd_and_hms(2026, 4, 30, 12, 0, 0) {
314 chrono::LocalResult::Single(t) => t,
315 _ => panic!("fixed_issued_at fixture must construct"),
316 }
317 }
318
319 fn good_card() -> ModelCard {
320 let issued = fixed_issued_at();
321 let expires = issued + chrono::Duration::days(30);
322 match ModelCard::new(
323 "0000000000000000000000000000000000000000000000000000000000000001",
324 StringSet::new(["tool:read", "tool:write"]),
325 StringSet::new(["tool:exec"]),
326 "public-internet",
327 "https://example.com/issuer",
328 issued,
329 expires,
330 ) {
331 Ok(card) => card,
332 Err(e) => panic!("good_card must construct: {e}"),
333 }
334 }
335
336 fn good_card_value() -> serde_json::Value {
337 serde_json::json!({
338 "allowed_capability_set": ["tool:read", "tool:write"],
339 "banned_tools": ["tool:exec"],
340 "card_version": CARD_VERSION_V1,
341 "expires_at": "2026-05-30T12:00:00Z",
342 "issued_at": "2026-04-30T12:00:00Z",
343 "issuer": "https://example.com/issuer",
344 "training_data_class": "public-internet",
345 "weights_hash": "0000000000000000000000000000000000000000000000000000000000000001",
346 })
347 }
348
349 fn value_to_bytes(value: &serde_json::Value) -> Vec<u8> {
350 match serde_json::to_vec(value) {
351 Ok(bytes) => bytes,
352 Err(e) => panic!("serialize fixture value: {e}"),
353 }
354 }
355
356 #[test]
357 fn lowercase_sha256_hex_helper_accepts_only_exact_lowercase_digest() {
358 assert!(is_lowercase_sha256_hex(
359 "0000000000000000000000000000000000000000000000000000000000000001"
360 ));
361 assert!(!is_lowercase_sha256_hex(
362 "ABCDEF0000000000000000000000000000000000000000000000000000000001"
363 ));
364 assert!(!is_lowercase_sha256_hex("abcdef"));
365 assert!(!is_lowercase_sha256_hex(
366 "000000000000000000000000000000000000000000000000000000000000000g"
367 ));
368 }
369
370 #[test]
371 fn validate_rejects_uppercase_weights_hash() {
372 let issued = fixed_issued_at();
373 let res = ModelCard::new(
374 "ABCDEF0000000000000000000000000000000000000000000000000000000001",
375 StringSet::new(["tool:read"]),
376 StringSet::default(),
377 "public-internet",
378 "https://example.com/issuer",
379 issued,
380 issued + chrono::Duration::days(1),
381 );
382 assert!(matches!(res, Err(WeightsError::SchemaRejected(_))));
383 }
384
385 #[test]
386 fn validate_rejects_short_weights_hash() {
387 let issued = fixed_issued_at();
388 let res = ModelCard::new(
389 "abcdef",
390 StringSet::default(),
391 StringSet::default(),
392 "public-internet",
393 "https://example.com/issuer",
394 issued,
395 issued + chrono::Duration::days(1),
396 );
397 assert!(matches!(res, Err(WeightsError::SchemaRejected(_))));
398 }
399
400 #[test]
401 fn validate_rejects_empty_training_data_class() {
402 let issued = fixed_issued_at();
403 let res = ModelCard::new(
404 "0000000000000000000000000000000000000000000000000000000000000001",
405 StringSet::default(),
406 StringSet::default(),
407 "",
408 "https://example.com/issuer",
409 issued,
410 issued + chrono::Duration::days(1),
411 );
412 assert!(matches!(res, Err(WeightsError::MissingField(_))));
413 }
414
415 #[test]
416 fn validate_rejects_blank_or_padded_required_text_fields() {
417 let issued = fixed_issued_at();
418 let blank_training = ModelCard::new(
419 "0000000000000000000000000000000000000000000000000000000000000001",
420 StringSet::default(),
421 StringSet::default(),
422 " ",
423 "https://example.com/issuer",
424 issued,
425 issued + chrono::Duration::days(1),
426 );
427 assert!(matches!(
428 blank_training,
429 Err(WeightsError::MissingField("training_data_class"))
430 ));
431
432 let padded_issuer = ModelCard::new(
433 "0000000000000000000000000000000000000000000000000000000000000001",
434 StringSet::default(),
435 StringSet::default(),
436 "public-internet",
437 " https://example.com/issuer",
438 issued,
439 issued + chrono::Duration::days(1),
440 );
441 assert!(matches!(
442 padded_issuer,
443 Err(WeightsError::SchemaRejected(message)) if message.contains("issuer")
444 ));
445 }
446
447 #[test]
448 fn validate_rejects_expires_before_issued() {
449 let issued = fixed_issued_at();
450 let res = ModelCard::new(
451 "0000000000000000000000000000000000000000000000000000000000000001",
452 StringSet::default(),
453 StringSet::default(),
454 "public-internet",
455 "https://example.com/issuer",
456 issued,
457 issued - chrono::Duration::seconds(1),
458 );
459 assert!(matches!(res, Err(WeightsError::SchemaRejected(_))));
460 }
461
462 #[test]
463 fn from_canonical_json_rejects_unknown_fields() {
464 let mut value = good_card_value();
465 match value {
466 serde_json::Value::Object(ref mut map) => {
467 map.insert(
468 "unexpected".to_string(),
469 serde_json::Value::String("field".to_string()),
470 );
471 }
472 _ => panic!("fixture must be a JSON object"),
473 }
474 let bytes = value_to_bytes(&value);
475 let res = ModelCard::from_canonical_json(&bytes);
476 assert!(matches!(res, Err(WeightsError::Encoding(_))));
477 }
478
479 #[test]
480 fn from_canonical_json_rejects_duplicate_set_entries() {
481 let mut value = good_card_value();
482 match value {
483 serde_json::Value::Object(ref mut map) => {
484 map.insert(
485 "allowed_capability_set".to_string(),
486 serde_json::json!(["tool:read", "tool:read"]),
487 );
488 }
489 _ => panic!("fixture must be a JSON object"),
490 }
491 let bytes = value_to_bytes(&value);
492 let res = ModelCard::from_canonical_json(&bytes);
493 assert!(matches!(res, Err(WeightsError::Encoding(_))));
494 }
495
496 #[test]
497 fn from_canonical_json_rejects_empty_set_entries() {
498 let mut value = good_card_value();
499 match value {
500 serde_json::Value::Object(ref mut map) => {
501 map.insert(
502 "banned_tools".to_string(),
503 serde_json::json!(["tool:exec", ""]),
504 );
505 }
506 _ => panic!("fixture must be a JSON object"),
507 }
508 let bytes = value_to_bytes(&value);
509 let res = ModelCard::from_canonical_json(&bytes);
510 assert!(matches!(res, Err(WeightsError::Encoding(_))));
511 }
512
513 #[test]
514 fn new_rejects_empty_set_entries() {
515 let issued = fixed_issued_at();
516 let res = ModelCard::new(
517 "0000000000000000000000000000000000000000000000000000000000000001",
518 StringSet::new([""]),
519 StringSet::default(),
520 "public-internet",
521 "https://example.com/issuer",
522 issued,
523 issued + chrono::Duration::days(1),
524 );
525 assert!(matches!(res, Err(WeightsError::SchemaRejected(_))));
526 }
527
528 #[test]
529 fn new_rejects_blank_or_padded_set_entries() {
530 let issued = fixed_issued_at();
531 let blank = ModelCard::new(
532 "0000000000000000000000000000000000000000000000000000000000000001",
533 StringSet::new([" "]),
534 StringSet::default(),
535 "public-internet",
536 "https://example.com/issuer",
537 issued,
538 issued + chrono::Duration::days(1),
539 );
540 assert!(matches!(
541 blank,
542 Err(WeightsError::SchemaRejected(message)) if message.contains("allowed_capability_set")
543 ));
544
545 let padded = ModelCard::new(
546 "0000000000000000000000000000000000000000000000000000000000000001",
547 StringSet::default(),
548 StringSet::new([" tool:exec"]),
549 "public-internet",
550 "https://example.com/issuer",
551 issued,
552 issued + chrono::Duration::days(1),
553 );
554 assert!(matches!(
555 padded,
556 Err(WeightsError::SchemaRejected(message)) if message.contains("banned_tools")
557 ));
558 }
559
560 #[test]
561 fn require_live_rejects_expired_card() {
562 let card = good_card();
563 let later = card.expires_at + chrono::Duration::seconds(1);
564 let res = card.require_live(later);
565 assert!(matches!(res, Err(WeightsError::Expired { .. })));
566 }
567
568 #[test]
569 fn canonical_round_trip_is_byte_stable() {
570 let card = good_card();
571 let bytes = match card.to_canonical_json() {
572 Ok(b) => b,
573 Err(e) => panic!("encode must succeed: {e}"),
574 };
575 let again = match ModelCard::from_canonical_json(&bytes) {
576 Ok(c) => c,
577 Err(e) => panic!("decode must succeed: {e}"),
578 };
579 let bytes2 = match again.to_canonical_json() {
580 Ok(b) => b,
581 Err(e) => panic!("re-encode must succeed: {e}"),
582 };
583 assert_eq!(bytes, bytes2, "canonical-json bytes must round-trip stably");
584 }
585
586 #[test]
587 fn from_canonical_json_rejects_noncanonical_whitespace() {
588 let card = good_card();
589 let pretty = match serde_json::to_vec_pretty(&card) {
590 Ok(bytes) => bytes,
591 Err(e) => panic!("pretty encode must succeed: {e}"),
592 };
593 let res = ModelCard::from_canonical_json(&pretty);
594 assert!(
595 matches!(res, Err(WeightsError::Encoding(_))),
596 "signed model cards must be presented as RFC 8785 canonical bytes"
597 );
598 }
599
600 #[test]
601 fn weights_hash_of_matches_known_vector() {
602 assert_eq!(
604 weights_hash_of(b""),
605 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
606 );
607 }
608
609 #[test]
610 fn allowed_capability_set_covers_subset() {
611 let card = good_card();
612 let req = StringSet::new(["tool:read"]);
613 assert!(card.allowed_capability_set.covers(&req));
614 let bad = StringSet::new(["tool:read", "tool:admin"]);
615 assert!(!card.allowed_capability_set.covers(&bad));
616 }
617
618 #[test]
619 fn banned_tools_intersects_detects_overlap() {
620 let card = good_card();
621 let req = StringSet::new(["tool:read", "tool:exec"]);
622 assert!(card.banned_tools.intersects(&req));
623 let benign = StringSet::new(["tool:read"]);
624 assert!(!card.banned_tools.intersects(&benign));
625 }
626}