Como posso codificar com segurança uma string em Java para usar como nome de arquivo?


117

Estou recebendo uma string de um processo externo. Quero usar essa string para criar um nome de arquivo e, em seguida, gravar nesse arquivo. Este é o meu snippet de código para fazer isso:

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

Se s contiver um caractere inválido, como '/' em um sistema operacional baseado em Unix, uma java.io.FileNotFoundException é (corretamente) lançada.

Como posso codificar com segurança a String para que possa ser usada como um nome de arquivo?

Edit: O que estou esperando é uma chamada de API que faça isso para mim.

Eu posso fazer isso:

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

Mas não tenho certeza se o URLEncoder é confiável para essa finalidade.


1
Qual é o propósito de codificar a string?
Stephen C

3
@Stephen C: O propósito de codificar a string é torná-la adequada para uso como um nome de arquivo, como java.net.URLEncoder faz para URLs.
Steve McLeod

1
Ah eu vejo. A codificação precisa ser reversível?
Stephen C

@Stephen C: Não, não precisa ser reversível, mas gostaria que o resultado se parecesse o mais próximo possível com a string original.
Steve McLeod

1
A codificação precisa ocultar o nome original? Precisa ser 1 para 1; ou seja, as colisões estão OK?
Stephen C

Respostas:


17

Se você quiser que o resultado se pareça com o arquivo original, SHA-1 ou qualquer outro esquema de hash não é a resposta. Se as colisões devem ser evitadas, então a simples substituição ou remoção de caracteres "ruins" também não é a resposta.

Em vez disso, você quer algo assim. (Observação: isso deve ser tratado como um exemplo ilustrativo, não algo para copiar e colar.)

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 solução fornece uma codificação reversível (sem colisões) onde as strings codificadas se assemelham às strings originais na maioria dos casos. Presumo que você esteja usando caracteres de 8 bits.

URLEncoder funciona, mas tem a desvantagem de codificar muitos caracteres de nomes de arquivos legais.

Se você quiser uma solução reversível não garantida, simplesmente remova os caracteres 'ruins' em vez de substituí-los por sequências de escape.


O reverso da codificação acima deve ser igualmente simples de implementar.


105

Minha sugestão é adotar uma abordagem de "lista branca", ou seja, não tente filtrar personagens ruins. Em vez disso, defina o que está OK. Você pode rejeitar o nome do arquivo ou filtrá-lo. Se você deseja filtrar:

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

O que isso faz é substituir qualquer caractere que não seja um número, letra ou sublinhado por nada. Alternativamente, você pode substituí-los por outro caractere (como um sublinhado).

O problema é que, se este for um diretório compartilhado, você não deseja colisão de nomes de arquivo. Mesmo se as áreas de armazenamento do usuário forem segregadas por usuário, você pode acabar com um nome de arquivo colidindo apenas ao filtrar os caracteres ruins. O nome que um usuário insere costuma ser útil se ele também quiser fazer o download.

Por este motivo, tendo a permitir que o usuário insira o que deseja, armazene o nome do arquivo com base em um esquema de minha própria escolha (por exemplo, userId_fileId) e, em seguida, armazene o nome do arquivo do usuário em uma tabela de banco de dados. Dessa forma, você pode exibi-lo de volta para o usuário, armazenar as coisas como quiser e não comprometer a segurança ou apagar outros arquivos.

Você também pode fazer o hash do arquivo (por exemplo, hash MD5), mas não pode listar os arquivos que o usuário colocou (não com um nome significativo de qualquer maneira).

EDIT: Regex fixo para java


Não acho uma boa ideia fornecer a solução ruim primeiro. Além disso, o MD5 é um algoritmo de hash quase quebrado. Eu recomendo pelo menos SHA-1 ou melhor.
vog

19
Com o propósito de criar um nome de arquivo exclusivo, quem se importa se o algoritmo está "quebrado"?
cletus

3
@cletus: o problema é que strings diferentes serão mapeadas para o mesmo nome de arquivo; ou seja, colisão.
Stephen C

3
Uma colisão teria que ser deliberada, a pergunta original não fala sobre essas strings serem escolhidas por um invasor.
tialaramex

8
Você precisa usar "\\W+"para regexp em Java. A barra invertida se aplica primeiro à própria string e \Wnão é uma sequência de escape válida. Tentei editar a resposta, mas parece que alguém rejeitou minha edição :(
vadipp

35

Depende se a codificação deve ser reversível ou não.

Reversível

Use a codificação de URL ( java.net.URLEncoder) para substituir caracteres especiais por %xx. Observe que você cuida dos casos especiais onde a string é igual ., igual ..ou vazia! ¹ Muitos programas usam codificação de URL para criar nomes de arquivo, portanto, esta é uma técnica padrão que todos entendem.

Irreversível

Use um hash (por exemplo, SHA-1) da string fornecida. Algoritmos hash modernos ( não MD5) podem ser considerados livres de colisão. Na verdade, você terá um avanço na criptografia se encontrar uma colisão.


¹ Você pode lidar com todos os 3 casos especiais elegantemente usando um prefixo como "myApp-". Se você colocar o arquivo diretamente em $HOME, terá que fazer isso de qualquer maneira para evitar conflitos com arquivos 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!?");
    }
}


2
A ideia de URLEncoder do que é um caractere especial pode não estar correta.
Stephen C

