Skip to content

Commit 6317636

Browse files
committed
Migrate crypto module from avocado to autils
- Add autils/file/crypto.py (hash_file without deprecation block) - Add unit and functional tests with updated imports - Add metadata/file/crypto.yml - Add crypto to docs under File section Reference: #43 Assisted-By: Cursor-Claude-4-Sonnet Signed-off-by: Harvey Lynden <hlynden@redhat.com>
1 parent 7b239af commit 6317636

5 files changed

Lines changed: 318 additions & 0 deletions

File tree

autils/file/crypto.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# This program is free software; you can redistribute it and/or modify
2+
# it under the terms of the GNU General Public License as published by
3+
# the Free Software Foundation; either version 2 of the License, or
4+
# (at your option) any later version.
5+
#
6+
# This program is distributed in the hope that it will be useful,
7+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
8+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
9+
#
10+
# See LICENSE for more details.
11+
#
12+
# Copyright: Red Hat Inc. 2013-2014
13+
# Author: Lucas Meneghel Rodrigues <lmr@redhat.com>
14+
15+
"""Cryptographic hash utilities for file verification."""
16+
17+
import hashlib
18+
import io
19+
import logging
20+
import os
21+
22+
LOG = logging.getLogger(__name__)
23+
24+
25+
def hash_file(filename, size=None, algorithm="md5"):
26+
"""Calculate the hash value of a file.
27+
28+
Computes a cryptographic hash of the specified file using the given
29+
algorithm. Optionally limits hashing to the first N bytes of the file,
30+
which is useful for verifying partial downloads or large files.
31+
32+
:param filename: Path of the file that will have its hash calculated.
33+
:type filename: str
34+
:param size: If provided, hash only the first size bytes of the file.
35+
If None or 0, the entire file is hashed. If size exceeds the file
36+
size, the entire file is hashed.
37+
:type size: int or None
38+
:param algorithm: Hash algorithm to use. Supported algorithms include
39+
md5, sha1, sha256, sha512, blake2b, and others available in hashlib.
40+
:type algorithm: str
41+
:return: Hexadecimal digest string of the computed hash. Returns None
42+
if an invalid algorithm is specified.
43+
:rtype: str or None
44+
:raises FileNotFoundError: When the specified file does not exist.
45+
:raises PermissionError: When the file cannot be read due to permissions.
46+
47+
Example::
48+
49+
>>> hash_file('/path/to/file')
50+
'abc123...'
51+
>>> hash_file('/path/to/file', algorithm='sha256')
52+
'e3b0c44298fc1c149afbf4c8996fb924...'
53+
>>> hash_file('/path/to/large_file', size=1024)
54+
'abc123...'
55+
"""
56+
chunksize = io.DEFAULT_BUFFER_SIZE
57+
fsize = os.path.getsize(filename)
58+
59+
if not size or size > fsize:
60+
size = fsize
61+
62+
try:
63+
hash_obj = hashlib.new(algorithm)
64+
except ValueError as detail:
65+
LOG.error(
66+
'Returning "None" due to inability to create hash object: "%s"', detail
67+
)
68+
return None
69+
70+
with open(filename, "rb") as file_to_hash:
71+
while size > 0:
72+
chunksize = min(chunksize, size)
73+
data = file_to_hash.read(chunksize)
74+
if not data:
75+
LOG.debug("Nothing left to read but size=%d", size)
76+
break
77+
hash_obj.update(data)
78+
size -= len(data)
79+
80+
return hash_obj.hexdigest()

docs/source/utils.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ Ports
2020
File
2121
=======
2222

23+
Crypto
24+
------
25+
.. automodule:: autils.file.crypto
26+
2327
Path
2428
----
2529
.. automodule:: autils.file.path

