Current section
Files
Jump to
Current section
Files
src/multipartkit@form.erl
-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_<n>\"`, where `<n>` 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_<n>\"`, where `<n>`\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_<k>\"` 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,
<<Value/binary>>
),
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
).