Skip to content

fix(cli): make --model and --feature option strings escape-aware (ADR-1180) - #1310

Closed
lusoris wants to merge 5 commits into
masterfrom
fix/cli-option-string-escapes
Closed

fix(cli): make --model and --feature option strings escape-aware (ADR-1180)#1310
lusoris wants to merge 5 commits into
masterfrom
fix/cli-option-string-escapes

Conversation

@lusoris

@lusoris lusoris commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Make --model and --feature option-string parsing escape-aware so paths containing =, :, ., or Windows drive letters can be passed without premature delimiter splitting (Netflix/vmaf#766, ADR-1180). Replaced raw strsep / vmaf_cli_strsep calls in core/tools/cli_parse.cpp with vmaf_cli_split which scans in place and compacts \:, \=, \., and \\. Added a Windows drive-letter affordance in parse_model_config preserving [A-Za-z]:\ and [A-Za-z]:/ prefixes in path= verbatim without requiring manual escaping. Updated Go surfaces (pkg/libvmaf, pkg/corpus) and in-tree FFmpeg tune filter patch (ffmpeg-patches/0008-add-libvmaf_tune-filter.patch) to escape and unescape delimiters consistently. Verified via incremental build and 30/30 core/build/test/test_cli_parse unit tests, 115/115 meson test --suite=fast (excluding GPU), 271 passed / 12 skipped / 0 failed Netflix CPU golden tests, and Go unit tests in pkg/libvmaf and pkg/corpus. Note: this branch was stacked on PR #1287 (feat/vmafx-cli-alias), which merged to master on 2026-09-05 (b14a718); the branch is now rebased onto master (c2d22cea5, 2026-09-06). #1287's three follow-up fixes (the %%.6f usage-text escape, the argv null-check removal in cli_parse(), and the test_cli_parse.c tidy pass) are kept verbatim, and the ADR-1180 tests were split into six focused functions with cited NULL returns so the file stays at its clang-tidy baseline (24 warnings, all pre-existing).

Type

Checklist

  • Commits follow Conventional Commits.
  • make format && make lint is green locally (pre-commit on every touched file).
  • Unit tests: 30/30 build-cpu/test/test_cli_parse, 114/114 meson test -C build-cpu --suite=fast (0 fail), go test ./pkg/corpus/... ok. go test ./pkg/libvmaf/... cannot link on this workstation (stale local core/build-cpu missing vmaf_dnn_*) — reproduced identically on unmodified origin/master, so it is not this PR's regression; TestEscapeOptValue's 9-case table was verified out-of-tree against the shipped escapeOptValue body.
  • Docs in the same PR: docs/usage/cli.md, docs/adr/1180-cli-option-string-escapes.md.
  • SIMD/GPU, twins, new C sources, breaking change, ADR — ADR-1180 added; no SIMD/GPU or breaking changes; default parsing is byte-identical.

Bug-status hygiene (ADR-0165)

  • docs/state.md — moved T-UPSTREAM-766-CLI-OPTION-STRING-DELIMITERS-2026-09-03 to Recently closed, citing PR #1310 (fix/cli-option-string-escapes).

Netflix golden-data gate (ADR-0024)

  • I did not modify any assertAlmostEqual(...) score in the Netflix golden Python tests.

Deep-dive deliverables (ADR-0108)

  • Research digest — no digest needed: CLI option parsing fix covered directly by ADR-1180
  • Decision matrix — docs/adr/1180-cli-option-string-escapes.md § Alternatives considered
  • AGENTS.md invariant note — core/tools/AGENTS.md § ADR-1180
  • Reproducer / smoke-test command — below.
  • CHANGELOG fragmentchangelog.d/fixed/cli-option-string-escapes.md
  • Rebase notedocs/rebase-notes.md entry

Reproducer

core/build/test/test_cli_parse
# Expected output (tail):
# test_cli_split_escapes_direct_equals_and_backslash: pass
# test_cli_split_escapes_direct_colon_and_dot: pass
# test_cli_parse_model_escapes_delimiters: pass
# test_cli_parse_model_escapes_windows_drive: pass
# test_cli_parse_model_options_regression: pass
# test_cli_parse_feature_regression_and_escapes: pass
# 30 tests run, 30 passed

🤖 Generated with Claude Code

@lusoris lusoris added this to the 1.0.0 — First release milestone Sep 5, 2026
@lusoris
lusoris force-pushed the fix/cli-option-string-escapes branch 6 times, most recently from 612aa7b to 732ee54 Compare September 6, 2026 02:59
Lusoris added 5 commits September 6, 2026 08:37
…c at its tidy baseline

Extract the in-place `\=` compaction from parse_model_config() into
unescape_equals_in_place() and make the drained `rest` token const.
cli_parse() stays exactly as #1287 left it (no argv/settings null-guard:
the analyzer misread that guard as evidence argv can be null, 16828c3).

