///|
/// The identity claims of a Fulcio certificate. Each value is the text of its
/// extension (`1.3.6.1.4.1.57264.1.*`), or `None` when the extension is absent.
pub struct CertificateClaims {
  /// The OIDC issuer (`.1.8`, else `.1.1`).
  issuer : String?
  /// The URI or email subject alternative names.
  subject_names : Array[String]
  build_signer_uri : String?
  build_signer_digest : String?
  runner_environment : String?
  source_repository_uri : String?
  source_repository_digest : String?
  source_repository_ref : String?
  source_repository_identifier : String?
  source_repository_owner_uri : String?
  source_repository_owner_identifier : String?
  build_config_uri : String?
  build_config_digest : String?
  build_trigger : String?
  run_invocation_uri : String?
  source_repository_visibility : String?
} derive(Eq, Debug)

///|
pub extend CertificateClaims with Eq::{equal, not_equal}

///|
pub extend CertificateClaims with @debug.Debug::{to_repr}

///|
fn read_claims(leaf : @x509.Certificate) -> CertificateClaims raise @x509.X509Error {
  fn claim(arc : Int) -> String? raise @x509.X509Error {
    match leaf.extension("1.3.6.1.4.1.57264.1.\{arc}") {
      Some(extension) => Some(@x509.fulcio_value(extension))
      None => None
    }
  }

  let issuer = match claim(8) {
    Some(value) => Some(value)
    None => claim(1)
  }
  {
    issuer,
    subject_names: [..leaf.san_uris, ..leaf.san_emails],
    build_signer_uri: claim(9),
    build_signer_digest: claim(10),
    runner_environment: claim(11),
    source_repository_uri: claim(12),
    source_repository_digest: claim(13),
    source_repository_ref: claim(14),
    source_repository_identifier: claim(15),
    source_repository_owner_uri: claim(16),
    source_repository_owner_identifier: claim(17),
    build_config_uri: claim(18),
    build_config_digest: claim(19),
    build_trigger: claim(20),
    run_invocation_uri: claim(21),
    source_repository_visibility: claim(22),
  }
}

///|
priv enum Identity {
  ExactName(SubjectName)
  /// A GitHub release of `repository` at `tag`, signed by GitHub.
  Release(GitHubRepository, ReleaseTag)
  SignerWorkflow(GitHubRepository, WorkflowPath, GitRef?)
}

///|
/// A publisher identity that a signing certificate must have. A policy is an
/// immutable value; each method returns a new policy with one more claim.
pub struct Policy {
  /// `None` for a release policy: release certificates have no issuer.
  priv issuer : Issuer?
  priv identity : Identity
  priv source_repository : GitHubRepository?
  priv source_owner : GitHubOwner?
  priv repository_id : GitHubId?
  priv owner_id : GitHubId?
  priv source_ref : GitRef?
  priv hosted_runner_only : Bool
  priv predicate_type : PredicateType?
}

///|
fn Policy::new(issuer : Issuer?, identity : Identity) -> Policy {
  {
    issuer,
    identity,
    source_repository: None,
    source_owner: None,
    repository_id: None,
    owner_id: None,
    source_ref: None,
    hosted_runner_only: false,
    predicate_type: None,
  }
}

///|
/// A policy that requires the exact subject alternative name `name`.
pub fn Policy::subject_name(
  name : SubjectName,
  issuer? : Issuer = Issuer::github_actions(),
) -> Policy {
  Policy::new(Some(issuer), ExactName(name))
}

///|
/// A policy that requires the signer workflow `path` in `repository`, at `git_ref`
/// when it is given, else at any ref. It matches the SAN
/// `https://github.com///@`.
pub fn Policy::signer_workflow(
  repository : GitHubRepository,
  path : WorkflowPath,
  git_ref? : GitRef,
  issuer? : Issuer = Issuer::github_actions(),
) -> Policy {
  Policy::new(Some(issuer), SignerWorkflow(repository, path, git_ref))
}

///|
/// The usual policy for a GitHub release: the workflow `workflow` of
/// `repository` signed it, from the source of that repository, on a
/// GitHub-hosted runner unless `hosted_runner_only` is false, and the statement
/// has the predicate type `predicate_type` (by default SLSA provenance v1, as
/// `gh attestation verify` requires). So an SBOM attestation of the same
/// workflow does not pass as provenance.
pub fn Policy::github_workflow(
  repository : GitHubRepository,
  workflow~ : WorkflowPath,
  git_ref? : GitRef,
  hosted_runner_only? : Bool = true,
  predicate_type? : PredicateType = PredicateType::slsa_provenance_v1(),
) -> Policy {
  let policy = Policy::signer_workflow(repository, workflow, git_ref?)
    .source_repository(repository)
    .predicate_type(predicate_type)
  if hosted_runner_only {
    policy.hosted_runners_only()
  } else {
    policy
  }
}

