use std::path::Path;
use base64::Engine;
use crate::config::FileConfig;
pub const SCHEME: &str = "shadowvpn://";
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;
#[derive(Debug, thiserror::Error)]
pub enum UriError {
#[error("not a shadowvpn:// URI (expected the `{SCHEME}` scheme)")]
Scheme,
#[error("invalid Base64 in URI: {0}")]
Base64(#[from] base64::DecodeError),
#[error("URI payload is not valid UTF-8: {0}")]
Utf8(#[from] std::str::Utf8Error),
#[error("URI payload is not a valid config: {0}")]
Json(#[from] serde_json::Error),
}
pub fn encode(cfg: &FileConfig) -> String {
let json = serde_json::to_vec(cfg).expect("FileConfig always serializes to JSON");
format!("{SCHEME}{}", B64.encode(json))
}
pub fn decode(uri: &str) -> Result<FileConfig, UriError> {
let body = uri.trim().strip_prefix(SCHEME).ok_or(UriError::Scheme)?;
let payload = body
.split(|c: char| c == '#' || c.is_whitespace())
.next()
.unwrap_or("");
let bytes = B64.decode(payload)?;
let text = std::str::from_utf8(&bytes)?;
Ok(serde_json::from_str(text)?)
}
pub fn render_qr(text: &str) -> Result<String, qrcode::types::QrError> {
use qrcode::render::unicode;
let code = qrcode::QrCode::new(text.as_bytes())?;
Ok(code.render::<unicode::Dense1x2>().quiet_zone(true).build())
}
#[derive(Debug, thiserror::Error)]
pub enum QrImageError {
#[error("failed to open image {path}: {source}")]
Open {
path: String,
#[source]
source: image::ImageError,
},
#[error("no QR code found in the image")]
NotFound,
#[error("failed to decode QR code: {0}")]
Decode(String),
}
pub fn decode_qr_image(path: &Path) -> Result<String, QrImageError> {
let img = image::open(path)
.map_err(|source| QrImageError::Open {
path: path.display().to_string(),
source,
})?
.to_luma8();
let mut prepared = rqrr::PreparedImage::prepare(img);
let grid = prepared
.detect_grids()
.into_iter()
.next()
.ok_or(QrImageError::NotFound)?;
let (_meta, content) = grid
.decode()
.map_err(|e| QrImageError::Decode(e.to_string()))?;
Ok(content)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
use std::path::PathBuf;
fn sample() -> FileConfig {
FileConfig {
server: Some("sf1.maxlv.net:443".to_string()),
password: Some("pYGmRwycA/vVnoNlXg5aK2in5Tamsw4K".to_string()),
cipher: Some("chacha20-poly1305".to_string()),
obfs: Some("quic".to_string()),
tun_ip: Some(Ipv4Addr::new(10, 9, 0, 2)),
tun_netmask: Some(Ipv4Addr::new(255, 255, 255, 0)),
peer_ip: Some(Ipv4Addr::new(10, 9, 0, 1)),
mtu: Some(1400),
mode: Some("chinadns".to_string()),
geoip: Some(PathBuf::from("/opt/svpn/GeoLite2-Country.mmdb")),
geoip_country: Some("CN".to_string()),
..Default::default()
}
}
#[test]
fn round_trips_through_a_uri() {
let cfg = sample();
let uri = encode(&cfg);
assert!(uri.starts_with(SCHEME));
let back = decode(&uri).expect("decode");
assert_eq!(
serde_json::to_string(&cfg).unwrap(),
serde_json::to_string(&back).unwrap()
);
}
#[test]
fn tolerates_whitespace_and_fragment() {
let uri = encode(&sample());
let messy = format!(" {uri}#sf1-chinadns\n");
let back = decode(&messy).expect("decode messy");
assert_eq!(back.server.as_deref(), Some("sf1.maxlv.net:443"));
}
#[test]
fn rejects_wrong_scheme() {
assert!(matches!(decode("ss://whatever"), Err(UriError::Scheme)));
}
#[test]
fn rejects_garbage_payload() {
let err = decode("shadowvpn://!!!not-base64!!!").unwrap_err();
assert!(matches!(err, UriError::Base64(_)));
}
#[test]
fn renders_a_non_empty_qr() {
let qr = render_qr(&encode(&sample())).expect("render");
assert!(qr.lines().count() > 5);
}
#[test]
fn qr_round_trips_through_an_image() {
let uri = encode(&sample());
let code = qrcode::QrCode::new(uri.as_bytes()).unwrap();
let w = code.width();
let colors = code.to_colors();
let (scale, quiet) = (6u32, 4u32);
let dim = (w as u32 + 2 * quiet) * scale;
let mut img = image::GrayImage::from_pixel(dim, dim, image::Luma([255u8]));
for y in 0..w {
for x in 0..w {
if colors[y * w + x] == qrcode::Color::Dark {
for dy in 0..scale {
for dx in 0..scale {
let px = (quiet + x as u32) * scale + dx;
let py = (quiet + y as u32) * scale + dy;
img.put_pixel(px, py, image::Luma([0u8]));
}
}
}
}
}
let path = std::env::temp_dir().join("shadowvpn-uri-test.png");
img.save(&path).expect("save png");
let decoded = decode_qr_image(&path).expect("decode image");
let _ = std::fs::remove_file(&path);
assert_eq!(decoded, uri);
}
}