QVINTVS · SCRIBET

Überraschungen mit Rubys Filesystem-Encoding

Rubys Dateisystem-Encoding hält eine etwas irritierende Überraschung für Fälle bereit, in denen man ausnahmsweise mal nicht mit UTF-8 konfrontiert ist.

Ich experimentiere zurzeit mit einem FreeBSD-System. Genau wie bei Linux handelt es sich dabei um ein unixoides System, d.h. ein System, dass sich an den Ideen und Funktionen der ursprünglichen UNIX-Systeme orientiert, was insbesondere die Implementierung des POSIX-Standards einschließt. Die große Mehrheit der heute verfügbaren Betriebssysteme sind Unixoide, mit einer prominenten Ausnahme: Microsoft Windows. Windows implementiert POSIX nur teilweise und heute wohl auch nurnoch aus Kompatibilitätsgründen mit früheren Versionen des Betriebssystems. Diese Sonderrolle hat Entwickler, die ein plattformunabhängig kompilierbares (und danach hoffentlich auch lauffähiges) Programm entwickeln wollen, seit jeher vor Probleme gestellt, worin insofern eine besondere Ironie liegt, alsdass „POSIX“ tatsächlich für „Portable Operating System Interface for UNIX“ steht, also gerade die Aufgabe der Plattformunabhängigkeit erleichtern will.

Ich will aber nicht bloß über Windows und seine Sonderrolle meckern, sondern mich mit einem spezifischen Teilaspekt der Kompatibilitätsdiskussion beschäftigen, nämlich mit Dateisystem-Koderierungen. Linux und sein umgebendes Ökosystem als der prominenteste Vertreter der Unixoiden-Familie setzt seit einer nicht ganz unerheblichen Zeit ausschließlich auf UTF-8 als Zeichenkodierung sowohl für Dateisysteme als auch für die Inhalte von Dateien und die Interaktion mit dem Nutzer; andere Unixoide tun dies oftmals auch, aber nicht immer. So schaut es etwa bei meinem Test-FreeBSD aus:

$ locale
LANG="de_DE.ISO8859-1"
LC_CTYPE="de_DE.ISO8859-1"
LC_COLLATE="de_DE.ISO8859-1"
LC_TIME="de_DE.ISO8859-1"
LC_NUMERIC="de_DE.ISO8859-1"
LC_MONETARY="de_DE.ISO8859-1"
LC_MESSAGES="de_DE.ISO8859-1"
LC_ALL=
$ ruby -e 'puts Encoding.find("filesystem")'
ISO-8859-1

FreeBSD als nicht ganz unbedeutender Vertreter der alternativen Unixoiden zeigt, dass man sich nicht schlafwandlerisch darauf verlassen sollte, dass das Dateisystemencoding auf UNIX-Systemen stets UTF-8 ist. Das ist insofern problematisch, alsdass POSIX nicht zwischen den einzelnen Kodierungen für Dateipfade differenziert. Dort heißt es nur uniform:

#include <sys/stat.h>
#include <fcntl.h>

int open(const char *path, int oflag, ...);

open() reicht, was immer man ihm gibt, so ans Dateisystem durch, ohne Kontrolle, ob die Zeichenkodierung stimmt. Auch der C-Standard als etwas höhere Ebene schafft keine Abhilfe:

#include <stdio.h>
FILE *fopen(const char *path, const char *mode);

Das führt im Ergebnis dazu, dass man unleserliche Dateinamen sehr leicht erzeugen kann:

// -*- coding: utf-8 -*-
#include <stdio.h>

int main(int argc, char* argv[])
{
  FILE* p_file = fopen("Bärenstark.txt", "w");
  fclose(p_file);
  return 0;
}

Auf einem modernen Linux, wo alles UTF-8-kodiert ist, hat das Programm den gewünschten Effekt. Auf FreeBSD entsteht ein unleserlicher Dateiname (Bärenstark.txt), denn das Dateisystem geht von ISO-8859-1-kodierten Dateinamen aus. Auf Windows, was ohnehin nicht POSIX-konform ist, passiert das Gleiche. Windows geht seinen Sonderweg allerdings konsequent zu Ende und stellt mit _wfopen() eine Funktion zur Verfügung, die UTF-16LE-kodierte Strings annimmt, wenn auch leider nicht als Typ char*, sondern als wchar_t*, was einige Extrabehandlung erforderlich macht.

Das ist soweit ein bedauerlicher Zustand, der aber bekannt ist. Interessant ist das, was Ruby daraus gemacht hat. Hier ist der Ruby-Äquivalent zu obigem C-Code:

