1use std::fs::{self, OpenOptions, Permissions};
33use std::io::{Read, Write};
34use std::path::{Path, PathBuf};
35
36use serde_json::{json, Value};
37use sha2::{Digest, Sha256};
38
39use crate::formal_ai_policy::{parent_of, workspace_path, PolicyContext, Verification};
40
41pub const FILE_TOOL_NAMES: [&str; 3] = ["read", "edit", "write"];
42
43fn path_schema() -> Value {
44 json!({ "type": "string", "description": "File path inside the working directory" })
45}
46
47pub fn file_tool(name: &str) -> Value {
49 let (description, properties, required) = match name {
50 "read" => (
51 "Read the exact text of one file in the working directory.",
52 json!({ "path": path_schema() }),
53 json!(["path"]),
54 ),
55 "edit" => (
56 "Replace the literal text old_string with new_string in one file read completely with read in this task, unchanged since; old_string must occur exactly once unless replace_all is true. Nothing else in the file changes.",
57 json!({
58 "path": path_schema(),
59 "old_string": { "type": "string" },
60 "new_string": { "type": "string" },
61 "replace_all": { "type": "boolean" },
62 }),
63 json!(["path", "old_string", "new_string"]),
64 ),
65 _ => (
66 "Write the whole text of one file: a file read completely with read in this task, unchanged since, or a new file in a directory listed before.",
67 json!({ "path": path_schema(), "content": { "type": "string" } }),
68 json!(["path", "content"]),
69 ),
70 };
71 json!({
72 "type": "function",
73 "function": {
74 "name": name,
75 "description": description,
76 "parameters": { "type": "object", "properties": properties, "required": required },
77 },
78 })
79}
80
81#[derive(Debug, Clone, PartialEq, Default)]
83pub struct FileArgs {
84 pub path: String,
85 pub old_string: Option<String>,
86 pub new_string: Option<String>,
87 pub replace_all: bool,
88 pub content: Option<String>,
89}
90
91const PATH: &[&str] = &["path", "filePath", "file_path"];
93const OLD: &[&str] = &["old_string", "oldString", "old_str", "old"];
94const NEW: &[&str] = &["new_string", "newString", "new_str", "new"];
95const REPLACE_ALL: &[&str] = &["replace_all", "replaceAll"];
96const CONTENT: &[&str] = &["content"];
97
98fn fields(tool: &str) -> &'static [&'static [&'static str]] {
99 match tool {
100 "read" => &[PATH],
101 "edit" => &[PATH, OLD, NEW, REPLACE_ALL],
102 _ => &[PATH, CONTENT],
103 }
104}
105
106fn required(tool: &str) -> &'static [&'static [&'static str]] {
107 match tool {
108 "read" => &[PATH],
109 "edit" => &[PATH, OLD, NEW],
110 _ => &[PATH, CONTENT],
111 }
112}
113
114pub fn normalize_file_args(tool: &str, args: &Value) -> Result<FileArgs, String> {
116 let Some(object) = args.as_object() else {
117 return Err(format!("{} arguments are not a JSON object", tool));
118 };
119 let mut found: Vec<(&str, Value)> = Vec::new();
120 for aliases in fields(tool) {
121 let boolean = *aliases == REPLACE_ALL;
122 let kind = if boolean { "boolean" } else { "string" };
123 let values: Vec<&Value> = aliases.iter().filter_map(|key| object.get(*key)).collect();
124 if values
125 .iter()
126 .any(|value| (boolean && !value.is_boolean()) || (!boolean && !value.is_string()))
127 {
128 return Err(format!(
129 "{} argument {} must be a {}",
130 tool, aliases[0], kind
131 ));
132 }
133 if values.iter().any(|value| *value != values[0]) {
134 return Err(format!(
135 "{} received conflicting {} values",
136 tool, aliases[0]
137 ));
138 }
139 if let Some(value) = values.first() {
140 found.push((aliases[0], (*value).clone()));
141 }
142 }
143 let missing: Vec<&str> = required(tool)
144 .iter()
145 .map(|aliases| aliases[0])
146 .filter(|name| !found.iter().any(|(key, _)| key == name))
147 .collect();
148 if !missing.is_empty() {
149 return Err(format!("{} needs {}", tool, missing.join(", ")));
150 }
151 let text = |name: &str| {
152 found
153 .iter()
154 .find(|(key, _)| *key == name)
155 .and_then(|(_, value)| value.as_str())
156 .map(str::to_string)
157 };
158 let result = FileArgs {
159 path: text("path").unwrap_or_default(),
160 old_string: text("old_string"),
161 new_string: text("new_string"),
162 replace_all: found
163 .iter()
164 .find(|(key, _)| *key == "replace_all")
165 .and_then(|(_, value)| value.as_bool())
166 .unwrap_or(false),
167 content: text("content"),
168 };
169 if result.path.trim().is_empty() || result.path.contains('\0') {
170 return Err(format!("{} needs a non-empty path", tool));
171 }
172 Ok(result)
173}
174
175pub fn describe_file_call(tool: &str, args: &FileArgs) -> String {
177 format!("{} {}", tool, args.path)
178}
179
180#[derive(Debug, Clone, PartialEq, Default)]
182pub struct FileDecision {
183 pub allowed: bool,
184 pub reason: Option<String>,
185 pub kind: Option<&'static str>,
187 pub target: Option<String>,
188}
189
190fn refuse(reason: String) -> FileDecision {
191 FileDecision {
192 allowed: false,
193 reason: Some(reason),
194 ..FileDecision::default()
195 }
196}
197
198fn root_of(cwd: &Path) -> PathBuf {
199 fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())
200}
201
202pub fn evaluate_file_call(tool: &str, args: &FileArgs, context: &PolicyContext) -> FileDecision {
204 let kind = if tool == "read" { "read" } else { "write" };
205 if kind == "write" && context.effects != "workspace-write" {
206 return refuse(format!(
207 "\"{}\" changes files; the effect mode is read-only (set START_FORMAL_AI_EFFECTS=workspace-write to allow workspace changes)",
208 tool
209 ));
210 }
211 let outside = || {
212 refuse(format!(
213 "\"{}\" is outside the working directory",
214 args.path
215 ))
216 };
217 if args.path.starts_with('~') {
218 return outside();
219 }
220 let Some(target) = workspace_path(&context.cwd, &args.path) else {
221 return outside();
222 };
223 if target == "." {
224 return refuse(format!(
225 "\"{}\" is the working directory, not a file",
226 args.path
227 ));
228 }
229 if kind == "write" {
230 let exists = root_of(&context.cwd).join(&target).exists();
231 let observed = context.observed.contains(&target)
232 || (tool == "write" && !exists && context.observed_dirs.contains(&parent_of(&target)));
233 if !observed {
234 return refuse(format!(
235 "target \"{}\" was not {} before the change",
236 target,
237 if tool == "edit" { "read" } else { "observed" }
238 ));
239 }
240 }
241 FileDecision {
242 allowed: true,
243 reason: None,
244 kind: Some(kind),
245 target: Some(target),
246 }
247}
248
249pub fn sha256_hex(data: &[u8]) -> String {
251 Sha256::digest(data)
252 .iter()
253 .map(|byte| format!("{:02x}", byte))
254 .collect()
255}
256
257struct FileText {
258 text: String,
259 bytes: Vec<u8>,
260 permissions: Permissions,
261}
262
263const LINK_APPEARED: &str =
264 "is a symbolic link that appeared after start resolved the path; nothing changed";
265
266fn lstat_or_none(file: &Path) -> Result<Option<fs::Metadata>, String> {
268 match fs::symlink_metadata(file) {
269 Ok(meta) => Ok(Some(meta)),
270 Err(error) if missing(&error) => Ok(None),
271 Err(error) => Err(format!("cannot inspect the file ({})", error)),
272 }
273}
274
275fn missing(error: &std::io::Error) -> bool {
276 #[cfg(unix)]
277 if error.raw_os_error() == Some(libc::ENOTDIR) {
278 return true;
279 }
280 error.kind() == std::io::ErrorKind::NotFound
281}
282
283fn open_no_follow(file: &Path) -> std::io::Result<fs::File> {
286 let mut options = OpenOptions::new();
287 options.read(true);
288 #[cfg(unix)]
289 {
290 use std::os::unix::fs::OpenOptionsExt;
291 options.custom_flags(libc::O_NOFOLLOW);
292 }
293 options.open(file)
294}
295
296fn read_bytes(file: &Path, max_file_bytes: u64) -> Result<(Vec<u8>, Permissions), String> {
299 match lstat_or_none(file)? {
300 Some(meta) if meta.file_type().is_symlink() => return Err(LINK_APPEARED.to_string()),
301 Some(meta) if meta.is_dir() => return Err("is a directory, not a file".to_string()),
304 _ => {}
305 }
306 let mut handle = open_no_follow(file).map_err(|error| {
307 #[cfg(unix)]
308 if error.raw_os_error() == Some(libc::ELOOP) {
309 return LINK_APPEARED.to_string();
310 }
311 if error.kind() == std::io::ErrorKind::NotFound {
312 "no such file".to_string()
313 } else {
314 format!("cannot read the file ({})", error)
315 }
316 })?;
317 let meta = handle
318 .metadata()
319 .map_err(|error| format!("cannot read the file ({})", error))?;
320 if meta.is_dir() {
321 return Err("is a directory, not a file".to_string());
322 }
323 if !meta.is_file() {
324 return Err("is not a regular file".to_string());
325 }
326 let too_large = |size: u64| {
327 format!(
328 "has {} bytes; START_FORMAL_AI_MAX_FILE_BYTES is {}",
329 size, max_file_bytes
330 )
331 };
332 if meta.len() > max_file_bytes {
333 return Err(too_large(meta.len()));
334 }
335 let mut bytes = Vec::new();
336 (&mut handle)
338 .take(max_file_bytes + 1)
339 .read_to_end(&mut bytes)
340 .map_err(|error| format!("cannot read the file ({})", error))?;
341 if bytes.len() as u64 > max_file_bytes {
342 return Err(too_large(bytes.len() as u64));
343 }
344 Ok((bytes, meta.permissions()))
345}
346
347fn read_text(file: &Path, max_file_bytes: u64) -> Result<FileText, String> {
349 let (bytes, permissions) = read_bytes(file, max_file_bytes)?;
350 match std::str::from_utf8(&bytes) {
351 Ok(text) if !text.contains('\0') => Ok(FileText {
352 text: text.to_string(),
353 bytes: bytes.clone(),
354 permissions,
355 }),
356 _ => Err("is not UTF-8 text".to_string()),
357 }
358}
359
360enum WriteError {
362 Tool(String),
363 Io(std::io::Error),
364}
365
366impl WriteError {
367 fn message(&self) -> String {
368 match self {
369 WriteError::Tool(message) => message.clone(),
370 WriteError::Io(error) => format!("failed ({})", error),
371 }
372 }
373}
374
375impl From<std::io::Error> for WriteError {
376 fn from(error: std::io::Error) -> Self {
377 WriteError::Io(error)
378 }
379}
380
381fn write_atomically(
386 file: &Path,
387 content: &[u8],
388 permissions: Option<Permissions>,
389 guard: &dyn Fn() -> Result<(), String>,
390 create: bool,
391) -> Result<(), WriteError> {
392 let name = file
393 .file_name()
394 .map(|n| n.to_string_lossy().to_string())
395 .unwrap_or_default();
396 let temporary = file.with_file_name(format!(
397 ".{}.{}.{}.start-tmp",
398 name,
399 std::process::id(),
400 uuid::Uuid::new_v4()
401 ));
402 let result = (|| {
403 let mut handle = OpenOptions::new()
404 .write(true)
405 .create_new(true)
406 .open(&temporary)?;
407 handle.write_all(content)?;
408 if let Some(permissions) = permissions {
409 fs::set_permissions(&temporary, permissions)?;
410 }
411 handle.sync_all()?;
412 drop(handle);
413 guard().map_err(WriteError::Tool)?;
414 if create {
415 fs::hard_link(&temporary, file).map_err(|error| {
416 if error.kind() == std::io::ErrorKind::AlreadyExists {
417 WriteError::Tool(
418 "was created while start was writing it; nothing changed".to_string(),
419 )
420 } else {
421 WriteError::Io(error)
422 }
423 })
424 } else {
425 fs::rename(&temporary, file).map_err(WriteError::Io)
426 }
427 })();
428 let _ = fs::remove_file(&temporary);
429 result
430}
431
432#[derive(Debug, Clone, Copy, PartialEq, Eq)]
434pub struct Match {
435 pub offset: usize,
436 pub old_bytes: usize,
437 pub new_bytes: usize,
438}
439
440impl Match {
441 fn to_json(self) -> Value {
442 json!({ "offset": self.offset, "oldBytes": self.old_bytes, "newBytes": self.new_bytes })
443 }
444}
445
446fn splice(preimage: &[u8], matches: &[Match], new_bytes: &[u8]) -> Vec<u8> {
448 let mut out = Vec::with_capacity(preimage.len());
449 let mut cursor = 0;
450 for found in matches {
451 out.extend_from_slice(&preimage[cursor..found.offset]);
452 out.extend_from_slice(new_bytes);
453 cursor = found.offset + found.old_bytes;
454 }
455 out.extend_from_slice(&preimage[cursor..]);
456 out
457}
458
459pub fn verify_mutation(
464 preimage: &[u8],
465 readback: &[u8],
466 matches: &[Match],
467 old_bytes: &[u8],
468 new_bytes: &[u8],
469) -> Verification {
470 let bad = |detail: String| Verification {
471 verified: false,
472 detail,
473 };
474 if matches.is_empty() {
475 return bad("the mutation receipt has no matched region".to_string());
476 }
477 let expected_len = (preimage.len() as i128)
478 + (matches.len() as i128) * (new_bytes.len() as i128 - old_bytes.len() as i128);
479 if readback.len() as i128 != expected_len {
480 return bad(format!(
481 "readback has {} bytes, expected {}",
482 readback.len(),
483 expected_len
484 ));
485 }
486 let mut before = 0;
487 let mut after = 0;
488 for (index, found) in matches.iter().enumerate() {
489 let number = index + 1;
490 if found.offset < before
491 || found.old_bytes != old_bytes.len()
492 || found.new_bytes != new_bytes.len()
493 || found.offset + found.old_bytes > preimage.len()
494 {
495 return bad(format!(
496 "match {} of the mutation receipt is malformed",
497 number
498 ));
499 }
500 let keep = found.offset - before;
501 if readback.get(after..after + keep) != Some(&preimage[before..found.offset]) {
502 return bad(format!("bytes before match {} changed", number));
503 }
504 after += keep;
505 if &preimage[found.offset..found.offset + found.old_bytes] != old_bytes {
506 return bad(format!(
507 "match {} did not hold old_string in the preimage",
508 number
509 ));
510 }
511 if readback.get(after..after + new_bytes.len()) != Some(new_bytes) {
512 return bad(format!(
513 "match {} does not hold new_string after the change",
514 number
515 ));
516 }
517 before = found.offset + found.old_bytes;
518 after += new_bytes.len();
519 }
520 if readback.get(after..) != Some(&preimage[before..]) {
521 return bad("bytes after the last match changed".to_string());
522 }
523 Verification {
524 verified: true,
525 detail: format!(
526 "{} replacement(s); every other byte unchanged",
527 matches.len()
528 ),
529 }
530}
531
532fn digest(bytes: &[u8]) -> Value {
533 json!({ "sha256": sha256_hex(bytes), "bytes": bytes.len() })
534}
535
536pub fn read_frame(text: &str) -> String {
543 let lines: Vec<&str> = text.split('\n').collect();
544 let width = lines.len().to_string().len().max(5);
545 let numbered: Vec<String> = lines
546 .iter()
547 .enumerate()
548 .map(|(index, line)| format!("{:0width$}| {}", index + 1, line, width = width))
549 .collect();
550 format!(
551 "<file>\n{}\n\n(End of file - total {} lines)\n</file>",
552 numbered.join("\n"),
553 lines.len()
554 )
555}
556
557pub fn read_frame_text(frame: &str) -> Option<String> {
559 const OPEN: &str = "<file>\n";
560 const MARKER: &str = "\n\n(End of file - total ";
561 if !frame.starts_with(OPEN) || !frame.ends_with("</file>") {
562 return None;
563 }
564 let at = frame.rfind(MARKER)?;
565 if at < OPEN.len() {
566 return None;
567 }
568 let lines: Vec<&str> = frame[OPEN.len()..at].split('\n').collect();
569 let count: usize = frame[at + MARKER.len()..]
570 .split_whitespace()
571 .next()?
572 .parse()
573 .ok()?;
574 if count != lines.len() {
575 return None;
576 }
577 let mut out = Vec::with_capacity(lines.len());
578 for (index, line) in lines.iter().enumerate() {
579 let split = line.find("| ")?;
580 if line[..split].parse::<usize>().ok()? != index + 1 {
581 return None;
582 }
583 out.push(&line[split + 2..]);
584 }
585 Some(out.join("\n"))
586}
587
588pub fn echoed_by_tool(text: &str, messages: &[Value]) -> bool {
592 messages.iter().any(|m| {
593 if m["role"] != "tool" {
594 return false;
595 }
596 let content = match &m["content"] {
597 Value::String(content) => content.clone(),
598 Value::Null => String::new(),
599 other => other.to_string(),
600 };
601 content.contains(text) || read_frame_text(&content).is_some_and(|t| t.contains(text))
602 })
603}
604
605#[derive(Debug, Clone, PartialEq, Default)]
607pub struct FileReceipt {
608 pub exit_code: i32,
609 pub result: String,
612 pub text: Option<String>,
614 pub error: Option<String>,
615 pub bytes: Option<u64>,
616 pub sha256: Option<String>,
617 pub previous_sha256: Option<String>,
618 pub replacements: Option<usize>,
619 pub mutation: Option<Value>,
621 pub verification: Option<Verification>,
622}
623
624impl FileReceipt {
625 pub fn evidence(&self) -> Value {
627 let mut out = json!({ "exitCode": self.exit_code });
628 if let Some(error) = &self.error {
629 out["error"] = json!(error);
630 }
631 if let Some(bytes) = self.bytes {
632 out["bytes"] = json!(bytes);
633 }
634 if let Some(sha) = &self.sha256 {
635 out["sha256"] = json!(sha);
636 }
637 if let Some(sha) = &self.previous_sha256 {
638 out["previousSha256"] = json!(sha);
639 }
640 if let Some(count) = self.replacements {
641 out["replacements"] = json!(count);
642 }
643 if let Some(mutation) = &self.mutation {
644 out["mutation"] = mutation.clone();
645 }
646 out
647 }
648}
649
650fn fail(target: &str, message: &str) -> FileReceipt {
651 let error = format!("{}: {}", target, message);
652 FileReceipt {
653 exit_code: 1,
654 result: json!({ "ok": false, "is_error": true, "error": error }).to_string(),
655 error: Some(error),
656 ..FileReceipt::default()
657 }
658}
659
660fn change_result(receipt: &Value, verified: bool) -> String {
662 let mut out = json!({
663 "ok": verified,
664 "path": receipt["target"],
665 "tool": receipt["tool"],
666 "preimage": receipt["preimage"],
667 "postimage": receipt["postimage"],
668 "verified": verified,
669 });
670 if let Some(matches) = receipt["matches"].as_array() {
671 out["replacements"] = json!(matches.len());
672 }
673 out.to_string()
674}
675
676#[derive(Default)]
678pub struct FileHooks<'a> {
679 pub before_replace: Option<&'a dyn Fn(&Path)>,
680 pub after_replace: Option<&'a dyn Fn(&Path)>,
681}
682
683pub fn run_file_call(
692 tool: &str,
693 args: &FileArgs,
694 decision: &FileDecision,
695 context: &mut PolicyContext,
696 max_file_bytes: u64,
697) -> FileReceipt {
698 run_file_call_with(
699 tool,
700 args,
701 decision,
702 context,
703 max_file_bytes,
704 &FileHooks::default(),
705 )
706}
707
708pub fn run_file_call_with(
710 tool: &str,
711 args: &FileArgs,
712 decision: &FileDecision,
713 context: &mut PolicyContext,
714 max_file_bytes: u64,
715 hooks: &FileHooks<'_>,
716) -> FileReceipt {
717 let target = decision.target.clone().unwrap_or_default();
718 let file = root_of(&context.cwd).join(&target);
719 if tool == "read" {
720 return match read_text(&file, max_file_bytes) {
721 Ok(read) => {
722 let sha = sha256_hex(&read.bytes);
723 context.observed.insert(target.clone());
724 context.contents.insert(target, sha.clone());
725 FileReceipt {
726 exit_code: 0,
727 result: read_frame(&read.text),
728 text: Some(read.text),
729 bytes: Some(read.bytes.len() as u64),
730 sha256: Some(sha),
731 ..FileReceipt::default()
732 }
733 }
734 Err(message) => fail(&target, &message),
735 };
736 }
737 let existing = match lstat_or_none(&file) {
738 Ok(existing) => existing,
739 Err(message) => return fail(&target, &message),
740 };
741 if existing
742 .as_ref()
743 .is_some_and(|meta| meta.file_type().is_symlink())
744 {
745 return fail(
746 &target,
747 "is a symbolic link start cannot resolve inside the working directory; nothing changed",
748 );
749 }
750 let mut current = None;
751 if tool == "edit" || existing.is_some() {
752 let Some(seen) = context.contents.get(&target).cloned() else {
753 return fail(
754 &target,
755 "was not read completely with the read tool in this task; read it before changing it; nothing changed",
756 );
757 };
758 match read_text(&file, max_file_bytes) {
759 Ok(read) if seen == sha256_hex(&read.bytes) => current = Some(read),
760 Ok(_) => return fail(
761 &target,
762 "changed since start read it; read it again before changing it; nothing changed",
763 ),
764 Err(message) => return fail(&target, &message),
765 }
766 } else if !file.parent().is_some_and(Path::is_dir) {
767 return fail(&target, "its directory does not exist");
768 }
769 let mut matches: Option<Vec<Match>> = None;
770 let old = args.old_string.as_deref().unwrap_or("");
771 let new = args.new_string.as_deref().unwrap_or("");
772 let expected = if tool == "edit" {
773 let read = current.as_ref().expect("edit read the file");
774 if old.is_empty() {
775 return fail(&target, "old_string is empty; nothing changed");
776 }
777 if old == new {
778 return fail(
779 &target,
780 "old_string and new_string are the same; nothing changed",
781 );
782 }
783 let found: Vec<usize> = read.text.match_indices(old).map(|(at, _)| at).collect();
786 if found.is_empty() {
787 return fail(&target, "old_string was not found; nothing changed");
788 }
789 if found.len() > 1 && !args.replace_all {
790 return fail(
791 &target,
792 &format!(
793 "old_string occurs {} times; give more surrounding text or set replace_all; nothing changed",
794 found.len()
795 ),
796 );
797 }
798 let chosen = if args.replace_all {
799 &found[..]
800 } else {
801 &found[..1]
802 };
803 let list: Vec<Match> = chosen
804 .iter()
805 .map(|offset| Match {
806 offset: *offset,
807 old_bytes: old.len(),
808 new_bytes: new.len(),
809 })
810 .collect();
811 let spliced = splice(&read.bytes, &list, new.as_bytes());
812 let text = if args.replace_all {
814 read.text.replace(old, new)
815 } else {
816 read.text.replacen(old, new, 1)
817 };
818 if spliced != text.as_bytes() {
819 return fail(
820 &target,
821 "the replacement does not map to whole UTF-8 characters; nothing changed",
822 );
823 }
824 matches = Some(list);
825 spliced
826 } else {
827 args.content.clone().unwrap_or_default().into_bytes()
828 };
829 if expected.len() as u64 > max_file_bytes {
830 return fail(
831 &target,
832 &format!(
833 "the new text has {} bytes; START_FORMAL_AI_MAX_FILE_BYTES is {}; nothing changed",
834 expected.len(),
835 max_file_bytes
836 ),
837 );
838 }
839 let mut receipt = json!({
840 "tool": tool,
841 "target": target,
842 "preimage": current.as_ref().map(|read| digest(&read.bytes)),
843 "postimage": digest(&expected),
844 });
845 if let Some(list) = &matches {
846 receipt["matches"] = json!(list.iter().map(|m| m.to_json()).collect::<Vec<_>>());
847 }
848 let preimage = current.as_ref().map(|read| read.bytes.as_slice());
849 let guard = || {
850 if let Some(hook) = hooks.before_replace {
851 hook(&file);
852 }
853 if still_holds(&file, preimage, max_file_bytes) {
854 Ok(())
855 } else if preimage.is_some() {
856 Err("changed while start was changing it; read it again; nothing changed".to_string())
857 } else {
858 Err("was created while start was writing it; nothing changed".to_string())
859 }
860 };
861 let permissions = current.as_ref().map(|read| read.permissions.clone());
862 if let Err(error) = write_atomically(&file, &expected, permissions, &guard, current.is_none()) {
863 let message = error.message();
864 let intact = still_holds(&file, preimage, max_file_bytes);
867 if !intact {
868 context.contents.remove(&target);
869 }
870 let message = if intact || message.ends_with("nothing changed") {
871 format!(
872 "{}; nothing changed",
873 message.trim_end_matches("; nothing changed")
874 )
875 } else {
876 format!("{}; the file may have changed; read it again", message)
877 };
878 return FileReceipt {
879 mutation: Some(receipt),
880 ..fail(&target, &message)
881 };
882 }
883 context.observed.insert(target.clone());
884 let previous_sha256 = preimage.map(sha256_hex);
885 let replacements = matches.as_ref().map(Vec::len);
886 if let Some(hook) = hooks.after_replace {
887 hook(&file);
888 }
889 let readback = match read_bytes(&file, max_file_bytes) {
890 Ok((bytes, _)) => bytes,
891 Err(message) => {
892 context.contents.remove(&target);
893 return FileReceipt {
894 exit_code: 0,
895 result: change_result(&receipt, false),
896 bytes: Some(expected.len() as u64),
897 previous_sha256,
898 replacements,
899 mutation: Some(receipt),
900 verification: Some(Verification {
901 verified: false,
902 detail: format!("readback of {} failed ({})", target, message),
903 }),
904 ..FileReceipt::default()
905 };
906 }
907 };
908 let check = match (&matches, preimage) {
909 (Some(list), Some(preimage)) => {
910 verify_mutation(preimage, &readback, list, old.as_bytes(), new.as_bytes())
911 }
912 _ => Verification {
913 verified: true,
914 detail: "whole text".to_string(),
915 },
916 };
917 let verified = check.verified && readback == expected;
918 receipt["readback"] = digest(&readback);
919 let sha = sha256_hex(&readback);
920 if verified {
921 context.contents.insert(target.clone(), sha.clone());
922 } else {
923 context.contents.remove(&target);
924 }
925 FileReceipt {
926 exit_code: 0,
927 result: change_result(&receipt, verified),
928 bytes: Some(expected.len() as u64),
929 sha256: Some(sha),
930 previous_sha256,
931 replacements,
932 mutation: Some(receipt),
933 verification: Some(Verification {
934 verified,
935 detail: if verified {
936 format!(
937 "readback confirmed {} {} ({} bytes; {})",
938 tool,
939 target,
940 expected.len(),
941 check.detail
942 )
943 } else if check.verified {
944 format!("readback of {} differs from the intended text", target)
945 } else {
946 format!(
947 "readback of {} differs from the intended text ({})",
948 target, check.detail
949 )
950 },
951 }),
952 ..FileReceipt::default()
953 }
954}
955
956fn still_holds(file: &Path, preimage: Option<&[u8]>, max_file_bytes: u64) -> bool {
958 match (lstat_or_none(file), preimage) {
959 (Ok(None), None) => true,
960 (Ok(Some(meta)), Some(preimage)) if !meta.file_type().is_symlink() => {
961 read_bytes(file, max_file_bytes).is_ok_and(|(bytes, _)| bytes == preimage)
962 }
963 _ => false,
964 }
965}
966
967#[cfg(test)]
968#[path = "formal_ai_files_cases.rs"]
969mod formal_ai_files_cases;