CodeGym /Cours /Docker for Spring /Chemins, noms de fichiers et droits

Chemins, noms de fichiers et droits

Docker for Spring
Niveau 13, Leçon 3
Disponible

1. Le chemin de fichier comme contrat de service

Nous avons déjà mis de l’ordre avec le répertoire d’exportation : le service le reçoit de l’extérieur et a cessé de deviner où écrire. Mais le scénario de fichier ne cesse pas d’être aléatoire pour autant — il ne devient pas encore fiable. Lorsqu’un développeur effectue pour la première fois un export dans un fichier, il veut généralement « écrire rapidement une chaîne dans catalog.csv et profiter de la vie ». Et c’est normal — tant que nous vivons dans un monde composé d’un seul OS, d’une seule machine et d’un seul lancement. Mais dès que Docker et les mounts apparaissent, le chemin devient soudainement un contrat : où exactement le fichier apparaîtra, qui le verra, quels caractères sont autorisés, et si le processus peut même y écrire.

La partie la plus insidieuse est que les erreurs de chemins et de droits ressemblent à « Java est cassé » ou « Spring est cassé ». En réalité, ce n’est souvent pas le code de la logique métier qui casse, mais l’environnement : le chemin du conteneur n’existe pas, le bind mount a écrasé un autre répertoire, le nom du fichier contient : (et bonjour Windows), le répertoire est en lecture seule, et l’application échoue honnêtement parce qu’elle ne peut pas écrire sur une pierre.

Fixons une idée qui sauve les nerfs : dans l’application, il y a un « chemin conteneur » (target path), et dans Docker il y a un « chemin hôte » (source path). Ce sont des entités différentes, et les mélanger revient à confondre l’adresse de livraison et l’adresse du magasin.

Pour visualiser, voici un schéma simple :

flowchart TD
  Host["Host OS (Windows/macOS/Linux) répertoire du projet : ./data/exports"] -->|"bind mount (source)"| Container["Container FS /app/exports (target)"]
  Container -->|"APP_EXPORT_DIR=/app/exports"| App["Spring Boot app écrit le fichier CSV"]
  App --> File["/app/exports/catalog-2026-03-21T10-15-00.csv"]

L’application ne connaît que le target path à l’intérieur du conteneur (par exemple /app/exports). Docker sait à quoi cela est lié sur l’hôte (par exemple ./data/exports). Si vous essayez de donner à l’application un chemin hôte comme C:\Users\me\project\data\exports alors qu’elle s’exécute dans un conteneur Linux — un stand‑up très triste commence, mais sans rire.

2. Path et resolve au lieu de chaînes

Concaténer les chemins avec des chaînes est l’un de ces « péchés de jeunesse » qui ne sont pas interdits, mais qui finissent par vous mordre. La version typique semble innocente : "dir" + "/" + "file". Le problème est que vous prenez manuellement la responsabilité des séparateurs (/ vs \), des barres obliques superflues, des .. au milieu, et de la lisibilité du code. Et aussi du fait qu’à un endroit apparaîtra forcément dir + file sans barre oblique, créant un bug mystérieux du type « pourquoi le chemin est incorrect ».

En Java, il existe un modèle correct pour les chemins : java.nio.file.Path. Il sait concaténer les segments de chemin, comprend les séparateurs de la plateforme courante, et surtout rend le code lisible. C’est comme arrêter de porter de l’eau dans un panier : théoriquement possible, mais pourquoi faire.

Exemple « comment ne PAS faire »

// Répertoire d’exportation (en tant que chaîne)
String exportDir = "/app/exports";
// Nom de fichier (en tant que chaîne)
String fileName = "catalog.csv";

// Concaténation manuelle : facile d'oublier la barre oblique, d'obtenir une double barre oblique ou de casser sur un autre OS
String path = exportDir + "/" + fileName;
System.out.println(path); // /app/exports/catalog.csv

