diff --git a/nix/lib/aspects/fx/pipeline.nix b/nix/lib/aspects/fx/pipeline.nix index 6c99d598e..c58f85954 100644 --- a/nix/lib/aspects/fx/pipeline.nix +++ b/nix/lib/aspects/fx/pipeline.nix @@ -107,6 +107,17 @@ let # mkScopeId: injective scope identity from a context attrset. # Produces a canonical comma-separated "key=value" string, sorted by key. + # + # An entity's `name` is NOT always injective across entities of one kind: a + # home's `name` is force-set to the bare user name + # (nix/lib/entities/home.nix), so two standalone homes `user@hostA` / + # `user@hostB` both rendered `home=user` and collapsed onto ONE scope — their + # collected class content merged and each flake output yielded the other's + # configuration. Entities therefore expose `__scopeName`, their registry key + # (see nix/lib/entities/_types.nix), which is unique per kind per system. + # It defaults to `name`, so only kinds that rewrite `name` differ here. + # Synthetic context values (e.g. a bare `{ name = ...; }` host) carry no + # `__scopeName` and fall back to `name`. mkScopeId = ctx: lib.concatStringsSep "," ( @@ -117,7 +128,9 @@ let v = ctx.${k}; in "${k}=${ - if builtins.isAttrs v && v ? name then + if builtins.isAttrs v && v ? __scopeName then + v.__scopeName + else if builtins.isAttrs v && v ? name then v.name else if builtins.isString v then v diff --git a/nix/lib/entities/_types.nix b/nix/lib/entities/_types.nix index e0732223f..073a3049c 100644 --- a/nix/lib/entities/_types.nix +++ b/nix/lib/entities/_types.nix @@ -118,6 +118,19 @@ let kind: { config, ... }: { + # Injective scope identity, consumed by fx.pipeline's mkScopeId. Defaults + # to `name`, which is the registry key for most kinds. A kind that rewrites + # `name` into something non-unique (home forces it to the bare user name) + # MUST override this with its registry key, or two such entities collapse + # onto one pipeline scope and merge each other's content. + options.__scopeName = lib.mkOption { + description = "Registry key of this ${kind}, used as its internal identity."; + internal = true; + visible = false; + type = lib.types.str; + default = config.name; + defaultText = "config.name"; + }; options.resolved = lib.mkOption { description = "The resolved aspect for this ${kind}."; readOnly = true; diff --git a/nix/lib/entities/home.nix b/nix/lib/entities/home.nix index 0f292a939..26efed825 100644 --- a/nix/lib/entities/home.nix +++ b/nix/lib/entities/home.nix @@ -101,7 +101,15 @@ let { # mkInstanceType defaults name to the registry key (e.g. "tux@igloo"); # den's name is the bare user name, so identity/description stay stable. + # That also makes `name` non-unique across homes (two `user@host` homes + # on one system share it), so the registry key is kept as the scope + # identity — see __scopeName in ./_types.nix. config.name = lib.mkForce userName; + config.__scopeName = name; + config._identity.keys = [ + "__scopeName" + "system" + ]; config._module.args.host = hostCtx; config._module.args.user = userByName; options = { diff --git a/templates/ci/modules/deadbugs/home-samename-crosshost-idhash.nix b/templates/ci/modules/deadbugs/home-samename-crosshost-idhash.nix new file mode 100644 index 000000000..1dd747e50 --- /dev/null +++ b/templates/ci/modules/deadbugs/home-samename-crosshost-idhash.nix @@ -0,0 +1,42 @@ +# Regression: two STANDALONE homes sharing a username but bound to different +# hosts (`user@hostA`, `user@hostB`) hashed to an identical `id_hash`. +# +# A home's public `name` is force-set to the bare user name, so it cannot +# distinguish these homes. Identity is instead keyed on `__scopeName` (the +# registry key, e.g. `user@hostA`) plus `system` via `_identity.keys` (see +# nix/lib/entities/home.nix), never on reflected, user-overridable presentation +# fields like `description`. +{ denTest, ... }: +{ + flake.tests.home-samename-crosshost-idhash = { + + # Same username, different host binding: id_hash must differ. + test-crosshost-distinct-id-hash = denTest ( + { den, ... }: + { + den.homes.aarch64-darwin."someuser@hostA" = { }; + den.homes.aarch64-darwin."someuser@hostB" = { }; + + expr = + den.homes.aarch64-darwin."someuser@hostA".id_hash + == den.homes.aarch64-darwin."someuser@hostB".id_hash; + expected = false; + } + ); + + # The same registry key on different systems is also a distinct home. + test-cross-system-distinct-id-hash = denTest ( + { den, ... }: + { + den.homes.aarch64-darwin."someuser@hostA" = { }; + den.homes.x86_64-linux."someuser@hostA" = { }; + + expr = + den.homes.aarch64-darwin."someuser@hostA".id_hash + == den.homes.x86_64-linux."someuser@hostA".id_hash; + expected = false; + } + ); + + }; +} diff --git a/templates/ci/modules/deadbugs/home-samename-crosshost-scope.nix b/templates/ci/modules/deadbugs/home-samename-crosshost-scope.nix new file mode 100644 index 000000000..3a21d405a --- /dev/null +++ b/templates/ci/modules/deadbugs/home-samename-crosshost-scope.nix @@ -0,0 +1,179 @@ +# Regression: two STANDALONE homes sharing a username but bound to different +# hosts (`user@hostA`, `user@hostB`) collapsed onto ONE pipeline scope, so each +# flake output yielded the other's configuration. +# +# `mkScopeId` (nix/lib/aspects/fx/pipeline.nix) rendered an entity as `v.name`, +# but a home's `name` is force-set to the bare user name, so both rendered +# `home=someuser` and shared a scope. Fix: entities expose `__scopeName` (their +# registry key) and mkScopeId reads that, falling back to `name`. +# +# The `provides.` cases are downstream symptoms of the same collapse; +# `test-two-targets-single-home` is the control proving the dispatch itself is +# sound. Markers are separate `sessionVariables` keys so a leak surfaces as an +# extra attribute rather than a merge conflict. +{ denTest, ... }: +{ + flake.tests.home-samename-crosshost-scope = { + + # The core defect, with no provides involved: each home must resolve its own + # host context, not the first-declared home's. + test-same-username-homes-resolve-independently = denTest ( + { config, den, ... }: + { + den.homes.x86_64-linux."someuser@hostA" = { }; + den.homes.x86_64-linux."someuser@hostB" = { }; + + den.aspects.someuser.homeManager = + { home, ... }: + { + home = { + username = "someuser"; + homeDirectory = "/home/someuser"; + sessionVariables.SAW_HOST = home.hostName; + }; + }; + + expr = + let + sawHost = key: config.flake.homeConfigurations.${key}.config.home.sessionVariables.SAW_HOST; + in + { + hostA = sawHost "someuser@hostA"; + hostB = sawHost "someuser@hostB"; + }; + expected = { + hostA = "hostA"; + hostB = "hostB"; + }; + } + ); + + # No `den.hosts` at all: both homes synthesize their host identity from the + # `user@host` key, so only the home entities are in play. + test-synthetic-host-provides-no-cross-contamination = denTest ( + { config, den, ... }: + { + den.homes.x86_64-linux."someuser@hostA" = { }; + den.homes.x86_64-linux."someuser@hostB" = { }; + + den.aspects.someuser.homeManager.home = { + username = "someuser"; + homeDirectory = "/home/someuser"; + }; + den.aspects.someuser.provides = { + hostA.homeManager.home.sessionVariables.FROM_A = "a"; + hostB.homeManager.home.sessionVariables.FROM_B = "b"; + }; + + expr = + let + varsOf = key: config.flake.homeConfigurations.${key}.config.home.sessionVariables; + a = varsOf "someuser@hostA"; + b = varsOf "someuser@hostB"; + in + { + hostA = { + own = a.FROM_A or "MISSING"; + other = a.FROM_B or "MISSING"; + }; + hostB = { + own = b.FROM_B or "MISSING"; + other = b.FROM_A or "MISSING"; + }; + }; + expected = { + hostA = { + own = "a"; + other = "MISSING"; + }; + hostB = { + own = "b"; + other = "MISSING"; + }; + }; + } + ); + + # Both hosts declared, each with the same user. The users keep + # `classes = [ "user" ]` so the hosts do not also build inline home-manager — + # the standalone homes are the delivery path under test. + test-declared-host-provides-no-cross-contamination = denTest ( + { config, den, ... }: + { + den.hosts.x86_64-linux.hostA.users.someuser.classes = [ "user" ]; + den.hosts.x86_64-linux.hostB.users.someuser.classes = [ "user" ]; + + den.homes.x86_64-linux."someuser@hostA" = { }; + den.homes.x86_64-linux."someuser@hostB" = { }; + + den.aspects.someuser.homeManager.home = { + username = "someuser"; + homeDirectory = "/home/someuser"; + }; + den.aspects.someuser.provides = { + hostA.homeManager.home.sessionVariables.FROM_A = "a"; + hostB.homeManager.home.sessionVariables.FROM_B = "b"; + }; + + expr = + let + varsOf = key: config.flake.homeConfigurations.${key}.config.home.sessionVariables; + a = varsOf "someuser@hostA"; + b = varsOf "someuser@hostB"; + in + { + hostA = { + own = a.FROM_A or "MISSING"; + other = a.FROM_B or "MISSING"; + }; + hostB = { + own = b.FROM_B or "MISSING"; + other = b.FROM_A or "MISSING"; + }; + }; + expected = { + hostA = { + own = "a"; + other = "MISSING"; + }; + hostB = { + own = "b"; + other = "MISSING"; + }; + }; + } + ); + + # Control: the same two-target aspect with only ONE home consuming it — the + # `provides.` dispatch itself was never the defect. + test-two-targets-single-home = denTest ( + { config, den, ... }: + { + den.homes.x86_64-linux."someuser@hostB" = { }; + + den.aspects.someuser.homeManager.home = { + username = "someuser"; + homeDirectory = "/home/someuser"; + }; + den.aspects.someuser.provides = { + hostA.homeManager.home.sessionVariables.FROM_A = "a"; + hostB.homeManager.home.sessionVariables.FROM_B = "b"; + }; + + expr = + let + vars = config.flake.homeConfigurations."someuser@hostB".config.home.sessionVariables; + in + { + own = vars.FROM_B or "MISSING"; + other = vars.FROM_A or "MISSING"; + }; + expected = { + own = "b"; + other = "MISSING"; + }; + } + ); + + }; +}