diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yaml similarity index 100% rename from .github/workflows/ci.yml rename to .github/workflows/ci.yaml diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yaml similarity index 100% rename from .github/workflows/dependabot-automerge.yml rename to .github/workflows/dependabot-automerge.yaml diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml new file mode 100644 index 0000000..9e311e1 --- /dev/null +++ b/.github/workflows/python.yaml @@ -0,0 +1,121 @@ +name: Python + +on: + push: + tags: ["v*"] + pull_request: + paths: + - "src/python.rs" + - "python/**" + - "pyproject.toml" + - "murk.pyi" + - "Cargo.toml" + +env: + PYO3_USE_ABI3_FORWARD_COMPATIBILITY: "1" + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3 + with: + src: python/ + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 + + - name: Build release binary (for test fixture) + run: cargo build --release + + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + + - name: Install and test + run: | + uv venv + source .venv/bin/activate + uv pip install maturin pytest + maturin develop --features python + pytest python/tests -v + + wheels: + name: Build wheels (${{ matrix.os }}) + if: startsWith(github.ref, 'refs/tags/v') + needs: [lint, test] + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64 + - os: ubuntu-latest + target: aarch64 + - os: macos-14 + target: x86_64 + - os: macos-latest + target: aarch64 + - os: windows-latest + target: x64 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Build wheels + uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --features python + manylinux: auto + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: wheels-${{ matrix.os }}-${{ matrix.target }} + path: dist/ + + sdist: + name: Build sdist + if: startsWith(github.ref, 'refs/tags/v') + needs: [lint, test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Build sdist + uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1 + with: + command: sdist + args: --out dist + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: wheels-sdist + path: dist/ + + publish: + name: Publish to PyPI + if: startsWith(github.ref, 'refs/tags/v') + needs: [wheels, sdist] + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: wheels-* + merge-multiple: true + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 + with: + skip-existing: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yaml similarity index 100% rename from .github/workflows/release.yml rename to .github/workflows/release.yaml diff --git a/.gitignore b/.gitignore index 1ff3361..60fdc3c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ AGENTS.md CLAUDE.md .DS_Store +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/Cargo.lock b/Cargo.lock index 30c66fd..5a53e9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1156,6 +1156,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -1267,6 +1276,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1301,6 +1319,7 @@ dependencies = [ "constant_time_eq", "fs2", "predicates", + "pyo3", "rand 0.10.0", "rpassword", "serde", @@ -1499,6 +1518,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1579,6 +1604,69 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyo3" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.45" @@ -2035,6 +2123,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -2164,6 +2258,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "universal-hash" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 06e37fd..6578618 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,11 @@ constant_time_eq = "0.4" tempfile = "3.27.0" clap_complete = "4.6.0" fs2 = "0.4.3" +pyo3 = { version = "0.24", features = ["extension-module"], optional = true } + +[features] +default = [] +python = ["pyo3"] [dev-dependencies] assert_cmd = "2" diff --git a/Makefile b/Makefile index 5e16eee..8186285 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ SHELL := /bin/bash MURK := $(CURDIR)/target/release/murk MUSL_TARGET := x86_64-unknown-linux-musl -.PHONY: build test test-demos test-hero test-team test-offboard test-eve test-recovery test-github test-direnv test-mallory test-vhs +.PHONY: build test test-demos test-hero test-team test-offboard test-eve test-recovery test-github test-direnv test-mallory test-ssh test-vhs build: cargo build --release @@ -10,7 +10,7 @@ build: test: cargo nextest run -test-demos: build test-hero test-team test-offboard test-eve test-recovery test-github test-direnv test-mallory +test-demos: build test-hero test-team test-offboard test-eve test-recovery test-github test-direnv test-mallory test-ssh @echo "\nall demo tests passed" test-hero: build @@ -163,11 +163,25 @@ test-mallory: build ! $(MURK) export >/dev/null 2>&1 && \ echo "ok" +test-ssh: build + @printf " %-12s" "ssh" && \ + set -e && \ + export PATH="$(CURDIR)/target/release:$$PATH" && \ + source demo/setup.sh && \ + demo_init_dirs alice bob && \ + trap "demo_cleanup" EXIT && \ + demo_alice_vault && \ + ssh-keygen -t ed25519 -f "$$BOB_DIR/id_ed25519" -N "" -q && \ + cd $$ALICE_DIR && export MURK_KEY=$$ALICE_KEY && \ + murk circle authorize "ssh:$$BOB_DIR/id_ed25519.pub" --name bob >/dev/null 2>&1 && \ + murk circle 2>/dev/null | grep -q "bob" && \ + echo "ok" + test-vhs: @command -v cross >/dev/null 2>&1 || { echo "error: cross not found — install with: cargo install cross --locked"; exit 1; } cross build --release --target $(MUSL_TARGET) @printf 'FROM ghcr.io/charmbracelet/vhs\nRUN apt-get update --allow-releaseinfo-change && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*\n' | docker build -t vhs-git - - @for tape in hero team offboard eve recovery github direnv mallory; do \ + @for tape in hero team offboard eve recovery github direnv mallory ssh; do \ printf " %-12s" "$$tape" && \ docker run --rm -v $(CURDIR):/vhs -e PATH="/vhs/target/$(MUSL_TARGET)/release:$$PATH" vhs-git demo/$$tape.tape && \ echo "ok"; \ diff --git a/README.md b/README.md index e99dc4f..1788fb5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # murk -[![CI](https://github.com/iicky/murk/actions/workflows/ci.yml/badge.svg)](https://github.com/iicky/murk/actions/workflows/ci.yml) +[![CI](https://github.com/iicky/murk/actions/workflows/ci.yaml/badge.svg)](https://github.com/iicky/murk/actions/workflows/ci.yaml) [![codecov](https://codecov.io/gh/iicky/murk/graph/badge.svg)](https://codecov.io/gh/iicky/murk) [![Crates.io](https://img.shields.io/crates/v/murk-cli)](https://crates.io/crates/murk-cli) [![License](https://img.shields.io/crates/l/murk-cli)](LICENSE-MIT) @@ -195,6 +195,7 @@ murk restore | `murk rotate --all` | Rotate all secrets (prompts for each) | | `murk rm KEY` | Remove a secret | | `murk get KEY` | Print a single decrypted value | +| `murk edit [KEY] [--scoped]` | Edit secrets in `$EDITOR` | | `murk ls` | List key names | | `murk export` | Print all secrets as shell exports | | `murk exec CMD...` | Run a command with secrets in the environment | @@ -203,7 +204,7 @@ murk restore | `murk describe KEY "..."` | Set description for a key | | `murk info` | Show public schema (no key required) | | `murk circle` | List recipients | -| `murk circle authorize PUBKEY [--name NAME]` | Add a recipient | +| `murk circle authorize PUBKEY [--name NAME]` | Add a recipient (age key, `ssh:path`, or `github:user`) | | `murk circle revoke RECIPIENT` | Remove a recipient | | `murk restore` | Recover key from BIP39 phrase | | `murk recover` | Show recovery phrase for current key | diff --git a/demo/ssh.tape b/demo/ssh.tape new file mode 100644 index 0000000..589cb4a --- /dev/null +++ b/demo/ssh.tape @@ -0,0 +1,58 @@ +# SSH demo — authorize a recipient from an SSH public key file + +Output demo/ssh.gif + +Require murk + +Source demo/theme.tape + +Hide +Type `export PATH="$PWD/target/release:$PATH"` +Enter +Type `source demo/setup.sh` +Enter +Type `demo_init_dirs alice bob` +Enter +Sleep 200ms +Type `demo_alice_vault` +Enter +Sleep 3s + +# Bob: generate an SSH keypair (no passphrase) +Type `ssh-keygen -t ed25519 -f "$BOB_DIR/id_ed25519" -N "" -q` +Enter +Sleep 500ms + +# Start as Alice +Type `cd "$ALICE_DIR" && export MURK_KEY="$ALICE_KEY"` +Enter +Type `export PS1="\n\[\e[94m\]alice \$\[\e[0m\] "` +Enter +Type `clear` +Enter +Sleep 300ms +Show + +Type "# Bob sends Alice his SSH public key" +Enter +Sleep 1s + +Type `cat "$BOB_DIR/id_ed25519.pub"` +Enter +Sleep 2s + +Type "# Alice authorizes Bob from the key file" +Enter +Sleep 500ms + +Type `murk circle authorize "ssh:$BOB_DIR/id_ed25519.pub" --name bob` +Enter +Sleep 2s + +Type "murk circle" +Enter +Sleep 2s + +Type "# Bob's SSH key is now a recipient — done" +Enter +Sleep 2s diff --git a/deny.toml b/deny.toml index d0de6e8..7af2797 100644 --- a/deny.toml +++ b/deny.toml @@ -10,6 +10,7 @@ ignore = [ allow = [ "MIT", "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", "CC0-1.0", diff --git a/murk.pyi b/murk.pyi new file mode 100644 index 0000000..9d0a49b --- /dev/null +++ b/murk.pyi @@ -0,0 +1,15 @@ +"""Type stubs for the murk Python module.""" + +class Vault: + def get(self, key: str) -> str | None: ... + def export(self) -> dict[str, str]: ... + def keys(self) -> list[str]: ... + def __len__(self) -> int: ... + def __getitem__(self, key: str) -> str: ... + def __contains__(self, key: str) -> bool: ... + def __repr__(self) -> str: ... + +def load(vault_path: str = ".murk") -> Vault: ... +def get(key: str, vault_path: str = ".murk") -> str | None: ... +def export_all(vault_path: str = ".murk") -> dict[str, str]: ... +def has_key() -> bool: ... diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a1b5396 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "murk-secrets" +dynamic = ["version"] +description = "Python bindings for murk — encrypted secrets manager" +readme = "python/README.md" +license = { text = "MIT OR Apache-2.0" } +requires-python = ">=3.9" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Security :: Cryptography", + "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Apache Software License", +] +keywords = ["secrets", "encryption", "age", "dotenv", "security"] + +[project.urls] +Homepage = "https://github.com/iicky/murk" +Repository = "https://github.com/iicky/murk" +Issues = "https://github.com/iicky/murk/issues" + +[tool.maturin] +features = ["python"] +module-name = "murk" +bindings = "pyo3" + +[tool.pytest.ini_options] +testpaths = ["python/tests"] + +[tool.ruff] +target-version = "py39" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..0116e5e --- /dev/null +++ b/python/README.md @@ -0,0 +1,98 @@ +# murk-secrets + +Python bindings for [murk](https://github.com/iicky/murk) — an encrypted secrets manager for developers. + +murk stores encrypted secrets in a single `.murk` file safe to commit to git. This package lets Python apps read those secrets at runtime. + +## Prerequisites + +You need the [murk CLI](https://github.com/iicky/murk) to create and manage vaults. This package only reads them. + +```bash +# Install the CLI first +brew tap iicky/murk && brew install murk + +# Initialize a vault and add secrets +murk init +murk add DATABASE_URL +murk add API_KEY +``` + +Then add the Python package to your project: + +```bash +pip install murk-secrets +``` + +## Quick start + +```bash +# Load your key (created by murk init) +source .env +``` + +```python +import murk + +# Load the vault (reads MURK_KEY from environment) +vault = murk.load() + +# Get a single secret +db_url = vault.get("DATABASE_URL") + +# Get all secrets as a dict +secrets = vault.export() + +# Dict-style access +api_key = vault["API_KEY"] +``` + +## API + +### `murk.load(vault_path=".murk") -> Vault` + +Load and decrypt a murk vault. Reads `MURK_KEY` or `MURK_KEY_FILE` from the environment. + +### `murk.get(key, vault_path=".murk") -> str | None` + +One-liner: load the vault and get a single value. + +### `murk.export_all(vault_path=".murk") -> dict[str, str]` + +One-liner: load the vault and export all secrets as a dict. + +### `murk.has_key() -> bool` + +Check if a `MURK_KEY` is available in the environment. + +### `Vault` + +| Method | Returns | Description | +|--------|---------|-------------| +| `vault.get(key)` | `str \| None` | Get a single decrypted value | +| `vault.export()` | `dict[str, str]` | All secrets as a dict | +| `vault.keys()` | `list[str]` | List of key names | +| `vault[key]` | `str` | Dict-style access (raises on missing key) | +| `key in vault` | `bool` | Check if a key exists | +| `len(vault)` | `int` | Number of secrets | + +Scoped (per-user) overrides are applied automatically — if you have a scoped value for a key, it takes priority over the shared value. + +## Environment + +Set one of: +- `MURK_KEY` — your age secret key directly +- `MURK_KEY_FILE` — path to your key file (created by `murk init`) + +The easiest setup is `source .env` in your project directory after running `murk init`. + +## Requirements + +- Python >= 3.9 +- [murk CLI](https://github.com/iicky/murk) installed (to create and manage vaults) +- A `.murk` vault file in your project (created with `murk init`) +- `MURK_KEY` or `MURK_KEY_FILE` in the environment (created by `murk init`, loaded via `source .env`) + +## License + +MIT OR Apache-2.0 diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 0000000..707f7e4 --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,67 @@ +"""Shared fixtures for murk Python SDK tests.""" + +import os +import subprocess +import tempfile +from pathlib import Path + +import pytest + + +@pytest.fixture() +def vault_dir(): + """Create a temp directory with an initialized murk vault and secrets.""" + murk_bin = Path(__file__).resolve().parents[2] / "target" / "release" / "murk" + if not murk_bin.exists(): + pytest.skip("murk binary not found — run cargo build --release first") + + with tempfile.TemporaryDirectory() as tmpdir: + env = {**os.environ, "PATH": f"{murk_bin.parent}:{os.environ['PATH']}"} + + # Remove any existing MURK_KEY/MURK_KEY_FILE to avoid interference. + env.pop("MURK_KEY", None) + env.pop("MURK_KEY_FILE", None) + + # Init vault. + subprocess.run( + [str(murk_bin), "init", "--vault", ".murk"], + input="testuser\n", + capture_output=True, + text=True, + cwd=tmpdir, + env=env, + check=True, + ) + + # Read the key from .env. + dot_env = Path(tmpdir) / ".env" + for line in dot_env.read_text().splitlines(): + if line.startswith("export MURK_KEY_FILE="): + key_file = line.split("=", 1)[1].strip() + murk_key = Path(key_file).read_text().strip() + break + elif line.startswith("export MURK_KEY="): + murk_key = line.split("=", 1)[1].strip() + break + else: + pytest.fail("Could not find MURK_KEY in .env") + + env["MURK_KEY"] = murk_key + + # Add secrets. + for key, value in [ + ("DATABASE_URL", "postgres://localhost/mydb"), + ("API_KEY", "sk-test-123"), + ("STRIPE_SECRET", "sk_live_abc"), + ]: + subprocess.run( + [str(murk_bin), "add", key, "--vault", ".murk"], + input=f"{value}\n", + capture_output=True, + text=True, + cwd=tmpdir, + env=env, + check=True, + ) + + yield {"path": tmpdir, "key": murk_key} diff --git a/python/tests/test_murk.py b/python/tests/test_murk.py new file mode 100644 index 0000000..b8f14dd --- /dev/null +++ b/python/tests/test_murk.py @@ -0,0 +1,159 @@ +"""Tests for the murk Python SDK.""" + +import os + +import murk + + +class TestLoad: + def test_load_vault(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + assert vault is not None + + def test_load_with_explicit_path(self, vault_dir): + os.environ["MURK_KEY"] = vault_dir["key"] + vault_path = os.path.join(vault_dir["path"], ".murk") + + vault = murk.load(vault_path) + assert vault is not None + + def test_load_missing_vault_raises(self, vault_dir): + os.environ["MURK_KEY"] = vault_dir["key"] + + try: + murk.load("/nonexistent/.murk") + assert False, "Expected RuntimeError" + except RuntimeError: + pass + + def test_load_missing_key_raises(self, tmp_path): + # Use a clean dir with no .murk or .env to avoid key auto-discovery. + os.chdir(tmp_path) + os.environ.pop("MURK_KEY", None) + os.environ.pop("MURK_KEY_FILE", None) + + try: + murk.load() + assert False, "Expected RuntimeError" + except RuntimeError: + pass + + +class TestGet: + def test_get_existing_key(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + assert vault.get("DATABASE_URL") == "postgres://localhost/mydb" + assert vault.get("API_KEY") == "sk-test-123" + + def test_get_missing_key_returns_none(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + assert vault.get("NONEXISTENT") is None + + def test_get_oneliner(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + assert murk.get("DATABASE_URL") == "postgres://localhost/mydb" + + def test_get_oneliner_missing_returns_none(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + assert murk.get("NONEXISTENT") is None + + +class TestExport: + def test_export_returns_all_secrets(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + secrets = vault.export() + + assert isinstance(secrets, dict) + assert secrets["DATABASE_URL"] == "postgres://localhost/mydb" + assert secrets["API_KEY"] == "sk-test-123" + assert secrets["STRIPE_SECRET"] == "sk_live_abc" + assert len(secrets) == 3 + + def test_export_all_oneliner(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + secrets = murk.export_all() + assert len(secrets) == 3 + assert secrets["API_KEY"] == "sk-test-123" + + +class TestVaultMethods: + def test_keys(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + keys = vault.keys() + assert sorted(keys) == ["API_KEY", "DATABASE_URL", "STRIPE_SECRET"] + + def test_len(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + assert len(vault) == 3 + + def test_contains(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + assert "DATABASE_URL" in vault + assert "NONEXISTENT" not in vault + + def test_getitem(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + assert vault["API_KEY"] == "sk-test-123" + + def test_getitem_missing_raises(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + try: + _ = vault["NONEXISTENT"] + assert False, "Expected RuntimeError" + except RuntimeError: + pass + + def test_repr(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + r = repr(vault) + assert "3 secrets" in r + assert "1 recipients" in r + + +class TestHasKey: + def test_has_key_true(self, vault_dir): + os.environ["MURK_KEY"] = vault_dir["key"] + assert murk.has_key() is True + + def test_has_key_false(self, tmp_path): + os.environ.pop("MURK_KEY", None) + os.environ.pop("MURK_KEY_FILE", None) + # Use a clean dir with no .murk or .env to avoid auto-discovery. + os.chdir(tmp_path) + assert murk.has_key() is False diff --git a/src/lib.rs b/src/lib.rs index 591bb66..16b95ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,6 +33,9 @@ pub(crate) mod secrets; pub mod types; pub mod vault; +#[cfg(feature = "python")] +mod python; + // Shared test utilities #[cfg(test)] pub mod testutil; diff --git a/src/main.rs b/src/main.rs index f110729..b0feeb0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -192,6 +192,18 @@ enum Command { vault: String, }, + /// Edit secrets in $EDITOR + Edit { + /// Edit a single key (omit to edit all) + key: Option, + /// Edit scoped overrides instead of shared secrets + #[arg(long)] + scoped: bool, + /// Vault filename + #[arg(long, env = "MURK_VAULT", default_value = ".murk")] + vault: String, + }, + /// Run a command with secrets injected as environment variables #[command(trailing_var_arg = true)] Exec { @@ -209,7 +221,7 @@ enum Command { /// Add a recipient to the vault #[command(hide = true)] Authorize { - /// Public key (age1.../ssh-ed25519.../ssh-rsa...) or github:username + /// Public key (age1...), ssh:path, ssh: (default ~/.ssh/id_ed25519.pub), or github:username pubkey: String, /// Display name for this recipient #[arg(long)] @@ -298,7 +310,7 @@ enum Command { enum CircleCommand { /// Add a recipient to the vault Authorize { - /// Public key (age1.../ssh-ed25519.../ssh-rsa...) or github:username + /// Public key (age1...), ssh:path, ssh: (default ~/.ssh/id_ed25519.pub), or github:username pubkey: String, /// Display name for this recipient #[arg(long)] @@ -872,6 +884,292 @@ fn cmd_export(tags: &[String], json: bool, vault_path: &str) { } } +fn cmd_edit(key: Option<&str>, scoped: bool, vault_path: &str) { + let (mut vault, murk, identity, _lock) = load_vault_locked(vault_path); + let original = murk.clone(); + let mut current = murk; + let pubkey = identity.pubkey_string().unwrap_or_else(|e| die(&e, 1)); + + // Build the edit buffer. + let (header, entries) = if let Some(k) = key { + // Single key: just the raw value. + let value = if scoped { + current.scoped.get(k).and_then(|m| m.get(&pubkey)).cloned() + } else { + current.values.get(k).cloned() + }; + let value = value.unwrap_or_else(|| { + die( + &format_args!( + "key {} not found{}", + k.bold(), + if scoped { " (scoped)" } else { "" } + ), + 1, + ); + }); + ( + format!( + "# Editing {}{}\n# Save and quit to apply. Empty value or exit non-zero to abort.\n", + k, + if scoped { " (scoped)" } else { "" } + ), + vec![(k.to_string(), value)], + ) + } else { + // All keys: KEY=VALUE format. + let mut entries: Vec<(String, String)> = if scoped { + current + .scoped + .iter() + .filter_map(|(k, m)| m.get(&pubkey).map(|v| (k.clone(), v.clone()))) + .collect() + } else { + current + .values + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }; + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let header = format!( + "# Edit secrets below. Lines starting with # are ignored.\n\ + # Format: KEY=VALUE (one per line).\n\ + # Delete a line to remove that secret. Add KEY=VALUE to create.\n\ + # Save and quit to apply. Exit non-zero to abort.\n{}\n", + if scoped { + "# Editing scoped overrides.\n" + } else { + "" + } + ); + (header, entries) + }; + + let single_key = key.is_some(); + let buffer = if single_key { + format!("{}{}", header, entries[0].1) + } else { + let mut buf = header; + for (k, v) in &entries { + buf.push_str(&format!("{k}={v}\n")); + } + buf + }; + + // Write to a secure tempfile. + let dir = std::env::temp_dir(); + let mut tmp = tempfile::Builder::new() + .prefix("murk-edit-") + .suffix(".env") + .tempfile_in(&dir) + .unwrap_or_else(|e| die(&format_args!("creating tempfile: {e}"), 1)); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = tmp + .as_file() + .set_permissions(std::fs::Permissions::from_mode(0o600)); + } + + use std::io::Write; + tmp.write_all(buffer.as_bytes()) + .unwrap_or_else(|e| die(&format_args!("writing tempfile: {e}"), 1)); + tmp.flush() + .unwrap_or_else(|e| die(&format_args!("flushing tempfile: {e}"), 1)); + + // Open $EDITOR. + let editor = std::env::var("EDITOR") + .or_else(|_| std::env::var("VISUAL")) + .unwrap_or_else(|_| "vi".into()); + + let path = tmp.path().to_path_buf(); + let status = std::process::Command::new(&editor) + .arg(&path) + .status() + .unwrap_or_else(|e| die(&format_args!("launching {editor}: {e}"), 1)); + + if !status.success() { + // Securely wipe tempfile before exiting. + overwrite_and_remove(&path); + die(&"editor exited with error — aborting", 1); + } + + // Read back the edited content. + let edited = std::fs::read_to_string(&path) + .unwrap_or_else(|e| die(&format_args!("reading tempfile: {e}"), 1)); + + // Securely wipe the tempfile (overwrite with zeros before unlinking). + overwrite_and_remove(&path); + + // Parse and apply changes. + if single_key { + let k = key.unwrap(); + // Strip comment header, trim trailing newline. + let new_value: String = edited + .lines() + .filter(|l| !l.starts_with('#')) + .collect::>() + .join("\n"); + let new_value = new_value.trim_end_matches('\n'); + + if new_value.is_empty() { + eprintln!("{} empty value — no changes", "◆".magenta()); + return; + } + + let old_value = if scoped { + current.scoped.get(k).and_then(|m| m.get(&pubkey)).cloned() + } else { + current.values.get(k).cloned() + }; + + if old_value.as_deref() == Some(new_value) { + eprintln!("{} no changes", "◆".magenta()); + return; + } + + if scoped { + current + .scoped + .entry(k.into()) + .or_default() + .insert(pubkey.clone(), new_value.to_string()); + } else { + current.values.insert(k.into(), new_value.to_string()); + } + + save_vault(vault_path, &mut vault, &original, ¤t); + eprintln!( + "{} updated {}{}", + "◆".magenta(), + k.bold(), + if scoped { " (scoped)" } else { "" } + ); + } else { + // Multi-key: parse KEY=VALUE lines, diff against original. + let mut new_entries: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for line in edited.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let (k, v) = match trimmed.split_once('=') { + Some((k, v)) => (k.trim(), v), + None => { + eprintln!( + "{} skipping malformed line: {}", + "⚠".yellow(), + trimmed.dimmed() + ); + continue; + } + }; + if !is_valid_key_name(k) { + eprintln!("{} skipping invalid key name: {}", "⚠".yellow(), k.bold()); + continue; + } + new_entries.insert(k.to_string(), v.to_string()); + } + + // Compute diff. + let old_entries: std::collections::BTreeMap = entries.into_iter().collect(); + let mut added = 0usize; + let mut updated = 0usize; + let mut removed = 0usize; + + // Add or update. + for (k, v) in &new_entries { + match old_entries.get(k) { + Some(old_v) if old_v == v => {} // Unchanged. + Some(_) => { + if scoped { + current + .scoped + .entry(k.clone()) + .or_default() + .insert(pubkey.clone(), v.clone()); + } else { + current.values.insert(k.clone(), v.clone()); + } + updated += 1; + } + None => { + if scoped { + current + .scoped + .entry(k.clone()) + .or_default() + .insert(pubkey.clone(), v.clone()); + } else { + current.values.insert(k.clone(), v.clone()); + } + // Ensure schema entry exists for new keys. + vault + .schema + .entry(k.clone()) + .or_insert_with(|| murk_cli::types::SchemaEntry { + description: String::new(), + example: None, + tags: vec![], + }); + added += 1; + } + } + } + + // Remove deleted keys. + for k in old_entries.keys() { + if !new_entries.contains_key(k) { + if scoped { + if let Some(m) = current.scoped.get_mut(k) { + m.remove(&pubkey); + } + } else { + current.values.remove(k); + current.scoped.remove(k); + vault.schema.remove(k); + } + removed += 1; + } + } + + if added == 0 && updated == 0 && removed == 0 { + eprintln!("{} no changes", "◆".magenta()); + return; + } + + save_vault(vault_path, &mut vault, &original, ¤t); + + let mut parts = vec![]; + if added > 0 { + parts.push(format!("{added} added")); + } + if updated > 0 { + parts.push(format!("{updated} updated")); + } + if removed > 0 { + parts.push(format!("{removed} removed")); + } + eprintln!("{} {}", "◆".magenta(), parts.join(", ")); + } +} + +/// Overwrite a file with zeros and remove it. +fn overwrite_and_remove(path: &std::path::Path) { + if let Ok(meta) = std::fs::metadata(path) { + let len = meta.len() as usize; + if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open(path) { + use std::io::Write; + let _ = f.write_all(&vec![0u8; len]); + let _ = f.sync_all(); + } + } + let _ = std::fs::remove_file(path); +} + fn cmd_exec(command: &[String], tags: &[String], vault_path: &str) { let (vault, murk, identity) = load_vault(vault_path); let pubkey = identity.pubkey_string().unwrap_or_else(|e| die(&e, 1)); @@ -1152,6 +1450,52 @@ fn cmd_authorize(pubkey: &str, name: Option<&str>, vault_path: &str) { summary, if added == 1 { "" } else { "s" } ); + } else if let Some(path_hint) = pubkey.strip_prefix("ssh:") { + // Read SSH public key from a file. + let path = if path_hint.is_empty() { + // Default: ~/.ssh/id_ed25519.pub + let home = std::env::var("HOME").unwrap_or_else(|_| die(&"HOME not set", 1)); + std::path::PathBuf::from(home).join(".ssh/id_ed25519.pub") + } else { + if path_hint.starts_with('~') { + let home = std::env::var("HOME").unwrap_or_else(|_| die(&"HOME not set", 1)); + std::path::PathBuf::from(path_hint.replacen('~', &home, 1)) + } else { + std::path::PathBuf::from(path_hint) + } + }; + + let contents = std::fs::read_to_string(&path).unwrap_or_else(|e| { + die(&format_args!("cannot read {}: {e}", path.display()), 1); + }); + // Take first non-empty line (pub files may have trailing newlines). + let key_line = contents + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or_else(|| die(&format_args!("empty key file: {}", path.display()), 1)); + // Strip the comment field if present (ssh-type base64 comment). + let key_string = { + let parts: Vec<&str> = key_line.splitn(3, ' ').collect(); + if parts.len() >= 2 { + format!("{} {}", parts[0], parts[1]) + } else { + key_line.to_string() + } + }; + + try_or_die(murk_cli::authorize_recipient( + &mut vault, + &mut current, + &key_string, + name, + )); + + save_vault(vault_path, &mut vault, &original, ¤t); + + let display = name + .map(|n| n.to_string()) + .unwrap_or_else(|| path.display().to_string()); + eprintln!("{} authorized {}", "◆".magenta(), display.bold()); } else { // Raw pubkey (age or SSH). try_or_die(murk_cli::authorize_recipient( @@ -1537,6 +1881,7 @@ fn main() { } => cmd_describe(&key, &description, example.as_deref(), &tag, &vault), Command::Info { tag, json, vault } => cmd_info(&tag, json, &vault), Command::Export { tag, json, vault } => cmd_export(&tag, json, &vault), + Command::Edit { key, scoped, vault } => cmd_edit(key.as_deref(), scoped, &vault), Command::Exec { tag, vault, diff --git a/src/python.rs b/src/python.rs new file mode 100644 index 0000000..0f50eff --- /dev/null +++ b/src/python.rs @@ -0,0 +1,126 @@ +//! Python bindings for murk via PyO3. +//! +//! ```python +//! import murk +//! +//! vault = murk.load() # reads MURK_KEY from env, .murk from cwd +//! vault.get("DATABASE_URL") # decrypt a single value +//! vault.export() # dict of all key/values +//! murk.get("DATABASE_URL") # one-liner convenience +//! ``` + +use std::collections::HashMap; + +use pyo3::exceptions::PyRuntimeError; +use pyo3::prelude::*; + +use crate::{env, export, types}; + +/// A loaded and decrypted murk vault. +#[pyclass] +struct Vault { + vault: types::Vault, + murk: types::Murk, + pubkey: String, +} + +#[pymethods] +impl Vault { + /// Get a single decrypted secret value. + /// Returns the scoped override if one exists, otherwise the shared value. + fn get(&self, key: &str) -> PyResult> { + // Check scoped first. + if let Some(scoped_map) = self.murk.scoped.get(key) { + if let Some(value) = scoped_map.get(&self.pubkey) { + return Ok(Some(value.clone())); + } + } + Ok(self.murk.values.get(key).cloned()) + } + + /// Export all secrets as a dict. Scoped values override shared values. + fn export(&self) -> PyResult> { + Ok( + export::resolve_secrets(&self.vault, &self.murk, &self.pubkey, &[]) + .into_iter() + .collect(), + ) + } + + /// List all key names. + fn keys(&self) -> PyResult> { + Ok(self.vault.schema.keys().cloned().collect()) + } + + /// Number of secrets in the vault. + fn __len__(&self) -> usize { + self.vault.schema.len() + } + + /// Get a value by key (dict-style access). + fn __getitem__(&self, key: &str) -> PyResult { + self.get(key)? + .ok_or_else(|| PyRuntimeError::new_err(format!("key not found: {key}"))) + } + + /// Check if a key exists. + fn __contains__(&self, key: &str) -> bool { + self.vault.schema.contains_key(key) + } + + fn __repr__(&self) -> String { + format!( + "Vault({} secrets, {} recipients)", + self.vault.schema.len(), + self.vault.recipients.len() + ) + } +} + +/// Load a murk vault. Reads MURK_KEY from the environment. +#[pyfunction] +#[pyo3(signature = (vault_path=".murk"))] +fn load(vault_path: &str) -> PyResult { + let (vault, murk, identity) = + crate::load_vault(vault_path).map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + let pubkey = identity + .pubkey_string() + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + Ok(Vault { + vault, + murk, + pubkey, + }) +} + +/// One-liner: load the vault and get a single key. +#[pyfunction] +#[pyo3(signature = (key, vault_path=".murk"))] +fn get(key: &str, vault_path: &str) -> PyResult> { + load(vault_path)?.get(key) +} + +/// One-liner: load the vault and export all secrets as a dict. +#[pyfunction] +#[pyo3(signature = (vault_path=".murk"))] +fn export_all(vault_path: &str) -> PyResult> { + load(vault_path)?.export() +} + +/// Resolve the MURK_KEY from the environment without loading a vault. +/// Returns true if a key is available. +#[pyfunction] +fn has_key() -> bool { + env::resolve_key().is_ok() +} + +/// Python module definition. +#[pymodule] +fn murk(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_function(wrap_pyfunction!(load, m)?)?; + m.add_function(wrap_pyfunction!(get, m)?)?; + m.add_function(wrap_pyfunction!(export_all, m)?)?; + m.add_function(wrap_pyfunction!(has_key, m)?)?; + Ok(()) +} diff --git a/tests/cli.rs b/tests/cli.rs index 0434b13..057f375 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2354,3 +2354,281 @@ fn github_username_too_long_rejected() { .failure() .stderr(predicate::str::contains("invalid GitHub username")); } + +#[test] +fn authorize_ssh_file_adds_recipient() { + let dir = TempDir::new().unwrap(); + let (key, _) = init_vault(&dir); + + // Write a valid SSH public key file (with comment field). + let ssh_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uObz5dvMgjz1OxfM/XXUB+VHtZ6isGN test@example"; + let pub_path = dir.path().join("bob.pub"); + fs::write(&pub_path, format!("{ssh_key}\n")).unwrap(); + + murk(&dir, &key) + .args([ + "circle", + "authorize", + &format!("ssh:{}", pub_path.display()), + "--name", + "bob", + "--vault", + "test.murk", + ]) + .assert() + .success() + .stderr(predicate::str::contains("authorized bob")); + + // Verify the key shows up in circle output. + murk(&dir, &key) + .args(["circle", "--vault", "test.murk"]) + .assert() + .success() + .stdout(predicate::str::contains("bob")); +} + +#[test] +fn authorize_ssh_file_not_found() { + let dir = TempDir::new().unwrap(); + let (key, _) = init_vault(&dir); + + murk(&dir, &key) + .args([ + "circle", + "authorize", + "ssh:/nonexistent/key.pub", + "--vault", + "test.murk", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("cannot read")); +} + +#[test] +fn authorize_ssh_file_empty() { + let dir = TempDir::new().unwrap(); + let (key, _) = init_vault(&dir); + + let pub_path = dir.path().join("empty.pub"); + fs::write(&pub_path, "").unwrap(); + + murk(&dir, &key) + .args([ + "circle", + "authorize", + &format!("ssh:{}", pub_path.display()), + "--vault", + "test.murk", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("empty key file")); +} + +// ── edit ── + +/// Helper: write an editor script that replaces the file content. +/// On Unix, writes a shell script. On Windows, writes a .cmd batch file. +/// `body` is the shell command (Unix). `win_body` is the batch equivalent. +fn write_editor_script(dir: &TempDir, name: &str, body: &str, win_body: &str) -> String { + #[cfg(unix)] + { + let script = dir.path().join(name); + fs::write(&script, format!("#!/bin/sh\n{body}\n")).unwrap(); + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + script.display().to_string() + } + #[cfg(windows)] + { + let script = dir.path().join(format!("{name}.cmd")); + fs::write(&script, format!("@echo off\r\n{win_body}\r\n")).unwrap(); + script.display().to_string() + } +} + +#[cfg(unix)] +#[test] +fn edit_single_key_updates_value() { + let dir = TempDir::new().unwrap(); + let (key, _) = init_vault(&dir); + + murk(&dir, &key) + .args(["add", "SECRET", "--vault", "test.murk"]) + .write_stdin("original\n") + .assert() + .success(); + + let editor = write_editor_script( + &dir, + "editor.sh", + r#"echo "updated" > "$1""#, + r#"echo updated> %1"#, + ); + + murk(&dir, &key) + .args(["edit", "SECRET", "--vault", "test.murk"]) + .env("EDITOR", &editor) + .assert() + .success() + .stderr(predicate::str::contains("updated SECRET")); + + murk(&dir, &key) + .args(["get", "SECRET", "--vault", "test.murk"]) + .assert() + .success() + .stdout(predicate::str::contains("updated")); +} + +#[cfg(unix)] +#[test] +fn edit_single_key_no_change() { + let dir = TempDir::new().unwrap(); + let (key, _) = init_vault(&dir); + + murk(&dir, &key) + .args(["add", "SECRET", "--vault", "test.murk"]) + .write_stdin("original\n") + .assert() + .success(); + + // cat leaves the file unchanged. + murk(&dir, &key) + .args(["edit", "SECRET", "--vault", "test.murk"]) + .env("EDITOR", "cat") + .assert() + .success() + .stderr(predicate::str::contains("no changes")); +} + +#[cfg(unix)] +#[test] +fn edit_abort_preserves_value() { + let dir = TempDir::new().unwrap(); + let (key, _) = init_vault(&dir); + + murk(&dir, &key) + .args(["add", "SECRET", "--vault", "test.murk"]) + .write_stdin("keep_me\n") + .assert() + .success(); + + murk(&dir, &key) + .args(["edit", "SECRET", "--vault", "test.murk"]) + .env("EDITOR", "false") + .assert() + .failure() + .stderr(predicate::str::contains("aborting")); + + // Value is preserved. + murk(&dir, &key) + .args(["get", "SECRET", "--vault", "test.murk"]) + .assert() + .success() + .stdout(predicate::str::contains("keep_me")); +} + +#[cfg(unix)] +#[test] +fn edit_multi_key_add_update_remove() { + let dir = TempDir::new().unwrap(); + let (key, _) = init_vault(&dir); + + murk(&dir, &key) + .args(["add", "KEEP", "--vault", "test.murk"]) + .write_stdin("original\n") + .assert() + .success(); + + murk(&dir, &key) + .args(["add", "DELETE_ME", "--vault", "test.murk"]) + .write_stdin("gone\n") + .assert() + .success(); + + // Editor: update KEEP, remove DELETE_ME, add NEW_KEY. + let editor = write_editor_script( + &dir, + "editor.sh", + r#"printf "KEEP=changed\nNEW_KEY=hello\n" > "$1""#, + "(\r\necho KEEP=changed\r\necho NEW_KEY=hello\r\n) > %1", + ); + + murk(&dir, &key) + .args(["edit", "--vault", "test.murk"]) + .env("EDITOR", &editor) + .assert() + .success() + .stderr( + predicate::str::contains("added") + .and(predicate::str::contains("updated")) + .and(predicate::str::contains("removed")), + ); + + // Verify state. + murk(&dir, &key) + .args(["get", "KEEP", "--vault", "test.murk"]) + .assert() + .success() + .stdout(predicate::str::contains("changed")); + + murk(&dir, &key) + .args(["get", "NEW_KEY", "--vault", "test.murk"]) + .assert() + .success() + .stdout(predicate::str::contains("hello")); + + murk(&dir, &key) + .args(["get", "DELETE_ME", "--vault", "test.murk"]) + .assert() + .failure(); +} + +#[cfg(unix)] +#[test] +fn edit_missing_key_fails() { + let dir = TempDir::new().unwrap(); + let (key, _) = init_vault(&dir); + + murk(&dir, &key) + .args(["edit", "NONEXISTENT", "--vault", "test.murk"]) + .env("EDITOR", "cat") + .assert() + .failure() + .stderr(predicate::str::contains("not found")); +} + +#[cfg(unix)] +#[test] +fn edit_tempfile_cleaned_up() { + let dir = TempDir::new().unwrap(); + let (key, _) = init_vault(&dir); + + murk(&dir, &key) + .args(["add", "SECRET", "--vault", "test.murk"]) + .write_stdin("value\n") + .assert() + .success(); + + // Editor that records the tempfile path. + let marker = dir.path().join("temppath.txt"); + let editor = write_editor_script( + &dir, + "editor.sh", + &format!(r#"cp "$1" "{}" "#, marker.display()), + &format!(r#"copy %1 "{}""#, marker.display()), + ); + + murk(&dir, &key) + .args(["edit", "SECRET", "--vault", "test.murk"]) + .env("EDITOR", &editor) + .assert() + .success(); + + // The copied file should exist (proves editor ran), but we can't easily + // check the original tempfile is gone since we don't know its path. + // Instead verify the marker file doesn't contain secrets in plaintext + // after the edit (the original tempfile was wiped). + assert!(marker.exists()); +}