#![allow(dead_code)]
use nono::{NonoError, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
const USER_CONFIG_FILE: &str = "config.toml";
#[derive(Debug, Default, Deserialize)]
pub struct UserConfig {
#[serde(default)]
pub meta: UserConfigMeta,
#[serde(default)]
pub overrides: UserOverrides,
#[serde(default)]
pub extensions: UserExtensions,
#[serde(default)]
pub trusted_keys: HashMap<String, TrustedKeyInfo>,
#[serde(default, alias = "undo")]
pub rollback: RollbackSettings,
#[serde(default)]
pub updates: UpdateSettings,
#[serde(default)]
pub ui: UiSettings,
#[serde(default)]
pub redaction: RedactionSettings,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct UiSettings {
#[serde(default)]
pub theme: Option<String>,
#[serde(default)]
pub detach_sequence: Option<DetachSequence>,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct RedactionSettings {
#[serde(default)]
pub extra_flags: Vec<String>,
#[serde(default)]
pub extra_headers: Vec<String>,
#[serde(default)]
pub extra_query_keys: Vec<String>,
#[serde(default)]
pub unsafe_redaction_overrides: bool,
#[serde(default)]
pub allow_unredacted_defaults: Vec<String>,
}
impl RedactionSettings {
pub fn to_scrub_policy(&self) -> Result<nono::ScrubPolicy> {
if !self.unsafe_redaction_overrides && !self.allow_unredacted_defaults.is_empty() {
return Err(NonoError::ConfigParse(
"[redaction].allow_unredacted_defaults requires \
unsafe_redaction_overrides = true"
.to_string(),
));
}
let mut redactions = nono::ScrubPolicy::secure_default();
for flag in &self.extra_flags {
redactions.add_flag(flag);
}
for header in &self.extra_headers {
redactions.add_header(header);
}
for key in &self.extra_query_keys {
redactions.add_query_key(key);
}
if self.unsafe_redaction_overrides {
for name in &self.allow_unredacted_defaults {
redactions.remove_flag(name);
redactions.remove_header(name);
redactions.remove_query_key(name);
}
}
Ok(redactions)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetachSequence(Vec<u8>);
impl DetachSequence {
#[must_use]
pub fn bytes(&self) -> &[u8] {
&self.0
}
}
impl<'de> Deserialize<'de> for DetachSequence {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
parse_detach_sequence(&raw).map_err(serde::de::Error::custom)
}
}
fn parse_detach_sequence(raw: &str) -> std::result::Result<DetachSequence, String> {
let mut bytes = Vec::new();
for token in raw.split_whitespace() {
let token_bytes = parse_detach_sequence_token(token)?;
bytes.extend(token_bytes);
}
if bytes.len() < 2 {
return Err(
"detach sequence must contain at least two key presses (for example: \"ctrl-] d\")"
.to_string(),
);
}
Ok(DetachSequence(bytes))
}
fn parse_detach_sequence_token(token: &str) -> std::result::Result<Vec<u8>, String> {
let normalized = token.trim().to_ascii_lowercase();
if normalized.is_empty() {
return Err("detach sequence contains an empty token".to_string());
}
if let Some(ctrl_suffix) = normalized.strip_prefix("ctrl-") {
let control = parse_control_token(ctrl_suffix)?;
return Ok(vec![control]);
}
match normalized.as_str() {
"esc" | "escape" => Ok(vec![0x1b]),
"tab" => Ok(vec![b'\t']),
"enter" | "return" => Ok(vec![b'\r']),
"space" => Ok(vec![b' ']),
_ => parse_literal_token(&normalized),
}
}
fn parse_control_token(token: &str) -> std::result::Result<u8, String> {
if token.len() != 1 {
return Err(format!(
"unsupported control key token \"ctrl-{token}\"; use ctrl-<single-char>"
));
}
let byte = token.as_bytes()[0];
if !(0x40..=0x5f).contains(&byte.to_ascii_uppercase()) {
return Err(format!("unsupported control key token \"ctrl-{token}\""));
}
Ok(byte.to_ascii_uppercase() & 0x1f)
}
fn parse_literal_token(token: &str) -> std::result::Result<Vec<u8>, String> {
if token.len() == 1 {
return Ok(vec![token.as_bytes()[0]]);
}
Err(format!(
"unsupported detach key token \"{token}\"; use a single character or ctrl-<char>"
))
}
#[derive(Debug, Default, Deserialize)]
pub struct UserConfigMeta {
#[serde(default)]
pub version: u64,
}
#[derive(Debug, Default, Deserialize)]
pub struct UserOverrides {
#[serde(default)]
pub sensitive_paths: HashMap<String, PathOverrideInfo>,
#[serde(default)]
pub commands: HashMap<String, CommandOverrideInfo>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PathOverrideInfo {
pub reason: String,
pub acknowledged: Option<String>,
#[serde(default)]
pub access: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CommandOverrideInfo {
pub reason: String,
pub acknowledged: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
pub struct UserExtensions {
#[serde(default)]
pub sensitive_paths: HashMap<String, Vec<String>>,
#[serde(default)]
pub dangerous_commands: HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TrustedKeyInfo {
pub name: String,
#[serde(default)]
pub fingerprint: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RollbackSettings {
#[serde(default = "default_max_sessions")]
pub max_sessions: usize,
#[serde(default = "default_max_storage_gb")]
pub max_storage_gb: f64,
#[serde(default = "default_max_snapshots")]
pub max_snapshots: u32,
#[serde(default = "default_stale_grace_hours")]
pub stale_grace_hours: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UpdateSettings {
#[serde(default = "default_true")]
pub check: bool,
}
fn default_true() -> bool {
true
}
impl Default for UpdateSettings {
fn default() -> Self {
Self {
check: default_true(),
}
}
}
fn default_max_sessions() -> usize {
10
}
fn default_max_storage_gb() -> f64 {
5.0
}
fn default_max_snapshots() -> u32 {
100
}
fn default_stale_grace_hours() -> u64 {
24
}
impl Default for RollbackSettings {
fn default() -> Self {
Self {
max_sessions: default_max_sessions(),
max_storage_gb: default_max_storage_gb(),
max_snapshots: default_max_snapshots(),
stale_grace_hours: default_stale_grace_hours(),
}
}
}
pub fn load_user_config() -> Result<Option<UserConfig>> {
let config_path = user_config_path()?;
if !config_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&config_path).map_err(|e| NonoError::ConfigRead {
path: config_path.clone(),
source: e,
})?;
let config: UserConfig = toml::from_str(&content)
.map_err(|e| NonoError::ConfigParse(format!("Failed to parse user config: {}", e)))?;
Ok(Some(config))
}
pub fn user_config_path() -> Result<PathBuf> {
let config_dir = super::user_config_dir().ok_or_else(|| {
NonoError::ConfigParse("Could not determine user config directory".to_string())
})?;
Ok(config_dir.join(USER_CONFIG_FILE))
}
pub fn user_profiles_dir() -> Result<PathBuf> {
let config_dir = super::user_config_dir().ok_or_else(|| {
NonoError::ConfigParse("Could not determine user config directory".to_string())
})?;
Ok(config_dir.join("profiles"))
}
pub fn user_trusted_keys_dir() -> Result<PathBuf> {
let config_dir = super::user_config_dir().ok_or_else(|| {
NonoError::ConfigParse("Could not determine user config directory".to_string())
})?;
Ok(config_dir.join("trusted-keys"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_user_config() {
let toml = r#"
[meta]
version = 1
[overrides.sensitive_paths]
"~/.ssh/id_rsa.pub" = { reason = "Public key for git", acknowledged = "2025-01-15", access = "read" }
[overrides.commands]
pip = { reason = "Python development", acknowledged = "2025-01-15" }
[extensions.sensitive_paths]
custom = ["~/work/secrets"]
[extensions.dangerous_commands]
custom = ["my-dangerous-tool"]
[trusted_keys]
alice = { name = "Alice", fingerprint = "abc123" }
"#;
let config: UserConfig = toml::from_str(toml).expect("Failed to parse");
assert_eq!(config.meta.version, 1);
assert!(
config
.overrides
.sensitive_paths
.contains_key("~/.ssh/id_rsa.pub")
);
let ssh_override = &config.overrides.sensitive_paths["~/.ssh/id_rsa.pub"];
assert_eq!(ssh_override.reason, "Public key for git");
assert_eq!(ssh_override.access.as_deref(), Some("read"));
assert!(config.overrides.commands.contains_key("pip"));
assert!(config.extensions.sensitive_paths.contains_key("custom"));
assert!(config.extensions.dangerous_commands.contains_key("custom"));
assert!(config.trusted_keys.contains_key("alice"));
}
#[test]
fn test_empty_user_config() {
let toml = "";
let config: UserConfig = toml::from_str(toml).expect("Failed to parse empty config");
assert!(config.overrides.sensitive_paths.is_empty());
assert!(config.overrides.commands.is_empty());
assert!(config.ui.detach_sequence.is_none());
assert!(config.redaction.extra_flags.is_empty());
}
#[test]
fn test_redaction_settings_add_extra_patterns() {
let toml = r#"
[redaction]
extra_flags = ["--private-token"]
extra_headers = ["Private-Token"]
extra_query_keys = ["signature"]
"#;
let config: UserConfig = toml::from_str(toml).expect("Failed to parse");
let policy = config
.redaction
.to_scrub_policy()
.expect("Failed to build policy");
let diff = policy.diff_from_secure_default();
assert_eq!(diff.added_flags, vec!["--private-token".to_string()]);
assert_eq!(diff.added_headers, vec!["private-token".to_string()]);
assert_eq!(diff.added_query_keys, vec!["signature".to_string()]);
}
#[test]
fn test_redaction_settings_require_unsafe_override_for_removals() {
let toml = r#"
[redaction]
allow_unredacted_defaults = ["state"]
"#;
let config: UserConfig = toml::from_str(toml).expect("Failed to parse");
let err = config
.redaction
.to_scrub_policy()
.err()
.map(|e| e.to_string())
.unwrap_or_default();
assert!(err.contains("unsafe_redaction_overrides = true"));
}
#[test]
fn test_redaction_settings_can_remove_defaults_when_unsafe_enabled() {
let toml = r#"
[redaction]
unsafe_redaction_overrides = true
allow_unredacted_defaults = ["state"]
"#;
let config: UserConfig = toml::from_str(toml).expect("Failed to parse");
let policy = config
.redaction
.to_scrub_policy()
.expect("Failed to build policy");
let diff = policy.diff_from_secure_default();
assert_eq!(diff.removed_query_keys, vec!["state".to_string()]);
}
#[test]
fn test_rollback_settings_defaults() {
let toml = "";
let config: UserConfig = toml::from_str(toml).expect("Failed to parse");
assert_eq!(config.rollback.max_sessions, 10);
assert!((config.rollback.max_storage_gb - 5.0).abs() < f64::EPSILON);
assert_eq!(config.rollback.max_snapshots, 100);
assert_eq!(config.rollback.stale_grace_hours, 24);
}
#[test]
fn test_rollback_settings_custom() {
let toml = r#"
[rollback]
max_sessions = 20
max_storage_gb = 10.0
max_snapshots = 50
stale_grace_hours = 48
"#;
let config: UserConfig = toml::from_str(toml).expect("Failed to parse");
assert_eq!(config.rollback.max_sessions, 20);
assert!((config.rollback.max_storage_gb - 10.0).abs() < f64::EPSILON);
assert_eq!(config.rollback.max_snapshots, 50);
assert_eq!(config.rollback.stale_grace_hours, 48);
}
#[test]
fn test_ui_detach_sequence_parses_control_prefix() {
let toml = r#"
[ui]
detach_sequence = "ctrl-] d"
"#;
let config: UserConfig = toml::from_str(toml).expect("Failed to parse");
let detach_sequence = config
.ui
.detach_sequence
.as_ref()
.map(DetachSequence::bytes)
.unwrap_or(&[]);
assert_eq!(detach_sequence, &[0x1d, b'd']);
}
#[test]
fn test_ui_detach_sequence_rejects_single_key() {
let toml = r#"
[ui]
detach_sequence = "ctrl-]"
"#;
let err = toml::from_str::<UserConfig>(toml)
.err()
.map(|e| e.to_string())
.unwrap_or_default();
assert!(err.contains("detach sequence must contain at least two key presses"));
}
}