135 lines
5.8 KiB
Python
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)
|