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