metadata/file/crypto.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: crypto
2+
description: Cryptographic hash utilities for file verification.
3+
4+
categories:
5+
- Files
6+
- Security
7+
maintainers:
8+
- name: Harvey James Lynden
9+
email: hlynden@redhat.com
10+
github_usr_name: harvey0100
11+
supported_platforms:
12+
- CentOS Stream 9
13+
- Fedora 36
14+
- Fedora 37
15+
tests:
16+
- tests/unit/modules/file/crypto.py
17+
- tests/functional/modules/file/crypto.py
18+
remote: false
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import hashlib
2+
import os
3+
import unittest
4+
5+
from autils.file import crypto
6+
from tests.utils import TestCaseTmpDir, setup_autils_loggers
7+
8+
setup_autils_loggers()
9+
10+
11+
class HashFileFunctionalTest(TestCaseTmpDir):
12+
"""Functional tests for crypto.hash_file with real-world scenarios."""
13+
14+
def test_download_verification_with_known_checksums(self):
15+
"""
16+
Test verifying a downloaded file against published checksums.
17+
18+
Real-world scenario: Package managers and download sites publish
19+
checksums that users verify after downloading. This test uses
20+
a well-known test vector with pre-computed checksums.
21+
"""
22+
content = b"The quick brown fox jumps over the lazy dog"
23+
filepath = os.path.join(self.tmpdir.name, "downloaded_file.bin")
24+
with open(filepath, "wb") as f:
25+
f.write(content)
26+
27+
self.assertEqual(
28+
crypto.hash_file(filepath, algorithm="md5"),
29+
"9e107d9d372bb6826bd81d3542a419d6",
30+
)
31+
self.assertEqual(
32+
crypto.hash_file(filepath, algorithm="sha256"),
33+
"d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592",
34+
)
35+
36+
def test_file_tampering_detection(self):
37+
"""
38+
Test detecting file modification through hash comparison.
39+
40+
Real-world scenario: Security systems use hashes to detect if
41+
files have been tampered with. This tests the complete workflow.
42+
"""
43+
filepath = os.path.join(self.tmpdir.name, "secure_config.conf")
44+
45+
with open(filepath, "wb") as f:
46+
f.write(b"secure_setting=true\npassword_hash=abc123")
47+
original_hash = crypto.hash_file(filepath, algorithm="sha256")
48+
49+
with open(filepath, "wb") as f:
50+
f.write(b"secure_setting=false\npassword_hash=abc123")
51+
tampered_hash = crypto.hash_file(filepath, algorithm="sha256")
52+
53+
self.assertNotEqual(original_hash, tampered_hash)
54+
55+
def test_create_file_manifest(self):
56+
"""
57+
Test creating a manifest of file checksums for a directory.
58+
59+
Real-world scenario: Build systems and package managers create
60+
manifests listing checksums of all files for verification.
61+
"""
62+
files = {
63+
"src/main.py": b"print('hello')",
64+
"src/utils.py": b"def helper(): pass",
65+
"data/config.json": b'{"key": "value"}',
66+
}
67+
68+
manifest = {}
69+
for relpath, content in files.items():
70+
filepath = os.path.join(self.tmpdir.name, relpath)
71+
os.makedirs(os.path.dirname(filepath), exist_ok=True)
72+
with open(filepath, "wb") as f:
73+
f.write(content)
74+
manifest[relpath] = crypto.hash_file(filepath, algorithm="sha256")
75+
76+
for relpath, content in files.items():
77+
expected = hashlib.sha256(content).hexdigest()
78+
self.assertEqual(manifest[relpath], expected)
79+
80+
self.assertEqual(len(set(manifest.values())), len(files))
81+
82+
def test_symlink_follows_to_target(self):
83+
"""
84+
Test that hashing through symlink produces same result as original.
85+
86+
Real-world scenario: Linux systems use symlinks extensively;
87+
hash verification must work regardless of access path.
88+
"""
89+
original = os.path.join(self.tmpdir.name, "original.bin")
90+
symlink = os.path.join(self.tmpdir.name, "link.bin")
91+
92+
with open(original, "wb") as f:
93+
f.write(b"Linked content")
94+
os.symlink(original, symlink)
95+
96+
self.assertEqual(crypto.hash_file(original), crypto.hash_file(symlink))
97+
98+
99+
if __name__ == "__main__":
100+
unittest.main()