4
@vog: URLEncoder falha para "." e "..". Eles devem ser codificados ou você irá colidir com as entradas do diretório em $ HOME
Stephen C

6
@vog: "*" só é permitido na maioria dos sistemas de arquivos baseados em Unix, NTFS e FAT32 não o suportam.
Jonathan

1
"." e ".." pode ser tratado escapando pontos para% 2E quando a string é apenas pontos (se você quiser minimizar as sequências de escape). '*' também pode ser substituído por "% 2A".
viphe

1
observe que qualquer abordagem que estenda o nome do arquivo (alterando caracteres únicos para% 20 ou qualquer outro) invalidará alguns nomes de arquivo que estão próximos do limite de comprimento (255 caracteres para sistemas Unix)
smcg

24

Aqui está o que eu uso:

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

O que isso faz é substituir cada caractere que não seja uma letra, número, sublinhado ou ponto por um sublinhado, usando regex.

Isso significa que algo como "Como converter £ em $" se tornará "How_to_convert___to__". É certo que esse resultado não é muito amigável, mas é seguro e os nomes de diretório / arquivo resultantes funcionam em qualquer lugar. No meu caso, o resultado não é mostrado ao usuário e, portanto, não é um problema, mas você pode querer alterar o regex para ser mais permissivo.

Vale a pena observar que outro problema que encontrei foi que às vezes eu recebia nomes idênticos (já que é baseado na entrada do usuário), então você deve estar ciente disso, já que não pode haver vários diretórios / arquivos com o mesmo nome em um único diretório . Eu apenas acrescentei a hora e a data atuais e uma string curta aleatória para evitar isso. (uma string real aleatória, não um hash do nome do arquivo, uma vez que nomes de arquivos idênticos resultarão em hashes idênticos)

Além disso, pode ser necessário truncar ou encurtar a string resultante, pois ela pode exceder o limite de 255 caracteres que alguns sistemas têm.


6
Outro problema é que ele é específico para idiomas que usam caracteres ASCII. Para outros idiomas, isso resultaria em nomes de arquivos consistindo em nada além de sublinhados.
Andy Thomas de

13

Para quem procura uma solução geral, estes podem ser os critérios comuns:

  • O nome do arquivo deve ser semelhante à string.
  • A codificação deve ser reversível sempre que possível.
  • A probabilidade de colisões deve ser minimizada.

Para conseguir isso, podemos usar regex para corresponder a caracteres ilegais, codificá- los por cento e , em seguida, restringir o comprimento da string 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);
}

Padrões

O padrão acima é baseado em um subconjunto conservador de caracteres permitidos na especificação POSIX .

Se você quiser permitir o caractere de ponto, use:

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

Apenas tome cuidado com strings como "." e ".."

Se você quiser evitar colisões em sistemas de arquivos que não diferenciam maiúsculas de minúsculas, será necessário escapar de maiúsculas:

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

Ou escape de letras minúsculas:

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

Em vez de usar uma lista de permissões, você pode optar por criar uma lista negra de caracteres reservados para seu sistema de arquivos específico. EX: Este regex é adequado para sistemas de arquivos FAT32:

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

comprimento

No Android, 127 caracteres é o limite seguro. Muitos sistemas de arquivos permitem 255 caracteres.

Se você preferir manter a cauda, ​​em vez da ponta da corda, use:

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

Decodificação

Para converter o nome do arquivo de volta à string original, use:

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

Limitações

Como as strings mais longas são truncadas, existe a possibilidade de uma colisão de nomes durante a codificação ou corrupção durante a decodificação.


1
Posix permite hifens - você deve adicioná-los ao padrão -Pattern.compile("[^A-Za-z0-9_\\-]")
mkdev

Hífens adicionados. Obrigado :)
SharkAlley

Não acho que a codificação por cento funcione bem no Windows, visto que é um caractere reservado.
Amalgovinus

1
Não considera idiomas diferentes do inglês.
NateS

5

Tente usar o seguinte regex que substitui cada caractere de nome de arquivo inválido por um espaço:

public static String toValidFileName(String input)
{
    return input.replaceAll("[:\\\\/*\"?|<>']", " ");
}

Os espaços são desagradáveis ​​para CLIs; considere a substituição por _ou -.
sdgfsdh


2

Provavelmente, essa não é a maneira mais eficaz, mas mostra como fazer isso usando 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());
}

A solução pode ser melhorada com a criação de um coletor personalizado que usa StringBuilder, para que você não precise converter cada caractere leve em uma string pesada.


-1

Você pode remover os caracteres inválidos ('/', '\', '?', '*') E então usá-los.


1
Isso introduziria a possibilidade de conflitos de nomenclatura. Ou seja, "tes? T", "tes * t" e "teste" iriam para o mesmo arquivo "teste".
vog

Verdade. Em seguida, substitua-os. Por exemplo, '/' -> barra, '*' -> estrela ... ou use um hash como vog sugerido.
Burkhard

4
Você está sempre aberto à possibilidade de conflitos de nomenclatura
Brian Agnew

2
"?" e "*" são caracteres permitidos em nomes de arquivo. Eles só precisam ter escape em comandos de shell, porque geralmente é usado o globbing. No nível da API do arquivo, entretanto, não há problema.
vog

2
@Brian Agnew: não é verdade. Esquemas que codificam caracteres inválidos usando um esquema de escape reversível não geram colisões.
Stephen C
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.