Skip to content

Conversation

@mediabounds
Copy link
Contributor

I ran across an issue while using Drush and calling a command that starts a subprocess (such as updatedb) and found that the binary proxy does not handle spaces in the source path.

In my environment, I have my Composer/Drupal install at a patch which has a space in the name (i.e. /home/jenkins/workspace/Job Name). I attempted to call vendor/bin/drush updatedb and got an error similar to:

The command "'/home/jenkins/workspace/Job Name/vendor/bin/drush' updatedb:status --no-cache-clear --strict=0 --uri=default" failed.                                                                                                        
                                                                                                                                       
  Exit Code: 127(Command not found)                                                                                                    
                                                                                                                                       
  Working directory:                                                                                                                   
                                                                                                                                       
  Output:                                                                                                                              
  ================                                                                                                                                                                                                                                           
                                                                                                                                       
                                                                                                                                       
  Error Output:                                                                                                                        
  ================                                                                                                                     
  /home/jenkins/workspace/Job Name/vendor/bin/drush: line 17: cd: ../drush/drush: No such file or directory                                                                                                                                  
  /home/jenkins/workspace/Job Name/vendor/bin/drush: line 41: /drush: No such file or directory

The drush command started correctly, but when it attempted to start a subprocess ('/home/jenkins/workspace/Job Name/vendor/bin/drush' updatedb:status --no-cache-clear --strict=0 --uri=default), it could not find drush.

I found the root cause to be in the binary proxy where it calls realpath:

selfArg="/home/jenkins/workspace/Job Name/vendor/bin/drush"
self=$(realpath $selfArg 2> /dev/null)

self was becoming /home/jenkins/workspace/Job.

Seldaek pushed a commit that referenced this pull request Sep 3, 2025
@Seldaek
Copy link
Member

Seldaek commented Sep 3, 2025

good catch, thanks. Merged in 2.8 here 54119de

@Seldaek Seldaek closed this Sep 3, 2025
@Seldaek Seldaek added this to the 2.8 milestone Sep 3, 2025
@drupol
Copy link
Contributor

drupol commented Oct 17, 2025

Hello guys,

Just for your information this minor change broke many PHP builds in Nix because the changes modifies the content of the vendor. It has been fixed by @Ma27 in NixOS/nixpkgs#452933

I wonder what kind of guardrails we could setup to avoid this kind of situation in the future. Do you have any idea?

@mediabounds
Copy link
Contributor Author