Split the ADR-1180 tests into six focused functions so each stays under
the readability-function-size thresholds, and cite every `return NULL;`
with NOLINTNEXTLINE(modernize-use-nullptr) per ADR-1138, matching the
tidy pass #1287 applied to the rest of the file. clang-tidy measured
against origin/feat/vmafx-cli-alias: core/tools/cli_parse.cpp 0 -> 0,
core/test/test_cli_parse.c 24 -> 24 (all pre-existing).
@lusoris
lusoris force-pushed the fix/cli-option-string-escapes branch from 732ee54 to ee7e13e Compare September 6, 2026 06:37
lusoris pushed a commit that referenced this pull request Sep 6, 2026
The seven `cli_parse_corpus/` seeds all predate the escape-aware splitter, so
the fuzzer had no starting point that exercises `cli_split()` /
`cli_unescape()`. Add one NUL-separated argv seed covering, in a single input,
the three constructs the new grammar introduces: an unescaped inner `=` in a
value (`path=/dir=eq/...`, the silent-truncation case), a backslash-escaped
delimiter (`m\:colon.json`), and a Windows drive-letter colon inside a
`--feature` option value (`some_path=C:\x`).

Force-added, like the seven seeds already tracked beside it: the
`core/test/fuzz/*_corpus/` ignore rule exists to keep fuzzer-generated corpus
growth out of the tree, not the checked-in seeds.

Ported from PR #1310, which proposed the same idea but wrote the seed as one
space-joined line with a trailing newline. `tokenise_argv()` in
core/test/fuzz/fuzz_cli_parse.c splits on NUL, so that form collapses to a
single argv token and never reaches the option-string parsers at all; this
seed uses the NUL separation the harness and the other seeds use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lusoris

lusoris commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favour of #1336 (fix/t-upstream-766-cli-option-string-delimit, ADR-1190), which fixes the same bug — Netflix/vmaf#766, ledger row T-UPSTREAM-766-CLI-OPTION-STRING-DELIMITERS-2026-09-03 — and covers strictly more of it. The branch is not deleted.

Both binaries were built from their own branch tips (CPU-only, -Db_lto=false) and probed side by side:

Case this PR (ee7e13e3b) #1336 (f87f190ba)
-m 'path=/a/dir=eq/m.json' could not read model from path: "/a/dir"still silently truncated … "/a/dir=eq/m.json"
--feature 'psnr=some_path=C:\x' Problem parsing feature "psnr", bad option string "\x"still rejected unknown option 'some_path' (the value reached the extractor whole)
-m 'path=C:\models\vmaf_v0.6.1.json' ok ok
-m 'path=/a/dir\:colon/m.json' ok ok

