Skip to main content

max / alloy

Recognise our own render across an image, so a config fix reaches an existing home GO d866e125 subtask 5. `/etc/skel` seeds new users only, so `alloy theme apply` is the only thing that reaches a home that already exists, and the subtask read as deciding when it runs and what it may touch. When it runs was already answered: `usr/bin/alloy-session` calls it before `exec sway`, every login. What was not answered is whether it can tell its own work from the user's once the image moves. It could not. The pristine test compares the file on disk against the two renders the CURRENT image ships, which answers the mode switch completely and is blind to a template changing. A home seeded at one image holds that image's render; ship a fix and both renders move, so the file matches neither and reads as edited. The fix is withheld from exactly the people who need it, and the console tells them they edited a file they never opened. Measured before it was fixed, not reasoned about. So the console records what it put down, one SHA-256 per path at `~/.config/alloy/seeded.toml`, and a file matching its record is ours however old the render is. That is the state file this module's first draft ruled out; the ruling is kept for the case it was made about and reversed here, with the reasoning written into the module docs rather than left as a contradiction. The alternative that would have preserved it — ship every digest ever rendered, so the image carries the history — loses to 1372b159. Distribution is builders, not images, so a user renders their own skeleton from their own theme and no ledger we ship has ever seen it. Recorded from the outcome and not the decision, so a failed write records nothing and `--force` records what it overwrote. Always the digest of the render, never of what was found on disk, so the ledger cannot learn a file the console did not write. A kept file leaves its old entry, so reverting an edit is recognised again. A missing or damaged ledger falls back to the two-render test, which loses a fix rather than a file. One transition cost, in the module docs as a release constraint: a home enrols on its first run, so any file whose template also moves in the image that introduces the ledger is kept with no record to rescue it. Ship this in an image that changes no template.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 22:28 UTC
Signed with PGP, not checked
Commit: 94589425c43e71c757c6c2f75bbd040bf8889448
Parent: 57e6efa
1 file changed, +371 insertions, -15 deletions
@@ -20,12 +20,58 @@
20 20 //! Copying costs divergence, and divergence is how somebody loses the mako
21 21 //! config they spent an evening on. So no file is ever overwritten on the
22 22 //! strength of the console having written it once: the question "did the user
23 - //! edit this" is answered from content alone, by comparing what is on disk
24 - //! against **both** shipped renders. Matching either one means the file is
25 - //! still the image's and may be replaced; matching neither means it is the
26 - //! user's and is left exactly where it is. No state file, no hash manifest,
27 - //! nothing to go stale, and the same answer on every run, which is also what
28 - //! makes apply idempotent.
23 + //! edit this" is answered from content, by comparing what is on disk against
24 + //! **both** shipped renders. Matching either one means the file is still the
25 + //! image's and may be replaced; matching neither means it is the user's and is
26 + //! left exactly where it is.
27 + //!
28 + //! ## Why two renders are not enough, and what the ledger adds
29 + //!
30 + //! Those two renders are the *current* image's. They answer the mode switch
31 + //! completely, which is what this verb was built for, and they are blind to the
32 + //! other thing that moves a shipped config: a new image. A home seeded at one
33 + //! image holds that image's render; change the template and both renders move,
34 + //! so the file on disk matches neither and reads as edited. Measured, not
35 + //! reasoned: `an_untouched_file_survives_the_image_moving_under_it` fails
36 + //! against the two-render test alone.
37 + //!
38 + //! That is not a cosmetic wrong answer. It is the whole delivery path for a
39 + //! config fix — `/etc/skel` seeds new users only, so this verb is the only
40 + //! thing that reaches a home that already exists — and it withholds the fix
41 + //! from exactly the people who need it while telling them they edited a file
42 + //! they never opened.
43 + //!
44 + //! So the console records what it put down, at [`SEEDED`]: one SHA-256 per
45 + //! path, rewritten each run. A file whose digest matches that record is ours
46 + //! however old the render is, and may be replaced. This is the state file the
47 + //! first draft of this module ruled out ("no state file, no hash manifest,
48 + //! nothing to go stale"), and that ruling is kept for the case it was made
49 + //! about and reversed for this one.
50 + //!
51 + //! The alternative that would have preserved it — shipping every digest ever
52 + //! rendered, so the image rather than the home carries the history — loses to
53 + //! `1372b159`: distribution is builders, not images, so a user renders their
54 + //! own skeleton from their own theme and no ledger we ship has ever seen it.
55 + //! Their configs would read as edited forever. Past that, an old digest cannot
56 + //! be regenerated faithfully, because rendering yesterday's template with
57 + //! today's skelgen answers for a file that was never shipped.
58 + //!
59 + //! Three properties keep the cost down. A path with no record falls back to the
60 + //! two-render test, so a missing or deleted ledger loses the fix rather than a
61 + //! file. A [`Decision::Keep`] records nothing, so an edit is never adopted as
62 + //! ours. And the record is written from the render, never from what was found
63 + //! on disk, so the ledger cannot learn a file it did not write.
64 + //!
65 + //! ## The one-time transition, which is a release constraint
66 + //!
67 + //! A home is enrolled the first time this runs against it: every file still
68 + //! holding its render reads [`Outcome::Current`] and is recorded. Logins
69 + //! precede image upgrades, so an ordinary machine enrolls before it ever needs
70 + //! the ledger. The exception is the image that introduces the ledger: any file
71 + //! whose template *also* changed in that image is unrecognisable on arrival
72 + //! and is kept, with no record to rescue it, and stays kept until someone runs
73 + //! `--force`. So ship the ledger in an image that changes no template, and
74 + //! move the templates in the next one.
29 75 //!
30 76 //! ## Nothing here may fail a login
31 77 //!
@@ -52,6 +98,14 @@
52 98 /// The dark tree. Not seeded anywhere: it exists to be applied from.
53 99 const SKEL_NIGHT: &str = "/usr/share/alloy/skel-night";
54 100
101 + /// What the console last wrote into this home, one digest per path.
102 + ///
103 + /// Beside `monitors.toml` rather than under `$XDG_STATE_HOME`, for the reason
104 + /// `monitors.rs` gives for its own placement: these are all files written by
105 + /// the console about this machine, and a home where Alloy's bookkeeping is
106 + /// split across two directories is worse than either choice made consistently.
107 + const SEEDED: &str = ".config/alloy/seeded.toml";
108 +
55 109 /// Where a file the user edited goes before `--force` replaces it.
56 110 const BACKUP_SUFFIX: &str = ".alloy-bak";
57 111
@@ -80,6 +134,9 @@
80 134 day: PathBuf,
81 135 night: PathBuf,
82 136 home: PathBuf,
137 + /// Where this home's ledger lives. Derived from `home` in the real case and
138 + /// named separately so a test can drive the whole verb over a scratch tree.
139 + seeded: PathBuf,
83 140 }
84 141
85 142 impl Trees {
@@ -87,6 +144,7 @@
87 144 Self {
88 145 day: PathBuf::from(SKEL_DAY),
89 146 night: PathBuf::from(SKEL_NIGHT),
147 + seeded: home.join(SEEDED),
90 148 home,
91 149 }
92 150 }
@@ -110,6 +168,74 @@
110 168 }
111 169 }
112 170
171 + /// The digest of one render, as the ledger holds it.
172 + ///
173 + /// The whole SHA-256, unlike the monitor fingerprint, which keeps 64 bits
174 + /// because a person opens that file to see what Alloy thinks is attached.
175 + /// Nobody reads this one, so legibility buys nothing, and the stakes are not
176 + /// the same: a collision here is a config file somebody wrote silently
177 + /// replaced by a render.
178 + fn digest(bytes: &[u8]) -> String {
179 + use sha2::{Digest as _, Sha256};
180 +
181 + let mut hasher = Sha256::new();
182 + hasher.update(bytes);
183 + let mut hex = String::with_capacity(64);
184 + for byte in hasher.finalize() {
185 + use std::fmt::Write as _;
186 + let _ = write!(hex, "{byte:02x}");
187 + }
188 + hex
189 + }
190 +
191 + /// Path to the digest of the render the console last put there.
192 + ///
193 + /// `BTreeMap` so the file is stable between writes, matching `monitors.rs`: a
194 + /// table that reorders itself every login is a table nobody can diff.
195 + #[derive(Debug, Clone, Default, PartialEq, serde::Deserialize, serde::Serialize)]
196 + struct Seeded {
197 + #[serde(default)]
198 + files: std::collections::BTreeMap<String, String>,
199 + }
200 +
201 + impl Seeded {
202 + /// Parse, treating a damaged file as an empty ledger.
203 + ///
204 + /// Forgiving for the reason every path in this module is: it rides the
205 + /// login. An empty ledger costs a config fix, and a hard error costs the
206 + /// session.
207 + fn parse(raw: &str) -> Self {
208 + toml::from_str(raw).unwrap_or_default()
209 + }
210 +
211 + fn to_toml(&self) -> String {
212 + let body = toml::to_string_pretty(self).unwrap_or_default();
213 + format!(
214 + "# Generated by Alloy. The SHA-256 of the skeleton file this console\n\
215 + # last wrote at each path, so a config fix in a later image can tell\n\
216 + # its own render from something you edited. Rewritten whole, so hand\n\
217 + # edits are lost. Deleting this file costs you the next config fix\n\
218 + # and nothing else: your own edits are kept either way.\n\n{body}"
219 + )
220 + }
221 +
222 + /// What we last wrote at `relative`, if we have written it.
223 + ///
224 + /// Keyed by the path as a string with `/` separators. Every path here comes
225 + /// from walking the image's own skeleton, so it is UTF-8 and relative by
226 + /// construction; a path that somehow is not simply has no record, which is
227 + /// the same conservative answer as never having been written.
228 + fn get(&self, relative: &Path) -> Option<&str> {
229 + self.files.get(relative.to_str()?).map(String::as_str)
230 + }
231 +
232 + fn record(&mut self, relative: &Path, digest: String) {
233 + if let Some(key) = relative.to_str() {
234 + self.files.insert(key.to_string(), digest);
235 + }
236 + }
237 + }
238 +
113 239 /// What happened to one file.
114 240 #[derive(Debug, PartialEq, Eq)]
115 241 enum Outcome {
@@ -297,9 +423,15 @@
297 423
298 424 let manifest = manifest(&trees.night);
299 425 let mut report = Report::default();
426 + // An unreadable or absent ledger is an empty one, which is the first-login
427 + // case and the deleted-file case at once. Both mean the same thing: nothing
428 + // is known about this home yet, so every file answers on its renders alone.
429 + let mut seeded = std::fs::read_to_string(&trees.seeded)
430 + .map_or_else(|_| Seeded::default(), |raw| Seeded::parse(&raw));
431 + let before = seeded.clone();
300 432
301 433 for relative in &manifest {
302 - let outcome = apply_one(trees, mode, relative, options);
434 + let outcome = apply_one(trees, mode, relative, options, &mut seeded);
303 435 report.count(&outcome);
304 436
305 437 let shown = contract_home(&trees.home.join(relative));
@@ -323,9 +455,41 @@
323 455 }
324 456 }
325 457
458 + // Once, at the end, and only when something moved. An ordinary login finds
459 + // every file current, records the digests it already holds, and writes
460 + // nothing — which keeps the mtime of a file on the boot path from changing
461 + // on every single login for no reason.
462 + if !options.dry_run && seeded != before {
463 + save_seeded(&trees.seeded, &seeded, err);
464 + }
465 +
326 466 let _ = writeln!(out, "{}", report.summary(mode, options.dry_run));
327 467 }
328 468
469 + /// Write the ledger, complaining rather than failing.
470 + ///
471 + /// Losing it costs the next config fix and nothing a user can see today, so it
472 + /// warns to stderr like every other error path here and the login continues.
473 + fn save_seeded(path: &Path, seeded: &Seeded, err: &mut impl Write) {
474 + if let Some(parent) = path.parent()
475 + && let Err(why) = std::fs::create_dir_all(parent)
476 + {
477 + let _ = writeln!(
478 + err,
479 + "alloy theme: cannot create {}: {why}",
480 + contract_home(parent)
481 + );
482 + return;
483 + }
484 + if let Err(why) = std::fs::write(path, seeded.to_toml()) {
485 + let _ = writeln!(
486 + err,
487 + "alloy theme: cannot record what was applied to {}: {why}",
488 + contract_home(path)
489 + );
490 + }
491 + }
492 +
329 493 /// Every file under `root`, as paths relative to it.
330 494 ///
331 495 /// The manifest is derived rather than listed, so adding a template to
@@ -359,7 +523,13 @@
359 523 }
360 524 }
361 525
362 - fn apply_one(trees: &Trees, mode: &str, relative: &Path, options: &Options) -> Outcome {
526 + fn apply_one(
527 + trees: &Trees,
528 + mode: &str,
529 + relative: &Path,
530 + options: &Options,
531 + seeded: &mut Seeded,
532 + ) -> Outcome {
363 533 let source = trees.source(mode).join(relative);
364 534 let Ok(want) = std::fs::read(&source) else {
365 535 return Outcome::Failed(format!("cannot read {}", source.display()));
@@ -371,7 +541,14 @@
371 541 let target = trees.home.join(relative);
372 542 let current = std::fs::read(&target).ok();
373 543
374 - match decide(current.as_deref(), &want, other.as_deref()) {
544 + let decision = decide(
545 + current.as_deref(),
546 + &want,
547 + other.as_deref(),
548 + seeded.get(relative),
549 + );
550 +
551 + let outcome = match decision {
375 552 Decision::Current => Outcome::Current,
376 553 Decision::Keep if !options.force => Outcome::Kept,
377 554 Decision::Keep => {
@@ -399,7 +576,20 @@
399 576 Err(why) => Outcome::Failed(why),
400 577 }
401 578 }
579 + };
580 +
581 + // Recorded from the outcome and not from the decision, so it says what is on
582 + // disk rather than what was intended: a write that failed records nothing,
583 + // and `--force` over an edited file records, which the decision alone would
584 + // have got backwards in both directions. Always the digest of the render,
585 + // never of what was found there, so the ledger cannot learn a file the
586 + // console did not write. A `Kept` file leaves the old entry in place, so
587 + // reverting an edit is recognised again.
588 + if !options.dry_run && matches!(outcome, Outcome::Applied | Outcome::Current) {
589 + seeded.record(relative, digest(&want));
402 590 }
591 +
592 + outcome
403 593 }
404 594
405 595 /// What the pristine test decides for one file.
@@ -418,13 +608,28 @@
418 608 /// Byte-exact rather than a normalizing compare: the files are rendered by one
419 609 /// program from one template, so anything that differs at all differs because
420 610 /// somebody typed it.
421 - fn decide(current: Option<&[u8]>, want: &[u8], other: Option<&[u8]>) -> Decision {
611 + ///
612 + /// `seeded` is what the ledger says this console last wrote here, and it is
613 + /// what carries the answer across an image upgrade: the two renders can only
614 + /// recognise the render shipped *now*, while the digest recognises the one that
615 + /// was shipped when the file was written. Checked last, so it never overrides a
616 + /// live render match, and only ever turns a `Keep` into a `Write`.
617 + fn decide(
618 + current: Option<&[u8]>,
619 + want: &[u8],
620 + other: Option<&[u8]>,
621 + seeded: Option<&str>,
622 + ) -> Decision {
422 623 match current {
423 624 // A home that predates a newly added template. Seeding it is the same
424 625 // thing `useradd` would have done had the file existed then.
425 626 None => Decision::Write,
426 627 Some(current) if current == want => Decision::Current,
427 628 Some(current) if other.is_some_and(|other| current == other) => Decision::Write,
629 + // Ours, from an image that has since moved. The digest is computed only
630 + // here, on the files about to be called the user's, so an ordinary login
631 + // where everything is current hashes nothing.
632 + Some(current) if seeded.is_some_and(|seeded| digest(current) == seeded) => Decision::Write,
428 633 Some(_) => Decision::Keep,
429 634 }
430 635 }
@@ -503,6 +708,7 @@
503 708 day: root.join("skel"),
504 709 night: root.join("skel-night"),
505 710 home: root.join("home"),
711 + seeded: root.join("home").join(SEEDED),
506 712 };
507 713 for dir in [&trees.day, &trees.night, &trees.home] {
508 714 std::fs::create_dir_all(dir).unwrap();
@@ -647,13 +853,13 @@
647 853
648 854 #[test]
649 855 fn a_file_that_is_not_there_yet_is_written() {
650 - assert_eq!(decide(None, b"day", Some(b"night")), Decision::Write);
856 + assert_eq!(decide(None, b"day", Some(b"night"), None), Decision::Write);
651 857 }
652 858
653 859 #[test]
654 860 fn a_file_already_in_this_mode_is_left_alone() {
655 861 assert_eq!(
656 - decide(Some(b"day"), b"day", Some(b"night")),
862 + decide(Some(b"day"), b"day", Some(b"night"), None),
657 863 Decision::Current
658 864 );
659 865 }
@@ -661,7 +867,7 @@
661 867 #[test]
662 868 fn a_file_holding_the_other_render_is_the_switch() {
663 869 assert_eq!(
664 - decide(Some(b"night"), b"day", Some(b"night")),
870 + decide(Some(b"night"), b"day", Some(b"night"), None),
665 871 Decision::Write
666 872 );
667 873 }
@@ -669,7 +875,7 @@
669 875 #[test]
670 876 fn a_file_matching_neither_render_belongs_to_the_user() {
671 877 assert_eq!(
672 - decide(Some(b"mine"), b"day", Some(b"night")),
878 + decide(Some(b"mine"), b"day", Some(b"night"), None),
673 879 Decision::Keep
674 880 );
675 881 }
@@ -678,11 +884,247 @@
678 884 // The conservative branch is the one that has to hold when a source is gone.
679 885 #[test]
680 886 fn without_a_counterpart_an_unfamiliar_file_is_still_kept() {
681 - assert_eq!(decide(Some(b"mine"), b"day", None), Decision::Keep);
887 + assert_eq!(decide(Some(b"mine"), b"day", None, None), Decision::Keep);
888 + }
889 +
890 + // ---- the ledger, which is the pristine test across an image ----
891 +
892 + // The case the two-render test cannot answer, and the reason the ledger
893 + // exists. The file on disk is a render neither source carries any more,
894 + // because the template moved with the image; the digest says the console
895 + // put it there, so the fix lands.
896 + #[test]
897 + fn a_render_from_an_older_image_is_still_ours() {
898 + assert_eq!(
899 + decide(
900 + Some(b"old day"),
901 + b"new day",
902 + Some(b"new night"),
903 + Some(&digest(b"old day"))
904 + ),
905 + Decision::Write
906 + );
907 + }
908 +
909 + // The same shape with the digest of something else, which is what a real
910 + // edit looks like: a file matching no render and no record.
911 + #[test]
912 + fn a_record_of_a_different_file_does_not_claim_this_one() {
913 + assert_eq!(
914 + decide(
915 + Some(b"mine"),
916 + b"new day",
917 + Some(b"new night"),
918 + Some(&digest(b"old day"))
919 + ),
920 + Decision::Keep
921 + );
922 + }
923 +
924 + // The ledger only ever adds a Write. A live render match is the stronger
925 + // answer and must not be downgraded by a stale record, or a file already
926 + // correct would be rewritten every login.
927 + #[test]
928 + fn a_stale_record_does_not_override_a_current_file() {
929 + assert_eq!(
930 + decide(
931 + Some(b"day"),
932 + b"day",
933 + Some(b"night"),
934 + Some(&digest(b"something else"))
935 + ),
936 + Decision::Current
937 + );
938 + }
939 +
940 + #[test]
941 + fn a_damaged_ledger_reads_as_an_empty_one() {
942 + assert_eq!(Seeded::parse("this is not toml {{{"), Seeded::default());
943 + }
944 +
945 + #[test]
946 + fn the_ledger_round_trips_through_its_own_format() {
947 + let mut seeded = Seeded::default();
948 + seeded.record(Path::new(".config/mako/config"), digest(b"rendered"));
949 +
950 + let read_back = Seeded::parse(&seeded.to_toml());
951 +
952 + assert_eq!(read_back, seeded);
953 + assert_eq!(
954 + read_back.get(Path::new(".config/mako/config")),
955 + Some(digest(b"rendered").as_str())
956 + );
682 957 }
683 958
684 959 // ---- the verb over real trees ----
685 960
961 + // The whole point, end to end: a home seeded at one image, a template that
962 + // moves under it, and the fix arriving anyway. Before the ledger this run
963 + // printed "kept your edited ~/.config/mako/config" about a file nobody had
964 + // touched, and the fix reached nobody already running.
965 + #[test]
966 + fn an_untouched_file_survives_the_image_moving_under_it() {
967 + let scratch = Scratch::new("image-moved");
968 + scratch.shipped(".config/mako/config", "old day", "old night");
969 + scratch.home(".config/mako/config", "old day");
970 +
971 + // The login on the old image. Nothing to do, and the home is enrolled.
972 + let (_, _) = scratch.run(DAY, &plain());
973 +
974 + // The new image lands, carrying a fixed template.
975 + scratch.shipped(".config/mako/config", "new day", "new night");
976 + let (out, _) = scratch.run(DAY, &plain());
977 +
978 + assert_eq!(
979 + scratch.read_home(".config/mako/config").as_deref(),
980 + Some("new day")
981 + );
982 + assert!(
983 + !out.contains("kept"),
984 + "a file nobody edited was called edited: {out}"
985 + );
986 + }
987 +
988 + // The other half, and the one that must not regress: the same image move,
989 + // over a file the user did edit. The ledger holds a digest for it, and the
990 + // digest is of the render rather than of the edit, so it does not match.
991 + #[test]
992 + fn an_edited_file_survives_the_image_moving_under_it() {
993 + let scratch = Scratch::new("image-moved-edited");
994 + scratch.shipped(".config/mako/config", "old day", "old night");
995 + scratch.home(".config/mako/config", "old day");
996 + let (_, _) = scratch.run(DAY, &plain());
997 +
998 + scratch.home(".config/mako/config", "mine, hard won");
999 + scratch.shipped(".config/mako/config", "new day", "new night");
1000 + let (out, _) = scratch.run(DAY, &plain());
1001 +
1002 + assert_eq!(
1003 + scratch.read_home(".config/mako/config").as_deref(),
1004 + Some("mine, hard won")
1005 + );
1006 + assert!(out.contains("kept"), "the edit was not reported: {out}");
1007 + }
1008 +
1009 + // A home the console has never run against, whose file the user had already
1010 + // edited before the ledger existed. There is no record to consult and there
1011 + // must never be one invented: the edit is kept, and stays kept.
1012 + #[test]
1013 + fn an_edit_made_before_the_ledger_existed_is_never_adopted() {
1014 + let scratch = Scratch::new("pre-ledger-edit");
1015 + scratch.shipped(".config/mako/config", "day", "night");
1016 + scratch.home(".config/mako/config", "mine");
1017 +
1018 + let (out, _) = scratch.run(DAY, &plain());
1019 + assert!(out.contains("kept"), "{out}");
1020 +
1021 + // Second run, now that a ledger file exists for the other paths.
1022 + scratch.shipped(".config/mako/config", "moved day", "moved night");
1023 + let (out, _) = scratch.run(DAY, &plain());
1024 +
1025 + assert_eq!(
1026 + scratch.read_home(".config/mako/config").as_deref(),
1027 + Some("mine")
1028 + );
1029 + assert!(out.contains("kept"), "{out}");
1030 + }
1031 +
1032 + // `--force` is the escape hatch the transition note points at, so the file
1033 + // it rewrites has to be recorded, or the machine is back where it started
1034 + // at the next image.
1035 + #[test]
1036 + fn force_enrolls_the_file_it_overwrote() {
1037 + let scratch = Scratch::new("force-enrols");
1038 + scratch.shipped(".config/mako/config", "day", "night");
1039 + scratch.home(".config/mako/config", "mine");
1040 +
1041 + let forced = Options {
Lines truncated