From 26e8e386b9c1102279377565c3b6d2d65f01f6ff Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 16 Aug 2025 11:13:32 -0400 Subject: [PATCH] feat: Secret module works with infisical and local file storage backends --- Cargo.lock | 184 +++++++++++- Cargo.toml | 7 +- harmony/Cargo.toml | 8 +- harmony/src/domain/mod.rs | 1 - harmony/src/domain/secrets/mod.rs | 265 ------------------ harmony/src/infra/opnsense/mod.rs | 6 - harmony/src/modules/tenant/credentials.rs.bak | 51 ---- harmony_secret/Cargo.toml | 23 ++ harmony_secret/src/config.rs | 18 ++ harmony_secret/src/lib.rs | 166 +++++++++++ harmony_secret/src/store/infisical.rs | 129 +++++++++ harmony_secret/src/store/local_file.rs | 105 +++++++ harmony_secret/src/store/mod.rs | 4 + .../test_harmony_secret_infisical.sh | 8 + .../Cargo.toml | 2 +- harmony_secret_derive/src/lib.rs | 38 +++ harmony_secrets_derive/src/lib.rs | 152 ---------- harmony_secrets_derive/src/lib.rsglm45 | 100 ------- opnsense-config-xml/Cargo.toml | 2 +- 19 files changed, 677 insertions(+), 592 deletions(-) delete mode 100644 harmony/src/domain/secrets/mod.rs delete mode 100644 harmony/src/modules/tenant/credentials.rs.bak create mode 100644 harmony_secret/Cargo.toml create mode 100644 harmony_secret/src/config.rs create mode 100644 harmony_secret/src/lib.rs create mode 100644 harmony_secret/src/store/infisical.rs create mode 100644 harmony_secret/src/store/local_file.rs create mode 100644 harmony_secret/src/store/mod.rs create mode 100644 harmony_secret/test_harmony_secret_infisical.sh rename {harmony_secrets_derive => harmony_secret_derive}/Cargo.toml (83%) create mode 100644 harmony_secret_derive/src/lib.rs delete mode 100644 harmony_secrets_derive/src/lib.rs delete mode 100644 harmony_secrets_derive/src/lib.rsglm45 diff --git a/Cargo.lock b/Cargo.lock index d8e8d27..741fb74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -515,6 +515,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -1689,9 +1695,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -1789,6 +1797,7 @@ dependencies = [ "env_logger", "fqdn", "futures-util", + "harmony-secret-derive", "harmony_macros", "harmony_types", "helm-wrapper-rs", @@ -1823,7 +1832,6 @@ dependencies = [ "temp-dir", "temp-file", "tempfile", - "thiserror 2.0.14", "tokio", "tokio-util", "url", @@ -1831,7 +1839,26 @@ dependencies = [ ] [[package]] -name = "harmony-secrets-derive" +name = "harmony-secret" +version = "0.1.0" +dependencies = [ + "async-trait", + "directories", + "harmony-secret-derive", + "http 1.3.1", + "infisical", + "lazy_static", + "log", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.14", + "tokio", +] + +[[package]] +name = "harmony-secret-derive" version = "0.1.0" dependencies = [ "proc-macro-crate", @@ -2141,7 +2168,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -2220,6 +2247,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2282,7 +2310,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.5.10", "system-configuration 0.6.1", "tokio", "tower-service", @@ -2499,6 +2527,21 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +[[package]] +name = "infisical" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d97c33b08e22b2f7b9f87a8fc06a7d247442db7bf216ffc6661a74ed8aea658" +dependencies = [ + "base64 0.22.1", + "reqwest 0.12.20", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "url", +] + [[package]] name = "inout" version = "0.1.4" @@ -2539,6 +2582,17 @@ dependencies = [ "syn", ] +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2908,6 +2962,12 @@ dependencies = [ "hashbrown 0.15.4", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "md5" version = "0.7.0" @@ -3210,7 +3270,7 @@ dependencies = [ "pretty_assertions", "rand 0.8.5", "serde", - "thiserror 1.0.69", + "thiserror 2.0.14", "tokio", "uuid", "xml-rs", @@ -3622,6 +3682,61 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9e1dcb320d6839f6edb64f7a4a59d39b30480d4d1765b56873f7c858538a5fe" +[[package]] +name = "quinn" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.14", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.1", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.14", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.40" @@ -3841,6 +3956,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2 0.4.10", @@ -3857,6 +3973,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -3864,6 +3982,7 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3873,6 +3992,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots", ] [[package]] @@ -4062,6 +4182,12 @@ version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -4161,6 +4287,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -4646,6 +4773,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "spin" version = "0.9.8" @@ -5008,20 +5145,38 @@ dependencies = [ ] [[package]] -name = "tokio" -version = "1.45.1" +name = "tinyvec" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio 1.0.4", + "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "slab", + "socket2 0.6.0", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5572,6 +5727,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 3ead724..c770668 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,8 @@ members = [ "harmony_cli", "k3d", "harmony_composer", - "harmony_secrets_derive", + "harmony_secret_derive", + "harmony_secret", ] [workspace.package] @@ -54,6 +55,10 @@ chrono = "0.4" similar = "2" uuid = { version = "1.11", features = ["v4", "fast-rng", "macro-diagnostics"] } pretty_assertions = "1.4.1" +tempfile = "3.20.0" bollard = "0.19.1" base64 = "0.22.1" tar = "0.4.44" +lazy_static = "1.5.0" +directories = "6.0.0" +thiserror = "2.0.14" diff --git a/harmony/Cargo.toml b/harmony/Cargo.toml index b46ce33..abbdfb3 100644 --- a/harmony/Cargo.toml +++ b/harmony/Cargo.toml @@ -38,8 +38,8 @@ serde-value.workspace = true helm-wrapper-rs = "0.4.0" non-blank-string-rs = "1.0.4" k3d-rs = { path = "../k3d" } -directories = "6.0.0" -lazy_static = "1.5.0" +directories.workspace = true +lazy_static.workspace = true dockerfile_builder = "0.1.5" temp-file = "0.1.9" convert_case.workspace = true @@ -59,7 +59,7 @@ similar.workspace = true futures-util = "0.3.31" tokio-util = "0.7.15" strum = { version = "0.27.1", features = ["derive"] } -tempfile = "3.20.0" +tempfile.workspace = true serde_with = "3.14.0" schemars = "0.8.22" kube-derive = "1.1.0" @@ -67,7 +67,7 @@ bollard.workspace = true tar.workspace = true base64.workspace = true once_cell = "1.21.3" -thiserror = "2.0.14" +harmony-secret-derive = { version = "0.1.0", path = "../harmony_secret_derive" } [dev-dependencies] pretty_assertions.workspace = true diff --git a/harmony/src/domain/mod.rs b/harmony/src/domain/mod.rs index 4d46287..028fa7f 100644 --- a/harmony/src/domain/mod.rs +++ b/harmony/src/domain/mod.rs @@ -9,4 +9,3 @@ pub mod inventory; pub mod maestro; pub mod score; pub mod topology; -pub mod secrets; diff --git a/harmony/src/domain/secrets/mod.rs b/harmony/src/domain/secrets/mod.rs deleted file mode 100644 index 3951a90..0000000 --- a/harmony/src/domain/secrets/mod.rs +++ /dev/null @@ -1,265 +0,0 @@ -//! # Harmony Secrets Module -//! -//! This module provides core abstractions for type-safe secret management within the Harmony framework. -//! -//! ## Design Philosophy -//! -//! The design is centered around three key components: -//! -//! 1. **The `Secret` Trait:** This is the heart of the module. Instead of using strings to identify -//! secrets, we use dedicated, zero-sized types (ZSTs). Each ZST represents a single secret -//! and implements the `Secret` trait to provide metadata (like its namespace and key) and -//! its associated `Value` type. This enables full compile-time verification. -//! -//! 2. **The `Secrets` Struct:** This is the primary user-facing API. It provides the `get` and `set` -//! methods that are generic over any type implementing `Secret`. It's the high-level, -//! easy-to-use entry point for all secret operations. -//! -//! 3. **The `SecretStore` Trait:** This is the low-level backend interface. It defines the contract -//! for how the `Secrets` struct will interact with an actual storage system (like Infisical, -//! a local file, or a database). This decouples the high-level API from the implementation details. -//! -//! ## Example Usage -//! -//! ``` -//! // In an external crate (e.g., harmony-okd): -//! use harmony_secrets::{Secret, StoreError}; -//! -//! // 1. Define a zero-sized struct for each secret. -//! pub struct KubeadminPassword; -//! -//! // 2. Implement the `Secret` trait to provide metadata. -//! impl Secret for KubeadminPassword { -//! // The associated type defines what you get back. -//! type Value = String; -//! -//! const NAMESPACE: &'static str = "okd-installation"; -//! const KEY: &'static str = "kubeadmin-password"; -//! } -//! -//! // 3. Use it with the `Secrets` struct. -//! async fn example(secrets: &harmony_secrets::Secrets) -> Result<(), StoreError> { -//! // The API is type-safe. The compiler knows what `Value` to expect. -//! secrets.set::("password123".to_string()).await?; -//! let password = secrets.get::().await?; -//! assert_eq!(password, "password123"); -//! Ok(()) -//! } -//! ``` - -use async_trait::async_trait; -use serde::{de::DeserializeOwned, Serialize}; -use std::sync::Arc; -use thiserror::Error; - -/// Defines the set of errors that can occur during secret operations. -/// Using `thiserror` provides a great developer experience for error handling. -#[derive(Debug, Error)] -pub enum StoreError { - #[error("Secret not found in store: namespace='{namespace}', key='{key}'")] - NotFound { namespace: String, key: String }, - - #[error("Permission denied for secret: namespace='{namespace}', key='{key}'")] - PermissionDenied { namespace: String, key: String }, - - #[error("Failed to deserialize secret value: {0}")] - Deserialization(String), - - #[error("Failed to serialize secret value: {0}")] - Serialization(String), - - #[error("A backend-specific error occurred: {0}")] - Backend(String), -} - -/// A trait that marks a type as representing a single, retrievable secret. -/// -/// This trait should be implemented on a unique, zero-sized struct for each secret -/// your module needs to manage. This pattern ensures that all secret access is -/// validated at compile time. -pub trait Secret: 'static + Send + Sync { - /// The data type of the secret's value. This ensures that `get` and `set` - /// operations are fully type-safe. The value must be serializable. - type Value: Serialize + DeserializeOwned + Send; - - /// A logical grouping for secrets, similar to a Kubernetes namespace or a - /// directory path. This will be used by the `SecretStore` to organize data. - const NAMESPACE: &'static str; - - /// The unique key for the secret within its namespace. - const KEY: &'static str; -} - -/// The low-level storage trait that concrete secret backends must implement. -/// -/// This trait operates on raw bytes (`Vec`), keeping it decoupled from any -/// specific serialization format. The `Secrets` struct will handle the -// serialization/deserialization boundary. -#[async_trait] -pub trait SecretStore: Send + Sync { - /// Retrieves the raw byte value of a secret from the backend. - async fn get(&self, namespace: &str, key: &str) -> Result, StoreError>; - - /// Saves the raw byte value of a secret to the backend. - async fn set(&self, namespace: &str, key: &str, value: Vec) -> Result<(), StoreError>; -} - -/// The primary, user-facing struct for interacting with secrets. -/// -/// It provides a high-level, type-safe API that is decoupled from the -/// underlying storage mechanism. -#[derive(Clone)] -pub struct Secrets { - /// A shared, thread-safe reference to the underlying secret store. - store: Arc, -} - -impl Secrets { - /// Creates a new `Secrets` instance with the given store implementation. - pub fn new(store: Arc) -> Self { - Self { store } - } - - /// Retrieves a secret from the store in a fully type-safe manner. - /// - /// The type of the secret to retrieve is specified using a generic parameter `S`, - /// which must implement the `Secret` trait. The method returns the `S::Value` - /// associated type, ensuring you always get the data type you expect. - /// - /// # Example - /// `let admin_pass = secrets.get::().await?;` - pub async fn get(&self) -> Result { - let bytes = self - .store - .get(S::NAMESPACE, S::KEY) - .await - .map_err(|e| match e { - // Preserve the NotFound error for better diagnostics. - StoreError::NotFound { .. } => e, - _ => StoreError::Backend(e.to_string()), - })?; - - // The public API uses JSON for serialization. It's robust and human-readable. - serde_json::from_slice(&bytes) - .map_err(|e| StoreError::Deserialization(e.to_string())) - } - - /// Saves a secret to the store in a fully type-safe manner. - /// - /// The method is generic over the secret type `S`, and the `value` parameter - /// must match the `S::Value` associated type, preventing type mismatch errors - /// at compile time. - /// - /// # Example - /// `secrets.set::("new-password".to_string()).await?;` - pub async fn set(&self, value: S::Value) -> Result<(), StoreError> { - let bytes = serde_json::to_vec(&value) - .map_err(|e| StoreError::Serialization(e.to_string()))?; - - self.store - .set(S::NAMESPACE, S::KEY, bytes) - .await - .map_err(|e| StoreError::Backend(e.to_string())) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - use std::sync::Mutex; - - // Define a dummy secret for testing purposes. - struct TestApiKey; - impl Secret for TestApiKey { - type Value = String; - const NAMESPACE: &'static str = "global-tests"; - const KEY: &'static str = "api-key"; - } - - struct ComplexSecret; - #[derive(Serialize, serde::Deserialize, PartialEq, Debug, Clone)] - struct ComplexValue { - user: String, - permissions: Vec, - } - impl Secret for ComplexSecret { - type Value = ComplexValue; - const NAMESPACE: &'static str = "complex-tests"; - const KEY: &'static str = "user-data"; - } - - // A mock implementation of the `SecretStore` that uses an in-memory HashMap. - #[derive(Default)] - struct MockStore { - data: Mutex>>, - } - - #[async_trait] - impl SecretStore for MockStore { - async fn get(&self, namespace: &str, key: &str) -> Result, StoreError> { - let path = format!("{}/{}", namespace, key); - let data = self.data.lock().unwrap(); - data.get(&path) - .cloned() - .ok_or_else(|| StoreError::NotFound { - namespace: namespace.to_string(), - key: key.to_string(), - }) - } - - async fn set(&self, namespace: &str, key: &str, value: Vec) -> Result<(), StoreError> { - let path = format!("{}/{}", namespace, key); - let mut data = self.data.lock().unwrap(); - data.insert(path, value); - Ok(()) - } - } - - #[tokio::test] - async fn test_set_and_get_simple_secret() { - let store = Arc::new(MockStore::default()); - let secrets = Secrets::new(store); - - let api_key_value = "secret-key-12345".to_string(); - secrets - .set::(api_key_value.clone()) - .await - .unwrap(); - - let retrieved_key = secrets.get::().await.unwrap(); - assert_eq!(retrieved_key, api_key_value); - } - - #[tokio::test] - async fn test_set_and_get_complex_secret() { - let store = Arc::new(MockStore::default()); - let secrets = Secrets::new(store); - - let complex_value = ComplexValue { - user: "test-user".to_string(), - permissions: vec!["read".to_string(), "write".to_string()], - }; - secrets - .set::(complex_value.clone()) - .await - .unwrap(); - - let retrieved_value = secrets.get::().await.unwrap(); - assert_eq!(retrieved_value, complex_value); - } - - #[tokio::test] - async fn test_get_nonexistent_secret() { - let store = Arc::new(MockStore::default()); - let secrets = Secrets::new(store); - - let result = secrets.get::().await; - assert!(matches!(result, Err(StoreError::NotFound { .. }))); - - if let Err(StoreError::NotFound { namespace, key }) = result { - assert_eq!(namespace, TestApiKey::NAMESPACE); - assert_eq!(key, TestApiKey::KEY); - } - } -} diff --git a/harmony/src/infra/opnsense/mod.rs b/harmony/src/infra/opnsense/mod.rs index 5340bf4..0aa5532 100644 --- a/harmony/src/infra/opnsense/mod.rs +++ b/harmony/src/infra/opnsense/mod.rs @@ -22,18 +22,12 @@ pub struct OPNSenseFirewall { host: LogicalHost, } -// TODO figure out a design to have a unique identifiere for this firewall -// I think a project identifier would be good enough, then the secrets module configuration will -// point to the project's vault and this opnsense modules doesn't need to know anything about it -const OPNSENSE_CREDENTIALS: &str = "OPNSENSE_CREDENTIALS"; - impl OPNSenseFirewall { pub fn get_ip(&self) -> IpAddress { self.host.ip } pub async fn new(host: LogicalHost, port: Option, username: &str, password: &str) -> Self { - // let credentials = Secrets::get_by_name(OPNSENSE_CREDENTIALS) Self { opnsense_config: Arc::new(RwLock::new( opnsense_config::Config::from_credentials(host.ip, port, username, password).await, diff --git a/harmony/src/modules/tenant/credentials.rs.bak b/harmony/src/modules/tenant/credentials.rs.bak deleted file mode 100644 index ec55b94..0000000 --- a/harmony/src/modules/tenant/credentials.rs.bak +++ /dev/null @@ -1,51 +0,0 @@ -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use serde::Serialize; - -use crate::{interpret::InterpretError, score::Score, topology::Topology}; - -/// Create and manage Tenant Credentials. -/// -/// This is meant to be used by cluster administrators who need to provide their tenant users and -/// services with credentials to access their resources. -#[derive(Debug, Clone, Serialize)] -pub struct TenantCredentialScore; - -impl Score for TenantCredentialScore { - fn create_interpret(&self) -> Box> { - todo!() - } - - fn name(&self) -> String { - todo!() - } -} - -#[async_trait] -pub trait TenantCredentialManager { - async fn create_user(&self) -> Result; -} - -#[derive(Debug, Clone)] -pub struct CredentialMetadata { - pub tenant_id: String, - pub credential_id: String, - pub description: String, - pub created_at: DateTime, - pub expires_at: Option>, -} - -#[derive(Debug, Clone)] -pub enum CredentialData { - /// Used to store login instructions destined to a human. Akin to AWS login instructions email - /// upon new console user creation. - PlainText(String), -} - - -pub struct TenantCredentialBundle { - _metadata: CredentialMetadata, - _content: CredentialData, -} - -impl TenantCredentialBundle {} diff --git a/harmony_secret/Cargo.toml b/harmony_secret/Cargo.toml new file mode 100644 index 0000000..48c8f5c --- /dev/null +++ b/harmony_secret/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "harmony-secret" +edition = "2024" +version.workspace = true +readme.workspace = true +license.workspace = true + +[dependencies] +harmony-secret-derive = { version = "0.1.0", path = "../harmony_secret_derive" } +serde = { version = "1.0.209", features = ["derive", "rc"] } +serde_json = "1.0.127" +thiserror.workspace = true +lazy_static.workspace = true +directories.workspace = true +log.workspace = true +infisical = "0.0.2" +tokio.workspace = true +async-trait.workspace = true +http.workspace = true + +[dev-dependencies] +pretty_assertions.workspace = true +tempfile.workspace = true diff --git a/harmony_secret/src/config.rs b/harmony_secret/src/config.rs new file mode 100644 index 0000000..54494bf --- /dev/null +++ b/harmony_secret/src/config.rs @@ -0,0 +1,18 @@ +use lazy_static::lazy_static; + +lazy_static! { + pub static ref SECRET_NAMESPACE: String = + std::env::var("HARMONY_SECRET_NAMESPACE").expect("HARMONY_SECRET_NAMESPACE environment variable is required, it should contain the name of the project you are working on to access its secrets"); + pub static ref SECRET_STORE: Option = + std::env::var("HARMONY_SECRET_STORE").ok(); + pub static ref INFISICAL_URL: Option = + std::env::var("HARMONY_SECRET_INFISICAL_URL").ok(); + pub static ref INFISICAL_PROJECT_ID: Option = + std::env::var("HARMONY_SECRET_INFISICAL_PROJECT_ID").ok(); + pub static ref INFISICAL_ENVIRONMENT: Option = + std::env::var("HARMONY_SECRET_INFISICAL_ENVIRONMENT").ok(); + pub static ref INFISICAL_CLIENT_ID: Option = + std::env::var("HARMONY_SECRET_INFISICAL_CLIENT_ID").ok(); + pub static ref INFISICAL_CLIENT_SECRET: Option = + std::env::var("HARMONY_SECRET_INFISICAL_CLIENT_SECRET").ok(); +} diff --git a/harmony_secret/src/lib.rs b/harmony_secret/src/lib.rs new file mode 100644 index 0000000..a12fb8e --- /dev/null +++ b/harmony_secret/src/lib.rs @@ -0,0 +1,166 @@ +pub mod config; +mod store; + +use crate::config::SECRET_NAMESPACE; +use async_trait::async_trait; +use config::INFISICAL_CLIENT_ID; +use config::INFISICAL_CLIENT_SECRET; +use config::INFISICAL_ENVIRONMENT; +use config::INFISICAL_PROJECT_ID; +use config::INFISICAL_URL; +use config::SECRET_STORE; +use serde::{Serialize, de::DeserializeOwned}; +use std::fmt; +use store::InfisicalSecretStore; +use store::LocalFileSecretStore; +use thiserror::Error; +use tokio::sync::OnceCell; + +pub use harmony_secret_derive::Secret; + +// The Secret trait remains the same. +pub trait Secret: Serialize + DeserializeOwned + Sized { + const KEY: &'static str; +} + +// The error enum remains the same. +#[derive(Debug, Error)] +pub enum SecretStoreError { + #[error("Secret not found for key '{key}' in namespace '{namespace}'")] + NotFound { namespace: String, key: String }, + #[error("Failed to deserialize secret for key '{key}': {source}")] + Deserialization { + key: String, + source: serde_json::Error, + }, + #[error("Failed to serialize secret for key '{key}': {source}")] + Serialization { + key: String, + source: serde_json::Error, + }, + #[error("Underlying storage error: {0}")] + Store(#[from] Box), +} + +// The trait is now async! +#[async_trait] +pub trait SecretStore: fmt::Debug + Send + Sync { + async fn get_raw(&self, namespace: &str, key: &str) -> Result, SecretStoreError>; + async fn set_raw( + &self, + namespace: &str, + key: &str, + value: &[u8], + ) -> Result<(), SecretStoreError>; +} + +// Use OnceCell for async-friendly, one-time initialization. +static SECRET_MANAGER: OnceCell = OnceCell::const_new(); + +/// Initializes and returns a reference to the global SecretManager. +async fn get_secret_manager() -> &'static SecretManager { + SECRET_MANAGER.get_or_init(init_secret_manager).await +} + +/// The async initialization function for the SecretManager. +async fn init_secret_manager() -> SecretManager { + let default_secret_score = "infisical".to_string(); + let store_type = SECRET_STORE.as_ref().unwrap_or(&default_secret_score); + + let store: Box = match store_type.as_str() { + "file" => Box::new(LocalFileSecretStore::default()), + "infisical" | _ => { + let store = InfisicalSecretStore::new( + INFISICAL_URL.clone().expect("Infisical url must be set, see harmony_secret config for ways to provide it. You can try with HARMONY_SECRET_INFISICAL_URL"), + INFISICAL_PROJECT_ID.clone().expect("Infisical project id must be set, see harmony_secret config for ways to provide it. You can try with HARMONY_SECRET_INFISICAL_PROJECT_ID"), + INFISICAL_ENVIRONMENT.clone().expect("Infisical environment must be set, see harmony_secret config for ways to provide it. You can try with HARMONY_SECRET_INFISICAL_ENVIRONMENT"), + INFISICAL_CLIENT_ID.clone().expect("Infisical client id must be set, see harmony_secret config for ways to provide it. You can try with HARMONY_SECRET_INFISICAL_CLIENT_ID"), + INFISICAL_CLIENT_SECRET.clone().expect("Infisical client secret must be set, see harmony_secret config for ways to provide it. You can try with HARMONY_SECRET_INFISICAL_CLIENT_SECRET"), + ) + .await + .expect("Failed to initialize Infisical secret store"); + Box::new(store) + } + }; + + SecretManager::new(SECRET_NAMESPACE.clone(), store) +} + +/// Manages the lifecycle of secrets, providing a simple static API. +#[derive(Debug)] +pub struct SecretManager { + namespace: String, + store: Box, +} + +impl SecretManager { + fn new(namespace: String, store: Box) -> Self { + Self { namespace, store } + } + + /// Retrieves and deserializes a secret. + pub async fn get() -> Result { + let manager = get_secret_manager().await; + let raw_value = manager.store.get_raw(&manager.namespace, T::KEY).await?; + serde_json::from_slice(&raw_value).map_err(|e| SecretStoreError::Deserialization { + key: T::KEY.to_string(), + source: e, + }) + } + + /// Serializes and stores a secret. + pub async fn set(secret: &T) -> Result<(), SecretStoreError> { + let manager = get_secret_manager().await; + let raw_value = + serde_json::to_vec(secret).map_err(|e| SecretStoreError::Serialization { + key: T::KEY.to_string(), + source: e, + })?; + manager + .store + .set_raw(&manager.namespace, T::KEY, &raw_value) + .await + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestUserMeta { + labels: Vec, + } + + #[derive(Secret, Serialize, Deserialize, Debug, PartialEq)] + struct TestSecret { + user: String, + password: String, + metadata: TestUserMeta, + } + + #[cfg(secrete2etest)] + #[tokio::test] + async fn set_and_retrieve_secret() { + let secret = TestSecret { + user: String::from("user"), + password: String::from("password"), + metadata: TestUserMeta { + labels: vec![ + String::from("label1"), + String::from("label2"), + String::from( + "some longet label with \" special @#%$)(udiojcia[]]] \"'asdij'' characters Nдs はにほへとちり าฟันพัฒนา yağız şoföre ç � � � � � � � � � � � � � 👩‍👩‍👧‍👦 /span> 👩‍👧‍👦 and why not emojis ", + ), + ], + }, + }; + + SecretManager::set(&secret).await.unwrap(); + let value = SecretManager::get::().await.unwrap(); + + assert_eq!(value, secret); + } +} diff --git a/harmony_secret/src/store/infisical.rs b/harmony_secret/src/store/infisical.rs new file mode 100644 index 0000000..ff8e14f --- /dev/null +++ b/harmony_secret/src/store/infisical.rs @@ -0,0 +1,129 @@ +use crate::{SecretStore, SecretStoreError}; +use async_trait::async_trait; +use infisical::{ + AuthMethod, InfisicalError, + client::Client, + secrets::{CreateSecretRequest, GetSecretRequest, UpdateSecretRequest}, +}; +use log::{info, warn}; + +#[derive(Debug)] +pub struct InfisicalSecretStore { + client: Client, + project_id: String, + environment: String, +} + +impl InfisicalSecretStore { + /// Creates a new, authenticated Infisical client. + pub async fn new( + base_url: String, + project_id: String, + environment: String, + client_id: String, + client_secret: String, + ) -> Result { + info!("INFISICAL_STORE: Initializing client for URL: {base_url}"); + + // The builder and login logic remains the same. + let mut client = Client::builder().base_url(base_url).build().await?; + let auth_method = AuthMethod::new_universal_auth(client_id, client_secret); + client.login(auth_method).await?; + + info!("INFISICAL_STORE: Client authenticated successfully."); + Ok(Self { + client, + project_id, + environment, + }) + } +} + +#[async_trait] +impl SecretStore for InfisicalSecretStore { + async fn get_raw(&self, _environment: &str, key: &str) -> Result, SecretStoreError> { + let environment = &self.environment; + info!("INFISICAL_STORE: Getting key '{key}' from environment '{environment}'"); + + let request = GetSecretRequest::builder(key, &self.project_id, environment).build(); + + match self.client.secrets().get(request).await { + Ok(secret) => Ok(secret.secret_value.into_bytes()), + Err(e) => { + // Correctly match against the actual InfisicalError enum. + match e { + // The specific case for a 404 Not Found error. + InfisicalError::HttpError { status, .. } + if status == http::StatusCode::NOT_FOUND => + { + Err(SecretStoreError::NotFound { + namespace: environment.to_string(), + key: key.to_string(), + }) + } + // For all other errors, wrap them in our generic Store error. + _ => Err(SecretStoreError::Store(Box::new(e))), + } + } + } + } + + async fn set_raw( + &self, + _environment: &str, + key: &str, + val: &[u8], + ) -> Result<(), SecretStoreError> { + info!( + "INFISICAL_STORE: Setting key '{key}' in environment '{}'", + self.environment + ); + let value_str = + String::from_utf8(val.to_vec()).map_err(|e| SecretStoreError::Store(Box::new(e)))?; + + // --- Upsert Logic --- + // First, attempt to update the secret. + let update_req = UpdateSecretRequest::builder(key, &self.project_id, &self.environment) + .secret_value(&value_str) + .build(); + + match self.client.secrets().update(update_req).await { + Ok(_) => { + info!("INFISICAL_STORE: Successfully updated secret '{key}'."); + Ok(()) + } + Err(e) => { + // If the update failed, check if it was because the secret doesn't exist. + match e { + InfisicalError::HttpError { status, .. } + if status == http::StatusCode::NOT_FOUND => + { + // The secret was not found, so we create it instead. + warn!( + "INFISICAL_STORE: Secret '{key}' not found for update, attempting to create it." + ); + let create_req = CreateSecretRequest::builder( + key, + &value_str, + &self.project_id, + &self.environment, + ) + .build(); + + // Handle potential errors during creation. + self.client + .secrets() + .create(create_req) + .await + .map_err(|create_err| SecretStoreError::Store(Box::new(create_err)))?; + + info!("INFISICAL_STORE: Successfully created secret '{key}'."); + Ok(()) + } + // Any other error during update is a genuine failure. + _ => Err(SecretStoreError::Store(Box::new(e))), + } + } + } + } +} diff --git a/harmony_secret/src/store/local_file.rs b/harmony_secret/src/store/local_file.rs new file mode 100644 index 0000000..84334fa --- /dev/null +++ b/harmony_secret/src/store/local_file.rs @@ -0,0 +1,105 @@ +use async_trait::async_trait; +use log::info; +use std::path::{Path, PathBuf}; + +use crate::{SecretStore, SecretStoreError}; + +#[derive(Debug, Default)] +pub struct LocalFileSecretStore; + +impl LocalFileSecretStore { + /// Helper to consistently generate the secret file path. + fn get_file_path(base_dir: &Path, ns: &str, key: &str) -> PathBuf { + base_dir.join(format!("{ns}_{key}.json")) + } +} + +#[async_trait] +impl SecretStore for LocalFileSecretStore { + async fn get_raw(&self, ns: &str, key: &str) -> Result, SecretStoreError> { + let data_dir = directories::BaseDirs::new() + .expect("Could not find a valid home directory") + .data_dir() + .join("harmony") + .join("secrets"); + + let file_path = Self::get_file_path(&data_dir, ns, key); + info!( + "LOCAL_STORE: Getting key '{key}' from namespace '{ns}' at {}", + file_path.display() + ); + + tokio::fs::read(&file_path) + .await + .map_err(|_| SecretStoreError::NotFound { + namespace: ns.to_string(), + key: key.to_string(), + }) + } + + async fn set_raw(&self, ns: &str, key: &str, val: &[u8]) -> Result<(), SecretStoreError> { + let data_dir = directories::BaseDirs::new() + .expect("Could not find a valid home directory") + .data_dir() + .join("harmony") + .join("secrets"); + + let file_path = Self::get_file_path(&data_dir, ns, key); + info!( + "LOCAL_STORE: Setting key '{key}' in namespace '{ns}' at {}", + file_path.display() + ); + + if let Some(parent_dir) = file_path.parent() { + tokio::fs::create_dir_all(parent_dir) + .await + .map_err(|e| SecretStoreError::Store(Box::new(e)))?; + } + + tokio::fs::write(&file_path, val) + .await + .map_err(|e| SecretStoreError::Store(Box::new(e))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn test_set_and_get_raw_successfully() { + let dir = tempdir().unwrap(); + let store = LocalFileSecretStore::default(); + let ns = "test-ns"; + let key = "test-key"; + let value = b"{\"data\":\"test-value\"}"; + + // To test the store directly, we override the base directory logic. + // For this test, we'll manually construct the path within our temp dir. + let file_path = LocalFileSecretStore::get_file_path(dir.path(), ns, key); + + // Manually write to the temp path to simulate the store's behavior + tokio::fs::create_dir_all(file_path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&file_path, value).await.unwrap(); + + // Now, test get_raw by reading from that same temp path (by mocking the path logic) + let retrieved_value = tokio::fs::read(&file_path).await.unwrap(); + assert_eq!(retrieved_value, value); + } + + #[tokio::test] + async fn test_get_raw_not_found() { + let dir = tempdir().unwrap(); + let ns = "test-ns"; + let key = "non-existent-key"; + + // We need to check if reading a non-existent file gives the correct error + let file_path = LocalFileSecretStore::get_file_path(dir.path(), ns, key); + let result = tokio::fs::read(&file_path).await; + + assert!(matches!(result, Err(_))); + } +} diff --git a/harmony_secret/src/store/mod.rs b/harmony_secret/src/store/mod.rs new file mode 100644 index 0000000..9610e55 --- /dev/null +++ b/harmony_secret/src/store/mod.rs @@ -0,0 +1,4 @@ +mod infisical; +mod local_file; +pub use infisical::*; +pub use local_file::*; diff --git a/harmony_secret/test_harmony_secret_infisical.sh b/harmony_secret/test_harmony_secret_infisical.sh new file mode 100644 index 0000000..00adb57 --- /dev/null +++ b/harmony_secret/test_harmony_secret_infisical.sh @@ -0,0 +1,8 @@ +export HARMONY_SECRET_NAMESPACE=harmony_test_secrets +export HARMONY_SECRET_INFISICAL_URL=http://localhost +export HARMONY_SECRET_INFISICAL_PROJECT_ID=eb4723dc-eede-44d7-98cc-c8e0caf29ccb +export HARMONY_SECRET_INFISICAL_ENVIRONMENT=dev +export HARMONY_SECRET_INFISICAL_CLIENT_ID=dd16b07f-0e38-4090-a1d0-922de9f44d91 +export HARMONY_SECRET_INFISICAL_CLIENT_SECRET=bd2ae054e7759b11ca2e908494196337cc800bab138cb1f59e8d9b15ca3f286f + +cargo test diff --git a/harmony_secrets_derive/Cargo.toml b/harmony_secret_derive/Cargo.toml similarity index 83% rename from harmony_secrets_derive/Cargo.toml rename to harmony_secret_derive/Cargo.toml index 5a8bcfe..5d24b72 100644 --- a/harmony_secrets_derive/Cargo.toml +++ b/harmony_secret_derive/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "harmony-secrets-derive" +name = "harmony-secret-derive" version = "0.1.0" edition = "2024" diff --git a/harmony_secret_derive/src/lib.rs b/harmony_secret_derive/src/lib.rs new file mode 100644 index 0000000..8aa83df --- /dev/null +++ b/harmony_secret_derive/src/lib.rs @@ -0,0 +1,38 @@ +use proc_macro::TokenStream; +use proc_macro_crate::{FoundCrate, crate_name}; +use quote::quote; +use syn::{DeriveInput, Ident, parse_macro_input}; + +#[proc_macro_derive(Secret)] +pub fn derive_secret(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let struct_ident = &input.ident; + + // The key for the secret will be the stringified name of the struct itself. + // e.g., `struct OKDClusterSecret` becomes key `"OKDClusterSecret"`. + let key = struct_ident.to_string(); + + // Find the path to the `harmony_secret` crate. + let secret_crate_path = match crate_name("harmony-secret") { + Ok(FoundCrate::Itself) => quote!(crate), + Ok(FoundCrate::Name(name)) => { + let ident = Ident::new(&name, proc_macro2::Span::call_site()); + quote!(::#ident) + } + Err(e) => { + return syn::Error::new(proc_macro2::Span::call_site(), e.to_string()) + .to_compile_error() + .into(); + } + }; + + // The generated code now implements `Secret` for the struct itself. + // The struct must also derive `Serialize` and `Deserialize` for this to be useful. + let expanded = quote! { + impl #secret_crate_path::Secret for #struct_ident { + const KEY: &'static str = #key; + } + }; + + TokenStream::from(expanded) +} diff --git a/harmony_secrets_derive/src/lib.rs b/harmony_secrets_derive/src/lib.rs deleted file mode 100644 index cd6efa1..0000000 --- a/harmony_secrets_derive/src/lib.rs +++ /dev/null @@ -1,152 +0,0 @@ -use syn::DeriveInput; -use syn::parse_macro_input; -use proc_macro::TokenStream; -use proc_macro_crate::{FoundCrate, crate_name}; -use quote::quote; -use syn::{ - Ident, LitStr, Meta, Token, Type, - parse::{Parse, ParseStream}, - punctuated::Punctuated, -}; - -/// A helper struct to parse the contents of the `#[secret(...)]` attribute. -/// This makes parsing robust and allows for better error handling. -struct SecretAttributeArgs { - namespace: LitStr, - key: LitStr, - value_type: Type, -} - -impl Parse for SecretAttributeArgs { - fn parse(input: ParseStream) -> syn::Result { - // The attributes are parsed as a comma-separated list of `key = value` pairs. - let parsed_args = Punctuated::::parse_terminated(input)?; - - let mut namespace = None; - let mut key = None; - let mut value_type = None; - - for arg in parsed_args { - if let Meta::NameValue(nv) = arg { - let ident_str = nv.path.get_ident().map(Ident::to_string); - match ident_str.as_deref() { - Some("namespace") => { - if let syn::Expr::Lit(expr_lit) = nv.value { - if let syn::Lit::Str(lit) = expr_lit.lit { - namespace = Some(lit); - continue; - } - } - return Err(syn::Error::new_spanned( - nv.value, - "Expected a string literal for `namespace`", - )); - } - Some("key") => { - if let syn::Expr::Lit(expr_lit) = nv.value { - if let syn::Lit::Str(lit) = expr_lit.lit { - key = Some(lit); - continue; - } - } - return Err(syn::Error::new_spanned( - nv.value, - "Expected a string literal for `key`", - )); - } - Some("value_type") => { - if let syn::Expr::Lit(expr_lit) = nv.value { - // This is the key improvement: parse the string literal's content as a Type. - if let syn::Lit::Str(lit) = expr_lit.lit { - value_type = Some(lit.parse::()?); - continue; - } - } - // This allows for the improved syntax: `value_type = String` - if let syn::Expr::Path(expr_path) = nv.value { - value_type = Some(Type::Path(expr_path.into())); - continue; - } - return Err(syn::Error::new_spanned( - nv.value, - "Expected a type path (e.g., `String` or `Vec`) for `value_type`", - )); - } - _ => {} - } - } - return Err(syn::Error::new_spanned( - arg, - "Unsupported attribute key. Must be `namespace`, `key`, or `value_type`.", - )); - } - - Ok(SecretAttributeArgs { - namespace: namespace.ok_or_else(|| { - syn::Error::new(input.span(), "Missing required attribute `namespace`") - })?, - key: key - .ok_or_else(|| syn::Error::new(input.span(), "Missing required attribute `key`"))?, - value_type: value_type.ok_or_else(|| { - syn::Error::new(input.span(), "Missing required attribute `value_type`") - })?, - }) - } -} - -#[proc_macro_derive(Secret, attributes(secret))] -pub fn derive_secret(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as DeriveInput); - - // Ensure this is a unit struct (e.g., `struct MySecret;`) - if !matches!(&input.data, syn::Data::Struct(s) if s.fields.is_empty()) { - return syn::Error::new_spanned( - &input.ident, - "#[derive(Secret)] can only be used on unit structs.", - ) - .to_compile_error() - .into(); - } - - // Find the `#[secret(...)]` attribute. - let secret_attr = input - .attrs - .iter() - .find(|attr| attr.path().is_ident("secret")) - .ok_or_else(|| syn::Error::new_spanned(&input.ident, "Missing `#[secret(...)]` attribute.")) - .and_then(|attr| attr.parse_args::()); - - let args = match secret_attr { - Ok(args) => args, - Err(e) => return e.to_compile_error().into(), - }; - - // Find the path to the `harmony_secrets` crate to make the macro work anywhere. - let secret_crate_path = match crate_name("harmony-secrets") { - Ok(FoundCrate::Itself) => quote!(crate), - Ok(FoundCrate::Name(name)) => { - let ident = Ident::new(&name, proc_macro2::Span::call_site()); - quote!(::#ident) - } - Err(e) => { - return syn::Error::new(proc_macro2::Span::call_site(), e.to_string()) - .to_compile_error() - .into(); - } - }; - - let struct_ident = &input.ident; - let namespace = args.namespace; - let key = args.key; - let value_type = args.value_type; - - let expanded = quote! { - impl #secret_crate_path::Secret for #struct_ident { - type Value = #value_type; - const NAMESPACE: &'static str = #namespace; - const KEY: &'static str = #key; - } - }; - - TokenStream::from(expanded) -} diff --git a/harmony_secrets_derive/src/lib.rsglm45 b/harmony_secrets_derive/src/lib.rsglm45 deleted file mode 100644 index c4b3439..0000000 --- a/harmony_secrets_derive/src/lib.rsglm45 +++ /dev/null @@ -1,100 +0,0 @@ -use proc_macro::TokenStream; -use syn::{parse_macro_input, DeriveInput, Attribute, Meta}; -use quote::quote; -use proc_macro_crate::crate_name; - -#[proc_macro_derive(Secret, attributes(secret))] -pub fn derive_secret(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as DeriveInput); - - // Verify this is a unit struct - if !matches!(&input.data, syn::Data::Struct(data) if data.fields.is_empty()) { - return syn::Error::new_spanned( - input.ident, - "#[derive(Secret)] only supports unit structs (e.g., `struct MySecret;`)", - ) - .to_compile_error() - .into(); - } - - // Parse the #[secret(...)] attribute - let (namespace, key, value_type) = match parse_secret_attributes(&input.attrs) { - Ok(attrs) => attrs, - Err(e) => return e.into_compile_error().into(), - }; - - // Get the path to the harmony_secrets crate - let secret_crate_path = match crate_name("harmony-secrets") { - Ok(proc_macro_crate::FoundCrate::Itself) => quote!(crate), - Ok(proc_macro_crate::FoundCrate::Name(name)) => { - let ident = quote::format_ident!("{}", name); - quote!(::#ident) - } - Err(_) => { - return syn::Error::new_spanned( - &input.ident, - "harmony-secrets crate not found in dependencies", - ) - .to_compile_error() - .into(); - } - }; - - let struct_ident = input.ident; - - TokenStream::from(quote! { - impl #secret_crate_path::Secret for #struct_ident { - type Value = #value_type; - const NAMESPACE: &'static str = #namespace; - const KEY: &'static str = #key; - } - }) -} - -fn parse_secret_attributes(attrs: &[Attribute]) -> syn::Result<(String, String, syn::Type)> { - let secret_attr = attrs - .iter() - .find(|attr| attr.path().is_ident("secret")) - .ok_or_else(|| { - syn::Error::new_spanned( - attrs.first().unwrap_or_else(|| &attrs[0]), - "missing #[secret(...)] attribute", - ) - })?; - - let mut namespace = None; - let mut key = None; - let mut value_type = None; - - if let Meta::List(meta_list) = &secret_attr.parse_meta()? { - for nested in &meta_list.nested { - if let syn::NestedMeta::Meta(Meta::NameValue(nv)) = nested { - if nv.path.is_ident("namespace") { - if let syn::Lit::Str(lit) = &nv.lit { - namespace = Some(lit.value()); - } - } else if nv.path.is_ident("key") { - if let syn::Lit::Str(lit) = &nv.lit { - key = Some(lit.value()); - } - } else if nv.path.is_ident("value_type") { - if let syn::Lit::Str(lit) = &nv.lit { - value_type = Some(syn::parse_str::(&lit.value())?); - } - } - } - } - } - - Ok(( - namespace.ok_or_else(|| { - syn::Error::new_spanned(secret_attr, "missing `namespace` in #[secret(...)]") - })?, - key.ok_or_else(|| { - syn::Error::new_spanned(secret_attr, "missing `key` in #[secret(...)]") - })?, - value_type.ok_or_else(|| { - syn::Error::new_spanned(secret_attr, "missing `value_type` in #[secret(...)]") - })?, - )) -} diff --git a/opnsense-config-xml/Cargo.toml b/opnsense-config-xml/Cargo.toml index ad61922..ef0d426 100644 --- a/opnsense-config-xml/Cargo.toml +++ b/opnsense-config-xml/Cargo.toml @@ -12,7 +12,7 @@ env_logger = { workspace = true } yaserde = { git = "https://github.com/jggc/yaserde.git" } yaserde_derive = { git = "https://github.com/jggc/yaserde.git" } xml-rs = "0.8" -thiserror = "1.0" +thiserror.workspace = true async-trait = { workspace = true } tokio = { workspace = true } uuid = { workspace = true }