use std::fs;
use std::io;
use std::path::{Path, PathBuf};
const DIR_NAME: &str = ".lightshuttle";
const FILE_NAME: &str = "control.url";
fn path_at(cwd: &Path) -> PathBuf {
cwd.join(DIR_NAME).join(FILE_NAME)
}
pub(crate) fn write(cwd: &Path, url: &str) -> io::Result<PathBuf> {
let path = path_at(cwd);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut contents = String::with_capacity(url.len() + 1);
contents.push_str(url);
contents.push('\n');
fs::write(&path, contents)?;
Ok(path)
}
pub(crate) fn read(cwd: &Path) -> io::Result<String> {
let path = path_at(cwd);
let raw = fs::read_to_string(&path)?;
let trimmed = raw.trim().to_owned();
if trimmed.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{} is empty", path.display()),
));
}
if !is_loopback_url(&trimmed) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{} points at a non-loopback address: {trimmed}",
path.display()
),
));
}
Ok(trimmed)
}
fn is_loopback_url(raw: &str) -> bool {
use std::net::{Ipv4Addr, Ipv6Addr};
let Ok(parsed) = url::Url::parse(raw) else {
return false;
};
if parsed.scheme() != "http" {
return false;
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return false;
}
match parsed.host() {
Some(url::Host::Ipv4(ip)) => ip == Ipv4Addr::LOCALHOST,
Some(url::Host::Ipv6(ip)) => ip == Ipv6Addr::LOCALHOST,
_ => false,
}
}
pub(crate) fn remove(cwd: &Path) -> io::Result<()> {
let path = path_at(cwd);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn write_then_remove_round_trip() {
let dir = tempdir().expect("tempdir");
let url = "http://127.0.0.1:54321/";
let written = write(dir.path(), url).expect("write");
assert!(written.exists());
let read_back = fs::read_to_string(&written).expect("read");
assert_eq!(read_back, format!("{url}\n"));
remove(dir.path()).expect("remove");
assert!(!written.exists());
}
#[test]
fn remove_is_idempotent_when_file_missing() {
let dir = tempdir().expect("tempdir");
remove(dir.path()).expect("remove on empty dir is ok");
}
#[test]
fn read_returns_the_recorded_url_without_trailing_newline() {
let dir = tempdir().expect("tempdir");
let url = "http://127.0.0.1:54321/";
write(dir.path(), url).expect("write");
let recovered = read(dir.path()).expect("read");
assert_eq!(recovered, url);
}
#[test]
fn read_errors_when_file_missing() {
let dir = tempdir().expect("tempdir");
let err = read(dir.path()).expect_err("missing file is an error");
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
#[test]
fn read_rejects_a_non_loopback_url() {
let dir = tempdir().expect("tempdir");
write(dir.path(), "http://evil.example.com:80/").expect("write");
let err = read(dir.path()).expect_err("non-loopback url is rejected");
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn read_accepts_ipv6_loopback() {
let dir = tempdir().expect("tempdir");
let url = "http://[::1]:54321/";
write(dir.path(), url).expect("write");
assert_eq!(read(dir.path()).expect("read"), url);
}
#[test]
fn loopback_check_resists_userinfo_smuggling() {
assert!(!is_loopback_url("http://127.0.0.1:80@evil.example/"));
assert!(!is_loopback_url("http://127.0.0.1:@evil.example/"));
assert!(!is_loopback_url("http://user:pass@127.0.0.1:8080/"));
assert!(!is_loopback_url("https://127.0.0.1:8080/"));
assert!(!is_loopback_url("file:///etc/passwd"));
assert!(is_loopback_url("http://127.0.0.1:8080/"));
assert!(is_loopback_url("http://[::1]:8080/"));
}
}