1use std::collections::HashSet;
2use std::path::{Component, Path, PathBuf};
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Value};
7
8use crate::errors::{Result, WinxError};
9use crate::state::bash_state::BashState;
10use crate::tool_policy::EditPermissionSet;
11use crate::tool_registry::ToolKind;
12use crate::tools::file_write_or_edit::resolve_edit_path;
13use crate::types::{normalize_thread_id, LinePatch};
14
15const MAX_FILES: usize = 100;
16const MAX_TOTAL_PATCHES: usize = 1_024;
17const MAX_AGGREGATE_CONTENT_BYTES: usize = 64 * 1024 * 1024;
18
19#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum EditOperation {
22 Apply,
23 Undo,
24}
25
26#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, PartialEq, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum EditMode {
29 Replace,
30 SearchReplace,
31 LinePatch,
32 Undo,
33}
34
35#[derive(Clone, Debug, PartialEq)]
36pub enum EditChange {
37 Replace { file_path: String, content: String },
38 SearchReplace { file_path: String, content: String },
39 LinePatch { file_path: String, expected_revision: String, patches: Vec<LinePatch> },
40}
41
42impl EditChange {
43 pub fn file_path(&self) -> &str {
44 match self {
45 Self::Replace { file_path, .. }
46 | Self::SearchReplace { file_path, .. }
47 | Self::LinePatch { file_path, .. } => file_path,
48 }
49 }
50
51 pub const fn mode(&self) -> EditMode {
52 match self {
53 Self::Replace { .. } => EditMode::Replace,
54 Self::SearchReplace { .. } => EditMode::SearchReplace,
55 Self::LinePatch { .. } => EditMode::LinePatch,
56 }
57 }
58
59 fn canonical_value(&self, resolved_path: &str) -> Value {
60 match self {
61 Self::Replace { content, .. } => {
62 json!({"file_path": resolved_path, "mode": "replace", "content": content})
63 }
64 Self::SearchReplace { content, .. } => json!({
65 "file_path": resolved_path,
66 "mode": "search_replace",
67 "content": content
68 }),
69 Self::LinePatch { expected_revision, patches, .. } => json!({
70 "file_path": resolved_path,
71 "mode": "line_patch",
72 "expected_revision": expected_revision,
73 "patches": patches
74 }),
75 }
76 }
77}
78
79#[derive(Clone, Debug, PartialEq)]
80pub enum EditCommand {
81 Apply { changes: Vec<EditChange> },
82 Undo { file_path: String, undo_id: Option<String>, legacy_lifo: bool },
83}
84
85impl EditCommand {
86 pub const fn operation(&self) -> EditOperation {
87 match self {
88 Self::Apply { .. } => EditOperation::Apply,
89 Self::Undo { .. } => EditOperation::Undo,
90 }
91 }
92
93 pub fn modes(&self) -> Vec<EditMode> {
94 match self {
95 Self::Apply { changes } => changes.iter().map(EditChange::mode).collect(),
96 Self::Undo { .. } => vec![EditMode::Undo],
97 }
98 }
99
100 pub fn file_paths(&self) -> Vec<&str> {
101 match self {
102 Self::Apply { changes } => changes.iter().map(EditChange::file_path).collect(),
103 Self::Undo { file_path, .. } => vec![file_path],
104 }
105 }
106
107 pub fn file_count(&self) -> usize {
108 self.file_paths().len()
109 }
110}
111
112#[derive(Clone, Debug)]
113pub struct EditVerification {
114 pub command: String,
115 pub wait_for_seconds: Option<f32>,
116}
117
118#[derive(Clone, Debug, Eq, PartialEq)]
122pub(crate) struct CanonicalEditTarget {
123 path: PathBuf,
124 identity: String,
125 existed_at_preflight: bool,
126}
127
128impl CanonicalEditTarget {
129 pub(crate) fn from_preflight(path: PathBuf) -> Result<Self> {
130 if !path.is_absolute()
131 || path
132 .components()
133 .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
134 {
135 return Err(WinxError::InvalidInput(
136 "edit preflight produced a non-canonical target".to_string(),
137 ));
138 }
139 let identity = path.to_string_lossy().into_owned();
140 let existed_at_preflight = std::fs::symlink_metadata(&path).is_ok();
141 Ok(Self { path, identity, existed_at_preflight })
142 }
143
144 pub(crate) fn path(&self) -> &Path {
145 &self.path
146 }
147
148 pub(crate) fn identity(&self) -> &str {
149 &self.identity
150 }
151
152 pub(crate) fn validate_binding(&self) -> Result<()> {
153 let exists_now = std::fs::symlink_metadata(&self.path).is_ok();
154 if exists_now != self.existed_at_preflight {
155 return Err(self.binding_error("target existence changed after preflight"));
156 }
157 let current = if exists_now {
158 self.path.canonicalize().map(crate::utils::path::simplified).map_err(|error| {
159 self.binding_error(&format!("cannot revalidate target: {error}"))
160 })?
161 } else {
162 resolve_missing_target(&self.path).map_err(|error| {
163 self.binding_error(&format!("cannot revalidate target: {error}"))
164 })?
165 };
166 if current != self.path {
167 return Err(self.binding_error("a symlink or parent directory was retargeted"));
168 }
169 Ok(())
170 }
171
172 fn binding_error(&self, message: &str) -> WinxError {
173 WinxError::FileChangedAfterEdit {
174 path: self.path.clone(),
175 message: format!(
176 "canonical edit target changed after preflight ({message}); no write was attempted"
177 ),
178 }
179 }
180}
181
182fn resolve_missing_target(path: &Path) -> std::io::Result<PathBuf> {
183 let mut existing = path;
184 while std::fs::symlink_metadata(existing).is_err() {
185 existing = existing.parent().ok_or_else(|| {
186 std::io::Error::new(std::io::ErrorKind::NotFound, "no existing target ancestor")
187 })?;
188 }
189 let mut resolved = existing.canonicalize().map(crate::utils::path::simplified)?;
190 let remainder = path.strip_prefix(existing).map_err(|_| {
191 std::io::Error::new(
192 std::io::ErrorKind::InvalidInput,
193 "target escaped its captured ancestor",
194 )
195 })?;
196 for component in remainder.components() {
197 match component {
198 Component::Normal(component) => resolved.push(component),
199 Component::CurDir => {}
200 _ => {
201 return Err(std::io::Error::new(
202 std::io::ErrorKind::InvalidInput,
203 "non-canonical target remainder",
204 ));
205 }
206 }
207 }
208 Ok(resolved)
209}
210
211#[derive(Clone, Copy, Debug, Eq, PartialEq)]
212pub enum EditSurface {
213 FileWriteOrEdit,
214 MultiFileEdit,
215 ApplyPatch,
216 UndoEdit,
217 EditFiles,
218}
219
220impl EditSurface {
221 pub const fn legacy_tool_kind(self) -> Option<ToolKind> {
222 match self {
223 Self::FileWriteOrEdit => Some(ToolKind::FileWriteOrEdit),
224 Self::MultiFileEdit => Some(ToolKind::MultiFileEdit),
225 Self::ApplyPatch => Some(ToolKind::ApplyPatch),
226 Self::UndoEdit => Some(ToolKind::UndoEdit),
227 Self::EditFiles => None,
228 }
229 }
230
231 pub const fn tool_name(self) -> &'static str {
232 match self {
233 Self::FileWriteOrEdit => "FileWriteOrEdit",
234 Self::MultiFileEdit => "MultiFileEdit",
235 Self::ApplyPatch => "ApplyPatch",
236 Self::UndoEdit => "UndoEdit",
237 Self::EditFiles => "EditFiles",
238 }
239 }
240
241 pub const fn is_legacy(self) -> bool {
242 !matches!(self, Self::EditFiles)
243 }
244
245 pub const fn from_public_tool(tool: ToolKind) -> Option<Self> {
246 match tool {
247 ToolKind::FileWriteOrEdit => Some(Self::FileWriteOrEdit),
248 ToolKind::MultiFileEdit => Some(Self::MultiFileEdit),
249 ToolKind::ApplyPatch => Some(Self::ApplyPatch),
250 ToolKind::UndoEdit => Some(Self::UndoEdit),
251 ToolKind::EditFiles => Some(Self::EditFiles),
252 _ => None,
253 }
254 }
255
256 pub(crate) const fn source_permissions(self) -> EditPermissionSet {
257 match self.legacy_tool_kind() {
258 Some(tool) => EditPermissionSet::for_legacy_tool(tool),
259 None => EditPermissionSet::all_mutations(),
260 }
261 }
262}
263
264#[derive(Clone, Debug)]
265pub struct NormalizedEditCall {
266 pub surface: EditSurface,
267 pub command: EditCommand,
268 pub verification: Option<EditVerification>,
269 pub thread_id: String,
270 pub workspace_root: Option<String>,
271 pub original_arguments: Value,
272}
273
274#[derive(Clone, Debug)]
275pub struct PreparedEditContext {
276 pub surface: EditSurface,
277 pub command: EditCommand,
278 pub verification: Option<EditVerification>,
279 pub thread_id: String,
280 pub workspace_root: Option<String>,
281 pub canonical_workspace_root: String,
284 pub(crate) targets: Vec<CanonicalEditTarget>,
285 pub original_arguments: Value,
286 pub(crate) effective_permissions: EditPermissionSet,
287}
288
289impl PreparedEditContext {
290 pub fn prepare(
291 normalized: NormalizedEditCall,
292 state: &BashState,
293 effective_permissions: EditPermissionSet,
294 ) -> Result<Self> {
295 let thread_id = normalize_thread_id(&normalized.thread_id);
296 if thread_id != state.current_thread_id {
297 return Err(WinxError::ThreadIdMismatch(thread_id));
298 }
299
300 let file_count = normalized.command.file_count();
301 if file_count == 0 || file_count > MAX_FILES {
302 return Err(WinxError::InvalidInput(format!(
303 "EditFiles files must contain between 1 and {MAX_FILES} entries"
304 )));
305 }
306 if matches!(normalized.command, EditCommand::Undo { .. }) && file_count != 1 {
307 return Err(WinxError::InvalidInput(
308 "EditFiles undo requires exactly one file".to_string(),
309 ));
310 }
311 if normalized.verification.is_some() && !effective_permissions.allows_verification() {
312 return Err(WinxError::CommandNotAllowed(
313 "edit verification requires BashCommand authority in the effective tool policy"
314 .to_string(),
315 ));
316 }
317
318 let source_permissions = normalized.surface.source_permissions();
319 let mut targets = Vec::with_capacity(file_count);
320 let mut seen = HashSet::with_capacity(file_count);
321 for (mode, requested) in
322 normalized.command.modes().into_iter().zip(normalized.command.file_paths())
323 {
324 if !effective_permissions.allows(mode, file_count)
325 || !source_permissions.allows(mode, file_count)
326 {
327 return Err(WinxError::FileOperationDenied {
328 path: requested.into(),
329 message: format!(
330 "{} does not authorize mode={} with {} file(s)",
331 normalized.surface.tool_name(),
332 mode.as_str(),
333 file_count
334 ),
335 });
336 }
337 let (_, resolved) = resolve_edit_path(state, requested)?;
338 let target = CanonicalEditTarget::from_preflight(resolved)?;
339 let resolved_string = target.identity().to_string();
340 let mode_allowed = match mode {
341 EditMode::Replace => state.is_file_write_allowed(&resolved_string),
342 EditMode::SearchReplace | EditMode::LinePatch | EditMode::Undo => {
343 state.is_file_edit_allowed(&resolved_string)
344 }
345 };
346 if !mode_allowed {
347 return Err(WinxError::FileOperationDenied {
348 path: target.path().to_path_buf(),
349 message: format!(
350 "{} is not allowed for this path in the current shell mode",
351 mode.as_str()
352 ),
353 });
354 }
355 if !seen.insert(resolved_string.clone()) {
356 return Err(if normalized.surface == EditSurface::MultiFileEdit {
357 WinxError::ArgumentParseError(format!(
358 "MultiFileEdit targets '{resolved_string}' more than once; edits to the same file don't compose - combine them into a single entry."
359 ))
360 } else {
361 WinxError::InvalidInput(format!(
362 "EditFiles targets {resolved_string:?} more than once; combine edits for one file"
363 ))
364 });
365 }
366 targets.push(target);
367 }
368
369 Ok(Self {
370 surface: normalized.surface,
371 command: normalized.command,
372 verification: normalized.verification,
373 thread_id,
374 workspace_root: normalized.workspace_root,
375 canonical_workspace_root: state.workspace_root.to_string_lossy().into_owned(),
376 targets,
377 original_arguments: normalized.original_arguments,
378 effective_permissions,
379 })
380 }
381
382 pub(crate) fn authorize_current_state(&self, state: &BashState) -> Result<()> {
386 if self.thread_id != state.current_thread_id {
387 return Err(WinxError::ThreadIdMismatch(self.thread_id.clone()));
388 }
389 let file_count = self.targets.len();
390 for (mode, target) in self.command.modes().into_iter().zip(&self.targets) {
391 let path = target.identity();
396 if !self.effective_permissions.allows(mode, file_count) {
397 return Err(WinxError::FileOperationDenied {
398 path: path.into(),
399 message: format!(
400 "effective tool policy no longer authorizes mode={} with {file_count} file(s)",
401 mode.as_str()
402 ),
403 });
404 }
405 let allowed = match mode {
406 EditMode::Replace => state.is_file_write_allowed(path),
407 EditMode::SearchReplace | EditMode::LinePatch | EditMode::Undo => {
408 state.is_file_edit_allowed(path)
409 }
410 };
411 if !allowed {
412 return Err(WinxError::FileOperationDenied {
413 path: path.into(),
414 message: format!(
415 "{} is no longer allowed for this path in the current shell mode",
416 mode.as_str()
417 ),
418 });
419 }
420 }
421 Ok(())
422 }
423
424 pub fn canonical_value(&self) -> Value {
425 let command = match &self.command {
426 EditCommand::Apply { changes } => json!({
427 "operation": "apply",
428 "files": changes
429 .iter()
430 .zip(&self.targets)
431 .map(|(change, target)| change.canonical_value(target.identity()))
432 .collect::<Vec<_>>()
433 }),
434 EditCommand::Undo { undo_id, .. } => json!({
435 "operation": "undo",
436 "files": [{
437 "file_path": self.targets.first().map(CanonicalEditTarget::identity).unwrap_or_default(),
438 "mode": "undo",
439 "undo_id": undo_id
440 }]
441 }),
442 };
443 json!({
444 "schema": "EditFiles/v1",
445 "thread_id": self.thread_id,
446 "workspace_root": self.canonical_workspace_root,
447 "command": command
448 })
449 }
450
451 pub fn audit_summary(&self) -> String {
452 format!(
453 "operation={} files={} modes={} verify={}",
454 self.command.operation().as_str(),
455 self.targets.len(),
456 self.command.modes().iter().map(|mode| mode.as_str()).collect::<Vec<_>>().join(","),
457 self.verification.is_some()
458 )
459 }
460
461 pub(crate) fn targets(&self) -> &[CanonicalEditTarget] {
462 &self.targets
463 }
464
465 pub(crate) fn target_paths(&self) -> Vec<String> {
466 self.targets.iter().map(|target| target.identity().to_string()).collect()
467 }
468}
469
470impl EditOperation {
471 pub const fn as_str(self) -> &'static str {
472 match self {
473 Self::Apply => "apply",
474 Self::Undo => "undo",
475 }
476 }
477}
478
479impl EditMode {
480 pub const fn as_str(self) -> &'static str {
481 match self {
482 Self::Replace => "replace",
483 Self::SearchReplace => "search_replace",
484 Self::LinePatch => "line_patch",
485 Self::Undo => "undo",
486 }
487 }
488}
489
490pub(super) fn validate_aggregate(changes: &[EditChange]) -> Result<()> {
491 let total_patches = changes
492 .iter()
493 .map(|change| match change {
494 EditChange::LinePatch { patches, .. } => patches.len(),
495 _ => 0,
496 })
497 .sum::<usize>();
498 if total_patches > MAX_TOTAL_PATCHES {
499 return Err(WinxError::InvalidInput(format!(
500 "EditFiles is limited to {MAX_TOTAL_PATCHES} total line patches per call"
501 )));
502 }
503 let total_content = changes
504 .iter()
505 .map(|change| match change {
506 EditChange::Replace { content, .. } | EditChange::SearchReplace { content, .. } => {
507 content.len()
508 }
509 EditChange::LinePatch { patches, .. } => {
510 patches.iter().map(|patch| patch.replacement.len()).sum()
511 }
512 })
513 .sum::<usize>();
514 if total_content > MAX_AGGREGATE_CONTENT_BYTES {
515 return Err(WinxError::InvalidInput(format!(
516 "EditFiles payload content exceeds the {MAX_AGGREGATE_CONTENT_BYTES}-byte aggregate limit"
517 )));
518 }
519 Ok(())
520}