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
//! Timestamped-backup helper for programmatic TOML rewrites.
//!
//! Every write site that mutates a user-facing TOML on disk
//! (mnml config, integration manifests, glyph assignments,
//! integration settings, custom theme) goes through
//! [`write_toml_with_backup`] so a bad write leaves a
//! recoverable copy behind. User rule (2026-08-18): "all
//! programmatic toml edits need the backups".
//!
//! Backup filename: `<original>.pre-<reason>-<UTC-YYYYMMDD-HHMMSS>`
//! placed alongside the file. Retention is capped per (path,
//! reason) at [`MAX_BACKUPS_PER_REASON`] most-recent; older
//! ones are unlinked after every successful write.
use std::io;
use std::path::{Path, PathBuf};
/// How many timestamped backups to keep per (file, reason). Older
/// ones get unlinked after every write. 10 is roughly two weeks of
/// aggressive UI-driven fiddling at one edit/day/reason; enough to
/// walk back a botched Configure-pane session without ballooning
/// the config directory.
pub(crate) const MAX_BACKUPS_PER_REASON: usize = 10;
/// Copy `path` to a timestamped backup (if it exists), then write
/// `contents` to `path`. Prunes older backups for the same
/// `(path, reason)` past [`MAX_BACKUPS_PER_REASON`].
///
/// - `path`: destination file to (over)write.
/// - `contents`: the new TOML string.
/// - `reason`: short kebab-case tag identifying the writer — one of
/// `"config"`, `"manifest"`, `"assignments"`, `"settings"`,
/// `"theme"`. Prunes only match backups tagged with the same
/// reason, so unrelated call sites never delete each other's
/// safety net.
///
/// Ordering: backup FIRST, then write. If the backup step fails
/// we still write (a stale backup blocking a legitimate write
/// would be worse than a missing backup); the io::Error is
/// suppressed to a debug-eprintln.
pub(crate) fn write_toml_with_backup(path: &Path, contents: &str, reason: &str) -> io::Result<()> {
if path.exists() {
if let Err(e) = make_backup(path, reason) {
eprintln!("mnml: TOML backup for {} failed: {e}", path.display());
}
prune_backups(path, reason, MAX_BACKUPS_PER_REASON);
}
// Integration manifests carry `[auth_values]` — Slack bot tokens,
// Jira API tokens, Bitbucket app passwords. A plain `fs::write`
// put them on disk at the process umask (0644 on a stock box).
// Every caller of this helper writes user config, so tighten
// unconditionally rather than trying to guess which files are
// secret-bearing: a private theme file costs nothing, a
// world-readable token costs a lot.
crate::secret_file::write_secret(path, contents.as_bytes())
}
fn make_backup(path: &Path, reason: &str) -> io::Result<PathBuf> {
let backup = backup_path(path, reason);
// `fs::copy` propagates the SOURCE's mode, so a backup taken from
// a pre-0600 manifest would faithfully reproduce its 0644 — and
// these backups outlive the value they copy: clearing a token from
// the Configure pane leaves up to MAX_BACKUPS_PER_REASON readable
// copies of it behind.
crate::secret_file::copy_secret(path, &backup)?;
Ok(backup)
}
fn backup_path(path: &Path, reason: &str) -> PathBuf {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
path.with_file_name(format!("{name}.pre-{reason}-{}", utc_stamp()))
}
/// Delete backups for `(path, reason)` past the `keep` most-recent.
/// Backups sort chronologically by the UTC stamp in their filename
/// (the format was chosen for exactly this).
fn prune_backups(path: &Path, reason: &str, keep: usize) {
let Some(dir) = path.parent() else {
return;
};
let Some(base_name) = path.file_name().and_then(|s| s.to_str()) else {
return;
};
let prefix = format!("{base_name}.pre-{reason}-");
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut candidates: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|s| s.to_str())
.map(|s| s.starts_with(&prefix))
.unwrap_or(false)
})
.collect();
if candidates.len() <= keep {
return;
}
// Sort by filename — lex order matches chronological because the
// stamp is fixed-width YYYYMMDD-HHMMSS.
candidates.sort();
let excess = candidates.len() - keep;
for old in &candidates[..excess] {
let _ = std::fs::remove_file(old);
}
}
/// `YYYYMMDD-HHMMSS` UTC — same shape reset.rs uses for its whole-
/// config backups so they interleave cleanly on `ls`.
pub(crate) fn utc_stamp() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (yr, mo, day, hr, mn, sc) = unix_to_ymdhms(now as i64);
format!("{yr:04}{mo:02}{day:02}-{hr:02}{mn:02}{sc:02}")
}
fn unix_to_ymdhms(mut ts: i64) -> (i32, u32, u32, u32, u32, u32) {
if ts < 0 {
ts = 0;
}
let sc = (ts % 60) as u32;
ts /= 60;
let mn = (ts % 60) as u32;
ts /= 60;
let hr = (ts % 24) as u32;
let mut days = ts / 24;
let mut yr: i32 = 1970;
loop {
let leap = is_leap(yr);
let year_days = if leap { 366 } else { 365 };
if days < year_days {
break;
}
days -= year_days;
yr += 1;
}
let month_lengths: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut mo: u32 = 1;
for (i, &len) in month_lengths.iter().enumerate() {
let feb_bonus = if i == 1 && is_leap(yr) { 1 } else { 0 };
let this_month = (len + feb_bonus) as i64;
if days < this_month {
mo = (i + 1) as u32;
break;
}
days -= this_month;
}
let day = (days + 1) as u32;
(yr, mo, day, hr, mn, sc)
}
fn is_leap(y: i32) -> bool {
(y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn writes_new_file_without_backup() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write_toml_with_backup(&path, "a = 1\n", "config").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "a = 1\n");
// No `.pre-` files yet — nothing to back up.
let siblings: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_str()
.map(|s| s.contains(".pre-"))
.unwrap_or(false)
})
.collect();
assert!(siblings.is_empty());
}
/// Integration manifests carry `[auth_values]` — real service
/// tokens. Both the live file AND the timestamped backup must be
/// owner-only, including when the pre-existing file was 0644 from
/// an older mnml. The backup matters as much as the live file:
/// clearing a token from the Configure pane leaves up to
/// `MAX_BACKUPS_PER_REASON` copies of it behind.
#[test]
#[cfg(unix)]
fn secret_manifests_and_their_backups_are_owner_only() {
use std::os::unix::fs::PermissionsExt;
let mode_of = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("slack.toml");
fs::write(&path, "[auth_values]\nbot_token = \"xoxb-old\"\n").unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
write_toml_with_backup(
&path,
"[auth_values]\nbot_token = \"xoxb-new\"\n",
"settings",
)
.unwrap();
assert_eq!(mode_of(&path), 0o600, "live manifest must be 0600");
let backups: Vec<PathBuf> = fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|s| s.to_str())
.map(|s| s.contains(".pre-settings-"))
.unwrap_or(false)
})
.collect();
assert_eq!(backups.len(), 1, "expected exactly one backup");
assert_eq!(
mode_of(&backups[0]),
0o600,
"backup must not inherit the source's 0644"
);
// And it really is the old secret we just made private.
assert!(
fs::read_to_string(&backups[0])
.unwrap()
.contains("xoxb-old"),
"backup should hold the prior value"
);
}
#[test]
fn overwrites_leave_a_backup() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "a = 1\n").unwrap();
write_toml_with_backup(&path, "a = 2\n", "config").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "a = 2\n");
let backups: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_str()
.map(|s| s.contains(".pre-config-"))
.unwrap_or(false)
})
.collect();
assert_eq!(backups.len(), 1);
// Backup content matches the old bytes.
assert_eq!(fs::read_to_string(backups[0].path()).unwrap(), "a = 1\n");
}
#[test]
fn stamp_shape_is_15_chars() {
let s = utc_stamp();
assert_eq!(s.len(), 15, "YYYYMMDD-HHMMSS: {s}");
assert_eq!(&s[8..9], "-");
}
#[test]
fn prunes_past_max_keeping_newest() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.toml");
fs::write(&path, "seed").unwrap();
// Seed with (MAX + 3) fake backups whose UTC stamps sort
// in a known order. Insert names manually so we don't need
// to sleep 1s between real writes.
for i in 0..(MAX_BACKUPS_PER_REASON + 3) {
let name = format!("t.toml.pre-config-2026081{i:02}-000000");
fs::write(dir.path().join(name), format!("v{i}")).unwrap();
}
prune_backups(&path, "config", MAX_BACKUPS_PER_REASON);
let remaining: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_str()
.map(|s| s.starts_with("t.toml.pre-config-"))
.unwrap_or(false)
})
.collect();
assert_eq!(remaining.len(), MAX_BACKUPS_PER_REASON);
// The 3 oldest (v0, v1, v2 — lowest stamps) should be gone.
for name in remaining.iter().map(|e| e.file_name()) {
let s = name.to_string_lossy().to_string();
assert!(
!s.contains("2026081000") && !s.contains("2026081100") && !s.contains("2026081200"),
"kept an over-old backup: {s}"
);
}
}
#[test]
fn different_reasons_dont_prune_each_other() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.toml");
fs::write(&path, "seed").unwrap();
// 15 config backups + 1 manifest backup — the pruner
// caps config at MAX; manifest untouched.
for i in 0..15 {
let name = format!("t.toml.pre-config-2026081{i:02}-000000");
fs::write(dir.path().join(name), "x").unwrap();
}
fs::write(dir.path().join("t.toml.pre-manifest-20260810-000000"), "m").unwrap();
prune_backups(&path, "config", MAX_BACKUPS_PER_REASON);
let manifest_still_there = dir
.path()
.join("t.toml.pre-manifest-20260810-000000")
.exists();
assert!(manifest_still_there);
}
}