Cela fonctionne dans un environnement de type Linux, mais ici vous gérez « manuellement » le séparateur et ne contrôlez pas que exportDir puisse arriver avec une barre oblique finale /.

Exemple « comment faire » (Path.of + resolve)

import java.nio.file.Path;

// Répertoire en tant que Path (c'est déjà un modèle de chemin, pas juste une chaîne)
Path exportDir = Path.of("/app/exports");
// Ajouter le nom de fichier comme « segment » du chemin (sans barre oblique manuelle)
Path file = exportDir.resolve("catalog.csv");

System.out.println(file); // /app/exports/catalog.csv

Remarquez : resolve se lit littéralement comme « résous le fichier relativement au répertoire ». C’est un langage très humain — et c’est la moitié du succès.

Un petit tableau pour consolider :

Tâche Chaînes Path
Concaténer un répertoire et un nom de fichier erreur facile avec / exportDir.resolve(fileName)
Multiplateforme il faut se souvenir de \ Path gère tout seul
Normalisation (.., .) doit être écrit manuellement path.normalize()
Vérifications (existe ? répertoire ?) via File/chaînes, douleur Files.* +
Path

Et oui : on peut utiliser File.separator, mais c’est plus un « pansement » qu’une solution. Path est une vraie habitude architecturale.

3. Chemins relatifs et absolus

Lorsque vous écrivez Path.of("data", "exports"), Java crée un chemin relatif. Relatif signifie « à partir du répertoire de travail actuel du processus ». En lancement local depuis l’IDE, c’est généralement la racine du projet (mais pas toujours), et dans un conteneur, c’est généralement la valeur de WORKDIR du Dockerfile (par exemple /app). Et c’est là que survient le premier « paradoxe du fichier » pour les débutants : le même code écrit soudainement à différents endroits parce que le répertoire de travail est différent.

Donc la règle de base est très simple : un chemin relatif est correct si vous contrôlez consciemment à partir d’où il est calculé. Dans un projet pédagogique, nous voulons souvent qu’en lancement local, l’export aille dans le dépôt (par exemple ./data/exports), et dans un conteneur, dans le répertoire conteneur (par exemple /app/exports) avec la possibilité de le monter sur l’hôte.

Un point important : APP_EXPORT_DIR pour l’application est un chemin à l’intérieur du processus, c’est-à-dire à l’intérieur du conteneur (quand nous sommes dans le conteneur). N’essayez pas de transformer APP_EXPORT_DIR en « chemin sur votre ordinateur portable ». C’est du niveau Docker, pas du niveau du code Java.

Regardez un exemple qui montre bien la différence :

import java.nio.file.Path;

// Chemin relatif : calculé à partir du répertoire de travail actuel du processus
Path local = Path.of("data", "exports");
System.out.println(local.toString()); // data/exports (ou data\exports sur Windows)

// Chemin absolu (style Linux) : souvent utilisé comme cible stable dans le conteneur
Path container = Path.of("/app/exports");
System.out.println(container.toString()); // /app/exports

Les deux chemins sont valides, ils correspondent simplement à différents modes d’exécution. En mode local, le chemin relatif est pratique car il n’est pas lié à votre C:\Users\... ou /Users/.... En mode conteneur, le chemin absolu à l’intérieur du conteneur est pratique car il est stable et ne dépend pas de l’endroit où vous lancez docker run.

Et encore une petite astuce utile : dans le code, il est presque toujours bon de convertir le chemin en forme normalisée, afin qu’au moins dans les logs vous voyiez « ce qu’on a obtenu ».

import java.nio.file.Path;

// toAbsolutePath() : pour voir le chemin complet dans les logs et ne pas deviner « d'où ça vient »
// normalize() : pour supprimer les "./" et "dir/.." superflus
Path dir = Path.of("data/exports").toAbsolutePath().normalize();
System.out.println(dir); // /.../project/data/exports

Si vous avez déjà attrapé un « pourquoi le fichier n’est pas là » — vous apprécierez à quel point cela réduit le temps de devinette.

