fix(keychain) + release: macOS keychain ACL self-repair, bump 0.8.1 (#37)
* fix(keychain): repair stale macOS keychain ACLs before reads macOS records the trusted application of a legacy keychain item as the executable *path* for an ad-hoc (dev/linker-signed) build, and as a code requirement + cdhash partition list for a signed bundle. When the path changes — the monorepo move (`src-tauri/` -> `desktop/src-tauri/`), a reinstalled app, running the installed app after a dev build — the ACL no longer admits the running binary, so macOS demands the keychain password on *every* access: once per secret, every time, with no "Always Allow". The pin only ran after writes, and writing to an inaccessible item needs the same authorization, so the ACL was never repaired. - keychain_acl::is_item_trusted() answers "am I already in this item's ACL?" with a metadata-only, prompt-free lookup (SecKeychainItemCopyAccess + SecAccessCopyACLList + comparison against this process's own SecTrustedApplication representation; a signed bundle's `cdhash:` partition lists count as trusted and are left alone). - ensure_item_acl() now no-ops when already trusted (no duplicate ACL entries, no redundant authorization) and otherwise pins exactly as before. - New before_read() hook, called from every keychain read path (DB password, SSH password, SSH passphrase — commands and internal helpers), so a stale item costs one authorization and is silent from then on. - ItemSearchOptions reports "nothing matched" as errSecItemNotFound, which is now handled as "no item" instead of a lookup error — previously every connection without a stored password logged a spurious failure. Verified: cargo test --lib (408 passed) plus the ignored live test (`cargo test --lib -- --ignored keychain_acl`), which asserts a fresh item is already trusted, repairs are idempotent and the secret still reads back. That test now finishes in 0.25s instead of ~8s because the redundant SecKeychainItemSetAccess is gone. * chore(release): bump 0.8.1 and document GitHub-only releases Version 0.8.0 -> 0.8.1 across every synced location: desktop/package.json, desktop/src-tauri/Cargo.toml (+ Cargo.lock gridline entry), tauri.conf.json, www/src/pages/index.astro, www/public/landing.js, both README download tables (+ the MAINTENANCE note), and the version/docs-coverage/bundle-config tests. Adds the 0.8.1 changelog entry (site + README Recent Changes). AGENTS.md: releases are GitHub-only — tag `prod` and `git push github vN.M.N` (origin/Gitea is a just-in-case mirror; never push branches, PRs or tags there), and the keychain row documents the self-healing ACL repair.
This commit is contained in:
@@ -167,17 +167,19 @@ cargo test # Rust tests (from desktop/sr
|
|||||||
|
|
||||||
### Branch & PR workflow
|
### Branch & PR workflow
|
||||||
|
|
||||||
|
**GitHub is the only remote that matters.** The canonical repo is `github` → [AdrianBonpin/gridline](https://github.com/AdrianBonpin/gridline) (default branch `prod`, issues, PRs, Actions, releases). The `origin` remote (`git.ranio.xyz/adrianbonpin/gridline`) is a **just-in-case mirror** — do not push branches, open PRs, or publish releases/tags there. If you must mirror something manually, push to `github` first.
|
||||||
|
|
||||||
**Never commit or push directly to `prod`** — `prod` is protected (branch rules require a pull request). Always:
|
**Never commit or push directly to `prod`** — `prod` is protected (branch rules require a pull request). Always:
|
||||||
|
|
||||||
1. Branch out first — `git checkout -b feat/<short-name>` (or `fix/…`, `docs/…`) from `prod`
|
1. Branch out first — `git checkout -b feat/<short-name>` (or `fix/…`, `docs/…`) from `prod`
|
||||||
2. Commit and push the feature branch
|
2. Commit and push the feature branch to `github` (`git push -u github <branch>`)
|
||||||
3. Open a PR into `prod` and merge it there
|
3. Open a PR into `prod` on GitHub (`gh pr create --repo AdrianBonpin/gridline --base prod`) and merge it there
|
||||||
|
|
||||||
Direct pushes to `prod` will be bypassed only in emergencies; prefer the PR path so changes land through the PR gate.
|
Direct pushes to `prod` will be bypassed only in emergencies; prefer the PR path so changes land through the PR gate.
|
||||||
|
|
||||||
### Releases
|
### Releases
|
||||||
|
|
||||||
Cut a release by tagging the **`prod`** branch once the PR is merged — `git tag vN.M.N && git push origin vN.M.N`. GitHub Actions (`release.yml`) builds installers for macOS (Apple Silicon + Intel), Windows, and Linux and opens a **draft** release (review + publish on GitHub).
|
**Releases are GitHub-only.** Tag `prod` and push the tag to the `github` remote — `git tag vN.M.N && git push github vN.M.N`. GitHub Actions (`release.yml`) builds installers for macOS (Apple Silicon + Intel), Windows, and Linux and opens a **draft** release (review + publish on GitHub). Never push release tags to `origin`/Gitea; it exists only as a fallback copy of the code.
|
||||||
|
|
||||||
**Before tagging**, keep everything in sync:
|
**Before tagging**, keep everything in sync:
|
||||||
- Version number across `desktop/package.json`, `desktop/src-tauri/Cargo.toml`, `desktop/src-tauri/tauri.conf.json`, and `www/src/pages/index.astro` (the landing page version string)
|
- Version number across `desktop/package.json`, `desktop/src-tauri/Cargo.toml`, `desktop/src-tauri/tauri.conf.json`, and `www/src/pages/index.astro` (the landing page version string)
|
||||||
@@ -245,7 +247,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite |
|
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite |
|
||||||
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly |
|
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly |
|
||||||
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
|
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
|
||||||
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager; macOS items get an explicit `SecAccess` ACL pinning them to the app's code-signature requirement on every save (`keychain_acl.rs`) — items survive re-signs/upgrades without keychain permission prompts |
|
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager. macOS items get an explicit `SecAccess` ACL pinning them to the app's code-signature requirement on every save (`keychain_acl.rs`) — items survive re-signs/upgrades without keychain permission prompts. **Self-healing ACLs**: the ACL is also (silently) verified *before* reads and repaired when this app isn't in it, so items pinned to an older binary path (pre-monorepo `src-tauri/`, a reinstalled app, an ad-hoc dev build) cost one authorization instead of asking for the keychain password on every access, per secret |
|
||||||
| Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) |
|
| Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) |
|
||||||
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
||||||
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
|
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
|
||||||
|
|||||||
@@ -30,16 +30,16 @@
|
|||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.8.0**):
|
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.8.1**):
|
||||||
|
|
||||||
| OS | Architecture | Download |
|
| OS | Architecture | Download |
|
||||||
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
|
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.8.0_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_aarch64.dmg) |
|
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.8.1_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_aarch64.dmg) |
|
||||||
| **macOS** | Intel | [Gridline_0.8.0_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64.dmg) |
|
| **macOS** | Intel | [Gridline_0.8.1_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_x64.dmg) |
|
||||||
| **Windows** | x64 | [Gridline_0.8.0_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64-setup.exe) |
|
| **Windows** | x64 | [Gridline_0.8.1_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_x64-setup.exe) |
|
||||||
| **Debian / Ubuntu** | amd64 | [Gridline_0.8.0_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_amd64.deb) |
|
| **Debian / Ubuntu** | amd64 | [Gridline_0.8.1_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_amd64.deb) |
|
||||||
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.8.0-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline-0.8.0-1.x86_64.rpm) |
|
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.8.1-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline-0.8.1-1.x86_64.rpm) |
|
||||||
| **Other Linux** | amd64 | [Gridline_0.8.0_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_amd64.AppImage) |
|
| **Other Linux** | amd64 | [Gridline_0.8.1_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_amd64.AppImage) |
|
||||||
|
|
||||||
**macOS via Homebrew** (recommended for Mac users):
|
**macOS via Homebrew** (recommended for Mac users):
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ The cask installs the app directly. The DMGs are Developer-ID signed and notariz
|
|||||||
> **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
|
> **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.8.0
|
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.8.1
|
||||||
release assets, not at a moving "latest" target. On every new release,
|
release assets, not at a moving "latest" target. On every new release,
|
||||||
update BOTH tables here (Download + Which file should I download?) to the
|
update BOTH tables here (Download + Which file should I download?) to the
|
||||||
new version's asset names, which are tauri-action's default naming:
|
new version's asset names, which are tauri-action's default naming:
|
||||||
@@ -102,6 +102,8 @@ Gridline is built for developers and small teams who manage multiple database en
|
|||||||
<details>
|
<details>
|
||||||
<summary>View the full changelog</summary>
|
<summary>View the full changelog</summary>
|
||||||
|
|
||||||
|
- **v0.8.1** — **Neon / Supabase / PlanetScale connections fixed**: a pasted connection string's `?sslmode=` is now carried into the saved connection (it was silently dropped, so TLS stayed off and Neon replied *"connection is insecure (try using `sslmode=require`)"*); the SSH / SSL tab's mode and certificate paths persist on create *and* edit, managed presets pre-select their TLS mode, and `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` hosts default to `require` when no mode is stored — so previously-saved connections keep working. Pasting a URI also fills every setting it can and suggests a name, so a URL can be tested and saved with no retyping. **macOS keychain prompts self-heal**: items whose ACL still points at an older binary path made macOS ask for the keychain password on *every* access, once per secret — the app now checks the ACL (silently, before reading) and repairs it when needed, so you authorize a secret once. Version bump.
|
||||||
|
|
||||||
- **v0.7.15** — **Support channel on the landing page**: optional coffee via [Ko-fi](https://ko-fi.com/adrianbonpin), never gates a feature. Landing copy refreshed (em dashes dropped, OSS section simplified, funding FAQ); version bump.
|
- **v0.7.15** — **Support channel on the landing page**: optional coffee via [Ko-fi](https://ko-fi.com/adrianbonpin), never gates a feature. Landing copy refreshed (em dashes dropped, OSS section simplified, funding FAQ); version bump.
|
||||||
|
|
||||||
- **v0.7.14** — **No more repeated macOS keychain prompts**: saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature on every save (`keychain_acl`), so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from pre-signing builds see one final prompt — choose **Always Allow**). **Install via Homebrew**: `brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git` then `brew install --cask gridline`; version bump.
|
- **v0.7.14** — **No more repeated macOS keychain prompts**: saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature on every save (`keychain_acl`), so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from pre-signing builds see one final prompt — choose **Always Allow**). **Install via Homebrew**: `brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git` then `brew install --cask gridline`; version bump.
|
||||||
@@ -344,16 +346,16 @@ Gridline's macOS builds are **Developer-ID signed and notarized by GitHub Action
|
|||||||
|
|
||||||
#### Which file should I download?
|
#### Which file should I download?
|
||||||
|
|
||||||
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.8.0**):
|
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.8.1**):
|
||||||
|
|
||||||
| Your system | Download this | Notes |
|
| Your system | Download this | Notes |
|
||||||
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
|
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
|
||||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.8.0_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_aarch64.dmg) | `aarch64` = Apple's own chip |
|
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.8.1_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||||
| macOS **Intel** | [Gridline_0.8.0_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64.dmg) | `x64` = Intel/AMD |
|
| macOS **Intel** | [Gridline_0.8.1_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_x64.dmg) | `x64` = Intel/AMD |
|
||||||
| **Windows** (most PCs) | [Gridline_0.8.0_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
| **Windows** (most PCs) | [Gridline_0.8.1_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||||
| **Debian / Ubuntu** | [Gridline_0.8.0_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_amd64.deb) | Install: `sudo apt install ./Gridline_0.8.0_amd64.deb` |
|
| **Debian / Ubuntu** | [Gridline_0.8.1_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_amd64.deb) | Install: `sudo apt install ./Gridline_0.8.1_amd64.deb` |
|
||||||
| **Fedora / RHEL / openSUSE** | [Gridline-0.8.0-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline-0.8.0-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.8.0-1.x86_64.rpm` |
|
| **Fedora / RHEL / openSUSE** | [Gridline-0.8.1-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline-0.8.1-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.8.1-1.x86_64.rpm` |
|
||||||
| **Any other Linux** | [Gridline_0.8.0_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
| **Any other Linux** | [Gridline_0.8.1_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
||||||
|
|
||||||
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
|
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gridline-desktop",
|
"name": "gridline-desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.0",
|
"version": "0.8.1",
|
||||||
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -1783,7 +1783,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridline"
|
name = "gridline"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gridline"
|
name = "gridline"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ pub fn get_connection_password(
|
|||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
|
// Repair a stale item ACL first: after a rebuild/path change macOS would
|
||||||
|
// otherwise ask for the keychain password on every access (once per secret).
|
||||||
|
keychain_acl::before_read(&app, &connection_id);
|
||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.get_password(&connection_id)
|
.get_password(&connection_id)
|
||||||
@@ -37,6 +40,7 @@ pub fn get_connection_password_internal(
|
|||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
|
keychain_acl::before_read(app, connection_id);
|
||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.get_password(connection_id)
|
.get_password(connection_id)
|
||||||
@@ -71,6 +75,7 @@ pub fn get_connection_ssh_password(
|
|||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
|
keychain_acl::before_read(&app, &ssh_account("password", &connection_id));
|
||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.get_password(&ssh_account("password", &connection_id))
|
.get_password(&ssh_account("password", &connection_id))
|
||||||
@@ -111,6 +116,7 @@ pub fn get_connection_ssh_passphrase(
|
|||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
|
keychain_acl::before_read(&app, &ssh_account("passphrase", &connection_id));
|
||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.get_password(&ssh_account("passphrase", &connection_id))
|
.get_password(&ssh_account("passphrase", &connection_id))
|
||||||
@@ -135,6 +141,7 @@ pub fn get_connection_ssh_password_internal(
|
|||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
|
keychain_acl::before_read(app, &ssh_account("password", connection_id));
|
||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.get_password(&ssh_account("password", connection_id))
|
.get_password(&ssh_account("password", connection_id))
|
||||||
|
|||||||
@@ -1,117 +1,45 @@
|
|||||||
//! Keychain ACL normalization for stored secrets (macOS).
|
//! Keychain ACL normalization and repair for stored secrets (macOS).
|
||||||
//!
|
//!
|
||||||
//! # Why this exists
|
//! # Why this exists
|
||||||
//!
|
//!
|
||||||
//! Generic-password items stored via the legacy keychain API get a default ACL
|
//! Generic-password items stored via the legacy keychain API are guarded by an
|
||||||
//! that grants access to "the application that created the item". When a Tauri
|
//! item ACL. For a dev (linker-signed / ad-hoc) binary macOS records that ACL's
|
||||||
//! app is rebuilt, its code signature changes (dev builds are ad-hoc signed, so
|
//! trusted application as **the path of the executable** — not a code
|
||||||
//! the identity differs on every build), and the new binary no longer matches
|
//! requirement — so the item stays readable across rebuilds *as long as the
|
||||||
//! the item's ACL — macOS then shows the "Gridline wants to access your
|
//! binary keeps living at that path*. A properly signed bundle is recorded as a
|
||||||
//! keychain" prompt.
|
//! code requirement plus a `cdhash` partition list, which is stable across
|
||||||
|
//! versions and even moves.
|
||||||
//!
|
//!
|
||||||
//! The fix: after storing a secret, replace the item's ACL with an explicit
|
//! Two failure modes follow from that:
|
||||||
//! `SecAccess` whose trusted-application list contains exactly *this app*,
|
|
||||||
//! resolved by `SecTrustedApplicationCreateFromPath(NULL)` — the current
|
|
||||||
//! process's code-signature requirement. For a Developer ID-signed release
|
|
||||||
//! build that requirement is stable across versions (anchored to the signing
|
|
||||||
//! certificate), so items stay accessible without prompts through upgrades.
|
|
||||||
//!
|
//!
|
||||||
//! # Prompt behavior
|
//! 1. **The path changes** (monorepo move `src-tauri/` → `desktop/src-tauri/`,
|
||||||
|
//! app reinstalled elsewhere, release vs. dev binary). The stored paths no
|
||||||
|
//! longer match the running binary, so macOS treats it as a *foreign* app and
|
||||||
|
//! asks for the keychain password on **every** access — per secret, and with
|
||||||
|
//! no "Always Allow", because the item's ACL lists only other apps.
|
||||||
|
//! 2. **A differently-signed build wrote the item** and the pin never ran again.
|
||||||
//!
|
//!
|
||||||
//! - Item created by this build → the ACL set is silent.
|
//! Pinning only after writes (`after_store`) can't repair either case: writing
|
||||||
//! - Item created by an older/differently-signed build → macOS shows the
|
//! to an item we can't access already needs the same authorization, so the ACL
|
||||||
//! standard one-time authorization dialog; "Always Allow" replaces the ACL
|
//! is never corrected. This module therefore also *repairs before reads*.
|
||||||
//! and the prompt never reappears (the same dialog the app would show on the
|
|
||||||
//! first read anyway — this just makes the result permanent and clean).
|
|
||||||
//!
|
//!
|
||||||
//! All operations are best-effort: `after_store` swallows errors so keychain
|
//! # How the repair works
|
||||||
//! writes never fail because of ACL normalization.
|
//!
|
||||||
|
//! [`ensure_access`] first asks, **without prompting**, whether this process is
|
||||||
/// Best-effort ACL normalization after a successful keychain write.
|
//! already represented in the item's ACL (see [`is_item_trusted`]). Only when it
|
||||||
///
|
//! is not does it call `SecKeychainItemSetAccess` with an ACL whose trusted list
|
||||||
/// Resolves the service name the same way `tauri-plugin-keyring-store` does
|
//! is exactly this app. That call is what macOS gates behind the authorization
|
||||||
/// (the Tauri bundle identifier) and never fails the caller's operation.
|
//! dialog — so a stale item costs the user **one** authorization, and every
|
||||||
pub fn after_store(app: &tauri::AppHandle, account: &str) {
|
//! later access (including after future rebuilds at the same path) is silent.
|
||||||
let service = app.config().identifier.clone();
|
//! Items that are already trusted are left alone, so the ACL never accumulates
|
||||||
if let Err(e) = ensure_item_acl(&service, account) {
|
//! duplicate entries.
|
||||||
eprintln!("[keychain_acl] skipped for {account}: {e}");
|
//!
|
||||||
}
|
//! All operations are best-effort: they log to stderr and never fail the
|
||||||
}
|
//! caller's keychain read/write.
|
||||||
|
|
||||||
/// Grant this app (by its code-signature requirement) access to a keychain item.
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub fn ensure_item_acl(service: &str, account: &str) -> Result<(), String> {
|
|
||||||
use core_foundation::array::CFArray;
|
|
||||||
use core_foundation::base::{CFTypeRef, TCFType};
|
|
||||||
use core_foundation::string::CFString;
|
|
||||||
use security_framework::item::{ItemClass, ItemSearchOptions, Reference, SearchResult};
|
|
||||||
use security_framework::os::macos::access::SecAccess;
|
|
||||||
use security_framework_sys::base::SecAccessRef;
|
|
||||||
|
|
||||||
// Locate the item WITHOUT reading its secret: a ref-only lookup
|
|
||||||
// (`kSecReturnRef`, no data/attributes) never triggers an authorization
|
|
||||||
// prompt, so merely probing an item we can't yet access is safe.
|
|
||||||
let found = ItemSearchOptions::new()
|
|
||||||
.class(ItemClass::generic_password())
|
|
||||||
.service(service)
|
|
||||||
.account(account)
|
|
||||||
.load_refs(true)
|
|
||||||
.load_data(false)
|
|
||||||
.load_attributes(false)
|
|
||||||
.search()
|
|
||||||
.map_err(|e| format!("keychain item lookup failed: {e}"))?;
|
|
||||||
|
|
||||||
let item = match found.first() {
|
|
||||||
Some(SearchResult::Ref(Reference::KeychainItem(item))) => item,
|
|
||||||
// No item (yet) or an unexpected result shape — nothing to normalize.
|
|
||||||
_ => return Ok(()),
|
|
||||||
};
|
|
||||||
|
|
||||||
fn cvt(status: i32) -> Result<(), String> {
|
|
||||||
if status == 0 {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(format!("security framework error {status}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build an access object whose trusted list is exactly this app. A NULL
|
|
||||||
// path resolves the trusted application to the current process's code
|
|
||||||
// signature requirement.
|
|
||||||
unsafe {
|
|
||||||
let mut trusted_app_ref: *mut OpaqueSecTrustedApplication = std::ptr::null_mut();
|
|
||||||
cvt(SecTrustedApplicationCreateFromPath(std::ptr::null(), &mut trusted_app_ref))?;
|
|
||||||
let trusted_app = SecTrustedApplication::wrap_under_create_rule(trusted_app_ref);
|
|
||||||
let trusted_list = CFArray::from_CFTypes(&[trusted_app]);
|
|
||||||
|
|
||||||
let descriptor = CFString::new("Gridline");
|
|
||||||
let mut access_ref: SecAccessRef = std::ptr::null_mut();
|
|
||||||
cvt(SecAccessCreate(
|
|
||||||
descriptor.as_concrete_TypeRef() as CFTypeRef,
|
|
||||||
trusted_list.as_concrete_TypeRef() as CFTypeRef,
|
|
||||||
&mut access_ref,
|
|
||||||
))?;
|
|
||||||
let access = SecAccess::wrap_under_create_rule(access_ref);
|
|
||||||
|
|
||||||
// Replace the item's ACL. Silent when we already own the item;
|
|
||||||
// one-time authorization dialog when the item was created by a
|
|
||||||
// differently-signed build.
|
|
||||||
cvt(SecKeychainItemSetAccess(
|
|
||||||
item.as_concrete_TypeRef(),
|
|
||||||
access.as_concrete_TypeRef(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Non-macOS platforms have no keychain ACL concept — nothing to do.
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
pub fn ensure_item_acl(_service: &str, _account: &str) -> Result<(), String> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod macos_ffi {
|
mod macos_ffi {
|
||||||
use core_foundation::base::{CFTypeRef, CFTypeID};
|
use core_foundation::base::{CFTypeID, CFTypeRef};
|
||||||
|
|
||||||
/// Minimal `TCFType` wrapper for `SecTrustedApplication` — the
|
/// Minimal `TCFType` wrapper for `SecTrustedApplication` — the
|
||||||
/// security-framework crate exposes no high-level API for it.
|
/// security-framework crate exposes no high-level API for it.
|
||||||
@@ -140,11 +68,29 @@ mod macos_ffi {
|
|||||||
path: CFTypeRef,
|
path: CFTypeRef,
|
||||||
app: *mut SecTrustedApplicationRef,
|
app: *mut SecTrustedApplicationRef,
|
||||||
) -> i32;
|
) -> i32;
|
||||||
|
pub fn SecTrustedApplicationCopyData(
|
||||||
|
app: CFTypeRef,
|
||||||
|
data: *mut core_foundation::data::CFDataRef,
|
||||||
|
) -> i32;
|
||||||
pub fn SecAccessCreate(
|
pub fn SecAccessCreate(
|
||||||
descriptor: CFTypeRef,
|
descriptor: CFTypeRef,
|
||||||
trustedlist: CFTypeRef,
|
trustedlist: core_foundation::array::CFArrayRef,
|
||||||
access: *mut security_framework_sys::base::SecAccessRef,
|
access: *mut security_framework_sys::base::SecAccessRef,
|
||||||
) -> i32;
|
) -> i32;
|
||||||
|
pub fn SecKeychainItemCopyAccess(
|
||||||
|
item: security_framework_sys::base::SecKeychainItemRef,
|
||||||
|
access: *mut security_framework_sys::base::SecAccessRef,
|
||||||
|
) -> i32;
|
||||||
|
pub fn SecAccessCopyACLList(
|
||||||
|
access: security_framework_sys::base::SecAccessRef,
|
||||||
|
list: *mut core_foundation::array::CFArrayRef,
|
||||||
|
) -> i32;
|
||||||
|
pub fn SecACLCopyContents(
|
||||||
|
acl: CFTypeRef,
|
||||||
|
apps: *mut core_foundation::array::CFArrayRef,
|
||||||
|
desc: *mut core_foundation::string::CFStringRef,
|
||||||
|
prompt: *mut u32,
|
||||||
|
) -> i32;
|
||||||
pub fn SecKeychainItemSetAccess(
|
pub fn SecKeychainItemSetAccess(
|
||||||
item: security_framework_sys::base::SecKeychainItemRef,
|
item: security_framework_sys::base::SecKeychainItemRef,
|
||||||
access: security_framework_sys::base::SecAccessRef,
|
access: security_framework_sys::base::SecAccessRef,
|
||||||
@@ -152,21 +98,260 @@ mod macos_ffi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use core_foundation::base::TCFType;
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use macos_ffi::{
|
use macos_ffi::{
|
||||||
OpaqueSecTrustedApplication, SecAccessCreate, SecKeychainItemSetAccess, SecTrustedApplication,
|
SecACLCopyContents, SecAccessCopyACLList, SecAccessCreate, SecKeychainItemCopyAccess,
|
||||||
|
SecKeychainItemSetAccess, SecTrustedApplication, SecTrustedApplicationCopyData,
|
||||||
SecTrustedApplicationCreateFromPath,
|
SecTrustedApplicationCreateFromPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Best-effort ACL normalization after a successful keychain write.
|
||||||
|
pub fn after_store(app: &tauri::AppHandle, account: &str) {
|
||||||
|
ensure_access(app, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort ACL repair *before* reading a secret.
|
||||||
|
///
|
||||||
|
/// A stale ACL is what makes macOS ask for the keychain password on every
|
||||||
|
/// access, so this runs before reads: it authorizes/repairs the item once and
|
||||||
|
/// leaves it silent from then on. Prompt-free (and cheap) when the item is
|
||||||
|
/// already trusted or does not exist.
|
||||||
|
pub fn before_read(app: &tauri::AppHandle, account: &str) {
|
||||||
|
ensure_access(app, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the keyring service name (the Tauri bundle identifier) and repair the
|
||||||
|
/// item's ACL when this app is not already trusted. Never fails the caller.
|
||||||
|
fn ensure_access(app: &tauri::AppHandle, account: &str) {
|
||||||
|
let service = app.config().identifier.clone();
|
||||||
|
if let Err(e) = ensure_item_acl(&service, account) {
|
||||||
|
eprintln!("[keychain_acl] skipped for {account}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make sure this app can read `account` without an authorization prompt.
|
||||||
|
///
|
||||||
|
/// Idempotent and cheap when the item is already trusted (the common case):
|
||||||
|
/// the trust check is a metadata-only, prompt-free lookup. Returns `Ok(())` when
|
||||||
|
/// the item exists and now trusts this app, or when there is nothing to do.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn ensure_item_acl(service: &str, account: &str) -> Result<(), String> {
|
||||||
|
use security_framework::os::macos::access::SecAccess;
|
||||||
|
use security_framework_sys::base::SecAccessRef;
|
||||||
|
|
||||||
|
// Locate the item WITHOUT reading its secret: a ref-only lookup
|
||||||
|
// (`kSecReturnRef`, no data/attributes) never triggers an authorization
|
||||||
|
// prompt, so merely probing an item we can't yet access is safe.
|
||||||
|
let item = match item_ref(service, account)? {
|
||||||
|
Some(item) => item,
|
||||||
|
// No item (yet) — nothing to normalize.
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Already trusted → leave the ACL alone (repairing again would only
|
||||||
|
// authorize-and-append a duplicate entry).
|
||||||
|
if is_item_trusted(&item) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cvt(status: i32) -> Result<(), String> {
|
||||||
|
if status == 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("security framework error {status}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build an access object whose trusted list is exactly this app. A NULL
|
||||||
|
// path resolves the trusted application to the current process's identity
|
||||||
|
// (its executable path for ad-hoc dev builds, its code requirement for a
|
||||||
|
// signed bundle).
|
||||||
|
unsafe {
|
||||||
|
let mut trusted_app_ref = std::ptr::null_mut();
|
||||||
|
cvt(SecTrustedApplicationCreateFromPath(
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut trusted_app_ref,
|
||||||
|
))?;
|
||||||
|
let trusted_app = SecTrustedApplication::wrap_under_create_rule(trusted_app_ref);
|
||||||
|
let trusted_list = core_foundation::array::CFArray::from_CFTypes(&[trusted_app]);
|
||||||
|
|
||||||
|
let descriptor = core_foundation::string::CFString::new("Gridline");
|
||||||
|
let mut access_ref: SecAccessRef = std::ptr::null_mut();
|
||||||
|
cvt(SecAccessCreate(
|
||||||
|
descriptor.as_concrete_TypeRef() as core_foundation::base::CFTypeRef,
|
||||||
|
trusted_list.as_concrete_TypeRef(),
|
||||||
|
&mut access_ref,
|
||||||
|
))?;
|
||||||
|
let access = SecAccess::wrap_under_create_rule(access_ref);
|
||||||
|
|
||||||
|
// Replace the item's ACL. Silent when this app already owns the item;
|
||||||
|
// the one-time authorization dialog otherwise (this is what makes a
|
||||||
|
// stale item permanently silent again).
|
||||||
|
cvt(SecKeychainItemSetAccess(
|
||||||
|
item.as_concrete_TypeRef(),
|
||||||
|
access.as_concrete_TypeRef(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ref-only lookup for a generic-password item. Prompt-free.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn item_ref(
|
||||||
|
service: &str,
|
||||||
|
account: &str,
|
||||||
|
) -> Result<Option<security_framework::os::macos::keychain_item::SecKeychainItem>, String> {
|
||||||
|
use security_framework::item::{ItemClass, ItemSearchOptions, Reference, SearchResult};
|
||||||
|
|
||||||
|
let found = match ItemSearchOptions::new()
|
||||||
|
.class(ItemClass::generic_password())
|
||||||
|
.service(service)
|
||||||
|
.account(account)
|
||||||
|
.load_refs(true)
|
||||||
|
.load_data(false)
|
||||||
|
.load_attributes(false)
|
||||||
|
.search()
|
||||||
|
{
|
||||||
|
Ok(found) => found,
|
||||||
|
// `search()` reports "nothing matched" as an error, not an empty vec.
|
||||||
|
// That is the normal case for connections without a stored secret.
|
||||||
|
Err(e) if e.code() == security_framework_sys::base::errSecItemNotFound => return Ok(None),
|
||||||
|
Err(e) => return Err(format!("keychain item lookup failed: {e}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(match found.first() {
|
||||||
|
Some(SearchResult::Ref(Reference::KeychainItem(item))) => Some(item.clone()),
|
||||||
|
// No item or an unexpected result shape — nothing to normalize.
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This process's identity as a `SecTrustedApplication`, serialized.
|
||||||
|
///
|
||||||
|
/// For a signed bundle this is the code requirement; for a dev/ad-hoc binary it
|
||||||
|
/// is the resolved executable path (NUL-terminated). Either way, byte-comparing
|
||||||
|
/// it against an item's ACL entries tells us whether the ACL already admits us.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn own_identity() -> Option<Vec<u8>> {
|
||||||
|
unsafe {
|
||||||
|
let mut app = std::ptr::null_mut();
|
||||||
|
if SecTrustedApplicationCreateFromPath(std::ptr::null(), &mut app) != 0 || app.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let app = SecTrustedApplication::wrap_under_create_rule(app);
|
||||||
|
let mut data: core_foundation::data::CFDataRef = std::ptr::null();
|
||||||
|
if SecTrustedApplicationCopyData(
|
||||||
|
app.as_concrete_TypeRef() as core_foundation::base::CFTypeRef,
|
||||||
|
&mut data,
|
||||||
|
) != 0
|
||||||
|
|| data.is_null()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bytes = std::slice::from_raw_parts(
|
||||||
|
core_foundation::data::CFDataGetBytePtr(data),
|
||||||
|
core_foundation::data::CFDataGetLength(data) as usize,
|
||||||
|
)
|
||||||
|
.to_vec();
|
||||||
|
core_foundation::base::CFRelease(data as core_foundation::base::CFTypeRef);
|
||||||
|
Some(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this process is already admitted by the item's ACL — **prompt-free**.
|
||||||
|
///
|
||||||
|
/// True when an ACL entry's serialized trusted application is byte-identical to
|
||||||
|
/// this process's identity, or when the item carries a `cdhash:` partition list
|
||||||
|
/// (how macOS records access for a signed bundle — we leave those untouched).
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn is_item_trusted(item: &security_framework::os::macos::keychain_item::SecKeychainItem) -> bool {
|
||||||
|
use core_foundation::array::{CFArray, CFArrayRef};
|
||||||
|
use core_foundation::base::{CFTypeRef, TCFType};
|
||||||
|
use core_foundation::string::{CFString, CFStringRef};
|
||||||
|
|
||||||
|
let Some(own) = own_identity() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let mut access: security_framework_sys::base::SecAccessRef = std::ptr::null_mut();
|
||||||
|
if SecKeychainItemCopyAccess(item.as_concrete_TypeRef(), &mut access) != 0
|
||||||
|
|| access.is_null()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut acl_list: CFArrayRef = std::ptr::null();
|
||||||
|
if SecAccessCopyACLList(access, &mut acl_list) != 0 || acl_list.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let acls = CFArray::<CFTypeRef>::wrap_under_create_rule(acl_list);
|
||||||
|
|
||||||
|
for acl in acls.iter() {
|
||||||
|
let mut apps: CFArrayRef = std::ptr::null();
|
||||||
|
let mut desc: CFStringRef = std::ptr::null();
|
||||||
|
let mut prompt: u32 = 0;
|
||||||
|
if SecACLCopyContents(*acl, &mut apps, &mut desc, &mut prompt) != 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !desc.is_null() {
|
||||||
|
// Signed-bundle items store a code-signing partition list here.
|
||||||
|
if CFString::wrap_under_create_rule(desc)
|
||||||
|
.to_string()
|
||||||
|
.contains("cdhash:")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if apps.is_null() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let list = CFArray::<CFTypeRef>::wrap_under_create_rule(apps);
|
||||||
|
for app in list.iter() {
|
||||||
|
let mut data: core_foundation::data::CFDataRef = std::ptr::null();
|
||||||
|
if SecTrustedApplicationCopyData(*app, &mut data) != 0 || data.is_null() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bytes = std::slice::from_raw_parts(
|
||||||
|
core_foundation::data::CFDataGetBytePtr(data),
|
||||||
|
core_foundation::data::CFDataGetLength(data) as usize,
|
||||||
|
);
|
||||||
|
if bytes == own.as_slice() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-macOS platforms have no keychain ACL concept — nothing to do.
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
pub fn ensure_item_acl(_service: &str, _account: &str) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(test, target_os = "macos"))]
|
#[cfg(all(test, target_os = "macos"))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use security_framework::os::macos::keychain::SecKeychain;
|
use security_framework::os::macos::keychain::SecKeychain;
|
||||||
use security_framework::os::macos::passwords::find_generic_password;
|
use security_framework::os::macos::passwords::find_generic_password;
|
||||||
|
|
||||||
/// Real-keychain integration test: creates a generic-password item the
|
/// A missing item is a silent no-op — no lookup errors, no prompts. This is
|
||||||
/// same way the keyring store does, normalizes its ACL, verifies the
|
/// the path taken for every connection that never stored a password, so it
|
||||||
/// secret is still readable, and cleans up.
|
/// must stay cheap and quiet.
|
||||||
|
#[test]
|
||||||
|
fn ensure_item_acl_missing_item_is_noop() {
|
||||||
|
let service = "com.adrianbonpin.gridline.acl-test.missing";
|
||||||
|
let account = "definitely-not-stored";
|
||||||
|
ensure_item_acl(service, account).expect("no-op for a missing item");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real-keychain integration test: creates a generic-password item the same
|
||||||
|
/// way the keyring store does, asserts the trust check, repairs the ACL,
|
||||||
|
/// verifies the item is now trusted (and that repairing again is a silent
|
||||||
|
/// no-op), reads the secret back, and cleans up.
|
||||||
///
|
///
|
||||||
/// Ignored by default (`cargo test`) so CI/dev runs never touch the login
|
/// Ignored by default (`cargo test`) so CI/dev runs never touch the login
|
||||||
/// keychain. Run explicitly with:
|
/// keychain. Run explicitly with:
|
||||||
@@ -184,8 +369,21 @@ mod tests {
|
|||||||
.set_generic_password(service, &account, b"test-secret")
|
.set_generic_password(service, &account, b"test-secret")
|
||||||
.expect("create test item");
|
.expect("create test item");
|
||||||
|
|
||||||
// ACL normalization must succeed on an item we just created.
|
// Fresh items are created with this build as the trusted app, so the
|
||||||
|
// trust check must already be true and the repair a no-op.
|
||||||
|
let item = item_ref(service, &account)
|
||||||
|
.expect("lookup")
|
||||||
|
.expect("item exists");
|
||||||
|
assert!(is_item_trusted(&item), "fresh item should already trust us");
|
||||||
|
|
||||||
ensure_item_acl(service, &account).expect("normalize ACL");
|
ensure_item_acl(service, &account).expect("normalize ACL");
|
||||||
|
assert!(
|
||||||
|
is_item_trusted(&item),
|
||||||
|
"item must be trusted after repair"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Idempotent: a second repair must not need authorization again.
|
||||||
|
ensure_item_acl(service, &account).expect("second repair");
|
||||||
|
|
||||||
// The item must remain readable with the correct secret.
|
// The item must remain readable with the correct secret.
|
||||||
let (password, item) =
|
let (password, item) =
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Gridline",
|
"productName": "Gridline",
|
||||||
"version": "0.8.0",
|
"version": "0.8.1",
|
||||||
"identifier": "com.adrianbonpin.gridline",
|
"identifier": "com.adrianbonpin.gridline",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import tauriConf from "../../src-tauri/tauri.conf.json";
|
import tauriConf from "../../src-tauri/tauri.conf.json";
|
||||||
|
|
||||||
describe("tauri bundle config (v0.8.0)", () => {
|
describe("tauri bundle config (v0.8.1)", () => {
|
||||||
it("declares bundled pg_tools resources", () => {
|
it("declares bundled pg_tools resources", () => {
|
||||||
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
||||||
});
|
});
|
||||||
it("version is 0.8.0", () => {
|
it("version is 0.8.1", () => {
|
||||||
expect(tauriConf.version).toBe("0.8.0");
|
expect(tauriConf.version).toBe("0.8.1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5,7 +5,7 @@ import agents from "../../../AGENTS.md?raw";
|
|||||||
import readme from "../../../README.md?raw";
|
import readme from "../../../README.md?raw";
|
||||||
import landing from "../../../www/public/landing.js?raw";
|
import landing from "../../../www/public/landing.js?raw";
|
||||||
|
|
||||||
describe("v0.8.0 docs coverage", () => {
|
describe("v0.8.1 docs coverage", () => {
|
||||||
it("AGENTS.md marks inline cell editing complete", () => {
|
it("AGENTS.md marks inline cell editing complete", () => {
|
||||||
expect(agents).toContain("Inline cell editing");
|
expect(agents).toContain("Inline cell editing");
|
||||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||||
@@ -25,8 +25,8 @@ describe("v0.8.0 docs coverage", () => {
|
|||||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||||
});
|
});
|
||||||
it("README declares v0.8.0", () => {
|
it("README declares v0.8.1", () => {
|
||||||
expect(readme).toContain("0.8.0");
|
expect(readme).toContain("0.8.1");
|
||||||
});
|
});
|
||||||
it("AGENTS.md marks schema CRUD complete", () => {
|
it("AGENTS.md marks schema CRUD complete", () => {
|
||||||
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
||||||
@@ -65,12 +65,12 @@ describe("v0.8.0 docs coverage", () => {
|
|||||||
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
|
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
|
||||||
expect(agents).toMatch(/Settings export\/import \| ✅/);
|
expect(agents).toMatch(/Settings export\/import \| ✅/);
|
||||||
});
|
});
|
||||||
it("README links to v0.8.0 assets in both download tables", () => {
|
it("README links to v0.8.1 assets in both download tables", () => {
|
||||||
expect(readme).toContain("releases/download/v0.8.0/");
|
expect(readme).toContain("releases/download/v0.8.1/");
|
||||||
expect(readme).toContain("Gridline_0.8.0_aarch64.dmg");
|
expect(readme).toContain("Gridline_0.8.1_aarch64.dmg");
|
||||||
expect(readme).toContain("Gridline-0.8.0-1.x86_64.rpm");
|
expect(readme).toContain("Gridline-0.8.1-1.x86_64.rpm");
|
||||||
});
|
});
|
||||||
it("landing.js platform download links point to v0.8.0", () => {
|
it("landing.js platform download links point to v0.8.1", () => {
|
||||||
expect(landing).toContain('const version = "0.8.0"');
|
expect(landing).toContain('const version = "0.8.1"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||||||
import pkg from "../../package.json";
|
import pkg from "../../package.json";
|
||||||
|
|
||||||
describe("version", () => {
|
describe("version", () => {
|
||||||
it("declares v0.8.0 across the app shell", () => {
|
it("declares v0.8.1 across the app shell", () => {
|
||||||
expect(pkg.version).toBe("0.8.0");
|
expect(pkg.version).toBe("0.8.1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
const keepLabel = downloadBtn.hasAttribute("data-download-label");
|
const keepLabel = downloadBtn.hasAttribute("data-download-label");
|
||||||
|
|
||||||
const releasesUrl = "https://github.com/AdrianBonpin/gridline/releases";
|
const releasesUrl = "https://github.com/AdrianBonpin/gridline/releases";
|
||||||
const version = "0.8.0";
|
const version = "0.8.1";
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
downloadBtn.href = releasesUrl;
|
downloadBtn.href = releasesUrl;
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ export interface ChangelogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const changelogEntries: ChangelogEntry[] = [
|
export const changelogEntries: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.8.1",
|
||||||
|
date: "2026-09-24",
|
||||||
|
highlights: [
|
||||||
|
"Managed-provider connections fixed — pasting a Neon / Supabase / PlanetScale connection string now carries its `?sslmode=` into the saved connection (it was silently dropped, so TLS stayed off and Neon answered `connection is insecure (try using sslmode=require)`). The SSH / SSL tab's mode and certificate paths are now persisted on create *and* edit, and the managed presets pre-select their TLS mode (Supabase / Neon → Require, PlanetScale → Verify Full). As a safety net, hosts under `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` default to `require` when a connection has no stored mode, so connections saved before this fix keep working.",
|
||||||
|
"Pasting a connection URI fills every setting it can — type, host, port, user, password, database and TLS mode — and suggests a name from the URL (database name, else host), which Test and Save use when the label is left blank. A pasted URL can now be tested and saved with no retyping.",
|
||||||
|
"macOS keychain prompts now self-heal — an item whose ACL still points at an older binary path (the pre-monorepo `src-tauri/` layout, a reinstalled app, an ad-hoc dev build) made macOS ask for your keychain password on *every* access, once per secret, with no way to make it stick. The app now checks the item's ACL (silently) before reading and repairs it when needed, so you authorize a secret once and it stays silent afterwards.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.8.0",
|
version: "0.8.0",
|
||||||
date: "2026-09-09",
|
date: "2026-09-09",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import queryEditor from "../assets/screenshots/query-editor.png";
|
|||||||
import erDiagram from "../assets/screenshots/er-diagram.png";
|
import erDiagram from "../assets/screenshots/er-diagram.png";
|
||||||
import home from "../assets/screenshots/home.png";
|
import home from "../assets/screenshots/home.png";
|
||||||
import dataGrid from "../assets/screenshots/data-grid.png";
|
import dataGrid from "../assets/screenshots/data-grid.png";
|
||||||
const version = "0.8.0";
|
const version = "0.8.1";
|
||||||
const site = "https://getgridline.app";
|
const site = "https://getgridline.app";
|
||||||
const jsonLd = [
|
const jsonLd = [
|
||||||
buildSoftwareApplicationJsonLd({
|
buildSoftwareApplicationJsonLd({
|
||||||
|
|||||||
Reference in New Issue
Block a user