use std::fs::File;
use std::io::{self, prelude::*, BufReader};
use std::path::{Component, Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use shellexpand;
use crate::scan::location::{Location, Locations};
pub type Ignore = Vec<String>;
#[derive(Debug)]
pub struct Profile {
pub local: String,
pub remote: String,
pub locations: Locations,
pub ignore: Ignore,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProfileSource {
Named(String),
File(PathBuf),
}
#[derive(Debug)]
pub struct ProfileConfig {
pub display_name: String,
pub identity: String,
pub profile: Profile,
pub local_state: PathBuf,
pub remote_state_dir: PathBuf,
pub server_log: PathBuf,
}
fn config_dir() -> Result<PathBuf, io::Error> {
let expanded = shellexpand::full("~/.config/duet/").map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("unable to expand ~/.config/duet/: {}", e),
)
})?;
Ok(PathBuf::from(expanded.into_owned()))
}
pub fn location(name: &str) -> Result<PathBuf, io::Error> {
validate_profile_name(name)?;
let mut base = config_dir()?;
base.push(name.to_owned() + ".prf");
Ok(base)
}
fn validate_profile_name(name: &str) -> Result<(), io::Error> {
if name.contains('\\') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid profile name: {}", name),
));
}
let mut components = Path::new(name).components();
match (components.next(), components.next()) {
(Some(Component::Normal(_)), None) if name != "." && name != ".." => Ok(()),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid profile name: {}", name),
)),
}
}
pub fn local_state(name: &str) -> Result<PathBuf, io::Error> {
let mut profile_location = location(name)?;
profile_location.set_extension("snp");
Ok(profile_location)
}
pub fn remote_state_dir() -> Result<PathBuf, io::Error> {
let mut base = config_dir()?;
base.push("remotes");
Ok(base)
}
pub fn client_id() -> Result<String, io::Error> {
let mut path = config_dir()?;
std::fs::create_dir_all(&path)?;
path.push("client-id");
match std::fs::read_to_string(&path) {
Ok(existing) => {
let existing = existing.trim();
validate_remote_state_id(existing)?;
Ok(existing.to_string())
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
let id = generate_client_id()?;
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
writeln!(file, "{}", id)?;
Ok(id)
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
let existing = std::fs::read_to_string(&path)?;
let existing = existing.trim();
validate_remote_state_id(existing)?;
Ok(existing.to_string())
}
Err(e) => Err(e),
}
}
Err(e) => Err(e),
}
}
fn generate_client_id() -> Result<String, io::Error> {
let mut bytes = [0u8; 16];
match File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut bytes)) {
Ok(()) => {}
Err(_) => {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
bytes[..8].copy_from_slice(&(nanos as u64).to_le_bytes());
bytes[8..12].copy_from_slice(&std::process::id().to_le_bytes());
}
}
Ok(format_client_id(&bytes))
}
fn format_client_id(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{:02x}", byte)).collect()
}
pub fn remote_state_in(dir: &Path, id: &str) -> PathBuf {
dir.join(id)
}
pub fn validate_remote_state_id(id: &str) -> Result<(), io::Error> {
if id.is_empty()
|| id == "."
|| id == ".."
|| id.contains('/')
|| id.contains('\\')
|| !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid remote state id: {}", id),
));
}
Ok(())
}
pub fn load(source: &ProfileSource) -> Result<ProfileConfig, io::Error> {
match source {
ProfileSource::Named(name) => Ok(ProfileConfig {
display_name: name.clone(),
identity: name.clone(),
profile: parse(name)?,
local_state: local_state(name)?,
remote_state_dir: remote_state_dir()?,
server_log: default_server_log()?,
}),
ProfileSource::File(path) => {
let path = std::fs::canonicalize(path)?;
let display_name = path.display().to_string();
let mut local_state = path.clone();
local_state.set_extension("snp");
let mut remote_state_dir = path.clone();
remote_state_dir.set_extension("remotes");
let mut server_log = path.clone();
server_log.set_extension("remote.log");
Ok(ProfileConfig {
display_name,
identity: path.display().to_string(),
profile: parse_file(&path)?,
local_state,
remote_state_dir,
server_log,
})
}
}
}
fn default_server_log() -> Result<PathBuf, io::Error> {
let mut base = config_dir()?;
base.push("remote.log");
Ok(base)
}
pub fn parse(name: &str) -> Result<Profile, io::Error> {
let profile_location = location(name)?;
log::debug!("Loading {:?}", profile_location);
parse_file(&profile_location)
}
pub fn parse_file(profile_location: &Path) -> Result<Profile, io::Error> {
log::debug!("Loading {:?}", profile_location);
let f = File::open(profile_location)?;
let reader = BufReader::new(f);
let mut p = Profile {
local: String::new(),
remote: String::new(),
locations: vec![Location::Exclude(PathBuf::from("."))], ignore: Vec::new(),
};
let mut locations = 0;
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if locations == 0 {
p.local = line.to_string();
locations += 1;
continue;
} else if locations == 1 {
p.remote = line;
locations += 1;
continue;
}
if locations == 2 {
if let Some(path) = trimmed.strip_prefix('+') {
p.locations
.push(Location::Include(PathBuf::from(path.trim())));
} else if let Some(path) = trimmed.strip_prefix('-') {
p.locations
.push(Location::Exclude(PathBuf::from(path.trim())));
} else if trimmed == "[ignore]" {
locations += 1;
} else {
return parse_error(&line);
}
} else {
p.ignore.push(line);
}
}
Ok(p)
}
fn parse_error(line: &str) -> Result<Profile, io::Error> {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("can't parse line: {}", line),
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn rejects_profile_names_with_path_components() {
assert!(location("work").is_ok());
assert!(location("../work").is_err());
assert!(location("/tmp/work").is_err());
assert!(location("work/other").is_err());
assert!(location("work\\other").is_err());
assert!(location(".").is_err());
assert!(location("..").is_err());
}
#[test]
fn parses_include_exclude_markers_after_leading_whitespace() {
let mut file = tempfile::NamedTempFile::new().unwrap();
writeln!(file, "/local").unwrap();
writeln!(file, "remote /remote").unwrap();
writeln!(file, " +src").unwrap();
writeln!(file, " -target").unwrap();
writeln!(file, " [ignore]").unwrap();
writeln!(file, "*.tmp").unwrap();
let profile = parse_file(file.path()).unwrap();
assert!(matches!(
&profile.locations[1],
Location::Include(path) if path == &PathBuf::from("src")
));
assert!(matches!(
&profile.locations[2],
Location::Exclude(path) if path == &PathBuf::from("target")
));
assert_eq!(profile.ignore, vec!["*.tmp".to_string()]);
}
#[test]
fn formats_client_ids_as_safe_remote_state_ids() {
let id = format_client_id(&[0, 1, 2, 10, 15, 16, 254, 255]);
assert_eq!(id, "0001020a0f10feff");
validate_remote_state_id(&id).unwrap();
}
}