FastCGI implementation in OCaml

Compare changes

Choose any two refs to compare.

+1
.gitignore
···
+
_build
+98 -2
CLAUDE.md
···
-
This branch exists to figure out an OCaml specification for the FastCGI interface.
+
This repository exists to implement an OCaml specification for the FastCGI interface.
The spec for the FastCGI protocol is in spec/FastCGI_Specification.html.
The Go lang implementation of FastCGI is in spec/fcgi.go.
+
An OCaml specification exists in spec/OCAML.md.
-
Both of these are intended to act as a reference implementation, for us to figure out what the ideal OCaml interface should look like for FastCGI.
+
These are intended to act as a reference implementation for us to figure out what the ideal OCaml interface should look like for FastCGI.
Our target language is OCaml, using the Eio library. The README for Eio is in OCaml-EIO-README.md to give you a reference.
+
The parsing and serialisation should be done using Eio's Buf_read and Buf_write modules.
+
+
You can run commands with:
+
+
- clean: `opam exec -- dune clean`
+
- build: `opam exec -- dune build @check`
+
- docs: `opam exec -- dune build @doc`
+
- build while ignoring warnings: add `--profile=release` to the CLI to activate the profile that ignores warnings
+
+
# Tips on fixing bugs
+
+
If you see errors like this:
+
+
```
+
File "../../.jmap.objs/byte/jmap.odoc":
+
Warning: Hidden fields in type 'Jmap.Email.Identity.identity_create'
+
```
+
+
Then examine the HTML docs built for that module. You will see that there are module references with __ in them, e.g. "Jmap__.Jmap_email_types.Email_address.t" which indicate that the module is being accessed directly instead of via the module aliases defined.
+
+
## Documentation Comments
+
+
When adding OCaml documentation comments, be careful about ambiguous documentation comments. If you see errors like:
+
+
```
+
Error (warning 50 [unexpected-docstring]): ambiguous documentation comment
+
```
+
+
This usually means there isn't enough whitespace between the documentation comment and the code element it's documenting. Always:
+
+
1. Add blank lines between consecutive documentation comments
+
2. Add a blank line before a documentation comment for a module/type/value declaration
+
3. When documenting record fields or variant constructors, place the comment after the field with at least one space
+
+
Example of correct documentation spacing:
+
+
```ocaml
+
(** Module documentation. *)
+
+
(** Value documentation. *)
+
val some_value : int
+
+
(** Type documentation. *)
+
type t =
+
| First (** First constructor *)
+
| Second (** Second constructor *)
+
+
(** Record documentation. *)
+
type record = {
+
field1 : int; (** Field1 documentation *)
+
field2 : string (** Field2 documentation *)
+
}
+
```
+
+
If in doubt, add more whitespace lines than needed - you can always clean this up later with `dune build @fmt` to get ocamlformat to sort out the whitespace properly.
+
+
When adding ocamldoc comments, use the information in the protocol specifications available to also add relevant explanations to the OCamldoc. Do not refer to the specification in the third-person, but directly include enough information in the OCamldoc for a reader to understand the protocol flow directly.
+
+
# Module Structure Guidelines
+
+
IMPORTANT: For all modules, use a nested module structure with a canonical `type t` inside each submodule. This approach ensures consistent type naming and logical grouping of related functionality.
+
+
1. Top-level files should define their main types directly (e.g., `jmap_identity.mli` should define identity-related types at the top level).
+
+
2. Related operations or specialized subtypes should be defined in nested modules within the file:
+
```ocaml
+
module Create : sig
+
type t (* NOT 'type create' or any other name *)
+
(* Functions operating on creation requests *)
+
+
module Response : sig
+
type t
+
(* Functions for creation responses *)
+
end
+
end
+
```
+
+
3. Consistently use `type t` for the main type in each module and submodule.
+
+
4. Functions operating on a type should be placed in the same module as the type.
+
+
5. When a file is named after a concept (e.g., `jmap_identity.mli`), there's no need to have a matching nested module inside the file (e.g., `module Identity : sig...`), as the file itself represents that namespace.
+
+
This structured approach promotes encapsulation, consistent type naming, and clearer organization of related functionality.
+
+
# Software engineering
+
+
We will go through a multi step process to build this library.
+
+
1) we will generate OCaml interface files only, and no module implementations. The purpose here is to write and document the necessary type signatures. Once we generate these, we can check that they work with "dune build @check". Once that succeeds, we will build HTML documentation with "dune build @doc" in order to ensure the interfaces are reasonable.
+
+
2) once these interface files exist, we will build a series of sample binaries that will attempt to implement the library for some sample usecases. This binary will not fully link, but it should type check. The only linking error that we get should be from the missing library implementation that we are currently buildign.
+
+
3) we will calculate the dependency order for each module in our library, and work through an implementation of each one in increasing dependency order (that is, the module with the fewest dependencies should be handled first). For each module interface, we will generate a corresponding module implementation. We will also add test cases for this specific module, and update the dune files. Before proceeding to the next module, a build should be done to ensure the implementation builds and type checks as far as is possible.
+
+4
bin/dune
···
+
(executable
+
(public_name fcgi-server)
+
(name fcgi_server)
+
(libraries cmdliner eio eio_main fastcgi))
+79
bin/fcgi_server.ml
···
+
open Cmdliner
+
+
(* Handler function that processes FastCGI requests *)
+
let handler ~stdout ~stderr request =
+
Eio.traceln "Processing request: %a" Fastcgi.Request.pp request;
+
+
(* Get request parameters *)
+
let params = request.Fastcgi.Request.params in
+
let method_ = Fastcgi.Record.KV.find_opt "REQUEST_METHOD" params |> Option.value ~default:"GET" in
+
let uri = Fastcgi.Record.KV.find_opt "REQUEST_URI" params |> Option.value ~default:"/" in
+
let script_name = Fastcgi.Record.KV.find_opt "SCRIPT_NAME" params |> Option.value ~default:"" in
+
+
(* Log request info *)
+
Eio.traceln " Method: %s" method_;
+
Eio.traceln " URI: %s" uri;
+
Eio.traceln " Script: %s" script_name;
+
+
(* Generate simple HTTP response *)
+
let response_body =
+
Printf.sprintf
+
"<!DOCTYPE html>\n\
+
<html>\n\
+
<head><title>FastCGI OCaml Server</title></head>\n\
+
<body>\n\
+
<h1>FastCGI OCaml Server</h1>\n\
+
<p>Request processed successfully!</p>\n\
+
<ul>\n\
+
<li>Method: %s</li>\n\
+
<li>URI: %s</li>\n\
+
<li>Script: %s</li>\n\
+
</ul>\n\
+
<h2>All Parameters:</h2>\n\
+
<pre>%s</pre>\n\
+
</body>\n\
+
</html>\n"
+
method_ uri script_name
+
(let params_seq = Fastcgi.Record.KV.to_seq params in
+
let params_list = List.of_seq params_seq in
+
String.concat "\n" (List.map (fun (k, v) -> Printf.sprintf "%s = %s" k v) params_list))
+
in
+
+
(* Write HTTP response using FastCGI STDOUT records *)
+
let response_headers =
+
Printf.sprintf
+
"Status: 200 OK\r\n\
+
Content-Type: text/html; charset=utf-8\r\n\
+
Content-Length: %d\r\n\
+
\r\n"
+
(String.length response_body)
+
in
+
stdout response_headers;
+
stderr "stderr stuff";
+
stdout response_body
+
+
let run port =
+
Eio_main.run @@ fun env ->
+
Eio.Switch.run @@ fun sw ->
+
let net = Eio.Stdenv.net env in
+
let addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, port) in
+
let server_socket = Eio.Net.listen net ~backlog:10 ~reuse_addr:true ~sw addr in
+
Eio.traceln "FastCGI server listening on port %d" port;
+
+
(* Run the FastCGI server *)
+
Fastcgi.run server_socket
+
~on_error:(fun ex ->
+
Eio.traceln "Error: %s" (Printexc.to_string ex);
+
Eio.traceln "bt: %s" (Printexc.get_backtrace ()))
+
handler
+
+
let port =
+
let doc = "Port to listen on" in
+
Arg.(value & opt int 9000 & info ["p"; "port"] ~docv:"PORT" ~doc)
+
+
let cmd =
+
let doc = "FastCGI server" in
+
let info = Cmd.info "fcgi-server" ~doc in
+
Cmd.v info Term.(const run $ port)
+
+
let () = exit (Cmd.eval cmd)
+7
config/Caddyfile
···
+
{
+
debug
+
}
+
+
localhost:80 {
+
php_fastcgi 127.0.0.1:9000
+
}
+27
dune-project
···
+
(lang dune 3.0)
+
+
(name fastcgi)
+
+
(generate_opam_files true)
+
+
(source
+
(github ocaml/ocaml-fastcgi))
+
+
(authors "OCaml FastCGI Library Authors")
+
+
(maintainers "OCaml FastCGI Library Authors")
+
+
(license ISC)
+
+
(documentation https://ocaml.github.io/ocaml-fastcgi/)
+
+
(package
+
(name fastcgi)
+
(depends
+
ocaml
+
dune
+
eio
+
cmdliner
+
eio_main)
+
(synopsis "FastCGI protocol implementation for OCaml using Eio")
+
(description "A type-safe implementation of the FastCGI protocol for OCaml using the Eio effects-based IO library. Supports all three FastCGI roles: Responder, Authorizer, and Filter."))
+34
fastcgi.opam
···
+
# This file is generated by dune, edit dune-project instead
+
opam-version: "2.0"
+
synopsis: "FastCGI protocol implementation for OCaml using Eio"
+
description:
+
"A type-safe implementation of the FastCGI protocol for OCaml using the Eio effects-based IO library. Supports all three FastCGI roles: Responder, Authorizer, and Filter."
+
maintainer: ["OCaml FastCGI Library Authors"]
+
authors: ["OCaml FastCGI Library Authors"]
+
license: "ISC"
+
homepage: "https://github.com/ocaml/ocaml-fastcgi"
+
doc: "https://ocaml.github.io/ocaml-fastcgi/"
+
bug-reports: "https://github.com/ocaml/ocaml-fastcgi/issues"
+
depends: [
+
"ocaml"
+
"dune" {>= "3.0"}
+
"eio"
+
"cmdliner"
+
"eio_main"
+
"odoc" {with-doc}
+
]
+
build: [
+
["dune" "subst"] {dev}
+
[
+
"dune"
+
"build"
+
"-p"
+
name
+
"-j"
+
jobs
+
"@install"
+
"@runtest" {with-test}
+
"@doc" {with-doc}
+
]
+
]
+
dev-repo: "git+https://github.com/ocaml/ocaml-fastcgi.git"
+10
lib/dune
···
+
(library
+
(public_name fastcgi)
+
(name fastcgi)
+
(libraries eio fmt)
+
(modules
+
fastcgi
+
fastcgi_record
+
fastcgi_request
+
)
+
)
+50
lib/fastcgi.ml
···
+
module Record = Fastcgi_record
+
+
(** Request-level state machine and application interface *)
+
module Request = Fastcgi_request
+
+
(* The lifetime of the handler is that the fiber should return when the
+
stdout and stderr flows are closed, or an abort request has been received *)
+
let handle req bw cancel fn =
+
let cancel () =
+
Eio.Promise.await cancel;
+
Eio.traceln "cancelled TODO"
+
in
+
let stdout buf = Request.write_stdout_records bw req.Request.request_id buf in
+
let stderr buf = Request.write_stderr_records bw req.Request.request_id buf in
+
let run () =
+
fn ~stdout ~stderr req;
+
Request.write_end_request bw req.Request.request_id 0 Request.Request_complete
+
in
+
Eio.Fiber.first run cancel
+
+
let run ?max_connections ?additional_domains ?stop ~on_error socket handler =
+
Eio.Net.run_server socket ?max_connections ?additional_domains ?stop ~on_error
+
(fun socket peer_address ->
+
let ids = Hashtbl.create 7 in
+
Eio.Switch.run @@ fun sw ->
+
Eio.traceln "%a: accept connection" Eio.Net.Sockaddr.pp peer_address;
+
let input = Eio.Buf_read.of_flow ~max_size:max_int socket in
+
Eio.Buf_write.with_flow socket @@ fun output ->
+
let cont = ref true in
+
try while !cont do
+
match Request.read_request input with
+
| Error msg ->
+
Eio.traceln "%a: failed to read request: %s" Eio.Net.Sockaddr.pp peer_address msg;
+
failwith "done";
+
| Ok req ->
+
cont := req.Request.keep_conn;
+
Eio.traceln "%a: %b read request %a" Eio.Net.Sockaddr.pp peer_address !cont Request.pp req;
+
Eio.Fiber.fork ~sw (fun () ->
+
Eio.Switch.run ~name:"req_handler" @@ fun sw ->
+
let cancel, canceler = Eio.Promise.create () in
+
Hashtbl.add ids req.Request.request_id canceler;
+
Eio.Switch.on_release sw (fun () ->
+
Hashtbl.remove ids req.Request.request_id
+
);
+
handle req output cancel handler;
+
);
+
done
+
with Eio.Io (Eio.Net.E (Connection_reset _), _) ->
+
Eio.traceln "%a: connection reset" Eio.Net.Sockaddr.pp peer_address
+
)
+28
lib/fastcgi.mli
···
+
(** FastCGI protocol implementation for OCaml using Eio.
+
+
This library provides a complete implementation of the FastCGI protocol
+
for building high-performance web applications in OCaml. *)
+
+
+
(** {1 Core Protocol Components} *)
+
+
(** Record-level protocol handling *)
+
module Record = Fastcgi_record
+
+
(** Request-level state machine and application interface *)
+
module Request = Fastcgi_request
+
+
(** {1 High-level Request Processing} *)
+
+
(** [handle_connection ~sw flow handler] handles complete FastCGI connection.
+
Reads requests from flow, processes them with handler, multiplexes responses.
+
Continues until connection is closed. *)
+
val run :
+
?max_connections:int ->
+
?additional_domains:[> Eio.Domain_manager.ty ] Eio.Resource.t *
+
int ->
+
?stop:'a Eio__core.Promise.t ->
+
on_error:(exn -> unit) ->
+
[> [> `Generic ] Eio.Net.listening_socket_ty ] Eio.Resource.t ->
+
(stdout:(string -> unit) ->
+
stderr:(string -> unit) -> Request.t -> unit) -> 'a
+305
lib/fastcgi_record.ml
···
+
+
(** {1 Error Handling Helpers} *)
+
+
(** Helper functions for consistent error handling *)
+
let invalid_version version =
+
invalid_arg (Printf.sprintf "Unsupported FastCGI version: %d" version)
+
+
let unknown_record_type n =
+
invalid_arg (Printf.sprintf "Unknown FastCGI record type: %d" n)
+
+
let buffer_underflow msg =
+
failwith (Printf.sprintf "Unexpected end of buffer while %s" msg)
+
+
let check_bounds buf pos len context =
+
if pos + len > String.length buf then
+
buffer_underflow context
+
+
type version = int
+
+
type record =
+
| Begin_request
+
| Abort_request
+
| End_request
+
| Params
+
| Stdin
+
| Stdout
+
| Stderr
+
| Data
+
| Get_values
+
| Get_values_result
+
| Unknown_type
+
+
let record_to_int = function
+
| Begin_request -> 1
+
| Abort_request -> 2
+
| End_request -> 3
+
| Params -> 4
+
| Stdin -> 5
+
| Stdout -> 6
+
| Stderr -> 7
+
| Data -> 8
+
| Get_values -> 9
+
| Get_values_result -> 10
+
| Unknown_type -> 11
+
+
let record_of_int = function
+
| 1 -> Begin_request
+
| 2 -> Abort_request
+
| 3 -> End_request
+
| 4 -> Params
+
| 5 -> Stdin
+
| 6 -> Stdout
+
| 7 -> Stderr
+
| 8 -> Data
+
| 9 -> Get_values
+
| 10 -> Get_values_result
+
| 11 -> Unknown_type
+
| n -> unknown_record_type n
+
+
let pp_record ppf = function
+
| Begin_request -> Format.pp_print_string ppf "Begin_request"
+
| Abort_request -> Format.pp_print_string ppf "Abort_request"
+
| End_request -> Format.pp_print_string ppf "End_request"
+
| Params -> Format.pp_print_string ppf "Params"
+
| Stdin -> Format.pp_print_string ppf "Stdin"
+
| Stdout -> Format.pp_print_string ppf "Stdout"
+
| Stderr -> Format.pp_print_string ppf "Stderr"
+
| Data -> Format.pp_print_string ppf "Data"
+
| Get_values -> Format.pp_print_string ppf "Get_values"
+
| Get_values_result -> Format.pp_print_string ppf "Get_values_result"
+
| Unknown_type -> Format.pp_print_string ppf "Unknown_type"
+
+
type request_id = int
+
+
type t = {
+
version : version;
+
record_type : record;
+
request_id : request_id;
+
content : string;
+
offset : int;
+
length : int;
+
}
+
+
let pp ?(max_content_len=100) ppf record =
+
let actual_content = String.sub record.content record.offset record.length in
+
let truncated_content =
+
let len = String.length actual_content in
+
if len <= max_content_len then actual_content
+
else String.sub actual_content 0 max_content_len ^ "..." ^ Printf.sprintf " (%d more bytes)" (len - max_content_len)
+
in
+
Format.fprintf ppf
+
"@[<2>{ version = %d;@ record_type = %a;@ request_id = %d;@ content = %S;@ offset = %d;@ length = %d }@]"
+
record.version
+
pp_record record.record_type
+
record.request_id
+
truncated_content
+
record.offset
+
record.length
+
+
(* FastCGI constants *)
+
let fcgi_version_1 = 1
+
let fcgi_header_len = 8
+
+
let read buf_read =
+
Printf.eprintf "[DEBUG] Fastcgi_record.read: Starting to read record\n%!";
+
(* Read the 8-byte header *)
+
Printf.eprintf "[DEBUG] Fastcgi_record.read: Reading %d-byte header\n%!" fcgi_header_len;
+
let header = Eio.Buf_read.take fcgi_header_len buf_read in
+
+
(* Parse header fields *)
+
let version = Char.code header.[0] in
+
let record_type_int = Char.code header.[1] in
+
let request_id = (Char.code header.[2] lsl 8) lor (Char.code header.[3]) in
+
let content_length = (Char.code header.[4] lsl 8) lor (Char.code header.[5]) in
+
let padding_length = Char.code header.[6] in
+
let _reserved = Char.code header.[7] in
+
+
Printf.eprintf "[DEBUG] Fastcgi_record.read: Header parsed - version=%d, type=%d, id=%d, content_len=%d, padding=%d\n%!"
+
version record_type_int request_id content_length padding_length;
+
+
(* Validate version *)
+
if version <> fcgi_version_1 then invalid_version version;
+
+
(* Convert record type *)
+
let record_type = record_of_int record_type_int in
+
Printf.eprintf "[DEBUG] Fastcgi_record.read: Record type = %s\n%!"
+
(Format.asprintf "%a" pp_record record_type);
+
+
(* Read content *)
+
let content =
+
if content_length = 0 then (
+
Printf.eprintf "[DEBUG] Fastcgi_record.read: No content to read (length=0)\n%!";
+
""
+
) else (
+
Printf.eprintf "[DEBUG] Fastcgi_record.read: Reading %d bytes of content\n%!" content_length;
+
let c = Eio.Buf_read.take content_length buf_read in
+
Printf.eprintf "[DEBUG] Fastcgi_record.read: Successfully read %d bytes\n%!" (String.length c);
+
c
+
)
+
in
+
+
(* Skip padding *)
+
if padding_length > 0 then (
+
Printf.eprintf "[DEBUG] Fastcgi_record.read: Skipping %d bytes of padding\n%!" padding_length;
+
ignore (Eio.Buf_read.take padding_length buf_read)
+
);
+
+
let record = { version; record_type; request_id; content; offset = 0; length = String.length content } in
+
Printf.eprintf "[DEBUG] Fastcgi_record.read: Complete record = %s\n%!"
+
(Format.asprintf "%a" (pp ~max_content_len:50) record);
+
record
+
+
let write buf_write record =
+
let total_content_length = String.length record.content in
+
let content_offset = record.offset in
+
let content_length = record.length in
+
+
(* Validate bounds *)
+
if content_offset < 0 || content_offset > total_content_length then
+
invalid_arg "Fastcgi_record.write: offset out of bounds";
+
if content_length < 0 || content_offset + content_length > total_content_length then
+
invalid_arg "Fastcgi_record.write: length out of bounds";
+
+
(* Calculate padding for 8-byte alignment *)
+
let padding_length = (8 - (content_length land 7)) land 7 in
+
+
(* Create and write header *)
+
let header = Bytes.create fcgi_header_len in
+
Bytes.set_uint8 header 0 record.version;
+
Bytes.set_uint8 header 1 (record_to_int record.record_type);
+
Bytes.set_uint16_be header 2 record.request_id;
+
Bytes.set_uint16_be header 4 content_length;
+
Bytes.set_uint8 header 6 padding_length;
+
Bytes.set_uint8 header 7 0; (* reserved *)
+
+
Eio.Buf_write.string buf_write (Bytes.to_string header);
+
+
(* Write content with offset and length *)
+
if content_length > 0 then
+
Eio.Buf_write.string buf_write record.content ~off:content_offset ~len:content_length;
+
+
(* Write padding *)
+
if padding_length > 0 then
+
Eio.Buf_write.string buf_write (String.make padding_length '\000')
+
+
let create ?(version=1) ~record ~request_id ~content ?(offset=0) ?length () =
+
let content_length = match length with
+
| None -> String.length content - offset
+
| Some l -> l
+
in
+
{ version; record_type = record; request_id; content; offset; length = content_length }
+
+
module KV = struct
+
type t = (string * string) list
+
+
let empty = []
+
+
let add key value kvs = (key, value) :: kvs
+
+
let remove key kvs = List.filter (fun (k, _) -> k <> key) kvs
+
+
let cardinal kvs = List.length kvs
+
+
let find key kvs =
+
try List.assoc key kvs
+
with Not_found -> raise Not_found
+
+
let find_opt key kvs =
+
try Some (List.assoc key kvs)
+
with Not_found -> None
+
+
let to_seq kvs = List.to_seq kvs
+
+
let of_seq seq = List.of_seq seq
+
+
(** Helper functions for length encoding/decoding *)
+
let is_long_length first_byte = first_byte land 0x80 <> 0
+
+
let decode_short_length first_byte = first_byte
+
+
let decode_long_length buf pos =
+
check_bounds buf pos 4 "reading 4-byte length";
+
let first_byte = Char.code buf.[pos] in
+
((first_byte land 0x7f) lsl 24) lor
+
((Char.code buf.[pos + 1]) lsl 16) lor
+
((Char.code buf.[pos + 2]) lsl 8) lor
+
(Char.code buf.[pos + 3])
+
+
let encode_length len =
+
if len <= 127 then
+
String.make 1 (Char.chr len)
+
else
+
let b = Bytes.create 4 in
+
Bytes.set_int32_be b 0 (Int32.logor (Int32.of_int len) 0x80000000l);
+
Bytes.to_string b
+
+
let decode_length buf pos =
+
check_bounds buf pos 1 "reading length";
+
let first_byte = Char.code buf.[pos] in
+
if is_long_length first_byte then
+
(* Four byte length *)
+
let len = decode_long_length buf pos in
+
(len, pos + 4)
+
else
+
(* Single byte length *)
+
(decode_short_length first_byte, pos + 1)
+
+
let encode kvs =
+
let buf = Buffer.create 256 in
+
List.iter (fun (key, value) ->
+
let key_len = String.length key in
+
let value_len = String.length value in
+
Buffer.add_string buf (encode_length key_len);
+
Buffer.add_string buf (encode_length value_len);
+
Buffer.add_string buf key;
+
Buffer.add_string buf value
+
) kvs;
+
Buffer.contents buf
+
+
(** Extract key-value pair from buffer at given position *)
+
let extract_kv_pair content pos name_len value_len =
+
check_bounds content pos (name_len + value_len) "reading key-value data";
+
let name = String.sub content pos name_len in
+
let value = String.sub content (pos + name_len) value_len in
+
(name, value)
+
+
let decode content =
+
let len = String.length content in
+
let rec loop pos acc =
+
if pos >= len then
+
List.rev acc
+
else
+
(* Read name length *)
+
let name_len, pos = decode_length content pos in
+
(* Read value length *)
+
let value_len, pos = decode_length content pos in
+
+
(* Extract name and value *)
+
let name, value = extract_kv_pair content pos name_len value_len in
+
+
loop (pos + name_len + value_len) ((name, value) :: acc)
+
in
+
loop 0 []
+
+
let read buf_read =
+
(* For reading from a stream, we need to determine how much data to read.
+
Since we're in a record context, we should read all available data
+
until we hit the end of the record content. *)
+
let content = Eio.Buf_read.take_all buf_read in
+
decode content
+
+
let write buf_write kvs =
+
let encoded = encode kvs in
+
Eio.Buf_write.string buf_write encoded
+
+
let pp ppf kvs =
+
Format.fprintf ppf "@[<2>[@[";
+
let first = ref true in
+
List.iter (fun (key, value) ->
+
if not !first then Format.fprintf ppf ";@ ";
+
first := false;
+
Format.fprintf ppf "(%S, %S)" key value
+
) kvs;
+
Format.fprintf ppf "@]]@]"
+
end
+131
lib/fastcgi_record.mli
···
+
(** FastCGI record handling for parsing and creating protocol messages.
+
+
This module provides the core functionality for handling FastCGI records,
+
which are the fundamental units of communication in the FastCGI protocol.
+
Records multiplex multiple requests over a single connection and provide
+
independent data streams within each request. *)
+
+
(** {1 Record Structure} *)
+
+
(** FastCGI protocol version *)
+
type version = int
+
+
(** Record types define the purpose and interpretation of record content *)
+
type record =
+
| Begin_request (** Start a new FastCGI request *)
+
| Abort_request (** Abort an existing request *)
+
| End_request (** Terminate a request *)
+
| Params (** Name-value pairs (environment variables) *)
+
| Stdin (** Standard input data stream *)
+
| Stdout (** Standard output data stream *)
+
| Stderr (** Standard error data stream *)
+
| Data (** Additional data stream for Filter role *)
+
| Get_values (** Query application capabilities *)
+
| Get_values_result (** Response to capability query *)
+
| Unknown_type (** Unknown record type response *)
+
+
(** [record_type_to_int rt] converts record type to its protocol integer value *)
+
val record_to_int : record -> int
+
+
(** [record_type_of_int i] converts protocol integer to record type.
+
Raises Invalid_argument for unknown values. *)
+
val record_of_int : int -> record
+
+
(** [pp_record ppf rt] pretty-prints a record type *)
+
val pp_record : Format.formatter -> record -> unit
+
+
(** Request identifier for multiplexing multiple requests over one connection.
+
Request ID 0 is reserved for management records. *)
+
type request_id = int
+
+
(** A complete FastCGI record containing header and content.
+
Records consist of an 8-byte fixed header followed by variable-length
+
content and optional padding for alignment. *)
+
type t = {
+
version : version; (** Protocol version (always 1) *)
+
record_type : record; (** Type of this record *)
+
request_id : request_id; (** Request identifier *)
+
content : string; (** Record content data *)
+
offset : int; (** Offset within content string (default: 0) *)
+
length : int; (** Length to use from content (default: String.length content) *)
+
}
+
+
(** [pp ?max_content_len ppf record] pretty-prints a FastCGI record.
+
[max_content_len] limits the displayed content length (default: 100 bytes) *)
+
val pp : ?max_content_len:int -> Format.formatter -> t -> unit
+
+
(** {1 Record Operations} *)
+
+
(** [read buf_read] reads a complete FastCGI record from the input buffer.
+
Returns the parsed record or raises an exception if the record is malformed
+
or if there's insufficient data. The padding is automatically handled and
+
discarded during parsing. *)
+
val read : Eio.Buf_read.t -> t
+
+
(** [write buf_write record] writes a FastCGI record to the output buffer.
+
The record header is automatically constructed from the record fields,
+
and appropriate padding is added to align the record on 8-byte boundaries
+
for optimal performance. Uses the record's offset and length fields to
+
determine which portion of the content to write. *)
+
val write : Eio.Buf_write.t -> t -> unit
+
+
(** [create ?version ~record ~request_id ~content ?offset ?length] creates a new record
+
with the specified parameters. Version defaults to 1 (the only supported version).
+
If offset and length are not provided, the entire content string is used. *)
+
val create : ?version:version -> record:record ->
+
request_id:request_id -> content:string ->
+
?offset:int -> ?length:int -> unit -> t
+
+
(** {1 Key-Value Pairs} *)
+
+
(** Key-value pairs are used to transmit environment variables and other
+
key-value data in FCGI_PARAMS and other record types. The FastCGI protocol
+
uses a specific encoding where lengths are variable-width (1 or 4 bytes). *)
+
+
module KV : sig
+
(** Type for key-value pair collections *)
+
type t
+
+
(** [empty] creates an empty key-value pair collection *)
+
val empty : t
+
+
(** [add name value pairs] adds a key-value pair to the collection *)
+
val add : string -> string -> t -> t
+
+
(** [remove name pairs] removes all pairs with the given key *)
+
val remove : string -> t -> t
+
+
(** [find name pairs] returns the value associated with the given key.
+
Raises Not_found if the key is not present. *)
+
val find : string -> t -> string
+
+
(** [find_opt name pairs] returns Some value or None if not found *)
+
val find_opt : string -> t -> string option
+
+
(** [to_seq pairs] converts to a sequence of (key, value) tuples *)
+
val to_seq : t -> (string * string) Seq.t
+
+
(** [of_seq pairs] creates from a sequence of (key, value) tuples *)
+
val of_seq : (string * string) Seq.t -> t
+
+
(** [cardinal pairs] returns the number of key-value pairs *)
+
val cardinal : t -> int
+
+
(** [read buf_read] reads key-value pairs from a buffer.
+
Handles the FastCGI variable-length encoding where lengths ≤ 127 bytes
+
use 1 byte, longer lengths use 4 bytes with the high bit set. *)
+
val read : Eio.Buf_read.t -> t
+
+
(** [write buf_write pairs] writes key-value pairs to a buffer using
+
the FastCGI encoding format *)
+
val write : Eio.Buf_write.t -> t -> unit
+
+
(** [encode pairs] returns the encoded byte string representation *)
+
val encode : t -> string
+
+
(** [decode content] parses encoded key-value pairs from a string *)
+
val decode : string -> t
+
+
(** [pp ppf pairs] pretty-prints key-value pairs *)
+
val pp : Format.formatter -> t -> unit
+
end
+279
lib/fastcgi_request.ml
···
+
open Fastcgi_record
+
+
(** {1 Request Roles} *)
+
+
type role =
+
| Responder
+
| Authorizer
+
| Filter
+
+
let pp_role ppf = function
+
| Responder -> Format.pp_print_string ppf "Responder"
+
| Authorizer -> Format.pp_print_string ppf "Authorizer"
+
| Filter -> Format.pp_print_string ppf "Filter"
+
+
let role_of_begin_request record =
+
if record.record_type <> Begin_request then
+
Error "Expected BEGIN_REQUEST record"
+
else if String.length record.content <> 8 then
+
Error "Invalid BEGIN_REQUEST content length"
+
else
+
let content = record.content in
+
let role_int = (Char.code content.[0] lsl 8) lor (Char.code content.[1]) in
+
match role_int with
+
| 1 -> Ok Responder
+
| 2 -> Ok Authorizer
+
| 3 -> Ok Filter
+
| n -> Error (Printf.sprintf "Unknown FastCGI role: %d" n)
+
+
(** {1 Request Context} *)
+
+
type t = {
+
request_id : request_id;
+
role : role;
+
keep_conn : bool;
+
params : KV.t;
+
stdin_data : string;
+
data_stream : string option;
+
}
+
+
let pp ppf request =
+
let data_str = match request.data_stream with
+
| None -> "None"
+
| Some d -> Printf.sprintf "Some(%d bytes)" (String.length d)
+
in
+
Format.fprintf ppf
+
"@[<2>{ request_id = %d;@ role = %a;@ keep_conn = %b;@ params = %a;@ stdin = %d bytes;@ data = %s }@]"
+
request.request_id
+
pp_role request.role
+
request.keep_conn
+
(KV.pp) request.params
+
(String.length request.stdin_data)
+
data_str
+
+
let create record =
+
match role_of_begin_request record with
+
| Error _ as e -> e
+
| Ok role ->
+
if String.length record.content <> 8 then
+
Error "Invalid BEGIN_REQUEST content length"
+
else
+
let flags_int = Char.code record.content.[2] in
+
let keep_conn = (flags_int land 1) <> 0 in
+
Ok {
+
request_id = record.request_id;
+
role;
+
keep_conn;
+
params = KV.empty;
+
stdin_data = "";
+
data_stream = if role = Filter then Some "" else None;
+
}
+
+
+
(** {1 Stream Processing} *)
+
+
(** Helper functions for result binding to simplify nested pattern matching *)
+
let ( let* ) = Result.bind
+
+
let is_stream_terminator record =
+
String.length record.content = 0
+
+
+
let read_params buf_read =
+
Printf.eprintf "[DEBUG] read_params_from_flow: Starting\n%!";
+
let params = ref KV.empty in
+
let rec loop () =
+
try
+
Printf.eprintf "[DEBUG] read_params_from_flow: Reading next PARAMS record\n%!";
+
let record = Fastcgi_record.read buf_read in
+
Printf.eprintf "[DEBUG] read_params_from_flow: Got record type=%s, content_length=%d\n%!"
+
(Format.asprintf "%a" pp_record record.record_type)
+
(String.length record.content);
+
if record.record_type <> Params then
+
Error (Printf.sprintf "Expected PARAMS record, got %s"
+
(Format.asprintf "%a" pp_record record.record_type))
+
else if is_stream_terminator record then (
+
Printf.eprintf "[DEBUG] read_params_from_flow: Got stream terminator, returning %d params\n%!"
+
(Fastcgi_record.KV.cardinal !params);
+
Ok !params
+
) else (
+
let record_params = KV.decode record.content in
+
Printf.eprintf "[DEBUG] read_params_from_flow: Decoded %d params from record\n%!"
+
(Fastcgi_record.KV.cardinal record_params);
+
params := KV.to_seq record_params
+
|> Seq.fold_left (fun acc (k, v) -> KV.add k v acc) !params;
+
loop ()
+
)
+
with
+
| End_of_file ->
+
Printf.eprintf "[DEBUG] read_params_from_flow: Hit End_of_file\n%!";
+
Error "Unexpected end of stream while reading PARAMS"
+
| exn ->
+
Printf.eprintf "[DEBUG] read_params_from_flow: Exception: %s\n%!" (Printexc.to_string exn);
+
Error (Printf.sprintf "Error reading PARAMS: %s" (Printexc.to_string exn))
+
in
+
loop ()
+
+
let read_stdin buf_read =
+
Printf.eprintf "[DEBUG] read_stdin_from_flow: Starting\n%!";
+
let data = Buffer.create 1024 in
+
let rec loop () =
+
try
+
Printf.eprintf "[DEBUG] read_stdin_from_flow: Reading next STDIN record\n%!";
+
let record = Fastcgi_record.read buf_read in
+
Printf.eprintf "[DEBUG] read_stdin_from_flow: Got record type=%s, content_length=%d\n%!"
+
(Format.asprintf "%a" pp_record record.record_type)
+
(String.length record.content);
+
if record.record_type <> Stdin then
+
Error (Printf.sprintf "Expected STDIN record, got %s"
+
(Format.asprintf "%a" pp_record record.record_type))
+
else if is_stream_terminator record then (
+
Printf.eprintf "[DEBUG] read_stdin_from_flow: Got stream terminator, total stdin=%d bytes\n%!"
+
(Buffer.length data);
+
Ok (Buffer.contents data)
+
) else (
+
Buffer.add_string data record.content;
+
Printf.eprintf "[DEBUG] read_stdin_from_flow: Added %d bytes, total now %d\n%!"
+
(String.length record.content) (Buffer.length data);
+
loop ()
+
)
+
with
+
| End_of_file ->
+
Printf.eprintf "[DEBUG] read_stdin_from_flow: Hit End_of_file\n%!";
+
Error "Unexpected end of stream while reading STDIN"
+
| exn ->
+
Printf.eprintf "[DEBUG] read_stdin_from_flow: Exception: %s\n%!" (Printexc.to_string exn);
+
Error (Printf.sprintf "Error reading STDIN: %s" (Printexc.to_string exn))
+
in
+
loop ()
+
+
(** Read DATA stream for Filter role *)
+
let read_data buf_read =
+
let data_buf = Buffer.create 1024 in
+
let rec read_data () =
+
try
+
let record = Fastcgi_record.read buf_read in
+
if record.record_type <> Data then
+
Error "Expected DATA record"
+
else if is_stream_terminator record then
+
Ok (Buffer.contents data_buf)
+
else (
+
Buffer.add_string data_buf record.content;
+
read_data ()
+
)
+
with
+
| End_of_file -> Error "Unexpected end of DATA stream"
+
| exn -> Error (Printf.sprintf "Error reading DATA: %s" (Printexc.to_string exn))
+
in
+
read_data ()
+
+
(** Read request streams based on role *)
+
let read_request_streams request buf_read =
+
Printf.eprintf "[DEBUG] read_request_streams: Processing role=%s\n%!"
+
(Format.asprintf "%a" pp_role request.role);
+
match request.role with
+
| Authorizer ->
+
Printf.eprintf "[DEBUG] read_request_streams: Authorizer role, no streams to read\n%!";
+
Ok request
+
| Responder ->
+
Printf.eprintf "[DEBUG] read_request_streams: Responder role, reading STDIN\n%!";
+
let* stdin_data = read_stdin buf_read in
+
Printf.eprintf "[DEBUG] read_request_streams: Got STDIN data, %d bytes\n%!" (String.length stdin_data);
+
Ok { request with stdin_data }
+
| Filter ->
+
Printf.eprintf "[DEBUG] read_request_streams: Filter role, reading STDIN and DATA\n%!";
+
let* stdin_data = read_stdin buf_read in
+
Printf.eprintf "[DEBUG] read_request_streams: Got STDIN data, %d bytes\n%!" (String.length stdin_data);
+
let request = { request with stdin_data } in
+
let* data = read_data buf_read in
+
Printf.eprintf "[DEBUG] read_request_streams: Got DATA stream, %d bytes\n%!" (String.length data);
+
Ok { request with data_stream = Some data }
+
+
let read_request buf_read =
+
Printf.eprintf "[DEBUG] read_request: Starting\n%!";
+
try
+
(* Read BEGIN_REQUEST *)
+
Printf.eprintf "[DEBUG] read_request: Reading BEGIN_REQUEST record\n%!";
+
let begin_record = Fastcgi_record.read buf_read in
+
Printf.eprintf "[DEBUG] read_request: Got BEGIN_REQUEST record: %s\n%!"
+
(Format.asprintf "%a" (Fastcgi_record.pp ~max_content_len:50) begin_record);
+
let* request = create begin_record in
+
Printf.eprintf "[DEBUG] read_request: Created request with role=%s, id=%d\n%!"
+
(Format.asprintf "%a" pp_role request.role) request.request_id;
+
(* Read PARAMS stream *)
+
Printf.eprintf "[DEBUG] read_request: Reading PARAMS stream\n%!";
+
let* params = read_params buf_read in
+
Printf.eprintf "[DEBUG] read_request: Got %d params\n%!" (Fastcgi_record.KV.cardinal params);
+
let request = { request with params } in
+
(* Read remaining streams based on role *)
+
Printf.eprintf "[DEBUG] read_request: Reading streams for role=%s\n%!"
+
(Format.asprintf "%a" pp_role request.role);
+
let result = read_request_streams request buf_read in
+
Printf.eprintf "[DEBUG] read_request: Finished reading request\n%!";
+
result
+
with
+
| End_of_file ->
+
Printf.eprintf "[DEBUG] read_request: Hit End_of_file\n%!";
+
Error "Unexpected end of stream"
+
| exn ->
+
Printf.eprintf "[DEBUG] read_request: Exception: %s\n%!" (Printexc.to_string exn);
+
Error (Printf.sprintf "Error reading request: %s" (Printexc.to_string exn))
+
+
(** {1 Response Generation} *)
+
+
type app_status = int
+
type protocol_status =
+
| Request_complete
+
| Cant_mpx_conn
+
| Overloaded
+
| Unknown_role
+
+
let pp_protocol_status ppf = function
+
| Request_complete -> Format.pp_print_string ppf "Request_complete"
+
| Cant_mpx_conn -> Format.pp_print_string ppf "Cant_mpx_conn"
+
| Overloaded -> Format.pp_print_string ppf "Overloaded"
+
| Unknown_role -> Format.pp_print_string ppf "Unknown_role"
+
+
let protocol_status_to_int = function
+
| Request_complete -> 0
+
| Cant_mpx_conn -> 1
+
| Overloaded -> 2
+
| Unknown_role -> 3
+
+
let write_stream_records buf_write request_id record_type content =
+
let max_chunk = 65535 in (* FastCGI max record content length *)
+
let len = String.length content in
+
let rec chunk_string pos =
+
if pos < len then begin
+
let chunk_len = min max_chunk (len - pos) in
+
let record = Fastcgi_record.create ~record:record_type ~request_id ~content ~offset:pos ~length:chunk_len () in
+
Fastcgi_record.write buf_write record;
+
chunk_string (pos + chunk_len)
+
end
+
in
+
chunk_string 0;
+
let terminator = Fastcgi_record.create ~record:record_type ~request_id ~content:"" () in
+
Fastcgi_record.write buf_write terminator
+
+
let write_stdout_records buf_write request_id content =
+
Printf.eprintf "[DEBUG] write_stdout_records: Writing %d bytes for request_id=%d\n%!"
+
(String.length content) request_id;
+
write_stream_records buf_write request_id Stdout content
+
+
let write_stderr_records buf_write request_id content =
+
Printf.eprintf "[DEBUG] write_stderr_records: Writing %d bytes for request_id=%d\n%!"
+
(String.length content) request_id;
+
write_stream_records buf_write request_id Stderr content
+
+
let write_end_request buf_write request_id app_status protocol_status =
+
let content =
+
let buf = Bytes.create 8 in
+
Bytes.set_int32_be buf 0 (Int32.of_int app_status);
+
Bytes.set_uint8 buf 4 (protocol_status_to_int protocol_status);
+
Bytes.set_uint8 buf 5 0; (* reserved *)
+
Bytes.set_uint8 buf 6 0; (* reserved *)
+
Bytes.set_uint8 buf 7 0; (* reserved *)
+
Bytes.to_string buf
+
in
+
let record = Fastcgi_record.create ~record:End_request ~request_id ~content () in
+
Fastcgi_record.write buf_write record
+80
lib/fastcgi_request.mli
···
+
(** FastCGI request handling with functional state management and Eio flows.
+
+
This module provides a functional approach to FastCGI request processing,
+
using immutable data structures and higher-order functions. Input and output
+
are handled through Eio flows for efficient streaming I/O. *)
+
+
(** {1 Request Roles} *)
+
+
(** FastCGI application roles defining request processing behavior *)
+
type role =
+
| Responder (** Standard CGI-like request/response processing *)
+
| Authorizer (** Authorization decision with optional variables *)
+
| Filter (** Content filtering with additional data stream *)
+
+
(** [pp_role ppf role] pretty-prints a FastCGI role *)
+
val pp_role : Format.formatter -> role -> unit
+
+
(** [role_of_begin_request record] extracts role from BEGIN_REQUEST record *)
+
val role_of_begin_request : Fastcgi_record.t -> (role, string) result
+
+
(** {1 Request Context} *)
+
+
(** Immutable request context containing all request data *)
+
type t = private {
+
request_id : Fastcgi_record.request_id; (** Request identifier *)
+
role : role; (** Application role *)
+
keep_conn : bool; (** Connection keep-alive flag *)
+
params : Fastcgi_record.KV.t; (** Environment parameters *)
+
stdin_data : string; (** Complete STDIN content *)
+
data_stream : string option; (** DATA stream for Filter role *)
+
}
+
+
(** [pp ppf request] pretty-prints a request context *)
+
val pp : Format.formatter -> t -> unit
+
+
(** [create record] creates request context from BEGIN_REQUEST record *)
+
val create : Fastcgi_record.t -> (t, string) result
+
+
(** {1 Stream Processing} *)
+
+
(** [read_params buf_read] reads PARAMS stream from buf_read until empty record.
+
Returns the accumulated parameters. *)
+
val read_params : Eio.Buf_read.t -> (Fastcgi_record.KV.t, string) result
+
+
(** [read_stdin buf_read] reads STDIN stream from buf_read until empty record.
+
Returns the accumulated data. *)
+
val read_stdin : Eio.Buf_read.t -> (string, string) result
+
+
(** [read_data buf_read] reads DATA stream from buf_read until empty record.
+
Returns the accumulated data for Filter role. *)
+
val read_data : Eio.Buf_read.t -> (string, string) result
+
+
(** [read_request buf_read] reads a complete FastCGI request from buf_read.
+
Processes BEGIN_REQUEST, PARAMS, STDIN, and DATA records until complete.
+
Returns the populated request context. *)
+
val read_request : Eio.Buf_read.t -> (t, string) result
+
+
(** {1 Response Generation} *)
+
+
(** Response status codes *)
+
type app_status = int
+
type protocol_status =
+
| Request_complete
+
| Cant_mpx_conn
+
| Overloaded
+
| Unknown_role
+
+
(** [pp_protocol_status ppf status] pretty-prints protocol status *)
+
val pp_protocol_status : Format.formatter -> protocol_status -> unit
+
+
(** [write_stdout_records buf_write request_id content] writes STDOUT stream records.
+
Splits content into chunks and writes with terminator. *)
+
val write_stdout_records : Eio.Buf_write.t -> Fastcgi_record.request_id -> string -> unit
+
+
(** [write_stderr_records buf_write request_id content] writes STDERR stream records.
+
Splits content into chunks and writes with terminator. *)
+
val write_stderr_records : Eio.Buf_write.t -> Fastcgi_record.request_id -> string -> unit
+
+
(** [write_end_request buf_write request_id app_status protocol_status] writes END_REQUEST record. *)
+
val write_end_request : Eio.Buf_write.t -> Fastcgi_record.request_id -> app_status -> protocol_status -> unit
+13
test/dune
···
+
(test
+
(name simple_test)
+
(modules simple_test)
+
(libraries fastcgi eio eio_main)
+
(deps (source_tree test_cases))
+
)
+
+
(test
+
(name validate_all_test_cases)
+
(modules validate_all_test_cases)
+
(libraries fastcgi eio eio_main)
+
(deps (source_tree test_cases))
+
)
+161
test/simple_test.ml
···
+
open Fastcgi
+
+
let hex_dump ?(max_bytes=256) content =
+
let len = String.length content in
+
let display_len = min len max_bytes in
+
let bytes_per_line = 16 in
+
let lines_to_show = (display_len - 1) / bytes_per_line + 1 in
+
+
for i = 0 to lines_to_show - 1 do
+
let start = i * bytes_per_line in
+
let end_ = min (start + bytes_per_line) display_len in
+
Printf.printf " %04x: " start;
+
+
(* Print hex bytes *)
+
for j = start to end_ - 1 do
+
Printf.printf "%02x " (Char.code content.[j]);
+
if j = start + 7 then Printf.printf " "
+
done;
+
+
(* Pad remaining space *)
+
for _ = end_ to start + bytes_per_line - 1 do
+
Printf.printf " ";
+
if end_ <= start + 7 then Printf.printf " "
+
done;
+
+
Printf.printf " |";
+
+
(* Print ASCII representation *)
+
for j = start to end_ - 1 do
+
let c = content.[j] in
+
if c >= ' ' && c <= '~' then
+
Printf.printf "%c" c
+
else
+
Printf.printf "."
+
done;
+
+
Printf.printf "|\n%!"
+
done;
+
+
if len > max_bytes then
+
Printf.printf " ... (%d more bytes truncated)\n%!" (len - max_bytes)
+
+
+
let test_record_types () =
+
Printf.printf "Testing record type conversions...\n%!";
+
+
let test_type rt expected_int =
+
assert (Record.record_to_int rt = expected_int);
+
assert (Record.record_of_int expected_int = rt)
+
in
+
+
test_type Begin_request 1;
+
test_type Abort_request 2;
+
test_type End_request 3;
+
test_type Params 4;
+
test_type Stdin 5;
+
test_type Stdout 6;
+
test_type Stderr 7;
+
test_type Data 8;
+
test_type Get_values 9;
+
test_type Get_values_result 10;
+
test_type Unknown_type 11;
+
+
(* Test invalid record type *)
+
(try
+
let _ = Record.record_of_int 99 in
+
assert false (* Should not reach here *)
+
with Invalid_argument _ -> ());
+
+
Printf.printf "āœ“ Record type conversion test passed\n%!"
+
+
let test_kv_encoding () =
+
Printf.printf "Testing key-value encoding...\n%!";
+
+
let kv = Record.KV.empty
+
|> Record.KV.add "REQUEST_METHOD" "GET"
+
|> Record.KV.add "SERVER_NAME" "localhost"
+
|> Record.KV.add "SERVER_PORT" "80"
+
in
+
+
let encoded = Record.KV.encode kv in
+
let decoded = Record.KV.decode encoded in
+
+
(* Check that all original pairs are present *)
+
assert (Record.KV.find "REQUEST_METHOD" decoded = "GET");
+
assert (Record.KV.find "SERVER_NAME" decoded = "localhost");
+
assert (Record.KV.find "SERVER_PORT" decoded = "80");
+
+
Printf.printf "āœ“ Key-value encoding test passed\n%!"
+
+
let test_long_key_value () =
+
Printf.printf "Testing long key-value encoding...\n%!";
+
+
(* Create a long key and value to test 4-byte length encoding *)
+
let long_key = String.make 200 'k' in
+
let long_value = String.make 300 'v' in
+
+
let kv = Record.KV.empty
+
|> Record.KV.add long_key long_value
+
|> Record.KV.add "short" "val"
+
in
+
+
let encoded = Record.KV.encode kv in
+
let decoded = Record.KV.decode encoded in
+
+
assert (Record.KV.find long_key decoded = long_value);
+
assert (Record.KV.find "short" decoded = "val");
+
+
Printf.printf "āœ“ Long key-value encoding test passed\n%!"
+
+
let test_with_binary_test_case ~fs filename expected_type expected_request_id =
+
Printf.printf "Testing with binary test case: %s...\n%!" filename;
+
+
let (raw_content, parsed) =
+
Eio.Path.with_open_in (fs, "test_cases/" ^ filename) @@ fun flow ->
+
let buf = Buffer.create 1024 in
+
Eio.Flow.copy flow (Eio.Flow.buffer_sink buf);
+
let raw_content = Buffer.contents buf in
+
let buf_read = Eio.Buf_read.of_string raw_content in
+
let parsed = Record.read buf_read in
+
(raw_content, parsed)
+
in
+
+
Printf.printf "\nRaw file contents (%d bytes):\n" (String.length raw_content);
+
hex_dump raw_content;
+
+
Printf.printf "\nParsed record:\n";
+
Format.printf "%a\n" (fun ppf -> Record.pp ppf) parsed;
+
+
(* If this is a Params record, also show the decoded key-value pairs *)
+
if parsed.record_type = Params && String.length parsed.content > 0 then (
+
let params = Record.KV.decode parsed.content in
+
Printf.printf "\nDecoded parameters:\n";
+
Format.printf "%a\n" Record.KV.pp params
+
);
+
+
assert (parsed.version = 1);
+
assert (parsed.record_type = expected_type);
+
assert (parsed.request_id = expected_request_id);
+
+
Printf.printf "āœ“ Binary test case %s passed\n%!" filename
+
+
let run_tests ~fs =
+
Printf.printf "Running FastCGI Record tests...\n\n%!";
+
+
test_record_types ();
+
test_kv_encoding ();
+
test_long_key_value ();
+
+
(* Test with some of our binary test cases *)
+
test_with_binary_test_case ~fs "begin_request_responder.bin" Begin_request 1;
+
test_with_binary_test_case ~fs "params_empty.bin" Params 1;
+
test_with_binary_test_case ~fs "end_request_success.bin" End_request 1;
+
test_with_binary_test_case ~fs "get_values.bin" Get_values 0;
+
test_with_binary_test_case ~fs "abort_request.bin" Abort_request 1;
+
+
Printf.printf "\nāœ… All FastCGI Record tests passed!\n%!"
+
+
let () = Eio_main.run @@ fun env ->
+
let fs = Eio.Stdenv.cwd env in
+
run_tests ~fs:(fst fs)
+123
test/test_cases/README.md
···
+
# FastCGI Test Cases
+
+
This directory contains binary test case files representing various FastCGI records as defined in the FastCGI specification. These files can be used to test a FastCGI parser implementation.
+
+
## Test Case Files
+
+
### Management Records (requestId = 0)
+
+
- **`get_values.bin`** - FCGI_GET_VALUES record requesting capability information
+
- **`get_values_result.bin`** - FCGI_GET_VALUES_RESULT record with capability responses
+
- **`unknown_type.bin`** - FCGI_UNKNOWN_TYPE record for unrecognized record types
+
+
### Application Records (requestId > 0)
+
+
#### BEGIN_REQUEST Records
+
- **`begin_request_responder.bin`** - Begin request for Responder role with KEEP_CONN flag
+
- **`begin_request_no_keep.bin`** - Begin request for Responder role without KEEP_CONN flag
+
- **`begin_request_authorizer.bin`** - Begin request for Authorizer role
+
- **`begin_request_filter.bin`** - Begin request for Filter role
+
+
#### Stream Records
+
- **`params_get.bin`** - FCGI_PARAMS record with GET request environment variables
+
- **`params_post.bin`** - FCGI_PARAMS record with POST request environment variables
+
- **`params_empty.bin`** - Empty FCGI_PARAMS record (end of params stream)
+
- **`stdin_form_data.bin`** - FCGI_STDIN record with form data
+
- **`stdin_empty.bin`** - Empty FCGI_STDIN record (end of stdin stream)
+
- **`stdout_response.bin`** - FCGI_STDOUT record with HTTP response
+
- **`stdout_empty.bin`** - Empty FCGI_STDOUT record (end of stdout stream)
+
- **`stderr_message.bin`** - FCGI_STDERR record with error message
+
- **`stderr_empty.bin`** - Empty FCGI_STDERR record (end of stderr stream)
+
- **`data_filter.bin`** - FCGI_DATA record with file content (for Filter role)
+
- **`data_empty.bin`** - Empty FCGI_DATA record (end of data stream)
+
+
#### Control Records
+
- **`end_request_success.bin`** - FCGI_END_REQUEST record with successful completion
+
- **`end_request_error.bin`** - FCGI_END_REQUEST record with error status
+
- **`abort_request.bin`** - FCGI_ABORT_REQUEST record
+
+
### Complex Scenarios
+
- **`multiplexed_requests.bin`** - Multiple concurrent requests on same connection
+
- **`large_record.bin`** - Record with maximum content size (65KB)
+
- **`padded_record.bin`** - Record with padding for 8-byte alignment
+
+
## Record Format
+
+
All records follow the FastCGI record format:
+
+
```
+
typedef struct {
+
unsigned char version; // FCGI_VERSION_1 (1)
+
unsigned char type; // Record type (1-11)
+
unsigned char requestIdB1; // Request ID high byte
+
unsigned char requestIdB0; // Request ID low byte
+
unsigned char contentLengthB1; // Content length high byte
+
unsigned char contentLengthB0; // Content length low byte
+
unsigned char paddingLength; // Padding length
+
unsigned char reserved; // Reserved (always 0)
+
// contentData[contentLength]
+
// paddingData[paddingLength]
+
} FCGI_Record;
+
```
+
+
## Usage for Parser Testing
+
+
These test cases can be used to verify:
+
+
1. **Header parsing** - Correct extraction of version, type, requestId, lengths
+
2. **Content parsing** - Proper handling of record body data
+
3. **Stream handling** - Recognition of stream start/end patterns
+
4. **Name-value pair parsing** - Decoding of FCGI_PARAMS format
+
5. **Role-specific parsing** - Different record sequences for each role
+
6. **Error handling** - Response to unknown types, malformed data
+
7. **Multiplexing** - Handling multiple concurrent request IDs
+
8. **Padding** - Correct skipping of padding bytes
+
+
## Typical Protocol Flows
+
+
### Simple Responder Request
+
1. `begin_request_responder.bin`
+
2. `params_get.bin`
+
3. `params_empty.bin`
+
4. `stdin_empty.bin`
+
5. `stdout_response.bin`
+
6. `stdout_empty.bin`
+
7. `end_request_success.bin`
+
+
### POST Request with Form Data
+
1. `begin_request_responder.bin`
+
2. `params_post.bin`
+
3. `params_empty.bin`
+
4. `stdin_form_data.bin`
+
5. `stdin_empty.bin`
+
6. `stdout_response.bin`
+
7. `stdout_empty.bin`
+
8. `end_request_success.bin`
+
+
### Authorizer Request
+
1. `begin_request_authorizer.bin`
+
2. `params_get.bin`
+
3. `params_empty.bin`
+
4. `stdout_response.bin`
+
5. `stdout_empty.bin`
+
6. `end_request_success.bin`
+
+
### Filter Request
+
1. `begin_request_filter.bin`
+
2. `params_get.bin`
+
3. `params_empty.bin`
+
4. `stdin_empty.bin`
+
5. `data_filter.bin`
+
6. `data_empty.bin`
+
7. `stdout_response.bin`
+
8. `stdout_empty.bin`
+
9. `end_request_success.bin`
+
+
## Binary Format Verification
+
+
You can inspect the binary content using hexdump:
+
```bash
+
hexdump -C begin_request_responder.bin
+
```
+
+
The first 8 bytes should always be the FastCGI header, followed by the record content and any padding.
test/test_cases/abort_request.bin

This is a binary file and will not be displayed.

test/test_cases/begin_request_authorizer.bin

This is a binary file and will not be displayed.

test/test_cases/begin_request_filter.bin

This is a binary file and will not be displayed.

test/test_cases/begin_request_no_keep.bin

This is a binary file and will not be displayed.

test/test_cases/begin_request_responder.bin

This is a binary file and will not be displayed.

test/test_cases/data_empty.bin

This is a binary file and will not be displayed.

test/test_cases/data_filter.bin

This is a binary file and will not be displayed.

test/test_cases/end_request_error.bin

This is a binary file and will not be displayed.

test/test_cases/end_request_success.bin

This is a binary file and will not be displayed.

+349
test/test_cases/generate_test_cases.py
···
+
#!/usr/bin/env python3
+
"""
+
Generate FastCGI test case files based on the specification.
+
"""
+
import struct
+
import os
+
+
# FastCGI constants from the specification
+
FCGI_VERSION_1 = 1
+
+
# Record types
+
FCGI_BEGIN_REQUEST = 1
+
FCGI_ABORT_REQUEST = 2
+
FCGI_END_REQUEST = 3
+
FCGI_PARAMS = 4
+
FCGI_STDIN = 5
+
FCGI_STDOUT = 6
+
FCGI_STDERR = 7
+
FCGI_DATA = 8
+
FCGI_GET_VALUES = 9
+
FCGI_GET_VALUES_RESULT = 10
+
FCGI_UNKNOWN_TYPE = 11
+
+
# Roles
+
FCGI_RESPONDER = 1
+
FCGI_AUTHORIZER = 2
+
FCGI_FILTER = 3
+
+
# Flags
+
FCGI_KEEP_CONN = 1
+
+
# Protocol status
+
FCGI_REQUEST_COMPLETE = 0
+
FCGI_CANT_MPX_CONN = 1
+
FCGI_OVERLOADED = 2
+
FCGI_UNKNOWN_ROLE = 3
+
+
def create_record_header(version, record_type, request_id, content_length, padding_length=0):
+
"""Create a FastCGI record header."""
+
return struct.pack('>BBHHBB',
+
version,
+
record_type,
+
request_id,
+
content_length,
+
padding_length,
+
0) # reserved
+
+
def encode_name_value_pair(name, value):
+
"""Encode a name-value pair according to FastCGI spec."""
+
name_bytes = name.encode('utf-8')
+
value_bytes = value.encode('utf-8')
+
+
name_len = len(name_bytes)
+
value_len = len(value_bytes)
+
+
# Encode lengths
+
if name_len < 128:
+
name_len_encoded = struct.pack('B', name_len)
+
else:
+
name_len_encoded = struct.pack('>I', name_len | 0x80000000)
+
+
if value_len < 128:
+
value_len_encoded = struct.pack('B', value_len)
+
else:
+
value_len_encoded = struct.pack('>I', value_len | 0x80000000)
+
+
return name_len_encoded + value_len_encoded + name_bytes + value_bytes
+
+
def create_begin_request():
+
"""Create FCGI_BEGIN_REQUEST record."""
+
# FCGI_BeginRequestBody
+
body = struct.pack('>HB5x', FCGI_RESPONDER, FCGI_KEEP_CONN)
+
header = create_record_header(FCGI_VERSION_1, FCGI_BEGIN_REQUEST, 1, len(body))
+
return header + body
+
+
def create_begin_request_no_keep_conn():
+
"""Create FCGI_BEGIN_REQUEST record without keep connection."""
+
body = struct.pack('>HB5x', FCGI_RESPONDER, 0)
+
header = create_record_header(FCGI_VERSION_1, FCGI_BEGIN_REQUEST, 1, len(body))
+
return header + body
+
+
def create_begin_request_authorizer():
+
"""Create FCGI_BEGIN_REQUEST record for authorizer role."""
+
body = struct.pack('>HB5x', FCGI_AUTHORIZER, 0)
+
header = create_record_header(FCGI_VERSION_1, FCGI_BEGIN_REQUEST, 2, len(body))
+
return header + body
+
+
def create_begin_request_filter():
+
"""Create FCGI_BEGIN_REQUEST record for filter role."""
+
body = struct.pack('>HB5x', FCGI_FILTER, FCGI_KEEP_CONN)
+
header = create_record_header(FCGI_VERSION_1, FCGI_BEGIN_REQUEST, 3, len(body))
+
return header + body
+
+
def create_end_request():
+
"""Create FCGI_END_REQUEST record."""
+
# FCGI_EndRequestBody
+
body = struct.pack('>IB3x', 0, FCGI_REQUEST_COMPLETE) # app_status=0, protocol_status=COMPLETE
+
header = create_record_header(FCGI_VERSION_1, FCGI_END_REQUEST, 1, len(body))
+
return header + body
+
+
def create_end_request_error():
+
"""Create FCGI_END_REQUEST record with error status."""
+
body = struct.pack('>IB3x', 1, FCGI_REQUEST_COMPLETE) # app_status=1 (error)
+
header = create_record_header(FCGI_VERSION_1, FCGI_END_REQUEST, 1, len(body))
+
return header + body
+
+
def create_params_record():
+
"""Create FCGI_PARAMS record with CGI environment variables."""
+
# Typical CGI parameters
+
params = [
+
("REQUEST_METHOD", "GET"),
+
("SCRIPT_NAME", "/test.cgi"),
+
("REQUEST_URI", "/test.cgi?foo=bar"),
+
("QUERY_STRING", "foo=bar"),
+
("SERVER_NAME", "localhost"),
+
("SERVER_PORT", "80"),
+
("HTTP_HOST", "localhost"),
+
("HTTP_USER_AGENT", "Mozilla/5.0"),
+
("CONTENT_TYPE", ""),
+
("CONTENT_LENGTH", "0")
+
]
+
+
body = b''
+
for name, value in params:
+
body += encode_name_value_pair(name, value)
+
+
header = create_record_header(FCGI_VERSION_1, FCGI_PARAMS, 1, len(body))
+
return header + body
+
+
def create_params_record_post():
+
"""Create FCGI_PARAMS record for POST request."""
+
params = [
+
("REQUEST_METHOD", "POST"),
+
("SCRIPT_NAME", "/form.cgi"),
+
("REQUEST_URI", "/form.cgi"),
+
("QUERY_STRING", ""),
+
("SERVER_NAME", "localhost"),
+
("SERVER_PORT", "443"),
+
("HTTPS", "on"),
+
("HTTP_HOST", "localhost"),
+
("HTTP_USER_AGENT", "curl/7.68.0"),
+
("CONTENT_TYPE", "application/x-www-form-urlencoded"),
+
("CONTENT_LENGTH", "23")
+
]
+
+
body = b''
+
for name, value in params:
+
body += encode_name_value_pair(name, value)
+
+
header = create_record_header(FCGI_VERSION_1, FCGI_PARAMS, 1, len(body))
+
return header + body
+
+
def create_empty_params():
+
"""Create empty FCGI_PARAMS record (end of params stream)."""
+
header = create_record_header(FCGI_VERSION_1, FCGI_PARAMS, 1, 0)
+
return header
+
+
def create_stdin_record():
+
"""Create FCGI_STDIN record with form data."""
+
data = b"name=John&email=john@example.com"
+
header = create_record_header(FCGI_VERSION_1, FCGI_STDIN, 1, len(data))
+
return header + data
+
+
def create_empty_stdin():
+
"""Create empty FCGI_STDIN record (end of stdin stream)."""
+
header = create_record_header(FCGI_VERSION_1, FCGI_STDIN, 1, 0)
+
return header
+
+
def create_stdout_record():
+
"""Create FCGI_STDOUT record with HTTP response."""
+
response = b"Content-Type: text/html\r\n\r\n<html><body>Hello World</body></html>"
+
header = create_record_header(FCGI_VERSION_1, FCGI_STDOUT, 1, len(response))
+
return header + response
+
+
def create_empty_stdout():
+
"""Create empty FCGI_STDOUT record (end of stdout stream)."""
+
header = create_record_header(FCGI_VERSION_1, FCGI_STDOUT, 1, 0)
+
return header
+
+
def create_stderr_record():
+
"""Create FCGI_STDERR record with error message."""
+
error_msg = b"Warning: Configuration file not found\n"
+
header = create_record_header(FCGI_VERSION_1, FCGI_STDERR, 1, len(error_msg))
+
return header + error_msg
+
+
def create_empty_stderr():
+
"""Create empty FCGI_STDERR record (end of stderr stream)."""
+
header = create_record_header(FCGI_VERSION_1, FCGI_STDERR, 1, 0)
+
return header
+
+
def create_get_values():
+
"""Create FCGI_GET_VALUES record."""
+
# Request standard capability variables
+
body = (encode_name_value_pair("FCGI_MAX_CONNS", "") +
+
encode_name_value_pair("FCGI_MAX_REQS", "") +
+
encode_name_value_pair("FCGI_MPXS_CONNS", ""))
+
header = create_record_header(FCGI_VERSION_1, FCGI_GET_VALUES, 0, len(body))
+
return header + body
+
+
def create_get_values_result():
+
"""Create FCGI_GET_VALUES_RESULT record."""
+
body = (encode_name_value_pair("FCGI_MAX_CONNS", "1") +
+
encode_name_value_pair("FCGI_MAX_REQS", "1") +
+
encode_name_value_pair("FCGI_MPXS_CONNS", "0"))
+
header = create_record_header(FCGI_VERSION_1, FCGI_GET_VALUES_RESULT, 0, len(body))
+
return header + body
+
+
def create_unknown_type():
+
"""Create FCGI_UNKNOWN_TYPE record."""
+
# FCGI_UnknownTypeBody
+
body = struct.pack('B7x', 99) # unknown type 99
+
header = create_record_header(FCGI_VERSION_1, FCGI_UNKNOWN_TYPE, 0, len(body))
+
return header + body
+
+
def create_abort_request():
+
"""Create FCGI_ABORT_REQUEST record."""
+
header = create_record_header(FCGI_VERSION_1, FCGI_ABORT_REQUEST, 1, 0)
+
return header
+
+
def create_data_record():
+
"""Create FCGI_DATA record (for Filter role)."""
+
file_data = b"This is file content that needs to be filtered\nLine 2\nLine 3\n"
+
header = create_record_header(FCGI_VERSION_1, FCGI_DATA, 3, len(file_data))
+
return header + file_data
+
+
def create_empty_data():
+
"""Create empty FCGI_DATA record (end of data stream)."""
+
header = create_record_header(FCGI_VERSION_1, FCGI_DATA, 3, 0)
+
return header
+
+
def create_multiplexed_records():
+
"""Create a sequence showing multiplexed requests."""
+
records = []
+
+
# Request 1 begins
+
records.append(create_begin_request())
+
records.append(create_params_record())
+
records.append(create_empty_params())
+
+
# Request 2 begins (different request ID)
+
body = struct.pack('>HB5x', FCGI_RESPONDER, FCGI_KEEP_CONN)
+
header = create_record_header(FCGI_VERSION_1, FCGI_BEGIN_REQUEST, 2, len(body))
+
records.append(header + body)
+
+
# Request 1 continues
+
records.append(create_empty_stdin())
+
+
# Request 2 params
+
params = [("REQUEST_METHOD", "POST"), ("SCRIPT_NAME", "/other.cgi")]
+
body = b''
+
for name, value in params:
+
body += encode_name_value_pair(name, value)
+
header = create_record_header(FCGI_VERSION_1, FCGI_PARAMS, 2, len(body))
+
records.append(header + body)
+
+
# Request 2 empty params
+
header = create_record_header(FCGI_VERSION_1, FCGI_PARAMS, 2, 0)
+
records.append(header)
+
+
# Request 2 completes first
+
response = b"Content-Type: text/plain\r\n\r\nRequest 2 done"
+
header = create_record_header(FCGI_VERSION_1, FCGI_STDOUT, 2, len(response))
+
records.append(header + response)
+
+
header = create_record_header(FCGI_VERSION_1, FCGI_STDOUT, 2, 0)
+
records.append(header)
+
+
body = struct.pack('>IB3x', 0, FCGI_REQUEST_COMPLETE)
+
header = create_record_header(FCGI_VERSION_1, FCGI_END_REQUEST, 2, len(body))
+
records.append(header + body)
+
+
# Request 1 completes
+
response = b"Content-Type: text/html\r\n\r\n<html><body>Request 1 done</body></html>"
+
header = create_record_header(FCGI_VERSION_1, FCGI_STDOUT, 1, len(response))
+
records.append(header + response)
+
+
header = create_record_header(FCGI_VERSION_1, FCGI_STDOUT, 1, 0)
+
records.append(header)
+
+
body = struct.pack('>IB3x', 0, FCGI_REQUEST_COMPLETE)
+
header = create_record_header(FCGI_VERSION_1, FCGI_END_REQUEST, 1, len(body))
+
records.append(header + body)
+
+
return b''.join(records)
+
+
def create_large_record():
+
"""Create a record with maximum content size."""
+
# Create a large response (just under 64KB)
+
large_content = b"x" * 65000
+
header = create_record_header(FCGI_VERSION_1, FCGI_STDOUT, 1, len(large_content))
+
return header + large_content
+
+
def create_padded_record():
+
"""Create a record with padding for alignment."""
+
data = b"Hello" # 5 bytes
+
padding_length = 3 # to align to 8-byte boundary (5 + 3 = 8)
+
header = create_record_header(FCGI_VERSION_1, FCGI_STDOUT, 1, len(data), padding_length)
+
padding = b'\x00' * padding_length
+
return header + data + padding
+
+
# Test case definitions
+
test_cases = {
+
"begin_request_responder.bin": create_begin_request,
+
"begin_request_no_keep.bin": create_begin_request_no_keep_conn,
+
"begin_request_authorizer.bin": create_begin_request_authorizer,
+
"begin_request_filter.bin": create_begin_request_filter,
+
"end_request_success.bin": create_end_request,
+
"end_request_error.bin": create_end_request_error,
+
"params_get.bin": create_params_record,
+
"params_post.bin": create_params_record_post,
+
"params_empty.bin": create_empty_params,
+
"stdin_form_data.bin": create_stdin_record,
+
"stdin_empty.bin": create_empty_stdin,
+
"stdout_response.bin": create_stdout_record,
+
"stdout_empty.bin": create_empty_stdout,
+
"stderr_message.bin": create_stderr_record,
+
"stderr_empty.bin": create_empty_stderr,
+
"get_values.bin": create_get_values,
+
"get_values_result.bin": create_get_values_result,
+
"unknown_type.bin": create_unknown_type,
+
"abort_request.bin": create_abort_request,
+
"data_filter.bin": create_data_record,
+
"data_empty.bin": create_empty_data,
+
"multiplexed_requests.bin": create_multiplexed_records,
+
"large_record.bin": create_large_record,
+
"padded_record.bin": create_padded_record,
+
}
+
+
if __name__ == "__main__":
+
for filename, creator in test_cases.items():
+
with open(filename, 'wb') as f:
+
f.write(creator())
+
print(f"Created {filename}")
+
+
print(f"\nGenerated {len(test_cases)} test case files")
+
print("\nTest case descriptions:")
+
print("- begin_request_*.bin: Various BEGIN_REQUEST records for different roles")
+
print("- end_request_*.bin: END_REQUEST records with different status codes")
+
print("- params_*.bin: PARAMS records with CGI environment variables")
+
print("- stdin_*.bin: STDIN records with request body data")
+
print("- stdout_*.bin: STDOUT records with response data")
+
print("- stderr_*.bin: STDERR records with error messages")
+
print("- get_values*.bin: Management records for capability negotiation")
+
print("- unknown_type.bin: Unknown record type handling")
+
print("- abort_request.bin: Request abortion")
+
print("- data_*.bin: DATA records for Filter role")
+
print("- multiplexed_requests.bin: Multiple concurrent requests")
+
print("- large_record.bin: Maximum size record")
+
print("- padded_record.bin: Record with padding for alignment")
test/test_cases/get_values.bin

This is a binary file and will not be displayed.

test/test_cases/get_values_result.bin

This is a binary file and will not be displayed.

test/test_cases/large_record.bin

This is a binary file and will not be displayed.

test/test_cases/multiplexed_requests.bin

This is a binary file and will not be displayed.

test/test_cases/padded_record.bin

This is a binary file and will not be displayed.

test/test_cases/params_empty.bin

This is a binary file and will not be displayed.

test/test_cases/params_get.bin

This is a binary file and will not be displayed.

test/test_cases/params_post.bin

This is a binary file and will not be displayed.

test/test_cases/stderr_empty.bin

This is a binary file and will not be displayed.

test/test_cases/stderr_message.bin

This is a binary file and will not be displayed.

test/test_cases/stdin_empty.bin

This is a binary file and will not be displayed.

test/test_cases/stdin_form_data.bin

This is a binary file and will not be displayed.

test/test_cases/stdout_empty.bin

This is a binary file and will not be displayed.

test/test_cases/stdout_response.bin

This is a binary file and will not be displayed.

+24
test/test_cases/test_case_sizes.txt
···
+
abort_request.bin 8 bytes
+
begin_request_authorizer.bin 16 bytes
+
begin_request_filter.bin 16 bytes
+
begin_request_no_keep.bin 16 bytes
+
begin_request_responder.bin 16 bytes
+
data_empty.bin 8 bytes
+
data_filter.bin 69 bytes
+
end_request_error.bin 16 bytes
+
end_request_success.bin 16 bytes
+
get_values_result.bin 59 bytes
+
get_values.bin 56 bytes
+
large_record.bin 65008 bytes
+
multiplexed_requests.bin 496 bytes
+
padded_record.bin 16 bytes
+
params_empty.bin 8 bytes
+
params_get.bin 216 bytes
+
params_post.bin 246 bytes
+
stderr_empty.bin 8 bytes
+
stderr_message.bin 46 bytes
+
stdin_empty.bin 8 bytes
+
stdin_form_data.bin 40 bytes
+
stdout_empty.bin 8 bytes
+
stdout_response.bin 72 bytes
+
unknown_type.bin 16 bytes
test/test_cases/unknown_type.bin

This is a binary file and will not be displayed.

+130
test/test_cases/validate_test_cases.py
···
+
#!/usr/bin/env python3
+
"""
+
Validate that the generated FastCGI test cases are properly formatted.
+
"""
+
import struct
+
import os
+
import glob
+
+
def parse_record_header(data):
+
"""Parse a FastCGI record header."""
+
if len(data) < 8:
+
return None
+
+
version, record_type, request_id, content_length, padding_length, reserved = struct.unpack('>BBHHBB', data[:8])
+
return {
+
'version': version,
+
'type': record_type,
+
'request_id': request_id,
+
'content_length': content_length,
+
'padding_length': padding_length,
+
'reserved': reserved,
+
'total_length': 8 + content_length + padding_length
+
}
+
+
def validate_file(filename):
+
"""Validate a single test case file."""
+
print(f"\nValidating {filename}:")
+
+
with open(filename, 'rb') as f:
+
data = f.read()
+
+
if len(data) < 8:
+
print(f" āŒ File too short: {len(data)} bytes")
+
return False
+
+
# Parse all records in the file
+
offset = 0
+
record_count = 0
+
+
while offset < len(data):
+
if offset + 8 > len(data):
+
print(f" āŒ Incomplete header at offset {offset}")
+
return False
+
+
header = parse_record_header(data[offset:])
+
if not header:
+
print(f" āŒ Failed to parse header at offset {offset}")
+
return False
+
+
record_count += 1
+
print(f" Record {record_count}:")
+
print(f" Version: {header['version']}")
+
print(f" Type: {header['type']}")
+
print(f" Request ID: {header['request_id']}")
+
print(f" Content Length: {header['content_length']}")
+
print(f" Padding Length: {header['padding_length']}")
+
print(f" Reserved: {header['reserved']}")
+
+
# Validate header fields
+
if header['version'] != 1:
+
print(f" āŒ Invalid version: {header['version']}")
+
return False
+
+
if header['type'] < 1 or header['type'] > 11:
+
print(f" āŒ Invalid record type: {header['type']}")
+
return False
+
+
if header['reserved'] != 0:
+
print(f" āŒ Reserved field not zero: {header['reserved']}")
+
return False
+
+
# Check if we have enough data for content and padding
+
expected_end = offset + header['total_length']
+
if expected_end > len(data):
+
print(f" āŒ Not enough data: need {header['total_length']}, have {len(data) - offset}")
+
return False
+
+
# Extract content
+
content_start = offset + 8
+
content_end = content_start + header['content_length']
+
content = data[content_start:content_end]
+
+
# Extract padding
+
padding_start = content_end
+
padding_end = padding_start + header['padding_length']
+
padding = data[padding_start:padding_end]
+
+
print(f" Content: {len(content)} bytes")
+
if header['padding_length'] > 0:
+
print(f" Padding: {len(padding)} bytes")
+
+
# Show content preview for small records
+
if len(content) <= 32:
+
print(f" Content hex: {content.hex()}")
+
else:
+
print(f" Content preview: {content[:16].hex()}...")
+
+
print(f" āœ… Record valid")
+
+
offset = expected_end
+
+
print(f" āœ… File valid: {record_count} record(s), {len(data)} total bytes")
+
return True
+
+
def main():
+
"""Validate all test case files."""
+
test_files = glob.glob("*.bin")
+
test_files.sort()
+
+
print(f"Found {len(test_files)} test case files")
+
+
valid_count = 0
+
for filename in test_files:
+
if validate_file(filename):
+
valid_count += 1
+
+
print(f"\n{'='*50}")
+
print(f"Validation complete: {valid_count}/{len(test_files)} files valid")
+
+
if valid_count == len(test_files):
+
print("āœ… All test cases are valid!")
+
else:
+
print("āŒ Some test cases failed validation")
+
return 1
+
+
return 0
+
+
if __name__ == "__main__":
+
import sys
+
sys.exit(main())
+438
test/test_cases/validation_results.txt
···
+
Found 24 test case files
+
+
Validating abort_request.bin:
+
Record 1:
+
Version: 1
+
Type: 2
+
Request ID: 1
+
Content Length: 0
+
Padding Length: 0
+
Reserved: 0
+
Content: 0 bytes
+
Content hex:
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 8 total bytes
+
+
Validating begin_request_authorizer.bin:
+
Record 1:
+
Version: 1
+
Type: 1
+
Request ID: 2
+
Content Length: 8
+
Padding Length: 0
+
Reserved: 0
+
Content: 8 bytes
+
Content hex: 0002000000000000
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 16 total bytes
+
+
Validating begin_request_filter.bin:
+
Record 1:
+
Version: 1
+
Type: 1
+
Request ID: 3
+
Content Length: 8
+
Padding Length: 0
+
Reserved: 0
+
Content: 8 bytes
+
Content hex: 0003010000000000
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 16 total bytes
+
+
Validating begin_request_no_keep.bin:
+
Record 1:
+
Version: 1
+
Type: 1
+
Request ID: 1
+
Content Length: 8
+
Padding Length: 0
+
Reserved: 0
+
Content: 8 bytes
+
Content hex: 0001000000000000
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 16 total bytes
+
+
Validating begin_request_responder.bin:
+
Record 1:
+
Version: 1
+
Type: 1
+
Request ID: 1
+
Content Length: 8
+
Padding Length: 0
+
Reserved: 0
+
Content: 8 bytes
+
Content hex: 0001010000000000
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 16 total bytes
+
+
Validating data_empty.bin:
+
Record 1:
+
Version: 1
+
Type: 8
+
Request ID: 3
+
Content Length: 0
+
Padding Length: 0
+
Reserved: 0
+
Content: 0 bytes
+
Content hex:
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 8 total bytes
+
+
Validating data_filter.bin:
+
Record 1:
+
Version: 1
+
Type: 8
+
Request ID: 3
+
Content Length: 61
+
Padding Length: 0
+
Reserved: 0
+
Content: 61 bytes
+
Content preview: 546869732069732066696c6520636f6e...
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 69 total bytes
+
+
Validating end_request_error.bin:
+
Record 1:
+
Version: 1
+
Type: 3
+
Request ID: 1
+
Content Length: 8
+
Padding Length: 0
+
Reserved: 0
+
Content: 8 bytes
+
Content hex: 0000000100000000
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 16 total bytes
+
+
Validating end_request_success.bin:
+
Record 1:
+
Version: 1
+
Type: 3
+
Request ID: 1
+
Content Length: 8
+
Padding Length: 0
+
Reserved: 0
+
Content: 8 bytes
+
Content hex: 0000000000000000
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 16 total bytes
+
+
Validating get_values.bin:
+
Record 1:
+
Version: 1
+
Type: 9
+
Request ID: 0
+
Content Length: 48
+
Padding Length: 0
+
Reserved: 0
+
Content: 48 bytes
+
Content preview: 0e00464347495f4d41585f434f4e4e53...
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 56 total bytes
+
+
Validating get_values_result.bin:
+
Record 1:
+
Version: 1
+
Type: 10
+
Request ID: 0
+
Content Length: 51
+
Padding Length: 0
+
Reserved: 0
+
Content: 51 bytes
+
Content preview: 0e01464347495f4d41585f434f4e4e53...
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 59 total bytes
+
+
Validating large_record.bin:
+
Record 1:
+
Version: 1
+
Type: 6
+
Request ID: 1
+
Content Length: 65000
+
Padding Length: 0
+
Reserved: 0
+
Content: 65000 bytes
+
Content preview: 78787878787878787878787878787878...
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 65008 total bytes
+
+
Validating multiplexed_requests.bin:
+
Record 1:
+
Version: 1
+
Type: 1
+
Request ID: 1
+
Content Length: 8
+
Padding Length: 0
+
Reserved: 0
+
Content: 8 bytes
+
Content hex: 0001010000000000
+
āœ… Record valid
+
Record 2:
+
Version: 1
+
Type: 4
+
Request ID: 1
+
Content Length: 208
+
Padding Length: 0
+
Reserved: 0
+
Content: 208 bytes
+
Content preview: 0e03524551554553545f4d4554484f44...
+
āœ… Record valid
+
Record 3:
+
Version: 1
+
Type: 4
+
Request ID: 1
+
Content Length: 0
+
Padding Length: 0
+
Reserved: 0
+
Content: 0 bytes
+
Content hex:
+
āœ… Record valid
+
Record 4:
+
Version: 1
+
Type: 1
+
Request ID: 2
+
Content Length: 8
+
Padding Length: 0
+
Reserved: 0
+
Content: 8 bytes
+
Content hex: 0001010000000000
+
āœ… Record valid
+
Record 5:
+
Version: 1
+
Type: 5
+
Request ID: 1
+
Content Length: 0
+
Padding Length: 0
+
Reserved: 0
+
Content: 0 bytes
+
Content hex:
+
āœ… Record valid
+
Record 6:
+
Version: 1
+
Type: 4
+
Request ID: 2
+
Content Length: 43
+
Padding Length: 0
+
Reserved: 0
+
Content: 43 bytes
+
Content preview: 0e04524551554553545f4d4554484f44...
+
āœ… Record valid
+
Record 7:
+
Version: 1
+
Type: 4
+
Request ID: 2
+
Content Length: 0
+
Padding Length: 0
+
Reserved: 0
+
Content: 0 bytes
+
Content hex:
+
āœ… Record valid
+
Record 8:
+
Version: 1
+
Type: 6
+
Request ID: 2
+
Content Length: 42
+
Padding Length: 0
+
Reserved: 0
+
Content: 42 bytes
+
Content preview: 436f6e74656e742d547970653a207465...
+
āœ… Record valid
+
Record 9:
+
Version: 1
+
Type: 6
+
Request ID: 2
+
Content Length: 0
+
Padding Length: 0
+
Reserved: 0
+
Content: 0 bytes
+
Content hex:
+
āœ… Record valid
+
Record 10:
+
Version: 1
+
Type: 3
+
Request ID: 2
+
Content Length: 8
+
Padding Length: 0
+
Reserved: 0
+
Content: 8 bytes
+
Content hex: 0000000000000000
+
āœ… Record valid
+
Record 11:
+
Version: 1
+
Type: 6
+
Request ID: 1
+
Content Length: 67
+
Padding Length: 0
+
Reserved: 0
+
Content: 67 bytes
+
Content preview: 436f6e74656e742d547970653a207465...
+
āœ… Record valid
+
Record 12:
+
Version: 1
+
Type: 6
+
Request ID: 1
+
Content Length: 0
+
Padding Length: 0
+
Reserved: 0
+
Content: 0 bytes
+
Content hex:
+
āœ… Record valid
+
Record 13:
+
Version: 1
+
Type: 3
+
Request ID: 1
+
Content Length: 8
+
Padding Length: 0
+
Reserved: 0
+
Content: 8 bytes
+
Content hex: 0000000000000000
+
āœ… Record valid
+
āœ… File valid: 13 record(s), 496 total bytes
+
+
Validating padded_record.bin:
+
Record 1:
+
Version: 1
+
Type: 6
+
Request ID: 1
+
Content Length: 5
+
Padding Length: 3
+
Reserved: 0
+
Content: 5 bytes
+
Padding: 3 bytes
+
Content hex: 48656c6c6f
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 16 total bytes
+
+
Validating params_empty.bin:
+
Record 1:
+
Version: 1
+
Type: 4
+
Request ID: 1
+
Content Length: 0
+
Padding Length: 0
+
Reserved: 0
+
Content: 0 bytes
+
Content hex:
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 8 total bytes
+
+
Validating params_get.bin:
+
Record 1:
+
Version: 1
+
Type: 4
+
Request ID: 1
+
Content Length: 208
+
Padding Length: 0
+
Reserved: 0
+
Content: 208 bytes
+
Content preview: 0e03524551554553545f4d4554484f44...
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 216 total bytes
+
+
Validating params_post.bin:
+
Record 1:
+
Version: 1
+
Type: 4
+
Request ID: 1
+
Content Length: 238
+
Padding Length: 0
+
Reserved: 0
+
Content: 238 bytes
+
Content preview: 0e04524551554553545f4d4554484f44...
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 246 total bytes
+
+
Validating stderr_empty.bin:
+
Record 1:
+
Version: 1
+
Type: 7
+
Request ID: 1
+
Content Length: 0
+
Padding Length: 0
+
Reserved: 0
+
Content: 0 bytes
+
Content hex:
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 8 total bytes
+
+
Validating stderr_message.bin:
+
Record 1:
+
Version: 1
+
Type: 7
+
Request ID: 1
+
Content Length: 38
+
Padding Length: 0
+
Reserved: 0
+
Content: 38 bytes
+
Content preview: 5761726e696e673a20436f6e66696775...
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 46 total bytes
+
+
Validating stdin_empty.bin:
+
Record 1:
+
Version: 1
+
Type: 5
+
Request ID: 1
+
Content Length: 0
+
Padding Length: 0
+
Reserved: 0
+
Content: 0 bytes
+
Content hex:
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 8 total bytes
+
+
Validating stdin_form_data.bin:
+
Record 1:
+
Version: 1
+
Type: 5
+
Request ID: 1
+
Content Length: 32
+
Padding Length: 0
+
Reserved: 0
+
Content: 32 bytes
+
Content hex: 6e616d653d4a6f686e26656d61696c3d6a6f686e406578616d706c652e636f6d
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 40 total bytes
+
+
Validating stdout_empty.bin:
+
Record 1:
+
Version: 1
+
Type: 6
+
Request ID: 1
+
Content Length: 0
+
Padding Length: 0
+
Reserved: 0
+
Content: 0 bytes
+
Content hex:
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 8 total bytes
+
+
Validating stdout_response.bin:
+
Record 1:
+
Version: 1
+
Type: 6
+
Request ID: 1
+
Content Length: 64
+
Padding Length: 0
+
Reserved: 0
+
Content: 64 bytes
+
Content preview: 436f6e74656e742d547970653a207465...
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 72 total bytes
+
+
Validating unknown_type.bin:
+
Record 1:
+
Version: 1
+
Type: 11
+
Request ID: 0
+
Content Length: 8
+
Padding Length: 0
+
Reserved: 0
+
Content: 8 bytes
+
Content hex: 6300000000000000
+
āœ… Record valid
+
āœ… File valid: 1 record(s), 16 total bytes
+
+
==================================================
+
Validation complete: 24/24 files valid
+
āœ… All test cases are valid!
+219
test/validate_all_test_cases.ml
···
+
open Fastcgi
+
open Record
+
+
let hex_dump ?(max_bytes=256) content =
+
let len = String.length content in
+
let display_len = min len max_bytes in
+
let bytes_per_line = 16 in
+
let lines_to_show = (display_len - 1) / bytes_per_line + 1 in
+
+
for i = 0 to lines_to_show - 1 do
+
let start = i * bytes_per_line in
+
let end_ = min (start + bytes_per_line) display_len in
+
Printf.printf " %04x: " start;
+
+
(* Print hex bytes *)
+
for j = start to end_ - 1 do
+
Printf.printf "%02x " (Char.code content.[j]);
+
if j = start + 7 then Printf.printf " "
+
done;
+
+
(* Pad remaining space *)
+
for _ = end_ to start + bytes_per_line - 1 do
+
Printf.printf " ";
+
if end_ <= start + 7 then Printf.printf " "
+
done;
+
+
Printf.printf " |";
+
+
(* Print ASCII representation *)
+
for j = start to end_ - 1 do
+
let c = content.[j] in
+
if c >= ' ' && c <= '~' then
+
Printf.printf "%c" c
+
else
+
Printf.printf "."
+
done;
+
+
Printf.printf "|\n%!"
+
done;
+
+
if len > max_bytes then
+
Printf.printf " ... (%d more bytes truncated)\n%!" (len - max_bytes)
+
+
+
let test_cases = [
+
("abort_request.bin", Abort_request, 1);
+
("begin_request_authorizer.bin", Begin_request, 2);
+
("begin_request_filter.bin", Begin_request, 3);
+
("begin_request_no_keep.bin", Begin_request, 1);
+
("begin_request_responder.bin", Begin_request, 1);
+
("data_empty.bin", Data, 3);
+
("data_filter.bin", Data, 3);
+
("end_request_error.bin", End_request, 1);
+
("end_request_success.bin", End_request, 1);
+
("get_values.bin", Get_values, 0);
+
("get_values_result.bin", Get_values_result, 0);
+
("params_empty.bin", Params, 1);
+
("params_get.bin", Params, 1);
+
("params_post.bin", Params, 1);
+
("stderr_empty.bin", Stderr, 1);
+
("stderr_message.bin", Stderr, 1);
+
("stdin_empty.bin", Stdin, 1);
+
("stdin_form_data.bin", Stdin, 1);
+
("stdout_empty.bin", Stdout, 1);
+
("stdout_response.bin", Stdout, 1);
+
("unknown_type.bin", Unknown_type, 0);
+
]
+
+
let test_binary_file ~fs filename expected_type expected_request_id =
+
Printf.printf "Testing %s...\n" filename;
+
+
let (raw_content, parsed) =
+
Eio.Path.with_open_in (fs, "test_cases/" ^ filename) @@ fun flow ->
+
let buf = Buffer.create 1024 in
+
Eio.Flow.copy flow (Eio.Flow.buffer_sink buf);
+
let raw_content = Buffer.contents buf in
+
let buf_read = Eio.Buf_read.of_string raw_content in
+
let parsed = Record.read buf_read in
+
(raw_content, parsed)
+
in
+
+
Printf.printf "\nRaw file contents (%d bytes):\n" (String.length raw_content);
+
hex_dump raw_content;
+
+
Printf.printf "\nParsed record:\n";
+
Format.printf "%a\n" (fun ppf -> Record.pp ppf) parsed;
+
+
(* If this is a Params record, also show the decoded key-value pairs *)
+
if parsed.record_type = Params && String.length parsed.content > 0 then (
+
let params = Record.KV.decode parsed.content in
+
Printf.printf "\nDecoded parameters:\n";
+
Format.printf "%a\n" Record.KV.pp params
+
);
+
+
assert (parsed.version = 1);
+
assert (parsed.record_type = expected_type);
+
assert (parsed.request_id = expected_request_id);
+
+
Printf.printf "āœ“ %s passed\n\n%!" filename
+
+
let test_params_decoding ~fs =
+
Printf.printf "Testing params record content decoding...\n";
+
+
let parsed =
+
Eio.Path.with_open_in (fs, "test_cases/params_get.bin") @@ fun flow ->
+
let buf_read = Eio.Buf_read.of_flow flow ~max_size:1000000 in
+
Record.read buf_read
+
in
+
+
Printf.printf "\nParsed params record:\n";
+
Format.printf "%a\n" (fun ppf -> Record.pp ppf) parsed;
+
+
(* Decode the params content *)
+
let params = Record.KV.decode parsed.content in
+
+
Printf.printf "\nDecoded parameters:\n";
+
Format.printf "%a\n" Record.KV.pp params;
+
+
(* Check some expected environment variables *)
+
assert (Record.KV.find "REQUEST_METHOD" params = "GET");
+
assert (Record.KV.find "SERVER_NAME" params = "localhost");
+
assert (Record.KV.find "SERVER_PORT" params = "80");
+
+
Printf.printf "āœ“ params decoding passed\n\n%!"
+
+
let test_large_record ~fs =
+
Printf.printf "Testing large record...\n";
+
+
let parsed =
+
Eio.Path.with_open_in (fs, "test_cases/large_record.bin") @@ fun flow ->
+
let buf_read = Eio.Buf_read.of_flow flow ~max_size:1000000 in
+
Record.read buf_read
+
in
+
+
Printf.printf "\nParsed large record:\n";
+
Format.printf "%a\n" (fun ppf -> Record.pp ppf) parsed;
+
+
assert (parsed.version = 1);
+
assert (parsed.record_type = Stdout);
+
assert (parsed.request_id = 1);
+
assert (String.length parsed.content = 65000);
+
+
Printf.printf "āœ“ large record test passed\n\n%!"
+
+
let test_padded_record ~fs =
+
Printf.printf "Testing padded record...\n";
+
+
let parsed =
+
Eio.Path.with_open_in (fs, "test_cases/padded_record.bin") @@ fun flow ->
+
let buf_read = Eio.Buf_read.of_flow flow ~max_size:1000000 in
+
Record.read buf_read
+
in
+
+
Printf.printf "\nParsed padded record:\n";
+
Format.printf "%a\n" (fun ppf -> Record.pp ppf) parsed;
+
+
assert (parsed.version = 1);
+
assert (parsed.record_type = Stdout);
+
assert (parsed.request_id = 1);
+
assert (parsed.content = "Hello");
+
+
Printf.printf "āœ“ padded record test passed\n\n%!"
+
+
let test_multiplexed_records ~fs =
+
Printf.printf "Testing multiplexed records...\n";
+
+
let records =
+
Eio.Path.with_open_in (fs, "test_cases/multiplexed_requests.bin") @@ fun flow ->
+
let buf_read = Eio.Buf_read.of_flow flow ~max_size:1000000 in
+
let records = ref [] in
+
+
(* Read all records from the multiplexed stream *)
+
(try
+
while true do
+
let record = Record.read buf_read in
+
records := record :: !records
+
done
+
with End_of_file -> ());
+
!records
+
in
+
+
let records = List.rev records in
+
+
Printf.printf "\nParsed %d multiplexed records:\n" (List.length records);
+
List.iteri (fun i record ->
+
Printf.printf "\nRecord %d:\n" (i + 1);
+
Format.printf "%a\n" (fun ppf -> Record.pp ppf) record
+
) records;
+
+
(* Should have multiple records with different request IDs *)
+
assert (List.length records > 5);
+
+
(* Check that we have records for both request ID 1 and 2 *)
+
let request_ids = List.map (fun r -> r.Record.request_id) records in
+
assert (List.mem 1 request_ids);
+
assert (List.mem 2 request_ids);
+
+
Printf.printf "āœ“ multiplexed records test passed\n\n%!"
+
+
let run_all_tests ~fs =
+
Printf.printf "Validating all FastCGI test case files...\n\n%!";
+
+
(* Test individual files *)
+
List.iter (fun (filename, expected_type, expected_request_id) ->
+
test_binary_file ~fs filename expected_type expected_request_id
+
) test_cases;
+
+
Printf.printf "\nTesting specific content decoding...\n%!";
+
test_params_decoding ~fs;
+
test_large_record ~fs;
+
test_padded_record ~fs;
+
test_multiplexed_records ~fs;
+
+
Printf.printf "\nāœ… All %d test case files validated successfully!\n%!" (List.length test_cases);
+
Printf.printf "āœ… FastCGI Record implementation is working correctly!\n%!"
+
+
let () = Eio_main.run @@ fun env ->
+
let fs = Eio.Stdenv.cwd env in
+
run_all_tests ~fs:(fst fs)