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
13 changes: 8 additions & 5 deletions modules/aspects/batteries/os-user.nix
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{ den, ... }:
{ den, lib, ... }:
let

description = ''
Expand Down Expand Up @@ -42,8 +42,11 @@ in

den.policies.user-to-host =
{ user, host, ... }:
[
(den.lib.policy.route {
# Same gate as os-to-host: a standalone home binds a user and a synthetic
# host identity, neither of which has an OS to route into. `host ? class`
# keeps this inert there and reading `host.class` safe.
lib.optional (host ? class) (
den.lib.policy.route {
fromClass = "user";
intoClass = host.class;
path = [
Expand All @@ -52,6 +55,6 @@ in
user.userName
];
adaptArgs = args: args // { osConfig = args.config; };
})
];
}
);
}
26 changes: 24 additions & 2 deletions nix/lib/aspects/fx/edges/route.nix
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,27 @@ let
# A `den.default`-tagged module — root content shared across the entity chain.
isDenDefaultModule = mod: lib.hasSuffix "@default" (mod.key or mod._file or "");

# A root-scope module that a descendant-arg fan-out bound to ONE entity
# belongs to that entity alone. handlers/bind.nix emits every fan instance at
# the emitting (root) scope, distinguished only by the `@<kind>=<name>` pairs
# in its {ctxId} — so a child-scope forward pulling the root bucket sees all
# siblings' instances and, without this, hands each child the union. Bindings
# naming a kind the child's ctx does not have are not about this child and
# cannot disqualify the module; unfanned content binds nothing and always
# passes.
boundToChild =
childCtx: mod:
let
bindings = den.lib.aspects.fx.identity.ctxBindings (toString (mod.key or mod._file or ""));
in
builtins.all (
kind:
let
record = childCtx.${kind} or null;
in
record == null || (record.name or null) == bindings.${kind}
) (builtins.attrNames bindings);

# Root-scope `fromClass` content a child-scope COMPLEX forward may pull in.
# S-construction rule (§B Decision 2): when `fromClass` is owned by an entity in
# the chain, root content under it is that entity's OWN declaration (not
Expand All @@ -545,11 +566,12 @@ let
(childCtx.user.classes or [ ])
++ lib.optional (childCtx ? host) childCtx.host.class
++ lib.optional (childCtx ? home) childCtx.home.class;
ownBinding = builtins.filter (boundToChild childCtx) rootModules;
in
if builtins.elem spec.fromClass ownedClasses then
builtins.filter isDenDefaultModule rootModules
builtins.filter isDenDefaultModule ownBinding
else
rootModules;
ownBinding;

# The "collected" source branch: a child-scope forward collects its own-scope
# fromClass modules PLUS the (filtered) root-scope fromClass modules; a root-
Expand Down
27 changes: 27 additions & 0 deletions nix/lib/aspects/fx/identity.nix
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,32 @@ let
# Strip the {ctxId} suffix from an identity, yielding the base identity.
stripCtxSuffix = id: lib.head (lib.splitString "/{" id);

# The entity bindings a ctx-qualified identity carries, as { kind = name; }.
# handlers/bind.nix fans a descendant-arg aspect by appending `@<kind>=<name>`
# per bound child and emits EVERY instance at the same scope, so the {ctxId}
# suffix is the only record of which entity an instance belongs to. Identities
# with no suffix (unfanned content) bind nothing and yield { }.
ctxBindings =
id:
let
parts = lib.splitString "/{" id;
in
if builtins.length parts < 2 then
{ }
else
builtins.listToAttrs (
builtins.concatMap (
pair:
let
kv = lib.splitString "=" pair;
in
lib.optional (builtins.length kv == 2) {
name = builtins.head kv;
value = lib.elemAt kv 1;
}
) (builtins.tail (lib.splitString "@" (lib.removeSuffix "}" (lib.last parts))))
);

tombstone = resolved: extra: {
name = "~${resolved.name or "<anon>"}";
meta =
Expand Down Expand Up @@ -114,6 +140,7 @@ in
baseKey
isAnonIdentity
stripCtxSuffix
ctxBindings
tombstone
flattenPathSetByScope
collectPathsHandler
Expand Down
61 changes: 59 additions & 2 deletions nix/lib/aspects/fx/spawn-node.nix
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,27 @@ in
allScopeIds = spawnAllScopeIds;
}) (_: true);

