Skip to main content

ferrum_types/
native_operator.rs

1//! Native operator artifact manifest types.
2
3use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8pub const LEGACY_NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION: u32 = 1;
9pub const PROVIDER_BOUND_NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION: u32 = 2;
10pub const NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION: u32 = 3;
11pub const NATIVE_OPERATOR_PROVIDER_CATALOG_SCHEMA_VERSION: u32 = 1;
12pub const NATIVE_OPERATOR_ABI_CONTRACT_SCHEMA_VERSION: u32 = 1;
13pub const LEGACY_FERRUM_NATIVE_OPERATOR_ABI_VERSION: &str = "1";
14pub const FERRUM_NATIVE_OPERATOR_ABI_VERSION: &str = "2";
15pub const DEFAULT_NATIVE_OPERATOR_ABI_VERSION: &str = "1";
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum NativeOperatorBackend {
20    Cuda,
21    Metal,
22    Cpu,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum NativeOperatorLinkage {
28    Static,
29    Dynamic,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct NativeOperatorSourcePackage {
34    pub kind: String,
35    pub revision: String,
36    pub sha256: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct NativeOperatorBuildSummary {
41    pub builder_sha: String,
42    pub elapsed_ms: u64,
43    pub nvcc_version: Option<String>,
44    pub host_compiler: String,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
48pub struct NativeOperatorBinding {
49    pub operation_id: String,
50    pub operation_contract_version: NativeOperatorContractVersion,
51    pub provider_id: String,
52    pub provider_version: NativeOperatorContractVersion,
53    pub provider_implementation_fingerprint: String,
54    #[serde(default)]
55    pub entrypoints: Vec<String>,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
59pub struct NativeOperatorContractVersion {
60    pub major: u16,
61    pub minor: u16,
62}
63
64impl NativeOperatorContractVersion {
65    pub const fn new(major: u16, minor: u16) -> Self {
66        Self { major, minor }
67    }
68}
69
70#[derive(Deserialize)]
71#[serde(untagged)]
72enum NativeOperatorContractVersionWire {
73    Version { major: u16, minor: u16 },
74    LegacyMajor(u32),
75}
76
77impl<'de> Deserialize<'de> for NativeOperatorContractVersion {
78    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
79    where
80        D: serde::Deserializer<'de>,
81    {
82        match NativeOperatorContractVersionWire::deserialize(deserializer)? {
83            NativeOperatorContractVersionWire::Version { major, minor } => {
84                Ok(Self { major, minor })
85            }
86            NativeOperatorContractVersionWire::LegacyMajor(major) => {
87                let major = u16::try_from(major)
88                    .map_err(|_| serde::de::Error::custom("legacy contract major exceeds u16"))?;
89                Ok(Self { major, minor: 0 })
90            }
91        }
92    }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct NativeOperatorProviderCatalog {
98    pub schema_version: u32,
99    pub backend: NativeOperatorBackend,
100    pub providers: Vec<NativeOperatorProviderCatalogRow>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct NativeOperatorProviderCatalogRow {
106    pub operation_id: String,
107    pub operation_contract_version: NativeOperatorContractVersion,
108    pub operation_fingerprint: String,
109    pub provider_id: String,
110    pub provider_version: NativeOperatorContractVersion,
111    pub provider_implementation_fingerprint: String,
112}
113
114impl NativeOperatorProviderCatalog {
115    pub fn validate(&self) -> std::result::Result<(), String> {
116        if self.schema_version != NATIVE_OPERATOR_PROVIDER_CATALOG_SCHEMA_VERSION {
117            return Err(format!(
118                "native operator provider catalog schema_version must be {NATIVE_OPERATOR_PROVIDER_CATALOG_SCHEMA_VERSION}"
119            ));
120        }
121        if self.providers.is_empty() {
122            return Err("native operator provider catalog must not be empty".to_string());
123        }
124        let provider_prefix = match self.backend {
125            NativeOperatorBackend::Cuda => "provider.cuda.",
126            NativeOperatorBackend::Metal => "provider.metal.",
127            NativeOperatorBackend::Cpu => "provider.cpu.",
128        };
129        let mut previous_key: Option<(&str, &str)> = None;
130        for (index, provider) in self.providers.iter().enumerate() {
131            let label = format!("providers[{index}]");
132            require_contract_identifier(
133                &format!("{label}.operation_id"),
134                &provider.operation_id,
135                "operation.",
136            )?;
137            require_contract_identifier(
138                &format!("{label}.provider_id"),
139                &provider.provider_id,
140                "provider.",
141            )?;
142            if !provider.provider_id.starts_with(provider_prefix) {
143                return Err(format!(
144                    "{label}.provider_id must match catalog backend {:?}",
145                    self.backend
146                ));
147            }
148            if provider.operation_contract_version.major == 0
149                || provider.provider_version.major == 0
150            {
151                return Err(format!("{label} contract major versions must be positive"));
152            }
153            require_sha256(
154                &format!("{label}.operation_fingerprint"),
155                &provider.operation_fingerprint,
156            )?;
157            require_sha256(
158                &format!("{label}.provider_implementation_fingerprint"),
159                &provider.provider_implementation_fingerprint,
160            )?;
161            let key = (
162                provider.operation_id.as_str(),
163                provider.provider_id.as_str(),
164            );
165            if previous_key.is_some_and(|previous| previous >= key) {
166                return Err(
167                    "native operator provider catalog rows must be sorted and unique by operation_id/provider_id"
168                        .to_string(),
169                );
170            }
171            previous_key = Some(key);
172        }
173        Ok(())
174    }
175
176    pub fn canonical_json_bytes(&self) -> std::result::Result<Vec<u8>, String> {
177        self.validate()?;
178        canonical_json_bytes(self, "native operator provider catalog")
179    }
180
181    pub fn canonical_sha256(&self) -> std::result::Result<String, String> {
182        Ok(format!(
183            "{:x}",
184            Sha256::digest(self.canonical_json_bytes()?)
185        ))
186    }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(deny_unknown_fields)]
191pub struct NativeOperatorAbiContract {
192    pub schema_version: u32,
193    pub ferrum_native_abi_version: String,
194    pub descriptor_struct: String,
195    pub descriptor_symbol_policy: String,
196    pub descriptor_fields: Vec<NativeOperatorAbiField>,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(deny_unknown_fields)]
201pub struct NativeOperatorAbiField {
202    pub name: String,
203    pub c_type: String,
204}
205
206impl NativeOperatorAbiContract {
207    pub fn validate(&self) -> std::result::Result<(), String> {
208        if self.schema_version != NATIVE_OPERATOR_ABI_CONTRACT_SCHEMA_VERSION {
209            return Err(format!(
210                "native ABI contract schema_version must be {NATIVE_OPERATOR_ABI_CONTRACT_SCHEMA_VERSION}"
211            ));
212        }
213        if self.ferrum_native_abi_version != FERRUM_NATIVE_OPERATOR_ABI_VERSION
214            || self.descriptor_struct != "FerrumNativeOperatorDescriptorV2"
215            || self.descriptor_symbol_policy != "operator_namespaced"
216        {
217            return Err(
218                "native ABI contract version, descriptor, or symbol policy is unsupported"
219                    .to_string(),
220            );
221        }
222        let expected = [
223            ("struct_size", "uint32_t"),
224            ("ferrum_native_abi_version", "uint32_t"),
225            ("operator_name", "const char *"),
226            ("operator_abi_version", "const char *"),
227            ("g03_catalog_sha256", "const char *"),
228            ("abi_contract_sha256", "const char *"),
229        ];
230        if self.descriptor_fields.len() != expected.len()
231            || self
232                .descriptor_fields
233                .iter()
234                .zip(expected)
235                .any(|(actual, (name, c_type))| actual.name != name || actual.c_type != c_type)
236        {
237            return Err(
238                "native ABI descriptor fields differ from FerrumNativeOperatorDescriptorV2"
239                    .to_string(),
240            );
241        }
242        Ok(())
243    }
244
245    pub fn canonical_json_bytes(&self) -> std::result::Result<Vec<u8>, String> {
246        self.validate()?;
247        canonical_json_bytes(self, "native operator ABI contract")
248    }
249
250    pub fn canonical_sha256(&self) -> std::result::Result<String, String> {
251        Ok(format!(
252            "{:x}",
253            Sha256::digest(self.canonical_json_bytes()?)
254        ))
255    }
256}
257
258fn canonical_json_bytes(
259    value: &impl Serialize,
260    label: &str,
261) -> std::result::Result<Vec<u8>, String> {
262    let mut bytes =
263        serde_json::to_vec_pretty(value).map_err(|error| format!("serialize {label}: {error}"))?;
264    bytes.push(b'\n');
265    Ok(bytes)
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269pub struct NativeOperatorManifest {
270    pub schema_version: u32,
271    pub operator: String,
272    pub operator_abi_version: String,
273    pub ferrum_native_abi_version: String,
274    pub backend: NativeOperatorBackend,
275    pub cuda_toolkit: Option<String>,
276    pub cuda_runtime_min: Option<String>,
277    #[serde(default)]
278    pub compute_capabilities: Vec<String>,
279    pub source_package: NativeOperatorSourcePackage,
280    pub inputs_sha256: String,
281    pub binary_sha256: String,
282    pub linkage: NativeOperatorLinkage,
283    #[serde(default)]
284    pub g03_catalog_sha256: Option<String>,
285    #[serde(default)]
286    pub abi_contract_sha256: Option<String>,
287    #[serde(default)]
288    pub descriptor_export: Option<String>,
289    #[serde(default)]
290    pub operation_bindings: Vec<NativeOperatorBinding>,
291    #[serde(default)]
292    pub exports: Vec<String>,
293    #[serde(default)]
294    pub license_files: Vec<String>,
295    pub build_summary: NativeOperatorBuildSummary,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299pub struct CompiledNativeOperatorIdentity {
300    pub schema_version: u32,
301    pub operator: String,
302    pub operator_abi_version: String,
303    pub ferrum_native_abi_version: String,
304    pub backend: NativeOperatorBackend,
305    pub linkage: NativeOperatorLinkage,
306    pub g03_catalog_sha256: Option<String>,
307    pub abi_contract_sha256: Option<String>,
308    pub descriptor_export: Option<String>,
309    pub operation_bindings: Vec<NativeOperatorBinding>,
310    pub exports: Vec<String>,
311    pub source_package_sha256: String,
312    pub inputs_sha256: String,
313    pub binary_sha256: String,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub struct NativeOperatorRequirement {
318    pub operator: String,
319    pub backend: NativeOperatorBackend,
320    pub operator_abi_version: String,
321    pub ferrum_native_abi_version: String,
322    pub compute_capability: Option<String>,
323    pub source_package_sha256: Option<String>,
324    pub inputs_sha256: Option<String>,
325    pub binary_sha256: Option<String>,
326    pub g03_catalog_sha256: Option<String>,
327    pub abi_contract_sha256: Option<String>,
328    pub descriptor_export: Option<String>,
329    pub required_exports: Vec<String>,
330    pub operation_bindings: Option<Vec<NativeOperatorBinding>>,
331}
332
333impl NativeOperatorRequirement {
334    pub fn cuda(operator: impl Into<String>, compute_capability: impl Into<String>) -> Self {
335        Self {
336            operator: operator.into(),
337            backend: NativeOperatorBackend::Cuda,
338            operator_abi_version: DEFAULT_NATIVE_OPERATOR_ABI_VERSION.to_string(),
339            ferrum_native_abi_version: FERRUM_NATIVE_OPERATOR_ABI_VERSION.to_string(),
340            compute_capability: Some(compute_capability.into()),
341            source_package_sha256: None,
342            inputs_sha256: None,
343            binary_sha256: None,
344            g03_catalog_sha256: None,
345            abi_contract_sha256: None,
346            descriptor_export: None,
347            required_exports: Vec::new(),
348            operation_bindings: None,
349        }
350    }
351}
352
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct NativeOperatorResolution {
355    pub operator: String,
356    pub backend: NativeOperatorBackend,
357    pub linkage: NativeOperatorLinkage,
358    pub binary_sha256: String,
359    pub g03_catalog_sha256: Option<String>,
360    pub abi_contract_sha256: Option<String>,
361}
362
363impl NativeOperatorManifest {
364    pub fn validate(&self) -> std::result::Result<(), String> {
365        if ![
366            LEGACY_NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION,
367            PROVIDER_BOUND_NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION,
368            NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION,
369        ]
370        .contains(&self.schema_version)
371        {
372            return Err(format!(
373                "schema_version must be {LEGACY_NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION}, \
374                 {PROVIDER_BOUND_NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION}, or \
375                 {NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION}"
376            ));
377        }
378        require_non_empty("operator", &self.operator)?;
379        require_non_empty("operator_abi_version", &self.operator_abi_version)?;
380        require_non_empty("ferrum_native_abi_version", &self.ferrum_native_abi_version)?;
381        require_non_empty("source_package.kind", &self.source_package.kind)?;
382        require_non_empty("source_package.revision", &self.source_package.revision)?;
383        require_sha256("source_package.sha256", &self.source_package.sha256)?;
384        require_sha256("inputs_sha256", &self.inputs_sha256)?;
385        require_sha256("binary_sha256", &self.binary_sha256)?;
386        require_non_empty("build_summary.builder_sha", &self.build_summary.builder_sha)?;
387        require_non_empty(
388            "build_summary.host_compiler",
389            &self.build_summary.host_compiler,
390        )?;
391        if self.backend == NativeOperatorBackend::Cuda {
392            if self.compute_capabilities.is_empty() {
393                return Err(
394                    "cuda native operator manifest requires compute_capabilities".to_string(),
395                );
396            }
397            for capability in &self.compute_capabilities {
398                if !capability.starts_with("sm_") {
399                    return Err("compute_capabilities entries must use sm_xx form".to_string());
400                }
401            }
402        }
403        match self.schema_version {
404            LEGACY_NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION => self.validate_legacy_v1()?,
405            PROVIDER_BOUND_NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION => {
406                self.validate_versioned(false)?
407            }
408            NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION => self.validate_versioned(true)?,
409            _ => unreachable!("schema version checked above"),
410        }
411        Ok(())
412    }
413
414    fn validate_legacy_v1(&self) -> std::result::Result<(), String> {
415        if self.ferrum_native_abi_version != LEGACY_FERRUM_NATIVE_OPERATOR_ABI_VERSION {
416            return Err(format!(
417                "legacy schema v1 requires ferrum_native_abi_version={LEGACY_FERRUM_NATIVE_OPERATOR_ABI_VERSION}"
418            ));
419        }
420        if !self
421            .exports
422            .iter()
423            .any(|export| export == "ferrum_native_op_init")
424        {
425            return Err("legacy schema v1 exports must include ferrum_native_op_init".to_string());
426        }
427        if !self
428            .exports
429            .iter()
430            .any(|export| export == "ferrum_native_op_descriptor")
431        {
432            return Err(
433                "legacy schema v1 exports must include ferrum_native_op_descriptor".to_string(),
434            );
435        }
436        if self.g03_catalog_sha256.is_some()
437            || self.abi_contract_sha256.is_some()
438            || self.descriptor_export.is_some()
439            || !self.operation_bindings.is_empty()
440        {
441            return Err("legacy schema v1 must not contain schema v2 identity fields".to_string());
442        }
443        Ok(())
444    }
445
446    fn validate_versioned(&self, allow_unbound_component: bool) -> std::result::Result<(), String> {
447        let schema_label = format!("schema v{}", self.schema_version);
448        let catalog_sha256 = self
449            .g03_catalog_sha256
450            .as_deref()
451            .ok_or_else(|| format!("{schema_label} requires g03_catalog_sha256"))?;
452        require_sha256("g03_catalog_sha256", catalog_sha256)?;
453        let abi_contract_sha256 = self
454            .abi_contract_sha256
455            .as_deref()
456            .ok_or_else(|| format!("{schema_label} requires abi_contract_sha256"))?;
457        require_sha256("abi_contract_sha256", abi_contract_sha256)?;
458
459        require_sorted_unique_symbols("exports", &self.exports)?;
460        let descriptor_export = self
461            .descriptor_export
462            .as_deref()
463            .ok_or_else(|| format!("{schema_label} requires descriptor_export"))?;
464        require_native_symbol("descriptor_export", descriptor_export)?;
465        if matches!(
466            descriptor_export,
467            "ferrum_native_op_init" | "ferrum_native_op_descriptor"
468        ) {
469            return Err(format!(
470                "{schema_label} descriptor_export must be namespaced per native operator"
471            ));
472        }
473        if !self
474            .exports
475            .iter()
476            .any(|export| export == descriptor_export)
477        {
478            return Err(format!(
479                "{schema_label} exports must include descriptor_export"
480            ));
481        }
482        if !allow_unbound_component && self.operation_bindings.is_empty() {
483            return Err("schema v2 requires at least one operation_binding".to_string());
484        }
485        if self.license_files.is_empty() {
486            return Err(format!(
487                "{schema_label} requires at least one license_files entry"
488            ));
489        }
490        if self.license_files.windows(2).any(|pair| pair[0] >= pair[1])
491            || self.license_files.iter().any(|path| {
492                path.is_empty()
493                    || path.starts_with('/')
494                    || path.split('/').any(|component| component == "..")
495            })
496        {
497            return Err(format!(
498                "{schema_label} license_files must be sorted, unique, non-empty relative paths"
499            ));
500        }
501        if !is_git_oid(&self.build_summary.builder_sha) {
502            return Err(format!(
503                "{schema_label} build_summary.builder_sha must be a lowercase 40- or 64-hex git object id"
504            ));
505        }
506        if self.backend == NativeOperatorBackend::Cuda {
507            require_non_empty(
508                "cuda_toolkit",
509                self.cuda_toolkit.as_deref().unwrap_or_default(),
510            )?;
511            require_non_empty(
512                "cuda_runtime_min",
513                self.cuda_runtime_min.as_deref().unwrap_or_default(),
514            )?;
515            require_non_empty(
516                "build_summary.nvcc_version",
517                self.build_summary
518                    .nvcc_version
519                    .as_deref()
520                    .unwrap_or_default(),
521            )?;
522        }
523
524        let mut previous_key: Option<(&str, &str)> = None;
525        let mut keys = BTreeSet::new();
526        for (index, binding) in self.operation_bindings.iter().enumerate() {
527            let label = format!("operation_bindings[{index}]");
528            require_contract_identifier(
529                &format!("{label}.operation_id"),
530                &binding.operation_id,
531                "operation.",
532            )?;
533            require_contract_identifier(
534                &format!("{label}.provider_id"),
535                &binding.provider_id,
536                "provider.",
537            )?;
538            if binding.operation_contract_version.major == 0 {
539                return Err(format!(
540                    "{label}.operation_contract_version major must be positive"
541                ));
542            }
543            if binding.provider_version.major == 0 {
544                return Err(format!("{label}.provider_version major must be positive"));
545            }
546            require_sha256(
547                &format!("{label}.provider_implementation_fingerprint"),
548                &binding.provider_implementation_fingerprint,
549            )?;
550            require_sorted_unique_symbols(&format!("{label}.entrypoints"), &binding.entrypoints)?;
551            for entrypoint in &binding.entrypoints {
552                if !self.exports.iter().any(|export| export == entrypoint) {
553                    return Err(format!(
554                        "{label}.entrypoints contains {entrypoint}, which is missing from exports"
555                    ));
556                }
557            }
558            let key = (binding.operation_id.as_str(), binding.provider_id.as_str());
559            if let Some(previous) = previous_key {
560                if previous >= key {
561                    return Err(
562                        "operation_bindings must be sorted and unique by operation_id/provider_id"
563                            .to_string(),
564                    );
565                }
566            }
567            if !keys.insert((binding.operation_id.clone(), binding.provider_id.clone())) {
568                return Err(
569                    "operation_bindings contains a duplicate operation/provider".to_string()
570                );
571            }
572            previous_key = Some(key);
573        }
574        Ok(())
575    }
576}
577
578pub fn resolve_native_operator_manifest(
579    manifest: Option<&NativeOperatorManifest>,
580    requirement: &NativeOperatorRequirement,
581) -> std::result::Result<NativeOperatorResolution, String> {
582    let manifest = manifest.ok_or_else(|| "native operator manifest is missing".to_string())?;
583    manifest.validate()?;
584    if manifest.operator != requirement.operator {
585        return Err(format!(
586            "native operator mismatch: manifest={} required={}",
587            manifest.operator, requirement.operator
588        ));
589    }
590    if manifest.backend != requirement.backend {
591        return Err(format!(
592            "native operator backend mismatch: manifest={:?} required={:?}",
593            manifest.backend, requirement.backend
594        ));
595    }
596    if manifest.operator_abi_version != requirement.operator_abi_version {
597        return Err(format!(
598            "native operator ABI mismatch: manifest={} required={}",
599            manifest.operator_abi_version, requirement.operator_abi_version
600        ));
601    }
602    if manifest.ferrum_native_abi_version != requirement.ferrum_native_abi_version {
603        return Err(format!(
604            "Ferrum native ABI mismatch: manifest={} required={}",
605            manifest.ferrum_native_abi_version, requirement.ferrum_native_abi_version
606        ));
607    }
608    if let Some(required_capability) = requirement.compute_capability.as_deref() {
609        if !manifest
610            .compute_capabilities
611            .iter()
612            .any(|capability| capability == required_capability)
613        {
614            return Err(format!(
615                "compute capability mismatch: manifest={:?} required={}",
616                manifest.compute_capabilities, required_capability
617            ));
618        }
619    }
620    if let Some(expected) = requirement.source_package_sha256.as_deref() {
621        require_expected_sha256(
622            "source_package.sha256",
623            &manifest.source_package.sha256,
624            expected,
625        )?;
626    }
627    if let Some(expected) = requirement.inputs_sha256.as_deref() {
628        require_expected_sha256("inputs_sha256", &manifest.inputs_sha256, expected)?;
629    }
630    if let Some(expected) = requirement.binary_sha256.as_deref() {
631        require_expected_sha256("binary_sha256", &manifest.binary_sha256, expected)?;
632    }
633    if let Some(expected) = requirement.g03_catalog_sha256.as_deref() {
634        require_expected_optional_sha256(
635            "g03_catalog_sha256",
636            manifest.g03_catalog_sha256.as_deref(),
637            expected,
638        )?;
639    }
640    if let Some(expected) = requirement.abi_contract_sha256.as_deref() {
641        require_expected_optional_sha256(
642            "abi_contract_sha256",
643            manifest.abi_contract_sha256.as_deref(),
644            expected,
645        )?;
646    }
647    if let Some(expected) = requirement.descriptor_export.as_deref() {
648        if manifest.descriptor_export.as_deref() != Some(expected) {
649            return Err(format!(
650                "descriptor_export mismatch: manifest={:?} expected={expected}",
651                manifest.descriptor_export
652            ));
653        }
654    }
655    for required_export in &requirement.required_exports {
656        if !manifest
657            .exports
658            .iter()
659            .any(|export| export == required_export)
660        {
661            return Err(format!(
662                "required export is missing from manifest: {required_export}"
663            ));
664        }
665    }
666    if let Some(expected) = requirement.operation_bindings.as_ref() {
667        if &manifest.operation_bindings != expected {
668            return Err("operation_bindings mismatch".to_string());
669        }
670    }
671    Ok(NativeOperatorResolution {
672        operator: manifest.operator.clone(),
673        backend: manifest.backend,
674        linkage: manifest.linkage,
675        binary_sha256: manifest.binary_sha256.clone(),
676        g03_catalog_sha256: manifest.g03_catalog_sha256.clone(),
677        abi_contract_sha256: manifest.abi_contract_sha256.clone(),
678    })
679}
680
681fn require_non_empty(field: &str, value: &str) -> std::result::Result<(), String> {
682    if value.trim().is_empty() {
683        Err(format!("{field} must be non-empty"))
684    } else {
685        Ok(())
686    }
687}
688
689fn require_sha256(field: &str, value: &str) -> std::result::Result<(), String> {
690    if is_sha256_digest(value) {
691        Ok(())
692    } else {
693        Err(format!("{field} must be a lowercase hex sha256 digest"))
694    }
695}
696
697fn require_expected_sha256(
698    field: &str,
699    actual: &str,
700    expected: &str,
701) -> std::result::Result<(), String> {
702    require_sha256(field, actual)?;
703    require_sha256(&format!("expected {field}"), expected)?;
704    if actual.eq_ignore_ascii_case(expected) {
705        Ok(())
706    } else {
707        Err(format!(
708            "{field} mismatch: manifest={actual} expected={expected}"
709        ))
710    }
711}
712
713fn require_expected_optional_sha256(
714    field: &str,
715    actual: Option<&str>,
716    expected: &str,
717) -> std::result::Result<(), String> {
718    let actual = actual.ok_or_else(|| format!("{field} is missing"))?;
719    require_expected_sha256(field, actual, expected)
720}
721
722fn require_sorted_unique_symbols(
723    field: &str,
724    symbols: &[String],
725) -> std::result::Result<(), String> {
726    if symbols.is_empty() {
727        return Err(format!("{field} must be non-empty"));
728    }
729    let mut previous: Option<&str> = None;
730    for symbol in symbols {
731        require_native_symbol(field, symbol)?;
732        if previous.is_some_and(|value| value >= symbol.as_str()) {
733            return Err(format!("{field} must be sorted and unique"));
734        }
735        previous = Some(symbol);
736    }
737    Ok(())
738}
739
740fn require_native_symbol(field: &str, value: &str) -> std::result::Result<(), String> {
741    let mut chars = value.chars();
742    let Some(first) = chars.next() else {
743        return Err(format!("{field} must be non-empty"));
744    };
745    if !(first == '_' || first.is_ascii_alphabetic())
746        || !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
747    {
748        return Err(format!(
749            "{field} contains an invalid native symbol: {value}"
750        ));
751    }
752    Ok(())
753}
754
755fn require_contract_identifier(
756    field: &str,
757    value: &str,
758    prefix: &str,
759) -> std::result::Result<(), String> {
760    if !value.starts_with(prefix)
761        || !value
762            .chars()
763            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
764    {
765        return Err(format!(
766            "{field} must start with {prefix} and contain only canonical identifier characters"
767        ));
768    }
769    Ok(())
770}
771
772pub fn is_sha256_digest(value: &str) -> bool {
773    value.len() == 64
774        && value
775            .bytes()
776            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
777}
778
779fn is_git_oid(value: &str) -> bool {
780    matches!(value.len(), 40 | 64)
781        && value
782            .bytes()
783            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789
790    fn digest(ch: char) -> String {
791        std::iter::repeat(ch).take(64).collect()
792    }
793
794    fn manifest() -> NativeOperatorManifest {
795        NativeOperatorManifest {
796            schema_version: NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION,
797            operator: "fa2".to_string(),
798            operator_abi_version: "1".to_string(),
799            ferrum_native_abi_version: FERRUM_NATIVE_OPERATOR_ABI_VERSION.to_string(),
800            backend: NativeOperatorBackend::Cuda,
801            cuda_toolkit: Some("12.4".to_string()),
802            cuda_runtime_min: Some("12.4".to_string()),
803            compute_capabilities: vec!["sm_89".to_string()],
804            source_package: NativeOperatorSourcePackage {
805                kind: "external_archive".to_string(),
806                revision: "rev".to_string(),
807                sha256: digest('a'),
808            },
809            inputs_sha256: digest('b'),
810            binary_sha256: digest('c'),
811            linkage: NativeOperatorLinkage::Static,
812            g03_catalog_sha256: Some(digest('d')),
813            abi_contract_sha256: Some(digest('e')),
814            descriptor_export: Some("ferrum_native_fa2_descriptor_v2".to_string()),
815            operation_bindings: vec![NativeOperatorBinding {
816                operation_id: "operation.causal_paged_attention".to_string(),
817                operation_contract_version: NativeOperatorContractVersion::new(1, 0),
818                provider_id: "provider.cuda.fa2".to_string(),
819                provider_version: NativeOperatorContractVersion::new(1, 0),
820                provider_implementation_fingerprint: digest('f'),
821                entrypoints: vec!["ferrum_native_fa2_execute_v1".to_string()],
822            }],
823            exports: vec![
824                "ferrum_native_fa2_descriptor_v2".to_string(),
825                "ferrum_native_fa2_execute_v1".to_string(),
826            ],
827            license_files: vec!["LICENSE".to_string()],
828            build_summary: NativeOperatorBuildSummary {
829                builder_sha: digest('7'),
830                elapsed_ms: 1,
831                nvcc_version: Some("12.4".to_string()),
832                host_compiler: "clang".to_string(),
833            },
834        }
835    }
836
837    #[test]
838    fn validates_required_hashes_and_cuda_capability() {
839        manifest().validate().unwrap();
840
841        let mut missing_hash = manifest();
842        missing_hash.binary_sha256.clear();
843        assert!(missing_hash.validate().is_err());
844
845        let mut bad_capability = manifest();
846        bad_capability.compute_capabilities = vec!["rtx4090".to_string()];
847        assert!(bad_capability.validate().is_err());
848    }
849
850    #[test]
851    fn resolver_fails_closed_for_missing_or_mismatched_manifest() {
852        let mut requirement = NativeOperatorRequirement::cuda("fa2", "sm_89");
853        requirement.source_package_sha256 = Some(digest('a'));
854        requirement.inputs_sha256 = Some(digest('b'));
855        requirement.binary_sha256 = Some(digest('c'));
856        requirement.g03_catalog_sha256 = Some(digest('d'));
857        requirement.abi_contract_sha256 = Some(digest('e'));
858        requirement.descriptor_export = Some("ferrum_native_fa2_descriptor_v2".to_string());
859        requirement.required_exports = vec!["ferrum_native_fa2_execute_v1".to_string()];
860        requirement.operation_bindings = Some(manifest().operation_bindings);
861
862        let resolution = resolve_native_operator_manifest(Some(&manifest()), &requirement).unwrap();
863        assert_eq!(resolution.operator, "fa2");
864        assert_eq!(resolution.binary_sha256, digest('c'));
865
866        assert!(resolve_native_operator_manifest(None, &requirement).is_err());
867
868        let mut bad_binary = requirement.clone();
869        bad_binary.binary_sha256 = Some(digest('d'));
870        assert!(resolve_native_operator_manifest(Some(&manifest()), &bad_binary).is_err());
871
872        let mut bad_abi = manifest();
873        bad_abi.operator_abi_version = "2".to_string();
874        assert!(resolve_native_operator_manifest(Some(&bad_abi), &requirement).is_err());
875
876        let bad_capability = NativeOperatorRequirement::cuda("fa2", "sm_90");
877        assert!(resolve_native_operator_manifest(Some(&manifest()), &bad_capability).is_err());
878
879        let wrong_operator = NativeOperatorRequirement::cuda("dummy", "sm_89");
880        assert!(resolve_native_operator_manifest(Some(&manifest()), &wrong_operator).is_err());
881    }
882
883    #[test]
884    fn versioned_schema_rejects_legacy_shared_descriptor_symbols() {
885        let mut invalid = manifest();
886        invalid.descriptor_export = Some("ferrum_native_op_descriptor".to_string());
887        invalid.exports = vec![
888            "ferrum_native_fa2_execute_v1".to_string(),
889            "ferrum_native_op_descriptor".to_string(),
890        ];
891        assert!(invalid.validate().is_err());
892    }
893
894    #[test]
895    fn schema_v3_allows_a_native_leaf_without_a_g03_consumer() {
896        let mut component = manifest();
897        component.operation_bindings.clear();
898        component.validate().unwrap();
899
900        component.schema_version = PROVIDER_BOUND_NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION;
901        assert!(component
902            .validate()
903            .unwrap_err()
904            .contains("schema v2 requires at least one operation_binding"));
905    }
906
907    #[test]
908    fn provider_catalog_and_abi_contract_validate_exact_versioned_identity() {
909        let version = NativeOperatorContractVersion::new(1, 2);
910        let mut catalog = NativeOperatorProviderCatalog {
911            schema_version: NATIVE_OPERATOR_PROVIDER_CATALOG_SCHEMA_VERSION,
912            backend: NativeOperatorBackend::Cuda,
913            providers: vec![NativeOperatorProviderCatalogRow {
914                operation_id: "operation.alpha".to_string(),
915                operation_contract_version: version,
916                operation_fingerprint: digest('1'),
917                provider_id: "provider.cuda.alpha".to_string(),
918                provider_version: version,
919                provider_implementation_fingerprint: digest('2'),
920            }],
921        };
922        catalog.validate().unwrap();
923        let canonical = catalog.canonical_json_bytes().unwrap();
924        assert_eq!(
925            catalog.canonical_sha256().unwrap(),
926            format!("{:x}", Sha256::digest(&canonical))
927        );
928        catalog.backend = NativeOperatorBackend::Metal;
929        assert!(catalog.validate().is_err());
930        catalog.backend = NativeOperatorBackend::Cuda;
931        catalog.providers[0].provider_implementation_fingerprint = "not-a-digest".to_string();
932        assert!(catalog.validate().is_err());
933
934        let mut abi = NativeOperatorAbiContract {
935            schema_version: NATIVE_OPERATOR_ABI_CONTRACT_SCHEMA_VERSION,
936            ferrum_native_abi_version: FERRUM_NATIVE_OPERATOR_ABI_VERSION.to_string(),
937            descriptor_struct: "FerrumNativeOperatorDescriptorV2".to_string(),
938            descriptor_symbol_policy: "operator_namespaced".to_string(),
939            descriptor_fields: [
940                ("struct_size", "uint32_t"),
941                ("ferrum_native_abi_version", "uint32_t"),
942                ("operator_name", "const char *"),
943                ("operator_abi_version", "const char *"),
944                ("g03_catalog_sha256", "const char *"),
945                ("abi_contract_sha256", "const char *"),
946            ]
947            .into_iter()
948            .map(|(name, c_type)| NativeOperatorAbiField {
949                name: name.to_string(),
950                c_type: c_type.to_string(),
951            })
952            .collect(),
953        };
954        abi.validate().unwrap();
955        assert_eq!(
956            abi.canonical_sha256().unwrap(),
957            format!("{:x}", Sha256::digest(abi.canonical_json_bytes().unwrap()))
958        );
959        abi.descriptor_fields.swap(0, 1);
960        assert!(abi.validate().is_err());
961    }
962
963    #[test]
964    fn legacy_schema_v1_remains_read_only_compatible() {
965        let mut legacy = manifest();
966        legacy.schema_version = LEGACY_NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION;
967        legacy.ferrum_native_abi_version = LEGACY_FERRUM_NATIVE_OPERATOR_ABI_VERSION.to_string();
968        legacy.g03_catalog_sha256 = None;
969        legacy.abi_contract_sha256 = None;
970        legacy.descriptor_export = None;
971        legacy.operation_bindings.clear();
972        legacy.exports = vec![
973            "ferrum_native_op_init".to_string(),
974            "ferrum_native_op_descriptor".to_string(),
975        ];
976        legacy.validate().unwrap();
977    }
978}