Rows 1 and 2 are the two failures this row's own "Closes when" clause names, and they are the ones docs/state.md calls the worst failure mode ("a phantom path the user never typed"). Two causes, both structural:

  1. parse_model_config() / parse_feature_config() still take the value with a second split — char *val = vmaf_cli_split(&key_val, '=') — so everything after a second unescaped = is dropped on the floor. fix(cli): split --model/--feature option strings escape-aware (ADR-1190) #1336 makes the value the whole remainder after the first unescaped =.
  2. The drive-letter affordance lives inside parse_model_config() behind !strcmp(key, "path"), so --feature and every non-path key never see it. fix(cli): split --model/--feature option strings escape-aware (ADR-1190) #1336 puts the rule in the splitter (cli_is_drive_colon()), so it holds at all nine sites.

This PR's 30/30 green suite does not contradict that: the drive-letter feature case is asserted against vmaf_cli_split() in isolation, not through cli_parse(), and the unescaped-inner-= case is not asserted at all.

Smaller deltas, same direction: this PR leaves vmaf_cli_strsep plus the #ifndef HAVE_STRSEP fork in the tree as [[maybe_unused]] dead code and keeps the cc.has_function('strsep') probe in core/tools/meson.build (#1336 deletes both); it exports vmaf_cli_split from cli_parse.h purely so the test can reach it; and it duplicates escapeOptValue byte-for-byte in pkg/libvmaf and pkg/corpus (#1336 factors it into pkg/cliopt.EscapeValue with a round-trip test that pins the Go escaper to the C grammar). Its Go escaper also disagrees with its own C splitter on \\: cli_split unescapes \\ on every pass, so foo\\=barEscapeValue → parser round-trips to foo\=bar, losing a backslash — the two-pass hazard #1336's cli_split/cli_unescape split exists to avoid.

Nothing here is lost. The one salvageable unique artifact, core/test/fuzz/cli_parse_corpus/seed_escapes.argv, is ported into #1336 as commit f87f190ba, rewritten NUL-separated: tokenise_argv() in core/test/fuzz/fuzz_cli_parse.c splits on NUL, so the space-joined, newline-terminated form here collapses to one argv token and never reaches the option-string parsers.

The other unique change, ffmpeg-patches/0008-add-libvmaf_tune-filter.patch, was deliberately not ported, because it is broken: the new-file hunk header still reads @@ -0,0 +1,427 @@ while the body now carries 429 + lines. Applying it writes a 427-line vf_libvmaf_tune.c whose last two lines — FILTER_QUERY_FUNC2(query_formats); and the closing }; of ff_vf_libvmaf_tune — are gone, so the filter cannot compile:

$ git apply <tune hunk from ee7e13e3b>   &&  wc -l libavfilter/vf_libvmaf_tune.c
427                     # origin/master's copy of the same patch yields 410/410, header and body in agreement
$ tail -1 libavfilter/vf_libvmaf_tune.c
    FILTER_OUTPUTS(libvmaf_tune_outputs),

Its intent is wrong for that layer too: load_model() already takes the whole remainder after the first = and never splits on :, so it was never affected by #766, and unescaping \\ there would turn a UNC \\server\share path into \server\share. #1336 records that reasoning in docs/rebase-notes.md and in its PR body.

#1336 is rebased onto current master, keeps the single T-UPSTREAM-766… row in docs/state.md, and is green locally: 32/32 test_cli_parse, 114/114 meson test --suite=fast, go test ./pkg/cliopt/, plus scripts/ci/check-state-md-rows.sh, assertion-density.sh, check-copyright.sh and ffmpeg-patches-surface-check.sh. ADR-1180 in this PR is superseded by ADR-1190 there.

@lusoris lusoris closed this Sep 6, 2026
lusoris pushed a commit that referenced this pull request Sep 6, 2026
The seven `cli_parse_corpus/` seeds all predate the escape-aware splitter, so
the fuzzer had no starting point that exercises `cli_split()` /
`cli_unescape()`. Add one NUL-separated argv seed covering, in a single input,
the three constructs the new grammar introduces: an unescaped inner `=` in a
value (`path=/dir=eq/...`, the silent-truncation case), a backslash-escaped
delimiter (`m\:colon.json`), and a Windows drive-letter colon inside a
`--feature` option value (`some_path=C:\x`).

