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
//! Timestamped snapshots of a `settings.json` the hooks writers are about to
//! replace, kept to a bounded set (#7244).
//!
//! Why: #7244 was a bad hook command written into a real project's
//! `.claude/settings.json`, and the only way back was to reconstruct the file
//! by hand. The atomic-write path
//! ([`trusty_common::claude_config::write_json_atomic`]) already keeps a
//! `<path>.bak`, but that is ONE slot overwritten by every writer of that file
//! — the launch that discovers the damage is itself a rewrite, so by the time
//! an operator looks the good copy is gone. A per-rewrite snapshot keeps the
//! last few known states of the file instead of the last one.
//! What: [`snapshot_then_prune`] copies the existing file to
//! `<name>.<YYYYMMDDTHHMMSSZ>.bak` beside it, then deletes all but the newest
//! [`HOOK_SETTINGS_SNAPSHOTS_KEPT`] snapshots of that same file. It is
//! FAIL-CLOSED by contract: it returns the copy's error and the caller must
//! not write. A missing source file yields `Ok(None)` — there is nothing to
//! preserve and the rewrite proceeds.
//! Test: `backup_tests.rs`.
use io;
use ;
use ;
/// How many snapshots of one settings file survive a prune.
///
/// Why (#7244): enough to step back past the launch that noticed the damage
/// and the one that caused it, without turning `.claude/` into an archive —
/// every managed launch that changes the file adds one.
/// What: `3`, the count both hooks writers pass to [`snapshot_then_prune`].
/// Test: `snapshot_then_prune_keeps_only_the_newest_three`.
pub const HOOK_SETTINGS_SNAPSHOTS_KEPT: usize = 3;
/// The extension every snapshot ends with.
const SNAPSHOT_SUFFIX: &str = ".bak";
/// The stamp width of `YYYYMMDDTHHMMSSZ`, in bytes (all ASCII).
const STAMP_LEN: usize = 16;
/// How many snapshots one wall-clock second may hold before the claim gives up.
///
/// Why: the stamp is second-precision, so a burst of rewrites inside one
/// second collides; the `-N` suffix disambiguates. A bound keeps a directory
/// that somehow cannot accept a new file from spinning forever.
/// What: `1000` — far above [`HOOK_SETTINGS_SNAPSHOTS_KEPT`], so the prune
/// always reclaims the names long before this matters.
const MAX_SAME_SECOND_SNAPSHOTS: u32 = 1000;
/// Copy `path` to a timestamped sibling, then prune that file's snapshots to
/// the newest `keep`.
///
/// Why: see the module doc — the hooks writers must be able to hand an
/// operator the file as it was before the rewrite that broke it, and one
/// overwritten `.bak` slot cannot do that. The prune is what keeps the
/// guarantee from costing unbounded disk.
/// What: returns `Ok(None)` when `path` does not exist (nothing to snapshot,
/// the rewrite may proceed). Otherwise claims
/// `<name>.<YYYYMMDDTHHMMSSZ>.bak` — suffixing `-1`, `-2`, … when that second
/// already holds a snapshot — with an exclusive create so two concurrent
/// writers cannot claim one name, copies `path` into it, and returns the
/// snapshot path. Any failure up to and including the copy is returned
/// unchanged and leaves no partial snapshot behind; the CALLER must treat that
/// as fatal to its rewrite. The prune afterwards is best-effort: the snapshot
/// this call owed already exists, so a stale sibling that cannot be removed is
/// a `warn`, not a reason to abandon the write.
/// Test: `snapshot_then_prune_copies_the_existing_file`,
/// `snapshot_then_prune_ignores_a_missing_file`,
/// `snapshot_then_prune_errors_when_the_parent_is_read_only`.
pub
/// [`snapshot_then_prune`] with the clock supplied by the caller.
///
/// Why: the stamp is the snapshot's identity, so every test of naming,
/// collision suffixing, and prune ordering needs to pin it — reading the real
/// clock would make those assertions race a second boundary.
/// What: as [`snapshot_then_prune`]; `now` supplies the `YYYYMMDDTHHMMSSZ`
/// stamp. Production reads `Utc::now()` one frame up.
/// Test: `snapshot_then_prune_suffixes_a_same_second_collision`,
/// `snapshot_then_prune_keeps_only_the_newest_three`.
/// Create — exclusively — the first free snapshot name for `stamp`.
///
/// Why: `exists()` followed by `copy` is a race two managed launches can lose
/// together, and the loser's snapshot would be silently overwritten by the
/// winner's copy. `create_new` makes claiming the name the same operation as
/// checking it.
/// What: tries `<name>.<stamp>.bak`, then `<name>.<stamp>-1.bak`, … up to
/// [`MAX_SAME_SECOND_SNAPSHOTS`], returning the first name it could create.
/// An `AlreadyExists` moves to the next candidate; any other error is the
/// caller's. A directory sitting on a candidate name also reports
/// `AlreadyExists`, so it is stepped over rather than treated as a failure.
/// Test: `snapshot_then_prune_suffixes_a_same_second_collision`,
/// `snapshot_then_prune_errors_when_the_parent_is_read_only`.
/// Build the snapshot path for `path` at `stamp`, `attempt` collisions in.
///
/// What: `attempt` 0 is the bare `<name>.<stamp>.bak`; every later one appends
/// `-<attempt>` to the stamp, so the `.bak` extension stays last.
/// Test: `snapshot_then_prune_suffixes_a_same_second_collision`.
/// Delete all but the newest `keep` snapshots of `path`.
///
/// Why: without it every managed launch that changes the file leaves another
/// copy in the project's `.claude/` forever.
/// What: lists the parent directory, keeps only names
/// [`snapshot_order_key`] accepts for THIS file, orders them by stamp then
/// collision index, and removes everything before the last `keep`. Best-effort
/// throughout — an unreadable directory or an undeletable entry is logged at
/// `warn` and does not fail the caller, because the snapshot it owed already
/// exists. Sorting on the parsed collision index rather than raw bytes keeps
/// the eleventh snapshot of one second from sorting before the second.
/// Test: `snapshot_then_prune_keeps_only_the_newest_three`,
/// `snapshot_then_prune_leaves_foreign_backups_alone`.
/// Whether `entry_name` is a snapshot of `settings_name` this module wrote.
///
/// Why: the prune's inclusion rule and the assertions that count snapshots
/// have to be the SAME rule, or a test can pass by counting a file the prune
/// would never have touched — `.claude/` also holds the atomic writer's own
/// `settings.json.bak`. Three test modules across two module trees assert on
/// snapshot counts, so the alternative is three copies of the shape drifting
/// apart from the one the prune actually applies. #8688: decommission's
/// `--force` excuse reads it too, so it excuses exactly the names this module
/// writes.
/// What: [`snapshot_order_key`] reduced to a yes/no.
/// Test: `snapshot_order_key_rejects_foreign_names`,
/// `write_project_hooks_snapshots_the_file_it_replaces`.
pub
/// Read `entry_name`'s ordering key when it is a snapshot of `settings_name`.
///
/// Why: the prune deletes files, so its predicate must be exact. `.claude/`
/// also holds `settings.json.bak` (the atomic writer's own single-slot backup)
/// and transient `settings.json.bak.<pid>.<seq>` / `settings.json.tmp.<pid>.<seq>`
/// staging names — none of which this module created and none of which it may
/// remove.
/// What: `Some((stamp, collision index))` only when `entry_name` is exactly
/// `<settings_name>.<stamp>.bak` or `<settings_name>.<stamp>-<n>.bak`, with
/// `stamp` shaped `YYYYMMDDTHHMMSSZ` (8 digits, `T`, 6 digits, `Z`) and `n` a
/// run of digits. Everything else is `None`.
/// Test: `snapshot_then_prune_leaves_foreign_backups_alone`,
/// `snapshot_order_key_rejects_foreign_names`.
/// Whether `stamp` is a `YYYYMMDDTHHMMSSZ` UTC stamp.
///
/// What: exactly [`STAMP_LEN`] ASCII bytes — 8 digits, `T`, 6 digits, `Z`. The
/// field VALUES are not range-checked: this only has to separate our own
/// generated names from foreign ones, and every name it accepts was written by
/// [`snapshot_path`].
/// Test: `snapshot_order_key_rejects_foreign_names`.