1use std::path::{Path, PathBuf};
26
27pub const URL_FILE: &str = "plm.json";
29pub const TOKEN_FILE: &str = "plm-token";
31pub const URL_ENV: &str = "BREP_PLM_URL";
33pub const TOKEN_ENV: &str = "BREP_PLM_TOKEN";
35pub const TOKEN_PREFIX: &str = "plm_";
38
39pub fn app_config_dir() -> PathBuf {
49 config_dir_from(&|name| std::env::var_os(name), cfg!(windows))
50}
51
52fn config_dir_from(env: &dyn Fn(&str) -> Option<std::ffi::OsString>, windows: bool) -> PathBuf {
55 let var = |name: &str| env(name).filter(|v| !v.is_empty()).map(PathBuf::from);
56 var("XDG_CONFIG_HOME")
57 .or_else(|| if windows { var("APPDATA") } else { None })
58 .or_else(|| var("HOME").map(|h| h.join(".config")))
59 .unwrap_or_else(|| PathBuf::from("."))
60 .join("brep-app")
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum Source {
67 File,
68 Env,
69 Flag,
70}
71
72impl Source {
73 pub fn describe(self) -> &'static str {
74 match self {
75 Source::File => "config file",
76 Source::Env => "environment",
77 Source::Flag => "command line",
78 }
79 }
80}
81
82#[derive(Clone, PartialEq, Eq)]
84pub struct PlmConfig {
85 pub url: String,
87 pub url_source: Source,
88 pub token: Option<String>,
90 pub token_source: Option<Source>,
91}
92
93impl std::fmt::Debug for PlmConfig {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 f.debug_struct("PlmConfig")
97 .field("url", &self.url)
98 .field("url_source", &self.url_source)
99 .field("token", &self.token.as_ref().map(|_| "<redacted>"))
100 .field("token_source", &self.token_source)
101 .finish()
102 }
103}
104
105#[derive(Clone, Debug, Default, PartialEq, Eq)]
107pub struct Flags {
108 pub url: Option<String>,
109 pub token_file: Option<PathBuf>,
110}
111
112pub fn resolve(
117 dir: &Path,
118 env: &dyn Fn(&str) -> Option<String>,
119 flags: &Flags,
120) -> Result<Option<PlmConfig>, String> {
121 let env = |name: &str| env(name).filter(|v| !v.trim().is_empty());
122
123 let (raw_url, url_source) = if let Some(url) = &flags.url {
124 (url.clone(), Source::Flag)
125 } else if let Some(url) = env(URL_ENV) {
126 (url, Source::Env)
127 } else if let Some(url) = read_url_file(dir)? {
128 (url, Source::File)
129 } else {
130 return Ok(None);
131 };
132 let url = normalise_url(&raw_url).map_err(|e| format!("{} ({}): {e}", url_origin(url_source), raw_url))?;
133
134 let token = if let Some(path) = &flags.token_file {
135 Some((read_token_file(path, false)?.ok_or_else(|| format!("--plm-token-file {}: no such file", path.display()))?, Source::Flag))
136 } else if let Some(token) = env(TOKEN_ENV) {
137 Some((token, Source::Env))
138 } else {
139 read_token_file(&dir.join(TOKEN_FILE), true)?.map(|t| (t, Source::File))
140 };
141 let (token, token_source) = match token {
142 Some((token, source)) => {
143 let token = token.trim().to_string();
144 check_token(&token).map_err(|e| format!("{}: {e}", token_origin(source)))?;
145 (Some(token), Some(source))
146 }
147 None => (None, None),
148 };
149 Ok(Some(PlmConfig { url, url_source, token, token_source }))
150}
151
152pub fn write(dir: &Path, url: &str, token: Option<&str>) -> Result<(), String> {
158 let url = normalise_url(url)?;
159 if let Some(token) = token {
160 check_token(token.trim())?;
161 }
162 std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
163 let json = serde_json::to_string_pretty(&serde_json::json!({ "url": url })).expect("a string map serialises");
164 write_atomic(&dir.join(URL_FILE), format!("{json}\n").as_bytes(), false)?;
165 if let Some(token) = token {
166 write_atomic(&dir.join(TOKEN_FILE), format!("{}\n", token.trim()).as_bytes(), true)?;
167 }
168 Ok(())
169}
170
171pub fn write_token(dir: &Path, token: &str) -> Result<(), String> {
173 check_token(token.trim())?;
174 std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
175 write_atomic(&dir.join(TOKEN_FILE), format!("{}\n", token.trim()).as_bytes(), true)
176}
177
178pub fn forget(dir: &Path) -> Result<Vec<PathBuf>, String> {
181 let mut removed = Vec::new();
182 for name in [URL_FILE, TOKEN_FILE] {
183 let path = dir.join(name);
184 match std::fs::remove_file(&path) {
185 Ok(()) => removed.push(path),
186 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
187 Err(e) => return Err(format!("cannot remove {}: {e}", path.display())),
188 }
189 }
190 Ok(removed)
191}
192
193pub fn describe(config: Option<&PlmConfig>, dir: &Path) -> String {
197 let Some(config) = config else {
198 return format!("no PLM configured (no {URL_ENV}, no {} in {}): the file-based app\n", URL_FILE, dir.display());
199 };
200 let token = match (&config.token, config.token_source) {
201 (Some(token), Some(source)) => {
202 let shown: String = token.chars().take(TOKEN_PREFIX.len() + 4).collect();
203 format!("{shown}… (from the {})", source.describe())
204 }
205 _ => "none — sign in from the app".to_string(),
206 };
207 format!("url: {} (from the {})\ntoken: {token}\n", config.url, config.url_source.describe())
208}
209
210fn url_origin(source: Source) -> String {
211 match source {
212 Source::Flag => "--plm-url".into(),
213 Source::Env => URL_ENV.into(),
214 Source::File => URL_FILE.into(),
215 }
216}
217
218fn token_origin(source: Source) -> String {
219 match source {
220 Source::Flag => "--plm-token-file".into(),
221 Source::Env => TOKEN_ENV.into(),
222 Source::File => TOKEN_FILE.into(),
223 }
224}
225
226fn normalise_url(raw: &str) -> Result<String, String> {
229 let url = raw.trim().trim_end_matches('/');
230 let rest = url
231 .strip_prefix("https://")
232 .or_else(|| url.strip_prefix("http://"))
233 .ok_or("the PLM address must start with http:// or https://")?;
234 if rest.is_empty() || rest.starts_with('/') {
235 return Err("the PLM address has no host".into());
236 }
237 if rest.contains(['?', '#', ' ']) {
238 return Err("the PLM address must be the server's base URL, with no query, fragment or spaces".into());
239 }
240 Ok(url.to_string())
241}
242
243fn check_token(token: &str) -> Result<(), String> {
244 if !token.starts_with(TOKEN_PREFIX) || token.len() <= TOKEN_PREFIX.len() {
245 return Err(format!("not a PLM API token (they start with `{TOKEN_PREFIX}`)"));
246 }
247 if token.chars().any(|c| c.is_whitespace() || c.is_control()) {
248 return Err("a PLM API token is one word; this one holds whitespace".into());
249 }
250 Ok(())
251}
252
253fn read_url_file(dir: &Path) -> Result<Option<String>, String> {
254 let path = dir.join(URL_FILE);
255 let text = match std::fs::read_to_string(&path) {
256 Ok(text) => text,
257 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
258 Err(e) => return Err(format!("cannot read {}: {e}", path.display())),
259 };
260 let value: serde_json::Value =
261 serde_json::from_str(&text).map_err(|e| format!("{} is not JSON: {e}", path.display()))?;
262 match value.get("url") {
263 Some(serde_json::Value::String(url)) if !url.trim().is_empty() => Ok(Some(url.clone())),
264 _ => Err(format!("{} has no \"url\" string", path.display())),
265 }
266}
267
268fn read_token_file(path: &Path, strict_mode: bool) -> Result<Option<String>, String> {
272 let text = match std::fs::read_to_string(path) {
273 Ok(text) => text,
274 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
275 Err(e) => return Err(format!("cannot read {}: {e}", path.display())),
276 };
277 #[cfg(unix)]
278 if strict_mode {
279 use std::os::unix::fs::PermissionsExt;
280 let mode = std::fs::metadata(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?.permissions().mode();
281 if mode & 0o077 != 0 {
282 return Err(format!(
283 "{} is readable by other users (mode {:o}); run `chmod 600 {}` or sign in again",
284 path.display(),
285 mode & 0o777,
286 path.display()
287 ));
288 }
289 }
290 #[cfg(not(unix))]
291 let _ = strict_mode;
292 Ok(Some(text))
293}
294
295fn write_atomic(path: &Path, bytes: &[u8], secret: bool) -> Result<(), String> {
296 use std::io::Write;
297 let tmp = path.with_extension(format!("tmp{}", std::process::id()));
298 let mut options = std::fs::OpenOptions::new();
299 options.write(true).create(true).truncate(true);
300 #[cfg(unix)]
301 if secret {
302 use std::os::unix::fs::OpenOptionsExt;
303 options.mode(0o600);
306 }
307 #[cfg(not(unix))]
308 let _ = secret;
309 let result = options
310 .open(&tmp)
311 .and_then(|mut file| {
312 file.write_all(bytes)?;
313 file.sync_all()
314 })
315 .and_then(|()| std::fs::rename(&tmp, path));
316 if let Err(e) = result {
317 let _ = std::fs::remove_file(&tmp);
318 return Err(format!("cannot write {}: {e}", path.display()));
319 }
320 Ok(())
321}
322