Skip to content

Latest commit

 

History

History
710 lines (511 loc) · 24.8 KB

File metadata and controls

710 lines (511 loc) · 24.8 KB

🔝 Retour au Sommaire

19.1.2 — Manipulation de chemins (path)

Module 7 — Programmation Système sur Linux (Niveau Avancé)


Introduction

La section 19.1 a présenté les bases de fs::path : construction, décomposition et concaténation. Cette section approfondit la manipulation des chemins avec les techniques avancées nécessaires en programmation système : normalisation et résolution, transformation entre chemins absolus et relatifs, modification dynamique des composantes, conversion entre représentations, et les pièges subtils que tout développeur rencontre sur Linux.

La maîtrise de fs::path est un prérequis pour écrire du code filesystem robuste. Un chemin mal construit, mal normalisé ou mal résolu est la source d'erreurs silencieuses qui ne se manifestent qu'en production : fichier écrit au mauvais endroit, lien symbolique suivi de manière inattendue, chemin qui fonctionne dans le répertoire de build mais pas après installation.


Anatomie complète d'un chemin Linux

Avant de manipuler les chemins programmatiquement, il faut comprendre comment fs::path les décompose. Un chemin Linux est constitué de composantes séparées par /. La classe path identifie les parties suivantes :

/home/user/projet/build/app.tar.gz
│                               │
├── root_name()      : ""       │  (vide sur Linux, "C:" sur Windows)
├── root_directory() : "/"      │
├── root_path()      : "/"      │  (root_name + root_directory)
├── relative_path()  : "home/user/projet/build/app.tar.gz"
├── parent_path()    : "/home/user/projet/build"
├── filename()       : "app.tar.gz"
│   ├── stem()       : "app.tar"
│   └── extension()  : ".gz"

Le chemin est aussi itérable composante par composante :

#include <filesystem>
#include <print>

namespace fs = std::filesystem;

int main() {
    fs::path p = "/home/user/projet/src/main.cpp";

    std::println("Composantes de {} :", p.string());
    for (const auto& composante : p) {
        std::println("  [{}]", composante.string());
    }
}

Sortie :

Composantes de /home/user/projet/src/main.cpp :
  [/]
  [home]
  [user]
  [projet]
  [src]
  [main.cpp]

Cette itération produit des fs::path pour chaque segment. Le / initial apparaît comme une composante à part entière, ce qui permet de distinguer un chemin absolu d'un chemin relatif.


Chemins absolus et relatifs

Distinction fondamentale

#include <filesystem>
#include <print>

namespace fs = std::filesystem;

int main() {
    fs::path absolu = "/var/log/syslog";
    fs::path relatif = "build/release/app";
    fs::path relatif_dot = "./config/settings.yaml";

    std::println("{} est absolu : {}", absolu.string(), absolu.is_absolute());
    std::println("{} est relatif : {}", relatif.string(), relatif.is_relative());
    std::println("{} est relatif : {}", relatif_dot.string(), relatif_dot.is_relative());
}

Sur Linux, un chemin est absolu s'il commence par /, relatif dans tous les autres cas. Le préfixe ./ ne rend pas un chemin absolu : il est relatif au répertoire courant, mais il reste syntaxiquement relatif.

Conversion relatif → absolu

#include <filesystem>
#include <print>

namespace fs = std::filesystem;

int main() {
    fs::path relatif = "src/main.cpp";

    // absolute() préfixe avec le répertoire courant sans normaliser
    fs::path abs = fs::absolute(relatif);
    std::println("absolute()  : {}", abs.string());
    // Ex : /home/user/projet/src/main.cpp

    // canonical() préfixe, normalise ET résout les symlinks
    // Le chemin DOIT exister
    try {
        fs::path canon = fs::canonical(relatif);
        std::println("canonical() : {}", canon.string());
    } catch (const fs::filesystem_error& e) {
        std::println("canonical() a échoué : {}", e.what());
    }

    // weakly_canonical() : résout ce qui existe, normalise le reste
    // Le chemin n'a PAS besoin d'exister entièrement
    fs::path wcanon = fs::weakly_canonical("src/../build/output.bin");
    std::println("weakly_canonical() : {}", wcanon.string());
}

