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

我正在接收来自外部进程的字符串。我想使用这个 String 来创建一个文件名,然后写入该文件。下面是我的代码片段:

    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 是否可靠。

96550 次浏览

这取决于编码应该是可逆的还是不可逆的。

可逆转的

使用 URL 编码(java.net.URLEncoder)将特殊字符替换为 %xx。注意,要注意字符串等于 .、等于 ..或为空的 特殊情况!1许多程序使用 URL 编码来创建文件名,所以这是一个每个人都能理解的标准技术。

不可逆转

使用给定字符串的哈希(例如 SHA-1)。现代哈希算法(没有 MD5)可以被认为是无冲突的。事实上,如果你发现一个碰撞,你将在密码学上有一个突破。


1你可以使用一个像 "myApp-"这样的前缀来优雅地处理所有3种特殊情况。如果您将文件直接放到 $HOME中,那么无论如何都必须这样做,以避免与现有文件发生冲突,例如“。巴希尔”。
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!?");
}
}

我的建议是采取“白名单”的方法,意思是不要试图过滤掉不好的字符。而是定义什么是可以的。您可以拒绝文件名或过滤它。如果你想过滤它:

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

它的作用是将 不是中的任何数字、字母或下划线替换为空。或者,您可以用另一个字符(如下划线)替换它们。

问题是,如果这是一个共享目录,那么您不希望文件名冲突。即使用户存储区域是由用户隔离的,只要过滤掉不好的字符,就可能导致文件名发生冲突。用户输入的名称通常是有用的,如果他们想要下载它太。

出于这个原因,我倾向于允许用户输入他们想要的内容,根据我自己选择的方案(例如 userId _ fileId)存储文件名,然后将用户的文件名存储在数据库表中。这样你就可以把它显示回给用户,按照你想要的方式存储东西,而且不会影响安全性或者清除其他文件。

您也可以散列文件(例如 MD5散列) ,但是不能列出用户放入的文件(无论如何不能使用有意义的名称)。

编辑: Java 的固定正则表达式

如果希望结果类似于原始文件,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可以工作,但它的缺点是编码了大量合法的文件名字符。

如果您想要一个不保证可逆的解决方案,那么只需删除“坏”字符,而不是用转义序列替换它们。


上述编码的反向应该同样直接地实现。

由 commons-codec 提供的选项中挑选你的毒药,例如:

String safeFileName = DigestUtils.sha1(filename);

对于那些寻求一般性解决方案的人来说,这些可能是共同的标准:

  • 文件名应类似于字符串。
  • 在可能的情况下,编码应该是可逆的。
  • 碰撞的概率应该尽量减少。

为了实现这一点,我们可以使用正则表达式来匹配非法字符,百分比编码他们,然后约束编码字符串的长度。

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_\\-]");

您可以选择为特定的文件系统列出保留字符的黑名单,而不是使用白名单。脑电图。这个正则表达式适用于 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");

限制

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

尝试使用下面的正则表达式,它将每个无效的文件名字符替换为一个空格:

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

这可能不是最有效的方法,但是展示了如何使用 Java8管道:

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 的自定义收集器,可以改进解决方案,这样就不必将每个轻量级字符强制转换为重量级字符串。

以下是我的方法:

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

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

这意味着“如何转换成 $”将变成“如何转换成 _ _ _”。无可否认,这个结果并不是非常友好的用户界面,但是它是安全的,并且生成的目录/文件名保证可以在任何地方工作。在我的示例中,结果没有显示给用户,因此不是问题,但是您可能希望更改正则表达式以使其更为宽松。

值得注意的是,我遇到的另一个问题是,我有时会得到相同的名称(因为它是基于用户输入的) ,所以您应该注意到这一点,因为您不能在一个目录中有多个目录/文件具有相同的名称。我只是预先设置了当前的时间和日期,以及一个简短的随机字符串来避免这一点。(一个实际的随机字符串,而不是文件名的散列,因为相同的文件名将导致相同的散列)

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

如果你的 不在乎可逆性,但是希望在大多数情况下拥有与 跨平台 兼容的好名字,这里是我的方法。

//: and ? into .
name = name.replaceAll("[\\?:]", ".");


//" into '
name = name.replaceAll("[\"]", "'");


//\, / and | into ,
name = name.replaceAll("[\\\\/|]", ",");


//<, > and * int _
name = name.replaceAll("[<>*]", "_");
return name;

这就变成了:

This is a **Special** "Test": A\B/C is <BETTER> than D|E|F! Or?

变成:

This is a __Special__ 'Test'. A,B,C is _BETTER_ than D,E,F! Or.

如果您的系统将文件存储在区分大小写的文件系统中(可以将 a.txtA.txt存储在同一目录中) ,那么您可以在变体“ base64url”中使用 Base64。根据 https://en.wikipedia.org/wiki/Base64#Variants_summary_table,它是“ URL-and filename-safe”,因为它使用“-”和“ _”而不是“ +”和“/”。

Apache commons-codec 实现了这一点: https://commons.apache.org/proper/commons-codec/apidocs/org/apache/commons/codec/binary/Base64.html#encodeBase64URLSafeString-byte:A-

如果您的文件名/目录名太长,那么将其分成多个目录: [前128个字符]/[后128个字符]/..。

由于 Base64字符集中没有点,所以不必关心特殊的文件名,如 ...或文件名末尾的最后一个点。 而且你也不用在意尾随的空格。

如果你的文件系统(或者你的操作系统)中有保留字/文件名,比如 Windows 中的 LPT4,那么 Base64url 编码的结果就等于这样的保留字,你可以用 @字符(@LPT4)来掩盖它,在解码之前去掉掩盖的 @字符。在这里查找保留字: https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words

我想,在 Linux 系统中,这可以正反向工作而不会丢失数据/字符。Windows 将拒绝拥有两个名为“ ABCD”和“ ABCD”的文件。

转换你的字符串十六进制(例如使用这个 https://commons.apache.org/proper/commons-codec/apidocs/org/apache/commons/codec/binary/Hex.html#encodeHexString-byte:A-)。向前和向后工作(https://commons.apache.org/proper/commons-codec/apidocs/org/apache/commons/codec/binary/Hex.html#decodeHex-char:A-)。

将生成的 String 拆分为128个字符的块,每个块有一个(子)目录。

即使在不区分大小写的文件系统/操作系统中也没有冲突(就像 Base64中的情况一样)。

目前我不知道任何保留的文件名(如 COMLPT1,...)会与 HEX 值发生冲突,所以我想没有必要进行屏蔽。即使需要屏蔽,也可以在文件名前使用例如 @,并在将文件名解码为原始 String 时将其删除。