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