Skip to main content

wekan_cli/config/
profiles.rs

1use std::{
2    collections::BTreeMap,
3    env,
4    ffi::OsString,
5    fs::{self, File, OpenOptions},
6    io::{self, Read, Write},
7    path::{Path, PathBuf},
8    sync::atomic::{AtomicU64, Ordering},
9};
10
11use directories::ProjectDirs;
12use fs4::FileExt;
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use thiserror::Error;
16
17use crate::client::{ServerUrl, ServerUrlError};
18
19const CONFIG_VERSION: u8 = 1;
20const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
21const APPLICATION_NAME: &str = "wekan-cli";
22const CONFIG_FILE_NAME: &str = "profiles.json";
23const LOCK_FILE_NAME: &str = "profiles.lock";
24const BACKUP_FILE_NAME: &str = "profiles.json.bak";
25static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
26
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct Profile {
29    server: String,
30}
31
32impl Profile {
33    pub fn new(server: String) -> Self {
34        Self { server }
35    }
36
37    pub fn server(&self) -> &str {
38        &self.server
39    }
40}
41
42#[derive(Clone, Debug, Default, Eq, PartialEq)]
43pub struct ProfileDocument {
44    revision: u64,
45    active_profile: Option<String>,
46    profiles: BTreeMap<String, Profile>,
47}
48
49impl ProfileDocument {
50    pub(crate) const fn revision(&self) -> u64 {
51        self.revision
52    }
53
54    pub fn active_profile(&self) -> Option<&str> {
55        self.active_profile.as_deref()
56    }
57
58    pub fn profiles(&self) -> &BTreeMap<String, Profile> {
59        &self.profiles
60    }
61
62    pub fn profile(&self, name: &str) -> Option<&Profile> {
63        self.profiles.get(name)
64    }
65
66    pub fn insert(&mut self, name: String, profile: Profile) -> Option<Profile> {
67        self.profiles.insert(name, profile)
68    }
69
70    pub fn remove(&mut self, name: &str) -> Option<Profile> {
71        self.profiles.remove(name)
72    }
73
74    pub fn set_active_profile(&mut self, name: Option<String>) {
75        self.active_profile = name;
76    }
77
78    pub fn is_empty(&self) -> bool {
79        self.profiles.is_empty()
80    }
81}
82
83pub trait ProfileLease: Send {}
84
85impl ProfileLease for File {}
86
87#[derive(Debug)]
88struct NoopLease;
89
90impl ProfileLease for NoopLease {}
91
92pub struct ProfileSnapshot {
93    document: ProfileDocument,
94    _lease: Box<dyn ProfileLease>,
95}
96
97impl ProfileSnapshot {
98    pub fn new_unlocked(document: ProfileDocument) -> Self {
99        Self {
100            document,
101            _lease: Box::new(NoopLease),
102        }
103    }
104
105    fn locked(document: ProfileDocument, lease: File) -> Self {
106        Self {
107            document,
108            _lease: Box::new(lease),
109        }
110    }
111
112    pub const fn document(&self) -> &ProfileDocument {
113        &self.document
114    }
115}
116
117pub trait ProfileMutation: Send {
118    fn document(&self) -> &ProfileDocument;
119    fn save(&mut self, document: ProfileDocument) -> Result<(), ProfileStoreError>;
120}
121
122pub trait ProfileStore: Send + Sync {
123    fn read(&self) -> Result<ProfileSnapshot, ProfileStoreError>;
124    fn lock_mutation(&self) -> Result<Box<dyn ProfileMutation + Send + '_>, ProfileStoreError>;
125    fn credential_namespace(&self) -> Result<String, ProfileStoreError>;
126}
127
128#[derive(Clone, Debug)]
129pub struct FileProfileStore {
130    location: Result<PathBuf, String>,
131}
132
133impl FileProfileStore {
134    pub fn production() -> Self {
135        Self {
136            location: profile_directory(env::var_os("WEKAN_CONFIG_DIR")),
137        }
138    }
139
140    pub fn at(directory: PathBuf) -> Self {
141        Self {
142            location: Ok(directory),
143        }
144    }
145
146    fn paths(&self) -> Result<ProfilePaths, ProfileStoreError> {
147        let directory = self
148            .location
149            .as_ref()
150            .map_err(|message| ProfileStoreError::Configuration(message.clone()))?
151            .clone();
152        Ok(ProfilePaths {
153            config: directory.join(CONFIG_FILE_NAME),
154            lock: directory.join(LOCK_FILE_NAME),
155            backup: directory.join(BACKUP_FILE_NAME),
156            directory,
157        })
158    }
159}
160
161impl Default for FileProfileStore {
162    fn default() -> Self {
163        Self::production()
164    }
165}
166
167impl ProfileStore for FileProfileStore {
168    fn read(&self) -> Result<ProfileSnapshot, ProfileStoreError> {
169        let paths = self.paths()?;
170        prepare_directory(&paths.directory)?;
171        let lock = open_lock(&paths.lock)?;
172        FileExt::lock_shared(&lock).map_err(ProfileStoreError::Lock)?;
173        let document = load_document(&paths)?;
174        Ok(ProfileSnapshot::locked(document, lock))
175    }
176
177    fn lock_mutation(&self) -> Result<Box<dyn ProfileMutation + Send + '_>, ProfileStoreError> {
178        let paths = self.paths()?;
179        prepare_directory(&paths.directory)?;
180        let lock = open_lock(&paths.lock)?;
181        FileExt::lock(&lock).map_err(ProfileStoreError::Lock)?;
182        let document = load_document(&paths)?;
183        Ok(Box::new(FileProfileMutation {
184            paths,
185            document,
186            _lock: lock,
187        }))
188    }
189
190    fn credential_namespace(&self) -> Result<String, ProfileStoreError> {
191        let paths = self.paths()?;
192        prepare_directory(&paths.directory)?;
193        profile_store_namespace(&paths.directory)
194    }
195}
196
197struct FileProfileMutation {
198    paths: ProfilePaths,
199    document: ProfileDocument,
200    _lock: File,
201}
202
203impl ProfileMutation for FileProfileMutation {
204    fn document(&self) -> &ProfileDocument {
205        &self.document
206    }
207
208    fn save(&mut self, mut document: ProfileDocument) -> Result<(), ProfileStoreError> {
209        if document.revision != self.document.revision {
210            return Err(ProfileStoreError::Invalid(
211                "the profile configuration changed before it could be saved".to_owned(),
212            ));
213        }
214        document.revision = document.revision.checked_add(1).ok_or_else(|| {
215            ProfileStoreError::Invalid("the profile configuration revision is exhausted".to_owned())
216        })?;
217        validate_document(&document)?;
218        match persist_document(&self.paths, &document) {
219            Ok(()) => {
220                self.document = document;
221                Ok(())
222            }
223            Err(error) if error.profile_was_installed() => {
224                self.document = document;
225                Err(error)
226            }
227            Err(error) => Err(error),
228        }
229    }
230}
231
232#[derive(Clone, Debug)]
233struct ProfilePaths {
234    directory: PathBuf,
235    config: PathBuf,
236    lock: PathBuf,
237    backup: PathBuf,
238}
239
240#[derive(Debug, Error)]
241pub enum ProfileStoreError {
242    #[error("{0}")]
243    Configuration(String),
244    #[error("the profile configuration directory could not be prepared: {0}")]
245    Directory(#[source] io::Error),
246    #[error("the profile configuration lock could not be opened: {0}")]
247    LockOpen(#[source] io::Error),
248    #[error("the profile configuration lock could not be acquired: {0}")]
249    Lock(#[source] io::Error),
250    #[error("the profile configuration could not be inspected: {0}")]
251    Inspect(#[source] io::Error),
252    #[error("the profile configuration is larger than 1 MiB")]
253    TooLarge,
254    #[error("the profile configuration could not be read: {0}")]
255    Read(#[source] io::Error),
256    #[error("the profile configuration is not valid JSON: {0}")]
257    Deserialize(#[source] serde_json::Error),
258    #[error("profile configuration version {0} is not supported")]
259    UnsupportedVersion(u8),
260    #[error("the profile configuration is invalid: {0}")]
261    Invalid(String),
262    #[error("the profile configuration could not be serialized: {0}")]
263    Serialize(#[source] serde_json::Error),
264    #[error("the profile configuration could not be written safely: {0}")]
265    Write(#[source] io::Error),
266    #[error("the profile configuration was installed but could not be finalized safely: {0}")]
267    WriteAfterInstall(#[source] io::Error),
268}
269
270impl ProfileStoreError {
271    pub(crate) const fn profile_was_installed(&self) -> bool {
272        matches!(self, Self::WriteAfterInstall(_))
273    }
274}
275
276#[derive(Deserialize, Serialize)]
277#[serde(deny_unknown_fields)]
278struct StoredDocument {
279    version: u8,
280    revision: u64,
281    active_profile: Option<String>,
282    profiles: BTreeMap<String, StoredProfile>,
283}
284
285#[derive(Deserialize, Serialize)]
286#[serde(deny_unknown_fields)]
287struct StoredProfile {
288    server: String,
289}
290
291pub fn validate_profile_name(name: &str) -> Result<(), String> {
292    let valid_length = (1..=64).contains(&name.len());
293    let mut bytes = name.bytes();
294    let valid_first = bytes
295        .next()
296        .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit());
297    let valid_rest = bytes.all(|byte| {
298        byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
299    });
300
301    if valid_length && valid_first && valid_rest {
302        Ok(())
303    } else {
304        Err("profile names must be 1-64 characters and match [a-z0-9][a-z0-9._-]*".to_owned())
305    }
306}
307
308pub fn parse_profile_name(raw: &str) -> Result<String, String> {
309    validate_profile_name(raw)?;
310    Ok(raw.to_owned())
311}
312
313pub fn canonical_server(raw: &str) -> Result<String, ServerUrlError> {
314    ServerUrl::parse(raw).map(|url| url.as_str().to_owned())
315}
316
317fn profile_directory(override_directory: Option<OsString>) -> Result<PathBuf, String> {
318    if let Some(raw) = override_directory {
319        let directory = PathBuf::from(raw);
320        if directory.as_os_str().is_empty() {
321            return Err("WEKAN_CONFIG_DIR must not be empty".to_owned());
322        }
323        if !directory.is_absolute() {
324            return Err("WEKAN_CONFIG_DIR must be an absolute path".to_owned());
325        }
326        return Ok(directory);
327    }
328
329    ProjectDirs::from("org", "Wekan", APPLICATION_NAME)
330        .map(|directories| directories.config_dir().to_owned())
331        .ok_or_else(|| "no platform configuration directory is available".to_owned())
332}
333
334fn profile_store_namespace(directory: &Path) -> Result<String, ProfileStoreError> {
335    let canonical = fs::canonicalize(directory).map_err(ProfileStoreError::Inspect)?;
336    let mut digest = Sha256::new();
337    digest.update(b"wekan-cli-profile-store-v1\0");
338    digest.update(profile_store_identity_bytes(&canonical));
339    Ok(format!("sha256-{:x}", digest.finalize()))
340}
341
342#[cfg(unix)]
343fn profile_store_identity_bytes(directory: &Path) -> Vec<u8> {
344    use std::os::unix::ffi::OsStrExt;
345
346    directory.as_os_str().as_bytes().to_vec()
347}
348
349#[cfg(windows)]
350fn profile_store_identity_bytes(directory: &Path) -> Vec<u8> {
351    use std::os::windows::ffi::OsStrExt;
352
353    directory
354        .as_os_str()
355        .encode_wide()
356        .flat_map(u16::to_le_bytes)
357        .collect()
358}
359
360#[cfg(not(any(unix, windows)))]
361fn profile_store_identity_bytes(directory: &Path) -> Vec<u8> {
362    directory
363        .as_os_str()
364        .to_string_lossy()
365        .into_owned()
366        .into_bytes()
367}
368
369fn prepare_directory(directory: &Path) -> Result<(), ProfileStoreError> {
370    if let Some(parent) = directory
371        .parent()
372        .filter(|parent| !parent.as_os_str().is_empty())
373    {
374        fs::create_dir_all(parent).map_err(ProfileStoreError::Directory)?;
375    }
376
377    let builder = fs::DirBuilder::new();
378    #[cfg(unix)]
379    let builder = {
380        use std::os::unix::fs::DirBuilderExt;
381        let mut builder = builder;
382        builder.mode(0o700);
383        builder
384    };
385    match builder.create(directory) {
386        Ok(()) => Ok(()),
387        Err(error) if error.kind() == io::ErrorKind::AlreadyExists && directory.is_dir() => Ok(()),
388        Err(error) => Err(ProfileStoreError::Directory(error)),
389    }
390}
391
392fn open_lock(path: &Path) -> Result<File, ProfileStoreError> {
393    let file = OpenOptions::new()
394        .read(true)
395        .write(true)
396        .create(true)
397        .truncate(false)
398        .open(path)
399        .map_err(ProfileStoreError::LockOpen)?;
400    restrict_file_permissions(path).map_err(ProfileStoreError::LockOpen)?;
401    Ok(file)
402}
403
404fn metadata_if_present(path: &Path) -> io::Result<Option<fs::Metadata>> {
405    match fs::symlink_metadata(path) {
406        Ok(metadata) if metadata.file_type().is_symlink() => fs::metadata(path).map(Some),
407        Ok(metadata) => Ok(Some(metadata)),
408        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
409        Err(error) => Err(error),
410    }
411}
412
413fn load_document(paths: &ProfilePaths) -> Result<ProfileDocument, ProfileStoreError> {
414    let (path, metadata) = if let Some(metadata) =
415        metadata_if_present(&paths.config).map_err(ProfileStoreError::Inspect)?
416    {
417        (&paths.config, metadata)
418    } else if let Some(metadata) =
419        metadata_if_present(&paths.backup).map_err(ProfileStoreError::Inspect)?
420    {
421        (&paths.backup, metadata)
422    } else {
423        return Ok(ProfileDocument::default());
424    };
425
426    if metadata.len() > MAX_CONFIG_BYTES {
427        return Err(ProfileStoreError::TooLarge);
428    }
429    let mut bytes = Vec::with_capacity(metadata.len() as usize);
430    File::open(path)
431        .map_err(ProfileStoreError::Read)?
432        .take(MAX_CONFIG_BYTES + 1)
433        .read_to_end(&mut bytes)
434        .map_err(ProfileStoreError::Read)?;
435    if bytes.len() as u64 > MAX_CONFIG_BYTES {
436        return Err(ProfileStoreError::TooLarge);
437    }
438
439    let stored: StoredDocument =
440        serde_json::from_slice(&bytes).map_err(ProfileStoreError::Deserialize)?;
441    if stored.version != CONFIG_VERSION {
442        return Err(ProfileStoreError::UnsupportedVersion(stored.version));
443    }
444    let document = ProfileDocument {
445        revision: stored.revision,
446        active_profile: stored.active_profile,
447        profiles: stored
448            .profiles
449            .into_iter()
450            .map(|(name, profile)| (name, Profile::new(profile.server)))
451            .collect(),
452    };
453    validate_document(&document)?;
454    Ok(document)
455}
456
457fn validate_document(document: &ProfileDocument) -> Result<(), ProfileStoreError> {
458    for (name, profile) in document.profiles() {
459        validate_profile_name(name).map_err(ProfileStoreError::Invalid)?;
460        let canonical = canonical_server(profile.server()).map_err(|error| {
461            ProfileStoreError::Invalid(format!("profile `{name}` has an invalid server: {error}"))
462        })?;
463        if canonical != profile.server() {
464            return Err(ProfileStoreError::Invalid(format!(
465                "profile `{name}` does not contain a canonical server URL"
466            )));
467        }
468    }
469    if let Some(active) = document.active_profile()
470        && !document.profiles().contains_key(active)
471    {
472        return Err(ProfileStoreError::Invalid(format!(
473            "active profile `{active}` does not exist"
474        )));
475    }
476    Ok(())
477}
478
479fn persist_document(
480    paths: &ProfilePaths,
481    document: &ProfileDocument,
482) -> Result<(), ProfileStoreError> {
483    let stored = StoredDocument {
484        version: CONFIG_VERSION,
485        revision: document.revision,
486        active_profile: document.active_profile.clone(),
487        profiles: document
488            .profiles
489            .iter()
490            .map(|(name, profile)| {
491                (
492                    name.clone(),
493                    StoredProfile {
494                        server: profile.server.clone(),
495                    },
496                )
497            })
498            .collect(),
499    };
500    let mut bytes = serde_json::to_vec_pretty(&stored).map_err(ProfileStoreError::Serialize)?;
501    bytes.push(b'\n');
502    if bytes.len() as u64 > MAX_CONFIG_BYTES {
503        return Err(ProfileStoreError::TooLarge);
504    }
505
506    let (temp_path, mut temp) = loop {
507        let path = paths.directory.join(format!(
508            ".profiles.json.{}.{}.tmp",
509            std::process::id(),
510            TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed)
511        ));
512        match OpenOptions::new().write(true).create_new(true).open(&path) {
513            Ok(file) => break (path, file),
514            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
515            Err(error) => return Err(ProfileStoreError::Write(error)),
516        }
517    };
518    if let Err(error) = restrict_file_permissions(&temp_path)
519        .and_then(|()| temp.write_all(&bytes))
520        .and_then(|()| temp.sync_all())
521    {
522        let _ = fs::remove_file(&temp_path);
523        return Err(ProfileStoreError::Write(error));
524    }
525    drop(temp);
526
527    let result = replace_with_backup(paths, &temp_path);
528    if result.is_err() {
529        let _ = fs::remove_file(&temp_path);
530    }
531    result
532}
533
534fn replace_with_backup(paths: &ProfilePaths, temp_path: &Path) -> Result<(), ProfileStoreError> {
535    let before_install = ProfileStoreError::Write;
536    let after_install = ProfileStoreError::WriteAfterInstall;
537    let had_config = metadata_if_present(&paths.config)
538        .map_err(before_install)?
539        .is_some();
540    if had_config {
541        if metadata_if_present(&paths.backup)
542            .map_err(before_install)?
543            .is_some()
544        {
545            fs::remove_file(&paths.backup).map_err(before_install)?;
546        }
547        fs::rename(&paths.config, &paths.backup).map_err(before_install)?;
548        sync_directory(&paths.directory).map_err(before_install)?;
549    }
550
551    if let Err(error) = fs::rename(temp_path, &paths.config) {
552        if had_config {
553            let _ = fs::rename(&paths.backup, &paths.config);
554        }
555        return Err(before_install(error));
556    }
557
558    sync_directory(&paths.directory).map_err(after_install)?;
559    if metadata_if_present(&paths.backup)
560        .map_err(after_install)?
561        .is_some()
562    {
563        // The new configuration is already committed. Failure to remove a
564        // stale backup is safe; it will be cleaned after a later mutation.
565        let _ = fs::remove_file(&paths.backup);
566    }
567    Ok(())
568}
569
570#[cfg(unix)]
571fn sync_directory(directory: &Path) -> io::Result<()> {
572    File::open(directory)?.sync_all()
573}
574
575#[cfg(not(unix))]
576fn sync_directory(_directory: &Path) -> io::Result<()> {
577    Ok(())
578}
579
580#[cfg(unix)]
581fn restrict_file_permissions(path: &Path) -> io::Result<()> {
582    use std::os::unix::fs::PermissionsExt;
583    fs::set_permissions(path, fs::Permissions::from_mode(0o600))
584}
585
586#[cfg(not(unix))]
587fn restrict_file_permissions(_path: &Path) -> io::Result<()> {
588    Ok(())
589}
590
591#[cfg(test)]
592mod tests;