import hashlib
import os
[docs]
class InvalidChecksumLine(Exception):
pass
[docs]
class NoDifferException(Exception):
pass
[docs]
class ChecksumMismatch(Exception):
pass
[docs]
class ChecksumFile:
"""
Slurp a checksum file and be able to check and compare its contents to a
given root directory. Also: be able to write out a checksum file.
We only allow sha256 for now, though supporting 512, etc. would be easy.
"""
def __init__(self, root, differ=None):
self.root = root
if differ is not None:
self.differ = differ(root=self.root)
else:
from .differ.distlib_manifest import (
DistlibManifestChecksumFileExistenceDiffer,
)
self.differ = DistlibManifestChecksumFileExistenceDiffer(root=self.root)
@property
def differ_warnings(self):
"""
A differ can store a set of warnings (as strings) in the_differ.warnings
which we can propagate up here. This allows calling code to display any
warnings found during diffing time.
"""
return self.differ.warnings
@property
def warnings(self):
"""
Right now this is just the same as differ_warnings. In the future it
might include warnings that we differ in methods in this class as well.
"""
return self.differ_warnings
def _parse_gnu_style(self, line):
"""
Attempt to parse a GNU style line checksum line, returning False if
we are unable to.
A GNU style line looks like this:
f712979c4c5dfe739253908d122f5c87faa8b5de6f15ba7a1548ae028ff22d13 hello_world.yml
Or maybe like this:
f82da8b4f98a3d3125fbc98408911f65dbc8dc38c0f38e258ebe290a8ad3d3c0 *binary
"""
parts = line.split(" ", 1)
if len(parts) != 2 or len(parts[0]) != 64:
return False
if len(parts[1]) < 2 or parts[1][0] not in (" ", "*"):
return False
shasum = parts[0]
path = parts[1][1:]
return (path, shasum)
[docs]
def parse(self, checksum_file_contents):
"""
Given a complete checksum manifest as a string, parse it and return a
dict with the result, keyed on each filename or path.
"""
checksums = {}
for idx, line in enumerate(checksum_file_contents.splitlines()):
if not line.strip():
continue
# parsed = self._parse_bsd_style(line)
# if parsed is False:
parsed = self._parse_gnu_style(line)
if parsed is False:
raise InvalidChecksumLine(
f"Unparsable checksum, line {idx + 1}: {line}"
)
path = parsed[0]
shasum = parsed[1]
if path in checksums:
raise InvalidChecksumLine(
f"Duplicate path in checksum, line {idx + 1}: {line}"
)
checksums[path] = shasum
return checksums
[docs]
def diff(self, paths):
"""
Given a collection of paths, use the differ to figure out which files
(in reality) have been added/removed from the project root (or latest
SCM tree).
"""
paths = set(paths)
return self.differ.compare_filelist(paths)
[docs]
def generate_gnu_style(self):
"""
Using the root directory and 'differ' class given to the constructor,
generate a GNU-style checksum manifest file. This is always generated
from scratch by finding the list of relevant files in the root directory
(by asking the differ), and calculating the checksum for each of them.
The resulting list is always sorted by filename.
"""
lines = []
calculated = self.calculate_checksums_from_root(verifying=False)
for path, checksum in sorted(calculated.items()):
# *two* spaces here - it's important for compat with coreutils.
lines.append(f"{checksum} {path}")
return "\n".join(lines) + "\n"
[docs]
def calculate_checksum(self, path):
shasum = hashlib.sha256()
with open(path, "rb") as f:
while True:
chunk = f.read(65536)
if not chunk:
break
shasum.update(chunk)
return shasum.hexdigest()
[docs]
def calculate_checksums_from_root(self, verifying):
"""
Using the root of the project and the differ class passed to the
constructor, iterate over all files in the project and calculate their
checksums. Return a dictionary of the result, keyed on the filename.
Just calling this is not enough in many cases- you want to ensure that
the files in the checksum list are the same ones present in reality.
diff() above does just that. Use that in combination with this method,
or use verify() which does it for you.
"""
out = {}
for path in self.differ.list_files(verifying=verifying):
shasum = self.calculate_checksum(os.path.join(self.root, path))
out[path] = shasum
return out
[docs]
def verify(self, parsed_manifest_dct, diff=True):
"""
Takes a parsed manifest file (e.g. using parse(), with paths as keys and
checksums as values).
Then calculates the current list of files in the project root. If paths
have been added or removed, ChecksumMismatch is raised.
Otherwise, each the checksum of file in the project root (and subdirs)
is calculated and that result is checked to be equal to the parsed
checksums passed in.
"""
if diff:
# If there are any differences in existing paths, fail the check...
differences = self.diff(parsed_manifest_dct.keys())
if differences["added"] or differences["removed"]:
raise ChecksumMismatch(differences)
recalculated = self.calculate_checksums_from_root(verifying=True)
mismatches = set()
for parsed_path, parsed_checksum in parsed_manifest_dct.items():
if recalculated[parsed_path] != parsed_checksum:
mismatches.add(parsed_path)
if mismatches:
raise ChecksumMismatch(f"Checksum mismatch: {', '.join(mismatches)}")
return True