[Backport release-26.05] nixos/lib/utils: fix escapeSystemdPath for paths with "." segments (#535004)

This commit is contained in:
Florian Klink 2026-06-24 22:35:07 +00:00 committed by GitHub
commit f66c4456b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 62 additions and 14 deletions

View file

@ -14,6 +14,7 @@ let
escapeShellArg
filter
flatten
foldl'
getName
hasPrefix
hasSuffix
@ -31,13 +32,13 @@ let
nameValuePair
optionalString
removePrefix
removeSuffix
replaceStrings
splitString
stringToCharacters
types
;
inherit (lib.strings) toJSON normalizePath escapeC;
inherit (lib.strings) toJSON escapeC;
in
let
@ -98,26 +99,60 @@ let
|| hasPrefix a'.mountPoint b'.mountPoint
|| any (hasPrefix a'.mountPoint) b'.depends;
# Escape a path according to the systemd rules. FIXME: slow
# Escape a path according to the systemd rules.
# The rules are described in systemd.unit(5) as follows:
# The escaping algorithm operates as follows: given a string, any "/" character is replaced by "-", and all other characters which are not ASCII alphanumerics, ":", "_" or "." are replaced by C-style "\x2d" escapes. In addition, "." is replaced with such a C-style escape when it would appear as the first character in the escaped string.
# When the input qualifies as absolute file system path, this algorithm is extended slightly: the path to the root directory "/" is encoded as single dash "-". In addition, any leading, trailing or duplicate "/" characters are removed from the string before transformation. Example: /foo//bar/baz/ becomes "foo-bar-baz".
escapeSystemdPath =
s:
let
# These don't depend on the path being escaped, so build them once
# rather than on every call.
escapeChar = escapeC (stringToCharacters " !\"#$%&'()*+,;<=>?@[\\]^`{|}~-");
escapeLeadingDot = escapeC [ "." ] ".";
slashesToDashes = replaceStrings [ "/" ] [ "-" ];
replacePrefix =
p: r: s:
(if (hasPrefix p s) then r + (removePrefix p s) else s);
trim = s: removeSuffix "/" (removePrefix "/" s);
normalizedPath = normalizePath s;
(if hasPrefix p s then r + removePrefix p s else s);
in
replaceStrings [ "/" ] [ "-" ] (
replacePrefix "." (escapeC [ "." ] ".") (
escapeC (stringToCharacters " !\"#$%&'()*+,;<=>=@[\\]^`{|}~-") (
if normalizedPath == "/" then normalizedPath else trim normalizedPath
)
)
);
s:
let
isAbsolute = hasPrefix "/" s;
# path_simplify(): collapse duplicate slashes and drop "." components.
rawComponents = filter (c: c != "" && c != ".") (splitString "/" s);
# systemd accepts ".." only where it is redundant: a leading ".." in an
# absolute path refers to the root's parent, i.e. the root itself, and is
# dropped. Any other ".." cannot be resolved without the filesystem, so
# the path is not normalized and systemd-escape errors on it.
simplified =
foldl'
(
acc: c:
if c == ".." then
# A leading ".." in an absolute path is the only redundant case.
if isAbsolute && acc.components == [ ] then acc else acc // { normalized = false; }
else
acc // { components = acc.components ++ [ c ]; }
)
{
components = [ ];
normalized = true;
}
rawComponents;
notNormalized = throw "escapeSystemdPath: ${s} is not a normalized path";
simplifiedPath =
if !simplified.normalized then
notNormalized
else if simplified.components != [ ] then
concatStringsSep "/" simplified.components
# The root directory, and - matching systemd-escape - the empty string.
else if isAbsolute || s == "" then
"/"
# A relative path that reduces to nothing (e.g. "."), which has no
# valid escaping.
else
notNormalized;
in
slashesToDashes (replacePrefix "." escapeLeadingDot (escapeChar simplifiedPath));
# Quotes an argument for use in Exec* service lines.
# systemd accepts "-quoted strings with escape sequences, toJSON produces

View file

@ -36,6 +36,19 @@ in
assert !(builtins.tryEval (utils.escapeSystemdExecArgs [ null ])).success;
assert !(builtins.tryEval (utils.escapeSystemdExecArgs [ false ])).success;
assert !(builtins.tryEval (utils.escapeSystemdExecArgs [ (_: _) ])).success;
# escapeSystemdPath simplifies the path like systemd-escape --path does:
# "." components are dropped and duplicate/leading/trailing slashes removed.
assert utils.escapeSystemdPath "/mnt/./foo" == "mnt-foo";
assert utils.escapeSystemdPath "/foo//bar/baz/" == "foo-bar-baz";
assert utils.escapeSystemdPath "/" == "-";
assert utils.escapeSystemdPath "" == "-";
assert utils.escapeSystemdPath "/.hidden/x" == "\\x2ehidden-x";
assert utils.escapeSystemdPath "/foo?bar" == "foo\\x3fbar";
# A leading ".." in an absolute path is the root's parent, i.e. the root.
assert utils.escapeSystemdPath "/../foo" == "foo";
# Non-normalized paths can't be escaped, matching systemd-escape.
assert !(builtins.tryEval (utils.escapeSystemdPath "/mnt/../foo")).success;
assert !(builtins.tryEval (utils.escapeSystemdPath ".")).success;
{
description = "Echo to the journal";
serviceConfig.Type = "oneshot";