Skip to content

fix(static): finish gitignore's rule for hide patterns - #310

Merged
EdmondDantes merged 2 commits into
mainfrom
static-hide-gitignore-forms
Aug 26, 2026
Merged

EdmondDantes merged 2 commits into
mainfrom
static-hide-gitignore-forms

Conversation

@EdmondDantes

Copy link
Copy Markdown
Contributor

Closes #309.

hide('/index.php'), hide('cache/') and hide('logs/**') are the three shapes
an operator carries over from gitignore after #270 taught the other two. The
first two covered no path at all; the third stopped one level down. Each was
accepted without complaint, and each ended with the file on the wire.

  • A leading separator put the pattern in the anchored branch, which compares
    it against a mount-relative path that never carries a leading /. It is
    stripped now, and the pattern pins the mount root — /index.php hides the
    root's own file and leaves sub/index.php alone.
  • A trailing separator was compared literally against paths that are never
    bare directories. It names a directory now: that directory wherever it sits,
    and everything under it at any depth.
  • ** met FNM_PATHNAME, which stops each * at the separator. A pattern
    containing ** turns that flag off for its whole length.

A bare pattern still reads the file name, so a directory named like the pattern
keeps serving what it holds — cache/ is how to say otherwise. That half is a
deliberate decision with a unit row behind it and is untouched.

The platform split underneath

src/static/http_static_path.c carries an fnmatch shim for Windows whose
flags parameter was (void)-cast away, so * stopped at a separator there
whatever the caller asked. Turning FNM_PATHNAME off therefore crossed
directories on POSIX and did nothing on Windows: the ** half of this change
passed its POSIX build and failed on mine until the shim was taught to read the
flag. Worth knowing for anything else that reaches for those flags.

hide() refuses an overlong pattern

The matcher reads at most HTTP_STATIC_HIDE_GLOB_MAX (512) bytes. A longer
pattern could only ever match nothing, which is the answer an operator reads as
"hidden", so hide() throws HttpServerInvalidArgumentException where the
pattern is written rather than leaving it to be silently inert.

Evidence

static/024-static-hide-gitignore-forms drives a live mount at / with all
three patterns. Against dbe66f0 it prints

/index.php          -> root-secret
/var/cache/x.txt    -> cached
/logs/deep/app.log  -> logged

— three files the operator meant to hide, served — and here it hands all three
to the handler while /sub/index.php and /app.svg still come off disk. The
StaticHide unit table gains a group per shape, including the anchored
directory pattern (var/cache/ covers var/cache/x.txt, not
app/var/cache/x.txt) and the negative rows (cache/ covers neither
var/cached/x.txt nor cache.txt).

Whole local suite, openssl on PATH so the TLS group executes: 497 tests, 260
executed, 0 failed, 0 warned
.

Backward compatibility

The rule only widens what is hidden — a pattern that covered nothing now covers
what it names. Nothing previously hidden becomes visible. The one behaviour
change an existing configuration could notice: a mount that wrote logs/**
meaning "one level" now hides the subtree, which is what the pattern says.

A leading separator anchored the pattern and then compared it against a
mount-relative path that never carries one; a trailing separator was
compared literally against paths that are never bare directories; ** met
FNM_PATHNAME and stopped one level down. Each accepted the pattern
without complaint and served the file.

Underneath the third: the Windows fnmatch shim cast its flags away, so
turning FNM_PATHNAME off crossed directories on POSIX and did nothing on
Windows. The shim reads the flag.

hide() refuses a pattern longer than the matcher reads, rather than
accepting one that can only match nothing.
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Coverage

Total lines: 83.16% → 83.18% (+0.03 pp)

File Baseline Current Δ Touched
src/http1/http_parser.c 84.05% 84.43% +0.38 pp
src/http3/http3_callbacks.c 84.07% 84.39% +0.32 pp
src/http_server_class.c 76.04% 76.01% -0.04 pp
src/http_server_config.c 92.58% 92.52% -0.06 pp
src/static/http_static_path.c 98.98% 99.17% +0.19 pp
src/static/static_handler_class.c 97.07% 97.09% +0.02 pp
src/websocket/ws_session.c 89.93% 89.74% -0.18 pp

… shim

A pattern opening with a double star and a separator names every
directory, and the mount root is one of them - but there the separator
has nothing to match, so hide("**/secret.txt") served the root copy
while covering every nested one. That is the disclosure direction.

Crossing separators widened what the Windows shim backtracks over, from
one path segment to the whole path. Collapsing a run of stars keeps the
form this rule now encourages from paying for each star twice over.

The contract says which measure the length limit uses and that case
follows the platform filesystem, neither of which the reader could tell
from the signature.
@EdmondDantes

Copy link
Copy Markdown
Contributor Author

Review follow-up

A Fable Critic worked the matcher by hand against adversarial inputs. Two findings, both fixed here.

A pattern opening with a double star did not reach the mount root. hide('**/secret.txt') covered a/b/secret.txt and served the root's own secret.txt: with FNM_PATHNAME off the stars cross separators, but the literal / after them still needs a / in the path, and a root-level file has none. gitignore defines a leading double star as every directory including the top one, so this under-hid — the direction that discloses. The anchored branch retries without the prefix; static/024 now asks for /secret.txt and /sub/secret.txt, and the unit table asserts both plus the near-miss secret.txt.bak.

Crossing separators widened what the Windows shim backtracks over. It is recursive, and each * used to be bounded to one path segment; with the flag off it now walks the whole path. Runs of stars are collapsed, which is the case this rule encourages — logs/** is in the documentation and builds logs/**/* as an under-variant. An operator pattern of the a*a*a*b/ shape still has a backtracking cliff on Windows that POSIX fnmatch does not; that is a hardening note, not an exploit, since the pattern is written by the operator rather than the caller.

The Critic also proved the buffer bounds hold for every reachable input, including a 512-byte pattern in the */%s/* variant, which lands on exactly 517 of 517; traced /, //, a//b, ./.. remnants, pattern-equals-path and a bare *; confirmed static/024 fails against the parent commit for the three reasons the CHANGELOG states; and confirmed the arginfo did not need regenerating, since only the docblock changed.

Two things it raised that stay as they are, named rather than fixed:

  • hide() measures the pattern as written, the matcher measures it with a leading or trailing separator off. So '/' plus 512 bytes is refused although the matcher could read its body. Over-rejection only; the header says which measure is which.
  • Case sensitivity differs by platform — the Windows shim folds case to match NTFS, POSIX fnmatch does not. Pre-existing and correct per platform; the contract and the PHPDoc say so now instead of leaving it to be discovered.

Suite after the rework: 497 tests, 260 executed, 0 failed. One warning, core/018-log-off-no-overhead, a perf gate that passed on retry.

@EdmondDantes
EdmondDantes merged commit 57da6ac into main Aug 26, 2026
8 checks passed
@EdmondDantes
EdmondDantes deleted the static-hide-gitignore-forms branch August 26, 2026 10:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hide(): a leading /, a trailing /, and ** each match nothing, and the Windows shim ignores its flags

1 participant