Skip to main content

ferrum_native_ops/
resolver.rs

1//! Fail-closed resolver for native operator artifacts.
2
3use 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}