blog · git · desktop · images · contact


Wo sind meine Dateien?

2017-10-17

Jemand hat mich kürzlich gefragt:

Ich habe da ein Skript in ~/projects/work/foo/bar. Dieses Verzeichnis habe ich in meinen $PATH mit aufgenommen. Mein Skript muss jetzt noch ein paar andere Dateien einlesen, die im selben Verzeichnis liegen. Wie finde ich dieses Verzeichnis?

Das ist eine ziemlich interessante Frage. Im ersten Moment denkt man, dass die Antwort doch leicht zu finden sein müsse – ist sie aber nicht.

Wie immer geht dieser Blogpost davon aus, dass ein UNIX-artiges Betriebssystem benutzt wird.

– Update, 2018-03-24: Teil 2

Die allgemeine und portable Antwort

Die Antwort auf die Frage ist, dass es keine Antwort gibt.

Führt man ein Programm aus, dann gibt es keine Garantie, dass dieses Programm tatsächlich auf der Festplatte liegt. Das kann man leicht zeigen: Man lösche das Programm unmittelbar nach dem Start.

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

int
main()
{
    sleep(5);
    printf("I'm still alive.\n");
    return 0;
}

Man öffne zwei Terminals, starte das Programm im ersten und lösche das Programm im zweiten. Es wird sauber zu Ende laufen.

Anderes Szenario: Was, wenn das Programm über zwei Hardlinks verfügbar ist? Wie ist dann der Pfad „des“ Programms?

Es gibt also keine endgültige Antwort.

„In $foo geht das aber!“

Es gibt Programme, die mit dieser Situation umzugehen versuchen. Den meisten Leuten wird Java ein Begriff sein und sie werden von dort auch kennen, dass das Problem lösbar ist. Man kann ein JRE nämlich irgendwo auf der Platte ablegen, dieses Verzeichnis in den $PATH mit aufnehmen und dann funktioniert das. Es ist auch nicht so, dass Java einfach eine gigantische statisch gelinkte Binary wäre – das muss schon seine JARs und andere Dateien noch lesen. Und, nein, man muss auch nicht $JAVA_HOME setzen, damit es funktioniert.

Wie also geht das?

Was ist mit argv[0]?

Es gibt so eine lose Konvention, die besagt, dass argv[0] den Namen enthalten soll, unter dem ein Programm aufgerufen wurde. Das bringt uns der Antwort ein bisschen näher und „löst“ (eher: „umgeht“) das Problem mit den mehreren Hardlinks. Der Problemfall „Programm gelöscht“ bleibt aber.

Viel wichtiger ist aber, dass es keine Garantie gibt, dass argv[0] tatsächlich den Pfad des Programms enthält:

#include <unistd.h>

int
main()
{
    execl("/bin/ps", "foobar", "-f", NULL);
    return 1;
}

Die Ausgabe sieht dann so aus:

UID        PID  PPID  C STIME TTY          TIME CMD
void      3465  7122  0 08:24 pts/13   00:00:00 -/bin/bash
void      3544  3465  0 08:24 pts/13   00:00:00 vim -p bla.c
void     30336     1  0 08:42 pts/13   00:00:00 xclip
void     30337     1  0 08:42 pts/13   00:00:00 xclip -selection clipboard -f
void     30688  3544  0 08:42 pts/13   00:00:00 foobar -f

Man beachte hier auch -/bin/bash: Dieser zusätzliche Bindestrich am Anfang, der eindeutig nicht Teil des Programmpfads ist, wird von login(1) (und anderen) hinzugefügt, um der Shell mitzuteilen, dass sie doch bitte eine Loginshell sein möge. Das ist also ein Anwendungsfall eines modifizierten argv[0].

Noch ein Hindernis: $PATH

Man kann jetzt argumentieren, dass argv[0] in aller Regel schon den richtigen Pfad enthalten wird. Dass dort etwas Seltsames drinsteht, ist schon eher selten. Man könnte das also ignorieren. Naja, fast.

Wenn man ein Verzeichnis zu $PATH hinzufügt, dann kann man beispielsweise ls statt /bin/ls eingeben. Und genau das ist das Problem: argv[0] wird weiterhin den Namen enthalten, unter dem das Programm aufgerufen wurde, und das ist jetzt eben ls.

Workaround #1: procfs auf Linux

In Linux gibt es ein sehr mächtiges Interface namens procfs. Das ist ein Pseudo-Dateisystem, das in der Regel unter /proc gemountet ist. Es enthält allerlei Informationen über laufende Prozess. Jeder Prozess wird über seine PID identifiziert und ist unter /proc/$pid verfügbar. Es gibt auch noch einen besonderen Symlink: /proc/self. Der zeigt immer auf das Verzeichnis desjenigen Prozesses, der den Link ausliest.

Zu guter Letzt gibt es in /proc/$pid einen weiteren Symlink namens exe. Und der zeigt endlich auf den Pfad, über den der Prozess gestartet wurde.

Man schaue:

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

int
main()
{
    char buf[4096] = "";
    ssize_t len;

    if ((len = readlink("/proc/self/exe", buf, (sizeof buf) - 1)) != -1)
    {
        buf[len] = 0;
        printf("[%s]\n", buf);
        return 0;
    }
    else
        return 1;
}

So findet auch Java unter Linux seine Dateien. Das kann man ganz am Anfang sehen, wenn man es mit strace aufruft:

