///|
/// A fresh, empty findings buffer.
///
/// The check helpers append to a caller-owned array, so every entry point needs
/// one; naming the constructor keeps the public API free of an inline mutable
/// literal and gives tests a single spelling to use.
pub fn empty_findings() -> Array[Finding] {
  []
}

///|
/// Severity of a single preflight finding.
///
/// `Error` marks a metadata envelope that is structurally invalid for the
/// requested role. `Warning` marks a value that parses but is suspicious, such
/// as a target path that escapes its directory prefix.
pub enum Severity {
  Error
  Warning
}

///|
/// One diagnostic produced by a structural preflight check.
///
/// `code` is a stable, machine-readable identifier such as
/// `invalid-target-hash`; `location` names the offending field owner (a key id,
/// a meta file name or a target path) and is empty when the finding applies to
/// the envelope as a whole.
pub struct Finding {
  severity : Severity
  code : String
  location : String
}

///|
/// Whether the finding blocks handing the document to a TUF verifier.
pub fn Finding::severity(self : Finding) -> Severity {
  self.severity
}

///|
/// The stable machine-readable code, such as `invalid-target-hash`.
pub fn Finding::code(self : Finding) -> String {
  self.code
}

///|
/// The key id, meta file name, target path or document the finding belongs to,
/// or the empty string when it applies to the whole envelope.
pub fn Finding::location(self : Finding) -> String {
  self.location
}

///|
/// The complete preflight result for one metadata document.
///
/// Call [`Report::findings`] for the human-readable diagnostics and
/// [`Report::ok`] or [`Report::has_errors`] for pipeline decisions. A report
/// with no findings is a *structural* statement only: this library never
/// verifies signatures, hashes or the trusted root chain.
pub struct Report {
  findings : Array[Finding]
  role : String
  version : Int?
}

///|
pub fn Severity::to_string(self : Severity) -> String {
  match self {
    Error => "error"
    Warning => "warning"
  }
}

///|
/// The stable `severity:code[:location]` rendering of a finding.
pub fn Finding::to_string(self : Finding) -> String {
  if self.location == "" {
    self.severity.to_string() + ":" + self.code
  } else {
    self.severity.to_string() + ":" + self.code + ":" + self.location
  }
}

///|
/// Whether the document may be handed to a TUF verifier at all.
///
/// `true` means every structural field required by the requested role is
/// present and well formed. It does not mean the update is trusted,
/// unexpired or rollback-safe.
pub fn Report::ok(self : Report) -> Bool {
  self.findings.all(finding => finding.severity is Warning)
}

///|
/// Whether any finding has severity `Error`.
pub fn Report::has_errors(self : Report) -> Bool {
  self.findings.any(finding => finding.severity is Error)
}

///|
/// Number of `Warning` findings.
pub fn Report::warning_count(self : Report) -> Int {
  self.findings.filter(finding => finding.severity is Warning).length()
}

///|
/// Number of `Error` findings.
pub fn Report::error_count(self : Report) -> Int {
  self.findings.filter(finding => finding.severity is Error).length()
}

///|
/// All findings, in detection order.
pub fn Report::findings(self : Report) -> Array[Finding] {
  self.findings.copy()
}

///|
/// The role the report was produced for, as passed to [`preflight_report`].
pub fn Report::role(self : Report) -> String {
  self.role
}

///|
/// The `signed.version` integer, or `None` when it is missing or not an
/// integer in `1 ..= 2147483647`.
pub fn Report::version(self : Report) -> Int? {
  self.version
}

///|
/// The findings rendered as `severity:code[:location]` strings.
pub fn Report::to_strings(self : Report) -> Array[String] {
  self.findings.map(finding => finding.to_string())
}

///|
/// A newline-joined report suitable for printing in a terminal or a CI log.
pub fn Report::render(self : Report) -> String {
  if self.findings.is_empty() {
    return "ok"
  }
  self.to_strings().join("\n")
}

