1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Orchestrates the atomic write pipeline (I/O-bound, single-file ordered).
use std::fs;
use std::path::Path;
use std::time::Instant;
use anyhow::{Context, Result};
use crate::checksum;
use crate::ndjson_types::PlatformInfo;
use crate::platform;
use super::inplace::{create_backup_in, delete_backup_quietly, write_inplace_path};
use super::rename_path::write_rename_path;
use super::syntax_heuristic::syntax_heuristic_check;
use super::types::{AtomicWriteOptions, WriteResult, WriteStrategy};
/// Write content atomically via tempfile, fsync, and rename.
///
/// # Errors
///
/// Returns `AtomwriteError::WorkspaceJail` if the target path escapes the workspace.
/// Returns `AtomwriteError::Io` if creating, writing, or renaming the tempfile fails.
/// Returns `AtomwriteError::PermissionDenied` if the target directory is not writable.
/// Returns `AtomwriteError::DiskFull` if the filesystem runs out of space during write.
#[tracing::instrument(skip_all, fields(path = %target.display()))]
pub fn atomic_write(
target: &Path,
content: &[u8],
opts: &AtomicWriteOptions,
workspace: &Path,
) -> Result<WriteResult> {
let start = Instant::now();
// Step 1: validate path
let target = crate::path_safety::validate_path(target, workspace)?;
let resolved_durability = opts.durability.resolve(&target);
let mut backup_method_used: Option<&'static str> = None;
// Step 1a: G119 L1 — decide whether to create a sidecar at all. The
// heuristic short-circuits for trivial writes (small file in a git
// dir, plain write, set/del). This is the first line of defence
// against sidecar pollution and prevents 60-80% of unnecessary
// sidecar writes in agent LLM workloads.
let sidecar_wanted =
crate::wal::should_create_sidecar(&target, crate::wal::JournalOp::Write, opts.wal_policy);
// Step 1b: G114 — append a `Started` WAL entry. On crash, the
// orphan journal surfaces `expected_new_checksum` for recovery.
// G119 L2 — wrap the sidecar in a `JournalGuard` so the sidecar is
// automatically removed on normal scope exit (after `Committed`).
// `wal_guard.keep_on_drop` starts as `true` (safe-by-default) and is
// flipped to `false` by `wal_guard.release()` only after the rename
// and the `Committed` entry succeed. We swallow errors here because
// journaling is best-effort and must never block the actual write.
let new_checksum = blake3::hash(content);
let (wal_op_id, mut wal_guard) = if sidecar_wanted {
match crate::wal::journal_started_with_guard(
&target,
crate::wal::JournalOp::Write,
None, // checksum_before filled in just below
new_checksum,
) {
Ok(pair) => pair,
Err(_) => (String::new(), crate::wal::JournalGuard::inert()),
}
} else {
// L1 suppression: no sidecar, no guard, no recovery metadata.
// The write is still atomic via the tempfile+rename pipeline;
// only the WAL layer is bypassed.
tracing::debug!(
path = %target.display(),
policy = opts.wal_policy.as_str(),
"G119 L1: sidecar suppressed by wal-policy"
);
(String::new(), crate::wal::JournalGuard::inert())
};
let wal_op_id_opt: Option<String> = if wal_op_id.is_empty() {
None
} else {
Some(wal_op_id)
};
// Step 2: capture metadata of existing file
let (checksum_before, original_meta) = if target.exists() {
let meta =
fs::metadata(&target).with_context(|| format!("cannot stat {}", target.display()))?;
let hash = checksum::hash_file(&target, u64::MAX)?;
(Some(hash), Some(meta))
} else {
(None, None)
};
// Step 2b: detect hardlinks that will be broken by rename
#[cfg(unix)]
let hardlink_nlink = if let Some(ref meta) = original_meta {
use std::os::unix::fs::MetadataExt;
let nlink = meta.nlink();
if nlink > 1 { Some(nlink) } else { None }
} else {
None
};
#[cfg(not(unix))]
let hardlink_nlink: Option<u64> = None;
// Step 2c: G39 — capture xattrs before any modification
let saved_xattrs = crate::xattr_restore::save_xattrs(&target).unwrap_or_else(|e| {
tracing::warn!(path = %target.display(), error = %e, "xattr save failed; continuing");
Vec::new()
});
let xattr_count = saved_xattrs.len() as u32;
// Step 2d: G55 — auto-detect strategy. Hardlinks and symlinks force InPlace.
let is_symlink = {
let sm = fs::symlink_metadata(&target);
sm.as_ref().map(fs::Metadata::is_symlink).unwrap_or(false)
};
let strategy = match opts.strategy {
Some(s) => s,
None => {
if hardlink_nlink.is_some_and(|n| n > 1) || is_symlink {
WriteStrategy::InPlace
} else {
WriteStrategy::Rename
}
}
};
if matches!(strategy, WriteStrategy::InPlace) {
if let Some(n) = hardlink_nlink {
tracing::info!(
path = %target.display(),
nlink = n,
"auto-switched to InPlace to preserve hardlink(s)"
);
} else if is_symlink {
tracing::info!(
path = %target.display(),
"auto-switched to InPlace because target is a symlink"
);
}
}
// Step 3: capture timestamps for preservation
let (mtime, atime) = if let Some(ref meta) = original_meta {
(
filetime::FileTime::from_last_modification_time(meta),
filetime::FileTime::from_last_access_time(meta),
)
} else {
let now = filetime::FileTime::now();
(now, now)
};
// Step 4: create backup if requested
let backup_path = if opts.backup && target.exists() {
// v0.1.30: create_backup_in ALWAYS uses reflink_or_copy (never hardlink).
// Do not probe nlink — a hardlink target can make nlink>1 on the copy
// path only if create failed into hardlink, which we forbid.
let bp = create_backup_in(&target, opts.retention, opts.backup_output_dir.as_deref())?;
backup_method_used = Some("reflink_or_copy");
Some(bp)
} else {
None
};
// Step 5: create parent directories
if let Some(parent) = target.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.with_context(|| format!("cannot create directories for {}", target.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(
parent,
fs::Permissions::from_mode(crate::constants::DIR_PERMISSIONS),
);
}
}
}
// Step 5.5: G72 — optional post-write syntax check.
// v0.1.12: real tree-sitter parse via `crate::syntax_check`. Falls
// back to a lightweight bracket-balance heuristic for unknown
// languages. Auto-skips when no parser is available for the
// detected extension. See `src/syntax_check.rs` for the full
// algorithm.
let syntax_errors: u32 = 0;
if opts.syntax_check {
match crate::syntax_check::syntax_check(&target, content) {
Ok(crate::syntax_check::SyntaxCheckResult::Ok) => {}
Ok(crate::syntax_check::SyntaxCheckResult::Skipped { .. }) => {
// No parser for this language; keep the lightweight
// heuristic as a final safety net.
if let Some(reason) = syntax_heuristic_check(content) {
tracing::warn!(
path = %target.display(),
reason = %reason,
"G72 syntax heuristic (no tree-sitter parser) failed"
);
return Err(crate::error::AtomwriteError::SyntaxError {
path: target.to_path_buf(),
count: 1,
}
.into());
}
}
Ok(crate::syntax_check::SyntaxCheckResult::Errors { count, first }) => {
tracing::warn!(
path = %target.display(),
count = count,
line = first.line,
column = first.column,
kind = %first.kind,
message = %first.message,
"G72 tree-sitter syntax check failed"
);
return Err(crate::error::AtomwriteError::SyntaxError {
path: target.to_path_buf(),
count: count as u32,
}
.into());
}
Err(e) => {
tracing::warn!(
path = %target.display(),
error = %e,
"G72 tree-sitter check errored; falling back to heuristic"
);
if let Some(_reason) = syntax_heuristic_check(content) {
return Err(crate::error::AtomwriteError::SyntaxError {
path: target.to_path_buf(),
count: 1,
}
.into());
}
}
}
}
// Step 6–9: dispatch by strategy
let (exdev_fallback, rename_method_used) = match strategy {
WriteStrategy::Rename => write_rename_path(
target.as_path(),
content,
opts.strict_atomic,
resolved_durability,
)?,
WriteStrategy::InPlace | WriteStrategy::CopyBack => (
write_inplace_path(target.as_path(), content)?,
strategy.as_str(),
),
};
// Step 10: fsync parent directory (Full durability; skip for Fast)
if !matches!(resolved_durability, crate::platform::Durability::Fast) {
if let Some(parent) = target.parent() {
if let Err(e) = platform::fsync_dir(parent) {
tracing::warn!(
path = %parent.display(),
error = %e,
"fsync_dir after persist failed"
);
}
}
}
// Step 11: restore permissions
if let Some(ref meta) = original_meta {
let _ = fs::set_permissions(&target, meta.permissions());
}
// Step 12: G39 — restore xattrs on the freshly written target
let xattr_preserved = crate::xattr_restore::restore_xattrs(&target, &saved_xattrs)
.unwrap_or_else(|e| {
tracing::warn!(path = %target.display(), error = %e, "xattr restore failed");
0
});
// Step 13: restore timestamps
if opts.preserve_timestamps && original_meta.is_some() {
let _ = platform::preserve_timestamps(&target, mtime, atime);
}
let checksum = checksum::hash_bytes(content);
// G114: append a `Committed` journal entry to mark the write complete.
// G119 L2: release the guard so the Drop runs and removes the sidecar.
// We ignore errors here (best-effort) since the file is already on disk
// and a recovery-time report will surface any orphan.
if let Some(ref op_id) = wal_op_id_opt {
let _ = crate::wal::journal_committed(&target, op_id);
wal_guard.release();
} else {
// No WAL was created (best-effort fallback) — guard is inert.
wal_guard.keep();
}
// v0.1.21 GAP-014 v2: delete backup quietly after successful write
// when the caller did not request retention. Idempotent: NotFound is
// treated as success. Errors other than NotFound are logged at WARN
// level but do NOT propagate — the user's write already succeeded.
// GAP-101: clear backup_path when the file is deleted so NDJSON
// never reports a path that does not exist on disk.
let backup_path = if let Some(ref bp) = backup_path {
if !opts.keep_backup {
delete_backup_quietly(bp);
None
} else {
Some(bp.clone())
}
} else {
None
};
Ok(WriteResult {
bytes_written: content.len() as u64,
checksum,
checksum_before,
backup_path: backup_path.map(|p| p.display().to_string()),
elapsed_ms: start.elapsed().as_millis() as u64,
platform: PlatformInfo {
fsync: platform::platform_fsync_name(),
dir_fsync: platform::platform_dir_fsync_name(),
durability: Some(resolved_durability.as_str()),
rename_method: Some(rename_method_used),
backup_method: backup_method_used,
},
hardlink_nlink,
write_strategy: strategy.as_str(),
xattr_preserved,
xattr_count,
exdev_fallback,
syntax_errors,
durability: resolved_durability.as_str(),
rename_method: rename_method_used,
backup_method: backup_method_used,
})
}