# A route that materializes an adapter DECLARES `options.den.fwd.<key>` in
# the target bucket (handlers/forward.nix mkAdapterAspect, edges/route.nix
# mkAdapterFunctor). Content definitions merge; an option DECLARATION does
# not — a second one in the same evalModules is a hard "already declared"
# error. Of buildForwardAspect's three arms only mkAdapterAspect declares:
# the top-level adapter arm evaluates inline and mkDirectAspect places
# content, so both stay.
#
# Testing __complexForward FIRST is load-bearing, not stylistic: every
# complex forward carries an adapterKey (lib/forward.nix always builds
# one), so a bare `adapterKey != null` would also exclude non-declaring
# forwards — among them the home-manager battery's own delivery route.
# The simple-route arm is defensive: adapterKey has one in-tree producer
# (lib/forward.nix), which only builds complex-forward specs.
declaresForwardOption =
r:
if r.__complexForward or false then
(r.needsAdapter or false) && !(r.needsTopLevelAdapter or false)
else
(r.adapterKey or null) != null;

# DELIBERATE: parent-pipeline routes sourced inside the spawned
# subtree MUST re-apply — the spawn re-emits class content at the same scope
# ids but never re-fires schema policies, so without them a user-schema route
Expand All @@ -171,6 +192,34 @@ in
# simple route would re-nest content in fresh keyless wrappers and conflict
# at the target). Order/precedence preserved exactly: freshParent (parent
# routes whose key ∉ spawn keys) ++ spawnHere.
#
# Declaration-bearing routes get the OPPOSITE precedence: the parent owns
# them, and the spawn's copy goes. The parent materializes the same route
# at the same scope and both folds land in one target (the user's
# home-manager evaluation), so whichever side re-applies it emits a second
# `den.fwd.<key>` declaration — and unlike content definitions, two
# declarations do not merge. So a declaring route is dropped from
# `freshParent` always, and additionally from `spawnHere` whenever the
# parent registered the same identity at that scope. A declaration the
# spawn alone registers stays: there is no other producer for it.
#
# Applying this by identity rather than by target class is deliberate. A
# declaration can reach the extracted class INDIRECTLY — an `inner -> mid`
# forward declares into the `mid` bucket, which a `mid -> homeManager` hop
# then nests into the target — so gating on `intoClass == class` would miss
# the chained shape.
#
# The parent's copy is always present to take over — an excluded route is
# by construction inside the spawned subtree, and `suppressionVerdicts`'
# redundant-root rule only fires on a route AT the fold root, which for the
# parent is an ancestor of spawnRoot.
#
# For a forward-only custom class the parent's copy also carries what the
# spawn would have forwarded, because `getCollectedSource` pulls root-scope
# content for it. That is NOT general: `filterRootModules` narrows root
# content to `den.default` modules once fromClass is an entity-owned class,
# and the parent collects the host bucket unprojected where the spawn would
# have re-resolved it per user.
spawnRoutes = result.state.scopedRoutes null;
parentSubtreeRoutes = lib.filterAttrs (sid: _: subtreeSet ? ${sid}) parentState.scopedRoutes;
mergedSpawnRoutes =
Expand All @@ -180,9 +229,17 @@ in
let
spawnHere = spawnRoutes.${sid} or [ ];
spawnKeys = lib.genAttrs (map (routeKey sid) spawnHere) (_: true);
freshParent = builtins.filter (r: !(spawnKeys ? ${routeKey sid r})) parentRoutes;
parentDeclaredKeys = lib.genAttrs (map (routeKey sid) (
builtins.filter declaresForwardOption parentRoutes
)) (_: true);
freshParent = builtins.filter (
r: !(spawnKeys ? ${routeKey sid r}) && !(declaresForwardOption r)
) parentRoutes;
keptSpawnHere = builtins.filter (
r: !(declaresForwardOption r && parentDeclaredKeys ? ${routeKey sid r})
) spawnHere;
in
freshParent ++ spawnHere
freshParent ++ keptSpawnHere
) parentSubtreeRoutes;

# The spawn's provides + routes fold + isolation-BLIND, dedup-FREE final
Expand Down
40 changes: 32 additions & 8 deletions nix/lib/entities/home.nix
Original file line number Diff line number Diff line change
Expand Up @@ -73,19 +73,43 @@ let
# A declared host always wins and remains the only thing that wires
# `osConfig`.
#
# Only `host` is synthesized, never `user`: a synthetic host alone fires
# `{ host }`-keyed policies (gated on `host ? class` so OS-class routing
# stays inert for a classless synthetic host), while `{ host, user }`-keyed
# OS batteries (define-user, user-to-host, …) keep their existing
# null-user gating and fall back to their home-scope path.
# The synthetic host stays classless: `host ? class` is what keeps
# OS-class routing (os-to-host, user-to-host, hostname, unfree, …)
# inert for a host that was never declared. It does carry `system`,
# which the home knows for certain and which host-keyed content needs
# to compute platform-dependent values (e.g. define-user's home dir).
hostCtx =
if hostByName != null then
hostByName
else if nameWithHost then
{ name = hostName; }
{
name = hostName;
inherit system;
}
else
null;

