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,
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)?,
subpath: model::repository_subpath(project),
});
}
let (host, path) = project.split_once('/')?;
if host != "gitlab.com" || path.split('/').any(str::is_empty) {
return None;
}
Some(ForgeName {
host,
repository: path,
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())
}
}
fn same_repository(&self, other: &ForgeName<'_>) -> bool {
same_repository((self.host, self.repository), (other.host, other.repository))
}
}
pub fn same_project(a: &str, b: &str) -> bool {
match (ForgeName::parse(a), ForgeName::parse(b)) {
(Some(x), Some(y)) => x.same_repository(&y) && x.subpath == y.subpath,
_ => a == b,
}
}
fn same_repository(a: (&str, &str), b: (&str, &str)) -> bool {
a.0 == b.0 && a.1.eq_ignore_ascii_case(b.1)
}
fn repository_span(url: &str) -> Option<std::ops::Range<usize>> {
if let Some(rest) = url.strip_prefix("https://github.com/") {
let start = url.len() - rest.len();
let len = rest
.match_indices('/')
.nth(1)
.map_or(rest.len(), |(i, _)| i);
return Some(start..start + len);
}
let rest = url.strip_prefix("https://gitlab.com/")?;
let start = url.len() - rest.len();
Some(start..start + rest.find("//").unwrap_or(rest.len()))
}
pub(crate) fn identity_has_prefix(identity: &str, prefix: &str) -> bool {
let Some(name) = repository_span(prefix) else {
return identity.starts_with(prefix);
};
let (identity, prefix) = (identity.as_bytes(), prefix.as_bytes());
identity.len() >= name.end
&& identity[..name.start] == prefix[..name.start]
&& identity[name.clone()].eq_ignore_ascii_case(&prefix[name.clone()])
&& identity[name.end..].starts_with(&prefix[name.end..])
}
pub(crate) fn same_url(a: &str, b: &str) -> bool {
a.len() == b.len() && identity_has_prefix(a, b)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct ForgePin {
pub project: String,
pub repository_id: String,
#[deprecated(
since = "1.5.0",
note = "ignored: the repository ID alone pins a forge project"
)]
#[serde(default, skip_serializing)]
pub owner_id: Option<String>,
}
impl PartialEq for ForgePin {
fn eq(&self, other: &ForgePin) -> bool {
self.project == other.project && self.repository_id == other.repository_id
}
}
impl Eq for ForgePin {}
#[allow(deprecated)]
impl ForgePin {
pub fn of(project: impl Into<String>, repository_id: impl Into<String>) -> ForgePin {
ForgePin {
project: project.into(),
repository_id: repository_id.into(),
owner_id: None,
}
}
#[deprecated(
since = "1.5.0",
note = "the owner ID is ignored; use ForgePin::of(project, repository_id)"
)]
pub fn new(
project: impl Into<String>,
repository_id: impl Into<String>,
owner_id: Option<String>,
) -> ForgePin {
ForgePin {
owner_id,
..ForgePin::of(project, repository_id)
}
}
pub fn continues(&self, previous: &ForgePin) -> bool {
let (Some(before), Some(now)) = (
ForgeName::parse(&previous.project),
ForgeName::parse(&self.project),
) else {
return false;
};
before.host == now.host && previous.repository_id == self.repository_id
}
}
#[cfg(any(feature = "install-policy", test))]
pub(crate) fn source_pin(source: &SourceRepository) -> Option<ForgePin> {
let id = source.id.as_deref().filter(|id| !id.is_empty())?;
let name = source.uri.strip_prefix("https://")?;
let parsed = ForgeName::parse(name)?;
(parsed.subpath.is_none() && parsed.uri() == source.uri).then(|| ForgePin::of(name, id))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum PinSource {
Local,
Lockfile,
}
impl std::fmt::Display for PinSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
PinSource::Local => "this machine's pin",
PinSource::Lockfile => "the lockfile's pin",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
#[allow(deprecated)]
pub struct Expected<'a> {
pub project: &'a str,
pub pin: Option<&'a ForgePin>,
pub pins: &'a [(PinSource, ForgePin)],
pub resolved_repository_id: Option<&'a str>,
#[deprecated(
since = "1.5.0",
note = "ignored: a transfer is followed by repository ID and needs no acceptance"
)]
pub accept_transfer: bool,
}
#[allow(deprecated)]
impl<'a> Expected<'a> {
pub fn new(project: &'a str) -> Expected<'a> {
Expected {
project,
pin: None,
pins: &[],
resolved_repository_id: None,
accept_transfer: false,
}
}
pub fn pinned(self, pin: Option<&'a ForgePin>) -> Expected<'a> {
Expected { pin, ..self }
}
pub fn pinned_by(self, pins: &'a [(PinSource, ForgePin)]) -> Expected<'a> {
Expected { pins, ..self }
}
pub fn resolved(self, repository_id: Option<&'a str>) -> Expected<'a> {
Expected {
resolved_repository_id: repository_id,
..self
}
}
#[deprecated(
since = "1.5.0",
note = "ignored: a transfer is followed by repository ID and needs no acceptance"
)]
#[allow(deprecated)]
pub fn accepting_transfer(self, accept: bool) -> Expected<'a> {
Expected {
accept_transfer: accept,
..self
}
}
fn all_pins(&self) -> Vec<(&'a ForgePin, Evidence)> {
self.pin
.map(|pin| (pin, Evidence::Pin))
.into_iter()
.chain(
self.pins
.iter()
.map(|(source, pin)| (pin, Evidence::from(*source))),
)
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Evidence {
Pin,
Resolved,
LocalPin,
LockfilePin,
}
impl Evidence {
pub fn pin_source(self) -> Option<PinSource> {
match self {
Evidence::LocalPin => Some(PinSource::Local),
Evidence::LockfilePin => Some(PinSource::Lockfile),
Evidence::Pin | Evidence::Resolved => None,
}
}
}
impl From<PinSource> for Evidence {
fn from(source: PinSource) -> Evidence {
match source {
PinSource::Local => Evidence::LocalPin,
PinSource::Lockfile => Evidence::LockfilePin,
}
}
}
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",
Evidence::LocalPin => "the repository this machine pinned",
Evidence::LockfilePin => "the repository the lockfile pins",
})
}
}
#[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 },
#[deprecated(
since = "1.5.0",
note = "not returned any more: a transfer is Continuity::Renamed"
)]
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,
},
#[deprecated(
since = "1.5.0",
note = "not returned any more: a transfer is followed by repository ID"
)]
#[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,
by_id: bool,
pin_workflow: bool,
requested: String,
previous_projects: Vec<String>,
}
impl Check {
pub fn continues_signer(&self, previous: &str) -> bool {
let pin = self.pin.as_ref().filter(|_| self.by_id);
if !self.pin_workflow {
let (Some(before), Some(now)) =
(repository_of(previous), repository_of(&self.identity))
else {
return false;
};
return before.0 == now.0 && (self.by_id || same_repository(before, now));
}
if let (Some(pin), Some(before)) = (pin, repository_of(previous)) {
let own = std::iter::once(pin.project.as_str())
.chain(std::iter::once(self.requested.as_str()))
.chain(self.previous_projects.iter().map(String::as_str))
.filter_map(ForgeName::parse)
.any(|name| same_repository((name.host, name.repository), before));
let continues = match external_signer(&self.identity, pin) {
Some(shared) => same_repository(before, shared) && !own,
None => own,
};
if !continues {
return false;
}
}
same_path(previous, pin, &self.identity, pin)
}
pub fn pins_workflow(&self) -> bool {
self.pin_workflow
}
pub(crate) fn declaring_pin_workflow(mut self, pins: bool) -> Check {
self.pin_workflow = pins;
self
}
}
pub fn same_workflow(
previous: &str,
previous_pin: Option<&ForgePin>,
current: &str,
current_pin: Option<&ForgePin>,
) -> bool {
if let (Some(before), Some(now)) = (previous_pin, current_pin) {
let shared = match (
external_signer(previous, before),
external_signer(current, now),
) {
(None, None) => true,
(Some(before), Some(now)) => same_repository(before, now),
_ => false,
};
if !shared {
return false;
}
}
same_path(previous, previous_pin, current, current_pin)
}
fn same_path(
previous: &str,
previous_pin: Option<&ForgePin>,
current: &str,
current_pin: Option<&ForgePin>,
) -> bool {
match (previous_pin, current_pin) {
(Some(before), Some(now)) => {
now.continues(before)
&& match (workflow_path(previous), workflow_path(current)) {
(Some(before), Some(now)) => before == now,
_ => same_url(without_ref(previous), without_ref(current)),
}
}
_ => same_url(without_ref(previous), without_ref(current)),
}
}
fn external_signer<'a>(identity: &'a str, pin: &ForgePin) -> Option<(&'a str, &'a str)> {
let signer = repository_of(identity)?;
let project = ForgeName::parse(&pin.project)?;
(!same_repository(signer, (project.host, project.repository))).then_some(signer)
}
fn without_ref(identity: &str) -> &str {
if !identity.starts_with("https://") {
return identity;
}
identity.rsplit_once('@').map_or(identity, |(path, _)| path)
}
fn repository_of(identity: &str) -> Option<(&str, &str)> {
let rest = without_ref(identity).strip_prefix("https://")?;
if let Some(path) = rest.strip_prefix("github.com/") {
let end = path.match_indices('/').nth(1).map(|(i, _)| i)?;
return Some(("github.com", &path[..end]));
}
if let Some(path) = rest.strip_prefix("gitlab.com/") {
return Some(("gitlab.com", path.split_once("//")?.0));
}
None
}
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 => got,
_ => want,
};
Some(Policy {
issuer: Some(target.issuer().to_string()),
identity: None,
identity_prefix: None,
})
}
pub fn check(
expected: &Expected<'_>,
signed_project: &str,
identity: &str,
issuer: Option<&str>,
source: Option<&SourceRepository>,
) -> Result<Check, IdentityError> {
check_inner(expected, signed_project, identity, true, issuer, source)
}
pub fn check_source(
expected: &Expected<'_>,
signed_project: &str,
identity: &str,
issuer: Option<&str>,
source: Option<&SourceRepository>,
) -> Result<Check, IdentityError> {
check_inner(
expected,
signed_project,
identity,
source.is_none(),
issuer,
source,
)
}
fn check_inner(
expected: &Expected<'_>,
signed_project: &str,
identity: &str,
require_signer: bool,
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 require_signer && !identity_has_prefix(identity, &got.signer_prefix()) {
return Err(IdentityError::SignerOutsideRepository {
identity: identity.to_string(),
repository,
});
}
if let Some(source) = source
&& !same_url(&source.uri, &repository)
{
return Err(IdentityError::SourceMismatch {
certificate: source.uri.clone(),
repository,
});
}
let actual_id = source.and_then(|s| s.id.as_deref());
let pins = expected.all_pins();
let same_name = same_project(signed_project, requested);
let by_id =
actual_id.is_some() && (!pins.is_empty() || expected.resolved_repository_id.is_some());
let continuity = match actual_id {
Some(actual) if by_id => {
let known: Vec<(&str, Evidence)> = if pins.is_empty() {
expected
.resolved_repository_id
.map(|id| (id, Evidence::Resolved))
.into_iter()
.collect()
} else {
pins.iter()
.map(|(pin, evidence)| (pin.repository_id.as_str(), *evidence))
.collect()
};
if let Some((expected_id, evidence)) = known.iter().find(|(id, _)| *id != actual) {
return Err(IdentityError::DifferentRepository {
project: signed_project.to_string(),
expected: expected_id.to_string(),
actual: actual.to_string(),
evidence: *evidence,
});
}
if same_name {
Continuity::Same
} else {
Continuity::Renamed {
requested: requested.to_string(),
signed: signed_project.to_string(),
}
}
}
_ if same_name => Continuity::Same,
_ => return Err(mismatch()),
};
let pin = actual_id.map(|id| ForgePin::of(signed_project, id));
Ok(Check {
continuity,
source: source.cloned(),
pin,
identity: identity.to_string(),
by_id,
pin_workflow: true,
requested: requested.to_string(),
previous_projects: pins.iter().map(|(pin, _)| pin.project.clone()).collect(),
})
}
#[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::new(uri)
.with_id(id)
.with_owner(owner_uri, owner_id)
}
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::of("github.com/jdx/hk", "922514152")
}
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::of("github.com/jdx/hook", "922514152"))
);
}
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::of("github.com/jdx/hook", "922514152");
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_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/acme/hk", Some(&transferred())).unwrap();
assert_eq!(
ok.continuity,
Continuity::Renamed {
requested: "github.com/jdx/hk".into(),
signed: "github.com/acme/hk".into(),
}
);
assert_eq!(
ok.pin,
Some(ForgePin::of("github.com/acme/hk", "922514152")),
"the pin moves with the repository"
);
assert!(
ok.continues_signer("https://github.com/jdx/hk/.github/workflows/release.yml"),
"the workflow keeps its path inside the repository"
);
}
let moved = source(
"https://github.com/acme/tool",
"922514152",
"https://github.com/acme",
"999",
);
let ok = run(
&Expected::new("github.com/jdx/hk").pinned(Some(&pin)),
"github.com/acme/tool",
Some(&moved),
)
.unwrap();
assert!(matches!(ok.continuity, Continuity::Renamed { .. }));
let bare = SourceRepository::new("https://github.com/acme/hk").with_id("922514152");
let ok = run(
&Expected::new("github.com/jdx/hk").pinned(Some(&pin)),
"github.com/acme/hk",
Some(&bare),
)
.unwrap();
assert!(matches!(ok.continuity, Continuity::Renamed { .. }));
let after = ForgePin::of("github.com/acme/hk", "922514152");
let ok = run(
&Expected::new("github.com/acme/hk").pinned(Some(&after)),
"github.com/jdx/hk",
Some(&hk()),
)
.unwrap();
assert_eq!(
ok.continuity,
Continuity::Renamed {
requested: "github.com/acme/hk".into(),
signed: "github.com/jdx/hk".into(),
}
);
let ok = run(
&Expected::new("github.com/acme/hk").pinned(Some(&after)),
"github.com/acme/hk",
Some(&transferred()),
)
.unwrap();
assert_eq!(ok.continuity, Continuity::Same);
let evil = source(
"https://github.com/evil/hk",
"666",
"https://github.com/evil",
"1",
);
let err = run(
&Expected::new("github.com/jdx/hk").pinned(Some(&pin)),
"github.com/evil/hk",
Some(&evil),
)
.unwrap_err();
assert!(
matches!(err, IdentityError::DifferentRepository { .. }),
"{err}"
);
}
#[test]
#[allow(deprecated)]
fn the_owner_is_not_part_of_the_pin() {
{
let old: ForgePin = serde_json::from_str(
r#"{"project":"github.com/jdx/hk","repository_id":"922514152","owner_id":"216188"}"#,
)
.unwrap();
assert_eq!(old.owner_id.as_deref(), Some("216188"));
assert_eq!(old, pin(), "equal to the pin the same release gives now");
let written = serde_json::to_string(&old).unwrap();
assert!(!written.contains("owner"), "{written}");
assert_eq!(serde_json::from_str::<ForgePin>(&written).unwrap(), pin());
let prompted: ForgePin = serde_json::from_str(
r#"{"project":"github.com/acme/hk","repository_id":"922514152","owner_id":"999","accepted_owner_ids":["216188"]}"#,
)
.unwrap();
assert_eq!(prompted, ForgePin::of("github.com/acme/hk", "922514152"));
let jdx = old;
let ok = run(
&Expected::new("github.com/jdx/hk").pinned(Some(&jdx)),
"github.com/acme/hk",
Some(&transferred()),
)
.unwrap();
assert!(matches!(ok.continuity, Continuity::Renamed { .. }));
let ok = run(
&Expected::new("github.com/acme/hk").pinned(Some(&prompted)),
"github.com/jdx/hk",
Some(&hk()),
)
.unwrap();
assert!(matches!(ok.continuity, Continuity::Renamed { .. }));
let built = ForgePin::new("github.com/jdx/hk", "922514152", Some("1".into()));
assert_eq!(built, pin());
let expected = Expected::new("github.com/jdx/hk")
.pinned(Some(&built))
.accepting_transfer(true);
let ok = run(&expected, "github.com/acme/hk", Some(&transferred())).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 check_source_accepts_a_reusable_workflow_of_another_repository() {
let expected = Expected::new("github.com/jdx/hk");
let reusable = "https://github.com/jdx/shared/.github/workflows/r.yml@refs/tags/v1";
let signed = hk();
let args = |source| {
check_source(
&expected,
"github.com/jdx/hk",
reusable,
Some(GITHUB_ISSUER),
source,
)
};
assert!(args(Some(&signed)).is_ok());
let other = source(
"https://github.com/jdx/other",
"1",
"https://github.com/jdx",
"216188",
);
assert!(matches!(
args(Some(&other)).unwrap_err(),
IdentityError::SourceMismatch { .. }
));
assert!(matches!(
args(None).unwrap_err(),
IdentityError::SignerOutsideRepository { .. }
));
}
#[test]
fn a_shared_workflow_at_the_same_path_is_not_the_projects_own_signer() {
let pin = pin();
let expected = Expected::new("github.com/jdx/hk").pinned(Some(&pin));
let shared = "https://github.com/jdx/shared/.github/workflows/release.yml@refs/tags/v1";
let ok = check_source(
&expected,
"github.com/jdx/hk",
shared,
Some(GITHUB_ISSUER),
Some(&hk()),
)
.unwrap();
assert!(ok.continues_signer(shared));
assert!(ok.continues_signer(
"https://github.com/jdx/shared/.github/workflows/release.yml@refs/tags/v0"
));
assert!(!ok.continues_signer("https://github.com/jdx/hk/.github/workflows/release.yml"));
assert!(!ok.continues_signer("https://github.com/jdx/other/.github/workflows/release.yml"));
let own_signer = "https://github.com/jdx/hk/.github/workflows/release.yml@refs/tags/v2";
let own = check_source(
&expected,
"github.com/jdx/hk",
own_signer,
Some(GITHUB_ISSUER),
Some(&hk()),
)
.unwrap();
assert!(!own.continues_signer(
"https://github.com/jdx/shared/.github/workflows/release.yml@refs/tags/v1"
));
assert!(own.continues_signer(
"https://github.com/jdx/hk/.github/workflows/release.yml@refs/tags/v1"
));
let old = ForgePin::of("github.com/jdx/old-hk", "922514152");
let renamed = check_source(
&Expected::new("github.com/jdx/hk").pinned(Some(&old)),
"github.com/jdx/hk",
own_signer,
Some(GITHUB_ISSUER),
Some(&hk()),
)
.unwrap();
assert!(renamed.continues_signer(
"https://github.com/jdx/old-hk/.github/workflows/release.yml@refs/tags/v1"
));
assert!(!renamed.continues_signer(
"https://github.com/jdx/shared/.github/workflows/release.yml@refs/tags/v1"
));
let squatter = "https://github.com/jdx/old-hk/.github/workflows/release.yml@refs/tags/v2";
let taken = check_source(
&Expected::new("github.com/jdx/hk").pinned(Some(&old)),
"github.com/jdx/hk",
squatter,
Some(GITHUB_ISSUER),
Some(&hk()),
)
.unwrap();
assert!(!taken.continues_signer(
"https://github.com/jdx/old-hk/.github/workflows/release.yml@refs/tags/v1"
));
}
#[test]
fn without_a_pin_a_shared_workflow_and_the_projects_own_still_differ() {
let expected = Expected::new("github.com/jdx/hk").resolved(Some("922514152"));
let shared = "https://github.com/jdx/shared/.github/workflows/release.yml@refs/tags/v2";
let own = "https://github.com/jdx/hk/.github/workflows/release.yml@refs/tags/v2";
let signed = |signer: &str| {
check_source(
&expected,
"github.com/jdx/hk",
signer,
Some(GITHUB_ISSUER),
Some(&hk()),
)
.unwrap()
};
let shared_before =
"https://github.com/jdx/shared/.github/workflows/release.yml@refs/tags/v1";
let own_before = "https://github.com/jdx/hk/.github/workflows/release.yml@refs/tags/v1";
assert!(signed(shared).continues_signer(shared_before));
assert!(!signed(shared).continues_signer(own_before));
assert!(signed(own).continues_signer(own_before));
assert!(!signed(own).continues_signer(shared_before));
let moved = check_source(
&expected,
"github.com/acme/hk",
"https://github.com/acme/hk/.github/workflows/release.yml@refs/tags/v2",
Some(GITHUB_ISSUER),
Some(&transferred()),
)
.unwrap();
assert!(moved.continues_signer(own_before));
assert!(!moved.continues_signer(shared_before));
}
#[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::of("gitlab.com/group/sub/tool", "42");
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 a_release_that_does_not_pin_its_workflow_continues_any_workflow_of_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.pins_workflow());
assert!(!ok.continues_signer("https://github.com/jdx/hk/.github/workflows/other.yml"));
let ok = ok.declaring_pin_workflow(false);
assert!(!ok.pins_workflow());
assert!(ok.continues_signer("https://github.com/jdx/hk/.github/workflows/other.yml"));
assert!(ok.continues_signer(
"https://github.com/jdx/hook/.github/workflows/other.yml@refs/tags/v0.1.0"
));
assert!(!ok.continues_signer("https://gitlab.com/jdx/hk//.gitlab-ci.yml"));
assert!(!ok.continues_signer("me@example.com"));
}
#[test]
fn without_a_repository_id_the_unpinned_workflow_is_compared_by_repository_name() {
let ok = run(
&Expected::new("github.com/jdx/hk"),
"github.com/jdx/hk",
None,
)
.unwrap()
.declaring_pin_workflow(false);
assert!(ok.continues_signer("https://github.com/jdx/hk/.github/workflows/other.yml"));
assert!(!ok.continues_signer("https://github.com/jdx/hook/.github/workflows/release.yml"));
assert_eq!(
repository_of("https://gitlab.com/group/sub/tool//.gitlab-ci.yml@refs/tags/v1"),
Some(("gitlab.com", "group/sub/tool"))
);
}
#[test]
fn the_verification_policy_is_the_forges_issuer_alone() {
for signed in ["github.com/jdx/hook", "github.com/jdx/hook/tool"] {
let p = policy("github.com/jdx/hk", signed).unwrap();
assert_eq!(p.issuer.as_deref(), Some(GITHUB_ISSUER));
assert!(
p.identity.is_none() && p.identity_prefix.is_none(),
"{signed}"
);
}
for other in ["gitlab.com/jdx/hook", "mise.jdx.dev", ""] {
let p = policy("github.com/jdx/hk", other).unwrap();
assert_eq!(p.issuer.as_deref(), Some(GITHUB_ISSUER), "{other}");
}
let gitlab = policy("gitlab.com/group/proj", "gitlab.com/group/proj").unwrap();
assert_eq!(gitlab.issuer.as_deref(), Some(GITLAB_ISSUER));
assert!(policy("mise.jdx.dev", "github.com/jdx/hk").is_none());
}
#[test]
fn every_pin_must_hold_and_a_refusal_names_the_one_that_did_not() {
let other = ForgePin::of("github.com/jdx/hk", "555");
let pins = [
(PinSource::Local, pin()),
(PinSource::Lockfile, other.clone()),
];
let err = run(
&Expected::new("github.com/jdx/hk").pinned_by(&pins),
"github.com/jdx/hk",
Some(&hk()),
)
.unwrap_err();
assert_eq!(
err,
IdentityError::DifferentRepository {
project: "github.com/jdx/hk".into(),
expected: "555".into(),
actual: "922514152".into(),
evidence: Evidence::LockfilePin,
}
);
assert!(
err.to_string()
.contains("the repository the lockfile pins is ID 555"),
"{err}"
);
let good = [(PinSource::Lockfile, pin())];
let err = run(
&Expected::new("github.com/jdx/hk")
.pinned(Some(&other))
.pinned_by(&good),
"github.com/jdx/hk",
Some(&hk()),
)
.unwrap_err();
assert!(
matches!(
err,
IdentityError::DifferentRepository {
evidence: Evidence::Pin,
..
}
),
"{err}"
);
let pins = [
(PinSource::Local, pin()),
(
PinSource::Lockfile,
ForgePin::of("github.com/jdx/hook", "922514152"),
),
];
let expected = Expected::new("github.com/jdx/hk")
.pinned_by(&pins)
.resolved(Some("555"));
let ok = run(&expected, "github.com/jdx/hook", Some(&renamed())).unwrap();
assert!(matches!(ok.continuity, Continuity::Renamed { .. }));
assert!(ok.continues_signer("https://github.com/jdx/hk/.github/workflows/release.yml"));
let pins = [
(PinSource::Local, pin()),
(
PinSource::Lockfile,
ForgePin::of("github.com/acme/hk", "922514152"),
),
];
let ok = run(
&Expected::new("github.com/jdx/hk").pinned_by(&pins),
"github.com/acme/hk",
Some(&transferred()),
)
.unwrap();
assert!(matches!(ok.continuity, Continuity::Renamed { .. }));
}
#[test]
fn a_gitlab_project_moved_to_another_group_is_followed() {
let moved = source(
"https://gitlab.com/group/other/tool",
"42",
"https://gitlab.com/group/other",
"8",
);
let pin = ForgePin::of("gitlab.com/group/sub/tool", "42");
let ok = check(
&Expected::new("gitlab.com/group/sub/tool").pinned(Some(&pin)),
"gitlab.com/group/other/tool",
"https://gitlab.com/group/other/tool//.gitlab-ci.yml@refs/tags/v1",
Some(GITLAB_ISSUER),
Some(&moved),
)
.unwrap();
assert!(matches!(ok.continuity, Continuity::Renamed { .. }));
assert!(ok.continues_signer("https://gitlab.com/group/sub/tool//.gitlab-ci.yml"));
}
#[test]
fn stored_signers_are_compared_within_their_repository() {
let release =
|repo: &str| format!("https://github.com/{repo}/.github/workflows/release.yml");
let hk = pin();
let hook = ForgePin::of("github.com/jdx/hook", "922514152");
assert!(same_workflow(
&release("jdx/hk"),
Some(&hk),
&format!("{}@refs/tags/v3.0.0", release("jdx/hook")),
Some(&hook),
));
assert!(!same_workflow(
&release("jdx/hk"),
Some(&hk),
"https://github.com/jdx/hook/.github/workflows/other.yml",
Some(&hook),
));
let squatter = ForgePin::of("github.com/jdx/hk", "555");
assert!(!same_workflow(
&release("jdx/hk"),
Some(&hk),
&release("jdx/hk"),
Some(&squatter),
));
let acme = ForgePin::of("github.com/acme/hk", "922514152");
assert!(same_workflow(
&release("jdx/hk"),
Some(&hk),
&release("acme/hk"),
Some(&acme),
));
let shared = |repo: &str| format!("{}@refs/tags/v1", release(repo));
assert!(!same_workflow(
&release("jdx/hk"),
Some(&hk),
&shared("jdx/shared"),
Some(&hk),
));
assert!(!same_workflow(
&shared("jdx/shared"),
Some(&hk),
&release("jdx/hk"),
Some(&hk),
));
assert!(!same_workflow(
&shared("jdx/shared"),
Some(&hk),
&shared("jdx/other"),
Some(&hk),
));
assert!(same_workflow(
&shared("jdx/shared"),
Some(&hk),
&shared("jdx/shared"),
Some(&hook),
));
assert!(same_workflow(
&release("jdx/hk"),
None,
&format!("{}@refs/tags/v1", release("jdx/hk")),
Some(&hk),
));
assert!(!same_workflow(
&release("jdx/hk"),
None,
&release("jdx/hook"),
Some(&hook),
));
assert!(same_workflow(
"alice@example.com",
None,
"alice@example.com",
None
));
assert!(!same_workflow(
"alice@example.com",
None,
"alice@example.org",
None
));
let gitlab = ForgePin::of("gitlab.com/jdx/hk", "922514152");
assert!(!gitlab.continues(&hk));
assert!(acme.continues(&hk));
assert!(!ForgePin::of("github.com/jdx/hk", "555").continues(&hk));
}
#[test]
fn forge_names_compare_without_case() {
let canonical = SourceRepository::new("https://github.com/jdx/Mise").with_id("1");
let signer = "https://github.com/jdx/Mise/.github/workflows/release.yml@refs/tags/v1";
let pin = ForgePin::of("github.com/jdx/mise", "1");
for expected in [
Expected::new("github.com/jdx/mise"),
Expected::new("github.com/JDX/MISE"),
Expected::new("github.com/JDX/mise").pinned(Some(&pin)),
] {
for source in [Some(&canonical), None] {
let ok = check(
&expected,
"github.com/jdx/mise",
signer,
Some(GITHUB_ISSUER),
source,
)
.unwrap();
assert_eq!(ok.continuity, Continuity::Same, "{expected:?} {source:?}");
}
}
let by_name = check(
&Expected::new("github.com/jdx/mise"),
"github.com/jdx/mise",
signer,
Some(GITHUB_ISSUER),
None,
)
.unwrap();
let by_id = check(
&Expected::new("github.com/jdx/mise").pinned(Some(&pin)),
"github.com/jdx/mise",
signer,
Some(GITHUB_ISSUER),
Some(&canonical),
)
.unwrap();
for ok in [&by_name, &by_id] {
assert!(
ok.continues_signer("https://github.com/JDX/mise/.github/workflows/release.yml")
);
assert!(
!ok.continues_signer("https://github.com/jdx/Mise/.github/workflows/Release.yml")
);
}
assert!(same_workflow(
"https://github.com/jdx/mise/.github/workflows/release.yml",
None,
signer,
None,
));
let err = check(
&Expected::new("github.com/jdx/mise").pinned(Some(&pin)),
"github.com/jdx/MISE",
"https://github.com/jdx/MISE/.github/workflows/release.yml@refs/tags/v1",
Some(GITHUB_ISSUER),
Some(&SourceRepository::new("https://github.com/jdx/MISE").with_id("2")),
)
.unwrap_err();
assert!(
matches!(err, IdentityError::DifferentRepository { .. }),
"{err}"
);
let err = check(
&Expected::new("github.com/jdx/mise/tool"),
"github.com/jdx/mise/Tool",
signer,
Some(GITHUB_ISSUER),
Some(&canonical),
)
.unwrap_err();
assert!(
matches!(err, IdentityError::ProjectMismatch { .. }),
"{err}"
);
let ok = check(
&Expected::new("gitlab.com/group/sub/tool"),
"gitlab.com/Group/Sub/Tool",
"https://gitlab.com/Group/Sub/Tool//.gitlab-ci.yml@refs/tags/v1",
Some(GITLAB_ISSUER),
Some(&SourceRepository::new("https://gitlab.com/group/sub/tool").with_id("42")),
)
.unwrap();
assert_eq!(ok.continuity, Continuity::Same);
assert!(identity_has_prefix(
"https://github.com/JDX/Mise/.github/workflows/r.yml",
"https://github.com/jdx/mise/"
));
assert!(identity_has_prefix(
"https://gitlab.com/Group/Tool//.gitlab-ci.yml",
"https://gitlab.com/group/tool//"
));
for (identity, prefix) in [
(
"HTTPS://GITHUB.COM/jdx/mise/r.yml",
"https://github.com/jdx/mise/",
),
(
"https://github.com/jdx/mise/.GITHUB/workflows/r.yml",
"https://github.com/jdx/mise/.github/",
),
("https://example.com/Jdx/r.yml", "https://example.com/jdx/"),
(
"https://github.com/jdx/mise-evil/r.yml",
"https://github.com/jdx/mise/",
),
("https://github.com/jdx", "https://github.com/jdx/mise/"),
(
"https://gitlab.com/group/tool/x//.gitlab-ci.yml",
"https://gitlab.com/group/tool//",
),
] {
assert!(!identity_has_prefix(identity, prefix), "{identity}");
}
assert!(!same_project("example.com/Tool", "example.com/tool"));
assert!(same_project("example.com/tool", "example.com/tool"));
assert!(!same_project("github.com/jdx/mise", "gitlab.com/jdx/mise"));
}
#[test]
fn a_source_repository_with_an_id_on_a_forge_gives_a_pin() {
assert_eq!(source_pin(&hk()), Some(pin()));
assert_eq!(
source_pin(&SourceRepository::new("https://gitlab.com/group/sub/tool").with_id("7")),
Some(ForgePin::of("gitlab.com/group/sub/tool", "7"))
);
for source in [
SourceRepository::new("https://github.com/jdx/hk"),
SourceRepository::new("https://github.com/jdx/hk").with_id(""),
SourceRepository::new("https://git.example.com/jdx/hk").with_id("1"),
SourceRepository::new("https://github.com/jdx").with_id("1"),
SourceRepository::new("https://github.com/jdx/hk/tool").with_id("1"),
SourceRepository::new("https://github.com/jdx/hk/").with_id("1"),
SourceRepository::new("http://github.com/jdx/hk").with_id("1"),
] {
assert_eq!(source_pin(&source), None, "{source:?}");
}
}
#[test]
fn signer_continuity_by_name_needs_the_same_identity() {
let ok = run(
&Expected::new("github.com/jdx/hk"),
"github.com/jdx/hk",
Some(&hk()),
)
.unwrap();
assert!(ok.continues_signer("https://github.com/jdx/hk/.github/workflows/release.yml"));
assert!(!ok.continues_signer("https://github.com/other/hk/.github/workflows/release.yml"));
}
}