use std::fmt;
use std::str::FromStr;
use url::Url;
use crate::error::Error;
pub const FORCE_GHES_ENV: &str = "GITHUB_ACTIONS_FORCE_GHES";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Scope {
Enterprise {
enterprise: String,
},
Organization {
organization: String,
},
Repository {
owner: String,
repository: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitHubConfigUrl {
raw: String,
url: Url,
scope: Scope,
hosted: bool,
}
impl GitHubConfigUrl {
pub fn parse(input: &str) -> Result<Self, Error> {
let force_ghes = std::env::var_os(FORCE_GHES_ENV).is_some();
Self::parse_with(input, force_ghes)
}
fn parse_with(input: &str, force_ghes: bool) -> Result<Self, Error> {
let raw = input.trim_matches('/').to_owned();
let invalid = |reason: &str| Error::InvalidConfigUrl {
url: input.to_owned(),
reason: reason.to_owned(),
};
let url = Url::parse(&raw).map_err(|e| invalid(&e.to_string()))?;
if url.host_str().is_none_or(str::is_empty) {
return Err(invalid("missing host"));
}
let parts: Vec<&str> = url
.path()
.trim_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect();
let scope = match parts.as_slice() {
[org] => Scope::Organization {
organization: (*org).to_owned(),
},
[first, enterprise] if first.eq_ignore_ascii_case("enterprises") => Scope::Enterprise {
enterprise: (*enterprise).to_owned(),
},
[owner, repo] => Scope::Repository {
owner: (*owner).to_owned(),
repository: (*repo).to_owned(),
},
_ => {
return Err(invalid(
"should point to an enterprise, organization, or repository",
));
}
};
let hosted = !force_ghes && is_hosted_host(url.host_str().unwrap_or_default());
Ok(Self {
raw,
url,
scope,
hosted,
})
}
pub fn scope(&self) -> &Scope {
&self.scope
}
pub fn is_hosted(&self) -> bool {
self.hosted
}
pub(crate) fn requires_https(&self) -> bool {
let host = self.url.host_str().unwrap_or_default();
is_hosted_host(host)
&& !host
.trim_end_matches('.')
.eq_ignore_ascii_case("github.localhost")
}
pub fn as_str(&self) -> &str {
&self.raw
}
pub fn url(&self) -> &Url {
&self.url
}
pub fn api_url(&self, path: &str) -> Url {
let host = self.url.host_str().unwrap_or_default();
let authority = match self.url.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_owned(),
};
let base = if self.hosted {
if host.eq_ignore_ascii_case("www.github.com") {
format!("{}://api.github.com", self.url.scheme())
} else {
format!("{}://api.{authority}", self.url.scheme())
}
} else {
format!("{}://{authority}/api/v3", self.url.scheme())
};
let path = if path.starts_with('/') || path.is_empty() {
path.to_owned()
} else {
format!("/{path}")
};
Url::parse(&format!("{base}{path}")).expect("valid API URL")
}
pub(crate) fn registration_token_path(&self) -> String {
match &self.scope {
Scope::Organization { organization } => {
format!("/orgs/{organization}/actions/runners/registration-token")
}
Scope::Enterprise { enterprise } => {
format!("/enterprises/{enterprise}/actions/runners/registration-token")
}
Scope::Repository { owner, repository } => {
format!("/repos/{owner}/{repository}/actions/runners/registration-token")
}
}
}
}
fn is_hosted_host(host: &str) -> bool {
let host = host.trim_end_matches('.').to_ascii_lowercase();
host == "github.com"
|| host == "www.github.com"
|| host == "github.localhost"
|| host.ends_with(".ghe.com")
}
impl FromStr for GitHubConfigUrl {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl TryFrom<&str> for GitHubConfigUrl {
type Error = Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
impl TryFrom<String> for GitHubConfigUrl {
type Error = Error;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(&value)
}
}
impl fmt::Display for GitHubConfigUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.raw)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &str) -> Result<GitHubConfigUrl, Error> {
GitHubConfigUrl::parse_with(s, false)
}
#[test]
fn trailing_dot_is_still_hosted() {
assert!(parse("http://github.com./org").unwrap().is_hosted());
assert!(parse("http://acme.ghe.com./org").unwrap().is_hosted());
}
#[test]
fn scopes() {
let org = parse("https://github.com/org/").unwrap();
assert_eq!(
org.scope(),
&Scope::Organization {
organization: "org".into()
}
);
assert_eq!(org.as_str(), "https://github.com/org");
let repo = parse("https://github.com/org/repo").unwrap();
assert_eq!(
repo.scope(),
&Scope::Repository {
owner: "org".into(),
repository: "repo".into()
}
);
let ent = parse("https://github.com/Enterprises/acme").unwrap();
assert_eq!(
ent.scope(),
&Scope::Enterprise {
enterprise: "acme".into()
}
);
}
#[test]
fn invalid() {
for input in [
"https://github.com",
"https://github.com/",
"https://github.com/a/b/c",
"not a url",
] {
assert!(
matches!(parse(input), Err(Error::InvalidConfigUrl { .. })),
"{input}"
);
}
}
#[test]
fn api_urls() {
let cases = [
("https://github.com/org", "https://api.github.com/x"),
("https://www.github.com/org", "https://api.github.com/x"),
("https://acme.ghe.com/org", "https://api.acme.ghe.com/x"),
(
"https://github.localhost/org",
"https://api.github.localhost/x",
),
(
"https://ghes.example.com/org",
"https://ghes.example.com/api/v3/x",
),
(
"http://127.0.0.1:8080/org",
"http://127.0.0.1:8080/api/v3/x",
),
];
for (input, want) in cases {
assert_eq!(
parse(input).unwrap().api_url("/x").as_str(),
want,
"{input}"
);
}
}
#[test]
fn force_ghes() {
let url = GitHubConfigUrl::parse_with("https://github.com/org", true).unwrap();
assert!(!url.is_hosted());
assert_eq!(url.api_url("/x").as_str(), "https://github.com/api/v3/x");
assert!(url.requires_https());
let local = GitHubConfigUrl::parse_with("http://github.localhost/org", true).unwrap();
assert!(!local.requires_https());
}
#[test]
fn registration_paths() {
assert_eq!(
parse("https://github.com/org")
.unwrap()
.registration_token_path(),
"/orgs/org/actions/runners/registration-token"
);
assert_eq!(
parse("https://github.com/org/repo")
.unwrap()
.registration_token_path(),
"/repos/org/repo/actions/runners/registration-token"
);
assert_eq!(
parse("https://github.com/enterprises/e")
.unwrap()
.registration_token_path(),
"/enterprises/e/actions/runners/registration-token"
);
}
}