Jak bezpiecznie zakodować ciąg w Javie jako nazwę pliku?

117

Otrzymuję ciąg z zewnętrznego procesu. Chcę użyć tego ciągu, aby utworzyć nazwę pliku, a następnie zapisać do tego pliku. Oto mój fragment kodu, aby to zrobić:

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

Jeśli s zawiera nieprawidłowy znak, taki jak „/” w systemie operacyjnym opartym na systemie Unix, to (słusznie) wyrzucany jest wyjątek java.io.FileNotFoundException.

Jak bezpiecznie zakodować ciąg znaków, aby można go było użyć jako nazwy pliku?

Edycja: liczę na wywołanie interfejsu API, które robi to za mnie.

Mogę to zrobić:

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

Ale nie jestem pewien, czy URLEncoder jest niezawodny do tego celu.

Steve McLeod
źródło
1
Jaki jest cel kodowania łańcucha?
Stephen C
3
@Stephen C: Celem kodowania ciągu jest przystosowanie go do użycia jako nazwy pliku, tak jak robi to java.net.URLEncoder w przypadku adresów URL.
Steve McLeod
1
Rozumiem. Czy kodowanie musi być odwracalne?
Stephen C
@Stephen C: Nie, to nie musi być odwracalne, ale chciałbym, aby wynik był jak najbliżej oryginalnego ciągu.
Steve McLeod
1
Czy kodowanie musi przesłaniać oryginalną nazwę? Czy to musi być 1 do 1; tj. czy kolizje są w porządku?
Stephen C

Odpowiedzi:

17

Jeśli chcesz, aby wynik przypominał oryginalny plik, SHA-1 lub inny schemat mieszania nie jest odpowiedzią. Jeśli trzeba unikać kolizji, to zwykła zamiana lub usunięcie „złych” znaków również nie jest rozwiązaniem.

Zamiast tego chcesz czegoś takiego. (Uwaga: należy to traktować jako przykład ilustrujący, a nie coś do skopiowania i wklejenia).

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

To rozwiązanie zapewnia odwracalne kodowanie (bez kolizji), w którym zakodowane ciągi w większości przypadków przypominają oryginalne. Zakładam, że używasz 8-bitowych znaków.

URLEncoder działa, ale ma tę wadę, że koduje całe mnóstwo dozwolonych znaków nazw plików.

Jeśli chcesz mieć rozwiązanie, którego nie gwarantuje się odwracalności, po prostu usuń „złe” znaki zamiast zastępować je sekwencjami ucieczki.


Odwrócenie powyższego kodowania powinno być równie łatwe do wdrożenia.

Stephen C.
źródło
105

Moją sugestią jest przyjęcie podejścia „białej listy”, co oznacza, że ​​nie próbuj odfiltrowywać złych znaków. Zamiast tego określ, co jest w porządku. Możesz odrzucić nazwę pliku lub ją przefiltrować. Jeśli chcesz to przefiltrować:

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

To powoduje, że każdy znak, który nie jest cyfrą, literą ani podkreśleniem, zostaje zastąpiony niczym. Alternatywnie możesz zastąpić je innym znakiem (np. Podkreśleniem).

Problem polega na tym, że jeśli jest to katalog współdzielony, nie chcesz kolizji nazw plików. Nawet jeśli obszary pamięci użytkowników są segregowane według użytkownika, możesz skończyć z kolidującą nazwą pliku, po prostu odfiltrowując złe znaki. Nazwa wprowadzona przez użytkownika jest często przydatna, jeśli chcą ją również pobrać.

Z tego powodu staram się pozwalać użytkownikowi wprowadzić to, czego chce, przechowywać nazwę pliku w oparciu o wybrany przez siebie schemat (np. UserId_fileId), a następnie przechowywać nazwę pliku użytkownika w tabeli bazy danych. W ten sposób możesz wyświetlić go z powrotem użytkownikowi, przechowywać rzeczy tak, jak chcesz i nie narażasz bezpieczeństwa ani nie usuwasz innych plików.

Możesz również zaszyfrować plik (np. Hash MD5), ale wtedy nie możesz wyświetlić listy plików, które umieścił użytkownik (i tak nie ze zrozumiałą nazwą).

EDYCJA: Naprawiono regex dla java

cletus
źródło
Nie wydaje mi się, aby najpierw zapewnić złe rozwiązanie. Ponadto MD5 jest prawie złamanym algorytmem mieszania. Polecam przynajmniej SHA-1 lub lepszy.
vog
19
W celu stworzenia unikalnej nazwy pliku, kogo obchodzi, czy algorytm jest „uszkodzony”?
cletus
3
@cletus: problem polega na tym, że różne ciągi znaków będą mapowane na tę samą nazwę pliku; tj. kolizja.
Stephen C
3
Zderzenie musiałoby być celowe, pierwotne pytanie nie mówi o tym, że te struny zostały wybrane przez atakującego.
tialaramex
8
Musisz użyć "\\W+"wyrażenia regularnego w Javie. Ukośnik odwrotny najpierw odnosi się do samego ciągu i \Wnie jest prawidłową sekwencją ucieczki. Próbowałem edytować odpowiedź, ale wygląda na to, że ktoś odrzucił moją zmianę :(
vadipp
35

Zależy to od tego, czy kodowanie powinno być odwracalne, czy nie.

Odwracalny

Użyj kodowania adresu URL ( java.net.URLEncoder), aby zastąpić znaki specjalne %xx. Zwróć uwagę, że zajmujesz się specjalnymi przypadkami, w których ciąg jest równy ., równy ..lub pusty! ¹ Wiele programów używa kodowania adresów URL do tworzenia nazw plików, więc jest to standardowa technika, którą każdy rozumie.

Nieodwracalny

Użyj skrótu (np. SHA-1) podanego ciągu. Nowoczesne algorytmy haszujące ( nie MD5) można uznać za bezkolizyjne. W rzeczywistości będziesz miał przełom w kryptografii, jeśli znajdziesz kolizję.


¹ Możesz elegancko obsłużyć wszystkie 3 przypadki specjalne, używając przedrostka, takiego jak "myApp-". Jeśli umieścisz plik bezpośrednio w $HOME, będziesz musiał to zrobić, aby uniknąć konfliktów z istniejącymi plikami, takimi jak „.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
źródło
2
Pomysł URLEncodera na to, co jest znakiem specjalnym, może nie być poprawny.
Stephen C
4
@vog: URLEncoder nie działa dla „.” i "..". Muszą być one zakodowane, w przeciwnym razie zderzą się z pozycjami katalogu w $ HOME
Stephen C
6
@vog: „*” jest dozwolone tylko w większości systemów plików opartych na Uniksie, NTFS i FAT32 go nie obsługują.
Jonathan
1
„.” i ".." można rozwiązać poprzez zmianę znaczenia kropek do% 2E, gdy ciąg znaków składa się tylko z kropek (jeśli chcesz zminimalizować sekwencje specjalne). „*” można również zastąpić „% 2A”.
viphe
1
zwróć uwagę, że każde podejście, które wydłuża nazwę pliku (poprzez zmianę pojedynczych znaków na% 20 lub cokolwiek innego) unieważni niektóre nazwy plików, które są bliskie limitowi długości (255 znaków dla systemów uniksowych)
smcg
24

Oto czego używam:

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

To, co robi, to zastąpienie każdego znaku, który nie jest literą, cyfrą, podkreśleniem lub kropką, podkreśleniem, używając wyrażenia regularnego.

Oznacza to, że coś w rodzaju „Jak zamienić GBP na $” zmieni się w „How_to_convert___to__”. Wprawdzie wynik ten nie jest zbyt przyjazny dla użytkownika, ale jest bezpieczny, a wynikowe nazwy katalogów / plików gwarantują, że będą działać wszędzie. W moim przypadku wynik nie jest wyświetlany użytkownikowi, a zatem nie stanowi problemu, ale możesz chcieć zmienić wyrażenie regularne, aby było bardziej liberalne.

Warto zauważyć, że innym problemem, który napotkałem, było to, że czasami otrzymywałem identyczne nazwy (ponieważ są one oparte na danych wejściowych użytkownika), więc powinieneś być tego świadomy, ponieważ nie możesz mieć wielu katalogów / plików o tej samej nazwie w jednym katalogu . Po prostu dodałem aktualny czas i datę oraz krótki, losowy ciąg, aby tego uniknąć. (rzeczywisty losowy ciąg znaków, a nie skrót nazwy pliku, ponieważ identyczne nazwy plików spowodują identyczne skróty)

Konieczne może być również obcięcie lub w inny sposób skrócenie otrzymanego ciągu, ponieważ może on przekroczyć limit 255 znaków, jaki mają niektóre systemy.

JonasCz - Przywróć Monikę
źródło
6
Innym problemem jest to, że jest to specyficzne dla języków używających znaków ASCII. W przypadku innych języków nazwy plików składałyby się wyłącznie z podkreśleń.
Andy Thomas
13

Dla tych, którzy szukają ogólnego rozwiązania, mogą to być typowe kryteria:

  • Nazwa pliku powinna przypominać ciąg.
  • W miarę możliwości kodowanie powinno być odwracalne.
  • Należy zminimalizować prawdopodobieństwo kolizji.

Aby to osiągnąć, możemy użyć wyrażenia regularnego, aby dopasować niedozwolone znaki, zakodować je procentowo , a następnie ograniczyć długość zakodowanego ciągu.

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

Wzory

Powyższy wzorzec jest oparty na konserwatywnym podzbiorze dozwolonych znaków w specyfikacji POSIX .

Jeśli chcesz zezwolić na znak kropki, użyj:

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

Uważaj tylko na ciągi typu „”. i ".."

Jeśli chcesz uniknąć kolizji w systemach plików bez rozróżniania wielkości liter, musisz uciec z wielkich liter:

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

Lub unikaj małych liter:

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

Zamiast używać białej listy, możesz zdecydować się na czarną listę znaków zastrzeżonych dla swojego konkretnego systemu plików. EG To wyrażenie regularne pasuje do systemów plików FAT32:

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

Długość

W systemie Android bezpieczny limit to 127 znaków . Wiele systemów plików dopuszcza 255 znaków.

Jeśli wolisz zachować ogon zamiast główki sznurka, użyj:

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

Rozszyfrowanie

Aby przekonwertować nazwę pliku z powrotem na oryginalny ciąg, użyj:

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

Ograniczenia

Ponieważ dłuższe łańcuchy są obcinane, istnieje możliwość kolizji nazw podczas kodowania lub uszkodzenia podczas dekodowania.

SharkAlley
źródło
1
Posix dopuszcza łączniki - należy dodać je do wzorca -Pattern.compile("[^A-Za-z0-9_\\-]")
mkdev
Dodano myślniki. Dzięki :)
SharkAlley
Nie sądzę, aby kodowanie procentowe działało dobrze w systemie Windows, biorąc pod uwagę, że jest to zastrzeżony znak ...
Amalgovinus
1
Nie uwzględnia języków innych niż angielski.
NateS
5

Spróbuj użyć następującego wyrażenia regularnego, które zastępuje każdy nieprawidłowy znak w nazwie pliku spacją:

public static String toValidFileName(String input)
{
    return input.replaceAll("[:\\\\/*\"?|<>']", " ");
}
BullyWiiPlaza
źródło
Spacje są nieprzyjemne dla CLI; rozważ wymianę na _lub -.
sdgfsdh
2

To prawdopodobnie nie jest najbardziej efektywny sposób, ale pokazuje, jak to zrobić za pomocą potoków 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());
}

Rozwiązanie można ulepszyć, tworząc niestandardowy kolektor, który używa StringBuilder, dzięki czemu nie trzeba rzutować każdego lekkiego znaku na ciężki ciąg.

voho
źródło
-1

Możesz usunąć nieprawidłowe znaki („/”, „\”, „?”, „*”), A następnie ich użyć.

Burkhard
źródło
1
Wprowadziłoby to możliwość nazewnictwa konfliktów. To znaczy, „tes? T”, „tes * t” i „test” powinny przejść do tego samego pliku „test”.
vog
Prawdziwe. Następnie wymień je. Na przykład „/” -> slash, „*” -> star ... lub użyj hasha zgodnie z sugestią vog.
Burkhard
4
Jesteś zawsze otwarty na możliwość konfliktu nazewnictwa
Brian Agnew
2
„?” i „*” to dozwolone znaki w nazwach plików. Trzeba je tylko zmienić w poleceniach powłoki, ponieważ zwykle używany jest globbing. Na poziomie API plików nie ma jednak problemu.
vog
2
@Brian Agnew: nie do końca prawda. Schematy, które kodują nieprawidłowe znaki za pomocą odwracalnego schematu zmiany znaczenia, nie powodują kolizji.
Stephen C