lol

nixos: add functions and documentation for escaping systemd Exec* directives

it's really easy to accidentally write the wrong systemd Exec* directive, ones
that works most of the time but fails when users include systemd metacharacters
in arguments that are interpolated into an Exec* directive. add a few functions
analogous to escapeShellArg{,s} and some documentation on how and when to use them.

pennae 40a35299 74f542c4

+157
+42
nixos/doc/manual/development/writing-modules.chapter.md
··· 90 90 `systemd.timers` (the list of commands to be executed periodically by 91 91 `systemd`). 92 92 93 + Care must be taken when writing systemd services using `Exec*` directives. By 94 + default systemd performs substitution on `%<char>` specifiers in these 95 + directives, expands environment variables from `$FOO` and `${FOO}`, splits 96 + arguments on whitespace, and splits commands on `;`. All of these must be escaped 97 + to avoid unexpected substitution or splitting when interpolating into an `Exec*` 98 + directive, e.g. when using an `extraArgs` option to pass additional arguments to 99 + the service. The functions `utils.escapeSystemdExecArg` and 100 + `utils.escapeSystemdExecArgs` are provided for this, see [Example: Escaping in 101 + Exec directives](#exec-escaping-example) for an example. When using these 102 + functions system environment substitution should *not* be disabled explicitly. 103 + 93 104 ::: {#locate-example .example} 94 105 ::: {.title} 95 106 **Example: NixOS Module for the "locate" Service** ··· 149 160 timerConfig.OnCalendar = cfg.interval; 150 161 }; 151 162 }; 163 + } 164 + ``` 165 + ::: 166 + 167 + ::: {#exec-escaping-example .example} 168 + ::: {.title} 169 + **Example: Escaping in Exec directives** 170 + ::: 171 + ```nix 172 + { config, lib, pkgs, utils, ... }: 173 + 174 + with lib; 175 + 176 + let 177 + cfg = config.services.echo; 178 + echoAll = pkgs.writeScript "echo-all" '' 179 + #! ${pkgs.runtimeShell} 180 + for s in "$@"; do 181 + printf '%s\n' "$s" 182 + done 183 + ''; 184 + args = [ "a%Nything" "lang=\${LANG}" ";" "/bin/sh -c date" ]; 185 + in { 186 + systemd.services.echo = 187 + { description = "Echo to the journal"; 188 + wantedBy = [ "multi-user.target" ]; 189 + serviceConfig.Type = "oneshot"; 190 + serviceConfig.ExecStart = '' 191 + ${echoAll} ${utils.escapeSystemdExecArgs args} 192 + ''; 193 + }; 152 194 } 153 195 ``` 154 196 :::
+49
nixos/doc/manual/from_md/development/writing-modules.chapter.xml
··· 122 122 services) and <literal>systemd.timers</literal> (the list of 123 123 commands to be executed periodically by <literal>systemd</literal>). 124 124 </para> 125 + <para> 126 + Care must be taken when writing systemd services using 127 + <literal>Exec*</literal> directives. By default systemd performs 128 + substitution on <literal>%&lt;char&gt;</literal> specifiers in these 129 + directives, expands environment variables from 130 + <literal>$FOO</literal> and <literal>${FOO}</literal>, splits 131 + arguments on whitespace, and splits commands on 132 + <literal>;</literal>. All of these must be escaped to avoid 133 + unexpected substitution or splitting when interpolating into an 134 + <literal>Exec*</literal> directive, e.g. when using an 135 + <literal>extraArgs</literal> option to pass additional arguments to 136 + the service. The functions 137 + <literal>utils.escapeSystemdExecArg</literal> and 138 + <literal>utils.escapeSystemdExecArgs</literal> are provided for 139 + this, see <link linkend="exec-escaping-example">Example: Escaping in 140 + Exec directives</link> for an example. When using these functions 141 + system environment substitution should <emphasis>not</emphasis> be 142 + disabled explicitly. 143 + </para> 125 144 <anchor xml:id="locate-example" /> 126 145 <para> 127 146 <emphasis role="strong">Example: NixOS Module for the ··· 182 201 timerConfig.OnCalendar = cfg.interval; 183 202 }; 184 203 }; 204 + } 205 + </programlisting> 206 + <anchor xml:id="exec-escaping-example" /> 207 + <para> 208 + <emphasis role="strong">Example: Escaping in Exec 209 + directives</emphasis> 210 + </para> 211 + <programlisting language="bash"> 212 + { config, lib, pkgs, utils, ... }: 213 + 214 + with lib; 215 + 216 + let 217 + cfg = config.services.echo; 218 + echoAll = pkgs.writeScript &quot;echo-all&quot; '' 219 + #! ${pkgs.runtimeShell} 220 + for s in &quot;$@&quot;; do 221 + printf '%s\n' &quot;$s&quot; 222 + done 223 + ''; 224 + args = [ &quot;a%Nything&quot; &quot;lang=\${LANG}&quot; &quot;;&quot; &quot;/bin/sh -c date&quot; ]; 225 + in { 226 + systemd.services.echo = 227 + { description = &quot;Echo to the journal&quot;; 228 + wantedBy = [ &quot;multi-user.target&quot; ]; 229 + serviceConfig.Type = &quot;oneshot&quot;; 230 + serviceConfig.ExecStart = '' 231 + ${echoAll} ${utils.escapeSystemdExecArgs args} 232 + ''; 233 + }; 185 234 } 186 235 </programlisting> 187 236 <xi:include href="option-declarations.section.xml" />
+20
nixos/lib/utils.nix
··· 45 45 replaceChars ["/" "-" " "] ["-" "\\x2d" "\\x20"] 46 46 (removePrefix "/" s); 47 47 48 + # Quotes an argument for use in Exec* service lines. 49 + # systemd accepts "-quoted strings with escape sequences, toJSON produces 50 + # a subset of these. 51 + # Additionally we escape % to disallow expansion of % specifiers. Any lone ; 52 + # in the input will be turned it ";" and thus lose its special meaning. 53 + # Every $ is escaped to $$, this makes it unnecessary to disable environment 54 + # substitution for the directive. 55 + escapeSystemdExecArg = arg: 56 + let 57 + s = if builtins.isPath arg then "${arg}" 58 + else if builtins.isString arg then arg 59 + else if builtins.isInt arg || builtins.isFloat arg then toString arg 60 + else throw "escapeSystemdExecArg only allows strings, paths and numbers"; 61 + in 62 + replaceChars [ "%" "$" ] [ "%%" "$$" ] (builtins.toJSON s); 63 + 64 + # Quotes a list of arguments into a single string for use in a Exec* 65 + # line. 66 + escapeSystemdExecArgs = concatMapStringsSep " " escapeSystemdExecArg; 67 + 48 68 # Returns a system path for a given shell package 49 69 toShellPath = shell: 50 70 if types.shellPackage.check shell then
+1
nixos/tests/all-tests.nix
··· 459 459 systemd-boot = handleTest ./systemd-boot.nix {}; 460 460 systemd-confinement = handleTest ./systemd-confinement.nix {}; 461 461 systemd-cryptenroll = handleTest ./systemd-cryptenroll.nix {}; 462 + systemd-escaping = handleTest ./systemd-escaping.nix {}; 462 463 systemd-journal = handleTest ./systemd-journal.nix {}; 463 464 systemd-networkd = handleTest ./systemd-networkd.nix {}; 464 465 systemd-networkd-dhcpserver = handleTest ./systemd-networkd-dhcpserver.nix {};
nixos/tests/empty-file

