-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, add_field/3, add_field_strict/3, unsafe_add_part/2, 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", 53). ?DOC(" A new empty form.\n"). -spec new() -> form(). new() -> {form, []}. -file("src/multipartkit/form.gleam", 250). ?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", 254). -spec push(form(), multipartkit@part:part()) -> form(). push(Form, The_part) -> {form, [The_part | erlang:element(2, Form)]}. -file("src/multipartkit/form.gleam", 74). ?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** enforce that — it silently accepts `\"\"` and\n" " also silently strips CR / LF / NUL bytes from `name` to prevent\n" " header injection. The cached `name` on the resulting `Part`\n" " reflects the sanitized value, matching what a parse-after-encode\n" " round-trip would produce. The silent acceptance and strip is data\n" " loss the caller cannot observe — `add_field(\"\", _)` produces\n" " `name=\"\"`, and `add_field(\"name\\n\", _)` produces a part with\n" " `name=\"\"` — so callers passing user-typed or upstream data into\n" " `name` should prefer `add_field_strict`, which surfaces both\n" " failure modes as typed errors (`EmptyFieldName(value:)` and\n" " `NameContainsControlBytes(value:)`). 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 = 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", 183). ?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", 245). ?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", 258). -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", 112). ?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", 148). ?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", 128). ?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() ). -file("src/multipartkit/form.gleam", 214). ?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 ).