///|
/// Compare two strings by character codes, the ordering usually called
/// lexicographic or "ordinal".
///
/// This exists because MoonBit's own `String` comparison is *shortlex*: it
/// compares lengths first, so `"duplicate-signature-keyid" < "empty-keyid"` is
/// false even though `d` sorts before `e`. Sorting a list of codes therefore
/// needs this helper, and consumers that want the documented order of
/// [`finding_codes`] should use it too.
pub fn compare_ordinal(left : StringView, right : StringView) -> Int {
  let left_chars = left.iter().to_array()
  let right_chars = right.iter().to_array()
  let limit = if left_chars.length() < right_chars.length() {
    left_chars.length()
  } else {
    right_chars.length()
  }
  for i in 0.. Bool {
  for i in 1..= 0 {
      return false
    }
  }
  true
}

///|
/// Every diagnostic code this library can emit, sorted with
/// [`compare_ordinal`].
///
/// Consumers that sort, filter or count findings often need to know the full
/// set up front, for example to size a dashboard or to reject a code that a
/// newer version of the library introduced. Codes that carry a location are
/// listed without one; the bundle codes appear under their bare code.
pub fn finding_codes() -> Array[String] {
  [
    "duplicate-keyid", "duplicate-signature-keyid", "empty-keyid", "empty-meta",
    "empty-signatures", "envelope-not-object", "expired", "invalid-current-time",
    "invalid-expires", "invalid-json", "invalid-key", "invalid-key-scheme", "invalid-keyid",
    "invalid-keytype", "invalid-keyval", "invalid-meta-entry", "invalid-meta-hash",
    "invalid-meta-hashes", "invalid-meta-length", "invalid-meta-version", "invalid-public-key",
    "invalid-signature-entry", "invalid-signature-keyid", "invalid-signature-value",
    "invalid-target-entry", "invalid-target-hash", "invalid-target-length", "invalid-threshold",
    "invalid-version", "malformed-spec-version", "missing-document-version", "missing-keyids",
    "missing-keys", "missing-meta", "missing-role", "missing-roles", "missing-signatures-array",
    "missing-signed-object", "missing-spec-version", "missing-target-hashes", "missing-targets",
    "missing-version-reference", "non-object-target-custom", "role-mismatch", "snapshot-version-mismatch",
    "targets-version-mismatch", "unknown-keyid", "unreachable-threshold", "unsafe-target-path",
    "unsupported-meta-hash-algorithm", "unsupported-role", "unsupported-target-hash-algorithm",
  ]
}

///|
/// Core routine: append findings for `document` interpreted as `role`.
///
/// `role` must be one of `root`, `timestamp`, `snapshot` or `targets`; any
/// other value yields a single `unsupported-role` error and nothing else.
fn collect_findings(
  document : Json,
  role : String,
  findings : Array[Finding],
) -> Int? {
  if role != "root" &&
    role != "timestamp" &&
    role != "snapshot" &&
    role != "targets" {
    findings.push(Finding::{
      severity: Error,
      code: "unsupported-role",
      location: role,
    })
    return None
  }
  let envelope = match document {
    Object(envelope) => envelope
    _ => {
      findings.push(Finding::{
        severity: Error,
        code: "envelope-not-object",
        location: "",
      })
      return None
    }
  }
  let signed = match envelope.get("signed") {
    Some(Object(signed)) => signed
    _ => {
      findings.push(Finding::{
        severity: Error,
        code: "missing-signed-object",
        location: "",
      })
      return None
    }
  }
  check_signatures(envelope, findings)
  match signed.get("_type") {
    Some(String(value)) if value == role => ()
    _ =>
      findings.push(Finding::{
        severity: Error,
        code: "role-mismatch",
        location: role,
      })
  }
  let version = match signed.get("version") {
    Some(Number(value, ..)) if in_int_range(value) => Some(value.to_int())
    _ => {
      findings.push(Finding::{
        severity: Error,
        code: "invalid-version",
        location: "",
      })
      None
    }
  }
  match signed.get("expires") {
    Some(String(value)) if valid_utc_time(value) => ()
    _ =>
      findings.push(Finding::{
        severity: Error,
        code: "invalid-expires",
        location: "",
      })
  }
  // TUF 1.0 metadata declares the specification version it was written for.
  // It is reported as a warning rather than an error because pre-1.0
  // repositories still publish metadata without it, and clients are required to
  // tolerate its absence.
  match signed.get("spec_version") {
    Some(String(value)) if valid_spec_version(value) => ()
    Some(String(value)) =>
      findings.push(Finding::{
        severity: Warning,
        code: "malformed-spec-version",
        location: value,
      })
    _ =>
      findings.push(Finding::{
        severity: Warning,
        code: "missing-spec-version",
        location: "",
      })
  }
  match role {
    "root" => check_root(signed, findings)
    "timestamp" | "snapshot" => check_meta(signed, role, findings)
    "targets" => check_targets(signed, findings)
    _ => ()
  }
  version
}

