I recently encountered a puzzling issue: zig build worked perfectly, but make build failed with "Permission denied". What followed was an interesting debugging session that uncovered a quirky interaction between GNU Make's PATH search and directory structures.
This article documents the investigation process and some debugging techniques that proved useful along the way.
I had the world's simplest Makefile:
Running zig build directly? Works great:
# ... builds successfully ...
Running make build? Hmm, not so much:
Permission denied? That seemed odd. The zig executable definitely had execute permissions, and I could run it just fine from the shell. Something else was going on here.
My first thought was to check the Makefile itself. Make's requirement for tabs instead of spaces has caught me before, so I verified:
The ^I shows it's a proper tab (not spaces), and the $ shows line endings. Everything looks correct.
I also verified that zig was properly in my PATH and executable:
Everything looked correct. The zig command worked fine when called directly, so the issue had to be specific to how Make was executing it.
When something works in one context but not another, strace becomes invaluable. It shows every system call a program makes, which is perfect for understanding these kinds of environmental differences.
|
The flags serve specific purposes:
-f: Follow child processes (necessary since Make spawns shells)-e trace=...: Filter to only show specific system calls2>&1: Redirect stderr to stdout for easier greppingThe output revealed the issue:
||| =
=
=
=
This was surprising: Make was trying to execute /home/sam/bin/zig, but which zig returned /home/sam/bin/zig/zig. I needed to check what was actually at /home/sam/bin/zig:
Ah! /home/sam/bin/zig was a directory containing the zig executable. Make was attempting to execute the directory itself, which explained the permission denied error.
This suggested a PATH configuration issue. I examined my PATH more closely:
| |
# ... and many more ...
Aha! Both /home/sam/bin and /home/sam/bin/zig are in my PATH. When Make searches for zig, it's finding the directory /home/sam/bin/zig before it finds the actual executable at /home/sam/bin/zig/zig.
But wait, what's actually in /home/sam/bin?
So I had a zig directory inside /home/sam/bin/. When Make searched PATH, it was finding this directory and mistaking it for the executable.
But why did it work when running zig directly? I traced the shell's behavior to compare:
|
=
=
The shell found the correct executable at /home/sam/bin/zig/zig. Clearly, Make and the shell were using different PATH search algorithms.
I checked which shell Make was using:
|
Make was using dash (Debian Almquist Shell) by default. I tested whether dash itself had the same issue:
Dash found zig correctly when run directly. The issue was specific to Make's command execution mechanism.
To better understand PATH searching behavior, I wrote a small C program to test how access() behaves with directories:
int
The output was revealing:
&& |
Both paths showed execute permissions, as expected — directories have execute permission to allow traversal, not because they can be executed as programs.
I tested how the standard C library's execvp function handles this situation:
int
&&
So execvp worked correctly, which meant Make was implementing its own PATH search rather than using the standard
library.
I also considered using ltrace to trace library function calls and see whether Make was calling execvp or similar
functions, but it wasn't installed on this system. ltrace is complementary to strace - while strace shows system
calls (kernel interface), ltrace shows library function calls (like execvp, malloc, printf). It would have
quickly revealed whether Make was using standard library functions or implementing everything from scratch.
At that point i needed more test data, I created various test Makefiles to better understand Make's behavior:
# test2.mk
Notice that which -a found the correct zig, but Make still failed. I tested different execution methods:
# test3.mk
When Make explicitly invoked a shell with -c, it worked. The failure only occurred when Make tried to execute commands directly.
I also tried Make's debug mode to get more insight:
|
While not particularly revealing, this confirmed that Make was attempting to execute zig build directly without shell interpretation.
After all this investigation, here's what's happening:
execvp)access(path, X_OK) succeeds/home/sam/bin/zig is a directory with execute permission (for traversal), Make thinks it found the executableexecve() the directory, which fails with EACCESThis behavior could be considered a bug in GNU Make. From what I understand the POSIX specification indicates that implementations should skip non-regular files, but Make doesn't seem to follow this guidance. Given that this behavior has persisted for decades, it's unlikely to change, and since it's been this way for decades, it's more accurately described as a long-standing quirk.
# Success!
This works because bash handles PATH searching correctly.
# Success!
While all of these workarounds functioned correctly, they were addressing the symptom rather than the root cause.
During the investigation, I employed several other debugging techniques worth documenting:
To rule out hidden character issues in the Makefile, I used hexdump:
||
||
The 09 represented a tab character (required by Make), and 0a represented newlines. No hidden characters were present.
Sometimes files have extended attributes that can cause weird issues:
The file was a standard ELF executable with no unusual attributes.
Sometimes environment variables can affect behavior:
|
# (no output - no MAKE-specific environment variables set)
# This works, confirming SHELL is the key variable
strace is your best friend - When something works in one context but not another, trace the system calls. The -f flag is crucial for following child processes.
PATH conflicts are sneaky - Having a directory with the same name as an executable in your PATH is asking for trouble. Different tools handle it differently.
Make is weird - Make does its own thing instead of using standard library functions. This leads to surprising behavior.
"Permission denied" lies - It doesn't always mean file permissions. Sometimes it means you're trying to execute something that's not a regular file.
The proper solution is to clean up the PATH configuration. Having both /home/sam/bin and /home/sam/bin/zig in PATH when there's a zig directory inside /home/sam/bin creates this ambiguity.
Several solutions are available:
/home/sam/bin from PATH (though I need other executables from there)/home/sam/bin/zig from PATH and create a symlink elsewheremv /home/sam/bin/zig /home/sam/bin/zig-distSHELL := /bin/bash to Makefiles that need itOption 3 or 4 seem most practical for my use case.
This was one of those bugs that initially seemed impossible. How could Make fail to find an executable that clearly existed? Through systematic debugging, the root cause emerged: an interaction between Make's simplistic PATH search algorithm and an ambiguous PATH configuration.
The debugging process proved quite educational. Each tool provided a different perspective on the problem:
Next time you hit a weird "works here but not there" bug:
So if Make ever reports "Permission denied" on an executable you know works elsewhere, consider whether it might be attempting to execute a directory. It's more common than you might think!