Files
Gotcha/crates/app/src/storage.rs
2026-08-03 22:06:32 +02:00

348 lines
12 KiB
Rust

use std::{
env, fs,
path::PathBuf,
process,
time::{SystemTime, UNIX_EPOCH},
};
use gotcha_gitea::{Client, Provider};
use security_framework::passwords::{
PasswordOptions, delete_generic_password_options, generic_password, get_generic_password,
set_generic_password_options,
};
use crate::{
RepositoryPane,
domain::{Preferences, Server, open_status},
};
pub fn favorite_key(pane: RepositoryPane, server: &str, owner: &str, repository: &str) -> String {
format!(
"{}|{}",
pane.key(),
repository_key(server, owner, repository)
)
}
pub fn repository_key(server: &str, owner: &str, repository: &str) -> String {
format!("{server}|{owner}/{repository}")
}
pub fn validate_server(
name: &str,
url: &str,
token: &str,
provider: Provider,
) -> Result<Server, String> {
let name = name.trim();
let url = url.trim().trim_end_matches('/');
let token = token.trim();
if name.is_empty() {
return Err("Give this server a name.".into());
}
if token.is_empty() {
return Err("Enter an access token.".into());
}
Client::with_provider(url, Some(token), provider).map_err(|error| error.to_string())?;
Ok(Server {
name: name.into(),
url: url.into(),
provider,
credential_account: String::new(),
token: token.into(),
})
}
const APP_GROUP: &str = "group.de.rfc1437.gotcha";
const KEYCHAIN_SERVICE: &str = "de.rfc1437.gotcha";
pub fn load_preferences(storage_directory: Option<&str>) -> Result<Preferences, String> {
let path = preferences_path(storage_directory)?;
let legacy = preferences_path(None)?;
let source = if path.exists() {
path.clone()
} else if path != legacy && legacy.exists() {
legacy
} else {
return Ok(Preferences {
path,
..Preferences::default()
});
};
let mut preferences: Preferences =
serde_json::from_slice(&fs::read(&source).map_err(|error| error.to_string())?)
.map_err(|error| format!("Cannot read {}: {error}", source.display()))?;
preferences.path = path;
let storage_migrated = source != preferences.path;
let favorites_migrated = migrate_favorites(&mut preferences.favorites);
if !matches!(preferences.issue_status.as_str(), "open" | "closed") {
preferences.issue_status = open_status();
}
if !matches!(preferences.pull_status.as_str(), "open" | "closed") {
preferences.pull_status = open_status();
}
let mut credentials_migrated = false;
for server in &mut preferences.servers {
if server.credential_account.is_empty() {
server.credential_account = format!("{}|{}", server.name, server.url);
credentials_migrated = true;
}
server.token = String::from_utf8(load_server_token(server)?)
.map_err(|_| format!("The token for {} is not valid text.", server.name))?;
}
if storage_migrated || favorites_migrated || credentials_migrated {
save_preferences(&preferences)?;
}
Ok(preferences)
}
fn migrate_favorites(favorites: &mut std::collections::BTreeSet<String>) -> bool {
let legacy: Vec<_> = favorites
.iter()
.filter(|favorite| {
!RepositoryPane::ALL
.iter()
.any(|pane| favorite.starts_with(&format!("{}|", pane.key())))
})
.cloned()
.collect();
for favorite in &legacy {
favorites.remove(favorite);
favorites.extend(RepositoryPane::ALL.map(|pane| format!("{}|{favorite}", pane.key())));
}
!legacy.is_empty()
}
pub fn save_preferences(preferences: &Preferences) -> Result<(), String> {
let path = if preferences.path.as_os_str().is_empty() {
preferences_path(None)?
} else {
preferences.path.clone()
};
let parent = path.parent().ok_or("Invalid app data directory.")?;
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
let temporary = path.with_extension("tmp");
fs::write(
&temporary,
serde_json::to_vec(preferences).map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
fs::rename(&temporary, &path).map_err(|error| error.to_string())
}
pub fn save_server_token(server: &Server) -> Result<(), String> {
set_generic_password_options(server.token.as_bytes(), shared_password_options(server))
.map_err(|error| format!("Cannot save the token for {}: {error}", server.name))
}
pub fn delete_server_token(server: &Server) -> Result<(), String> {
match delete_generic_password_options(shared_password_options(server)) {
Ok(()) => Ok(()),
// A missing Keychain item must not make an otherwise valid profile undeletable.
Err(error) if error.code() == -25300 => Ok(()), // errSecItemNotFound
Err(error) => Err(format!(
"Cannot delete the token for {}: {error}",
server.name
)),
}
}
pub fn assign_server_credential_account(server: &mut Server, existing: &[Server]) {
if !server.credential_account.is_empty() {
return;
}
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let prefix = format!("server-{}-{timestamp}", process::id());
server.credential_account = (0_u32..)
.map(|suffix| {
if suffix == 0 {
prefix.clone()
} else {
format!("{prefix}-{suffix}")
}
})
.find(|candidate| {
existing
.iter()
.all(|saved| saved.credential_account != *candidate)
})
.expect("a unique Keychain account must exist");
}
fn preferences_path(storage_directory: Option<&str>) -> Result<PathBuf, String> {
if let Some(directory) = storage_directory {
let directory = directory.trim();
if directory.is_empty() {
return Err("Invalid shared app data directory.".into());
}
return Ok(PathBuf::from(directory).join("preferences.json"));
}
let home = env::var_os("HOME").ok_or("Cannot find the app data directory.")?;
Ok(PathBuf::from(home).join("Library/Application Support/Gotcha/preferences.json"))
}
fn load_server_token(server: &Server) -> Result<Vec<u8>, String> {
match generic_password(shared_password_options(server)) {
Ok(token) => Ok(token),
Err(error) if error.code() == -25300 => {
let token = get_generic_password(KEYCHAIN_SERVICE, &keychain_account(server))
.map_err(|error| format!("Cannot read the token for {}: {error}", server.name))?;
set_generic_password_options(&token, shared_password_options(server))
.map_err(|error| format!("Cannot share the token for {}: {error}", server.name))?;
Ok(token)
}
Err(error) => Err(format!(
"Cannot read the token for {}: {error}",
server.name
)),
}
}
fn shared_password_options(server: &Server) -> PasswordOptions {
let mut options =
PasswordOptions::new_generic_password(KEYCHAIN_SERVICE, &keychain_account(server));
options.set_access_group(APP_GROUP);
options
}
fn keychain_account(server: &Server) -> String {
if server.credential_account.is_empty() {
format!("{}|{}", server.name, server.url)
} else {
server.credential_account.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_servers_and_builds_stable_favorite_keys() {
assert!(
validate_server(
"Work",
"https://gitea.example.com/",
"secret",
Provider::Gitea
)
.is_ok()
);
assert!(
validate_server("", "https://gitea.example.com", "secret", Provider::Gitea).is_err()
);
assert!(validate_server("Work", "file:///tmp/gitea", "secret", Provider::Forgejo).is_err());
assert_eq!(
favorite_key(
RepositoryPane::Issues,
"https://gitea.example.com",
"octo",
"demo"
),
"issues|https://gitea.example.com|octo/demo"
);
let preferences: Preferences = serde_json::from_str(
r#"{"issue_filters":{"server|octo/demo":{"milestone":"v1","labels":["bug"]}},"pull_filters":{"server":{"milestone":"v2"}}}"#,
)
.unwrap();
assert_eq!(
preferences.issue_filters["server|octo/demo"].milestone,
"v1"
);
assert!(
preferences.issue_filters["server|octo/demo"]
.labels
.contains("bug")
);
assert_eq!(preferences.pull_filters["server"].milestone, "v2");
assert_eq!(Preferences::default().pull_status, "open");
assert_eq!(
Preferences::default().appearance,
crate::domain::AppearanceMode::Auto
);
assert_eq!(
serde_json::from_str::<Preferences>(r#"{"appearance":"dark"}"#)
.unwrap()
.appearance,
crate::domain::AppearanceMode::Dark
);
assert_eq!(
serde_json::from_str::<Preferences>(r#"{"appearance":"future-mode"}"#)
.unwrap()
.appearance,
crate::domain::AppearanceMode::Auto
);
let legacy_server: Server =
serde_json::from_str(r#"{"name":"Work","url":"https://gitea.example.com"}"#).unwrap();
assert_eq!(legacy_server.provider, Provider::Gitea);
assert!(legacy_server.credential_account.is_empty());
assert_eq!(
keychain_account(&legacy_server),
"Work|https://gitea.example.com"
);
let mut new_server = legacy_server.clone();
assign_server_credential_account(&mut new_server, &[]);
assert!(new_server.credential_account.starts_with("server-"));
assert_eq!(keychain_account(&new_server), new_server.credential_account);
assert_eq!(
crate::domain::AppearanceMode::from_index(1),
Some(crate::domain::AppearanceMode::Light)
);
assert_eq!(crate::domain::AppearanceMode::from_index(3), None);
}
#[test]
fn migrates_global_favorites_to_each_repository_pane() {
let mut favorites = ["https://gitea.example.com|octo/demo".to_string()]
.into_iter()
.collect();
assert!(migrate_favorites(&mut favorites));
assert_eq!(
favorites,
[
"commits|https://gitea.example.com|octo/demo".to_string(),
"issues|https://gitea.example.com|octo/demo".to_string(),
"milestones|https://gitea.example.com|octo/demo".to_string(),
]
.into_iter()
.collect()
);
assert!(!migrate_favorites(&mut favorites));
}
#[test]
fn saves_preferences_to_the_selected_storage_directory() {
let directory = env::temp_dir().join(format!(
"gotcha-preferences-{}-{}",
process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let path = directory.join("preferences.json");
let preferences = Preferences {
path: path.clone(),
pull_status: "closed".into(),
..Preferences::default()
};
save_preferences(&preferences).unwrap();
let stored = fs::read_to_string(&path).unwrap();
assert!(!stored.contains(path.to_string_lossy().as_ref()));
assert_eq!(
serde_json::from_str::<Preferences>(&stored)
.unwrap()
.pull_status,
"closed"
);
fs::remove_file(path).unwrap();
fs::remove_dir(directory).unwrap();
}
}