4. Noms de fichiers sécurisés

Les noms de fichiers sont souvent perçus comme « bon, une chaîne quoi ». Mais dans la réalité, ce sont des données d’entrée (parfois même de l’utilisateur), et les systèmes de fichiers ont leur caractère. Et ce caractère varie : Linux est généralement plus tolérant avec les caractères, Windows est beaucoup plus strict, macOS a ses propres particularités. Donc « ça marche dans mon conteneur » ne garantit pas « ça marche chez l’étudiant sous Windows qui veut voir l’export sur l’hôte ».

La surprise la plus fréquente : vous prenez LocalDateTime.now().toString() et obtenez une chaîne avec des deux‑points dans l’heure, par exemple 2026-03-21T10:15:00. Sous Linux, le deux‑points est généralement autorisé dans un nom de fichier, mais sous Windows, non. Conclusion : l’export « tombe soudainement » alors que le code est « absolument correct ».

Mauvaise variante (mais très populaire) de nom

import java.time.LocalDateTime;

// LocalDateTime.toString() contient ":" dans la partie horaire — sous Windows, c'est un caractère interdit dans les noms de fichiers
String fileName = "catalog-" + LocalDateTime.now() + ".csv";
System.out.println(fileName); // catalog-2026-03-21T10:15:00.csv

Du point de vue de Java, c’est juste une chaîne. Du point de vue de Windows, c’est une tentative d’insérer un caractère interdit.

Bonne variante : DateTimeFormatter sans caractères interdits

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

// Format sans ":" (remplacé par "-") pour que le nom soit multiplateforme
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss");
String fileName = "catalog-" + LocalDateTime.now().format(fmt) + ".csv";

System.out.println(fileName); // catalog-2026-03-21T10-15-00.csv

Ici, nous avons remplacé : par - dans le format de l’heure. Le résultat est lisible, triable, multiplateforme.

Nettoyage d’un « nom venu d’ailleurs »

Même si nous générons le nom nous-mêmes, il est utile d’avoir une petite fonction qui élimine les problèmes évidents. Inutile d’en faire une « bibliothèque de nom de fichier de 600 lignes », un minimum honnête et pédagogique suffit.

public class FileNameSanitizer {

    public String sanitize(String raw) {
        // Nettoyage minimal : supprime les caractères problématiques les plus courants
        return raw
                .replace(":", "-")
                .replace("/", "_")
                .replace("\\", "_");
    }
}

Oui, c’est rudimentaire. Et oui, en production réelle on pourrait discuter de normalisation Unicode et autres joies. Mais dans un projet pédagogique, l’objectif est différent : supprimer les surprises les plus fréquentes pour que les exports cessent de planter « juste à cause d’un caractère ».

Et un autre point que beaucoup oublient : le nom du fichier et le répertoire sont des entités différentes. D’abord vous choisissez le répertoire, puis vous ajoutez le nom via resolve. Si vous conservez le tout dans une seule chaîne comme "data/exports/catalog.csv", vous aurez inévitablement des bugs où vous dupliquez accidentellement une partie du chemin ou remplacez le répertoire.

5. normalize() et protection contre ../

Voici la partie où vous vous sentirez un peu ingénieur confirmé : même si le cours n’a pas de volet sécurité dédié, les fichiers sont un endroit où la sécurité et « le simple soin » se rencontrent sans prévenir.

Le problème est le suivant : vous pensez qu’on vous a donné un nom de fichier catalog.csv, mais on vous a (accidentellement ou intentionnellement) donné ../../oops.txt. Si vous faites bêtement exportDir.resolve(fileName) — vous obtiendrez un chemin en dehors du répertoire d’exportation. Parfois c’est juste une erreur de l’utilisateur, parfois une tentative désagréable d’écrire un fichier « ailleurs ». Dans tous les cas, le service doit savoir détecter cela.

La solution est simple et très révélatrice : resolve + normalize, puis vérification startsWith(exportDir).