La distinction entre ces trois fonctions est cruciale :

Fonction Accès disque Chemin doit exister Résout les symlinks Normalise . et ..
absolute() Oui (lit CWD) Non Non Non
canonical() Oui Oui (intégralement) Oui Oui
weakly_canonical() Oui Non Partiellement Oui

En pratique, weakly_canonical() est le meilleur compromis pour la plupart des situations : il produit un chemin propre sans exiger que chaque composante existe déjà sur le disque. C'est particulièrement utile pour calculer un chemin de destination avant de créer le fichier.


Normalisation : syntaxique vs physique

La normalisation est un sujet sur lequel les erreurs sont fréquentes car il existe deux types de normalisation très différents.

Normalisation syntaxique avec lexically_normal()

lexically_normal() transforme le chemin sans aucun accès au disque. Elle élimine les ., résout les .. de manière purement textuelle et supprime les séparateurs redondants :

#include <filesystem>
#include <print>

namespace fs = std::filesystem;

int main() {
    // Redondances et composantes superflues
    fs::path p1 = "/home/user/./projet/../projet/src//main.cpp";
    std::println("{}", p1.lexically_normal().string());
    // /home/user/projet/src/main.cpp

    // Trailing slash
    fs::path p2 = "/var/log/";
    std::println("{}", p2.lexically_normal().string());
    // /var/log/   (le trailing slash est préservé, indiquant un répertoire)

    // Remontées au-delà de la racine : tronquées
    fs::path p3 = "/home/../../etc/passwd";
    std::println("{}", p3.lexically_normal().string());
    // /etc/passwd

    // Sur un chemin relatif, les .. initiaux sont préservés
    fs::path p4 = "../../src/main.cpp";
    std::println("{}", p4.lexically_normal().string());
    // ../../src/main.cpp
}

Le piège des liens symboliques

La normalisation syntaxique ne connaît pas les liens symboliques. C'est une source de bugs subtils :

Filesystem réel :
/home/user/current_project -> /opt/projects/alpha   (lien symbolique)
/opt/projects/alpha/src/main.cpp                     (fichier réel)
namespace fs = std::filesystem;

fs::path p = "/home/user/current_project/src/../config/app.yaml";

// Normalisation syntaxique : résout .. textuellement
auto syntaxique = p.lexically_normal();
// Résultat : /home/user/current_project/config/app.yaml
// Ce chemin est FAUX si current_project est un symlink :
// le vrai parent de src/ est /opt/projects/alpha/, pas /home/user/current_project/

// Normalisation physique : résout d'abord les symlinks
auto physique = fs::canonical(p);
// Résultat : /opt/projects/alpha/config/app.yaml
// (en supposant que ce fichier existe)

La règle à retenir : utilisez lexically_normal() quand vous travaillez sur des chemins abstraits (affichage, stockage en configuration, comparaison syntaxique). Utilisez canonical() ou weakly_canonical() quand vous devez accéder à un fichier réel et que des liens symboliques peuvent être présents dans le chemin.


Calcul de chemins relatifs

lexically_relative() : relation syntaxique

#include <filesystem>
#include <print>

namespace fs = std::filesystem;

int main() {
    fs::path fichier = "/home/user/projet/src/main.cpp";
    fs::path base = "/home/user/projet";

    auto rel = fichier.lexically_relative(base);
    std::println("{}", rel.string());
    // src/main.cpp

    // Dans l'autre sens
    auto rel2 = base.lexically_relative(fichier);
    std::println("{}", rel2.string());
    // ../..

    // Chemins sans relation (racines différentes sur Windows — rare sur Linux)
    fs::path a = "/home/user/docs";
    fs::path b = "/var/log";
    auto rel3 = a.lexically_relative(b);
    std::println("{}", rel3.string());
    // ../../home/user/docs
}

lexically_proximate() : fallback sûr

lexically_proximate() fonctionne comme lexically_relative(), mais retourne le chemin original au lieu d'un path vide si la relation relative ne peut pas être calculée :

