Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion nix/lib/aspects/fx/pipeline.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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 "," (
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions nix/lib/entities/_types.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions nix/lib/entities/home.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
42 changes: 42 additions & 0 deletions templates/ci/modules/deadbugs/home-samename-crosshost-idhash.nix
Original file line number Diff line number Diff line change
@@ -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;
}
);

};
}
179 changes: 179 additions & 0 deletions templates/ci/modules/deadbugs/home-samename-crosshost-scope.nix
Original file line number Diff line number Diff line change
@@ -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.<hostname>` 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.<hostname>` 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";
};
}
);

};
}
Loading