Rework http to not reinvent the wheel for cookies, add fs.getScriptDir()

master
Ondřej Hruška 2 weeks ago
parent 154f0b97bf
commit ccdbf1307c
  1. 144
      Cargo.lock
  2. 14
      Cargo.toml
  3. 7
      Makefile
  4. 16411
      assets/public_suffix_list.dat
  5. 20
      docs/fs.md
  6. 65
      docs/http.md
  7. 9
      lua/stubs/fs.lua
  8. 14
      lua/stubs/http.lua
  9. 19
      src/lua_tests/fs_tests.rs
  10. 543
      src/lua_tests/http_tests.rs
  11. 1
      src/lua_tests/mod.rs
  12. 2
      src/main.rs
  13. 26
      src/stdlib/fs/mod.rs
  14. 748
      src/stdlib/http.rs

144
Cargo.lock generated

@ -10,15 +10,19 @@ dependencies = [
"chrono", "chrono",
"clap", "clap",
"colored", "colored",
"cookie_store",
"crossterm", "crossterm",
"digest_auth", "digest_auth",
"dotenvy", "dotenvy",
"env_logger", "env_logger",
"form_urlencoded",
"futures", "futures",
"inquire", "inquire",
"log", "log",
"mlua", "mlua",
"publicsuffix",
"reqwest", "reqwest",
"reqwest_cookie_store",
"rusqlite", "rusqlite",
"rustix", "rustix",
"rustls", "rustls",
@ -27,6 +31,7 @@ dependencies = [
"serde_json", "serde_json",
"tempfile", "tempfile",
"thiserror", "thiserror",
"tiny_http",
"tokio", "tokio",
] ]
@ -116,6 +121,12 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@ -212,6 +223,12 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.1" version = "4.6.1"
@ -295,6 +312,35 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@ -399,6 +445,12 @@ dependencies = [
"thiserror", "thiserror",
] ]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@ -835,6 +887,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.10.1" version = "1.10.1"
@ -1311,6 +1369,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@ -1412,6 +1476,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@ -1452,6 +1522,22 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@ -1553,6 +1639,8 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"cookie",
"cookie_store",
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
"h2", "h2",
@ -1582,6 +1670,18 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "reqwest_cookie_store"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "beb98c4d52ae6ceed18a0dff0564717623dd75fae08619ae0927332f0a81abbc"
dependencies = [
"bytes",
"cookie_store",
"reqwest",
"url",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@ -2011,7 +2111,7 @@ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -2043,6 +2143,48 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "time"
version = "0.3.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny_http"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
dependencies = [
"ascii",
"chunked_transfer",
"httpdate",
"log",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.3" version = "0.8.3"

@ -29,7 +29,7 @@ mlua = { version = "0.11.6", features = ["lua55", "vendored", "serde", "async",
# Use rustls with the self-contained, vendored `ring` provider (installed as the # Use rustls with the self-contained, vendored `ring` provider (installed as the
# process default in stdlib::http). `rustls-no-provider` stops reqwest from # process default in stdlib::http). `rustls-no-provider` stops reqwest from
# pulling aws-lc-rs back in. # pulling aws-lc-rs back in.
reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "charset", "http2", "system-proxy"] } reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "charset", "http2", "system-proxy", "cookies"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
# flock() guarding the fs-permission store (access.json) against concurrently # flock() guarding the fs-permission store (access.json) against concurrently
@ -41,6 +41,18 @@ serde_json = "1.0.150"
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["full"] } tokio = { version = "1.52.3", features = ["full"] }
digest_auth = "0.3" digest_auth = "0.3"
# RFC 6265 cookie jar for http sessions; `public_suffix` rejects cookies scoped
# to a whole TLD (Domain=com) using the vendored assets/public_suffix_list.dat.
cookie_store = { version = "0.22.1", features = ["public_suffix"] }
# Mutex wrapper making cookie_store usable as reqwest's cookie provider.
reqwest_cookie_store = "0.10.0"
# WHATWG application/x-www-form-urlencoded serializer for opts.form; already in
# the tree as a mandatory dependency of url (via reqwest).
form_urlencoded = "1.2.2"
# Public-suffix-list parser consumed by cookie_store's `public_suffix` feature.
publicsuffix = "2.3.0"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
# Local HTTP server for the http module's end-to-end tests.
tiny_http = "0.12.0"

@ -1,4 +1,9 @@
.PHONY: make .PHONY: make update-psl
make: make:
cargo build --release cargo build --release
# Refresh the vendored Public Suffix List used for cookie domain checks
# (compiled into the binary by stdlib::http).
update-psl:
curl -sfL https://publicsuffix.org/list/public_suffix_list.dat -o assets/public_suffix_list.dat

File diff suppressed because it is too large Load Diff

@ -46,14 +46,14 @@ permission system. These never error: a nonexistent path is `false` without
any permission prompt; an unknown permission prompts like any other read (a any permission prompt; an unknown permission prompts like any other read (a
denial makes the result `false`). denial makes the result `false`).
### `fs.getWorkdir() -> string` ### `fs.getWorkDir() -> string`
The process's current working directory, as an absolute path. Relative paths The process's current working directory, as an absolute path. Relative paths
given to the other fs functions resolve against it, so it is the natural base given to the other fs functions resolve against it, so it is the natural base
for building paths to probe with `fs.access`: for building paths to probe with `fs.access`:
```lua ```lua
local cwd = fs.getWorkdir() local cwd = fs.getWorkDir()
if fs.access(cwd, "w") then if fs.access(cwd, "w") then
fs.writeAll("report.txt", text) -- relative = cwd .. "/report.txt" fs.writeAll("report.txt", text) -- relative = cwd .. "/report.txt"
end end
@ -61,6 +61,22 @@ end
Not permission-gated — it reports process state and touches no file content. Not permission-gated — it reports process state and touches no file content.
### `fs.getScriptDir() -> string|nil`
The directory the main script really lives in, as an absolute path with
symlinks resolved — an installed script invoked through a `~/.local/bin`
symlink gets the directory of the real file. Unlike the working directory,
this doesn't depend on where the script was invoked from, so it is the right
base for assets shipped alongside the script:
```lua
local tmpl = fs.readAll(fs.getScriptDir() .. "/templates/report.html")
```
Returns `nil` in the REPL, where there is no script. Not permission-gated —
it names a place and reads no file content (accessing files under it prompts
like anywhere else).
### `fs.access(dir, mode) -> boolean` ### `fs.access(dir, mode) -> boolean`
Runs the permission cycle for a directory explicitly — `mode` is `"r"` or Runs the permission cycle for a directory explicitly — `mode` is `"r"` or

@ -49,15 +49,20 @@ local resp = http.request("DELETE", "https://api.example.com/items/42")
Every request returns a table describing the response: Every request returns a table describing the response:
| Field | Type | Description | | Field | Type | Description |
|-----------|-----------|-------------------------------------------------------------------| |--------------|-----------|-------------------------------------------------------------------|
| `status` | integer | HTTP status code, e.g. `200`, `404`. | | `status` | integer | HTTP status code, e.g. `200`, `404`. |
| `ok` | boolean | `true` when `status` is in the 2xx range. | | `ok` | boolean | `true` when `status` is in the 2xx range. |
| `url` | string | The final URL, after any redirects. |
| `headers` | table | Response headers, keyed by **lowercase** name. | | `headers` | table | Response headers, keyed by **lowercase** name. |
| `setCookies` | table | Every `Set-Cookie` value on the final response, as an array. |
| `body` | string | The raw response body (Lua strings are byte sequences). | | `body` | string | The raw response body (Lua strings are byte sequences). |
| `json` | function | `resp.json()` parses `body` as JSON. See [JSON](#json). | | `json` | function | `resp.json()` parses `body` as JSON. See [JSON](#json). |
Header names are lowercased so you can look them up without guessing the server's Header names are lowercased so you can look them up without guessing the server's
capitalization. If a header appears more than once, the first value wins. capitalization. A header that appears more than once has its values joined with
`", "` (the HTTP list convention) — except `set-cookie`, whose values can contain
commas: `headers["set-cookie"]` holds only the first one, and the full list is in
`resp.setCookies`.
```lua ```lua
local resp = http.get("https://example.com") local resp = http.get("https://example.com")
@ -79,7 +84,7 @@ The optional `opts` table accepts these fields, all optional:
| `json` | any | Serialized to JSON; sets `Content-Type: application/json`. | | `json` | any | Serialized to JSON; sets `Content-Type: application/json`. |
| `form` | table | URL-encoded; sets `Content-Type: application/x-www-form-urlencoded`. | | `form` | table | URL-encoded; sets `Content-Type: application/x-www-form-urlencoded`. |
| `cookies` | table | Cookies for this request, `{session = "abc"}``Cookie:` header. | | `cookies` | table | Cookies for this request, `{session = "abc"}``Cookie:` header. |
| `timeout` | number | Per-request timeout in seconds (default `30`). | | `timeout` | number | Timeout in seconds for the whole request, redirects included (default `30`). |
| `auth` | table | Basic or digest credentials. See [Authentication](#authentication). | | `auth` | table | Basic or digest credentials. See [Authentication](#authentication). |
### Bodies ### Bodies
@ -117,7 +122,9 @@ local resp = http.get("https://example.com", {
``` ```
Per-request `cookies` are sent as a `Cookie` header. With a [session](#sessions), Per-request `cookies` are sent as a `Cookie` header. With a [session](#sessions),
they are merged with the jar and take precedence on a name collision. they are merged with the jar and take precedence on a name collision. Passing
`cookies` *and* a `Cookie` entry in `headers` is ambiguous and raises an error —
pick one.
## JSON ## JSON
@ -216,20 +223,28 @@ local page = s:get("https://example.com/dashboard")
### Cookie behaviour ### Cookie behaviour
A stored cookie is sent on a request when the request host **equals** the cookie's The jar implements RFC 6265 the way a browser does (it is the `cookie_store`
domain or is a subdomain of it. The jar reads the `name=value` pair and the crate underneath): `Domain`, `Path`, `Expires`, `Max-Age`, `Secure` and
`Domain` attribute from each `Set-Cookie` header; other attributes (`Path`, `HttpOnly` attributes all take effect. In particular:
`Expires`, `Secure`, `HttpOnly`) are ignored. When no `Domain` is given, the
request host is used.
`Set-Cookie` headers set on redirect responses are captured too, so a login that - A cookie is sent only to hosts the `Domain` attribute covers (the setting
`302`-redirects to a dashboard still records its cookie. host itself when absent), only on matching paths, and only until it expires —
a server deleting a cookie with `Max-Age=0` really removes it from the jar.
- A `Secure` cookie is never sent over plain `http://`.
- A response cannot set a cookie for an unrelated domain, nor for a public
suffix like `com` or `co.uk` — the jar checks Mozilla's Public Suffix List
(compiled into the binary), same as browsers and curl.
`Set-Cookie` headers on redirect responses are captured too, so a login that
`302`-redirects to a dashboard still records its cookie — and the redirected
request already carries it.
### Inspecting and clearing ### Inspecting and clearing
#### `s:cookies()` #### `s:cookies()`
Return the jar as a nested table, `{domain = {name = value}}`, for inspection. Return the unexpired cookies as a nested table, `{domain = {name = value}}`,
for inspection.
```lua ```lua
local jar = s:cookies() local jar = s:cookies()
@ -257,18 +272,16 @@ networking entirely: every request errors with
#### `s:save(path)` #### `s:save(path)`
Write the jar to `path` as JSONL — one JSON object per line, one cookie per line: Write the jar to `path` as JSONL — one JSON object per line, one cookie per
line, in `cookie_store`'s format (the full cookie: name, value, domain, path,
```jsonl expiry). Session cookies — ones without an `Expires`/`Max-Age`, which is what
{"domain":"example.com","name":"session","value":"abc123"} most login tokens are — are included.
{"domain":"api.example.com","name":"token","value":"xyz789"}
```
#### `s:load(path)` #### `s:load(path)`
Merge cookies from a JSONL file into the current jar (existing cookies are kept, Load a jar file written by `s:save`, **replacing** the current jar contents.
matching names overwritten). `http.session(path)` is the same as creating a `http.session(path)` is the same as creating a session and calling
session and calling `:load(path)`. `:load(path)`. A file that is not a saved jar raises an error.
```lua ```lua
-- First run: log in and persist -- First run: log in and persist
@ -284,9 +297,13 @@ local page = s:get("https://example.com/dashboard")
## Redirects ## Redirects
Redirects are followed automatically, up to 10 hops; you receive the final Redirects are followed automatically, up to 10 hops; you receive the final
response. A `303`, and a `301`/`302` in response to a `POST`, are followed as a response (`resp.url` tells you where you ended up). A `303`, and a `301`/`302`
bodyless `GET`, matching browser behaviour. For a session, cookies set along the in response to a `POST`, are followed as a bodyless `GET`, matching browser
way are captured at each hop. behaviour. For a session, cookies set along the way are captured at each hop.
When a redirect leaves the original host, the `Authorization` and `Cookie`
headers are dropped so credentials never reach a host the request was not
addressed to. Other custom headers follow the redirect, as in browsers and curl.
## Errors ## Errors

@ -46,7 +46,14 @@ function fs.isFile(path) end
---The process's current working directory (absolute path) — what relative ---The process's current working directory (absolute path) — what relative
---paths in the other fs functions resolve against. Not permission-gated. ---paths in the other fs functions resolve against. Not permission-gated.
---@return string dir ---@return string dir
function fs.getWorkdir() end function fs.getWorkDir() end
---The directory the main script really lives in (absolute path, symlinks
---resolved) — the stable base for loading assets shipped next to the script,
---independent of the CWD it was invoked from. nil in the REPL, where there
---is no script. Not permission-gated.
---@return string|nil dir
function fs.getScriptDir() end
---Run the permission cycle for a directory explicitly: recorded answer, else ---Run the permission cycle for a directory explicitly: recorded answer, else
---interactive prompt, else false (nothing recorded when there is no terminal ---interactive prompt, else false (nothing recorded when there is no terminal

@ -14,7 +14,9 @@ http = {}
---@class http.Response ---@class http.Response
---@field status integer HTTP status code, e.g. 200 ---@field status integer HTTP status code, e.g. 200
---@field ok boolean true when status is 2xx ---@field ok boolean true when status is 2xx
---@field headers table<string, string> response headers, keyed by lowercase name ---@field url string final URL after redirects
---@field headers table<string, string> response headers, keyed by lowercase name; repeats joined with ", " (except set-cookie: first value only, see setCookies)
---@field setCookies string[] every Set-Cookie header value on the final response
---@field body string raw response body ---@field body string raw response body
---@field json fun(): any parse body as JSON (JSON null becomes utils.NULL); raises on invalid JSON ---@field json fun(): any parse body as JSON (JSON null becomes utils.NULL); raises on invalid JSON
@ -27,8 +29,8 @@ http = {}
---@field headers table<string, string>? extra request headers ---@field headers table<string, string>? extra request headers
---@field body string? raw request body (set Content-Type yourself) ---@field body string? raw request body (set Content-Type yourself)
---@field json any? serialized to JSON; sets Content-Type: application/json ---@field json any? serialized to JSON; sets Content-Type: application/json
---@field form table<string, any>? URL-encoded; sets application/x-www-form-urlencoded ---@field form table<string, string|number>? URL-encoded; sets application/x-www-form-urlencoded
---@field cookies table<string, string>? cookies for this request ---@field cookies table<string, string>? cookies for this request (merged with the session jar; not combinable with a Cookie header)
---@field timeout number? per-request timeout in seconds (default 30) ---@field timeout number? per-request timeout in seconds (default 30)
---@field auth http.Auth? basic or digest credentials ---@field auth http.Auth? basic or digest credentials
@ -117,17 +119,17 @@ function Session:getJSON(url, opts) end
---@return http.Response ---@return http.Response
function Session:postJSON(url, body, opts) end function Session:postJSON(url, body, opts) end
---The jar as a nested table, {domain = {name = value}}. ---The unexpired cookies as a nested table, {domain = {name = value}}.
---@return table<string, table<string, string>> ---@return table<string, table<string, string>>
function Session:cookies() end function Session:cookies() end
---Empty the in-memory jar. ---Empty the in-memory jar.
function Session:clearCookies() end function Session:clearCookies() end
---Write the jar to a JSONL file (one cookie per line). ---Write the jar to a JSONL file (one cookie per line, including session cookies).
---@param path string ---@param path string
function Session:save(path) end function Session:save(path) end
---Merge cookies from a JSONL file into the jar (matching names overwritten). ---Load a jar file, replacing the current jar contents.
---@param path string ---@param path string
function Session:load(path) end function Session:load(path) end

@ -156,15 +156,30 @@ async fn access_validates_arguments() {
} }
#[tokio::test] #[tokio::test]
async fn get_workdir_returns_the_cwd_ungated() { async fn get_work_dir_returns_the_cwd_ungated() {
// Works even under the strictest policy: it reveals process state, not // Works even under the strictest policy: it reveals process state, not
// filesystem content. // filesystem content.
let lua = lua_with(FsPolicy::deny_all("--sandbox", false)); let lua = lua_with(FsPolicy::deny_all("--sandbox", false));
let cwd: String = lua.load("return fs.getWorkdir()").eval_async().await.unwrap(); let cwd: String = lua.load("return fs.getWorkDir()").eval_async().await.unwrap();
assert_eq!(std::path::PathBuf::from(&cwd), std::env::current_dir().unwrap()); assert_eq!(std::path::PathBuf::from(&cwd), std::env::current_dir().unwrap());
assert!(std::path::Path::new(&cwd).is_absolute()); assert!(std::path::Path::new(&cwd).is_absolute());
} }
#[tokio::test]
async fn get_script_dir_reports_planted_dir_ungated_and_nil_without_one() {
// No ScriptDir in app data (REPL semantics): nil.
let lua = lua_with(FsPolicy::deny_all("--sandbox", false));
let noscript: Option<String> =
lua.load("return fs.getScriptDir()").eval_async().await.unwrap();
assert_eq!(noscript, None);
// With the slot planted (as main does for a script), it comes back
// verbatim even under the strictest policy.
lua.set_app_data(crate::stdlib::fs::ScriptDir("/opt/tool".into()));
let dir: String = lua.load("return fs.getScriptDir()").eval_async().await.unwrap();
assert_eq!(dir, "/opt/tool");
}
#[tokio::test] #[tokio::test]
async fn deny_all_blocks_everything_without_prompting() { async fn deny_all_blocks_everything_without_prompting() {
let lua = lua_with(FsPolicy::deny_all("--no-fs", true)); let lua = lua_with(FsPolicy::deny_all("--no-fs", true));

@ -0,0 +1,543 @@
//! End-to-end tests for the http module against a local `tiny_http` server:
//! request building (form/json/headers/auth), redirect behaviour, the session
//! cookie jar, and jar persistence. Cookie *policy* (public suffix, Secure,
//! expiry) is unit-tested in `stdlib::http`.
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use super::lua as sandboxed_lua;
/// The stdlib Lua with networking and filesystem allowed (the default
/// test policy is deny-all), plus a `BASE` global pointing at `server`.
fn lua_net(server: &TestServer) -> mlua::Lua {
let lua = sandboxed_lua();
lua.set_app_data(Arc::new(crate::stdlib::fs::FsPolicy::allow_all()));
lua.globals().set("BASE", server.base.clone()).unwrap();
lua
}
/// A request as the server saw it, for asserting on from the test thread
/// (a panic inside the server thread would be silently swallowed).
#[derive(Clone, Debug)]
struct Captured {
method: String,
url: String,
headers: Vec<(String, String)>,
body: String,
}
impl Captured {
fn header(&self, name: &str) -> Option<&str> {
self.headers.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str())
}
}
struct TestServer {
base: String,
seen: Arc<Mutex<Vec<Captured>>>,
server: Arc<tiny_http::Server>,
thread: Option<JoinHandle<()>>,
}
impl TestServer {
/// All requests observed so far, in arrival order.
fn requests(&self) -> Vec<Captured> {
self.seen.lock().unwrap().clone()
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.server.unblock();
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// Spawn a local HTTP server; `handler` maps each captured request to a
/// response. Every request is also recorded for later assertions.
fn serve(
handler: impl Fn(&Captured) -> tiny_http::Response<std::io::Cursor<Vec<u8>>>
+ Send
+ Sync
+ 'static,
) -> TestServer {
let server = Arc::new(tiny_http::Server::http("127.0.0.1:0").unwrap());
let base = format!("http://{}", server.server_addr().to_ip().unwrap());
let seen: Arc<Mutex<Vec<Captured>>> = Arc::default();
let thread = {
let server = server.clone();
let seen = seen.clone();
std::thread::spawn(move || {
for mut req in server.incoming_requests() {
let mut body = String::new();
req.as_reader().read_to_string(&mut body).unwrap();
let captured = Captured {
method: req.method().to_string(),
url: req.url().to_string(),
headers: req
.headers()
.iter()
.map(|h| (h.field.to_string().to_ascii_lowercase(), h.value.to_string()))
.collect(),
body,
};
seen.lock().unwrap().push(captured.clone());
let _ = req.respond(handler(&captured));
}
})
};
TestServer { base, seen, server, thread: Some(thread) }
}
fn resp(
code: u16,
headers: &[(&str, &str)],
body: &str,
) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
let mut r = tiny_http::Response::from_data(body.as_bytes().to_vec()).with_status_code(code);
for (k, v) in headers {
r.add_header(tiny_http::Header::from_bytes(k.as_bytes(), v.as_bytes()).unwrap());
}
r
}
fn path_of(c: &Captured) -> &str {
c.url.split('?').next().unwrap()
}
#[tokio::test]
async fn test_get_response_fields() {
let server = serve(|_| resp(200, &[("X-One", "1")], "hello"));
let lua = lua_net(&server);
let (status, ok, body, x_one, url): (u16, bool, String, String, String) = lua
.load(
r#"
local r = http.get(BASE)
return r.status, r.ok, r.body, r.headers["x-one"], r.url
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!((status, ok), (200, true));
assert_eq!(body, "hello");
assert_eq!(x_one, "1");
assert_eq!(url, format!("{}/", server.base));
// The default User-Agent goes out unless overridden.
let reqs = server.requests();
assert!(reqs[0].header("user-agent").unwrap().starts_with("a/"), "{reqs:?}");
}
#[tokio::test]
async fn test_form_body_is_urlencoded() {
let server = serve(|_| resp(200, &[], ""));
let lua = lua_net(&server);
lua.load(r#"http.post(BASE, { form = { q = "a b&c=d", plain = "x", num = 5 } })"#)
.exec_async()
.await
.unwrap();
let req = &server.requests()[0];
assert_eq!(req.method, "POST");
assert_eq!(req.header("content-type"), Some("application/x-www-form-urlencoded"));
// Lua table order is unspecified; compare as a set of encoded pairs.
let mut pairs: Vec<&str> = req.body.split('&').collect();
pairs.sort();
assert_eq!(pairs, ["num=5", "plain=x", "q=a+b%26c%3Dd"]);
}
#[tokio::test]
async fn test_json_body_and_resp_json() {
let server =
serve(|c| resp(200, &[("Content-Type", "application/json")], &c.body.clone()));
let lua = lua_net(&server);
let hello: String = lua
.load(r#"return http.postJSON(BASE, { hello = "world" }).json().hello"#)
.eval_async()
.await
.unwrap();
assert_eq!(hello, "world");
let req = &server.requests()[0];
assert_eq!(req.header("content-type"), Some("application/json"));
assert!(super::json_eq(&req.body, r#"{"hello":"world"}"#));
}
#[tokio::test]
async fn test_user_content_type_wins_over_implied() {
let server = serve(|_| resp(200, &[], ""));
let lua = lua_net(&server);
lua.load(
r#"
http.post(BASE, {
form = { a = "1" },
headers = { ["Content-Type"] = "text/plain" },
})
"#,
)
.exec_async()
.await
.unwrap();
let req = &server.requests()[0];
let cts: Vec<&str> =
req.headers.iter().filter(|(k, _)| k == "content-type").map(|(_, v)| v.as_str()).collect();
assert_eq!(cts, ["text/plain"], "exactly one Content-Type, the caller's");
}
#[tokio::test]
async fn test_redirect_302_degrades_post_to_get() {
let server = serve(|c| match path_of(c) {
"/a" => resp(302, &[("Location", "/b")], ""),
"/b" => resp(200, &[], "final"),
p => resp(404, &[], p),
});
let lua = lua_net(&server);
let (status, body, url): (u16, String, String) = lua
.load(
r#"
local r = http.post(BASE .. "/a", { json = { x = 1 } })
return r.status, r.body, r.url
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!((status, body.as_str()), (200, "final"));
assert!(url.ends_with("/b"), "{url}");
let reqs = server.requests();
assert_eq!(reqs[1].method, "GET");
assert_eq!(reqs[1].body, "", "302-degraded GET must not carry the POST body");
}
#[tokio::test]
async fn test_redirect_307_preserves_method_and_body() {
let server = serve(|c| match path_of(c) {
"/a" => resp(307, &[("Location", "/b")], ""),
_ => resp(200, &[], ""),
});
let lua = lua_net(&server);
lua.load(r#"http.post(BASE .. "/a", { body = "payload" })"#).exec_async().await.unwrap();
let reqs = server.requests();
assert_eq!(reqs[1].method, "POST");
assert_eq!(reqs[1].body, "payload");
}
#[tokio::test]
async fn test_too_many_redirects_errors() {
let server = serve(|_| resp(302, &[("Location", "/loop")], ""));
let lua = lua_net(&server);
let err =
lua.load(r#"http.get(BASE .. "/loop")"#).exec_async().await.unwrap_err().to_string();
assert!(err.contains("http:") && err.contains("redirect"), "{err}");
}
#[tokio::test]
async fn test_session_captures_set_cookie_on_redirect() {
// The login pattern: Set-Cookie arrives on the 302 itself, and the
// redirected hop must already send it back.
let server = serve(|c| match path_of(c) {
"/login" => resp(302, &[("Location", "/dash"), ("Set-Cookie", "sid=abc; Path=/")], ""),
"/dash" => resp(200, &[], ""),
p => resp(404, &[], p),
});
let lua = lua_net(&server);
let (sid, n): (String, i64) = lua
.load(
r#"
local s = http.session()
s:get(BASE .. "/login")
local jar = s:cookies()
local domains = 0
for _ in pairs(jar) do domains = domains + 1 end
return jar["127.0.0.1"].sid, domains
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(sid, "abc");
assert_eq!(n, 1);
let reqs = server.requests();
assert_eq!(reqs[0].header("cookie"), None);
assert_eq!(reqs[1].header("cookie"), Some("sid=abc"), "jar cookie must reach /dash");
}
#[tokio::test]
async fn test_session_clear_cookies() {
let server = serve(|c| match path_of(c) {
"/set" => resp(200, &[("Set-Cookie", "sid=abc")], ""),
_ => resp(200, &[], ""),
});
let lua = lua_net(&server);
lua.load(
r#"
local s = http.session()
s:get(BASE .. "/set")
s:clearCookies()
assert(next(s:cookies()) == nil, "jar must be empty")
s:get(BASE .. "/check")
"#,
)
.exec_async()
.await
.unwrap();
let reqs = server.requests();
assert_eq!(reqs[1].header("cookie"), None);
}
#[tokio::test]
async fn test_per_request_cookies_merge_with_jar() {
let server = serve(|c| match path_of(c) {
"/set" => resp(200, &[("Set-Cookie", "a=1"), ("Set-Cookie", "keep=yes")], ""),
_ => resp(200, &[], ""),
});
let lua = lua_net(&server);
lua.load(
r#"
local s = http.session()
s:get(BASE .. "/set")
s:get(BASE .. "/check", { cookies = { a = "2", b = "3" } })
"#,
)
.exec_async()
.await
.unwrap();
let reqs = server.requests();
let mut got: Vec<&str> = reqs[1].header("cookie").unwrap().split("; ").collect();
got.sort();
// Jar cookie `keep` rides along, per-request `a` wins over the jar's.
assert_eq!(got, ["a=2", "b=3", "keep=yes"]);
}
#[tokio::test]
async fn test_cookies_opt_conflicts_with_cookie_header() {
let server = serve(|_| resp(200, &[], ""));
let lua = lua_net(&server);
let err = lua
.load(r#"http.get(BASE, { cookies = { a = "1" }, headers = { Cookie = "b=2" } })"#)
.exec_async()
.await
.unwrap_err()
.to_string();
assert!(err.contains("not both"), "{err}");
assert!(server.requests().is_empty(), "the request must not go out");
}
#[tokio::test]
async fn test_session_save_load_roundtrip() {
let server = serve(|c| match path_of(c) {
"/set" => resp(200, &[("Set-Cookie", "sid=abc")], ""),
_ => resp(200, &[], ""),
});
let lua = lua_net(&server);
let dir = tempfile::tempdir().unwrap();
let jar_path = dir.path().join("jar.jsonl");
lua.globals().set("JAR", jar_path.to_str().unwrap()).unwrap();
lua.load(
r#"
local s = http.session()
s:get(BASE .. "/set")
s:save(JAR)
-- A fresh session preloaded from the file sends the cookie again.
local s2 = http.session(JAR)
assert(s2:cookies()["127.0.0.1"].sid == "abc", "cookie must survive save/load")
s2:get(BASE .. "/check")
"#,
)
.exec_async()
.await
.unwrap();
let reqs = server.requests();
assert_eq!(reqs[1].header("cookie"), Some("sid=abc"));
}
#[tokio::test]
async fn test_basic_auth_header() {
let server = serve(|_| resp(200, &[], ""));
let lua = lua_net(&server);
lua.load(r#"http.get(BASE, { auth = { username = "alice", password = "secret" } })"#)
.exec_async()
.await
.unwrap();
// base64("alice:secret")
let auth = server.requests()[0].header("authorization").unwrap().to_string();
assert_eq!(auth, "Basic YWxpY2U6c2VjcmV0");
}
#[tokio::test]
async fn test_digest_auth_answers_challenge() {
let server = serve(|c| {
if c.header("authorization").is_none() {
resp(
401,
&[("WWW-Authenticate", r#"Digest realm="test", nonce="abc123", qop="auth""#)],
"",
)
} else {
resp(200, &[], "in")
}
});
let lua = lua_net(&server);
let (ok, body): (bool, String) = lua
.load(
r#"
local r = http.get(BASE .. "/p", {
auth = { username = "alice", password = "secret", scheme = "digest" },
})
return r.ok, r.body
"#,
)
.eval_async()
.await
.unwrap();
assert!(ok);
assert_eq!(body, "in");
let reqs = server.requests();
assert_eq!(reqs.len(), 2, "one challenge, one answer");
let auth = reqs[1].header("authorization").unwrap();
assert!(auth.starts_with("Digest "), "{auth}");
for part in [r#"username="alice""#, r#"uri="/p""#, "response="] {
assert!(auth.contains(part), "{auth} missing {part}");
}
}
#[tokio::test]
async fn test_cross_host_redirect_strips_credentials() {
// Two servers = two hosts (the port differs). Authorization and Cookie
// must not follow the redirect off the original host; ordinary custom
// headers do (matching browser/curl behaviour).
let target = serve(|_| resp(200, &[], "landed"));
let origin = {
let to = format!("{}/land", target.base);
serve(move |_| resp(302, &[("Location", &to.clone())], ""))
};
let lua = lua_net(&origin);
let body: String = lua
.load(
r#"
local r = http.get(BASE .. "/go", {
auth = { username = "alice", password = "secret" },
cookies = { sid = "abc" },
headers = { ["X-Custom"] = "keep" },
})
return r.body
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(body, "landed");
let first = &origin.requests()[0];
assert!(first.header("authorization").is_some());
assert!(first.header("cookie").is_some());
let landed = &target.requests()[0];
assert_eq!(landed.header("authorization"), None, "auth leaked cross-host");
assert_eq!(landed.header("cookie"), None, "cookies leaked cross-host");
assert_eq!(landed.header("x-custom"), Some("keep"));
}
#[tokio::test]
async fn test_multi_value_headers_and_set_cookies() {
let server = serve(|_| {
resp(
200,
&[
("X-Multi", "one"),
("X-Multi", "two"),
("Set-Cookie", "a=1; Path=/"),
("Set-Cookie", "b=2; Path=/"),
],
"",
)
});
let lua = lua_net(&server);
let (multi, first_sc, sc1, sc2): (String, String, String, String) = lua
.load(
r#"
local r = http.get(BASE)
assert(#r.setCookies == 2, "expected two Set-Cookie values")
return r.headers["x-multi"], r.headers["set-cookie"], r.setCookies[1], r.setCookies[2]
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(multi, "one, two");
assert_eq!(first_sc, "a=1; Path=/");
assert_eq!((sc1.as_str(), sc2.as_str()), ("a=1; Path=/", "b=2; Path=/"));
}
#[tokio::test]
async fn test_timeout_errors() {
let server = serve(|_| {
std::thread::sleep(std::time::Duration::from_secs(2));
resp(200, &[], "late")
});
let lua = lua_net(&server);
let start = std::time::Instant::now();
let err = lua
.load(r#"http.get(BASE, { timeout = 0.1 })"#)
.exec_async()
.await
.unwrap_err()
.to_string();
assert!(err.contains("http:"), "{err}");
assert!(start.elapsed() < std::time::Duration::from_secs(1), "timeout did not apply");
}
#[tokio::test]
async fn test_invalid_url_and_method() {
let server = serve(|_| resp(200, &[], ""));
let lua = lua_net(&server);
let err = lua.load(r#"http.get("not a url")"#).exec_async().await.unwrap_err().to_string();
assert!(err.contains("invalid URL"), "{err}");
let err = lua
.load(r#"http.request("BAD METHOD", BASE)"#)
.exec_async()
.await
.unwrap_err()
.to_string();
assert!(err.contains("invalid method"), "{err}");
assert!(server.requests().is_empty());
}

@ -4,6 +4,7 @@
mod async_tests; mod async_tests;
mod core_tests; mod core_tests;
mod fs_tests; mod fs_tests;
mod http_tests;
mod math_tests; mod math_tests;
mod os_tests; mod os_tests;
mod require_tests; mod require_tests;

@ -104,6 +104,8 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
.map_err(|e| format!("a: cannot open {}: {e}", filepath.display()))?; .map_err(|e| format!("a: cannot open {}: {e}", filepath.display()))?;
let script_dir = realpath.parent().unwrap_or(Path::new("/")); let script_dir = realpath.parent().unwrap_or(Path::new("/"));
setup_require(&lua, script_dir)?; setup_require(&lua, script_dir)?;
// Exposed to scripts as fs.getScriptDir() — the base for bundled assets.
lua.set_app_data(stdlib::fs::ScriptDir(script_dir.to_path_buf()));
// The canonical path doubles as the script's identity in the permission // The canonical path doubles as the script's identity in the permission
// store; this replaces the session-only default planted by fs::install. // store; this replaces the session-only default planted by fs::install.

@ -35,6 +35,11 @@ pub(crate) use policy::{FsPolicy, Wants};
#[cfg(test)] #[cfg(test)]
pub(crate) use prompt::Choice; pub(crate) use prompt::Choice;
/// App-data slot for the main script's real (symlink-resolved) directory.
/// `main` plants it before running a script; absent in the REPL and in bare
/// test states, where `fs.getScriptDir()` returns nil.
pub(crate) struct ScriptDir(pub(crate) PathBuf);
/// `mlua::Error::external(format!(...))` — every Lua-facing error in this /// `mlua::Error::external(format!(...))` — every Lua-facing error in this
/// module goes through this, prefixed `fs.<fn>: …`. /// module goes through this, prefixed `fs.<fn>: …`.
macro_rules! ext_err { macro_rules! ext_err {
@ -142,18 +147,35 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
)?; )?;
fs.set( fs.set(
"getWorkdir", "getWorkDir",
lua.create_function(|lua, ()| { lua.create_function(|lua, ()| {
// Process state, not filesystem content — no permission check. // Process state, not filesystem content — no permission check.
// This is what relative paths in the other fs functions (and in // This is what relative paths in the other fs functions (and in
// fs.access probes) resolve against. // fs.access probes) resolve against.
use std::os::unix::ffi::OsStrExt; use std::os::unix::ffi::OsStrExt;
let cwd = std::env::current_dir() let cwd = std::env::current_dir()
.map_err(|e| ext_err!("fs.getWorkdir: {e}"))?; .map_err(|e| ext_err!("fs.getWorkDir: {e}"))?;
lua.create_string(cwd.as_os_str().as_bytes()) lua.create_string(cwd.as_os_str().as_bytes())
})?, })?,
)?; )?;
fs.set(
"getScriptDir",
lua.create_function(|lua, ()| {
// The real (symlink-resolved) directory of the main script, set
// by main before execution — the stable base for a script's
// bundled assets, independent of the CWD it was invoked from.
// Not permission-gated: it names a place, reads no content.
match lua.app_data_ref::<ScriptDir>() {
Some(dir) => {
use std::os::unix::ffi::OsStrExt;
Ok(Some(lua.create_string(dir.0.as_os_str().as_bytes())?))
}
None => Ok(None), // REPL: no script to have a directory
}
})?,
)?;
fs.set( fs.set(
"access", "access",
lua.create_async_function(|lua, (dir, mode): (LuaString, LuaString)| async move { lua.create_async_function(|lua, (dir, mode): (LuaString, LuaString)| async move {

@ -1,212 +1,80 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, OnceLock};
use std::time::Duration; use std::time::Duration;
use cookie_store::CookieStore;
use mlua::prelude::LuaResult; use mlua::prelude::LuaResult;
use mlua::{Lua, Table as LuaTable, Value as LuaValue}; use mlua::{Lua, Table as LuaTable, Value as LuaValue};
use reqwest_cookie_store::CookieStoreMutex;
use crate::stdlib::utils::lua_to_json; use crate::stdlib::utils::lua_to_json;
const HTTP_LUA: &str = include_str!("../../lua/stdlib/http.lua"); const HTTP_LUA: &str = include_str!("../../lua/stdlib/http.lua");
const MAX_REDIRECTS: usize = 10;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Cookie jar // Cookie store
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// A minimal in-memory cookie jar: bare domain (no leading dot) → name → value. /// Mozilla's Public Suffix List, vendored so cookie domain checks work offline
/// Deliberately simple — it covers the >95% case of `Set-Cookie` flows without /// (the same list curl and the browsers ship). Refresh with `make update-psl`.
/// pulling in the `cookie_store` crate, and serializes cleanly to JSONL. fn public_suffix_list() -> &'static publicsuffix::List {
#[derive(Default)] static PSL: OnceLock<publicsuffix::List> = OnceLock::new();
struct CookieJar { PSL.get_or_init(|| {
cookies: HashMap<String, HashMap<String, String>>, include_str!("../../assets/public_suffix_list.dat")
} .parse()
.expect("vendored public suffix list must parse")
impl CookieJar { })
/// All cookies (name, value) whose stored domain matches `host`: either an
/// exact match or `host` being a subdomain of the stored domain.
fn cookies_for(&self, host: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for (domain, names) in &self.cookies {
let suffix = format!(".{domain}");
if host == domain || host.ends_with(&suffix) {
for (name, value) in names {
out.push((name.clone(), value.clone()));
}
}
}
out
}
/// Parse a single `Set-Cookie` header value and store the cookie. Extracts
/// `name=value` (first segment) and an optional `Domain=` attribute, falling
/// back to the request host. Malformed headers are ignored.
fn set_from_header(&mut self, host: &str, header: &str) {
let mut segments = header.split(';');
let first = match segments.next() {
Some(s) => s.trim(),
None => return,
};
let (name, value) = match first.split_once('=') {
Some((n, v)) => (n.trim(), v.trim()),
None => return,
};
if name.is_empty() {
return;
}
let request_host = host.to_ascii_lowercase();
let mut domain = request_host.clone();
for seg in segments {
if let Some((k, v)) = seg.split_once('=')
&& k.trim().eq_ignore_ascii_case("domain")
{
let d = v.trim().trim_start_matches('.').to_ascii_lowercase();
// Only accept a Domain the request host actually belongs to
// (exact match or a subdomain). Otherwise a response could plant
// a cookie scoped to an unrelated domain ("cookie tossing"); such
// an attribute is ignored and the cookie stays host-scoped.
if !d.is_empty() && (request_host == d || request_host.ends_with(&format!(".{d}"))) {
domain = d;
}
}
}
self.cookies
.entry(domain)
.or_default()
.insert(name.to_string(), value.to_string());
}
/// Serialize as JSONL — one `{"domain","name","value"}` object per line.
fn to_jsonl(&self) -> String {
let mut out = String::new();
for (domain, names) in &self.cookies {
for (name, value) in names {
let obj = serde_json::json!({
"domain": domain,
"name": name,
"value": value,
});
out.push_str(&obj.to_string());
out.push('\n');
}
}
out
} }
/// Merge cookies from JSONL produced by `to_jsonl`. Bad lines are skipped. /// An empty RFC 6265 cookie store with public-suffix checking, so a response
fn merge_jsonl(&mut self, src: &str) { /// cannot plant a cookie scoped to a whole TLD (`Domain=com`) or to an
for line in src.lines() { /// unrelated domain ("cookie tossing"). Domain, Path, Secure, HttpOnly,
let line = line.trim(); /// Expires and Max-Age all behave per spec — this is `cookie_store`, the same
if line.is_empty() { /// crate reqwest's own cookie support builds on.
continue; fn new_cookie_store() -> CookieStore {
} CookieStore::new_with_public_suffix(Some(public_suffix_list().clone()))
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let domain = v.get("domain").and_then(|x| x.as_str());
let name = v.get("name").and_then(|x| x.as_str());
let value = v.get("value").and_then(|x| x.as_str());
if let (Some(d), Some(n), Some(val)) = (domain, name, value) {
self.cookies
.entry(d.to_string())
.or_default()
.insert(n.to_string(), val.to_string());
}
}
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Request execution // Request options
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// `application/x-www-form-urlencoded` body from key/value pairs. Implemented /// `opts` parsed into owned values (the request may be built twice: once
/// inline so the build needs no optional reqwest features. /// plain, once more to answer a digest challenge).
fn form_urlencode(pairs: &[(String, String)]) -> String { #[derive(Default)]
fn encode(s: &str) -> String { struct RequestOpts {
let mut out = String::new(); timeout: Option<Duration>,
for b in s.bytes() { headers: Vec<(String, String)>,
match b { cookies: Vec<(String, String)>,
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { /// Body bytes, with the Content-Type it implies (None for a raw body).
out.push(b as char) body: Option<(Vec<u8>, Option<&'static str>)>,
} /// (is_digest, username, password).
b' ' => out.push('+'), auth: Option<(bool, String, String)>,
_ => out.push_str(&format!("%{b:02X}")), /// The caller set their own Content-Type header; it wins over the one a
} /// json/form body would otherwise imply (no silent override, no duplicate).
} has_user_content_type: bool,
out }
}
pairs fn parse_opts(opts: Option<&LuaTable>) -> LuaResult<RequestOpts> {
.iter() let mut o = RequestOpts::default();
.map(|(k, v)| format!("{}={}", encode(k), encode(v))) let Some(opts) = opts else { return Ok(o) };
.collect::<Vec<_>>()
.join("&")
}
const MAX_REDIRECTS: u32 = 10;
/// The origin of a URL: (scheme, host, effective port). Two URLs share an origin
/// when all three match — the boundary across which credentials and secret
/// headers must not be replayed on a redirect.
fn origin_of(u: &reqwest::Url) -> (String, Option<String>, Option<u16>) {
(
u.scheme().to_ascii_lowercase(),
u.host_str().map(|h| h.to_ascii_lowercase()),
u.port_or_known_default(),
)
}
/// Shared by the stateless `http.request` and a session's `:request`. When `jar`
/// is `Some`, the matching jar cookies are sent and any `Set-Cookie` responses
/// are stored back.
///
/// Redirects are followed manually (the client is built with
/// `redirect::Policy::none`) so that `Set-Cookie` headers on 30x responses —
/// the common login → redirect → dashboard pattern — are captured into the jar,
/// which reqwest's transparent redirect following would otherwise hide.
async fn execute_request(
lua: Lua,
client: reqwest::Client,
jar: Option<Arc<Mutex<CookieJar>>>,
method: String,
url: String,
opts: Option<LuaTable>,
) -> LuaResult<LuaTable> {
// Every request funnels through here (http.request, the shorthands, and
// all session methods) — the one choke point for --sandbox.
super::fs::ensure_net(&lua)?;
let mut method = reqwest::Method::from_bytes(method.to_ascii_uppercase().as_bytes())
.map_err(|e| mlua::Error::external(format!("http: invalid method '{method}': {e}")))?;
// Parse opts once into owned pieces so each redirect hop can rebuild the
// request (a reqwest RequestBuilder is single-use).
let mut timeout: Option<Duration> = None;
let mut custom_headers: Vec<(String, String)> = Vec::new();
let mut opts_cookies: Vec<(String, String)> = Vec::new();
// Body, with the Content-Type it implies (None for a raw body).
let mut body: Option<(Vec<u8>, Option<&'static str>)> = None;
// Auth: (is_digest, username, password).
let mut auth: Option<(bool, String, String)> = None;
if let Some(opts) = opts.as_ref() {
if let Some(t) = opts.get::<Option<f64>>("timeout")? { if let Some(t) = opts.get::<Option<f64>>("timeout")? {
timeout = Some(Duration::try_from_secs_f64(t).map_err(|_| { o.timeout = Some(Duration::try_from_secs_f64(t).map_err(|_| {
mlua::Error::external( mlua::Error::external("http: timeout must be a non-negative finite number of seconds")
"http: timeout must be a non-negative finite number of seconds",
)
})?); })?);
} }
if let Some(headers) = opts.get::<Option<LuaTable>>("headers")? { if let Some(headers) = opts.get::<Option<LuaTable>>("headers")? {
for pair in headers.pairs::<String, String>() { for pair in headers.pairs::<String, String>() {
custom_headers.push(pair?); o.headers.push(pair?);
} }
} }
if let Some(cookies) = opts.get::<Option<LuaTable>>("cookies")? { if let Some(cookies) = opts.get::<Option<LuaTable>>("cookies")? {
for pair in cookies.pairs::<String, String>() { for pair in cookies.pairs::<String, String>() {
opts_cookies.push(pair?); o.cookies.push(pair?);
} }
} }
@ -214,18 +82,19 @@ async fn execute_request(
if let Some(json_val) = opts.get::<Option<LuaValue>>("json")? { if let Some(json_val) = opts.get::<Option<LuaValue>>("json")? {
let json = lua_to_json(json_val, 0)?; let json = lua_to_json(json_val, 0)?;
let s = serde_json::to_string(&json).map_err(mlua::Error::external)?; let s = serde_json::to_string(&json).map_err(mlua::Error::external)?;
body = Some((s.into_bytes(), Some("application/json"))); o.body = Some((s.into_bytes(), Some("application/json")));
} else if let Some(form) = opts.get::<Option<LuaTable>>("form")? { } else if let Some(form) = opts.get::<Option<LuaTable>>("form")? {
let mut pairs = Vec::new(); let mut ser = form_urlencoded::Serializer::new(String::new());
for pair in form.pairs::<String, String>() { for pair in form.pairs::<String, String>() {
pairs.push(pair?); let (k, v) = pair?;
ser.append_pair(&k, &v);
} }
body = Some(( o.body = Some((
form_urlencode(&pairs).into_bytes(), ser.finish().into_bytes(),
Some("application/x-www-form-urlencoded"), Some("application/x-www-form-urlencoded"),
)); ));
} else if let Some(b) = opts.get::<Option<mlua::String>>("body")? { } else if let Some(b) = opts.get::<Option<mlua::String>>("body")? {
body = Some((b.as_bytes().to_vec(), None)); o.body = Some((b.as_bytes().to_vec(), None));
} }
// Auth: { username, password, scheme = "basic" (default) | "digest" }. // Auth: { username, password, scheme = "basic" (default) | "digest" }.
@ -245,70 +114,78 @@ async fn execute_request(
))); )));
} }
}; };
auth = Some((is_digest, username, password)); o.auth = Some((is_digest, username, password));
}
} }
// Did the caller supply their own Content-Type? If so it wins over the one a o.has_user_content_type = o
// json/form body would otherwise imply (no silent override, no duplicate). .headers
let has_user_content_type = custom_headers
.iter() .iter()
.any(|(k, _)| k.eq_ignore_ascii_case("content-type")); .any(|(k, _)| k.eq_ignore_ascii_case("content-type"));
let is_digest = matches!(auth.as_ref(), Some((true, _, _))); // A Cookie header and opts.cookies would produce two Cookie headers (or
let mut digest_header: Option<String> = None; // silently drop one) — refuse the ambiguity instead.
let mut digest_tried = false; if !o.cookies.is_empty() && o.headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("cookie")) {
return Err(mlua::Error::external(
let mut url = url; "http: pass cookies either in opts.cookies or as a Cookie header, not both",
let mut redirects_left = MAX_REDIRECTS; ));
// Origin (scheme, host, port) of the ORIGINAL request. Once a redirect }
// leaves this origin we stop attaching credentials, custom headers, and
// per-request cookies, mirroring what reqwest's own redirect policy does:
// a redirect to an attacker-controlled host must not receive the caller's
// Authorization, Cookie, or secret headers.
let origin = reqwest::Url::parse(&url).ok().map(|u| origin_of(&u));
let resp = loop {
// Host used for cookie matching and as the Set-Cookie domain fallback;
// recomputed each hop since a redirect may cross hosts.
let parsed = reqwest::Url::parse(&url).ok();
let host = parsed
.as_ref()
.and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase()));
// Are we still on the origin the request was addressed to?
let same_origin = match (origin.as_ref(), parsed.as_ref()) {
(Some(o), Some(u)) => *o == origin_of(u),
_ => false,
};
let mut req = client.request(method.clone(), &url); Ok(o)
}
if let Some(t) = timeout { // ---------------------------------------------------------------------------
// Request execution
// ---------------------------------------------------------------------------
/// reqwest's Display often hides the interesting part ("too many redirects",
/// "operation timed out") behind a generic wrapper — surface the source chain.
fn req_error(e: reqwest::Error) -> mlua::Error {
use std::error::Error as _;
let mut msg = format!("http: {e}");
let mut src = e.source();
while let Some(s) = src {
msg.push_str(&format!(": {s}"));
src = s.source();
}
mlua::Error::external(msg)
}
/// Assemble one request. Redirects, cookie capture (including `Set-Cookie` on
/// 30x hops) and re-sending jar cookies to each hop are reqwest's job: the
/// session client carries the cookie store as its provider, and its redirect
/// policy strips Authorization/Cookie when a redirect leaves the host.
fn build_request(
client: &reqwest::Client,
jar: Option<&CookieStoreMutex>,
method: &reqwest::Method,
url: &reqwest::Url,
o: &RequestOpts,
digest_header: Option<&str>,
) -> reqwest::RequestBuilder {
let mut req = client.request(method.clone(), url.clone());
if let Some(t) = o.timeout {
req = req.timeout(t); req = req.timeout(t);
} }
// Custom headers carry only while on the original origin. for (k, v) in &o.headers {
if same_origin {
for (k, v) in &custom_headers {
req = req.header(k, v); req = req.header(k, v);
} }
}
// Cookie header: jar cookies for this host (the jar is domain-scoped, so // Per-request cookies. reqwest only injects jar cookies when the request
// this is always safe), then per-request cookies which override on name // has no Cookie header, so an explicit header must merge the jar's cookies
// collision — but the explicit per-request cookies stay on-origin only. // itself (per-request wins on a name collision).
let mut cookie_map: HashMap<String, String> = HashMap::new(); if !o.cookies.is_empty() {
if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) { let mut map: HashMap<String, String> = HashMap::new();
for (n, v) in jar.lock().unwrap_or_else(|e| e.into_inner()).cookies_for(host) { if let Some(jar) = jar {
cookie_map.insert(n, v); let store = jar.lock().unwrap_or_else(|e| e.into_inner());
for (n, v) in store.get_request_values(url) {
map.insert(n.to_string(), v.to_string());
} }
} }
if same_origin { for (k, v) in &o.cookies {
for (k, v) in &opts_cookies { map.insert(k.clone(), v.clone());
cookie_map.insert(k.clone(), v.clone());
} }
} let header = map
if !cookie_map.is_empty() {
let header = cookie_map
.iter() .iter()
.map(|(k, v)| format!("{k}={v}")) .map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
@ -316,148 +193,175 @@ async fn execute_request(
req = req.header(reqwest::header::COOKIE, header); req = req.header(reqwest::header::COOKIE, header);
} }
if let Some((bytes, ct)) = body.as_ref() { if let Some((bytes, implied_ct)) = &o.body {
// Apply the body's implied Content-Type only when the caller didn't if let Some(ct) = implied_ct
// set their own (and theirs is still in effect, i.e. same origin). && !o.has_user_content_type
if let Some(ct) = ct
&& !(same_origin && has_user_content_type)
{ {
req = req.header(reqwest::header::CONTENT_TYPE, *ct); req = req.header(reqwest::header::CONTENT_TYPE, *ct);
} }
req = req.body(bytes.clone()); req = req.body(bytes.clone());
} }
// Auth. Basic goes out on each hop; digest's Authorization is set only // Basic auth goes out with the request; digest's Authorization exists only
// after the 401 challenge below has been answered (digest_header). Both // after a 401 challenge has been answered (digest_header).
// are withheld once a redirect leaves the original origin, so credentials match (&o.auth, digest_header) {
// never reach a host the caller did not address. (Some((false, username, password)), _) => req = req.basic_auth(username, Some(password)),
if same_origin (Some((true, ..)), Some(h)) => req = req.header(reqwest::header::AUTHORIZATION, h),
&& let Some((digest, username, password)) = auth.as_ref() _ => {}
{
if *digest {
if let Some(h) = digest_header.as_ref() {
req = req.header(reqwest::header::AUTHORIZATION, h);
}
} else {
req = req.basic_auth(username, Some(password));
}
} }
let resp = req req
.send()
.await
.map_err(|e| mlua::Error::external(format!("http: {e}")))?;
let status = resp.status();
// Store Set-Cookie from this hop into the jar.
if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) {
let mut j = jar.lock().unwrap_or_else(|e| e.into_inner());
for value in resp.headers().get_all(reqwest::header::SET_COOKIE).iter() {
if let Ok(s) = value.to_str() {
j.set_from_header(host, s);
}
}
} }
// Digest auth: answer a 401 challenge once, then retry the same request /// Answer a digest challenge: the Authorization header value for retrying the
// with the computed Authorization header. /// request that came back 401, and the URL to retry (the response's final URL,
if is_digest && !digest_tried && status == reqwest::StatusCode::UNAUTHORIZED { /// so a challenge behind a redirect is answered for the right path).
fn digest_answer(
resp: &reqwest::Response,
method: &reqwest::Method,
o: &RequestOpts,
) -> Option<(reqwest::Url, String)> {
let (_, username, password) = o.auth.as_ref()?;
let challenge = resp let challenge = resp
.headers() .headers()
.get(reqwest::header::WWW_AUTHENTICATE) .get(reqwest::header::WWW_AUTHENTICATE)?
.and_then(|v| v.to_str().ok()) .to_str()
.map(str::to_string); .ok()?;
if let Some((_, username, password)) = auth.as_ref() let mut prompt = digest_auth::parse(challenge).ok()?;
&& let Some(challenge) = challenge let url = resp.url().clone();
&& let Ok(mut prompt) = digest_auth::parse(&challenge) let uri = match url.query() {
{ Some(q) => format!("{}?{q}", url.path()),
let uri = reqwest::Url::parse(&url) None => url.path().to_string(),
.map(|u| match u.query() { };
Some(q) => format!("{}?{}", u.path(), q),
None => u.path().to_string(),
})
.unwrap_or_else(|_| url.clone());
let ctx = digest_auth::AuthContext::new_with_method( let ctx = digest_auth::AuthContext::new_with_method(
username.as_str(), username.as_str(),
password.as_str(), password.as_str(),
uri, uri,
body.as_ref().map(|(b, _)| b.as_slice()), o.body.as_ref().map(|(b, _)| b.as_slice()),
// If a 303 redirect degraded the method before the challenge, this
// replays the original method; a digest endpoint behind a
// method-changing redirect is not a case worth carrying state for.
digest_auth::HttpMethod::from(method.as_str()), digest_auth::HttpMethod::from(method.as_str()),
); );
if let Ok(answer) = prompt.respond(&ctx) { let answer = prompt.respond(&ctx).ok()?;
digest_header = Some(answer.to_header_string()); Some((url, answer.to_header_string()))
digest_tried = true;
continue;
}
}
} }
// Follow a redirect if there is one to follow. A redirect with a usable /// Shared by the stateless `http.request` and a session's `:request`. A
// Location that we can't follow because the budget is spent is a "too /// session's cookie jar rides along inside `client` (as its cookie provider);
// many redirects" error, not a silent success returning the 3xx. /// `jar` is passed separately only so per-request cookies can merge with it.
if status.is_redirection() async fn execute_request(
&& let Some(next) = resp lua: Lua,
.headers() client: reqwest::Client,
.get(reqwest::header::LOCATION) jar: Option<Arc<CookieStoreMutex>>,
.and_then(|l| l.to_str().ok()) method: String,
.and_then(|loc| reqwest::Url::parse(&url).and_then(|base| base.join(loc)).ok()) url: String,
{ opts: Option<LuaTable>,
if redirects_left == 0 { ) -> LuaResult<LuaTable> {
return Err(mlua::Error::external(format!( // Every request funnels through here (http.request, the shorthands, and
"http: too many redirects (max {MAX_REDIRECTS})" // all session methods) — the one choke point for --sandbox.
))); super::fs::ensure_net(&lua)?;
}
redirects_left -= 1; let method = reqwest::Method::from_bytes(method.to_ascii_uppercase().as_bytes())
// 303, and 301/302 on a POST, degrade to a bodyless GET — the .map_err(|e| mlua::Error::external(format!("http: invalid method '{method}': {e}")))?;
// behaviour browsers and reqwest's own redirect policy apply. let url = reqwest::Url::parse(&url)
let code = status.as_u16(); .map_err(|e| mlua::Error::external(format!("http: invalid URL '{url}': {e}")))?;
if code == 303 || ((code == 301 || code == 302) && method == reqwest::Method::POST) { let o = parse_opts(opts.as_ref())?;
method = reqwest::Method::GET;
body = None; let resp = build_request(&client, jar.as_deref(), &method, &url, &o, None)
} .send()
url = next.to_string(); .await
continue; .map_err(req_error)?;
}
break resp; // Digest auth: answer a 401 challenge once, then retry the same request
// with the computed Authorization header. (Each send has its own timeout
// window; the challenge round-trip is a second request.)
let resp = if matches!(o.auth, Some((true, ..)))
&& resp.status() == reqwest::StatusCode::UNAUTHORIZED
&& let Some((retry_url, header)) = digest_answer(&resp, &method, &o)
{
build_request(&client, jar.as_deref(), &method, &retry_url, &o, Some(&header))
.send()
.await
.map_err(req_error)?
} else {
resp
}; };
let status = resp.status().as_u16(); let status = resp.status().as_u16();
// Response headers: lowercase names, first value wins. // Response headers, keyed by lowercase name. Repeats are joined with ", "
// (RFC 9110 list semantics) — except Set-Cookie, whose values may contain
// commas; the headers table keeps its first value and the full list is
// exposed as resp.setCookies.
let headers_tbl = lua.create_table()?; let headers_tbl = lua.create_table()?;
for (name, value) in resp.headers().iter() { let set_cookies = lua.create_table()?;
for name in resp.headers().keys() {
let lname = name.as_str().to_ascii_lowercase(); let lname = name.as_str().to_ascii_lowercase();
if !headers_tbl.contains_key(lname.as_str())? { let mut values = resp.headers().get_all(name).iter();
headers_tbl.raw_set(lname.as_str(), lua.create_string(value.as_bytes())?)?; if name == reqwest::header::SET_COOKIE {
if let Some(first) = values.next() {
headers_tbl.raw_set(lname.as_str(), lua.create_string(first.as_bytes())?)?;
}
for v in resp.headers().get_all(name) {
set_cookies.raw_push(lua.create_string(v.as_bytes())?)?;
}
} else {
let mut joined: Vec<u8> = Vec::new();
for (i, v) in values.enumerate() {
if i > 0 {
joined.extend_from_slice(b", ");
}
joined.extend_from_slice(v.as_bytes());
}
headers_tbl.raw_set(lname.as_str(), lua.create_string(&joined)?)?;
} }
} }
let bytes = resp let final_url = resp.url().to_string();
.bytes() let bytes = resp.bytes().await.map_err(req_error)?;
.await
.map_err(|e| mlua::Error::external(format!("http: {e}")))?;
let out = lua.create_table()?; let out = lua.create_table()?;
out.raw_set("status", status)?; out.raw_set("status", status)?;
out.raw_set("ok", (200..=299).contains(&status))?; out.raw_set("ok", (200..=299).contains(&status))?;
out.raw_set("url", final_url)?;
out.raw_set("headers", headers_tbl)?; out.raw_set("headers", headers_tbl)?;
out.raw_set("setCookies", set_cookies)?;
out.raw_set("body", lua.create_string(&bytes)?)?; out.raw_set("body", lua.create_string(&bytes)?)?;
Ok(out) Ok(out)
} }
// ---------------------------------------------------------------------------
// Client construction
// ---------------------------------------------------------------------------
/// One client per cookie jar: reqwest attaches the store at build time. The
/// stateless module shares a single jarless client; each session builds its own.
fn build_client(jar: Option<Arc<CookieStoreMutex>>) -> reqwest::Result<reqwest::Client> {
let mut builder = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
// reqwest sends no User-Agent by default; some edges/CDNs reject
// empty-UA requests outright. A per-request `headers` entry overrides it.
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
.redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS));
if let Some(jar) = jar {
builder = builder.cookie_provider(jar);
}
builder.build()
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Session // Session
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Build the plain Lua table that backs a session. State (the cookie jar) lives /// Build the plain Lua table that backs a session. State (the cookie store)
/// in the closures; the Lua layer applies a metatable for the method shorthands. /// lives in the closures; the Lua layer applies a metatable for the method
/// Methods are called as `s:method(...)`, so each closure receives the session /// shorthands. Methods are called as `s:method(...)`, so each closure receives
/// table as a leading `_this` argument that it ignores. /// the session table as a leading `_this` argument that it ignores.
fn make_session( fn make_session(
lua: &Lua, lua: &Lua,
client: reqwest::Client, client: reqwest::Client,
jar: Arc<Mutex<CookieJar>>, jar: Arc<CookieStoreMutex>,
) -> LuaResult<LuaTable> { ) -> LuaResult<LuaTable> {
let tbl = lua.create_table()?; let tbl = lua.create_table()?;
@ -469,9 +373,7 @@ fn make_session(
move |lua, (_this, method, url, opts): (LuaTable, String, String, Option<LuaTable>)| { move |lua, (_this, method, url, opts): (LuaTable, String, String, Option<LuaTable>)| {
let client = client.clone(); let client = client.clone();
let jar = jar.clone(); let jar = jar.clone();
async move { async move { execute_request(lua, client, Some(jar), method, url, opts).await }
execute_request(lua, client, Some(jar), method, url, opts).await
}
} }
})?, })?,
)?; )?;
@ -487,8 +389,15 @@ fn make_session(
super::fs::write_target("session:save", Path::new(&path)).await?; super::fs::write_target("session:save", Path::new(&path)).await?;
super::fs::ensure_access(&lua, "session:save", dir, super::fs::Wants::WRITE) super::fs::ensure_access(&lua, "session:save", dir, super::fs::Wants::WRITE)
.await?; .await?;
let jsonl = jar.lock().unwrap_or_else(|e| e.into_inner()).to_jsonl(); // Session cookies (no Expires/Max-Age — most login tokens) are
tokio::fs::write(&target, jsonl) // exactly what the caller wants to persist, so include them.
let mut buf = Vec::new();
cookie_store::serde::json::save_incl_expired_and_nonpersistent(
&jar.lock().unwrap_or_else(|e| e.into_inner()),
&mut buf,
)
.map_err(|e| mlua::Error::external(format!("session:save: {e}")))?;
tokio::fs::write(&target, buf)
.await .await
.map_err(|e| mlua::Error::external(format!("session:save: {e}"))) .map_err(|e| mlua::Error::external(format!("session:save: {e}")))
} }
@ -507,7 +416,8 @@ fn make_session(
let src = tokio::fs::read_to_string(&target) let src = tokio::fs::read_to_string(&target)
.await .await
.map_err(|e| mlua::Error::external(format!("session:load: {e}")))?; .map_err(|e| mlua::Error::external(format!("session:load: {e}")))?;
jar.lock().unwrap_or_else(|e| e.into_inner()).merge_jsonl(&src); let loaded = load_store("session:load", &src)?;
*jar.lock().unwrap_or_else(|e| e.into_inner()) = loaded;
Ok(()) Ok(())
} }
})? })?
@ -516,7 +426,7 @@ fn make_session(
tbl.raw_set("clearCookies", { tbl.raw_set("clearCookies", {
let jar = jar.clone(); let jar = jar.clone();
lua.create_function(move |_, _this: LuaTable| { lua.create_function(move |_, _this: LuaTable| {
jar.lock().unwrap_or_else(|e| e.into_inner()).cookies.clear(); jar.lock().unwrap_or_else(|e| e.into_inner()).clear();
Ok(()) Ok(())
})? })?
})?; })?;
@ -525,13 +435,18 @@ fn make_session(
let jar = jar.clone(); let jar = jar.clone();
lua.create_function(move |lua, _this: LuaTable| { lua.create_function(move |lua, _this: LuaTable| {
let outer = lua.create_table()?; let outer = lua.create_table()?;
let j = jar.lock().unwrap_or_else(|e| e.into_inner()); let store = jar.lock().unwrap_or_else(|e| e.into_inner());
for (domain, names) in j.cookies.iter() { for c in store.iter_unexpired() {
let inner = lua.create_table()?; let Some(domain) = c.domain.as_cow() else { continue };
for (name, value) in names.iter() { let inner = match outer.raw_get::<Option<LuaTable>>(domain.as_ref())? {
inner.raw_set(name.as_str(), value.as_str())?; Some(t) => t,
None => {
let t = lua.create_table()?;
outer.raw_set(domain.as_ref(), &t)?;
t
} }
outer.raw_set(domain.as_str(), inner)?; };
inner.raw_set(c.name(), c.value())?;
} }
Ok(outer) Ok(outer)
})? })?
@ -540,6 +455,15 @@ fn make_session(
Ok(tbl) Ok(tbl)
} }
/// Parse a saved jar (JSONL, one cookie per line, `cookie_store`'s format),
/// re-attaching the public suffix list — a loaded store must reject bad
/// Domain attributes exactly like a fresh one.
fn load_store(fname: &str, src: &str) -> LuaResult<CookieStore> {
cookie_store::serde::json::load_all(src.as_bytes())
.map_err(|e| mlua::Error::external(format!("{fname}: malformed cookie jar: {e}")))
.map(|s| s.with_suffix_list(public_suffix_list().clone()))
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Installation // Installation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -552,16 +476,7 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
// first install in the process wins, and they would all install ring. // first install in the process wins, and they would all install ring.
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
let client = reqwest::Client::builder() let client = build_client(None).map_err(mlua::Error::external)?;
.timeout(Duration::from_secs(30))
// reqwest sends no User-Agent by default; some edges/CDNs reject
// empty-UA requests outright. A per-request `headers` entry overrides it.
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
// Redirects are followed manually in execute_request so Set-Cookie
// headers on 30x responses can be captured into the cookie jar.
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(mlua::Error::external)?;
let http = lua.create_table()?; let http = lua.create_table()?;
@ -580,30 +495,21 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
// http.session(path?) — constructor returning the raw session table. // http.session(path?) — constructor returning the raw session table.
http.raw_set( http.raw_set(
"session", "session",
lua.create_async_function({ lua.create_async_function(move |lua, path: Option<String>| async move {
let client = client.clone(); let mut store = new_cookie_store();
move |lua, path: Option<String>| {
let client = client.clone();
async move {
let mut jar = CookieJar::default();
if let Some(p) = path { if let Some(p) = path {
let (dir, target) = let (dir, target) =
super::fs::read_target("http.session", Path::new(&p)).await?; super::fs::read_target("http.session", Path::new(&p)).await?;
super::fs::ensure_access( super::fs::ensure_access(&lua, "http.session", dir, super::fs::Wants::READ)
&lua,
"http.session",
dir,
super::fs::Wants::READ,
)
.await?; .await?;
let src = tokio::fs::read_to_string(&target) let src = tokio::fs::read_to_string(&target)
.await .await
.map_err(|e| mlua::Error::external(format!("http.session: {e}")))?; .map_err(|e| mlua::Error::external(format!("http.session: {e}")))?;
jar.merge_jsonl(&src); store = load_store("http.session", &src)?;
}
make_session(&lua, client, Arc::new(Mutex::new(jar)))
}
} }
let jar = Arc::new(CookieStoreMutex::new(store));
let client = build_client(Some(jar.clone())).map_err(mlua::Error::external)?;
make_session(&lua, client, jar)
})?, })?,
)?; )?;
@ -616,68 +522,88 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
//! Unit tests for the cookie-store policy this module relies on: these pin
//! the security properties (public-suffix, cookie tossing, Secure,
//! Max-Age) that motivated using `cookie_store` over a hand-rolled jar.
//! End-to-end behaviour is covered in `crate::lua_tests::http_tests`.
use super::*; use super::*;
fn cookie(jar: &CookieJar, domain: &str, name: &str) -> Option<String> { fn url(s: &str) -> reqwest::Url {
jar.cookies.get(domain).and_then(|m| m.get(name).cloned()) reqwest::Url::parse(s).unwrap()
}
fn names_for(store: &CookieStore, u: &str) -> Vec<String> {
let mut v: Vec<String> =
store.get_request_values(&url(u)).map(|(n, _)| n.to_string()).collect();
v.sort();
v
} }
#[test] #[test]
fn set_cookie_defaults_to_request_host() { fn rejects_public_suffix_domain() {
let mut jar = CookieJar::default(); let mut store = new_cookie_store();
jar.set_from_header("example.com", "sid=abc"); // A TLD-scoped cookie would be sent to every .com host ("supercookie").
assert_eq!(cookie(&jar, "example.com", "sid").as_deref(), Some("abc")); assert!(store.parse("x=1; Domain=com", &url("http://evil.com/")).is_err());
assert!(store.iter_any().next().is_none());
} }
#[test] #[test]
fn set_cookie_honors_domain_the_host_belongs_to() { fn rejects_foreign_domain() {
let mut jar = CookieJar::default(); let mut store = new_cookie_store();
// A subdomain may scope a cookie up to its parent domain. // Cookie tossing: a response must not plant a cookie for an unrelated
jar.set_from_header("app.example.com", "sid=abc; Domain=example.com"); // domain, including a raw-substring "suffix" that is not a subdomain.
assert_eq!(cookie(&jar, "example.com", "sid").as_deref(), Some("abc")); assert!(store.parse("x=1; Domain=other.example.com", &url("http://evil.com/")).is_err());
assert!(store.parse("x=1; Domain=example.com", &url("http://notexample.com/")).is_err());
assert!(store.iter_any().next().is_none());
} }
#[test] #[test]
fn set_cookie_rejects_foreign_domain() { fn subdomain_may_scope_to_parent() {
let mut jar = CookieJar::default(); let mut store = new_cookie_store();
// Cookie tossing: a response from one host must not plant a cookie scoped store.parse("sid=abc; Domain=example.com", &url("http://app.example.com/")).unwrap();
// to an unrelated domain. The bad Domain= is ignored; it stays host-scoped. // Sent to the parent and to sibling subdomains, not to strangers.
jar.set_from_header("127.0.0.1", "evil=1; Domain=other.example"); assert_eq!(names_for(&store, "http://example.com/"), ["sid"]);
assert!(cookie(&jar, "other.example", "evil").is_none()); assert_eq!(names_for(&store, "http://other.example.com/"), ["sid"]);
assert_eq!(cookie(&jar, "127.0.0.1", "evil").as_deref(), Some("1")); assert!(names_for(&store, "http://example.org/").is_empty());
} }
#[test] #[test]
fn set_cookie_rejects_partial_suffix_match() { fn secure_cookie_not_sent_over_http() {
let mut jar = CookieJar::default(); let mut store = new_cookie_store();
// "notexample.com" ends with "example.com" as a raw substring but is not store.parse("s=1; Secure", &url("https://example.com/")).unwrap();
// a subdomain, so the Domain must be rejected. assert_eq!(names_for(&store, "https://example.com/"), ["s"]);
jar.set_from_header("notexample.com", "x=1; Domain=example.com"); assert!(names_for(&store, "http://example.com/").is_empty());
assert!(cookie(&jar, "example.com", "x").is_none());
assert_eq!(cookie(&jar, "notexample.com", "x").as_deref(), Some("1"));
} }
#[test] #[test]
fn origins_compare_by_scheme_host_port() { fn max_age_zero_expires_cookie() {
let u = |s: &str| origin_of(&reqwest::Url::parse(s).unwrap()); let u = url("http://example.com/");
assert_eq!(u("https://a.com/x"), u("https://a.com/y")); // path-independent let mut store = new_cookie_store();
assert_eq!(u("https://a.com"), u("https://a.com:443")); // default port store.parse("sid=abc", &u).unwrap();
assert_ne!(u("https://a.com"), u("http://a.com")); // scheme differs assert_eq!(names_for(&store, "http://example.com/"), ["sid"]);
assert_ne!(u("https://a.com"), u("https://b.com")); // host differs // How servers delete a cookie (e.g. logout).
assert_ne!(u("https://a.com"), u("https://a.com:8443")); // port differs let _ = store.parse("sid=; Max-Age=0", &u);
assert!(names_for(&store, "http://example.com/").is_empty());
}
#[test]
fn save_load_round_trips_session_cookies() {
let mut store = new_cookie_store();
// No Expires/Max-Age — a session cookie, which plain save would drop.
store.parse("sid=abc", &url("https://example.com/")).unwrap();
let mut buf = Vec::new();
cookie_store::serde::json::save_incl_expired_and_nonpersistent(&store, &mut buf).unwrap();
let loaded = load_store("test", std::str::from_utf8(&buf).unwrap()).unwrap();
assert_eq!(names_for(&loaded, "https://example.com/"), ["sid"]);
// The reloaded store must keep rejecting public-suffix domains.
let mut loaded = loaded;
assert!(loaded.parse("x=1; Domain=com", &url("http://evil.com/")).is_err());
} }
#[test] #[test]
fn cookies_for_matches_domain_and_subdomains() { fn load_rejects_malformed_jar() {
let mut jar = CookieJar::default(); assert!(load_store("test", "not json\n").is_err());
jar.set_from_header("example.com", "a=1");
assert_eq!(jar.cookies_for("example.com"), vec![("a".into(), "1".into())]);
// a cookie stored on example.com is sent to its subdomains
jar.set_from_header("sub.example.com", "b=2; Domain=example.com");
let mut got = jar.cookies_for("sub.example.com");
got.sort();
assert_eq!(got, vec![("a".into(), "1".into()), ("b".into(), "2".into())]);
// but not to an unrelated host
assert!(jar.cookies_for("other.com").is_empty());
} }
} }

Loading…
Cancel
Save