Bagaimana saya dapat dengan aman menyandikan string di Java untuk digunakan sebagai nama file?

117

Saya menerima string dari proses eksternal. Saya ingin menggunakan String itu untuk membuat nama file, dan kemudian menulis ke file itu. Berikut potongan kode saya untuk melakukan ini:

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

Jika s berisi karakter yang tidak valid, seperti '/' dalam OS berbasis Unix, maka java.io.FileNotFoundException (dengan benar) dilemparkan.

Bagaimana saya dapat menyandikan String dengan aman sehingga dapat digunakan sebagai nama file?

Sunting: Yang saya harapkan adalah panggilan API yang melakukan ini untuk saya.

Aku bisa melakukan ini:

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

Tapi saya tidak yakin apakah URLEncoder itu dapat diandalkan untuk tujuan ini.

Steve McLeod
sumber
1
Apa tujuan encoding string?
Stephen C
3
@ Stephen C: Tujuan pengkodean string adalah agar cocok untuk digunakan sebagai nama file, seperti yang dilakukan java.net.URLEncoder untuk URL.
Steve McLeod
1
Oh begitu. Apakah pengkodean harus dapat dibalik?
Stephen C
@ Stephen C: Tidak, itu tidak perlu bisa dibalik, tapi saya ingin hasilnya sedekat mungkin dengan string aslinya.
Steve McLeod
1
Apakah pengkodean perlu mengaburkan nama aslinya? Apakah harus 1-to-1; yaitu apakah tabrakan OK?
Stephen C

Jawaban:

17

Jika Anda ingin hasilnya menyerupai file asli, SHA-1 atau skema hashing lainnya bukanlah jawabannya. Jika tabrakan harus dihindari, maka penggantian atau penghapusan karakter "buruk" juga bukanlah jawabannya.

Sebaliknya Anda menginginkan sesuatu seperti ini. (Catatan: ini harus diperlakukan sebagai contoh ilustrasi, bukan sesuatu untuk disalin dan ditempel.)

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

Solusi ini memberikan pengkodean yang dapat dibalik (tanpa benturan) di mana string yang disandikan menyerupai string asli dalam banyak kasus. Saya berasumsi bahwa Anda menggunakan karakter 8-bit.

URLEncoder berfungsi, tetapi memiliki kelemahan karena ia mengkodekan banyak karakter nama file legal.

Jika Anda menginginkan solusi yang tidak dijamin menjadi dapat dibalik, cukup hapus karakter 'buruk' daripada menggantinya dengan urutan pelolosan.


Kebalikan dari pengkodean di atas harus sama-sama lurus ke depan untuk diterapkan.

Stephen C
sumber
105

Saran saya adalah untuk mengambil pendekatan "daftar putih", artinya jangan mencoba dan menyaring karakter buruk. Sebaliknya tentukan apa yang OK. Anda dapat menolak nama file atau memfilternya. Jika Anda ingin memfilternya:

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

Apa yang dilakukannya adalah mengganti karakter apa pun yang bukan angka, huruf, atau garis bawah dengan apa pun. Atau Anda dapat menggantinya dengan karakter lain (seperti garis bawah).

Masalahnya adalah jika ini adalah direktori bersama maka Anda tidak ingin nama file bertabrakan. Bahkan jika area penyimpanan pengguna dipisahkan oleh pengguna, Anda mungkin berakhir dengan nama file yang bertabrakan hanya dengan menyaring karakter buruk. Nama yang dimasukkan pengguna sering kali berguna jika mereka ingin mengunduhnya juga.

Untuk alasan ini saya cenderung mengizinkan pengguna untuk memasukkan apa yang mereka inginkan, menyimpan nama file berdasarkan skema yang saya pilih sendiri (misalnya userId_fileId) dan kemudian menyimpan nama file pengguna dalam tabel database. Dengan begitu, Anda dapat menampilkannya kembali kepada pengguna, menyimpan hal-hal yang Anda inginkan dan tidak membahayakan keamanan atau menghapus file lain.

Anda juga dapat mencirikan file (mis. Hash MD5) tetapi kemudian Anda tidak dapat mencantumkan file yang dimasukkan pengguna (toh tidak dengan nama yang berarti).

EDIT: Memperbaiki regex untuk java

cletus
sumber
Menurut saya bukan ide yang baik untuk memberikan solusi yang buruk terlebih dahulu. Selain itu, MD5 adalah algoritma hash yang hampir retak. Saya merekomendasikan setidaknya SHA-1 atau lebih baik.
vog
19
Untuk tujuan membuat nama file yang unik, siapa yang peduli jika algoritme "rusak"?
cletus
3
@cletus: masalahnya adalah string yang berbeda akan dipetakan ke nama file yang sama; yaitu tabrakan.
Stephen C
3
Tabrakan harus disengaja, pertanyaan awal tidak berbicara tentang string yang dipilih oleh penyerang.
tialaramex
8
Anda perlu menggunakan "\\W+"regexp di Java. Garis miring terbalik pertama kali diterapkan ke string itu sendiri, dan \Wbukan merupakan urutan escape yang valid. Saya mencoba mengedit jawabannya, tetapi sepertinya seseorang menolak suntingan saya :(
vadipp
35

Itu tergantung pada apakah pengkodean harus dibalik atau tidak.

Dapat dibalik

Gunakan pengkodean URL ( java.net.URLEncoder) untuk mengganti karakter khusus dengan %xx. Perhatikan bahwa Anda menangani kasus khusus di mana string sama ., sama ..atau kosong! ¹ Banyak program menggunakan pengkodean URL untuk membuat nama file, jadi ini adalah teknik standar yang dipahami semua orang.

Tidak dapat diubah

Gunakan hash (misalnya SHA-1) dari string yang diberikan. Algoritme hash modern ( bukan MD5) dapat dianggap bebas benturan. Faktanya, Anda akan mengalami terobosan dalam kriptografi jika Anda menemukan tabrakan.


¹ Anda dapat menangani semua 3 kasus khusus dengan elegan menggunakan awalan seperti "myApp-". Jika Anda memasukkan file secara langsung ke dalamnya $HOME, Anda harus melakukannya untuk menghindari konflik dengan file yang sudah ada seperti ".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
sumber
2
Ide URLEncoder tentang apa itu karakter khusus mungkin tidak benar.
Stephen C
4
@vog: URLEncoder gagal untuk "." dan "..". Ini harus dikodekan atau Anda akan bertabrakan dengan entri direktori di $ HOME
Stephen C
6
@vog: "*" hanya diperbolehkan di sebagian besar sistem berkas berbasis Unix, NTFS dan FAT32 tidak mendukungnya.
Jonathan
1
"." dan ".." dapat ditangani dengan mengosongkan titik ke% 2E jika string hanya berupa titik (jika Anda ingin meminimalkan urutan escape). '*' juga bisa diganti dengan "% 2A".
viphe
1
perhatikan bahwa pendekatan apa pun yang memperpanjang nama file (dengan mengubah karakter tunggal menjadi% 20 atau apa pun) akan membatalkan beberapa nama file yang mendekati batas panjang (255 karakter untuk sistem Unix)
smcg
24

Inilah yang saya gunakan:

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

Apa yang dilakukannya adalah mengganti setiap karakter yang bukan huruf, angka, garis bawah atau titik dengan garis bawah, menggunakan regex.

Ini berarti bahwa sesuatu seperti "Bagaimana mengubah £ menjadi $" akan menjadi "How_to_convert___to__". Memang, hasil ini tidak terlalu ramah pengguna, tetapi aman dan nama direktori / file yang dihasilkan dijamin berfungsi di mana-mana. Dalam kasus saya, hasilnya tidak ditampilkan kepada pengguna, dan karenanya tidak menjadi masalah, tetapi Anda mungkin ingin mengubah regex menjadi lebih permisif.

Perlu dicatat bahwa masalah lain yang saya temui adalah terkadang saya mendapatkan nama yang identik (karena ini didasarkan pada input pengguna), jadi Anda harus menyadarinya, karena Anda tidak dapat memiliki banyak direktori / file dengan nama yang sama dalam satu direktori. . Saya baru saja menambahkan waktu dan tanggal saat ini, dan string acak pendek untuk menghindarinya. (string acak aktual, bukan hash nama file, karena nama file yang identik akan menghasilkan hash yang identik)

Juga, Anda mungkin perlu memotong atau memperpendek string yang dihasilkan, karena mungkin melebihi batas 255 karakter yang dimiliki beberapa sistem.

JonasCz - Kembalikan Monica
sumber
6
Masalah lainnya adalah bahwa ini khusus untuk bahasa yang menggunakan karakter ASCII. Untuk bahasa lain, ini akan menghasilkan nama file yang hanya terdiri dari garis bawah.
Andy Thomas
13

Bagi mereka yang mencari solusi umum, ini mungkin kriteria umum:

  • Nama file harus menyerupai string.
  • Pengkodean harus dapat dibalik jika memungkinkan.
  • Kemungkinan tabrakan harus diminimalkan.

Untuk mencapai ini, kita dapat menggunakan regex untuk mencocokkan karakter ilegal, mengenkodenya dalam persen , lalu membatasi panjang string yang dikodekan.

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

Pola

Pola di atas didasarkan pada subset konservatif dari karakter yang diperbolehkan dalam spesifikasi POSIX .

Jika Anda ingin mengizinkan karakter titik, gunakan:

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

Berhati-hatilah dengan string seperti "." dan ".."

Jika Anda ingin menghindari tabrakan pada sistem file yang tidak peka huruf besar kecil, Anda harus keluar dari kapital:

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

Atau hindari huruf kecil:

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

Daripada menggunakan daftar putih, Anda dapat memilih untuk memasukkan karakter yang dicadangkan ke daftar hitam untuk sistem file spesifik Anda. EG Regex ini sesuai dengan sistem file FAT32:

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

Panjangnya

Di Android, 127 karakter adalah batas aman. Banyak sistem file mengizinkan 255 karakter.

Jika Anda lebih suka mempertahankan ekor, daripada kepala senar, gunakan:

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

Decoding

Untuk mengubah nama file kembali ke string asli, gunakan:

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

Batasan

Karena string yang lebih panjang dipotong, ada kemungkinan nama bertabrakan saat encoding, atau rusak saat decoding.

SharkAlley
sumber
1
Posix mengizinkan tanda hubung - Anda harus menambahkannya ke pola -Pattern.compile("[^A-Za-z0-9_\\-]")
mkdev
Tanda hubung ditambahkan. Terima kasih :)
SharkAlley
Saya tidak berpikir encoding persen akan bekerja dengan baik di windows, mengingat itu adalah karakter yang dicadangkan ..
Amalgovinus
1
Tidak mempertimbangkan bahasa non-Inggris.
NateS
5

Coba gunakan regex berikut yang menggantikan setiap karakter nama file yang tidak valid dengan spasi:

public static String toValidFileName(String input)
{
    return input.replaceAll("[:\\\\/*\"?|<>']", " ");
}
BullyWiiPlaza
sumber
Spasi tidak bagus untuk CLI; pertimbangkan untuk mengganti dengan _atau -.
sdgfsdh
2

Ini mungkin bukan cara yang paling efektif, tetapi menunjukkan cara melakukannya menggunakan pipeline 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());
}

Solusinya dapat ditingkatkan dengan membuat kolektor kustom yang menggunakan StringBuilder, jadi Anda tidak perlu mentransmisikan setiap karakter ringan ke string kelas berat.

voho
sumber
-1

Anda dapat menghapus karakter yang tidak valid ('/', '\', '?', '*') Dan kemudian menggunakannya.

Burkhard
sumber
1
Ini akan memperkenalkan kemungkinan konflik penamaan. Yaitu, "tes? T", "tes * t" dan "test" akan pergi ke file "test" yang sama.
vog
Benar. Lalu gantilah. Misalnya, '/' -> garis miring, '*' -> bintang ... atau gunakan hash seperti yang disarankan vog.
Burkhard
4
Anda selalu terbuka terhadap kemungkinan konflik penamaan
Brian Agnew
2
"?" dan "*" adalah karakter yang diperbolehkan dalam nama file. Mereka hanya perlu di-escape dalam perintah shell, karena biasanya digunakan globbing. Namun, pada level API file, tidak ada masalah.
vog
2
@ Brian Agnew: sebenarnya tidak benar. Skema yang menyandikan karakter yang tidak valid menggunakan skema pelolosan yang dapat dibalik tidak akan menimbulkan benturan.
Stephen C