Skip to main content

orbok_models/
trust.rs

1//! Reviewed trust root and pure transport policy for RFC-050.
2//!
3//! This module contains no network client and performs no network access. It
4//! translates Appendix B into immutable typed data and decisions that a later
5//! production client must obey.
6
7use 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
34/// Source-controlled representation of Appendix B's normative manifest.
35pub 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    /// Validate invariants rather than trusting even source-controlled data
75    /// implicitly. This is useful for parity tests and future reviewed updates.
76    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/// Opaque identity of every trust-root field used by readiness and planning.
143/// It is crate-private so public callers cannot mint trusted readiness.
144#[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
234/// Required construction settings for the future production HTTP client.
235pub 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
250/// Decide which headers may cross the reviewed redirect boundary.
251pub 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
275/// Accept only the exact, revision-qualified source URL in the trust root.
276pub 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
305/// Validate one absolute redirect target. `redirect_number` is one-based.
306pub 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}