1use std::collections::{BTreeMap, BTreeSet};
4use std::fs::{self, File, OpenOptions};
5use std::io::{Read, Write};
6#[cfg(unix)]
7use std::os::unix::fs::OpenOptionsExt;
8use std::path::{Path, PathBuf};
9use std::process::{Command, ExitStatus, Stdio};
10use std::time::{Instant, SystemTime, UNIX_EPOCH};
11
12use ferrum_native_ops::{
13 CudaNativeBuildUnit, NativeBuildArtifactCache, NativeBuildArtifactLookup,
14 NativeBuildArtifactSpec,
15};
16use ferrum_types::{is_sha256_digest, NativeOperatorBackend, NativeOperatorSourcePackage};
17use serde::{Deserialize, Serialize};
18use tempfile::NamedTempFile;
19
20use super::{
21 read_json, require_file, sha256_bytes, sha256_file, symbol_slug, validate_relative_path,
22 write_json, NativeOperatorBuilderError, NativeOperatorEvidenceFile, Result,
23};
24
25pub const NATIVE_OPERATOR_SOURCE_DEFINITION_SCHEMA_VERSION: u32 = 3;
26pub const NATIVE_OPERATOR_SOURCE_BUILD_PLAN_SCHEMA_VERSION: u32 = 3;
27pub const NATIVE_OPERATOR_SOURCE_BUILD_RECEIPT_SCHEMA_VERSION: u32 = 7;
28pub const NATIVE_OPERATOR_SOURCE_OBJECT_BUILD_CONTRACT_VERSION: u32 = 7;
29pub const NATIVE_OPERATOR_CUDA_TOOLKIT_MANIFEST_SCHEMA_VERSION: u32 = 1;
30pub const NATIVE_OPERATOR_HOST_TOOLCHAIN_MANIFEST_SCHEMA_VERSION: u32 = 2;
31pub const NATIVE_OPERATOR_OBJECT_DEPENDENCY_PROOF_SCHEMA_VERSION: u32 = 3;
32const REQUIRED_CUDA_TOOLKIT_FILES: [&str; 6] = [
33 "bin/bin2c",
34 "bin/cudafe++",
35 "bin/fatbinary",
36 "bin/nvcc",
37 "bin/nvlink",
38 "bin/ptxas",
39];
40const REQUIRED_CUDA_TOOLKIT_SCOPES: [&str; 4] =
41 ["bin/crt", "include", "nvvm/bin", "nvvm/libdevice"];
42const HOST_TOOLCHAIN_PROGRAMS: [&str; 5] = ["as", "cc1", "cc1plus", "collect2", "ld"];
43const MAX_HOST_TOOLCHAIN_FILES: usize = 250_000;
44const MAX_DEPFILE_BYTES: usize = 16 * 1024 * 1024;
45const MAX_DEPFILE_DEPENDENCIES: usize = 250_000;
46const MAX_DEPFILE_WORD_BYTES: usize = 16 * 1024;
47const MAX_DEPENDENCY_PROOF_BYTES: usize = 64 * 1024 * 1024;
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct NativeOperatorSourceDefinition {
51 pub schema_version: u32,
52 pub operator: String,
53 pub source_package_kind: String,
54 pub source_package_revision: String,
55 pub upstream_sources: Vec<NativeOperatorUpstreamSource>,
56 pub translation_units: Vec<String>,
57 pub headers: Vec<String>,
58 pub dependency_closures: Vec<NativeOperatorTranslationUnitDependencies>,
59 pub include_dirs: Vec<String>,
60 pub defines: Vec<String>,
61 pub nvcc_policy: NativeOperatorNvccPolicy,
62 pub architecture: NativeOperatorCudaArchitecture,
63 pub archive_file: String,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct NativeOperatorUpstreamSource {
68 pub repository: String,
69 pub revision: String,
70 pub license: String,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct NativeOperatorTranslationUnitDependencies {
75 pub translation_unit: String,
76 pub headers: Vec<String>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct NativeOperatorSourceBuildPlan {
81 pub schema_version: u32,
82 pub operator: String,
83 pub source_package: NativeOperatorSourcePackage,
84 pub upstream_sources: Vec<NativeOperatorUpstreamSource>,
85 pub translation_units: Vec<NativeOperatorSourceFileLock>,
86 pub headers: Vec<NativeOperatorSourceFileLock>,
87 pub dependency_closures: Vec<NativeOperatorTranslationUnitDependencyLock>,
88 pub include_dirs: Vec<String>,
89 pub defines: Vec<String>,
90 pub nvcc_policy: NativeOperatorNvccPolicy,
91 pub architecture: NativeOperatorCudaArchitecture,
92 pub archive_file: String,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
96pub struct NativeOperatorSourceFileLock {
97 pub path: String,
98 pub sha256: String,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct NativeOperatorTranslationUnitDependencyLock {
103 pub translation_unit: String,
104 pub headers: Vec<NativeOperatorSourceFileLock>,
105 pub closure_sha256: String,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum NativeOperatorCudaArchitecture {
111 DeviceComputeCapability,
112 Compute80Ptx,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum NativeOperatorCppStandard {
118 Cpp17,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum NativeOperatorOptimization {
124 O3,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct NativeOperatorNvccPolicy {
129 pub cpp_standard: NativeOperatorCppStandard,
130 pub optimization: NativeOperatorOptimization,
131 pub use_fast_math: bool,
132 pub relaxed_constexpr: bool,
133 pub extended_lambda: bool,
134 pub host_position_independent_code: bool,
135 pub host_default_visibility: bool,
136}
137
138#[derive(Debug, Clone)]
139pub struct NativeOperatorSourceBuildRequest {
140 pub plan_path: PathBuf,
141 pub source_root: PathBuf,
142 pub output_dir: PathBuf,
143 pub compute_capability: String,
144 pub builder_sha: String,
145 pub nvcc_path: PathBuf,
146 pub ccbin_path: PathBuf,
147 pub ar_path: PathBuf,
148 pub cuda_toolkit_root: PathBuf,
149 pub nvcc_threads: u32,
150 pub object_cache_dir: PathBuf,
151 pub plan_only: bool,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct NativeOperatorSourceBuildReceipt {
156 pub schema_version: u32,
157 pub status: NativeOperatorSourceBuildStatus,
158 pub operator: String,
159 pub plan_only: bool,
160 pub plan_sha256: String,
161 pub source_package: NativeOperatorSourcePackage,
162 pub builder_sha: String,
163 pub compute_capability: String,
164 pub architecture_argument: String,
165 pub nvcc_threads: u32,
166 pub object_cache_root: String,
167 pub toolchain: Option<NativeOperatorSourceBuildToolchain>,
168 pub effective_environment: BTreeMap<String, String>,
169 pub inputs_sha256: String,
170 pub commands: Vec<NativeOperatorSourceBuildCommand>,
171 pub compiled_translation_units: Vec<String>,
172 pub cache_hit_translation_units: Vec<String>,
173 pub archive_file: Option<String>,
174 pub archive_sha256: Option<String>,
175 pub started_unix_ms: u64,
176 pub elapsed_ms: u64,
177 pub failure_class: Option<String>,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub enum NativeOperatorSourceBuildStatus {
183 Plan,
184 Pass,
185 Reject,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189pub struct NativeOperatorSourceBuildToolchain {
190 pub static_identity: NativeOperatorSourceBuildStaticToolchain,
191 pub miss_probe: Option<NativeOperatorSourceBuildToolchainProbe>,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct NativeOperatorSourceBuildStaticToolchain {
196 pub backend: NativeOperatorBackend,
197 pub compiler_driver: NativeOperatorSourceCompilerDriver,
198 pub cuda_toolkit: NativeOperatorCudaToolkitIdentity,
199 pub host_toolchain: NativeOperatorHostToolchainIdentity,
200 pub archiver: NativeOperatorToolFileIdentity,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum NativeOperatorSourceCompilerDriver {
206 CudaNvcc,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210pub struct NativeOperatorCudaToolkitIdentity {
211 pub canonical_root: String,
212 pub invocation_root: String,
213 pub release_version: String,
214 pub nvcc: NativeOperatorToolFileIdentity,
215 pub manifest: NativeOperatorEvidenceFile,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219pub struct NativeOperatorToolFileIdentity {
220 pub path: String,
221 pub sha256: String,
222 pub size_bytes: u64,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub struct NativeOperatorHostToolchainIdentity {
227 pub compiler: NativeOperatorToolFileIdentity,
228 pub compiler_version: String,
229 pub target: String,
230 pub manifest: NativeOperatorEvidenceFile,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234pub struct NativeOperatorHostToolchainManifest {
235 pub schema_version: u32,
236 pub compiler: NativeOperatorToolFileIdentity,
237 pub compiler_version: String,
238 pub target: String,
239 pub executable_inputs: Vec<NativeOperatorToolFileIdentity>,
240 pub include_roots: Vec<String>,
241 pub include_probe_sha256: String,
242 pub driver_probe_sha256: String,
243 pub discovery_roots: Vec<String>,
244 pub files: Vec<NativeOperatorHostToolchainFileIdentity>,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248pub struct NativeOperatorHostToolchainFileIdentity {
249 pub logical_path: String,
250 pub resolved_path: String,
251 pub sha256: String,
252 pub size_bytes: u64,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256pub struct NativeOperatorSourceBuildToolchainProbe {
257 pub nvcc_version: String,
258 pub host_compiler_version: String,
259 pub host_target: String,
260 pub archiver_version: String,
261 pub probed_for_misses: Vec<String>,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265pub struct NativeOperatorCudaToolkitManifest {
266 pub schema_version: u32,
267 pub canonical_root: String,
268 pub entries: Vec<NativeOperatorCudaToolkitFileIdentity>,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272pub struct NativeOperatorCudaToolkitFileIdentity {
273 pub logical_path: String,
274 pub resolved_path: String,
275 pub sha256: String,
276 pub size_bytes: u64,
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct NativeOperatorToolIdentity {
281 pub path: String,
282 pub sha256: String,
283 pub version: String,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287pub struct NativeOperatorObjectIdentity {
288 pub format: NativeOperatorObjectFormat,
289 pub class_bits: u8,
290 pub endianness: NativeOperatorObjectEndianness,
291 pub machine: u32,
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
295#[serde(rename_all = "snake_case")]
296pub enum NativeOperatorObjectFormat {
297 Elf,
298 MachO,
299 Coff,
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(rename_all = "snake_case")]
304pub enum NativeOperatorObjectEndianness {
305 Little,
306 Big,
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310pub struct NativeOperatorSourceBuildCommand {
311 pub translation_unit: Option<String>,
312 pub working_directory: String,
313 pub argv: Vec<String>,
314 pub object_file: Option<String>,
315 pub stdout_log: String,
316 pub stderr_log: String,
317 pub object_cache_key: Option<String>,
318 pub object_cache_status: Option<NativeOperatorSourceObjectCacheStatus>,
319 pub object_cache_entry: Option<String>,
320 pub object_sha256: Option<String>,
321 pub object_size_bytes: Option<u64>,
322 pub object_identity: Option<NativeOperatorObjectIdentity>,
323 pub dependency_closure_sha256: Option<String>,
324 pub dependency_validation: Option<NativeOperatorDependencyValidation>,
325 pub compiler_depfile: Option<String>,
326 pub compiler_depfile_sha256: Option<String>,
327 pub depfile: Option<String>,
328 pub depfile_sha256: Option<String>,
329 pub depfile_producer_working_directory: Option<String>,
330 pub depfile_producer_object_file: Option<String>,
331 pub depfile_bindings: Vec<NativeOperatorDepfileDependencyBinding>,
332 pub observed_dependencies: Vec<NativeOperatorObservedDependency>,
333 pub compiler_executed: bool,
334 pub elapsed_ms: Option<u64>,
335 pub return_code: Option<i32>,
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "snake_case")]
340pub enum NativeOperatorDependencyValidation {
341 Plan,
342 Pending,
343 CacheProof,
344 Depfile,
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
348#[serde(rename_all = "snake_case")]
349pub enum NativeOperatorDependencyDomain {
350 Source,
351 BackendToolchain,
352 HostToolchain,
353}
354
355#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
356pub struct NativeOperatorObservedDependency {
357 pub domain: NativeOperatorDependencyDomain,
358 pub path: String,
359 pub sha256: String,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363pub struct NativeOperatorDepfileDependencyBinding {
364 pub producer_path: String,
365 pub portable_path: String,
366 pub dependency: NativeOperatorObservedDependency,
367}
368
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370pub struct NativeOperatorObjectDependencyProof {
371 pub schema_version: u32,
372 pub object_cache_key: String,
373 pub object_sha256: String,
374 pub dependency_closure_sha256: String,
375 pub dependency_set_sha256: String,
376 pub compiler_depfile_sha256: String,
377 pub depfile_sha256: String,
378 pub producer_working_directory: String,
379 pub producer_object_file: String,
380 pub depfile_bindings: Vec<NativeOperatorDepfileDependencyBinding>,
381 pub observed_dependencies: Vec<NativeOperatorObservedDependency>,
382}
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
385#[serde(rename_all = "snake_case")]
386pub enum NativeOperatorSourceObjectCacheStatus {
387 Plan,
388 Pending,
389 Hit,
390 Miss,
391 Published,
392 Rejected,
393}
394
395#[derive(Debug, Serialize)]
396struct NativeOperatorSourceInventoryIdentity<'a> {
397 operator: &'a str,
398 upstream_sources: &'a [NativeOperatorUpstreamSource],
399 translation_units: &'a [NativeOperatorSourceFileLock],
400 headers: &'a [NativeOperatorSourceFileLock],
401}
402
403#[derive(Debug, Serialize)]
404struct NativeOperatorDependencyClosureIdentity<'a> {
405 translation_unit: &'a NativeOperatorSourceFileLock,
406 headers: &'a [NativeOperatorSourceFileLock],
407}
408
409#[derive(Debug, Serialize)]
410struct NativeOperatorBuildInputIdentity<'a> {
411 plan_sha256: &'a str,
412 source_package_sha256: &'a str,
413 builder_contract_version: u32,
414 architecture_argument: &'a str,
415 effective_environment: &'a BTreeMap<String, String>,
416 toolchain: Option<&'a NativeOperatorSourceBuildStaticToolchain>,
417}
418
419#[derive(Debug, Serialize)]
420struct NativeOperatorObjectInputIdentity<'a> {
421 schema_version: u32,
422 operator: &'a str,
423 translation_unit: &'a NativeOperatorSourceFileLock,
424 dependency_closure_sha256: &'a str,
425 headers: &'a [NativeOperatorSourceFileLock],
426 include_dirs: &'a [String],
427 defines: &'a [String],
428 nvcc_policy: &'a NativeOperatorNvccPolicy,
429 architecture_argument: &'a str,
430 builder_contract_version: u32,
431 effective_environment: &'a BTreeMap<String, String>,
432 toolchain: &'a NativeOperatorSourceBuildStaticToolchain,
433}
434
435pub fn lock_native_operator_source_definition(
436 definition_path: &Path,
437 source_root: &Path,
438 output_plan_path: &Path,
439) -> Result<NativeOperatorSourceBuildPlan> {
440 require_file(definition_path)?;
441 if output_plan_path.exists() {
442 return Err(NativeOperatorBuilderError::OutputExists(
443 output_plan_path.to_path_buf(),
444 ));
445 }
446 let definition: NativeOperatorSourceDefinition = read_json(definition_path)?;
447 validate_definition(&definition)?;
448 let canonical_root =
449 source_root
450 .canonicalize()
451 .map_err(|source| NativeOperatorBuilderError::Io {
452 path: source_root.to_path_buf(),
453 source,
454 })?;
455 if !canonical_root.is_dir() {
456 return Err(NativeOperatorBuilderError::Invalid(format!(
457 "source_root is not a directory: {}",
458 canonical_root.display()
459 )));
460 }
461 for include_dir in &definition.include_dirs {
462 let path = canonical_root.join(include_dir);
463 let canonical = path
464 .canonicalize()
465 .map_err(|source| NativeOperatorBuilderError::Io {
466 path: path.clone(),
467 source,
468 })?;
469 if !canonical.starts_with(&canonical_root) || !canonical.is_dir() {
470 return Err(NativeOperatorBuilderError::Invalid(format!(
471 "include directory escapes source root or does not exist: {}",
472 path.display()
473 )));
474 }
475 }
476 let translation_units = lock_source_files(&canonical_root, &definition.translation_units)?;
477 let headers = lock_source_files(&canonical_root, &definition.headers)?;
478 let translation_unit_by_path = translation_units
479 .iter()
480 .map(|locked| (locked.path.as_str(), locked))
481 .collect::<BTreeMap<_, _>>();
482 let header_by_path = headers
483 .iter()
484 .map(|locked| (locked.path.as_str(), locked))
485 .collect::<BTreeMap<_, _>>();
486 let dependency_closures = definition
487 .dependency_closures
488 .iter()
489 .map(|closure| {
490 let translation_unit = translation_unit_by_path
491 .get(closure.translation_unit.as_str())
492 .copied()
493 .ok_or_else(|| {
494 NativeOperatorBuilderError::Invalid(format!(
495 "dependency closure references unknown translation unit: {}",
496 closure.translation_unit
497 ))
498 })?;
499 let locked_headers = closure
500 .headers
501 .iter()
502 .map(|path| {
503 header_by_path
504 .get(path.as_str())
505 .copied()
506 .cloned()
507 .ok_or_else(|| {
508 NativeOperatorBuilderError::Invalid(format!(
509 "dependency closure for {} references unknown header: {path}",
510 closure.translation_unit
511 ))
512 })
513 })
514 .collect::<Result<Vec<_>>>()?;
515 let closure_sha256 =
516 dependency_closure_sha256(translation_unit, &locked_headers, output_plan_path)?;
517 Ok(NativeOperatorTranslationUnitDependencyLock {
518 translation_unit: closure.translation_unit.clone(),
519 headers: locked_headers,
520 closure_sha256,
521 })
522 })
523 .collect::<Result<Vec<_>>>()?;
524 let inventory_identity = NativeOperatorSourceInventoryIdentity {
525 operator: &definition.operator,
526 upstream_sources: &definition.upstream_sources,
527 translation_units: &translation_units,
528 headers: &headers,
529 };
530 let source_package_sha256 =
531 sha256_bytes(&serde_json::to_vec(&inventory_identity).map_err(|source| {
532 NativeOperatorBuilderError::Json {
533 path: output_plan_path.to_path_buf(),
534 source,
535 }
536 })?);
537 let plan = NativeOperatorSourceBuildPlan {
538 schema_version: NATIVE_OPERATOR_SOURCE_BUILD_PLAN_SCHEMA_VERSION,
539 operator: definition.operator,
540 source_package: NativeOperatorSourcePackage {
541 kind: definition.source_package_kind,
542 revision: definition.source_package_revision,
543 sha256: source_package_sha256,
544 },
545 upstream_sources: definition.upstream_sources,
546 translation_units,
547 headers,
548 dependency_closures,
549 include_dirs: definition.include_dirs,
550 defines: definition.defines,
551 nvcc_policy: definition.nvcc_policy,
552 architecture: definition.architecture,
553 archive_file: definition.archive_file,
554 };
555 validate_plan(&plan)?;
556 if let Some(parent) = output_plan_path.parent() {
557 fs::create_dir_all(parent).map_err(|source| NativeOperatorBuilderError::Io {
558 path: parent.to_path_buf(),
559 source,
560 })?;
561 }
562 write_json(output_plan_path, &plan)?;
563 Ok(plan)
564}
565
566pub fn run_native_operator_source_build(
567 request: &NativeOperatorSourceBuildRequest,
568) -> Result<NativeOperatorSourceBuildReceipt> {
569 require_file(&request.plan_path)?;
570 if request.output_dir.exists() {
571 return Err(NativeOperatorBuilderError::OutputExists(
572 request.output_dir.clone(),
573 ));
574 }
575 if !request.output_dir.is_absolute() {
576 return Err(NativeOperatorBuilderError::Invalid(
577 "source build output_dir must be absolute".to_string(),
578 ));
579 }
580 if !request.object_cache_dir.is_absolute() {
581 return Err(NativeOperatorBuilderError::Invalid(
582 "source build object_cache_dir must be absolute".to_string(),
583 ));
584 }
585 if [
586 &request.nvcc_path,
587 &request.ccbin_path,
588 &request.ar_path,
589 &request.cuda_toolkit_root,
590 ]
591 .iter()
592 .any(|path| !path.is_absolute())
593 {
594 return Err(NativeOperatorBuilderError::Invalid(
595 "source build tool paths and cuda_toolkit_root must be absolute".to_string(),
596 ));
597 }
598 validate_compute_capability(&request.compute_capability)?;
599 if request.nvcc_threads == 0 {
600 return Err(NativeOperatorBuilderError::Invalid(
601 "nvcc_threads must be greater than zero".to_string(),
602 ));
603 }
604 if !is_git_oid(&request.builder_sha) {
605 return Err(NativeOperatorBuilderError::Invalid(
606 "builder_sha must be a lowercase 40- or 64-hex git object id".to_string(),
607 ));
608 }
609 let plan: NativeOperatorSourceBuildPlan = read_json(&request.plan_path)?;
610 validate_plan(&plan)?;
611 let plan_sha256 = sha256_file(&request.plan_path)?;
612 let canonical_root =
613 request
614 .source_root
615 .canonicalize()
616 .map_err(|source| NativeOperatorBuilderError::Io {
617 path: request.source_root.clone(),
618 source,
619 })?;
620 validate_locked_source_tree(&canonical_root, &plan)?;
621 let architecture_argument =
622 architecture_argument(plan.architecture, &request.compute_capability);
623 let started_unix_ms = unix_ms();
624 let started = Instant::now();
625 fs::create_dir_all(&request.output_dir).map_err(|source| NativeOperatorBuilderError::Io {
626 path: request.output_dir.clone(),
627 source,
628 })?;
629 let receipt_path = request.output_dir.join("source-build.receipt.json");
630 let logs_dir = request.output_dir.join("logs");
631 let objects_dir = request.output_dir.join("objects");
632 let depfiles_dir = request.output_dir.join("depfiles");
633 fs::create_dir_all(&logs_dir).map_err(|source| NativeOperatorBuilderError::Io {
634 path: logs_dir.clone(),
635 source,
636 })?;
637 if !request.plan_only {
638 fs::create_dir_all(&objects_dir).map_err(|source| NativeOperatorBuilderError::Io {
639 path: objects_dir.clone(),
640 source,
641 })?;
642 fs::create_dir_all(&depfiles_dir).map_err(|source| NativeOperatorBuilderError::Io {
643 path: depfiles_dir.clone(),
644 source,
645 })?;
646 }
647
648 let (toolchain, toolchain_failure) = if request.plan_only {
649 (None, None)
650 } else {
651 match resolve_static_toolchain(request) {
652 Ok(toolchain) => (Some(toolchain), None),
653 Err(error) => (None, Some(error.to_string())),
654 }
655 };
656 let effective_environment = effective_build_environment(request, toolchain.as_ref())?;
657 let inputs_sha256 = build_inputs_sha256(
658 &plan_sha256,
659 &plan.source_package.sha256,
660 &architecture_argument,
661 &effective_environment,
662 toolchain
663 .as_ref()
664 .map(|toolchain| &toolchain.static_identity),
665 &receipt_path,
666 )?;
667 let mut commands = build_commands(
668 request,
669 &plan,
670 &canonical_root,
671 &architecture_argument,
672 &objects_dir,
673 &logs_dir,
674 toolchain.as_ref(),
675 &effective_environment,
676 );
677 let initial_log = if request.plan_only {
678 b"plan-only: command was not executed\n".as_slice()
679 } else {
680 b"pending: command has not executed\n".as_slice()
681 };
682 for command in &commands {
683 write_command_stream(
684 &request.output_dir.join(&command.stdout_log),
685 "stdout",
686 &command.argv,
687 initial_log,
688 )?;
689 write_command_stream(
690 &request.output_dir.join(&command.stderr_log),
691 "stderr",
692 &command.argv,
693 initial_log,
694 )?;
695 }
696 let mut receipt = NativeOperatorSourceBuildReceipt {
697 schema_version: NATIVE_OPERATOR_SOURCE_BUILD_RECEIPT_SCHEMA_VERSION,
698 status: if request.plan_only {
699 NativeOperatorSourceBuildStatus::Plan
700 } else {
701 NativeOperatorSourceBuildStatus::Reject
702 },
703 operator: plan.operator.clone(),
704 plan_only: request.plan_only,
705 plan_sha256,
706 source_package: plan.source_package.clone(),
707 builder_sha: request.builder_sha.clone(),
708 compute_capability: request.compute_capability.clone(),
709 architecture_argument,
710 nvcc_threads: request.nvcc_threads,
711 object_cache_root: request.object_cache_dir.display().to_string(),
712 toolchain,
713 effective_environment,
714 inputs_sha256,
715 commands: commands.clone(),
716 compiled_translation_units: Vec::new(),
717 cache_hit_translation_units: Vec::new(),
718 archive_file: None,
719 archive_sha256: None,
720 started_unix_ms,
721 elapsed_ms: 0,
722 failure_class: None,
723 };
724 if request.plan_only {
725 receipt.elapsed_ms = millis(started.elapsed());
726 write_json(&receipt_path, &receipt)?;
727 return Ok(receipt);
728 }
729 if let Some(error) = toolchain_failure {
730 receipt.elapsed_ms = millis(started.elapsed());
731 return reject_source_build(
732 &receipt_path,
733 &mut receipt,
734 format!("toolchain_preflight_failed:{error}"),
735 );
736 }
737 let toolchain_dependency_scope = match load_toolchain_dependency_scope(
738 &request.output_dir,
739 &receipt
740 .toolchain
741 .as_ref()
742 .expect("actual source build has a resolved toolchain")
743 .static_identity,
744 ) {
745 Ok(scope) => scope,
746 Err(error) => {
747 receipt.elapsed_ms = millis(started.elapsed());
748 return reject_source_build(
749 &receipt_path,
750 &mut receipt,
751 format!("toolchain_dependency_scope_failed:{error}"),
752 );
753 }
754 };
755 let object_cache = match NativeBuildArtifactCache::new(&request.object_cache_dir) {
756 Ok(cache) => cache,
757 Err(error) => {
758 receipt.elapsed_ms = millis(started.elapsed());
759 return reject_source_build(
760 &receipt_path,
761 &mut receipt,
762 format!("object_cache_init_failed:{error}"),
763 );
764 }
765 };
766 let object_specs = match build_object_cache_specs(
767 &plan,
768 &receipt.architecture_argument,
769 receipt
770 .toolchain
771 .as_ref()
772 .map(|toolchain| &toolchain.static_identity)
773 .expect("actual source build has a resolved toolchain"),
774 &receipt.effective_environment,
775 ) {
776 Ok(specs) => specs,
777 Err(error) => {
778 receipt.elapsed_ms = millis(started.elapsed());
779 return reject_source_build(
780 &receipt_path,
781 &mut receipt,
782 format!("object_cache_spec_failed:{error}"),
783 );
784 }
785 };
786 for (command, spec) in commands.iter_mut().zip(object_specs.iter()) {
787 command.object_cache_key = Some(spec.input_signature_sha256().to_string());
788 }
789 receipt.commands = commands.clone();
790 receipt.failure_class = Some("build_incomplete".to_string());
791 write_json(&receipt_path, &receipt)?;
792
793 let mut expected_object_identity: Option<NativeOperatorObjectIdentity> = None;
794 let mut miss_indices = Vec::new();
795 for index in 0..plan.translation_units.len() {
796 let translation_unit = &plan.translation_units[index];
797 let object_path = PathBuf::from(
798 commands[index]
799 .object_file
800 .as_deref()
801 .expect("translation-unit command has an object file"),
802 );
803 let lookup_started = Instant::now();
804 match object_cache.restore(&object_specs[index], &object_path) {
805 Ok(NativeBuildArtifactLookup::Hit(cache_receipt)) => {
806 let compiler_depfile_relative = commands[index]
807 .compiler_depfile
808 .as_deref()
809 .expect("translation-unit command has compiler depfile output")
810 .to_string();
811 let compiler_depfile_path = request.output_dir.join(&compiler_depfile_relative);
812 let depfile_relative = commands[index]
813 .depfile
814 .as_deref()
815 .expect("translation-unit command has depfile output")
816 .to_string();
817 let depfile_path = request.output_dir.join(&depfile_relative);
818 let dependency_proof = match restore_object_dependency_proof(
819 &cache_receipt.cache_entry,
820 object_specs[index].input_signature_sha256(),
821 &cache_receipt.artifact_sha256,
822 &plan.dependency_closures[index],
823 translation_unit,
824 &object_path,
825 &compiler_depfile_path,
826 &depfile_path,
827 &toolchain_dependency_scope,
828 ) {
829 Ok(Some(proof)) => proof,
830 Ok(None) => {
831 fs::remove_file(&object_path).map_err(|source| {
832 NativeOperatorBuilderError::Io {
833 path: object_path.clone(),
834 source,
835 }
836 })?;
837 commands[index].object_cache_status =
838 Some(NativeOperatorSourceObjectCacheStatus::Miss);
839 append_command_stream(
840 &request.output_dir.join(&commands[index].stdout_log),
841 b"object-cache-miss: dependency-proof-absent\n",
842 )?;
843 miss_indices.push(index);
844 continue;
845 }
846 Err(error) => {
847 commands[index].object_cache_status =
848 Some(NativeOperatorSourceObjectCacheStatus::Rejected);
849 receipt.commands = commands.clone();
850 receipt.elapsed_ms = millis(started.elapsed());
851 return reject_source_build(
852 &receipt_path,
853 &mut receipt,
854 format!("cached_dependency_proof_failed:{index}:{error}"),
855 );
856 }
857 };
858 commands[index].object_cache_status =
859 Some(NativeOperatorSourceObjectCacheStatus::Hit);
860 commands[index].object_cache_entry =
861 Some(cache_receipt.cache_entry.display().to_string());
862 commands[index].object_sha256 = Some(cache_receipt.artifact_sha256);
863 let object_size_bytes = match native_object_size(&object_path) {
864 Ok(size_bytes) => size_bytes,
865 Err(error) => {
866 commands[index].object_cache_status =
867 Some(NativeOperatorSourceObjectCacheStatus::Rejected);
868 receipt.commands = commands.clone();
869 receipt.elapsed_ms = millis(started.elapsed());
870 return reject_source_build(
871 &receipt_path,
872 &mut receipt,
873 format!("cached_object_size_failed:{index}:{error}"),
874 );
875 }
876 };
877 commands[index].object_size_bytes = Some(object_size_bytes);
878 let object_identity = match native_object_identity_file(&object_path) {
879 Ok(identity) => identity,
880 Err(error) => {
881 commands[index].object_cache_status =
882 Some(NativeOperatorSourceObjectCacheStatus::Rejected);
883 receipt.commands = commands.clone();
884 receipt.elapsed_ms = millis(started.elapsed());
885 return reject_source_build(
886 &receipt_path,
887 &mut receipt,
888 format!("cached_object_identity_failed:{index}:{error}"),
889 );
890 }
891 };
892 if expected_object_identity
893 .as_ref()
894 .is_some_and(|expected| expected != &object_identity)
895 {
896 commands[index].object_cache_status =
897 Some(NativeOperatorSourceObjectCacheStatus::Rejected);
898 receipt.commands = commands.clone();
899 receipt.elapsed_ms = millis(started.elapsed());
900 return reject_source_build(
901 &receipt_path,
902 &mut receipt,
903 format!("cached_object_target_mismatch:{index}"),
904 );
905 }
906 expected_object_identity.get_or_insert_with(|| object_identity.clone());
907 commands[index].object_identity = Some(object_identity);
908 commands[index].dependency_validation =
909 Some(NativeOperatorDependencyValidation::CacheProof);
910 commands[index].compiler_depfile_sha256 =
911 Some(dependency_proof.compiler_depfile_sha256);
912 commands[index].depfile_sha256 = Some(dependency_proof.depfile_sha256);
913 commands[index].depfile_producer_working_directory =
914 Some(dependency_proof.producer_working_directory);
915 commands[index].depfile_producer_object_file =
916 Some(dependency_proof.producer_object_file);
917 commands[index].depfile_bindings = dependency_proof.depfile_bindings;
918 commands[index].observed_dependencies = dependency_proof.observed_dependencies;
919 commands[index].elapsed_ms = Some(millis(lookup_started.elapsed()));
920 append_command_stream(
921 &request.output_dir.join(&commands[index].stdout_log),
922 b"object-cache-hit: compiler and toolchain probes were not executed\n",
923 )?;
924 receipt
925 .cache_hit_translation_units
926 .push(translation_unit.path.clone());
927 }
928 Ok(NativeBuildArtifactLookup::Miss { reason }) => {
929 commands[index].object_cache_status =
930 Some(NativeOperatorSourceObjectCacheStatus::Miss);
931 append_command_stream(
932 &request.output_dir.join(&commands[index].stdout_log),
933 format!("object-cache-miss: {reason}\n").as_bytes(),
934 )?;
935 miss_indices.push(index);
936 }
937 Err(error) => {
938 commands[index].object_cache_status =
939 Some(NativeOperatorSourceObjectCacheStatus::Rejected);
940 receipt.commands = commands.clone();
941 receipt.elapsed_ms = millis(started.elapsed());
942 return reject_source_build(
943 &receipt_path,
944 &mut receipt,
945 format!("object_cache_restore_failed:{index}:{error}"),
946 );
947 }
948 }
949 }
950 receipt.commands = commands.clone();
951 receipt.elapsed_ms = millis(started.elapsed());
952 write_json(&receipt_path, &receipt)?;
953
954 if !miss_indices.is_empty() {
955 let missed_translation_units = miss_indices
956 .iter()
957 .map(|index| plan.translation_units[*index].path.clone())
958 .collect::<Vec<_>>();
959 let static_identity = receipt
960 .toolchain
961 .as_ref()
962 .expect("actual source build has a resolved toolchain")
963 .static_identity
964 .clone();
965 let probe = match probe_source_toolchain(&static_identity, missed_translation_units) {
966 Ok(probe) => probe,
967 Err(error) => {
968 receipt.commands = commands.clone();
969 receipt.elapsed_ms = millis(started.elapsed());
970 return reject_source_build(
971 &receipt_path,
972 &mut receipt,
973 format!("toolchain_miss_probe_failed:{error}"),
974 );
975 }
976 };
977 receipt
978 .toolchain
979 .as_mut()
980 .expect("actual source build has a resolved toolchain")
981 .miss_probe = Some(probe);
982 write_json(&receipt_path, &receipt)?;
983 }
984
985 for index in miss_indices {
986 let translation_unit = &plan.translation_units[index];
987 let object_path = PathBuf::from(
988 commands[index]
989 .object_file
990 .as_deref()
991 .expect("translation-unit command has an object file"),
992 );
993 let command_started = Instant::now();
994 let stdout_path = request.output_dir.join(&commands[index].stdout_log);
995 let stderr_path = request.output_dir.join(&commands[index].stderr_log);
996 commands[index].compiler_executed = true;
997 let status = match run_logged_command(
998 &commands[index].argv,
999 &stdout_path,
1000 &stderr_path,
1001 &commands[index].working_directory,
1002 &receipt.effective_environment,
1003 ) {
1004 Ok(status) => status,
1005 Err(error) => {
1006 append_command_stream(&stderr_path, format!("spawn failed: {error}\n").as_bytes())?;
1007 receipt.commands = commands.clone();
1008 receipt.elapsed_ms = millis(started.elapsed());
1009 return reject_source_build(
1010 &receipt_path,
1011 &mut receipt,
1012 format!("nvcc_translation_unit_{index}_spawn_failed"),
1013 );
1014 }
1015 };
1016 commands[index].elapsed_ms = Some(millis(command_started.elapsed()));
1017 commands[index].return_code = status.code();
1018 receipt
1019 .compiled_translation_units
1020 .push(translation_unit.path.clone());
1021 receipt.commands = commands.clone();
1022 receipt.elapsed_ms = millis(started.elapsed());
1023 if !status.success() {
1024 return reject_source_build(
1025 &receipt_path,
1026 &mut receipt,
1027 format!("nvcc_translation_unit_{index}_failed"),
1028 );
1029 }
1030 if let Err(error) = require_file(&object_path) {
1031 commands[index].object_cache_status =
1032 Some(NativeOperatorSourceObjectCacheStatus::Rejected);
1033 receipt.commands = commands.clone();
1034 receipt.elapsed_ms = millis(started.elapsed());
1035 return reject_source_build(
1036 &receipt_path,
1037 &mut receipt,
1038 format!("compiled_object_missing:{index}:{error}"),
1039 );
1040 }
1041 let compiler_depfile_relative = commands[index]
1042 .compiler_depfile
1043 .as_deref()
1044 .expect("cache-miss command has a compiler depfile")
1045 .to_string();
1046 let compiler_depfile_path = request.output_dir.join(&compiler_depfile_relative);
1047 let depfile_relative = commands[index]
1048 .depfile
1049 .as_deref()
1050 .expect("cache-miss command has a portable depfile")
1051 .to_string();
1052 let depfile_path = request.output_dir.join(&depfile_relative);
1053 let validated_depfile = match validate_translation_unit_depfile(
1054 &compiler_depfile_path,
1055 &depfile_path,
1056 &object_path,
1057 &canonical_root,
1058 translation_unit,
1059 &plan.dependency_closures[index],
1060 &toolchain_dependency_scope,
1061 ) {
1062 Ok(dependencies) => dependencies,
1063 Err(error) => {
1064 commands[index].object_cache_status =
1065 Some(NativeOperatorSourceObjectCacheStatus::Rejected);
1066 receipt.commands = commands.clone();
1067 receipt.elapsed_ms = millis(started.elapsed());
1068 return reject_source_build(
1069 &receipt_path,
1070 &mut receipt,
1071 format!("dependency_validation_failed:{index}:{error}"),
1072 );
1073 }
1074 };
1075 commands[index].compiler_depfile_sha256 = Some(validated_depfile.compiler_sha256.clone());
1076 commands[index].depfile_sha256 = Some(validated_depfile.portable_sha256.clone());
1077 commands[index].depfile_producer_working_directory =
1078 Some(commands[index].working_directory.clone());
1079 commands[index].depfile_producer_object_file = commands[index].object_file.clone();
1080 commands[index].depfile_bindings = validated_depfile.bindings.clone();
1081 commands[index].observed_dependencies = validated_depfile.observed_dependencies.clone();
1082 commands[index].dependency_validation = Some(NativeOperatorDependencyValidation::Depfile);
1083 let object_identity = match native_object_identity_file(&object_path) {
1084 Ok(identity) => identity,
1085 Err(error) => {
1086 commands[index].object_cache_status =
1087 Some(NativeOperatorSourceObjectCacheStatus::Rejected);
1088 receipt.commands = commands.clone();
1089 receipt.elapsed_ms = millis(started.elapsed());
1090 return reject_source_build(
1091 &receipt_path,
1092 &mut receipt,
1093 format!("compiled_object_identity_failed:{index}:{error}"),
1094 );
1095 }
1096 };
1097 if expected_object_identity
1098 .as_ref()
1099 .is_some_and(|expected| expected != &object_identity)
1100 {
1101 commands[index].object_cache_status =
1102 Some(NativeOperatorSourceObjectCacheStatus::Rejected);
1103 receipt.commands = commands.clone();
1104 receipt.elapsed_ms = millis(started.elapsed());
1105 return reject_source_build(
1106 &receipt_path,
1107 &mut receipt,
1108 format!("compiled_object_target_mismatch:{index}"),
1109 );
1110 }
1111 expected_object_identity.get_or_insert_with(|| object_identity.clone());
1112 commands[index].object_identity = Some(object_identity);
1113 let object_size_bytes = match native_object_size(&object_path) {
1114 Ok(size_bytes) => size_bytes,
1115 Err(error) => {
1116 commands[index].object_cache_status =
1117 Some(NativeOperatorSourceObjectCacheStatus::Rejected);
1118 receipt.commands = commands.clone();
1119 receipt.elapsed_ms = millis(started.elapsed());
1120 return reject_source_build(
1121 &receipt_path,
1122 &mut receipt,
1123 format!("compiled_object_size_failed:{index}:{error}"),
1124 );
1125 }
1126 };
1127 commands[index].object_size_bytes = Some(object_size_bytes);
1128 let cache_receipt = match object_cache.publish(&object_specs[index], &object_path) {
1129 Ok(cache_receipt) => cache_receipt,
1130 Err(error) => {
1131 commands[index].object_cache_status =
1132 Some(NativeOperatorSourceObjectCacheStatus::Rejected);
1133 receipt.commands = commands.clone();
1134 receipt.elapsed_ms = millis(started.elapsed());
1135 return reject_source_build(
1136 &receipt_path,
1137 &mut receipt,
1138 format!("object_cache_publish_failed:{index}:{error}"),
1139 );
1140 }
1141 };
1142 if let Err(error) = publish_object_dependency_proof(
1143 &cache_receipt.cache_entry,
1144 object_specs[index].input_signature_sha256(),
1145 &cache_receipt.artifact_sha256,
1146 translation_unit,
1147 &plan.dependency_closures[index],
1148 &validated_depfile.compiler_raw,
1149 &validated_depfile.compiler_sha256,
1150 &validated_depfile.portable_raw,
1151 &validated_depfile.portable_sha256,
1152 commands[index]
1153 .depfile_producer_working_directory
1154 .as_deref()
1155 .expect("compiled depfile records its producer working directory"),
1156 commands[index]
1157 .depfile_producer_object_file
1158 .as_deref()
1159 .expect("compiled depfile records its producer object"),
1160 &commands[index].depfile_bindings,
1161 &commands[index].observed_dependencies,
1162 &toolchain_dependency_scope,
1163 ) {
1164 commands[index].object_cache_status =
1165 Some(NativeOperatorSourceObjectCacheStatus::Rejected);
1166 receipt.commands = commands.clone();
1167 receipt.elapsed_ms = millis(started.elapsed());
1168 return reject_source_build(
1169 &receipt_path,
1170 &mut receipt,
1171 format!("object_dependency_proof_publish_failed:{index}:{error}"),
1172 );
1173 }
1174 commands[index].object_cache_status =
1175 Some(NativeOperatorSourceObjectCacheStatus::Published);
1176 commands[index].object_cache_entry = Some(cache_receipt.cache_entry.display().to_string());
1177 commands[index].object_sha256 = Some(cache_receipt.artifact_sha256);
1178 receipt.commands = commands.clone();
1179 write_json(&receipt_path, &receipt)?;
1180 }
1181
1182 let archive_index = plan.translation_units.len();
1183 let archive_command = commands
1184 .get_mut(archive_index)
1185 .expect("archive command follows translation units");
1186 let archive_started = Instant::now();
1187 let archive_stdout_path = request.output_dir.join(&archive_command.stdout_log);
1188 let archive_stderr_path = request.output_dir.join(&archive_command.stderr_log);
1189 let archive_status = match run_logged_command(
1190 &archive_command.argv,
1191 &archive_stdout_path,
1192 &archive_stderr_path,
1193 &archive_command.working_directory,
1194 &receipt.effective_environment,
1195 ) {
1196 Ok(status) => status,
1197 Err(error) => {
1198 append_command_stream(
1199 &archive_stderr_path,
1200 format!("spawn failed: {error}\n").as_bytes(),
1201 )?;
1202 receipt.commands = commands.clone();
1203 receipt.elapsed_ms = millis(started.elapsed());
1204 return reject_source_build(
1205 &receipt_path,
1206 &mut receipt,
1207 "archive_spawn_failed".to_string(),
1208 );
1209 }
1210 };
1211 archive_command.elapsed_ms = Some(millis(archive_started.elapsed()));
1212 archive_command.return_code = archive_status.code();
1213 receipt.commands = commands;
1214 receipt.elapsed_ms = millis(started.elapsed());
1215 if !archive_status.success() {
1216 return reject_source_build(&receipt_path, &mut receipt, "archive_failed".to_string());
1217 }
1218
1219 let archive_path = request.output_dir.join(&plan.archive_file);
1220 if let Err(error) = require_file(&archive_path) {
1221 return reject_source_build(
1222 &receipt_path,
1223 &mut receipt,
1224 format!("archive_output_missing:{error}"),
1225 );
1226 }
1227 let archive_sha256 = match sha256_file(&archive_path) {
1228 Ok(sha256) => sha256,
1229 Err(error) => {
1230 return reject_source_build(
1231 &receipt_path,
1232 &mut receipt,
1233 format!("archive_output_hash_failed:{error}"),
1234 );
1235 }
1236 };
1237 let static_identity = receipt
1238 .toolchain
1239 .as_ref()
1240 .expect("actual source build has a resolved toolchain")
1241 .static_identity
1242 .clone();
1243 if let Err(error) = validate_tool_file_unchanged(&static_identity.archiver) {
1244 return reject_source_build(
1245 &receipt_path,
1246 &mut receipt,
1247 format!("archiver_changed_during_build:{error}"),
1248 );
1249 }
1250 if receipt
1251 .toolchain
1252 .as_ref()
1253 .is_some_and(|toolchain| toolchain.miss_probe.is_some())
1254 {
1255 if let Err(error) = validate_host_toolchain_unchanged(
1256 &static_identity.host_toolchain,
1257 &request.output_dir,
1258 &receipt.effective_environment,
1259 )
1260 .and_then(|()| validate_tool_file_unchanged(&static_identity.cuda_toolkit.nvcc))
1261 .and_then(|()| validate_cuda_toolkit_unchanged(&static_identity.cuda_toolkit))
1262 {
1263 return reject_source_build(
1264 &receipt_path,
1265 &mut receipt,
1266 format!("compiler_toolchain_changed_during_build:{error}"),
1267 );
1268 }
1269 }
1270 receipt.archive_sha256 = Some(archive_sha256);
1271 receipt.archive_file = Some(plan.archive_file);
1272 receipt.status = NativeOperatorSourceBuildStatus::Pass;
1273 receipt.failure_class = None;
1274 receipt.elapsed_ms = millis(started.elapsed());
1275 write_json(&receipt_path, &receipt)?;
1276 Ok(receipt)
1277}
1278
1279fn validate_definition(definition: &NativeOperatorSourceDefinition) -> Result<()> {
1280 if definition.schema_version != NATIVE_OPERATOR_SOURCE_DEFINITION_SCHEMA_VERSION {
1281 return Err(NativeOperatorBuilderError::Invalid(format!(
1282 "source definition schema_version must be {NATIVE_OPERATOR_SOURCE_DEFINITION_SCHEMA_VERSION}"
1283 )));
1284 }
1285 validate_common(
1286 &definition.operator,
1287 &definition.upstream_sources,
1288 &definition.translation_units,
1289 &definition.headers,
1290 &definition.include_dirs,
1291 &definition.defines,
1292 &definition.archive_file,
1293 )?;
1294 validate_definition_dependency_closures(
1295 &definition.translation_units,
1296 &definition.headers,
1297 &definition.dependency_closures,
1298 )?;
1299 if definition.source_package_kind.trim().is_empty()
1300 || definition.source_package_revision.trim().is_empty()
1301 {
1302 return Err(NativeOperatorBuilderError::Invalid(
1303 "source package kind and revision must be non-empty".to_string(),
1304 ));
1305 }
1306 Ok(())
1307}
1308
1309fn validate_plan(plan: &NativeOperatorSourceBuildPlan) -> Result<()> {
1310 if plan.schema_version != NATIVE_OPERATOR_SOURCE_BUILD_PLAN_SCHEMA_VERSION {
1311 return Err(NativeOperatorBuilderError::Invalid(format!(
1312 "source build plan schema_version must be {NATIVE_OPERATOR_SOURCE_BUILD_PLAN_SCHEMA_VERSION}"
1313 )));
1314 }
1315 let translation_units = plan
1316 .translation_units
1317 .iter()
1318 .map(|file| file.path.clone())
1319 .collect::<Vec<_>>();
1320 let headers = plan
1321 .headers
1322 .iter()
1323 .map(|file| file.path.clone())
1324 .collect::<Vec<_>>();
1325 validate_common(
1326 &plan.operator,
1327 &plan.upstream_sources,
1328 &translation_units,
1329 &headers,
1330 &plan.include_dirs,
1331 &plan.defines,
1332 &plan.archive_file,
1333 )?;
1334 if plan.source_package.kind.trim().is_empty()
1335 || plan.source_package.revision.trim().is_empty()
1336 || !is_sha256_digest(&plan.source_package.sha256)
1337 {
1338 return Err(NativeOperatorBuilderError::Invalid(
1339 "source_package kind/revision must be non-empty and sha256 must be lowercase"
1340 .to_string(),
1341 ));
1342 }
1343 for file in plan.translation_units.iter().chain(plan.headers.iter()) {
1344 if !is_sha256_digest(&file.sha256) {
1345 return Err(NativeOperatorBuilderError::Invalid(format!(
1346 "{} has an invalid sha256",
1347 file.path
1348 )));
1349 }
1350 }
1351 validate_plan_dependency_closures(plan)?;
1352 let identity = NativeOperatorSourceInventoryIdentity {
1353 operator: &plan.operator,
1354 upstream_sources: &plan.upstream_sources,
1355 translation_units: &plan.translation_units,
1356 headers: &plan.headers,
1357 };
1358 let actual = sha256_bytes(&serde_json::to_vec(&identity).map_err(|source| {
1359 NativeOperatorBuilderError::Json {
1360 path: PathBuf::from("<source-build-plan>"),
1361 source,
1362 }
1363 })?);
1364 if actual != plan.source_package.sha256 {
1365 return Err(NativeOperatorBuilderError::Invalid(format!(
1366 "source_package.sha256 differs from locked file inventory: expected={} actual={actual}",
1367 plan.source_package.sha256
1368 )));
1369 }
1370 Ok(())
1371}
1372
1373fn validate_definition_dependency_closures(
1374 translation_units: &[String],
1375 headers: &[String],
1376 closures: &[NativeOperatorTranslationUnitDependencies],
1377) -> Result<()> {
1378 let closure_translation_units = closures
1379 .iter()
1380 .map(|closure| closure.translation_unit.as_str())
1381 .collect::<Vec<_>>();
1382 let expected_translation_units = translation_units
1383 .iter()
1384 .map(String::as_str)
1385 .collect::<Vec<_>>();
1386 if closure_translation_units != expected_translation_units {
1387 return Err(NativeOperatorBuilderError::Invalid(
1388 "dependency_closures must contain exactly one row per translation unit in translation_units order"
1389 .to_string(),
1390 ));
1391 }
1392 let declared_headers = headers.iter().map(String::as_str).collect::<BTreeSet<_>>();
1393 let mut attached_headers = BTreeSet::new();
1394 for closure in closures {
1395 require_sorted_optional_paths(
1396 &format!("dependency_closures[{}].headers", closure.translation_unit),
1397 &closure.headers,
1398 )?;
1399 for header in &closure.headers {
1400 if !declared_headers.contains(header.as_str()) {
1401 return Err(NativeOperatorBuilderError::Invalid(format!(
1402 "dependency closure for {} references undeclared header: {header}",
1403 closure.translation_unit
1404 )));
1405 }
1406 attached_headers.insert(header.as_str());
1407 }
1408 }
1409 if attached_headers != declared_headers {
1410 let missing = declared_headers
1411 .difference(&attached_headers)
1412 .copied()
1413 .collect::<Vec<_>>()
1414 .join(",");
1415 return Err(NativeOperatorBuilderError::Invalid(format!(
1416 "every declared header must belong to at least one dependency closure; unattached={missing}"
1417 )));
1418 }
1419 Ok(())
1420}
1421
1422fn validate_plan_dependency_closures(plan: &NativeOperatorSourceBuildPlan) -> Result<()> {
1423 let translation_units = plan
1424 .translation_units
1425 .iter()
1426 .map(|file| file.path.clone())
1427 .collect::<Vec<_>>();
1428 let headers = plan
1429 .headers
1430 .iter()
1431 .map(|file| file.path.clone())
1432 .collect::<Vec<_>>();
1433 let closures = plan
1434 .dependency_closures
1435 .iter()
1436 .map(|closure| NativeOperatorTranslationUnitDependencies {
1437 translation_unit: closure.translation_unit.clone(),
1438 headers: closure
1439 .headers
1440 .iter()
1441 .map(|header| header.path.clone())
1442 .collect(),
1443 })
1444 .collect::<Vec<_>>();
1445 validate_definition_dependency_closures(&translation_units, &headers, &closures)?;
1446
1447 let translation_unit_by_path = plan
1448 .translation_units
1449 .iter()
1450 .map(|locked| (locked.path.as_str(), locked))
1451 .collect::<BTreeMap<_, _>>();
1452 let header_by_path = plan
1453 .headers
1454 .iter()
1455 .map(|locked| (locked.path.as_str(), locked))
1456 .collect::<BTreeMap<_, _>>();
1457 for closure in &plan.dependency_closures {
1458 let translation_unit = translation_unit_by_path
1459 .get(closure.translation_unit.as_str())
1460 .copied()
1461 .expect("definition-shaped closure already validated");
1462 for header in &closure.headers {
1463 let global = header_by_path
1464 .get(header.path.as_str())
1465 .copied()
1466 .expect("definition-shaped closure already validated");
1467 if global != header {
1468 return Err(NativeOperatorBuilderError::Invalid(format!(
1469 "dependency closure header lock differs from the global lock: {}",
1470 header.path
1471 )));
1472 }
1473 }
1474 if !is_sha256_digest(&closure.closure_sha256) {
1475 return Err(NativeOperatorBuilderError::Invalid(format!(
1476 "dependency closure for {} has an invalid sha256",
1477 closure.translation_unit
1478 )));
1479 }
1480 let actual = dependency_closure_sha256(
1481 translation_unit,
1482 &closure.headers,
1483 Path::new("<source-build-plan>"),
1484 )?;
1485 if actual != closure.closure_sha256 {
1486 return Err(NativeOperatorBuilderError::Invalid(format!(
1487 "dependency closure hash mismatch for {}: expected={} actual={actual}",
1488 closure.translation_unit, closure.closure_sha256
1489 )));
1490 }
1491 }
1492 Ok(())
1493}
1494
1495fn dependency_closure_sha256(
1496 translation_unit: &NativeOperatorSourceFileLock,
1497 headers: &[NativeOperatorSourceFileLock],
1498 context: &Path,
1499) -> Result<String> {
1500 let identity = NativeOperatorDependencyClosureIdentity {
1501 translation_unit,
1502 headers,
1503 };
1504 let bytes =
1505 serde_json::to_vec(&identity).map_err(|source| NativeOperatorBuilderError::Json {
1506 path: context.to_path_buf(),
1507 source,
1508 })?;
1509 Ok(sha256_bytes(&bytes))
1510}
1511
1512pub(crate) fn verify_source_build_receipt_against_plan(
1513 receipt: &NativeOperatorSourceBuildReceipt,
1514 receipt_root: &Path,
1515 plan_path: &Path,
1516 source_root: &Path,
1517) -> Result<NativeOperatorSourceBuildPlan> {
1518 let plan = verify_source_build_receipt_against_plan_portable(receipt, plan_path)?;
1519 verify_source_build_evidence(receipt, receipt_root, &plan)?;
1520 let canonical_source_root =
1521 source_root
1522 .canonicalize()
1523 .map_err(|source| NativeOperatorBuilderError::Io {
1524 path: source_root.to_path_buf(),
1525 source,
1526 })?;
1527 validate_locked_source_tree(&canonical_source_root, &plan)?;
1528 Ok(plan)
1529}
1530
1531pub(crate) fn verify_source_build_receipt_against_plan_portable(
1532 receipt: &NativeOperatorSourceBuildReceipt,
1533 plan_path: &Path,
1534) -> Result<NativeOperatorSourceBuildPlan> {
1535 require_file(plan_path)?;
1536 let plan: NativeOperatorSourceBuildPlan = read_json(plan_path)?;
1537 validate_plan(&plan)?;
1538 let plan_sha256 = sha256_file(plan_path)?;
1539 if receipt.plan_sha256 != plan_sha256 {
1540 return Err(NativeOperatorBuilderError::Invalid(format!(
1541 "{} source-build receipt plan_sha256 mismatch: expected={plan_sha256} actual={}",
1542 receipt.operator, receipt.plan_sha256
1543 )));
1544 }
1545 if receipt.operator != plan.operator || receipt.source_package != plan.source_package {
1546 return Err(NativeOperatorBuilderError::Invalid(format!(
1547 "{} source-build receipt does not match its locked plan identity",
1548 receipt.operator
1549 )));
1550 }
1551
1552 let expected_architecture =
1553 architecture_argument(plan.architecture, &receipt.compute_capability);
1554 if receipt.architecture_argument != expected_architecture {
1555 return Err(NativeOperatorBuilderError::Invalid(format!(
1556 "{} source-build architecture argument differs from its plan: expected={expected_architecture} actual={}",
1557 receipt.operator, receipt.architecture_argument
1558 )));
1559 }
1560 let toolchain = receipt.toolchain.as_ref().ok_or_else(|| {
1561 NativeOperatorBuilderError::Invalid(format!(
1562 "{} source-build receipt is missing toolchain provenance",
1563 receipt.operator
1564 ))
1565 })?;
1566 validate_static_toolchain_identity(&receipt.operator, &toolchain.static_identity)?;
1567 let static_identity = &toolchain.static_identity;
1568 let expected_environment = effective_environment_for_tool_paths([
1569 static_identity.cuda_toolkit.nvcc.path.as_str(),
1570 static_identity.host_toolchain.compiler.path.as_str(),
1571 static_identity.archiver.path.as_str(),
1572 ])?;
1573 if receipt.effective_environment != expected_environment {
1574 return Err(NativeOperatorBuilderError::Invalid(format!(
1575 "{} source-build effective environment differs from the deterministic policy",
1576 receipt.operator
1577 )));
1578 }
1579 let expected_inputs_sha256 = build_inputs_sha256(
1580 &plan_sha256,
1581 &plan.source_package.sha256,
1582 &expected_architecture,
1583 &expected_environment,
1584 Some(static_identity),
1585 plan_path,
1586 )?;
1587 if receipt.inputs_sha256 != expected_inputs_sha256 {
1588 return Err(NativeOperatorBuilderError::Invalid(format!(
1589 "{} source-build inputs_sha256 mismatch: expected={expected_inputs_sha256} actual={}",
1590 receipt.operator, receipt.inputs_sha256
1591 )));
1592 }
1593 let object_specs = build_object_cache_specs(
1594 &plan,
1595 &expected_architecture,
1596 static_identity,
1597 &expected_environment,
1598 )?;
1599 if receipt.commands.len() != plan.translation_units.len() + 1 {
1600 return Err(NativeOperatorBuilderError::Invalid(format!(
1601 "{} source-build command count differs from its plan",
1602 receipt.operator
1603 )));
1604 }
1605 for (index, ((translation_unit, object_spec), command)) in plan
1606 .translation_units
1607 .iter()
1608 .zip(object_specs.iter())
1609 .zip(receipt.commands.iter())
1610 .enumerate()
1611 {
1612 let expected_object_file = object_file_name(index, translation_unit);
1613 let closure = &plan.dependency_closures[index];
1614 if command.translation_unit.as_deref() != Some(translation_unit.path.as_str())
1615 || command.object_cache_key.as_deref() != Some(object_spec.input_signature_sha256())
1616 || command.dependency_closure_sha256.as_deref() != Some(closure.closure_sha256.as_str())
1617 || command
1618 .object_file
1619 .as_deref()
1620 .and_then(|path| Path::new(path).file_name())
1621 != Some(std::ffi::OsStr::new(&expected_object_file))
1622 {
1623 return Err(NativeOperatorBuilderError::Invalid(format!(
1624 "{} source-build object identity for {} differs from its plan",
1625 receipt.operator, translation_unit.path
1626 )));
1627 }
1628 let stem = Path::new(&translation_unit.path)
1629 .file_stem()
1630 .and_then(|value| value.to_str())
1631 .unwrap_or("translation_unit");
1632 let expected_depfile = format!("{index:08}-{stem}.d");
1633 let expected_compiler_depfile = format!("{index:08}-{stem}.compiler.raw.d");
1634 let mut expected_argv = vec![
1635 static_identity.cuda_toolkit.nvcc.path.clone(),
1636 "-c".to_string(),
1637 translation_unit.path.clone(),
1638 "-o".to_string(),
1639 expected_object_file.clone(),
1640 expected_architecture.clone(),
1641 "-ccbin".to_string(),
1642 static_identity.host_toolchain.compiler.path.clone(),
1643 "-MMD".to_string(),
1644 "-MF".to_string(),
1645 expected_compiler_depfile.clone(),
1646 "-MT".to_string(),
1647 expected_object_file.clone(),
1648 ];
1649 expected_argv.extend(plan.include_dirs.iter().map(|path| format!("-I{path}")));
1650 expected_argv.extend(plan.defines.iter().map(|define| format!("-D{define}")));
1651 expected_argv.extend(nvcc_policy_flags(&plan.nvcc_policy));
1652 expected_argv.push("--threads".to_string());
1653 expected_argv.push(receipt.nvcc_threads.to_string());
1654 let mut actual_argv = command.argv.clone();
1655 if let Some(output) = actual_argv.get_mut(4) {
1656 *output = Path::new(output)
1657 .file_name()
1658 .unwrap_or_default()
1659 .to_string_lossy()
1660 .into_owned();
1661 }
1662 for argument in [10_usize, 12] {
1663 if let Some(path) = actual_argv.get_mut(argument) {
1664 *path = Path::new(path)
1665 .file_name()
1666 .unwrap_or_default()
1667 .to_string_lossy()
1668 .into_owned();
1669 }
1670 }
1671 if actual_argv != expected_argv {
1672 return Err(NativeOperatorBuilderError::Invalid(format!(
1673 "{} source-build argv for {} differs from its locked plan",
1674 receipt.operator, translation_unit.path
1675 )));
1676 }
1677 validate_observed_dependencies(
1678 &format!("{}:{}", receipt.operator, translation_unit.path),
1679 &command.observed_dependencies,
1680 )?;
1681 let expected_source = expected_source_dependencies(translation_unit, closure);
1682 let observed_source = command
1683 .observed_dependencies
1684 .iter()
1685 .filter(|dependency| dependency.domain == NativeOperatorDependencyDomain::Source)
1686 .cloned()
1687 .collect::<BTreeSet<_>>();
1688 if observed_source != expected_source {
1689 return Err(NativeOperatorBuilderError::Invalid(format!(
1690 "{} source-build source dependency evidence for {} differs from its locked closure",
1691 receipt.operator, translation_unit.path
1692 )));
1693 }
1694 let expected_depfile_relative = format!("depfiles/{expected_depfile}");
1695 let expected_compiler_depfile_relative = format!("depfiles/{expected_compiler_depfile}");
1696 let binding_dependencies = validate_depfile_bindings_basic(
1697 &format!("{}:{}", receipt.operator, translation_unit.path),
1698 &command.depfile_bindings,
1699 )?;
1700 match command.object_cache_status {
1701 Some(NativeOperatorSourceObjectCacheStatus::Published)
1702 if command.dependency_validation
1703 == Some(NativeOperatorDependencyValidation::Depfile)
1704 && command.compiler_depfile.as_deref()
1705 == Some(expected_compiler_depfile_relative.as_str())
1706 && command
1707 .compiler_depfile_sha256
1708 .as_deref()
1709 .is_some_and(is_sha256_digest)
1710 && command.depfile.as_deref() == Some(expected_depfile_relative.as_str())
1711 && command
1712 .depfile_sha256
1713 .as_deref()
1714 .is_some_and(is_sha256_digest)
1715 && command.depfile_producer_working_directory.as_deref()
1716 == Some(command.working_directory.as_str())
1717 && command.depfile_producer_object_file.as_deref()
1718 == command.object_file.as_deref()
1719 && binding_dependencies == command.observed_dependencies => {}
1720 Some(NativeOperatorSourceObjectCacheStatus::Hit)
1721 if command.dependency_validation
1722 == Some(NativeOperatorDependencyValidation::CacheProof)
1723 && command.compiler_depfile.as_deref()
1724 == Some(expected_compiler_depfile_relative.as_str())
1725 && command
1726 .compiler_depfile_sha256
1727 .as_deref()
1728 .is_some_and(is_sha256_digest)
1729 && command.depfile.as_deref() == Some(expected_depfile_relative.as_str())
1730 && command
1731 .depfile_sha256
1732 .as_deref()
1733 .is_some_and(is_sha256_digest)
1734 && command
1735 .depfile_producer_working_directory
1736 .as_deref()
1737 .is_some_and(|path| Path::new(path).is_absolute())
1738 && command
1739 .depfile_producer_object_file
1740 .as_deref()
1741 .is_some_and(|path| {
1742 Path::new(path).is_absolute()
1743 && Path::new(path).file_name()
1744 == Some(std::ffi::OsStr::new(&expected_object_file))
1745 })
1746 && binding_dependencies == command.observed_dependencies => {}
1747 _ => {
1748 return Err(NativeOperatorBuilderError::Invalid(format!(
1749 "{} source-build dependency evidence for {} differs from its locked plan",
1750 receipt.operator, translation_unit.path
1751 )))
1752 }
1753 }
1754 }
1755 match &toolchain.miss_probe {
1756 Some(probe)
1757 if probe.probed_for_misses == receipt.compiled_translation_units
1758 && !receipt.compiled_translation_units.is_empty()
1759 && !probe.nvcc_version.trim().is_empty()
1760 && probe.host_compiler_version
1761 == static_identity.host_toolchain.compiler_version
1762 && !probe.archiver_version.trim().is_empty()
1763 && probe.host_target == static_identity.host_toolchain.target => {}
1764 None if receipt.compiled_translation_units.is_empty() => {}
1765 _ => {
1766 return Err(NativeOperatorBuilderError::Invalid(format!(
1767 "{} source-build miss-only toolchain probe differs from compiled translation units",
1768 receipt.operator
1769 )))
1770 }
1771 }
1772 let archive_command = receipt.commands.last().expect("command count checked");
1773 let actual_archive_argv = archive_command
1774 .argv
1775 .iter()
1776 .enumerate()
1777 .map(|(index, value)| {
1778 if index >= 2 {
1779 Path::new(value)
1780 .file_name()
1781 .unwrap_or_default()
1782 .to_string_lossy()
1783 .into_owned()
1784 } else {
1785 value.clone()
1786 }
1787 })
1788 .collect::<Vec<_>>();
1789 let mut expected_archive_argv = vec![
1790 static_identity.archiver.path.clone(),
1791 "rcs".to_string(),
1792 plan.archive_file.clone(),
1793 ];
1794 expected_archive_argv.extend(
1795 plan.translation_units
1796 .iter()
1797 .enumerate()
1798 .map(|(index, translation_unit)| object_file_name(index, translation_unit)),
1799 );
1800 if actual_archive_argv != expected_archive_argv
1801 || receipt.archive_file.as_deref() != Some(plan.archive_file.as_str())
1802 {
1803 return Err(NativeOperatorBuilderError::Invalid(format!(
1804 "{} source-build archive command differs from its locked plan",
1805 receipt.operator
1806 )));
1807 }
1808 Ok(plan)
1809}
1810
1811pub(crate) fn verify_source_build_evidence(
1812 receipt: &NativeOperatorSourceBuildReceipt,
1813 receipt_root: &Path,
1814 plan: &NativeOperatorSourceBuildPlan,
1815) -> Result<()> {
1816 let toolchain = receipt.toolchain.as_ref().ok_or_else(|| {
1817 NativeOperatorBuilderError::Invalid(format!(
1818 "{} source-build receipt is missing toolchain provenance",
1819 receipt.operator
1820 ))
1821 })?;
1822 let manifest_evidence = &toolchain.static_identity.cuda_toolkit.manifest;
1823 let manifest_path =
1824 resolve_source_build_evidence_file(receipt_root, &receipt.operator, manifest_evidence)?;
1825 let manifest: NativeOperatorCudaToolkitManifest = read_json(&manifest_path)?;
1826 validate_cuda_toolkit_manifest(
1827 &receipt.operator,
1828 &toolchain.static_identity.cuda_toolkit,
1829 &manifest,
1830 )?;
1831 let host_identity = &toolchain.static_identity.host_toolchain;
1832 let host_manifest_path = resolve_source_build_evidence_file(
1833 receipt_root,
1834 &receipt.operator,
1835 &host_identity.manifest,
1836 )?;
1837 let host_manifest: NativeOperatorHostToolchainManifest = read_json(&host_manifest_path)?;
1838 validate_host_toolchain_manifest(&receipt.operator, &host_manifest)?;
1839 if host_manifest.compiler != host_identity.compiler
1840 || host_manifest.compiler_version != host_identity.compiler_version
1841 || host_manifest.target != host_identity.target
1842 {
1843 return Err(NativeOperatorBuilderError::Invalid(format!(
1844 "{} host toolchain manifest differs from its receipt identity",
1845 receipt.operator
1846 )));
1847 }
1848 let toolchain_dependency_scope =
1849 toolchain_dependency_scope(&toolchain.static_identity, &manifest, &host_manifest)?;
1850 for (index, command) in receipt
1851 .commands
1852 .iter()
1853 .take(plan.translation_units.len())
1854 .enumerate()
1855 {
1856 if !matches!(
1857 command.dependency_validation,
1858 Some(
1859 NativeOperatorDependencyValidation::Depfile
1860 | NativeOperatorDependencyValidation::CacheProof
1861 )
1862 ) {
1863 continue;
1864 }
1865 let compiler_relative = command.compiler_depfile.as_deref().ok_or_else(|| {
1866 NativeOperatorBuilderError::Invalid(format!(
1867 "{} compiled source-build command is missing compiler depfile evidence",
1868 receipt.operator
1869 ))
1870 })?;
1871 let compiler_sha256 = command.compiler_depfile_sha256.as_deref().ok_or_else(|| {
1872 NativeOperatorBuilderError::Invalid(format!(
1873 "{} compiled source-build command is missing compiler depfile sha256",
1874 receipt.operator
1875 ))
1876 })?;
1877 let compiler_path = resolve_source_build_relative_file(receipt_root, compiler_relative)?;
1878 let compiler_bytes = read_bounded_regular_file(
1879 &compiler_path,
1880 MAX_DEPFILE_BYTES,
1881 "source-build compiler depfile",
1882 )?;
1883 let compiler_actual = sha256_bytes(&compiler_bytes);
1884 if compiler_actual != compiler_sha256 {
1885 return Err(NativeOperatorBuilderError::Invalid(format!(
1886 "{} source-build compiler depfile sha256 mismatch: path={compiler_relative} expected={compiler_sha256} actual={compiler_actual}",
1887 receipt.operator
1888 )));
1889 }
1890 let compiler_raw = std::str::from_utf8(&compiler_bytes).map_err(|_| {
1891 NativeOperatorBuilderError::Invalid(format!(
1892 "{} source-build compiler depfile is not UTF-8: {compiler_relative}",
1893 receipt.operator
1894 ))
1895 })?;
1896 let relative = command.depfile.as_deref().ok_or_else(|| {
1897 NativeOperatorBuilderError::Invalid(format!(
1898 "{} compiled source-build command is missing portable depfile evidence",
1899 receipt.operator
1900 ))
1901 })?;
1902 let sha256 = command.depfile_sha256.as_deref().ok_or_else(|| {
1903 NativeOperatorBuilderError::Invalid(format!(
1904 "{} compiled source-build command is missing depfile sha256",
1905 receipt.operator
1906 ))
1907 })?;
1908 let path = resolve_source_build_relative_file(receipt_root, relative)?;
1909 let bytes =
1910 read_bounded_regular_file(&path, MAX_DEPFILE_BYTES, "source-build portable depfile")?;
1911 let actual = sha256_bytes(&bytes);
1912 if actual != sha256 {
1913 return Err(NativeOperatorBuilderError::Invalid(format!(
1914 "{} source-build depfile sha256 mismatch: path={relative} expected={sha256} actual={actual}",
1915 receipt.operator
1916 )));
1917 }
1918 let raw = std::str::from_utf8(&bytes).map_err(|_| {
1919 NativeOperatorBuilderError::Invalid(format!(
1920 "{} source-build portable depfile is not UTF-8: {relative}",
1921 receipt.operator
1922 ))
1923 })?;
1924 let producer_object_file =
1925 command
1926 .depfile_producer_object_file
1927 .as_deref()
1928 .ok_or_else(|| {
1929 NativeOperatorBuilderError::Invalid(format!(
1930 "{} compiled source-build command is missing depfile producer object",
1931 receipt.operator
1932 ))
1933 })?;
1934 let producer_working_directory = command
1935 .depfile_producer_working_directory
1936 .as_deref()
1937 .ok_or_else(|| {
1938 NativeOperatorBuilderError::Invalid(format!(
1939 "{} compiled source-build command is missing depfile producer working directory",
1940 receipt.operator
1941 ))
1942 })?;
1943 command.object_file.as_deref().ok_or_else(|| {
1944 NativeOperatorBuilderError::Invalid(format!(
1945 "{} source-build command is missing object path for depfile validation",
1946 receipt.operator
1947 ))
1948 })?;
1949 let observed = validate_portable_depfile_pair(
1950 compiler_raw,
1951 &compiler_path,
1952 raw,
1953 &path,
1954 producer_object_file,
1955 producer_working_directory,
1956 &plan.translation_units[index],
1957 &plan.dependency_closures[index],
1958 &command.depfile_bindings,
1959 &toolchain_dependency_scope,
1960 )?;
1961 if observed != command.observed_dependencies {
1962 return Err(NativeOperatorBuilderError::Invalid(format!(
1963 "{} source-build depfile semantics differ from receipt evidence: path={relative}",
1964 receipt.operator
1965 )));
1966 }
1967 }
1968 Ok(())
1969}
1970
1971fn resolve_source_build_evidence_file(
1972 root: &Path,
1973 operator: &str,
1974 evidence: &NativeOperatorEvidenceFile,
1975) -> Result<PathBuf> {
1976 if !is_sha256_digest(&evidence.sha256) || evidence.size_bytes == 0 {
1977 return Err(NativeOperatorBuilderError::Invalid(format!(
1978 "{operator} source-build evidence identity is incomplete: {}",
1979 evidence.path
1980 )));
1981 }
1982 let path = resolve_source_build_relative_file(root, &evidence.path)?;
1983 let size_bytes = fs::metadata(&path)
1984 .map_err(|source| NativeOperatorBuilderError::Io {
1985 path: path.clone(),
1986 source,
1987 })?
1988 .len();
1989 let sha256 = sha256_file(&path)?;
1990 if size_bytes != evidence.size_bytes || sha256 != evidence.sha256 {
1991 return Err(NativeOperatorBuilderError::Invalid(format!(
1992 "{operator} source-build evidence mismatch: path={} expected_size={} actual_size={size_bytes} expected_sha256={} actual_sha256={sha256}",
1993 evidence.path, evidence.size_bytes, evidence.sha256
1994 )));
1995 }
1996 Ok(path)
1997}
1998
1999fn resolve_source_build_relative_file(root: &Path, relative: &str) -> Result<PathBuf> {
2000 validate_relative_path(relative)?;
2001 let canonical_root = root
2002 .canonicalize()
2003 .map_err(|source| NativeOperatorBuilderError::Io {
2004 path: root.to_path_buf(),
2005 source,
2006 })?;
2007 let path = canonical_root.join(relative);
2008 let canonical = path
2009 .canonicalize()
2010 .map_err(|source| NativeOperatorBuilderError::Io {
2011 path: path.clone(),
2012 source,
2013 })?;
2014 if !canonical.starts_with(&canonical_root) || !canonical.is_file() {
2015 return Err(NativeOperatorBuilderError::Invalid(format!(
2016 "source-build evidence escapes its root or is not a file: {relative}"
2017 )));
2018 }
2019 Ok(canonical)
2020}
2021
2022fn validate_cuda_toolkit_manifest(
2023 operator: &str,
2024 identity: &NativeOperatorCudaToolkitIdentity,
2025 manifest: &NativeOperatorCudaToolkitManifest,
2026) -> Result<()> {
2027 if manifest.schema_version != NATIVE_OPERATOR_CUDA_TOOLKIT_MANIFEST_SCHEMA_VERSION
2028 || manifest.canonical_root != identity.canonical_root
2029 || manifest.entries.is_empty()
2030 || manifest
2031 .entries
2032 .windows(2)
2033 .any(|pair| pair[0].logical_path >= pair[1].logical_path)
2034 {
2035 return Err(NativeOperatorBuilderError::Invalid(format!(
2036 "{operator} cuda toolkit manifest header/order is invalid"
2037 )));
2038 }
2039 let mut scopes = BTreeSet::new();
2040 let mut required_files = REQUIRED_CUDA_TOOLKIT_FILES
2041 .iter()
2042 .copied()
2043 .collect::<BTreeSet<_>>();
2044 let mut selected_nvcc = None;
2045 for entry in &manifest.entries {
2046 validate_relative_path(&entry.logical_path)?;
2047 validate_relative_path(&entry.resolved_path)?;
2048 if !is_sha256_digest(&entry.sha256) {
2049 return Err(NativeOperatorBuilderError::Invalid(format!(
2050 "{operator} cuda toolkit manifest entry is incomplete: {}",
2051 entry.logical_path
2052 )));
2053 }
2054 for scope in ["bin/crt/", "include/", "nvvm/bin/", "nvvm/libdevice/"] {
2055 if entry.logical_path.starts_with(scope) {
2056 scopes.insert(scope);
2057 }
2058 }
2059 required_files.remove(entry.logical_path.as_str());
2060 if Path::new(&identity.canonical_root).join(&entry.resolved_path)
2061 == Path::new(&identity.nvcc.path)
2062 {
2063 selected_nvcc = Some(entry);
2064 }
2065 }
2066 if scopes.len() != REQUIRED_CUDA_TOOLKIT_SCOPES.len() || !required_files.is_empty() {
2067 return Err(NativeOperatorBuilderError::Invalid(format!(
2068 "{operator} cuda toolkit manifest does not cover every required compiler input; missing={}",
2069 required_files.into_iter().collect::<Vec<_>>().join(",")
2070 )));
2071 }
2072 if selected_nvcc.is_none_or(|entry| {
2073 entry.sha256 != identity.nvcc.sha256 || entry.size_bytes != identity.nvcc.size_bytes
2074 }) {
2075 return Err(NativeOperatorBuilderError::Invalid(format!(
2076 "{operator} cuda toolkit manifest does not bind the selected nvcc"
2077 )));
2078 }
2079 Ok(())
2080}
2081
2082#[allow(clippy::too_many_arguments)]
2083fn validate_common(
2084 operator: &str,
2085 upstream_sources: &[NativeOperatorUpstreamSource],
2086 translation_units: &[String],
2087 headers: &[String],
2088 include_dirs: &[String],
2089 defines: &[String],
2090 archive_file: &str,
2091) -> Result<()> {
2092 symbol_slug(operator)?;
2093 if CudaNativeBuildUnit::from_artifact_operator(operator).is_none() {
2094 return Err(NativeOperatorBuilderError::Invalid(format!(
2095 "source build operator is not a registered CUDA build unit: {operator}"
2096 )));
2097 }
2098 if upstream_sources.is_empty() {
2099 return Err(NativeOperatorBuilderError::Invalid(
2100 "upstream_sources must be non-empty".to_string(),
2101 ));
2102 }
2103 if upstream_sources.windows(2).any(|pair| {
2104 (&pair[0].repository, &pair[0].revision) >= (&pair[1].repository, &pair[1].revision)
2105 }) {
2106 return Err(NativeOperatorBuilderError::Invalid(
2107 "upstream_sources must be sorted and unique by repository/revision".to_string(),
2108 ));
2109 }
2110 for upstream in upstream_sources {
2111 if upstream.repository.trim().is_empty()
2112 || upstream.revision.trim().is_empty()
2113 || upstream.license.trim().is_empty()
2114 {
2115 return Err(NativeOperatorBuilderError::Invalid(
2116 "upstream source repository, revision, and license must be non-empty".to_string(),
2117 ));
2118 }
2119 }
2120 require_sorted_paths("translation_units", translation_units)?;
2121 require_sorted_optional_paths("headers", headers)?;
2122 if translation_units.iter().any(|path| !path.ends_with(".cu")) {
2123 return Err(NativeOperatorBuilderError::Invalid(
2124 "translation_units must use .cu paths".to_string(),
2125 ));
2126 }
2127 let mut all_files = translation_units
2128 .iter()
2129 .chain(headers.iter())
2130 .collect::<Vec<_>>();
2131 all_files.sort();
2132 if all_files.windows(2).any(|pair| pair[0] == pair[1]) {
2133 return Err(NativeOperatorBuilderError::Invalid(
2134 "translation_units and headers overlap".to_string(),
2135 ));
2136 }
2137 require_sorted_optional_paths("include_dirs", include_dirs)?;
2138 if defines.windows(2).any(|pair| pair[0] >= pair[1])
2139 || defines
2140 .iter()
2141 .any(|define| define.is_empty() || define.chars().any(char::is_whitespace))
2142 {
2143 return Err(NativeOperatorBuilderError::Invalid(
2144 "defines must be sorted, unique, non-empty single arguments".to_string(),
2145 ));
2146 }
2147 validate_relative_path(archive_file)?;
2148 if Path::new(archive_file).parent() != Some(Path::new(""))
2149 || !archive_file.starts_with("lib")
2150 || !archive_file.ends_with(".a")
2151 {
2152 return Err(NativeOperatorBuilderError::Invalid(
2153 "archive_file must be a lib*.a filename without directories".to_string(),
2154 ));
2155 }
2156 Ok(())
2157}
2158
2159fn require_sorted_paths(field: &str, paths: &[String]) -> Result<()> {
2160 if paths.is_empty() {
2161 return Err(NativeOperatorBuilderError::Invalid(format!(
2162 "{field} must be sorted, unique, non-empty normalized relative paths"
2163 )));
2164 }
2165 require_sorted_optional_paths(field, paths)
2166}
2167
2168fn require_sorted_optional_paths(field: &str, paths: &[String]) -> Result<()> {
2169 if paths.windows(2).any(|pair| pair[0] >= pair[1])
2170 || paths
2171 .iter()
2172 .any(|path| validate_relative_path(path).is_err())
2173 {
2174 return Err(NativeOperatorBuilderError::Invalid(format!(
2175 "{field} must be sorted, unique normalized relative paths"
2176 )));
2177 }
2178 Ok(())
2179}
2180
2181fn lock_source_files(root: &Path, paths: &[String]) -> Result<Vec<NativeOperatorSourceFileLock>> {
2182 paths
2183 .iter()
2184 .map(|relative| {
2185 let path = locked_source_file(root, relative)?;
2186 Ok(NativeOperatorSourceFileLock {
2187 path: relative.clone(),
2188 sha256: sha256_file(&path)?,
2189 })
2190 })
2191 .collect()
2192}
2193
2194fn validate_locked_source_tree(root: &Path, plan: &NativeOperatorSourceBuildPlan) -> Result<()> {
2195 if !root.is_dir() {
2196 return Err(NativeOperatorBuilderError::Invalid(format!(
2197 "source_root is not a directory: {}",
2198 root.display()
2199 )));
2200 }
2201 for include_dir in &plan.include_dirs {
2202 let path = root.join(include_dir);
2203 let canonical = path
2204 .canonicalize()
2205 .map_err(|source| NativeOperatorBuilderError::Io {
2206 path: path.clone(),
2207 source,
2208 })?;
2209 if !canonical.starts_with(root) || !canonical.is_dir() {
2210 return Err(NativeOperatorBuilderError::Invalid(format!(
2211 "locked include directory escapes source root or is missing: {include_dir}"
2212 )));
2213 }
2214 }
2215 for locked in plan.translation_units.iter().chain(plan.headers.iter()) {
2216 let path = locked_source_file(root, &locked.path)?;
2217 let actual = sha256_file(&path)?;
2218 if actual != locked.sha256 {
2219 return Err(NativeOperatorBuilderError::Invalid(format!(
2220 "locked source drift: path={} expected={} actual={actual}",
2221 locked.path, locked.sha256
2222 )));
2223 }
2224 }
2225 Ok(())
2226}
2227
2228struct NativeOperatorToolchainDependencyScope {
2229 by_absolute_path: BTreeMap<String, NativeOperatorObservedDependency>,
2230}
2231
2232fn load_toolchain_dependency_scope(
2233 receipt_root: &Path,
2234 toolchain: &NativeOperatorSourceBuildStaticToolchain,
2235) -> Result<NativeOperatorToolchainDependencyScope> {
2236 let cuda_manifest_path = resolve_source_build_evidence_file(
2237 receipt_root,
2238 "<dependency-scope>",
2239 &toolchain.cuda_toolkit.manifest,
2240 )?;
2241 let cuda_manifest: NativeOperatorCudaToolkitManifest = read_json(&cuda_manifest_path)?;
2242 validate_cuda_toolkit_manifest(
2243 "<dependency-scope>",
2244 &toolchain.cuda_toolkit,
2245 &cuda_manifest,
2246 )?;
2247 let host_manifest_path = resolve_source_build_evidence_file(
2248 receipt_root,
2249 "<dependency-scope>",
2250 &toolchain.host_toolchain.manifest,
2251 )?;
2252 let host_manifest: NativeOperatorHostToolchainManifest = read_json(&host_manifest_path)?;
2253 validate_host_toolchain_manifest("<dependency-scope>", &host_manifest)?;
2254 toolchain_dependency_scope(toolchain, &cuda_manifest, &host_manifest)
2255}
2256
2257fn toolchain_dependency_scope(
2258 toolchain: &NativeOperatorSourceBuildStaticToolchain,
2259 cuda_manifest: &NativeOperatorCudaToolkitManifest,
2260 host_manifest: &NativeOperatorHostToolchainManifest,
2261) -> Result<NativeOperatorToolchainDependencyScope> {
2262 let mut by_absolute_path = BTreeMap::new();
2263 let cuda_roots = [
2264 Path::new(&toolchain.cuda_toolkit.canonical_root),
2265 Path::new(&toolchain.cuda_toolkit.invocation_root),
2266 ];
2267 for entry in &cuda_manifest.entries {
2268 let dependency = NativeOperatorObservedDependency {
2269 domain: NativeOperatorDependencyDomain::BackendToolchain,
2270 path: entry.logical_path.clone(),
2271 sha256: entry.sha256.clone(),
2272 };
2273 for root in cuda_roots {
2274 for relative in [&entry.logical_path, &entry.resolved_path] {
2275 insert_toolchain_dependency(
2276 &mut by_absolute_path,
2277 root.join(relative).display().to_string(),
2278 dependency.clone(),
2279 )?;
2280 }
2281 }
2282 }
2283 for entry in &host_manifest.files {
2284 let dependency = NativeOperatorObservedDependency {
2285 domain: NativeOperatorDependencyDomain::HostToolchain,
2286 path: entry.resolved_path.clone(),
2287 sha256: entry.sha256.clone(),
2288 };
2289 for absolute in [&entry.logical_path, &entry.resolved_path] {
2290 insert_toolchain_dependency(
2291 &mut by_absolute_path,
2292 absolute.clone(),
2293 dependency.clone(),
2294 )?;
2295 }
2296 }
2297 Ok(NativeOperatorToolchainDependencyScope { by_absolute_path })
2298}
2299
2300fn insert_toolchain_dependency(
2301 dependencies: &mut BTreeMap<String, NativeOperatorObservedDependency>,
2302 absolute_path: String,
2303 dependency: NativeOperatorObservedDependency,
2304) -> Result<()> {
2305 validate_normalized_absolute_path(&absolute_path, "toolchain dependency path")?;
2306 if let Some(existing) = dependencies.get(&absolute_path) {
2307 if existing != &dependency {
2308 return Err(NativeOperatorBuilderError::Invalid(format!(
2309 "toolchain manifests ambiguously own dependency path: {absolute_path}"
2310 )));
2311 }
2312 return Ok(());
2313 }
2314 dependencies.insert(absolute_path, dependency);
2315 Ok(())
2316}
2317
2318fn expected_source_dependencies(
2319 translation_unit: &NativeOperatorSourceFileLock,
2320 closure: &NativeOperatorTranslationUnitDependencyLock,
2321) -> BTreeSet<NativeOperatorObservedDependency> {
2322 std::iter::once(translation_unit)
2323 .chain(closure.headers.iter())
2324 .map(|locked| NativeOperatorObservedDependency {
2325 domain: NativeOperatorDependencyDomain::Source,
2326 path: locked.path.clone(),
2327 sha256: locked.sha256.clone(),
2328 })
2329 .collect()
2330}
2331
2332fn validate_observed_dependency(
2333 context: &str,
2334 dependency: &NativeOperatorObservedDependency,
2335) -> Result<()> {
2336 let path_is_valid = match dependency.domain {
2337 NativeOperatorDependencyDomain::Source
2338 | NativeOperatorDependencyDomain::BackendToolchain => {
2339 validate_relative_path(&dependency.path).is_ok()
2340 }
2341 NativeOperatorDependencyDomain::HostToolchain => Path::new(&dependency.path).is_absolute(),
2342 };
2343 if !path_is_valid || !is_sha256_digest(&dependency.sha256) {
2344 return Err(NativeOperatorBuilderError::Invalid(format!(
2345 "{context} observed dependency identity is invalid: {:?}:{}",
2346 dependency.domain, dependency.path
2347 )));
2348 }
2349 Ok(())
2350}
2351
2352fn validate_observed_dependencies(
2353 context: &str,
2354 dependencies: &[NativeOperatorObservedDependency],
2355) -> Result<()> {
2356 if dependencies.is_empty()
2357 || dependencies.windows(2).any(|pair| pair[0] >= pair[1])
2358 || dependencies
2359 .iter()
2360 .any(|dependency| validate_observed_dependency(context, dependency).is_err())
2361 {
2362 return Err(NativeOperatorBuilderError::Invalid(format!(
2363 "{context} observed dependencies are empty, unsorted, duplicated, or invalid"
2364 )));
2365 }
2366 Ok(())
2367}
2368
2369fn observed_dependency_set_sha256(
2370 dependencies: &[NativeOperatorObservedDependency],
2371) -> Result<String> {
2372 validate_observed_dependencies("<dependency-set>", dependencies)?;
2373 let bytes =
2374 serde_json::to_vec(dependencies).map_err(|source| NativeOperatorBuilderError::Json {
2375 path: PathBuf::from("<dependency-set>"),
2376 source,
2377 })?;
2378 Ok(sha256_bytes(&bytes))
2379}
2380
2381fn validate_translation_unit_depfile(
2382 compiler_depfile_path: &Path,
2383 portable_depfile_path: &Path,
2384 object_path: &Path,
2385 source_root: &Path,
2386 translation_unit: &NativeOperatorSourceFileLock,
2387 closure: &NativeOperatorTranslationUnitDependencyLock,
2388 toolchain_scope: &NativeOperatorToolchainDependencyScope,
2389) -> Result<ValidatedTranslationUnitDepfile> {
2390 if closure.translation_unit != translation_unit.path {
2391 return Err(NativeOperatorBuilderError::Invalid(format!(
2392 "dependency closure translation unit mismatch: expected={} actual={}",
2393 translation_unit.path, closure.translation_unit
2394 )));
2395 }
2396 require_file(compiler_depfile_path)?;
2397 let raw =
2398 read_bounded_regular_file(compiler_depfile_path, MAX_DEPFILE_BYTES, "compiler depfile")?;
2399 let raw_text = std::str::from_utf8(&raw).map_err(|_| {
2400 NativeOperatorBuilderError::Invalid(format!(
2401 "compiler depfile is not valid UTF-8: {}",
2402 compiler_depfile_path.display()
2403 ))
2404 })?;
2405 let (target, dependencies) = parse_make_depfile(raw_text, compiler_depfile_path)?;
2406 let expected_target = object_path.display().to_string();
2407 validate_normalized_absolute_path(&expected_target, "compiler depfile object target")?;
2408 if target != expected_target {
2409 return Err(NativeOperatorBuilderError::Invalid(format!(
2410 "compiler depfile target differs from its exact -MT object: expected={expected_target} actual={target}"
2411 )));
2412 }
2413
2414 let mut observed = BTreeSet::new();
2415 let mut portable_paths = BTreeMap::new();
2416 let mut bindings = Vec::with_capacity(dependencies.len());
2417 for dependency in dependencies {
2418 let candidate = if Path::new(&dependency).is_absolute() {
2419 PathBuf::from(&dependency)
2420 } else {
2421 source_root.join(&dependency)
2422 };
2423 let canonical =
2424 candidate
2425 .canonicalize()
2426 .map_err(|source| NativeOperatorBuilderError::Io {
2427 path: candidate.clone(),
2428 source,
2429 })?;
2430 if !canonical.is_file() {
2431 return Err(NativeOperatorBuilderError::Invalid(format!(
2432 "compiler depfile dependency is not a file: {}",
2433 canonical.display()
2434 )));
2435 }
2436 let (identity, portable_path) = if canonical.starts_with(source_root) {
2437 let relative = canonical.strip_prefix(source_root).map_err(|_| {
2438 NativeOperatorBuilderError::Invalid(format!(
2439 "compiler depfile dependency escapes the source root: {}",
2440 canonical.display()
2441 ))
2442 })?;
2443 let identity = NativeOperatorObservedDependency {
2444 domain: NativeOperatorDependencyDomain::Source,
2445 path: path_with_forward_slashes(relative)?,
2446 sha256: sha256_file(&canonical)?,
2447 };
2448 let portable_path = identity.path.clone();
2449 (identity, portable_path)
2450 } else {
2451 let candidate_path = candidate.display().to_string();
2452 let canonical_path = canonical.display().to_string();
2453 let identity = toolchain_scope
2454 .by_absolute_path
2455 .get(&candidate_path)
2456 .or_else(|| toolchain_scope.by_absolute_path.get(&canonical_path))
2457 .ok_or_else(|| {
2458 NativeOperatorBuilderError::Invalid(format!(
2459 "compiler depfile dependency is outside the locked source and toolchain manifests: {}",
2460 canonical.display()
2461 ))
2462 })?
2463 .clone();
2464 let actual = sha256_file(&canonical)?;
2465 if actual != identity.sha256 {
2466 return Err(NativeOperatorBuilderError::Invalid(format!(
2467 "toolchain dependency changed while compiling: path={} expected={} actual={actual}",
2468 canonical.display(),
2469 identity.sha256
2470 )));
2471 }
2472 validate_normalized_absolute_path(
2473 &canonical_path,
2474 "compiled depfile canonical toolchain dependency",
2475 )?;
2476 (identity, canonical_path)
2477 };
2478 validate_observed_dependency("<compiled-depfile>", &identity)?;
2479 if !observed.insert(identity.clone()) {
2480 return Err(NativeOperatorBuilderError::Invalid(format!(
2481 "compiler depfile contains a duplicate dependency identity: {:?}:{}",
2482 identity.domain, identity.path
2483 )));
2484 }
2485 portable_paths.insert(identity.clone(), portable_path.clone());
2486 bindings.push(NativeOperatorDepfileDependencyBinding {
2487 producer_path: dependency,
2488 portable_path,
2489 dependency: identity,
2490 });
2491 }
2492
2493 let expected = expected_source_dependencies(translation_unit, closure);
2494 let observed_source = observed
2495 .iter()
2496 .filter(|dependency| dependency.domain == NativeOperatorDependencyDomain::Source)
2497 .cloned()
2498 .collect::<BTreeSet<_>>();
2499 if observed_source != expected {
2500 let missing = expected
2501 .difference(&observed_source)
2502 .cloned()
2503 .collect::<Vec<_>>();
2504 let undeclared = observed_source
2505 .difference(&expected)
2506 .cloned()
2507 .collect::<Vec<_>>();
2508 return Err(NativeOperatorBuilderError::Invalid(format!(
2509 "depfile differs from the declared source dependency closure: missing={missing:?} undeclared={undeclared:?}"
2510 )));
2511 }
2512
2513 for locked in std::iter::once(translation_unit).chain(closure.headers.iter()) {
2514 let path = locked_source_file(source_root, &locked.path)?;
2515 let actual = sha256_file(&path)?;
2516 if actual != locked.sha256 {
2517 return Err(NativeOperatorBuilderError::Invalid(format!(
2518 "dependency changed while compiling: path={} expected={} actual={actual}",
2519 locked.path, locked.sha256
2520 )));
2521 }
2522 }
2523 let observed_dependencies = observed.into_iter().collect::<Vec<_>>();
2524 let portable_dependencies = observed_dependencies
2525 .iter()
2526 .map(|identity| {
2527 portable_paths.get(identity).cloned().ok_or_else(|| {
2528 NativeOperatorBuilderError::Invalid(format!(
2529 "validated dependency has no portable depfile path: {:?}:{}",
2530 identity.domain, identity.path
2531 ))
2532 })
2533 })
2534 .collect::<Result<Vec<_>>>()?;
2535 let portable_raw =
2536 serialize_portable_depfile(&object_path.display().to_string(), &portable_dependencies)?;
2537 let portable_text = std::str::from_utf8(&portable_raw).expect("portable depfile is UTF-8");
2538 let verified_dependencies = validate_portable_depfile_pair(
2539 raw_text,
2540 compiler_depfile_path,
2541 portable_text,
2542 portable_depfile_path,
2543 &object_path.display().to_string(),
2544 &source_root.display().to_string(),
2545 translation_unit,
2546 closure,
2547 &bindings,
2548 toolchain_scope,
2549 )?;
2550 if verified_dependencies != observed_dependencies {
2551 return Err(NativeOperatorBuilderError::Invalid(
2552 "producer depfile verification changed the typed dependency set".to_string(),
2553 ));
2554 }
2555 atomic_write_bytes(portable_depfile_path, &portable_raw)?;
2556 Ok(ValidatedTranslationUnitDepfile {
2557 compiler_sha256: sha256_bytes(&raw),
2558 compiler_raw: raw,
2559 portable_sha256: sha256_bytes(&portable_raw),
2560 portable_raw,
2561 bindings,
2562 observed_dependencies,
2563 })
2564}
2565
2566struct ValidatedTranslationUnitDepfile {
2567 compiler_raw: Vec<u8>,
2568 compiler_sha256: String,
2569 portable_raw: Vec<u8>,
2570 portable_sha256: String,
2571 bindings: Vec<NativeOperatorDepfileDependencyBinding>,
2572 observed_dependencies: Vec<NativeOperatorObservedDependency>,
2573}
2574
2575fn serialize_portable_depfile(target: &str, dependencies: &[String]) -> Result<Vec<u8>> {
2576 validate_normalized_absolute_path(target, "portable depfile target")?;
2577 if dependencies.is_empty() || dependencies.len() > MAX_DEPFILE_DEPENDENCIES {
2578 return Err(NativeOperatorBuilderError::Invalid(
2579 "portable depfile dependency count is invalid".to_string(),
2580 ));
2581 }
2582 let mut result = escape_make_word(target)?;
2583 result.push(':');
2584 for dependency in dependencies {
2585 if Path::new(dependency).is_absolute() {
2586 validate_normalized_absolute_path(dependency, "portable depfile dependency")?;
2587 } else {
2588 validate_relative_path(dependency)?;
2589 }
2590 result.push(' ');
2591 result.push_str(&escape_make_word(dependency)?);
2592 }
2593 result.push('\n');
2594 if result.len() > MAX_DEPFILE_BYTES {
2595 return Err(NativeOperatorBuilderError::Invalid(format!(
2596 "portable depfile exceeds {MAX_DEPFILE_BYTES} bytes"
2597 )));
2598 }
2599 let (parsed_target, parsed_dependencies) =
2600 parse_make_depfile(&result, Path::new("<portable-depfile>"))?;
2601 if parsed_target != target || parsed_dependencies != dependencies {
2602 return Err(NativeOperatorBuilderError::Invalid(
2603 "portable depfile serialization did not round-trip".to_string(),
2604 ));
2605 }
2606 Ok(result.into_bytes())
2607}
2608
2609fn escape_make_word(value: &str) -> Result<String> {
2610 if value.is_empty()
2611 || value.len() > MAX_DEPFILE_WORD_BYTES
2612 || value
2613 .chars()
2614 .any(|character| matches!(character, '\0' | '\n' | '\r'))
2615 {
2616 return Err(NativeOperatorBuilderError::Invalid(
2617 "make depfile word must be non-empty and single-line".to_string(),
2618 ));
2619 }
2620 let mut result = String::with_capacity(value.len());
2621 for character in value.chars() {
2622 if matches!(character, '\\' | ' ' | '\t' | ':' | '#' | '$') {
2623 result.push('\\');
2624 }
2625 result.push(character);
2626 }
2627 Ok(result)
2628}
2629
2630fn publish_object_dependency_proof(
2631 cache_entry: &Path,
2632 object_cache_key: &str,
2633 object_sha256: &str,
2634 translation_unit: &NativeOperatorSourceFileLock,
2635 closure: &NativeOperatorTranslationUnitDependencyLock,
2636 compiler_depfile_raw: &[u8],
2637 expected_compiler_depfile_sha256: &str,
2638 depfile_raw: &[u8],
2639 expected_depfile_sha256: &str,
2640 producer_working_directory: &str,
2641 producer_object_file: &str,
2642 depfile_bindings: &[NativeOperatorDepfileDependencyBinding],
2643 observed_dependencies: &[NativeOperatorObservedDependency],
2644 toolchain_scope: &NativeOperatorToolchainDependencyScope,
2645) -> Result<()> {
2646 let compiler_depfile_sha256 = sha256_bytes(compiler_depfile_raw);
2647 if compiler_depfile_sha256 != expected_compiler_depfile_sha256 {
2648 return Err(NativeOperatorBuilderError::Invalid(format!(
2649 "compiler depfile bytes differ from their recorded sha256: expected={expected_compiler_depfile_sha256} actual={compiler_depfile_sha256}"
2650 )));
2651 }
2652 let depfile_sha256 = sha256_bytes(depfile_raw);
2653 if depfile_sha256 != expected_depfile_sha256 {
2654 return Err(NativeOperatorBuilderError::Invalid(format!(
2655 "validated depfile bytes differ from their recorded sha256: expected={expected_depfile_sha256} actual={depfile_sha256}"
2656 )));
2657 }
2658 let proof = NativeOperatorObjectDependencyProof {
2659 schema_version: NATIVE_OPERATOR_OBJECT_DEPENDENCY_PROOF_SCHEMA_VERSION,
2660 object_cache_key: object_cache_key.to_string(),
2661 object_sha256: object_sha256.to_string(),
2662 dependency_closure_sha256: closure.closure_sha256.clone(),
2663 dependency_set_sha256: observed_dependency_set_sha256(observed_dependencies)?,
2664 compiler_depfile_sha256,
2665 depfile_sha256: depfile_sha256.clone(),
2666 producer_working_directory: producer_working_directory.to_string(),
2667 producer_object_file: producer_object_file.to_string(),
2668 depfile_bindings: depfile_bindings.to_vec(),
2669 observed_dependencies: observed_dependencies.to_vec(),
2670 };
2671 validate_object_dependency_proof(
2672 "<object-cache-publish>",
2673 &proof,
2674 object_cache_key,
2675 object_sha256,
2676 translation_unit,
2677 closure,
2678 )?;
2679 let proof_dir = cache_entry.join("dependency-proof");
2680 if proof_dir.exists() {
2681 return validate_existing_dependency_proof(
2682 &proof_dir,
2683 object_cache_key,
2684 object_sha256,
2685 translation_unit,
2686 closure,
2687 producer_object_file,
2688 toolchain_scope,
2689 );
2690 }
2691
2692 let staging = tempfile::Builder::new()
2693 .prefix(".dependency-proof-")
2694 .tempdir_in(cache_entry)
2695 .map_err(|source| NativeOperatorBuilderError::Io {
2696 path: cache_entry.to_path_buf(),
2697 source,
2698 })?;
2699 let staging_path = staging.path().to_path_buf();
2700 atomic_write_bytes(
2701 &staging_path.join("compiler-dependency.raw.d"),
2702 compiler_depfile_raw,
2703 )?;
2704 atomic_write_bytes(&staging_path.join("dependency.d"), depfile_raw)?;
2705 write_json(&staging_path.join("proof.json"), &proof)?;
2706 sync_directory(&staging_path)?;
2707 let staging_path = staging.keep();
2708 match fs::rename(&staging_path, &proof_dir) {
2709 Ok(()) => {
2710 sync_directory(cache_entry)?;
2711 validate_existing_dependency_proof(
2712 &proof_dir,
2713 object_cache_key,
2714 object_sha256,
2715 translation_unit,
2716 closure,
2717 producer_object_file,
2718 toolchain_scope,
2719 )
2720 }
2721 Err(_source) if proof_dir.exists() => {
2722 fs::remove_dir_all(&staging_path).map_err(|cleanup_source| {
2723 NativeOperatorBuilderError::Io {
2724 path: staging_path.clone(),
2725 source: cleanup_source,
2726 }
2727 })?;
2728 validate_existing_dependency_proof(
2729 &proof_dir,
2730 object_cache_key,
2731 object_sha256,
2732 translation_unit,
2733 closure,
2734 producer_object_file,
2735 toolchain_scope,
2736 )
2737 }
2738 Err(source) => {
2739 let _ = fs::remove_dir_all(&staging_path);
2740 Err(NativeOperatorBuilderError::Io {
2741 path: proof_dir,
2742 source,
2743 })
2744 }
2745 }
2746}
2747
2748fn validate_dependency_proof_directory(proof_dir: &Path) -> Result<()> {
2749 let metadata =
2750 fs::symlink_metadata(proof_dir).map_err(|source| NativeOperatorBuilderError::Io {
2751 path: proof_dir.to_path_buf(),
2752 source,
2753 })?;
2754 if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() {
2755 return Err(NativeOperatorBuilderError::Invalid(format!(
2756 "object dependency proof is not a real directory: {}",
2757 proof_dir.display()
2758 )));
2759 }
2760 let mut entries = fs::read_dir(proof_dir)
2761 .map_err(|source| NativeOperatorBuilderError::Io {
2762 path: proof_dir.to_path_buf(),
2763 source,
2764 })?
2765 .map(|entry| {
2766 entry
2767 .map(|entry| entry.file_name())
2768 .map_err(|source| NativeOperatorBuilderError::Io {
2769 path: proof_dir.to_path_buf(),
2770 source,
2771 })
2772 })
2773 .collect::<Result<Vec<_>>>()?;
2774 entries.sort();
2775 let expected = [
2776 std::ffi::OsString::from("compiler-dependency.raw.d"),
2777 std::ffi::OsString::from("dependency.d"),
2778 std::ffi::OsString::from("proof.json"),
2779 ];
2780 if entries != expected {
2781 return Err(NativeOperatorBuilderError::Invalid(format!(
2782 "object dependency proof directory is incomplete or contains extra files: {}",
2783 proof_dir.display()
2784 )));
2785 }
2786 for name in expected {
2787 let path = proof_dir.join(name);
2788 let metadata =
2789 fs::symlink_metadata(&path).map_err(|source| NativeOperatorBuilderError::Io {
2790 path: path.clone(),
2791 source,
2792 })?;
2793 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2794 return Err(NativeOperatorBuilderError::Invalid(format!(
2795 "object dependency proof member is not a regular file: {}",
2796 path.display()
2797 )));
2798 }
2799 }
2800 Ok(())
2801}
2802
2803fn sync_directory(path: &Path) -> Result<()> {
2804 let directory = File::open(path).map_err(|source| NativeOperatorBuilderError::Io {
2805 path: path.to_path_buf(),
2806 source,
2807 })?;
2808 directory
2809 .sync_all()
2810 .map_err(|source| NativeOperatorBuilderError::Io {
2811 path: path.to_path_buf(),
2812 source,
2813 })
2814}
2815
2816struct ValidatedCachedDependencyProof {
2817 proof: NativeOperatorObjectDependencyProof,
2818 compiler_raw: Vec<u8>,
2819 portable_raw: Vec<u8>,
2820}
2821
2822#[allow(clippy::too_many_arguments)]
2823fn load_validated_dependency_proof(
2824 proof_dir: &Path,
2825 object_cache_key: &str,
2826 object_sha256: &str,
2827 translation_unit: &NativeOperatorSourceFileLock,
2828 closure: &NativeOperatorTranslationUnitDependencyLock,
2829 toolchain_scope: &NativeOperatorToolchainDependencyScope,
2830) -> Result<ValidatedCachedDependencyProof> {
2831 validate_dependency_proof_directory(proof_dir)?;
2832 let proof_path = proof_dir.join("proof.json");
2833 let proof_bytes = read_bounded_regular_file(
2834 &proof_path,
2835 MAX_DEPENDENCY_PROOF_BYTES,
2836 "object dependency proof",
2837 )?;
2838 let proof: NativeOperatorObjectDependencyProof =
2839 serde_json::from_slice(&proof_bytes).map_err(|source| {
2840 NativeOperatorBuilderError::Json {
2841 path: proof_path.clone(),
2842 source,
2843 }
2844 })?;
2845 validate_object_dependency_proof(
2846 "<object-cache-proof>",
2847 &proof,
2848 object_cache_key,
2849 object_sha256,
2850 translation_unit,
2851 closure,
2852 )?;
2853
2854 let compiler_depfile_path = proof_dir.join("compiler-dependency.raw.d");
2855 let compiler_raw = read_bounded_regular_file(
2856 &compiler_depfile_path,
2857 MAX_DEPFILE_BYTES,
2858 "cached compiler depfile",
2859 )?;
2860 if sha256_bytes(&compiler_raw) != proof.compiler_depfile_sha256 {
2861 return Err(NativeOperatorBuilderError::Invalid(format!(
2862 "cached object compiler depfile hash mismatch: {}",
2863 compiler_depfile_path.display()
2864 )));
2865 }
2866 let depfile_path = proof_dir.join("dependency.d");
2867 let portable_raw =
2868 read_bounded_regular_file(&depfile_path, MAX_DEPFILE_BYTES, "cached portable depfile")?;
2869 if sha256_bytes(&portable_raw) != proof.depfile_sha256 {
2870 return Err(NativeOperatorBuilderError::Invalid(format!(
2871 "cached object dependency depfile hash mismatch: {}",
2872 depfile_path.display()
2873 )));
2874 }
2875 let compiler_text = std::str::from_utf8(&compiler_raw).map_err(|_| {
2876 NativeOperatorBuilderError::Invalid(format!(
2877 "cached object compiler depfile is not UTF-8: {}",
2878 compiler_depfile_path.display()
2879 ))
2880 })?;
2881 let portable_text = std::str::from_utf8(&portable_raw).map_err(|_| {
2882 NativeOperatorBuilderError::Invalid(format!(
2883 "cached object dependency depfile is not UTF-8: {}",
2884 depfile_path.display()
2885 ))
2886 })?;
2887 let observed = validate_portable_depfile_pair(
2888 compiler_text,
2889 &compiler_depfile_path,
2890 portable_text,
2891 &depfile_path,
2892 &proof.producer_object_file,
2893 &proof.producer_working_directory,
2894 translation_unit,
2895 closure,
2896 &proof.depfile_bindings,
2897 toolchain_scope,
2898 )?;
2899 if observed != proof.observed_dependencies {
2900 return Err(NativeOperatorBuilderError::Invalid(format!(
2901 "cached object dependency proof observed list differs from depfile: {}",
2902 proof_path.display()
2903 )));
2904 }
2905 Ok(ValidatedCachedDependencyProof {
2906 proof,
2907 compiler_raw,
2908 portable_raw,
2909 })
2910}
2911
2912#[allow(clippy::too_many_arguments)]
2913fn validate_existing_dependency_proof(
2914 proof_dir: &Path,
2915 object_cache_key: &str,
2916 object_sha256: &str,
2917 translation_unit: &NativeOperatorSourceFileLock,
2918 closure: &NativeOperatorTranslationUnitDependencyLock,
2919 producer_object_file: &str,
2920 toolchain_scope: &NativeOperatorToolchainDependencyScope,
2921) -> Result<()> {
2922 let validated = load_validated_dependency_proof(
2923 proof_dir,
2924 object_cache_key,
2925 object_sha256,
2926 translation_unit,
2927 closure,
2928 toolchain_scope,
2929 )?;
2930 if Path::new(&validated.proof.producer_object_file).file_name()
2931 != Path::new(producer_object_file).file_name()
2932 {
2933 return Err(NativeOperatorBuilderError::Invalid(format!(
2934 "published dependency proof object name differs from the current object: {}",
2935 proof_dir.display()
2936 )));
2937 }
2938 Ok(())
2939}
2940
2941#[allow(clippy::too_many_arguments)]
2942fn restore_object_dependency_proof(
2943 cache_entry: &Path,
2944 object_cache_key: &str,
2945 object_sha256: &str,
2946 closure: &NativeOperatorTranslationUnitDependencyLock,
2947 translation_unit: &NativeOperatorSourceFileLock,
2948 object_path: &Path,
2949 output_compiler_depfile: &Path,
2950 output_depfile: &Path,
2951 toolchain_scope: &NativeOperatorToolchainDependencyScope,
2952) -> Result<Option<NativeOperatorObjectDependencyProof>> {
2953 let proof_dir = cache_entry.join("dependency-proof");
2954 if !proof_dir.exists() {
2955 return Ok(None);
2956 }
2957 let validated = load_validated_dependency_proof(
2958 &proof_dir,
2959 object_cache_key,
2960 object_sha256,
2961 translation_unit,
2962 closure,
2963 toolchain_scope,
2964 )?;
2965 if Path::new(&validated.proof.producer_object_file).file_name() != object_path.file_name() {
2966 return Err(NativeOperatorBuilderError::Invalid(format!(
2967 "cached dependency proof object name differs from restored object: {}",
2968 proof_dir.display()
2969 )));
2970 }
2971 atomic_write_bytes(output_compiler_depfile, &validated.compiler_raw)?;
2972 atomic_write_bytes(output_depfile, &validated.portable_raw)?;
2973 Ok(Some(validated.proof))
2974}
2975
2976fn validate_object_dependency_proof(
2977 context: &str,
2978 proof: &NativeOperatorObjectDependencyProof,
2979 object_cache_key: &str,
2980 object_sha256: &str,
2981 translation_unit: &NativeOperatorSourceFileLock,
2982 closure: &NativeOperatorTranslationUnitDependencyLock,
2983) -> Result<()> {
2984 let dependency_set_sha256 = observed_dependency_set_sha256(&proof.observed_dependencies)?;
2985 let binding_dependencies = validate_depfile_bindings_basic(context, &proof.depfile_bindings)?;
2986 let expected_source = expected_source_dependencies(translation_unit, closure);
2987 let observed_source = proof
2988 .observed_dependencies
2989 .iter()
2990 .filter(|dependency| dependency.domain == NativeOperatorDependencyDomain::Source)
2991 .cloned()
2992 .collect::<BTreeSet<_>>();
2993 validate_normalized_absolute_path(
2994 &proof.producer_working_directory,
2995 &format!("{context} producer_working_directory"),
2996 )?;
2997 validate_normalized_absolute_path(
2998 &proof.producer_object_file,
2999 &format!("{context} producer_object_file"),
3000 )?;
3001 if proof.schema_version != NATIVE_OPERATOR_OBJECT_DEPENDENCY_PROOF_SCHEMA_VERSION
3002 || proof.object_cache_key != object_cache_key
3003 || proof.object_sha256 != object_sha256
3004 || proof.dependency_closure_sha256 != closure.closure_sha256
3005 || proof.dependency_set_sha256 != dependency_set_sha256
3006 || !is_sha256_digest(&proof.object_cache_key)
3007 || !is_sha256_digest(&proof.object_sha256)
3008 || !is_sha256_digest(&proof.dependency_closure_sha256)
3009 || !is_sha256_digest(&proof.dependency_set_sha256)
3010 || !is_sha256_digest(&proof.compiler_depfile_sha256)
3011 || !is_sha256_digest(&proof.depfile_sha256)
3012 || binding_dependencies != proof.observed_dependencies
3013 || observed_source != expected_source
3014 {
3015 return Err(NativeOperatorBuilderError::Invalid(format!(
3016 "{context} object dependency proof is invalid"
3017 )));
3018 }
3019 Ok(())
3020}
3021
3022fn validate_depfile_bindings_basic(
3023 context: &str,
3024 bindings: &[NativeOperatorDepfileDependencyBinding],
3025) -> Result<Vec<NativeOperatorObservedDependency>> {
3026 if bindings.is_empty() || bindings.len() > MAX_DEPFILE_DEPENDENCIES {
3027 return Err(NativeOperatorBuilderError::Invalid(format!(
3028 "{context} depfile bindings are empty or exceed {MAX_DEPFILE_DEPENDENCIES}"
3029 )));
3030 }
3031 let mut dependencies = BTreeSet::new();
3032 for binding in bindings {
3033 if binding.producer_path.is_empty()
3034 || binding.producer_path.len() > MAX_DEPFILE_WORD_BYTES
3035 || binding
3036 .producer_path
3037 .chars()
3038 .any(|character| matches!(character, '\0' | '\n' | '\r'))
3039 || binding.portable_path.is_empty()
3040 || binding.portable_path.len() > MAX_DEPFILE_WORD_BYTES
3041 {
3042 return Err(NativeOperatorBuilderError::Invalid(format!(
3043 "{context} depfile binding path is invalid"
3044 )));
3045 }
3046 validate_observed_dependency(context, &binding.dependency)?;
3047 match binding.dependency.domain {
3048 NativeOperatorDependencyDomain::Source => {
3049 validate_relative_path(&binding.portable_path)?;
3050 if binding.portable_path != binding.dependency.path {
3051 return Err(NativeOperatorBuilderError::Invalid(format!(
3052 "{context} source depfile binding does not use its locked path"
3053 )));
3054 }
3055 }
3056 NativeOperatorDependencyDomain::BackendToolchain
3057 | NativeOperatorDependencyDomain::HostToolchain => {
3058 validate_normalized_absolute_path(
3059 &binding.portable_path,
3060 &format!("{context} toolchain depfile binding"),
3061 )?;
3062 }
3063 }
3064 if !dependencies.insert(binding.dependency.clone()) {
3065 return Err(NativeOperatorBuilderError::Invalid(format!(
3066 "{context} depfile bindings duplicate a typed dependency"
3067 )));
3068 }
3069 }
3070 Ok(dependencies.into_iter().collect())
3071}
3072
3073#[allow(clippy::too_many_arguments)]
3074fn validate_portable_depfile_pair(
3075 compiler_raw: &str,
3076 compiler_depfile_path: &Path,
3077 portable_raw: &str,
3078 depfile_path: &Path,
3079 object_path: &str,
3080 working_directory: &str,
3081 translation_unit: &NativeOperatorSourceFileLock,
3082 closure: &NativeOperatorTranslationUnitDependencyLock,
3083 bindings: &[NativeOperatorDepfileDependencyBinding],
3084 toolchain_scope: &NativeOperatorToolchainDependencyScope,
3085) -> Result<Vec<NativeOperatorObservedDependency>> {
3086 let (compiler_target, compiler_dependencies) =
3087 parse_make_depfile(compiler_raw, compiler_depfile_path)?;
3088 let (target, dependencies) = parse_make_depfile(portable_raw, depfile_path)?;
3089 validate_normalized_absolute_path(object_path, "portable depfile producer object")?;
3090 if compiler_target != object_path || target != object_path {
3091 return Err(NativeOperatorBuilderError::Invalid(format!(
3092 "compiler or portable depfile target differs from object file: depfile={} compiler_target={compiler_target} portable_target={target}",
3093 depfile_path.display()
3094 )));
3095 }
3096 validate_normalized_absolute_path(working_directory, "portable depfile working directory")?;
3097 let working_directory = Path::new(working_directory);
3098 let expected = expected_source_dependencies(translation_unit, closure);
3099 let observed = validate_depfile_bindings_basic("<portable-depfile>", bindings)?;
3100 let expected_compiler_dependencies = bindings
3101 .iter()
3102 .map(|binding| binding.producer_path.as_str())
3103 .collect::<Vec<_>>();
3104 if compiler_dependencies
3105 != expected_compiler_dependencies
3106 .iter()
3107 .map(|value| (*value).to_string())
3108 .collect::<Vec<_>>()
3109 {
3110 return Err(NativeOperatorBuilderError::Invalid(
3111 "compiler depfile bytes differ from their ordered typed bindings".to_string(),
3112 ));
3113 }
3114 let portable_by_dependency = bindings
3115 .iter()
3116 .map(|binding| (binding.dependency.clone(), binding.portable_path.clone()))
3117 .collect::<BTreeMap<_, _>>();
3118 let expected_portable_dependencies = observed
3119 .iter()
3120 .map(|dependency| {
3121 portable_by_dependency
3122 .get(dependency)
3123 .cloned()
3124 .expect("binding dependencies were collected from the same rows")
3125 })
3126 .collect::<Vec<_>>();
3127 let canonical_portable =
3128 serialize_portable_depfile(object_path, &expected_portable_dependencies)?;
3129 if dependencies != expected_portable_dependencies
3130 || portable_raw.as_bytes() != canonical_portable.as_slice()
3131 {
3132 return Err(NativeOperatorBuilderError::Invalid(
3133 "portable depfile bytes differ from their canonical typed bindings".to_string(),
3134 ));
3135 }
3136 for binding in bindings {
3137 match binding.dependency.domain {
3138 NativeOperatorDependencyDomain::Source => {
3139 let producer = Path::new(&binding.producer_path);
3140 let relative = if producer.is_absolute() {
3141 producer.strip_prefix(working_directory).map_err(|_| {
3142 NativeOperatorBuilderError::Invalid(format!(
3143 "source compiler depfile path escapes its recorded working directory: {}",
3144 binding.producer_path
3145 ))
3146 })?
3147 } else {
3148 producer
3149 };
3150 if normalize_portable_relative_path(relative)? != binding.dependency.path {
3151 return Err(NativeOperatorBuilderError::Invalid(format!(
3152 "source compiler depfile path differs from its locked identity: {}",
3153 binding.producer_path
3154 )));
3155 }
3156 }
3157 NativeOperatorDependencyDomain::BackendToolchain
3158 | NativeOperatorDependencyDomain::HostToolchain => {
3159 let normalized_producer = normalize_absolute_posix_path_lexically(
3160 &binding.producer_path,
3161 "toolchain compiler depfile binding",
3162 )?;
3163 if toolchain_scope.by_absolute_path.get(&normalized_producer)
3164 != Some(&binding.dependency)
3165 || toolchain_scope.by_absolute_path.get(&binding.portable_path)
3166 != Some(&binding.dependency)
3167 {
3168 return Err(NativeOperatorBuilderError::Invalid(format!(
3169 "toolchain depfile binding is outside its typed manifest: producer={} portable={}",
3170 binding.producer_path, binding.portable_path
3171 )));
3172 }
3173 }
3174 }
3175 }
3176 let observed_source = observed
3177 .iter()
3178 .filter(|dependency| dependency.domain == NativeOperatorDependencyDomain::Source)
3179 .cloned()
3180 .collect::<BTreeSet<_>>();
3181 if observed_source != expected {
3182 return Err(NativeOperatorBuilderError::Invalid(format!(
3183 "portable depfile differs from locked source dependency closure: expected={expected:?} observed={observed_source:?}"
3184 )));
3185 }
3186 validate_observed_dependencies("<portable-depfile>", &observed)?;
3187 for dependency in &observed {
3188 if !bindings
3189 .iter()
3190 .any(|binding| &binding.dependency == dependency)
3191 {
3192 return Err(NativeOperatorBuilderError::Invalid(format!(
3193 "portable depfile is missing a binding for {:?}:{}",
3194 dependency.domain, dependency.path
3195 )));
3196 }
3197 }
3198 Ok(observed)
3199}
3200
3201fn normalize_absolute_posix_path_lexically(value: &str, label: &str) -> Result<String> {
3202 if !value.starts_with('/')
3203 || value.contains('\\')
3204 || value
3205 .chars()
3206 .any(|character| matches!(character, '\0' | '\n' | '\r'))
3207 {
3208 return Err(NativeOperatorBuilderError::Invalid(format!(
3209 "{label} must be an absolute POSIX path: {value}"
3210 )));
3211 }
3212 let mut components = Vec::new();
3213 for component in value.split('/') {
3214 match component {
3215 "" | "." => {}
3216 ".." => {
3217 if components.pop().is_none() {
3218 return Err(NativeOperatorBuilderError::Invalid(format!(
3219 "{label} escapes the filesystem root: {value}"
3220 )));
3221 }
3222 }
3223 _ => components.push(component),
3224 }
3225 }
3226 if components.is_empty() {
3227 Ok("/".to_string())
3228 } else {
3229 Ok(format!("/{}", components.join("/")))
3230 }
3231}
3232
3233fn validate_normalized_absolute_path(value: &str, label: &str) -> Result<()> {
3234 if value == "/" {
3235 return Ok(());
3236 }
3237 if !value.starts_with('/')
3238 || value.contains('\\')
3239 || value.ends_with('/')
3240 || value[1..]
3241 .split('/')
3242 .any(|component| component.is_empty() || component == "." || component == "..")
3243 {
3244 return Err(NativeOperatorBuilderError::Invalid(format!(
3245 "{label} must be a normalized absolute POSIX path: {value}"
3246 )));
3247 }
3248 Ok(())
3249}
3250
3251fn normalize_portable_relative_path(path: &Path) -> Result<String> {
3252 let raw = path.to_str().ok_or_else(|| {
3253 NativeOperatorBuilderError::Invalid(format!(
3254 "depfile path is not valid UTF-8: {}",
3255 path.display()
3256 ))
3257 })?;
3258 if raw.contains('\\')
3259 || raw
3260 .chars()
3261 .any(|character| matches!(character, '\0' | '\n' | '\r'))
3262 {
3263 return Err(NativeOperatorBuilderError::Invalid(format!(
3264 "depfile path is not a relative POSIX path: {}",
3265 path.display()
3266 )));
3267 }
3268 let mut components = Vec::new();
3269 for component in path.components() {
3270 match component {
3271 std::path::Component::Normal(value) => {
3272 components.push(value.to_str().ok_or_else(|| {
3273 NativeOperatorBuilderError::Invalid(format!(
3274 "depfile path is not valid UTF-8: {}",
3275 path.display()
3276 ))
3277 })?)
3278 }
3279 std::path::Component::CurDir => {}
3280 std::path::Component::ParentDir => {
3281 if components.pop().is_none() {
3282 return Err(NativeOperatorBuilderError::Invalid(format!(
3283 "depfile path escapes its working directory: {}",
3284 path.display()
3285 )));
3286 }
3287 }
3288 _ => {
3289 return Err(NativeOperatorBuilderError::Invalid(format!(
3290 "depfile path is not normalized beneath its working directory: {}",
3291 path.display()
3292 )))
3293 }
3294 }
3295 }
3296 let normalized = components.join("/");
3297 validate_relative_path(&normalized)?;
3298 Ok(normalized)
3299}
3300
3301fn read_bounded_regular_file(path: &Path, max_bytes: usize, label: &str) -> Result<Vec<u8>> {
3302 let mut options = OpenOptions::new();
3303 options.read(true);
3304 #[cfg(unix)]
3305 options.custom_flags(libc::O_NOFOLLOW);
3306 let file = options
3307 .open(path)
3308 .map_err(|source| NativeOperatorBuilderError::Io {
3309 path: path.to_path_buf(),
3310 source,
3311 })?;
3312 let metadata = file
3313 .metadata()
3314 .map_err(|source| NativeOperatorBuilderError::Io {
3315 path: path.to_path_buf(),
3316 source,
3317 })?;
3318 if !metadata.is_file() || metadata.len() > max_bytes as u64 {
3319 return Err(NativeOperatorBuilderError::Invalid(format!(
3320 "{label} is not a regular file or exceeds {max_bytes} bytes: {}",
3321 path.display()
3322 )));
3323 }
3324 let mut bytes = Vec::with_capacity(metadata.len() as usize);
3325 file.take(max_bytes as u64 + 1)
3326 .read_to_end(&mut bytes)
3327 .map_err(|source| NativeOperatorBuilderError::Io {
3328 path: path.to_path_buf(),
3329 source,
3330 })?;
3331 if bytes.len() > max_bytes {
3332 return Err(NativeOperatorBuilderError::Invalid(format!(
3333 "{label} grew beyond {max_bytes} bytes while reading: {}",
3334 path.display()
3335 )));
3336 }
3337 Ok(bytes)
3338}
3339
3340fn atomic_write_bytes(destination: &Path, bytes: &[u8]) -> Result<()> {
3341 let parent = destination.parent().ok_or_else(|| {
3342 NativeOperatorBuilderError::Invalid(format!(
3343 "atomic write destination has no parent: {}",
3344 destination.display()
3345 ))
3346 })?;
3347 fs::create_dir_all(parent).map_err(|source| NativeOperatorBuilderError::Io {
3348 path: parent.to_path_buf(),
3349 source,
3350 })?;
3351 let mut temporary =
3352 NamedTempFile::new_in(parent).map_err(|source| NativeOperatorBuilderError::Io {
3353 path: parent.to_path_buf(),
3354 source,
3355 })?;
3356 temporary
3357 .write_all(bytes)
3358 .map_err(|source| NativeOperatorBuilderError::Io {
3359 path: temporary.path().to_path_buf(),
3360 source,
3361 })?;
3362 temporary
3363 .as_file_mut()
3364 .flush()
3365 .and_then(|()| temporary.as_file().sync_all())
3366 .map_err(|source| NativeOperatorBuilderError::Io {
3367 path: temporary.path().to_path_buf(),
3368 source,
3369 })?;
3370 temporary
3371 .persist(destination)
3372 .map_err(|error| NativeOperatorBuilderError::Io {
3373 path: destination.to_path_buf(),
3374 source: error.error,
3375 })?;
3376 Ok(())
3377}
3378
3379fn parse_make_depfile(raw: &str, path: &Path) -> Result<(String, Vec<String>)> {
3380 if raw.len() > MAX_DEPFILE_BYTES || raw.trim().is_empty() || raw.as_bytes().contains(&0) {
3381 return Err(NativeOperatorBuilderError::Invalid(format!(
3382 "depfile is too large, empty, or contains NUL: {}",
3383 path.display()
3384 )));
3385 }
3386 let normalized = raw.replace("\\\r\n", "").replace("\\\n", "");
3387 let normalized = normalized.trim_end_matches(&['\r', '\n'][..]);
3388 if normalized.contains('\n') || normalized.contains('\r') {
3389 return Err(NativeOperatorBuilderError::Invalid(format!(
3390 "depfile contains more than one make rule: {}",
3391 path.display()
3392 )));
3393 }
3394 let mut escaped = false;
3395 let mut delimiter = None;
3396 for (index, character) in normalized.char_indices() {
3397 if escaped {
3398 escaped = false;
3399 continue;
3400 }
3401 match character {
3402 '\\' => escaped = true,
3403 ':' => {
3404 delimiter = Some(index);
3405 break;
3406 }
3407 '\n' | '\r' => {
3408 return Err(NativeOperatorBuilderError::Invalid(format!(
3409 "depfile contains multiple or unterminated rules: {}",
3410 path.display()
3411 )))
3412 }
3413 _ => {}
3414 }
3415 }
3416 let delimiter = delimiter.ok_or_else(|| {
3417 NativeOperatorBuilderError::Invalid(format!(
3418 "depfile has no make target delimiter: {}",
3419 path.display()
3420 ))
3421 })?;
3422 let targets = parse_make_words(&normalized[..delimiter], path, 1)?;
3423 let dependencies =
3424 parse_make_words(&normalized[delimiter + 1..], path, MAX_DEPFILE_DEPENDENCIES)?;
3425 if targets.len() != 1 || dependencies.is_empty() {
3426 return Err(NativeOperatorBuilderError::Invalid(format!(
3427 "depfile target/dependency count or word size is invalid: {}",
3428 path.display()
3429 )));
3430 }
3431 Ok((targets[0].clone(), dependencies))
3432}
3433
3434fn parse_make_words(value: &str, path: &Path, max_words: usize) -> Result<Vec<String>> {
3435 let mut words = Vec::new();
3436 let mut word = String::new();
3437 let mut escaped = false;
3438 for character in value.chars() {
3439 if escaped {
3440 word.push(character);
3441 if word.len() > MAX_DEPFILE_WORD_BYTES {
3442 return Err(NativeOperatorBuilderError::Invalid(format!(
3443 "depfile word exceeds {MAX_DEPFILE_WORD_BYTES} bytes: {}",
3444 path.display()
3445 )));
3446 }
3447 escaped = false;
3448 continue;
3449 }
3450 match character {
3451 '\\' => escaped = true,
3452 '\n' | '\r' => unreachable!("newlines rejected before make-word parsing"),
3453 character if character.is_whitespace() => {
3454 if !word.is_empty() {
3455 if words.len() >= max_words {
3456 return Err(NativeOperatorBuilderError::Invalid(format!(
3457 "depfile exceeds its word-count limit: {}",
3458 path.display()
3459 )));
3460 }
3461 words.push(std::mem::take(&mut word));
3462 }
3463 }
3464 _ => {
3465 word.push(character);
3466 if word.len() > MAX_DEPFILE_WORD_BYTES {
3467 return Err(NativeOperatorBuilderError::Invalid(format!(
3468 "depfile word exceeds {MAX_DEPFILE_WORD_BYTES} bytes: {}",
3469 path.display()
3470 )));
3471 }
3472 }
3473 }
3474 }
3475 if escaped {
3476 return Err(NativeOperatorBuilderError::Invalid(format!(
3477 "depfile ends with an incomplete escape: {}",
3478 path.display()
3479 )));
3480 }
3481 if !word.is_empty() {
3482 if words.len() >= max_words {
3483 return Err(NativeOperatorBuilderError::Invalid(format!(
3484 "depfile exceeds its word-count limit: {}",
3485 path.display()
3486 )));
3487 }
3488 words.push(word);
3489 }
3490 Ok(words)
3491}
3492
3493fn locked_source_file(root: &Path, relative: &str) -> Result<PathBuf> {
3494 validate_relative_path(relative)?;
3495 let path = root.join(relative);
3496 let metadata =
3497 fs::symlink_metadata(&path).map_err(|source| NativeOperatorBuilderError::Io {
3498 path: path.clone(),
3499 source,
3500 })?;
3501 if metadata.file_type().is_symlink() {
3502 return Err(NativeOperatorBuilderError::Invalid(format!(
3503 "locked source files must not be symlinks: {relative}"
3504 )));
3505 }
3506 let canonical = path
3507 .canonicalize()
3508 .map_err(|source| NativeOperatorBuilderError::Io {
3509 path: path.clone(),
3510 source,
3511 })?;
3512 if !canonical.starts_with(root) || !canonical.is_file() {
3513 return Err(NativeOperatorBuilderError::Invalid(format!(
3514 "locked source file escapes source root or is not a file: {relative}"
3515 )));
3516 }
3517 Ok(canonical)
3518}
3519
3520fn resolve_static_toolchain(
3521 request: &NativeOperatorSourceBuildRequest,
3522) -> Result<NativeOperatorSourceBuildToolchain> {
3523 let invocation_root_path = if request.cuda_toolkit_root.is_absolute() {
3524 request.cuda_toolkit_root.clone()
3525 } else {
3526 std::env::current_dir()
3527 .map_err(|source| NativeOperatorBuilderError::Io {
3528 path: PathBuf::from("."),
3529 source,
3530 })?
3531 .join(&request.cuda_toolkit_root)
3532 };
3533 let invocation_root = invocation_root_path.to_str().ok_or_else(|| {
3534 NativeOperatorBuilderError::Invalid(format!(
3535 "cuda_toolkit_root is not valid UTF-8: {}",
3536 invocation_root_path.display()
3537 ))
3538 })?;
3539 validate_normalized_absolute_path(invocation_root, "cuda_toolkit_root")?;
3540 let canonical_root = request.cuda_toolkit_root.canonicalize().map_err(|source| {
3541 NativeOperatorBuilderError::Io {
3542 path: request.cuda_toolkit_root.clone(),
3543 source,
3544 }
3545 })?;
3546 if !canonical_root.is_dir() {
3547 return Err(NativeOperatorBuilderError::Invalid(format!(
3548 "cuda_toolkit_root is not a directory: {}",
3549 canonical_root.display()
3550 )));
3551 }
3552 let canonical_root_string = canonical_root.to_str().ok_or_else(|| {
3553 NativeOperatorBuilderError::Invalid(format!(
3554 "canonical cuda_toolkit_root is not valid UTF-8: {}",
3555 canonical_root.display()
3556 ))
3557 })?;
3558 validate_normalized_absolute_path(canonical_root_string, "canonical cuda_toolkit_root")?;
3559 let nvcc = tool_file_identity(&request.nvcc_path)?;
3560 let canonical_nvcc = Path::new(&nvcc.path);
3561 if !canonical_nvcc.starts_with(&canonical_root) {
3562 return Err(NativeOperatorBuilderError::Invalid(format!(
3563 "nvcc must resolve inside cuda_toolkit_root: nvcc={} root={}",
3564 canonical_nvcc.display(),
3565 canonical_root.display()
3566 )));
3567 }
3568 let manifest = build_cuda_toolkit_manifest(&canonical_root)?;
3569 if !manifest.entries.iter().any(|entry| {
3570 canonical_root.join(&entry.resolved_path) == canonical_nvcc
3571 && entry.sha256 == nvcc.sha256
3572 && entry.size_bytes == nvcc.size_bytes
3573 }) {
3574 return Err(NativeOperatorBuilderError::Invalid(format!(
3575 "cuda toolkit manifest does not contain the selected nvcc: {}",
3576 canonical_nvcc.display()
3577 )));
3578 }
3579 let manifest_relative = "toolchain/cuda-static-manifest.json";
3580 let manifest_path = request.output_dir.join(manifest_relative);
3581 let manifest_parent = manifest_path
3582 .parent()
3583 .expect("cuda toolkit manifest has a parent directory");
3584 fs::create_dir_all(manifest_parent).map_err(|source| NativeOperatorBuilderError::Io {
3585 path: manifest_parent.to_path_buf(),
3586 source,
3587 })?;
3588 write_json(&manifest_path, &manifest)?;
3589 let manifest_size = fs::metadata(&manifest_path)
3590 .map_err(|source| NativeOperatorBuilderError::Io {
3591 path: manifest_path.clone(),
3592 source,
3593 })?
3594 .len();
3595 let manifest_evidence = NativeOperatorEvidenceFile {
3596 path: manifest_relative.to_string(),
3597 sha256: sha256_file(&manifest_path)?,
3598 size_bytes: manifest_size,
3599 };
3600 let host_toolchain = resolve_host_toolchain(request)?;
3601 Ok(NativeOperatorSourceBuildToolchain {
3602 static_identity: NativeOperatorSourceBuildStaticToolchain {
3603 backend: NativeOperatorBackend::Cuda,
3604 compiler_driver: NativeOperatorSourceCompilerDriver::CudaNvcc,
3605 cuda_toolkit: NativeOperatorCudaToolkitIdentity {
3606 canonical_root: canonical_root_string.to_string(),
3607 invocation_root: invocation_root.to_string(),
3608 release_version: cuda_toolkit_release_version(&canonical_root)?,
3609 nvcc,
3610 manifest: manifest_evidence,
3611 },
3612 host_toolchain,
3613 archiver: tool_file_identity(&request.ar_path)?,
3614 },
3615 miss_probe: None,
3616 })
3617}
3618
3619fn resolve_host_toolchain(
3620 request: &NativeOperatorSourceBuildRequest,
3621) -> Result<NativeOperatorHostToolchainIdentity> {
3622 let compiler = tool_file_identity(&request.ccbin_path)?;
3623 let cache_key = sha256_bytes(format!("{}\n{}\n", compiler.path, compiler.sha256).as_bytes());
3624 let cache_dir = request
3625 .object_cache_dir
3626 .join(".host-toolchains")
3627 .join(cache_key);
3628 fs::create_dir_all(&cache_dir).map_err(|source| NativeOperatorBuilderError::Io {
3629 path: cache_dir.clone(),
3630 source,
3631 })?;
3632 let cached_path = cache_dir.join("manifest.json");
3633 let environment = effective_environment_for_tool_paths([
3634 request.nvcc_path.to_str().unwrap_or(""),
3635 compiler.path.as_str(),
3636 request.ar_path.to_str().unwrap_or(""),
3637 ])?;
3638 let cached = cached_path
3639 .is_file()
3640 .then(|| read_json::<NativeOperatorHostToolchainManifest>(&cached_path))
3641 .transpose()
3642 .ok()
3643 .flatten()
3644 .filter(|manifest| {
3645 validate_host_toolchain_manifest("<host-toolchain-cache>", manifest).is_ok()
3646 });
3647 let manifest = match cached {
3648 Some(cached)
3649 if host_toolchain_manifest_matches_current(&cached, &compiler, &environment)? =>
3650 {
3651 cached
3652 }
3653 _ => {
3654 let probed = probe_host_toolchain_manifest(&compiler, &environment)?;
3655 write_json(&cached_path, &probed)?;
3656 probed
3657 }
3658 };
3659
3660 let relative = "toolchain/host-static-manifest.json";
3661 let output_path = request.output_dir.join(relative);
3662 write_json(&output_path, &manifest)?;
3663 let size_bytes = fs::metadata(&output_path)
3664 .map_err(|source| NativeOperatorBuilderError::Io {
3665 path: output_path.clone(),
3666 source,
3667 })?
3668 .len();
3669 Ok(NativeOperatorHostToolchainIdentity {
3670 compiler: manifest.compiler.clone(),
3671 compiler_version: manifest.compiler_version.clone(),
3672 target: manifest.target.clone(),
3673 manifest: NativeOperatorEvidenceFile {
3674 path: relative.to_string(),
3675 sha256: sha256_file(&output_path)?,
3676 size_bytes,
3677 },
3678 })
3679}
3680
3681fn probe_host_toolchain_manifest(
3682 compiler: &NativeOperatorToolFileIdentity,
3683 environment: &BTreeMap<String, String>,
3684) -> Result<NativeOperatorHostToolchainManifest> {
3685 let compiler_path = Path::new(&compiler.path);
3686 let compiler_version = host_compiler_output(compiler_path, &["--version"], b"", environment)?;
3687 let target = host_compiler_output(compiler_path, &["-dumpmachine"], b"", environment)?;
3688 if target.is_empty() || target.len() > 256 || target.chars().any(char::is_whitespace) {
3689 return Err(NativeOperatorBuilderError::Invalid(format!(
3690 "host compiler produced an invalid target: {}",
3691 compiler.path
3692 )));
3693 }
3694
3695 let discovery_probe = probe_host_compiler_discovery(compiler_path, environment)?;
3696
3697 let mut executable_inputs = BTreeMap::new();
3698 executable_inputs.insert(compiler.path.clone(), compiler.clone());
3699 for program in HOST_TOOLCHAIN_PROGRAMS {
3700 let value = host_compiler_output(
3701 compiler_path,
3702 &[&format!("-print-prog-name={program}")],
3703 b"",
3704 environment,
3705 )?;
3706 if let Some(path) = resolve_host_program(&value, environment)? {
3707 let identity = tool_file_identity(&path)?;
3708 executable_inputs.insert(identity.path.clone(), identity);
3709 }
3710 }
3711
3712 let mut discovery_roots = executable_inputs
3713 .values()
3714 .filter_map(|identity| Path::new(&identity.path).parent())
3715 .map(|path| path.display().to_string())
3716 .collect::<Vec<_>>();
3717 discovery_roots.sort();
3718 discovery_roots.dedup();
3719 let scope_roots = discovery_probe
3720 .include_roots
3721 .iter()
3722 .chain(discovery_roots.iter())
3723 .cloned()
3724 .collect::<Vec<_>>();
3725 let files = collect_host_toolchain_scope_files(&scope_roots)?;
3726 let manifest = NativeOperatorHostToolchainManifest {
3727 schema_version: NATIVE_OPERATOR_HOST_TOOLCHAIN_MANIFEST_SCHEMA_VERSION,
3728 compiler: compiler.clone(),
3729 compiler_version,
3730 target,
3731 executable_inputs: executable_inputs.into_values().collect(),
3732 include_roots: discovery_probe.include_roots,
3733 include_probe_sha256: discovery_probe.include_probe_sha256,
3734 driver_probe_sha256: discovery_probe.driver_probe_sha256,
3735 discovery_roots,
3736 files,
3737 };
3738 validate_host_toolchain_manifest("<host-toolchain-probe>", &manifest)?;
3739 Ok(manifest)
3740}
3741
3742fn rebuild_host_toolchain_manifest(
3743 recorded: &NativeOperatorHostToolchainManifest,
3744) -> Result<NativeOperatorHostToolchainManifest> {
3745 validate_host_toolchain_manifest("<host-toolchain-recorded>", recorded)?;
3746 let compiler = tool_file_identity(Path::new(&recorded.compiler.path))?;
3747 let executable_inputs = recorded
3748 .executable_inputs
3749 .iter()
3750 .map(|identity| tool_file_identity(Path::new(&identity.path)))
3751 .collect::<Result<Vec<_>>>()?;
3752 let scope_roots = recorded
3753 .include_roots
3754 .iter()
3755 .chain(recorded.discovery_roots.iter())
3756 .cloned()
3757 .collect::<Vec<_>>();
3758 let files = collect_host_toolchain_scope_files(&scope_roots)?;
3759 let current = NativeOperatorHostToolchainManifest {
3760 schema_version: NATIVE_OPERATOR_HOST_TOOLCHAIN_MANIFEST_SCHEMA_VERSION,
3761 compiler,
3762 compiler_version: recorded.compiler_version.clone(),
3763 target: recorded.target.clone(),
3764 executable_inputs,
3765 include_roots: recorded.include_roots.clone(),
3766 include_probe_sha256: recorded.include_probe_sha256.clone(),
3767 driver_probe_sha256: recorded.driver_probe_sha256.clone(),
3768 discovery_roots: recorded.discovery_roots.clone(),
3769 files,
3770 };
3771 validate_host_toolchain_manifest("<host-toolchain-current>", ¤t)?;
3772 Ok(current)
3773}
3774
3775struct HostCompilerDiscoveryProbe {
3776 include_roots: Vec<String>,
3777 include_probe_sha256: String,
3778 driver_probe_sha256: String,
3779}
3780
3781fn probe_host_compiler_discovery(
3782 compiler: &Path,
3783 environment: &BTreeMap<String, String>,
3784) -> Result<HostCompilerDiscoveryProbe> {
3785 let include_probe = host_compiler_raw_output(
3786 compiler,
3787 &["-E", "-x", "c++", "-", "-v"],
3788 b"\n",
3789 environment,
3790 )?;
3791 let include_roots = parse_host_compiler_include_roots(
3792 &String::from_utf8_lossy(&include_probe.stderr),
3793 compiler,
3794 )?;
3795 let driver_probe = host_compiler_raw_output(
3796 compiler,
3797 &["-###", "-pipe", "-x", "c++", "-c", "-", "-o", "/dev/null"],
3798 b"\n",
3799 environment,
3800 )?;
3801 Ok(HostCompilerDiscoveryProbe {
3802 include_roots,
3803 include_probe_sha256: compiler_probe_sha256(&include_probe),
3804 driver_probe_sha256: compiler_probe_sha256(&driver_probe),
3805 })
3806}
3807
3808fn compiler_probe_sha256(output: &std::process::Output) -> String {
3809 let mut identity = Vec::with_capacity(output.stdout.len() + output.stderr.len() + 16);
3810 identity.extend_from_slice(&(output.stdout.len() as u64).to_le_bytes());
3811 identity.extend_from_slice(&output.stdout);
3812 identity.extend_from_slice(&(output.stderr.len() as u64).to_le_bytes());
3813 identity.extend_from_slice(&output.stderr);
3814 sha256_bytes(&identity)
3815}
3816
3817fn host_toolchain_manifest_matches_current(
3818 recorded: &NativeOperatorHostToolchainManifest,
3819 compiler: &NativeOperatorToolFileIdentity,
3820 environment: &BTreeMap<String, String>,
3821) -> Result<bool> {
3822 if &recorded.compiler != compiler || rebuild_host_toolchain_manifest(recorded)? != *recorded {
3823 return Ok(false);
3824 }
3825 let discovery = probe_host_compiler_discovery(Path::new(&compiler.path), environment)?;
3826 Ok(discovery.include_roots == recorded.include_roots
3827 && discovery.include_probe_sha256 == recorded.include_probe_sha256
3828 && discovery.driver_probe_sha256 == recorded.driver_probe_sha256)
3829}
3830
3831fn host_compiler_output(
3832 compiler: &Path,
3833 args: &[&str],
3834 stdin: &[u8],
3835 environment: &BTreeMap<String, String>,
3836) -> Result<String> {
3837 let output = host_compiler_raw_output(compiler, args, stdin, environment)?;
3838 let value = format!(
3839 "{}{}",
3840 String::from_utf8_lossy(&output.stdout),
3841 String::from_utf8_lossy(&output.stderr)
3842 )
3843 .trim()
3844 .chars()
3845 .take(16_384)
3846 .collect::<String>();
3847 if value.is_empty() {
3848 return Err(NativeOperatorBuilderError::Invalid(format!(
3849 "host compiler produced no output for {:?}: {}",
3850 args,
3851 compiler.display()
3852 )));
3853 }
3854 Ok(value)
3855}
3856
3857fn host_compiler_raw_output(
3858 compiler: &Path,
3859 args: &[&str],
3860 stdin: &[u8],
3861 environment: &BTreeMap<String, String>,
3862) -> Result<std::process::Output> {
3863 let mut child = Command::new(compiler)
3864 .args(args)
3865 .env_clear()
3866 .envs(environment)
3867 .stdin(Stdio::piped())
3868 .stdout(Stdio::piped())
3869 .stderr(Stdio::piped())
3870 .spawn()
3871 .map_err(|source| NativeOperatorBuilderError::Io {
3872 path: compiler.to_path_buf(),
3873 source,
3874 })?;
3875 let stdin_result = {
3876 let mut child_stdin = child.stdin.take().expect("host compiler stdin is piped");
3877 let result = child_stdin.write_all(stdin);
3878 drop(child_stdin);
3879 result
3880 };
3881 let output = child
3882 .wait_with_output()
3883 .map_err(|source| NativeOperatorBuilderError::Io {
3884 path: compiler.to_path_buf(),
3885 source,
3886 })?;
3887 if let Err(source) = stdin_result {
3888 if source.kind() != std::io::ErrorKind::BrokenPipe {
3893 return Err(NativeOperatorBuilderError::Io {
3894 path: compiler.to_path_buf(),
3895 source,
3896 });
3897 }
3898 }
3899 if !output.status.success() {
3900 return Err(NativeOperatorBuilderError::Invalid(format!(
3901 "host compiler probe failed for {:?}: path={} status={}",
3902 args,
3903 compiler.display(),
3904 output.status
3905 )));
3906 }
3907 Ok(output)
3908}
3909
3910fn parse_host_compiler_include_roots(stderr: &str, compiler: &Path) -> Result<Vec<String>> {
3911 let mut in_search_list = false;
3912 let mut roots = Vec::new();
3913 let mut seen = BTreeSet::new();
3914 for line in stderr.lines() {
3915 let trimmed = line.trim();
3916 if trimmed == "#include <...> search starts here:" {
3917 in_search_list = true;
3918 continue;
3919 }
3920 if in_search_list && trimmed == "End of search list." {
3921 break;
3922 }
3923 if !in_search_list || trimmed.is_empty() {
3924 continue;
3925 }
3926 let path = trimmed
3927 .strip_suffix(" (framework directory)")
3928 .unwrap_or(trimmed);
3929 validate_normalized_absolute_path(path, "host compiler include search entry")?;
3930 let canonical =
3931 Path::new(path)
3932 .canonicalize()
3933 .map_err(|source| NativeOperatorBuilderError::Io {
3934 path: PathBuf::from(path),
3935 source,
3936 })?;
3937 if !canonical.is_dir() {
3938 return Err(NativeOperatorBuilderError::Invalid(format!(
3939 "host compiler include search entry is not a directory: {}",
3940 canonical.display()
3941 )));
3942 }
3943 if seen.insert(path.to_string()) {
3944 roots.push(path.to_string());
3945 }
3946 }
3947 if roots.is_empty() {
3948 return Err(NativeOperatorBuilderError::Invalid(format!(
3949 "host compiler include search probe produced no roots: {}",
3950 compiler.display()
3951 )));
3952 }
3953 Ok(roots)
3954}
3955
3956fn resolve_host_program(
3957 value: &str,
3958 environment: &BTreeMap<String, String>,
3959) -> Result<Option<PathBuf>> {
3960 if value.is_empty()
3961 || value
3962 .chars()
3963 .any(|character| matches!(character, '\n' | '\r'))
3964 {
3965 return Err(NativeOperatorBuilderError::Invalid(format!(
3966 "host compiler returned an invalid program path: {value:?}"
3967 )));
3968 }
3969 let candidate = Path::new(value);
3970 if candidate.is_absolute() {
3971 return Ok(candidate.is_file().then(|| candidate.to_path_buf()));
3972 }
3973 let path = environment.get("PATH").ok_or_else(|| {
3974 NativeOperatorBuilderError::Invalid(
3975 "host compiler probe environment has no PATH".to_string(),
3976 )
3977 })?;
3978 Ok(std::env::split_paths(std::ffi::OsStr::new(path))
3979 .map(|directory| directory.join(candidate))
3980 .find(|candidate| candidate.is_file()))
3981}
3982
3983fn collect_host_toolchain_scope_files(
3984 roots: &[String],
3985) -> Result<Vec<NativeOperatorHostToolchainFileIdentity>> {
3986 let mut files = BTreeMap::new();
3987 for root in roots {
3988 collect_host_toolchain_files(Path::new(root), &mut BTreeSet::new(), &mut files)?;
3989 }
3990 if files.len() > MAX_HOST_TOOLCHAIN_FILES {
3991 return Err(NativeOperatorBuilderError::Invalid(format!(
3992 "host toolchain manifest must contain at most {MAX_HOST_TOOLCHAIN_FILES} sorted unique files"
3993 )));
3994 }
3995 Ok(files.into_values().collect())
3996}
3997
3998fn collect_host_toolchain_files(
3999 directory: &Path,
4000 active_directories: &mut BTreeSet<PathBuf>,
4001 files: &mut BTreeMap<String, NativeOperatorHostToolchainFileIdentity>,
4002) -> Result<()> {
4003 let resolved_directory =
4004 directory
4005 .canonicalize()
4006 .map_err(|source| NativeOperatorBuilderError::Io {
4007 path: directory.to_path_buf(),
4008 source,
4009 })?;
4010 if !resolved_directory.is_dir() {
4011 return Err(NativeOperatorBuilderError::Invalid(format!(
4012 "host toolchain scope is not a directory: {}",
4013 directory.display()
4014 )));
4015 }
4016 if !active_directories.insert(resolved_directory.clone()) {
4017 return Ok(());
4018 }
4019 let mut children = fs::read_dir(directory)
4020 .map_err(|source| NativeOperatorBuilderError::Io {
4021 path: directory.to_path_buf(),
4022 source,
4023 })?
4024 .collect::<std::result::Result<Vec<_>, _>>()
4025 .map_err(|source| NativeOperatorBuilderError::Io {
4026 path: directory.to_path_buf(),
4027 source,
4028 })?;
4029 children.sort_by_key(|entry| entry.file_name());
4030 for child in children {
4031 let logical = child.path();
4032 let resolved = logical
4033 .canonicalize()
4034 .map_err(|source| NativeOperatorBuilderError::Io {
4035 path: logical.clone(),
4036 source,
4037 })?;
4038 if resolved.is_dir() {
4039 collect_host_toolchain_files(&logical, active_directories, files)?;
4040 } else if resolved.is_file() {
4041 let logical_path = logical.display().to_string();
4042 if !files.contains_key(&logical_path) && files.len() >= MAX_HOST_TOOLCHAIN_FILES {
4043 return Err(NativeOperatorBuilderError::Invalid(format!(
4044 "host toolchain manifest exceeds {MAX_HOST_TOOLCHAIN_FILES} files"
4045 )));
4046 }
4047 let size_bytes = fs::metadata(&resolved)
4048 .map_err(|source| NativeOperatorBuilderError::Io {
4049 path: resolved.clone(),
4050 source,
4051 })?
4052 .len();
4053 let identity = NativeOperatorHostToolchainFileIdentity {
4054 logical_path,
4055 resolved_path: resolved.display().to_string(),
4056 sha256: sha256_file(&resolved)?,
4057 size_bytes,
4058 };
4059 if let Some(existing) = files.insert(identity.logical_path.clone(), identity.clone()) {
4060 if existing != identity {
4061 return Err(NativeOperatorBuilderError::Invalid(format!(
4062 "host toolchain scope resolved inconsistently: {}",
4063 identity.logical_path
4064 )));
4065 }
4066 }
4067 } else {
4068 return Err(NativeOperatorBuilderError::Invalid(format!(
4069 "host toolchain scope contains a non-file entry: {}",
4070 logical.display()
4071 )));
4072 }
4073 }
4074 active_directories.remove(&resolved_directory);
4075 Ok(())
4076}
4077
4078fn validate_host_toolchain_manifest(
4079 context: &str,
4080 manifest: &NativeOperatorHostToolchainManifest,
4081) -> Result<()> {
4082 if manifest.schema_version != NATIVE_OPERATOR_HOST_TOOLCHAIN_MANIFEST_SCHEMA_VERSION
4083 || manifest.compiler_version.trim().is_empty()
4084 || manifest.target.trim().is_empty()
4085 || manifest.target.len() > 256
4086 || manifest.target.chars().any(char::is_whitespace)
4087 || manifest.executable_inputs.is_empty()
4088 || manifest.include_roots.is_empty()
4089 || !is_sha256_digest(&manifest.include_probe_sha256)
4090 || !is_sha256_digest(&manifest.driver_probe_sha256)
4091 || manifest.discovery_roots.is_empty()
4092 || manifest.files.is_empty()
4093 || manifest
4094 .executable_inputs
4095 .windows(2)
4096 .any(|pair| pair[0].path >= pair[1].path)
4097 || manifest.include_roots.iter().collect::<BTreeSet<_>>().len()
4098 != manifest.include_roots.len()
4099 || manifest
4100 .discovery_roots
4101 .windows(2)
4102 .any(|pair| pair[0] >= pair[1])
4103 || manifest
4104 .files
4105 .windows(2)
4106 .any(|pair| pair[0].logical_path >= pair[1].logical_path)
4107 {
4108 return Err(NativeOperatorBuilderError::Invalid(format!(
4109 "{context} host toolchain manifest header/order is invalid"
4110 )));
4111 }
4112 for tool in std::iter::once(&manifest.compiler).chain(manifest.executable_inputs.iter()) {
4113 validate_normalized_absolute_path(
4114 &tool.path,
4115 &format!("{context} host toolchain executable path"),
4116 )?;
4117 if !is_sha256_digest(&tool.sha256) || tool.size_bytes == 0 {
4118 return Err(NativeOperatorBuilderError::Invalid(format!(
4119 "{context} host toolchain executable identity is invalid: {}",
4120 tool.path
4121 )));
4122 }
4123 }
4124 for root in manifest
4125 .include_roots
4126 .iter()
4127 .chain(manifest.discovery_roots.iter())
4128 {
4129 validate_normalized_absolute_path(root, &format!("{context} host toolchain scope root"))?;
4130 }
4131 if !manifest
4132 .executable_inputs
4133 .iter()
4134 .any(|tool| tool == &manifest.compiler)
4135 || manifest.executable_inputs.iter().any(|tool| {
4136 Path::new(&tool.path).parent().map_or(true, |parent| {
4137 !manifest
4138 .discovery_roots
4139 .iter()
4140 .any(|root| Path::new(root) == parent)
4141 })
4142 })
4143 || manifest.discovery_roots.iter().any(|root| {
4144 !manifest
4145 .executable_inputs
4146 .iter()
4147 .any(|tool| Path::new(&tool.path).parent() == Some(Path::new(root)))
4148 })
4149 {
4150 return Err(NativeOperatorBuilderError::Invalid(format!(
4151 "{context} host toolchain manifest does not bind its compiler/search roots"
4152 )));
4153 }
4154 for file in &manifest.files {
4155 validate_normalized_absolute_path(
4156 &file.logical_path,
4157 &format!("{context} host toolchain logical path"),
4158 )?;
4159 validate_normalized_absolute_path(
4160 &file.resolved_path,
4161 &format!("{context} host toolchain resolved path"),
4162 )?;
4163 if !is_sha256_digest(&file.sha256)
4164 || !manifest
4165 .include_roots
4166 .iter()
4167 .chain(manifest.discovery_roots.iter())
4168 .any(|root| Path::new(&file.logical_path).starts_with(root))
4169 {
4170 return Err(NativeOperatorBuilderError::Invalid(format!(
4171 "{context} host toolchain file identity is invalid: {}",
4172 file.logical_path
4173 )));
4174 }
4175 }
4176 Ok(())
4177}
4178
4179fn build_cuda_toolkit_manifest(root: &Path) -> Result<NativeOperatorCudaToolkitManifest> {
4180 let canonical_root = root
4181 .canonicalize()
4182 .map_err(|source| NativeOperatorBuilderError::Io {
4183 path: root.to_path_buf(),
4184 source,
4185 })?;
4186 let root = canonical_root.as_path();
4187 let mut entries = Vec::new();
4188 for relative in REQUIRED_CUDA_TOOLKIT_FILES {
4189 collect_cuda_toolkit_single_file(root, relative, &mut entries)?;
4190 }
4191 for optional in ["bin/cudafe", "bin/nvcc.profile"] {
4192 if root.join(optional).exists() {
4193 collect_cuda_toolkit_single_file(root, optional, &mut entries)?;
4194 }
4195 }
4196 for scope in REQUIRED_CUDA_TOOLKIT_SCOPES {
4197 let scope_path = root.join(scope);
4198 if !scope_path.is_dir() {
4199 return Err(NativeOperatorBuilderError::Invalid(format!(
4200 "cuda toolkit compiler scope is missing: {scope}"
4201 )));
4202 }
4203 collect_cuda_toolkit_files(root, &scope_path, &mut BTreeSet::new(), &mut entries)?;
4204 }
4205 entries.sort_by(|left, right| left.logical_path.cmp(&right.logical_path));
4206 if entries.is_empty()
4207 || entries
4208 .windows(2)
4209 .any(|pair| pair[0].logical_path >= pair[1].logical_path)
4210 {
4211 return Err(NativeOperatorBuilderError::Invalid(
4212 "cuda toolkit manifest entries must be non-empty, sorted, and unique".to_string(),
4213 ));
4214 }
4215 Ok(NativeOperatorCudaToolkitManifest {
4216 schema_version: NATIVE_OPERATOR_CUDA_TOOLKIT_MANIFEST_SCHEMA_VERSION,
4217 canonical_root: root.display().to_string(),
4218 entries,
4219 })
4220}
4221
4222fn collect_cuda_toolkit_single_file(
4223 root: &Path,
4224 relative: &str,
4225 entries: &mut Vec<NativeOperatorCudaToolkitFileIdentity>,
4226) -> Result<()> {
4227 validate_relative_path(relative)?;
4228 let logical = root.join(relative);
4229 let resolved = logical
4230 .canonicalize()
4231 .map_err(|source| NativeOperatorBuilderError::Io {
4232 path: logical.clone(),
4233 source,
4234 })?;
4235 if !resolved.starts_with(root) || !resolved.is_file() {
4236 return Err(NativeOperatorBuilderError::Invalid(format!(
4237 "cuda toolkit compiler input escapes root or is not a file: {relative}"
4238 )));
4239 }
4240 entries.push(cuda_toolkit_file_identity(root, &logical, &resolved)?);
4241 Ok(())
4242}
4243
4244fn cuda_toolkit_release_version(root: &Path) -> Result<String> {
4245 let cuda_header = root.join("include/cuda.h");
4246 require_file(&cuda_header)?;
4247 let contents =
4248 fs::read_to_string(&cuda_header).map_err(|source| NativeOperatorBuilderError::Io {
4249 path: cuda_header.clone(),
4250 source,
4251 })?;
4252 let encoded = contents
4253 .lines()
4254 .find_map(|line| {
4255 let mut fields = line.split_whitespace();
4256 match (fields.next(), fields.next(), fields.next(), fields.next()) {
4257 (Some("#define"), Some("CUDA_VERSION"), Some(value), None) => {
4258 value.parse::<u32>().ok()
4259 }
4260 _ => None,
4261 }
4262 })
4263 .filter(|value| *value >= 1000)
4264 .ok_or_else(|| {
4265 NativeOperatorBuilderError::Invalid(format!(
4266 "cuda toolkit include/cuda.h has no valid CUDA_VERSION: {}",
4267 cuda_header.display()
4268 ))
4269 })?;
4270 Ok(format!(
4271 "{}.{}.{}",
4272 encoded / 1000,
4273 (encoded % 1000) / 10,
4274 encoded % 10
4275 ))
4276}
4277
4278fn collect_cuda_toolkit_files(
4279 root: &Path,
4280 directory: &Path,
4281 active_directories: &mut BTreeSet<PathBuf>,
4282 entries: &mut Vec<NativeOperatorCudaToolkitFileIdentity>,
4283) -> Result<()> {
4284 let resolved_directory =
4285 directory
4286 .canonicalize()
4287 .map_err(|source| NativeOperatorBuilderError::Io {
4288 path: directory.to_path_buf(),
4289 source,
4290 })?;
4291 if !resolved_directory.starts_with(root) || !resolved_directory.is_dir() {
4292 return Err(NativeOperatorBuilderError::Invalid(format!(
4293 "cuda toolkit compiler directory escapes its canonical root: {}",
4294 directory.display()
4295 )));
4296 }
4297 if !active_directories.insert(resolved_directory.clone()) {
4298 return Err(NativeOperatorBuilderError::Invalid(format!(
4299 "cuda toolkit compiler directory contains a symlink cycle: {}",
4300 directory.display()
4301 )));
4302 }
4303 let mut children = fs::read_dir(directory)
4304 .map_err(|source| NativeOperatorBuilderError::Io {
4305 path: directory.to_path_buf(),
4306 source,
4307 })?
4308 .collect::<std::result::Result<Vec<_>, _>>()
4309 .map_err(|source| NativeOperatorBuilderError::Io {
4310 path: directory.to_path_buf(),
4311 source,
4312 })?;
4313 children.sort_by_key(|entry| entry.file_name());
4314 for child in children {
4315 let logical_path = child.path();
4316 let resolved =
4317 logical_path
4318 .canonicalize()
4319 .map_err(|source| NativeOperatorBuilderError::Io {
4320 path: logical_path.clone(),
4321 source,
4322 })?;
4323 if !resolved.starts_with(root) {
4324 return Err(NativeOperatorBuilderError::Invalid(format!(
4325 "cuda toolkit symlink escapes its canonical root: {}",
4326 logical_path.display()
4327 )));
4328 }
4329 if resolved.is_dir() {
4330 collect_cuda_toolkit_files(root, &logical_path, active_directories, entries)?;
4331 } else if resolved.is_file() {
4332 entries.push(cuda_toolkit_file_identity(root, &logical_path, &resolved)?);
4333 } else {
4334 return Err(NativeOperatorBuilderError::Invalid(format!(
4335 "cuda toolkit compiler scope contains a non-file entry: {}",
4336 logical_path.display()
4337 )));
4338 }
4339 }
4340 active_directories.remove(&resolved_directory);
4341 Ok(())
4342}
4343
4344fn cuda_toolkit_file_identity(
4345 root: &Path,
4346 logical: &Path,
4347 resolved: &Path,
4348) -> Result<NativeOperatorCudaToolkitFileIdentity> {
4349 let logical_path = logical.strip_prefix(root).map_err(|_| {
4350 NativeOperatorBuilderError::Invalid(format!(
4351 "cuda toolkit logical path escapes root: {}",
4352 logical.display()
4353 ))
4354 })?;
4355 let resolved_path = resolved.strip_prefix(root).map_err(|_| {
4356 NativeOperatorBuilderError::Invalid(format!(
4357 "cuda toolkit resolved path escapes root: {}",
4358 resolved.display()
4359 ))
4360 })?;
4361 let size_bytes = fs::metadata(resolved)
4362 .map_err(|source| NativeOperatorBuilderError::Io {
4363 path: resolved.to_path_buf(),
4364 source,
4365 })?
4366 .len();
4367 Ok(NativeOperatorCudaToolkitFileIdentity {
4368 logical_path: path_with_forward_slashes(logical_path)?,
4369 resolved_path: path_with_forward_slashes(resolved_path)?,
4370 sha256: sha256_file(resolved)?,
4371 size_bytes,
4372 })
4373}
4374
4375fn path_with_forward_slashes(path: &Path) -> Result<String> {
4376 let components = path
4377 .components()
4378 .map(|component| match component {
4379 std::path::Component::Normal(value) => {
4380 value.to_str().map(str::to_string).ok_or_else(|| {
4381 NativeOperatorBuilderError::Invalid(format!(
4382 "native build path is not valid UTF-8: {}",
4383 path.display()
4384 ))
4385 })
4386 }
4387 _ => Err(NativeOperatorBuilderError::Invalid(format!(
4388 "native build path is not normalized and relative: {}",
4389 path.display()
4390 ))),
4391 })
4392 .collect::<Result<Vec<_>>>()?;
4393 Ok(components.join("/"))
4394}
4395
4396fn tool_file_identity(path: &Path) -> Result<NativeOperatorToolFileIdentity> {
4397 require_file(path)?;
4398 let canonical = path
4399 .canonicalize()
4400 .map_err(|source| NativeOperatorBuilderError::Io {
4401 path: path.to_path_buf(),
4402 source,
4403 })?;
4404 let size_bytes = fs::metadata(&canonical)
4405 .map_err(|source| NativeOperatorBuilderError::Io {
4406 path: canonical.clone(),
4407 source,
4408 })?
4409 .len();
4410 if size_bytes == 0 {
4411 return Err(NativeOperatorBuilderError::Invalid(format!(
4412 "source build tool is empty: {}",
4413 canonical.display()
4414 )));
4415 }
4416 Ok(NativeOperatorToolFileIdentity {
4417 path: canonical.display().to_string(),
4418 sha256: sha256_file(&canonical)?,
4419 size_bytes,
4420 })
4421}
4422
4423fn probe_source_toolchain(
4424 static_identity: &NativeOperatorSourceBuildStaticToolchain,
4425 missed_translation_units: Vec<String>,
4426) -> Result<NativeOperatorSourceBuildToolchainProbe> {
4427 Ok(NativeOperatorSourceBuildToolchainProbe {
4428 nvcc_version: tool_version(Path::new(&static_identity.cuda_toolkit.nvcc.path))?,
4429 host_compiler_version: static_identity.host_toolchain.compiler_version.clone(),
4430 host_target: static_identity.host_toolchain.target.clone(),
4431 archiver_version: tool_version(Path::new(&static_identity.archiver.path))?,
4432 probed_for_misses: missed_translation_units,
4433 })
4434}
4435
4436fn validate_static_toolchain_identity(
4437 operator: &str,
4438 toolchain: &NativeOperatorSourceBuildStaticToolchain,
4439) -> Result<()> {
4440 if toolchain.backend != NativeOperatorBackend::Cuda
4441 || toolchain.compiler_driver != NativeOperatorSourceCompilerDriver::CudaNvcc
4442 {
4443 return Err(NativeOperatorBuilderError::Invalid(format!(
4444 "{operator} source-build toolchain must use the CUDA nvcc driver"
4445 )));
4446 }
4447 validate_normalized_absolute_path(
4448 &toolchain.cuda_toolkit.canonical_root,
4449 &format!("{operator} cuda toolkit canonical_root"),
4450 )?;
4451 validate_normalized_absolute_path(
4452 &toolchain.cuda_toolkit.invocation_root,
4453 &format!("{operator} cuda toolkit invocation_root"),
4454 )?;
4455 if toolchain.cuda_toolkit.release_version.trim().is_empty()
4456 || toolchain
4457 .cuda_toolkit
4458 .release_version
4459 .chars()
4460 .any(|character| !(character.is_ascii_digit() || character == '.'))
4461 {
4462 return Err(NativeOperatorBuilderError::Invalid(format!(
4463 "{operator} cuda toolkit release_version is invalid"
4464 )));
4465 }
4466 for (name, tool) in [
4467 ("nvcc", &toolchain.cuda_toolkit.nvcc),
4468 ("host_compiler", &toolchain.host_toolchain.compiler),
4469 ("archiver", &toolchain.archiver),
4470 ] {
4471 if validate_normalized_absolute_path(
4472 &tool.path,
4473 &format!("{operator} source-build static {name} path"),
4474 )
4475 .is_err()
4476 || !is_sha256_digest(&tool.sha256)
4477 || tool.size_bytes == 0
4478 {
4479 return Err(NativeOperatorBuilderError::Invalid(format!(
4480 "{operator} source-build static {name} identity is incomplete"
4481 )));
4482 }
4483 }
4484 if !Path::new(&toolchain.cuda_toolkit.nvcc.path)
4485 .starts_with(&toolchain.cuda_toolkit.canonical_root)
4486 {
4487 return Err(NativeOperatorBuilderError::Invalid(format!(
4488 "{operator} nvcc identity escapes cuda toolkit root"
4489 )));
4490 }
4491 let manifest = &toolchain.cuda_toolkit.manifest;
4492 if manifest.path != "toolchain/cuda-static-manifest.json"
4493 || !is_sha256_digest(&manifest.sha256)
4494 || manifest.size_bytes == 0
4495 {
4496 return Err(NativeOperatorBuilderError::Invalid(format!(
4497 "{operator} cuda toolkit manifest evidence is incomplete"
4498 )));
4499 }
4500 let host = &toolchain.host_toolchain;
4501 if host.compiler_version.trim().is_empty()
4502 || host.target.trim().is_empty()
4503 || host.target.len() > 256
4504 || host.target.chars().any(char::is_whitespace)
4505 || host.manifest.path != "toolchain/host-static-manifest.json"
4506 || !is_sha256_digest(&host.manifest.sha256)
4507 || host.manifest.size_bytes == 0
4508 {
4509 return Err(NativeOperatorBuilderError::Invalid(format!(
4510 "{operator} host toolchain manifest evidence is incomplete"
4511 )));
4512 }
4513 Ok(())
4514}
4515
4516fn validate_tool_file_unchanged(identity: &NativeOperatorToolFileIdentity) -> Result<()> {
4517 let current = tool_file_identity(Path::new(&identity.path))?;
4518 if ¤t != identity {
4519 return Err(NativeOperatorBuilderError::Invalid(format!(
4520 "tool file changed after static identity was recorded: {}",
4521 identity.path
4522 )));
4523 }
4524 Ok(())
4525}
4526
4527fn validate_cuda_toolkit_unchanged(identity: &NativeOperatorCudaToolkitIdentity) -> Result<()> {
4528 let manifest_path = Path::new(&identity.canonical_root);
4529 let current = build_cuda_toolkit_manifest(manifest_path)?;
4530 validate_cuda_toolkit_manifest("<source-build-finalize>", identity, ¤t)?;
4531 let recorded_path = Path::new(&identity.canonical_root);
4532 if current.canonical_root != recorded_path.display().to_string() {
4533 return Err(NativeOperatorBuilderError::Invalid(
4534 "cuda toolkit canonical root changed during source build".to_string(),
4535 ));
4536 }
4537 let recorded_manifest = identity.manifest.sha256.as_str();
4538 let serialized_with_newline = {
4539 let mut bytes = serde_json::to_vec_pretty(¤t).map_err(|source| {
4540 NativeOperatorBuilderError::Json {
4541 path: PathBuf::from("<cuda-static-manifest>"),
4542 source,
4543 }
4544 })?;
4545 bytes.push(b'\n');
4546 sha256_bytes(&bytes)
4547 };
4548 if serialized_with_newline != recorded_manifest {
4549 return Err(NativeOperatorBuilderError::Invalid(format!(
4550 "cuda toolkit manifest changed during source build: expected={recorded_manifest} actual={serialized_with_newline}"
4551 )));
4552 }
4553 Ok(())
4554}
4555
4556fn validate_host_toolchain_unchanged(
4557 identity: &NativeOperatorHostToolchainIdentity,
4558 receipt_root: &Path,
4559 environment: &BTreeMap<String, String>,
4560) -> Result<()> {
4561 let manifest_path = resolve_source_build_evidence_file(
4562 receipt_root,
4563 "<source-build-finalize>",
4564 &identity.manifest,
4565 )?;
4566 let recorded: NativeOperatorHostToolchainManifest = read_json(&manifest_path)?;
4567 validate_host_toolchain_manifest("<source-build-finalize>", &recorded)?;
4568 if recorded.compiler != identity.compiler
4569 || recorded.compiler_version != identity.compiler_version
4570 || recorded.target != identity.target
4571 {
4572 return Err(NativeOperatorBuilderError::Invalid(
4573 "host toolchain identity differs from its manifest".to_string(),
4574 ));
4575 }
4576 if !host_toolchain_manifest_matches_current(&recorded, &identity.compiler, environment)? {
4577 return Err(NativeOperatorBuilderError::Invalid(
4578 "host toolchain files or driver configuration changed during source build".to_string(),
4579 ));
4580 }
4581 Ok(())
4582}
4583
4584pub(crate) fn compiler_target(path: &Path) -> Result<String> {
4585 require_file(path)?;
4586 let canonical = path
4587 .canonicalize()
4588 .map_err(|source| NativeOperatorBuilderError::Io {
4589 path: path.to_path_buf(),
4590 source,
4591 })?;
4592 let output = Command::new(&canonical)
4593 .arg("-dumpmachine")
4594 .env_clear()
4595 .env("LANG", "C")
4596 .env("LC_ALL", "C")
4597 .env("TZ", "UTC")
4598 .output()
4599 .map_err(|source| NativeOperatorBuilderError::Io {
4600 path: canonical.clone(),
4601 source,
4602 })?;
4603 let target = String::from_utf8_lossy(&output.stdout).trim().to_string();
4604 if !output.status.success()
4605 || target.is_empty()
4606 || target.len() > 256
4607 || target.chars().any(char::is_whitespace)
4608 {
4609 return Err(NativeOperatorBuilderError::Invalid(format!(
4610 "compiler produced no valid target identity: {}",
4611 canonical.display()
4612 )));
4613 }
4614 Ok(target)
4615}
4616
4617pub(crate) fn native_object_identity_file(path: &Path) -> Result<NativeOperatorObjectIdentity> {
4618 let bytes = fs::read(path).map_err(|source| NativeOperatorBuilderError::Io {
4619 path: path.to_path_buf(),
4620 source,
4621 })?;
4622 native_object_identity_bytes(&bytes, &path.display().to_string())
4623}
4624
4625fn native_object_size(path: &Path) -> Result<u64> {
4626 let size_bytes = fs::metadata(path)
4627 .map_err(|source| NativeOperatorBuilderError::Io {
4628 path: path.to_path_buf(),
4629 source,
4630 })?
4631 .len();
4632 if size_bytes == 0 {
4633 return Err(NativeOperatorBuilderError::Invalid(format!(
4634 "native object is empty: {}",
4635 path.display()
4636 )));
4637 }
4638 Ok(size_bytes)
4639}
4640
4641pub(crate) fn native_object_identity_bytes(
4642 bytes: &[u8],
4643 context: &str,
4644) -> Result<NativeOperatorObjectIdentity> {
4645 if bytes.len() >= 20 && bytes.starts_with(b"\x7fELF") {
4646 let class_bits = match bytes[4] {
4647 1 => 32,
4648 2 => 64,
4649 value => {
4650 return Err(NativeOperatorBuilderError::Invalid(format!(
4651 "unsupported ELF class in {context}: {value}"
4652 )))
4653 }
4654 };
4655 let endianness = match bytes[5] {
4656 1 => NativeOperatorObjectEndianness::Little,
4657 2 => NativeOperatorObjectEndianness::Big,
4658 value => {
4659 return Err(NativeOperatorBuilderError::Invalid(format!(
4660 "unsupported ELF endianness in {context}: {value}"
4661 )))
4662 }
4663 };
4664 let machine = u32::from(read_u16(&bytes[18..20], endianness));
4665 let identity = NativeOperatorObjectIdentity {
4666 format: NativeOperatorObjectFormat::Elf,
4667 class_bits,
4668 endianness,
4669 machine,
4670 };
4671 validate_native_object_identity(&identity, context)?;
4672 return Ok(identity);
4673 }
4674
4675 if bytes.len() >= 8 {
4676 let (class_bits, endianness) = match &bytes[..4] {
4677 [0xce, 0xfa, 0xed, 0xfe] => (32, NativeOperatorObjectEndianness::Little),
4678 [0xfe, 0xed, 0xfa, 0xce] => (32, NativeOperatorObjectEndianness::Big),
4679 [0xcf, 0xfa, 0xed, 0xfe] => (64, NativeOperatorObjectEndianness::Little),
4680 [0xfe, 0xed, 0xfa, 0xcf] => (64, NativeOperatorObjectEndianness::Big),
4681 _ => (0, NativeOperatorObjectEndianness::Little),
4682 };
4683 if class_bits != 0 {
4684 let machine = read_u32(&bytes[4..8], endianness);
4685 let identity = NativeOperatorObjectIdentity {
4686 format: NativeOperatorObjectFormat::MachO,
4687 class_bits,
4688 endianness,
4689 machine,
4690 };
4691 validate_native_object_identity(&identity, context)?;
4692 return Ok(identity);
4693 }
4694 }
4695
4696 if bytes.len() >= 20 {
4697 let machine = u16::from_le_bytes([bytes[0], bytes[1]]);
4698 if matches!(machine, 0x014c | 0x01c0 | 0x01c4 | 0x8664 | 0xaa64) {
4699 let identity = NativeOperatorObjectIdentity {
4700 format: NativeOperatorObjectFormat::Coff,
4701 class_bits: if matches!(machine, 0x8664 | 0xaa64) {
4702 64
4703 } else {
4704 32
4705 },
4706 endianness: NativeOperatorObjectEndianness::Little,
4707 machine: u32::from(machine),
4708 };
4709 validate_native_object_identity(&identity, context)?;
4710 return Ok(identity);
4711 }
4712 }
4713
4714 Err(NativeOperatorBuilderError::Invalid(format!(
4715 "native object has no supported ELF, Mach-O, or COFF header: {context}"
4716 )))
4717}
4718
4719pub(crate) fn validate_native_object_identity(
4720 identity: &NativeOperatorObjectIdentity,
4721 context: &str,
4722) -> Result<()> {
4723 if !matches!(identity.class_bits, 32 | 64) || identity.machine == 0 {
4724 return Err(NativeOperatorBuilderError::Invalid(format!(
4725 "native object identity is incomplete for {context}: {identity:?}"
4726 )));
4727 }
4728 if identity.format == NativeOperatorObjectFormat::Coff
4729 && identity.endianness != NativeOperatorObjectEndianness::Little
4730 {
4731 return Err(NativeOperatorBuilderError::Invalid(format!(
4732 "COFF object must be little-endian for {context}"
4733 )));
4734 }
4735 Ok(())
4736}
4737
4738fn read_u16(bytes: &[u8], endianness: NativeOperatorObjectEndianness) -> u16 {
4739 let bytes = [bytes[0], bytes[1]];
4740 match endianness {
4741 NativeOperatorObjectEndianness::Little => u16::from_le_bytes(bytes),
4742 NativeOperatorObjectEndianness::Big => u16::from_be_bytes(bytes),
4743 }
4744}
4745
4746fn read_u32(bytes: &[u8], endianness: NativeOperatorObjectEndianness) -> u32 {
4747 let bytes = [bytes[0], bytes[1], bytes[2], bytes[3]];
4748 match endianness {
4749 NativeOperatorObjectEndianness::Little => u32::from_le_bytes(bytes),
4750 NativeOperatorObjectEndianness::Big => u32::from_be_bytes(bytes),
4751 }
4752}
4753
4754pub(crate) fn tool_identity(path: &Path) -> Result<NativeOperatorToolIdentity> {
4755 require_file(path)?;
4756 let canonical = path
4757 .canonicalize()
4758 .map_err(|source| NativeOperatorBuilderError::Io {
4759 path: path.to_path_buf(),
4760 source,
4761 })?;
4762 Ok(NativeOperatorToolIdentity {
4763 path: canonical.display().to_string(),
4764 sha256: sha256_file(&canonical)?,
4765 version: tool_version(&canonical)?,
4766 })
4767}
4768
4769fn tool_version(path: &Path) -> Result<String> {
4770 require_file(path)?;
4771 let canonical = path
4772 .canonicalize()
4773 .map_err(|source| NativeOperatorBuilderError::Io {
4774 path: path.to_path_buf(),
4775 source,
4776 })?;
4777 let output = Command::new(&canonical)
4778 .arg("--version")
4779 .env_clear()
4780 .env("LANG", "C")
4781 .env("LC_ALL", "C")
4782 .env("TZ", "UTC")
4783 .output()
4784 .map_err(|source| NativeOperatorBuilderError::Io {
4785 path: canonical.clone(),
4786 source,
4787 })?;
4788 let version = format!(
4789 "{}{}",
4790 String::from_utf8_lossy(&output.stdout),
4791 String::from_utf8_lossy(&output.stderr)
4792 )
4793 .trim()
4794 .chars()
4795 .take(4000)
4796 .collect::<String>();
4797 if version.is_empty() {
4798 return Err(NativeOperatorBuilderError::Invalid(format!(
4799 "tool produced no version identity: {}",
4800 canonical.display()
4801 )));
4802 }
4803 Ok(version)
4804}
4805
4806#[allow(clippy::too_many_arguments)]
4807fn build_inputs_sha256(
4808 plan_sha256: &str,
4809 source_package_sha256: &str,
4810 architecture_argument: &str,
4811 effective_environment: &BTreeMap<String, String>,
4812 toolchain: Option<&NativeOperatorSourceBuildStaticToolchain>,
4813 receipt_path: &Path,
4814) -> Result<String> {
4815 let identity = NativeOperatorBuildInputIdentity {
4816 plan_sha256,
4817 source_package_sha256,
4818 builder_contract_version: NATIVE_OPERATOR_SOURCE_OBJECT_BUILD_CONTRACT_VERSION,
4819 architecture_argument,
4820 effective_environment,
4821 toolchain,
4822 };
4823 let bytes =
4824 serde_json::to_vec(&identity).map_err(|source| NativeOperatorBuilderError::Json {
4825 path: receipt_path.to_path_buf(),
4826 source,
4827 })?;
4828 Ok(sha256_bytes(&bytes))
4829}
4830
4831fn effective_build_environment(
4832 request: &NativeOperatorSourceBuildRequest,
4833 toolchain: Option<&NativeOperatorSourceBuildToolchain>,
4834) -> Result<BTreeMap<String, String>> {
4835 let tool_paths = if let Some(toolchain) = toolchain {
4836 [
4837 toolchain.static_identity.cuda_toolkit.nvcc.path.as_str(),
4838 toolchain
4839 .static_identity
4840 .host_toolchain
4841 .compiler
4842 .path
4843 .as_str(),
4844 toolchain.static_identity.archiver.path.as_str(),
4845 ]
4846 } else {
4847 [
4848 request.nvcc_path.to_str().unwrap_or(""),
4849 request.ccbin_path.to_str().unwrap_or(""),
4850 request.ar_path.to_str().unwrap_or(""),
4851 ]
4852 };
4853 effective_environment_for_tool_paths(tool_paths)
4854}
4855
4856fn effective_environment_for_tool_paths(tool_paths: [&str; 3]) -> Result<BTreeMap<String, String>> {
4857 let mut path_entries = tool_paths
4858 .iter()
4859 .filter_map(|path| Path::new(path).parent())
4860 .map(Path::to_path_buf)
4861 .collect::<Vec<_>>();
4862 path_entries.extend([PathBuf::from("/bin"), PathBuf::from("/usr/bin")]);
4863 path_entries.sort();
4864 path_entries.dedup();
4865 if path_entries.iter().any(|path| path.as_os_str().is_empty()) {
4866 return Err(NativeOperatorBuilderError::Invalid(
4867 "source build tool paths must have parent directories".to_string(),
4868 ));
4869 }
4870 let path = std::env::join_paths(&path_entries)
4871 .map_err(|error| {
4872 NativeOperatorBuilderError::Invalid(format!(
4873 "source build tool PATH cannot be represented: {error}"
4874 ))
4875 })?
4876 .into_string()
4877 .map_err(|_| {
4878 NativeOperatorBuilderError::Invalid(
4879 "source build tool PATH is not valid UTF-8".to_string(),
4880 )
4881 })?;
4882 let mut environment = BTreeMap::new();
4883 environment.insert("LANG".to_string(), "C".to_string());
4884 environment.insert("LC_ALL".to_string(), "C".to_string());
4885 environment.insert("PATH".to_string(), path);
4886 environment.insert("SOURCE_DATE_EPOCH".to_string(), "0".to_string());
4887 environment.insert("TMPDIR".to_string(), "/tmp".to_string());
4888 environment.insert("TZ".to_string(), "UTC".to_string());
4889 environment.insert("ZERO_AR_DATE".to_string(), "1".to_string());
4890 Ok(environment)
4891}
4892
4893fn build_object_cache_specs(
4894 plan: &NativeOperatorSourceBuildPlan,
4895 architecture_argument: &str,
4896 toolchain: &NativeOperatorSourceBuildStaticToolchain,
4897 effective_environment: &BTreeMap<String, String>,
4898) -> Result<Vec<NativeBuildArtifactSpec>> {
4899 plan.translation_units
4900 .iter()
4901 .enumerate()
4902 .map(|(index, translation_unit)| {
4903 let closure = plan.dependency_closures.get(index).ok_or_else(|| {
4904 NativeOperatorBuilderError::Invalid(format!(
4905 "missing dependency closure for {}",
4906 translation_unit.path
4907 ))
4908 })?;
4909 if closure.translation_unit != translation_unit.path {
4910 return Err(NativeOperatorBuilderError::Invalid(format!(
4911 "dependency closure order differs from translation units: expected={} actual={}",
4912 translation_unit.path, closure.translation_unit
4913 )));
4914 }
4915 let identity = NativeOperatorObjectInputIdentity {
4916 schema_version: NATIVE_OPERATOR_SOURCE_OBJECT_BUILD_CONTRACT_VERSION,
4917 operator: &plan.operator,
4918 translation_unit,
4919 dependency_closure_sha256: &closure.closure_sha256,
4920 headers: &closure.headers,
4921 include_dirs: &plan.include_dirs,
4922 defines: &plan.defines,
4923 nvcc_policy: &plan.nvcc_policy,
4924 architecture_argument,
4925 builder_contract_version: NATIVE_OPERATOR_SOURCE_OBJECT_BUILD_CONTRACT_VERSION,
4926 effective_environment,
4927 toolchain,
4928 };
4929 let input_signature = serde_json::to_string(&identity).map_err(|source| {
4930 NativeOperatorBuilderError::Json {
4931 path: PathBuf::from("<object-cache-input>"),
4932 source,
4933 }
4934 })?;
4935 NativeBuildArtifactSpec::new(
4936 format!("{}.object.{index:02}", plan.operator),
4937 object_file_name(index, translation_unit),
4938 input_signature,
4939 )
4940 .map_err(NativeOperatorBuilderError::from)
4941 })
4942 .collect()
4943}
4944
4945fn object_file_name(index: usize, translation_unit: &NativeOperatorSourceFileLock) -> String {
4946 let stem = Path::new(&translation_unit.path)
4947 .file_stem()
4948 .and_then(|value| value.to_str())
4949 .unwrap_or("translation_unit");
4950 format!(
4951 "{index:08}_{}_{}.o",
4952 safe_component(stem),
4953 &translation_unit.sha256[..8]
4954 )
4955}
4956
4957fn nvcc_policy_flags(policy: &NativeOperatorNvccPolicy) -> Vec<String> {
4958 let mut flags = vec![
4959 match policy.cpp_standard {
4960 NativeOperatorCppStandard::Cpp17 => "-std=c++17",
4961 }
4962 .to_string(),
4963 match policy.optimization {
4964 NativeOperatorOptimization::O3 => "-O3",
4965 }
4966 .to_string(),
4967 ];
4968 if policy.use_fast_math {
4969 flags.push("--use_fast_math".to_string());
4970 }
4971 if policy.relaxed_constexpr {
4972 flags.push("--expt-relaxed-constexpr".to_string());
4973 }
4974 if policy.extended_lambda {
4975 flags.push("--expt-extended-lambda".to_string());
4976 }
4977 if policy.host_position_independent_code {
4978 flags.extend(["-Xcompiler".to_string(), "-fPIC".to_string()]);
4979 }
4980 if policy.host_default_visibility {
4981 flags.extend(["-Xcompiler".to_string(), "-fvisibility=default".to_string()]);
4982 }
4983 flags
4984}
4985
4986fn build_commands(
4987 request: &NativeOperatorSourceBuildRequest,
4988 plan: &NativeOperatorSourceBuildPlan,
4989 source_root: &Path,
4990 architecture_argument: &str,
4991 objects_dir: &Path,
4992 logs_dir: &Path,
4993 toolchain: Option<&NativeOperatorSourceBuildToolchain>,
4994 _effective_environment: &BTreeMap<String, String>,
4995) -> Vec<NativeOperatorSourceBuildCommand> {
4996 let nvcc_path = toolchain
4997 .map(|toolchain| toolchain.static_identity.cuda_toolkit.nvcc.path.as_str())
4998 .unwrap_or_else(|| request.nvcc_path.to_str().unwrap_or("<non-utf8-nvcc>"));
4999 let ccbin_path = toolchain
5000 .map(|toolchain| {
5001 toolchain
5002 .static_identity
5003 .host_toolchain
5004 .compiler
5005 .path
5006 .as_str()
5007 })
5008 .unwrap_or_else(|| request.ccbin_path.to_str().unwrap_or("<non-utf8-ccbin>"));
5009 let ar_path = toolchain
5010 .map(|toolchain| toolchain.static_identity.archiver.path.as_str())
5011 .unwrap_or_else(|| request.ar_path.to_str().unwrap_or("<non-utf8-ar>"));
5012 let mut commands = Vec::with_capacity(plan.translation_units.len() + 1);
5013 let mut object_paths = Vec::with_capacity(plan.translation_units.len());
5014 for (index, translation_unit) in plan.translation_units.iter().enumerate() {
5015 let stem = Path::new(&translation_unit.path)
5016 .file_stem()
5017 .and_then(|value| value.to_str())
5018 .unwrap_or("translation_unit");
5019 let object_name = object_file_name(index, translation_unit);
5020 let object_path = objects_dir.join(object_name);
5021 let depfile_name = format!("{index:08}-{stem}.d");
5022 let depfile_relative = format!("depfiles/{depfile_name}");
5023 let compiler_depfile_relative = format!("depfiles/{index:08}-{stem}.compiler.raw.d");
5024 let compiler_depfile_path = request.output_dir.join(&compiler_depfile_relative);
5025 object_paths.push(object_path.clone());
5026 let mut argv = vec![
5027 nvcc_path.to_string(),
5028 "-c".to_string(),
5029 translation_unit.path.clone(),
5030 "-o".to_string(),
5031 object_path.display().to_string(),
5032 architecture_argument.to_string(),
5033 "-ccbin".to_string(),
5034 ccbin_path.to_string(),
5035 "-MMD".to_string(),
5036 "-MF".to_string(),
5037 compiler_depfile_path.display().to_string(),
5038 "-MT".to_string(),
5039 object_path.display().to_string(),
5040 ];
5041 argv.extend(plan.include_dirs.iter().map(|path| format!("-I{path}")));
5042 argv.extend(plan.defines.iter().map(|define| format!("-D{define}")));
5043 argv.extend(nvcc_policy_flags(&plan.nvcc_policy));
5044 argv.push("--threads".to_string());
5045 argv.push(request.nvcc_threads.to_string());
5046 commands.push(NativeOperatorSourceBuildCommand {
5047 translation_unit: Some(translation_unit.path.clone()),
5048 working_directory: source_root.display().to_string(),
5049 argv,
5050 object_file: Some(object_path.display().to_string()),
5051 stdout_log: relative_log(logs_dir, &format!("{index:02}-{stem}.stdout.log")),
5052 stderr_log: relative_log(logs_dir, &format!("{index:02}-{stem}.stderr.log")),
5053 object_cache_key: None,
5054 object_cache_status: Some(if request.plan_only {
5055 NativeOperatorSourceObjectCacheStatus::Plan
5056 } else {
5057 NativeOperatorSourceObjectCacheStatus::Pending
5058 }),
5059 object_cache_entry: None,
5060 object_sha256: None,
5061 object_size_bytes: None,
5062 object_identity: None,
5063 dependency_closure_sha256: plan
5064 .dependency_closures
5065 .get(index)
5066 .map(|closure| closure.closure_sha256.clone()),
5067 dependency_validation: Some(if request.plan_only {
5068 NativeOperatorDependencyValidation::Plan
5069 } else {
5070 NativeOperatorDependencyValidation::Pending
5071 }),
5072 compiler_depfile: Some(compiler_depfile_relative),
5073 compiler_depfile_sha256: None,
5074 depfile: Some(depfile_relative),
5075 depfile_sha256: None,
5076 depfile_producer_working_directory: None,
5077 depfile_producer_object_file: None,
5078 depfile_bindings: Vec::new(),
5079 observed_dependencies: Vec::new(),
5080 compiler_executed: false,
5081 elapsed_ms: None,
5082 return_code: None,
5083 });
5084 }
5085 let archive_path = request.output_dir.join(&plan.archive_file);
5086 let mut archive_argv = vec![
5087 ar_path.to_string(),
5088 "rcs".to_string(),
5089 archive_path.display().to_string(),
5090 ];
5091 archive_argv.extend(object_paths.iter().map(|path| path.display().to_string()));
5092 commands.push(NativeOperatorSourceBuildCommand {
5093 translation_unit: None,
5094 working_directory: source_root.display().to_string(),
5095 argv: archive_argv,
5096 object_file: None,
5097 stdout_log: relative_log(logs_dir, "archive.stdout.log"),
5098 stderr_log: relative_log(logs_dir, "archive.stderr.log"),
5099 object_cache_key: None,
5100 object_cache_status: None,
5101 object_cache_entry: None,
5102 object_sha256: None,
5103 object_size_bytes: None,
5104 object_identity: None,
5105 dependency_closure_sha256: None,
5106 dependency_validation: None,
5107 compiler_depfile: None,
5108 compiler_depfile_sha256: None,
5109 depfile: None,
5110 depfile_sha256: None,
5111 depfile_producer_working_directory: None,
5112 depfile_producer_object_file: None,
5113 depfile_bindings: Vec::new(),
5114 observed_dependencies: Vec::new(),
5115 compiler_executed: false,
5116 elapsed_ms: None,
5117 return_code: None,
5118 });
5119 commands
5120}
5121
5122fn run_logged_command(
5123 argv: &[String],
5124 stdout_path: &Path,
5125 stderr_path: &Path,
5126 working_directory: &str,
5127 effective_environment: &BTreeMap<String, String>,
5128) -> Result<ExitStatus> {
5129 let (program, args) = argv.split_first().ok_or_else(|| {
5130 NativeOperatorBuilderError::Invalid("source build command is empty".to_string())
5131 })?;
5132 let stdout = append_command_file(stdout_path)?;
5133 let stderr = append_command_file(stderr_path)?;
5134 Command::new(program)
5135 .args(args)
5136 .current_dir(working_directory)
5137 .env_clear()
5138 .envs(effective_environment)
5139 .stdout(Stdio::from(stdout))
5140 .stderr(Stdio::from(stderr))
5141 .status()
5142 .map_err(|source| NativeOperatorBuilderError::Io {
5143 path: PathBuf::from(program),
5144 source,
5145 })
5146}
5147
5148fn append_command_file(path: &Path) -> Result<fs::File> {
5149 let mut file = OpenOptions::new()
5150 .append(true)
5151 .open(path)
5152 .map_err(|source| NativeOperatorBuilderError::Io {
5153 path: path.to_path_buf(),
5154 source,
5155 })?;
5156 file.write_all(b"execution-start\n")
5157 .and_then(|()| file.flush())
5158 .map_err(|source| NativeOperatorBuilderError::Io {
5159 path: path.to_path_buf(),
5160 source,
5161 })?;
5162 Ok(file)
5163}
5164
5165fn write_command_stream(path: &Path, stream: &str, argv: &[String], payload: &[u8]) -> Result<()> {
5166 let command =
5167 serde_json::to_string(argv).map_err(|source| NativeOperatorBuilderError::Json {
5168 path: path.to_path_buf(),
5169 source,
5170 })?;
5171 let mut bytes = format!("stream={stream}\nargv={command}\n").into_bytes();
5172 bytes.extend_from_slice(payload);
5173 if !payload.ends_with(b"\n") {
5174 bytes.push(b'\n');
5175 }
5176 fs::write(path, bytes).map_err(|source| NativeOperatorBuilderError::Io {
5177 path: path.to_path_buf(),
5178 source,
5179 })
5180}
5181
5182fn append_command_stream(path: &Path, payload: &[u8]) -> Result<()> {
5183 let mut file = OpenOptions::new()
5184 .append(true)
5185 .open(path)
5186 .map_err(|source| NativeOperatorBuilderError::Io {
5187 path: path.to_path_buf(),
5188 source,
5189 })?;
5190 file.write_all(payload)
5191 .and_then(|()| {
5192 if payload.ends_with(b"\n") {
5193 Ok(())
5194 } else {
5195 file.write_all(b"\n")
5196 }
5197 })
5198 .and_then(|()| file.flush())
5199 .map_err(|source| NativeOperatorBuilderError::Io {
5200 path: path.to_path_buf(),
5201 source,
5202 })
5203}
5204
5205fn reject_source_build<T>(
5206 receipt_path: &Path,
5207 receipt: &mut NativeOperatorSourceBuildReceipt,
5208 reason: String,
5209) -> Result<T> {
5210 receipt.status = NativeOperatorSourceBuildStatus::Reject;
5211 receipt.failure_class = Some(reason.clone());
5212 write_json(receipt_path, receipt)?;
5213 Err(NativeOperatorBuilderError::SourceBuildRejected {
5214 receipt_path: receipt_path.to_path_buf(),
5215 reason,
5216 })
5217}
5218
5219fn architecture_argument(
5220 architecture: NativeOperatorCudaArchitecture,
5221 compute_capability: &str,
5222) -> String {
5223 match architecture {
5224 NativeOperatorCudaArchitecture::DeviceComputeCapability => {
5225 format!("-arch={compute_capability}")
5226 }
5227 NativeOperatorCudaArchitecture::Compute80Ptx => "-arch=compute_80".to_string(),
5228 }
5229}
5230
5231fn validate_compute_capability(value: &str) -> Result<()> {
5232 if value.len() >= 5
5233 && value.starts_with("sm_")
5234 && value[3..].bytes().all(|byte| byte.is_ascii_digit())
5235 {
5236 Ok(())
5237 } else {
5238 Err(NativeOperatorBuilderError::Invalid(
5239 "compute_capability must use sm_<digits> form".to_string(),
5240 ))
5241 }
5242}
5243
5244fn is_git_oid(value: &str) -> bool {
5245 matches!(value.len(), 40 | 64)
5246 && value
5247 .bytes()
5248 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
5249}
5250
5251fn safe_component(value: &str) -> String {
5252 value
5253 .chars()
5254 .map(|character| {
5255 if character.is_ascii_alphanumeric() {
5256 character
5257 } else {
5258 '_'
5259 }
5260 })
5261 .collect()
5262}
5263
5264fn relative_log(logs_dir: &Path, file: &str) -> String {
5265 Path::new("logs")
5266 .join(logs_dir.join(file).file_name().expect("log file name"))
5267 .to_string_lossy()
5268 .replace('\\', "/")
5269}
5270
5271fn unix_ms() -> u64 {
5272 SystemTime::now()
5273 .duration_since(UNIX_EPOCH)
5274 .unwrap_or_default()
5275 .as_millis()
5276 .try_into()
5277 .unwrap_or(u64::MAX)
5278}
5279
5280fn millis(duration: std::time::Duration) -> u64 {
5281 duration.as_millis().try_into().unwrap_or(u64::MAX)
5282}
5283
5284#[cfg(test)]
5285mod tests {
5286 use std::os::unix::fs::{symlink, PermissionsExt};
5287
5288 use super::*;
5289
5290 fn definition() -> NativeOperatorSourceDefinition {
5291 NativeOperatorSourceDefinition {
5292 schema_version: NATIVE_OPERATOR_SOURCE_DEFINITION_SCHEMA_VERSION,
5293 operator: CudaNativeBuildUnit::Marlin.artifact_operator().to_string(),
5294 source_package_kind: "ferrum-native-source-bundle".to_string(),
5295 source_package_revision: "fixture".to_string(),
5296 upstream_sources: vec![NativeOperatorUpstreamSource {
5297 repository: "https://example.invalid/marlin.git".to_string(),
5298 revision: "abc123".to_string(),
5299 license: "Apache-2.0".to_string(),
5300 }],
5301 translation_units: vec!["kernels/marlin.cu".to_string()],
5302 headers: vec!["kernels/marlin.h".to_string()],
5303 dependency_closures: vec![NativeOperatorTranslationUnitDependencies {
5304 translation_unit: "kernels/marlin.cu".to_string(),
5305 headers: vec!["kernels/marlin.h".to_string()],
5306 }],
5307 include_dirs: vec!["kernels".to_string()],
5308 defines: vec!["FERRUM_FIXTURE=1".to_string()],
5309 nvcc_policy: NativeOperatorNvccPolicy {
5310 cpp_standard: NativeOperatorCppStandard::Cpp17,
5311 optimization: NativeOperatorOptimization::O3,
5312 use_fast_math: false,
5313 relaxed_constexpr: false,
5314 extended_lambda: false,
5315 host_position_independent_code: true,
5316 host_default_visibility: false,
5317 },
5318 architecture: NativeOperatorCudaArchitecture::Compute80Ptx,
5319 archive_file: "libmarlin.a".to_string(),
5320 }
5321 }
5322
5323 fn write_fixture(root: &Path) -> (PathBuf, PathBuf) {
5324 let source_root = root.join("source");
5325 fs::create_dir_all(source_root.join("kernels")).unwrap();
5326 fs::create_dir_all(source_root.join("kernels/core")).unwrap();
5327 fs::write(
5328 source_root.join("kernels/marlin.cu"),
5329 "#include \"marlin.h\"\n\
5330 int marlin_cuda(void) { return MARLIN - 1; }\n\
5331 int marlin_cuda_moe(void) { return 0; }\n",
5332 )
5333 .unwrap();
5334 fs::write(source_root.join("kernels/marlin.h"), "#define MARLIN 1\n").unwrap();
5335 let definition_path = root.join("source-definition.json");
5336 write_json(&definition_path, &definition()).unwrap();
5337 (source_root, definition_path)
5338 }
5339
5340 struct FakeCudaToolkit {
5341 root: PathBuf,
5342 nvcc: PathBuf,
5343 ccbin: PathBuf,
5344 empty_host_include_root: PathBuf,
5345 compile_counter: PathBuf,
5346 invocation_counter: PathBuf,
5347 host_compiler_invocation_counter: PathBuf,
5348 host_driver_config: PathBuf,
5349 }
5350
5351 #[derive(Clone, Copy)]
5352 enum FakeDepfileMode {
5353 Valid,
5354 MissingDeclaredHeader,
5355 UndeclaredExternal,
5356 }
5357
5358 fn write_fake_nvcc(root: &Path) -> FakeCudaToolkit {
5359 write_fake_nvcc_with_mode(root, FakeDepfileMode::Valid)
5360 }
5361
5362 fn write_fake_nvcc_with_mode(root: &Path, mode: FakeDepfileMode) -> FakeCudaToolkit {
5363 let toolkit_root = root.join("fake-cuda");
5364 for directory in ["bin/crt", "include", "nvvm/bin", "nvvm/libdevice"] {
5365 fs::create_dir_all(toolkit_root.join(directory)).unwrap();
5366 }
5367 for (relative, contents) in [
5368 ("bin/bin2c", "fake bin2c\n"),
5369 ("bin/crt/link.stub", "fake link stub\n"),
5370 ("bin/cudafe++", "fake cudafe\n"),
5371 ("bin/ptxas", "fake ptxas\n"),
5372 ("bin/fatbinary", "fake fatbinary\n"),
5373 ("bin/nvlink", "fake nvlink\n"),
5374 ("include/cuda.h", "#define CUDA_VERSION 12040\n"),
5375 ("nvvm/bin/cicc", "fake cicc\n"),
5376 ("nvvm/libdevice/libdevice.10.bc", "fake libdevice\n"),
5377 ] {
5378 fs::write(toolkit_root.join(relative), contents).unwrap();
5379 }
5380 let path = toolkit_root.join("bin/nvcc");
5381 let counter = root.join("fake-nvcc-compile-count");
5382 let invocation_counter = root.join("fake-nvcc-invocation-count");
5383 let host_root = root.join("fake-host-toolchain");
5384 let compile_tail = match mode {
5385 FakeDepfileMode::Valid => format!(
5386 "/usr/bin/cc -x c -c \"$src\" -o \"$out\" || exit $?\n\
5387 declared_header=''\n\
5388 case \"$src\" in */marlin.cu) declared_header=' kernels/core/../marlin.h' ;; esac\n\
5389 printf '%s: %s%s %s %s\\n' \"$dep_target\" \"$src\" \"$declared_header\" '{}' '{}' > \"$depfile\"\n",
5390 toolkit_root.join("bin/../include/cuda.h").display(),
5391 host_root.join("include/stddef.h").display(),
5392 ),
5393 FakeDepfileMode::MissingDeclaredHeader => {
5394 "/usr/bin/cc -x c -MMD -MF \"$depfile\" -MT \"$dep_target\" -c \"$src\" -o \"$out\" || exit $?\n\
5395 printf '%s: %s\\n' \"$dep_target\" \"$src\" > \"$depfile\"\n"
5396 .to_string()
5397 }
5398 FakeDepfileMode::UndeclaredExternal => {
5399 "/usr/bin/cc -x c -MMD -MF \"$depfile\" -MT \"$dep_target\" -c \"$src\" -o \"$out\" || exit $?\n\
5400 printf '%s: %s kernels/marlin.h /etc/hosts\\n' \"$dep_target\" \"$src\" > \"$depfile\"\n"
5401 .to_string()
5402 }
5403 };
5404 fs::write(
5405 &path,
5406 format!(
5407 "#!/bin/sh\n\
5408 printf 'invoke:%s\\n' \"$*\" >> '{}'\n\
5409 if [ \"$1\" = \"--version\" ]; then echo 'fake nvcc 12.4'; exit 0; fi\n\
5410 src=''\n\
5411 out=''\n\
5412 depfile=''\n\
5413 dep_target=''\n\
5414 while [ \"$#\" -gt 0 ]; do\n\
5415 case \"$1\" in\n\
5416 -c) src=\"$2\"; shift 2 ;;\n\
5417 -o) out=\"$2\"; shift 2 ;;\n\
5418 -MF) depfile=\"$2\"; shift 2 ;;\n\
5419 -MT) dep_target=\"$2\"; shift 2 ;;\n\
5420 *) shift ;;\n\
5421 esac\n\
5422 done\n\
5423 build_dir=$(dirname \"$(dirname \"$out\")\")\n\
5424 receipt=\"$build_dir/source-build.receipt.json\"\n\
5425 test -s \"$receipt\"\n\
5426 grep -q '\"status\": \"reject\"' \"$receipt\"\n\
5427 grep -q '\"failure_class\": \"build_incomplete\"' \"$receipt\"\n\
5428 printf 'compile\\n' >> '{}'\n\
5429 {}",
5430 invocation_counter.display(),
5431 counter.display(),
5432 compile_tail
5433 ),
5434 )
5435 .unwrap();
5436 let mut permissions = fs::metadata(&path).unwrap().permissions();
5437 permissions.set_mode(0o755);
5438 fs::set_permissions(&path, permissions).unwrap();
5439
5440 fs::create_dir_all(host_root.join("bin")).unwrap();
5441 fs::create_dir_all(host_root.join("include")).unwrap();
5442 let empty_host_include_root = host_root.join("empty-include");
5443 fs::create_dir_all(&empty_host_include_root).unwrap();
5444 fs::write(
5445 host_root.join("include/stddef.h"),
5446 "#define FAKE_SIZE_T 1\n",
5447 )
5448 .unwrap();
5449 for program in HOST_TOOLCHAIN_PROGRAMS {
5450 fs::write(
5451 host_root.join("bin").join(program),
5452 format!("fake host tool {program}\n"),
5453 )
5454 .unwrap();
5455 }
5456 fs::write(
5457 host_root.join("bin/driver.specs"),
5458 "fake host driver configuration\n",
5459 )
5460 .unwrap();
5461 let ccbin = host_root.join("bin/c++");
5462 let host_compiler_invocation_counter = root.join("fake-host-compiler-invocation-count");
5463 let host_driver_config = root.join("fake-host-driver.conf");
5464 fs::write(&host_driver_config, "external driver option v1\n").unwrap();
5465 fs::write(
5466 &ccbin,
5467 format!(
5468 "#!/bin/sh\n\
5469 printf 'invoke:%s\\n' \"$*\" >> '{}'\n\
5470 case \"$1\" in\n\
5471 --version) echo 'fake host compiler 1.0'; exit 0 ;;\n\
5472 -dumpmachine) echo 'x86_64-ferrum-linux-gnu'; exit 0 ;;\n\
5473 -E) echo '#include <...> search starts here:' >&2; echo ' {}' >&2; echo ' {}' >&2; echo 'End of search list.' >&2; exit 0 ;;\n\
5474 -###) test \"$2\" = '-pipe' || exit 3; echo 'fake cc1plus -O2 -x c++' >&2; cat '{}' >&2; exit 0 ;;\n\
5475 -print-prog-name=*) name=${{1#*=}}; echo '{}/bin/'\"$name\"; exit 0 ;;\n\
5476 esac\n\
5477 exit 2\n",
5478 host_compiler_invocation_counter.display(),
5479 host_root.join("include").display(),
5480 empty_host_include_root.display(),
5481 host_driver_config.display(),
5482 host_root.display(),
5483 ),
5484 )
5485 .unwrap();
5486 let mut permissions = fs::metadata(&ccbin).unwrap().permissions();
5487 permissions.set_mode(0o755);
5488 fs::set_permissions(&ccbin, permissions).unwrap();
5489 FakeCudaToolkit {
5490 root: toolkit_root,
5491 nvcc: path,
5492 ccbin,
5493 empty_host_include_root,
5494 compile_counter: counter,
5495 invocation_counter,
5496 host_compiler_invocation_counter,
5497 host_driver_config,
5498 }
5499 }
5500
5501 #[test]
5502 fn object_file_names_preserve_lexical_order_past_one_hundred_units() {
5503 let translation_unit = NativeOperatorSourceFileLock {
5504 path: "kernels/unit.cu".to_string(),
5505 sha256: "a".repeat(64),
5506 };
5507
5508 assert!(object_file_name(99, &translation_unit) < object_file_name(100, &translation_unit));
5509 }
5510
5511 #[test]
5512 fn portable_depfile_serialization_round_trips_restricted_make_words() {
5513 let target = "/tmp/build path/object:name#$value.o";
5514 let dependencies = vec![
5515 "kernels/header name.h".to_string(),
5516 "/tmp/tool chain/header:name#$value.h".to_string(),
5517 ];
5518
5519 let raw = serialize_portable_depfile(target, &dependencies).unwrap();
5520 let text = std::str::from_utf8(&raw).unwrap();
5521 let (parsed_target, parsed_dependencies) =
5522 parse_make_depfile(text, Path::new("<round-trip>")).unwrap();
5523
5524 assert_eq!(parsed_target, target);
5525 assert_eq!(parsed_dependencies, dependencies);
5526 assert!(text.contains("\\ "));
5527 assert!(text.contains("\\:"));
5528 assert!(text.contains("\\#"));
5529 assert!(text.contains("\\$"));
5530 }
5531
5532 #[test]
5533 fn source_depfile_paths_lexically_normalize_without_escaping_working_directory() {
5534 assert_eq!(
5535 normalize_portable_relative_path(Path::new(
5536 "kernels/vllm_marlin_moe/core/../vllm_torch_shim.h"
5537 ))
5538 .unwrap(),
5539 "kernels/vllm_marlin_moe/vllm_torch_shim.h"
5540 );
5541 for path in ["../outside.h", "kernels/../../outside.h"] {
5542 let error = normalize_portable_relative_path(Path::new(path)).unwrap_err();
5543 assert!(error.to_string().contains("escapes its working directory"));
5544 }
5545 let error = normalize_portable_relative_path(Path::new("kernels\\outside.h")).unwrap_err();
5546 assert!(error.to_string().contains("relative POSIX path"));
5547 }
5548
5549 #[test]
5550 fn host_include_probe_preserves_search_order_and_deduplicates_first_occurrence() {
5551 let root = tempfile::tempdir().unwrap();
5552 let first = root.path().join("first include");
5553 let second = root.path().join("second include");
5554 fs::create_dir_all(&first).unwrap();
5555 fs::create_dir_all(&second).unwrap();
5556 let raw = format!(
5557 "#include <...> search starts here:\n {}\n {}\n {}\nEnd of search list.\n",
5558 second.display(),
5559 first.display(),
5560 second.display()
5561 );
5562
5563 let roots = parse_host_compiler_include_roots(&raw, Path::new("/fake/compiler")).unwrap();
5564
5565 assert_eq!(
5566 roots,
5567 [second.display().to_string(), first.display().to_string(),]
5568 );
5569 }
5570
5571 #[test]
5572 fn successful_host_probe_may_close_stdin_before_parent_finishes_writing() {
5573 let root = tempfile::tempdir().unwrap();
5574 let compiler = root.path().join("successful-probe");
5575 fs::write(
5576 &compiler,
5577 "#!/bin/sh\nexec 0<&-\nprintf 'probe complete\\n'\nexit 0\n",
5578 )
5579 .unwrap();
5580 let mut permissions = fs::metadata(&compiler).unwrap().permissions();
5581 permissions.set_mode(0o755);
5582 fs::set_permissions(&compiler, permissions).unwrap();
5583 let environment = BTreeMap::from([
5584 ("LANG".to_string(), "C".to_string()),
5585 ("LC_ALL".to_string(), "C".to_string()),
5586 ("TZ".to_string(), "UTC".to_string()),
5587 ]);
5588
5589 let output =
5590 host_compiler_raw_output(&compiler, &[], &vec![b'x'; 1024 * 1024], &environment)
5591 .unwrap();
5592
5593 assert!(output.status.success());
5594 assert_eq!(output.stdout, b"probe complete\n");
5595 }
5596
5597 #[test]
5598 fn host_program_resolution_and_effective_path_support_spaces() {
5599 let root = tempfile::tempdir().unwrap();
5600 let tool_dir = root.path().join("tool chain");
5601 fs::create_dir_all(&tool_dir).unwrap();
5602 let helper = tool_dir.join("cc helper");
5603 fs::write(&helper, "fixture\n").unwrap();
5604 let path = std::env::join_paths([tool_dir.clone()])
5605 .unwrap()
5606 .into_string()
5607 .unwrap();
5608 let environment = BTreeMap::from([("PATH".to_string(), path)]);
5609
5610 assert_eq!(
5611 resolve_host_program("cc helper", &environment).unwrap(),
5612 Some(helper)
5613 );
5614
5615 let nvcc = tool_dir.join("nvcc");
5616 let ccbin = tool_dir.join("c++");
5617 let ar = tool_dir.join("ar");
5618 let effective = effective_environment_for_tool_paths([
5619 nvcc.to_str().unwrap(),
5620 ccbin.to_str().unwrap(),
5621 ar.to_str().unwrap(),
5622 ])
5623 .unwrap();
5624 assert!(
5625 std::env::split_paths(std::ffi::OsStr::new(&effective["PATH"]))
5626 .any(|entry| entry == tool_dir)
5627 );
5628 }
5629
5630 #[test]
5631 fn toolchain_dependency_scope_rejects_cross_domain_aliases() {
5632 let absolute = "/toolchain/include/shared.h".to_string();
5633 let mut dependencies = BTreeMap::new();
5634 insert_toolchain_dependency(
5635 &mut dependencies,
5636 absolute.clone(),
5637 NativeOperatorObservedDependency {
5638 domain: NativeOperatorDependencyDomain::BackendToolchain,
5639 path: "include/shared.h".to_string(),
5640 sha256: "a".repeat(64),
5641 },
5642 )
5643 .unwrap();
5644
5645 let error = insert_toolchain_dependency(
5646 &mut dependencies,
5647 absolute,
5648 NativeOperatorObservedDependency {
5649 domain: NativeOperatorDependencyDomain::HostToolchain,
5650 path: "/toolchain/include/shared.h".to_string(),
5651 sha256: "a".repeat(64),
5652 },
5653 )
5654 .unwrap_err();
5655
5656 assert!(error
5657 .to_string()
5658 .contains("toolchain manifests ambiguously own dependency path"));
5659 }
5660
5661 #[test]
5662 fn locks_self_contained_translation_unit_without_auxiliary_inputs() {
5663 let root = tempfile::tempdir().unwrap();
5664 let (source_root, _) = write_fixture(root.path());
5665 let mut definition = definition();
5666 definition.headers.clear();
5667 definition.dependency_closures[0].headers.clear();
5668 definition.include_dirs.clear();
5669 let definition_path = root.path().join("self-contained-definition.json");
5670 write_json(&definition_path, &definition).unwrap();
5671 let plan_path = root.path().join("source-build.plan.json");
5672
5673 let plan =
5674 lock_native_operator_source_definition(&definition_path, &source_root, &plan_path)
5675 .unwrap();
5676
5677 assert!(plan.headers.is_empty());
5678 assert!(plan.include_dirs.is_empty());
5679 assert_eq!(plan.translation_units.len(), 1);
5680 }
5681
5682 #[test]
5683 fn locks_files_and_rejects_source_drift_before_rendering_commands() {
5684 let root = tempfile::tempdir().unwrap();
5685 let (source_root, definition_path) = write_fixture(root.path());
5686 let plan_path = root.path().join("source-build.plan.json");
5687 lock_native_operator_source_definition(&definition_path, &source_root, &plan_path).unwrap();
5688 fs::write(source_root.join("kernels/marlin.h"), "#define MARLIN 2\n").unwrap();
5689
5690 let error = run_native_operator_source_build(&NativeOperatorSourceBuildRequest {
5691 plan_path,
5692 source_root,
5693 output_dir: root.path().join("build"),
5694 compute_capability: "sm_89".to_string(),
5695 builder_sha: "7".repeat(40),
5696 nvcc_path: PathBuf::from("/missing/nvcc"),
5697 cuda_toolkit_root: PathBuf::from("/missing/cuda"),
5698 ccbin_path: PathBuf::from("/missing/c++"),
5699 ar_path: PathBuf::from("/missing/ar"),
5700 nvcc_threads: 4,
5701 object_cache_dir: root.path().join("object-cache"),
5702 plan_only: true,
5703 })
5704 .unwrap_err();
5705
5706 assert!(error.to_string().contains("locked source drift"));
5707 assert!(!root.path().join("build").exists());
5708 }
5709
5710 #[test]
5711 fn plan_only_records_exact_commands_without_requiring_cuda_tools() {
5712 let root = tempfile::tempdir().unwrap();
5713 let (source_root, definition_path) = write_fixture(root.path());
5714 let plan_path = root.path().join("source-build.plan.json");
5715 lock_native_operator_source_definition(&definition_path, &source_root, &plan_path).unwrap();
5716
5717 let receipt = run_native_operator_source_build(&NativeOperatorSourceBuildRequest {
5718 plan_path,
5719 source_root,
5720 output_dir: root.path().join("plan"),
5721 compute_capability: "sm_89".to_string(),
5722 builder_sha: "7".repeat(40),
5723 nvcc_path: PathBuf::from("/missing/nvcc"),
5724 cuda_toolkit_root: PathBuf::from("/missing/cuda"),
5725 ccbin_path: PathBuf::from("/missing/c++"),
5726 ar_path: PathBuf::from("/missing/ar"),
5727 nvcc_threads: 256,
5728 object_cache_dir: root.path().join("object-cache"),
5729 plan_only: true,
5730 })
5731 .unwrap();
5732
5733 assert_eq!(receipt.status, NativeOperatorSourceBuildStatus::Plan);
5734 assert_eq!(receipt.architecture_argument, "-arch=compute_80");
5735 assert_eq!(receipt.commands.len(), 2);
5736 assert!(receipt.commands[0]
5737 .argv
5738 .windows(2)
5739 .any(|pair| pair == ["--threads", "256"]));
5740 assert!(receipt.toolchain.is_none());
5741 assert!(receipt.commands.iter().all(|command| {
5742 [
5743 root.path().join("plan").join(&command.stdout_log),
5744 root.path().join("plan").join(&command.stderr_log),
5745 ]
5746 .iter()
5747 .all(|path| fs::metadata(path).is_ok_and(|metadata| metadata.len() > 0))
5748 }));
5749 }
5750
5751 #[test]
5752 fn missing_toolchain_writes_reject_receipt_before_any_compiler_spawn() {
5753 let root = tempfile::tempdir().unwrap();
5754 let (source_root, definition_path) = write_fixture(root.path());
5755 let plan_path = root.path().join("source-build.plan.json");
5756 lock_native_operator_source_definition(&definition_path, &source_root, &plan_path).unwrap();
5757 let output_dir = root.path().join("toolchain-reject");
5758
5759 let error = run_native_operator_source_build(&NativeOperatorSourceBuildRequest {
5760 plan_path,
5761 source_root,
5762 output_dir: output_dir.clone(),
5763 compute_capability: "sm_89".to_string(),
5764 builder_sha: "7".repeat(40),
5765 nvcc_path: PathBuf::from("/missing/nvcc"),
5766 cuda_toolkit_root: PathBuf::from("/missing/cuda"),
5767 ccbin_path: PathBuf::from("/missing/c++"),
5768 ar_path: PathBuf::from("/missing/ar"),
5769 nvcc_threads: 4,
5770 object_cache_dir: root.path().join("object-cache"),
5771 plan_only: false,
5772 })
5773 .unwrap_err();
5774
5775 assert!(matches!(
5776 error,
5777 NativeOperatorBuilderError::SourceBuildRejected { .. }
5778 ));
5779 let receipt: NativeOperatorSourceBuildReceipt =
5780 read_json(&output_dir.join("source-build.receipt.json")).unwrap();
5781 assert_eq!(receipt.status, NativeOperatorSourceBuildStatus::Reject);
5782 assert!(receipt
5783 .failure_class
5784 .as_deref()
5785 .is_some_and(|failure| failure.starts_with("toolchain_preflight_failed:")));
5786 assert!(receipt.commands.iter().all(|command| {
5787 [
5788 output_dir.join(&command.stdout_log),
5789 output_dir.join(&command.stderr_log),
5790 ]
5791 .iter()
5792 .all(|path| fs::metadata(path).is_ok_and(|metadata| metadata.len() > 0))
5793 }));
5794 }
5795
5796 #[test]
5797 fn rejects_incomplete_or_undeclared_depfiles_before_cache_publish() {
5798 for (name, mode) in [
5799 ("missing-header", FakeDepfileMode::MissingDeclaredHeader),
5800 ("undeclared-external", FakeDepfileMode::UndeclaredExternal),
5801 ] {
5802 let root = tempfile::tempdir().unwrap();
5803 let (source_root, definition_path) = write_fixture(root.path());
5804 let plan_path = root.path().join("source-build.plan.json");
5805 lock_native_operator_source_definition(&definition_path, &source_root, &plan_path)
5806 .unwrap();
5807 let fake_cuda = write_fake_nvcc_with_mode(root.path(), mode);
5808 let output_dir = root.path().join(name);
5809 let object_cache_dir = root.path().join("object-cache");
5810
5811 let error = run_native_operator_source_build(&NativeOperatorSourceBuildRequest {
5812 plan_path,
5813 source_root,
5814 output_dir: output_dir.clone(),
5815 compute_capability: "sm_89".to_string(),
5816 builder_sha: "7".repeat(40),
5817 nvcc_path: fake_cuda.nvcc,
5818 cuda_toolkit_root: fake_cuda.root,
5819 ccbin_path: fake_cuda.ccbin.clone(),
5820 ar_path: PathBuf::from("/usr/bin/ar"),
5821 nvcc_threads: 2,
5822 object_cache_dir: object_cache_dir.clone(),
5823 plan_only: false,
5824 })
5825 .unwrap_err();
5826
5827 assert!(matches!(
5828 error,
5829 NativeOperatorBuilderError::SourceBuildRejected { .. }
5830 ));
5831 let receipt: NativeOperatorSourceBuildReceipt =
5832 read_json(&output_dir.join("source-build.receipt.json")).unwrap();
5833 assert!(receipt
5834 .failure_class
5835 .as_deref()
5836 .is_some_and(|failure| failure.starts_with("dependency_validation_failed:")));
5837 assert_eq!(
5838 receipt.commands[0].object_cache_status,
5839 Some(NativeOperatorSourceObjectCacheStatus::Rejected)
5840 );
5841 assert!(receipt.commands[0].object_cache_entry.is_none());
5842 assert!(
5843 fs::read_dir(object_cache_dir).unwrap().all(|entry| {
5844 entry
5845 .is_ok_and(|entry| entry.file_name() == ".host-toolchains")
5846 }),
5847 "invalid dependency evidence may cache toolchain inventory but must not publish an object"
5848 );
5849 }
5850 }
5851
5852 #[test]
5853 fn rejects_tampered_cached_depfile_proof_before_compiler_start() {
5854 let root = tempfile::tempdir().unwrap();
5855 let (source_root, definition_path) = write_fixture(root.path());
5856 let plan_path = root.path().join("source-build.plan.json");
5857 lock_native_operator_source_definition(&definition_path, &source_root, &plan_path).unwrap();
5858 let fake_cuda = write_fake_nvcc(root.path());
5859 let object_cache_dir = root.path().join("object-cache");
5860 let request = |name: &str| NativeOperatorSourceBuildRequest {
5861 plan_path: plan_path.clone(),
5862 source_root: source_root.clone(),
5863 output_dir: root.path().join(name),
5864 compute_capability: "sm_89".to_string(),
5865 builder_sha: "7".repeat(40),
5866 nvcc_path: fake_cuda.nvcc.clone(),
5867 cuda_toolkit_root: fake_cuda.root.clone(),
5868 ccbin_path: fake_cuda.ccbin.clone(),
5869 ar_path: PathBuf::from("/usr/bin/ar"),
5870 nvcc_threads: 2,
5871 object_cache_dir: object_cache_dir.clone(),
5872 plan_only: false,
5873 };
5874
5875 let cold = run_native_operator_source_build(&request("cold")).unwrap();
5876 let cache_entry = PathBuf::from(
5877 cold.commands[0]
5878 .object_cache_entry
5879 .as_deref()
5880 .expect("published object records its cache entry"),
5881 );
5882 let proof_dir = cache_entry.join("dependency-proof");
5883 fs::write(
5884 proof_dir.join("dependency.d"),
5885 "forged.o: kernels/marlin.cu\n",
5886 )
5887 .unwrap();
5888
5889 let error = run_native_operator_source_build(&request("tampered")).unwrap_err();
5890 assert!(matches!(
5891 error,
5892 NativeOperatorBuilderError::SourceBuildRejected { .. }
5893 ));
5894 let receipt: NativeOperatorSourceBuildReceipt =
5895 read_json(&root.path().join("tampered/source-build.receipt.json")).unwrap();
5896 assert!(receipt
5897 .failure_class
5898 .as_deref()
5899 .is_some_and(|failure| failure.starts_with("cached_dependency_proof_failed:")));
5900 assert_eq!(
5901 fs::read_to_string(&fake_cuda.compile_counter)
5902 .unwrap()
5903 .lines()
5904 .count(),
5905 1,
5906 "tampered cache proof must reject before another compiler starts"
5907 );
5908 }
5909
5910 #[test]
5911 fn rejects_tampered_cached_compiler_depfile_before_compiler_start() {
5912 let root = tempfile::tempdir().unwrap();
5913 let (source_root, definition_path) = write_fixture(root.path());
5914 let plan_path = root.path().join("source-build.plan.json");
5915 lock_native_operator_source_definition(&definition_path, &source_root, &plan_path).unwrap();
5916 let fake_cuda = write_fake_nvcc(root.path());
5917 let object_cache_dir = root.path().join("object-cache");
5918 let request = |name: &str| NativeOperatorSourceBuildRequest {
5919 plan_path: plan_path.clone(),
5920 source_root: source_root.clone(),
5921 output_dir: root.path().join(name),
5922 compute_capability: "sm_89".to_string(),
5923 builder_sha: "7".repeat(40),
5924 nvcc_path: fake_cuda.nvcc.clone(),
5925 cuda_toolkit_root: fake_cuda.root.clone(),
5926 ccbin_path: fake_cuda.ccbin.clone(),
5927 ar_path: PathBuf::from("/usr/bin/ar"),
5928 nvcc_threads: 2,
5929 object_cache_dir: object_cache_dir.clone(),
5930 plan_only: false,
5931 };
5932
5933 let cold = run_native_operator_source_build(&request("cold")).unwrap();
5934 let cache_entry = PathBuf::from(
5935 cold.commands[0]
5936 .object_cache_entry
5937 .as_deref()
5938 .expect("published object records its cache entry"),
5939 );
5940 let proof_path = cache_entry.join("dependency-proof/proof.json");
5941 let compiler_depfile_path = cache_entry.join("dependency-proof/compiler-dependency.raw.d");
5942 let mut proof: NativeOperatorObjectDependencyProof = read_json(&proof_path).unwrap();
5943 let backend_binding = proof
5944 .depfile_bindings
5945 .iter_mut()
5946 .find(|binding| {
5947 binding.dependency.domain == NativeOperatorDependencyDomain::BackendToolchain
5948 })
5949 .expect("fixture depfile contains a backend toolchain dependency");
5950 let original_producer = backend_binding.producer_path.clone();
5951 let forged_producer =
5952 original_producer.replace("/bin/../include/cuda.h", "/forged/include/cuda.h");
5953 assert_ne!(forged_producer, original_producer);
5954 backend_binding.producer_path = forged_producer.clone();
5955 let compiler_raw = fs::read_to_string(&compiler_depfile_path)
5956 .unwrap()
5957 .replace(&original_producer, &forged_producer);
5958 proof.compiler_depfile_sha256 = sha256_bytes(compiler_raw.as_bytes());
5959 fs::write(&compiler_depfile_path, compiler_raw).unwrap();
5960 write_json(&proof_path, &proof).unwrap();
5961
5962 let error = run_native_operator_source_build(&request("tampered")).unwrap_err();
5963 assert!(matches!(
5964 error,
5965 NativeOperatorBuilderError::SourceBuildRejected { .. }
5966 ));
5967 let receipt: NativeOperatorSourceBuildReceipt =
5968 read_json(&root.path().join("tampered/source-build.receipt.json")).unwrap();
5969 assert!(receipt
5970 .failure_class
5971 .as_deref()
5972 .is_some_and(|failure| failure.starts_with("cached_dependency_proof_failed:")));
5973 assert_eq!(
5974 fs::read_to_string(&fake_cuda.compile_counter)
5975 .unwrap()
5976 .lines()
5977 .count(),
5978 1,
5979 "tampered compiler depfile must reject before another compiler starts"
5980 );
5981 }
5982
5983 #[test]
5984 fn rejects_partial_cached_dependency_proof_before_compiler_start() {
5985 let root = tempfile::tempdir().unwrap();
5986 let (source_root, definition_path) = write_fixture(root.path());
5987 let plan_path = root.path().join("source-build.plan.json");
5988 lock_native_operator_source_definition(&definition_path, &source_root, &plan_path).unwrap();
5989 let fake_cuda = write_fake_nvcc(root.path());
5990 let object_cache_dir = root.path().join("object-cache");
5991 let request = |name: &str| NativeOperatorSourceBuildRequest {
5992 plan_path: plan_path.clone(),
5993 source_root: source_root.clone(),
5994 output_dir: root.path().join(name),
5995 compute_capability: "sm_89".to_string(),
5996 builder_sha: "7".repeat(40),
5997 nvcc_path: fake_cuda.nvcc.clone(),
5998 cuda_toolkit_root: fake_cuda.root.clone(),
5999 ccbin_path: fake_cuda.ccbin.clone(),
6000 ar_path: PathBuf::from("/usr/bin/ar"),
6001 nvcc_threads: 2,
6002 object_cache_dir: object_cache_dir.clone(),
6003 plan_only: false,
6004 };
6005
6006 let cold = run_native_operator_source_build(&request("cold")).unwrap();
6007 let cache_entry = PathBuf::from(
6008 cold.commands[0]
6009 .object_cache_entry
6010 .as_deref()
6011 .expect("published object records its cache entry"),
6012 );
6013 fs::remove_file(cache_entry.join("dependency-proof/proof.json")).unwrap();
6014
6015 let error = run_native_operator_source_build(&request("partial")).unwrap_err();
6016 assert!(matches!(
6017 error,
6018 NativeOperatorBuilderError::SourceBuildRejected { .. }
6019 ));
6020 let receipt: NativeOperatorSourceBuildReceipt =
6021 read_json(&root.path().join("partial/source-build.receipt.json")).unwrap();
6022 assert!(receipt
6023 .failure_class
6024 .as_deref()
6025 .is_some_and(|failure| failure.starts_with("cached_dependency_proof_failed:")));
6026 assert_eq!(
6027 fs::read_to_string(&fake_cuda.compile_counter)
6028 .unwrap()
6029 .lines()
6030 .count(),
6031 1,
6032 "partial proof publication must reject before another compiler starts"
6033 );
6034 }
6035
6036 #[test]
6037 fn rejects_tampered_cached_dependency_identity_before_compiler_start() {
6038 let root = tempfile::tempdir().unwrap();
6039 let (source_root, definition_path) = write_fixture(root.path());
6040 let plan_path = root.path().join("source-build.plan.json");
6041 lock_native_operator_source_definition(&definition_path, &source_root, &plan_path).unwrap();
6042 let fake_cuda = write_fake_nvcc(root.path());
6043 let object_cache_dir = root.path().join("object-cache");
6044 let request = |name: &str| NativeOperatorSourceBuildRequest {
6045 plan_path: plan_path.clone(),
6046 source_root: source_root.clone(),
6047 output_dir: root.path().join(name),
6048 compute_capability: "sm_89".to_string(),
6049 builder_sha: "7".repeat(40),
6050 nvcc_path: fake_cuda.nvcc.clone(),
6051 cuda_toolkit_root: fake_cuda.root.clone(),
6052 ccbin_path: fake_cuda.ccbin.clone(),
6053 ar_path: PathBuf::from("/usr/bin/ar"),
6054 nvcc_threads: 2,
6055 object_cache_dir: object_cache_dir.clone(),
6056 plan_only: false,
6057 };
6058
6059 let cold = run_native_operator_source_build(&request("cold")).unwrap();
6060 let cache_entry = PathBuf::from(
6061 cold.commands[0]
6062 .object_cache_entry
6063 .as_deref()
6064 .expect("published object records its cache entry"),
6065 );
6066 let proof_path = cache_entry.join("dependency-proof/proof.json");
6067 let mut proof: NativeOperatorObjectDependencyProof = read_json(&proof_path).unwrap();
6068 let backend_dependency = proof
6069 .observed_dependencies
6070 .iter_mut()
6071 .find(|dependency| {
6072 dependency.domain == NativeOperatorDependencyDomain::BackendToolchain
6073 })
6074 .expect("fixture depfile contains a backend toolchain dependency");
6075 backend_dependency.sha256 = "b".repeat(64);
6076 proof.dependency_set_sha256 =
6077 observed_dependency_set_sha256(&proof.observed_dependencies).unwrap();
6078 write_json(&proof_path, &proof).unwrap();
6079
6080 let error = run_native_operator_source_build(&request("tampered")).unwrap_err();
6081 assert!(matches!(
6082 error,
6083 NativeOperatorBuilderError::SourceBuildRejected { .. }
6084 ));
6085 let receipt: NativeOperatorSourceBuildReceipt =
6086 read_json(&root.path().join("tampered/source-build.receipt.json")).unwrap();
6087 assert!(receipt
6088 .failure_class
6089 .as_deref()
6090 .is_some_and(|failure| failure.starts_with("cached_dependency_proof_failed:")));
6091 assert_eq!(
6092 fs::read_to_string(&fake_cuda.compile_counter)
6093 .unwrap()
6094 .lines()
6095 .count(),
6096 1,
6097 "tampered typed dependency identity must reject before another compiler starts"
6098 );
6099 }
6100
6101 #[test]
6102 fn compiler_inputs_invalidate_object_cache_without_hidden_probe_hits() {
6103 let root = tempfile::tempdir().unwrap();
6104 let (source_root, definition_path) = write_fixture(root.path());
6105 let plan_path = root.path().join("source-build.plan.json");
6106 lock_native_operator_source_definition(&definition_path, &source_root, &plan_path).unwrap();
6107 let fake_cuda = write_fake_nvcc(root.path());
6108 let object_cache_dir = root.path().join("object-cache");
6109 let run_build = |name: &str| {
6110 run_native_operator_source_build(&NativeOperatorSourceBuildRequest {
6111 plan_path: plan_path.clone(),
6112 source_root: source_root.clone(),
6113 output_dir: root.path().join(name),
6114 compute_capability: "sm_89".to_string(),
6115 builder_sha: "7".repeat(40),
6116 nvcc_path: fake_cuda.nvcc.clone(),
6117 cuda_toolkit_root: fake_cuda.root.clone(),
6118 ccbin_path: fake_cuda.ccbin.clone(),
6119 ar_path: PathBuf::from("/usr/bin/ar"),
6120 nvcc_threads: 2,
6121 object_cache_dir: object_cache_dir.clone(),
6122 plan_only: false,
6123 })
6124 .unwrap()
6125 };
6126
6127 assert_eq!(run_build("cold").compiled_translation_units.len(), 1);
6128 assert_eq!(run_build("hit").cache_hit_translation_units.len(), 1);
6129 for (index, relative) in [
6130 "bin/ptxas",
6131 "include/cuda.h",
6132 "nvvm/libdevice/libdevice.10.bc",
6133 ]
6134 .iter()
6135 .enumerate()
6136 {
6137 let path = fake_cuda.root.join(relative);
6138 let mut contents = fs::read_to_string(&path).unwrap();
6139 contents.push_str(&format!("mutation-{index}\n"));
6140 fs::write(path, contents).unwrap();
6141 let receipt = run_build(&format!("mutation-{index}"));
6142 assert_eq!(receipt.compiled_translation_units, ["kernels/marlin.cu"]);
6143 assert!(receipt.cache_hit_translation_units.is_empty());
6144 }
6145 let host_root = fake_cuda
6146 .ccbin
6147 .parent()
6148 .and_then(Path::parent)
6149 .unwrap()
6150 .to_path_buf();
6151 for (index, relative) in ["include/stddef.h", "bin/cc1plus", "bin/driver.specs"]
6152 .iter()
6153 .enumerate()
6154 {
6155 let path = host_root.join(relative);
6156 let mut contents = fs::read_to_string(&path).unwrap();
6157 contents.push_str(&format!("host-mutation-{index}\n"));
6158 fs::write(path, contents).unwrap();
6159 let receipt = run_build(&format!("host-mutation-{index}"));
6160 assert_eq!(receipt.compiled_translation_units, ["kernels/marlin.cu"]);
6161 assert!(receipt.cache_hit_translation_units.is_empty());
6162 }
6163 fs::write(&fake_cuda.host_driver_config, "external driver option v2\n").unwrap();
6164 let external_config_receipt = run_build("external-driver-config-mutation");
6165 assert_eq!(
6166 external_config_receipt.compiled_translation_units,
6167 ["kernels/marlin.cu"]
6168 );
6169 assert!(external_config_receipt
6170 .cache_hit_translation_units
6171 .is_empty());
6172 assert_eq!(
6173 fs::read_to_string(&fake_cuda.compile_counter)
6174 .unwrap()
6175 .lines()
6176 .count(),
6177 8
6178 );
6179 assert_eq!(
6180 fs::read_to_string(&fake_cuda.invocation_counter)
6181 .unwrap()
6182 .lines()
6183 .count(),
6184 16,
6185 "each cache miss invokes one nvcc version probe and one compile; the full hit invokes zero"
6186 );
6187 }
6188
6189 #[test]
6190 fn empty_host_include_root_is_locked_and_new_header_invalidates_object_cache() {
6191 let root = tempfile::tempdir().unwrap();
6192 let (source_root, definition_path) = write_fixture(root.path());
6193 let plan_path = root.path().join("source-build.plan.json");
6194 lock_native_operator_source_definition(&definition_path, &source_root, &plan_path).unwrap();
6195 let fake_cuda = write_fake_nvcc(root.path());
6196 let object_cache_dir = root.path().join("object-cache");
6197 let run_build = |name: &str| {
6198 run_native_operator_source_build(&NativeOperatorSourceBuildRequest {
6199 plan_path: plan_path.clone(),
6200 source_root: source_root.clone(),
6201 output_dir: root.path().join(name),
6202 compute_capability: "sm_89".to_string(),
6203 builder_sha: "7".repeat(40),
6204 nvcc_path: fake_cuda.nvcc.clone(),
6205 cuda_toolkit_root: fake_cuda.root.clone(),
6206 ccbin_path: fake_cuda.ccbin.clone(),
6207 ar_path: PathBuf::from("/usr/bin/ar"),
6208 nvcc_threads: 2,
6209 object_cache_dir: object_cache_dir.clone(),
6210 plan_only: false,
6211 })
6212 .unwrap()
6213 };
6214
6215 assert_eq!(
6216 fs::read_dir(&fake_cuda.empty_host_include_root)
6217 .unwrap()
6218 .count(),
6219 0
6220 );
6221 let cold = run_build("cold-empty-root");
6222 assert_eq!(cold.compiled_translation_units, ["kernels/marlin.cu"]);
6223 let cold_manifest: NativeOperatorHostToolchainManifest = read_json(
6224 &root
6225 .path()
6226 .join("cold-empty-root/toolchain/host-static-manifest.json"),
6227 )
6228 .unwrap();
6229 let empty_root = fake_cuda.empty_host_include_root.display().to_string();
6230 assert!(cold_manifest.include_roots.contains(&empty_root));
6231 assert!(!cold_manifest
6232 .files
6233 .iter()
6234 .any(|file| Path::new(&file.logical_path).starts_with(&empty_root)));
6235
6236 let hit = run_build("unchanged-empty-root");
6237 assert!(hit.compiled_translation_units.is_empty());
6238 assert_eq!(hit.cache_hit_translation_units, ["kernels/marlin.cu"]);
6239 assert!(!hit.commands[0].compiler_executed);
6240
6241 let late_header = fake_cuda.empty_host_include_root.join("late-header.h");
6242 fs::write(&late_header, "#define LATE_HEADER 1\n").unwrap();
6243 let changed = run_build("populated-root");
6244 assert_eq!(changed.compiled_translation_units, ["kernels/marlin.cu"]);
6245 assert!(changed.cache_hit_translation_units.is_empty());
6246 let changed_manifest: NativeOperatorHostToolchainManifest = read_json(
6247 &root
6248 .path()
6249 .join("populated-root/toolchain/host-static-manifest.json"),
6250 )
6251 .unwrap();
6252 assert!(changed_manifest.files.iter().any(|file| {
6253 file.logical_path == late_header.display().to_string()
6254 && file.resolved_path == late_header.canonicalize().unwrap().display().to_string()
6255 }));
6256 assert_eq!(
6257 fs::read_to_string(&fake_cuda.compile_counter)
6258 .unwrap()
6259 .lines()
6260 .count(),
6261 2
6262 );
6263 assert_eq!(
6264 fs::read_to_string(&fake_cuda.invocation_counter)
6265 .unwrap()
6266 .lines()
6267 .count(),
6268 4,
6269 "the unchanged empty root is an nvcc-free hit; adding a header forces one probe and compile"
6270 );
6271 }
6272
6273 #[test]
6274 fn cuda_toolkit_manifest_accepts_internal_symlink_directories_and_rejects_escapes() {
6275 let root = tempfile::tempdir().unwrap();
6276 let fake_cuda = write_fake_nvcc(root.path());
6277 let internal_target = fake_cuda.root.join("targets/headers");
6278 fs::create_dir_all(&internal_target).unwrap();
6279 fs::write(internal_target.join("linked.h"), "#define LINKED 1\n").unwrap();
6280 let internal_link = fake_cuda.root.join("include/linked");
6281 symlink(&internal_target, &internal_link).unwrap();
6282
6283 let manifest = build_cuda_toolkit_manifest(&fake_cuda.root).unwrap();
6284 assert!(manifest.entries.iter().any(|entry| {
6285 entry.logical_path == "include/linked/linked.h"
6286 && entry.resolved_path == "targets/headers/linked.h"
6287 }));
6288
6289 fs::remove_file(&internal_link).unwrap();
6290 symlink("/etc", &internal_link).unwrap();
6291 let error = build_cuda_toolkit_manifest(&fake_cuda.root).unwrap_err();
6292 assert!(error.to_string().contains("escapes its canonical root"));
6293 }
6294
6295 #[test]
6296 fn bounded_fixture_build_writes_pass_receipt_and_archive_hash() {
6297 let root = tempfile::tempdir().unwrap();
6298 let (source_root, definition_path) = write_fixture(root.path());
6299 let plan_path = root.path().join("source-build.plan.json");
6300 lock_native_operator_source_definition(&definition_path, &source_root, &plan_path).unwrap();
6301 let output_dir = root.path().join("build");
6302 let fake_cuda = write_fake_nvcc(root.path());
6303 let object_cache_dir = root.path().join("object-cache");
6304
6305 let receipt = run_native_operator_source_build(&NativeOperatorSourceBuildRequest {
6306 plan_path: plan_path.clone(),
6307 source_root: source_root.clone(),
6308 output_dir: output_dir.clone(),
6309 compute_capability: "sm_89".to_string(),
6310 builder_sha: "7".repeat(40),
6311 nvcc_path: fake_cuda.nvcc.clone(),
6312 cuda_toolkit_root: fake_cuda.root.clone(),
6313 ccbin_path: fake_cuda.ccbin.clone(),
6314 ar_path: PathBuf::from("/usr/bin/ar"),
6315 nvcc_threads: 2,
6316 object_cache_dir: object_cache_dir.clone(),
6317 plan_only: false,
6318 })
6319 .unwrap();
6320
6321 assert_eq!(receipt.status, NativeOperatorSourceBuildStatus::Pass);
6322 let static_toolchain = &receipt
6323 .toolchain
6324 .as_ref()
6325 .expect("completed build records toolchain identity")
6326 .static_identity;
6327 assert_eq!(static_toolchain.backend, NativeOperatorBackend::Cuda);
6328 assert_eq!(
6329 static_toolchain.compiler_driver,
6330 NativeOperatorSourceCompilerDriver::CudaNvcc
6331 );
6332 assert!(is_sha256_digest(receipt.archive_sha256.as_deref().unwrap()));
6333 assert!(output_dir.join("libmarlin.a").is_file());
6334 assert!(output_dir.join("source-build.receipt.json").is_file());
6335 assert_eq!(receipt.compiled_translation_units, ["kernels/marlin.cu"]);
6336 assert!(receipt.cache_hit_translation_units.is_empty());
6337 assert_eq!(
6338 receipt.commands[0]
6339 .observed_dependencies
6340 .iter()
6341 .map(|dependency| dependency.domain)
6342 .collect::<Vec<_>>(),
6343 [
6344 NativeOperatorDependencyDomain::Source,
6345 NativeOperatorDependencyDomain::Source,
6346 NativeOperatorDependencyDomain::BackendToolchain,
6347 NativeOperatorDependencyDomain::HostToolchain,
6348 ]
6349 );
6350 assert!(receipt.commands[0]
6351 .observed_dependencies
6352 .iter()
6353 .all(|dependency| is_sha256_digest(&dependency.sha256)));
6354 let compiler_depfile = fs::read_to_string(
6355 output_dir.join(
6356 receipt.commands[0]
6357 .compiler_depfile
6358 .as_deref()
6359 .expect("cold build records its compiler depfile"),
6360 ),
6361 )
6362 .unwrap();
6363 let portable_depfile = fs::read_to_string(
6364 output_dir.join(
6365 receipt.commands[0]
6366 .depfile
6367 .as_deref()
6368 .expect("cold build records its portable depfile"),
6369 ),
6370 )
6371 .unwrap();
6372 assert!(compiler_depfile.contains("/bin/../include/cuda.h"));
6373 assert!(!portable_depfile.contains("/../"));
6374 let plan: NativeOperatorSourceBuildPlan = read_json(&plan_path).unwrap();
6375 let toolchain_scope =
6376 load_toolchain_dependency_scope(&output_dir, static_toolchain).unwrap();
6377 let cache_entry = PathBuf::from(
6378 receipt.commands[0]
6379 .object_cache_entry
6380 .as_deref()
6381 .expect("cold build records its cache entry"),
6382 );
6383 let object_name = Path::new(
6384 receipt.commands[0]
6385 .object_file
6386 .as_deref()
6387 .expect("cold build records its object"),
6388 )
6389 .file_name()
6390 .unwrap()
6391 .to_str()
6392 .unwrap();
6393 validate_existing_dependency_proof(
6394 &cache_entry.join("dependency-proof"),
6395 receipt.commands[0].object_cache_key.as_deref().unwrap(),
6396 receipt.commands[0].object_sha256.as_deref().unwrap(),
6397 &plan.translation_units[0],
6398 &plan.dependency_closures[0],
6399 &format!("/another-worktree/objects/{object_name}"),
6400 &toolchain_scope,
6401 )
6402 .expect("a concurrent cache winner from another worktree remains valid");
6403 assert!(receipt.commands.iter().all(|command| {
6404 [
6405 output_dir.join(&command.stdout_log),
6406 output_dir.join(&command.stderr_log),
6407 ]
6408 .iter()
6409 .all(|path| {
6410 fs::read_to_string(path).is_ok_and(|content| content.contains("execution-start"))
6411 })
6412 }));
6413 let host_probe_count = fs::read_to_string(&fake_cuda.host_compiler_invocation_counter)
6414 .unwrap()
6415 .lines()
6416 .count();
6417
6418 let cached_output_dir = root.path().join("cached-build");
6419 let cached_receipt = run_native_operator_source_build(&NativeOperatorSourceBuildRequest {
6420 plan_path: plan_path.clone(),
6421 source_root: source_root.clone(),
6422 output_dir: cached_output_dir.clone(),
6423 compute_capability: "sm_89".to_string(),
6424 builder_sha: "8".repeat(40),
6425 nvcc_path: fake_cuda.nvcc.clone(),
6426 cuda_toolkit_root: fake_cuda.root.clone(),
6427 ccbin_path: fake_cuda.ccbin.clone(),
6428 ar_path: PathBuf::from("/usr/bin/ar"),
6429 nvcc_threads: 8,
6430 object_cache_dir,
6431 plan_only: false,
6432 })
6433 .unwrap();
6434
6435 assert!(cached_receipt.compiled_translation_units.is_empty());
6436 assert_eq!(
6437 cached_receipt.cache_hit_translation_units,
6438 ["kernels/marlin.cu"]
6439 );
6440 assert!(!cached_receipt.commands[0].compiler_executed);
6441 assert_eq!(
6442 cached_receipt.commands[0].object_cache_status,
6443 Some(NativeOperatorSourceObjectCacheStatus::Hit)
6444 );
6445 assert_eq!(
6446 cached_receipt.commands[0].dependency_validation,
6447 Some(NativeOperatorDependencyValidation::CacheProof)
6448 );
6449 assert_eq!(
6450 cached_receipt.commands[0].observed_dependencies,
6451 receipt.commands[0].observed_dependencies
6452 );
6453 assert_ne!(
6454 cached_receipt.commands[0].object_file,
6455 cached_receipt.commands[0].depfile_producer_object_file,
6456 "portable cache proof must preserve the producer object while restoring to a new output root"
6457 );
6458 assert!(cached_receipt.commands[0]
6459 .depfile
6460 .as_deref()
6461 .is_some_and(|relative| cached_output_dir.join(relative).is_file()));
6462 assert_eq!(
6463 fs::read_to_string(&fake_cuda.compile_counter)
6464 .unwrap()
6465 .lines()
6466 .count(),
6467 1
6468 );
6469 assert_eq!(
6470 fs::read_to_string(&fake_cuda.invocation_counter)
6471 .unwrap()
6472 .lines()
6473 .count(),
6474 2,
6475 "cold build invokes one miss-only version probe plus one compile; full cache hit invokes neither"
6476 );
6477 assert_eq!(
6478 fs::read_to_string(&fake_cuda.host_compiler_invocation_counter)
6479 .unwrap()
6480 .lines()
6481 .count(),
6482 host_probe_count + 2,
6483 "full cache hit runs only the bounded include/driver configuration probes"
6484 );
6485 assert_eq!(
6486 receipt.archive_sha256, cached_receipt.archive_sha256,
6487 "worker-count changes must not change the object or archive"
6488 );
6489 assert_eq!(
6490 receipt.inputs_sha256, cached_receipt.inputs_sha256,
6491 "worker-count and provenance commit changes are not output-content inputs"
6492 );
6493
6494 fs::write(source_root.join("LICENSE"), "fixture license\n").unwrap();
6495 let package_spec = crate::NativeOperatorPackageSpec {
6496 schema_version: crate::NATIVE_OPERATOR_PACKAGE_SPEC_SCHEMA_VERSION,
6497 operator: CudaNativeBuildUnit::Marlin.artifact_operator().to_string(),
6498 operator_abi_version: "1".to_string(),
6499 backend: ferrum_types::NativeOperatorBackend::Cuda,
6500 compute_capabilities: vec!["sm_89".to_string()],
6501 operation_bindings: vec![ferrum_types::NativeOperatorBinding {
6502 operation_id: "operation.dense_linear".to_string(),
6503 operation_contract_version: ferrum_types::NativeOperatorContractVersion::new(1, 0),
6504 provider_id: "provider.cuda.dense_linear.f16.marlin".to_string(),
6505 provider_version: ferrum_types::NativeOperatorContractVersion::new(1, 0),
6506 provider_implementation_fingerprint: "a".repeat(64),
6507 entrypoints: CudaNativeBuildUnit::Marlin
6508 .required_exports()
6509 .iter()
6510 .map(|value| (*value).to_string())
6511 .collect(),
6512 }],
6513 required_exports: CudaNativeBuildUnit::Marlin
6514 .required_exports()
6515 .iter()
6516 .map(|value| (*value).to_string())
6517 .collect(),
6518 license_files: vec![crate::NativeOperatorLicenseInput {
6519 source_path: "LICENSE".to_string(),
6520 output_path: "licenses/LICENSE".to_string(),
6521 }],
6522 cuda_toolkit: Some("12.4".to_string()),
6523 cuda_runtime_min: Some("12.4".to_string()),
6524 system_libraries: vec![
6525 ferrum_native_ops::NativeOperatorSystemLibrary::CudaRuntime,
6526 ferrum_native_ops::NativeOperatorSystemLibrary::StdCxx,
6527 ],
6528 };
6529 let package_spec_path = root.path().join("package-spec.json");
6530 write_json(&package_spec_path, &package_spec).unwrap();
6531 let catalog_path = root.path().join("operation-catalog.json");
6532 let abi_path = root.path().join("native-abi.json");
6533 write_json(
6534 &catalog_path,
6535 &ferrum_types::NativeOperatorProviderCatalog {
6536 schema_version: ferrum_types::NATIVE_OPERATOR_PROVIDER_CATALOG_SCHEMA_VERSION,
6537 backend: ferrum_types::NativeOperatorBackend::Cuda,
6538 providers: vec![ferrum_types::NativeOperatorProviderCatalogRow {
6539 operation_id: "operation.dense_linear".to_string(),
6540 operation_contract_version: ferrum_types::NativeOperatorContractVersion::new(
6541 1, 0,
6542 ),
6543 operation_fingerprint: "b".repeat(64),
6544 provider_id: "provider.cuda.dense_linear.f16.marlin".to_string(),
6545 provider_version: ferrum_types::NativeOperatorContractVersion::new(1, 0),
6546 provider_implementation_fingerprint: "a".repeat(64),
6547 }],
6548 },
6549 )
6550 .unwrap();
6551 write_json(
6552 &abi_path,
6553 &ferrum_types::NativeOperatorAbiContract {
6554 schema_version: ferrum_types::NATIVE_OPERATOR_ABI_CONTRACT_SCHEMA_VERSION,
6555 ferrum_native_abi_version: ferrum_types::FERRUM_NATIVE_OPERATOR_ABI_VERSION
6556 .to_string(),
6557 descriptor_struct: "FerrumNativeOperatorDescriptorV2".to_string(),
6558 descriptor_symbol_policy: "operator_namespaced".to_string(),
6559 descriptor_fields: [
6560 ("struct_size", "uint32_t"),
6561 ("ferrum_native_abi_version", "uint32_t"),
6562 ("operator_name", "const char *"),
6563 ("operator_abi_version", "const char *"),
6564 ("g03_catalog_sha256", "const char *"),
6565 ("abi_contract_sha256", "const char *"),
6566 ]
6567 .into_iter()
6568 .map(|(name, c_type)| ferrum_types::NativeOperatorAbiField {
6569 name: name.to_string(),
6570 c_type: c_type.to_string(),
6571 })
6572 .collect(),
6573 },
6574 )
6575 .unwrap();
6576 let package_output = root.path().join("package");
6577
6578 let package_receipt =
6579 crate::package_native_operator(&crate::NativeOperatorPackageRequest {
6580 spec_path: package_spec_path,
6581 source_root: source_root.clone(),
6582 license_root: source_root.clone(),
6583 source_build_receipt_path: output_dir.join("source-build.receipt.json"),
6584 source_build_plan_path: plan_path.clone(),
6585 g03_catalog_path: catalog_path,
6586 abi_contract_path: abi_path,
6587 output_dir: package_output.clone(),
6588 cc: PathBuf::from("/usr/bin/cc"),
6589 ar: PathBuf::from("/usr/bin/ar"),
6590 })
6591 .unwrap();
6592
6593 assert_eq!(
6594 package_receipt.source_build_plan.sha256,
6595 receipt.plan_sha256
6596 );
6597 assert_eq!(
6598 package_receipt.source_archive_sha256,
6599 receipt.archive_sha256.unwrap()
6600 );
6601 assert!(package_output.join("package.receipt.json").is_file());
6602
6603 let cached_package_spec_path = root.path().join("cached-package-spec.json");
6604 write_json(&cached_package_spec_path, &package_spec).unwrap();
6605 let cached_package_output = root.path().join("cached-package");
6606 crate::package_native_operator(&crate::NativeOperatorPackageRequest {
6607 spec_path: cached_package_spec_path,
6608 source_root: source_root.clone(),
6609 license_root: source_root,
6610 source_build_receipt_path: cached_output_dir.join("source-build.receipt.json"),
6611 source_build_plan_path: plan_path,
6612 g03_catalog_path: root.path().join("operation-catalog.json"),
6613 abi_contract_path: root.path().join("native-abi.json"),
6614 output_dir: cached_package_output.clone(),
6615 cc: PathBuf::from("/usr/bin/cc"),
6616 ar: PathBuf::from("/usr/bin/ar"),
6617 })
6618 .unwrap();
6619 let cached_manifest: ferrum_types::NativeOperatorManifest =
6620 read_json(&cached_package_output.join("native_operator_manifest.json")).unwrap();
6621 assert_eq!(
6622 cached_manifest.build_summary.nvcc_version.as_deref(),
6623 Some("cuda-toolkit-static 12.4.0")
6624 );
6625 }
6626
6627 #[test]
6628 fn changing_one_translation_unit_recompiles_only_that_unit() {
6629 let root = tempfile::tempdir().unwrap();
6630 let (source_root, _) = write_fixture(root.path());
6631 fs::write(
6632 source_root.join("kernels/other.cu"),
6633 "int other_cuda(void) { return 1; }\n",
6634 )
6635 .unwrap();
6636 let mut source_definition = definition();
6637 source_definition.translation_units = vec![
6638 "kernels/marlin.cu".to_string(),
6639 "kernels/other.cu".to_string(),
6640 ];
6641 source_definition.dependency_closures = vec![
6642 NativeOperatorTranslationUnitDependencies {
6643 translation_unit: "kernels/marlin.cu".to_string(),
6644 headers: vec!["kernels/marlin.h".to_string()],
6645 },
6646 NativeOperatorTranslationUnitDependencies {
6647 translation_unit: "kernels/other.cu".to_string(),
6648 headers: Vec::new(),
6649 },
6650 ];
6651 let definition_path = root.path().join("two-tu-definition.json");
6652 write_json(&definition_path, &source_definition).unwrap();
6653 let first_plan_path = root.path().join("first.plan.json");
6654 lock_native_operator_source_definition(&definition_path, &source_root, &first_plan_path)
6655 .unwrap();
6656 let fake_cuda = write_fake_nvcc(root.path());
6657 let object_cache_dir = root.path().join("object-cache");
6658
6659 let first_receipt = run_native_operator_source_build(&NativeOperatorSourceBuildRequest {
6660 plan_path: first_plan_path,
6661 source_root: source_root.clone(),
6662 output_dir: root.path().join("first-build"),
6663 compute_capability: "sm_89".to_string(),
6664 builder_sha: "7".repeat(40),
6665 nvcc_path: fake_cuda.nvcc.clone(),
6666 cuda_toolkit_root: fake_cuda.root.clone(),
6667 ccbin_path: fake_cuda.ccbin.clone(),
6668 ar_path: PathBuf::from("/usr/bin/ar"),
6669 nvcc_threads: 2,
6670 object_cache_dir: object_cache_dir.clone(),
6671 plan_only: false,
6672 })
6673 .unwrap();
6674 assert_eq!(first_receipt.compiled_translation_units.len(), 2);
6675 assert!(first_receipt.cache_hit_translation_units.is_empty());
6676
6677 fs::write(
6678 source_root.join("kernels/other.cu"),
6679 "int other_cuda(void) { return 2; }\n",
6680 )
6681 .unwrap();
6682 let second_plan_path = root.path().join("second.plan.json");
6683 lock_native_operator_source_definition(&definition_path, &source_root, &second_plan_path)
6684 .unwrap();
6685 let second_receipt = run_native_operator_source_build(&NativeOperatorSourceBuildRequest {
6686 plan_path: second_plan_path,
6687 source_root: source_root.clone(),
6688 output_dir: root.path().join("second-build"),
6689 compute_capability: "sm_89".to_string(),
6690 builder_sha: "8".repeat(40),
6691 nvcc_path: fake_cuda.nvcc.clone(),
6692 cuda_toolkit_root: fake_cuda.root.clone(),
6693 ccbin_path: fake_cuda.ccbin.clone(),
6694 ar_path: PathBuf::from("/usr/bin/ar"),
6695 nvcc_threads: 4,
6696 object_cache_dir: object_cache_dir.clone(),
6697 plan_only: false,
6698 })
6699 .unwrap();
6700
6701 assert_eq!(
6702 second_receipt.compiled_translation_units,
6703 ["kernels/other.cu"]
6704 );
6705 assert_eq!(
6706 second_receipt.cache_hit_translation_units,
6707 ["kernels/marlin.cu"]
6708 );
6709 assert_eq!(
6710 fs::read_to_string(&fake_cuda.compile_counter)
6711 .unwrap()
6712 .lines()
6713 .count(),
6714 3
6715 );
6716
6717 fs::write(source_root.join("kernels/marlin.h"), "#define MARLIN 2\n").unwrap();
6718 let third_plan_path = root.path().join("third.plan.json");
6719 lock_native_operator_source_definition(&definition_path, &source_root, &third_plan_path)
6720 .unwrap();
6721 let third_receipt = run_native_operator_source_build(&NativeOperatorSourceBuildRequest {
6722 plan_path: third_plan_path,
6723 source_root,
6724 output_dir: root.path().join("third-build"),
6725 compute_capability: "sm_89".to_string(),
6726 builder_sha: "9".repeat(40),
6727 nvcc_path: fake_cuda.nvcc,
6728 cuda_toolkit_root: fake_cuda.root,
6729 ccbin_path: fake_cuda.ccbin.clone(),
6730 ar_path: PathBuf::from("/usr/bin/ar"),
6731 nvcc_threads: 4,
6732 object_cache_dir,
6733 plan_only: false,
6734 })
6735 .unwrap();
6736 assert_eq!(
6737 third_receipt.compiled_translation_units,
6738 ["kernels/marlin.cu"]
6739 );
6740 assert_eq!(
6741 third_receipt.cache_hit_translation_units,
6742 ["kernels/other.cu"]
6743 );
6744 assert_eq!(
6745 fs::read_to_string(fake_cuda.compile_counter)
6746 .unwrap()
6747 .lines()
6748 .count(),
6749 4,
6750 "private header drift must recompile only its owning translation unit"
6751 );
6752 }
6753}