-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_field_strict/3, add_file/5, add_file_auto_with/5, add_file_auto/4, add_file_strict/5]). -export_type([form/0, form_error/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())}. -type form_error() :: {name_contains_control_bytes, binary()} | {filename_contains_control_bytes, binary()} | {content_type_contains_control_bytes, binary()} | {empty_field_name, binary()}. -file("src/multipartkit/form.gleam", 55). ?DOC(" A new empty form.\n"). -spec new() -> form(). new() -> {form, []}. -file("src/multipartkit/form.gleam", 262). ?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", 266). -spec push(form(), multipartkit@part:part()) -> form(). push(Form, The_part) -> {form, [The_part | erlang:element(2, Form)]}. -file("src/multipartkit/form.gleam", 257). ?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", 279). ?DOC( " Guarantee a non-empty field name for the lenient (`add_field`)\n" " builder path. `safe_name` is the value after CR / LF / NUL stripping.\n" " RFC 7578 §4.2 requires the `name` parameter to be the field name; an\n" " empty name produces a wire image whose receiver interpretation is\n" " implementation-defined. Rather than emit `name=\"\"`, replace an empty\n" " (or whitespace-only) name with `\"_unnamed_\"`, where `` is the\n" " zero-based position the new part will occupy. A name that is merely\n" " padded (e.g. `\" a \"`) is kept as-is — only an entirely blank name is\n" " rewritten. (#57, #58)\n" ). -spec ensure_named(form(), binary()) -> binary(). ensure_named(Form, Safe_name) -> gleam@bool:guard( gleam@string:trim(Safe_name) /= <<""/utf8>>, Safe_name, fun() -> <<"_unnamed_"/utf8, (erlang:integer_to_binary( erlang:length(erlang:element(2, Form)) ))/binary>> end ). -file("src/multipartkit/form.gleam", 84). ?DOC( " Append a text field. `value` is encoded as UTF-8 in the part body. No\n" " filename is set.\n" "\n" " RFC 7578 §4.2 requires the `Content-Disposition` `name` parameter\n" " to be non-empty (it is the field name itself). This non-strict\n" " variant does **not** reject a bad `name` — it silently strips CR /\n" " LF / NUL bytes to prevent header injection. To guarantee the\n" " resulting part is always RFC 7578-addressable, a `name` that is\n" " empty (or becomes empty / whitespace-only after the strip) is\n" " replaced with a generated placeholder `\"_unnamed_\"`, where ``\n" " is the part's zero-based position in the form. This means the\n" " observable `name` is **never** `\"\"` — `add_field(\"\", _)` and\n" " `add_field(\"name\\n\", _)` both produce a part named `\"_unnamed_0\"`\n" " rather than a silently empty-named part whose receiver\n" " interpretation is implementation-defined (#57, #58). The rename is\n" " still a loss the caller cannot prevent here, so callers passing\n" " user-typed or upstream data into `name` should prefer\n" " `add_field_strict`, which surfaces both failure modes as typed\n" " errors (`EmptyFieldName(value:)` and `NameContainsControlBytes(value:)`)\n" " instead of renaming. The cached `name` on the resulting `Part`\n" " reflects the placeholder and survives a parse-after-encode round-trip\n" " unchanged. The placeholder is position-based, not collision-proof: a\n" " caller who also passes a literal `\"_unnamed_\"` as a real field name\n" " can end up with two parts sharing that name. Use `unsafe_add_part` if\n" " byte-exact preservation of arbitrary header values is required.\n" ). -spec add_field(form(), binary(), binary()) -> form(). add_field(Form, Name, Value) -> Safe_name = ensure_named( Form, multipartkit@internal@disposition:sanitize_value(Name) ), Header = {<<"Content-Disposition"/utf8>>, multipartkit@internal@disposition:build_form_data_value(Safe_name, none)}, New_part = multipartkit@part:unchecked_new( [Header], {some, Safe_name}, none, none, <> ), push(Form, New_part). -file("src/multipartkit/form.gleam", 195). ?DOC( " Strict counterpart of `add_field`: rejects names containing CR /\n" " LF / NUL bytes with `Error(NameContainsControlBytes(value:))`,\n" " and rejects empty or whitespace-only names with\n" " `Error(EmptyFieldName(value:))`.\n" "\n" " The non-strict `add_field` silently strips control bytes (so\n" " `add_field(\"name\\n\", _)` produces a part with `name=\"\"`), and\n" " also silently accepts a truly empty `name`. RFC 7578 §4.2\n" " requires the `Content-Disposition` `name` parameter to be the\n" " field name; an empty name produces a wire image whose\n" " interpretation is implementation-defined at the receiver. For\n" " callers that pass user-typed or upstream data into `name` and\n" " want to surface bad inputs as a typed error rather than silent\n" " data loss, use this variant. The `value` payload carries the\n" " caller's original input so the error renders as\n" " \"field name `foo\\\\nbar` contains forbidden control bytes\" or\n" " \"field name ` ` is empty\". (#40, #51)\n" ). -spec add_field_strict(form(), binary(), binary()) -> {ok, form()} | {error, form_error()}. add_field_strict(Form, Name, Value) -> gleam@bool:guard( gleam@string:trim(Name) =:= <<""/utf8>>, {error, {empty_field_name, Name}}, fun() -> gleam@bool:guard( multipartkit@internal@disposition:has_header_breaking_bytes( Name ), {error, {name_contains_control_bytes, Name}}, fun() -> {ok, add_field(Form, Name, Value)} end ) end ). -file("src/multipartkit/form.gleam", 284). -spec build_file_part(binary(), binary(), binary(), bitstring()) -> multipartkit@part:part(). build_file_part(Name, Filename, Content_type, Body) -> Safe_name = multipartkit@internal@disposition:sanitize_value(Name), Safe_filename = multipartkit@internal@disposition:sanitize_value(Filename), Safe_content_type = multipartkit@internal@disposition:sanitize_value( Content_type ), Disposition_header = {<<"Content-Disposition"/utf8>>, multipartkit@internal@disposition:build_form_data_value( Safe_name, {some, Safe_filename} )}, Content_type_header = {<<"Content-Type"/utf8>>, Safe_content_type}, multipartkit@part:unchecked_new( [Disposition_header, Content_type_header], {some, Safe_name}, {some, Safe_filename}, {some, Safe_content_type}, Body ). -file("src/multipartkit/form.gleam", 122). ?DOC( " Append a file part with an explicit content type.\n" "\n" " RFC 7578 §4.2 requires the `Content-Disposition` `name` parameter\n" " to be non-empty (the `filename` parameter may legitimately be\n" " empty). This non-strict variant does **not** enforce the\n" " non-empty `name` rule — it silently accepts `\"\"` and also\n" " silently strips CR / LF / NUL bytes from `name`, `filename`, and\n" " `content_type` to prevent header injection. The cached `name`,\n" " `filename`, and `content_type` on the resulting `Part` reflect\n" " the sanitized values. The strip on `filename` is especially\n" " dangerous — `add_file(_, \"fi\\nle.png\", _, _)` concatenates the\n" " two halves into the *different valid filename* `\"file.png\"`,\n" " which can change authorisation-relevant identifiers. Callers\n" " passing user-typed or upstream data should prefer\n" " `add_file_strict`, which surfaces the bad input as\n" " `Error(EmptyFieldName(value:))` /\n" " `Error(NameContainsControlBytes(value:))` /\n" " `Error(FilenameContainsControlBytes(value:))` /\n" " `Error(ContentTypeContainsControlBytes(value:))`. Use\n" " `unsafe_add_part` if byte-exact preservation of arbitrary header\n" " values 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", 160). ?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", 140). ?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, so this always falls through to `application/octet-stream` —\n" " inference is opt-in. To resolve well-known extensions and magic-byte\n" " signatures via `nao1215/mimetype`, call `add_file_auto_with` with\n" " `infer.builtin_inferer()` (or your own `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() ). -file("src/multipartkit/form.gleam", 226). ?DOC( " Strict counterpart of `add_file`: rejects names, filenames, and\n" " content types containing CR / LF / NUL bytes with the matching\n" " `FormError` variant, and rejects an empty or whitespace-only\n" " `name` with `Error(EmptyFieldName(value:))`. (`filename` may\n" " legitimately be empty — only `name` is required to be\n" " non-empty per RFC 7578 §4.2.)\n" "\n" " The non-strict `add_file` silently strips control bytes. For\n" " `name` the strip leaves an empty string (loud round-trip\n" " failure); for `filename` it concatenates the two halves into a\n" " *different valid filename*, which can change\n" " authorisation-relevant identifiers (the attacker shape\n" " described in #41). The strict variant catches both at the\n" " builder boundary so the wrong wire never gets produced.\n" " (#41, #51)\n" ). -spec add_file_strict(form(), binary(), binary(), binary(), bitstring()) -> {ok, form()} | {error, form_error()}. add_file_strict(Form, Name, Filename, Content_type, Body) -> gleam@bool:guard( gleam@string:trim(Name) =:= <<""/utf8>>, {error, {empty_field_name, Name}}, fun() -> gleam@bool:guard( multipartkit@internal@disposition:has_header_breaking_bytes( Name ), {error, {name_contains_control_bytes, Name}}, fun() -> gleam@bool:guard( multipartkit@internal@disposition:has_header_breaking_bytes( Filename ), {error, {filename_contains_control_bytes, Filename}}, fun() -> gleam@bool:guard( multipartkit@internal@disposition:has_header_breaking_bytes( Content_type ), {error, {content_type_contains_control_bytes, Content_type}}, fun() -> {ok, add_file( Form, Name, Filename, Content_type, Body )} end ) end ) end ) end ).