defmodule Systemd.UnitFile.Validator do @moduledoc false alias Systemd.UnitFile alias Systemd.UnitFile.{Directive, Section, ValidationError, Value, ValueParser} @known_sections %{ "service" => MapSet.new(["Unit", "Service", "Install"]), "socket" => MapSet.new(["Unit", "Socket", "Install"]), "timer" => MapSet.new(["Unit", "Timer", "Install"]), "target" => MapSet.new(["Unit", "Target", "Install"]), "mount" => MapSet.new(["Unit", "Mount", "Install"]), "path" => MapSet.new(["Unit", "Path", "Install"]) } @required_sections %{ "service" => ["Service"], "socket" => ["Socket"], "timer" => ["Timer"], "target" => ["Target"], "mount" => ["Mount"], "path" => ["Path"] } @known_directives %{ "Unit" => MapSet.new( ~w(Description Documentation Requires Wants After Before BindsTo PartOf Conflicts RequiresMountsFor ConditionPathExists AssertPathExists StartLimitIntervalSec StartLimitBurst) ), "Service" => MapSet.new( ~w(Type ExecStart ExecStartPre ExecStartPost ExecCondition ExecReload ExecStop ExecStopPost Restart RestartSec User Group WorkingDirectory RootDirectory Environment EnvironmentFile PassEnvironment UnsetEnvironment TimeoutStartSec TimeoutStopSec TimeoutAbortSec KillSignal KillMode RemainAfterExit GuessMainPID PIDFile RuntimeDirectory RuntimeDirectoryPreserve StateDirectory CacheDirectory LogsDirectory ConfigurationDirectory StandardOutput StandardError SyslogIdentifier Slice Delegate CPUAccounting CPUWeight CPUQuota MemoryAccounting MemoryMin MemoryLow MemoryHigh MemoryMax MemorySwapMax TasksAccounting TasksMax IOAccounting IOWeight IPAccounting LimitNOFILE LimitNPROC LimitMEMLOCK LimitCORE LimitCPU LimitAS LimitFSIZE NoNewPrivileges PrivateTmp PrivateDevices PrivateNetwork PrivateUsers ProtectSystem ProtectHome ProtectKernelTunables ProtectKernelModules ProtectKernelLogs ProtectControlGroups ProtectClock ProtectHostname ProtectProc RestrictAddressFamilies RestrictNamespaces RestrictRealtime RestrictSUIDSGID SystemCallFilter SystemCallArchitectures SystemCallErrorNumber AmbientCapabilities CapabilityBoundingSet ReadWritePaths ReadOnlyPaths InaccessiblePaths OOMPolicy) ), "Install" => MapSet.new(~w(WantedBy RequiredBy Also Alias DefaultInstance)), "Socket" => MapSet.new( ~w(ListenStream ListenDatagram ListenSequentialPacket ListenFIFO ListenSpecial ListenNetlink ListenMessageQueue ListenUSBFunction SocketUser SocketGroup SocketMode DirectoryMode Accept Writable MaxConnections MaxConnectionsPerSource KeepAlive NoDelay FreeBind BindIPv6Only Backlog Service) ), "Timer" => MapSet.new( ~w(OnActiveSec OnBootSec OnStartupSec OnUnitActiveSec OnUnitInactiveSec OnCalendar Unit Persistent AccuracySec RandomizedDelaySec FixedRandomDelay WakeSystem RemainAfterElapse) ), "Target" => MapSet.new(~w(AllowIsolate StopWhenUnneeded RefuseManualStart RefuseManualStop)), "Mount" => MapSet.new( ~w(What Where Type Options SloppyOptions LazyUnmount ForceUnmount DirectoryMode TimeoutSec ReadWriteOnly ExtraOptions) ), "Path" => MapSet.new( ~w(PathExists PathExistsGlob PathChanged PathModified DirectoryNotEmpty Unit MakeDirectory DirectoryMode TriggerLimitIntervalSec TriggerLimitBurst) ) } @doc false @spec validate(UnitFile.t(), String.t() | atom() | nil) :: :ok | {:error, [ValidationError.t()]} def validate(%UnitFile{} = unit_file, type \\ nil) do errors = [] |> collect_duplicate_section_errors(unit_file) |> collect_unknown_section_errors(unit_file, normalize_type(type)) |> collect_missing_section_errors(unit_file, normalize_type(type)) |> collect_directive_scope_errors(unit_file) |> collect_unknown_directive_errors(unit_file) |> collect_directive_value_errors(unit_file) case Enum.reverse(errors) do [] -> :ok errors -> {:error, errors} end end defp collect_duplicate_section_errors(errors, unit_file) do {_seen, errors} = Enum.reduce(unit_file.entries, {MapSet.new(), errors}, fn %Section{name: name, span: span}, {seen, errors} -> if MapSet.member?(seen, name) do {seen, [ error(:duplicate_section, "duplicate section #{inspect(name)}", name, nil, span) | errors ]} else {MapSet.put(seen, name), errors} end _entry, acc -> acc end) errors end defp collect_unknown_section_errors(errors, _unit_file, nil), do: errors defp collect_unknown_section_errors(errors, unit_file, type) do allowed = Map.get(@known_sections, type, MapSet.new()) Enum.reduce(unit_file.entries, errors, fn %Section{name: name, span: span}, errors -> if MapSet.member?(allowed, name) do errors else [ error(:unknown_section, "unknown #{type} section #{inspect(name)}", name, nil, span) | errors ] end _entry, errors -> errors end) end defp collect_missing_section_errors(errors, _unit_file, nil), do: errors defp collect_missing_section_errors(errors, unit_file, type) do present = unit_file |> section_names() |> MapSet.new() type |> required_sections() |> Enum.reduce(errors, fn section, errors -> if MapSet.member?(present, section) do errors else [ error( :missing_section, "missing required section #{inspect(section)}", section, nil, nil ) | errors ] end end) end defp collect_unknown_directive_errors(errors, unit_file) do {_section, errors} = Enum.reduce(unit_file.entries, {nil, errors}, fn %Section{name: section}, {_current_section, errors} -> {section, errors} %Directive{name: directive, span: span}, {section, errors} when is_binary(section) -> allowed = Map.get(@known_directives, section) if is_nil(allowed) or MapSet.member?(allowed, directive) do {section, errors} else {section, [ error( :unknown_directive, "unknown directive #{inspect(directive)} in section #{inspect(section)}", section, directive, span ) | errors ]} end _entry, acc -> acc end) errors end defp collect_directive_value_errors(errors, unit_file) do {_section, errors} = Enum.reduce(unit_file.entries, {nil, errors}, fn %Section{name: section}, {_current_section, errors} -> {section, errors} %Directive{name: directive, value: value, span: span}, {section, errors} -> errors = section |> value_errors(directive, value, span) |> Enum.concat(errors) {section, errors} _entry, acc -> acc end) errors end defp value_errors("Service", "Type", value, span) do one_of( "Service", "Type", value, ~w(simple exec forking oneshot dbus notify notify-reload idle), span ) end defp value_errors("Service", "Restart", value, span) do one_of( "Service", "Restart", value, ~w(no on-success on-failure on-abnormal on-watchdog on-abort always), span ) end defp value_errors("Service", directive, value, span) when directive in ["ExecStart", "ExecReload", "ExecStop"] do non_empty("Service", directive, value, span) end defp value_errors("Service", directive, value, span) when directive in ["TimeoutStartSec", "TimeoutStopSec", "TimeoutAbortSec", "RestartSec"] do duration("Service", directive, value, span) end defp value_errors("Service", directive, value, span) when directive in [ "RemainAfterExit", "GuessMainPID", "Delegate", "CPUAccounting", "MemoryAccounting", "TasksAccounting", "IOAccounting", "IPAccounting", "NoNewPrivileges", "PrivateTmp", "PrivateDevices", "PrivateNetwork", "PrivateUsers", "ProtectKernelTunables", "ProtectKernelModules", "ProtectKernelLogs", "ProtectControlGroups", "ProtectClock", "ProtectHostname", "RestrictNamespaces", "RestrictRealtime", "RestrictSUIDSGID" ] do boolean("Service", directive, value, span) end defp value_errors("Service", directive, value, span) when directive in [ "MemoryMin", "MemoryLow", "MemoryHigh", "MemoryMax", "MemorySwapMax", "TasksMax" ] do resource_limit("Service", directive, value, span) end defp value_errors("Service", directive, value, span) when directive in ["CPUWeight", "IOWeight"] do positive_integer("Service", directive, value, span) end defp value_errors("Service", "CPUQuota", value, span), do: percentage("Service", "CPUQuota", value, span) defp value_errors("Service", "ProtectSystem", value, span) do one_of( "Service", "ProtectSystem", String.downcase(value), ~w(1 yes true on 0 no false off full strict), span ) end defp value_errors("Service", "ProtectHome", value, span) do one_of( "Service", "ProtectHome", String.downcase(value), ~w(1 yes true on 0 no false off read-only tmpfs), span ) end defp value_errors("Service", "ProtectProc", value, span) do one_of("Service", "ProtectProc", value, ~w(default invisible ptraceable noaccess), span) end defp value_errors("Service", directive, value, span) when directive in [ "RestrictAddressFamilies", "SystemCallFilter", "SystemCallArchitectures", "SystemCallErrorNumber", "AmbientCapabilities", "CapabilityBoundingSet", "ReadWritePaths", "ReadOnlyPaths", "InaccessiblePaths" ] do non_empty_words("Service", directive, value, span) end defp value_errors("Service", "KillMode", value, span) do one_of("Service", "KillMode", value, ~w(control-group mixed process none), span) end defp value_errors("Timer", directive, value, span) when directive in [ "OnActiveSec", "OnBootSec", "OnStartupSec", "OnUnitActiveSec", "OnUnitInactiveSec", "AccuracySec", "RandomizedDelaySec" ] do duration("Timer", directive, value, span) end defp value_errors("Timer", directive, value, span) when directive in ["Persistent", "FixedRandomDelay", "WakeSystem", "RemainAfterElapse"] do boolean("Timer", directive, value, span) end defp value_errors("Timer", "OnCalendar", value, span), do: non_empty("Timer", "OnCalendar", value, span) defp value_errors("Socket", directive, value, span) when directive in [ "ListenStream", "ListenDatagram", "ListenSequentialPacket", "ListenFIFO", "ListenSpecial", "ListenNetlink", "ListenMessageQueue", "ListenUSBFunction" ] do non_empty("Socket", directive, value, span) end defp value_errors("Socket", directive, value, span) when directive in ["SocketMode", "DirectoryMode"] do octal_mode("Socket", directive, value, span) end defp value_errors("Socket", directive, value, span) when directive in ["Accept", "Writable", "KeepAlive", "NoDelay", "FreeBind"] do boolean("Socket", directive, value, span) end defp value_errors("Target", directive, value, span) when directive in [ "AllowIsolate", "StopWhenUnneeded", "RefuseManualStart", "RefuseManualStop" ] do boolean("Target", directive, value, span) end defp value_errors("Mount", directive, value, span) when directive in ["What", "Where"] do non_empty("Mount", directive, value, span) end defp value_errors("Mount", directive, value, span) when directive in ["SloppyOptions", "LazyUnmount", "ForceUnmount", "ReadWriteOnly"] do boolean("Mount", directive, value, span) end defp value_errors("Mount", "DirectoryMode", value, span), do: octal_mode("Mount", "DirectoryMode", value, span) defp value_errors("Mount", "TimeoutSec", value, span), do: duration("Mount", "TimeoutSec", value, span) defp value_errors("Path", directive, value, span) when directive in [ "PathExists", "PathExistsGlob", "PathChanged", "PathModified", "DirectoryNotEmpty" ] do non_empty("Path", directive, value, span) end defp value_errors("Path", "MakeDirectory", value, span), do: boolean("Path", "MakeDirectory", value, span) defp value_errors("Path", "DirectoryMode", value, span), do: octal_mode("Path", "DirectoryMode", value, span) defp value_errors("Path", "TriggerLimitIntervalSec", value, span), do: duration("Path", "TriggerLimitIntervalSec", value, span) defp value_errors("Install", directive, value, span) when directive in ["WantedBy", "RequiredBy", "Also", "Alias"] do non_empty_words("Install", directive, value, span) end defp value_errors(_section, _directive, _value, _span), do: [] defp one_of(section, directive, value, allowed, span) do if value in allowed do [] else [ value_error( section, directive, value, "expected one of #{Enum.join(allowed, ", ")}", span ) ] end end defp boolean(section, directive, value, span) do one_of(section, directive, String.downcase(value), ~w(1 yes true on 0 no false off), span) end defp duration(section, directive, value, span) do if ValueParser.duration?(value) do [] else [ value_error( section, directive, value, "expected a systemd duration such as 10s, 5min, or infinity", span ) ] end end defp resource_limit(_section, _directive, "infinity", _span), do: [] defp resource_limit(section, directive, value, span) do if ValueParser.resource_quantity?(value) do [] else [ value_error( section, directive, value, "expected bytes, K/M/G/T/P/E suffix, or infinity", span ) ] end end defp positive_integer(section, directive, value, span) do if ValueParser.positive_integer?(value) do [] else [value_error(section, directive, value, "expected a positive integer", span)] end end defp percentage(section, directive, value, span) do if ValueParser.percentage?(value) do [] else [value_error(section, directive, value, "expected a percentage such as 50%", span)] end end defp octal_mode(section, directive, value, span) do if ValueParser.octal_mode?(value) do [] else [value_error(section, directive, value, "expected an octal mode such as 0660", span)] end end defp non_empty(section, directive, value, span) do if String.trim(value) == "" do [value_error(section, directive, value, "must not be empty", span)] else [] end end defp non_empty_words(section, directive, value, span) do case Value.words(value) do {:ok, [_ | _]} -> [] _other -> [value_error(section, directive, value, "must contain at least one value", span)] end end defp value_error(section, directive, value, expectation, span) do error( :invalid_directive_value, "invalid #{section}.#{directive} value #{inspect(value)}: #{expectation}", section, directive, span ) end defp collect_directive_scope_errors(errors, unit_file) do {_section, errors} = Enum.reduce(unit_file.entries, {nil, errors}, fn %Section{name: section}, {_current_section, errors} -> {section, errors} %Directive{name: directive, span: span}, {nil, errors} -> {nil, [ error( :directive_outside_section, "directive #{inspect(directive)} appears before any section", nil, directive, span ) | errors ]} _entry, acc -> acc end) errors end defp section_names(unit_file) do Enum.flat_map(unit_file.entries, fn %Section{name: name} -> [name] _entry -> [] end) end defp required_sections(type), do: Map.get(@required_sections, type, []) defp normalize_type(nil), do: nil defp normalize_type(type) when is_atom(type), do: Atom.to_string(type) defp normalize_type(type) when is_binary(type), do: String.trim_leading(type, ".") defp error(reason, message, section, directive, span) do %ValidationError{ reason: reason, message: message, section: section, directive: directive, span: span } end end