Compare commits

...
1 Commits
Author SHA1 Message Date
Hunter Bown 28673c94e3 fix: write silently restores CRLF despite an explicit request to save with LF (#7070)
Produced by CodeWhale running in a Boat VM. Untested.
2026-10-24 22:29:53 -07:00
4 changed files with 135 additions and 17 deletions

No files matched your search

+57 -15
View File
@@ -1688,9 +1688,10 @@ impl WriteFileTool {
input: Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
reject_primitive_unknown(&input, "write", &["path", "content"])?;
reject_primitive_unknown(&input, "write", &["path", "content", "line_endings"])?;
let path_str = required_str(&input, "path")?;
let file_content = required_str(&input, "content")?;
let line_endings = contract_line_endings_override(&input)?;
check_contract_file_size(file_content.len())?;
let file_path = context.resolve_path(path_str)?;
let mutation_guard = acquire_file_mutation(&file_path, context).await?;
@@ -1715,20 +1716,9 @@ impl WriteFileTool {
check_file_operation_cancelled(context)?;
// Preserve the existing file's line-ending style on overwrite (see
// `preserve_prior_line_endings`); otherwise a CRLF (Windows) file is
// silently rewritten with LF line endings.
let mut written = if prior_contents.is_empty() {
file_content.to_string()
} else {
let normalized = normalize_contract_line_endings(file_content);
let ending = contract_line_ending(&prior_contents);
let extra = if ending == "\r\n" {
normalized.bytes().filter(|byte| *byte == b'\n').count()
} else {
0
};
check_contract_file_size(normalized.len().saturating_add(extra))?;
restore_contract_line_endings(&normalized, ending)
};
// silently rewritten with LF line endings. An explicit `line_endings`
// request ("lf" or "crlf") overrides the preserved style.
let mut written = contract_write_content(file_content, &prior_contents, line_endings)?;
guard_edit(
&file_path,
path_str,
@@ -1966,6 +1956,58 @@ fn restore_contract_line_endings(text: &str, ending: &str) -> String {
}
}
/// Parse the optional `line_endings` override on the contract `write` tool.
///
/// `Ok(None)` means "preserve the existing file's style" (also what an absent
/// parameter or the literal `"preserve"` mean); `"lf"` and `"crlf"` force a
/// specific style.
fn contract_line_endings_override(input: &Value) -> Result<Option<&'static str>, ToolError> {
match input.get("line_endings") {
None => Ok(None),
Some(Value::String(value)) => match value.as_str() {
"preserve" => Ok(None),
"lf" => Ok(Some("lf")),
"crlf" => Ok(Some("crlf")),
other => Err(ToolError::invalid_input(format!(
"line_endings must be one of \"preserve\", \"lf\", or \"crlf\"; got \"{other}\""
))),
},
Some(_) => Err(ToolError::invalid_input(
"line_endings must be a string: \"preserve\", \"lf\", or \"crlf\"",
)),
}
}
/// Build the text the contract `write` tool persists for `content`.
///
/// Without an override the existing file's line-ending style is preserved (a
/// new file is written verbatim); with `"lf"` or `"crlf"` the content is
/// normalized to exactly that style regardless of the prior file.
fn contract_write_content(
content: &str,
prior: &str,
override_ending: Option<&str>,
) -> Result<String, ToolError> {
let ending = match override_ending {
Some("lf") => "\n",
Some("crlf") => "\r\n",
_ => {
if prior.is_empty() {
return Ok(content.to_string());
}
contract_line_ending(prior)
}
};
let normalized = normalize_contract_line_endings(content);
let extra = if ending == "\r\n" {
normalized.bytes().filter(|byte| *byte == b'\n').count()
} else {
0
};
check_contract_file_size(normalized.len().saturating_add(extra))?;
Ok(restore_contract_line_endings(&normalized, ending))
}
/// First code point of the placeholder range that carries one non-UTF-8 byte
/// through a text edit (Supplementary Private Use Area-A, U+F0000..=U+F00FF).
const RAW_BYTE_PLACEHOLDER_BASE: u32 = 0xF_0000;
+71
View File
@@ -830,6 +830,77 @@ async fn contract_write_over_crlf_file_preserves_crlf_line_endings() {
);
}
#[tokio::test]
async fn contract_write_lf_override_over_crlf_file_writes_lf() {
// An explicit `line_endings: "lf"` must win over the preserved CRLF style,
// so a user asking for Unix endings is not silently given CRLF (#7070).
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let path = tmp.path().join("crlf.txt");
fs::write(&path, b"alpha\r\nbeta\r\n").expect("initial CRLF write");
ctx.note_file_read(&ctx.resolve_path("crlf.txt").expect("resolve"));
WriteFileTool::execute_contract_write(
json!({"path": "crlf.txt", "content": "gamma\ndelta\n", "line_endings": "lf"}),
&ctx,
)
.await
.expect("execute");
let written = fs::read(&path).expect("read");
assert_eq!(
written, b"gamma\ndelta\n",
"explicit lf override must not restore the prior CRLF style"
);
}
#[tokio::test]
async fn contract_write_crlf_override_over_lf_file_writes_crlf() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let path = tmp.path().join("lf.txt");
fs::write(&path, b"alpha\nbeta\n").expect("initial LF write");
ctx.note_file_read(&ctx.resolve_path("lf.txt").expect("resolve"));
WriteFileTool::execute_contract_write(
json!({"path": "lf.txt", "content": "gamma\ndelta\n", "line_endings": "crlf"}),
&ctx,
)
.await
.expect("execute");
let written = fs::read(&path).expect("read");
assert_eq!(
written, b"gamma\r\ndelta\r\n",
"explicit crlf override must normalize LF content to CRLF"
);
}
#[tokio::test]
async fn contract_write_rejects_unknown_line_endings_value() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let path = tmp.path().join("crlf.txt");
fs::write(&path, b"alpha\r\nbeta\r\n").expect("initial CRLF write");
ctx.note_file_read(&ctx.resolve_path("crlf.txt").expect("resolve"));
let error = WriteFileTool::execute_contract_write(
json!({"path": "crlf.txt", "content": "gamma\ndelta\n", "line_endings": "mixed"}),
&ctx,
)
.await
.expect_err("invalid line_endings must be refused");
assert!(
error.to_string().contains("line_endings"),
"refusal must name the bad parameter: {error}"
);
assert_eq!(
fs::read(&path).expect("read"),
b"alpha\r\nbeta\r\n",
"a refused write must leave the file unchanged"
);
}
#[test]
fn preserve_prior_line_endings_keeps_the_prior_style() {
// Existing CRLF file: incoming LF content is re-emitted as CRLF.
+6 -1
View File
@@ -133,7 +133,12 @@ impl ToolSpec for WriteTool {
"type": "string",
"description": "Path to the file to write (relative or absolute)."
},
"content": { "type": "string", "description": "Content to write to the file." }
"content": { "type": "string", "description": "Content to write to the file." },
"line_endings": {
"type": "string",
"enum": ["preserve", "lf", "crlf"],
"description": "Line endings for the written file. `preserve` (default) keeps the existing file's style; `lf` forces Unix LF endings; `crlf` forces Windows CRLF endings."
}
},
"required": ["path", "content"],
"additionalProperties": false
+1 -1
View File
@@ -567,7 +567,7 @@ fn primitive_schemas_are_separate_and_small_contract_shaped() {
.keys()
.cloned()
.collect::<std::collections::BTreeSet<_>>(),
["content", "path"]
["content", "line_endings", "path"]
.into_iter()
.map(str::to_string)
.collect()