# -*- coding: utf-8 -*-
f = File.open("Bärenstark.txt", "w")
f.close

Das funktioniert erwartungsgemäß unter Linux (alles UTF-8) immer noch. Aber: es funktioniert auch unter Windows. Unter der Haube nimmt Ruby den in UTF-8 kodierten String und konvertiert in nach UTF-16LE, und reicht ihn außerdem an die korrekten Windows-Sonderfunktionen anstatt an die C-Funktion fopen() bzw. die POSIX-Funktion open() weiter. Es wird kein Kauderwelsch erzeugt, sondern eine korrekt benannte Datei namens Bärenstark.txt.

Nicht aber so unter FreeBSD. Da fällt Ruby genau in dieselbe Problematik wie schon der reine C-Code zuvor. Es wird eine Datei mit dem falschen Dateinamen Bärenstark.txt erzeugt. Was zum…?

Das war die Beobachtung, die ich etwas erstaunt machen musste. Ruby hat eine eingebaute Konvertierung zum Dateisystem-Encoding, aber offenbar recht selektiv. Es funktioniert auf Windows, unter Linux ist es nicht nötig, weil sowieso alles in UTF-8 ist. Für BSD (und vielleicht auch andere Systeme?) gibt es dagegen keine automatische Konvertierung. Ist das nun Absicht? Oder ein Bug? Meine Neugierde war jedenfalls geweckt, und ich habe mir einen Blick auf den Quellcode von Ruby gegönnt — das geht nur, weil es sich um quelloffene Software handelt, wie ich noch einmal deutich betonen möchte. Die folgenden Quellzitate sind aus dem Ruby-Quellcode in der Version 2.1.5.

Die interessante Methode für diesen Zweck ist IO::open, was identisch ist mit File::open. In Ruby verfügbar gemacht wird sie in io.c:

rb_define_singleton_method(rb_cIO, "open",  rb_io_s_open, -1);

Das definiert auf der Klasse IO die Singleton-Methode (= Klassenmethode) open, deren C-Implementation rb_io_s_open() ist. Diese Funktion wiederum erzeugt, wenn aus der Subklasse File aufgerufen (also File.open), eine Instanz der Klasse File, deren #initialize im Wesentlichen so aussieht (immer noch io.c):

static VALUE
rb_file_initialize(int argc, VALUE *argv, VALUE io)
{
  /* ... */
  rb_open_file(argc, argv, io);
  return io;
}

Das ist wenig spannend. Zur Erklärung für diejenigen, die nicht mit Ruby-C-Extensions vertaut sind: io hier ist nichts anderes als self in Ruby, wenn auch etwas misslich benannt. Aber immerhin handelt es sich ja irgendwo um eine Instanz von IO, also vielleicht doch nicht sooo falsch benannt… VALUE ist der Typ für alle Ruby-Objekte von der C-Seite aus gesehen.

Was macht rb_open_file()?

static VALUE
rb_open_file(int argc, const VALUE *argv, VALUE io)
{
    VALUE fname;
    int oflags, fmode;
    convconfig_t convconfig;
    mode_t perm;

    rb_scan_open_args(argc, argv, &fname, &oflags, &fmode, &convconfig, &perm);
    rb_file_open_generic(io, fname, oflags, fmode, &convconfig, perm);

    return io;
}

Es macht nichts weiter, als die Argumente zu zerlegen, insbesondere den Dateinamen an die Variable fname zuzuweisen. Das ist noch immer ein Ruby-String, der nicht weiter verändert wurde. Weiter also zu rb_file_open_generic().

static VALUE
rb_file_open_generic(VALUE io, VALUE filename, int oflags, int fmode, convconfig_t *convconfig, mode_t perm)
{
  rb_io_t *fptr;
  /* ... */
  fptr->mode = fmode;
  /* ... */
  fptr->pathv = rb_str_new_frozen(filename);
  fptr->fd = rb_sysopen(fptr->pathv, oflags, perm);

  /* ... */
  return io;
}

