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