Compare commits

...
8 Commits
Author SHA1 Message Date
ges 31b0156f6c fix: updated outdated ci dependency 2026-10-13 01:05:48 +02:00
ges abf205bd93 feat: add missing tests 2026-10-13 01:03:46 +02:00
ges 8c6ec3a7f4 fix: branch name 2026-10-13 00:32:43 +02:00
ges b093fd6b0e feat: ci 2026-10-11 18:44:21 +02:00
ges 08e3b6025a Merge branch 'master' of github.com:Kruhlmann/punlock 2025-10-20 13:30:45 +02:00
ges 4ce91d617f Improve debugging 2025-10-20 13:30:16 +02:00
ges 993da22357 Merge pull request #1 from Kruhlmann/dependabot/cargo/cargo-2dc5bac7e8
Bump tracing-subscriber from 0.3.19 to 0.3.20 in the cargo group across 1 directory
2025-09-01 07:42:47 -04:00
dependabot[bot] f7543b2e6c Bump tracing-subscriber in the cargo group across 1 directory
Bumps the cargo group with 1 update in the / directory: [tracing-subscriber](https://github.com/tokio-rs/tracing).


Updates `tracing-subscriber` from 0.3.19 to 0.3.20
- [Release notes](https://github.com/tokio-rs/tracing/releases)
- [Commits](https://github.com/tokio-rs/tracing/compare/tracing-subscriber-0.3.19...tracing-subscriber-0.3.20)

---
updated-dependencies:
- dependency-name: tracing-subscriber
  dependency-version: 0.3.20
  dependency-type: direct:production
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-08-29 20:56:20 +00:00
13 changed files with 827 additions and 86 deletions

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Build
run: cargo build --release
- name: Run tests
run: cargo test --release
clippy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Run Clippy
run: cargo clippy -- -D warnings
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Check formatting
run: cargo fmt -- --check
+1
View File
@@ -1 +1,2 @@
/target /target
/.direnv
Generated
+81 -59
View File
@@ -225,6 +225,22 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.31" version = "0.3.31"
@@ -325,6 +341,17 @@ dependencies = [
"wasi", "wasi",
] ]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]] [[package]]
name = "gimli" name = "gimli"
version = "0.31.1" version = "0.31.1"
@@ -385,9 +412,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.172" version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]] [[package]]
name = "libredox" name = "libredox"
@@ -399,6 +426,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.12" version = "0.4.12"
@@ -417,11 +450,11 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.1.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [ dependencies = [
"regex-automata 0.1.10", "regex-automata",
] ]
[[package]] [[package]]
@@ -452,12 +485,11 @@ dependencies = [
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.46.0" version = "0.50.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399"
dependencies = [ dependencies = [
"overload", "windows-sys 0.52.0",
"winapi",
] ]
[[package]] [[package]]
@@ -481,12 +513,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "overload"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.3" version = "0.12.3"
@@ -548,6 +574,7 @@ dependencies = [
"rpassword", "rpassword",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
"tokio", "tokio",
"toml", "toml",
"tracing", "tracing",
@@ -564,6 +591,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.11" version = "0.5.11"
@@ -579,7 +612,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.2.16",
"libredox", "libredox",
"thiserror 2.0.12", "thiserror 2.0.12",
] ]
@@ -592,17 +625,8 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
"regex-automata 0.4.9", "regex-automata",
"regex-syntax 0.8.5", "regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
dependencies = [
"regex-syntax 0.6.29",
] ]
[[package]] [[package]]
@@ -613,15 +637,9 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
"regex-syntax 0.8.5", "regex-syntax",
] ]
[[package]]
name = "regex-syntax"
version = "0.6.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
[[package]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.8.5" version = "0.8.5"
@@ -655,6 +673,19 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.20" version = "1.0.20"
@@ -784,6 +815,19 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -942,14 +986,14 @@ dependencies = [
[[package]] [[package]]
name = "tracing-subscriber" name = "tracing-subscriber"
version = "0.3.19" version = "0.3.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
dependencies = [ dependencies = [
"matchers", "matchers",
"nu-ansi-term", "nu-ansi-term",
"once_cell", "once_cell",
"regex", "regex-automata",
"sharded-slab", "sharded-slab",
"smallvec", "smallvec",
"thread_local", "thread_local",
@@ -1050,28 +1094,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
+4 -1
View File
@@ -24,5 +24,8 @@ serde_json = "1.0.140"
tokio = { version = "1.44.2", features = ["full"] } tokio = { version = "1.44.2", features = ["full"] }
toml = "0.8.20" toml = "0.8.20"
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
users = "0.11.0" users = "0.11.0"
[dev-dependencies]
tempfile = "3.20.0"
+90
View File
@@ -0,0 +1,90 @@
# Punlock
PasswordUNLOCKer - A tool to unlock secrets from [Bitwarden](https://bitwarden.com/) and mount them to the filesystem.
## Overview
Punlock authenticates with Bitwarden, fetches secrets based on `JMESPath` queries, and writes them to a secure runtime directory. It can also create symlinks to make secrets accessible at convenient locations.
## Features
- Authenticate with Bitwarden (including self-hosted instances)
- Fetch secrets using `JMESPath` queries on Bitwarden items
- Store secrets in a secure runtime directory (Linux: `/run/user/<uid>/punlock/`)
- Create symlinks to secrets at custom locations
- Automatic file permissions (readonly, 400 for private secrets)
## Prerequisites
- Rust (for building)
- Bitwarden CLI (`bw`) installed and available in PATH
## Building
```bash
cargo build --release
```
The binary will be available at `target/release/punlock`.
## Configuration
Create a configuration file at one of these locations:
- Current directory: `config.toml`
- User config: `~/.config/punlock/config.toml`
- System config: `/etc/punlock/config.toml`
Or specify a custom path with `--config`:
```bash
punlock --config /path/to/config.toml
```
### Configuration Format
```toml
domain = "https://your-bitwarden-instance.com"
email = "[email protected]"
# Secrets to unlock
[[entries]]
id = "bitwarden-item-uuid"
query = "details.fields[[email protected]=='ssh_private_key'].value"
path = "ssh/id_rsa"
public = false
links = ["~/.ssh/id_rsa"]
[[entries]]
id = "another-item-uuid"
query = "details.login.password"
path = "api/tokens/github"
public = true
links = ["~/.config/github/token"]
```
### Configuration Fields
- `domain` (optional): Bitwarden server URL for self-hosted instances
- `email` (optional): Bitwarden account email
- `entries`: Array of secrets to unlock
- `id`: Bitwarden item UUID
- `query`: `JMESPath` expression to extract the secret from the item
- `path`: Relative path in the runtime directory to write the secret
- `links` (optional): Array of symlink paths to create (supports `~` for home directory)
- `public` (default: `false`): If true, file permissions are 0444; otherwise 0400
## Usage
```sh
punlock
# Or
punlock --config /path/to/config.toml
```
On first run, if email is not configured, you'll be prompted to enter it. You'll also need to enter your Bitwarden password.
## Runtime Directory
On Linux, secrets are stored in: `/run/user/<uid>/punlock/`
This is a tmpfs directory that is automatically cleaned on reboot to ensure that secrets do not stick around.
+66 -16
View File
@@ -5,8 +5,8 @@ use tokio::process::Command;
use crate::{config::PunlockConfigurationEntry, email::Email}; use crate::{config::PunlockConfigurationEntry, email::Email};
pub struct Bitwarden<S> { pub struct Bitwarden<S> {
email: Email, pub email: Email,
session: S, pub session: S,
} }
impl Bitwarden<()> { impl Bitwarden<()> {
@@ -26,7 +26,7 @@ impl Bitwarden<()> {
.ok(); .ok();
if let Some(ref d) = domain { if let Some(ref d) = domain {
Command::new("bw") Command::new("bw")
.args(["config", "server", &d]) .args(["config", "server", d])
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.status() .status()
@@ -77,7 +77,7 @@ impl Bitwarden<()> {
impl Bitwarden<String> { impl Bitwarden<String> {
pub async fn fetch(&self, entry: &PunlockConfigurationEntry) -> anyhow::Result<String> { pub async fn fetch(&self, entry: &PunlockConfigurationEntry) -> anyhow::Result<String> {
let bw = Command::new("bw") let bw = Command::new("bw")
.args(["get", "item", &entry.id, "--session", &self.session]) .args(["get", "item", entry.id.as_ref(), "--session", &self.session])
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.spawn() .spawn()
@@ -96,18 +96,7 @@ impl Bitwarden<String> {
let data: serde_json::Value = serde_json::from_slice(&output.stdout) let data: serde_json::Value = serde_json::from_slice(&output.stdout)
.inspect_err(|error| tracing::error!(?error, json = ?output.stdout, "invalid json"))?; .inspect_err(|error| tracing::error!(?error, json = ?output.stdout, "invalid json"))?;
let expr = jmespath::compile(&entry.query) extract_secret(&data, &entry.query)
.inspect_err(|error| tracing::error!(?error, ?entry, "input/expression mismatch"))?;
let result = expr
.search(&data)
.inspect_err(|error| tracing::error!(?error, ?expr, "query failed to apply"))?;
let secret = match result.as_string() {
Some(s) => s.to_owned(),
None => anyhow::bail!("invalid item"),
};
Ok(secret)
} }
pub async fn logout(&self) -> anyhow::Result<()> { pub async fn logout(&self) -> anyhow::Result<()> {
@@ -122,3 +111,64 @@ impl Bitwarden<String> {
Ok(()) Ok(())
} }
} }
pub(crate) fn extract_secret(data: &serde_json::Value, query: &str) -> anyhow::Result<String> {
let expr = jmespath::compile(query)
.inspect_err(|error| tracing::error!(?error, ?query, "input/expression mismatch"))?;
let result = expr
.search(data)
.inspect_err(|error| tracing::error!(?error, ?expr, "query failed to apply"))?;
match result.as_string() {
Some(s) => Ok(s.to_owned()),
None => {
tracing::error!(?data, ?expr, "find secret");
anyhow::bail!("invalid item")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn extracts_a_string_value() {
let item = json!({ "login": { "username": "u", "password": "s3cr3t" } });
let secret = extract_secret(&item, "login.password").expect("should extract password");
assert_eq!(secret, "s3cr3t");
}
#[test]
fn extracts_custom_field_via_filter_and_index() {
let item = json!({
"fields": [
{ "name": "other", "value": "ignored" },
{ "name": "ssh_private_key", "value": "-----BEGIN KEY-----" }
]
});
let query = "fields[?name=='ssh_private_key'].value | [0]";
let secret = extract_secret(&item, query).expect("should extract custom field");
assert_eq!(secret, "-----BEGIN KEY-----");
}
#[test]
fn rejects_query_that_yields_a_non_string() {
let item = json!({ "fields": [{ "name": "ssh_private_key", "value": "k" }] });
// a filter projection yields an array, not a string
assert!(extract_secret(&item, "fields[?name=='ssh_private_key'].value").is_err());
}
#[test]
fn rejects_missing_key() {
let item = json!({ "login": {} });
assert!(extract_secret(&item, "login.password").is_err());
}
#[test]
fn rejects_invalid_jmespath_expression() {
let item = json!({});
assert!(extract_secret(&item, "fields[?name==").is_err());
}
}
+125 -1
View File
@@ -6,12 +6,13 @@ use tokio::{fs::File, io::AsyncWriteExt};
use crate::{ use crate::{
email::Email, email::Email,
item_id::UuidV4,
statics::{LATEST_CONFIGURATION_VERSION, SYSTEM_CONFIG_PATH_CANDIDATES}, statics::{LATEST_CONFIGURATION_VERSION, SYSTEM_CONFIG_PATH_CANDIDATES},
}; };
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug)]
pub struct PunlockConfigurationEntry { pub struct PunlockConfigurationEntry {
pub id: String, pub id: UuidV4,
pub query: String, pub query: String,
pub path: String, pub path: String,
pub links: Option<Vec<String>>, pub links: Option<Vec<String>>,
@@ -105,3 +106,126 @@ impl PunlockConfiguration {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
const VALID_ID: &str = "11111111-2222-3333-4444-555555555555";
#[test]
fn partial_config_deserializes_full_document() {
let toml = r#"
domain = "https://bw.example.com"
version = "0.1.0"
email = "[email protected]"
[[entries]]
id = "11111111-2222-3333-4444-555555555555"
query = "details.fields[[email protected]=='ssh_private_key'].value"
path = "ssh/id_rsa"
public = true
links = ["~/.ssh/id_rsa"]
"#;
let cfg: PartialPunlockConfiguration =
toml::from_str(toml).expect("valid configuration should deserialize");
assert_eq!(cfg.domain.as_deref(), Some("https://bw.example.com"));
assert_eq!(cfg.version.as_deref(), Some("0.1.0"));
assert_eq!(cfg.email.as_deref(), Some("[email protected]"));
let entries = cfg.entries.expect("entries should be present");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id.as_ref(), VALID_ID);
assert_eq!(entries[0].path, "ssh/id_rsa");
assert!(entries[0].public);
assert_eq!(entries[0].links, Some(vec!["~/.ssh/id_rsa".to_string()]));
}
#[test]
fn partial_config_applies_field_defaults() {
let toml = r#"
[[entries]]
id = "11111111-2222-3333-4444-555555555555"
query = "details.login.password"
path = "api/token"
"#;
let cfg: PartialPunlockConfiguration =
toml::from_str(toml).expect("minimal configuration should deserialize");
assert!(cfg.domain.is_none());
assert!(cfg.version.is_none());
assert!(cfg.email.is_none());
let entries = cfg.entries.expect("entries should be present");
assert_eq!(entries.len(), 1);
assert!(!entries[0].public, "public should default to false");
assert!(entries[0].links.is_none(), "links should default to none");
}
#[test]
fn partial_config_rejects_invalid_toml() {
assert!(toml::from_str::<PartialPunlockConfiguration>("this is not = = toml").is_err());
}
#[test]
fn conversion_defaults_version_and_entries() {
let partial = PartialPunlockConfiguration {
domain: None,
version: None,
email: Some("[email protected]".to_string()),
entries: None,
};
let cfg: PunlockConfiguration = partial.try_into().expect("valid email should convert");
assert_eq!(cfg.version, *LATEST_CONFIGURATION_VERSION);
assert_eq!(cfg.email.as_ref(), "[email protected]");
assert!(cfg.domain.is_none());
assert!(cfg.entries.is_empty());
}
#[test]
fn conversion_preserves_provided_version_and_entries() {
let entry = PunlockConfigurationEntry {
id: UuidV4::try_from(VALID_ID).unwrap(),
query: "details.login.password".to_string(),
path: "api/token".to_string(),
links: None,
public: false,
};
let partial = PartialPunlockConfiguration {
domain: Some("https://bw.example.com".to_string()),
version: Some("0.1.0".to_string()),
email: Some("[email protected]".to_string()),
entries: Some(vec![entry]),
};
let cfg: PunlockConfiguration = partial.try_into().expect("valid config should convert");
assert_eq!(cfg.version, "0.1.0");
assert_eq!(cfg.domain.as_deref(), Some("https://bw.example.com"));
assert_eq!(cfg.entries.len(), 1);
assert_eq!(cfg.entries[0].id.as_ref(), VALID_ID);
}
#[test]
fn partial_config_rejects_invalid_item_id() {
let toml = r#"
[[entries]]
id = "not-a-uuid"
query = "details.login.password"
path = "api/token"
"#;
assert!(toml::from_str::<PartialPunlockConfiguration>(toml).is_err());
}
#[test]
fn conversion_rejects_invalid_email() {
let partial = PartialPunlockConfiguration {
domain: None,
version: Some("0.1.0".to_string()),
email: Some("not-an-email".to_string()),
entries: Some(vec![]),
};
assert!(PunlockConfiguration::try_from(partial).is_err());
}
}
+49 -1
View File
@@ -29,8 +29,10 @@ impl Email {
} }
} }
} }
}
pub fn as_ref(&self) -> &str { impl AsRef<str> for Email {
fn as_ref(&self) -> &str {
&self.0 &self.0
} }
} }
@@ -58,3 +60,49 @@ impl std::fmt::Display for Email {
write!(f, "{}", self.0) write!(f, "{}", self.0)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_valid_emails() {
for case in [
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
] {
let email = Email::try_from(case).expect("should accept valid email");
assert_eq!(email.as_ref(), case);
}
}
#[test]
fn rejects_invalid_emails() {
for case in [
"",
"plainaddress",
"@missing-local.org",
"missing-at-sign.org",
"no-tld@domain",
"space [email protected]",
"user@domain@",
"user @domain.com",
] {
assert!(
Email::try_from(case).is_err(),
"should reject invalid email: {:?}",
case
);
}
}
#[test]
fn display_and_into_string_round_trip() {
let email = Email::try_from("[email protected]").unwrap();
assert_eq!(email.to_string(), "[email protected]");
let as_string: String = email.into();
assert_eq!(as_string, "[email protected]");
}
}
+110
View File
@@ -0,0 +1,110 @@
use std::convert::TryFrom;
use serde::{Deserialize, Serialize};
use crate::statics::UUID_V4_REGEX;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "String")]
pub struct UuidV4(String);
impl UuidV4 {
fn is_valid(value: &str) -> bool {
UUID_V4_REGEX.is_match(value)
}
}
impl AsRef<str> for UuidV4 {
fn as_ref(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for UuidV4 {
type Error = anyhow::Error;
fn try_from(value: String) -> anyhow::Result<Self> {
if Self::is_valid(&value) {
Ok(Self(value))
} else {
anyhow::bail!("invalid item id {}", value)
}
}
}
impl TryFrom<&str> for UuidV4 {
type Error = anyhow::Error;
fn try_from(value: &str) -> anyhow::Result<Self> {
Self::try_from(value.to_string())
}
}
impl From<UuidV4> for String {
fn from(val: UuidV4) -> Self {
val.0
}
}
impl std::fmt::Display for UuidV4 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_valid_uuids() {
for case in [
"11111111-2222-3333-4444-555555555555",
"2f1e4b6a-9c3d-4a7e-8b2f-1c5d6e7f8a90",
"00000000-0000-0000-0000-000000000000",
"ABCDEF01-2345-6789-ABCD-EF0123456789",
] {
let id = UuidV4::try_from(case).expect("should accept valid uuid");
assert_eq!(id.as_ref(), case);
}
}
#[test]
fn rejects_invalid_uuids() {
for case in [
"",
"item-uuid",
"11111111222233334444555555555555",
"11111111-2222-3333-4444-55555555555",
"11111111-2222-3333-4444-5555555555555",
"11111111-2222-3333-4444-55555555555g",
"11111111 2222 3333 4444 555555555555",
"z1111111-2222-3333-4444-555555555555",
] {
assert!(
UuidV4::try_from(case).is_err(),
"should reject invalid uuid: {:?}",
case
);
}
}
#[test]
fn display_and_into_string_round_trip() {
let id = UuidV4::try_from("11111111-2222-3333-4444-555555555555").unwrap();
assert_eq!(id.to_string(), "11111111-2222-3333-4444-555555555555");
let as_string: String = id.into();
assert_eq!(as_string, "11111111-2222-3333-4444-555555555555");
}
#[test]
fn deserializes_valid_uuid_from_string() {
let id: UuidV4 = serde_json::from_str("\"11111111-2222-3333-4444-555555555555\"").unwrap();
assert_eq!(id.as_ref(), "11111111-2222-3333-4444-555555555555");
}
#[test]
fn deserialization_rejects_invalid_uuid() {
assert!(serde_json::from_str::<UuidV4>("\"not-a-uuid\"").is_err());
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod bitwarden; pub mod bitwarden;
pub mod config; pub mod config;
pub mod email; pub mod email;
pub mod item_id;
pub mod statics; pub mod statics;
pub mod store; pub mod store;
+4
View File
@@ -19,4 +19,8 @@ lazy_static! {
PathBuf::from("/etc/punlock/").join("CONFIG_FILE_NAME")] PathBuf::from("/etc/punlock/").join("CONFIG_FILE_NAME")]
.to_vec(); .to_vec();
pub static ref EMAIL_REGEX: Regex = Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap(); pub static ref EMAIL_REGEX: Regex = Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap();
pub static ref UUID_V4_REGEX: Regex = Regex::new(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
.unwrap();
} }
+66 -8
View File
@@ -1,6 +1,11 @@
use std::{os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc}; use std::{
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
process::{Command, Stdio},
sync::Arc,
};
use futures::{StreamExt, stream::FuturesUnordered}; use futures::{stream::FuturesUnordered, StreamExt, TryFutureExt};
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use crate::{ use crate::{
@@ -52,6 +57,12 @@ impl UnixSecretStore {
pub async fn write_secrets(&self, entries: &[PunlockConfigurationEntry]) -> anyhow::Result<()> { pub async fn write_secrets(&self, entries: &[PunlockConfigurationEntry]) -> anyhow::Result<()> {
let mut tasks = FuturesUnordered::new(); let mut tasks = FuturesUnordered::new();
Command::new("bw")
.args(["sync", "--session", &self.bitwarden.session])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.inspect_err(|error| tracing::error!(?error, "spawn get"))?;
for entry in entries.iter() { for entry in entries.iter() {
let root = self.root_path.clone(); let root = self.root_path.clone();
@@ -86,11 +97,7 @@ impl UnixSecretStore {
if let Some(ref links) = entry.links { if let Some(ref links) = entry.links {
for link in links { for link in links {
let link_path: PathBuf = if PathBuf::from(link).is_absolute() { let link_path = resolve_link_path(link, &HOME_DIRECTORY);
PathBuf::from(link)
} else {
HOME_DIRECTORY.join(link)
};
tokio::fs::create_dir_all(link_path.parent().unwrap_or(&link_path)) tokio::fs::create_dir_all(link_path.parent().unwrap_or(&link_path))
.await?; .await?;
match tokio::fs::symlink_metadata(&link_path).await { match tokio::fs::symlink_metadata(&link_path).await {
@@ -116,8 +123,9 @@ impl UnixSecretStore {
std::os::windows::fs::symlink_file(&src, &dst)?; std::os::windows::fs::symlink_file(&src, &dst)?;
Ok(()) Ok(())
}) })
.inspect_err(|error| tracing::error!(src = ?path, dst = ?link_path, ?error, "symlink failed"))
.await .await
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))??; .map_err(std::io::Error::other)??;
tracing::info!(src =? path, destination = ?link_path, "created/updated symlink"); tracing::info!(src =? path, destination = ?link_path, "created/updated symlink");
} }
@@ -167,3 +175,53 @@ impl UnixSecretStore {
Ok(self) Ok(self)
} }
} }
fn resolve_link_path(link: &str, home: &Path) -> PathBuf {
let path = PathBuf::from(link);
if path.is_absolute() {
return path;
}
if link == "~" {
return home.to_path_buf();
}
if let Some(rest) = link.strip_prefix("~/") {
return home.join(rest);
}
home.join(link)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn absolute_link_paths_are_left_untouched() {
let home = Path::new("/home/testuser");
assert_eq!(
resolve_link_path("/etc/punlock/key", home),
PathBuf::from("/etc/punlock/key")
);
}
#[test]
fn tilde_is_expanded_to_home() {
let home = Path::new("/home/testuser");
assert_eq!(
resolve_link_path("~", home),
PathBuf::from("/home/testuser")
);
assert_eq!(
resolve_link_path("~/.ssh/id_rsa", home),
PathBuf::from("/home/testuser/.ssh/id_rsa")
);
}
#[test]
fn relative_link_paths_are_joined_onto_home() {
let home = Path::new("/home/testuser");
assert_eq!(
resolve_link_path("sub/key", home),
PathBuf::from("/home/testuser/sub/key")
);
}
}
+166
View File
@@ -0,0 +1,166 @@
use std::{
convert::TryFrom,
ffi::OsString,
fs,
os::unix::fs::PermissionsExt,
sync::{Mutex, MutexGuard},
};
use punlock::{
bitwarden::Bitwarden, config::PunlockConfigurationEntry, email::Email, item_id::UuidV4,
store::UnixSecretStore,
};
static PATH_LOCK: Mutex<()> = Mutex::new(());
struct FakeBw {
_dir: tempfile::TempDir,
_lock: MutexGuard<'static, ()>,
old_path: OsString,
}
impl FakeBw {
fn new(script: &str) -> Self {
let lock = PATH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let dir = tempfile::tempdir().expect("tempdir");
let bw = dir.path().join("bw");
fs::write(&bw, format!("#!/usr/bin/env bash\n{script}\n")).expect("write fake bw");
fs::set_permissions(&bw, fs::Permissions::from_mode(0o755)).expect("chmod fake bw");
let old_path = std::env::var_os("PATH").unwrap_or_default();
std::env::set_var(
"PATH",
format!("{}:{}", dir.path().display(), old_path.to_string_lossy()),
);
Self {
_dir: dir,
_lock: lock,
old_path,
}
}
}
impl Drop for FakeBw {
fn drop(&mut self) {
std::env::set_var("PATH", &self.old_path);
}
}
fn bitwarden() -> Bitwarden<String> {
Bitwarden::<String> {
email: Email::try_from("[email protected]").expect("valid email"),
session: "fake-session".to_string(),
}
}
fn entry(id: &str, path: &str, public: bool) -> PunlockConfigurationEntry {
PunlockConfigurationEntry {
id: UuidV4::try_from(id).expect("valid uuid"),
query: "login.password".to_string(),
path: path.to_string(),
links: None,
public,
}
}
#[tokio::test]
async fn fetch_returns_secret_from_fake_bw() {
let _fake = FakeBw::new(
r#"case "$1" in
get) printf '%s' '{"login":{"password":"s3cr3t"}}' ;;
esac"#,
);
let secret = bitwarden()
.fetch(&entry(
"11111111-1111-4111-8111-111111111111",
"x/token",
false,
))
.await
.expect("fetch should succeed");
assert_eq!(secret, "s3cr3t");
}
#[tokio::test]
async fn fetch_errors_when_bw_fails() {
let _fake = FakeBw::new(
r#"case "$1" in
get) echo "item not found" >&2; exit 1 ;;
esac"#,
);
let result = bitwarden()
.fetch(&entry(
"11111111-1111-4111-8111-111111111111",
"x/token",
false,
))
.await;
assert!(result.is_err(), "fetch should fail when bw exits non-zero");
}
#[tokio::test]
async fn logout_succeeds_against_fake_bw() {
let _fake = FakeBw::new("exit 0");
bitwarden().logout().await.expect("logout should succeed");
}
#[tokio::test]
async fn write_secrets_writes_files_and_skips_failed_items() {
let _fake = FakeBw::new(
r#"case "$1" in
sync) exit 0 ;;
get)
case "$3" in
11111111-1111-4111-8111-111111111111) printf '%s' '{"login":{"password":"alpha"}}' ;;
22222222-2222-4222-8222-222222222222) printf '%s' '{"login":{"password":"beta"}}' ;;
33333333-3333-4333-8333-333333333333) echo "missing item" >&2; exit 1 ;;
*) printf '%s' '{}' ;;
esac ;;
esac"#,
);
let root = tempfile::tempdir().expect("tempdir");
let store = UnixSecretStore::new(bitwarden(), root.path().to_path_buf());
let entries = vec![
entry("11111111-1111-4111-8111-111111111111", "a/token", false),
entry("22222222-2222-4222-8222-222222222222", "b/token", true),
entry("33333333-3333-4333-8333-333333333333", "c/token", false),
];
store
.write_secrets(&entries)
.await
.expect("write_secrets should not fail on a single bad item");
let private = root.path().join("a/token");
let public = root.path().join("b/token");
let missing = root.path().join("c/token");
assert_eq!(
fs::read_to_string(&private).expect("private written"),
"alpha\n"
);
assert_eq!(
fs::read_to_string(&public).expect("public written"),
"beta\n"
);
assert!(!missing.exists(), "failed item should not be written");
let private_mode = fs::metadata(&private).expect("meta").permissions().mode();
assert_eq!(private_mode & 0o777, 0o400, "private secret should be 0400");
let public_mode = fs::metadata(&public).expect("meta").permissions().mode();
assert_eq!(
public_mode & 0o222,
0,
"public secret should still be read-only (no write bits)"
);
}