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 commeC:\
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
etOsString
. 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
etPathBuf
. C’est très similaire au typestd::filesystem::path
enfin introduit par C++17, sauf que…- On a la distinction
Path
/PathBuf
en Rust, qui est analogue à la distinctionstring_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.
- On a la distinction
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èteOpenOptions
, qui utilise une conception de type builder pattern et offre la même flexibilité que les chaînes de caractères defopen()
, 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 leftruncate()
de POSIX. - Changer les permissions d’accès via la méthode
file.set_permissions()
, à la manière dufchmod()
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()
etfile.sync_data()
qui fonctionnent comme lesfsync()
etfdatasync()
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"); }