"""Isolated file-identity regressions; never construct NativeClient or send. Run with either supported Python runtime; WECHAT_TEST_PROJECT selects sources. """ import hashlib import io import os from pathlib import Path import sys import tempfile import time from types import SimpleNamespace import unittest from unittest.mock import patch sys.path.insert(0, os.environ.get('WECHAT_TEST_PROJECT', r'C:\wechat_rpa')) import wecom_native_sender as sender FIELDS = ('st_dev', 'st_ino', 'st_size', 'st_mtime_ns', 'st_ctime_ns') def changed(info, **overrides): values = {field: getattr(info, field) for field in FIELDS} values.update(overrides) return SimpleNamespace(**values) class FakeHandle(io.BytesIO): def __init__(self, data, fd): super().__init__(data) self.fd = fd def fileno(self): return self.fd class WindowsBuildIdentityTests(unittest.TestCase): def setUp(self): self.directory = tempfile.TemporaryDirectory(prefix='wecom-file-identity-') self.addCleanup(self.directory.cleanup) self.path = Path(self.directory.name) / 'client.exe' self.data = b'isolated synthetic executable bytes\x00' * 257 self.path.write_bytes(self.data) self.expected = hashlib.sha256(self.data).hexdigest() with self.path.open('rb') as handle: self.info = os.fstat(handle.fileno()) def test_metadata_change_after_creation_hashes_on_actual_runtime(self): # Windows 3.12 can expose creation time through stat and change time # through fstat. Separate birth and later metadata modification. time.sleep(0.02) os.utime(self.path, ns=(946684800000000000, 946684800000000000)) digest, identity = sender._hash_client_file(self.path) self.assertEqual(digest, self.expected) with self.path.open('rb') as handle: self.assertEqual(identity, sender._file_identity(os.fstat(handle.fileno()))) def test_path_and_fd_ctime_difference_does_not_report_changed_file(self): path_info = changed(self.info, st_ctime_ns=self.info.st_ctime_ns - 1000000000) with patch.object(sender.Path, 'stat', return_value=path_info): digest, _ = sender._hash_client_file(self.path) self.assertEqual(digest, self.expected) def test_path_and_fd_file_id_representation_difference_is_not_mutation(self): path_info = changed(self.info, st_dev=self.info.st_dev + (1 << 32), st_ino=self.info.st_ino + (1 << 64)) with patch.object(sender.Path, 'stat', return_value=path_info): digest, _ = sender._hash_client_file(self.path) self.assertEqual(digest, self.expected) def test_readonly_cache_uses_same_provider_for_identity(self): path_info = changed(self.info, st_ctime_ns=self.info.st_ctime_ns - 1000000000) with patch.object(sender.Path, 'stat', return_value=path_info), \ patch.object(sender, '_process_creation_id', return_value=987): with sender.readonly_build_validation_scope() as stats: first = sender._client_file_digest(self.path, 10, None, readonly=True) second = sender._client_file_digest(self.path, 10, None, readonly=True) self.assertEqual((first, second), (self.expected, self.expected)) self.assertEqual(stats['hashedFiles'], 1) self.assertEqual(stats['reusedFiles'], 1) def test_path_replacement_with_same_length_and_times_is_rejected(self): replaced = changed(self.info, st_ino=self.info.st_ino + 1) opens = [] def open_fake(*args, **kwargs): handle = FakeHandle(self.data, 101 if not opens else 202) opens.append(handle) return handle def fstat_fake(fd): return self.info if fd == 101 else replaced with patch.object(sender.Path, 'open', side_effect=open_fake), \ patch.object(sender.os, 'fstat', side_effect=fstat_fake): with self.assertRaises(sender.NativeUnavailable): sender._hash_client_file(self.path) self.assertGreaterEqual(len(opens), 2, 'path must be re-opened to check replacement') def test_hashing_rejects_mutated_metadata_on_same_handle(self): different = changed(self.info, st_mtime_ns=self.info.st_mtime_ns + 1) calls = 0 def fstat_fake(fd): nonlocal calls calls += 1 return self.info if calls == 1 else different with patch.object(sender.os, 'fstat', side_effect=fstat_fake): with self.assertRaises(sender.NativeUnavailable): sender._hash_client_file(self.path) def test_reused_pid_with_new_creation_time_cannot_reuse_digest(self): with patch.object(sender, '_process_creation_id', side_effect=[10, 20]): with sender.readonly_build_validation_scope() as stats: first = sender._client_file_digest(self.path, 10, None, readonly=True) second = sender._client_file_digest(self.path, 10, None, readonly=True) self.assertEqual((first, second), (self.expected, self.expected)) self.assertEqual(stats['hashedFiles'], 2) self.assertEqual(stats['reusedFiles'], 0) def test_write_validation_never_uses_readonly_cache(self): with patch.object(sender, '_process_creation_id', return_value=20), \ patch.object(sender, '_hash_client_file', wraps=sender._hash_client_file) as hashing: with sender.readonly_build_validation_scope(): sender._client_file_digest(self.path, 10, None, readonly=True) sender._client_file_digest(self.path, 10, None, readonly=False) sender._client_file_digest(self.path, 10, None, readonly=False) self.assertEqual(hashing.call_count, 3) if __name__ == '__main__': unittest.main(verbosity=2)