feat(inbox): fail-fast on filenames over the 255-byte limit
The slug inherited the whole filename stem with no length bound, so a long dropped name produced a filename the filesystem cannot hold — surfacing as an untyped OSError at the write, with an errno that differs per platform (63 on macOS, 36 on Linux). The inbox contract promises a typed per-file outcome, so the gate belongs in the pure function that forms the name. Refusal, not truncation: truncating is lossy AND collision-prone — two long names sharing a prefix would reduce to one filename and the second write would silently claim the first file. Same posture as `inbox_slug_empty`: the library never invents a filename the operator did not give it. The message carries the actual size and the limit, since the fix is to rename the dropped file. Limit verified empirically on APFS 2026-07-25: a 255-byte component writes, a 258-byte one raises errno 63. ext4 and NTFS cap at the same 255. New MaterializationError code `inbox_slug_too_long`, registered in the errors docstring and in the error-code conformance suite.
This commit is contained in:
parent
6b9b21c602
commit
b7f5ce3800
4 changed files with 102 additions and 2 deletions
|
|
@ -98,6 +98,9 @@ class MaterializationError(IngestError):
|
|||
occupied by a file without the ingest stamp
|
||||
- `inbox_slug_empty` — a dropped file's name reduces to an empty slug
|
||||
under the id grammar (Door B; never an invented fallback name)
|
||||
- `inbox_slug_too_long` — the generated inbox filename would exceed the
|
||||
255-byte filesystem limit (Door B; never a truncated name, which would
|
||||
be lossy and could collide with another long name sharing its prefix)
|
||||
- `inbox_title_invalid` — an inbox title is multi-line or contains `[`/`]`,
|
||||
either of which would break frontmatter or an index link
|
||||
- `inbox_source_file_invalid` — an inbox `source_file` is multi-line and
|
||||
|
|
|
|||
|
|
@ -34,6 +34,16 @@ _RESERVED_OKF_TYPE = "verdict"
|
|||
# verbatim in the `title` field; the slug is an identifier, not a label.
|
||||
_SEPARATOR_RUN_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
# NAME_MAX: the per-component limit on every filesystem this library targets
|
||||
# (APFS, ext4, NTFS all cap at 255). Checked here rather than caught at the
|
||||
# write, because the OS signals it as an OSError whose errno differs per
|
||||
# platform (63 on macOS, 36 on Linux) — an untyped, unportable failure at the
|
||||
# very moment the caller needs a typed per-file outcome. Verified empirically
|
||||
# on APFS 2026-07-25: a 255-byte name writes, a 258-byte one raises errno 63.
|
||||
_MAX_FILENAME_BYTES = 255
|
||||
_FILENAME_PREFIX = "inbox-"
|
||||
_FILENAME_SUFFIX = ".md"
|
||||
|
||||
|
||||
def inbox_slug(source_filename: str) -> str:
|
||||
"""Reduce a dropped file's name to the Phase 1 id grammar.
|
||||
|
|
@ -66,8 +76,28 @@ def inbox_filename(slug: str) -> str:
|
|||
|
||||
The `inbox-` prefix keeps the namespace disjoint from `index.md`, Door A's
|
||||
`ingest-*`, and `promoted-verdict-*` for every slug the grammar admits.
|
||||
|
||||
A name the filesystem cannot hold fails fast rather than being truncated:
|
||||
truncation is lossy AND collision-prone (two long names sharing a prefix
|
||||
would reduce to one filename, and the second write would silently claim
|
||||
the first file). Refusing keeps the same posture as `inbox_slug_empty` —
|
||||
the library never invents a filename the operator did not give it. The
|
||||
operator's fix is to rename the dropped file, so the message carries both
|
||||
the actual size and the limit.
|
||||
"""
|
||||
return f"inbox-{slug}.md"
|
||||
name = f"{_FILENAME_PREFIX}{slug}{_FILENAME_SUFFIX}"
|
||||
# The slug is ASCII by construction (the id grammar admits nothing else),
|
||||
# so len() in characters and in bytes agree — encoding here anyway keeps
|
||||
# the check honest if the grammar is ever widened.
|
||||
size = len(name.encode("utf-8"))
|
||||
if size > _MAX_FILENAME_BYTES:
|
||||
raise MaterializationError(
|
||||
f"inbox filename for slug {slug!r} would be {size} bytes, over the "
|
||||
f"{_MAX_FILENAME_BYTES}-byte filesystem limit — rename the dropped "
|
||||
"file; refusing to truncate (lossy and collision-prone)",
|
||||
code="inbox_slug_too_long",
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def _normalize_body(text: str) -> str:
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ from llm_ingestion_okf.errors import (
|
|||
SourceError,
|
||||
)
|
||||
from llm_ingestion_okf.extract import extract_text
|
||||
from llm_ingestion_okf.inbox import inbox_slug, render_inbox_concept
|
||||
from llm_ingestion_okf.inbox import inbox_filename, inbox_slug, render_inbox_concept
|
||||
from llm_ingestion_okf.manifest import load_manifest, load_manifest_bytes
|
||||
from llm_ingestion_okf.materialize import materialize_bundle
|
||||
from llm_ingestion_okf.render import sql_value_to_text
|
||||
|
|
@ -363,6 +363,12 @@ def test_inbox_slug_empty() -> None:
|
|||
assert code_of(excinfo) == "inbox_slug_empty"
|
||||
|
||||
|
||||
def test_inbox_slug_too_long() -> None:
|
||||
with pytest.raises(MaterializationError) as excinfo:
|
||||
inbox_filename("a" * 247)
|
||||
assert code_of(excinfo) == "inbox_slug_too_long"
|
||||
|
||||
|
||||
def test_inbox_title_invalid() -> None:
|
||||
with pytest.raises(MaterializationError) as excinfo:
|
||||
inbox_concept(title="broken [link]")
|
||||
|
|
|
|||
|
|
@ -140,6 +140,67 @@ def test_inbox_namespace_is_disjoint_from_the_reserved_names(name: str) -> None:
|
|||
assert generated.startswith("inbox-")
|
||||
|
||||
|
||||
# --- filename length: the one gate the filesystem enforces for us otherwise ---
|
||||
|
||||
# NAME_MAX restated independently of the module constant, for the same reason
|
||||
# ID_GRAMMAR is: a change to the library's value must not silently move the
|
||||
# boundary this suite pins. Verified empirically on APFS 2026-07-25 (a 255-byte
|
||||
# component writes, a 258-byte one raises errno 63 ENAMETOOLONG); ext4 and NTFS
|
||||
# use the same 255 limit, and the slug is ASCII-only so bytes == characters.
|
||||
NAME_MAX = 255
|
||||
# `inbox-` (6) + `.md` (3) is the namespace's fixed overhead.
|
||||
LONGEST_ADMISSIBLE_SLUG = NAME_MAX - len("inbox-") - len(".md")
|
||||
|
||||
|
||||
def test_longest_admissible_slug_is_246_characters() -> None:
|
||||
# Pins the arithmetic itself: if the namespace prefix or suffix ever
|
||||
# changes, this fails before the length tests below start lying.
|
||||
assert LONGEST_ADMISSIBLE_SLUG == 246
|
||||
|
||||
|
||||
def test_inbox_filename_at_the_length_limit_is_accepted() -> None:
|
||||
slug = "a" * LONGEST_ADMISSIBLE_SLUG
|
||||
generated = inbox_filename(slug)
|
||||
assert len(generated.encode("utf-8")) == NAME_MAX
|
||||
|
||||
|
||||
def test_inbox_filename_over_the_length_limit_is_rejected() -> None:
|
||||
"""A name the filesystem cannot hold is a typed per-file failure, not an
|
||||
untyped OSError from the write and not a truncation. Truncating would be
|
||||
lossy AND collision-prone: two long names sharing a prefix would reduce to
|
||||
one filename, and the second write would silently claim the first's file.
|
||||
Refusing is the same posture as `inbox_slug_empty` — the library never
|
||||
invents a filename the operator did not give it.
|
||||
"""
|
||||
slug = "a" * (LONGEST_ADMISSIBLE_SLUG + 1)
|
||||
with pytest.raises(MaterializationError) as excinfo:
|
||||
inbox_filename(slug)
|
||||
assert excinfo.value.code == "inbox_slug_too_long"
|
||||
|
||||
|
||||
def test_length_rejection_names_the_limit_and_the_actual_size() -> None:
|
||||
# The operator's fix is to rename the dropped file, so the message has to
|
||||
# say by how much — a bare "too long" leaves them guessing.
|
||||
slug = "a" * 300
|
||||
with pytest.raises(MaterializationError) as excinfo:
|
||||
inbox_filename(slug)
|
||||
message = str(excinfo.value)
|
||||
assert str(NAME_MAX) in message
|
||||
# 6 + 300 + 3 — the size the name WOULD have had.
|
||||
assert "309" in message
|
||||
|
||||
|
||||
def test_a_long_dropped_filename_fails_at_the_filename_not_at_the_write() -> None:
|
||||
"""End-to-end on the pure functions: a 300-character dropped name reduces
|
||||
to a 300-character slug, and the gate catches it before any I/O is
|
||||
attempted — errno differs across platforms (63 on macOS, 36 on Linux), so
|
||||
catching OSError by number was never a portable option.
|
||||
"""
|
||||
with pytest.raises(MaterializationError) as excinfo:
|
||||
inbox_filename(inbox_slug("x" * 300 + ".md"))
|
||||
assert excinfo.value.code == "inbox_slug_too_long"
|
||||
|
||||
|
||||
# --- provenance rendering ---
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue