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
342
343
344
345
346
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Atomic file creation and overwrite from stdin content.
//! Workload: I/O-bound (stdin read + atomic write).
//! Parallelism: none — single target file.
use std::io::{Read, Write};
use std::time::Instant;
use anyhow::{Context, Result};
use crate::atomic::{AtomicWriteOptions, atomic_write};
use crate::checksum;
use crate::cli::{GlobalArgs, WriteArgs};
use crate::commands::{StdinCapOrigin, read_stdin_content_cancellable, resolve_backup};
use crate::constants::{PERCENT_SCALE, WRITE_RISK_HIGH_PERCENT, WRITE_RISK_MEDIUM_PERCENT};
use crate::error::AtomwriteError;
use crate::ndjson_types::WriteOutput;
use crate::output::NdjsonWriter;
use crate::signal::ShutdownSignal;
/// Line-ending mode in effect: the flag when it names one, else the config.
///
/// `--line-ending` defaults to `auto`, so a value of `auto` cannot tell "the
/// caller asked for auto" from "the caller said nothing". The config key
/// `defaults.line_ending` is only consulted in the second case, and `auto`
/// there means the same auto-detection the flag default asks for.
fn effective_line_ending(
args: &WriteArgs,
defaults: &crate::config::DefaultsSection,
) -> crate::line_endings::LineEnding {
use clap::ValueEnum;
if args.line_ending != crate::line_endings::LineEnding::Auto {
return args.line_ending;
}
crate::line_endings::LineEnding::from_str(&defaults.line_ending, true)
.unwrap_or(crate::line_endings::LineEnding::Auto)
}
/// Create or overwrite a file atomically from stdin content.
///
/// The target is resolved against the workspace once, up front, so
/// append/prepend, line-ending auto-detection, and `--expect-checksum`
/// operate on the same path identity as the final atomic write. Before
/// v0.1.15 these pre-steps used the raw CLI path relative to the CWD,
/// which truncated appends and skipped checksum verification whenever
/// the CWD differed from the workspace (G118, CWE-367).
///
/// # Errors
///
/// Returns `AtomwriteError::NotFound` if reading stdin fails.
/// Returns `AtomwriteError::WorkspaceJail` if the path escapes the workspace.
/// Returns `AtomwriteError::Io` if writing the file fails.
/// Returns `AtomwriteError::StateDrift` if `--checksum` is set and the expected hash does not match.
#[tracing::instrument(skip_all, fields(command = "write"))]
pub fn cmd_write(
args: &WriteArgs,
global: &GlobalArgs,
stdin: impl Read,
writer: &mut NdjsonWriter<impl Write>,
shutdown: &ShutdownSignal,
defaults: &crate::config::DefaultsSection,
write_cfg: &crate::config::WriteSection,
) -> Result<()> {
let start = Instant::now();
let workspace = global.resolve_workspace()?;
let resolved = crate::path_safety::validate_path(&args.target, &workspace)?;
let resolved_backup = resolve_backup(&args.backup_opts, defaults);
let effective_backup = resolved_backup.backup;
// The global `--max-filesize` bounds the payload too, exactly like it bounds
// the target: a verb-local `--max-size` only narrows it further, and the two
// never disagree. Checked before stdin is consumed so a rejected overwrite
// does not drain the pipe.
// The config key `defaults.max_filesize` fills the cap the flag did not
// name. The flag wins because it is the explicit target designation; the
// config key exists so an operator can lower the ceiling once, and a key
// nothing reads is a promise the product does not keep.
let global_cap = match global.max_filesize {
Some(n) => n,
None => defaults.max_filesize.max(1),
};
if let Ok(meta) = std::fs::metadata(&resolved) {
if meta.is_file() && meta.len() > global_cap {
return Err(AtomwriteError::FileTooLarge {
path: resolved,
size: meta.len(),
max_size: global_cap,
}
.into());
}
}
let (stdin_cap, cap_origin) = match args.max_size {
Some(local) => (Some(local), StdinCapOrigin::LocalMaxSize),
None => (Some(global_cap), StdinCapOrigin::GlobalMaxFilesize),
};
let stdin_bytes_read;
let mut content = {
let (buf, n) = read_stdin_content_cancellable(
stdin,
stdin_cap,
cap_origin,
args.allow_empty_stdin,
Some(shutdown),
)?;
stdin_bytes_read = n;
buf
};
if shutdown.is_shutdown() {
return Err(crate::signal::cancelled_error("interrupted before write").into());
}
// A1: append/prepend compute the new content from the file on disk, so lock
// BEFORE reading it (stdin was read above, never under the lock).
let _update_lock = if (args.append || args.prepend)
&& resolved.parent().is_some_and(std::path::Path::is_dir)
{
Some(crate::lock::acquire_for_update(&resolved)?)
} else {
None
};
if args.append || args.prepend {
content = handle_append_prepend(
&resolved,
&content,
args.append,
global.effective_max_filesize(),
args.allow_empty_stdin,
)?;
}
content = normalize_line_endings(&content, effective_line_ending(args, defaults), &resolved);
// G120 L3: cross-validation. When the caller combines
// `--append` (or `--prepend`) with `--expect-checksum` and the stdin
// is empty, the situation is ambiguous: the checksum is for the
// pre-mutation state but the empty append is effectively a no-op.
// We emit a structured warning on stderr so the agent and operator
// can audit the decision, but DO NOT abort — the user explicitly
// opted into empty stdin with `--allow-empty-stdin`.
if let Some(ref expected) = args.expect_checksum {
if stdin_bytes_read == 0 && (args.append || args.prepend) {
if args.no_checksum_when_empty {
tracing::warn!(
path = %resolved.display(),
expected = %expected,
"G120 L3: --append/--prepend + --expect-checksum with empty stdin; \
skipping checksum verification per --no-checksum-when-empty"
);
} else {
// Default: still verify against the pre-mutation state.
// If the pre-mutation file exists and matches, this
// passes and the empty append is a no-op. If it does
// not exist, verify_checksum's `if !target.exists()`
// short-circuit returns Ok — the same legacy behaviour
// as pre-v0.1.15, but now explicitly logged.
tracing::info!(
path = %resolved.display(),
expected = %expected,
"G120 L3: --append/--prepend + --expect-checksum with empty stdin; \
verifying pre-mutation state. Pass --no-checksum-when-empty to skip."
);
verify_checksum(&resolved, expected, global.effective_max_filesize())?;
}
} else {
verify_checksum(&resolved, expected, global.effective_max_filesize())?;
}
}
// A-WRITE-001: large-file overwrite is default-deny (not opt-in via --confirm).
// One-shot: never read Y/N from stdin (collides with payload). Require --ack-overwrite
// when the existing target exceeds XDG [write].confirm_large_bytes.
if resolved.exists() {
let original_size = std::fs::metadata(&resolved).map(|m| m.len()).unwrap_or(0);
crate::commands::write_guards::check_replacement_size(
&resolved,
original_size,
content.len() as u64,
write_cfg,
args.ack_overwrite,
args.allow_shrink,
)?;
}
let target_str = args.target.display().to_string();
if args.dry_run {
// The plan carries the risk the write would report: the dry-run is the
// run an agent reads before deciding to write at all.
let plan = crate::ndjson_types::DryRunPlan {
r#type: "plan",
operation: "write".into(),
path: target_str,
would_modify: true,
details: Some(format!("{} bytes from stdin", content.len())),
risk_assessment: crate::commands::write_guards::assess_content_risk(
&content,
std::fs::metadata(&resolved).map(|m| m.len()).unwrap_or(0),
content.len() as u64,
write_cfg,
),
};
writer.write_event(&plan)?;
return Ok(());
}
// GAP-2026-011 L2 — require-backup guard
// GAP-2026-033: check no_backup explicitly since backup has default_value_t=true
if args.require_backup && (args.backup_opts.no_backup || !effective_backup) && resolved.exists()
{
return Err(AtomwriteError::InvalidInput {
reason: "--require-backup is set but --no-backup disables backup; remove --no-backup or remove --require-backup".into(),
}
.into());
}
// GAP-2026-011 L5 — auto-rotate guard (age from XDG `[write].auto_rotate_max_age_secs`)
let auto_rotate_active = args.auto_rotate
&& effective_backup
&& resolved.exists()
&& std::fs::metadata(&resolved)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| {
age < std::time::Duration::from_secs(write_cfg.auto_rotate_max_age_secs)
});
// GAP-2026-011 L1 + L6 — size guard and risk_assessment diagnostics
// GAP-2026-024: skip size risk for append/prepend (never causes data loss)
// B-013: content-pattern risk applies to create/overwrite payloads (not product telemetry).
let new_bytes = content.len() as u64;
let original_bytes = if resolved.exists() {
std::fs::metadata(&resolved).map(|m| m.len()).unwrap_or(0)
} else {
0
};
let mut risk_assessment = None;
if let Some(content_risk) = crate::commands::write_guards::assess_content_risk(
&content,
original_bytes,
new_bytes,
write_cfg,
) {
crate::runtime::warn_stderr(
global.color_mode(),
format!(
"write content risk {} (guard={})",
content_risk.risk_level, content_risk.guard_triggered
),
);
risk_assessment = Some(content_risk);
}
if risk_assessment.is_none() && resolved.exists() && !args.append && !args.prepend {
let original = original_bytes;
if original > 0 {
let delta_pct = ((new_bytes.abs_diff(original)) * PERCENT_SCALE / original) as u32;
if delta_pct >= u32::from(args.risk_threshold) {
let level = if delta_pct >= WRITE_RISK_HIGH_PERCENT {
"high"
} else if delta_pct >= WRITE_RISK_MEDIUM_PERCENT {
"medium"
} else {
"low"
};
crate::runtime::warn_stderr(
global.color_mode(),
&rust_i18n::t!(
"warn.write-risk",
level = level,
delta_pct = delta_pct,
original = original,
new_bytes = new_bytes
),
);
// G-017: block large shrink always (CAS optional).
if !args.allow_shrink && new_bytes < original {
return Err(AtomwriteError::InvalidInput {
reason: format!(
"write risk {} ({}% size delta, {} -> {} bytes) blocked; \
pass --allow-shrink to override",
level, delta_pct, original, new_bytes
),
}
.into());
}
risk_assessment = Some(crate::ndjson_types::WriteRiskAssessment {
original_bytes: original,
new_bytes,
size_delta_pct: delta_pct,
risk_level: level,
guard_triggered: "size",
});
}
}
}
let opts = AtomicWriteOptions {
backup: effective_backup || auto_rotate_active,
syntax_check: args.syntax_check,
retention: resolved_backup.retention,
preserve_timestamps: args.preserve_timestamps,
backup_output_dir: None,
strategy: None,
strict_atomic: args.strict.strict_atomic,
wal_policy: args.wal_policy,
// GAP-106: --require-backup implies --keep-backup so the backup
// is retained on disk and the reported backup_path is valid.
keep_backup: resolved_backup.keep || args.require_backup || auto_rotate_active,
durability: args.durability.into(),
};
let result = atomic_write(&resolved, &content, &opts, &workspace)?;
let output = WriteOutput {
r#type: "write",
status: "success",
path: target_str,
bytes_written: result.bytes_written,
checksum: result.checksum,
checksum_before: result.checksum_before,
backup_path: result.backup_path,
backup_skipped: result.backup_skipped,
write_strategy: result.write_strategy,
elapsed_ms: start.elapsed().as_millis() as u64,
stdin_bytes_read,
wal_policy: args.wal_policy.as_str(),
platform: result.platform,
mtime_preserved: if args.preserve_timestamps {
Some(true)
} else {
None
},
risk_assessment,
};
writer.write_event(&output)?;
Ok(())
}
include!("write_helpers.inc.rs");