如何在Java中安全地编码字符串以用作文件名?


117

我正在从外部进程接收字符串。我想使用该字符串创建文件名,然后写入该文件。这是执行此操作的代码段:

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

如果s包含无效字符,例如在基于Unix的OS中为'/',则(正确)引发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所做的那样。
史蒂夫·麦克劳德

1
哦,我明白了。编码是否需要可逆?
Stephen C

@Stephen C:不,它不需要是可逆的,但是我希望结果尽可能类似于原始字符串。
史蒂夫·麦克劳德

1
编码是否需要掩盖原始名称?是否需要一对一;即碰撞可以吗?
Stephen C

Answers:


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正则表达式


我认为先提供不良解决方案不是一个好主意。另外,MD5是几乎破解的哈希算法。我建议至少SHA-1或更高。
vog

19
为了创建唯一的文件名,谁在乎算法是否“损坏”?
cletus

3
@cletus:问题在于不同的字符串将映射到相同的文件名;即碰撞。
Stephen C

3
碰撞必须是有意的,原始问题并不涉及攻击者选择的这些字符串。
tialaramex

8
您需要使用"\\W+"Java中的regexp。反斜杠首先适用于字符串本身,并且\W不是有效的转义序列。我试图编辑答案,但似乎有人拒绝了我的编辑:(
vadipp

35

这取决于编码是否应可逆。

可逆的

使用URL编码(java.net.URLEncoder)将特殊字符替换为%xx。请注意,在特殊情况下,字符串等于.,等于..或为空!¹

不可逆的

使用给定字符串的哈希(例如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!?");
    }
}


2
URLEncoder关于什么是特殊字符的想法可能不正确。
Stephen C

4
@vog:URLEncoder失败。和“ ..”。这些必须经过编码,否则您将与$ HOME中的目录条目冲突
Stephen C

6
@vog:“ *”仅在大多数基于Unix的文件系统中允许,NTFS和FAT32不支持它。
乔纳森

1
“。” 当字符串仅是点时(如果要最小化转义序列),可以通过将点转义到%2E来处理“ ..”。'*'也可以替换为“%2A”。
viphe

1
请注意,任何延长文件名的方法(将单个字符更改为%20或其他方式)都会使一些接近长度限制(对于Unix系统为255个字符)的文件名无效
smcg 2014年

24

这是我使用的:

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

这是使用正则表达式将每个不是字母,数字,下划线或点的字符替换为下划线。

这意味着“如何将£转换为$”之类的内容将变为“ How_to_convert___to__”。可以肯定的是,此结果不是很友好,但是很安全,并且所生成的目录/文件名可以在任何地方使用。就我而言,结果不会显示给用户,因此这不是问题,但是您可能需要更改正则表达式以使其更宽松。

值得一提的是,我遇到的另一个问题是有时我会得到相同的名称(因为它基于用户输入),因此您应该意识到这一点,因为在单个目录中不能有多个具有相同名称的目录/文件。我只是在当前时间和日期之前加上一个简短的随机字符串来避免这种情况。(实际的随机字符串,而不是文件名的哈希,因为相同的文件名将导致相同的哈希值)

另外,您可能需要截断或缩短结果字符串,因为它可能超过某些系统所限制的255个字符。


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

局限性

由于较长的字符串会被截断,因此在编码时可能会发生名称冲突,在解码时可能会损坏名称。


1
Posix允许使用连字符-您应将其添加到格式中Pattern.compile("[^A-Za-z0-9_\\-]")
mkdev

添加了连字符。谢谢:)
SharkAlley 2015年

考虑到这是保留字符,我认为百分比编码不会在Windows上正常工作
。– Amalgovinus

1
不考虑非英语语言。
NateS

5

尝试使用以下正则表达式将所有无效的文件名字符替换为一个空格:

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

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

真正。然后更换它们。例如,'/'->斜杠,'*'-> star ...或使用哈希(如vog建议)。
Burkhard

4
总是对命名冲突的可能性

2
“?” 和“ *”是文件名中允许使用的字符。它们仅需要在shell命令中转义,因为通常使用globbing。但是,在文件API级别上,没有问题。
vog

2
@Brian Agnew:并非如此。使用可逆转义方案对无效字符进行编码的方案不会产生冲突。
Stephen C
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.