set_current held the config RwLock for writing across make_context, which for a context with a parent re-entered the same lock through Context::new's global-manager lookup, deadlocking 'pkh context use'. Context building is now lock-free by construction: make_context resolves parent chains against a snapshot map (also rejecting parent cycles), and neither set_current nor remove_context holds a guard while building a Context. A corrupt contexts.json no longer aborts every command at manager init: load falls back to the default local-only config, backs the corrupt file up to contexts.json.bak so a later save cannot silently destroy it, and a dangling current/parent context falls back to local with an error log instead of panicking.
288 lines
11 KiB
Rust
288 lines
11 KiB
Rust
use directories::ProjectDirs;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::sync::RwLock;
|
|
|
|
use super::api::{Context, ContextConfig};
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct Config {
|
|
pub context: String,
|
|
pub contexts: HashMap<String, ContextConfig>,
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Self {
|
|
let mut contexts = HashMap::new();
|
|
contexts.insert("local".to_string(), ContextConfig::Local);
|
|
Self {
|
|
context: "local".to_string(),
|
|
contexts,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Helper managing contexts
|
|
pub struct ContextManager {
|
|
context: RwLock<Arc<Context>>,
|
|
config_path: PathBuf,
|
|
config: RwLock<Config>,
|
|
}
|
|
|
|
pub static MANAGER: std::sync::LazyLock<ContextManager> =
|
|
std::sync::LazyLock::new(|| ContextManager::new().expect("Cannot setup context manager"));
|
|
|
|
impl ContextManager {
|
|
fn new() -> io::Result<Self> {
|
|
let proj_dirs = ProjectDirs::from("com", "pkh", "pkh").ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::NotFound,
|
|
"Could not determine config directory",
|
|
)
|
|
})?;
|
|
let config_dir = proj_dirs.config_dir();
|
|
fs::create_dir_all(config_dir)?;
|
|
let config_path = config_dir.join("contexts.json");
|
|
|
|
let mut config = Self::load_config(&config_path);
|
|
|
|
// Build the initial Context against the freshly loaded map, before
|
|
// the manager itself exists: resolution must not go through the
|
|
// global MANAGER here, since a parented current context would
|
|
// re-enter its own LazyLock initialization.
|
|
let initial = match Self::make_context(&config.context, &config.contexts) {
|
|
Ok(context) => context,
|
|
Err(e) => {
|
|
log::error!(
|
|
"Cannot build current context '{}' from {}: {e}; falling back to 'local'",
|
|
config.context,
|
|
config_path.display()
|
|
);
|
|
config.context = "local".to_string();
|
|
Self::make_context("local", &config.contexts).unwrap_or_else(|e| {
|
|
// Only possible in a hand-edited configuration without
|
|
// any 'local' entry; a plain Local context has no parent
|
|
// and cannot fail to build.
|
|
log::error!(
|
|
"'local' context missing from {}: {e}",
|
|
config_path.display()
|
|
);
|
|
Context::new(ContextConfig::Local).expect("Local context cannot fail")
|
|
})
|
|
}
|
|
};
|
|
|
|
Ok(Self {
|
|
context: RwLock::new(Arc::new(initial)),
|
|
config_path,
|
|
config: RwLock::new(config),
|
|
})
|
|
}
|
|
|
|
/// Load the configuration stored at `path`.
|
|
///
|
|
/// A missing file yields [`Config::default`]. A file that cannot be read
|
|
/// or parsed must not take the whole program down: this falls back to
|
|
/// the default (local-only) configuration and logs an error. Because a
|
|
/// later [`ContextManager::save`] would otherwise silently overwrite the
|
|
/// corrupt file and destroy its content, the corrupt file is first
|
|
/// backed up to `<path>.bak` (best effort).
|
|
pub(crate) fn load_config(path: &Path) -> Config {
|
|
if !path.exists() {
|
|
return Config::default();
|
|
}
|
|
let loaded = fs::read_to_string(path).and_then(|content| {
|
|
serde_json::from_str(&content)
|
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
|
});
|
|
match loaded {
|
|
Ok(config) => config,
|
|
Err(e) => {
|
|
log::error!(
|
|
"Context configuration {} is corrupt ({e}); using the default (local-only) configuration",
|
|
path.display()
|
|
);
|
|
Self::backup_corrupt_file(path);
|
|
Config::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Back up a corrupt configuration file (best effort) so a later save
|
|
/// cannot silently destroy its content.
|
|
fn backup_corrupt_file(path: &Path) {
|
|
let mut os = path.as_os_str().to_os_string();
|
|
os.push(".bak");
|
|
let backup_path = PathBuf::from(os);
|
|
match fs::copy(path, &backup_path) {
|
|
Ok(_) => log::warn!(
|
|
"Corrupt context configuration backed up to {}",
|
|
backup_path.display()
|
|
),
|
|
Err(e) => log::warn!(
|
|
"Could not back up corrupt context configuration to {}: {e}",
|
|
backup_path.display()
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Obtain current ContextManager configuration
|
|
pub fn get_config(&self) -> std::sync::RwLockReadGuard<'_, Config> {
|
|
self.config.read().unwrap()
|
|
}
|
|
|
|
/// Make a ContextManager using a specific configuration path
|
|
pub fn with_path(path: PathBuf) -> Self {
|
|
let config = Config::default();
|
|
Self {
|
|
// 'local' is always present in Config::default and has no
|
|
// parent, so this cannot fail.
|
|
context: RwLock::new(Arc::new(
|
|
Self::make_context("local", &config.contexts)
|
|
.expect("default 'local' context cannot fail"),
|
|
)),
|
|
config_path: path,
|
|
config: RwLock::new(config),
|
|
}
|
|
}
|
|
|
|
/// Save current context configuration to disk
|
|
pub fn save(&self) -> io::Result<()> {
|
|
let config = self.config.read().unwrap();
|
|
let content = serde_json::to_string_pretty(&*config)
|
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
|
fs::write(&self.config_path, content)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Build a [`Context`] for `name` from `contexts`.
|
|
///
|
|
/// Lock-free by construction: parent references are resolved against
|
|
/// `contexts` itself (see [`Context::with_lookup`]), never against the
|
|
/// manager's configuration lock. This is what keeps [`ContextManager::new`]
|
|
/// working before the global [`MANAGER`] exists, and what allows callers
|
|
/// to build contexts without risking a re-entrant read on a lock they
|
|
/// already hold for writing.
|
|
fn make_context(name: &str, contexts: &HashMap<String, ContextConfig>) -> io::Result<Context> {
|
|
let context_config = contexts.get(name).cloned().ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::NotFound,
|
|
format!("Context '{name}' not found in configuration"),
|
|
)
|
|
})?;
|
|
Context::with_lookup(context_config, &|parent| contexts.get(parent).cloned())
|
|
}
|
|
|
|
/// List contexts from configuration
|
|
pub fn list_contexts(&self) -> Vec<String> {
|
|
self.config
|
|
.read()
|
|
.unwrap()
|
|
.contexts
|
|
.keys()
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
/// Add a context to configuration
|
|
pub fn add_context(&self, name: &str, config: ContextConfig) -> io::Result<()> {
|
|
self.config
|
|
.write()
|
|
.unwrap()
|
|
.contexts
|
|
.insert(name.to_string(), config);
|
|
self.save()
|
|
}
|
|
|
|
/// Remove context from configuration
|
|
pub fn remove_context(&self, name: &str) -> io::Result<()> {
|
|
if name == "local" {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"Cannot remove local context",
|
|
));
|
|
}
|
|
// Mutate under the write lock, snapshotting the remaining map when
|
|
// the removed context was current; the fallback Context is built
|
|
// after the lock is released (same discipline as `set_current`).
|
|
let fallback_contexts = {
|
|
let mut config = self.config.write().unwrap();
|
|
if config.contexts.remove(name).is_none() {
|
|
return Ok(());
|
|
}
|
|
if name == config.context {
|
|
// If we are removing the current context, fallback to local
|
|
config.context = "local".to_string();
|
|
Some(config.contexts.clone())
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
if let Some(contexts) = fallback_contexts {
|
|
self.set_current_ephemeral(Self::make_context("local", &contexts)?);
|
|
}
|
|
self.save()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set current context from name (modifying configuration)
|
|
pub fn set_current(&self, name: &str) -> io::Result<()> {
|
|
// Snapshot what `make_context` needs and release the lock before
|
|
// building the Context. Building resolves parent contexts, and this
|
|
// code path used to hold the config write guard while re-entering
|
|
// the same lock for a read — a guaranteed deadlock on a
|
|
// std::sync::RwLock (same-thread write-then-read).
|
|
let contexts = self.config.read().unwrap().contexts.clone();
|
|
if !contexts.contains_key(name) {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::NotFound,
|
|
format!("Context '{name}' not found"),
|
|
));
|
|
}
|
|
let context = Self::make_context(name, &contexts)?;
|
|
|
|
// Re-take the write lock briefly to commit. The name may have been
|
|
// removed between snapshot and commit; report the same NotFound
|
|
// error instead of persisting a dangling current-context reference.
|
|
let mut config = self.config.write().unwrap();
|
|
if !config.contexts.contains_key(name) {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::NotFound,
|
|
format!("Context '{name}' not found"),
|
|
));
|
|
}
|
|
config.context = name.to_string();
|
|
drop(config); // Drop write lock before saving
|
|
self.set_current_ephemeral(context);
|
|
self.save()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set current context, without modifying configuration
|
|
///
|
|
/// Accepts either an owned [`Context`] or an already-shared
|
|
/// `Arc<Context>`: callers that keep their own handle to the context
|
|
/// they install (e.g. [`crate::deb::ephemeral::EphemeralContextGuard`])
|
|
/// pass the Arc so they can restore exactly this context afterwards
|
|
/// instead of relying on whatever happens to be current at that time.
|
|
pub fn set_current_ephemeral(&self, context: impl Into<Arc<Context>>) {
|
|
*self.context.write().unwrap() = context.into();
|
|
}
|
|
|
|
/// Obtain current context handle
|
|
pub fn current(&self) -> Arc<Context> {
|
|
self.context.read().unwrap().clone()
|
|
}
|
|
|
|
/// Obtain current context name
|
|
/// Will not work for ephemeral context (obtained from config)
|
|
pub fn current_name(&self) -> String {
|
|
self.config.read().unwrap().context.clone()
|
|
}
|
|
}
|