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",
"clap",
"colored",
"cookie_store",
"crossterm",
"digest_auth",
"dotenvy",
"env_logger",
"form_urlencoded",
"futures",
"inquire",
"log",
"mlua",
"publicsuffix",
"reqwest",
"reqwest_cookie_store",
"rusqlite",
"rustix",
"rustls",
@ -27,6 +31,7 @@ dependencies = [
"serde_json",
"tempfile",
"thiserror",
"tiny_http",
"tokio",
]
@ -116,6 +121,12 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "atomic-waker"
version = "1.1.2"
@ -212,6 +223,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "clap"
version = "4.6.1"
@ -295,6 +312,35 @@ dependencies = [
"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]]
name = "core-foundation"
version = "0.9.4"
@ -399,6 +445,12 @@ dependencies = [
"thiserror",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive_more"
version = "2.1.1"
@ -835,6 +887,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.10.1"
@ -1311,6 +1369,12 @@ dependencies = [
"libc",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-traits"
version = "0.2.19"
@ -1412,6 +1476,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@ -1452,6 +1522,22 @@ dependencies = [
"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]]
name = "quote"
version = "1.0.45"
@ -1553,6 +1639,8 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64",
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-core",
"h2",
@ -1582,6 +1670,18 @@ dependencies = [
"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]]
name = "ring"
version = "0.17.14"
@ -2011,7 +2111,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -2043,6 +2143,48 @@ dependencies = [
"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]]
name = "tinystr"
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
# process default in stdlib::http). `rustls-no-provider` stops reqwest from
# 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"] }
rusqlite = { version = "0.32", features = ["bundled"] }
# flock() guarding the fs-permission store (access.json) against concurrently
@ -41,6 +41,18 @@ serde_json = "1.0.150"
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["full"] }
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]
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:
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
denial makes the result `false`).
### `fs.getWorkdir() -> string`
### `fs.getWorkDir() -> string`
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
for building paths to probe with `fs.access`:
```lua
local cwd = fs.getWorkdir()
local cwd = fs.getWorkDir()
if fs.access(cwd, "w") then
fs.writeAll("report.txt", text) -- relative = cwd .. "/report.txt"
end
@ -61,6 +61,22 @@ end
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`
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:
| Field | Type | Description |
|-----------|-----------|-------------------------------------------------------------------|
|--------------|-----------|-------------------------------------------------------------------|
| `status` | integer | HTTP status code, e.g. `200`, `404`. |
| `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. |
| `setCookies` | table | Every `Set-Cookie` value on the final response, as an array. |
| `body` | string | The raw response body (Lua strings are byte sequences). |
| `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
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
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`. |
| `form` | table | URL-encoded; sets `Content-Type: application/x-www-form-urlencoded`. |
| `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). |
### 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),
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
@ -216,20 +223,28 @@ local page = s:get("https://example.com/dashboard")
### Cookie behaviour
A stored cookie is sent on a request when the request host **equals** the cookie's
domain or is a subdomain of it. The jar reads the `name=value` pair and the
`Domain` attribute from each `Set-Cookie` header; other attributes (`Path`,
`Expires`, `Secure`, `HttpOnly`) are ignored. When no `Domain` is given, the
request host is used.
The jar implements RFC 6265 the way a browser does (it is the `cookie_store`
crate underneath): `Domain`, `Path`, `Expires`, `Max-Age`, `Secure` and
`HttpOnly` attributes all take effect. In particular:
`Set-Cookie` headers set on redirect responses are captured too, so a login that
`302`-redirects to a dashboard still records its cookie.
- A cookie is sent only to hosts the `Domain` attribute covers (the setting
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
#### `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
local jar = s:cookies()
@ -257,18 +272,16 @@ networking entirely: every request errors with
#### `s:save(path)`
Write the jar to `path` as JSONL — one JSON object per line, one cookie per line:
```jsonl
{"domain":"example.com","name":"session","value":"abc123"}
{"domain":"api.example.com","name":"token","value":"xyz789"}
```
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,
expiry). Session cookies — ones without an `Expires`/`Max-Age`, which is what
most login tokens are — are included.
#### `s:load(path)`
Merge cookies from a JSONL file into the current jar (existing cookies are kept,
matching names overwritten). `http.session(path)` is the same as creating a
session and calling `:load(path)`.
Load a jar file written by `s:save`, **replacing** the current jar contents.
`http.session(path)` is the same as creating a session and calling
`:load(path)`. A file that is not a saved jar raises an error.
```lua
-- First run: log in and persist
@ -284,9 +297,13 @@ local page = s:get("https://example.com/dashboard")
## Redirects
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
bodyless `GET`, matching browser behaviour. For a session, cookies set along the
way are captured at each hop.
response (`resp.url` tells you where you ended up). A `303`, and a `301`/`302`
in response to a `POST`, are followed as a bodyless `GET`, matching browser
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

@ -46,7 +46,14 @@ function fs.isFile(path) end
---The process's current working directory (absolute path) — what relative
---paths in the other fs functions resolve against. Not permission-gated.
---@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
---interactive prompt, else false (nothing recorded when there is no terminal

@ -14,7 +14,9 @@ http = {}
---@class http.Response
---@field status integer HTTP status code, e.g. 200
---@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 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 body string? raw request body (set Content-Type yourself)
---@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 cookies table<string, string>? cookies for this request
---@field form table<string, string|number>? URL-encoded; sets application/x-www-form-urlencoded
---@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 auth http.Auth? basic or digest credentials
@ -117,17 +119,17 @@ function Session:getJSON(url, opts) end
---@return http.Response
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>>
function Session:cookies() end
---Empty the in-memory jar.
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
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
function Session:load(path) end

@ -156,15 +156,30 @@ async fn access_validates_arguments() {
}
#[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
// filesystem content.
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!(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]
async fn deny_all_blocks_everything_without_prompting() {
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 core_tests;
mod fs_tests;
mod http_tests;
mod math_tests;
mod os_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()))?;
let script_dir = realpath.parent().unwrap_or(Path::new("/"));
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
// store; this replaces the session-only default planted by fs::install.

@ -35,6 +35,11 @@ pub(crate) use policy::{FsPolicy, Wants};
#[cfg(test)]
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
/// module goes through this, prefixed `fs.<fn>: …`.
macro_rules! ext_err {
@ -142,18 +147,35 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
)?;
fs.set(
"getWorkdir",
"getWorkDir",
lua.create_function(|lua, ()| {
// Process state, not filesystem content — no permission check.
// This is what relative paths in the other fs functions (and in
// fs.access probes) resolve against.
use std::os::unix::ffi::OsStrExt;
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())
})?,
)?;
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(
"access",
lua.create_async_function(|lua, (dir, mode): (LuaString, LuaString)| async move {

@ -1,212 +1,80 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use cookie_store::CookieStore;
use mlua::prelude::LuaResult;
use mlua::{Lua, Table as LuaTable, Value as LuaValue};
use reqwest_cookie_store::CookieStoreMutex;
use crate::stdlib::utils::lua_to_json;
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.
/// Deliberately simple — it covers the >95% case of `Set-Cookie` flows without
/// pulling in the `cookie_store` crate, and serializes cleanly to JSONL.
#[derive(Default)]
struct CookieJar {
cookies: HashMap<String, HashMap<String, String>>,
}
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
/// Mozilla's Public Suffix List, vendored so cookie domain checks work offline
/// (the same list curl and the browsers ship). Refresh with `make update-psl`.
fn public_suffix_list() -> &'static publicsuffix::List {
static PSL: OnceLock<publicsuffix::List> = OnceLock::new();
PSL.get_or_init(|| {
include_str!("../../assets/public_suffix_list.dat")
.parse()
.expect("vendored public suffix list must parse")
})
}
/// Merge cookies from JSONL produced by `to_jsonl`. Bad lines are skipped.
fn merge_jsonl(&mut self, src: &str) {
for line in src.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
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());
}
}
}
/// An empty RFC 6265 cookie store with public-suffix checking, so a response
/// cannot plant a cookie scoped to a whole TLD (`Domain=com`) or to an
/// unrelated domain ("cookie tossing"). Domain, Path, Secure, HttpOnly,
/// Expires and Max-Age all behave per spec — this is `cookie_store`, the same
/// crate reqwest's own cookie support builds on.
fn new_cookie_store() -> CookieStore {
CookieStore::new_with_public_suffix(Some(public_suffix_list().clone()))
}
// ---------------------------------------------------------------------------
// Request execution
// Request options
// ---------------------------------------------------------------------------
/// `application/x-www-form-urlencoded` body from key/value pairs. Implemented
/// inline so the build needs no optional reqwest features.
fn form_urlencode(pairs: &[(String, String)]) -> String {
fn encode(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
b' ' => out.push('+'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
pairs
.iter()
.map(|(k, v)| format!("{}={}", encode(k), encode(v)))
.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}")))?;
/// `opts` parsed into owned values (the request may be built twice: once
/// plain, once more to answer a digest challenge).
#[derive(Default)]
struct RequestOpts {
timeout: Option<Duration>,
headers: Vec<(String, String)>,
cookies: Vec<(String, String)>,
/// Body bytes, with the Content-Type it implies (None for a raw body).
body: Option<(Vec<u8>, Option<&'static str>)>,
/// (is_digest, username, password).
auth: Option<(bool, String, String)>,
/// 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,
}
fn parse_opts(opts: Option<&LuaTable>) -> LuaResult<RequestOpts> {
let mut o = RequestOpts::default();
let Some(opts) = opts else { return Ok(o) };
// 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")? {
timeout = Some(Duration::try_from_secs_f64(t).map_err(|_| {
mlua::Error::external(
"http: timeout must be a non-negative finite number of seconds",
)
o.timeout = Some(Duration::try_from_secs_f64(t).map_err(|_| {
mlua::Error::external("http: timeout must be a non-negative finite number of seconds")
})?);
}
if let Some(headers) = opts.get::<Option<LuaTable>>("headers")? {
for pair in headers.pairs::<String, String>() {
custom_headers.push(pair?);
o.headers.push(pair?);
}
}
if let Some(cookies) = opts.get::<Option<LuaTable>>("cookies")? {
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")? {
let json = lua_to_json(json_val, 0)?;
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")? {
let mut pairs = Vec::new();
let mut ser = form_urlencoded::Serializer::new(String::new());
for pair in form.pairs::<String, String>() {
pairs.push(pair?);
let (k, v) = pair?;
ser.append_pair(&k, &v);
}
body = Some((
form_urlencode(&pairs).into_bytes(),
o.body = Some((
ser.finish().into_bytes(),
Some("application/x-www-form-urlencoded"),
));
} 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" }.
@ -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
// json/form body would otherwise imply (no silent override, no duplicate).
let has_user_content_type = custom_headers
o.has_user_content_type = o
.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("content-type"));
let is_digest = matches!(auth.as_ref(), Some((true, _, _)));
let mut digest_header: Option<String> = None;
let mut digest_tried = false;
let mut url = url;
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,
};
// A Cookie header and opts.cookies would produce two Cookie headers (or
// silently drop one) — refuse the ambiguity instead.
if !o.cookies.is_empty() && o.headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("cookie")) {
return Err(mlua::Error::external(
"http: pass cookies either in opts.cookies or as a Cookie header, not both",
));
}
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);
}
// Custom headers carry only while on the original origin.
if same_origin {
for (k, v) in &custom_headers {
for (k, v) in &o.headers {
req = req.header(k, v);
}
}
// Cookie header: jar cookies for this host (the jar is domain-scoped, so
// this is always safe), then per-request cookies which override on name
// collision — but the explicit per-request cookies stay on-origin only.
let mut cookie_map: HashMap<String, String> = HashMap::new();
if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) {
for (n, v) in jar.lock().unwrap_or_else(|e| e.into_inner()).cookies_for(host) {
cookie_map.insert(n, v);
// Per-request cookies. reqwest only injects jar cookies when the request
// has no Cookie header, so an explicit header must merge the jar's cookies
// itself (per-request wins on a name collision).
if !o.cookies.is_empty() {
let mut map: HashMap<String, String> = HashMap::new();
if let Some(jar) = jar {
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 &opts_cookies {
cookie_map.insert(k.clone(), v.clone());
for (k, v) in &o.cookies {
map.insert(k.clone(), v.clone());
}
}
if !cookie_map.is_empty() {
let header = cookie_map
let header = map
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
@ -316,148 +193,175 @@ async fn execute_request(
req = req.header(reqwest::header::COOKIE, header);
}
if let Some((bytes, ct)) = body.as_ref() {
// Apply the body's implied Content-Type only when the caller didn't
// set their own (and theirs is still in effect, i.e. same origin).
if let Some(ct) = ct
&& !(same_origin && has_user_content_type)
if let Some((bytes, implied_ct)) = &o.body {
if let Some(ct) = implied_ct
&& !o.has_user_content_type
{
req = req.header(reqwest::header::CONTENT_TYPE, *ct);
}
req = req.body(bytes.clone());
}
// Auth. Basic goes out on each hop; digest's Authorization is set only
// after the 401 challenge below has been answered (digest_header). Both
// are withheld once a redirect leaves the original origin, so credentials
// never reach a host the caller did not address.
if same_origin
&& 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));
}
// Basic auth goes out with the request; digest's Authorization exists only
// after a 401 challenge has been answered (digest_header).
match (&o.auth, digest_header) {
(Some((false, username, password)), _) => req = req.basic_auth(username, Some(password)),
(Some((true, ..)), Some(h)) => req = req.header(reqwest::header::AUTHORIZATION, h),
_ => {}
}
let resp = 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);
}
}
req
}
// Digest auth: answer a 401 challenge once, then retry the same request
// with the computed Authorization header.
if is_digest && !digest_tried && status == reqwest::StatusCode::UNAUTHORIZED {
/// Answer a digest challenge: the Authorization header value for retrying the
/// request that came back 401, and the URL to retry (the response's final URL,
/// 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
.headers()
.get(reqwest::header::WWW_AUTHENTICATE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
if let Some((_, username, password)) = auth.as_ref()
&& let Some(challenge) = challenge
&& let Ok(mut prompt) = digest_auth::parse(&challenge)
{
let uri = reqwest::Url::parse(&url)
.map(|u| match u.query() {
Some(q) => format!("{}?{}", u.path(), q),
None => u.path().to_string(),
})
.unwrap_or_else(|_| url.clone());
.get(reqwest::header::WWW_AUTHENTICATE)?
.to_str()
.ok()?;
let mut prompt = digest_auth::parse(challenge).ok()?;
let url = resp.url().clone();
let uri = match url.query() {
Some(q) => format!("{}?{q}", url.path()),
None => url.path().to_string(),
};
let ctx = digest_auth::AuthContext::new_with_method(
username.as_str(),
password.as_str(),
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()),
);
if let Ok(answer) = prompt.respond(&ctx) {
digest_header = Some(answer.to_header_string());
digest_tried = true;
continue;
}
}
let answer = prompt.respond(&ctx).ok()?;
Some((url, answer.to_header_string()))
}
// Follow a redirect if there is one to follow. A redirect with a usable
// Location that we can't follow because the budget is spent is a "too
// many redirects" error, not a silent success returning the 3xx.
if status.is_redirection()
&& let Some(next) = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|l| l.to_str().ok())
.and_then(|loc| reqwest::Url::parse(&url).and_then(|base| base.join(loc)).ok())
{
if redirects_left == 0 {
return Err(mlua::Error::external(format!(
"http: too many redirects (max {MAX_REDIRECTS})"
)));
}
redirects_left -= 1;
// 303, and 301/302 on a POST, degrade to a bodyless GET — the
// behaviour browsers and reqwest's own redirect policy apply.
let code = status.as_u16();
if code == 303 || ((code == 301 || code == 302) && method == reqwest::Method::POST) {
method = reqwest::Method::GET;
body = None;
}
url = next.to_string();
continue;
}
/// Shared by the stateless `http.request` and a session's `:request`. A
/// session's cookie jar rides along inside `client` (as its cookie provider);
/// `jar` is passed separately only so per-request cookies can merge with it.
async fn execute_request(
lua: Lua,
client: reqwest::Client,
jar: Option<Arc<CookieStoreMutex>>,
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 method = reqwest::Method::from_bytes(method.to_ascii_uppercase().as_bytes())
.map_err(|e| mlua::Error::external(format!("http: invalid method '{method}': {e}")))?;
let url = reqwest::Url::parse(&url)
.map_err(|e| mlua::Error::external(format!("http: invalid URL '{url}': {e}")))?;
let o = parse_opts(opts.as_ref())?;
let resp = build_request(&client, jar.as_deref(), &method, &url, &o, None)
.send()
.await
.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();
// 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()?;
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();
if !headers_tbl.contains_key(lname.as_str())? {
headers_tbl.raw_set(lname.as_str(), lua.create_string(value.as_bytes())?)?;
let mut values = resp.headers().get_all(name).iter();
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
.bytes()
.await
.map_err(|e| mlua::Error::external(format!("http: {e}")))?;
let final_url = resp.url().to_string();
let bytes = resp.bytes().await.map_err(req_error)?;
let out = lua.create_table()?;
out.raw_set("status", status)?;
out.raw_set("ok", (200..=299).contains(&status))?;
out.raw_set("url", final_url)?;
out.raw_set("headers", headers_tbl)?;
out.raw_set("setCookies", set_cookies)?;
out.raw_set("body", lua.create_string(&bytes)?)?;
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
// ---------------------------------------------------------------------------
/// Build the plain Lua table that backs a session. State (the cookie jar) lives
/// in the closures; the Lua layer applies a metatable for the method shorthands.
/// Methods are called as `s:method(...)`, so each closure receives the session
/// table as a leading `_this` argument that it ignores.
/// Build the plain Lua table that backs a session. State (the cookie store)
/// lives in the closures; the Lua layer applies a metatable for the method
/// shorthands. Methods are called as `s:method(...)`, so each closure receives
/// the session table as a leading `_this` argument that it ignores.
fn make_session(
lua: &Lua,
client: reqwest::Client,
jar: Arc<Mutex<CookieJar>>,
jar: Arc<CookieStoreMutex>,
) -> LuaResult<LuaTable> {
let tbl = lua.create_table()?;
@ -469,9 +373,7 @@ fn make_session(
move |lua, (_this, method, url, opts): (LuaTable, String, String, Option<LuaTable>)| {
let client = client.clone();
let jar = jar.clone();
async move {
execute_request(lua, client, Some(jar), method, url, opts).await
}
async move { 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::ensure_access(&lua, "session:save", dir, super::fs::Wants::WRITE)
.await?;
let jsonl = jar.lock().unwrap_or_else(|e| e.into_inner()).to_jsonl();
tokio::fs::write(&target, jsonl)
// Session cookies (no Expires/Max-Age — most login tokens) are
// 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
.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)
.await
.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(())
}
})?
@ -516,7 +426,7 @@ fn make_session(
tbl.raw_set("clearCookies", {
let jar = jar.clone();
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(())
})?
})?;
@ -525,13 +435,18 @@ fn make_session(
let jar = jar.clone();
lua.create_function(move |lua, _this: LuaTable| {
let outer = lua.create_table()?;
let j = jar.lock().unwrap_or_else(|e| e.into_inner());
for (domain, names) in j.cookies.iter() {
let inner = lua.create_table()?;
for (name, value) in names.iter() {
inner.raw_set(name.as_str(), value.as_str())?;
let store = jar.lock().unwrap_or_else(|e| e.into_inner());
for c in store.iter_unexpired() {
let Some(domain) = c.domain.as_cow() else { continue };
let inner = match outer.raw_get::<Option<LuaTable>>(domain.as_ref())? {
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)
})?
@ -540,6 +455,15 @@ fn make_session(
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
// ---------------------------------------------------------------------------
@ -552,16 +476,7 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
// first install in the process wins, and they would all install ring.
let _ = rustls::crypto::ring::default_provider().install_default();
let client = 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")))
// 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 client = build_client(None).map_err(mlua::Error::external)?;
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.raw_set(
"session",
lua.create_async_function({
let client = client.clone();
move |lua, path: Option<String>| {
let client = client.clone();
async move {
let mut jar = CookieJar::default();
lua.create_async_function(move |lua, path: Option<String>| async move {
let mut store = new_cookie_store();
if let Some(p) = path {
let (dir, target) =
super::fs::read_target("http.session", Path::new(&p)).await?;
super::fs::ensure_access(
&lua,
"http.session",
dir,
super::fs::Wants::READ,
)
super::fs::ensure_access(&lua, "http.session", dir, super::fs::Wants::READ)
.await?;
let src = tokio::fs::read_to_string(&target)
.await
.map_err(|e| mlua::Error::external(format!("http.session: {e}")))?;
jar.merge_jsonl(&src);
}
make_session(&lua, client, Arc::new(Mutex::new(jar)))
}
store = load_store("http.session", &src)?;
}
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)]
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::*;
fn cookie(jar: &CookieJar, domain: &str, name: &str) -> Option<String> {
jar.cookies.get(domain).and_then(|m| m.get(name).cloned())
fn url(s: &str) -> reqwest::Url {
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]
fn set_cookie_defaults_to_request_host() {
let mut jar = CookieJar::default();
jar.set_from_header("example.com", "sid=abc");
assert_eq!(cookie(&jar, "example.com", "sid").as_deref(), Some("abc"));
fn rejects_public_suffix_domain() {
let mut store = new_cookie_store();
// A TLD-scoped cookie would be sent to every .com host ("supercookie").
assert!(store.parse("x=1; Domain=com", &url("http://evil.com/")).is_err());
assert!(store.iter_any().next().is_none());
}
#[test]
fn set_cookie_honors_domain_the_host_belongs_to() {
let mut jar = CookieJar::default();
// A subdomain may scope a cookie up to its parent domain.
jar.set_from_header("app.example.com", "sid=abc; Domain=example.com");
assert_eq!(cookie(&jar, "example.com", "sid").as_deref(), Some("abc"));
fn rejects_foreign_domain() {
let mut store = new_cookie_store();
// Cookie tossing: a response must not plant a cookie for an unrelated
// domain, including a raw-substring "suffix" that is not a subdomain.
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]
fn set_cookie_rejects_foreign_domain() {
let mut jar = CookieJar::default();
// Cookie tossing: a response from one host must not plant a cookie scoped
// to an unrelated domain. The bad Domain= is ignored; it stays host-scoped.
jar.set_from_header("127.0.0.1", "evil=1; Domain=other.example");
assert!(cookie(&jar, "other.example", "evil").is_none());
assert_eq!(cookie(&jar, "127.0.0.1", "evil").as_deref(), Some("1"));
fn subdomain_may_scope_to_parent() {
let mut store = new_cookie_store();
store.parse("sid=abc; Domain=example.com", &url("http://app.example.com/")).unwrap();
// Sent to the parent and to sibling subdomains, not to strangers.
assert_eq!(names_for(&store, "http://example.com/"), ["sid"]);
assert_eq!(names_for(&store, "http://other.example.com/"), ["sid"]);
assert!(names_for(&store, "http://example.org/").is_empty());
}
#[test]
fn set_cookie_rejects_partial_suffix_match() {
let mut jar = CookieJar::default();
// "notexample.com" ends with "example.com" as a raw substring but is not
// a subdomain, so the Domain must be rejected.
jar.set_from_header("notexample.com", "x=1; Domain=example.com");
assert!(cookie(&jar, "example.com", "x").is_none());
assert_eq!(cookie(&jar, "notexample.com", "x").as_deref(), Some("1"));
fn secure_cookie_not_sent_over_http() {
let mut store = new_cookie_store();
store.parse("s=1; Secure", &url("https://example.com/")).unwrap();
assert_eq!(names_for(&store, "https://example.com/"), ["s"]);
assert!(names_for(&store, "http://example.com/").is_empty());
}
#[test]
fn origins_compare_by_scheme_host_port() {
let u = |s: &str| origin_of(&reqwest::Url::parse(s).unwrap());
assert_eq!(u("https://a.com/x"), u("https://a.com/y")); // path-independent
assert_eq!(u("https://a.com"), u("https://a.com:443")); // default port
assert_ne!(u("https://a.com"), u("http://a.com")); // scheme differs
assert_ne!(u("https://a.com"), u("https://b.com")); // host differs
assert_ne!(u("https://a.com"), u("https://a.com:8443")); // port differs
fn max_age_zero_expires_cookie() {
let u = url("http://example.com/");
let mut store = new_cookie_store();
store.parse("sid=abc", &u).unwrap();
assert_eq!(names_for(&store, "http://example.com/"), ["sid"]);
// How servers delete a cookie (e.g. logout).
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]
fn cookies_for_matches_domain_and_subdomains() {
let mut jar = CookieJar::default();
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());
fn load_rejects_malformed_jar() {
assert!(load_store("test", "not json\n").is_err());
}
}

Loading…
Cancel
Save