Compare commits

..
Author SHA1 Message Date
dependabot[bot] 14685a72d5 Bump bytes from 1.10.1 to 1.11.1 in the cargo group across 1 directory
Bumps the cargo group with 1 update in the / directory: [bytes](https://github.com/tokio-rs/bytes).


Updates `bytes` from 1.10.1 to 1.11.1
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.10.1...v1.11.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.1
  dependency-type: indirect
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-03 19:26:21 +00:00
13 changed files with 31 additions and 801 deletions

No files matched your search

-64
View File
@@ -1,64 +0,0 @@
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,2 +1 @@
/target /target
/.direnv
Generated
+5 -71
View File
@@ -117,9 +117,9 @@ checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.10.1" version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "caps" name = "caps"
@@ -225,22 +225,6 @@ 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"
@@ -341,17 +325,6 @@ 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"
@@ -412,9 +385,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.190" version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]] [[package]]
name = "libredox" name = "libredox"
@@ -426,12 +399,6 @@ 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"
@@ -574,7 +541,6 @@ dependencies = [
"rpassword", "rpassword",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
"tokio", "tokio",
"toml", "toml",
"tracing", "tracing",
@@ -591,12 +557,6 @@ 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"
@@ -612,7 +572,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 0.2.16", "getrandom",
"libredox", "libredox",
"thiserror 2.0.12", "thiserror 2.0.12",
] ]
@@ -673,19 +633,6 @@ 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"
@@ -815,19 +762,6 @@ 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"
-3
View File
@@ -26,6 +26,3 @@ toml = "0.8.20"
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", 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
@@ -1,90 +0,0 @@
# 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.
+17 -64
View File
@@ -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.as_ref(), "--session", &self.session]) .args(["get", "item", &entry.id, "--session", &self.session])
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.spawn() .spawn()
@@ -96,7 +96,21 @@ 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"))?;
extract_secret(&data, &entry.query) let expr = jmespath::compile(&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 => {
tracing::error!(?data, ?expr, "find secret");
anyhow::bail!("invalid item")
}
};
Ok(secret)
} }
pub async fn logout(&self) -> anyhow::Result<()> { pub async fn logout(&self) -> anyhow::Result<()> {
@@ -111,64 +125,3 @@ 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());
}
}
+1 -125
View File
@@ -6,13 +6,12 @@ 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: UuidV4, pub id: String,
pub query: String, pub query: String,
pub path: String, pub path: String,
pub links: Option<Vec<String>>, pub links: Option<Vec<String>>,
@@ -106,126 +105,3 @@ 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());
}
}
+1 -49
View File
@@ -29,10 +29,8 @@ impl Email {
} }
} }
} }
}
impl AsRef<str> for Email { pub fn as_ref(&self) -> &str {
fn as_ref(&self) -> &str {
&self.0 &self.0
} }
} }
@@ -60,49 +58,3 @@ 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
@@ -1,110 +0,0 @@
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,6 +1,5 @@
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,8 +19,4 @@ 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();
} }
+7 -53
View File
@@ -1,6 +1,6 @@
use std::{ use std::{
os::unix::fs::PermissionsExt, os::unix::fs::PermissionsExt,
path::{Path, PathBuf}, path::PathBuf,
process::{Command, Stdio}, process::{Command, Stdio},
sync::Arc, sync::Arc,
}; };
@@ -97,7 +97,11 @@ 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 = resolve_link_path(link, &HOME_DIRECTORY); let link_path: PathBuf = if PathBuf::from(link).is_absolute() {
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 {
@@ -125,7 +129,7 @@ impl UnixSecretStore {
}) })
.inspect_err(|error| tracing::error!(src = ?path, dst = ?link_path, ?error, "symlink failed")) .inspect_err(|error| tracing::error!(src = ?path, dst = ?link_path, ?error, "symlink failed"))
.await .await
.map_err(std::io::Error::other)??; .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))??;
tracing::info!(src =? path, destination = ?link_path, "created/updated symlink"); tracing::info!(src =? path, destination = ?link_path, "created/updated symlink");
} }
@@ -175,53 +179,3 @@ 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
@@ -1,166 +0,0 @@
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)"
);
}