install: report ENAMETOOLONG for a --cwd value that does not fit the path buffer

Closed
RO
2mo ago
+82-31

Problem

  • Every package manager subcommand (bun install, add, remove, update, pm, ...) aborts when its --cwd value is at least PATH_MAX bytes long (4096 on Linux, 1024 on macOS), instead of printing an error:
    $ bun install --cwd "$(head -c 5000 /dev/zero | tr '\0' a)"
    panic: range end index 5000 out of range for slice of length 4096
    $ bun install --cwd "./$(head -c 5000 /dev/zero | tr '\0' a)"
    panic: range end index 5014 out of range for slice of length 4095
    $ bun install --cwd "$(head -c 4096 /dev/zero | tr '\0' a)"
    panic: index out of bounds: the len is 4096 but the index is 4096
    
    (exit 134). A value one byte shorter already printed error: failed to change directory to "...": ENAMETOOLONG and exited 1.
  • Cause: the --cwd block in src/install/PackageManager/CommandLineArguments.rs (lines 1298-1329 on main) stages the value in a PathBuffer of exactly PATH_MAX bytes with no length check. A value not starting with . is copied in with copy_from_slice and then NUL-terminated one byte further; a value starting with . is resolved with the unchecked join_abs_string_buf, which panics inside the normalizer when the result does not fit the buffer.
  • The runtime's global bun --cwd flag has the same bug in separate code (src/runtime/cli/Arguments.rs); that one is fixed in #38368. This PR only covers the package manager's own flag.

Fix

  • The block moves into change_directory. The .-prefixed branch resolves with join_abs_string_buf_checked into PATH_MAX - 1 bytes of the buffer; the other branch copies the value only when it leaves that last byte free. Both keep the last byte for the NUL terminator chdir reads. When the value does not fit, the existing failed to change directory to "<value>": <errno> message is printed with ENAMETOOLONG and the process exits 1, the same as when chdir itself fails.
  • Why ENAMETOOLONG is the right answer rather than some new error: PATH_MAX counts the terminator, so a path of PATH_MAX or more bytes is one chdir would reject with ENAMETOOLONG if it could be passed at all. The check only turns away values that could never have been entered, and the user sees the same line either side of the buffer boundary (the PATH_MAX - 1 control in the test is the kernel's own rejection, printed through the unchanged chdir error path).
  • Why the checked join rather than a length check on the argument in the . branch: that branch already normalizes (--cwd ./moo/.. enters the parent), and join_abs_string_buf_checked fails only when the normalized result does not fit, so that behaviour is kept. Mapping its None to ENAMETOOLONG is how the lockfile code already treats it (src/install/lockfile/Package/WorkspaceMap.rs).
  • Messages for failures that are not about length are unchanged: a missing directory still prints the value as given ("/nonexistent") or, for a . value, the resolved path ("/tmp/proj/nonexistent").
  • Verified with the new --cwd that does not fit the path buffer block in test/cli/install/bun-install.test.ts: PATH_MAX bytes, PATH_MAX + 1000 bytes, the ./ form, and bun add, plus PATH_MAX - 1 as the kernel-rejected control. On the current release build the four new lengths abort with the panics above and the control passes; with this change all five pass, as does the existing should handle --cwd test.
  • Checked by hand with the debug build that --cwd moo, ./moo, moo/, ., ./moo/.., an absolute path, "", a missing directory and ./<5000 bytes>/.. (normalizes to the cwd and succeeds) behave as before.
  • The test block is skipped on Windows: PathBuffer there is about 96 KiB, longer than any command line, so the overflow is unreachable.

Background

  • PathBuffer is a [u8; MAX_PATH_BYTES] scratch buffer, where MAX_PATH_BYTES is the platform PATH_MAX (4096 on Linux, 1024 on macOS and the BSDs, 32767 * 3 + 1 on Windows). PATH_MAX includes the NUL terminator, so the longest path a syscall accepts is PATH_MAX - 1 bytes.
  • join_abs_string_buf(cwd, buf, parts) is path.resolve into a caller buffer; it assumes the result fits. join_abs_string_buf_checked is the variant for user-controlled input: it normalizes into heap scratch when needed and returns None only if the normalized result does not fit buf.
  • Output::err_generic + Global::crash() is how this argument parser reports every invalid flag value: it prints error: ... and exits 1.