1use std::collections::BTreeSet;
10use std::ffi::OsStr;
11use std::fs;
12use std::path::{Component, Path, PathBuf};
13use std::sync::Mutex;
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16use serde::Serialize;
17use sha2::{Digest, Sha256};
18use tracing::{debug, info};
19
20use crate::errors::{Result, WinxError};
21
22fn temp_root() -> PathBuf {
25 Path::new(".winx").join("tmp")
26}
27const SESSION_PREFIX: &str = "session-";
28const SESSION_HASH_BYTES: usize = 8;
29
30pub const MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
33pub const MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
35pub const MAX_SESSION_BYTES: u64 = 64 * 1024 * 1024;
37pub const MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
39pub const MAX_SESSION_FILES: usize = 128;
41pub const ACTIVE_SESSION_PRUNE_TRIGGER_FILES: usize = MAX_SESSION_FILES * 3 / 4;
43pub const ACTIVE_SESSION_PRUNE_TARGET_FILES: usize = MAX_SESSION_FILES / 2;
45pub const ACTIVE_SESSION_PRUNE_TRIGGER_BYTES: u64 = MAX_SESSION_BYTES * 3 / 4;
47pub const ACTIVE_SESSION_PRUNE_TARGET_BYTES: u64 = MAX_SESSION_BYTES / 2;
49pub const MAX_DERIVED_CODE_MAP_PAYLOAD_BYTES: usize = 12 * 1024;
51pub const MAX_DERIVED_CODE_MAP_UNIQUE_FILES: usize = 24;
53pub const MAX_DERIVED_CODE_MAP_CALLS: usize = 64;
55pub const MAX_RELATIVE_COMPONENTS: usize = 8;
57pub const MAX_COMPONENT_BYTES: usize = 120;
59pub const MAX_RELATIVE_PATH_BYTES: usize = 512;
61
62const MAX_SCAN_ENTRIES: usize = 50_000;
63const MAX_SCAN_DEPTH: usize = 16;
64static TEMP_IO_LOCK: Mutex<()> = Mutex::new(());
65
66#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct AgentTempInfo {
69 pub directory: PathBuf,
70 pub ttl_seconds: u64,
71 pub max_total_bytes: u64,
72 pub max_session_bytes: u64,
73 pub max_file_bytes: u64,
74 pub max_session_files: usize,
75 pub stale_pruned_files: usize,
76 pub stale_pruned_bytes: u64,
77}
78
79#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
82pub struct TemporaryArtifactUsage {
83 pub temporary_artifact_dir: PathBuf,
84 pub total_bytes: u64,
85 pub max_total_bytes: u64,
86 pub session_bytes: u64,
87 pub max_session_bytes: u64,
88 pub session_files: usize,
89 pub max_session_files: usize,
90 pub largest_file_bytes: u64,
91 pub max_file_bytes: u64,
92 pub stale_pruned_files: usize,
93 pub stale_pruned_bytes: u64,
94 pub over_budget: bool,
95}
96
97impl TemporaryArtifactUsage {
98 pub fn requires_cleanup(&self) -> bool {
99 self.over_budget
100 }
101
102 fn as_budget_error(&self) -> WinxError {
103 WinxError::TemporaryArtifactBudgetExceeded {
104 temporary_artifact_dir: self.temporary_artifact_dir.clone(),
105 total_bytes: self.total_bytes,
106 max_total_bytes: self.max_total_bytes,
107 session_bytes: self.session_bytes,
108 max_session_bytes: self.max_session_bytes,
109 session_files: self.session_files,
110 max_session_files: self.max_session_files,
111 largest_file_bytes: self.largest_file_bytes,
112 max_file_bytes: self.max_file_bytes,
113 }
114 }
115}
116
117#[derive(Clone, Debug, Default)]
123pub struct DerivedCodeMapUsage {
124 mapped_files: BTreeSet<PathBuf>,
125 calls: usize,
126}
127
128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
130pub struct DerivedCodeMapPermit {
131 pub calls_used: usize,
132 pub calls_limit: usize,
133 pub unique_files_used: usize,
134 pub unique_files_limit: usize,
135}
136
137#[derive(Clone, Copy, Debug)]
140pub struct TempEdit<'a> {
141 pub path: &'a Path,
142 pub previous_bytes: u64,
143 pub new_bytes: u64,
144 pub is_new: bool,
145}
146
147pub fn session_info(workspace_root: &Path, thread_id: &str) -> AgentTempInfo {
149 let workspace = workspace_root
150 .canonicalize()
151 .map_or_else(|_| workspace_root.to_path_buf(), crate::utils::path::simplified);
152 AgentTempInfo {
153 directory: workspace.join(temp_root()).join(session_name(thread_id)),
154 ttl_seconds: MAX_AGE.as_secs(),
155 max_total_bytes: MAX_TOTAL_BYTES,
156 max_session_bytes: MAX_SESSION_BYTES,
157 max_file_bytes: MAX_FILE_BYTES,
158 max_session_files: MAX_SESSION_FILES,
159 stale_pruned_files: 0,
160 stale_pruned_bytes: 0,
161 }
162}
163
164pub fn prepare_session(workspace_root: &Path, thread_id: &str) -> AgentTempInfo {
169 let mut info = session_info(workspace_root, thread_id);
170 let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
171 prune_sessions_unlocked(workspace_root, &info.directory, MAX_AGE);
172 let pruned = prune_active_session_unlocked(&info.directory, MAX_AGE);
173 info.stale_pruned_files = pruned.files;
174 info.stale_pruned_bytes = pruned.bytes;
175 log_active_prune(&info.directory, pruned);
176 info
177}
178
179pub fn audit_session(workspace_root: &Path, thread_id: &str) -> Result<TemporaryArtifactUsage> {
183 audit_session_impl(workspace_root, thread_id, false)
184}
185
186pub fn maintain_and_audit_session(
189 workspace_root: &Path,
190 thread_id: &str,
191) -> Result<TemporaryArtifactUsage> {
192 audit_session_impl(workspace_root, thread_id, true)
193}
194
195fn audit_session_impl(
196 workspace_root: &Path,
197 thread_id: &str,
198 maintain: bool,
199) -> Result<TemporaryArtifactUsage> {
200 let info = session_info(workspace_root, thread_id);
201 let workspace = workspace_root
202 .canonicalize()
203 .map_or_else(|_| workspace_root.to_path_buf(), crate::utils::path::simplified);
204 let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
205 let pruned = if maintain {
206 prune_sessions_unlocked(workspace_root, &info.directory, MAX_AGE);
207 prune_active_session_unlocked(&info.directory, MAX_AGE)
208 } else {
209 PrunedArtifacts::default()
210 };
211 log_active_prune(&info.directory, pruned);
212 let usage =
213 temp_tree_usage(&workspace.join(temp_root()), &info.directory).ok_or_else(|| {
214 policy_error(
215 &info.directory,
216 &info,
217 "temporary storage could not be measured safely; remove malformed or excessive \
218 artifacts before retrying"
219 .to_string(),
220 )
221 })?;
222 let over_budget = usage.total_bytes > MAX_TOTAL_BYTES
223 || usage.session_bytes > MAX_SESSION_BYTES
224 || usage.session_files > MAX_SESSION_FILES
225 || usage.largest_file_bytes > MAX_FILE_BYTES;
226 Ok(TemporaryArtifactUsage {
227 temporary_artifact_dir: info.directory,
228 total_bytes: usage.total_bytes,
229 max_total_bytes: MAX_TOTAL_BYTES,
230 session_bytes: usage.session_bytes,
231 max_session_bytes: MAX_SESSION_BYTES,
232 session_files: usage.session_files,
233 max_session_files: MAX_SESSION_FILES,
234 largest_file_bytes: usage.largest_file_bytes,
235 max_file_bytes: MAX_FILE_BYTES,
236 stale_pruned_files: pruned.files,
237 stale_pruned_bytes: pruned.bytes,
238 over_budget,
239 })
240}
241
242pub fn validate_edit_target(
246 workspace_root: &Path,
247 thread_id: &str,
248 requested_path: &Path,
249 resolved_path: &Path,
250 previous_bytes: Option<u64>,
251 new_bytes: u64,
252) -> Result<()> {
253 let info = session_info(workspace_root, thread_id);
254 let workspace = workspace_root
255 .canonicalize()
256 .map_or_else(|_| workspace_root.to_path_buf(), crate::utils::path::simplified);
257 let requested_relative = workspace_relative(requested_path, workspace_root, &workspace);
264 let resolved_relative = workspace_relative(resolved_path, workspace_root, &workspace);
265
266 if let Some(relative) = resolved_relative.as_deref() {
267 reject_legacy_root_artifact(relative, previous_bytes.is_none(), requested_path, &info)?;
268 }
269 if requested_relative != resolved_relative {
270 if let Some(relative) = requested_relative.as_deref() {
271 reject_legacy_root_artifact(relative, previous_bytes.is_none(), requested_path, &info)?;
272 }
273 }
274
275 let requested_temp = requested_relative.as_deref().is_some_and(is_temp_relative);
276 let resolved_temp = resolved_relative.as_deref().is_some_and(is_temp_relative);
277 if !requested_temp && !resolved_temp {
278 return Ok(());
279 }
280
281 reject_symlinked_temp_root(&workspace, requested_path, &info)?;
282 let policy_path = resolved_relative
283 .as_deref()
284 .map_or_else(|| resolved_path.to_path_buf(), |relative| workspace.join(relative));
285 validate_session_relative_path(&policy_path, &info)?;
286 if new_bytes > MAX_FILE_BYTES {
287 return Err(policy_error(
288 &policy_path,
289 &info,
290 format!(
291 "helper content is {new_bytes} bytes; one temporary file is limited to \
292 {MAX_FILE_BYTES} bytes"
293 ),
294 ));
295 }
296
297 Ok(())
298}
299
300pub fn validate_bash_command(
307 workspace_root: &Path,
308 cwd: &Path,
309 thread_id: &str,
310 command: &str,
311) -> Result<()> {
312 let info = session_info(workspace_root, thread_id);
313 let recovery_command = is_explicit_temp_recovery(command, &info);
314 let usage = match maintain_and_audit_session(workspace_root, thread_id) {
315 Ok(usage) => usage,
316 Err(_) if recovery_command => return Ok(()),
320 Err(error) => return Err(error),
321 };
322 if usage.requires_cleanup() && !recovery_command {
323 return Err(usage.as_budget_error());
324 }
325 let mut paths = crate::utils::bash_parser::extract_static_write_paths(command);
326 paths.extend(embedded_writer_paths(command));
327 paths.sort();
328 paths.dedup();
329
330 let mut managed_targets = Vec::new();
331 for path in paths {
332 if let Some(target) = validate_bash_write_target(workspace_root, cwd, &path, &info)? {
333 managed_targets.push(target);
334 }
335 }
336 validate_known_bash_targets_quota(&info, &managed_targets, TempTreeUsage::from(&usage))?;
337 Ok(())
338}
339
340fn is_explicit_temp_recovery(command: &str, info: &AgentTempInfo) -> bool {
345 let directory = info.directory.to_string_lossy();
346 let names_active_dir = command.contains("$WINX_TEMP_DIR")
347 || command.contains("${WINX_TEMP_DIR}")
348 || command.contains(directory.as_ref());
349 if !names_active_dir {
350 return false;
351 }
352 let Ok(commands) = crate::utils::bash_parser::extract_command_texts(command) else {
353 return false;
354 };
355 !commands.is_empty()
356 && commands.iter().all(|statement| {
357 let executable = statement
358 .split_whitespace()
359 .next()
360 .and_then(|word| Path::new(word).file_name())
361 .and_then(OsStr::to_str)
362 .unwrap_or_default();
363 matches!(
364 executable,
365 "rm" | "unlink" | "rmdir" | "du" | "ls" | "stat" | "sort" | "head" | "tail" | "wc"
366 ) || (executable == "find" && !statement.contains("-exec"))
367 })
368}
369
370fn validate_bash_write_target(
371 workspace_root: &Path,
372 cwd: &Path,
373 path: &str,
374 info: &AgentTempInfo,
375) -> Result<Option<PathBuf>> {
376 let managed_env_path = expand_temp_env_path(path, info);
377 let expanded: String = managed_env_path.as_ref().map_or_else(
378 || crate::utils::path::expand_user(path),
379 |path| path.to_string_lossy().into_owned(),
380 );
381 let requested = if Path::new(&expanded).is_absolute() {
382 PathBuf::from(&expanded)
383 } else {
384 cwd.join(&expanded)
385 };
386 let resolved = if managed_env_path.is_some() {
387 requested.clone()
388 } else {
389 crate::utils::path::resolve_in_workspace(path, cwd, workspace_root)
390 .unwrap_or_else(|_| requested.clone())
391 };
392 let workspace = workspace_root
393 .canonicalize()
394 .map_or_else(|_| workspace_root.to_path_buf(), crate::utils::path::simplified);
395
396 let requested_relative = workspace_relative(&requested, workspace_root, &workspace);
397 let resolved_relative = workspace_relative(&resolved, workspace_root, &workspace);
398 let mut managed_target = None;
399 for relative in
400 [requested_relative.as_deref(), resolved_relative.as_deref()].into_iter().flatten()
401 {
402 let Some(Component::Normal(first)) = relative.components().next() else { continue };
403 let first = first.to_string_lossy();
404 if first == ".winx_tmp" || first.starts_with(".winx-") {
405 return Err(policy_error(
406 &requested,
407 info,
408 format!(
409 "shell writes to Winx helper artifacts at the workspace root are not \
410 allowed; use WINX_TEMP_DIR={} instead",
411 info.directory.display()
412 ),
413 ));
414 }
415 if is_temp_relative(relative) {
416 reject_symlinked_temp_root(&workspace, &requested, info)?;
417 let target = workspace.join(relative);
418 if target != info.directory {
419 validate_session_relative_path(&target, info)?;
420 managed_target = Some(target);
421 }
422 }
423 }
424 Ok(managed_target)
425}
426
427fn expand_temp_env_path(path: &str, info: &AgentTempInfo) -> Option<PathBuf> {
430 let path = path.trim();
431 let path = if path.len() >= 2
432 && matches!(
433 (path.as_bytes().first(), path.as_bytes().last()),
434 (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"'))
435 ) {
436 &path[1..path.len() - 1]
437 } else {
438 path
439 };
440 let suffix =
441 path.strip_prefix("$WINX_TEMP_DIR").or_else(|| path.strip_prefix("${WINX_TEMP_DIR}"))?;
442 if !suffix.is_empty() && !suffix.starts_with('/') {
443 return None;
444 }
445 let suffix = suffix.trim_start_matches('/');
446 Some(if suffix.is_empty() { info.directory.clone() } else { info.directory.join(suffix) })
447}
448
449fn embedded_writer_paths(command: &str) -> Vec<String> {
450 let lower = command.to_ascii_lowercase();
451 let explicit_writer = [
452 ".write_text(",
453 ".write_bytes(",
454 "writefilesync(",
455 "appendfilesync(",
456 "writefile(",
457 "appendfile(",
458 "bun.write(",
459 "deno.write",
460 "file.write(",
461 "file.binwrite(",
462 ]
463 .iter()
464 .any(|marker| lower.contains(marker));
465 let writable_open = lower.contains("open(")
466 && [", 'w", ", \"w", ", 'a", ", \"a", ", 'x", ", \"x", ", 'r+", ", \"r+"]
467 .iter()
468 .any(|mode| lower.contains(mode));
469 if !explicit_writer && !writable_open {
470 return Vec::new();
471 }
472
473 quoted_literals(command)
474 .into_iter()
475 .filter(|literal| literal.contains(".winx") || literal.contains("$WINX_TEMP_DIR"))
476 .collect()
477}
478
479fn quoted_literals(value: &str) -> Vec<String> {
480 let mut literals = Vec::new();
481 let mut chars = value.chars();
482 while let Some(character) = chars.next() {
483 if character != '\'' && character != '"' {
484 continue;
485 }
486 let quote = character;
487 let mut literal = String::new();
488 let mut escaped = false;
489 for character in chars.by_ref() {
490 if escaped {
491 literal.push(character);
492 escaped = false;
493 } else if character == '\\' && quote == '"' {
494 escaped = true;
495 } else if character == quote {
496 break;
497 } else {
498 literal.push(character);
499 }
500 }
501 if !literal.is_empty() {
502 literals.push(literal);
503 }
504 }
505 literals
506}
507
508fn workspace_relative(
509 path: &Path,
510 workspace_root: &Path,
511 canonical_workspace: &Path,
512) -> Option<PathBuf> {
513 path.strip_prefix(canonical_workspace)
514 .or_else(|_| path.strip_prefix(workspace_root))
515 .ok()
516 .map(Path::to_path_buf)
517}
518
519pub fn validate_code_map_target(
525 workspace_root: &Path,
526 thread_id: &str,
527 requested_path: &Path,
528 resolved_path: &Path,
529) -> Result<bool> {
530 let info = session_info(workspace_root, thread_id);
531 let workspace = workspace_root
532 .canonicalize()
533 .map_or_else(|_| workspace_root.to_path_buf(), crate::utils::path::simplified);
534 let requested_relative = workspace_relative(requested_path, workspace_root, &workspace);
535 let resolved_relative = workspace_relative(resolved_path, workspace_root, &workspace);
536 let requested_temp = requested_relative.as_deref().is_some_and(is_temp_relative);
537 let resolved_temp = resolved_relative.as_deref().is_some_and(is_temp_relative);
538 if !requested_temp && !resolved_temp {
539 return Ok(false);
540 }
541
542 reject_symlinked_temp_root(&workspace, requested_path, &info)?;
543 if requested_temp != resolved_temp {
544 return Err(policy_error(
545 requested_path,
546 &info,
547 "a temporary helper must not resolve through a symlink outside its managed session"
548 .to_string(),
549 ));
550 }
551
552 let requested_policy = requested_relative
553 .as_deref()
554 .map_or_else(|| requested_path.to_path_buf(), |relative| workspace.join(relative));
555 let resolved_policy = resolved_relative
556 .as_deref()
557 .map_or_else(|| resolved_path.to_path_buf(), |relative| workspace.join(relative));
558 if requested_policy != resolved_policy {
559 return Err(policy_error(
560 requested_path,
561 &info,
562 "temporary helper paths must resolve directly without aliases, parent traversal, or symlinks"
563 .to_string(),
564 ));
565 }
566 if requested_policy != info.directory {
567 validate_session_relative_path(&requested_policy, &info)?;
568 }
569 Ok(true)
570}
571
572pub fn reserve_derived_code_map(
574 usage: &mut DerivedCodeMapUsage,
575 path: &Path,
576 info: &AgentTempInfo,
577) -> Result<DerivedCodeMapPermit> {
578 let is_new = !usage.mapped_files.contains(path);
579 let unique_files = usage.mapped_files.len();
580 if usage.calls >= MAX_DERIVED_CODE_MAP_CALLS
581 || (is_new && unique_files >= MAX_DERIVED_CODE_MAP_UNIQUE_FILES)
582 {
583 return Err(WinxError::DerivedCodeMapBudget {
584 path: path.to_path_buf(),
585 temporary_artifact_dir: info.directory.clone(),
586 calls_used: usage.calls,
587 calls_limit: MAX_DERIVED_CODE_MAP_CALLS,
588 unique_files_used: unique_files,
589 unique_files_limit: MAX_DERIVED_CODE_MAP_UNIQUE_FILES,
590 message: format!(
591 "this session already used {} derived-helper map calls across {} unique files; \
592 reuse prior results and inspect canonical source with CodeMap/ReadFiles or rg \
593 instead of creating another carrier",
594 usage.calls, unique_files
595 ),
596 });
597 }
598
599 usage.calls += 1;
600 usage.mapped_files.insert(path.to_path_buf());
601 Ok(DerivedCodeMapPermit {
602 calls_used: usage.calls,
603 calls_limit: MAX_DERIVED_CODE_MAP_CALLS,
604 unique_files_used: usage.mapped_files.len(),
605 unique_files_limit: MAX_DERIVED_CODE_MAP_UNIQUE_FILES,
606 })
607}
608
609fn validate_known_bash_targets_quota(
610 info: &AgentTempInfo,
611 targets: &[PathBuf],
612 current: TempTreeUsage,
613) -> Result<()> {
614 if targets.is_empty() {
615 return Ok(());
616 }
617 let new_files = targets.iter().filter(|path| !path.exists()).collect::<BTreeSet<_>>().len();
618 let projected =
619 TempTreeUsage { session_files: current.session_files.saturating_add(new_files), ..current };
620 validate_projected_usage(&targets[0], info, current, projected)
621}
622
623pub fn validate_batch_quota(
626 workspace_root: &Path,
627 thread_id: &str,
628 edits: &[TempEdit<'_>],
629) -> Result<()> {
630 let info = session_info(workspace_root, thread_id);
631 let managed: Vec<_> =
632 edits.iter().filter(|edit| edit.path.starts_with(&info.directory)).collect();
633 if managed.is_empty() {
634 return Ok(());
635 }
636 let representative = managed[0].path;
637
638 let workspace = workspace_root
639 .canonicalize()
640 .map_or_else(|_| workspace_root.to_path_buf(), crate::utils::path::simplified);
641 let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
642 let pruned = prune_active_session_unlocked(&info.directory, MAX_AGE);
643 log_active_prune(&info.directory, pruned);
644 let current =
645 temp_tree_usage(&workspace.join(temp_root()), &info.directory).ok_or_else(|| {
646 policy_error(
647 representative,
648 &info,
649 "temporary storage could not be measured safely; remove malformed or excessive \
650 artifacts before retrying"
651 .to_string(),
652 )
653 })?;
654 let mut projected_total_bytes = current.total_bytes;
655 let mut projected_session_bytes = current.session_bytes;
656 let mut projected_session_files = current.session_files;
657 for edit in managed {
658 projected_total_bytes = projected_total_bytes
659 .saturating_sub(edit.previous_bytes)
660 .saturating_add(edit.new_bytes);
661 projected_session_bytes = projected_session_bytes
662 .saturating_sub(edit.previous_bytes)
663 .saturating_add(edit.new_bytes);
664 if edit.is_new {
665 projected_session_files = projected_session_files.saturating_add(1);
666 }
667 }
668 validate_projected_usage(
669 representative,
670 &info,
671 current,
672 TempTreeUsage {
673 total_bytes: projected_total_bytes,
674 session_bytes: projected_session_bytes,
675 session_files: projected_session_files,
676 largest_file_bytes: current.largest_file_bytes,
677 },
678 )
679}
680
681fn session_name(thread_id: &str) -> String {
682 use std::fmt::Write as _;
683
684 let digest = Sha256::digest(thread_id.as_bytes());
685 let mut suffix = String::with_capacity(SESSION_HASH_BYTES * 2);
686 for byte in &digest[..SESSION_HASH_BYTES] {
687 let _ = write!(suffix, "{byte:02x}");
688 }
689 format!("{SESSION_PREFIX}{suffix}")
690}
691
692fn reject_legacy_root_artifact(
693 relative: &Path,
694 is_new: bool,
695 requested_path: &Path,
696 info: &AgentTempInfo,
697) -> Result<()> {
698 let Some(Component::Normal(first)) = relative.components().next() else { return Ok(()) };
699 let first = first.to_string_lossy();
700 if first == ".winx_tmp" || (is_new && first.starts_with(".winx-")) {
701 return Err(policy_error(
702 requested_path,
703 info,
704 format!(
705 "ad hoc Winx artifacts at the workspace root are not allowed; use {} with a \
706 short descriptive filename",
707 info.directory.display()
708 ),
709 ));
710 }
711 Ok(())
712}
713
714fn is_temp_relative(relative: &Path) -> bool {
715 let mut components = relative.components();
716 matches!(components.next(), Some(Component::Normal(value)) if value == OsStr::new(".winx"))
717 && matches!(components.next(), Some(Component::Normal(value)) if value == OsStr::new("tmp"))
718}
719
720fn reject_symlinked_temp_root(workspace: &Path, path: &Path, info: &AgentTempInfo) -> Result<()> {
721 let temp_root = workspace.join(temp_root());
722 if fs::symlink_metadata(&temp_root).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
723 return Err(policy_error(
724 path,
725 info,
726 format!(
727 "{} is a symlink; the managed temporary root must be a real directory inside \
728 the workspace",
729 temp_root.display()
730 ),
731 ));
732 }
733 Ok(())
734}
735
736fn validate_session_relative_path(path: &Path, info: &AgentTempInfo) -> Result<()> {
737 let relative = path.strip_prefix(&info.directory).map_err(|_| {
738 policy_error(
739 path,
740 info,
741 format!(
742 "temporary helpers are session-scoped; use the exact temporary_artifact_dir {} \
743 returned by Initialize",
744 info.directory.display()
745 ),
746 )
747 })?;
748 let relative_bytes = relative.as_os_str().as_encoded_bytes().len();
749 if relative_bytes == 0 {
750 return Err(policy_error(path, info, "a helper filename is required".to_string()));
751 }
752 if relative_bytes > MAX_RELATIVE_PATH_BYTES {
753 return Err(policy_error(
754 path,
755 info,
756 format!(
757 "the session-relative path is {relative_bytes} bytes; temporary paths are limited \
758 to {MAX_RELATIVE_PATH_BYTES} bytes"
759 ),
760 ));
761 }
762
763 let mut count = 0usize;
764 for component in relative.components() {
765 let Component::Normal(name) = component else {
766 return Err(policy_error(
767 path,
768 info,
769 "temporary paths may contain only normal filename components".to_string(),
770 ));
771 };
772 count += 1;
773 let bytes = name.as_encoded_bytes().len();
774 if bytes > MAX_COMPONENT_BYTES {
775 return Err(policy_error(
776 path,
777 info,
778 format!(
779 "a temporary path component is {bytes} bytes; components are limited to \
780 {MAX_COMPONENT_BYTES} bytes so content is never encoded in filesystem names"
781 ),
782 ));
783 }
784 }
785 if count > MAX_RELATIVE_COMPONENTS {
786 return Err(policy_error(
787 path,
788 info,
789 format!(
790 "temporary path depth is {count}; at most {MAX_RELATIVE_COMPONENTS} components \
791 are allowed beneath the session directory"
792 ),
793 ));
794 }
795 Ok(())
796}
797
798fn validate_projected_usage(
799 path: &Path,
800 info: &AgentTempInfo,
801 current: TempTreeUsage,
802 projected: TempTreeUsage,
803) -> Result<()> {
804 let violation =
805 if projected.total_bytes > MAX_TOTAL_BYTES && projected.total_bytes > current.total_bytes {
806 Some(format!(
807 "this write would grow managed temporary storage to {} bytes; the workspace-wide \
808 limit is {MAX_TOTAL_BYTES} bytes",
809 projected.total_bytes
810 ))
811 } else if projected.session_bytes > MAX_SESSION_BYTES
812 && projected.session_bytes > current.session_bytes
813 {
814 Some(format!(
815 "this write would grow the active session's temporary storage to {} bytes; the \
816 per-session limit is {MAX_SESSION_BYTES} bytes",
817 projected.session_bytes
818 ))
819 } else if projected.session_files > MAX_SESSION_FILES
820 && projected.session_files > current.session_files
821 {
822 Some(format!(
823 "this write would grow the active session to {} helper files; the per-session limit \
824 is {MAX_SESSION_FILES}. Reuse or overwrite a stable helper and remove obsolete \
825 carriers instead of creating another file",
826 projected.session_files
827 ))
828 } else {
829 None
830 };
831 match violation {
832 Some(message) => Err(policy_error(
833 path,
834 info,
835 format!(
836 "{message}. Shrinking existing helpers remains allowed so the session can recover"
837 ),
838 )),
839 None => Ok(()),
840 }
841}
842
843fn policy_error(path: &Path, info: &AgentTempInfo, message: String) -> WinxError {
844 WinxError::TemporaryArtifactPolicy {
845 path: path.to_path_buf(),
846 temporary_artifact_dir: info.directory.clone(),
847 message,
848 }
849}
850
851#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
852struct TempTreeUsage {
853 total_bytes: u64,
854 session_bytes: u64,
855 session_files: usize,
856 largest_file_bytes: u64,
857}
858
859impl From<&TemporaryArtifactUsage> for TempTreeUsage {
860 fn from(usage: &TemporaryArtifactUsage) -> Self {
861 Self {
862 total_bytes: usage.total_bytes,
863 session_bytes: usage.session_bytes,
864 session_files: usage.session_files,
865 largest_file_bytes: usage.largest_file_bytes,
866 }
867 }
868}
869
870fn temp_tree_usage(root: &Path, active_session: &Path) -> Option<TempTreeUsage> {
871 let metadata = match fs::symlink_metadata(root) {
872 Ok(metadata) => metadata,
873 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
874 return Some(TempTreeUsage::default())
875 }
876 Err(_) => return None,
877 };
878 if metadata.file_type().is_symlink() {
879 return None;
880 }
881
882 let mut usage = TempTreeUsage::default();
883 let mut seen = 0usize;
884 let mut stack = vec![(root.to_path_buf(), 0usize)];
885 while let Some((path, depth)) = stack.pop() {
886 if seen >= MAX_SCAN_ENTRIES || depth > MAX_SCAN_DEPTH {
887 return None;
888 }
889 seen += 1;
890 let metadata = match fs::symlink_metadata(&path) {
891 Ok(metadata) => metadata,
892 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
893 Err(_) => return None,
894 };
895 if !metadata.is_dir() {
898 usage.total_bytes = usage.total_bytes.saturating_add(metadata.len());
899 if path.starts_with(active_session) {
900 usage.session_bytes = usage.session_bytes.saturating_add(metadata.len());
901 usage.session_files = usage.session_files.saturating_add(1);
902 usage.largest_file_bytes = usage.largest_file_bytes.max(metadata.len());
903 }
904 continue;
905 }
906 if metadata.is_dir() {
907 let entries = match fs::read_dir(&path) {
908 Ok(entries) => entries,
909 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
910 Err(_) => return None,
911 };
912 for entry in entries {
913 match entry {
914 Ok(entry) => stack.push((entry.path(), depth + 1)),
915 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
916 Err(_) => return None,
917 }
918 }
919 }
920 }
921 Some(usage)
922}
923
924#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
925struct PrunedArtifacts {
926 files: usize,
927 bytes: u64,
928}
929
930#[derive(Debug)]
931struct PrunableArtifact {
932 path: PathBuf,
933 modified: SystemTime,
934 bytes: u64,
935 is_file: bool,
936 is_symlink: bool,
937 device: u64,
938 inode: u64,
939}
940
941#[cfg(unix)]
942fn artifact_identity(metadata: &fs::Metadata) -> (u64, u64) {
943 use std::os::unix::fs::MetadataExt;
944
945 (metadata.dev(), metadata.ino())
946}
947
948#[cfg(not(unix))]
949fn artifact_identity(_metadata: &fs::Metadata) -> (u64, u64) {
950 (0, 0)
951}
952
953fn still_matches_prunable_artifact(candidate: &PrunableArtifact, cutoff: SystemTime) -> bool {
954 let Ok(metadata) = fs::symlink_metadata(&candidate.path) else { return false };
955 let Ok(modified) = metadata.modified() else { return false };
956 let (device, inode) = artifact_identity(&metadata);
957 modified == candidate.modified
958 && modified < cutoff
959 && metadata.len() == candidate.bytes
960 && metadata.is_file() == candidate.is_file
961 && metadata.file_type().is_symlink() == candidate.is_symlink
962 && device == candidate.device
963 && inode == candidate.inode
964}
965
966fn prune_active_session_unlocked(active: &Path, max_age: Duration) -> PrunedArtifacts {
971 let metadata = match fs::symlink_metadata(active) {
972 Ok(metadata) => metadata,
973 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
974 return PrunedArtifacts::default();
975 }
976 Err(_) => return PrunedArtifacts::default(),
977 };
978 if !metadata.is_dir() || metadata.file_type().is_symlink() {
979 return PrunedArtifacts::default();
980 }
981
982 let cutoff = SystemTime::now().checked_sub(max_age).unwrap_or(UNIX_EPOCH);
983 let mut seen = 0usize;
984 let mut stack = vec![(active.to_path_buf(), 0usize)];
985 let mut candidates = Vec::new();
986 let mut session_files = 0usize;
987 let mut session_bytes = 0u64;
988 let mut largest_file_bytes = 0u64;
989
990 while let Some((path, depth)) = stack.pop() {
991 if seen >= MAX_SCAN_ENTRIES || depth > MAX_SCAN_DEPTH {
992 return PrunedArtifacts::default();
993 }
994 seen += 1;
995 let metadata = match fs::symlink_metadata(&path) {
996 Ok(metadata) => metadata,
997 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
998 Err(_) => return PrunedArtifacts::default(),
999 };
1000 if metadata.is_dir() && !metadata.file_type().is_symlink() {
1001 let entries = match fs::read_dir(&path) {
1002 Ok(entries) => entries,
1003 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1004 Err(_) => return PrunedArtifacts::default(),
1005 };
1006 for entry in entries {
1007 match entry {
1008 Ok(entry) => stack.push((entry.path(), depth + 1)),
1009 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1010 Err(_) => return PrunedArtifacts::default(),
1011 }
1012 }
1013 continue;
1014 }
1015
1016 session_files = session_files.saturating_add(1);
1017 session_bytes = session_bytes.saturating_add(metadata.len());
1018 largest_file_bytes = largest_file_bytes.max(metadata.len());
1019 if let Ok(modified) = metadata.modified() {
1020 if modified < cutoff {
1021 let (device, inode) = artifact_identity(&metadata);
1022 candidates.push(PrunableArtifact {
1023 path,
1024 modified,
1025 bytes: metadata.len(),
1026 is_file: metadata.is_file(),
1027 is_symlink: metadata.file_type().is_symlink(),
1028 device,
1029 inode,
1030 });
1031 }
1032 }
1033 }
1034
1035 let crossed_high_water = session_files >= ACTIVE_SESSION_PRUNE_TRIGGER_FILES
1036 || session_bytes >= ACTIVE_SESSION_PRUNE_TRIGGER_BYTES
1037 || largest_file_bytes > MAX_FILE_BYTES;
1038 if !crossed_high_water || candidates.is_empty() {
1039 return PrunedArtifacts::default();
1040 }
1041
1042 candidates.sort_by(|left, right| {
1043 left.modified.cmp(&right.modified).then_with(|| left.path.cmp(&right.path))
1044 });
1045 let mut pruned = PrunedArtifacts::default();
1046 for candidate in candidates {
1047 let needs_headroom = session_files > ACTIVE_SESSION_PRUNE_TARGET_FILES
1048 || session_bytes > ACTIVE_SESSION_PRUNE_TARGET_BYTES;
1049 if !needs_headroom && candidate.bytes <= MAX_FILE_BYTES {
1050 continue;
1051 }
1052 if !still_matches_prunable_artifact(&candidate, cutoff) {
1053 continue;
1054 }
1055 match fs::remove_file(&candidate.path) {
1056 Ok(()) => {
1057 session_files = session_files.saturating_sub(1);
1058 session_bytes = session_bytes.saturating_sub(candidate.bytes);
1059 pruned.files = pruned.files.saturating_add(1);
1060 pruned.bytes = pruned.bytes.saturating_add(candidate.bytes);
1061 }
1062 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1063 Err(error) => debug!(
1064 path = %candidate.path.display(),
1065 %error,
1066 "failed to prune stale active-session helper"
1067 ),
1068 }
1069 }
1070
1071 pruned
1072}
1073
1074fn log_active_prune(active: &Path, pruned: PrunedArtifacts) {
1075 if pruned.files > 0 {
1076 info!(
1077 temporary_artifact_dir = %active.display(),
1078 stale_pruned_files = pruned.files,
1079 stale_pruned_bytes = pruned.bytes,
1080 "pruned stale active-session temporary artifacts at high water mark"
1081 );
1082 }
1083}
1084
1085fn prune_sessions_unlocked(workspace_root: &Path, active: &Path, max_age: Duration) {
1086 let workspace = workspace_root
1087 .canonicalize()
1088 .map_or_else(|_| workspace_root.to_path_buf(), crate::utils::path::simplified);
1089 let root = workspace.join(temp_root());
1090 if fs::symlink_metadata(&root).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
1091 return;
1092 }
1093 let Ok(entries) = fs::read_dir(&root) else { return };
1094 let cutoff = SystemTime::now().checked_sub(max_age).unwrap_or(UNIX_EPOCH);
1095 for entry in entries.flatten() {
1096 let path = entry.path();
1097 if path == active || !is_managed_session_entry(&entry) || !session_is_stale(&path, cutoff) {
1098 continue;
1099 }
1100 if let Err(error) = fs::remove_dir_all(&path) {
1101 debug!(path = %path.display(), %error, "failed to prune expired agent temp session");
1102 }
1103 }
1104}
1105
1106fn is_managed_session_entry(entry: &fs::DirEntry) -> bool {
1107 if !entry.file_type().is_ok_and(|kind| kind.is_dir() && !kind.is_symlink()) {
1108 return false;
1109 }
1110 let name = entry.file_name();
1111 let name = name.to_string_lossy();
1112 let Some(hash) = name.strip_prefix(SESSION_PREFIX) else { return false };
1113 hash.len() == SESSION_HASH_BYTES * 2 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
1114}
1115
1116fn session_is_stale(root: &Path, cutoff: SystemTime) -> bool {
1119 let mut newest = UNIX_EPOCH;
1120 let mut seen = 0usize;
1121 let mut stack = vec![(root.to_path_buf(), 0usize)];
1122 while let Some((path, depth)) = stack.pop() {
1123 if seen >= MAX_SCAN_ENTRIES || depth > MAX_SCAN_DEPTH {
1124 return false;
1125 }
1126 seen += 1;
1127 let Ok(metadata) = fs::symlink_metadata(&path) else { return false };
1128 let Ok(modified) = metadata.modified() else { return false };
1129 newest = newest.max(modified);
1130 if metadata.is_dir() && !metadata.file_type().is_symlink() {
1131 let Ok(entries) = fs::read_dir(&path) else { return false };
1132 for entry in entries {
1133 let Ok(entry) = entry else { return false };
1134 stack.push((entry.path(), depth + 1));
1135 }
1136 }
1137 }
1138 newest < cutoff
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143 #![allow(clippy::expect_used, clippy::unwrap_used)]
1144
1145 use super::*;
1146 use std::thread::sleep;
1147 use tempfile::TempDir;
1148
1149 #[test]
1150 fn session_path_is_stable_short_and_workspace_local() {
1151 let workspace = TempDir::new().unwrap();
1152 let first = session_info(workspace.path(), "chat/project:one");
1153 let repeated = session_info(workspace.path(), "chat/project:one");
1154 let other = session_info(workspace.path(), "chat/project:two");
1155 assert_eq!(first, repeated);
1156 assert_ne!(first.directory, other.directory);
1157 assert!(first.directory.starts_with(
1158 workspace.path().canonicalize().map(crate::utils::path::simplified).unwrap()
1159 ));
1160 let name = first.directory.file_name().unwrap().to_string_lossy();
1161 assert_eq!(name.len(), SESSION_PREFIX.len() + SESSION_HASH_BYTES * 2);
1162 }
1163
1164 #[test]
1165 fn accepts_short_helpers_only_in_the_active_session() {
1166 let workspace = TempDir::new().unwrap();
1167 let info = session_info(workspace.path(), "active");
1168 let valid = info.directory.join("review/adapter.py");
1169 validate_edit_target(workspace.path(), "active", &valid, &valid, None, 128).unwrap();
1170
1171 let other = session_info(workspace.path(), "other").directory.join("adapter.py");
1172 let error = validate_edit_target(workspace.path(), "active", &other, &other, None, 128)
1173 .expect_err("cross-session temp write must fail");
1174 assert!(error.to_string().contains("session-scoped"), "{error}");
1175 }
1176
1177 #[test]
1178 fn bash_writes_accept_only_the_active_session_temp_path() {
1179 let workspace = TempDir::new().unwrap();
1180 let info = session_info(workspace.path(), "active");
1181 let valid = format!("printf x > '{}'", info.directory.join("adapter.ts").display());
1182 validate_bash_command(workspace.path(), workspace.path(), "active", &valid)
1183 .expect("active session destination must be accepted");
1184
1185 for command in [
1186 "printf x > .winx/tmp/direct.ts",
1187 "printf x | tee .winx-review-carrier.js",
1188 "python - <<'PY'\nfrom pathlib import Path\nPath('.winx/tmp/direct.py').write_text('x')\nPY",
1189 ] {
1190 let error = validate_bash_command(workspace.path(), workspace.path(), "active", command)
1191 .expect_err("unmanaged shell helper must fail");
1192 assert!(
1193 error.to_string().to_ascii_lowercase().contains("temporary artifact policy"),
1194 "{error}"
1195 );
1196 }
1197 }
1198
1199 #[test]
1200 fn bash_temp_preflight_does_not_block_reads_or_dynamic_active_path() {
1201 let workspace = TempDir::new().unwrap();
1202 for command in [
1203 "rg needle .winx/tmp/direct.ts",
1204 "cat .winx-review-carrier.js",
1205 "mkdir -p \"$WINX_TEMP_DIR\" && printf x > \"$WINX_TEMP_DIR/helper.ts\"",
1206 ] {
1207 validate_bash_command(workspace.path(), workspace.path(), "active", command)
1208 .expect("read or runtime-provided destination must remain available");
1209 }
1210 }
1211
1212 #[test]
1213 fn rejects_legacy_root_artifacts_and_payload_names() {
1214 let workspace = TempDir::new().unwrap();
1215 let legacy = workspace.path().join(".winx_tmp/payload/file.txt");
1216 let error = validate_edit_target(workspace.path(), "active", &legacy, &legacy, None, 10)
1217 .expect_err("legacy root must fail");
1218 assert!(error.to_string().contains("workspace root"), "{error}");
1219
1220 let info = session_info(workspace.path(), "active");
1221 let encoded = info.directory.join(format!("{}.txt", "x".repeat(MAX_COMPONENT_BYTES + 1)));
1222 let error = validate_edit_target(workspace.path(), "active", &encoded, &encoded, None, 10)
1223 .expect_err("long component must fail");
1224 assert!(error.to_string().contains("filesystem names"), "{error}");
1225 }
1226
1227 #[test]
1228 fn resolved_path_cannot_hide_a_root_artifact_behind_parent_components() {
1229 let workspace = TempDir::new().unwrap();
1230 let requested = workspace.path().join("nested/../.winx-review-carrier.py");
1231 let resolved = workspace.path().join(".winx-review-carrier.py");
1232 let error =
1233 validate_edit_target(workspace.path(), "active", &requested, &resolved, None, 10)
1234 .expect_err("resolved top-level artifact must fail");
1235 assert!(error.to_string().contains("workspace root"), "{error}");
1236 }
1237
1238 #[cfg(unix)]
1239 #[test]
1240 fn lexical_workspace_alias_cannot_bypass_temp_policy() {
1241 use std::os::unix::fs::symlink;
1242
1243 let parent = TempDir::new().unwrap();
1244 let real_workspace = parent.path().join("real-workspace");
1245 let alias_workspace = parent.path().join("workspace-alias");
1246 fs::create_dir(&real_workspace).unwrap();
1247 symlink(&real_workspace, &alias_workspace).unwrap();
1248
1249 let legacy = alias_workspace.join(".winx_tmp/payload.txt");
1250 let error = validate_edit_target(&alias_workspace, "active", &legacy, &legacy, None, 10)
1251 .expect_err("a lexical workspace alias must not bypass root-artifact policy");
1252 assert!(error.to_string().contains("workspace root"), "{error}");
1253
1254 let canonical_workspace =
1255 alias_workspace.canonicalize().map(crate::utils::path::simplified).unwrap();
1256 let canonical_session = session_info(&alias_workspace, "active").directory;
1257 let relative_session = canonical_session.strip_prefix(&canonical_workspace).unwrap();
1258 let aliased_helper = alias_workspace.join(relative_session).join("adapter.py");
1259 validate_edit_target(
1260 &alias_workspace,
1261 "active",
1262 &aliased_helper,
1263 &aliased_helper,
1264 None,
1265 10,
1266 )
1267 .expect("the active managed directory must work through the lexical alias");
1268 }
1269
1270 #[test]
1271 fn prune_removes_only_expired_managed_siblings() {
1272 let workspace = TempDir::new().unwrap();
1273 let active = session_info(workspace.path(), "active").directory;
1274 let expired = session_info(workspace.path(), "expired").directory;
1275 let unmanaged = workspace.path().join(temp_root()).join("manual-cache");
1276 fs::create_dir_all(&active).unwrap();
1277 fs::create_dir_all(&expired).unwrap();
1278 fs::create_dir_all(&unmanaged).unwrap();
1279 fs::write(expired.join("old.txt"), "old").unwrap();
1280 sleep(Duration::from_millis(15));
1281
1282 let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1283 prune_sessions_unlocked(workspace.path(), &active, Duration::from_millis(1));
1284 assert!(active.exists());
1285 assert!(!expired.exists());
1286 assert!(unmanaged.exists());
1287 }
1288
1289 #[test]
1290 fn active_session_prunes_only_stale_helpers_after_high_water_mark() {
1291 let workspace = TempDir::new().unwrap();
1292 let active = session_info(workspace.path(), "active").directory;
1293 fs::create_dir_all(active.join("old/nested")).unwrap();
1294 for index in 0..ACTIVE_SESSION_PRUNE_TRIGGER_FILES {
1295 fs::write(active.join("old/nested").join(format!("old-{index}.txt")), "old").unwrap();
1296 }
1297 sleep(Duration::from_millis(15));
1298 let fresh =
1299 (0..8).map(|index| active.join(format!("fresh-{index}.txt"))).collect::<Vec<_>>();
1300 for path in &fresh {
1301 fs::write(path, "fresh").unwrap();
1302 }
1303
1304 let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1305 let pruned = prune_active_session_unlocked(&active, Duration::from_millis(5));
1306 assert_eq!(pruned.files, 40);
1307 let usage_root = workspace
1313 .path()
1314 .canonicalize()
1315 .map_or_else(|_| workspace.path().to_path_buf(), crate::utils::path::simplified)
1316 .join(temp_root());
1317 assert_eq!(
1318 temp_tree_usage(&usage_root, &active).unwrap().session_files,
1319 ACTIVE_SESSION_PRUNE_TARGET_FILES
1320 );
1321 assert!(fresh.iter().all(|path| path.exists()), "fresh helpers must be retained");
1322 }
1323
1324 #[test]
1325 fn active_session_does_not_prune_stale_helpers_below_high_water_mark() {
1326 let workspace = TempDir::new().unwrap();
1327 let active = session_info(workspace.path(), "active").directory;
1328 fs::create_dir_all(&active).unwrap();
1329 for index in 0..(ACTIVE_SESSION_PRUNE_TRIGGER_FILES - 1) {
1330 fs::write(active.join(format!("helper-{index}.txt")), "old").unwrap();
1331 }
1332 sleep(Duration::from_millis(10));
1333
1334 let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1335 let pruned = prune_active_session_unlocked(&active, Duration::from_millis(1));
1336 assert_eq!(pruned, PrunedArtifacts::default());
1337 assert_eq!(fs::read_dir(active).unwrap().count(), ACTIVE_SESSION_PRUNE_TRIGGER_FILES - 1);
1338 }
1339
1340 #[test]
1341 fn stale_candidate_revalidation_rejects_a_fresh_replacement() {
1342 let workspace = TempDir::new().unwrap();
1343 let path = workspace.path().join("helper.txt");
1344 fs::write(&path, "old").unwrap();
1345 let metadata = fs::symlink_metadata(&path).unwrap();
1346 let modified = metadata.modified().unwrap();
1347 let (device, inode) = artifact_identity(&metadata);
1348 let candidate = PrunableArtifact {
1349 path: path.clone(),
1350 modified,
1351 bytes: metadata.len(),
1352 is_file: metadata.is_file(),
1353 is_symlink: metadata.file_type().is_symlink(),
1354 device,
1355 inode,
1356 };
1357 sleep(Duration::from_millis(10));
1358 fs::write(&path, "fresh replacement").unwrap();
1359
1360 assert!(!still_matches_prunable_artifact(&candidate, SystemTime::now()));
1361 assert_eq!(fs::read_to_string(path).unwrap(), "fresh replacement");
1362 }
1363
1364 #[cfg(unix)]
1365 #[test]
1366 fn active_session_pruning_never_follows_symlink_helpers() {
1367 use std::os::unix::fs::symlink;
1368
1369 let workspace = TempDir::new().unwrap();
1370 let active = session_info(workspace.path(), "active").directory;
1371 let outside = workspace.path().join("canonical.txt");
1372 fs::create_dir_all(&active).unwrap();
1373 fs::write(&outside, "keep").unwrap();
1374 for index in 0..ACTIVE_SESSION_PRUNE_TRIGGER_FILES {
1375 symlink(&outside, active.join(format!("link-{index}"))).unwrap();
1376 }
1377 sleep(Duration::from_millis(10));
1378
1379 let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1380 let pruned = prune_active_session_unlocked(&active, Duration::from_millis(1));
1381 assert!(pruned.files > 0);
1382 assert_eq!(fs::read_to_string(outside).unwrap(), "keep");
1383 }
1384
1385 #[test]
1386 fn quota_projection_accounts_for_replacements() {
1387 let workspace = TempDir::new().unwrap();
1388 let info = session_info(workspace.path(), "active");
1389 let path = info.directory.join("large.bin");
1390 let current = TempTreeUsage::default();
1391 assert!(validate_projected_usage(
1392 &path,
1393 &info,
1394 current,
1395 TempTreeUsage { total_bytes: MAX_TOTAL_BYTES, ..current }
1396 )
1397 .is_ok());
1398 assert!(validate_projected_usage(
1399 &path,
1400 &info,
1401 current,
1402 TempTreeUsage { total_bytes: MAX_TOTAL_BYTES + 1, ..current }
1403 )
1404 .is_err());
1405
1406 let oversized = TempTreeUsage {
1407 total_bytes: MAX_TOTAL_BYTES + 10,
1408 session_bytes: MAX_SESSION_BYTES + 10,
1409 session_files: MAX_SESSION_FILES + 10,
1410 largest_file_bytes: MAX_FILE_BYTES + 10,
1411 };
1412 let shrinking = TempTreeUsage {
1413 total_bytes: oversized.total_bytes - 1,
1414 session_bytes: oversized.session_bytes - 1,
1415 session_files: oversized.session_files,
1416 largest_file_bytes: oversized.largest_file_bytes,
1417 };
1418 validate_projected_usage(&path, &info, oversized, shrinking)
1419 .expect("an over-limit session must be allowed to shrink existing helpers");
1420 }
1421
1422 #[test]
1423 fn batch_quota_counts_every_planned_helper_before_committing() {
1424 let workspace = TempDir::new().unwrap();
1425 let info = session_info(workspace.path(), "active");
1426 let paths = (0..9)
1427 .map(|index| info.directory.join(format!("helper-{index}.bin")))
1428 .collect::<Vec<_>>();
1429 let edits = paths
1430 .iter()
1431 .map(|path| TempEdit {
1432 path,
1433 previous_bytes: 0,
1434 new_bytes: MAX_FILE_BYTES,
1435 is_new: true,
1436 })
1437 .collect::<Vec<_>>();
1438
1439 let error = validate_batch_quota(workspace.path(), "active", &edits)
1440 .expect_err("aggregate batch must honor the workspace quota");
1441 assert!(error.to_string().contains("workspace-wide limit"), "{error}");
1442 }
1443
1444 #[test]
1445 fn literal_winx_temp_destinations_honor_the_session_file_cap() {
1446 let workspace = TempDir::new().unwrap();
1447 let info = session_info(workspace.path(), "active");
1448 fs::create_dir_all(&info.directory).unwrap();
1449 for index in 0..MAX_SESSION_FILES {
1450 fs::write(info.directory.join(format!("helper-{index}.txt")), "x").unwrap();
1451 }
1452
1453 validate_bash_command(
1454 workspace.path(),
1455 workspace.path(),
1456 "active",
1457 "printf replacement > \"$WINX_TEMP_DIR/helper-0.txt\"",
1458 )
1459 .expect("overwriting a stable helper must remain possible at the cap");
1460 let error = validate_bash_command(
1461 workspace.path(),
1462 workspace.path(),
1463 "active",
1464 "printf new > \"$WINX_TEMP_DIR/one-more.txt\"",
1465 )
1466 .expect_err("a visible new shell helper must honor the cap");
1467 assert!(error.to_string().contains("helper files"), "{error}");
1468 }
1469
1470 #[test]
1471 fn dynamic_bash_overflow_blocks_work_until_explicit_cleanup() {
1472 let workspace = TempDir::new().unwrap();
1473 let info = session_info(workspace.path(), "active");
1474 fs::create_dir_all(&info.directory).unwrap();
1475 for index in 0..=MAX_SESSION_FILES {
1476 fs::write(info.directory.join(format!("dynamic-{index}.txt")), "x").unwrap();
1477 }
1478
1479 let usage = audit_session(workspace.path(), "active").unwrap();
1480 assert!(usage.over_budget);
1481 assert!(usage.requires_cleanup());
1482 let error =
1483 validate_bash_command(workspace.path(), workspace.path(), "active", "cargo test --lib")
1484 .expect_err(
1485 "ordinary commands must pause while the active temp session is over budget",
1486 );
1487 assert!(matches!(error, WinxError::TemporaryArtifactBudgetExceeded { .. }));
1488
1489 validate_bash_command(
1490 workspace.path(),
1491 workspace.path(),
1492 "active",
1493 "find \"$WINX_TEMP_DIR\" -type f -delete",
1494 )
1495 .expect("an explicit cleanup-only command must remain available");
1496 }
1497
1498 #[cfg(unix)]
1499 #[test]
1500 fn symlink_helpers_count_toward_the_session_artifact_budget() {
1501 use std::os::unix::fs::symlink;
1502
1503 let workspace = TempDir::new().unwrap();
1504 let info = session_info(workspace.path(), "active");
1505 fs::create_dir_all(&info.directory).unwrap();
1506 for index in 0..=MAX_SESSION_FILES {
1507 symlink("missing-target", info.directory.join(format!("link-{index}"))).unwrap();
1508 }
1509
1510 let usage = audit_session(workspace.path(), "active").unwrap();
1511 assert_eq!(usage.session_files, MAX_SESSION_FILES + 1);
1512 assert!(usage.requires_cleanup());
1513 }
1514
1515 #[test]
1516 fn code_map_accepts_only_the_active_direct_helper_path() {
1517 let workspace = TempDir::new().unwrap();
1518 let active = session_info(workspace.path(), "active");
1519 let helper = active.directory.join("adapter.py");
1520 assert!(validate_code_map_target(workspace.path(), "active", &helper, &helper).unwrap());
1521
1522 let foreign = session_info(workspace.path(), "other").directory.join("adapter.py");
1523 let error = validate_code_map_target(workspace.path(), "active", &foreign, &foreign)
1524 .expect_err("another session's helper must not be mapped");
1525 assert!(error.to_string().contains("session-scoped"), "{error}");
1526
1527 let canonical = workspace.path().join("src/lib.rs");
1528 assert!(
1529 !validate_code_map_target(workspace.path(), "active", &canonical, &canonical).unwrap()
1530 );
1531 }
1532
1533 #[test]
1534 fn derived_code_map_budget_rewards_reuse_and_caps_churn() {
1535 let workspace = TempDir::new().unwrap();
1536 let info = session_info(workspace.path(), "active");
1537 let mut usage = DerivedCodeMapUsage::default();
1538 let stable = info.directory.join("stable.py");
1539 let first = reserve_derived_code_map(&mut usage, &stable, &info).unwrap();
1540 let repeated = reserve_derived_code_map(&mut usage, &stable, &info).unwrap();
1541 assert_eq!(first.unique_files_used, 1);
1542 assert_eq!(repeated.unique_files_used, 1);
1543 assert_eq!(repeated.calls_used, 2);
1544
1545 for index in 1..MAX_DERIVED_CODE_MAP_UNIQUE_FILES {
1546 reserve_derived_code_map(
1547 &mut usage,
1548 &info.directory.join(format!("helper-{index}.py")),
1549 &info,
1550 )
1551 .unwrap();
1552 }
1553 let error =
1554 reserve_derived_code_map(&mut usage, &info.directory.join("one-too-many.py"), &info)
1555 .expect_err("a new carrier past the unique-file budget must fail");
1556 assert!(matches!(error, WinxError::DerivedCodeMapBudget { .. }));
1557
1558 let mut repeated_usage = DerivedCodeMapUsage::default();
1559 for _ in 0..MAX_DERIVED_CODE_MAP_CALLS {
1560 reserve_derived_code_map(&mut repeated_usage, &stable, &info).unwrap();
1561 }
1562 let error = reserve_derived_code_map(&mut repeated_usage, &stable, &info)
1563 .expect_err("even one stable helper has an aggregate call budget");
1564 assert!(matches!(error, WinxError::DerivedCodeMapBudget { .. }));
1565 }
1566}