feat(v3): migrate workspace to API, Tauri desktop, and v3 crates; remove legacy MCP stack
Some checks failed
Secrets v3 CI / 检查 (push) Has been cancelled

- Add apps/api, desktop Tauri shell, domain/application/crypto/device-auth/infrastructure-db
- Replace desktop-daemon vault integration; drop secrets-core and secrets-mcp*
- Ignore apps/desktop/dist and generated Tauri icons; document icon/dist steps in AGENTS.md
- Apply rustfmt; fix clippy (collapsible_if, HTTP method as str)
This commit is contained in:
agent
2026-04-13 08:49:57 +08:00
parent cb5865b958
commit 0374899dab
130 changed files with 20447 additions and 21577 deletions

View File

@@ -0,0 +1,26 @@
[package]
name = "secrets-desktop-daemon"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_desktop_daemon"
path = "src/lib.rs"
[[bin]]
name = "secrets-desktop-daemon"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
axum.workspace = true
dotenvy.workspace = true
reqwest = { workspace = true, features = ["stream"] }
rmcp.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
secrets-device-auth = { path = "../device-auth" }

View File

@@ -0,0 +1,23 @@
use anyhow::Result;
#[derive(Debug, Clone)]
pub struct DaemonConfig {
pub bind: String,
}
pub fn load_config() -> Result<DaemonConfig> {
let bind =
std::env::var("SECRETS_DAEMON_BIND").unwrap_or_else(|_| "127.0.0.1:9515".to_string());
if bind.trim().is_empty() {
anyhow::bail!("SECRETS_DAEMON_BIND must not be empty");
}
Ok(DaemonConfig { bind })
}
pub fn load_persisted_device_token() -> Result<Option<String>> {
let token = std::env::var("SECRETS_DEVICE_LOGIN_TOKEN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
Ok(token)
}

View File

@@ -0,0 +1,139 @@
use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tokio::process::Command;
use crate::target::{ExecutionTarget, ResolvedTarget};
const MAX_OUTPUT_CHARS: usize = 64 * 1024;
#[derive(Clone, Debug, Deserialize)]
pub struct TargetExecInput {
pub target_ref: Option<String>,
pub command: String,
pub timeout_secs: Option<u64>,
pub working_dir: Option<String>,
pub env_overrides: Option<Map<String, Value>>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ExecResult {
pub resolved_target: ResolvedTarget,
pub resolved_env_keys: Vec<String>,
pub command: String,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub timed_out: bool,
pub duration_ms: u128,
pub stdout_truncated: bool,
pub stderr_truncated: bool,
}
fn truncate_output(text: String) -> (String, bool) {
if text.chars().count() <= MAX_OUTPUT_CHARS {
return (text, false);
}
let truncated = text.chars().take(MAX_OUTPUT_CHARS).collect::<String>();
(truncated, true)
}
fn stringify_env_override(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(s) => Some(s.clone()),
Value::Bool(v) => Some(v.to_string()),
Value::Number(v) => Some(v.to_string()),
other => serde_json::to_string(other).ok(),
}
}
fn apply_env_overrides(
env: &mut BTreeMap<String, String>,
overrides: Option<&Map<String, Value>>,
) -> Result<()> {
let Some(overrides) = overrides else {
return Ok(());
};
for (key, value) in overrides {
if key.is_empty() || key.contains('=') {
return Err(anyhow!("invalid env override key: {key}"));
}
if key.starts_with("TARGET_") {
return Err(anyhow!(
"env override `{key}` cannot override reserved TARGET_* variables"
));
}
if let Some(value) = stringify_env_override(value) {
env.insert(key.clone(), value);
}
}
Ok(())
}
pub async fn execute_command(
input: &TargetExecInput,
target: &ExecutionTarget,
timeout_secs: u64,
) -> Result<ExecResult> {
let mut env = target.env.clone();
apply_env_overrides(&mut env, input.env_overrides.as_ref())?;
let started = std::time::Instant::now();
let mut command = Command::new("/bin/sh");
command
.arg("-lc")
.arg(&input.command)
.kill_on_drop(true)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if let Some(dir) = input.working_dir.as_ref().filter(|dir| !dir.is_empty()) {
command.current_dir(dir);
}
for (key, value) in &env {
command.env(key, value);
}
let child = command
.spawn()
.with_context(|| format!("failed to spawn command: {}", input.command))?;
let timed = tokio::time::timeout(
Duration::from_secs(timeout_secs.clamp(1, 86400)),
child.wait_with_output(),
)
.await;
let (exit_code, stdout, stderr, timed_out) = match timed {
Ok(output) => {
let output = output.context("failed waiting for command output")?;
(
output.status.code(),
String::from_utf8_lossy(&output.stdout).to_string(),
String::from_utf8_lossy(&output.stderr).to_string(),
false,
)
}
Err(_) => (None, String::new(), "command timed out".to_string(), true),
};
let (stdout, stdout_truncated) = truncate_output(stdout);
let (stderr, stderr_truncated) = truncate_output(stderr);
Ok(ExecResult {
resolved_target: target.resolved.clone(),
resolved_env_keys: target.resolved_env_keys(),
command: input.command.clone(),
exit_code,
stdout,
stderr,
timed_out,
duration_ms: started.elapsed().as_millis(),
stdout_truncated,
stderr_truncated,
})
}

View File

@@ -0,0 +1,684 @@
pub mod config;
pub mod exec;
pub mod target;
pub mod vault_client;
use std::collections::HashMap;
use anyhow::{Context, Result, anyhow};
use axum::{
Router,
body::Body,
extract::State,
http::{StatusCode, header},
response::Response,
routing::{any, get},
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
exec::{TargetExecInput, execute_command},
target::{TargetSnapshot, build_execution_target},
vault_client::{
EntryDetail, EntrySummary, SecretHistoryItem, SecretValueField, authorized_get,
authorized_patch, authorized_post, entry_detail_payload, fetch_entry_detail,
fetch_revealed_entry_secrets,
},
};
#[derive(Clone)]
pub struct AppState {
session_base: String,
client: reqwest::Client,
}
#[derive(Deserialize)]
struct JsonRpcRequest {
#[serde(default)]
id: Value,
method: String,
#[serde(default)]
params: Value,
}
fn json_response(status: StatusCode, value: Value) -> Response {
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.body(Body::from(value.to_string()))
.expect("build response")
}
fn jsonrpc_result_response(id: Value, result: Value) -> Response {
json_response(
StatusCode::OK,
json!({
"jsonrpc": "2.0",
"id": id,
"result": result,
}),
)
}
fn tool_success_response(id: Value, value: Value) -> Response {
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
jsonrpc_result_response(
id,
json!({
"content": [
{
"type": "text",
"text": pretty
}
],
"isError": false
}),
)
}
fn tool_error_response(id: Value, message: impl Into<String>) -> Response {
jsonrpc_result_response(
id,
json!({
"content": [
{
"type": "text",
"text": message.into()
}
],
"isError": true
}),
)
}
fn initialize_response(id: Value) -> Response {
let session_id = format!(
"desktop-daemon-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0)
);
let payload = json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "secrets-desktop-daemon",
"version": env!("CARGO_PKG_VERSION"),
"title": "Secrets Desktop Daemon"
},
"instructions": "Preferred tools: secrets_entry_find, secrets_entry_get, secrets_entry_add, secrets_entry_update, secrets_entry_delete, secrets_entry_restore, secrets_secret_add, secrets_secret_update, secrets_secret_delete, secrets_secret_history, secrets_secret_rollback, and target_exec. All data is resolved from the desktop app's unlocked local vault session. Legacy aliases secrets_find, secrets_add, and secrets_update remain supported."
}
});
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.header("mcp-session-id", session_id)
.body(Body::from(payload.to_string()))
.expect("build response")
}
fn tool_definitions() -> Vec<Value> {
vec![
json!({
"name": "secrets_entry_find",
"description": "Find entries from the user's secrets vault.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": ["string", "null"] },
"folder": { "type": ["string", "null"] },
"type": { "type": ["string", "null"] }
}
}
}),
json!({
"name": "secrets_entry_get",
"description": "Get one entry from the unlocked local vault by entry id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_entry_add",
"description": "Create a new entry and optionally include initial secrets.",
"inputSchema": {
"type": "object",
"properties": {
"folder": { "type": "string" },
"name": { "type": "string" },
"type": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] },
"secrets": {
"type": ["array", "null"],
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"secret_type": { "type": ["string", "null"] },
"value": { "type": "string" }
},
"required": ["name", "value"]
}
}
},
"required": ["folder", "name"]
}
}),
json!({
"name": "secrets_entry_update",
"description": "Update an existing entry by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"folder": { "type": ["string", "null"] },
"name": { "type": ["string", "null"] },
"type": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_entry_delete",
"description": "Move an entry into recycle bin by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_entry_restore",
"description": "Restore a deleted entry from recycle bin by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_secret_add",
"description": "Create one secret under an existing entry.",
"inputSchema": {
"type": "object",
"properties": {
"entry_id": { "type": "string" },
"name": { "type": "string" },
"secret_type": { "type": ["string", "null"] },
"value": { "type": "string" }
},
"required": ["entry_id", "name", "value"]
}
}),
json!({
"name": "secrets_secret_update",
"description": "Update one secret by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": ["string", "null"] },
"secret_type": { "type": ["string", "null"] },
"value": { "type": ["string", "null"] }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_secret_delete",
"description": "Delete one secret by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_secret_history",
"description": "List history snapshots for one secret by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_secret_rollback",
"description": "Rollback one secret by id to a previous version or history id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"version": { "type": ["integer", "null"] },
"history_id": { "type": ["integer", "null"] }
},
"required": ["id"]
}
}),
json!({
"name": "target_exec",
"description": "Execute a local shell command with resolved TARGET_* environment variables from one entry.",
"inputSchema": {
"type": "object",
"properties": {
"target_ref": { "type": ["string", "null"] },
"command": { "type": "string" },
"timeout_secs": { "type": ["integer", "null"] },
"working_dir": { "type": ["string", "null"] },
"env_overrides": { "type": ["object", "null"] }
},
"required": ["target_ref", "command"]
}
}),
json!({
"name": "secrets_find",
"description": "Legacy alias for secrets_entry_find.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": ["string", "null"] },
"folder": { "type": ["string", "null"] },
"type": { "type": ["string", "null"] }
}
}
}),
json!({
"name": "secrets_add",
"description": "Legacy alias for secrets_entry_add.",
"inputSchema": {
"type": "object",
"properties": {
"folder": { "type": "string" },
"name": { "type": "string" },
"type": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] },
"secrets": { "type": ["array", "null"] }
},
"required": ["folder", "name"]
}
}),
json!({
"name": "secrets_update",
"description": "Legacy alias for secrets_entry_update.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"folder": { "type": ["string", "null"] },
"name": { "type": ["string", "null"] },
"type": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] }
},
"required": ["id"]
}
}),
]
}
fn entry_detail_to_snapshot(detail: &EntryDetail) -> TargetSnapshot {
let metadata = detail
.metadata
.iter()
.map(|field| (field.label.clone(), Value::String(field.value.clone())))
.collect();
let secret_fields = detail
.secrets
.iter()
.map(|secret| crate::target::SecretFieldRef {
name: secret.name.clone(),
secret_type: Some(secret.secret_type.clone()),
})
.collect();
TargetSnapshot {
id: detail.id.clone(),
folder: detail.folder.clone(),
name: detail.name.clone(),
entry_type: Some(detail.cipher_type.clone()),
metadata,
secret_fields,
}
}
fn revealed_secrets_to_env(secrets: &[SecretValueField]) -> HashMap<String, Value> {
secrets
.iter()
.map(|secret| (secret.name.clone(), Value::String(secret.value.clone())))
.collect()
}
async fn call_tool(state: &AppState, name: &str, arguments: Value) -> Result<Value> {
match name {
"secrets_find" | "secrets_entry_find" => {
let folder = arguments
.get("folder")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let query = arguments
.get("query")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let entry_type = arguments
.get("type")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let mut params = Vec::new();
if let Some(folder) = folder {
params.push(("folder", folder));
}
if let Some(query) = query {
params.push(("query", query));
}
if let Some(entry_type) = entry_type {
params.push(("entry_type", entry_type));
}
params.push(("deleted_only", "false".to_string()));
let entries = authorized_get(state, "/vault/entries", &params)
.await?
.json::<Vec<EntrySummary>>()
.await
.context("failed to decode entries list")?;
Ok(json!({
"entries": entries.into_iter().map(|entry| {
json!({
"id": entry.id,
"folder": entry.folder,
"name": entry.name,
"type": entry.cipher_type
})
}).collect::<Vec<_>>()
}))
}
"secrets_entry_get" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let detail = fetch_entry_detail(state, id).await?;
let secrets = fetch_revealed_entry_secrets(state, id).await?;
Ok(entry_detail_payload(&detail, Some(&secrets)))
}
"secrets_add" | "secrets_entry_add" => {
let folder = arguments
.get("folder")
.and_then(Value::as_str)
.context("folder is required")?;
let name = arguments
.get("name")
.and_then(Value::as_str)
.context("name is required")?;
let entry_type = arguments
.get("type")
.and_then(Value::as_str)
.unwrap_or("entry");
let metadata = arguments
.get("metadata")
.cloned()
.unwrap_or_else(|| json!({}));
let res = authorized_post(
state,
"/vault/entries",
&json!({
"folder": folder,
"name": name,
"entry_type": entry_type,
"metadata": metadata,
"secrets": arguments.get("secrets").cloned().unwrap_or(Value::Null)
}),
)
.await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode create result")?)
}
"secrets_update" | "secrets_entry_update" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let body = json!({
"folder": arguments.get("folder").cloned().unwrap_or(Value::Null),
"entry_type": arguments.get("type").cloned().unwrap_or(Value::Null),
"title": arguments.get("name").cloned().unwrap_or(Value::Null),
"metadata": arguments.get("metadata").cloned().unwrap_or(Value::Null)
});
let res = authorized_patch(state, &format!("/vault/entries/{id}"), &body).await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode update result")?)
}
"secrets_entry_delete" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let res =
authorized_post(state, &format!("/vault/entries/{id}/delete"), &json!({})).await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode delete result")?)
}
"secrets_entry_restore" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let res =
authorized_post(state, &format!("/vault/entries/{id}/restore"), &json!({})).await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode restore result")?)
}
"secrets_secret_add" => {
let entry_id = arguments
.get("entry_id")
.and_then(Value::as_str)
.context("entry_id is required")?;
let name = arguments
.get("name")
.and_then(Value::as_str)
.context("name is required")?;
let value = arguments
.get("value")
.and_then(Value::as_str)
.context("value is required")?;
let res = authorized_post(
state,
&format!("/vault/entries/{entry_id}/secrets"),
&json!({
"name": name,
"secret_type": arguments.get("secret_type").cloned().unwrap_or(Value::Null),
"value": value
}),
)
.await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode secret create result")?)
}
"secrets_secret_update" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let res = authorized_patch(
state,
&format!("/vault/secrets/{id}"),
&json!({
"name": arguments.get("name").cloned().unwrap_or(Value::Null),
"secret_type": arguments.get("secret_type").cloned().unwrap_or(Value::Null),
"value": arguments.get("value").cloned().unwrap_or(Value::Null)
}),
)
.await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode secret update result")?)
}
"secrets_secret_delete" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let res =
authorized_post(state, &format!("/vault/secrets/{id}/delete"), &json!({})).await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode secret delete result")?)
}
"secrets_secret_history" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let history = authorized_get(state, &format!("/vault/secrets/{id}/history"), &[])
.await?
.json::<Vec<SecretHistoryItem>>()
.await
.context("failed to decode secret history")?;
Ok(json!({
"history": history.into_iter().map(|item| {
json!({
"history_id": item.history_id,
"secret_id": item.secret_id,
"name": item.name,
"type": item.secret_type,
"masked_value": item.masked_value,
"value": item.value,
"version": item.version,
"action": item.action,
"created_at": item.created_at
})
}).collect::<Vec<_>>()
}))
}
"secrets_secret_rollback" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let res = authorized_post(
state,
&format!("/vault/secrets/{id}/rollback"),
&json!({
"version": arguments.get("version").cloned().unwrap_or(Value::Null),
"history_id": arguments.get("history_id").cloned().unwrap_or(Value::Null)
}),
)
.await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode secret rollback result")?)
}
"target_exec" => {
let input: TargetExecInput =
serde_json::from_value(arguments).context("invalid target_exec arguments")?;
let target_ref = input
.target_ref
.as_ref()
.context("target_ref is required")?;
let detail = fetch_entry_detail(state, target_ref).await?;
let secrets = fetch_revealed_entry_secrets(state, target_ref).await?;
let execution_target = build_execution_target(
&entry_detail_to_snapshot(&detail),
&revealed_secrets_to_env(&secrets),
)?;
let result =
execute_command(&input, &execution_target, input.timeout_secs.unwrap_or(30))
.await?;
Ok(serde_json::to_value(result).context("failed to encode exec result")?)
}
other => Err(anyhow!("unsupported tool: {other}")),
}
}
pub async fn handle_mcp(State(state): State<AppState>, body: String) -> Response {
let request: JsonRpcRequest = match serde_json::from_str(&body) {
Ok(request) => request,
Err(err) => {
return json_response(
StatusCode::BAD_REQUEST,
json!({
"jsonrpc": "2.0",
"id": null,
"error": {
"code": -32600,
"message": format!("invalid request: {err}")
}
}),
);
}
};
match request.method.as_str() {
"initialize" => initialize_response(request.id),
"tools/list" => jsonrpc_result_response(request.id, json!({ "tools": tool_definitions() })),
"tools/call" => {
let name = request
.params
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let arguments = request
.params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
match call_tool(&state, name, arguments).await {
Ok(value) => tool_success_response(request.id, value),
Err(err) => tool_error_response(request.id, err.to_string()),
}
}
other => json_response(
StatusCode::OK,
json!({
"jsonrpc": "2.0",
"id": request.id,
"error": {
"code": -32601,
"message": format!("method `{other}` not supported by secrets-desktop-daemon")
}
}),
),
}
}
pub async fn build_router() -> Result<Router> {
let session_base = std::env::var("SECRETS_DESKTOP_SESSION_URL")
.unwrap_or_else(|_| "http://127.0.0.1:9520".to_string());
let state = AppState {
session_base,
client: reqwest::Client::new(),
};
Ok(Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/mcp", any(handle_mcp))
.with_state(state))
}

