From 12aec6675a9f8ac8e2a3e5e37856da85b03cfc7f Mon Sep 17 00:00:00 2001 From: voson Date: Thu, 19 Mar 2026 15:29:26 +0800 Subject: [PATCH] feat: add export/import commands for batch backup (JSON/TOML/YAML) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - export: filter by namespace/kind/name/tag/query, decrypt secrets, write to file or stdout - import: parse file, conflict check (error by default, --force to overwrite), --dry-run preview - Add ExportFormat enum, ExportData/ExportEntry in models.rs with TOML↔JSON conversion - Bump version to 0.9.0 Made-with: Cursor --- AGENTS.md | 71 +++++++++++ Cargo.lock | 22 +++- Cargo.toml | 3 +- README.md | 18 +++ src/commands/export_cmd.rs | 109 +++++++++++++++++ src/commands/import_cmd.rs | 233 +++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 2 + src/main.rs | 132 +++++++++++++++++++++ src/models.rs | 167 ++++++++++++++++++++++++++ 9 files changed, 755 insertions(+), 2 deletions(-) create mode 100644 src/commands/export_cmd.rs create mode 100644 src/commands/import_cmd.rs diff --git a/AGENTS.md b/AGENTS.md index 045f3cb..5ed062d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,8 @@ secrets/ rollback.rs # rollback / history 命令:按 entry_version 恢复 entry + secrets run.rs # inject / run 命令:逐字段解密 + key_ref 引用解析 upgrade.rs # upgrade 命令:检查、校验摘要并下载最新版本,自动替换二进制 + export_cmd.rs # export 命令:批量导出记录,支持 JSON/TOML/YAML,含解密明文 + import_cmd.rs # import 命令:批量导入记录,冲突检测,dry-run,重新加密写入 scripts/ release-check.sh # 发版前检查版本号/tag 是否重复,并执行 fmt/clippy/test setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets @@ -493,6 +495,75 @@ secrets upgrade --- +### export — 批量导出记录 + +将匹配的记录(含解密后的明文 secrets)导出到文件或 stdout。支持 JSON、TOML、YAML 三种格式,文件格式由扩展名自动推断。使用 `--no-secrets` 时无需主密钥。 + +```bash +# 参数说明 +# -n / --namespace refining | ricnsmart +# --kind server | service +# --name gitea | i-uf63f2uookgs5uxmrdyc +# --tag aliyun | production(可重复) +# -q / --query 模糊关键词 +# --file 输出文件路径,格式由扩展名推断(.json / .toml / .yaml / .yml) +# --format json | toml | yaml 显式指定格式(输出到 stdout 时必须指定) +# --no-secrets 不导出 secrets,无需主密钥 + +# 全量导出到 JSON 文件 +secrets export --file backup.json + +# 按 namespace 导出为 TOML +secrets export -n refining --file refining.toml + +# 按 kind 导出为 YAML +secrets export -n refining --kind service --file services.yaml + +# 按 tag 过滤导出 +secrets export --tag production --file prod.json + +# 模糊关键词导出 +secrets export -q mqtt --file mqtt.json + +# 仅导出 schema(不含 secrets,无需主密钥) +secrets export --no-secrets --file schema.json + +# 输出到 stdout(必须指定 --format) +secrets export -n refining --format yaml +secrets export --format json | jq '.' +``` + +--- + +### import — 批量导入记录 + +从导出文件读取记录并写入数据库,自动重新加密 secrets。支持 JSON、TOML、YAML 三种格式,文件格式由扩展名自动推断。 + +```bash +# 参数说明 +# 必选,输入文件路径(格式由扩展名推断) +# --force 冲突时覆盖已有记录(默认:报错并停止) +# --dry-run 预览将执行的操作,不写入数据库 +# -o / --output text | json | json-compact + +# 导入 JSON 文件(遇到已存在记录报错) +secrets import backup.json + +# 导入 TOML 文件,冲突时覆盖 +secrets import --force refining.toml + +# 导入 YAML 文件,冲突时覆盖 +secrets import --force services.yaml + +# 预览将执行的操作(不写入) +secrets import --dry-run backup.json + +# JSON 格式输出导入摘要 +secrets import backup.json -o json +``` + +--- + ### config — 配置管理(无需主密钥) ```bash diff --git a/Cargo.lock b/Cargo.lock index ca1cce2..8bb2037 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1836,7 +1836,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "secrets" -version = "0.8.0" +version = "0.9.0" dependencies = [ "aes-gcm", "anyhow", @@ -1853,6 +1853,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_yaml", "sha2", "sqlx", "tar", @@ -1982,6 +1983,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2681,6 +2695,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index fac46cd..c4b5b1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets" -version = "0.8.0" +version = "0.9.0" edition = "2024" [dependencies] @@ -19,6 +19,7 @@ self-replace = "1.5.0" semver = "1.0.27" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +serde_yaml = "0.9" sha2 = "0.10.9" sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] } tar = "0.4.44" diff --git a/README.md b/README.md index 54038c5..1309a2d 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,8 @@ secrets update --help secrets delete --help secrets config --help secrets upgrade --help # 检查并更新 CLI 版本 +secrets export --help # 批量导出(JSON/TOML/YAML) +secrets import --help # 批量导入(JSON/TOML/YAML) # ── search ────────────────────────────────────────────────────────────────── secrets search --summary --limit 20 # 发现概览 @@ -158,6 +160,20 @@ secrets config path # 打印配置文件路径 secrets upgrade --check # 仅检查是否有新版本 secrets upgrade # 下载、校验 SHA-256 并安装最新版(从 Gitea Release) +# ── export ──────────────────────────────────────────────────────────────────── +secrets export --file backup.json # 全量导出到 JSON +secrets export -n refining --file refining.toml # 按 namespace 导出为 TOML +secrets export -n refining --kind service --file svc.yaml # 按 kind 导出为 YAML +secrets export --tag production --file prod.json # 按 tag 过滤 +secrets export -q mqtt --file mqtt.json # 模糊搜索导出 +secrets export --no-secrets --file schema.json # 仅导出 schema(无需主密钥) +secrets export -n refining --format yaml # 输出到 stdout,指定格式 + +# ── import ──────────────────────────────────────────────────────────────────── +secrets import backup.json # 导入(冲突时报错) +secrets import --force refining.toml # 冲突时覆盖已有记录 +secrets import --dry-run backup.yaml # 预览将要执行的操作(不写入) + # ── 调试 ────────────────────────────────────────────────────────────────────── secrets --verbose search -q mqtt RUST_LOG=secrets=trace secrets search @@ -297,6 +313,8 @@ src/ rollback.rs # rollback / history:按 entry_version 恢复 run.rs # inject / run,逐字段解密 + key_ref 引用解析 upgrade.rs # 从 Gitea Release 自更新 + export_cmd.rs # export:批量导出,支持 JSON/TOML/YAML,含解密明文 + import_cmd.rs # import:批量导入,冲突检测,dry-run,重新加密写入 scripts/ setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets ``` diff --git a/src/commands/export_cmd.rs b/src/commands/export_cmd.rs new file mode 100644 index 0000000..6a655ef --- /dev/null +++ b/src/commands/export_cmd.rs @@ -0,0 +1,109 @@ +use anyhow::Result; +use sqlx::PgPool; +use std::collections::BTreeMap; +use std::io::Write; + +use crate::commands::search::{fetch_entries, fetch_secrets_for_entries}; +use crate::crypto; +use crate::models::{ExportData, ExportEntry, ExportFormat}; + +pub struct ExportArgs<'a> { + pub namespace: Option<&'a str>, + pub kind: Option<&'a str>, + pub name: Option<&'a str>, + pub tags: &'a [String], + pub query: Option<&'a str>, + /// Output file path. None means write to stdout. + pub file: Option<&'a str>, + /// Explicit format override (e.g. from --format flag). + pub format: Option<&'a str>, + /// When true, secrets are omitted and master_key is not used. + pub no_secrets: bool, +} + +pub async fn run(pool: &PgPool, args: ExportArgs<'_>, master_key: Option<&[u8; 32]>) -> Result<()> { + // Determine output format: --format > file extension > default JSON. + let format = if let Some(fmt_str) = args.format { + ExportFormat::from_str(fmt_str)? + } else if let Some(path) = args.file { + ExportFormat::from_extension(path).unwrap_or(ExportFormat::Json) + } else { + ExportFormat::Json + }; + + let entries = fetch_entries( + pool, + args.namespace, + args.kind, + args.name, + args.tags, + args.query, + ) + .await?; + + let entry_ids: Vec = entries.iter().map(|e| e.id).collect(); + + let secrets_map = if !args.no_secrets && !entry_ids.is_empty() { + fetch_secrets_for_entries(pool, &entry_ids).await? + } else { + std::collections::HashMap::new() + }; + + let key = if !args.no_secrets { master_key } else { None }; + + let mut export_entries: Vec = Vec::with_capacity(entries.len()); + for entry in &entries { + let secrets = if args.no_secrets { + None + } else { + let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]); + if fields.is_empty() { + Some(BTreeMap::new()) + } else { + let mk = + key.ok_or_else(|| anyhow::anyhow!("master key required to decrypt secrets"))?; + let mut map = BTreeMap::new(); + for f in fields { + let decrypted = crypto::decrypt_json(mk, &f.encrypted)?; + map.insert(f.field_name.clone(), decrypted); + } + Some(map) + } + }; + + export_entries.push(ExportEntry { + namespace: entry.namespace.clone(), + kind: entry.kind.clone(), + name: entry.name.clone(), + tags: entry.tags.clone(), + metadata: entry.metadata.clone(), + secrets, + }); + } + + let data = ExportData { + version: 1, + exported_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(), + entries: export_entries, + }; + + let serialized = format.serialize(&data)?; + + if let Some(path) = args.file { + std::fs::write(path, &serialized)?; + println!( + "Exported {} record(s) to {} ({:?})", + data.entries.len(), + path, + format + ); + } else { + std::io::stdout().write_all(serialized.as_bytes())?; + // Ensure trailing newline on stdout. + if !serialized.ends_with('\n') { + println!(); + } + } + + Ok(()) +} diff --git a/src/commands/import_cmd.rs b/src/commands/import_cmd.rs new file mode 100644 index 0000000..62a595f --- /dev/null +++ b/src/commands/import_cmd.rs @@ -0,0 +1,233 @@ +use anyhow::Result; +use serde_json::Value; +use sqlx::PgPool; +use std::collections::BTreeMap; + +use crate::commands::add::{self, AddArgs}; +use crate::models::ExportFormat; +use crate::output::OutputMode; + +pub struct ImportArgs<'a> { + pub file: &'a str, + /// Overwrite existing records when there is a conflict (upsert). + pub force: bool, + /// Check and preview operations without writing to the database. + pub dry_run: bool, + pub output: OutputMode, +} + +pub async fn run(pool: &PgPool, args: ImportArgs<'_>, master_key: &[u8; 32]) -> Result<()> { + let format = ExportFormat::from_extension(args.file)?; + let content = std::fs::read_to_string(args.file) + .map_err(|e| anyhow::anyhow!("Cannot read file '{}': {}", args.file, e))?; + let data = format.deserialize(&content)?; + + if data.version != 1 { + anyhow::bail!( + "Unsupported export version {}. Only version 1 is supported.", + data.version + ); + } + + let total = data.entries.len(); + let mut inserted = 0usize; + let mut skipped = 0usize; + let mut failed = 0usize; + + for entry in &data.entries { + // Check if record already exists. + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM entries \ + WHERE namespace = $1 AND kind = $2 AND name = $3)", + ) + .bind(&entry.namespace) + .bind(&entry.kind) + .bind(&entry.name) + .fetch_one(pool) + .await + .unwrap_or(false); + + if exists && !args.force { + let msg = format!( + "[{}/{}/{}] conflict — record already exists (use --force to overwrite)", + entry.namespace, entry.kind, entry.name + ); + match args.output { + OutputMode::Json | OutputMode::JsonCompact => { + let v = serde_json::json!({ + "action": "conflict", + "namespace": entry.namespace, + "kind": entry.kind, + "name": entry.name, + }); + let s = if args.output == OutputMode::Json { + serde_json::to_string_pretty(&v)? + } else { + serde_json::to_string(&v)? + }; + eprintln!("{}", s); + } + _ => eprintln!("{}", msg), + } + return Err(anyhow::anyhow!( + "Import aborted: conflict on [{}/{}/{}]", + entry.namespace, + entry.kind, + entry.name + )); + } + + let action = if exists { "upsert" } else { "insert" }; + + if args.dry_run { + match args.output { + OutputMode::Json | OutputMode::JsonCompact => { + let v = serde_json::json!({ + "action": action, + "namespace": entry.namespace, + "kind": entry.kind, + "name": entry.name, + "dry_run": true, + }); + let s = if args.output == OutputMode::Json { + serde_json::to_string_pretty(&v)? + } else { + serde_json::to_string(&v)? + }; + println!("{}", s); + } + _ => println!( + "[dry-run] {} [{}/{}/{}]", + action, entry.namespace, entry.kind, entry.name + ), + } + if exists { + skipped += 1; + } else { + inserted += 1; + } + continue; + } + + // Build secret_entries: convert BTreeMap to Vec ("key:=json") + let secret_entries = build_secret_entries(entry.secrets.as_ref()); + + // Build meta_entries from metadata JSON object. + let meta_entries = build_meta_entries(&entry.metadata); + + match add::run( + pool, + AddArgs { + namespace: &entry.namespace, + kind: &entry.kind, + name: &entry.name, + tags: &entry.tags, + meta_entries: &meta_entries, + secret_entries: &secret_entries, + output: OutputMode::Text, + }, + master_key, + ) + .await + { + Ok(()) => { + match args.output { + OutputMode::Json | OutputMode::JsonCompact => { + let v = serde_json::json!({ + "action": action, + "namespace": entry.namespace, + "kind": entry.kind, + "name": entry.name, + }); + let s = if args.output == OutputMode::Json { + serde_json::to_string_pretty(&v)? + } else { + serde_json::to_string(&v)? + }; + println!("{}", s); + } + _ => println!( + "Imported [{}/{}/{}]", + entry.namespace, entry.kind, entry.name + ), + } + inserted += 1; + } + Err(e) => { + eprintln!( + "Error importing [{}/{}/{}]: {}", + entry.namespace, entry.kind, entry.name, e + ); + failed += 1; + } + } + } + + match args.output { + OutputMode::Json | OutputMode::JsonCompact => { + let v = serde_json::json!({ + "total": total, + "inserted": inserted, + "skipped": skipped, + "failed": failed, + "dry_run": args.dry_run, + }); + let s = if args.output == OutputMode::Json { + serde_json::to_string_pretty(&v)? + } else { + serde_json::to_string(&v)? + }; + println!("{}", s); + } + _ => { + if args.dry_run { + println!( + "\n[dry-run] {} total: {} would insert, {} would skip, {} would fail", + total, inserted, skipped, failed + ); + } else { + println!( + "\nImport done: {} total — {} inserted, {} skipped, {} failed", + total, inserted, skipped, failed + ); + } + } + } + + if failed > 0 { + anyhow::bail!("{} record(s) failed to import", failed); + } + + Ok(()) +} + +/// Convert metadata JSON object into Vec of "key:=json_value" entries. +fn build_meta_entries(metadata: &Value) -> Vec { + let mut entries = Vec::new(); + if let Some(obj) = metadata.as_object() { + for (k, v) in obj { + entries.push(value_to_kv_entry(k, v)); + } + } + entries +} + +/// Convert a BTreeMap (secrets) into Vec of "key:=json_value" entries. +fn build_secret_entries(secrets: Option<&BTreeMap>) -> Vec { + let mut entries = Vec::new(); + if let Some(map) = secrets { + for (k, v) in map { + entries.push(value_to_kv_entry(k, v)); + } + } + entries +} + +/// Convert a key/value pair to a CLI-style entry string. +/// Strings use `key=value`; everything else uses `key:=`. +fn value_to_kv_entry(key: &str, value: &Value) -> String { + match value { + Value::String(s) => format!("{}={}", key, s), + other => format!("{}:={}", key, other), + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 305ec8f..e3f5545 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,8 @@ pub mod add; pub mod config; pub mod delete; +pub mod export_cmd; +pub mod import_cmd; pub mod init; pub mod rollback; pub mod run; diff --git a/src/main.rs b/src/main.rs index 7887a7b..398119f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -436,6 +436,83 @@ EXAMPLES: #[arg(long)] check: bool, }, + + /// Export records to a file (JSON, TOML, or YAML). + /// + /// Decrypts and exports all matched records. Requires master key unless --no-secrets is used. + #[command(after_help = "EXAMPLES: + # Export everything to JSON + secrets export --file backup.json + + # Export a specific namespace to TOML + secrets export -n refining --file refining.toml + + # Export a specific kind + secrets export -n refining --kind service --file services.yaml + + # Export by tag + secrets export --tag production --file prod.json + + # Export schema only (no decryption needed) + secrets export --no-secrets --file schema.json + + # Print to stdout in YAML + secrets export -n refining --format yaml")] + Export { + /// Filter by namespace + #[arg(short, long)] + namespace: Option, + /// Filter by kind, e.g. server, service + #[arg(long)] + kind: Option, + /// Exact name filter + #[arg(long)] + name: Option, + /// Filter by tag (repeatable) + #[arg(long)] + tag: Vec, + /// Fuzzy keyword search + #[arg(short, long)] + query: Option, + /// Output file path (format inferred from extension: .json / .toml / .yaml / .yml) + #[arg(long)] + file: Option, + /// Explicit format: json, toml, or yaml (overrides file extension; required for stdout) + #[arg(long)] + format: Option, + /// Omit secrets from output (no master key required) + #[arg(long)] + no_secrets: bool, + }, + + /// Import records from a file (JSON, TOML, or YAML). + /// + /// Reads an export file and inserts or updates entries. Requires master key to re-encrypt secrets. + #[command(after_help = "EXAMPLES: + # Import a JSON backup (conflict = error by default) + secrets import backup.json + + # Import and overwrite existing records + secrets import --force refining.toml + + # Preview what would be imported (no writes) + secrets import --dry-run backup.yaml + + # JSON output for the import summary + secrets import backup.json -o json")] + Import { + /// Input file path (format inferred from extension: .json / .toml / .yaml / .yml) + file: String, + /// Overwrite existing records on conflict (default: error and abort) + #[arg(long)] + force: bool, + /// Preview operations without writing to the database + #[arg(long)] + dry_run: bool, + /// Output format: text (default on TTY), json, json-compact + #[arg(short, long = "output")] + output: Option, + }, } #[derive(Subcommand)] @@ -682,6 +759,61 @@ async fn main() -> Result<()> { ) .await?; } + + Commands::Export { + namespace, + kind, + name, + tag, + query, + file, + format, + no_secrets, + } => { + let master_key = if no_secrets { + None + } else { + Some(crypto::load_master_key()?) + }; + let _span = tracing::info_span!("cmd", command = "export").entered(); + commands::export_cmd::run( + &pool, + commands::export_cmd::ExportArgs { + namespace: namespace.as_deref(), + kind: kind.as_deref(), + name: name.as_deref(), + tags: &tag, + query: query.as_deref(), + file: file.as_deref(), + format: format.as_deref(), + no_secrets, + }, + master_key.as_ref(), + ) + .await?; + } + + Commands::Import { + file, + force, + dry_run, + output, + } => { + let master_key = crypto::load_master_key()?; + let _span = tracing::info_span!("cmd", command = "import").entered(); + let out = resolve_output_mode(output.as_deref())?; + commands::import_cmd::run( + &pool, + commands::import_cmd::ImportArgs { + file: &file, + force, + dry_run, + output: out, + }, + &master_key, + ) + .await?; + } } Ok(()) diff --git a/src/models.rs b/src/models.rs index 0513304..469b397 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,6 +1,7 @@ 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, …). @@ -36,3 +37,169 @@ pub struct SecretField { pub created_at: DateTime, pub updated_at: DateTime, } + +// ── 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 { + let ext = path.rsplit('.').next().unwrap_or("").to_lowercase(); + match ext.as_str() { + "json" => Ok(Self::Json), + "toml" => Ok(Self::Toml), + "yaml" | "yml" => Ok(Self::Yaml), + other => anyhow::bail!( + "Cannot infer format from extension '.{}'. Use --format json|toml|yaml", + other + ), + } + } + + /// Parse from --format CLI value. + pub fn from_str(s: &str) -> anyhow::Result { + 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 { + 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 { + 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, +} + +/// 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, + #[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>, +} + +// ── 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 { + 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) => { + // Check for uniform scalar type (TOML requires homogeneous arrays at the value level, + // though arrays of tables are handled separately via TOML's [[table]] syntax). + // For simplicity we convert each element; if types are mixed, toml crate will + // handle it gracefully or we fall back to a JSON string. + let items: anyhow::Result> = + arr.iter().map(json_to_toml_value).collect(); + match items { + Ok(vals) => Ok(toml::Value::Array(vals)), + Err(_) => { + // Fallback: serialise as 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(_) => { + // Fallback: serialise as 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 = map + .iter() + .map(|(k, v)| (k.clone(), toml_to_json_value(v))) + .collect(); + Value::Object(obj) + } + } +}