Skip to main content

oboron_cli_core/
profile.rs

1//! Per-profile files at `~/.oboron/profiles/<NAME>.json`.
2
3use anyhow::{anyhow, bail, Context, Result};
4use serde_json::Value;
5use std::fs;
6use std::path::PathBuf;
7
8use crate::config::{read_json_or_empty, write_json};
9use crate::key::{normalize_key_classify, normalize_key_to_hex, KeyFormat};
10use crate::paths::{backup_dir, profile_dir, profile_path};
11
12#[derive(Debug, Default, Clone)]
13pub struct KeyProfile {
14    /// The key as stored. May be hex (128 chars, canonical) or base64
15    /// (86 chars, legacy/deprecated).
16    pub key: Option<String>,
17}
18
19/// Validate a profile name (no path traversal; alphanumeric + `-` + `_` only).
20pub fn validate_profile_name(name: &str) -> Result<()> {
21    if name.is_empty() {
22        bail!("profile name is empty");
23    }
24    if name.contains('/') || name.contains('\\') || name.contains("..") {
25        bail!("profile name '{name}' contains invalid path characters");
26    }
27    if !name
28        .chars()
29        .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
30    {
31        bail!(
32            "profile name '{name}' contains invalid characters; \
33             only alphanumeric, '-' and '_' allowed"
34        );
35    }
36    Ok(())
37}
38
39pub fn load_profile(name: &str) -> Result<KeyProfile> {
40    let path = profile_path(name)?;
41    if !path.exists() {
42        bail!("profile '{name}' not found (looked at {})", path.display());
43    }
44    let v = crate::config::read_json(&path)?;
45    let key = v.get("key").and_then(Value::as_str).map(str::to_string);
46    Ok(KeyProfile { key })
47}
48
49/// Load `<name>`'s key and return it as a 128-char hex string.
50pub fn load_profile_key_as_hex(name: &str) -> Result<String> {
51    let p = load_profile(name)?;
52    let key = p
53        .key
54        .ok_or_else(|| anyhow!("profile '{name}' has no `key` field"))?;
55    normalize_key_to_hex(&key).with_context(|| format!("invalid key in profile '{name}'"))
56}
57
58/// Result of [`load_profile_key`]: the canonical hex key plus, if a
59/// migration happened, the path to the backup of the pre-migration
60/// profile file.
61#[derive(Debug, Clone)]
62pub struct LoadedKey {
63    pub hex: String,
64    /// `Some(path)` if the stored profile had a legacy base64 key
65    /// that we just rewrote in place to canonical hex; `None`
66    /// otherwise. Callers should display this to the user as a
67    /// migration notice.
68    pub migrated_backup: Option<PathBuf>,
69}
70
71/// Load `<name>`'s key as canonical hex, **auto-migrating** any
72/// legacy base64 profile in place.
73///
74/// If the stored key was 86-char base64, this rewrites the profile
75/// file with the equivalent 128-char hex (creating a backup of the
76/// pre-migration file). The returned [`LoadedKey::migrated_backup`]
77/// is `Some(path)` in that case, so callers can print a notice.
78///
79/// Used by the `ob` and `obc` CLIs during the base64 → hex transition;
80/// once base64 support is removed before oboron 1.0, this function
81/// becomes equivalent to [`load_profile_key_as_hex`].
82pub fn load_profile_key(name: &str) -> Result<LoadedKey> {
83    let p = load_profile(name)?;
84    let key = p
85        .key
86        .ok_or_else(|| anyhow!("profile '{name}' has no `key` field"))?;
87    let (hex, fmt) = normalize_key_classify(&key)
88        .with_context(|| format!("invalid key in profile '{name}'"))?;
89    let migrated_backup = if fmt == KeyFormat::LegacyBase64 {
90        // Rewrite the profile with the canonical hex form.
91        save_profile(
92            name,
93            &KeyProfile {
94                key: Some(hex.clone()),
95            },
96        )?
97    } else {
98        None
99    };
100    Ok(LoadedKey {
101        hex,
102        migrated_backup,
103    })
104}
105
106/// Save a profile. Preserves unknown fields; backs up the existing
107/// file (if any) before overwriting. Returns the backup path, if one
108/// was created.
109pub fn save_profile(name: &str, profile: &KeyProfile) -> Result<Option<PathBuf>> {
110    validate_profile_name(name)?;
111    let backup = backup_profile(name)?;
112    let path = profile_path(name)?;
113    let mut v = read_json_or_empty(&path)?;
114    let obj = v
115        .as_object_mut()
116        .ok_or_else(|| anyhow!("{} is not a JSON object", path.display()))?;
117    if let Some(k) = &profile.key {
118        obj.insert("key".into(), Value::String(k.clone()));
119    }
120    write_json(&path, &v)?;
121    Ok(backup)
122}
123
124/// List all profile names, sorted.
125pub fn list_profiles() -> Result<Vec<String>> {
126    let dir = profile_dir()?;
127    if !dir.exists() {
128        return Ok(Vec::new());
129    }
130    let mut names = Vec::new();
131    for entry in fs::read_dir(&dir).context("read profile directory")? {
132        let entry = entry.context("read profile entry")?;
133        let path = entry.path();
134        if path.extension().and_then(|s| s.to_str()) == Some("json") {
135            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
136                names.push(name.to_string());
137            }
138        }
139    }
140    names.sort();
141    Ok(names)
142}
143
144pub fn delete_profile(name: &str) -> Result<Option<PathBuf>> {
145    validate_profile_name(name)?;
146    let path = profile_path(name)?;
147    if !path.exists() {
148        bail!("profile '{name}' does not exist");
149    }
150    let backup = backup_profile(name)?;
151    fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
152    Ok(backup)
153}
154
155pub fn rename_profile(old_name: &str, new_name: &str) -> Result<Option<PathBuf>> {
156    validate_profile_name(old_name)?;
157    validate_profile_name(new_name)?;
158    let old_path = profile_path(old_name)?;
159    let new_path = profile_path(new_name)?;
160    if !old_path.exists() {
161        bail!("profile '{old_name}' does not exist");
162    }
163    if new_path.exists() {
164        bail!("profile '{new_name}' already exists — cannot rename onto it");
165    }
166    let backup = backup_profile(old_name)?;
167    fs::rename(&old_path, &new_path).with_context(|| {
168        format!("rename {} → {}", old_path.display(), new_path.display())
169    })?;
170    Ok(backup)
171}
172
173/// Back up a profile file (if it exists). Returns the backup path, or
174/// `None` if the source didn't exist.
175fn backup_profile(name: &str) -> Result<Option<PathBuf>> {
176    let path = profile_path(name)?;
177    if !path.exists() {
178        return Ok(None);
179    }
180    let ts = std::time::SystemTime::now()
181        .duration_since(std::time::UNIX_EPOCH)
182        .map_err(|e| anyhow!("system time error: {e}"))?
183        .as_secs();
184    let backup_path = backup_dir()?.join(format!("{name}-{ts}.json"));
185    if let Some(parent) = backup_path.parent() {
186        fs::create_dir_all(parent).context("create backup directory")?;
187    }
188    fs::copy(&path, &backup_path)
189        .with_context(|| format!("backup profile '{name}'"))?;
190    Ok(Some(backup_path))
191}