From 5a1a49e436e59b2e3a22b4c68a4446a54bad21d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 15:16:57 +0000 Subject: [PATCH 01/10] test: add failing session parser and store tests Red evidence for SQLite-backed user sessions (issue #555 surface). Parser tests require new listen/respond clauses and session statements; store tests require SessionManager backends. Co-authored-by: logbie --- tests/session_store_test.rs | 227 +++++++++++++++ tests/web_server_session_parser_test.rs | 360 ++++++++++++++++++++++++ 2 files changed, 587 insertions(+) create mode 100644 tests/session_store_test.rs create mode 100644 tests/web_server_session_parser_test.rs diff --git a/tests/session_store_test.rs b/tests/session_store_test.rs new file mode 100644 index 00000000..be7f5e06 --- /dev/null +++ b/tests/session_store_test.rs @@ -0,0 +1,227 @@ +// TDD unit tests for the session store backends (memory, file, SQLite). +// +// These talk to the real store — no mocks of the persistence boundary. + +use std::sync::Arc; +use std::time::Duration; +use wfl::interpreter::sessions::{ + SessionConfig, SessionManager, SessionSameSite, SessionStorageKind, +}; +use wfl::interpreter::value::Value; + +fn memory_config() -> SessionConfig { + SessionConfig { + timeout_ms: 60_000, + storage: SessionStorageKind::Memory, + db_path: "wfl_sessions.db".into(), + file_path: "wfl_sessions.json".into(), + cookie_name: "wfl_sid".into(), + cookie_secure: false, + cookie_samesite: SessionSameSite::Lax, + cookie_httponly: true, + csrf_enabled: false, + max_sessions: 10, + } +} + +fn text(s: &str) -> Value { + Value::Text(s.into()) +} + +#[tokio::test] +async fn memory_create_get_set_destroy() { + let manager = SessionManager::new(memory_config()).await.unwrap(); + let created = manager.create().await.unwrap(); + assert!(!created.id.is_empty()); + assert_eq!(created.id.len(), 64, "32 CSPRNG bytes as hex"); + + manager + .set_value(&created.id, "user_id", text("alice")) + .await + .unwrap(); + let loaded = manager.get(&created.id).await.unwrap().expect("session"); + assert_eq!(loaded.id, created.id); + match loaded.data.get("user_id") { + Some(Value::Text(s)) => assert_eq!(s.as_ref(), "alice"), + other => panic!("expected alice, got {other:?}"), + } + + manager.destroy(&created.id).await.unwrap(); + assert!(manager.get(&created.id).await.unwrap().is_none()); +} + +#[tokio::test] +async fn unknown_and_forged_ids_return_none() { + let manager = SessionManager::new(memory_config()).await.unwrap(); + assert!(manager.get("not-a-real-session").await.unwrap().is_none()); + assert!( + manager + .get("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .await + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn expired_session_get_returns_none() { + let mut cfg = memory_config(); + cfg.timeout_ms = 1; + let manager = SessionManager::new(cfg).await.unwrap(); + let created = manager.create().await.unwrap(); + tokio::time::sleep(Duration::from_millis(5)).await; + assert!(manager.get(&created.id).await.unwrap().is_none()); +} + +#[tokio::test] +async fn max_sessions_denies_create() { + let mut cfg = memory_config(); + cfg.max_sessions = 1; + let manager = SessionManager::new(cfg).await.unwrap(); + manager.create().await.unwrap(); + let err = manager.create().await.expect_err("store should be full"); + assert!( + err.to_lowercase().contains("maximum") || err.to_lowercase().contains("full"), + "expected a max-sessions error, got {err}" + ); +} + +#[tokio::test] +async fn set_after_destroy_errors() { + let manager = SessionManager::new(memory_config()).await.unwrap(); + let created = manager.create().await.unwrap(); + manager.destroy(&created.id).await.unwrap(); + let err = manager + .set_value(&created.id, "x", text("y")) + .await + .expect_err("set after destroy"); + assert!( + err.to_lowercase().contains("destroy") + || err.to_lowercase().contains("not found") + || err.to_lowercase().contains("unknown"), + "expected a missing-session error, got {err}" + ); +} + +#[tokio::test] +async fn kv_storage_put_load_delete() { + let manager = SessionManager::new(memory_config()).await.unwrap(); + manager + .put_kv("test_session_123", text("payload")) + .await + .unwrap(); + match manager.load_kv("test_session_123").await.unwrap() { + Some(Value::Text(s)) => assert_eq!(s.as_ref(), "payload"), + other => panic!("expected payload, got {other:?}"), + } + manager.delete_kv("test_session_123").await.unwrap(); + assert!(manager.load_kv("test_session_123").await.unwrap().is_none()); +} + +#[tokio::test] +async fn find_expired_and_stats() { + let mut cfg = memory_config(); + cfg.timeout_ms = 1; + let manager = SessionManager::new(cfg).await.unwrap(); + let created = manager.create().await.unwrap(); + tokio::time::sleep(Duration::from_millis(5)).await; + let expired = manager.find_expired().await.unwrap(); + assert_eq!(expired.len(), 1); + assert_eq!(expired[0].id, created.id); + + let stats = manager.stats().await; + assert_eq!(stats.storage_type, "memory"); + assert_eq!(stats.total_created, 1); +} + +#[tokio::test] +async fn file_backend_persists_across_managers() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("wfl_sessions.json"); + let mut cfg = memory_config(); + cfg.storage = SessionStorageKind::File; + cfg.file_path = path.to_string_lossy().into_owned(); + + let first = SessionManager::new(cfg.clone()).await.unwrap(); + let created = first.create().await.unwrap(); + first + .set_value(&created.id, "user_id", text("bob")) + .await + .unwrap(); + drop(first); + + let second = SessionManager::new(cfg).await.unwrap(); + let loaded = second.get(&created.id).await.unwrap().expect("reloaded"); + match loaded.data.get("user_id") { + Some(Value::Text(s)) => assert_eq!(s.as_ref(), "bob"), + other => panic!("expected bob, got {other:?}"), + } +} + +#[tokio::test] +async fn sqlite_backend_persists_across_managers() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("wfl_sessions.db"); + let mut cfg = memory_config(); + cfg.storage = SessionStorageKind::Database; + cfg.db_path = path.to_string_lossy().into_owned(); + + let first = SessionManager::new(cfg.clone()).await.unwrap(); + let created = first.create().await.unwrap(); + first + .set_value(&created.id, "user_id", text("carol")) + .await + .unwrap(); + drop(first); + + let second = SessionManager::new(cfg).await.unwrap(); + let loaded = second.get(&created.id).await.unwrap().expect("reloaded"); + match loaded.data.get("user_id") { + Some(Value::Text(s)) => assert_eq!(s.as_ref(), "carol"), + other => panic!("expected carol, got {other:?}"), + } +} + +#[tokio::test] +async fn concurrent_updates_do_not_corrupt_store() { + let manager = Arc::new(SessionManager::new(memory_config()).await.unwrap()); + let a = manager.create().await.unwrap(); + let b = manager.create().await.unwrap(); + let mgr_a = Arc::clone(&manager); + let mgr_b = Arc::clone(&manager); + let id_a = a.id.clone(); + let id_b = b.id.clone(); + let left = tokio::spawn(async move { + for i in 0..50 { + mgr_a + .set_value(&id_a, "n", Value::Number(i as f64)) + .await + .unwrap(); + } + }); + let right = tokio::spawn(async move { + for i in 0..50 { + mgr_b + .set_value(&id_b, "n", Value::Number(i as f64)) + .await + .unwrap(); + } + }); + left.await.unwrap(); + right.await.unwrap(); + assert!(manager.get(&a.id).await.unwrap().is_some()); + assert!(manager.get(&b.id).await.unwrap().is_some()); +} + +#[test] +fn cookie_header_includes_httponly_and_clear_expires() { + let cfg = memory_config(); + let set = SessionManager::format_set_cookie(&cfg, "abc123", false); + assert!(set.contains("wfl_sid=abc123")); + assert!(set.contains("HttpOnly")); + assert!(set.contains("SameSite=Lax")); + assert!(set.contains("Max-Age=60")); + + let clear = SessionManager::format_set_cookie(&cfg, "", true); + assert!(clear.contains("Max-Age=0")); +} diff --git a/tests/web_server_session_parser_test.rs b/tests/web_server_session_parser_test.rs new file mode 100644 index 00000000..2327758f --- /dev/null +++ b/tests/web_server_session_parser_test.rs @@ -0,0 +1,360 @@ +// TDD parser tests for issue #555 session statements. +// +// Session markers (`sessions`, `session`, `configure`, `enable`, …) are +// contextual identifiers, not reserved keywords. Existing programs that use +// those words as variable names must keep parsing. + +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::Parser; +use wfl::parser::ast::{Expression, Literal, Statement}; + +fn parse_program(code: &str) -> Vec { + let tokens = lex_wfl_with_positions(code); + let mut parser = Parser::new(&tokens); + parser + .parse() + .unwrap_or_else(|e| panic!("Failed to parse {code:?}: {e:?}")) + .statements +} + +fn parse_one(code: &str) -> Statement { + let mut stmts = parse_program(code); + assert_eq!(stmts.len(), 1, "expected one statement for {code:?}"); + stmts.pop().unwrap() +} + +fn parse_store_value(code: &str) -> Expression { + match parse_one(code) { + Statement::VariableDeclaration { value, .. } => value, + other => panic!("expected store statement, got {other:?}"), + } +} + +fn assert_variable(expr: &Expression, expected: &str, what: &str) { + match expr { + Expression::Variable(name, ..) => { + assert_eq!(name, expected, "{what} variable name mismatch") + } + other => panic!("{what} should be a variable, got {other:?}"), + } +} + +fn assert_string(expr: &Expression, expected: &str, what: &str) { + match expr { + Expression::Literal(Literal::String(s), ..) => { + assert_eq!(s.as_ref(), expected, "{what} literal mismatch") + } + other => panic!("{what} should be a string literal, got {other:?}"), + } +} + +fn assert_integer(expr: &Expression, expected: i64, what: &str) { + match expr { + Expression::Literal(Literal::Integer(n), ..) => { + assert_eq!(*n, expected, "{what} literal mismatch") + } + other => panic!("{what} should be an integer literal, got {other:?}"), + } +} + +#[test] +fn plain_listen_does_not_enable_sessions() { + let stmt = parse_one("listen on port 8080 as web_server"); + match stmt { + Statement::ListenStatement { + sessions_enabled, .. + } => { + assert!( + !sessions_enabled, + "plain listen must leave sessions disabled" + ); + } + other => panic!("expected ListenStatement, got {other:?}"), + } +} + +#[test] +fn listen_with_sessions_enabled_sets_flag() { + let stmt = parse_one("listen on port 8080 as web_server with sessions enabled"); + match stmt { + Statement::ListenStatement { + server_name, + sessions_enabled, + tls, + redirect_to_port, + .. + } => { + assert_eq!(server_name, "web_server"); + assert!(sessions_enabled); + assert!(tls.is_none()); + assert!(redirect_to_port.is_none()); + } + other => panic!("expected ListenStatement, got {other:?}"), + } +} + +#[test] +fn configure_sessions_captures_timeout_and_storage() { + let stmt = parse_one( + r#"configure sessions on web_server with timeout 1800000 and storage "database""#, + ); + match stmt { + Statement::ConfigureSessionsStatement { + server, + timeout, + storage, + .. + } => { + assert_variable(&server, "web_server", "server"); + assert_integer(&timeout, 1_800_000, "timeout"); + assert_string(&storage, "database", "storage"); + } + other => panic!("expected ConfigureSessionsStatement, got {other:?}"), + } +} + +#[test] +fn enable_csrf_protection_statement() { + let stmt = parse_one("enable csrf protection on web_server"); + match stmt { + Statement::EnableCsrfProtectionStatement { server, .. } => { + assert_variable(&server, "web_server", "server"); + } + other => panic!("expected EnableCsrfProtectionStatement, got {other:?}"), + } +} + +#[test] +fn enable_secure_cookies_statement() { + let stmt = parse_one("enable secure cookies on web_server"); + match stmt { + Statement::EnableSecureCookiesStatement { server, .. } => { + assert_variable(&server, "web_server", "server"); + } + other => panic!("expected EnableSecureCookiesStatement, got {other:?}"), + } +} + +#[test] +fn create_session_expression() { + let expr = parse_store_value("store sess as create session for req"); + match expr { + Expression::CreateSession { request, .. } => { + assert_variable(&request, "req", "request"); + } + other => panic!("expected CreateSession, got {other:?}"), + } +} + +#[test] +fn get_session_expression() { + let expr = parse_store_value("store sess as get session from req"); + match expr { + Expression::GetSession { request, .. } => { + assert_variable(&request, "req", "request"); + } + other => panic!("expected GetSession, got {other:?}"), + } +} + +#[test] +fn get_session_value_expression() { + let expr = parse_store_value(r#"store user_id as get session value "user_id" from sess"#); + match expr { + Expression::GetSessionValue { key, session, .. } => { + assert_string(&key, "user_id", "key"); + assert_variable(&session, "sess", "session"); + } + other => panic!("expected GetSessionValue, got {other:?}"), + } +} + +#[test] +fn generate_csrf_token_for_session_expression() { + let expr = parse_store_value("store csrf as generate csrf token for sess"); + match expr { + Expression::GenerateCsrfTokenForSession { session, .. } => { + assert_variable(&session, "sess", "session"); + } + other => panic!("expected GenerateCsrfTokenForSession, got {other:?}"), + } +} + +#[test] +fn set_session_value_statement() { + let stmt = parse_one(r#"set session value "user_id" to "guest" in sess"#); + match stmt { + Statement::SetSessionValueStatement { + key, + value, + session, + .. + } => { + assert_string(&key, "user_id", "key"); + assert_string(&value, "guest", "value"); + assert_variable(&session, "sess", "session"); + } + other => panic!("expected SetSessionValueStatement, got {other:?}"), + } +} + +#[test] +fn destroy_session_statement() { + let stmt = parse_one("destroy session sess"); + match stmt { + Statement::DestroySessionStatement { session, .. } => { + assert_variable(&session, "sess", "session"); + } + other => panic!("expected DestroySessionStatement, got {other:?}"), + } +} + +#[test] +fn find_expired_sessions_does_not_steal_find_in() { + let expr = parse_store_value("store expired as find expired sessions on web_server"); + match expr { + Expression::FindExpiredSessions { server, .. } => { + assert_variable(&server, "web_server", "server"); + } + other => panic!("expected FindExpiredSessions, got {other:?}"), + } + + let pattern_find = parse_store_value(r#"store hits as find "a" in "abc""#); + assert!( + matches!(pattern_find, Expression::PatternFind { .. }), + "find … in … must stay a pattern find, got {pattern_find:?}" + ); +} + +#[test] +fn get_session_statistics_expression() { + let expr = parse_store_value("store stats as get session statistics from web_server"); + match expr { + Expression::GetSessionStatistics { server, .. } => { + assert_variable(&server, "web_server", "server"); + } + other => panic!("expected GetSessionStatistics, got {other:?}"), + } +} + +#[test] +fn store_session_data_is_not_store_as() { + let stmt = parse_one( + r#"store session_data to storage with key "test_session_123" and data payload"#, + ); + match stmt { + Statement::StoreSessionDataStatement { key, data, .. } => { + assert_string(&key, "test_session_123", "key"); + assert_variable(&data, "payload", "data"); + } + other => panic!("expected StoreSessionDataStatement, got {other:?}"), + } + + let ordinary = parse_one(r#"store name as "alice""#); + assert!( + matches!(ordinary, Statement::VariableDeclaration { .. }), + "store x as y must stay a variable declaration, got {ordinary:?}" + ); +} + +#[test] +fn load_session_data_does_not_steal_load_module() { + let expr = parse_store_value( + r#"store retrieved as load session data from storage with key "test_session_123""#, + ); + match expr { + Expression::LoadSessionData { key, .. } => { + assert_string(&key, "test_session_123", "key"); + } + other => panic!("expected LoadSessionData, got {other:?}"), + } + + let tokens = lex_wfl_with_positions(r#"load module from "mod.wfl""#); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("load module from must still parse"); + assert!(matches!( + program.statements[0], + Statement::LoadModuleStatement { .. } + )); +} + +#[test] +fn delete_session_data_does_not_steal_delete_file() { + let stmt = parse_one(r#"delete session data from storage with key "test_session_123""#); + match stmt { + Statement::DeleteSessionDataStatement { key, .. } => { + assert_string(&key, "test_session_123", "key"); + } + other => panic!("expected DeleteSessionDataStatement, got {other:?}"), + } + + let file_delete = parse_one(r#"delete file at "gone.txt""#); + assert!( + matches!(file_delete, Statement::DeleteFileStatement { .. }), + "delete file at must stay a file delete, got {file_delete:?}" + ); +} + +#[test] +fn respond_and_set_session_clause() { + let stmt = parse_one(r#"respond to req with "ok" and set session sess"#); + match stmt { + Statement::RespondStatement { + set_session, + clear_session, + .. + } => { + let set_session = set_session.expect("set session clause missing"); + assert_variable(&set_session, "sess", "set_session"); + assert!(!clear_session); + } + other => panic!("expected RespondStatement, got {other:?}"), + } +} + +#[test] +fn respond_and_clear_session_clause() { + let stmt = parse_one(r#"respond to req with "bye" and clear session"#); + match stmt { + Statement::RespondStatement { + set_session, + clear_session, + .. + } => { + assert!(set_session.is_none()); + assert!(clear_session); + } + other => panic!("expected RespondStatement, got {other:?}"), + } +} + +#[test] +fn respond_set_session_keeps_headers_and_status() { + let stmt = parse_one( + r#"respond to req with "ok" and status 200 and headers hdrs and set session sess"#, + ); + match stmt { + Statement::RespondStatement { + status, + headers, + set_session, + .. + } => { + assert!(status.is_some()); + assert!(headers.is_some()); + assert!(set_session.is_some()); + } + other => panic!("expected RespondStatement, got {other:?}"), + } +} + +#[test] +fn session_is_still_a_legal_variable_name() { + let stmt = parse_one(r#"store session as "active""#); + match stmt { + Statement::VariableDeclaration { name, .. } => { + assert_eq!(name, "session"); + } + other => panic!("expected VariableDeclaration, got {other:?}"), + } +} From af22294579778365c42b7738f83ea7afdb554710 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 15:34:38 +0000 Subject: [PATCH 02/10] feat: add session_* keys to .wflcfg Register timeout, storage backend, cookie, CSRF, and max-sessions defaults so listen/configure can pick them up without a session secret in config. Co-authored-by: logbie --- src/config.rs | 194 ++++++++++++++++++++++++++++++++++++++ src/wfl_config/checker.rs | 122 +++++++++++++++++++++++- 2 files changed, 315 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 6e105a99..237d4cf8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -110,6 +110,82 @@ pub struct WflConfig { /// buffer without bound. Feeds `ExecutionBudget`. Default 16 MiB; must be at /// least 1. pub web_socket_max_queued_bytes: usize, + /// Idle session lifetime in milliseconds. Default 1800000 (30 minutes). + pub session_timeout_ms: u64, + /// Session persistence backend: memory, file, or database (SQLite). + pub session_storage: SessionStorageKind, + /// SQLite file used when `session_storage = database`. + pub session_db_path: String, + /// JSON file used when `session_storage = file`. + pub session_file_path: String, + /// Name of the session cookie (`Set-Cookie`). + pub session_cookie_name: String, + /// Add the `Secure` flag to the session cookie. + pub session_cookie_secure: bool, + /// `SameSite` attribute on the session cookie. + pub session_cookie_samesite: SessionSameSite, + /// Add the `HttpOnly` flag to the session cookie. + pub session_cookie_httponly: bool, + /// Default for `enable csrf protection` when the statement is omitted. + pub session_csrf_enabled: bool, + /// Maximum stored sessions before `create session` fails. Default 10000. + pub session_max_sessions: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionStorageKind { + Memory, + File, + Database, +} + +impl SessionStorageKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Memory => "memory", + Self::File => "file", + Self::Database => "database", + } + } + + pub fn parse(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "memory" => Ok(Self::Memory), + "file" => Ok(Self::File), + "database" => Ok(Self::Database), + other => Err(format!( + "Unknown session storage '{other}'. Use memory, file, or database." + )), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionSameSite { + Lax, + Strict, + None, +} + +impl SessionSameSite { + pub fn as_str(self) -> &'static str { + match self { + Self::Lax => "Lax", + Self::Strict => "Strict", + Self::None => "None", + } + } + + pub fn parse(value: &str) -> Result { + match value.trim() { + "Lax" | "lax" => Ok(Self::Lax), + "Strict" | "strict" => Ok(Self::Strict), + "None" | "none" => Ok(Self::None), + other => Err(format!( + "Unknown session_cookie_samesite '{other}'. Use Lax, Strict, or None." + )), + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -217,6 +293,16 @@ impl Default for WflConfig { web_socket_max_connections: 1_024, web_socket_max_message_size: 1_048_576, web_socket_max_queued_bytes: 16 * 1_048_576, + session_timeout_ms: 1_800_000, + session_storage: SessionStorageKind::Memory, + session_db_path: "wfl_sessions.db".to_string(), + session_file_path: "wfl_sessions.json".to_string(), + session_cookie_name: "wfl_sid".to_string(), + session_cookie_secure: false, + session_cookie_samesite: SessionSameSite::Lax, + session_cookie_httponly: true, + session_csrf_enabled: false, + session_max_sessions: 10_000, } } } @@ -922,6 +1008,62 @@ fn parse_config_text(config: &mut WflConfig, text: &str, file: &Path) { value, file, ), + "session_timeout_ms" => { + if let Ok(parsed) = value.parse::() + && parsed >= 1 + { + config.session_timeout_ms = parsed; + } else { + log::warn!( + "Invalid session_timeout_ms '{value}' in {}: expected an integer >= 1", + file.display() + ); + } + } + "session_storage" => match SessionStorageKind::parse(value) { + Ok(kind) => config.session_storage = kind, + Err(err) => log::warn!("{err} in {}", file.display()), + }, + "session_db_path" => { + if !value.is_empty() { + config.session_db_path = value.to_string(); + } + } + "session_file_path" => { + if !value.is_empty() { + config.session_file_path = value.to_string(); + } + } + "session_cookie_name" => { + if !value.is_empty() { + config.session_cookie_name = value.to_string(); + } + } + "session_cookie_secure" => { + if let Ok(enabled) = value.parse::() { + config.session_cookie_secure = enabled; + } + } + "session_cookie_samesite" => match SessionSameSite::parse(value) { + Ok(kind) => config.session_cookie_samesite = kind, + Err(err) => log::warn!("{err} in {}", file.display()), + }, + "session_cookie_httponly" => { + if let Ok(enabled) = value.parse::() { + config.session_cookie_httponly = enabled; + } + } + "session_csrf_enabled" => { + if let Ok(enabled) = value.parse::() { + config.session_csrf_enabled = enabled; + } + } + "session_max_sessions" => set_positive_usize( + &mut config.session_max_sessions, + "session_max_sessions", + value, + file, + ), _ => { log::warn!("Unknown configuration key: {} in {}", key, file.display()); } @@ -1588,4 +1730,56 @@ mod tests { "Empty web_server_bind_address should keep default value" ); } + + #[test] + fn test_session_config_defaults_and_overrides() { + let temp_dir = tempfile::tempdir().unwrap(); + let defaults = with_test_global_path(|| { + set_test_env_var(Some("/non/existent/path")); + load_config(temp_dir.path()) + }); + assert_eq!(defaults.session_timeout_ms, 1_800_000); + assert_eq!(defaults.session_storage, SessionStorageKind::Memory); + assert_eq!(defaults.session_db_path, "wfl_sessions.db"); + assert_eq!(defaults.session_file_path, "wfl_sessions.json"); + assert_eq!(defaults.session_cookie_name, "wfl_sid"); + assert!(!defaults.session_cookie_secure); + assert_eq!(defaults.session_cookie_samesite, SessionSameSite::Lax); + assert!(defaults.session_cookie_httponly); + assert!(!defaults.session_csrf_enabled); + assert_eq!(defaults.session_max_sessions, 10_000); + + let config_path = temp_dir.path().join(".wflcfg"); + fs::write( + &config_path, + r#" + session_timeout_ms = 900000 + session_storage = database + session_db_path = custom_sessions.db + session_file_path = custom_sessions.json + session_cookie_name = sid + session_cookie_secure = true + session_cookie_samesite = Strict + session_cookie_httponly = false + session_csrf_enabled = true + session_max_sessions = 50 + "#, + ) + .unwrap(); + + let config = with_test_global_path(|| { + set_test_env_var(Some("/non/existent/path")); + load_config(temp_dir.path()) + }); + assert_eq!(config.session_timeout_ms, 900_000); + assert_eq!(config.session_storage, SessionStorageKind::Database); + assert_eq!(config.session_db_path, "custom_sessions.db"); + assert_eq!(config.session_file_path, "custom_sessions.json"); + assert_eq!(config.session_cookie_name, "sid"); + assert!(config.session_cookie_secure); + assert_eq!(config.session_cookie_samesite, SessionSameSite::Strict); + assert!(!config.session_cookie_httponly); + assert!(config.session_csrf_enabled); + assert_eq!(config.session_max_sessions, 50); + } } diff --git a/src/wfl_config/checker.rs b/src/wfl_config/checker.rs index 29c8a3af..9bef37ca 100644 --- a/src/wfl_config/checker.rs +++ b/src/wfl_config/checker.rs @@ -614,8 +614,125 @@ impl ConfigChecker { "Execution Budget", "Maximum bytes buffered by one text or binary file read (default 50 MiB, min 1)", ); + int_setting( + "session_timeout_ms", + "1800000", + "Sessions", + "Idle session lifetime in milliseconds (default 1800000, min 1)", + ); + int_setting( + "session_max_sessions", + "10000", + "Sessions", + "Maximum stored sessions before create session fails (min 1)", + ); } + expected_settings.insert( + "session_storage".to_string(), + ExpectedSetting { + name: "session_storage".to_string(), + config_type: ConfigType::String, + required: false, + default_value: Some("memory".to_string()), + description: "Session persistence backend (memory, file, or database)".to_string(), + valid_values: Some(vec![ + "memory".to_string(), + "file".to_string(), + "database".to_string(), + ]), + category: "Sessions".to_string(), + }, + ); + expected_settings.insert( + "session_db_path".to_string(), + ExpectedSetting { + name: "session_db_path".to_string(), + config_type: ConfigType::String, + required: false, + default_value: Some("wfl_sessions.db".to_string()), + description: "SQLite file used when session_storage is database".to_string(), + valid_values: None, + category: "Sessions".to_string(), + }, + ); + expected_settings.insert( + "session_file_path".to_string(), + ExpectedSetting { + name: "session_file_path".to_string(), + config_type: ConfigType::String, + required: false, + default_value: Some("wfl_sessions.json".to_string()), + description: "JSON file used when session_storage is file".to_string(), + valid_values: None, + category: "Sessions".to_string(), + }, + ); + expected_settings.insert( + "session_cookie_name".to_string(), + ExpectedSetting { + name: "session_cookie_name".to_string(), + config_type: ConfigType::String, + required: false, + default_value: Some("wfl_sid".to_string()), + description: "Name of the session cookie".to_string(), + valid_values: None, + category: "Sessions".to_string(), + }, + ); + expected_settings.insert( + "session_cookie_secure".to_string(), + ExpectedSetting { + name: "session_cookie_secure".to_string(), + config_type: ConfigType::Boolean, + required: false, + default_value: Some("false".to_string()), + description: "Add the Secure flag to the session cookie".to_string(), + valid_values: None, + category: "Sessions".to_string(), + }, + ); + expected_settings.insert( + "session_cookie_samesite".to_string(), + ExpectedSetting { + name: "session_cookie_samesite".to_string(), + config_type: ConfigType::String, + required: false, + default_value: Some("Lax".to_string()), + description: "SameSite attribute on the session cookie".to_string(), + valid_values: Some(vec![ + "Lax".to_string(), + "Strict".to_string(), + "None".to_string(), + ]), + category: "Sessions".to_string(), + }, + ); + expected_settings.insert( + "session_cookie_httponly".to_string(), + ExpectedSetting { + name: "session_cookie_httponly".to_string(), + config_type: ConfigType::Boolean, + required: false, + default_value: Some("true".to_string()), + description: "Add the HttpOnly flag to the session cookie".to_string(), + valid_values: None, + category: "Sessions".to_string(), + }, + ); + expected_settings.insert( + "session_csrf_enabled".to_string(), + ExpectedSetting { + name: "session_csrf_enabled".to_string(), + config_type: ConfigType::Boolean, + required: false, + default_value: Some("false".to_string()), + description: "Default CSRF-protection flag when enable csrf is omitted".to_string(), + valid_values: None, + category: "Sessions".to_string(), + }, + ); + Self { expected_settings } } @@ -633,6 +750,7 @@ impl ConfigChecker { "Security", "Subprocess Management", "Web Server", + "Sessions", // Without this, the config wizard silently omitted every setting // assigned to the ExecutionBudget category. "Execution Budget", @@ -1116,7 +1234,9 @@ fn integer_min_for_key(key: &str) -> Option { | "max_pattern_steps" | "max_pattern_states" | "max_source_size" - | "max_file_read_size" => Some(1), + | "max_file_read_size" + | "session_timeout_ms" + | "session_max_sessions" => Some(1), _ => None, } } From 07bbcc66adcb28ec1ef62ee5aa671746bc4ae13a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 15:34:38 +0000 Subject: [PATCH 03/10] feat: parse the issue #555 session language surface Add AST nodes and parsers for listen/respond session clauses and the session statements/expressions, with analyzer and typechecker arms. Session words stay positional markers so existing `session` variables keep working. Co-authored-by: logbie --- src/analyzer/mod.rs | 22 + src/analyzer/static_analyzer.rs | 2 + src/parser/ast.rs | 82 +++ src/parser/expr/primary.rs | 46 +- src/parser/helpers.rs | 50 +- src/parser/mod.rs | 44 ++ src/parser/stmt/web.rs | 660 +++++++++++++++++- src/parser/tests.rs | 4 + src/typechecker/mod.rs | 88 +++ ...checker_statement_operand_contract_test.rs | 2 + 10 files changed, 997 insertions(+), 3 deletions(-) diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index dd36c36d..5462d2e2 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -2668,6 +2668,7 @@ impl Analyzer { server_name, tls, redirect_to_port, + sessions_enabled: _, line, column, } => { @@ -2765,6 +2766,7 @@ impl Analyzer { status, content_type, headers, + set_session, .. } => { // Analyze all expressions @@ -2782,6 +2784,9 @@ impl Analyzer { if let Some(headers_expr) = headers { self.analyze_expression(headers_expr); } + if let Some(session_expr) = set_session { + self.analyze_expression(session_expr); + } } Statement::ListenWebSocketStatement { @@ -5359,6 +5364,23 @@ impl Analyzer { self.analyze_expression(params); } } + Expression::CreateSession { request, .. } | Expression::GetSession { request, .. } => { + self.analyze_expression(request); + } + Expression::GetSessionValue { key, session, .. } => { + self.analyze_expression(key); + self.analyze_expression(session); + } + Expression::GenerateCsrfTokenForSession { session, .. } => { + self.analyze_expression(session); + } + Expression::FindExpiredSessions { server, .. } + | Expression::GetSessionStatistics { server, .. } => { + self.analyze_expression(server); + } + Expression::LoadSessionData { key, .. } => { + self.analyze_expression(key); + } } } } diff --git a/src/analyzer/static_analyzer.rs b/src/analyzer/static_analyzer.rs index 6a286023..2adacfde 100644 --- a/src/analyzer/static_analyzer.rs +++ b/src/analyzer/static_analyzer.rs @@ -2359,6 +2359,8 @@ display ln"; status: None, content_type: None, headers: Some(Expression::Variable("response_headers".to_string(), 2, 30)), + set_session: None, + clear_session: false, line: 2, column: 1, }, diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 34eff3a8..bc8c522f 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -576,6 +576,8 @@ pub enum Statement { tls: Option, /// `redirecting to port ` — native HTTP->HTTPS 301 redirect server. redirect_to_port: Option, + /// `with sessions enabled` after `as `. + sessions_enabled: bool, line: usize, column: usize, }, @@ -594,6 +596,10 @@ pub enum Statement { /// Optional map of extra response headers, e.g. `Accept-Query` / /// `Content-Location` / `Location` for RFC 10008 (HTTP QUERY). headers: Option, + /// `and set session ` — persist the session and emit Set-Cookie. + set_session: Option, + /// `and clear session` — emit an expired session cookie. + clear_session: bool, line: usize, column: usize, }, @@ -736,6 +742,46 @@ pub enum Statement { line: usize, column: usize, }, + ConfigureSessionsStatement { + server: Expression, + timeout: Expression, + storage: Expression, + line: usize, + column: usize, + }, + EnableCsrfProtectionStatement { + server: Expression, + line: usize, + column: usize, + }, + EnableSecureCookiesStatement { + server: Expression, + line: usize, + column: usize, + }, + SetSessionValueStatement { + key: Expression, + value: Expression, + session: Expression, + line: usize, + column: usize, + }, + DestroySessionStatement { + session: Expression, + line: usize, + column: usize, + }, + StoreSessionDataStatement { + key: Expression, + data: Expression, + line: usize, + column: usize, + }, + DeleteSessionDataStatement { + key: Expression, + line: usize, + column: usize, + }, } /// Represents different types of assertions in test expectations @@ -940,6 +986,42 @@ pub enum Expression { line: usize, column: usize, }, + CreateSession { + request: Box, + line: usize, + column: usize, + }, + GetSession { + request: Box, + line: usize, + column: usize, + }, + GetSessionValue { + key: Box, + session: Box, + line: usize, + column: usize, + }, + GenerateCsrfTokenForSession { + session: Box, + line: usize, + column: usize, + }, + FindExpiredSessions { + server: Box, + line: usize, + column: usize, + }, + GetSessionStatistics { + server: Box, + line: usize, + column: usize, + }, + LoadSessionData { + key: Box, + line: usize, + column: usize, + }, } #[derive(Debug, Clone, PartialEq)] diff --git a/src/parser/expr/primary.rs b/src/parser/expr/primary.rs index ba00e123..d64f9307 100644 --- a/src/parser/expr/primary.rs +++ b/src/parser/expr/primary.rs @@ -6,7 +6,7 @@ use super::super::{Argument, Expression, Literal, ParseError, Parser, UnaryOpera use super::{BinaryExprParser, ExprParser}; use crate::exec_trace; use crate::lexer::token::Token; -use crate::parser::stmt::PatternParser; +use crate::parser::stmt::{PatternParser, WebParser}; use std::sync::Arc; /// Trait for parsing primary (atomic) expressions @@ -497,6 +497,32 @@ impl<'a> Parser<'a> { self.bump_sync(); // Consume 'call' return self.parse_call_expression(call_line, call_column, stop_at_clause); } + Token::Identifier(name) + if name == "get session" + || name.starts_with("get session ") + || name == "get session value" + || name.starts_with("get session value ") + || name == "get session statistics" + || name.starts_with("get session statistics ") => + { + return self.parse_get_session_expression(); + } + Token::Identifier(name) + if name == "generate csrf token" || name.starts_with("generate csrf token ") => + { + return self.parse_generate_csrf_token_for_session(); + } + Token::KeywordLoad => { + if self.next_is_session_data_phrase() { + return self.parse_load_session_data_expression(); + } + return Err(ParseError::from_token( + "Unexpected 'load' in expression — use 'load session data from storage \ + with key …' here, or the 'load module' statement" + .to_string(), + token, + )); + } Token::Identifier(name) => { self.bump_sync(); let token_line = token.line; @@ -1194,6 +1220,19 @@ impl<'a> Parser<'a> { token_column, )) } + Token::KeywordFind + if self.cursor.peek_next().is_some_and(|t| { + matches!( + &t.token, + Token::Identifier(id) + if id == "expired sessions" + || id == "expired" + || id.starts_with("expired sessions ") + ) + }) => + { + return self.parse_find_expired_sessions_expression(); + } Token::KeywordFind => { self.bump_sync(); // Consume "find" let pattern_expr = @@ -1288,6 +1327,11 @@ impl<'a> Parser<'a> { _ if token.token.is_contextual_keyword() => { // Special handling for "create list" expression if token.token == Token::KeywordCreate { + if self.cursor.peek_next().is_some_and(|t| { + matches!(&t.token, Token::Identifier(id) if id == "session") + }) { + return self.parse_create_session_expression(); + } self.bump_sync(); // Consume "create" let token_line = token.line; let token_column = token.column; diff --git a/src/parser/helpers.rs b/src/parser/helpers.rs index 4e3e17e6..82f8ccc3 100644 --- a/src/parser/helpers.rs +++ b/src/parser/helpers.rs @@ -279,7 +279,7 @@ impl<'a> Parser<'a> { /// explicit keyword-led arms (`call`, `not`, `-` unary, `with`, `count`, /// `pattern`, `loop`, `output`, `repeat`, `exit`, `back`, `try`, `when`, /// `error`, `file`, `directory`, `process`, `header`, `current`, `list`, - /// `read`, `find`, `replace`, `split`); and finally the contextual-keyword + /// `read`, `find`, `replace`, `split`, `load`); and finally the contextual-keyword /// catch-all described above. `Token::Eol` has its own arm but it always /// errors, so it is excluded here. pub(crate) fn can_start_primary_expression(token: &Token) -> bool { @@ -317,6 +317,7 @@ impl<'a> Parser<'a> { | Token::KeywordFind | Token::KeywordReplace | Token::KeywordSplit + | Token::KeywordLoad ) || token.is_contextual_keyword() } @@ -603,4 +604,51 @@ impl<'a> Parser<'a> { self.bump_sync(); // Consume "of" } } + + /// `session_data` / merged `session data` identifiers. `data` is a reserved + /// keyword, so the two-word phrase also arrives as `Identifier("session")` + /// plus `KeywordData`. + pub(crate) fn next_is_session_data_phrase(&self) -> bool { + match self.cursor.peek_next() { + Some(t) if session_data_ident(&t.token) => true, + Some(t) if matches!(&t.token, Token::Identifier(id) if id == "session") => { + matches!( + self.cursor.peek_n(2).map(|tok| &tok.token), + Some(Token::KeywordData) + ) + } + _ => false, + } + } + + /// `store session_data to storage` / `store session data to storage`. + pub(crate) fn next_is_store_session_data(&self) -> bool { + match self.cursor.peek_next() { + Some(t) if session_data_ident(&t.token) => self + .cursor + .peek_n(2) + .is_some_and(|tok| tok.token == Token::KeywordTo), + Some(t) if matches!(&t.token, Token::Identifier(id) if id == "session") => { + matches!( + self.cursor.peek_n(2).map(|tok| &tok.token), + Some(Token::KeywordData) + ) && self + .cursor + .peek_n(3) + .is_some_and(|tok| tok.token == Token::KeywordTo) + } + _ => false, + } + } +} + +fn session_data_ident(token: &Token) -> bool { + matches!( + token, + Token::Identifier(id) + if id == "session_data" + || id == "session data" + || id.starts_with("session_data ") + || id.starts_with("session data ") + ) } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index fb35f63d..9ff8731e 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -469,7 +469,22 @@ impl<'a> StmtParser<'a> for Parser<'a> { } if let Some(token) = self.cursor.peek() { match &token.token { + Token::KeywordStore if self.next_is_store_session_data() => { + self.parse_store_session_data_statement() + } Token::KeywordStore => self.parse_variable_declaration(), + Token::KeywordLoad if self.next_is_session_data_phrase() => { + // Expression-shaped `load session data ...` used as a statement + // is unexpected; still accept it by wrapping as an expression + // statement so `store x as load session data ...` is not + // required at statement level. + let expr = self.parse_load_session_data_expression()?; + Ok(Statement::ExpressionStatement { + expression: expr, + line: 0, + column: 0, + }) + } Token::KeywordLoad => self.parse_load_module_statement(), Token::KeywordInclude => self.parse_include_statement(), Token::KeywordExport => self.parse_export_statement(), @@ -610,6 +625,9 @@ impl<'a> StmtParser<'a> for Parser<'a> { self.parse_close_file_statement() } } + Token::KeywordDelete if self.next_is_session_data_phrase() => { + self.parse_delete_session_data_statement() + } Token::KeywordDelete => self.parse_delete_statement(), Token::KeywordWrite => self.parse_write_to_statement(), Token::KeywordWait => self.parse_wait_for_statement(), @@ -773,6 +791,32 @@ impl<'a> StmtParser<'a> for Parser<'a> { column: token_pos.column, }) } + Token::Identifier(id) + if id == "configure sessions" || id.starts_with("configure sessions ") => + { + self.parse_configure_sessions_statement() + } + Token::Identifier(id) + if id == "enable csrf protection" + || id.starts_with("enable csrf protection ") => + { + self.parse_enable_csrf_protection_statement() + } + Token::Identifier(id) + if id == "enable secure cookies" || id.starts_with("enable secure cookies ") => + { + self.parse_enable_secure_cookies_statement() + } + Token::Identifier(id) + if id == "set session value" || id.starts_with("set session value ") => + { + self.parse_set_session_value_statement() + } + Token::Identifier(id) + if id == "destroy session" || id.starts_with("destroy session ") => + { + self.parse_destroy_session_statement() + } Token::Identifier(id) if id == "main" => { // Check if next token is "loop" if let Some(next_token) = self.cursor.peek_next() { diff --git a/src/parser/stmt/web.rs b/src/parser/stmt/web.rs index 6fab753f..5a76de29 100644 --- a/src/parser/stmt/web.rs +++ b/src/parser/stmt/web.rs @@ -24,6 +24,18 @@ pub(crate) trait WebParser<'a>: ExprParser<'a> + PrimaryExprParser<'a> { line: usize, column: usize, ) -> Result; + fn parse_configure_sessions_statement(&mut self) -> Result; + fn parse_enable_csrf_protection_statement(&mut self) -> Result; + fn parse_enable_secure_cookies_statement(&mut self) -> Result; + fn parse_set_session_value_statement(&mut self) -> Result; + fn parse_destroy_session_statement(&mut self) -> Result; + fn parse_store_session_data_statement(&mut self) -> Result; + fn parse_delete_session_data_statement(&mut self) -> Result; + fn parse_create_session_expression(&mut self) -> Result; + fn parse_get_session_expression(&mut self) -> Result; + fn parse_generate_csrf_token_for_session(&mut self) -> Result; + fn parse_find_expired_sessions_expression(&mut self) -> Result; + fn parse_load_session_data_expression(&mut self) -> Result; } impl<'a> WebParser<'a> for Parser<'a> { @@ -192,11 +204,77 @@ impl<'a> WebParser<'a> for Parser<'a> { // Parse server name let server_name = self.parse_variable_name_simple()?; + let mut sessions_enabled = false; + if self + .cursor + .peek() + .is_some_and(|t| t.token == Token::KeywordWith) + { + self.bump_sync(); // Consume "with" + match self.cursor.peek() { + Some(token) => match &token.token { + Token::Identifier(id) + if id == "sessions enabled" + || id == "sessions" + || id.starts_with("sessions ") => + { + let id = id.clone(); + self.bump_sync(); + if id == "sessions" { + match self.cursor.peek() { + Some(enabled) + if matches!( + &enabled.token, + Token::Identifier(word) if word == "enabled" + ) => + { + self.bump_sync(); + } + Some(other) => { + return Err(ParseError::from_token( + "Expected 'sessions enabled' after 'with'".to_string(), + other, + )); + } + None => { + return Err(ParseError::from_token( + "Expected 'sessions enabled' after 'with'".to_string(), + token, + )); + } + } + } else if id != "sessions enabled" + && !id.starts_with("sessions enabled") + { + return Err(ParseError::from_token( + "Expected 'sessions enabled' after 'with'".to_string(), + token, + )); + } + sessions_enabled = true; + } + _ => { + return Err(ParseError::from_token( + "Expected 'sessions enabled' after 'with'".to_string(), + token, + )); + } + }, + None => { + return Err(ParseError::from_token( + "Expected 'sessions enabled' after 'with'".to_string(), + listen_token, + )); + } + } + } + Ok(Statement::ListenStatement { port, server_name, tls, redirect_to_port, + sessions_enabled, line: listen_token.line, column: listen_token.column, }) @@ -260,10 +338,12 @@ impl<'a> WebParser<'a> for Parser<'a> { // Parse content expression (use primary to avoid consuming "and") let content = self.parse_primary_expression()?; - // Optional status, content_type, and headers + // Optional status, content_type, headers, and session cookie clauses let mut status = None; let mut content_type = None; let mut headers = None; + let mut set_session = None; + let mut clear_session = false; // Check for optional "and" clauses (status, content_type, and/or headers) loop { @@ -347,6 +427,51 @@ impl<'a> WebParser<'a> for Parser<'a> { Some(Expression::Variable(rest.to_string(), id_line, id_column)); } continue; + } else if let Token::Identifier(id) = &next_token.token + && (id == "set session" || id.starts_with("set session ")) + { + let id = id.clone(); + let (id_line, id_column) = (next_token.line, next_token.column); + self.bump_sync(); // Consume "and" + self.bump_sync(); // Consume "set session" (possibly merged) + let rest = id + .strip_prefix("set session") + .map(str::trim_start) + .unwrap_or(""); + if rest.is_empty() { + set_session = Some(self.parse_primary_expression()?); + } else { + set_session = + Some(Expression::Variable(rest.to_string(), id_line, id_column)); + } + continue; + } else if next_token.token == Token::KeywordClear { + self.bump_sync(); // Consume "and" + self.bump_sync(); // Consume "clear" + match self.cursor.peek() { + Some(token) + if matches!( + &token.token, + Token::Identifier(id) if id == "session" || id.starts_with("session ") + ) => + { + self.bump_sync(); + clear_session = true; + continue; + } + Some(token) => { + return Err(ParseError::from_token( + "Expected 'session' after 'and clear'".to_string(), + token, + )); + } + None => { + return Err(ParseError::from_token( + "Expected 'session' after 'and clear'".to_string(), + respond_token, + )); + } + } } } } @@ -359,6 +484,8 @@ impl<'a> WebParser<'a> for Parser<'a> { status, content_type, headers, + set_session, + clear_session, line: respond_token.line, column: respond_token.column, }) @@ -903,4 +1030,535 @@ impl<'a> WebParser<'a> for Parser<'a> { )), } } + + fn parse_configure_sessions_statement(&mut self) -> Result { + let token = self.bump_sync().unwrap(); + let (line, column) = (token.line, token.column); + self.expect_token(Token::KeywordOn, "Expected 'on' after 'configure sessions'")?; + let server = self.parse_primary_expression()?; + self.expect_token( + Token::KeywordWith, + "Expected 'with' after the server in 'configure sessions'", + )?; + match self.cursor.peek() { + Some(t) if t.token == Token::KeywordTimeout => { + self.bump_sync(); + } + Some(t) => { + return Err(ParseError::from_token( + "Expected 'timeout' after 'configure sessions ... with'".to_string(), + t, + )); + } + None => { + return Err(ParseError::from_token( + "Expected 'timeout' after 'configure sessions ... with'".to_string(), + token, + )); + } + } + let timeout = self.parse_primary_expression()?; + self.expect_token( + Token::KeywordAnd, + "Expected 'and storage' after the session timeout", + )?; + match self.cursor.peek() { + Some(t) if matches!(&t.token, Token::Identifier(id) if id == "storage" || id.starts_with("storage ")) => + { + let id = match &t.token { + Token::Identifier(id) => id.clone(), + _ => unreachable!(), + }; + let (id_line, id_column) = (t.line, t.column); + self.bump_sync(); + let rest = id.strip_prefix("storage").map(str::trim_start).unwrap_or(""); + let storage = if rest.is_empty() { + self.parse_primary_expression()? + } else { + Expression::Variable(rest.to_string(), id_line, id_column) + }; + Ok(Statement::ConfigureSessionsStatement { + server, + timeout, + storage, + line, + column, + }) + } + Some(t) => Err(ParseError::from_token( + "Expected 'storage' after 'and' in 'configure sessions'".to_string(), + t, + )), + None => Err(ParseError::from_token( + "Expected 'storage' after 'and' in 'configure sessions'".to_string(), + token, + )), + } + } + + fn parse_enable_csrf_protection_statement(&mut self) -> Result { + let token = self.bump_sync().unwrap(); + let (line, column) = (token.line, token.column); + self.expect_token( + Token::KeywordOn, + "Expected 'on' after 'enable csrf protection'", + )?; + let server = self.parse_primary_expression()?; + Ok(Statement::EnableCsrfProtectionStatement { + server, + line, + column, + }) + } + + fn parse_enable_secure_cookies_statement(&mut self) -> Result { + let token = self.bump_sync().unwrap(); + let (line, column) = (token.line, token.column); + self.expect_token( + Token::KeywordOn, + "Expected 'on' after 'enable secure cookies'", + )?; + let server = self.parse_primary_expression()?; + Ok(Statement::EnableSecureCookiesStatement { + server, + line, + column, + }) + } + + fn parse_set_session_value_statement(&mut self) -> Result { + let token = self.bump_sync().unwrap(); + let (line, column) = (token.line, token.column); + let rest = match &token.token { + Token::Identifier(id) => id + .strip_prefix("set session value") + .map(str::trim_start) + .unwrap_or("") + .to_string(), + _ => String::new(), + }; + let key = if rest.is_empty() { + self.parse_primary_expression()? + } else { + Expression::Variable(rest, line, column) + }; + self.expect_token(Token::KeywordTo, "Expected 'to' after the session value key")?; + let value = self.parse_primary_expression()?; + self.expect_token(Token::KeywordIn, "Expected 'in' after the session value")?; + let session = self.parse_primary_expression()?; + Ok(Statement::SetSessionValueStatement { + key, + value, + session, + line, + column, + }) + } + + fn parse_destroy_session_statement(&mut self) -> Result { + let token = self.bump_sync().unwrap(); + let (line, column) = (token.line, token.column); + let rest = match &token.token { + Token::Identifier(id) => id + .strip_prefix("destroy session") + .map(str::trim_start) + .unwrap_or("") + .to_string(), + _ => String::new(), + }; + let session = if rest.is_empty() { + self.parse_primary_expression()? + } else { + Expression::Variable(rest, line, column) + }; + Ok(Statement::DestroySessionStatement { + session, + line, + column, + }) + } + + fn parse_store_session_data_statement(&mut self) -> Result { + let token = self.bump_sync().unwrap(); // store + let (line, column) = (token.line, token.column); + self.consume_session_data_marker(&token)?; + self.expect_token(Token::KeywordTo, "Expected 'to storage' after 'session_data'")?; + match self.cursor.peek() { + Some(t) if matches!(&t.token, Token::Identifier(id) if id == "storage") => { + self.bump_sync(); + } + Some(t) => { + return Err(ParseError::from_token( + "Expected 'storage' after 'to'".to_string(), + t, + )); + } + None => { + return Err(ParseError::from_token( + "Expected 'storage' after 'to'".to_string(), + token, + )); + } + } + let (key, data) = self.parse_storage_key_and_optional_data(true, &token)?; + Ok(Statement::StoreSessionDataStatement { + key, + data: data.expect("data required"), + line, + column, + }) + } + + fn parse_delete_session_data_statement(&mut self) -> Result { + let token = self.bump_sync().unwrap(); // delete + let (line, column) = (token.line, token.column); + self.consume_session_data_marker(&token)?; + self.expect_token( + Token::KeywordFrom, + "Expected 'from storage' after 'session data'", + )?; + self.consume_storage_marker(&token)?; + let (key, _) = self.parse_storage_key_and_optional_data(false, &token)?; + Ok(Statement::DeleteSessionDataStatement { key, line, column }) + } + + fn parse_create_session_expression(&mut self) -> Result { + let token = self.bump_sync().unwrap(); // create + let (line, column) = (token.line, token.column); + match self.cursor.peek() { + Some(t) if matches!(&t.token, Token::Identifier(id) if id == "session") => { + self.bump_sync(); + } + Some(t) => { + return Err(ParseError::from_token( + "Expected 'session' after 'create'".to_string(), + t, + )); + } + None => { + return Err(ParseError::from_token( + "Expected 'session' after 'create'".to_string(), + token, + )); + } + } + self.expect_token(Token::KeywordFor, "Expected 'for' after 'create session'")?; + let request = self.parse_primary_expression()?; + Ok(Expression::CreateSession { + request: Box::new(request), + line, + column, + }) + } + + fn parse_get_session_expression(&mut self) -> Result { + let token = self.bump_sync().unwrap(); + let (line, column) = (token.line, token.column); + let id = match &token.token { + Token::Identifier(id) => id.clone(), + _ => String::new(), + }; + if id == "get session statistics" || id.starts_with("get session statistics ") { + let rest = id + .strip_prefix("get session statistics") + .map(str::trim_start) + .unwrap_or(""); + self.expect_token( + Token::KeywordFrom, + "Expected 'from' after 'get session statistics'", + )?; + let server = if rest.is_empty() { + self.parse_primary_expression()? + } else { + // rest should not include the server; server follows `from` + self.parse_primary_expression()? + }; + return Ok(Expression::GetSessionStatistics { + server: Box::new(server), + line, + column, + }); + } + if id == "get session value" || id.starts_with("get session value ") { + let rest = id + .strip_prefix("get session value") + .map(str::trim_start) + .unwrap_or("") + .to_string(); + let key = if rest.is_empty() { + self.parse_primary_expression()? + } else { + Expression::Variable(rest, line, column) + }; + self.expect_token( + Token::KeywordFrom, + "Expected 'from' after the session value key", + )?; + let session = self.parse_primary_expression()?; + return Ok(Expression::GetSessionValue { + key: Box::new(key), + session: Box::new(session), + line, + column, + }); + } + // `get session` or `get session ` + self.expect_token(Token::KeywordFrom, "Expected 'from' after 'get session'")?; + let request = self.parse_primary_expression()?; + Ok(Expression::GetSession { + request: Box::new(request), + line, + column, + }) + } + + fn parse_generate_csrf_token_for_session(&mut self) -> Result { + let token = self.bump_sync().unwrap(); + let (line, column) = (token.line, token.column); + self.expect_token( + Token::KeywordFor, + "Expected 'for' after 'generate csrf token'", + )?; + let session = self.parse_primary_expression()?; + Ok(Expression::GenerateCsrfTokenForSession { + session: Box::new(session), + line, + column, + }) + } + + fn parse_find_expired_sessions_expression(&mut self) -> Result { + let token = self.bump_sync().unwrap(); // find + let (line, column) = (token.line, token.column); + match self.cursor.peek() { + Some(t) + if matches!( + &t.token, + Token::Identifier(id) + if id == "expired sessions" + || id == "expired" + || id.starts_with("expired sessions ") + ) => + { + let id = match &t.token { + Token::Identifier(id) => id.clone(), + _ => unreachable!(), + }; + self.bump_sync(); + if id == "expired" { + match self.cursor.peek() { + Some(s) + if matches!( + &s.token, + Token::Identifier(word) if word == "sessions" || word.starts_with("sessions ") + ) => + { + self.bump_sync(); + } + Some(s) => { + return Err(ParseError::from_token( + "Expected 'expired sessions' after 'find'".to_string(), + s, + )); + } + None => { + return Err(ParseError::from_token( + "Expected 'expired sessions' after 'find'".to_string(), + token, + )); + } + } + } + } + Some(t) => { + return Err(ParseError::from_token( + "Expected 'expired sessions' after 'find'".to_string(), + t, + )); + } + None => { + return Err(ParseError::from_token( + "Expected 'expired sessions' after 'find'".to_string(), + token, + )); + } + } + self.expect_token( + Token::KeywordOn, + "Expected 'on' after 'find expired sessions'", + )?; + let server = self.parse_primary_expression()?; + Ok(Expression::FindExpiredSessions { + server: Box::new(server), + line, + column, + }) + } + + fn parse_load_session_data_expression(&mut self) -> Result { + let token = self.bump_sync().unwrap(); // load + let (line, column) = (token.line, token.column); + self.consume_session_data_marker(&token)?; + self.expect_token( + Token::KeywordFrom, + "Expected 'from storage' after 'session data'", + )?; + self.consume_storage_marker(&token)?; + let (key, _) = self.parse_storage_key_and_optional_data(false, &token)?; + Ok(Expression::LoadSessionData { + key: Box::new(key), + line, + column, + }) + } +} + +impl<'a> Parser<'a> { + fn consume_session_data_marker( + &mut self, + origin: &crate::lexer::token::TokenWithPosition, + ) -> Result<(), ParseError> { + match self.cursor.peek() { + Some(t) + if matches!( + &t.token, + Token::Identifier(id) + if id == "session data" + || id == "session_data" + || id.starts_with("session data ") + || id.starts_with("session_data ") + ) => + { + self.bump_sync(); + Ok(()) + } + Some(t) if matches!(&t.token, Token::Identifier(id) if id == "session") => { + self.bump_sync(); + match self.cursor.peek() { + Some(d) if d.token == Token::KeywordData => { + self.bump_sync(); + Ok(()) + } + Some(d) => Err(ParseError::from_token( + "Expected 'data' after 'session'".to_string(), + d, + )), + None => Err(ParseError::from_token( + "Expected 'data' after 'session'".to_string(), + origin, + )), + } + } + Some(t) => Err(ParseError::from_token( + "Expected 'session data'".to_string(), + t, + )), + None => Err(ParseError::from_token( + "Expected 'session data'".to_string(), + origin, + )), + } + } + + fn consume_storage_marker( + &mut self, + origin: &crate::lexer::token::TokenWithPosition, + ) -> Result<(), ParseError> { + match self.cursor.peek() { + Some(t) if matches!(&t.token, Token::Identifier(id) if id == "storage") => { + self.bump_sync(); + Ok(()) + } + Some(t) => Err(ParseError::from_token( + "Expected 'storage'".to_string(), + t, + )), + None => Err(ParseError::from_token("Expected 'storage'".to_string(), origin)), + } + } + + fn parse_storage_key_and_optional_data( + &mut self, + require_data: bool, + origin: &crate::lexer::token::TokenWithPosition, + ) -> Result<(Expression, Option), ParseError> { + self.expect_token( + Token::KeywordWith, + "Expected 'with key' after 'storage'", + )?; + let key = match self.cursor.peek() { + Some(t) if matches!(&t.token, Token::Identifier(id) if id == "key" || id.starts_with("key ")) => + { + let id = match &t.token { + Token::Identifier(id) => id.clone(), + _ => unreachable!(), + }; + let (id_line, id_column) = (t.line, t.column); + self.bump_sync(); + let rest = id.strip_prefix("key").map(str::trim_start).unwrap_or(""); + if rest.is_empty() { + self.parse_primary_expression()? + } else { + Expression::Variable(rest.to_string(), id_line, id_column) + } + } + Some(t) => { + return Err(ParseError::from_token( + "Expected 'key' after 'with'".to_string(), + t, + )); + } + None => { + return Err(ParseError::from_token( + "Expected 'key' after 'with'".to_string(), + origin, + )); + } + }; + if !require_data { + return Ok((key, None)); + } + self.expect_token( + Token::KeywordAnd, + "Expected 'and data' after the storage key", + )?; + let data = match self.cursor.peek() { + Some(t) if t.token == Token::KeywordData => { + self.bump_sync(); + self.parse_primary_expression()? + } + Some(t) + if matches!( + &t.token, + Token::Identifier(id) if id == "data" || id.starts_with("data ") + ) => + { + let id = match &t.token { + Token::Identifier(id) => id.clone(), + _ => unreachable!(), + }; + let (id_line, id_column) = (t.line, t.column); + self.bump_sync(); + let rest = id.strip_prefix("data").map(str::trim_start).unwrap_or(""); + if rest.is_empty() { + self.parse_primary_expression()? + } else { + Expression::Variable(rest.to_string(), id_line, id_column) + } + } + Some(t) => { + return Err(ParseError::from_token( + "Expected 'data' after 'and'".to_string(), + t, + )); + } + None => { + return Err(ParseError::from_token( + "Expected 'data' after 'and'".to_string(), + origin, + )); + } + }; + Ok((key, Some(data))) + } } diff --git a/src/parser/tests.rs b/src/parser/tests.rs index 170599fc..b03f98b8 100644 --- a/src/parser/tests.rs +++ b/src/parser/tests.rs @@ -3154,6 +3154,10 @@ fn can_start_primary_expression_matches_parse_primary_expression() { ("find x in y", true), ("replace x with y in z", true), ("split x by y", true), + ( + r#"load session data from storage with key "k""#, + true, + ), // A sample of the contextual-keyword catch-all: no dedicated arm // above, but `token.is_contextual_keyword()` is true, so a bare // keyword still resolves to a plain variable reference. diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index d33db924..35384e61 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -7098,6 +7098,7 @@ impl TypeChecker { server_name, tls, redirect_to_port, + sessions_enabled: _, line: _line, column: _column, } => { @@ -7197,6 +7198,8 @@ impl TypeChecker { status, content_type, headers, + set_session, + clear_session: _, line: _line, column: _column, } => { @@ -7267,6 +7270,9 @@ impl TypeChecker { ); } } + if let Some(session_expr) = set_session { + let _ = self.infer_expression_type(session_expr); + } } // Graceful shutdown and signal handling statements Statement::RegisterSignalHandlerStatement { @@ -7664,6 +7670,41 @@ impl TypeChecker { } } } + + Statement::ConfigureSessionsStatement { + server, + timeout, + storage, + .. + } => { + self.infer_expression_type(server); + self.infer_expression_type(timeout); + self.infer_expression_type(storage); + } + Statement::EnableCsrfProtectionStatement { server, .. } + | Statement::EnableSecureCookiesStatement { server, .. } => { + self.infer_expression_type(server); + } + Statement::SetSessionValueStatement { + key, + value, + session, + .. + } => { + self.infer_expression_type(key); + self.infer_expression_type(value); + self.infer_expression_type(session); + } + Statement::DestroySessionStatement { session, .. } => { + self.infer_expression_type(session); + } + Statement::StoreSessionDataStatement { key, data, .. } => { + self.infer_expression_type(key); + self.infer_expression_type(data); + } + Statement::DeleteSessionDataStatement { key, .. } => { + self.infer_expression_type(key); + } } } @@ -9288,6 +9329,34 @@ impl TypeChecker { self.check_database_query_operands(db, sql, parameters.as_deref(), *line, *column); Self::database_result_type(*kind) } + Expression::CreateSession { request, .. } | Expression::GetSession { request, .. } => { + self.infer_expression_type(request); + Type::Map(Box::new(Type::Text), Box::new(Type::Any)) + } + Expression::GetSessionValue { key, session, .. } => { + self.infer_expression_type(key); + self.infer_expression_type(session); + Type::Any + } + Expression::GenerateCsrfTokenForSession { session, .. } => { + self.infer_expression_type(session); + Type::Text + } + Expression::FindExpiredSessions { server, .. } => { + self.infer_expression_type(server); + Type::List(Box::new(Type::Map( + Box::new(Type::Text), + Box::new(Type::Any), + ))) + } + Expression::GetSessionStatistics { server, .. } => { + self.infer_expression_type(server); + Type::Map(Box::new(Type::Text), Box::new(Type::Any)) + } + Expression::LoadSessionData { key, .. } => { + self.infer_expression_type(key); + Type::Any + } } } @@ -10845,6 +10914,23 @@ impl TypeChecker { self.check_expression_names_defined(parameters); } } + Expression::CreateSession { request, .. } | Expression::GetSession { request, .. } => { + self.check_expression_names_defined(request); + } + Expression::GetSessionValue { key, session, .. } => { + self.check_expression_names_defined(key); + self.check_expression_names_defined(session); + } + Expression::GenerateCsrfTokenForSession { session, .. } => { + self.check_expression_names_defined(session); + } + Expression::FindExpiredSessions { server, .. } + | Expression::GetSessionStatistics { server, .. } => { + self.check_expression_names_defined(server); + } + Expression::LoadSessionData { key, .. } => { + self.check_expression_names_defined(key); + } } } @@ -11327,6 +11413,8 @@ push with exposed and "text" status: None, content_type: None, headers: Some(Expression::Literal(Literal::Integer(42), 2, 1)), + set_session: None, + clear_session: false, line: 2, column: 1, }, diff --git a/tests/typechecker_statement_operand_contract_test.rs b/tests/typechecker_statement_operand_contract_test.rs index 5a0d3da3..ad465faa 100644 --- a/tests/typechecker_statement_operand_contract_test.rs +++ b/tests/typechecker_statement_operand_contract_test.rs @@ -646,6 +646,8 @@ fn response_statements_reject_ordinary_maps_but_execute_file_defers_shape() { status: None, content_type: None, headers: None, + set_session: None, + clear_session: false, line: 2, column: 1, }, From 1cceb7754ba899229f63ae7574c3ad9f7553f7fe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 15:34:38 +0000 Subject: [PATCH 04/10] feat: add SessionManager with memory, file, and SQLite stores Wire listen, configure/enable, create/get/set/destroy, respond cookies, CSRF tokens, expiry, statistics, and the storage KV API through a shared manager. Concurrent handlers share one lock or the sqlx pool. Co-authored-by: logbie --- src/interpreter/mod.rs | 593 +++++++++++++++++++++++++++++++ src/interpreter/sessions.rs | 683 ++++++++++++++++++++++++++++++++++++ tests/session_store_test.rs | 39 +- 3 files changed, 1297 insertions(+), 18 deletions(-) create mode 100644 src/interpreter/sessions.rs diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index d577b598..4ffbf711 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -5,6 +5,7 @@ pub mod command_sanitizer; pub mod control_flow; pub mod database; pub mod environment; +pub mod sessions; pub mod error; pub(crate) mod io_capture; #[cfg(test)] @@ -331,6 +332,31 @@ pub(crate) fn lookup_header_case_insensitive( }) } +fn cookie_value_from_request(request: &Value, cookie_name: &str) -> Option { + let Value::Object(obj) = request else { + return None; + }; + let obj = obj.borrow(); + let headers = match obj.get("headers") { + Some(Value::Object(headers)) => headers.borrow(), + _ => return None, + }; + let raw = lookup_header_case_insensitive(&headers, "cookie")?; + let header = match raw { + Value::Text(s) => s.to_string(), + _ => return None, + }; + for part in header.split(';') { + let part = part.trim(); + if let Some((name, value)) = part.split_once('=') + && name.trim() == cookie_name + { + return Some(value.trim().to_string()); + } + } + None +} + #[derive(Debug)] pub struct WflWebServer { // Bounded transport→interpreter queue (Phase 0, PR-0c): a full queue sheds @@ -338,6 +364,7 @@ pub struct WflWebServer { pub request_receiver: Arc>>, pub request_sender: mpsc::Sender, pub server_handle: Option>, + pub sessions: Option>, } impl Drop for WflWebServer { @@ -1496,6 +1523,17 @@ fn stmt_type(stmt: &Statement) -> String { format!("TestBlock '{description}'") } Statement::ExpectStatement { .. } => "ExpectStatement".to_string(), + Statement::ConfigureSessionsStatement { .. } => "ConfigureSessionsStatement".to_string(), + Statement::EnableCsrfProtectionStatement { .. } => { + "EnableCsrfProtectionStatement".to_string() + } + Statement::EnableSecureCookiesStatement { .. } => { + "EnableSecureCookiesStatement".to_string() + } + Statement::SetSessionValueStatement { .. } => "SetSessionValueStatement".to_string(), + Statement::DestroySessionStatement { .. } => "DestroySessionStatement".to_string(), + Statement::StoreSessionDataStatement { .. } => "StoreSessionDataStatement".to_string(), + Statement::DeleteSessionDataStatement { .. } => "DeleteSessionDataStatement".to_string(), } } @@ -1550,6 +1588,13 @@ fn expr_type(expr: &Expression) -> String { } Expression::ProcessRunning { .. } => "ProcessRunning".to_string(), Expression::DatabaseQuery { .. } => "DatabaseQuery".to_string(), + Expression::CreateSession { .. } => "CreateSession".to_string(), + Expression::GetSession { .. } => "GetSession".to_string(), + Expression::GetSessionValue { .. } => "GetSessionValue".to_string(), + Expression::GenerateCsrfTokenForSession { .. } => "GenerateCsrfTokenForSession".to_string(), + Expression::FindExpiredSessions { .. } => "FindExpiredSessions".to_string(), + Expression::GetSessionStatistics { .. } => "GetSessionStatistics".to_string(), + Expression::LoadSessionData { .. } => "LoadSessionData".to_string(), } } @@ -7141,6 +7186,13 @@ impl Interpreter { Statement::DescribeBlock { line, column, .. } => (*line, *column), Statement::TestBlock { line, column, .. } => (*line, *column), Statement::ExpectStatement { line, column, .. } => (*line, *column), + Statement::ConfigureSessionsStatement { line, column, .. } => (*line, *column), + Statement::EnableCsrfProtectionStatement { line, column, .. } => (*line, *column), + Statement::EnableSecureCookiesStatement { line, column, .. } => (*line, *column), + Statement::SetSessionValueStatement { line, column, .. } => (*line, *column), + Statement::DestroySessionStatement { line, column, .. } => (*line, *column), + Statement::StoreSessionDataStatement { line, column, .. } => (*line, *column), + Statement::DeleteSessionDataStatement { line, column, .. } => (*line, *column), }; let result = match stmt { @@ -10619,6 +10671,7 @@ impl Interpreter { server_name, tls, redirect_to_port, + sessions_enabled, line, column, } => { @@ -10651,6 +10704,18 @@ impl Interpreter { // can no longer pin memory indefinitely. Body size is enforced // *while streaming* (below), which bounds chunked bodies that // carry no Content-Length. + let session_manager = if *sessions_enabled { + Some(Arc::new( + sessions::SessionManager::new(sessions::SessionConfig::from_wfl_config( + &self.config, + )) + .await + .map_err(|e| RuntimeError::new(e, *line, *column))?, + )) + } else { + None + }; + let request_sender_clone = request_sender.clone(); let max_body_size = self.budget.max_request_body_bytes(); let max_body_size_u64 = max_body_size as u64; @@ -11020,6 +11085,7 @@ impl Interpreter { request_receiver: request_receiver.clone(), request_sender: request_sender.clone(), server_handle: Some(server_handle), + sessions: session_manager.clone(), }; self.web_servers .borrow_mut() @@ -11163,6 +11229,7 @@ impl Interpreter { request_receiver: request_receiver.clone(), request_sender: request_sender.clone(), server_handle: Some(server_handle), + sessions: session_manager.clone(), }; self.web_servers .borrow_mut() @@ -11204,6 +11271,7 @@ impl Interpreter { request_receiver: request_receiver.clone(), request_sender: request_sender.clone(), server_handle: Some(server_handle), + sessions: session_manager.clone(), }; // Store the server in the interpreter @@ -11455,6 +11523,10 @@ impl Interpreter { ); request_properties.insert("body_bytes".to_string(), body_binary.clone()); request_properties.insert("headers".to_string(), headers_object.clone()); + request_properties.insert( + "_session_server".to_string(), + Value::Text(Arc::from(server_name.as_str())), + ); let request_object = Value::Object(Rc::new(RefCell::new(request_properties))); // These bindings are refreshed on every wait, so overwrite any @@ -11522,6 +11594,8 @@ impl Interpreter { status, content_type, headers, + set_session, + clear_session, line, column, } => { @@ -11559,6 +11633,8 @@ impl Interpreter { } }; + let request_for_cookie = request_val.clone(); + // Keep the pending request parked until content/status/header // expressions are evaluated so a browser disconnect still cancels // any upstream open/read performed during that evaluation @@ -11721,6 +11797,25 @@ impl Interpreter { } } + if let Some(session_expr) = set_session { + let session_val = self + .evaluate_expression(session_expr, Rc::clone(&env)) + .await?; + let cookie = self + .session_set_cookie(&session_val, false, *line, *column) + .await?; + custom_headers + .entry("Set-Cookie".to_string()) + .or_insert(cookie); + } else if *clear_session { + let cookie = self + .session_set_cookie(&request_for_cookie, true, *line, *column) + .await?; + custom_headers + .entry("Set-Cookie".to_string()) + .or_insert(cookie); + } + Ok(WflHttpResponse { content: content_bytes, status: status_code, @@ -13258,6 +13353,160 @@ impl Interpreter { Ok((Value::Null, ControlFlow::None)) } + Statement::ConfigureSessionsStatement { + server, + timeout, + storage, + line, + column, + } => { + let manager = self + .session_manager_from_server_expr(server, Rc::clone(&env), *line, *column) + .await?; + let timeout_val = self.evaluate_expression(timeout, Rc::clone(&env)).await?; + let timeout_ms = match timeout_val { + Value::Number(n) if n >= 1.0 => n as u64, + _ => { + return Err(RuntimeError::new( + "Session timeout must be a number of milliseconds >= 1".to_string(), + *line, + *column, + )); + } + }; + let storage_val = self.evaluate_expression(storage, Rc::clone(&env)).await?; + let storage_kind = match &storage_val { + Value::Text(s) => sessions::SessionStorageKind::parse(s).map_err(|e| { + RuntimeError::new(e, *line, *column) + })?, + _ => { + return Err(RuntimeError::new( + "Session storage must be text: memory, file, or database".to_string(), + *line, + *column, + )); + } + }; + manager + .configure(timeout_ms, storage_kind) + .await + .map_err(|e| RuntimeError::new(e, *line, *column))?; + Ok((Value::Null, ControlFlow::None)) + } + Statement::EnableCsrfProtectionStatement { + server, + line, + column, + } => { + let manager = self + .session_manager_from_server_expr(server, Rc::clone(&env), *line, *column) + .await?; + manager.enable_csrf().await; + Ok((Value::Null, ControlFlow::None)) + } + Statement::EnableSecureCookiesStatement { + server, + line, + column, + } => { + let manager = self + .session_manager_from_server_expr(server, Rc::clone(&env), *line, *column) + .await?; + manager.enable_secure_cookies().await; + Ok((Value::Null, ControlFlow::None)) + } + Statement::SetSessionValueStatement { + key, + value, + session, + line, + column, + } => { + let session_val = self.evaluate_expression(session, Rc::clone(&env)).await?; + let key_val = self.evaluate_expression(key, Rc::clone(&env)).await?; + let value_val = self.evaluate_expression(value, Rc::clone(&env)).await?; + let key_text = match &key_val { + Value::Text(s) => s.to_string(), + _ => { + return Err(RuntimeError::new( + "Session value key must be text".to_string(), + *line, + *column, + )); + } + }; + let (manager, id) = self + .session_manager_from_session_object(&session_val, *line, *column) + .await?; + manager + .set_value(&id, &key_text, value_val) + .await + .map_err(|e| RuntimeError::new(e, *line, *column))?; + Ok((Value::Null, ControlFlow::None)) + } + Statement::DestroySessionStatement { + session, + line, + column, + } => { + let session_val = self.evaluate_expression(session, Rc::clone(&env)).await?; + let (manager, id) = self + .session_manager_from_session_object(&session_val, *line, *column) + .await?; + manager + .destroy(&id) + .await + .map_err(|e| RuntimeError::new(e, *line, *column))?; + Ok((Value::Null, ControlFlow::None)) + } + Statement::StoreSessionDataStatement { + key, + data, + line, + column, + } => { + let manager = self + .sole_or_named_session_manager(*line, *column) + .await?; + let key_val = self.evaluate_expression(key, Rc::clone(&env)).await?; + let data_val = self.evaluate_expression(data, Rc::clone(&env)).await?; + let key_text = match &key_val { + Value::Text(s) => s.to_string(), + _ => { + return Err(RuntimeError::new( + "Session storage key must be text".to_string(), + *line, + *column, + )); + } + }; + manager + .put_kv(&key_text, data_val) + .await + .map_err(|e| RuntimeError::new(e, *line, *column))?; + Ok((Value::Null, ControlFlow::None)) + } + Statement::DeleteSessionDataStatement { key, line, column } => { + let manager = self + .sole_or_named_session_manager(*line, *column) + .await?; + let key_val = self.evaluate_expression(key, Rc::clone(&env)).await?; + let key_text = match &key_val { + Value::Text(s) => s.to_string(), + _ => { + return Err(RuntimeError::new( + "Session storage key must be text".to_string(), + *line, + *column, + )); + } + }; + manager + .delete_kv(&key_text) + .await + .map_err(|e| RuntimeError::new(e, *line, *column))?; + Ok((Value::Null, ControlFlow::None)) + } }; if self.step_mode { @@ -13270,6 +13519,203 @@ impl Interpreter { result } + async fn session_server_name_from_expr( + &self, + server: &Expression, + env: Rc>, + line: usize, + column: usize, + ) -> Result { + if let Expression::Variable(name, ..) = server + && self.web_servers.borrow().contains_key(name) + { + return Ok(name.clone()); + } + let value = self.evaluate_expression(server, env).await?; + match value { + Value::Text(name) => { + let name_str = name.as_ref(); + if self.web_servers.borrow().contains_key(name_str) { + return Ok(name_str.to_string()); + } + if name_str.starts_with("WebServer::") { + let web_servers = self.web_servers.borrow(); + for server_name in web_servers.keys() { + return Ok(server_name.clone()); + } + } + Err(RuntimeError::new( + format!("Unknown web server '{name_str}'"), + line, + column, + )) + } + _ => Err(RuntimeError::new( + "Expected a web server".to_string(), + line, + column, + )), + } + } + + async fn session_manager_from_server_expr( + &self, + server: &Expression, + env: Rc>, + line: usize, + column: usize, + ) -> Result, RuntimeError> { + let name = self + .session_server_name_from_expr(server, env, line, column) + .await?; + self.session_manager_by_name(&name, line, column) + } + + fn session_manager_by_name( + &self, + name: &str, + line: usize, + column: usize, + ) -> Result, RuntimeError> { + let servers = self.web_servers.borrow(); + match servers.get(name).and_then(|s| s.sessions.clone()) { + Some(manager) => Ok(manager), + None => Err(RuntimeError::new( + format!( + "Server '{name}' does not have sessions enabled. Use `listen ... with sessions enabled`." + ), + line, + column, + )), + } + } + + async fn session_manager_from_request( + &self, + request: &Value, + line: usize, + column: usize, + ) -> Result<(Arc, String), RuntimeError> { + let server = match request { + Value::Object(obj) => match obj.borrow().get("_session_server") { + Some(Value::Text(name)) => name.to_string(), + _ => { + return Err(RuntimeError::new( + "Request is missing its session server. Wait for the request on a session-enabled listener.".to_string(), + line, + column, + )); + } + }, + _ => { + return Err(RuntimeError::new( + "Expected a request object".to_string(), + line, + column, + )); + } + }; + let manager = self.session_manager_by_name(&server, line, column)?; + Ok((manager, server)) + } + + async fn session_manager_from_session_object( + &self, + session: &Value, + line: usize, + column: usize, + ) -> Result<(Arc, String), RuntimeError> { + let (server, id) = match session { + Value::Object(obj) => { + let obj = obj.borrow(); + let server = match obj.get("_server") { + Some(Value::Text(name)) => name.to_string(), + _ => { + return Err(RuntimeError::new( + "Expected a session object from create session or get session" + .to_string(), + line, + column, + )); + } + }; + let id = match obj.get("id") { + Some(Value::Text(id)) => id.to_string(), + _ => { + return Err(RuntimeError::new( + "Session object is missing its id".to_string(), + line, + column, + )); + } + }; + (server, id) + } + _ => { + return Err(RuntimeError::new( + "Expected a session object".to_string(), + line, + column, + )); + } + }; + let manager = self.session_manager_by_name(&server, line, column)?; + Ok((manager, id)) + } + + async fn sole_or_named_session_manager( + &self, + line: usize, + column: usize, + ) -> Result, RuntimeError> { + let managers: Vec<_> = self + .web_servers + .borrow() + .iter() + .filter_map(|(name, server)| server.sessions.clone().map(|m| (name.clone(), m))) + .collect(); + match managers.len() { + 0 => Err(RuntimeError::new( + "No session-enabled server is running. Use `listen ... with sessions enabled`." + .to_string(), + line, + column, + )), + 1 => Ok(managers.into_iter().next().unwrap().1), + _ => Err(RuntimeError::new( + "More than one session-enabled server is running; name the server in configure sessions." + .to_string(), + line, + column, + )), + } + } + + async fn session_set_cookie( + &self, + source: &Value, + clear: bool, + line: usize, + column: usize, + ) -> Result { + let (manager, id) = if matches!(source, Value::Object(obj) if obj.borrow().contains_key("id")) + { + self.session_manager_from_session_object(source, line, column) + .await? + } else { + let (manager, _) = self + .session_manager_from_request(source, line, column) + .await?; + (manager, String::new()) + }; + let cfg = manager.config_snapshot().await; + Ok(sessions::SessionManager::format_set_cookie( + &cfg, + if clear { "" } else { &id }, + clear, + )) + } + /// Resolves a server expression to the key of a running WebSocket server. async fn resolve_ws_server_key( &self, @@ -15310,6 +15756,152 @@ impl Interpreter { ) .await } + Expression::CreateSession { + request, + line, + column, + } => { + let request_val = self.evaluate_expression(request, Rc::clone(&env)).await?; + let (manager, server_name) = self + .session_manager_from_request(&request_val, *line, *column) + .await?; + let record = manager + .create() + .await + .map_err(|e| RuntimeError::new(e, *line, *column))?; + Ok(sessions::SessionManager::session_object( + &record, + &server_name, + )) + } + Expression::GetSession { + request, + line, + column, + } => { + let request_val = self.evaluate_expression(request, Rc::clone(&env)).await?; + let (manager, server_name) = self + .session_manager_from_request(&request_val, *line, *column) + .await?; + let cfg = manager.config_snapshot().await; + let sid = match cookie_value_from_request(&request_val, &cfg.cookie_name) { + Some(id) => id, + None => return Ok(Value::Nothing), + }; + match manager + .get(&sid) + .await + .map_err(|e| RuntimeError::new(e, *line, *column))? + { + Some(record) => Ok(sessions::SessionManager::session_object( + &record, + &server_name, + )), + None => Ok(Value::Nothing), + } + } + Expression::GetSessionValue { + key, + session, + line, + column, + } => { + let session_val = self.evaluate_expression(session, Rc::clone(&env)).await?; + let key_val = self.evaluate_expression(key, Rc::clone(&env)).await?; + let key_text = match &key_val { + Value::Text(s) => s.to_string(), + _ => { + return Err(RuntimeError::new( + "Session value key must be text".to_string(), + *line, + *column, + )); + } + }; + let (manager, id) = self + .session_manager_from_session_object(&session_val, *line, *column) + .await?; + match manager + .get(&id) + .await + .map_err(|e| RuntimeError::new(e, *line, *column))? + { + Some(record) => Ok(record.data.get(&key_text).cloned().unwrap_or(Value::Nothing)), + None => Ok(Value::Nothing), + } + } + Expression::GenerateCsrfTokenForSession { + session, + line, + column, + } => { + let session_val = self.evaluate_expression(session, Rc::clone(&env)).await?; + let (manager, id) = self + .session_manager_from_session_object(&session_val, *line, *column) + .await?; + let token = sessions::SessionManager::generate_csrf_hex(); + manager + .set_value(&id, "csrf_token", Value::Text(Arc::from(token.as_str()))) + .await + .map_err(|e| RuntimeError::new(e, *line, *column))?; + Ok(Value::Text(Arc::from(token.as_str()))) + } + Expression::FindExpiredSessions { + server, + line, + column, + } => { + let manager = self + .session_manager_from_server_expr(server, Rc::clone(&env), *line, *column) + .await?; + let server_name = self + .session_server_name_from_expr(server, Rc::clone(&env), *line, *column) + .await?; + let expired = manager + .find_expired() + .await + .map_err(|e| RuntimeError::new(e, *line, *column))?; + let values = expired + .iter() + .map(|record| sessions::SessionManager::session_object(record, &server_name)) + .collect(); + Ok(Value::List(Rc::new(RefCell::new(values)))) + } + Expression::GetSessionStatistics { + server, + line, + column, + } => { + let manager = self + .session_manager_from_server_expr(server, Rc::clone(&env), *line, *column) + .await?; + let stats = manager.stats().await; + Ok(sessions::SessionManager::stats_object(&stats)) + } + Expression::LoadSessionData { key, line, column } => { + let manager = self + .sole_or_named_session_manager(*line, *column) + .await?; + let key_val = self.evaluate_expression(key, Rc::clone(&env)).await?; + let key_text = match &key_val { + Value::Text(s) => s.to_string(), + _ => { + return Err(RuntimeError::new( + "Session storage key must be text".to_string(), + *line, + *column, + )); + } + }; + match manager + .load_kv(&key_text) + .await + .map_err(|e| RuntimeError::new(e, *line, *column))? + { + Some(value) => Ok(value), + None => Ok(Value::Nothing), + } + } }; self.assert_invariants(); result @@ -17369,6 +17961,7 @@ mod request_wait_timeout_tests { request_receiver: Arc::new(tokio::sync::Mutex::new(request_receiver)), request_sender, server_handle: None, + sessions: None, }, ); env.borrow_mut() diff --git a/src/interpreter/sessions.rs b/src/interpreter/sessions.rs new file mode 100644 index 00000000..29fdffe4 --- /dev/null +++ b/src/interpreter/sessions.rs @@ -0,0 +1,683 @@ +//! Server-side session store for `listen ... with sessions enabled`. +//! +//! Three backends share one [`SessionManager`]: in-memory, a JSON file, and +//! SQLite. Session IDs are 32 CSPRNG bytes encoded as hex. Lookups of unknown +//! or expired IDs return `None`; a full store refuses `create`. + +use super::value::Value; +use crate::config::WflConfig; +use serde_json::{Map, json}; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::sync::Mutex; + +const SESSION_ID_BYTES: usize = 32; +const CSRF_BYTES: usize = 32; + +pub use crate::config::{SessionSameSite, SessionStorageKind}; + +#[derive(Debug, Clone)] +pub struct SessionConfig { + pub timeout_ms: u64, + pub storage: SessionStorageKind, + pub db_path: String, + pub file_path: String, + pub cookie_name: String, + pub cookie_secure: bool, + pub cookie_samesite: SessionSameSite, + pub cookie_httponly: bool, + pub csrf_enabled: bool, + pub max_sessions: usize, +} + +impl SessionConfig { + pub fn from_wfl_config(config: &WflConfig) -> Self { + Self { + timeout_ms: config.session_timeout_ms, + storage: config.session_storage, + db_path: config.session_db_path.clone(), + file_path: config.session_file_path.clone(), + cookie_name: config.session_cookie_name.clone(), + cookie_secure: config.session_cookie_secure, + cookie_samesite: config.session_cookie_samesite, + cookie_httponly: config.session_cookie_httponly, + csrf_enabled: config.session_csrf_enabled, + max_sessions: config.session_max_sessions, + } + } +} + +#[derive(Debug, Clone)] +pub struct SessionRecord { + pub id: String, + pub data: HashMap, + pub created_at: i64, + pub last_activity: i64, + pub expires_at: i64, +} + +#[derive(Debug, Clone)] +pub struct SessionStats { + pub active_sessions: u64, + pub total_created: u64, + pub expired_count: u64, + pub storage_type: String, +} + +struct StoreState { + kind: SessionStorageKind, + sessions: HashMap, + kv: HashMap, + file_path: Option, + db: Option, +} + +pub struct SessionManager { + config: Mutex, + store: Mutex, + total_created: Mutex, + expired_count: Mutex, +} + +impl std::fmt::Debug for SessionManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SessionManager") + } +} + +impl SessionManager { + pub async fn new(config: SessionConfig) -> Result { + let store = open_store(&config).await?; + Ok(Self { + config: Mutex::new(config), + store: Mutex::new(store), + total_created: Mutex::new(0), + expired_count: Mutex::new(0), + }) + } + + pub async fn config_snapshot(&self) -> SessionConfig { + self.config.lock().await.clone() + } + + pub async fn configure( + &self, + timeout_ms: u64, + storage: SessionStorageKind, + ) -> Result<(), String> { + let mut cfg = self.config.lock().await; + let storage_changed = cfg.storage != storage; + cfg.timeout_ms = timeout_ms; + cfg.storage = storage; + if storage_changed { + let new_store = open_store(&cfg).await?; + *self.store.lock().await = new_store; + } + Ok(()) + } + + pub async fn enable_csrf(&self) { + self.config.lock().await.csrf_enabled = true; + } + + pub async fn enable_secure_cookies(&self) { + self.config.lock().await.cookie_secure = true; + } + + pub async fn create(&self) -> Result { + let cfg = self.config.lock().await.clone(); + let mut store = self.store.lock().await; + prune_expired(&mut store, &cfg, &self.expired_count).await; + if store.sessions.len() >= cfg.max_sessions { + return Err(format!( + "Session store is full (maximum {} sessions). Destroy unused sessions or raise session_max_sessions in .wflcfg.", + cfg.max_sessions + )); + } + let now = now_ms(); + let record = SessionRecord { + id: random_hex(SESSION_ID_BYTES), + data: HashMap::new(), + created_at: now, + last_activity: now, + expires_at: now.saturating_add(cfg.timeout_ms as i64), + }; + store.sessions.insert(record.id.clone(), record.clone()); + persist(&store).await?; + *self.total_created.lock().await += 1; + Ok(record) + } + + pub async fn get(&self, id: &str) -> Result, String> { + let cfg = self.config.lock().await.clone(); + let mut store = self.store.lock().await; + prune_expired(&mut store, &cfg, &self.expired_count).await; + match store.sessions.get(id) { + Some(record) if record.expires_at >= now_ms() => { + let mut updated = record.clone(); + let now = now_ms(); + updated.last_activity = now; + updated.expires_at = now.saturating_add(cfg.timeout_ms as i64); + store.sessions.insert(id.to_string(), updated.clone()); + persist(&store).await?; + Ok(Some(updated)) + } + Some(_) => { + store.sessions.remove(id); + *self.expired_count.lock().await += 1; + persist(&store).await?; + Ok(None) + } + None => Ok(None), + } + } + + pub async fn set_value(&self, id: &str, key: &str, value: Value) -> Result<(), String> { + value_to_json(&value)?; + let cfg = self.config.lock().await.clone(); + let mut store = self.store.lock().await; + prune_expired(&mut store, &cfg, &self.expired_count).await; + let record = store + .sessions + .get_mut(id) + .ok_or_else(|| format!("Unknown session '{id}'. It may have been destroyed."))?; + if record.expires_at < now_ms() { + store.sessions.remove(id); + *self.expired_count.lock().await += 1; + persist(&store).await?; + return Err(format!("Unknown session '{id}'. It may have been destroyed.")); + } + record.data.insert(key.to_string(), value); + let now = now_ms(); + record.last_activity = now; + record.expires_at = now.saturating_add(cfg.timeout_ms as i64); + persist(&store).await + } + + pub async fn destroy(&self, id: &str) -> Result<(), String> { + let mut store = self.store.lock().await; + store.sessions.remove(id); + persist(&store).await + } + + pub async fn find_expired(&self) -> Result, String> { + let mut store = self.store.lock().await; + let now = now_ms(); + let expired: Vec = store + .sessions + .values() + .filter(|record| record.expires_at < now) + .cloned() + .collect(); + for record in &expired { + store.sessions.remove(&record.id); + } + if !expired.is_empty() { + *self.expired_count.lock().await += expired.len() as u64; + persist(&store).await?; + } + Ok(expired) + } + + pub async fn stats(&self) -> SessionStats { + let cfg = self.config.lock().await.clone(); + let store = self.store.lock().await; + let now = now_ms(); + let active = store + .sessions + .values() + .filter(|record| record.expires_at >= now) + .count() as u64; + SessionStats { + active_sessions: active, + total_created: *self.total_created.lock().await, + expired_count: *self.expired_count.lock().await, + storage_type: cfg.storage.as_str().to_string(), + } + } + + pub async fn put_kv(&self, key: &str, data: Value) -> Result<(), String> { + value_to_json(&data)?; + let mut store = self.store.lock().await; + store.kv.insert(key.to_string(), data); + persist(&store).await + } + + pub async fn load_kv(&self, key: &str) -> Result, String> { + let store = self.store.lock().await; + Ok(store.kv.get(key).cloned()) + } + + pub async fn delete_kv(&self, key: &str) -> Result<(), String> { + let mut store = self.store.lock().await; + store.kv.remove(key); + persist(&store).await + } + + pub fn format_set_cookie(config: &SessionConfig, session_id: &str, clear: bool) -> String { + let max_age = if clear { + 0 + } else { + config.timeout_ms.div_ceil(1000) + }; + let mut parts = vec![ + format!("{}={}", config.cookie_name, session_id), + "Path=/".to_string(), + format!("Max-Age={max_age}"), + format!("SameSite={}", config.cookie_samesite.as_str()), + ]; + if config.cookie_httponly { + parts.push("HttpOnly".to_string()); + } + if config.cookie_secure { + parts.push("Secure".to_string()); + } + parts.join("; ") + } + + pub fn session_object(record: &SessionRecord, server_name: &str) -> Value { + let mut map = HashMap::new(); + map.insert("id".to_string(), Value::Text(Arc::from(record.id.as_str()))); + map.insert( + "created_at".to_string(), + Value::Number(record.created_at as f64), + ); + map.insert( + "last_activity".to_string(), + Value::Number(record.last_activity as f64), + ); + map.insert( + "_server".to_string(), + Value::Text(Arc::from(server_name)), + ); + Value::Object(std::rc::Rc::new(std::cell::RefCell::new(map))) + } + + pub fn stats_object(stats: &SessionStats) -> Value { + let mut map = HashMap::new(); + map.insert( + "active_sessions".to_string(), + Value::Number(stats.active_sessions as f64), + ); + map.insert( + "total_created".to_string(), + Value::Number(stats.total_created as f64), + ); + map.insert( + "expired_count".to_string(), + Value::Number(stats.expired_count as f64), + ); + map.insert( + "storage_type".to_string(), + Value::Text(Arc::from(stats.storage_type.as_str())), + ); + Value::Object(std::rc::Rc::new(std::cell::RefCell::new(map))) + } + + pub fn generate_csrf_hex() -> String { + random_hex(CSRF_BYTES) + } +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn random_hex(n: usize) -> String { + use rand::Rng; + let mut buf = vec![0u8; n]; + rand::rng().fill_bytes(&mut buf); + hex_encode(&buf) +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} + +async fn open_store(config: &SessionConfig) -> Result { + match config.storage { + SessionStorageKind::Memory => Ok(StoreState { + kind: SessionStorageKind::Memory, + sessions: HashMap::new(), + kv: HashMap::new(), + file_path: None, + db: None, + }), + SessionStorageKind::File => { + let path = PathBuf::from(&config.file_path); + let (sessions, kv) = load_file_store(&path)?; + Ok(StoreState { + kind: SessionStorageKind::File, + sessions, + kv, + file_path: Some(path), + db: None, + }) + } + SessionStorageKind::Database => { + let options = SqliteConnectOptions::new() + .filename(&config.db_path) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect_with(options) + .await + .map_err(|e| format!("Failed to open session database: {e}"))?; + init_sqlite(&pool).await?; + let (sessions, kv) = load_sqlite(&pool).await?; + Ok(StoreState { + kind: SessionStorageKind::Database, + sessions, + kv, + file_path: None, + db: Some(pool), + }) + } + } +} + +async fn prune_expired( + store: &mut StoreState, + _cfg: &SessionConfig, + expired_count: &Mutex, +) { + let now = now_ms(); + let before = store.sessions.len(); + store.sessions.retain(|_, record| record.expires_at >= now); + let removed = before - store.sessions.len(); + if removed > 0 { + *expired_count.lock().await += removed as u64; + } +} + +async fn persist(store: &StoreState) -> Result<(), String> { + match store.kind { + SessionStorageKind::Memory => Ok(()), + SessionStorageKind::File => { + let path = store + .file_path + .as_ref() + .ok_or_else(|| "Session file path is missing".to_string())?; + save_file_store(path, &store.sessions, &store.kv) + } + SessionStorageKind::Database => { + let pool = store + .db + .as_ref() + .ok_or_else(|| "Session database is missing".to_string())?; + save_sqlite(pool, &store.sessions, &store.kv).await + } + } +} + +fn load_file_store( + path: &Path, +) -> Result<(HashMap, HashMap), String> { + if !path.exists() { + return Ok((HashMap::new(), HashMap::new())); + } + let text = std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read session file {}: {e}", path.display()))?; + let root: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| format!("Failed to parse session file {}: {e}", path.display()))?; + decode_store_json(&root) +} + +fn save_file_store( + path: &Path, + sessions: &HashMap, + kv: &HashMap, +) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create session file directory: {e}"))?; + } + let json = encode_store_json(sessions, kv)?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, json) + .map_err(|e| format!("Failed to write session file {}: {e}", tmp.display()))?; + std::fs::rename(&tmp, path) + .map_err(|e| format!("Failed to replace session file {}: {e}", path.display()))?; + Ok(()) +} + +async fn init_sqlite(pool: &SqlitePool) -> Result<(), String> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS wfl_sessions ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_activity INTEGER NOT NULL, + expires_at INTEGER NOT NULL + )", + ) + .execute(pool) + .await + .map_err(|e| format!("Failed to create wfl_sessions table: {e}"))?; + sqlx::query( + "CREATE TABLE IF NOT EXISTS wfl_session_kv ( + key TEXT PRIMARY KEY, + data TEXT NOT NULL + )", + ) + .execute(pool) + .await + .map_err(|e| format!("Failed to create wfl_session_kv table: {e}"))?; + Ok(()) +} + +async fn load_sqlite( + pool: &SqlitePool, +) -> Result<(HashMap, HashMap), String> { + let session_rows = sqlx::query_as::<_, (String, String, i64, i64, i64)>( + "SELECT id, data, created_at, last_activity, expires_at FROM wfl_sessions", + ) + .fetch_all(pool) + .await + .map_err(|e| format!("Failed to load sessions: {e}"))?; + let mut sessions = HashMap::new(); + for (id, data, created_at, last_activity, expires_at) in session_rows { + let json: serde_json::Value = serde_json::from_str(&data) + .map_err(|e| format!("Corrupt session row {id}: {e}"))?; + sessions.insert( + id.clone(), + SessionRecord { + id, + data: json_to_map(&json)?, + created_at, + last_activity, + expires_at, + }, + ); + } + let kv_rows = sqlx::query_as::<_, (String, String)>("SELECT key, data FROM wfl_session_kv") + .fetch_all(pool) + .await + .map_err(|e| format!("Failed to load session storage: {e}"))?; + let mut kv = HashMap::new(); + for (key, data) in kv_rows { + let json: serde_json::Value = + serde_json::from_str(&data).map_err(|e| format!("Corrupt session kv '{key}': {e}"))?; + kv.insert(key, json_to_value(&json)?); + } + Ok((sessions, kv)) +} + +async fn save_sqlite( + pool: &SqlitePool, + sessions: &HashMap, + kv: &HashMap, +) -> Result<(), String> { + let mut tx = pool + .begin() + .await + .map_err(|e| format!("Failed to start session transaction: {e}"))?; + sqlx::query("DELETE FROM wfl_sessions") + .execute(&mut *tx) + .await + .map_err(|e| format!("Failed to clear sessions: {e}"))?; + sqlx::query("DELETE FROM wfl_session_kv") + .execute(&mut *tx) + .await + .map_err(|e| format!("Failed to clear session storage: {e}"))?; + for record in sessions.values() { + let data = serde_json::to_string(&map_to_json(&record.data)?) + .map_err(|e| format!("Failed to encode session: {e}"))?; + sqlx::query( + "INSERT INTO wfl_sessions (id, data, created_at, last_activity, expires_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(&record.id) + .bind(data) + .bind(record.created_at) + .bind(record.last_activity) + .bind(record.expires_at) + .execute(&mut *tx) + .await + .map_err(|e| format!("Failed to save session: {e}"))?; + } + for (key, value) in kv { + let data = serde_json::to_string(&value_to_json(value)?) + .map_err(|e| format!("Failed to encode session storage: {e}"))?; + sqlx::query("INSERT INTO wfl_session_kv (key, data) VALUES (?, ?)") + .bind(key) + .bind(data) + .execute(&mut *tx) + .await + .map_err(|e| format!("Failed to save session storage: {e}"))?; + } + tx.commit() + .await + .map_err(|e| format!("Failed to commit session store: {e}"))?; + Ok(()) +} + +fn encode_store_json( + sessions: &HashMap, + kv: &HashMap, +) -> Result { + let mut session_map = Map::new(); + for (id, record) in sessions { + session_map.insert( + id.clone(), + json!({ + "data": map_to_json(&record.data)?, + "created_at": record.created_at, + "last_activity": record.last_activity, + "expires_at": record.expires_at, + }), + ); + } + let mut kv_map = Map::new(); + for (key, value) in kv { + kv_map.insert(key.clone(), value_to_json(value)?); + } + serde_json::to_string_pretty(&json!({ + "sessions": session_map, + "kv": kv_map, + })) + .map_err(|e| format!("Failed to encode session file: {e}")) +} + +fn decode_store_json( + root: &serde_json::Value, +) -> Result<(HashMap, HashMap), String> { + let mut sessions = HashMap::new(); + if let Some(map) = root.get("sessions").and_then(|v| v.as_object()) { + for (id, record) in map { + sessions.insert( + id.clone(), + SessionRecord { + id: id.clone(), + data: json_to_map(record.get("data").unwrap_or(&json!({})))?, + created_at: record.get("created_at").and_then(|v| v.as_i64()).unwrap_or(0), + last_activity: record + .get("last_activity") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + expires_at: record.get("expires_at").and_then(|v| v.as_i64()).unwrap_or(0), + }, + ); + } + } + let mut kv = HashMap::new(); + if let Some(map) = root.get("kv").and_then(|v| v.as_object()) { + for (key, value) in map { + kv.insert(key.clone(), json_to_value(value)?); + } + } + Ok((sessions, kv)) +} + +fn map_to_json(map: &HashMap) -> Result { + let mut out = Map::new(); + for (key, value) in map { + out.insert(key.clone(), value_to_json(value)?); + } + Ok(serde_json::Value::Object(out)) +} + +fn json_to_map(value: &serde_json::Value) -> Result, String> { + let obj = value + .as_object() + .ok_or_else(|| "Session data must be a JSON object".to_string())?; + let mut map = HashMap::new(); + for (key, val) in obj { + map.insert(key.clone(), json_to_value(val)?); + } + Ok(map) +} + +fn value_to_json(value: &Value) -> Result { + match value { + Value::Number(n) => Ok(json!(n)), + Value::Text(s) => Ok(json!(s.as_ref())), + Value::Bool(b) => Ok(json!(b)), + Value::Nothing | Value::Null => Ok(serde_json::Value::Null), + Value::List(list) => { + let items: Result, _> = list.borrow().iter().map(value_to_json).collect(); + Ok(serde_json::Value::Array(items?)) + } + Value::Object(obj) => map_to_json(&obj.borrow()), + other => Err(format!( + "Session values must be text, numbers, yes/no, lists, maps, or nothing. Cannot store {}.", + other.type_name() + )), + } +} + +fn json_to_value(value: &serde_json::Value) -> Result { + match value { + serde_json::Value::Null => Ok(Value::Nothing), + serde_json::Value::Bool(b) => Ok(Value::Bool(*b)), + serde_json::Value::Number(n) => n + .as_f64() + .map(Value::Number) + .ok_or_else(|| "Session number is out of range".to_string()), + serde_json::Value::String(s) => Ok(Value::Text(Arc::from(s.as_str()))), + serde_json::Value::Array(items) => { + let values: Result, _> = items.iter().map(json_to_value).collect(); + Ok(Value::List(std::rc::Rc::new(std::cell::RefCell::new( + values?, + )))) + } + serde_json::Value::Object(obj) => { + let mut map = HashMap::new(); + for (key, val) in obj { + map.insert(key.clone(), json_to_value(val)?); + } + Ok(Value::Object(std::rc::Rc::new(std::cell::RefCell::new(map)))) + } + } +} diff --git a/tests/session_store_test.rs b/tests/session_store_test.rs index be7f5e06..f8214a5f 100644 --- a/tests/session_store_test.rs +++ b/tests/session_store_test.rs @@ -187,28 +187,31 @@ async fn concurrent_updates_do_not_corrupt_store() { let manager = Arc::new(SessionManager::new(memory_config()).await.unwrap()); let a = manager.create().await.unwrap(); let b = manager.create().await.unwrap(); - let mgr_a = Arc::clone(&manager); - let mgr_b = Arc::clone(&manager); let id_a = a.id.clone(); let id_b = b.id.clone(); - let left = tokio::spawn(async move { - for i in 0..50 { - mgr_a - .set_value(&id_a, "n", Value::Number(i as f64)) - .await - .unwrap(); + let left = { + let manager = Arc::clone(&manager); + async move { + for i in 0..50 { + manager + .set_value(&id_a, "n", Value::Number(i as f64)) + .await + .unwrap(); + } } - }); - let right = tokio::spawn(async move { - for i in 0..50 { - mgr_b - .set_value(&id_b, "n", Value::Number(i as f64)) - .await - .unwrap(); + }; + let right = { + let manager = Arc::clone(&manager); + async move { + for i in 0..50 { + manager + .set_value(&id_b, "n", Value::Number(i as f64)) + .await + .unwrap(); + } } - }); - left.await.unwrap(); - right.await.unwrap(); + }; + tokio::join!(left, right); assert!(manager.get(&a.id).await.unwrap().is_some()); assert!(manager.get(&b.id).await.unwrap().is_some()); } From 6c1b09accd6372a828748687cf299aadea5a7587 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 15:39:58 +0000 Subject: [PATCH 05/10] fix: treat session operands as used by the analyzer Session statements and expressions reference variables (the session object, server, keys). Mark those uses so programs are not warned as unused after a real set/destroy/respond. Co-authored-by: logbie --- src/analyzer/static_analyzer.rs | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/analyzer/static_analyzer.rs b/src/analyzer/static_analyzer.rs index 2adacfde..25d40dfb 100644 --- a/src/analyzer/static_analyzer.rs +++ b/src/analyzer/static_analyzer.rs @@ -1320,6 +1320,7 @@ impl Analyzer { status, content_type, headers, + set_session, .. } => { self.mark_used_in_expression(request, usages); @@ -1333,6 +1334,43 @@ impl Analyzer { if let Some(headers) = headers { self.mark_used_in_expression(headers, usages); } + if let Some(session) = set_session { + self.mark_used_in_expression(session, usages); + } + } + Statement::ConfigureSessionsStatement { + server, + timeout, + storage, + .. + } => { + self.mark_used_in_expression(server, usages); + self.mark_used_in_expression(timeout, usages); + self.mark_used_in_expression(storage, usages); + } + Statement::EnableCsrfProtectionStatement { server, .. } + | Statement::EnableSecureCookiesStatement { server, .. } => { + self.mark_used_in_expression(server, usages); + } + Statement::SetSessionValueStatement { + key, + value, + session, + .. + } => { + self.mark_used_in_expression(key, usages); + self.mark_used_in_expression(value, usages); + self.mark_used_in_expression(session, usages); + } + Statement::DestroySessionStatement { session, .. } => { + self.mark_used_in_expression(session, usages); + } + Statement::StoreSessionDataStatement { key, data, .. } => { + self.mark_used_in_expression(key, usages); + self.mark_used_in_expression(data, usages); + } + Statement::DeleteSessionDataStatement { key, .. } => { + self.mark_used_in_expression(key, usages); } Statement::ListenStatement { port, .. } => { self.mark_used_in_expression(port, usages); @@ -1578,6 +1616,23 @@ impl Analyzer { Expression::AwaitExpression { expression, .. } => { self.mark_used_in_expression(expression, usages); } + Expression::CreateSession { request, .. } | Expression::GetSession { request, .. } => { + self.mark_used_in_expression(request, usages); + } + Expression::GetSessionValue { key, session, .. } => { + self.mark_used_in_expression(key, usages); + self.mark_used_in_expression(session, usages); + } + Expression::GenerateCsrfTokenForSession { session, .. } => { + self.mark_used_in_expression(session, usages); + } + Expression::FindExpiredSessions { server, .. } + | Expression::GetSessionStatistics { server, .. } => { + self.mark_used_in_expression(server, usages); + } + Expression::LoadSessionData { key, .. } => { + self.mark_used_in_expression(key, usages); + } _ => {} } } From dd8fe970ea12367876b4a44720dd6cf169cbfe26 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 15:39:58 +0000 Subject: [PATCH 06/10] docs: document sessions and drive the #555 e2e server Rewrite the skipped session program into valid WFL, hook run_web_tests.sh/.ps1 with curl cookie flows, and document the language surface plus .wflcfg keys. Keyword count stays 181. Co-authored-by: logbie --- Docs/04-advanced-features/web-servers.md | 88 ++++- Docs/reference/configuration-reference.md | 114 +++++- Docs/reference/keyword-reference.md | 4 +- Docs/reference/reserved-keywords.md | 4 + .../2026/2026-09-03-sqlite-user-sessions.md | 67 ++++ .../docs_examples/_meta/manifest.json | 17 + .../web_servers/session_login.wfl | 23 ++ TestPrograms/web_server_session_test.wfl | 354 +++++------------- scripts/run_web_tests.ps1 | 132 +++++++ scripts/run_web_tests.sh | 128 +++++++ 10 files changed, 666 insertions(+), 265 deletions(-) create mode 100644 History/dev-diary/2026/2026-09-03-sqlite-user-sessions.md create mode 100644 TestPrograms/docs_examples/web_servers/session_login.wfl diff --git a/Docs/04-advanced-features/web-servers.md b/Docs/04-advanced-features/web-servers.md index 231ce23c..0f8d89cd 100644 --- a/Docs/04-advanced-features/web-servers.md +++ b/Docs/04-advanced-features/web-servers.md @@ -1230,7 +1230,93 @@ end check - **Bounded request body (chunked-safe):** The request-body limit (`web_server_max_body_size`) is enforced *while the body streams in*, so a chunked upload with no `Content-Length` is bounded too — an oversized body is refused with `413 Payload Too Large` without being fully buffered. - **Global in-flight cap + request deadline:** The accepted-request cap is shared across every `listen` server via one budget, and one deadline (`web_server_response_timeout_seconds`, default 300s) is set at admission and covers the whole accepted-request lifetime. A body that is not fully received in time is shed with `408 Request Timeout` (so a slow "trickle" upload under the size cap cannot pin a slot), and a handler that does not answer in time is shed with `504 Gateway Timeout`. A shed or abandoned request is skipped and its bookkeeping pruned rather than run as zombie work. - **No middleware system** (yet) - Implement manually -- **No built-in session management** - Implement yourself +- **No automatic CSRF rejection** - `enable csrf protection` records the flag; your handler still compares `header "X-CSRF-Token"` to `get session value "csrf_token"`. Automatic rejection would change every handler without a test that requires it. + +## User sessions + +`listen … with sessions enabled` starts a server with a built-in session store. +`wait for request` does **not** create a session automatically — you call +`create session` / `get session` explicitly. `respond … and set session` appends +a `Set-Cookie` header; `respond … and clear session` expires it. + +```wfl +listen on port 8080 as web_server with sessions enabled +configure sessions on web_server with timeout 1800000 and storage "memory" +enable csrf protection on web_server + +main loop: + wait for request comes in on web_server as req + store sess as get session from req + check if sess is nothing: + store sess as create session for req + set session value "user_id" to "guest" in sess + store csrf as generate csrf token for sess + respond to req with "ok" and set session sess + otherwise: + store user_id as get session value "user_id" from sess + respond to req with user_id + end check +end loop +``` + +Session objects expose `id` (and `created_at` / `last_activity`) through +`id of sess`. User data is **not** dumped onto that object — use +`get session value` / `set session value` so a key named `id` cannot collide +with the session identifier. + +### Storage backends + +`configure sessions … and storage` picks the backend (`memory`, `file`, or +`database`). `database` is SQLite via the in-tree sqlx dependency, not +Postgres or MySQL. Defaults come from `.wflcfg` (`session_storage`, +`session_timeout_ms`, cookie flags); statement-level `configure` / `enable` +override them for that server. See [Configuration Reference](../reference/configuration-reference.md#sessions). + +- **memory** — process-local map. Default, test-safe. +- **file** — one JSON file (`session_file_path`), written with a temp file + rename. +- **database** — SQLite file (`session_db_path`) with `wfl_sessions` and `wfl_session_kv`. + +Values must be JSON-safe (text, number, bool, list, object, nothing). Functions, +natives, and binaries produce an actionable error. `create session` fails cleanly +when the store is full (`session_max_sessions`). + +Concurrent handlers may touch the same store: last write wins per session id. +Two handlers on **different** sessions do not block each other at the language +level; they still interleave on one thread under `main loop concurrently:` +(cooperative concurrency, not parallel cores). + +### Cookies and CSRF + +The default cookie name is `wfl_sid`, with `Path=/`, `HttpOnly`, `SameSite=Lax`, +and `Max-Age` from the timeout. `enable secure cookies` (or +`session_cookie_secure = true`) adds `Secure`. `respond … and set session` +keeps any `and headers` you already set. + +`generate csrf token for session` returns a new hex token and stores it on that +session as `csrf_token`. Check it yourself: + +```wfl +store provided as header "X-CSRF-Token" of req +store expected as get session value "csrf_token" from sess +check if provided is equal to expected: + respond to req with "ok" +otherwise: + respond to req with "CSRF token invalid" and status 403 +end check +``` + +### Expiry, statistics, and raw storage + +`find expired sessions on web_server` returns the expired records so you can +`destroy` them. `get session statistics from web_server` is a map with +`active_sessions`, `total_created`, `expired_count`, and `storage_type`. +Destroyed or missing cookies make `get session` return `nothing`; `set` after +destroy is an error. + +The storage KV API is a separate key/value map on the same backend +(`store session_data to storage …`, `load session data from storage …`, +`delete session data from storage …`) — useful for one-off blobs that are not +tied to a cookie. All of these ceilings, together with the request timeout and body-size limits, are part of one shared [execution budget](../reference/configuration-reference.md#execution-budget-resource-limits). diff --git a/Docs/reference/configuration-reference.md b/Docs/reference/configuration-reference.md index 7bc0fe55..f7b5bf45 100644 --- a/Docs/reference/configuration-reference.md +++ b/Docs/reference/configuration-reference.md @@ -220,6 +220,23 @@ All keys currently loaded from config files, with defaults. | `web_socket_max_message_size` | integer ≥ 1 | `1048576` (1 MiB) | Max size of a single WebSocket text message (bytes); larger frames are dropped | | `web_socket_max_queued_bytes` | integer ≥ 1 | `16777216` (16 MiB) | Global ceiling on queued WebSocket payload bytes across all connections | +### Sessions + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `session_timeout_ms` | integer | `1800000` | Idle timeout in milliseconds (30 minutes) | +| `session_storage` | `memory` / `file` / `database` | `memory` | Backend for `listen … with sessions enabled` | +| `session_db_path` | string | `wfl_sessions.db` | SQLite file when storage is `database` | +| `session_file_path` | string | `wfl_sessions.json` | JSON file when storage is `file` | +| `session_cookie_name` | string | `wfl_sid` | Session cookie name | +| `session_cookie_secure` | bool | `false` | Add `Secure` to `Set-Cookie` | +| `session_cookie_samesite` | `Lax` / `Strict` / `None` | `Lax` | `SameSite` attribute | +| `session_cookie_httponly` | bool | `true` | Add `HttpOnly` to `Set-Cookie` | +| `session_csrf_enabled` | bool | `false` | Default for `enable csrf protection` | +| `session_max_sessions` | integer ≥ 1 | `10000` | DoS ceiling; `create session` fails when full | + +No session secret belongs in `.wflcfg` (config files must not hold app secrets). Session IDs and CSRF tokens come from the OS CSPRNG. + ### Execution budget keys (summary) A single [`ExecutionBudget`](#execution-budget-resource-limits) governs every @@ -613,6 +630,100 @@ Global ceiling in bytes on all WebSocket payloads queued across every connection - **Default:** `16777216` (16 MiB) - **Example:** `web_socket_max_queued_bytes = 8388608` +### Sessions + +Defaults for `listen … with sessions enabled`. Statement-level `configure sessions` +and `enable csrf protection` / `enable secure cookies` override these for that +server. Timeouts are **milliseconds** so they match `configure sessions … with +timeout 1800000`. See [User sessions](../04-advanced-features/web-servers.md#user-sessions). + +#### `session_timeout_ms` + +Idle lifetime of a session in milliseconds. `get session` of an expired id +returns `nothing`. Cookie `Max-Age` is this value in seconds (`timeout_ms / 1000`). + +- **Type:** Integer (milliseconds) +- **Default:** `1800000` (30 minutes) +- **Example:** `session_timeout_ms = 600000` # 10 minutes + +#### `session_storage` + +Backend used when the program does not `configure sessions … and storage`. +`memory` is test-safe (nothing written to disk). `file` is one JSON file. +`database` is SQLite (not Postgres or MySQL). + +- **Type:** `memory` / `file` / `database` +- **Default:** `memory` +- **Example:** `session_storage = database` + +#### `session_db_path` + +SQLite file created when `session_storage = database`. + +- **Type:** File path string +- **Default:** `wfl_sessions.db` +- **Example:** `session_db_path = /var/lib/wfl/sessions.db` + +#### `session_file_path` + +JSON file used when `session_storage = file`. Writes are atomic (temp file + rename). + +- **Type:** File path string +- **Default:** `wfl_sessions.json` +- **Example:** `session_file_path = /var/lib/wfl/sessions.json` + +#### `session_cookie_name` + +Name of the session cookie (`Set-Cookie` / `Cookie`). + +- **Type:** String +- **Default:** `wfl_sid` +- **Example:** `session_cookie_name = sid` + +#### `session_cookie_secure` + +When `true`, `Set-Cookie` includes `Secure`. Also set by `enable secure cookies`. +Leave `false` for plain HTTP (the cookie will not come back on HTTP if `Secure` +is set). + +- **Type:** Boolean +- **Default:** `false` +- **Example:** `session_cookie_secure = true` + +#### `session_cookie_samesite` + +`SameSite` attribute on the session cookie. + +- **Type:** `Lax` / `Strict` / `None` +- **Default:** `Lax` +- **Example:** `session_cookie_samesite = Strict` + +#### `session_cookie_httponly` + +When `true`, `Set-Cookie` includes `HttpOnly` so JavaScript cannot read the id. + +- **Type:** Boolean +- **Default:** `true` +- **Example:** `session_cookie_httponly = true` + +#### `session_csrf_enabled` + +Default CSRF flag when the program omits `enable csrf protection`. v1 records +the flag only; handlers still compare tokens themselves. + +- **Type:** Boolean +- **Default:** `false` +- **Example:** `session_csrf_enabled = true` + +#### `session_max_sessions` + +Maximum stored sessions. `create session` fails with an actionable error when +the store is full. + +- **Type:** Integer (at least 1) +- **Default:** `10000` +- **Example:** `session_max_sessions = 1000` + ### Execution budget (resource limits) WFL enforces every resource ceiling through a single shared **execution budget** @@ -738,8 +849,9 @@ Application settings (business ports, feature flags) belong in data files your p | HTTPS defaults without hardcoding paths | `web_server_tls_cert_file` / `web_server_tls_key_file` | | Large uploads | `web_server_max_body_size` | | Bound request backlog under load | `web_server_request_queue_bound` | +| Session timeout / store / cookie | `session_timeout_ms`, `session_storage`, `session_cookie_*` | -TLS intent always lives in the program (`secured`); config only supplies default file paths. +TLS intent always lives in the program (`secured`); config only supplies default file paths. Session `configure` / `enable` statements override `.wflcfg` for that server. ### Shell / subprocesses diff --git a/Docs/reference/keyword-reference.md b/Docs/reference/keyword-reference.md index 12a91ed4..87ee665a 100644 --- a/Docs/reference/keyword-reference.md +++ b/Docs/reference/keyword-reference.md @@ -369,8 +369,8 @@ connect to database at "sqlite://app.db" as db - 24 contextual keywords CAN be used as variables in certain contexts - 5 appear contextual but are actually always reserved -### "What about `secured`, `certificate`, `key`, `redirecting`, `content_type`, `transaction`?" → Not keywords -These words are recognized purely by position — inside `listen` / `respond` statements, or in `in transaction on db:` / `end transaction` — and are **never reserved**. Use them as variable names freely. See [Marker Words That Are Not Keywords](reserved-keywords.md#marker-words-that-are-not-keywords-at-all). +### "What about `secured`, `certificate`, `key`, `redirecting`, `content_type`, `transaction`, `session`?" → Not keywords +These words are recognized purely by position — inside `listen` / `respond` statements, session phrases, or in `in transaction on db:` / `end transaction` — and are **never reserved**. Use them as variable names freely. See [Marker Words That Are Not Keywords](reserved-keywords.md#marker-words-that-are-not-keywords-at-all). --- diff --git a/Docs/reference/reserved-keywords.md b/Docs/reference/reserved-keywords.md index f7148f90..0eee8379 100644 --- a/Docs/reference/reserved-keywords.md +++ b/Docs/reference/reserved-keywords.md @@ -101,6 +101,9 @@ A few words have special meaning in exactly one statement position but are **not - `redirecting` - redirect marker in `listen on port 8080 redirecting to port 8443 as server` - `content_type` - response content type marker in `respond to req with ... and content_type "text/html"` - `transaction` - transaction block marker in `in transaction on db:` and `end transaction` +- `sessions` / `session` - session markers in `listen … with sessions enabled`, `configure sessions`, `create session`, `get session`, `set session value`, `destroy session`, `respond … and set session` / `and clear session` +- `storage` - session storage marker in `configure sessions … and storage "memory"` and `store session_data to storage …` +- `csrf` / `protection` - CSRF markers in `enable csrf protection` and `generate csrf token for …` ```wfl // All perfectly valid — these words are not reserved: @@ -108,6 +111,7 @@ store key as "secret_key_456" store certificate as "diploma" store secured as yes store transaction as "TX-1094" +store session as "active" ``` `transaction` is recognized in exactly two positions: directly after a leading diff --git a/History/dev-diary/2026/2026-09-03-sqlite-user-sessions.md b/History/dev-diary/2026/2026-09-03-sqlite-user-sessions.md new file mode 100644 index 00000000..e7576eb2 --- /dev/null +++ b/History/dev-diary/2026/2026-09-03-sqlite-user-sessions.md @@ -0,0 +1,67 @@ +# Dev Diary — 2026-09-03 — SQLite-backed user sessions (issue #555) + +## Summary + +WFL programs can now keep HTTP sessions with the natural-language surface +from issue #555. `listen … with sessions enabled` builds a runtime +`SessionManager`; `configure` / `enable` override `.wflcfg` defaults; handlers +create, read, update, and destroy sessions and attach `Set-Cookie` on +`respond`. Three stores ship: in-memory, a JSON file, and SQLite (`database`). + +```wfl +listen on port 8080 as web_server with sessions enabled +configure sessions on web_server with timeout 1800000 and storage "database" +enable csrf protection on web_server + +main loop: + wait for request comes in on web_server as req + store sess as get session from req + check if sess is nothing: + store sess as create session for req + set session value "user_id" to "guest" in sess + respond to req with "ok" and set session sess + otherwise: + store user_id as get session value "user_id" from sess + respond to req with user_id + end check +end loop +``` + +## Design decisions + +- **Language statements, not a stdlib native.** Natives are + `fn(Vec) -> Result` with no interpreter, request, + or sqlx access — they cannot persist sessions or set cookies. +- **No new lexer keywords.** `session` / `sessions` are positional markers, so + existing `store session as "active"` programs keep working. Keyword count + stays 181. +- **User data is not dumped onto the session object.** `id of sess` uses the + existing `property of object` form; values live behind + `get` / `set session value` so they cannot collide with `id`. +- **Timeouts are milliseconds.** `configure sessions … with timeout 1800000` + is 30 minutes; `session_timeout_ms` matches that unit. +- **CSRF is explicit.** `generate csrf token for session` stores a hex token + on that session. `enable csrf protection` records the flag. v1 does not + auto-reject requests — that would change handler semantics without a test + that requires it. The #555 e2e program checks `X-CSRF-Token` in user code. +- **No session secret in `.wflcfg`.** IDs and tokens come from the OS CSPRNG + (same source as `secure_random_bytes`). + +## Storage + +SQLite uses the in-tree sqlx dependency (`sqlite://` + `create_if_missing`) +with `wfl_sessions` and `wfl_session_kv`. File storage writes a temp file and +renames. Memory and file share one `tokio::sync::Mutex`; the database backend +uses the sqlx pool. Last write wins per session id. `create session` fails +cleanly when `session_max_sessions` is reached. + +## Testing + +Red commit `5a1a49e` added parser and store tests that failed for the intended +reasons (unknown AST variants / missing `wfl::interpreter::sessions`). Green +commits implement the surface. `scripts/run_web_tests.sh` drives +`TestPrograms/web_server_session_test.wfl` with `curl -c/-b` (login cookie, +profile, CSRF, logout clear, stats, storage KV). + +Issue #555 stays open: WebSockets already shipped; keyword-reference web +examples are a separate leftover. diff --git a/TestPrograms/docs_examples/_meta/manifest.json b/TestPrograms/docs_examples/_meta/manifest.json index 06dadad0..f518e2e4 100644 --- a/TestPrograms/docs_examples/_meta/manifest.json +++ b/TestPrograms/docs_examples/_meta/manifest.json @@ -506,6 +506,23 @@ ], "description": "Concurrent request handling with main loop concurrently." }, + "docs_examples/web_servers/session_login.wfl": { + "doc_section": "Docs/04-advanced-features/web-servers.md#user-sessions", + "type": "snippet", + "validate_layers": [ + 1, + 2, + 3, + 4 + ], + "skip_execution": true, + "tags": [ + "web-server", + "sessions", + "cookies" + ], + "description": "Session-enabled listen, create/get session, set value, CSRF token, and Set-Cookie on respond." + }, "docs_examples/containers/basic_container_01.wfl": { "doc_section": "Docs/04-advanced-features/containers-oop.md#basic-container", "type": "executable", diff --git a/TestPrograms/docs_examples/web_servers/session_login.wfl b/TestPrograms/docs_examples/web_servers/session_login.wfl new file mode 100644 index 00000000..111bc998 --- /dev/null +++ b/TestPrograms/docs_examples/web_servers/session_login.wfl @@ -0,0 +1,23 @@ +// CI-SKIP: starts a session-enabled server; needs an HTTP client (layers 1-4) +// Built-in user sessions: create, store values, set the session cookie. +// +// Validated for syntax/analysis/lint only (layers 1-4): running it needs a +// live client, so execution is skipped. + +listen on port 8080 as web_server with sessions enabled +configure sessions on web_server with timeout 1800000 and storage "memory" +enable csrf protection on web_server + +main loop: + wait for request comes in on web_server as req + store sess as get session from req + check if sess is nothing: + store new_sess as create session for req + set session value "user_id" to "guest" in new_sess + store csrf as generate csrf token for new_sess + respond to req with csrf and set session new_sess + otherwise: + store user_id as get session value "user_id" from sess + respond to req with user_id + end check +end loop diff --git a/TestPrograms/web_server_session_test.wfl b/TestPrograms/web_server_session_test.wfl index 21ebf216..b476311c 100644 --- a/TestPrograms/web_server_session_test.wfl +++ b/TestPrograms/web_server_session_test.wfl @@ -1,273 +1,105 @@ -// CI-SKIP: exercises unimplemented session/CSRF/cookie features (tracked in issue #555) -// TDD Test: Web Server Session Management -// This test MUST FAIL initially because session management is not implemented -// Following TDD approach - write failing test first +// CI-SKIP: starts a web server; HTTP client drives it (run_web_tests) +// Session management E2E for issue #555, driven by scripts/run_web_tests.sh. -display "=== Web Server Session Management Test ===" -display "This test validates session handling, storage, security, and lifecycle management" -display "" +display "=== Session Web Server Test ===" +listen on port 8097 as session_server with sessions enabled +configure sessions on session_server with timeout 1800000 and storage "memory" +enable csrf protection on session_server +display "Session server ready on port 8097" -// Configuration -store session_port as 8095 -store session_timeout as 1800000 // 30 minutes in milliseconds -store session_storage as "memory" // Options: memory, file, database -store csrf_protection as true -store secure_cookies as true +main loop: + wait for request comes in on session_server as req + store request_path as req["path"] -display "Starting session management test server..." -display "Port: " with session_port -display "Session timeout: " with session_timeout with "ms" -display "Storage backend: " with session_storage -display "CSRF protection: " with csrf_protection -display "Secure cookies: " with secure_cookies -display "" - -try: - // Test 1: Session-Enabled Server Setup - display "Test 1: Session-Enabled Server Setup" - - try: - // This should fail because session management is not implemented - listen on port session_port as session_server with sessions enabled - display "✓ Session-enabled server started" - - // Configure session settings - configure sessions on session_server with timeout session_timeout and storage session_storage - enable csrf protection on session_server - enable secure cookies on session_server - display "✓ Session configuration applied" - - catch: - display "✗ EXPECTED FAILURE: Session-enabled server setup not implemented" - display "Error: " with error_message - end try - - display "" - display "Test 2: Session Creation and Storage" - - store active_sessions as create list - store session_counter as 0 - - main loop: - check if session_counter is greater than or equal to 5: - break - end check - - try: - wait for request comes in on session_server as session_request - add 1 to session_counter - - store request_method as method of session_request - store request_path as path of session_request - store client_ip as client_ip of session_request - - display "📥 Request " with session_counter with ": " with request_method with " " with request_path with " from " with client_ip - - // Test session handling based on path - check if request_path is equal to "/login": - // Test session creation - display "Testing session creation..." - - // Create new session - store new_session as create session for session_request - store session_id as id of new_session - - // Store user data in session - set session value "user_id" to "user123" in new_session - set session value "username" to "testuser" in new_session - set session value "login_time" to current time in milliseconds in new_session - set session value "permissions" to create list with "read", "write" in new_session - - display "✓ Session created: " with session_id - add new_session to active_sessions - - // Generate CSRF token - store csrf_token as generate csrf token for new_session - set session value "csrf_token" to csrf_token in new_session - display "✓ CSRF token generated: " with csrf_token - - // Send login response with session cookie - store login_response as "{\"status\": \"success\", \"session_id\": \"" with session_id with "\", \"csrf_token\": \"" with csrf_token with "\"}" - respond to session_request with login_response and content_type "application/json" and set session new_session - display "✓ Login response sent with session cookie" - - otherwise check if request_path is equal to "/profile": - // Test session retrieval - display "Testing session retrieval..." - - store current_session as get session from session_request - - check if current_session is not null: - store session_id as id of current_session - store user_id as get session value "user_id" from current_session - store username as get session value "username" from current_session - store login_time as get session value "login_time" from current_session - - display "✓ Session retrieved: " with session_id - display " User ID: " with user_id - display " Username: " with username - display " Login time: " with login_time - - // Update session activity - set session value "last_activity" to current time in milliseconds in current_session - - store profile_response as "{\"user_id\": \"" with user_id with "\", \"username\": \"" with username with "\", \"session_valid\": true}" - respond to session_request with profile_response and content_type "application/json" - display "✓ Profile response sent" - + check if request_path is equal to "/": + respond to req with "Session server ready" + otherwise: + check if request_path is equal to "/login": + store sess as create session for req + store session_id as sess["id"] + set session value "user_id" to "user123" in sess + set session value "username" to "testuser" in sess + set session value "login_time" to current time in milliseconds in sess + set session value "permissions" to ["read" and "write"] in sess + store csrf as generate csrf token for sess + store login_body as session_id with " " with csrf + respond to req with login_body and set session sess + otherwise: + check if request_path is equal to "/profile": + store sess as get session from req + check if sess is nothing: + respond to req with "No valid session" and status 401 otherwise: - display "✗ No valid session found" - respond to session_request with "{\"error\": \"No valid session\"}" and status 401 and content_type "application/json" + store user_id as get session value "user_id" from sess + respond to req with user_id end check - - otherwise check if request_path is equal to "/secure": - // Test CSRF protection - display "Testing CSRF protection..." - - store current_session as get session from session_request - - check if current_session is not null: - store provided_csrf as header "X-CSRF-Token" of session_request - store session_csrf as get session value "csrf_token" from current_session - - check if provided_csrf is equal to session_csrf: - display "✓ CSRF token validated" - respond to session_request with "{\"message\": \"Access granted to secure resource\"}" and content_type "application/json" + otherwise: + check if request_path is equal to "/secure": + store sess as get session from req + check if sess is nothing: + respond to req with "No session" and status 401 otherwise: - display "✗ CSRF token validation failed" - respond to session_request with "{\"error\": \"CSRF token invalid\"}" and status 403 and content_type "application/json" + store provided as header "X-CSRF-Token" of req + store expected as get session value "csrf_token" from sess + check if provided is equal to expected: + respond to req with "Access granted" + otherwise: + respond to req with "CSRF token invalid" and status 403 + end check end check otherwise: - display "✗ No session for CSRF validation" - respond to session_request with "{\"error\": \"No session\"}" and status 401 and content_type "application/json" - end check - - otherwise check if request_path is equal to "/logout": - // Test session destruction - display "Testing session destruction..." - - store current_session as get session from session_request - - check if current_session is not null: - store session_id as id of current_session - - // Remove session data - destroy session current_session - remove current_session from active_sessions - - display "✓ Session destroyed: " with session_id - respond to session_request with "{\"status\": \"logged_out\"}" and content_type "application/json" and clear session - - otherwise: - display "✗ No session to destroy" - respond to session_request with "{\"error\": \"No active session\"}" and status 400 and content_type "application/json" - end check - - otherwise check if request_path is equal to "/admin": - // Test session-based authorization - display "Testing session-based authorization..." - - store current_session as get session from session_request - - check if current_session is not null: - store permissions as get session value "permissions" from current_session - - check if contains of permissions and "admin": - respond to session_request with "{\"message\": \"Admin access granted\"}" and content_type "application/json" - display "✓ Admin access granted" + check if request_path is equal to "/logout": + store sess as get session from req + check if sess is nothing: + respond to req with "No active session" and status 400 + otherwise: + destroy session sess + respond to req with "logged_out" and clear session + end check otherwise: - respond to session_request with "{\"error\": \"Insufficient permissions\"}" and status 403 and content_type "application/json" - display "✗ Admin access denied - insufficient permissions" + check if request_path is equal to "/admin": + store sess as get session from req + check if sess is nothing: + respond to req with "Authentication required" and status 401 + otherwise: + store perms as get session value "permissions" from sess + check if contains of perms and "admin": + respond to req with "Admin access granted" + otherwise: + respond to req with "Insufficient permissions" and status 403 + end check + end check + otherwise: + check if request_path is equal to "/stats": + store expired as find expired sessions on session_server + store stats as get session statistics from session_server + store kind as stats["storage_type"] + store active as stats["active_sessions"] + store created as stats["total_created"] + store expired_count as length of expired + store stats_body as kind with " " with active with " " with created + display expired_count + respond to req with stats_body + otherwise: + check if request_path is equal to "/storage": + store session_data to storage with key "k" and data "test_value" + store retrieved as load session data from storage with key "k" + delete session data from storage with key "k" + store leftover as load session data from storage with key "k" + check if leftover is nothing: + store storage_body as "stored " with retrieved + respond to req with storage_body + otherwise: + respond to req with "delete failed" and status 500 + end check + otherwise: + respond to req with "not found" and status 404 + end check + end check + end check end check - otherwise: - respond to session_request with "{\"error\": \"Authentication required\"}" and status 401 and content_type "application/json" - display "✗ Admin access denied - no session" end check - - otherwise: - // Default response - respond to session_request with "Session test server - endpoints: /login, /profile, /secure, /logout, /admin" and content_type "text/plain" - display "✓ Default response sent" end check - - catch: - display "✗ EXPECTED FAILURE: Session management functionality not implemented" - display "Error: " with error_message - break - end try - end loop - - display "" - display "Test 3: Session Expiration and Cleanup" - - try: - display "Testing session expiration and cleanup..." - - // Check for expired sessions - store expired_sessions as find expired sessions on session_server - display "Found " with length of expired_sessions with " expired sessions" - - // Clean up expired sessions - count through expired_sessions as expired_session: - store expired_id as id of expired_session - destroy session expired_session - display "✓ Cleaned up expired session: " with expired_id - end count - - // Get session statistics - store session_stats as get session statistics from session_server - display "Session statistics:" - display "- Active sessions: " with active_sessions of session_stats - display "- Total sessions created: " with total_created of session_stats - display "- Sessions expired: " with expired_count of session_stats - display "- Storage backend: " with storage_type of session_stats - - catch: - display "✗ EXPECTED FAILURE: Session expiration and cleanup not implemented" - display "Error: " with error_message - end try - - display "" - display "Test 4: Session Storage Backend Testing" - - try: - display "Testing session storage backend operations..." - - // Test direct storage operations - store test_session_data as create object with "test_key" as "test_value" and "timestamp" as current time in milliseconds - - // Store session data - store session_data to storage with key "test_session_123" and data test_session_data - display "✓ Session data stored to backend" - - // Retrieve session data - store retrieved_data as load session data from storage with key "test_session_123" - display "✓ Session data retrieved from backend" - display "Retrieved data: " with retrieved_data - - // Delete session data - delete session data from storage with key "test_session_123" - display "✓ Session data deleted from backend" - - catch: - display "✗ EXPECTED FAILURE: Session storage backend not implemented" - display "Error: " with error_message - end try - -catch: - display "✗ EXPECTED FAILURE: Session management server functionality failed" - display "Error: " with error_message -end try - -display "" -display "=== Web Server Session Management Test Complete ===" -display "Expected result: This test should FAIL until session management is implemented" -display "Once implemented, this test should PASS and demonstrate:" -display "- Session-enabled server setup and configuration" -display "- Session creation, storage, and retrieval" -display "- CSRF token generation and validation" -display "- Session-based authentication and authorization" -display "- Session lifecycle management (creation, update, destruction)" -display "- Session expiration and automatic cleanup" -display "- Multiple storage backend support (memory, file, database)" -display "- Secure cookie handling and session security" + end check + end check +end loop diff --git a/scripts/run_web_tests.ps1 b/scripts/run_web_tests.ps1 index c4b549a7..c8f0d508 100644 --- a/scripts/run_web_tests.ps1 +++ b/scripts/run_web_tests.ps1 @@ -473,6 +473,138 @@ if (Test-Path "TestPrograms\web_server_tls.wfl") { } } +# Test 5: web_server_session_test.wfl (cookies, CSRF, storage KV) +if (Test-Path "TestPrograms\web_server_session_test.wfl") { + $totalTests++ + Write-Host "" + Write-Host "[INFO] Testing: web_server_session_test.wfl on port 8097" -ForegroundColor Blue + + $sessionOutLog = Join-Path ([System.IO.Path]::GetTempPath()) ("wfl-session-" + [System.IO.Path]::GetRandomFileName() + ".out.log") + $sessionErrLog = Join-Path ([System.IO.Path]::GetTempPath()) ("wfl-session-" + [System.IO.Path]::GetRandomFileName() + ".err.log") + $cookieJar = Join-Path ([System.IO.Path]::GetTempPath()) ("wfl-session-" + [System.IO.Path]::GetRandomFileName() + ".cookies") + $sessionProcess = Start-Process -FilePath $BinaryPath -ArgumentList "TestPrograms\web_server_session_test.wfl" -NoNewWindow -PassThru -RedirectStandardOutput $sessionOutLog -RedirectStandardError $sessionErrLog + $null = $sessionProcess.Handle + + try { + $sessionReady = $false + $deadline = [System.Diagnostics.Stopwatch]::StartNew() + while (-not $sessionReady -and $deadline.Elapsed.TotalSeconds -lt $Timeout) { + try { + $ready = Invoke-WebRequest -Uri "http://127.0.0.1:8097/" -TimeoutSec 2 -UseBasicParsing -ErrorAction Stop + if ($ready.Content -match "Session server ready") { $sessionReady = $true } + } catch { + Start-Sleep -Milliseconds 500 + } + } + + $sessionOk = $true + if (-not $sessionReady) { + Write-Host "[ERROR] TIMEOUT: Session server did not start within ${Timeout}s" -ForegroundColor Red + $sessionOk = $false + } else { + $curl = Get-Command curl.exe -ErrorAction SilentlyContinue + if (-not $curl) { + Write-Host "[ERROR] curl.exe is required to drive session cookie tests" -ForegroundColor Red + $sessionOk = $false + } else { + $login = & curl.exe -s --max-time 2 -D - -c $cookieJar "http://127.0.0.1:8097/login" + $loginText = ($login | Out-String) + if ($loginText -match "Set-Cookie: wfl_sid=" -and $loginText -match "HttpOnly") { + Write-Host "[SUCCESS] PASS: /login sets HttpOnly wfl_sid cookie" -ForegroundColor Green + } else { + Write-Host "[ERROR] FAIL: /login did not set HttpOnly wfl_sid: $loginText" -ForegroundColor Red + $sessionOk = $false + } + $loginBody = ($login | Select-Object -Last 1).ToString().Trim() + $parts = $loginBody.Split(" ", 2, [System.StringSplitOptions]::RemoveEmptyEntries) + $csrfToken = if ($parts.Count -ge 2) { $parts[1] } else { "" } + + $profile = & curl.exe -s --max-time 2 -b $cookieJar "http://127.0.0.1:8097/profile" + if ($profile -eq "user123") { + Write-Host "[SUCCESS] PASS: /profile returns user_id from cookie" -ForegroundColor Green + } else { + Write-Host "[ERROR] FAIL: /profile returned '$profile'" -ForegroundColor Red + $sessionOk = $false + } + + $anon = & curl.exe -s -o NUL -w "%{http_code}" --max-time 2 "http://127.0.0.1:8097/profile" + if ($anon -eq "401") { + Write-Host "[SUCCESS] PASS: /profile without cookie returns 401" -ForegroundColor Green + } else { + Write-Host "[ERROR] FAIL: anonymous /profile returned HTTP $anon" -ForegroundColor Red + $sessionOk = $false + } + + $csrfMiss = & curl.exe -s -o NUL -w "%{http_code}" --max-time 2 -b $cookieJar "http://127.0.0.1:8097/secure" + if ($csrfMiss -eq "403") { + Write-Host "[SUCCESS] PASS: /secure without CSRF header returns 403" -ForegroundColor Green + } else { + Write-Host "[ERROR] FAIL: /secure without CSRF returned HTTP $csrfMiss" -ForegroundColor Red + $sessionOk = $false + } + + $csrfOk = & curl.exe -s --max-time 2 -b $cookieJar -H "X-CSRF-Token: $csrfToken" "http://127.0.0.1:8097/secure" + if ($csrfOk -eq "Access granted") { + Write-Host "[SUCCESS] PASS: /secure accepts matching CSRF token" -ForegroundColor Green + } else { + Write-Host "[ERROR] FAIL: /secure with CSRF returned '$csrfOk'" -ForegroundColor Red + $sessionOk = $false + } + + $admin = & curl.exe -s -o NUL -w "%{http_code}" --max-time 2 -b $cookieJar "http://127.0.0.1:8097/admin" + if ($admin -eq "403") { + Write-Host "[SUCCESS] PASS: /admin denies missing admin permission" -ForegroundColor Green + } else { + Write-Host "[ERROR] FAIL: /admin returned HTTP $admin" -ForegroundColor Red + $sessionOk = $false + } + + $stats = & curl.exe -s --max-time 2 "http://127.0.0.1:8097/stats" + if ($stats -like "memory *") { + Write-Host "[SUCCESS] PASS: /stats reports memory backend ($stats)" -ForegroundColor Green + } else { + Write-Host "[ERROR] FAIL: /stats returned '$stats'" -ForegroundColor Red + $sessionOk = $false + } + + $storage = & curl.exe -s --max-time 2 "http://127.0.0.1:8097/storage" + if ($storage -eq "stored test_value") { + Write-Host "[SUCCESS] PASS: storage KV put/load/delete" -ForegroundColor Green + } else { + Write-Host "[ERROR] FAIL: /storage returned '$storage'" -ForegroundColor Red + $sessionOk = $false + } + + $logout = & curl.exe -s --max-time 2 -D - -b $cookieJar -c $cookieJar "http://127.0.0.1:8097/logout" + $logoutText = ($logout | Out-String) + if ($logoutText -match "logged_out" -and $logoutText -match "Max-Age=0") { + Write-Host "[SUCCESS] PASS: /logout clears session cookie" -ForegroundColor Green + } else { + Write-Host "[ERROR] FAIL: /logout did not clear cookie: $logoutText" -ForegroundColor Red + $sessionOk = $false + } + + $after = & curl.exe -s -o NUL -w "%{http_code}" --max-time 2 -b $cookieJar "http://127.0.0.1:8097/profile" + if ($after -eq "401") { + Write-Host "[SUCCESS] PASS: /profile after logout returns 401" -ForegroundColor Green + } else { + Write-Host "[ERROR] FAIL: /profile after logout returned HTTP $after" -ForegroundColor Red + $sessionOk = $false + } + } + } + + if ($sessionOk) { + $passedTests++ + } else { + Show-ServerLogs -OutLog $sessionOutLog -ErrLog $sessionErrLog -Process $sessionProcess + } + } finally { + Stop-ServerProcess -Process $sessionProcess + Remove-Item -Force $cookieJar, $sessionOutLog, $sessionErrLog -ErrorAction SilentlyContinue + } +} + # Summary Write-Host "" Write-Host "[INFO] ============================" -ForegroundColor Blue diff --git a/scripts/run_web_tests.sh b/scripts/run_web_tests.sh index d9d7615f..21876fe0 100755 --- a/scripts/run_web_tests.sh +++ b/scripts/run_web_tests.sh @@ -300,6 +300,134 @@ if [ -f "TestPrograms/web_server_tls.wfl" ]; then fi fi +# Test 5: web_server_session_test.wfl (cookies, CSRF, storage KV) +if [ -f "TestPrograms/web_server_session_test.wfl" ]; then + total_tests=$((total_tests + 1)) + echo "" + echo -e "${BLUE}[INFO]${NC} Testing: web_server_session_test.wfl on port 8097" + + session_log=$(mktemp) + "./$BINARY_PATH" "TestPrograms/web_server_session_test.wfl" > "$session_log" 2>&1 & + session_pid=$! + cookie_jar=$(mktemp) + + session_ok=true + session_ready=false + retries=0 + max_retries=$((TIMEOUT * 2)) + while [ "$session_ready" = false ] && [ $retries -lt $max_retries ]; do + sleep 0.5 + retries=$((retries + 1)) + if curl -s --max-time 2 "http://127.0.0.1:8097/" 2>/dev/null | grep -q "Session server ready"; then + session_ready=true + fi + done + + if [ "$session_ready" = false ]; then + echo -e "${RED}[ERROR]${NC} TIMEOUT: Session server did not start within ${TIMEOUT}s" + echo -e "${GRAY} server log:${NC}" + cat "$session_log" || true + session_ok=false + else + login_headers=$(mktemp) + login_body=$(curl -s --max-time 2 -D "$login_headers" -c "$cookie_jar" "http://127.0.0.1:8097/login") + session_id=$(echo "$login_body" | awk '{print $1}') + csrf_token=$(echo "$login_body" | awk '{print $2}') + if [[ "$login_body" == *" "* ]] && grep -qi "Set-Cookie: wfl_sid=" "$login_headers" && grep -qi "HttpOnly" "$login_headers"; then + echo -e "${GREEN}[SUCCESS]${NC} PASS: /login sets HttpOnly wfl_sid cookie" + else + echo -e "${RED}[ERROR]${NC} FAIL: /login body='$login_body' headers:" + cat "$login_headers" || true + session_ok=false + fi + + profile_body=$(curl -s --max-time 2 -b "$cookie_jar" "http://127.0.0.1:8097/profile") + if [ "$profile_body" = "user123" ]; then + echo -e "${GREEN}[SUCCESS]${NC} PASS: /profile returns user_id from cookie" + else + echo -e "${RED}[ERROR]${NC} FAIL: /profile returned '$profile_body' (expected user123)" + session_ok=false + fi + + anon_code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 "http://127.0.0.1:8097/profile") + if [ "$anon_code" = "401" ]; then + echo -e "${GREEN}[SUCCESS]${NC} PASS: /profile without cookie returns 401" + else + echo -e "${RED}[ERROR]${NC} FAIL: anonymous /profile returned HTTP $anon_code (expected 401)" + session_ok=false + fi + + csrf_miss=$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 -b "$cookie_jar" "http://127.0.0.1:8097/secure") + if [ "$csrf_miss" = "403" ]; then + echo -e "${GREEN}[SUCCESS]${NC} PASS: /secure without CSRF header returns 403" + else + echo -e "${RED}[ERROR]${NC} FAIL: /secure without CSRF returned HTTP $csrf_miss (expected 403)" + session_ok=false + fi + + csrf_ok=$(curl -s --max-time 2 -b "$cookie_jar" -H "X-CSRF-Token: $csrf_token" "http://127.0.0.1:8097/secure") + if [ "$csrf_ok" = "Access granted" ]; then + echo -e "${GREEN}[SUCCESS]${NC} PASS: /secure accepts matching CSRF token" + else + echo -e "${RED}[ERROR]${NC} FAIL: /secure with CSRF returned '$csrf_ok'" + session_ok=false + fi + + admin_code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 -b "$cookie_jar" "http://127.0.0.1:8097/admin") + if [ "$admin_code" = "403" ]; then + echo -e "${GREEN}[SUCCESS]${NC} PASS: /admin denies missing admin permission" + else + echo -e "${RED}[ERROR]${NC} FAIL: /admin returned HTTP $admin_code (expected 403)" + session_ok=false + fi + + stats_body=$(curl -s --max-time 2 "http://127.0.0.1:8097/stats") + if [[ "$stats_body" == memory\ * ]]; then + echo -e "${GREEN}[SUCCESS]${NC} PASS: /stats reports memory backend ($stats_body)" + else + echo -e "${RED}[ERROR]${NC} FAIL: /stats returned '$stats_body'" + session_ok=false + fi + + storage_body=$(curl -s --max-time 2 "http://127.0.0.1:8097/storage") + if [ "$storage_body" = "stored test_value" ]; then + echo -e "${GREEN}[SUCCESS]${NC} PASS: storage KV put/load/delete" + else + echo -e "${RED}[ERROR]${NC} FAIL: /storage returned '$storage_body'" + session_ok=false + fi + + logout_headers=$(mktemp) + logout_body=$(curl -s --max-time 2 -D "$logout_headers" -b "$cookie_jar" -c "$cookie_jar" "http://127.0.0.1:8097/logout") + if [ "$logout_body" = "logged_out" ] && grep -qi "Max-Age=0" "$logout_headers"; then + echo -e "${GREEN}[SUCCESS]${NC} PASS: /logout clears session cookie" + else + echo -e "${RED}[ERROR]${NC} FAIL: /logout body='$logout_body' headers:" + cat "$logout_headers" || true + session_ok=false + fi + + after_logout=$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 -b "$cookie_jar" "http://127.0.0.1:8097/profile") + if [ "$after_logout" = "401" ]; then + echo -e "${GREEN}[SUCCESS]${NC} PASS: /profile after logout returns 401" + else + echo -e "${RED}[ERROR]${NC} FAIL: /profile after logout returned HTTP $after_logout" + session_ok=false + fi + + rm -f "$login_headers" "$logout_headers" + fi + + if kill -0 $session_pid 2>/dev/null; then + kill $session_pid 2>/dev/null || true + fi + rm -f "$cookie_jar" "$session_log" + + if [ "$session_ok" = true ]; then + passed_tests=$((passed_tests + 1)) + fi +fi + # Summary echo "" echo -e "${BLUE}[INFO]${NC} ============================" From 1678a9cdff2e1667df66d75b5956a8fa4a1fee61 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 15:40:05 +0000 Subject: [PATCH 07/10] style: rustfmt session parser and interpreter changes Co-authored-by: logbie --- src/interpreter/mod.rs | 25 +++++++++---------- src/interpreter/sessions.rs | 33 ++++++++++++++----------- src/parser/expr/primary.rs | 9 ++++--- src/parser/mod.rs | 3 ++- src/parser/stmt/web.rs | 30 ++++++++++++---------- src/parser/tests.rs | 5 +--- tests/web_server_session_parser_test.rs | 5 ++-- 7 files changed, 56 insertions(+), 54 deletions(-) diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 4ffbf711..f7d2d8f5 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -5,7 +5,6 @@ pub mod command_sanitizer; pub mod control_flow; pub mod database; pub mod environment; -pub mod sessions; pub mod error; pub(crate) mod io_capture; #[cfg(test)] @@ -14,6 +13,7 @@ mod memory_tests; mod op_refactor_error_tests; #[cfg(test)] mod op_refactor_tests; +pub mod sessions; #[cfg(test)] mod tests; mod tls; @@ -13376,9 +13376,8 @@ impl Interpreter { }; let storage_val = self.evaluate_expression(storage, Rc::clone(&env)).await?; let storage_kind = match &storage_val { - Value::Text(s) => sessions::SessionStorageKind::parse(s).map_err(|e| { - RuntimeError::new(e, *line, *column) - })?, + Value::Text(s) => sessions::SessionStorageKind::parse(s) + .map_err(|e| RuntimeError::new(e, *line, *column))?, _ => { return Err(RuntimeError::new( "Session storage must be text: memory, file, or database".to_string(), @@ -13465,9 +13464,7 @@ impl Interpreter { line, column, } => { - let manager = self - .sole_or_named_session_manager(*line, *column) - .await?; + let manager = self.sole_or_named_session_manager(*line, *column).await?; let key_val = self.evaluate_expression(key, Rc::clone(&env)).await?; let data_val = self.evaluate_expression(data, Rc::clone(&env)).await?; let key_text = match &key_val { @@ -13487,9 +13484,7 @@ impl Interpreter { Ok((Value::Null, ControlFlow::None)) } Statement::DeleteSessionDataStatement { key, line, column } => { - let manager = self - .sole_or_named_session_manager(*line, *column) - .await?; + let manager = self.sole_or_named_session_manager(*line, *column).await?; let key_val = self.evaluate_expression(key, Rc::clone(&env)).await?; let key_text = match &key_val { Value::Text(s) => s.to_string(), @@ -15826,7 +15821,11 @@ impl Interpreter { .await .map_err(|e| RuntimeError::new(e, *line, *column))? { - Some(record) => Ok(record.data.get(&key_text).cloned().unwrap_or(Value::Nothing)), + Some(record) => Ok(record + .data + .get(&key_text) + .cloned() + .unwrap_or(Value::Nothing)), None => Ok(Value::Nothing), } } @@ -15879,9 +15878,7 @@ impl Interpreter { Ok(sessions::SessionManager::stats_object(&stats)) } Expression::LoadSessionData { key, line, column } => { - let manager = self - .sole_or_named_session_manager(*line, *column) - .await?; + let manager = self.sole_or_named_session_manager(*line, *column).await?; let key_val = self.evaluate_expression(key, Rc::clone(&env)).await?; let key_text = match &key_val { Value::Text(s) => s.to_string(), diff --git a/src/interpreter/sessions.rs b/src/interpreter/sessions.rs index 29fdffe4..199f6d3b 100644 --- a/src/interpreter/sessions.rs +++ b/src/interpreter/sessions.rs @@ -188,7 +188,9 @@ impl SessionManager { store.sessions.remove(id); *self.expired_count.lock().await += 1; persist(&store).await?; - return Err(format!("Unknown session '{id}'. It may have been destroyed.")); + return Err(format!( + "Unknown session '{id}'. It may have been destroyed." + )); } record.data.insert(key.to_string(), value); let now = now_ms(); @@ -289,10 +291,7 @@ impl SessionManager { "last_activity".to_string(), Value::Number(record.last_activity as f64), ); - map.insert( - "_server".to_string(), - Value::Text(Arc::from(server_name)), - ); + map.insert("_server".to_string(), Value::Text(Arc::from(server_name))); Value::Object(std::rc::Rc::new(std::cell::RefCell::new(map))) } @@ -388,11 +387,7 @@ async fn open_store(config: &SessionConfig) -> Result { } } -async fn prune_expired( - store: &mut StoreState, - _cfg: &SessionConfig, - expired_count: &Mutex, -) { +async fn prune_expired(store: &mut StoreState, _cfg: &SessionConfig, expired_count: &Mutex) { let now = now_ms(); let before = store.sessions.len(); store.sessions.retain(|_, record| record.expires_at >= now); @@ -489,8 +484,8 @@ async fn load_sqlite( .map_err(|e| format!("Failed to load sessions: {e}"))?; let mut sessions = HashMap::new(); for (id, data, created_at, last_activity, expires_at) in session_rows { - let json: serde_json::Value = serde_json::from_str(&data) - .map_err(|e| format!("Corrupt session row {id}: {e}"))?; + let json: serde_json::Value = + serde_json::from_str(&data).map_err(|e| format!("Corrupt session row {id}: {e}"))?; sessions.insert( id.clone(), SessionRecord { @@ -601,12 +596,18 @@ fn decode_store_json( SessionRecord { id: id.clone(), data: json_to_map(record.get("data").unwrap_or(&json!({})))?, - created_at: record.get("created_at").and_then(|v| v.as_i64()).unwrap_or(0), + created_at: record + .get("created_at") + .and_then(|v| v.as_i64()) + .unwrap_or(0), last_activity: record .get("last_activity") .and_then(|v| v.as_i64()) .unwrap_or(0), - expires_at: record.get("expires_at").and_then(|v| v.as_i64()).unwrap_or(0), + expires_at: record + .get("expires_at") + .and_then(|v| v.as_i64()) + .unwrap_or(0), }, ); } @@ -677,7 +678,9 @@ fn json_to_value(value: &serde_json::Value) -> Result { for (key, val) in obj { map.insert(key.clone(), json_to_value(val)?); } - Ok(Value::Object(std::rc::Rc::new(std::cell::RefCell::new(map)))) + Ok(Value::Object(std::rc::Rc::new(std::cell::RefCell::new( + map, + )))) } } } diff --git a/src/parser/expr/primary.rs b/src/parser/expr/primary.rs index d64f9307..34009cd5 100644 --- a/src/parser/expr/primary.rs +++ b/src/parser/expr/primary.rs @@ -508,7 +508,8 @@ impl<'a> Parser<'a> { return self.parse_get_session_expression(); } Token::Identifier(name) - if name == "generate csrf token" || name.starts_with("generate csrf token ") => + if name == "generate csrf token" + || name.starts_with("generate csrf token ") => { return self.parse_generate_csrf_token_for_session(); } @@ -1327,9 +1328,9 @@ impl<'a> Parser<'a> { _ if token.token.is_contextual_keyword() => { // Special handling for "create list" expression if token.token == Token::KeywordCreate { - if self.cursor.peek_next().is_some_and(|t| { - matches!(&t.token, Token::Identifier(id) if id == "session") - }) { + if self.cursor.peek_next().is_some_and( + |t| matches!(&t.token, Token::Identifier(id) if id == "session"), + ) { return self.parse_create_session_expression(); } self.bump_sync(); // Consume "create" diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 9ff8731e..41a4a83e 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -803,7 +803,8 @@ impl<'a> StmtParser<'a> for Parser<'a> { self.parse_enable_csrf_protection_statement() } Token::Identifier(id) - if id == "enable secure cookies" || id.starts_with("enable secure cookies ") => + if id == "enable secure cookies" + || id.starts_with("enable secure cookies ") => { self.parse_enable_secure_cookies_statement() } diff --git a/src/parser/stmt/web.rs b/src/parser/stmt/web.rs index 5a76de29..a7954f2b 100644 --- a/src/parser/stmt/web.rs +++ b/src/parser/stmt/web.rs @@ -243,9 +243,7 @@ impl<'a> WebParser<'a> for Parser<'a> { )); } } - } else if id != "sessions enabled" - && !id.starts_with("sessions enabled") - { + } else if id != "sessions enabled" && !id.starts_with("sessions enabled") { return Err(ParseError::from_token( "Expected 'sessions enabled' after 'with'".to_string(), token, @@ -1071,7 +1069,10 @@ impl<'a> WebParser<'a> for Parser<'a> { }; let (id_line, id_column) = (t.line, t.column); self.bump_sync(); - let rest = id.strip_prefix("storage").map(str::trim_start).unwrap_or(""); + let rest = id + .strip_prefix("storage") + .map(str::trim_start) + .unwrap_or(""); let storage = if rest.is_empty() { self.parse_primary_expression()? } else { @@ -1142,7 +1143,10 @@ impl<'a> WebParser<'a> for Parser<'a> { } else { Expression::Variable(rest, line, column) }; - self.expect_token(Token::KeywordTo, "Expected 'to' after the session value key")?; + self.expect_token( + Token::KeywordTo, + "Expected 'to' after the session value key", + )?; let value = self.parse_primary_expression()?; self.expect_token(Token::KeywordIn, "Expected 'in' after the session value")?; let session = self.parse_primary_expression()?; @@ -1182,7 +1186,10 @@ impl<'a> WebParser<'a> for Parser<'a> { let token = self.bump_sync().unwrap(); // store let (line, column) = (token.line, token.column); self.consume_session_data_marker(&token)?; - self.expect_token(Token::KeywordTo, "Expected 'to storage' after 'session_data'")?; + self.expect_token( + Token::KeywordTo, + "Expected 'to storage' after 'session_data'", + )?; match self.cursor.peek() { Some(t) if matches!(&t.token, Token::Identifier(id) if id == "storage") => { self.bump_sync(); @@ -1469,11 +1476,11 @@ impl<'a> Parser<'a> { self.bump_sync(); Ok(()) } - Some(t) => Err(ParseError::from_token( + Some(t) => Err(ParseError::from_token("Expected 'storage'".to_string(), t)), + None => Err(ParseError::from_token( "Expected 'storage'".to_string(), - t, + origin, )), - None => Err(ParseError::from_token("Expected 'storage'".to_string(), origin)), } } @@ -1482,10 +1489,7 @@ impl<'a> Parser<'a> { require_data: bool, origin: &crate::lexer::token::TokenWithPosition, ) -> Result<(Expression, Option), ParseError> { - self.expect_token( - Token::KeywordWith, - "Expected 'with key' after 'storage'", - )?; + self.expect_token(Token::KeywordWith, "Expected 'with key' after 'storage'")?; let key = match self.cursor.peek() { Some(t) if matches!(&t.token, Token::Identifier(id) if id == "key" || id.starts_with("key ")) => { diff --git a/src/parser/tests.rs b/src/parser/tests.rs index b03f98b8..d4648cdb 100644 --- a/src/parser/tests.rs +++ b/src/parser/tests.rs @@ -3154,10 +3154,7 @@ fn can_start_primary_expression_matches_parse_primary_expression() { ("find x in y", true), ("replace x with y in z", true), ("split x by y", true), - ( - r#"load session data from storage with key "k""#, - true, - ), + (r#"load session data from storage with key "k""#, true), // A sample of the contextual-keyword catch-all: no dedicated arm // above, but `token.is_contextual_keyword()` is true, so a bare // keyword still resolves to a plain variable reference. diff --git a/tests/web_server_session_parser_test.rs b/tests/web_server_session_parser_test.rs index 2327758f..ee15dd55 100644 --- a/tests/web_server_session_parser_test.rs +++ b/tests/web_server_session_parser_test.rs @@ -239,9 +239,8 @@ fn get_session_statistics_expression() { #[test] fn store_session_data_is_not_store_as() { - let stmt = parse_one( - r#"store session_data to storage with key "test_session_123" and data payload"#, - ); + let stmt = + parse_one(r#"store session_data to storage with key "test_session_123" and data payload"#); match stmt { Statement::StoreSessionDataStatement { key, data, .. } => { assert_string(&key, "test_session_123", "key"); From 13acc2292d3f67cff01168e34314b7ad2621018c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 15:44:37 +0000 Subject: [PATCH 08/10] refactor: satisfy clippy on session manager sharing Use Rc for the !Send SessionManager, alias the loaded-store tuple, and drop needless token borrows so -D warnings stays clean. Co-authored-by: logbie --- src/interpreter/mod.rs | 16 ++++++++-------- src/interpreter/sessions.rs | 14 +++++--------- src/parser/stmt/web.rs | 16 ++++++++-------- tests/session_store_test.rs | 8 ++++---- 4 files changed, 25 insertions(+), 29 deletions(-) diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index f7d2d8f5..2c05ac38 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -364,7 +364,7 @@ pub struct WflWebServer { pub request_receiver: Arc>>, pub request_sender: mpsc::Sender, pub server_handle: Option>, - pub sessions: Option>, + pub sessions: Option>, } impl Drop for WflWebServer { @@ -10705,7 +10705,7 @@ impl Interpreter { // *while streaming* (below), which bounds chunked bodies that // carry no Content-Length. let session_manager = if *sessions_enabled { - Some(Arc::new( + Some(Rc::new( sessions::SessionManager::new(sessions::SessionConfig::from_wfl_config( &self.config, )) @@ -13535,7 +13535,7 @@ impl Interpreter { } if name_str.starts_with("WebServer::") { let web_servers = self.web_servers.borrow(); - for server_name in web_servers.keys() { + if let Some(server_name) = web_servers.keys().next() { return Ok(server_name.clone()); } } @@ -13559,7 +13559,7 @@ impl Interpreter { env: Rc>, line: usize, column: usize, - ) -> Result, RuntimeError> { + ) -> Result, RuntimeError> { let name = self .session_server_name_from_expr(server, env, line, column) .await?; @@ -13571,7 +13571,7 @@ impl Interpreter { name: &str, line: usize, column: usize, - ) -> Result, RuntimeError> { + ) -> Result, RuntimeError> { let servers = self.web_servers.borrow(); match servers.get(name).and_then(|s| s.sessions.clone()) { Some(manager) => Ok(manager), @@ -13590,7 +13590,7 @@ impl Interpreter { request: &Value, line: usize, column: usize, - ) -> Result<(Arc, String), RuntimeError> { + ) -> Result<(Rc, String), RuntimeError> { let server = match request { Value::Object(obj) => match obj.borrow().get("_session_server") { Some(Value::Text(name)) => name.to_string(), @@ -13619,7 +13619,7 @@ impl Interpreter { session: &Value, line: usize, column: usize, - ) -> Result<(Arc, String), RuntimeError> { + ) -> Result<(Rc, String), RuntimeError> { let (server, id) = match session { Value::Object(obj) => { let obj = obj.borrow(); @@ -13662,7 +13662,7 @@ impl Interpreter { &self, line: usize, column: usize, - ) -> Result, RuntimeError> { + ) -> Result, RuntimeError> { let managers: Vec<_> = self .web_servers .borrow() diff --git a/src/interpreter/sessions.rs b/src/interpreter/sessions.rs index 199f6d3b..70894add 100644 --- a/src/interpreter/sessions.rs +++ b/src/interpreter/sessions.rs @@ -417,9 +417,9 @@ async fn persist(store: &StoreState) -> Result<(), String> { } } -fn load_file_store( - path: &Path, -) -> Result<(HashMap, HashMap), String> { +type LoadedStore = (HashMap, HashMap); + +fn load_file_store(path: &Path) -> Result { if !path.exists() { return Ok((HashMap::new(), HashMap::new())); } @@ -473,9 +473,7 @@ async fn init_sqlite(pool: &SqlitePool) -> Result<(), String> { Ok(()) } -async fn load_sqlite( - pool: &SqlitePool, -) -> Result<(HashMap, HashMap), String> { +async fn load_sqlite(pool: &SqlitePool) -> Result { let session_rows = sqlx::query_as::<_, (String, String, i64, i64, i64)>( "SELECT id, data, created_at, last_activity, expires_at FROM wfl_sessions", ) @@ -585,9 +583,7 @@ fn encode_store_json( .map_err(|e| format!("Failed to encode session file: {e}")) } -fn decode_store_json( - root: &serde_json::Value, -) -> Result<(HashMap, HashMap), String> { +fn decode_store_json(root: &serde_json::Value) -> Result { let mut sessions = HashMap::new(); if let Some(map) = root.get("sessions").and_then(|v| v.as_object()) { for (id, record) in map { diff --git a/src/parser/stmt/web.rs b/src/parser/stmt/web.rs index a7954f2b..b64996a1 100644 --- a/src/parser/stmt/web.rs +++ b/src/parser/stmt/web.rs @@ -1185,7 +1185,7 @@ impl<'a> WebParser<'a> for Parser<'a> { fn parse_store_session_data_statement(&mut self) -> Result { let token = self.bump_sync().unwrap(); // store let (line, column) = (token.line, token.column); - self.consume_session_data_marker(&token)?; + self.consume_session_data_marker(token)?; self.expect_token( Token::KeywordTo, "Expected 'to storage' after 'session_data'", @@ -1207,7 +1207,7 @@ impl<'a> WebParser<'a> for Parser<'a> { )); } } - let (key, data) = self.parse_storage_key_and_optional_data(true, &token)?; + let (key, data) = self.parse_storage_key_and_optional_data(true, token)?; Ok(Statement::StoreSessionDataStatement { key, data: data.expect("data required"), @@ -1219,13 +1219,13 @@ impl<'a> WebParser<'a> for Parser<'a> { fn parse_delete_session_data_statement(&mut self) -> Result { let token = self.bump_sync().unwrap(); // delete let (line, column) = (token.line, token.column); - self.consume_session_data_marker(&token)?; + self.consume_session_data_marker(token)?; self.expect_token( Token::KeywordFrom, "Expected 'from storage' after 'session data'", )?; - self.consume_storage_marker(&token)?; - let (key, _) = self.parse_storage_key_and_optional_data(false, &token)?; + self.consume_storage_marker(token)?; + let (key, _) = self.parse_storage_key_and_optional_data(false, token)?; Ok(Statement::DeleteSessionDataStatement { key, line, column }) } @@ -1405,13 +1405,13 @@ impl<'a> WebParser<'a> for Parser<'a> { fn parse_load_session_data_expression(&mut self) -> Result { let token = self.bump_sync().unwrap(); // load let (line, column) = (token.line, token.column); - self.consume_session_data_marker(&token)?; + self.consume_session_data_marker(token)?; self.expect_token( Token::KeywordFrom, "Expected 'from storage' after 'session data'", )?; - self.consume_storage_marker(&token)?; - let (key, _) = self.parse_storage_key_and_optional_data(false, &token)?; + self.consume_storage_marker(token)?; + let (key, _) = self.parse_storage_key_and_optional_data(false, token)?; Ok(Expression::LoadSessionData { key: Box::new(key), line, diff --git a/tests/session_store_test.rs b/tests/session_store_test.rs index f8214a5f..feff478b 100644 --- a/tests/session_store_test.rs +++ b/tests/session_store_test.rs @@ -2,7 +2,7 @@ // // These talk to the real store — no mocks of the persistence boundary. -use std::sync::Arc; +use std::rc::Rc; use std::time::Duration; use wfl::interpreter::sessions::{ SessionConfig, SessionManager, SessionSameSite, SessionStorageKind, @@ -184,13 +184,13 @@ async fn sqlite_backend_persists_across_managers() { #[tokio::test] async fn concurrent_updates_do_not_corrupt_store() { - let manager = Arc::new(SessionManager::new(memory_config()).await.unwrap()); + let manager = Rc::new(SessionManager::new(memory_config()).await.unwrap()); let a = manager.create().await.unwrap(); let b = manager.create().await.unwrap(); let id_a = a.id.clone(); let id_b = b.id.clone(); let left = { - let manager = Arc::clone(&manager); + let manager = Rc::clone(&manager); async move { for i in 0..50 { manager @@ -201,7 +201,7 @@ async fn concurrent_updates_do_not_corrupt_store() { } }; let right = { - let manager = Arc::clone(&manager); + let manager = Rc::clone(&manager); async move { for i in 0..50 { manager From b319858456bedf0e99f724d9644eb5f4d6f966c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 15:58:57 +0000 Subject: [PATCH 09/10] fix: use interpreter stack in disconnect burst regression test Session-aware interpreter paths increased native stack use enough that the 270-client disconnect burst overflows a default ~2 MiB thread. Spawn the proxy server with INTERPRETER_STACK_SIZE like the CLI does. Co-authored-by: logbie --- tests/concurrent_disconnect_burst_test.rs | 28 ++++++++++++++--------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/concurrent_disconnect_burst_test.rs b/tests/concurrent_disconnect_burst_test.rs index e07fe18d..f1349f29 100644 --- a/tests/concurrent_disconnect_burst_test.rs +++ b/tests/concurrent_disconnect_burst_test.rs @@ -82,17 +82,23 @@ async fn spawn_counting_stall_upstream() -> (u16, mpsc::UnboundedReceiver<()>) { } fn start_proxy_server(code: String) -> std::thread::JoinHandle<()> { - std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().expect("runtime"); - rt.block_on(async { - let tokens = lex_wfl_with_positions(&code); - let ast = Parser::new(&tokens).parse().expect("parse"); - let mut interp = Interpreter::new(); - if let Err(errors) = interp.interpret(&ast).await { - panic!("proxy interpreter failed: {errors:?}"); - } - }); - }) + // Session-aware interpreter paths need the CLI-sized stack; the default + // ~2 MiB OS thread stack overflows under this 270-client disconnect burst. + std::thread::Builder::new() + .name("wfl-interpreter".to_string()) + .stack_size(wfl::INTERPRETER_STACK_SIZE) + .spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + rt.block_on(async { + let tokens = lex_wfl_with_positions(&code); + let ast = Parser::new(&tokens).parse().expect("parse"); + let mut interp = Interpreter::new(); + if let Err(errors) = interp.interpret(&ast).await { + panic!("proxy interpreter failed: {errors:?}"); + } + }); + }) + .expect("spawn interpreter thread") } async fn wait_for_server(port: u16) { From 445e389990b8455aca5b21bfd613a6a454c32610 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 16:15:29 +0000 Subject: [PATCH 10/10] fix: spawn interpreter web-server tests on large stack Session-aware interpreter paths overflow the default ~2 MiB thread stack in debug builds under concurrent handler load. Add common::spawn_interpreter_thread and use it across burst, capture, module, and stream ownership regressions. Co-authored-by: logbie --- tests/common/mod.rs | 17 +++++++++++ tests/concurrent_disconnect_burst_test.rs | 28 ++++++++----------- .../concurrent_disconnect_paths_burst_test.rs | 2 +- tests/concurrent_execute_capture_test.rs | 2 +- tests/concurrent_main_loop_test.rs | 2 +- tests/concurrent_module_loading_test.rs | 2 +- tests/concurrent_stream_ownership_test.rs | 2 +- tests/outbound_stream_disconnect_test.rs | 2 +- 8 files changed, 34 insertions(+), 23 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 761753f7..75b813cf 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -29,6 +29,23 @@ pub fn free_tcp_port() -> u16 { .port() } +/// Spawn a background thread with the CLI-sized interpreter stack. +/// +/// Web-server integration tests that drive [`Interpreter::interpret`] on a +/// dedicated thread must use this instead of [`std::thread::spawn`]: session- +/// aware interpreter paths overflow the default ~2 MiB OS thread stack under +/// concurrent handler load in debug builds. +pub fn spawn_interpreter_thread(work: F) -> std::thread::JoinHandle<()> +where + F: FnOnce() + Send + 'static, +{ + std::thread::Builder::new() + .name("wfl-interpreter".to_string()) + .stack_size(wfl::INTERPRETER_STACK_SIZE) + .spawn(work) + .expect("spawn interpreter thread") +} + // --------------------------------------------------------------------------- // Shape A: run WFL source, get back `Result` for // inspecting arbitrary globals afterwards. diff --git a/tests/concurrent_disconnect_burst_test.rs b/tests/concurrent_disconnect_burst_test.rs index f1349f29..c40d628f 100644 --- a/tests/concurrent_disconnect_burst_test.rs +++ b/tests/concurrent_disconnect_burst_test.rs @@ -82,23 +82,17 @@ async fn spawn_counting_stall_upstream() -> (u16, mpsc::UnboundedReceiver<()>) { } fn start_proxy_server(code: String) -> std::thread::JoinHandle<()> { - // Session-aware interpreter paths need the CLI-sized stack; the default - // ~2 MiB OS thread stack overflows under this 270-client disconnect burst. - std::thread::Builder::new() - .name("wfl-interpreter".to_string()) - .stack_size(wfl::INTERPRETER_STACK_SIZE) - .spawn(move || { - let rt = tokio::runtime::Runtime::new().expect("runtime"); - rt.block_on(async { - let tokens = lex_wfl_with_positions(&code); - let ast = Parser::new(&tokens).parse().expect("parse"); - let mut interp = Interpreter::new(); - if let Err(errors) = interp.interpret(&ast).await { - panic!("proxy interpreter failed: {errors:?}"); - } - }); - }) - .expect("spawn interpreter thread") + common::spawn_interpreter_thread(move || { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + rt.block_on(async { + let tokens = lex_wfl_with_positions(&code); + let ast = Parser::new(&tokens).parse().expect("parse"); + let mut interp = Interpreter::new(); + if let Err(errors) = interp.interpret(&ast).await { + panic!("proxy interpreter failed: {errors:?}"); + } + }); + }) } async fn wait_for_server(port: u16) { diff --git a/tests/concurrent_disconnect_paths_burst_test.rs b/tests/concurrent_disconnect_paths_burst_test.rs index d4f9dba4..8bd29ceb 100644 --- a/tests/concurrent_disconnect_paths_burst_test.rs +++ b/tests/concurrent_disconnect_paths_burst_test.rs @@ -464,7 +464,7 @@ struct ProxyServer { fn start_proxy_server(code: String) -> ProxyServer { let (abort, abort_rx) = tokio::sync::oneshot::channel(); - let thread = std::thread::spawn(move || { + let thread = common::spawn_interpreter_thread(move || { let rt = tokio::runtime::Runtime::new().expect("runtime"); rt.block_on(async { let tokens = lex_wfl_with_positions(&code); diff --git a/tests/concurrent_execute_capture_test.rs b/tests/concurrent_execute_capture_test.rs index 62d85c88..3d50bef2 100644 --- a/tests/concurrent_execute_capture_test.rs +++ b/tests/concurrent_execute_capture_test.rs @@ -24,7 +24,7 @@ use wfl::parser::Parser; mod common; fn start_server_thread(code: String) -> std::thread::JoinHandle<()> { - std::thread::spawn(move || { + common::spawn_interpreter_thread(move || { let rt = tokio::runtime::Runtime::new().expect("runtime"); rt.block_on(async { let tokens = lex_wfl_with_positions(&code); diff --git a/tests/concurrent_main_loop_test.rs b/tests/concurrent_main_loop_test.rs index 9648b055..0b2b91e1 100644 --- a/tests/concurrent_main_loop_test.rs +++ b/tests/concurrent_main_loop_test.rs @@ -48,7 +48,7 @@ fn test_plain_main_loop_stays_serial() { } fn start_server_thread(code: String) -> std::thread::JoinHandle<()> { - std::thread::spawn(move || { + common::spawn_interpreter_thread(move || { let rt = tokio::runtime::Runtime::new().expect("runtime"); rt.block_on(async { let tokens = lex_wfl_with_positions(&code); diff --git a/tests/concurrent_module_loading_test.rs b/tests/concurrent_module_loading_test.rs index 6998e90a..3c804ba2 100644 --- a/tests/concurrent_module_loading_test.rs +++ b/tests/concurrent_module_loading_test.rs @@ -20,7 +20,7 @@ use wfl::parser::Parser; mod common; fn start_server_thread(code: String, source_file: Option) -> std::thread::JoinHandle<()> { - std::thread::spawn(move || { + common::spawn_interpreter_thread(move || { let rt = tokio::runtime::Runtime::new().expect("runtime"); rt.block_on(async { let tokens = lex_wfl_with_positions(&code); diff --git a/tests/concurrent_stream_ownership_test.rs b/tests/concurrent_stream_ownership_test.rs index 48581389..e62ebc45 100644 --- a/tests/concurrent_stream_ownership_test.rs +++ b/tests/concurrent_stream_ownership_test.rs @@ -21,7 +21,7 @@ use wfl::parser::Parser; mod common; fn start_server_thread(code: String) -> std::thread::JoinHandle<()> { - std::thread::spawn(move || { + common::spawn_interpreter_thread(move || { let rt = tokio::runtime::Runtime::new().expect("runtime"); rt.block_on(async { let tokens = lex_wfl_with_positions(&code); diff --git a/tests/outbound_stream_disconnect_test.rs b/tests/outbound_stream_disconnect_test.rs index 40e2bb1b..b302a1bd 100644 --- a/tests/outbound_stream_disconnect_test.rs +++ b/tests/outbound_stream_disconnect_test.rs @@ -54,7 +54,7 @@ async fn spawn_one_chunk_then_stall_upstream() -> (u16, tokio::sync::oneshot::Re } fn start_proxy_server(code: String) -> std::thread::JoinHandle<()> { - std::thread::spawn(move || { + common::spawn_interpreter_thread(move || { let rt = tokio::runtime::Runtime::new().expect("runtime"); rt.block_on(async { let tokens = lex_wfl_with_positions(&code);