Как я могу безопасно закодировать строку в Java для использования в качестве имени файла?

117

Я получаю строку от внешнего процесса. Я хочу использовать эту строку для создания имени файла, а затем записать в этот файл. Вот мой фрагмент кода для этого:

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

Если s содержит недопустимый символ, такой как '/' в ОС на базе Unix, тогда (справедливо) генерируется исключение java.io.FileNotFoundException.

Как я могу безопасно закодировать строку, чтобы ее можно было использовать в качестве имени файла?

Изменить: я надеюсь на вызов API, который сделает это за меня.

Я могу это сделать:

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

Но я не уверен, надежен ли URLEncoder для этой цели.

Стив Маклеод
источник
1
Какова цель кодирования строки?
Stephen C
3
@Stephen C: Цель кодирования строки - сделать ее пригодной для использования в качестве имени файла, как это делает java.net.URLEncoder для URL-адресов.
Steve McLeod
1
О, я вижу. Должна ли кодировка быть обратимой?
Stephen C
@Stephen C: Нет, это не обязательно должно быть обратимым, но я бы хотел, чтобы результат был как можно ближе к исходной строке.
Steve McLeod
1
Должна ли кодировка скрывать исходное имя? Это должно быть 1: 1; т.е. столкновения в порядке?
Stephen C,

Ответы:

17

Если вы хотите, чтобы результат напоминал исходный файл, SHA-1 или любая другая схема хеширования не подходит. Если необходимо избегать коллизий, то простая замена или удаление «плохих» символов тоже не решение.

Вместо этого вы хотите что-то вроде этого. (Примечание: это следует рассматривать как иллюстративный пример, а не как что-то для копирования и вставки.)

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

Это решение обеспечивает обратимое кодирование (без конфликтов), при котором закодированные строки в большинстве случаев напоминают исходные строки. Я предполагаю, что вы используете 8-битные символы.

URLEncoder работает, но имеет тот недостаток, что он кодирует множество допустимых символов имени файла.

Если вам нужно решение с не гарантированной обратимостью, просто удалите «плохие» символы, а не заменяйте их управляющими последовательностями.


Обратное кодирование выше должно быть столь же простым для реализации.

Стивен С
источник
105

Я предлагаю использовать подход «белого списка», то есть не пытайтесь отфильтровать плохие символы. Вместо этого определите, что хорошо. Вы можете либо отклонить имя файла, либо отфильтровать его. Если вы хотите его отфильтровать:

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

Что это делает заменяет любой символ , который не является числом, буква или подчеркивание ни с чем. В качестве альтернативы вы можете заменить их другим символом (например, подчеркиванием).

Проблема в том, что если это общий каталог, вы не хотите конфликтовать с именами файлов. Даже если пользовательские области хранения разделены пользователем, вы можете получить конфликтующее имя файла, просто отфильтровав плохие символы. Имя, введенное пользователем, часто бывает полезным, если он тоже когда-нибудь захочет его скачать.

По этой причине я обычно позволяю пользователю вводить то, что он хочет, сохранять имя файла на основе выбранной мной схемы (например, userId_fileId), а затем сохранять имя файла пользователя в таблице базы данных. Таким образом, вы можете отображать его обратно пользователю, хранить вещи так, как вы хотите, и не подвергать риску безопасность и не стирать другие файлы.

Вы также можете хэшировать файл (например, MD5-хеш), но тогда вы не можете перечислить файлы, которые пользователь вставил (в любом случае, с осмысленным именем).

РЕДАКТИРОВАТЬ: исправлено регулярное выражение для java

Cletus
источник
Я не думаю, что сначала предлагать плохое решение - хорошая идея. Кроме того, MD5 - это почти взломанный алгоритм хеширования. Я рекомендую как минимум SHA-1 или лучше.
vog
19
Кого волнует, "сломан" ли алгоритм в целях создания уникального имени файла?
cletus
3
@cletus: проблема в том, что разные строки соответствуют одному и тому же имени файла; т.е. столкновение.
Stephen C
3
Коллизия должна быть преднамеренной, исходный вопрос не говорит о том, что эти строки выбираются злоумышленником.
tialaramex
8
Вам нужно использовать "\\W+"для регулярного выражения в Java. Обратная косая черта сначала применяется к самой строке и \Wне является допустимой escape-последовательностью. Я попытался отредактировать ответ, но похоже, что кто-то отклонил мою правку :(
vadipp
35

Это зависит от того, должно ли кодирование быть обратимым или нет.

обратимый

Используйте кодировку URL ( java.net.URLEncoder) для замены специальных символов на %xx. Обратите внимание, что вы позаботитесь о специальных случаях, когда строка равна ., равна ..или пуста! ¹ Многие программы используют кодировку URL-адресов для создания имен файлов, поэтому это стандартный метод, понятный всем.

Необратимый

Используйте хэш (например, SHA-1) данной строки. Современные алгоритмы хеширования ( не MD5) можно считать бесконфликтными. Фактически, у вас будет прорыв в криптографии, если вы обнаружите коллизию.


¹ Вы можете элегантно обработать все 3 особых случая, используя префикс, например "myApp-". Если вы поместите файл напрямую $HOME, вам все равно придется это сделать, чтобы избежать конфликтов с существующими файлами, такими как ".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
источник
2
Представление URLEncoder о том, что такое специальный символ, может быть неверным.
Stephen C
4
@vog: URLEncoder не работает для "." и "..". Они должны быть закодированы, иначе вы столкнетесь с записями каталога в $ HOME
Стивен Си,
6
@vog: "*" разрешено только в большинстве файловых систем на основе Unix, NTFS и FAT32 его не поддерживают.
Джонатан
1
"" и ".." можно обработать, экранируя точки до% 2E, когда строка состоит только из точек (если вы хотите минимизировать escape-последовательности). '*' также можно заменить на "% 2A".
viphe 03
1
обратите внимание, что любой подход, который удлиняет имя файла (изменяя отдельные символы на% 20 или что-то еще), сделает недействительными некоторые имена файлов, длина которых близка к пределу (255 символов для систем Unix)
smcg
24

Вот что я использую:

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

Это означает замену каждого символа, который не является буквой, числом, подчеркиванием или точкой, подчеркиванием, используя регулярное выражение.

Это означает, что что-то вроде «Как конвертировать £ в $» станет «How_to_convert___to__». По общему признанию, этот результат не очень удобен для пользователя, но он безопасен, и полученные имена каталогов / файлов гарантированно работают везде. В моем случае результат не отображается пользователю и, следовательно, не является проблемой, но вы можете изменить регулярное выражение, чтобы оно было более разрешительным.

Стоит отметить, что еще одна проблема, с которой я столкнулся, заключалась в том, что я иногда получал идентичные имена (поскольку это основано на вводе пользователя), поэтому вы должны знать об этом, поскольку вы не можете иметь несколько каталогов / файлов с одинаковым именем в одном каталоге. . Я просто добавил текущее время и дату, а также короткую случайную строку, чтобы этого избежать. (фактическая случайная строка, а не хеш имени файла, поскольку идентичные имена файлов приведут к одинаковым хешам)

Кроме того, вам может потребоваться усечь или иным образом сократить результирующую строку, поскольку она может превышать ограничение в 255 символов, которое есть в некоторых системах.

JonasCz - Восстановить Монику
источник
6
Другая проблема заключается в том, что это характерно для языков, в которых используются символы ASCII. Для других языков это приведет к тому, что имена файлов будут состоять только из подчеркивания.
Энди Томас
13

Для тех, кто ищет общее решение, это могут быть общие критерии:

  • Имя файла должно напоминать строку.
  • По возможности кодирование должно быть обратимым.
  • Вероятность столкновений должна быть минимизирована.

Для этого мы можем использовать регулярное выражение для сопоставления недопустимых символов, кодировать их в процентах , а затем ограничивать длину закодированной строки.

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

Узоры

Приведенный выше шаблон основан на консервативном подмножестве разрешенных символов в спецификации POSIX .

Если вы хотите разрешить символ точки, используйте:

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

Только будьте осторожны со строками типа "." и ".."

Если вы хотите избежать конфликтов в файловых системах, нечувствительных к регистру, вам нужно избегать заглавных букв:

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

Или экранируйте строчные буквы:

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

Вместо использования белого списка вы можете занести в черный список зарезервированные символы для вашей конкретной файловой системы. EG Это регулярное выражение подходит для файловых систем FAT32:

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

Длина

На Android безопасным пределом является 127 символов . Многие файловые системы позволяют использовать 255 символов.

Если вы предпочитаете сохранить хвост, а не головку вашей веревки, используйте:

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

Декодирование

Чтобы преобразовать имя файла обратно в исходную строку, используйте:

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

Ограничения

Поскольку более длинные строки усекаются, существует вероятность конфликта имен при кодировании или повреждения при декодировании.

SharkAlley
источник
1
Posix позволяет использовать дефисы - вы должны добавить их в шаблон -Pattern.compile("[^A-Za-z0-9_\\-]")
mkdev
Добавлены дефисы. Спасибо :)
SharkAlley
Я не думаю, что процентное кодирование будет хорошо работать в Windows, учитывая, что это зарезервированный символ ..
Амальговинус
1
Не учитывает неанглийские языки.
NateS
5

Попробуйте использовать следующее регулярное выражение, которое заменяет каждый недопустимый символ имени файла пробелом:

public static String toValidFileName(String input)
{
    return input.replaceAll("[:\\\\/*\"?|<>']", " ");
}
BullyWiiPlaza
источник
Пробелы неприятны для CLI; подумайте о замене на _или -.
sdgfsdh
2

Вероятно, это не самый эффективный способ, но он показывает, как это сделать с помощью конвейеров 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());
}

Решение можно улучшить, создав собственный сборщик, который использует StringBuilder, поэтому вам не нужно преобразовывать каждый легкий символ в строку с тяжелым весом.

Вохо
источник
-1

Вы можете удалить недопустимые символы ('/', '\', '?', '*'), А затем использовать его.

Буркхард
источник
1
Это может привести к конфликтам имен. Т.е., «tes? T», «tes * t» и «test» будут отправлены в один и тот же файл «test».
vog
Правда. Затем замените их. Например, '/' -> косая черта, '*' -> звездочка ... или используйте хеш, как предлагает vog.
Burkhard
4
Вы всегда открыты для возможных конфликтов имен,
Брайан Агнью,
2
"?" и «*» - допустимые символы в именах файлов. Их нужно только экранировать в командах оболочки, потому что обычно используется глобализация. Однако на уровне файлового API проблем нет.
vog
2
@ Брайан Агнью: это не совсем так. Схемы, которые кодируют недопустимые символы с использованием схемы обратимого экранирования, не вызывают коллизий.
Stephen C