import java.nio.file.Path;

// Répertoire d'exportation de base (ce que nous considérons comme la « zone autorisée »)
Path exportDir = Path.of("/app/exports").normalize();
// « Nom de fichier » qui s'est avéré être un chemin remontant
String rawName = "../oops.txt";

// resolve() concatène le chemin, normalize() essaie de réduire ".." et "."
Path candidate = exportDir.resolve(rawName).normalize();

// Vérifie qu'après normalisation, le chemin commence toujours par exportDir
if (!candidate.startsWith(exportDir)) {
    throw new IllegalArgumentException("Invalid export file name: " + rawName);
}

Il est important de comprendre la mécanique : normalize() supprime les .. et . au niveau du chemin (là où c’est possible). Et startsWith(exportDir) vérifie que le chemin final est toujours à l’intérieur du répertoire souhaité. C’est une bonne « ceinture de sécurité » même pour un projet pédagogique : elle protège des accidents et rend le comportement prévisible.

On peut joliment encapsuler cela dans une petite classe pour éviter que le code métier ne se disperse :

import java.nio.file.Path;

public class ExportPathResolver {
    private final Path exportDir;

    public ExportPathResolver(Path exportDir) {
        this.exportDir = exportDir.normalize();
    }

    public Path resolve(String fileName) {
        Path file = exportDir.resolve(fileName).normalize();
        if (!file.startsWith(exportDir)) {
            throw new IllegalArgumentException("File escapes export dir: " + fileName);
        }
        return file;
    }
}

Le code est court, la logique est à un seul endroit, et désormais CatalogExportService peut penser à l’export plutôt qu’à ne pas s’envoler vers un répertoire voisin.

6. Droits d’écriture : comment distinguer « je ne peux pas écrire » de « l’export est cassé »

Lorsque l’application ne peut pas écrire un fichier, le développeur débutant soupçonne souvent tout : la génération CSV, l’encodage, « le mauvais profil Spring », « Docker encore de la magie ». En pratique, c’est souvent plus banal : le répertoire n’existe pas, le répertoire existe mais c’est un fichier, le répertoire existe mais est en lecture seule, le répertoire est monté mais le conteneur n’a pas les droits d’écriture, ou le système de fichiers lui-même dans l’environnement n’autorise pas l’écriture.

Une bonne habitude d’ingénieur ici est de valider le répertoire à l’avance et d’échouer avec un message compréhensible avant même de commencer « l’export ». Ainsi l’erreur ressemblera à « le répertoire d’exportation n’est pas accessible en écriture » plutôt qu’à « quelque chose ne va pas quelque part au milieu ».

Voici un validateur minimal de répertoire (sans fanatisme) :

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class ExportDirectoryValidator {

    public Path prepare(String configuredDir) throws IOException {
        // Convertir le chemin en forme absolue et normalisée pour qu'il soit clair dans les logs
        Path dir = Path.of(configuredDir).toAbsolutePath().normalize();

        // Créer les répertoires si nécessaire (opération idempotente)
        Files.createDirectories(dir);

        // Vérifier le droit d'écriture à l'avance pour que l'erreur soit « humaine » et précoce
        if (!Files.isWritable(dir)) {
            throw new IllegalStateException("Export directory is not writable: " + dir);
        }
        return dir;
    }
}

Files.createDirectories(dir) est pratique car il est idempotent : si le répertoire existe déjà, il ne fait pas comme si tout était perdu. Et Files.isWritable(dir) donne un signal rapide « peut-on vraiment y écrire ? ».

Exceptions les plus courantes que vous rencontrerez

En Java, lors de problèmes avec le système de fichiers, on rencontre souvent AccessDeniedException, NoSuchFileException, parfois FileSystemException. Pour un débutant, il est utile de voir au moins une fois que ce sont des classes d’erreurs différentes : la première concerne les droits, la seconde l’absence de chemin, la troisième est un conteneur général pour « le système de fichiers a dit non ».

