1use std::ffi::OsStr;
10use std::fs;
11use std::path::{Component, Path, PathBuf};
12use std::sync::Mutex;
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14
15use sha2::{Digest, Sha256};
16use tracing::debug;
17
18use crate::errors::{Result, WinxError};
19
20const TEMP_ROOT: &str = ".winx/tmp";
21const SESSION_PREFIX: &str = "session-";
22const SESSION_HASH_BYTES: usize = 8;
23
24pub const MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
27pub const MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
29pub const MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
31pub const MAX_RELATIVE_COMPONENTS: usize = 8;
33pub const MAX_COMPONENT_BYTES: usize = 120;
35pub const MAX_RELATIVE_PATH_BYTES: usize = 512;
37
38const MAX_SCAN_ENTRIES: usize = 50_000;
39const MAX_SCAN_DEPTH: usize = 16;
40static TEMP_IO_LOCK: Mutex<()> = Mutex::new(());
41
42#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct AgentTempInfo {
45 pub directory: PathBuf,
46 pub ttl_seconds: u64,
47 pub max_total_bytes: u64,
48 pub max_file_bytes: u64,
49}
50
51#[derive(Clone, Copy, Debug)]
54pub struct TempEdit<'a> {
55 pub path: &'a Path,
56 pub previous_bytes: u64,
57 pub new_bytes: u64,
58}
59
60pub fn session_info(workspace_root: &Path, thread_id: &str) -> AgentTempInfo {
62 let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
63 AgentTempInfo {
64 directory: workspace.join(TEMP_ROOT).join(session_name(thread_id)),
65 ttl_seconds: MAX_AGE.as_secs(),
66 max_total_bytes: MAX_TOTAL_BYTES,
67 max_file_bytes: MAX_FILE_BYTES,
68 }
69}
70
71pub fn prepare_session(workspace_root: &Path, thread_id: &str) -> AgentTempInfo {
75 let info = session_info(workspace_root, thread_id);
76 let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
77 prune_sessions_unlocked(workspace_root, &info.directory, MAX_AGE);
78 info
79}
80
81pub fn validate_edit_target(
85 workspace_root: &Path,
86 thread_id: &str,
87 requested_path: &Path,
88 resolved_path: &Path,
89 previous_bytes: Option<u64>,
90 new_bytes: u64,
91) -> Result<()> {
92 let info = session_info(workspace_root, thread_id);
93 let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
94 let requested_relative = workspace_relative(requested_path, workspace_root, &workspace);
101 let resolved_relative = workspace_relative(resolved_path, workspace_root, &workspace);
102
103 if let Some(relative) = resolved_relative.as_deref() {
104 reject_legacy_root_artifact(relative, previous_bytes.is_none(), requested_path, &info)?;
105 }
106 if requested_relative != resolved_relative {
107 if let Some(relative) = requested_relative.as_deref() {
108 reject_legacy_root_artifact(relative, previous_bytes.is_none(), requested_path, &info)?;
109 }
110 }
111
112 let requested_temp = requested_relative.as_deref().is_some_and(is_temp_relative);
113 let resolved_temp = resolved_relative.as_deref().is_some_and(is_temp_relative);
114 if !requested_temp && !resolved_temp {
115 return Ok(());
116 }
117
118 reject_symlinked_temp_root(&workspace, requested_path, &info)?;
119 let policy_path = resolved_relative
120 .as_deref()
121 .map_or_else(|| resolved_path.to_path_buf(), |relative| workspace.join(relative));
122 validate_session_relative_path(&policy_path, &info)?;
123 if new_bytes > MAX_FILE_BYTES {
124 return Err(policy_error(
125 &policy_path,
126 &info,
127 format!(
128 "helper content is {new_bytes} bytes; one temporary file is limited to \
129 {MAX_FILE_BYTES} bytes"
130 ),
131 ));
132 }
133
134 Ok(())
135}
136
137pub fn validate_bash_command(
144 workspace_root: &Path,
145 cwd: &Path,
146 thread_id: &str,
147 command: &str,
148) -> Result<()> {
149 let info = session_info(workspace_root, thread_id);
150 let mut paths = crate::utils::bash_parser::extract_static_write_paths(command);
151 paths.extend(embedded_writer_paths(command));
152 paths.sort();
153 paths.dedup();
154
155 for path in paths {
156 validate_bash_write_target(workspace_root, cwd, &path, &info)?;
157 }
158 Ok(())
159}
160
161fn validate_bash_write_target(
162 workspace_root: &Path,
163 cwd: &Path,
164 path: &str,
165 info: &AgentTempInfo,
166) -> Result<()> {
167 let expanded = crate::utils::path::expand_user(path);
168 let requested = if Path::new(&expanded).is_absolute() {
169 PathBuf::from(&expanded)
170 } else {
171 cwd.join(&expanded)
172 };
173 let resolved = crate::utils::path::resolve_in_workspace(path, cwd, workspace_root)
174 .unwrap_or_else(|_| requested.clone());
175 let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
176
177 let requested_relative = workspace_relative(&requested, workspace_root, &workspace);
178 let resolved_relative = workspace_relative(&resolved, workspace_root, &workspace);
179 for relative in
180 [requested_relative.as_deref(), resolved_relative.as_deref()].into_iter().flatten()
181 {
182 let Some(Component::Normal(first)) = relative.components().next() else { continue };
183 let first = first.to_string_lossy();
184 if first == ".winx_tmp" || first.starts_with(".winx-") {
185 return Err(policy_error(
186 &requested,
187 info,
188 format!(
189 "shell writes to Winx helper artifacts at the workspace root are not \
190 allowed; use WINX_TEMP_DIR={} instead",
191 info.directory.display()
192 ),
193 ));
194 }
195 if is_temp_relative(relative) {
196 reject_symlinked_temp_root(&workspace, &requested, info)?;
197 validate_session_relative_path(&workspace.join(relative), info)?;
198 }
199 }
200 Ok(())
201}
202
203fn embedded_writer_paths(command: &str) -> Vec<String> {
204 let lower = command.to_ascii_lowercase();
205 let explicit_writer = [
206 ".write_text(",
207 ".write_bytes(",
208 "writefilesync(",
209 "appendfilesync(",
210 "writefile(",
211 "appendfile(",
212 "bun.write(",
213 "deno.write",
214 "file.write(",
215 "file.binwrite(",
216 ]
217 .iter()
218 .any(|marker| lower.contains(marker));
219 let writable_open = lower.contains("open(")
220 && [", 'w", ", \"w", ", 'a", ", \"a", ", 'x", ", \"x", ", 'r+", ", \"r+"]
221 .iter()
222 .any(|mode| lower.contains(mode));
223 if !explicit_writer && !writable_open {
224 return Vec::new();
225 }
226
227 quoted_literals(command).into_iter().filter(|literal| literal.contains(".winx")).collect()
228}
229
230fn quoted_literals(value: &str) -> Vec<String> {
231 let mut literals = Vec::new();
232 let mut chars = value.chars();
233 while let Some(character) = chars.next() {
234 if character != '\'' && character != '"' {
235 continue;
236 }
237 let quote = character;
238 let mut literal = String::new();
239 let mut escaped = false;
240 for character in chars.by_ref() {
241 if escaped {
242 literal.push(character);
243 escaped = false;
244 } else if character == '\\' && quote == '"' {
245 escaped = true;
246 } else if character == quote {
247 break;
248 } else {
249 literal.push(character);
250 }
251 }
252 if !literal.is_empty() {
253 literals.push(literal);
254 }
255 }
256 literals
257}
258
259fn workspace_relative(
260 path: &Path,
261 workspace_root: &Path,
262 canonical_workspace: &Path,
263) -> Option<PathBuf> {
264 path.strip_prefix(canonical_workspace)
265 .or_else(|_| path.strip_prefix(workspace_root))
266 .ok()
267 .map(Path::to_path_buf)
268}
269
270pub fn validate_batch_quota(
273 workspace_root: &Path,
274 thread_id: &str,
275 edits: &[TempEdit<'_>],
276) -> Result<()> {
277 let info = session_info(workspace_root, thread_id);
278 let managed: Vec<_> =
279 edits.iter().filter(|edit| edit.path.starts_with(&info.directory)).collect();
280 if managed.is_empty() {
281 return Ok(());
282 }
283 let representative = managed[0].path;
284
285 let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
286 let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
287 let mut projected = temp_tree_size(&workspace.join(TEMP_ROOT)).ok_or_else(|| {
288 policy_error(
289 representative,
290 &info,
291 "temporary storage could not be measured safely; remove malformed or excessive \
292 artifacts before retrying"
293 .to_string(),
294 )
295 })?;
296 for edit in managed {
297 projected = projected.saturating_sub(edit.previous_bytes).saturating_add(edit.new_bytes);
298 }
299 validate_projected_size(representative, &info, projected)
300}
301
302fn session_name(thread_id: &str) -> String {
303 use std::fmt::Write as _;
304
305 let digest = Sha256::digest(thread_id.as_bytes());
306 let mut suffix = String::with_capacity(SESSION_HASH_BYTES * 2);
307 for byte in &digest[..SESSION_HASH_BYTES] {
308 let _ = write!(suffix, "{byte:02x}");
309 }
310 format!("{SESSION_PREFIX}{suffix}")
311}
312
313fn reject_legacy_root_artifact(
314 relative: &Path,
315 is_new: bool,
316 requested_path: &Path,
317 info: &AgentTempInfo,
318) -> Result<()> {
319 let Some(Component::Normal(first)) = relative.components().next() else { return Ok(()) };
320 let first = first.to_string_lossy();
321 if first == ".winx_tmp" || (is_new && first.starts_with(".winx-")) {
322 return Err(policy_error(
323 requested_path,
324 info,
325 format!(
326 "ad hoc Winx artifacts at the workspace root are not allowed; use {} with a \
327 short descriptive filename",
328 info.directory.display()
329 ),
330 ));
331 }
332 Ok(())
333}
334
335fn is_temp_relative(relative: &Path) -> bool {
336 let mut components = relative.components();
337 matches!(components.next(), Some(Component::Normal(value)) if value == OsStr::new(".winx"))
338 && matches!(components.next(), Some(Component::Normal(value)) if value == OsStr::new("tmp"))
339}
340
341fn reject_symlinked_temp_root(workspace: &Path, path: &Path, info: &AgentTempInfo) -> Result<()> {
342 let temp_root = workspace.join(TEMP_ROOT);
343 if fs::symlink_metadata(&temp_root).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
344 return Err(policy_error(
345 path,
346 info,
347 format!(
348 "{} is a symlink; the managed temporary root must be a real directory inside \
349 the workspace",
350 temp_root.display()
351 ),
352 ));
353 }
354 Ok(())
355}
356
357fn validate_session_relative_path(path: &Path, info: &AgentTempInfo) -> Result<()> {
358 let relative = path.strip_prefix(&info.directory).map_err(|_| {
359 policy_error(
360 path,
361 info,
362 format!(
363 "temporary helpers are session-scoped; use the exact temporary_artifact_dir {} \
364 returned by Initialize",
365 info.directory.display()
366 ),
367 )
368 })?;
369 let relative_bytes = relative.as_os_str().as_encoded_bytes().len();
370 if relative_bytes == 0 {
371 return Err(policy_error(path, info, "a helper filename is required".to_string()));
372 }
373 if relative_bytes > MAX_RELATIVE_PATH_BYTES {
374 return Err(policy_error(
375 path,
376 info,
377 format!(
378 "the session-relative path is {relative_bytes} bytes; temporary paths are limited \
379 to {MAX_RELATIVE_PATH_BYTES} bytes"
380 ),
381 ));
382 }
383
384 let mut count = 0usize;
385 for component in relative.components() {
386 let Component::Normal(name) = component else {
387 return Err(policy_error(
388 path,
389 info,
390 "temporary paths may contain only normal filename components".to_string(),
391 ));
392 };
393 count += 1;
394 let bytes = name.as_encoded_bytes().len();
395 if bytes > MAX_COMPONENT_BYTES {
396 return Err(policy_error(
397 path,
398 info,
399 format!(
400 "a temporary path component is {bytes} bytes; components are limited to \
401 {MAX_COMPONENT_BYTES} bytes so content is never encoded in filesystem names"
402 ),
403 ));
404 }
405 }
406 if count > MAX_RELATIVE_COMPONENTS {
407 return Err(policy_error(
408 path,
409 info,
410 format!(
411 "temporary path depth is {count}; at most {MAX_RELATIVE_COMPONENTS} components \
412 are allowed beneath the session directory"
413 ),
414 ));
415 }
416 Ok(())
417}
418
419fn validate_projected_size(path: &Path, info: &AgentTempInfo, projected: u64) -> Result<()> {
420 if projected <= MAX_TOTAL_BYTES {
421 return Ok(());
422 }
423 Err(policy_error(
424 path,
425 info,
426 format!(
427 "this write would grow managed temporary storage to {projected} bytes; the \
428 workspace-wide limit is {MAX_TOTAL_BYTES} bytes. Remove helpers that are no longer \
429 needed, then retry with corrected content"
430 ),
431 ))
432}
433
434fn policy_error(path: &Path, info: &AgentTempInfo, message: String) -> WinxError {
435 WinxError::TemporaryArtifactPolicy {
436 path: path.to_path_buf(),
437 temporary_artifact_dir: info.directory.clone(),
438 message,
439 }
440}
441
442fn temp_tree_size(root: &Path) -> Option<u64> {
443 let metadata = match fs::symlink_metadata(root) {
444 Ok(metadata) => metadata,
445 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(0),
446 Err(_) => return None,
447 };
448 if metadata.file_type().is_symlink() {
449 return None;
450 }
451
452 let mut total = 0u64;
453 let mut seen = 0usize;
454 let mut stack = vec![(root.to_path_buf(), 0usize)];
455 while let Some((path, depth)) = stack.pop() {
456 if seen >= MAX_SCAN_ENTRIES || depth > MAX_SCAN_DEPTH {
457 return None;
458 }
459 seen += 1;
460 let metadata = fs::symlink_metadata(&path).ok()?;
461 if metadata.file_type().is_symlink() {
462 continue;
463 }
464 if metadata.is_file() {
465 total = total.saturating_add(metadata.len());
466 continue;
467 }
468 if metadata.is_dir() {
469 for entry in fs::read_dir(&path).ok()? {
470 stack.push((entry.ok()?.path(), depth + 1));
471 }
472 }
473 }
474 Some(total)
475}
476
477fn prune_sessions_unlocked(workspace_root: &Path, active: &Path, max_age: Duration) {
478 let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
479 let root = workspace.join(TEMP_ROOT);
480 if fs::symlink_metadata(&root).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
481 return;
482 }
483 let Ok(entries) = fs::read_dir(&root) else { return };
484 let cutoff = SystemTime::now().checked_sub(max_age).unwrap_or(UNIX_EPOCH);
485 for entry in entries.flatten() {
486 let path = entry.path();
487 if path == active || !is_managed_session_entry(&entry) || !session_is_stale(&path, cutoff) {
488 continue;
489 }
490 if let Err(error) = fs::remove_dir_all(&path) {
491 debug!(path = %path.display(), %error, "failed to prune expired agent temp session");
492 }
493 }
494}
495
496fn is_managed_session_entry(entry: &fs::DirEntry) -> bool {
497 if !entry.file_type().is_ok_and(|kind| kind.is_dir() && !kind.is_symlink()) {
498 return false;
499 }
500 let name = entry.file_name();
501 let name = name.to_string_lossy();
502 let Some(hash) = name.strip_prefix(SESSION_PREFIX) else { return false };
503 hash.len() == SESSION_HASH_BYTES * 2 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
504}
505
506fn session_is_stale(root: &Path, cutoff: SystemTime) -> bool {
509 let mut newest = UNIX_EPOCH;
510 let mut seen = 0usize;
511 let mut stack = vec![(root.to_path_buf(), 0usize)];
512 while let Some((path, depth)) = stack.pop() {
513 if seen >= MAX_SCAN_ENTRIES || depth > MAX_SCAN_DEPTH {
514 return false;
515 }
516 seen += 1;
517 let Ok(metadata) = fs::symlink_metadata(&path) else { return false };
518 let Ok(modified) = metadata.modified() else { return false };
519 newest = newest.max(modified);
520 if metadata.is_dir() && !metadata.file_type().is_symlink() {
521 let Ok(entries) = fs::read_dir(&path) else { return false };
522 for entry in entries {
523 let Ok(entry) = entry else { return false };
524 stack.push((entry.path(), depth + 1));
525 }
526 }
527 }
528 newest < cutoff
529}
530
531#[cfg(test)]
532mod tests {
533 #![allow(clippy::expect_used, clippy::unwrap_used)]
534
535 use super::*;
536 use std::thread::sleep;
537 use tempfile::TempDir;
538
539 #[test]
540 fn session_path_is_stable_short_and_workspace_local() {
541 let workspace = TempDir::new().unwrap();
542 let first = session_info(workspace.path(), "chat/project:one");
543 let repeated = session_info(workspace.path(), "chat/project:one");
544 let other = session_info(workspace.path(), "chat/project:two");
545 assert_eq!(first, repeated);
546 assert_ne!(first.directory, other.directory);
547 assert!(first.directory.starts_with(workspace.path().canonicalize().unwrap()));
548 let name = first.directory.file_name().unwrap().to_string_lossy();
549 assert_eq!(name.len(), SESSION_PREFIX.len() + SESSION_HASH_BYTES * 2);
550 }
551
552 #[test]
553 fn accepts_short_helpers_only_in_the_active_session() {
554 let workspace = TempDir::new().unwrap();
555 let info = session_info(workspace.path(), "active");
556 let valid = info.directory.join("review/adapter.py");
557 validate_edit_target(workspace.path(), "active", &valid, &valid, None, 128).unwrap();
558
559 let other = session_info(workspace.path(), "other").directory.join("adapter.py");
560 let error = validate_edit_target(workspace.path(), "active", &other, &other, None, 128)
561 .expect_err("cross-session temp write must fail");
562 assert!(error.to_string().contains("session-scoped"), "{error}");
563 }
564
565 #[test]
566 fn bash_writes_accept_only_the_active_session_temp_path() {
567 let workspace = TempDir::new().unwrap();
568 let info = session_info(workspace.path(), "active");
569 let valid = format!("printf x > '{}'", info.directory.join("adapter.ts").display());
570 validate_bash_command(workspace.path(), workspace.path(), "active", &valid)
571 .expect("active session destination must be accepted");
572
573 for command in [
574 "printf x > .winx/tmp/direct.ts",
575 "printf x | tee .winx-review-carrier.js",
576 "python - <<'PY'\nfrom pathlib import Path\nPath('.winx/tmp/direct.py').write_text('x')\nPY",
577 ] {
578 let error = validate_bash_command(workspace.path(), workspace.path(), "active", command)
579 .expect_err("unmanaged shell helper must fail");
580 assert!(
581 error.to_string().to_ascii_lowercase().contains("temporary artifact policy"),
582 "{error}"
583 );
584 }
585 }
586
587 #[test]
588 fn bash_temp_preflight_does_not_block_reads_or_dynamic_active_path() {
589 let workspace = TempDir::new().unwrap();
590 for command in [
591 "rg needle .winx/tmp/direct.ts",
592 "cat .winx-review-carrier.js",
593 "mkdir -p \"$WINX_TEMP_DIR\" && printf x > \"$WINX_TEMP_DIR/helper.ts\"",
594 ] {
595 validate_bash_command(workspace.path(), workspace.path(), "active", command)
596 .expect("read or runtime-provided destination must remain available");
597 }
598 }
599
600 #[test]
601 fn rejects_legacy_root_artifacts_and_payload_names() {
602 let workspace = TempDir::new().unwrap();
603 let legacy = workspace.path().join(".winx_tmp/payload/file.txt");
604 let error = validate_edit_target(workspace.path(), "active", &legacy, &legacy, None, 10)
605 .expect_err("legacy root must fail");
606 assert!(error.to_string().contains("workspace root"), "{error}");
607
608 let info = session_info(workspace.path(), "active");
609 let encoded = info.directory.join(format!("{}.txt", "x".repeat(MAX_COMPONENT_BYTES + 1)));
610 let error = validate_edit_target(workspace.path(), "active", &encoded, &encoded, None, 10)
611 .expect_err("long component must fail");
612 assert!(error.to_string().contains("filesystem names"), "{error}");
613 }
614
615 #[test]
616 fn resolved_path_cannot_hide_a_root_artifact_behind_parent_components() {
617 let workspace = TempDir::new().unwrap();
618 let requested = workspace.path().join("nested/../.winx-review-carrier.py");
619 let resolved = workspace.path().join(".winx-review-carrier.py");
620 let error =
621 validate_edit_target(workspace.path(), "active", &requested, &resolved, None, 10)
622 .expect_err("resolved top-level artifact must fail");
623 assert!(error.to_string().contains("workspace root"), "{error}");
624 }
625
626 #[cfg(unix)]
627 #[test]
628 fn lexical_workspace_alias_cannot_bypass_temp_policy() {
629 use std::os::unix::fs::symlink;
630
631 let parent = TempDir::new().unwrap();
632 let real_workspace = parent.path().join("real-workspace");
633 let alias_workspace = parent.path().join("workspace-alias");
634 fs::create_dir(&real_workspace).unwrap();
635 symlink(&real_workspace, &alias_workspace).unwrap();
636
637 let legacy = alias_workspace.join(".winx_tmp/payload.txt");
638 let error = validate_edit_target(&alias_workspace, "active", &legacy, &legacy, None, 10)
639 .expect_err("a lexical workspace alias must not bypass root-artifact policy");
640 assert!(error.to_string().contains("workspace root"), "{error}");
641
642 let canonical_workspace = alias_workspace.canonicalize().unwrap();
643 let canonical_session = session_info(&alias_workspace, "active").directory;
644 let relative_session = canonical_session.strip_prefix(&canonical_workspace).unwrap();
645 let aliased_helper = alias_workspace.join(relative_session).join("adapter.py");
646 validate_edit_target(
647 &alias_workspace,
648 "active",
649 &aliased_helper,
650 &aliased_helper,
651 None,
652 10,
653 )
654 .expect("the active managed directory must work through the lexical alias");
655 }
656
657 #[test]
658 fn prune_removes_only_expired_managed_siblings() {
659 let workspace = TempDir::new().unwrap();
660 let active = session_info(workspace.path(), "active").directory;
661 let expired = session_info(workspace.path(), "expired").directory;
662 let unmanaged = workspace.path().join(TEMP_ROOT).join("manual-cache");
663 fs::create_dir_all(&active).unwrap();
664 fs::create_dir_all(&expired).unwrap();
665 fs::create_dir_all(&unmanaged).unwrap();
666 fs::write(expired.join("old.txt"), "old").unwrap();
667 sleep(Duration::from_millis(15));
668
669 let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
670 prune_sessions_unlocked(workspace.path(), &active, Duration::from_millis(1));
671 assert!(active.exists());
672 assert!(!expired.exists());
673 assert!(unmanaged.exists());
674 }
675
676 #[test]
677 fn quota_projection_accounts_for_replacements() {
678 let workspace = TempDir::new().unwrap();
679 let info = session_info(workspace.path(), "active");
680 let path = info.directory.join("large.bin");
681 assert!(validate_projected_size(&path, &info, MAX_TOTAL_BYTES).is_ok());
682 assert!(validate_projected_size(&path, &info, MAX_TOTAL_BYTES + 1).is_err());
683 }
684
685 #[test]
686 fn batch_quota_counts_every_planned_helper_before_committing() {
687 let workspace = TempDir::new().unwrap();
688 let info = session_info(workspace.path(), "active");
689 let paths = (0..9)
690 .map(|index| info.directory.join(format!("helper-{index}.bin")))
691 .collect::<Vec<_>>();
692 let edits = paths
693 .iter()
694 .map(|path| TempEdit { path, previous_bytes: 0, new_bytes: MAX_FILE_BYTES })
695 .collect::<Vec<_>>();
696
697 let error = validate_batch_quota(workspace.path(), "active", &edits)
698 .expect_err("aggregate batch must honor the workspace quota");
699 assert!(error.to_string().contains("workspace-wide limit"), "{error}");
700 }
701}