Files
kefu/deploy/protocol-file-identity-20260917/test_windows_build_identity.py
T
2026-09-21 10:34:06 +08:00

135 lines
5.8 KiB
Python

"""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)