Skip to main content

ferrum_native_ops/
artifact_set.rs

1//! Deterministic, fail-closed resolution for a set of native operator artifacts.
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::io;
6use std::path::{Component, Path, PathBuf};
7
8use ferrum_types::{
9    is_sha256_digest, NativeOperatorBackend, NativeOperatorBinding, NativeOperatorContractVersion,
10    NativeOperatorHostAbi, NativeOperatorLinkage, FERRUM_NATIVE_OPERATOR_ABI_VERSION,
11    NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION,
12};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use thiserror::Error;
16
17use crate::{
18    NativeOperatorResolveError, NativeOperatorResolveRequest, NativeOperatorResolver,
19    ResolvedNativeOperator,
20};
21
22pub const NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION: u32 = 5;
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct NativeOperatorArtifactSetLock {
26    pub schema_version: u32,
27    pub g03_catalog_sha256: String,
28    pub artifacts: Vec<NativeOperatorArtifactLock>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct NativeOperatorArtifactLock {
33    pub operator: String,
34    pub backend: NativeOperatorBackend,
35    pub manifest_path: String,
36    pub manifest: NativeOperatorEvidenceFile,
37    pub artifact_path: String,
38    pub operator_abi_version: String,
39    pub ferrum_native_abi_version: String,
40    pub source_package_sha256: String,
41    pub inputs_sha256: String,
42    pub package_spec: NativeOperatorEvidenceFile,
43    pub g03_catalog: NativeOperatorEvidenceFile,
44    pub abi_contract: NativeOperatorEvidenceFile,
45    pub source_build_receipt: NativeOperatorEvidenceFile,
46    pub source_build_plan: NativeOperatorEvidenceFile,
47    pub source_build_inputs: Vec<NativeOperatorEvidenceFile>,
48    pub source_build_logs: Vec<NativeOperatorEvidenceFile>,
49    pub source_archive_sha256: String,
50    pub package_receipt: NativeOperatorEvidenceFile,
51    pub package_build_logs: Vec<NativeOperatorEvidenceFile>,
52    pub license_files: Vec<NativeOperatorEvidenceFile>,
53    pub binary_sha256: String,
54    pub abi_contract_sha256: String,
55    pub descriptor_export: String,
56    pub required_exports: Vec<String>,
57    pub operation_bindings: Vec<NativeOperatorBinding>,
58    #[serde(default)]
59    pub system_libraries: Vec<NativeOperatorSystemLibrary>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub host_abi: Option<NativeOperatorHostAbi>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
65pub struct NativeOperatorEvidenceFile {
66    pub path: String,
67    pub sha256: String,
68    pub size_bytes: u64,
69}
70
71#[derive(Debug)]
72pub struct ResolvedNativeOperatorArtifactSet {
73    pub lock_path: PathBuf,
74    pub g03_catalog_sha256: String,
75    pub artifacts: Vec<ResolvedNativeOperatorArtifact>,
76}
77
78#[derive(Debug)]
79pub struct ResolvedNativeOperatorArtifact {
80    pub lock: NativeOperatorArtifactLock,
81    pub resolved: ResolvedNativeOperator,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum NativeOperatorSystemLibrary {
87    CudaDriver,
88    CudaRuntime,
89    Cublas,
90    CublasLt,
91    StdCxx,
92    MsvcRuntime,
93}
94
95#[derive(Debug, Error)]
96pub enum NativeOperatorArtifactSetError {
97    #[error("native operator artifact-set lock does not exist: {0}")]
98    LockMissing(PathBuf),
99    #[error("failed to read native operator artifact-set lock {path}: {source}")]
100    LockRead { path: PathBuf, source: io::Error },
101    #[error("failed to parse native operator artifact-set lock {path}: {source}")]
102    LockJson {
103        path: PathBuf,
104        source: serde_json::Error,
105    },
106    #[error("invalid native operator artifact-set lock: {0}")]
107    LockInvalid(String),
108    #[error("native operator artifact-set path escapes its lock directory: {0}")]
109    PathEscape(String),
110    #[error("native operator artifact resolution failed for {operator}: {source}")]
111    ArtifactResolve {
112        operator: String,
113        #[source]
114        source: NativeOperatorResolveError,
115    },
116    #[error(
117        "native operator artifact-set pin mismatch for {operator}.{field}: expected={expected} actual={actual}"
118    )]
119    PinMismatch {
120        operator: String,
121        field: String,
122        expected: String,
123        actual: String,
124    },
125    #[error(
126        "native operator artifact-set static symbol collision: symbol={symbol} first={first} second={second}"
127    )]
128    StaticSymbolCollision {
129        symbol: String,
130        first: String,
131        second: String,
132    },
133    #[error(
134        "native operator artifact-set link-name collision: link_name={link_name} first={first} second={second}"
135    )]
136    LinkNameCollision {
137        link_name: String,
138        first: String,
139        second: String,
140    },
141    #[error(
142        "native operator artifact-set operation/provider identity conflict: operation={operation_id} provider={provider_id} first={first} second={second}"
143    )]
144    OperationProviderIdentityConflict {
145        operation_id: String,
146        provider_id: String,
147        first: String,
148        second: String,
149    },
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
153struct OperationProviderIdentity {
154    operation_contract_version: NativeOperatorContractVersion,
155    provider_version: NativeOperatorContractVersion,
156    provider_implementation_fingerprint: String,
157}
158
159impl From<&NativeOperatorBinding> for OperationProviderIdentity {
160    fn from(binding: &NativeOperatorBinding) -> Self {
161        Self {
162            operation_contract_version: binding.operation_contract_version,
163            provider_version: binding.provider_version,
164            provider_implementation_fingerprint: binding
165                .provider_implementation_fingerprint
166                .clone(),
167        }
168    }
169}
170
171impl NativeOperatorArtifactSetLock {
172    pub fn load_and_resolve(
173        lock_path: impl AsRef<Path>,
174        compute_capability: Option<&str>,
175    ) -> Result<ResolvedNativeOperatorArtifactSet, NativeOperatorArtifactSetError> {
176        Self::load_and_resolve_impl(lock_path.as_ref(), compute_capability, None)
177    }
178
179    pub fn load_and_resolve_for_target(
180        lock_path: impl AsRef<Path>,
181        compute_capability: Option<&str>,
182        target: &str,
183    ) -> Result<ResolvedNativeOperatorArtifactSet, NativeOperatorArtifactSetError> {
184        Self::load_and_resolve_impl(lock_path.as_ref(), compute_capability, Some(target))
185    }
186
187    fn load_and_resolve_impl(
188        lock_path: &Path,
189        compute_capability: Option<&str>,
190        target: Option<&str>,
191    ) -> Result<ResolvedNativeOperatorArtifactSet, NativeOperatorArtifactSetError> {
192        if !lock_path.is_file() {
193            return Err(NativeOperatorArtifactSetError::LockMissing(
194                lock_path.to_path_buf(),
195            ));
196        }
197        let raw = fs::read_to_string(lock_path).map_err(|source| {
198            NativeOperatorArtifactSetError::LockRead {
199                path: lock_path.to_path_buf(),
200                source,
201            }
202        })?;
203        let lock: Self = serde_json::from_str(&raw).map_err(|source| {
204            NativeOperatorArtifactSetError::LockJson {
205                path: lock_path.to_path_buf(),
206                source,
207            }
208        })?;
209        lock.resolve_impl(lock_path, compute_capability, target)
210    }
211
212    pub fn resolve(
213        &self,
214        lock_path: impl AsRef<Path>,
215        compute_capability: Option<&str>,
216    ) -> Result<ResolvedNativeOperatorArtifactSet, NativeOperatorArtifactSetError> {
217        self.resolve_impl(lock_path.as_ref(), compute_capability, None)
218    }
219
220    pub fn resolve_for_target(
221        &self,
222        lock_path: impl AsRef<Path>,
223        compute_capability: Option<&str>,
224        target: &str,
225    ) -> Result<ResolvedNativeOperatorArtifactSet, NativeOperatorArtifactSetError> {
226        self.resolve_impl(lock_path.as_ref(), compute_capability, Some(target))
227    }
228
229    fn resolve_impl(
230        &self,
231        lock_path: &Path,
232        compute_capability: Option<&str>,
233        target: Option<&str>,
234    ) -> Result<ResolvedNativeOperatorArtifactSet, NativeOperatorArtifactSetError> {
235        self.validate()?;
236        let expected_host = target
237            .map(NativeOperatorHostAbi::for_target)
238            .transpose()
239            .map_err(NativeOperatorArtifactSetError::LockInvalid)?;
240        let root = lock_path.parent().unwrap_or_else(|| Path::new("."));
241        let canonical_root =
242            fs::canonicalize(root).map_err(|source| NativeOperatorArtifactSetError::LockRead {
243                path: root.to_path_buf(),
244                source,
245            })?;
246
247        let mut resolved_artifacts = Vec::with_capacity(self.artifacts.len());
248        let mut link_names = BTreeMap::<String, String>::new();
249        let mut strong_symbols = BTreeMap::<String, String>::new();
250        let mut operation_providers =
251            BTreeMap::<(String, String), (OperationProviderIdentity, String)>::new();
252
253        for artifact_lock in &self.artifacts {
254            let manifest_path = resolve_locked_path(
255                &canonical_root,
256                &artifact_lock.manifest_path,
257                &artifact_lock.operator,
258            )?;
259            let artifact_path = resolve_locked_path(
260                &canonical_root,
261                &artifact_lock.artifact_path,
262                &artifact_lock.operator,
263            )?;
264            verify_evidence_file(
265                &canonical_root,
266                &artifact_lock.operator,
267                "manifest",
268                &artifact_lock.manifest,
269            )?;
270            verify_evidence_file(
271                &canonical_root,
272                &artifact_lock.operator,
273                "package_spec",
274                &artifact_lock.package_spec,
275            )?;
276            verify_evidence_file(
277                &canonical_root,
278                &artifact_lock.operator,
279                "g03_catalog",
280                &artifact_lock.g03_catalog,
281            )?;
282            verify_evidence_file(
283                &canonical_root,
284                &artifact_lock.operator,
285                "abi_contract",
286                &artifact_lock.abi_contract,
287            )?;
288            verify_evidence_file(
289                &canonical_root,
290                &artifact_lock.operator,
291                "source_build_receipt",
292                &artifact_lock.source_build_receipt,
293            )?;
294            verify_evidence_file(
295                &canonical_root,
296                &artifact_lock.operator,
297                "source_build_plan",
298                &artifact_lock.source_build_plan,
299            )?;
300            for evidence in &artifact_lock.source_build_inputs {
301                verify_evidence_file(
302                    &canonical_root,
303                    &artifact_lock.operator,
304                    "source_build_input",
305                    evidence,
306                )?;
307            }
308            for evidence in &artifact_lock.source_build_logs {
309                verify_evidence_file(
310                    &canonical_root,
311                    &artifact_lock.operator,
312                    "source_build_log",
313                    evidence,
314                )?;
315            }
316            verify_evidence_file(
317                &canonical_root,
318                &artifact_lock.operator,
319                "package_receipt",
320                &artifact_lock.package_receipt,
321            )?;
322            for evidence in &artifact_lock.package_build_logs {
323                verify_evidence_file(
324                    &canonical_root,
325                    &artifact_lock.operator,
326                    "package_build_log",
327                    evidence,
328                )?;
329            }
330            for evidence in &artifact_lock.license_files {
331                verify_evidence_file(
332                    &canonical_root,
333                    &artifact_lock.operator,
334                    "license_file",
335                    evidence,
336                )?;
337            }
338            let mut request = NativeOperatorResolveRequest::new(
339                artifact_lock.operator.clone(),
340                artifact_lock.backend,
341                manifest_path,
342                artifact_path,
343            )
344            .with_operator_abi_version(artifact_lock.operator_abi_version.clone())
345            .with_ferrum_native_abi_version(artifact_lock.ferrum_native_abi_version.clone())
346            .with_g03_catalog_sha256(self.g03_catalog_sha256.clone())
347            .with_abi_contract_sha256(artifact_lock.abi_contract_sha256.clone())
348            .with_descriptor_export(artifact_lock.descriptor_export.clone())
349            .with_required_exports(artifact_lock.required_exports.clone())
350            .with_operation_bindings(artifact_lock.operation_bindings.clone());
351            if let Some(compute_capability) = compute_capability {
352                request = request.with_compute_capability(compute_capability);
353            }
354            if let Some(host_abi) = &expected_host {
355                request = request.with_host_abi(host_abi.clone());
356            }
357            let resolved = NativeOperatorResolver.resolve(&request).map_err(|source| {
358                NativeOperatorArtifactSetError::ArtifactResolve {
359                    operator: artifact_lock.operator.clone(),
360                    source,
361                }
362            })?;
363            if artifact_lock.host_abi != resolved.manifest.host_abi {
364                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
365                    "{}.host_abi differs between lock and manifest",
366                    artifact_lock.operator
367                )));
368            }
369            if resolved.manifest.schema_version != NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION {
370                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
371                    "{} uses legacy manifest schema {}; artifact sets require schema {}",
372                    artifact_lock.operator,
373                    resolved.manifest.schema_version,
374                    NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION
375                )));
376            }
377            require_pin(
378                artifact_lock,
379                "source_package_sha256",
380                &artifact_lock.source_package_sha256,
381                &resolved.manifest.source_package.sha256,
382            )?;
383            require_pin(
384                artifact_lock,
385                "inputs_sha256",
386                &artifact_lock.inputs_sha256,
387                &resolved.manifest.inputs_sha256,
388            )?;
389            require_pin(
390                artifact_lock,
391                "binary_sha256",
392                &artifact_lock.binary_sha256,
393                &resolved.artifact_sha256,
394            )?;
395
396            let link_name = if let Some(host_abi) = &resolved.manifest.host_abi {
397                native_artifact_link_name_for_target(
398                    &resolved.artifact_path,
399                    resolved.manifest.linkage,
400                    &host_abi.target,
401                )
402            } else {
403                native_artifact_link_name(&resolved.artifact_path, resolved.manifest.linkage)
404            }
405            .map_err(NativeOperatorArtifactSetError::LockInvalid)?;
406            let collision_key = if resolved.manifest.host_abi.as_ref().is_some_and(|host| {
407                host.compiler_flavor == ferrum_types::NativeOperatorCompilerFlavor::Msvc
408            }) {
409                link_name.to_ascii_lowercase()
410            } else {
411                link_name.clone()
412            };
413            if let Some(first) = link_names.insert(collision_key, artifact_lock.operator.clone()) {
414                return Err(NativeOperatorArtifactSetError::LinkNameCollision {
415                    link_name,
416                    first,
417                    second: artifact_lock.operator.clone(),
418                });
419            }
420            if resolved.manifest.linkage == NativeOperatorLinkage::Static {
421                for symbol in &resolved.binary_validation.strong_defined_symbols {
422                    if let Some(first) =
423                        strong_symbols.insert(symbol.clone(), artifact_lock.operator.clone())
424                    {
425                        if first != artifact_lock.operator {
426                            return Err(NativeOperatorArtifactSetError::StaticSymbolCollision {
427                                symbol: symbol.clone(),
428                                first,
429                                second: artifact_lock.operator.clone(),
430                            });
431                        }
432                    }
433                }
434            }
435            for binding in &resolved.manifest.operation_bindings {
436                let key = (binding.operation_id.clone(), binding.provider_id.clone());
437                let identity = OperationProviderIdentity::from(binding);
438                if let Some((first_identity, first_operator)) = operation_providers.get(&key) {
439                    if first_identity != &identity {
440                        return Err(
441                            NativeOperatorArtifactSetError::OperationProviderIdentityConflict {
442                                operation_id: key.0,
443                                provider_id: key.1,
444                                first: first_operator.clone(),
445                                second: artifact_lock.operator.clone(),
446                            },
447                        );
448                    }
449                } else {
450                    operation_providers.insert(key, (identity, artifact_lock.operator.clone()));
451                }
452            }
453            resolved_artifacts.push(ResolvedNativeOperatorArtifact {
454                lock: artifact_lock.clone(),
455                resolved,
456            });
457        }
458        if operation_providers.is_empty() {
459            return Err(NativeOperatorArtifactSetError::LockInvalid(
460                "artifact set must bind at least one live G03 operation/provider".to_string(),
461            ));
462        }
463
464        Ok(ResolvedNativeOperatorArtifactSet {
465            lock_path: lock_path.to_path_buf(),
466            g03_catalog_sha256: self.g03_catalog_sha256.clone(),
467            artifacts: resolved_artifacts,
468        })
469    }
470
471    fn validate(&self) -> Result<(), NativeOperatorArtifactSetError> {
472        if self.schema_version != NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION {
473            return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
474                "schema_version must be {NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION}"
475            )));
476        }
477        if !is_sha256_digest(&self.g03_catalog_sha256) {
478            return Err(NativeOperatorArtifactSetError::LockInvalid(
479                "g03_catalog_sha256 must be a lowercase hex sha256 digest".to_string(),
480            ));
481        }
482        if self.artifacts.is_empty() {
483            return Err(NativeOperatorArtifactSetError::LockInvalid(
484                "artifacts must be non-empty".to_string(),
485            ));
486        }
487        let mut previous: Option<&str> = None;
488        let mut operation_binding_count = 0_usize;
489        for artifact in &self.artifacts {
490            if artifact.operator.trim().is_empty() {
491                return Err(NativeOperatorArtifactSetError::LockInvalid(
492                    "artifact operator must be non-empty".to_string(),
493                ));
494            }
495            if artifact.ferrum_native_abi_version != FERRUM_NATIVE_OPERATOR_ABI_VERSION {
496                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
497                    "{}.ferrum_native_abi_version must be {}",
498                    artifact.operator, FERRUM_NATIVE_OPERATOR_ABI_VERSION
499                )));
500            }
501            if previous.is_some_and(|value| value >= artifact.operator.as_str()) {
502                return Err(NativeOperatorArtifactSetError::LockInvalid(
503                    "artifacts must be sorted and unique by operator".to_string(),
504                ));
505            }
506            previous = Some(&artifact.operator);
507            for (field, digest) in [
508                ("source_package_sha256", &artifact.source_package_sha256),
509                ("inputs_sha256", &artifact.inputs_sha256),
510                ("source_archive_sha256", &artifact.source_archive_sha256),
511                ("binary_sha256", &artifact.binary_sha256),
512                ("abi_contract_sha256", &artifact.abi_contract_sha256),
513            ] {
514                if !is_sha256_digest(digest) {
515                    return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
516                        "{}.{field} must be a lowercase hex sha256 digest",
517                        artifact.operator
518                    )));
519                }
520            }
521            validate_evidence_file(&artifact.operator, "manifest", &artifact.manifest)?;
522            if artifact.manifest.path != artifact.manifest_path {
523                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
524                    "{}.manifest evidence path must equal manifest_path",
525                    artifact.operator
526                )));
527            }
528            validate_evidence_file(&artifact.operator, "package_spec", &artifact.package_spec)?;
529            validate_evidence_file(&artifact.operator, "g03_catalog", &artifact.g03_catalog)?;
530            validate_evidence_file(&artifact.operator, "abi_contract", &artifact.abi_contract)?;
531            if artifact.g03_catalog.sha256 != self.g03_catalog_sha256 {
532                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
533                    "{}.g03_catalog sha256 must equal the artifact-set catalog pin",
534                    artifact.operator
535                )));
536            }
537            if artifact.abi_contract.sha256 != artifact.abi_contract_sha256 {
538                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
539                    "{}.abi_contract sha256 must equal abi_contract_sha256",
540                    artifact.operator
541                )));
542            }
543            validate_evidence_file(
544                &artifact.operator,
545                "source_build_receipt",
546                &artifact.source_build_receipt,
547            )?;
548            validate_evidence_file(
549                &artifact.operator,
550                "source_build_plan",
551                &artifact.source_build_plan,
552            )?;
553            if artifact.source_build_inputs.is_empty()
554                || artifact
555                    .source_build_inputs
556                    .windows(2)
557                    .any(|pair| pair[0].path >= pair[1].path)
558            {
559                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
560                    "{}.source_build_inputs must be sorted, unique, and non-empty",
561                    artifact.operator
562                )));
563            }
564            for evidence in &artifact.source_build_inputs {
565                validate_evidence_file(&artifact.operator, "source_build_input", evidence)?;
566            }
567            if artifact.source_build_logs.is_empty()
568                || artifact
569                    .source_build_logs
570                    .windows(2)
571                    .any(|pair| pair[0].path >= pair[1].path)
572            {
573                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
574                    "{}.source_build_logs must be sorted, unique, and non-empty",
575                    artifact.operator
576                )));
577            }
578            for evidence in &artifact.source_build_logs {
579                validate_evidence_file(&artifact.operator, "source_build_log", evidence)?;
580            }
581            validate_evidence_file(
582                &artifact.operator,
583                "package_receipt",
584                &artifact.package_receipt,
585            )?;
586            if artifact.package_build_logs.is_empty()
587                || artifact
588                    .package_build_logs
589                    .windows(2)
590                    .any(|pair| pair[0].path >= pair[1].path)
591            {
592                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
593                    "{}.package_build_logs must be sorted, unique, and non-empty",
594                    artifact.operator
595                )));
596            }
597            for evidence in &artifact.package_build_logs {
598                validate_evidence_file(&artifact.operator, "package_build_log", evidence)?;
599            }
600            if artifact.license_files.is_empty()
601                || artifact
602                    .license_files
603                    .windows(2)
604                    .any(|pair| pair[0].path >= pair[1].path)
605            {
606                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
607                    "{}.license_files must be sorted, unique, and non-empty",
608                    artifact.operator
609                )));
610            }
611            for evidence in &artifact.license_files {
612                validate_evidence_file(&artifact.operator, "license_file", evidence)?;
613            }
614            if artifact.required_exports.is_empty() {
615                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
616                    "{}.required_exports must be non-empty",
617                    artifact.operator
618                )));
619            }
620            if artifact
621                .required_exports
622                .windows(2)
623                .any(|pair| pair[0] >= pair[1])
624            {
625                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
626                    "{}.required_exports must be sorted and unique",
627                    artifact.operator
628                )));
629            }
630            operation_binding_count = operation_binding_count
631                .checked_add(artifact.operation_bindings.len())
632                .ok_or_else(|| {
633                    NativeOperatorArtifactSetError::LockInvalid(
634                        "artifact-set operation binding count overflows usize".to_string(),
635                    )
636                })?;
637            if artifact
638                .system_libraries
639                .windows(2)
640                .any(|pair| pair[0] >= pair[1])
641            {
642                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
643                    "{}.system_libraries must be sorted and unique",
644                    artifact.operator
645                )));
646            }
647            validate_relative_path(&artifact.manifest_path)?;
648            validate_relative_path(&artifact.artifact_path)?;
649            if let Some(host_abi) = &artifact.host_abi {
650                host_abi
651                    .validate()
652                    .map_err(NativeOperatorArtifactSetError::LockInvalid)?;
653                if host_abi.compiler_flavor == ferrum_types::NativeOperatorCompilerFlavor::Msvc {
654                    if artifact
655                        .system_libraries
656                        .contains(&NativeOperatorSystemLibrary::StdCxx)
657                        || !artifact
658                            .system_libraries
659                            .contains(&NativeOperatorSystemLibrary::MsvcRuntime)
660                    {
661                        return Err(NativeOperatorArtifactSetError::LockInvalid("MSVC operators require msvc_runtime and cannot declare the GNU stdc++ runtime".into()));
662                    }
663                } else if artifact
664                    .system_libraries
665                    .contains(&NativeOperatorSystemLibrary::MsvcRuntime)
666                {
667                    return Err(NativeOperatorArtifactSetError::LockInvalid(
668                        "MSVC runtime requires a matching MSVC host ABI".into(),
669                    ));
670                }
671            } else if artifact
672                .system_libraries
673                .contains(&NativeOperatorSystemLibrary::MsvcRuntime)
674            {
675                return Err(NativeOperatorArtifactSetError::LockInvalid(
676                    "MSVC runtime requires a declared MSVC host ABI".into(),
677                ));
678            }
679        }
680        if operation_binding_count == 0 {
681            return Err(NativeOperatorArtifactSetError::LockInvalid(
682                "artifact set must bind at least one live G03 operation/provider".to_string(),
683            ));
684        }
685        Ok(())
686    }
687}
688
689pub fn native_artifact_link_name_for_target(
690    path: &Path,
691    linkage: NativeOperatorLinkage,
692    target: &str,
693) -> Result<String, String> {
694    let host = NativeOperatorHostAbi::for_target(target)?;
695    if host.compiler_flavor != ferrum_types::NativeOperatorCompilerFlavor::Msvc {
696        return native_artifact_link_name(path, linkage);
697    }
698    if linkage != NativeOperatorLinkage::Static
699        || !path
700            .extension()
701            .and_then(|value| value.to_str())
702            .is_some_and(|value| value.eq_ignore_ascii_case("lib"))
703    {
704        return Err(format!(
705            "MSVC native artifact must be a static .lib: {}",
706            path.display()
707        ));
708    }
709    let name = path
710        .file_stem()
711        .and_then(|value| value.to_str())
712        .filter(|name| !name.is_empty())
713        .ok_or_else(|| {
714            format!(
715                "MSVC artifact has no UTF-8 library name: {}",
716                path.display()
717            )
718        })?;
719    if !name
720        .chars()
721        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
722    {
723        return Err("MSVC native library name contains unsupported linker characters".into());
724    }
725    // Unlike Unix libfoo.a, MSVC libfoo.lib is linked as libfoo, not foo.
726    Ok(name.to_owned())
727}
728
729pub fn native_artifact_link_name(
730    path: &Path,
731    linkage: NativeOperatorLinkage,
732) -> Result<String, String> {
733    let name = path
734        .file_name()
735        .and_then(|name| name.to_str())
736        .ok_or_else(|| format!("native artifact has no UTF-8 file name: {}", path.display()))?;
737    let link_name = match linkage {
738        NativeOperatorLinkage::Static => name
739            .strip_prefix("lib")
740            .and_then(|value| value.strip_suffix(".a")),
741        NativeOperatorLinkage::Dynamic => {
742            if let Some(value) = name
743                .strip_prefix("lib")
744                .and_then(|value| value.strip_suffix(".dylib"))
745            {
746                Some(value)
747            } else {
748                name.strip_prefix("lib")
749                    .and_then(|value| value.split_once(".so"))
750                    .map(|(value, _)| value)
751            }
752        }
753    }
754    .filter(|value| !value.is_empty())
755    .ok_or_else(|| {
756        format!(
757            "native artifact file name does not match {:?} linkage: {}",
758            linkage,
759            path.display()
760        )
761    })?;
762    Ok(link_name.to_string())
763}
764
765fn validate_relative_path(path: &str) -> Result<(), NativeOperatorArtifactSetError> {
766    let path = Path::new(path);
767    if path.as_os_str().is_empty()
768        || path.is_absolute()
769        || path
770            .components()
771            .any(|component| matches!(component, Component::ParentDir | Component::RootDir))
772    {
773        return Err(NativeOperatorArtifactSetError::PathEscape(
774            path.display().to_string(),
775        ));
776    }
777    Ok(())
778}
779
780fn resolve_locked_path(
781    root: &Path,
782    relative: &str,
783    operator: &str,
784) -> Result<PathBuf, NativeOperatorArtifactSetError> {
785    validate_relative_path(relative)?;
786    let path = root.join(relative);
787    let canonical =
788        fs::canonicalize(&path).map_err(|source| NativeOperatorArtifactSetError::LockRead {
789            path: path.clone(),
790            source,
791        })?;
792    if !canonical.starts_with(root) {
793        return Err(NativeOperatorArtifactSetError::PathEscape(format!(
794            "{operator}:{relative}"
795        )));
796    }
797    Ok(canonical)
798}
799
800fn validate_evidence_file(
801    operator: &str,
802    field: &str,
803    evidence: &NativeOperatorEvidenceFile,
804) -> Result<(), NativeOperatorArtifactSetError> {
805    validate_relative_path(&evidence.path)?;
806    if !is_sha256_digest(&evidence.sha256) || evidence.size_bytes == 0 {
807        return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
808            "{operator}.{field} must record a non-empty file and lowercase sha256"
809        )));
810    }
811    Ok(())
812}
813
814fn verify_evidence_file(
815    root: &Path,
816    operator: &str,
817    field: &str,
818    evidence: &NativeOperatorEvidenceFile,
819) -> Result<(), NativeOperatorArtifactSetError> {
820    let path = resolve_locked_path(root, &evidence.path, operator)?;
821    let bytes = fs::read(&path).map_err(|source| NativeOperatorArtifactSetError::LockRead {
822        path: path.clone(),
823        source,
824    })?;
825    let actual_sha256 = format!("{:x}", Sha256::digest(&bytes));
826    let actual_size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
827    if actual_sha256 != evidence.sha256 {
828        return Err(NativeOperatorArtifactSetError::PinMismatch {
829            operator: operator.to_string(),
830            field: format!("{field}.sha256"),
831            expected: evidence.sha256.clone(),
832            actual: actual_sha256,
833        });
834    }
835    if actual_size != evidence.size_bytes {
836        return Err(NativeOperatorArtifactSetError::PinMismatch {
837            operator: operator.to_string(),
838            field: format!("{field}.size_bytes"),
839            expected: evidence.size_bytes.to_string(),
840            actual: actual_size.to_string(),
841        });
842    }
843    Ok(())
844}
845
846fn require_pin(
847    artifact: &NativeOperatorArtifactLock,
848    field: &str,
849    expected: &str,
850    actual: &str,
851) -> Result<(), NativeOperatorArtifactSetError> {
852    if expected == actual {
853        Ok(())
854    } else {
855        Err(NativeOperatorArtifactSetError::PinMismatch {
856            operator: artifact.operator.clone(),
857            field: field.to_string(),
858            expected: expected.to_string(),
859            actual: actual.to_string(),
860        })
861    }
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867    use std::process::Command;
868    use std::sync::atomic::{AtomicU64, Ordering};
869    use std::time::{SystemTime, UNIX_EPOCH};
870
871    use ferrum_types::{
872        NativeOperatorBuildSummary, NativeOperatorManifest, NativeOperatorSourcePackage,
873        FERRUM_NATIVE_OPERATOR_ABI_VERSION,
874    };
875    use sha2::{Digest, Sha256};
876
877    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
878
879    struct TestDir(PathBuf);
880
881    impl TestDir {
882        fn path(&self) -> &Path {
883            &self.0
884        }
885    }
886
887    impl Drop for TestDir {
888        fn drop(&mut self) {
889            let _ = fs::remove_dir_all(&self.0);
890        }
891    }
892
893    fn temp_dir(name: &str) -> TestDir {
894        let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
895        let unique = SystemTime::now()
896            .duration_since(UNIX_EPOCH)
897            .unwrap()
898            .as_nanos();
899        let path = std::env::temp_dir().join(format!(
900            "ferrum-native-artifact-set-{name}-{}-{counter}-{unique}",
901            std::process::id()
902        ));
903        fs::create_dir_all(&path).unwrap();
904        TestDir(path)
905    }
906
907    fn digest_bytes(bytes: &[u8]) -> String {
908        format!("{:x}", Sha256::digest(bytes))
909    }
910
911    fn digest(ch: char) -> String {
912        std::iter::repeat(ch).take(64).collect()
913    }
914
915    fn write_artifact(
916        root: &Path,
917        operator: &str,
918        operation_id: &str,
919        provider_id: &str,
920        extra_strong_symbol: Option<&str>,
921    ) -> NativeOperatorArtifactLock {
922        write_artifact_for_target(
923            root,
924            operator,
925            operation_id,
926            provider_id,
927            extra_strong_symbol,
928            cfg!(windows).then_some("x86_64-pc-windows-msvc"),
929        )
930    }
931
932    fn write_artifact_for_target(
933        root: &Path,
934        operator: &str,
935        operation_id: &str,
936        provider_id: &str,
937        extra_strong_symbol: Option<&str>,
938        target: Option<&str>,
939    ) -> NativeOperatorArtifactLock {
940        let dir = root.join(operator);
941        fs::create_dir_all(&dir).unwrap();
942        let descriptor = format!("ferrum_native_{operator}_descriptor_v2");
943        let execute = format!("ferrum_native_{operator}_execute_v1");
944        let mut source = format!(
945            "int {execute}(void) {{ return 0; }}\n\
946             const char *{descriptor}(void) {{ return \"{operator}\"; }}\n"
947        );
948        if let Some(symbol) = extra_strong_symbol {
949            source.push_str(&format!("int {symbol}(void) {{ return 1; }}\n"));
950        }
951        let source_path = dir.join("operator.c");
952        let object_path = dir.join("operator.o");
953        let artifact_name = if target.is_some() {
954            format!("ferrum_native_{operator}.lib")
955        } else {
956            format!("libferrum_native_{operator}.a")
957        };
958        let artifact_path = dir.join(&artifact_name);
959        fs::write(&source_path, source).unwrap();
960        if target.is_some() {
961            let mut exports = vec![descriptor.as_str(), execute.as_str()];
962            if let Some(symbol) = extra_strong_symbol {
963                exports.push(symbol);
964            }
965            fs::write(&artifact_path, crate::coff::tests::native_library(&exports)).unwrap();
966        } else {
967            assert!(Command::new("cc")
968                .args(["-c"])
969                .arg(&source_path)
970                .arg("-o")
971                .arg(&object_path)
972                .status()
973                .unwrap()
974                .success());
975            assert!(Command::new("ar")
976                .arg("rcs")
977                .arg(&artifact_path)
978                .arg(&object_path)
979                .status()
980                .unwrap()
981                .success());
982        }
983        let host_abi = target.map(|target| NativeOperatorHostAbi::for_target(target).unwrap());
984
985        let binary_sha256 = digest_bytes(&fs::read(&artifact_path).unwrap());
986        let source_package_sha256 = digest(if operator == "alpha" { 'a' } else { 'b' });
987        let inputs_sha256 = digest(if operator == "alpha" { 'c' } else { 'd' });
988        let g03_catalog_bytes = b"{\"schema_version\":1}\n";
989        let abi_contract_bytes = b"{\"schema_version\":1}\n";
990        let g03_catalog_sha256 = digest_bytes(g03_catalog_bytes);
991        let abi_contract_sha256 = digest_bytes(abi_contract_bytes);
992        let provider_fingerprint = digest(if operator == "alpha" { '1' } else { '2' });
993        let mut exports = vec![descriptor.clone(), execute.clone()];
994        if let Some(symbol) = extra_strong_symbol {
995            exports.push(symbol.to_string());
996            exports.sort();
997        }
998        let operation_bindings = vec![NativeOperatorBinding {
999            operation_id: operation_id.to_string(),
1000            operation_contract_version: NativeOperatorContractVersion::new(1, 0),
1001            provider_id: provider_id.to_string(),
1002            provider_version: NativeOperatorContractVersion::new(1, 0),
1003            provider_implementation_fingerprint: provider_fingerprint,
1004            entrypoints: vec![execute],
1005        }];
1006        let manifest = NativeOperatorManifest {
1007            schema_version: NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION,
1008            operator: operator.to_string(),
1009            operator_abi_version: "1".to_string(),
1010            ferrum_native_abi_version: FERRUM_NATIVE_OPERATOR_ABI_VERSION.to_string(),
1011            backend: NativeOperatorBackend::Cuda,
1012            cuda_toolkit: Some("12.4".to_string()),
1013            cuda_runtime_min: Some("12.4".to_string()),
1014            compute_capabilities: vec!["sm_89".to_string()],
1015            source_package: NativeOperatorSourcePackage {
1016                kind: "external_archive".to_string(),
1017                revision: "fixture".to_string(),
1018                sha256: source_package_sha256.clone(),
1019            },
1020            inputs_sha256: inputs_sha256.clone(),
1021            binary_sha256: binary_sha256.clone(),
1022            linkage: NativeOperatorLinkage::Static,
1023            host_abi: host_abi.clone(),
1024            g03_catalog_sha256: Some(g03_catalog_sha256.clone()),
1025            abi_contract_sha256: Some(abi_contract_sha256.clone()),
1026            descriptor_export: Some(descriptor.clone()),
1027            operation_bindings: operation_bindings.clone(),
1028            exports: exports.clone(),
1029            license_files: vec!["LICENSE".to_string()],
1030            build_summary: NativeOperatorBuildSummary {
1031                builder_sha: digest('7'),
1032                elapsed_ms: 1,
1033                nvcc_version: Some("12.4".to_string()),
1034                host_compiler: "cc".to_string(),
1035            },
1036        };
1037        let manifest_path = dir.join("native_operator_manifest.json");
1038        fs::write(
1039            &manifest_path,
1040            serde_json::to_string_pretty(&manifest).unwrap(),
1041        )
1042        .unwrap();
1043        let receipt_path = dir.join("source-build.receipt.json");
1044        let plan_path = dir.join("source-build.plan.json");
1045        let log_path = dir.join("source-build.log");
1046        let package_spec_path = dir.join("package.spec.json");
1047        let g03_catalog_path = dir.join("g03-provider-catalog.json");
1048        let abi_contract_path = dir.join("native-abi-contract.json");
1049        let source_input_path = dir.join("cuda-static-manifest.json");
1050        let package_receipt_path = dir.join("package.receipt.json");
1051        let package_log_path = dir.join("package-build.log");
1052        let license_path = dir.join("LICENSE");
1053        fs::write(&receipt_path, "{\"status\":\"pass\"}\n").unwrap();
1054        fs::write(&plan_path, "{\"schema_version\":2}\n").unwrap();
1055        fs::write(&log_path, "source build complete\n").unwrap();
1056        fs::write(&package_spec_path, "{\"schema_version\":2}\n").unwrap();
1057        fs::write(&g03_catalog_path, g03_catalog_bytes).unwrap();
1058        fs::write(&abi_contract_path, abi_contract_bytes).unwrap();
1059        fs::write(&source_input_path, "{\"schema_version\":1}\n").unwrap();
1060        fs::write(&package_receipt_path, "{\"status\":\"pass\"}\n").unwrap();
1061        fs::write(&package_log_path, "package build complete\n").unwrap();
1062        fs::write(&license_path, "fixture license\n").unwrap();
1063        let evidence = |path: &Path| NativeOperatorEvidenceFile {
1064            path: format!("{operator}/{}", path.file_name().unwrap().to_string_lossy()),
1065            sha256: digest_bytes(&fs::read(path).unwrap()),
1066            size_bytes: fs::metadata(path).unwrap().len(),
1067        };
1068        NativeOperatorArtifactLock {
1069            host_abi,
1070            operator: operator.to_string(),
1071            backend: NativeOperatorBackend::Cuda,
1072            manifest_path: format!("{operator}/native_operator_manifest.json"),
1073            manifest: evidence(&manifest_path),
1074            artifact_path: format!("{operator}/{artifact_name}"),
1075            operator_abi_version: "1".to_string(),
1076            ferrum_native_abi_version: FERRUM_NATIVE_OPERATOR_ABI_VERSION.to_string(),
1077            source_package_sha256,
1078            inputs_sha256,
1079            package_spec: evidence(&package_spec_path),
1080            g03_catalog: evidence(&g03_catalog_path),
1081            abi_contract: evidence(&abi_contract_path),
1082            source_build_receipt: evidence(&receipt_path),
1083            source_build_plan: evidence(&plan_path),
1084            source_build_inputs: vec![evidence(&source_input_path)],
1085            source_build_logs: vec![evidence(&log_path)],
1086            source_archive_sha256: digest('8'),
1087            package_receipt: evidence(&package_receipt_path),
1088            package_build_logs: vec![evidence(&package_log_path)],
1089            license_files: vec![evidence(&license_path)],
1090            binary_sha256,
1091            abi_contract_sha256,
1092            descriptor_export: descriptor,
1093            required_exports: exports,
1094            operation_bindings,
1095            system_libraries: vec![
1096                NativeOperatorSystemLibrary::CudaRuntime,
1097                if target.is_some() {
1098                    NativeOperatorSystemLibrary::MsvcRuntime
1099                } else {
1100                    NativeOperatorSystemLibrary::StdCxx
1101                },
1102            ],
1103        }
1104    }
1105
1106    fn rewrite_bindings(
1107        root: &Path,
1108        artifact: &mut NativeOperatorArtifactLock,
1109        bindings: Vec<NativeOperatorBinding>,
1110    ) {
1111        let manifest_path = root.join(&artifact.manifest_path);
1112        let mut manifest: NativeOperatorManifest =
1113            serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
1114        manifest.operation_bindings = bindings.clone();
1115        let bytes = serde_json::to_vec_pretty(&manifest).unwrap();
1116        fs::write(&manifest_path, &bytes).unwrap();
1117        artifact.manifest.sha256 = digest_bytes(&bytes);
1118        artifact.manifest.size_bytes = bytes.len().try_into().unwrap();
1119        artifact.operation_bindings = bindings;
1120    }
1121
1122    #[test]
1123    fn resolves_multiple_schema_v5_artifacts_in_deterministic_order() {
1124        let dir = temp_dir("pass");
1125        let alpha = write_artifact(
1126            dir.path(),
1127            "alpha",
1128            "operation.alpha",
1129            "provider.cuda.alpha",
1130            None,
1131        );
1132        let beta = write_artifact(
1133            dir.path(),
1134            "beta",
1135            "operation.beta",
1136            "provider.cuda.beta",
1137            None,
1138        );
1139        let g03_catalog_sha256 = alpha.g03_catalog.sha256.clone();
1140        let lock = NativeOperatorArtifactSetLock {
1141            schema_version: NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION,
1142            g03_catalog_sha256,
1143            artifacts: vec![alpha, beta],
1144        };
1145        let lock_path = dir.path().join("native-operators.lock.json");
1146        fs::write(&lock_path, serde_json::to_string_pretty(&lock).unwrap()).unwrap();
1147
1148        let resolved =
1149            NativeOperatorArtifactSetLock::load_and_resolve(&lock_path, Some("sm_89")).unwrap();
1150        assert_eq!(resolved.artifacts.len(), 2);
1151        assert_eq!(resolved.artifacts[0].resolved.manifest.operator, "alpha");
1152        assert_eq!(resolved.artifacts[1].resolved.manifest.operator, "beta");
1153    }
1154
1155    #[test]
1156    fn resolves_msvc_set_only_for_matching_host_and_runtime() {
1157        let dir = temp_dir("msvc-set");
1158        let target = "x86_64-pc-windows-msvc";
1159        let artifact = write_artifact_for_target(
1160            dir.path(),
1161            "alpha",
1162            "operation.alpha",
1163            "provider.cuda.alpha",
1164            None,
1165            Some(target),
1166        );
1167        let mut lock = NativeOperatorArtifactSetLock {
1168            schema_version: NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION,
1169            g03_catalog_sha256: artifact.g03_catalog.sha256.clone(),
1170            artifacts: vec![artifact],
1171        };
1172        let path = dir.path().join("native-operators.lock.json");
1173        lock.resolve_for_target(&path, Some("sm_89"), target)
1174            .unwrap();
1175        assert!(lock
1176            .resolve_for_target(&path, Some("sm_89"), "x86_64-unknown-linux-gnu")
1177            .is_err());
1178        let host = lock.artifacts[0].host_abi.take();
1179        assert!(lock
1180            .resolve_for_target(&path, Some("sm_89"), target)
1181            .is_err());
1182        lock.artifacts[0].host_abi = host;
1183        lock.artifacts[0].system_libraries = vec![
1184            NativeOperatorSystemLibrary::CudaRuntime,
1185            NativeOperatorSystemLibrary::StdCxx,
1186        ];
1187        assert!(lock
1188            .resolve_for_target(&path, Some("sm_89"), target)
1189            .is_err());
1190    }
1191
1192    #[test]
1193    fn msvc_link_names_preserve_lib_prefix_and_reject_unix_or_invalid_targets() {
1194        let target = "x86_64-pc-windows-msvc";
1195        assert_eq!(
1196            native_artifact_link_name_for_target(
1197                Path::new("libnative.lib"),
1198                NativeOperatorLinkage::Static,
1199                target
1200            )
1201            .unwrap(),
1202            "libnative"
1203        );
1204        assert_eq!(
1205            native_artifact_link_name_for_target(
1206                Path::new("native.LIB"),
1207                NativeOperatorLinkage::Static,
1208                target
1209            )
1210            .unwrap(),
1211            "native"
1212        );
1213        assert!(native_artifact_link_name_for_target(
1214            Path::new("libnative.a"),
1215            NativeOperatorLinkage::Static,
1216            target
1217        )
1218        .is_err());
1219        assert!(native_artifact_link_name_for_target(
1220            Path::new("native.lib"),
1221            NativeOperatorLinkage::Static,
1222            "x86_64-pc-windows-gnu"
1223        )
1224        .is_err());
1225        assert!(native_artifact_link_name_for_target(
1226            Path::new("native.lib"),
1227            NativeOperatorLinkage::Static,
1228            "x86_64-unknown-linux-gnu"
1229        )
1230        .is_err());
1231        assert_eq!(
1232            native_artifact_link_name_for_target(
1233                Path::new("libnative.a"),
1234                NativeOperatorLinkage::Static,
1235                "x86_64-unknown-linux-gnu"
1236            )
1237            .unwrap(),
1238            "native"
1239        );
1240    }
1241
1242    #[test]
1243    fn msvc_set_rejects_case_insensitive_link_name_collision() {
1244        let dir = temp_dir("msvc-link-name-collision");
1245        let target = "x86_64-pc-windows-msvc";
1246        let mut artifacts = Vec::new();
1247        for (operator, library) in [("alpha", "native.lib"), ("beta", "NATIVE.lib")] {
1248            let mut artifact = write_artifact_for_target(
1249                dir.path(),
1250                operator,
1251                &format!("operation.{operator}"),
1252                &format!("provider.cuda.{operator}"),
1253                None,
1254                Some(target),
1255            );
1256            let path = format!("{operator}/{library}");
1257            fs::rename(
1258                dir.path().join(&artifact.artifact_path),
1259                dir.path().join(&path),
1260            )
1261            .unwrap();
1262            artifact.artifact_path = path;
1263            artifacts.push(artifact);
1264        }
1265        let lock = NativeOperatorArtifactSetLock {
1266            schema_version: NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION,
1267            g03_catalog_sha256: artifacts[0].g03_catalog.sha256.clone(),
1268            artifacts,
1269        };
1270        let error = lock
1271            .resolve_for_target(dir.path().join("lock.json"), Some("sm_89"), target)
1272            .unwrap_err();
1273        assert!(
1274            matches!(
1275                error,
1276                NativeOperatorArtifactSetError::LinkNameCollision { .. }
1277            ),
1278            "{error}"
1279        );
1280    }
1281
1282    #[test]
1283    fn resolves_unbound_leaf_and_shared_provider_across_multiple_archives() {
1284        let dir = temp_dir("many-to-many");
1285        let mut unbound = write_artifact(
1286            dir.path(),
1287            "alpha",
1288            "operation.alpha",
1289            "provider.cuda.alpha",
1290            None,
1291        );
1292        rewrite_bindings(dir.path(), &mut unbound, Vec::new());
1293        let mut first = write_artifact(
1294            dir.path(),
1295            "beta",
1296            "operation.shared",
1297            "provider.cuda.shared",
1298            None,
1299        );
1300        let mut second = write_artifact(
1301            dir.path(),
1302            "gamma",
1303            "operation.shared",
1304            "provider.cuda.shared",
1305            None,
1306        );
1307        let first_identity = first.operation_bindings[0].clone();
1308        let mut second_binding = second.operation_bindings[0].clone();
1309        second_binding.operation_contract_version = first_identity.operation_contract_version;
1310        second_binding.provider_version = first_identity.provider_version;
1311        second_binding.provider_implementation_fingerprint =
1312            first_identity.provider_implementation_fingerprint.clone();
1313        rewrite_bindings(dir.path(), &mut first, vec![first_identity]);
1314        rewrite_bindings(dir.path(), &mut second, vec![second_binding]);
1315        let g03_catalog_sha256 = unbound.g03_catalog.sha256.clone();
1316        let lock = NativeOperatorArtifactSetLock {
1317            schema_version: NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION,
1318            g03_catalog_sha256,
1319            artifacts: vec![unbound, first, second],
1320        };
1321        let lock_path = dir.path().join("native-operators.lock.json");
1322        fs::write(&lock_path, serde_json::to_vec_pretty(&lock).unwrap()).unwrap();
1323
1324        let resolved =
1325            NativeOperatorArtifactSetLock::load_and_resolve(&lock_path, Some("sm_89")).unwrap();
1326        assert_eq!(resolved.artifacts.len(), 3);
1327        assert!(resolved.artifacts[0]
1328            .resolved
1329            .manifest
1330            .operation_bindings
1331            .is_empty());
1332    }
1333
1334    #[test]
1335    fn rejects_conflicting_provider_identity_across_archives() {
1336        let dir = temp_dir("provider-conflict");
1337        let alpha = write_artifact(
1338            dir.path(),
1339            "alpha",
1340            "operation.shared",
1341            "provider.cuda.shared",
1342            None,
1343        );
1344        let beta = write_artifact(
1345            dir.path(),
1346            "beta",
1347            "operation.shared",
1348            "provider.cuda.shared",
1349            None,
1350        );
1351        let g03_catalog_sha256 = alpha.g03_catalog.sha256.clone();
1352        let lock = NativeOperatorArtifactSetLock {
1353            schema_version: NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION,
1354            g03_catalog_sha256,
1355            artifacts: vec![alpha, beta],
1356        };
1357        let lock_path = dir.path().join("native-operators.lock.json");
1358        fs::write(&lock_path, serde_json::to_vec_pretty(&lock).unwrap()).unwrap();
1359
1360        let error =
1361            NativeOperatorArtifactSetLock::load_and_resolve(&lock_path, Some("sm_89")).unwrap_err();
1362        assert!(matches!(
1363            error,
1364            NativeOperatorArtifactSetError::OperationProviderIdentityConflict { .. }
1365        ));
1366    }
1367
1368    #[test]
1369    fn rejects_cross_artifact_strong_symbol_collision() {
1370        let dir = temp_dir("symbol-collision");
1371        let alpha = write_artifact(
1372            dir.path(),
1373            "alpha",
1374            "operation.alpha",
1375            "provider.cuda.alpha",
1376            Some("ferrum_native_shared_collision"),
1377        );
1378        let beta = write_artifact(
1379            dir.path(),
1380            "beta",
1381            "operation.beta",
1382            "provider.cuda.beta",
1383            Some("ferrum_native_shared_collision"),
1384        );
1385        let g03_catalog_sha256 = alpha.g03_catalog.sha256.clone();
1386        let lock = NativeOperatorArtifactSetLock {
1387            schema_version: NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION,
1388            g03_catalog_sha256,
1389            artifacts: vec![alpha, beta],
1390        };
1391        let lock_path = dir.path().join("native-operators.lock.json");
1392        fs::write(&lock_path, serde_json::to_string_pretty(&lock).unwrap()).unwrap();
1393
1394        let error =
1395            NativeOperatorArtifactSetLock::load_and_resolve(&lock_path, Some("sm_89")).unwrap_err();
1396        assert!(matches!(
1397            error,
1398            NativeOperatorArtifactSetError::StaticSymbolCollision { .. }
1399        ));
1400    }
1401
1402    #[test]
1403    fn rejects_tampered_package_provenance_files() {
1404        for field in [
1405            "manifest",
1406            "package_spec",
1407            "g03_catalog",
1408            "abi_contract",
1409            "package_receipt",
1410            "package_build_log",
1411            "license_file",
1412        ] {
1413            let dir = temp_dir(field);
1414            let artifact = write_artifact(
1415                dir.path(),
1416                "alpha",
1417                "operation.alpha",
1418                "provider.cuda.alpha",
1419                None,
1420            );
1421            let evidence = match field {
1422                "manifest" => &artifact.manifest,
1423                "package_spec" => &artifact.package_spec,
1424                "g03_catalog" => &artifact.g03_catalog,
1425                "abi_contract" => &artifact.abi_contract,
1426                "package_receipt" => &artifact.package_receipt,
1427                "package_build_log" => &artifact.package_build_logs[0],
1428                "license_file" => &artifact.license_files[0],
1429                _ => unreachable!(),
1430            };
1431            let evidence_path = dir.path().join(&evidence.path);
1432            fs::write(&evidence_path, format!("tampered {field}\n")).unwrap();
1433            let g03_catalog_sha256 = artifact.g03_catalog.sha256.clone();
1434            let lock = NativeOperatorArtifactSetLock {
1435                schema_version: NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION,
1436                g03_catalog_sha256,
1437                artifacts: vec![artifact],
1438            };
1439            let lock_path = dir.path().join("native-operators.lock.json");
1440            fs::write(&lock_path, serde_json::to_string_pretty(&lock).unwrap()).unwrap();
1441
1442            let error = NativeOperatorArtifactSetLock::load_and_resolve(&lock_path, Some("sm_89"))
1443                .unwrap_err();
1444            assert!(matches!(
1445                error,
1446                NativeOperatorArtifactSetError::PinMismatch { .. }
1447            ));
1448        }
1449    }
1450}