1use std::fs;
4use std::io::{self, Read};
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use ferrum_types::{
9 resolve_native_operator_manifest, NativeOperatorBackend, NativeOperatorBinding,
10 NativeOperatorCompilerFlavor, NativeOperatorHostAbi, NativeOperatorLinkage,
11 NativeOperatorManifest, NativeOperatorRequirement, DEFAULT_NATIVE_OPERATOR_ABI_VERSION,
12};
13use sha2::{Digest, Sha256};
14use thiserror::Error;
15
16use crate::abi::FERRUM_NATIVE_ABI_VERSION;
17use crate::manifest::load_manifest;
18
19pub type Result<T> = std::result::Result<T, NativeOperatorResolveError>;
20
21#[derive(Debug, Error)]
22pub enum NativeOperatorResolveError {
23 #[error("native operator manifest does not exist: {0}")]
24 ManifestMissing(PathBuf),
25 #[error("native operator artifact does not exist: {0}")]
26 ArtifactMissing(PathBuf),
27 #[error("native operator artifact must not be a Python wheel: {0}")]
28 PythonWheelArtifact(PathBuf),
29 #[error("failed to read native operator manifest {path}: {source}")]
30 ManifestRead { path: PathBuf, source: io::Error },
31 #[error("failed to parse native operator manifest {path}: {source}")]
32 ManifestJson {
33 path: PathBuf,
34 source: serde_json::Error,
35 },
36 #[error("invalid native operator manifest: {0}")]
37 ManifestInvalid(String),
38 #[error("native operator mismatch: expected {expected}, got {actual}")]
39 OperatorMismatch { expected: String, actual: String },
40 #[error("native operator backend mismatch: expected {expected:?}, got {actual:?}")]
41 BackendMismatch {
42 expected: NativeOperatorBackend,
43 actual: NativeOperatorBackend,
44 },
45 #[error("native operator ABI mismatch: expected {expected}, got {actual}")]
46 AbiMismatch { expected: String, actual: String },
47 #[error("native operator compute capability mismatch: expected {expected}")]
48 ComputeCapabilityMismatch { expected: String },
49 #[error("failed to read native operator artifact {path}: {source}")]
50 ArtifactRead { path: PathBuf, source: io::Error },
51 #[error("native operator artifact sha256 mismatch: expected {expected}, got {actual}")]
52 ArtifactSha256Mismatch { expected: String, actual: String },
53 #[error("native operator artifact suffix mismatch for {path}: linkage={linkage:?}")]
54 ArtifactSuffixMismatch {
55 path: PathBuf,
56 linkage: NativeOperatorLinkage,
57 },
58 #[error("native operator static archive is empty: {0}")]
59 ArtifactArchiveEmpty(PathBuf),
60 #[error(
61 "native operator artifact tool failed: tool={tool} path={path} status={status} stderr={stderr}"
62 )]
63 ArtifactToolFailed {
64 tool: String,
65 path: PathBuf,
66 status: String,
67 stderr: String,
68 },
69 #[error("native operator artifact missing required exports in {path}: {missing:?}")]
70 ArtifactMissingExports { path: PathBuf, missing: Vec<String> },
71 #[error("native operator host ABI mismatch: {0}")]
72 HostAbiMismatch(String),
73 #[error("invalid native operator COFF library {path}: {reason}")]
74 InvalidCoffLibrary { path: PathBuf, reason: String },
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct NativeOperatorResolveRequest {
79 pub operator: String,
80 pub backend: NativeOperatorBackend,
81 pub compute_capability: Option<String>,
82 pub manifest_path: PathBuf,
83 pub artifact_path: PathBuf,
84 pub operator_abi_version: String,
85 pub ferrum_native_abi_version: String,
86 pub g03_catalog_sha256: Option<String>,
87 pub abi_contract_sha256: Option<String>,
88 pub descriptor_export: Option<String>,
89 pub required_exports: Vec<String>,
90 pub operation_bindings: Option<Vec<NativeOperatorBinding>>,
91 pub host_abi: Option<NativeOperatorHostAbi>,
92}
93
94impl NativeOperatorResolveRequest {
95 pub fn new(
96 operator: impl Into<String>,
97 backend: NativeOperatorBackend,
98 manifest_path: impl Into<PathBuf>,
99 artifact_path: impl Into<PathBuf>,
100 ) -> Self {
101 Self {
102 operator: operator.into(),
103 backend,
104 compute_capability: None,
105 manifest_path: manifest_path.into(),
106 artifact_path: artifact_path.into(),
107 operator_abi_version: DEFAULT_NATIVE_OPERATOR_ABI_VERSION.to_string(),
108 ferrum_native_abi_version: FERRUM_NATIVE_ABI_VERSION.to_string(),
109 g03_catalog_sha256: None,
110 abi_contract_sha256: None,
111 descriptor_export: None,
112 required_exports: Vec::new(),
113 operation_bindings: None,
114 host_abi: None,
115 }
116 }
117
118 pub fn with_compute_capability(mut self, compute_capability: impl Into<String>) -> Self {
119 self.compute_capability = Some(compute_capability.into());
120 self
121 }
122
123 pub fn with_host_abi(mut self, host_abi: NativeOperatorHostAbi) -> Self {
124 self.host_abi = Some(host_abi);
125 self
126 }
127
128 pub fn with_ferrum_native_abi_version(mut self, version: impl Into<String>) -> Self {
129 self.ferrum_native_abi_version = version.into();
130 self
131 }
132
133 pub fn with_operator_abi_version(mut self, version: impl Into<String>) -> Self {
134 self.operator_abi_version = version.into();
135 self
136 }
137
138 pub fn with_g03_catalog_sha256(mut self, sha256: impl Into<String>) -> Self {
139 self.g03_catalog_sha256 = Some(sha256.into());
140 self
141 }
142
143 pub fn with_abi_contract_sha256(mut self, sha256: impl Into<String>) -> Self {
144 self.abi_contract_sha256 = Some(sha256.into());
145 self
146 }
147
148 pub fn with_descriptor_export(mut self, symbol: impl Into<String>) -> Self {
149 self.descriptor_export = Some(symbol.into());
150 self
151 }
152
153 pub fn with_required_exports(mut self, exports: impl IntoIterator<Item = String>) -> Self {
154 self.required_exports = exports.into_iter().collect();
155 self
156 }
157
158 pub fn with_operation_bindings(
159 mut self,
160 bindings: impl IntoIterator<Item = NativeOperatorBinding>,
161 ) -> Self {
162 self.operation_bindings = Some(bindings.into_iter().collect());
163 self
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct ResolvedNativeOperator {
169 pub manifest: NativeOperatorManifest,
170 pub manifest_path: PathBuf,
171 pub artifact_path: PathBuf,
172 pub artifact_sha256: String,
173 pub binary_validation: NativeOperatorBinaryValidation,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub enum NativeOperatorArtifactFormat {
178 StaticArchive,
179 DynamicLibrary,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct NativeOperatorBinaryValidation {
184 pub format: NativeOperatorArtifactFormat,
185 pub archive_members: Vec<String>,
186 pub defined_symbols: Vec<String>,
187 pub strong_defined_symbols: Vec<String>,
188 pub required_exports: Vec<String>,
189 pub matched_exports: Vec<String>,
190}
191
192#[derive(Debug, Default, Clone)]
193pub struct NativeOperatorResolver;
194
195impl NativeOperatorResolver {
196 pub fn resolve(
197 &self,
198 request: &NativeOperatorResolveRequest,
199 ) -> Result<ResolvedNativeOperator> {
200 if !request.manifest_path.is_file() {
201 return Err(NativeOperatorResolveError::ManifestMissing(
202 request.manifest_path.clone(),
203 ));
204 }
205 if request
206 .artifact_path
207 .extension()
208 .is_some_and(|extension| extension == "whl")
209 {
210 return Err(NativeOperatorResolveError::PythonWheelArtifact(
211 request.artifact_path.clone(),
212 ));
213 }
214 if !request.artifact_path.is_file() {
215 return Err(NativeOperatorResolveError::ArtifactMissing(
216 request.artifact_path.clone(),
217 ));
218 }
219
220 let manifest = load_manifest(&request.manifest_path)?;
221 let default_host = if cfg!(target_os = "windows") {
222 let target = if cfg!(all(target_arch = "x86_64", target_env = "msvc")) {
223 "x86_64-pc-windows-msvc"
224 } else {
225 return Err(NativeOperatorResolveError::HostAbiMismatch(
226 "unsupported Windows native operator host".into(),
227 ));
228 };
229 Some(
230 NativeOperatorHostAbi::for_target(target)
231 .map_err(NativeOperatorResolveError::HostAbiMismatch)?,
232 )
233 } else {
234 None
235 };
236 let expected_host = request.host_abi.as_ref().or(default_host.as_ref());
237 validate_host_abi(manifest.host_abi.as_ref(), expected_host)?;
238 if manifest.operator != request.operator {
239 return Err(NativeOperatorResolveError::OperatorMismatch {
240 expected: request.operator.clone(),
241 actual: manifest.operator.clone(),
242 });
243 }
244 if manifest.backend != request.backend {
245 return Err(NativeOperatorResolveError::BackendMismatch {
246 expected: request.backend,
247 actual: manifest.backend,
248 });
249 }
250 if manifest.ferrum_native_abi_version != request.ferrum_native_abi_version {
251 return Err(NativeOperatorResolveError::AbiMismatch {
252 expected: request.ferrum_native_abi_version.clone(),
253 actual: manifest.ferrum_native_abi_version.clone(),
254 });
255 }
256 if let Some(expected) = &request.compute_capability {
257 if !manifest
258 .compute_capabilities
259 .iter()
260 .any(|capability| capability == expected)
261 {
262 return Err(NativeOperatorResolveError::ComputeCapabilityMismatch {
263 expected: expected.clone(),
264 });
265 }
266 }
267 let requirement = NativeOperatorRequirement {
268 operator: request.operator.clone(),
269 backend: request.backend,
270 operator_abi_version: request.operator_abi_version.clone(),
271 ferrum_native_abi_version: request.ferrum_native_abi_version.clone(),
272 compute_capability: request.compute_capability.clone(),
273 source_package_sha256: None,
274 inputs_sha256: None,
275 binary_sha256: None,
276 g03_catalog_sha256: request.g03_catalog_sha256.clone(),
277 abi_contract_sha256: request.abi_contract_sha256.clone(),
278 descriptor_export: request.descriptor_export.clone(),
279 required_exports: request.required_exports.clone(),
280 operation_bindings: request.operation_bindings.clone(),
281 };
282 resolve_native_operator_manifest(Some(&manifest), &requirement)
283 .map_err(NativeOperatorResolveError::ManifestInvalid)?;
284
285 let artifact_sha256 = file_sha256(&request.artifact_path)?;
286 if artifact_sha256 != manifest.binary_sha256 {
287 return Err(NativeOperatorResolveError::ArtifactSha256Mismatch {
288 expected: manifest.binary_sha256.clone(),
289 actual: artifact_sha256,
290 });
291 }
292 let binary_validation = if expected_host
293 .is_some_and(|host| host.compiler_flavor == NativeOperatorCompilerFlavor::Msvc)
294 {
295 validate_msvc_artifact(
296 &request.artifact_path,
297 manifest.linkage,
298 &manifest.exports,
299 &expected_host.expect("checked MSVC host").target,
300 )?
301 } else {
302 validate_binary_artifact(&request.artifact_path, manifest.linkage, &manifest.exports)?
303 };
304 Ok(ResolvedNativeOperator {
305 manifest,
306 manifest_path: request.manifest_path.clone(),
307 artifact_path: request.artifact_path.clone(),
308 artifact_sha256,
309 binary_validation,
310 })
311 }
312}
313
314fn validate_host_abi(
315 actual: Option<&NativeOperatorHostAbi>,
316 expected: Option<&NativeOperatorHostAbi>,
317) -> Result<()> {
318 if let Some(expected) = expected {
319 expected
320 .validate()
321 .map_err(NativeOperatorResolveError::HostAbiMismatch)?;
322 if let Some(actual) = actual {
323 if actual != expected {
324 return Err(NativeOperatorResolveError::HostAbiMismatch(format!(
325 "expected {expected:?}, got {actual:?}"
326 )));
327 }
328 } else if expected.compiler_flavor == NativeOperatorCompilerFlavor::Msvc {
329 return Err(NativeOperatorResolveError::HostAbiMismatch(
330 "MSVC library has no declared target/CRT contract".into(),
331 ));
332 }
333 } else if actual.is_some_and(|host| host.compiler_flavor == NativeOperatorCompilerFlavor::Msvc)
334 {
335 return Err(NativeOperatorResolveError::HostAbiMismatch(
336 "MSVC library requires an explicit matching product target".into(),
337 ));
338 }
339 Ok(())
340}
341
342fn validate_msvc_artifact(
343 path: &Path,
344 linkage: NativeOperatorLinkage,
345 exports: &[String],
346 target: &str,
347) -> Result<NativeOperatorBinaryValidation> {
348 if linkage != NativeOperatorLinkage::Static
349 || !path
350 .extension()
351 .and_then(|extension| extension.to_str())
352 .is_some_and(|extension| extension.eq_ignore_ascii_case("lib"))
353 {
354 return Err(NativeOperatorResolveError::ArtifactSuffixMismatch {
355 path: path.to_path_buf(),
356 linkage,
357 });
358 }
359 let bytes = fs::read(path).map_err(|source| NativeOperatorResolveError::ArtifactRead {
360 path: path.to_path_buf(),
361 source,
362 })?;
363 let archive = crate::inspect_msvc_archive(&bytes, target).map_err(|reason| {
364 NativeOperatorResolveError::InvalidCoffLibrary {
365 path: path.to_path_buf(),
366 reason,
367 }
368 })?;
369 let missing = exports
370 .iter()
371 .filter(|symbol| !archive.defined_symbols.contains(symbol))
372 .cloned()
373 .collect::<Vec<_>>();
374 if !missing.is_empty() {
375 return Err(NativeOperatorResolveError::ArtifactMissingExports {
376 path: path.to_path_buf(),
377 missing,
378 });
379 }
380 archive.require_unique_exports(exports).map_err(|reason| {
381 NativeOperatorResolveError::InvalidCoffLibrary {
382 path: path.to_path_buf(),
383 reason,
384 }
385 })?;
386 Ok(NativeOperatorBinaryValidation {
387 format: NativeOperatorArtifactFormat::StaticArchive,
388 archive_members: archive
389 .members
390 .into_iter()
391 .map(|member| member.name)
392 .collect(),
393 defined_symbols: archive.defined_symbols,
394 strong_defined_symbols: archive.strong_defined_symbols,
395 required_exports: exports.to_vec(),
396 matched_exports: exports.to_vec(),
397 })
398}
399
400fn file_sha256(path: &Path) -> Result<String> {
401 let mut file =
402 fs::File::open(path).map_err(|source| NativeOperatorResolveError::ArtifactRead {
403 path: path.to_path_buf(),
404 source,
405 })?;
406 let mut hasher = Sha256::new();
407 let mut buf = [0_u8; 16 * 1024];
408 loop {
409 let n = file
410 .read(&mut buf)
411 .map_err(|source| NativeOperatorResolveError::ArtifactRead {
412 path: path.to_path_buf(),
413 source,
414 })?;
415 if n == 0 {
416 break;
417 }
418 hasher.update(&buf[..n]);
419 }
420 Ok(format!("{:x}", hasher.finalize()))
421}
422
423fn validate_binary_artifact(
424 path: &Path,
425 linkage: NativeOperatorLinkage,
426 exports: &[String],
427) -> Result<NativeOperatorBinaryValidation> {
428 let (format, archive_members) = match linkage {
429 NativeOperatorLinkage::Static => {
430 if path.extension().and_then(|extension| extension.to_str()) != Some("a") {
431 return Err(NativeOperatorResolveError::ArtifactSuffixMismatch {
432 path: path.to_path_buf(),
433 linkage,
434 });
435 }
436 let output = run_artifact_tool("ar", &["t"], path)?;
437 let members = output
438 .lines()
439 .map(str::trim)
440 .filter(|line| !line.is_empty())
441 .map(ToOwned::to_owned)
442 .collect::<Vec<_>>();
443 if members.is_empty() {
444 return Err(NativeOperatorResolveError::ArtifactArchiveEmpty(
445 path.to_path_buf(),
446 ));
447 }
448 (NativeOperatorArtifactFormat::StaticArchive, members)
449 }
450 NativeOperatorLinkage::Dynamic => {
451 let name = path
452 .file_name()
453 .and_then(|name| name.to_str())
454 .unwrap_or("");
455 let suffix_ok = path
456 .extension()
457 .and_then(|extension| extension.to_str())
458 .is_some_and(|extension| extension == "dylib" || extension == "so")
459 || name.contains(".so.");
460 if !suffix_ok {
461 return Err(NativeOperatorResolveError::ArtifactSuffixMismatch {
462 path: path.to_path_buf(),
463 linkage,
464 });
465 }
466 (NativeOperatorArtifactFormat::DynamicLibrary, Vec::new())
467 }
468 };
469
470 let nm_output = run_artifact_tool("nm", &["-g"], path)?;
471 let (defined_symbols, strong_defined_symbols) = collect_defined_symbols(&nm_output);
472 let missing = exports
473 .iter()
474 .filter(|export| !defined_symbols.contains(export.as_str()))
475 .cloned()
476 .collect::<Vec<_>>();
477 if !missing.is_empty() {
478 return Err(NativeOperatorResolveError::ArtifactMissingExports {
479 path: path.to_path_buf(),
480 missing,
481 });
482 }
483
484 Ok(NativeOperatorBinaryValidation {
485 format,
486 archive_members,
487 defined_symbols: defined_symbols.into_iter().collect(),
488 strong_defined_symbols: strong_defined_symbols.into_iter().collect(),
489 required_exports: exports.to_vec(),
490 matched_exports: exports.to_vec(),
491 })
492}
493
494fn run_artifact_tool(program: &str, args: &[&str], path: &Path) -> Result<String> {
495 let output = Command::new(program)
496 .args(args)
497 .arg(path)
498 .output()
499 .map_err(|source| NativeOperatorResolveError::ArtifactRead {
500 path: path.to_path_buf(),
501 source,
502 })?;
503 if !output.status.success() {
504 let stderr = String::from_utf8_lossy(&output.stderr)
505 .trim()
506 .chars()
507 .take(1000)
508 .collect::<String>();
509 return Err(NativeOperatorResolveError::ArtifactToolFailed {
510 tool: program.to_string(),
511 path: path.to_path_buf(),
512 status: output.status.to_string(),
513 stderr,
514 });
515 }
516 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
517}
518
519fn collect_defined_symbols(
520 nm_output: &str,
521) -> (
522 std::collections::BTreeSet<String>,
523 std::collections::BTreeSet<String>,
524) {
525 let mut symbols = std::collections::BTreeSet::new();
526 let mut strong_symbols = std::collections::BTreeSet::new();
527 for raw_line in nm_output.lines() {
528 let line = raw_line.trim();
529 if line.is_empty() || line.ends_with(':') {
530 continue;
531 }
532 let parts = line.split_whitespace().collect::<Vec<_>>();
533 if parts.len() < 2 {
534 continue;
535 }
536 let mut symbol_type = None;
537 let mut symbol = None;
538 if parts.len() >= 3 && parts[parts.len() - 2].len() == 1 {
539 symbol_type = parts.get(parts.len() - 2).copied();
540 symbol = parts.last().copied();
541 } else if parts[0].len() == 1 {
542 symbol_type = parts.first().copied();
543 symbol = parts.last().copied();
544 }
545 let (Some(symbol_type), Some(symbol)) = (symbol_type, symbol) else {
546 continue;
547 };
548 if matches!(symbol_type, "U" | "u" | "w" | "v") {
549 continue;
550 }
551 symbols.insert(symbol.to_string());
552 if !matches!(symbol_type, "W" | "V") {
553 strong_symbols.insert(symbol.to_string());
554 }
555 if let Some(stripped) = symbol.strip_prefix('_') {
556 symbols.insert(stripped.to_string());
557 if !matches!(symbol_type, "W" | "V") {
558 strong_symbols.insert(stripped.to_string());
559 }
560 }
561 }
562 (symbols, strong_symbols)
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568 use ferrum_types::{
569 NativeOperatorBinding, NativeOperatorBuildSummary, NativeOperatorLinkage,
570 NativeOperatorSourcePackage, NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION,
571 };
572 use std::sync::atomic::{AtomicU64, Ordering};
573 use std::time::{SystemTime, UNIX_EPOCH};
574
575 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
576
577 fn digest_bytes(bytes: &[u8]) -> String {
578 format!("{:x}", Sha256::digest(bytes))
579 }
580
581 fn digest(ch: char) -> String {
582 std::iter::repeat(ch).take(64).collect()
583 }
584
585 struct TestDir(PathBuf);
586
587 impl TestDir {
588 fn path(&self) -> &Path {
589 &self.0
590 }
591 }
592
593 impl Drop for TestDir {
594 fn drop(&mut self) {
595 let _ = fs::remove_dir_all(&self.0);
596 }
597 }
598
599 struct TestFixture {
600 _dir: TestDir,
601 manifest: PathBuf,
602 artifact: PathBuf,
603 }
604
605 fn temp_dir(name: &str) -> TestDir {
606 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
607 let unique = SystemTime::now()
608 .duration_since(UNIX_EPOCH)
609 .unwrap()
610 .as_nanos();
611 let dir = std::env::temp_dir().join(format!(
612 "ferrum-native-ops-{name}-{}-{counter}-{unique}",
613 std::process::id()
614 ));
615 fs::create_dir_all(&dir).unwrap();
616 TestDir(dir)
617 }
618
619 fn write_manifest(
620 path: &Path,
621 binary_sha256: String,
622 abi: &str,
623 caps: Vec<String>,
624 exports: Vec<String>,
625 ) {
626 let manifest = NativeOperatorManifest {
627 schema_version: NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION,
628 operator: "dummy".to_string(),
629 operator_abi_version: "1".to_string(),
630 ferrum_native_abi_version: abi.to_string(),
631 backend: NativeOperatorBackend::Cuda,
632 cuda_toolkit: Some("12.4".to_string()),
633 cuda_runtime_min: Some("12.4".to_string()),
634 compute_capabilities: caps,
635 source_package: NativeOperatorSourcePackage {
636 kind: "external_archive".to_string(),
637 revision: "fixture".to_string(),
638 sha256: digest('a'),
639 },
640 inputs_sha256: digest('b'),
641 binary_sha256,
642 linkage: NativeOperatorLinkage::Static,
643 host_abi: cfg!(windows)
644 .then(|| NativeOperatorHostAbi::for_target("x86_64-pc-windows-msvc").unwrap()),
645 g03_catalog_sha256: Some(digest('c')),
646 abi_contract_sha256: Some(digest('d')),
647 descriptor_export: Some("ferrum_native_dummy_descriptor_v2".to_string()),
648 operation_bindings: vec![NativeOperatorBinding {
649 operation_id: "operation.dummy".to_string(),
650 operation_contract_version: ferrum_types::NativeOperatorContractVersion::new(1, 0),
651 provider_id: "provider.cuda.dummy".to_string(),
652 provider_version: ferrum_types::NativeOperatorContractVersion::new(1, 0),
653 provider_implementation_fingerprint: digest('e'),
654 entrypoints: vec!["ferrum_native_dummy_execute_v1".to_string()],
655 }],
656 exports,
657 license_files: vec!["LICENSE".to_string()],
658 build_summary: NativeOperatorBuildSummary {
659 builder_sha: digest('7'),
660 elapsed_ms: 1,
661 nvcc_version: Some("12.4".to_string()),
662 host_compiler: "clang".to_string(),
663 },
664 };
665 fs::write(path, serde_json::to_string_pretty(&manifest).unwrap()).unwrap();
666 }
667
668 fn required_exports() -> Vec<String> {
669 vec![
670 "ferrum_native_dummy_descriptor_v2".to_string(),
671 "ferrum_native_dummy_execute_v1".to_string(),
672 ]
673 }
674
675 fn write_static_archive(dir: &Path, include_descriptor: bool) -> PathBuf {
676 if cfg!(windows) {
677 let mut exports = vec!["ferrum_native_dummy_execute_v1"];
678 if include_descriptor {
679 exports.push("ferrum_native_dummy_descriptor_v2");
680 }
681 let archive = dir.join("ferrum_native_dummy.lib");
682 fs::write(&archive, crate::coff::tests::native_library(&exports)).unwrap();
683 return archive;
684 }
685 let source = dir.join("native_op.c");
686 let mut source_text =
687 String::from("int ferrum_native_dummy_execute_v1(void) { return 0; }\n");
688 if include_descriptor {
689 source_text.push_str(
690 "const char *ferrum_native_dummy_descriptor_v2(void) { return \"dummy\"; }\n",
691 );
692 }
693 fs::write(&source, source_text).unwrap();
694 let object = dir.join("native_op.o");
695 let archive = dir.join("libferrum_native_dummy.a");
696 let cc_status = Command::new("cc")
697 .arg("-c")
698 .arg(&source)
699 .arg("-o")
700 .arg(&object)
701 .status()
702 .unwrap();
703 assert!(cc_status.success());
704 let ar_status = Command::new("ar")
705 .arg("rcs")
706 .arg(&archive)
707 .arg(&object)
708 .status()
709 .unwrap();
710 assert!(ar_status.success());
711 archive
712 }
713
714 fn fixture() -> TestFixture {
715 let dir = temp_dir("resolver");
716 let artifact = write_static_archive(dir.path(), true);
717 let bytes = fs::read(&artifact).unwrap();
718 let manifest = dir.path().join("native_operator_manifest.json");
719 write_manifest(
720 &manifest,
721 digest_bytes(&bytes),
722 FERRUM_NATIVE_ABI_VERSION,
723 vec!["sm_89".to_string()],
724 required_exports(),
725 );
726 TestFixture {
727 _dir: dir,
728 manifest,
729 artifact,
730 }
731 }
732
733 fn request(manifest: &Path, artifact: &Path) -> NativeOperatorResolveRequest {
734 NativeOperatorResolveRequest::new("dummy", NativeOperatorBackend::Cuda, manifest, artifact)
735 .with_compute_capability("sm_89")
736 }
737
738 #[test]
739 fn resolves_matching_manifest_and_artifact() {
740 let fixture = fixture();
741 let resolved = NativeOperatorResolver
742 .resolve(&request(&fixture.manifest, &fixture.artifact))
743 .unwrap();
744 assert_eq!(resolved.manifest.operator, "dummy");
745 assert_eq!(resolved.artifact_path, fixture.artifact);
746 assert_eq!(
747 resolved.binary_validation.format,
748 NativeOperatorArtifactFormat::StaticArchive
749 );
750 assert_eq!(
751 resolved.binary_validation.required_exports,
752 required_exports()
753 );
754 }
755
756 #[test]
757 fn fails_closed_for_missing_manifest() {
758 let fixture = fixture();
759 let err = NativeOperatorResolver
760 .resolve(&request(
761 &fixture.manifest.with_file_name("missing.json"),
762 &fixture.artifact,
763 ))
764 .unwrap_err();
765 assert!(matches!(
766 err,
767 NativeOperatorResolveError::ManifestMissing(_)
768 ));
769 }
770
771 #[test]
772 fn fails_closed_for_hash_mismatch() {
773 let fixture = fixture();
774 fs::write(&fixture.artifact, b"changed").unwrap();
775 let err = NativeOperatorResolver
776 .resolve(&request(&fixture.manifest, &fixture.artifact))
777 .unwrap_err();
778 assert!(
779 matches!(
780 err,
781 NativeOperatorResolveError::ArtifactSha256Mismatch { .. }
782 ),
783 "{err:?}"
784 );
785 }
786
787 #[test]
788 fn fails_closed_for_abi_mismatch() {
789 let fixture = fixture();
790
791 let abi_manifest = fixture._dir.path().join("abi_mismatch.json");
792 write_manifest(
793 &abi_manifest,
794 digest_bytes(&fs::read(&fixture.artifact).unwrap()),
795 "999",
796 vec!["sm_89".to_string()],
797 required_exports(),
798 );
799 let err = NativeOperatorResolver
800 .resolve(&request(&abi_manifest, &fixture.artifact))
801 .unwrap_err();
802 assert!(
803 matches!(err, NativeOperatorResolveError::AbiMismatch { .. }),
804 "{err:?}"
805 );
806 }
807
808 #[test]
809 fn fails_closed_for_compute_capability_mismatch() {
810 let fixture = fixture();
811
812 let cap_manifest = fixture._dir.path().join("cap_mismatch.json");
813 write_manifest(
814 &cap_manifest,
815 digest_bytes(&fs::read(&fixture.artifact).unwrap()),
816 FERRUM_NATIVE_ABI_VERSION,
817 vec!["sm_80".to_string()],
818 required_exports(),
819 );
820 let err = NativeOperatorResolver
821 .resolve(&request(&cap_manifest, &fixture.artifact))
822 .unwrap_err();
823 assert!(
824 matches!(
825 err,
826 NativeOperatorResolveError::ComputeCapabilityMismatch { .. }
827 ),
828 "{err:?}"
829 );
830 }
831
832 #[test]
833 fn rejects_python_wheel_artifacts() {
834 let fixture = fixture();
835 let wheel = fixture._dir.path().join("native_op.whl");
836 fs::write(&wheel, b"not allowed").unwrap();
837 let err = NativeOperatorResolver
838 .resolve(&request(&fixture.manifest, &wheel))
839 .unwrap_err();
840 assert!(matches!(
841 err,
842 NativeOperatorResolveError::PythonWheelArtifact(_)
843 ));
844 }
845
846 #[test]
847 fn rejects_text_file_even_when_hash_matches() {
848 let dir = temp_dir("text-artifact");
849 let artifact = dir.path().join(if cfg!(windows) {
850 "ferrum_native_dummy.lib"
851 } else {
852 "libferrum_native_dummy.a"
853 });
854 let bytes = b"not an archive";
855 fs::write(&artifact, bytes).unwrap();
856 let manifest = dir.path().join("native_operator_manifest.json");
857 write_manifest(
858 &manifest,
859 digest_bytes(bytes),
860 FERRUM_NATIVE_ABI_VERSION,
861 vec!["sm_89".to_string()],
862 required_exports(),
863 );
864
865 let err = NativeOperatorResolver
866 .resolve(&request(&manifest, &artifact))
867 .unwrap_err();
868 assert!(
869 matches!(
870 err,
871 NativeOperatorResolveError::ArtifactToolFailed { .. }
872 | NativeOperatorResolveError::InvalidCoffLibrary { .. }
873 ),
874 "{err:?}"
875 );
876 }
877
878 #[test]
879 fn rejects_archive_missing_declared_export() {
880 let dir = temp_dir("missing-export");
881 let artifact = write_static_archive(dir.path(), false);
882 let bytes = fs::read(&artifact).unwrap();
883 let manifest = dir.path().join("native_operator_manifest.json");
884 write_manifest(
885 &manifest,
886 digest_bytes(&bytes),
887 FERRUM_NATIVE_ABI_VERSION,
888 vec!["sm_89".to_string()],
889 required_exports(),
890 );
891
892 let err = NativeOperatorResolver
893 .resolve(&request(&manifest, &artifact))
894 .unwrap_err();
895 assert!(
896 matches!(
897 err,
898 NativeOperatorResolveError::ArtifactMissingExports { .. }
899 ),
900 "{err:?}"
901 );
902 }
903
904 #[test]
905 fn rejects_manifest_without_descriptor_export() {
906 let fixture = fixture();
907 let manifest = fixture._dir.path().join("missing_descriptor.json");
908 write_manifest(
909 &manifest,
910 digest_bytes(&fs::read(&fixture.artifact).unwrap()),
911 FERRUM_NATIVE_ABI_VERSION,
912 vec!["sm_89".to_string()],
913 vec!["ferrum_native_dummy_execute_v1".to_string()],
914 );
915
916 let err = NativeOperatorResolver
917 .resolve(&request(&manifest, &fixture.artifact))
918 .unwrap_err();
919 assert!(
920 matches!(err, NativeOperatorResolveError::ManifestInvalid(_)),
921 "{err:?}"
922 );
923 }
924
925 #[test]
926 fn msvc_library_requires_real_coff_and_exact_product_host_contract() {
927 let dir = temp_dir("msvc-resolver");
928 let path = dir.path().join("native.lib");
929 let exports = required_exports();
930 let bytes = crate::coff::tests::native_library(
931 &exports.iter().map(String::as_str).collect::<Vec<_>>(),
932 );
933 fs::write(&path, &bytes).unwrap();
934 let manifest_path = dir.path().join("manifest.json");
935 write_manifest(
936 &manifest_path,
937 digest_bytes(&bytes),
938 FERRUM_NATIVE_ABI_VERSION,
939 vec!["sm_89".into()],
940 exports,
941 );
942 let mut manifest: NativeOperatorManifest =
943 serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
944 let host = NativeOperatorHostAbi::for_target("x86_64-pc-windows-msvc").unwrap();
945 manifest.host_abi = Some(host.clone());
946 fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap();
947 let request = request(&manifest_path, &path).with_host_abi(host.clone());
948 NativeOperatorResolver.resolve(&request).unwrap();
949 let wrong = request
950 .clone()
951 .with_host_abi(NativeOperatorHostAbi::for_target("x86_64-unknown-linux-gnu").unwrap());
952 assert!(matches!(
953 NativeOperatorResolver.resolve(&wrong),
954 Err(NativeOperatorResolveError::HostAbiMismatch(_))
955 ));
956 manifest.host_abi = None;
957 fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap();
958 assert!(matches!(
959 NativeOperatorResolver.resolve(&request),
960 Err(NativeOperatorResolveError::HostAbiMismatch(_))
961 ));
962 manifest.host_abi = Some(host);
963 let fake = b"!<arch>\nnot a COFF implementation";
964 manifest.binary_sha256 = digest_bytes(fake);
965 fs::write(&path, fake).unwrap();
966 fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap();
967 assert!(matches!(
968 NativeOperatorResolver.resolve(&request),
969 Err(NativeOperatorResolveError::InvalidCoffLibrary { .. })
970 ));
971 }
972}