///|
/// Also require an in-toto statement with the predicate type `predicate_type`.
/// A message-signature bundle has no statement, so it does not match.
pub fn Policy::predicate_type(self : Policy, predicate_type : PredicateType) -> Policy {
  { ..self, predicate_type: Some(predicate_type) }
}

///|
/// The SAN of GitHub release attestations on github.com.
let release_signer : String = "https://dotcom.releases.github.com"

///|
/// A policy for a GitHub immutable release: GitHub signed it (SAN
/// `https://dotcom.releases.github.com`, and the certificate has no OIDC
/// issuer extension, as no workflow certificate does), the statement has the
/// predicate type `predicate_type` (by default release v0.2), and its predicate
/// names `repository` (ASCII case ignored), `tag` and the purl
/// `pkg:github//@`. Verify it with a release asset digest and
/// the GitHub root. `repository_id` and `owner_id` pin the predicate IDs.
pub fn Policy::github_release(
  repository : GitHubRepository,
  tag~ : ReleaseTag,
  predicate_type? : PredicateType = PredicateType::github_release_v0_2(),
) -> Policy {
  Policy::new(None, Release(repository, tag)).predicate_type(predicate_type)
}

///|
/// Also require the source repository claim (`.1.12`).
pub fn Policy::source_repository(self : Policy, repository : GitHubRepository) -> Policy {
  { ..self, source_repository: Some(repository) }
}

///|
/// Also require the source repository owner claim (`.1.16`).
pub fn Policy::source_owner(self : Policy, owner : GitHubOwner) -> Policy {
  { ..self, source_owner: Some(owner) }
}

///|
/// Also require the source repository ID claim (`.1.15`), or for a release
/// policy the predicate `repositoryId`.
pub fn Policy::repository_id(self : Policy, id : GitHubId) -> Policy {
  { ..self, repository_id: Some(id) }
}

///|
/// Also require the source repository owner ID claim (`.1.17`), or for a
/// release policy the predicate `ownerId`.
pub fn Policy::owner_id(self : Policy, id : GitHubId) -> Policy {
  { ..self, owner_id: Some(id) }
}

///|
/// Also require the source ref claim (`.1.14`).
pub fn Policy::source_ref(self : Policy, git_ref : GitRef) -> Policy {
  { ..self, source_ref: Some(git_ref) }
}

///|
/// Also require the runner environment `github-hosted` (`.1.11`).
pub fn Policy::hosted_runners_only(self : Policy) -> Policy {
  { ..self, hosted_runner_only: true }
}

///|
/// A certificate claim that a policy can require.
pub enum Claim {
  SourceRepository
  SourceOwner
  RepositoryId
  OwnerId
  SourceRef
} derive(Eq, Debug)

///|
pub extend Claim with Eq::{equal, not_equal}

///|
pub extend Claim with @debug.Debug::{to_repr}

///|
/// Why a certificate does not match one policy.
pub enum PolicyMismatch {
  /// The certificate issuer is `actual`, not `expected`.
  IssuerMismatch(expected~ : String, actual~ : String?)
  /// No subject alternative name of the certificate matches `expected`.
  IdentityMismatch(expected~ : String, actual~ : Array[String])
  /// The claim `claim` is `actual`, not `expected`.
  ClaimMismatch(claim~ : Claim, expected~ : String, actual~ : String?)
  /// The policy requires a GitHub-hosted runner; the certificate gives `actual`.
  RunnerEnvironment(actual~ : String?)
  /// The statement has the predicate type `actual` (`None` without a
  /// statement), not `expected`.
  PredicateTypeMismatch(expected~ : PredicateType, actual~ : PredicateType?)
  /// The release predicate field `field` is `actual`, not `expected`.
  ReleaseMismatch(field~ : ReleaseField, expected~ : String, actual~ : String?)
} derive(Eq, Debug)

///|
pub extend PolicyMismatch with Eq::{equal, not_equal}

///|
pub extend PolicyMismatch with @debug.Debug::{to_repr}

