Source code for httk.core.records
"""Deterministic filesystem records for signed manifests and seals.
A *record list* is the canonical description of what a directory tree contained
at one moment: one entry per file, symlink, and directory, sorted by path, with
a file's size, SHA-256, and owner-execute bit. It never follows symlinks, so a
symlink is recorded by its target rather than by what it points at. The same
record list underlies both the signed project manifest and every seal, so a
covered byte cannot change without a later verification noticing.
"""
import os
import stat
from collections.abc import Callable, Iterator, Sequence
from fnmatch import fnmatchcase
from pathlib import Path
from .digests import sha256_file
__all__ = ["file_records"]
def _excluded(relative: str, patterns: Sequence[str]) -> bool:
return any(fnmatchcase(relative, pattern) for pattern in patterns)
[docs]
def file_records(
root: str | os.PathLike[str],
*,
exclusions: Sequence[str] = (),
skip: Callable[[Path], bool] | None = None,
) -> list[dict[str, object]]:
"""Return the sorted, deterministic records of the tree rooted at *root*.
Symlinks are never followed: a symlink is recorded as its target text and a
directory is recorded as its own entry before its contents. A file record
carries its ``size``, ``sha256``, and ``executable`` (the owner-execute
bit). Two seams keep out what a record list must not cover: *exclusions* are
``fnmatch`` patterns matched case-sensitively against each entry's posix
relpath, and *skip*, when given, is a predicate on the absolute path of each
entry — an entry it accepts is left out entirely, and a directory it accepts
is not descended into.
:param root: The directory tree to describe.
:param exclusions: ``fnmatch`` patterns on posix relpaths to leave out.
:param skip: A predicate on absolute paths; a truthy result omits the entry.
:return: The record dictionaries, sorted by posix relpath.
:raises ValueError: If the tree holds a special filesystem entry.
"""
base = Path(root)
paths: list[Path] = []
def visit(directory: Path) -> None:
with os.scandir(directory) as scan:
for raw in scan:
entry = Path(raw.path)
relative = entry.relative_to(base).as_posix()
if _excluded(relative, exclusions):
continue
if skip is not None and skip(entry):
continue
mode = raw.stat(follow_symlinks=False).st_mode
paths.append(entry)
if stat.S_ISDIR(mode):
visit(entry)
visit(base)
return list(_emit(base, paths))
def _emit(base: Path, paths: Sequence[Path]) -> Iterator[dict[str, object]]:
for entry in sorted(paths, key=lambda item: item.relative_to(base).as_posix()):
relative = entry.relative_to(base).as_posix()
mode = entry.lstat().st_mode
if stat.S_ISLNK(mode):
yield {"path": relative, "type": "symlink", "target": os.readlink(entry)}
elif stat.S_ISDIR(mode):
yield {"path": relative, "type": "directory"}
elif stat.S_ISREG(mode):
yield {
"path": relative,
"type": "file",
"size": entry.stat().st_size,
"sha256": sha256_file(entry),
"executable": bool(mode & 0o100),
}
else:
raise ValueError(f"record list rejects special filesystem entry: {relative}")