On peut écrire un simple traitement qui transforme « l’erreur effrayante » en texte compréhensible sans révéler trop de détails :

import java.nio.file.AccessDeniedException;
import java.nio.file.NoSuchFileException;

public class ExportErrorMapper {

    public String toHumanMessage(Exception e) {
        if (e instanceof AccessDeniedException) {
            return "Pas de droit d'écriture dans le répertoire d'exportation";
        }
        if (e instanceof NoSuchFileException) {
            return "Répertoire ou fichier introuvable (vérifie le chemin et le mount)";
        }
        return "Erreur du système de fichiers : " + e.getClass().getSimpleName();
    }
}

Ce n’est pas une « grande architecture », mais en tant qu’habitude pédagogique, c’est excellent. Surtout quand l’application est dans un conteneur et que vous essayez de comprendre s’il s’agit d’une erreur de code ou d’environnement.

7. Bind mount : nuances multiplateforme

Les scénarios de fichiers ne cassent pas seulement à cause de Java. Le bind mount peut ajouter ses propres surprises, surtout si l’équipe travaille sur différents OS. Il est important de connaître exactement quelques éléments — suffisamment pour ne pas perdre une demi‑journée à chercher « pourquoi ça ne marche pas », mais pas assez pour que le cours devienne soudainement un cours sur Linux.

Première nuance : Docker distingue source et target. target est le chemin à l’intérieur du conteneur (presque toujours en style Linux, par exemple /app/exports). source est le chemin sur l’hôte (peut être un chemin Windows, macOS, un chemin relatif du projet). Et donc APP_EXPORT_DIR doit correspondre exactement à target, sinon l’application écrira ailleurs que là où vous avez monté.

Deuxième nuance : la forme explicite --mount type=bind s’attend généralement à ce que le chemin source existe déjà. Si vous indiquez un répertoire inexistant, Docker ne le créera pas toujours « poliment » pour vous. C’est bien : l’erreur est précoce et claire, au lieu qu’apparaisse soudainement quelque chose « ailleurs et autrement ». (La syntaxe courte -v peut se comporter différemment dans certains environnements, mais dans un contexte pédagogique, il est plus utile de s’habituer à l’explicite.)

Troisième nuance : le chemin Windows avec C: entre en conflit avec le « deux‑points » dans l’ancienne syntaxe du volume mapping. C’est pourquoi sur Windows, il est souvent plus simple d’utiliser soit --mount ..., soit d’échapper / mettre entre guillemets soigneusement. Nous n’entrerons pas dans la ménagerie des shells, mais il est utile de se rappeler : si vous voyez une erreur où Docker « n’a pas compris » le chemin, ce n’est pas toujours de votre faute — parfois c’est juste la syntaxe de la ligne de commande.

Pour ne pas rédiger trois cours séparés « Bash / PowerShell / CMD », il suffit de retenir l’idée : fournissez le chemin source de manière à ce qu’il soit correct dans votre shell, et faites toujours en sorte que le chemin target à l’intérieur du conteneur soit absolu en style Linux.

Par exemple, la logique de montage elle‑même peut ressembler à ceci (conceptuellement) :

docker run --rm \
  --mount type=bind,source=./data/exports,target=/app/exports \
  -e APP_EXPORT_DIR=/app/exports \
  your-image:tag

Ici, il n’y a rien de « spécifique à Windows » ou « spécifique à Linux » dans target — il est stable. Toute la « plateforme‑dépendance » vit dans source, et c’est normal : l’hôte est toujours différent.

8. Mini‑refactorisation du service d’export

Safe filename, normalize() et la vérification d’écriture sont utiles exactement à l’endroit où le service écrit réellement le fichier. Le répertoire d’exportation vient déjà de app.export.dir, donc aucune nouvelle configuration n’est nécessaire. Ici, deux choses locales sont importantes : un nom de fichier sûr et la garantie que le chemin final ne sorte pas du répertoire d’exportation.

