Skip to main content

brep_app/plm/
config.rs

1//! Where the native app finds its PLM server (plan D5).
2//!
3//! Two files beside the config the native app already keeps
4//! (`$XDG_CONFIG_HOME/brep-app`, else `$HOME/.config/brep-app` — the directory
5//! `FileModelStore` roots itself in):
6//!
7//! * `plm.json` — `{"url": "https://plm.example.com"}`. Not a secret.
8//! * `plm-token` — the API token (`plm_…`) alone, at mode 0600. A secret, so it
9//!   is its own file and never a field of `@settings`, which the explorer shows
10//!   and an export could carry.
11//!
12//! Each field is overridden by an environment variable (`BREP_PLM_URL`,
13//! `BREP_PLM_TOKEN`), which is overridden by a command-line flag (`--plm-url`,
14//! `--plm-token-file`). The token's flag names a FILE, never the token itself:
15//! a command line is readable by every user of the machine through `ps`.
16//!
17//! **No URL from any source means no PLM**, and [`resolve`] answers `Ok(None)`
18//! having read nothing but two absent files — the file-based app of today. An
19//! empty variable counts as unset, so `BREP_PLM_URL= brep-app` is the file app.
20//!
21//! [`write()`] and [`forget`] are what `brep-app --plm-configure` / `--plm-forget`
22//! run, so an installer script configures a machine with no prompt and no
23//! hand-written JSON (plan S12).
24
25use std::path::{Path, PathBuf};
26
27/// The URL file's name inside the app config directory.
28pub const URL_FILE: &str = "plm.json";
29/// The token file's name inside the app config directory.
30pub const TOKEN_FILE: &str = "plm-token";
31/// Overrides `plm.json`'s `url`.
32pub const URL_ENV: &str = "BREP_PLM_URL";
33/// Overrides the token file.
34pub const TOKEN_ENV: &str = "BREP_PLM_TOKEN";
35/// Every API token the server issues starts with this (`BREP_plm`'s
36/// `security::TOKEN_PREFIX`).
37pub const TOKEN_PREFIX: &str = "plm_";
38
39/// The native app's config directory — the ONE rule, which `FileModelStore`
40/// (models, settings, recovery) and the two PLM files both use:
41/// `$XDG_CONFIG_HOME/brep-app`, else on Windows `%APPDATA%\brep-app`, else
42/// `$HOME/.config/brep-app`, else `./brep-app`. An empty variable counts as
43/// unset.
44///
45/// Windows sets neither `XDG_CONFIG_HOME` nor `HOME`, so without its own arm
46/// the app kept its files in whatever the working directory happened to be:
47/// an installer's `--plm-configure` and a Start-menu launch would disagree.
48pub fn app_config_dir() -> PathBuf {
49    config_dir_from(&|name| std::env::var_os(name), cfg!(windows))
50}
51
52/// [`app_config_dir`] with the environment and platform passed in, so a test
53/// can hold both still.
54fn 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/// Which layer a resolved value came from — shown by `--plm-show` so a user
64/// can tell why the app is talking to the server it is.
65#[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/// The server this session connects to.
83#[derive(Clone, PartialEq, Eq)]
84pub struct PlmConfig {
85    /// Normalised: `http://` or `https://`, no trailing slash.
86    pub url: String,
87    pub url_source: Source,
88    /// `None` until the user signs in (S1's password-to-token flow writes it).
89    pub token: Option<String>,
90    pub token_source: Option<Source>,
91}
92
93// The token is a secret: a `{:?}` in a log line must not print it.
94impl 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/// The command line's two overrides.
106#[derive(Clone, Debug, Default, PartialEq, Eq)]
107pub struct Flags {
108    pub url: Option<String>,
109    pub token_file: Option<PathBuf>,
110}
111
112/// Resolve the session's PLM from the config directory `dir`, the environment
113/// (`env` stands in for `std::env::var` so tests need not mutate the process),
114/// and `flags`. `Ok(None)`: no PLM, the file-based app. `Err`: a PLM was asked
115/// for and cannot be used as written — the sentence names the file or variable.
116pub 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
152/// Write `plm.json` (and, when given, `plm-token` at 0600) into `dir`, creating
153/// it. With `token: None` an existing token file is LEFT ALONE — re-pointing a
154/// machine at a moved server keeps its sign-in; [`forget`] is the way to drop it.
155/// Each file is written whole to a sibling temp file and renamed over, so a
156/// reader never sees half a file.
157pub 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
171/// The Settings PLM tab's Server URL: [`write()`] `plm.json` exactly as
172/// `--plm-configure URL` does (the same validation: a malformed URL is refused
173/// and nothing is written) — except that a URL naming a DIFFERENT server than
174/// the one `plm.json` named first removes `plm-token`, because a token is never
175/// sent to a server it was not issued by. The comparison is on normalised URLs,
176/// so re-saving the same server (a trailing slash, spaces) keeps the sign-in.
177/// Returns the URL as written and whether a token was dropped.
178pub fn repoint(dir: &Path, url: &str) -> Result<(String, bool), String> {
179    let url = normalise_url(url)?;
180    let before = read_url_file(dir).ok().flatten().and_then(|old| normalise_url(&old).ok());
181    let token = dir.join(TOKEN_FILE);
182    let drop_token = before.as_deref() != Some(url.as_str()) && token.exists();
183    if drop_token {
184        std::fs::remove_file(&token).map_err(|e| format!("cannot remove {}: {e}", token.display()))?;
185    }
186    write(dir, &url, None)?;
187    Ok((url, drop_token))
188}
189
190/// Store only the token (S1's sign-in writes it after a password exchange).
191pub fn write_token(dir: &Path, token: &str) -> Result<(), String> {
192    check_token(token.trim())?;
193    std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
194    write_atomic(&dir.join(TOKEN_FILE), format!("{}\n", token.trim()).as_bytes(), true)
195}
196
197/// Remove both files: the next launch is the file-based app. Absent files are
198/// not an error. Returns the paths actually removed.
199pub fn forget(dir: &Path) -> Result<Vec<PathBuf>, String> {
200    let mut removed = Vec::new();
201    for name in [URL_FILE, TOKEN_FILE] {
202        let path = dir.join(name);
203        match std::fs::remove_file(&path) {
204            Ok(()) => removed.push(path),
205            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
206            Err(e) => return Err(format!("cannot remove {}: {e}", path.display())),
207        }
208    }
209    Ok(removed)
210}
211
212/// `--plm-show`'s text: the effective server and where each part came from.
213/// The token itself is never printed, only its first characters, the way the
214/// server's own token list shows a `prefix`.
215pub fn describe(config: Option<&PlmConfig>, dir: &Path) -> String {
216    let Some(config) = config else {
217        return format!("no PLM configured (no {URL_ENV}, no {} in {}): the file-based app\n", URL_FILE, dir.display());
218    };
219    let token = match (&config.token, config.token_source) {
220        (Some(token), Some(source)) => {
221            let shown: String = token.chars().take(TOKEN_PREFIX.len() + 4).collect();
222            format!("{shown}… (from the {})", source.describe())
223        }
224        _ => "none — sign in from the app".to_string(),
225    };
226    format!("url:   {} (from the {})\ntoken: {token}\n", config.url, config.url_source.describe())
227}
228
229/// The switch, variable or file a URL from `source` was named by, as the
230/// Settings PLM tab and the refusals say it.
231pub fn url_origin(source: Source) -> String {
232    match source {
233        Source::Flag => "--plm-url".into(),
234        Source::Env => URL_ENV.into(),
235        Source::File => URL_FILE.into(),
236    }
237}
238
239fn token_origin(source: Source) -> String {
240    match source {
241        Source::Flag => "--plm-token-file".into(),
242        Source::Env => TOKEN_ENV.into(),
243        Source::File => TOKEN_FILE.into(),
244    }
245}
246
247/// `http://` or `https://`, a host, no query or fragment; the trailing slash
248/// is dropped so `format!("{url}{path}")` joins cleanly.
249fn normalise_url(raw: &str) -> Result<String, String> {
250    let url = raw.trim().trim_end_matches('/');
251    let rest = url
252        .strip_prefix("https://")
253        .or_else(|| url.strip_prefix("http://"))
254        .ok_or("the PLM address must start with http:// or https://")?;
255    if rest.is_empty() || rest.starts_with('/') {
256        return Err("the PLM address has no host".into());
257    }
258    if rest.contains(['?', '#', ' ']) {
259        return Err("the PLM address must be the server's base URL, with no query, fragment or spaces".into());
260    }
261    Ok(url.to_string())
262}
263
264fn check_token(token: &str) -> Result<(), String> {
265    if !token.starts_with(TOKEN_PREFIX) || token.len() <= TOKEN_PREFIX.len() {
266        return Err(format!("not a PLM API token (they start with `{TOKEN_PREFIX}`)"));
267    }
268    if token.chars().any(|c| c.is_whitespace() || c.is_control()) {
269        return Err("a PLM API token is one word; this one holds whitespace".into());
270    }
271    Ok(())
272}
273
274fn read_url_file(dir: &Path) -> Result<Option<String>, String> {
275    let path = dir.join(URL_FILE);
276    let text = match std::fs::read_to_string(&path) {
277        Ok(text) => text,
278        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
279        Err(e) => return Err(format!("cannot read {}: {e}", path.display())),
280    };
281    let value: serde_json::Value =
282        serde_json::from_str(&text).map_err(|e| format!("{} is not JSON: {e}", path.display()))?;
283    match value.get("url") {
284        Some(serde_json::Value::String(url)) if !url.trim().is_empty() => Ok(Some(url.clone())),
285        _ => Err(format!("{} has no \"url\" string", path.display())),
286    }
287}
288
289/// Read a token file. `strict_mode`: refuse one other users can read (unix) —
290/// the app's own file must be 0600; a file named by a flag is the caller's
291/// business and is only read.
292fn read_token_file(path: &Path, strict_mode: bool) -> Result<Option<String>, String> {
293    let text = match std::fs::read_to_string(path) {
294        Ok(text) => text,
295        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
296        Err(e) => return Err(format!("cannot read {}: {e}", path.display())),
297    };
298    #[cfg(unix)]
299    if strict_mode {
300        use std::os::unix::fs::PermissionsExt;
301        let mode = std::fs::metadata(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?.permissions().mode();
302        if mode & 0o077 != 0 {
303            return Err(format!(
304                "{} is readable by other users (mode {:o}); run `chmod 600 {}` or sign in again",
305                path.display(),
306                mode & 0o777,
307                path.display()
308            ));
309        }
310    }
311    #[cfg(not(unix))]
312    let _ = strict_mode;
313    Ok(Some(text))
314}
315
316fn write_atomic(path: &Path, bytes: &[u8], secret: bool) -> Result<(), String> {
317    crate::store::write_atomic(path, bytes, secret).map_err(|e| format!("cannot write {}: {e}", path.display()))
318}
319