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