1use serde::Serialize;
8use sha2::Digest as _;
9use url::Url;
10
11pub const DEFAULT_MODEL_REVISION: &str = "614241f622f53c4eeff9890bdc4f31cfecc418b3";
12
13const PERMITTED_REDIRECT_HOSTS: &[&str] = &["cas-bridge.xethub.hf.co"];
14
15const DEFAULT_MODEL_FILES: &[TrustedModelFile] = &[
16 TrustedModelFile {
17 logical_name: "tokenizer",
18 relative_path: "tokenizer.json",
19 url: "https://huggingface.co/intfloat/multilingual-e5-small/resolve/614241f622f53c4eeff9890bdc4f31cfecc418b3/tokenizer.json",
20 sha256: "0b44a9d7b51c3c62626640cda0e2c2f70fdacdc25bbbd68038369d14ebdf4c39",
21 exact_size_bytes: 17_082_730,
22 max_transfer_bytes: 18_000_000,
23 },
24 TrustedModelFile {
25 logical_name: "onnx-model",
26 relative_path: "onnx/model.onnx",
27 url: "https://huggingface.co/intfloat/multilingual-e5-small/resolve/614241f622f53c4eeff9890bdc4f31cfecc418b3/onnx/model.onnx",
28 sha256: "ca456c06b3a9505ddfd9131408916dd79290368331e7d76bb621f1cba6bc8665",
29 exact_size_bytes: 470_268_510,
30 max_transfer_bytes: 471_000_000,
31 },
32];
33
34pub const DEFAULT_TRUSTED_MODEL: TrustedModelManifest = TrustedModelManifest {
36 schema_version: 1,
37 manifest_id: "multilingual-e5-small-hf-614241f6",
38 model: TrustedModelIdentity {
39 id: "intfloat/multilingual-e5-small",
40 display_name: "multilingual-e5-small",
41 revision: DEFAULT_MODEL_REVISION,
42 role: "embedding",
43 dimension: 384,
44 license: "MIT",
45 },
46 transport: TrustedTransportPolicy {
47 https_only: true,
48 credentials_allowed: false,
49 max_redirects: 1,
50 initial_host: "huggingface.co",
51 permitted_redirect_hosts: PERMITTED_REDIRECT_HOSTS,
52 relative_redirects_allowed: false,
53 strip_sensitive_headers_on_redirect: true,
54 },
55 files: DEFAULT_MODEL_FILES,
56};
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
59pub struct TrustedModelManifest {
60 pub schema_version: u32,
61 pub manifest_id: &'static str,
62 pub model: TrustedModelIdentity,
63 pub transport: TrustedTransportPolicy,
64 pub files: &'static [TrustedModelFile],
65}
66
67impl TrustedModelManifest {
68 pub fn file_by_path(&self, relative_path: &str) -> Option<&'static TrustedModelFile> {
69 self.files
70 .iter()
71 .find(|file| file.relative_path == relative_path)
72 }
73
74 pub fn validate(&self) -> Result<(), TrustPolicyError> {
77 if self.schema_version != 1 {
78 return Err(TrustPolicyError::UnsupportedSchema);
79 }
80 if self.manifest_id.is_empty()
81 || self.model.id.is_empty()
82 || self.model.display_name.is_empty()
83 || self.model.role.is_empty()
84 || self.model.license.is_empty()
85 || self.model.dimension == 0
86 {
87 return Err(TrustPolicyError::InvalidModelIdentity);
88 }
89 if self.model.revision.len() != 40
90 || !self
91 .model
92 .revision
93 .bytes()
94 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
95 {
96 return Err(TrustPolicyError::MovingOrInvalidRevision);
97 }
98 if self.files.is_empty() {
99 return Err(TrustPolicyError::InvalidFileMetadata);
100 }
101 if !self.transport.https_only
102 || self.transport.credentials_allowed
103 || self.transport.max_redirects != 1
104 || self.transport.initial_host.is_empty()
105 || self.transport.permitted_redirect_hosts.is_empty()
106 || self.transport.relative_redirects_allowed
107 || !self.transport.strip_sensitive_headers_on_redirect
108 {
109 return Err(TrustPolicyError::InvalidTransportPolicy);
110 }
111
112 let mut total_exact_bytes = 0_u64;
113 let mut total_transfer_bytes = 0_u64;
114 for (index, file) in self.files.iter().enumerate() {
115 validate_relative_path(file.relative_path)?;
116 validate_sha256(file.sha256)?;
117 if file.exact_size_bytes == 0 || file.max_transfer_bytes < file.exact_size_bytes {
118 return Err(TrustPolicyError::InvalidFileMetadata);
119 }
120 total_exact_bytes = total_exact_bytes
121 .checked_add(file.exact_size_bytes)
122 .ok_or(TrustPolicyError::SizeOverflow)?;
123 total_transfer_bytes = total_transfer_bytes
124 .checked_add(file.max_transfer_bytes)
125 .ok_or(TrustPolicyError::SizeOverflow)?;
126 validate_initial_url(self, file, file.url)?;
127 if self.files[..index].iter().any(|previous| {
128 previous.logical_name == file.logical_name
129 || previous.relative_path == file.relative_path
130 || previous.url == file.url
131 }) {
132 return Err(TrustPolicyError::DuplicateFileIdentity);
133 }
134 }
135 if total_transfer_bytes < total_exact_bytes {
136 return Err(TrustPolicyError::InvalidFileMetadata);
137 }
138 Ok(())
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub(crate) struct TrustRootBinding {
146 manifest_id: &'static str,
147 fingerprint: [u8; 32],
148}
149
150pub(crate) fn trust_root_binding(manifest: &'static TrustedModelManifest) -> TrustRootBinding {
151 let mut hasher = sha2::Sha256::new();
152 hash_u64(&mut hasher, u64::from(manifest.schema_version));
153 hash_str(&mut hasher, manifest.manifest_id);
154 hash_str(&mut hasher, manifest.model.id);
155 hash_str(&mut hasher, manifest.model.display_name);
156 hash_str(&mut hasher, manifest.model.revision);
157 hash_str(&mut hasher, manifest.model.role);
158 hash_u64(&mut hasher, u64::from(manifest.model.dimension));
159 hash_str(&mut hasher, manifest.model.license);
160 hash_bool(&mut hasher, manifest.transport.https_only);
161 hash_bool(&mut hasher, manifest.transport.credentials_allowed);
162 hash_u64(&mut hasher, u64::from(manifest.transport.max_redirects));
163 hash_str(&mut hasher, manifest.transport.initial_host);
164 hash_u64(
165 &mut hasher,
166 manifest.transport.permitted_redirect_hosts.len() as u64,
167 );
168 for host in manifest.transport.permitted_redirect_hosts {
169 hash_str(&mut hasher, host);
170 }
171 hash_bool(&mut hasher, manifest.transport.relative_redirects_allowed);
172 hash_bool(
173 &mut hasher,
174 manifest.transport.strip_sensitive_headers_on_redirect,
175 );
176 hash_u64(&mut hasher, manifest.files.len() as u64);
177 for file in manifest.files {
178 hash_str(&mut hasher, file.logical_name);
179 hash_str(&mut hasher, file.relative_path);
180 hash_str(&mut hasher, file.url);
181 hash_str(&mut hasher, file.sha256);
182 hash_u64(&mut hasher, file.exact_size_bytes);
183 hash_u64(&mut hasher, file.max_transfer_bytes);
184 }
185 let digest = hasher.finalize();
186 let mut fingerprint = [0_u8; 32];
187 fingerprint.copy_from_slice(&digest);
188 TrustRootBinding {
189 manifest_id: manifest.manifest_id,
190 fingerprint,
191 }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
195pub struct TrustedModelIdentity {
196 pub id: &'static str,
197 pub display_name: &'static str,
198 pub revision: &'static str,
199 pub role: &'static str,
200 pub dimension: u32,
201 pub license: &'static str,
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
205pub struct TrustedTransportPolicy {
206 pub https_only: bool,
207 pub credentials_allowed: bool,
208 pub max_redirects: u8,
209 pub initial_host: &'static str,
210 pub permitted_redirect_hosts: &'static [&'static str],
211 pub relative_redirects_allowed: bool,
212 pub strip_sensitive_headers_on_redirect: bool,
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
216pub struct TrustedModelFile {
217 pub logical_name: &'static str,
218 pub relative_path: &'static str,
219 pub url: &'static str,
220 pub sha256: &'static str,
221 pub exact_size_bytes: u64,
222 pub max_transfer_bytes: u64,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub struct HttpClientPolicy {
227 pub automatic_proxy_discovery: bool,
228 pub explicit_proxy: bool,
229 pub credential_store: bool,
230 pub cookie_store: bool,
231 pub automatic_referer: bool,
232}
233
234pub const PRODUCTION_HTTP_CLIENT_POLICY: HttpClientPolicy = HttpClientPolicy {
236 automatic_proxy_discovery: false,
237 explicit_proxy: false,
238 credential_store: false,
239 cookie_store: false,
240 automatic_referer: false,
241};
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum HeaderDisposition {
245 Retain,
246 Strip,
247 Reject,
248}
249
250pub fn redirect_header_disposition(name: &str) -> HeaderDisposition {
252 if [
253 "authorization",
254 "proxy-authorization",
255 "cookie",
256 "cookie2",
257 "referer",
258 ]
259 .iter()
260 .any(|candidate| name.eq_ignore_ascii_case(candidate))
261 {
262 return HeaderDisposition::Strip;
263 }
264
265 if ["accept", "accept-encoding", "range", "user-agent"]
266 .iter()
267 .any(|candidate| name.eq_ignore_ascii_case(candidate))
268 {
269 HeaderDisposition::Retain
270 } else {
271 HeaderDisposition::Reject
272 }
273}
274
275pub fn validate_initial_url(
277 manifest: &TrustedModelManifest,
278 file: &TrustedModelFile,
279 candidate: &str,
280) -> Result<Url, TrustPolicyError> {
281 if candidate != file.url {
282 return Err(TrustPolicyError::UntrustedInitialUrl);
283 }
284 let parsed = Url::parse(candidate).map_err(|_| TrustPolicyError::InvalidUrl)?;
285 validate_common_url(&parsed)?;
286 if parsed.host_str() != Some(manifest.transport.initial_host) {
287 return Err(TrustPolicyError::UntrustedInitialHost);
288 }
289 if parsed.query().is_some() {
290 return Err(TrustPolicyError::UnexpectedInitialUrlComponent);
291 }
292 let expected_url = format!(
293 "https://{}/{}/resolve/{}/{}",
294 manifest.transport.initial_host,
295 manifest.model.id,
296 manifest.model.revision,
297 file.relative_path
298 );
299 if candidate != expected_url {
300 return Err(TrustPolicyError::MovingOrInvalidRevision);
301 }
302 Ok(parsed)
303}
304
305pub fn validate_redirect_url(
307 manifest: &TrustedModelManifest,
308 location: &str,
309 redirect_number: u8,
310) -> Result<Url, TrustPolicyError> {
311 if redirect_number == 0 || redirect_number > manifest.transport.max_redirects {
312 return Err(TrustPolicyError::TooManyRedirects);
313 }
314 let parsed = Url::parse(location).map_err(|_| TrustPolicyError::RelativeOrInvalidRedirect)?;
315 validate_common_url(&parsed)?;
316 let host = parsed
317 .host_str()
318 .ok_or(TrustPolicyError::UntrustedRedirectHost)?;
319 if !manifest.transport.permitted_redirect_hosts.contains(&host) {
320 return Err(TrustPolicyError::UntrustedRedirectHost);
321 }
322 Ok(parsed)
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub enum TrustPolicyError {
327 UnsupportedSchema,
328 InvalidModelIdentity,
329 InvalidTransportPolicy,
330 MovingOrInvalidRevision,
331 InvalidFileMetadata,
332 SizeOverflow,
333 DuplicateFileIdentity,
334 InvalidRelativePath,
335 InvalidDigest,
336 InvalidUrl,
337 UntrustedInitialUrl,
338 UntrustedInitialHost,
339 RelativeOrInvalidRedirect,
340 TooManyRedirects,
341 UntrustedRedirectHost,
342 InsecureTransport,
343 CredentialsForbidden,
344 AlternatePortForbidden,
345 FragmentForbidden,
346 UnexpectedInitialUrlComponent,
347}
348
349fn validate_common_url(url: &Url) -> Result<(), TrustPolicyError> {
350 if url.scheme() != "https" {
351 return Err(TrustPolicyError::InsecureTransport);
352 }
353 if !url.username().is_empty() || url.password().is_some() {
354 return Err(TrustPolicyError::CredentialsForbidden);
355 }
356 if url.port().is_some() {
357 return Err(TrustPolicyError::AlternatePortForbidden);
358 }
359 if url.fragment().is_some() {
360 return Err(TrustPolicyError::FragmentForbidden);
361 }
362 Ok(())
363}
364
365fn validate_relative_path(path: &str) -> Result<(), TrustPolicyError> {
366 if path.is_empty()
367 || path.starts_with('/')
368 || path.contains('\\')
369 || path
370 .split('/')
371 .any(|part| part.is_empty() || part == "." || part == "..")
372 {
373 return Err(TrustPolicyError::InvalidRelativePath);
374 }
375 Ok(())
376}
377
378fn validate_sha256(digest: &str) -> Result<(), TrustPolicyError> {
379 if digest.len() != 64
380 || !digest
381 .bytes()
382 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
383 {
384 return Err(TrustPolicyError::InvalidDigest);
385 }
386 Ok(())
387}
388
389fn hash_str(hasher: &mut sha2::Sha256, value: &str) {
390 hash_u64(hasher, value.len() as u64);
391 hasher.update(value.as_bytes());
392}
393
394fn hash_u64(hasher: &mut sha2::Sha256, value: u64) {
395 hasher.update(value.to_be_bytes());
396}
397
398fn hash_bool(hasher: &mut sha2::Sha256, value: bool) {
399 hasher.update([u8::from(value)]);
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 const APPENDIX_B: &str =
407 include_str!("../../../../rfcs/appendices/APPENDIX-B-default-model-trust-root.md");
408
409 #[test]
410 fn typed_manifest_exactly_matches_appendix_b_json() {
411 let json = APPENDIX_B
412 .split_once("```json\n")
413 .and_then(|(_, remainder)| remainder.split_once("\n```").map(|(json, _)| json))
414 .expect("Appendix B must contain one JSON code block");
415 let appendix: serde_json::Value = serde_json::from_str(json).unwrap();
416 let typed = serde_json::to_value(DEFAULT_TRUSTED_MODEL).unwrap();
417 assert_eq!(typed, appendix);
418 assert_eq!(DEFAULT_TRUSTED_MODEL.validate(), Ok(()));
419 }
420
421 #[test]
422 fn initial_urls_are_exact_and_revision_qualified() {
423 for file in DEFAULT_TRUSTED_MODEL.files {
424 assert!(validate_initial_url(&DEFAULT_TRUSTED_MODEL, file, file.url).is_ok());
425 assert_eq!(
426 validate_initial_url(
427 &DEFAULT_TRUSTED_MODEL,
428 file,
429 &file.url.replace(DEFAULT_MODEL_REVISION, "main")
430 ),
431 Err(TrustPolicyError::UntrustedInitialUrl)
432 );
433 }
434 }
435
436 #[test]
437 fn initial_url_requires_exact_immutable_resolver_structure() {
438 let base = DEFAULT_TRUSTED_MODEL.files[0];
439 let moving_selector = TrustedModelFile {
440 url: "https://huggingface.co/intfloat/multilingual-e5-small/resolve/main/614241f622f53c4eeff9890bdc4f31cfecc418b3/tokenizer.json",
441 ..base
442 };
443 assert_eq!(
444 validate_initial_url(
445 &DEFAULT_TRUSTED_MODEL,
446 &moving_selector,
447 moving_selector.url
448 ),
449 Err(TrustPolicyError::MovingOrInvalidRevision)
450 );
451
452 let wrong_model = TrustedModelFile {
453 url: "https://huggingface.co/another/model/resolve/614241f622f53c4eeff9890bdc4f31cfecc418b3/tokenizer.json",
454 ..base
455 };
456 assert_eq!(
457 validate_initial_url(&DEFAULT_TRUSTED_MODEL, &wrong_model, wrong_model.url),
458 Err(TrustPolicyError::MovingOrInvalidRevision)
459 );
460
461 let wrong_file = TrustedModelFile {
462 url: "https://huggingface.co/intfloat/multilingual-e5-small/resolve/614241f622f53c4eeff9890bdc4f31cfecc418b3/config.json",
463 ..base
464 };
465 assert_eq!(
466 validate_initial_url(&DEFAULT_TRUSTED_MODEL, &wrong_file, wrong_file.url),
467 Err(TrustPolicyError::MovingOrInvalidRevision)
468 );
469
470 let query = TrustedModelFile {
471 url: "https://huggingface.co/intfloat/multilingual-e5-small/resolve/614241f622f53c4eeff9890bdc4f31cfecc418b3/tokenizer.json?download=1",
472 ..base
473 };
474 assert_eq!(
475 validate_initial_url(&DEFAULT_TRUSTED_MODEL, &query, query.url),
476 Err(TrustPolicyError::UnexpectedInitialUrlComponent)
477 );
478 }
479
480 #[test]
481 fn manifest_validation_rejects_invalid_path_and_digest() {
482 let invalid_path = manifest_with_files(vec![TrustedModelFile {
483 relative_path: "../tokenizer.json",
484 ..DEFAULT_TRUSTED_MODEL.files[0]
485 }]);
486 assert_eq!(
487 invalid_path.validate(),
488 Err(TrustPolicyError::InvalidRelativePath)
489 );
490
491 let invalid_digest = manifest_with_files(vec![TrustedModelFile {
492 sha256: "not-a-sha256",
493 ..DEFAULT_TRUSTED_MODEL.files[0]
494 }]);
495 assert_eq!(
496 invalid_digest.validate(),
497 Err(TrustPolicyError::InvalidDigest)
498 );
499 }
500
501 #[test]
502 fn manifest_validation_rejects_duplicates_and_size_overflow() {
503 let duplicate = manifest_with_files(vec![
504 DEFAULT_TRUSTED_MODEL.files[0],
505 DEFAULT_TRUSTED_MODEL.files[0],
506 ]);
507 assert_eq!(
508 duplicate.validate(),
509 Err(TrustPolicyError::DuplicateFileIdentity)
510 );
511
512 let overflow = manifest_with_files(vec![
513 TrustedModelFile {
514 exact_size_bytes: u64::MAX,
515 max_transfer_bytes: u64::MAX,
516 ..DEFAULT_TRUSTED_MODEL.files[0]
517 },
518 TrustedModelFile {
519 exact_size_bytes: u64::MAX,
520 max_transfer_bytes: u64::MAX,
521 ..DEFAULT_TRUSTED_MODEL.files[1]
522 },
523 ]);
524 assert_eq!(overflow.validate(), Err(TrustPolicyError::SizeOverflow));
525 }
526
527 #[test]
528 fn redirect_policy_rejects_relative_extra_and_unknown_targets() {
529 let accepted = "https://cas-bridge.xethub.hf.co/object?X-Amz-Signature=temporary";
530 assert!(validate_redirect_url(&DEFAULT_TRUSTED_MODEL, accepted, 1).is_ok());
531 assert_eq!(
532 validate_redirect_url(&DEFAULT_TRUSTED_MODEL, "/relative", 1),
533 Err(TrustPolicyError::RelativeOrInvalidRedirect)
534 );
535 assert_eq!(
536 validate_redirect_url(&DEFAULT_TRUSTED_MODEL, accepted, 2),
537 Err(TrustPolicyError::TooManyRedirects)
538 );
539 assert_eq!(
540 validate_redirect_url(&DEFAULT_TRUSTED_MODEL, "https://example.com/object", 1),
541 Err(TrustPolicyError::UntrustedRedirectHost)
542 );
543 }
544
545 #[test]
546 fn redirect_policy_rejects_credentials_downgrade_and_alternate_ports() {
547 assert_eq!(
548 validate_redirect_url(
549 &DEFAULT_TRUSTED_MODEL,
550 "https://user:secret@cas-bridge.xethub.hf.co/object",
551 1
552 ),
553 Err(TrustPolicyError::CredentialsForbidden)
554 );
555 assert_eq!(
556 validate_redirect_url(
557 &DEFAULT_TRUSTED_MODEL,
558 "http://cas-bridge.xethub.hf.co/object",
559 1
560 ),
561 Err(TrustPolicyError::InsecureTransport)
562 );
563 assert_eq!(
564 validate_redirect_url(
565 &DEFAULT_TRUSTED_MODEL,
566 "https://cas-bridge.xethub.hf.co:8443/object",
567 1
568 ),
569 Err(TrustPolicyError::AlternatePortForbidden)
570 );
571 }
572
573 #[test]
574 fn redirect_headers_are_explicitly_classified() {
575 for sensitive in [
576 "Authorization",
577 "proxy-authorization",
578 "COOKIE",
579 "Cookie2",
580 "Referer",
581 ] {
582 assert_eq!(
583 redirect_header_disposition(sensitive),
584 HeaderDisposition::Strip
585 );
586 }
587 for safe in ["Accept", "Accept-Encoding", "Range", "User-Agent"] {
588 assert_eq!(redirect_header_disposition(safe), HeaderDisposition::Retain);
589 }
590 assert_eq!(
591 redirect_header_disposition("X-Document-Path"),
592 HeaderDisposition::Reject
593 );
594 }
595
596 #[test]
597 fn production_policy_forbids_all_ambient_client_state() {
598 assert_eq!(
599 PRODUCTION_HTTP_CLIENT_POLICY,
600 HttpClientPolicy {
601 automatic_proxy_discovery: false,
602 explicit_proxy: false,
603 credential_store: false,
604 cookie_store: false,
605 automatic_referer: false,
606 }
607 );
608 }
609
610 fn manifest_with_files(files: Vec<TrustedModelFile>) -> TrustedModelManifest {
611 TrustedModelManifest {
612 files: Box::leak(files.into_boxed_slice()),
613 ..DEFAULT_TRUSTED_MODEL
614 }
615 }
616}