Comment puis-je encoder en toute sécurité une chaîne en Java à utiliser comme nom de fichier?

117

Je reçois une chaîne d'un processus externe. Je veux utiliser cette chaîne pour créer un nom de fichier, puis écrire dans ce fichier. Voici mon extrait de code pour ce faire:

    String s = ... // comes from external source
    File currentFile = new File(System.getProperty("user.home"), s);
    PrintWriter currentWriter = new PrintWriter(currentFile);

Si s contient un caractère non valide, tel que «/» dans un système d'exploitation Unix, alors une exception java.io.FileNotFoundException est (à juste titre) lancée.

Comment puis-je encoder la chaîne en toute sécurité afin qu'elle puisse être utilisée comme nom de fichier?

Edit: Ce que j'espère, c'est un appel API qui fait cela pour moi.

Je peux le faire:

    String s = ... // comes from external source
    File currentFile = new File(System.getProperty("user.home"), URLEncoder.encode(s, "UTF-8"));
    PrintWriter currentWriter = new PrintWriter(currentFile);

Mais je ne suis pas sûr que URLEncoder soit fiable à cet effet.

Steve McLeod
la source
1
Quel est le but de l'encodage de la chaîne?
Stephen C
3
@Stephen C: Le but de l'encodage de la chaîne est de rendre utilisable comme nom de fichier, comme le fait java.net.URLEncoder pour les URL.
Steve McLeod
1
Oh je vois. L'encodage doit-il être réversible?
Stephen C
@Stephen C: Non, il n'est pas nécessaire que ce soit réversible, mais j'aimerais que le résultat ressemble le plus possible à la chaîne d'origine.
Steve McLeod
1
Le codage doit-il masquer le nom d'origine? Doit-il être 1 pour 1; c'est-à-dire que les collisions sont OK?
Stephen C

Réponses:

17

Si vous voulez que le résultat ressemble au fichier d'origine, SHA-1 ou tout autre schéma de hachage n'est pas la solution. Si les collisions doivent être évitées, le simple remplacement ou la suppression de "mauvais" caractères n'est pas non plus la solution.

Au lieu de cela, vous voulez quelque chose comme ça. (Remarque: cela doit être traité comme un exemple illustratif, pas comme quelque chose à copier et coller.)

char fileSep = '/'; // ... or do this portably.
char escape = '%'; // ... or some other legal char.
String s = ...
int len = s.length();
StringBuilder sb = new StringBuilder(len);
for (int i = 0; i < len; i++) {
    char ch = s.charAt(i);
    if (ch < ' ' || ch >= 0x7F || ch == fileSep || ... // add other illegal chars
        || (ch == '.' && i == 0) // we don't want to collide with "." or ".."!
        || ch == escape) {
        sb.append(escape);
        if (ch < 0x10) {
            sb.append('0');
        }
        sb.append(Integer.toHexString(ch));
    } else {
        sb.append(ch);
    }
}
File currentFile = new File(System.getProperty("user.home"), sb.toString());
PrintWriter currentWriter = new PrintWriter(currentFile);

Cette solution donne un encodage réversible (sans collision) où les chaînes encodées ressemblent aux chaînes d'origine dans la plupart des cas. Je suppose que vous utilisez des caractères 8 bits.

URLEncoder fonctionne, mais il a l'inconvénient d'encoder un grand nombre de caractères de noms de fichiers légaux.

Si vous voulez une solution non garantie-réversible, supprimez simplement les «mauvais» caractères plutôt que de les remplacer par des séquences d'échappement.


L'inverse du codage ci-dessus doit être tout aussi simple à mettre en œuvre.

Stephen C
la source
105

Ma suggestion est d'adopter une approche «liste blanche», ce qui signifie ne pas essayer de filtrer les mauvais caractères. Définissez plutôt ce qui est OK. Vous pouvez rejeter le nom de fichier ou le filtrer. Si vous souhaitez le filtrer:

