Skip to main content

lib/systemd/unit_file/validator.ex

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