moon-http-range

RFC 9110 HTTP byte-range parser, resolver, serializer, response planner and audit toolkit for MoonBit.

http
range
byte-range
rfc9110
content-range
partial-content
moonbit
moon add hjn0123/moon-http-range@0.1.0
Download zip
Author
Version
0.1.0
License
Apache-2.0
Last updated
4 hours ago
Downloads
4
README

#moon-http-range

moon-http-range is a strict RFC 9110-oriented HTTP byte-range parser, resolver, serializer, response planner, and audit toolkit for MoonBit.

#Overview

The library implements the range semantics that an HTTP server, object store, download service, media service, or client can reuse. It is deliberately a pure semantic layer: it accepts header values and representation lengths, and returns typed values or plans. It does not open files, send network data, or decide an application's authorization and caching policy.

#Why HTTP Range

Range requests let a recipient ask for selected parts of a representation. Common uses include resumable downloads, seeking in media, serving large static objects, and fetching small regions of an archive. Correct handling requires more than splitting on punctuation: the parser must distinguish invalid syntax from a valid but unsatisfiable range and must resolve suffix and open-ended forms safely.

#RFC 9110 Scope

The implemented scope covers RFC 9110 Sections 14.1–14.4 and planning metadata for 14.6, plus status guidance for 206 and 416, If-Range validation from §13.1.5, IMF-fixdate dates from §5.6.7, and defensive controls related to Range denial-of-service considerations. See docs/specification-map.md for the implementation and test map.

#Range Model

ByteRangeSpec retains the request form and order:

  • Closed(first, last) for 0-499;
  • OpenEnded(first) for 500-;
  • Suffix(length) for -500.

Resolved ConcreteRange values are inclusive: [first, last]. Their length is last - first + 1. Positions and representation lengths use Int64.

Unknown range units are represented as Other(unit) with their raw range set. The library never applies byte semantics to an unknown unit.

#Parsing Range

let request = @range.parse_range("bytes=0-499,-100").unwrap()
println(@range.serialize_range(request))

parse_range_with_limits accepts an explicit Limits profile. Decimal values are accumulated one digit at a time and checked before multiplication, so values above 9223372036854775807 return IntegerOverflow rather than wrapping.

Errors carry a stage, kind, offset, and context. The offset is a zero-based MoonBit string code-unit offset, not a UTF-8 byte offset.

#Resolving Byte Ranges

let request = @range.parse_range("bytes=500-,-100").unwrap()
let result = @range.resolve_byte_ranges(request, 1_000L).unwrap()

Resolution clips a closed range at the representation end, expands an open-ended range to the end, and maps a suffix range to the final requested bytes. The result includes satisfiable ranges, the original member count, and the count of unsatisfiable members.

#Satisfiability

Syntax and satisfiability are separate:

  • bytes=500-400 is syntactically invalid;
  • bytes=500-600 is valid but unsatisfiable for a 100-byte representation;
  • bytes=0-99,9999-10000 is satisfiable for a 1000-byte representation because one member is satisfiable;
  • bytes=-0 parses but resolves to no range;
  • every byte range is unsatisfiable for a zero-length representation.

#Accept-Ranges

parse_accept_ranges handles none, bytes, and comma-separated range units. Unit comparison is ASCII case-insensitive. none is reserved and cannot be combined with another unit. supports_bytes() and supports(unit) provide queries, while serialize_accept_ranges provides deterministic output.

#Content-Range

parse_content_range and serialize_content_range support:

bytes 0-499/1234 bytes 42-1233/* bytes */1234

For a known complete length, the length must be greater than the last byte position. unsatisfied_content_range(length) creates the value commonly used with a 416 response. Unknown units are retained without invented semantics.

#Response Planning

plan_byte_range_response is a pure helper. Given a parsed request, length, and caller policy flags, it returns Ignore, Single, Multiple, or Unsatisfiable. recommended_status() maps those to 200, 206, 206, and 416. This is guidance after the caller supplies its HTTP policy; the library does not control server behavior.

