diff --git a/README.md b/README.md index 2d43dd1..d5c5df2 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ In one sentence: **Fun is a scripting language that goes all the way from JSON to machine code.** - One runtime spans the whole spectrum, from high-level data work (JSON/FD) down to low-level system programming (FFI, C at runtime, JIT, machine code); -- 66 standard-library modules written in Fun itself ship together as one embeddable `fun.dll`; +- 67 standard-library modules written in Fun itself ship together as one embeddable `fun.dll`; - in AI-agent and domestic-IT scenarios, it is the lightweight glue connecting OSes, databases, chips, and business logic. Fun is designed around a simple premise: **data and code should be equally first-class, and a script should be able to reach down to the machine without leaving the language.** @@ -70,7 +70,7 @@ Fun is written in Pascal (Delphi / Free Pascal). The core is a small tree-walkin - **Keyword aliasing** — a canonical English keyword set with pluggable alias packs (Chinese, French, custom) selected at runtime (see [below](#keyword-aliasing)). - **Cross-platform** — Windows (32/64-bit), Linux, ARM, and Windows CE. - **Dual distribution** — command-line `fun.exe` and embeddable runtime `fun.dll` exporting a simple `Run` interface. -- **Self-hosted standard library** — 66 modules written in Fun itself, from JSON/YAML/XML to ORM, async, UI, and native bindings. +- **Self-hosted standard library** — 67 modules written in Fun itself, from JSON/YAML/XML to ORM, async, UI, and native bindings. --- @@ -372,7 +372,7 @@ fun/ │ ├── utils/ # Utility functions │ └── prj/fun/ # Projects & build scripts (funcmd.dpr, Delphi/FPC) ├── fun/ -│ ├── lib/ # Standard library (pure Fun, 66 modules) +│ ├── lib/ # Standard library (pure Fun, 67 modules) │ ├── demo/ # Language syntax demos │ ├── demos/ # Full applications & benchmarks │ └── key_*.ini # Keyword alias packs (CN / FR / PUA) @@ -497,16 +497,16 @@ win64/aarch64 target, so there is nothing to build with. ## Standard library -The standard library in [`fun/lib`](fun/lib) is written in pure Fun (66 modules): +The standard library in [`fun/lib`](fun/lib) is written in pure Fun (67 modules): -> In total roughly 6,000 lines of logical code (excluding comments), about 100 lines per module — all 66 modules written in Fun itself. +> In total roughly 6,000 lines of logical code (excluding comments), about 100 lines per module — all 67 modules written in Fun itself. - **Data**: `lib-json`, `lib-yaml`, `lib-xml`, `lib-base64`, `lib-cstruct`, `lib-md5`, `lib-crypt` - **Utility**: `uuid` - **Collections / algorithms**: `lib-set`, `lib-tree`, `lib-stack`, `lib-dyns`, `lib-math` - **Text**: `lib-string`, `lib-regex`, `lib-match`, `lib-unicode` - **System / IO**: `lib-file`, `lib-os`, `lib-host`, `lib-time`, `lib-cmdline`, `lib-proc` -- **Network**: `lib-winsock`, `lib-ajax`, `lib-jsonrpc`, `http-server`, `http-client`, `http-auth`, `http-bearer`, `http-cors` +- **Network**: `lib-winsock`, `lib-ajax`, `lib-jsonrpc`, `http-server`, `http-client`, `http-auth`, `http-bearer`, `http-cors`, `http-session` - **Databases**: `lib-orm`, `lib-orm-pro`, `lib-orm-gen`, `lib-orm-rpc`, `lib-orm-cte`, `lib-ado`, `lib-ado-schema` - **Native / low-level**: `lib-winapi`, `lib-winole`, `lib-tcc` (C compile), `lib-jit`, `lib-asm`, `lib-asm-pro` - **UI**: `lib-ui`, `lib-ui-base`, `lib-dialog`, `lib-trayicon` @@ -533,6 +533,7 @@ The standard library in [`fun/lib`](fun/lib) is written in pure Fun (66 modules) - [`fun/demos/test-demo`](fun/demos/test-demo) — a lib-only package with tests, a shared helper, and a failing-assertion example. - [`fun/demos/http-cors-demo`](fun/demos/http-cors-demo) — CORS preflight, credentials, exposed headers, and a browser page. - [`fun/demos/http-bearer-demo`](fun/demos/http-bearer-demo) — Bearer token auth, 401 challenges, and a same-origin page. +- [`fun/demos/http-session-demo`](fun/demos/http-session-demo) — cookie session login/logout, SID rotation, and server-side state. --- diff --git a/README.zh.md b/README.zh.md index 29f8184..dbb9b1a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -39,7 +39,7 @@ 一句话定位:**Fun 是一门从 JSON 一路写到机器码的脚本语言。** - 用同一个运行时,覆盖从高层数据处理(JSON/FD)到底层系统编程(FFI、运行时 C、JIT、机器码)的全谱系; -- 自带 66 个用 Fun 自身编写的标准库模块,全栈能力打包进单个可嵌入的 `fun.dll`; +- 自带 67 个用 Fun 自身编写的标准库模块,全栈能力打包进单个可嵌入的 `fun.dll`; - 在信创与 AI Agent 场景中,作为连接国产 OS、数据库、芯片与业务逻辑的轻量级胶水。 Fun 的设计出发点很简单:**数据与代码都应该是头等公民,而且一段脚本应该能在不离开语言的情况下一路触达机器底层。** @@ -70,7 +70,7 @@ Fun 使用 Pascal(Delphi / Free Pascal)编写。核心是一个小巧的树 - **关键字别名** —— 规范英文关键字 + 可插拔的别名包(中文、法文、自定义),运行时选择(见[下文](#关键字别名))。 - **跨平台** —— Windows(32/64 位)、Linux、ARM 与 Windows CE。 - **双形态分发** —— 命令行 `fun.exe` 与嵌入运行时 `fun.dll`(导出简单的 `Run` 接口)。 -- **自托管的标准库** —— 66 个用 Fun 自身编写的模块,覆盖 JSON/YAML/XML 到 ORM、异步、UI 与原生绑定。 +- **自托管的标准库** —— 67 个用 Fun 自身编写的模块,覆盖 JSON/YAML/XML 到 ORM、异步、UI 与原生绑定。 --- @@ -365,7 +365,7 @@ fun/ │ ├── utils/ # 工具函数 │ └── prj/fun/ # 工程与构建脚本(funcmd.dpr,Delphi/FPC) ├── fun/ -│ ├── lib/ # 标准库(纯 Fun 编写,66 个模块) +│ ├── lib/ # 标准库(纯 Fun 编写,67 个模块) │ ├── demo/ # 语言语法演示 │ ├── demos/ # 完整应用与基准测试 │ └── key_*.ini # 关键字别名包(CN / FR / PUA) @@ -477,16 +477,16 @@ float/混合参数与 `@toCallback` 闭包在 win64 上也有覆盖;`ffi-scala ## 标准库 -标准库位于 [`fun/lib`](fun/lib),以纯 Fun 编写(66 个模块): +标准库位于 [`fun/lib`](fun/lib),以纯 Fun 编写(67 个模块): -> 总代码量约 6,000 行逻辑代码(不含注释),平均每个模块约 100 行——66 个模块全部用 Fun 自身写成。 +> 总代码量约 6,000 行逻辑代码(不含注释),平均每个模块约 100 行——67 个模块全部用 Fun 自身写成。 - **数据**:`lib-json`、`lib-yaml`、`lib-xml`、`lib-base64`、`lib-cstruct`、`lib-md5`、`lib-crypt` - **工具**:`uuid` - **集合 / 算法**:`lib-set`、`lib-tree`、`lib-stack`、`lib-dyns`、`lib-math` - **文本**:`lib-string`、`lib-regex`、`lib-match`、`lib-unicode` - **系统 / IO**:`lib-file`、`lib-os`、`lib-host`、`lib-time`、`lib-cmdline`、`lib-proc` -- **网络**:`lib-winsock`、`lib-ajax`、`lib-jsonrpc`、`http-server`、`http-client`、`http-auth`、`http-bearer`、`http-cors` +- **网络**:`lib-winsock`、`lib-ajax`、`lib-jsonrpc`、`http-server`、`http-client`、`http-auth`、`http-bearer`、`http-cors`、`http-session` - **数据库**:`lib-orm`、`lib-orm-pro`、`lib-orm-gen`、`lib-orm-rpc`、`lib-orm-cte`、`lib-ado`、`lib-ado-schema` - **原生 / 底层**:`lib-winapi`、`lib-winole`、`lib-tcc`(C 编译)、`lib-jit`、`lib-asm`、`lib-asm-pro` - **UI**:`lib-ui`、`lib-ui-base`、`lib-dialog`、`lib-trayicon` @@ -510,6 +510,7 @@ float/混合参数与 `@toCallback` 闭包在 win64 上也有覆盖;`ffi-scala - [`fun/demos/test-demo`](fun/demos/test-demo) —— 只有库的包及测试:共享 helper、通过用例与失败断言示例。 - [`fun/demos/http-cors-demo`](fun/demos/http-cors-demo) —— CORS 预检、credentials、暴露响应头与浏览器页面示例。 - [`fun/demos/http-bearer-demo`](fun/demos/http-bearer-demo) —— Bearer token 认证、401 挑战与同源页面示例。 +- [`fun/demos/http-session-demo`](fun/demos/http-session-demo) —— cookie session 登录/登出、SID 轮换与服务端状态示例。 --- diff --git a/fun/demos/http-session-demo/README.md b/fun/demos/http-session-demo/README.md new file mode 100644 index 0000000..abd55d5 --- /dev/null +++ b/fun/demos/http-session-demo/README.md @@ -0,0 +1,68 @@ +# http-session-demo + +A small login/session page: + +- `POST /api/login` stores the user in `req.session` and rotates the SID; +- `GET /api/me` reads the session; +- `GET /api/count` increments a value stored server-side; +- `POST /api/logout` sets `req.session = nil`, destroying the session and cookie. + +```text +package.fun # manifest +src/main.fun # API + static page on :8080 +web/index.html # same-origin page +``` + +## Run + +`fun` below is the command-line binary (`fun.exe` on Windows). From the +repository root, after building, the in-tree binary is +`src/prj/fun/funcmd`; from this directory that is +`../../../src/prj/fun/funcmd`. + +```text +cd fun/demos/http-session-demo +fun run +# or: ../../../src/prj/fun/funcmd run +``` + +Open . Login, then click `GET /api/me` and +`GET /api/count`; the browser sends the `sid` cookie automatically. + +## Curl + +Use a cookie jar: + +```text +curl -i -c /tmp/fun-sid.txt -b /tmp/fun-sid.txt \ + -X POST http://127.0.0.1:8080/api/login \ + -H 'Content-Type: application/json' \ + -d '{"user":"alice"}' +``` + +The response sets `sid=...; Path=/; HttpOnly; SameSite=Lax`. Then: + +```text +curl -i -c /tmp/fun-sid.txt -b /tmp/fun-sid.txt http://127.0.0.1:8080/api/me +curl -i -c /tmp/fun-sid.txt -b /tmp/fun-sid.txt http://127.0.0.1:8080/api/count +curl -i -c /tmp/fun-sid.txt -b /tmp/fun-sid.txt -X POST http://127.0.0.1:8080/api/logout +``` + +After logout the cookie is cleared and `/api/me` returns `{"user":null}`. + +## Notes + +- Only a random SID is stored in the cookie; session data is in the server. +- `SessionRegenerate(req)` after login prevents session fixation; the old SID + is destroyed. +- Session data must be JSON-serializable (it is compared before/after the + handler to decide whether to save). +- The default cookie is `HttpOnly`, `SameSite=Lax`, `Path=/`; set `secure: true` + behind HTTPS. +- SameSite helps with CSRF but is not a complete CSRF defense. State-changing + routes should still use a CSRF token. +- The in-memory store is per process. For multiple instances, pass a shared + `store` implementing `get(sid)`, `set(sid, data, ttl)`, `destroy(sid)`. +- For a cross-site SPA, CORS must allow credentials with an exact origin, the + cookie needs `secure: true` and `sameSite: 'None'`, and browser third-party + cookie restrictions may still block it; a same-site/BFF deployment is safer. diff --git a/fun/demos/http-session-demo/package.fun b/fun/demos/http-session-demo/package.fun new file mode 100644 index 0000000..d5c0881 --- /dev/null +++ b/fun/demos/http-session-demo/package.fun @@ -0,0 +1,15 @@ +fun package() + result = [ + name: 'http-session-demo', + version: '0.1.0', + bin: 'src/main.fun', + lib: '', + 'fun': '9.4', + + dependencies: [], + + // >>> generated - do not edit; run 'fun package tidy' to refresh + resolved: [] + // <<< generated + ]; +end fun; diff --git a/fun/demos/http-session-demo/src/main.fun b/fun/demos/http-session-demo/src/main.fun new file mode 100644 index 0000000..5986b45 --- /dev/null +++ b/fun/demos/http-session-demo/src/main.fun @@ -0,0 +1,42 @@ +use '../../../lib/http-server.fun'; +use '../../../lib/http-session.fun'; + +fun meH(req, res) + if req.session['user'] = nil then + res.json([user: nil]); + else + res.json([user: req.session['user']]); + end if; +end fun; + +fun loginH(req, res) + var body = req.json(); + var user = body['user']; + if user = nil or user = '' then + res.status(400).json([error: 'user required']); + return; + end if; + req.session['user'] = user; + SessionRegenerate(req); + res.json([user: user]); +end fun; + +fun countH(req, res) + req.session['count'] = (req.session['count'] or 0) + 1; + res.json([count: req.session['count']]); +end fun; + +fun logoutH(req, res) + req.session = nil; + res.json([ok: true]); +end fun; + +var s = HttpServer(8080); +s.Use(HttpLogger()); +s.Use(HttpSession([name: 'sid', ttl: 86400, maxAge: 0, sameSite: 'Lax'])); +s.get('/api/me', meH); +s.post('/api/login', loginH); +s.get('/api/count', countH); +s.post('/api/logout', logoutH); +s.static('/', 'web'); +s.run(); diff --git a/fun/demos/http-session-demo/web/index.html b/fun/demos/http-session-demo/web/index.html new file mode 100644 index 0000000..cee1b0b --- /dev/null +++ b/fun/demos/http-session-demo/web/index.html @@ -0,0 +1,52 @@ + + + + + + Fun session demo + + + +

Fun session demo

+

Cookie sid carries only a random session id; data stays on the server.

+

+ + + + +
Click a button.
+ + + + diff --git a/fun/lib/http-server.fun b/fun/lib/http-server.fun index cecea49..6341fa7 100644 --- a/fun/lib/http-server.fun +++ b/fun/lib/http-server.fun @@ -46,6 +46,8 @@ // * HttpServer.Use runs before route matching (all requests, including // 404/405); HttpGroup.Use runs after a route matches. Put request-wide // middleware such as CORS on the server, before authentication. +// * res.beforeSend(fn) runs fn(res, body) just before headers are written; +// session/cookie middleware uses it to add or clear cookies. // * the middleware continuation parameter is named `nx` because `next` // (like `step`) is reserved by the language. // * routing rules: literal segments beat `:name` parameters, which beat the @@ -438,6 +440,7 @@ class HttpRes() var code = 0; var hdrs = nil; var sent = false; + var preSend = nil; fun status(c) code = c; @@ -472,10 +475,26 @@ class HttpRes() result = this; end fun; + // Run before the response headers are written; used by session/cookie + // middleware to add or clear cookies after the handler has run. + fun beforeSend(fx) + if preSend = nil then preSend = new []; end if; + preSend.@add(fx); + result = this; + end fun; + fun send(s) if sent then raise 'http-server: response already sent'; end if; + if preSend <> nil then + var ps = preSend; + preSend = nil; + for i = 0 to ps.@count() - 1 do + var f = ps[i]; + f(this, s); + end do; + end if; sent = true; if code <= 0 then code = 200; diff --git a/fun/lib/http-session.fun b/fun/lib/http-session.fun new file mode 100644 index 0000000..92622a0 --- /dev/null +++ b/fun/lib/http-session.fun @@ -0,0 +1,227 @@ +// Copyright (c) 2010-2026 Zhang Weidong +// SPDX-License-Identifier: MIT +// +// http-session: cookie-backed server-side sessions (RFC 6265). +// +// s.Use(HttpSession([ +// name: 'sid', // cookie name +// ttl: 86400, // server-side seconds +// maxAge: 0, // cookie seconds; 0 = browser session cookie +// secure: true, // HTTPS only +// sameSite: 'Lax' +// ])); +// +// req.session['user'] = 'alice'; +// SessionRegenerate(req); // after login (new SID) +// req.session = nil; // logout +// +// Options: name, ttl, maxAge, secure, sameSite, domain, path, httpOnly, and +// store (get(sid), set(sid, data, ttl), destroy(sid)). +// The cookie carries only a random SID; data stays in the store. Session data +// must be JSON-serializable (it is compared before/after the handler). TTL is +// refreshed when the session is saved. CSRF is not included: SameSite helps, +// but state-changing routes still need a CSRF token. +use 'uuid.fun'; + +fun _sessNow() + var p = -2; // QPC counter + var f = -1; // QPC frequency + result = p.time() div (f.time() div 1000); +end fun; + +class CMemStore() + var map = new []; + var lastGc = 0; + + fun gc() + var now = _sessNow(); + var m = new []; + for k: v in map do + if v['expires'] > now then m[k] = v; end if; + end do; + map = m; + lastGc = now; + end fun; + + fun drop(sid) + var m = new []; + for k: v in map do + if k <> sid then m[k] = v; end if; + end do; + map = m; + end fun; + + fun get(sid) + var e = map[sid]; + if e = nil then result = nil; return; end if; + if e['expires'] <= _sessNow() then + this.drop(sid); + result = nil; + return; + end if; + result = e['data']; + end fun; + + fun set(sid, data, ttl) + var now = _sessNow(); + if map.@count() > 128 and now - lastGc > 60000 then this.gc(); end if; + var e = new []; + e['data'] = data; + e['expires'] = now + ttl * 1000; + map[sid] = e; + result = true; + end fun; + + fun destroy(sid) + if map[sid] <> nil then this.drop(sid); end if; + result = true; + end fun; +end class; + +fun _sessionFlush(res, body) + var ctx = res.sessionCtx; + if ctx = nil then return; end if; + res.sessionCtx = nil; + + var req = ctx['req']; + var store = ctx['store']; + var name = ctx['name']; + var cookieOpts = ctx['cookieOpts']; + var clearOpts = ctx['clearOpts']; + var ttl = ctx['ttl']; + var sid = ctx['sid']; + var stale = ctx['stale']; + var had = ctx['had']; + var sess = req.session; + var regen = req.sessionRegen = true; + req.sessionRegen = false; + + if sess = nil then + if sid <> nil then store.destroy(sid); end if; + if had then res.clearCookie(name, clearOpts); end if; + return; + end if; + + var after = sess.@toJson(json: true); + if regen then + if sid <> nil then store.destroy(sid); end if; + sid = UUID(); + store.set(sid, sess, ttl); + res.cookie(name, sid, cookieOpts); + elsif after <> ctx['before'] then + if sid = nil then + sid = UUID(); + store.set(sid, sess, ttl); + res.cookie(name, sid, cookieOpts); + else + store.set(sid, sess, ttl); + end if; + elsif stale and had then + res.clearCookie(name, clearOpts); + end if; +end fun; + +class CSession(o) + var name = 'sid'; + var ttl = 86400; + var maxAge = 0; + var secure = false; + var sameSite = 'Lax'; + var domain = nil; + var path = '/'; + var httpOnly = true; + var store = nil; + var cookieOpts = new []; + var clearOpts = new []; + + if o <> nil then + if o.@has('name') and o['name'] <> nil and o['name'] <> '' then name = '' & o['name']; end if; + if o.@has('ttl') then + ttl = o['ttl']; + if ttl < 0 then raise 'http-session: ttl must be >= 0'; end if; + end if; + if o.@has('maxAge') then + maxAge = o['maxAge']; + if maxAge < 0 then raise 'http-session: maxAge must be >= 0'; end if; + end if; + if o.@has('secure') and o['secure'] = true then secure = true; end if; + if o.@has('httpOnly') then httpOnly = (o['httpOnly'] = true); end if; + if o.@has('domain') and o['domain'] <> nil and o['domain'] <> '' then domain = '' & o['domain']; end if; + if o.@has('path') and o['path'] <> nil and o['path'] <> '' then path = '' & o['path']; end if; + if o.@has('sameSite') and o['sameSite'] <> nil then + var ss = ('' & o['sameSite']).upper(); + if ss = 'LAX' then sameSite = 'Lax'; + elsif ss = 'STRICT' then sameSite = 'Strict'; + elsif ss = 'NONE' then sameSite = 'None'; + else raise 'http-session: sameSite must be Lax, Strict or None'; end if; + end if; + if o.@has('store') then store = o['store']; end if; + end if; + if sameSite = 'None' and not secure then + raise 'http-session: sameSite None requires secure'; + end if; + if store = nil then store = CMemStore(); end if; + + var co = new []; + co['path'] = path; + co['httpOnly'] = httpOnly; + co['sameSite'] = sameSite; + if secure then co['secure'] = true; end if; + if domain <> nil then co['domain'] = domain; end if; + if maxAge > 0 then co['maxAge'] = maxAge; end if; + cookieOpts = co; + + var cl = new []; + cl['path'] = path; + cl['httpOnly'] = httpOnly; + cl['sameSite'] = sameSite; + if secure then cl['secure'] = true; end if; + if domain <> nil then cl['domain'] = domain; end if; + clearOpts = cl; + + fun run(req, res, nx) + var h = req.cookie(name); + var sid = nil; + var data = nil; + var stale = false; + var had = false; + if h <> nil and h <> '' then + had = true; + sid = h; + data = store.get(sid); + if data = nil then + sid = nil; + stale = true; + else + data = data.@toJson(json: true).getJson(json: true); + end if; + end if; + if data = nil then data = new []; end if; + req.session = data; + req.sessionRegen = false; + + var ctx = new []; + ctx['req'] = req; + ctx['store'] = store; + ctx['name'] = name; + ctx['cookieOpts'] = cookieOpts; + ctx['clearOpts'] = clearOpts; + ctx['ttl'] = ttl; + ctx['sid'] = sid; + ctx['stale'] = stale; + ctx['had'] = had; + ctx['before'] = data.@toJson(json: true); + res.sessionCtx = ctx; + res.beforeSend(_sessionFlush); + nx(); + end fun; +end class; + +fun HttpSession(opts) + result = CSession(opts); +end fun; + +fun SessionRegenerate(req) + req.sessionRegen = true; + result = true; +end fun; diff --git a/fun/test/expected/lib-http-session-test.fun.txt b/fun/test/expected/lib-http-session-test.fun.txt new file mode 100644 index 0000000..ad58053 --- /dev/null +++ b/fun/test/expected/lib-http-session-test.fun.txt @@ -0,0 +1,26 @@ +session read empty: True +session read no cookie: True +session write cookie: True +session cookie httponly: True +session cookie samesite: True +session cookie path: True +session read user: True +session read no resend: True +session inc 1: True +session inc no cookie: True +session inc 2: True +session isolated: True +session regen cookie: True +session old sid dead: True +session new sid alive: True +session logout clears: True +session logout dead: True +session stale clear: True +session secure: True +session strict: True +session maxage: True +session ttl expired: True +session ttl stale clear: True +session custom store: True +session none requires secure: True +ok diff --git a/fun/test/expected/lib-loadcheck.sh.txt b/fun/test/expected/lib-loadcheck.sh.txt index 4e06b2c..0b1e56e 100644 --- a/fun/test/expected/lib-loadcheck.sh.txt +++ b/fun/test/expected/lib-loadcheck.sh.txt @@ -3,6 +3,7 @@ LOAD http-bearer LOAD http-client LOAD http-cors LOAD http-server +LOAD http-session LOAD lib-ado-schema LOAD lib-ado LOAD lib-ajax @@ -67,4 +68,4 @@ LOAD lib-zlib LOAD test LOAD uuid -loaded=62 failed=6 \ No newline at end of file +loaded=63 failed=6 \ No newline at end of file diff --git a/fun/test/expected/macos/lib-loadcheck.sh.txt b/fun/test/expected/macos/lib-loadcheck.sh.txt index 6080b73..ef4c8ec 100644 --- a/fun/test/expected/macos/lib-loadcheck.sh.txt +++ b/fun/test/expected/macos/lib-loadcheck.sh.txt @@ -3,6 +3,7 @@ LOAD http-bearer LOAD http-client LOAD http-cors LOAD http-server +LOAD http-session LOAD lib-ado-schema LOAD lib-ado LOAD lib-ajax @@ -67,4 +68,4 @@ LOAD lib-zlib LOAD test LOAD uuid -loaded=62 failed=6 \ No newline at end of file +loaded=63 failed=6 \ No newline at end of file diff --git a/fun/test/lib-http-session-test.fun b/fun/test/lib-http-session-test.fun new file mode 100644 index 0000000..8f4b96b --- /dev/null +++ b/fun/test/lib-http-session-test.fun @@ -0,0 +1,256 @@ +// fun/lib/http-session: self-driven checks. Raw requests go out over +// ConnectSocket and the server is pumped with runOnce, so every assertion is +// a stable boolean snapshot. +use 'fun/lib/http-server.fun'; +use 'fun/lib/http-session.fun'; + +fun hasRe(s, re) + result = false; + if s =~ re then result = true; end if; +end fun; + +fun pump(s, n) + for i = 1 to n loop + exit when s.runOnce(300) = 0; + end loop; +end fun; + +fun rq(port, raw) + var c = ConnectSocket('127.0.0.1', port); + SendAll(c, raw.escape()); + result = c; +end fun; + +fun getReq(port, path) + result = rq(port, 'GET ' & path & ' HTTP/1.1\r\nHost: t\r\n\r\n'); +end fun; + +fun getSid(port, path, sid) + result = rq(port, 'GET ' & path & ' HTTP/1.1\r\nHost: t\r\nCookie: sid=' & sid & '\r\n\r\n'); +end fun; + +// Set-Cookie: sid=; ... +fun cookieSid(resp, name) + result = ''; + var lines = split(/\r?\n/, resp); + var key = 'Set-Cookie: ' & name & '='; + for i = 0 to lines.@count() - 1 do + var ln = lines[i]; + if ln.subpos(key) = 0 then + var v = ln.substr(key.length()); + var e = v.subpos(';'); + if e >= 0 then v = v.substr(0, e); end if; + result = v; + return; + end if; + end do; +end fun; + +fun readH(req, res) + if req.session['user'] = nil then + res.text('none'); + else + res.text('user:' & req.session['user']); + end if; +end fun; + +fun writeH(req, res) + req.session['user'] = 'alice'; + res.text('written'); +end fun; + +fun incH(req, res) + req.session['count'] = (req.session['count'] or 0) + 1; + res.text('count:' & req.session['count']); +end fun; + +fun logoutH(req, res) + req.session = nil; + res.text('out'); +end fun; + +fun regenH(req, res) + req.session['user'] = 'alice'; + SessionRegenerate(req); + res.text('regen'); +end fun; + +class CTestStore() + var map = new []; + + fun get(sid) + result = map[sid]; + end fun; + + fun set(sid, data, ttl) + map[sid] = data; + result = true; + end fun; + + fun destroy(sid) + map[sid] = nil; + result = true; + end fun; +end class; + +var s = HttpServer(0); +s.Use(HttpSession([name: 'sid', ttl: 60, maxAge: 0, sameSite: 'Lax'])); +s.get('/read', readH); +s.get('/write', writeH); +s.get('/inc', incH); +s.get('/logout', logoutH); +s.get('/regen', regenH); +var p = s.listen(); + +// 1. read-only request: no session, no cookie +var c1 = getReq(p, '/read'); +pump(s, 5); +var r1 = RecvSome(c1, 4096); +?. 'session read empty: ' & hasRe(r1, %\r\n\r\nnone$%); +?. 'session read no cookie: ' & not hasRe(r1, %Set-Cookie%); +CloseSocket(c1); + +// 2. write creates a cookie with secure defaults +var c2 = getReq(p, '/write'); +pump(s, 5); +var r2 = RecvSome(c2, 4096); +var sid1 = cookieSid(r2, 'sid'); +?. 'session write cookie: ' & (sid1.length() = 36); +?. 'session cookie httponly: ' & hasRe(r2, %HttpOnly%); +?. 'session cookie samesite: ' & hasRe(r2, %SameSite=Lax%); +?. 'session cookie path: ' & hasRe(r2, %Path=/%); +CloseSocket(c2); + +// 3. next request loads the session +var c3 = getSid(p, '/read', sid1); +pump(s, 5); +var r3 = RecvSome(c3, 4096); +?. 'session read user: ' & hasRe(r3, %\r\n\r\nuser:alice$%); +?. 'session read no resend: ' & not hasRe(r3, %Set-Cookie%); +CloseSocket(c3); + +// 4. existing session mutation persists without a new cookie +var c4 = getSid(p, '/inc', sid1); +pump(s, 5); +var r4 = RecvSome(c4, 4096); +?. 'session inc 1: ' & hasRe(r4, %\r\n\r\ncount:1$%); +?. 'session inc no cookie: ' & not hasRe(r4, %Set-Cookie%); +CloseSocket(c4); + +var c5 = getSid(p, '/inc', sid1); +pump(s, 5); +var r5 = RecvSome(c5, 4096); +?. 'session inc 2: ' & hasRe(r5, %\r\n\r\ncount:2$%); +CloseSocket(c5); + +// 5. another client has no session +var c6 = getReq(p, '/read'); +pump(s, 5); +var r6 = RecvSome(c6, 4096); +?. 'session isolated: ' & hasRe(r6, %\r\n\r\nnone$%); +CloseSocket(c6); + +// 6. regenerate rotates the SID, old SID dies, data survives +var c7 = getSid(p, '/regen', sid1); +pump(s, 5); +var r7 = RecvSome(c7, 4096); +var sid2 = cookieSid(r7, 'sid'); +?. 'session regen cookie: ' & (sid2.length() = 36 and sid2 <> sid1); +CloseSocket(c7); + +var c8 = getSid(p, '/read', sid1); +pump(s, 5); +var r8 = RecvSome(c8, 4096); +?. 'session old sid dead: ' & hasRe(r8, %\r\n\r\nnone$%); +CloseSocket(c8); + +var c9 = getSid(p, '/read', sid2); +pump(s, 5); +var r9 = RecvSome(c9, 4096); +?. 'session new sid alive: ' & hasRe(r9, %\r\n\r\nuser:alice$%); +CloseSocket(c9); + +// 7. logout clears the cookie and the store +var c10 = getSid(p, '/logout', sid2); +pump(s, 5); +var r10 = RecvSome(c10, 4096); +?. 'session logout clears: ' & hasRe(r10, %Set-Cookie:\x20sid=;\x20Path=/;\x20Max-Age=0%); +CloseSocket(c10); + +var c11 = getSid(p, '/read', sid2); +pump(s, 5); +var r11 = RecvSome(c11, 4096); +?. 'session logout dead: ' & hasRe(r11, %\r\n\r\nnone$%); +CloseSocket(c11); + +// 8. unknown SID is treated as stale and cleared +var c12 = getSid(p, '/read', 'bogus'); +pump(s, 5); +var r12 = RecvSome(c12, 4096); +?. 'session stale clear: ' & hasRe(r12, %Set-Cookie:\x20sid=;\x20Path=/;\x20Max-Age=0%); +CloseSocket(c12); + +s.stop(); + +// 9. secure + sameSite Strict + cookie maxAge +var s2 = HttpServer(0); +s2.Use(HttpSession([secure: true, sameSite: 'Strict', maxAge: 120])); +s2.get('/write', writeH); +var p2 = s2.listen(); +var c13 = getReq(p2, '/write'); +pump(s2, 5); +var r13 = RecvSome(c13, 4096); +?. 'session secure: ' & hasRe(r13, %Secure%); +?. 'session strict: ' & hasRe(r13, %SameSite=Strict%); +?. 'session maxage: ' & hasRe(r13, %Max-Age=120%); +CloseSocket(c13); +s2.stop(); + +// 10. TTL expiry +var s3 = HttpServer(0); +s3.Use(HttpSession([ttl: 0])); +s3.get('/write', writeH); +s3.get('/read', readH); +var p3 = s3.listen(); +var c14 = getReq(p3, '/write'); +pump(s3, 5); +var r14 = RecvSome(c14, 4096); +var sid3 = cookieSid(r14, 'sid'); +CloseSocket(c14); +var c15 = getSid(p3, '/read', sid3); +pump(s3, 5); +var r15 = RecvSome(c15, 4096); +?. 'session ttl expired: ' & hasRe(r15, %\r\n\r\nnone$%); +?. 'session ttl stale clear: ' & hasRe(r15, %Set-Cookie:\x20sid=;%); +CloseSocket(c15); +s3.stop(); + +// 11. custom store interface +var st = CTestStore(); +var s4 = HttpServer(0); +s4.Use(HttpSession([store: st])); +s4.get('/write', writeH); +s4.get('/read', readH); +var p4 = s4.listen(); +var c16 = getReq(p4, '/write'); +pump(s4, 5); +var r16 = RecvSome(c16, 4096); +var sid4 = cookieSid(r16, 'sid'); +CloseSocket(c16); +var c17 = getSid(p4, '/read', sid4); +pump(s4, 5); +var r17 = RecvSome(c17, 4096); +?. 'session custom store: ' & hasRe(r17, %\r\n\r\nuser:alice$%); +CloseSocket(c17); +s4.stop(); + +// 12. SameSite=None requires Secure +var bad = false; +try + var x = HttpSession([sameSite: 'None']); +except + bad = true; +end try; +?. 'session none requires secure: ' & bad; + +?. 'ok'; diff --git a/fun/test/regression-list.txt b/fun/test/regression-list.txt index 51fc4b4..46e10f9 100644 --- a/fun/test/regression-list.txt +++ b/fun/test/regression-list.txt @@ -57,6 +57,7 @@ lib-hash-test.fun test/lib-hash-test.fun snap lib-http-test.fun test/lib-http-test.fun snap lib-http-bearer-test.fun test/lib-http-bearer-test.fun snap lib-http-cors-test.fun test/lib-http-cors-test.fun snap +lib-http-session-test.fun test/lib-http-session-test.fun snap lib-proc-test.fun test/lib-proc-test.fun snap lib-unicode-test.fun test/lib-unicode-test.fun snap lib-bnf-dedup.fun test/lib-bnf-dedup.fun snap diff --git a/fun/test/run-windows-regression.bat b/fun/test/run-windows-regression.bat index 6c5ff51..66211f5 100644 --- a/fun/test/run-windows-regression.bat +++ b/fun/test/run-windows-regression.bat @@ -63,7 +63,7 @@ rem The four portable FFI cases (ffi-int64/float/float-cb/callbacks) are NOT rem listed here: they only need getapi plus libffitest.dll, so they run rem when both are there and are added below when they are not. ffi-scalar rem stays: it calls libc/libm directly (getpid does not exist on Windows). -set "HOSTDEP= builtin-dispatch.fun lib-host-test.fun lib-param-linux.fun lib-cmdline-linux.fun lib-loadcheck.sh libc-outbuf.fun ptr-primitives.fun raw-address-getapi.fun lib-tcc-test.fun lib-jit-test.fun lib-asm-lnx.fun lib-asm-a64.fun lib-asm-x64.fun fractal-asm-x64.fun lib-hash-test.fun lib-http-test.fun lib-http-bearer-test.fun lib-http-cors-test.fun lib-proc-test.fun lib-unicode-test.fun run-benchmarks.sh ffi-scalar.fun lib-os-dispatch-prototype.fun lib-crypt-lnx.fun lib-e2e-lnx.fun lib-ajax-lnx-test.fun run-ajax-lnx.sh lib-winsock-lnx-test.fun lib-netif-test.fun lib-ado-lnx-test.fun lib-ado-schema-lnx-test.fun lib-xml-lnx-test.fun lib-jsonrpc-lnx-test.fun lib-orm-rpc-lnx-test.fun lib-os-lnx-test.fun lib-cmdline-lnx-test.fun lib-zlib-test.fun lib-orm-rows.fun lib-file-test.fun lib-dyns-test.fun " +set "HOSTDEP= builtin-dispatch.fun lib-host-test.fun lib-param-linux.fun lib-cmdline-linux.fun lib-loadcheck.sh libc-outbuf.fun ptr-primitives.fun raw-address-getapi.fun lib-tcc-test.fun lib-jit-test.fun lib-asm-lnx.fun lib-asm-a64.fun lib-asm-x64.fun fractal-asm-x64.fun lib-hash-test.fun lib-http-test.fun lib-http-bearer-test.fun lib-http-cors-test.fun lib-http-session-test.fun lib-proc-test.fun lib-unicode-test.fun run-benchmarks.sh ffi-scalar.fun lib-os-dispatch-prototype.fun lib-crypt-lnx.fun lib-e2e-lnx.fun lib-ajax-lnx-test.fun run-ajax-lnx.sh lib-winsock-lnx-test.fun lib-netif-test.fun lib-ado-lnx-test.fun lib-ado-schema-lnx-test.fun lib-xml-lnx-test.fun lib-jsonrpc-lnx-test.fun lib-orm-rpc-lnx-test.fun lib-os-lnx-test.fun lib-cmdline-lnx-test.fun lib-zlib-test.fun lib-orm-rows.fun lib-file-test.fun lib-dyns-test.fun " rem Tests gated only by the regex engine's absence: probed below, skipped while rem REGEXOK=0. The CI win64 build links PCRE dynamically (pcre3.dll next to