Find report-1.csv after curl 8.23.0, not report.csv.1

John Burns

Find report-1.csv after curl 8.23.0, not report.csv.1

A second download with curl --no-clobber --output report.csv did not create report.csv.1. The first file was still the original copy. The new file was report-1.csv. The same command on curl 8.22.0, and on the Debian 13 client, created report.csv.1 and left report-1.csv absent.

That is the check a download job has to make before it is pointed at 8.23.0. The 14 October 2026 release is the one that published the spare-name change, and it is also the release that dropped SMB and SMBS. The man page shipped with 8.23.0 says an extension is preserved as image-1.jpg, then image-2.jpg, and that numbering continues up to filename.9999. The binary followed the extension rule. It did not create the 9999 name. The highest spare file it wrote was image-9998.jpg.

The name is decided after the transfer starts

The comparison used three clients on Debian 13, Linux amd64. The distribution package was curl 8.14.1-2+deb13u7. curl --version printed 8.14.1, release date 2025-06-04, security patched as 8.14.1-2+deb13u7. Its protocol list included smb and smbs.

The other two clients were built from the official release tarballs. Each SHA-256 matched the digest on the corresponding GitHub release asset before the archive was unpacked:

8.22.0  d54dd598bf05927a726deb38df31c6a255ba83ff1de57c5d1464dac3ed8f44a1
8.23.0  e3de77cfa6367083c776a26647fc9f633b538c06c3baa73d931a252ac8f72b49

Both trees were configured with the same options, including --without-ssl, and both printed a release date that matched the tarball: 2026-09-02 for 8.22.0 and 2026-10-14 for 8.23.0. Those lab binaries are not a feature-complete package. They have no TLS. They are enough for the output-name rule, because that rule lives in the command-line tool, not in the protocol handler. One later check repeated the report.csv case over HTTP against a local server. The spare name was the same as the file:// case.

The payload was the eleven-byte text payload-v1 plus a newline. The first run, with the output path absent, wrote report.csv on all three clients and exited 0. A second run, with that file already present and containing the text old, is the run that split:

8.14.1  report.csv  report.csv.1
8.22.0  report.csv  report.csv.1
8.23.0  report.csv  report-1.csv

report.csv still contained old on every client. The spare file contained payload-v1. Without --no-clobber, 8.23.0 replaced report.csv and did not create a spare file. The flag is what selects the numbered name. A job that omits it is still an overwrite, on the new binary as on the old one.

The shipped 8.23.0 page for --no-clobber still opens with the old sentence: a dot and a number are appended, up to filename.9999. The next paragraph is the one the binary followed. If the target name has an extension, the number is inserted as -[num] before that extension. The release announcement on 14 October says the same thing, and it says the tool now tries numbers up to 9999. The page and the announcement agree with each other on the cap. They do not agree with the open that this binary performed.

The last dot is the extension, and some dots are not

The rule is narrower than “any name that contains a dot.” On 8.23.0 the second download of these names produced the spare file on the right. On 8.22.0 and on 8.14.1, the same names produced the old suffix.

report.csv       report-1.csv
image.jpg        image-1.jpg
archive.tar.gz   archive.tar-1.gz
notes            notes.1
.hidden          .hidden.1
file.            file..1

archive.tar.gz keeps .gz and puts the number before that suffix only. The spare file is not archive-1.tar.gz, and it is not archive.tar.gz.1. A job that strips one suffix and then looks for a trailing .1 will miss the file 8.23.0 wrote. A job that inserts the number before the first dot will miss it too.

A name with no dot still uses the old form. notes became notes.1 on 8.23.0, the same string 8.22.0 wrote. A leading dot does not count as an extension when it is the first character of the file name. .hidden became .hidden.1, not -1.hidden. A trailing dot with nothing after it does not count either. file. became file..1. The extra dot is the old suffix being appended to a name that already ended in a dot.

The directory is not part of the extension test. With the output path sub/report.csv already present, 8.23.0 wrote sub/report-1.csv. 8.22.0 wrote sub/report.csv.1. The number is inserted into the final path component. It is not inserted into a directory name, and it is not written beside the directory.

A file left behind by the other rule does not satisfy the new one. 8.23.0 was given an existing report.csv and an existing report.csv.1. It still created report-1.csv. 8.22.0 was given an existing report.csv and an existing report-1.csv. It still created report.csv.1. A migration that only renames yesterday’s spare file, or only deletes one pattern, will leave the other pattern in place and will also fail to reserve the name the new binary wants.

The man page lists --output, --remote-header-name, --remote-name, and --remote-name-all as the options this flag applies to. This comparison used --output. It did not exercise a remote name taken from the URL, and it did not exercise a Content-Disposition name. Those paths use the same numbering function once a local name has been chosen, but that was not a separate run.

The documented cap is one past the last file

The 8.22.0 page says numbering stops at filename.100. The 8.23.0 page says filename.9999. Neither binary created that name.

The boundary was measured by pre-creating the numbered files, then running one transfer. That is a test of the open loop, not of 9998 separate downloads. On 8.22.0, report.csv and report.csv.1 through report.csv.98 already existed. The next --no-clobber run exited 0 and wrote payload-v1 to report.csv.99. It did not create report.csv.100. A directory that already contained .1 through .99 made the same command exit 23. report.csv.100 was still absent.