#include <filesystem>
#include <print>

namespace fs = std::filesystem;

int main() {
    fs::path fichier = "/home/user/projet/src/main.cpp";
    fs::path base = "/home/user/projet";

    // relative() peut retourner un path vide dans certains cas limites
    auto rel = fichier.lexically_relative(base);
    std::println("relative   : '{}' (vide = {})", rel.string(), rel.empty());

    // proximate() retourne le chemin original si relative échoue
    auto prox = fichier.lexically_proximate(base);
    std::println("proximate  : '{}'", prox.string());
}

fs::relative() et fs::proximate() : résolution physique

Les fonctions libres fs::relative() et fs::proximate() sont les équivalents physiques (avec accès disque) des méthodes lexicales. Elles résolvent d'abord les chemins via weakly_canonical() avant de calculer la relation :

#include <filesystem>
#include <print>

namespace fs = std::filesystem;

int main() {
    // Suppose que /home/user/link -> /opt/real_project
    fs::path fichier = "/home/user/link/src/main.cpp";
    fs::path base = "/opt/real_project";

    // Lexical : ne voit pas le symlink, calcule une relation incorrecte
    auto lex = fichier.lexically_relative(base);
    std::println("Lexical  : {}", lex.string());
    // ../../../home/user/link/src/main.cpp  (incorrect !)

    // Physique : résout le symlink d'abord
    auto phys = fs::relative(fichier, base);
    std::println("Physique : {}", phys.string());
    // src/main.cpp  (correct)
}

Modification dynamique des composantes

fs::path offre des méthodes pour modifier les composantes d'un chemin en place. Ces méthodes sont essentielles pour construire des chemins dérivés (fichier de sortie à partir d'un fichier d'entrée, fichier de backup, etc.).

replace_filename()

Remplace le dernier segment du chemin (le nom de fichier) :

#include <filesystem>
#include <print>

namespace fs = std::filesystem;

int main() {
    fs::path source = "/home/user/projet/src/main.cpp";

    // Remplacer le nom de fichier
    fs::path header = source;
    header.replace_filename("main.h");
    std::println("{}", header.string());
    // /home/user/projet/src/main.h

    // Fonctionne aussi pour construire un chemin frère
    fs::path sibling = source;
    sibling.replace_filename("utils.cpp");
    std::println("{}", sibling.string());
    // /home/user/projet/src/utils.cpp
}

replace_extension()

Remplace uniquement l'extension (la partie après le dernier . du filename) :

#include <filesystem>
#include <print>

namespace fs = std::filesystem;

int main() {
    fs::path source = "/home/user/projet/src/main.cpp";

    // .cpp → .o (fichier objet)
    fs::path objet = source;
    objet.replace_extension(".o");
    std::println("{}", objet.string());
    // /home/user/projet/src/main.o

    // Supprimer l'extension (passer une chaîne vide)
    fs::path sans_ext = source;
    sans_ext.replace_extension("");
    std::println("{}", sans_ext.string());
    // /home/user/projet/src/main

    // Ajouter une extension (le point est optionnel)
    fs::path backup = source;
    backup.replace_extension(".cpp.bak");
    std::println("{}", backup.string());
    // /home/user/projet/src/main.cpp.bak

    // Attention : seule la dernière extension est remplacée
    fs::path archive = "/tmp/data.tar.gz";
    fs::path changed = archive;
    changed.replace_extension(".xz");
    std::println("{}", changed.string());
    // /tmp/data.tar.xz  (le .gz est remplacé, .tar est dans le stem)
}

remove_filename()

Supprime le dernier segment, laissant le chemin parent avec son séparateur final :

#include <filesystem>
#include <print>

namespace fs = std::filesystem;

int main() {
    fs::path p = "/home/user/projet/src/main.cpp";

    p.remove_filename();
    std::println("Après remove_filename : '{}'", p.string());
    // /home/user/projet/src/

    // Différence avec parent_path() : 
    fs::path q = "/home/user/projet/src/main.cpp";
    std::println("parent_path() : '{}'", q.parent_path().string());
    // /home/user/projet/src      (pas de slash final, retourne un nouveau path)
}

La différence est subtile mais importante : remove_filename() modifie le path en place et laisse un trailing slash, tandis que parent_path() retourne un nouveau path sans trailing slash. Pour la construction de chemins dérivés, parent_path() / "nouveau_fichier" est généralement plus lisible.


Comparaison de chemins

Comparaison syntaxique

Les opérateurs de comparaison de fs::path effectuent une comparaison lexicographique composante par composante, pas une comparaison de chaînes brutes :

#include <filesystem>
#include <print>

namespace fs = std::filesystem;

int main() {
    fs::path a = "/home/user/projet";
    fs::path b = "/home/user/projet";
    fs::path c = "/home/user/projet/";  // Trailing slash
    fs::path d = "/home/user/./projet"; // Composante . superflue

    std::println("a == b : {}", a == b);   // true
    std::println("a == c : {}", a == c);   // false ! Le trailing slash diffère
    std::println("a == d : {}", a == d);   // false ! Le . est une composante distincte

    // Pour une comparaison correcte, normaliser d'abord
    std::println("a == c (normalisé) : {}",
        a.lexically_normal() == c.lexically_normal());
    // Attention : peut encore être false selon l'implémentation du trailing slash
}

Les opérateurs == et < comparent les composantes dans l'ordre. Cela signifie que deux chemins syntaxiquement différents mais pointant vers le même fichier (/home/user/projet et /home/user/./projet) ne seront pas égaux. Si vous devez vérifier que deux chemins désignent le même fichier, il faut les normaliser ou les résoudre.

Comparaison physique avec fs::equivalent()

Pour savoir si deux chemins pointent vers le même fichier ou répertoire sur le disque (même inode), indépendamment de leur représentation syntaxique :

#include <filesystem>
#include <print>

namespace fs = std::filesystem;

int main() {
    // Suppose que /home/user/link -> /home/user/projet (symlink)
    fs::path a = "/home/user/projet/src/main.cpp";
    fs::path b = "/home/user/link/src/main.cpp";
    fs::path c = "/home/user/projet/./src/../src/main.cpp";

    // Les DEUX chemins doivent exister, sinon exception
    try {
        std::println("a ≡ b : {}", fs::equivalent(a, b));  // true (même inode)
        std::println("a ≡ c : {}", fs::equivalent(a, c));  // true (même inode)
    } catch (const fs::filesystem_error& e) {
        std::println("Erreur : {}", e.what());
    }
}

fs::equivalent() effectue un stat() sur chaque chemin et compare les paires (device, inode). C'est la seule méthode fiable pour déterminer si deux chemins référencent le même objet du filesystem. Elle échoue si l'un des deux chemins n'existe pas.


Conversion et interopérabilité

Extraire des chaînes de caractères

fs::path stocke le chemin dans un format natif et offre plusieurs méthodes de conversion :

#include <filesystem>
#include <print>
#include <string>
#include <string_view>

namespace fs = std::filesystem;

int main() {
    fs::path p = "/home/user/données/rapport.txt";

    // Forme native (std::string sur Linux)
    std::string natif = p.string();

    // Forme native retournée par référence (pas de copie)
    const auto& natif_ref = p.native();
    // Sur Linux : const std::string&

    // Conversion en c_str pour les API C
    const char* cstr = p.c_str();

    // Conversion implicite en string_view (C++17)
    // Non disponible directement, mais :
    std::string_view sv(p.native());

    std::println("string()  : {}", natif);
    std::println("c_str()   : {}", cstr);
}

Sur Linux, path::value_type est char et path::string_type est std::string. Les méthodes string() et native() retournent donc le même type. Sur Windows, value_type est wchar_t, et des méthodes supplémentaires (wstring(), u8string(), u16string(), u32string()) permettent les conversions entre encodages. Dans le cadre de cette formation centrée sur Ubuntu, string() et c_str() couvrent la quasi-totalité des besoins.

Interopérabilité avec les API POSIX

Les appels système POSIX attendent des const char*. L'interopérabilité est directe :

