mirror of
https://github.com/Kruhlmann/punlock.git
synced 2026-11-05 09:24:08 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31b0156f6c | ||
|
|
abf205bd93 | ||
|
|
8c6ec3a7f4 |
No files matched your search
@@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-action@stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-action@stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-action@stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt -- --check
|
||||
@@ -1 +1,2 @@
|
||||
/target
|
||||
/.direnv
|
||||
Generated
+69
-3
@@ -225,6 +225,22 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
@@ -325,6 +341,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "gimli"
|
||||
version = "0.31.1"
|
||||
@@ -385,9 +412,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.172"
|
||||
version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
@@ -399,6 +426,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.12"
|
||||
@@ -541,6 +574,7 @@ dependencies = [
|
||||
"rpassword",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
@@ -557,6 +591,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.11"
|
||||
@@ -572,7 +612,7 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"getrandom 0.2.16",
|
||||
"libredox",
|
||||
"thiserror 2.0.12",
|
||||
]
|
||||
@@ -633,6 +673,19 @@ version = "0.1.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "rustversion"
|
||||
version = "1.0.20"
|
||||
@@ -762,6 +815,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
|
||||
@@ -26,3 +26,6 @@ toml = "0.8.20"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
|
||||
users = "0.11.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.20.0"
|
||||
+64
-17
@@ -26,7 +26,7 @@ impl Bitwarden<()> {
|
||||
.ok();
|
||||
if let Some(ref d) = domain {
|
||||
Command::new("bw")
|
||||
.args(["config", "server", &d])
|
||||
.args(["config", "server", d])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
@@ -77,7 +77,7 @@ impl Bitwarden<()> {
|
||||
impl Bitwarden<String> {
|
||||
pub async fn fetch(&self, entry: &PunlockConfigurationEntry) -> anyhow::Result<String> {
|
||||
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())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
@@ -96,21 +96,7 @@ impl Bitwarden<String> {
|
||||
let data: serde_json::Value = serde_json::from_slice(&output.stdout)
|
||||
.inspect_err(|error| tracing::error!(?error, json = ?output.stdout, "invalid json"))?;
|
||||
|
||||
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)
|
||||
extract_secret(&data, &entry.query)
|
||||
}
|
||||
|
||||
pub async fn logout(&self) -> anyhow::Result<()> {
|
||||
@@ -125,3 +111,64 @@ impl Bitwarden<String> {
|
||||
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
@@ -6,12 +6,13 @@ use tokio::{fs::File, io::AsyncWriteExt};
|
||||
|
||||
use crate::{
|
||||
email::Email,
|
||||
item_id::UuidV4,
|
||||
statics::{LATEST_CONFIGURATION_VERSION, SYSTEM_CONFIG_PATH_CANDIDATES},
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct PunlockConfigurationEntry {
|
||||
pub id: String,
|
||||
pub id: UuidV4,
|
||||
pub query: String,
|
||||
pub path: String,
|
||||
pub links: Option<Vec<String>>,
|
||||
@@ -105,3 +106,126 @@ impl PunlockConfiguration {
|
||||
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
@@ -29,8 +29,10 @@ impl Email {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_ref(&self) -> &str {
|
||||
impl AsRef<str> for Email {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
@@ -58,3 +60,49 @@ impl std::fmt::Display for Email {
|
||||
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
@@ -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,5 +1,6 @@
|
||||
pub mod bitwarden;
|
||||
pub mod config;
|
||||
pub mod email;
|
||||
pub mod item_id;
|
||||
pub mod statics;
|
||||
pub mod store;
|
||||
@@ -19,4 +19,8 @@ lazy_static! {
|
||||
PathBuf::from("/etc/punlock/").join("CONFIG_FILE_NAME")]
|
||||
.to_vec();
|
||||
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();
|
||||
}
|
||||
+53
-7
@@ -1,6 +1,6 @@
|
||||
use std::{
|
||||
os::unix::fs::PermissionsExt,
|
||||
path::PathBuf,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
sync::Arc,
|
||||
};
|
||||
@@ -97,11 +97,7 @@ impl UnixSecretStore {
|
||||
|
||||
if let Some(ref links) = entry.links {
|
||||
for link in links {
|
||||
let link_path: PathBuf = if PathBuf::from(link).is_absolute() {
|
||||
PathBuf::from(link)
|
||||
} else {
|
||||
HOME_DIRECTORY.join(link)
|
||||
};
|
||||
let link_path = resolve_link_path(link, &HOME_DIRECTORY);
|
||||
tokio::fs::create_dir_all(link_path.parent().unwrap_or(&link_path))
|
||||
.await?;
|
||||
match tokio::fs::symlink_metadata(&link_path).await {
|
||||
@@ -129,7 +125,7 @@ impl UnixSecretStore {
|
||||
})
|
||||
.inspect_err(|error| tracing::error!(src = ?path, dst = ?link_path, ?error, "symlink failed"))
|
||||
.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");
|
||||
}
|
||||
@@ -179,3 +175,53 @@ impl UnixSecretStore {
|
||||
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
@@ -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)"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user