tests/unit/modules/file/crypto.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import hashlib
2+
import os
3+
import unittest
4+
5+
from autils.file import crypto
6+
from tests.utils import TestCaseTmpDir, setup_autils_loggers
7+
8+
setup_autils_loggers()
9+
10+
11+
class HashFileTest(TestCaseTmpDir):
12+
"""Test cases for crypto.hash_file function."""
13+
14+
def _create_test_file(self, content, filename="testfile"):
15+
"""Helper to create a test file with given content."""
16+
filepath = os.path.join(self.tmpdir.name, filename)
17+
with open(filepath, "wb") as f:
18+
f.write(content)
19+
return filepath
20+
21+
# Core algorithm tests - testing the algorithm parameter code path
22+
def test_hash_file_md5_default(self):
23+
"""Test MD5 hash calculation with default algorithm."""
24+
content = b"Hello, World!"
25+
filepath = self._create_test_file(content)
26+
expected = hashlib.md5(content).hexdigest()
27+
result = crypto.hash_file(filepath)
28+
self.assertEqual(result, expected)
29+
30+
def test_hash_file_sha256(self):
31+
"""Test SHA256 hash calculation."""
32+
content = b"Test content for SHA256"
33+
filepath = self._create_test_file(content)
34+
expected = hashlib.sha256(content).hexdigest()
35+
result = crypto.hash_file(filepath, algorithm="sha256")
36+
self.assertEqual(result, expected)
37+
38+
# Size parameter tests - each tests a distinct code path
39+
def test_hash_file_with_size_limit(self):
40+
"""Test hashing only the first N bytes of a file."""
41+
content = b"ABCDEFGHIJ" # 10 bytes
42+
filepath = self._create_test_file(content)
43+
# Hash only first 5 bytes - tests size < file_size path
44+
expected = hashlib.md5(b"ABCDE").hexdigest()
45+
result = crypto.hash_file(filepath, size=5)
46+
self.assertEqual(result, expected)
47+
48+
def test_hash_file_size_larger_than_file(self):
49+
"""Test that size larger than file hashes the whole file."""
50+
content = b"Small file"
51+
filepath = self._create_test_file(content)
52+
expected = hashlib.md5(content).hexdigest()
53+
# Request more bytes than file contains - tests size > file_size branch
54+
result = crypto.hash_file(filepath, size=1000000)
55+
self.assertEqual(result, expected)
56+
57+
def test_hash_file_size_falsy_hashes_whole_file(self):
58+
"""Test that falsy size values (None, 0) hash the entire file."""
59+
content = b"Complete file content"
60+
filepath = self._create_test_file(content)
61+
expected = hashlib.md5(content).hexdigest()
62+
# Both None and 0 are falsy - tests 'not size' branch
63+
self.assertEqual(crypto.hash_file(filepath, size=None), expected)
64+
self.assertEqual(crypto.hash_file(filepath, size=0), expected)
65+
66+
# Edge case tests - each tests unique behavior
67+
def test_hash_file_empty_file(self):
68+
"""Test hashing an empty file."""
69+
filepath = self._create_test_file(b"")
70+
expected = hashlib.md5(b"").hexdigest()
71+
result = crypto.hash_file(filepath)
72+
self.assertEqual(result, expected)
73+
74+
def test_hash_file_binary_content(self):
75+
"""Test hashing a file with all possible byte values."""
76+
content = bytes(range(256)) # All byte values 0-255
77+
filepath = self._create_test_file(content)
78+
expected = hashlib.md5(content).hexdigest()
79+
result = crypto.hash_file(filepath)
80+
self.assertEqual(result, expected)
81+
82+
def test_hash_file_larger_than_chunk_size(self):
83+
"""Test hashing a file that requires multiple read iterations."""
84+
# Create content larger than io.DEFAULT_BUFFER_SIZE (typically 8192)
85+
content = b"x" * 100000
86+
filepath = self._create_test_file(content)
87+
expected = hashlib.md5(content).hexdigest()
88+
result = crypto.hash_file(filepath)
89+
self.assertEqual(result, expected)
90+
91+
# Error handling tests
92+
def test_hash_file_invalid_algorithm_returns_none(self):
93+
"""Test that invalid algorithm returns None without raising."""
94+
content = b"Test content"
95+
filepath = self._create_test_file(content)
96+
result = crypto.hash_file(filepath, algorithm="invalid_algo")
97+
self.assertIsNone(result)
98+
99+
def test_hash_file_nonexistent_file_raises(self):
100+
"""Test that non-existent file raises FileNotFoundError."""
101+
nonexistent = os.path.join(self.tmpdir.name, "nonexistent_file.txt")
102+
with self.assertRaises(FileNotFoundError):
103+
crypto.hash_file(nonexistent)
104+
105+
# Hash uniqueness test - verifies hash function works correctly
106+
def test_hash_file_different_content_produces_different_hash(self):
107+
"""Test that different content produces different hash values."""
108+
filepath1 = self._create_test_file(b"Content A", filename="file1.txt")
109+
filepath2 = self._create_test_file(b"Content B", filename="file2.txt")
110+
hash1 = crypto.hash_file(filepath1)
111+
hash2 = crypto.hash_file(filepath2)
112+
self.assertNotEqual(hash1, hash2)
113+
114+
115+
if __name__ == "__main__":
116+
unittest.main()

0 commit comments

Comments
 (0)