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 NativeOperatorLinkage, NativeOperatorManifest, NativeOperatorRequirement,
11 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}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct NativeOperatorResolveRequest {
75 pub operator: String,
76 pub backend: NativeOperatorBackend,
77 pub compute_capability: Option<String>,
78 pub manifest_path: PathBuf,
79 pub artifact_path: PathBuf,
80 pub operator_abi_version: String,
81 pub ferrum_native_abi_version: String,
82 pub g03_catalog_sha256: Option<String>,
83 pub abi_contract_sha256: Option<String>,
84 pub descriptor_export: Option<String>,
85 pub required_exports: Vec<String>,
86 pub operation_bindings: Option<Vec<NativeOperatorBinding>>,
87}
88
89impl NativeOperatorResolveRequest {
90 pub fn new(
91 operator: impl Into<String>,
92 backend: NativeOperatorBackend,
93 manifest_path: impl Into<PathBuf>,
94 artifact_path: impl Into<PathBuf>,
95 ) -> Self {
96 Self {
97 operator: operator.into(),
98 backend,
99 compute_capability: None,
100 manifest_path: manifest_path.into(),
101 artifact_path: artifact_path.into(),
102 operator_abi_version: DEFAULT_NATIVE_OPERATOR_ABI_VERSION.to_string(),
103 ferrum_native_abi_version: FERRUM_NATIVE_ABI_VERSION.to_string(),
104 g03_catalog_sha256: None,
105 abi_contract_sha256: None,
106 descriptor_export: None,
107 required_exports: Vec::new(),
108 operation_bindings: None,
109 }
110 }
111
112 pub fn with_compute_capability(mut self, compute_capability: impl Into<String>) -> Self {
113 self.compute_capability = Some(compute_capability.into());
114 self
115 }
116
117 pub fn with_ferrum_native_abi_version(mut self, version: impl Into<String>) -> Self {
118 self.ferrum_native_abi_version = version.into();
119 self
120 }
121
122 pub fn with_operator_abi_version(mut self, version: impl Into<String>) -> Self {
123 self.operator_abi_version = version.into();
124 self
125 }
126
127 pub fn with_g03_catalog_sha256(mut self, sha256: impl Into<String>) -> Self {
128 self.g03_catalog_sha256 = Some(sha256.into());
129 self
130 }
131
132 pub fn with_abi_contract_sha256(mut self, sha256: impl Into<String>) -> Self {
133 self.abi_contract_sha256 = Some(sha256.into());
134 self
135 }
136
137 pub fn with_descriptor_export(mut self, symbol: impl Into<String>) -> Self {
138 self.descriptor_export = Some(symbol.into());
139 self
140 }
141
142 pub fn with_required_exports(mut self, exports: impl IntoIterator<Item = String>) -> Self {
143 self.required_exports = exports.into_iter().collect();
144 self
145 }
146
147 pub fn with_operation_bindings(
148 mut self,
149 bindings: impl IntoIterator<Item = NativeOperatorBinding>,
150 ) -> Self {
151 self.operation_bindings = Some(bindings.into_iter().collect());
152 self
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct ResolvedNativeOperator {
158 pub manifest: NativeOperatorManifest,
159 pub manifest_path: PathBuf,
160 pub artifact_path: PathBuf,
161 pub artifact_sha256: String,
162 pub binary_validation: NativeOperatorBinaryValidation,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum NativeOperatorArtifactFormat {
167 StaticArchive,
168 DynamicLibrary,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct NativeOperatorBinaryValidation {
173 pub format: NativeOperatorArtifactFormat,
174 pub archive_members: Vec<String>,
175 pub defined_symbols: Vec<String>,
176 pub strong_defined_symbols: Vec<String>,
177 pub required_exports: Vec<String>,
178 pub matched_exports: Vec<String>,
179}
180
181#[derive(Debug, Default, Clone)]
182pub struct NativeOperatorResolver;
183
184impl NativeOperatorResolver {
185 pub fn resolve(
186 &self,
187 request: &NativeOperatorResolveRequest,
188 ) -> Result<ResolvedNativeOperator> {
189 if !request.manifest_path.is_file() {
190 return Err(NativeOperatorResolveError::ManifestMissing(
191 request.manifest_path.clone(),
192 ));
193 }
194 if request
195 .artifact_path
196 .extension()
197 .is_some_and(|extension| extension == "whl")
198 {
199 return Err(NativeOperatorResolveError::PythonWheelArtifact(
200 request.artifact_path.clone(),
201 ));
202 }
203 if !request.artifact_path.is_file() {
204 return Err(NativeOperatorResolveError::ArtifactMissing(
205 request.artifact_path.clone(),
206 ));
207 }
208
209 let manifest = load_manifest(&request.manifest_path)?;
210 if manifest.operator != request.operator {
211 return Err(NativeOperatorResolveError::OperatorMismatch {
212 expected: request.operator.clone(),
213 actual: manifest.operator.clone(),
214 });
215 }
216 if manifest.backend != request.backend {
217 return Err(NativeOperatorResolveError::BackendMismatch {
218 expected: request.backend,
219 actual: manifest.backend,
220 });
221 }
222 if manifest.ferrum_native_abi_version != request.ferrum_native_abi_version {
223 return Err(NativeOperatorResolveError::AbiMismatch {
224 expected: request.ferrum_native_abi_version.clone(),
225 actual: manifest.ferrum_native_abi_version.clone(),
226 });
227 }
228 if let Some(expected) = &request.compute_capability {
229 if !manifest
230 .compute_capabilities
231 .iter()
232 .any(|capability| capability == expected)
233 {
234 return Err(NativeOperatorResolveError::ComputeCapabilityMismatch {
235 expected: expected.clone(),
236 });
237 }
238 }
239 let requirement = NativeOperatorRequirement {
240 operator: request.operator.clone(),
241 backend: request.backend,
242 operator_abi_version: request.operator_abi_version.clone(),
243 ferrum_native_abi_version: request.ferrum_native_abi_version.clone(),
244 compute_capability: request.compute_capability.clone(),
245 source_package_sha256: None,
246 inputs_sha256: None,
247 binary_sha256: None,
248 g03_catalog_sha256: request.g03_catalog_sha256.clone(),
249 abi_contract_sha256: request.abi_contract_sha256.clone(),
250 descriptor_export: request.descriptor_export.clone(),
251 required_exports: request.required_exports.clone(),
252 operation_bindings: request.operation_bindings.clone(),
253 };
254 resolve_native_operator_manifest(Some(&manifest), &requirement)
255 .map_err(NativeOperatorResolveError::ManifestInvalid)?;
256
257 let artifact_sha256 = file_sha256(&request.artifact_path)?;
258 if artifact_sha256 != manifest.binary_sha256 {
259 return Err(NativeOperatorResolveError::ArtifactSha256Mismatch {
260 expected: manifest.binary_sha256.clone(),
261 actual: artifact_sha256,
262 });
263 }
264 let binary_validation =
265 validate_binary_artifact(&request.artifact_path, manifest.linkage, &manifest.exports)?;
266 Ok(ResolvedNativeOperator {
267 manifest,
268 manifest_path: request.manifest_path.clone(),
269 artifact_path: request.artifact_path.clone(),
270 artifact_sha256,
271 binary_validation,
272 })
273 }
274}
275
276fn file_sha256(path: &Path) -> Result<String> {
277 let mut file =
278 fs::File::open(path).map_err(|source| NativeOperatorResolveError::ArtifactRead {
279 path: path.to_path_buf(),
280 source,
281 })?;
282 let mut hasher = Sha256::new();
283 let mut buf = [0_u8; 16 * 1024];
284 loop {
285 let n = file
286 .read(&mut buf)
287 .map_err(|source| NativeOperatorResolveError::ArtifactRead {
288 path: path.to_path_buf(),
289 source,
290 })?;
291 if n == 0 {
292 break;
293 }
294 hasher.update(&buf[..n]);
295 }
296 Ok(format!("{:x}", hasher.finalize()))
297}
298
299fn validate_binary_artifact(
300 path: &Path,
301 linkage: NativeOperatorLinkage,
302 exports: &[String],
303) -> Result<NativeOperatorBinaryValidation> {
304 let (format, archive_members) = match linkage {
305 NativeOperatorLinkage::Static => {
306 if path.extension().and_then(|extension| extension.to_str()) != Some("a") {
307 return Err(NativeOperatorResolveError::ArtifactSuffixMismatch {
308 path: path.to_path_buf(),
309 linkage,
310 });
311 }
312 let output = run_artifact_tool("ar", &["t"], path)?;
313 let members = output
314 .lines()
315 .map(str::trim)
316 .filter(|line| !line.is_empty())
317 .map(ToOwned::to_owned)
318 .collect::<Vec<_>>();
319 if members.is_empty() {
320 return Err(NativeOperatorResolveError::ArtifactArchiveEmpty(
321 path.to_path_buf(),
322 ));
323 }
324 (NativeOperatorArtifactFormat::StaticArchive, members)
325 }
326 NativeOperatorLinkage::Dynamic => {
327 let name = path
328 .file_name()
329 .and_then(|name| name.to_str())
330 .unwrap_or("");
331 let suffix_ok = path
332 .extension()
333 .and_then(|extension| extension.to_str())
334 .is_some_and(|extension| extension == "dylib" || extension == "so")
335 || name.contains(".so.");
336 if !suffix_ok {
337 return Err(NativeOperatorResolveError::ArtifactSuffixMismatch {
338 path: path.to_path_buf(),
339 linkage,
340 });
341 }
342 (NativeOperatorArtifactFormat::DynamicLibrary, Vec::new())
343 }
344 };
345
346 let nm_output = run_artifact_tool("nm", &["-g"], path)?;
347 let (defined_symbols, strong_defined_symbols) = collect_defined_symbols(&nm_output);
348 let missing = exports
349 .iter()
350 .filter(|export| !defined_symbols.contains(export.as_str()))
351 .cloned()
352 .collect::<Vec<_>>();
353 if !missing.is_empty() {
354 return Err(NativeOperatorResolveError::ArtifactMissingExports {
355 path: path.to_path_buf(),
356 missing,
357 });
358 }
359
360 Ok(NativeOperatorBinaryValidation {
361 format,
362 archive_members,
363 defined_symbols: defined_symbols.into_iter().collect(),
364 strong_defined_symbols: strong_defined_symbols.into_iter().collect(),
365 required_exports: exports.to_vec(),
366 matched_exports: exports.to_vec(),
367 })
368}
369
370fn run_artifact_tool(program: &str, args: &[&str], path: &Path) -> Result<String> {
371 let output = Command::new(program)
372 .args(args)
373 .arg(path)
374 .output()
375 .map_err(|source| NativeOperatorResolveError::ArtifactRead {
376 path: path.to_path_buf(),
377 source,
378 })?;
379 if !output.status.success() {
380 let stderr = String::from_utf8_lossy(&output.stderr)
381 .trim()
382 .chars()
383 .take(1000)
384 .collect::<String>();
385 return Err(NativeOperatorResolveError::ArtifactToolFailed {
386 tool: program.to_string(),
387 path: path.to_path_buf(),
388 status: output.status.to_string(),
389 stderr,
390 });
391 }
392 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
393}
394
395fn collect_defined_symbols(
396 nm_output: &str,
397) -> (
398 std::collections::BTreeSet<String>,
399 std::collections::BTreeSet<String>,
400) {
401 let mut symbols = std::collections::BTreeSet::new();
402 let mut strong_symbols = std::collections::BTreeSet::new();
403 for raw_line in nm_output.lines() {
404 let line = raw_line.trim();
405 if line.is_empty() || line.ends_with(':') {
406 continue;
407 }
408 let parts = line.split_whitespace().collect::<Vec<_>>();
409 if parts.len() < 2 {
410 continue;
411 }
412 let mut symbol_type = None;
413 let mut symbol = None;
414 if parts.len() >= 3 && parts[parts.len() - 2].len() == 1 {
415 symbol_type = parts.get(parts.len() - 2).copied();
416 symbol = parts.last().copied();
417 } else if parts[0].len() == 1 {
418 symbol_type = parts.first().copied();
419 symbol = parts.last().copied();
420 }
421 let (Some(symbol_type), Some(symbol)) = (symbol_type, symbol) else {
422 continue;
423 };
424 if matches!(symbol_type, "U" | "u" | "w" | "v") {
425 continue;
426 }
427 symbols.insert(symbol.to_string());
428 if !matches!(symbol_type, "W" | "V") {
429 strong_symbols.insert(symbol.to_string());
430 }
431 if let Some(stripped) = symbol.strip_prefix('_') {
432 symbols.insert(stripped.to_string());
433 if !matches!(symbol_type, "W" | "V") {
434 strong_symbols.insert(stripped.to_string());
435 }
436 }
437 }
438 (symbols, strong_symbols)
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use ferrum_types::{
445 NativeOperatorBinding, NativeOperatorBuildSummary, NativeOperatorLinkage,
446 NativeOperatorSourcePackage, NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION,
447 };
448 use std::sync::atomic::{AtomicU64, Ordering};
449 use std::time::{SystemTime, UNIX_EPOCH};
450
451 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
452
453 fn digest_bytes(bytes: &[u8]) -> String {
454 format!("{:x}", Sha256::digest(bytes))
455 }
456
457 fn digest(ch: char) -> String {
458 std::iter::repeat(ch).take(64).collect()
459 }
460
461 struct TestDir(PathBuf);
462
463 impl TestDir {
464 fn path(&self) -> &Path {
465 &self.0
466 }
467 }
468
469 impl Drop for TestDir {
470 fn drop(&mut self) {
471 let _ = fs::remove_dir_all(&self.0);
472 }
473 }
474
475 struct TestFixture {
476 _dir: TestDir,
477 manifest: PathBuf,
478 artifact: PathBuf,
479 }
480
481 fn temp_dir(name: &str) -> TestDir {
482 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
483 let unique = SystemTime::now()
484 .duration_since(UNIX_EPOCH)
485 .unwrap()
486 .as_nanos();
487 let dir = std::env::temp_dir().join(format!(
488 "ferrum-native-ops-{name}-{}-{counter}-{unique}",
489 std::process::id()
490 ));
491 fs::create_dir_all(&dir).unwrap();
492 TestDir(dir)
493 }
494
495 fn write_manifest(
496 path: &Path,
497 binary_sha256: String,
498 abi: &str,
499 caps: Vec<String>,
500 exports: Vec<String>,
501 ) {
502 let manifest = NativeOperatorManifest {
503 schema_version: NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION,
504 operator: "dummy".to_string(),
505 operator_abi_version: "1".to_string(),
506 ferrum_native_abi_version: abi.to_string(),
507 backend: NativeOperatorBackend::Cuda,
508 cuda_toolkit: Some("12.4".to_string()),
509 cuda_runtime_min: Some("12.4".to_string()),
510 compute_capabilities: caps,
511 source_package: NativeOperatorSourcePackage {
512 kind: "external_archive".to_string(),
513 revision: "fixture".to_string(),
514 sha256: digest('a'),
515 },
516 inputs_sha256: digest('b'),
517 binary_sha256,
518 linkage: NativeOperatorLinkage::Static,
519 g03_catalog_sha256: Some(digest('c')),
520 abi_contract_sha256: Some(digest('d')),
521 descriptor_export: Some("ferrum_native_dummy_descriptor_v2".to_string()),
522 operation_bindings: vec![NativeOperatorBinding {
523 operation_id: "operation.dummy".to_string(),
524 operation_contract_version: ferrum_types::NativeOperatorContractVersion::new(1, 0),
525 provider_id: "provider.cuda.dummy".to_string(),
526 provider_version: ferrum_types::NativeOperatorContractVersion::new(1, 0),
527 provider_implementation_fingerprint: digest('e'),
528 entrypoints: vec!["ferrum_native_dummy_execute_v1".to_string()],
529 }],
530 exports,
531 license_files: vec!["LICENSE".to_string()],
532 build_summary: NativeOperatorBuildSummary {
533 builder_sha: digest('7'),
534 elapsed_ms: 1,
535 nvcc_version: Some("12.4".to_string()),
536 host_compiler: "clang".to_string(),
537 },
538 };
539 fs::write(path, serde_json::to_string_pretty(&manifest).unwrap()).unwrap();
540 }
541
542 fn required_exports() -> Vec<String> {
543 vec![
544 "ferrum_native_dummy_descriptor_v2".to_string(),
545 "ferrum_native_dummy_execute_v1".to_string(),
546 ]
547 }
548
549 fn write_static_archive(dir: &Path, include_descriptor: bool) -> PathBuf {
550 let source = dir.join("native_op.c");
551 let mut source_text =
552 String::from("int ferrum_native_dummy_execute_v1(void) { return 0; }\n");
553 if include_descriptor {
554 source_text.push_str(
555 "const char *ferrum_native_dummy_descriptor_v2(void) { return \"dummy\"; }\n",
556 );
557 }
558 fs::write(&source, source_text).unwrap();
559 let object = dir.join("native_op.o");
560 let archive = dir.join("libferrum_native_dummy.a");
561 let cc_status = Command::new("cc")
562 .arg("-c")
563 .arg(&source)
564 .arg("-o")
565 .arg(&object)
566 .status()
567 .unwrap();
568 assert!(cc_status.success());
569 let ar_status = Command::new("ar")
570 .arg("rcs")
571 .arg(&archive)
572 .arg(&object)
573 .status()
574 .unwrap();
575 assert!(ar_status.success());
576 archive
577 }
578
579 fn fixture() -> TestFixture {
580 let dir = temp_dir("resolver");
581 let artifact = write_static_archive(dir.path(), true);
582 let bytes = fs::read(&artifact).unwrap();
583 let manifest = dir.path().join("native_operator_manifest.json");
584 write_manifest(
585 &manifest,
586 digest_bytes(&bytes),
587 FERRUM_NATIVE_ABI_VERSION,
588 vec!["sm_89".to_string()],
589 required_exports(),
590 );
591 TestFixture {
592 _dir: dir,
593 manifest,
594 artifact,
595 }
596 }
597
598 fn request(manifest: &Path, artifact: &Path) -> NativeOperatorResolveRequest {
599 NativeOperatorResolveRequest::new("dummy", NativeOperatorBackend::Cuda, manifest, artifact)
600 .with_compute_capability("sm_89")
601 }
602
603 #[test]
604 fn resolves_matching_manifest_and_artifact() {
605 let fixture = fixture();
606 let resolved = NativeOperatorResolver
607 .resolve(&request(&fixture.manifest, &fixture.artifact))
608 .unwrap();
609 assert_eq!(resolved.manifest.operator, "dummy");
610 assert_eq!(resolved.artifact_path, fixture.artifact);
611 assert_eq!(
612 resolved.binary_validation.format,
613 NativeOperatorArtifactFormat::StaticArchive
614 );
615 assert_eq!(
616 resolved.binary_validation.required_exports,
617 required_exports()
618 );
619 }
620
621 #[test]
622 fn fails_closed_for_missing_manifest() {
623 let fixture = fixture();
624 let err = NativeOperatorResolver
625 .resolve(&request(
626 &fixture.manifest.with_file_name("missing.json"),
627 &fixture.artifact,
628 ))
629 .unwrap_err();
630 assert!(matches!(
631 err,
632 NativeOperatorResolveError::ManifestMissing(_)
633 ));
634 }
635
636 #[test]
637 fn fails_closed_for_hash_mismatch() {
638 let fixture = fixture();
639 fs::write(&fixture.artifact, b"changed").unwrap();
640 let err = NativeOperatorResolver
641 .resolve(&request(&fixture.manifest, &fixture.artifact))
642 .unwrap_err();
643 assert!(
644 matches!(
645 err,
646 NativeOperatorResolveError::ArtifactSha256Mismatch { .. }
647 ),
648 "{err:?}"
649 );
650 }
651
652 #[test]
653 fn fails_closed_for_abi_mismatch() {
654 let fixture = fixture();
655
656 let abi_manifest = fixture._dir.path().join("abi_mismatch.json");
657 write_manifest(
658 &abi_manifest,
659 digest_bytes(&fs::read(&fixture.artifact).unwrap()),
660 "999",
661 vec!["sm_89".to_string()],
662 required_exports(),
663 );
664 let err = NativeOperatorResolver
665 .resolve(&request(&abi_manifest, &fixture.artifact))
666 .unwrap_err();
667 assert!(
668 matches!(err, NativeOperatorResolveError::AbiMismatch { .. }),
669 "{err:?}"
670 );
671 }
672
673 #[test]
674 fn fails_closed_for_compute_capability_mismatch() {
675 let fixture = fixture();
676
677 let cap_manifest = fixture._dir.path().join("cap_mismatch.json");
678 write_manifest(
679 &cap_manifest,
680 digest_bytes(&fs::read(&fixture.artifact).unwrap()),
681 FERRUM_NATIVE_ABI_VERSION,
682 vec!["sm_80".to_string()],
683 required_exports(),
684 );
685 let err = NativeOperatorResolver
686 .resolve(&request(&cap_manifest, &fixture.artifact))
687 .unwrap_err();
688 assert!(
689 matches!(
690 err,
691 NativeOperatorResolveError::ComputeCapabilityMismatch { .. }
692 ),
693 "{err:?}"
694 );
695 }
696
697 #[test]
698 fn rejects_python_wheel_artifacts() {
699 let fixture = fixture();
700 let wheel = fixture._dir.path().join("native_op.whl");
701 fs::write(&wheel, b"not allowed").unwrap();
702 let err = NativeOperatorResolver
703 .resolve(&request(&fixture.manifest, &wheel))
704 .unwrap_err();
705 assert!(matches!(
706 err,
707 NativeOperatorResolveError::PythonWheelArtifact(_)
708 ));
709 }
710
711 #[test]
712 fn rejects_text_file_even_when_hash_matches() {
713 let dir = temp_dir("text-artifact");
714 let artifact = dir.path().join("libferrum_native_dummy.a");
715 let bytes = b"not an archive";
716 fs::write(&artifact, bytes).unwrap();
717 let manifest = dir.path().join("native_operator_manifest.json");
718 write_manifest(
719 &manifest,
720 digest_bytes(bytes),
721 FERRUM_NATIVE_ABI_VERSION,
722 vec!["sm_89".to_string()],
723 required_exports(),
724 );
725
726 let err = NativeOperatorResolver
727 .resolve(&request(&manifest, &artifact))
728 .unwrap_err();
729 assert!(
730 matches!(err, NativeOperatorResolveError::ArtifactToolFailed { .. }),
731 "{err:?}"
732 );
733 }
734
735 #[test]
736 fn rejects_archive_missing_declared_export() {
737 let dir = temp_dir("missing-export");
738 let artifact = write_static_archive(dir.path(), false);
739 let bytes = fs::read(&artifact).unwrap();
740 let manifest = dir.path().join("native_operator_manifest.json");
741 write_manifest(
742 &manifest,
743 digest_bytes(&bytes),
744 FERRUM_NATIVE_ABI_VERSION,
745 vec!["sm_89".to_string()],
746 required_exports(),
747 );
748
749 let err = NativeOperatorResolver
750 .resolve(&request(&manifest, &artifact))
751 .unwrap_err();
752 assert!(
753 matches!(
754 err,
755 NativeOperatorResolveError::ArtifactMissingExports { .. }
756 ),
757 "{err:?}"
758 );
759 }
760
761 #[test]
762 fn rejects_manifest_without_descriptor_export() {
763 let fixture = fixture();
764 let manifest = fixture._dir.path().join("missing_descriptor.json");
765 write_manifest(
766 &manifest,
767 digest_bytes(&fs::read(&fixture.artifact).unwrap()),
768 FERRUM_NATIVE_ABI_VERSION,
769 vec!["sm_89".to_string()],
770 vec!["ferrum_native_dummy_execute_v1".to_string()],
771 );
772
773 let err = NativeOperatorResolver
774 .resolve(&request(&manifest, &fixture.artifact))
775 .unwrap_err();
776 assert!(
777 matches!(err, NativeOperatorResolveError::ManifestInvalid(_)),
778 "{err:?}"
779 );
780 }
781}