Env secrets

use "collections"
use "files"

primitive DotEnvUnreadable
  """
  A `DotEnvError` reported when a file is at the given path but its contents
  could not be read. The `FilePath` may deny `FileRead` or `FileStat`, the
  filesystem permissions may deny it, the path may not name a regular file,
  the file may be larger than `ReadEnvSecrets` will read, or the read may have
  returned less than the whole file. Which of those it was is not reported.
  """
  fun string(): String iso^ =>
    "the .env file could not be read".clone()

class val DotEnvMalformed
  """
  A `DotEnvError` reported for a line that is neither blank, nor a comment,
  nor a `NAME=VALUE` pair. One such line discards the whole file: no entry
  from it reaches the caller, including the entries that did parse.

  The offending line is deliberately not carried: a malformed line is often a
  secret with a formatting mistake, so putting its text in an error puts the
  secret somewhere it can be printed. Report the number rather than the line.
  """
  let line_number: USize
    """
    The 1-based number of the offending line. Blank and comment lines are
    counted, so it matches what an editor shows.
    """

  new val _create(line_number': USize) =>
    line_number = line_number'

  fun string(): String iso^ =>
    "line " + line_number.string() + " of the .env file is malformed"

type DotEnvError is (DotEnvUnreadable | DotEnvMalformed)
  """
  The reasons a `.env` file cannot be used. Both mean secrets the file was
  meant to supply are absent, so neither is safe to carry on from. Both
  describe themselves, so a caller can report one without matching on it.

  A path with no file at it is not one of them. `ReadEnvSecrets` reports that
  as success and returns the process environment alone: a `.env` file is a
  development convenience, and a deployed application is normally handed its
  secrets by the environment.
  """

primitive ReadEnvSecrets
  """
  Read a `.env` file and the process environment into an `EnvSecrets`.

  A path with no file at it is not an error: the result is an `EnvSecrets`
  holding the environment alone. A file that is there but cannot be read, or
  that has a malformed line, is a `DotEnvError`, and neither is safe to carry
  on from.

  Call this once at startup. It reads the file synchronously, so the calling
  actor's scheduler thread waits for the disk.

  Reading the file needs `FileRead` and `FileStat` on `dotenv`. A relative
  path resolves against the working directory, so pass an absolute path when
  that directory is not trusted. A file larger than 1 MiB is not read.

  An environment variable overrides the `.env` file. When a name is set in
  both, the value from `vars` wins and the file's value is discarded, so the
  file supplies only the names the environment leaves unset. A `.env` file is
  a development convenience; that ordering keeps one from shadowing a secret
  a deployment supplies. Where one name is set twice in the same source, the
  first of them wins, which is what `getenv` returns for the environment.

  Names are matched exactly. On Windows, where the environment's own names are
  case-insensitive, a `.env` name that differs from an environment name only
  in case is a second name and is not overridden.

  Each line of the file is one of:

  * blank, or a comment beginning with `#`, both of which are ignored
  * `NAME=VALUE`, split at the first `=`

  A name is one or more letters, digits and underscores, not starting with a
  digit. Anything else is malformed, including the `export NAME=VALUE` that a
  shell would accept.

  Surrounding whitespace is removed from the name and from the value. A value
  wrapped in a matching pair of `"` or `'` has those quotes removed and keeps
  the whitespace inside them. Everything else is part of the value: there is
  no escaping, no `${...}` substitution, no value spanning two lines, and no
  comment after a value, so `A=b # note` binds `b # note`.

  An environment variable's value is taken verbatim instead, so the same text
  can mean different things depending on which source supplied it. `A=hunter2 `
  keeps its trailing space from the environment and loses it from the file.

  Nothing is written back into the process environment. A name read from the
  file is visible through the returned `EnvSecrets` and nowhere else, so it
  does not reach a subprocess.

  ```pony
  use "files"
  use "sensitive"

  actor Main
    new create(env: Env) =>
      let dotenv = FilePath(
        FileAuth(env.root),
        ".env",
        recover val FileCaps .> set(FileRead) .> set(FileStat) end)

      let secrets =
        match ReadEnvSecrets(env.vars, dotenv)
        | let s: EnvSecrets => s
        | let e: DotEnvError =>
          env.err.print(e.string())
          env.exitcode(1)
          return
        end

      try
        let password = secrets("DB_PASSWORD")?
        env.out.print("The password is " + password.string())
      else
        env.out.print("DB_PASSWORD is not set")
      end
  ```
  """
  fun apply(vars: Array[String] val, dotenv: FilePath)
    : (EnvSecrets | DotEnvError) =>
    """
    Read `dotenv` and `vars` into an `EnvSecrets`, or report why the file
    could not be used.
    """
    if not (dotenv.caps(FileRead) and dotenv.caps(FileStat)) then
      return DotEnvUnreadable
    end

    if not dotenv.exists() then
      return EnvSecrets(vars)
    end

    let file =
      match OpenFile(dotenv)
      | let f: File => f
      else
        return DotEnvUnreadable
      end

    let wanted = file.size()
    if wanted > _MaxDotEnvSize() then
      file.dispose()
      return DotEnvUnreadable
    end

    let content: String val = file.read_string(wanted)
    file.dispose()

    // read_string returns up to `wanted` bytes and reports a failure only
    // through errno, so a short read arrives looking like a whole file.
    if content.size() != wanted then
      return DotEnvUnreadable
    end

    _DotEnv.parse_all(content, vars)

primitive _MaxDotEnvSize
  fun apply(): USize => 1_048_576

class val EnvSecrets
  """
  Sensitive values supplied by the process environment and, optionally, a
  `.env` file.

  Build one from `Env.vars` at startup, and hand each component the values it
  needs. It is `val`, so it can be shared between actors, and every value it
  returns is already wrapped in a `Sensitive[String]`.

  Every environment value is wrapped, whether or not it is a secret, because
  which names are secret is not something this package can know. `PATH` and
  `LOG_LEVEL` come back as `Sensitive[String]` too, and exposing them costs
  the same deliberate act as exposing a password. Read ordinary configuration
  from `Env.vars` directly, so that a call to `expose` stays a signal.

  Use [ReadEnvSecrets](sensitive-ReadEnvSecrets.md) to take a `.env` file into
  account as well.

  ```pony
  use "sensitive"

  actor Main
    new create(env: Env) =>
      let secrets = EnvSecrets(env.vars)

      try
        let password = secrets("DB_PASSWORD")?
        env.out.print("The password is " + password.string())
      else
        env.out.print("DB_PASSWORD is not set")
      end
  ```
  """
  let _values: Map[String, Sensitive[String]] val

  new val create(vars: Array[String] val) =>
    """
    Read the `NAME=VALUE` entries of `vars`, which is `Env.vars` in the usual
    case. The value is everything after the first `=`, taken verbatim. An
    entry with no `=`, or with an empty name, is ignored. Where one name is
    set twice, the first of them wins, which is what `getenv` returns.
    """
    _values = _EnvVars(vars)

  new val _from_values(values: Map[String, Sensitive[String]] iso) =>
    _values = consume values

  fun apply(name: String): Sensitive[String] ? =>
    """
    The value bound to `name`. Raises an error if `name` is not bound.
    """
    _values(name)?

primitive _EnvVars
  """
  The `NAME=VALUE` entries of an environment, as a map. The value is taken
  verbatim, an entry with no `=` or an empty name is skipped, and the first of
  a repeated name wins.
  """
  fun apply(vars: Array[String] val): Map[String, Sensitive[String]] iso^ =>
    let values = recover Map[String, Sensitive[String]](vars.size()) end

    for entry in vars.values() do
      try
        let eq = entry.find("=")?.usize()
        // `trim` on a val shares the entry's bytes rather than copying them,
        // and name + "=" + value is the whole entry, so nothing extra is held.
        let name: String val = entry.trim(0, eq)
        if (name.size() > 0) and (not values.contains(name)) then
          values(name) = Sensitive[String](entry.trim(eq + 1))
        end
      end
    end

    consume values

primitive _DotEnv
  fun parse_all(content: String box, vars: Array[String] val)
    : (EnvSecrets | DotEnvMalformed) =>
    // The environment goes in first and nothing already bound is replaced, so
    // the environment wins over the file and the first of a repeated name
    // wins within each source.
    let values = _EnvVars(vars)

    let lines: Array[String] val = content.split_by("\n")
    var number: USize = 0

    for raw in lines.values() do
      number = number + 1

      let line: String ref = raw.clone()
      line.strip()

      if (line.size() > 0) and (not line.at("#")) then
        (let name: String val, let value: String val) =
          try
            parse(line)?
          else
            return DotEnvMalformed._create(number)
          end

        if not values.contains(name) then
          values(name) = Sensitive[String](value)
        end
      end
    end

    EnvSecrets._from_values(consume values)

  fun parse(line: String box): (String val, String val) ? =>
    // `line` must already be stripped: this removes whitespace between the
    // name and the `=` and between the `=` and the value, but not whitespace
    // at either end of the line.
    let eq = line.find("=")?

    let trimmed = line.substring(0, eq)
    trimmed.rstrip()

    let name: String val = consume trimmed
    if not _valid_name(name) then
      error
    end

    let value = line.substring(eq + 1)
    value.lstrip()

    (name, _unquote(consume value))

  fun _valid_name(name: String box): Bool =>
    var i: USize = 0

    while i < name.size() do
      let c = try name(i)? else return false end

      let ok =
        ((c >= 'A') and (c <= 'Z')) or
        ((c >= 'a') and (c <= 'z')) or
        (c == '_') or
        ((i > 0) and (c >= '0') and (c <= '9'))

      if not ok then
        return false
      end

      i = i + 1
    end

    name.size() > 0

  fun _unquote(quoted: String iso): String val =>
    let value = consume quoted

    if value.size() >= 2 then
      let last = (value.size() - 1).isize()
      if (value.at("\"") and value.at("\"", last))
        or (value.at("'") and value.at("'", last))
      then
        return value.substring(1, last)
      end
    end
    consume value