use std::io::{self, Write};
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
pub fn write_private(path: &Path, data: &[u8]) -> io::Result<()> {
create_parent_private(path)?;
write_private_to(path, data, false)
}
pub fn write_private_atomic(path: &Path, data: &[u8]) -> io::Result<()> {
create_parent_private(path)?;
let tmp = tmp_sidecar(path, ".tmp");
write_private_to(&tmp, data, true)?;
#[cfg(windows)]
if path.exists() {
std::fs::remove_file(path)?;
}
match std::fs::rename(&tmp, path) {
Ok(()) => Ok(()),
Err(e) => {
#[cfg(not(windows))]
let _ = std::fs::remove_file(&tmp);
Err(e)
}
}
}
pub fn default_client_state_path(config_path: Option<&Path>, server: &str) -> PathBuf {
if let Some(cfg) = config_path {
let mut s = cfg.as_os_str().to_os_string();
s.push(".state");
return PathBuf::from(s);
}
let filename = format!("{}.json", hex_sha256(server.as_bytes()));
state_dir().join(filename)
}
fn state_dir() -> PathBuf {
#[cfg(target_os = "macos")]
{
home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("Library/Application Support/shadowvpn")
}
#[cfg(not(target_os = "macos"))]
{
if let Some(xdg) = std::env::var_os("XDG_STATE_HOME") {
return PathBuf::from(xdg).join("shadowvpn");
}
home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".local/state/shadowvpn")
}
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
fn hex_sha256(data: &[u8]) -> String {
hex_encode(&Sha256::digest(data))
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for &b in bytes {
s.push(HEX[(b >> 4) as usize] as char);
s.push(HEX[(b & 0xf) as usize] as char);
}
s
}
fn tmp_sidecar(path: &Path, suffix: &str) -> PathBuf {
let mut s = path.as_os_str().to_os_string();
s.push(suffix);
PathBuf::from(s)
}
fn create_parent_private(path: &Path) -> io::Result<()> {
match path.parent() {
Some(dir) if !dir.as_os_str().is_empty() => create_dir_private(dir),
_ => Ok(()),
}
}
fn create_dir_private(dir: &Path) -> io::Result<()> {
if dir.as_os_str().is_empty() || dir.exists() {
return Ok(());
}
if let Some(parent) = dir.parent() {
create_dir_private(parent)?;
}
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
match builder.create(dir) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()),
Err(e) => Err(e),
}
}
fn write_private_to(path: &Path, data: &[u8], sync: bool) -> io::Result<()> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
file.write_all(data)?;
if sync {
file.sync_all()?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
fn temp_path(tag: &str) -> PathBuf {
let n = N.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"shadowvpn-state-{}-{}-{n}",
tag,
std::process::id()
))
}
struct TempPath(PathBuf);
impl Drop for TempPath {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
let _ = std::fs::remove_file(tmp_sidecar(&self.0, ".tmp"));
if let Some(dir) = self.0.parent() {
if dir.ends_with("shadowvpn-state-nest") {
let _ = std::fs::remove_dir_all(dir);
}
}
}
}
#[test]
fn config_path_appends_state_suffix() {
let p = default_client_state_path(Some(Path::new("/etc/client.json")), "ignored:1");
assert_eq!(p, PathBuf::from("/etc/client.json.state"));
}
#[test]
fn no_config_path_uses_sha256_not_default_hasher() {
let server = "vpn.example.com:8388";
let p = default_client_state_path(None, server);
let expected = format!("{}.json", hex_sha256(server.as_bytes()));
assert_eq!(p.file_name().unwrap(), expected.as_str());
assert!(p.to_string_lossy().contains("shadowvpn"));
let q = default_client_state_path(None, "203.0.113.8:8388");
assert_ne!(p, q);
}
#[test]
fn write_private_round_trip() {
let path = TempPath(temp_path("wp.json"));
write_private(&path.0, b"hello").unwrap();
assert_eq!(std::fs::read(&path.0).unwrap(), b"hello");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path.0).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
}
#[test]
fn write_private_atomic_replaces() {
let path = TempPath(temp_path("atom.json"));
write_private_atomic(&path.0, b"one").unwrap();
write_private_atomic(&path.0, b"two").unwrap();
assert_eq!(std::fs::read(&path.0).unwrap(), b"two");
assert!(!tmp_sidecar(&path.0, ".tmp").exists());
}
#[test]
fn write_private_creates_parent_dirs() {
let dir = std::env::temp_dir().join(format!(
"shadowvpn-state-nest/{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
let path = TempPath(dir.join("file.json"));
write_private(&path.0, b"x").unwrap();
assert_eq!(std::fs::read(&path.0).unwrap(), b"x");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(path.0.parent().unwrap())
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o700);
}
}
}