oboron_cli_core/
profile.rs1use 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 pub key: Option<String>,
17}
18
19pub 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
49pub 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#[derive(Debug, Clone)]
62pub struct LoadedKey {
63 pub hex: String,
64 pub migrated_backup: Option<PathBuf>,
69}
70
71pub 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 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
106pub 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
124pub 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
173fn 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}