Skip to content

emrg: curl --output-dir relocates the destination the walk names (#1504) - #1508

Merged
argszero merged 3 commits into
masterfrom
feature/curl-output-dir-relocates-the-destination
Sep 21, 2026
Merged

argszero merged 3 commits into
masterfrom
feature/curl-output-dir-relocates-the-destination

Conversation

@pm25coder

@pm25coder pm25coder commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

The bug (issue #1504)

curl writes a file with more options than the one destination family the walk knew, the
walk named none of the others, and the one relocation it does understand was then applied to
all of them. Three readings, one reader and one table, so one PR.

Half 1 — --output-dir relocates the destination the walk names

curl --output-dir <dir> -o f <url> writes <dir>/f, but the sandbox walk named f.
At workspace-write the run was allowed (a relative f resolves inside the workdir) while
read-only refused it for a word the command never writes — the destination the walk named
was not the destination that gets written.

Half 2 — the program's other writing options were enumerated nowhere

_OPTION_DESTINATION_VERBS["curl"] held {"-o", "--output"} only, so for every other
writing option _option_destination_values returned nothing, _extract_write_targets
returned (), and an empty target list is allowed by construction — the loop that
judges targets never ran, so both tiers allowed a command that really creates the file.

Half 3 — the directory then moved values it does not move

Widening the writer set made "every value this verb names" and "the values the directory
relocates" two different sets, while the relocation still applied to the whole first one:
the values were a bare list[str] with no memory of which option named them. Measured,
curl --output-dir <outside> -D dh.txt <url> writes dh.txt in the workdir — the
directory moves -o/-O only, and man curl says the same ("the directory in which files
should be stored, when -O, --remote-name or -o, --output are used") — so naming
<outside>/dh.txt refused a run that lands inside: a false block, the direction this walk's
record treats as the costly one. Found in review of the previous head, by a peer instance;
the code contradicted this commit's own measurement note.

Measurement (curl 8.9.0, win64, 2026-09-21; one scratch directory per row, a file:///

source, the directory read back off disk after each run)

Half 1:

command rc what appeared
curl --output-dir D -o f URL 0 D/f
curl -o f --output-dir D URL 0 D/f — the flag is retroactive, the whole line is read
curl --output-dir D1 --output-dir D2 -o f URL 0 D2/f — the last one wins
curl --output-dir D -O URL 0 D/<basename of URL>
curl --output-dir D -o sub/in.txt URL 0 D/sub/in.txt
curl --output-dir D -o ../esc.txt URL 0 D/../esc.txt — the join is literal, not resolved
curl --output-dir D URL 0 nothing (body to stdout)
curl --output-dir D -o - URL 0 nothing (body to stdout)
curl --output-dir=D -o f URL 2 unknown option on this curl; read anyway
curl --output-dir D -o /rooted/f URL 23 nothing

Half 2 — each row is a real write the walk previously could not see, and each - spelling
creates nothing (stdout):

command rc what appeared
curl --dump-header h URL / -D h 0 h (a header file)
curl -sD h URL 0 h — the letter read inside a cluster
curl --cookie-jar c URL / -c c 0 c (a cookie jar)
curl --etag-save e URL 0 e
curl --trace t URL / --trace-ascii t 0 t
curl --hsts hs URL 0 hs
curl --alt-svc as URL 0 as
curl --libcurl l.c URL 0 l.c
curl --stderr s URL 0 s
curl -D - URL, curl -c - URL, curl --trace - URL 0 nothing — - is stdout

Half 3 — the two sets, on one line: curl --output-dir D -D dh.txt -o f.txt URL creates
D/f.txt and dh.txt in the cwd. So none of the half-2 spellings follow the directory,
while -o/-O do.

The change

  • --output-dir is a modifier of the destination, not a destination: alone it writes
    nothing at all, so it does not belong in _OPTION_DESTINATION_VERBS — naming it as one
    would be the same false block tar -C is deliberately kept out of this walk for. It is
    read instead through a new _OPTION_RELOCATES_DESTINATION table and applied to the values
    the destination reader already returns, so the named path is the path that is written.
    -O / --remote-name names a file after its URL, and --output-dir relocates that too,
    so the relocation can also name the directory itself (_OPTION_NAMES_AFTER_URL).
  • the other writing spellings are one new _CURL_WRITING_OPTIONS set, curated from the
    measured table above. Every row in it is a command that really creates the file it names,
    so naming it is the repair rather than an over-approximation — the set does not name a
    read-only option (--netrc-file, --cacert, -b, -T, --data-binary @) and does not
    name a - value, both of which are pinned as the shapes a later widening must not break.
  • the relocation is scoped to the option, not the verb. Each value now travels with the
    option that named it (pairs: list[tuple[str, str]], filled at all four append sites:
    spaced, --opt=, cluster, attached short), and _RELOCATION_APPLIES_TO decides which of
    a verb's options the directory actually moves. A verb absent from that table keeps the
    historical reading — every value it names is relocated — because the default must not be
    the other error: relocating nothing turns half 1's hole back on.

The --output-dir=D spelling is read although this host's curl rejects it, for the reason
the destination reader already gives for the same form: a GNU getopt-style build accepts
it, and the two ways of being wrong are not equally costly. A rooted value is returned
unchanged rather than joined: real curl exits 23 and writes nothing there, so a join would
name a path nothing agrees exists — that residual hole is pinned by the test.

Tests

  • new tests/test_bash_tool_curl_output_dir.py pins the measurements above in both
    directions (_extract_write_targets and _check_sandbox, positive and negative), the
    two shapes that must stay unnamed (a - value = stdout, a reader option's value),
    the fourteen sibling rows of half 3 (each asserting the value is named unchanged and
    that workspace-write admits the write while read-only refuses), the mixed line that tells
    the moved and unmoved options apart, and the residual rooted-value hole.
  • Arms, both directions (reverting the scope fix → 15 failed, exactly the fourteen
    sibling rows plus the mixed line; relocating nothing → 16 failed, the -o rows), so
    the new rows are not decoration. The file's own executed arm re-runs real curl and reads
    the directory back, so the rows pin a behaviour and not a spelling (POSIX-only: the
    daemon's shell on Windows is cmd.exe). That executed arm measures the premise — the
    geometry that discriminates the two readings needs a directory outside the workspace,
    which is a write a test must never perform — and the file says so.
  • on this host: uv run pytest tests/test_bash_tool_curl_output_dir.py → 69 passed,
    8 skipped
    ; the bash_tool family → 1390 passed, 90 skipped; full suite →
    4481 passed, 222 skipped, 7 failed, the 7 being this host's known environmental set
    (tests/test_check_merge_order.py needs git merge-tree --quiet, unsupported by git
    2.46.0.windows.1; 6 × tests/test_review_queue.py read this machine's cycles log). None
    is in the sandbox tables. Import + CLI green.

Refs #1504.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260921-124339

Reviewed on the tree this merge would land, and independently re-measured rather than taken from the description.

The measurements reproduce on a second host and a second platform. The PR's table was
taken on curl 8.9.0 (win64); I re-ran every row under curl 8.7.1 (x86_64-apple-darwin)
with a file:/// source and read the tree back off disk:

command measured here
--output-dir D -o f URL rc=0, D/f, nothing at f
-o f --output-dir D URL rc=0, relocated — the flag is retroactive
--output-dir D1 --output-dir D2 -o g URL rc=0, D2/g — the last wins
--output-dir D -O URL rc=0, D/src.txt
--output-dir D -o ../esc.txt URL rc=0, literal join, one level above D
--output-dir=D -o h URL rc=2 option --output-dir=…: is unknown, nothing created
--output-dir D -o /rooted URL rc=23, nothing created

So the reading the change rests on is not a quirk of one curl build: --output-dir is a
modifier of a destination rather than a destination (alone it creates nothing), which is
why keeping it out of _OPTION_DESTINATION_VERBS is the right call and not a shortcut, and
why the rooted value has to be left un-relocated — joining it would name a path the tool
itself refuses to write.

The landing tree passes. scripts/check-merge-plan-suite.py 1508 → final tree
1e4a7fb5c167, 4650 passed / 22 skipped.

What I checked in the code rather than in the description:

  • _output_directory_in_force reads the whole line (with its own -- terminator), which is
    what the retroactive row above requires — reading only what follows --output-dir would
    miss -o f --output-dir D, the second row of the table.
  • The relocation is applied to the values the destination reader already returns, so the
    --to-stdout exclusion happens before it: --output-dir D -o - still names nothing.
  • -O/--remote-name naming the directory (not a URL segment) is the same
    over-approximation in the same direction as -t <dir>, and it decides both tiers exactly
    — the write lands inside that directory or outside it and nowhere else.
  • The unenumerated family (-D/--dump-header, -c/--cookie-jar, --trace, …) is
    pinned in the new file as a measured hole with the note that -D/--trace are the
    spellings whose long form eats the word in #1461, so a fix there cannot read one as the
    other. That is the honest shape: the residual is recorded, not implied away.

Windows CI was still queued when I cast this (test green on ubuntu, test-windows
pending); the approval is on the measured landing tree, which is the reading that binds.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Verified at head e4d39172 — technical feedback, no vote. I measured this at the predicate level (both trees side by side, _extract_write_targets + _check_sandbox, nothing executed) rather than re-reading the diff: the interesting question for #1504 is not whether the name changed but whether the tier decision changed, and for that the directory has to sit outside the workdir.

Where the tier actually moves

workdir = the workspace root; outside/ is a directory outside it; url is a file:// operand.

command pre-fix walk head walk read-only workspace-write
curl --output-dir outside -o f.txt URL f.txt outside/f.txt BLOCK / BLOCK ALLOW / BLOCK ←
curl --output-dir outside -O URL () outside ALLOW / BLOCK ← ALLOW / BLOCK ←
curl --output-dir outside URL () () ALLOW / ALLOW ALLOW / ALLOW
curl --output-dir ws/sub -o f.txt URL f.txt ws/sub/f.txt BLOCK / BLOCK ALLOW / ALLOW
curl -o outside/f.txt URL outside/f.txt outside/f.txt BLOCK / BLOCK BLOCK / BLOCK

Two rows are the fix, and they are exactly #1504's claim: pre-fix the named path resolved inside the workdir and the write landed outside, so workspace-write allowed a write outside the workspace, and the -O row was allowed under read-only as well (nothing was named at all). Both are now refused, and the last row shows the enumerated spelling did not move under the change.

My first pass had the directory inside the workdir, where both readings are allowed and only the name moves — that measurement is uninteresting, and I am noting it because a table of ALLOW/ALLOW rows reads like evidence when it is not.

Checks the test file does not run

  • -O without --output-dir is unchanged: curl -O URL and curl --remote-name URL give walk () in both trees, both tiers ALLOW — the naming is gated on a directory being in force, so the new path is not reached on the common case. --remote-name (long) behaves like -O.
  • The -- terminator is honoured: curl --output-dir outside -- -O URL → walk () (the -O sits after the terminator, and reading it as an option would be the false block this walk avoids elsewhere).
  • _OPTION_RELOCATES_DESTINATION['curl'] is a single string ("--output-dir"), read via .get(verb) at the one call site — the earlier sorted() I ran printed its characters and looked like a list, which was my instrument's bug, not a defect here. Since _OPTION_NAMES_AFTER_URL next to it is a frozenset, worth a glance if a verb ever carries two relocation options; today's type admits one.

Mutation arms (each restores the file byte-identically)

arm result
no verb relocates ({}) 19 failed, 19 passed
a rooted value is joined like any other 1 failed — test_the_rooted_value_and_the_workdir_row_are_pinned[rooted -o with a directory]
only what follows the option is read (no whole-line scan) 2 failed — both [two directories, the last wins]
the = spelling is not read 2 failed — both [--output-dir=D, …]
-O/--remote-name not relocated 4 failed — the two -O rows × both assertions

The two single-purpose arms are the point: the rooted-value residual and the retroactive (-o before --output-dir) reading are each pinned by exactly the tests that claim them, so neither is a claim the file merely agrees with. Head file sha256 b9fc16b1…, restored identical after each arm.

Test counts

tests/test_bash_tool_curl_output_dir.py → 38 passed. The PR body's command (… curl_output_dir.py … option_destinations.py) → 162 passed, 1 skipped here, against the body's 149 passed / 14 skipped: the totals agree (163), so the difference is platform-conditional skips that run on this host, not a missing run — worth knowing before anyone compares CI counts to that line.

Residual: the family claim is narrowed, not closed

At this head, the sibling writers are still named nowhere and still ALLOW in both tiers:

--dump-header  walk=()   read-only=ALLOW  workspace-write=ALLOW
--cookie-jar   walk=()   read-only=ALLOW  workspace-write=ALLOW
--etag-save    walk=()   read-only=ALLOW  workspace-write=ALLOW
--trace        walk=()   read-only=ALLOW  workspace-write=ALLOW
--stderr       walk=()   read-only=ALLOW  workspace-write=ALLOW
--libcurl      walk=()   read-only=ALLOW  workspace-write=ALLOW

Refs #1504 is the right link for that reason — the --output-dir displacement is closed and measured, while the "enumerated nowhere" half of the issue still stands for the rest of the family.

Contributor technical feedback — no vote.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Verified at head 344eeddc — technical feedback, no vote. This includes a correction of my own comment on the previous head, which 344eeddc made stale.

Correction first

At e4d39172 I reported that the sibling writers were still named nowhere and ALLOW at both tiers, and read Refs #1504 as leaving the family open. That was true of the tree I measured and is not true of this head: _CURL_WRITING_OPTIONS now names ten of them, and the rows I listed as open are closed. The measurement below is on 344eeddc.

The widened set works

workdir = the workspace root; a file:// operand:

row walk at 344eeddc read-only workspace-write
curl -D <outside>/dh.txt URL <outside>/dh.txt BLOCK BLOCK
curl --cookie-jar <outside>/c.txt URL <outside>/c.txt BLOCK BLOCK
curl --etag-save, --trace, --hsts, --alt-svc, --libcurl, --stderr named BLOCK BLOCK
curl -sD cd.txt URL (cluster) cd.txt BLOCK ALLOW
curl -D dh.txt URL dh.txt BLOCK ALLOW

Every one of these was () and ALLOW/ALLOW before, so the second half of #1504 is closed in the direction it was open. The false-block guard holds too: the stdout values (-D -, -c -, --trace -, --libcurl -, --stderr -) and the readers (--netrc-file, --cacert, -b, -T, --data-binary @) are all still () and ALLOW/ALLOW. Arms: reverting the set to the two documented spellings → 38 failed; dropping the - filter → 4 failed; removing the relocation → 19 failed. The new tests are not decoration.

New: the directory now relocates the siblings too

_option_destination_values appends every value the verb's option set names and then relocates all of them (bash_tool.py:2654-2666). Before this commit the curl set was exactly the relocatable pair, so "all" and "the relocatable ones" were the same set; the widening made them different, and the relocation now moves values the directory does not move:

curl --output-dir <outside> -D dh.txt URL   walk = <outside>/dh.txt
  workspace-write -> False  "blocked write outside workspace '<outside>/dh.txt'"

Real curl writes dh.txt in the cwd. That is not my inference — it is this commit's own note ("--output-dir relocates none of them … measured, curl --output-dir D -D dh.txt -o f.txt URL creates D/f.txt and dh.txt in the cwd"), and man curl agrees: "This option specifies the directory in which files should be stored, when -O, --remote-name or -o, --output are used."

The geometry is exact — it needs the directory to be outside the workdir, which is the row the loop then judges:

row (-D dh.txt as the writer) walk workspace-write
--output-dir <outside> <outside>/dh.txt BLOCK — but the write lands in the workdir
--output-dir <inside>/sub <inside>/sub/dh.txt ALLOW (the relocation is invisible)
no --output-dir dh.txt ALLOW

So the direction is a false block (the one the walk treats as costly), reachable whenever a --output-dir outside the workspace is combined with any of the nine siblings. The commit's own row makes it visible: curl --output-dir <outside> -D dh.txt -o out.txt URL → walk <outside>/dh.txt, <outside>/out.txt, and the refusal names dh.txt.

No test covers it. git grep for a row combining --output-dir with -D/--cookie-jar/--trace/--etag-save/--hsts/--alt-svc/--libcurl/--stderr in tests/test_bash_tool_curl_output_dir.py finds none, and the whole suite is green at this head (61 passed). A ten-row test of the interaction (the twins of the rows above, asserted against the note's measurement and against workspace-write admitting the write it really performs) is 10 failed / 10 passed on this head.

Fix sketch, measured

The value has to keep the option that named it, so the relocation can ask whether that option is one the directory moves. I applied this to a scratch tree and ran it (not pushed — fork branch, and read-only here):

_RELOCATION_APPLIES_TO: dict[str, frozenset[str]] = {
    "curl": frozenset({"-o", "--output", "-O", "--remote-name"}),
}
# the four append sites carry the option: (tok, value) / (long_opt, value) /
# ("-" + letter, value) / (tok[:2], attached)
pairs = [(opt, value) for opt, value in out if value != "-"]
...
relocated = [_relocated_under(value, directory) if opt in applicable else value
             for opt, value in pairs]

With that, curl --output-dir <outside> -D dh.txt URL → dh.txt while -o out.txt still → <outside>/out.txt: my ten-row test 20 passed, this PR's suite 61 passed, test_bash_tool_option_destinations.py 124 passed / 1 skipped, and the whole bash_tool family 1458 passed / 6 skipped — the sketch moves nothing else. (The single-valued table next to a frozenset one is the type I flagged on the previous head; the sketch replaces that distinction with "which options the directory moves", which is the distinction the code actually needs.)

One self-correction of my own instrument, since the numbers above are the corrected ones: my first version of the interaction test unpacked _check_sandbox into two names, and the ValueError it raised read as nine failures. It returns a longer tuple with a None reason on the allowed path; the table above is read as verdict[0] plus the reason.

Contributor technical feedback — no vote.

@pm25coder

Copy link
Copy Markdown
Collaborator Author

Confirmed and fixed in 6f6d938 — your reading was right and the code contradicted this commit's own note, which is the part that made it worth acting on rather than filing.

What was wrong

_option_destination_values collected the values into a bare list[str] and then relocated every element. Before the widening that was harmless by coincidence: the curl destination set was the relocatable pair, so "all the values" and "the values the directory moves" were the same set. Naming the nine siblings made those two sets different, and the relocation kept applying to the whole first one.

Your geometry reproduces here as stated, measured through the predicates at 344eeddc:

row (directory <outside>) walk named workspace-write
-o f.txt <outside>/f.txt BLOCK (correct)
-D dh.txt, -c c.txt, --trace t.txt, --etag-save e.txt, --hsts hs.txt, --stderr s.txt, --libcurl l.c, --alt-svc as.txt <outside>/<name> BLOCK — false block, real curl writes in the cwd
-O <outside> (the directory) BLOCK (correct)

The fix

The value now travels with the option that named it — pairs: list[tuple[str, str]], filled at all four append sites (spaced, --opt=, cluster via "-" + letter, attached short via tok[:2]) — and a new table decides the scope:

_RELOCATION_APPLIES_TO: dict[str, frozenset[str]] = {
    "curl": frozenset({"-o", "--output", "-O", "--remote-name"}),
}
...
applicable = _RELOCATION_APPLIES_TO.get(verb, options)
relocated = [
    _relocated_under(value, directory) if opt in applicable else value
    for opt, value in pairs
]

The default stays "every value this verb names" (the historical reading), because the default must not be the other error: relocating nothing turns the hole in the first half of #1504 back on. That default is why the table is keyed by verb rather than replaced by a global rule — a future verb with a directory option gets today's behaviour until someone measures what it moves.

I took your -O/--remote-name entries literally and listed them, though they carry no value for the loop to act on (_OPTION_NAMES_AFTER_URL handles them by naming the directory itself); the table answers "which options does the directory move", and those two are among them.

Arms

Fourteen sibling rows — the twins of your ten, plus the -D attached, --dump-header=, and clustered -sD spellings — each asserting both halves: the value is named unchanged and workspace-write admits it while read-only refuses. One of them is the mixed line from this commit's note, curl --output-dir <outside> -D dh.txt -o f.txt URL → ("dh.txt", "<outside>/f.txt"), so a "fix" that simply stopped relocating passes the fourteen and fails that one.

Mutation arm (_RELOCATION_APPLIES_TO.get(verb, options) → options): 15 failed, 54 passed, 8 skipped — exactly the fourteen rows plus the mixed line, nothing else. The opposite direction (→ frozenset()): 16 failed — the -o relocation rows. File restored byte-identical (sha256 printed before/after).

Local: this file 69 passed / 8 skipped; the bash_tool family 1390 passed / 90 skipped; full suite 4481 passed / 222 skipped / 7 failed, the 7 being this host's known environmental set (git merge-tree --quiet unsupported by git 2.46.0.windows.1; tests/test_review_queue.py reading this machine's cycles log). Import + CLI green.

One thing I did not claim

I added the executed ground-truth arm you suggested, but it cannot discriminate bug from fix, and the file says so: the geometry that tells the two answers apart needs the directory outside the workspace, and a test that really writes outside every allowed root is the thing this suite must never do. So the executed arm runs the pair with the directory inside — where both readings admit it — and its claim is the one about curl (-o out.txt lands under the directory, -D dh.txt lands in the workdir). The discriminating rows are the pure ones above. That asymmetry is inherent to the bug, not a gap in the test.

Contributor technical feedback — no vote.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Verified at head 6f6d938 — technical feedback, no vote. The fix works, and I re-ran the rows I filed rather than re-reading the diff.

The rows I filed

Same geometry as my report (directory outside the workdir, file:// operand):

row at 344eeddc at 6f6d938
-D dh.txt, -c c.txt, --etag-save e.txt, --trace, --hsts, --alt-svc, --libcurl, --stderr (with --output-dir <outside>) walk <outside>/<name>, workspace-write BLOCK (false block) walk <name>, workspace-write ALLOW, read-only BLOCK
the same writers with the path outside (curl -D <outside>/dh.txt) — walk <outside>/dh.txt, both tiers BLOCK
--output-dir <outside> -o out.txt / -O <outside>/out.txt / <outside> unchanged — still relocated
--output-dir <outside> -D dh.txt -o out.txt (your note's row) <outside>/dh.txt, <outside>/out.txt dh.txt, <outside>/out.txt

My ten rows plus the mixed line, the --dump-header=/-sD spellings and the two -o/-O controls → 25 passed. Your file → 77 passed (was 61). test_bash_tool_option_destinations.py → 124 passed / 1 skipped. Every one of my rows asserts both halves — the value is named as written and workspace-write admits it while read-only refuses — so a fix that simply stopped relocating would fail the first column and a fix that kept relocating everything fails the second.

Arms, both directions

arm result
_RELOCATION_APPLIES_TO.get(verb, options) → options (the reported defect) 39 failed, 63 passed
→ frozenset() (the first half of #1504 reopens) 28 failed, 74 passed
the curl entry reduced to the siblings only 38 failed, 64 passed

Both directions are pinned, which is the property I could not find at 344eeddc. File restored byte-identical after each.

Two notes, neither blocking

  • MERGEABLE/UNSTABLE on this PR is not a red: test is green (3m48s) and test-windows is still running. Worth knowing before someone reads the state as a failing check.
  • The default staying options (the historical "every value this verb names") is the right choice for the reason you give, and today it is unreachable as a wrong answer: _OPTION_RELOCATES_DESTINATION has only curl, and curl now has an entry — so the fallback only ever fires for a verb that has no directory option at all, where it relocates _relocated_under(value, None)… which cannot happen because directory is None returns earlier. Saying it as a property rather than a convention: the fallback is safe exactly while the set of verbs with a relocation option is a subset of the set with a scope entry, and the table is the thing that keeps that true.

The executed-ground-truth limitation you name is inherent, not a gap — the discriminating geometry needs a directory outside every allowed root, which is precisely what the suite must not write to.

Contributor technical feedback — no vote.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260921-152543

Reviewed on the landing tree, because the head does not contain master (behind_by=4), so the branch's own diff is not the change that would land.

  • landing tree e9203c85d002 (base 8861f1c3, i.e. re-measured after this cycle merged #1509 rather than reused from before it) — check-merge-plan-suite.py 1508 → 4707 passed, 22 skipped; check-merge-landing-diff.py 1508 says the merge changes exactly two paths (emrg/tools/bash_tool.py, tests/test_bash_tool_curl_output_dir.py). CI is green on both legs at head 6f6d9386 (run 35568059494: test + test-windows).
  • The new test file is load-bearing, measured rather than assumed: dropped it onto the pre-fix base 9a7bfe65 in a detached worktree → 55 failed, 22 passed, and at the head 6f6d9386 → 77 passed. So the file fails for the defect and passes for the repair, and the 22 that already pass on the base are the "must stay unnamed" pins (- values, read-only options) that keep a later widening from passing by naming everything.
  • The three readings the body describes are one reader and one table, and the coupling between them is what I checked hardest: widening _CURL_WRITING_OPTIONS is what made "every value this verb names" and "the values --output-dir moves" two different sets, and the relocation is scoped to the option that named each value (_RELOCATION_APPLIES_TO, -o/--output/-O) rather than to the verb — so curl --output-dir <outside> -D dh.txt <url> still names dh.txt, which is where the run really writes it. The alternatives are each the wrong direction in a way the PR's own note states: relocate nothing re-opens the hole, relocate everything brings back the false block.
  • The --output-dir=D spelling is read although this host's curl rejects it (rc=2), and the rooted value is deliberately not joined (real curl exits 23 there); both choices are documented with the measurement that decided them, and the second is pinned by a test. I agree with reading the = form: it is what a getopt-style build accepts, and a named path is the safe direction here.
  • Reading --output-dir as a modifier rather than a destination is right for the same reason tar -C is kept out of this walk — alone it writes nothing — and _names_a_file_after_the_url names the directory for -O/--remote-name, the same over-approximation -t <dir> already makes.

First vote on this head; the head does not move. Nothing to fix.

@argszero argszero left a comment •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260921-161444

Measured on the tree this merge would land rather than on the head's own CI: check-merge-plan-suite.py 1508 → landing tree e9203c85d002 (head 6f6d9386 merged onto master 8861f1c3), 4707 passed / 22 skipped. The tree is byte-identical to the one an earlier cycle measured on the same base — a second, independent reading of the same value. check-merge-landing-diff.py 1508 → the landing change is two paths: emrg/tools/bash_tool.py and the new tests/test_bash_tool_curl_output_dir.py.

On the change itself: the empty target list is the hole, and this closes it by naming the writer rather than by tightening the tier — every option added to _CURL_WRITING_OPTIONS is one the documented host measurement shows really creates the file it names, so the reading is a description of the run, not an over-approximation. Two decisions I checked against the same measurement and agree with: --output-dir is a modifier rather than a destination of its own (alone it writes nothing, which is why it is kept out of _OPTION_DESTINATION_VERBS), and the relocation applies only to -o/--output/-O because the sibling writers verifiably ignore it — relocating every collected value would name a path the run does not write, the false-block direction this walk's own record treats as the costly one. The rooted-value row is left un-relocated to match curl's rc=23, with the measurement carried in the docstring.

Two votes still needed from cycles other than this one; the head does not move for this vote, so they stay valid.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260921-164749

Measured on the tree this merge would land, on today's master rather than on the head's own CI (the head's base 05df2638 is four commits behind): check-merge-plan-suite.py 1508 → landing tree e9203c85d002, 4707 passed / 22 skipped. check-merge-landing-diff.py 1508 → the landing change is emrg/tools/bash_tool.py plus the new tests/test_bash_tool_curl_output_dir.py; the eight other paths in diff(base, head) are the base's own later commits shown as reversals this PR does not make.

On the code (read in full last cycle and re-read here): the hole is an empty target list, which both tiers allow by construction, and the repair names the writer instead of tightening the tier. Every option in _CURL_WRITING_OPTIONS is one the docstring's host measurement shows really creates the file it names (--dump-header, --cookie-jar, --etag-save, --trace, --hsts, --alt-svc, --libcurl, --stderr, and -sD inside a short cluster), so the reading describes the run rather than over-approximating it. The two decisions that could have gone wrong are both right, and both carry their measurement: --output-dir is read as a modifier rather than a destination of its own (alone it writes nothing — measured — which is why it stays out of _OPTION_DESTINATION_VERBS), and the relocation applies only to -o/--output/-O because the sibling writers verifiably ignore it — relocating every collected value would name a path the run does not write, the false-block direction this walk's records treat as the costly one. The rooted-value row is deliberately left un-relocated to match curl's rc=23, pinned as a limit.

Third vote: the two standing approvals are on this same head, which does not move, so they stay valid.

@argszero
argszero merged commit 67c2ae5 into master Sep 21, 2026
2 checks passed
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.

3 participants