¿Cómo puedo codificar de forma segura una cadena en Java para usar como nombre de archivo?

117

Recibo una cadena de un proceso externo. Quiero usar esa Cadena para hacer un nombre de archivo y luego escribir en ese archivo. Aquí está mi fragmento de código para hacer esto:

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

Si s contiene un carácter no válido, como '/' en un sistema operativo basado en Unix, entonces se lanza (correctamente) una java.io.FileNotFoundException.

¿Cómo puedo codificar de forma segura la cadena para que pueda usarse como nombre de archivo?

Editar: Lo que espero es una llamada API que haga esto por mí.

Puedo hacer esto:

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

Pero no estoy seguro de si URLEncoder es confiable para este propósito.

Steve McLeod
fuente
1
¿Cuál es el propósito de codificar la cadena?
Stephen C
3
@Stephen C: El propósito de codificar la cadena es hacerla adecuada para su uso como nombre de archivo, como lo hace java.net.URLEncoder para las URL.
Steve McLeod
1
Oh ya veo. ¿Es necesario que la codificación sea reversible?
Stephen C
@Stephen C: No, no es necesario que sea reversible, pero me gustaría que el resultado se pareciera lo más posible a la cadena original.
Steve McLeod
1
¿Es necesario que la codificación oscurezca el nombre original? ¿Tiene que ser 1 a 1? es decir, ¿están bien las colisiones?
Stephen C

Respuestas:

17

Si desea que el resultado se parezca al archivo original, SHA-1 o cualquier otro esquema de hash no es la respuesta. Si se deben evitar las colisiones, la simple sustitución o eliminación de los caracteres "malos" tampoco es la respuesta.

En su lugar, quiere algo como esto. (Nota: esto debe tratarse como un ejemplo ilustrativo, no como algo para copiar y pegar).

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);

Esta solución proporciona una codificación reversible (sin colisiones) donde las cadenas codificadas se parecen a las cadenas originales en la mayoría de los casos. Supongo que está utilizando caracteres de 8 bits.

URLEncoder funciona, pero tiene la desventaja de que codifica una gran cantidad de caracteres de nombres de archivos legales.

Si desea una solución no garantizada para ser reversible, simplemente elimine los caracteres 'malos' en lugar de reemplazarlos con secuencias de escape.


El reverso de la codificación anterior debería ser igualmente sencillo de implementar.

Stephen C
fuente
105

Mi sugerencia es adoptar un enfoque de "lista blanca", lo que significa que no intente filtrar los personajes malos. En su lugar, defina lo que está bien. Puede rechazar el nombre del archivo o filtrarlo. Si quieres filtrarlo:

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

Lo que hace es reemplazar cualquier carácter que no sea un número, letra o guión bajo por nada. Alternativamente, puede reemplazarlos con otro carácter (como un guión bajo).

El problema es que si se trata de un directorio compartido, no querrás que se produzca una colisión de nombres de archivo. Incluso si las áreas de almacenamiento del usuario están segregadas por usuario, puede terminar con un nombre de archivo en colisión con solo filtrar los caracteres incorrectos. El nombre que pone un usuario suele ser útil si alguna vez quiere descargarlo también.

Por esta razón, tiendo a permitir que el usuario ingrese lo que quiera, almacene el nombre de archivo según un esquema de mi propia elección (por ejemplo, userId_fileId) y luego almacene el nombre de archivo del usuario en una tabla de base de datos. De esa manera, puede mostrarlo al usuario, almacenar las cosas como desee y no comprometer la seguridad ni borrar otros archivos.

También puede aplicar un hash al archivo (p. Ej., Hash MD5), pero luego no puede enumerar los archivos que puso el usuario (de todos modos, no con un nombre significativo).

EDITAR: expresión regular fija para java

