Metadata-Version: 2.4
Name: digest-fields-parse
Version: 0.1.1
Summary: Zero-dependency RFC 9530 HTTP Digest field (Content-Digest, Repr-Digest, Want-Content-Digest, Want-Repr-Digest) parser and serializer for Node.js (>=18, ESM+CJS) and Python (>=3.11)
Author: repo-factory
License: MIT
Keywords: http,digest,content-digest,repr-digest,rfc9530,parser,header
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Topic :: Internet :: WWW/HTTP
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Dynamic: license-file

# digest-fields-parse

> Zero-dependency parser and serializer for the HTTP **`Content-Digest`**, **`Repr-Digest`**, **`Want-Content-Digest`**, and **`Want-Repr-Digest`** header fields defined in **RFC 9530** — for **Node.js** (≥18, ESM + CJS) and **Python** (≥3.11).

[![npm](https://img.shields.io/badge/npm-digest--fields--parse-CB3837)](https://www.npmjs.com/package/digest-fields-parse)
[![pypi](https://img.shields.io/pypi/v/digest-fields-parse)](https://pypi.org/project/digest-fields-parse/)
[![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
[![tests](https://img.shields.io/badge/tests-155%20passing-brightgreen)](./test)

---

## Why

HTTP integrity digests (RFC 9530) let endpoints communicate the integrity of HTTP message content via `Content-Digest`, `Repr-Digest`, `Want-Content-Digest`, and `Want-Repr-Digest` headers. The headers look like:

```
Content-Digest: sha-256=:pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4=:
Want-Content-Digest: sha-256;q=1, sha-512;q=0.5
```

Most developers today hand-roll this with `header.split(',')`, which silently mishandles multi-algorithm headers, base64 padding inside values, case sensitivity, q-value sorting, and malformed entries. `digest-fields-parse` handles all four header types correctly in **<300 LOC** per language, **zero runtime dependencies** in either package.

## Install

### Node.js

```bash
npm install digest-fields-parse
```

ESM:

```js
import {
  parseContentDigest,
  parseReprDigest,
  parseWantContentDigest,
  parseWantReprDigest,
  serializeContentDigest,
  serializeReprDigest,
  serializeWantContentDigest,
  serializeWantReprDigest,
} from 'digest-fields-parse';
```

CJS:

```js
const {
  parseContentDigest,
  parseReprDigest,
  parseWantContentDigest,
  parseWantReprDigest,
  serializeContentDigest,
  serializeReprDigest,
  serializeWantContentDigest,
  serializeWantReprDigest,
} = require('digest-fields-parse');
```

TypeScript definitions are bundled (`index.d.ts`).

### Python

```bash
pip install digest-fields-parse
```

```python
from digest_fields_parse import (
    parse_content_digest,
    parse_repr_digest,
    parse_want_content_digest,
    parse_want_repr_digest,
    serialize_content_digest,
    serialize_repr_digest,
    serialize_want_content_digest,
    serialize_want_repr_digest,
)
```

Module ships with `py.typed` (PEP 561) for full `mypy --strict` compatibility.

---

## Usage

### Parse `Content-Digest` / `Repr-Digest`

```js
// Node
parseContentDigest('sha-256=:pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4=:');
// → Map { 'sha-256' => 'pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4' }

parseContentDigest('sha-256=:abc=, sha-512=:xyz=');
// → Map { 'sha-256' => 'abc', 'sha-512' => 'xyz' }
```

```python
# Python
parse_content_digest('sha-256=:pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4=:')
# → OrderedDict([('sha-256', 'pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4')])
```

`parseReprDigest` is semantically identical — the field names are separate only because servers distinguish content vs. representation integrity.

### Parse `Want-Content-Digest` / `Want-Repr-Digest`

```js
parseWantContentDigest('sha-256, sha-512;q=0.5, sha-1;q=1');
// → [
//     { algorithm: 'sha-1',   q: 1.0, raw: 'sha-1;q=1' },
//     { algorithm: 'sha-256', q: 1.0, raw: 'sha-256' },
//     { algorithm: 'sha-512', q: 0.5, raw: 'sha-512;q=0.5' },
//   ]
//   (sorted by descending q; ties keep input order)
```

```python
parse_want_content_digest('sha-256, sha-512;q=0.5')
# → [
#     DigestPreference(algorithm='sha-256', q=1.0, raw='sha-256'),
#     DigestPreference(algorithm='sha-512', q=0.5, raw='sha-512;q=0.5'),
#   ]
```

### Serialise

```js
serializeContentDigest(new Map([['sha-256', 'pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4']]));
// → 'sha-256=:pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4:'

serializeWantContentDigest([
  { algorithm: 'sha-256', q: 1.0 },
  { algorithm: 'sha-512', q: 0.5 },
]);
// → 'sha-256;q=1, sha-512;q=0.5'
```

### Round-trip

```js
const m = parseContentDigest(wire);
const sameWire = serializeContentDigest(m);
parseContentDigest(sameWire);  // === m
```

---

## Semantics

| Header | Wire form | Returned type (Node) | Returned type (Python) |
| --- | --- | --- | --- |
| `Content-Digest` / `Repr-Digest` | `alg=:base64:,( alg=:base64:)*` | `Map<string, string>` | `OrderedDict[str, str]` |
| `Want-Content-Digest` / `Want-Repr-Digest` | `alg[;q=N.NNN][, …]` | `Array<{algorithm, q, raw}>` | `list[DigestPreference]` |

- **Base64 padding.** Trailing `=` padding inside the value is **stripped** (per RFC 9530 §2.2). Internal `=` characters (which are part of the base64 alphabet) are preserved.
- **Algorithm case.** Algorithm names are case-sensitive. `SHA-256` and `sha-256` are distinct keys.
- **Multi-algorithm.** A single header may contain multiple `alg=:base64:` pairs separated by commas. The parser uses insertion-order; the last duplicate wins (matches the spec example).
- **Empty header.** `Content-Digest:` (empty value) returns an empty Map / OrderedDict.
- **Malformed entries.** Garbage that lacks the `=:` delimiters is silently dropped, not raised — matches the typical HTTP-header parser philosophy (lenient on input, strict on output).
- **Want q sorting.** Preferences are sorted by descending `q`. Ties keep input order (stable sort). Default `q` is `1.0` when `;q=` is omitted.
- **Want q format.** Strict `[0, 1]` decimal matching `^(0(\.\d{1,3})?|1(\.0{1,3})?)$`. Malformed q-values fall back to `1.0` (no silent `parseFloat` consumption).
- **Want serializer canonical form.** `serializeWant*` always emits explicit `;q=N` (matches spec AC-16 wire form) so parse ∘ serialize is a stable round-trip.

### Error handling

| Call | Behaviour |
| --- | --- |
| `parse*(null)` | throws `TypeError` |
| `parse*(undefined)` | throws `TypeError` |
| `parse*(<non-string>)` | throws `TypeError` |
| `parse*(<empty string>)` | returns empty Map / `[]` |
| `parse*(<malformed entry>)` | silently skips bad entries, returns partial result |
| `serialize*(null)` | throws `TypeError` |
| `serialize*({})` | returns empty string (valid) |

---

## Tests

Both languages ship a deep-coverage test suite that exercises all 33 spec acceptance criteria plus regression cases for every parser/serializer edge case.

**155 tests** (73 Node.js + 82 Python)

|| Suite | Command | Test count |
|| --- | --- | --- |
|| Node | `npm test` | 73 tests |
|| Python | `pytest` | 82 tests |

Type-checking is part of the test contract:

```bash
# Node — bundled types
npx --yes tsc --noEmit index.d.ts

# Python — strict static typing
mypy --strict digest_fields_parse.py
```

---

## Non-goals

Explicitly out of scope:

- **No crypto.** This library parses and serialises the wire format only. It does NOT compute or verify digest hashes. Pair it with `crypto.createHash('sha256')` in Node or `hashlib.sha256()` in Python if you need to compute digests.
- **No RFC 3230.** The older `Digest` / `Want-Digest` headers (RFC 3230) are obsolete and use a different syntax. This package implements only the four RFC 9530 header types.
- **No RFC 9421.** HTTP Message Signatures are a separate standard with a different scope.
- **No `Content-MD5`.** The MD5-based header (RFC 1864) is deprecated and not part of RFC 9530.
- **No streaming.** Buffers must be in memory; no incremental parsing API.

---

## Known limitations / Cross-runtime parity

These are documented divergences and accepted trade-offs for v0.1.0. None of them affect correctly-encoded ASCII inputs (which is everything RFC 9530 §5 examples and §2.2 normative ABNF produce). They are confined to pathological inputs that RFC 9530 does not constrain.

### L-1 — Lenient on control characters inside opaque-tag / digest-value / algorithm-name

RFC 9530 §2.2 specifies `opaque-tag = ALPHA *( ALPHA / DIGIT / "-" )` and `algorithm-name = token` (RFC 9110), both of which exclude CTL bytes (`\x00`–`\x1F`, `\x7F`). v0.1.0's parser is lenient and preserves CTL bytes verbatim on round-trip, so an attacker who controls header input could pass a CRLF-containing value that, if a downstream consumer concatenates library output into an HTTP header context, could enable header injection.

**Mitigation in your code:** before serializing library output into a header, validate that the algorithm-name, opaque-tag, and digest-value do not contain CR (`\r`), LF (`\n`), or NUL (`\x00`). The library deliberately does NOT enforce this because it is not itself a header-writing sink — it is the consumer's responsibility to validate before serialization.

### L-2 — Cross-runtime: U+FEFF (`\uFEFF`, ZERO WIDTH NO-BREAK SPACE) handling differs

V8's `String.prototype.trim()` strips U+FEFF; CPython's `str.strip()` does not. This causes up to 138 documented parity divergences on fuzz inputs that mix U+FEFF with ASCII whitespace at token boundaries (opaque-tag positions, algorithm-name positions, and around `;q=` values).

**Impact:** zero on RFC 9530 §5 documented inputs. Documented in `VULN_AUDIT.md` (cycle_39/05 addendum) as findings F-04 and F-05 (both Low). The library is lenient on both runtimes; if you need strict parity, normalize U+FEFF in your input before parsing:

```js
// Node
parseContentDigest(input.replace(/\uFEFF/g, ''));
```

```python
# Python
parse_content_digest(input.replace('\uFEFF', ''))
```

### L-3 — RFC 9530 §2.2 conformance is "lenient-on-input, strict-on-output" only for ASCII

The parser silently skips malformed entries (entries lacking the `=:` delimiters, or with invalid q-values). This matches typical HTTP-header-parser philosophy and is intentional, but it means the parser does NOT raise on RFC 9530 §2.2 violations for CTL bytes (see L-1). If you need strict ABNF validation, run the output of `parse*` through your own validator before serializing.

---

## Competitive landscape

| Package | Why it doesn't fit |
| --- | --- |
| `@misskey-dev/node-http-message-signatures` | Scoped package; non-zero deps (WebCrypto); focus on signing (RFC 9421), not digest-field parsing |
| `@shujaapay/http-message-signatures` | Scoped; focus on RFC 9421 + GNAP signing; no dedicated `Content-Digest` parsing API |
| `http-digest` (npm) | Pre-RFC-9530; implements the obsolete RFC 3230 `Digest` header |
| Hand-rolled `header.split(',')` | Silently fails on multi-algorithm, base64 padding, q-values, mixed-case |
| (No PyPI equivalent) | No prior Python package exists for RFC 9530 digest fields |

---

## License

MIT. See [LICENSE](./LICENSE).
