Environnement

Notre tour des fonctionnalités de programmation système de Rust va se terminer vers le module std::env. Comme vous pouvez le deviner, il permet notamment de manipuler des variables d’environnement. Mais on y trouve aussi d’autres spécificités des systèmes d’exploitation sur lesquelles un programme Rust est susceptible de s’exécuter, qui n’ont pas trouvé leur place ailleurs : arguments d’un exécutable, répertoire de travail, etc.

Variables d’environnement

En C++, le seul utilitaire standard disponible pour interroger l’environnement est std::getenv(), une fonction qui prend un nom de variable d’environnement en paramètre et retourne un pointeur vers la valeur de la variable en sortie. Si la variable n’a pas de valeur, le pointeur retourné est nul.

Cette conception a plusieurs conséquences :

  • On n’a pas accès à la liste complète des variables d’environnement. Il faut utiliser pour ça des utilitaires spécifiques à chaque OS, comme la variable globale environ et le paramètre optionel envp sur les systèmes POSIX.
  • On ne peut pas modifier une variable d’environnement de façon portable. Il faut utiliser pour ça des utilitaires spécifiques à chaque OS, comme la fonction setenv() de POSIX.
  • L’une des utilisations les plus courantes des variables d’environnement est la famille des variables PATH. Mais la bibliothèque standard C++ ne fournit aucun utilitaire pour les manipuler d’une façon indépendante du système d’exploitation.
  • La fonction getenv() ne peut pas être utilisée de façon sécurisée dans un programme multi-thread, car rien n’empêche un autre thread d’appeler des fonctions comme setenv() en parallèle, et la conception de getenv() ne permet pas de synchroniser implicitement les accès aux variables d’environnement au niveau de l’implémentation de la bibliothèque standard.

En comparaison, l’API fournie par Rust pour manipuler des variables d’environnement est à la fois plus complète et plus sécurisée :

  • On peut lister les variables d’environnement avec vars(), lire une variable unique avec var(), modifier une variable avec set_var(), et supprimer une variable avec remove_var().
  • Par défaut, les fonctions ci-dessus supposent que les variables d’environnement contiennent du texte Unicode valide, tentent de le décoder en UTF-8, et retournent une erreur si ça échoue. On peut accéder aux données brutes des variables d’environnement avec des variantes des fonctions de lecture ayant un suffixe _os, comme vars_os().
  • Les fonctions split_paths() et join_paths() permettent de manipuler une variable d’environnement PATH de façon indépendante du système d’exploitation.
  • Les accès aux variables d’environnement par la bibliothèque standard Rust sont synchronisés entre threads. Le comportement indéfini observé en C++ n’est malheureusement pas complètement éliminé, mais n’est possible que si les threads utilisent directement les fonctions de l’OS sans passer par la bibliothèque standard Rust, ce qui réduit le risque d’accident.

Voici un exemple de manipulation de variables d’environnements en Rust :

#![allow(unused)]
fn main() {
println!("Mes variables d'environnement :");
for (cle, valeur) in std::env::vars() {
    print!("- {cle} : ");
    if cle.ends_with("PATH") {
        let chemins = std::env::split_paths(&valeur).collect::<Vec<_>>();
        println!("{chemins:#?}");
    } else {
        println!("{valeur:?}");
    }
}
}

Arguments et chemin d’exécutable

En C++, la manière normale d’accéder aux arguments du programme est d’utiliser les arguments spéciaux argc et argv de la fonction main(), qui contiennent respectivement le nombre d’arguments et un pointeur vers un char** contenant les valeurs des arguments. L’implémentation de la bibliothèque standard C peut mettre un pointeur nul en premier argument, si elle ne le fait pas elle est censée y mettre le nom du programme.

En résumé…

  • On doit manipuler un tableau C, avec le risque habituel de se tromper sur l’itération et de lire en-dehors des bornes du tableau.
  • On doit gérer la possibilité qu’il y ait des pointeurs nuls dans la liste des arguments.
  • Seul l’exécutable peut avoir accès à la liste des arguments. Une bibliothèque ne peut y avoir accès que si on lui transmet argc et argv ou utilise des extensions spécifiques à un OS.
  • La liste des arguments est modifiable, et on ne peut pas en dépendre dans du code opérant sous contraintes de sécurité. Par exemple, si un programme setuid veut exécuter récursivement une copie de lui-même, il ne peut pas utiliser argv[0] pour savoir comment il s’appelle, car c’est une donnée sous le contrôle de l’attaquant qui appelle le programme.
  • Plus généralement, le premier argument est difficile à interpréter car…
    • Il peut contenir un chemin absolu ou relatif.
    • Si le programme a été invoqué via un symlink, il peut contenir le nom du symlink ou le nom du programme (donc celui de la cible du symlink).
    • Si le programme est renommé, il ne correspondra plus au réel chemin vers le programme sur le système de fichiers.

