Skip to content

libutil: treat an existing empty environment variable as empty, not absent - #16345

Open
awsmadi wants to merge 4 commits into
NixOS:masterfrom
awsmadi:pr/getenvos-empty-not-absent
Open

libutil: treat an existing empty environment variable as empty, not absent#16345
awsmadi wants to merge 4 commits into
NixOS:masterfrom
awsmadi:pr/getenvos-empty-not-absent

Conversation

@awsmadi

@awsmadi awsmadi commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

getEnvOs returned nullopt when the second GetEnvironmentVariableW stored zero
characters. But reaching that call means the sizing call already succeeded, which
means the variable exists, so zero there means the value is empty rather than
missing.

Windows distinguishes the two and this discarded the distinction. Per the documented
contract, an existing empty variable returns 1 from the sizing call (the count
including the terminator) and 0 from the buffered call. An absent one returns 0 from
both and sets ERROR_ENVVAR_NOT_FOUND.

Unix already behaves correctly: getEnv on an empty variable yields an engaged
optional holding "". So the same calling code took different branches on the two
platforms, and one consequence is that an empty-string guard in
FileTransferSettings is unreachable on Windows.

Following review, the branch is gone entirely rather than corrected.
value.resize(resultSize) on a zero count gives the empty string, and returning it
gives an engaged optional, which is what was wanted. Thanks @Ericson2314.

Two tests in libutil-tests, per @xokdvium. getEnvOs.emptyValueIsPresent fails on
Windows without this change. They pass on both platforms, since Unix has always had
the behaviour being asserted.

@awsmadi
awsmadi marked this pull request as ready for review August 21, 2026 19:25
@awsmadi
awsmadi requested a review from edolstra as a code owner August 21, 2026 19:25
@Ericson2314

Copy link
Copy Markdown
Member

BTW see https://github.com/NixOS/nix/blob/master/CONTRIBUTING.md forgive my guessing but you might be missing an Assisted-By: commit trailer, but otherwise yes --- this sounds good. Thanks fixing.

Comment on lines 25 to 28
if (resultSize == 0) {
return std::nullopt;
return OsString{};
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we even need this special case, or will value.resize(resultSize); below just do the right thing?

Let's delete the branch if possible, but still keep the comment.

@xokdvium xokdvium left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we get a libutil test for this?

…bsent

getEnvOs() returned nullopt when the second GetEnvironmentVariableW call stored
zero characters. Reaching that call already implies the sizing call succeeded,
which implies the variable exists -- so zero there means the variable is present
and its value is empty, not that it is missing.

Windows distinguishes the two cases and this discarded the distinction. For an
existing empty variable the sizing call returns 1, the character count required
including the terminator, and the buffered call then returns 0. For an absent
variable both calls return 0 and set ERROR_ENVVAR_NOT_FOUND.

Measured under Wine 11.0 with a kernel32-only probe: an empty value is
representable and survives inheritance. A launcher exporting VAR= produces a
process whose environment block carries the literal entry "VAR=" and whose
sizing call returns 1 with no error, while an unset variable returns 0 with
ERROR_ENVVAR_NOT_FOUND. SetEnvironmentVariableW(name, L"") stores an empty
value; only passing NULL removes the entry. So setEnvOs(k, "") followed by
getEnvOs(k) now round-trips, and an inherited empty variable is no longer
reported as missing.

The alternative fix is to test GetLastError() == ERROR_ENVVAR_NOT_FOUND after
the second call rather than inferring existence from the first. That is the
documented disambiguator and it is immune to a concurrent deleter, but it needs
SetLastError(0) beforehand, because a zero return from an existing empty
variable is a success and leaves any earlier error code in place. Inferring from
the sizing call needs no error handling and is sound for every case this process
creates.

Assisted-by: Claude Code (claude-opus-5)
@awsmadi
awsmadi force-pushed the pr/getenvos-empty-not-absent branch from 5ea9704 to 12640d4 Compare August 24, 2026 15:28
Per review: resize(resultSize) already returns the empty value, so the branch
was redundant. Comment kept, as asked.

nix-util-tests under Wine unchanged at 782 passed / 8 skipped / 0 failed.

Assisted-by: Claude Code (claude-opus-5)
Per review. emptyValueIsPresent fails on Windows without the change in this PR.

807 tests pass natively, and the suite compiles for mingw.

Assisted-by: Claude Code (claude-opus-5)
On Windows it reports success as -1, the inverse of the POSIX convention the
header describes. Keeping the tests off that means they pass either way.

Assisted-by: Claude Code (claude-opus-5)
@Ericson2314

Copy link
Copy Markdown
Member

Please squash the history, as I have mentioned in the other PRs. Also, I suspect we will have a problem where the environment variable tests keep on conflicting with one another. That may call for combining these PRs into one.

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