Skip to content

Commit 1acaf86

Browse files
committed
Confine cache and evidence cleanup
1 parent cf65f25 commit 1acaf86

9 files changed

Lines changed: 230 additions & 45 deletions

File tree

‎crates/cellscript-tools/src/ckb_acceptance.rs‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -591,7 +591,7 @@ pub fn run(
591591
fs::create_dir_all(&run_dir)?;
592592
let report_root = run_dir.parent().context("managed acceptance run has no report root")?;
593593
let marker = format!("-{mode}-");
594-
let removed = prune_run_directories(report_root, &run_dir, &marker)?;
594+
let removed = prune_run_directories(root, report_root, &run_dir, &marker)?;
595595
if !removed.is_empty() {
596596
println!("CKB acceptance retention: pruned {} old {mode} run(s)", removed.len());
597597
}
@@ -603,7 +603,7 @@ pub fn run(
603603
eprintln!("CKB compile-only production evidence is not sufficient for external release; run without --compile-only for final hardening.");
604604
}
605605
if managed_run {
606-
finalize_managed_run(&run_dir, &evidence.report_path, mode)?;
606+
finalize_managed_run(root, &run_dir, &evidence.report_path, mode)?;
607607
}
608608
println!("CKB CellScript {mode} compile-only acceptance passed: {}", evidence.report_path.display());
609609
return Ok(0);
@@ -614,16 +614,16 @@ pub fn run(
614614
production_evidence::run(root, &evidence.report_path, Some(root), false)?;
615615
}
616616
if managed_run {
617-
finalize_managed_run(&run_dir, &evidence.report_path, mode)?;
617+
finalize_managed_run(root, &run_dir, &evidence.report_path, mode)?;
618618
}
619619
println!("CKB CellScript {mode} acceptance passed: {}", evidence.report_path.display());
620620
Ok(0)
621621
}
622622

