Skip to content
ScannerServer.
Journal / Article
Tutorials & Projects

Build a file-integrity checker with Python

Nimuthu Ganegoda5 min read
Learning project · No external packages · Source files included

A folder can look unchanged while one file has different contents. This project builds a small tool that records a SHA-256 digest for each regular file, then compares a later scan with that baseline. It reports additions, removals, and changes separately, which makes it useful for learning about release verification and backup checks.

The exercise uses a directory you control. It does not scan the whole computer, delete files, repair damage, or identify malware. Its question is narrow: do the file bytes match the baseline you saved?

1. Prepare a small fixture

Install Python 3.11 or newer, create a new folder called demo, and put two ordinary text files inside it. Keep the project script outside that folder. Save the script below as integrity.py.

2. Read files in chunks

We feed the hash incrementally instead of reading an entire file into memory. Python’s hashlib reference documents the update and hexadecimal-digest interfaces. A digest identifies the bytes we read; it is not an interpretation of the file’s purpose.

"""Small file-integrity teaching project. Standard library only."""
import argparse
import hashlib
import json
import os
from pathlib import Path


def scan(root: Path, excluded: Path) -> dict[str, str]:
    result = {}
    for base, directories, files in os.walk(root, followlinks=False):
        directories[:] = sorted(d for d in directories if not (Path(base) / d).is_symlink())
        for name in sorted(files):
            path = Path(base) / name
            if path.is_symlink() or not path.is_file() or path.resolve() == excluded:
                continue
            digest = hashlib.sha256()
            with path.open('rb') as stream:
                for chunk in iter(lambda: stream.read(1024 * 1024), b''):
                    digest.update(chunk)
            result[path.relative_to(root).as_posix()] = digest.hexdigest()
    return dict(sorted(result.items()))


def main() -> int:
    parser = argparse.ArgumentParser(description='Snapshot or compare regular files; skip symlinks.')
    parser.add_argument('mode', choices=['snapshot', 'check'])
    parser.add_argument('directory', type=Path)
    parser.add_argument('manifest', type=Path)
    args = parser.parse_args()
    root = args.directory.resolve()
    manifest = args.manifest.resolve()
    if not root.is_dir():
        parser.error('directory must exist')
    try:
        current = scan(root, manifest)
        if args.mode == 'snapshot':
            with manifest.open('x', encoding='utf-8') as stream:
                json.dump({'version': 1, 'files': current}, stream, indent=2)
                stream.write('\n')
            print(f'Saved {len(current)} files')
            return 0
        if manifest.stat().st_size > 16 * 1024 * 1024:
            raise ValueError('manifest exceeds the 16 MiB teaching-project limit')
        saved = json.loads(manifest.read_text(encoding='utf-8'))
        if not isinstance(saved, dict) or saved.get('version') != 1:
            raise ValueError('unsupported manifest format')
        expected = saved.get('files')
        if not isinstance(expected, dict) or any(
            not isinstance(k, str) or not isinstance(v, str) or len(v) != 64
            or any(c not in '0123456789abcdef' for c in v)
            for k, v in expected.items()
        ):
            raise ValueError('invalid file hashes')
        added = sorted(current.keys() - expected.keys())
        removed = sorted(expected.keys() - current.keys())
        changed = sorted(k for k in current.keys() & expected.keys() if current[k] != expected[k])
        for label, names in [('ADDED', added), ('REMOVED', removed), ('CHANGED', changed)]:
            for name in names:
                print(f'{label}: {name!r}')
        if not (added or removed or changed):
            print('OK: no content changes')
            return 0
        return 1
    except (OSError, ValueError) as error:
        print(f'ERROR: {error}')
        return 2


if __name__ == '__main__':
    raise SystemExit(main())

3. Save a baseline and compare it

python integrity.py snapshot demo baseline.json
python integrity.py check demo baseline.json

The first command prints Saved 2 files if the fixture contains two files. The second prints OK: no content changes. The snapshot uses exclusive creation, so an existing baseline is not overwritten by accident.

Now edit the first file, remove the second, and create a third. Run only the check command again. It should print one CHANGED, one REMOVED, and one ADDED entry. The order within each group is sorted so reports are easier to compare.

4. Understand the design choices

Relative paths make the manifest portable when a folder is moved. We skip symbolic links so the normal exercise stays within ordinary files in the chosen tree. The manifest itself is excluded if it is stored inside the scanned folder. Paths read from the manifest are used as comparison keys, not opened as files.

A zero exit status means the comparison matched; one means differences were found; two means an error such as an unreadable file, invalid manifest, or existing output. The argparse documentation explains the command-line interface used here. Large manifests have a 16 MiB input limit in this example.

Failure cases worth trying

  • Change only a filename: it appears as a removal and an addition, not a content change.
  • Change only a file’s timestamp: the digest stays the same.
  • Use the wrong baseline: matching bytes cannot prove that you chose the correct release.

Limits and a next exercise

Keep the baseline somewhere trustworthy. Someone who can replace both the files and the manifest can make a modified folder appear consistent. Scan a stable copy: a file changing during a scan can produce an inconsistent snapshot. Skipping links is not a complete defense against concurrent filesystem manipulation.

For a next exercise, add a JSON output mode and compare two known release folders. Document the expected changes before running the tool, then check whether the report matches your prediction.

What was checked

The examples were exercised with fixture data using Python 3.14.7 and Node.js 26.7.0 on Windows. The guide targets Python 3.11+ or Node.js 24+ as specified. This is an example-project check, not a production audit or a benchmark.

Download the project files (ZIP) ↗

The ZIP contains readable source, sample log data, a README, and an MIT license. Inspect the files before running them. Nothing needs administrator access.

Scroll to Top