07:52:14.046676 execve("/opt/jdk-9/bin/java", ["java", "Test"], 0x7ffda7b0fa28 /* 10 vars */) = 0
07:52:14.047496 brk(NULL)               = 0x1114000
07:52:14.047592 readlink("/proc/self/exe", "/opt/jdk-9/bin/java", 4096) = 19

Workaround #2: Manuell den $PATH abklappern

procfs ist nicht auf jedem Betriebssystem verfügbar. Man kann aber das tun, was die Shell (oder der Kernel) gemacht hat, um das Programm zu finden. Warum sollte das auch nicht gehen: Irgendwas hat /bin/ls gefunden, nachdem nur ls eingegeben wurde, also warum sollte unser Programm das nicht auch können?

Java scheint das auf OpenBSD so zu machen:

 93056 java     CALL  stat(0x7f7ffffc73d0,0x7f7ffffc7350)
 93056 java     NAMI  "/sbin/java"
 93056 java     RET   stat -1 errno 2 No such file or directory
 93056 java     CALL  stat(0x7f7ffffc73d0,0x7f7ffffc7350)
 93056 java     NAMI  "/usr/sbin/java"
 93056 java     RET   stat -1 errno 2 No such file or directory
 93056 java     CALL  stat(0x7f7ffffc73d0,0x7f7ffffc7350)
 93056 java     NAMI  "/bin/java"
 93056 java     RET   stat -1 errno 2 No such file or directory
 93056 java     CALL  stat(0x7f7ffffc73d0,0x7f7ffffc7350)
 93056 java     NAMI  "/usr/bin/java"
 93056 java     RET   stat -1 errno 2 No such file or directory
 93056 java     CALL  stat(0x7f7ffffc73d0,0x7f7ffffc7350)
 93056 java     NAMI  "/usr/X11R6/bin/java"
 93056 java     RET   stat -1 errno 2 No such file or directory
 93056 java     CALL  stat(0x7f7ffffc73d0,0x7f7ffffc7350)
 93056 java     NAMI  "/usr/local/sbin/java"
 93056 java     RET   stat -1 errno 2 No such file or directory
 93056 java     CALL  stat(0x7f7ffffc73d0,0x7f7ffffc7350)
 93056 java     NAMI  "/usr/local/bin/java"
 93056 java     RET   stat -1 errno 2 No such file or directory
 93056 java     CALL  stat(0x7f7ffffc73d0,0x7f7ffffc7350)
 93056 java     NAMI  "/usr/local/foobar/bin/java"
 93056 java     STRU  struct stat { dev=1027, ino=155914, mode=-rwxr-xr-x , nlink=1, uid=0<"root">, gid=7<"bin">, rdev=623880, atime=1508220651<"Oct 17 08:10:51 2017">.082047559, mtime=1506962794<"Oct  2 18:46:34 2017">, ctime=1508219904<"Oct 17 07:58:24 2017">.120012249, size=63657, blocks=128, blksize=16384, flags=0x0, gen=0x2049be9e }
 93056 java     RET   stat 0

(Hier und auch oben bei Javas Benutzung von /proc/self/exe ist ein bisschen Vorsicht geboten. Ich habe den Quellcode von Java nicht gelesen, weil der so unglaublich kompliziert ist, beide Ausgaben sind aber starke Indizien dafür, dass Java wirklich so arbeitet. Es geht mir aber auch viel mehr darum, das Durchlaufen von $PATH als möglichen Workaround darzustellen.)

Von den Race-Conditions hier fange ich gar nicht erst an.

Natürlich funktioniert das nur, solange das Programm noch Zugriff auf den originalen Wert von $PATH hat. Man kann das auch mutwillig kaputtmachen.

executor.c:

#include <unistd.h>

int
main()
{
    char *env[] = { "PATH=broken", NULL };
    execle("my-sub-program", "foo", NULL, env);
    return 1;
}

my-sub-program.c:

#include <stdio.h>
#include <stdlib.h>

int
main()
{
    printf("PATH=%s\n", getenv("PATH"));
    return 0;
}

Ausführung:

$ export PATH=$PATH:.
$ executor
PATH=broken

Die Tatsache, dass in der Ausgabe PATH=broken erscheint, heißt, dass das Unterprogramm aufgerufen wurde – es kann aber den originalen $PATH nicht mehr sehen.

Workaround #3: Den gewünschten Pfad hardcoden

Das machen dann letztendlich viele Programm. Häufig wird Software im Quellcode weitergegeben und es gibt dann einen Build-Prozess. Während des Builds kann man leicht einen fest vorgegebenen String in die fertige Binary „injizieren“. Ruft man das Programm dann auf, muss gar nichts mehr „aufgelöst“ werden, sondern es weiß schon, wo es im Dateisystem nachschauen muss.

ratterplatter macht das so:

(Update 2023-08-13: Das ist nicht mehr so, das Programm erwartet jetzt zwingend den Pfad in seinem argv. In diesem Fall ist das eine akzeptable Lösung.)

Oder irssi:

Selbstverständlich gibt es wieder Szenarien, in denen auch dieser Ansatz nicht gangbar ist. Zum Beispiel dann, wenn es nur um ein kleines Skript gibt, das gar keinen Build-Prozess hat – so wie im ursprünglichen Fall desjenigen Menschen, der die Frage stellte.

Und was macht man da jetzt?

Sieht nicht so gut aus. Was auch immer man tut, es ist ein bisschen zerbrechlich.

Es sei denn: Vielleicht übersehe ich hier etwas. Gibt es einen besseren Weg? Ich bin ganz Ohr. :-)

Comments?