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