///|
/// Structurally preflight one TUF metadata envelope.
///
/// `input` is the raw JSON document and `role` the role it claims to be.
/// Malformed JSON yields `error:invalid-json`. See [`Report`] for the meaning
/// of the result, and [`preflight_at`] / [`preflight_bundle`] for expiry and
/// cross-document checks.
pub fn preflight_report(input : String, role : String) -> Report {
  let findings : Array[Finding] = []
  let document = @json.parse(input) catch {
    _ => {
      findings.push(Finding::{
        severity: Error,
        code: "invalid-json",
        location: "",
      })
      return Report::{ findings, role, version: None, }
    }
  }
  let version = collect_findings(document, role, findings)
  Report::{ findings, role, version, }
}

///|
/// [`preflight_report`] collapsed to `severity:code[:location]` strings.
pub fn preflight(input : String, role : String) -> Array[String] {
  preflight_report(input, role).to_strings()
}

///|
/// Preflight a single envelope and additionally compare `expires` with the
/// caller-supplied current UTC time `now`.
///
/// Passing `now` in keeps CI, offline mirrors and Wasm builds reproducible and
/// free of hidden clock reads. The check is deliberately non-strict at the
/// boundary: metadata is reported as expired once `expires <= now`, matching
/// the TUF rule that an update ceases to be valid at `expires`.
pub fn preflight_at_report(
  input : String,
  role : String,
  now : String,
) -> Report {
  let report = preflight_report(input, role)
  let findings = report.findings
  if !valid_utc_time(now) {
    findings.push(Finding::{
      severity: Error,
      code: "invalid-current-time",
      location: "",
    })
    return Report::{ findings, role, version: report.version, }
  }
  match signed_object(input) {
    Some(signed) =>
      match signed.get("expires") {
        Some(String(expires)) if valid_utc_time(expires) =>
          if utc_not_after(expires, now) {
            findings.push(Finding::{
              severity: Error,
              code: "expired",
              location: expires,
            })
          }
        _ => ()
      }
    None => ()
  }
  Report::{ findings, role, version: report.version, }
}

///|
/// [`preflight_at_report`] collapsed to `severity:code[:location]` strings.
pub fn preflight_at(
  input : String,
  role : String,
  now : String,
) -> Array[String] {
  preflight_at_report(input, role, now).to_strings()
}

