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 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;