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
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:
@@ -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" }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ use serde_json::{Map, Value, json};
|
||||
use sqlx::{FromRow, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::add::parse_kv;
|
||||
use super::add::{
|
||||
collect_field_paths, collect_key_paths, insert_path, parse_key_path, parse_kv, remove_path,
|
||||
};
|
||||
use crate::crypto;
|
||||
use crate::db;
|
||||
use crate::output::OutputMode;
|
||||
@@ -89,11 +91,12 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
_ => Map::new(),
|
||||
};
|
||||
for entry in args.meta_entries {
|
||||
let (key, value) = parse_kv(entry)?;
|
||||
meta_map.insert(key, value);
|
||||
let (path, value) = parse_kv(entry)?;
|
||||
insert_path(&mut meta_map, &path, value)?;
|
||||
}
|
||||
for key in args.remove_meta {
|
||||
meta_map.remove(key);
|
||||
let path = parse_key_path(key)?;
|
||||
remove_path(&mut meta_map, &path)?;
|
||||
}
|
||||
let metadata = Value::Object(meta_map);
|
||||
|
||||
@@ -108,11 +111,12 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
_ => Map::new(),
|
||||
};
|
||||
for entry in args.secret_entries {
|
||||
let (key, value) = parse_kv(entry)?;
|
||||
enc_map.insert(key, value);
|
||||
let (path, value) = parse_kv(entry)?;
|
||||
insert_path(&mut enc_map, &path, value)?;
|
||||
}
|
||||
for key in args.remove_secrets {
|
||||
enc_map.remove(key);
|
||||
let path = parse_key_path(key)?;
|
||||
remove_path(&mut enc_map, &path)?;
|
||||
}
|
||||
let secret_json = Value::Object(enc_map);
|
||||
let encrypted_bytes = crypto::encrypt_json(master_key, &secret_json)?;
|
||||
@@ -148,16 +152,10 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
);
|
||||
}
|
||||
|
||||
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 remove_meta_keys = collect_field_paths(args.remove_meta)?;
|
||||
let secret_keys = collect_key_paths(args.secret_entries)?;
|
||||
let remove_secret_keys = collect_field_paths(args.remove_secrets)?;
|
||||
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
@@ -169,9 +167,9 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
"add_tags": args.add_tags,
|
||||
"remove_tags": args.remove_tags,
|
||||
"meta_keys": meta_keys,
|
||||
"remove_meta": args.remove_meta,
|
||||
"remove_meta": remove_meta_keys,
|
||||
"secret_keys": secret_keys,
|
||||
"remove_secrets": args.remove_secrets,
|
||||
"remove_secrets": remove_secret_keys,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -186,9 +184,9 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
"add_tags": args.add_tags,
|
||||
"remove_tags": args.remove_tags,
|
||||
"meta_keys": meta_keys,
|
||||
"remove_meta": args.remove_meta,
|
||||
"remove_meta": remove_meta_keys,
|
||||
"secret_keys": secret_keys,
|
||||
"remove_secrets": args.remove_secrets,
|
||||
"remove_secrets": remove_secret_keys,
|
||||
});
|
||||
|
||||
match args.output {
|
||||
@@ -210,13 +208,13 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
println!(" +metadata: {}", meta_keys.join(", "));
|
||||
}
|
||||
if !args.remove_meta.is_empty() {
|
||||
println!(" -metadata: {}", args.remove_meta.join(", "));
|
||||
println!(" -metadata: {}", remove_meta_keys.join(", "));
|
||||
}
|
||||
if !args.secret_entries.is_empty() {
|
||||
println!(" +secrets: {}", secret_keys.join(", "));
|
||||
}
|
||||
if !args.remove_secrets.is_empty() {
|
||||
println!(" -secrets: {}", args.remove_secrets.join(", "));
|
||||
println!(" -secrets: {}", remove_secret_keys.join(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user