#include <filesystem>
#include <fcntl.h>
#include <unistd.h>
#include <print>

namespace fs = std::filesystem;

int main() {
    fs::path chemin = "/tmp" / fs::path("test.txt");

    // Utilisation directe avec les API POSIX via c_str()
    int fd = open(chemin.c_str(), O_RDONLY);
    if (fd >= 0) {
        std::println("Fichier ouvert : {}", chemin.string());
        close(fd);
    }
}

Construction depuis des variables d'environnement

Un cas d'usage très fréquent en programmation système — construire des chemins à partir de variables d'environnement :

#include <filesystem>
#include <print>
#include <cstdlib>

namespace fs = std::filesystem;

auto get_config_path() -> fs::path {
    // $XDG_CONFIG_HOME ou fallback vers ~/.config
    const char* xdg = std::getenv("XDG_CONFIG_HOME");
    if (xdg && xdg[0] != '\0') {
        return fs::path(xdg) / "mon_app";
    }

    const char* home = std::getenv("HOME");
    if (home && home[0] != '\0') {
        return fs::path(home) / ".config" / "mon_app";
    }

    // Dernier recours
    return "/tmp/mon_app";
}

auto get_cache_path() -> fs::path {
    const char* xdg = std::getenv("XDG_CACHE_HOME");
    if (xdg && xdg[0] != '\0') {
        return fs::path(xdg) / "mon_app";
    }

    const char* home = std::getenv("HOME");
    if (home && home[0] != '\0') {
        return fs::path(home) / ".cache" / "mon_app";
    }

    return fs::temp_directory_path() / "mon_app";
}

int main() {
    std::println("Config : {}", get_config_path().string());
    std::println("Cache  : {}", get_cache_path().string());
}

Ce pattern respecte la spécification XDG Base Directory qui est le standard de fait sur les systèmes Linux modernes pour l'emplacement des fichiers de configuration, cache et données utilisateur.


fs::temp_directory_path() : chemins temporaires

La fonction fs::temp_directory_path() retourne le répertoire temporaire du système, déterminé en consultant dans l'ordre les variables d'environnement TMPDIR, TMP, TEMP, puis le fallback /tmp :

#include <filesystem>
#include <print>

namespace fs = std::filesystem;

int main() {
    fs::path tmp = fs::temp_directory_path();
    std::println("Répertoire temporaire : {}", tmp.string());
    // Typiquement : /tmp

    // Construction d'un chemin temporaire unique
    // (pas de génération automatique de nom — voir section 19.1.3)
    fs::path work_dir = tmp / "mon_app_build_cache";
}

Notez que temp_directory_path() retourne le répertoire temporaire, mais ne crée pas de fichier ou répertoire unique. Pour créer des fichiers temporaires avec un nom unique, il faut soit utiliser l'API POSIX mkstemp() (section 19.2), soit construire un nom basé sur le PID ou un identifiant unique.


Pièges courants sur Linux

Le chemin vide

Un fs::path par défaut est vide. Passer un path vide à la plupart des fonctions filesystem produit une erreur :

namespace fs = std::filesystem;

fs::path p;  // Path vide

// Vérifiez toujours avant d'utiliser
if (p.empty()) {
    std::println("Chemin non défini");
}

// fs::exists("") retourne false (pas d'exception)
// fs::canonical("") lance une exception

Les fichiers cachés (dotfiles)

Sur Linux, les fichiers commençant par . sont cachés par convention. fs::path les traite comme n'importe quel fichier, mais stem() et extension() ont un comportement qui peut surprendre :

#include <filesystem>
#include <print>

namespace fs = std::filesystem;

int main() {
    fs::path dotfile = "/home/user/.bashrc";
    std::println("filename()  : {}", dotfile.filename().string());   // .bashrc
    std::println("stem()      : {}", dotfile.stem().string());       // .bashrc
    std::println("extension() : {}", dotfile.extension().string());  // "" (vide !)

    fs::path dotconf = "/home/user/.config";
    std::println("stem()      : {}", dotconf.stem().string());       // .config
    std::println("extension() : {}", dotconf.extension().string());  // "" (vide !)

    // Comparer avec un fichier qui a une vraie extension
    fs::path hidden = "/home/user/.app.log";
    std::println("stem()      : {}", hidden.stem().string());        // .app
    std::println("extension() : {}", hidden.extension().string());   // .log
}

