From 090aa14e5dbaa73f16624f408977582869c0c49a Mon Sep 17 00:00:00 2001 From: Benedikt Rips Date: Thu, 30 Oct 2025 22:25:31 +0100 Subject: [PATCH] tmpfiles: migrate to an RFC42-style option --- modules/misc/tmpfiles.nix | 198 ++++++++++++++++-- modules/programs/glab.nix | 14 +- tests/default.nix | 1 + .../standalone/rclone/sops-nix.nix | 9 +- tests/modules/misc/tmpfiles/basic-rules.nix | 24 +++ tests/modules/misc/tmpfiles/common-stubs.nix | 3 + tests/modules/misc/tmpfiles/default.nix | 6 + .../tmpfiles/escaped-argument-warning.nix | 14 ++ tests/modules/misc/tmpfiles/no-rules.nix | 9 + 9 files changed, 247 insertions(+), 31 deletions(-) create mode 100644 tests/modules/misc/tmpfiles/basic-rules.nix create mode 100644 tests/modules/misc/tmpfiles/common-stubs.nix create mode 100644 tests/modules/misc/tmpfiles/default.nix create mode 100644 tests/modules/misc/tmpfiles/escaped-argument-warning.nix create mode 100644 tests/modules/misc/tmpfiles/no-rules.nix diff --git a/modules/misc/tmpfiles.nix b/modules/misc/tmpfiles.nix index b0d4c681e..82b791d89 100644 --- a/modules/misc/tmpfiles.nix +++ b/modules/misc/tmpfiles.nix @@ -9,38 +9,193 @@ let cfg = config.systemd.user.tmpfiles; + ruleSubmodule = lib.types.submodule ( + { name, ... }: + { + options.type = lib.mkOption { + type = lib.types.str; + readOnly = true; + default = name; + defaultText = "‹tmpfiles-type›"; + example = "d"; + description = '' + The type of operation to perform on the file. + + The type consists of a single letter and optionally one or more + modifier characters. + + Please see the upstream documentation for the available types and + more details: {manpage}`tmpfiles.d(5)` + ''; + }; + options.mode = lib.mkOption { + type = lib.types.str; + default = "-"; + example = "0755"; + description = '' + The file access mode to use when creating this file or directory. + ''; + }; + options.user = lib.mkOption { + type = lib.types.str; + default = "-"; + example = "root"; + description = '' + The user of the file. + + This may either be a numeric ID or a user/group name. + + If omitted or when set to `"-"`, the user and group of the user who + invokes systemd-tmpfiles is used. + ''; + }; + options.group = lib.mkOption { + type = lib.types.str; + default = "-"; + example = "root"; + description = '' + The group of the file. + + This may either be a numeric ID or a user/group name. + + If omitted or when set to `"-"`, the user and group of the user who + invokes systemd-tmpfiles is used. + ''; + }; + options.age = lib.mkOption { + type = lib.types.str; + default = "-"; + example = "10d"; + description = '' + Delete a file when it reaches a certain age. + + If a file or directory is older than the current time minus the age + field, it is deleted. + + If set to `"-"`, no automatic clean-up is done. + ''; + }; + options.argument = lib.mkOption { + type = lib.types.str; + default = ""; + example = ""; + description = '' + An argument whose meaning depends on the type of operation. + + Please see the upstream documentation for the meaning of this + parameter in different situations: {manpage}`tmpfiles.d(5)` + ''; + }; + } + ); + + modulePrefix = [ + "systemd" + "user" + "tmpfiles" + "settings" + ]; + + mkFileName = configName: "user-tmpfiles.d/home-manager-${configName}.conf"; + + mkConfigFile = + name: rules: + let + escapeArgument = lib.strings.escapeC [ + "\t" + "\n" + "\r" + " " + "\\" + ]; + mkRule = path: rule: '' + '${rule.type}' '${path}' '${rule.mode}' '${rule.user}' '${rule.group}' '${rule.age}' ${escapeArgument rule.argument} + ''; + in + { + text = '' + # This file was generated by Home Manager and should not be modified. + # Please change the option '${lib.showAttrPath (modulePrefix ++ [ name ])}' instead. + ${lib.pipe rules [ + (lib.mapAttrs (_path: lib.attrValues)) + (lib.mapAttrsToList (path: map (mkRule path))) + lib.flatten + lib.concatStrings + ]} + ''; + onChange = '' + run ${pkgs.systemd}/bin/systemd-tmpfiles --user --remove --create ''${DRY_RUN:+--dry-run} '${mkFileName name}' + ''; + }; + in { meta.maintainers = [ lib.maintainers.dawidsowa ]; - options.systemd.user.tmpfiles.rules = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - example = [ "L /home/user/Documents - - - - /mnt/data/Documents" ]; + imports = [ + (lib.mkRemovedOptionModule [ "systemd" "user" "tmpfiles" "rules" ] '' + It has been replaced by 'systemd.user.tmpfiles.settings'. + '') + ]; + + options.systemd.user.tmpfiles.settings = lib.mkOption { description = '' - Rules for creating and cleaning up temporary files - automatically. See - {manpage}`tmpfiles.d(5)` - for the exact format. + Declare systemd-tmpfiles rules to create, delete, and clean up volatile + and temporary files and directories. + + Even though the service is called `*tmp*files` you can also create + persistent files. ''; + example = { + cache."%C".d = { + mode = "0755"; + user = "alice"; + group = "alice"; + age = "4 weeks"; + }; + }; + default = { }; + type = + let + attrsWith' = placeholder: elemType: lib.types.attrsWith { inherit elemType placeholder; }; + nonEmptyAttrsWith' = + placeholder: elemType: + let + attrs = lib.types.addCheck (attrsWith' placeholder elemType) (s: s != { }); + in + attrs + // { + name = "nonEmptyAttrsOf"; + description = "non-empty ${attrs.description}"; + emptyValue = { }; # no .value attribute, meaning there is not empty value + substSubModules = m: nonEmptyAttrsWith' placeholder (elemType.substSubModules m); + }; + in + attrsWith' "config-name" ( + nonEmptyAttrsWith' "path" (nonEmptyAttrsWith' "tmpfiles-type" ruleSubmodule) + ); }; - config = lib.mkIf (cfg.rules != [ ]) { + config = lib.mkIf (cfg.settings != { }) { assertions = [ (lib.hm.assertions.assertPlatform "systemd.user.tmpfiles" pkgs lib.platforms.linux) ]; + warnings = lib.flatten ( + lib.mapAttrsToListRecursive ( + path: value: + lib.optional + (lib.last path == "argument" && lib.match ''.*\\([nrt]|x[0-9A-Fa-f]{2}).*'' value != null) + '' + The '${lib.showAttrPath (modulePrefix ++ path)}' option + appears to contain escape sequences, which will be escaped again. + Unescape them if this is not intended. The assigned string is: + "${value}" + '' + ) cfg.settings + ); + xdg.configFile = { - "user-tmpfiles.d/home-manager.conf" = { - text = '' - # This file is created automatically and should not be modified. - # Please change the option ‘systemd.user.tmpfiles.rules’ instead. - ${lib.concatStringsSep "\n" cfg.rules} - ''; - onChange = '' - run ${pkgs.systemd}/bin/systemd-tmpfiles --user --remove --create ''${DRY_RUN:+--dry-run} - ''; - }; "systemd/user/basic.target.wants/systemd-tmpfiles-setup.service".source = "${pkgs.systemd}/example/systemd/user/systemd-tmpfiles-setup.service"; "systemd/user/systemd-tmpfiles-setup.service".source = @@ -49,6 +204,9 @@ in "${pkgs.systemd}/example/systemd/user/systemd-tmpfiles-clean.timer"; "systemd/user/systemd-tmpfiles-clean.service".source = "${pkgs.systemd}/example/systemd/user/systemd-tmpfiles-clean.service"; - }; + } + // lib.mapAttrs' ( + name: rules: lib.nameValuePair (mkFileName name) (mkConfigFile name rules) + ) cfg.settings; }; } diff --git a/modules/programs/glab.nix b/modules/programs/glab.nix index 525456dc4..1aac3c004 100644 --- a/modules/programs/glab.nix +++ b/modules/programs/glab.nix @@ -36,14 +36,12 @@ in # Use `systemd-tmpfiles` since glab requires its configuration file to have # mode 0600. - systemd.user.tmpfiles.rules = - let - target = "${config.xdg.configHome}/glab-cli/config.yml"; - in - lib.mkIf (cfg.settings != { }) [ - "C+ ${target} - - - - ${yaml.generate "glab-config" cfg.settings}" - "z ${target} 0600" - ]; + systemd.user.tmpfiles.settings.glab = lib.mkIf (cfg.settings != { }) { + "${config.xdg.configHome}/glab-cli/config.yml" = { + "C+".argument = yaml.generate "glab-config" cfg.settings; + z.mode = "0600"; + }; + }; xdg.configFile."glab-cli/aliases.yml" = lib.mkIf (cfg.aliases != { }) { source = yaml.generate "glab-aliases" cfg.aliases; diff --git a/tests/default.nix b/tests/default.nix index 88ff40ece..735e1f246 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -203,6 +203,7 @@ import nmtSrc { ./modules/misc/numlock ./modules/misc/pam ./modules/misc/qt + ./modules/misc/tmpfiles ./modules/misc/xdg/linux.nix ./modules/misc/xsession ./modules/systemd diff --git a/tests/integration/standalone/rclone/sops-nix.nix b/tests/integration/standalone/rclone/sops-nix.nix index 71283edf5..a7baf0614 100644 --- a/tests/integration/standalone/rclone/sops-nix.nix +++ b/tests/integration/standalone/rclone/sops-nix.nix @@ -28,9 +28,12 @@ in uid = 1000; }; - systemd.tmpfiles.rules = [ - "f /home/alice/age-key 400 alice users - ${ageKey}" - ]; + systemd.tmpfiles.settings.age."/home/alice/age-key".f = { + mode = "400"; + user = "alice"; + group = "users"; + argument = ageKey; + }; home-manager.users.alice = { config, ... }: diff --git a/tests/modules/misc/tmpfiles/basic-rules.nix b/tests/modules/misc/tmpfiles/basic-rules.nix new file mode 100644 index 000000000..30a451737 --- /dev/null +++ b/tests/modules/misc/tmpfiles/basic-rules.nix @@ -0,0 +1,24 @@ +{ + imports = [ ./common-stubs.nix ]; + + systemd.user.tmpfiles.settings = { + cache."%C".d.age = "4 weeks"; + myTool."%h/.config/myTool.conf"."f+" = { + mode = "0644"; + user = "alice"; + group = "users"; + argument = "my unescaped config"; + }; + }; + + nmt.script = '' + cacheRulesFile=home-files/.config/user-tmpfiles.d/home-manager-cache.conf + assertFileExists $cacheRulesFile + assertFileRegex $cacheRulesFile "^'d' '%C' '-' '-' '-' '4 weeks' $" + + myToolRulesFile=home-files/.config/user-tmpfiles.d/home-manager-myTool.conf + assertFileExists $myToolRulesFile + assertFileRegex $myToolRulesFile \ + "^'f+' '%h/.config/myTool.conf' '0644' 'alice' 'users' '-' my\\\\x20unescaped\\\\x20config$" + ''; +} diff --git a/tests/modules/misc/tmpfiles/common-stubs.nix b/tests/modules/misc/tmpfiles/common-stubs.nix new file mode 100644 index 000000000..abc4c732d --- /dev/null +++ b/tests/modules/misc/tmpfiles/common-stubs.nix @@ -0,0 +1,3 @@ +{ + test.stubs.systemd.outPath = null; +} diff --git a/tests/modules/misc/tmpfiles/default.nix b/tests/modules/misc/tmpfiles/default.nix new file mode 100644 index 000000000..1caa5fc7e --- /dev/null +++ b/tests/modules/misc/tmpfiles/default.nix @@ -0,0 +1,6 @@ +{ + tmpfiles-no-rules = ./no-rules.nix; + tmpfiles-basic-rules = ./basic-rules.nix; + + tmpfiles-escaped-argument-warning = ./escaped-argument-warning.nix; +} diff --git a/tests/modules/misc/tmpfiles/escaped-argument-warning.nix b/tests/modules/misc/tmpfiles/escaped-argument-warning.nix new file mode 100644 index 000000000..9284de3c8 --- /dev/null +++ b/tests/modules/misc/tmpfiles/escaped-argument-warning.nix @@ -0,0 +1,14 @@ +{ + imports = [ ./common-stubs.nix ]; + + systemd.user.tmpfiles.settings.foo.path.f.argument = "my\\x20unescaped\\x20config"; + + test.asserts.warnings.expected = [ + '' + The 'systemd.user.tmpfiles.settings.foo.path.f.argument' option + appears to contain escape sequences, which will be escaped again. + Unescape them if this is not intended. The assigned string is: + "my\x20unescaped\x20config" + '' + ]; +} diff --git a/tests/modules/misc/tmpfiles/no-rules.nix b/tests/modules/misc/tmpfiles/no-rules.nix new file mode 100644 index 000000000..8c8f0e77e --- /dev/null +++ b/tests/modules/misc/tmpfiles/no-rules.nix @@ -0,0 +1,9 @@ +{ + imports = [ ./common-stubs.nix ]; + + systemd.user.tmpfiles.settings = { }; + + nmt.script = '' + assertPathNotExists home-files/.config/user-tmpfiles.d/ + ''; +}