plan_conditional_range_response adds an optional If-Range validator: when the validator does not match the current representation (strong comparison for tags, exact seconds for dates), the plan is Ignore so the caller serves the full representation per RFC 9110 §13.1.5.

#If-Range and HTTP Dates

parse_if_range accepts either an entity-tag (optionally weak-prefixed with W/) or an IMF-fixdate, and normalizes dates to Unix seconds. if_range_matches compares a validator against the current representation using strong entity-tag comparison (RFC 9110 §8.8.3.2) or exact second equality.

let validator = @range.parse_if_range("\"xyzzy\"").unwrap()
let matches = @range.if_range_matches(validator, "\"xyzzy\"", 0L)

parse_http_date validates the canonical 29-character IMF-fixdate shape, the calendar date (including leap years), and the day-of-week, and returns Unix seconds. http_date_from_unix is the inverse formatter and rejects years outside 0000-9999.

#Multiple Ranges

plan_multipart_ranges validates a caller-provided deterministic boundary and produces per-part range, Content-Range, and content length metadata. multipart_content_type creates multipart/byteranges; boundary=.... estimated_body_length returns the exact byte length of the framed body (delimiters, headers, CRLFs, data, and closing delimiter) for a given per-part Content-Type, suitable for a Content-Length decision. No multipart body is parsed or streamed.

Server-side helpers are explicit and separate from parsing: sort_ranges, merge_overlapping_ranges, merge_adjacent_ranges, and coalesce_ranges. Parsing and canonical serialization preserve request order and never merge members automatically.

#Audit

audit_range_request reports advisory findings including excessive counts, overlap, duplicates, descending order, many small ranges, zero-length suffixes, large values or headers, adjacent ranges, unknown units, and inefficient sets. An audit warning does not change parser validity. Resource limits are enforced separately by Limits::default(), Limits::strict(), and Limits::permissive().

#CLI

The executable package is cmd/range-tool:

moon run cmd/range-tool -- parse --range "bytes=0-499,-100" moon run cmd/range-tool -- resolve --range "bytes=0-499,-100" --length 1000 moon run cmd/range-tool -- content-range --value "bytes 0-499/1000" moon run cmd/range-tool -- accept-ranges --value "bytes, exampleunit" moon run cmd/range-tool -- plan --range "bytes=0-99,900-" --length 1000 moon run cmd/range-tool -- audit --range "bytes=0-0,1-1,2-2,3-3" --length 1000 --details moon run cmd/range-tool -- if-range --value "Sun, 06 Nov 1994 08:49:37 GMT" --etag "anything" --length 784111777 moon run cmd/range-tool -- stats --range "bytes=0-99" moon run cmd/range-tool -- version

Commands emit deterministic text or JSON. audit --details lists every finding with its kind and context, and an invalid --length is reported as an error instead of being treated as zero. Argument normalization is isolated and tested because native, JavaScript, and WebAssembly launchers can expose the program name differently.

#Examples

Six executable examples live under examples/parse, examples/resolve, examples/content_range, examples/response_plan, examples/if_range, and examples/multipart.

moon run examples/parse --target wasm-gc moon run examples/resolve --target js moon run examples/content_range --target native moon run examples/response_plan --target wasm-gc moon run examples/if_range --target wasm-gc moon run examples/multipart --target wasm-gc

examples/if_range replays a resumable download against matching and stale validators. examples/multipart plans a two-part multipart/byteranges body and prints its exact estimated length.

#Testing

The suite contains 187 focused named tests, 2,700 deterministic generated cases, and every-prefix truncation checks. It covers parsing, overflow, limits, resolution, normalization, headers, planning, audit, CLI normalization, and reconstructed RFC examples on wasm, wasm-gc, js, and native.

Run the fail-fast verification script from PowerShell:

powershell -ExecutionPolicy Bypass -File scripts/verify_all.ps1

#Security