Le point initial d'un dotfile n'est pas considéré comme un séparateur d'extension. .bashrc a un stem de .bashrc et une extension vide. C'est le comportement correct selon POSIX, mais il surprend souvent les développeurs habitués à d'autres langages.

Le slash final (trailing slash)

La présence ou l'absence d'un / final modifie le comportement de certaines méthodes :

#include <filesystem>
#include <print>

namespace fs = std::filesystem;

int main() {
    fs::path a = "/home/user/projet";
    fs::path b = "/home/user/projet/";

    std::println("a.filename() : '{}'", a.filename().string());   // projet
    std::println("b.filename() : '{}'", b.filename().string());   // "" (vide !)

    std::println("a.parent_path() : '{}'", a.parent_path().string());
    // /home/user
    std::println("b.parent_path() : '{}'", b.parent_path().string());
    // /home/user/projet
}

Un trailing slash fait que filename() retourne un path vide et que parent_path() retourne le chemin entier sans le slash. Ce comportement est logique (le slash indique "le contenu de ce répertoire"), mais il peut casser du code qui s'attend à ce que filename() retourne toujours quelque chose. Utilisez lexically_normal() pour supprimer les trailing slashes ambigus avant d'appeler ces méthodes si l'entrée n'est pas sous votre contrôle.

Encodage et caractères spéciaux

Sur les systèmes Linux modernes, les noms de fichiers sont des séquences d'octets (tout sauf \0 et /). La convention est UTF-8, mais le filesystem ne l'impose pas. fs::path sur Linux stocke les chemins tels quels, sans conversion d'encodage :

#include <filesystem>
#include <print>

namespace fs = std::filesystem;

int main() {
    // Les caractères accentués fonctionnent si le système est en UTF-8
    fs::path p = "/home/user/données/résumé.txt";
    std::println("{}", p.string());

    // Espaces dans les chemins : aucun problème avec fs::path
    // (contrairement aux scripts shell où il faut échapper)
    fs::path espaces = "/home/user/Mon Projet/mes fichiers/doc.pdf";
    std::println("Existe : {}", fs::exists(espaces));
}

Les espaces et caractères spéciaux dans les chemins sont gérés correctement par fs::path. C'est un avantage majeur par rapport à la construction manuelle de chaînes, où l'oubli d'un échappement ou d'un guillemet est une source classique de bugs.


Résumé des méthodes de fs::path

Catégorie Méthodes
Décomposition root_name(), root_directory(), root_path(), relative_path(), parent_path(), filename(), stem(), extension()
Test empty(), is_absolute(), is_relative(), has_root_path(), has_filename(), has_stem(), has_extension()
Modification replace_filename(), replace_extension(), remove_filename(), operator/=, operator+=
Normalisation lexically_normal(), lexically_relative(), lexically_proximate()
Conversion string(), native(), c_str(), u8string(), wstring()
Itération begin(), end() (composantes du chemin)
Comparaison ==, !=, <, <=, >, >=, compare()
Catégorie Fonctions libres
Résolution absolute(), canonical(), weakly_canonical()
Relation relative(), proximate()
Équivalence equivalent()
Système current_path(), temp_directory_path()

💡 Note — La manipulation de chemins semble triviale dans les exemples isolés, mais elle devient rapidement complexe dans un vrai projet : chemins provenant de l'utilisateur (potentiellement malformés), liens symboliques, variables d'environnement absentes, caractères spéciaux, différences entre l'arbre de build et l'arbre d'installation. La rigueur acquise ici — normaliser avant de comparer, résoudre avant d'accéder, vérifier les paths vides — est ce qui sépare un outil fiable d'un prototype fragile. Le réflexe de toujours utiliser fs::path plutôt que des std::string pour représenter des chemins est le premier pas vers cette robustesse.

⏭️ Opérations sur fichiers et répertoires