Auch nur Herumgereiche. Der Dateiname wird dupliziert und eingefroren (das ist ein Aufruf an String#freeze). Es handelt sich noch immer um einen gewöhnlichen Ruby-String, dessen Encoding nicht angefasst wurde. Also rb_sysopen().

struct sysopen_struct {
    VALUE fname;
    int oflags;
    mode_t perm;
};

/* ... */

static int
rb_sysopen(VALUE fname, int oflags, mode_t perm)
{
    int fd;
    struct sysopen_struct data;

    data.fname = rb_str_encode_ospath(fname);
    data.oflags = oflags;
    data.perm = perm;

    fd = rb_sysopen_internal(&data);

    /* ... */

    return fd;
}

Da ist was mit Encoding. Der Blick auf die Definition des Structs sysopen_struct zeigt, dass es sich nach der Behandlung durch rb_str_encode_ospath() noch immer um einen Ruby-String (und nicht um einen C-String) handelt, höchstwahrscheinlich werden also nur Rubys eigene Transkodierungsfunktionalitäten genutzt. rb_str_encode_ospath() ist nicht in io.c, sondern in file.c definiert:

VALUE
rb_str_encode_ospath(VALUE path)
{
#ifdef _WIN32
    rb_encoding *enc = rb_enc_get(path);
    rb_encoding *utf8 = rb_utf8_encoding();
    if (enc == rb_ascii8bit_encoding()) {
        enc = rb_filesystem_encoding();
    }
    if (enc != utf8) {
        path = rb_str_conv_enc(path, enc, utf8);
    }
#elif defined __APPLE__
    path = rb_str_conv_enc(path, NULL, rb_utf8_encoding());
#endif
    return path;
}

Und da haben wir den Übeltäter. Diese Funktion verhält sich unter Windows (_WIN32) anders als unter Mac OS X (__APPLE__) bzw. anderen Systemen. Unter Windows setzt diese Funktion alles daran, immer einen in UTF-8 kodierten String zu erzeugen: rb_enc_get() holt das Encoding des Dateinamens, rb_utf8_encoding() gibt immer das Encoding-Objekt für UTF-8 zurück. rb_conv_env entspricht String#encode und transkodiert von welchem Encoding auch immer nach UTF-8. Das hat einen kuriosen Nebeneffekt: Unter Windows muss das Argument für File::open nicht zwingend UTF-8 (oder UTF-16LE) sein. Es kann auch Shift_JIS oder ISO-8859-1 oder UTF32-BE oder sonst irgendein Encoding sein: Ruby wird es immer nach UTF-8 übersetzen, bevor es unter Windows damit weiterarbeitet. Es gibt eine spezielle Ausnahme für Strings, die reines ASCII (Kodierung ASCII-8BIT) sind, die hier aber, da ich mich speziell mit Nicht-ASCII-Strings beschäftige, nicht interessieren soll.

All das findet aber nur unter Windows statt. Unter Mac OS X wird ebenfalls UTF-8 erzwungen, sogar gänzlich ohne Ausnahmen.

Das war es aber auch. Für andere Systeme gibt es keine Ausnahmen und der String, d.i. der Dateiname, wird einfach unbearbeitet wieder zurückgegeben. Das ist der Grund, warum unter FreeBSD mit ISO-8859-1 als Dateisystemkodierung Bärenstark.txt statt Bärenstark.txt erzeugt wird und derselbe Code unter Windows, was doch auch ein anderes Encoding für Dateipfade verwendet (nämlich UTF-16LE), wider Erwarten den korrekten Dateinamen (Bärenstark.txt) erzeugt. Auf Linux wird zwar auch ohne Sonderbehandlung durchgereicht, aber da dort das Dateisystemencoding ja eh UTF-8 ist, ensteht kein Problem in einem UTF-8-Programm. Dieser kleine Ausflug in Rubys Quellcode erlaubt aber folgenden interessanten Rückschluss:

# -*- coding: utf-8 -*-
f = File.open("Bärenstark.txt".encode("ISO-8859-1"), "w")
f.close

Führt man dieses Ruby-Programm unter Linux aus, so ensteht auch dort eine Datei mit unleserlichem Dateinamen. Grund: Wie an der Implementation von rb_str_encode_ospath() gezeigt, wird nur für Windows und Mac OS X zwangsweise nach UTF-8 transkodiert, nicht aber für andere Systeme, eben auch nicht für Linux. Man sollte sich also nicht zusehr davon verwöhnen lassen, dass unter Windows und unter Mac OS X jegliches Encoding automatisch nach UTF-8 transkodiert wird — insbesondere die Sonderbehandlung von Mac OS X dürfte für einen nicht unerheblichen Teil der Rubyisten sowohl überraschend als auch von Bedeutung sein, speziell dann, wenn sie ihren unter OS X lauffähigen Code unbesehen auf UNIX-Systeme portieren, die nicht auf UTF-8 als Dateisystemencoding setzen (etwa FreeBSD).

Um die Analyse bis zum Ende zu führen, sei noch erwähnt, wie rb_sysopen() weiter verfährt. Wie im obigen Codeschnippsel zu sehen, wird als nächstes rb_sysopen_internal() aufgerufen, was als erstes den GVL (Global VM Lock, auch bekannt als GIL, Global Interpreter Lock), löst, sodass andere Ruby-Threads parallel laufen könnten. Innerhalb dieser GVL-freien Zone wird dann rb_cloexec_open() aufgerufen, dessen erster Parameter der nun vorbehandelte — oder eben auch nicht vorbehandelte — Dateiname ist:

int
rb_cloexec_open(const char *pathname, int flags, mode_t mode)
{
    int ret;

    /* ... */

    ret = open(pathname, flags, mode);

    /* ... */
    return ret;
}

Und da ist der Syscall: open(2), die POSIX-Funktion. Tiefer runter geht’s nur noch, wenn wir uns den Kernel-Quellcode angucken. Wirklich? Moment mal, da war doch noch was mit Windows. Das hatte da doch so eine spezielle Funktion, die gar keine char* akzeptiert? Zitat aus der Windows-API-Dokumentation:

/* Quelle: http://msdn.microsoft.com/en-us/library/z0kc8e3z.aspx */
int _wopen(
   const wchar_t *filename,
   int oflag [,
   int pmode]
);

Tatsächlich schummelt Ruby an dieser Stelle. Weiter oben in io.c gibt es einen kleinen Kniff:

/* define system APIs */
#ifdef _WIN32
#undef open
#define open        rb_w32_uopen
#endif

Das ist etwas, auf das man erstmal kommen muss. Statt einer Kompatibilitätsfunktion einfach eine Kernfunktion mit einem Makro überschreiben, halte ich für ziemlich schlechten Stil. Das kommt so unerwartet, dass wer nicht weiß, wonach er suchen muss, sich ganz ernsthaft fragt, wie Ruby denn nun die Win32-API überredet, in open() doch UTF-8-Strings anzunehmen. Dank der Prüfung auf _WIN32 findet das folgende daher nur und ausschließlich unter Windows statt — für alle anderen Plattformen, insbesondere auch für FreeBSD, wird wie beschrieben stattdessen die POSIX-Funktion open() direkt aufgerufen.

Unter Windows geht das Abenteuer daher noch ein wenig weiter. rb_w32_uopen() ist in win32/win32.c definiert und schaut so aus:

int
rb_w32_uopen(const char *file, int oflag, ...)
{
    WCHAR *wfile;
    int ret;
    int pmode;

   /* ...Zuweisung von pmode... */

    if (!(wfile = utf8_to_wstr(file, NULL)))
        return -1;
    ret = rb_w32_wopen(wfile, oflag, pmode);
    free(wfile);
    return ret;
}

Da findet sich die erwartete Konvertierung zu Windows’ eigenem Spezialdatentyp, wchar_t bzw. dessen Alias/Typedef WCHAR. utf8_to_wstr() nimmt den nun zwingend in UTF-8 kodierten Dateinamen, konvertiert ihn nach UTF-16LE und packt das Ergebnis in einen wchar_t-String anstatt eines üblichen char-Strings. Anschließend wird mit dieser Errungenschaft rb_w32_wopen() aufgerufen, was in der Folge dann endlich zur Win32-Systemfunktion _wopen() kommt:

int
rb_w32_wopen(const WCHAR *file, int oflag, ...)
{
    char flags = 0;
    int fd;

    /* ... */

    if ((oflag & O_TEXT) || !(oflag & O_BINARY)) {
      va_list arg;
      int pmode;
      /* ...Zuweisung von pmode... */

        fd = _wopen(file, oflag, pmode);

        if (fd == -1 && errno == EACCES) check_if_wdir(file);
        return fd;
    }

   /* ... */
}

So. Ich hoffe, euch hat dieser kleine Ausflug unter die Haube von File.open gefallen. An die Mac- und Windows-Nutzer unter euch: Verlasst euch nicht darauf, dass File::open jedes Encoding frisst!

Achso, und unter FreeBSD löst sich das Problem mit den unleserlichen Dateinamen so:

# -*- coding: utf-8 -*-
f = File.open("Bärenstark.txt".encode("ISO-8859-1"), "w")
f.close

Nicht eben schön. Aber wie gesagt: Ist das nun ein Bug mit Workaround? Oder beabsichtigtes Verhalten? Unvollständiger MRI-Quellcode? Schwer zu sagen.

Valete.