# A standalone home always names its user, whether or not a declared
# host can resolve one. Binding it from the home is what lets a
# `{ user, ... }` class module resolve at all: without it
# wrapFunctionModule takes the missingDenArgNames path and the whole
# class block is dropped — silently, since the lib.warn it attaches
# rides on the discarded module and is never forced.
#
# A declared host still wins, so a real user keeps its full record
# (classes, aspect, host). The synthetic one is identity-only; OS
# batteries do not act on it because they gate on `host ? class`,
# which no standalone home satisfies.
userCtx =
if userByName != null then
userByName
else
{
name = userName;
userName = userName;
classes = [ config.class ];
};

homeManagerConfiguration =
if nameWithHost && hostByName != null then
{ pkgs, modules }:
Expand All @@ -111,7 +135,7 @@ let
"system"
];
config._module.args.host = hostCtx;
config._module.args.user = userByName;
config._module.args.user = userCtx;
options = {

userName = strOpt "user account name" userName;
Expand All @@ -121,7 +145,7 @@ let
description = "host name (null for unbound standalone homes)";
};
user = lib.mkOption {
default = userByName;
default = userCtx;
defaultText = lib.literalExpression "user";
};
host = lib.mkOption {
Expand Down
107 changes: 107 additions & 0 deletions templates/ci/modules/deadbugs/issue-640-standalone-home-user-arg.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Issue #640: a class module requesting the `user` entity arg is silently
# dropped on a standalone home whose user cannot be resolved from a DECLARED
# host — no error, no warning, the whole class block disappears.
#
# `nix/lib/entities/home.nix` only bound `user` when the home is named
# `user@host` and that host is declared in `den.hosts` with that user. Without
# it, `wrapFunctionModule` takes the `missingDenArgNames` path and returns
# `unsatisfied = true`; the module is dropped, and the `lib.warn` it attaches is
# never forced, so even the existing warning stays invisible.
{ denTest, ... }:
let
outOf =
config: home: config.flake.homeConfigurations.${home}.config.home.file."OUT".text or "<dropped>";
in
{
flake.tests.deadbugs.issue-640-standalone-home-user-arg = {

# A bare standalone home: no host in the name at all.
test-bare-standalone-home-binds-user = denTest (
{ den, config, ... }:
{
den.default.homeManager.home.stateVersion = "25.11";
den.default.includes = [ den._.define-user ];

den.homes.x86_64-linux.tux = { };

den.aspects.probe.homeManager =
{ user, ... }:
{
home.file."OUT".text = "user=${user.name}";
};
den.aspects.tux.includes = [ den.aspects.probe ];

expr = outOf config "tux";
expected = "user=tux";
}
);

# `user@host` where the host is NOT declared in den.hosts — home.nix
# synthesizes the host, so the user must come from the home too.
test-synthetic-host-home-binds-user = denTest (
{ den, config, ... }:
{
den.default.homeManager.home.stateVersion = "25.11";
den.default.includes = [ den._.define-user ];

den.homes.x86_64-linux."tux@astra" = { };

den.aspects.probe.homeManager =
{ user, ... }:
{
home.file."OUT".text = "user=${user.name}";
};
den.aspects.tux.includes = [ den.aspects.probe ];

expr = outOf config "tux@astra";
expected = "user=tux";
}
);

# CONTROL: `user@host` with the host declared — the path that already
# resolved `user` from `den.hosts`, and must keep resolving it from there.
test-declared-host-home-still-binds-user = denTest (
{ den, config, ... }:
{
den.default.homeManager.home.stateVersion = "25.11";
den.default.includes = [ den._.define-user ];

den.hosts.x86_64-linux.igloo.users.tux = { };
den.homes.x86_64-linux."tux@igloo" = { };

den.aspects.probe.homeManager =
{ user, ... }:
{
home.file."OUT".text = "user=${user.name}";
};
den.aspects.tux.includes = [ den.aspects.probe ];

expr = outOf config "tux@igloo";
expected = "user=tux";
}
);

# CONTROL: `{ home, ... }` on a bare standalone home already worked and must
# keep working — it is what shows the drop is specific to `user`.
test-bare-standalone-home-binds-home = denTest (
{ den, config, ... }:
{
den.default.homeManager.home.stateVersion = "25.11";
den.default.includes = [ den._.define-user ];

den.homes.x86_64-linux.tux = { };

den.aspects.probe.homeManager =
{ home, ... }:
{
home.file."OUT".text = "home=${home.userName}";
};
den.aspects.tux.includes = [ den.aspects.probe ];

expr = outOf config "tux";
expected = "home=tux";
}
);

};
}
Loading
Loading