Parsing a Range field is not access control. Callers must authorize the selected representation and guard the cost of reading, buffering, compressing, and transmitting each part. Use limits before parsing untrusted fields and use the audit report as one input to server policy. See docs/security.md.

#Limitations

There is no HTTP server or client, file I/O, networking, persistent validator state, MIME parser, multipart parser, cache, or authorization system. See docs/limitations.md.

#Release Status

Version 0.1.0 is the verified final-acceptance release for the hjn0123/moon-http-range module.

#License

Licensed under Apache License 2.0. See LICENSE. RFC-derived test fixture attribution is recorded in THIRD_PARTY_NOTICES.md.

#
RangeError

pub(all) suberror RangeError {
RangeError(RangeErrorStage, RangeErrorKind, Int, String)
}

Offset is a zero-based MoonBit string code-unit offset, not a UTF-8 byte offset.

#
RangeError::context

fn RangeError::context(self : RangeError) -> String

#
RangeError::kind

#
RangeError::offset

fn RangeError::offset(self : RangeError) -> Int

#
RangeError::stage

#
RangeError::to_string

fn RangeError::to_string(self : RangeError) -> String

#
AcceptRanges

pub(all) enum AcceptRanges {
NoneAccepted
Units(Array[String])
} derive(Eq,
Debug
)

Parsed Accept-Ranges field. NoneAccepted is the reserved none value.

#
AcceptRanges::supports

fn AcceptRanges::supports(self : AcceptRanges, unit : String) -> Bool

#
AcceptRanges::supports_bytes

fn AcceptRanges::supports_bytes(self : AcceptRanges) -> Bool

#
AcceptRanges::units

fn AcceptRanges::units(self : AcceptRanges) -> Array[String]

#
AuditFinding

pub struct AuditFinding {
kind : AuditFindingKind
context : String
} derive(Eq,
Debug
)

#
AuditFinding::context

fn AuditFinding::context(self : AuditFinding) -> String

#
AuditFinding::kind

#
AuditFindingKind

pub(all) enum AuditFindingKind {
TooManyRanges
OverlappingRanges
ManySmallRanges
DescendingOrder
DuplicateRanges
ZeroLengthSuffix
LargeNumericValue
LargeHeader
UnknownRangeUnit
AdjacentRanges
InefficientRangeSet
} derive(Eq,
Debug
)

Advisory finding; audit never changes parser validity.

#
AuditFindingKind::name

fn AuditFindingKind::name(self : AuditFindingKind) -> String

#
AuditReport

pub struct AuditReport {
findings : Array[AuditFinding]
} derive(Eq,
Debug
)

#
AuditReport::findings

fn AuditReport::findings(self : AuditReport) -> Array[AuditFinding]

#
AuditReport::is_clean

fn AuditReport::is_clean(self : AuditReport) -> Bool

#
ByteRangeSpec

pub(all) enum ByteRangeSpec {
Closed(Int64, Int64)
OpenEnded(Int64)
Suffix(Int64)
} derive(Eq,
Debug
)

One syntactically valid byte-range-spec or suffix-byte-range-spec.

#
ConcreteRange

pub struct ConcreteRange {
first : Int64
last : Int64
} derive(Eq,
Debug
)

Inclusive concrete byte interval [first, last].

#
ConcreteRange::first

fn ConcreteRange::first(self : ConcreteRange) -> Int64

#
ConcreteRange::last

fn ConcreteRange::last(self : ConcreteRange) -> Int64

#
ConcreteRange::length

fn ConcreteRange::length(self : ConcreteRange) -> Int64

#
ContentRangeValue

pub(all) enum ContentRangeValue {
Satisfied(RangeUnit, Int64, Int64, Int64?)
Unsatisfied(RangeUnit, Int64)
OtherContentRange(String, String)
} derive(Eq,
Debug
)

Content-Range value for satisfied or unsatisfied responses.

#
IfRangeValue

