use crate::error::{SecretSet, WITHHELD};
use crate::output::terminal_safe;
pub const MAX_INDEX_URL_BYTES: usize = 8 * 1024;
pub const UNUSABLE_FOR_FILE: &str =
"The PyPI auth response is not a usable index URL; nothing was written";
pub const UNUSABLE_FOR_INSTALL: &str =
"The PyPI auth response is not a usable index URL; nothing was installed.";
pub fn percent_decode(text: &str) -> Option<String> {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
let byte = bytes[index];
if byte == b'%'
&& index + 2 < bytes.len()
&& let (Some(high), Some(low)) = (hex(bytes[index + 1]), hex(bytes[index + 2]))
{
out.push(high * 16 + low);
index += 3;
continue;
}
out.push(byte);
index += 1;
}
String::from_utf8(out).ok()
}
fn hex(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
fn parsed(raw: &str) -> Option<url::Url> {
let url = url::Url::parse(raw).ok()?;
url.has_host().then_some(url)
}
fn raw_userinfo(raw: &str) -> Option<&str> {
let (_, rest) = raw.split_once("://")?;
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let authority = &rest[..authority_end];
authority.rsplit_once('@').map(|(userinfo, _)| userinfo)
}
pub fn register(raw: &str, secrets: &SecretSet) {
if !raw.is_empty() {
secrets.add(raw);
}
let Some(userinfo) = raw_userinfo(raw) else {
return;
};
if !userinfo.is_empty() {
secrets.add(userinfo);
}
let (username, password) = match userinfo.split_once(':') {
Some((username, password)) => (username, Some(password)),
None => (userinfo, None),
};
match password.filter(|password| !password.is_empty()) {
Some(password) => {
secrets.add(password);
let decoded_password = percent_decode(password);
if let Some(decoded) = &decoded_password {
secrets.add(decoded);
}
let decoded_username = percent_decode(username).unwrap_or_else(|| username.to_owned());
secrets.add_basic_credentials(
&decoded_username,
decoded_password.as_deref().unwrap_or(password),
);
secrets.add_basic_credentials(username, password);
}
None if !username.is_empty() => {
secrets.add(username);
if let Some(decoded) = percent_decode(username) {
secrets.add(&decoded);
}
}
None => {}
}
}
pub fn withheld_form(raw: &str, secrets: &SecretSet) -> String {
let Some(url) = parsed(raw) else {
return WITHHELD.to_owned();
};
let mut out = format!("{}://", url.scheme());
if !url.username().is_empty() || url.password().is_some() || raw_userinfo(raw).is_some() {
out.push_str(WITHHELD);
out.push('@');
}
out.push_str(url.host_str().unwrap_or_default());
if let Some(port) = url.port() {
out.push_str(&format!(":{port}"));
}
out.push_str(url.path());
if url.query().is_some() {
out.push('?');
out.push_str(WITHHELD);
}
if url.fragment().is_some() {
out.push('#');
out.push_str(WITHHELD);
}
let out = terminal_safe(&out).into_owned();
if secrets.carried_by(&out) {
WITHHELD.to_owned()
} else {
out
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct Index {
pub index: String,
pub username: String,
pub password: String,
pub raw_password: String,
pub raw_userinfo: String,
}
impl std::fmt::Debug for Index {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Index")
.field("index", &self.index)
.finish_non_exhaustive()
}
}
fn has_control(text: &str) -> bool {
text.chars().any(char::is_control)
}
pub fn check(raw: &str) -> Option<Index> {
if raw.len() > MAX_INDEX_URL_BYTES || has_control(raw) {
return None;
}
let url = parsed(raw)?;
if url.scheme() != "https" || url.query().is_some() || url.fragment().is_some() {
return None;
}
let userinfo = raw_userinfo(raw)?;
let (raw_username, raw_password) = userinfo.split_once(':')?;
let username = percent_decode(raw_username)?;
let password = percent_decode(raw_password)?;
if username.is_empty()
|| password.is_empty()
|| has_control(&username)
|| has_control(&password)
{
return None;
}
let mut index = format!("https://{}", url.host_str()?);
if let Some(port) = url.port() {
index.push_str(&format!(":{port}"));
}
index.push_str(url.path());
if has_control(&index) || index.contains('@') {
return None;
}
Some(Index {
index,
username,
password,
raw_password: raw_password.to_owned(),
raw_userinfo: userinfo.to_owned(),
})
}
#[cfg(test)]
mod tests {
use super::*;
const URL: &str =
"https://oauth2accesstoken:FAKE-PYPI-SECRET-100@us-west1-python.pkg.dev/proj/repo/simple/";
#[test]
fn the_credential_joins_the_secret_set_and_is_withheld() {
let secrets = SecretSet::default();
register(URL, &secrets);
for text in [
URL,
"FAKE-PYPI-SECRET-100",
"oauth2accesstoken:FAKE-PYPI-SECRET-100",
&crate::error::base64_standard(b"oauth2accesstoken:FAKE-PYPI-SECRET-100"),
] {
assert!(secrets.carried_by(text), "{text}");
}
assert!(
!secrets.carried_by("oauth2accesstoken"),
"the username alone is not a secret when a password exists"
);
assert_eq!(
withheld_form(URL, &secrets),
"https://[withheld]@us-west1-python.pkg.dev/proj/repo/simple/"
);
let secrets = SecretSet::default();
register("https://u:FAKE%2DPW%2B1@h.example/simple/", &secrets);
assert!(secrets.carried_by("FAKE-PW+1"));
assert!(secrets.carried_by("FAKE%2DPW%2B1"));
let secrets = SecretSet::default();
register("https://TOKEN-ONLY-1234@h.example/simple/", &secrets);
assert!(secrets.carried_by("TOKEN-ONLY-1234"));
}
#[test]
fn the_withheld_form_keeps_scheme_host_port_and_path_only() {
let secrets = SecretSet::default();
for (raw, form) in [
("https://h.example/simple/", "https://h.example/simple/"),
(
"https://u:p@h.example:8443/s/?token=abc#frag",
"https://[withheld]@h.example:8443/s/?[withheld]#[withheld]",
),
(
"http://u:p@127.0.0.1:9/x",
"http://[withheld]@127.0.0.1:9/x",
),
("not a url", "[withheld]"),
("", "[withheld]"),
("mailto:someone", "[withheld]"),
] {
assert_eq!(withheld_form(raw, &secrets), form, "{raw}");
}
let secrets = SecretSet::default();
secrets.add("h.example");
assert_eq!(
withheld_form("https://u:p@h.example/s/", &secrets),
"[withheld]"
);
}
#[test]
fn the_index_check_refuses_unusable_urls() {
let index = check(URL).unwrap();
assert_eq!(
index.index,
"https://us-west1-python.pkg.dev/proj/repo/simple/"
);
assert_eq!(index.username, "oauth2accesstoken");
assert_eq!(index.password, "FAKE-PYPI-SECRET-100");
assert!(!format!("{index:?}").contains("FAKE"), "Debug withholds");
let decoded = check("https://us%65r:p%40ss%2Bw@h.example:444/s/").unwrap();
assert_eq!(decoded.username, "user");
assert_eq!(decoded.password, "p@ss+w");
assert_eq!(decoded.index, "https://h.example:444/s/");
let long = format!("https://u:p@h.example/{}", "a".repeat(MAX_INDEX_URL_BYTES));
for bad in [
"http://u:p@h.example/s/",
"https://h.example/s/",
"https://u@h.example/s/",
"https://u:@h.example/s/",
"https://:p@h.example/s/",
"https://u:p@h.example/s/?q=1",
"https://u:p@h.example/s/#f",
"https://u:p@h.example/s/\u{1b}[31m",
"https://u:p%0A@h.example/s/",
"https://u:%FF@h.example/s/",
"not a url",
long.as_str(),
] {
assert!(check(bad).is_none(), "{bad}");
}
}
#[test]
fn percent_decoding_keeps_plus_and_stray_percents() {
assert_eq!(percent_decode("a+b%20c%2").as_deref(), Some("a+b c%2"));
assert_eq!(percent_decode("%zz").as_deref(), Some("%zz"));
assert_eq!(percent_decode("%ff"), None);
}
}