///|
/// Preflight a `timestamp` / `snapshot` / `targets` triple and check that the
/// versions the documents reference agree with the versions they carry.
///
/// Findings from a single document gain that document's name as a code
/// segment, so a timestamp that expired is rendered as
/// `error:timestamp:expired:2030-01-01T00:00:00Z`. A missing or malformed
/// `version` on either side of a reference is reported as
/// `missing-version-reference` or `missing-document-version` instead of being
/// treated as agreement. `now` is the caller-supplied current UTC time.
pub fn preflight_bundle_report(
  timestamp : String,
  snapshot : String,
  targets : String,
  now : String,
) -> Report {
  let findings : Array[Finding] = []
  for
    entry in [
      ("timestamp", timestamp),
      ("snapshot", snapshot),
      ("targets", targets),
    ] {
    let (name, input) = entry
    for finding in preflight_at_report(input, name, now).findings {
      findings.push(Finding::{
        severity: finding.severity,
        code: name + ":" + finding.code,
        location: finding.location,
      })
    }
  }
  check_reference(
    timestamp, "snapshot.json", snapshot, "timestamp", "snapshot-version-mismatch",
    findings,
  )
  check_reference(
    snapshot, "targets.json", targets, "snapshot", "targets-version-mismatch", findings,
  )
  let version = match metadata_version(timestamp) {
    Some(version) => Some(version)
    None => metadata_version(snapshot)
  }
  Report::{ findings, role: "bundle", version, }
}

///|
/// [`preflight_bundle_report`] collapsed to `severity:code[:location]` strings.
pub fn preflight_bundle(
  timestamp : String,
  snapshot : String,
  targets : String,
  now : String,
) -> Array[String] {
  preflight_bundle_report(timestamp, snapshot, targets, now).to_strings()
}

///|
/// Compare `meta[name].version` inside `referrer` with the `version` carried by
/// `target`. Absent or malformed versions on either side are reported instead
/// of being treated as agreement.
fn check_reference(
  referrer : String,
  name : String,
  target : String,
  referrer_role : String,
  mismatch_code : String,
  findings : Array[Finding],
) -> Unit {
  let referenced = referenced_version(referrer, name)
  let actual = metadata_version(target)
  match (referenced, actual) {
    (Some(referenced), Some(actual)) =>
      if referenced != actual {
        findings.push(Finding::{
          severity: Error,
          code: mismatch_code,
          location: "\{name}:referenced=\{referenced},actual=\{actual}",
        })
      }
    (None, _) =>
      findings.push(Finding::{
        severity: Error,
        code: "missing-version-reference",
        location: referrer_role + "->" + name,
      })
    (_, None) =>
      findings.push(Finding::{
        severity: Error,
        code: "missing-document-version",
        location: name,
      })
  }
}

///|
/// Validate the `signatures` array: at least one entry, every entry an object
/// with a non-empty `keyid` and `sig`, and no repeated `keyid`.
///
/// This checks entry *shape* only. No signature is ever verified here.
fn check_signatures(
  envelope : Map[String, Json],
  findings : Array[Finding],
) -> Unit {
  let signatures = match envelope.get("signatures") {
    Some(Array(signatures)) => signatures
    _ => {
      findings.push(Finding::{
        severity: Error,
        code: "missing-signatures-array",
        location: "",
      })
      return
    }
  }
  if signatures.is_empty() {
    findings.push(Finding::{
      severity: Error,
      code: "empty-signatures",
      location: "",
    })
  }
  let seen : Map[String, Bool] = Map([])
  for signature in signatures {
    match signature {
      Object(entry) => {
        let keyid = match entry.get("keyid") {
          Some(String(value)) if value != "" => value
          _ => {
            findings.push(Finding::{
              severity: Error,
              code: "invalid-signature-keyid",
              location: "",
            })
            ""
          }
        }
        match entry.get("sig") {
          Some(String(value)) if value != "" => ()
          _ =>
            findings.push(Finding::{
              severity: Error,
              code: "invalid-signature-value",
              location: keyid,
            })
        }
        if keyid != "" {
          if seen.get(keyid) is Some(_) {
            findings.push(Finding::{
              severity: Error,
              code: "duplicate-signature-keyid",
              location: keyid,
            })
          } else {
            seen[keyid] = true
          }
        }
      }
      _ =>
        findings.push(Finding::{
          severity: Error,
          code: "invalid-signature-entry",
          location: "",
        })
    }
  }
}