pub(all) enum IfRangeValue {
EntityTag(String)
HttpDate(Int64)
} derive(Eq,
Debug
)

Parsed If-Range field value (RFC 9110 §13.1.5): either an entity-tag or an HTTP-date normalized to Unix seconds (UTC).

#
Limits

pub struct Limits {
max_input_bytes : Int
max_ranges : Int
max_digits_per_number : Int
max_unit_bytes : Int
max_raw_other_range_bytes : Int
} derive(Eq,
Debug
)

Resource limits applied before and during header parsing.

#
Limits::default

fn Limits::default() -> Limits

#
Limits::max_digits_per_number

fn Limits::max_digits_per_number(self : Limits) -> Int

#
Limits::max_input_bytes

fn Limits::max_input_bytes(self : Limits) -> Int

#
Limits::max_ranges

fn Limits::max_ranges(self : Limits) -> Int

#
Limits::max_raw_other_range_bytes

fn Limits::max_raw_other_range_bytes(self : Limits) -> Int

#
Limits::max_unit_bytes

fn Limits::max_unit_bytes(self : Limits) -> Int

#
Limits::permissive

fn Limits::permissive() -> Limits

#
Limits::strict

fn Limits::strict() -> Limits

#
MultipartPartPlan

pub struct MultipartPartPlan {
range : ConcreteRange
content_range : ContentRangeValue
content_length : Int64
} derive(Eq,
Debug
)

Per-part metadata for a caller-produced multipart/byteranges body.

#
MultipartPartPlan::content_length

fn MultipartPartPlan::content_length(self : MultipartPartPlan) -> Int64

#
MultipartPartPlan::content_range

#
MultipartPartPlan::range

#
MultipartPlan

pub struct MultipartPlan {
boundary : String
content_type : String
parts : Array[MultipartPartPlan]
} derive(Eq,
Debug
)

#
MultipartPlan::boundary

fn MultipartPlan::boundary(self : MultipartPlan) -> String

#
MultipartPlan::content_type

fn MultipartPlan::content_type(self : MultipartPlan) -> String

#
MultipartPlan::estimated_body_length

fn MultipartPlan::estimated_body_length(self : MultipartPlan, part_content_type : String) -> Int64

Estimate the exact byte length of the multipart/byteranges body framed per RFC 9110 §14.6, given the per-part Content-Type value. Every delimiter line, header line, CRLF pair, data byte, and the closing delimiter is counted, so the value is suitable for a Content-Length decision when the per-part Content-Type header matches the supplied string.

#
MultipartPlan::parts

#
RangeErrorKind

pub(all) enum RangeErrorKind {
EmptyInput
MissingEquals
InvalidUnit
EmptyRangeSet
InvalidRange
MissingNumber
InvalidNumber
IntegerOverflow
LastBeforeFirst
InvalidCompleteLength
LimitExceeded
UnexpectedCharacter
InvalidRepresentationLength
UnsupportedRangeUnit
InvalidBoundary
InvalidEntityTag
InvalidHttpDate
} derive(Eq,
Debug
)

Machine-readable error category.

#
RangeErrorKind::name

fn RangeErrorKind::name(self : RangeErrorKind) -> String

#
RangeErrorStage

pub(all) enum RangeErrorStage {
Input
Unit
Syntax
Number
RangeSpec
ContentRange
AcceptRanges
Resolve
Limit
Plan
IfRange
HttpDate
} derive(Eq,
Debug
)

Processing stage for a structured range error.

#
RangeErrorStage::name

fn RangeErrorStage::name(self : RangeErrorStage) -> String

#
RangeRequest

pub struct RangeRequest {
unit : RangeUnit
specs : Array[ByteRangeSpec]
raw_other_range_set : String?
} derive(Eq,
Debug
)

Parsed Range field. Byte units carry structured specs; other units keep raw data.

#
RangeRequest::raw_other_range_set

fn RangeRequest::raw_other_range_set(self : RangeRequest) -> String?

#
RangeRequest::specs