On 8.23.0, image.jpg and image-1.jpg through image-9997.jpg already existed. The next run exited 0 and wrote the eleven-byte payload to image-9998.jpg. image-9999.jpg was absent. A directory that already contained image-1.jpg through image-9998.jpg made the command exit 23, and image-9999.jpg was still absent. The no-extension form has the same ceiling. With notes.1 through notes.9998 present, 8.23.0 exited 23 and did not create notes.9999.

The warning does not name the spare file that failed. Without --silent, both cap failures printed a progress meter and then:

Warning: Failed to open the file image.jpg: File exists
curl: (23) client returned ERROR on write of 11 bytes

The 8.22.0 failure was the same text with report.csv in the warning. The file named in the warning is the path that was requested, which had existed since the first download. It is not image-9998.jpg and it is not report.csv.99. The “11 bytes” is the payload that could not be stored, not a fixed curl constant. --silent hides the warning. --show-error still prints the exit 23 line. A log scraper that looks only for the spare name, or only for the documented 9999 name, will not see this failure.

Exit 23 here is the tool refusing the write after the transfer had already started. It is not an HTTP error and not a missing URL. The source file was readable. The numbered names were the condition that stopped the open.

A job that plans for 100 old-style spares, or for 9999 new-style spares, is planning for a file this code does not create. The last successful spare is 99 on 8.22.0 and 9998 on 8.23.0. After that, the command fails and the requested file is left as it was.

SMB is a different line in the version output

The spare-name change does not show up in curl --version. The protocol list does show whether this binary still has SMB, and that is the other 8.23.0 change a transfer job can hit on the same upgrade.

The 8.23.0 tarball has no lib/smb.c. The 8.22.0 tarball still has that file. The public header in 8.23.0 still defines CURLPROTO_SMB. A compile-time check for the constant does not prove the protocol is implemented. The runtime check does.

The 8.23.0 lab binary rejected the scheme before a connection:

curl: (1) Protocol "smb" not supported

The process exited 1. The 8.22.0 lab binary, built with the same options, printed Protocol "smb" is disabled and also exited 1. That message is not evidence that a normal 8.22.0 package still speaks SMB. SMB was left out of this build. The distribution client is the useful before-state. Its curl --version line included smb and smbs. The same smb:// URL was accepted far enough to attempt a connection, which then failed, and the process exited 7. No share was served. The distinction that matters is earlier: 8.14.1 treated the scheme as a protocol, and 8.23.0 did not.

An upgrade check that only diffs the version number will miss both changes. The spare-name probe is a second download of a dotted name. The SMB probe is the protocol list, or a URL the binary should reject before it connects. A package build can omit SMB while still containing the old code, and that build says disabled rather than not supported. Read the message. Do not treat every non-zero SMB attempt as the 8.23.0 removal.

What to change in the job

Print the version from the binary the job will execute, not from a package index and not from a header that still defines CURLPROTO_SMB.

curl --version

On 8.23.0 the release date in that output was 2026-10-14. A distribution build can carry a different package revision under an older upstream version. The Debian client in this run was 8.14.1 and still used the old spare name. A version string of 8.23.0 is the boundary for the new name. A string older than that, including 8.22.0, still appended .<num> in this comparison.

Create the output directory, put a known file at the requested path, and run the transfer a second time with --no-clobber. Then list the directory. Do not delete the first file between the two runs. The second run is the one that chooses the spare name.

curl --silent --show-error --fail \
  --output report.csv --no-clobber \
  --url <download-url>
curl --silent --show-error --fail \
  --output report.csv --no-clobber \
  --url <download-url>

After 8.23.0, expect report.csv and report-1.csv. Expect report.csv.1 only on an older tool. If the URL name is archive.tar.gz, expect archive.tar-1.gz, not a number before .tar. If the name has no extension, expect notes.1 on both sides of this release. If the name begins with a dot, expect .hidden.1.

Repeat the second run once with a pre-created report-1.csv if the job must prove it will advance to report-2.csv rather than overwrite. This comparison stopped at the first spare name for the ordinary case, and at 9998 for the ceiling. It did not fill every slot in between with a real transfer. The open loop does not skip a missing number: it starts at 1 and stops at the first name it can create. A hole at report-1.csv is the name the next run will take, even if report-4.csv is already there.

For the ceiling, do not look for image-9999.jpg or report.csv.100 as a success file. Look for exit 23, and run once without --silent if the warning has to be in the log. The warning will name the requested file and say it exists. That is the full-directory failure, not a collision on the spare name the page describes.

If the job still uses smb:// or smbs://, treat 8.23.0 as a hard removal, not as a disabled feature that a configure flag will turn back on. The source file is gone. A client that must keep speaking SMB cannot move to this release and keep that URL. The 14 October release is still the security release: the project published 24 curl CVEs that day, and 21 of them list 8.22.0 as the last affected version. This comparison did not attempt those bugs. It recorded the two behavior changes a transfer job hits while taking that upgrade: the spare file is no longer beside the extension, and SMB is no longer a protocol.

Sources