@drupol I'm not familiar with the NixOS project, but I'm curious to understand from an academic standpoint of how this change could have impacted with hashes. (Maybe you meant #12225?)

@drupol
Copy link
Contributor

drupol commented Oct 18, 2025

Let me provide some context about Nix and why stability in the vendor directory is critical.

What is Nix?

Nix is a purely functional package manager and build system. It’s best known for powering the NixOS Linux distribution but can also run on other Linux systems and macOS.
The key idea is that builds are deterministic and reproducible. Given the same inputs, Nix guarantees bit-for-bit identical outputs (or it tries, 99% of the time). It achieves this by isolating builds from the environment and requiring that all dependencies be explicitly declared.

For PHP projects, Nix can build and package applications (including their Composer-managed dependencies). The vendor directory that Composer generates is therefore considered part of the build output.

Why is output stability important?

In Nix, the hash of a build output (here, the vendor directory) uniquely identifies it.
If its contents change (even slightly, like the changes introduced in this PR!) the hash changes, and Nix treats it as a different output. This leads to build failures, unnecessary rebuilds, cache invalidation, inconsistencies across systems and require manual interventions.

Because each PHP application embeds the hash of its vendor directory, any unintentional change makes the application no longer "buildable" and broken.

The recent change in this PR (for very good reasons actually) modified the generated proxy script, which in turn altered the contents of the vendor directory. Although functionally harmless, it caused the hash to differ, breaking many Nix-based PHP builds that relied on the previous stable output.

Why does this matter?

  1. Reproducibility: Nix assumes that vendor changes only when composer.json or composer.lock change. Any other modification breaks this assumption.
  2. Caching: Nix’s binary caches rely on output hashes. Unexpected changes invalidate caches and trigger full rebuilds.
  3. Collaboration: In teams and CI/CD systems, Nix guarantees identical build results everywhere. Unstable outputs cause inconsistent environments.

How to prevent this in the future

To avoid similar issues, the vendor directory should be treated as immutable once generated. Any change to its structure or scripts should be intentional and well-documented. Some kind of output stability stamp would be very nice to have, for each release.

That would help downstream systems like Nix anticipate necessary hash updates and coordinate transitions more smoothly. For example, when the PR to bump Composer in Nix would be created, all the updated hashes would need to be updated as well, all at once.

Edit: I dedicated a bit of time tonight to build a test that would check that the output is stable, find it at #12574


Out of curiosity, I ran shellcheck on one of the generated scripts:

ShellCheck output
❯ shellcheck ./result/share/php/baikal/vendor/bin/naturalselection 

In ./result/share/php/baikal/vendor/bin/naturalselection line 5:
selfArg="$BASH_SOURCE"
         ^----------^ SC2128 (warning): Expanding an array without an index only gives the first element.
         ^----------^ SC3028 (warning): In POSIX sh, BASH_SOURCE is undefined.


In ./result/share/php/baikal/vendor/bin/naturalselection line 10:
self=$(realpath $selfArg 2> /dev/null)
                ^------^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
self=$(realpath "$selfArg" 2> /dev/null)


In ./result/share/php/baikal/vendor/bin/naturalselection line 15:
dir=$(cd "${self%[/\\]*}" > /dev/null; cd '../sabre/dav/bin' && pwd)
      ^-----------------------------^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails.

Did you mean:
dir=$(cd "${self%[/\\]*}" > /dev/null || exit; cd '../sabre/dav/bin' && pwd)


In ./result/share/php/baikal/vendor/bin/naturalselection line 26:
export COMPOSER_RUNTIME_BIN_DIR="$(cd "${self%[/\\]*}" > /dev/null; pwd)"
       ^----------------------^ SC2155 (warning): Declare and assign separately to avoid masking return values.
                                   ^-----------------------------^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails.

Did you mean:
export COMPOSER_RUNTIME_BIN_DIR="$(cd "${self%[/\\]*}" > /dev/null || exit; pwd)"


In ./result/share/php/baikal/vendor/bin/naturalselection line 29:
bashSource="$BASH_SOURCE"
            ^----------^ SC2128 (warning): Expanding an array without an index only gives the first element.
            ^----------^ SC3028 (warning): In POSIX sh, BASH_SOURCE is undefined.


In ./result/share/php/baikal/vendor/bin/naturalselection line 32:
        source "${dir}/naturalselection" "$@"
        ^-- SC3046 (warning): In POSIX sh, 'source' in place of '.' is undefined.
               ^-----------------------^ SC1091 (info): Not following: ./naturalselection was not specified as input (see shellcheck -x).

For more information:
  https://www.shellcheck.net/wiki/SC2128 -- Expanding an array without an ind...
  https://www.shellcheck.net/wiki/SC2155 -- Declare and assign separately to ...
  https://www.shellcheck.net/wiki/SC2164 -- Use 'cd ... || exit' or 'cd ... |...

If these small fixes are ever integrated into Composer (which would be great!), it would means that all the PHP packages in Nix would be broken and will need a manual intervention to update the vendor hash.

If possible, a short note in the Composer changelog would help us to know if the resulting output of composer has been updated from a version to another.

Hope this helps, let me know if this is not the case.

@fredden
Copy link
Contributor

fredden commented Oct 18, 2025

Reproducibility: Nix assumes that vendor changes only when composer.json or composer.lock change. Any other modification breaks this assumption.

It seems like this assumption is missing a piece: the version of Composer used.

@drupol
Copy link
Contributor

drupol commented Oct 19, 2025

Reproducibility: Nix assumes that vendor changes only when composer.json or composer.lock change. Any other modification breaks this assumption.

It seems like this assumption is missing a piece: the version of Composer used.

You're right that the Composer version could in theory influence the output, but in practice that hasn’t been the case and that’s precisely why Nix doesn’t need to account for it.

We’ve upgraded Composer within the same major version (v2) many times in the past without any reproducibility issues or changes to the generated vendor directory. As long as the Composer team preserves backward-compatible behaviour within a major version, Nix can safely assume that a given composer.json + composer.lock combination will always produce identical outputs.

That’s why this situation is quite uncommon. IIRC, it’s the first time we’ve observed such a change affecting reproducibility.
It might be worth keeping this in mind when preparing the next release, so that downstream systems like Nix can anticipate and adjust if similar changes are expected.

@drupol
Copy link
Contributor

drupol commented Oct 24, 2025

This issue led to the creation of a PHP package that implements the NAR format designed by Eelco Dolstra (the original Nix author).

By creating an external package that implements this specification, it can now be reused everywhere else. I think I made it in an efficient way, if you think it can be improved, feel free to contribute to the project.

The project is available at https://github.com/loophp/path-hasher

In #12574, I updated the code to use that library. I am not expecting this pull request to be merged (but perhaps I am wrong?) but rather open a discussion on how we could avoid issue in the future when it comes to the PHP code generated by Composer.

Also, that PR is incomplete because it doesn't take into account the Composer proxy binaries mechanism wrapper.

@Seldaek
Copy link
Member

Seldaek commented Oct 27, 2025

You're right that the Composer version could in theory influence the output, but in practice that hasn’t been the case and that’s precisely why Nix doesn’t need to account for it.

That was just luck. I am totally with you on producing deterministic output for a given version, but things within the vendor/composer/* and vendor/bin/* dirs are definitely going to keep changing every now and then. We cannot bump the major version every time.

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.

4 participants