FastCGI implementation in OCaml

tests

-1
lib/dune
···
)
(modules_without_implementation
fastcgi
-
fastcgi_record
)
)
+243
lib/fastcgi_record.ml
···
+
+
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 -> invalid_arg (Printf.sprintf "Unknown FastCGI record type: %d" 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;
+
}
+
+
let pp ppf record =
+
Format.fprintf ppf
+
"@[<2>{ version = %d;@ record_type = %a;@ request_id = %d;@ content = %S }@]"
+
record.version
+
pp_record record.record_type
+
record.request_id
+
record.content
+
+
(* FastCGI constants *)
+
let fcgi_version_1 = 1
+
let fcgi_header_len = 8
+
+
let read buf_read =
+
(* Read the 8-byte header *)
+
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
+
+
(* Validate version *)
+
if version <> fcgi_version_1 then
+
invalid_arg (Printf.sprintf "Unsupported FastCGI version: %d" version);
+
+
(* Convert record type *)
+
let record_type = record_of_int record_type_int in
+
+
(* Read content *)
+
let content =
+
if content_length = 0 then
+
""
+
else
+
Eio.Buf_read.take content_length buf_read
+
in
+
+
(* Skip padding *)
+
if padding_length > 0 then
+
ignore (Eio.Buf_read.take padding_length buf_read);
+
+
{ version; record_type; request_id; content }
+
+
let write buf_write record =
+
let content_length = String.length record.content in
+
+
(* 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 *)
+
if content_length > 0 then
+
Eio.Buf_write.string buf_write record.content;
+
+
(* Write padding *)
+
if padding_length > 0 then
+
Eio.Buf_write.string buf_write (String.make padding_length '\000')
+
+
let create ~version ~record ~request_id ~content =
+
{ version; record_type = record; request_id; content }
+
+
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 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
+
+
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 =
+
if pos >= String.length buf then
+
failwith "Unexpected end of buffer while reading length";
+
let first_byte = Char.code buf.[pos] in
+
if first_byte land 0x80 = 0 then
+
(* Single byte length *)
+
(first_byte, pos + 1)
+
else (
+
(* Four byte length *)
+
if pos + 4 > String.length buf then
+
failwith "Unexpected end of buffer while reading 4-byte length";
+
let len =
+
((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])
+
in
+
(len, pos + 4)
+
)
+
+
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
+
+
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
+
+
(* Check bounds *)
+
if pos + name_len + value_len > len then
+
failwith "Unexpected end of buffer while reading key-value data";
+
+
(* Extract name and value *)
+
let name = String.sub content pos name_len in
+
let value = String.sub 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
+11
test/dune
···
+
(test
+
(name simple_test)
+
(libraries fastcgi eio)
+
(deps (source_tree test_cases))
+
)
+
+
(test
+
(name validate_all_test_cases)
+
(libraries fastcgi eio)
+
(deps (source_tree test_cases))
+
)
+106
test/simple_test.ml
···
+
open Fastcgi
+
+
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 filename expected_type expected_request_id =
+
Printf.printf "Testing with binary test case: %s...\n%!" filename;
+
+
let content =
+
let ic = open_in_bin ("test_cases/" ^ filename) in
+
let len = in_channel_length ic in
+
let content = really_input_string ic len in
+
close_in ic;
+
content
+
in
+
+
let buf_read = Eio.Buf_read.of_string content in
+
let parsed = Record.read buf_read in
+
+
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 () =
+
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 "begin_request_responder.bin" Begin_request 1;
+
test_with_binary_test_case "params_empty.bin" Params 1;
+
test_with_binary_test_case "end_request_success.bin" End_request 1;
+
test_with_binary_test_case "get_values.bin" Get_values 0;
+
test_with_binary_test_case "abort_request.bin" Abort_request 1;
+
+
Printf.printf "\n✅ All FastCGI Record tests passed!\n%!"
+
+
let () = run_tests ()
+165
test/validate_all_test_cases.ml
···
+
open Fastcgi
+
open Record
+
+
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 filename expected_type expected_request_id =
+
Printf.printf "Testing %s... " filename;
+
+
let content =
+
let ic = open_in_bin ("test_cases/" ^ filename) in
+
let len = in_channel_length ic in
+
let content = really_input_string ic len in
+
close_in ic;
+
content
+
in
+
+
let buf_read = Eio.Buf_read.of_string content in
+
let parsed = Record.read buf_read in
+
+
assert (parsed.version = 1);
+
assert (parsed.record_type = expected_type);
+
assert (parsed.request_id = expected_request_id);
+
+
Printf.printf "✓\n%!"
+
+
let test_params_decoding () =
+
Printf.printf "Testing params record content decoding... ";
+
+
let content =
+
let ic = open_in_bin "test_cases/params_get.bin" in
+
let len = in_channel_length ic in
+
let content = really_input_string ic len in
+
close_in ic;
+
content
+
in
+
+
let buf_read = Eio.Buf_read.of_string content in
+
let parsed = Record.read buf_read in
+
+
(* Decode the params content *)
+
let params = Record.KV.decode parsed.content in
+
+
(* 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 "✓\n%!"
+
+
let test_large_record () =
+
Printf.printf "Testing large record... ";
+
+
let content =
+
let ic = open_in_bin "test_cases/large_record.bin" in
+
let len = in_channel_length ic in
+
let content = really_input_string ic len in
+
close_in ic;
+
content
+
in
+
+
let buf_read = Eio.Buf_read.of_string content in
+
let parsed = Record.read buf_read in
+
+
assert (parsed.version = 1);
+
assert (parsed.record_type = Stdout);
+
assert (parsed.request_id = 1);
+
assert (String.length parsed.content = 65000);
+
+
Printf.printf "✓\n%!"
+
+
let test_padded_record () =
+
Printf.printf "Testing padded record... ";
+
+
let content =
+
let ic = open_in_bin "test_cases/padded_record.bin" in
+
let len = in_channel_length ic in
+
let content = really_input_string ic len in
+
close_in ic;
+
content
+
in
+
+
let buf_read = Eio.Buf_read.of_string content in
+
let parsed = Record.read buf_read in
+
+
assert (parsed.version = 1);
+
assert (parsed.record_type = Stdout);
+
assert (parsed.request_id = 1);
+
assert (parsed.content = "Hello");
+
+
Printf.printf "✓\n%!"
+
+
let test_multiplexed_records () =
+
Printf.printf "Testing multiplexed records... ";
+
+
let content =
+
let ic = open_in_bin "test_cases/multiplexed_requests.bin" in
+
let len = in_channel_length ic in
+
let content = really_input_string ic len in
+
close_in ic;
+
content
+
in
+
+
let buf_read = Eio.Buf_read.of_string content 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 -> ());
+
+
let records = List.rev !records in
+
+
(* 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 "✓\n%!"
+
+
let run_all_tests () =
+
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 filename expected_type expected_request_id
+
) test_cases;
+
+
Printf.printf "\nTesting specific content decoding...\n%!";
+
test_params_decoding ();
+
test_large_record ();
+
test_padded_record ();
+
test_multiplexed_records ();
+
+
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 () = run_all_tests ()
test_cases/README.md test/test_cases/README.md
test_cases/abort_request.bin test/test_cases/abort_request.bin
test_cases/begin_request_authorizer.bin test/test_cases/begin_request_authorizer.bin
test_cases/begin_request_filter.bin test/test_cases/begin_request_filter.bin
test_cases/begin_request_no_keep.bin test/test_cases/begin_request_no_keep.bin
test_cases/begin_request_responder.bin test/test_cases/begin_request_responder.bin
test_cases/data_empty.bin test/test_cases/data_empty.bin
test_cases/data_filter.bin test/test_cases/data_filter.bin
test_cases/end_request_error.bin test/test_cases/end_request_error.bin
test_cases/end_request_success.bin test/test_cases/end_request_success.bin
test_cases/generate_test_cases.py test/test_cases/generate_test_cases.py
test_cases/get_values.bin test/test_cases/get_values.bin
test_cases/get_values_result.bin test/test_cases/get_values_result.bin
test_cases/large_record.bin test/test_cases/large_record.bin
test_cases/multiplexed_requests.bin test/test_cases/multiplexed_requests.bin
test_cases/padded_record.bin test/test_cases/padded_record.bin
test_cases/params_empty.bin test/test_cases/params_empty.bin
test_cases/params_get.bin test/test_cases/params_get.bin
test_cases/params_post.bin test/test_cases/params_post.bin
test_cases/stderr_empty.bin test/test_cases/stderr_empty.bin
test_cases/stderr_message.bin test/test_cases/stderr_message.bin
test_cases/stdin_empty.bin test/test_cases/stdin_empty.bin
test_cases/stdin_form_data.bin test/test_cases/stdin_form_data.bin
test_cases/stdout_empty.bin test/test_cases/stdout_empty.bin
test_cases/stdout_response.bin test/test_cases/stdout_response.bin
test_cases/test_case_sizes.txt test/test_cases/test_case_sizes.txt
test_cases/unknown_type.bin test/test_cases/unknown_type.bin
test_cases/validate_test_cases.py test/test_cases/validate_test_cases.py
test_cases/validation_results.txt test/test_cases/validation_results.txt