feat(add,update): key:=json typed values, nested path for meta/secrets, bump 0.7.4
Some checks failed
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m53s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m3s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 49s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
voson
2026-03-19 14:27:04 +08:00
parent 5a5867adc1
commit efa76cae55
7 changed files with 386 additions and 72 deletions

View File

@@ -7,11 +7,13 @@ use crate::crypto;
use crate::db;
use crate::output::OutputMode;
/// Parse "key=value" or "key:=<json>" entries.
/// - `key=value` → stores the literal string `value`
/// - `key:=<json>` → parses `<json>` as a typed JSON value (number, bool, null, array, object)
/// - `value=@file` → reads the file content as a string (only for `=` form)
pub(crate) fn parse_kv(entry: &str) -> Result<(String, Value)> {
/// Parse secret / metadata entries into a nested key path and JSON value.
/// - `key=value` → stores the literal string `value`
/// - `key:=<json>` → parses `<json>` as a typed JSON value
/// - `key=@file` → reads the file content as a string
/// - `a:b=value` → writes nested fields: `{ "a": { "b": "value" } }`
/// - `a:b@./file.txt` → shorthand for nested file reads without manual JSON escaping
pub(crate) fn parse_kv(entry: &str) -> Result<(Vec<String>, Value)> {
// Typed JSON form: key:=<json>
if let Some((key, json_str)) = entry.split_once(":=") {
let val: Value = serde_json::from_str(json_str).map_err(|e| {
@@ -21,36 +23,141 @@ pub(crate) fn parse_kv(entry: &str) -> Result<(String, Value)> {
e
)
})?;
return Ok((key.to_string(), val));
return Ok((parse_key_path(key)?, val));
}
// Plain string form: key=value or key=@file
let (key, raw_val) = entry.split_once('=').ok_or_else(|| {
anyhow::anyhow!(
"Invalid format '{}'. Expected: key=value, key=@file, or key:=<json>",
entry
)
})?;
if let Some((key, raw_val)) = entry.split_once('=') {
let value = if let Some(path) = raw_val.strip_prefix('@') {
fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
} else {
raw_val.to_string()
};
let value = if let Some(path) = raw_val.strip_prefix('@') {
fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
} else {
raw_val.to_string()
};
return Ok((parse_key_path(key)?, Value::String(value)));
}
Ok((key.to_string(), Value::String(value)))
// Shorthand file form: nested:key@file
if let Some((key, path)) = entry.split_once('@') {
let value = fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?;
return Ok((parse_key_path(key)?, Value::String(value)));
}
anyhow::bail!(
"Invalid format '{}'. Expected: key=value, key=@file, nested:key@file, or key:=<json>",
entry
)
}
pub(crate) fn build_json(entries: &[String]) -> Result<Value> {
let mut map = Map::new();
for entry in entries {
let (key, value) = parse_kv(entry)?;
map.insert(key, value);
let (path, value) = parse_kv(entry)?;
insert_path(&mut map, &path, value)?;
}
Ok(Value::Object(map))
}
pub(crate) fn key_path_to_string(path: &[String]) -> String {
path.join(":")
}
pub(crate) fn collect_key_paths(entries: &[String]) -> Result<Vec<String>> {
entries
.iter()
.map(|entry| parse_kv(entry).map(|(path, _)| key_path_to_string(&path)))
.collect()
}
pub(crate) fn collect_field_paths(entries: &[String]) -> Result<Vec<String>> {
entries
.iter()
.map(|entry| parse_key_path(entry).map(|path| key_path_to_string(&path)))
.collect()
}
pub(crate) fn parse_key_path(key: &str) -> Result<Vec<String>> {
let path: Vec<String> = key
.split(':')
.map(str::trim)
.map(ToOwned::to_owned)
.collect();
if path.is_empty() || path.iter().any(|part| part.is_empty()) {
anyhow::bail!(
"Invalid key path '{}'. Use non-empty segments like 'credentials:content'.",
key
);
}
Ok(path)
}
pub(crate) fn insert_path(
map: &mut Map<String, Value>,
path: &[String],
value: Value,
) -> Result<()> {
if path.is_empty() {
anyhow::bail!("Key path cannot be empty");
}
if path.len() == 1 {
map.insert(path[0].clone(), value);
return Ok(());
}
let head = path[0].clone();
let tail = &path[1..];
match map.entry(head.clone()) {
serde_json::map::Entry::Vacant(entry) => {
let mut child = Map::new();
insert_path(&mut child, tail, value)?;
entry.insert(Value::Object(child));
}
serde_json::map::Entry::Occupied(mut entry) => match entry.get_mut() {
Value::Object(child) => insert_path(child, tail, value)?,
_ => {
anyhow::bail!(
"Cannot set nested key '{}' because '{}' is already a non-object value",
key_path_to_string(path),
head
);
}
},
}
Ok(())
}
pub(crate) fn remove_path(map: &mut Map<String, Value>, path: &[String]) -> Result<bool> {
if path.is_empty() {
anyhow::bail!("Key path cannot be empty");
}
if path.len() == 1 {
return Ok(map.remove(&path[0]).is_some());
}
let Some(value) = map.get_mut(&path[0]) else {
return Ok(false);
};
let Value::Object(child) = value else {
return Ok(false);
};
let removed = remove_path(child, &path[1..])?;
if child.is_empty() {
map.remove(&path[0]);
}
Ok(removed)
}
pub struct AddArgs<'a> {
pub namespace: &'a str,
pub kind: &'a str,
@@ -68,16 +175,8 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
tracing::debug!(args.namespace, args.kind, args.name, "upserting record");
let meta_keys: Vec<&str> = args
.meta_entries
.iter()
.filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
.collect();
let secret_keys: Vec<&str> = args
.secret_entries
.iter()
.filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
.collect();
let meta_keys = collect_key_paths(args.meta_entries)?;
let secret_keys = collect_key_paths(args.secret_entries)?;
let mut tx = pool.begin().await?;
@@ -191,3 +290,77 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
Ok(())
}
#[cfg(test)]
mod tests {
use super::{build_json, key_path_to_string, parse_kv, remove_path};
use serde_json::Value;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_file_path(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("secrets-{name}-{nanos}.txt"))
}
#[test]
fn parse_nested_file_shorthand() {
let path = temp_file_path("ssh-key");
fs::write(&path, "line1\nline2\n").expect("should write temp file");
let entry = format!("credentials:content@{}", path.display());
let (path_parts, value) = parse_kv(&entry).expect("should parse nested file shorthand");
assert_eq!(key_path_to_string(&path_parts), "credentials:content");
assert_eq!(value, serde_json::Value::String("line1\nline2\n".into()));
fs::remove_file(path).expect("should remove temp file");
}
#[test]
fn build_nested_json_from_mixed_entries() {
let payload = vec![
"credentials:type=ssh".to_string(),
"credentials:enabled:=true".to_string(),
"username=root".to_string(),
];
let value = build_json(&payload).expect("should build nested json");
assert_eq!(
value,
serde_json::json!({
"credentials": {
"type": "ssh",
"enabled": true
},
"username": "root"
})
);
}
#[test]
fn remove_nested_path_prunes_empty_parents() {
let mut value = serde_json::json!({
"credentials": {
"content": "pem-data"
},
"username": "root"
});
let map = match &mut value {
Value::Object(map) => map,
_ => panic!("expected object"),
};
let removed = remove_path(map, &["credentials".to_string(), "content".to_string()])
.expect("should remove nested field");
assert!(removed);
assert_eq!(value, serde_json::json!({ "username": "root" }));
}
}