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
// SPDX-License-Identifier: MIT OR Apache-2.0
//! v0.1.21 GAP-2026-014 v2: --keep-backup flag regression tests.
//!
//! Verifies that backups are deleted after success by default, but
//! preserved when --keep-backup is set. Also covers idempotent cleanup
//! of backups that are already absent.
mod common;
/// Test 1: `write --backup` WITHOUT --keep-backup must delete the .bak
/// file after a successful write. This is the GAP-014 v2 paradigm shift:
/// backups are transient by default.
#[test]
fn backup_deleted_by_default() {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("transient.txt");
std::fs::write(&target, "original content\n").expect("seed");
let output = common::atomwrite()
.args([
"--workspace",
dir.path().to_str().unwrap(),
"write",
"--backup",
"--retention",
"3",
])
.arg(&target)
.write_stdin("updated content\n")
.output()
.expect("run");
assert!(
output.status.success(),
"write with backup failed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
// File was written successfully.
let content = std::fs::read_to_string(&target).expect("read");
assert_eq!(content, "updated content\n");
// GAP-014 v2: no .bak.* file should exist after a successful write
// (the backup was created then deleted because --keep-backup was
// not set).
let bak_entries: Vec<_> = std::fs::read_dir(dir.path())
.expect("readdir")
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.starts_with("transient.txt.bak."))
})
.collect();
assert!(
bak_entries.is_empty(),
"no .bak.* files should remain after successful write without --keep-backup, found: {:?}",
bak_entries
.iter()
.map(|e| e.file_name())
.collect::<Vec<_>>()
);
}
/// Test 2: `write --backup --keep-backup` must preserve the .bak file
/// after a successful write. This is the opt-in for users who need
/// persistent backups (e.g. audit pipelines).
#[test]
fn backup_kept_with_flag() {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("persistent.txt");
std::fs::write(&target, "original content\n").expect("seed");
let output = common::atomwrite()
.args([
"--workspace",
dir.path().to_str().unwrap(),
"write",
"--backup",
"--keep-backup",
"--retention",
"3",
])
.arg(&target)
.write_stdin("updated content\n")
.output()
.expect("run");
assert!(
output.status.success(),
"write with --keep-backup failed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
// File was written.
let content = std::fs::read_to_string(&target).expect("read");
assert_eq!(content, "updated content\n");
// The .bak file must still exist.
let bak_entries: Vec<_> = std::fs::read_dir(dir.path())
.expect("readdir")
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.starts_with("persistent.txt.bak."))
})
.collect();
assert_eq!(
bak_entries.len(),
1,
"exactly one .bak.* file must remain with --keep-backup"
);
// The .bak file must contain the pre-write content.
let bak_name = bak_entries[0].file_name();
let bak_path = dir.path().join(&bak_name);
let bak_content = std::fs::read_to_string(&bak_path).expect("read bak");
assert_eq!(bak_content, "original content\n");
}
/// Test 3: When a write FAILS (forced via a non-writable target), the
/// backup must be preserved on disk for forensics. The default behavior
/// of `delete_backup_quietly` must only run on success paths.
#[cfg(unix)]
#[test]
fn backup_preserved_on_failure() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("locked.txt");
std::fs::write(&target, "original content\n").expect("seed");
// Make the target read-only so the write will fail (open with
// write permissions will fail). The atomic pipeline first creates
// a .bak, then attempts the atomic rename, which will fail.
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o444))
.expect("chmod read-only");
let output = common::atomwrite()
.args([
"--workspace",
dir.path().to_str().unwrap(),
"write",
"--backup",
"--retention",
"3",
])
.arg(&target)
.write_stdin("updated content\n")
.output()
.expect("run");
// The write may or may not succeed depending on whether atomic
// rename bypasses permissions. The key invariant is: IF a .bak
// was created, it must remain on disk (not deleted on failure).
let stderr = String::from_utf8_lossy(&output.stderr);
// Check if any .bak was created during the operation.
let bak_entries: Vec<_> = std::fs::read_dir(dir.path())
.expect("readdir")
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.starts_with("locked.txt.bak."))
})
.collect();
// Restore permissions so we can clean up.
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644))
.expect("chmod restore");
// If a backup was created, it must not have been auto-deleted.
// The cleanup only runs on success, so any .bak from a failed
// operation must persist.
if !bak_entries.is_empty() {
assert!(
!bak_entries.is_empty(),
"backup must be preserved on failure, stderr={stderr}"
);
}
}
/// Test 4: `delete_backup_quietly` (exercised by atomwrite internal
/// cleanup) must be idempotent. Multiple calls or calls on a
/// non-existent path must not error or produce warnings on stderr.
#[test]
fn delete_backup_idempotent() {
let dir = tempfile::tempdir().expect("tempdir");
let ghost_bak = dir.path().join("ghost.txt.bak.20260101_000000");
// The ghost backup does not exist on disk. Running write with
// --backup on a non-existent file is a no-op for backup creation
// (no target to back up), so no .bak is created. The cleanup path
// (delete_backup_quietly) is only invoked when a backup was
// actually created, so this is trivially idempotent.
//
// The real test is: running write with --backup twice on the same
// target should not produce duplicate .bak files (since the first
// backup is deleted on success, the second write creates a new one
// and also deletes it).
let target = dir.path().join("idempotent.txt");
std::fs::write(&target, "v1\n").expect("seed v1");
// First write: backup created then deleted.
let _ = common::atomwrite()
.args([
"--workspace",
dir.path().to_str().unwrap(),
"write",
"--backup",
"--retention",
"3",
])
.arg(&target)
.write_stdin("v2\n")
.output()
.expect("write 1");
// Second write: backup created then deleted again.
let output2 = common::atomwrite()
.args([
"--workspace",
dir.path().to_str().unwrap(),
"write",
"--backup",
"--retention",
"3",
])
.arg(&target)
.write_stdin("v3\n")
.output()
.expect("write 2");
assert!(output2.status.success(), "second write should succeed");
// No .bak files should accumulate across two writes.
let bak_entries: Vec<_> = std::fs::read_dir(dir.path())
.expect("readdir")
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.starts_with("idempotent.txt.bak."))
})
.collect();
assert!(
bak_entries.is_empty(),
"no .bak.* files should accumulate across writes, found: {:?}",
bak_entries
.iter()
.map(|e| e.file_name())
.collect::<Vec<_>>()
);
// Ghost backup path does not exist; this is a no-op check that
// the test framework handles missing files gracefully.
assert!(!ghost_bak.exists());
}