138 lines
4.5 KiB
Rust
138 lines
4.5 KiB
Rust
use std::{fs, io, io::Read, path::Path};
|
|
use tauri::Url;
|
|
|
|
pub const DEFAULT_SERVER: &str = "https://kanbangit.de/";
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Server {
|
|
pub url: Url,
|
|
pub profile: [u8; 16],
|
|
}
|
|
|
|
pub fn validate_url(input: &str) -> Result<Url, &'static str> {
|
|
let input = input.trim();
|
|
if !input
|
|
.get(..8)
|
|
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
|
|
|| input
|
|
.chars()
|
|
.any(|character| character.is_control() || character == '\\')
|
|
{
|
|
return Err("invalidServer");
|
|
}
|
|
let mut url = Url::parse(input).map_err(|_| "invalidServer")?;
|
|
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("invalidServer");
|
|
}
|
|
if !url.path().ends_with('/') {
|
|
url.set_path(&format!("{}/", url.path()));
|
|
}
|
|
Ok(url)
|
|
}
|
|
|
|
impl Server {
|
|
pub fn new(url: Url) -> io::Result<Self> {
|
|
let mut profile = [0; 16];
|
|
fs::File::open("/dev/urandom")?.read_exact(&mut profile)?;
|
|
Ok(Self { url, profile })
|
|
}
|
|
|
|
pub fn load(path: &Path) -> io::Result<Option<Self>> {
|
|
let contents = match fs::read_to_string(path) {
|
|
Ok(contents) => contents,
|
|
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
|
|
Err(error) => return Err(error),
|
|
};
|
|
let invalid = || io::Error::new(io::ErrorKind::InvalidData, "Invalid server configuration");
|
|
let (url, profile) = contents.split_once('\n').ok_or_else(invalid)?;
|
|
let url = validate_url(url).map_err(|_| invalid())?;
|
|
if profile.len() != 32 || !profile.is_ascii() {
|
|
return Err(invalid());
|
|
}
|
|
let mut bytes = [0; 16];
|
|
for (index, byte) in bytes.iter_mut().enumerate() {
|
|
*byte = u8::from_str_radix(&profile[index * 2..index * 2 + 2], 16)
|
|
.map_err(|_| invalid())?;
|
|
}
|
|
Ok(Some(Self {
|
|
url,
|
|
profile: bytes,
|
|
}))
|
|
}
|
|
|
|
pub fn save(&self, path: &Path) -> io::Result<()> {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
let profile: String = self
|
|
.profile
|
|
.iter()
|
|
.map(|byte| format!("{byte:02x}"))
|
|
.collect();
|
|
let temporary = path.with_extension("tmp");
|
|
fs::write(&temporary, format!("{}\n{profile}", self.url))?;
|
|
fs::rename(temporary, path)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn validates_and_normalizes_installation_addresses() {
|
|
assert_eq!(
|
|
validate_url(" HTTPS://Example.com:443/kanban ")
|
|
.unwrap()
|
|
.as_str(),
|
|
"https://example.com/kanban/"
|
|
);
|
|
assert!(validate_url("https://[::1]:8443/").is_ok());
|
|
for input in [
|
|
"http://example.com",
|
|
"https:example.com",
|
|
"https://user:secret@example.com",
|
|
"https://user@example.com",
|
|
"https://example.com/?token=secret",
|
|
"https://example.com/#secret",
|
|
"https://exa\nmple.com",
|
|
"https://example.com\\other",
|
|
"file:///tmp/test",
|
|
"javascript:alert(1)",
|
|
"",
|
|
] {
|
|
assert!(validate_url(input).is_err(), "{input}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn configuration_round_trip_preserves_only_the_address_and_profile() {
|
|
let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap();
|
|
let path =
|
|
std::env::temp_dir().join(format!("kanbangit-server-{:x?}/active", server.profile));
|
|
assert_eq!(Server::load(&path).unwrap(), None);
|
|
server.save(&path).unwrap();
|
|
assert_eq!(Server::load(&path).unwrap(), Some(server.clone()));
|
|
let other = Server::new(validate_url("https://other.example.com:444/").unwrap()).unwrap();
|
|
assert_ne!(server.profile, other.profile);
|
|
other.save(&path).unwrap();
|
|
assert_eq!(Server::load(&path).unwrap(), Some(other));
|
|
for contents in [
|
|
"http://example.com/\n00000000000000000000000000000000",
|
|
"invalid",
|
|
"https://example.com/\nshort",
|
|
"https://example.com/\nzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
|
|
] {
|
|
fs::write(&path, contents).unwrap();
|
|
assert!(Server::load(&path).is_err());
|
|
}
|
|
fs::remove_dir_all(path.parent().unwrap()).unwrap();
|
|
}
|
|
}
|