1use std::collections::{BTreeMap, BTreeSet};
4use std::ffi::OsStr;
5use std::path::{Component, Path};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use rayon::prelude::*;
9use serde::ser::SerializeStruct;
10use serde::{Deserialize, Serialize};
11use zccache_core::{normalize_for_key, NormalizedPath};
12
13const DEFAULT_RUST_PLAN_TAR_THREADS_CAP: usize = 8;
17const MAX_RUST_PLAN_TAR_THREADS: usize = 64;
20
21pub const RUST_ARTIFACT_PLAN_SCHEMA_VERSION: u32 = 1;
23pub const RUST_ARTIFACT_CACHE_SCHEMA_VERSION: u32 = 1;
25
26const BUNDLE_MANIFEST_NAME: &str = "manifest.json";
27const BUNDLE_FILES_DIR: &str = "files";
28
29#[derive(Debug, thiserror::Error)]
31pub enum RustPlanError {
32 #[error("I/O error: {0}")]
33 Io(#[from] std::io::Error),
34 #[error("JSON error: {0}")]
35 Json(#[from] serde_json::Error),
36 #[error(
37 "unsupported Rust artifact plan schema version {found}; supported version is {supported}"
38 )]
39 UnsupportedSchemaVersion { found: u32, supported: u32 },
40 #[error(
41 "unsupported Rust artifact cache schema version {found}; supported version is {supported}"
42 )]
43 UnsupportedCacheSchemaVersion { found: u32, supported: u32 },
44 #[error("invalid Rust artifact plan: {0}")]
45 InvalidPlan(String),
46 #[error("Rust artifact bundle is missing: {0}")]
47 BundleMissing(NormalizedPath),
48 #[error("invalid Rust artifact bundle manifest: {0}")]
49 InvalidManifest(String),
50 #[error("unsafe relative artifact path in bundle: {0}")]
51 UnsafeRelativePath(String),
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum RustPlanMode {
58 Thin,
60 Full,
62}
63
64impl std::fmt::Display for RustPlanMode {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 match self {
67 Self::Thin => write!(f, "thin"),
68 Self::Full => write!(f, "full"),
69 }
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum RustArtifactClass {
77 Rlib,
78 Rmeta,
79 DepInfo,
80 ProcMacro,
81 SharedLib,
82 CargoFingerprint,
83 BuildScriptMetadata,
84 BuildScriptOutput,
85 FullTarget,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct RustToolchainIdentity {
92 pub rustc: String,
93 pub cargo: String,
94 pub channel: String,
95 pub host: String,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct RustPlanInputs {
102 pub features_hash: String,
103 pub rustflags_hash: String,
104 pub env_hash: String,
105 pub lockfile_hash: String,
106 pub cargo_config_hash: String,
107 #[serde(default)]
108 pub manifest_hashes: Vec<String>,
109}
110
111#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct RustPlanPackages {
115 #[serde(default)]
116 pub selected_package_ids: Vec<String>,
117 #[serde(default)]
118 pub workspace_package_ids: Vec<String>,
119 #[serde(default)]
120 pub excluded_path_package_ids: Vec<String>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct RustArtifactPlanV1 {
127 pub schema_version: u32,
128 pub mode: RustPlanMode,
129 pub workspace_root: NormalizedPath,
130 pub target_dir: NormalizedPath,
131 pub toolchain: RustToolchainIdentity,
132 pub target_triple: String,
133 pub profile: String,
134 pub inputs: RustPlanInputs,
135 pub packages: RustPlanPackages,
136 #[serde(default)]
137 pub allowed_artifact_classes: Vec<RustArtifactClass>,
138 pub cache_schema_version: u32,
139 #[serde(default)]
140 pub journal_log_path: Option<NormalizedPath>,
141}
142
143impl RustArtifactPlanV1 {
144 pub fn load(path: &Path) -> Result<Self, RustPlanError> {
146 let raw = std::fs::read_to_string(path)?;
147 Self::from_json_str(&raw)
148 }
149
150 pub fn from_json_str(raw: &str) -> Result<Self, RustPlanError> {
152 let value: serde_json::Value = serde_json::from_str(raw.trim_start_matches('\u{feff}'))?;
153 Self::from_json_value(value)
154 }
155
156 pub fn from_json_value(value: serde_json::Value) -> Result<Self, RustPlanError> {
158 let schema_version = json_u32_field(&value, "schema_version")?;
159 if schema_version != RUST_ARTIFACT_PLAN_SCHEMA_VERSION {
160 return Err(RustPlanError::UnsupportedSchemaVersion {
161 found: schema_version,
162 supported: RUST_ARTIFACT_PLAN_SCHEMA_VERSION,
163 });
164 }
165
166 let cache_schema_version = json_u32_field(&value, "cache_schema_version")?;
167 if cache_schema_version != RUST_ARTIFACT_CACHE_SCHEMA_VERSION {
168 return Err(RustPlanError::UnsupportedCacheSchemaVersion {
169 found: cache_schema_version,
170 supported: RUST_ARTIFACT_CACHE_SCHEMA_VERSION,
171 });
172 }
173
174 let plan: Self = serde_json::from_value(value)?;
175 plan.validate()?;
176 Ok(plan)
177 }
178
179 pub fn validate(&self) -> Result<(), RustPlanError> {
181 let mut errors = Vec::new();
182 if self.profile.trim().is_empty() {
183 errors.push("profile must not be empty");
184 }
185 if self.target_triple.trim().is_empty() {
186 errors.push("target_triple must not be empty");
187 }
188 if self.toolchain.rustc.trim().is_empty() {
189 errors.push("toolchain.rustc must not be empty");
190 }
191 if self.toolchain.cargo.trim().is_empty() {
192 errors.push("toolchain.cargo must not be empty");
193 }
194 if self.toolchain.channel.trim().is_empty() {
195 errors.push("toolchain.channel must not be empty");
196 }
197 if self.toolchain.host.trim().is_empty() {
198 errors.push("toolchain.host must not be empty");
199 }
200 if self.workspace_root.as_os_str().is_empty() {
201 errors.push("workspace_root must not be empty");
202 }
203 if self.target_dir.as_os_str().is_empty() {
204 errors.push("target_dir must not be empty");
205 }
206 if errors.is_empty() {
207 Ok(())
208 } else {
209 Err(RustPlanError::InvalidPlan(errors.join("; ")))
210 }
211 }
212
213 #[must_use]
215 pub fn effective_allowed_classes(&self) -> BTreeSet<RustArtifactClass> {
216 if self.allowed_artifact_classes.is_empty() {
217 default_thin_classes()
218 } else {
219 self.allowed_artifact_classes.iter().copied().collect()
220 }
221 }
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum RustPlanOperation {
228 Validate,
229 Restore,
230 Save,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct RustPlanCompatibility {
236 pub status: String,
237 #[serde(default)]
238 pub errors: Vec<String>,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243pub struct RustPlanSkippedSample {
244 pub path: String,
245 pub reason: String,
246}
247
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
250pub struct RustPlanArtifactEffectiveness {
251 pub eligible_file_count: u64,
252 pub restored_file_count: u64,
253 pub reuse_ratio: f64,
254}
255
256#[derive(Debug, Clone, PartialEq, Deserialize)]
258pub struct RustPlanSummary {
259 pub operation: RustPlanOperation,
260 pub mode: RustPlanMode,
261 pub plan_schema_version: u32,
262 pub cache_schema_version: u32,
263 pub compatibility: RustPlanCompatibility,
264 pub restored_file_count: u64,
265 pub restored_bytes: u64,
266 pub saved_file_count: u64,
267 pub saved_bytes: u64,
268 pub skipped_count: u64,
269 #[serde(default)]
270 pub skipped_reasons: BTreeMap<String, u64>,
271 #[serde(default)]
272 pub skipped_samples: Vec<RustPlanSkippedSample>,
273 #[serde(default)]
274 pub key_input_mismatches: Vec<String>,
275 #[serde(default)]
276 pub miss_classifications: BTreeMap<String, u64>,
277 pub backend: String,
278 pub cache_key: String,
279 pub backend_cache_key: Option<String>,
280 pub backend_cache_version: Option<String>,
281 pub archive_path: Option<NormalizedPath>,
282 pub journal_log_path: Option<NormalizedPath>,
283 pub target_artifact_effectiveness: RustPlanArtifactEffectiveness,
284 pub compile_cache_stats: Option<serde_json::Value>,
285}
286
287impl Serialize for RustPlanSummary {
288 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
289 where
290 S: serde::Serializer,
291 {
292 let miss_classifications = self.computed_miss_classifications();
293 let mut state = serializer.serialize_struct("RustPlanSummary", 22)?;
294 state.serialize_field("operation", &self.operation)?;
295 state.serialize_field("mode", &self.mode)?;
296 state.serialize_field("plan_schema_version", &self.plan_schema_version)?;
297 state.serialize_field("cache_schema_version", &self.cache_schema_version)?;
298 state.serialize_field("compatibility", &self.compatibility)?;
299 state.serialize_field("restored_file_count", &self.restored_file_count)?;
300 state.serialize_field("restored_bytes", &self.restored_bytes)?;
301 state.serialize_field("saved_file_count", &self.saved_file_count)?;
302 state.serialize_field("saved_bytes", &self.saved_bytes)?;
303 state.serialize_field("skipped_count", &self.skipped_count)?;
304 state.serialize_field("skipped_reasons", &self.skipped_reasons)?;
305 state.serialize_field("skipped_samples", &self.skipped_samples)?;
306 state.serialize_field("key_input_mismatches", &self.key_input_mismatches)?;
307 state.serialize_field("miss_classifications", &miss_classifications)?;
308 state.serialize_field("backend", &self.backend)?;
309 state.serialize_field("cache_key", &self.cache_key)?;
310 state.serialize_field("backend_cache_key", &self.backend_cache_key)?;
311 state.serialize_field("backend_cache_version", &self.backend_cache_version)?;
312 state.serialize_field("archive_path", &self.archive_path)?;
313 state.serialize_field("journal_log_path", &self.journal_log_path)?;
314 state.serialize_field(
315 "target_artifact_effectiveness",
316 &self.target_artifact_effectiveness,
317 )?;
318 state.serialize_field("compile_cache_stats", &self.compile_cache_stats)?;
319 state.end()
320 }
321}
322
323impl RustPlanSummary {
324 #[must_use]
326 pub fn validation_success(plan: &RustArtifactPlanV1, cache_dir: &Path) -> Self {
327 let cache_key = rust_plan_cache_key(plan);
328 let archive_path = rust_plan_bundle_dir(cache_dir, &cache_key);
329 Self::new(
330 RustPlanOperation::Validate,
331 plan.mode,
332 plan.schema_version,
333 plan.cache_schema_version,
334 cache_key,
335 Some(archive_path),
336 plan.journal_log_path.clone(),
337 )
338 }
339
340 #[must_use]
342 pub fn compatibility_failure(operation: RustPlanOperation, err: &RustPlanError) -> Self {
343 Self {
344 operation,
345 mode: RustPlanMode::Thin,
346 plan_schema_version: 0,
347 cache_schema_version: 0,
348 compatibility: RustPlanCompatibility {
349 status: "error".to_string(),
350 errors: vec![err.to_string()],
351 },
352 restored_file_count: 0,
353 restored_bytes: 0,
354 saved_file_count: 0,
355 saved_bytes: 0,
356 skipped_count: 0,
357 skipped_reasons: BTreeMap::new(),
358 skipped_samples: Vec::new(),
359 key_input_mismatches: Vec::new(),
360 miss_classifications: BTreeMap::new(),
361 backend: "unknown".to_string(),
362 cache_key: String::new(),
363 backend_cache_key: None,
364 backend_cache_version: None,
365 archive_path: None,
366 journal_log_path: None,
367 target_artifact_effectiveness: RustPlanArtifactEffectiveness {
368 eligible_file_count: 0,
369 restored_file_count: 0,
370 reuse_ratio: 0.0,
371 },
372 compile_cache_stats: None,
373 }
374 }
375
376 fn new(
377 operation: RustPlanOperation,
378 mode: RustPlanMode,
379 plan_schema_version: u32,
380 cache_schema_version: u32,
381 cache_key: String,
382 archive_path: Option<NormalizedPath>,
383 journal_log_path: Option<NormalizedPath>,
384 ) -> Self {
385 Self {
386 operation,
387 mode,
388 plan_schema_version,
389 cache_schema_version,
390 compatibility: RustPlanCompatibility {
391 status: "ok".to_string(),
392 errors: Vec::new(),
393 },
394 restored_file_count: 0,
395 restored_bytes: 0,
396 saved_file_count: 0,
397 saved_bytes: 0,
398 skipped_count: 0,
399 skipped_reasons: BTreeMap::new(),
400 skipped_samples: Vec::new(),
401 key_input_mismatches: Vec::new(),
402 miss_classifications: BTreeMap::new(),
403 backend: "local".to_string(),
404 cache_key,
405 backend_cache_key: None,
406 backend_cache_version: None,
407 archive_path,
408 journal_log_path,
409 target_artifact_effectiveness: RustPlanArtifactEffectiveness {
410 eligible_file_count: 0,
411 restored_file_count: 0,
412 reuse_ratio: 0.0,
413 },
414 compile_cache_stats: None,
415 }
416 }
417
418 fn skip(&mut self, path: impl Into<String>, reason: &'static str) {
419 self.skipped_count += 1;
420 *self.skipped_reasons.entry(reason.to_string()).or_insert(0) += 1;
421 if self.skipped_samples.len() < 16 {
422 self.skipped_samples.push(RustPlanSkippedSample {
423 path: path.into(),
424 reason: reason.to_string(),
425 });
426 }
427 self.refresh_miss_classifications();
428 }
429
430 pub fn record_skip(&mut self, path: impl Into<String>, reason: &'static str) {
432 self.skip(path, reason);
433 }
434
435 pub fn set_backend(
437 &mut self,
438 backend: impl Into<String>,
439 backend_cache_key: Option<String>,
440 backend_cache_version: Option<String>,
441 ) {
442 self.backend = backend.into();
443 self.backend_cache_key = backend_cache_key;
444 self.backend_cache_version = backend_cache_version;
445 }
446
447 fn refresh_effectiveness(&mut self, eligible: u64) {
448 self.target_artifact_effectiveness.eligible_file_count = eligible;
449 self.target_artifact_effectiveness.restored_file_count = self.restored_file_count;
450 self.target_artifact_effectiveness.reuse_ratio = if eligible == 0 {
451 0.0
452 } else {
453 self.restored_file_count as f64 / eligible as f64
454 };
455 self.refresh_miss_classifications();
456 }
457
458 pub fn refresh_miss_classifications(&mut self) {
460 self.miss_classifications = self.computed_miss_classifications();
461 }
462
463 #[must_use]
464 pub fn computed_miss_classifications(&self) -> BTreeMap<String, u64> {
465 let mut classifications = BTreeMap::new();
466
467 for (reason, count) in &self.skipped_reasons {
468 if let Some(classification) = skip_reason_miss_classification(reason) {
469 add_miss_classification(&mut classifications, classification, *count);
470 }
471 }
472
473 for mismatch in &self.key_input_mismatches {
474 for classification in key_mismatch_classifications(mismatch) {
475 add_miss_classification(&mut classifications, classification, 1);
476 }
477 }
478
479 if let Some(stats) = &self.compile_cache_stats {
480 let misses = compile_cache_misses(stats);
481 if misses > 0 {
482 add_miss_classification(
483 &mut classifications,
484 "zccache_compile_cache_miss_despite_equivalent_rustc_command",
485 misses,
486 );
487 }
488 }
489
490 classifications
491 }
492}
493
494fn add_miss_classification(
495 classifications: &mut BTreeMap<String, u64>,
496 classification: &'static str,
497 count: u64,
498) {
499 *classifications
500 .entry(classification.to_string())
501 .or_insert(0) += count;
502}
503
504fn skip_reason_miss_classification(reason: &str) -> Option<&'static str> {
505 match reason {
506 "artifact_absent_from_restored_plan" => Some("artifact_absent_from_restored_plan"),
507 "artifact_class_disallowed_by_plan" => Some("artifact_class_disallowed_by_plan"),
508 "workspace_or_path_dependency_excluded_by_plan" => {
509 Some("workspace_or_path_dependency_excluded_by_plan")
510 }
511 "restored_payload_missing_or_corrupt" => Some("restored_payload_missing_or_corrupt"),
512 "backend_cache_miss" => Some("backend_cache_miss"),
513 _ => None,
514 }
515}
516
517fn key_mismatch_classifications(mismatch: &str) -> Vec<&'static str> {
518 let lower = mismatch.to_ascii_lowercase();
519 let mut classifications = Vec::new();
520
521 if lower.contains("cache key")
522 || lower.contains("mode")
523 || lower.contains("toolchain")
524 || lower.contains("profile")
525 || lower.contains("rustflags")
526 || lower.contains("target")
527 {
528 classifications.push("toolchain_profile_rustflags_target_mismatch");
529 }
530
531 if lower.contains("cache key")
532 || lower.contains("input hash")
533 || lower.contains("lockfile")
534 || lower.contains("config")
535 || lower.contains("manifest")
536 {
537 classifications.push("lockfile_config_manifest_hash_mismatch");
538 }
539
540 classifications
541}
542
543fn compile_cache_misses(stats: &serde_json::Value) -> u64 {
544 stats
545 .get("misses")
546 .and_then(serde_json::Value::as_u64)
547 .or_else(|| {
548 stats
549 .get("cache_misses")
550 .and_then(serde_json::Value::as_u64)
551 })
552 .unwrap_or(0)
553}
554
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
557pub struct RustBundledArtifact {
558 pub relative_path: String,
559 pub class: RustArtifactClass,
560 pub size: u64,
561 pub content_hash: String,
562}
563
564#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
566pub struct RustArtifactBundleManifest {
567 pub manifest_schema_version: u32,
568 pub plan_schema_version: u32,
569 pub cache_schema_version: u32,
570 pub mode: RustPlanMode,
571 pub cache_key: String,
572 pub created_at_secs: u64,
573 pub plan_identity_hash: String,
574 pub artifacts: Vec<RustBundledArtifact>,
575}
576
577#[must_use]
579pub fn rust_plan_cache_key(plan: &RustArtifactPlanV1) -> String {
580 let identity = rust_plan_identity_hash(plan);
581 format!("rust-plan-v1-{}", &identity[..32])
582}
583
584#[must_use]
586pub fn rust_plan_identity_hash(plan: &RustArtifactPlanV1) -> String {
587 let payload = serde_json::json!({
588 "schema_version": plan.schema_version,
589 "mode": plan.mode,
590 "workspace_root": normalize_for_key(plan.workspace_root.as_path()),
591 "target_dir": normalize_for_key(plan.target_dir.as_path()),
592 "toolchain": plan.toolchain,
593 "target_triple": plan.target_triple,
594 "profile": plan.profile,
595 "inputs": plan.inputs,
596 "packages": plan.packages,
597 "allowed_artifact_classes": plan.effective_allowed_classes().into_iter().collect::<Vec<_>>(),
598 "cache_schema_version": plan.cache_schema_version,
599 });
600 let bytes = serde_json::to_vec(&payload).unwrap_or_default();
601 zccache_hash::hash_bytes(&bytes).to_hex()
602}
603
604#[must_use]
606pub fn rust_plan_bundle_dir(cache_dir: &Path, cache_key: &str) -> NormalizedPath {
607 NormalizedPath::new(cache_dir.join("rust-plan").join(cache_key))
608}
609
610pub fn save_rust_plan_local(
612 plan: &RustArtifactPlanV1,
613 cache_dir: &Path,
614) -> Result<RustPlanSummary, RustPlanError> {
615 plan.validate()?;
616 ensure_supported_cache_schema_version(plan.cache_schema_version)?;
617 let cache_key = rust_plan_cache_key(plan);
618 let bundle_dir = rust_plan_bundle_dir(cache_dir, &cache_key);
619 let files_dir = bundle_dir.join(BUNDLE_FILES_DIR);
620 let mut summary = RustPlanSummary::new(
621 RustPlanOperation::Save,
622 plan.mode,
623 plan.schema_version,
624 plan.cache_schema_version,
625 cache_key.clone(),
626 Some(bundle_dir.clone()),
627 plan.journal_log_path.clone(),
628 );
629
630 let mut candidates = Vec::new();
631 collect_files(plan.target_dir.as_path(), &mut candidates)?;
632 candidates.sort();
633
634 let selected = select_artifacts(plan, candidates, &mut summary);
635
636 if bundle_dir.exists() {
637 std::fs::remove_dir_all(&bundle_dir)?;
638 }
639 std::fs::create_dir_all(&files_dir)?;
640
641 let artifacts = bundle_selected_artifacts(&selected, &files_dir)?;
642 summary.saved_file_count += artifacts.len() as u64;
643 summary.saved_bytes += artifacts.iter().map(|a| a.size).sum::<u64>();
644
645 let manifest = RustArtifactBundleManifest {
646 manifest_schema_version: RUST_ARTIFACT_CACHE_SCHEMA_VERSION,
647 plan_schema_version: plan.schema_version,
648 cache_schema_version: plan.cache_schema_version,
649 mode: plan.mode,
650 cache_key,
651 created_at_secs: now_secs(),
652 plan_identity_hash: rust_plan_identity_hash(plan),
653 artifacts,
654 };
655 let manifest_bytes = serde_json::to_vec_pretty(&manifest)?;
656 std::fs::write(bundle_dir.join(BUNDLE_MANIFEST_NAME), manifest_bytes)?;
657 Ok(summary)
658}
659
660fn bundle_selected_artifacts(
665 selected: &[SelectedArtifact],
666 files_dir: &Path,
667) -> Result<Vec<RustBundledArtifact>, RustPlanError> {
668 bundle_selected_artifacts_with_threads(selected, files_dir, resolve_rust_plan_tar_threads())
669}
670
671fn bundle_selected_artifacts_with_threads(
674 selected: &[SelectedArtifact],
675 files_dir: &Path,
676 threads: usize,
677) -> Result<Vec<RustBundledArtifact>, RustPlanError> {
678 if threads <= 1 || selected.len() < 2 {
679 return selected
680 .iter()
681 .map(|sel| bundle_one_artifact(sel, files_dir))
682 .collect();
683 }
684
685 let pool = rayon::ThreadPoolBuilder::new()
686 .num_threads(threads)
687 .thread_name(|idx| format!("zccache-rust-plan-{idx}"))
688 .build()
689 .map_err(|err| {
690 RustPlanError::Io(std::io::Error::other(format!(
691 "failed to build rust-plan thread pool: {err}"
692 )))
693 })?;
694
695 pool.install(|| {
696 selected
697 .par_iter()
698 .map(|sel| bundle_one_artifact(sel, files_dir))
699 .collect()
700 })
701}
702
703fn bundle_one_artifact(
704 sel: &SelectedArtifact,
705 files_dir: &Path,
706) -> Result<RustBundledArtifact, RustPlanError> {
707 let dst = safe_join(files_dir, &sel.relative_path)?;
708 if let Some(parent) = dst.parent() {
709 std::fs::create_dir_all(parent)?;
710 }
711 std::fs::copy(&sel.source_path, &dst)?;
712 let size = std::fs::metadata(&sel.source_path)?.len();
713 let content_hash = zccache_hash::hash_file(&sel.source_path)?.to_hex();
714 Ok(RustBundledArtifact {
715 relative_path: sel.relative_path.clone(),
716 class: sel.class,
717 size,
718 content_hash,
719 })
720}
721
722pub fn resolve_rust_plan_tar_threads() -> usize {
733 let raw = std::env::var("ZCCACHE_RUST_PLAN_TAR_THREADS")
734 .ok()
735 .or_else(|| std::env::var("SOLDR_TARGET_CACHE_TAR_THREADS").ok());
736 parse_rust_plan_tar_threads(raw.as_deref())
737}
738
739fn parse_rust_plan_tar_threads(raw: Option<&str>) -> usize {
740 let trimmed = raw.map(str::trim).filter(|s| !s.is_empty());
741 match trimmed {
742 None => default_rust_plan_tar_threads(),
743 Some(s) if s.eq_ignore_ascii_case("auto") => default_rust_plan_tar_threads(),
744 Some(s) => match s.parse::<usize>() {
745 Ok(0) => default_rust_plan_tar_threads(),
746 Ok(n) => n.min(MAX_RUST_PLAN_TAR_THREADS),
747 Err(_) => default_rust_plan_tar_threads(),
748 },
749 }
750}
751
752fn default_rust_plan_tar_threads() -> usize {
753 std::thread::available_parallelism()
754 .map(std::num::NonZeroUsize::get)
755 .unwrap_or(1)
756 .min(DEFAULT_RUST_PLAN_TAR_THREADS_CAP)
757}
758
759pub fn restore_rust_plan_local(
761 plan: &RustArtifactPlanV1,
762 cache_dir: &Path,
763) -> Result<RustPlanSummary, RustPlanError> {
764 plan.validate()?;
765 ensure_supported_cache_schema_version(plan.cache_schema_version)?;
766 let cache_key = rust_plan_cache_key(plan);
767 let bundle_dir = rust_plan_bundle_dir(cache_dir, &cache_key);
768 let files_dir = bundle_dir.join(BUNDLE_FILES_DIR);
769 let mut summary = RustPlanSummary::new(
770 RustPlanOperation::Restore,
771 plan.mode,
772 plan.schema_version,
773 plan.cache_schema_version,
774 cache_key.clone(),
775 Some(bundle_dir.clone()),
776 plan.journal_log_path.clone(),
777 );
778
779 if !bundle_dir.exists() {
780 summary.skip("<bundle>", "artifact_absent_from_restored_plan");
781 summary.refresh_effectiveness(0);
782 return Ok(summary);
783 }
784
785 let manifest_path = bundle_dir.join(BUNDLE_MANIFEST_NAME);
786 let manifest: RustArtifactBundleManifest =
787 serde_json::from_slice(&std::fs::read(&manifest_path)?)?;
788 if !validate_manifest(plan, &cache_key, &manifest, &mut summary)? {
789 summary.refresh_effectiveness(0);
790 return Ok(summary);
791 }
792
793 let now = SystemTime::now();
794 let file_times = std::fs::FileTimes::new()
795 .set_accessed(now)
796 .set_modified(now);
797 let eligible = manifest.artifacts.len() as u64;
798
799 for artifact in &manifest.artifacts {
800 let src = match safe_join(&files_dir, &artifact.relative_path) {
801 Ok(path) => path,
802 Err(err) => {
803 summary.skip(&artifact.relative_path, "path_traversal");
804 summary.compatibility.errors.push(err.to_string());
805 continue;
806 }
807 };
808 let dst = match safe_join(plan.target_dir.as_path(), &artifact.relative_path) {
809 Ok(path) => path,
810 Err(err) => {
811 summary.skip(&artifact.relative_path, "path_traversal");
812 summary.compatibility.errors.push(err.to_string());
813 continue;
814 }
815 };
816 let Ok(metadata) = std::fs::metadata(&src) else {
817 summary.skip(
818 &artifact.relative_path,
819 "restored_payload_missing_or_corrupt",
820 );
821 continue;
822 };
823 if metadata.len() != artifact.size {
824 summary.skip(
825 &artifact.relative_path,
826 "restored_payload_missing_or_corrupt",
827 );
828 continue;
829 }
830 let Ok(content_hash) = zccache_hash::hash_file(&src).map(|hash| hash.to_hex()) else {
831 summary.skip(
832 &artifact.relative_path,
833 "restored_payload_missing_or_corrupt",
834 );
835 continue;
836 };
837 if content_hash != artifact.content_hash {
838 summary.skip(
839 &artifact.relative_path,
840 "restored_payload_missing_or_corrupt",
841 );
842 continue;
843 }
844 if let Some(parent) = dst.parent() {
845 std::fs::create_dir_all(parent)?;
846 }
847 if dst.exists() {
848 std::fs::remove_file(&dst)?;
849 }
850 if std::fs::hard_link(&src, &dst).is_err() {
851 std::fs::copy(&src, &dst)?;
852 }
853 if let Ok(file) = std::fs::File::open(&dst) {
854 let _ = file.set_times(file_times);
855 }
856 summary.restored_file_count += 1;
857 summary.restored_bytes += artifact.size;
858 }
859
860 summary.refresh_effectiveness(eligible);
861 Ok(summary)
862}
863
864fn validate_manifest(
865 plan: &RustArtifactPlanV1,
866 cache_key: &str,
867 manifest: &RustArtifactBundleManifest,
868 summary: &mut RustPlanSummary,
869) -> Result<bool, RustPlanError> {
870 if manifest.manifest_schema_version != RUST_ARTIFACT_CACHE_SCHEMA_VERSION {
871 return Err(RustPlanError::UnsupportedCacheSchemaVersion {
872 found: manifest.manifest_schema_version,
873 supported: RUST_ARTIFACT_CACHE_SCHEMA_VERSION,
874 });
875 }
876 let mut compatible = true;
877 if manifest.cache_key != cache_key {
878 summary
879 .key_input_mismatches
880 .push("bundle cache key does not match requested plan".to_string());
881 compatible = false;
882 }
883 if manifest.mode != plan.mode {
884 summary
885 .key_input_mismatches
886 .push("bundle mode does not match requested plan".to_string());
887 compatible = false;
888 }
889 let plan_identity_hash = rust_plan_identity_hash(plan);
890 if manifest.plan_identity_hash != plan_identity_hash {
891 summary
892 .key_input_mismatches
893 .push("bundle input hash does not match requested plan".to_string());
894 compatible = false;
895 }
896 if compatible {
897 Ok(true)
898 } else {
899 summary.compatibility.status = "warning".to_string();
900 summary.compatibility.errors = summary.key_input_mismatches.clone();
901 Ok(false)
902 }
903}
904
905#[derive(Debug, Clone, PartialEq, Eq)]
906struct SelectedArtifact {
907 source_path: NormalizedPath,
908 relative_path: String,
909 class: RustArtifactClass,
910}
911
912fn select_artifacts(
913 plan: &RustArtifactPlanV1,
914 candidates: Vec<NormalizedPath>,
915 summary: &mut RustPlanSummary,
916) -> Vec<SelectedArtifact> {
917 let allowed = plan.effective_allowed_classes();
918 let excluded_names = excluded_package_names(&plan.packages);
919 let mut selected = Vec::new();
920
921 for path in candidates {
922 let rel_path = match path.strip_prefix(plan.target_dir.as_path()) {
923 Ok(rel) => rel,
924 Err(_) => {
925 summary.skip(path.display().to_string(), "outside_target_dir");
926 continue;
927 }
928 };
929 let rel = relative_path_string(rel_path);
930
931 if has_component(rel_path, "incremental") {
932 summary.skip(rel, "transient_state");
933 continue;
934 }
935
936 let class = classify_artifact(rel_path, plan.mode);
937
938 if plan.mode == RustPlanMode::Thin {
939 let Some(class) = class else {
940 summary.skip(rel, "artifact_class_disallowed_by_plan");
941 continue;
942 };
943 if !allowed.contains(&class) {
944 summary.skip(rel, "artifact_class_disallowed_by_plan");
945 continue;
946 }
947 if artifact_matches_excluded_package(rel_path, &excluded_names) {
948 summary.skip(rel, "workspace_or_path_dependency_excluded_by_plan");
949 continue;
950 }
951 selected.push(SelectedArtifact {
952 source_path: path,
953 relative_path: rel,
954 class,
955 });
956 continue;
957 }
958
959 selected.push(SelectedArtifact {
960 source_path: path,
961 relative_path: rel,
962 class: class.unwrap_or(RustArtifactClass::FullTarget),
963 });
964 }
965
966 selected.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
967 selected
968}
969
970fn classify_artifact(rel: &Path, mode: RustPlanMode) -> Option<RustArtifactClass> {
971 if has_component(rel, ".fingerprint") {
972 return Some(RustArtifactClass::CargoFingerprint);
973 }
974 if has_component(rel, "build") {
975 if has_component(rel, "out") {
976 return Some(RustArtifactClass::BuildScriptOutput);
977 }
978 if let Some(name) = rel.file_name().and_then(OsStr::to_str) {
979 if matches!(name, "output" | "invoked.timestamp" | "root-output") {
980 return Some(RustArtifactClass::BuildScriptMetadata);
981 }
982 }
983 }
984
985 match rel.extension().and_then(OsStr::to_str) {
986 Some("rlib") => Some(RustArtifactClass::Rlib),
987 Some("rmeta") => Some(RustArtifactClass::Rmeta),
988 Some("d") => Some(RustArtifactClass::DepInfo),
989 Some("so" | "dylib" | "dll") if is_likely_proc_macro_dylib(rel) => {
990 Some(RustArtifactClass::ProcMacro)
991 }
992 Some("so" | "dylib" | "dll") => Some(RustArtifactClass::SharedLib),
993 _ if mode == RustPlanMode::Full => Some(RustArtifactClass::FullTarget),
994 _ => None,
995 }
996}
997
998fn is_likely_proc_macro_dylib(rel: &Path) -> bool {
999 if !has_component(rel, "deps") {
1000 return false;
1001 }
1002
1003 rel.file_stem()
1004 .and_then(OsStr::to_str)
1005 .map(|stem| {
1006 let stem = stem.to_ascii_lowercase();
1007 stem.contains("proc_macro") || stem.contains("proc-macro")
1008 })
1009 .unwrap_or(false)
1010}
1011
1012fn collect_files(root: &Path, files: &mut Vec<NormalizedPath>) -> Result<(), RustPlanError> {
1013 if !root.exists() {
1014 return Ok(());
1015 }
1016
1017 let mut entries = Vec::new();
1018 for entry in std::fs::read_dir(root)? {
1019 entries.push(entry?);
1020 }
1021 entries.sort_by_key(|entry| entry.file_name());
1022
1023 for entry in entries {
1024 let path = NormalizedPath::new(entry.path());
1025 let file_type = entry.file_type()?;
1026 if file_type.is_dir() {
1027 collect_files(path.as_path(), files)?;
1028 } else if file_type.is_file() {
1029 files.push(path);
1030 }
1031 }
1032 Ok(())
1033}
1034
1035fn safe_join(root: &Path, relative: &str) -> Result<NormalizedPath, RustPlanError> {
1036 let rel = Path::new(relative);
1037 if rel.as_os_str().is_empty() {
1038 return Err(RustPlanError::UnsafeRelativePath(relative.to_string()));
1039 }
1040 for component in rel.components() {
1041 match component {
1042 Component::Normal(_) | Component::CurDir => {}
1043 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
1044 return Err(RustPlanError::UnsafeRelativePath(relative.to_string()));
1045 }
1046 }
1047 }
1048 Ok(NormalizedPath::new(root.join(rel)))
1049}
1050
1051fn default_thin_classes() -> BTreeSet<RustArtifactClass> {
1052 [
1053 RustArtifactClass::Rlib,
1054 RustArtifactClass::Rmeta,
1055 RustArtifactClass::DepInfo,
1056 RustArtifactClass::ProcMacro,
1057 RustArtifactClass::SharedLib,
1058 RustArtifactClass::CargoFingerprint,
1059 RustArtifactClass::BuildScriptMetadata,
1060 RustArtifactClass::BuildScriptOutput,
1061 ]
1062 .into_iter()
1063 .collect()
1064}
1065
1066fn json_u32_field(value: &serde_json::Value, field: &'static str) -> Result<u32, RustPlanError> {
1067 let Some(raw) = value.get(field) else {
1068 return Err(RustPlanError::InvalidPlan(format!("{field} is required")));
1069 };
1070 let Some(n) = raw.as_u64() else {
1071 return Err(RustPlanError::InvalidPlan(format!(
1072 "{field} must be an unsigned integer"
1073 )));
1074 };
1075 u32::try_from(n).map_err(|_| RustPlanError::InvalidPlan(format!("{field} is too large")))
1076}
1077
1078fn ensure_supported_cache_schema_version(cache_schema_version: u32) -> Result<(), RustPlanError> {
1079 if cache_schema_version != RUST_ARTIFACT_CACHE_SCHEMA_VERSION {
1080 return Err(RustPlanError::UnsupportedCacheSchemaVersion {
1081 found: cache_schema_version,
1082 supported: RUST_ARTIFACT_CACHE_SCHEMA_VERSION,
1083 });
1084 }
1085 Ok(())
1086}
1087fn relative_path_string(path: &Path) -> String {
1088 path.components()
1089 .filter_map(|component| match component {
1090 Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
1091 Component::CurDir => None,
1092 _ => Some(component.as_os_str().to_string_lossy().into_owned()),
1093 })
1094 .collect::<Vec<_>>()
1095 .join("/")
1096}
1097
1098fn has_component(path: &Path, needle: &str) -> bool {
1099 path.components()
1100 .any(|component| component.as_os_str() == OsStr::new(needle))
1101}
1102
1103fn excluded_package_names(packages: &RustPlanPackages) -> BTreeSet<String> {
1104 packages
1105 .workspace_package_ids
1106 .iter()
1107 .chain(packages.excluded_path_package_ids.iter())
1108 .filter_map(|id| package_name_from_id(id))
1109 .collect()
1110}
1111
1112fn package_name_from_id(id: &str) -> Option<String> {
1113 let candidate = if let Some(after_hash) = id.rsplit_once('#').map(|(_, right)| right) {
1114 after_hash.split('@').next().unwrap_or(after_hash)
1115 } else if let Some((left, _)) = id.split_once(' ') {
1116 left
1117 } else {
1118 id
1119 };
1120 let candidate = candidate
1121 .trim()
1122 .trim_matches('"')
1123 .trim_matches('\'')
1124 .replace('-', "_");
1125 if candidate.is_empty()
1126 || candidate.contains('/')
1127 || candidate.contains('\\')
1128 || candidate.contains(':')
1129 {
1130 None
1131 } else {
1132 Some(candidate)
1133 }
1134}
1135
1136fn artifact_matches_excluded_package(rel: &Path, excluded_names: &BTreeSet<String>) -> bool {
1137 if excluded_names.is_empty() {
1138 return false;
1139 }
1140 rel.components().any(|component| {
1141 let name = component.as_os_str().to_string_lossy();
1142 excluded_names.iter().any(|package| {
1143 let without_lib = name.strip_prefix("lib").unwrap_or(&name);
1144 without_lib == package
1145 || without_lib.starts_with(&format!("{package}-"))
1146 || without_lib.starts_with(&format!("{package}."))
1147 })
1148 })
1149}
1150
1151fn now_secs() -> u64 {
1152 SystemTime::now()
1153 .duration_since(UNIX_EPOCH)
1154 .unwrap_or_default()
1155 .as_secs()
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160 use super::*;
1161
1162 fn sample_plan(root: &Path, mode: RustPlanMode) -> RustArtifactPlanV1 {
1163 RustArtifactPlanV1 {
1164 schema_version: 1,
1165 mode,
1166 workspace_root: root.into(),
1167 target_dir: root.join("target").into(),
1168 toolchain: RustToolchainIdentity {
1169 rustc: "rustc 1.94.1".to_string(),
1170 cargo: "cargo 1.94.1".to_string(),
1171 channel: "1.94.1".to_string(),
1172 host: "x86_64-pc-windows-msvc".to_string(),
1173 },
1174 target_triple: "x86_64-pc-windows-msvc".to_string(),
1175 profile: "debug".to_string(),
1176 inputs: RustPlanInputs {
1177 features_hash: "features".to_string(),
1178 rustflags_hash: "rustflags".to_string(),
1179 env_hash: "env".to_string(),
1180 lockfile_hash: "lock".to_string(),
1181 cargo_config_hash: "config".to_string(),
1182 manifest_hashes: vec!["manifest".to_string()],
1183 },
1184 packages: RustPlanPackages {
1185 selected_package_ids: vec!["app 0.1.0".to_string()],
1186 workspace_package_ids: vec!["app 0.1.0".to_string()],
1187 excluded_path_package_ids: vec!["local_dep 0.1.0".to_string()],
1188 },
1189 allowed_artifact_classes: vec![
1190 RustArtifactClass::Rlib,
1191 RustArtifactClass::Rmeta,
1192 RustArtifactClass::DepInfo,
1193 RustArtifactClass::CargoFingerprint,
1194 RustArtifactClass::BuildScriptMetadata,
1195 RustArtifactClass::BuildScriptOutput,
1196 ],
1197 cache_schema_version: 1,
1198 journal_log_path: Some(root.join("zccache-session.jsonl").into()),
1199 }
1200 }
1201
1202 fn write(path: &Path, bytes: &[u8]) {
1203 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1204 std::fs::write(path, bytes).unwrap();
1205 }
1206
1207 fn load_manifest(bundle_dir: &Path) -> RustArtifactBundleManifest {
1208 let manifest_path = bundle_dir.join(BUNDLE_MANIFEST_NAME);
1209 serde_json::from_slice(&std::fs::read(manifest_path).unwrap()).unwrap()
1210 }
1211
1212 fn write_manifest(bundle_dir: &Path, manifest: &RustArtifactBundleManifest) {
1213 let manifest_path = bundle_dir.join(BUNDLE_MANIFEST_NAME);
1214 let bytes = serde_json::to_vec_pretty(manifest).unwrap();
1215 std::fs::write(manifest_path, bytes).unwrap();
1216 }
1217
1218 fn synthetic_target(root: &Path) {
1219 let target = root.join("target").join("debug");
1220 write(
1221 &target.join("deps").join("libserde-abc.rlib"),
1222 b"serde rlib",
1223 );
1224 write(
1225 &target.join("deps").join("libserde-abc.rmeta"),
1226 b"serde rmeta",
1227 );
1228 write(&target.join("deps").join("serde-abc.d"), b"serde depinfo");
1229 write(
1230 &target.join("deps").join("libapp-abc.rlib"),
1231 b"workspace rlib",
1232 );
1233 write(
1234 &target.join("deps").join("liblocal_dep-abc.rlib"),
1235 b"path dep rlib",
1236 );
1237 write(
1238 &target
1239 .join(".fingerprint")
1240 .join("serde-abc")
1241 .join("dep-lib-serde"),
1242 b"fingerprint",
1243 );
1244 write(
1245 &target
1246 .join("build")
1247 .join("serde-abc")
1248 .join("invoked.timestamp"),
1249 b"timestamp",
1250 );
1251 write(
1252 &target
1253 .join("build")
1254 .join("serde-abc")
1255 .join("out")
1256 .join("gen.rs"),
1257 b"generated",
1258 );
1259 write(&target.join("incremental").join("state.bin"), b"transient");
1260 }
1261
1262 fn synthetic_target_with_final_binary(root: &Path) {
1263 synthetic_target(root);
1264 let target = root.join("target").join("debug");
1265 #[cfg(windows)]
1266 write(&target.join("app.exe"), b"final binary");
1267 #[cfg(not(windows))]
1268 write(&target.join("app"), b"final binary");
1269 }
1270
1271 fn synthetic_target_with_proc_macro_outputs(root: &Path) {
1272 synthetic_target(root);
1273 let target = root.join("target").join("debug");
1274 #[cfg(windows)]
1275 let proc_macro = target.join("deps").join("libproc_macro2-def456.dll");
1276 #[cfg(not(windows))]
1277 let proc_macro = target.join("deps").join("libproc_macro2-def456.so");
1278 write(&proc_macro, b"proc-macro dylib");
1279
1280 #[cfg(windows)]
1281 let shared_lib = target.join("deps").join("libserde_shared-def456.dll");
1282 #[cfg(not(windows))]
1283 let shared_lib = target.join("deps").join("libserde_shared-def456.so");
1284 write(&shared_lib, b"shared lib");
1285 }
1286
1287 fn synthetic_target_with_package_exclusions(root: &Path) {
1288 synthetic_target(root);
1289 let target = root.join("target").join("debug");
1290 write(
1291 &target.join("deps").join("libapp-abc.rmeta"),
1292 b"workspace rmeta",
1293 );
1294 write(&target.join("deps").join("app-abc.d"), b"workspace depinfo");
1295 write(
1296 &target.join("deps").join("liblocal_dep-abc.rmeta"),
1297 b"path dep rmeta",
1298 );
1299 write(
1300 &target.join("deps").join("local_dep-abc.d"),
1301 b"path dep depinfo",
1302 );
1303 write(
1304 &target
1305 .join(".fingerprint")
1306 .join("app-abc")
1307 .join("dep-lib-app"),
1308 b"workspace fingerprint",
1309 );
1310 write(
1311 &target
1312 .join(".fingerprint")
1313 .join("local_dep-abc")
1314 .join("dep-lib-local_dep"),
1315 b"path dep fingerprint",
1316 );
1317 write(
1318 &target
1319 .join("build")
1320 .join("app-abc")
1321 .join("invoked.timestamp"),
1322 b"workspace timestamp",
1323 );
1324 write(
1325 &target
1326 .join("build")
1327 .join("local_dep-abc")
1328 .join("invoked.timestamp"),
1329 b"path dep timestamp",
1330 );
1331 write(
1332 &target
1333 .join("build")
1334 .join("app-abc")
1335 .join("out")
1336 .join("gen.rs"),
1337 b"workspace generated",
1338 );
1339 write(
1340 &target
1341 .join("build")
1342 .join("local_dep-abc")
1343 .join("out")
1344 .join("gen.rs"),
1345 b"path dep generated",
1346 );
1347 }
1348
1349 #[test]
1350 fn rejects_unsupported_schema_before_deserializing_unknown_fields() {
1351 let raw = serde_json::json!({
1352 "schema_version": 99,
1353 "cache_schema_version": 1,
1354 "unexpected_future_field": true
1355 });
1356 let err = RustArtifactPlanV1::from_json_value(raw).unwrap_err();
1357 assert!(matches!(
1358 err,
1359 RustPlanError::UnsupportedSchemaVersion {
1360 found: 99,
1361 supported: 1
1362 }
1363 ));
1364 }
1365
1366 #[test]
1367 fn rejects_unsupported_cache_schema_before_filesystem_mutation() {
1368 let dir = tempfile::tempdir().unwrap();
1369 synthetic_target(dir.path());
1370 let plan = RustArtifactPlanV1 {
1371 cache_schema_version: 99,
1372 ..sample_plan(dir.path(), RustPlanMode::Thin)
1373 };
1374 let cache = dir.path().join("cache");
1375
1376 let err = save_rust_plan_local(&plan, &cache).unwrap_err();
1377 assert!(matches!(
1378 err,
1379 RustPlanError::UnsupportedCacheSchemaVersion {
1380 found: 99,
1381 supported: 1
1382 }
1383 ));
1384 assert!(!cache.exists());
1385 }
1386
1387 #[test]
1388 fn restore_rejects_unsupported_cache_schema_before_filesystem_mutation() {
1389 let dir = tempfile::tempdir().unwrap();
1390 synthetic_target(dir.path());
1391 let plan = RustArtifactPlanV1 {
1392 cache_schema_version: 99,
1393 ..sample_plan(dir.path(), RustPlanMode::Thin)
1394 };
1395 let cache = dir.path().join("cache");
1396 std::fs::create_dir_all(&cache).unwrap();
1397 std::fs::create_dir_all(plan.target_dir.as_path()).unwrap();
1398 let sentinel = plan.target_dir.join("sentinel.txt");
1399 std::fs::write(&sentinel, b"keep me").unwrap();
1400
1401 let err = restore_rust_plan_local(&plan, &cache).unwrap_err();
1402 assert!(matches!(
1403 err,
1404 RustPlanError::UnsupportedCacheSchemaVersion {
1405 found: 99,
1406 supported: 1
1407 }
1408 ));
1409 assert!(sentinel.exists());
1410 }
1411
1412 #[test]
1413 fn omitted_or_empty_allowed_classes_default_to_thin_classes() {
1414 let dir = tempfile::tempdir().unwrap();
1415 let mut plan_value =
1416 serde_json::to_value(sample_plan(dir.path(), RustPlanMode::Thin)).unwrap();
1417
1418 plan_value
1419 .as_object_mut()
1420 .unwrap()
1421 .remove("allowed_artifact_classes");
1422 let omitted = RustArtifactPlanV1::from_json_value(plan_value.clone()).unwrap();
1423 assert_eq!(omitted.effective_allowed_classes(), default_thin_classes());
1424
1425 plan_value.as_object_mut().unwrap().insert(
1426 "allowed_artifact_classes".to_string(),
1427 serde_json::json!([]),
1428 );
1429 let empty = RustArtifactPlanV1::from_json_value(plan_value).unwrap();
1430 assert_eq!(empty.effective_allowed_classes(), default_thin_classes());
1431 }
1432 #[test]
1433 fn thin_save_restore_selects_dependency_artifacts_and_metadata() {
1434 let dir = tempfile::tempdir().unwrap();
1435 synthetic_target(dir.path());
1436 let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1437 let cache = dir.path().join("cache");
1438
1439 let saved = save_rust_plan_local(&plan, &cache).unwrap();
1440 assert_eq!(saved.saved_file_count, 6);
1441 assert_eq!(
1442 saved
1443 .skipped_reasons
1444 .get("workspace_or_path_dependency_excluded_by_plan"),
1445 Some(&2)
1446 );
1447 assert_eq!(saved.skipped_reasons.get("transient_state"), Some(&1));
1448
1449 std::fs::remove_dir_all(plan.target_dir.as_path()).unwrap();
1450 let restored = restore_rust_plan_local(&plan, &cache).unwrap();
1451 assert_eq!(restored.restored_file_count, 6);
1452 assert!(plan
1453 .target_dir
1454 .join("debug/deps/libserde-abc.rlib")
1455 .exists());
1456 assert!(plan
1457 .target_dir
1458 .join("debug/.fingerprint/serde-abc/dep-lib-serde")
1459 .exists());
1460 assert!(!plan.target_dir.join("debug/deps/libapp-abc.rlib").exists());
1461 assert!(!plan.target_dir.join("debug/incremental/state.bin").exists());
1462 }
1463
1464 #[test]
1465 fn thin_plan_skips_final_binary_outputs() {
1466 let dir = tempfile::tempdir().unwrap();
1467 synthetic_target_with_final_binary(dir.path());
1468 let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1469 let cache = dir.path().join("cache");
1470
1471 let saved = save_rust_plan_local(&plan, &cache).unwrap();
1472 assert_eq!(saved.saved_file_count, 6);
1473 assert_eq!(
1474 saved
1475 .skipped_reasons
1476 .get("artifact_class_disallowed_by_plan"),
1477 Some(&1)
1478 );
1479 assert!(saved.skipped_samples.iter().any(|sample| {
1480 sample.path.ends_with("debug/app.exe") || sample.path.ends_with("debug/app")
1481 }));
1482
1483 std::fs::remove_dir_all(plan.target_dir.as_path()).unwrap();
1484 let restored = restore_rust_plan_local(&plan, &cache).unwrap();
1485 assert_eq!(restored.restored_file_count, 6);
1486 assert!(!plan.target_dir.join("debug/app.exe").exists());
1487 assert!(!plan.target_dir.join("debug/app").exists());
1488 }
1489
1490 #[test]
1491 fn thin_plan_respects_explicit_class_gates_for_dependency_metadata_and_outputs() {
1492 let dir = tempfile::tempdir().unwrap();
1493 synthetic_target(dir.path());
1494 let mut plan = sample_plan(dir.path(), RustPlanMode::Thin);
1495 plan.allowed_artifact_classes = vec![RustArtifactClass::Rlib, RustArtifactClass::Rmeta];
1496 let cache = dir.path().join("cache");
1497
1498 let saved = save_rust_plan_local(&plan, &cache).unwrap();
1499 assert_eq!(saved.saved_file_count, 2);
1500 assert_eq!(
1501 saved
1502 .skipped_reasons
1503 .get("artifact_class_disallowed_by_plan"),
1504 Some(&4)
1505 );
1506 assert_eq!(
1507 saved
1508 .skipped_reasons
1509 .get("workspace_or_path_dependency_excluded_by_plan"),
1510 Some(&2)
1511 );
1512 assert!(saved
1513 .skipped_samples
1514 .iter()
1515 .any(|sample| sample.path.ends_with("debug/deps/serde-abc.d")));
1516 assert!(saved.skipped_samples.iter().any(|sample| sample
1517 .path
1518 .ends_with("debug/.fingerprint/serde-abc/dep-lib-serde")));
1519 assert!(saved.skipped_samples.iter().any(|sample| sample
1520 .path
1521 .ends_with("debug/build/serde-abc/invoked.timestamp")));
1522 assert!(saved
1523 .skipped_samples
1524 .iter()
1525 .any(|sample| sample.path.ends_with("debug/build/serde-abc/out/gen.rs")));
1526 }
1527
1528 #[test]
1529 fn thin_plan_saves_and_restores_likely_proc_macro_dylibs_without_shared_libs() {
1530 let dir = tempfile::tempdir().unwrap();
1531 synthetic_target_with_proc_macro_outputs(dir.path());
1532 let mut plan = sample_plan(dir.path(), RustPlanMode::Thin);
1533 plan.allowed_artifact_classes = vec![
1534 RustArtifactClass::Rlib,
1535 RustArtifactClass::Rmeta,
1536 RustArtifactClass::DepInfo,
1537 RustArtifactClass::ProcMacro,
1538 RustArtifactClass::CargoFingerprint,
1539 RustArtifactClass::BuildScriptMetadata,
1540 RustArtifactClass::BuildScriptOutput,
1541 ];
1542 let cache = dir.path().join("cache");
1543
1544 let saved = save_rust_plan_local(&plan, &cache).unwrap();
1545 assert_eq!(saved.saved_file_count, 7);
1546 assert_eq!(
1547 saved
1548 .skipped_reasons
1549 .get("artifact_class_disallowed_by_plan"),
1550 Some(&1)
1551 );
1552 assert!(saved.skipped_samples.iter().any(|sample| {
1553 sample
1554 .path
1555 .ends_with("debug/deps/libserde_shared-def456.dll")
1556 || sample
1557 .path
1558 .ends_with("debug/deps/libserde_shared-def456.so")
1559 }));
1560
1561 std::fs::remove_dir_all(plan.target_dir.as_path()).unwrap();
1562 let restored = restore_rust_plan_local(&plan, &cache).unwrap();
1563 assert_eq!(restored.restored_file_count, 7);
1564 assert!(plan
1565 .target_dir
1566 .join(if cfg!(windows) {
1567 "debug/deps/libproc_macro2-def456.dll"
1568 } else {
1569 "debug/deps/libproc_macro2-def456.so"
1570 })
1571 .exists());
1572 assert!(!plan
1573 .target_dir
1574 .join(if cfg!(windows) {
1575 "debug/deps/libserde_shared-def456.dll"
1576 } else {
1577 "debug/deps/libserde_shared-def456.so"
1578 })
1579 .exists());
1580 }
1581
1582 #[test]
1583 fn thin_plan_skips_likely_proc_macro_dylibs_when_disallowed() {
1584 let dir = tempfile::tempdir().unwrap();
1585 synthetic_target_with_proc_macro_outputs(dir.path());
1586 let mut plan = sample_plan(dir.path(), RustPlanMode::Thin);
1587 plan.allowed_artifact_classes = vec![
1588 RustArtifactClass::Rlib,
1589 RustArtifactClass::Rmeta,
1590 RustArtifactClass::DepInfo,
1591 RustArtifactClass::SharedLib,
1592 RustArtifactClass::CargoFingerprint,
1593 RustArtifactClass::BuildScriptMetadata,
1594 RustArtifactClass::BuildScriptOutput,
1595 ];
1596 let cache = dir.path().join("cache");
1597
1598 let saved = save_rust_plan_local(&plan, &cache).unwrap();
1599 assert_eq!(saved.saved_file_count, 7);
1600 assert_eq!(
1601 saved
1602 .skipped_reasons
1603 .get("artifact_class_disallowed_by_plan"),
1604 Some(&1)
1605 );
1606 assert!(saved.skipped_samples.iter().any(|sample| {
1607 sample
1608 .path
1609 .ends_with("debug/deps/libproc_macro2-def456.dll")
1610 || sample.path.ends_with("debug/deps/libproc_macro2-def456.so")
1611 }));
1612 }
1613
1614 #[test]
1615 fn restore_skips_mismatched_bundles_without_mutating_target_dir() {
1616 let dir = tempfile::tempdir().unwrap();
1617 synthetic_target(dir.path());
1618 let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1619 let cache = dir.path().join("cache");
1620
1621 save_rust_plan_local(&plan, &cache).unwrap();
1622 let bundle_dir = rust_plan_bundle_dir(&cache, &rust_plan_cache_key(&plan));
1623 let mut manifest = load_manifest(&bundle_dir);
1624 manifest.cache_key = "rust-plan-v1-deadbeefdeadbeefdeadbeefdeadbeef".to_string();
1625 manifest.mode = RustPlanMode::Full;
1626 manifest.plan_identity_hash =
1627 "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string();
1628 write_manifest(&bundle_dir, &manifest);
1629
1630 std::fs::remove_dir_all(plan.target_dir.as_path()).unwrap();
1631 std::fs::create_dir_all(plan.target_dir.as_path()).unwrap();
1632
1633 let restored = restore_rust_plan_local(&plan, &cache).unwrap();
1634 assert_eq!(restored.restored_file_count, 0);
1635 assert_eq!(restored.compatibility.status, "warning");
1636 assert_eq!(restored.key_input_mismatches.len(), 3);
1637 assert_eq!(
1638 restored
1639 .miss_classifications
1640 .get("toolchain_profile_rustflags_target_mismatch"),
1641 Some(&2)
1642 );
1643 assert_eq!(
1644 restored
1645 .miss_classifications
1646 .get("lockfile_config_manifest_hash_mismatch"),
1647 Some(&2)
1648 );
1649 assert!(std::fs::read_dir(plan.target_dir.as_path())
1650 .unwrap()
1651 .next()
1652 .is_none());
1653 }
1654
1655 #[test]
1656 fn full_save_restore_includes_workspace_outputs_but_prunes_incremental() {
1657 let dir = tempfile::tempdir().unwrap();
1658 synthetic_target(dir.path());
1659 let plan = sample_plan(dir.path(), RustPlanMode::Full);
1660 let cache = dir.path().join("cache");
1661
1662 let saved = save_rust_plan_local(&plan, &cache).unwrap();
1663 assert_eq!(saved.skipped_reasons.get("transient_state"), Some(&1));
1664 assert!(saved.saved_file_count > 6);
1665
1666 std::fs::remove_dir_all(plan.target_dir.as_path()).unwrap();
1667 let restored = restore_rust_plan_local(&plan, &cache).unwrap();
1668 assert!(restored.restored_file_count > 6);
1669 assert!(plan.target_dir.join("debug/deps/libapp-abc.rlib").exists());
1670 assert!(!plan.target_dir.join("debug/incremental/state.bin").exists());
1671 }
1672
1673 #[test]
1674 fn restore_missing_bundle_is_a_diagnostic_cache_miss() {
1675 let dir = tempfile::tempdir().unwrap();
1676 let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1677 let summary = restore_rust_plan_local(&plan, &dir.path().join("cache")).unwrap();
1678 assert_eq!(summary.backend, "local");
1679 assert_eq!(summary.restored_file_count, 0);
1680 assert_eq!(
1681 summary
1682 .skipped_reasons
1683 .get("artifact_absent_from_restored_plan"),
1684 Some(&1)
1685 );
1686 assert_eq!(
1687 summary
1688 .miss_classifications
1689 .get("artifact_absent_from_restored_plan"),
1690 Some(&1)
1691 );
1692 }
1693
1694 #[test]
1695 fn summary_records_backend_identity_and_manual_skips() {
1696 let dir = tempfile::tempdir().unwrap();
1697 let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1698 let mut summary = RustPlanSummary::validation_success(&plan, &dir.path().join("cache"));
1699 assert_eq!(summary.backend, "local");
1700 assert!(summary.backend_cache_key.is_none());
1701
1702 summary.set_backend(
1703 "gha",
1704 Some("rust-plan-v1-key".to_string()),
1705 Some("version".to_string()),
1706 );
1707 summary.record_skip("<gha-cache>", "backend_cache_miss");
1708
1709 assert_eq!(summary.backend, "gha");
1710 assert_eq!(
1711 summary.backend_cache_key.as_deref(),
1712 Some("rust-plan-v1-key")
1713 );
1714 assert_eq!(summary.backend_cache_version.as_deref(), Some("version"));
1715 assert_eq!(summary.skipped_reasons.get("backend_cache_miss"), Some(&1));
1716 assert_eq!(
1717 summary.miss_classifications.get("backend_cache_miss"),
1718 Some(&1)
1719 );
1720 }
1721
1722 #[test]
1723 fn summary_derives_miss_classifications_from_existing_diagnostics() {
1724 let dir = tempfile::tempdir().unwrap();
1725 let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1726 let mut summary = RustPlanSummary::validation_success(&plan, &dir.path().join("cache"));
1727
1728 summary.record_skip("debug/deps/app.exe", "artifact_class_disallowed_by_plan");
1729 summary.record_skip(
1730 "debug/deps/libapp-abc.rlib",
1731 "workspace_or_path_dependency_excluded_by_plan",
1732 );
1733 summary
1734 .key_input_mismatches
1735 .push("bundle mode does not match requested plan".to_string());
1736 summary
1737 .key_input_mismatches
1738 .push("bundle input hash does not match requested plan".to_string());
1739 summary.compile_cache_stats = Some(serde_json::json!({
1740 "compilations": 4,
1741 "hits": 1,
1742 "misses": 3,
1743 }));
1744 summary.refresh_miss_classifications();
1745
1746 assert_eq!(
1747 summary
1748 .miss_classifications
1749 .get("artifact_class_disallowed_by_plan"),
1750 Some(&1)
1751 );
1752 assert_eq!(
1753 summary
1754 .miss_classifications
1755 .get("workspace_or_path_dependency_excluded_by_plan"),
1756 Some(&1)
1757 );
1758 assert_eq!(
1759 summary
1760 .miss_classifications
1761 .get("toolchain_profile_rustflags_target_mismatch"),
1762 Some(&1)
1763 );
1764 assert_eq!(
1765 summary
1766 .miss_classifications
1767 .get("lockfile_config_manifest_hash_mismatch"),
1768 Some(&1)
1769 );
1770 assert_eq!(
1771 summary
1772 .miss_classifications
1773 .get("zccache_compile_cache_miss_despite_equivalent_rustc_command"),
1774 Some(&3)
1775 );
1776 }
1777
1778 #[test]
1779 fn serialized_summary_recomputes_miss_classifications_from_session_stats() {
1780 let dir = tempfile::tempdir().unwrap();
1781 let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1782 let mut summary = RustPlanSummary::validation_success(&plan, &dir.path().join("cache"));
1783 summary.record_skip("<gha-cache>", "backend_cache_miss");
1784
1785 summary.compile_cache_stats = Some(serde_json::json!({
1786 "status": "ok",
1787 "cache_misses": 2,
1788 }));
1789
1790 let json = serde_json::to_value(&summary).unwrap();
1791 assert_eq!(
1792 json["miss_classifications"]["backend_cache_miss"].as_u64(),
1793 Some(1)
1794 );
1795 assert_eq!(
1796 json["miss_classifications"]
1797 ["zccache_compile_cache_miss_despite_equivalent_rustc_command"]
1798 .as_u64(),
1799 Some(2)
1800 );
1801 }
1802
1803 #[test]
1804 fn restore_skips_missing_wrong_size_and_wrong_hash_payloads() {
1805 let dir = tempfile::tempdir().unwrap();
1806 synthetic_target(dir.path());
1807 let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1808 let cache = dir.path().join("cache");
1809
1810 let saved = save_rust_plan_local(&plan, &cache).unwrap();
1811 assert_eq!(saved.saved_file_count, 6);
1812
1813 let bundle_dir = rust_plan_bundle_dir(&cache, &rust_plan_cache_key(&plan));
1814 let mut manifest = load_manifest(&bundle_dir);
1815
1816 std::fs::remove_file(
1817 bundle_dir
1818 .join(BUNDLE_FILES_DIR)
1819 .join("debug/deps/libserde-abc.rlib"),
1820 )
1821 .unwrap();
1822 manifest.artifacts[1].size += 1;
1823 manifest.artifacts[2].content_hash = "not-the-right-hash".to_string();
1824 write_manifest(&bundle_dir, &manifest);
1825
1826 std::fs::remove_dir_all(plan.target_dir.as_path()).unwrap();
1827 let restored = restore_rust_plan_local(&plan, &cache).unwrap();
1828
1829 assert_eq!(restored.restored_file_count, 3);
1830 assert_eq!(
1831 restored
1832 .skipped_reasons
1833 .get("restored_payload_missing_or_corrupt"),
1834 Some(&3)
1835 );
1836 assert_eq!(
1837 restored
1838 .miss_classifications
1839 .get("restored_payload_missing_or_corrupt"),
1840 Some(&3)
1841 );
1842 assert!(!plan
1843 .target_dir
1844 .join("debug/deps/libserde-abc.rlib")
1845 .exists());
1846 }
1847
1848 #[test]
1849 fn restore_skips_manifest_path_traversal_entries() {
1850 let dir = tempfile::tempdir().unwrap();
1851 synthetic_target(dir.path());
1852 let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1853 let cache = dir.path().join("cache");
1854
1855 save_rust_plan_local(&plan, &cache).unwrap();
1856
1857 let bundle_dir = rust_plan_bundle_dir(&cache, &rust_plan_cache_key(&plan));
1858 let mut manifest = load_manifest(&bundle_dir);
1859 manifest.artifacts[0].relative_path = "../escape.txt".to_string();
1860 write_manifest(&bundle_dir, &manifest);
1861
1862 std::fs::remove_dir_all(plan.target_dir.as_path()).unwrap();
1863 let restored = restore_rust_plan_local(&plan, &cache).unwrap();
1864
1865 assert_eq!(restored.restored_file_count, 5);
1866 assert_eq!(restored.skipped_count, 1);
1867 assert_eq!(restored.skipped_reasons.get("path_traversal"), Some(&1));
1868 assert!(!dir.path().join("escape.txt").exists());
1869 }
1870
1871 #[test]
1872 fn safe_join_rejects_path_traversal() {
1873 let err = safe_join(Path::new("root"), "../outside").unwrap_err();
1874 assert!(matches!(err, RustPlanError::UnsafeRelativePath(_)));
1875 }
1876
1877 #[test]
1878 fn package_name_parsing_handles_cargo_package_id_shapes() {
1879 assert_eq!(
1880 package_name_from_id(
1881 "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.0"
1882 ),
1883 Some("serde".to_string())
1884 );
1885 assert_eq!(
1886 package_name_from_id("path+file:///repo#my-crate@0.1.0"),
1887 Some("my_crate".to_string())
1888 );
1889 }
1890
1891 #[test]
1892 fn thin_package_exclusions_match_deps_fingerprint_and_build_by_package_stem() {
1893 let dir = tempfile::tempdir().unwrap();
1894 synthetic_target_with_package_exclusions(dir.path());
1895 let mut plan = sample_plan(dir.path(), RustPlanMode::Thin);
1896 plan.packages.workspace_package_ids =
1897 vec!["registry+https://github.com/rust-lang/crates.io-index#app@0.1.0".to_string()];
1898 plan.packages.excluded_path_package_ids =
1899 vec!["path+file:///workspace/local_dep#local-dep@0.1.0".to_string()];
1900 let cache = dir.path().join("cache");
1901
1902 let saved = save_rust_plan_local(&plan, &cache).unwrap();
1903 assert_eq!(saved.saved_file_count, 6);
1904 assert_eq!(
1905 saved
1906 .skipped_reasons
1907 .get("workspace_or_path_dependency_excluded_by_plan"),
1908 Some(&12)
1909 );
1910 assert_eq!(saved.skipped_reasons.get("transient_state"), Some(&1));
1911 assert!(saved
1912 .skipped_samples
1913 .iter()
1914 .any(|sample| sample.path.ends_with("debug/deps/libapp-abc.rlib")));
1915 assert!(saved.skipped_samples.iter().any(|sample| sample
1916 .path
1917 .ends_with("debug/.fingerprint/app-abc/dep-lib-app")));
1918 assert!(saved.skipped_samples.iter().any(|sample| sample
1919 .path
1920 .ends_with("debug/build/local_dep-abc/out/gen.rs")));
1921 }
1922 #[test]
1923 fn from_json_str_accepts_utf8_bom() {
1924 let dir = tempfile::tempdir().unwrap();
1925 let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1926 let json = serde_json::to_string(&plan).unwrap();
1927 let loaded = RustArtifactPlanV1::from_json_str(&format!("\u{feff}{json}")).unwrap();
1928 assert_eq!(loaded.schema_version, 1);
1929 }
1930
1931 #[test]
1934 fn tar_threads_parser_accepts_grammar_from_soldr_273() {
1935 let default = default_rust_plan_tar_threads();
1937 assert!((1..=DEFAULT_RUST_PLAN_TAR_THREADS_CAP).contains(&default));
1938 assert_eq!(parse_rust_plan_tar_threads(None), default);
1939 assert_eq!(parse_rust_plan_tar_threads(Some("auto")), default);
1940 assert_eq!(parse_rust_plan_tar_threads(Some("AUTO")), default);
1941 assert_eq!(parse_rust_plan_tar_threads(Some("")), default);
1942 assert_eq!(parse_rust_plan_tar_threads(Some(" ")), default);
1943
1944 assert_eq!(parse_rust_plan_tar_threads(Some("1")), 1);
1946
1947 assert_eq!(parse_rust_plan_tar_threads(Some("4")), 4);
1949 assert_eq!(
1950 parse_rust_plan_tar_threads(Some("9999")),
1951 MAX_RUST_PLAN_TAR_THREADS
1952 );
1953
1954 assert_eq!(parse_rust_plan_tar_threads(Some("0")), default);
1956 assert_eq!(parse_rust_plan_tar_threads(Some("not-a-number")), default);
1957 assert_eq!(parse_rust_plan_tar_threads(Some("-1")), default);
1958 }
1959
1960 #[test]
1961 fn parallel_bundling_matches_sequential_byte_for_byte() {
1962 fn bundle_with(threads: usize) -> Vec<RustBundledArtifact> {
1966 let dir = tempfile::tempdir().unwrap();
1967 synthetic_target(dir.path());
1968 let plan = sample_plan(dir.path(), RustPlanMode::Thin);
1969
1970 let mut candidates = Vec::new();
1971 collect_files(plan.target_dir.as_path(), &mut candidates).unwrap();
1972 candidates.sort();
1973 let mut summary = RustPlanSummary::new(
1974 RustPlanOperation::Save,
1975 plan.mode,
1976 plan.schema_version,
1977 plan.cache_schema_version,
1978 rust_plan_cache_key(&plan),
1979 None,
1980 None,
1981 );
1982 let selected = select_artifacts(&plan, candidates, &mut summary);
1983
1984 let files_dir = dir.path().join("out").join(format!("t{threads}"));
1985 std::fs::create_dir_all(&files_dir).unwrap();
1986 bundle_selected_artifacts_with_threads(&selected, &files_dir, threads).unwrap()
1987 }
1988
1989 let sequential = bundle_with(1);
1990 let parallel = bundle_with(4);
1991
1992 assert!(!sequential.is_empty());
1993 assert_eq!(sequential.len(), parallel.len());
1994 for (seq, par) in sequential.iter().zip(parallel.iter()) {
1995 assert_eq!(seq.relative_path, par.relative_path);
1996 assert_eq!(seq.size, par.size);
1997 assert_eq!(seq.content_hash, par.content_hash);
1998 assert_eq!(seq.class, par.class);
1999 }
2000 }
2001}