Skip to content

process.argv silently drops an argument when it resolves (via realpath) to the same file as the running binary #176

Description

@RyanDev-21

I don't know if this bug is intentional or not.

A compiled binary's process.argv comes up one element short whenever an argument, resolved as a filesystem path relative to cwd, points to the same file on disk as the binary itself. This silently discards a real user argument — even code that correctly guards process.argv.length gets the wrong result, with no error of any kind.

Repro

hello.ts:
ts const argument = process.argv.length > 2 ? process.argv[2] : "something"; console.log(`hello ,${argument}`); ​

​```
$ scriptc build hello.ts -o hello
$ ./hello world
hello ,world

$ ./hello hello
hello ,something
​```

Expected hello ,hello on the second run. Instead the argument is dropped and the code falls back to its default, silently. There's no crash, no diagnostic — this is a correctness bug, not just a crash bug.

The trigger is path resolution, not string-literal matching, by renaming the binary:

$ scriptc build hello.ts -o foo $ ./foo hello hello ,hello ​

Same argument, different binary name → correct result. "hello" no longer resolves to the same file as argv[0].

(A version of hello.ts without the length guard traps instead of silently defaulting — scriptc: RangeError: array index 2 out of bounds (length 2) — same root cause, surfaced as a crash because it does an unguarded process.argv[2] read. Either way, the underlying array is already the wrong length before code runs.)

Root cause (traced to source)

scr_lib_init() calls scr_lib_same_executable_arg(argv[0], argv[1]) to detect Node-style self-re-exec, where a relaunched process's argv contains the executable path twice (spawn(execPath, [argv[1], ...args])). The check does:

  1. strcmp(a, b) — exact string match
  2. realpath(a) vs realpath(b) — resolved absolute path match

Step 2 is the problem. It resolves both argv[0] and the first user-supplied argument as filesystem paths and compares the results, with no way to distinguish "this is a re-exec marker" from "this is an ordinary user argument that happens to resolve to the running binary's own file." When they collide, scr_lib_skip_reexec_arg is incorrectly set to true, and scr_lib_arg_count()/scr_lib_arg() silently drop one element from process.argv, shifting everything after it.

Relevant runtime code:

​```c
static bool scr_lib_same_executable_arg(const char *a, const char *b) {
if (strcmp(a, b) == 0)
return true;
char resolved_a[PATH_MAX], resolved_b[PATH_MAX];
const char *use_a = realpath(a, resolved_a) != NULL ? resolved_a : a;
const char *use_b = realpath(b, resolved_b) != NULL ? resolved_b : b;
return strcmp(use_a, use_b) == 0;
}

void scr_lib_init(int argc, char **argv) {
scr_lib_argc = argc;
scr_lib_argv = argv;
scr_lib_skip_reexec_arg = argc >= 2 && scr_lib_same_executable_arg(argv[0], argv[1]);
atexit(scr_lib_cleanup);
}

int scr_lib_arg_count(void) { return scr_lib_argc - (scr_lib_skip_reexec_arg ? 1 : 0); }
const char *scr_lib_arg(int i) {
return scr_lib_argv[i + (scr_lib_skip_reexec_arg && i > 0 ? 1 : 0)];
}
​```

The actual trigger is "argument resolves via realpath to the same file as the running binary," not "argument text equals binary name." Any CLI that takes filenames as arguments is exposed to this if run from its own directory with a coincidentally matching filename — this isn't limited to deliberately passing the binary's own name.

This concludes what i found. And Thank you.

Environment

  • scriptc version: 0.0.33

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions