blog · git · desktop · images · contact & privacy · gopher


Von relativen, absoluten und unvollständigen Pfaden

Gibt man Pfade zu Dateien an, kann man das so tun:

touch foo
rm ./foo
vim ../mein_skript.sh

Dabei meint "foo" die Datei "foo" im aktuellen Verzeichnis und "../mein_skript.sh" ist auch relativ zum aktuellen Verzeichnis gemeint, ebenso "./foo". So weit, so einfach. Möchte man aber Programme ausführen, muss zwingend ein vollständiger Pfad angegeben werden:

./mein_skript.sh
/usr/bin/vim

Kurze Definition, da der Begriff "vollständiger Pfad" nicht so üblich ist: Ich meine damit einen Pfad, bei dem -- wenn das aktuelle Verzeichnis bekannt ist -- unmissverständlich klar ist, welche Datei gemeint ist. Bei "./foo" ist das der Fall, denn der Punkt bezeichnet das aktuelle Verzeichnis. Da gibt es keinen Interpretationsspielraum. Bei nur "foo" ist das aber eigentlich gar nicht so klar und es muss eine Konvention eingeführt werden.

Zurück zum Beispiel: Wird hier auf "./" oder "/usr/bin/" verzichtet, dann ist nicht mehr klar festgelegt, welche Datei gemeint ist -- der Pfad ist also unvollständig. Ist eine Datei im aktuellen Verzeichnis gemeint? Oder wo ganz anders? In diesem Fall wird tatsächlich der Inhalt von "$PATH" angeschaut und die dortigen Verzeichnisse nach der Datei durchsucht. Das aktuelle Verzeichnis wird nur betrachtet, wenn es explizit in "$PATH" an geeigneter Position auftaucht.

Wer schon einmal mit der Shell Kontakt hatte, für den ist das alles kalter Kaffee. Man lernt das ganz schnell. Trotzdem ist es inkonsistent. Bei "touch foo" wird "touch" ausschließlich über "$PATH" bestimmt, dagegen bezieht sich "foo" ausschließlich auf das aktuelle Verzeichnis. Beides sind aber Pfadangaben.

Warum ist das so? Müssen letztendlich nicht beide Dateien über etwas wie "open()" geöffnet werden? Oder läuft das bei "exec()" ganz anders ab? Warum kann bei "foo" die Angabe "./" ausgelassen werden? Das könnte genauso gut Pflicht sein. Wer sucht "$PATH" ab? Welche weiteren Mechanismen gibt es vielleicht sonst noch und wo ist all dies jeweils implementiert? Zeit, diesen Fragen auf den Grund zu gehen.

Dateien mit "open()" öffnen

Folgendes Minimalbeispiel:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main(int argc, const char *argv[])
{
    int fd = open("foo", O_RDONLY);
    if (fd == -1)
    {
        perror("open() failed");
        return 1;
    }
    printf("File opened.\n");
    close(fd);
    return 0;
}

"open()" ist also der fragliche Syscall, dem einfach nur "foo" übergeben wird. Über die Syscall-Table gelangt man (wie zu erwarten) zu "sys_open", was sich in fs/open.c wiederfindet und auf "do_sys_open()" in derselben Datei verweist.

Dort springt einem zwar "getname()" ins Auge, aber die tatsächliche Magie spielt sich in "do_filp_open()" in fs/namei.c ab. Nach ein paar "if"-Statements findet sich in dieser Funktion der Kommentar "find the parent" und darunter ein Call zu "path_init()".

Dort sieht man nun, dass, wenn der Parameter "dfd" auf "AT_FDCWD" gesetzt ist, das aktuelle Arbeitsverzeichnis als Basis verwendet wird. "dfd" ist kurz für "directory file descriptor". Zur "current"-Variable steht übrigens hier ein Wort -- sie ist ein struct vom Typ task_struct und enthält den Kontext des aktuellen Prozesses. Ist "dfd" also entsprechend gesetzt? Zurück zum ursprünglichen "open()"-Syscall. Ja, hier wird "AT_FDCWD" übergeben. Damit ist für jeden "open()"-Aufruf sicher, dass sich relative Pfade auf das aktuelle Verzeichnis beziehen -- auch unvollständige.

– Nachtrag vom 12.04.2011 zu "current": Der oben verlinkte Artikel ist von 2002 und bezieht sich damit wohl auf Linux 2.4. Wie ich eben im Buch "Understanding the Linux Kernel" (3. Ausgabe, Seite 86 f.), das Linux 2.6.11 behandelt, gelesen habe, stimmt obige Beschreibung so nicht mehr. "current" ist keine Variable mehr, sondern ein Makro (und das ist heute in Linux 2.6.38 immernoch so, siehe "arch/x86/include/asm/current.h" und "arch/x86/include/asm/thread_info.h"). Die Bedeutung ist aber immernoch dieselbe. Details zu diesem Makro möchte ich in diesem Nachtrag nicht auch noch einwerfen, sondern verweise mal ganz frech auf's Buch. Das ist ohnehin hochinteressant und sehr empfehlenswert.

Übrigens bezieht sich das nur auf den ersten Startpunkt. Relative Angaben wie "." oder ".." werden danach in "link_path_walk()" weiterverarbeitet, was kurz nach "path_init()" aufgerufen wird.

Neben "open()" gibt es aber auch noch "openat()", das benutzt man bloß in der Regel nicht. Hier ist "AT_FDCWD" nicht fest verdrahtet, sondern man kann einen beliebigen Verzeichnis-Deskriptor übergeben. Relativ zu diesem wird dann die Datei geöffnet. Folgendes Beispiel würde "/tmp/foo" öffnen:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main(int argc, const char *argv[])
{
    int dirfd = open("/tmp", O_RDONLY);
    if (dirfd == -1)
    {
        perror("open(dir) failed");
        return 1;
    }

    int fd = openat(dirfd, "foo", O_RDONLY);
    if (fd == -1)
    {
        perror("openat(file) failed");
        return 1;
    }

    printf("File opened.\n");

    close(fd);
    close(dirfd);

    return 0;
}

Dateien mit der "exec()"-Familie ausführen

Wie bereits bekannt (siehe Posting zum Shebang-Mechanismus), ist "do_execve()" in fs/exec.c der "point of interest", wenn es um das Ausführen von Dateien geht. Jeder mögliche "exec()"-Call landet hier. Dort findet sich dann ein Call zu "open_exec()" in derselben Datei, wo sich wieder "do_filp_open()" findet. Whoops. Dieser Call wird auch mit "AT_FDCWD" ausgeführt. Hieße das etwa, dass in "execve()" auch immer das aktuelle Verzeichnis als Basis verwendet wird? Ja, heißt es:

#include <stdio.h>
#include <unistd.h>

int main(int argc, const char *argv[])
{
    char *args[] = { "test.sh", NULL };
    char *envs[] = { NULL };

    if (execve("test.sh", args, envs) == -1)
    {
        perror("execve()");
        return 1;
    }

    return 0;
}

Befindet sich im aktuellen Verzeichnis eine ausführbare Datei namens "test.sh", dann wird diese auch tatsächlich ausgeführt.

Etwas erhellend ist es, einmal RTFM auszuführen. In "man 3 execve" (Achtung, nicht "man 2 execve") werden nämlich auch die Varianten "execlp()" und "execvp()" erwähnt. Diese suchen nun "$PATH" ab, das heißt, hier wird ein systemweites "test.sh" gesucht:

#include <stdio.h>
#include <unistd.h>

int main(int argc, const char *argv[])
{
    if (execlp("test.sh", "test.sh", NULL) == -1)
    {
        perror("execlp()");
        return 1;
    }
    return 0;
}

Höchstwahrscheinlich wird keines gefunden und es kommt zum Fehler. In diesem Fall muss "./test.sh" explizit angegeben werden, um "test.sh" im aktuellen Verzeichnis zu starten.

Man kann festhalten: Die Inkonsistenz ist keine grundlegende Eigenschaft, sondern tritt erst "in Sonderfällen" auf, wenn bewusst "$PATH" verwendet werden soll. Ansonsten verhält sich "exec()" ohne "p" wie "open()".

Weil das heutzutage so ein großes Thema ist: Man könnte vorschnell auf die Idee kommen, dass ein unvorsichtiges Benutzen von "execve()" oder den anderen Varianten ohne "p" ein ähnliches Einfallstor öffnet -- schließlich werden Dateien aus dem aktuellen Arbeitsverzeichnis geöffnet. Dem ist aber mitnichten so. Denn will man "ls" starten, kann man nicht folgendes schreiben:

...
execl("ls", "ls", "-al", NULL);
...

Dieser Aufruf würde zwar "ls" im aktuellen Verzeichnis starten, es gibt aber keinen Fallback-Mechanismus, falls es dort kein "ls" gibt. Man kann so nicht ungewollterweise das richtige "ls" starten. Man müsste explizit "/bin/ls" starten -- und dann ist wieder ausgeschlossen, dass eine Datei im aktuellen Verzeichnis gemeint ist. Also gilt für die "exec()"-Varianten ohne "p":

Die "p"-Varianten sind also nur Hilfsfunktionen für den Fall, dass man nicht genau weiß, wo sich Binaries befinden, und dies vom System über "$PATH" herausgefunden haben möchte. Sie führen aber niemals Dateien aus dem aktuellen Verzeichnis aus, es sei denn, das ist explizit gewünscht (was nicht der Standard ist).

Bleibt die Frage zu klären, wo die "p"-Varianten implementiert sind und wer "$PATH" abläuft. Leider bleibt einem hier nicht erspart, in der glibc mit der Suche zu starten. Da "execlp()" nur ein Frontend für "execvp()" ist, beginnt man besser mit letzterem. Über posix/execvp.c wird man an "__execvpe()" verwiesen, was in posix/execvpe.c ausprogrammiert ist.

Diese Funktion prüft zuerst, ob der übergebene Pfad einen Schrägstrich enthält. Da solche nicht in Dateinamen erlaubt sind und immer Verzeichnistrenner sind, ist also klar, dass es sich um einen vollständigen Pfad handeln muss. Dann wird "$PATH" gar nicht angerührt.

Nur dann, wenn es keinen Schrägstrich gibt, wird "$PATH" ausgelesen. Die Funktion ist dann ganz pragmatisch: Sie baut nacheinander eine Pfadangabe aus einem Element aus "$PATH" und dem übergebenen Dateinamen zusammen. Das wird an "__execve()" übergeben. Schlägt dieser Aufruf fehl, wird einfach das nächste Element aus "$PATH" verwendet. Danach endet die Funktion aber -- es gibt definitiv kein Fallback, um eben doch eine Datei im aktuellen Verzeichnis auszuführen, wenn sie im Pfad nicht gefunden wurde (andersherum auch nicht).

Einzig für den Fall, dass eine Datei ausführbar ist, vom Kernel aber nicht gestartet werden konnte, gibt es einen Ausnahmefall: Dann wird "eine Shell" verwendet, um die Datei zu interpretieren. Relevant ist hierbei der Wert von "_PATH_BSHELL", was für Linux in der glibc als "/bin/sh" definiert ist. Warum man das tut, ist mir -- ehrlich gesagt -- schleierhaft. Zumindest die Shell des Nutzers könnte man verwenden, aber sauberer fände ich es, in diesem Fall einfach mit einem Fehler abzubrechen. "execl()" tut das übrigens: Hier gibt es keinen "och, probieren wir's mal mit einer Shell"-Fallback.

Fazit

Bezüglich "open()":

Bezüglich der "exec()"-Varianten ohne "p":

"exec()"-Varianten mit "p":