Le nom du fichier est toujours pratique à générer séparément — par exemple via DateTimeFormatter sans :. Et à l’intérieur du CatalogExportService existant, qui reçoit ExportProperties props, l’écriture peut être renforcée ainsi :

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public Path writeCsv(String fileName, String csv) throws IOException {
    Path exportDir = ExportDirectoryValidator.prepare(props.dir().toAbsolutePath().normalize());

    Path file = exportDir.resolve(fileName).normalize();
    if (!file.startsWith(exportDir)) {
        throw new IllegalArgumentException("Bad file name: " + fileName);
    }

    return Files.writeString(file, csv, StandardCharsets.UTF_8);
}

On obtient une chose importante : le répertoire provient toujours d’une source unique de vérité, et le service lui‑même devient plus résistant aux noms tordus, aux chemins relatifs étranges et aux problèmes d’écriture inattendus.

Quand le répertoire, le nom du fichier et les droits cessent d’être aléatoires, il reste à vérifier l’essentiel : le résultat survit‑il au remplacement du conteneur, ou vit‑il seulement jusqu’au premier docker rm ?

9. Erreurs typiques lors du travail avec les chemins

Erreur n°1 : concaténer les chemins avec des chaînes et des « barres obliques au gré de l’humeur ».
Cela commence généralement par dir + "/" + name et se termine par le fait qu’à un endroit on a écrit dir + name, à un autre deux barres obliques à la suite, et sous Windows apparaît soudainement \ et une partie du code commence à vivre sa propre vie. Path.of(...) et resolve(...) éliminent presque complètement cette classe de problèmes, et rendent en plus le code plus facile à lire.

Erreur n°2 : confondre le chemin hôte (source) et le chemin conteneur (target) et essayer de donner à l’application un chemin C:\....
Dans un conteneur, le système de fichiers est presque toujours Linux, et la chaîne C:\temp\exports à l’intérieur d’un tel environnement ne signifie pas « disque C ». C’est juste un nom de répertoire étrange avec des deux‑points et des barres obliques inverses, que vous allez vous‑même créer (si les droits le permettent). L’application doit recevoir un chemin à l’intérieur du conteneur, par exemple /app/exports, et le chemin hôte ne vit que dans la commande Docker en tant que source.

Erreur n°3 : générer le nom de fichier via LocalDateTime.toString() et attraper des caractères interdits.
Sous Linux, les deux‑points passent généralement, sous Windows non. Résultat : l’export « ne marche que pour une partie du groupe » et la chasse aux sorcières commence. Un DateTimeFormatter stable avec le format HH-mm-ss résout le problème tout en gardant le nom triable et lisible.

Erreur n°4 : ne pas normaliser le chemin et ne pas vérifier startsWith(exportDir).
Si le nom du fichier contient soudainement ../ (par erreur ou depuis une entrée externe), resolve(fileName) peut vous emmener en dehors du répertoire d’exportation. C’est une imprévisibilité fâcheuse même pour un service pédagogique. normalize() + startsWith() — une petite protection qui rend le résultat évident et maîtrisé.

Erreur n°5 : penser que « si le répertoire existe, on peut écrire ».
Les droits sont une couche de réalité distincte. Un répertoire peut exister mais être en lecture seule, être monté sans droits d’écriture, être un système de fichiers qui interdit les modifications, ou simplement appartenir à un utilisateur auquel le processus n’a pas accès. Une validation rapide via Files.createDirectories(...) et Files.isWritable(...) donne une erreur claire avant que vous vous enfouissiez dans une enquête.

1
Mission
Docker for Spring, niveau 13, leçon 3
Bloqué
Nom de fichier sécurisé et chemin correct
Nom de fichier sécurisé et chemin correct
1
Mission
Docker for Spring, niveau 13, leçon 3
Bloqué
Erreur d'écriture dans un répertoire en lecture seule
Erreur d'écriture dans un répertoire en lecture seule
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION