Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Test

on:
push:
pull_request:

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable

- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri -> target

- name: Install Tauri Linux system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libsoup-3.0-dev \
build-essential

# `--tests` is required, not cosmetic: the integration suites under
# src-tauri/tests/ (environment_naming, path_containment,
# project_relay, vault_integration) are separate targets that
# `--lib --bins` does not build or run.
- name: Run tests (lib + bins + integration)
working-directory: src-tauri
run: cargo test --lib --bins --tests --no-fail-fast

- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov

- name: Coverage summary (informational)
working-directory: src-tauri
run: |
cargo llvm-cov --lib --bins --no-fail-fast \
--ignore-filename-regex '(^|/)(tests|test_support)/' \
--summary-only
60 changes: 59 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,64 @@ Authentication: Header `X-Vault-Token` containing either a session token (from P
| POST | /workspaces/:id/relay/send | token | Share complete workspace (definition + all decrypted referenced secrets) via relay. Returns code + passphrase. Legacy, workspace-table-backed, out of scope for the projects/environments migration |
| POST | /workspaces/relay/receive | token | Receive shared workspace from relay. Recreates secrets and rebuilds workspace with variables re-linked. Legacy, same as above β€” items imported this way are NOT linked into any project/environment and are invisible to the scoped endpoints above |

### Examples

`curl` examples against the local server. `-k` is required β€” the certificate
is self-signed (see Notes below). Replace `$TOKEN` with a session token from
`/unlock` or the static MCP token from Settings.

```bash
# Unlock β€” returns a session token with a configurable TTL
curl -sk -X POST https://127.0.0.1:47821/unlock \
-H 'Content-Type: application/json' \
-d '{"master_password": "your-master-password"}'

# List items β€” scoped by environment_id
curl -sk https://127.0.0.1:47821/items?environment_id=1 \
-H "X-Vault-Token: $TOKEN"

# List items β€” scoped by project + environment names (case-insensitive)
curl -sk 'https://127.0.0.1:47821/items?project=demo&environment=production' \
-H "X-Vault-Token: $TOKEN"

# Create an item, linked into an environment as DB_HOST
curl -sk -X POST 'https://127.0.0.1:47821/items?environment_id=1' \
-H "X-Vault-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"type": "secret", "name": "DB_HOST", "value": "localhost", "key": "DB_HOST"}'

# Reveal a plaintext value β€” requires explicit confirm
curl -sk -X POST https://127.0.0.1:47821/items/1/reveal \
-H "X-Vault-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"confirm": true}'

# Fill a .env template inline, scoped by project + environment
curl -sk -X POST 'https://127.0.0.1:47821/fill?project=demo&environment=production' \
-H "X-Vault-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"template": "DB_HOST=\nPORT=3000\n"}'

# List all projects with their nested environments
curl -sk https://127.0.0.1:47821/projects -H "X-Vault-Token: $TOKEN"

# Inject an environment's variables into its configured .env path(s)
curl -sk -X POST https://127.0.0.1:47821/environments/1/inject \
-H "X-Vault-Token: $TOKEN" -H 'Content-Type: application/json' -d '{}'
```

The server is HTTPS-only on `127.0.0.1:47821` with a self-signed certificate
generated on first launch (`tls::ensure_tls_config`) β€” clients must either
pass `-k`/`--insecure` (as above) or trust that certificate explicitly.

**On the retired Postman collection**: `src-tauri/tests/crypt-env-api.postman_collection.json`
was deleted (tech-debt issue #11) β€” it asserted a `GET /health` field that no
longer exists and none of its 15 requests carried the (now mandatory)
project/environment scope, so every one of them 422'd. Nothing in CI ever
executed it, so it silently drifted out of date across a whole schema
migration. This reference section plus the `api::tests::*` suite (executed
on every push/PR β€” see `.github/workflows/test.yml`) are the replacement:
one documents the contract, the other proves it. A Postman collection may
return only alongside a test that replays it through the same router and
fails the build on drift β€” see the plan doc for the full reasoning.

### Notes

`decrypt_all_items` decrypts the entire vault on every authenticated request (no caching, no index), making every GET /items a full decryption pass — O(n) per request regardless of filters. Scoped endpoints add a second cost on top: `resolve_scope` loads the full project→environment→vars graph (`GET /projects`-equivalent) before the item decryption pass, so every scoped request is now O(vault) + O(project graph).
Expand All @@ -69,7 +127,7 @@ Projects/environments model replaces the old workspaces. A Project contains mult

`projects.name` has a case-insensitive UNIQUE index (`idx_projects_name_nocase`) β€” duplicate-name creation now returns 409 instead of silently succeeding. `environments.name` is only unique per-project under SQLite's default (case-sensitive) collation β€” two environments in the same project differing only by case (e.g. `Production`/`production`) can still coexist, and name-pair resolution (case-insensitive, picks the lowest-id match) will silently prefer one over the other with no ambiguity error. Known limitation, not fixed.

**Known, deferred issues** (found in review, not fixed in this pass): (1) a crafted environment `name` (only validated non-empty) combined with `output_dir` on `/fill`, `/environments/:id/inject`, or `/environments/:id/example` can path-traverse outside the intended directory, because `create_dir_all` on the joined path materializes the intermediate component that makes `..` segments resolve β€” reachable by anything holding the static MCP token via `POST /environments`. (2) `/fill`, `/environments/:id/inject`, and `/environments/:id/example` all write via a plain `std::fs::write` to `output_path` with no existence check β€” pointing one at a real, unrelated file truncates it. (3) `POST /items` on a key that already exists in the environment creates a new item row and repoints the link, orphaning (not deleting) the previous item β€” repeated `add`-equivalent calls grow the vault unboundedly and "rotating" a secret this way doesn't actually remove the old value.
**Known, deferred issues** (found in review, not fixed in this pass): (1) a crafted environment `name` (only validated non-empty) combined with `output_dir` on `/fill`, `/environments/:id/inject`, or `/environments/:id/example` can path-traverse outside the intended directory, because `create_dir_all` on the joined path materializes the intermediate component that makes `..` segments resolve β€” reachable by anything holding the static MCP token via `POST /environments`. (2) `/fill`, `/environments/:id/inject`, and `/environments/:id/example` all write via a plain `std::fs::write` to `output_path` with no existence check β€” pointing one at a real, unrelated file truncates it. (3) `POST /items` on a key that already exists in the environment creates a new item row and repoints the link, orphaning (not deleting) the previous item β€” repeated `add`-equivalent calls grow the vault unboundedly and "rotating" a secret this way doesn't actually remove the old value. (4) `PUT /items/:id`'s "merge" behavior only applies to the `Option<T>` fields on `VaultItem` β€” `type` has no `#[serde(default)]`, so a client omitting it entirely gets a 422 from axum's `Json` extractor before the merge logic (or `validate_update`) ever runs; every partial update must still resend `type`. Found and pinned by `api::tests::items::update_item_partial_update_preserves_other_fields` (issue #11), not changed there since that's a behavior fix out of scope for a test-only PR.

---

Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ windows = { version = "0.58", features = [
[dev-dependencies]
tempfile = "3"
tokio = { version = "1", features = ["rt", "macros"] }
tower = { version = "0.5", features = ["util"] }
# Issue #12: integration tests need to seed a DB that already contains a
# case-colliding name pair, which is impossible through the public API once
# the NOCASE unique index exists. Zero extra compilation β€” sqlx is already a
# normal dependency above; this just makes it usable from `tests/`.
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] }

[[bin]]
name = "crypt-env"
Expand Down
109 changes: 86 additions & 23 deletions src-tauri/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,26 @@ pub struct ApiState {
share: Arc<ShareState>,
}

impl ApiState {
/// Builds a fresh state: no active session, rate limiter reset, new share
/// session. Used by `start_server` and by `crate::test_support::router`
/// so both build `ApiState` identically β€” no duplicated initialisation to
/// drift. `pub(crate)` (not `pub`): visible to the in-crate test harness
/// without widening the crate's public API.
pub(crate) fn new(vault: SharedState) -> Self {
ApiState {
vault,
session_token: Arc::new(Mutex::new(None)),
token_expires: Arc::new(Mutex::new(None)),
unlock_rate: Mutex::new(RateLimitState {
attempts: 0,
window_start: Instant::now(),
}),
share: Arc::new(ShareState::new()),
}
}
}

// ─── Tipos de respuesta ───────────────────────────────────────────────────────

#[derive(Serialize)]
Expand Down Expand Up @@ -231,9 +251,14 @@ struct EnvScopeQuery {
environment: Option<String>,
}

/// Resolves the environment for a scoped request, or a 422 response with a
/// clear message when the identifier is missing or doesn't match anything β€”
/// matching the existing validation-error convention (see `err_validation`).
/// Resolves the environment for a scoped request. A missing/unmatched
/// identifier is a 422 (matching the existing validation-error convention,
/// see `err_validation`); an *ambiguous* case-insensitive match β€” more than
/// one project or environment satisfying the given name β€” is a distinct 409
/// `AMBIGUOUS_SCOPE` instead, since the request itself is well-formed and the
/// fix (`environment_id`) is deterministic. Every endpoint that scopes a
/// request via `EnvScopeQuery` goes through this single function, so this is
/// the one place the 409 mapping needs to live.
async fn resolve_scope(
state: &ApiState,
environment_id: Option<i64>,
Expand All @@ -243,7 +268,13 @@ async fn resolve_scope(
let vault = state.vault.lock().await;
project::resolve_environment(&vault.db, environment_id, project, environment)
.await
.map_err(|msg| err_validation("project/environment", &msg))
.map_err(|msg| {
if msg.starts_with(project::AMBIGUOUS_MATCH_PREFIX) {
err_json(StatusCode::CONFLICT, &msg, "AMBIGUOUS_SCOPE").into_response()
} else {
err_validation("project/environment", &msg)
}
})
}

/// Validates fields for a POST /items (create) request.
Expand Down Expand Up @@ -1954,15 +1985,25 @@ async fn handle_save_project(
(status, Json(serde_json::json!({ "id": id }))).into_response()
}
// A concurrent request may have already created a project with the
// same case-insensitive name (enforced by the DB's unique index) β€”
// same case-insensitive name (enforced by the DB's unique index,
// surfaced here via the stable `"conflict:"` sentinel from
// `db::upsert_project` β€” never sqlx's own error text, which is not a
// stable string and would leak SQL identifiers on any mismatch) β€”
// report it as a distinguishable conflict so callers like the CLI's
// auto-create fallback can re-fetch and reuse the existing project
// instead of treating this as a hard failure.
Err(e) if e.to_lowercase().contains("unique constraint") => {
Err(e) if e.starts_with("conflict:") => {
err_json(StatusCode::CONFLICT, "a project with this name already exists", "CONFLICT")
.into_response()
}
Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, &e, "INTERNAL_ERROR").into_response(),
// Never echo `e` here: on any other failure it may carry raw sqlx/SQL
// text (table, column, index names), which CLAUDE.md forbids in an
// API response. Log the detail server-side instead β€” never the
// input `name`, never any var value.
Err(e) => {
eprintln!("handle_save_project: {e}");
err_json(StatusCode::INTERNAL_SERVER_ERROR, "internal error", "INTERNAL_ERROR").into_response()
}
}
}

Expand Down Expand Up @@ -2058,7 +2099,25 @@ async fn handle_save_environment(
let status = if is_new { StatusCode::CREATED } else { StatusCode::OK };
(status, Json(serde_json::json!({ "id": id }))).into_response()
}
Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, &e, "INTERNAL_ERROR").into_response(),
// Same "conflict:" sentinel contract as `handle_save_project` β€” set
// either by `db::upsert_environment`'s unique-index violation (ASCII
// case) or `project::ensure_no_case_collision`'s app-level
// Unicode-aware pre-check (non-ASCII case). Both return the exact
// same string, so one match arm covers both layers.
Err(e) if e.starts_with("conflict:") => err_json(
StatusCode::CONFLICT,
"an environment with this name already exists in this project",
"CONFLICT",
)
.into_response(),
// Never echo `e`: on any other failure it may carry raw sqlx/SQL text
// (table, column, index names), which CLAUDE.md forbids in an API
// response. Log the detail server-side instead β€” never the input
// `name`, never any var value.
Err(e) => {
eprintln!("handle_save_environment: {e}");
err_json(StatusCode::INTERNAL_SERVER_ERROR, "internal error", "INTERNAL_ERROR").into_response()
}
}
}

Expand Down Expand Up @@ -3039,19 +3098,15 @@ async fn handle_workspace_relay_receive(

// ─── FunciΓ³n pΓΊblica de arranque ──────────────────────────────────────────────

pub async fn start_server(vault: SharedState, app_data_dir: PathBuf) {
let api_state = Arc::new(ApiState {
vault,
session_token: Arc::new(Mutex::new(None)),
token_expires: Arc::new(Mutex::new(None)),
unlock_rate: Mutex::new(RateLimitState {
attempts: 0,
window_start: Instant::now(),
}),
share: Arc::new(ShareState::new()),
});

let app = Router::new()
/// Builds the plain `axum::Router` with all 36 routes plus the `cors_guard`
/// middleware layer, given an already-constructed `ApiState`. `pub(crate)`
/// (not `pub`): reachable from `api::tests` (a descendant module) and from
/// `crate::test_support::router` (same crate), but never part of the crate's
/// external public API β€” no production caller outside this crate can build a
/// router or bind it to a socket other than the one fixed inside
/// `start_server` below.
pub(crate) fn build_router(state: Arc<ApiState>) -> Router {
Router::new()
.route("/health", get(handle_health))
.route("/unlock", post(handle_unlock))
.route("/fill", post(handle_fill))
Expand Down Expand Up @@ -3088,8 +3143,13 @@ pub async fn start_server(vault: SharedState, app_data_dir: PathBuf) {
.route("/relay/receive", post(handle_relay_receive))
.route("/workspaces/:id/relay/send", post(handle_workspace_relay_send))
.route("/workspaces/relay/receive", post(handle_workspace_relay_receive))
.with_state(api_state)
.layer(middleware::from_fn(cors_guard));
.with_state(state)
.layer(middleware::from_fn(cors_guard))
}

pub async fn start_server(vault: SharedState, app_data_dir: PathBuf) {
let api_state = Arc::new(ApiState::new(vault));
let app = build_router(api_state);

const ADDR: &str = "127.0.0.1:47821";

Expand Down Expand Up @@ -3120,3 +3180,6 @@ pub async fn start_server(vault: SharedState, app_data_dir: PathBuf) {
eprintln!("[api] REST server error: {e}");
}
}

#[cfg(test)]
mod tests;
Loading
Loading