cletus
fuente
No creo que sea una buena idea proporcionar primero la mala solución. Además, MD5 es un algoritmo hash casi roto. Recomiendo al menos SHA-1 o mejor.
vog
19
Con el fin de crear un nombre de archivo único, ¿a quién le importa si el algoritmo está "roto"?
cletus
3
@cletus: el problema es que diferentes cadenas se asignarán al mismo nombre de archivo; es decir, colisión.
Stephen C
3
Una colisión tendría que ser deliberada, la pregunta original no habla de que estas cadenas sean elegidas por un atacante.
tialaramex
8
Necesita usar "\\W+"para la expresión regular en Java. La barra invertida se aplica primero a la cadena en sí y \Wno es una secuencia de escape válida. Intenté editar la respuesta, pero parece que alguien rechazó mi edición :(
vadipp
35

Depende de si la codificación debe ser reversible o no.

Reversible

Utilice la codificación URL ( java.net.URLEncoder) para reemplazar los caracteres especiales con %xx. ¡Tenga en cuenta que se ocupa de los casos especiales en los que la cadena es igual ., es igual ..o está vacía! ¹ Muchos programas utilizan la codificación URL para crear nombres de archivos, por lo que esta es una técnica estándar que todos comprenden.

Irreversible

Utilice un hash (por ejemplo, SHA-1) de la cadena dada. Los algoritmos hash modernos ( no MD5) pueden considerarse libres de colisiones. De hecho, tendrá un gran avance en criptografía si encuentra una colisión.


¹ Puede manejar los 3 casos especiales con elegancia usando un prefijo como "myApp-". Si coloca el archivo directamente en $HOME, tendrá que hacerlo de todos modos para evitar conflictos con archivos existentes como ".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
fuente
2
La idea de URLEncoder de qué es un carácter especial puede no ser correcta.
Stephen C
4
@vog: URLEncoder falla para "." y "..". Estos deben estar codificados o de lo contrario chocará con las entradas del directorio en $ HOME
Stephen C
6
@vog: "*" solo está permitido en la mayoría de los sistemas de archivos basados ​​en Unix, NTFS y FAT32 no lo admiten.
Jonathan
1
"." y ".." pueden resolverse escapando puntos a% 2E cuando la cadena es solo puntos (si desea minimizar las secuencias de escape). '*' también se puede reemplazar por "% 2A".
viphe
1
tenga en cuenta que cualquier enfoque que alargue el nombre del archivo (cambiando los caracteres individuales a% 20 o lo que sea) invalidará algunos nombres de archivo que están cerca del límite de longitud (255 caracteres para sistemas Unix)
smcg
24

Esto es lo que uso:

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

Lo que hace es reemplazar cada carácter que no sea una letra, número, guión bajo o punto con un guión bajo, usando expresiones regulares.

Esto significa que algo como "Cómo convertir £ a $" se convertirá en "Cómo_convertir___a__". Es cierto que este resultado no es muy fácil de usar, pero es seguro y se garantiza que los nombres de directorio / archivo resultantes funcionarán en todas partes. En mi caso, el resultado no se muestra al usuario y, por lo tanto, no es un problema, pero es posible que desee modificar la expresión regular para que sea más permisivo.

Vale la pena señalar que otro problema que encontré fue que a veces obtenía nombres idénticos (ya que se basa en la entrada del usuario), por lo que debe tenerlo en cuenta, ya que no puede tener varios directorios / archivos con el mismo nombre en un solo directorio . Acabo de agregar la hora y fecha actuales, y una cadena aleatoria corta para evitar eso. (una cadena aleatoria real, no un hash del nombre del archivo, ya que los nombres de archivo idénticos darán como resultado hash idénticos)

Además, es posible que deba truncar o acortar la cadena resultante, ya que puede exceder el límite de 255 caracteres que tienen algunos sistemas.

JonasCz - Reincorporar a Monica
fuente
6
Otro problema es que es específico de los idiomas que utilizan caracteres ASCII. Para otros idiomas, el resultado sería que los nombres de archivo no consistieran más que en guiones bajos.
Andy Thomas
13

Para aquellos que buscan una solución general, estos pueden ser criterios comunes:

  • El nombre del archivo debe parecerse a la cadena.
  • La codificación debe ser reversible siempre que sea posible.
  • Debe minimizarse la probabilidad de colisiones.

Para lograr esto, podemos usar expresiones regulares para hacer coincidir caracteres ilegales, codificarlos en porcentaje y luego restringir la longitud de la cadena codificada.

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);
}

Patrones

El patrón anterior se basa en un subconjunto conservador de caracteres permitidos en la especificación POSIX .

Si desea permitir el carácter de punto, use:

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

Solo tenga cuidado con cadenas como "." y ".."

Si desea evitar colisiones en sistemas de archivos que no distinguen entre mayúsculas y minúsculas, deberá escapar de las mayúsculas:

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

O escapar de las minúsculas:

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

En lugar de utilizar una lista blanca, puede optar por incluir en la lista negra los caracteres reservados para su sistema de archivos específico. Por ejemplo, esta expresión regular se adapta a los sistemas de archivos FAT32:

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

Longitud

En Android, 127 caracteres es el límite seguro. Muchos sistemas de archivos permiten 255 caracteres.

Si prefiere retener la cola, en lugar de la cabeza de su cuerda, use:

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

Descodificación

Para convertir el nombre de archivo de nuevo a la cadena original, use:

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

Limitaciones

Debido a que las cadenas más largas se truncan, existe la posibilidad de una colisión de nombres al codificar o de daños al decodificar.

SharkAlley
fuente
1
Posix permite guiones - debe agregarlo al patrón -Pattern.compile("[^A-Za-z0-9_\\-]")
mkdev
Guiones añadidos. Gracias :)
SharkAlley
No creo que la codificación porcentual funcione bien en Windows, dado que es un carácter reservado ..
Amalgovinus
1
No considera idiomas distintos del inglés.
NateS
5

Intente usar la siguiente expresión regular que reemplaza cada carácter de nombre de archivo no válido con un espacio:

public static String toValidFileName(String input)
{
    return input.replaceAll("[:\\\\/*\"?|<>']", " ");
}
BullyWiiPlaza
fuente
Los espacios son desagradables para las CLI; considere reemplazar con _o -.
sdgfsdh
2

Probablemente esta no sea la forma más efectiva, pero muestra cómo hacerlo usando canalizaciones de 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 solución podría mejorarse creando un colector personalizado que use StringBuilder, por lo que no tiene que convertir cada carácter ligero en una cadena pesada.

voho
fuente
-1

Puede eliminar los caracteres no válidos ('/', '\', '?', '*') Y luego usarlos.

Burkhard
fuente
1
Esto introduciría la posibilidad de conflictos de nombres. Es decir, "tes? T", "tes * t" y "test" irían al mismo archivo "test".
vog
Cierto. Luego reemplácelos. Por ejemplo, '/' -> barra, '*' -> estrella ... o usa un hash como sugiere vog.
Burkhard
4
Usted es siempre abierto a la posibilidad de conflictos de nombres
Brian Agnew
2
"?" y "*" son caracteres permitidos en los nombres de archivo. Solo necesitan escaparse en comandos de shell, porque generalmente se usa globbing. Sin embargo, a nivel de API de archivo, no hay problema.
vog
2
@Brian Agnew: en realidad no es cierto. Los esquemas que codifican caracteres no válidos usando un esquema de escape reversible no darán colisiones.
Stephen C