From a8b9331c1ce6642848a980b0247ba261d5fa1963 Mon Sep 17 00:00:00 2001 From: Chris Hodapp Date: Sat, 17 Oct 2026 11:33:47 -0700 Subject: [PATCH 1/4] feat(integrations): check drift between every activated configuration and the one above it Drift detection was written for one pair: a home inside a NixOS machine compared `/etc/caisson-home-manager/source.json` with the machine it was built for. It is now a property of the framework. An integration whose configurations are activated passes `activation` to `mkModuleConfiguration`: `identity`, the store path of the childless evaluation, and `marker`, the file its activation writes that identity into. NixOS, darwin and system-manager record the system and `/etc/caisson/marker.json`; a home records its activation package and a file in the state directory of its user; a hjem configuration records its manifest file and a file in its directory. A machine `standsAlone`: it is applied on its own host, so it checks nothing above it. `lib.caisson.integrations` gains `activatingAncestorOf`, `markerOf`, `checksMarkerOf` and `driftCheckOf`. Each activating class writes its marker from its `default` module (`activation-marker` for nixos, darwin, systemManager, homeManager and hjem) when a configuration declared inside it checks it. A home runs `driftCheckOf` against the nearest activating configuration above it from the home-manager module `activation-drift`, so a home inside a darwin or system-manager machine is checked as one inside NixOS is. caisson now registers a `default` home-manager module, which imports `activation-drift` and `activation-marker`, and a `default` hjem module, which imports `activation-marker`. The NixOS module `home-manager-source-marker` and the home-manager module `nixos-source-marker` are removed. The option `caisson.home-manager.machineDrift.warn` becomes `caisson.drift.warn`, and the marker field `baseSystemOutPath` becomes `identity`. Co-Authored-By: Claude Opus 5.5 --- lib-overlays/darwin/default.nix | 4 + lib-overlays/hjem/default.nix | 16 ++ lib-overlays/home-manager/default.nix | 11 ++ lib-overlays/integrations/default.nix | 157 +++++++++++++++++- lib-overlays/nixos/default.nix | 4 + lib-overlays/system-manager/default.nix | 4 + modules/darwin/activation-marker | 1 + modules/darwin/default/default.nix | 1 + modules/hjem/activation-marker/default.nix | 36 ++++ modules/hjem/default/default.nix | 15 ++ .../homeManager/activation-drift/default.nix | 60 +++++++ .../homeManager/activation-marker/default.nix | 42 +++++ modules/homeManager/default/default.nix | 21 +++ .../nixos-source-marker/default.nix | 135 --------------- modules/nixos/activation-marker/default.nix | 77 +++++++++ modules/nixos/default/caisson/forChildren.nix | 15 +- modules/nixos/default/default.nix | 22 +-- .../home-manager-source-marker/default.nix | 110 ------------ modules/systemManager/activation-marker | 1 + modules/systemManager/default/default.nix | 1 + tests/pinned-world/default.nix | 29 ++-- tests/unit/lib-overlays.nix | 15 +- 22 files changed, 490 insertions(+), 287 deletions(-) create mode 120000 modules/darwin/activation-marker create mode 100644 modules/hjem/activation-marker/default.nix create mode 100644 modules/hjem/default/default.nix create mode 100644 modules/homeManager/activation-drift/default.nix create mode 100644 modules/homeManager/activation-marker/default.nix create mode 100644 modules/homeManager/default/default.nix delete mode 100644 modules/homeManager/nixos-source-marker/default.nix create mode 100644 modules/nixos/activation-marker/default.nix delete mode 100644 modules/nixos/home-manager-source-marker/default.nix create mode 120000 modules/systemManager/activation-marker diff --git a/lib-overlays/darwin/default.nix b/lib-overlays/darwin/default.nix index bcaff53f..173985be 100644 --- a/lib-overlays/darwin/default.nix +++ b/lib-overlays/darwin/default.nix @@ -164,6 +164,10 @@ perSystem = true; defaultPkgs = args.defaultPkgs or null; inherit exportsTo; + # A darwin configuration is activated on a machine; the + # configurations with an activation declared inside it check + # its marker. + activation = final.caisson.integrations.machineActivation; evaluate = evaluate args; }; diff --git a/lib-overlays/hjem/default.nix b/lib-overlays/hjem/default.nix index 14ab0b94..658b4e10 100644 --- a/lib-overlays/hjem/default.nix +++ b/lib-overlays/hjem/default.nix @@ -292,9 +292,25 @@ perSystem = true; defaultPkgs = args.defaultPkgs or null; inherit exportsTo; + # An hjem configuration is activated for its user. Its + # identity is its manifest file; its marker is a file in the + # directory of the user, which the hjem module + # `activation-marker` adds to the files of the configuration. + # The configurations with an activation declared inside it + # check the marker. + activation = { + identity = manifest: manifest.outputs.manifestFile.outPath; + marker = + manifest: + "${manifest.value.config.directory}/${markerDirectory}/${final.caisson.integrations.markerFileName manifest}"; + }; evaluate = evaluate args; }; + # The directory, relative to the directory of the user, that holds + # the marker of an hjem configuration. + markerDirectory = ".local/state/caisson/markers"; + integration = final.caisson.integrations.mkIntegration { name = "hjem"; class = "hjem"; diff --git a/lib-overlays/home-manager/default.nix b/lib-overlays/home-manager/default.nix index a8f62a9e..b1387f3d 100644 --- a/lib-overlays/home-manager/default.nix +++ b/lib-overlays/home-manager/default.nix @@ -516,6 +516,17 @@ perSystem = true; defaultPkgs = args.defaultPkgs or null; inherit exportsTo; + # A home is activated for its user. Its identity is its + # activation package; its marker is a file in the state + # directory of the user, which the home-manager module + # `activation-marker` writes. The configurations with an + # activation declared inside the home check it. + activation = { + identity = manifest: manifest.outputs.activationPackage.outPath; + marker = + manifest: + "\${XDG_STATE_HOME:-$HOME/.local/state}/caisson/markers/${selection.markerFileName manifest}"; + }; evaluate = evaluation variant args; }; diff --git a/lib-overlays/integrations/default.nix b/lib-overlays/integrations/default.nix index ad8543ff..aaba3fea 100644 --- a/lib-overlays/integrations/default.nix +++ b/lib-overlays/integrations/default.nix @@ -265,19 +265,37 @@ # one output. It is recorded on the manifest, and applied where # the configuration is passed up (`entriesOf`). Null for a # configuration that is not published under a name. + # + # `activation` is given by an integration whose configurations + # are activated on a machine or for a user: NixOS, darwin and + # system-manager machines, homes, hjem configurations. It holds + # two functions of a manifest of the configuration. `identity` + # returns the store path that names what the activation applies, + # computed from the childless evaluation, or null where there is + # none. `standsAlone`, when true, says the configuration is + # applied on its own, as a machine is on its own host, so that it + # checks no configuration above it. `marker` returns the path of the file the activation + # writes that identity into, as shell text, which may name + # variables such as `$HOME`. It is recorded on the manifest. A + # configuration with an activation declared beneath one checks + # the marker of the nearest such configuration above it when it + # is activated (`driftCheckOf`). Null for a configuration that is + # not activated. mkModuleConfiguration = { type, perSystem ? false, defaultPkgs ? null, exportsTo ? null, + activation ? null, evaluate, }: final.caisson-core.mkConfiguration { inherit type perSystem; record = (if defaultPkgs == null then { } else { inherit defaultPkgs; }) - // (if exportsTo == null then { } else { inherit exportsTo; }); + // (if exportsTo == null then { } else { inherit exportsTo; }) + // (if activation == null then { } else { inherit activation; }); evaluate = view: let @@ -597,6 +615,137 @@ found ) null types; + # The nearest configuration above `manifest` that has an + # activation (`mkModuleConfiguration`'s `activation`), as its + # childless manifest, or null when there is none. A home inside a + # NixOS machine finds the machine; an nvf configuration inside + # that home finds the home. + activatingAncestorOf = + manifest: + let + found = builtins.filter (ancestor: (ancestor.activation or null) != null) ( + manifest.ancestors or [ ] + ); + in + if found == [ ] then null else last found; + + # The `activation` of a machine: a NixOS, darwin or system-manager + # configuration. Its identity is its system, `toplevel`, null for + # a configuration that builds none, as the nixos-minimal + # integration can produce. Its marker is a file under `/etc`, + # which the module `activation-marker` of its class writes. + # + # A machine is activated on its own host. A machine declared + # inside another, such as an image, is applied on a different host + # than the machine above, and the marker under `/etc` that it + # finds there is its own, so it checks nothing above it: + # `standsAlone`. + machineActivation = { + standsAlone = true; + identity = + manifest: if (manifest.outputs or { }) ? toplevel then manifest.outputs.toplevel.outPath else null; + marker = _manifest: "/etc/caisson/marker.json"; + }; + + # The file name of the marker of a configuration activated for a + # user: the integration and the name the configuration is declared + # under, with the characters the shell would read inside double + # quotes, and the path separator, replaced. + markerFileName = + manifest: + "${manifest.type}-${ + builtins.replaceStrings [ "/" "\"" "$" "`" "\\" ] [ "_" "_" "_" "_" "_" ] ( + manifest.name or "default" + ) + }.json"; + + # What the activation of the configuration of `manifest` writes + # into its marker: the identity of its childless evaluation, as a + # plain string, so that the marker does not depend on that + # evaluation. `path` is the shell text of the path of the marker. + # Null when the configuration has no activation or no identity. + markerOf = + manifest: + let + activation = manifest.activation or null; + childless = manifest.childlessManifest or manifest; + identity = if activation == null then null else activation.identity childless; + in + if identity == null then + null + else + { + path = activation.marker childless; + identity = builtins.unsafeDiscardStringContext identity; + text = builtins.toJSON { identity = builtins.unsafeDiscardStringContext identity; }; + }; + + # True when `entry`, an entry of `caisson.exports.configurations` + # of the configuration of `manifest`, checks the marker of that + # configuration when it is activated: the entry has an activation, + # and the configuration is the nearest one above it that has an + # activation. + checksMarkerOf = + manifest: entry: + let + ancestor = activatingAncestorOf entry.manifest; + in + (entry.manifest.activation or null) != null + && !(entry.manifest.activation.standsAlone or false) + && ancestor != null + && builtins.length (ancestor.ancestors or [ ]) == builtins.length (manifest.ancestors or [ ]); + + # The shell text with which the activation of the configuration of + # `manifest` checks for drift: it compares the marker of the + # nearest configuration above with an activation against the + # identity of that configuration as this configuration was built + # for it, and prints a warning to standard error when the marker is + # missing or names another identity. It does not stop the + # activation. When that configuration activates this one, its + # marker already belongs to the generation being activated, and + # the check prints nothing. Empty when there is no such + # configuration, or it has no identity. `jq` is the path of a jq + # executable, and `optionText` names the option that turns the + # warning off, for the message. + driftCheckOf = + { jq, optionText }: + manifest: + let + ancestor = + if (manifest.activation.standsAlone or false) then null else activatingAncestorOf manifest; + expected = if ancestor == null then null else ancestor.activation.identity ancestor; + marker = ancestor.activation.marker ancestor; + above = "the ${ancestor.type} configuration `${ancestor.name or "(unnamed)"}`"; + self = "the ${manifest.type} configuration `${manifest.name or "(unnamed)"}`"; + # The identity, quoted for the shell. A store path holds no + # single quote, so quoting it whole is enough. + escaped = "'${builtins.unsafeDiscardStringContext expected}'"; + in + if expected == null then + "" + else + '' + _caissonMarker="${marker}" + if [ ! -f "$_caissonMarker" ]; then + echo "WARNING: $_caissonMarker does not exist." >&2 + echo " ${self} is declared inside ${above}," >&2 + echo " and the activation of ${above} has not recorded what it applied." >&2 + echo " Activate ${above} as this configuration was built with it," >&2 + echo " or set ${optionText} to false in ${self}." >&2 + else + _caissonApplied="$(${jq} -r '.identity // empty' < "$_caissonMarker")" + if [ "$_caissonApplied" != ${escaped} ]; then + echo "WARNING: ${self} was built for a version of ${above} that differs from the version that is applied." >&2 + echo " applied (configurations declared inside it left out):" >&2 + echo " ''${_caissonApplied:-unknown}" >&2 + echo " built for (configurations declared inside it left out):" >&2 + echo " "${escaped} >&2 + echo " Activate ${above} as this configuration was built with it," >&2 + echo " or set ${optionText} to false in ${self}." >&2 + fi + fi + ''; + isManifest = value: builtins.isAttrs value && (value._type or null) == "caisson-manifest"; # How a configuration is published: the exports its manifest @@ -895,6 +1044,12 @@ integrationsIn integrationsDeclaringIn nearestOf + activatingAncestorOf + machineActivation + markerFileName + markerOf + checksMarkerOf + driftCheckOf mkEvaluation publish topValue diff --git a/lib-overlays/nixos/default.nix b/lib-overlays/nixos/default.nix index 42e02d6d..591fc813 100644 --- a/lib-overlays/nixos/default.nix +++ b/lib-overlays/nixos/default.nix @@ -114,6 +114,10 @@ perSystem = true; defaultPkgs = args.defaultPkgs or null; inherit exportsTo; + # A NixOS configuration is activated on a machine; the + # configurations with an activation declared inside it check + # its marker. + activation = final.caisson.integrations.machineActivation; evaluate = evaluate variant args; }; diff --git a/lib-overlays/system-manager/default.nix b/lib-overlays/system-manager/default.nix index 493ad60a..2a6f1d93 100644 --- a/lib-overlays/system-manager/default.nix +++ b/lib-overlays/system-manager/default.nix @@ -299,6 +299,10 @@ perSystem = true; defaultPkgs = args.defaultPkgs or null; inherit exportsTo; + # A system-manager configuration is activated on a machine; + # the configurations with an activation declared inside it + # check its marker. + activation = final.caisson.integrations.machineActivation; evaluate = evaluate args; }; diff --git a/modules/darwin/activation-marker b/modules/darwin/activation-marker new file mode 120000 index 00000000..d4a211a0 --- /dev/null +++ b/modules/darwin/activation-marker @@ -0,0 +1 @@ +../nixos/activation-marker \ No newline at end of file diff --git a/modules/darwin/default/default.nix b/modules/darwin/default/default.nix index 2982630a..e7baecdc 100644 --- a/modules/darwin/default/default.nix +++ b/modules/darwin/default/default.nix @@ -28,6 +28,7 @@ (mkModule ./caisson) closure-lib.caisson.modules.darwin.home-manager-activation closure-lib.caisson.modules.darwin.hjem-activation + closure-lib.caisson.modules.darwin.activation-marker ] else [ ]; diff --git a/modules/hjem/activation-marker/default.nix b/modules/hjem/activation-marker/default.nix new file mode 100644 index 00000000..81a6d445 --- /dev/null +++ b/modules/hjem/activation-marker/default.nix @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: MIT +# +# An hjem module with which an hjem configuration records what it +# applied, so that the configurations declared inside it can detect +# drift. It is the counterpart, for an hjem configuration, of the +# machine module `activation-marker`: see there for why. +# +# The module adds a file to the files of the configuration, +# `.local/state/caisson/markers/hjem-.json` in the directory of +# the user, holding the identity of the childless evaluation of the +# configuration: its manifest file without the configurations declared +# inside it. hjem links the file into place when it applies the +# configuration. +# +# caisson's `default` hjem module imports this module. The file is +# added only when the configuration has a configuration declared inside +# it that checks it, and not in the childless evaluation. +{ ... }: +{ + config, + lib, + ... +}: +let + inherit (lib.caisson) evalManifest; + selection = lib.caisson.integrations; + + checked = builtins.any (selection.checksMarkerOf evalManifest) config.caisson.exports.configurations; + + marker = selection.markerOf evalManifest; +in +{ + config = lib.mkIf (!evalManifest.childless && checked && marker != null) { + files.".local/state/caisson/markers/${selection.markerFileName evalManifest}".text = marker.text; + }; +} diff --git a/modules/hjem/default/default.nix b/modules/hjem/default/default.nix new file mode 100644 index 00000000..f97a6379 --- /dev/null +++ b/modules/hjem/default/default.nix @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: MIT +# +# The `default` hjem module caisson registers. A hjem configuration +# imports every registered module named `default` unless it passes +# `moduleImports`, so this is what it gets from caisson when it does not +# select its modules itself: `activation-marker`, which records what the +# hjem configuration applied, for the configurations declared inside it +# to check. +{ closure-lib, ... }: +{ ... }: +{ + imports = [ + closure-lib.caisson.modules.hjem.activation-marker + ]; +} diff --git a/modules/homeManager/activation-drift/default.nix b/modules/homeManager/activation-drift/default.nix new file mode 100644 index 00000000..dc0ee1a4 --- /dev/null +++ b/modules/homeManager/activation-drift/default.nix @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: MIT +# +# A home-manager module with which a home declared inside another +# configuration that has an activation, such as a NixOS, darwin or +# system-manager machine, detects drift when it is activated. +# +# The user can activate such a home by hand, with `home-manager switch`, +# built from the source the user has checked out. That source can +# describe a different version of the configuration above than the one +# that is applied. The configuration above records what it applied in a +# marker (the module `activation-marker` of its class). A step of the +# activation of the home compares that marker with the identity of the +# configuration above as the home was built for it, and prints a +# warning when they differ or when the marker is missing. The step does +# not stop the activation. When the configuration above activates the +# home, its marker belongs to the generation being activated, and the +# step prints nothing. +# +# The check is `lib.caisson.integrations.driftCheckOf`, the same check +# every configuration with an activation runs against the nearest +# configuration above it that has one. A home with no such +# configuration above it has nothing to check, and the module then +# defines no step. +# +# caisson's `default` home-manager module imports this module. +{ ... }: +{ + config, + lib, + pkgs, + ... +}: +let + check = lib.caisson.integrations.driftCheckOf { + jq = lib.getExe' pkgs.jq "jq"; + optionText = "`caisson.drift.warn`"; + } lib.caisson.evalManifest; +in +{ + options.caisson.drift.warn = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether the activation of this home prints a warning when the home + was built for a version of the configuration it is declared inside + that differs from the version that is applied. + + The configuration above, such as the machine, records what it + applied in a marker file. The activation of this home compares the + marker with the configuration above as it is in the source this + home was built from. The comparison leaves out the configurations + declared inside the configuration above, so a change to a home + does not cause a warning. + ''; + }; + + config = lib.mkIf (config.caisson.drift.warn && check != "") { + home.activation.caissonDrift = lib.hm.dag.entryBefore [ "writeBoundary" ] check; + }; +} diff --git a/modules/homeManager/activation-marker/default.nix b/modules/homeManager/activation-marker/default.nix new file mode 100644 index 00000000..8654916e --- /dev/null +++ b/modules/homeManager/activation-marker/default.nix @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: MIT +# +# A home-manager module with which a home records what it applied, so +# that the configurations declared inside it can detect drift. It is the +# counterpart, for a home, of the machine module `activation-marker`: +# see there for why. +# +# A step of the activation of the home writes the identity of the +# childless evaluation of the home, its activation package without the +# configurations declared inside it, into a file in the state directory +# of the user, `$XDG_STATE_HOME/caisson/markers/home-manager-.json`. +# A configuration declared inside the home, such as an nvf +# configuration, compares that file with the identity it was built for +# when it is activated by hand. The step runs after the files of the +# home are linked, so a configuration the home activates after it reads +# the identity of the generation being activated. +# +# caisson's `default` home-manager module imports this module. The file +# is written only when the home has a configuration declared inside it +# that checks it, and not in the childless evaluation. +{ ... }: +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib.caisson) evalManifest; + selection = lib.caisson.integrations; + + checked = builtins.any (selection.checksMarkerOf evalManifest) config.caisson.exports.configurations; + + marker = selection.markerOf evalManifest; +in +{ + config = lib.mkIf (!evalManifest.childless && checked && marker != null) { + home.activation.caissonActivationMarker = lib.hm.dag.entryAfter [ "linkGeneration" ] '' + run ${lib.getExe' pkgs.coreutils "install"} -D -m 0644 ${pkgs.writeText "caisson-marker.json" marker.text} "${marker.path}" + ''; + }; +} diff --git a/modules/homeManager/default/default.nix b/modules/homeManager/default/default.nix new file mode 100644 index 00000000..02a02e7e --- /dev/null +++ b/modules/homeManager/default/default.nix @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: MIT +# +# The `default` home-manager module caisson registers. A home imports +# every registered module named `default` unless it passes +# `moduleImports`, so this is what a home gets from caisson when it does +# not select its modules itself: +# +# - `activation-drift`, with which the activation of the home warns when +# the home was built for a different version of the configuration it +# is declared inside, such as its machine; +# - `activation-marker`, with which the activation of the home records +# what it applied, for the configurations declared inside the home to +# check in turn. +{ closure-lib, ... }: +{ ... }: +{ + imports = [ + closure-lib.caisson.modules.homeManager.activation-drift + closure-lib.caisson.modules.homeManager.activation-marker + ]; +} diff --git a/modules/homeManager/nixos-source-marker/default.nix b/modules/homeManager/nixos-source-marker/default.nix deleted file mode 100644 index ffcd3370..00000000 --- a/modules/homeManager/nixos-source-marker/default.nix +++ /dev/null @@ -1,135 +0,0 @@ -# SPDX-License-Identifier: MIT -# -# A home-manager module for a home that is declared inside a NixOS -# configuration. The module adds a step to the activation of the home. -# The step prints a warning if the home was built for a NixOS -# configuration that differs from the machine that is running. -# -# Why -# -# The machine activates the homes declared inside its NixOS -# configuration. The user can also activate such a home by hand, with -# `home-manager switch`. `home-manager switch` builds the home from -# the source the user has checked out. -# -# That source can describe a different machine than the machine that -# is running. This happens when the user changed the NixOS -# configuration and has not switched the machine yet. It also happens -# when the checkout is older than the machine. -# -# A home reads settings of its machine. A home built from such a -# source has read the settings of a machine that is not the machine -# the home is activated on. -# -# How -# -# The machine has a file, `/etc/caisson-home-manager/source.json`, -# that names the NixOS configuration the machine is running. The NixOS -# module `home-manager-source-marker` writes the file. The field -# `baseSystemOutPath` of the file is a store path. The store path is -# the system that the childless evaluation of the NixOS configuration -# builds. The childless evaluation is the evaluation that leaves out -# the homes. -# -# This module computes the same store path from the source the home is -# built from. The module reads the childless evaluation of the NixOS -# configuration through the manifest of the home. -# -# The step in the activation compares the store path this module -# computed with the store path in the file. The step prints a warning -# when the two store paths differ. The step also prints a warning when -# the file does not exist. The step does not stop the activation. -# -# When the machine activates the home, the file on the machine already -# belongs to the generation that is being activated. The two store -# paths are then equal, and the step prints nothing. -# -# Cost -# -# Building the activation package of the home evaluates the system of -# the childless evaluation of the NixOS configuration. -# -# Which homes import this module -# -# caisson's `default` NixOS module gives this module to the homes -# declared inside the NixOS configuration, through -# `caisson.forChildren`. Such a home imports this module by default. -# A home that passes `moduleImports` selects its modules itself. That -# home imports this module only if the home lists it. -# -# The module reads the NixOS configuration through the manifest of the -# home. A home with no NixOS configuration above it cannot import this -# module. -{ ... }: -# `lib` is the library of the home, and `config` is the configuration -# of the home. -{ - lib, - config, - pkgs, - ... -}: -let - # The manifest of the childless evaluation of the NixOS configuration - # the home is declared inside. - machine = lib.caisson.evalManifest.nearest.nixos; - - # The store path of the system that the childless evaluation builds. - # - # The store path is kept as a plain string. A string that refers to - # the system would make the home depend on the system. Building the - # home would then build the system. - # - # The value is null for a NixOS configuration that builds no system. - # The nixos-minimal integration can produce such a NixOS - # configuration. - expected = - if machine.outputs ? toplevel then - builtins.unsafeDiscardStringContext machine.outputs.toplevel.outPath - else - null; - - marker = "/etc/caisson-home-manager/source.json"; - jq = lib.getExe' pkgs.jq "jq"; -in -{ - options.caisson.home-manager.machineDrift.warn = lib.mkOption { - type = lib.types.bool; - default = true; - description = '' - Whether the activation of this home prints a warning when the - home was built for a NixOS configuration that differs from the - machine that is running. - - The file `/etc/caisson-home-manager/source.json` on the machine - names the NixOS configuration the machine is running. The - activation of this home compares the file with the NixOS - configuration in the source this home was built from. - - The comparison leaves the homes out. A change to a home does not - cause a warning. - ''; - }; - - config = lib.mkIf (config.caisson.home-manager.machineDrift.warn && expected != null) { - home.activation.caissonMachineDrift = lib.hm.dag.entryBefore [ "writeBoundary" ] '' - if [ ! -f ${marker} ]; then - echo "WARNING: ${marker} does not exist." >&2 - echo " This home is declared inside the NixOS configuration \`${machine.name}\`," >&2 - echo " and the machine that is running does not record which NixOS configuration it runs." >&2 - echo " Switch the machine to the NixOS configuration this home was built with." >&2 - else - _caissonRunning="$(${jq} -r '.baseSystemOutPath // empty' < ${marker})" - if [ "$_caissonRunning" != ${lib.escapeShellArg expected} ]; then - echo "WARNING: this home was built for a NixOS configuration that differs from the machine that is running." >&2 - echo " the machine runs (homes left out):" >&2 - echo " ''${_caissonRunning:-unknown}" >&2 - echo " this home was built for (homes left out):" >&2 - echo " ${expected}" >&2 - echo " Switch the machine to the NixOS configuration this home was built with," >&2 - echo " or set \`caisson.home-manager.machineDrift.warn = false\` in the home." >&2 - fi - fi - ''; - }; -} diff --git a/modules/nixos/activation-marker/default.nix b/modules/nixos/activation-marker/default.nix new file mode 100644 index 00000000..559813e2 --- /dev/null +++ b/modules/nixos/activation-marker/default.nix @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: MIT +# +# A module with which a machine records what it applied, so that the +# configurations declared inside it can detect drift. +# +# Why +# +# A machine activates the configurations declared inside it: homes, +# hjem configurations, and the others that have an activation. A user +# can also activate such a configuration by hand, built from the source +# the user has checked out, for example with `home-manager switch`. +# That source can describe a different machine than the one that is +# applied: the user changed the machine and has not switched it yet, or +# the checkout is older than the machine. A configuration declared +# inside a machine reads settings of the machine, so it was then built +# for a machine that is not the one it is activated on. +# +# What the file holds +# +# The module writes `/etc/caisson/marker.json`, with one field, +# `identity`: the system that the childless evaluation of this machine +# builds, which is the evaluation without the configurations declared +# inside it. Changing one of those configurations leaves the identity +# the same; changing anything else about the machine changes it. +# +# A configuration declared inside the machine reads the same childless +# evaluation through its own manifest, so it computes the same identity +# from the same source. Its activation compares the two +# (`lib.caisson.integrations.driftCheckOf`), and warns when they differ. +# When the machine activates it, the file already belongs to the +# generation being activated, and the two are equal. +# +# The module is registered for the `nixos`, `darwin` and +# `systemManager` classes, under the same name, and the `default` +# module of each imports it. The file is written only when the machine +# has a configuration declared inside it that checks it, because +# computing the identity evaluates the machine a second time. It is not +# written in the childless evaluation, and not in an evaluation without +# `environment.etc`, which the nixos-minimal integration can produce. +{ closure-lib, ... }: +{ + config, + lib, + options, + ... +}: +let + inherit (lib.caisson) evalManifest; + selection = lib.caisson.integrations; + + hasEtc = options ? environment && options.environment ? etc; + + # The core module of the class of this evaluation, which declares + # `caisson.exports`. + coreModule = + { + darwin = closure-lib.caisson.modules.darwin.core; + system-manager = closure-lib.caisson.modules.systemManager.core; + } + .${evalManifest.type or "nixos"} or closure-lib.caisson.modules.nixos.core; + + checked = builtins.any (selection.checksMarkerOf evalManifest) config.caisson.exports.configurations; + + marker = selection.markerOf evalManifest; +in +{ + imports = [ coreModule ]; + + # `optionalAttrs` and not `mkIf` decides whether the option is defined + # at all: a definition under `mkIf false` is still a definition, and + # NixOS refuses a definition of an option that no module declares. + config = lib.optionalAttrs hasEtc ( + lib.mkIf (!evalManifest.childless && checked && marker != null) { + environment.etc."caisson/marker.json".text = marker.text; + } + ); +} diff --git a/modules/nixos/default/caisson/forChildren.nix b/modules/nixos/default/caisson/forChildren.nix index 2b5e74b1..8d745d1a 100644 --- a/modules/nixos/default/caisson/forChildren.nix +++ b/modules/nixos/default/caisson/forChildren.nix @@ -4,16 +4,10 @@ # # A configuration gives things to the configurations declared inside # it through the options under `caisson.forChildren`. This file adds -# two home-manager modules, `nixos-parent` and `nixos-source-marker`, -# to the modules that a home declared inside this NixOS configuration -# imports by default, and the hjem module `nixos-parent` to those of a -# hjem configuration declared inside it. -# -# `nixos-source-marker` is the module caisson registers as -# `modules/homeManager/nixos-source-marker`. It adds a step to the -# activation of the home. The step prints a warning if the home was -# built for a NixOS configuration that differs from the machine that -# is running. +# the home-manager module `nixos-parent` to the modules that a home +# declared inside this NixOS configuration imports by default, and the +# hjem module `nixos-parent` to those of a hjem configuration declared +# inside it. # # `nixos-parent` is the module caisson registers as # `modules/homeManager/nixos-parent`. It sets the options of a home @@ -35,7 +29,6 @@ { caisson.forChildren.extraDefaultModuleImports.homeManager = _lib: [ closure-lib.caisson.modules.homeManager.nixos-parent - closure-lib.caisson.modules.homeManager.nixos-source-marker ]; # The hjem module `nixos-parent` sets the home directory of a hjem diff --git a/modules/nixos/default/default.nix b/modules/nixos/default/default.nix index ed0fa4c7..da2aab6f 100644 --- a/modules/nixos/default/default.nix +++ b/modules/nixos/default/default.nix @@ -15,19 +15,19 @@ # machine's account. # # It also holds what a NixOS configuration does for the homes declared -# inside it, in three parts: +# inside it, in two parts: # -# - `./caisson/forChildren.nix` gives each of those homes two -# home-manager modules. The first module sets the options of the -# home that come from the machine. The second module prints a -# warning when the home is activated on a machine that differs from -# the machine the home was built for. +# - `./caisson/forChildren.nix` gives each of those homes the +# home-manager module `nixos-parent`, which sets the options of the +# home that come from the machine. # - The registered module `home-manager-activation` writes the # systemd units that activate those homes on the machine. -# - The registered module `home-manager-source-marker` writes a file -# on the machine. The file names the NixOS configuration the -# machine is running. The warning of the second module compares -# against that file. +# +# The registered module `activation-marker` writes a file on the +# machine that names what the machine applied. Every configuration with +# an activation declared inside the machine, a home among them, compares +# against that file when it is activated by hand, and warns when it was +# built for a different machine. # # The parts read options of caisson's core module. The core module # leaves its options out when the library of the evaluation is not a @@ -42,7 +42,7 @@ (mkModule ./caisson) closure-lib.caisson.modules.nixos.home-manager-activation closure-lib.caisson.modules.nixos.hjem-activation - closure-lib.caisson.modules.nixos.home-manager-source-marker + closure-lib.caisson.modules.nixos.activation-marker ] else [ ]; diff --git a/modules/nixos/home-manager-source-marker/default.nix b/modules/nixos/home-manager-source-marker/default.nix deleted file mode 100644 index a6f01252..00000000 --- a/modules/nixos/home-manager-source-marker/default.nix +++ /dev/null @@ -1,110 +0,0 @@ -# SPDX-License-Identifier: MIT -# -# A NixOS module for a NixOS configuration that has homes declared -# inside it. The module writes a file on the machine. The file names -# the NixOS configuration that the machine is running. A home reads -# the file when the home is activated. The home prints a warning if -# the home was built for a different NixOS configuration. -# -# Why a home needs this -# -# The machine activates the homes declared inside its NixOS -# configuration. The user can also activate such a home by hand, with -# `home-manager switch`. `home-manager switch` builds the home from -# the source the user has checked out. -# -# That source can describe a different machine than the machine that -# is running. This happens when the user changed the NixOS -# configuration and has not switched the machine yet. It also happens -# when the checkout is older than the machine. -# -# A home reads settings of its machine. A home built from such a -# source has read the settings of a machine that is not the machine -# the home is activated on. -# -# What the file holds -# -# The file is `/etc/caisson-home-manager/source.json`. It has one -# field, `baseSystemOutPath`, which is a store path. The store path is -# the system that the childless evaluation of this NixOS configuration -# builds. -# -# The childless evaluation is the evaluation of the NixOS -# configuration that leaves out the configurations declared inside the -# NixOS configuration. The homes are among those configurations. So -# the store path does not depend on the homes. Changing a home leaves -# the store path the same. Changing anything else about the machine -# changes the store path. -# -# A home declared inside the NixOS configuration can read the same -# childless evaluation through the manifest of the home. The home -# therefore computes the same store path from the same source. The -# home-manager module `nixos-source-marker` compares the store path -# the home computed with the store path in the file. -# -# Cost -# -# To write the file, the module evaluates the system of the childless -# evaluation. That is a second evaluation of the whole NixOS -# configuration. The module writes the file only when at least one -# home is declared inside the NixOS configuration. A machine with no -# homes does not pay for the second evaluation. -# -# Where the module defines nothing -# -# The module defines nothing in the childless evaluation, because the -# childless evaluation has no homes. -# -# The module also defines nothing in a NixOS evaluation that has no -# `environment.etc` option. The nixos-minimal integration can produce -# such an evaluation, and such an evaluation has nowhere to put the -# file. -{ closure-lib, ... }: -{ - config, - lib, - options, - ... -}: -let - inherit (lib.caisson) evalManifest; - - hasEtc = options ? environment && options.environment ? etc; - - # True for a home that belongs to this machine. - # - # `entry.path` is a list of `{ type, name }` segments. The list is - # the path from this NixOS configuration to the configuration of the - # entry. A NixOS configuration on that path is a machine inside this - # machine. A home below such a machine belongs to that machine and - # not to this machine. - isHomeOfThisMachine = - entry: - entry.manifest.type == "home-manager" - && !(builtins.any (segment: segment.type == "nixos") (lib.lists.init entry.path)); - - hasHomes = builtins.any isHomeOfThisMachine config.caisson.exports.configurations; - - # The store path is written as a plain string. A string that refers - # to the system would make the file depend on the system. The - # machine would then have to build the system of the childless - # evaluation, only to write the name of that system into the file. - baseSystemOutPath = builtins.unsafeDiscardStringContext evalManifest.childlessManifest.outputs.toplevel.outPath; -in -{ - # The module reads `config.caisson.exports.configurations`. The core - # module declares that option. - imports = [ closure-lib.caisson.modules.nixos.core ]; - - # `optionalAttrs` and not `mkIf` decides whether the option is - # defined at all. A definition under `mkIf false` is still a - # definition, and NixOS refuses a definition of an option that no - # module declares. - config = lib.optionalAttrs hasEtc ( - lib.mkIf (!evalManifest.childless && hasHomes) { - environment.etc."caisson-home-manager/source.json".text = builtins.toJSON { - inherit baseSystemOutPath; - }; - } - ); -} diff --git a/modules/systemManager/activation-marker b/modules/systemManager/activation-marker new file mode 120000 index 00000000..d4a211a0 --- /dev/null +++ b/modules/systemManager/activation-marker @@ -0,0 +1 @@ +../nixos/activation-marker \ No newline at end of file diff --git a/modules/systemManager/default/default.nix b/modules/systemManager/default/default.nix index 39c8fbaa..4b893502 100644 --- a/modules/systemManager/default/default.nix +++ b/modules/systemManager/default/default.nix @@ -30,6 +30,7 @@ (mkModule ./caisson) closure-lib.caisson.modules.systemManager.home-manager-activation closure-lib.caisson.modules.systemManager.hjem-activation + closure-lib.caisson.modules.systemManager.activation-marker ] else [ ]; diff --git a/tests/pinned-world/default.nix b/tests/pinned-world/default.nix index 20f75abf..f72723c9 100644 --- a/tests/pinned-world/default.nix +++ b/tests/pinned-world/default.nix @@ -464,7 +464,7 @@ let builtins.all evaluates [ "default" "home-manager-activation" - "home-manager-source-marker" + "activation-marker" ]; composesTheCaissonLibrary = @@ -737,10 +737,9 @@ let && !(machine.systemd.user.services ? home-manager-declared) && builtins.isString machine.system.build.toplevel.drvPath; - # The same machine writes the file - # `/etc/caisson-home-manager/source.json`. The file holds a store - # path. The store path is the system of the machine with the homes - # left out. + # The same machine writes the file `/etc/caisson/marker.json`. The + # file holds a store path. The store path is the system of the + # machine with the configurations declared inside it left out. # # The system that the machine builds has the units of the homes in # it. So the store path in the file must differ from the system @@ -748,7 +747,8 @@ let # # Each home computes the same store path. The activation of each # home has a step that compares that store path with the file. The - # script of the step must therefore contain the store path. + # script of the step must therefore contain the store path and the + # path of the file. aMachineRecordsWhatItsHomesCompareAgainst = let machine = machineWithHomes.nixosConfigurations.probe.config; @@ -758,16 +758,17 @@ let ]; # fromJSON refuses a string that refers to a store path. record = builtins.fromJSON ( - builtins.unsafeDiscardStringContext machine.environment.etc."caisson-home-manager/source.json".text + builtins.unsafeDiscardStringContext machine.environment.etc."caisson/marker.json".text ); - base = record.baseSystemOutPath; + base = record.identity; comparesAgainstIt = home: - pkgs.lib.hasInfix base ( - builtins.unsafeDiscardStringContext home.config.home.activation.caissonMachineDrift.data - ); + let + step = builtins.unsafeDiscardStringContext home.config.home.activation.caissonDrift.data; + in + pkgs.lib.hasInfix base step && pkgs.lib.hasInfix "/etc/caisson/marker.json" step; in - builtins.attrNames record == [ "baseSystemOutPath" ] + builtins.attrNames record == [ "identity" ] && pkgs.lib.hasPrefix builtins.storeDir base && base != builtins.unsafeDiscardStringContext machine.system.build.toplevel.outPath && builtins.all comparesAgainstIt homes; @@ -2001,6 +2002,10 @@ let names = packages: builtins.map (package: package.name or "") packages; in home.home.homeDirectory == "/home/probe" + # The machine records what it applied, and the home compares + # against it when activated by hand, as inside a NixOS machine. + && box.environment.etc ? "caisson/marker.json" + && pkgs.lib.hasInfix "/etc/caisson/marker.json" home.home.activation.caissonDrift.data && hjem.directory == "/home/probe" && box.systemd.services ? home-manager-probe && box.systemd.services ? "hjem-activate@" diff --git a/tests/unit/lib-overlays.nix b/tests/unit/lib-overlays.nix index bb595812..f013fedb 100644 --- a/tests/unit/lib-overlays.nix +++ b/tests/unit/lib-overlays.nix @@ -4008,9 +4008,9 @@ in }; # A NixOS configuration with a home declared inside it writes the - # file `caisson-home-manager/source.json` under `/etc`. The file - # holds a store path. The store path is the system of the - # machine with the homes left out. + # file `caisson/marker.json` under `/etc`. The file holds a store + # path. The store path is the system of the machine with the + # configurations declared inside it left out. # # The stub of NixOS puts the number of system units into the # store path of a system. The machine `laptop` has one home, and @@ -4022,7 +4022,8 @@ in # # The machine `outer` has a machine `image` declared inside it, # and `image` has a home. That home belongs to `image`. So - # `image` writes the file, and `outer` does not. + # `image` writes the file, and `outer` does not: `image` is + # applied on its own host, so it checks no marker of `outer`. "test: a NixOS configuration with homes records the system it runs without them" = { expr = let @@ -4056,7 +4057,7 @@ in }; }; }; - file = "caisson-home-manager/source.json"; + file = "caisson/marker.json"; recordOf = name: let @@ -4073,13 +4074,13 @@ in }; expected = { laptop = { - baseSystemOutPath = "/nix/store/stub-system-with-0-units"; + identity = "/nix/store/stub-system-with-0-units"; }; laptopBuilds = "/nix/store/stub-system-with-1-units"; bare = null; outer = null; image = { - baseSystemOutPath = "/nix/store/stub-system-with-0-units"; + identity = "/nix/store/stub-system-with-0-units"; }; }; }; From adbaef2dc65eb1542a8574a50a0164126165ae18 Mon Sep 17 00:00:00 2001 From: Chris Hodapp Date: Sat, 17 Oct 2026 11:54:40 -0700 Subject: [PATCH 2/4] refactor(integrations): record a digest of the identity in a drift marker A drift marker held the store path of the childless evaluation with its string context dropped, which made it a string that looks like a store path but is not tracked as one. The marker and the check now hold the SHA-256 digest of that store path. Computing it evaluates the childless evaluation and builds nothing, and the digest names no store path, so nothing depends on it and nothing can read it as a path. Co-Authored-By: Claude Opus 5.5 --- lib-overlays/integrations/default.nix | 29 +++++++++++------- modules/nixos/activation-marker/default.nix | 11 ++++--- tests/pinned-world/default.nix | 33 ++++++++++----------- tests/unit/lib-overlays.nix | 12 ++++---- 4 files changed, 47 insertions(+), 38 deletions(-) diff --git a/lib-overlays/integrations/default.nix b/lib-overlays/integrations/default.nix index aaba3fea..7c2b50c8 100644 --- a/lib-overlays/integrations/default.nix +++ b/lib-overlays/integrations/default.nix @@ -659,11 +659,18 @@ ) }.json"; + # The digest a marker holds for an identity: the SHA-256 of its + # store path. Computing the store path evaluates the childless + # evaluation and builds nothing, and the digest refers to no store + # path, so a marker that holds it depends on nothing. The digest + # is compared, never read as a path. + digestOf = identity: builtins.hashString "sha256" identity; + # What the activation of the configuration of `manifest` writes - # into its marker: the identity of its childless evaluation, as a - # plain string, so that the marker does not depend on that - # evaluation. `path` is the shell text of the path of the marker. - # Null when the configuration has no activation or no identity. + # into its marker: the digest of the identity of its childless + # evaluation (`digestOf`). `path` is the shell text of the path of + # the marker. Null when the configuration has no activation or no + # identity. markerOf = manifest: let @@ -676,8 +683,8 @@ else { path = activation.marker childless; - identity = builtins.unsafeDiscardStringContext identity; - text = builtins.toJSON { identity = builtins.unsafeDiscardStringContext identity; }; + identity = digestOf identity; + text = builtins.toJSON { identity = digestOf identity; }; }; # True when `entry`, an entry of `caisson.exports.configurations` @@ -717,9 +724,9 @@ marker = ancestor.activation.marker ancestor; above = "the ${ancestor.type} configuration `${ancestor.name or "(unnamed)"}`"; self = "the ${manifest.type} configuration `${manifest.name or "(unnamed)"}`"; - # The identity, quoted for the shell. A store path holds no - # single quote, so quoting it whole is enough. - escaped = "'${builtins.unsafeDiscardStringContext expected}'"; + # The digest of the identity, quoted for the shell. A digest is + # hexadecimal, so quoting it whole is enough. + escaped = "'${digestOf expected}'"; in if expected == null then "" @@ -736,9 +743,9 @@ _caissonApplied="$(${jq} -r '.identity // empty' < "$_caissonMarker")" if [ "$_caissonApplied" != ${escaped} ]; then echo "WARNING: ${self} was built for a version of ${above} that differs from the version that is applied." >&2 - echo " applied (configurations declared inside it left out):" >&2 + echo " digest of the version applied (configurations declared inside it left out):" >&2 echo " ''${_caissonApplied:-unknown}" >&2 - echo " built for (configurations declared inside it left out):" >&2 + echo " digest of the version built for (configurations declared inside it left out):" >&2 echo " "${escaped} >&2 echo " Activate ${above} as this configuration was built with it," >&2 echo " or set ${optionText} to false in ${self}." >&2 diff --git a/modules/nixos/activation-marker/default.nix b/modules/nixos/activation-marker/default.nix index 559813e2..133962a8 100644 --- a/modules/nixos/activation-marker/default.nix +++ b/modules/nixos/activation-marker/default.nix @@ -18,10 +18,13 @@ # What the file holds # # The module writes `/etc/caisson/marker.json`, with one field, -# `identity`: the system that the childless evaluation of this machine -# builds, which is the evaluation without the configurations declared -# inside it. Changing one of those configurations leaves the identity -# the same; changing anything else about the machine changes it. +# `identity`: the SHA-256 digest of the store path of the system that +# the childless evaluation of this machine builds, which is the +# evaluation without the configurations declared inside it. Computing +# the store path evaluates that system and builds nothing, and the +# digest names no store path, so the file depends on nothing. Changing +# one of the configurations declared inside leaves the identity the +# same; changing anything else about the machine changes it. # # A configuration declared inside the machine reads the same childless # evaluation through its own manifest, so it computes the same identity diff --git a/tests/pinned-world/default.nix b/tests/pinned-world/default.nix index f72723c9..9393e905 100644 --- a/tests/pinned-world/default.nix +++ b/tests/pinned-world/default.nix @@ -738,17 +738,17 @@ let && builtins.isString machine.system.build.toplevel.drvPath; # The same machine writes the file `/etc/caisson/marker.json`. The - # file holds a store path. The store path is the system of the - # machine with the configurations declared inside it left out. + # file holds the digest of the system of the machine with the + # configurations declared inside it left out. # # The system that the machine builds has the units of the homes in - # it. So the store path in the file must differ from the system - # that the machine builds. + # it. So the digest in the file is the digest of the childless + # system, and not of the system that the machine builds. # - # Each home computes the same store path. The activation of each - # home has a step that compares that store path with the file. The - # script of the step must therefore contain the store path and the - # path of the file. + # Each home computes the same digest. The activation of each home + # has a step that compares that digest with the file. The script of + # the step must therefore contain the digest and the path of the + # file. aMachineRecordsWhatItsHomesCompareAgainst = let machine = machineWithHomes.nixosConfigurations.probe.config; @@ -756,21 +756,20 @@ let machineWithHomes.homeConfigurations."declared@probe" machineWithHomes.homeConfigurations."undeclared@probe" ]; - # fromJSON refuses a string that refers to a store path. - record = builtins.fromJSON ( - builtins.unsafeDiscardStringContext machine.environment.etc."caisson/marker.json".text - ); - base = record.identity; + record = builtins.fromJSON machine.environment.etc."caisson/marker.json".text; + digest = record.identity; + childless = + machineWithHomes.caisson.manifest.children.system.x86_64-linux.children.nixos.probe.childlessManifest.outputs.toplevel; comparesAgainstIt = home: let - step = builtins.unsafeDiscardStringContext home.config.home.activation.caissonDrift.data; + step = home.config.home.activation.caissonDrift.data; in - pkgs.lib.hasInfix base step && pkgs.lib.hasInfix "/etc/caisson/marker.json" step; + pkgs.lib.hasInfix digest step && pkgs.lib.hasInfix "/etc/caisson/marker.json" step; in builtins.attrNames record == [ "identity" ] - && pkgs.lib.hasPrefix builtins.storeDir base - && base != builtins.unsafeDiscardStringContext machine.system.build.toplevel.outPath + && digest == builtins.hashString "sha256" childless.outPath + && digest != builtins.hashString "sha256" machine.system.build.toplevel.outPath && builtins.all comparesAgainstIt homes; # A NixOS configuration can be evaluated alone, with diff --git a/tests/unit/lib-overlays.nix b/tests/unit/lib-overlays.nix index f013fedb..6aa2bd48 100644 --- a/tests/unit/lib-overlays.nix +++ b/tests/unit/lib-overlays.nix @@ -4008,15 +4008,15 @@ in }; # A NixOS configuration with a home declared inside it writes the - # file `caisson/marker.json` under `/etc`. The file holds a store - # path. The store path is the system of the machine with the + # file `caisson/marker.json` under `/etc`. The file holds the digest + # of the store path of the system of the machine with the # configurations declared inside it left out. # # The stub of NixOS puts the number of system units into the # store path of a system. The machine `laptop` has one home, and # `laptop` declares the account of that home. The system of - # `laptop` therefore has one unit. The store path in the file - # must be the system with no units. + # `laptop` therefore has one unit. The digest in the file must be + # that of the system with no units. # # The machine `bare` has no home, and writes no file. # @@ -4074,13 +4074,13 @@ in }; expected = { laptop = { - identity = "/nix/store/stub-system-with-0-units"; + identity = builtins.hashString "sha256" "/nix/store/stub-system-with-0-units"; }; laptopBuilds = "/nix/store/stub-system-with-1-units"; bare = null; outer = null; image = { - identity = "/nix/store/stub-system-with-0-units"; + identity = builtins.hashString "sha256" "/nix/store/stub-system-with-0-units"; }; }; }; From 99e2d6bf51db2bb6953e804b1b4e95e329a9a935 Mon Sep 17 00:00:00 2001 From: Chris Hodapp Date: Sat, 17 Oct 2026 12:18:57 -0700 Subject: [PATCH 3/4] test: check that a home unit runs its activation through string context The test of the units that activate the homes dropped the string context of the activation package and of the unit script to search one in the other. It now reads the context of the script, which records the store paths the script refers to, and checks that it holds the activation package of the home and not that of the other home. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit b6f1ff3f821a30d1c423abef7aafb5cfe97c2a98) --- tests/pinned-world/default.nix | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/pinned-world/default.nix b/tests/pinned-world/default.nix index 9393e905..d1b3a6b3 100644 --- a/tests/pinned-world/default.nix +++ b/tests/pinned-world/default.nix @@ -719,20 +719,25 @@ let undeclared = machineWithHomes.homeConfigurations."undeclared@probe"; systemUnit = machine.systemd.services.home-manager-declared; userUnit = machine.systemd.user.services.home-manager-undeclared; - # The comparison is made on plain strings: a string that - # refers to a store path cannot be searched for. - plain = builtins.unsafeDiscardStringContext; + # The script of the unit runs `activate` of the activation + # package of the home: the string context of the script records + # that it refers to the activation package, and the script runs + # an `activate`. runs = unit: home: - pkgs.lib.hasInfix (plain "${home.activationPackage}/activate") ( - plain unit.serviceConfig.ExecStart.text - ); + let + script = unit.serviceConfig.ExecStart.text; + in + builtins.elem home.activationPackage.drvPath (builtins.attrNames (builtins.getContext script)) + && pkgs.lib.hasInfix "/activate" script; in systemUnit.serviceConfig.User == "declared" && systemUnit.unitConfig.RequiresMountsFor == "/home/declared" && runs systemUnit declared + && !(runs systemUnit undeclared) && userUnit.unitConfig.ConditionUser == "undeclared" && runs userUnit undeclared + && !(runs userUnit declared) && !(machine.systemd.services ? home-manager-undeclared) && !(machine.systemd.user.services ? home-manager-declared) && builtins.isString machine.system.build.toplevel.drvPath; From 5c79217355dd2fc89801d651f0c794694f81bd92 Mon Sep 17 00:00:00 2001 From: Chris Hodapp Date: Sun, 18 Oct 2026 10:34:23 -0700 Subject: [PATCH 4/4] docs: write "a hjem" in the drift integration Co-Authored-By: Claude Opus 5.5 --- lib-overlays/hjem/default.nix | 4 ++-- modules/hjem/activation-marker/default.nix | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib-overlays/hjem/default.nix b/lib-overlays/hjem/default.nix index 658b4e10..d094644f 100644 --- a/lib-overlays/hjem/default.nix +++ b/lib-overlays/hjem/default.nix @@ -292,7 +292,7 @@ perSystem = true; defaultPkgs = args.defaultPkgs or null; inherit exportsTo; - # An hjem configuration is activated for its user. Its + # A hjem configuration is activated for its user. Its # identity is its manifest file; its marker is a file in the # directory of the user, which the hjem module # `activation-marker` adds to the files of the configuration. @@ -308,7 +308,7 @@ }; # The directory, relative to the directory of the user, that holds - # the marker of an hjem configuration. + # the marker of a hjem configuration. markerDirectory = ".local/state/caisson/markers"; integration = final.caisson.integrations.mkIntegration { diff --git a/modules/hjem/activation-marker/default.nix b/modules/hjem/activation-marker/default.nix index 81a6d445..e263cfaf 100644 --- a/modules/hjem/activation-marker/default.nix +++ b/modules/hjem/activation-marker/default.nix @@ -1,8 +1,8 @@ # SPDX-License-Identifier: MIT # -# An hjem module with which an hjem configuration records what it +# A hjem module with which a hjem configuration records what it # applied, so that the configurations declared inside it can detect -# drift. It is the counterpart, for an hjem configuration, of the +# drift. It is the counterpart, for a hjem configuration, of the # machine module `activation-marker`: see there for why. # # The module adds a file to the files of the configuration,