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