String name = s.replaceAll("\\W+", "");

Cela remplace tout caractère qui n'est pas un chiffre, une lettre ou un trait de soulignement par rien. Vous pouvez également les remplacer par un autre caractère (comme un trait de soulignement).

Le problème est que s'il s'agit d'un répertoire partagé, vous ne voulez pas de collision de noms de fichiers. Même si les zones de stockage utilisateur sont séparées par utilisateur, vous pouvez vous retrouver avec un nom de fichier en collision simplement en filtrant les mauvais caractères. Le nom qu'un utilisateur a mis est souvent utile s'il souhaite également le télécharger.

Pour cette raison, j'ai tendance à permettre à l'utilisateur d'entrer ce qu'il veut, de stocker le nom de fichier basé sur un schéma de mon choix (par exemple userId_fileId), puis de stocker le nom de fichier de l'utilisateur dans une table de base de données. De cette façon, vous pouvez l'afficher à nouveau à l'utilisateur, stocker les choses comme vous le souhaitez et ne pas compromettre la sécurité ou effacer d'autres fichiers.

Vous pouvez également hacher le fichier (par exemple, le hachage MD5), mais vous ne pouvez pas lister les fichiers que l'utilisateur a placés (pas avec un nom significatif de toute façon).

EDIT: Correction de regex pour java

cletus
la source
Je ne pense pas que ce soit une bonne idée de fournir d'abord la mauvaise solution. De plus, MD5 est un algorithme de hachage presque fissuré. Je recommande au moins SHA-1 ou mieux.
vog le
19
Dans le but de créer un nom de fichier unique qui se soucie si l'algorithme est "cassé"?
cletus
3
@cletus: le problème est que différentes chaînes seront mappées vers le même nom de fichier; ie collision.
Stephen C
3
Une collision devrait être délibérée, la question d'origine ne parle pas du choix de ces chaînes par un attaquant.
tialaramex
8
Vous devez utiliser "\\W+"pour l'expression rationnelle en Java. La barre oblique inverse s'applique d'abord à la chaîne elle-même et \Wn'est pas une séquence d'échappement valide. J'ai essayé de modifier la réponse, mais on dirait que quelqu'un a rejeté ma modification :(
vadipp
35

Cela dépend si l'encodage doit être réversible ou non.

Réversible

Utilisez le codage URL ( java.net.URLEncoder) pour remplacer les caractères spéciaux par %xx. Notez que vous prenez soin des cas particuliers où la chaîne est égale ., égale ..ou vide! ¹ De nombreux programmes utilisent le codage URL pour créer des noms de fichiers, c'est donc une technique standard que tout le monde comprend.

Irréversible

Utilisez un hachage (par exemple SHA-1) de la chaîne donnée. Les algorithmes de hachage modernes (et non MD5) peuvent être considérés comme sans collision. En fait, vous aurez une percée dans la cryptographie si vous trouvez une collision.


¹ Vous pouvez gérer les 3 cas spéciaux avec élégance en utilisant un préfixe tel que "myApp-". Si vous mettez le fichier directement dans $HOME, vous devrez le faire de toute façon pour éviter les conflits avec des fichiers existants tels que ".bashrc".
public static String encodeFilename(String s)
{
    try
    {
        return "myApp-" + java.net.URLEncoder.encode(s, "UTF-8");
    }
    catch (java.io.UnsupportedEncodingException e)
    {
        throw new RuntimeException("UTF-8 is an unknown encoding!?");
    }
}