///|
/// Validate a `root` document: the `keys` table, the four top-level roles, and
/// for each role the threshold and its key ids.
///
/// A role is unusable when its threshold exceeds the number of *distinct* key
/// ids that exist in `keys`, which is reported as `unreachable-threshold`.
///
/// A missing `keys` or `roles` table is reported and then treated as empty, so a
/// document that lacks both gets both findings instead of only the first one.
/// Diagnosing one problem per run is not useful for a release check.
fn check_root(signed : Map[String, Json], findings : Array[Finding]) -> Unit {
  let keys = match signed.get("keys") {
    Some(Object(keys)) => keys
    _ => {
      findings.push(Finding::{
        severity: Error,
        code: "missing-keys",
        location: "",
      })
      Map([])
    }
  }
  let roles = match signed.get("roles") {
    Some(Object(roles)) => roles
    _ => {
      findings.push(Finding::{
        severity: Error,
        code: "missing-roles",
        location: "",
      })
      Map([])
    }
  }
  for entry in keys.iter() {
    let (keyid, key) = entry
    if keyid == "" {
      findings.push(Finding::{
        severity: Error,
        code: "empty-keyid",
        location: "",
      })
    }
    match key {
      Object(config) => {
        match config.get("keytype") {
          Some(String(value)) if value != "" => ()
          _ =>
            findings.push(Finding::{
              severity: Error,
              code: "invalid-keytype",
              location: keyid,
            })
        }
        match config.get("scheme") {
          Some(String(value)) if value != "" => ()
          _ =>
            findings.push(Finding::{
              severity: Error,
              code: "invalid-key-scheme",
              location: keyid,
            })
        }
        match config.get("keyval") {
          Some(Object(keyval)) =>
            match keyval.get("public") {
              Some(String(value)) if value != "" => ()
              _ =>
                findings.push(Finding::{
                  severity: Error,
                  code: "invalid-public-key",
                  location: keyid,
                })
            }
          _ =>
            findings.push(Finding::{
              severity: Error,
              code: "invalid-keyval",
              location: keyid,
            })
        }
      }
      _ =>
        findings.push(Finding::{
          severity: Error,
          code: "invalid-key",
          location: keyid,
        })
    }
  }
  for role in ["root", "timestamp", "snapshot", "targets"] {
    match roles.get(role) {
      Some(Object(config)) => {
        let threshold = match config.get("threshold") {
          Some(Number(value, ..)) if in_int_range(value) => value.to_int()
          _ => {
            findings.push(Finding::{
              severity: Error,
              code: "invalid-threshold",
              location: role,
            })
            0
          }
        }
        check_role_keyids(role, config, keys, threshold, findings)
      }
      _ =>
        findings.push(Finding::{
          severity: Error,
          code: "missing-role",
          location: role,
        })
    }
  }
}

///|
/// Validate one `root` role entry: `keyids` must be a non-empty array of
/// distinct strings that all exist in `keys`, and at least `threshold` of them
/// must do so.
fn check_role_keyids(
  role : String,
  config : Map[String, Json],
  keys : Map[String, Json],
  threshold : Int,
  findings : Array[Finding],
) -> Unit {
  match config.get("keyids") {
    Some(Array(keyids)) => {
      if keyids.is_empty() {
        findings.push(Finding::{
          severity: Error,
          code: "missing-keyids",
          location: role,
        })
      }
      let mut usable = 0
      let seen : Map[String, Bool] = Map([])
      for keyid in keyids {
        match keyid {
          String(name) =>
            if seen.get(name) is Some(_) {
              findings.push(Finding::{
                severity: Error,
                code: "duplicate-keyid",
                location: role + ":" + name,
              })
            } else {
              seen[name] = true
              if keys.get(name) is Some(_) {
                usable += 1
              } else {
                findings.push(Finding::{
                  severity: Error,
                  code: "unknown-keyid",
                  location: role + ":" + name,
                })
              }
            }
          _ =>
            findings.push(Finding::{
              severity: Error,
              code: "invalid-keyid",
              location: role,
            })
        }
      }
      if usable < threshold {
        findings.push(Finding::{
          severity: Error,
          code: "unreachable-threshold",
          location: "\{role}:threshold=\{threshold},usable=\{usable}",
        })
      }
    }
    _ =>
      findings.push(Finding::{
        severity: Error,
        code: "missing-keyids",
        location: role,
      })
  }
}

