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