This is a binary file and will not be displayed.

+45
nixos/tests/systemd-escaping.nix
··· 1 + import ./make-test-python.nix ({ pkgs, ... }: 2 + 3 + let 4 + echoAll = pkgs.writeScript "echo-all" '' 5 + #! ${pkgs.runtimeShell} 6 + for s in "$@"; do 7 + printf '%s\n' "$s" 8 + done 9 + ''; 10 + # deliberately using a local empty file instead of pkgs.emptyFile to have 11 + # a non-store path in the test 12 + args = [ "a%Nything" "lang=\${LANG}" ";" "/bin/sh -c date" ./empty-file 4.2 23 ]; 13 + in 14 + { 15 + name = "systemd-escaping"; 16 + 17 + machine = { pkgs, lib, utils, ... }: { 18 + systemd.services.echo = 19 + assert !(builtins.tryEval (utils.escapeSystemdExecArgs [ [] ])).success; 20 + assert !(builtins.tryEval (utils.escapeSystemdExecArgs [ {} ])).success; 21 + assert !(builtins.tryEval (utils.escapeSystemdExecArgs [ null ])).success; 22 + assert !(builtins.tryEval (utils.escapeSystemdExecArgs [ false ])).success; 23 + assert !(builtins.tryEval (utils.escapeSystemdExecArgs [ (_:_) ])).success; 24 + { description = "Echo to the journal"; 25 + serviceConfig.Type = "oneshot"; 26 + serviceConfig.ExecStart = '' 27 + ${echoAll} ${utils.escapeSystemdExecArgs args} 28 + ''; 29 + }; 30 + }; 31 + 32 + testScript = '' 33 + machine.wait_for_unit("multi-user.target") 34 + machine.succeed("systemctl start echo.service") 35 + # skip the first 'Starting <service> ...' line 36 + logs = machine.succeed("journalctl -u echo.service -o cat").splitlines()[1:] 37 + assert "a%Nything" == logs[0] 38 + assert "lang=''${LANG}" == logs[1] 39 + assert ";" == logs[2] 40 + assert "/bin/sh -c date" == logs[3] 41 + assert "/nix/store/ij3gw72f4n5z4dz6nnzl1731p9kmjbwr-empty-file" == logs[4] 42 + assert "4.2" in logs[5] # toString produces extra fractional digits! 43 + assert "23" == logs[6] 44 + ''; 45 + })