Fichiers et dossiers

En Rust, les lectures et écriture dans les fichiers se font via l’infrastructure standard pour les flux d’octets (Read, Write et Seek). Mais il y a aussi plusieurs utilitaires supplémentaires pour gérer les chemins de fichiers de façon portable, explorer l’arborescence des dossiers, consulter les métadonnées, et simplifier quelques opérations courantes. C’est l’objet de ce chapitre.

Chemins de fichiers

La manipulation portable de chemins de fichiers est plus difficile qu’il n’y paraît. Quelques exemples :

  • Les préfixes varient entre systèmes d’exploitation. Un chemin de fichier Unix absolu commence par /, alors que sous Windows on trouve d’autres choses comme C:\ et \\server\share.
  • Les séparateurs varient entre systèmes d’exploitation. Unix utilise toujours /, là où Windows privilégie \ et peut accepter aussi / ou pas selon l’API que vous êtes en train d’utiliser.
  • La sensibilité à la casse varie. Unix y est sensible, mais Windows ne l’est pas, et les comparaisons de chemins devraient en tenir compte.
  • L’encodage varie entre systèmes d’exploitation. Unix utilise des séquences d’octets non nuls (généralement de l’UTF-8) là où Windows utilise des entiers 16-bits (généralement de l’UTF-16).
  • Il est possible de transformer toute chaîne de caractère ne contenant pas de caractère nul vers l’encodage attendu par le système d’exploitation. En revanche, un chemin de fichier issu du système d’exploitation peut contenir de l’Unicode malformé et ne correspond donc pas nécessairement à une chaîne de caractère Unicode valide.

Rust fournit deux outils pour gérer ces différences entre systèmes d’exploitation :

  • Une gestion générale des formats de chaînes de caractères spécifiques à chaque système d’exploitation, avec leur Unicode potentiellement malformé et potentiellement pas en UTF-8, via les types std::ffi::OsStr et OsString. Ceux-ci peuvent être convertis depuis et vers les chaînes de caractère UTF-8 standard de Rust avec une gestion des erreurs.
    • Ces types n’ont pas d’équivalent en C++, ils sont pourtant très pratique dès qu’on interagit avec des APIs des systèmes d’exploitation autres que le système de fichiers.
  • Une gestion des chemins de fichiers basée sur cette gestion des chaînes de caractères OS, via les types std::path::Path et PathBuf. C’est très similaire au type std::filesystem::path enfin introduit par C++17, sauf que…
    • On a la distinction Path/PathBuf en Rust, qui est analogue à la distinction string_view/string en C++ : on peut manipuler des (fragments de) chemins sans avoir besoin de faire une allocation mémoire par fragment.
    • Ces types exposent un certain nombre de méthodes qui simplifient certains accès au système de fichier (pour résoudre les symlinks et chemins relatifs, interroger les métadonnées de la cible, etc.), là où en C++17 ces fonctionnalités sont uniquement accessibles via des fonctions libres.

Voici un exemple d’utilisation de Path :

#![allow(unused)]
fn main() {
// Accès au répertoire de travail via std::env (cf chapitre ultérieur)
let repertoire_travail =
    std::env::current_dir()
             .expect("Accès au répertoire de travail refusé");

// Affichage du chemin dans la console
println!("Répertoire de travail : {repertoire_travail:?}");

// Itération sur les composantes du chemin
for fragment in repertoire_travail.components() {
    println!("- {fragment:?}");
}

// On vérifie que c'est bien un dossier
assert!(repertoire_travail.is_dir());
}

Toutes les APIs qui acceptent des chemins de fichiers sont génériques de façon à accepter &str, String, OsStr, OsString, Path, PathBuf, etc. Vous n’êtes donc pas forcés d’utiliser Path si vous écrivez du code non portable où une chaîne de caractère convient comme chemin de fichier. Et vous pouvez récupérer un chemin de fichier d’une API OS et l’utiliser directement sans devoir passer par une conversion intermédiaire depuis et vers &str.

Système de fichiers

La bibliothèque standard C ne supporte qu’un faible nombre d’opérations sur les fichiers : à part ouvrir des fichiers, on peut les supprimer, les déplacer, créer des fichiers temporaires, et c’est tout.

C’est loin de répondre aux besoins courants de manipulation de fichiers. Par exemple, il est tout aussi courant de vouloir…

  • Canonicaliser des chemins de fichiers (résoudre les chemins relatifs et symlinks pour obtenir une forme normalisée)
  • Créer des dossiers, lister leurs contenus, les supprimer.
  • Créer des hardlinks et liens symboliques, les différencier du fichier vers lequel ils pointent.
  • Interroger des métadonnées telles que les permissions d’accès et les dates de création/dernière modification/dernier accès.

…mais rien de tout ça n’est possible en C standard, il faut se tourner vers des APIs spécifiques à chaque système d’exploitation.

Lorsque le comité de normalisation C++ a tenté de relever le niveau, il a réussi l’exploit douteux de produire la seule API de la bibliothèque standard C++17 qu’il est presque impossible utiliser sans risque de comportement indéfini. En effet, la documentation de std::filesystem commence par poser une contrainte de validité sur les programmes qui utilisent cette API…

The behavior is undefined if the calls to functions in this library introduce a file system race, that is, when multiple threads, processes, or computers interleave access and modification to the same object in a file system.

…et sur tous les systèmes d’exploitation courants, c’est une contrainte qu’un programme ne peut pas respecter avec certitude, puisqu’il est impossible de contrôler ce que les autres programmes en cours d’exécution vont faire avec le système de fichiers en parallèle.

Heureusement, Rust est là pour relever le niveau : le module std::fs de la bibliothèque standard Rust, qui fournit une fonctionnalité à peu près équivalente au std::filesystem de C++17, définit précisément à quelles fonctions des différents systèmes d’exploitation il fait appel, ce qui permet de se référer à la documentation du système d’exploitation et du système de fichiers utilisé pour savoir ce qui va se passer dans ce genre de cas tordu. Le comportement en cas d’accès concurrent est donc non portable, mais bien défini, et le programme reste valide : c’est plus raisonnable…

Voici un exemple d’utilisation de std::fs :

#![allow(unused)]
fn main() {
use std::time::{Duration, SystemTime};

// Equivalent de mkdir -p
std::fs::create_dir_all("abc/def")
        .expect("Echec de création récursive de dossiers");

// On vérifie que la date de création est cohérente
let creation = std::fs::metadata("abc/def")
                       .expect("Echec de lecture des métadonnées")
                       .created()
                       .expect("Echec de lecture de la date de création");
let maintenant = SystemTime::now();
assert!(
    maintenant >= creation,
    "Erreur: Le fichier a été créé avant la mesure de l'heure ! \
     (maintenant {:?} < creation {:?})",
    maintenant,
    creation
);

// Equivalent de rm -r
std::fs::remove_dir_all("abc")
        .expect("Echec de suppression du dossier");
}

Ouverture, accès, fermeture

En Rust, les fichiers sont représentés par le type std::fs::File, analogue au type FILE de C. Par rapport à ce dernier, le type File de Rust a une API de construction un peu plus élaborée que le fopen() du C, ce qui simplifie les utilisations courantes :

  • File::open() ouvre un fichier en lecture seule.
  • File::create() ouvre un fichier en écriture. Si le fichier existe déjà, son contenu est effacé, sinon un nouveau fichier est créé.
  • File::options() donne accès à l’API plus complète OpenOptions, qui utilise une conception de type builder pattern et offre la même flexibilité que les chaînes de caractères de fopen(), la sûreté de typage et la lisibilité en plus.

Une fois qu’on a ouvert un fichier, on a accès à l’API Read/Write/Seek usuelle de std::io, mais aussi à quelques méthodes spécifiques aux fichiers qui permettent de…

  • Accéder aux métadonnées sans passer par le système de fichier, via file.metadata().
  • Réduire la taille du fichier ou prévenir le système d’exploitation qu’il aura une certaine taille à terme, via la méthode file.set_len() qui fonctionne comme le ftruncate() de POSIX.
  • Changer les permissions d’accès via la méthode file.set_permissions(), à la manière du fchmod() de POSIX.
  • Demander au système d’exploitation de s’assurer que les données et métadonnées aient été écrites sur le stockage sous-jacent avant de continuer, avec file.sync_all() et file.sync_data() qui fonctionnent comme les fsync() et fdatasync() de POSIX.

Bref, les capacités de File sont plus proches de celles de POSIX que de celles de la bibliothèque standard C, ce qui est bien pratique quand on veut écrire du code portable qui manipule des fichiers de façon non triviale (bases de données, code ayant des contraintes de sécurité, etc.).

Comme en C++, les fichiers sont automatiquement fermés quand ils sortent du scope. Mais cette façon de faire ne permet pas de détecter et gérer les erreurs d’écriture soulevées par fclose(), ce qui peut arriver dans quelques cas tordus. Il est donc préférable d’appeler file.sync_all() quand on en a terminé avec un fichier pour gérer ces erreurs aussi.

Raccourcis

La chose la plus courante qu’on puisse vouloir faire avec un fichier, c’est écrire ou lire la totalité du fichier. Rust fournit donc des raccourcis pour ces tâches courantes avec les fonctions std::fs::read(), read_to_string() et write() :

#![allow(unused)]
fn main() {
// write() permet d'enregistrer du texte ou une autre séquence d'octets
const NOM_FICHIER: &str = "test.txt";
std::fs::write(NOM_FICHIER, "J'ai écrit des trucs dans un fichier")
        .expect("Echec de l'écriture du fichier");

// read() permet de rélire la séquence d'octets
println!(
    "Octets du fichier : {:02x?}",
    std::fs::read(NOM_FICHIER).expect("Echec de lecture des octets")
);

// read_to_string() traite les octets comme de l'UTF-8 (avec validation)
println!(
    "Texte du fichier : {}",
    std::fs::read_to_string(NOM_FICHIER).expect("Echec de lecture de texte")
);

std::fs::remove_file(NOM_FICHIER).expect("Echec de nettoyage");
}