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    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}
61
62#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
63pub struct NativeOperatorEvidenceFile {
64    pub path: String,
65    pub sha256: String,
66    pub size_bytes: u64,
67}
68
69#[derive(Debug)]
70pub struct ResolvedNativeOperatorArtifactSet {
71    pub lock_path: PathBuf,
72    pub g03_catalog_sha256: String,
73    pub artifacts: Vec<ResolvedNativeOperatorArtifact>,
74}
75
76#[derive(Debug)]
77pub struct ResolvedNativeOperatorArtifact {
78    pub lock: NativeOperatorArtifactLock,
79    pub resolved: ResolvedNativeOperator,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum NativeOperatorSystemLibrary {
85    CudaDriver,
86    CudaRuntime,
87    Cublas,
88    CublasLt,
89    StdCxx,
90}
91
92#[derive(Debug, Error)]
93pub enum NativeOperatorArtifactSetError {
94    #[error("native operator artifact-set lock does not exist: {0}")]
95    LockMissing(PathBuf),
96    #[error("failed to read native operator artifact-set lock {path}: {source}")]
97    LockRead { path: PathBuf, source: io::Error },
98    #[error("failed to parse native operator artifact-set lock {path}: {source}")]
99    LockJson {
100        path: PathBuf,
101        source: serde_json::Error,
102    },
103    #[error("invalid native operator artifact-set lock: {0}")]
104    LockInvalid(String),
105    #[error("native operator artifact-set path escapes its lock directory: {0}")]
106    PathEscape(String),
107    #[error("native operator artifact resolution failed for {operator}: {source}")]
108    ArtifactResolve {
109        operator: String,
110        #[source]
111        source: NativeOperatorResolveError,
112    },
113    #[error(
114        "native operator artifact-set pin mismatch for {operator}.{field}: expected={expected} actual={actual}"
115    )]
116    PinMismatch {
117        operator: String,
118        field: String,
119        expected: String,
120        actual: String,
121    },
122    #[error(
123        "native operator artifact-set static symbol collision: symbol={symbol} first={first} second={second}"
124    )]
125    StaticSymbolCollision {
126        symbol: String,
127        first: String,
128        second: String,
129    },
130    #[error(
131        "native operator artifact-set link-name collision: link_name={link_name} first={first} second={second}"
132    )]
133    LinkNameCollision {
134        link_name: String,
135        first: String,
136        second: String,
137    },
138    #[error(
139        "native operator artifact-set operation/provider identity conflict: operation={operation_id} provider={provider_id} first={first} second={second}"
140    )]
141    OperationProviderIdentityConflict {
142        operation_id: String,
143        provider_id: String,
144        first: String,
145        second: String,
146    },
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
150struct OperationProviderIdentity {
151    operation_contract_version: NativeOperatorContractVersion,
152    provider_version: NativeOperatorContractVersion,
153    provider_implementation_fingerprint: String,
154}
155
156impl From<&NativeOperatorBinding> for OperationProviderIdentity {
157    fn from(binding: &NativeOperatorBinding) -> Self {
158        Self {
159            operation_contract_version: binding.operation_contract_version,
160            provider_version: binding.provider_version,
161            provider_implementation_fingerprint: binding
162                .provider_implementation_fingerprint
163                .clone(),
164        }
165    }
166}
167
168impl NativeOperatorArtifactSetLock {
169    pub fn load_and_resolve(
170        lock_path: impl AsRef<Path>,
171        compute_capability: Option<&str>,
172    ) -> Result<ResolvedNativeOperatorArtifactSet, NativeOperatorArtifactSetError> {
173        let lock_path = lock_path.as_ref();
174        if !lock_path.is_file() {
175            return Err(NativeOperatorArtifactSetError::LockMissing(
176                lock_path.to_path_buf(),
177            ));
178        }
179        let raw = fs::read_to_string(lock_path).map_err(|source| {
180            NativeOperatorArtifactSetError::LockRead {
181                path: lock_path.to_path_buf(),
182                source,
183            }
184        })?;
185        let lock: Self = serde_json::from_str(&raw).map_err(|source| {
186            NativeOperatorArtifactSetError::LockJson {
187                path: lock_path.to_path_buf(),
188                source,
189            }
190        })?;
191        lock.resolve(lock_path, compute_capability)
192    }
193
194    pub fn resolve(
195        &self,
196        lock_path: impl AsRef<Path>,
197        compute_capability: Option<&str>,
198    ) -> Result<ResolvedNativeOperatorArtifactSet, NativeOperatorArtifactSetError> {
199        self.validate()?;
200        let lock_path = lock_path.as_ref();
201        let root = lock_path.parent().unwrap_or_else(|| Path::new("."));
202        let canonical_root =
203            fs::canonicalize(root).map_err(|source| NativeOperatorArtifactSetError::LockRead {
204                path: root.to_path_buf(),
205                source,
206            })?;
207
208        let mut resolved_artifacts = Vec::with_capacity(self.artifacts.len());
209        let mut link_names = BTreeMap::<String, String>::new();
210        let mut strong_symbols = BTreeMap::<String, String>::new();
211        let mut operation_providers =
212            BTreeMap::<(String, String), (OperationProviderIdentity, String)>::new();
213
214        for artifact_lock in &self.artifacts {
215            let manifest_path = resolve_locked_path(
216                &canonical_root,
217                &artifact_lock.manifest_path,
218                &artifact_lock.operator,
219            )?;
220            let artifact_path = resolve_locked_path(
221                &canonical_root,
222                &artifact_lock.artifact_path,
223                &artifact_lock.operator,
224            )?;
225            verify_evidence_file(
226                &canonical_root,
227                &artifact_lock.operator,
228                "manifest",
229                &artifact_lock.manifest,
230            )?;
231            verify_evidence_file(
232                &canonical_root,
233                &artifact_lock.operator,
234                "package_spec",
235                &artifact_lock.package_spec,
236            )?;
237            verify_evidence_file(
238                &canonical_root,
239                &artifact_lock.operator,
240                "g03_catalog",
241                &artifact_lock.g03_catalog,
242            )?;
243            verify_evidence_file(
244                &canonical_root,
245                &artifact_lock.operator,
246                "abi_contract",
247                &artifact_lock.abi_contract,
248            )?;
249            verify_evidence_file(
250                &canonical_root,
251                &artifact_lock.operator,
252                "source_build_receipt",
253                &artifact_lock.source_build_receipt,
254            )?;
255            verify_evidence_file(
256                &canonical_root,
257                &artifact_lock.operator,
258                "source_build_plan",
259                &artifact_lock.source_build_plan,
260            )?;
261            for evidence in &artifact_lock.source_build_inputs {
262                verify_evidence_file(
263                    &canonical_root,
264                    &artifact_lock.operator,
265                    "source_build_input",
266                    evidence,
267                )?;
268            }
269            for evidence in &artifact_lock.source_build_logs {
270                verify_evidence_file(
271                    &canonical_root,
272                    &artifact_lock.operator,
273                    "source_build_log",
274                    evidence,
275                )?;
276            }
277            verify_evidence_file(
278                &canonical_root,
279                &artifact_lock.operator,
280                "package_receipt",
281                &artifact_lock.package_receipt,
282            )?;
283            for evidence in &artifact_lock.package_build_logs {
284                verify_evidence_file(
285                    &canonical_root,
286                    &artifact_lock.operator,
287                    "package_build_log",
288                    evidence,
289                )?;
290            }
291            for evidence in &artifact_lock.license_files {
292                verify_evidence_file(
293                    &canonical_root,
294                    &artifact_lock.operator,
295                    "license_file",
296                    evidence,
297                )?;
298            }
299            let mut request = NativeOperatorResolveRequest::new(
300                artifact_lock.operator.clone(),
301                artifact_lock.backend,
302                manifest_path,
303                artifact_path,
304            )
305            .with_operator_abi_version(artifact_lock.operator_abi_version.clone())
306            .with_ferrum_native_abi_version(artifact_lock.ferrum_native_abi_version.clone())
307            .with_g03_catalog_sha256(self.g03_catalog_sha256.clone())
308            .with_abi_contract_sha256(artifact_lock.abi_contract_sha256.clone())
309            .with_descriptor_export(artifact_lock.descriptor_export.clone())
310            .with_required_exports(artifact_lock.required_exports.clone())
311            .with_operation_bindings(artifact_lock.operation_bindings.clone());
312            if let Some(compute_capability) = compute_capability {
313                request = request.with_compute_capability(compute_capability);
314            }
315            let resolved = NativeOperatorResolver.resolve(&request).map_err(|source| {
316                NativeOperatorArtifactSetError::ArtifactResolve {
317                    operator: artifact_lock.operator.clone(),
318                    source,
319                }
320            })?;
321            if resolved.manifest.schema_version != NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION {
322                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
323                    "{} uses legacy manifest schema {}; artifact sets require schema {}",
324                    artifact_lock.operator,
325                    resolved.manifest.schema_version,
326                    NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION
327                )));
328            }
329            require_pin(
330                artifact_lock,
331                "source_package_sha256",
332                &artifact_lock.source_package_sha256,
333                &resolved.manifest.source_package.sha256,
334            )?;
335            require_pin(
336                artifact_lock,
337                "inputs_sha256",
338                &artifact_lock.inputs_sha256,
339                &resolved.manifest.inputs_sha256,
340            )?;
341            require_pin(
342                artifact_lock,
343                "binary_sha256",
344                &artifact_lock.binary_sha256,
345                &resolved.artifact_sha256,
346            )?;
347
348            let link_name =
349                native_artifact_link_name(&resolved.artifact_path, resolved.manifest.linkage)
350                    .map_err(NativeOperatorArtifactSetError::LockInvalid)?;
351            if let Some(first) =
352                link_names.insert(link_name.clone(), artifact_lock.operator.clone())
353            {
354                return Err(NativeOperatorArtifactSetError::LinkNameCollision {
355                    link_name,
356                    first,
357                    second: artifact_lock.operator.clone(),
358                });
359            }
360            if resolved.manifest.linkage == NativeOperatorLinkage::Static {
361                for symbol in &resolved.binary_validation.strong_defined_symbols {
362                    if let Some(first) =
363                        strong_symbols.insert(symbol.clone(), artifact_lock.operator.clone())
364                    {
365                        if first != artifact_lock.operator {
366                            return Err(NativeOperatorArtifactSetError::StaticSymbolCollision {
367                                symbol: symbol.clone(),
368                                first,
369                                second: artifact_lock.operator.clone(),
370                            });
371                        }
372                    }
373                }
374            }
375            for binding in &resolved.manifest.operation_bindings {
376                let key = (binding.operation_id.clone(), binding.provider_id.clone());
377                let identity = OperationProviderIdentity::from(binding);
378                if let Some((first_identity, first_operator)) = operation_providers.get(&key) {
379                    if first_identity != &identity {
380                        return Err(
381                            NativeOperatorArtifactSetError::OperationProviderIdentityConflict {
382                                operation_id: key.0,
383                                provider_id: key.1,
384                                first: first_operator.clone(),
385                                second: artifact_lock.operator.clone(),
386                            },
387                        );
388                    }
389                } else {
390                    operation_providers.insert(key, (identity, artifact_lock.operator.clone()));
391                }
392            }
393            resolved_artifacts.push(ResolvedNativeOperatorArtifact {
394                lock: artifact_lock.clone(),
395                resolved,
396            });
397        }
398        if operation_providers.is_empty() {
399            return Err(NativeOperatorArtifactSetError::LockInvalid(
400                "artifact set must bind at least one live G03 operation/provider".to_string(),
401            ));
402        }
403
404        Ok(ResolvedNativeOperatorArtifactSet {
405            lock_path: lock_path.to_path_buf(),
406            g03_catalog_sha256: self.g03_catalog_sha256.clone(),
407            artifacts: resolved_artifacts,
408        })
409    }
410
411    fn validate(&self) -> Result<(), NativeOperatorArtifactSetError> {
412        if self.schema_version != NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION {
413            return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
414                "schema_version must be {NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION}"
415            )));
416        }
417        if !is_sha256_digest(&self.g03_catalog_sha256) {
418            return Err(NativeOperatorArtifactSetError::LockInvalid(
419                "g03_catalog_sha256 must be a lowercase hex sha256 digest".to_string(),
420            ));
421        }
422        if self.artifacts.is_empty() {
423            return Err(NativeOperatorArtifactSetError::LockInvalid(
424                "artifacts must be non-empty".to_string(),
425            ));
426        }
427        let mut previous: Option<&str> = None;
428        let mut operation_binding_count = 0_usize;
429        for artifact in &self.artifacts {
430            if artifact.operator.trim().is_empty() {
431                return Err(NativeOperatorArtifactSetError::LockInvalid(
432                    "artifact operator must be non-empty".to_string(),
433                ));
434            }
435            if artifact.ferrum_native_abi_version != FERRUM_NATIVE_OPERATOR_ABI_VERSION {
436                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
437                    "{}.ferrum_native_abi_version must be {}",
438                    artifact.operator, FERRUM_NATIVE_OPERATOR_ABI_VERSION
439                )));
440            }
441            if previous.is_some_and(|value| value >= artifact.operator.as_str()) {
442                return Err(NativeOperatorArtifactSetError::LockInvalid(
443                    "artifacts must be sorted and unique by operator".to_string(),
444                ));
445            }
446            previous = Some(&artifact.operator);
447            for (field, digest) in [
448                ("source_package_sha256", &artifact.source_package_sha256),
449                ("inputs_sha256", &artifact.inputs_sha256),
450                ("source_archive_sha256", &artifact.source_archive_sha256),
451                ("binary_sha256", &artifact.binary_sha256),
452                ("abi_contract_sha256", &artifact.abi_contract_sha256),
453            ] {
454                if !is_sha256_digest(digest) {
455                    return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
456                        "{}.{field} must be a lowercase hex sha256 digest",
457                        artifact.operator
458                    )));
459                }
460            }
461            validate_evidence_file(&artifact.operator, "manifest", &artifact.manifest)?;
462            if artifact.manifest.path != artifact.manifest_path {
463                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
464                    "{}.manifest evidence path must equal manifest_path",
465                    artifact.operator
466                )));
467            }
468            validate_evidence_file(&artifact.operator, "package_spec", &artifact.package_spec)?;
469            validate_evidence_file(&artifact.operator, "g03_catalog", &artifact.g03_catalog)?;
470            validate_evidence_file(&artifact.operator, "abi_contract", &artifact.abi_contract)?;
471            if artifact.g03_catalog.sha256 != self.g03_catalog_sha256 {
472                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
473                    "{}.g03_catalog sha256 must equal the artifact-set catalog pin",
474                    artifact.operator
475                )));
476            }
477            if artifact.abi_contract.sha256 != artifact.abi_contract_sha256 {
478                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
479                    "{}.abi_contract sha256 must equal abi_contract_sha256",
480                    artifact.operator
481                )));
482            }
483            validate_evidence_file(
484                &artifact.operator,
485                "source_build_receipt",
486                &artifact.source_build_receipt,
487            )?;
488            validate_evidence_file(
489                &artifact.operator,
490                "source_build_plan",
491                &artifact.source_build_plan,
492            )?;
493            if artifact.source_build_inputs.is_empty()
494                || artifact
495                    .source_build_inputs
496                    .windows(2)
497                    .any(|pair| pair[0].path >= pair[1].path)
498            {
499                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
500                    "{}.source_build_inputs must be sorted, unique, and non-empty",
501                    artifact.operator
502                )));
503            }
504            for evidence in &artifact.source_build_inputs {
505                validate_evidence_file(&artifact.operator, "source_build_input", evidence)?;
506            }
507            if artifact.source_build_logs.is_empty()
508                || artifact
509                    .source_build_logs
510                    .windows(2)
511                    .any(|pair| pair[0].path >= pair[1].path)
512            {
513                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
514                    "{}.source_build_logs must be sorted, unique, and non-empty",
515                    artifact.operator
516                )));
517            }
518            for evidence in &artifact.source_build_logs {
519                validate_evidence_file(&artifact.operator, "source_build_log", evidence)?;
520            }
521            validate_evidence_file(
522                &artifact.operator,
523                "package_receipt",
524                &artifact.package_receipt,
525            )?;
526            if artifact.package_build_logs.is_empty()
527                || artifact
528                    .package_build_logs
529                    .windows(2)
530                    .any(|pair| pair[0].path >= pair[1].path)
531            {
532                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
533                    "{}.package_build_logs must be sorted, unique, and non-empty",
534                    artifact.operator
535                )));
536            }
537            for evidence in &artifact.package_build_logs {
538                validate_evidence_file(&artifact.operator, "package_build_log", evidence)?;
539            }
540            if artifact.license_files.is_empty()
541                || artifact
542                    .license_files
543                    .windows(2)
544                    .any(|pair| pair[0].path >= pair[1].path)
545            {
546                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
547                    "{}.license_files must be sorted, unique, and non-empty",
548                    artifact.operator
549                )));
550            }
551            for evidence in &artifact.license_files {
552                validate_evidence_file(&artifact.operator, "license_file", evidence)?;
553            }
554            if artifact.required_exports.is_empty() {
555                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
556                    "{}.required_exports must be non-empty",
557                    artifact.operator
558                )));
559            }
560            if artifact
561                .required_exports
562                .windows(2)
563                .any(|pair| pair[0] >= pair[1])
564            {
565                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
566                    "{}.required_exports must be sorted and unique",
567                    artifact.operator
568                )));
569            }
570            operation_binding_count = operation_binding_count
571                .checked_add(artifact.operation_bindings.len())
572                .ok_or_else(|| {
573                    NativeOperatorArtifactSetError::LockInvalid(
574                        "artifact-set operation binding count overflows usize".to_string(),
575                    )
576                })?;
577            if artifact
578                .system_libraries
579                .windows(2)
580                .any(|pair| pair[0] >= pair[1])
581            {
582                return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
583                    "{}.system_libraries must be sorted and unique",
584                    artifact.operator
585                )));
586            }
587            validate_relative_path(&artifact.manifest_path)?;
588            validate_relative_path(&artifact.artifact_path)?;
589        }
590        if operation_binding_count == 0 {
591            return Err(NativeOperatorArtifactSetError::LockInvalid(
592                "artifact set must bind at least one live G03 operation/provider".to_string(),
593            ));
594        }
595        Ok(())
596    }
597}
598
599pub fn native_artifact_link_name(
600    path: &Path,
601    linkage: NativeOperatorLinkage,
602) -> Result<String, String> {
603    let name = path
604        .file_name()
605        .and_then(|name| name.to_str())
606        .ok_or_else(|| format!("native artifact has no UTF-8 file name: {}", path.display()))?;
607    let link_name = match linkage {
608        NativeOperatorLinkage::Static => name
609            .strip_prefix("lib")
610            .and_then(|value| value.strip_suffix(".a")),
611        NativeOperatorLinkage::Dynamic => {
612            if let Some(value) = name
613                .strip_prefix("lib")
614                .and_then(|value| value.strip_suffix(".dylib"))
615            {
616                Some(value)
617            } else {
618                name.strip_prefix("lib")
619                    .and_then(|value| value.split_once(".so"))
620                    .map(|(value, _)| value)
621            }
622        }
623    }
624    .filter(|value| !value.is_empty())
625    .ok_or_else(|| {
626        format!(
627            "native artifact file name does not match {:?} linkage: {}",
628            linkage,
629            path.display()
630        )
631    })?;
632    Ok(link_name.to_string())
633}
634
635fn validate_relative_path(path: &str) -> Result<(), NativeOperatorArtifactSetError> {
636    let path = Path::new(path);
637    if path.as_os_str().is_empty()
638        || path.is_absolute()
639        || path
640            .components()
641            .any(|component| matches!(component, Component::ParentDir | Component::RootDir))
642    {
643        return Err(NativeOperatorArtifactSetError::PathEscape(
644            path.display().to_string(),
645        ));
646    }
647    Ok(())
648}
649
650fn resolve_locked_path(
651    root: &Path,
652    relative: &str,
653    operator: &str,
654) -> Result<PathBuf, NativeOperatorArtifactSetError> {
655    validate_relative_path(relative)?;
656    let path = root.join(relative);
657    let canonical =
658        fs::canonicalize(&path).map_err(|source| NativeOperatorArtifactSetError::LockRead {
659            path: path.clone(),
660            source,
661        })?;
662    if !canonical.starts_with(root) {
663        return Err(NativeOperatorArtifactSetError::PathEscape(format!(
664            "{operator}:{relative}"
665        )));
666    }
667    Ok(canonical)
668}
669
670fn validate_evidence_file(
671    operator: &str,
672    field: &str,
673    evidence: &NativeOperatorEvidenceFile,
674) -> Result<(), NativeOperatorArtifactSetError> {
675    validate_relative_path(&evidence.path)?;
676    if !is_sha256_digest(&evidence.sha256) || evidence.size_bytes == 0 {
677        return Err(NativeOperatorArtifactSetError::LockInvalid(format!(
678            "{operator}.{field} must record a non-empty file and lowercase sha256"
679        )));
680    }
681    Ok(())
682}
683
684fn verify_evidence_file(
685    root: &Path,
686    operator: &str,
687    field: &str,
688    evidence: &NativeOperatorEvidenceFile,
689) -> Result<(), NativeOperatorArtifactSetError> {
690    let path = resolve_locked_path(root, &evidence.path, operator)?;
691    let bytes = fs::read(&path).map_err(|source| NativeOperatorArtifactSetError::LockRead {
692        path: path.clone(),
693        source,
694    })?;
695    let actual_sha256 = format!("{:x}", Sha256::digest(&bytes));
696    let actual_size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
697    if actual_sha256 != evidence.sha256 {
698        return Err(NativeOperatorArtifactSetError::PinMismatch {
699            operator: operator.to_string(),
700            field: format!("{field}.sha256"),
701            expected: evidence.sha256.clone(),
702            actual: actual_sha256,
703        });
704    }
705    if actual_size != evidence.size_bytes {
706        return Err(NativeOperatorArtifactSetError::PinMismatch {
707            operator: operator.to_string(),
708            field: format!("{field}.size_bytes"),
709            expected: evidence.size_bytes.to_string(),
710            actual: actual_size.to_string(),
711        });
712    }
713    Ok(())
714}
715
716fn require_pin(
717    artifact: &NativeOperatorArtifactLock,
718    field: &str,
719    expected: &str,
720    actual: &str,
721) -> Result<(), NativeOperatorArtifactSetError> {
722    if expected == actual {
723        Ok(())
724    } else {
725        Err(NativeOperatorArtifactSetError::PinMismatch {
726            operator: artifact.operator.clone(),
727            field: field.to_string(),
728            expected: expected.to_string(),
729            actual: actual.to_string(),
730        })
731    }
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use std::process::Command;
738    use std::sync::atomic::{AtomicU64, Ordering};
739    use std::time::{SystemTime, UNIX_EPOCH};
740
741    use ferrum_types::{
742        NativeOperatorBuildSummary, NativeOperatorManifest, NativeOperatorSourcePackage,
743        FERRUM_NATIVE_OPERATOR_ABI_VERSION,
744    };
745    use sha2::{Digest, Sha256};
746
747    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
748
749    struct TestDir(PathBuf);
750
751    impl TestDir {
752        fn path(&self) -> &Path {
753            &self.0
754        }
755    }
756
757    impl Drop for TestDir {
758        fn drop(&mut self) {
759            let _ = fs::remove_dir_all(&self.0);
760        }
761    }
762
763    fn temp_dir(name: &str) -> TestDir {
764        let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
765        let unique = SystemTime::now()
766            .duration_since(UNIX_EPOCH)
767            .unwrap()
768            .as_nanos();
769        let path = std::env::temp_dir().join(format!(
770            "ferrum-native-artifact-set-{name}-{}-{counter}-{unique}",
771            std::process::id()
772        ));
773        fs::create_dir_all(&path).unwrap();
774        TestDir(path)
775    }
776
777    fn digest_bytes(bytes: &[u8]) -> String {
778        format!("{:x}", Sha256::digest(bytes))
779    }
780
781    fn digest(ch: char) -> String {
782        std::iter::repeat(ch).take(64).collect()
783    }
784
785    fn write_artifact(
786        root: &Path,
787        operator: &str,
788        operation_id: &str,
789        provider_id: &str,
790        extra_strong_symbol: Option<&str>,
791    ) -> NativeOperatorArtifactLock {
792        let dir = root.join(operator);
793        fs::create_dir_all(&dir).unwrap();
794        let descriptor = format!("ferrum_native_{operator}_descriptor_v2");
795        let execute = format!("ferrum_native_{operator}_execute_v1");
796        let mut source = format!(
797            "int {execute}(void) {{ return 0; }}\n\
798             const char *{descriptor}(void) {{ return \"{operator}\"; }}\n"
799        );
800        if let Some(symbol) = extra_strong_symbol {
801            source.push_str(&format!("int {symbol}(void) {{ return 1; }}\n"));
802        }
803        let source_path = dir.join("operator.c");
804        let object_path = dir.join("operator.o");
805        let artifact_path = dir.join(format!("libferrum_native_{operator}.a"));
806        fs::write(&source_path, source).unwrap();
807        assert!(Command::new("cc")
808            .args(["-c"])
809            .arg(&source_path)
810            .arg("-o")
811            .arg(&object_path)
812            .status()
813            .unwrap()
814            .success());
815        assert!(Command::new("ar")
816            .arg("rcs")
817            .arg(&artifact_path)
818            .arg(&object_path)
819            .status()
820            .unwrap()
821            .success());
822
823        let binary_sha256 = digest_bytes(&fs::read(&artifact_path).unwrap());
824        let source_package_sha256 = digest(if operator == "alpha" { 'a' } else { 'b' });
825        let inputs_sha256 = digest(if operator == "alpha" { 'c' } else { 'd' });
826        let g03_catalog_bytes = b"{\"schema_version\":1}\n";
827        let abi_contract_bytes = b"{\"schema_version\":1}\n";
828        let g03_catalog_sha256 = digest_bytes(g03_catalog_bytes);
829        let abi_contract_sha256 = digest_bytes(abi_contract_bytes);
830        let provider_fingerprint = digest(if operator == "alpha" { '1' } else { '2' });
831        let mut exports = vec![descriptor.clone(), execute.clone()];
832        if let Some(symbol) = extra_strong_symbol {
833            exports.push(symbol.to_string());
834            exports.sort();
835        }
836        let operation_bindings = vec![NativeOperatorBinding {
837            operation_id: operation_id.to_string(),
838            operation_contract_version: NativeOperatorContractVersion::new(1, 0),
839            provider_id: provider_id.to_string(),
840            provider_version: NativeOperatorContractVersion::new(1, 0),
841            provider_implementation_fingerprint: provider_fingerprint,
842            entrypoints: vec![execute],
843        }];
844        let manifest = NativeOperatorManifest {
845            schema_version: NATIVE_OPERATOR_MANIFEST_SCHEMA_VERSION,
846            operator: operator.to_string(),
847            operator_abi_version: "1".to_string(),
848            ferrum_native_abi_version: FERRUM_NATIVE_OPERATOR_ABI_VERSION.to_string(),
849            backend: NativeOperatorBackend::Cuda,
850            cuda_toolkit: Some("12.4".to_string()),
851            cuda_runtime_min: Some("12.4".to_string()),
852            compute_capabilities: vec!["sm_89".to_string()],
853            source_package: NativeOperatorSourcePackage {
854                kind: "external_archive".to_string(),
855                revision: "fixture".to_string(),
856                sha256: source_package_sha256.clone(),
857            },
858            inputs_sha256: inputs_sha256.clone(),
859            binary_sha256: binary_sha256.clone(),
860            linkage: NativeOperatorLinkage::Static,
861            g03_catalog_sha256: Some(g03_catalog_sha256.clone()),
862            abi_contract_sha256: Some(abi_contract_sha256.clone()),
863            descriptor_export: Some(descriptor.clone()),
864            operation_bindings: operation_bindings.clone(),
865            exports: exports.clone(),
866            license_files: vec!["LICENSE".to_string()],
867            build_summary: NativeOperatorBuildSummary {
868                builder_sha: digest('7'),
869                elapsed_ms: 1,
870                nvcc_version: Some("12.4".to_string()),
871                host_compiler: "cc".to_string(),
872            },
873        };
874        let manifest_path = dir.join("native_operator_manifest.json");
875        fs::write(
876            &manifest_path,
877            serde_json::to_string_pretty(&manifest).unwrap(),
878        )
879        .unwrap();
880        let receipt_path = dir.join("source-build.receipt.json");
881        let plan_path = dir.join("source-build.plan.json");
882        let log_path = dir.join("source-build.log");
883        let package_spec_path = dir.join("package.spec.json");
884        let g03_catalog_path = dir.join("g03-provider-catalog.json");
885        let abi_contract_path = dir.join("native-abi-contract.json");
886        let source_input_path = dir.join("cuda-static-manifest.json");
887        let package_receipt_path = dir.join("package.receipt.json");
888        let package_log_path = dir.join("package-build.log");
889        let license_path = dir.join("LICENSE");
890        fs::write(&receipt_path, "{\"status\":\"pass\"}\n").unwrap();
891        fs::write(&plan_path, "{\"schema_version\":2}\n").unwrap();
892        fs::write(&log_path, "source build complete\n").unwrap();
893        fs::write(&package_spec_path, "{\"schema_version\":2}\n").unwrap();
894        fs::write(&g03_catalog_path, g03_catalog_bytes).unwrap();
895        fs::write(&abi_contract_path, abi_contract_bytes).unwrap();
896        fs::write(&source_input_path, "{\"schema_version\":1}\n").unwrap();
897        fs::write(&package_receipt_path, "{\"status\":\"pass\"}\n").unwrap();
898        fs::write(&package_log_path, "package build complete\n").unwrap();
899        fs::write(&license_path, "fixture license\n").unwrap();
900        let evidence = |path: &Path| NativeOperatorEvidenceFile {
901            path: format!("{operator}/{}", path.file_name().unwrap().to_string_lossy()),
902            sha256: digest_bytes(&fs::read(path).unwrap()),
903            size_bytes: fs::metadata(path).unwrap().len(),
904        };
905        NativeOperatorArtifactLock {
906            operator: operator.to_string(),
907            backend: NativeOperatorBackend::Cuda,
908            manifest_path: format!("{operator}/native_operator_manifest.json"),
909            manifest: evidence(&manifest_path),
910            artifact_path: format!("{operator}/libferrum_native_{operator}.a"),
911            operator_abi_version: "1".to_string(),
912            ferrum_native_abi_version: FERRUM_NATIVE_OPERATOR_ABI_VERSION.to_string(),
913            source_package_sha256,
914            inputs_sha256,
915            package_spec: evidence(&package_spec_path),
916            g03_catalog: evidence(&g03_catalog_path),
917            abi_contract: evidence(&abi_contract_path),
918            source_build_receipt: evidence(&receipt_path),
919            source_build_plan: evidence(&plan_path),
920            source_build_inputs: vec![evidence(&source_input_path)],
921            source_build_logs: vec![evidence(&log_path)],
922            source_archive_sha256: digest('8'),
923            package_receipt: evidence(&package_receipt_path),
924            package_build_logs: vec![evidence(&package_log_path)],
925            license_files: vec![evidence(&license_path)],
926            binary_sha256,
927            abi_contract_sha256,
928            descriptor_export: descriptor,
929            required_exports: exports,
930            operation_bindings,
931            system_libraries: vec![
932                NativeOperatorSystemLibrary::CudaRuntime,
933                NativeOperatorSystemLibrary::StdCxx,
934            ],
935        }
936    }
937
938    fn rewrite_bindings(
939        root: &Path,
940        artifact: &mut NativeOperatorArtifactLock,
941        bindings: Vec<NativeOperatorBinding>,
942    ) {
943        let manifest_path = root.join(&artifact.manifest_path);
944        let mut manifest: NativeOperatorManifest =
945            serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
946        manifest.operation_bindings = bindings.clone();
947        let bytes = serde_json::to_vec_pretty(&manifest).unwrap();
948        fs::write(&manifest_path, &bytes).unwrap();
949        artifact.manifest.sha256 = digest_bytes(&bytes);
950        artifact.manifest.size_bytes = bytes.len().try_into().unwrap();
951        artifact.operation_bindings = bindings;
952    }
953
954    #[test]
955    fn resolves_multiple_schema_v5_artifacts_in_deterministic_order() {
956        let dir = temp_dir("pass");
957        let alpha = write_artifact(
958            dir.path(),
959            "alpha",
960            "operation.alpha",
961            "provider.cuda.alpha",
962            None,
963        );
964        let beta = write_artifact(
965            dir.path(),
966            "beta",
967            "operation.beta",
968            "provider.cuda.beta",
969            None,
970        );
971        let g03_catalog_sha256 = alpha.g03_catalog.sha256.clone();
972        let lock = NativeOperatorArtifactSetLock {
973            schema_version: NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION,
974            g03_catalog_sha256,
975            artifacts: vec![alpha, beta],
976        };
977        let lock_path = dir.path().join("native-operators.lock.json");
978        fs::write(&lock_path, serde_json::to_string_pretty(&lock).unwrap()).unwrap();
979
980        let resolved =
981            NativeOperatorArtifactSetLock::load_and_resolve(&lock_path, Some("sm_89")).unwrap();
982        assert_eq!(resolved.artifacts.len(), 2);
983        assert_eq!(resolved.artifacts[0].resolved.manifest.operator, "alpha");
984        assert_eq!(resolved.artifacts[1].resolved.manifest.operator, "beta");
985    }
986
987    #[test]
988    fn resolves_unbound_leaf_and_shared_provider_across_multiple_archives() {
989        let dir = temp_dir("many-to-many");
990        let mut unbound = write_artifact(
991            dir.path(),
992            "alpha",
993            "operation.alpha",
994            "provider.cuda.alpha",
995            None,
996        );
997        rewrite_bindings(dir.path(), &mut unbound, Vec::new());
998        let mut first = write_artifact(
999            dir.path(),
1000            "beta",
1001            "operation.shared",
1002            "provider.cuda.shared",
1003            None,
1004        );
1005        let mut second = write_artifact(
1006            dir.path(),
1007            "gamma",
1008            "operation.shared",
1009            "provider.cuda.shared",
1010            None,
1011        );
1012        let first_identity = first.operation_bindings[0].clone();
1013        let mut second_binding = second.operation_bindings[0].clone();
1014        second_binding.operation_contract_version = first_identity.operation_contract_version;
1015        second_binding.provider_version = first_identity.provider_version;
1016        second_binding.provider_implementation_fingerprint =
1017            first_identity.provider_implementation_fingerprint.clone();
1018        rewrite_bindings(dir.path(), &mut first, vec![first_identity]);
1019        rewrite_bindings(dir.path(), &mut second, vec![second_binding]);
1020        let g03_catalog_sha256 = unbound.g03_catalog.sha256.clone();
1021        let lock = NativeOperatorArtifactSetLock {
1022            schema_version: NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION,
1023            g03_catalog_sha256,
1024            artifacts: vec![unbound, first, second],
1025        };
1026        let lock_path = dir.path().join("native-operators.lock.json");
1027        fs::write(&lock_path, serde_json::to_vec_pretty(&lock).unwrap()).unwrap();
1028
1029        let resolved =
1030            NativeOperatorArtifactSetLock::load_and_resolve(&lock_path, Some("sm_89")).unwrap();
1031        assert_eq!(resolved.artifacts.len(), 3);
1032        assert!(resolved.artifacts[0]
1033            .resolved
1034            .manifest
1035            .operation_bindings
1036            .is_empty());
1037    }
1038
1039    #[test]
1040    fn rejects_conflicting_provider_identity_across_archives() {
1041        let dir = temp_dir("provider-conflict");
1042        let alpha = write_artifact(
1043            dir.path(),
1044            "alpha",
1045            "operation.shared",
1046            "provider.cuda.shared",
1047            None,
1048        );
1049        let beta = write_artifact(
1050            dir.path(),
1051            "beta",
1052            "operation.shared",
1053            "provider.cuda.shared",
1054            None,
1055        );
1056        let g03_catalog_sha256 = alpha.g03_catalog.sha256.clone();
1057        let lock = NativeOperatorArtifactSetLock {
1058            schema_version: NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION,
1059            g03_catalog_sha256,
1060            artifacts: vec![alpha, beta],
1061        };
1062        let lock_path = dir.path().join("native-operators.lock.json");
1063        fs::write(&lock_path, serde_json::to_vec_pretty(&lock).unwrap()).unwrap();
1064
1065        let error =
1066            NativeOperatorArtifactSetLock::load_and_resolve(&lock_path, Some("sm_89")).unwrap_err();
1067        assert!(matches!(
1068            error,
1069            NativeOperatorArtifactSetError::OperationProviderIdentityConflict { .. }
1070        ));
1071    }
1072
1073    #[test]
1074    fn rejects_cross_artifact_strong_symbol_collision() {
1075        let dir = temp_dir("symbol-collision");
1076        let alpha = write_artifact(
1077            dir.path(),
1078            "alpha",
1079            "operation.alpha",
1080            "provider.cuda.alpha",
1081            Some("ferrum_native_shared_collision"),
1082        );
1083        let beta = write_artifact(
1084            dir.path(),
1085            "beta",
1086            "operation.beta",
1087            "provider.cuda.beta",
1088            Some("ferrum_native_shared_collision"),
1089        );
1090        let g03_catalog_sha256 = alpha.g03_catalog.sha256.clone();
1091        let lock = NativeOperatorArtifactSetLock {
1092            schema_version: NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION,
1093            g03_catalog_sha256,
1094            artifacts: vec![alpha, beta],
1095        };
1096        let lock_path = dir.path().join("native-operators.lock.json");
1097        fs::write(&lock_path, serde_json::to_string_pretty(&lock).unwrap()).unwrap();
1098
1099        let error =
1100            NativeOperatorArtifactSetLock::load_and_resolve(&lock_path, Some("sm_89")).unwrap_err();
1101        assert!(matches!(
1102            error,
1103            NativeOperatorArtifactSetError::StaticSymbolCollision { .. }
1104        ));
1105    }
1106
1107    #[test]
1108    fn rejects_tampered_package_provenance_files() {
1109        for field in [
1110            "manifest",
1111            "package_spec",
1112            "g03_catalog",
1113            "abi_contract",
1114            "package_receipt",
1115            "package_build_log",
1116            "license_file",
1117        ] {
1118            let dir = temp_dir(field);
1119            let artifact = write_artifact(
1120                dir.path(),
1121                "alpha",
1122                "operation.alpha",
1123                "provider.cuda.alpha",
1124                None,
1125            );
1126            let evidence = match field {
1127                "manifest" => &artifact.manifest,
1128                "package_spec" => &artifact.package_spec,
1129                "g03_catalog" => &artifact.g03_catalog,
1130                "abi_contract" => &artifact.abi_contract,
1131                "package_receipt" => &artifact.package_receipt,
1132                "package_build_log" => &artifact.package_build_logs[0],
1133                "license_file" => &artifact.license_files[0],
1134                _ => unreachable!(),
1135            };
1136            let evidence_path = dir.path().join(&evidence.path);
1137            fs::write(&evidence_path, format!("tampered {field}\n")).unwrap();
1138            let g03_catalog_sha256 = artifact.g03_catalog.sha256.clone();
1139            let lock = NativeOperatorArtifactSetLock {
1140                schema_version: NATIVE_OPERATOR_ARTIFACT_SET_SCHEMA_VERSION,
1141                g03_catalog_sha256,
1142                artifacts: vec![artifact],
1143            };
1144            let lock_path = dir.path().join("native-operators.lock.json");
1145            fs::write(&lock_path, serde_json::to_string_pretty(&lock).unwrap()).unwrap();
1146
1147            let error = NativeOperatorArtifactSetLock::load_and_resolve(&lock_path, Some("sm_89"))
1148                .unwrap_err();
1149            assert!(matches!(
1150                error,
1151                NativeOperatorArtifactSetError::PinMismatch { .. }
1152            ));
1153        }
1154    }
1155}