From 52d82d6c4e193fe7f85d3ba4aa7332c4e0b1676e Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Thu, 24 Sep 2026 04:25:16 -0700 Subject: [PATCH 01/12] [agent] fix: authorize and commit secret mutations atomically Co-Authored-By: GPT-6 (OpenAI) --- src/helpers/caps.rs | 82 +++------ src/helpers/mod.rs | 1 + src/helpers/variables.rs | 232 +++++++++++++++++++++++++ src/main.rs | 7 + src/routes/v2/invite/accept.rs | 131 +++++++++++++- src/routes/v2/variables/update_many.rs | 107 +++++------- src/routes/variables.rs | 160 +++++++++-------- src/test_support.rs | 37 ++++ 8 files changed, 549 insertions(+), 208 deletions(-) create mode 100644 src/helpers/variables.rs create mode 100644 src/test_support.rs diff --git a/src/helpers/caps.rs b/src/helpers/caps.rs index 807c806..09dc6c9 100644 --- a/src/helpers/caps.rs +++ b/src/helpers/caps.rs @@ -4,7 +4,6 @@ //! the absolute upper bound the user can sustain is bounded by these //! checks even if a single batch slips through. -use std::collections::HashMap; use std::net::IpAddr; use axum::http::StatusCode; @@ -13,8 +12,7 @@ use sqlx::types::Uuid; use crate::config::Caps; use crate::error::AppError; -use crate::state::{AppState, DB}; -use crate::traits::to_uuid::ToUuid; +use crate::state::DB; use crate::Context as _; fn too_big(msg: String) -> AppError { @@ -94,9 +92,9 @@ pub async fn check_project_for_insert( /// Reject an UPDATE that would push the project past the byte cap. /// (Count does not change on update.) -pub async fn check_project_for_update( +pub async fn check_project_for_update_on( caps: &Caps, - db: &DB, + connection: &mut sqlx::PgConnection, project_id: Uuid, update_ids: &[Uuid], new_values: &[&str], @@ -115,7 +113,7 @@ pub async fn check_project_for_update( project_id, update_ids ) - .fetch_one(db.as_ref()) + .fetch_one(&mut *connection) .await .context("Failed to fetch project size")?; @@ -140,6 +138,16 @@ pub async fn check_user_total( db: &DB, user_id: Uuid, delta_bytes: i64, +) -> Result<(), AppError> { + let mut connection = db.acquire().await?; + check_user_total_on(caps, &mut connection, user_id, delta_bytes).await +} + +pub async fn check_user_total_on( + caps: &Caps, + connection: &mut sqlx::PgConnection, + user_id: Uuid, + delta_bytes: i64, ) -> Result<(), AppError> { let cap = caps.max_user_bytes; if cap == 0 { @@ -153,7 +161,7 @@ pub async fn check_user_total( WHERE upr.user_id = $1"#, user_id ) - .fetch_one(db.as_ref()) + .fetch_one(&mut *connection) .await .context("Failed to fetch user total")?; @@ -175,6 +183,16 @@ pub async fn check_and_record_ip( db: &DB, ip: IpAddr, bytes: i64, +) -> Result<(), AppError> { + let mut connection = db.acquire().await?; + check_and_record_ip_on(caps, &mut connection, ip, bytes).await +} + +pub async fn check_and_record_ip_on( + caps: &Caps, + connection: &mut sqlx::PgConnection, + ip: IpAddr, + bytes: i64, ) -> Result<(), AppError> { let cap = caps.max_ip_bytes_per_day; if cap == 0 { @@ -185,7 +203,7 @@ pub async fn check_and_record_ip( // Prune old rows opportunistically. Cheap when the index is hot. sqlx::query!("DELETE FROM upload_log WHERE created_at < now() - interval '24 hours'") - .execute(db.as_ref()) + .execute(&mut *connection) .await .context("Failed to prune upload_log")?; @@ -195,7 +213,7 @@ pub async fn check_and_record_ip( WHERE ip = $1 AND created_at > now() - interval '24 hours'"#, net ) - .fetch_one(db.as_ref()) + .fetch_one(&mut *connection) .await .context("Failed to fetch ip upload total")?; @@ -212,53 +230,9 @@ pub async fn check_and_record_ip( net, bytes ) - .execute(db.as_ref()) + .execute(&mut *connection) .await .context("Failed to record upload_log entry")?; Ok(()) } - -/// Cross-project update-many helper: groups the incoming updates by -/// project, runs the per-project byte check on each, then runs the -/// per-user and per-IP checks on the aggregate delta. `triples` is -/// `(project_id_str, variable_id_str, new_value)`. -pub async fn check_update_caps_grouped( - state: &AppState, - user_id: Uuid, - ip: IpAddr, - triples: Vec<(&str, &str, &str)>, -) -> Result<(), AppError> { - let mut by_project: HashMap, Vec<&str>)> = HashMap::new(); - let mut total_new_bytes: i64 = 0; - - for (pid_str, vid_str, value) in &triples { - let pid = pid_str.to_string().to_uuid()?; - let vid = vid_str.to_string().to_uuid()?; - let entry = by_project.entry(pid).or_default(); - entry.0.push(vid); - entry.1.push(*value); - total_new_bytes += value.len() as i64; - } - - let mut total_replaced_bytes: i64 = 0; - for (pid, (ids, values)) in &by_project { - check_project_for_update(&state.caps, &state.db, *pid, ids, values).await?; - - let row = sqlx::query!( - r#"SELECT COALESCE(sum(octet_length(value))::bigint, 0) AS "bytes!" - FROM variables WHERE id = ANY($1::uuid[])"#, - ids - ) - .fetch_one(state.db.as_ref()) - .await - .context("Failed to fetch replaced byte count")?; - total_replaced_bytes += row.bytes; - } - - let delta = total_new_bytes - total_replaced_bytes; - check_user_total(&state.caps, &state.db, user_id, delta).await?; - check_and_record_ip(&state.caps, &state.db, ip, delta.max(0)).await?; - - Ok(()) -} diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index 7257150..f08e1b6 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -1,2 +1,3 @@ pub mod caps; pub mod project; +pub mod variables; diff --git a/src/helpers/variables.rs b/src/helpers/variables.rs new file mode 100644 index 0000000..ff3693d --- /dev/null +++ b/src/helpers/variables.rs @@ -0,0 +1,232 @@ +use super::caps; +use crate::{error::Errors, structs::Variable, AppError, AppState}; +use axum::http::StatusCode; +use std::{collections::HashSet, net::IpAddr}; +use uuid::Uuid; + +pub async fn update_many( + state: &AppState, + user_id: Uuid, + ip: IpAddr, + variables: Vec, +) -> Result, AppError> { + let mut seen = HashSet::new(); + let mut parsed = Vec::new(); + for variable in &variables { + let id = Uuid::parse_str(&variable.id).map_err(|_| { + AppError::Generic(StatusCode::BAD_REQUEST, "Invalid variable ID".into()) + })?; + let project = Uuid::parse_str(&variable.project_id) + .map_err(|_| AppError::Generic(StatusCode::BAD_REQUEST, "Invalid project ID".into()))?; + if !seen.insert(id) { + return Err(AppError::Generic( + StatusCode::BAD_REQUEST, + "Duplicate variable ID".into(), + )); + } + parsed.push((id, project)); + } + // Stable lock order prevents opposing batches from deadlocking. + parsed.sort_unstable(); + let mut tx = state.db.begin().await?; + for (id, project) in &parsed { + let authorized: Option = sqlx::query_scalar("SELECT v.id FROM variables v JOIN user_project_relations upr ON upr.project_id=v.project_id WHERE v.id=$1 AND v.project_id=$2 AND upr.user_id=$3 FOR UPDATE OF v FOR SHARE OF upr") + .bind(id).bind(project).bind(user_id).fetch_optional(&mut *tx).await?; + if authorized.is_none() { + return Err(Errors::Unauthorized.into()); + } + } + caps::check_per_value( + &state.caps, + &variables + .iter() + .map(|v| v.value.as_str()) + .collect::>(), + )?; + let mut grouped = std::collections::HashMap::, Vec<&str>)>::new(); + for variable in &variables { + let group = grouped + .entry(Uuid::parse_str(&variable.project_id)?) + .or_default(); + group.0.push(Uuid::parse_str(&variable.id)?); + group.1.push(&variable.value); + } + let mut delta = 0; + for (project, (ids, values)) in grouped { + caps::check_project_for_update_on(&state.caps, &mut tx, project, &ids, &values).await?; + let old: i64 = sqlx::query_scalar("SELECT COALESCE(sum(octet_length(value)),0)::bigint FROM variables WHERE project_id=$1 AND id=ANY($2)") + .bind(project).bind(ids).fetch_one(&mut *tx).await?; + delta += values.iter().map(|v| v.len() as i64).sum::() - old; + } + caps::check_user_total_on(&state.caps, &mut tx, user_id, delta).await?; + caps::check_and_record_ip_on(&state.caps, &mut tx, ip, delta.max(0)).await?; + let mut ids = Vec::new(); + for variable in variables { + let id = Uuid::parse_str(&variable.id)?; + sqlx::query("UPDATE variables SET value=$1 WHERE id=$2") + .bind(variable.value) + .bind(id) + .execute(&mut *tx) + .await?; + ids.push(id.to_string()); + } + tx.commit().await?; + Ok(ids) +} + +pub async fn replace_many( + state: &AppState, + user_id: Uuid, + ip: IpAddr, + project: Uuid, + values: Vec, + replace_ids: Vec, +) -> Result, AppError> { + let bad = |message: &str| AppError::Generic(StatusCode::BAD_REQUEST, message.into()); + if replace_ids.iter().collect::>().len() != replace_ids.len() { + return Err(bad("Duplicate replacement ID")); + } + let refs = values.iter().map(String::as_str).collect::>(); + caps::check_per_value(&state.caps, &refs)?; + let mut tx = state.db.begin().await?; + // Serialize replacements in a project and hold membership through commit. + let authorized: Option = sqlx::query_scalar("SELECT p.id FROM projects p JOIN user_project_relations upr ON upr.project_id=p.id WHERE p.id=$1 AND upr.user_id=$2 FOR UPDATE OF p FOR SHARE OF upr") + .bind(project).bind(user_id).fetch_optional(&mut *tx).await?; + if authorized.is_none() { + return Err(Errors::Unauthorized.into()); + } + let removed: Vec = sqlx::query_scalar( + "DELETE FROM variables WHERE project_id=$1 AND id=ANY($2) RETURNING value", + ) + .bind(project) + .bind(&replace_ids) + .fetch_all(&mut *tx) + .await?; + if removed.len() != replace_ids.len() { + return Err(bad( + "Replacement variables changed or do not belong to this project", + )); + } + let (count, bytes): (i64,i64) = sqlx::query_as("SELECT count(*), COALESCE(sum(octet_length(value)),0)::bigint FROM variables WHERE project_id=$1") + .bind(project).fetch_one(&mut *tx).await?; + let added: i64 = values.iter().map(|v| v.len() as i64).sum(); + if state.caps.max_variables_per_project > 0 + && count + values.len() as i64 > state.caps.max_variables_per_project + { + return Err(bad("Project variable cap exceeded")); + } + if state.caps.max_project_bytes > 0 && bytes + added > state.caps.max_project_bytes { + return Err(bad("Project size cap exceeded")); + } + caps::check_user_total_on(&state.caps, &mut tx, user_id, added).await?; + caps::check_and_record_ip_on(&state.caps, &mut tx, ip, added).await?; + let ids: Vec = sqlx::query_scalar("INSERT INTO variables(id,project_id,value) SELECT gen_random_uuid(),$1,value FROM UNNEST($2::text[]) AS t(value) RETURNING id") + .bind(project).bind(&values).fetch_all(&mut *tx).await?; + tx.commit().await?; + Ok(ids) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::*; + #[sqlx::test] + async fn failed_insert_rolls_back_deleted_values(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let project = project(&pool, owner).await; + let id: Uuid=sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES (gen_random_uuid(),$1,'original') RETURNING id").bind(project).fetch_one(&pool).await.unwrap(); + sqlx::query( + "ALTER TABLE variables ADD CONSTRAINT reject_test_value CHECK (value <> 'reject-me')", + ) + .execute(&pool) + .await + .unwrap(); + assert!(replace_many( + &state(pool.clone()), + owner, + "127.0.0.1".parse().unwrap(), + project, + vec!["reject-me".into()], + vec![id] + ) + .await + .is_err()); + let value: String = sqlx::query_scalar("SELECT value FROM variables WHERE id=$1") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(value, "original"); + } + #[sqlx::test] + async fn foreign_replacement_rolls_back_entire_batch(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let victim = user(&pool).await; + let own = project(&pool, owner).await; + let other = project(&pool, victim).await; + let a: Uuid=sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES (gen_random_uuid(),$1,'original') RETURNING id").bind(own).fetch_one(&pool).await.unwrap(); + let b: Uuid=sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES (gen_random_uuid(),$1,'victim') RETURNING id").bind(other).fetch_one(&pool).await.unwrap(); + assert!(replace_many( + &state(pool.clone()), + owner, + "127.0.0.1".parse().unwrap(), + own, + vec!["new".into()], + vec![a, b] + ) + .await + .is_err()); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM variables WHERE id=ANY($1)") + .bind(vec![a, b]) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 2); + } + #[sqlx::test] + async fn authorized_update_succeeds_but_mixed_batch_is_atomic(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let project = project(&pool, owner).await; + let id:Uuid=sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES (gen_random_uuid(),$1,'original') RETURNING id").bind(project).fetch_one(&pool).await.unwrap(); + let item = || Variable { + id: id.to_string(), + project_id: project.to_string(), + value: "updated".into(), + }; + assert!(update_many( + &state(pool.clone()), + owner, + "127.0.0.1".parse().unwrap(), + vec![ + item(), + Variable { + id: Uuid::new_v4().to_string(), + project_id: project.to_string(), + value: "missing".into() + } + ] + ) + .await + .is_err()); + let value: String = sqlx::query_scalar("SELECT value FROM variables WHERE id=$1") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(value, "original"); + assert!(update_many( + &state(pool.clone()), + owner, + "127.0.0.1".parse().unwrap(), + vec![item()] + ) + .await + .is_ok()); + let value: String = sqlx::query_scalar("SELECT value FROM variables WHERE id=$1") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(value, "updated"); + } +} diff --git a/src/main.rs b/src/main.rs index b524b81..37089b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -109,6 +109,10 @@ async fn init_router() -> anyhow::Result { delete(variables::delete_variable), ) .route("/variables/set-many", post(variables::set_many_variables)) + .route( + "/variables/replace-many", + post(variables::set_many_variables), + ) .route( "/variables/set-many/v2", post(variables::set_many_variables_v2), @@ -150,3 +154,6 @@ async fn init_router() -> anyhow::Result { Ok(router) } + +#[cfg(test)] +mod test_support; diff --git a/src/routes/v2/invite/accept.rs b/src/routes/v2/invite/accept.rs index 4a3bb89..da91650 100644 --- a/src/routes/v2/invite/accept.rs +++ b/src/routes/v2/invite/accept.rs @@ -64,6 +64,18 @@ pub async fn accept_invite( )); } + let mut tx = state.db.begin().await?; + // An invitation cannot outlive its author's authority to grant access. + let author_membership: Option = sqlx::query_scalar( + "SELECT id FROM user_project_relations WHERE user_id=$1 AND project_id=$2 FOR SHARE", + ) + .bind(invite.author_id) + .bind(invite.project_id) + .fetch_optional(&mut *tx) + .await?; + if author_membership.is_none() { + return Err(AppError::Error(Errors::Unauthorized)); + } let id = sqlx::query!( "UPDATE project_invites SET invited_id = $1, @@ -77,10 +89,14 @@ pub async fn accept_invite( user_id, body.code ) - .fetch_optional(&*state.db) + .fetch_optional(&mut *tx) .await .context("Failed to update invite")?; + if id.is_none() { + return Err(AppError::Error(Errors::Unauthorized)); + } + sqlx::query!( "INSERT INTO user_project_relations (user_id, project_id) VALUES ($1, $2) @@ -88,13 +104,11 @@ pub async fn accept_invite( &user_id, &invite.project_id, ) - .execute(&*state.db) + .execute(&mut *tx) .await .context("Failed to insert user project relations")?; - if id.is_none() { - return Err(AppError::Error(Errors::Unauthorized)); - } + tx.commit().await?; Ok(Json(AcceptInviteReturnType { ciphertext: invite.ciphertext.unwrap(), @@ -102,3 +116,110 @@ pub async fn accept_invite( project_id: invite.project_id.to_string(), })) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::*; + use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + PasswordHasher, + }; + #[sqlx::test] + async fn expired_invite_does_not_grant_membership(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + let verifier = Uuid::new_v4(); + let hash = Argon2::default() + .hash_password(verifier.as_bytes(), &SaltString::generate(&mut OsRng)) + .unwrap() + .to_string(); + let code=sqlx::query_scalar("INSERT INTO project_invites(project_id,author_id,expires_at,verifier_argon2id,ciphertext) VALUES ($1,$2,now()-interval '1 hour',$3,'secret') RETURNING id").bind(project).bind(owner).bind(hash).fetch_one(&pool).await.unwrap(); + assert!(accept_invite( + State(state(pool.clone())), + UserId(guest), + Json(AcceptInviteBody { code, verifier }) + ) + .await + .is_err()); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM user_project_relations WHERE user_id=$1 AND project_id=$2", + ) + .bind(guest) + .bind(project) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); + } + #[sqlx::test] + async fn removed_author_cannot_grant_membership(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + let verifier = Uuid::new_v4(); + let hash = Argon2::default() + .hash_password(verifier.as_bytes(), &SaltString::generate(&mut OsRng)) + .unwrap() + .to_string(); + let code=sqlx::query_scalar("INSERT INTO project_invites(project_id,author_id,expires_at,verifier_argon2id,ciphertext) VALUES ($1,$2,now()+interval '1 hour',$3,'secret') RETURNING id").bind(project).bind(owner).bind(hash).fetch_one(&pool).await.unwrap(); + sqlx::query("DELETE FROM user_project_relations WHERE user_id=$1 AND project_id=$2") + .bind(owner) + .bind(project) + .execute(&pool) + .await + .unwrap(); + assert!(accept_invite( + State(state(pool.clone())), + UserId(guest), + Json(AcceptInviteBody { code, verifier }) + ) + .await + .is_err()); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM user_project_relations WHERE user_id=$1 AND project_id=$2", + ) + .bind(guest) + .bind(project) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); + } + #[sqlx::test] + async fn concurrent_redemption_has_one_winner(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let a = user(&pool).await; + let b = user(&pool).await; + let project = project(&pool, owner).await; + let verifier = Uuid::new_v4(); + let hash = Argon2::default() + .hash_password(verifier.as_bytes(), &SaltString::generate(&mut OsRng)) + .unwrap() + .to_string(); + let code=sqlx::query_scalar("INSERT INTO project_invites(project_id,author_id,expires_at,verifier_argon2id,ciphertext) VALUES ($1,$2,now()+interval '1 hour',$3,'secret') RETURNING id").bind(project).bind(owner).bind(hash).fetch_one(&pool).await.unwrap(); + let (ra, rb) = tokio::join!( + accept_invite( + State(state(pool.clone())), + UserId(a), + Json(AcceptInviteBody { code, verifier }) + ), + accept_invite( + State(state(pool.clone())), + UserId(b), + Json(AcceptInviteBody { code, verifier }) + ) + ); + assert_eq!(usize::from(ra.is_ok()) + usize::from(rb.is_ok()), 1); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM user_project_relations WHERE project_id=$1 AND user_id<>$2", + ) + .bind(project) + .bind(owner) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); + } +} diff --git a/src/routes/v2/variables/update_many.rs b/src/routes/v2/variables/update_many.rs index 9dd1588..f71e8e7 100644 --- a/src/routes/v2/variables/update_many.rs +++ b/src/routes/v2/variables/update_many.rs @@ -1,9 +1,5 @@ -use std::collections::HashSet; - use crate::extractors::client_ip::ClientIp; -use crate::helpers::caps; -use crate::helpers::caps::check_update_caps_grouped; -use crate::{structs::Variable, traits::to_uuid::ToUuid}; +use crate::structs::Variable; use super::*; @@ -30,69 +26,44 @@ pub async fn update_many( ClientIp(ip): ClientIp, Json(body): Json, ) -> Result>, AppError> { - let project_ids = body - .variables - .iter() - .map(|v| v.project_id.as_str()) - .collect::>() - .iter() - .map(|s| s.to_string().to_uuid()) - .collect::, _>>()?; - - let projects = sqlx::query!( - "SELECT id FROM projects WHERE id = ANY($1::uuid[])", - &project_ids - ) - .fetch_all(&*state.db) - .await - .context("Failed to get projects")?; - - // make sure the user is in all the projects - for project in projects { - if !user_in_project(user_id, project.id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - } - - let all_values: Vec<&str> = body.variables.iter().map(|v| v.value.as_str()).collect(); - caps::check_per_value(&state.caps, &all_values)?; - check_update_caps_grouped( - &state, - user_id, - ip, - body.variables - .iter() - .map(|v| (v.project_id.as_str(), v.id.as_str(), v.value.as_str())) - .collect::>(), - ) - .await?; - - // use UNNEST to update all the variables at once - let variables = sqlx::query!( - "UPDATE variables AS v - SET value = u.value - FROM UNNEST($1::uuid[], $2::text[]) AS u(id, value) - WHERE v.id = u.id - RETURNING v.id", - &body - .variables - .iter() - .map(|v| v.id.to_uuid().unwrap()) - .collect::>(), - &body - .variables - .iter() - .map(|v| v.value.clone()) - .collect::>() - ) - .fetch_all(&*state.db) - .await - .context("Failed to update variables")?; - Ok(Json( - variables - .iter() - .map(|v| v.id.to_string()) - .collect::>(), + crate::helpers::variables::update_many(&state, user_id, ip, body.variables).await?, )) } + +#[cfg(test)] +mod authorization_tests { + use super::*; + use crate::test_support::*; + #[sqlx::test] + async fn mismatched_project_cannot_overwrite_variable(pool: sqlx::PgPool) { + let attacker = user(&pool).await; + let victim = user(&pool).await; + let own = project(&pool, attacker).await; + let other = project(&pool, victim).await; + let id: Uuid=sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES (gen_random_uuid(),$1,'original') RETURNING id").bind(other).fetch_one(&pool).await.unwrap(); + let result = update_many( + State(state(pool.clone())), + UserId(attacker), + ClientIp("127.0.0.1".parse().unwrap()), + Json(UpdateManyBody { + variables: vec![Variable { + id: id.to_string(), + project_id: own.to_string(), + value: "tampered".into(), + }], + }), + ) + .await; + assert!( + result.is_err(), + "must reject a variable belonging to another project" + ); + let value: String = sqlx::query_scalar("SELECT value FROM variables WHERE id=$1") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(value, "original"); + } +} diff --git a/src/routes/variables.rs b/src/routes/variables.rs index 202b70f..190bd52 100644 --- a/src/routes/variables.rs +++ b/src/routes/variables.rs @@ -1,8 +1,5 @@ -use std::collections::HashSet; - use crate::extractors::client_ip::ClientIp; use crate::helpers::caps; -use crate::helpers::caps::check_update_caps_grouped; use crate::structs::Variable; use crate::traits::to_uuid::ToUuid; use crate::*; @@ -60,6 +57,8 @@ pub async fn new_variable( pub struct SetManyBody { project_id: String, variables: Vec, + #[serde(default)] + replace_ids: Vec, } #[derive(Serialize, Deserialize)] @@ -73,35 +72,19 @@ pub async fn set_many_variables( ClientIp(ip): ClientIp, Json(body): Json, ) -> Result>, AppError> { - let project_id = body.project_id.to_uuid()?; - - if !user_in_project(user_id, project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - let values: Vec<&str> = body.variables.iter().map(String::as_str).collect(); - caps::check_per_value(&state.caps, &values)?; - caps::check_project_for_insert(&state.caps, &state.db, project_id, &values).await?; - let delta: i64 = values.iter().map(|v| v.len() as i64).sum(); - caps::check_user_total(&state.caps, &state.db, user_id, delta).await?; - caps::check_and_record_ip(&state.caps, &state.db, ip, delta).await?; - - let variables = sqlx::query!( - "INSERT INTO variables (value, project_id) SELECT * FROM UNNEST($1::text[], $2::uuid[]) RETURNING id", - &body.variables, - &vec![project_id; body.variables.len()] + let ids = crate::helpers::variables::replace_many( + &state, + user_id, + ip, + body.project_id.to_uuid()?, + body.variables, + body.replace_ids, ) - .fetch_all(&*state.db) - .await - .context("Failed to insert variables")?; - + .await?; Ok(Json( - variables - .iter() - .map(|v| SetManyReturnType { - id: v.id.to_string(), - }) - .collect::>(), + ids.into_iter() + .map(|id| SetManyReturnType { id: id.to_string() }) + .collect(), )) } @@ -137,58 +120,8 @@ pub async fn update_many_variables( ClientIp(ip): ClientIp, Json(body): Json, ) -> Result>, AppError> { - let projects = body - .variables - .iter() - .map(|v| v.project_id.as_str()) - .collect::>() - .iter() - .map(|s| s.to_string().to_uuid().unwrap()) - .collect::>(); - - let projects = sqlx::query!( - "SELECT id FROM projects WHERE id = ANY($1::uuid[])", - &projects - ) - .fetch_all(&*state.db) - .await - .context("Failed to get projects")?; - - // make sure the user is in all the projects - for project in projects { - if !user_in_project(user_id, project.id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - } - - let all_values: Vec<&str> = body.variables.iter().map(|v| v.value.as_str()).collect(); - caps::check_per_value(&state.caps, &all_values)?; - check_update_caps_grouped( - &state, - user_id, - ip, - body.variables - .iter() - .map(|v| (v.project_id.as_str(), v.id.as_str(), v.value.as_str())) - .collect::>(), - ) - .await?; - - // use UNNEST to update all the variables at once - let variables = sqlx::query!( - "UPDATE variables AS v SET value = u.value FROM UNNEST($1::uuid[], $2::text[]) AS u(id, value) WHERE v.id = u.id RETURNING v.id", - &body.variables.iter().map(|v| v.id.to_uuid().unwrap()).collect::>(), - &body.variables.iter().map(|v| v.value.clone()).collect::>() - ) - .fetch_all(&*state.db) - .await - .context("Failed to update variables")?; - Ok(Json( - variables - .iter() - .map(|v| v.id.to_string()) - .collect::>(), + crate::helpers::variables::update_many(&state, user_id, ip, body.variables).await?, )) } @@ -291,3 +224,68 @@ pub async fn set_many_variables_v2( // .collect::>(), // )) } + +#[cfg(test)] +mod authorization_tests { + use super::*; + use crate::test_support::*; + #[sqlx::test] + async fn mismatched_project_cannot_overwrite_variable(pool: sqlx::PgPool) { + let attacker = user(&pool).await; + let victim = user(&pool).await; + let own = project(&pool, attacker).await; + let other = project(&pool, victim).await; + let id: Uuid=sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES (gen_random_uuid(),$1,'original') RETURNING id").bind(other).fetch_one(&pool).await.unwrap(); + let result = update_many_variables( + State(state(pool.clone())), + UserId(attacker), + ClientIp("127.0.0.1".parse().unwrap()), + Json(UpdateManyBody { + variables: vec![Variable { + id: id.to_string(), + project_id: own.to_string(), + value: "tampered".into(), + }], + }), + ) + .await; + assert!( + result.is_err(), + "must reject a variable belonging to another project" + ); + let value: String = sqlx::query_scalar("SELECT value FROM variables WHERE id=$1") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(value, "original"); + } +} + +#[cfg(test)] +mod replacement_tests { + use super::*; + use crate::test_support::*; + #[sqlx::test] + async fn replacement_removes_old_values_only_on_success(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let project = project(&pool, owner).await; + let id: Uuid=sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES (gen_random_uuid(),$1,'original') RETURNING id").bind(project).fetch_one(&pool).await.unwrap(); + let body=serde_json::from_value(serde_json::json!({"project_id":project,"variables":["replacement"],"replace_ids":[id]})).unwrap(); + assert!(set_many_variables( + State(state(pool.clone())), + UserId(owner), + ClientIp("127.0.0.1".parse().unwrap()), + Json(body) + ) + .await + .is_ok()); + let values: Vec = + sqlx::query_scalar("SELECT value FROM variables WHERE project_id=$1") + .bind(project) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(values, vec!["replacement"]); + } +} diff --git a/src/test_support.rs b/src/test_support.rs new file mode 100644 index 0000000..6d3aa83 --- /dev/null +++ b/src/test_support.rs @@ -0,0 +1,37 @@ +use crate::{config::Caps, state::AppState}; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; +pub fn state(pool: PgPool) -> AppState { + AppState { + db: Arc::new(pool), + caps: Arc::new(Caps { + max_variable_bytes: 1024, + max_variables_per_project: 256, + max_project_bytes: 0, + max_user_bytes: 0, + max_ip_bytes_per_day: 0, + }), + } +} +pub async fn user(pool: &PgPool) -> Uuid { + sqlx::query_scalar( + "INSERT INTO users(username, public_key) VALUES ('test', 'fixture') RETURNING id", + ) + .fetch_one(pool) + .await + .unwrap() +} +pub async fn project(pool: &PgPool, user: Uuid) -> Uuid { + let id = sqlx::query_scalar("INSERT INTO projects DEFAULT VALUES RETURNING id") + .fetch_one(pool) + .await + .unwrap(); + sqlx::query("INSERT INTO user_project_relations(user_id, project_id) VALUES ($1,$2)") + .bind(user) + .bind(id) + .execute(pool) + .await + .unwrap(); + id +} From fdb1de8373287d102a41d40be3f97b21fa0ee0c8 Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Thu, 24 Sep 2026 04:26:59 -0700 Subject: [PATCH 02/12] [agent] feat: add mutual friends and encrypted secret handoffs Add transactional single-use friend links with signed redemption receipts, friend-only ciphertext mailboxes, idempotent sends, per-party deletion, and bounded quotas. Cover concurrency and authorization against disposable PostgreSQL. Co-Authored-By: GPT-6 (OpenAI) --- Cargo.toml | 3 + docs/social-api.md | 85 ++++ .../20260924120000_friends_messages.sql | 51 +++ src/routes/v2/mod.rs | 11 +- src/routes/v2/social/links.rs | 291 +++++++++++++ src/routes/v2/social/messages.rs | 163 +++++++ src/routes/v2/social/mod.rs | 161 +++++++ src/routes/v2/social/tests.rs | 403 ++++++++++++++++++ 8 files changed, 1164 insertions(+), 4 deletions(-) create mode 100644 docs/social-api.md create mode 100644 migrations/20260924120000_friends_messages.sql create mode 100644 src/routes/v2/social/links.rs create mode 100644 src/routes/v2/social/messages.rs create mode 100644 src/routes/v2/social/mod.rs create mode 100644 src/routes/v2/social/tests.rs diff --git a/Cargo.toml b/Cargo.toml index 50720dd..6bc4def 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,3 +47,6 @@ utoipa-axum = "0.2.0" tracing = "^0.1" tracing-subscriber = "^0.3" tower-http = { version = "0.6", features = ["trace"] } + +[dev-dependencies] +rand_08 = { package = "rand", version = "0.8" } diff --git a/docs/social-api.md b/docs/social-api.md new file mode 100644 index 0000000..0a68447 --- /dev/null +++ b/docs/social-api.md @@ -0,0 +1,85 @@ +# Friends and encrypted handoffs + +All routes are authenticated under `/v2`. Stable UUIDs identify users; usernames +are display text, not unique addresses. Public-key fingerprints are lowercase hex. +The server treats message bodies as opaque ciphertext. Clients must sign and +encrypt an envelope binding the message ID, both identities and fingerprints, +expiry, and payload, and verify the envelope before displaying or importing. + +## Friends and links + +- `GET /friends`: `{user: Identity, created_at, receipts: string[]}[]`. +- `DELETE /friends/{user_id}`: remove the mutual relationship, returning 204. + Existing mailbox copies remain accessible. New sends are forbidden. +- `POST /friend-links`: `{label, target_id?, expires_at?}` returns + `{link: Link, token: string}`. The 256-bit hex token is returned only here; only + its SHA-256 digest is stored. Label is 1–64 lowercase ASCII letters/hyphens and + has no security meaning. Expiry defaults to 24 hours, maximum 30 days. +- `GET /friend-links`: creator-only history, `Link[]`, including redeemed-by + identity and signed receipt. This endpoint never returns the token or hash. +- `DELETE /friend-links/{id}`: creator-only revocation of an unused link, 204. +- `POST /friend-links/preview`: `{id, token}` returns `{link, creator: Identity}` + without consuming the link. Wrong targets and bad tokens return 404. +- `POST /friend-links/redeem`: `{id, token, receipt}` returns the same shape as + preview. `receipt` is an uncompressed armored OpenPGP signed message containing + this JSON object (field order is irrelevant): + +```json +{"version":1,"id":"link UUID","creator_id":"UUID","creator_fingerprint":"hex","redeemer_id":"UUID","redeemer_fingerprint":"hex"} +``` + +The server verifies the receipt using the redeemer's registered key and compares +all fields before committing friendship and consumption in one transaction. +Self-friending is rejected. Existing friendships make redemption a no-op for the +relationship but still consume the link. Concurrent redeemers produce one winner. +The original redeemer can recover a response after expiry; recovery does not +restore a friendship subsequently removed. Clients must verify receipts before +pinning a creator's new friend. First-use identity still depends on the link's +out-of-band fingerprint and the authenticated server directory. + +`Identity` is `{id, username, public_key, fingerprint}`. +`Link` is `{id, creator_id, target_id, label, created_at, expires_at, revoked_at, +redeemed_at, redeemed_by, receipt}`; optional fields are JSON null. `redeemed_by` +is an `Identity`. Timestamps are RFC3339. + +## Messages + +- `POST /messages`: `{id, recipient_id, ciphertext, expires_at?}`. `id` is a + client-generated durable UUID. An identical retry returns the original + metadata, including after deletion or expiry; it never restores ciphertext. + Reusing the ID with different content, recipient, or expiry returns 409. +- `GET /messages`: both incoming and outgoing visible messages, metadata only. +- `GET /messages/{id}`: participant-only metadata plus ciphertext. +- `DELETE /messages/{id}`: delete the caller's mailbox copy, 204. Once both copies + are deleted, ciphertext is erased; minimal retry state remains. + +Message fields: `{id, sender_id, recipient_id, created_at, expires_at, +sender_public_key, recipient_public_key, ciphertext}`. Send and list responses +set ciphertext to null. Keys are snapshotted at send time. Never trust a server +snapshot in place of a locally pinned fingerprint. + +Expiry makes both copies inaccessible immediately. A bounded cleanup during +subsequent sends erases up to 1,000 expired ciphertexts. This is request-driven +cleanup, not a promise of physical erasure at the expiry instant; backups may +also retain ciphertext. Database maintenance can clear the remaining expired +ciphertext independently. The message ID and digest remain tombstones so retries +cannot resurrect messages. + +All three lists accept `limit` (1–100, default 50) and `before` (UUID). They sort +by UUID descending; pass the last item's ID as the next cursor (friend user ID +for the friends list). Ordering is stable but deliberately not chronological. + +## Limits and concurrency + +- 100 active links and 100 new links per account per UTC day. +- 1,000 preview/redemption attempts per account per UTC day, including failures. +- 1,000 mutual friends per account. +- 128 KiB ciphertext per message; 1,000 sends per account per UTC day. +- 1,000 live messages and 20 MiB ciphertext per participant's mailbox. + +Daily counters are independent of message deletion. User rows are locked in UUID +order before friendship mutations or quota-sensitive writes. PostgreSQL tests +cover claim races, retry recovery, target/signature/token rejection, revocation, +expiry, authorization, deletion, pagination, send-rate and mailbox-capacity races. +Run them only against a disposable PostgreSQL instance; `sqlx::test` creates +isolated test databases and applies migrations automatically. diff --git a/migrations/20260924120000_friends_messages.sql b/migrations/20260924120000_friends_messages.sql new file mode 100644 index 0000000..b771513 --- /dev/null +++ b/migrations/20260924120000_friends_messages.sql @@ -0,0 +1,51 @@ +-- Canonical pairs prevent reverse duplicates; all mutations lock users in UUID order. +CREATE TABLE friendships ( + user_low uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_high uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_low, user_high), + CHECK (user_low < user_high) +); +CREATE TABLE friend_links ( + id uuid PRIMARY KEY, + creator_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + target_id uuid REFERENCES users(id) ON DELETE CASCADE, + label text NOT NULL CHECK (octet_length(label) BETWEEN 1 AND 64), + token_hash text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + redeemed_at timestamptz, + redeemed_by uuid REFERENCES users(id) ON DELETE CASCADE, + receipt text, + CHECK (target_id IS NULL OR target_id <> creator_id), + CHECK ((redeemed_by IS NULL) = (redeemed_at IS NULL)), + CHECK ((redeemed_by IS NULL) = (receipt IS NULL)) +); +CREATE INDEX friend_links_creator ON friend_links(creator_id, created_at DESC); +CREATE TABLE secret_messages ( + id uuid PRIMARY KEY, + sender_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + recipient_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ciphertext text, + ciphertext_hash text NOT NULL, + sender_public_key text NOT NULL, + recipient_public_key text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz, + sender_deleted boolean NOT NULL DEFAULT false, + recipient_deleted boolean NOT NULL DEFAULT false, + CHECK(sender_id <> recipient_id), + CHECK(ciphertext IS NULL OR octet_length(ciphertext) BETWEEN 1 AND 131072) +); +CREATE INDEX secret_messages_expiry ON secret_messages(expires_at) WHERE ciphertext IS NOT NULL AND expires_at IS NOT NULL; +CREATE INDEX secret_messages_sender ON secret_messages(sender_id, created_at DESC); +CREATE INDEX secret_messages_recipient ON secret_messages(recipient_id, created_at DESC); +-- Bounded daily counters survive message deletion and failed link claims. +CREATE TABLE social_daily_usage ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + day date NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')::date, + kind text NOT NULL, + count bigint NOT NULL, + PRIMARY KEY (user_id, day, kind) +); diff --git a/src/routes/v2/mod.rs b/src/routes/v2/mod.rs index ca915fa..ecb124b 100644 --- a/src/routes/v2/mod.rs +++ b/src/routes/v2/mod.rs @@ -1,16 +1,19 @@ - use crate::*; - use utoipa::ToSchema; - use utoipa_axum::router::OpenApiRouter; - use utoipa_axum::routes; + +use crate::*; +use utoipa::ToSchema; +use utoipa_axum::router::OpenApiRouter; +use utoipa_axum::routes; pub mod invite; pub mod project; pub mod projects; +pub mod social; pub mod user; pub mod variables; pub fn router(state: AppState) -> OpenApiRouter { OpenApiRouter::new() + .merge(social::router(state.clone())) .nest("/project", project::router(state.clone())) .nest("/projects", projects::router(state.clone())) .nest("/user", user::router(state.clone())) diff --git a/src/routes/v2/social/links.rs b/src/routes/v2/social/links.rs new file mode 100644 index 0000000..dfff581 --- /dev/null +++ b/src/routes/v2/social/links.rs @@ -0,0 +1,291 @@ +use super::*; +use pgp::composed::Message; +use rand::RngCore; + +pub(super) fn router() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(create, list)) + .routes(routes!(revoke)) + .routes(routes!(preview)) + .routes(routes!(redeem)) +} +#[derive(Deserialize, ToSchema)] +pub struct CreateLink { + pub label: String, + pub target_id: Option, + pub expires_at: Option>, +} +#[derive(Serialize, ToSchema)] +pub struct Link { + pub id: Uuid, + pub creator_id: Uuid, + pub target_id: Option, + pub label: String, + pub created_at: DateTime, + pub expires_at: DateTime, + pub revoked_at: Option>, + pub redeemed_at: Option>, + pub redeemed_by: Option, + pub receipt: Option, +} +#[derive(FromRow)] +struct LinkRow { + id: Uuid, + creator_id: Uuid, + target_id: Option, + label: String, + token_hash: String, + created_at: DateTime, + expires_at: DateTime, + revoked_at: Option>, + redeemed_at: Option>, + redeemed_by: Option, + receipt: Option, +} +impl LinkRow { + pub(super) async fn public(self, pool: &sqlx::PgPool) -> Result { + Ok(Link { + id: self.id, + creator_id: self.creator_id, + target_id: self.target_id, + label: self.label, + created_at: self.created_at, + expires_at: self.expires_at, + revoked_at: self.revoked_at, + redeemed_at: self.redeemed_at, + redeemed_by: match self.redeemed_by { + Some(id) => Some(identity(pool, id).await?), + None => None, + }, + receipt: self.receipt, + }) + } + fn validate(&self, user: Uuid, token: &str) -> Result<(), AppError> { + if token.len() != 64 || hash(token) != self.token_hash { + return Err(not_found()); + } + if self.creator_id == user { + return Err(bad("Cannot redeem your own friend link")); + } + if self.target_id.is_some_and(|id| id != user) { + return Err(not_found()); + } + // The winning redeemer can recover a lost response even after expiry or removal. + if self.redeemed_by == Some(user) { + return Ok(()); + } + if self.redeemed_by.is_some() { + return Err(conflict("Link already redeemed")); + } + if self.revoked_at.is_some() || self.expires_at <= Utc::now() { + return Err(conflict("Link expired or revoked")); + } + Ok(()) + } +} +#[derive(Serialize, ToSchema)] +pub struct CreatedLink { + pub link: Link, + pub token: String, +} +#[derive(Deserialize, ToSchema)] +pub struct Claim { + pub id: Uuid, + pub token: String, +} +#[derive(Deserialize, ToSchema)] +pub struct Redeem { + pub id: Uuid, + pub token: String, + pub receipt: String, +} +#[derive(Serialize, ToSchema)] +pub struct LinkPreview { + pub link: Link, + pub creator: Identity, +} +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Receipt { + pub version: u8, + pub id: Uuid, + pub creator_id: Uuid, + pub creator_fingerprint: String, + pub redeemer_id: Uuid, + pub redeemer_fingerprint: String, +} + +#[utoipa::path(post,path="/friend-links",tag="friends",request_body=CreateLink,responses((status=200,body=CreatedLink)),security(("bearer"=[])))] +pub(super) async fn create( + State(state): State, + UserId(user): UserId, + Json(body): Json, +) -> Result, AppError> { + if body.label.is_empty() + || body.label.len() > 64 + || !body + .label + .bytes() + .all(|b| b.is_ascii_lowercase() || b == b'-') + { + return Err(bad("label must contain 1-64 lowercase letters or hyphens")); + } + if body.target_id == Some(user) { + return Err(bad("Cannot target yourself")); + } + let now = Utc::now(); + let expires = body.expires_at.unwrap_or(now + chrono::Duration::hours(24)); + if expires <= now || expires > now + chrono::Duration::days(30) { + return Err(bad("Link expiry must be in the next 30 days")); + } + let mut random = [0u8; 32]; + rand::rng().fill_bytes(&mut random); + let token = hex::encode(random); + let mut tx = state.db.begin().await?; + lock_users(&mut tx, user, body.target_id.unwrap_or(user)).await?; + rate(&mut tx, user, "link-create", 100).await?; + let active: i64=sqlx::query_scalar("SELECT count(*) FROM friend_links WHERE creator_id=$1 AND redeemed_by IS NULL AND revoked_at IS NULL AND expires_at>now()") + .bind(user).fetch_one(&mut *tx).await?; + if active >= 100 { + return Err(( + StatusCode::TOO_MANY_REQUESTS, + "Too many active friend links", + ) + .into()); + } + let row:LinkRow=sqlx::query_as("INSERT INTO friend_links(id,creator_id,target_id,label,token_hash,expires_at) VALUES($1,$2,$3,$4,$5,$6) RETURNING *") + .bind(Uuid::new_v4()).bind(user).bind(body.target_id).bind(body.label).bind(hash(&token)).bind(expires).fetch_one(&mut *tx).await?; + tx.commit().await?; + Ok(Json(CreatedLink { + link: row.public(&state.db).await?, + token, + })) +} +#[utoipa::path(get,path="/friend-links",tag="friends",params(("before"=Option,Query),("limit"=Option,Query)),responses((status=200,body=Vec)),security(("bearer"=[])))] +pub(super) async fn list( + State(state): State, + UserId(user): UserId, + Query(page): Query, +) -> Result>, AppError> { + let rows:Vec=sqlx::query_as("SELECT * FROM friend_links WHERE creator_id=$1 AND ($2::uuid IS NULL OR id<$2) ORDER BY id DESC LIMIT $3") + .bind(user).bind(page.before).bind(page.limit()?).fetch_all(&*state.db).await?; + let mut result = Vec::new(); + for row in rows { + result.push(row.public(&state.db).await?); + } + Ok(Json(result)) +} +#[utoipa::path(delete,path="/friend-links/{id}",tag="friends",params(("id"=Uuid,Path)),responses((status=204)),security(("bearer"=[])))] +pub(super) async fn revoke( + State(state): State, + UserId(user): UserId, + Path(id): Path, +) -> Result { + let result=sqlx::query("UPDATE friend_links SET revoked_at=COALESCE(revoked_at,now()) WHERE id=$1 AND creator_id=$2 AND redeemed_by IS NULL") + .bind(id).bind(user).execute(&*state.db).await?; + if result.rows_affected() == 0 { + return Err(not_found()); + } + Ok(StatusCode::NO_CONTENT) +} +// Commit failed-attempt usage separately: rolling it back would make the cap bypassable. +pub(super) async fn claim_attempt(state: &AppState, user: Uuid) -> Result<(), AppError> { + let mut tx = state.db.begin().await?; + rate(&mut tx, user, "link-claim", 1000).await?; + tx.commit().await?; + Ok(()) +} +#[utoipa::path(post,path="/friend-links/preview",tag="friends",request_body=Claim,responses((status=200,body=LinkPreview)),security(("bearer"=[])))] +pub(super) async fn preview( + State(state): State, + UserId(user): UserId, + Json(body): Json, +) -> Result, AppError> { + claim_attempt(&state, user).await?; + let row: LinkRow = sqlx::query_as("SELECT * FROM friend_links WHERE id=$1") + .bind(body.id) + .fetch_optional(&*state.db) + .await? + .ok_or_else(not_found)?; + row.validate(user, &body.token)?; + let creator = identity(&state.db, row.creator_id).await?; + Ok(Json(LinkPreview { + link: row.public(&state.db).await?, + creator, + })) +} +#[utoipa::path(post,path="/friend-links/redeem",tag="friends",request_body=Redeem,responses((status=200,body=LinkPreview)),security(("bearer"=[])))] +pub(super) async fn redeem( + State(state): State, + UserId(user): UserId, + Json(body): Json, +) -> Result, AppError> { + claim_attempt(&state, user).await?; + if body.receipt.len() > 32 * 1024 { + return Err(bad("Receipt too large")); + } + // Read creator first to preserve the same user-lock order as remove/send. + let creator_id: Uuid = sqlx::query_scalar("SELECT creator_id FROM friend_links WHERE id=$1") + .bind(body.id) + .fetch_optional(&*state.db) + .await? + .ok_or_else(not_found)?; + let creator = identity(&state.db, creator_id).await?; + let redeemer = identity(&state.db, user).await?; + let key = SignedPublicKey::from_string(&redeemer.public_key) + .map_err(|_| bad("Invalid public key"))? + .0; + let mut signed = Message::from_string(&body.receipt) + .map_err(|_| bad("Invalid signed receipt"))? + .0; + let data = signed + .as_data_string() + .map_err(|_| bad("Invalid receipt payload"))?; + signed + .verify(&key) + .map_err(|_| bad("Invalid receipt signature"))?; + let receipt: Receipt = serde_json::from_str(&data).map_err(|_| bad("Invalid receipt JSON"))?; + if receipt.version != 1 + || receipt.id != body.id + || receipt.creator_id != creator_id + || receipt.creator_fingerprint != creator.fingerprint + || receipt.redeemer_id != user + || receipt.redeemer_fingerprint != redeemer.fingerprint + { + return Err(bad("Receipt identity mismatch")); + } + let mut tx = state.db.begin().await?; + lock_users(&mut tx, user, creator_id).await?; + let row: LinkRow = sqlx::query_as("SELECT * FROM friend_links WHERE id=$1 FOR UPDATE") + .bind(body.id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(not_found)?; + row.validate(user, &body.token)?; + if row.redeemed_by == Some(user) { + tx.commit().await?; + return Ok(Json(LinkPreview { + link: row.public(&state.db).await?, + creator, + })); + } + for account in [user, creator_id] { + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM friendships WHERE user_low=$1 OR user_high=$1", + ) + .bind(account) + .fetch_one(&mut *tx) + .await?; + let exists:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM friendships WHERE user_low=LEAST($1,$2) AND user_high=GREATEST($1,$2))").bind(user).bind(creator_id).fetch_one(&mut *tx).await?; + if count >= 1000 && !exists { + return Err((StatusCode::TOO_MANY_REQUESTS, "Friend limit reached").into()); + } + } + sqlx::query("INSERT INTO friendships(user_low,user_high) VALUES(LEAST($1,$2),GREATEST($1,$2)) ON CONFLICT DO NOTHING").bind(user).bind(creator_id).execute(&mut *tx).await?; + let row:LinkRow=sqlx::query_as("UPDATE friend_links SET redeemed_by=$2,redeemed_at=now(),receipt=$3 WHERE id=$1 RETURNING *").bind(body.id).bind(user).bind(&body.receipt).fetch_one(&mut *tx).await?; + tx.commit().await?; + Ok(Json(LinkPreview { + link: row.public(&state.db).await?, + creator, + })) +} diff --git a/src/routes/v2/social/messages.rs b/src/routes/v2/social/messages.rs new file mode 100644 index 0000000..3ee7ec9 --- /dev/null +++ b/src/routes/v2/social/messages.rs @@ -0,0 +1,163 @@ +use super::*; +pub(super) fn router() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(send, list)) + .routes(routes!(get, delete)) +} +#[derive(Deserialize, ToSchema)] +pub struct SendMessage { + pub id: Uuid, + pub recipient_id: Uuid, + pub ciphertext: String, + pub expires_at: Option>, +} +#[derive(Serialize, FromRow, ToSchema)] +pub struct SecretMessage { + pub id: Uuid, + pub sender_id: Uuid, + pub recipient_id: Uuid, + pub created_at: DateTime, + pub expires_at: Option>, + pub sender_public_key: String, + pub recipient_public_key: String, + pub ciphertext: Option, +} +#[derive(FromRow)] +struct StoredMessage { + id: Uuid, + sender_id: Uuid, + recipient_id: Uuid, + created_at: DateTime, + expires_at: Option>, + sender_public_key: String, + recipient_public_key: String, + ciphertext: Option, + ciphertext_hash: String, + sender_deleted: bool, + recipient_deleted: bool, +} +impl StoredMessage { + fn public(self, include_ciphertext: bool) -> SecretMessage { + SecretMessage { + id: self.id, + sender_id: self.sender_id, + recipient_id: self.recipient_id, + created_at: self.created_at, + expires_at: self.expires_at, + sender_public_key: self.sender_public_key, + recipient_public_key: self.recipient_public_key, + ciphertext: if include_ciphertext { + self.ciphertext + } else { + None + }, + } + } + fn visible(&self, user: Uuid) -> bool { + self.expires_at.is_none_or(|at| at > Utc::now()) + && self.ciphertext.is_some() + && ((self.sender_id == user && !self.sender_deleted) + || (self.recipient_id == user && !self.recipient_deleted)) + } +} +#[utoipa::path(post,path="/messages",tag="messages",request_body=SendMessage,responses((status=200,body=SecretMessage)),security(("bearer"=[])))] +pub(super) async fn send( + State(state): State, + UserId(user): UserId, + Json(body): Json, +) -> Result, AppError> { + if body.recipient_id == user { + return Err(bad("Cannot send to yourself")); + } + if body.ciphertext.is_empty() || body.ciphertext.len() > 128 * 1024 { + return Err(( + StatusCode::PAYLOAD_TOO_LARGE, + "Ciphertext must contain 1-131072 bytes", + ) + .into()); + } + // Opportunistic bounded cleanup; expiry access checks do not depend on cleanup. + sqlx::query("WITH expired AS (SELECT id FROM secret_messages WHERE expires_at<=now() AND ciphertext IS NOT NULL ORDER BY expires_at LIMIT 1000 FOR UPDATE SKIP LOCKED) UPDATE secret_messages SET ciphertext=NULL FROM expired WHERE secret_messages.id=expired.id") + .execute(&*state.db).await?; + let mut tx = state.db.begin().await?; + lock_users(&mut tx, user, body.recipient_id).await?; + // Retain the digest after deletion so a retried request cannot resurrect a secret. + let existing: Option = + sqlx::query_as("SELECT * FROM secret_messages WHERE id=$1") + .bind(body.id) + .fetch_optional(&mut *tx) + .await?; + if let Some(existing) = existing { + if existing.sender_id != user + || existing.recipient_id != body.recipient_id + || existing.ciphertext_hash != hash(&body.ciphertext) + || existing.expires_at.map(|at| at.timestamp_micros()) + != body.expires_at.map(|at| at.timestamp_micros()) + { + return Err(conflict("Message ID already used")); + } + tx.commit().await?; + return Ok(Json(existing.public(false))); + } + if body.expires_at.is_some_and(|expiry| expiry <= Utc::now()) { + return Err(bad("Message expiry must be in the future")); + } + let friends:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM friendships WHERE user_low=LEAST($1,$2) AND user_high=GREATEST($1,$2))").bind(user).bind(body.recipient_id).fetch_one(&mut *tx).await?; + if !friends { + return Err((StatusCode::FORBIDDEN, "Recipient must be a friend").into()); + } + rate(&mut tx, user, "message-send", 1000).await?; + for account in [user, body.recipient_id] { + let (count,bytes):(i64,i64)=sqlx::query_as("SELECT count(*),COALESCE(sum(octet_length(ciphertext)),0)::bigint FROM secret_messages WHERE ((sender_id=$1 AND NOT sender_deleted) OR (recipient_id=$1 AND NOT recipient_deleted)) AND (expires_at IS NULL OR expires_at>now()) AND ciphertext IS NOT NULL") + .bind(account).fetch_one(&mut *tx).await?; + if count >= 1000 || bytes + body.ciphertext.len() as i64 > 20 * 1024 * 1024 { + return Err((StatusCode::TOO_MANY_REQUESTS, "Mailbox quota exceeded").into()); + } + } + let row:StoredMessage=sqlx::query_as("INSERT INTO secret_messages(id,sender_id,recipient_id,ciphertext,ciphertext_hash,sender_public_key,recipient_public_key,expires_at) VALUES($1,$2,$3,$4,$5,(SELECT public_key FROM users WHERE id=$2),(SELECT public_key FROM users WHERE id=$3),$6) RETURNING *") + .bind(body.id).bind(user).bind(body.recipient_id).bind(&body.ciphertext).bind(hash(&body.ciphertext)).bind(body.expires_at).fetch_one(&mut *tx).await?; + tx.commit().await?; + Ok(Json(row.public(false))) +} +#[utoipa::path(get,path="/messages",tag="messages",params(("before"=Option,Query),("limit"=Option,Query)),responses((status=200,body=Vec)),security(("bearer"=[])))] +pub(super) async fn list( + State(state): State, + UserId(user): UserId, + Query(page): Query, +) -> Result>, AppError> { + let rows:Vec=sqlx::query_as("SELECT id,sender_id,recipient_id,created_at,expires_at,sender_public_key,recipient_public_key,NULL::text AS ciphertext FROM secret_messages WHERE ((sender_id=$1 AND NOT sender_deleted) OR (recipient_id=$1 AND NOT recipient_deleted)) AND (expires_at IS NULL OR expires_at>now()) AND ciphertext IS NOT NULL AND ($2::uuid IS NULL OR id<$2) ORDER BY id DESC LIMIT $3") + .bind(user).bind(page.before).bind(page.limit()?).fetch_all(&*state.db).await?; + Ok(Json(rows)) +} +#[utoipa::path(get,path="/messages/{id}",tag="messages",params(("id"=Uuid,Path)),responses((status=200,body=SecretMessage)),security(("bearer"=[])))] +pub(super) async fn get( + State(state): State, + UserId(user): UserId, + Path(id): Path, +) -> Result, AppError> { + let row: StoredMessage = sqlx::query_as( + "SELECT * FROM secret_messages WHERE id=$1 AND (sender_id=$2 OR recipient_id=$2)", + ) + .bind(id) + .bind(user) + .fetch_optional(&*state.db) + .await? + .ok_or_else(not_found)?; + if !row.visible(user) { + return Err(not_found()); + } + Ok(Json(row.public(true))) +} +#[utoipa::path(delete,path="/messages/{id}",tag="messages",params(("id"=Uuid,Path)),responses((status=204)),security(("bearer"=[])))] +pub(super) async fn delete( + State(state): State, + UserId(user): UserId, + Path(id): Path, +) -> Result { + let result=sqlx::query("UPDATE secret_messages SET sender_deleted=sender_deleted OR sender_id=$2,recipient_deleted=recipient_deleted OR recipient_id=$2,ciphertext=CASE WHEN (sender_deleted OR sender_id=$2) AND (recipient_deleted OR recipient_id=$2) THEN NULL ELSE ciphertext END WHERE id=$1 AND (sender_id=$2 OR recipient_id=$2)") + .bind(id).bind(user).execute(&*state.db).await?; + if result.rows_affected() == 0 { + return Err(not_found()); + } + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/routes/v2/social/mod.rs b/src/routes/v2/social/mod.rs new file mode 100644 index 0000000..d204394 --- /dev/null +++ b/src/routes/v2/social/mod.rs @@ -0,0 +1,161 @@ +//! Friend-only handoffs. Locks on user rows serialize pair changes and mailbox quotas. +use crate::{extractors::user::UserId, AppError, AppState, Json, State}; +use axum::{ + extract::{Path, Query}, + http::StatusCode, +}; +use chrono::{DateTime, Utc}; +use pgp::{ + composed::{Deserializable, SignedPublicKey}, + types::KeyDetails, +}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, Postgres, Transaction}; +use utoipa::ToSchema; +use utoipa_axum::{router::OpenApiRouter, routes}; +use uuid::Uuid; + +mod links; +mod messages; +#[cfg(test)] +mod tests; + +pub fn router(state: AppState) -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(friends, remove_friend)) + .merge(links::router()) + .merge(messages::router()) + .with_state(state) +} + +#[derive(Serialize, ToSchema)] +pub struct Identity { + pub id: Uuid, + pub username: String, + pub public_key: String, + pub fingerprint: String, +} + +async fn identity(pool: &sqlx::PgPool, id: Uuid) -> Result { + let (username, public_key): (String, String) = + sqlx::query_as("SELECT username, public_key FROM users WHERE id=$1") + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(not_found)?; + let key = SignedPublicKey::from_string(&public_key) + .map_err(|_| { + AppError::Generic( + StatusCode::INTERNAL_SERVER_ERROR, + "Invalid stored public key".into(), + ) + })? + .0; + Ok(Identity { + id, + username, + fingerprint: hex::encode(key.fingerprint().as_bytes()), + public_key, + }) +} + +fn not_found() -> AppError { + (StatusCode::NOT_FOUND, "Not found").into() +} +fn bad(message: &str) -> AppError { + (StatusCode::BAD_REQUEST, message.to_string()).into() +} +fn conflict(message: &str) -> AppError { + (StatusCode::CONFLICT, message.to_string()).into() +} +fn hash(value: &str) -> String { + crypto_hash::hex_digest(crypto_hash::Algorithm::SHA256, value.as_bytes()) +} + +async fn lock_users(tx: &mut Transaction<'_, Postgres>, a: Uuid, b: Uuid) -> Result<(), AppError> { + let rows: Vec = + sqlx::query_scalar("SELECT id FROM users WHERE id=$1 OR id=$2 ORDER BY id FOR UPDATE") + .bind(a) + .bind(b) + .fetch_all(&mut **tx) + .await?; + if rows.len() != if a == b { 1 } else { 2 } { + return Err(not_found()); + } + Ok(()) +} +async fn rate( + tx: &mut Transaction<'_, Postgres>, + user: Uuid, + kind: &str, + cap: i64, +) -> Result<(), AppError> { + let count: Option = sqlx::query_scalar("INSERT INTO social_daily_usage(user_id,kind,count) VALUES($1,$2,1) ON CONFLICT(user_id,day,kind) DO UPDATE SET count=social_daily_usage.count+1 WHERE social_daily_usage.count<$3 RETURNING count") + .bind(user).bind(kind).bind(cap).fetch_optional(&mut **tx).await?; + if count.is_none() { + return Err((StatusCode::TOO_MANY_REQUESTS, "Daily limit reached").into()); + } + Ok(()) +} + +#[derive(Deserialize, ToSchema, Default)] +pub struct Page { + pub before: Option, + pub limit: Option, +} +impl Page { + fn limit(&self) -> Result { + let value = self.limit.unwrap_or(50); + if !(1..=100).contains(&value) { + return Err(bad("limit must be between 1 and 100")); + } + Ok(value) + } +} +#[derive(Serialize, ToSchema)] +pub struct Friend { + pub user: Identity, + pub created_at: DateTime, + /// Signed redemption evidence; verify locally before accepting a new key. + pub receipts: Vec, +} + +#[utoipa::path(get, path="/friends", tag="friends", params(("before"=Option,Query),("limit"=Option,Query)), responses((status=200,body=Vec)), security(("bearer"=[])))] +async fn friends( + State(state): State, + UserId(user): UserId, + Query(page): Query, +) -> Result>, AppError> { + let rows: Vec<(Uuid, DateTime)> = sqlx::query_as("SELECT CASE WHEN user_low=$1 THEN user_high ELSE user_low END AS friend_id,created_at FROM friendships WHERE (user_low=$1 OR user_high=$1) AND ($2::uuid IS NULL OR (CASE WHEN user_low=$1 THEN user_high ELSE user_low END)<$2) ORDER BY friend_id DESC LIMIT $3") + .bind(user).bind(page.before).bind(page.limit()?).fetch_all(&*state.db).await?; + let mut result = Vec::new(); + for (friend_id, created_at) in rows { + let receipts: Vec=sqlx::query_scalar("SELECT receipt FROM friend_links WHERE ((creator_id=$1 AND redeemed_by=$2) OR (creator_id=$2 AND redeemed_by=$1)) AND receipt IS NOT NULL ORDER BY redeemed_at DESC LIMIT 10") + .bind(user).bind(friend_id).fetch_all(&*state.db).await?; + result.push(Friend { + user: identity(&state.db, friend_id).await?, + created_at, + receipts, + }); + } + Ok(Json(result)) +} + +#[utoipa::path(delete, path="/friends/{user_id}", tag="friends", params(("user_id"=Uuid,Path)), responses((status=204)), security(("bearer"=[])))] +async fn remove_friend( + State(state): State, + UserId(user): UserId, + Path(friend): Path, +) -> Result { + let mut tx = state.db.begin().await?; + lock_users(&mut tx, user, friend).await?; + sqlx::query( + "DELETE FROM friendships WHERE user_low=LEAST($1,$2) AND user_high=GREATEST($1,$2)", + ) + .bind(user) + .bind(friend) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/routes/v2/social/tests.rs b/src/routes/v2/social/tests.rs new file mode 100644 index 0000000..fc51c62 --- /dev/null +++ b/src/routes/v2/social/tests.rs @@ -0,0 +1,403 @@ +use super::*; +use pgp::{ + composed::{ArmorOptions, KeyType, MessageBuilder, SecretKeyParamsBuilder, SignedSecretKey}, + crypto::hash::HashAlgorithm, +}; +use std::sync::Arc; + +fn state(pool: sqlx::PgPool) -> AppState { + AppState { + db: Arc::new(pool), + caps: Arc::new(crate::config::Caps::from_env()), + } +} +async fn user(pool: &sqlx::PgPool) -> (Uuid, SignedSecretKey) { + let key = SecretKeyParamsBuilder::default() + .key_type(KeyType::Ed25519Legacy) + .can_sign(true) + .primary_user_id("test".into()) + .build() + .unwrap() + .generate(rand_08::rngs::OsRng) + .unwrap() + .sign(rand_08::rngs::OsRng, &"".into()) + .unwrap(); + let public = SignedPublicKey::from(key.clone()) + .to_armored_string(ArmorOptions::default()) + .unwrap(); + let id = + sqlx::query_scalar("INSERT INTO users(username,public_key) VALUES('test',$1) RETURNING id") + .bind(public) + .fetch_one(pool) + .await + .unwrap(); + (id, key) +} +fn ok(result: Result) -> T { + match result { + Ok(v) => v, + Err(e) => { + use axum::response::IntoResponse; + panic!("request failed: {}", e.into_response().status()) + } + } +} +async fn link(pool: &sqlx::PgPool, owner: Uuid, target: Option) -> links::CreatedLink { + ok(links::create( + State(state(pool.clone())), + UserId(owner), + Json(links::CreateLink { + label: "amber-otter".into(), + target_id: target, + expires_at: None, + }), + ) + .await) + .0 +} +async fn receipt( + pool: &sqlx::PgPool, + link: &links::CreatedLink, + id: Uuid, + key: &SignedSecretKey, +) -> String { + let creator = ok(identity(pool, link.link.creator_id).await); + let redeemer = ok(identity(pool, id).await); + let payload = links::Receipt { + version: 1, + id: link.link.id, + creator_id: creator.id, + creator_fingerprint: creator.fingerprint, + redeemer_id: id, + redeemer_fingerprint: redeemer.fingerprint, + }; + sign(key, &serde_json::to_string(&payload).unwrap()) +} +fn sign(key: &SignedSecretKey, data: &str) -> String { + let mut builder = MessageBuilder::from_bytes("", data.as_bytes().to_vec()); + builder.sign(&key.primary_key, "".into(), HashAlgorithm::Sha256); + builder + .to_armored_string(rand_08::rngs::OsRng, ArmorOptions::default()) + .unwrap() +} +async fn redeem( + pool: &sqlx::PgPool, + link: &links::CreatedLink, + id: Uuid, + key: &SignedSecretKey, +) -> Result, AppError> { + links::redeem( + State(state(pool.clone())), + UserId(id), + Json(links::Redeem { + id: link.link.id, + token: link.token.clone(), + receipt: receipt(pool, link, id, key).await, + }), + ) + .await +} +async fn befriend(pool: &sqlx::PgPool, a: Uuid, b: Uuid, key: &SignedSecretKey) { + let link = link(pool, a, None).await; + let _ = ok(redeem(pool, &link, b, key).await); +} +fn body(id: Uuid, to: Uuid) -> messages::SendMessage { + messages::SendMessage { + id, + recipient_id: to, + ciphertext: "opaque ciphertext".into(), + expires_at: None, + } +} + +#[sqlx::test] +async fn concurrent_claim_one_winner_and_recovery(pool: sqlx::PgPool) { + let (owner, _) = user(&pool).await; + let (a, ak) = user(&pool).await; + let (b, bk) = user(&pool).await; + let link = link(&pool, owner, None).await; + let (ra, rb) = tokio::join!(redeem(&pool, &link, a, &ak), redeem(&pool, &link, b, &bk)); + assert_eq!(usize::from(ra.is_ok()) + usize::from(rb.is_ok()), 1); + let (winner, key) = if ra.is_ok() { (a, &ak) } else { (b, &bk) }; + let repeated = ok(redeem(&pool, &link, winner, key).await).0; + assert_eq!(repeated.link.redeemed_by.unwrap().id, winner); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM friendships") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); + ok(remove_friend(State(state(pool.clone())), UserId(owner), Path(winner)).await); + let _ = ok(redeem(&pool, &link, winner, key).await); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM friendships") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0, "recovery must not restore a removed friendship"); +} +#[sqlx::test] +async fn wrong_target_invalid_signature_and_expiry_do_not_consume(pool: sqlx::PgPool) { + let (owner, _) = user(&pool).await; + let (a, ak) = user(&pool).await; + let (b, bk) = user(&pool).await; + let link = link(&pool, owner, Some(a)).await; + assert!(redeem(&pool, &link, b, &bk).await.is_err()); + assert!(redeem(&pool, &link, a, &bk).await.is_err()); + assert!(links::preview( + State(state(pool.clone())), + UserId(b), + Json(links::Claim { + id: link.link.id, + token: link.token.clone() + }) + ) + .await + .is_err()); + let row: Option = sqlx::query_scalar("SELECT redeemed_by FROM friend_links WHERE id=$1") + .bind(link.link.id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(row.is_none()); + let _ = ok(redeem(&pool, &link, a, &ak).await); + let expired = super::tests::link(&pool, owner, None).await; + sqlx::query("UPDATE friend_links SET expires_at=now()-interval '1 second' WHERE id=$1") + .bind(expired.link.id) + .execute(&pool) + .await + .unwrap(); + assert!(redeem(&pool, &expired, b, &bk).await.is_err()); + assert!(redeem(&pool, &expired, owner, &bk).await.is_err()); +} +#[sqlx::test] +async fn messages_authorization_idempotency_deletion_and_removal(pool: sqlx::PgPool) { + let (a, _) = user(&pool).await; + let (b, bk) = user(&pool).await; + let (c, _) = user(&pool).await; + let id = Uuid::new_v4(); + assert!( + messages::send(State(state(pool.clone())), UserId(a), Json(body(id, b))) + .await + .is_err() + ); + befriend(&pool, a, b, &bk).await; + let (r1, r2) = tokio::join!( + messages::send(State(state(pool.clone())), UserId(a), Json(body(id, b))), + messages::send(State(state(pool.clone())), UserId(a), Json(body(id, b))) + ); + assert!(r1.is_ok() && r2.is_ok()); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM secret_messages") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); + assert!( + messages::get(State(state(pool.clone())), UserId(c), Path(id)) + .await + .is_err() + ); + assert!( + messages::delete(State(state(pool.clone())), UserId(c), Path(id)) + .await + .is_err() + ); + let listed = ok(messages::list( + State(state(pool.clone())), + UserId(b), + Query(Page::default()), + ) + .await) + .0; + assert_eq!(listed.len(), 1); + assert!(listed[0].ciphertext.is_none()); + ok(messages::delete(State(state(pool.clone())), UserId(a), Path(id)).await); + assert!( + messages::get(State(state(pool.clone())), UserId(a), Path(id)) + .await + .is_err() + ); + assert!( + messages::get(State(state(pool.clone())), UserId(b), Path(id)) + .await + .is_ok() + ); + ok(remove_friend(State(state(pool.clone())), UserId(a), Path(b)).await); + assert!(messages::send( + State(state(pool.clone())), + UserId(a), + Json(body(Uuid::new_v4(), b)) + ) + .await + .is_err()); + ok(messages::delete(State(state(pool.clone())), UserId(b), Path(id)).await); + let _ = ok(messages::send(State(state(pool.clone())), UserId(a), Json(body(id, b))).await); + let ciphertext: Option = + sqlx::query_scalar("SELECT ciphertext FROM secret_messages WHERE id=$1") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(ciphertext.is_none()); + let mut different = body(id, b); + different.ciphertext = "changed".into(); + assert!( + messages::send(State(state(pool.clone())), UserId(a), Json(different)) + .await + .is_err() + ); +} +#[sqlx::test] +async fn expiry_quota_and_failed_claim_rate(pool: sqlx::PgPool) { + let (a, _) = user(&pool).await; + let (b, bk) = user(&pool).await; + befriend(&pool, a, b, &bk).await; + let id = Uuid::new_v4(); + let _ = ok(messages::send(State(state(pool.clone())), UserId(a), Json(body(id, b))).await); + sqlx::query("UPDATE secret_messages SET expires_at=now()-interval '1 second' WHERE id=$1") + .bind(id) + .execute(&pool) + .await + .unwrap(); + assert!( + messages::get(State(state(pool.clone())), UserId(a), Path(id)) + .await + .is_err() + ); + assert!( + messages::get(State(state(pool.clone())), UserId(b), Path(id)) + .await + .is_err() + ); + sqlx::query("INSERT INTO social_daily_usage(user_id,kind,count) VALUES($1,'message-send',999) ON CONFLICT(user_id,day,kind) DO UPDATE SET count=999").bind(a).execute(&pool).await.unwrap(); + let (ra, rb) = tokio::join!( + messages::send( + State(state(pool.clone())), + UserId(a), + Json(body(Uuid::new_v4(), b)) + ), + messages::send( + State(state(pool.clone())), + UserId(a), + Json(body(Uuid::new_v4(), b)) + ) + ); + assert_eq!(usize::from(ra.is_ok()) + usize::from(rb.is_ok()), 1); + sqlx::query("INSERT INTO social_daily_usage(user_id,kind,count) VALUES($1,'link-claim',999) ON CONFLICT(user_id,day,kind) DO UPDATE SET count=999").bind(a).execute(&pool).await.unwrap(); + for _ in 0..2 { + assert!(links::preview( + State(state(pool.clone())), + UserId(a), + Json(links::Claim { + id: Uuid::new_v4(), + token: "bad".into() + }) + ) + .await + .is_err()); + } + let count: i64 = sqlx::query_scalar( + "SELECT count FROM social_daily_usage WHERE user_id=$1 AND kind='link-claim'", + ) + .bind(a) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1000); +} + +#[sqlx::test] +async fn revoked_links_token_secrecy_and_pagination(pool: sqlx::PgPool) { + let (a, _) = user(&pool).await; + let (b, bk) = user(&pool).await; + let created = link(&pool, a, None).await; + let stored: String = sqlx::query_scalar("SELECT token_hash FROM friend_links WHERE id=$1") + .bind(created.link.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_ne!(stored, created.token); + assert_eq!(stored, hash(&created.token)); + assert!(links::preview( + State(state(pool.clone())), + UserId(b), + Json(links::Claim { + id: created.link.id, + token: "0".repeat(64) + }) + ) + .await + .is_err()); + assert!( + links::revoke(State(state(pool.clone())), UserId(b), Path(created.link.id)) + .await + .is_err() + ); + ok(links::revoke(State(state(pool.clone())), UserId(a), Path(created.link.id)).await); + assert!(redeem(&pool, &created, b, &bk).await.is_err()); + let _ = link(&pool, a, None).await; + let first = ok(links::list( + State(state(pool.clone())), + UserId(a), + Query(Page { + before: None, + limit: Some(1), + }), + ) + .await) + .0; + let second = ok(links::list( + State(state(pool.clone())), + UserId(a), + Query(Page { + before: Some(first[0].id), + limit: Some(1), + }), + ) + .await) + .0; + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].id, second[0].id); + let other = ok(links::list( + State(state(pool.clone())), + UserId(b), + Query(Page::default()), + ) + .await) + .0; + assert!(other.is_empty()); +} + +#[sqlx::test] +async fn mailbox_capacity_serializes_and_precise_expiry_retries(pool: sqlx::PgPool) { + let (a, _) = user(&pool).await; + let (b, bk) = user(&pool).await; + befriend(&pool, a, b, &bk).await; + let id = Uuid::new_v4(); + let expiry = Utc::now() + chrono::Duration::hours(1); + for _ in 0..2 { + let mut send_body = body(id, b); + send_body.expires_at = Some(expiry); + let _ = ok(messages::send(State(state(pool.clone())), UserId(a), Json(send_body)).await); + } + // Fill all but one slot using fixtures, then contend for the final slot. + sqlx::query("INSERT INTO secret_messages(id,sender_id,recipient_id,ciphertext,ciphertext_hash,sender_public_key,recipient_public_key) SELECT gen_random_uuid(),$1,$2,'fixture','hash','key','key' FROM generate_series(1,998)") + .bind(a).bind(b).execute(&pool).await.unwrap(); + let (ra, rb) = tokio::join!( + messages::send( + State(state(pool.clone())), + UserId(a), + Json(body(Uuid::new_v4(), b)) + ), + messages::send( + State(state(pool.clone())), + UserId(a), + Json(body(Uuid::new_v4(), b)) + ) + ); + assert_eq!(usize::from(ra.is_ok()) + usize::from(rb.is_ok()), 1); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM secret_messages") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1000); +} From b52a8a36daf401e0a5c21e9f0edd10868924c58c Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Thu, 24 Sep 2026 04:26:54 -0700 Subject: [PATCH 03/12] [agent] fix: make API builds offline and database upgrades reliable Run embedded migrations before serving, preserve populated legacy invitations as expired records through an audited compatibility replay, and verify offline query metadata in pull request CI. Co-Authored-By: GPT-6 (OpenAI) --- .dockerignore | 7 +- .github/workflows/rust.yml | 43 + .gitignore | 1 - ...2d43acc3b923c3b4de92d1add69fedb75431f.json | 24 + ...2a0166590fceec30a5fcd7ed31eef02ac2092.json | 15 + ...27ebe62b0a8071e029630bcf740de1f32f867.json | 24 + ...1e0a430852df4b62768892c16fcd865bc91cf.json | 12 + ...09821269d7727832e8df0a60d4b190bb29877.json | 15 + ...14f5a9f96476116f019c816dfec563985b54a.json | 28 + ...218433bf450b101f1f61ca16299c2a9a80d65.json | 14 + ...d18a32870c06c56cd0e23220c0ef496ec6084.json | 23 + ...5935c547aac60d82a4e5e2961dce893783853.json | 28 + ...479ba517f2b2986ad0dff1a45fac59335e0ab.json | 23 + ...1c76c9559c708ccef0fa4c0aeaf2c47ef9181.json | 22 + ...22c4f14b844eee0c943cf140fd69fe3405a8c.json | 26 + ...98d0c31ca5bdfe131cd3e1f6de32d0b5b98e5.json | 15 + ...425b56cff848cb7a7f16edb414a18667395f8.json | 22 + ...b5bf8833d458b586879eaa20119747fd5cd00.json | 15 + ...c2c37ba82267b60474bcef69ec020c8298316.json | 15 + ...67d0901584436223d3b8ed07f6853a17741d3.json | 23 + ...d11548e1c1f14752866c5a24c3a4972586c9f.json | 22 + ...d148d49e109b12f44af3f58a550c9dd7397b3.json | 64 + ...362d088349503320b7a8f351ef973ca9ed411.json | 20 + ...4f39701ef2b579f026f4d362af7598f89a36b.json | 22 + ...4450c60586632ab90b036788f18bfa5744f08.json | 22 + ...81b1c06f24d583e85b4430ef02db2fbcd33a1.json | 23 + ...6d2de19d94efd3201874758631eb7c85e8ede.json | 24 + ...04c47edb161cdf805421f453592d6a8cc485f.json | 40 + ...fcc98e8f393d9d1ecc06dd311ee751e2d94e9.json | 22 + ...e871398d17b46d5b399a5634d3f9543dbc189.json | 23 + ...4c4bf2fd3415dcc770ae2f367fed903404620.json | 28 + ...5833a8a708f9918c5681ce292836cb2c8a747.json | 22 + ...6b20acca1dd237de96a3ac39a1a258143a7d4.json | 22 + ...c7adfb2906596571bf2bcbaa13984c3dc3f1b.json | 40 + ...ec2dc395ab918830337ce33953d8727240f81.json | 29 + ...8b5aa78b6f90a640841756ee697201cfd04a9.json | 40 + ...40a2537c040667905b583c968e0ef6580dca9.json | 28 + ...7e09e733dede0607c05eed349f2cc7c75a77a.json | 23 + ...f6c5bd0c2221dd77f6054883f489ae5f6cdf5.json | 40 + ...2b17f1ec44dc13783b989747986ea90bcf770.json | 34 + ...e380465575f97e178e700f6872b5272d2ce33.json | 28 + ...6820b0b91b121cfa97168f1678ab14dffc1ed.json | 15 + Cargo.lock | 3737 +++++++++++++++++ Dockerfile | 42 +- docs/deployment.md | 39 + .../20251025060537_invites_v2.sql | 27 + railway.json | 4 + src/db.rs | 156 +- 48 files changed, 4997 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/rust.yml create mode 100644 .sqlx/query-05b48dc5ba42bccba2eb07a6f212d43acc3b923c3b4de92d1add69fedb75431f.json create mode 100644 .sqlx/query-08eae3c0fe8d3216914c6222baf2a0166590fceec30a5fcd7ed31eef02ac2092.json create mode 100644 .sqlx/query-0988fd19b6d220c531a9f8595ff27ebe62b0a8071e029630bcf740de1f32f867.json create mode 100644 .sqlx/query-1c2be9b90755c1d4865b8b713b21e0a430852df4b62768892c16fcd865bc91cf.json create mode 100644 .sqlx/query-1e6182ddd1c95e784d0b42819e009821269d7727832e8df0a60d4b190bb29877.json create mode 100644 .sqlx/query-27dd1da6157430522aebea48ade14f5a9f96476116f019c816dfec563985b54a.json create mode 100644 .sqlx/query-348418c0b6a8bc63030eaeac3f5218433bf450b101f1f61ca16299c2a9a80d65.json create mode 100644 .sqlx/query-34f5c6b75b4e839ab0b0a4051c4d18a32870c06c56cd0e23220c0ef496ec6084.json create mode 100644 .sqlx/query-358f243683ea759a066982024b15935c547aac60d82a4e5e2961dce893783853.json create mode 100644 .sqlx/query-51318419dd4c7edfa3fcccbd97d479ba517f2b2986ad0dff1a45fac59335e0ab.json create mode 100644 .sqlx/query-5f047dcf55c9a5b707f1259f1581c76c9559c708ccef0fa4c0aeaf2c47ef9181.json create mode 100644 .sqlx/query-62055684c67264ed925999a650d22c4f14b844eee0c943cf140fd69fe3405a8c.json create mode 100644 .sqlx/query-65dd4110495521d9eb2caf72e2d98d0c31ca5bdfe131cd3e1f6de32d0b5b98e5.json create mode 100644 .sqlx/query-85e38e91113f7666b02d1dc121e425b56cff848cb7a7f16edb414a18667395f8.json create mode 100644 .sqlx/query-87d7f9d2b7de696a5432ffb7e57b5bf8833d458b586879eaa20119747fd5cd00.json create mode 100644 .sqlx/query-89bba6e31d5deac0afb6da6d52ac2c37ba82267b60474bcef69ec020c8298316.json create mode 100644 .sqlx/query-92ec08f24e47a2d182d281f8b7667d0901584436223d3b8ed07f6853a17741d3.json create mode 100644 .sqlx/query-93b244aa1404ee7eb242b75c8a4d11548e1c1f14752866c5a24c3a4972586c9f.json create mode 100644 .sqlx/query-9529be080008a3a5e62bc86d01dd148d49e109b12f44af3f58a550c9dd7397b3.json create mode 100644 .sqlx/query-99d2006aa414a12d47a69503dde362d088349503320b7a8f351ef973ca9ed411.json create mode 100644 .sqlx/query-a764f66bd2c0cbc95025881180f4f39701ef2b579f026f4d362af7598f89a36b.json create mode 100644 .sqlx/query-a78911e7c75633213cc82367bdf4450c60586632ab90b036788f18bfa5744f08.json create mode 100644 .sqlx/query-b556e22867fc6fed8bd3c0b5c9d81b1c06f24d583e85b4430ef02db2fbcd33a1.json create mode 100644 .sqlx/query-b74aad6bf9e966112839fe7dac46d2de19d94efd3201874758631eb7c85e8ede.json create mode 100644 .sqlx/query-b7e9108e3f5c604b0a0f9eb5b3d04c47edb161cdf805421f453592d6a8cc485f.json create mode 100644 .sqlx/query-ba22f4e90a2cc62a3254413b713fcc98e8f393d9d1ecc06dd311ee751e2d94e9.json create mode 100644 .sqlx/query-c9f0b460a2be57201d1a300b181e871398d17b46d5b399a5634d3f9543dbc189.json create mode 100644 .sqlx/query-cb65398a8ab2b4ac27a0e5a80514c4bf2fd3415dcc770ae2f367fed903404620.json create mode 100644 .sqlx/query-cb8644ac85832a0f697c0160dad5833a8a708f9918c5681ce292836cb2c8a747.json create mode 100644 .sqlx/query-d398da13592cf4359d47f63a8fb6b20acca1dd237de96a3ac39a1a258143a7d4.json create mode 100644 .sqlx/query-d8cdfe44f5fc57f076ffa05badfc7adfb2906596571bf2bcbaa13984c3dc3f1b.json create mode 100644 .sqlx/query-e01cf6176b1d89cd6bdf6f6f97fec2dc395ab918830337ce33953d8727240f81.json create mode 100644 .sqlx/query-e5067fb159182957df05a39da468b5aa78b6f90a640841756ee697201cfd04a9.json create mode 100644 .sqlx/query-ed86ed45d43258e0457084c495840a2537c040667905b583c968e0ef6580dca9.json create mode 100644 .sqlx/query-efe3e535d4030b7437a955bfccb7e09e733dede0607c05eed349f2cc7c75a77a.json create mode 100644 .sqlx/query-f40785e5dcd70ab6666252a31d5f6c5bd0c2221dd77f6054883f489ae5f6cdf5.json create mode 100644 .sqlx/query-f9ee96f7b8a985d46d03de1af0f2b17f1ec44dc13783b989747986ea90bcf770.json create mode 100644 .sqlx/query-fd14fd3094f6c4b1a06e90157c9e380465575f97e178e700f6872b5272d2ce33.json create mode 100644 .sqlx/query-fd25c7ae11e0f4ed4c9144521a76820b0b91b121cfa97168f1678ab14dffc1ed.json create mode 100644 Cargo.lock create mode 100644 docs/deployment.md create mode 100644 migration-compat/20251025060537_invites_v2.sql diff --git a/.dockerignore b/.dockerignore index 2233067..5eea1e6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,7 @@ target/ -Dockerfile \ No newline at end of file +.git/ +.env +.env.* +*.env +.pg/ +.claude/ diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..90c85b8 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,43 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: envx_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/envx_test + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.98.1 + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + - run: cargo install sqlx-cli --version 0.8.6 --locked --no-default-features --features postgres,rustls + - run: cargo sqlx migrate run + - run: cargo fmt -- --check + - run: cargo sqlx prepare --check -- --all-targets + - run: cargo test --locked + - run: cargo clippy --locked --all-targets + - name: Verify build without a database connection + run: env -u DATABASE_URL SQLX_OFFLINE=true cargo check --locked --all-targets diff --git a/.gitignore b/.gitignore index 134c25d..c586188 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ target/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk diff --git a/.sqlx/query-05b48dc5ba42bccba2eb07a6f212d43acc3b923c3b4de92d1add69fedb75431f.json b/.sqlx/query-05b48dc5ba42bccba2eb07a6f212d43acc3b923c3b4de92d1add69fedb75431f.json new file mode 100644 index 0000000..7ddf84c --- /dev/null +++ b/.sqlx/query-05b48dc5ba42bccba2eb07a6f212d43acc3b923c3b4de92d1add69fedb75431f.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO variables (value, project_id, tag) VALUES ($1, $2, $3) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid", + "Varchar" + ] + }, + "nullable": [ + false + ] + }, + "hash": "05b48dc5ba42bccba2eb07a6f212d43acc3b923c3b4de92d1add69fedb75431f" +} diff --git a/.sqlx/query-08eae3c0fe8d3216914c6222baf2a0166590fceec30a5fcd7ed31eef02ac2092.json b/.sqlx/query-08eae3c0fe8d3216914c6222baf2a0166590fceec30a5fcd7ed31eef02ac2092.json new file mode 100644 index 0000000..cb72b28 --- /dev/null +++ b/.sqlx/query-08eae3c0fe8d3216914c6222baf2a0166590fceec30a5fcd7ed31eef02ac2092.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM user_project_relations WHERE user_id = ANY($1::uuid[]) AND project_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "08eae3c0fe8d3216914c6222baf2a0166590fceec30a5fcd7ed31eef02ac2092" +} diff --git a/.sqlx/query-0988fd19b6d220c531a9f8595ff27ebe62b0a8071e029630bcf740de1f32f867.json b/.sqlx/query-0988fd19b6d220c531a9f8595ff27ebe62b0a8071e029630bcf740de1f32f867.json new file mode 100644 index 0000000..95a5561 --- /dev/null +++ b/.sqlx/query-0988fd19b6d220c531a9f8595ff27ebe62b0a8071e029630bcf740de1f32f867.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO variables (value, project_id, tag) SELECT * FROM UNNEST($1::text[], $2::uuid[], $3::text[]) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "TextArray", + "UuidArray", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0988fd19b6d220c531a9f8595ff27ebe62b0a8071e029630bcf740de1f32f867" +} diff --git a/.sqlx/query-1c2be9b90755c1d4865b8b713b21e0a430852df4b62768892c16fcd865bc91cf.json b/.sqlx/query-1c2be9b90755c1d4865b8b713b21e0a430852df4b62768892c16fcd865bc91cf.json new file mode 100644 index 0000000..622e0ce --- /dev/null +++ b/.sqlx/query-1c2be9b90755c1d4865b8b713b21e0a430852df4b62768892c16fcd865bc91cf.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM upload_log WHERE created_at < now() - interval '24 hours'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "1c2be9b90755c1d4865b8b713b21e0a430852df4b62768892c16fcd865bc91cf" +} diff --git a/.sqlx/query-1e6182ddd1c95e784d0b42819e009821269d7727832e8df0a60d4b190bb29877.json b/.sqlx/query-1e6182ddd1c95e784d0b42819e009821269d7727832e8df0a60d4b190bb29877.json new file mode 100644 index 0000000..bd4cd6a --- /dev/null +++ b/.sqlx/query-1e6182ddd1c95e784d0b42819e009821269d7727832e8df0a60d4b190bb29877.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO user_project_relations (user_id, project_id)\n SELECT user_id, $2::uuid\n FROM UNNEST($1::uuid[]) AS t(user_id)\n ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "1e6182ddd1c95e784d0b42819e009821269d7727832e8df0a60d4b190bb29877" +} diff --git a/.sqlx/query-27dd1da6157430522aebea48ade14f5a9f96476116f019c816dfec563985b54a.json b/.sqlx/query-27dd1da6157430522aebea48ade14f5a9f96476116f019c816dfec563985b54a.json new file mode 100644 index 0000000..77de7ee --- /dev/null +++ b/.sqlx/query-27dd1da6157430522aebea48ade14f5a9f96476116f019c816dfec563985b54a.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, public_key FROM users WHERE id = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "public_key", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "27dd1da6157430522aebea48ade14f5a9f96476116f019c816dfec563985b54a" +} diff --git a/.sqlx/query-348418c0b6a8bc63030eaeac3f5218433bf450b101f1f61ca16299c2a9a80d65.json b/.sqlx/query-348418c0b6a8bc63030eaeac3f5218433bf450b101f1f61ca16299c2a9a80d65.json new file mode 100644 index 0000000..4726771 --- /dev/null +++ b/.sqlx/query-348418c0b6a8bc63030eaeac3f5218433bf450b101f1f61ca16299c2a9a80d65.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM variables WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "348418c0b6a8bc63030eaeac3f5218433bf450b101f1f61ca16299c2a9a80d65" +} diff --git a/.sqlx/query-34f5c6b75b4e839ab0b0a4051c4d18a32870c06c56cd0e23220c0ef496ec6084.json b/.sqlx/query-34f5c6b75b4e839ab0b0a4051c4d18a32870c06c56cd0e23220c0ef496ec6084.json new file mode 100644 index 0000000..3365f0c --- /dev/null +++ b/.sqlx/query-34f5c6b75b4e839ab0b0a4051c4d18a32870c06c56cd0e23220c0ef496ec6084.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users (username, public_key) VALUES ($1, $2) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "34f5c6b75b4e839ab0b0a4051c4d18a32870c06c56cd0e23220c0ef496ec6084" +} diff --git a/.sqlx/query-358f243683ea759a066982024b15935c547aac60d82a4e5e2961dce893783853.json b/.sqlx/query-358f243683ea759a066982024b15935c547aac60d82a4e5e2961dce893783853.json new file mode 100644 index 0000000..85cb261 --- /dev/null +++ b/.sqlx/query-358f243683ea759a066982024b15935c547aac60d82a4e5e2961dce893783853.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, username FROM users WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "358f243683ea759a066982024b15935c547aac60d82a4e5e2961dce893783853" +} diff --git a/.sqlx/query-51318419dd4c7edfa3fcccbd97d479ba517f2b2986ad0dff1a45fac59335e0ab.json b/.sqlx/query-51318419dd4c7edfa3fcccbd97d479ba517f2b2986ad0dff1a45fac59335e0ab.json new file mode 100644 index 0000000..1545933 --- /dev/null +++ b/.sqlx/query-51318419dd4c7edfa3fcccbd97d479ba517f2b2986ad0dff1a45fac59335e0ab.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE variables AS v \n SET value = u.value \n FROM UNNEST($1::uuid[], $2::text[]) AS u(id, value) \n WHERE v.id = u.id \n RETURNING v.id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "UuidArray", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "51318419dd4c7edfa3fcccbd97d479ba517f2b2986ad0dff1a45fac59335e0ab" +} diff --git a/.sqlx/query-5f047dcf55c9a5b707f1259f1581c76c9559c708ccef0fa4c0aeaf2c47ef9181.json b/.sqlx/query-5f047dcf55c9a5b707f1259f1581c76c9559c708ccef0fa4c0aeaf2c47ef9181.json new file mode 100644 index 0000000..6f32397 --- /dev/null +++ b/.sqlx/query-5f047dcf55c9a5b707f1259f1581c76c9559c708ccef0fa4c0aeaf2c47ef9181.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(sum(bytes)::bigint, 0) AS \"bytes!\"\n FROM upload_log\n WHERE ip = $1 AND created_at > now() - interval '24 hours'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "bytes!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Inet" + ] + }, + "nullable": [ + null + ] + }, + "hash": "5f047dcf55c9a5b707f1259f1581c76c9559c708ccef0fa4c0aeaf2c47ef9181" +} diff --git a/.sqlx/query-62055684c67264ed925999a650d22c4f14b844eee0c943cf140fd69fe3405a8c.json b/.sqlx/query-62055684c67264ed925999a650d22c4f14b844eee0c943cf140fd69fe3405a8c.json new file mode 100644 index 0000000..27b7385 --- /dev/null +++ b/.sqlx/query-62055684c67264ed925999a650d22c4f14b844eee0c943cf140fd69fe3405a8c.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO project_invites (\n project_id,\n author_id,\n expires_at,\n verifier_argon2id,\n ciphertext\n )\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Timestamptz", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "62055684c67264ed925999a650d22c4f14b844eee0c943cf140fd69fe3405a8c" +} diff --git a/.sqlx/query-65dd4110495521d9eb2caf72e2d98d0c31ca5bdfe131cd3e1f6de32d0b5b98e5.json b/.sqlx/query-65dd4110495521d9eb2caf72e2d98d0c31ca5bdfe131cd3e1f6de32d0b5b98e5.json new file mode 100644 index 0000000..874e24e --- /dev/null +++ b/.sqlx/query-65dd4110495521d9eb2caf72e2d98d0c31ca5bdfe131cd3e1f6de32d0b5b98e5.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO user_project_relations (user_id, project_id) VALUES ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "65dd4110495521d9eb2caf72e2d98d0c31ca5bdfe131cd3e1f6de32d0b5b98e5" +} diff --git a/.sqlx/query-85e38e91113f7666b02d1dc121e425b56cff848cb7a7f16edb414a18667395f8.json b/.sqlx/query-85e38e91113f7666b02d1dc121e425b56cff848cb7a7f16edb414a18667395f8.json new file mode 100644 index 0000000..1ac88a8 --- /dev/null +++ b/.sqlx/query-85e38e91113f7666b02d1dc121e425b56cff848cb7a7f16edb414a18667395f8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT p.id FROM projects p\n JOIN user_project_relations upr ON p.id = upr.project_id\n WHERE upr.user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "85e38e91113f7666b02d1dc121e425b56cff848cb7a7f16edb414a18667395f8" +} diff --git a/.sqlx/query-87d7f9d2b7de696a5432ffb7e57b5bf8833d458b586879eaa20119747fd5cd00.json b/.sqlx/query-87d7f9d2b7de696a5432ffb7e57b5bf8833d458b586879eaa20119747fd5cd00.json new file mode 100644 index 0000000..11b1563 --- /dev/null +++ b/.sqlx/query-87d7f9d2b7de696a5432ffb7e57b5bf8833d458b586879eaa20119747fd5cd00.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE projects SET name = COALESCE($1, name) WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "87d7f9d2b7de696a5432ffb7e57b5bf8833d458b586879eaa20119747fd5cd00" +} diff --git a/.sqlx/query-89bba6e31d5deac0afb6da6d52ac2c37ba82267b60474bcef69ec020c8298316.json b/.sqlx/query-89bba6e31d5deac0afb6da6d52ac2c37ba82267b60474bcef69ec020c8298316.json new file mode 100644 index 0000000..6e16837 --- /dev/null +++ b/.sqlx/query-89bba6e31d5deac0afb6da6d52ac2c37ba82267b60474bcef69ec020c8298316.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO user_project_relations (user_id, project_id)\n VALUES ($1, $2)\n ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "89bba6e31d5deac0afb6da6d52ac2c37ba82267b60474bcef69ec020c8298316" +} diff --git a/.sqlx/query-92ec08f24e47a2d182d281f8b7667d0901584436223d3b8ed07f6853a17741d3.json b/.sqlx/query-92ec08f24e47a2d182d281f8b7667d0901584436223d3b8ed07f6853a17741d3.json new file mode 100644 index 0000000..c2021ee --- /dev/null +++ b/.sqlx/query-92ec08f24e47a2d182d281f8b7667d0901584436223d3b8ed07f6853a17741d3.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT user_id FROM user_project_relations WHERE user_id = $1 AND project_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "92ec08f24e47a2d182d281f8b7667d0901584436223d3b8ed07f6853a17741d3" +} diff --git a/.sqlx/query-93b244aa1404ee7eb242b75c8a4d11548e1c1f14752866c5a24c3a4972586c9f.json b/.sqlx/query-93b244aa1404ee7eb242b75c8a4d11548e1c1f14752866c5a24c3a4972586c9f.json new file mode 100644 index 0000000..7fc84ff --- /dev/null +++ b/.sqlx/query-93b244aa1404ee7eb242b75c8a4d11548e1c1f14752866c5a24c3a4972586c9f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO projects (name) VALUES ($1) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "93b244aa1404ee7eb242b75c8a4d11548e1c1f14752866c5a24c3a4972586c9f" +} diff --git a/.sqlx/query-9529be080008a3a5e62bc86d01dd148d49e109b12f44af3f58a550c9dd7397b3.json b/.sqlx/query-9529be080008a3a5e62bc86d01dd148d49e109b12f44af3f58a550c9dd7397b3.json new file mode 100644 index 0000000..7c77c0a --- /dev/null +++ b/.sqlx/query-9529be080008a3a5e62bc86d01dd148d49e109b12f44af3f58a550c9dd7397b3.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM project_invites WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "author_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "invited_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "verifier_argon2id", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "ciphertext", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true + ] + }, + "hash": "9529be080008a3a5e62bc86d01dd148d49e109b12f44af3f58a550c9dd7397b3" +} diff --git a/.sqlx/query-99d2006aa414a12d47a69503dde362d088349503320b7a8f351ef973ca9ed411.json b/.sqlx/query-99d2006aa414a12d47a69503dde362d088349503320b7a8f351ef973ca9ed411.json new file mode 100644 index 0000000..37a1f9f --- /dev/null +++ b/.sqlx/query-99d2006aa414a12d47a69503dde362d088349503320b7a8f351ef973ca9ed411.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO projects DEFAULT VALUES RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "99d2006aa414a12d47a69503dde362d088349503320b7a8f351ef973ca9ed411" +} diff --git a/.sqlx/query-a764f66bd2c0cbc95025881180f4f39701ef2b579f026f4d362af7598f89a36b.json b/.sqlx/query-a764f66bd2c0cbc95025881180f4f39701ef2b579f026f4d362af7598f89a36b.json new file mode 100644 index 0000000..f13db60 --- /dev/null +++ b/.sqlx/query-a764f66bd2c0cbc95025881180f4f39701ef2b579f026f4d362af7598f89a36b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(sum(octet_length(value))::bigint, 0) AS \"bytes!\"\n FROM variables WHERE id = ANY($1::uuid[])", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "bytes!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a764f66bd2c0cbc95025881180f4f39701ef2b579f026f4d362af7598f89a36b" +} diff --git a/.sqlx/query-a78911e7c75633213cc82367bdf4450c60586632ab90b036788f18bfa5744f08.json b/.sqlx/query-a78911e7c75633213cc82367bdf4450c60586632ab90b036788f18bfa5744f08.json new file mode 100644 index 0000000..2ab36eb --- /dev/null +++ b/.sqlx/query-a78911e7c75633213cc82367bdf4450c60586632ab90b036788f18bfa5744f08.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(sum(octet_length(v.value))::bigint, 0) AS \"bytes!\"\n FROM variables v\n JOIN user_project_relations upr ON v.project_id = upr.project_id\n WHERE upr.user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "bytes!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a78911e7c75633213cc82367bdf4450c60586632ab90b036788f18bfa5744f08" +} diff --git a/.sqlx/query-b556e22867fc6fed8bd3c0b5c9d81b1c06f24d583e85b4430ef02db2fbcd33a1.json b/.sqlx/query-b556e22867fc6fed8bd3c0b5c9d81b1c06f24d583e85b4430ef02db2fbcd33a1.json new file mode 100644 index 0000000..163162f --- /dev/null +++ b/.sqlx/query-b556e22867fc6fed8bd3c0b5c9d81b1c06f24d583e85b4430ef02db2fbcd33a1.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO variables (value, project_id) SELECT * FROM UNNEST($1::text[], $2::uuid[]) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "TextArray", + "UuidArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "b556e22867fc6fed8bd3c0b5c9d81b1c06f24d583e85b4430ef02db2fbcd33a1" +} diff --git a/.sqlx/query-b74aad6bf9e966112839fe7dac46d2de19d94efd3201874758631eb7c85e8ede.json b/.sqlx/query-b74aad6bf9e966112839fe7dac46d2de19d94efd3201874758631eb7c85e8ede.json new file mode 100644 index 0000000..fd50903 --- /dev/null +++ b/.sqlx/query-b74aad6bf9e966112839fe7dac46d2de19d94efd3201874758631eb7c85e8ede.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO variables (value, project_id, tag)\n SELECT value, $2::uuid, tag \n FROM UNNEST($1::text[], $3::text[]) AS t(value, tag)\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "TextArray", + "Uuid", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "b74aad6bf9e966112839fe7dac46d2de19d94efd3201874758631eb7c85e8ede" +} diff --git a/.sqlx/query-b7e9108e3f5c604b0a0f9eb5b3d04c47edb161cdf805421f453592d6a8cc485f.json b/.sqlx/query-b7e9108e3f5c604b0a0f9eb5b3d04c47edb161cdf805421f453592d6a8cc485f.json new file mode 100644 index 0000000..f86c635 --- /dev/null +++ b/.sqlx/query-b7e9108e3f5c604b0a0f9eb5b3d04c47edb161cdf805421f453592d6a8cc485f.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, value, project_id, created_at \n FROM variables \n WHERE project_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "value", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "b7e9108e3f5c604b0a0f9eb5b3d04c47edb161cdf805421f453592d6a8cc485f" +} diff --git a/.sqlx/query-ba22f4e90a2cc62a3254413b713fcc98e8f393d9d1ecc06dd311ee751e2d94e9.json b/.sqlx/query-ba22f4e90a2cc62a3254413b713fcc98e8f393d9d1ecc06dd311ee751e2d94e9.json new file mode 100644 index 0000000..9959bed --- /dev/null +++ b/.sqlx/query-ba22f4e90a2cc62a3254413b713fcc98e8f393d9d1ecc06dd311ee751e2d94e9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT public_key FROM users WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "public_key", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ba22f4e90a2cc62a3254413b713fcc98e8f393d9d1ecc06dd311ee751e2d94e9" +} diff --git a/.sqlx/query-c9f0b460a2be57201d1a300b181e871398d17b46d5b399a5634d3f9543dbc189.json b/.sqlx/query-c9f0b460a2be57201d1a300b181e871398d17b46d5b399a5634d3f9543dbc189.json new file mode 100644 index 0000000..64f8629 --- /dev/null +++ b/.sqlx/query-c9f0b460a2be57201d1a300b181e871398d17b46d5b399a5634d3f9543dbc189.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE project_invites \n SET invited_id = $1,\n ciphertext = NULL \n WHERE id = $2\n AND invited_id IS NULL\n AND ciphertext IS NOT NULL\n AND expires_at > NOW()\n RETURNING id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c9f0b460a2be57201d1a300b181e871398d17b46d5b399a5634d3f9543dbc189" +} diff --git a/.sqlx/query-cb65398a8ab2b4ac27a0e5a80514c4bf2fd3415dcc770ae2f367fed903404620.json b/.sqlx/query-cb65398a8ab2b4ac27a0e5a80514c4bf2fd3415dcc770ae2f367fed903404620.json new file mode 100644 index 0000000..a099f54 --- /dev/null +++ b/.sqlx/query-cb65398a8ab2b4ac27a0e5a80514c4bf2fd3415dcc770ae2f367fed903404620.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT p.id, p.name FROM projects p\n JOIN user_project_relations upr ON p.id = upr.project_id\n WHERE upr.user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "cb65398a8ab2b4ac27a0e5a80514c4bf2fd3415dcc770ae2f367fed903404620" +} diff --git a/.sqlx/query-cb8644ac85832a0f697c0160dad5833a8a708f9918c5681ce292836cb2c8a747.json b/.sqlx/query-cb8644ac85832a0f697c0160dad5833a8a708f9918c5681ce292836cb2c8a747.json new file mode 100644 index 0000000..b508308 --- /dev/null +++ b/.sqlx/query-cb8644ac85832a0f697c0160dad5833a8a708f9918c5681ce292836cb2c8a747.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT name FROM projects WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "cb8644ac85832a0f697c0160dad5833a8a708f9918c5681ce292836cb2c8a747" +} diff --git a/.sqlx/query-d398da13592cf4359d47f63a8fb6b20acca1dd237de96a3ac39a1a258143a7d4.json b/.sqlx/query-d398da13592cf4359d47f63a8fb6b20acca1dd237de96a3ac39a1a258143a7d4.json new file mode 100644 index 0000000..82b0211 --- /dev/null +++ b/.sqlx/query-d398da13592cf4359d47f63a8fb6b20acca1dd237de96a3ac39a1a258143a7d4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM projects WHERE id = ANY($1::uuid[])", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d398da13592cf4359d47f63a8fb6b20acca1dd237de96a3ac39a1a258143a7d4" +} diff --git a/.sqlx/query-d8cdfe44f5fc57f076ffa05badfc7adfb2906596571bf2bcbaa13984c3dc3f1b.json b/.sqlx/query-d8cdfe44f5fc57f076ffa05badfc7adfb2906596571bf2bcbaa13984c3dc3f1b.json new file mode 100644 index 0000000..e68a066 --- /dev/null +++ b/.sqlx/query-d8cdfe44f5fc57f076ffa05badfc7adfb2906596571bf2bcbaa13984c3dc3f1b.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT v.id, v.value, v.project_id, v.created_at\n FROM users u\n JOIN user_project_relations upr ON u.id = upr.user_id\n JOIN variables v ON upr.project_id = v.project_id\n WHERE u.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "value", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "d8cdfe44f5fc57f076ffa05badfc7adfb2906596571bf2bcbaa13984c3dc3f1b" +} diff --git a/.sqlx/query-e01cf6176b1d89cd6bdf6f6f97fec2dc395ab918830337ce33953d8727240f81.json b/.sqlx/query-e01cf6176b1d89cd6bdf6f6f97fec2dc395ab918830337ce33953d8727240f81.json new file mode 100644 index 0000000..a2ab144 --- /dev/null +++ b/.sqlx/query-e01cf6176b1d89cd6bdf6f6f97fec2dc395ab918830337ce33953d8727240f81.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n COALESCE(sum(CASE WHEN id = ANY($2::uuid[])\n THEN octet_length(value) ELSE 0 END)::bigint, 0) AS \"replaced!\",\n COALESCE(sum(octet_length(value))::bigint, 0) AS \"total!\"\n FROM variables WHERE project_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "replaced!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "total!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "UuidArray" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "e01cf6176b1d89cd6bdf6f6f97fec2dc395ab918830337ce33953d8727240f81" +} diff --git a/.sqlx/query-e5067fb159182957df05a39da468b5aa78b6f90a640841756ee697201cfd04a9.json b/.sqlx/query-e5067fb159182957df05a39da468b5aa78b6f90a640841756ee697201cfd04a9.json new file mode 100644 index 0000000..65920d5 --- /dev/null +++ b/.sqlx/query-e5067fb159182957df05a39da468b5aa78b6f90a640841756ee697201cfd04a9.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT u.id, u.username, u.created_at, u.public_key\n FROM users u\n JOIN user_project_relations upr ON u.id = upr.user_id\n WHERE upr.project_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "public_key", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "e5067fb159182957df05a39da468b5aa78b6f90a640841756ee697201cfd04a9" +} diff --git a/.sqlx/query-ed86ed45d43258e0457084c495840a2537c040667905b583c968e0ef6580dca9.json b/.sqlx/query-ed86ed45d43258e0457084c495840a2537c040667905b583c968e0ef6580dca9.json new file mode 100644 index 0000000..30ad8b4 --- /dev/null +++ b/.sqlx/query-ed86ed45d43258e0457084c495840a2537c040667905b583c968e0ef6580dca9.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n count(*) AS \"count!\",\n COALESCE(sum(octet_length(value))::bigint, 0) AS \"bytes!\"\n FROM variables WHERE project_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "bytes!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "ed86ed45d43258e0457084c495840a2537c040667905b583c968e0ef6580dca9" +} diff --git a/.sqlx/query-efe3e535d4030b7437a955bfccb7e09e733dede0607c05eed349f2cc7c75a77a.json b/.sqlx/query-efe3e535d4030b7437a955bfccb7e09e733dede0607c05eed349f2cc7c75a77a.json new file mode 100644 index 0000000..0779b53 --- /dev/null +++ b/.sqlx/query-efe3e535d4030b7437a955bfccb7e09e733dede0607c05eed349f2cc7c75a77a.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE variables AS v SET value = u.value FROM UNNEST($1::uuid[], $2::text[]) AS u(id, value) WHERE v.id = u.id RETURNING v.id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "UuidArray", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "efe3e535d4030b7437a955bfccb7e09e733dede0607c05eed349f2cc7c75a77a" +} diff --git a/.sqlx/query-f40785e5dcd70ab6666252a31d5f6c5bd0c2221dd77f6054883f489ae5f6cdf5.json b/.sqlx/query-f40785e5dcd70ab6666252a31d5f6c5bd0c2221dd77f6054883f489ae5f6cdf5.json new file mode 100644 index 0000000..313fb23 --- /dev/null +++ b/.sqlx/query-f40785e5dcd70ab6666252a31d5f6c5bd0c2221dd77f6054883f489ae5f6cdf5.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, value, project_id, created_at FROM variables WHERE project_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "value", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "f40785e5dcd70ab6666252a31d5f6c5bd0c2221dd77f6054883f489ae5f6cdf5" +} diff --git a/.sqlx/query-f9ee96f7b8a985d46d03de1af0f2b17f1ec44dc13783b989747986ea90bcf770.json b/.sqlx/query-f9ee96f7b8a985d46d03de1af0f2b17f1ec44dc13783b989747986ea90bcf770.json new file mode 100644 index 0000000..1ca32ee --- /dev/null +++ b/.sqlx/query-f9ee96f7b8a985d46d03de1af0f2b17f1ec44dc13783b989747986ea90bcf770.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, value, project_id FROM variables WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "value", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "project_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "f9ee96f7b8a985d46d03de1af0f2b17f1ec44dc13783b989747986ea90bcf770" +} diff --git a/.sqlx/query-fd14fd3094f6c4b1a06e90157c9e380465575f97e178e700f6872b5272d2ce33.json b/.sqlx/query-fd14fd3094f6c4b1a06e90157c9e380465575f97e178e700f6872b5272d2ce33.json new file mode 100644 index 0000000..0237f79 --- /dev/null +++ b/.sqlx/query-fd14fd3094f6c4b1a06e90157c9e380465575f97e178e700f6872b5272d2ce33.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, public_key FROM users WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "public_key", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "fd14fd3094f6c4b1a06e90157c9e380465575f97e178e700f6872b5272d2ce33" +} diff --git a/.sqlx/query-fd25c7ae11e0f4ed4c9144521a76820b0b91b121cfa97168f1678ab14dffc1ed.json b/.sqlx/query-fd25c7ae11e0f4ed4c9144521a76820b0b91b121cfa97168f1678ab14dffc1ed.json new file mode 100644 index 0000000..e09ba76 --- /dev/null +++ b/.sqlx/query-fd25c7ae11e0f4ed4c9144521a76820b0b91b121cfa97168f1678ab14dffc1ed.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO upload_log (ip, bytes) VALUES ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Inet", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "fd25c7ae11e0f4ed4c9144521a76820b0b91b121cfa97168f1678ab14dffc1ed" +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..fb3c69f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3737 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "bytes", + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aes-kw" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" +dependencies = [ + "aes", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" + +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", + "zeroize", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "axum" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fc6f625a1f7705c6cf62d0d070794e94668988b1c38111baeec177c715f7b" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "serde", + "tower 0.5.2", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "backtrace" +version = "0.3.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide 0.7.4", + "object", + "rustc-demangle", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bitfields" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f7b84260234ecc8ba5a13cac862569913ee84281f108e6520121de0d50ac4db" +dependencies = [ + "bitfields-impl", +] + +[[package]] +name = "bitfields-impl" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e80ed89419086de767177cc00f0a9af47c9b34646c7c6d2203cd04b2a15c672a" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "thiserror", +] + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +dependencies = [ + "serde", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "buffer-redux" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8acf87c5b9f5897cd3ebb9a327f420e0cae9dd4e5c1d2e36f2c84c571a58f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "camellia" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "cast5" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57b6a275aa2903740dc87da01c62040406b8812552e97129a63ea8850a17c6e6" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfb-mode" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" +dependencies = [ + "cipher", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets 0.52.6", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest", +] + +[[package]] +name = "commoncrypto" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d056a8586ba25a1e4d61cb090900e495952c7886786fc55f909ab2f819b69007" +dependencies = [ + "commoncrypto-sys", +] + +[[package]] +name = "commoncrypto-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fed34f46747aa73dfaa578069fd8279d2818ade2b55f38f22a9401c7f4083e2" +dependencies = [ + "libc", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e852e6dc9a5bed1fae92dd2375037bf2b768725bf3be87811edee3249d09ad" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc24" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0" + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-hash" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a77162240fd97248d19a564a565eb563a3f592b386e4136fb300909e67dddca" +dependencies = [ + "commoncrypto", + "hex 0.3.2", + "openssl", + "winapi", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cx448" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c0cf476284b03eb6c10e78787b21c7abb7d7d43cb2f02532ba6b831ed892fa" +dependencies = [ + "crypto-bigint", + "elliptic-curve", + "pkcs8", + "rand_core 0.6.4", + "serdect 0.3.0", + "sha3", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + +[[package]] +name = "der" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dsa" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" +dependencies = [ + "digest", + "num-bigint-dig", + "num-traits", + "pkcs8", + "rfc6979", + "sha2", + "signature", + "zeroize", +] + +[[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead", + "cipher", + "cmac", + "ctr", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "base64ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "serde_json", + "serdect 0.2.0", + "subtle", + "tap", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" + +[[package]] +name = "ff" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" +dependencies = [ + "bitvec", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "libz-rs-sys", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "flume" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.13.3+wasi-0.2.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gimli" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.2", +] + +[[package]] +name = "headers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" +dependencies = [ + "base64 0.21.7", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hex" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "805026a5d0141ffc30abb3be3173848ad46a1b1664fe632428479619a3644d77" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower 0.4.13", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "idea" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477" +dependencies = [ + "cipher", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +dependencies = [ + "equivalent", + "hashbrown 0.14.5", + "serde", +] + +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "js-sys" +version = "0.3.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", + "signature", +] + +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + +[[package]] +name = "libc" +version = "0.2.158" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" + +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-rs-sys" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" +dependencies = [ + "zlib-rs", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +dependencies = [ + "adler", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +dependencies = [ + "hermit-abi", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "object" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" +dependencies = [ + "memchr", +] + +[[package]] +name = "ocb3" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-sys" +version = "0.9.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70786f51bcc69f6a4c0360e063a4cac5419ef7c5cd5b3c99ad70f3be5ba79209" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pgp" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d918d5da2ce943e4c6088d7694f33f47c19374d6f0f2080a0c5e8010afdfd29" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "aes-kw", + "argon2", + "base64 0.22.1", + "bitfields", + "block-padding", + "blowfish", + "buffer-redux", + "byteorder", + "bytes", + "bzip2", + "camellia", + "cast5", + "cfb-mode", + "chrono", + "cipher", + "const-oid", + "crc24", + "curve25519-dalek", + "cx448", + "derive_builder", + "derive_more", + "des", + "digest", + "dsa", + "eax", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "flate2", + "generic-array", + "hex 0.4.3", + "hkdf", + "idea", + "k256", + "log", + "md-5", + "nom", + "num-bigint-dig", + "num-traits", + "num_enum", + "ocb3", + "p256", + "p384", + "p521", + "rand 0.8.5", + "regex", + "replace_with", + "ripemd", + "rsa", + "sha1", + "sha1-checked", + "sha2", + "sha3", + "signature", + "smallvec", + "snafu", + "twofish", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "pin-project" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy 0.7.35", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.0", + "zerocopy 0.8.17", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.0", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b08f3c9802962f7e1b25113931d94f43ed9725bebc59db9d0c3e9a23b67e15ff" +dependencies = [ + "getrandom 0.3.1", + "zerocopy 0.8.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "replace_with" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.15", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest", +] + +[[package]] +name = "rsa" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c75d7c5c6b673e58bf54d8544a9f432e3a925b0e80f7cd3602ab5c50c55519" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-embed" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa66af4a4fdd5e7ebc276f115e895611a34739a9c1c01028383d612d550953c0" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6125dbc8867951125eec87294137f4e9c2c96566e61bf72c45095a7c77761478" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5347777e9aacb56039b0e1f28785929a8a3b709e87482e7442c72e7c12529d" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a85d50532239da68e9addb745ba38ff4612a242c1c7ceea689c4bc7c2f43c36f" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" + +[[package]] +name = "rusty-api" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "axum-extra", + "base64 0.22.1", + "chrono", + "crypto-hash", + "dotenv", + "hex 0.4.3", + "pgp", + "rand 0.9.0", + "serde", + "serde_json", + "smallvec", + "sqlx", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "utoipa", + "utoipa-axum", + "utoipa-swagger-ui", + "uuid", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + +[[package]] +name = "serde" +version = "1.0.209" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99fce0ffe7310761ca6bf9faf5115afbc19688edd00171d81b1bb1b116c63e09" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.209" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8043c06d9f82bd7271361ed64f415fe5e12a77fdb52e573e7f06a516dea329ad" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest", + "sha1", + "zeroize", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +dependencies = [ + "serde", +] + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0" +dependencies = [ + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.2", + "hashlink", + "indexmap", + "ipnetwork", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex 0.4.3", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex 0.4.3", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex 0.4.3", + "hkdf", + "hmac", + "home", + "ipnetwork", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinyvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +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.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" + +[[package]] +name = "toml_edit" +version = "0.22.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "twofish" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013" +dependencies = [ + "cipher", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ea75f83c0137a9b98608359a5f1af8144876eb67bcb1ce837368e906a9f524" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utoipa" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435c6f69ef38c9017b4b4eea965dfb91e71e53d869e896db40d1cf2441dd75c0" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-axum" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c25bae5bccc842449ec0c5ddc5cbb6a3a1eaeac4503895dc105a1138f8234a0" +dependencies = [ + "axum", + "paste", + "tower-layer", + "tower-service", + "utoipa", +] + +[[package]] +name = "utoipa-gen" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77d306bc75294fd52f3e99b13ece67c02c1a2789190a6f31d32f736624326f7" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", + "uuid", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161166ec520c50144922a625d8bc4925cc801b2dda958ab69878527c0e5c5d61" +dependencies = [ + "axum", + "base64 0.22.1", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "zip", +] + +[[package]] +name = "uuid" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced87ca4be083373936a67f8de945faa23b6b42384bd5b64434850802c6dccd0" +dependencies = [ + "getrandom 0.3.1", + "serde", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.13.3+wasi-0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" + +[[package]] +name = "webpki-roots" +version = "0.26.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" +dependencies = [ + "redox_syscall", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59690dea168f2198d1a3b0cac23b8063efcd11012f10ae4698f284808c8ef603" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa91407dacce3a68c56de03abe2760159582b846c6a4acd2f456618087f12713" +dependencies = [ + "zerocopy-derive 0.8.17", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06718a168365cad3d5ff0bb133aad346959a2074bd4a85c121255a11304a8626" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae9c1ea7b3a5e1f4b922ff856a129881167511563dc219869afe3787fc0c1a45" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] diff --git a/Dockerfile b/Dockerfile index 80138c8..310a48a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,34 +1,22 @@ -FROM rust:1-bookworm AS template +FROM rust:1.98.1-bookworm AS builder -ARG DATABASE_URL - -RUN apt-get update && apt-get install -y \ - libssl-dev \ - pkg-config \ +RUN apt-get update && apt-get install -y --no-install-recommends \ + libssl-dev pkg-config \ && rm -rf /var/lib/apt/lists/* -FROM template AS migrator - -WORKDIR /app - -RUN cargo install sqlx-cli - -COPY ./migrations /app/migrations - -RUN cargo sqlx migrate run - -FROM template AS builder - WORKDIR /app - COPY . . - -RUN cargo build --release - -FROM template AS runner - +# Query metadata is generated against a disposable database and checked in. +# Building an image must never connect to, or migrate, a production database. +ENV SQLX_OFFLINE=true +RUN cargo build --release --locked + +FROM debian:bookworm-slim AS runner +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates libssl3 \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 10001 --no-create-home envx WORKDIR /app - COPY --from=builder /app/target/release/rusty-api /app/rusty-api - -CMD [ "/app/rusty-api" ] +USER envx +CMD ["/app/rusty-api"] diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..4a76e04 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,39 @@ +# Building and upgrading the API + +The Docker image builds with `SQLX_OFFLINE=true` and a committed lockfile. +It never connects to the deployment database during the build. Query metadata +in `.sqlx/` must be regenerated against a disposable PostgreSQL database after +query or schema changes (`cargo sqlx migrate run`, then `cargo sqlx prepare -- --all-targets`). +Do not use production credentials for these commands. + +At startup the API establishes a database connection and runs embedded migrations +before accepting HTTP requests. SQLx serializes concurrent migration runners with +its PostgreSQL advisory lock and applies each migration transactionally. Startup +fails if a migration fails or a recorded checksum differs. Railway's health check +only succeeds after initialization has completed. + +## Legacy invitation compatibility + +Published migration `20251025060537` adds a required verifier without a backfill. +It works on an empty invitation table but fails on populated older installations. +Its original SQL and checksum remain unchanged in the migrations directory. + +The startup migrator substitutes `migration-compat/20251025060537_invites_v2.sql` +only when that version is pending. SQLx still validates the original published +checksum for already-applied versions. The replay preserves invitation rows, +expires them, gives them an unusable verifier, and otherwise produces the original +migration's schema. Old signatures cannot be converted into the new verifiers; +users must create new invitations. As in the original migration, the obsolete +`author_signature` column is removed. + +The replay records version, operation, affected-row count, and timestamp in +`envx_migration_compatibility` in the same transaction. Its recorded SQLx checksum +is deliberately the published migration checksum, so databases upgraded by either +path have compatible migration histories. Existing upgraded databases do not +replay this operation. This is a narrowly scoped historical compatibility shim, +not permission to suppress future checksum mismatches. + +For an old populated database, start the API to upgrade; do not run plain +`cargo sqlx migrate run`, which intentionally executes the unmodified historical +migration. Fresh disposable databases can use the standard SQLx CLI for metadata +generation and test setup. diff --git a/migration-compat/20251025060537_invites_v2.sql b/migration-compat/20251025060537_invites_v2.sql new file mode 100644 index 0000000..4fe7e14 --- /dev/null +++ b/migration-compat/20251025060537_invites_v2.sql @@ -0,0 +1,27 @@ +-- Compatibility replay for the original pending migration, which cannot add a +-- NOT NULL verifier to a populated legacy table. Legacy signatures cannot be +-- converted into Argon2 verifiers, so retain those invitations as expired rows. +ALTER TABLE project_invites + ADD COLUMN verifier_argon2id TEXT NOT NULL DEFAULT 'legacy-invite-unusable'; + +CREATE TABLE IF NOT EXISTS envx_migration_compatibility ( + migration_version BIGINT PRIMARY KEY, + operation TEXT NOT NULL, + affected_rows BIGINT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +WITH expired AS ( + UPDATE project_invites + SET expires_at = LEAST(expires_at, CURRENT_TIMESTAMP) + RETURNING id +) +INSERT INTO envx_migration_compatibility (migration_version, operation, affected_rows) +SELECT 20251025060537, 'retain-and-expire-legacy-invites-v1', COUNT(*) FROM expired; + +ALTER TABLE project_invites + ALTER COLUMN verifier_argon2id DROP DEFAULT; +ALTER TABLE project_invites + ADD COLUMN ciphertext TEXT; +ALTER TABLE project_invites + DROP COLUMN author_signature; diff --git a/railway.json b/railway.json index 71db952..c8b8ac5 100644 --- a/railway.json +++ b/railway.json @@ -2,5 +2,9 @@ "$schema": "https://schema.up.railway.app/railway.schema.json", "build": { "builder": "DOCKERFILE" + }, + "deploy": { + "healthcheckPath": "/.well-known/health-check", + "healthcheckTimeout": 120 } } diff --git a/src/db.rs b/src/db.rs index 5db6739..66b22db 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,11 +1,157 @@ use anyhow::{Context, Result}; -use sqlx::postgres::PgPoolOptions; +use sqlx::{migrate::Migrator, postgres::PgPoolOptions, PgPool}; -pub async fn db() -> Result> { +pub async fn db() -> Result { let db_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; - - Ok(PgPoolOptions::new() + let pool = PgPoolOptions::new() .max_connections(5) .connect(&db_url) - .await?) + .await?; + + migrate(&pool).await?; + Ok(pool) +} + +fn startup_migrator() -> Migrator { + let mut migrator = sqlx::migrate!(); + // The published migration fails on populated legacy databases. Keep its + // checksum unchanged so already-upgraded databases still validate, but use + // a transactionally audited compatibility replay when it is still pending. + // SQLx continues to reject every mismatched applied checksum. + for migration in migrator.migrations.to_mut() { + if migration.version == 20251025060537 { + migration.sql = + include_str!("../migration-compat/20251025060537_invites_v2.sql").into(); + } + } + migrator +} + +async fn migrate(pool: &PgPool) -> Result<()> { + startup_migrator() + .run(pool) + .await + .context("database migrations failed") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::borrow::Cow; + use uuid::Uuid; + + async fn legacy_invite(pool: &PgPool) -> Uuid { + let mut legacy = sqlx::migrate!(); + legacy.migrations = Cow::Owned( + legacy + .iter() + .filter(|m| m.version < 20251025060537) + .cloned() + .collect(), + ); + legacy.run(pool).await.unwrap(); + let project: Uuid = sqlx::query_scalar("INSERT INTO projects DEFAULT VALUES RETURNING id") + .fetch_one(pool) + .await + .unwrap(); + let user: Uuid = sqlx::query_scalar( + "INSERT INTO users(username,public_key) VALUES('legacy','fixture') RETURNING id", + ) + .fetch_one(pool) + .await + .unwrap(); + sqlx::query_scalar("INSERT INTO project_invites(project_id,author_id,author_signature,expires_at) VALUES($1,$2,'legacy-signature',CURRENT_TIMESTAMP + INTERVAL '1 day') RETURNING id") + .bind(project).bind(user).fetch_one(pool).await.unwrap() + } + + #[sqlx::test(migrations = false)] + async fn startup_migrates_an_empty_database_and_is_repeatable(pool: PgPool) { + migrate(&pool).await.unwrap(); + migrate(&pool).await.unwrap(); + let table: Option = + sqlx::query_scalar("SELECT to_regclass('public.upload_log')::text") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(table.as_deref(), Some("upload_log")); + } + + #[sqlx::test(migrations = false)] + async fn startup_preserves_but_expires_legacy_invites(pool: PgPool) { + let id = legacy_invite(&pool).await; + migrate(&pool).await.unwrap(); + migrate(&pool).await.unwrap(); + let (expired, verifier): (bool, String) = sqlx::query_as("SELECT expires_at <= CURRENT_TIMESTAMP, verifier_argon2id FROM project_invites WHERE id=$1") + .bind(id).fetch_one(&pool).await.unwrap(); + assert!(expired); + assert_eq!(verifier, "legacy-invite-unusable"); + let affected: i64 = sqlx::query_scalar("SELECT affected_rows FROM envx_migration_compatibility WHERE migration_version=20251025060537") + .fetch_one(&pool).await.unwrap(); + assert_eq!(affected, 1); + let recorded: Vec = sqlx::query_scalar( + "SELECT checksum FROM _sqlx_migrations WHERE version=20251025060537", + ) + .fetch_one(&pool) + .await + .unwrap(); + let original = sqlx::migrate!(); + assert_eq!( + recorded, + original + .iter() + .find(|m| m.version == 20251025060537) + .unwrap() + .checksum + .as_ref() + ); + } + + #[sqlx::test] + async fn startup_accepts_original_migration_checksums(pool: PgPool) { + migrate(&pool).await.unwrap(); + let audit: Option = + sqlx::query_scalar("SELECT to_regclass('public.envx_migration_compatibility')::text") + .fetch_one(&pool) + .await + .unwrap(); + assert!( + audit.is_none(), + "applied original migrations must not be replayed" + ); + } + + #[sqlx::test(migrations = false)] + async fn failed_compatibility_replay_rolls_back_rows_and_schema(pool: PgPool) { + let id = legacy_invite(&pool).await; + let mut broken = startup_migrator(); + let migration = broken + .migrations + .to_mut() + .iter_mut() + .find(|m| m.version == 20251025060537) + .unwrap(); + migration.sql = format!("{}\nSELECT 1 / 0;", migration.sql).into(); + assert!(broken.run(&pool).await.is_err()); + let (signature, valid): (String, bool) = sqlx::query_as("SELECT author_signature, expires_at > CURRENT_TIMESTAMP FROM project_invites WHERE id=$1") + .bind(id).fetch_one(&pool).await.unwrap(); + assert_eq!(signature, "legacy-signature"); + assert!(valid); + let applied: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version=20251025060537)", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(!applied); + // Reopening after an interrupted transaction can safely finish migration. + migrate(&pool).await.unwrap(); + } + + #[sqlx::test] + async fn startup_rejects_changed_historical_checksums(pool: PgPool) { + sqlx::query("UPDATE _sqlx_migrations SET checksum = decode('00','hex') WHERE version=20251025060537") + .execute(&pool).await.unwrap(); + let error = migrate(&pool).await.unwrap_err(); + assert!(format!("{error:#}").contains("modified")); + } } From 30fffa866921f4efb89b18fffb4989e7d64462e2 Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Thu, 24 Sep 2026 04:31:24 -0700 Subject: [PATCH 04/12] [agent] harden API credentials proxy trust and project deletion Co-Authored-By: GPT-6 Astra (OpenAI) --- README.md | 9 +++ src/config.rs | 22 ++++++- src/extractors/client_ip.rs | 77 ++++++++++++---------- src/extractors/user.rs | 102 +++++++++++++++++++---------- src/helpers/mod.rs | 1 + src/helpers/registration.rs | 27 ++++++++ src/routes/user.rs | 9 +-- src/routes/v2/project/delete.rs | 112 ++++++++++++++++++++++++++++++++ src/routes/v2/project/mod.rs | 2 + src/routes/v2/user/new.rs | 9 +-- 10 files changed, 282 insertions(+), 88 deletions(-) create mode 100644 src/helpers/registration.rs create mode 100644 src/routes/v2/project/delete.rs diff --git a/README.md b/README.md index fd80a84..7c8e3ba 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,12 @@ ```bash make generate ``` + +### Reverse proxy trust + +By default rate limits use the connection's peer IP and ignore forwarded headers. +Set `ENVX_TRUST_PROXY=true` only when all incoming requests pass through a trusted +proxy that overwrites `X-Real-IP`, and untrusted callers cannot connect directly. +This uses a single valid `X-Real-IP`; `X-Forwarded-For` is never trusted. Railway's +HTTP ingress provides `X-Real-IP` (see its public networking specs). Self-hosted +operators must configure their ingress accordingly before enabling this option. diff --git a/src/config.rs b/src/config.rs index b0de36f..ee8febf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,8 +43,24 @@ fn env_u64(name: &str, default: u64) -> u64 { } fn env_i64(name: &str, default: i64) -> i64 { - env::var(name) - .ok() - .and_then(|v| v.parse().ok()) + nonnegative_cap(env::var(name).ok().as_deref(), default) +} + +fn nonnegative_cap(value: Option<&str>, default: i64) -> i64 { + value + .and_then(|v| v.parse::().ok()) + .filter(|v| *v >= 0) .unwrap_or(default) } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn negative_or_invalid_caps_do_not_disable_limits() { + assert_eq!(nonnegative_cap(Some("-1"), 256), 256); + assert_eq!(nonnegative_cap(Some("invalid"), 256), 256); + assert_eq!(nonnegative_cap(Some("0"), 256), 0); + assert_eq!(nonnegative_cap(Some("42"), 256), 42); + } +} diff --git a/src/extractors/client_ip.rs b/src/extractors/client_ip.rs index f39164c..9553575 100644 --- a/src/extractors/client_ip.rs +++ b/src/extractors/client_ip.rs @@ -1,44 +1,55 @@ -//! Extract the client IP, honoring X-Forwarded-For when behind a proxy -//! (Railway sets this). Falls back to peer SocketAddr. - -use std::net::{IpAddr, Ipv4Addr}; - +//! Forwarding headers are trusted only when explicitly enabled by the operator. +use crate::error::AppError; use axum::{ extract::{ConnectInfo, FromRequestParts}, - http::request::Parts, + http::{request::Parts, HeaderMap}, }; - -use crate::error::AppError; +use std::net::{IpAddr, Ipv4Addr}; pub struct ClientIp(pub IpAddr); -impl FromRequestParts for ClientIp -where - S: Send + Sync, -{ - type Rejection = AppError; - - async fn from_request_parts(parts: &mut Parts, _s: &S) -> Result { - if let Some(value) = parts.headers.get("x-forwarded-for") { - if let Ok(s) = value.to_str() { - // X-Forwarded-For can be comma-separated; the leftmost entry - // is the original client. - if let Some(first) = s.split(',').next() { - if let Ok(ip) = first.trim().parse::() { - return Ok(ClientIp(ip)); - } - } - } - } - - if let Some(ConnectInfo(addr)) = parts.extensions.get::>() +fn client_ip(headers: &HeaderMap, peer: IpAddr, trust_proxy: bool) -> IpAddr { + if trust_proxy && headers.get_all("x-real-ip").iter().count() == 1 { + if let Some(ip) = headers + .get("x-real-ip") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()) { - return Ok(ClientIp(addr.ip())); + return ip; } + } + peer +} + +impl FromRequestParts for ClientIp { + type Rejection = AppError; + async fn from_request_parts(parts: &mut Parts, _s: &S) -> Result { + let peer = parts + .extensions + .get::>() + .map(|addr| addr.0.ip()) + .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)); + let trust_proxy = std::env::var("ENVX_TRUST_PROXY").as_deref() == Ok("true"); + Ok(ClientIp(client_ip(&parts.headers, peer, trust_proxy))) + } +} - // Fall back to localhost rather than 500 — IP-based rate limiting - // is best-effort; we don't want to break the request entirely if - // we can't identify the client. - Ok(ClientIp(IpAddr::V4(Ipv4Addr::LOCALHOST))) +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn forwarding_requires_explicit_trust_and_single_ip() { + let peer = "127.0.0.1".parse().unwrap(); + let remote: IpAddr = "192.0.2.1".parse().unwrap(); + let mut h = HeaderMap::new(); + h.insert("x-forwarded-for", "203.0.113.1, 192.0.2.1".parse().unwrap()); + assert_eq!(client_ip(&h, peer, true), peer); + h.insert("x-real-ip", "192.0.2.1".parse().unwrap()); + assert_eq!(client_ip(&h, peer, false), peer); + assert_eq!(client_ip(&h, peer, true), remote); + h.append("x-real-ip", "203.0.113.1".parse().unwrap()); + assert_eq!(client_ip(&h, peer, true), peer); + h.insert("x-real-ip", "192.0.2.1, 203.0.113.1".parse().unwrap()); + assert_eq!(client_ip(&h, peer, true), peer); } } diff --git a/src/extractors/user.rs b/src/extractors/user.rs index 70f83e5..ff53daf 100644 --- a/src/extractors/user.rs +++ b/src/extractors/user.rs @@ -44,7 +44,8 @@ where match auth_header { Some(auth_header) => { let auth_header = auth_header.to_str().unwrap_or(""); - let auth_token = auth_header.trim_start_matches("Bearer "); + let auth_token = + bearer_payload(auth_header).ok_or(AppError::Error(Errors::Unauthorized))?; let formatted_token = match serde_json::from_str::(auth_token) { Ok(formatted_token) => formatted_token, @@ -62,39 +63,10 @@ where let user_id = match validate_challenge(auth_token, state.db).await { Ok(user_id) => user_id, Err(e) => match e { - ChallengeError::InvalidChallenge => { - return Err((StatusCode::BAD_REQUEST, "Invalid challenge").into()) - } - ChallengeError::InvalidSignature => { - return Err((StatusCode::UNAUTHORIZED, "Invalid signature").into()) - } - ChallengeError::TooOld => { - return Err((StatusCode::UNAUTHORIZED, "Too old").into()) - } - ChallengeError::TooYoung => { - return Err((StatusCode::UNAUTHORIZED, "Too young").into()) - } - ChallengeError::ChronoParseError(e) => { - return Err(AppError::Error(Errors::InternalServerError(e.into()))) - } - ChallengeError::PgpError(e) => { - return Err(AppError::Error(Errors::InternalServerError(e.into()))) - } - ChallengeError::SqlxError(e) => { - return Err(AppError::Error(Errors::InternalServerError(e.into()))) - } - ChallengeError::Utf8Error(e) => { - return Err(AppError::Error(Errors::InternalServerError(e.into()))) - } - ChallengeError::UuidError(e) => { - return Err(AppError::Error(Errors::InternalServerError(e.into()))) - } - ChallengeError::Generic(e) => { - return Err(AppError::Error(Errors::InternalServerError(e))) - } - ChallengeError::IoError(error) => { - return Err(AppError::Error(Errors::InternalServerError(error.into()))) + ChallengeError::SqlxError(e) if !matches!(e, sqlx::Error::RowNotFound) => { + return Err(AppError::Error(Errors::SqlxError(e))); } + _ => return Err((StatusCode::UNAUTHORIZED, "Invalid credentials").into()), }, }; @@ -105,6 +77,7 @@ where } } +#[allow(dead_code)] enum ChallengeError { InvalidChallenge, InvalidSignature, @@ -176,10 +149,10 @@ async fn validate_challenge(challenge: &str, db: DB) -> Result 10 * 60 { - return Err(ChallengeError::TooOld)?; + return Err(ChallengeError::TooOld); } if diff.num_seconds() < 0 { - return Err(ChallengeError::TooYoung)?; + return Err(ChallengeError::TooYoung); } let user_pubkey = sqlx::query!("SELECT public_key FROM users WHERE id = $1", user_id) @@ -192,7 +165,7 @@ async fn validate_challenge(challenge: &str, db: DB) -> Result Option<&str> { + let payload = value.strip_prefix("Bearer ")?; + Some(payload.strip_prefix("Bearer ").unwrap_or(payload)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn malformed_credentials_return_unauthorized_without_database_access() { + use axum::{http::Request, response::IntoResponse}; + use std::sync::Arc; + let state = AppState { + db: Arc::new( + sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://invalid/unused") + .unwrap(), + ), + caps: Arc::new(crate::config::Caps::from_env()), + }; + for value in [ + "{}".to_owned(), + "Bearer not-json".to_owned(), + r#"Bearer {"token":"not-a-uuid","signature":"not-pgp"}"#.to_owned(), + format!( + r#"Bearer {{"token":"{}","signature":"not-pgp"}}"#, + Uuid::new_v4() + ), + ] { + let (mut parts, _) = Request::builder() + .header(header::AUTHORIZATION, value) + .body(()) + .unwrap() + .into_parts(); + let result = UserId::from_request_parts(&mut parts, &state).await; + match result { + Err(error) => assert_eq!(error.into_response().status(), StatusCode::UNAUTHORIZED), + Ok(_) => panic!("malformed credentials accepted"), + } + } + } + + #[test] + fn bearer_scheme_required_with_legacy_compatibility() { + assert_eq!(bearer_payload("Bearer {}"), Some("{}")); + assert_eq!(bearer_payload("Bearer Bearer {}"), Some("{}")); + assert_eq!(bearer_payload("{}"), None); + assert_eq!(bearer_payload("Basic {}"), None); + assert!( + serde_json::from_str::(bearer_payload("Bearer Bearer Bearer {}").unwrap()) + .is_err() + ); + } +} diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index f08e1b6..f3bca7e 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -1,3 +1,4 @@ pub mod caps; pub mod project; pub mod variables; +pub mod registration; diff --git a/src/helpers/registration.rs b/src/helpers/registration.rs new file mode 100644 index 0000000..98a1ae7 --- /dev/null +++ b/src/helpers/registration.rs @@ -0,0 +1,27 @@ +use crate::error::{AppError, Errors}; +use pgp::composed::{Deserializable, SignedPublicKey}; + +pub fn validate_public_key(value: &str) -> Result<(), AppError> { + if value.len() > 128 * 1024 { + return Err(( + axum::http::StatusCode::PAYLOAD_TOO_LARGE, + "Public key exceeds 128 KiB", + ) + .into()); + } + let (key, _) = SignedPublicKey::from_string(value) + .map_err(|_| AppError::Error(Errors::InvalidPublicKey))?; + key.verify() + .map_err(|_| AppError::Error(Errors::InvalidPublicKey))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_invalid_and_oversized_keys() { + assert!(validate_public_key("not a key").is_err()); + assert!(validate_public_key(&"x".repeat(128 * 1024 + 1)).is_err()); + } +} diff --git a/src/routes/user.rs b/src/routes/user.rs index a41c473..cb6368c 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -4,7 +4,6 @@ use crate::{ *, }; use axum::extract::Path; -use pgp::composed::{Deserializable, SignedPublicKey}; use uuid::Uuid; #[derive(Serialize, Deserialize)] @@ -17,13 +16,7 @@ pub async fn new_user( State(state): State, Json(body): Json, ) -> Result { - // public key validation - match SignedPublicKey::from_string(&body.public_key) { - Ok(_) => {} - Err(_) => { - return Err(AppError::Error(Errors::InvalidPublicKey)); - } - } + crate::helpers::registration::validate_public_key(&body.public_key)?; let user = sqlx::query!( "INSERT INTO users (username, public_key) VALUES ($1, $2) RETURNING id", diff --git a/src/routes/v2/project/delete.rs b/src/routes/v2/project/delete.rs new file mode 100644 index 0000000..6e59e38 --- /dev/null +++ b/src/routes/v2/project/delete.rs @@ -0,0 +1,112 @@ +use super::*; +use uuid::Uuid; + +#[utoipa::path( + delete, path = "/{project_id}/delete", tag = PROJECT_TAG, + security(("bearer" = [])), + responses((status = 200, description = "Project deleted"), + (status = 403, description = "Not a project member"), + (status = 409, description = "Shared projects cannot be deleted")) +)] +pub async fn delete_project( + State(state): State, + UserId(user_id): UserId, + Path(project_id): Path, +) -> Result<(), AppError> { + let mut tx = state.db.begin().await?; + sqlx::query("SELECT id FROM projects WHERE id = $1 FOR UPDATE") + .bind(project_id) + .fetch_optional(&mut *tx) + .await?; + let members: Vec = + sqlx::query_scalar("SELECT user_id FROM user_project_relations WHERE project_id = $1") + .bind(project_id) + .fetch_all(&mut *tx) + .await?; + if !members.contains(&user_id) { + return Err((axum::http::StatusCode::FORBIDDEN, "Not a project member").into()); + } + if members.len() != 1 { + return Err(( + axum::http::StatusCode::CONFLICT, + "Shared projects cannot be deleted; you must be the sole remaining member", + ) + .into()); + } + sqlx::query("DELETE FROM variables WHERE project_id = $1") + .bind(project_id) + .execute(&mut *tx) + .await?; + // Memberships and outstanding invitations cascade with the project. + sqlx::query("DELETE FROM projects WHERE id = $1") + .bind(project_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + #[sqlx::test] + async fn deletes_only_sole_member_project(pool: sqlx::PgPool) { + let user: Uuid = sqlx::query_scalar( + "INSERT INTO users(username, public_key) VALUES('a','a') RETURNING id", + ) + .fetch_one(&pool) + .await + .unwrap(); + let other: Uuid = sqlx::query_scalar( + "INSERT INTO users(username, public_key) VALUES('b','b') RETURNING id", + ) + .fetch_one(&pool) + .await + .unwrap(); + let project: Uuid = sqlx::query_scalar("INSERT INTO projects DEFAULT VALUES RETURNING id") + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO user_project_relations(user_id,project_id) VALUES($1,$2),($3,$2)") + .bind(user) + .bind(project) + .bind(other) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO variables(value,project_id) VALUES('ciphertext',$1)") + .bind(project) + .execute(&pool) + .await + .unwrap(); + let state = AppState { + db: Arc::new(pool.clone()), + caps: Arc::new(crate::config::Caps::from_env()), + }; + assert!( + delete_project(State(state.clone()), UserId(Uuid::new_v4()), Path(project)) + .await + .is_err() + ); + assert!( + delete_project(State(state.clone()), UserId(user), Path(project)) + .await + .is_err() + ); + sqlx::query("DELETE FROM user_project_relations WHERE user_id=$1") + .bind(other) + .execute(&pool) + .await + .unwrap(); + assert!(delete_project(State(state), UserId(user), Path(project)) + .await + .is_ok()); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM variables WHERE project_id=$1") + .bind(project) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); + } +} diff --git a/src/routes/v2/project/mod.rs b/src/routes/v2/project/mod.rs index d7f7441..c36b78b 100644 --- a/src/routes/v2/project/mod.rs +++ b/src/routes/v2/project/mod.rs @@ -5,6 +5,7 @@ use utoipa::ToSchema; mod add_user; +mod delete; mod info; mod remove_users; mod update; @@ -15,6 +16,7 @@ pub const PROJECT_TAG: &str = "project"; pub fn router(state: AppState) -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(info::get_project_info_v2)) + .routes(routes!(delete::delete_project)) .routes(routes!(update::update)) .routes(routes!(add_user::add_user)) .routes(routes!(remove_users::remove_users)) diff --git a/src/routes/v2/user/new.rs b/src/routes/v2/user/new.rs index 904fc20..93fa0a1 100644 --- a/src/routes/v2/user/new.rs +++ b/src/routes/v2/user/new.rs @@ -1,5 +1,4 @@ use super::*; -use pgp::composed::{Deserializable, SignedPublicKey}; #[derive(Serialize, Deserialize, ToSchema)] pub struct NewUserBody { @@ -24,13 +23,7 @@ pub async fn new_user_v2( State(state): State, Json(body): Json, ) -> Result { - // public key validation - match SignedPublicKey::from_string(&body.public_key) { - Ok(_) => {} - Err(_) => { - return Err(AppError::Error(Errors::InvalidPublicKey)); - } - } + crate::helpers::registration::validate_public_key(&body.public_key)?; let user = sqlx::query!( "INSERT INTO users (username, public_key) VALUES ($1, $2) RETURNING id", From 1f47ac506e0ae8ebfeccef8c5c7fd74f6070bc83 Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Thu, 24 Sep 2026 04:32:59 -0700 Subject: [PATCH 05/12] [agent] fix: make OpenAPI file export explicit Keep normal API startup compatible with nonroot read-only containers. Export a schema file only when ENVX_OPENAPI_OUTPUT names a destination; continue serving the schema over HTTP. Co-Authored-By: GPT-6 (OpenAI) --- docs/deployment.md | 13 +++++++++++++ src/main.rs | 9 ++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/deployment.md b/docs/deployment.md index 4a76e04..791d192 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -37,3 +37,16 @@ For an old populated database, start the API to upgrade; do not run plain `cargo sqlx migrate run`, which intentionally executes the unmodified historical migration. Fresh disposable databases can use the standard SQLx CLI for metadata generation and test setup. + +## OpenAPI export + +Normal startup does not write files and supports a read-only container filesystem. +The schema remains available over HTTP at `/docs/openapi.json`. For SDK generation, +start a local API against a disposable database with an explicit output path: + +```sh +ENVX_OPENAPI_OUTPUT=./openapi.json cargo run --locked +``` + +Alternatively, fetch `/docs/openapi.json` from that running local API. An explicitly +requested export fails startup if its destination cannot be written. diff --git a/src/main.rs b/src/main.rs index 37089b0..2d90a54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -148,7 +148,14 @@ async fn init_router() -> anyhow::Result { .with_state(state) .split_for_parts(); - std::fs::write("./openapi.json", api.to_json()?)?; + if let Some(path) = std::env::var_os("ENVX_OPENAPI_OUTPUT") { + std::fs::write(&path, api.to_json()?).with_context(|| { + format!( + "could not export OpenAPI schema to {}", + std::path::Path::new(&path).display() + ) + })?; + } let router = router.merge(SwaggerUi::new("/docs").url("/docs/openapi.json", api)); From f9e317760d7caee40de55b774edc4277bd848d2c Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Thu, 24 Sep 2026 04:34:38 -0700 Subject: [PATCH 06/12] [agent] fix: bound message snapshots and order inbox history Count canonical verified key snapshots against mailbox storage and erase them with expired or fully deleted ciphertext. Return keys only on message detail. Paginate links and messages newest first using owned timestamp-and-ID cursors; cover ties, unrelated cursors, padded armor, snapshot quotas and erasure. Co-Authored-By: GPT-6 (OpenAI) --- docs/social-api.md | 23 ++-- src/routes/v2/social/links.rs | 16 ++- src/routes/v2/social/messages.rs | 69 +++++++++-- src/routes/v2/social/tests.rs | 192 +++++++++++++++++++++++++++++++ 4 files changed, 279 insertions(+), 21 deletions(-) diff --git a/docs/social-api.md b/docs/social-api.md index 0a68447..ee7caba 100644 --- a/docs/social-api.md +++ b/docs/social-api.md @@ -51,23 +51,28 @@ is an `Identity`. Timestamps are RFC3339. - `GET /messages`: both incoming and outgoing visible messages, metadata only. - `GET /messages/{id}`: participant-only metadata plus ciphertext. - `DELETE /messages/{id}`: delete the caller's mailbox copy, 204. Once both copies - are deleted, ciphertext is erased; minimal retry state remains. + are deleted, ciphertext and public-key snapshots are erased; minimal retry state remains. Message fields: `{id, sender_id, recipient_id, created_at, expires_at, sender_public_key, recipient_public_key, ciphertext}`. Send and list responses -set ciphertext to null. Keys are snapshotted at send time. Never trust a server +set ciphertext to null and public-key fields to empty strings. Full keys are +returned only by the message detail endpoint. Keys are verified and canonically +armored at send time, discarding arbitrary armor headers. Legacy stored keys +larger than 1 MiB and canonical keys larger than 128 KiB are rejected when +sending; this intentionally bounds previously uncapped registrations. Never trust a server snapshot in place of a locally pinned fingerprint. Expiry makes both copies inaccessible immediately. A bounded cleanup during -subsequent sends erases up to 1,000 expired ciphertexts. This is request-driven +subsequent sends erases up to 1,000 expired ciphertexts and their key snapshots. This is request-driven cleanup, not a promise of physical erasure at the expiry instant; backups may also retain ciphertext. Database maintenance can clear the remaining expired ciphertext independently. The message ID and digest remain tombstones so retries cannot resurrect messages. -All three lists accept `limit` (1–100, default 50) and `before` (UUID). They sort -by UUID descending; pass the last item's ID as the next cursor (friend user ID -for the friends list). Ordering is stable but deliberately not chronological. +All three lists accept `limit` (1–100, default 50) and `before` (UUID). Messages and links sort +newest first, with UUID descending breaking creation-time ties. Pass the last +item's ID as the next cursor; cursors must belong to the caller. The friends list +sorts by friend UUID descending and uses the friend user ID as its cursor. ## Limits and concurrency @@ -75,11 +80,13 @@ for the friends list). Ordering is stable but deliberately not chronological. - 1,000 preview/redemption attempts per account per UTC day, including failures. - 1,000 mutual friends per account. - 128 KiB ciphertext per message; 1,000 sends per account per UTC day. -- 1,000 live messages and 20 MiB ciphertext per participant's mailbox. +- 1,000 live messages and 20 MiB of ciphertext plus both canonical key snapshots + per participant's mailbox. Daily counters are independent of message deletion. User rows are locked in UUID order before friendship mutations or quota-sensitive writes. PostgreSQL tests cover claim races, retry recovery, target/signature/token rejection, revocation, -expiry, authorization, deletion, pagination, send-rate and mailbox-capacity races. +expiry, authorization, deletion, pagination, send-rate and mailbox-capacity races, creation-time pagination ties, unrelated cursors, and +legacy padded-key canonicalization/accounting/cleanup. Run them only against a disposable PostgreSQL instance; `sqlx::test` creates isolated test databases and applies migrations automatically. diff --git a/src/routes/v2/social/links.rs b/src/routes/v2/social/links.rs index dfff581..7321705 100644 --- a/src/routes/v2/social/links.rs +++ b/src/routes/v2/social/links.rs @@ -167,8 +167,20 @@ pub(super) async fn list( UserId(user): UserId, Query(page): Query, ) -> Result>, AppError> { - let rows:Vec=sqlx::query_as("SELECT * FROM friend_links WHERE creator_id=$1 AND ($2::uuid IS NULL OR id<$2) ORDER BY id DESC LIMIT $3") - .bind(user).bind(page.before).bind(page.limit()?).fetch_all(&*state.db).await?; + let limit = page.limit()?; + let before_time: Option> = match page.before { + Some(id) => Some( + sqlx::query_scalar("SELECT created_at FROM friend_links WHERE id=$1 AND creator_id=$2") + .bind(id) + .bind(user) + .fetch_optional(&*state.db) + .await? + .ok_or_else(not_found)?, + ), + None => None, + }; + let rows:Vec=sqlx::query_as("SELECT * FROM friend_links WHERE creator_id=$1 AND ($2::timestamptz IS NULL OR (created_at,id)<($2,$3)) ORDER BY created_at DESC,id DESC LIMIT $4") + .bind(user).bind(before_time).bind(page.before).bind(limit).fetch_all(&*state.db).await?; let mut result = Vec::new(); for row in rows { result.push(row.public(&state.db).await?); diff --git a/src/routes/v2/social/messages.rs b/src/routes/v2/social/messages.rs index 3ee7ec9..1d3ac14 100644 --- a/src/routes/v2/social/messages.rs +++ b/src/routes/v2/social/messages.rs @@ -1,4 +1,5 @@ use super::*; +use pgp::composed::ArmorOptions; pub(super) fn router() -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(send, list)) @@ -44,8 +45,16 @@ impl StoredMessage { recipient_id: self.recipient_id, created_at: self.created_at, expires_at: self.expires_at, - sender_public_key: self.sender_public_key, - recipient_public_key: self.recipient_public_key, + sender_public_key: if include_ciphertext { + self.sender_public_key + } else { + String::new() + }, + recipient_public_key: if include_ciphertext { + self.recipient_public_key + } else { + String::new() + }, ciphertext: if include_ciphertext { self.ciphertext } else { @@ -64,8 +73,12 @@ impl StoredMessage { pub(super) async fn send( State(state): State, UserId(user): UserId, - Json(body): Json, + Json(mut body): Json, ) -> Result, AppError> { + // PostgreSQL timestamps have microsecond precision; normalize before storage. + body.expires_at = body + .expires_at + .and_then(|at| DateTime::from_timestamp_micros(at.timestamp_micros())); if body.recipient_id == user { return Err(bad("Cannot send to yourself")); } @@ -77,7 +90,7 @@ pub(super) async fn send( .into()); } // Opportunistic bounded cleanup; expiry access checks do not depend on cleanup. - sqlx::query("WITH expired AS (SELECT id FROM secret_messages WHERE expires_at<=now() AND ciphertext IS NOT NULL ORDER BY expires_at LIMIT 1000 FOR UPDATE SKIP LOCKED) UPDATE secret_messages SET ciphertext=NULL FROM expired WHERE secret_messages.id=expired.id") + sqlx::query("WITH expired AS (SELECT id FROM secret_messages WHERE expires_at<=now() AND ciphertext IS NOT NULL ORDER BY expires_at LIMIT 1000 FOR UPDATE SKIP LOCKED) UPDATE secret_messages SET ciphertext=NULL,sender_public_key='',recipient_public_key='' FROM expired WHERE secret_messages.id=expired.id") .execute(&*state.db).await?; let mut tx = state.db.begin().await?; lock_users(&mut tx, user, body.recipient_id).await?; @@ -106,16 +119,27 @@ pub(super) async fn send( if !friends { return Err((StatusCode::FORBIDDEN, "Recipient must be a friend").into()); } + let mut keys = Vec::new(); + for account in [user, body.recipient_id] { + // Legacy registrations predate key-size caps. Do not parse unbounded armor. + let raw: Option = sqlx::query_scalar("SELECT CASE WHEN octet_length(public_key)<=1048576 THEN public_key ELSE NULL END FROM users WHERE id=$1") + .bind(account).fetch_one(&mut *tx).await?; + keys.push(canonical_key( + raw.as_deref() + .ok_or_else(|| bad("Stored public key exceeds size limit"))?, + )?); + } + let message_bytes = (body.ciphertext.len() + keys[0].len() + keys[1].len()) as i64; rate(&mut tx, user, "message-send", 1000).await?; for account in [user, body.recipient_id] { - let (count,bytes):(i64,i64)=sqlx::query_as("SELECT count(*),COALESCE(sum(octet_length(ciphertext)),0)::bigint FROM secret_messages WHERE ((sender_id=$1 AND NOT sender_deleted) OR (recipient_id=$1 AND NOT recipient_deleted)) AND (expires_at IS NULL OR expires_at>now()) AND ciphertext IS NOT NULL") + let (count,bytes):(i64,i64)=sqlx::query_as("SELECT count(*),COALESCE(sum(octet_length(ciphertext)+octet_length(sender_public_key)+octet_length(recipient_public_key)),0)::bigint FROM secret_messages WHERE ((sender_id=$1 AND NOT sender_deleted) OR (recipient_id=$1 AND NOT recipient_deleted)) AND (expires_at IS NULL OR expires_at>now()) AND ciphertext IS NOT NULL") .bind(account).fetch_one(&mut *tx).await?; - if count >= 1000 || bytes + body.ciphertext.len() as i64 > 20 * 1024 * 1024 { + if count >= 1000 || bytes + message_bytes > 20 * 1024 * 1024 { return Err((StatusCode::TOO_MANY_REQUESTS, "Mailbox quota exceeded").into()); } } - let row:StoredMessage=sqlx::query_as("INSERT INTO secret_messages(id,sender_id,recipient_id,ciphertext,ciphertext_hash,sender_public_key,recipient_public_key,expires_at) VALUES($1,$2,$3,$4,$5,(SELECT public_key FROM users WHERE id=$2),(SELECT public_key FROM users WHERE id=$3),$6) RETURNING *") - .bind(body.id).bind(user).bind(body.recipient_id).bind(&body.ciphertext).bind(hash(&body.ciphertext)).bind(body.expires_at).fetch_one(&mut *tx).await?; + let row:StoredMessage=sqlx::query_as("INSERT INTO secret_messages(id,sender_id,recipient_id,ciphertext,ciphertext_hash,sender_public_key,recipient_public_key,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *") + .bind(body.id).bind(user).bind(body.recipient_id).bind(&body.ciphertext).bind(hash(&body.ciphertext)).bind(&keys[0]).bind(&keys[1]).bind(body.expires_at).fetch_one(&mut *tx).await?; tx.commit().await?; Ok(Json(row.public(false))) } @@ -125,8 +149,16 @@ pub(super) async fn list( UserId(user): UserId, Query(page): Query, ) -> Result>, AppError> { - let rows:Vec=sqlx::query_as("SELECT id,sender_id,recipient_id,created_at,expires_at,sender_public_key,recipient_public_key,NULL::text AS ciphertext FROM secret_messages WHERE ((sender_id=$1 AND NOT sender_deleted) OR (recipient_id=$1 AND NOT recipient_deleted)) AND (expires_at IS NULL OR expires_at>now()) AND ciphertext IS NOT NULL AND ($2::uuid IS NULL OR id<$2) ORDER BY id DESC LIMIT $3") - .bind(user).bind(page.before).bind(page.limit()?).fetch_all(&*state.db).await?; + let limit = page.limit()?; + // A previously visible cursor remains usable after deletion or expiry, but + // another account's message cannot be used to probe its creation time. + let before_time: Option> = match page.before { + Some(id) => Some(sqlx::query_scalar("SELECT created_at FROM secret_messages WHERE id=$1 AND (sender_id=$2 OR recipient_id=$2)") + .bind(id).bind(user).fetch_optional(&*state.db).await?.ok_or_else(not_found)?), + None => None, + }; + let rows:Vec=sqlx::query_as("SELECT id,sender_id,recipient_id,created_at,expires_at,''::text AS sender_public_key,''::text AS recipient_public_key,NULL::text AS ciphertext FROM secret_messages WHERE ((sender_id=$1 AND NOT sender_deleted) OR (recipient_id=$1 AND NOT recipient_deleted)) AND (expires_at IS NULL OR expires_at>now()) AND ciphertext IS NOT NULL AND ($2::timestamptz IS NULL OR (created_at,id)<($2,$3)) ORDER BY created_at DESC,id DESC LIMIT $4") + .bind(user).bind(before_time).bind(page.before).bind(limit).fetch_all(&*state.db).await?; Ok(Json(rows)) } #[utoipa::path(get,path="/messages/{id}",tag="messages",params(("id"=Uuid,Path)),responses((status=200,body=SecretMessage)),security(("bearer"=[])))] @@ -154,10 +186,25 @@ pub(super) async fn delete( UserId(user): UserId, Path(id): Path, ) -> Result { - let result=sqlx::query("UPDATE secret_messages SET sender_deleted=sender_deleted OR sender_id=$2,recipient_deleted=recipient_deleted OR recipient_id=$2,ciphertext=CASE WHEN (sender_deleted OR sender_id=$2) AND (recipient_deleted OR recipient_id=$2) THEN NULL ELSE ciphertext END WHERE id=$1 AND (sender_id=$2 OR recipient_id=$2)") + let result=sqlx::query("UPDATE secret_messages SET sender_deleted=sender_deleted OR sender_id=$2,recipient_deleted=recipient_deleted OR recipient_id=$2,ciphertext=CASE WHEN (sender_deleted OR sender_id=$2) AND (recipient_deleted OR recipient_id=$2) THEN NULL ELSE ciphertext END,sender_public_key=CASE WHEN (sender_deleted OR sender_id=$2) AND (recipient_deleted OR recipient_id=$2) THEN '' ELSE sender_public_key END,recipient_public_key=CASE WHEN (sender_deleted OR sender_id=$2) AND (recipient_deleted OR recipient_id=$2) THEN '' ELSE recipient_public_key END WHERE id=$1 AND (sender_id=$2 OR recipient_id=$2)") .bind(id).bind(user).execute(&*state.db).await?; if result.rows_affected() == 0 { return Err(not_found()); } Ok(StatusCode::NO_CONTENT) } + +fn canonical_key(raw: &str) -> Result { + let key = SignedPublicKey::from_string(raw) + .map_err(|_| bad("Invalid stored public key"))? + .0; + key.verify() + .map_err(|_| bad("Invalid stored public key signatures"))?; + let canonical = key + .to_armored_string(ArmorOptions::default()) + .map_err(|_| bad("Invalid stored public key"))?; + if canonical.len() > 128 * 1024 { + return Err(bad("Canonical public key exceeds size limit")); + } + Ok(canonical) +} diff --git a/src/routes/v2/social/tests.rs b/src/routes/v2/social/tests.rs index fc51c62..39e8dfe 100644 --- a/src/routes/v2/social/tests.rs +++ b/src/routes/v2/social/tests.rs @@ -401,3 +401,195 @@ async fn mailbox_capacity_serializes_and_precise_expiry_retries(pool: sqlx::PgPo .unwrap(); assert_eq!(count, 1000); } + +#[sqlx::test] +async fn histories_are_chronological_with_owned_cursors_and_ties(pool: sqlx::PgPool) { + let (a, _) = user(&pool).await; + let (b, bk) = user(&pool).await; + let (outsider, _) = user(&pool).await; + befriend(&pool, a, b, &bk).await; + let ids = [Uuid::from_u128(1), Uuid::from_u128(2), Uuid::from_u128(3)]; + for id in ids { + let _ = ok(messages::send(State(state(pool.clone())), UserId(a), Json(body(id, b))).await); + } + sqlx::query("UPDATE secret_messages SET created_at='2026-01-01T00:00:00Z'") + .execute(&pool) + .await + .unwrap(); + sqlx::query("UPDATE secret_messages SET created_at='2026-01-02T00:00:00Z' WHERE id=$1") + .bind(ids[0]) + .execute(&pool) + .await + .unwrap(); + let mut cursor = None; + for expected in [ids[0], ids[2], ids[1]] { + let page = ok(messages::list( + State(state(pool.clone())), + UserId(b), + Query(Page { + before: cursor, + limit: Some(1), + }), + ) + .await) + .0; + assert_eq!(page.len(), 1); + assert_eq!(page[0].id, expected); + cursor = Some(expected); + } + assert!(messages::list( + State(state(pool.clone())), + UserId(outsider), + Query(Page { + before: Some(ids[0]), + limit: Some(1) + }) + ) + .await + .is_err()); + let mut link_ids = Vec::new(); + for _ in 0..3 { + link_ids.push(link(&pool, a, None).await.link.id); + } + link_ids.sort(); + sqlx::query( + "UPDATE friend_links SET created_at='2026-01-01T00:00:00Z' WHERE redeemed_by IS NULL", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query("UPDATE friend_links SET created_at='2026-01-02T00:00:00Z' WHERE id=$1") + .bind(link_ids[0]) + .execute(&pool) + .await + .unwrap(); + // Exclude the befriend fixture above from the first page. + sqlx::query( + "UPDATE friend_links SET created_at='2025-01-01T00:00:00Z' WHERE redeemed_by IS NOT NULL", + ) + .execute(&pool) + .await + .unwrap(); + let mut cursor = None; + for expected in [link_ids[0], link_ids[2], link_ids[1]] { + let page = ok(links::list( + State(state(pool.clone())), + UserId(a), + Query(Page { + before: cursor, + limit: Some(1), + }), + ) + .await) + .0; + assert_eq!(page.len(), 1); + assert_eq!(page[0].id, expected); + cursor = Some(expected); + } + assert!(links::list( + State(state(pool.clone())), + UserId(b), + Query(Page { + before: Some(link_ids[0]), + limit: Some(1) + }) + ) + .await + .is_err()); +} + +#[sqlx::test] +async fn key_snapshots_are_canonical_accounted_and_erased(pool: sqlx::PgPool) { + let (a, _) = user(&pool).await; + let (b, bk) = user(&pool).await; + befriend(&pool, a, b, &bk).await; + let original: String = sqlx::query_scalar("SELECT public_key FROM users WHERE id=$1") + .bind(a) + .fetch_one(&pool) + .await + .unwrap(); + let padded = original.replacen( + "-----BEGIN PGP PUBLIC KEY BLOCK-----\n", + &format!( + "-----BEGIN PGP PUBLIC KEY BLOCK-----\nComment: {}\n", + "X".repeat(64 * 1024) + ), + 1, + ); + assert!(padded.len() > original.len() + 60 * 1024); + sqlx::query("UPDATE users SET public_key=$2 WHERE id=$1") + .bind(a) + .bind(padded) + .execute(&pool) + .await + .unwrap(); + let id = Uuid::new_v4(); + let sent = ok(messages::send(State(state(pool.clone())), UserId(a), Json(body(id, b))).await).0; + assert!(sent.sender_public_key.is_empty()); + assert!(sent.recipient_public_key.is_empty()); + let got = ok(messages::get(State(state(pool.clone())), UserId(b), Path(id)).await).0; + assert_eq!(got.sender_public_key, original); + let listed = ok(messages::list( + State(state(pool.clone())), + UserId(b), + Query(Page::default()), + ) + .await) + .0; + assert!(listed[0].sender_public_key.is_empty()); + assert!(listed[0].recipient_public_key.is_empty()); + // Existing snapshot bytes count even though ciphertext itself is tiny. + sqlx::query("UPDATE secret_messages SET sender_public_key=repeat('k',20971520) WHERE id=$1") + .bind(id) + .execute(&pool) + .await + .unwrap(); + assert!(messages::send( + State(state(pool.clone())), + UserId(a), + Json(body(Uuid::new_v4(), b)) + ) + .await + .is_err()); + ok(messages::delete(State(state(pool.clone())), UserId(a), Path(id)).await); + ok(messages::delete(State(state(pool.clone())), UserId(b), Path(id)).await); + let (ciphertext, sender, recipient): (Option, String, String) = sqlx::query_as( + "SELECT ciphertext,sender_public_key,recipient_public_key FROM secret_messages WHERE id=$1", + ) + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(ciphertext.is_none()); + assert!(sender.is_empty()); + assert!(recipient.is_empty()); + let _ = ok(messages::send(State(state(pool.clone())), UserId(a), Json(body(id, b))).await); + let expired = Uuid::new_v4(); + let _ = ok(messages::send( + State(state(pool.clone())), + UserId(a), + Json(body(expired, b)), + ) + .await); + sqlx::query("UPDATE secret_messages SET expires_at=now()-interval '1 hour' WHERE id=$1") + .bind(expired) + .execute(&pool) + .await + .unwrap(); + let _ = ok(messages::send( + State(state(pool.clone())), + UserId(a), + Json(body(Uuid::new_v4(), b)), + ) + .await); + let (ciphertext, sender, recipient): (Option, String, String) = sqlx::query_as( + "SELECT ciphertext,sender_public_key,recipient_public_key FROM secret_messages WHERE id=$1", + ) + .bind(expired) + .fetch_one(&pool) + .await + .unwrap(); + assert!(ciphertext.is_none()); + assert!(sender.is_empty()); + assert!(recipient.is_empty()); +} From 0cfd0dd4d20bebb52914e3c47f89176417315a36 Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Thu, 24 Sep 2026 04:42:42 -0700 Subject: [PATCH 07/12] [agent] build: refresh offline SQL metadata for integrated routes Co-Authored-By: GPT-6 (OpenAI) --- ...479ba517f2b2986ad0dff1a45fac59335e0ab.json | 23 ------------------- ...4f39701ef2b579f026f4d362af7598f89a36b.json | 22 ------------------ ...81b1c06f24d583e85b4430ef02db2fbcd33a1.json | 23 ------------------- ...6b20acca1dd237de96a3ac39a1a258143a7d4.json | 22 ------------------ ...7e09e733dede0607c05eed349f2cc7c75a77a.json | 23 ------------------- Cargo.lock | 1 + 6 files changed, 1 insertion(+), 113 deletions(-) delete mode 100644 .sqlx/query-51318419dd4c7edfa3fcccbd97d479ba517f2b2986ad0dff1a45fac59335e0ab.json delete mode 100644 .sqlx/query-a764f66bd2c0cbc95025881180f4f39701ef2b579f026f4d362af7598f89a36b.json delete mode 100644 .sqlx/query-b556e22867fc6fed8bd3c0b5c9d81b1c06f24d583e85b4430ef02db2fbcd33a1.json delete mode 100644 .sqlx/query-d398da13592cf4359d47f63a8fb6b20acca1dd237de96a3ac39a1a258143a7d4.json delete mode 100644 .sqlx/query-efe3e535d4030b7437a955bfccb7e09e733dede0607c05eed349f2cc7c75a77a.json diff --git a/.sqlx/query-51318419dd4c7edfa3fcccbd97d479ba517f2b2986ad0dff1a45fac59335e0ab.json b/.sqlx/query-51318419dd4c7edfa3fcccbd97d479ba517f2b2986ad0dff1a45fac59335e0ab.json deleted file mode 100644 index 1545933..0000000 --- a/.sqlx/query-51318419dd4c7edfa3fcccbd97d479ba517f2b2986ad0dff1a45fac59335e0ab.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE variables AS v \n SET value = u.value \n FROM UNNEST($1::uuid[], $2::text[]) AS u(id, value) \n WHERE v.id = u.id \n RETURNING v.id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "UuidArray", - "TextArray" - ] - }, - "nullable": [ - false - ] - }, - "hash": "51318419dd4c7edfa3fcccbd97d479ba517f2b2986ad0dff1a45fac59335e0ab" -} diff --git a/.sqlx/query-a764f66bd2c0cbc95025881180f4f39701ef2b579f026f4d362af7598f89a36b.json b/.sqlx/query-a764f66bd2c0cbc95025881180f4f39701ef2b579f026f4d362af7598f89a36b.json deleted file mode 100644 index f13db60..0000000 --- a/.sqlx/query-a764f66bd2c0cbc95025881180f4f39701ef2b579f026f4d362af7598f89a36b.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COALESCE(sum(octet_length(value))::bigint, 0) AS \"bytes!\"\n FROM variables WHERE id = ANY($1::uuid[])", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "bytes!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "UuidArray" - ] - }, - "nullable": [ - null - ] - }, - "hash": "a764f66bd2c0cbc95025881180f4f39701ef2b579f026f4d362af7598f89a36b" -} diff --git a/.sqlx/query-b556e22867fc6fed8bd3c0b5c9d81b1c06f24d583e85b4430ef02db2fbcd33a1.json b/.sqlx/query-b556e22867fc6fed8bd3c0b5c9d81b1c06f24d583e85b4430ef02db2fbcd33a1.json deleted file mode 100644 index 163162f..0000000 --- a/.sqlx/query-b556e22867fc6fed8bd3c0b5c9d81b1c06f24d583e85b4430ef02db2fbcd33a1.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO variables (value, project_id) SELECT * FROM UNNEST($1::text[], $2::uuid[]) RETURNING id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "TextArray", - "UuidArray" - ] - }, - "nullable": [ - false - ] - }, - "hash": "b556e22867fc6fed8bd3c0b5c9d81b1c06f24d583e85b4430ef02db2fbcd33a1" -} diff --git a/.sqlx/query-d398da13592cf4359d47f63a8fb6b20acca1dd237de96a3ac39a1a258143a7d4.json b/.sqlx/query-d398da13592cf4359d47f63a8fb6b20acca1dd237de96a3ac39a1a258143a7d4.json deleted file mode 100644 index 82b0211..0000000 --- a/.sqlx/query-d398da13592cf4359d47f63a8fb6b20acca1dd237de96a3ac39a1a258143a7d4.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id FROM projects WHERE id = ANY($1::uuid[])", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "UuidArray" - ] - }, - "nullable": [ - false - ] - }, - "hash": "d398da13592cf4359d47f63a8fb6b20acca1dd237de96a3ac39a1a258143a7d4" -} diff --git a/.sqlx/query-efe3e535d4030b7437a955bfccb7e09e733dede0607c05eed349f2cc7c75a77a.json b/.sqlx/query-efe3e535d4030b7437a955bfccb7e09e733dede0607c05eed349f2cc7c75a77a.json deleted file mode 100644 index 0779b53..0000000 --- a/.sqlx/query-efe3e535d4030b7437a955bfccb7e09e733dede0607c05eed349f2cc7c75a77a.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE variables AS v SET value = u.value FROM UNNEST($1::uuid[], $2::text[]) AS u(id, value) WHERE v.id = u.id RETURNING v.id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "UuidArray", - "TextArray" - ] - }, - "nullable": [ - false - ] - }, - "hash": "efe3e535d4030b7437a955bfccb7e09e733dede0607c05eed349f2cc7c75a77a" -} diff --git a/Cargo.lock b/Cargo.lock index fb3c69f..3369f29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2369,6 +2369,7 @@ dependencies = [ "dotenv", "hex 0.4.3", "pgp", + "rand 0.8.5", "rand 0.9.0", "serde", "serde_json", From 524fd52a837426af5cb16912a72bbfa3b14ee815 Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Thu, 24 Sep 2026 04:45:52 -0700 Subject: [PATCH 08/12] [agent] Make project invitations and member rewraps snapshot-conditional and atomic Co-Authored-By: GPT-6 (OpenAI) --- docs/project-invite-protocol.md | 24 + ...0260924130000_project_invite_snapshots.sql | 5 + src/error.rs | 6 + src/helpers/caps.rs | 11 +- src/helpers/mod.rs | 1 + src/helpers/project_snapshot.rs | 213 +++++++++ src/helpers/variables.rs | 53 ++- src/routes/projects.rs | 40 +- src/routes/v2/invite/accept.rs | 317 +++++-------- src/routes/v2/invite/accept/tests.rs | 440 ++++++++++++++++++ src/routes/v2/invite/mod.rs | 5 +- src/routes/v2/invite/new.rs | 94 ++-- src/routes/v2/project/add_user.rs | 19 +- src/routes/v2/project/mod.rs | 13 +- src/routes/v2/project/remove_users.rs | 18 +- src/routes/v2/project/snapshot.rs | 142 ++++++ src/routes/v2/variables/delete.rs | 19 +- src/routes/v2/variables/set_many.rs | 35 +- src/routes/variables.rs | 115 ++--- 19 files changed, 1101 insertions(+), 469 deletions(-) create mode 100644 docs/project-invite-protocol.md create mode 100644 migrations/20260924130000_project_invite_snapshots.sql create mode 100644 src/helpers/project_snapshot.rs create mode 100644 src/routes/v2/invite/accept/tests.rs create mode 100644 src/routes/v2/project/snapshot.rs diff --git a/docs/project-invite-protocol.md b/docs/project-invite-protocol.md new file mode 100644 index 0000000..05f3dc7 --- /dev/null +++ b/docs/project-invite-protocol.md @@ -0,0 +1,24 @@ +# Version 1 project invitations + +`POST /v2/project/{id}/snapshot` returns all encrypted rows, members, and a SHA-256 snapshot token. Optional `add_user_ids` includes the proposed recipients' public keys in that token. Rows and users are sorted before hashing. The token covers project ID, variable IDs/content/metadata, current membership, and the complete recipient set. + +`POST /v2/invite/new` requires `protocol_version: 1`, the snapshot token, project ID, and a client-encrypted invitation payload. It saves the token only if the snapshot is still current. `POST /v2/invite/prepare` checks the verifier, expiry, unclaimed status, author's membership, and current source snapshot, then returns the payload and a token that also binds the invitee's key. Preparation does not claim the invite or grant membership. + +`POST /v2/invite/accept` requires protocol version, code, verifier, the prepared token, and every `{id, value}` rewrapped row exactly once. In one transaction it checks the source and prepared snapshots, updates all values, claims the invitation, clears its payload, and adds membership. Any failure rolls everything back. Empty projects are valid. `POST /v2/project/{id}/rewrap` provides the same conditional complete rewrap and membership transaction for direct add-users. + +All membership and variable writers acquire the project row lock before variable/membership locks. Cross-project update batches acquire sorted project IDs first. Creating invitations also serializes each author's quota after acquiring the project lock. Payload limits are 1 MiB each, 32 active invitations per author, and 16 MiB active ciphertext per author. Invites expire after one hour; new invitations clear that author's expired payloads. + +Legacy create/accept/add-user requests fail with `invite_upgrade_required`; existing memberships remain intact. Old invitations must be regenerated. Stale snapshots return HTTP 409 with code `project_snapshot_stale`. Flat membership permissions are unchanged: every current member can write and manage membership. Server-side validation treats rewrapped ciphertext as opaque, as ordinary authorized writes already do. + +## Remaining ordinary-write concurrency contract + +Ordinary variable endpoints retain their existing API and do not require a recipient snapshot. This patch protects invitation/add-users rewraps, not all future writes. An exact reproduction of the remaining limitation is: + +1. Existing member A fetches a variable and the project's public keys, and encrypts a replacement for the current recipients. +2. Before A submits it, B successfully accepts a version 1 invitation. The server atomically rewraps the variables and adds B. +3. A submits the previously prepared replacement through `/variables/update-many` or `/v2/variables/update-many` with the same variable/project IDs. +4. The existing API accepts A's authorized write. The replacement lacks B's recipient key even though B is now a member. + +A complete remedy requires snapshot preconditions on every variable write plus migration of ordinary clients; rejecting older variable clients would change the compatibility contract. The invitation transaction does not claim to prevent later authorized writes from replacing its result. The same broader issue affects separately prepared removal rewraps. + +Regression coverage lives in `src/routes/v2/invite/accept/tests.rs` and `src/routes/v2/project/snapshot.rs`. It covers expired/legacy/removed-author failures, changed/added/deleted/replaced rows, membership changes, incomplete/duplicate/failed rewraps, empty projects, concurrent redemption, waiting for a project writer, stale create, storage quotas, expiry cleanup, and conditional direct add-users. Run with a disposable `DATABASE_URL` via `cargo test`. diff --git a/migrations/20260924130000_project_invite_snapshots.sql b/migrations/20260924130000_project_invite_snapshots.sql new file mode 100644 index 0000000..80818af --- /dev/null +++ b/migrations/20260924130000_project_invite_snapshots.sql @@ -0,0 +1,5 @@ +-- Existing invitations have no verifiable source snapshot and must be regenerated. +-- Preserve their ciphertext and all existing memberships for explicit safe failure. +ALTER TABLE project_invites ADD COLUMN snapshot_hash TEXT; +CREATE INDEX project_invites_author_live_payload ON project_invites(author_id, expires_at) +WHERE ciphertext IS NOT NULL; diff --git a/src/error.rs b/src/error.rs index 54daac4..6deb539 100644 --- a/src/error.rs +++ b/src/error.rs @@ -26,6 +26,7 @@ pub enum AppError { AnyhowError(AnyhowError), Error(Errors), Generic(StatusCode, String), + Protocol(StatusCode, &'static str, &'static str), } impl From<(StatusCode, String)> for AppError { @@ -97,6 +98,11 @@ impl IntoResponse for AppError { Errors::NotFound => (StatusCode::NOT_FOUND, "Not found").into_response(), }, AppError::Generic(status_code, string) => (status_code, string).into_response(), + AppError::Protocol(status, code, message) => ( + status, + axum::Json(serde_json::json!({"code":code,"message":message})), + ) + .into_response(), } } } diff --git a/src/helpers/caps.rs b/src/helpers/caps.rs index 09dc6c9..ae0a850 100644 --- a/src/helpers/caps.rs +++ b/src/helpers/caps.rs @@ -52,6 +52,15 @@ pub async fn check_project_for_insert( db: &DB, project_id: Uuid, new_values: &[&str], +) -> Result<(), AppError> { + let mut connection = db.acquire().await?; + check_project_for_insert_on(caps, &mut connection, project_id, new_values).await +} +pub async fn check_project_for_insert_on( + caps: &Caps, + connection: &mut sqlx::PgConnection, + project_id: Uuid, + new_values: &[&str], ) -> Result<(), AppError> { let count_cap = caps.max_variables_per_project; let bytes_cap = caps.max_project_bytes; @@ -66,7 +75,7 @@ pub async fn check_project_for_insert( FROM variables WHERE project_id = $1"#, project_id ) - .fetch_one(db.as_ref()) + .fetch_one(&mut *connection) .await .context("Failed to fetch project size")?; diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index f3bca7e..d00b1bd 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -1,4 +1,5 @@ pub mod caps; pub mod project; +pub mod project_snapshot; pub mod variables; pub mod registration; diff --git a/src/helpers/project_snapshot.rs b/src/helpers/project_snapshot.rs new file mode 100644 index 0000000..0bfb5ac --- /dev/null +++ b/src/helpers/project_snapshot.rs @@ -0,0 +1,213 @@ +//! A project row is the serialization lock for every membership and variable writer. +//! Snapshot digests bind encrypted row identities/content and all recipient keys. +use crate::{error::Errors, helpers::caps, AppError, AppState}; +use axum::http::StatusCode; +use serde::{Deserialize, Serialize}; +use sqlx::{PgConnection, Postgres, Transaction}; +use std::{collections::HashSet, net::IpAddr}; +use utoipa::ToSchema; +use uuid::Uuid; + +pub const VERSION: u8 = 1; +pub fn conflict(code: &'static str, message: &'static str) -> AppError { + AppError::Protocol(StatusCode::CONFLICT, code, message) +} +pub fn upgrade() -> AppError { + conflict( + "invite_upgrade_required", + "Upgrade envx and regenerate this invitation using a project snapshot", + ) +} +pub fn stale() -> AppError { + conflict( + "project_snapshot_stale", + "Project or recipients changed; fetch a fresh snapshot and regenerate the invitation", + ) +} +pub fn require_version(version: Option) -> Result<(), AppError> { + if version != Some(VERSION) { + return Err(upgrade()); + } + Ok(()) +} +pub async fn lock_project( + tx: &mut Transaction<'_, Postgres>, + project: Uuid, +) -> Result<(), AppError> { + let found: Option = sqlx::query_scalar("SELECT id FROM projects WHERE id=$1 FOR UPDATE") + .bind(project) + .fetch_optional(&mut **tx) + .await?; + if found.is_none() { + return Err(Errors::NotFound.into()); + } + Ok(()) +} +pub async fn authorize( + connection: &mut PgConnection, + project: Uuid, + user: Uuid, +) -> Result<(), AppError> { + let member: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM user_project_relations WHERE project_id=$1 AND user_id=$2)", + ) + .bind(project) + .bind(user) + .fetch_one(connection) + .await?; + if !member { + return Err(Errors::Unauthorized.into()); + } + Ok(()) +} +#[derive(Clone, Serialize, Deserialize, ToSchema, sqlx::FromRow)] +pub struct SnapshotVariable { + pub id: Uuid, + pub project_id: Uuid, + pub value: String, + pub created_at: chrono::DateTime, + pub tag: Option, +} +#[derive(Clone, Serialize, Deserialize, ToSchema, sqlx::FromRow)] +pub struct Recipient { + pub id: Uuid, + pub public_key: String, +} +#[derive(Serialize, ToSchema)] +pub struct Snapshot { + pub protocol_version: u8, + pub project_id: Uuid, + pub snapshot: String, + pub variables: Vec, + pub users: Vec, +} +#[derive(Clone, Serialize, Deserialize, ToSchema)] +pub struct RewrappedVariable { + pub id: Uuid, + pub value: String, +} + +/// Call only while holding the project lock. Added recipients are part of the token, +/// so a caller cannot re-use ciphertext prepared for another set of public keys. +pub async fn read( + connection: &mut PgConnection, + project: Uuid, + added: &[Uuid], +) -> Result { + let variables: Vec = sqlx::query_as( + "SELECT id,project_id,value,created_at,tag FROM variables WHERE project_id=$1 ORDER BY id", + ) + .bind(project) + .fetch_all(&mut *connection) + .await?; + let members: Vec = sqlx::query_as("SELECT u.id,u.public_key FROM users u JOIN user_project_relations r ON r.user_id=u.id WHERE r.project_id=$1 ORDER BY u.id") + .bind(project).fetch_all(&mut *connection).await?; + let mut users = members.clone(); + let added_users: Vec = + sqlx::query_as("SELECT id,public_key FROM users WHERE id=ANY($1) ORDER BY id") + .bind(added) + .fetch_all(&mut *connection) + .await?; + if added_users.len() != added.iter().collect::>().len() { + return Err(AppError::Generic( + StatusCode::BAD_REQUEST, + "Unknown recipient".into(), + )); + } + users.extend(added_users); + users.sort_by_key(|user| user.id); + users.dedup_by_key(|user| user.id); + let encoded = serde_json::to_vec(&( + "envx-project-snapshot-v1", + project, + &variables, + &members, + &users, + )) + .map_err(anyhow::Error::from)?; + let snapshot = crypto_hash::hex_digest(crypto_hash::Algorithm::SHA256, &encoded); + Ok(Snapshot { + protocol_version: VERSION, + project_id: project, + snapshot, + variables, + users, + }) +} + +pub async fn rewrap( + state: &AppState, + connection: &mut PgConnection, + current: &Snapshot, + expected: &str, + variables: &[RewrappedVariable], + user: Uuid, + ip: IpAddr, +) -> Result<(), AppError> { + if current.snapshot != expected { + return Err(stale()); + } + let actual: HashSet<_> = variables.iter().map(|v| v.id).collect(); + let required: HashSet<_> = current.variables.iter().map(|v| v.id).collect(); + if actual != required || actual.len() != variables.len() { + return Err(AppError::Generic( + StatusCode::BAD_REQUEST, + "Rewrap must contain every snapshot variable exactly once".into(), + )); + } + let values: Vec<&str> = variables.iter().map(|v| v.value.as_str()).collect(); + caps::check_per_value(&state.caps, &values)?; + let ids: Vec = variables.iter().map(|v| v.id).collect(); + caps::check_project_for_update_on(&state.caps, connection, current.project_id, &ids, &values) + .await?; + let new_bytes: i64 = values.iter().map(|v| v.len() as i64).sum(); + let old_bytes: i64 = current.variables.iter().map(|v| v.value.len() as i64).sum(); + // Invitees are not members yet, so their storage delta is the entire project. + let is_member: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM user_project_relations WHERE project_id=$1 AND user_id=$2)", + ) + .bind(current.project_id) + .bind(user) + .fetch_one(&mut *connection) + .await?; + caps::check_user_total_on( + &state.caps, + connection, + user, + if is_member { + new_bytes - old_bytes + } else { + new_bytes + }, + ) + .await?; + caps::check_and_record_ip_on(&state.caps, connection, ip, (new_bytes - old_bytes).max(0)) + .await?; + for variable in variables { + sqlx::query("UPDATE variables SET value=$1 WHERE id=$2 AND project_id=$3") + .bind(&variable.value) + .bind(variable.id) + .bind(current.project_id) + .execute(&mut *connection) + .await?; + } + Ok(()) +} + +pub async fn remove_members( + state: &AppState, + project: Uuid, + user: Uuid, + removed: &[Uuid], +) -> Result<(), AppError> { + let mut tx = state.db.begin().await?; + lock_project(&mut tx, project).await?; + authorize(&mut tx, project, user).await?; + sqlx::query("DELETE FROM user_project_relations WHERE project_id=$1 AND user_id=ANY($2)") + .bind(project) + .bind(removed) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} diff --git a/src/helpers/variables.rs b/src/helpers/variables.rs index ff3693d..cc6b406 100644 --- a/src/helpers/variables.rs +++ b/src/helpers/variables.rs @@ -29,6 +29,11 @@ pub async fn update_many( // Stable lock order prevents opposing batches from deadlocking. parsed.sort_unstable(); let mut tx = state.db.begin().await?; + let projects: std::collections::BTreeSet<_> = + parsed.iter().map(|(_, project)| *project).collect(); + for project in projects { + super::project_snapshot::lock_project(&mut tx, project).await?; + } for (id, project) in &parsed { let authorized: Option = sqlx::query_scalar("SELECT v.id FROM variables v JOIN user_project_relations upr ON upr.project_id=v.project_id WHERE v.id=$1 AND v.project_id=$2 AND upr.user_id=$3 FOR UPDATE OF v FOR SHARE OF upr") .bind(id).bind(project).bind(user_id).fetch_optional(&mut *tx).await?; @@ -89,12 +94,8 @@ pub async fn replace_many( let refs = values.iter().map(String::as_str).collect::>(); caps::check_per_value(&state.caps, &refs)?; let mut tx = state.db.begin().await?; - // Serialize replacements in a project and hold membership through commit. - let authorized: Option = sqlx::query_scalar("SELECT p.id FROM projects p JOIN user_project_relations upr ON upr.project_id=p.id WHERE p.id=$1 AND upr.user_id=$2 FOR UPDATE OF p FOR SHARE OF upr") - .bind(project).bind(user_id).fetch_optional(&mut *tx).await?; - if authorized.is_none() { - return Err(Errors::Unauthorized.into()); - } + super::project_snapshot::lock_project(&mut tx, project).await?; + super::project_snapshot::authorize(&mut tx, project, user_id).await?; let removed: Vec = sqlx::query_scalar( "DELETE FROM variables WHERE project_id=$1 AND id=ANY($2) RETURNING value", ) @@ -126,6 +127,46 @@ pub async fn replace_many( Ok(ids) } +pub async fn insert_many( + state: &AppState, + user: Uuid, + ip: IpAddr, + project: Uuid, + values: Vec, + tags: Vec, +) -> Result, AppError> { + let refs = values.iter().map(String::as_str).collect::>(); + caps::check_per_value(&state.caps, &refs)?; + let mut tx = state.db.begin().await?; + super::project_snapshot::lock_project(&mut tx, project).await?; + super::project_snapshot::authorize(&mut tx, project, user).await?; + caps::check_project_for_insert_on(&state.caps, &mut tx, project, &refs).await?; + let delta = values.iter().map(|v| v.len() as i64).sum(); + caps::check_user_total_on(&state.caps, &mut tx, user, delta).await?; + caps::check_and_record_ip_on(&state.caps, &mut tx, ip, delta).await?; + let ids = sqlx::query_scalar("INSERT INTO variables(id,value,project_id,tag) SELECT gen_random_uuid(),value,$1,tag FROM UNNEST($2::text[],$3::text[]) AS t(value,tag) RETURNING id") + .bind(project).bind(values).bind(tags).fetch_all(&mut *tx).await?; + tx.commit().await?; + Ok(ids) +} +pub async fn delete(state: &AppState, user: Uuid, id: Uuid) -> Result<(), AppError> { + let mut tx = state.db.begin().await?; + let project: Option = sqlx::query_scalar("SELECT project_id FROM variables WHERE id=$1") + .bind(id) + .fetch_optional(&mut *tx) + .await?; + let project = project.ok_or(Errors::NotFound)?; + super::project_snapshot::lock_project(&mut tx, project).await?; + super::project_snapshot::authorize(&mut tx, project, user).await?; + sqlx::query("DELETE FROM variables WHERE id=$1 AND project_id=$2") + .bind(id) + .bind(project) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/routes/projects.rs b/src/routes/projects.rs index 9b8ebeb..96942da 100644 --- a/src/routes/projects.rs +++ b/src/routes/projects.rs @@ -84,22 +84,8 @@ pub async fn add_user( Path(project_id): Path, Json(body): Json, ) -> Result<(), AppError> { - if !user_in_project(user_id, project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - let user_to_insert = body.user_id.to_uuid()?; - - sqlx::query!( - "INSERT INTO user_project_relations (user_id, project_id) VALUES ($1, $2)", - user_to_insert, - project_id - ) - .execute(&*state.db) - .await - .context("Failed to add user to project")?; - - Ok(()) + let _ = (state, user_id, project_id, body); + Err(crate::helpers::project_snapshot::upgrade()) } #[derive(Serialize, Deserialize)] @@ -113,26 +99,12 @@ pub async fn remove_user( Path(project_id): Path, Json(body): Json, ) -> Result<(), AppError> { - if !user_in_project(user_id, project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - let users_to_remove = body + let users = body .users .iter() - .map(|user| user.to_uuid().unwrap()) - .collect::>(); - - sqlx::query!( - "DELETE FROM user_project_relations WHERE user_id = ANY($1::uuid[]) AND project_id = $2", - &users_to_remove, - project_id - ) - .execute(&*state.db) - .await - .context("Failed to remove user from project")?; - - Ok(()) + .map(|user| user.to_uuid()) + .collect::, _>>()?; + crate::helpers::project_snapshot::remove_members(&state, project_id, user_id, &users).await } pub async fn list_projects( diff --git a/src/routes/v2/invite/accept.rs b/src/routes/v2/invite/accept.rs index da91650..d184b6d 100644 --- a/src/routes/v2/invite/accept.rs +++ b/src/routes/v2/invite/accept.rs @@ -1,225 +1,136 @@ +use super::*; +use crate::{ + extractors::{client_ip::ClientIp, user::UserId}, + helpers::project_snapshot::{self as snapshots, Recipient, RewrappedVariable}, +}; use argon2::{Argon2, PasswordHash, PasswordVerifier}; -use axum::http::StatusCode; use uuid::Uuid; -use crate::{extractors::user::UserId, structs::ProjectInvite}; - -use super::*; - -#[derive(Deserialize, ToSchema)] +#[derive(Clone, Deserialize, ToSchema)] +pub struct PrepareInviteBody { + pub code: Uuid, + pub verifier: Uuid, +} +#[derive(Clone, Deserialize, ToSchema)] pub struct AcceptInviteBody { pub code: Uuid, pub verifier: Uuid, + pub protocol_version: Option, + pub snapshot: Option, + pub variables: Option>, } - #[derive(Serialize, ToSchema)] -pub struct AcceptInviteReturnType { - pub project_id: String, - pub invite_id: String, +pub struct PreparedInvite { + pub protocol_version: u8, + pub project_id: Uuid, + pub invite_id: Uuid, pub ciphertext: String, + pub source_snapshot: String, + pub snapshot: String, + pub users: Vec, } - -#[utoipa::path( - post, - path = "/accept", - tag = INVITE_TAG, - responses( - (status = 200, description = "Success", body = AcceptInviteReturnType), - (status = 400, description = "Invalid public key"), - (status = 404, description = "Invite not found"), - (status = 409, description = "Invite already accepted"), - ), - security( - ("bearer" = []), - ), -)] -pub async fn accept_invite( - State(state): State, - UserId(user_id): UserId, - Json(body): Json, -) -> Result, AppError> { - let invite = sqlx::query_as!( - ProjectInvite, - "SELECT * FROM project_invites WHERE id = $1", - body.code - ) - .fetch_one(&*state.db) - .await - .context("Failed to fetch invite")?; - - let parsed_hash = PasswordHash::new(&invite.verifier_argon2id) +#[derive(Serialize, ToSchema)] +pub struct AcceptInviteReturnType { + pub project_id: Uuid, + pub invite_id: Uuid, +} +#[derive(sqlx::FromRow)] +struct Invite { + project_id: Uuid, + author_id: Uuid, + verifier_argon2id: String, + ciphertext: Option, + snapshot_hash: Option, + invited_id: Option, + expires_at: chrono::DateTime, +} +// The initial lookup only chooses the project lock. All authorization and content +// checks use a fresh locked invitation after that lock has been acquired. +async fn lock_invite( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + code: Uuid, + verifier: Uuid, +) -> Result { + let project: Option = + sqlx::query_scalar("SELECT project_id FROM project_invites WHERE id=$1") + .bind(code) + .fetch_optional(&mut **tx) + .await?; + snapshots::lock_project(tx, project.ok_or(Errors::NotFound)?).await?; + let invite: Invite = sqlx::query_as("SELECT project_id,author_id,verifier_argon2id,ciphertext,snapshot_hash,invited_id,expires_at FROM project_invites WHERE id=$1 FOR UPDATE") + .bind(code).fetch_one(&mut **tx).await?; + let hash = PasswordHash::new(&invite.verifier_argon2id) + .map_err(|_| AppError::Error(Errors::Unauthorized))?; + Argon2::default() + .verify_password(verifier.as_bytes(), &hash) .map_err(|_| AppError::Error(Errors::Unauthorized))?; - let is_valid = Argon2::default() - .verify_password(body.verifier.as_bytes(), &parsed_hash) - .is_ok(); - - if !is_valid { - return Err(AppError::Error(Errors::Unauthorized)); - } - if invite.invited_id.is_some() { - return Err(AppError::Generic( - StatusCode::CONFLICT, - "Invite already accepted".into(), + return Err(snapshots::conflict( + "invite_already_accepted", + "Invitation already accepted", )); } - - let mut tx = state.db.begin().await?; - // An invitation cannot outlive its author's authority to grant access. - let author_membership: Option = sqlx::query_scalar( - "SELECT id FROM user_project_relations WHERE user_id=$1 AND project_id=$2 FOR SHARE", - ) - .bind(invite.author_id) - .bind(invite.project_id) - .fetch_optional(&mut *tx) - .await?; - if author_membership.is_none() { - return Err(AppError::Error(Errors::Unauthorized)); + if invite.expires_at <= chrono::Utc::now() || invite.ciphertext.is_none() { + return Err(Errors::Unauthorized.into()); } - let id = sqlx::query!( - "UPDATE project_invites - SET invited_id = $1, - ciphertext = NULL - WHERE id = $2 - AND invited_id IS NULL - AND ciphertext IS NOT NULL - AND expires_at > NOW() - RETURNING id; - ", - user_id, - body.code - ) - .fetch_optional(&mut *tx) - .await - .context("Failed to update invite")?; - - if id.is_none() { - return Err(AppError::Error(Errors::Unauthorized)); + snapshots::authorize(tx, invite.project_id, invite.author_id).await?; + let expected = invite + .snapshot_hash + .as_deref() + .ok_or_else(snapshots::upgrade)?; + let current = snapshots::read(tx, invite.project_id, &[]).await?; + if current.snapshot != expected { + return Err(snapshots::stale()); } - - sqlx::query!( - "INSERT INTO user_project_relations (user_id, project_id) - VALUES ($1, $2) - ON CONFLICT DO NOTHING", - &user_id, - &invite.project_id, - ) - .execute(&mut *tx) - .await - .context("Failed to insert user project relations")?; - + Ok(invite) +} +#[utoipa::path(post,path="/prepare",tag=INVITE_TAG,responses((status=200,body=PreparedInvite),(status=409,description="Upgrade or regenerate invitation")),security(("bearer"=[])))] +pub async fn prepare_invite( + State(state): State, + UserId(user): UserId, + Json(body): Json, +) -> Result, AppError> { + let mut tx = state.db.begin().await?; + let invite = lock_invite(&mut tx, body.code, body.verifier).await?; + let current = snapshots::read(&mut tx, invite.project_id, &[user]).await?; tx.commit().await?; - - Ok(Json(AcceptInviteReturnType { + Ok(Json(PreparedInvite { + protocol_version: snapshots::VERSION, + project_id: invite.project_id, + invite_id: body.code, ciphertext: invite.ciphertext.unwrap(), - invite_id: invite.id.to_string(), - project_id: invite.project_id.to_string(), + source_snapshot: invite.snapshot_hash.unwrap(), + snapshot: current.snapshot, + users: current.users, + })) +} +#[utoipa::path(post,path="/accept",tag=INVITE_TAG,responses((status=200,body=AcceptInviteReturnType),(status=409,description="Upgrade or regenerate invitation")),security(("bearer"=[])))] +pub async fn accept_invite( + State(state): State, + UserId(user): UserId, + ClientIp(ip): ClientIp, + Json(body): Json, +) -> Result, AppError> { + snapshots::require_version(body.protocol_version)?; + let expected = body.snapshot.as_deref().ok_or_else(snapshots::upgrade)?; + let variables = body.variables.as_deref().ok_or_else(snapshots::upgrade)?; + let mut tx = state.db.begin().await?; + let invite = lock_invite(&mut tx, body.code, body.verifier).await?; + let current = snapshots::read(&mut tx, invite.project_id, &[user]).await?; + snapshots::rewrap(&state, &mut tx, ¤t, expected, variables, user, ip).await?; + sqlx::query("UPDATE project_invites SET invited_id=$1,ciphertext=NULL WHERE id=$2") + .bind(user) + .bind(body.code) + .execute(&mut *tx) + .await?; + sqlx::query("INSERT INTO user_project_relations(user_id,project_id) VALUES($1,$2) ON CONFLICT DO NOTHING") + .bind(user).bind(invite.project_id).execute(&mut *tx).await?; + tx.commit().await?; + Ok(Json(AcceptInviteReturnType { + project_id: invite.project_id, + invite_id: body.code, })) } #[cfg(test)] -mod tests { - use super::*; - use crate::test_support::*; - use argon2::{ - password_hash::{rand_core::OsRng, SaltString}, - PasswordHasher, - }; - #[sqlx::test] - async fn expired_invite_does_not_grant_membership(pool: sqlx::PgPool) { - let owner = user(&pool).await; - let guest = user(&pool).await; - let project = project(&pool, owner).await; - let verifier = Uuid::new_v4(); - let hash = Argon2::default() - .hash_password(verifier.as_bytes(), &SaltString::generate(&mut OsRng)) - .unwrap() - .to_string(); - let code=sqlx::query_scalar("INSERT INTO project_invites(project_id,author_id,expires_at,verifier_argon2id,ciphertext) VALUES ($1,$2,now()-interval '1 hour',$3,'secret') RETURNING id").bind(project).bind(owner).bind(hash).fetch_one(&pool).await.unwrap(); - assert!(accept_invite( - State(state(pool.clone())), - UserId(guest), - Json(AcceptInviteBody { code, verifier }) - ) - .await - .is_err()); - let count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM user_project_relations WHERE user_id=$1 AND project_id=$2", - ) - .bind(guest) - .bind(project) - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(count, 0); - } - #[sqlx::test] - async fn removed_author_cannot_grant_membership(pool: sqlx::PgPool) { - let owner = user(&pool).await; - let guest = user(&pool).await; - let project = project(&pool, owner).await; - let verifier = Uuid::new_v4(); - let hash = Argon2::default() - .hash_password(verifier.as_bytes(), &SaltString::generate(&mut OsRng)) - .unwrap() - .to_string(); - let code=sqlx::query_scalar("INSERT INTO project_invites(project_id,author_id,expires_at,verifier_argon2id,ciphertext) VALUES ($1,$2,now()+interval '1 hour',$3,'secret') RETURNING id").bind(project).bind(owner).bind(hash).fetch_one(&pool).await.unwrap(); - sqlx::query("DELETE FROM user_project_relations WHERE user_id=$1 AND project_id=$2") - .bind(owner) - .bind(project) - .execute(&pool) - .await - .unwrap(); - assert!(accept_invite( - State(state(pool.clone())), - UserId(guest), - Json(AcceptInviteBody { code, verifier }) - ) - .await - .is_err()); - let count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM user_project_relations WHERE user_id=$1 AND project_id=$2", - ) - .bind(guest) - .bind(project) - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(count, 0); - } - #[sqlx::test] - async fn concurrent_redemption_has_one_winner(pool: sqlx::PgPool) { - let owner = user(&pool).await; - let a = user(&pool).await; - let b = user(&pool).await; - let project = project(&pool, owner).await; - let verifier = Uuid::new_v4(); - let hash = Argon2::default() - .hash_password(verifier.as_bytes(), &SaltString::generate(&mut OsRng)) - .unwrap() - .to_string(); - let code=sqlx::query_scalar("INSERT INTO project_invites(project_id,author_id,expires_at,verifier_argon2id,ciphertext) VALUES ($1,$2,now()+interval '1 hour',$3,'secret') RETURNING id").bind(project).bind(owner).bind(hash).fetch_one(&pool).await.unwrap(); - let (ra, rb) = tokio::join!( - accept_invite( - State(state(pool.clone())), - UserId(a), - Json(AcceptInviteBody { code, verifier }) - ), - accept_invite( - State(state(pool.clone())), - UserId(b), - Json(AcceptInviteBody { code, verifier }) - ) - ); - assert_eq!(usize::from(ra.is_ok()) + usize::from(rb.is_ok()), 1); - let count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM user_project_relations WHERE project_id=$1 AND user_id<>$2", - ) - .bind(project) - .bind(owner) - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(count, 1); - } -} +mod tests; diff --git a/src/routes/v2/invite/accept/tests.rs b/src/routes/v2/invite/accept/tests.rs new file mode 100644 index 0000000..27fbb74 --- /dev/null +++ b/src/routes/v2/invite/accept/tests.rs @@ -0,0 +1,440 @@ +use super::*; +use crate::{ + routes::v2::invite::new::{new_invite, InviteBody}, + test_support::*, +}; +fn ip() -> ClientIp { + ClientIp("127.0.0.1".parse().unwrap()) +} +async fn issue(pool: &sqlx::PgPool, owner: Uuid, guest: Uuid, project: Uuid) -> AcceptInviteBody { + let mut tx = pool.begin().await.unwrap(); + snapshots::lock_project(&mut tx, project) + .await + .ok() + .unwrap(); + let snapshot = snapshots::read(&mut tx, project, &[]).await.ok().unwrap(); + tx.commit().await.unwrap(); + let invite = new_invite( + State(state(pool.clone())), + UserId(owner), + Json(InviteBody { + project_id: project, + ciphertext: "encrypted-invite".into(), + protocol_version: Some(1), + snapshot: Some(snapshot.snapshot), + }), + ) + .await + .ok() + .unwrap() + .0; + let prepared = prepare_invite( + State(state(pool.clone())), + UserId(guest), + Json(PrepareInviteBody { + code: invite.invite_code, + verifier: invite.verifier, + }), + ) + .await + .ok() + .unwrap() + .0; + AcceptInviteBody { + code: invite.invite_code, + verifier: invite.verifier, + protocol_version: Some(1), + snapshot: Some(prepared.snapshot), + variables: Some( + snapshot + .variables + .into_iter() + .map(|v| RewrappedVariable { + id: v.id, + value: "rewrapped".into(), + }) + .collect(), + ), + } +} +async fn assert_unclaimed(pool: &sqlx::PgPool, code: Uuid, guest: Uuid, project: Uuid) { + let (claimed, ciphertext): (Option, Option) = + sqlx::query_as("SELECT invited_id,ciphertext FROM project_invites WHERE id=$1") + .bind(code) + .fetch_one(pool) + .await + .unwrap(); + assert!(claimed.is_none()); + assert!(ciphertext.is_some()); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM user_project_relations WHERE project_id=$1 AND user_id=$2", + ) + .bind(project) + .bind(guest) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(count, 0); +} +async fn variable(pool: &sqlx::PgPool, project: Uuid) -> Uuid { + sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES(gen_random_uuid(),$1,'original') RETURNING id").bind(project).fetch_one(pool).await.unwrap() +} +#[sqlx::test] +async fn legacy_invite_cannot_join_before_variables_are_rewrapped(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + let body = issue(&pool, owner, guest, project).await; + sqlx::query("UPDATE project_invites SET snapshot_hash=NULL WHERE id=$1") + .bind(body.code) + .execute(&pool) + .await + .unwrap(); + assert!(prepare_invite( + State(state(pool.clone())), + UserId(guest), + Json(PrepareInviteBody { + code: body.code, + verifier: body.verifier + }) + ) + .await + .is_err()); + assert!(accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(body.clone()) + ) + .await + .is_err()); + let legacy = AcceptInviteBody { + protocol_version: None, + snapshot: None, + variables: None, + ..body.clone() + }; + assert!(accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(legacy) + ) + .await + .is_err()); + assert_unclaimed(&pool, body.code, guest, project).await; +} +#[sqlx::test] +async fn expired_invite_does_not_grant_membership(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + let body = issue(&pool, owner, guest, project).await; + sqlx::query("UPDATE project_invites SET expires_at=now()-interval '1 hour' WHERE id=$1") + .bind(body.code) + .execute(&pool) + .await + .unwrap(); + assert!(accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(body.clone()) + ) + .await + .is_err()); + assert_unclaimed(&pool, body.code, guest, project).await; +} +#[sqlx::test] +async fn removed_author_cannot_grant_membership(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + let body = issue(&pool, owner, guest, project).await; + snapshots::remove_members(&state(pool.clone()), project, owner, &[owner]) + .await + .ok() + .unwrap(); + assert!(accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(body.clone()) + ) + .await + .is_err()); + assert_unclaimed(&pool, body.code, guest, project).await; +} +#[sqlx::test] +async fn changed_added_deleted_or_replaced_variables_and_membership_reject_stale_invites( + pool: sqlx::PgPool, +) { + for change in ["changed", "added", "deleted", "replaced", "membership"] { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + let id = variable(&pool, project).await; + let body = issue(&pool, owner, guest, project).await; + match change { + "changed" => { + sqlx::query("UPDATE variables SET value='changed' WHERE id=$1") + .bind(id) + .execute(&pool) + .await + .unwrap(); + } + "added" => { + variable(&pool, project).await; + } + "deleted" => { + sqlx::query("DELETE FROM variables WHERE id=$1") + .bind(id) + .execute(&pool) + .await + .unwrap(); + } + "replaced" => { + crate::helpers::variables::replace_many( + &state(pool.clone()), + owner, + ip().0, + project, + vec!["original".into()], + vec![id], + ) + .await + .ok() + .unwrap(); + } + _ => { + let extra = user(&pool).await; + sqlx::query("INSERT INTO user_project_relations(project_id,user_id) VALUES($1,$2)") + .bind(project) + .bind(extra) + .execute(&pool) + .await + .unwrap(); + } + } + let before: Vec<(Uuid, String)> = + sqlx::query_as("SELECT id,value FROM variables WHERE project_id=$1 ORDER BY id") + .bind(project) + .fetch_all(&pool) + .await + .unwrap(); + let result = accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(body.clone()), + ) + .await; + assert!( + matches!( + result, + Err(AppError::Protocol(_, "project_snapshot_stale", _)) + ), + "{change}" + ); + assert_unclaimed(&pool, body.code, guest, project).await; + let after: Vec<(Uuid, String)> = + sqlx::query_as("SELECT id,value FROM variables WHERE project_id=$1 ORDER BY id") + .bind(project) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(before, after, "{change}"); + } +} +#[sqlx::test] +async fn failed_or_incomplete_rewrap_does_not_join_or_consume(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + variable(&pool, project).await; + variable(&pool, project).await; + let body = issue(&pool, owner, guest, project).await; + let mut incomplete = body.clone(); + incomplete.variables.as_mut().unwrap().pop(); + assert!(accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(incomplete) + ) + .await + .is_err()); + let mut duplicate = body.clone(); + let first = duplicate.variables.as_ref().unwrap()[0].clone(); + duplicate.variables.as_mut().unwrap().push(first); + assert!(accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(duplicate) + ) + .await + .is_err()); + sqlx::query("ALTER TABLE variables ADD CONSTRAINT reject_rewrap CHECK(value<>'reject-me')") + .execute(&pool) + .await + .unwrap(); + let mut failed = body.clone(); + failed.variables.as_mut().unwrap()[1].value = "reject-me".into(); + assert!(accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(failed) + ) + .await + .is_err()); + assert_unclaimed(&pool, body.code, guest, project).await; + let originals: i64 = sqlx::query_scalar( + "SELECT count(*) FROM variables WHERE project_id=$1 AND value='original'", + ) + .bind(project) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(originals, 2); + assert!( + accept_invite(State(state(pool.clone())), UserId(guest), ip(), Json(body)) + .await + .is_ok() + ); +} +#[sqlx::test] +async fn concurrent_redemption_has_one_winner_and_empty_project_works(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let a = user(&pool).await; + let b = user(&pool).await; + let project = project(&pool, owner).await; + let body = issue(&pool, owner, a, project).await; + let prepared = prepare_invite( + State(state(pool.clone())), + UserId(b), + Json(PrepareInviteBody { + code: body.code, + verifier: body.verifier, + }), + ) + .await + .ok() + .unwrap() + .0; + let other = AcceptInviteBody { + snapshot: Some(prepared.snapshot), + ..body.clone() + }; + let (ra, rb) = tokio::join!( + accept_invite(State(state(pool.clone())), UserId(a), ip(), Json(body)), + accept_invite(State(state(pool.clone())), UserId(b), ip(), Json(other)) + ); + assert_eq!(usize::from(ra.is_ok()) + usize::from(rb.is_ok()), 1); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM user_project_relations WHERE project_id=$1 AND user_id<>$2", + ) + .bind(project) + .bind(owner) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); +} + +#[sqlx::test] +async fn creator_must_submit_the_fetched_snapshot_and_payload_quotas_are_bounded( + pool: sqlx::PgPool, +) { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + let body = issue(&pool, owner, guest, project).await; + let source: String = + sqlx::query_scalar("SELECT snapshot_hash FROM project_invites WHERE id=$1") + .bind(body.code) + .fetch_one(&pool) + .await + .unwrap(); + let create = |ciphertext: String, snapshot: String| { + new_invite( + State(state(pool.clone())), + UserId(owner), + Json(InviteBody { + project_id: project, + ciphertext, + protocol_version: Some(1), + snapshot: Some(snapshot), + }), + ) + }; + assert!(create("x".repeat(1024 * 1024 + 1), source.clone()) + .await + .is_err()); + sqlx::query("UPDATE project_invites SET expires_at=now()-interval '1 hour' WHERE id=$1") + .bind(body.code) + .execute(&pool) + .await + .unwrap(); + assert!(create("valid".into(), source.clone()).await.is_ok()); + let expired: Option = + sqlx::query_scalar("SELECT ciphertext FROM project_invites WHERE id=$1") + .bind(body.code) + .fetch_one(&pool) + .await + .unwrap(); + assert!(expired.is_none()); + sqlx::query("INSERT INTO project_invites(project_id,author_id,expires_at,verifier_argon2id,ciphertext,snapshot_hash) SELECT $1,$2,now()+interval '1 hour','fixture','payload',$3 FROM generate_series(1,31)").bind(project).bind(owner).bind(&source).execute(&pool).await.unwrap(); + assert!(create("valid".into(), source.clone()).await.is_err()); + // Count and byte limits are independent. + sqlx::query("UPDATE project_invites SET ciphertext=repeat('x',16777216) WHERE id=(SELECT id FROM project_invites WHERE author_id=$1 AND expires_at>now() LIMIT 1)").bind(owner).execute(&pool).await.unwrap(); + sqlx::query("DELETE FROM project_invites WHERE author_id=$1 AND length(ciphertext)<16777216") + .bind(owner) + .execute(&pool) + .await + .unwrap(); + assert!(create("valid".into(), source.clone()).await.is_err()); + variable(&pool, project).await; + assert!(matches!( + create("valid".into(), source).await, + Err(AppError::Protocol(_, "project_snapshot_stale", _)) + )); +} + +#[sqlx::test] +async fn writer_holds_project_lock_until_commit_and_acceptance_observes_it(pool: sqlx::PgPool) { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + let id = variable(&pool, project).await; + let body = issue(&pool, owner, guest, project).await; + let mut tx = pool.begin().await.unwrap(); + snapshots::lock_project(&mut tx, project) + .await + .ok() + .unwrap(); + sqlx::query("UPDATE variables SET value='concurrent-newer-value' WHERE id=$1") + .bind(id) + .execute(&mut *tx) + .await + .unwrap(); + let accept = accept_invite( + State(state(pool.clone())), + UserId(guest), + ip(), + Json(body.clone()), + ); + tokio::pin!(accept); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(75), &mut accept) + .await + .is_err() + ); + tx.commit().await.unwrap(); + assert!(matches!( + accept.await, + Err(AppError::Protocol(_, "project_snapshot_stale", _)) + )); + assert_unclaimed(&pool, body.code, guest, project).await; +} diff --git a/src/routes/v2/invite/mod.rs b/src/routes/v2/invite/mod.rs index 8f895bf..6e03d97 100644 --- a/src/routes/v2/invite/mod.rs +++ b/src/routes/v2/invite/mod.rs @@ -1,7 +1,7 @@ - use super::*; +use super::*; -mod new; mod accept; +mod new; pub const INVITE_TAG: &str = "invite"; @@ -9,5 +9,6 @@ pub fn router(state: AppState) -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(new::new_invite)) .routes(routes!(accept::accept_invite)) + .routes(routes!(accept::prepare_invite)) .with_state(state) } diff --git a/src/routes/v2/invite/new.rs b/src/routes/v2/invite/new.rs index fea7881..42c0507 100644 --- a/src/routes/v2/invite/new.rs +++ b/src/routes/v2/invite/new.rs @@ -1,77 +1,75 @@ +use super::*; +use crate::{extractors::user::UserId, helpers::project_snapshot as snapshots}; use argon2::{ password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, Argon2, }; use axum::http::StatusCode; -use chrono::Utc; use uuid::Uuid; -use super::{extractors::user::UserId, helpers::project::user_in_project, *}; +const MAX_PAYLOAD_BYTES: usize = 1024 * 1024; +const MAX_ACTIVE_INVITES: i64 = 32; +const MAX_ACTIVE_BYTES: i64 = 16 * 1024 * 1024; #[derive(Serialize, Deserialize, ToSchema)] pub struct InviteBody { - project_id: Uuid, - ciphertext: String, + pub project_id: Uuid, + pub ciphertext: String, + pub protocol_version: Option, + pub snapshot: Option, } - #[derive(Serialize, Deserialize, ToSchema)] pub struct InviteResponse { pub invite_code: Uuid, pub verifier: Uuid, } - -#[utoipa::path( - post, - path = "/new", - tag = INVITE_TAG, - responses( - (status = 200, description = "Success", body = InviteResponse), - (status = 400, description = "Invalid public key"), - ), - security( - ("bearer" = []), - ), -)] +#[utoipa::path(post,path="/new",tag=INVITE_TAG,responses((status=200,body=InviteResponse),(status=409,description="Upgrade or regenerate invitation")),security(("bearer"=[])))] pub async fn new_invite( State(state): State, - UserId(user_id): UserId, + UserId(user): UserId, Json(body): Json, ) -> Result, AppError> { - if !user_in_project(user_id, body.project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); + snapshots::require_version(body.protocol_version)?; + if body.ciphertext.is_empty() || body.ciphertext.len() > MAX_PAYLOAD_BYTES { + return Err(AppError::Generic( + StatusCode::PAYLOAD_TOO_LARGE, + "Invitation ciphertext must be between 1 byte and 1 MiB".into(), + )); } - + let expected = body.snapshot.as_deref().ok_or_else(snapshots::upgrade)?; let verifier = Uuid::new_v4(); - let verifier_hash = Argon2::default() + let hash = Argon2::default() .hash_password(verifier.as_bytes(), &SaltString::generate(&mut OsRng)) .map_err(|e| AppError::Generic(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .to_string(); - - let exp = Utc::now() + chrono::Duration::hours(1); - - let res = sqlx::query!( - "INSERT INTO project_invites ( - project_id, - author_id, - expires_at, - verifier_argon2id, - ciphertext - ) - VALUES ($1, $2, $3, $4, $5) - RETURNING id; - ", - body.project_id, - user_id, - exp, - verifier_hash, - body.ciphertext, - ) - .fetch_one(&*state.db) - .await - .context("Failed to insert project invite")?; - + let mut tx = state.db.begin().await?; + snapshots::lock_project(&mut tx, body.project_id).await?; + snapshots::authorize(&mut tx, body.project_id, user).await?; + let current = snapshots::read(&mut tx, body.project_id, &[]).await?; + if current.snapshot != expected { + return Err(snapshots::stale()); + } + // Serialize this author's invite quota across all their projects. The project + // lock always precedes this lock, and redemption never acquires an author lock. + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('envx-project-invites'),hashtext($1))") + .bind(user.to_string()) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE project_invites SET ciphertext=NULL WHERE author_id=$1 AND ciphertext IS NOT NULL AND expires_at<=clock_timestamp()") + .bind(user).execute(&mut *tx).await?; + let (count,bytes):(i64,i64)=sqlx::query_as("SELECT count(*),COALESCE(sum(octet_length(ciphertext)),0)::bigint FROM project_invites WHERE author_id=$1 AND invited_id IS NULL AND expires_at>clock_timestamp() AND ciphertext IS NOT NULL") + .bind(user).fetch_one(&mut *tx).await?; + if count >= MAX_ACTIVE_INVITES || bytes + body.ciphertext.len() as i64 > MAX_ACTIVE_BYTES { + return Err(AppError::Generic( + StatusCode::TOO_MANY_REQUESTS, + "Active invitation quota exceeded; wait for invitations to expire".into(), + )); + } + let invite_code = sqlx::query_scalar("INSERT INTO project_invites(project_id,author_id,expires_at,verifier_argon2id,ciphertext,snapshot_hash) VALUES($1,$2,clock_timestamp()+interval '1 hour',$3,$4,$5) RETURNING id") + .bind(body.project_id).bind(user).bind(hash).bind(body.ciphertext).bind(expected).fetch_one(&mut *tx).await?; + tx.commit().await?; Ok(Json(InviteResponse { - invite_code: res.id, + invite_code, verifier, })) } diff --git a/src/routes/v2/project/add_user.rs b/src/routes/v2/project/add_user.rs index ce91eed..dcf59fd 100644 --- a/src/routes/v2/project/add_user.rs +++ b/src/routes/v2/project/add_user.rs @@ -20,21 +20,6 @@ pub async fn add_user( Path(project_id): Path, Json(users_to_add): Json>, ) -> Result<(), AppError> { - if !user_in_project(user_id, project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - sqlx::query!( - "INSERT INTO user_project_relations (user_id, project_id) - SELECT user_id, $2::uuid - FROM UNNEST($1::uuid[]) AS t(user_id) - ON CONFLICT DO NOTHING", - &users_to_add, - project_id, - ) - .execute(&*state.db) - .await - .context("Failed to insert user project relations")?; - - Ok(()) + let _ = (state, user_id, project_id, users_to_add); + Err(crate::helpers::project_snapshot::upgrade()) } diff --git a/src/routes/v2/project/mod.rs b/src/routes/v2/project/mod.rs index c36b78b..afa1c55 100644 --- a/src/routes/v2/project/mod.rs +++ b/src/routes/v2/project/mod.rs @@ -1,13 +1,14 @@ - use crate::structs::User; - use crate::*; - use crate::{extractors::user::UserId, helpers::project::user_in_project}; - use axum::extract::Path; - use utoipa::ToSchema; +use crate::structs::User; +use crate::*; +use crate::{extractors::user::UserId, helpers::project::user_in_project}; +use axum::extract::Path; +use utoipa::ToSchema; mod add_user; mod delete; mod info; mod remove_users; +mod snapshot; mod update; mod variables; @@ -21,5 +22,7 @@ pub fn router(state: AppState) -> OpenApiRouter { .routes(routes!(add_user::add_user)) .routes(routes!(remove_users::remove_users)) .routes(routes!(variables::variables)) + .routes(routes!(snapshot::snapshot)) + .routes(routes!(snapshot::rewrap)) .with_state(state) } diff --git a/src/routes/v2/project/remove_users.rs b/src/routes/v2/project/remove_users.rs index a50d567..005eae7 100644 --- a/src/routes/v2/project/remove_users.rs +++ b/src/routes/v2/project/remove_users.rs @@ -25,24 +25,10 @@ pub async fn remove_users( Path(project_id): Path, Json(body): Json, ) -> Result<(), AppError> { - if !user_in_project(user_id, project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - let users_to_remove = body + let users = body .user_ids .iter() .map(|user| user.to_uuid()) .collect::, _>>()?; - - sqlx::query!( - "DELETE FROM user_project_relations WHERE user_id = ANY($1::uuid[]) AND project_id = $2", - &users_to_remove, - project_id - ) - .execute(&*state.db) - .await - .context("Failed to remove user from project")?; - - Ok(()) + crate::helpers::project_snapshot::remove_members(&state, project_id, user_id, &users).await } diff --git a/src/routes/v2/project/snapshot.rs b/src/routes/v2/project/snapshot.rs new file mode 100644 index 0000000..64d51d3 --- /dev/null +++ b/src/routes/v2/project/snapshot.rs @@ -0,0 +1,142 @@ +use super::*; +use crate::{ + extractors::client_ip::ClientIp, + helpers::project_snapshot::{self as snapshots, RewrappedVariable, Snapshot}, +}; +use uuid::Uuid; + +#[derive(Deserialize, ToSchema)] +pub struct SnapshotBody { + #[serde(default)] + pub add_user_ids: Vec, +} +#[utoipa::path(post, path="/{project_id}/snapshot", tag=PROJECT_TAG, responses((status=200,body=Snapshot)), security(("bearer"=[])))] +pub async fn snapshot( + State(state): State, + UserId(user): UserId, + Path(project): Path, + Json(body): Json, +) -> Result, AppError> { + let mut tx = state.db.begin().await?; + snapshots::lock_project(&mut tx, project).await?; + snapshots::authorize(&mut tx, project, user).await?; + let result = snapshots::read(&mut tx, project, &body.add_user_ids).await?; + tx.commit().await?; + Ok(Json(result)) +} +#[derive(Deserialize, ToSchema)] +pub struct RewrapBody { + pub protocol_version: Option, + pub snapshot: String, + pub variables: Vec, + pub add_user_ids: Vec, +} +#[utoipa::path(post, path="/{project_id}/rewrap", tag=PROJECT_TAG, responses((status=200),(status=409,description="Snapshot changed")), security(("bearer"=[])))] +pub async fn rewrap( + State(state): State, + UserId(user): UserId, + Path(project): Path, + ClientIp(ip): ClientIp, + Json(body): Json, +) -> Result<(), AppError> { + snapshots::require_version(body.protocol_version)?; + let mut tx = state.db.begin().await?; + snapshots::lock_project(&mut tx, project).await?; + snapshots::authorize(&mut tx, project, user).await?; + let current = snapshots::read(&mut tx, project, &body.add_user_ids).await?; + snapshots::rewrap( + &state, + &mut tx, + ¤t, + &body.snapshot, + &body.variables, + user, + ip, + ) + .await?; + sqlx::query("INSERT INTO user_project_relations(user_id,project_id) SELECT user_id,$1 FROM UNNEST($2::uuid[]) t(user_id) ON CONFLICT DO NOTHING") + .bind(project).bind(body.add_user_ids).execute(&mut *tx).await?; + tx.commit().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::*; + fn ip() -> ClientIp { + ClientIp("127.0.0.1".parse().unwrap()) + } + #[sqlx::test] + async fn adding_users_rejects_stale_or_partial_batches_and_joins_atomically( + pool: sqlx::PgPool, + ) { + let owner = user(&pool).await; + let guest = user(&pool).await; + let project = project(&pool, owner).await; + let id:Uuid=sqlx::query_scalar("INSERT INTO variables(id,project_id,value) VALUES(gen_random_uuid(),$1,'original') RETURNING id").bind(project).fetch_one(&pool).await.unwrap(); + let read = || { + snapshot( + State(state(pool.clone())), + UserId(owner), + Path(project), + Json(SnapshotBody { + add_user_ids: vec![guest], + }), + ) + }; + let old = read().await.ok().unwrap().0; + sqlx::query("UPDATE variables SET value='changed' WHERE id=$1") + .bind(id) + .execute(&pool) + .await + .unwrap(); + let apply = |snapshot: String, variables: Vec| { + rewrap( + State(state(pool.clone())), + UserId(owner), + Path(project), + ip(), + Json(RewrapBody { + protocol_version: Some(1), + snapshot, + variables, + add_user_ids: vec![guest], + }), + ) + }; + assert!(matches!( + apply( + old.snapshot, + vec![RewrappedVariable { + id, + value: "rewrapped".into() + }] + ) + .await, + Err(AppError::Protocol(_, "project_snapshot_stale", _)) + )); + let fresh = read().await.ok().unwrap().0; + assert!(apply(fresh.snapshot.clone(), vec![]).await.is_err()); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM user_project_relations WHERE project_id=$1 AND user_id=$2", + ) + .bind(project) + .bind(guest) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); + assert!(apply( + fresh.snapshot, + vec![RewrappedVariable { + id, + value: "rewrapped".into() + }] + ) + .await + .is_ok()); + let value:String=sqlx::query_scalar("SELECT value FROM variables v JOIN user_project_relations r ON r.project_id=v.project_id WHERE v.id=$1 AND r.user_id=$2").bind(id).bind(guest).fetch_one(&pool).await.unwrap(); + assert_eq!(value, "rewrapped"); + } +} diff --git a/src/routes/v2/variables/delete.rs b/src/routes/v2/variables/delete.rs index 0fd68f1..8c8b23d 100644 --- a/src/routes/v2/variables/delete.rs +++ b/src/routes/v2/variables/delete.rs @@ -18,22 +18,5 @@ pub async fn delete( Path(variable_id): Path, UserId(user_id): UserId, ) -> Result<(), AppError> { - let variable = sqlx::query!( - "SELECT id, value, project_id FROM variables WHERE id = $1", - variable_id - ) - .fetch_one(&*state.db) - .await - .context("Failed to get variable")?; - - if !user_in_project(user_id, variable.project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - sqlx::query!("DELETE FROM variables WHERE id = $1", variable_id) - .execute(&*state.db) - .await - .context("Failed to delete variable")?; - - Ok(()) + crate::helpers::variables::delete(&state, user_id, variable_id).await } diff --git a/src/routes/v2/variables/set_many.rs b/src/routes/v2/variables/set_many.rs index b1176d2..a2f2f08 100644 --- a/src/routes/v2/variables/set_many.rs +++ b/src/routes/v2/variables/set_many.rs @@ -1,5 +1,4 @@ use crate::extractors::client_ip::ClientIp; -use crate::helpers::caps; use super::*; @@ -34,37 +33,13 @@ pub async fn set_many( ClientIp(ip): ClientIp, Json(body): Json, ) -> Result>, AppError> { - let project_id = body.project_id; - - if !user_in_project(user_id, project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - let (values, tags): (Vec<_>, Vec<_>) = body + let (values, tags) = body .variables .into_iter() .map(|v| (v.value, v.tag.unwrap_or_default())) .unzip(); - - let value_refs: Vec<&str> = values.iter().map(String::as_str).collect(); - caps::check_per_value(&state.caps, &value_refs)?; - caps::check_project_for_insert(&state.caps, &state.db, project_id, &value_refs).await?; - let delta: i64 = value_refs.iter().map(|v| v.len() as i64).sum(); - caps::check_user_total(&state.caps, &state.db, user_id, delta).await?; - caps::check_and_record_ip(&state.caps, &state.db, ip, delta).await?; - - let variables = sqlx::query!( - "INSERT INTO variables (value, project_id, tag) - SELECT value, $2::uuid, tag - FROM UNNEST($1::text[], $3::text[]) AS t(value, tag) - RETURNING id", - &values, - project_id, - &tags, - ) - .fetch_all(&*state.db) - .await - .context("Failed to insert variables")?; - - Ok(Json(variables.iter().map(|v| v.id).collect::>())) + Ok(Json( + crate::helpers::variables::insert_many(&state, user_id, ip, body.project_id, values, tags) + .await?, + )) } diff --git a/src/routes/variables.rs b/src/routes/variables.rs index 190bd52..729b025 100644 --- a/src/routes/variables.rs +++ b/src/routes/variables.rs @@ -1,5 +1,4 @@ use crate::extractors::client_ip::ClientIp; -use crate::helpers::caps; use crate::structs::Variable; use crate::traits::to_uuid::ToUuid; use crate::*; @@ -25,31 +24,17 @@ pub async fn new_variable( ClientIp(ip): ClientIp, Json(body): Json, ) -> Result, AppError> { - let project_id = body.project_id.to_uuid()?; - - if !user_in_project(user_id, project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - let values = [body.value.as_str()]; - caps::check_per_value(&state.caps, &values)?; - caps::check_project_for_insert(&state.caps, &state.db, project_id, &values).await?; - let delta = body.value.len() as i64; - caps::check_user_total(&state.caps, &state.db, user_id, delta).await?; - caps::check_and_record_ip(&state.caps, &state.db, ip, delta).await?; - - let variable = sqlx::query!( - "INSERT INTO variables (value, project_id, tag) VALUES ($1, $2, $3) RETURNING id", - body.value, - project_id, - body.tag.unwrap_or_default() + let ids = crate::helpers::variables::insert_many( + &state, + user_id, + ip, + body.project_id.to_uuid()?, + vec![body.value], + vec![body.tag.unwrap_or_default()], ) - .fetch_one(&*state.db) - .await - .context("Failed to insert variable")?; - + .await?; Ok(Json(NewVariableReturnType { - id: variable.id.to_string(), + id: ids[0].to_string(), })) } @@ -130,24 +115,7 @@ pub async fn delete_variable( Path(variable_id): Path, UserId(user_id): UserId, ) -> Result<(), AppError> { - let variable = sqlx::query!( - "SELECT id, value, project_id FROM variables WHERE id = $1", - variable_id - ) - .fetch_one(&*state.db) - .await - .context("Failed to get variable")?; - - if !user_in_project(user_id, variable.project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - sqlx::query!("DELETE FROM variables WHERE id = $1", variable_id) - .execute(&*state.db) - .await - .context("Failed to delete variable")?; - - Ok(()) + crate::helpers::variables::delete(&state, user_id, variable_id).await } #[derive(Serialize, Deserialize)] @@ -173,56 +141,25 @@ pub async fn set_many_variables_v2( ClientIp(ip): ClientIp, Json(body): Json, ) -> Result>, AppError> { - let project_id = body.project_id.to_uuid()?; - - if !user_in_project(user_id, project_id, &state.db).await? { - return Err(AppError::Error(Errors::Unauthorized)); - } - - let values: Vec<&str> = body.variables.iter().map(|v| v.value.as_str()).collect(); - caps::check_per_value(&state.caps, &values)?; - caps::check_project_for_insert(&state.caps, &state.db, project_id, &values).await?; - let delta: i64 = values.iter().map(|v| v.len() as i64).sum(); - caps::check_user_total(&state.caps, &state.db, user_id, delta).await?; - caps::check_and_record_ip(&state.caps, &state.db, ip, delta).await?; - - let variables = sqlx::query!( - "INSERT INTO variables (value, project_id, tag) SELECT * FROM UNNEST($1::text[], $2::uuid[], $3::text[]) RETURNING id", - &body.variables.iter().map(|v| v.value.clone()).collect::>(), - &vec![project_id; body.variables.len()], - // &body.variables.iter().map(|v| v.tag.clone()).collect::>>() - &body.variables.iter().map(|v| v.tag.clone().unwrap_or_default()).collect::>() + let (values, tags) = body + .variables + .into_iter() + .map(|v| (v.value, v.tag.unwrap_or_default())) + .unzip(); + let ids = crate::helpers::variables::insert_many( + &state, + user_id, + ip, + body.project_id.to_uuid()?, + values, + tags, ) - .fetch_all(&*state.db) - .await - .context("Failed to insert variables")?; - + .await?; Ok(Json( - variables - .iter() - .map(|v| V2SetManyReturnType { - id: v.id.to_string(), - }) - .collect::>(), + ids.into_iter() + .map(|id| V2SetManyReturnType { id: id.to_string() }) + .collect(), )) - - // let variables = sqlx::query!( - // "INSERT INTO variables (value, project_id) SELECT * FROM UNNEST($1::text[], $2::uuid[]) RETURNING id", - // &body.variables, - // &vec![project_id; body.variables.len()] - // ) - // .fetch_all(&*state.db) - // .await - // .context("Failed to insert variables")?; - // - // Ok(Json( - // variables - // .iter() - // .map(|v| SetManyReturnType { - // id: v.id.to_string(), - // }) - // .collect::>(), - // )) } #[cfg(test)] From 7fe4d22651f3312e6376d834a22fd7ce3646510e Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Thu, 24 Sep 2026 04:47:14 -0700 Subject: [PATCH 09/12] [agent] build: refresh invitation query metadata and formatting Co-Authored-By: GPT-6 (OpenAI) --- ...2d43acc3b923c3b4de92d1add69fedb75431f.json | 24 ------- ...2a0166590fceec30a5fcd7ed31eef02ac2092.json | 15 ----- ...27ebe62b0a8071e029630bcf740de1f32f867.json | 24 ------- ...09821269d7727832e8df0a60d4b190bb29877.json | 15 ----- ...218433bf450b101f1f61ca16299c2a9a80d65.json | 14 ---- ...22c4f14b844eee0c943cf140fd69fe3405a8c.json | 26 -------- ...c2c37ba82267b60474bcef69ec020c8298316.json | 15 ----- ...d148d49e109b12f44af3f58a550c9dd7397b3.json | 64 ------------------- ...6d2de19d94efd3201874758631eb7c85e8ede.json | 24 ------- ...e871398d17b46d5b399a5634d3f9543dbc189.json | 23 ------- src/helpers/mod.rs | 2 +- src/routes/v2/mod.rs | 1 - src/routes/v2/projects/mod.rs | 7 +- src/routes/v2/user/mod.rs | 9 +-- src/routes/v2/variables/mod.rs | 11 ++-- 15 files changed, 16 insertions(+), 258 deletions(-) delete mode 100644 .sqlx/query-05b48dc5ba42bccba2eb07a6f212d43acc3b923c3b4de92d1add69fedb75431f.json delete mode 100644 .sqlx/query-08eae3c0fe8d3216914c6222baf2a0166590fceec30a5fcd7ed31eef02ac2092.json delete mode 100644 .sqlx/query-0988fd19b6d220c531a9f8595ff27ebe62b0a8071e029630bcf740de1f32f867.json delete mode 100644 .sqlx/query-1e6182ddd1c95e784d0b42819e009821269d7727832e8df0a60d4b190bb29877.json delete mode 100644 .sqlx/query-348418c0b6a8bc63030eaeac3f5218433bf450b101f1f61ca16299c2a9a80d65.json delete mode 100644 .sqlx/query-62055684c67264ed925999a650d22c4f14b844eee0c943cf140fd69fe3405a8c.json delete mode 100644 .sqlx/query-89bba6e31d5deac0afb6da6d52ac2c37ba82267b60474bcef69ec020c8298316.json delete mode 100644 .sqlx/query-9529be080008a3a5e62bc86d01dd148d49e109b12f44af3f58a550c9dd7397b3.json delete mode 100644 .sqlx/query-b74aad6bf9e966112839fe7dac46d2de19d94efd3201874758631eb7c85e8ede.json delete mode 100644 .sqlx/query-c9f0b460a2be57201d1a300b181e871398d17b46d5b399a5634d3f9543dbc189.json diff --git a/.sqlx/query-05b48dc5ba42bccba2eb07a6f212d43acc3b923c3b4de92d1add69fedb75431f.json b/.sqlx/query-05b48dc5ba42bccba2eb07a6f212d43acc3b923c3b4de92d1add69fedb75431f.json deleted file mode 100644 index 7ddf84c..0000000 --- a/.sqlx/query-05b48dc5ba42bccba2eb07a6f212d43acc3b923c3b4de92d1add69fedb75431f.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO variables (value, project_id, tag) VALUES ($1, $2, $3) RETURNING id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Text", - "Uuid", - "Varchar" - ] - }, - "nullable": [ - false - ] - }, - "hash": "05b48dc5ba42bccba2eb07a6f212d43acc3b923c3b4de92d1add69fedb75431f" -} diff --git a/.sqlx/query-08eae3c0fe8d3216914c6222baf2a0166590fceec30a5fcd7ed31eef02ac2092.json b/.sqlx/query-08eae3c0fe8d3216914c6222baf2a0166590fceec30a5fcd7ed31eef02ac2092.json deleted file mode 100644 index cb72b28..0000000 --- a/.sqlx/query-08eae3c0fe8d3216914c6222baf2a0166590fceec30a5fcd7ed31eef02ac2092.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM user_project_relations WHERE user_id = ANY($1::uuid[]) AND project_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "UuidArray", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "08eae3c0fe8d3216914c6222baf2a0166590fceec30a5fcd7ed31eef02ac2092" -} diff --git a/.sqlx/query-0988fd19b6d220c531a9f8595ff27ebe62b0a8071e029630bcf740de1f32f867.json b/.sqlx/query-0988fd19b6d220c531a9f8595ff27ebe62b0a8071e029630bcf740de1f32f867.json deleted file mode 100644 index 95a5561..0000000 --- a/.sqlx/query-0988fd19b6d220c531a9f8595ff27ebe62b0a8071e029630bcf740de1f32f867.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO variables (value, project_id, tag) SELECT * FROM UNNEST($1::text[], $2::uuid[], $3::text[]) RETURNING id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "TextArray", - "UuidArray", - "TextArray" - ] - }, - "nullable": [ - false - ] - }, - "hash": "0988fd19b6d220c531a9f8595ff27ebe62b0a8071e029630bcf740de1f32f867" -} diff --git a/.sqlx/query-1e6182ddd1c95e784d0b42819e009821269d7727832e8df0a60d4b190bb29877.json b/.sqlx/query-1e6182ddd1c95e784d0b42819e009821269d7727832e8df0a60d4b190bb29877.json deleted file mode 100644 index bd4cd6a..0000000 --- a/.sqlx/query-1e6182ddd1c95e784d0b42819e009821269d7727832e8df0a60d4b190bb29877.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO user_project_relations (user_id, project_id)\n SELECT user_id, $2::uuid\n FROM UNNEST($1::uuid[]) AS t(user_id)\n ON CONFLICT DO NOTHING", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "UuidArray", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "1e6182ddd1c95e784d0b42819e009821269d7727832e8df0a60d4b190bb29877" -} diff --git a/.sqlx/query-348418c0b6a8bc63030eaeac3f5218433bf450b101f1f61ca16299c2a9a80d65.json b/.sqlx/query-348418c0b6a8bc63030eaeac3f5218433bf450b101f1f61ca16299c2a9a80d65.json deleted file mode 100644 index 4726771..0000000 --- a/.sqlx/query-348418c0b6a8bc63030eaeac3f5218433bf450b101f1f61ca16299c2a9a80d65.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM variables WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "348418c0b6a8bc63030eaeac3f5218433bf450b101f1f61ca16299c2a9a80d65" -} diff --git a/.sqlx/query-62055684c67264ed925999a650d22c4f14b844eee0c943cf140fd69fe3405a8c.json b/.sqlx/query-62055684c67264ed925999a650d22c4f14b844eee0c943cf140fd69fe3405a8c.json deleted file mode 100644 index 27b7385..0000000 --- a/.sqlx/query-62055684c67264ed925999a650d22c4f14b844eee0c943cf140fd69fe3405a8c.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO project_invites (\n project_id,\n author_id,\n expires_at,\n verifier_argon2id,\n ciphertext\n )\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id;\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Timestamptz", - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "62055684c67264ed925999a650d22c4f14b844eee0c943cf140fd69fe3405a8c" -} diff --git a/.sqlx/query-89bba6e31d5deac0afb6da6d52ac2c37ba82267b60474bcef69ec020c8298316.json b/.sqlx/query-89bba6e31d5deac0afb6da6d52ac2c37ba82267b60474bcef69ec020c8298316.json deleted file mode 100644 index 6e16837..0000000 --- a/.sqlx/query-89bba6e31d5deac0afb6da6d52ac2c37ba82267b60474bcef69ec020c8298316.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO user_project_relations (user_id, project_id)\n VALUES ($1, $2)\n ON CONFLICT DO NOTHING", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "89bba6e31d5deac0afb6da6d52ac2c37ba82267b60474bcef69ec020c8298316" -} diff --git a/.sqlx/query-9529be080008a3a5e62bc86d01dd148d49e109b12f44af3f58a550c9dd7397b3.json b/.sqlx/query-9529be080008a3a5e62bc86d01dd148d49e109b12f44af3f58a550c9dd7397b3.json deleted file mode 100644 index 7c77c0a..0000000 --- a/.sqlx/query-9529be080008a3a5e62bc86d01dd148d49e109b12f44af3f58a550c9dd7397b3.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT * FROM project_invites WHERE id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "project_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "author_id", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "invited_id", - "type_info": "Uuid" - }, - { - "ordinal": 4, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "expires_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, - "name": "verifier_argon2id", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "ciphertext", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - false, - true, - false, - false, - false, - true - ] - }, - "hash": "9529be080008a3a5e62bc86d01dd148d49e109b12f44af3f58a550c9dd7397b3" -} diff --git a/.sqlx/query-b74aad6bf9e966112839fe7dac46d2de19d94efd3201874758631eb7c85e8ede.json b/.sqlx/query-b74aad6bf9e966112839fe7dac46d2de19d94efd3201874758631eb7c85e8ede.json deleted file mode 100644 index fd50903..0000000 --- a/.sqlx/query-b74aad6bf9e966112839fe7dac46d2de19d94efd3201874758631eb7c85e8ede.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO variables (value, project_id, tag)\n SELECT value, $2::uuid, tag \n FROM UNNEST($1::text[], $3::text[]) AS t(value, tag)\n RETURNING id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "TextArray", - "Uuid", - "TextArray" - ] - }, - "nullable": [ - false - ] - }, - "hash": "b74aad6bf9e966112839fe7dac46d2de19d94efd3201874758631eb7c85e8ede" -} diff --git a/.sqlx/query-c9f0b460a2be57201d1a300b181e871398d17b46d5b399a5634d3f9543dbc189.json b/.sqlx/query-c9f0b460a2be57201d1a300b181e871398d17b46d5b399a5634d3f9543dbc189.json deleted file mode 100644 index 64f8629..0000000 --- a/.sqlx/query-c9f0b460a2be57201d1a300b181e871398d17b46d5b399a5634d3f9543dbc189.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE project_invites \n SET invited_id = $1,\n ciphertext = NULL \n WHERE id = $2\n AND invited_id IS NULL\n AND ciphertext IS NOT NULL\n AND expires_at > NOW()\n RETURNING id;\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "c9f0b460a2be57201d1a300b181e871398d17b46d5b399a5634d3f9543dbc189" -} diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index d00b1bd..145544f 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -1,5 +1,5 @@ pub mod caps; pub mod project; pub mod project_snapshot; -pub mod variables; pub mod registration; +pub mod variables; diff --git a/src/routes/v2/mod.rs b/src/routes/v2/mod.rs index ecb124b..96f83cd 100644 --- a/src/routes/v2/mod.rs +++ b/src/routes/v2/mod.rs @@ -1,4 +1,3 @@ - use crate::*; use utoipa::ToSchema; use utoipa_axum::router::OpenApiRouter; diff --git a/src/routes/v2/projects/mod.rs b/src/routes/v2/projects/mod.rs index a033557..e863bcc 100644 --- a/src/routes/v2/projects/mod.rs +++ b/src/routes/v2/projects/mod.rs @@ -1,6 +1,7 @@ - use crate::extractors::user::UserId; - use crate::*; - use utoipa::ToSchema; + +use crate::extractors::user::UserId; +use crate::*; +use utoipa::ToSchema; mod list; mod new; diff --git a/src/routes/v2/user/mod.rs b/src/routes/v2/user/mod.rs index 68a5766..3e56a79 100644 --- a/src/routes/v2/user/mod.rs +++ b/src/routes/v2/user/mod.rs @@ -1,7 +1,8 @@ - use crate::*; - use crate::extractors::user::UserId; - use utoipa::ToSchema; - use uuid::Uuid; + +use crate::extractors::user::UserId; +use crate::*; +use utoipa::ToSchema; +use uuid::Uuid; mod get; mod get_many; diff --git a/src/routes/v2/variables/mod.rs b/src/routes/v2/variables/mod.rs index 763d91d..844bc83 100644 --- a/src/routes/v2/variables/mod.rs +++ b/src/routes/v2/variables/mod.rs @@ -1,8 +1,9 @@ - use crate::*; - use crate::{extractors::user::UserId, helpers::project::user_in_project}; - use axum::extract::Path; - use utoipa::ToSchema; - use uuid::Uuid; + +use crate::*; +use crate::{extractors::user::UserId, helpers::project::user_in_project}; +use axum::extract::Path; +use utoipa::ToSchema; +use uuid::Uuid; mod delete; mod get; From a22f133c34405d0dd9ac111dbf2d06d47589b2b7 Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Thu, 24 Sep 2026 04:52:34 -0700 Subject: [PATCH 10/12] [agent] style: normalize module headers for CI Co-Authored-By: GPT-6 (OpenAI) --- src/routes/v2/projects/mod.rs | 1 - src/routes/v2/user/mod.rs | 1 - src/routes/v2/variables/mod.rs | 1 - 3 files changed, 3 deletions(-) diff --git a/src/routes/v2/projects/mod.rs b/src/routes/v2/projects/mod.rs index e863bcc..7c952be 100644 --- a/src/routes/v2/projects/mod.rs +++ b/src/routes/v2/projects/mod.rs @@ -1,4 +1,3 @@ - use crate::extractors::user::UserId; use crate::*; use utoipa::ToSchema; diff --git a/src/routes/v2/user/mod.rs b/src/routes/v2/user/mod.rs index 3e56a79..0a287d5 100644 --- a/src/routes/v2/user/mod.rs +++ b/src/routes/v2/user/mod.rs @@ -1,4 +1,3 @@ - use crate::extractors::user::UserId; use crate::*; use utoipa::ToSchema; diff --git a/src/routes/v2/variables/mod.rs b/src/routes/v2/variables/mod.rs index 844bc83..d139f79 100644 --- a/src/routes/v2/variables/mod.rs +++ b/src/routes/v2/variables/mod.rs @@ -1,4 +1,3 @@ - use crate::*; use crate::{extractors::user::UserId, helpers::project::user_in_project}; use axum::extract::Path; From dc35d53f8ce77efda6fd97f08c73da1362098087 Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Thu, 24 Sep 2026 04:56:40 -0700 Subject: [PATCH 11/12] [agent] fix: preserve replacement cap semantics and bound identity parsing Co-Authored-By: GPT-6 Astra (OpenAI) --- README.md | 9 +++++ src/helpers/caps.rs | 30 ----------------- src/helpers/variables.rs | 65 +++++++++++++++++++++++++++++++------ src/routes/v2/social/mod.rs | 9 +++++ 4 files changed, 73 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 7c8e3ba..752fd40 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,12 @@ proxy that overwrites `X-Real-IP`, and untrusted callers cannot connect directly This uses a single valid `X-Real-IP`; `X-Forwarded-For` is never trusted. Railway's HTTP ingress provides `X-Real-IP` (see its public networking specs). Self-hosted operators must configure their ingress accordingly before enabling this option. + +Railway deployment checklist: + +- Route public requests through Railway HTTP ingress and prevent untrusted direct access. +- Set `ENVX_TRUST_PROXY=true` on the API service before deploying. +- Verify the ingress supplies one valid `X-Real-IP`; missing or malformed values fall back to the peer IP. +- Keep this flag unset for direct connections or proxies that do not overwrite client-supplied `X-Real-IP`. + +Railway documents its client IP header in the [public networking specifications](https://docs.railway.com/networking/public-networking/specs-and-limits). diff --git a/src/helpers/caps.rs b/src/helpers/caps.rs index ae0a850..fb6ae32 100644 --- a/src/helpers/caps.rs +++ b/src/helpers/caps.rs @@ -12,7 +12,6 @@ use sqlx::types::Uuid; use crate::config::Caps; use crate::error::AppError; -use crate::state::DB; use crate::Context as _; fn too_big(msg: String) -> AppError { @@ -47,15 +46,6 @@ pub fn check_per_value(caps: &Caps, values: &[&str]) -> Result<(), AppError> { /// Reject an INSERT that would push the project past either the count /// cap or the byte cap. -pub async fn check_project_for_insert( - caps: &Caps, - db: &DB, - project_id: Uuid, - new_values: &[&str], -) -> Result<(), AppError> { - let mut connection = db.acquire().await?; - check_project_for_insert_on(caps, &mut connection, project_id, new_values).await -} pub async fn check_project_for_insert_on( caps: &Caps, connection: &mut sqlx::PgConnection, @@ -142,16 +132,6 @@ pub async fn check_project_for_update_on( /// projects past the per-user cap. `delta_bytes` is the net bytes the /// pending write would add (new bytes minus replaced bytes; may be /// negative for updates that shrink values). -pub async fn check_user_total( - caps: &Caps, - db: &DB, - user_id: Uuid, - delta_bytes: i64, -) -> Result<(), AppError> { - let mut connection = db.acquire().await?; - check_user_total_on(caps, &mut connection, user_id, delta_bytes).await -} - pub async fn check_user_total_on( caps: &Caps, connection: &mut sqlx::PgConnection, @@ -187,16 +167,6 @@ pub async fn check_user_total_on( /// Reject a write from an IP that has uploaded more than the per-IP cap /// in the last 24 hours, then record this write's byte count in the /// upload_log table. Opportunistically prunes rows older than 24h. -pub async fn check_and_record_ip( - caps: &Caps, - db: &DB, - ip: IpAddr, - bytes: i64, -) -> Result<(), AppError> { - let mut connection = db.acquire().await?; - check_and_record_ip_on(caps, &mut connection, ip, bytes).await -} - pub async fn check_and_record_ip_on( caps: &Caps, connection: &mut sqlx::PgConnection, diff --git a/src/helpers/variables.rs b/src/helpers/variables.rs index cc6b406..3f313d7 100644 --- a/src/helpers/variables.rs +++ b/src/helpers/variables.rs @@ -108,17 +108,9 @@ pub async fn replace_many( "Replacement variables changed or do not belong to this project", )); } - let (count, bytes): (i64,i64) = sqlx::query_as("SELECT count(*), COALESCE(sum(octet_length(value)),0)::bigint FROM variables WHERE project_id=$1") - .bind(project).fetch_one(&mut *tx).await?; + caps::check_project_for_insert_on(&state.caps, &mut tx, project, &refs).await?; + // The transaction already removed replaced rows, so add the full new size. let added: i64 = values.iter().map(|v| v.len() as i64).sum(); - if state.caps.max_variables_per_project > 0 - && count + values.len() as i64 > state.caps.max_variables_per_project - { - return Err(bad("Project variable cap exceeded")); - } - if state.caps.max_project_bytes > 0 && bytes + added > state.caps.max_project_bytes { - return Err(bad("Project size cap exceeded")); - } caps::check_user_total_on(&state.caps, &mut tx, user_id, added).await?; caps::check_and_record_ip_on(&state.caps, &mut tx, ip, added).await?; let ids: Vec = sqlx::query_scalar("INSERT INTO variables(id,project_id,value) SELECT gen_random_uuid(),$1,value FROM UNNEST($2::text[]) AS t(value) RETURNING id") @@ -171,6 +163,59 @@ pub async fn delete(state: &AppState, user: Uuid, id: Uuid) -> Result<(), AppErr mod tests { use super::*; use crate::test_support::*; + #[sqlx::test] + async fn replacement_at_user_cap_and_project_cap_rollback(pool: sqlx::PgPool) { + use axum::response::IntoResponse; + let owner = user(&pool).await; + let project = project(&pool, owner).await; + let id: Uuid = sqlx::query_scalar( + "INSERT INTO variables(project_id,value) VALUES($1,'original') RETURNING id", + ) + .bind(project) + .fetch_one(&pool) + .await + .unwrap(); + let mut state = state(pool.clone()); + let caps = std::sync::Arc::make_mut(&mut state.caps); + caps.max_user_bytes = 8; + caps.max_project_bytes = 8; + caps.max_variables_per_project = 1; + let ids = replace_many( + &state, + owner, + "127.0.0.1".parse().unwrap(), + project, + vec!["newvalue".into()], + vec![id], + ) + .await + .ok() + .expect("same-size overwrite at full cap"); + for values in [vec!["too-large".into()], vec!["one".into(), "two".into()]] { + let error = replace_many( + &state, + owner, + "127.0.0.1".parse().unwrap(), + project, + values, + ids.clone(), + ) + .await + .err() + .expect("project cap must reject replacement"); + assert_eq!( + error.into_response().status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + let stored: String = sqlx::query_scalar("SELECT value FROM variables WHERE id=$1") + .bind(ids[0]) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored, "newvalue"); + } + } + #[sqlx::test] async fn failed_insert_rolls_back_deleted_values(pool: sqlx::PgPool) { let owner = user(&pool).await; diff --git a/src/routes/v2/social/mod.rs b/src/routes/v2/social/mod.rs index d204394..34d8cc0 100644 --- a/src/routes/v2/social/mod.rs +++ b/src/routes/v2/social/mod.rs @@ -43,6 +43,15 @@ async fn identity(pool: &sqlx::PgPool, id: Uuid) -> Result { .fetch_optional(pool) .await? .ok_or_else(not_found)?; + // Legacy registrations predate the smaller registration limit. Bound parsing + // without rejecting historical keys that still fit the authentication limit. + if public_key.len() > 1024 * 1024 { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "Invalid stored public key", + ) + .into()); + } let key = SignedPublicKey::from_string(&public_key) .map_err(|_| { AppError::Generic( From 08b7ab09ad8ac0af540ec9998cef4c301686f982 Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Thu, 24 Sep 2026 04:59:08 -0700 Subject: [PATCH 12/12] [agent] fix: reject unverifiable legacy social identities Co-Authored-By: GPT-6 Astra (OpenAI) --- src/routes/v2/social/links.rs | 6 +++++ src/routes/v2/social/mod.rs | 6 +++++ src/routes/v2/social/tests.rs | 51 +++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/src/routes/v2/social/links.rs b/src/routes/v2/social/links.rs index 7321705..317eb21 100644 --- a/src/routes/v2/social/links.rs +++ b/src/routes/v2/social/links.rs @@ -138,6 +138,12 @@ pub(super) async fn create( if expires <= now || expires > now + chrono::Duration::days(30) { return Err(bad("Link expiry must be in the next 30 days")); } + // Historical registrations only parsed keys; reject unusable identities + // before publishing an invitation that clients cannot safely accept. + identity(&state.db, user).await?; + if let Some(target) = body.target_id { + identity(&state.db, target).await?; + } let mut random = [0u8; 32]; rand::rng().fill_bytes(&mut random); let token = hex::encode(random); diff --git a/src/routes/v2/social/mod.rs b/src/routes/v2/social/mod.rs index 34d8cc0..bc8755e 100644 --- a/src/routes/v2/social/mod.rs +++ b/src/routes/v2/social/mod.rs @@ -60,6 +60,12 @@ async fn identity(pool: &sqlx::PgPool, id: Uuid) -> Result { ) })? .0; + key.verify().map_err(|_| { + AppError::Generic( + StatusCode::INTERNAL_SERVER_ERROR, + "Invalid stored public key".into(), + ) + })?; Ok(Identity { id, username, diff --git a/src/routes/v2/social/tests.rs b/src/routes/v2/social/tests.rs index 39e8dfe..dd15758 100644 --- a/src/routes/v2/social/tests.rs +++ b/src/routes/v2/social/tests.rs @@ -593,3 +593,54 @@ async fn key_snapshots_are_canonical_accounted_and_erased(pool: sqlx::PgPool) { assert!(sender.is_empty()); assert!(recipient.is_empty()); } + +#[sqlx::test] +async fn legacy_key_with_invalid_self_signature_cannot_join_social_graph(pool: sqlx::PgPool) { + let (invalid, key) = user(&pool).await; + let (valid, other_key) = user(&pool).await; + let mut public = SignedPublicKey::from(key); + public.details = SignedPublicKey::from(other_key).details; + let armor = public.to_armored_string(ArmorOptions::default()).unwrap(); + let parsed = SignedPublicKey::from_string(&armor).unwrap().0; + assert!( + parsed.verify().is_err(), + "fixture must parse but fail self-signature validation" + ); + sqlx::query("UPDATE users SET public_key=$1 WHERE id=$2") + .bind(armor) + .bind(invalid) + .execute(&pool) + .await + .unwrap(); + assert!(identity(&pool, invalid).await.is_err()); + for (creator, target) in [(invalid, None), (valid, Some(invalid))] { + assert!(links::create( + State(state(pool.clone())), + UserId(creator), + Json(links::CreateLink { + label: "amber-otter".into(), + target_id: target, + expires_at: None, + }) + ) + .await + .is_err()); + } + let invitation = link(&pool, valid, None).await; + assert!(links::redeem( + State(state(pool.clone())), + UserId(invalid), + Json(links::Redeem { + id: invitation.link.id, + token: invitation.token, + receipt: "untrusted".into(), + }) + ) + .await + .is_err()); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM friendships") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); +}