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