View File

@@ -0,0 +1,26 @@
use anyhow::{Context, Result};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<()> {
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "secrets_desktop_daemon=info".into()),
)
.init();
let config = secrets_desktop_daemon::config::load_config()?;
let app = secrets_desktop_daemon::build_router().await?;
let listener = tokio::net::TcpListener::bind(&config.bind)
.await
.with_context(|| format!("failed to bind {}", config.bind))?;
tracing::info!(bind = %config.bind, "secrets-desktop-daemon listening");
axum::serve(listener, app)
.await
.context("daemon server error")?;
Ok(())
}

View File

@@ -0,0 +1,332 @@
use std::collections::{BTreeMap, HashMap};
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SecretFieldRef {
pub name: String,
#[serde(rename = "type")]
pub secret_type: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct TargetSnapshot {
pub id: String,
pub folder: String,
pub name: String,
#[serde(rename = "type")]
pub entry_type: Option<String>,
#[serde(default)]
pub metadata: Map<String, Value>,
#[serde(default)]
pub secret_fields: Vec<SecretFieldRef>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ResolvedTarget {
pub id: String,
pub folder: String,
pub name: String,
#[serde(rename = "type")]
pub entry_type: Option<String>,
}
#[derive(Clone, Debug)]
pub struct ExecutionTarget {
pub resolved: ResolvedTarget,
pub env: BTreeMap<String, String>,
}
impl ExecutionTarget {
pub fn resolved_env_keys(&self) -> Vec<String> {
self.env.keys().cloned().collect()
}
}
fn stringify_value(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(s) => Some(s.clone()),
Value::Bool(v) => Some(v.to_string()),
Value::Number(v) => Some(v.to_string()),
other => serde_json::to_string(other).ok(),
}
}
fn sanitize_env_key(key: &str) -> String {
let mut out = String::with_capacity(key.len());
for ch in key.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_uppercase());
} else {
out.push('_');
}
}
while out.contains("__") {
out = out.replace("__", "_");
}
out.trim_matches('_').to_string()
}
fn set_if_missing(env: &mut BTreeMap<String, String>, key: &str, value: Option<String>) {
if let Some(value) = value.filter(|v| !v.is_empty()) {
env.entry(key.to_string()).or_insert(value);
}
}
fn metadata_alias(metadata: &Map<String, Value>, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| metadata.get(*key))
.and_then(stringify_value)
}
fn secret_alias(
secrets: &HashMap<String, Value>,
secret_types: &HashMap<&str, Option<&str>>,
name_match: impl Fn(&str) -> bool,
type_match: impl Fn(Option<&str>) -> bool,
) -> Option<String> {
secrets.iter().find_map(|(name, value)| {
let normalized = sanitize_env_key(name);
let ty = secret_types.get(name.as_str()).copied().flatten();
if name_match(&normalized) || type_match(ty) {
stringify_value(value)
} else {
None
}
})
}
pub fn build_execution_target(
snapshot: &TargetSnapshot,
secrets: &HashMap<String, Value>,
) -> Result<ExecutionTarget> {
if snapshot.id.trim().is_empty() {
return Err(anyhow!("target snapshot missing id"));
}
let mut env = BTreeMap::new();
env.insert("TARGET_ENTRY_ID".to_string(), snapshot.id.clone());
env.insert("TARGET_NAME".to_string(), snapshot.name.clone());
env.insert("TARGET_FOLDER".to_string(), snapshot.folder.clone());
if let Some(entry_type) = snapshot.entry_type.as_ref().filter(|v| !v.is_empty()) {
env.insert("TARGET_TYPE".to_string(), entry_type.clone());
}
for (key, value) in &snapshot.metadata {
if let Some(value) = stringify_value(value) {
let name = sanitize_env_key(key);
if !name.is_empty() {
env.insert(format!("TARGET_META_{name}"), value);
}
}
}
let secret_type_map: HashMap<&str, Option<&str>> = snapshot
.secret_fields
.iter()
.map(|field| (field.name.as_str(), field.secret_type.as_deref()))
.collect();
for (key, value) in secrets {
if let Some(value) = stringify_value(value) {
let name = sanitize_env_key(key);
if !name.is_empty() {
env.insert(format!("TARGET_SECRET_{name}"), value);
}
}
}
set_if_missing(
&mut env,
"TARGET_HOST",
metadata_alias(
&snapshot.metadata,
&["public_ip", "ipv4", "private_ip", "host", "hostname"],
),
);
set_if_missing(
&mut env,
"TARGET_PORT",
metadata_alias(&snapshot.metadata, &["ssh_port", "port"]),
);
set_if_missing(
&mut env,
"TARGET_USER",
metadata_alias(&snapshot.metadata, &["username", "ssh_user", "user"]),
);
set_if_missing(
&mut env,
"TARGET_BASE_URL",
metadata_alias(&snapshot.metadata, &["base_url", "url", "endpoint"]),
);
set_if_missing(
&mut env,
"TARGET_API_KEY",
secret_alias(
secrets,
&secret_type_map,
|name| matches!(name, "API_KEY" | "APIKEY" | "ACCESS_KEY" | "ACCESS_KEY_ID"),
|_| false,
),
);
set_if_missing(
&mut env,
"TARGET_TOKEN",
secret_alias(
secrets,
&secret_type_map,
|name| name.contains("TOKEN"),
|_| false,
),
);
set_if_missing(
&mut env,
"TARGET_SSH_KEY",
secret_alias(
secrets,
&secret_type_map,
|name| name.contains("SSH") || name.ends_with("PEM"),
|ty| ty.is_some_and(|v| v.eq_ignore_ascii_case("ssh-key")),
),
);
Ok(ExecutionTarget {
resolved: ResolvedTarget {
id: snapshot.id.clone(),
folder: snapshot.folder.clone(),
name: snapshot.name.clone(),
entry_type: snapshot.entry_type.clone(),
},
env,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn build_snapshot() -> TargetSnapshot {
let mut metadata = Map::new();
metadata.insert(
"host".to_string(),
Value::String("git.example.com".to_string()),
);
metadata.insert("port".to_string(), Value::String("22".to_string()));
metadata.insert("username".to_string(), Value::String("deploy".to_string()));
metadata.insert(
"base_url".to_string(),
Value::String("https://api.example.com".to_string()),
);
TargetSnapshot {
id: "entry-1".to_string(),
folder: "infra".to_string(),
name: "production".to_string(),
entry_type: Some("ssh_key".to_string()),
metadata,
secret_fields: vec![
SecretFieldRef {
name: "api_key".to_string(),
secret_type: Some("text".to_string()),
},
SecretFieldRef {
name: "token".to_string(),
secret_type: Some("text".to_string()),
},
SecretFieldRef {
name: "ssh_key".to_string(),
secret_type: Some("ssh-key".to_string()),
},
],
}
}
#[test]
fn derives_standard_target_env_keys() {
let snapshot = build_snapshot();
let secrets = HashMap::from([
("api_key".to_string(), Value::String("ak-123".to_string())),
("token".to_string(), Value::String("tok-456".to_string())),
(
"ssh_key".to_string(),
Value::String("-----BEGIN KEY-----".to_string()),
),
]);
let target = build_execution_target(&snapshot, &secrets).expect("build execution target");
assert_eq!(
target.env.get("TARGET_ENTRY_ID").map(String::as_str),
Some("entry-1")
);
assert_eq!(
target.env.get("TARGET_NAME").map(String::as_str),
Some("production")
);
assert_eq!(
target.env.get("TARGET_FOLDER").map(String::as_str),
Some("infra")
);
assert_eq!(
target.env.get("TARGET_TYPE").map(String::as_str),
Some("ssh_key")
);
assert_eq!(
target.env.get("TARGET_HOST").map(String::as_str),
Some("git.example.com")
);
assert_eq!(
target.env.get("TARGET_PORT").map(String::as_str),
Some("22")
);
assert_eq!(
target.env.get("TARGET_USER").map(String::as_str),
Some("deploy")
);
assert_eq!(
target.env.get("TARGET_BASE_URL").map(String::as_str),
Some("https://api.example.com")
);
assert_eq!(
target.env.get("TARGET_API_KEY").map(String::as_str),
Some("ak-123")
);
assert_eq!(
target.env.get("TARGET_TOKEN").map(String::as_str),
Some("tok-456")
);
assert_eq!(
target.env.get("TARGET_SSH_KEY").map(String::as_str),
Some("-----BEGIN KEY-----")
);
}
#[test]
fn exports_sanitized_meta_and_secret_keys() {
let mut snapshot = build_snapshot();
snapshot.metadata.insert(
"private-ip".to_string(),
Value::String("10.0.0.8".to_string()),
);
let secrets = HashMap::from([(
"access key id".to_string(),
Value::String("access-1".to_string()),
)]);
let target = build_execution_target(&snapshot, &secrets).expect("build execution target");
assert_eq!(
target.env.get("TARGET_META_PRIVATE_IP").map(String::as_str),
Some("10.0.0.8")
);
assert_eq!(
target
.env
.get("TARGET_SECRET_ACCESS_KEY_ID")
.map(String::as_str),
Some("access-1")
);
}
}

View File

@@ -0,0 +1,168 @@
use std::collections::HashMap;
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::AppState;
#[derive(Debug, Deserialize)]
pub struct EntrySummary {
pub id: String,
pub folder: String,
#[serde(rename = "title")]
pub name: String,
#[serde(rename = "subtitle")]
pub cipher_type: String,
}
#[derive(Debug, Deserialize)]
pub struct EntryDetail {
pub id: String,
#[serde(rename = "title")]
pub name: String,
pub folder: String,
#[serde(rename = "entry_type")]
pub cipher_type: String,
pub metadata: Vec<DetailField>,
pub secrets: Vec<SecretField>,
}
#[derive(Debug, Deserialize)]
pub struct DetailField {
pub label: String,
pub value: String,
}
#[derive(Debug, Deserialize)]
pub struct SecretField {
pub id: String,
pub name: String,
pub secret_type: String,
pub masked_value: String,
pub version: i64,
}
#[derive(Debug, Deserialize)]
pub struct SecretValueField {
pub id: String,
pub name: String,
pub value: String,
}
#[derive(Debug, Deserialize)]
pub struct SecretHistoryItem {
pub history_id: i64,
pub secret_id: String,
pub name: String,
pub secret_type: String,
pub masked_value: String,
pub value: String,
pub version: i64,
pub action: String,
pub created_at: String,
}
pub async fn authorized_get(
state: &AppState,
path: &str,
query: &[(&str, String)],
) -> Result<reqwest::Response> {
state
.client
.get(format!("{}{}", state.session_base, path))
.query(query)
.send()
.await
.with_context(|| format!("desktop local vault unavailable: {path}"))?
.error_for_status()
.with_context(|| format!("desktop local vault requires sign-in and unlock: {path}"))
}
pub async fn authorized_patch(
state: &AppState,
path: &str,
body: &Value,
) -> Result<reqwest::Response> {
state
.client
.patch(format!("{}{}", state.session_base, path))
.json(body)
.send()
.await
.with_context(|| format!("desktop local vault unavailable: {path}"))?
.error_for_status()
.with_context(|| format!("desktop local vault requires sign-in and unlock: {path}"))
}
pub async fn authorized_post(
state: &AppState,
path: &str,
body: &Value,
) -> Result<reqwest::Response> {
state
.client
.post(format!("{}{}", state.session_base, path))
.json(body)
.send()
.await
.with_context(|| format!("desktop local vault unavailable: {path}"))?
.error_for_status()
.with_context(|| format!("desktop local vault requires sign-in and unlock: {path}"))
}
pub async fn fetch_entry_detail(state: &AppState, entry_id: &str) -> Result<EntryDetail> {
authorized_get(state, &format!("/vault/entries/{entry_id}"), &[])
.await?
.json::<EntryDetail>()
.await
.context("failed to decode entry detail")
}
pub async fn fetch_revealed_entry_secrets(
state: &AppState,
entry_id: &str,
) -> Result<Vec<SecretValueField>> {
let detail = fetch_entry_detail(state, entry_id).await?;
let mut secrets = Vec::new();
for secret in detail.secrets {
let item = authorized_get(state, &format!("/vault/secrets/{}/value", secret.id), &[])
.await?
.json::<SecretValueField>()
.await
.context("failed to decode revealed secret value")?;
secrets.push(item);
}
Ok(secrets)
}
pub fn entry_detail_payload(detail: &EntryDetail, revealed: Option<&[SecretValueField]>) -> Value {
let revealed_by_id: HashMap<&str, &SecretValueField> = revealed
.unwrap_or(&[])
.iter()
.map(|secret| (secret.id.as_str(), secret))
.collect();
json!({
"id": detail.id,
"folder": detail.folder,
"name": detail.name,
"type": detail.cipher_type,
"metadata": detail.metadata.iter().map(|field| {
json!({
"label": field.label,
"value": field.value
})
}).collect::<Vec<_>>(),
"secrets": detail.secrets.iter().map(|secret| {
let revealed = revealed_by_id.get(secret.id.as_str());
json!({
"id": secret.id,
"name": secret.name,
"type": secret.secret_type,
"masked_value": secret.masked_value,
"value": revealed.map(|item| item.value.clone()),
"version": secret.version
})
}).collect::<Vec<_>>()
})
}