#
RangeRequest::unit

fn RangeRequest::unit(self : RangeRequest) -> RangeUnit

#
RangeUnit

pub(all) enum RangeUnit {
Bytes
Other(String)
} derive(Eq,
Debug
)

Range unit. Other keeps an unknown unit without inferring semantics.

#
RangeUnit::is_bytes

fn RangeUnit::is_bytes(self : RangeUnit) -> Bool

#
RangeUnit::name

fn RangeUnit::name(self : RangeUnit) -> String

#
ResolvedRangeSet

pub struct ResolvedRangeSet {
satisfiable_ranges : Array[ConcreteRange]
unsatisfiable_count : Int
original_count : Int
} derive(Eq,
Debug
)

Resolution preserves how many original members could not be satisfied.

#
ResolvedRangeSet::original_count

fn ResolvedRangeSet::original_count(self : ResolvedRangeSet) -> Int

#
ResolvedRangeSet::ranges

#
ResolvedRangeSet::satisfiable_count

fn ResolvedRangeSet::satisfiable_count(self : ResolvedRangeSet) -> Int

#
ResolvedRangeSet::unsatisfiable_count

fn ResolvedRangeSet::unsatisfiable_count(self : ResolvedRangeSet) -> Int

#
ResponsePlan

pub(all) enum ResponsePlan {
Ignore
Unsatisfiable(Int64)
Single(ConcreteRange)
Multiple(Array[ConcreteRange])
} derive(Eq,
Debug
)

Pure response plan. The caller remains responsible for HTTP policy and I/O.

#
ResponsePlan::ranges

#
ResponsePlan::recommended_status

fn ResponsePlan::recommended_status(self : ResponsePlan) -> Int

Suggested status assuming the caller applies this plan.

#
audit_range_request

fn audit_range_request(request : RangeRequest, raw_input? : String, representation_length? : Int64) -> AuditReport

Audit raw request shape and, when available, resolved byte ranges.

#
canonicalize_range

fn canonicalize_range(input : String) -> Result[String, RangeError]

Parse then serialize; syntax normalization does not merge ranges.

#
coalesce_ranges

fn coalesce_ranges(input : Array[ConcreteRange]) -> Array[ConcreteRange]

Server-side planning helper: sort, then merge overlap and adjacency.

#
concrete_range

fn concrete_range(first : Int64, last : Int64) -> ConcreteRange

#
http_date_from_unix

fn http_date_from_unix(seconds : Int64) -> Result[String, RangeError]

Format Unix seconds (UTC) as an IMF-fixdate (RFC 9110 §5.6.7). Negative values produce pre-1970 dates. Fails only when the year falls outside the four-digit 0000-9999 range.

#
if_range_matches

fn if_range_matches(value : IfRangeValue, current_etag : String, current_last_modified_unix : Int64) -> Bool

Decide whether an If-Range validator matches the current representation. Entity-tags use strong comparison; dates compare as exact Unix seconds.

#
library_version

fn library_version() -> String

#
merge_adjacent_ranges

fn merge_adjacent_ranges(input : Array[ConcreteRange]) -> Array[ConcreteRange]

Merge ranges that overlap or touch at one byte boundary.

#
merge_overlapping_ranges

fn merge_overlapping_ranges(input : Array[ConcreteRange]) -> Array[ConcreteRange]

Merge overlapping ranges, preserving gaps and not merging adjacency.

#
multipart_content_type

fn multipart_content_type(boundary : String) -> String

#
parse_accept_ranges

fn parse_accept_ranges(input : String) -> Result[AcceptRanges, RangeError]

#
parse_accept_ranges_with_limits

fn parse_accept_ranges_with_limits(input : String, limits : Limits) -> Result[AcceptRanges, RangeError]

#
parse_content_range

fn parse_content_range(input : String) -> Result[ContentRangeValue, RangeError]

#
parse_content_range_with_limits