Force-added, like the seven seeds already tracked beside it: the
`core/test/fuzz/*_corpus/` ignore rule exists to keep fuzzer-generated corpus
growth out of the tree, not the checked-in seeds.

Ported from PR #1310, which proposed the same idea but wrote the seed as one
space-joined line with a trailing newline. `tokenise_argv()` in
core/test/fuzz/fuzz_cli_parse.c splits on NUL, so that form collapses to a
single argv token and never reaches the option-string parsers at all; this
seed uses the NUL separation the harness and the other seeds use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lusoris pushed a commit that referenced this pull request Sep 6, 2026
The seven `cli_parse_corpus/` seeds all predate the escape-aware splitter, so
the fuzzer had no starting point that exercises `cli_split()` /
`cli_unescape()`. Add one NUL-separated argv seed covering, in a single input,
the three constructs the new grammar introduces: an unescaped inner `=` in a
value (`path=/dir=eq/...`, the silent-truncation case), a backslash-escaped
delimiter (`m\:colon.json`), and a Windows drive-letter colon inside a
`--feature` option value (`some_path=C:\x`).

Force-added, like the seven seeds already tracked beside it: the
`core/test/fuzz/*_corpus/` ignore rule exists to keep fuzzer-generated corpus
growth out of the tree, not the checked-in seeds.

Ported from PR #1310, which proposed the same idea but wrote the seed as one
space-joined line with a trailing newline. `tokenise_argv()` in
core/test/fuzz/fuzz_cli_parse.c splits on NUL, so that form collapses to a
single argv token and never reaches the option-string parsers at all; this
seed uses the NUL separation the harness and the other seeds use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lusoris pushed a commit that referenced this pull request Sep 6, 2026
The seven `cli_parse_corpus/` seeds all predate the escape-aware splitter, so
the fuzzer had no starting point that exercises `cli_split()` /
`cli_unescape()`. Add one NUL-separated argv seed covering, in a single input,
the three constructs the new grammar introduces: an unescaped inner `=` in a
value (`path=/dir=eq/...`, the silent-truncation case), a backslash-escaped
delimiter (`m\:colon.json`), and a Windows drive-letter colon inside a
`--feature` option value (`some_path=C:\x`).

Force-added, like the seven seeds already tracked beside it: the
`core/test/fuzz/*_corpus/` ignore rule exists to keep fuzzer-generated corpus
growth out of the tree, not the checked-in seeds.

Ported from PR #1310, which proposed the same idea but wrote the seed as one
space-joined line with a trailing newline. `tokenise_argv()` in
core/test/fuzz/fuzz_cli_parse.c splits on NUL, so that form collapses to a
single argv token and never reaches the option-string parsers at all; this
seed uses the NUL separation the harness and the other seeds use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lusoris pushed a commit that referenced this pull request Sep 6, 2026
The seven `cli_parse_corpus/` seeds all predate the escape-aware splitter, so
the fuzzer had no starting point that exercises `cli_split()` /
`cli_unescape()`. Add one NUL-separated argv seed covering, in a single input,
the three constructs the new grammar introduces: an unescaped inner `=` in a
value (`path=/dir=eq/...`, the silent-truncation case), a backslash-escaped
delimiter (`m\:colon.json`), and a Windows drive-letter colon inside a
`--feature` option value (`some_path=C:\x`).

Force-added, like the seven seeds already tracked beside it: the
`core/test/fuzz/*_corpus/` ignore rule exists to keep fuzzer-generated corpus
growth out of the tree, not the checked-in seeds.

Ported from PR #1310, which proposed the same idea but wrote the seed as one
space-joined line with a trailing newline. `tokenise_argv()` in
core/test/fuzz/fuzz_cli_parse.c splits on NUL, so that form collapses to a
single argv token and never reaches the option-string parsers at all; this
seed uses the NUL separation the harness and the other seeds use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant