use crate::model;
use crate::sigstore::{GITHUB_ISSUER, GITLAB_ISSUER, Policy, SourceRepository};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ForgeName<'a> {
host: &'a str,
repository: &'a str,
owner: &'a str,
subpath: Option<&'a str>,
}
impl ForgeName<'_> {
fn parse(project: &str) -> Option<ForgeName<'_>> {
if let Some((host, owner, repo)) = model::repository(project) {
let end = host.len() + 1 + owner.len() + 1 + repo.len();
return Some(ForgeName {
host,
repository: project.get(host.len() + 1..end)?,
owner,
subpath: model::repository_subpath(project),
});
}
let (host, path) = project.split_once('/')?;
if host != "gitlab.com" || path.split('/').any(str::is_empty) {
return None;
}
let (owner, _) = path.rsplit_once('/')?;
Some(ForgeName {
host,
repository: path,
owner,
subpath: None,
})
}
fn issuer(&self) -> &'static str {
if self.host == "gitlab.com" {
GITLAB_ISSUER
} else {
GITHUB_ISSUER
}
}
fn uri(&self) -> String {
format!("https://{}/{}", self.host, self.repository)
}
fn signer_prefix(&self) -> String {
if self.host == "gitlab.com" {
format!("{}//", self.uri())
} else {
format!("{}/", self.uri())
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct ForgePin {
pub project: String,
pub repository_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_id: Option<String>,
}
impl ForgePin {
pub fn new(
project: impl Into<String>,
repository_id: impl Into<String>,
owner_id: Option<String>,
) -> ForgePin {
ForgePin {
project: project.into(),
repository_id: repository_id.into(),
owner_id,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Expected<'a> {
pub project: &'a str,
pub pin: Option<&'a ForgePin>,
pub resolved_repository_id: Option<&'a str>,
pub accept_transfer: bool,
}
impl<'a> Expected<'a> {
pub fn new(project: &'a str) -> Expected<'a> {
Expected {
project,
pin: None,
resolved_repository_id: None,
accept_transfer: false,
}
}
pub fn pinned(self, pin: Option<&'a ForgePin>) -> Expected<'a> {
Expected { pin, ..self }
}
pub fn resolved(self, repository_id: Option<&'a str>) -> Expected<'a> {
Expected {
resolved_repository_id: repository_id,
..self
}
}
pub fn accepting_transfer(self, accept: bool) -> Expected<'a> {
Expected {
accept_transfer: accept,
..self
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Evidence {
Pin,
Resolved,
}
impl std::fmt::Display for Evidence {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Evidence::Pin => "the pinned repository",
Evidence::Resolved => "the repository the forge resolves the name to",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Continuity {
Same,
Renamed { requested: String, signed: String },
Transferred {
requested: String,
signed: String,
previous_owner: String,
owner: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum IdentityError {
#[error("{0} is not a GitHub or GitLab project, so no forge identity pins it")]
NotForge(String),
#[error(
"the release is for {signed}, not {requested}, and nothing shows they are the same repository"
)]
ProjectMismatch { requested: String, signed: String },
#[error("{project} is signed through issuer {actual:?}, expected {expected}")]
Issuer {
project: String,
expected: String,
actual: Option<String>,
},
#[error("signed by {identity}, which is not a workflow of {repository}")]
SignerOutsideRepository {
identity: String,
repository: String,
},
#[error("the certificate's source repository is {certificate}, not {repository}")]
SourceMismatch {
certificate: String,
repository: String,
},
#[error(
"{project} is signed from repository ID {actual}, but {evidence} is ID {expected}: the name belongs to a different repository now"
)]
DifferentRepository {
project: String,
expected: String,
actual: String,
evidence: Evidence,
},
#[error(
"{requested} moved from owner {previous_owner} to owner {owner} as {signed}; accept the new owner explicitly to trust it"
)]
Transferred {
requested: String,
signed: String,
previous_owner: String,
owner: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Check {
pub continuity: Continuity,
pub source: Option<SourceRepository>,
pub pin: Option<ForgePin>,
identity: String,
}
impl Check {
pub fn continues_signer(&self, previous: &str) -> bool {
match (workflow_path(previous), workflow_path(&self.identity)) {
(Some(before), Some(now)) => before == now,
_ => without_ref(previous) == without_ref(&self.identity),
}
}
}
fn without_ref(identity: &str) -> &str {
identity.rsplit_once('@').map_or(identity, |(path, _)| path)
}
fn workflow_path(identity: &str) -> Option<(&str, &str)> {
let rest = without_ref(identity.strip_prefix("https://")?);
if let Some(path) = rest.strip_prefix("github.com/") {
let mut parts = path.splitn(3, '/');
let (_owner, _repo, file) = (parts.next()?, parts.next()?, parts.next()?);
return Some(("github.com", file));
}
if let Some(path) = rest.strip_prefix("gitlab.com/") {
let (_project, file) = path.split_once("//")?;
return Some(("gitlab.com", file));
}
None
}
pub fn policy(requested: &str, signed_project: &str) -> Option<Policy> {
let want = ForgeName::parse(requested)?;
let target = match ForgeName::parse(signed_project) {
Some(got) if got.host == want.host && got.subpath == want.subpath => signed_project,
_ => requested,
};
Policy::for_project(target)
}
pub fn check(
expected: &Expected<'_>,
signed_project: &str,
identity: &str,
issuer: Option<&str>,
source: Option<&SourceRepository>,
) -> Result<Check, IdentityError> {
let requested = expected.project;
let want = ForgeName::parse(requested)
.ok_or_else(|| IdentityError::NotForge(requested.to_string()))?;
let mismatch = || IdentityError::ProjectMismatch {
requested: requested.to_string(),
signed: signed_project.to_string(),
};
let got = ForgeName::parse(signed_project).ok_or_else(mismatch)?;
if got.host != want.host || got.subpath != want.subpath {
return Err(mismatch());
}
if issuer != Some(want.issuer()) {
return Err(IdentityError::Issuer {
project: signed_project.to_string(),
expected: want.issuer().to_string(),
actual: issuer.map(str::to_string),
});
}
let repository = got.uri();
if !identity.starts_with(&got.signer_prefix()) {
return Err(IdentityError::SignerOutsideRepository {
identity: identity.to_string(),
repository,
});
}
if let Some(source) = source
&& source.uri != repository
{
return Err(IdentityError::SourceMismatch {
certificate: source.uri.clone(),
repository,
});
}
let actual_id = source.and_then(|s| s.id.as_deref());
let actual_owner_id = source.and_then(|s| s.owner_id.as_deref());
let known = match (expected.pin, expected.resolved_repository_id) {
(Some(pin), _) => Some((pin.repository_id.as_str(), Evidence::Pin)),
(None, Some(id)) => Some((id, Evidence::Resolved)),
(None, None) => None,
};
let same_name = signed_project == requested;
let continuity = match (known, actual_id) {
(Some((expected_id, evidence)), Some(actual)) if expected_id != actual => {
return Err(IdentityError::DifferentRepository {
project: signed_project.to_string(),
expected: expected_id.to_string(),
actual: actual.to_string(),
evidence,
});
}
(Some(_), Some(_)) => {
let pinned_owner_id = expected.pin.and_then(|p| p.owner_id.as_deref());
let moved = match (pinned_owner_id, actual_owner_id) {
(Some(before), Some(now)) => {
(before != now).then(|| (before.to_string(), now.to_string()))
}
_ => {
let before = expected
.pin
.and_then(|p| ForgeName::parse(&p.project))
.filter(|p| p.host == want.host)
.map_or(want.owner, |p| p.owner);
(!before.eq_ignore_ascii_case(got.owner))
.then(|| (before.to_string(), got.owner.to_string()))
}
};
match moved {
Some((previous_owner, owner)) if expected.accept_transfer => {
Continuity::Transferred {
requested: requested.to_string(),
signed: signed_project.to_string(),
previous_owner,
owner,
}
}
Some((previous_owner, owner)) => {
return Err(IdentityError::Transferred {
requested: requested.to_string(),
signed: signed_project.to_string(),
previous_owner,
owner,
});
}
None if same_name => Continuity::Same,
None => Continuity::Renamed {
requested: requested.to_string(),
signed: signed_project.to_string(),
},
}
}
_ if same_name => Continuity::Same,
_ => return Err(mismatch()),
};
Ok(Check {
continuity,
source: source.cloned(),
pin: actual_id
.map(|id| ForgePin::new(signed_project, id, actual_owner_id.map(str::to_string))),
identity: identity.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
const HK_WORKFLOW: &str =
"https://github.com/jdx/hk/.github/workflows/release.yml@refs/tags/v2.3.0";
fn source(uri: &str, id: &str, owner_uri: &str, owner_id: &str) -> SourceRepository {
SourceRepository {
uri: uri.into(),
id: Some(id.into()),
owner_uri: Some(owner_uri.into()),
owner_id: Some(owner_id.into()),
}
}
fn hk() -> SourceRepository {
source(
"https://github.com/jdx/hk",
"922514152",
"https://github.com/jdx",
"216188",
)
}
fn renamed() -> SourceRepository {
source(
"https://github.com/jdx/hook",
"922514152",
"https://github.com/jdx",
"216188",
)
}
fn transferred() -> SourceRepository {
source(
"https://github.com/acme/hk",
"922514152",
"https://github.com/acme",
"999",
)
}
fn squatted() -> SourceRepository {
source(
"https://github.com/jdx/hk",
"555",
"https://github.com/jdx",
"216188",
)
}
fn pin() -> ForgePin {
ForgePin::new("github.com/jdx/hk", "922514152", Some("216188".into()))
}
fn run(
expected: &Expected<'_>,
project: &str,
source: Option<&SourceRepository>,
) -> Result<Check, IdentityError> {
let identity = match source {
Some(s) => format!("{}/.github/workflows/release.yml@refs/tags/v1", s.uri),
None => format!(
"https://{}/.github/workflows/release.yml@refs/tags/v1",
project
),
};
check(expected, project, &identity, Some(GITHUB_ISSUER), source)
}
#[test]
fn same_name_and_repository_is_the_same() {
let pin = pin();
for expected in [
Expected::new("github.com/jdx/hk"),
Expected::new("github.com/jdx/hk").pinned(Some(&pin)),
Expected::new("github.com/jdx/hk").resolved(Some("922514152")),
] {
let ok = run(&expected, "github.com/jdx/hk", Some(&hk())).unwrap();
assert_eq!(ok.continuity, Continuity::Same);
assert_eq!(ok.pin, Some(pin.clone()));
assert_eq!(ok.source, Some(hk()));
}
let ok = run(
&Expected::new("github.com/jdx/hk").pinned(Some(&pin)),
"github.com/jdx/hk",
None,
)
.unwrap();
assert_eq!(ok.continuity, Continuity::Same);
assert_eq!(ok.pin, None);
}
#[test]
fn a_rename_is_followed_by_repository_id() {
let pin = pin();
for expected in [
Expected::new("github.com/jdx/hk").pinned(Some(&pin)),
Expected::new("github.com/jdx/hk").resolved(Some("922514152")),
] {
let ok = run(&expected, "github.com/jdx/hook", Some(&renamed())).unwrap();
assert_eq!(
ok.continuity,
Continuity::Renamed {
requested: "github.com/jdx/hk".into(),
signed: "github.com/jdx/hook".into(),
}
);
assert_eq!(
ok.pin,
Some(ForgePin::new(
"github.com/jdx/hook",
"922514152",
Some("216188".into())
))
);
}
let moved_owner = source(
"https://github.com/jdx2/hook",
"922514152",
"https://github.com/jdx2",
"216188",
);
let ok = run(
&Expected::new("github.com/jdx/hk").pinned(Some(&pin)),
"github.com/jdx2/hook",
Some(&moved_owner),
)
.unwrap();
assert!(matches!(ok.continuity, Continuity::Renamed { .. }));
}
#[test]
fn old_releases_under_the_old_name_still_verify() {
let pin = ForgePin::new("github.com/jdx/hook", "922514152", Some("216188".into()));
let ok = run(
&Expected::new("github.com/jdx/hook").pinned(Some(&pin)),
"github.com/jdx/hk",
Some(&hk()),
)
.unwrap();
assert_eq!(
ok.continuity,
Continuity::Renamed {
requested: "github.com/jdx/hook".into(),
signed: "github.com/jdx/hk".into(),
}
);
}
#[test]
fn a_transfer_needs_explicit_trust() {
let pin = pin();
let expected = Expected::new("github.com/jdx/hk").pinned(Some(&pin));
let err = run(&expected, "github.com/acme/hk", Some(&transferred())).unwrap_err();
assert_eq!(
err,
IdentityError::Transferred {
requested: "github.com/jdx/hk".into(),
signed: "github.com/acme/hk".into(),
previous_owner: "216188".into(),
owner: "999".into(),
}
);
let ok = run(
&expected.accepting_transfer(true),
"github.com/acme/hk",
Some(&transferred()),
)
.unwrap();
assert!(matches!(ok.continuity, Continuity::Transferred { .. }));
assert_eq!(
ok.pin.unwrap().owner_id.as_deref(),
Some("999"),
"the pin moves to the new owner"
);
let err = run(
&Expected::new("github.com/jdx/hk").resolved(Some("922514152")),
"github.com/acme/hk",
Some(&transferred()),
)
.unwrap_err();
assert!(
matches!(&err, IdentityError::Transferred { previous_owner, owner, .. }
if previous_owner == "jdx" && owner == "acme"),
"{err}"
);
let old_pin = ForgePin::new("github.com/jdx/hk", "922514152", None);
let err = run(
&Expected::new("github.com/jdx/hk").pinned(Some(&old_pin)),
"github.com/acme/hk",
Some(&transferred()),
)
.unwrap_err();
assert!(matches!(err, IdentityError::Transferred { .. }), "{err}");
let ok = run(
&Expected::new("github.com/JDX/hk").resolved(Some("922514152")),
"github.com/jdx/hook",
Some(&renamed()),
)
.unwrap();
assert!(matches!(ok.continuity, Continuity::Renamed { .. }));
}
#[test]
fn a_recreated_name_is_a_different_repository() {
let pin = pin();
let err = run(
&Expected::new("github.com/jdx/hk").pinned(Some(&pin)),
"github.com/jdx/hk",
Some(&squatted()),
)
.unwrap_err();
assert_eq!(
err,
IdentityError::DifferentRepository {
project: "github.com/jdx/hk".into(),
expected: "922514152".into(),
actual: "555".into(),
evidence: Evidence::Pin,
}
);
let err = run(
&Expected::new("github.com/jdx/hk")
.pinned(Some(&pin))
.resolved(Some("555")),
"github.com/jdx/hk",
Some(&squatted()),
)
.unwrap_err();
assert!(
matches!(err, IdentityError::DifferentRepository { .. }),
"{err}"
);
let err = run(
&Expected::new("github.com/jdx/hk").resolved(Some("922514152")),
"github.com/other/hk",
Some(&source(
"https://github.com/other/hk",
"777",
"https://github.com/other",
"1",
)),
)
.unwrap_err();
assert!(
matches!(
err,
IdentityError::DifferentRepository {
evidence: Evidence::Resolved,
..
}
),
"{err}"
);
}
#[test]
fn another_name_without_id_evidence_is_a_mismatch() {
let err = run(
&Expected::new("github.com/jdx/hk"),
"github.com/jdx/hook",
Some(&renamed()),
)
.unwrap_err();
assert_eq!(
err,
IdentityError::ProjectMismatch {
requested: "github.com/jdx/hk".into(),
signed: "github.com/jdx/hook".into(),
}
);
let pin = pin();
let err = run(
&Expected::new("github.com/jdx/hk").pinned(Some(&pin)),
"github.com/jdx/hook",
None,
)
.unwrap_err();
assert!(
matches!(err, IdentityError::ProjectMismatch { .. }),
"{err}"
);
}
#[test]
fn a_monorepo_subpath_must_survive_the_rename() {
let pin = pin();
let expected = Expected::new("github.com/jdx/hk/tool").pinned(Some(&pin));
let ok = run(&expected, "github.com/jdx/hook/tool", Some(&renamed())).unwrap();
assert!(matches!(ok.continuity, Continuity::Renamed { .. }));
for other in ["github.com/jdx/hook/other", "github.com/jdx/hook"] {
let err = run(&expected, other, Some(&renamed())).unwrap_err();
assert!(
matches!(err, IdentityError::ProjectMismatch { .. }),
"{err}"
);
}
}
#[test]
fn the_signer_and_certificate_must_belong_to_the_named_repository() {
let pin = pin();
let expected = Expected::new("github.com/jdx/hk").pinned(Some(&pin));
let err = check(
&expected,
"github.com/jdx/hk",
"https://github.com/evil/x/.github/workflows/r.yml@refs/tags/v1",
Some(GITHUB_ISSUER),
Some(&hk()),
)
.unwrap_err();
assert!(
matches!(err, IdentityError::SignerOutsideRepository { .. }),
"{err}"
);
let err = check(
&expected,
"github.com/jdx/hk",
HK_WORKFLOW,
Some(GITHUB_ISSUER),
Some(&renamed()),
)
.unwrap_err();
assert!(matches!(err, IdentityError::SourceMismatch { .. }), "{err}");
let err = check(
&expected,
"github.com/jdx/hk",
HK_WORKFLOW,
Some(GITLAB_ISSUER),
Some(&hk()),
)
.unwrap_err();
assert!(matches!(err, IdentityError::Issuer { .. }), "{err}");
let err = check(&expected, "github.com/jdx/hk", HK_WORKFLOW, None, None).unwrap_err();
assert!(matches!(err, IdentityError::Issuer { .. }), "{err}");
assert!(matches!(
run(&expected, "gitlab.com/jdx/hk", None).unwrap_err(),
IdentityError::ProjectMismatch { .. }
));
assert_eq!(
run(&Expected::new("mise.jdx.dev"), "mise.jdx.dev", None).unwrap_err(),
IdentityError::NotForge("mise.jdx.dev".into())
);
}
#[test]
fn gitlab_projects_are_pinned_by_project_id() {
let old = source(
"https://gitlab.com/group/sub/tool",
"42",
"https://gitlab.com/group/sub",
"7",
);
let new = source(
"https://gitlab.com/group/sub/tool2",
"42",
"https://gitlab.com/group/sub",
"7",
);
let pin = ForgePin::new("gitlab.com/group/sub/tool", "42", Some("7".into()));
let expected = Expected::new("gitlab.com/group/sub/tool").pinned(Some(&pin));
let signer = |uri: &str| format!("{uri}//.gitlab-ci.yml@refs/tags/v1");
let ok = check(
&expected,
"gitlab.com/group/sub/tool",
&signer(&old.uri),
Some(GITLAB_ISSUER),
Some(&old),
)
.unwrap();
assert_eq!(ok.continuity, Continuity::Same);
let ok = check(
&expected,
"gitlab.com/group/sub/tool2",
&signer(&new.uri),
Some(GITLAB_ISSUER),
Some(&new),
)
.unwrap();
assert!(matches!(ok.continuity, Continuity::Renamed { .. }));
assert!(ok.continues_signer("https://gitlab.com/group/sub/tool//.gitlab-ci.yml"));
let err = check(
&expected,
"gitlab.com/group/sub/tool",
"https://gitlab.com/group/sub/tool/x//.gitlab-ci.yml@refs/tags/v1",
Some(GITLAB_ISSUER),
Some(&old),
)
.unwrap_err();
assert!(
matches!(err, IdentityError::SignerOutsideRepository { .. }),
"{err}"
);
}
#[test]
fn signer_continuity_follows_the_repository() {
let pin = pin();
let ok = run(
&Expected::new("github.com/jdx/hk").pinned(Some(&pin)),
"github.com/jdx/hook",
Some(&renamed()),
)
.unwrap();
assert!(ok.continues_signer("https://github.com/jdx/hk/.github/workflows/release.yml"));
assert!(ok.continues_signer(
"https://github.com/jdx/hk/.github/workflows/release.yml@refs/tags/v0.1.0"
));
assert!(!ok.continues_signer("https://github.com/jdx/hk/.github/workflows/other.yml"));
assert!(!ok.continues_signer("https://gitlab.com/jdx/hk//.github/workflows/release.yml"));
assert!(!ok.continues_signer("me@example.com"));
}
#[test]
fn the_verification_policy_follows_the_signed_name_on_the_same_forge() {
let renamed = policy("github.com/jdx/hk", "github.com/jdx/hook").unwrap();
assert_eq!(
renamed.identity_prefix.as_deref(),
Some("https://github.com/jdx/hook/")
);
assert_eq!(renamed.issuer.as_deref(), Some(GITHUB_ISSUER));
for other in [
"github.com/jdx/hook/tool",
"gitlab.com/jdx/hook",
"mise.jdx.dev",
"",
] {
let p = policy("github.com/jdx/hk", other).unwrap();
assert_eq!(
p.identity_prefix.as_deref(),
Some("https://github.com/jdx/hk/"),
"{other}"
);
}
assert!(policy("mise.jdx.dev", "github.com/jdx/hk").is_none());
}
}