如何使用Python计算文件系统目录的哈希值?

31

我正在使用以下代码计算文件的哈希值:

m = hashlib.md5()
with open("calculator.pdf", 'rb') as fh:
    while True:
        data = fh.read(8192)
        if not data:
            break
        m.update(data)
    hash_value = m.hexdigest()

    print  hash_value

当我尝试在文件夹"folder"上运行它时,得到了

IOError: [Errno 13] Permission denied: folder

我该如何计算一个文件夹的哈希值?


1
为了什么目的?唯一标识?使用完整文件夹路径或inode。为了识别其内容?然后迭代其全部内容并进行哈希处理。 - Konrad Rudolph
2
你必须计算所有文件及其子文件夹中的文件的哈希值。 - user3522371
Konrad的正确之处在于问题存在很多歧义。他没有列出的另一个可能性是对目录条目元数据进行哈希处理,这可以用于快速/粗略地检查内容是否已更改。顺便说一下,一些操作系统确实允许您“打开”目录,就像它是一个文本文件一样,上面的文件代码将已经适用于目录“文件”流产生的任何元数据。如果不明确需求或目标,该问题应被关闭。 - Tony Delroy
这里有一个 gist,其中包含更简洁的代码。还有一个专门的包 checksumdirdirtools,具有哈希功能。 - funky-future
7个回答

23

使用checksumdir python包来计算目录的校验和/哈希值。它可以在https://pypi.python.org/pypi/checksumdir找到。

用法:

import checksumdir
hash = checksumdir.dirhash("c:\\temp")
print hash

