580 lines
26 KiB
Python
580 lines
26 KiB
Python
import concurrent.futures
|
|
import os
|
|
from pathlib import Path
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
APP_DIR = ROOT / 'app'
|
|
sys.path.insert(0, str(APP_DIR))
|
|
|
|
import scanner
|
|
import janitor
|
|
|
|
|
|
class RecordingLease:
|
|
def __init__(self):
|
|
self.releasable = True
|
|
self.children = []
|
|
self.release_calls = 0
|
|
self.non_release_calls = 0
|
|
|
|
def set_child_pid(self, pid):
|
|
self.children.append(pid)
|
|
return True
|
|
|
|
def mark_non_releasable(self):
|
|
self.non_release_calls += 1
|
|
self.releasable = False
|
|
|
|
def release(self):
|
|
self.release_calls += 1
|
|
|
|
|
|
class CompleteProcess:
|
|
returncode = 0
|
|
job_membership_verified = True
|
|
|
|
def __init__(self, pid):
|
|
self.pid = pid
|
|
self.payload_identity = {
|
|
'pid': pid,
|
|
'creation_time': f'fixture-{pid}',
|
|
'executable': sys.executable,
|
|
}
|
|
|
|
def poll(self):
|
|
return 0
|
|
|
|
|
|
def private_work_dir(temp_dir):
|
|
work_dir = os.path.join(temp_dir, 'work')
|
|
scanner.ensure_private_directory(work_dir, reject_reparse=True)
|
|
return work_dir
|
|
|
|
|
|
class ArtifactLifecycleSlotTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
scanner.initialize_scanner_runtime(preflight_complete=True, register_cleanup=False)
|
|
|
|
def tearDown(self):
|
|
if hasattr(scanner._scan_slot_scope_local, 'scope'):
|
|
del scanner._scan_slot_scope_local.scope
|
|
with scanner._scan_slot_fatal_lock:
|
|
scanner._scan_slot_fatal_detail = None
|
|
scanner._scan_slot_fatal_event.clear()
|
|
|
|
def test_existing_limiter_schema_gains_slot_kind_and_single_bonus_index(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
limiter_db = os.path.join(temp_dir, 'scan-limiter.sqlite')
|
|
connection = sqlite3.connect(limiter_db)
|
|
try:
|
|
connection.executescript('''
|
|
CREATE TABLE scan_slots (
|
|
slot_id TEXT PRIMARY KEY,
|
|
owner_pid INTEGER NOT NULL,
|
|
owner_thread INTEGER NOT NULL,
|
|
owner_source TEXT,
|
|
owner_creation_time TEXT,
|
|
owner_executable TEXT,
|
|
child_pid INTEGER,
|
|
child_creation_time TEXT,
|
|
child_executable TEXT,
|
|
command TEXT,
|
|
acquired_at REAL NOT NULL,
|
|
updated_at REAL NOT NULL
|
|
);
|
|
''')
|
|
connection.commit()
|
|
finally:
|
|
connection.close()
|
|
|
|
scanner.ensure_scan_limiter_db(limiter_db)
|
|
connection = sqlite3.connect(limiter_db)
|
|
try:
|
|
columns = {
|
|
row[1] for row in connection.execute('PRAGMA table_info(scan_slots)')
|
|
}
|
|
indexes = {
|
|
row[1] for row in connection.execute('PRAGMA index_list(scan_slots)')
|
|
}
|
|
finally:
|
|
connection.close()
|
|
self.assertIn('slot_kind', columns)
|
|
self.assertIn('idx_scan_slots_single_bonus', indexes)
|
|
|
|
def test_two_workers_cannot_enter_target_staging_concurrently(self):
|
|
first_entered = threading.Event()
|
|
second_entered = threading.Event()
|
|
release_first = threading.Event()
|
|
state_lock = threading.Lock()
|
|
state = {'active': 0, 'maximum': 0, 'entries': 0}
|
|
|
|
def stage_target(target, provider=None, **kwargs):
|
|
with state_lock:
|
|
state['active'] += 1
|
|
state['maximum'] = max(state['maximum'], state['active'])
|
|
state['entries'] += 1
|
|
entry = state['entries']
|
|
try:
|
|
if entry == 1:
|
|
first_entered.set()
|
|
if not release_first.wait(5):
|
|
raise RuntimeError('test did not release first target')
|
|
else:
|
|
second_entered.set()
|
|
return {'findings': [], 'errors': []}
|
|
finally:
|
|
with state_lock:
|
|
state['active'] -= 1
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
limiter_db = os.path.join(temp_dir, 'scan-limiter.sqlite')
|
|
with mock.patch.object(scanner.scan_config, 'max_active_scans', 1), \
|
|
mock.patch.object(scanner.scan_config, 'scan_limiter_db', limiter_db), \
|
|
mock.patch.object(scanner.scan_config, 'scan_slot_wait_sec', 0.01), \
|
|
mock.patch.object(scanner, 'scan_git_repo', side_effect=stage_target):
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as caller:
|
|
future = caller.submit(
|
|
scanner.scan_targets_batch,
|
|
['first', 'second'],
|
|
'git',
|
|
max_workers=2,
|
|
persist_results=False,
|
|
)
|
|
try:
|
|
self.assertTrue(first_entered.wait(3))
|
|
self.assertFalse(second_entered.wait(0.3))
|
|
finally:
|
|
release_first.set()
|
|
results = future.result(timeout=5)
|
|
|
|
self.assertTrue(second_entered.is_set())
|
|
self.assertEqual(state['maximum'], 1)
|
|
self.assertEqual(len(results), 2)
|
|
conn = sqlite3.connect(limiter_db)
|
|
try:
|
|
self.assertEqual(conn.execute('SELECT COUNT(*) FROM scan_slots').fetchone()[0], 0)
|
|
finally:
|
|
conn.close()
|
|
|
|
def test_fair_waiters_rotate_to_the_source_least_recently_granted(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir, mock.patch.dict(
|
|
os.environ, {'SCANNER_SOURCE': ''}, clear=False,
|
|
):
|
|
limiter_db = os.path.join(temp_dir, 'scan-limiter.sqlite')
|
|
order = []
|
|
order_lock = threading.Lock()
|
|
acquired = {'a': threading.Event(), 'b': threading.Event()}
|
|
release = {'a': threading.Event(), 'b': threading.Event()}
|
|
|
|
def waiter(source):
|
|
lease = scanner.acquire_scan_slot(['dispatch', source], 60)
|
|
with order_lock:
|
|
order.append(source)
|
|
acquired[source].set()
|
|
release[source].wait(5)
|
|
lease.release()
|
|
|
|
with mock.patch.object(scanner.scan_config, 'max_active_scans', 1), \
|
|
mock.patch.object(scanner.scan_config, 'scan_limiter_db', limiter_db), \
|
|
mock.patch.object(scanner.scan_config, 'scan_slot_wait_sec', 0.01):
|
|
occupant = scanner.acquire_scan_slot(['dispatch', 'a'], 60)
|
|
threads = [
|
|
threading.Thread(target=waiter, args=('a',)),
|
|
threading.Thread(target=waiter, args=('b',)),
|
|
]
|
|
for thread in threads:
|
|
thread.start()
|
|
time.sleep(0.1)
|
|
occupant.release()
|
|
self.assertTrue(acquired['b'].wait(3))
|
|
self.assertFalse(acquired['a'].is_set())
|
|
release['b'].set()
|
|
self.assertTrue(acquired['a'].wait(3))
|
|
release['a'].set()
|
|
for thread in threads:
|
|
thread.join(5)
|
|
self.assertEqual(order, ['b', 'a'])
|
|
|
|
def test_three_base_slots_remain_guaranteed_with_one_gated_bonus(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir, mock.patch.dict(
|
|
os.environ, {'SCANNER_SOURCE': ''}, clear=False,
|
|
):
|
|
limiter_db = os.path.join(temp_dir, 'scan-limiter.sqlite')
|
|
policy = (
|
|
mock.patch.object(scanner.scan_config, 'max_active_scans', 3),
|
|
mock.patch.object(scanner.scan_config, 'opportunistic_scan_slots', 1),
|
|
mock.patch.object(scanner.scan_config, 'opportunistic_scan_sources', ['github']),
|
|
mock.patch.object(scanner.scan_config, 'scan_limiter_db', limiter_db),
|
|
mock.patch.object(scanner.scan_config, 'scan_slot_wait_sec', 0.01),
|
|
mock.patch.object(scanner, 'opportunistic_scan_slot_allowed', return_value=True),
|
|
)
|
|
with policy[0], policy[1], policy[2], policy[3], policy[4], policy[5]:
|
|
base = [
|
|
scanner.acquire_scan_slot(['dispatch', 'github'], 60, wait=False)
|
|
for _ in range(3)
|
|
]
|
|
bonus = scanner.acquire_scan_slot(['dispatch', 'github'], 60, wait=False)
|
|
fifth = scanner.acquire_scan_slot(['dispatch', 'github'], 60, wait=False)
|
|
self.assertTrue(all(base))
|
|
self.assertIsNotNone(bonus)
|
|
self.assertIsNone(fifth)
|
|
|
|
connection = sqlite3.connect(limiter_db)
|
|
try:
|
|
self.assertEqual(
|
|
dict(connection.execute(
|
|
'SELECT slot_kind, COUNT(*) FROM scan_slots GROUP BY slot_kind'
|
|
)),
|
|
{'base': 3, 'bonus': 1},
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
base[0].release()
|
|
replacement = scanner.acquire_scan_slot(
|
|
['dispatch', 'github'], 60, wait=False,
|
|
)
|
|
self.assertIsNotNone(replacement)
|
|
connection = sqlite3.connect(limiter_db)
|
|
try:
|
|
kinds = dict(connection.execute(
|
|
'SELECT slot_kind, COUNT(*) FROM scan_slots GROUP BY slot_kind'
|
|
))
|
|
finally:
|
|
connection.close()
|
|
self.assertEqual(kinds, {'base': 3, 'bonus': 1})
|
|
for lease in base[1:] + [bonus, replacement]:
|
|
lease.release()
|
|
|
|
def test_bonus_slot_is_denied_to_docker_and_on_probe_failure(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir, mock.patch.dict(
|
|
os.environ, {'SCANNER_SOURCE': ''}, clear=False,
|
|
):
|
|
limiter_db = os.path.join(temp_dir, 'scan-limiter.sqlite')
|
|
with mock.patch.object(scanner.scan_config, 'max_active_scans', 3), \
|
|
mock.patch.object(scanner.scan_config, 'opportunistic_scan_slots', 1), \
|
|
mock.patch.object(scanner.scan_config, 'opportunistic_scan_sources', ['github']), \
|
|
mock.patch.object(scanner.scan_config, 'scan_limiter_db', limiter_db), \
|
|
mock.patch.object(scanner.scan_config, 'scan_slot_wait_sec', 0.01), \
|
|
mock.patch.object(scanner, 'windows_scan_capacity_snapshot', side_effect=OSError('probe')):
|
|
base = [
|
|
scanner.acquire_scan_slot(['dispatch', 'github'], 60, wait=False)
|
|
for _ in range(3)
|
|
]
|
|
self.assertIsNone(scanner.acquire_scan_slot(
|
|
['dispatch', 'dockerhub'], 60, wait=False,
|
|
))
|
|
self.assertIsNone(scanner.acquire_scan_slot(
|
|
['dispatch', 'github'], 60, wait=False,
|
|
))
|
|
for lease in base:
|
|
lease.release()
|
|
|
|
def test_preacquired_three_slots_release_before_blocked_downstream_work(self):
|
|
stage_barrier = threading.Barrier(3)
|
|
downstream_entered = threading.Event()
|
|
release_downstream = threading.Event()
|
|
state_lock = threading.Lock()
|
|
state = {'active': 0, 'maximum': 0, 'sink_calls': 0}
|
|
|
|
def stage_target(target, provider=None, **kwargs):
|
|
with state_lock:
|
|
state['active'] += 1
|
|
state['maximum'] = max(state['maximum'], state['active'])
|
|
try:
|
|
stage_barrier.wait(timeout=5)
|
|
return {'findings': [], 'errors': []}
|
|
finally:
|
|
with state_lock:
|
|
state['active'] -= 1
|
|
|
|
def sink(_result):
|
|
with state_lock:
|
|
state['sink_calls'] += 1
|
|
|
|
def blocked_downstream(completed, total, target):
|
|
if target not in ('Scan completed', 'Scan completed with persistence failures') and not downstream_entered.is_set():
|
|
downstream_entered.set()
|
|
if not release_downstream.wait(5):
|
|
raise RuntimeError('test did not release downstream work')
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
limiter_db = os.path.join(temp_dir, 'scan-limiter.sqlite')
|
|
with mock.patch.object(scanner.scan_config, 'max_active_scans', 3), \
|
|
mock.patch.object(scanner.scan_config, 'scan_limiter_db', limiter_db), \
|
|
mock.patch.object(scanner.scan_config, 'scan_slot_wait_sec', 0.01), \
|
|
mock.patch.object(scanner, 'scan_git_repo', side_effect=stage_target):
|
|
leases = scanner.acquire_scan_slot_leases(['dispatch', 'git'], 3, timeout_sec=60)
|
|
self.assertEqual(len(leases), 3)
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as caller:
|
|
future = caller.submit(
|
|
scanner.scan_targets_batch,
|
|
['one', 'two', 'three'],
|
|
'git',
|
|
max_workers=3,
|
|
progress_callback=blocked_downstream,
|
|
persist_results=False,
|
|
result_sink=sink,
|
|
scan_slot_leases=leases,
|
|
sink_within_scan_slot=True,
|
|
)
|
|
self.assertTrue(downstream_entered.wait(5))
|
|
deadline = time.monotonic() + 3
|
|
active = None
|
|
while time.monotonic() < deadline:
|
|
conn = sqlite3.connect(limiter_db)
|
|
try:
|
|
active = conn.execute('SELECT COUNT(*) FROM scan_slots').fetchone()[0]
|
|
finally:
|
|
conn.close()
|
|
if active == 0:
|
|
break
|
|
time.sleep(0.02)
|
|
self.assertEqual(active, 0)
|
|
release_downstream.set()
|
|
results = future.result(timeout=10)
|
|
|
|
self.assertEqual(state['maximum'], 3)
|
|
self.assertEqual(state['sink_calls'], 3)
|
|
self.assertEqual(len(results), 3)
|
|
conn = sqlite3.connect(limiter_db)
|
|
try:
|
|
self.assertEqual(conn.execute('SELECT COUNT(*) FROM scan_slots').fetchone()[0], 0)
|
|
finally:
|
|
conn.close()
|
|
|
|
def test_durable_bundle_handoff_releases_all_permits_before_db_notification(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
limiter_db = os.path.join(temp_dir, 'scan-limiter.sqlite')
|
|
bundle_root = os.path.join(temp_dir, 'bundles')
|
|
scanner.ensure_private_directory(bundle_root, reject_reparse=True)
|
|
notification_lock = threading.Lock()
|
|
notification_state = {'entered': 0}
|
|
all_notifications_entered = threading.Event()
|
|
release_notifications = threading.Event()
|
|
|
|
def stage(index, lease):
|
|
event_id = f'{index + 1:032x}'
|
|
bundle_id = f'{index + 100:032x}'
|
|
reservation = {
|
|
'reservation_id': index + 1,
|
|
'reservation_token': f'reservation-{index}',
|
|
'bundle_id': bundle_id,
|
|
'scan_event_id': event_id,
|
|
'queue_id': index + 1,
|
|
'claim_lease_token': f'lease-{index}',
|
|
'declared_bundle_bytes': 1024 * 1024,
|
|
'ready_relative_path': f'ready/{bundle_id[:2]}/{bundle_id}.trb',
|
|
'source': 'fixture',
|
|
'platform': 'git',
|
|
'target': f'target-{index}',
|
|
}
|
|
with scanner.scan_slot_scope(['scan-target', 'git'], 60, lease=lease):
|
|
result = {
|
|
'scan_event_id': event_id,
|
|
'target': f'target-{index}',
|
|
'scan_type': 'git',
|
|
'timestamp': '2026-07-27T00:00:00+00:00',
|
|
'findings': [],
|
|
'errors': [],
|
|
}
|
|
scanner.stage_result_bundle(
|
|
result, reservation, bundle_root, {}, {'queue_status': 'done'},
|
|
)
|
|
with notification_lock:
|
|
notification_state['entered'] += 1
|
|
if notification_state['entered'] == 3:
|
|
all_notifications_entered.set()
|
|
release_notifications.wait(5)
|
|
|
|
with mock.patch.object(scanner.scan_config, 'max_active_scans', 3), \
|
|
mock.patch.object(scanner.scan_config, 'scan_limiter_db', limiter_db), \
|
|
mock.patch.object(scanner.scan_config, 'scan_slot_wait_sec', 0.01):
|
|
leases = scanner.acquire_scan_slot_leases(['dispatch', 'git'], 3, timeout_sec=60)
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
|
|
futures = [executor.submit(stage, index, lease) for index, lease in enumerate(leases)]
|
|
deadline = time.monotonic() + 5
|
|
active = None
|
|
while time.monotonic() < deadline:
|
|
conn = sqlite3.connect(limiter_db)
|
|
try:
|
|
active = conn.execute('SELECT COUNT(*) FROM scan_slots').fetchone()[0]
|
|
finally:
|
|
conn.close()
|
|
if active == 0 and all_notifications_entered.is_set():
|
|
break
|
|
time.sleep(0.02)
|
|
self.assertEqual(active, 0)
|
|
self.assertTrue(all_notifications_entered.is_set())
|
|
release_notifications.set()
|
|
for future in futures:
|
|
future.result(timeout=5)
|
|
|
|
def test_nested_commands_borrow_one_outer_physical_lease(self):
|
|
lease = RecordingLease()
|
|
processes = [CompleteProcess(4101), CompleteProcess(4102)]
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
work_dir = private_work_dir(temp_dir)
|
|
|
|
def scan_target(target, provider=None, **kwargs):
|
|
first = scanner.run_command(['fixture', 'one'], timeout_sec=5)
|
|
second = scanner.run_command(['fixture', 'two'], timeout_sec=5)
|
|
self.assertEqual(first, ('', '', 0))
|
|
self.assertEqual(second, ('', '', 0))
|
|
return {'findings': [], 'errors': []}
|
|
|
|
with mock.patch.object(scanner, 'acquire_scan_slot', return_value=lease) as acquire, \
|
|
mock.patch.object(scanner, 'scan_git_repo', side_effect=scan_target), \
|
|
mock.patch.object(scanner.scan_config, 'work_dir', work_dir), \
|
|
mock.patch.object(scanner, 'require_trufflehog_launch_authority'), \
|
|
mock.patch.object(scanner, 'OwnedProcess', side_effect=processes), \
|
|
mock.patch.object(scanner.scan_config, 'min_free_gb', 0), \
|
|
mock.patch.object(scanner.scan_config, 'trufflehog_job_memory_limit_bytes', 4 * 1024 * 1024 * 1024):
|
|
results = scanner.scan_targets_batch(
|
|
['target'], 'git', max_workers=1, persist_results=False,
|
|
)
|
|
|
|
self.assertEqual(results[0]['errors'], [])
|
|
self.assertEqual(acquire.call_count, 1)
|
|
self.assertEqual(lease.children, [4101, 4102])
|
|
self.assertEqual(lease.release_calls, 1)
|
|
|
|
def test_scope_clears_after_exception_before_executor_thread_reuse(self):
|
|
lease = RecordingLease()
|
|
worker_threads = []
|
|
|
|
def fail_in_scope():
|
|
worker_threads.append(threading.get_ident())
|
|
with scanner.scan_slot_scope(['scan-target', 'fixture']):
|
|
raise RuntimeError('fixture failure')
|
|
|
|
def inspect_reused_thread():
|
|
worker_threads.append(threading.get_ident())
|
|
return scanner.scoped_scan_slot_lease()
|
|
|
|
with mock.patch.object(scanner, 'acquire_scan_slot', return_value=lease) as acquire:
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
with self.assertRaisesRegex(RuntimeError, 'fixture failure'):
|
|
executor.submit(fail_in_scope).result(timeout=3)
|
|
borrowed, current = executor.submit(inspect_reused_thread).result(timeout=3)
|
|
|
|
self.assertEqual(worker_threads[0], worker_threads[1])
|
|
self.assertFalse(borrowed)
|
|
self.assertIsNone(current)
|
|
self.assertEqual(acquire.call_count, 1)
|
|
self.assertEqual(lease.release_calls, 1)
|
|
|
|
def test_standalone_command_acquires_and_releases_its_own_lease(self):
|
|
lease = RecordingLease()
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
work_dir = private_work_dir(temp_dir)
|
|
with mock.patch.object(scanner, 'acquire_scan_slot', return_value=lease) as acquire, \
|
|
mock.patch.object(scanner.scan_config, 'work_dir', work_dir), \
|
|
mock.patch.object(scanner, 'require_trufflehog_launch_authority'), \
|
|
mock.patch.object(scanner, 'OwnedProcess', return_value=CompleteProcess(4151)), \
|
|
mock.patch.object(scanner.scan_config, 'min_free_gb', 0), \
|
|
mock.patch.object(scanner.scan_config, 'trufflehog_job_memory_limit_bytes', 4 * 1024 * 1024 * 1024):
|
|
result = scanner.run_command(['fixture'], timeout_sec=5)
|
|
|
|
self.assertEqual(result, ('', '', 0))
|
|
self.assertEqual(acquire.call_count, 1)
|
|
self.assertEqual(lease.children, [4151])
|
|
self.assertEqual(lease.release_calls, 1)
|
|
|
|
def test_uncertain_nested_child_termination_prevents_outer_release(self):
|
|
class UnstoppableProcess:
|
|
pid = 4201
|
|
returncode = None
|
|
job_membership_verified = True
|
|
payload_identity = {
|
|
'pid': pid,
|
|
'creation_time': 'fixture-4201',
|
|
'executable': sys.executable,
|
|
}
|
|
|
|
def poll(self):
|
|
return None
|
|
|
|
def kill(self):
|
|
return None
|
|
|
|
def wait(self, timeout=None):
|
|
raise subprocess.TimeoutExpired(['fixture'], timeout)
|
|
|
|
lease = RecordingLease()
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
work_dir = private_work_dir(temp_dir)
|
|
with mock.patch.object(scanner.scan_config, 'work_dir', work_dir), \
|
|
mock.patch.object(scanner.scan_config, 'min_free_gb', 0):
|
|
command_work_dir = scanner.create_command_work_dir()
|
|
with mock.patch.object(scanner, 'acquire_scan_slot', return_value=lease) as acquire, \
|
|
mock.patch.object(scanner.scan_config, 'work_dir', work_dir), \
|
|
mock.patch.object(scanner, 'create_command_work_dir', return_value=command_work_dir), \
|
|
mock.patch.object(scanner, 'require_trufflehog_launch_authority'), \
|
|
mock.patch.object(scanner, 'OwnedProcess', return_value=UnstoppableProcess()), \
|
|
mock.patch.object(scanner.shutil, 'disk_usage', return_value=mock.Mock(free=1)), \
|
|
mock.patch.object(scanner.scan_config, 'min_free_gb', 1), \
|
|
mock.patch.object(scanner.scan_config, 'trufflehog_job_memory_limit_bytes', 4 * 1024 * 1024 * 1024):
|
|
with scanner.scan_slot_scope(['scan-target', 'fixture']):
|
|
with self.assertRaisesRegex(scanner.ScanSlotFatalError, 'termination was not confirmed'):
|
|
scanner.run_command(['fixture'], timeout_sec=5)
|
|
self.assertEqual(acquire.call_count, 1)
|
|
self.assertFalse(lease.releasable)
|
|
self.assertGreaterEqual(lease.non_release_calls, 1)
|
|
self.assertEqual(lease.release_calls, 0)
|
|
self.assertEqual(scanner.scoped_scan_slot_lease(), (False, None))
|
|
|
|
def test_command_workdir_marker_is_schema_two_and_only_janitor_removes_stale_tree(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
work_dir = os.path.join(temp_dir, 'work')
|
|
scanner.ensure_private_directory(work_dir, reject_reparse=True)
|
|
markerless = os.path.join(work_dir, 'trufflehog-run-foreign')
|
|
scanner.ensure_private_directory(markerless, reject_reparse=True)
|
|
|
|
with mock.patch.object(scanner.scan_config, 'work_dir', work_dir), \
|
|
mock.patch.object(scanner.scan_config, 'min_free_gb', 0):
|
|
command_dir = scanner.create_command_work_dir()
|
|
marker = os.path.join(command_dir, scanner.TEMP_OWNER_FILE)
|
|
owner = scanner.read_temp_owner(command_dir)
|
|
self.assertEqual(owner.get('owner_pid'), os.getpid())
|
|
self.assertEqual(owner.get('schema'), 2)
|
|
self.assertTrue(owner.get('owner_creation_time'))
|
|
self.assertTrue(owner.get('owner_executable'))
|
|
self.assertTrue(scanner.private_file_ready(marker))
|
|
|
|
old = time.time() - 7200
|
|
os.utime(command_dir, (old, old))
|
|
os.utime(markerless, (old, old))
|
|
removed = scanner.cleanup_stale_temp_dirs(age_minutes=1, log=False)
|
|
self.assertEqual(removed, 0)
|
|
with mock.patch.object(janitor, 'exact_process_identity_state', return_value='dead'):
|
|
report = janitor.run_janitor_pass(
|
|
work_dir,
|
|
[owner['owner_executable'], owner['parent_executable']],
|
|
minimum_age_sec=0,
|
|
)
|
|
self.assertEqual(report['removed'], 1)
|
|
self.assertFalse(os.path.exists(command_dir))
|
|
self.assertTrue(os.path.isdir(markerless))
|
|
|
|
before = set(os.listdir(work_dir))
|
|
with mock.patch.object(scanner, 'atomic_write_private_json', side_effect=OSError('marker denied')):
|
|
with self.assertRaisesRegex(RuntimeError, 'required temp owner marker'):
|
|
scanner.create_command_work_dir()
|
|
self.assertEqual(set(os.listdir(work_dir)), before)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|