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. 77
      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. 864
      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

@ -48,16 +48,21 @@ 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. |
| `headers` | table | Response headers, keyed by **lowercase** name. |
| `body` | string | The raw response body (Lua strings are byte sequences). |
| `json` | function | `resp.json()` parses `body` as JSON. See [JSON](#json). |
| 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 {

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save