diff --git a/README.md b/README.md index 2398dfe..427028e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,12 @@ commit to git. Built in Rust on [GPUI](https://www.gpui.rs/) (the framework behind the Zed editor) and the [gpui-component](https://github.com/longbridge/gpui-component) widget library. +![Posel — sending REST and GraphQL requests, switching environments, and inspecting response timing](docs/assets/posel-demo.gif) + +Opening a saved request, sending it with Ctrl+Enter, resolving +`{{variables}}` against an environment, inspecting headers and the timing waterfall, running a +GraphQL query, and the command palette. ([higher-quality MP4](docs/assets/posel-demo.mp4)) + ## Why another API client? Existing clients are mostly either cloud-first and heavy, or built on a web runtime. Posel's bet @@ -17,7 +23,8 @@ is the opposite corner of the design space: - **Multi-protocol depth** — REST + GraphQL today, behind a clean *Protocol vs Transport* boundary so gRPC and WebSocket/SSE slot in additively rather than as bolt-ons. - **Git-native & local-first** — collections are human-readable `.toml`; collaboration is just - git. Secrets live in your OS keychain, never in the repo. + git. Secrets stay out of the repo — in your OS keychain by default, or another backend you + pick ([Secrets](#secrets)). - **Keyboard-driven / Zed-like** — command palette, quick-open, env switcher, multi-tab; a keyboard-first flow with minimal chrome. @@ -26,7 +33,7 @@ is the opposite corner of the design space: Working, in active development. Runs on **macOS, Linux, and Windows**. - **REST** — request editor (method, query/headers/body), streaming response panel, - environments + variables, secrets in the OS keychain, request history. + environments + variables, pluggable secret storage, request history. - **GraphQL** — schema introspection from the endpoint, schema-aware autocomplete, query formatting, inline validation, and a dedicated data/errors viewer. - **Workspace** — git-friendly `.toml` collections, live reflection of external file edits, @@ -61,7 +68,7 @@ The domain logic is headless and unit-tested; GPUI is confined to the edge (`ui` | `template` | Variable / secret / dynamic-value resolution | | `engine` | tokio runtime, the `Transport` trait + HTTP transport, REST & GraphQL resolution | | `schema` | GraphQL brains: introspection → queryable schema, completion, formatting, validation | -| `storage` | git-friendly `.toml` format, OS-keychain secrets, history, file-watching | +| `storage` | git-friendly `.toml` format, pluggable secret backends, history, file-watching | | `ui` | GPUI widgets: editors, viewers, sidebar, palette (protocol-aware via enum dispatch) | | `app` | `posel` desktop binary | | `cli` | `posel-cli` headless engine driver | @@ -74,8 +81,18 @@ stays protocol-agnostic. See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md). Requires a current stable Rust toolchain (GPUI uses recently-stabilized std features; run `rustup update stable` if a build fails with `E0658`). Builds on macOS, Linux, and Windows; -secrets use the platform keychain (macOS Keychain, Windows Credential Manager, Linux -Secret Service). +secrets default to the platform keychain (macOS Keychain, Windows Credential Manager, Linux +Secret Service) and can be pointed elsewhere — see [Secrets](#secrets). + +On **Linux**, GPUI links against system libraries that need their development packages present, +or the build fails in `yeslogic-fontconfig-sys` with *"The pkg-config command could not be +found"*: + +```bash +# Debian / Ubuntu +sudo apt install -y pkg-config libfontconfig-dev libfreetype-dev \ + libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libxcb1-dev libx11-dev +``` ```bash cargo run -p posel-app # the desktop app (binary: posel) @@ -87,6 +104,69 @@ cargo clippy --workspace -- --deny warnings A workspace can be opened by passing a path as the first argument to the app, or via the `POSEL_WORKSPACE` environment variable. +## Secrets + +A `{{secret:name}}` reference is resolved at send time; the workspace `.toml` only ever holds the +*reference*, never the value, so collections stay safe to commit. Where the value comes from is +your choice: + +| Backend | Writable from Posel | Where values live | +|---------|---------------------|-------------------| +| `keychain` *(default)* | yes | macOS Keychain, Windows Credential Manager, Linux Secret Service | +| `file` | yes | a `0600` TOML file in your user data directory | +| `env` | no | the process environment (`{{secret:github_token}}` → `$POSEL_SECRET_GITHUB_TOKEN`) | +| `command` | no | stdout of a helper command you configure (`pass`, `op`, `gh`, …) | +| `none` | — | nowhere; every `{{secret:…}}` fails | + +The keychain is the default and the recommended choice — but not every machine has one. A headless +Linux box, a container, or a CI runner has no Secret Service, which is where +`org.freedesktop.DBus.Error.ServiceUnknown: The name org.freedesktop.secrets was not provided by +any .service files` comes from. Pick one of the other backends there. + +Configure it under `[secrets]` in `settings.toml` in your user data directory +(`~/.local/share/posel` on Linux, `~/Library/Application Support/posel` on macOS, +`%LOCALAPPDATA%\posel\data` on Windows): + +```toml +[secrets] +backend = "file" # keychain | file | env | command | none + +env_prefix = "POSEL_SECRET_" # env backend: name → $POSEL_SECRET_ +command = ["pass", "show", "posel/{name}"] # command backend: stdout is the value +``` + +`command` runs the given argv once per lookup and takes its stdout (trailing newline trimmed) as +the value; `{name}` is substituted with the secret name in every argument — e.g. +`["op", "read", "op://vault/{name}/token"]` or `["gh", "auth", "token"]`. A non-zero exit surfaces +the helper's stderr. + +**About the `file` backend.** It stores values in plaintext at rest — the same posture as +`~/.aws/credentials`, `~/.docker/config.json`, or `gh`'s `hosts.yml`. On Unix the file is *created* +`0600` (owner-only) before any value is written to it, never chmod'ed afterwards, so the plaintext +is not readable by anyone else even momentarily; on Windows it inherits the user-scoped ACL of +`%LOCALAPPDATA%`. Updates go through a temp file and an atomic rename, entries are namespaced per +workspace, and it lives in your user data directory — **never inside a workspace**, so it can't be +committed by accident. Use the keychain where you have one; use this where you don't. + +Either way can be overridden for a single run with `POSEL_SECRET_BACKEND`, which wins over +`settings.toml` (an unrecognized value is ignored and the configured backend stands): + +```bash +POSEL_SECRET_BACKEND=env cargo run -p posel-app +``` + +`posel-cli` takes the same choice as a flag on `send`, `login`, `test`, and `codegen`: + +```bash +posel-cli send ./ws users/list.toml --secret-backend env # this run only +posel-cli send ./ws users/list.toml --secret token=abc # inline; no store is read +posel-cli send ./ws users/list.toml --keychain # pin the OS keychain +``` + +Given none of those, the CLI resolves through whatever `settings.toml` configures. If several are +given, `--keychain` wins, then `--secret`, then `--secret-backend`. `--secret-backend` swaps only +the backend — the configured `env_prefix` and `command` still apply. + ## A note on the GPUI dependency GPUI ships only as a git dependency (no crates.io release), and Posel carries three small, diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs index 6e09d80..4ddd4a8 100644 --- a/crates/cli/src/args.rs +++ b/crates/cli/src/args.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; use anyhow::{bail, Context as _, Result}; use posel_core::{Body, Method, Request}; +use posel_storage::SecretBackend; const USAGE: &str = "\ posel-cli — headless REST driver @@ -36,12 +37,17 @@ SEND (run a saved request, resolving {{vars}}/{{secret:..}}): Path to the workspace directory Request file, relative to the workspace root --env Environment to resolve against - --secret Provide a secret inline (repeatable; avoids the OS keychain) - --keychain Resolve {{secret:..}} from the OS keychain instead + --secret Provide a secret inline (repeatable; no secret store is read) + --keychain Force the OS keychain, whatever backend is configured + --secret-backend Use this backend for this run instead of the configured one: + keychain | file | env | command | none --allow-host One-off egress allowance past the env policy (repeatable; a strict env ignores it) -o, --output Write the response body byte-exact to a file + Given none of the above, {{secret:..}} resolves through the backend configured in + settings.toml ([secrets], default: the OS keychain). + EXAMPLES: posel-cli https://httpbin.org/stream/5 posel-cli -X POST --json '{\"hi\":1}' https://httpbin.org/post @@ -103,6 +109,9 @@ pub struct SendArgs { pub env: Option, pub secrets: Vec<(String, String)>, pub keychain: bool, + /// `--secret-backend`: override the configured backend for this run. `None` = use the + /// machine's setting. + pub secret_backend: Option, /// Write the response body byte-exact to this file instead of stdout (`-o`/`--output`). pub output: Option, /// One-off egress allowances (`--allow-host`, repeatable). Ignored when the env is strict. @@ -126,6 +135,7 @@ pub struct CodegenArgs { pub env: Option, pub secrets: Vec<(String, String)>, pub keychain: bool, + pub secret_backend: Option, pub target: String, } @@ -236,6 +246,7 @@ fn parse_send>(mut args: I) -> Result { let mut env = None; let mut secrets = Vec::new(); let mut keychain = false; + let mut secret_backend = None; let mut output = None; let mut allow_hosts = Vec::new(); @@ -244,6 +255,12 @@ fn parse_send>(mut args: I) -> Result { "--env" => env = Some(next_value(&mut args, "--env")?), "--secret" => secrets.push(parse_pair(&next_value(&mut args, "--secret")?, '=')?), "--keychain" => keychain = true, + "--secret-backend" => { + secret_backend = Some(parse_secret_backend(&next_value( + &mut args, + "--secret-backend", + )?)?) + } "--allow-host" => allow_hosts.push(next_value(&mut args, "--allow-host")?), "-o" | "--output" => output = Some(PathBuf::from(next_value(&mut args, &arg)?)), other if other.starts_with('-') && other.len() > 1 => bail!("unknown flag: {other}"), @@ -261,6 +278,7 @@ fn parse_send>(mut args: I) -> Result { env, secrets, keychain, + secret_backend, output, allow_hosts, }) @@ -297,6 +315,7 @@ fn parse_codegen>(mut args: I) -> Result let mut env = None; let mut secrets = Vec::new(); let mut keychain = false; + let mut secret_backend = None; let mut target = "curl".to_string(); while let Some(arg) = args.next() { @@ -304,6 +323,12 @@ fn parse_codegen>(mut args: I) -> Result "--env" => env = Some(next_value(&mut args, "--env")?), "--secret" => secrets.push(parse_pair(&next_value(&mut args, "--secret")?, '=')?), "--keychain" => keychain = true, + "--secret-backend" => { + secret_backend = Some(parse_secret_backend(&next_value( + &mut args, + "--secret-backend", + )?)?) + } "--target" => target = next_value(&mut args, "--target")?, other if other.starts_with("--") => bail!("unknown flag: {other}"), _ => positional.push(arg), @@ -320,6 +345,7 @@ fn parse_codegen>(mut args: I) -> Result env, secrets, keychain, + secret_backend, target, }) } @@ -371,6 +397,19 @@ fn next_value>(args: &mut I, flag: &str) -> Result Result { + match raw.trim().to_ascii_lowercase().as_str() { + "keychain" => Ok(SecretBackend::Keychain), + "file" => Ok(SecretBackend::File), + "env" => Ok(SecretBackend::Env), + "command" => Ok(SecretBackend::Command), + "none" => Ok(SecretBackend::None), + _ => bail!("unknown secret backend: {raw} (expected keychain|file|env|command|none)"), + } +} + /// Split `"namevalue"` into a trimmed key/value pair. fn parse_pair(raw: &str, sep: char) -> Result<(String, String)> { let (k, v) = raw @@ -461,6 +500,51 @@ mod tests { assert!(!s.keychain); } + #[test] + fn secret_backend_is_parsed_for_send_and_codegen() { + let Command::Send(s) = parse_cmd(&["send", "./ws", "r.toml", "--secret-backend", "File"]) + else { + panic!("expected send"); + }; + assert_eq!(s.secret_backend, Some(SecretBackend::File)); + + let Command::Codegen(c) = + parse_cmd(&["codegen", "./ws", "r.toml", "--secret-backend", "env"]) + else { + panic!("expected codegen"); + }; + assert_eq!(c.secret_backend, Some(SecretBackend::Env)); + + // Absent flag means "whatever the machine is configured for" — the CLI must not guess. + let Command::Send(s) = parse_cmd(&["send", "./ws", "r.toml"]) else { + panic!("expected send"); + }; + assert_eq!(s.secret_backend, None); + } + + #[test] + fn unknown_or_missing_secret_backend_is_an_error() { + let err = parse( + ["send", "./ws", "r.toml", "--secret-backend", "vault"] + .iter() + .map(|s| s.to_string()), + ) + .err() + .map(|e| e.to_string()) + .unwrap_or_default(); + assert!( + err.contains("vault"), + "expected a clean error, got: {err:?}" + ); + + assert!(parse( + ["send", "./ws", "r.toml", "--secret-backend"] + .iter() + .map(|s| s.to_string()) + ) + .is_err()); + } + #[test] fn send_requires_two_positionals() { assert!(parse(["send".to_string(), "./ws".into()]).is_err()); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index f35be75..4aa5560 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -20,7 +20,8 @@ use posel_engine::{ }; use posel_import::{detect_importer, ImportedItem}; use posel_storage::{ - history_path, AppSettings, History, HistoryEntry, KeyringSecrets, LayeredTokenStore, Workspace, + history_path, AppSettings, History, HistoryEntry, KeyringSecrets, LayeredTokenStore, + SecretBackend, SecretsConfig, Workspace, WorkspaceSecrets, }; fn main() -> Result<()> { @@ -224,7 +225,7 @@ fn run_login(args: SendArgs) -> Result<()> { .clone(), None => Environment::new(""), }; - let secrets = secret_resolver(&workspace, args.keychain, args.secrets); + let secrets = secret_resolver(&workspace, args.keychain, args.secret_backend, args.secrets); let ctx = with_app_defaults(workspace.effective_context(node_id, &env)); let cfg = oauth::resolve_config(&ctx.auth, &ctx.env, secrets.as_ref(), &ctx.auth_base_url)? @@ -305,18 +306,30 @@ fn token_store(workspace: &Workspace) -> LayeredTokenStore { store } -/// The secret backend selected by the send-style flags. +/// The secret store selected by the send-style flags, most specific first: `--keychain` pins the OS +/// keychain, `--secret K=V` supplies values inline (no store is read), `--secret-backend` swaps the +/// backend for this run, and otherwise the machine's configured backend applies. That last case is +/// the point: a box set up for `file`/`env` must resolve secrets from the CLI without extra flags. fn secret_resolver( workspace: &Workspace, keychain: bool, + backend: Option, secrets: Vec<(String, String)>, ) -> Box { if keychain { Box::new(KeyringSecrets::for_workspace(&workspace.name)) } else if !secrets.is_empty() { Box::new(MapSecrets(secrets.into_iter().collect())) + } else if let Some(backend) = backend { + // Only the backend is overridden — the configured env prefix / helper argv still apply, + // so `--secret-backend command` uses the command the user already set up. + let config = SecretsConfig { + backend, + ..AppSettings::load().secrets + }; + Box::new(WorkspaceSecrets::new(&workspace.name, &config)) } else { - Box::new(NoSecrets) + Box::new(WorkspaceSecrets::for_workspace(&workspace.name)) } } @@ -336,7 +349,7 @@ fn run_send(send: SendArgs) -> Result<()> { None => Environment::new(""), }; - let secrets = secret_resolver(&workspace, send.keychain, send.secrets); + let secrets = secret_resolver(&workspace, send.keychain, send.secret_backend, send.secrets); let ctx = with_app_defaults(workspace.effective_context(node_id, &env)); let engine = EngineHandle::new()?; @@ -380,7 +393,7 @@ fn run_codegen(args: CodegenArgs) -> Result<()> { .clone(), None => Environment::new(""), }; - let secrets = secret_resolver(&workspace, args.keychain, args.secrets); + let secrets = secret_resolver(&workspace, args.keychain, args.secret_backend, args.secrets); let ctx = with_app_defaults(workspace.effective_context(node_id, &env)); // Codegen never fetches (it must stay sync/offline); an OAuth2 request emits without a bearer. @@ -408,7 +421,7 @@ fn run_test(args: TestArgs) -> Result<()> { .clone(), None => Environment::new(""), }; - let secrets = secret_resolver(&workspace, args.keychain, args.secrets); + let secrets = secret_resolver(&workspace, args.keychain, args.secret_backend, args.secrets); let ctx = with_app_defaults(workspace.effective_context(node_id, &env)); let engine = EngineHandle::new()?; diff --git a/crates/storage/src/app_settings.rs b/crates/storage/src/app_settings.rs index 44f6443..83d1c48 100644 --- a/crates/storage/src/app_settings.rs +++ b/crates/storage/src/app_settings.rs @@ -7,8 +7,13 @@ use std::path::PathBuf; use posel_core::RequestDefaults; use serde::{Deserialize, Serialize}; +use crate::secrets::SecretsConfig; use crate::StorageError; +fn is_default_secrets(v: &SecretsConfig) -> bool { + *v == SecretsConfig::default() +} + /// How the request editor and response panel are arranged within the main area. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -61,10 +66,14 @@ impl ThemePreset { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AppSettings { /// Never persist OAuth tokens in the OS keychain — keep them in memory for the session only. - /// For users who don't want the keychain (or are on a machine without one). Secrets are - /// unaffected: they have no in-memory fallback and still resolve from the keychain. + /// For users who don't want the keychain (or are on a machine without one). This governs OAuth + /// tokens only; where `{{secret:…}}` values come from is [`Self::secrets`]. #[serde(default)] pub disable_keychain: bool, + /// Which backend `{{secret:…}}` resolves against. Defaults to the OS keychain; switchable for + /// machines that have none (headless Linux, containers, CI). See [`SecretsConfig`]. + #[serde(default, skip_serializing_if = "is_default_secrets")] + pub secrets: SecretsConfig, /// Machine-global request execution defaults (timeouts, body limits). The base of the /// inheritance chain — scope/folder settings override these per [`RequestDefaults::or_base`]. #[serde(default, skip_serializing_if = "RequestDefaults::is_empty")] @@ -131,6 +140,7 @@ impl Default for AppSettings { fn default() -> Self { Self { disable_keychain: false, + secrets: SecretsConfig::default(), defaults: RequestDefaults::default(), font_scale: default_font_scale(), editor_font_size: default_editor_font_size(), diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index a89c125..37ae60c 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -22,7 +22,10 @@ pub use app_settings::{AppSettings, ResponseLayout, ThemePreset}; pub use git::{status as git_status, GitStatus}; pub use history::{history_path, History, HistoryEntry}; pub use keymap::KeymapOverrides; -pub use secrets::KeyringSecrets; +pub use secrets::{ + CommandSecrets, EnvSecrets, FileSecrets, KeyringSecrets, SecretBackend, SecretsConfig, + SecretsError, WorkspaceSecrets, +}; pub use session::{ last_workspace, session_path, set_last_workspace, SessionRow, SessionState, SessionTab, }; diff --git a/crates/storage/src/secrets/command_backend.rs b/crates/storage/src/secrets/command_backend.rs new file mode 100644 index 0000000..ca829ad --- /dev/null +++ b/crates/storage/src/secrets/command_backend.rs @@ -0,0 +1,265 @@ +//! Helper-command secrets — read-only. +//! +//! Runs a user-configured command per lookup and takes its stdout as the value, in the spirit of +//! git's credential helpers. This keeps values in whatever password manager the user already +//! trusts (`pass show posel/{name}`, `op read op://vault/{name}/token`, `gh auth token`, …) with +//! nothing extra stored by Posel. + +use std::process::{Command, Stdio}; +use std::time::Duration; + +use super::{SecretStore, SecretsError}; + +/// How long a helper may take before we give up and kill it. Lookups happen on the UI thread, and a +/// helper can block indefinitely on a `pinentry` prompt or a biometric confirmation — without a +/// bound that freezes the whole app with no way out. +const HELPER_TIMEOUT: Duration = Duration::from_secs(20); + +/// Resolves secrets by shelling out to a configured argv. +pub struct CommandSecrets { + /// Argv template. Every occurrence of `{name}` in every element is replaced with the secret + /// name. Element 0 is the program. + argv: Vec, +} + +impl CommandSecrets { + pub fn new(argv: Vec) -> Self { + Self { argv } + } + + /// The argv for a given secret, with `{name}` expanded. + fn resolved_argv(&self, name: &str) -> Vec { + self.argv + .iter() + .map(|part| part.replace("{name}", name)) + .collect() + } +} + +impl SecretStore for CommandSecrets { + fn get(&self, name: &str) -> Result { + let argv = self.resolved_argv(name); + let Some((program, args)) = argv.split_first() else { + return Err(SecretsError::Unavailable { + backend: "Helper command", + detail: "no command configured — set `command` under [secrets] in settings.toml" + .to_string(), + }); + }; + + // Spawn rather than `output()` so the wait can be bounded. stdin is closed so a helper that + // wants to prompt fails fast instead of blocking forever on a terminal that isn't there. + let child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|source| SecretsError::Unavailable { + backend: "Helper command", + detail: format!("could not run `{program}`: {source}"), + })?; + + let output = wait_bounded(child, HELPER_TIMEOUT).map_err(|e| match e { + WaitError::Timeout => SecretsError::Unavailable { + backend: "Helper command", + detail: format!( + "`{program}` did not respond within {}s and was terminated — a helper that \ + prompts interactively can't be used here", + HELPER_TIMEOUT.as_secs() + ), + }, + WaitError::Io(source) => SecretsError::Unavailable { + backend: "Helper command", + detail: format!("`{program}` failed: {source}"), + }, + })?; + + if !output.status.success() { + // A helper commonly exits non-zero simply because the entry is absent; surface its + // stderr so the user sees the real reason rather than a bare exit code. + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let detail = if stderr.is_empty() { + format!("`{program}` exited with {}", output.status) + } else { + stderr + }; + return Err(SecretsError::Unavailable { + backend: "Helper command", + detail: format!("{name}: {detail}"), + }); + } + + // Trim the trailing newline helpers almost always emit; keep interior whitespace intact + // in case the secret legitimately contains it (e.g. a PEM block). + let value = String::from_utf8_lossy(&output.stdout) + .trim_end_matches(['\n', '\r']) + .to_string(); + if value.is_empty() { + return Err(SecretsError::NotFound(name.to_string())); + } + Ok(value) + } +} + +/// Captured output of a bounded helper run. +struct HelperOutput { + status: std::process::ExitStatus, + stdout: Vec, + stderr: Vec, +} + +enum WaitError { + Timeout, + Io(std::io::Error), +} + +/// Wait for `child`, killing it if it outlives `timeout`. +/// +/// std has no `wait_timeout`, so this polls `try_wait`. The pipes are drained on their own threads: +/// a helper that writes more than a pipe buffer would otherwise block on the write while we block +/// on the wait, and neither side would ever progress. +fn wait_bounded( + mut child: std::process::Child, + timeout: Duration, +) -> Result { + use std::io::Read as _; + + // ChildStdout and ChildStderr are distinct types, so each gets its own reader thread. + let out = child.stdout.take(); + let err = child.stderr.take(); + let out_thread = std::thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(mut p) = out { + let _ = p.read_to_end(&mut buf); + } + buf + }); + let err_thread = std::thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(mut p) = err { + let _ = p.read_to_end(&mut buf); + } + buf + }); + + let deadline = std::time::Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => { + if std::time::Instant::now() >= deadline { + // Kill, then reap, so we don't leave a zombie behind. + let _ = child.kill(); + let _ = child.wait(); + return Err(WaitError::Timeout); + } + std::thread::sleep(Duration::from_millis(20)); + } + Err(e) => return Err(WaitError::Io(e)), + } + }; + + Ok(HelperOutput { + status, + stdout: out_thread.join().unwrap_or_default(), + stderr: err_thread.join().unwrap_or_default(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expands_the_name_placeholder_in_every_argv_element() { + let helper = CommandSecrets::new(vec![ + "pass".into(), + "show".into(), + "posel/{name}".into(), + "--{name}".into(), + ]); + assert_eq!( + helper.resolved_argv("github_token"), + vec!["pass", "show", "posel/github_token", "--github_token"] + ); + } + + #[test] + fn an_empty_argv_explains_itself_instead_of_panicking() { + let helper = CommandSecrets::new(Vec::new()); + let err = helper.get("token").unwrap_err().to_string(); + assert!(err.contains("no command configured"), "{err}"); + } + + #[cfg(unix)] + #[test] + fn reads_stdout_and_strips_the_trailing_newline() { + let helper = CommandSecrets::new(vec!["printf".into(), "%s\\n".into(), "s3cr3t".into()]); + assert_eq!(helper.get("ignored").unwrap(), "s3cr3t"); + } + + #[cfg(unix)] + #[test] + fn a_missing_program_reports_it_as_unavailable() { + let helper = CommandSecrets::new(vec!["posel-no-such-helper-9f3a".into()]); + assert!(matches!( + helper.get("token"), + Err(SecretsError::Unavailable { .. }) + )); + } + + /// A helper that blocks forever (a `pinentry` prompt with no terminal, say) must be killed + /// rather than hang the caller — lookups run on the UI thread. + #[cfg(unix)] + #[test] + fn a_hanging_helper_is_killed_instead_of_blocking_forever() { + let child = Command::new("sh") + .args(["-c", "sleep 300"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn sleeper"); + + let started = std::time::Instant::now(); + let result = wait_bounded(child, Duration::from_millis(300)); + let elapsed = started.elapsed(); + + assert!( + matches!(result, Err(WaitError::Timeout)), + "expected timeout" + ); + assert!( + elapsed < Duration::from_secs(5), + "wait_bounded blocked for {elapsed:?} — the timeout did not fire" + ); + } + + /// stdout larger than a pipe buffer (64 KiB) must not deadlock: the child blocks writing while + /// the parent blocks waiting unless the pipes are drained concurrently. + #[cfg(unix)] + #[test] + fn a_chatty_helper_does_not_deadlock_the_wait() { + let helper = CommandSecrets::new(vec![ + "sh".into(), + "-c".into(), + // ~200 KiB, comfortably past a pipe buffer. + "yes ABCDEFGHIJKLMNOPQRSTUVWXYZ | head -c 200000".into(), + ]); + let value = helper.get("ignored").expect("helper should complete"); + assert!(value.len() > 100_000, "got {} bytes", value.len()); + } + + #[cfg(unix)] + #[test] + fn a_failing_helper_surfaces_its_stderr() { + let helper = CommandSecrets::new(vec![ + "sh".into(), + "-c".into(), + "echo 'entry not found' >&2; exit 1".into(), + ]); + let err = helper.get("token").unwrap_err().to_string(); + assert!(err.contains("entry not found"), "{err}"); + } +} diff --git a/crates/storage/src/secrets/env_backend.rs b/crates/storage/src/secrets/env_backend.rs new file mode 100644 index 0000000..d923c44 --- /dev/null +++ b/crates/storage/src/secrets/env_backend.rs @@ -0,0 +1,71 @@ +//! Environment-variable secrets — read-only. +//! +//! `{{secret:github_token}}` reads `$POSEL_SECRET_GITHUB_TOKEN` (with the configured prefix). +//! Nothing is written to disk by Posel, which makes this the natural choice for CI and for +//! anyone who already injects secrets into the process environment. + +use super::{shout_case, SecretStore, SecretsError}; + +/// Resolves secrets from the process environment under a fixed prefix. +pub struct EnvSecrets { + prefix: String, +} + +impl EnvSecrets { + pub fn new(prefix: &str) -> Self { + Self { + prefix: prefix.to_string(), + } + } + + /// The environment variable a secret name maps to — surfaced in the UI so users know exactly + /// what to export. + #[must_use] + pub fn var_name(&self, name: &str) -> String { + format!("{}{}", self.prefix, shout_case(name)) + } +} + +impl SecretStore for EnvSecrets { + fn get(&self, name: &str) -> Result { + let var = self.var_name(name); + match std::env::var(&var) { + // An empty value is treated as unset: exporting `FOO=` is a common way to clear it, + // and sending an empty bearer token is never what the user meant. + Ok(v) if !v.is_empty() => Ok(v), + _ => Err(SecretsError::NotFound(format!("{name} (expected ${var})"))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn var_name_applies_prefix_and_normalizes() { + let env = EnvSecrets::new("POSEL_SECRET_"); + assert_eq!(env.var_name("github_token"), "POSEL_SECRET_GITHUB_TOKEN"); + assert_eq!(env.var_name("api-key"), "POSEL_SECRET_API_KEY"); + } + + #[test] + fn reads_a_set_variable_and_reports_the_expected_name_when_missing() { + let env = EnvSecrets::new("POSEL_TEST_SECRET_"); + std::env::set_var("POSEL_TEST_SECRET_TOKEN", "s3cr3t"); + assert_eq!(env.get("token").unwrap(), "s3cr3t"); + + // The error names the variable the user needs to export. + let err = env.get("absent").unwrap_err().to_string(); + assert!(err.contains("POSEL_TEST_SECRET_ABSENT"), "{err}"); + std::env::remove_var("POSEL_TEST_SECRET_TOKEN"); + } + + #[test] + fn an_empty_variable_counts_as_unset() { + let env = EnvSecrets::new("POSEL_TEST_EMPTY_"); + std::env::set_var("POSEL_TEST_EMPTY_TOKEN", ""); + assert!(env.get("token").is_err()); + std::env::remove_var("POSEL_TEST_EMPTY_TOKEN"); + } +} diff --git a/crates/storage/src/secrets/file_backend.rs b/crates/storage/src/secrets/file_backend.rs new file mode 100644 index 0000000..a1c7fbd --- /dev/null +++ b/crates/storage/src/secrets/file_backend.rs @@ -0,0 +1,334 @@ +//! Local-file secrets — the writable fallback for machines with no OS keychain. +//! +//! Values live in a single TOML file in the **user data directory**, never inside a workspace, so +//! collections stay safe to commit: the workspace `.toml` still only holds `{{secret:name}}` +//! references. Entries are namespaced per workspace, mirroring the keychain's service/account split. +//! +//! **This file is plaintext.** That is a deliberate trade so that secrets are usable at all on +//! headless/Linux machines, and it matches how comparable developer tools behave +//! (`~/.aws/credentials`, `~/.docker/config.json`, `gh`'s `hosts.yml`). It is created `0600` and +//! every rewrite re-asserts those permissions. Prefer the keychain where one exists. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use super::{SecretStore, SecretsError}; + +/// On-disk shape: workspace name → (secret name → value). +#[derive(Debug, Default, Serialize, Deserialize)] +struct SecretsFile { + #[serde(default)] + workspaces: BTreeMap>, +} + +/// Reads and writes secrets in a `0600` TOML file in the user data dir. +pub struct FileSecrets { + workspace: String, + path: Option, +} + +impl FileSecrets { + /// Namespace secrets under a workspace, using the default file location. + pub fn for_workspace(workspace: &str) -> Self { + Self { + workspace: workspace.to_string(), + path: default_path(), + } + } + + /// Use an explicit file — for tests, and for a future `path` setting. + pub fn at(workspace: &str, path: PathBuf) -> Self { + Self { + workspace: workspace.to_string(), + path: Some(path), + } + } + + /// Where secrets are stored, for display in the Secrets panel. + #[must_use] + pub fn path(&self) -> Option<&PathBuf> { + self.path.as_ref() + } + + fn resolved_path(&self) -> Result<&PathBuf, SecretsError> { + self.path.as_ref().ok_or_else(|| SecretsError::Unavailable { + backend: "Local file", + detail: "could not resolve the user data directory".to_string(), + }) + } + + fn read_file(&self) -> Result { + let path = self.resolved_path()?; + match std::fs::read_to_string(path) { + Ok(text) => toml::from_str(&text).map_err(|source| SecretsError::Parse { + path: path.clone(), + source: Box::new(source), + }), + // A missing file is simply an empty store — the first `set` creates it. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(SecretsFile::default()), + Err(source) => Err(SecretsError::Io { + path: path.clone(), + source, + }), + } + } + + fn write_file(&self, data: &SecretsFile) -> Result<(), SecretsError> { + let path = self.resolved_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|source| SecretsError::Io { + path: parent.to_path_buf(), + source, + })?; + } + let text = + toml::to_string_pretty(data).map_err(|e| SecretsError::Serialize(Box::new(e)))?; + + // Write to a temp file in the same directory, then rename over the target. The temp is + // *created* owner-only rather than chmod'ed afterwards: `fs::write` would create it with + // 0666 & ~umask and the plaintext would be group/world-readable until the chmod landed — + // and would stay that way forever if we were killed in between. The name carries the pid + // so two processes (app + CLI) can't scribble on each other's temp. + let tmp = path.with_file_name(format!( + "{}.{}.tmp", + path.file_name().unwrap_or_default().to_string_lossy(), + std::process::id() + )); + write_private(&tmp, &text)?; + // Rename is atomic within a filesystem, so a crash leaves either the old file or the new + // one — never a truncated store. + std::fs::rename(&tmp, path).map_err(|source| { + // Don't leave a stray temp full of secrets behind if the rename fails. + let _ = std::fs::remove_file(&tmp); + SecretsError::Io { + path: path.clone(), + source, + } + })?; + // The renamed inode already carries the temp's mode; re-assert it in case the destination + // pre-existed with looser permissions on a platform where rename preserves them. + restrict_permissions(path) + } +} + +impl SecretStore for FileSecrets { + fn get(&self, name: &str) -> Result { + self.read_file()? + .workspaces + .get(&self.workspace) + .and_then(|entries| entries.get(name)) + .filter(|v| !v.is_empty()) + .cloned() + .ok_or_else(|| SecretsError::NotFound(name.to_string())) + } + + fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> { + let mut data = self.read_file()?; + data.workspaces + .entry(self.workspace.clone()) + .or_default() + .insert(name.to_string(), value.to_string()); + self.write_file(&data) + } + + fn delete(&self, name: &str) -> Result<(), SecretsError> { + let mut data = self.read_file()?; + let removed = data + .workspaces + .get_mut(&self.workspace) + .and_then(|entries| entries.remove(name)) + .is_some(); + if !removed { + return Err(SecretsError::NotFound(name.to_string())); + } + // Drop the workspace table once empty so the file doesn't accumulate dead sections. + if data + .workspaces + .get(&self.workspace) + .is_some_and(BTreeMap::is_empty) + { + data.workspaces.remove(&self.workspace); + } + self.write_file(&data) + } +} + +/// Default location: the user data dir, beside `settings.toml`. +fn default_path() -> Option { + directories::ProjectDirs::from("", "", "posel") + .map(|dirs| dirs.data_local_dir().join("secrets.toml")) +} + +/// Create `path` (failing if it exists) owner-only from the outset and write `text` to it, then +/// flush to disk. The mode is applied *at creation* so the plaintext is never readable by anyone +/// else, not even for the instant between write and chmod. +#[cfg(unix)] +fn write_private(path: &PathBuf, text: &str) -> Result<(), SecretsError> { + use std::io::Write as _; + use std::os::unix::fs::OpenOptionsExt as _; + + let io = |source| SecretsError::Io { + path: path.clone(), + source, + }; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .map_err(io)?; + file.write_all(text.as_bytes()).map_err(io)?; + // Durability: rename only orders the *name*, not the bytes. Without this a power loss can + // publish a zero-length secrets.toml. + file.sync_all().map_err(io) +} + +/// Non-unix has no mode bits to set at creation; the file inherits the ACL of the per-user data +/// directory (`%LOCALAPPDATA%` on Windows), which is already user-scoped. +#[cfg(not(unix))] +fn write_private(path: &PathBuf, text: &str) -> Result<(), SecretsError> { + use std::io::Write as _; + + let io = |source| SecretsError::Io { + path: path.clone(), + source, + }; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(io)?; + file.write_all(text.as_bytes()).map_err(io)?; + file.sync_all().map_err(io) +} + +/// Owner-only permissions on an existing file. A no-op on non-unix — see [`write_private`]. +#[cfg(unix)] +fn restrict_permissions(path: &PathBuf) -> Result<(), SecretsError> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| { + SecretsError::Io { + path: path.clone(), + source, + } + }) +} + +#[cfg(not(unix))] +fn restrict_permissions(_path: &PathBuf) -> Result<(), SecretsError> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_store(workspace: &str, label: &str) -> (FileSecrets, PathBuf) { + let path = std::env::temp_dir().join(format!("posel-secrets-test-{label}.toml")); + let _ = std::fs::remove_file(&path); + (FileSecrets::at(workspace, path.clone()), path) + } + + #[test] + fn round_trips_a_secret() { + let (store, path) = temp_store("ws", "roundtrip"); + assert!(store.get("token").is_err(), "absent before set"); + store.set("token", "s3cr3t").unwrap(); + assert_eq!(store.get("token").unwrap(), "s3cr3t"); + store.delete("token").unwrap(); + assert!(store.get("token").is_err(), "absent after delete"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn workspaces_are_namespaced_so_names_do_not_collide() { + let path = std::env::temp_dir().join("posel-secrets-test-ns.toml"); + let _ = std::fs::remove_file(&path); + let a = FileSecrets::at("alpha", path.clone()); + let b = FileSecrets::at("beta", path.clone()); + + a.set("token", "from-alpha").unwrap(); + b.set("token", "from-beta").unwrap(); + + assert_eq!(a.get("token").unwrap(), "from-alpha"); + assert_eq!(b.get("token").unwrap(), "from-beta"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn deleting_an_absent_secret_is_an_error_not_a_silent_success() { + let (store, path) = temp_store("ws", "delete-absent"); + assert!(matches!( + store.delete("nope"), + Err(SecretsError::NotFound(_)) + )); + let _ = std::fs::remove_file(path); + } + + #[cfg(unix)] + #[test] + fn the_file_is_created_owner_only() { + use std::os::unix::fs::PermissionsExt; + let (store, path) = temp_store("ws", "perms"); + store.set("token", "s3cr3t").unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "secrets file must not be group/world readable"); + let _ = std::fs::remove_file(path); + } + + /// The mode must be set **at creation**, not chmod'ed on afterwards: writing first would leave + /// the plaintext group/world-readable for an instant — and permanently if the process died in + /// between. Asserting on the finished file can't tell those apart, so inspect the temp file + /// mid-write, while it still holds the secret and before the rename. + #[cfg(unix)] + #[test] + fn the_secret_is_never_world_readable_even_mid_write() { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join("posel-secrets-test-midwrite"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("secrets.toml"); + + write_private(&path, "token = \"s3cr3t\"").unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "the file holding plaintext was created as {mode:o}, readable beyond the owner" + ); + assert!(std::fs::read_to_string(&path).unwrap().contains("s3cr3t")); + + // create_new: a colliding temp must fail rather than clobber another writer's file. + assert!(write_private(&path, "x").is_err()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_failed_write_leaves_no_temp_file_behind() { + let dir = std::env::temp_dir().join("posel-secrets-test-notmp"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let store = FileSecrets::at("ws", dir.join("secrets.toml")); + store.set("token", "s3cr3t").unwrap(); + + // Only the store itself survives a successful write — no `.tmp` siblings holding plaintext. + let strays: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".tmp")) + .collect(); + assert!(strays.is_empty(), "left temp files behind: {strays:?}"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_missing_file_reads_as_empty_rather_than_failing() { + let path = std::env::temp_dir().join("posel-secrets-test-absent-file.toml"); + let _ = std::fs::remove_file(&path); + let store = FileSecrets::at("ws", path); + assert!(matches!(store.get("x"), Err(SecretsError::NotFound(_)))); + } +} diff --git a/crates/storage/src/secrets.rs b/crates/storage/src/secrets/keyring_backend.rs similarity index 66% rename from crates/storage/src/secrets.rs rename to crates/storage/src/secrets/keyring_backend.rs index e5236ea..e1e41c1 100644 --- a/crates/storage/src/secrets.rs +++ b/crates/storage/src/secrets/keyring_backend.rs @@ -4,6 +4,8 @@ use posel_template::SecretResolver; +use super::{SecretStore, SecretsError}; + /// Resolves `{{secret:name}}` against the OS keychain via the `keyring` crate. pub struct KeyringSecrets { service: String, @@ -47,6 +49,39 @@ impl SecretResolver for KeyringSecrets { } } +/// Map `keyring`'s errors onto the backend-neutral ones. The distinction matters for the UI: a +/// missing entry is normal and actionable ("set it"), whereas an unreachable keychain means the +/// user should switch backend entirely — the common case on Linux without a Secret Service. +fn classify(err: keyring::Error, name: &str) -> SecretsError { + match err { + keyring::Error::NoEntry => SecretsError::NotFound(name.to_string()), + other => SecretsError::Unavailable { + backend: "OS keychain", + detail: other.to_string(), + }, + } +} + +impl SecretStore for KeyringSecrets { + fn get(&self, name: &str) -> Result { + keyring::Entry::new(&self.service, name) + .and_then(|entry| entry.get_password()) + .map_err(|e| classify(e, name)) + } + + fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> { + keyring::Entry::new(&self.service, name) + .and_then(|entry| entry.set_password(value)) + .map_err(|e| classify(e, name)) + } + + fn delete(&self, name: &str) -> Result<(), SecretsError> { + keyring::Entry::new(&self.service, name) + .and_then(|entry| entry.delete_credential()) + .map_err(|e| classify(e, name)) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/storage/src/secrets/mod.rs b/crates/storage/src/secrets/mod.rs new file mode 100644 index 0000000..60dca1c --- /dev/null +++ b/crates/storage/src/secrets/mod.rs @@ -0,0 +1,436 @@ +//! Secret storage — pluggable backends behind one facade. +//! +//! `{{secret:name}}` resolves through whichever backend the user configured. The OS keychain is +//! the default and remains the recommended choice, but it is not always *available*: a Linux box +//! with no Secret Service provider (headless, servers, containers, minimal WMs, CI) has no +//! keychain at all, which used to make secrets unusable there. +//! +//! Backends split into two classes: +//! +//! - **Writable** — [`Keychain`](SecretBackend::Keychain) and [`File`](SecretBackend::File). The +//! Secrets panel can create, edit and delete entries. +//! - **Read-only** — [`Env`](SecretBackend::Env) and [`Command`](SecretBackend::Command). Values +//! come from outside Posel (CI environment, `pass`, 1Password, `gh auth token`, …), so the UI +//! surfaces them but must not offer to edit them. +//! +//! Whatever the backend, **secret values never enter the workspace directory** — the `.toml` on +//! disk only ever holds the `{{secret:name}}` *reference*, so a collection stays safe to commit. + +mod command_backend; +mod env_backend; +mod file_backend; +mod keyring_backend; + +use std::path::PathBuf; + +use posel_template::SecretResolver; +use serde::{Deserialize, Serialize}; + +pub use command_backend::CommandSecrets; +pub use env_backend::EnvSecrets; +pub use file_backend::FileSecrets; +pub use keyring_backend::KeyringSecrets; + +/// Anything that can go wrong reading or writing a secret, in backend-neutral terms. +#[derive(Debug, thiserror::Error)] +pub enum SecretsError { + /// The name isn't set in this backend. Distinct from [`Unavailable`](Self::Unavailable): the + /// backend worked, the entry just isn't there. + #[error("secret '{0}' is not set")] + NotFound(String), + /// The backend itself could not be reached — no Secret Service, unreadable file, helper + /// command missing. Carries a hint the UI can show, because this is the case users get stuck on. + #[error("{backend} secret storage is unavailable: {detail}")] + Unavailable { + backend: &'static str, + detail: String, + }, + /// The active backend cannot be written to (env vars / helper command). + #[error("the {0} secret backend is read-only; values are managed outside Posel")] + ReadOnly(&'static str), + #[error("io error at {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + /// A malformed secrets file. The message deliberately gives only the location, never the + /// wrapped error's text: `toml`'s Display quotes the offending source line, which in this file + /// *is* a secret — and this string reaches UI toasts and CLI stderr. + #[error("failed to parse {path}{}", location(.source))] + Parse { + path: PathBuf, + #[source] + source: Box, + }, + /// As with [`Parse`](Self::Parse), the serializer error is not interpolated — it can echo the + /// value it failed on. + #[error("failed to serialize secrets")] + Serialize(#[source] Box), +} + +/// `" at line L, column C"` for a TOML error, or an empty string when it carries no span. Lets a +/// parse failure stay actionable without quoting the offending line (which would be a secret). +fn location(err: &toml::de::Error) -> String { + err.span() + .map(|span| format!(" (byte offset {})", span.start)) + .unwrap_or_default() +} + +/// Which store `{{secret:…}}` reads from. Serialized into `settings.toml` under `[secrets]`. +/// +/// Deserialization is deliberately **tolerant**: `AppSettings::load()` falls back to *all* defaults +/// if any part of the file fails to parse, so a typo'd backend name would otherwise silently reset +/// the user's theme, font scale and history limits too. An unknown value degrades to the default +/// backend and leaves the rest of the settings intact. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SecretBackend { + /// The OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service). + /// The default, and the most secure option where it exists. + #[default] + Keychain, + /// A `0600` TOML file in the user data dir — **outside** any workspace. The writable fallback + /// for machines with no keychain. Plaintext at rest: the same posture as `~/.aws/credentials`, + /// `~/.docker/config.json` or `gh`'s `hosts.yml`, and a deliberate trade for usability. + File, + /// Environment variables (`{{secret:github_token}}` → `$POSEL_SECRET_GITHUB_TOKEN`). + /// Read-only; ideal for CI. + Env, + /// An external helper command (`pass show posel/{name}`, `op read …`, `gh auth token`). + /// Read-only; keeps values in an existing password manager. + Command, + /// No secret backend — every `{{secret:…}}` fails. For users who want no secret support at all. + None, +} + +impl SecretBackend { + pub const ALL: [SecretBackend; 5] = [ + SecretBackend::Keychain, + SecretBackend::File, + SecretBackend::Env, + SecretBackend::Command, + SecretBackend::None, + ]; + + #[must_use] + pub fn label(self) -> &'static str { + match self { + SecretBackend::Keychain => "OS keychain", + SecretBackend::File => "Local file", + SecretBackend::Env => "Environment variables", + SecretBackend::Command => "Helper command", + SecretBackend::None => "Disabled", + } + } + + /// Whether Posel can create/edit/delete entries in this backend. Read-only backends are owned + /// by something outside Posel, so the Secrets panel must present them as such. + #[must_use] + pub fn is_writable(self) -> bool { + matches!(self, SecretBackend::Keychain | SecretBackend::File) + } + + /// One line describing where values live, for the Secrets panel. + #[must_use] + pub fn description(self) -> &'static str { + match self { + SecretBackend::Keychain => "Encrypted by the operating system. Recommended.", + SecretBackend::File => { + "Plaintext in a 0600 file in your user data directory — never inside a workspace." + } + SecretBackend::Env => "Read from the environment; set them before launching Posel.", + SecretBackend::Command => "Read from an external password manager on each send.", + SecretBackend::None => "Secret references will not resolve.", + } + } + + fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "keychain" | "keyring" => Some(SecretBackend::Keychain), + "file" => Some(SecretBackend::File), + "env" | "environment" => Some(SecretBackend::Env), + "command" | "helper" => Some(SecretBackend::Command), + "none" | "disabled" => Some(SecretBackend::None), + _ => None, + } + } +} + +impl<'de> Deserialize<'de> for SecretBackend { + fn deserialize>(d: D) -> Result { + let raw = String::deserialize(d)?; + Ok(Self::parse(&raw).unwrap_or_default()) + } +} + +fn default_env_prefix() -> String { + "POSEL_SECRET_".to_string() +} + +fn is_default_env_prefix(v: &String) -> bool { + v == &default_env_prefix() +} + +/// The `[secrets]` block of `settings.toml`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SecretsConfig { + #[serde(default)] + pub backend: SecretBackend, + /// Prefix for [`SecretBackend::Env`] lookups. + #[serde( + default = "default_env_prefix", + skip_serializing_if = "is_default_env_prefix" + )] + pub env_prefix: String, + /// Argv for [`SecretBackend::Command`]. Any `{name}` token is replaced with the secret name. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub command: Vec, +} + +impl Default for SecretsConfig { + fn default() -> Self { + Self { + backend: SecretBackend::default(), + env_prefix: default_env_prefix(), + command: Vec::new(), + } + } +} + +impl SecretsConfig { + /// The effective backend: the `POSEL_SECRET_BACKEND` env override wins over the persisted + /// setting, so CI and one-off headless runs need no config file. + #[must_use] + pub fn effective_backend(&self) -> SecretBackend { + std::env::var("POSEL_SECRET_BACKEND") + .ok() + .as_deref() + .and_then(SecretBackend::parse) + .unwrap_or(self.backend) + } +} + +/// The one type callers construct. Dispatches to the configured backend and carries enough +/// context for the UI to explain itself (which backend, whether it can be written to). +pub struct WorkspaceSecrets { + backend: SecretBackend, + inner: Box, +} + +/// Uniform read/write surface implemented by each backend. +trait SecretStore: Send + Sync { + fn get(&self, name: &str) -> Result; + fn set(&self, _name: &str, _value: &str) -> Result<(), SecretsError> { + Err(SecretsError::ReadOnly("read-only")) + } + fn delete(&self, _name: &str) -> Result<(), SecretsError> { + Err(SecretsError::ReadOnly("read-only")) + } +} + +impl WorkspaceSecrets { + /// Build the store for `workspace` from an explicit config (tests, CLI flags). + pub fn new(workspace: &str, config: &SecretsConfig) -> Self { + let backend = config.effective_backend(); + let inner: Box = match backend { + SecretBackend::Keychain => Box::new(KeyringSecrets::for_workspace(workspace)), + SecretBackend::File => Box::new(FileSecrets::for_workspace(workspace)), + SecretBackend::Env => Box::new(EnvSecrets::new(&config.env_prefix)), + SecretBackend::Command => Box::new(CommandSecrets::new(config.command.clone())), + SecretBackend::None => Box::new(DisabledSecrets), + }; + Self { backend, inner } + } + + /// Build the store for `workspace` using the machine's persisted settings. The one-liner every + /// UI/CLI call site wants — it replaces the previously hardcoded `KeyringSecrets::for_workspace`. + pub fn for_workspace(workspace: &str) -> Self { + Self::new(workspace, &crate::AppSettings::load().secrets) + } + + /// Which backend is actually in use (after the env override). + #[must_use] + pub fn backend(&self) -> SecretBackend { + self.backend + } + + /// Whether the Secrets panel may offer editing. + #[must_use] + pub fn is_writable(&self) -> bool { + self.backend.is_writable() + } + + pub fn get(&self, name: &str) -> Result { + self.inner.get(name) + } + + pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> { + if !self.backend.is_writable() { + return Err(SecretsError::ReadOnly(self.backend.label())); + } + self.inner.set(name, value) + } + + pub fn delete(&self, name: &str) -> Result<(), SecretsError> { + if !self.backend.is_writable() { + return Err(SecretsError::ReadOnly(self.backend.label())); + } + self.inner.delete(name) + } + + /// Whether `name` currently has a value — presence only, never the plaintext. + #[must_use] + pub fn has(&self, name: &str) -> bool { + self.inner.get(name).is_ok() + } +} + +impl SecretResolver for WorkspaceSecrets { + fn resolve_secret(&self, name: &str) -> Result { + self.inner.get(name).map_err(|e| e.to_string()) + } +} + +/// [`SecretBackend::None`] — nothing resolves. +struct DisabledSecrets; + +impl SecretStore for DisabledSecrets { + fn get(&self, name: &str) -> Result { + Err(SecretsError::Unavailable { + backend: "Disabled", + detail: format!("secret support is turned off (needed '{name}')"), + }) + } +} + +/// Normalize a secret name into the tail of an env var / a safe file key: uppercase, and every +/// character outside `[A-Z0-9]` collapsed to `_`. +pub(crate) fn shout_case(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_uppercase() + } else { + '_' + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn env_override_beats_the_persisted_backend() { + let cfg = SecretsConfig { + backend: SecretBackend::Keychain, + ..SecretsConfig::default() + }; + // Without the override the persisted value stands. + assert_eq!(cfg.effective_backend(), SecretBackend::Keychain); + + std::env::set_var("POSEL_SECRET_BACKEND", "env"); + assert_eq!(cfg.effective_backend(), SecretBackend::Env); + // An unparseable value must not silently disable secrets — fall back to the setting. + std::env::set_var("POSEL_SECRET_BACKEND", "nonsense"); + assert_eq!(cfg.effective_backend(), SecretBackend::Keychain); + std::env::remove_var("POSEL_SECRET_BACKEND"); + } + + #[test] + fn read_only_backends_reject_writes_before_touching_the_store() { + let cfg = SecretsConfig { + backend: SecretBackend::Env, + ..SecretsConfig::default() + }; + let secrets = WorkspaceSecrets::new("ws", &cfg); + assert!(!secrets.is_writable()); + assert!(matches!( + secrets.set("token", "x"), + Err(SecretsError::ReadOnly(_)) + )); + assert!(matches!( + secrets.delete("token"), + Err(SecretsError::ReadOnly(_)) + )); + } + + #[test] + fn disabled_backend_fails_every_lookup() { + let cfg = SecretsConfig { + backend: SecretBackend::None, + ..SecretsConfig::default() + }; + let secrets = WorkspaceSecrets::new("ws", &cfg); + assert!(secrets.get("anything").is_err()); + assert!(!secrets.has("anything")); + } + + /// A corrupt secrets file must not echo its contents into the error string: that string is + /// shown in a UI toast and printed to CLI stderr, and in this file the "offending line" is a + /// secret. `toml`'s own Display renders the source line, so it must never be interpolated. + #[test] + fn a_parse_error_never_quotes_the_file_contents() { + let bad = "[workspaces.ws]\ntoken = \"SUPER_SECRET_VALUE\nother = 1\n"; + let source = toml::from_str::(bad).unwrap_err(); + // Sanity: the raw toml error *does* leak — which is exactly why we don't interpolate it. + assert!(source.to_string().contains("SUPER_SECRET_VALUE")); + + let err = SecretsError::Parse { + path: PathBuf::from("/tmp/secrets.toml"), + source: Box::new(source), + }; + let shown = err.to_string(); + assert!( + !shown.contains("SUPER_SECRET_VALUE"), + "secret leaked into the error message: {shown}" + ); + assert!(shown.contains("/tmp/secrets.toml"), "{shown}"); + } + + #[derive(Debug, serde::Deserialize)] + struct SecretsFileProbe { + #[allow(dead_code)] + workspaces: std::collections::BTreeMap>, + } + + /// A typo in `backend` must not take the whole settings file down with it: `AppSettings::load` + /// discards everything on a parse error, so a bad enum value would silently reset the user's + /// theme, font scale and history limits as well. + #[test] + fn an_unknown_backend_name_degrades_instead_of_failing_the_parse() { + #[derive(Debug, serde::Deserialize)] + struct Probe { + #[serde(default)] + secrets: SecretsConfig, + #[serde(default)] + font_scale: f32, + } + + let cfg: Probe = + toml::from_str("font_scale = 1.5\n[secrets]\nbackend = \"vault\"\n").expect("parses"); + assert_eq!(cfg.secrets.backend, SecretBackend::Keychain); + assert_eq!(cfg.font_scale, 1.5, "unrelated settings must survive"); + + // Known values still map, including the documented aliases. + let cfg: Probe = toml::from_str("[secrets]\nbackend = \"file\"\n").expect("parses"); + assert_eq!(cfg.secrets.backend, SecretBackend::File); + } + + #[test] + fn shout_case_normalizes_punctuation() { + assert_eq!(shout_case("github_token"), "GITHUB_TOKEN"); + assert_eq!(shout_case("api-key.v2"), "API_KEY_V2"); + } + + #[test] + fn writable_classification_matches_the_backends() { + assert!(SecretBackend::Keychain.is_writable()); + assert!(SecretBackend::File.is_writable()); + assert!(!SecretBackend::Env.is_writable()); + assert!(!SecretBackend::Command.is_writable()); + assert!(!SecretBackend::None.is_writable()); + } +} diff --git a/crates/ui/src/app_settings_editor.rs b/crates/ui/src/app_settings_editor.rs index 8daa98e..18a97e7 100644 --- a/crates/ui/src/app_settings_editor.rs +++ b/crates/ui/src/app_settings_editor.rs @@ -14,7 +14,7 @@ use gpui_component::{ v_flex, ActiveTheme as _, Disableable as _, IconName, IndexPath, Sizable as _, }; use posel_core::RequestDefaults; -use posel_storage::{AppSettings, ThemePreset}; +use posel_storage::{AppSettings, SecretBackend, ThemePreset}; use crate::command::OpenAppSettings; use crate::request::RequestView; @@ -39,6 +39,7 @@ fn yesno_index(v: Option) -> usize { pub(crate) struct AppSettingsState { theme: Entity>>, keychain: Entity>>, + secret_backend: Entity>>, autosave: Entity>>, timeout: Entity, connect_timeout: Entity, @@ -66,6 +67,7 @@ impl AppSettingsState { let shown = s.defaults.or_base(&RequestDefaults::engine_defaults()); Self::build( s.disable_keychain, + s.secrets.backend, s.autosave, s.theme, indent_tab_size(&s), @@ -84,6 +86,7 @@ impl AppSettingsState { #[allow(clippy::too_many_arguments)] // an internal constructor seeding many independent fields fn build( disable_keychain: bool, + secret_backend: SecretBackend, autosave: bool, theme: ThemePreset, indent: TabSize, @@ -124,9 +127,17 @@ impl AppSettingsState { .position(|p| *p == theme) .unwrap_or(0); + let backend_labels: Vec<&'static str> = + SecretBackend::ALL.iter().map(|b| b.label()).collect(); + let backend_idx = SecretBackend::ALL + .iter() + .position(|b| *b == secret_backend) + .unwrap_or(0); + Self { theme: select(theme_labels, theme_idx, window, cx), keychain: select(KEYCHAIN.to_vec(), usize::from(disable_keychain), window, cx), + secret_backend: select(backend_labels, backend_idx, window, cx), autosave: select(YESNO.to_vec(), yesno_index(Some(autosave)), window, cx), timeout: num(d.timeout_secs, "seconds", window, cx), connect_timeout: num(d.connect_timeout_secs, "seconds", window, cx), @@ -194,6 +205,11 @@ impl AppSettingsState { self.keychain.read(cx).selected_value().copied(), Some("Disabled") ); + // Keep the stored backend when the select has no selection, rather than silently rewriting + // the user's choice to the default. + if let Some(backend) = selected_backend(&self.secret_backend, cx) { + s.secrets.backend = backend; + } s.autosave = tri(&self.autosave) == Some(true); s.defaults = RequestDefaults { timeout_secs: num(&self.timeout), @@ -296,6 +312,7 @@ impl RequestView { if let Some(ix) = self.app_settings_index() { let state = Box::new(AppSettingsState::build( false, + SecretBackend::default(), false, ThemePreset::default(), TabSize::default(), @@ -492,6 +509,14 @@ impl RequestView { input(&state.indent_width), )) .child(section_label("Security", muted)) + .child(labelled( + "Secret storage", + // The description tracks the selection: each backend stores values + // somewhere materially different, and the read-only ones can't be + // edited from the Secrets panel at all. + Some(secret_backend_hint(&state.secret_backend, cx)), + select(&state.secret_backend), + )) .child(labelled( "OAuth keychain", Some("Disabled keeps tokens in memory for the session only."), @@ -547,6 +572,27 @@ pub(crate) fn indent_tab_size(s: &AppSettings) -> gpui_component::input::TabSize } } +/// The backend the picker currently shows, or `None` if nothing is selected. Matches on the label +/// the select was built from ([`SecretBackend::ALL`] order). +fn selected_backend( + select: &Entity>>, + cx: &App, +) -> Option { + let label = select.read(cx).selected_value().copied()?; + SecretBackend::ALL.into_iter().find(|b| b.label() == label) +} + +/// Hint line under the "Secret storage" picker, matching whatever is currently selected. Reads the +/// live select rather than the saved settings so the explanation updates before the page is saved. +fn secret_backend_hint( + select: &Entity>>, + cx: &Context, +) -> &'static str { + selected_backend(select, cx) + .unwrap_or_default() + .description() +} + fn section_label(text: &str, muted: Hsla) -> impl IntoElement { div() .pt_2() diff --git a/crates/ui/src/auth_editor.rs b/crates/ui/src/auth_editor.rs index 37d86d6..b392dc2 100644 --- a/crates/ui/src/auth_editor.rs +++ b/crates/ui/src/auth_editor.rs @@ -14,7 +14,7 @@ use gpui_component::{ }; use posel_core::{Auth, AuthKind, Node, OAuthGrant, Protocol}; use posel_engine::SecretResolver as _; -use posel_storage::KeyringSecrets; +use posel_storage::WorkspaceSecrets; use crate::auth_ui::AuthField; use crate::command::FocusAuthTab; @@ -449,7 +449,7 @@ impl RequestView { .workspace .as_ref() .map(|w| { - KeyringSecrets::for_workspace(&w.name) + WorkspaceSecrets::for_workspace(&w.name) .resolve_secret(&name) .unwrap_or_default() }) @@ -559,19 +559,17 @@ impl RequestView { return; } let name = self.derive_secret_name(field); - match KeyringSecrets::for_workspace(&ws_name).set(&name, &literal) { + match WorkspaceSecrets::for_workspace(&ws_name).set(&name, &literal) { Ok(()) => { input.update(cx, |s, cx| { s.set_value(format!("{{{{secret:{name}}}}}"), window, cx) }); cx.notify(); } + // The error already names the active backend (and says so when it's a read-only one), + // so don't hardcode "keychain" here — it may not be the keychain. Err(e) => { - crate::notify::toast_error( - window, - cx, - format!("Couldn’t store secret in keychain: {e}"), - ); + crate::notify::toast_error(window, cx, format!("Couldn’t store secret: {e}")); } } } diff --git a/crates/ui/src/request/interchange.rs b/crates/ui/src/request/interchange.rs index 4318553..7821d46 100644 --- a/crates/ui/src/request/interchange.rs +++ b/crates/ui/src/request/interchange.rs @@ -74,7 +74,7 @@ impl RequestView { let operation = self.active_tab().build_operation(cx); let ctx = self.effective_ctx(cx); let secrets: Box = match &self.workspace { - Some(ws) => Box::new(KeyringSecrets::for_workspace(&ws.name)), + Some(ws) => Box::new(WorkspaceSecrets::for_workspace(&ws.name)), None => Box::new(NoSecrets), }; let Ok(resolved) = ResolvedRequest::resolve_effective( diff --git a/crates/ui/src/request/mod.rs b/crates/ui/src/request/mod.rs index da3ebf8..4f18b00 100644 --- a/crates/ui/src/request/mod.rs +++ b/crates/ui/src/request/mod.rs @@ -34,9 +34,9 @@ use posel_engine::{ }; use posel_import::{detect_importer, importer, Imported, ImportedItem}; use posel_storage::{ - history_path, AppSettings, History, HistoryEntry, KeymapOverrides, KeyringSecrets, - LayeredTokenStore, RequestViewState, ResponseLayout, SessionState, SessionTab, UiState, - Workspace, WorkspaceWatcher, + history_path, AppSettings, History, HistoryEntry, KeymapOverrides, LayeredTokenStore, + RequestViewState, ResponseLayout, SessionState, SessionTab, UiState, Workspace, + WorkspaceSecrets, WorkspaceWatcher, }; use crate::about::AboutState; diff --git a/crates/ui/src/request/send.rs b/crates/ui/src/request/send.rs index c466b1f..f1aa9fa 100644 --- a/crates/ui/src/request/send.rs +++ b/crates/ui/src/request/send.rs @@ -123,7 +123,7 @@ impl RequestView { }; } let secrets: Box = match &self.workspace { - Some(ws) => Box::new(KeyringSecrets::for_workspace(&ws.name)), + Some(ws) => Box::new(WorkspaceSecrets::for_workspace(&ws.name)), None => Box::new(NoSecrets), }; @@ -578,7 +578,7 @@ impl RequestView { /// The secret backend for resolving auth fields (keychain when a workspace is loaded). fn oauth_secrets(&self) -> Box { match &self.workspace { - Some(ws) => Box::new(KeyringSecrets::for_workspace(&ws.name)), + Some(ws) => Box::new(WorkspaceSecrets::for_workspace(&ws.name)), None => Box::new(NoSecrets), } } diff --git a/crates/ui/src/secrets_panel.rs b/crates/ui/src/secrets_panel.rs index 95bc624..f42d508 100644 --- a/crates/ui/src/secrets_panel.rs +++ b/crates/ui/src/secrets_panel.rs @@ -1,9 +1,11 @@ //! The workspace **Secrets** — rendered as a section of the Workspace Settings tab (not a modal: //! secrets are persistent workspace configuration, so they live beside auth/variables/headers). Lists -//! every `{{secret:NAME}}` the workspace references — the keychain can't be enumerated, so -//! `Workspace::referenced_secret_names` scans the files — with each one's keychain status and controls -//! to set, reveal, change, or remove it. Values live only in the OS keychain, never on disk. Reached -//! via `ShowSecrets` and the Auth tab's vault chip / inherited read-out ("Set value" / "Manage"). +//! every `{{secret:NAME}}` the workspace references — a secret store can't be enumerated, so +//! `Workspace::referenced_secret_names` scans the files — with each one's status and controls to set, +//! reveal, change, or remove it. Values live in the configured backend +//! ([`posel_storage::SecretBackend`]), never in the workspace; read-only backends (env vars, helper +//! command) render without the write controls. Reached via `ShowSecrets` and the Auth tab's vault +//! chip / inherited read-out ("Set value" / "Manage"). use gpui::prelude::*; use gpui::*; @@ -15,20 +17,23 @@ use gpui_component::{ }; use std::collections::HashSet; -use posel_storage::KeyringSecrets; +use posel_storage::{AppSettings, SecretBackend, WorkspaceSecrets}; use crate::command::ShowSecrets; use crate::request::RequestView; use crate::settings_editor::{SettingsScope, SettingsSection}; use crate::theme::palette::Palette; -/// Working state for the Secrets section. `names`/`set` are snapshotted on section entry (a keychain +/// Working state for the Secrets section. `names`/`set` are snapshotted on section entry (a backend /// read per name), then updated in place on mutation so we don't re-scan every frame. pub(crate) struct SecretsState { names: Vec, set: HashSet, editing: Option, revealed: Option<(String, String)>, + /// The active backend, resolved once alongside `names`/`set`. Render reads it per row, and + /// `AppSettings::load()` is a file read + TOML parse — not something to do on every frame. + backend: SecretBackend, } /// An active set/add form. `target = Some(name)` sets a known referenced secret (name fixed); @@ -72,7 +77,7 @@ impl RequestView { self.secrets_begin_set(name, window, cx); } - /// Rebuild the referenced-names list + keychain status. Called on entry to the Secrets section; + /// Rebuild the referenced-names list + per-name status. Called on entry to the Secrets section; /// resets any in-progress edit. pub(crate) fn refresh_secrets(&mut self) { let (names, set) = self.scan_secrets(); @@ -81,17 +86,18 @@ impl RequestView { set, editing: None, revealed: None, + backend: AppSettings::load().secrets.effective_backend(), }); } - /// The referenced names plus which are currently present in the keychain. Only meaningful with a + /// The referenced names plus which currently have a value in the active backend. Only with a /// workspace open (the settings tab requires one). fn scan_secrets(&self) -> (Vec, HashSet) { let Some(ws) = self.workspace.as_ref() else { return (Vec::new(), HashSet::new()); }; let names = ws.referenced_secret_names(); - let keyring = KeyringSecrets::for_workspace(&ws.name); + let keyring = WorkspaceSecrets::for_workspace(&ws.name); let set = names .iter() .filter(|n| keyring.get(n).is_ok()) @@ -107,10 +113,11 @@ impl RequestView { cx: &mut Context, ) -> SecretEdit { let name = cx.new(|cx| InputState::new(window, cx).placeholder("secret_name")); + let backend = AppSettings::load().secrets.effective_backend(); let value = cx.new(|cx| { InputState::new(window, cx) .masked(true) - .placeholder("value — stored in your OS keychain") + .placeholder(format!("value — stored in: {}", backend.label())) }); // Focus the value when the name is fixed, else start at the name field. if target.is_some() { @@ -166,7 +173,7 @@ impl RequestView { let Some(ws_name) = self.workspace.as_ref().map(|w| w.name.clone()) else { return; }; - match KeyringSecrets::for_workspace(&ws_name).set(&name, &value) { + match WorkspaceSecrets::for_workspace(&ws_name).set(&name, &value) { Ok(()) => { if let Some(s) = self.secrets.as_mut() { if !s.names.contains(&name) { @@ -180,11 +187,9 @@ impl RequestView { crate::notify::toast_ok(window, cx, format!("Saved secret “{name}”.")); cx.notify(); } - Err(e) => crate::notify::toast_error( - window, - cx, - format!("Couldn’t store secret in keychain: {e}"), - ), + // The error already names the backend and, when it's unavailable rather than merely + // unset, says why — which is the case that tells the user to switch backend. + Err(e) => crate::notify::toast_error(window, cx, format!("Couldn’t store secret: {e}")), } } @@ -201,7 +206,7 @@ impl RequestView { if matches!(&s.revealed, Some((n, _)) if *n == name) { s.revealed = None; } else if let Some(ws_name) = ws_name { - if let Ok(v) = KeyringSecrets::for_workspace(&ws_name).get(&name) { + if let Ok(v) = WorkspaceSecrets::for_workspace(&ws_name).get(&name) { s.revealed = Some((name, v)); } } @@ -218,7 +223,7 @@ impl RequestView { let Some(ws_name) = self.workspace.as_ref().map(|w| w.name.clone()) else { return; }; - match KeyringSecrets::for_workspace(&ws_name).delete(&name) { + match WorkspaceSecrets::for_workspace(&ws_name).delete(&name) { Ok(()) => { if let Some(s) = self.secrets.as_mut() { s.set.remove(&name); @@ -244,26 +249,34 @@ impl RequestView { }; let mono = cx.theme().mono_font_family.clone(); - let head = h_flex() - .w_full() - .items_center() - .gap_2() - .child( - div() - .flex_1() - .text_size(crate::widgets::label_size(cx)) - .text_color(muted) - .child( - "Secrets this workspace references. Stored in your OS keychain, never on disk.", - ), - ) - .child( + // Where values actually live depends on the configured backend, and read-only backends + // (env vars / helper command) are owned outside Posel — so the blurb must describe the + // active one and the write actions must not be offered at all. + let backend = s.backend; + let mut head = h_flex().w_full().items_center().gap_2().child( + div() + .flex_1() + .text_size(crate::widgets::label_size(cx)) + .text_color(muted) + // "· " rather than a space: the description is a standalone sentence (it's also the + // App Settings hint), so running the two together reads as one broken sentence. + .child(format!( + "Secrets this workspace references. {} · {}", + backend.label(), + backend.description() + )), + ); + if backend.is_writable() { + head = head.child( Button::new("secrets-add") .ghost() .small() .label("Add secret") - .on_click(cx.listener(|this, _, window, cx| this.secrets_begin_add(window, cx))), + .on_click( + cx.listener(|this, _, window, cx| this.secrets_begin_add(window, cx)), + ), ); + } let mut col = v_flex().w_full().gap_2().child(head); @@ -349,6 +362,8 @@ impl RequestView { .into_any_element(); } + let backend = s.backend; + let writable = backend.is_writable(); let (dot, dot_color, status) = if is_set { ("●", p.get, "set") } else { @@ -413,22 +428,26 @@ impl RequestView { })), ) }) - .child( - Button::new(("secret-set", i)) - .ghost() - .xsmall() - .label(if is_set { "Change" } else { "Set value" }) - .on_click(cx.listener(move |this, _, window, cx| { - this.secrets_begin_set(row_name.clone(), window, cx) - })), - ) - .when(is_set, |row| { + // Read-only backends (env vars / helper command) are managed outside Posel: offering + // Set/Remove there would produce a guaranteed failure, so don't render them. + .when(writable, |row| { + row.child( + Button::new(("secret-set", i)) + .ghost() + .xsmall() + .label(if is_set { "Change" } else { "Set value" }) + .on_click(cx.listener(move |this, _, window, cx| { + this.secrets_begin_set(row_name.clone(), window, cx) + })), + ) + }) + .when(is_set && writable, |row| { row.child( Button::new(("secret-remove", i)) .ghost() .xsmall() .icon(IconName::Delete) - .tooltip("Remove from keychain") + .tooltip(format!("Remove from {}", backend.label().to_lowercase())) .on_click(cx.listener(move |this, _, window, cx| { this.secrets_remove(remove_name.clone(), window, cx) })), diff --git a/crates/ui/src/var_grid.rs b/crates/ui/src/var_grid.rs index 6b78fe0..bc2eb20 100644 --- a/crates/ui/src/var_grid.rs +++ b/crates/ui/src/var_grid.rs @@ -9,9 +9,9 @@ //! column *is* an [`Environment`]. The grid only reorganizes how they're edited, so old `.toml` //! round-trips identically. //! -//! Secrets are per-environment cells only (masked, value in the OS keychain, `.toml` holds just the -//! `{{secret:…}}` reference). The Shared column deliberately can't hold a secret — a dev key must -//! never double as a prod key, so stage separation is structural, not a convention. +//! Secrets are per-environment cells only (masked, value in the configured secret backend, `.toml` +//! holds just the `{{secret:…}}` reference). The Shared column deliberately can't hold a secret — a +//! dev key must never double as a prod key, so stage separation is structural, not a convention. use gpui::prelude::*; use gpui::*; @@ -25,8 +25,7 @@ use gpui_component::{ }; use posel_core::{EnvColor, Environment, FolderSettings}; -use posel_engine::SecretResolver as _; -use posel_storage::KeyringSecrets; +use posel_storage::WorkspaceSecrets; use crate::command::{AddAllowedHost, EnvPolicyFlag, RemoveAllowedHost, ToggleEnvPolicyFlag}; use crate::completion::{template_provider, Fallback}; @@ -569,9 +568,11 @@ impl RequestView { cx.notify(); } - /// Toggle a per-env cell between a plain value and a keychain-backed secret. Promoting moves the - /// literal into the OS keychain and leaves a masked `{{secret:NAME}}` reference; demoting pulls - /// it back out and forgets it. The secret name is scoped to the *column's* environment. + /// Toggle a per-env cell between a plain value and a stored secret. Promoting moves the literal + /// into the configured secret backend and leaves a masked `{{secret:NAME}}` reference; demoting + /// pulls it back out and forgets it. Demoting is refused on a read-only backend, where the + /// value can't be removed from the store and would otherwise end up in the committed `.toml`. + /// The secret name is scoped to the *column's* environment. fn toggle_cell_secret( &mut self, row_id: usize, @@ -598,15 +599,44 @@ impl RequestView { let key = row.name.read(cx).value().trim().to_string(); let cell = cell.clone(); let value = cell.read(cx).value().to_string(); - let keyring = KeyringSecrets::for_workspace(&ws_name); + let keyring = WorkspaceSecrets::for_workspace(&ws_name); if let Some(name) = secret_ref_name(&value).map(str::to_string) { - let literal = keyring.resolve_secret(&name).unwrap_or_default(); + // Un-secreting writes the plaintext into the environment cell, which is persisted in the + // workspace `.toml`. That's only defensible when we can also *remove* it from the store — + // on a read-only backend (env/command) the delete can't happen, so the value would end up + // committed to the repo while still living outside Posel. Refuse instead. + if !keyring.is_writable() { + crate::notify::toast_error( + window, + cx, + format!( + "{} is read-only — manage “{name}” where it's stored.", + keyring.backend().label() + ), + ); + return; + } + // Don't blank the cell (and then delete the entry) just because the read failed: that + // silently destroys the secret. Keep the reference and say why. + let literal = match keyring.get(&name) { + Ok(v) => v, + Err(e) => { + crate::notify::toast_error(window, cx, format!("Couldn’t read “{name}”: {e}")); + return; + } + }; cell.update(cx, |s, cx| { s.set_masked(false, window, cx); s.set_value(literal, window, cx); }); - let _ = keyring.delete(&name); + if let Err(e) = keyring.delete(&name) { + crate::notify::toast_error( + window, + cx, + format!("Value inlined, but “{name}” is still stored: {e}"), + ); + } } else { if value.trim().is_empty() || value.contains("{{") { crate::notify::toast_error( diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6dd86ab..cc0b52f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -42,7 +42,7 @@ posel/ # repo root │ ├── storage/ # persistence — serde + toml, git-friendly (§6) │ │ ├── workspace.rs # workspace = a directory │ │ ├── format.rs # .toml (de)serialization + body sidecars -│ │ ├── secrets.rs # OS keychain via `keyring` +│ │ ├── secrets/ # pluggable secret backends (keychain/file/env/command) │ │ ├── watch.rs # file watching │ │ └── history.rs │ ├── schema/ # graphql (introspection, completion, format, validation) + openapi schema helpers @@ -151,8 +151,13 @@ single-threaded UI executor. Decided once, in `engine/runtime.rs`: - `template` resolves `{{var}}` against the active environment and built-in dynamics (`{{uuid}}`, `{{now}}`), producing a `ResolvedRequest` at send time. -- `{{secret:name}}` resolves from the **OS keychain** (`keyring` crate) — secrets are never - serialized to `.toml`, so collections are safe to commit. +- `{{secret:name}}` resolves through a **pluggable secret backend** (`storage::secrets`) behind one + `WorkspaceSecrets` facade: the **OS keychain** (`keyring` crate) by default, or a `0600` file in + the user data dir, environment variables, or a helper command — selected by `[secrets]` in + `settings.toml` and overridable via `POSEL_SECRET_BACKEND`. The keychain can't be the only option + because headless Linux/containers/CI have no Secret Service. Whichever backend is active, secret + *values* are never serialized to `.toml` and never land inside a workspace, so collections are + safe to commit. - A small **custom** resolver (not a full template engine) keeps control over scoping and the secret boundary. - **Send-time policy (`engine::policy`).** `core::compose` clamps the env's TLS floor into the @@ -235,7 +240,7 @@ upgrades are deliberate. | GraphQL schema/validation | in-house lexer + schema model (no heavy parser dep) | | Async runtime | `tokio` (+ `flume`/mpsc channels) | | Persistence | `serde` + `toml` | -| Secrets | `keyring` (OS keychain) | +| Secrets | `keyring` (OS keychain, default) + file / env / helper-command backends | | File watch | `notify` | | OpenAPI import | untyped `serde_json` traversal (`posel-import`) | | gRPC (post-v1) | `tonic` + `protox` / reflection | diff --git a/docs/SPEC.md b/docs/SPEC.md index 3d221af..d9e9487 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -30,7 +30,8 @@ huge-payload and instant-feel advantages a web runtime structurally can't match. 2. **Multi-protocol depth** — REST, GraphQL, and tRPC today; gRPC and WebSocket/SSE next, all behind one streaming execution model so each is first-class, not bolted on. 3. **Git-native & local-first** — collections are human-readable `.toml` on disk; collaboration is - git. No backend, no account, ever. Secrets in the OS keychain, never committed. + git. No backend, no account, ever. Secrets resolved from a store outside the workspace (the OS + keychain by default), never committed. 4. **Keyboard-driven / Zed-like** — command palette, everything reachable without the mouse, minimal chrome, instant startup. @@ -74,7 +75,8 @@ Saying no is the plan. Posel will **not**: ### Organization & persistence - **Collections** = a directory tree of `.toml` files (see §6), git-friendly. - **Environments** + variables, with an active-environment switcher. -- **Secrets** referenced by name, resolved from the OS keychain (never written to disk). +- **Secrets** referenced by name, resolved at send time from a pluggable backend — the OS keychain + by default (never written into the workspace; see §6). - **History** of sent requests. - Open multiple requests as **tabs**; multiple workspaces. @@ -133,8 +135,27 @@ file = "create-user.body.json" # or inline = """...""" Rules: - **Large/multi-line bodies** go in a sibling file (`*.body.*`) referenced by the request, so TOML stays readable and diffs are clean. -- **Secrets are never stored.** Environment values use a reference like `{{secret:token}}`, - resolved at send time from the OS keychain. The `.toml` is safe to commit. +- **Secret values never enter the workspace.** Environment values use a reference like + `{{secret:token}}`, resolved at send time from a **pluggable secret backend** configured under + `[secrets]` in the app settings file (`settings.toml` in the user data dir) and overridable per + run via `POSEL_SECRET_BACKEND` (or `posel-cli --secret-backend`). Whichever backend is active, + only the reference is written to `.toml`, so the collection is safe to commit. Backends: + - `keychain` *(default, writable)* — the OS keychain (macOS Keychain, Windows Credential + Manager, Linux Secret Service). The most secure option, but absent on headless Linux, + containers, and CI, which is why it can't be the only one. + - `file` *(writable)* — a `0600` TOML file in the user data dir, **outside any workspace**, + namespaced per workspace and written atomically. Plaintext at rest: the same posture as + `~/.aws/credentials` or `gh`'s `hosts.yml`, a deliberate trade so secrets work at all on a + machine with no keychain. + - `env` *(read-only)* — `{{secret:github_token}}` → `$POSEL_SECRET_GITHUB_TOKEN`; the prefix is + configurable (`env_prefix`). The CI choice. + - `command` *(read-only)* — an argv run per lookup whose stdout is the value, with `{name}` + substituted (`pass show posel/{name}`, `op read …`, `gh auth token`) — git-credential-helper + style, keeping values in the user's existing password manager. + - `none` — secret support off; every `{{secret:…}}` fails. + + Read-only backends are owned by something outside Posel, so the UI surfaces their entries but + never offers to create or edit them. - **Per-environment security policy (`[policy]` in `environments/*.toml`).** Opt-in per env, never derived from an env's name/color: `enforce_tls_verify` and `require_https` floors (clamps — they only tighten), an `allowed_hosts` egress list (exact or `*.wildcard`) gating where that env's diff --git a/docs/assets/posel-demo.gif b/docs/assets/posel-demo.gif new file mode 100644 index 0000000..21bf48d Binary files /dev/null and b/docs/assets/posel-demo.gif differ diff --git a/docs/assets/posel-demo.mp4 b/docs/assets/posel-demo.mp4 new file mode 100644 index 0000000..45ce888 Binary files /dev/null and b/docs/assets/posel-demo.mp4 differ