La bibliothèque standard Rust ne peut pas corriger l’ensemble de ces problèmes, car certains se situent au niveau du contrat d’interface de base entre la libc et les programmes. Mais elle en corrige autant que possible :

  • On itère sur les arguments via l’itérateur haut niveau args() ou sa variante bas niveau args_os(), la nuance étant identique à celle entre vars() et vars_os().
  • Les pointeurs nuls sont gérés par l’implémentation de la bibliothèque standard, le programme Rust ne voit que des String (éventuellement vides).
  • Les arguments sont accessibles en tous points du programme et ne peuvent pas être modifiés via la bibliothèque standard.
  • Les risques liés à l’interprétation du premier argument sont documentés, et une fonction dédiée current_exe() est disponible pour tenter de déterminer le chemin absolu vers l’exécutable du programme (même si on ne peut toujours pas en dépendre dans du code opérant sous contraintes de sécurité).

Voici un exemple d’utilisation :

#![allow(unused)]
fn main() {
print!("Commande utilisée, selon la libc : ");
for arg in std::env::args() {
    print!("{arg} ");
}
println!();

println!("Chemin d'exécutable, selon la libc : {:?}",
         std::env::current_exe());
}

Répertoires spéciaux

Jusqu’en C++17, il n’y avait pas de manière standard de lire et modifier le répertoire de travail du processus actif en C++, ni de connaître le chemin du répertoire standard des fichiers temporaires.

Dès sa première version stable en 2015, Rust a résolu ces problèmes avec les fonctions current_dir(), set_current_dir() et temp_dir() de std::env. En 2017, C++ a rattrapé ce retard en intégrant des fonctions similaires à sa nouvelle API std::filesystem.

#![allow(unused)]
fn main() {
println!("Répertoire de travail : {:?}", std::env::current_dir());
println!("Répertoire temporaire : {:?}", std::env::temp_dir());
}

Au moment de la sortie de Rust v1, ça avait aussi semblé être une bonne idée de fournir l’emplacement du répertoire de travail de l’utilisateur avec home_dir(). Mais un examen plus approfondi survenu après la stabilisation de Rust a révélé que la méthode utilisée pour obtenir cet emplacement était moins fiable que prévue sous Windows (notamment en présence de couches d’émulation Unix comme MinGW et Cygwin), et qu’il n’existait pas de méthode fiable sur cet OS.

La fonction home_dir() est donc aujourd’hui dépréciée et ne devrait plus être utilisée. A la place, il est recommandé de lui préférer des bibliothèques dédiées comme dirs.

Constantes diverses

Le module std::env contient enfin un sous-module std::env::consts, qui contient quelques constantes spécifiques à la plate-forme cible :

  • ARCH, FAMILY et OS identifient la plate-forme cible en suivant la même syntaxe que les options de configuration cfg!() (voir le chapitre dédié pour plus d’informations). Cela permet de gérer ces options de configuration à l’exécution plutôt qu’à la compilation.
  • EXE_SUFFIX et EXE_EXTENSION indiquent la manière standard de terminer un nom d’exécutable sur l’OS cible, avec ou sans le . d’extension.
  • De même, DLL_PREFIX, DLL_SUFFIX et DLL_EXTENSION indiquent la manière standard de commencer et terminer un nom de bibliothèque partagée sur l’OS cible.
#![allow(unused)]
fn main() {
println!("Compilé pour les CPUs {} et l'OS {} (famille {})",
         std::env::consts::ARCH,
         std::env::consts::OS,
         std::env::consts::FAMILY);

println!("Exemple de nom d'exécutable : echo{}",
         std::env::consts::EXE_SUFFIX);
println!("Exemple de nom de bibliothèque : {}SDL{}",
         std::env::consts::DLL_PREFIX,
         std::env::consts::DLL_SUFFIX);
}