///|
/// Text for people that names the expected and the actual value.
pub fn PolicyMismatch::message(self : PolicyMismatch) -> String {
  fn shown(value : String?) -> String {
    match value {
      Some(text) => text.escape()
      None => "nothing"
    }
  }

  match self {
    IssuerMismatch(expected~, actual~) =>
      "the issuer is \{shown(actual)}, not \{expected.escape()}"
    IdentityMismatch(expected~, actual~) =>
      "the certificate identity \{actual.map(name => name.escape()).join(", ")} does not match \{expected.escape()}"
    ClaimMismatch(claim~, expected~, actual~) => {
      let name = match claim {
        SourceRepository => "source repository"
        SourceOwner => "source repository owner"
        RepositoryId => "repository ID"
        OwnerId => "owner ID"
        SourceRef => "source ref"
      }
      "the \{name} is \{shown(actual)}, not \{expected.escape()}"
    }
    RunnerEnvironment(actual~) =>
      "the runner environment is \{shown(actual)}, not \"github-hosted\""
    PredicateTypeMismatch(expected~, actual~) =>
      "the predicate type is \{shown(actual.map(value => value.to_string()))}, not \{expected.to_string().escape()}"
    ReleaseMismatch(field~, expected~, actual~) => {
      let name = match field {
        Issuer => "certificate issuer"
        Repository => "repository"
        Tag => "tag"
        Purl => "purl"
        RepositoryId => "repository ID"
        OwnerId => "owner ID"
      }
      "the release \{name} is \{shown(actual)}, not \{expected.escape()}"
    }
  }
}

///|
fn equal_ignoring_case(a : String?, b : String) -> Bool {
  a is Some(text) && text.to_lower() == b.to_lower()
}

///|
/// The first reason that `claims` and `statement` do not match the policy, or
/// `None`.
fn Policy::mismatch(
  self : Policy,
  claims : CertificateClaims,
  statement : Statement?,
) -> PolicyMismatch? {
  if self.issuer is Some(required) {
    let issuer = required.to_string()
    guard claims.issuer == Some(issuer) else {
      return Some(IssuerMismatch(expected=issuer, actual=claims.issuer))
    }
  }
  match self.identity {
    Release(_, _) => {
      guard claims.subject_names.contains(release_signer) else {
        return Some(IdentityMismatch(expected=release_signer, actual=claims.subject_names))
      }
    }
    ExactName(name) => {
      let expected = name.to_string()
      guard claims.subject_names.contains(expected) else {
        return Some(IdentityMismatch(expected~, actual=claims.subject_names))
      }
    }
    SignerWorkflow(repository, path, git_ref) => {
      let prefix = "\{repository.url()}/\{path}@"
      let expected = match git_ref {
        Some(git_ref) => prefix + git_ref.to_string()
        None => prefix + "*"
      }
      let matches = claims.subject_names.iter().any(name => {
        // Owner and repository names are case-insensitive; the path and ref are not.
        guard name.length() > prefix.length() else { return false }
        let head = name[:repository.url().length()].to_owned()
        let tail = name[repository.url().length():].to_owned()
        guard head.to_lower() == repository.url().to_lower() &&
          tail.has_prefix("/\{path}@") else {
          return false
        }
        let actual_ref = tail["/\{path}@".length():].to_owned()
        match git_ref {
          Some(git_ref) => actual_ref == git_ref.to_string()
          None => actual_ref.has_prefix("refs/")
        }
      })
      guard matches else {
        return Some(IdentityMismatch(expected~, actual=claims.subject_names))
      }
    }
  }
  if self.source_repository is Some(repository) {
    guard equal_ignoring_case(claims.source_repository_uri, repository.url()) else {
      return Some(
        ClaimMismatch(
          claim=SourceRepository,
          expected=repository.url(),
          actual=claims.source_repository_uri,
        ),
      )
    }
  }
  if self.source_owner is Some(owner) {
    let expected = "https://github.com/\{owner}"
    guard equal_ignoring_case(claims.source_repository_owner_uri, expected) else {
      return Some(
        ClaimMismatch(
          claim=SourceOwner,
          expected~,
          actual=claims.source_repository_owner_uri,
        ),
      )
    }
  }
  // A release policy matches the predicate IDs; the workflow claims below,
  // such as the source ref, still apply and fail on a release certificate.
  if self.identity is Release(repository, tag) {
    if self.release_mismatch(repository, tag, claims, statement) is Some(reason) {
      return Some(reason)
    }
  } else {
    if self.repository_id is Some(id) {
      guard claims.source_repository_identifier == Some(id.to_string()) else {
        return Some(
          ClaimMismatch(
            claim=RepositoryId,
            expected=id.to_string(),
            actual=claims.source_repository_identifier,
          ),
        )
      }
    }
    if self.owner_id is Some(id) {
      guard claims.source_repository_owner_identifier == Some(id.to_string()) else {
        return Some(
          ClaimMismatch(
            claim=OwnerId,
            expected=id.to_string(),
            actual=claims.source_repository_owner_identifier,
          ),
        )
      }
    }
  }
  if self.source_ref is Some(git_ref) {
    guard claims.source_repository_ref == Some(git_ref.to_string()) else {
      return Some(
        ClaimMismatch(
          claim=SourceRef,
          expected=git_ref.to_string(),
          actual=claims.source_repository_ref,
        ),
      )
    }
  }
  if self.hosted_runner_only {
    guard claims.runner_environment == Some("github-hosted") else {
      return Some(RunnerEnvironment(actual=claims.runner_environment))
    }
  }
  if self.predicate_type is Some(expected) {
    let actual = statement.map(statement => statement.predicate_type)
    guard actual == Some(expected) else {
      return Some(PredicateTypeMismatch(expected~, actual~))
    }
  }
  None
}

