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 sha2::{Digest, Sha256};
17use tracing::debug;
18
19use crate::errors::{Result, WinxError};
20
21const TEMP_ROOT: &str = ".winx/tmp";
22const SESSION_PREFIX: &str = "session-";
23const SESSION_HASH_BYTES: usize = 8;
24
25pub const MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
28pub const MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
30pub const MAX_SESSION_BYTES: u64 = 64 * 1024 * 1024;
32pub const MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
34pub const MAX_SESSION_FILES: usize = 128;
36pub const MAX_DERIVED_CODE_MAP_PAYLOAD_BYTES: usize = 12 * 1024;
38pub const MAX_DERIVED_CODE_MAP_UNIQUE_FILES: usize = 24;
40pub const MAX_DERIVED_CODE_MAP_CALLS: usize = 64;
42pub const MAX_RELATIVE_COMPONENTS: usize = 8;
44pub const MAX_COMPONENT_BYTES: usize = 120;
46pub const MAX_RELATIVE_PATH_BYTES: usize = 512;
48
49const MAX_SCAN_ENTRIES: usize = 50_000;
50const MAX_SCAN_DEPTH: usize = 16;
51static TEMP_IO_LOCK: Mutex<()> = Mutex::new(());
52
53#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct AgentTempInfo {
56 pub directory: PathBuf,
57 pub ttl_seconds: u64,
58 pub max_total_bytes: u64,
59 pub max_session_bytes: u64,
60 pub max_file_bytes: u64,
61 pub max_session_files: usize,
62}
63
64#[derive(Clone, Debug, Default)]
70pub struct DerivedCodeMapUsage {
71 mapped_files: BTreeSet<PathBuf>,
72 calls: usize,
73}
74
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
77pub struct DerivedCodeMapPermit {
78 pub calls_used: usize,
79 pub calls_limit: usize,
80 pub unique_files_used: usize,
81 pub unique_files_limit: usize,
82}
83
84#[derive(Clone, Copy, Debug)]
87pub struct TempEdit<'a> {
88 pub path: &'a Path,
89 pub previous_bytes: u64,
90 pub new_bytes: u64,
91 pub is_new: bool,
92}
93
94pub fn session_info(workspace_root: &Path, thread_id: &str) -> AgentTempInfo {
96 let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
97 AgentTempInfo {
98 directory: workspace.join(TEMP_ROOT).join(session_name(thread_id)),
99 ttl_seconds: MAX_AGE.as_secs(),
100 max_total_bytes: MAX_TOTAL_BYTES,
101 max_session_bytes: MAX_SESSION_BYTES,
102 max_file_bytes: MAX_FILE_BYTES,
103 max_session_files: MAX_SESSION_FILES,
104 }
105}
106
107pub fn prepare_session(workspace_root: &Path, thread_id: &str) -> AgentTempInfo {
111 let info = session_info(workspace_root, thread_id);
112 let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
113 prune_sessions_unlocked(workspace_root, &info.directory, MAX_AGE);
114 info
115}
116
117pub fn validate_edit_target(
121 workspace_root: &Path,
122 thread_id: &str,
123 requested_path: &Path,
124 resolved_path: &Path,
125 previous_bytes: Option<u64>,
126 new_bytes: u64,
127) -> Result<()> {
128 let info = session_info(workspace_root, thread_id);
129 let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
130 let requested_relative = workspace_relative(requested_path, workspace_root, &workspace);
137 let resolved_relative = workspace_relative(resolved_path, workspace_root, &workspace);
138
139 if let Some(relative) = resolved_relative.as_deref() {
140 reject_legacy_root_artifact(relative, previous_bytes.is_none(), requested_path, &info)?;
141 }
142 if requested_relative != resolved_relative {
143 if let Some(relative) = requested_relative.as_deref() {
144 reject_legacy_root_artifact(relative, previous_bytes.is_none(), requested_path, &info)?;
145 }
146 }
147
148 let requested_temp = requested_relative.as_deref().is_some_and(is_temp_relative);
149 let resolved_temp = resolved_relative.as_deref().is_some_and(is_temp_relative);
150 if !requested_temp && !resolved_temp {
151 return Ok(());
152 }
153
154 reject_symlinked_temp_root(&workspace, requested_path, &info)?;
155 let policy_path = resolved_relative
156 .as_deref()
157 .map_or_else(|| resolved_path.to_path_buf(), |relative| workspace.join(relative));
158 validate_session_relative_path(&policy_path, &info)?;
159 if new_bytes > MAX_FILE_BYTES {
160 return Err(policy_error(
161 &policy_path,
162 &info,
163 format!(
164 "helper content is {new_bytes} bytes; one temporary file is limited to \
165 {MAX_FILE_BYTES} bytes"
166 ),
167 ));
168 }
169
170 Ok(())
171}
172
173pub fn validate_bash_command(
180 workspace_root: &Path,
181 cwd: &Path,
182 thread_id: &str,
183 command: &str,
184) -> Result<()> {
185 let info = session_info(workspace_root, thread_id);
186 let mut paths = crate::utils::bash_parser::extract_static_write_paths(command);
187 paths.extend(embedded_writer_paths(command));
188 paths.sort();
189 paths.dedup();
190
191 let mut managed_targets = Vec::new();
192 for path in paths {
193 if let Some(target) = validate_bash_write_target(workspace_root, cwd, &path, &info)? {
194 managed_targets.push(target);
195 }
196 }
197 validate_known_bash_targets_quota(workspace_root, &info, &managed_targets)?;
198 Ok(())
199}
200
201fn validate_bash_write_target(
202 workspace_root: &Path,
203 cwd: &Path,
204 path: &str,
205 info: &AgentTempInfo,
206) -> Result<Option<PathBuf>> {
207 let managed_env_path = expand_temp_env_path(path, info);
208 let expanded: String = managed_env_path.as_ref().map_or_else(
209 || crate::utils::path::expand_user(path),
210 |path| path.to_string_lossy().into_owned(),
211 );
212 let requested = if Path::new(&expanded).is_absolute() {
213 PathBuf::from(&expanded)
214 } else {
215 cwd.join(&expanded)
216 };
217 let resolved = if managed_env_path.is_some() {
218 requested.clone()
219 } else {
220 crate::utils::path::resolve_in_workspace(path, cwd, workspace_root)
221 .unwrap_or_else(|_| requested.clone())
222 };
223 let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
224
225 let requested_relative = workspace_relative(&requested, workspace_root, &workspace);
226 let resolved_relative = workspace_relative(&resolved, workspace_root, &workspace);
227 let mut managed_target = None;
228 for relative in
229 [requested_relative.as_deref(), resolved_relative.as_deref()].into_iter().flatten()
230 {
231 let Some(Component::Normal(first)) = relative.components().next() else { continue };
232 let first = first.to_string_lossy();
233 if first == ".winx_tmp" || first.starts_with(".winx-") {
234 return Err(policy_error(
235 &requested,
236 info,
237 format!(
238 "shell writes to Winx helper artifacts at the workspace root are not \
239 allowed; use WINX_TEMP_DIR={} instead",
240 info.directory.display()
241 ),
242 ));
243 }
244 if is_temp_relative(relative) {
245 reject_symlinked_temp_root(&workspace, &requested, info)?;
246 let target = workspace.join(relative);
247 if target != info.directory {
248 validate_session_relative_path(&target, info)?;
249 managed_target = Some(target);
250 }
251 }
252 }
253 Ok(managed_target)
254}
255
256fn expand_temp_env_path(path: &str, info: &AgentTempInfo) -> Option<PathBuf> {
259 let path = path.trim();
260 let path = if path.len() >= 2
261 && matches!(
262 (path.as_bytes().first(), path.as_bytes().last()),
263 (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"'))
264 ) {
265 &path[1..path.len() - 1]
266 } else {
267 path
268 };
269 let suffix =
270 path.strip_prefix("$WINX_TEMP_DIR").or_else(|| path.strip_prefix("${WINX_TEMP_DIR}"))?;
271 if !suffix.is_empty() && !suffix.starts_with('/') {
272 return None;
273 }
274 let suffix = suffix.trim_start_matches('/');
275 Some(if suffix.is_empty() { info.directory.clone() } else { info.directory.join(suffix) })
276}
277
278fn embedded_writer_paths(command: &str) -> Vec<String> {
279 let lower = command.to_ascii_lowercase();
280 let explicit_writer = [
281 ".write_text(",
282 ".write_bytes(",
283 "writefilesync(",
284 "appendfilesync(",
285 "writefile(",
286 "appendfile(",
287 "bun.write(",
288 "deno.write",
289 "file.write(",
290 "file.binwrite(",
291 ]
292 .iter()
293 .any(|marker| lower.contains(marker));
294 let writable_open = lower.contains("open(")
295 && [", 'w", ", \"w", ", 'a", ", \"a", ", 'x", ", \"x", ", 'r+", ", \"r+"]
296 .iter()
297 .any(|mode| lower.contains(mode));
298 if !explicit_writer && !writable_open {
299 return Vec::new();
300 }
301
302 quoted_literals(command)
303 .into_iter()
304 .filter(|literal| literal.contains(".winx") || literal.contains("$WINX_TEMP_DIR"))
305 .collect()
306}
307
308fn quoted_literals(value: &str) -> Vec<String> {
309 let mut literals = Vec::new();
310 let mut chars = value.chars();
311 while let Some(character) = chars.next() {
312 if character != '\'' && character != '"' {
313 continue;
314 }
315 let quote = character;
316 let mut literal = String::new();
317 let mut escaped = false;
318 for character in chars.by_ref() {
319 if escaped {
320 literal.push(character);
321 escaped = false;
322 } else if character == '\\' && quote == '"' {
323 escaped = true;
324 } else if character == quote {
325 break;
326 } else {
327 literal.push(character);
328 }
329 }
330 if !literal.is_empty() {
331 literals.push(literal);
332 }
333 }
334 literals
335}
336
337fn workspace_relative(
338 path: &Path,
339 workspace_root: &Path,
340 canonical_workspace: &Path,
341) -> Option<PathBuf> {
342 path.strip_prefix(canonical_workspace)
343 .or_else(|_| path.strip_prefix(workspace_root))
344 .ok()
345 .map(Path::to_path_buf)
346}
347
348pub fn validate_code_map_target(
354 workspace_root: &Path,
355 thread_id: &str,
356 requested_path: &Path,
357 resolved_path: &Path,
358) -> Result<bool> {
359 let info = session_info(workspace_root, thread_id);
360 let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
361 let requested_relative = workspace_relative(requested_path, workspace_root, &workspace);
362 let resolved_relative = workspace_relative(resolved_path, workspace_root, &workspace);
363 let requested_temp = requested_relative.as_deref().is_some_and(is_temp_relative);
364 let resolved_temp = resolved_relative.as_deref().is_some_and(is_temp_relative);
365 if !requested_temp && !resolved_temp {
366 return Ok(false);
367 }
368
369 reject_symlinked_temp_root(&workspace, requested_path, &info)?;
370 if requested_temp != resolved_temp {
371 return Err(policy_error(
372 requested_path,
373 &info,
374 "a temporary helper must not resolve through a symlink outside its managed session"
375 .to_string(),
376 ));
377 }
378
379 let requested_policy = requested_relative
380 .as_deref()
381 .map_or_else(|| requested_path.to_path_buf(), |relative| workspace.join(relative));
382 let resolved_policy = resolved_relative
383 .as_deref()
384 .map_or_else(|| resolved_path.to_path_buf(), |relative| workspace.join(relative));
385 if requested_policy != resolved_policy {
386 return Err(policy_error(
387 requested_path,
388 &info,
389 "temporary helper paths must resolve directly without aliases, parent traversal, or symlinks"
390 .to_string(),
391 ));
392 }
393 if requested_policy != info.directory {
394 validate_session_relative_path(&requested_policy, &info)?;
395 }
396 Ok(true)
397}
398
399pub fn reserve_derived_code_map(
401 usage: &mut DerivedCodeMapUsage,
402 path: &Path,
403 info: &AgentTempInfo,
404) -> Result<DerivedCodeMapPermit> {
405 let is_new = !usage.mapped_files.contains(path);
406 let unique_files = usage.mapped_files.len();
407 if usage.calls >= MAX_DERIVED_CODE_MAP_CALLS
408 || (is_new && unique_files >= MAX_DERIVED_CODE_MAP_UNIQUE_FILES)
409 {
410 return Err(WinxError::DerivedCodeMapBudget {
411 path: path.to_path_buf(),
412 temporary_artifact_dir: info.directory.clone(),
413 calls_used: usage.calls,
414 calls_limit: MAX_DERIVED_CODE_MAP_CALLS,
415 unique_files_used: unique_files,
416 unique_files_limit: MAX_DERIVED_CODE_MAP_UNIQUE_FILES,
417 message: format!(
418 "this session already used {} derived-helper map calls across {} unique files; \
419 reuse prior results and inspect canonical source with CodeMap/ReadFiles or rg \
420 instead of creating another carrier",
421 usage.calls, unique_files
422 ),
423 });
424 }
425
426 usage.calls += 1;
427 usage.mapped_files.insert(path.to_path_buf());
428 Ok(DerivedCodeMapPermit {
429 calls_used: usage.calls,
430 calls_limit: MAX_DERIVED_CODE_MAP_CALLS,
431 unique_files_used: usage.mapped_files.len(),
432 unique_files_limit: MAX_DERIVED_CODE_MAP_UNIQUE_FILES,
433 })
434}
435
436fn validate_known_bash_targets_quota(
437 workspace_root: &Path,
438 info: &AgentTempInfo,
439 targets: &[PathBuf],
440) -> Result<()> {
441 if targets.is_empty() {
442 return Ok(());
443 }
444 let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
445 let current =
446 temp_tree_usage(&workspace.join(TEMP_ROOT), &info.directory).ok_or_else(|| {
447 policy_error(
448 &targets[0],
449 info,
450 "temporary storage could not be measured safely; remove malformed or excessive \
451 artifacts before retrying"
452 .to_string(),
453 )
454 })?;
455 let new_files = targets.iter().filter(|path| !path.exists()).collect::<BTreeSet<_>>().len();
456 let projected =
457 TempTreeUsage { session_files: current.session_files.saturating_add(new_files), ..current };
458 validate_projected_usage(&targets[0], info, current, projected)
459}
460
461pub fn validate_batch_quota(
464 workspace_root: &Path,
465 thread_id: &str,
466 edits: &[TempEdit<'_>],
467) -> Result<()> {
468 let info = session_info(workspace_root, thread_id);
469 let managed: Vec<_> =
470 edits.iter().filter(|edit| edit.path.starts_with(&info.directory)).collect();
471 if managed.is_empty() {
472 return Ok(());
473 }
474 let representative = managed[0].path;
475
476 let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
477 let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
478 let current =
479 temp_tree_usage(&workspace.join(TEMP_ROOT), &info.directory).ok_or_else(|| {
480 policy_error(
481 representative,
482 &info,
483 "temporary storage could not be measured safely; remove malformed or excessive \
484 artifacts before retrying"
485 .to_string(),
486 )
487 })?;
488 let mut projected_total_bytes = current.total_bytes;
489 let mut projected_session_bytes = current.session_bytes;
490 let mut projected_session_files = current.session_files;
491 for edit in managed {
492 projected_total_bytes = projected_total_bytes
493 .saturating_sub(edit.previous_bytes)
494 .saturating_add(edit.new_bytes);
495 projected_session_bytes = projected_session_bytes
496 .saturating_sub(edit.previous_bytes)
497 .saturating_add(edit.new_bytes);
498 if edit.is_new {
499 projected_session_files = projected_session_files.saturating_add(1);
500 }
501 }
502 validate_projected_usage(
503 representative,
504 &info,
505 current,
506 TempTreeUsage {
507 total_bytes: projected_total_bytes,
508 session_bytes: projected_session_bytes,
509 session_files: projected_session_files,
510 },
511 )
512}
513
514fn session_name(thread_id: &str) -> String {
515 use std::fmt::Write as _;
516
517 let digest = Sha256::digest(thread_id.as_bytes());
518 let mut suffix = String::with_capacity(SESSION_HASH_BYTES * 2);
519 for byte in &digest[..SESSION_HASH_BYTES] {
520 let _ = write!(suffix, "{byte:02x}");
521 }
522 format!("{SESSION_PREFIX}{suffix}")
523}
524
525fn reject_legacy_root_artifact(
526 relative: &Path,
527 is_new: bool,
528 requested_path: &Path,
529 info: &AgentTempInfo,
530) -> Result<()> {
531 let Some(Component::Normal(first)) = relative.components().next() else { return Ok(()) };
532 let first = first.to_string_lossy();
533 if first == ".winx_tmp" || (is_new && first.starts_with(".winx-")) {
534 return Err(policy_error(
535 requested_path,
536 info,
537 format!(
538 "ad hoc Winx artifacts at the workspace root are not allowed; use {} with a \
539 short descriptive filename",
540 info.directory.display()
541 ),
542 ));
543 }
544 Ok(())
545}
546
547fn is_temp_relative(relative: &Path) -> bool {
548 let mut components = relative.components();
549 matches!(components.next(), Some(Component::Normal(value)) if value == OsStr::new(".winx"))
550 && matches!(components.next(), Some(Component::Normal(value)) if value == OsStr::new("tmp"))
551}
552
553fn reject_symlinked_temp_root(workspace: &Path, path: &Path, info: &AgentTempInfo) -> Result<()> {
554 let temp_root = workspace.join(TEMP_ROOT);
555 if fs::symlink_metadata(&temp_root).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
556 return Err(policy_error(
557 path,
558 info,
559 format!(
560 "{} is a symlink; the managed temporary root must be a real directory inside \
561 the workspace",
562 temp_root.display()
563 ),
564 ));
565 }
566 Ok(())
567}
568
569fn validate_session_relative_path(path: &Path, info: &AgentTempInfo) -> Result<()> {
570 let relative = path.strip_prefix(&info.directory).map_err(|_| {
571 policy_error(
572 path,
573 info,
574 format!(
575 "temporary helpers are session-scoped; use the exact temporary_artifact_dir {} \
576 returned by Initialize",
577 info.directory.display()
578 ),
579 )
580 })?;
581 let relative_bytes = relative.as_os_str().as_encoded_bytes().len();
582 if relative_bytes == 0 {
583 return Err(policy_error(path, info, "a helper filename is required".to_string()));
584 }
585 if relative_bytes > MAX_RELATIVE_PATH_BYTES {
586 return Err(policy_error(
587 path,
588 info,
589 format!(
590 "the session-relative path is {relative_bytes} bytes; temporary paths are limited \
591 to {MAX_RELATIVE_PATH_BYTES} bytes"
592 ),
593 ));
594 }
595
596 let mut count = 0usize;
597 for component in relative.components() {
598 let Component::Normal(name) = component else {
599 return Err(policy_error(
600 path,
601 info,
602 "temporary paths may contain only normal filename components".to_string(),
603 ));
604 };
605 count += 1;
606 let bytes = name.as_encoded_bytes().len();
607 if bytes > MAX_COMPONENT_BYTES {
608 return Err(policy_error(
609 path,
610 info,
611 format!(
612 "a temporary path component is {bytes} bytes; components are limited to \
613 {MAX_COMPONENT_BYTES} bytes so content is never encoded in filesystem names"
614 ),
615 ));
616 }
617 }
618 if count > MAX_RELATIVE_COMPONENTS {
619 return Err(policy_error(
620 path,
621 info,
622 format!(
623 "temporary path depth is {count}; at most {MAX_RELATIVE_COMPONENTS} components \
624 are allowed beneath the session directory"
625 ),
626 ));
627 }
628 Ok(())
629}
630
631fn validate_projected_usage(
632 path: &Path,
633 info: &AgentTempInfo,
634 current: TempTreeUsage,
635 projected: TempTreeUsage,
636) -> Result<()> {
637 let violation =
638 if projected.total_bytes > MAX_TOTAL_BYTES && projected.total_bytes > current.total_bytes {
639 Some(format!(
640 "this write would grow managed temporary storage to {} bytes; the workspace-wide \
641 limit is {MAX_TOTAL_BYTES} bytes",
642 projected.total_bytes
643 ))
644 } else if projected.session_bytes > MAX_SESSION_BYTES
645 && projected.session_bytes > current.session_bytes
646 {
647 Some(format!(
648 "this write would grow the active session's temporary storage to {} bytes; the \
649 per-session limit is {MAX_SESSION_BYTES} bytes",
650 projected.session_bytes
651 ))
652 } else if projected.session_files > MAX_SESSION_FILES
653 && projected.session_files > current.session_files
654 {
655 Some(format!(
656 "this write would grow the active session to {} helper files; the per-session limit \
657 is {MAX_SESSION_FILES}. Reuse or overwrite a stable helper and remove obsolete \
658 carriers instead of creating another file",
659 projected.session_files
660 ))
661 } else {
662 None
663 };
664 match violation {
665 Some(message) => Err(policy_error(
666 path,
667 info,
668 format!(
669 "{message}. Shrinking existing helpers remains allowed so the session can recover"
670 ),
671 )),
672 None => Ok(()),
673 }
674}
675
676fn policy_error(path: &Path, info: &AgentTempInfo, message: String) -> WinxError {
677 WinxError::TemporaryArtifactPolicy {
678 path: path.to_path_buf(),
679 temporary_artifact_dir: info.directory.clone(),
680 message,
681 }
682}
683
684#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
685struct TempTreeUsage {
686 total_bytes: u64,
687 session_bytes: u64,
688 session_files: usize,
689}
690
691fn temp_tree_usage(root: &Path, active_session: &Path) -> Option<TempTreeUsage> {
692 let metadata = match fs::symlink_metadata(root) {
693 Ok(metadata) => metadata,
694 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
695 return Some(TempTreeUsage::default())
696 }
697 Err(_) => return None,
698 };
699 if metadata.file_type().is_symlink() {
700 return None;
701 }
702
703 let mut usage = TempTreeUsage::default();
704 let mut seen = 0usize;
705 let mut stack = vec![(root.to_path_buf(), 0usize)];
706 while let Some((path, depth)) = stack.pop() {
707 if seen >= MAX_SCAN_ENTRIES || depth > MAX_SCAN_DEPTH {
708 return None;
709 }
710 seen += 1;
711 let metadata = fs::symlink_metadata(&path).ok()?;
712 if metadata.file_type().is_symlink() {
713 continue;
714 }
715 if metadata.is_file() {
716 usage.total_bytes = usage.total_bytes.saturating_add(metadata.len());
717 if path.starts_with(active_session) {
718 usage.session_bytes = usage.session_bytes.saturating_add(metadata.len());
719 usage.session_files = usage.session_files.saturating_add(1);
720 }
721 continue;
722 }
723 if metadata.is_dir() {
724 for entry in fs::read_dir(&path).ok()? {
725 stack.push((entry.ok()?.path(), depth + 1));
726 }
727 }
728 }
729 Some(usage)
730}
731
732fn prune_sessions_unlocked(workspace_root: &Path, active: &Path, max_age: Duration) {
733 let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
734 let root = workspace.join(TEMP_ROOT);
735 if fs::symlink_metadata(&root).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
736 return;
737 }
738 let Ok(entries) = fs::read_dir(&root) else { return };
739 let cutoff = SystemTime::now().checked_sub(max_age).unwrap_or(UNIX_EPOCH);
740 for entry in entries.flatten() {
741 let path = entry.path();
742 if path == active || !is_managed_session_entry(&entry) || !session_is_stale(&path, cutoff) {
743 continue;
744 }
745 if let Err(error) = fs::remove_dir_all(&path) {
746 debug!(path = %path.display(), %error, "failed to prune expired agent temp session");
747 }
748 }
749}
750
751fn is_managed_session_entry(entry: &fs::DirEntry) -> bool {
752 if !entry.file_type().is_ok_and(|kind| kind.is_dir() && !kind.is_symlink()) {
753 return false;
754 }
755 let name = entry.file_name();
756 let name = name.to_string_lossy();
757 let Some(hash) = name.strip_prefix(SESSION_PREFIX) else { return false };
758 hash.len() == SESSION_HASH_BYTES * 2 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
759}
760
761fn session_is_stale(root: &Path, cutoff: SystemTime) -> bool {
764 let mut newest = UNIX_EPOCH;
765 let mut seen = 0usize;
766 let mut stack = vec![(root.to_path_buf(), 0usize)];
767 while let Some((path, depth)) = stack.pop() {
768 if seen >= MAX_SCAN_ENTRIES || depth > MAX_SCAN_DEPTH {
769 return false;
770 }
771 seen += 1;
772 let Ok(metadata) = fs::symlink_metadata(&path) else { return false };
773 let Ok(modified) = metadata.modified() else { return false };
774 newest = newest.max(modified);
775 if metadata.is_dir() && !metadata.file_type().is_symlink() {
776 let Ok(entries) = fs::read_dir(&path) else { return false };
777 for entry in entries {
778 let Ok(entry) = entry else { return false };
779 stack.push((entry.path(), depth + 1));
780 }
781 }
782 }
783 newest < cutoff
784}
785
786#[cfg(test)]
787mod tests {
788 #![allow(clippy::expect_used, clippy::unwrap_used)]
789
790 use super::*;
791 use std::thread::sleep;
792 use tempfile::TempDir;
793
794 #[test]
795 fn session_path_is_stable_short_and_workspace_local() {
796 let workspace = TempDir::new().unwrap();
797 let first = session_info(workspace.path(), "chat/project:one");
798 let repeated = session_info(workspace.path(), "chat/project:one");
799 let other = session_info(workspace.path(), "chat/project:two");
800 assert_eq!(first, repeated);
801 assert_ne!(first.directory, other.directory);
802 assert!(first.directory.starts_with(workspace.path().canonicalize().unwrap()));
803 let name = first.directory.file_name().unwrap().to_string_lossy();
804 assert_eq!(name.len(), SESSION_PREFIX.len() + SESSION_HASH_BYTES * 2);
805 }
806
807 #[test]
808 fn accepts_short_helpers_only_in_the_active_session() {
809 let workspace = TempDir::new().unwrap();
810 let info = session_info(workspace.path(), "active");
811 let valid = info.directory.join("review/adapter.py");
812 validate_edit_target(workspace.path(), "active", &valid, &valid, None, 128).unwrap();
813
814 let other = session_info(workspace.path(), "other").directory.join("adapter.py");
815 let error = validate_edit_target(workspace.path(), "active", &other, &other, None, 128)
816 .expect_err("cross-session temp write must fail");
817 assert!(error.to_string().contains("session-scoped"), "{error}");
818 }
819
820 #[test]
821 fn bash_writes_accept_only_the_active_session_temp_path() {
822 let workspace = TempDir::new().unwrap();
823 let info = session_info(workspace.path(), "active");
824 let valid = format!("printf x > '{}'", info.directory.join("adapter.ts").display());
825 validate_bash_command(workspace.path(), workspace.path(), "active", &valid)
826 .expect("active session destination must be accepted");
827
828 for command in [
829 "printf x > .winx/tmp/direct.ts",
830 "printf x | tee .winx-review-carrier.js",
831 "python - <<'PY'\nfrom pathlib import Path\nPath('.winx/tmp/direct.py').write_text('x')\nPY",
832 ] {
833 let error = validate_bash_command(workspace.path(), workspace.path(), "active", command)
834 .expect_err("unmanaged shell helper must fail");
835 assert!(
836 error.to_string().to_ascii_lowercase().contains("temporary artifact policy"),
837 "{error}"
838 );
839 }
840 }
841
842 #[test]
843 fn bash_temp_preflight_does_not_block_reads_or_dynamic_active_path() {
844 let workspace = TempDir::new().unwrap();
845 for command in [
846 "rg needle .winx/tmp/direct.ts",
847 "cat .winx-review-carrier.js",
848 "mkdir -p \"$WINX_TEMP_DIR\" && printf x > \"$WINX_TEMP_DIR/helper.ts\"",
849 ] {
850 validate_bash_command(workspace.path(), workspace.path(), "active", command)
851 .expect("read or runtime-provided destination must remain available");
852 }
853 }
854
855 #[test]
856 fn rejects_legacy_root_artifacts_and_payload_names() {
857 let workspace = TempDir::new().unwrap();
858 let legacy = workspace.path().join(".winx_tmp/payload/file.txt");
859 let error = validate_edit_target(workspace.path(), "active", &legacy, &legacy, None, 10)
860 .expect_err("legacy root must fail");
861 assert!(error.to_string().contains("workspace root"), "{error}");
862
863 let info = session_info(workspace.path(), "active");
864 let encoded = info.directory.join(format!("{}.txt", "x".repeat(MAX_COMPONENT_BYTES + 1)));
865 let error = validate_edit_target(workspace.path(), "active", &encoded, &encoded, None, 10)
866 .expect_err("long component must fail");
867 assert!(error.to_string().contains("filesystem names"), "{error}");
868 }
869
870 #[test]
871 fn resolved_path_cannot_hide_a_root_artifact_behind_parent_components() {
872 let workspace = TempDir::new().unwrap();
873 let requested = workspace.path().join("nested/../.winx-review-carrier.py");
874 let resolved = workspace.path().join(".winx-review-carrier.py");
875 let error =
876 validate_edit_target(workspace.path(), "active", &requested, &resolved, None, 10)
877 .expect_err("resolved top-level artifact must fail");
878 assert!(error.to_string().contains("workspace root"), "{error}");
879 }
880
881 #[cfg(unix)]
882 #[test]
883 fn lexical_workspace_alias_cannot_bypass_temp_policy() {
884 use std::os::unix::fs::symlink;
885
886 let parent = TempDir::new().unwrap();
887 let real_workspace = parent.path().join("real-workspace");
888 let alias_workspace = parent.path().join("workspace-alias");
889 fs::create_dir(&real_workspace).unwrap();
890 symlink(&real_workspace, &alias_workspace).unwrap();
891
892 let legacy = alias_workspace.join(".winx_tmp/payload.txt");
893 let error = validate_edit_target(&alias_workspace, "active", &legacy, &legacy, None, 10)
894 .expect_err("a lexical workspace alias must not bypass root-artifact policy");
895 assert!(error.to_string().contains("workspace root"), "{error}");
896
897 let canonical_workspace = alias_workspace.canonicalize().unwrap();
898 let canonical_session = session_info(&alias_workspace, "active").directory;
899 let relative_session = canonical_session.strip_prefix(&canonical_workspace).unwrap();
900 let aliased_helper = alias_workspace.join(relative_session).join("adapter.py");
901 validate_edit_target(
902 &alias_workspace,
903 "active",
904 &aliased_helper,
905 &aliased_helper,
906 None,
907 10,
908 )
909 .expect("the active managed directory must work through the lexical alias");
910 }
911
912 #[test]
913 fn prune_removes_only_expired_managed_siblings() {
914 let workspace = TempDir::new().unwrap();
915 let active = session_info(workspace.path(), "active").directory;
916 let expired = session_info(workspace.path(), "expired").directory;
917 let unmanaged = workspace.path().join(TEMP_ROOT).join("manual-cache");
918 fs::create_dir_all(&active).unwrap();
919 fs::create_dir_all(&expired).unwrap();
920 fs::create_dir_all(&unmanaged).unwrap();
921 fs::write(expired.join("old.txt"), "old").unwrap();
922 sleep(Duration::from_millis(15));
923
924 let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
925 prune_sessions_unlocked(workspace.path(), &active, Duration::from_millis(1));
926 assert!(active.exists());
927 assert!(!expired.exists());
928 assert!(unmanaged.exists());
929 }
930
931 #[test]
932 fn quota_projection_accounts_for_replacements() {
933 let workspace = TempDir::new().unwrap();
934 let info = session_info(workspace.path(), "active");
935 let path = info.directory.join("large.bin");
936 let current = TempTreeUsage::default();
937 assert!(validate_projected_usage(
938 &path,
939 &info,
940 current,
941 TempTreeUsage { total_bytes: MAX_TOTAL_BYTES, ..current }
942 )
943 .is_ok());
944 assert!(validate_projected_usage(
945 &path,
946 &info,
947 current,
948 TempTreeUsage { total_bytes: MAX_TOTAL_BYTES + 1, ..current }
949 )
950 .is_err());
951
952 let oversized = TempTreeUsage {
953 total_bytes: MAX_TOTAL_BYTES + 10,
954 session_bytes: MAX_SESSION_BYTES + 10,
955 session_files: MAX_SESSION_FILES + 10,
956 };
957 let shrinking = TempTreeUsage {
958 total_bytes: oversized.total_bytes - 1,
959 session_bytes: oversized.session_bytes - 1,
960 session_files: oversized.session_files,
961 };
962 validate_projected_usage(&path, &info, oversized, shrinking)
963 .expect("an over-limit session must be allowed to shrink existing helpers");
964 }
965
966 #[test]
967 fn batch_quota_counts_every_planned_helper_before_committing() {
968 let workspace = TempDir::new().unwrap();
969 let info = session_info(workspace.path(), "active");
970 let paths = (0..9)
971 .map(|index| info.directory.join(format!("helper-{index}.bin")))
972 .collect::<Vec<_>>();
973 let edits = paths
974 .iter()
975 .map(|path| TempEdit {
976 path,
977 previous_bytes: 0,
978 new_bytes: MAX_FILE_BYTES,
979 is_new: true,
980 })
981 .collect::<Vec<_>>();
982
983 let error = validate_batch_quota(workspace.path(), "active", &edits)
984 .expect_err("aggregate batch must honor the workspace quota");
985 assert!(error.to_string().contains("workspace-wide limit"), "{error}");
986 }
987
988 #[test]
989 fn literal_winx_temp_destinations_honor_the_session_file_cap() {
990 let workspace = TempDir::new().unwrap();
991 let info = session_info(workspace.path(), "active");
992 fs::create_dir_all(&info.directory).unwrap();
993 for index in 0..MAX_SESSION_FILES {
994 fs::write(info.directory.join(format!("helper-{index}.txt")), "x").unwrap();
995 }
996
997 validate_bash_command(
998 workspace.path(),
999 workspace.path(),
1000 "active",
1001 "printf replacement > \"$WINX_TEMP_DIR/helper-0.txt\"",
1002 )
1003 .expect("overwriting a stable helper must remain possible at the cap");
1004 let error = validate_bash_command(
1005 workspace.path(),
1006 workspace.path(),
1007 "active",
1008 "printf new > \"$WINX_TEMP_DIR/one-more.txt\"",
1009 )
1010 .expect_err("a visible new shell helper must honor the cap");
1011 assert!(error.to_string().contains("helper files"), "{error}");
1012 }
1013
1014 #[test]
1015 fn code_map_accepts_only_the_active_direct_helper_path() {
1016 let workspace = TempDir::new().unwrap();
1017 let active = session_info(workspace.path(), "active");
1018 let helper = active.directory.join("adapter.py");
1019 assert!(validate_code_map_target(workspace.path(), "active", &helper, &helper).unwrap());
1020
1021 let foreign = session_info(workspace.path(), "other").directory.join("adapter.py");
1022 let error = validate_code_map_target(workspace.path(), "active", &foreign, &foreign)
1023 .expect_err("another session's helper must not be mapped");
1024 assert!(error.to_string().contains("session-scoped"), "{error}");
1025
1026 let canonical = workspace.path().join("src/lib.rs");
1027 assert!(
1028 !validate_code_map_target(workspace.path(), "active", &canonical, &canonical).unwrap()
1029 );
1030 }
1031
1032 #[test]
1033 fn derived_code_map_budget_rewards_reuse_and_caps_churn() {
1034 let workspace = TempDir::new().unwrap();
1035 let info = session_info(workspace.path(), "active");
1036 let mut usage = DerivedCodeMapUsage::default();
1037 let stable = info.directory.join("stable.py");
1038 let first = reserve_derived_code_map(&mut usage, &stable, &info).unwrap();
1039 let repeated = reserve_derived_code_map(&mut usage, &stable, &info).unwrap();
1040 assert_eq!(first.unique_files_used, 1);
1041 assert_eq!(repeated.unique_files_used, 1);
1042 assert_eq!(repeated.calls_used, 2);
1043
1044 for index in 1..MAX_DERIVED_CODE_MAP_UNIQUE_FILES {
1045 reserve_derived_code_map(
1046 &mut usage,
1047 &info.directory.join(format!("helper-{index}.py")),
1048 &info,
1049 )
1050 .unwrap();
1051 }
1052 let error =
1053 reserve_derived_code_map(&mut usage, &info.directory.join("one-too-many.py"), &info)
1054 .expect_err("a new carrier past the unique-file budget must fail");
1055 assert!(matches!(error, WinxError::DerivedCodeMapBudget { .. }));
1056
1057 let mut repeated_usage = DerivedCodeMapUsage::default();
1058 for _ in 0..MAX_DERIVED_CODE_MAP_CALLS {
1059 reserve_derived_code_map(&mut repeated_usage, &stable, &info).unwrap();
1060 }
1061 let error = reserve_derived_code_map(&mut repeated_usage, &stable, &info)
1062 .expect_err("even one stable helper has an aggregate call budget");
1063 assert!(matches!(error, WinxError::DerivedCodeMapBudget { .. }));
1064 }
1065}