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