///|
/// A field of the release predicate that a release policy matches.
pub enum ReleaseField {
  /// The certificate has an OIDC issuer, so it is not a release certificate.
  Issuer
  Repository
  Tag
  Purl
  RepositoryId
  OwnerId
} derive(Eq, Debug)

///|
pub extend ReleaseField with Eq::{equal, not_equal}

///|
pub extend ReleaseField with @debug.Debug::{to_repr}

///|
/// The first reason that the release statement does not match, or `None`.
fn Policy::release_mismatch(
  self : Policy,
  repository : GitHubRepository,
  tag : ReleaseTag,
  claims : CertificateClaims,
  statement : Statement?,
) -> PolicyMismatch? {
  // GitHub release certificates have no OIDC issuer; a Fulcio leaf of a
  // workflow always has one, so it cannot pass as a release.
  if claims.issuer is Some(issuer) {
    return Some(ReleaseMismatch(field=Issuer, expected="no OIDC issuer", actual=Some(issuer)))
  }
  if self.predicate_type is Some(expected) {
    let actual = statement.map(statement => statement.predicate_type)
    guard actual == Some(expected) else {
      return Some(PredicateTypeMismatch(expected~, actual~))
    }
  }
  guard statement is Some(statement) else {
    return Some(ReleaseMismatch(field=Repository, expected=repository.to_string(), actual=None))
  }
  fn field(name : String) -> String? {
    if statement.predicate is Object(map) && map.get(name) is Some(String(value)) {
      Some(value)
    } else {
      None
    }
  }

  let actual_repository = field("repository")
  guard equal_ignoring_case(actual_repository, repository.to_string()) else {
    return Some(
      ReleaseMismatch(field=Repository, expected=repository.to_string(), actual=actual_repository),
    )
  }
  let actual_tag = field("tag")
  guard actual_tag == Some(tag.to_string()) else {
    return Some(ReleaseMismatch(field=Tag, expected=tag.to_string(), actual=actual_tag))
  }
  // The package name is case-insensitive like the repository; the version is the exact tag.
  let expected_purl = "pkg:github/\{repository}@\{tag}"
  let actual_purl = field("purl")
  let prefix = "pkg:github/\{repository}@"
  let purl_matches = match actual_purl {
    Some(purl) =>
      purl.length() > prefix.length() &&
      purl[:prefix.length()].to_lower() == prefix.to_lower() &&
      percent_decode(purl[prefix.length():].to_owned()) == Some(tag.to_string())
    None => false
  }
  guard purl_matches else {
    return Some(ReleaseMismatch(field=Purl, expected=expected_purl, actual=actual_purl))
  }
  if self.repository_id is Some(id) {
    let actual = field("repositoryId")
    guard actual == Some(id.to_string()) else {
      return Some(ReleaseMismatch(field=RepositoryId, expected=id.to_string(), actual~))
    }
  }
  if self.owner_id is Some(id) {
    let actual = field("ownerId")
    guard actual == Some(id.to_string()) else {
      return Some(ReleaseMismatch(field=OwnerId, expected=id.to_string(), actual~))
    }
  }
  None
}

///|
/// The text of a purl component with its `%XX` escapes decoded as UTF-8
/// bytes, or `None` when an escape is not two hex digits or the bytes are not
/// UTF-8.
fn percent_decode(text : String) -> String? {
  let bytes : Array[Byte] = []
  let encoded = @utf8.encode(text)
  let mut i = 0
  while i < encoded.length() {
    let byte = encoded[i]
    if byte == b'%' {
      guard i + 2 < encoded.length() else { return None }
      let high = hex_digit(encoded[i + 1].to_int())
      let low = hex_digit(encoded[i + 2].to_int())
      guard high >= 0 && low >= 0 else { return None }
      bytes.push((high * 16 + low).to_byte())
      i += 3
    } else {
      bytes.push(byte)
      i += 1
    }
  }
  @utf8.decode(Bytes::from_array(bytes)) catch {
    _ => None
  } noraise {
    decoded => Some(decoded)
  }
}

///|
fn hex_digit(code : Int) -> Int {
  if code >= '0'.to_int() && code <= '9'.to_int() {
    code - '0'.to_int()
  } else if code >= 'a'.to_int() && code <= 'f'.to_int() {
    code - 'a'.to_int() + 10
  } else if code >= 'A'.to_int() && code <= 'F'.to_int() {
    code - 'A'.to_int() + 10
  } else {
    -1
  }
}