use crate::forge::{Credential, Env, Forges, Kind, Located, is_note};
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use url::Url;
pub const TOOLING_DIR: &str = ".tooling";
pub const VERSIONS_FILE: &str = "tooling-versions.json";
pub const VERSIONS_PATH: &str = ".tooling/tooling-versions.json";
#[derive(Debug, Clone)]
pub struct Pin {
pub repo: String,
pub version: String,
}
pub fn tooling_root(start: &Path) -> Option<PathBuf> {
let start = start.canonicalize().unwrap_or_else(|_| start.to_path_buf());
start
.ancestors()
.find(|dir| dir.join(TOOLING_DIR).is_dir())
.map(Path::to_path_buf)
}
pub fn corpus_root(start: &Path) -> Option<PathBuf> {
let start = start.canonicalize().unwrap_or_else(|_| start.to_path_buf());
tooling_root(&start).or_else(|| {
start
.ancestors()
.find(|dir| {
dir.join(crate::external::CONFIG_FILENAME).is_file()
|| dir.join(markdoc_lint::config::CONFIG_FILENAME).is_file()
})
.map(Path::to_path_buf)
})
}
pub fn corpus(start: &Path) -> PathBuf {
corpus_root(start).unwrap_or_else(|| start.to_path_buf())
}
#[derive(Debug, Clone)]
pub struct Tooling {
pub root: PathBuf,
pub pins: Vec<(String, Pin)>,
pub forges: Forges,
}
impl Tooling {
pub fn find(start: &Path) -> Result<Tooling, String> {
let root = tooling_root(start).ok_or_else(|| {
format!(
"no {TOOLING_DIR}/ directory at or above {}: tw reads the tools \
a corpus pins from {VERSIONS_PATH} at its root",
start.display()
)
})?;
Tooling::load(&root)
}
pub fn load(root: &Path) -> Result<Tooling, String> {
let path = root.join(TOOLING_DIR).join(VERSIONS_FILE);
let text =
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let value: serde_json::Value =
serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
let object = value
.as_object()
.ok_or_else(|| format!("{}: not a JSON object", path.display()))?;
crate::forge::check_shape(&text).map_err(|e| format!("{}: {e}", path.display()))?;
let forges =
Forges::parse(object.get("forges")).map_err(|e| format!("{}: {e}", path.display()))?;
let pins = object
.iter()
.filter(|(name, _)| !is_note(name) && name.as_str() != "forges")
.filter_map(|(name, entry)| {
let repo = entry
.get("repo")?
.as_str()?
.trim_end_matches('/')
.to_string();
let version = entry.get("version")?.as_str()?.to_string();
Some((name.clone(), Pin { repo, version }))
})
.collect();
Ok(Tooling {
root: root.to_path_buf(),
pins,
forges,
})
}
pub fn pin(&self, name: &str) -> Result<Pin, String> {
self.pins
.iter()
.find(|(n, _)| n == name)
.map(|(_, p)| p.clone())
.ok_or_else(|| format!("{VERSIONS_PATH} pins no `{name}`"))
}
pub fn bin(&self) -> PathBuf {
bin_dir(&self.root)
}
pub fn cached(&self, name: &str) -> Option<PathBuf> {
let pin = self.pin(name).ok()?;
let dir = self.bin().join(format!("{name}-{}", pin.version));
dir.join(".complete").exists().then_some(dir)
}
pub fn release(
&self,
name: &str,
pin: &Pin,
asset: &str,
extract: bool,
) -> Result<PathBuf, String> {
fetch(&Net::from_env(), self, name, pin, asset, extract).map_err(String::from)
}
}
pub fn bin_dir(root: &Path) -> PathBuf {
root.join(TOOLING_DIR).join("bin")
}
pub fn platform_slug() -> Result<String, String> {
let arch = match std::env::consts::ARCH {
"aarch64" => "aarch64",
"x86_64" => "x86_64",
other => return Err(format!("no prebuilt tooling is published for {other}")),
};
let os = match std::env::consts::OS {
"linux" => "linux",
"windows" => "windows",
"macos" => "macos",
other => {
return Err(format!(
"no prebuilt tooling is published for {other}; point the *_BIN \
environment variables at binaries you built yourself"
));
}
};
Ok(format!("{os}-{arch}"))
}
pub fn exe(stem: &str) -> String {
if cfg!(windows) {
format!("{stem}.exe")
} else {
stem.to_string()
}
}
pub fn verify(bytes: &[u8], checksum_file: &str, asset: &str) -> Result<(), String> {
let expected = checksum_file
.split_whitespace()
.next()
.map(str::to_ascii_lowercase)
.filter(|hex| hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()))
.ok_or_else(|| {
format!(
"{asset}.sha256 does not contain a SHA-256 digest. It held: {}",
checksum_file.chars().take(80).collect::<String>()
)
})?;
let actual = sha256_hex(bytes);
if actual == expected {
return Ok(());
}
Err(format!(
"{asset} does not match the checksum the release published.\n \
expected {expected}\n \
got {actual}\n \
Delete the .tooling/bin directory and try again. If it happens \
twice, the release or the path to it is not what it claims to be, \
and this is the message to quote."
))
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
pub fn extension_asset(name: &str, slug: &str) -> String {
format!("{name}-{slug}.tar.gz")
}
pub(crate) struct Net {
agent: ureq::Agent,
env: Env,
plain_http: bool,
}
pub const TEST_PROXY_VAR: &str = "TW_TEST_PROXY";
const MAX_HOPS: usize = 5;
fn agent(proxy: Option<ureq::Proxy>) -> ureq::Agent {
let config = ureq::Agent::config_builder()
.max_redirects(0)
.http_status_as_error(false)
.user_agent(concat!("tw/", env!("CARGO_PKG_VERSION")));
let config = match proxy {
Some(proxy) => config
.proxy(Some(proxy))
.timeout_global(Some(std::time::Duration::from_secs(20))),
None => config,
};
ureq::Agent::new_with_config(config.build())
}
fn test_proxy(value: &str) -> Result<ureq::Proxy, String> {
let refused = || {
format!(
"{TEST_PROXY_VAR} is `{value}`; it takes only http://127.0.0.1:<port>, \
http://[::1]:<port> or http://localhost:<port>, a stand-in on this \
machine for tests"
)
};
let url = Url::parse(value).map_err(|_| refused())?;
let loopback = match url.host() {
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
Some(url::Host::Domain(d)) => d == "localhost",
None => false,
};
if url.scheme() != "http" || !loopback || !url.username().is_empty() || url.password().is_some()
{
return Err(refused());
}
ureq::Proxy::new(value).map_err(|e| format!("{TEST_PROXY_VAR}: {e}"))
}
impl Net {
fn from_env() -> Self {
match std::env::var(TEST_PROXY_VAR) {
Ok(proxy) if !proxy.is_empty() => match test_proxy(&proxy) {
Ok(p) => {
eprintln!(
"tw: {TEST_PROXY_VAR} is set: every download goes as plain http \
through {proxy}. For tests against stand-ins only."
);
Net {
agent: agent(Some(p)),
env: Env::Process,
plain_http: true,
}
}
Err(why) => {
eprintln!("tw: {why}");
std::process::exit(2);
}
},
_ => Net {
agent: agent(None),
env: Env::Process,
plain_http: false,
},
}
}
fn get(
&self,
url: &Url,
accept: Option<&str>,
credential: Option<&Credential>,
) -> Result<ureq::http::Response<ureq::Body>, Fetch> {
let mut url = url.clone();
let mut credential = credential;
for _ in 0..=MAX_HOPS {
if !url.username().is_empty() || url.password().is_some() {
return Err(Fetch::Failed(format!(
"{} carries a user name or password, and tw does not \
follow such a URL",
url.host_str().unwrap_or("a URL")
)));
}
if url.scheme() != "https" {
return Err(Fetch::Failed(format!(
"{url} is not an https:// URL, and tw fetches over https only"
)));
}
let wire = if self.plain_http {
let mut plain = url.clone();
let port = plain.port();
let _ = plain.set_scheme("http");
let _ = plain.set_port(port);
plain
} else {
url.clone()
};
let mut request = self.agent.get(wire.as_str());
if let Some(accept) = accept {
request = request.header("Accept", accept);
}
if let Some(c) = credential.filter(|c| c.covers(&url)) {
request = request.header(c.header, c.value());
}
let response = match request.call() {
Ok(response) => response,
Err(e) if unreachable(&e) => {
return Err(Fetch::Unavailable(format!("could not reach {url}: {e}")));
}
Err(e) => return Err(Fetch::Failed(format!("{url}: {e}"))),
};
if response.status().is_redirection() {
let next = response
.headers()
.get("location")
.and_then(|l| l.to_str().ok())
.and_then(|l| url.join(l).ok());
if let Some(next) = next {
if next.scheme() != "https" {
return Err(Fetch::Failed(format!(
"{url} redirects to {next}, which is not https -- refusing it"
)));
}
if next.origin() != url.origin() {
credential = None;
}
url = next;
continue;
}
}
return Ok(response);
}
Err(Fetch::Failed(format!(
"{url}: more than {MAX_HOPS} redirects"
)))
}
}
#[derive(Debug)]
enum Fetch {
Unavailable(String),
Failed(String),
}
impl From<Fetch> for String {
fn from(fetch: Fetch) -> String {
match fetch {
Fetch::Unavailable(why) | Fetch::Failed(why) => why,
}
}
}
fn unreachable(error: &ureq::Error) -> bool {
matches!(
error,
ureq::Error::Io(_)
| ureq::Error::HostNotFound
| ureq::Error::ConnectionFailed
| ureq::Error::Timeout(_)
| ureq::Error::ConnectProxyFailed(_)
)
}
fn unset(names: &[String]) -> String {
match names {
[one] => format!("{one} is not set"),
_ => format!("none of {} is set", names.join(", ")),
}
}
struct Release<'a> {
net: &'a Net,
located: Located,
credential: Option<Credential>,
name: &'a str,
tag: &'a str,
}
impl Release<'_> {
fn fetch(&self, asset: &str) -> Result<(Vec<u8>, String), Fetch> {
match self.located.forge.kind {
Kind::Forgejo => self.by_url(asset),
Kind::GitHub if self.credential.is_none() => self.by_url(asset),
Kind::GitHub => self.github_api(asset),
Kind::GitLab => self.gitlab_api(asset),
}
}
fn by_url(&self, asset: &str) -> Result<(Vec<u8>, String), Fetch> {
let bytes = self.bytes(&self.located.download_url(self.tag, asset), None, asset)?;
let sum = self
.located
.download_url(self.tag, &format!("{asset}.sha256"));
Ok((bytes, self.checksum(&sum, None, asset)?))
}
fn github_api(&self, asset: &str) -> Result<(Vec<u8>, String), Fetch> {
let release_url = self.located.github_release(self.tag);
let release = self.json(&release_url, Some("application/vnd.github+json"), asset)?;
let id_of = |name: &str| {
release
.get("assets")?
.as_array()?
.iter()
.find(|a| a.get("name").and_then(|n| n.as_str()) == Some(name))?
.get("id")?
.as_u64()
};
let id = id_of(asset).ok_or_else(|| self.not_listed(asset, &release_url))?;
let octets = Some("application/octet-stream");
let bytes = self.bytes(&self.located.github_asset(id), octets, asset)?;
let sum = id_of(&format!("{asset}.sha256")).ok_or_else(|| no_checksum(asset))?;
Ok((
bytes,
self.checksum(&self.located.github_asset(sum), octets, asset)?,
))
}
fn gitlab_api(&self, asset: &str) -> Result<(Vec<u8>, String), Fetch> {
let release_url = self.located.gitlab_release(self.tag);
let release = self.json(&release_url, None, asset)?;
let link_of = |name: &str| {
let link = release
.get("assets")?
.get("links")?
.as_array()?
.iter()
.find(|l| l.get("name").and_then(|n| n.as_str()) == Some(name))?;
let href = ["url", "direct_asset_url"]
.iter()
.find_map(|key| link.get(*key).and_then(|u| u.as_str()))?;
release_url.join(href).ok()
};
let url = link_of(asset).ok_or_else(|| self.not_listed(asset, &release_url))?;
let bytes = self.bytes(&url, None, asset)?;
let sum = link_of(&format!("{asset}.sha256")).ok_or_else(|| no_checksum(asset))?;
Ok((bytes, self.checksum(&sum, None, asset)?))
}
fn json(
&self,
url: &Url,
accept: Option<&str>,
asset: &str,
) -> Result<serde_json::Value, Fetch> {
let mut response = self.net.get(url, accept, self.credential.as_ref())?;
match response.status().as_u16() {
200..=299 => {}
404 => return Err(self.not_found(asset, url)),
status => return Err(self.failed(asset, url, status)),
}
let text = response
.body_mut()
.with_config()
.limit(16 * 1024 * 1024)
.read_to_string()
.map_err(|e| Fetch::Failed(format!("{url}: {e}")))?;
serde_json::from_str(&text)
.map_err(|e| Fetch::Failed(format!("{url} did not answer with a release: {e}")))
}
fn bytes(&self, url: &Url, accept: Option<&str>, asset: &str) -> Result<Vec<u8>, Fetch> {
let mut response = self.net.get(url, accept, self.credential.as_ref())?;
match response.status().as_u16() {
200..=299 => response
.body_mut()
.with_config()
.limit(512 * 1024 * 1024)
.read_to_vec()
.map_err(|e| Fetch::Failed(format!("{url}: {e}"))),
404 => Err(self.not_found(asset, url)),
status => Err(self.failed(asset, url, status)),
}
}
fn checksum(&self, url: &Url, accept: Option<&str>, asset: &str) -> Result<String, Fetch> {
let mut response = self.net.get(url, accept, self.credential.as_ref())?;
match response.status().as_u16() {
200..=299 => response
.body_mut()
.read_to_string()
.map_err(|e| Fetch::Failed(format!("{url}: {e}"))),
404 => Err(no_checksum(asset)),
status => Err(Fetch::Failed(format!(
"could not download {asset}.sha256 ({status}) from {url}"
))),
}
}
fn not_found(&self, asset: &str, url: &Url) -> Fetch {
let (name, tag, host) = (self.name, self.tag, self.located.host());
let names = &self.located.forge.token_env;
let missing = format!(
"Release {tag} of {name} is not published there yet, or the pin \
names a tag that does not exist"
);
let why = match &self.credential {
Some(c) => format!(
"{missing} -- or the token in {} cannot read that repository.",
c.variable
),
None if names.is_empty() => format!(
"{missing} -- or the repository is private: {host} answers 404 \
to a request for a private repository that carries no token, \
and {VERSIONS_PATH} names no variable to read one for {host} \
from. If it is private, name one as \"token_env\" under \
\"forges\" -> \"{host}\", and set it."
),
None => format!(
"{missing} -- or the repository is private: {host} answers 404 \
to a request for a private repository that carries no token, \
and {}. Set {} if this one is private.",
unset(names),
self.located.token_names(),
),
};
Fetch::Unavailable(format!(
"could not download {asset} (404) from {url}\n {why}\n \
Check {VERSIONS_PATH} pins a released version of {name}."
))
}
fn not_listed(&self, asset: &str, url: &Url) -> Fetch {
Fetch::Unavailable(format!(
"could not download {asset}: release {} of {} at {url} lists no \
asset of that name. Check {VERSIONS_PATH} pins a released version \
of {}, and that the release publishes one for this platform.",
self.tag, self.name, self.name
))
}
fn failed(&self, asset: &str, url: &Url, status: u16) -> Fetch {
let mut message = format!("could not download {asset} ({status}) from {url}");
if matches!(status, 401 | 403) {
let host = self.located.host();
message.push_str(&match &self.credential {
Some(c) => format!(
"\n {} is set; check that its token can read {}.",
c.variable, self.located.repo
),
None if self.located.forge.token_env.is_empty() => format!(
"\n If the repository is private, name the variable that \
holds a token for {host} as \"token_env\" under \"forges\" \
in {VERSIONS_PATH}."
),
None => format!(
"\n Set {} if the repository is private.",
self.located.token_names()
),
});
}
Fetch::Failed(message)
}
}
fn no_checksum(asset: &str) -> Fetch {
Fetch::Failed(format!(
"the release publishes {asset} and no {asset}.sha256, so this \
download cannot be verified.\n \
Every release in this family ships one beside its asset; a \
release that does not is one to fix rather than to trust."
))
}
fn fetch(
net: &Net,
tooling: &Tooling,
name: &str,
pin: &Pin,
asset: &str,
extract: bool,
) -> Result<PathBuf, Fetch> {
let dir = tooling.bin().join(format!("{name}-{}", pin.version));
let marker = dir.join(".complete");
if marker.exists() {
return Ok(dir);
}
let located = tooling.forges.locate(&pin.repo).map_err(Fetch::Failed)?;
let release = Release {
net,
credential: located.credential(&net.env).map_err(Fetch::Failed)?,
located,
name,
tag: &pin.version,
};
eprintln!("fetching {name} {} ({asset})", pin.version);
let (bytes, published) = release.fetch(asset)?;
verify(&bytes, &published, asset).map_err(Fetch::Failed)?;
std::fs::create_dir_all(&dir).map_err(|e| Fetch::Failed(format!("{}: {e}", dir.display())))?;
let file = dir.join(asset);
std::fs::write(&file, &bytes).map_err(|e| Fetch::Failed(format!("{}: {e}", file.display())))?;
if extract {
let status = std::process::Command::new("tar")
.arg("-xf")
.arg(&file)
.arg("-C")
.arg(&dir)
.status()
.map_err(|e| Fetch::Failed(format!("tar: {e}")))?;
if !status.success() {
return Err(Fetch::Failed(format!("tar failed to extract {asset}")));
}
let _ = std::fs::remove_file(&file);
} else {
make_executable(&file).map_err(Fetch::Failed)?;
}
std::fs::write(&marker, "").map_err(|e| Fetch::Failed(format!("{}: {e}", marker.display())))?;
Ok(dir)
}
#[cfg(unix)]
fn make_executable(path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) -> Result<(), String> {
Ok(())
}
#[derive(Debug, Clone)]
pub struct Source {
pub pinned_by: Cow<'static, str>,
pub asset: Cow<'static, str>,
pub archived: bool,
}
impl Source {
pub fn asset_name(&self, slug: &str) -> String {
self.asset
.replace("{platform}", slug)
.replace("{exe}", if cfg!(windows) { ".exe" } else { "" })
.replace("{pin}", &self.pinned_by)
}
}
#[derive(Debug, Clone)]
pub struct Launched {
pub stem: Cow<'static, str>,
pub source: Source,
pub legacy: Option<Source>,
pub env_override: Cow<'static, str>,
pub built_in: Option<Cow<'static, str>>,
}
pub const MARKDOC_LS: Launched = Launched {
stem: Cow::Borrowed("markdoc-ls"),
source: Source {
pinned_by: Cow::Borrowed("markdoc-ls"),
asset: Cow::Borrowed("markdoc-ls-{platform}{exe}"),
archived: false,
},
legacy: Some(Source {
pinned_by: Cow::Borrowed("markdoc-tools"),
asset: Cow::Borrowed("markdoc-tools-{platform}.tar.gz"),
archived: true,
}),
env_override: Cow::Borrowed("MARKDOC_LS"),
built_in: Some(Cow::Borrowed("markdoc-ls")),
};
pub const MARKDOC_PDF: Launched = Launched {
stem: Cow::Borrowed("markdoc-pdf"),
source: Source {
pinned_by: Cow::Borrowed("markdoc-pdf"),
asset: Cow::Borrowed("markdoc-pdf-{platform}{exe}"),
archived: false,
},
legacy: None,
env_override: Cow::Borrowed("MARKDOC_PDF"),
built_in: Some(Cow::Borrowed("markdoc-pdf")),
};
pub fn builtin(stem: &str) -> Option<Launched> {
[MARKDOC_LS, MARKDOC_PDF]
.into_iter()
.find(|l| l.stem == stem)
}
pub fn default_env(stem: &str) -> String {
let upper: String = stem
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect();
format!("{upper}_BIN")
}
impl Launched {
pub fn configured(stem: &str, config: &crate::external::ToolConfig) -> Launched {
let asset = config
.asset
.clone()
.unwrap_or_else(|| "{pin}-{platform}.tar.gz".to_string());
let archived = [".tar.gz", ".tgz", ".zip"]
.iter()
.any(|suffix| asset.ends_with(suffix));
Launched {
stem: Cow::Owned(stem.to_string()),
source: Source {
pinned_by: Cow::Owned(config.pin.clone()),
asset: Cow::Owned(asset),
archived,
},
legacy: None,
env_override: Cow::Owned(config.env.clone().unwrap_or_else(|| default_env(stem))),
built_in: config.checkout.clone().map(Cow::Owned),
}
}
fn local(&self, root: &Path) -> Option<PathBuf> {
let exe = exe(&self.stem);
let candidates = [
std::env::var(self.env_override.as_ref())
.ok()
.map(PathBuf::from),
Some(root.join(TOOLING_DIR).join("tools").join(&exe)),
self.built_in.as_ref().map(|checkout| {
root.join("..")
.join(checkout.as_ref())
.join("target")
.join("release")
.join(&exe)
}),
];
candidates
.into_iter()
.flatten()
.find(|p| p.is_file())
.map(|p| p.canonicalize().unwrap_or(p))
}
pub fn resolve(&self, root: &Path) -> Result<PathBuf, String> {
if let Some(path) = self.local(&corpus(root)) {
return Ok(path);
}
let tooling = Tooling::find(root)?;
let path = self.download(&Net::from_env(), &tooling)?;
if path.is_file() {
return Ok(path);
}
which(&self.stem).ok_or_else(|| {
format!(
"{} is not here. It is not at {}, not in {}, and not on PATH.",
self.stem,
path.display(),
self.env_override
)
})
}
fn download(&self, net: &Net, tooling: &Tooling) -> Result<PathBuf, String> {
let pins = &tooling.pins;
let candidates = self.pinned(pins)?;
let names: Vec<String> = candidates
.iter()
.map(|(s, _)| s.pinned_by.to_string())
.collect();
let slug = platform_slug()?;
let mut unavailable = Vec::new();
for (i, (source, pin)) in candidates.into_iter().enumerate() {
let asset = source.asset_name(&slug);
match fetch(
net,
tooling,
&source.pinned_by,
&pin,
&asset,
source.archived,
) {
Ok(dir) if source.archived => return Ok(dir.join("bin").join(exe(&self.stem))),
Ok(dir) => return Ok(dir.join(&asset)),
Err(Fetch::Unavailable(why)) => {
if let Some(next) = names.get(i + 1) {
eprintln!("{why}\n falling back to the `{next}` pin");
}
unavailable.push(why);
}
Err(failed) => return Err(failed.into()),
}
}
if let Some((legacy, want, have)) = self.passed_over(pins) {
return Err(format!(
"{stem} {want}, the version {VERSIONS_PATH} pins, \
cannot be had: not from its own release,\n{why}\n \
and not from the legacy {legacy} archive, which is {have} -- \
tw falls back to it only for a {stem} pinned at {have}.\n \
Or point {env} at a {stem} you built or installed yourself.",
stem = self.stem,
want = want.version,
why = unavailable.join("\n"),
legacy = legacy.pinned_by,
have = have.version,
env = self.env_override,
));
}
Err(format!(
"{} cannot be had from any release this corpus pins:\n{}\n \
Or point {} at a {} you built or installed yourself.",
self.stem,
unavailable.join("\n"),
self.env_override,
self.stem
))
}
fn pinned(&self, pins: &[(String, Pin)]) -> Result<Vec<(Source, Pin)>, String> {
let find = |source: Source| {
pins.iter()
.find(|(n, _)| *n == source.pinned_by)
.map(|(_, pin)| (source.clone(), pin.clone()))
};
let legacy = self
.legacy
.clone()
.filter(|_| self.passed_over(pins).is_none());
let candidates: Vec<_> = std::iter::once(self.source.clone())
.chain(legacy)
.filter_map(find)
.collect();
if !candidates.is_empty() {
return Ok(candidates);
}
match &self.legacy {
Some(legacy) => Err(format!(
"{VERSIONS_PATH} pins no `{}`, nor the older `{}`",
self.source.pinned_by, legacy.pinned_by
)),
None => Err(format!(
"{VERSIONS_PATH} pins no `{}`",
self.source.pinned_by
)),
}
}
fn passed_over<'a>(&self, pins: &'a [(String, Pin)]) -> Option<(Source, &'a Pin, &'a Pin)> {
let legacy = self.legacy.clone()?;
let pinned = |name: &str| pins.iter().find(|(n, _)| n == name).map(|(_, pin)| pin);
let want = pinned(&self.source.pinned_by)?;
let have = pinned(&legacy.pinned_by)?;
(want.version != have.version).then_some((legacy, want, have))
}
}
pub fn which(name: &str) -> Option<PathBuf> {
Search::from_env().find(name)
}
pub struct Search {
pub dirs: Vec<PathBuf>,
pub pathext: Vec<String>,
}
impl Search {
pub fn from_env() -> Search {
let dirs = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).collect())
.unwrap_or_default();
let pathext = if cfg!(windows) {
std::env::var("PATHEXT")
.unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string())
.split(';')
.filter(|ext| !ext.is_empty())
.map(str::to_string)
.collect()
} else {
Vec::new()
};
Search { dirs, pathext }
}
pub fn find(&self, name: &str) -> Option<PathBuf> {
find_on_path(name, &self.dirs, &self.pathext, |p| p.is_file())
}
}
pub fn find_on_path(
name: &str,
dirs: &[PathBuf],
pathext: &[String],
exists: impl Fn(&Path) -> bool,
) -> Option<PathBuf> {
let has_ext = Path::new(name).extension().is_some_and(|ext| {
let ext = ext.to_string_lossy();
pathext
.iter()
.any(|known| known.trim_start_matches('.').eq_ignore_ascii_case(&ext))
});
if !has_ext {
for dir in dirs {
for ext in pathext {
let candidate = dir.join(format!("{name}{}", ext.to_ascii_lowercase()));
if exists(&candidate) {
return Some(candidate);
}
}
}
}
dirs.iter().map(|dir| dir.join(name)).find(|c| exists(c))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
const EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const ABC: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
#[test]
fn the_digest_is_sha256() {
assert_eq!(sha256_hex(b""), EMPTY);
assert_eq!(sha256_hex(b"abc"), ABC);
}
fn windows_lookup(name: &str, dirs: &[&str], files: &[&str], pathext: &str) -> Option<String> {
let dirs: Vec<PathBuf> = dirs.iter().map(PathBuf::from).collect();
let pathext: Vec<String> = pathext
.split(';')
.filter(|ext| !ext.is_empty())
.map(str::to_string)
.collect();
let files: Vec<PathBuf> = files.iter().map(PathBuf::from).collect();
find_on_path(name, &dirs, &pathext, |p| files.iter().any(|f| f == p))
.map(|p| p.to_string_lossy().replace('\\', "/"))
}
const PATHEXT: &str = ".COM;.EXE;.BAT;.CMD;.VBS;.JS";
#[test]
fn npm_on_windows_is_npm_cmd() {
let node = ["nodejs/npm", "nodejs/npm.cmd", "nodejs/npm.ps1"];
assert_eq!(
windows_lookup("npm", &["nodejs"], &node, PATHEXT).as_deref(),
Some("nodejs/npm.cmd")
);
assert_eq!(
windows_lookup(
"npm",
&["git/usr/bin", "nodejs"],
&["git/usr/bin/npm", "nodejs/npm.cmd"],
PATHEXT
)
.as_deref(),
Some("nodejs/npm.cmd")
);
assert_eq!(
windows_lookup("npm", &["a"], &["a/npm"], PATHEXT).as_deref(),
Some("a/npm")
);
}
#[test]
fn pathext_order_and_case_and_an_explicit_extension() {
assert_eq!(
windows_lookup("npm", &["a"], &["a/npm.exe"], PATHEXT).as_deref(),
Some("a/npm.exe")
);
assert_eq!(
windows_lookup("npm", &["a"], &["a/npm.cmd", "a/npm.exe"], PATHEXT).as_deref(),
Some("a/npm.exe")
);
assert_eq!(
windows_lookup("npm", &["a", "b"], &["a/npm.cmd", "b/npm.exe"], PATHEXT).as_deref(),
Some("a/npm.cmd")
);
assert_eq!(
windows_lookup("npm", &["a"], &["a/npm.cmd"], ".com;.Exe;.CMD").as_deref(),
Some("a/npm.cmd")
);
assert_eq!(
windows_lookup(
"npm.cmd",
&["a", "b"],
&["a/npm.cmd.exe", "b/npm.cmd"],
PATHEXT
)
.as_deref(),
Some("b/npm.cmd")
);
assert_eq!(
windows_lookup("npm.CMD", &["a"], &["a/npm.CMD.exe", "a/npm.CMD"], PATHEXT).as_deref(),
Some("a/npm.CMD")
);
assert_eq!(
windows_lookup("npm", &["a"], &["a/node.exe"], PATHEXT),
None
);
}
#[test]
fn off_windows_the_name_is_looked_up_as_it_is() {
assert_eq!(
windows_lookup("npm", &["a", "b"], &["a/npm.cmd", "b/npm"], "").as_deref(),
Some("b/npm")
);
let none: Vec<String> = Vec::new();
assert_eq!(
find_on_path("npm", &[PathBuf::from("b")], &none, |p| p
== Path::new("b/npm")),
Some(PathBuf::from("b/npm"))
);
}
#[test]
fn the_shape_sha256sum_writes_is_the_shape_this_reads() {
let file = format!("{ABC} markdoc-ls-linux-x86_64\n");
assert!(verify(b"abc", &file, "markdoc-ls-linux-x86_64").is_ok());
assert!(verify(b"abc", &ABC.to_uppercase(), "x").is_ok());
assert!(verify(b"abc", ABC, "x").is_ok());
}
#[test]
fn a_byte_out_of_place_is_refused_and_says_both_digests() {
let file = format!("{ABC} x");
let err = verify(b"abd", &file, "x").unwrap_err();
assert!(err.contains(ABC), "{err}");
assert!(err.contains(&sha256_hex(b"abd")), "{err}");
}
#[test]
fn something_that_is_not_a_checksum_is_not_treated_as_one() {
let err = verify(b"abc", "<!DOCTYPE html><html>Sign in", "x").unwrap_err();
assert!(err.contains("does not contain a SHA-256 digest"), "{err}");
assert!(verify(b"abc", "deadbeef x", "x").is_err());
assert!(verify(b"abc", &"z".repeat(64), "x").is_err());
assert!(verify(b"abc", " \n", "x").is_err());
}
fn pins_named(names: &[&str]) -> Vec<(String, Pin)> {
let at_0_1_0: Vec<_> = names.iter().map(|&name| (name, "0.1.0")).collect();
pins_at(&at_0_1_0)
}
fn pins_at(pins: &[(&str, &str)]) -> Vec<(String, Pin)> {
pins.iter()
.map(|&(name, version)| {
let pin = Pin {
repo: format!("https://forge.invalid/{name}"),
version: version.to_string(),
};
(name.to_string(), pin)
})
.collect()
}
fn tried(pins: &[(&str, &str)]) -> Vec<(String, String)> {
MARKDOC_LS
.pinned(&pins_at(pins))
.unwrap()
.into_iter()
.map(|(source, pin)| (source.pinned_by.to_string(), pin.version))
.collect()
}
fn pair(name: &str, version: &str) -> (String, String) {
(name.to_string(), version.to_string())
}
#[test]
fn the_archive_stands_in_only_for_a_markdoc_ls_pinned_at_its_version() {
assert_eq!(
tried(&[("markdoc-ls", "0.2.0"), ("markdoc-tools", "0.1.0")]),
[pair("markdoc-ls", "0.2.0")]
);
assert_eq!(
tried(&[("markdoc-ls", "0.2.0"), ("markdoc-tools", "0.2.0")]),
[pair("markdoc-ls", "0.2.0"), pair("markdoc-tools", "0.2.0")]
);
assert_eq!(
tried(&[("markdoc-tools", "0.0.9")]),
[pair("markdoc-tools", "0.0.9")]
);
}
#[test]
fn markdoc_ls_comes_from_its_own_release_as_a_bare_binary() {
let candidates = MARKDOC_LS.pinned(&pins_named(&["markdoc-ls"])).unwrap();
assert_eq!(candidates.len(), 1);
let (source, pin) = &candidates[0];
assert_eq!(source.pinned_by, "markdoc-ls");
assert_eq!(pin.repo, "https://forge.invalid/markdoc-ls");
let suffix = if cfg!(windows) { ".exe" } else { "" };
assert_eq!(
source.asset_name("windows-x86_64"),
format!("markdoc-ls-windows-x86_64{suffix}")
);
assert!(!source.archived);
}
#[test]
fn a_corpus_that_still_pins_markdoc_tools_keeps_working() {
let candidates = MARKDOC_LS
.pinned(&pins_named(&["tw", "markdoc-pdf", "markdoc-tools"]))
.unwrap();
assert_eq!(candidates.len(), 1);
let (source, pin) = &candidates[0];
assert_eq!(source.pinned_by, "markdoc-tools");
assert_eq!(pin.repo, "https://forge.invalid/markdoc-tools");
assert_eq!(
source.asset_name("linux-x86_64"),
"markdoc-tools-linux-x86_64.tar.gz"
);
assert!(source.archived);
}
#[test]
fn a_corpus_that_pins_both_tries_the_split_release_first() {
for names in [
["markdoc-tools", "markdoc-ls"],
["markdoc-ls", "markdoc-tools"],
] {
let candidates = MARKDOC_LS.pinned(&pins_named(&names)).unwrap();
let order: Vec<_> = candidates
.iter()
.map(|(s, p)| (s.pinned_by.to_string(), p.repo.clone()))
.collect();
assert_eq!(
order,
[
pair("markdoc-ls", "https://forge.invalid/markdoc-ls"),
pair("markdoc-tools", "https://forge.invalid/markdoc-tools"),
]
);
}
}
#[test]
fn a_corpus_that_pins_neither_is_told_both_names() {
let err = MARKDOC_LS.pinned(&pins_named(&["markdoc-pdf"])).err();
let err = err.expect("no pin for markdoc-ls is an error");
assert!(err.contains("`markdoc-ls`"), "{err}");
assert!(err.contains("`markdoc-tools`"), "{err}");
assert!(err.contains(".tooling/tooling-versions.json"), "{err}");
let err = MARKDOC_PDF.pinned(&pins_named(&["markdoc-ls"])).err();
let err = err.expect("no pin for markdoc-pdf is an error");
assert!(
err.contains("`markdoc-pdf`") && !err.contains("older"),
"{err}"
);
}
fn scratch(test: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("tw-tools-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
root.canonicalize().unwrap()
}
fn corpus_with(test: &str, json: &str) -> PathBuf {
let root = scratch(test);
std::fs::create_dir_all(root.join(TOOLING_DIR)).unwrap();
std::fs::write(root.join(TOOLING_DIR).join(VERSIONS_FILE), json).unwrap();
root
}
#[test]
fn a_token_env_that_is_no_variable_name_is_refused_naming_the_forge() {
let root = corpus_with(
"token-env-name",
r#"{"forges": {"git.example.com": {"type": "forgejo", "token_env": ["FORGEJO_TOKEN", "FORGE-TOKEN"]}}}"#,
);
let err = Tooling::find(&root).unwrap_err();
let _ = std::fs::remove_dir_all(&root);
assert!(
err.ends_with(
"tooling-versions.json: forge git.example.com: token_env \"FORGE-TOKEN\" is \
not an environment variable name"
),
"{err}"
);
}
#[test]
fn the_pins_and_the_forges_are_read_from_tooling_versions_json() {
let root = corpus_with(
"pins",
r#"{
"//": ["Pinned versions of the prebuilt tooling."],
"forges": {
"//": "the private one",
"git.example.com": {"type": "forgejo", "token_env": ["FORGEJO_TOKEN", "OLD_TOKEN"]}
},
"markdoc-tools": {
"repo": "https://git.example.com/docs/markdoc-tools/",
"version": "0.1.0",
"//": "markdoc-lint, markdoc-ls, markdoc-prose"
}
}"#,
);
let deep = root.join("products").join("manual");
std::fs::create_dir_all(&deep).unwrap();
let tooling = Tooling::find(&deep);
let _ = std::fs::remove_dir_all(&root);
let tooling = tooling.unwrap();
assert_eq!(tooling.root, root);
assert_eq!(tooling.pins.len(), 1);
let candidates = MARKDOC_LS.pinned(&tooling.pins).unwrap();
let (source, pin) = &candidates[0];
assert_eq!(source.pinned_by, "markdoc-tools");
assert_eq!(pin.repo, "https://git.example.com/docs/markdoc-tools");
assert_eq!(pin.version, "0.1.0");
let located = tooling.forges.locate(&pin.repo).unwrap();
assert_eq!(located.forge.token_env, ["FORGEJO_TOKEN", "OLD_TOKEN"]);
}
#[test]
fn the_root_is_the_nearest_directory_holding_a_tooling_directory() {
let root = scratch("root");
let deep = root.join("a").join("b");
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(root.join("a").join(TOOLING_DIR), "").unwrap();
assert_eq!(tooling_root(&deep), None);
std::fs::create_dir_all(root.join(TOOLING_DIR)).unwrap();
let found = tooling_root(&deep);
std::fs::create_dir_all(deep.join(TOOLING_DIR)).unwrap();
let nearest = tooling_root(&deep);
let err = Tooling::find(&deep).unwrap_err();
let _ = std::fs::remove_dir_all(&root);
assert_eq!(found, Some(root));
assert_eq!(nearest, Some(deep));
assert!(err.contains("tooling-versions.json"), "{err}");
}
#[test]
fn a_key_given_twice_in_forges_is_refused_though_json_allows_it() {
let forge = r#""git.example.com": {"type": "forgejo", "token_env": "FORGEJO_TOKEN"}"#;
for (n, (json, says)) in [
(
format!(r#"{{"forges": {{{forge}}}, "forges": {{{forge}}}}}"#),
"tooling-versions.json: \"forges\" is given twice",
),
(
format!(r#"{{"forges": {{{forge}, {forge}}}}}"#),
"tooling-versions.json: forge git.example.com: declared twice",
),
(
r#"{"forges": {"git.example.com": {"type": "forgejo", "token_env": "BAD-NAME", "token_env": "T"}}}"#.to_string(),
"tooling-versions.json: forge git.example.com: \"token_env\" is given twice",
),
(
r#"{"forges": {"git.example.com": {"type": "forgejo", "type": "forgejo"}}}"#.to_string(),
"tooling-versions.json: forge git.example.com: \"type\" is given twice",
),
(
r#"{"forges": {"git.example.com": {"type": "forgejo", "token_env": "T", "\u0074oken_env": "U"}}}"#.to_string(),
"tooling-versions.json: forge git.example.com: \"token_env\" is given twice",
),
]
.into_iter()
.enumerate()
{
let root = corpus_with(&format!("twice-{n}"), &json);
let err = Tooling::find(&root).unwrap_err();
let _ = std::fs::remove_dir_all(&root);
assert!(err.ends_with(says), "{json}: {err}");
}
}
#[test]
fn a_malformed_forges_entry_stops_everything_that_reads_the_pins() {
let root = corpus_with(
"bad-forges",
r#"{"forges": {"git.example.com": {"type": "forgejo", "token-env": "T"}},
"x": {"repo": "https://github.com/o/x", "version": "0.1.0"}}"#,
);
let err = Tooling::find(&root).unwrap_err();
let _ = std::fs::remove_dir_all(&root);
assert!(
err.contains("forge git.example.com: unknown field \"token-env\""),
"{err}"
);
assert!(err.contains("tooling-versions.json"), "{err}");
}
#[test]
fn a_configured_tool_takes_its_asset_and_variable_by_convention() {
let config: crate::external::ToolConfig = toml::from_str("pin = \"site-toolkit\"").unwrap();
let tool = Launched::configured("site-gen", &config);
assert_eq!(tool.env_override, "SITE_GEN_BIN");
assert_eq!(
tool.source.asset_name("linux-x86_64"),
"site-toolkit-linux-x86_64.tar.gz"
);
assert!(tool.source.archived);
assert!(tool.built_in.is_none());
let config: crate::external::ToolConfig = toml::from_str(
"pin = \"gen\"\nasset = \"gen-{platform}{exe}\"\nenv = \"GEN\"\ncheckout = \"gen-src\"",
)
.unwrap();
let tool = Launched::configured("gen", &config);
let suffix = if cfg!(windows) { ".exe" } else { "" };
assert_eq!(
tool.source.asset_name("macos-aarch64"),
format!("gen-macos-aarch64{suffix}")
);
assert!(!tool.source.archived);
assert_eq!(tool.env_override, "GEN");
assert_eq!(tool.built_in.as_deref(), Some("gen-src"));
}
mod standin {
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone)]
pub struct Seen {
pub host: String,
pub path: String,
pub headers: Vec<(String, String)>,
}
impl Seen {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
pub fn credential(&self) -> Option<String> {
["authorization", "private-token"]
.iter()
.find_map(|name| self.header(name).map(|v| format!("{name}: {v}")))
}
}
#[derive(Debug, Clone)]
pub struct Reply {
pub status: u16,
pub body: Vec<u8>,
pub location: Option<String>,
}
pub fn ok(body: impl Into<Vec<u8>>) -> Reply {
Reply {
status: 200,
body: body.into(),
location: None,
}
}
pub fn status(status: u16) -> Reply {
Reply {
status,
body: Vec::new(),
location: None,
}
}
pub fn redirect(to: &str) -> Reply {
Reply {
status: 302,
body: Vec::new(),
location: Some(to.to_string()),
}
}
#[derive(Default)]
pub struct Routes {
pub routes: HashMap<String, Reply>,
pub private: HashMap<String, (String, String)>,
pub down: Vec<String>,
}
impl Routes {
pub fn at(&mut self, key: &str, reply: Reply) -> &mut Self {
self.routes.insert(key.to_string(), reply);
self
}
}
pub struct Hosts {
pub proxy: String,
seen: Arc<Mutex<Vec<Seen>>>,
}
impl Hosts {
pub fn seen(&self) -> Vec<Seen> {
self.seen.lock().unwrap().clone()
}
}
pub fn start(routes: Routes) -> Hosts {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let proxy = format!("http://{}", listener.local_addr().unwrap());
let seen = Arc::new(Mutex::new(Vec::new()));
let log = Arc::clone(&seen);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let mut reader = BufReader::new(stream.try_clone().unwrap());
let Some((line, _)) = head(&mut reader) else {
continue;
};
let Some(target) = line
.strip_prefix("CONNECT ")
.and_then(|rest| rest.split_whitespace().next())
else {
continue;
};
let host = target.strip_suffix(":80").unwrap_or(target).to_string();
if routes.down.contains(&host) {
let _ = stream.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n");
continue;
}
let _ = stream.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n");
let Some((line, headers)) = head(&mut reader) else {
continue;
};
let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
let request = Seen {
host: host.clone(),
path: path.clone(),
headers,
};
let shut = routes
.private
.get(&host)
.is_some_and(|(name, value)| request.header(name) != Some(value.as_str()));
let reply = if shut {
status(404)
} else {
routes
.routes
.get(&format!("{host}{path}"))
.cloned()
.unwrap_or(status(404))
};
log.lock().unwrap().push(request);
let location = reply
.location
.map(|l| format!("Location: {l}\r\n"))
.unwrap_or_default();
let _ = write!(
stream,
"HTTP/1.1 {} Stand-in\r\n{location}Content-Length: {}\r\nConnection: close\r\n\r\n",
reply.status,
reply.body.len()
);
let _ = stream.write_all(&reply.body);
}
});
Hosts { proxy, seen }
}
fn head(reader: &mut impl BufRead) -> Option<(String, Vec<(String, String)>)> {
let mut line = String::new();
reader.read_line(&mut line).ok()?;
let mut headers = Vec::new();
loop {
let mut header = String::new();
reader.read_line(&mut header).ok()?;
let header = header.trim_end();
if header.is_empty() {
break;
}
let (k, v) = header.split_once(':')?;
headers.push((k.trim().to_string(), v.trim().to_string()));
}
Some((line.trim_end().to_string(), headers))
}
}
use standin::{Routes, Seen, ok, redirect, status};
const FORGES: &str = r#""forges": {
"github.com": {"type": "github", "api": "https://api.github.com"},
"gitlab.com": {"type": "gitlab", "api": "https://gitlab.com/api/v4"},
"gitlab.example.com:8443": {"type": "gitlab", "token_env": "SELF_GITLAB_TOKEN"},
"git.example.com": {"type": "forgejo", "token_env": ["FORGEJO_TOKEN", "OLD_FORGEJO_TOKEN"]},
"open.example.com": {"type": "forgejo"}
}"#;
const LS_REPO: &str = "https://github.com/markdoc-rs/markdoc-ls";
const TOOLS_REPO: &str = "https://git.example.com/docs/markdoc-tools";
const TOKENS: &[(&str, &str)] = &[
("GITHUB_TOKEN", "ghp-s3cret"),
("GITLAB_TOKEN", "glpat-s3cret"),
("SELF_GITLAB_TOKEN", "self-s3cret"),
("FORGEJO_TOKEN", "forgejo-s3cret"),
];
fn net_with(proxy: &str, set: &[&str]) -> Net {
let vars = TOKENS
.iter()
.filter(|(name, _)| set.contains(name))
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
Net {
agent: agent(Some(ureq::Proxy::new(proxy).unwrap())),
env: Env::Fixed(vars),
plain_http: true,
}
}
fn net(proxy: &str) -> Net {
let all: Vec<&str> = TOKENS.iter().map(|(k, _)| *k).collect();
net_with(proxy, &all)
}
fn net_without_tokens(proxy: &str) -> Net {
net_with(proxy, &[])
}
fn corpus(test: &str, pins: &[(&str, &str)]) -> Tooling {
let pins: Vec<_> = pins
.iter()
.map(|&(name, repo)| (name, repo, "0.1.0"))
.collect();
corpus_at(test, &pins)
}
fn corpus_at(test: &str, pins: &[(&str, &str, &str)]) -> Tooling {
let entries: Vec<String> = pins
.iter()
.map(|(name, repo, version)| {
format!(r#""{name}": {{"repo": "{repo}", "version": "{version}"}}"#)
})
.chain(std::iter::once(FORGES.to_string()))
.collect();
let root = corpus_with(test, &format!("{{{}}}", entries.join(",")));
Tooling::load(&root).unwrap()
}
fn ls_asset() -> String {
MARKDOC_LS.source.asset_name(&platform_slug().unwrap())
}
fn tools_asset() -> String {
MARKDOC_LS
.legacy
.clone()
.unwrap()
.asset_name(&platform_slug().unwrap())
}
fn at(repo: &str, asset: &str) -> String {
at_version(repo, "0.1.0", asset)
}
fn at_version(repo: &str, version: &str, asset: &str) -> String {
format!(
"{}/releases/download/{version}/{asset}",
repo.trim_start_matches("https://")
)
}
fn published(routes: &mut Routes, repo: &str, asset: &str, bytes: Vec<u8>) {
published_at(routes, repo, "0.1.0", asset, bytes);
}
fn published_at(routes: &mut Routes, repo: &str, version: &str, asset: &str, bytes: Vec<u8>) {
let sum = format!("{} {asset}\n", sha256_hex(&bytes));
routes.at(
&at_version(repo, version, &format!("{asset}.sha256")),
ok(sum),
);
routes.at(&at_version(repo, version, asset), ok(bytes));
}
fn sum_of(bytes: &[u8], asset: &str) -> String {
format!("{} {asset}\n", sha256_hex(bytes))
}
fn tools_archive(scratch: &Path) -> Vec<u8> {
let bin = scratch.join("archive").join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(bin.join(exe("markdoc-ls")), "legacy").unwrap();
let archive = scratch.join("markdoc-tools.tar.gz");
let status = std::process::Command::new("tar")
.arg("-czf")
.arg(&archive)
.arg("-C")
.arg(scratch.join("archive"))
.arg("bin")
.status()
.unwrap();
assert!(status.success());
let bytes = std::fs::read(&archive).unwrap();
std::fs::remove_dir_all(scratch.join("archive")).unwrap();
std::fs::remove_file(archive).unwrap();
bytes
}
fn pin(repo: &str) -> Pin {
Pin {
repo: repo.to_string(),
version: "0.1.0".to_string(),
}
}
fn get(
net: &Net,
tooling: &Tooling,
name: &str,
repo: &str,
asset: &str,
) -> Result<Vec<u8>, Fetch> {
let dir = fetch(net, tooling, name, &pin(repo), asset, false)?;
Ok(std::fs::read(dir.join(asset)).unwrap())
}
fn done(tooling: &Tooling) {
let _ = std::fs::remove_dir_all(&tooling.root);
}
fn sent(seen: &[Seen]) -> Vec<(String, Option<String>)> {
seen.iter()
.map(|r| (r.host.clone(), r.credential()))
.collect()
}
fn s(host: &str, credential: Option<&str>) -> (String, Option<String>) {
(host.to_string(), credential.map(str::to_string))
}
#[test]
fn github_without_a_token_downloads_the_public_url_and_follows_it_to_storage() {
let tooling = corpus("gh-public", &[]);
let mut routes = Routes::default();
let storage = "objects.githubusercontent.com/github-production-release-asset/1";
routes
.at(
&at(LS_REPO, "a"),
redirect(&format!("https://{storage}?sig=1")),
)
.at(&format!("{storage}?sig=1"), ok("bytes"))
.at(
&at(LS_REPO, "a.sha256"),
redirect(&format!("https://{storage}?sig=2")),
)
.at(&format!("{storage}?sig=2"), ok(sum_of(b"bytes", "a")));
let hosts = standin::start(routes);
let net = net_with(
&hosts.proxy,
&["GITLAB_TOKEN", "FORGEJO_TOKEN", "SELF_GITLAB_TOKEN"],
);
let bytes = get(&net, &tooling, "a", LS_REPO, "a");
done(&tooling);
assert_eq!(bytes.unwrap(), b"bytes");
assert_eq!(
sent(&hosts.seen()),
[
s("github.com", None),
s("objects.githubusercontent.com", None),
s("github.com", None),
s("objects.githubusercontent.com", None),
]
);
}
fn github_release(routes: &mut Routes, repo: &str, asset: &str, bytes: &[u8], ids: (u64, u64)) {
let path = repo.trim_start_matches("https://github.com/");
let json = serde_json::json!({
"tag_name": "0.1.0",
"assets": [
{"id": ids.0, "name": asset, "browser_download_url": "https://github.com/x"},
{"id": ids.1, "name": format!("{asset}.sha256")},
]
});
let api = format!("api.github.com/repos/{path}/releases");
let storage = "objects.githubusercontent.com/github-production-release-asset";
routes
.at(&format!("{api}/tags/0.1.0"), ok(json.to_string()))
.at(
&format!("{api}/assets/{}", ids.0),
redirect(&format!("https://{storage}/{}?X-Amz-Signature=x", ids.0)),
)
.at(
&format!("{storage}/{}?X-Amz-Signature=x", ids.0),
ok(bytes.to_vec()),
)
.at(
&format!("{api}/assets/{}", ids.1),
redirect(&format!("https://{storage}/{}?X-Amz-Signature=x", ids.1)),
)
.at(
&format!("{storage}/{}?X-Amz-Signature=x", ids.1),
ok(sum_of(bytes, asset)),
);
}
#[test]
fn github_with_a_token_goes_through_the_api_and_the_token_stops_at_storage() {
let tooling = corpus("gh-private", &[]);
let mut routes = Routes::default();
github_release(&mut routes, LS_REPO, "a", b"private bytes", (11, 12));
routes.private.insert(
"api.github.com".to_string(),
("authorization".to_string(), "Bearer ghp-s3cret".to_string()),
);
routes.private.insert(
"github.com".to_string(),
("authorization".to_string(), "Bearer ghp-s3cret".to_string()),
);
let hosts = standin::start(routes);
let bytes = get(&net(&hosts.proxy), &tooling, "a", LS_REPO, "a");
done(&tooling);
assert_eq!(bytes.unwrap(), b"private bytes");
let seen = hosts.seen();
let bearer = Some("authorization: Bearer ghp-s3cret");
assert_eq!(
sent(&seen),
[
s("api.github.com", bearer),
s("api.github.com", bearer),
s("objects.githubusercontent.com", None),
s("api.github.com", bearer),
s("objects.githubusercontent.com", None),
]
);
assert_eq!(
seen[0].path,
"/repos/markdoc-rs/markdoc-ls/releases/tags/0.1.0"
);
assert_eq!(
seen[0].header("accept"),
Some("application/vnd.github+json")
);
assert_eq!(
seen[1].path,
"/repos/markdoc-rs/markdoc-ls/releases/assets/11"
);
assert_eq!(seen[1].header("accept"), Some("application/octet-stream"));
assert!(seen.iter().all(|r| r.header("private-token").is_none()));
}
#[test]
fn github_s_api_without_the_asset_or_its_sha256() {
let mut routes = Routes::default();
let json = serde_json::json!({"assets": [{"id": 1, "name": "other"}]});
routes.at(
"api.github.com/repos/markdoc-rs/markdoc-ls/releases/tags/0.1.0",
ok(json.to_string()),
);
let hosts = standin::start(routes);
let tooling = corpus("gh-unlisted", &[]);
let unlisted = get(&net(&hosts.proxy), &tooling, "a", LS_REPO, "a").unwrap_err();
done(&tooling);
let Fetch::Unavailable(unlisted) = unlisted else {
panic!("an asset the release does not list is as good as a 404")
};
assert!(
unlisted.contains("lists no asset of that name"),
"{unlisted}"
);
let mut routes = Routes::default();
github_release(&mut routes, LS_REPO, "a", b"x", (21, 22));
routes.routes.retain(|k, _| !k.ends_with("/assets/22"));
let mut json: serde_json::Value = serde_json::from_slice(
&routes.routes["api.github.com/repos/markdoc-rs/markdoc-ls/releases/tags/0.1.0"].body,
)
.unwrap();
json["assets"].as_array_mut().unwrap().pop();
routes.at(
"api.github.com/repos/markdoc-rs/markdoc-ls/releases/tags/0.1.0",
ok(json.to_string()),
);
let hosts = standin::start(routes);
let tooling = corpus("gh-unsummed", &[]);
let err = get(&net(&hosts.proxy), &tooling, "a", LS_REPO, "a").unwrap_err();
let nothing_cached = !tooling.bin().exists();
done(&tooling);
let Fetch::Failed(err) = err else {
panic!("no .sha256 is a refusal")
};
assert!(err.contains("cannot be verified"), "{err}");
assert!(nothing_cached);
}
fn gitlab_release(
routes: &mut Routes,
api: &str,
project: &str,
link_host: &str,
asset: &str,
bytes: &[u8],
) {
let encoded = project.replace('/', "%2F");
let json = serde_json::json!({
"tag_name": "0.1.0",
"assets": {"links": [
{"name": asset, "url": format!("https://{link_host}/{project}/-/package_files/1/download"),
"direct_asset_url": format!("https://{link_host}/{project}/-/releases/0.1.0/downloads/{asset}")},
{"name": format!("{asset}.sha256"), "url": format!("https://{link_host}/{project}/-/package_files/2/download")},
]}
});
routes
.at(
&format!("{api}/projects/{encoded}/releases/0.1.0"),
ok(json.to_string()),
)
.at(
&format!("{link_host}/{project}/-/package_files/1/download"),
ok(bytes.to_vec()),
)
.at(
&format!("{link_host}/{project}/-/package_files/2/download"),
ok(sum_of(bytes, asset)),
);
}
#[test]
fn gitlab_without_a_token_reads_the_release_and_downloads_its_links() {
let tooling = corpus("gl-public", &[]);
let mut routes = Routes::default();
gitlab_release(
&mut routes,
"gitlab.com/api/v4",
"group/sub/tool",
"gitlab.com",
"a",
b"open",
);
let hosts = standin::start(routes);
let net = net_with(&hosts.proxy, &["GITHUB_TOKEN", "SELF_GITLAB_TOKEN"]);
let bytes = get(
&net,
&tooling,
"a",
"https://gitlab.com/group/sub/tool",
"a",
);
done(&tooling);
assert_eq!(bytes.unwrap(), b"open");
let seen = hosts.seen();
assert_eq!(
seen[0].path,
"/api/v4/projects/group%2Fsub%2Ftool/releases/0.1.0"
);
assert_eq!(
sent(&seen),
[
s("gitlab.com", None),
s("gitlab.com", None),
s("gitlab.com", None)
]
);
}
#[test]
fn a_self_hosted_gitlab_gets_private_token_and_storage_does_not() {
let tooling = corpus("gl-private", &[]);
let mut routes = Routes::default();
let host = "gitlab.example.com:8443";
gitlab_release(
&mut routes,
&format!("{host}/api/v4"),
"docs/site-gen",
host,
"a",
b"self-hosted",
);
routes.at(
&format!("{host}/docs/site-gen/-/package_files/1/download"),
redirect("https://storage.example.net/bucket/1?sig=x"),
);
routes.at("storage.example.net/bucket/1?sig=x", ok("self-hosted"));
routes.private.insert(
host.to_string(),
("private-token".to_string(), "self-s3cret".to_string()),
);
let hosts = standin::start(routes);
let bytes = get(
&net(&hosts.proxy),
&tooling,
"a",
&format!("https://{host}/docs/site-gen"),
"a",
);
done(&tooling);
assert_eq!(bytes.unwrap(), b"self-hosted");
let token = Some("private-token: self-s3cret");
assert_eq!(
sent(&hosts.seen()),
[
s(host, token),
s(host, token),
s("storage.example.net", None),
s(host, token),
]
);
}
#[test]
fn forgejo_sends_its_token_with_the_same_url_and_keeps_it_on_its_own_origin() {
let tooling = corpus("fj-private", &[]);
let mut routes = Routes::default();
routes
.at(&at(TOOLS_REPO, "a"), redirect("/attachments/0001"))
.at("git.example.com/attachments/0001", ok("forgejo bytes"))
.at(
&at(TOOLS_REPO, "a.sha256"),
ok(sum_of(b"forgejo bytes", "a")),
);
routes.private.insert(
"git.example.com".to_string(),
(
"authorization".to_string(),
"token forgejo-s3cret".to_string(),
),
);
let hosts = standin::start(routes);
let bytes = get(&net(&hosts.proxy), &tooling, "a", TOOLS_REPO, "a");
done(&tooling);
assert_eq!(bytes.unwrap(), b"forgejo bytes");
let token = Some("authorization: token forgejo-s3cret");
assert_eq!(
sent(&hosts.seen()),
[
s("git.example.com", token),
s("git.example.com", token),
s("git.example.com", token)
]
);
}
#[test]
fn the_second_variable_is_read_when_the_first_is_not_set() {
let tooling = corpus("fj-second", &[]);
let mut routes = Routes::default();
published(&mut routes, TOOLS_REPO, "a", b"x".to_vec());
let hosts = standin::start(routes);
let mut net = net_without_tokens(&hosts.proxy);
net.env = Env::Fixed(HashMap::from([
("FORGEJO_TOKEN".to_string(), String::new()),
("OLD_FORGEJO_TOKEN".to_string(), "old-s3cret".to_string()),
]));
get(&net, &tooling, "a", TOOLS_REPO, "a").unwrap();
done(&tooling);
assert!(
hosts
.seen()
.iter()
.all(|r| r.header("authorization") == Some("token old-s3cret"))
);
}
#[test]
fn forgejo_without_a_token_downloads_the_public_url() {
let tooling = corpus("fj-public", &[]);
let mut routes = Routes::default();
published(
&mut routes,
"https://open.example.com/docs/tool",
"a",
b"open".to_vec(),
);
let hosts = standin::start(routes);
let bytes = get(
&net(&hosts.proxy),
&tooling,
"a",
"https://open.example.com/docs/tool",
"a",
);
done(&tooling);
assert_eq!(bytes.unwrap(), b"open");
assert_eq!(
sent(&hosts.seen()),
[s("open.example.com", None), s("open.example.com", None)]
);
}
#[test]
fn a_redirect_off_the_origin_drops_the_token_for_the_rest_of_the_chain() {
let tooling = corpus("redirects", &[]);
let mut routes = Routes::default();
routes
.at(
&at(TOOLS_REPO, "a"),
redirect("https://git.example.com:8080/a"),
)
.at(
"git.example.com:8080/a",
redirect("https://git.example.com.attacker.example/c"),
)
.at(
"git.example.com.attacker.example/c",
redirect("https://git.example.com/d"),
)
.at("git.example.com/d", ok("bytes"))
.at(
&at(TOOLS_REPO, "a.sha256"),
redirect("https://git.example.com@attacker.example/sum"),
)
.at("attacker.example/sum", ok(sum_of(b"bytes", "a")));
let hosts = standin::start(routes);
let err = get(&net(&hosts.proxy), &tooling, "a", TOOLS_REPO, "a").unwrap_err();
done(&tooling);
assert!(String::from(err).contains("user name or password"));
let token = Some("authorization: token forgejo-s3cret");
assert_eq!(
sent(&hosts.seen()),
[
s("git.example.com", token),
s("git.example.com:8080", None),
s("git.example.com.attacker.example", None),
s("git.example.com", None),
s("git.example.com", token),
]
);
}
#[test]
fn a_redirect_to_plain_http_is_refused_and_not_followed() {
let tooling = corpus("redirect-http", &[]);
let mut routes = Routes::default();
routes
.at(
&at(TOOLS_REPO, "a"),
redirect("http://git.example.com/docs/markdoc-tools/a"),
)
.at("git.example.com/docs/markdoc-tools/a", ok("bytes"));
let hosts = standin::start(routes);
let err = get(&net(&hosts.proxy), &tooling, "a", TOOLS_REPO, "a").unwrap_err();
done(&tooling);
let Fetch::Failed(err) = err else {
panic!("a refused redirect is not a reason to fall back")
};
assert!(err.contains("which is not https -- refusing it"), "{err}");
assert_eq!(
sent(&hosts.seen()),
[s(
"git.example.com",
Some("authorization: token forgejo-s3cret")
)]
);
}
#[test]
fn a_pin_or_an_api_at_http_is_refused_before_anything_is_sent() {
let hosts = standin::start(Routes::default());
for (test, pins, repo) in [
(
"http-pin",
r#"{"forges": {"git.example.com": {"type": "forgejo", "token_env": "FORGEJO_TOKEN"}}}"#,
"http://git.example.com/docs/tool",
),
(
"http-api",
r#"{"forges": {"github.com": {"type": "github", "api": "http://api.github.com"}}}"#,
"https://github.com/o/r",
),
] {
let root = corpus_with(test, pins);
match Tooling::load(&root) {
Err(err) => assert!(err.contains("not an API's base URL"), "{err}"),
Ok(tooling) => {
let err = get(&net(&hosts.proxy), &tooling, "a", repo, "a").unwrap_err();
assert!(String::from(err).contains("not an https:// URL"));
}
}
let _ = std::fs::remove_dir_all(&root);
}
assert!(hosts.seen().is_empty());
}
#[test]
fn the_test_proxy_is_taken_only_on_this_machine() {
for ok in [
"http://127.0.0.1:9801",
"http://localhost:9801",
"http://[::1]:9801",
] {
assert!(test_proxy(ok).is_ok(), "{ok}");
}
for bad in [
"http://10.0.0.1:3128",
"http://proxy.example.com:3128",
"https://127.0.0.1:9801",
"http://u:p@127.0.0.1:9801",
"127.0.0.1:9801",
] {
let err = test_proxy(bad).err().unwrap_or_default();
assert!(err.contains("only http://127.0.0.1"), "{bad}: {err}");
}
}
#[test]
fn a_pin_on_an_undeclared_host_is_refused_before_anything_is_sent() {
let tooling = corpus("undeclared", &[]);
let hosts = standin::start(Routes::default());
for repo in [
"https://git.example.com.attacker.example/docs/tool",
"https://git.example.com:3000/docs/tool",
"https://github.com.attacker.example/o/r",
] {
let err = get(&net(&hosts.proxy), &tooling, "a", repo, "a").unwrap_err();
let Fetch::Failed(err) = err else {
panic!("an undeclared host is not a reason to fall back")
};
assert!(err.contains("does not declare"), "{err}");
}
let err = get(
&net(&hosts.proxy),
&tooling,
"a",
"https://x@git.example.com/docs/tool",
"a",
)
.unwrap_err();
done(&tooling);
assert!(String::from(err).contains("user name or password"));
assert!(hosts.seen().is_empty(), "{:?}", hosts.seen());
}
fn unavailable(result: Result<Vec<u8>, Fetch>) -> String {
match result {
Err(Fetch::Unavailable(why)) => why,
other => panic!("a 404 is unavailable, not {other:?}"),
}
}
#[test]
fn a_404_without_a_token_says_the_repository_may_be_private_and_which_variable() {
let tooling = corpus("hint", &[]);
let hosts = standin::start(Routes::default());
let none = net_without_tokens(&hosts.proxy);
let get404 =
|net: &Net, repo: &str| unavailable(get(net, &tooling, "tool", repo, "missing"));
for (repo, variable) in [
(
TOOLS_REPO,
"none of FORGEJO_TOKEN, OLD_FORGEJO_TOKEN is set",
),
(LS_REPO, "GITHUB_TOKEN is not set"),
("https://gitlab.com/g/p", "GITLAB_TOKEN is not set"),
(
"https://gitlab.example.com:8443/g/p",
"SELF_GITLAB_TOKEN is not set",
),
] {
let why = get404(&none, repo);
assert!(why.contains("(404)"), "{why}");
assert!(why.contains("or the repository is private"), "{why}");
assert!(why.contains(variable), "{why}");
assert!(
why.contains(".tooling/tooling-versions.json pins a released version of tool"),
"{why}"
);
}
assert!(get404(&none, TOOLS_REPO).contains("Set FORGEJO_TOKEN or OLD_FORGEJO_TOKEN if"));
let why = get404(&none, "https://open.example.com/docs/tool");
assert!(why.contains("names no variable"), "{why}");
assert!(why.contains("\"token_env\""), "{why}");
let with = net(&hosts.proxy);
let why = get404(&with, TOOLS_REPO);
assert!(!why.contains("private"), "{why}");
assert!(
why.contains("the token in FORGEJO_TOKEN cannot read"),
"{why}"
);
let why = get404(&with, LS_REPO);
assert!(
why.contains("the token in GITHUB_TOKEN cannot read"),
"{why}"
);
done(&tooling);
}
#[test]
fn a_403_names_the_variable_too_and_is_never_a_reason_to_fall_back() {
let tooling = corpus("denied", &[]);
let mut routes = Routes::default();
routes
.at(&at(TOOLS_REPO, "denied"), status(403))
.at(&at(LS_REPO, "denied"), status(403));
let hosts = standin::start(routes);
let failed = |net: &Net, repo: &str| match get(net, &tooling, "x", repo, "denied") {
Err(Fetch::Failed(why)) => why,
other => panic!("a 403 is not a 404: {other:?}"),
};
let why = failed(&net_without_tokens(&hosts.proxy), TOOLS_REPO);
assert!(why.contains("(403)"), "{why}");
assert!(
why.contains("Set FORGEJO_TOKEN or OLD_FORGEJO_TOKEN if the repository is private"),
"{why}"
);
let why = failed(&net(&hosts.proxy), TOOLS_REPO);
assert!(why.contains("FORGEJO_TOKEN is set; check"), "{why}");
let why = failed(&net_without_tokens(&hosts.proxy), LS_REPO);
assert!(why.contains("Set GITHUB_TOKEN"), "{why}");
assert!(!why.contains("FORGEJO"), "{why}");
done(&tooling);
}
fn both_pinned(test: &str) -> Tooling {
corpus(
test,
&[("markdoc-ls", LS_REPO), ("markdoc-tools", TOOLS_REPO)],
)
}
fn legacy_path(tooling: &Tooling) -> PathBuf {
tooling
.bin()
.join("markdoc-tools-0.1.0")
.join("bin")
.join(exe("markdoc-ls"))
}
#[test]
fn markdoc_ls_not_published_falls_back_to_markdoc_tools() {
let tooling = both_pinned("unpublished");
let mut routes = Routes::default();
published(
&mut routes,
TOOLS_REPO,
&tools_asset(),
tools_archive(&tooling.root),
);
let hosts = standin::start(routes);
let net = net_with(&hosts.proxy, &["FORGEJO_TOKEN"]);
let path = MARKDOC_LS.download(&net, &tooling).unwrap();
assert_eq!(path, legacy_path(&tooling));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "legacy");
done(&tooling);
let token = Some("authorization: token forgejo-s3cret");
assert_eq!(
sent(&hosts.seen()),
[
s("github.com", None),
s("git.example.com", token),
s("git.example.com", token)
]
);
}
#[test]
fn markdoc_ls_unreachable_falls_back_to_markdoc_tools() {
let tooling = both_pinned("unreachable");
let mut routes = Routes::default();
published(&mut routes, LS_REPO, &ls_asset(), b"split".to_vec());
published(
&mut routes,
TOOLS_REPO,
&tools_asset(),
tools_archive(&tooling.root),
);
routes.down.push("github.com".to_string());
let hosts = standin::start(routes);
let net = net_with(&hosts.proxy, &["FORGEJO_TOKEN"]);
let path = MARKDOC_LS.download(&net, &tooling).unwrap();
assert_eq!(path, legacy_path(&tooling));
done(&tooling);
let seen = hosts.seen();
assert!(
seen.iter().all(|r| r.host == "git.example.com"),
"{seen:#?}"
);
}
#[test]
fn offline_with_the_archive_cached_still_works() {
let tooling = both_pinned("offline");
let cached = legacy_path(&tooling);
std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
std::fs::write(&cached, "legacy").unwrap();
std::fs::write(
tooling.bin().join("markdoc-tools-0.1.0").join(".complete"),
"",
)
.unwrap();
let closed = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let proxy = format!("http://{}", closed.local_addr().unwrap());
drop(closed);
let path = MARKDOC_LS.download(&net(&proxy), &tooling);
done(&tooling);
assert_eq!(path.unwrap(), cached);
}
#[test]
fn a_markdoc_ls_that_fails_its_checksum_is_refused_and_not_replaced() {
let tooling = both_pinned("mismatch");
let mut routes = Routes::default();
published(&mut routes, LS_REPO, &ls_asset(), b"split".to_vec());
routes.at(&at(LS_REPO, &ls_asset()), ok("substituted"));
published(
&mut routes,
TOOLS_REPO,
&tools_asset(),
tools_archive(&tooling.root),
);
let hosts = standin::start(routes);
let net = net_with(&hosts.proxy, &["FORGEJO_TOKEN"]);
let err = MARKDOC_LS.download(&net, &tooling).unwrap_err();
let nothing_cached = !tooling.bin().exists();
done(&tooling);
assert!(err.contains("does not match the checksum"), "{err}");
assert!(nothing_cached);
let seen = hosts.seen();
assert!(seen.iter().all(|r| r.host == "github.com"), "{seen:#?}");
}
#[test]
fn a_checksum_mismatch_is_refused_on_every_forge() {
let tooling = corpus("mismatch-all", &[]);
let mut routes = Routes::default();
github_release(&mut routes, LS_REPO, "a", b"real", (31, 32));
routes.at(
"objects.githubusercontent.com/github-production-release-asset/31?X-Amz-Signature=x",
ok("swapped"),
);
gitlab_release(
&mut routes,
"gitlab.com/api/v4",
"g/p",
"gitlab.com",
"a",
b"real",
);
routes.at("gitlab.com/g/p/-/package_files/1/download", ok("swapped"));
published(&mut routes, TOOLS_REPO, "a", b"real".to_vec());
routes.at(&at(TOOLS_REPO, "a"), ok("swapped"));
let hosts = standin::start(routes);
for repo in [LS_REPO, "https://gitlab.com/g/p", TOOLS_REPO] {
let err = get(&net(&hosts.proxy), &tooling, "a", repo, "a").unwrap_err();
let Fetch::Failed(err) = err else {
panic!("a mismatch is never a reason to fall back")
};
assert!(err.contains("does not match the checksum"), "{repo}: {err}");
assert!(!tooling.bin().exists(), "{repo}: nothing is written");
}
done(&tooling);
}
#[test]
fn a_markdoc_ls_without_a_sha256_is_refused_and_not_replaced() {
let tooling = both_pinned("unsummed");
let mut routes = Routes::default();
routes.at(&at(LS_REPO, &ls_asset()), ok("split"));
published(
&mut routes,
TOOLS_REPO,
&tools_asset(),
tools_archive(&tooling.root),
);
let hosts = standin::start(routes);
let net = net_with(&hosts.proxy, &["FORGEJO_TOKEN"]);
let err = MARKDOC_LS.download(&net, &tooling).unwrap_err();
done(&tooling);
assert!(err.contains("cannot be verified"), "{err}");
let seen = hosts.seen();
assert!(seen.iter().all(|r| r.host == "github.com"), "{seen:#?}");
}
#[test]
fn markdoc_ls_published_wins_over_the_archive() {
let tooling = both_pinned("published");
let mut routes = Routes::default();
published(&mut routes, LS_REPO, &ls_asset(), b"split".to_vec());
published(
&mut routes,
TOOLS_REPO,
&tools_asset(),
tools_archive(&tooling.root),
);
let hosts = standin::start(routes);
let net = net_with(&hosts.proxy, &["FORGEJO_TOKEN"]);
let path = MARKDOC_LS.download(&net, &tooling).unwrap();
let bytes = std::fs::read(&path).unwrap();
let expected = tooling
.root
.join(".tooling")
.join("bin")
.join("markdoc-ls-0.1.0")
.join(ls_asset());
done(&tooling);
assert_eq!(path, expected);
assert_eq!(bytes, b"split");
assert_eq!(
sent(&hosts.seen()),
[s("github.com", None), s("github.com", None)]
);
}
#[test]
fn neither_to_be_had_says_why_for_both() {
let tooling = both_pinned("neither");
let hosts = standin::start(Routes::default());
let err = MARKDOC_LS
.download(&net(&hosts.proxy), &tooling)
.unwrap_err();
done(&tooling);
assert!(
err.contains("https://api.github.com/repos/markdoc-rs/markdoc-ls/releases/tags/0.1.0"),
"{err}"
);
assert!(err.contains(&at(TOOLS_REPO, &tools_asset())), "{err}");
assert!(err.contains("MARKDOC_LS"), "{err}");
}
fn pinned_past_the_archive(test: &str) -> Tooling {
corpus_at(
test,
&[
("markdoc-ls", LS_REPO, "0.2.0"),
("markdoc-tools", TOOLS_REPO, "0.1.0"),
],
)
}
fn says_the_pin_cannot_be_had(err: &str) {
assert!(
err.contains(
"markdoc-ls 0.2.0, the version .tooling/tooling-versions.json pins, cannot be had"
),
"{err}"
);
assert!(
err.contains(
"not from the legacy markdoc-tools archive, which is 0.1.0 -- tw falls back \
to it only for a markdoc-ls pinned at 0.1.0."
),
"{err}"
);
assert!(err.contains("MARKDOC_LS"), "{err}");
}
#[test]
fn markdoc_ls_pinned_past_the_archive_is_not_had_from_it() {
let tooling = pinned_past_the_archive("past");
let mut routes = Routes::default();
published(
&mut routes,
TOOLS_REPO,
&tools_asset(),
tools_archive(&tooling.root),
);
let hosts = standin::start(routes);
let net = net_with(&hosts.proxy, &["FORGEJO_TOKEN"]);
let err = MARKDOC_LS.download(&net, &tooling).unwrap_err();
let nothing_cached = !tooling.bin().exists();
done(&tooling);
says_the_pin_cannot_be_had(&err);
assert!(
err.contains(&at_version(LS_REPO, "0.2.0", &ls_asset())),
"{err}"
);
assert!(nothing_cached);
assert_eq!(sent(&hosts.seen()), [s("github.com", None)]);
}
#[test]
fn an_archive_already_cached_does_not_stand_in_for_a_later_pin() {
let tooling = pinned_past_the_archive("past-cached");
let cached = legacy_path(&tooling);
std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
std::fs::write(&cached, "legacy").unwrap();
std::fs::write(
tooling.bin().join("markdoc-tools-0.1.0").join(".complete"),
"",
)
.unwrap();
let closed = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let proxy = format!("http://{}", closed.local_addr().unwrap());
drop(closed);
let err = MARKDOC_LS.download(&net(&proxy), &tooling).unwrap_err();
done(&tooling);
says_the_pin_cannot_be_had(&err);
assert!(err.contains("could not reach"), "{err}");
}
#[test]
fn markdoc_ls_pinned_past_the_archive_comes_from_its_own_release() {
let tooling = pinned_past_the_archive("past-published");
let mut routes = Routes::default();
published_at(
&mut routes,
LS_REPO,
"0.2.0",
&ls_asset(),
b"split 0.2.0".to_vec(),
);
published(
&mut routes,
TOOLS_REPO,
&tools_asset(),
tools_archive(&tooling.root),
);
let hosts = standin::start(routes);
let net = net_with(&hosts.proxy, &["FORGEJO_TOKEN"]);
let path = MARKDOC_LS.download(&net, &tooling).unwrap();
let bytes = std::fs::read(&path).unwrap();
let expected = tooling.bin().join("markdoc-ls-0.2.0").join(ls_asset());
done(&tooling);
assert_eq!(path, expected);
assert_eq!(bytes, b"split 0.2.0");
let seen = hosts.seen();
assert!(seen.iter().all(|r| r.host == "github.com"), "{seen:#?}");
}
#[test]
fn both_pinned_at_one_later_version_fall_back_to_that_archive() {
let tooling = corpus_at(
"later",
&[
("markdoc-ls", LS_REPO, "0.2.0"),
("markdoc-tools", TOOLS_REPO, "0.2.0"),
],
);
let mut routes = Routes::default();
published_at(
&mut routes,
TOOLS_REPO,
"0.2.0",
&tools_asset(),
tools_archive(&tooling.root),
);
let hosts = standin::start(routes);
let net = net_with(&hosts.proxy, &["FORGEJO_TOKEN"]);
let path = MARKDOC_LS.download(&net, &tooling).unwrap();
let bytes = std::fs::read_to_string(&path).unwrap();
let expected = tooling
.bin()
.join("markdoc-tools-0.2.0")
.join("bin")
.join(exe("markdoc-ls"));
done(&tooling);
assert_eq!(path, expected);
assert_eq!(bytes, "legacy");
}
}