#[cfg(test)]
use std::collections::HashMap;
use url::{Origin, Url};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
GitHub,
GitLab,
Forgejo,
}
impl Kind {
fn parse(value: &str) -> Option<Kind> {
match value {
"github" => Some(Kind::GitHub),
"gitlab" => Some(Kind::GitLab),
"forgejo" => Some(Kind::Forgejo),
_ => None,
}
}
pub fn label(self) -> &'static str {
match self {
Kind::GitHub => "github",
Kind::GitLab => "gitlab",
Kind::Forgejo => "forgejo",
}
}
fn header(self, token: &str) -> (&'static str, String) {
match self {
Kind::GitHub => ("Authorization", format!("Bearer {token}")),
Kind::GitLab => ("PRIVATE-TOKEN", token.to_string()),
Kind::Forgejo => ("Authorization", format!("token {token}")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Host {
name: String,
port: Option<u16>,
}
impl Host {
pub fn parse(key: &str) -> Result<Host, String> {
let refused = || {
format!(
"`{key}` is not a host. A key of \"forges\" is a host name, \
and a port when it is not the default: `forge.example.com` or \
`forge.example.com:3000`, with no scheme, user or path."
)
};
if key.is_empty()
|| key
.chars()
.any(|c| c.is_whitespace() || c.is_control() || "/\\@?#%".contains(c))
{
return Err(refused());
}
let (name, digits) = match key.split_once(':') {
Some((name, digits)) => (name, Some(digits)),
None => (key, None),
};
let plain = name.starts_with(|c: char| c.is_ascii_alphanumeric())
&& name.ends_with(|c: char| c.is_ascii_alphanumeric())
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-');
let port_ok = digits.is_none_or(|d| {
(1..=5).contains(&d.len())
&& d.chars().all(|c| c.is_ascii_digit())
&& (1..=65535).contains(&d.parse::<u32>().unwrap_or(0))
});
if !plain || !port_ok {
return Err(refused());
}
let url = Url::parse(&format!("https://{key}/")).map_err(|_| refused())?;
let name = url.host_str().ok_or_else(refused)?.to_string();
let port = match key.rsplit_once(':') {
Some((host, digits))
if !digits.is_empty()
&& digits.chars().all(|c| c.is_ascii_digit())
&& (!host.starts_with('[') || host.ends_with(']')) =>
{
Some(digits.parse::<u16>().map_err(|_| refused())?)
}
_ => None,
};
Ok(Host { name, port })
}
pub fn serves(&self, url: &Url) -> bool {
no_userinfo(url)
&& url.host_str() == Some(self.name.as_str())
&& match self.port {
Some(port) => url.port_or_known_default() == Some(port),
None => url.port().is_none(),
}
}
fn display(&self) -> String {
match self.port {
Some(port) => format!("{}:{port}", self.name),
None => self.name.clone(),
}
}
}
fn no_userinfo(url: &Url) -> bool {
url.username().is_empty() && url.password().is_none()
}
pub fn token_value(name: &str, value: Option<&str>) -> Result<Option<String>, String> {
let Some(value) = value else {
return Ok(None);
};
let value = value.trim_matches([' ', '\t', '\r', '\n', '\x0b', '\x0c']);
if value.is_empty() {
return Ok(None);
}
if value.chars().any(|c| c.is_ascii_control()) {
return Err(format!(
"{name} holds a control character, so it cannot be sent as a token"
));
}
Ok(Some(value.to_string()))
}
#[derive(Debug, Clone)]
pub struct Forge {
pub host: Host,
pub kind: Kind,
pub token_env: Vec<String>,
api: Option<Url>,
}
impl Forge {
fn builtin(host: &str, kind: Kind, token_env: &str, api: &str) -> Forge {
Forge {
host: Host::parse(host).expect("a built-in host parses"),
kind,
token_env: vec![token_env.to_string()],
api: Some(Url::parse(api).expect("a built-in API URL parses")),
}
}
fn api_for(&self, repo: &Url) -> Url {
if let Some(api) = &self.api {
return api.clone();
}
let mut base = repo.clone();
base.set_query(None);
base.set_fragment(None);
base.set_path(match self.kind {
Kind::GitHub => "/api/v3",
Kind::GitLab => "/api/v4",
Kind::Forgejo => "/api/v1",
});
base
}
pub fn token(&self, env: &Env) -> Result<Option<(String, String)>, String> {
for name in &self.token_env {
if let Some(value) = token_value(name, env.get(name).as_deref())? {
return Ok(Some((name.clone(), value)));
}
}
Ok(None)
}
fn token_names(&self) -> String {
match self.token_env.as_slice() {
[] => String::new(),
[one] => one.clone(),
[rest @ .., last] => format!("{} or {last}", rest.join(", ")),
}
}
}
#[derive(Debug, Clone)]
pub struct Forges {
forges: Vec<Forge>,
}
fn builtins() -> Vec<Forge> {
vec![
Forge::builtin(
"github.com",
Kind::GitHub,
"GITHUB_TOKEN",
"https://api.github.com",
),
Forge::builtin(
"gitlab.com",
Kind::GitLab,
"GITLAB_TOKEN",
"https://gitlab.com/api/v4",
),
]
}
impl Default for Forges {
fn default() -> Self {
Forges { forges: builtins() }
}
}
pub fn is_note(key: &str) -> bool {
key.starts_with("//")
}
fn is_variable_name(name: &str) -> bool {
let mut chars = name.chars();
chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
enum Raw {
Object(Vec<(String, Raw)>),
Other,
}
impl<'de> serde::Deserialize<'de> for Raw {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Raw, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = Raw;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a JSON value")
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> Result<Raw, A::Error> {
let mut members = Vec::new();
while let Some(member) = map.next_entry::<String, Raw>()? {
members.push(member);
}
Ok(Raw::Object(members))
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Raw, A::Error> {
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
Ok(Raw::Other)
}
fn visit_bool<E>(self, _: bool) -> Result<Raw, E> {
Ok(Raw::Other)
}
fn visit_i64<E>(self, _: i64) -> Result<Raw, E> {
Ok(Raw::Other)
}
fn visit_u64<E>(self, _: u64) -> Result<Raw, E> {
Ok(Raw::Other)
}
fn visit_f64<E>(self, _: f64) -> Result<Raw, E> {
Ok(Raw::Other)
}
fn visit_str<E>(self, _: &str) -> Result<Raw, E> {
Ok(Raw::Other)
}
fn visit_unit<E>(self) -> Result<Raw, E> {
Ok(Raw::Other)
}
}
deserializer.deserialize_any(Visitor)
}
}
pub fn check_shape(text: &str) -> Result<(), String> {
let Ok(Raw::Object(top)) = serde_json::from_str::<Raw>(text) else {
return Ok(());
};
let mut all = top.iter().filter(|(key, _)| key == "forges");
let Some((_, forges)) = all.next() else {
return Ok(());
};
if all.next().is_some() {
return Err("\"forges\" is given twice".to_string());
}
check_forges(forges)
}
fn check_forges(forges: &Raw) -> Result<(), String> {
let Raw::Object(entries) = forges else {
return Err("\"forges\" is not an object of hosts".to_string());
};
let mut hosts: Vec<(String, u16)> = Vec::new();
for (key, entry) in entries {
if is_note(key) {
continue;
}
let host = Host::parse(key).map_err(|e| format!("\"forges\": {e}"))?;
let at = (host.name, host.port.unwrap_or(443));
if hosts.contains(&at) {
return Err(format!("forge {key}: declared twice"));
}
hosts.push(at);
let Raw::Object(fields) = entry else {
return Err(format!("forge {key}: not an object"));
};
let mut seen: Vec<&str> = Vec::new();
for (field, _) in fields {
if is_note(field) {
continue;
}
if !FIELDS.contains(&field.as_str()) {
return Err(format!(
"forge {key}: unknown field \"{field}\"; the fields are \"type\", \"token_env\" and \"api\""
));
}
if seen.contains(&field.as_str()) {
return Err(format!("forge {key}: \"{field}\" is given twice"));
}
seen.push(field);
}
}
Ok(())
}
const FIELDS: [&str; 3] = ["type", "token_env", "api"];
impl Forges {
pub fn parse(value: Option<&serde_json::Value>) -> Result<Forges, String> {
let mut forges: Vec<Forge> = Vec::new();
let Some(value) = value else {
return Ok(Forges::default());
};
let object = value
.as_object()
.ok_or("\"forges\" is not an object of hosts")?;
for (key, entry) in object {
if is_note(key) {
continue;
}
let host = Host::parse(key).map_err(|e| format!("\"forges\": {e}"))?;
let at = |what: String| format!("\"forges\" -> \"{key}\": {what}");
let fields = entry
.as_object()
.ok_or_else(|| format!("forge {key}: not an object"))?;
for field in fields.keys() {
if !is_note(field) && !FIELDS.contains(&field.as_str()) {
return Err(format!(
"forge {key}: unknown field \"{field}\"; the fields are \"type\", \"token_env\" and \"api\""
));
}
}
let kind = match fields.get("type") {
Some(serde_json::Value::String(t)) => Kind::parse(t).ok_or_else(|| {
at(format!(
"\"type\" is `{t}`; it is \"github\", \"gitlab\" or \"forgejo\""
))
})?,
Some(_) => return Err(at("\"type\" is not a string".to_string())),
None => {
return Err(at(
"no \"type\"; it is \"github\", \"gitlab\" or \"forgejo\"".to_string(),
));
}
};
let builtin = builtins()
.into_iter()
.find(|b| b.host == host && b.kind == kind);
let token_env = match fields.get("token_env") {
None => builtin
.as_ref()
.map(|b| b.token_env.clone())
.unwrap_or_default(),
Some(serde_json::Value::String(name)) => vec![name.clone()],
Some(serde_json::Value::Array(names)) => names
.iter()
.map(|n| n.as_str().map(str::to_string))
.collect::<Option<Vec<_>>>()
.ok_or_else(|| at("\"token_env\" holds a non-string".to_string()))?,
Some(_) => {
return Err(at(
"\"token_env\" is a variable's name or a list of them".to_string()
));
}
};
if let Some(bad) = token_env.iter().find(|n| !is_variable_name(n)) {
return Err(format!(
"forge {key}: token_env \"{bad}\" is not an environment variable name"
));
}
let api = match fields.get("api") {
None => builtin.and_then(|b| b.api),
Some(serde_json::Value::String(api)) => Some(api_url(api).map_err(at)?),
Some(_) => return Err(at("\"api\" is not a string".to_string())),
};
if forges.iter().any(|f| f.host == host) {
return Err(format!("forge {key}: declared twice"));
}
forges.push(Forge {
host,
kind,
token_env,
api,
});
}
for builtin in builtins() {
if !forges.iter().any(|f| f.host == builtin.host) {
forges.push(builtin);
}
}
Ok(Forges { forges })
}
pub fn serving(&self, url: &Url) -> Option<&Forge> {
self.forges.iter().find(|f| f.host.serves(url))
}
pub fn locate(&self, repo: &str) -> Result<Located, String> {
let url = repo_url(repo)?;
let forge = self.serving(&url).cloned().ok_or_else(|| {
let host = match url.port() {
Some(port) => format!("{}:{port}", url.host_str().unwrap_or("")),
None => url.host_str().unwrap_or("").to_string(),
};
format!(
"{repo} is on {host}, which .tooling/tooling-versions.json does \
not declare. tw knows github.com and gitlab.com; any other \
host is declared under \"forges\", with the software it runs \
and the variable that holds a token for it, if it needs one:\n \
\"forges\": {{\"{host}\": {{\"type\": \"forgejo\", \"token_env\": \"FORGE_TOKEN\"}}}}\n \
(\"type\" is \"github\", \"gitlab\" or \"forgejo\".)"
)
})?;
let path: Vec<String> = url
.path_segments()
.map(|s| s.filter(|s| !s.is_empty()).map(str::to_string).collect())
.unwrap_or_default();
let fits = match forge.kind {
Kind::GitHub => path.len() == 2,
Kind::GitLab => path.len() >= 2,
Kind::Forgejo => path.len() >= 2,
};
if !fits {
return Err(format!(
"{repo} is not a repository's URL on {} ({}): it wants \
https://{}/<owner>/<repository>",
forge.host.display(),
forge.kind.label(),
forge.host.display(),
));
}
let api = forge.api_for(&url);
Ok(Located {
repo: url,
path,
api,
forge,
})
}
}
fn repo_url(repo: &str) -> Result<Url, String> {
let url = Url::parse(repo.trim_end_matches('/'))
.map_err(|e| format!("`{repo}` is not a URL: {e}"))?;
if url.scheme() != "https" {
return Err(format!(
"`{repo}` is not an https:// URL. tw fetches only over https, so \
that neither a token nor a download crosses the network in clear."
));
}
if !no_userinfo(&url) {
return Err(format!(
"`{repo}` carries a user name or password. A token goes in an \
environment variable that \"forges\" names as \"token_env\", \
never in a URL in a committed file."
));
}
if url.query().is_some() || url.fragment().is_some() || url.host_str().is_none() {
return Err(format!("`{repo}` is not a repository's URL"));
}
Ok(url)
}
fn api_url(api: &str) -> Result<Url, String> {
let url = Url::parse(api.trim_end_matches('/'))
.map_err(|e| format!("\"api\" `{api}` is not a URL: {e}"))?;
if url.scheme() != "https"
|| !no_userinfo(&url)
|| url.query().is_some()
|| url.fragment().is_some()
|| url.host_str().is_none()
{
return Err(format!(
"\"api\" `{api}` is not an API's base URL, such as \
https://forge.example.com/api/v4"
));
}
Ok(url)
}
#[derive(Debug, Clone)]
pub struct Located {
pub repo: Url,
path: Vec<String>,
pub api: Url,
pub forge: Forge,
}
impl Located {
pub fn credential(&self, env: &Env) -> Result<Option<Credential>, String> {
let Some((variable, token)) = self.forge.token(env)? else {
return Ok(None);
};
let (header, value) = self.forge.kind.header(&token);
Ok(Some(Credential {
variable,
header,
value,
origins: vec![self.repo.origin(), self.api.origin()],
}))
}
pub fn download_url(&self, tag: &str, asset: &str) -> Url {
let mut url = self.repo.clone();
url.path_segments_mut()
.expect("an http URL has a path")
.pop_if_empty()
.extend(["releases", "download", tag, asset]);
url
}
pub fn api_url(&self, parts: &[&str]) -> Url {
let mut url = self.api.clone();
url.path_segments_mut()
.expect("an http URL has a path")
.pop_if_empty()
.extend(parts);
url
}
pub fn github_release(&self, tag: &str) -> Url {
self.api_url(&[
"repos",
&self.path[0],
&self.path[1],
"releases",
"tags",
tag,
])
}
pub fn github_asset(&self, id: u64) -> Url {
self.api_url(&[
"repos",
&self.path[0],
&self.path[1],
"releases",
"assets",
&id.to_string(),
])
}
pub fn gitlab_release(&self, tag: &str) -> Url {
self.api_url(&["projects", &self.path.join("/"), "releases", tag])
}
pub fn host(&self) -> String {
self.forge.host.display()
}
pub fn token_names(&self) -> String {
self.forge.token_names()
}
}
#[derive(Clone)]
pub struct Credential {
pub variable: String,
pub header: &'static str,
value: String,
origins: Vec<Origin>,
}
impl std::fmt::Debug for Credential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Credential")
.field("variable", &self.variable)
.field("header", &self.header)
.finish_non_exhaustive()
}
}
impl Credential {
pub fn covers(&self, url: &Url) -> bool {
no_userinfo(url) && self.origins.contains(&url.origin())
}
pub fn value(&self) -> &str {
&self.value
}
}
#[derive(Debug, Clone, Default)]
pub enum Env {
#[default]
Process,
#[cfg(test)]
Fixed(HashMap<String, String>),
}
impl Env {
pub fn get(&self, name: &str) -> Option<String> {
match self {
Env::Process => crate::dotenv::var(name),
#[cfg(test)]
Env::Fixed(vars) => vars.get(name).cloned(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn forges(json: &str) -> Result<Forges, String> {
check_forges(&serde_json::from_str::<Raw>(json).unwrap())?;
let value: serde_json::Value = serde_json::from_str(json).unwrap();
Forges::parse(Some(&value))
}
fn env(vars: &[(&str, &str)]) -> Env {
Env::Fixed(
vars.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
)
}
fn url(s: &str) -> Url {
Url::parse(s).unwrap()
}
#[test]
fn github_and_gitlab_need_no_declaring() {
let forges = Forges::default();
let github = forges.locate("https://github.com/markdoc-rs/tw").unwrap();
assert_eq!(github.forge.kind, Kind::GitHub);
assert_eq!(github.api.as_str(), "https://api.github.com/");
assert_eq!(github.forge.token_env, ["GITHUB_TOKEN"]);
let gitlab = forges
.locate("https://gitlab.com/group/sub/project")
.unwrap();
assert_eq!(gitlab.forge.kind, Kind::GitLab);
assert_eq!(gitlab.api.as_str(), "https://gitlab.com/api/v4");
assert_eq!(gitlab.forge.token_env, ["GITLAB_TOKEN"]);
assert_eq!(
gitlab.gitlab_release("0.1.0").as_str(),
"https://gitlab.com/api/v4/projects/group%2Fsub%2Fproject/releases/0.1.0"
);
}
#[test]
fn any_other_host_has_to_be_declared() {
let err = Forges::default()
.locate("https://git.example.com/docs/tool")
.unwrap_err();
assert!(err.contains("git.example.com"), "{err}");
assert!(err.contains("\"forges\""), "{err}");
assert!(err.contains("\"type\""), "{err}");
let declared = forges(r#"{"git.example.com": {"type": "forgejo"}}"#).unwrap();
assert!(declared.locate("https://git.example.com/docs/tool").is_ok());
let err = declared
.locate("https://git.example.com:3000/docs/tool")
.unwrap_err();
assert!(err.contains("git.example.com:3000"), "{err}");
}
#[test]
fn a_declared_host_gets_its_type_its_tokens_and_its_api() {
let declared = forges(
r#"{
"//": "notes are skipped",
"git.example.com": {"type": "forgejo", "token_env": ["FORGEJO_TOKEN", "OLD_TOKEN"], "//": "a note"},
"gitlab.example.com:8443": {"type": "gitlab", "token_env": "SELF_GITLAB_TOKEN"},
"ghe.example.com": {"type": "github", "api": "https://ghe.example.com/api/v3/"}
}"#,
)
.unwrap();
let forgejo = declared
.locate("https://git.example.com/docs/tool")
.unwrap();
assert_eq!(forgejo.forge.kind, Kind::Forgejo);
assert_eq!(forgejo.forge.token_env, ["FORGEJO_TOKEN", "OLD_TOKEN"]);
let gitlab = declared
.locate("https://gitlab.example.com:8443/group/project")
.unwrap();
assert_eq!(
gitlab.api.as_str(),
"https://gitlab.example.com:8443/api/v4"
);
let ghe = declared.locate("https://ghe.example.com/org/tool").unwrap();
assert_eq!(ghe.api.as_str(), "https://ghe.example.com/api/v3");
assert!(ghe.forge.token_env.is_empty());
assert_eq!(
ghe.github_release("1.2.3").as_str(),
"https://ghe.example.com/api/v3/repos/org/tool/releases/tags/1.2.3"
);
}
#[test]
fn an_entry_for_a_built_in_host_overrides_it_field_by_field() {
let declared =
forges(r#"{"github.com": {"type": "github", "api": "https://api.github.example/v3"}}"#)
.unwrap();
let github = declared.locate("https://github.com/o/r").unwrap();
assert_eq!(github.api.as_str(), "https://api.github.example/v3");
assert_eq!(github.forge.token_env, ["GITHUB_TOKEN"]);
let declared =
forges(r#"{"github.com": {"type": "github", "token_env": "MY_GH"}}"#).unwrap();
let github = declared.locate("https://github.com/o/r").unwrap();
assert_eq!(github.forge.token_env, ["MY_GH"]);
assert_eq!(github.api.as_str(), "https://api.github.com/");
assert!(declared.locate("https://gitlab.com/g/p").is_ok());
}
#[test]
fn a_malformed_declaration_is_an_error_and_names_itself() {
for (json, says) in [
(
r#"{"git.example.com": {"type": "gitea"}}"#,
"\"type\" is `gitea`",
),
(r#"{"git.example.com": {}}"#, "no \"type\""),
(
r#"{"git.example.com": {"type": "forgejo", "token-env": "X"}}"#,
"forge git.example.com: unknown field \"token-env\"; the fields are",
),
(
r#"{"git.example.com": {"type": "forgejo", "token_env": 7}}"#,
"\"token_env\"",
),
(
r#"{"git.example.com": {"type": "forgejo", "token_env": ["A=B"]}}"#,
"forge git.example.com: token_env \"A=B\" is not an environment variable name",
),
(
r#"{"git.example.com": {"type": "forgejo", "token_env": ["OK", "FORGE-TOKEN"]}}"#,
"forge git.example.com: token_env \"FORGE-TOKEN\" is not an environment variable name",
),
(
r#"{"git.example.com:3000": {"type": "forgejo", "token_env": "1TOKEN"}}"#,
"forge git.example.com:3000: token_env \"1TOKEN\" is not an environment variable name",
),
(
r#"{"git.example.com": {"type": "forgejo", "token_env": "MY.TOKEN"}}"#,
"token_env \"MY.TOKEN\" is not an environment variable name",
),
(
r#"{"git.example.com": {"type": "forgejo", "token_env": " TOKEN"}}"#,
"token_env \" TOKEN\" is not an environment variable name",
),
(
r#"{"git.example.com": {"type": "forgejo", "token_env": ""}}"#,
"token_env \"\" is not an environment variable name",
),
(
r#"{"https://git.example.com": {"type": "forgejo"}}"#,
"is not a host",
),
(
r#"{"user@git.example.com": {"type": "forgejo"}}"#,
"is not a host",
),
(
r#"{"git.example.com/x": {"type": "forgejo"}}"#,
"is not a host",
),
(
r#"{"git.example.com": {"type": "gitlab", "api": "ftp://x"}}"#,
"not an API's base URL",
),
(
r#"{"github.com": {"type": "github", "api": "http://api.github.com"}}"#,
"not an API's base URL",
),
(
r#"{"GIT.example.com": {"type": "forgejo"}, "git.example.com": {"type": "forgejo"}}"#,
"declared twice",
),
("[1]", "not an object"),
] {
let err = forges(json).unwrap_err();
assert!(err.contains(says), "{json}: {err}");
}
}
#[test]
fn every_key_of_forges_is_checked_before_any_is_matched() {
let real = r#""git.example.com": {"type": "forgejo", "token_env": "FORGEJO_TOKEN"}"#;
for (json, says) in [
(format!(r#"{{"*": {{"type": "forgejo"}}, {real}}}"#), "`*` is not a host"),
(format!(r#"{{"b\u00fccher.example": {{"type": "forgejo"}}, {real}}}"#), "is not a host"),
(format!(r#"{{"forge.example:0": {{"type": "forgejo"}}, {real}}}"#), "is not a host"),
(format!(r#"{{"forge.example:": {{"type": "forgejo"}}, {real}}}"#), "is not a host"),
(format!(r#"{{"-forge.example": {{"type": "forgejo"}}, {real}}}"#), "is not a host"),
(format!(r#"{{"": {{}}, {real}}}"#), "`` is not a host"),
(format!(r#"{{"": null, {real}}}"#), "`` is not a host"),
(format!(r#"{{"": "x", {real}}}"#), "`` is not a host"),
(format!(r#"{{"": [], {real}}}"#), "`` is not a host"),
(format!(r#"{{"a b": {{}}, {real}}}"#), "`a b` is not a host"),
(format!(r#"{{{real}, "": {{"type": "forgejo", "token_env": "FORGEJO_TOKEN"}}}}"#), "`` is not a host"),
(format!(r#"{{{real}, "a b": {{"type": "forgejo"}}}}"#), "`a b` is not a host"),
(format!(r#"{{"forge.example": "forgejo", {real}}}"#), "forge forge.example: not an object"),
(format!(r#"{{{real}, {real}}}"#), "forge git.example.com: declared twice"),
(format!(r#"{{{real}, "GIT.EXAMPLE.COM": {{"type": "forgejo"}}}}"#), "forge GIT.EXAMPLE.COM: declared twice"),
(format!(r#"{{{real}, "git.example.com:443": {{"type": "forgejo"}}}}"#), "forge git.example.com:443: declared twice"),
(
r#"{"git.example.com": {"type": "forgejo", "TOKEN_ENV": "x[$(touch probe)]", "token_env": "FORGEJO_TOKEN"}}"#.to_string(),
"forge git.example.com: unknown field \"TOKEN_ENV\"; the fields are",
),
(
r#"{"git.example.com": {"type": "forgejo", "Token_Env": "FORGEJO_TOKEN"}}"#.to_string(),
"forge git.example.com: unknown field \"Token_Env\"",
),
(
r#"{"git.example.com": {"type": "forgejo", "token_env": "FORGEJO_TOKEN", "token_env": "BAD-NAME"}}"#.to_string(),
"forge git.example.com: \"token_env\" is given twice",
),
] {
let err = forges(&json).unwrap_err();
assert!(err.contains(says), "{json}: {err}");
}
let escaped = forges(
r#"{"git.example.com": {"type": "forgejo", "\u0074oken_env": "x[$(touch probe)]"}}"#,
);
assert!(
escaped
.unwrap_err()
.contains("token_env \"x[$(touch probe)]\" is not an environment variable name")
);
let fine = forges(
r#"{"//": "a note", "//x": {"any": "thing"}, "Forge.Example:8443": {"type": "forgejo", "//": "note"}, "k": {"type": "github"}}"#,
)
.unwrap();
assert!(
fine.serving(&url("https://forge.example:8443/o/r"))
.is_some()
);
}
#[test]
fn a_token_env_name_is_one_a_shell_can_set() {
for good in ["T", "_T", "FORGEJO_TOKEN", "t0k3n_2"] {
assert!(is_variable_name(good), "{good}");
}
for bad in ["", "1T", "A-B", "A.B", "A B", "A=B", "T\u{e9}", "A\0"] {
assert!(!is_variable_name(bad), "{bad:?}");
}
}
#[test]
fn a_repository_url_is_refused_unless_it_is_plainly_one() {
let forges = Forges::default();
for (repo, says) in [
("https://user:pw@github.com/o/r", "user name or password"),
("https://tok@github.com/o/r", "user name or password"),
("ftp://github.com/o/r", "not an https:// URL"),
("http://git.example.com/docs/tool", "not an https:// URL"),
("http://github.com/o/r", "not an https:// URL"),
("HTTP://github.com/o/r", "not an https:// URL"),
("https://github.com/o/r?x=1", "not a repository's URL"),
("https://github.com/o", "<owner>/<repository>"),
("https://github.com/o/r/extra", "<owner>/<repository>"),
("not a url", "not a URL"),
] {
let err = forges.locate(repo).unwrap_err();
assert!(err.contains(says), "{repo}: {err}");
}
let located = forges.locate("https://github.com/o/r/").unwrap();
assert_eq!(
located.download_url("0.1.0", "a").as_str(),
"https://github.com/o/r/releases/download/0.1.0/a"
);
}
#[test]
fn the_token_is_the_first_variable_that_holds_one() {
let declared =
forges(r#"{"git.example.com": {"type": "forgejo", "token_env": ["FIRST", "SECOND"]}}"#)
.unwrap();
let located = declared.locate("https://git.example.com/o/r").unwrap();
let pick = |vars: &[(&str, &str)]| {
located
.credential(&env(vars))
.map(|c| c.map(|c| (c.variable.clone(), c.value().to_string())))
};
let got = |name: &str, value: &str| Ok(Some((name.to_string(), value.to_string())));
assert_eq!(pick(&[]), Ok(None));
assert_eq!(pick(&[("FIRST", " ")]), Ok(None));
assert_eq!(
pick(&[("FIRST", ""), ("SECOND", "s2")]),
got("SECOND", "token s2")
);
assert_eq!(
pick(&[("FIRST", " "), ("SECOND", "s2")]),
got("SECOND", "token s2")
);
assert_eq!(
pick(&[("FIRST", "\t\r\n"), ("SECOND", "s2")]),
got("SECOND", "token s2")
);
assert_eq!(
pick(&[("FIRST", " f1\n"), ("SECOND", "s2")]),
got("FIRST", "token f1")
);
assert_eq!(
pick(&[("FIRST", "f1\r"), ("SECOND", "s2")]),
got("FIRST", "token f1")
);
assert_eq!(pick(&[("FIRST", "\tf1\r\n")]), got("FIRST", "token f1"));
for bad in ["f\r1", "f\x001", "f\x1b1", "f1\x7f", "f\n1"] {
let err = pick(&[("FIRST", bad), ("SECOND", "s2")]).unwrap_err();
assert!(err.contains("FIRST holds a control character"), "{err}");
assert!(!err.contains(bad), "{err}");
}
let c = located
.credential(&env(&[("FIRST", "f1")]))
.unwrap()
.unwrap();
assert!(!format!("{c:?}").contains("f1"));
}
#[test]
fn each_forge_writes_its_own_header() {
let declared = forges(
r#"{"git.example.com": {"type": "forgejo", "token_env": "T"},
"gitlab.example.com": {"type": "gitlab", "token_env": "T"},
"ghe.example.com": {"type": "github", "token_env": "T"}}"#,
)
.unwrap();
let header = |repo: &str| {
let c = declared
.locate(repo)
.unwrap()
.credential(&env(&[("T", "abc")]))
.unwrap()
.unwrap();
(c.header, c.value().to_string())
};
assert_eq!(
header("https://git.example.com/o/r"),
("Authorization", "token abc".to_string())
);
assert_eq!(
header("https://gitlab.example.com/g/p"),
("PRIVATE-TOKEN", "abc".to_string())
);
assert_eq!(
header("https://ghe.example.com/o/r"),
("Authorization", "Bearer abc".to_string())
);
}
#[test]
fn a_token_reaches_its_own_origins_and_no_other() {
let declared =
forges(r#"{"git.example.com:3000": {"type": "forgejo", "token_env": "T"}}"#).unwrap();
let c = declared
.locate("https://git.example.com:3000/o/r")
.unwrap()
.credential(&env(&[("T", "abc")]))
.unwrap()
.unwrap();
assert!(c.covers(&url(
"https://git.example.com:3000/o/r/releases/download/1/a"
)));
assert!(c.covers(&url("https://GIT.Example.COM:3000/attachments/x")));
assert!(!c.covers(&url("https://git.example.com/o/r")));
assert!(!c.covers(&url("https://git.example.com:3001/o/r")));
assert!(!c.covers(&url("http://git.example.com:3000/o/r")));
for other in [
"https://git.example.com.attacker.example:3000/o/r",
"https://xgit.example.com:3000/o/r",
"https://example.com:3000/git.example.com/o/r",
"https://attacker.example:3000/?git.example.com:3000",
"https://example.com:3000/o/r",
] {
assert!(!c.covers(&url(other)), "{other}");
}
assert!(!c.covers(&url("https://user@git.example.com:3000/o/r")));
assert!(!c.covers(&url("https://git.example.com:3000@attacker.example/o/r")));
assert!(!c.covers(&url(
"https://git.example.com:3000:pw@attacker.example:3000/o/r"
)));
assert!(!c.covers(&url("https://gіt.example.com:3000/o/r")));
assert!(c.covers(&url("https://git\u{ff0e}example.com:3000/o/r")));
}
#[test]
fn github_s_token_reaches_the_api_and_not_the_asset_storage() {
let c = Forges::default()
.locate("https://github.com/o/r")
.unwrap()
.credential(&env(&[("GITHUB_TOKEN", "ghp")]))
.unwrap()
.unwrap();
assert_eq!(c.value(), "Bearer ghp");
assert!(c.covers(&url("https://api.github.com/repos/o/r/releases/tags/1")));
assert!(c.covers(&url("https://github.com/o/r/releases/download/1/a")));
for other in [
"https://objects.githubusercontent.com/github-production-release-asset/x",
"https://release-assets.githubusercontent.com/x",
"https://codeload.github.com/o/r",
"http://api.github.com/repos/o/r",
"https://api.github.com.attacker.example/repos/o/r",
"https://gitlab.com/o/r",
] {
assert!(!c.covers(&url(other)), "{other}");
}
let gitlab = Forges::default()
.locate("https://gitlab.com/g/p")
.unwrap()
.credential(&env(&[("GITLAB_TOKEN", "glpat")]))
.unwrap()
.unwrap();
assert!(!gitlab.covers(&url("https://github.com/o/r")));
assert!(gitlab.covers(&url("https://gitlab.com/api/v4/projects/g%2Fp/releases/1")));
}
#[test]
fn a_host_key_matches_by_name_after_parsing_and_by_port() {
let host = Host::parse("Forge.Example.com").unwrap();
assert!(host.serves(&url("https://forge.example.com/o/r")));
assert!(host.serves(&url("http://FORGE.example.com/o/r")));
assert!(host.serves(&url("https://forge.example.com:443/o/r")));
assert!(!host.serves(&url("https://forge.example.com:8443/o/r")));
assert!(!host.serves(&url("https://u@forge.example.com/o/r")));
let host = Host::parse("forge.example.com:443").unwrap();
assert!(host.serves(&url("https://forge.example.com/o/r")));
assert!(!host.serves(&url("http://forge.example.com/o/r")));
assert!(Host::parse("bücher.example").is_err());
let host = Host::parse("xn--bcher-kva.example").unwrap();
assert!(host.serves(&url("https://xn--bcher-kva.example/o/r")));
assert!(host.serves(&url("https://bücher.example/o/r")));
assert!(Host::parse("[::1]:8080").is_err());
}
}