Skip to main content

vtcode_core/tools/
apply_patch.rs

1//! Patch tool facade that exposes Codex-compatible patch parsing and application.
2//!
3//! Actual patch parsing logic lives in `tools::editing::patch` so future edit
4//! features can reuse the same primitives without depending on this facade.
5
6use anyhow::Context;
7use base64::Engine;
8use base64::engine::general_purpose::STANDARD as BASE64;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::path::PathBuf;
12
13pub use crate::tools::editing::{Patch, PatchError, PatchHunk, PatchLine, PatchOperation};
14pub use vtcode_utility_tool_specs::{
15    APPLY_PATCH_ALIAS_DESCRIPTION, APPLY_PATCH_TOOL_DESCRIPTION, DEFAULT_APPLY_PATCH_INPUT_DESCRIPTION,
16    SEMANTIC_ANCHOR_GUIDANCE, with_semantic_anchor_guidance,
17};
18
19/// Input structure for the apply_patch tool
20#[derive(Debug, Deserialize, Serialize)]
21pub struct ApplyPatchInput {
22    #[serde(alias = "patch")]
23    pub input: String,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct DecodedApplyPatchInput {
28    pub text: String,
29    pub source_bytes: usize,
30    pub was_base64: bool,
31}
32
33pub const APPLY_PATCH_ARGUMENT_CORRECTION: &str = r#"Missing or invalid apply_patch input. Call the apply_patch tool directly with JSON such as {"input":"*** Begin Patch\n*** Update File: src/example.rs\n@@\n-old\n+new\n*** End Patch\n"}. Copy exact current context, correct the payload, and retry once; do not run apply_patch in a shell."#;
34
35pub fn patch_source_from_args(args: &Value) -> Option<&str> {
36    let input = args.get("input").and_then(|value| value.as_str());
37    let patch = args.get("patch").and_then(|value| value.as_str());
38
39    // When only one alias field is present, use it (historical behavior).
40    // When both are present, prefer the field whose content actually looks
41    // like a VT Code patch. This prevents a non-patch `input` (e.g. raw file
42    // contents emitted by some providers — see checkpoint turn_615) from
43    // masking a valid `patch` field. Falls back to `input`-first precedence
44    // only if neither field is patch-shaped.
45    match (input, patch) {
46        (Some(i), Some(p)) => {
47            if crate::tools::editing::looks_like_vte_patch(p) && !crate::tools::editing::looks_like_vte_patch(i) {
48                Some(p)
49            } else {
50                Some(i)
51            }
52        }
53        (Some(i), None) => Some(i),
54        (None, Some(p)) => Some(p),
55        (None, None) => args.as_str(),
56    }
57}
58
59pub fn decode_apply_patch_input(args: &Value) -> anyhow::Result<Option<DecodedApplyPatchInput>> {
60    let Some(source) = patch_source_from_args(args) else {
61        return Err(anyhow::anyhow!(APPLY_PATCH_ARGUMENT_CORRECTION));
62    };
63
64    let was_base64 = source.starts_with("base64:");
65    let cap = effective_max_payload_bytes();
66    let text = if was_base64 {
67        let decoded = BASE64
68            .decode(&source[7..])
69            .with_context(|| format!("Failed to decode base64 patch. {APPLY_PATCH_ARGUMENT_CORRECTION}"))?;
70        enforce_decoded_size_limit(decoded.len(), source.len(), was_base64, cap)?;
71        String::from_utf8(decoded)
72            .with_context(|| format!("Decoded patch is not valid UTF-8. {APPLY_PATCH_ARGUMENT_CORRECTION}"))?
73    } else {
74        enforce_decoded_size_limit(source.len(), source.len(), was_base64, cap)?;
75        source.to_string()
76    };
77
78    Ok(Some(DecodedApplyPatchInput { text, source_bytes: source.len(), was_base64 }))
79}
80
81/// Extract filesystem paths affected by a mutating tool call.
82///
83/// Public `apply_patch` payloads are decoded and parsed through the same patch
84/// grammar used for execution. Generic path-bearing mutation tools retain the
85/// established singular and batch argument conventions. Tools whose payloads
86/// do not expose paths return an empty vector and cannot invalidate a scoped
87/// replay.
88pub fn mutation_target_paths(tool_name: &str, args: &Value) -> Vec<PathBuf> {
89    if crate::tools::names::canonical_tool_name(tool_name) == crate::config::constants::tools::APPLY_PATCH {
90        return decode_apply_patch_input(args)
91            .ok()
92            .flatten()
93            .and_then(|decoded| Patch::parse(&decoded.text).ok())
94            .map(|patch| patch_mutation_target_paths(&patch))
95            .unwrap_or_default();
96    }
97
98    const SINGULAR_KEYS: [&str; 7] = [
99        "path",
100        "file_path",
101        "filepath",
102        "target_path",
103        "destination",
104        "destination_path",
105        "file",
106    ];
107    let Some(obj) = args.as_object() else {
108        return Vec::new();
109    };
110    let mut paths = SINGULAR_KEYS
111        .iter()
112        .filter_map(|key| obj.get(*key).and_then(Value::as_str))
113        .map(str::trim)
114        .filter(|path| !path.is_empty())
115        .map(PathBuf::from)
116        .collect::<Vec<_>>();
117    for key in ["items", "paths", "files"] {
118        let Some(items) = obj.get(key).and_then(Value::as_array) else {
119            continue;
120        };
121        for item in items {
122            if let Some(path) = item.as_str().map(str::trim).filter(|path| !path.is_empty()) {
123                paths.push(PathBuf::from(path));
124            } else if let Some(item) = item.as_object() {
125                paths.extend(SINGULAR_KEYS.iter().filter_map(|key| {
126                    item.get(*key)
127                        .and_then(Value::as_str)
128                        .map(str::trim)
129                        .filter(|path| !path.is_empty())
130                        .map(PathBuf::from)
131                }));
132            }
133        }
134    }
135    paths
136}
137
138/// Return every path mutated by an already-parsed patch.
139///
140/// Move operations include both their source and destination. The result is
141/// sorted and deduplicated so execution metadata and replay invalidation share
142/// one stable path set without parsing the patch payload a second time.
143pub fn patch_mutation_target_paths(patch: &Patch) -> Vec<PathBuf> {
144    let mut paths = patch
145        .operations()
146        .iter()
147        .flat_map(|operation| match operation {
148            PatchOperation::AddFile { path, .. } | PatchOperation::DeleteFile { path } => {
149                vec![PathBuf::from(path)]
150            }
151            PatchOperation::UpdateFile { path, new_path, .. } => {
152                let mut paths = vec![PathBuf::from(path)];
153                paths.extend(new_path.as_deref().map(PathBuf::from));
154                paths
155            }
156        })
157        .collect::<Vec<_>>();
158    paths.sort();
159    paths.dedup();
160    paths
161}
162
163fn enforce_decoded_size_limit(
164    decoded_bytes: usize,
165    source_bytes: usize,
166    was_base64: bool,
167    cap: usize,
168) -> anyhow::Result<()> {
169    // Cap applies to the *decoded* size to prevent base64 decompression-bomb-style attacks
170    // where a small source expands to a very large patch.
171    if decoded_bytes <= cap {
172        return Ok(());
173    }
174    anyhow::bail!(
175        "apply_patch payload too large after decoding: decoded={decoded_bytes} bytes (source={source_bytes} bytes, base64={was_base64}). \
176         The per-patch cap is {cap} bytes; split the change into smaller patches or raise {UNIFIED_FILE_MAX_PAYLOAD_BYTES_ENV}."
177    )
178}
179
180/// Hard upper bound for any single `apply_patch` payload after base64 decoding.
181/// The preflight cap mirrors this same value; both share the same env-var override.
182pub const UNIFIED_FILE_MAX_PAYLOAD_BYTES: usize = 1024 * 1024;
183
184/// Maximum allowed decoded patch size — same as `UNIFIED_FILE_MAX_PAYLOAD_BYTES` but
185/// exposed with a more specific name to clarify that it is enforced at decode time,
186/// not preflight time.
187pub const MAX_DECODED_PATCH_BYTES: usize = UNIFIED_FILE_MAX_PAYLOAD_BYTES;
188
189/// Env var name that overrides both the preflight cap and the post-decode cap.
190pub const UNIFIED_FILE_MAX_PAYLOAD_BYTES_ENV: &str = "VTCODE_UNIFIED_FILE_MAX_PAYLOAD_BYTES";
191
192/// Resolve the effective cap, honoring the env-var override. A 1 KiB safety
193/// floor is enforced so a sub-floor override can never silently disable the
194/// post-decode cap; values below the floor fall back to the default. The same
195/// floor is applied by the preflight resolver in `execution_kernel` so both
196/// stages agree on the effective cap.
197pub fn effective_max_payload_bytes() -> usize {
198    std::env::var(UNIFIED_FILE_MAX_PAYLOAD_BYTES_ENV)
199        .ok()
200        .and_then(|v| v.trim().parse::<usize>().ok())
201        .filter(|value| *value >= 1024)
202        .unwrap_or(UNIFIED_FILE_MAX_PAYLOAD_BYTES)
203}
204
205pub fn parameter_schema(input_description: &str) -> Value {
206    vtcode_utility_tool_specs::apply_patch_parameter_schema(input_description)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::{
212        APPLY_PATCH_ALIAS_DESCRIPTION, SEMANTIC_ANCHOR_GUIDANCE, decode_apply_patch_input, mutation_target_paths,
213        parameter_schema, patch_mutation_target_paths, patch_source_from_args, with_semantic_anchor_guidance,
214    };
215    use serde_json::json;
216    use std::path::PathBuf;
217
218    #[test]
219    fn patch_source_accepts_raw_string_and_object_fields() {
220        assert_eq!(
221            patch_source_from_args(&json!("*** Begin Patch\n*** End Patch\n")),
222            Some("*** Begin Patch\n*** End Patch\n")
223        );
224        assert_eq!(patch_source_from_args(&json!({"input": "x"})), Some("x"));
225        assert_eq!(patch_source_from_args(&json!({"patch": "y"})), Some("y"));
226    }
227
228    #[test]
229    fn patch_source_prefers_patch_shaped_field_when_both_present() {
230        // When both `input` and `patch` are present, prefer the one whose
231        // content actually looks like a VTE patch. This prevents a non-patch
232        // `input` (raw file contents) from masking a valid `patch` field.
233        let raw_source = "pub fn foo() { println!(\"hi\"); }\n";
234        let vte_patch = "*** Begin Patch\n*** Update File: f.rs\n@@\n-old\n+new\n*** End Patch";
235
236        // `input` = raw source, `patch` = real patch → prefer `patch`.
237        let args = json!({ "input": raw_source, "patch": vte_patch });
238        assert_eq!(patch_source_from_args(&args), Some(vte_patch));
239
240        // `input` = real patch, `patch` = raw source → prefer `input`.
241        let args = json!({ "input": vte_patch, "patch": raw_source });
242        assert_eq!(patch_source_from_args(&args), Some(vte_patch));
243
244        // Neither is patch-shaped → fall back to `input`-first (historical).
245        let args = json!({ "input": "aaa", "patch": "bbb" });
246        assert_eq!(patch_source_from_args(&args), Some("aaa"));
247    }
248
249    #[test]
250    fn decode_apply_patch_input_supports_base64_payloads() {
251        let payload = json!({
252            "patch": "base64:KioqIEJlZ2luIFBhdGNoCioqKiBFbmQgUGF0Y2gK"
253        });
254
255        let decoded = decode_apply_patch_input(&payload)
256            .expect("payload should decode")
257            .expect("payload should be present");
258
259        assert_eq!(decoded.text, "*** Begin Patch\n*** End Patch\n");
260        assert_eq!(decoded.source_bytes, 47);
261        assert!(decoded.was_base64);
262    }
263
264    #[test]
265    fn mutation_paths_cover_all_public_patch_operations_and_alias_shapes() {
266        use base64::Engine;
267        use base64::engine::general_purpose::STANDARD as BASE64;
268
269        let patch = "*** Begin Patch\n*** Add File: src/add.rs\n+new\n*** Delete File: src/delete.rs\n*** Update File: src/old.rs\n*** Move to: src/new.rs\n@@\n-old\n+new\n*** End Patch\n";
270        let expected = vec![
271            PathBuf::from("src/add.rs"),
272            PathBuf::from("src/delete.rs"),
273            PathBuf::from("src/new.rs"),
274            PathBuf::from("src/old.rs"),
275        ];
276
277        assert_eq!(mutation_target_paths("apply_patch", &json!(patch)), expected);
278        assert_eq!(mutation_target_paths("apply_patch", &json!({"input": patch})), expected);
279        assert_eq!(
280            mutation_target_paths("apply_patch", &json!({"patch": format!("base64:{}", BASE64.encode(patch))}),),
281            expected
282        );
283
284        let duplicate_patch = super::Patch::parse(
285            "*** Begin Patch\n*** Update File: src/same.rs\n*** Move to: src/same.rs\n@@\n-old\n+new\n*** End Patch\n",
286        )
287        .expect("duplicate-path patch should parse");
288        assert_eq!(patch_mutation_target_paths(&duplicate_patch), vec![PathBuf::from("src/same.rs")]);
289    }
290
291    #[test]
292    fn mutation_paths_include_move_and_copy_destinations() {
293        assert_eq!(
294            mutation_target_paths("move_file", &json!({"path": "src/old.rs", "destination": "src/new.rs"}),),
295            vec![PathBuf::from("src/old.rs"), PathBuf::from("src/new.rs")]
296        );
297        assert_eq!(
298            mutation_target_paths(
299                "copy_file",
300                &json!({
301                    "file_path": "src/original.rs",
302                    "destination_path": "src/copy.rs"
303                }),
304            ),
305            vec![PathBuf::from("src/original.rs"), PathBuf::from("src/copy.rs")]
306        );
307    }
308
309    #[test]
310    fn decode_apply_patch_input_rejects_invalid_base64() {
311        let error =
312            decode_apply_patch_input(&json!({"input": "base64:not-valid"})).expect_err("invalid base64 should fail");
313
314        assert!(error.to_string().contains("Failed to decode base64 patch"));
315    }
316
317    #[test]
318    fn decode_apply_patch_input_caps_decoded_size() {
319        use base64::Engine;
320        // Build a 1.5 MiB decoded payload via base64. The default cap is 1 MiB so the
321        // decode must fail with the post-decode size error rather than producing the
322        // oversized text.
323        let large = "A".repeat(1_500_000);
324        let encoded = base64::engine::general_purpose::STANDARD.encode(large.as_bytes());
325        let payload = json!({ "input": format!("base64:{encoded}") });
326
327        let error = decode_apply_patch_input(&payload).expect_err("oversized decoded payload should be rejected");
328        let message = error.to_string();
329        assert!(
330            message.contains("apply_patch payload too large after decoding"),
331            "unexpected error message: {message}"
332        );
333        assert!(message.contains("base64=true"));
334    }
335
336    #[test]
337    fn decode_apply_patch_input_caps_raw_payload_size() {
338        // Even non-base64 inputs must respect the cap. Build a 1.5 MiB raw string
339        // and confirm it is rejected.
340        let large = "B".repeat(1_500_000);
341        let payload = json!({ "input": large });
342
343        let error = decode_apply_patch_input(&payload).expect_err("oversized raw payload should be rejected");
344        let message = error.to_string();
345        assert!(
346            message.contains("apply_patch payload too large after decoding"),
347            "unexpected error message: {message}"
348        );
349        assert!(message.contains("base64=false"));
350    }
351
352    #[test]
353    fn semantic_anchor_guidance_is_appended_once() {
354        let base = "Patch in VT Code format.";
355        let with_guidance = with_semantic_anchor_guidance(base);
356        assert!(with_guidance.contains(SEMANTIC_ANCHOR_GUIDANCE));
357        assert_eq!(with_semantic_anchor_guidance(&with_guidance), with_guidance, "guidance should not be duplicated");
358    }
359
360    #[test]
361    fn parameter_schema_keeps_alias_and_guidance_consistent() {
362        let schema = parameter_schema("Patch in VT Code format");
363
364        // Both `input` and `patch` are alias fields for the same payload, so
365        // both must carry the format description AND the semantic-anchor
366        // guidance. This prevents the model from placing a unified diff in
367        // `patch` (see checkpoint turn_615).
368        assert_eq!(
369            schema["properties"]["patch"]["description"],
370            with_semantic_anchor_guidance(APPLY_PATCH_ALIAS_DESCRIPTION)
371        );
372        let patch_description = schema["properties"]["patch"]["description"]
373            .as_str()
374            .expect("patch description");
375        assert!(patch_description.contains("*** Begin Patch"), "patch description must name the envelope format");
376        assert!(patch_description.contains("unified diff"), "patch description must warn against unified diff");
377        assert!(patch_description.contains(SEMANTIC_ANCHOR_GUIDANCE));
378
379        let input_description = schema["properties"]["input"]["description"]
380            .as_str()
381            .expect("input description");
382        assert!(input_description.contains(SEMANTIC_ANCHOR_GUIDANCE));
383    }
384}