///|
/// Validate a `timestamp` or `snapshot` document's `meta` table.
///
/// `timestamp` must reference `snapshot.json`, `snapshot` must reference
/// `targets.json`, and every entry needs an integer `version`. The optional
/// `length` and `hashes` fields are checked with the same rules as target
/// entries, because a snapshot that describes a metadata file with a malformed
/// digest would otherwise be accepted silently.
fn check_meta(
  signed : Map[String, Json],
  role : String,
  findings : Array[Finding],
) -> Unit {
  let meta = match signed.get("meta") {
    Some(Object(meta)) => meta
    _ => {
      findings.push(Finding::{
        severity: Error,
        code: "missing-meta",
        location: role,
      })
      return
    }
  }
  if meta.is_empty() {
    findings.push(Finding::{
      severity: Error,
      code: "empty-meta",
      location: role,
    })
  }
  let required = if role == "timestamp" {
    "snapshot.json"
  } else {
    "targets.json"
  }
  if meta.get(required) is None {
    findings.push(Finding::{
      severity: Error,
      code: "missing-meta",
      location: required,
    })
  }
  for entry in meta.iter() {
    let (name, value) = entry
    match value {
      Object(entry) => check_meta_entry(name, entry, findings)
      _ =>
        findings.push(Finding::{
          severity: Error,
          code: "invalid-meta-entry",
          location: name,
        })
    }
  }
}

///|
/// Validate one entry of a `meta` table: a required integer `version`, and the
/// optional `length` and `hashes` fields when they are present.
fn check_meta_entry(
  name : String,
  entry : Map[String, Json],
  findings : Array[Finding],
) -> Unit {
  match entry.get("version") {
    Some(Number(number, ..)) if in_int_range(number) => ()
    _ =>
      findings.push(Finding::{
        severity: Error,
        code: "invalid-meta-version",
        location: name,
      })
  }
  match entry.get("length") {
    None => ()
    Some(Number(length, ..)) if length >= 0.0 &&
      length <= 9007199254740991.0 &&
      length == length.floor() => ()
    _ =>
      findings.push(Finding::{
        severity: Error,
        code: "invalid-meta-length",
        location: name,
      })
  }
  match entry.get("hashes") {
    None => ()
    Some(Object(hashes)) => {
      if hashes.is_empty() {
        findings.push(Finding::{
          severity: Error,
          code: "invalid-meta-hashes",
          location: name,
        })
      }
      for digest in hashes.iter() {
        let (algorithm, value) = digest
        check_digest(
          algorithm,
          value,
          "invalid-meta-hash",
          "unsupported-meta-hash-algorithm",
          name + ":" + algorithm,
          findings,
        )
      }
    }
    Some(_) =>
      findings.push(Finding::{
        severity: Error,
        code: "invalid-meta-hashes",
        location: name,
      })
  }
}

