-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()}. -file("src/multipartkit/form.gleam", 43). ?DOC(" A new empty form.\n"). -spec new() -> form(). new() -> {form, []}. -file("src/multipartkit/form.gleam", 211). ?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", 215). -spec push(form(), multipartkit@part:part()) -> form(). push(Form, The_part) -> {form, [The_part | erlang:element(2, Form)]}. -file("src/multipartkit/form.gleam", 60). ?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. The strip is data loss the caller cannot\n" " observe — `add_field(\"name\\n\", _)` produces a part with `name=\"\"` —\n" " so callers passing user-typed or upstream data into `name` should\n" " prefer `add_field_strict`, which surfaces the bad input as\n" " `Error(NameContainsControlBytes(value:))` instead. Use\n" " `unsafe_add_part` if byte-exact preservation of arbitrary header\n" " 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", 156). ?DOC( " Strict counterpart of `add_field`: rejects names containing CR /\n" " LF / NUL bytes with `Error(NameContainsControlBytes(value:))`.\n" "\n" " The non-strict `add_field` silently strips these bytes (so\n" " `add_field(\"name\\n\", _)` produces a part with `name=\"\"`). 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\". (#40)\n" ). -spec add_field_strict(form(), binary(), binary()) -> {ok, form()} | {error, form_error()}. add_field_strict(Form, Name, Value) -> 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 ). -file("src/multipartkit/form.gleam", 206). ?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", 219). -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", 92). ?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. The strip on `filename` is especially\n" " dangerous — `add_file(_, \"fi\\nle.png\", _, _)` concatenates the two\n" " halves into the *different valid filename* `\"file.png\"`, which can\n" " change authorisation-relevant identifiers. Callers passing user-typed\n" " or upstream data should prefer `add_file_strict`, which surfaces the\n" " bad input as `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", 128). ?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", 108). ?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", 179). ?DOC( " Strict counterpart of `add_file`: rejects names, filenames, and\n" " content types containing CR / LF / NUL bytes with the matching\n" " `FormError` variant.\n" "\n" " The non-strict `add_file` silently strips these 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. (#41)\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( 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 ).