-module(multipartkit@form). -compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]). -define(FILEPATH, "src/multipartkit/form.gleam"). -export([new/0, parts/1, unsafe_add_part/2, add_field/3, add_file/5, add_file_auto_with/5, add_file_auto/4]). -export_type([form/0]). -if(?OTP_RELEASE >= 27). -define(MODULEDOC(Str), -moduledoc(Str)). -define(DOC(Str), -doc(Str)). -else. -define(MODULEDOC(Str), -compile([])). -define(DOC(Str), -compile([])). -endif. -opaque form() :: {form, list(multipartkit@part:part())}. -file("src/multipartkit/form.gleam", 21). ?DOC(" A new empty form.\n"). -spec new() -> form(). new() -> {form, []}. -file("src/multipartkit/form.gleam", 121). ?DOC(" Read the parts in insertion order.\n"). -spec parts(form()) -> list(multipartkit@part:part()). parts(Form) -> lists:reverse(erlang:element(2, Form)). -file("src/multipartkit/form.gleam", 125). -spec push(form(), multipartkit@part:part()) -> form(). push(Form, The_part) -> {form, [The_part | erlang:element(2, Form)]}. -file("src/multipartkit/form.gleam", 116). ?DOC( " Append a pre-built `Part` without validation or normalisation.\n" "\n" " The caller is responsible for keeping `headers`, `name`, `filename`, and\n" " `content_type` mutually consistent. Prefer `add_field` / `add_file` /\n" " `add_file_auto` for library-maintained consistency.\n" ). -spec unsafe_add_part(form(), multipartkit@part:part()) -> form(). unsafe_add_part(Form, The_part) -> push(Form, The_part). -file("src/multipartkit/form.gleam", 192). -spec is_ascii_safe_loop(bitstring()) -> boolean(). is_ascii_safe_loop(Bytes) -> case Bytes of <<>> -> true; <> -> case (B =:= 16#09) orelse ((B >= 16#20) andalso (B =< 16#7E)) of true -> is_ascii_safe_loop(Rest); false -> false end; _ -> false end. -file("src/multipartkit/form.gleam", 188). ?DOC( " True iff every code point in `s` is printable US-ASCII (0x20-0x7E)\n" " or HTAB. Matches the subset of bytes RFC 7230 §3.2.4 admits as\n" " header field values.\n" ). -spec is_ascii_safe(binary()) -> boolean(). is_ascii_safe(S) -> is_ascii_safe_loop(gleam_stdlib:identity(S)). -file("src/multipartkit/form.gleam", 211). -spec ascii_fallback_loop(list(binary()), binary()) -> binary(). ascii_fallback_loop(Remaining, Acc) -> case Remaining of [] -> Acc; [Grapheme | Rest] -> case is_ascii_safe(Grapheme) of true -> ascii_fallback_loop(Rest, <>); false -> ascii_fallback_loop(Rest, <>) end end. -file("src/multipartkit/form.gleam", 207). ?DOC( " Replace every non-ASCII-safe code point in `s` with `_` so the\n" " result is safe for use inside a legacy `filename=\"...\"`. Used as\n" " the pre-RFC-5987 fallback alongside `filename*=`.\n" ). -spec ascii_fallback(binary()) -> binary(). ascii_fallback(S) -> ascii_fallback_loop(gleam@string:to_graphemes(S), <<""/utf8>>). -file("src/multipartkit/form.gleam", 253). -spec is_attr_char(integer()) -> boolean(). is_attr_char(B) -> (((((((((((((((B >= 16#41) andalso (B =< 16#5A)) orelse ((B >= 16#61) andalso (B =< 16#7A))) orelse ((B >= 16#30) andalso (B =< 16#39))) orelse (B =:= 16#21)) orelse (B =:= 16#23)) orelse (B =:= 16#24)) orelse (B =:= 16#26)) orelse (B =:= 16#2B)) orelse (B =:= 16#2D)) orelse (B =:= 16#2E)) orelse (B =:= 16#5E)) orelse (B =:= 16#5F)) orelse (B =:= 16#60)) orelse (B =:= 16#7C)) orelse (B =:= 16#7E). -file("src/multipartkit/form.gleam", 277). -spec hex_digit(integer()) -> binary(). hex_digit(N) -> case N of 0 -> <<"0"/utf8>>; 1 -> <<"1"/utf8>>; 2 -> <<"2"/utf8>>; 3 -> <<"3"/utf8>>; 4 -> <<"4"/utf8>>; 5 -> <<"5"/utf8>>; 6 -> <<"6"/utf8>>; 7 -> <<"7"/utf8>>; 8 -> <<"8"/utf8>>; 9 -> <<"9"/utf8>>; 10 -> <<"A"/utf8>>; 11 -> <<"B"/utf8>>; 12 -> <<"C"/utf8>>; 13 -> <<"D"/utf8>>; 14 -> <<"E"/utf8>>; 15 -> <<"F"/utf8>>; _ -> erlang:integer_to_binary(N) end. -file("src/multipartkit/form.gleam", 273). -spec percent_byte(integer()) -> binary(). percent_byte(B) -> <<<<"%"/utf8, (hex_digit(B div 16))/binary>>/binary, (hex_digit(B rem 16))/binary>>. -file("src/multipartkit/form.gleam", 231). -spec percent_encode_loop(bitstring(), binary()) -> binary(). percent_encode_loop(Bytes, Acc) -> case Bytes of <<>> -> Acc; <> -> Chunk = case is_attr_char(B) of false -> percent_byte(B); true -> _pipe = gleam@bit_array:to_string(<>), gleam@result:unwrap(_pipe, percent_byte(B)) end, percent_encode_loop(Rest, <>); _ -> Acc end. -file("src/multipartkit/form.gleam", 227). ?DOC( " Percent-encode the UTF-8 byte representation of `s` per RFC 5987\n" " §3.2.1 attr-char. Bytes that match the attr-char production\n" " (`ALPHA / DIGIT / \"!\" / \"#\" / \"$\" / \"&\" / \"+\" / \"-\" / \".\" / \"^\" /\n" " \"_\" / \"`\" / \"|\" / \"~\"`) pass through unencoded; every other byte\n" " is emitted as `%HH` with uppercase hex digits.\n" ). -spec percent_encode_rfc5987(binary()) -> binary(). percent_encode_rfc5987(S) -> percent_encode_loop(gleam_stdlib:identity(S), <<""/utf8>>). -file("src/multipartkit/form.gleam", 302). ?DOC( " Strip CR, LF, and NUL from `value`. Used to neutralise header injection\n" " in any value that ends up on a header line — `name`, `filename`, and\n" " `content_type`.\n" ). -spec sanitize_value(binary()) -> binary(). sanitize_value(Value) -> _pipe = Value, _pipe@1 = gleam@string:replace(_pipe, <<"\r\n"/utf8>>, <<""/utf8>>), _pipe@2 = gleam@string:replace(_pipe@1, <<"\r"/utf8>>, <<""/utf8>>), _pipe@3 = gleam@string:replace(_pipe@2, <<"\n"/utf8>>, <<""/utf8>>), gleam@string:replace(_pipe@3, <<"\x{0000}"/utf8>>, <<""/utf8>>). -file("src/multipartkit/form.gleam", 314). -spec escape_quoted(binary(), binary()) -> binary(). escape_quoted(Remaining, Acc) -> case gleam_stdlib:string_pop_grapheme(Remaining) of {error, nil} -> Acc; {ok, {<<"\\"/utf8>>, Rest}} -> escape_quoted(Rest, <>); {ok, {<<"\""/utf8>>, Rest@1}} -> escape_quoted(Rest@1, <>); {ok, {Other, Rest@2}} -> escape_quoted(Rest@2, <>) end. -file("src/multipartkit/form.gleam", 310). -spec quote(binary()) -> binary(). quote(Value) -> <<<<"\""/utf8, (escape_quoted(Value, <<""/utf8>>))/binary>>/binary, "\""/utf8>>. -file("src/multipartkit/form.gleam", 33). ?DOC( " Append a text field. `value` is encoded as UTF-8 in the part body. No\n" " filename is set.\n" "\n" " Carriage returns, line feeds, and NUL bytes in `name` are silently\n" " stripped to prevent header injection. The cached `name` on the resulting\n" " `Part` reflects the sanitized value, matching what a parse-after-encode\n" " round-trip would produce. Use `unsafe_add_part` if byte-exact\n" " preservation is required.\n" ). -spec add_field(form(), binary(), binary()) -> form(). add_field(Form, Name, Value) -> Safe_name = sanitize_value(Name), Disposition = {<<"Content-Disposition"/utf8>>, <<"form-data; name="/utf8, (quote(Safe_name))/binary>>}, New_part = multipartkit@part:new( [Disposition], {some, Safe_name}, none, none, <> ), push(Form, New_part). -file("src/multipartkit/form.gleam", 174). ?DOC( " Build the `; filename=...` portion of a Content-Disposition header\n" " for a file part.\n" "\n" " For ASCII-safe filenames (every code point is printable US-ASCII\n" " per RFC 7230 §3.2.4) the legacy `filename=\"...\"` form is sufficient\n" " and is emitted unchanged.\n" "\n" " For filenames that contain bytes outside that range, the legacy\n" " form would produce a header value that violates RFC 7230 §3.2.4 and\n" " is mangled or rejected by strict HTTP intermediaries. We emit BOTH:\n" "\n" " - `filename=\"\"` — non-ASCII code points replaced\n" " with `_`. Lets pre-RFC-5987 clients still see a sensible name.\n" " - `filename*=UTF-8''` — RFC 5987 §3.2.1 / RFC 6266\n" " §4.3 form, faithful round-trip for spec-aware parsers.\n" "\n" " RFC 5987 §3.2.2: when both forms are present, the `*=` form takes\n" " precedence — multipartkit's own `content_disposition.parse` already\n" " honours that ordering, as do all browsers and the major HTTP\n" " servers.\n" ). -spec filename_disposition_params(binary()) -> binary(). filename_disposition_params(Filename) -> case is_ascii_safe(Filename) of true -> <<"; filename="/utf8, (quote(Filename))/binary>>; false -> <<<<<<"; filename="/utf8, (quote(ascii_fallback(Filename)))/binary>>/binary, "; filename*=UTF-8''"/utf8>>/binary, (percent_encode_rfc5987(Filename))/binary>> end. -file("src/multipartkit/form.gleam", 129). -spec build_file_part(binary(), binary(), binary(), bitstring()) -> multipartkit@part:part(). build_file_part(Name, Filename, Content_type, Body) -> Safe_name = sanitize_value(Name), Safe_filename = sanitize_value(Filename), Safe_content_type = sanitize_value(Content_type), Disposition_header = {<<"Content-Disposition"/utf8>>, <<<<"form-data; name="/utf8, (quote(Safe_name))/binary>>/binary, (filename_disposition_params(Safe_filename))/binary>>}, Content_type_header = {<<"Content-Type"/utf8>>, Safe_content_type}, multipartkit@part:new( [Disposition_header, Content_type_header], {some, Safe_name}, {some, Safe_filename}, {some, Safe_content_type}, Body ). -file("src/multipartkit/form.gleam", 57). ?DOC( " Append a file part with an explicit content type.\n" "\n" " Carriage returns, line feeds, and NUL bytes in `name`, `filename`, and\n" " `content_type` are silently stripped to prevent header injection. The\n" " cached `name`, `filename`, and `content_type` on the resulting `Part`\n" " reflect the sanitized values. Use `unsafe_add_part` if byte-exact\n" " preservation is required.\n" ). -spec add_file(form(), binary(), binary(), binary(), bitstring()) -> form(). add_file(Form, Name, Filename, Content_type, Body) -> push(Form, build_file_part(Name, Filename, Content_type, Body)). -file("src/multipartkit/form.gleam", 93). ?DOC( " Append a file part, inferring the content type via the supplied\n" " `Inferer`.\n" "\n" " Inference precedence:\n" "\n" " 1. `inferer.from_filename(filename)`\n" " 2. `inferer.from_bytes(body)`\n" " 3. `application/octet-stream`\n" "\n" " The inferred content type is sanitized (CR / LF / NUL stripped) before\n" " being written to the header.\n" ). -spec add_file_auto_with( form(), binary(), binary(), bitstring(), multipartkit@infer:inferer() ) -> form(). add_file_auto_with(Form, Name, Filename, Body, Inferer) -> Content_type = case (erlang:element(2, Inferer))(Filename) of {some, Value} -> Value; none -> case (erlang:element(3, Inferer))(Body) of {some, Value@1} -> Value@1; none -> <<"application/octet-stream"/utf8>> end end, add_file(Form, Name, Filename, Content_type, Body). -file("src/multipartkit/form.gleam", 73). ?DOC( " Append a file part using the default (no-op) inferer.\n" "\n" " Equivalent to `add_file_auto_with(form, name, filename, body,\n" " infer.default_inferer())`. The default inferer returns `None` from both\n" " helpers in v0.1.0, so this falls through to `application/octet-stream`\n" " unless you call `add_file_auto_with` with a real inferer.\n" ). -spec add_file_auto(form(), binary(), binary(), bitstring()) -> form(). add_file_auto(Form, Name, Filename, Body) -> add_file_auto_with( Form, Name, Filename, Body, multipartkit@infer:default_inferer() ).