Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m34s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m3s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m15s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- Drop field_type, value_len from secrets and secrets_history tables - Remove infer_field_type, compute_value_len from add.rs - Simplify search output to field names only - Update AGENTS.md, README.md documentation Bump version to 0.9.4 Made-with: Cursor
212 lines
7.6 KiB
Rust
212 lines
7.6 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use std::collections::BTreeMap;
|
|
use uuid::Uuid;
|
|
|
|
/// A top-level entry (server, service, key, …).
|
|
/// Sensitive fields are stored separately in `secrets`.
|
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
|
pub struct Entry {
|
|
pub id: Uuid,
|
|
pub namespace: String,
|
|
pub kind: String,
|
|
pub name: String,
|
|
pub tags: Vec<String>,
|
|
pub metadata: Value,
|
|
pub version: i64,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// A single encrypted field belonging to an Entry.
|
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
|
pub struct SecretField {
|
|
pub id: Uuid,
|
|
pub entry_id: Uuid,
|
|
pub field_name: String,
|
|
/// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag
|
|
pub encrypted: Vec<u8>,
|
|
pub version: i64,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
// ── Internal query row types (shared across commands) ─────────────────────────
|
|
|
|
/// Minimal entry row fetched for write operations (add / update / delete / rollback).
|
|
#[derive(Debug, sqlx::FromRow)]
|
|
pub struct EntryRow {
|
|
pub id: Uuid,
|
|
pub version: i64,
|
|
pub tags: Vec<String>,
|
|
pub metadata: Value,
|
|
}
|
|
|
|
/// Minimal secret field row fetched before snapshots or cascade deletes.
|
|
#[derive(Debug, sqlx::FromRow)]
|
|
pub struct SecretFieldRow {
|
|
pub id: Uuid,
|
|
pub field_name: String,
|
|
pub encrypted: Vec<u8>,
|
|
}
|
|
|
|
// ── Export / Import types ──────────────────────────────────────────────────────
|
|
|
|
/// Supported file formats for export/import.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub enum ExportFormat {
|
|
Json,
|
|
Toml,
|
|
Yaml,
|
|
}
|
|
|
|
impl ExportFormat {
|
|
/// Infer format from file extension (.json / .toml / .yaml / .yml).
|
|
pub fn from_extension(path: &str) -> anyhow::Result<Self> {
|
|
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
|
|
Self::from_str(&ext).map_err(|_| {
|
|
anyhow::anyhow!(
|
|
"Cannot infer format from extension '.{}'. Use --format json|toml|yaml",
|
|
ext
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Parse from --format CLI value.
|
|
pub fn from_str(s: &str) -> anyhow::Result<Self> {
|
|
match s.to_lowercase().as_str() {
|
|
"json" => Ok(Self::Json),
|
|
"toml" => Ok(Self::Toml),
|
|
"yaml" | "yml" => Ok(Self::Yaml),
|
|
other => anyhow::bail!("Unknown format '{}'. Expected: json, toml, or yaml", other),
|
|
}
|
|
}
|
|
|
|
/// Serialize ExportData to a string in this format.
|
|
pub fn serialize(&self, data: &ExportData) -> anyhow::Result<String> {
|
|
match self {
|
|
Self::Json => Ok(serde_json::to_string_pretty(data)?),
|
|
Self::Toml => {
|
|
let toml_val = json_to_toml_value(&serde_json::to_value(data)?)?;
|
|
toml::to_string_pretty(&toml_val)
|
|
.map_err(|e| anyhow::anyhow!("TOML serialization failed: {}", e))
|
|
}
|
|
Self::Yaml => serde_yaml::to_string(data)
|
|
.map_err(|e| anyhow::anyhow!("YAML serialization failed: {}", e)),
|
|
}
|
|
}
|
|
|
|
/// Deserialize ExportData from a string in this format.
|
|
pub fn deserialize(&self, content: &str) -> anyhow::Result<ExportData> {
|
|
match self {
|
|
Self::Json => Ok(serde_json::from_str(content)?),
|
|
Self::Toml => {
|
|
let toml_val: toml::Value = toml::from_str(content)
|
|
.map_err(|e| anyhow::anyhow!("TOML parse error: {}", e))?;
|
|
let json_val = toml_to_json_value(&toml_val);
|
|
Ok(serde_json::from_value(json_val)?)
|
|
}
|
|
Self::Yaml => serde_yaml::from_str(content)
|
|
.map_err(|e| anyhow::anyhow!("YAML parse error: {}", e)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Top-level structure for export/import files.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct ExportData {
|
|
pub version: u32,
|
|
pub exported_at: String,
|
|
pub entries: Vec<ExportEntry>,
|
|
}
|
|
|
|
/// A single entry with decrypted secrets for export/import.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct ExportEntry {
|
|
pub namespace: String,
|
|
pub kind: String,
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub tags: Vec<String>,
|
|
#[serde(default)]
|
|
pub metadata: Value,
|
|
/// Decrypted secret fields. None means no secrets in this export (--no-secrets).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub secrets: Option<BTreeMap<String, Value>>,
|
|
}
|
|
|
|
// ── TOML ↔ JSON value conversion ──────────────────────────────────────────────
|
|
|
|
/// Convert a serde_json Value to a toml Value.
|
|
/// `null` values are filtered out (TOML does not support null).
|
|
/// Mixed-type arrays are serialised as JSON strings.
|
|
pub fn json_to_toml_value(v: &Value) -> anyhow::Result<toml::Value> {
|
|
match v {
|
|
Value::Null => anyhow::bail!("TOML does not support null values"),
|
|
Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
|
|
Value::Number(n) => {
|
|
if let Some(i) = n.as_i64() {
|
|
Ok(toml::Value::Integer(i))
|
|
} else if let Some(f) = n.as_f64() {
|
|
Ok(toml::Value::Float(f))
|
|
} else {
|
|
anyhow::bail!("unsupported number: {}", n)
|
|
}
|
|
}
|
|
Value::String(s) => Ok(toml::Value::String(s.clone())),
|
|
Value::Array(arr) => {
|
|
let items: anyhow::Result<Vec<toml::Value>> =
|
|
arr.iter().map(json_to_toml_value).collect();
|
|
match items {
|
|
Ok(vals) => Ok(toml::Value::Array(vals)),
|
|
Err(e) => {
|
|
tracing::debug!(error = %e, "mixed-type array; falling back to JSON string");
|
|
Ok(toml::Value::String(serde_json::to_string(v)?))
|
|
}
|
|
}
|
|
}
|
|
Value::Object(map) => {
|
|
let mut toml_map = toml::map::Map::new();
|
|
for (k, val) in map {
|
|
if val.is_null() {
|
|
// Skip null entries
|
|
continue;
|
|
}
|
|
match json_to_toml_value(val) {
|
|
Ok(tv) => {
|
|
toml_map.insert(k.clone(), tv);
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!(key = %k, error = %e, "field not representable in TOML; falling back to JSON string");
|
|
toml_map
|
|
.insert(k.clone(), toml::Value::String(serde_json::to_string(val)?));
|
|
}
|
|
}
|
|
}
|
|
Ok(toml::Value::Table(toml_map))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Convert a toml Value back to a serde_json Value.
|
|
pub fn toml_to_json_value(v: &toml::Value) -> Value {
|
|
match v {
|
|
toml::Value::Boolean(b) => Value::Bool(*b),
|
|
toml::Value::Integer(i) => Value::Number((*i).into()),
|
|
toml::Value::Float(f) => serde_json::Number::from_f64(*f)
|
|
.map(Value::Number)
|
|
.unwrap_or(Value::Null),
|
|
toml::Value::String(s) => Value::String(s.clone()),
|
|
toml::Value::Datetime(dt) => Value::String(dt.to_string()),
|
|
toml::Value::Array(arr) => Value::Array(arr.iter().map(toml_to_json_value).collect()),
|
|
toml::Value::Table(map) => {
|
|
let obj: serde_json::Map<String, Value> = map
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), toml_to_json_value(v)))
|
|
.collect();
|
|
Value::Object(obj)
|
|
}
|
|
}
|
|
}
|