1use 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)]
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}