From 169dcd98110ae926f3a64426c28ce04133754313 Mon Sep 17 00:00:00 2001 From: C0ffeeCode Date: Fri, 2 May 2025 16:39:15 +0200 Subject: [PATCH] WIP --- migrations/20240501152243_KvSecret.sql | 13 ++-- src/common.rs | 88 +++++++++++++++++++++++++- src/engines/kv.rs | 63 ++++++++++++++---- src/engines/kv/data.rs | 36 +++++++++++ 4 files changed, 181 insertions(+), 19 deletions(-) diff --git a/migrations/20240501152243_KvSecret.sql b/migrations/20240501152243_KvSecret.sql index 60e8bc6..c01e5a4 100644 --- a/migrations/20240501152243_KvSecret.sql +++ b/migrations/20240501152243_KvSecret.sql @@ -1,15 +1,20 @@ -- Add migration script here +CREATE TABLE kv2_engine_cfg ( + engine_path TEXT PRIMARY KEY REFERENCES secret_engines (mount_point), + max_versions UNSIGNED INTEGER CHECK ( max_versions > 0 ), -- Shall be proper NULL if 0 + max_age_secs UNSIGNED INTEGER CHECK ( max_versions > 0 ), -- Shall be proper NULL if 0 + cas_required BOOLEAN NOT NULL DEFAULT (FALSE) +); + CREATE TABLE kv2_metadata ( - engine_path TEXT NOT NULL, + engine_path TEXT NOT NULL REFERENCES secret_engines (mount_point), secret_path TEXT NOT NULL, cas_required INTEGER NOT NULL, -- no bool datatype in sqlite created_time TIMESTAMP NOT NULL, - delete_version_after TEXT, -- Maybe NOT NULL + delete_version_after TEXT, -- May be NULL max_versions INTEGER NOT NULL, - -- current_version INTEGER NOT NULL, - -- oldest_version INTEGER NOT NULL, updated_time TIMESTAMP NOT NULL, custom_data TEXT, diff --git a/src/common.rs b/src/common.rs index 928ad2c..5ae52cd 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1,8 +1,8 @@ use axum::{ + Json, body::Body, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use serde::Serialize; @@ -29,6 +29,90 @@ where { match value { Some(data) => serializer.serialize_str(data), - None => Err(serde::ser::Error::custom("`secret_data` must not be None during serialization!")), + None => Err(serde::ser::Error::custom( + "`secret_data` must not be None during serialization!", + )), + } +} + +/// Parses duration strings to seconds. +/// Returns `None` on error or empty string. +/// Example: `4h3m1s` +pub fn parse_duration_str(input: &String) -> Option { + if input.is_empty() { + return None; + } + input + .split_inclusive(char::is_alphabetic) + .try_fold(0u32, |acc, chunk| { + let (value, unit) = chunk.split_at(chunk.len() - 1); + let value = value.parse::().ok()?; + match unit { + "h" => acc.checked_add(value.checked_mul(3600)?), + "m" => acc.checked_add(value.checked_mul(60)?), + "s" => acc.checked_add(value), + _ => None, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_duration_str_valid_inputs() { + let cases = vec![ + ("0s", 0), + ("4h", 14400), + ("3m", 180), + ("1s", 1), + ("4h3m1s", 14581), + ("2h30m", 9000), + ("2h30s", 7230), + ("2m30s", 150), + ]; + + for (str, res) in cases { + assert_eq!(parse_duration_str(&str.to_string()), Some(res)) + } + } + + #[test] + fn test_parse_duration_str_invalid_inputs() { + let cases = vec!["", "-5s", "4x", "4h3x1s", "4h-3m1s", "4h3m1"]; + + for str in cases { + assert_eq!(parse_duration_str(&str.to_string()), None); + } + } + + #[test] + fn test_parse_duration_str_edge_cases() { + let cases = vec![ + ("0h0m0s", Some(0)), + ("0h", Some(0)), + ("0m", Some(0)), + ("0s", Some(0)), + ("1h0m0s", Some(3600)), + ("1m1000000s", Some(60 + 1000000)), + ]; + + for (str, res) in cases { + assert_eq!(parse_duration_str(&str.to_string()), res) + } + } + + #[test] + fn test_parse_duration_str_overflow() { + let cases = vec![ + "100000000h".to_string(), // + "100000000m".to_string(), // + format!("{}s", u32::MAX as u64 + 1), + ]; + + for str in cases { + assert_eq!(parse_duration_str(&str), None, "Failed for {str}"); + } } } diff --git a/src/engines/kv.rs b/src/engines/kv.rs index 2d2281e..d8899a7 100644 --- a/src/engines/kv.rs +++ b/src/engines/kv.rs @@ -1,15 +1,15 @@ -mod structs; mod data; mod meta; +mod structs; // #[cfg(test)] // mod tests; -use crate::storage::DbPool; -use axum::{ - Router, - routing::*, -}; +use crate::{common::parse_duration_str, storage::DbPool}; +use axum::{Extension, Json, Router, extract::State, response::NoContent, routing::*}; +use serde::{Deserialize, Serialize}; + +use super::EnginePath; pub fn kv_router(pool: DbPool) -> Router { Router::new() @@ -28,7 +28,7 @@ pub fn kv_router(pool: DbPool) -> Router { .route("/metadata/{*path}", post(meta::post_meta)) .route("/metadata/{*path}", delete(meta::delete_meta)) .route("/subkeys/{*path}", get(get_subkeys)) - .route("/undelete/{*path}", post(post_undelete)) + // .route("/undelete/{*path}", post(data::post_undelete)) // TODO .with_state(pool) } @@ -36,14 +36,51 @@ async fn get_config() -> &'static str { todo!("not implemented") } -async fn post_config() -> &'static str { - todo!("not implemented") +#[derive(Debug, Deserialize, Serialize)] +struct Config { + /// A value `0` shall be be treated as 10 + /// TODO: Not implemented + pub max_versions: Option, + #[serde(default)] + // TODO: Not implemented + pub cas_required: bool, + /// Max age of a secret version + /// Example: `"3h25m19s"` + /// `0s` disable automatic deletion + /// TODO: Not implemented + pub delete_version_after: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ConfigRes { + pub data: Config, +} + +async fn post_config( + State(pool): State, + Extension(EnginePath(engine_path)): Extension, + Json(config): Json, +) -> NoContent { + let max_age_secs: Option = config + .delete_version_after + .map(|v| parse_duration_str(&v).expect("Failed to parse duration string")); + + // TODO: This + let a = sqlx::query!( + "UPDATE kv2_engine_cfg SET (max_versions, max_age_secs, cas_required) = ($2, $3, $4) + WHERE engine_path = $1", + engine_path, + config.max_versions, + max_age_secs, + config.cas_required + ) + .execute(&pool) + .await + .unwrap(); + + NoContent } async fn get_subkeys() -> &'static str { todo!("not implemented") } - -async fn post_undelete() -> &'static str { - todo!("not implemented") -} diff --git a/src/engines/kv/data.rs b/src/engines/kv/data.rs index 0e1c0c5..5d23b9f 100644 --- a/src/engines/kv/data.rs +++ b/src/engines/kv/data.rs @@ -229,6 +229,42 @@ pub async fn delete_data( Ok(NoContent.into_response()) } +pub struct UndeleteReq { + versions: Vec, +} + +pub async fn post_undelete( + State(pool): State, + Path(path): Path, + Extension(EnginePath(engine_path)): Extension, + Json(UndeleteReq { versions }): Json, +) -> Response { + info!("Undeleting versions {versions:?} of {path} from {engine_path}"); + + let mut tx = pool.begin().await.unwrap(); + + for version in versions { + sqlx::query!( + r#" + UPDATE kv2_secret_version + SET deletion_time = NULL + WHERE engine_path = $1 AND secret_path = $2 + AND version_number = $3 + "#, + engine_path, + path, + version, + ) + .execute(&mut *tx) + .await; + } + + tx.commit().await.unwrap(); + + NoContent.into_response() +} + + pub async fn patch_data( State(pool): State, Path(kv_path): Path,