diff --git a/container.nix b/container.nix index e94594e..06535f3 100644 --- a/container.nix +++ b/container.nix @@ -15,6 +15,7 @@ let example = [ "NET_ADMIN" ]; description = "--cap-add"; property = "AddCapability"; + encoding = "quoted_unescaped"; }; addHosts = quadletUtils.mkOption { @@ -31,6 +32,7 @@ let example = [ "/dev/foo" ]; description = "--device"; property = "AddDevice"; + encoding = "quoted_unescaped"; }; annotations = quadletUtils.mkOption { @@ -39,6 +41,7 @@ let example = [ "XYZ" ]; description = "--annotation"; property = "Annotation"; + encoding = "quoted_escaped"; }; autoUpdate = quadletUtils.mkOption { @@ -108,6 +111,7 @@ let example = [ "NET_ADMIN" ]; description = "--cap-drop"; property = "DropCapability"; + encoding = "quoted_unescaped"; }; entrypoint = quadletUtils.mkOption { @@ -126,6 +130,7 @@ let }; description = "--env"; property = "Environment"; + encoding = "quoted_escaped"; }; environmentFiles = quadletUtils.mkOption { @@ -134,6 +139,7 @@ let example = [ "/tmp/env" ]; description = "--env-file"; property = "EnvironmentFile"; + encoding = "quoted_escaped"; }; environmentHost = quadletUtils.mkOption { @@ -144,11 +150,12 @@ let }; exec = quadletUtils.mkOption { - type = types.nullOr types.str; + type = types.nullOr (types.oneOf [ types.str (types.listOf types.str) ]); default = null; example = "/usr/bin/command"; description = "Command after image specification"; property = "Exec"; + encoding = "quoted_escaped_singleline"; }; exposePorts = quadletUtils.mkOption { @@ -165,6 +172,7 @@ let example = [ "0:10000:10" ]; description = "--gidmap"; property = "GIDMap"; + encoding = "quoted_unescaped"; }; globalArgs = quadletUtils.mkOption { @@ -173,6 +181,7 @@ let example = [ "--log-level=debug" ]; description = "global args"; property = "GlobalArgs"; + encoding = "quoted_escaped"; }; group = quadletUtils.mkOption { @@ -341,6 +350,7 @@ let example = [ "XYZ" ]; description = "--label"; property = "Label"; + encoding = "quoted_escaped"; }; logDriver = quadletUtils.mkOption { @@ -357,6 +367,7 @@ let example = [ "path=/var/log/mykube.json" ]; description = "--log-opt"; property = "LogOpt"; + encoding = "quoted_unescaped"; }; mask = quadletUtils.mkOption { @@ -365,6 +376,7 @@ let example = "/proc/sys/foo:/proc/sys/bar"; description = "--security-opt mask=..."; property = "Mask"; + encoding = "quoted_escaped"; }; mounts = quadletUtils.mkOption { @@ -373,6 +385,7 @@ let example = [ "type=..." ]; description = "--mount"; property = "Mount"; + encoding = "quoted_escaped"; }; networks = quadletUtils.mkOption { @@ -426,6 +439,7 @@ let example = [ "--add-host foobar" ]; description = "Additional podman arguments"; property = "PodmanArgs"; + encoding = "quoted_escaped"; }; publishPorts = quadletUtils.mkOption { @@ -487,6 +501,7 @@ let example = [ "secret[,opt=opt …]" ]; description = "--secret"; property = "Secret"; + encoding = "quoted_escaped"; }; securityLabelDisable = quadletUtils.mkOption { @@ -582,6 +597,7 @@ let }; description = "--sysctl"; property = "Sysctl"; + encoding = "quoted_unescaped"; }; timezone = quadletUtils.mkOption { @@ -606,6 +622,7 @@ let example = [ "0:10000:10" ]; description = "--uidmap"; property = "UIDMap"; + encoding = "quoted_unescaped"; }; ulimits = quadletUtils.mkOption { @@ -622,6 +639,7 @@ let example = "ALL"; description = "--security-opt unmask=..."; property = "Unmask"; + encoding = "quoted_escaped"; }; user = quadletUtils.mkOption { @@ -691,6 +709,7 @@ in _serviceName = mkOption { internal = true; }; _configText = mkOption { internal = true; }; _autoStart = mkOption { internal = true; }; + _autoEscapeRequired = mkOption { internal = true; }; ref = mkOption { readOnly = true; }; }; @@ -714,6 +733,7 @@ in then config.rawConfig else quadletUtils.unitConfigToText unitConfig; _autoStart = config.autoStart; + _autoEscapeRequired = quadletUtils.autoEscapeRequired containerConfig containerOpts; ref = "${name}.container"; }; } diff --git a/home-manager-module.nix b/home-manager-module.nix index 5329db9..ed8444f 100644 --- a/home-manager-module.nix +++ b/home-manager-module.nix @@ -7,13 +7,14 @@ ... }: let - inherit (lib) types mkOption attrValues mergeAttrsList mkIf getExe; + inherit (lib) types lists strings mkOption attrNames attrValues mergeAttrsList mkIf getExe; cfg = config.virtualisation.quadlet; quadletUtils = import ./utils.nix { inherit lib; systemdUtils = (libUtils { inherit lib config pkgs; }).systemdUtils; podmanPackage = osConfig.virtualisation.podman.package or pkgs.podman; + autoEscape = config.virtualisation.quadlet.autoEscape; }; containerOpts = types.submodule (import ./container.nix { inherit quadletUtils; }); networkOpts = types.submodule (import ./network.nix { inherit quadletUtils; }); @@ -53,6 +54,15 @@ in type = types.attrsOf volumeOpts; default = { }; }; + autoEscape = mkOption { + type = types.bool; + default = false; + description = '' + Enables appropriate quoting / escaping. + + Not enabled by default to avoid breaking existing configurations. In the future this will be required. + ''; + }; }; config = let @@ -64,6 +74,32 @@ in ]); in { + assertions = + let + containerPodConflicts = lists.intersectLists (attrNames cfg.containers) (attrNames cfg.pods); + in + [ + { + assertion = containerPodConflicts == [ ]; + message = '' + The container/pod names should be unique! + See: https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html#podname + The following names are not unique: ${strings.concatStringsSep " " containerPodConflicts} + ''; + } + ]; + warnings = + quadletUtils.assertionsToWarnings [ + { + assertion = !(builtins.any (p: p._autoEscapeRequired) allObjects); + message = '' + `virtualisation.quadlet.autoEscape = true` is required because this configuration contains characters that require quoting or escaping. + + This will become a hard error in the future. If you have manual quoting or escaping in place, please undo those and enable `autoEscape`. + ''; + } + ]; + home.activation.quadletNix = mkIf (lib.length allObjects > 0) activationScript; xdg.configFile = diff --git a/network.nix b/network.nix index 0e37fe3..492b72d 100644 --- a/network.nix +++ b/network.nix @@ -62,6 +62,7 @@ let example = [ "--log-level=debug" ]; description = "global args"; property = "GlobalArgs"; + encoding = "quoted_escaped"; }; internal = quadletUtils.mkOption { @@ -106,6 +107,7 @@ let example = [ "XYZ" ]; description = "--label"; property = "Label"; + encoding = "quoted_escaped"; }; name = quadletUtils.mkOption { @@ -122,6 +124,7 @@ let example = "isolate"; description = "--opt"; property = "Options"; + encoding = "quoted_escaped"; }; podmanArgs = quadletUtils.mkOption { @@ -130,6 +133,7 @@ let example = [ "--dns=192.168.55.1" ]; description = "extra arguments to podman"; property = "PodmanArgs"; + encoding = "quoted_escaped"; }; subnets = quadletUtils.mkOption { @@ -170,6 +174,7 @@ in _serviceName = mkOption { internal = true; }; _configText = mkOption { internal = true; }; _autoStart = mkOption { internal = true; }; + _autoEscapeRequired = mkOption { internal = true; }; ref = mkOption { readOnly = true; }; }; @@ -194,6 +199,7 @@ in then config.rawConfig else quadletUtils.unitConfigToText unitConfig; _autoStart = config.autoStart; + _autoEscapeRequired = quadletUtils.autoEscapeRequired networkConfig networkOpts; ref = "${name}.network"; }; } diff --git a/nixos-module.nix b/nixos-module.nix index 55cb038..f234a36 100644 --- a/nixos-module.nix +++ b/nixos-module.nix @@ -13,6 +13,7 @@ let inherit lib; systemdUtils = (libUtils { inherit lib config pkgs; }).systemdUtils; podmanPackage = config.virtualisation.podman.package; + autoEscape = config.virtualisation.quadlet.autoEscape; }; containerOpts = types.submodule (import ./container.nix { inherit quadletUtils; }); @@ -42,6 +43,16 @@ in type = types.attrsOf volumeOpts; default = { }; }; + + autoEscape = mkOption { + type = types.bool; + default = false; + description = '' + Enables appropriate quoting / escaping. + + Not enabled by default to avoid breaking existing configurations. In the future this will be required. + ''; + }; }; }; @@ -70,6 +81,17 @@ in ''; } ]; + warnings = + quadletUtils.assertionsToWarnings [ + { + assertion = !(builtins.any (p: p._autoEscapeRequired) allObjects); + message = '' + `virtualisation.quadlet.autoEscape = true` is required because this configuration contains characters that require quoting or escaping. + + This will become a hard error in the future. If you have manual quoting or escaping in place, please undo those and enable `autoEscape`. + ''; + } + ]; environment.etc = # TODO: switch to `systemd.user.generators` once 24.11 is released. # Ensure podman-user-generator is available for systemd user services. diff --git a/pod.nix b/pod.nix index d2bd33e..639fec2 100644 --- a/pod.nix +++ b/pod.nix @@ -63,6 +63,7 @@ let example = [ "0:10000:10" ]; description = "--gidmap"; property = "GIDMap"; + encoding = "quoted_unescaped"; }; globalArgs = quadletUtils.mkOption { @@ -71,6 +72,7 @@ let example = [ "--log-level=debug" ]; description = "global args"; property = "GlobalArgs"; + encoding = "quoted_escaped"; }; ip = quadletUtils.mkOption { @@ -111,6 +113,7 @@ let example = [ "--cpus=2" ]; description = "Additional podman arguments"; property = "PodmanArgs"; + encoding = "quoted_escaped"; }; publishPorts = quadletUtils.mkOption { @@ -151,6 +154,7 @@ let example = [ "0:10000:10" ]; description = "--uidmap"; property = "UIDMap"; + encoding = "quoted_unescaped"; }; userns = quadletUtils.mkOption { @@ -199,6 +203,7 @@ in _serviceName = mkOption { internal = true; }; _configText = mkOption { internal = true; }; _autoStart = mkOption { internal = true; }; + _autoEscapeRequired = mkOption { internal = true; }; ref = mkOption { readOnly = true; }; }; @@ -225,6 +230,7 @@ in _configText = if config.rawConfig != null then config.rawConfig else quadletUtils.unitConfigToText unitConfig; + _autoEscapeRequired = quadletUtils.autoEscapeRequired podConfig podOpts; _autoStart = config.autoStart; ref = "${name}.pod"; }; diff --git a/tests/escaping.nix b/tests/escaping.nix new file mode 100644 index 0000000..50a451e --- /dev/null +++ b/tests/escaping.nix @@ -0,0 +1,39 @@ +{ + testConfig = { pkgs, ... }: { + virtualisation.quadlet = { + autoEscape = true; + containers.write = { + containerConfig = { + image = "docker-archive:${pkgs.dockerTools.examples.bash}"; + # quoted_unescaped + addCapabilities = [ "SYS_NICE" ]; + entrypoint = "bash"; + # quoted_escaped + environments = { + FOO = "aaa bbb \"ccc\n\n "; + bar = "\"aaa\""; + }; + # quoted_escaped_singleline + exec = "-c 'echo -n \"$FOO\" > /tmp/foo.txt; echo -n \"$bar\" > /tmp/bar.txt'"; + volumes = [ + "/tmp:/tmp" + ]; + }; + serviceConfig = { + RemainAfterExit = true; + }; + }; + }; + }; + testScript = '' + machine.wait_for_unit("default.target") + machine.wait_for_unit("default.target", user=user) + machine.wait_for_unit("write.service", user=user, timeout=30) + + machine.wait_for_file("/tmp/foo.txt", timeout=10) + assert machine.succeed("cat /tmp/foo.txt") == 'aaa bbb "ccc\n\n ' + + machine.wait_for_file("/tmp/bar.txt", timeout=10) + assert machine.succeed("cat /tmp/bar.txt") == '"aaa"' + ''; +} diff --git a/tests/flake.nix b/tests/flake.nix index f4cd91d..12f7998 100644 --- a/tests/flake.nix +++ b/tests/flake.nix @@ -135,6 +135,8 @@ (genRootlessTest ./raw.nix) (genRootfulTest ./health.nix) (genRootlessTest ./health.nix) + (genRootfulTest ./escaping.nix) + (genRootlessTest ./escaping.nix) ]; in { "${system}" = tests; diff --git a/utils.nix b/utils.nix index 10df248..8b77cc9 100644 --- a/utils.nix +++ b/utils.nix @@ -1,26 +1,64 @@ -{ lib, systemdUtils, podmanPackage }: +{ lib, systemdUtils, podmanPackage, autoEscape }: let - attrsToList = - attrs: - if builtins.isAttrs attrs then - lib.mapAttrsToList (name: value: "${name}=${toString value}") attrs + encodeValue = encoding: value: + if encoding == null then + systemdUtils.lib.toOption value + else if encoding == "quoted_escaped" then + lib.strings.toJSON value # same as systemdUtils.lib.serviceToUnit + else if encoding == "quoted_unescaped" then + "\"${value}\"" + else if encoding == "quoted_escaped_singleline" then + if builtins.isString value then + value + else + builtins.concatStringsSep " " (map (lib.strings.toJSON) value) else - attrs; + throw "quadlet-nix internal error: unknown encoding ${encoding}"; + + encodeValueConcise = encoding: value: + let + raw = encodeValue null value; + encoded = encodeValue encoding value; + in + if encoding == null then + raw + else if (builtins.match ".*[\t\n\r].*" raw) != null then + encoded + else if "\"${raw}\"" == encoded then + raw + else + encoded; + + encodeValuesConcise = encoding: values: + if builtins.isAttrs values then + lib.mapAttrsToList (name: value: encodeValueConcise encoding "${name}=${value}") values + else if builtins.isList values then + map (encodeValueConcise encoding) values + else + encodeValueConcise encoding values; + + configToProperties = autoEscape: config: options: + let + nonNullConfig = lib.filterAttrs (_: value: value != null) config; + encode = if autoEscape then encodeValuesConcise else _: encodeValuesConcise null; + encodeEntry = name: value: + lib.nameValuePair options.${name}.property + (encode options.${name}.encoding value); + in lib.mapAttrs' encodeEntry nonNullConfig; + in { mkOption = - { property, ... }@attrs: - (lib.mkOption (lib.filterAttrs (name: _: name != "property") attrs)) + { property, encoding ? null, ... }@attrs: + (lib.mkOption (lib.filterAttrs (name: _: !(builtins.elem name [ "property" "encoding" ])) attrs)) // { inherit property; + inherit encoding; }; - configToProperties = - config: options: - lib.mapAttrs' (name: value: lib.nameValuePair options.${name}.property (attrsToList value)) ( - lib.filterAttrs (_: value: value != null) config - ); + configToProperties = config: options: configToProperties autoEscape config options; + autoEscapeRequired = config: options: configToProperties autoEscape config options != configToProperties true config options; unitConfigToText = unitConfig: @@ -28,6 +66,9 @@ in lib.mapAttrsToList (name: section: "[${name}]\n${systemdUtils.lib.attrsToSection section}") unitConfig ); + assertionsToWarnings = asssertions: + map (x: x.message) (builtins.filter (x: !x.assertion) asssertions); + inherit (systemdUtils.unitOptions) unitOption; inherit podmanPackage; } diff --git a/volume.nix b/volume.nix index aea335c..e5eed72 100644 --- a/volume.nix +++ b/volume.nix @@ -46,6 +46,7 @@ let example = [ "--log-level=debug" ]; description = "global args"; property = "GlobalArgs"; + encoding = "quoted_escaped"; }; group = quadletUtils.mkOption { @@ -70,6 +71,7 @@ let example = [ "foo=bar" ]; description = "--label"; property = "Label"; + encoding = "quoted_escaped"; }; modules = quadletUtils.mkOption { @@ -93,6 +95,7 @@ let example = [ "--driver=image" ]; description = "Additional podman arguments"; property = "PodmanArgs"; + encoding = "quoted_escaped"; }; type = quadletUtils.mkOption { @@ -140,6 +143,7 @@ in _serviceName = mkOption { internal = true; }; _configText = mkOption { internal = true; }; _autoStart = mkOption { internal = true; }; + _autoEscapeRequired = mkOption { internal = true; }; ref = mkOption { readOnly = true; }; }; @@ -163,6 +167,7 @@ in then config.rawConfig else quadletUtils.unitConfigToText unitConfig; _autoStart = config.autoStart; + _autoEscapeRequired = quadletUtils.autoEscapeRequired volumeConfig volumeOpts; ref = "${name}.volume"; }; }