///|
/// Validate a `targets` document: every target path is relative and free of
/// `..`, `.` and backslashes, every target has a non-negative integer `length`
/// and a non-empty `hashes` map whose digests match the declared algorithm.
///
/// Digest checks cover hex characters and length for `sha256`, `sha3-256`,
/// `sha512` and `sha3-512`. No file content is read, so a well-formed digest is
/// never a content check.
fn check_targets(signed : Map[String, Json], findings : Array[Finding]) -> Unit {
  let targets = match signed.get("targets") {
    Some(Object(targets)) => targets
    _ => {
      findings.push(Finding::{
        severity: Error,
        code: "missing-targets",
        location: "",
      })
      return
    }
  }
  for entry in targets.iter() {
    let (name, value) = entry
    if !safe_target_path(name) {
      findings.push(Finding::{
        severity: Error,
        code: "unsafe-target-path",
        location: name,
      })
    }
    match value {
      Object(meta) => {
        match meta.get("length") {
          Some(Number(length, ..)) if length >= 0.0 &&
            length <= 9007199254740991.0 &&
            length == length.floor() => ()
          _ =>
            findings.push(Finding::{
              severity: Error,
              code: "invalid-target-length",
              location: name,
            })
        }
        match meta.get("hashes") {
          Some(Object(hashes)) if !hashes.is_empty() =>
            for digest in hashes.iter() {
              let (algorithm, value) = digest
              check_target_hash(name, algorithm, value, findings)
            }
          _ =>
            findings.push(Finding::{
              severity: Error,
              code: "missing-target-hashes",
              location: name,
            })
        }
        match meta.get("custom") {
          Some(custom) =>
            match custom {
              Object(_) | Null => ()
              _ =>
                findings.push(Finding::{
                  severity: Warning,
                  code: "non-object-target-custom",
                  location: name,
                })
            }
          None => ()
        }
      }
      _ =>
        findings.push(Finding::{
          severity: Error,
          code: "invalid-target-entry",
          location: name,
        })
    }
  }
}

///|
/// Check one `algorithm: digest` pair.
///
/// `location` is `:`, `invalid_code` is reported when the
/// digest is not a hex string of the length that `algorithm` requires, and
/// `unsupported_code` is reported for algorithms this preflight cannot measure.
/// Both target entries and `snapshot`'s `meta` entries describe file digests, so
/// they share the implementation and differ only in the codes they emit.
fn check_digest(
  algorithm : String,
  value : Json,
  invalid_code : String,
  unsupported_code : String,
  location : String,
  findings : Array[Finding],
) -> Unit {
  let digest = match value {
    String(digest) => digest
    _ => {
      findings.push(Finding::{ severity: Error, code: invalid_code, location, })
      return
    }
  }
  let expected = match algorithm {
    "sha256" | "sha3-256" => 64
    "sha512" | "sha3-512" => 128
    _ => {
      findings.push(Finding::{
        severity: Warning,
        code: unsupported_code,
        location,
      })
      return
    }
  }
  if digest.length() != expected || !valid_hex(digest) {
    findings.push(Finding::{ severity: Error, code: invalid_code, location, })
  }
}

///|
/// Check one `algorithm: digest` pair of a target's `hashes` map.
///
/// Supported digest lengths follow the TUF specification's hash function list.
/// An unknown algorithm is a warning rather than an error because the
/// specification allows repositories to publish additional hash functions; the
/// preflight simply cannot confirm their digest length.
fn check_target_hash(
  name : String,
  algorithm : String,
  value : Json,
  findings : Array[Finding],
) -> Unit {
  check_digest(
    algorithm,
    value,
    "invalid-target-hash",
    "unsupported-target-hash-algorithm",
    name + ":" + algorithm,
    findings,
  )
}

///|
/// Whether `path` is a safe relative TUF target path.
///
/// Rejects empty paths, absolute paths, backslashes, `:` (which would allow a
/// Windows drive or scheme prefix on some consumers), and any empty, `.` or
/// `..` segment.
fn safe_target_path(path : String) -> Bool {
  if path == "" ||
    path.has_prefix("/") ||
    path.contains("\\") ||
    path.contains(":") {
    return false
  }
  for segment in path.split("/") {
    if segment == "" || segment == "." || segment == ".." {
      return false
    }
  }
  true
}

///|
/// Whether every character of `value` is an ASCII hex digit.
fn valid_hex(value : String) -> Bool {
  if value == "" {
    return false
  }
  for char in value.iter() {
    if !((char >= '0' && char <= '9') ||
      (char >= 'a' && char <= 'f') ||
      (char >= 'A' && char <= 'F')) {
      return false
    }
  }
  true
}

///|
/// Whether `value` is an integer that fits a TUF `version` field, that is
/// `1 <= value <= 2147483647`.
fn in_int_range(value : Double) -> Bool {
  value >= 1.0 && value <= 2147483647.0 && value == value.floor()
}