use std::path::{Path, PathBuf};
pub const URL_FILE: &str = "plm.json";
pub const TOKEN_FILE: &str = "plm-token";
pub const URL_ENV: &str = "BREP_PLM_URL";
pub const TOKEN_ENV: &str = "BREP_PLM_TOKEN";
pub const TOKEN_PREFIX: &str = "plm_";
pub fn app_config_dir() -> PathBuf {
config_dir_from(&|name| std::env::var_os(name), cfg!(windows))
}
fn config_dir_from(env: &dyn Fn(&str) -> Option<std::ffi::OsString>, windows: bool) -> PathBuf {
let var = |name: &str| env(name).filter(|v| !v.is_empty()).map(PathBuf::from);
var("XDG_CONFIG_HOME")
.or_else(|| if windows { var("APPDATA") } else { None })
.or_else(|| var("HOME").map(|h| h.join(".config")))
.unwrap_or_else(|| PathBuf::from("."))
.join("brep-app")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
File,
Env,
Flag,
}
impl Source {
pub fn describe(self) -> &'static str {
match self {
Source::File => "config file",
Source::Env => "environment",
Source::Flag => "command line",
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct PlmConfig {
pub url: String,
pub url_source: Source,
pub token: Option<String>,
pub token_source: Option<Source>,
}
impl std::fmt::Debug for PlmConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PlmConfig")
.field("url", &self.url)
.field("url_source", &self.url_source)
.field("token", &self.token.as_ref().map(|_| "<redacted>"))
.field("token_source", &self.token_source)
.finish()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Flags {
pub url: Option<String>,
pub token_file: Option<PathBuf>,
}
pub fn resolve(
dir: &Path,
env: &dyn Fn(&str) -> Option<String>,
flags: &Flags,
) -> Result<Option<PlmConfig>, String> {
let env = |name: &str| env(name).filter(|v| !v.trim().is_empty());
let (raw_url, url_source) = if let Some(url) = &flags.url {
(url.clone(), Source::Flag)
} else if let Some(url) = env(URL_ENV) {
(url, Source::Env)
} else if let Some(url) = read_url_file(dir)? {
(url, Source::File)
} else {
return Ok(None);
};
let url = normalise_url(&raw_url).map_err(|e| format!("{} ({}): {e}", url_origin(url_source), raw_url))?;
let token = if let Some(path) = &flags.token_file {
Some((read_token_file(path, false)?.ok_or_else(|| format!("--plm-token-file {}: no such file", path.display()))?, Source::Flag))
} else if let Some(token) = env(TOKEN_ENV) {
Some((token, Source::Env))
} else {
read_token_file(&dir.join(TOKEN_FILE), true)?.map(|t| (t, Source::File))
};
let (token, token_source) = match token {
Some((token, source)) => {
let token = token.trim().to_string();
check_token(&token).map_err(|e| format!("{}: {e}", token_origin(source)))?;
(Some(token), Some(source))
}
None => (None, None),
};
Ok(Some(PlmConfig { url, url_source, token, token_source }))
}
pub fn write(dir: &Path, url: &str, token: Option<&str>) -> Result<(), String> {
let url = normalise_url(url)?;
if let Some(token) = token {
check_token(token.trim())?;
}
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
let json = serde_json::to_string_pretty(&serde_json::json!({ "url": url })).expect("a string map serialises");
write_atomic(&dir.join(URL_FILE), format!("{json}\n").as_bytes(), false)?;
if let Some(token) = token {
write_atomic(&dir.join(TOKEN_FILE), format!("{}\n", token.trim()).as_bytes(), true)?;
}
Ok(())
}
pub fn repoint(dir: &Path, url: &str) -> Result<(String, bool), String> {
let url = normalise_url(url)?;
let before = read_url_file(dir).ok().flatten().and_then(|old| normalise_url(&old).ok());
let token = dir.join(TOKEN_FILE);
let drop_token = before.as_deref() != Some(url.as_str()) && token.exists();
if drop_token {
std::fs::remove_file(&token).map_err(|e| format!("cannot remove {}: {e}", token.display()))?;
}
write(dir, &url, None)?;
Ok((url, drop_token))
}
pub fn write_token(dir: &Path, token: &str) -> Result<(), String> {
check_token(token.trim())?;
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
write_atomic(&dir.join(TOKEN_FILE), format!("{}\n", token.trim()).as_bytes(), true)
}
pub fn forget(dir: &Path) -> Result<Vec<PathBuf>, String> {
let mut removed = Vec::new();
for name in [URL_FILE, TOKEN_FILE] {
let path = dir.join(name);
match std::fs::remove_file(&path) {
Ok(()) => removed.push(path),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("cannot remove {}: {e}", path.display())),
}
}
Ok(removed)
}
pub fn describe(config: Option<&PlmConfig>, dir: &Path) -> String {
let Some(config) = config else {
return format!("no PLM configured (no {URL_ENV}, no {} in {}): the file-based app\n", URL_FILE, dir.display());
};
let token = match (&config.token, config.token_source) {
(Some(token), Some(source)) => {
let shown: String = token.chars().take(TOKEN_PREFIX.len() + 4).collect();
format!("{shown}… (from the {})", source.describe())
}
_ => "none — sign in from the app".to_string(),
};
format!("url: {} (from the {})\ntoken: {token}\n", config.url, config.url_source.describe())
}
pub fn url_origin(source: Source) -> String {
match source {
Source::Flag => "--plm-url".into(),
Source::Env => URL_ENV.into(),
Source::File => URL_FILE.into(),
}
}
fn token_origin(source: Source) -> String {
match source {
Source::Flag => "--plm-token-file".into(),
Source::Env => TOKEN_ENV.into(),
Source::File => TOKEN_FILE.into(),
}
}
fn normalise_url(raw: &str) -> Result<String, String> {
let url = raw.trim().trim_end_matches('/');
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.ok_or("the PLM address must start with http:// or https://")?;
if rest.is_empty() || rest.starts_with('/') {
return Err("the PLM address has no host".into());
}
if rest.contains(['?', '#', ' ']) {
return Err("the PLM address must be the server's base URL, with no query, fragment or spaces".into());
}
Ok(url.to_string())
}
fn check_token(token: &str) -> Result<(), String> {
if !token.starts_with(TOKEN_PREFIX) || token.len() <= TOKEN_PREFIX.len() {
return Err(format!("not a PLM API token (they start with `{TOKEN_PREFIX}`)"));
}
if token.chars().any(|c| c.is_whitespace() || c.is_control()) {
return Err("a PLM API token is one word; this one holds whitespace".into());
}
Ok(())
}
fn read_url_file(dir: &Path) -> Result<Option<String>, String> {
let path = dir.join(URL_FILE);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("cannot read {}: {e}", path.display())),
};
let value: serde_json::Value =
serde_json::from_str(&text).map_err(|e| format!("{} is not JSON: {e}", path.display()))?;
match value.get("url") {
Some(serde_json::Value::String(url)) if !url.trim().is_empty() => Ok(Some(url.clone())),
_ => Err(format!("{} has no \"url\" string", path.display())),
}
}
fn read_token_file(path: &Path, strict_mode: bool) -> Result<Option<String>, String> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("cannot read {}: {e}", path.display())),
};
#[cfg(unix)]
if strict_mode {
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?.permissions().mode();
if mode & 0o077 != 0 {
return Err(format!(
"{} is readable by other users (mode {:o}); run `chmod 600 {}` or sign in again",
path.display(),
mode & 0o777,
path.display()
));
}
}
#[cfg(not(unix))]
let _ = strict_mode;
Ok(Some(text))
}
fn write_atomic(path: &Path, bytes: &[u8], secret: bool) -> Result<(), String> {
crate::store::write_atomic(path, bytes, secret).map_err(|e| format!("cannot write {}: {e}", path.display()))
}