fn parse_content_range_with_limits(input : String, limits : Limits) -> Result[ContentRangeValue, RangeError]

#
parse_decimal_int64

fn parse_decimal_int64(input : String) -> Result[Int64, RangeError]

Parse a non-empty unsigned decimal into Int64 with pre-multiplication overflow checks.

#
parse_http_date

fn parse_http_date(input : String) -> Result[Int64, RangeError]

Parse an IMF-fixdate (RFC 9110 §5.6.7) into Unix seconds (UTC). The date must have exactly the canonical shape Sun, 06 Nov 1994 08:49:37 GMT. The day-of-week is validated against the calendar date, and the calendar date is validated by a days-from-civil round trip, so 32 Jan and 30 Feb are rejected. Leap seconds (second value 60) are accepted per RFC 9110.

#
parse_if_range

fn parse_if_range(input : String) -> Result[IfRangeValue, RangeError]

Parse an If-Range field value. Values that begin with a DQUOTE or with the case-sensitive W/ prefix are validated as entity-tags; everything else is parsed as an IMF-fixdate and normalized to Unix seconds.

#
parse_range

fn parse_range(input : String) -> Result[RangeRequest, RangeError]

Parse a Range field value using default resource limits.

#
parse_range_with_limits

fn parse_range_with_limits(input : String, limits : Limits) -> Result[RangeRequest, RangeError]

Strict RFC 9110-oriented Range parser. It preserves byte-spec order.

#
plan_byte_range_response

fn plan_byte_range_response(request : RangeRequest, representation_length : Int64, method_is_get : Bool, range_supported : Bool, apply_ranges : Bool) -> Result[ResponsePlan, RangeError]

#
plan_conditional_range_response

fn plan_conditional_range_response(request : RangeRequest, representation_length : Int64, if_range : IfRangeValue?, current_etag : String, current_last_modified_unix : Int64, method_is_get : Bool, range_supported : Bool, apply_ranges : Bool) -> Result[ResponsePlan, RangeError]

Plan a range response under an optional If-Range validator (RFC 9110 §13.1.5). When a validator is present and does not match the current representation, the Range field must be ignored and the caller serves the full representation, so the plan is Ignore (status 200). A matching validator falls through to the unconditional planner.

#
plan_multipart_ranges

fn plan_multipart_ranges(ranges : Array[ConcreteRange], representation_length : Int64, boundary : String) -> Result[MultipartPlan, RangeError]

#
range_error

fn range_error(stage : RangeErrorStage, kind : RangeErrorKind, offset : Int, context : String) -> RangeError

#
range_request

fn range_request(unit : RangeUnit, specs : Array[ByteRangeSpec], raw_other_range_set? : String) -> RangeRequest

#
resolve_byte_ranges

fn resolve_byte_ranges(request : RangeRequest, representation_length : Int64) -> Result[ResolvedRangeSet, RangeError]

Resolve byte specs against the selected representation length.

#
resolved_range_set

fn resolved_range_set(ranges : Array[ConcreteRange], unsatisfiable_count : Int, original_count : Int) -> ResolvedRangeSet

#
serialize_accept_ranges

fn serialize_accept_ranges(value : AcceptRanges) -> String

#
serialize_content_range

fn serialize_content_range(value : ContentRangeValue) -> String

#
serialize_range

fn serialize_range(request : RangeRequest) -> String

Serialize a parsed Range field without sorting or coalescing specs.

#
sort_ranges

fn sort_ranges(input : Array[ConcreteRange]) -> Array[ConcreteRange]

Return a copy sorted by inclusive first position, then last position.

#
strong_etag_equal

fn strong_etag_equal(left : String, right : String) -> Bool

Strong comparison of two entity-tags (RFC 9110 §8.8.3.2). Weak tags never match, even against an identical weak tag, because weak comparison is not strong comparison. Tags are compared as opaque strings.

#
unsatisfied_content_range

fn unsatisfied_content_range(length : Int64) -> Result[ContentRangeValue, RangeError]