Skip to main content

winx_code_agent/utils/
agent_temp.rs

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