use crate::prebuilt_extensions::{body_limited, check_hash};
use percent_encoding::percent_decode_str;
use reqwest::Url;
const CDN_ENV: &str = "RPI_CDN_BASE_URL";
const CLI_REPOSITORY: &str = "https://github.com/bigfish1913/pi-rust";
const PACKAGE_REPOSITORY: &str = "https://github.com/pi-rust/rpi-package";
pub(crate) fn resolve_base(cli_value: Option<&str>) -> Result<Option<Url>, String> {
let configured_setting = crate::settings::load_settings()
.ok()
.and_then(|settings| settings.cdn_base_url);
let value = cli_value
.map(str::to_owned)
.or_else(|| std::env::var(CDN_ENV).ok())
.or(configured_setting)
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty());
let Some(value) = value else { return Ok(None) };
let mut url = Url::parse(&value).map_err(|_| "invalid CDN base URL".to_string())?;
if url.scheme() != "https"
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err("CDN base URL must be HTTPS without credentials, query, or fragment".into());
}
let segments = url
.path()
.split('/')
.filter(|segment| !segment.is_empty())
.map(|segment| {
percent_decode_str(segment)
.decode_utf8()
.map(|value| value.into_owned())
.map_err(|_| "invalid encoded CDN base path".to_string())
})
.collect::<Result<Vec<_>, _>>()?;
if segments.iter().any(|segment| {
segment == "."
|| segment == ".."
|| segment.contains(['/', '\\'])
|| segment.chars().any(|ch| ch.is_control())
}) {
return Err("CDN base URL contains an unsafe path".into());
}
{
let mut path = url
.path_segments_mut()
.map_err(|_| "invalid CDN base URL")?;
path.clear();
for segment in &segments {
path.push(segment);
}
}
Ok(Some(url))
}
fn repository_prefix(repository: &str) -> Option<&'static str> {
match repository {
CLI_REPOSITORY => Some("rpi"),
PACKAGE_REPOSITORY => Some("rpi-package"),
_ => None,
}
}
fn safe_path_segment(value: &str, label: &str) -> Result<String, String> {
let decoded = percent_decode_str(value)
.decode_utf8()
.map_err(|_| format!("invalid encoded {label}"))?;
if decoded.is_empty()
|| decoded == "."
|| decoded == ".."
|| decoded.contains(['/', '\\'])
|| decoded.chars().any(|ch| ch.is_control())
|| !decoded
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'+'))
{
return Err(format!("{label} is not a safe URL path segment"));
}
Ok(decoded.into_owned())
}
fn safe_archive_filename(value: &str) -> Result<String, String> {
let url = Url::parse(value).map_err(|_| "invalid archive URL")?;
let filename = url
.path_segments()
.ok_or("archive URL has no path")?
.last()
.ok_or("archive URL has no filename")?;
safe_path_segment(filename, "archive filename")
}
pub(crate) fn archive_url(
base: &Url,
repository: &str,
tag: &str,
github_archive_url: &str,
) -> Result<Option<Url>, String> {
let Some(prefix) = repository_prefix(repository) else {
return Ok(None);
};
let tag = safe_path_segment(tag, "release tag")?;
let filename = safe_archive_filename(github_archive_url)?;
let mut url = base.clone();
{
let mut path = url
.path_segments_mut()
.map_err(|_| "invalid CDN base URL")?;
let already_scoped = base
.path_segments()
.and_then(|segments| segments.filter(|segment| !segment.is_empty()).next_back())
== Some(prefix);
if !already_scoped {
path.push(prefix);
}
path.push("releases").push(&tag).push(&filename);
}
Ok(Some(url))
}
async fn download_one(
client: &reqwest::Client,
url: &str,
limit: u64,
expected_hash: &str,
label: &str,
) -> Result<Vec<u8>, String> {
let response = client
.get(url)
.send()
.await
.map_err(|error| format!("{label} request failed: {error}"))?;
if !response.status().is_success() {
return Err(format!("{label} request returned {}", response.status()));
}
let bytes = body_limited(response, limit).await?;
if bytes.len() as u64 != limit {
return Err(format!(
"{label} size {} does not match expected size {limit}",
bytes.len()
));
}
check_hash(&bytes, expected_hash)?;
Ok(bytes)
}
pub(crate) async fn download_with_fallback(
client: &reqwest::Client,
cdn_url: Option<&Url>,
github_url: &str,
limit: u64,
expected_hash: &str,
label: &str,
) -> Result<Vec<u8>, String> {
if let Some(cdn_url) = cdn_url {
if let Ok(bytes) = download_one(
client,
cdn_url.as_str(),
limit,
expected_hash,
"CDN archive",
)
.await
{
return Ok(bytes);
}
}
download_one(client, github_url, limit, expected_hash, label).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_and_normalizes_base_and_builds_official_paths() {
let base = resolve_base(Some("https://assets.example.test///mirror//"))
.unwrap()
.unwrap();
assert_eq!(base.as_str(), "https://assets.example.test/mirror");
let url = archive_url(
&base,
CLI_REPOSITORY,
"v1.2.3",
"https://github.com/bigfish1913/pi-rust/releases/download/v1.2.3/rpi.zip",
)
.unwrap()
.unwrap();
assert_eq!(
url.as_str(),
"https://assets.example.test/mirror/rpi/releases/v1.2.3/rpi.zip"
);
let scoped = resolve_base(Some("https://assets.example.test/rpi"))
.unwrap()
.unwrap();
let scoped_url = archive_url(
&scoped,
CLI_REPOSITORY,
"v1.2.3",
"https://github.com/bigfish1913/pi-rust/releases/download/v1.2.3/rpi.zip",
)
.unwrap()
.unwrap();
assert_eq!(
scoped_url.as_str(),
"https://assets.example.test/rpi/releases/v1.2.3/rpi.zip"
);
}
#[tokio::test]
async fn invalid_cdn_archive_falls_back_to_github() {
use crate::prebuilt_extensions::hash;
use std::io::{Read, Write};
let good = b"canonical archive".to_vec();
let good_len = good.len() as u64;
let bad = b"stale CDN archive".to_vec();
let expected = hash(&good);
let server_good = good.clone();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
for body in [&bad, &server_good] {
let (mut socket, _) = listener.accept().unwrap();
let mut request = Vec::new();
let mut byte = [0u8; 1];
while !request.ends_with(b"\r\n\r\n") {
socket.read_exact(&mut byte).unwrap();
request.push(byte[0]);
}
write!(
socket,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.unwrap();
socket.write_all(body).unwrap();
}
});
let client = reqwest::Client::builder().no_proxy().build().unwrap();
let cdn = Url::parse(&format!("http://{address}/cdn")).unwrap();
let github = format!("http://{address}/github");
let result =
download_with_fallback(&client, Some(&cdn), &github, good_len, &expected, "archive")
.await
.unwrap();
assert_eq!(result, good);
server.join().unwrap();
}
#[test]
fn rejects_unsafe_bases_and_filenames_and_ignores_other_repositories() {
for value in [
"http://assets.example.test",
"https://user:pass@assets.example.test",
"https://assets.example.test/x?y=1",
"https://assets.example.test/x%2Fy",
] {
assert!(resolve_base(Some(value)).is_err(), "{value}");
}
let base = resolve_base(Some("https://assets.example.test"))
.unwrap()
.unwrap();
assert!(archive_url(
&base,
CLI_REPOSITORY,
"v1",
"https://github.com/a/b/releases/download/v1/a%2Fb.zip"
)
.is_err());
assert!(archive_url(
&base,
"https://github.com/other/repo",
"v1",
"https://github.com/other/repo/releases/download/v1/a.zip"
)
.unwrap()
.is_none());
}
}