vog
la source
2
L'idée d'URLEncoder de ce qu'est un caractère spécial peut ne pas être correcte.
Stephen C
4
@vog: URLEncoder échoue pour "." et "..". Ceux-ci doivent être encodés, sinon vous allez entrer en collision avec les entrées du répertoire dans $ HOME
Stephen C
6
@vog: "*" n'est autorisé que dans la plupart des systèmes de fichiers Unix, NTFS et FAT32 ne le prennent pas en charge.
Jonathan
1
"." et ".." peuvent être traités en échappant des points à% 2E lorsque la chaîne ne contient que des points (si vous souhaitez minimiser les séquences d'échappement). «*» peut également être remplacé par «% 2A».
viphe
1
notez que toute approche qui allonge le nom de fichier (en changeant les caractères uniques en% 20 ou autre) invalidera certains noms de fichiers qui sont proches de la limite de longueur (255 caractères pour les systèmes Unix)
smcg
24

Voici ce que j'utilise:

public String sanitizeFilename(String inputName) {
    return inputName.replaceAll("[^a-zA-Z0-9-_\\.]", "_");
}

Ce que cela fait est de remplacer chaque caractère qui n'est pas une lettre, un chiffre, un trait de soulignement ou un point par un trait de soulignement, en utilisant regex.

Cela signifie que quelque chose comme "Comment convertir £ en $" deviendra "How_to_convert___to__". Certes, ce résultat n'est pas très convivial, mais il est sûr et les noms de répertoires / fichiers résultants sont garantis de fonctionner partout. Dans mon cas, le résultat n'est pas montré à l'utilisateur, et n'est donc pas un problème, mais vous voudrez peut-être modifier l'expression régulière pour être plus permissive.

Il convient de noter qu'un autre problème que j'ai rencontré était que j'obtenais parfois des noms identiques (car il est basé sur l'entrée de l'utilisateur), vous devez donc être conscient de cela, car vous ne pouvez pas avoir plusieurs répertoires / fichiers avec le même nom dans un seul répertoire . J'ai juste ajouté l'heure et la date actuelles, et une courte chaîne aléatoire pour éviter cela. (une chaîne aléatoire réelle, pas un hachage du nom de fichier, car des noms de fichiers identiques entraîneront des hachages identiques)

De plus, vous devrez peut-être tronquer ou raccourcir la chaîne résultante, car elle peut dépasser la limite de 255 caractères de certains systèmes.

JonasCz - Réintégrer Monica
la source
6
Un autre problème est qu'il est spécifique aux langues qui utilisent des caractères ASCII. Pour les autres langues, il en résulterait des noms de fichiers composés uniquement de traits de soulignement.
Andy Thomas
13

Pour ceux qui recherchent une solution générale, il peut s'agir de critères courants:

  • Le nom de fichier doit ressembler à la chaîne.
  • Le codage doit être réversible dans la mesure du possible.
  • La probabilité de collision doit être minimisée.

Pour ce faire, nous pouvons utiliser regex pour faire correspondre les caractères illégaux, les encoder en pourcentage , puis contraindre la longueur de la chaîne encodée.

private static final Pattern PATTERN = Pattern.compile("[^A-Za-z0-9_\\-]");

private static final int MAX_LENGTH = 127;

public static String escapeStringAsFilename(String in){

    StringBuffer sb = new StringBuffer();

    // Apply the regex.
    Matcher m = PATTERN.matcher(in);

    while (m.find()) {

        // Convert matched character to percent-encoded.
        String replacement = "%"+Integer.toHexString(m.group().charAt(0)).toUpperCase();

        m.appendReplacement(sb,replacement);
    }
    m.appendTail(sb);

    String encoded = sb.toString();

    // Truncate the string.
    int end = Math.min(encoded.length(),MAX_LENGTH);
    return encoded.substring(0,end);
}

Motifs

Le modèle ci-dessus est basé sur un sous-ensemble conservateur de caractères autorisés dans la spécification POSIX .

Si vous souhaitez autoriser le caractère point, utilisez:

private static final Pattern PATTERN = Pattern.compile("[^A-Za-z0-9_\\-\\.]");

Méfiez-vous simplement des chaînes telles que "." et ".."

Si vous souhaitez éviter les collisions sur les systèmes de fichiers insensibles à la casse, vous devrez échapper les majuscules:

private static final Pattern PATTERN = Pattern.compile("[^a-z0-9_\\-]");

Ou échapper aux lettres minuscules:

private static final Pattern PATTERN = Pattern.compile("[^A-Z0-9_\\-]");

Plutôt que d'utiliser une liste blanche, vous pouvez choisir de mettre sur liste noire les caractères réservés pour votre système de fichiers spécifique. EG Cette regex convient aux systèmes de fichiers FAT32:

private static final Pattern PATTERN = Pattern.compile("[%\\.\"\\*/:<>\\?\\\\\\|\\+,\\.;=\\[\\]]");

Longueur

Sur Android, 127 caractères est la limite de sécurité. De nombreux systèmes de fichiers autorisent 255 caractères.

Si vous préférez conserver la queue plutôt que la tête de votre corde, utilisez:

// Truncate the string.
int start = Math.max(0,encoded.length()-MAX_LENGTH);
return encoded.substring(start,encoded.length());

Décodage

Pour reconvertir le nom de fichier dans la chaîne d'origine, utilisez:

URLDecoder.decode(filename, "UTF-8");

Limites

Étant donné que les chaînes plus longues sont tronquées, il existe un risque de collision de noms lors du codage ou de corruption lors du décodage.

SharkAlley
la source
1
Posix autorise les traits d'union - vous devriez l'ajouter au modèle -Pattern.compile("[^A-Za-z0-9_\\-]")
mkdev
Trait d'union ajouté. Merci :)
SharkAlley
Je ne pense pas que l'encodage en pourcentage fonctionnerait bien sur Windows, étant donné qu'il s'agit d'un caractère réservé ..
Amalgovinus
1
Ne considère pas les langues autres que l'anglais.
NateS
5

Essayez d'utiliser l'expression régulière suivante qui remplace chaque caractère de nom de fichier non valide par un espace:

public static String toValidFileName(String input)
{
    return input.replaceAll("[:\\\\/*\"?|<>']", " ");
}
BullyWiiPlaza
la source
Les espaces sont désagréables pour les CLI; envisagez de remplacer par _ou -.
sdgfsdh
3

Choisissez votre poison parmi les options présentées par commons-codec , exemple:

String safeFileName = DigestUtils.sha1(filename);
hd1
la source
Oui, cela a été corrigé maintenant
hd1
1
Utiliser sha1; shaest obsolète.
Daniel
2

Ce n'est probablement pas le moyen le plus efficace, mais montre comment le faire à l'aide de pipelines Java 8:

private static String sanitizeFileName(String name) {
    return name
            .chars()
            .mapToObj(i -> (char) i)
            .map(c -> Character.isWhitespace(c) ? '_' : c)
            .filter(c -> Character.isLetterOrDigit(c) || c == '-' || c == '_')
            .map(String::valueOf)
            .collect(Collectors.joining());
}

La solution pourrait être améliorée en créant un collecteur personnalisé qui utilise StringBuilder, afin que vous n'ayez pas à convertir chaque caractère léger en une chaîne lourde.

voho
la source
-1

Vous pouvez supprimer les caractères non valides ('/', '\', '?', '*'), Puis l'utiliser.

Burkhard
la source
1
Cela introduirait la possibilité de nommer des conflits. Ie, "tes? T", "tes * t" et "test" iraient dans le même fichier "test".
vog le
Vrai. Puis remplacez-les. Par exemple, '/' -> slash, '*' -> star ... ou utilisez un hachage comme le suggère vog.
Burkhard
4
Vous êtes toujours ouvert à la possibilité de nommer des conflits
Brian Agnew
2
"?" et "*" sont des caractères autorisés dans les noms de fichiers. Ils ont seulement besoin d'être échappés dans les commandes shell, car généralement le globbing est utilisé. Au niveau de l'API fichier, cependant, il n'y a pas de problème.
vog le
2
@Brian Agnew: pas vraiment vrai. Les schémas qui encodent des caractères non valides à l'aide d'un schéma d'échappement réversible ne donneront pas de collisions.
Stephen C