tl;博士
调用下面定义的is_path_exists_or_creatable()函数。
严格来说是 Python 3。这就是我们滚动的方式。
两个问题的故事
“我如何测试路径名的有效性,以及对于有效路径名,这些路径的存在性或可写性”的问题?显然是两个独立的问题。两者都很有趣,而且都没有在这里得到真正令人满意的答案......或者,嗯,我可以grep的任何地方。
vikki 的answer 可能是最接近的,但具有以下显着缺点:
- 不必要地打开(...然后无法可靠地关闭)文件句柄。
- 不必要地写入(...然后无法可靠地关闭或删除)0 字节文件。
- 忽略区分不可忽略的无效路径名和可忽略的文件系统问题的特定于操作系统的错误。不出所料,这在 Windows 下至关重要。 (见下文。)
- 忽略外部进程同时(重新)移动要测试的路径名的父目录导致的竞争条件。 (见下文。)
- 忽略此路径名导致的连接超时,该路径名驻留在陈旧、缓慢或其他暂时无法访问的文件系统上。这可能将面向公众的服务暴露给潜在的DoS 驱动的攻击。 (见下文。)
我们会解决所有这些问题。
问题 #0:什么是路径名有效性?
在将我们脆弱的肉服扔进布满蟒蛇的痛苦沼泽之前,我们应该先定义一下“路径名有效性”的含义。究竟是什么定义了有效性?
“路径名有效性”是指路径名相对于当前系统的根文件系统的句法正确性——无论是该路径还是父目录其物理存在。如果路径名符合根文件系统的所有语法要求,则路径名在此定义下的语法是正确的。
“根文件系统”是指:
- 在 POSIX 兼容系统上,文件系统挂载到根目录 (
/)。
- 在 Windows 上,文件系统挂载到
%HOMEDRIVE%,即包含当前 Windows 安装的以冒号后缀的驱动器号(通常但不一定必须是 C:)。
反过来,“句法正确性”的含义取决于根文件系统的类型。对于ext4(以及大多数但不是所有POSIX兼容的)文件系统,路径名在语法上是正确的当且仅当该路径名:
- 不包含空字节(即 Python 中的
\x00)。 这是所有 POSIX 兼容文件系统的硬性要求。
- 不包含长度超过 255 个字节的路径组件(例如,Python 中的
'a'*256)。路径组件是不包含/ 字符的路径名的最长子字符串(例如,路径名/bergtatt/ind/i/fjeldkamrene 中的bergtatt、ind、i 和fjeldkamrene)。
语法正确。根文件系统。就是这样。
问题 #1:我们现在应该如何进行路径名有效性?
在 Python 中验证路径名非常不直观。我在这里与Fake Name 完全一致:官方的os.path 包应该为此提供一个开箱即用的解决方案。由于未知(并且可能是令人不快的)原因,它没有。幸运的是,展开您自己的临时解决方案并不是 令人痛苦的......
好吧,确实如此。这很讨厌;它可能在发光时发出咯咯声和咯咯笑声。但是你要做什么? Nuthin'。
我们很快就会陷入低级代码的放射性深渊。但首先,让我们谈谈高级商店。标准的os.stat() 和os.lstat() 函数在传递无效路径名时会引发以下异常:
- 对于驻留在不存在目录中的路径名,
FileNotFoundError 的实例。
- 对于位于现有目录中的路径名:
- 在 Windows 下,
WindowsError 的实例winerror 属性为123(即ERROR_INVALID_NAME)。
- 在所有其他操作系统下:
- 对于包含空字节的路径名(即
'\x00'),TypeError 的实例。
- 对于包含长度超过 255 个字节的路径组件的路径名,
OSError 的实例errcode 属性为:
- 在 SunOS 和 *BSD 系列操作系统下,
errno.ERANGE。 (这似乎是一个操作系统级别的错误,也称为 POSIX 标准的“选择性解释”。)
- 在所有其他操作系统下,
errno.ENAMETOOLONG。
至关重要的是,这意味着只有存在于现有目录中的路径名是可验证的。os.stat() 和 os.lstat() 函数在传递驻留在不存在目录中的路径名时会引发通用 FileNotFoundError 异常,无论这些路径名是否无效。目录存在优先于路径名无效。
这是否意味着驻留在不存在目录中的路径名是不可验证的?是的——除非我们修改这些路径名以驻留在现有目录中。然而,这甚至是安全可行的吗?修改路径名不应该阻止我们验证原始路径名吗?
要回答这个问题,请回想一下,ext4 文件系统上语法正确的路径名不包含包含空字节或 (B) 的路径组件长度为 255 个字节。因此,当且仅当该路径名中的所有路径组件都有效时,ext4 路径名才有效。这是大多数感兴趣的real-world filesystems。
这种迂腐的见解真的对我们有帮助吗?是的。它将一次性验证完整路径名的较大问题减少为仅验证该路径名中的所有路径组件的较小问题。通过遵循以下算法,任何任意路径名都是可验证的(无论该路径名是否驻留在现有目录中)以跨平台方式:
- 将该路径名拆分为路径组件(例如,将路径名
/troldskog/faren/vild 拆分为列表 ['', 'troldskog', 'faren', 'vild'])。
- 对于每个这样的组件:
- 将保证与该组件一起存在的目录的路径名加入到新的临时路径名中(例如,
/troldskog)。
- 将该路径名传递给
os.stat() 或os.lstat()。如果该路径名和该组件无效,则此调用保证引发暴露无效类型的异常,而不是通用的FileNotFoundError 异常。为什么? 因为该路径名位于现有目录中。(循环逻辑是循环的。)
是否有保证存在的目录?可以,但通常只有一个:根文件系统的最顶层目录(如上定义)。
将驻留在任何其他目录(因此不保证存在)中的路径名传递给os.stat() 或os.lstat() 会引发竞争条件,即使该目录先前已被测试存在。为什么?因为不能阻止外部进程同时删除该目录之后测试已经执行但之前路径名被传递给os.stat()或os.lstat()。释放令人发狂的狗!
上述方法还有一个巨大的附带好处:安全性。(那不是很好吗?)具体来说:
通过简单地将这些路径名传递给os.stat() 或os.lstat() 来验证来自不受信任来源的任意路径名的前端应用程序容易受到拒绝服务 (DoS) 攻击和其他黑帽恶作剧的影响。恶意用户可能会尝试重复验证驻留在已知陈旧或缓慢的文件系统(例如 NFS Samba 共享)上的路径名;在这种情况下,盲目地声明传入的路径名可能最终会因连接超时而失败,或者会消耗更多的时间和资源,而不是您承受失业的微弱能力。
上述方法通过仅根据根文件系统的根目录验证路径名的路径组件来避免这种情况。 (即使那是陈旧、缓慢或不可访问的,你遇到的问题比路径名验证更大。)
迷路了? 太好了。让我们开始吧。 (假设为 Python 3。请参阅“What Is Fragile Hope for 300, leycec?”)
import errno, os
# Sadly, Python fails to provide the following magic number for us.
ERROR_INVALID_NAME = 123
'''
Windows-specific error code indicating an invalid pathname.
See Also
----------
https://docs.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-
Official listing of all such codes.
'''
def is_pathname_valid(pathname: str) -> bool:
'''
`True` if the passed pathname is a valid pathname for the current OS;
`False` otherwise.
'''
# If this pathname is either not a string or is but is empty, this pathname
# is invalid.
try:
if not isinstance(pathname, str) or not pathname:
return False
# Strip this pathname's Windows-specific drive specifier (e.g., `C:\`)
# if any. Since Windows prohibits path components from containing `:`
# characters, failing to strip this `:`-suffixed prefix would
# erroneously invalidate all valid absolute Windows pathnames.
_, pathname = os.path.splitdrive(pathname)
# Directory guaranteed to exist. If the current OS is Windows, this is
# the drive to which Windows was installed (e.g., the "%HOMEDRIVE%"
# environment variable); else, the typical root directory.
root_dirname = os.environ.get('HOMEDRIVE', 'C:') \
if sys.platform == 'win32' else os.path.sep
assert os.path.isdir(root_dirname) # ...Murphy and her ironclad Law
# Append a path separator to this directory if needed.
root_dirname = root_dirname.rstrip(os.path.sep) + os.path.sep
# Test whether each path component split from this pathname is valid or
# not, ignoring non-existent and non-readable path components.
for pathname_part in pathname.split(os.path.sep):
try:
os.lstat(root_dirname + pathname_part)
# If an OS-specific exception is raised, its error code
# indicates whether this pathname is valid or not. Unless this
# is the case, this exception implies an ignorable kernel or
# filesystem complaint (e.g., path not found or inaccessible).
#
# Only the following exceptions indicate invalid pathnames:
#
# * Instances of the Windows-specific "WindowsError" class
# defining the "winerror" attribute whose value is
# "ERROR_INVALID_NAME". Under Windows, "winerror" is more
# fine-grained and hence useful than the generic "errno"
# attribute. When a too-long pathname is passed, for example,
# "errno" is "ENOENT" (i.e., no such file or directory) rather
# than "ENAMETOOLONG" (i.e., file name too long).
# * Instances of the cross-platform "OSError" class defining the
# generic "errno" attribute whose value is either:
# * Under most POSIX-compatible OSes, "ENAMETOOLONG".
# * Under some edge-case OSes (e.g., SunOS, *BSD), "ERANGE".
except OSError as exc:
if hasattr(exc, 'winerror'):
if exc.winerror == ERROR_INVALID_NAME:
return False
elif exc.errno in {errno.ENAMETOOLONG, errno.ERANGE}:
return False
# If a "TypeError" exception was raised, it almost certainly has the
# error message "embedded NUL character" indicating an invalid pathname.
except TypeError as exc:
return False
# If no exception was raised, all path components and hence this
# pathname itself are valid. (Praise be to the curmudgeonly python.)
else:
return True
# If any other exception was raised, this is an unrelated fatal issue
# (e.g., a bug). Permit this exception to unwind the call stack.
#
# Did we mention this should be shipped with Python already?
完成。不要眯着眼睛看那段代码。 (它会咬人。)
问题 #2:路径名的存在或可创建性可能无效,嗯?
鉴于上述解决方案,测试可能无效的路径名的存在或可创建性几乎是微不足道的。这里的小关键是调用之前定义的函数before测试通过的路径:
def is_path_creatable(pathname: str) -> bool:
'''
`True` if the current user has sufficient permissions to create the passed
pathname; `False` otherwise.
'''
# Parent directory of the passed path. If empty, we substitute the current
# working directory (CWD) instead.
dirname = os.path.dirname(pathname) or os.getcwd()
return os.access(dirname, os.W_OK)
def is_path_exists_or_creatable(pathname: str) -> bool:
'''
`True` if the passed pathname is a valid pathname for the current OS _and_
either currently exists or is hypothetically creatable; `False` otherwise.
This function is guaranteed to _never_ raise exceptions.
'''
try:
# To prevent "os" module calls from raising undesirable exceptions on
# invalid pathnames, is_pathname_valid() is explicitly called first.
return is_pathname_valid(pathname) and (
os.path.exists(pathname) or is_path_creatable(pathname))
# Report failure on non-fatal filesystem complaints (e.g., connection
# timeouts, permissions issues) implying this path to be inaccessible. All
# other exceptions are unrelated fatal issues and should not be caught here.
except OSError:
return False
完成和完成。除了不完全。
问题 #3:Windows 上的路径名存在或可写性可能无效
有一个警告。当然有。
正如官方os.access() documentation 承认的那样:
注意:即使os.access() 表明它们会成功,I/O 操作也可能会失败,特别是对于网络文件系统上的操作,其权限语义可能超出通常的 POSIX 权限位模型。
不出所料,Windows 是这里的常见嫌疑人。由于在 NTFS 文件系统上广泛使用访问控制列表 (ACL),简单的 POSIX 权限位模型很难映射到底层的 Windows 现实。虽然这(可以说)不是 Python 的错,但它可能仍然是与 Windows 兼容的应用程序的问题。
如果是您,需要更强大的替代方案。如果传递的路径不存在,我们会尝试在该路径的父目录中创建一个保证立即删除的临时文件——更便携(如果昂贵)的可创建性测试:
import os, tempfile
def is_path_sibling_creatable(pathname: str) -> bool:
'''
`True` if the current user has sufficient permissions to create **siblings**
(i.e., arbitrary files in the parent directory) of the passed pathname;
`False` otherwise.
'''
# Parent directory of the passed path. If empty, we substitute the current
# working directory (CWD) instead.
dirname = os.path.dirname(pathname) or os.getcwd()
try:
# For safety, explicitly close and hence delete this temporary file
# immediately after creating it in the passed path's parent directory.
with tempfile.TemporaryFile(dir=dirname): pass
return True
# While the exact type of exception raised by the above function depends on
# the current version of the Python interpreter, all such types subclass the
# following exception superclass.
except EnvironmentError:
return False
def is_path_exists_or_creatable_portable(pathname: str) -> bool:
'''
`True` if the passed pathname is a valid pathname on the current OS _and_
either currently exists or is hypothetically creatable in a cross-platform
manner optimized for POSIX-unfriendly filesystems; `False` otherwise.
This function is guaranteed to _never_ raise exceptions.
'''
try:
# To prevent "os" module calls from raising undesirable exceptions on
# invalid pathnames, is_pathname_valid() is explicitly called first.
return is_pathname_valid(pathname) and (
os.path.exists(pathname) or is_path_sibling_creatable(pathname))
# Report failure on non-fatal filesystem complaints (e.g., connection
# timeouts, permissions issues) implying this path to be inaccessible. All
# other exceptions are unrelated fatal issues and should not be caught here.
except OSError:
return False
但是请注意,即使 这个 也可能还不够。
感谢用户访问控制 (UAC)、无与伦比的 Windows Vista 及其所有后续迭代blatantly lie 关于与系统目录有关的权限。当非管理员用户尝试在规范的 C:\Windows 或 C:\Windows\system32 目录中创建文件时,UAC 表面上允许用户这样做,而实际上将所有创建的文件隔离到该用户的个人资料。 (谁能想到欺骗用户会产生有害的长期后果?)
这太疯狂了。这是 Windows。
证明它
我们敢吗?是时候进行上述测试了。
由于 NULL 是面向 UNIX 的文件系统的路径名中唯一被禁止的字符,让我们利用它来证明冷酷的事实——忽略不可忽视的 Windows 恶作剧,坦率地说,这让我感到厌烦和愤怒:
>>> print('"foo.bar" valid? ' + str(is_pathname_valid('foo.bar')))
"foo.bar" valid? True
>>> print('Null byte valid? ' + str(is_pathname_valid('\x00')))
Null byte valid? False
>>> print('Long path valid? ' + str(is_pathname_valid('a' * 256)))
Long path valid? False
>>> print('"/dev" exists or creatable? ' + str(is_path_exists_or_creatable('/dev')))
"/dev" exists or creatable? True
>>> print('"/dev/foo.bar" exists or creatable? ' + str(is_path_exists_or_creatable('/dev/foo.bar')))
"/dev/foo.bar" exists or creatable? False
>>> print('Null byte exists or creatable? ' + str(is_path_exists_or_creatable('\x00')))
Null byte exists or creatable? False
超越理智。超越痛苦。您会发现 Python 的可移植性问题。