623-
fn finalize_managed_run(run_dir: &Path, report_path: &Path, mode: &str) -> Result<()> {
623+
fn finalize_managed_run(root: &Path, run_dir: &Path, report_path: &Path, mode: &str) -> Result<()> {
624624
let report_root = run_dir.parent().context("managed acceptance run has no report root")?;
625625
let marker = format!("-{mode}-");
626-
let stats = deduplicate_run(report_root, run_dir, &marker)?;
626+
let stats = deduplicate_run(root, report_root, run_dir, &marker)?;
627627
if stats.files > 0 {
628628
println!("CKB acceptance deduplication: hardlinked {} duplicate file(s), {} bytes", stats.files, stats.bytes);
629629
}
@@ -632,7 +632,7 @@ fn finalize_managed_run(run_dir: &Path, report_path: &Path, mode: &str) -> Resul
632632
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
633633
.and_then(|report| report.get("status").and_then(Value::as_str).map(ToOwned::to_owned))
634634
.unwrap_or_else(|| "unknown".to_owned());
635-
write_latest_index(&report_root.join(format!("latest-{mode}.json")), report_path, "ckb-cellscript-acceptance", mode, &status)
635+
write_latest_index(root, &report_root.join(format!("latest-{mode}.json")), report_path, "ckb-cellscript-acceptance", mode, &status)
636636
}
637637

638638
#[cfg(test)]

‎crates/cellscript-tools/src/ckb_acceptance_live.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,7 @@ pub(crate) fn run(
787787
if !keep_node {
788788
replayer.devnet.stop();
789789
if !keep_gate_workdirs()? {
790-
remove_directory_if_present(&replayer.devnet.ckb_dir.join("data"))?;
790+
remove_directory_if_present(&evidence.run_dir, &replayer.devnet.ckb_dir.join("data"))?;
791791
}
792792
}
793793
Ok(())

‎crates/cellscript-tools/src/evidence_retention.rs‎

Lines changed: 79 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
99
use std::collections::BTreeMap;
1010
use std::env;
11-
use std::fs::{self, File};
11+
use std::fs::{self, File, OpenOptions};
1212
use std::io::{Read, Write};
1313
use std::path::{Path, PathBuf};
1414

@@ -57,10 +57,21 @@ fn file_name(path: &Path) -> Option<&str> {
5757
path.file_name().and_then(|name| name.to_str())
5858
}
5959

60-
fn matching_children(parent: &Path, current: &Path, marker: &str, directories: bool) -> Result<Vec<PathBuf>> {
60+
fn ensure_confined(root: &Path, path: &Path, label: &str) -> Result<()> {
61+
let canonical_root = fs::canonicalize(root).with_context(|| format!("failed to resolve evidence root {}", root.display()))?;
62+
let canonical_path = fs::canonicalize(path).with_context(|| format!("failed to resolve {label} {}", path.display()))?;
63+
if !canonical_path.starts_with(&canonical_root) {
64+
bail!("refusing to access {label} outside evidence root {}: {}", canonical_root.display(), canonical_path.display());
65+
}
66+
Ok(())
67+
}
68+
69+
fn matching_children(root: &Path, parent: &Path, current: &Path, marker: &str, directories: bool) -> Result<Vec<PathBuf>> {
6170
if !parent.is_dir() {
6271
return Ok(Vec::new());
6372
}
73+
ensure_confined(root, parent, "evidence directory")?;
74+
ensure_confined(root, current, "current evidence path")?;
6475
let mut paths = Vec::new();
6576
for entry in fs::read_dir(parent).with_context(|| format!("failed to read evidence directory {}", parent.display()))? {
6677
let entry = entry?;
@@ -80,15 +91,15 @@ fn matching_children(parent: &Path, current: &Path, marker: &str, directories: b
8091
Ok(paths)
8192
}
8293

83-
pub(crate) fn prune_run_directories(parent: &Path, current: &Path, marker: &str) -> Result<Vec<PathBuf>> {
94+
pub(crate) fn prune_run_directories(root: &Path, parent: &Path, current: &Path, marker: &str) -> Result<Vec<PathBuf>> {
8495
let Some(keep) = configured_keep_runs()? else {
8596
return Ok(Vec::new());
8697
};
87-
prune_run_directories_with_limit(parent, current, marker, keep)
98+
prune_run_directories_with_limit(root, parent, current, marker, keep)
8899
}
89100

90-
fn prune_run_directories_with_limit(parent: &Path, current: &Path, marker: &str, keep: usize) -> Result<Vec<PathBuf>> {
91-
let mut candidates = matching_children(parent, current, marker, true)?;
101+
fn prune_run_directories_with_limit(root: &Path, parent: &Path, current: &Path, marker: &str, keep: usize) -> Result<Vec<PathBuf>> {
102+
let mut candidates = matching_children(root, parent, current, marker, true)?;
92103
let remove_from = keep.saturating_sub(1).min(candidates.len());
93104
let removed = candidates.split_off(remove_from);
94105
for path in &removed {
@@ -97,11 +108,11 @@ fn prune_run_directories_with_limit(parent: &Path, current: &Path, marker: &str,
97108
Ok(removed)
98109
}
99110

100-
pub(crate) fn prune_report_files(parent: &Path, current: &Path, marker: &str) -> Result<Vec<PathBuf>> {
111+
pub(crate) fn prune_report_files(root: &Path, parent: &Path, current: &Path, marker: &str) -> Result<Vec<PathBuf>> {
101112
let Some(keep) = configured_keep_runs()? else {
102113
return Ok(Vec::new());
103114
};
104-
let mut candidates = matching_children(parent, current, marker, false)?;
115+
let mut candidates = matching_children(root, parent, current, marker, false)?;
105116
let remove_from = keep.saturating_sub(1).min(candidates.len());
106117
let removed = candidates.split_off(remove_from);
107118
for path in &removed {
@@ -169,8 +180,8 @@ fn replace_with_hardlink(source: &Path, destination: &Path, expected_hash: &[u8;
169180
/// Hardlink immutable files in `current` to byte-identical files in sibling
170181
/// evidence runs. Paths and bytes stay unchanged; unsupported filesystems
171182
/// simply retain separate copies.
172-
pub(crate) fn deduplicate_run(parent: &Path, current: &Path, marker: &str) -> Result<DedupStats> {
173-
let siblings = matching_children(parent, current, marker, true)?;
183+
pub(crate) fn deduplicate_run(root: &Path, parent: &Path, current: &Path, marker: &str) -> Result<DedupStats> {
184+
let siblings = matching_children(root, parent, current, marker, true)?;
174185
if !current.is_dir() {
175186
return Ok(DedupStats::default());
176187
}
@@ -207,12 +218,13 @@ pub(crate) fn deduplicate_run(parent: &Path, current: &Path, marker: &str) -> Re
207218
Ok(stats)
208219
}
209220

210-
pub(crate) fn remove_directory_if_present(path: &Path) -> Result<bool> {
221+
pub(crate) fn remove_directory_if_present(root: &Path, path: &Path) -> Result<bool> {
211222
match fs::symlink_metadata(path) {
212223
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
213224
bail!("refusing to remove non-directory gate work path {}", path.display())
214225
}
215226
Ok(_) => {
227+
ensure_confined(root, path, "gate work directory")?;
216228
fs::remove_dir_all(path).with_context(|| format!("failed to remove gate work directory {}", path.display()))?;
217229
Ok(true)
218230
}
@@ -221,10 +233,13 @@ pub(crate) fn remove_directory_if_present(path: &Path) -> Result<bool> {
221233
}
222234
}
223235

224-
pub(crate) fn write_latest_index(path: &Path, report_path: &Path, kind: &str, mode: &str, status: &str) -> Result<()> {
236+
pub(crate) fn write_latest_index(root: &Path, path: &Path, report_path: &Path, kind: &str, mode: &str, status: &str) -> Result<()> {
237+
let parent = path.parent().context("latest evidence index has no parent")?;
238+
ensure_confined(root, parent, "latest evidence index directory")?;
239+
ensure_confined(root, report_path, "evidence report")?;
225240
let report_bytes = fs::read(report_path).with_context(|| format!("failed to read {}", report_path.display()))?;
226241
let report_sha256 = hex::encode(Sha256::digest(&report_bytes));
227-
let relative = report_path.strip_prefix(path.parent().unwrap_or_else(|| Path::new("."))).unwrap_or(report_path);
242+
let relative = report_path.strip_prefix(parent).unwrap_or(report_path);
228243
let index = serde_json::json!({
229244
"schema": "cellscript-local-evidence-index-v1",
230245
"kind": kind,
@@ -236,10 +251,27 @@ pub(crate) fn write_latest_index(path: &Path, report_path: &Path, kind: &str, mo
236251
"size_bytes": report_bytes.len(),
237252
},
238253
});
239-
let mut output = File::create(path).with_context(|| format!("failed to write {}", path.display()))?;
240-
serde_json::to_writer_pretty(&mut output, &index)?;
241-
output.write_all(b"\n")?;
242-
Ok(())
254+
let temporary = parent.join(format!(
255+
".cellscript-latest-{}-{}-{}.tmp",
256+
std::process::id(),
257+
kind.replace(|character: char| !character.is_ascii_alphanumeric(), "_"),
258+
mode.replace(|character: char| !character.is_ascii_alphanumeric(), "_")
259+
));
260+
let write_result = (|| -> Result<()> {
261+
let mut output = OpenOptions::new()
262+
.write(true)
263+
.create_new(true)
264+
.open(&temporary)
265+
.with_context(|| format!("failed to create temporary latest index {}", temporary.display()))?;
266+
serde_json::to_writer_pretty(&mut output, &index)?;
267+
output.write_all(b"\n")?;
268+
output.sync_all()?;
269+
fs::rename(&temporary, path).with_context(|| format!("failed to publish latest evidence index {}", path.display()))
270+
})();
271+
if write_result.is_err() {
272+
let _ = fs::remove_file(&temporary);
273+
}
274+
write_result
243275
}
244276

245277
#[cfg(test)]
@@ -262,7 +294,7 @@ mod tests {
262294
let current = parent.join("run-5-quick");
263295
fs::create_dir(&current).unwrap();
264296

265-
let removed = prune_run_directories_with_limit(&parent, &current, "-quick", 3).unwrap();
297+
let removed = prune_run_directories_with_limit(&parent, &parent, &current, "-quick", 3).unwrap();
266298
assert_eq!(removed.len(), 1);
267299
assert!(current.is_dir());
268300
assert!(parent.join("run-3-quick").is_dir());
@@ -272,6 +304,33 @@ mod tests {
272304
fs::remove_dir_all(parent).unwrap();
273305
}
274306

307+
#[cfg(unix)]
308+
#[test]
309+
fn pruning_refuses_a_symlinked_evidence_root() {
310+
use std::os::unix::fs::symlink;
311+
312+
let parent = test_root("confined-prune");
313+
let repository = parent.join("repository");
314+
let outside = parent.join("outside");
315+
fs::create_dir(&repository).unwrap();
316+
fs::create_dir(&outside).unwrap();
317+
fs::create_dir(outside.join("1-quick-old")).unwrap();
318+
fs::create_dir(outside.join("2-quick-current")).unwrap();
319+
symlink(&outside, repository.join("evidence")).unwrap();
320+
321+
let error = prune_run_directories_with_limit(
322+
&repository,
323+
&repository.join("evidence"),
324+
&repository.join("evidence/2-quick-current"),
325+
"-quick-",
326+
1,
327+
)
328+
.unwrap_err();
329+
assert!(error.to_string().contains("outside evidence root"));
330+
assert!(outside.join("1-quick-old").is_dir());
331+
fs::remove_dir_all(parent).unwrap();
332+
}
333+
275334
#[cfg(unix)]
276335
#[test]
277336
fn deduplication_preserves_paths_and_bytes() {
@@ -286,7 +345,7 @@ mod tests {
286345
fs::write(previous.join("artifact.elf"), &bytes).unwrap();
287346
fs::write(current.join("artifact.elf"), &bytes).unwrap();
288347

289-
let stats = deduplicate_run(&parent, &current, "-production").unwrap();
348+
let stats = deduplicate_run(&parent, &parent, &current, "-production").unwrap();
290349
assert_eq!(stats, DedupStats { files: 1, bytes: DEDUP_MIN_BYTES });
291350
assert_eq!(fs::read(current.join("artifact.elf")).unwrap(), bytes);
292351
assert_eq!(
@@ -308,7 +367,7 @@ mod tests {
308367
fs::write(current.join("left.json"), &bytes).unwrap();
309368
fs::write(current.join("right.json"), &bytes).unwrap();
310369

311-
let stats = deduplicate_run(&parent, &current, "-bounded").unwrap();
370+
let stats = deduplicate_run(&parent, &parent, &current, "-bounded").unwrap();
312371
assert_eq!(stats, DedupStats { files: 1, bytes: DEDUP_MIN_BYTES });
313372
assert_eq!(fs::metadata(current.join("left.json")).unwrap().ino(), fs::metadata(current.join("right.json")).unwrap().ino());
314373
fs::remove_dir_all(parent).unwrap();

‎crates/cellscript-tools/src/strict_backend.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,9 +314,9 @@ pub fn run(root: &Path, mode: &str) -> Result<i32> {
314314
.with_context(|| format!("failed to write {}", report_path.display()))?;
315315
if managed_report {
316316
let latest = report_parent.join(format!("latest-{mode}.json"));
317-
write_latest_index(&latest, &report_path, "strict-backend-audit", mode, if passed { "passed" } else { "failed" })?;
317+
write_latest_index(root, &latest, &report_path, "strict-backend-audit", mode, if passed { "passed" } else { "failed" })?;
318318
let marker = format!("strict-backend-audit-{mode}-");
319-
let removed = prune_report_files(report_parent, &report_path, &marker)?;
319+
let removed = prune_report_files(root, report_parent, &report_path, &marker)?;
320320
if !removed.is_empty() {
321321
println!("strict backend audit retention: pruned {} old {mode} report(s)", removed.len());
322322
}

‎crates/cellscript-tools/src/syntax_combo.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,7 +1257,7 @@ pub fn run(root: &Path, mode: &str, seed: u64, budget: Option<usize>, case_name:
12571257
let run_dir = report_root.join(format!("{timestamp}-{mode}-{seed}"));
12581258
fs::create_dir_all(&run_dir)?;
12591259
let retention_marker = format!("-{mode}-");
1260-
let pruned = prune_run_directories(&report_root, &run_dir, &retention_marker)?;
1260+
let pruned = prune_run_directories(root, &report_root, &run_dir, &retention_marker)?;
12611261
if !pruned.is_empty() {
12621262
println!("syntax-combo-audit retention: pruned {} old {mode} run(s)", pruned.len());
12631263
}
@@ -1325,10 +1325,11 @@ pub fn run(root: &Path, mode: &str, seed: u64, budget: Option<usize>, case_name:
13251325
write_reports(&run_dir, &report, &failures)?;
13261326
if !retain_intermediates {
13271327
for child in ["cases", "parse_reject", "fmt", "asm", "meta", "shrink"] {
1328-
remove_directory_if_present(&run_dir.join(child))?;
1328+
remove_directory_if_present(root, &run_dir.join(child))?;
13291329
}
13301330
}
13311331
write_latest_index(
1332+
root,
13321333
&report_root.join(format!("latest-{mode}.json")),
13331334
&run_dir.join("report.json"),
13341335
"syntax-combo-audit",

‎docs/releases/CELLSCRIPT_0_25_RELEASE_NOTES.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,22 @@ one-to-one correspondence, fresh identity, and capacity rules for
9999
`BoundedList`/`create_each`. Builder evidence is not accepted as a replacement
100100
for those on-chain checks.
101101

102+
## Bounded Local Evidence Storage
103+
104+
Compiler caches and local gate evidence no longer grow without a default
105+
bound. Incremental caches keep the 32 most recently used identities per root;
106+
managed syntax, strict-backend, and CKB-acceptance streams keep three runs per
107+
mode. Successful syntax audits discard reproducible per-case intermediates,
108+
and production acceptance removes its transient Cargo target and stopped-node
109+
database while preserving the reports and verified artifacts that identify the
110+
run. Matching large files are hardlinked only after SHA-256 comparison.
111+
112+
`latest-<mode>.json` indexes retain the exact report path, hash, size, and
113+
status. Operators can override the bounds for an external archiver or a debug
114+
session. Automatic retention and `cellc clean --cache` are confined to their
115+
workspace/evidence roots and reject symlinked managed path components instead
116+
of following them outside the repository.
117+
102118
## Validation Before Release
103119

104120
The implementation is not release-complete until the generated surface and

‎src/cli/commands.rs‎

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -822,8 +822,10 @@ fn collect_workspace_incremental_caches(root: &Path, caches: &mut Vec<PathBuf>)
822822
}
823823
let name = entry.file_name();
824824
if name == ".cell" {
825-
let cache = path.join("build/cache");
826-
if cache.is_dir() {
825+
let build = path.join("build");
826+
let Some(build) = managed_directory_if_present(&build)? else { continue };
827+
let cache = build.join("cache");
828+
if let Some(cache) = managed_directory_if_present(&cache)? {
827829
caches.push(cache);
828830
}
829831
continue;
@@ -836,6 +838,50 @@ fn collect_workspace_incremental_caches(root: &Path, caches: &mut Vec<PathBuf>)
836838
Ok(())
837839
}
838840

841+
fn managed_directory_if_present(path: &Path) -> Result<Option<PathBuf>> {
842+
match std::fs::symlink_metadata(path) {
843+
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => Err(CompileError::without_span(format!(
844+
"refusing managed cache path with a symlink or non-directory component: '{}'",
845+
path.display()
846+
))),
847+
Ok(_) => Ok(Some(path.to_path_buf())),
848+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
849+
Err(error) => Err(error.into()),
850+
}
851+
}
852+
853+
fn ensure_workspace_directory_for_removal(workspace_root: &Path, path: &Path) -> Result<PathBuf> {
854+
let canonical_root = std::fs::canonicalize(workspace_root)?;
855+
let absolute = if path.is_absolute() { path.to_path_buf() } else { canonical_root.join(path) };
856+
let canonical_path = std::fs::canonicalize(&absolute)?;
857+
if !canonical_path.starts_with(&canonical_root) {
858+
return Err(CompileError::without_span(format!(
859+
"refusing to clean path outside workspace '{}': '{}'",
860+
canonical_root.display(),
861+
canonical_path.display()
862+
)));
863+
}
864+
let relative = absolute
865+
.strip_prefix(&canonical_root)
866+
.map_err(|_| CompileError::without_span(format!("refusing non-workspace clean path '{}'", absolute.display())))?;
867+
let mut cursor = canonical_root;
868+
for component in relative.components() {
869+
let std::path::Component::Normal(component) = component else {
870+
return Err(CompileError::without_span(format!("refusing non-canonical clean path '{}'", path.display())));
871+
};
872+
cursor.push(component);
873+
let metadata = std::fs::symlink_metadata(&cursor)?;
874+
if metadata.file_type().is_symlink() {
875+
return Err(CompileError::without_span(format!("refusing to clean through symlink '{}'", cursor.display())));
876+
}
877+
}
878+
let metadata = std::fs::symlink_metadata(&canonical_path)?;
879+
if !metadata.is_dir() {
880+
return Err(CompileError::without_span(format!("refusing to clean non-directory path '{}'", path.display())));
881+
}
882+
Ok(canonical_path)
883+
}
884+
839885
impl CommandExecutor {
840886
#[cfg(not(feature = "vm-runner"))]
841887
fn experimental_command(name: &str, detail: &str) -> Result<()> {
@@ -1724,6 +1770,7 @@ impl CommandExecutor {
17241770
}
17251771

17261772
fn clean(args: CleanArgs) -> Result<()> {
1773+
let workspace_root = std::fs::canonicalize(".")?;
17271774
let mut paths = vec![PathBuf::from("target"), PathBuf::from(".cell/cache")];
17281775
if args.cache {
17291776
collect_workspace_incremental_caches(Path::new("."), &mut paths)?;
@@ -1734,11 +1781,8 @@ impl CommandExecutor {
17341781

17351782
for path in paths {
17361783
if path.exists() {
1737-
let metadata = std::fs::symlink_metadata(&path)?;
1738-
if metadata.file_type().is_symlink() || !metadata.is_dir() {
1739-
return Err(CompileError::without_span(format!("refusing to clean non-directory path '{}'", path.display())));
1740-
}
1741-
std::fs::remove_dir_all(&path)?;
1784+
let removable = ensure_workspace_directory_for_removal(&workspace_root, &path)?;
1785+
std::fs::remove_dir_all(removable)?;
17421786
removed_paths.push(path.to_string_lossy().into_owned());
17431787
}
17441788
}

0 commit comments

Comments
 (0)