3
请注意:checksumdir没有经过测试(即使声称是稳定的)。使用它可能比使用配方不可信,因为至少配方强制你阅读配方。 - Jorge Leitao
1
在不同的操作系统上似乎对我都不起作用(在 MacOS 和 Debian 下得到了不同的哈希值):( - Seub

21

这里有一个使用pathlib.Path而不是os.walk的实现。它在迭代之前对目录内容进行排序,因此应该可以在多个平台上重复运行。它还使用文件/目录名称更新哈希值,因此添加空文件和目录将更改哈希值。

版本带有类型注释(适用于Python 3.6或更高版本):

import hashlib
from _hashlib import HASH as Hash
from pathlib import Path
from typing import Union


def md5_update_from_file(filename: Union[str, Path], hash: Hash) -> Hash:
    assert Path(filename).is_file()
    with open(str(filename), "rb") as f:
        for chunk in iter(lambda: f.read(4096), b""):
            hash.update(chunk)
    return hash


def md5_file(filename: Union[str, Path]) -> str:
    return str(md5_update_from_file(filename, hashlib.md5()).hexdigest())


def md5_update_from_dir(directory: Union[str, Path], hash: Hash) -> Hash:
    assert Path(directory).is_dir()
    for path in sorted(Path(directory).iterdir(), key=lambda p: str(p).lower()):
        hash.update(path.name.encode())
        if path.is_file():
            hash = md5_update_from_file(path, hash)
        elif path.is_dir():
            hash = md5_update_from_dir(path, hash)
    return hash


def md5_dir(directory: Union[str, Path]) -> str:
    return str(md5_update_from_dir(directory, hashlib.md5()).hexdigest())

没有类型注释:

import hashlib
from pathlib import Path


def md5_update_from_file(filename, hash):
    assert Path(filename).is_file()
    with open(str(filename), "rb") as f:
        for chunk in iter(lambda: f.read(4096), b""):
            hash.update(chunk)
    return hash


def md5_file(filename):
    return md5_update_from_file(filename, hashlib.md5()).hexdigest()


def md5_update_from_dir(directory, hash):
    assert Path(directory).is_dir()
    for path in sorted(Path(directory).iterdir()):
        hash.update(path.name.encode())
        if path.is_file():
            hash = md5_update_from_file(path, hash)
        elif path.is_dir():
            hash = md5_update_from_dir(path, hash)
    return hash


def md5_dir(directory):
    return md5_update_from_dir(directory, hashlib.md5()).hexdigest()

如果您只需要哈希目录的压缩版本:

def md5_update_from_dir(directory, hash):
    assert Path(directory).is_dir()
    for path in sorted(Path(directory).iterdir(), key=lambda p: str(p).lower()):
        hash.update(path.name.encode())
        if path.is_file():
            with open(path, "rb") as f:
                for chunk in iter(lambda: f.read(4096), b""):
                    hash.update(chunk)
        elif path.is_dir():
            hash = md5_update_from_dir(path, hash)
    return hash


def md5_dir(directory):
    return md5_update_from_dir(directory, hashlib.md5()).hexdigest()

用法:md5_hash = md5_dir("/some/directory")


你是不是故意用文件的绝对路径来初始化哈希表,而不是从根目录开始使用相对路径呢? - nimig18
@nimig18,我并没有这么做;哈希值只包括文件名(不包含路径)。 - danmou
1
文件的排序对于可重复性非常重要,因为Path iterdir或os.walk都不能保证特定的顺序,并且将受到底层操作系统实现的影响。然而,仅按不区分大小写的路径排序是不够的,因为sorted是原地排序的,如果Linux中的两个文件夹仅由大小写不同,则sorted可能会根据iterdir/os.walk返回的特定顺序在不同时间返回不同的顺序。正确的解决方案是先按大小写排序,然后再按不区分大小写排序。@danmou也许你可以更新一下这个? - Matias Grioni
@MatiasGrioni,我写这段代码已经有一段时间了,我不记得当初为什么要进行不区分大小写的排序。普通的区分大小写的排序不应该就可以解决问题吗? - danmou
这取决于您希望哈希具有哪些特征。没有一种“自然”的方法来哈希文件夹,因此您应该事先定义一些策略/特征,确保算法适用于您的应用程序。 - Matias Grioni
显示剩余2条评论

10
这个教程提供了一个很好的函数来完成你所需的操作。我已经修改了它,使用MD5哈希代替SHA1,因为你最初的问题是这样要求的。
def GetHashofDirs(directory, verbose=0):
  import hashlib, os
  SHAhash = hashlib.md5()
  if not os.path.exists (directory):
    return -1

  try:
    for root, dirs, files in os.walk(directory):
      for names in files:
        if verbose == 1:
          print 'Hashing', names
        filepath = os.path.join(root,names)
        try:
          f1 = open(filepath, 'rb')
        except:
          # You can't open the file for some reason
          f1.close()
          continue

        while 1:
          # Read file in as little chunks
          buf = f1.read(4096)
          if not buf : break
          SHAhash.update(hashlib.md5(buf).hexdigest())
        f1.close()

  except:
    import traceback
    # Print the stack traceback
    traceback.print_exc()
    return -2

  return SHAhash.hexdigest()

你可以这样使用它:

print GetHashofDirs('folder_to_hash', 1)

输出如下,它对每个文件进行哈希处理:
...
Hashing file1.cache
Hashing text.txt
Hashing library.dll
Hashing vsfile.pdb
Hashing prog.cs
5be45c5a67810b53146eaddcae08a809

这个函数调用的返回值是哈希值。在这种情况下,哈希值为5be45c5a67810b53146eaddcae08a809

import traceback 的目的是什么? - user3522371
1
@begueradj,在这种情况下,traceback被用来在哈希过程中发生错误时打印堆栈跟踪。 - Andy
2
仅仅忽略一个无法打开的文件听起来对我来说并不是正确的方法。此外,你不能保证例如在不同的文件系统上os.walk将以相同的顺序遍历文件。 - Antonio
这是递归的吗? - The Quantum Physicist
我知道这很老,但我不得不给它投反对票,因为除了像os.walk不稳定之类的问题之外,这两个try..except块都是有问题的。 - Jeronimo
显示剩余3条评论

7

我不喜欢答案中提到的食谱的写法。我有一个更简单的版本:

import hashlib
import os


def hash_directory(path):
    digest = hashlib.sha1()

    for root, dirs, files in os.walk(path):
        for names in files:
            file_path = os.path.join(root, names)

            # Hash the path and add to the digest to account for empty files/directories
            digest.update(hashlib.sha1(file_path[len(path):].encode()).digest())

            # Per @pt12lol - if the goal is uniqueness over repeatability, this is an alternative method using 'hash'
            # digest.update(str(hash(file_path[len(path):])).encode())

            if os.path.isfile(file_path):
                with open(file_path, 'rb') as f_obj:
                    while True:
                        buf = f_obj.read(1024 * 1024)
                        if not buf:
                            break
                        digest.update(buf)

    return digest.hexdigest()

我发现通常在遇到像alias这样的东西时(出现在os.walk()中,但不能直接打开),会抛出异常。 os.path.isfile()检查可以解决这些问题。
如果在我尝试进行哈希处理的目录中存在实际文件,但无法打开,则跳过该文件并继续不是一个好的解决方案。这会影响哈希的结果。最好完全停止哈希处理尝试。 这里,try语句将包装调用我的hash_directory()函数。
>>> try:
...   print(hash_directory('/tmp'))
... except:
...   print('Failed!')
... 
e2a075b113239c8a25c7e1e43f21e8f2f6762094
>>> 

不错的东西,但请注意,如果您添加空文件或目录,此哈希值将不会更改。 - pt12lol
我为每个 file_pathdir_path 添加了 digest.update(str(hash(file_path[len(path):])).encode())。哈希确保此哈希将依赖于 PYTHONHASHSEED,因此伪造此哈希计算将非常困难。 - pt12lol
@pt12lol 我非常喜欢那个想法 - 我之前没有考虑过那种可能性 - 但我认为使用 hash 不是正确的解决方案。这个函数的结果在 Python 2 和 Python 3 中会不同(我刚在我的 Mac 上尝试了一下)。那么再次使用 hashlib 怎么样呢?digest.update(hashlib.sha1(file_path.encode()).digest()) - Bryson Tyrrell
难怪这个哈希在Python 2和3之间不同,因为Python 3引入了变量PYTHONHASHSEED,它在每个Python运行中都不同,并且hash依赖于它。我猜你正在使用硬编码值来断言目录哈希,而在这种情况下,digest肯定更好。老实说,我没有我的代码依赖硬编码值,所以我不关心它,只关心唯一性。在我的情况下,hash甚至比digest更安全。 - pt12lol
选择1024*1024作为缓冲区大小有什么特别的原因吗? - Darren
@Darren,我从我编写的用于哈希非常大的文件(超过几个GB)的代码中继承了这个。1MB似乎是一个合理的数量。 - Bryson Tyrrell

3
我经常在各个论坛中看到这段代码。
虽然ActiveState recipe answer可以正常运行,但正如Antonio指出的那样,由于无法以相同的顺序呈现文件,因此不能保证在不同的文件系统中重复使用。 一个解决方法是修改:
for root, dirs, files in os.walk(directory):
  for names in files:

为了

for root, dirs, files in os.walk(directory):
  for names in sorted(files): 

(是的,我在这里有点懒。这只对文件名进行排序,而不是目录。同样的原则适用)


2

使用checksumdir https://pypi.org/project/checksumdir/

该工具可用于计算目录中所有文件的校验和,以便在比较文件时进行快速检查。它是一个Python包,可以通过pip安装。


directory  = '/path/to/directory/'
md5hash    = dirhash(directory, 'md5')

1

我对Andy的回答进行了进一步的优化。

以下是Python3而不是Python2的实现。它使用SHA1,处理某些需要编码的情况,经过了代码检查,并包含了一些文档字符串。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""dir_hash: Return SHA1 hash of a directory.
- Copyright (c) 2009 Stephen Akiki, 2018 Joe Flack
- MIT License (http://www.opensource.org/licenses/mit-license.php)
- http://akiscode.com/articles/sha-1directoryhash.shtml
"""
import hashlib
import os


def update_hash(running_hash, filepath, encoding=''):
    """Update running SHA1 hash, factoring in hash of given file.

    Side Effects:
        running_hash.update()
    """
    if encoding:
        file = open(filepath, 'r', encoding=encoding)
        for line in file:
            hashed_line = hashlib.sha1(line.encode(encoding))
            hex_digest = hashed_line.hexdigest().encode(encoding)
            running_hash.update(hex_digest)
        file.close()
    else:
        file = open(filepath, 'rb')
        while True:
            # Read file in as little chunks.
            buffer = file.read(4096)
            if not buffer:
                break
            running_hash.update(hashlib.sha1(buffer).hexdigest())
        file.close()


def dir_hash(directory, verbose=False):
    """Return SHA1 hash of a directory.

    Args:
        directory (string): Path to a directory.
        verbose (bool): If True, prints progress updates.

    Raises:
        FileNotFoundError: If directory provided does not exist.

    Returns:
        string: SHA1 hash hexdigest of a directory.
    """
    sha_hash = hashlib.sha1()

    if not os.path.exists(directory):
        raise FileNotFoundError

    for root, dirs, files in os.walk(directory):
        for names in files:
            if verbose:
                print('Hashing', names)
            filepath = os.path.join(root, names)
            try:
                update_hash(running_hash=sha_hash,
                            filepath=filepath)
            except TypeError:
                update_hash(running_hash=sha_hash,
                            filepath=filepath,
                            encoding='utf-8')

    return sha_hash.hexdigest()

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接