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