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 { 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 { 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> { 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(); } }