test(harness): reorganize regression suites and preserve contract coverage
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""repository regression tests."""
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Synthetic local Git pre-fetch gate checks; no provider/network operations."""
|
||||
from harness.tests._support.paths import ROOT as SOURCE_ROOT
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = SOURCE_ROOT
|
||||
SCRIPT = ROOT / 'harness/lib/repository_prefetch.py'
|
||||
|
||||
|
||||
class RepositoryPrefetchTests(unittest.TestCase):
|
||||
def git(self, *args):
|
||||
result = subprocess.run(['/usr/bin/git', '-c', 'core.hooksPath=/dev/null', *map(str, args)],
|
||||
env=self.env, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
return result.stdout
|
||||
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR', '/opt/data/cache/scratch'))
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.env = {'PATH': '/usr/bin:/bin', 'HOME': str(self.root), 'GIT_CONFIG_NOSYSTEM': '1',
|
||||
'GIT_CONFIG_GLOBAL': '/dev/null', 'GIT_TERMINAL_PROMPT': '0'}
|
||||
self.seed = self.root / 'seed'
|
||||
self.bare = self.root / 'mirror.git'
|
||||
self.git('init', '-q', self.seed)
|
||||
self.git('-C', self.seed, 'config', 'user.name', 'Synthetic')
|
||||
self.git('-C', self.seed, 'config', 'user.email', 'fixture@example.invalid')
|
||||
(self.seed / 'a').write_text('synthetic')
|
||||
self.git('-C', self.seed, 'add', 'a')
|
||||
self.git('-C', self.seed, 'commit', '-qm', 'synthetic')
|
||||
self.git('clone', '--mirror', self.seed, self.bare)
|
||||
|
||||
def gate(self):
|
||||
self.assertTrue(SCRIPT.is_file(), 'canonical read-only prefetch helper is missing')
|
||||
result = subprocess.run(['python3', '-B', str(SCRIPT), '--git-dir', str(self.bare)],
|
||||
env=self.env, capture_output=True, text=True, timeout=15)
|
||||
return result.returncode, json.loads(result.stdout)
|
||||
|
||||
def test_bare_only_passes_real_git_read_only(self):
|
||||
before = self.git('--git-dir', self.bare, 'show-ref')
|
||||
code, value = self.gate()
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(value, {'status': 'pass', 'attached_worktrees': [], 'reason': 'no_attached_worktrees'})
|
||||
self.assertEqual(self.git('--git-dir', self.bare, 'show-ref'), before)
|
||||
|
||||
def test_attached_blocks_exact_branch_and_path_without_mutation(self):
|
||||
checkout = self.root / 'attached space\npath'
|
||||
self.git('--git-dir', self.bare, 'worktree', 'add', '-b', 'issue-fixture', checkout, 'HEAD')
|
||||
self.git('--git-dir', self.bare, 'worktree', 'lock', checkout)
|
||||
state = checkout.parent / 'state.json'
|
||||
state.write_text('sentinel')
|
||||
before = self.fingerprint(self.bare, checkout, state)
|
||||
code, value = self.gate()
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(value, {'status': 'block', 'attached_worktrees': [
|
||||
{'branch': 'refs/heads/issue-fixture', 'path': str(checkout)}], 'reason': 'attached_worktree'})
|
||||
self.assertEqual(self.fingerprint(self.bare, checkout, state), before)
|
||||
|
||||
def test_detached_locked_worktree_passes_without_mutation(self):
|
||||
checkout = self.root / 'detached'
|
||||
self.git('--git-dir', self.bare, 'worktree', 'add', '--detach', '--lock', checkout, 'HEAD')
|
||||
before = self.fingerprint(self.bare, checkout)
|
||||
code, value = self.gate()
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(value['attached_worktrees'], [])
|
||||
self.assertEqual(self.fingerprint(self.bare, checkout), before)
|
||||
|
||||
def test_mixed_worktrees_report_all_attached_without_branch_name_policy(self):
|
||||
detached = self.root / 'detached'
|
||||
self.git('--git-dir', self.bare, 'worktree', 'add', '--detach', detached, 'HEAD')
|
||||
expected = []
|
||||
for number, branch in enumerate(['feature/normal', 'issue-fixture']):
|
||||
checkout = self.root / ('attached-' + str(number))
|
||||
self.git('--git-dir', self.bare, 'worktree', 'add', '-b', branch, checkout, 'HEAD')
|
||||
expected.append({'branch': 'refs/heads/' + branch, 'path': str(checkout)})
|
||||
code, value = self.gate()
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(sorted(value['attached_worktrees'], key=lambda x: x['path']), expected)
|
||||
|
||||
def test_unavailable_nonbare_and_symlink_input_fail_closed(self):
|
||||
self.assertEqual(self.gate()[0], 0)
|
||||
original = self.bare
|
||||
link = self.root / 'alias'
|
||||
link.symlink_to(original, target_is_directory=True)
|
||||
for target in [self.root / 'absent', self.seed / '.git', link]:
|
||||
with self.subTest(target=str(target)):
|
||||
self.bare = target
|
||||
code, value = self.gate()
|
||||
self.assertEqual(code, 2)
|
||||
self.assertEqual(value['status'], 'block')
|
||||
self.bare = original
|
||||
# Raw Git stderr must not become diagnostic evidence.
|
||||
(original / 'config').write_text('secret-like fixture invalid config [')
|
||||
result = subprocess.run(['python3', '-B', str(SCRIPT), '--git-dir', str(original)],
|
||||
env=self.env, capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertNotIn('secret-like', result.stdout + result.stderr)
|
||||
|
||||
def test_malformed_nul_listing_is_rejected_not_treated_as_empty(self):
|
||||
from harness.lib import repository_prefetch as module
|
||||
raw = self.git('--git-dir', self.bare, 'worktree', 'list', '--porcelain', '-z').encode()
|
||||
self.assertEqual(module.parse_worktrees(raw)[0]['kind'], 'bare')
|
||||
for bad in [b'', raw[:-1], raw + raw, raw.replace(b'bare', b'unknown'),
|
||||
b'worktree /x\0HEAD ' + b'a' * 40 + b'\0detached\0\0',
|
||||
raw + b'worktree /x\0HEAD ' + b'a' * 40 + b'\0branch refs/heads/../bad\0\0']:
|
||||
with self.subTest(raw=bad):
|
||||
with self.assertRaises(module.PrefetchError):
|
||||
module.parse_worktrees(bad)
|
||||
|
||||
def test_reader_failure_and_malformed_output_fail_closed_after_valid_control(self):
|
||||
from harness.lib import repository_prefetch as module
|
||||
from unittest.mock import patch
|
||||
self.assertEqual(module.inspect_repository(str(self.bare))['status'], 'pass')
|
||||
bare = subprocess.CompletedProcess([], 0, b'true\n')
|
||||
bad = subprocess.CompletedProcess([], 0, b'invalid secret-like fixture')
|
||||
for responses in ([bare, bad], [bare, None]):
|
||||
with self.subTest(responses=str(responses)):
|
||||
with patch.object(module.binding, '_git', side_effect=responses):
|
||||
with self.assertRaises(module.PrefetchError) as error:
|
||||
module.inspect_repository(str(self.bare))
|
||||
self.assertNotIn('secret-like', str(error.exception))
|
||||
with patch.object(module.binding, '_git', side_effect=module.binding.BindingError('secret-like fixture')):
|
||||
with self.assertRaisesRegex(module.PrefetchError, '^git_read_unavailable$'):
|
||||
module.inspect_repository(str(self.bare))
|
||||
|
||||
def test_documentation_contract_requires_gate_before_fetch(self):
|
||||
text = (ROOT / 'harness/operations/repository-cache.md').read_text()
|
||||
section = text.split('## 기존 cache 갱신', 1)[1].split('\n## ', 1)[0]
|
||||
self.assertIn('repository_prefetch.py', section)
|
||||
self.assertLess(section.index('repository_prefetch.py'), section.index('prune/fetch'))
|
||||
for rule in ['--git-dir', 'repository lock', 'exit 0', 'exit 1', 'exit 2']:
|
||||
self.assertIn(rule, section)
|
||||
|
||||
def fingerprint(self, *roots):
|
||||
import hashlib
|
||||
result = {}
|
||||
for root in roots:
|
||||
for path in ([root] if root.is_file() else root.rglob('*')):
|
||||
if path.is_file():
|
||||
st = path.stat()
|
||||
result[str(path)] = (hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
st.st_mode, st.st_mtime_ns, st.st_ino)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,190 @@
|
||||
from harness.tests._support.paths import HARNESS as SOURCE_HARNESS
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from harness.lib.managed_lock import ManagedLockSet
|
||||
from harness.lib import runtime_root as runtime
|
||||
|
||||
|
||||
class RuntimeRootTests(unittest.TestCase):
|
||||
def cache(self, root):
|
||||
repo = root / 'repositories' / 'owner' / 'repo'
|
||||
for p in [root / 'repositories', root / 'repositories' / 'owner', repo, repo / 'locks']:
|
||||
p.mkdir(mode=0o700)
|
||||
return repo
|
||||
|
||||
def prepare(self, root, run='workflow-1'):
|
||||
with ManagedLockSet.for_issue(root, 'owner', 'repo', 17) as locks:
|
||||
with locks.repository_scope():
|
||||
return runtime.prepare_issue_runtime(root, 'owner', 'repo', 17, run, lock_set=locks)
|
||||
|
||||
def test_new_runtime_is_private_and_preserves_existing_cache(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); repo = self.cache(root)
|
||||
os.chmod(root / 'repositories', 0o755)
|
||||
before = repo.stat()
|
||||
result = self.prepare(root)
|
||||
try:
|
||||
self.assertEqual(result.path, runtime.issue_runtime_path(root, 'owner', 'repo', 17, 'workflow-1'))
|
||||
self.assertEqual(stat.S_IMODE(os.fstat(result.fd).st_mode), 0o700)
|
||||
self.assertEqual(os.fstat(result.fd).st_ino, result.path.stat().st_ino)
|
||||
finally:
|
||||
os.close(result.fd)
|
||||
self.assertEqual(repo.stat().st_ino, before.st_ino)
|
||||
self.assertEqual(stat.S_IMODE((root / 'repositories').stat().st_mode), 0o755)
|
||||
self.assertFalse((result.path.parents[1] / 'state.json').exists())
|
||||
|
||||
def test_missing_or_wrong_lock_set_cannot_create_directories(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); repo = self.cache(root)
|
||||
locks = ManagedLockSet.for_issue(root, 'owner', 'repo', 17)
|
||||
with self.assertRaises(ValueError):
|
||||
runtime.prepare_issue_runtime(root, 'owner', 'repo', 17, 'run', lock_set=locks)
|
||||
self.assertFalse((repo / 'worktrees').exists())
|
||||
with locks:
|
||||
with self.assertRaises(ValueError):
|
||||
runtime.prepare_issue_runtime(root, 'owner', 'repo', 17, 'run', lock_set=locks)
|
||||
with locks.repository_scope():
|
||||
with self.assertRaises(ValueError):
|
||||
runtime.prepare_issue_runtime(root, 'owner', 'repo', 18, 'run', lock_set=locks)
|
||||
self.assertFalse((repo / 'worktrees').exists())
|
||||
|
||||
def test_kernel_unlocked_wrappers_cannot_authorize_runtime_creation(self):
|
||||
import fcntl
|
||||
for resource in ('issue', 'repository'):
|
||||
with self.subTest(resource=resource), tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); repo = self.cache(root)
|
||||
with ManagedLockSet.for_issue(root, 'owner', 'repo', 17) as locks:
|
||||
with locks.repository_scope():
|
||||
fcntl.flock(getattr(locks, resource).fd, fcntl.LOCK_UN)
|
||||
self.assertTrue(getattr(locks, resource).held)
|
||||
with self.assertRaises(ValueError):
|
||||
runtime.prepare_issue_runtime(root, 'owner', 'repo', 17, 'run', lock_set=locks)
|
||||
self.assertFalse((repo / 'worktrees').exists())
|
||||
|
||||
def test_run_collision_preserves_competing_run_and_parent_chain(self):
|
||||
from unittest.mock import patch
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); self.cache(root)
|
||||
path = runtime.issue_runtime_path(root, 'owner', 'repo', 17, 'collision')
|
||||
mkdir = os.mkdir
|
||||
def competing_run(name, mode=0o777, *, dir_fd=None):
|
||||
mkdir(name, mode, dir_fd=dir_fd)
|
||||
if name == 'collision':
|
||||
raise FileExistsError('competing allocator created run')
|
||||
with patch.object(runtime.os, 'mkdir', side_effect=competing_run):
|
||||
with self.assertRaises(ValueError): self.prepare(root, 'collision')
|
||||
self.assertTrue(path.is_dir())
|
||||
self.assertTrue(path.parent.is_dir())
|
||||
self.assertTrue(path.parent.parent.is_dir())
|
||||
self.assertEqual(list(path.iterdir()), [])
|
||||
|
||||
def test_stale_private_run_is_rejected_without_reuse(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); self.cache(root)
|
||||
result = self.prepare(root); os.close(result.fd)
|
||||
marker = result.path / 'marker'; marker.write_text('preserve')
|
||||
before = result.path.stat()
|
||||
with self.assertRaises(ValueError): self.prepare(root)
|
||||
self.assertEqual(result.path.stat().st_ino, before.st_ino)
|
||||
self.assertEqual(marker.read_text(), 'preserve')
|
||||
|
||||
def test_unsafe_parent_is_not_repaired(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); repo = self.cache(root)
|
||||
p = repo / 'worktrees'; p.mkdir(mode=0o755)
|
||||
with self.assertRaises(ValueError): self.prepare(root)
|
||||
self.assertEqual(stat.S_IMODE(p.stat().st_mode), 0o755)
|
||||
self.assertEqual(list(p.iterdir()), [])
|
||||
|
||||
def test_symlink_parent_cannot_redirect_creation(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); repo = self.cache(root)
|
||||
other = root / 'elsewhere'; other.mkdir(mode=0o700)
|
||||
(repo / 'worktrees').symlink_to(other)
|
||||
with self.assertRaises((ValueError, OSError)): self.prepare(root)
|
||||
self.assertEqual(list(other.iterdir()), [])
|
||||
|
||||
def test_invalid_identifiers_and_literal_traversal_are_rejected(self):
|
||||
for root, owner, repository, issue, run in [
|
||||
('/tmp/../root', 'owner', 'repo', 17, 'run'),
|
||||
('/tmp/root', '../owner', 'repo', 17, 'run'),
|
||||
('/tmp/root', 'owner', 'repo', True, 'run'),
|
||||
('/tmp/root', 'owner', 'repo', 17, '..'),
|
||||
('/tmp/root', 'owner', 'repo', 17, 'x' * 129),
|
||||
]:
|
||||
with self.subTest(root=root, owner=owner, issue=issue, run=run), self.assertRaises(ValueError):
|
||||
runtime.issue_runtime_path(root, owner, repository, issue, run)
|
||||
self.assertIn('o' * 255, str(runtime.issue_runtime_path('/tmp/root', 'o' * 255, 'repo', 17, 'run')))
|
||||
|
||||
def git(self, *args):
|
||||
result = subprocess.run(['git', *map(str, args)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
return result.stdout
|
||||
|
||||
def test_runtime_only_container_supports_real_new_git_checkout_and_removal(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); repo = self.cache(root)
|
||||
seed = root / 'seed'
|
||||
self.git('init', seed)
|
||||
(seed / 'file').write_text('fixture')
|
||||
self.git('-C', seed, 'add', 'file')
|
||||
self.git('-C', seed, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'fixture')
|
||||
self.git('clone', '--bare', seed, repo / 'repository.git')
|
||||
source = self.git('-C', seed, 'rev-parse', 'HEAD').strip()
|
||||
run = self.prepare(root); os.close(run.fd)
|
||||
issue = repo / 'worktrees' / 'issue-17'
|
||||
self.assertEqual(runtime.inspect_issue_worktree_admission(root, 'owner', 'repo', 17), 'runtime-only')
|
||||
self.git('--git-dir', repo / 'repository.git', 'worktree', 'add', '--detach', issue / 'checkout', source)
|
||||
self.assertEqual(runtime.inspect_issue_worktree_admission(root, 'owner', 'repo', 17), 'invalid')
|
||||
state = issue / 'state.json'; state.write_text(json.dumps({'schema': 'gitea-agent.worktree/v1'})); state.chmod(0o600)
|
||||
self.assertEqual(runtime.inspect_issue_worktree_admission(root, 'owner', 'repo', 17), 'checkout-candidate')
|
||||
self.git('--git-dir', repo / 'repository.git', 'worktree', 'remove', issue / 'checkout')
|
||||
state.unlink()
|
||||
self.assertEqual(runtime.inspect_issue_worktree_admission(root, 'owner', 'repo', 17), 'runtime-only')
|
||||
self.assertTrue(run.path.exists())
|
||||
(issue / 'unknown').write_text('preserve')
|
||||
self.assertEqual(runtime.inspect_issue_worktree_admission(root, 'owner', 'repo', 17), 'invalid')
|
||||
|
||||
def test_inspection_cli_requires_live_repository_lease_and_uses_real_git(self):
|
||||
import sys
|
||||
from harness.lib import managed_lock_broker as broker
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); repo = self.cache(root)
|
||||
self.git('init', '--bare', repo / 'repository.git')
|
||||
base = SOURCE_HARNESS
|
||||
cmd = [sys.executable, '-B', str(base / 'lib' / 'managed_lock_broker.py'), 'serve',
|
||||
'--root', str(root), '--owner', 'owner', '--repository', 'repo', '--issue', '17',
|
||||
'--role', 'gitea-impl-agent', '--run-id', 'cli-workflow', '--ttl-seconds', '30']
|
||||
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
try:
|
||||
line = process.stdout.readline()
|
||||
self.assertTrue(line, 'broker failed before ready')
|
||||
ready = json.loads(line)
|
||||
inspect = [sys.executable, '-B', str(base / 'lib' / 'runtime_root.py'), 'inspect-worktree',
|
||||
'--root', str(root), '--owner', 'owner', '--repository', 'repo', '--issue', '17',
|
||||
'--handle', ready['handle']]
|
||||
result = subprocess.run(inspect, capture_output=True, text=True, timeout=10)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
broker.request_handle(ready['handle'], 'acquire_repository')
|
||||
result = subprocess.run(inspect, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(json.loads(result.stdout)['container'], 'runtime-only')
|
||||
broker.request_handle(ready['handle'], 'release_repository')
|
||||
broker.request_handle(ready['handle'], 'release')
|
||||
self.assertEqual(process.wait(timeout=10), 0)
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate(); process.wait(timeout=10)
|
||||
process.stdout.close(); process.stderr.close()
|
||||
|
||||
def test_inspection_rejects_missing_cache_instead_of_creating_it(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with self.assertRaises((ValueError, OSError)):
|
||||
runtime.inspect_issue_worktree_admission(Path(tmp), 'owner', 'repo', 17)
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [])
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Real-Git regressions; no pytest or product execution dependency."""
|
||||
from harness.tests._support.paths import ROOT as SOURCE_ROOT
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from harness.lib import test_inventory_binding as binding
|
||||
|
||||
ROOT = SOURCE_ROOT
|
||||
|
||||
|
||||
class InventoryBindingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.work = self.root / 'work'
|
||||
self.work.mkdir()
|
||||
self.git('init', '-q')
|
||||
self.git('config', 'user.email', 'fixture@example.invalid')
|
||||
self.git('config', 'user.name', 'Fixture')
|
||||
(self.work / 'tests').mkdir()
|
||||
(self.work / 'tests/sample.py').write_text('def test_sample():\n assert True\n')
|
||||
(self.work / 'config.ini').write_text('[test]\nvalue=1\n')
|
||||
self.git('add', '.')
|
||||
self.git('commit', '-qm', 'baseline')
|
||||
self.rev = self.git('rev-parse', 'HEAD').strip()
|
||||
self.gitdir = self.work / '.git'
|
||||
self.data = self.make_data(self.rev)
|
||||
|
||||
def git(self, *args):
|
||||
return subprocess.check_output(['git', '-C', str(self.work), *args], text=True, stderr=subprocess.DEVNULL)
|
||||
|
||||
def make_data(self, revision):
|
||||
hashes = {p: hashlib.sha256((self.work / p).read_bytes()).hexdigest()
|
||||
for p in ['config.ini', 'tests/sample.py']}
|
||||
return {'schema': 'gitea-agent.test-inventory-binding/v1',
|
||||
'source_revision': revision, 'stage': 'baseline',
|
||||
'ids': ['tests/sample.py::test_sample'],
|
||||
'file_sha256': {'tests/sample.py': hashes['tests/sample.py']},
|
||||
'collection': {'source_revision': revision, 'stage': 'baseline',
|
||||
'argv': ['python3', '-m', 'pytest', '--collect-only'],
|
||||
'scope_paths': list(hashes), 'scope_sha256': self.scope_digest(hashes),
|
||||
'evidence': 'retained-collection-record'}}
|
||||
|
||||
@staticmethod
|
||||
def scope_digest(hashes):
|
||||
return hashlib.sha256(json.dumps(hashes, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
|
||||
def verify(self, data=None, revision=None, stage='baseline', gitdir=None):
|
||||
return binding.verify_inventory(str(gitdir or self.gitdir), self.data if data is None else data,
|
||||
self.rev if revision is None else revision, stage)
|
||||
|
||||
def cli(self, raw=None, extra=(), input_path=None):
|
||||
inp = self.root / 'input.json'
|
||||
if input_path is None:
|
||||
inp.write_text(json.dumps(self.data) if raw is None else raw)
|
||||
else:
|
||||
inp = input_path
|
||||
return subprocess.run([sys.executable, '-B', str(ROOT / 'harness/lib/test_inventory_binding.py'),
|
||||
'--git-dir', str(self.gitdir), '--input', str(inp),
|
||||
'--expected-revision', self.rev, '--expected-stage', 'baseline', *extra],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
|
||||
def test_valid_normal_and_bare_without_state_or_ref_changes(self):
|
||||
before = (self.git('status', '--porcelain'), self.git('show-ref'))
|
||||
(self.work / 'tests/sample.py').write_text('uncommitted edit')
|
||||
dirty = self.git('status', '--porcelain')
|
||||
self.assertEqual(self.verify()['status'], 'pass')
|
||||
bare = self.root / 'mirror.git'
|
||||
subprocess.run(['git', 'clone', '-q', '--bare', str(self.work), str(bare)], check=True)
|
||||
self.assertEqual(self.verify(gitdir=bare)['status'], 'pass')
|
||||
self.assertEqual(self.git('status', '--porcelain'), dirty)
|
||||
self.assertEqual(self.git('show-ref'), before[1])
|
||||
|
||||
def test_expected_revision_and_stage_cannot_be_self_attested(self):
|
||||
self.assertEqual(self.verify(revision='0' * 40)['reason_codes'], ['source_revision_mismatch'])
|
||||
self.assertEqual(self.verify(stage='current')['reason_codes'], ['stage_mismatch'])
|
||||
newer = copy.deepcopy(self.data)
|
||||
newer['stage'] = newer['collection']['stage'] = 'current'
|
||||
self.assertEqual(self.verify(newer)['status'], 'block')
|
||||
|
||||
def test_tag_object_is_not_an_exact_commit_revision(self):
|
||||
self.git('tag', '-a', '-m', 'fixture tag', 'fixture-tag')
|
||||
tag = self.git('rev-parse', 'fixture-tag').strip()
|
||||
changed = copy.deepcopy(self.data)
|
||||
changed['source_revision'] = changed['collection']['source_revision'] = tag
|
||||
self.assertEqual(self.verify(changed, revision=tag)['status'], 'block')
|
||||
|
||||
def test_missing_commit_and_malformed_sha(self):
|
||||
data = copy.deepcopy(self.data)
|
||||
data['source_revision'] = data['collection']['source_revision'] = '0' * 40
|
||||
self.assertEqual(self.verify(data, revision='0' * 40)['reason_codes'], ['revision_not_found'])
|
||||
for rev in [self.rev.upper(), 'HEAD', self.rev + ' ', True]:
|
||||
with self.subTest(rev=rev), self.assertRaises(binding.BindingError):
|
||||
self.verify(revision=rev)
|
||||
|
||||
def test_absent_file_and_tampered_recorded_hash_are_blocked(self):
|
||||
changed = copy.deepcopy(self.data)
|
||||
changed['file_sha256']['tests/sample.py'] = '0' * 64
|
||||
self.assertIn('recorded_file_hash_mismatch', self.verify(changed)['reason_codes'])
|
||||
missing = copy.deepcopy(self.data)
|
||||
missing['ids'] = ['tests/absent.py::test_absent']
|
||||
missing['file_sha256'] = {'tests/absent.py': '0' * 64}
|
||||
missing['collection']['scope_paths'] = ['tests/absent.py']
|
||||
self.assertIn('scope_file_missing', self.verify(missing)['reason_codes'])
|
||||
|
||||
def test_changed_collector_configuration_breaks_scope_digest(self):
|
||||
(self.work / 'config.ini').write_text('[test]\nvalue=2\n')
|
||||
self.git('add', 'config.ini')
|
||||
self.git('commit', '-qm', 'change collection config')
|
||||
revision = self.git('rev-parse', 'HEAD').strip()
|
||||
changed = copy.deepcopy(self.data)
|
||||
changed['source_revision'] = changed['collection']['source_revision'] = revision
|
||||
self.assertIn('scope_digest_mismatch', self.verify(changed, revision=revision)['reason_codes'])
|
||||
|
||||
def test_nested_source_or_stage_and_omitted_scope_are_rejected(self):
|
||||
for key, value in [('source_revision', '0' * 40), ('stage', 'current'), ('scope_paths', ['config.ini'])]:
|
||||
changed = copy.deepcopy(self.data)
|
||||
changed['collection'][key] = value
|
||||
with self.subTest(key=key), self.assertRaises(binding.BindingError):
|
||||
self.verify(changed)
|
||||
|
||||
def test_duplicates_unsafe_paths_extra_hash_and_wrong_types(self):
|
||||
variants = []
|
||||
for value in [self.data['ids'] * 2, [{}], True, ['../outside::case'], ['/outside::case'], ['tests//sample.py::case']]:
|
||||
changed = copy.deepcopy(self.data); changed['ids'] = value; variants.append(changed)
|
||||
changed = copy.deepcopy(self.data); changed['extra'] = True; variants.append(changed)
|
||||
changed = copy.deepcopy(self.data); changed['file_sha256']['extra.py'] = '0' * 64; variants.append(changed)
|
||||
changed = copy.deepcopy(self.data); changed['collection']['scope_paths'] = [{}]; variants.append(changed)
|
||||
for changed in variants:
|
||||
with self.subTest(data_type=type(changed['ids']).__name__), self.assertRaises(binding.BindingError):
|
||||
self.verify(changed)
|
||||
|
||||
def test_empty_collection_is_valid_but_not_a_collection_execution_claim(self):
|
||||
changed = copy.deepcopy(self.data)
|
||||
changed['ids'] = []; changed['file_sha256'] = {}
|
||||
self.assertEqual(self.verify(changed)['status'], 'pass')
|
||||
changed['collection']['scope_paths'] = []
|
||||
changed['collection']['scope_sha256'] = self.scope_digest({})
|
||||
self.assertEqual(self.verify(changed)['status'], 'pass')
|
||||
|
||||
def test_symlink_tree_and_gitlink_are_not_regular_blobs(self):
|
||||
(self.work / 'tests/link.py').symlink_to('sample.py')
|
||||
self.git('add', 'tests/link.py'); self.git('commit', '-qm', 'add symlink')
|
||||
self.git('update-index', '--add', '--cacheinfo', f'160000,{self.rev},tests/submodule')
|
||||
self.git('commit', '-qm', 'add gitlink')
|
||||
revision = self.git('rev-parse', 'HEAD').strip()
|
||||
for path in ['tests/link.py', 'tests/submodule', 'tests']:
|
||||
changed = copy.deepcopy(self.data)
|
||||
changed['source_revision'] = changed['collection']['source_revision'] = revision
|
||||
changed['ids'] = [path + '::case']; changed['file_sha256'] = {path: '0' * 64}
|
||||
changed['collection']['scope_paths'] = [path]
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.verify(changed, revision=revision)['status'], 'block')
|
||||
|
||||
def test_symlink_git_directory_ancestor_and_lexical_alias_rejected(self):
|
||||
alias = self.root / 'alias'; alias.symlink_to(self.work, target_is_directory=True)
|
||||
for path in [str(alias / '.git'), str(self.gitdir) + '/', str(self.work / 'x') + '/../.git']:
|
||||
with self.subTest(path=path), self.assertRaises(binding.BindingError):
|
||||
binding.verify_inventory(path, self.data, self.rev, 'baseline')
|
||||
|
||||
def test_ambient_git_redirects_and_replacements_do_not_change_binding(self):
|
||||
self.git('replace', self.rev, self.rev)
|
||||
with patch.dict(os.environ, {'GIT_DIR': '/nonexistent', 'GIT_WORK_TREE': '/nonexistent',
|
||||
'GIT_CONFIG_COUNT': '1', 'GIT_CONFIG_KEY_0': 'core.bare',
|
||||
'GIT_CONFIG_VALUE_0': 'true'}):
|
||||
self.assertEqual(self.verify()['status'], 'pass')
|
||||
|
||||
def test_json_symlink_ancestor_and_lexical_alias_rejected(self):
|
||||
real = self.root / 'records'
|
||||
real.mkdir()
|
||||
record = real / 'record.json'
|
||||
record.write_text(json.dumps(self.data))
|
||||
alias = self.root / 'record-alias'
|
||||
alias.symlink_to(real, target_is_directory=True)
|
||||
for path in [str(alias / 'record.json'), str(real) + '/./record.json']:
|
||||
with self.subTest(path=path), self.assertRaises(binding.BindingError):
|
||||
binding._json_file(path)
|
||||
|
||||
def test_lazy_fetch_disabled_in_read_only_git_environment(self):
|
||||
self.assertEqual(binding._env().get('GIT_NO_LAZY_FETCH'), '1')
|
||||
|
||||
def test_json_duplicate_keys_symlink_fifo_and_oversize_rejected(self):
|
||||
duplicate = json.dumps(self.data)[:-1] + ',"schema":"secret-marker"}'
|
||||
for raw in [duplicate, '{"secret":"secret-marker"}', '[' * 2000, ' ' * (1024 * 1024 + 1)]:
|
||||
result = self.cli(raw)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertNotIn('secret-marker', result.stdout + result.stderr)
|
||||
real = self.root / 'real.json'; real.write_text(json.dumps(self.data))
|
||||
alias = self.root / 'alias.json'; alias.symlink_to(real)
|
||||
self.assertEqual(self.cli(input_path=alias).returncode, 2)
|
||||
fifo = self.root / 'fifo'; os.mkfifo(fifo)
|
||||
self.assertEqual(self.cli(input_path=fifo).returncode, 2)
|
||||
|
||||
def test_cli_pass_block_and_rejected_input_are_json(self):
|
||||
self.assertEqual(self.cli().returncode, 0)
|
||||
changed = copy.deepcopy(self.data); changed['stage'] = changed['collection']['stage'] = 'current'
|
||||
result = self.cli(json.dumps(changed))
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(json.loads(result.stdout)['status'], 'block')
|
||||
result = self.cli('{"secret":"secret-marker"}')
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertNotIn('secret-marker', result.stdout + result.stderr)
|
||||
|
||||
def test_recorded_argv_is_not_a_shell_command_or_character_policy(self):
|
||||
changed = copy.deepcopy(self.data)
|
||||
changed['collection']['argv'] += ['-k', 'case[param$with|literal;chars]']
|
||||
try:
|
||||
result = self.verify(changed)
|
||||
except binding.BindingError:
|
||||
self.fail('literal argv must not be rejected as a shell command')
|
||||
self.assertEqual(result['status'], 'pass')
|
||||
|
||||
def test_cli_unknown_option_never_echoes_input(self):
|
||||
result = self.cli(extra=('--unknown=secret-marker',))
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertNotIn('secret-marker', result.stdout + result.stderr)
|
||||
self.assertTrue(result.stdout.startswith('{'), 'CLI rejection must be bounded JSON')
|
||||
self.assertEqual(json.loads(result.stdout)['reason_codes'], ['rejected_input'])
|
||||
|
||||
def test_git_output_limit_terminates_before_wall_clock_timeout(self):
|
||||
import time
|
||||
fake = self.root / 'bin'
|
||||
fake.mkdir()
|
||||
program = fake / 'git'
|
||||
program.write_text('#!' + sys.executable + '\nimport os,time\nos.write(1,b"x"*1024)\ntime.sleep(10)\n')
|
||||
program.chmod(0o700)
|
||||
started = time.monotonic()
|
||||
with patch.object(binding, '_GIT_EXECUTABLE', str(program)), patch.object(binding, '_MAX_OUTPUT', 32):
|
||||
self.assertIsNone(binding._git(str(self.gitdir), ['fixture-read']))
|
||||
self.assertLess(time.monotonic() - started, 2.0)
|
||||
|
||||
def test_hostile_path_git_is_never_executed(self):
|
||||
marker = self.root / 'hostile-marker'
|
||||
fake = self.root / 'bin'
|
||||
fake.mkdir()
|
||||
program = fake / 'git'
|
||||
program.write_text('#!' + sys.executable + '\nimport pathlib\npathlib.Path(' + repr(str(marker)) + ').write_text("executed")\n')
|
||||
program.chmod(0o700)
|
||||
with patch.dict(os.environ, {'PATH': str(fake)}):
|
||||
self.assertEqual(self.verify()['status'], 'pass')
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_admitted_git_directory_remains_pinned_after_path_replacement(self):
|
||||
original = binding._git
|
||||
replacement_work = self.root / 'replacement-work'
|
||||
replacement_work.mkdir()
|
||||
subprocess.run(['git', '-C', str(replacement_work), 'init', '-q'], check=True)
|
||||
subprocess.run(['git', '-C', str(replacement_work), 'config', 'user.email', 'replacement@example.invalid'], check=True)
|
||||
subprocess.run(['git', '-C', str(replacement_work), 'config', 'user.name', 'Replacement'], check=True)
|
||||
(replacement_work / 'replacement.txt').write_text('replacement')
|
||||
subprocess.run(['git', '-C', str(replacement_work), 'add', '.'], check=True)
|
||||
subprocess.run(['git', '-C', str(replacement_work), 'commit', '-qm', 'replacement'], check=True)
|
||||
|
||||
swapped = False
|
||||
|
||||
def replace_after_admission(git_dir, args, **kwargs):
|
||||
nonlocal swapped
|
||||
result = original(git_dir, args, **kwargs)
|
||||
if not swapped and args[:2] == ['rev-parse', '--is-bare-repository']:
|
||||
swapped = True
|
||||
moved = self.root / 'admitted-original.git'
|
||||
os.rename(self.gitdir, moved)
|
||||
os.rename(replacement_work / '.git', self.gitdir)
|
||||
return result
|
||||
|
||||
with patch.object(binding, '_git', side_effect=replace_after_admission):
|
||||
self.assertEqual(self.verify()['status'], 'pass')
|
||||
self.assertTrue(swapped)
|
||||
|
||||
def test_each_scope_blob_is_read_once(self):
|
||||
original = binding._blob
|
||||
with patch.object(binding, '_blob', wraps=original) as read:
|
||||
self.assertEqual(self.verify()['status'], 'pass')
|
||||
self.assertEqual(read.call_count, 2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,343 @@
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from harness.lib import verification_root
|
||||
|
||||
|
||||
class VerificationRootTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.tmp.name) / "cache"
|
||||
self.issue = self.root / "repositories" / "alice" / "ledger" / "worktrees" / "issue-7"
|
||||
self.issue.mkdir(parents=True, mode=0o700)
|
||||
for path in (
|
||||
self.root / "repositories",
|
||||
self.root / "repositories/alice",
|
||||
self.root / "repositories/alice/ledger",
|
||||
self.root / "repositories/alice/ledger/worktrees",
|
||||
self.issue,
|
||||
):
|
||||
os.chmod(path, 0o700)
|
||||
# The trusted harness/NFS root is not required to be role-owned 0700.
|
||||
os.chmod(self.root, 0o770)
|
||||
self.handle = self.root / "runtime" / "handle.json"
|
||||
self.handle.parent.mkdir(mode=0o700)
|
||||
self.meta = {
|
||||
"root": str(self.root),
|
||||
"owner": "alice",
|
||||
"repository": "ledger",
|
||||
"issue": 7,
|
||||
"role": "gitea-impl-agent",
|
||||
}
|
||||
self.status = {
|
||||
"status": "held",
|
||||
"root": str(self.root),
|
||||
"owner": "alice",
|
||||
"repository": "ledger",
|
||||
"issue": 7,
|
||||
"role": "gitea-impl-agent",
|
||||
"deadline": 10**20,
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
@contextlib.contextmanager
|
||||
def broker(self, *, metadata=None, status=None):
|
||||
with (
|
||||
patch.object(
|
||||
verification_root.broker,
|
||||
"read_handle_metadata",
|
||||
return_value=self.meta if metadata is None else metadata,
|
||||
),
|
||||
patch.object(
|
||||
verification_root.broker,
|
||||
"request_handle",
|
||||
return_value=self.status if status is None else status,
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
def test_umask_does_not_make_verification_parent_world_readable(self):
|
||||
old = os.umask(0o022)
|
||||
try:
|
||||
with self.broker():
|
||||
result = verification_root.prepare(
|
||||
self.root, "alice", "ledger", 7, "run-1", self.handle
|
||||
)
|
||||
finally:
|
||||
os.umask(old)
|
||||
verification = self.issue / "verification"
|
||||
self.assertEqual(result, verification / "run-1")
|
||||
self.assertEqual(stat.S_IMODE(verification.stat().st_mode), 0o700)
|
||||
self.assertEqual(stat.S_IMODE((verification / "run-1").stat().st_mode), 0o700)
|
||||
|
||||
def test_existing_secure_parent_accepts_a_fresh_run(self):
|
||||
verification = self.issue / "verification"
|
||||
verification.mkdir(mode=0o700)
|
||||
(verification / "prior-run").mkdir(mode=0o700)
|
||||
with self.broker():
|
||||
result = verification_root.prepare(
|
||||
self.root, "alice", "ledger", 7, "new-run", self.handle
|
||||
)
|
||||
self.assertEqual(result, verification / "new-run")
|
||||
self.assertTrue((verification / "prior-run").is_dir())
|
||||
self.assertEqual(stat.S_IMODE(result.stat().st_mode), 0o700)
|
||||
|
||||
def test_bad_lease_is_rejected_before_filesystem_mutation(self):
|
||||
before = list(self.issue.iterdir())
|
||||
with self.broker(status={"error": "expired"}):
|
||||
with self.assertRaises(verification_root.VerificationError):
|
||||
verification_root.prepare(
|
||||
self.root, "alice", "ledger", 7, "run-1", self.handle
|
||||
)
|
||||
self.assertEqual(list(self.issue.iterdir()), before)
|
||||
|
||||
def test_missing_deadline_and_role_mismatch_fail_before_mutation(self):
|
||||
status = dict(self.status)
|
||||
status.pop("deadline")
|
||||
with self.broker(status=status):
|
||||
with self.assertRaisesRegex(verification_root.VerificationError, "expired"):
|
||||
verification_root.prepare(
|
||||
self.root, "alice", "ledger", 7, "run-1", self.handle
|
||||
)
|
||||
status = dict(self.status, role="gitea-review-agent")
|
||||
with self.broker(status=status):
|
||||
with self.assertRaisesRegex(verification_root.VerificationError, "role"):
|
||||
verification_root.prepare(
|
||||
self.root, "alice", "ledger", 7, "run-1", self.handle
|
||||
)
|
||||
self.assertFalse((self.issue / "verification").exists())
|
||||
|
||||
def test_existing_insecure_verification_is_not_repaired(self):
|
||||
verification = self.issue / "verification"
|
||||
verification.mkdir(mode=0o755)
|
||||
with self.broker():
|
||||
with self.assertRaises(verification_root.VerificationError):
|
||||
verification_root.prepare(
|
||||
self.root, "alice", "ledger", 7, "run-1", self.handle
|
||||
)
|
||||
self.assertEqual(stat.S_IMODE(verification.stat().st_mode), 0o755)
|
||||
self.assertFalse((verification / "run-1").exists())
|
||||
|
||||
def test_insecure_issue_directory_is_rejected_without_repair(self):
|
||||
os.chmod(self.issue, 0o755)
|
||||
with self.broker():
|
||||
with self.assertRaisesRegex(verification_root.VerificationError, "Issue directory"):
|
||||
verification_root.prepare(
|
||||
self.root, "alice", "ledger", 7, "run-1", self.handle
|
||||
)
|
||||
self.assertEqual(stat.S_IMODE(self.issue.stat().st_mode), 0o755)
|
||||
self.assertFalse((self.issue / "verification").exists())
|
||||
|
||||
def test_symlink_and_existing_run_are_rejected_without_mutation(self):
|
||||
verification = self.issue / "verification"
|
||||
verification.mkdir(mode=0o700)
|
||||
existing = verification / "run-1"
|
||||
existing.mkdir(mode=0o700)
|
||||
with self.broker():
|
||||
with self.assertRaisesRegex(verification_root.VerificationError, "existing run"):
|
||||
verification_root.prepare(
|
||||
self.root, "alice", "ledger", 7, "run-1", self.handle
|
||||
)
|
||||
self.assertTrue(existing.is_dir())
|
||||
existing.rmdir()
|
||||
verification.rmdir()
|
||||
verification.symlink_to(self.issue, target_is_directory=True)
|
||||
with self.broker():
|
||||
with self.assertRaises(verification_root.VerificationError):
|
||||
verification_root.prepare(
|
||||
self.root, "alice", "ledger", 7, "run-2", self.handle
|
||||
)
|
||||
self.assertTrue(verification.is_symlink())
|
||||
|
||||
def test_binding_and_identifier_validation(self):
|
||||
bad = dict(self.meta, owner="mallory")
|
||||
with self.broker(metadata=bad):
|
||||
with self.assertRaisesRegex(verification_root.VerificationError, "binding"):
|
||||
verification_root.prepare(
|
||||
self.root, "alice", "ledger", 7, "run-1", self.handle
|
||||
)
|
||||
wrong_root = dict(self.status, root=str(self.root / "other"))
|
||||
with self.broker(status=wrong_root):
|
||||
with self.assertRaisesRegex(verification_root.VerificationError, "binding"):
|
||||
verification_root.prepare(
|
||||
self.root, "alice", "ledger", 7, "run-1", self.handle
|
||||
)
|
||||
for value in ("../x", "a/b", "", ".", "..", ".hidden", "x" * 129):
|
||||
with self.subTest(value=value), self.assertRaises(verification_root.VerificationError):
|
||||
verification_root.prepare(
|
||||
self.root, "alice", "ledger", 7, value, self.handle
|
||||
)
|
||||
|
||||
def test_real_broker_cli_prepares_canonical_private_root(self):
|
||||
locks = self.root / "repositories/alice/ledger/locks"
|
||||
locks.mkdir(mode=0o700)
|
||||
(locks / "repository.lock").touch(mode=0o600)
|
||||
broker_script = Path(verification_root.broker.__file__)
|
||||
self.handle = self.issue / "runtime/broker-run/broker.handle"
|
||||
socket_path = self.handle.parent / "broker.sock"
|
||||
command = [
|
||||
sys.executable,
|
||||
str(broker_script),
|
||||
"serve",
|
||||
"--root",
|
||||
str(self.root),
|
||||
"--owner",
|
||||
"alice",
|
||||
"--repository",
|
||||
"ledger",
|
||||
"--issue",
|
||||
"7",
|
||||
"--role",
|
||||
"gitea-impl-agent",
|
||||
"--run-id",
|
||||
"broker-run",
|
||||
"--handle",
|
||||
str(self.handle),
|
||||
"--socket",
|
||||
str(socket_path),
|
||||
"--ttl-seconds",
|
||||
"30",
|
||||
"--acquire-timeout",
|
||||
"1",
|
||||
]
|
||||
proc = subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
||||
)
|
||||
assert proc.stdout is not None and proc.stderr is not None
|
||||
try:
|
||||
ready = json.loads(proc.stdout.readline())
|
||||
self.assertEqual(ready["status"], "held")
|
||||
helper = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(Path(verification_root.__file__)),
|
||||
"prepare",
|
||||
"--root",
|
||||
str(self.root),
|
||||
"--owner",
|
||||
"alice",
|
||||
"--repository",
|
||||
"ledger",
|
||||
"--issue",
|
||||
"7",
|
||||
"--run-id",
|
||||
"real-run",
|
||||
"--handle",
|
||||
str(self.handle),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
self.assertEqual(helper.returncode, 0, helper.stderr)
|
||||
record = json.loads(helper.stdout)
|
||||
run = Path(record["path"])
|
||||
self.assertEqual(run, self.issue / "verification/real-run")
|
||||
self.assertEqual(stat.S_IMODE(run.parent.stat().st_mode), 0o700)
|
||||
self.assertEqual(stat.S_IMODE(run.stat().st_mode), 0o700)
|
||||
self.assertEqual(
|
||||
verification_root.broker.request_handle(self.handle, "release")["status"],
|
||||
"released",
|
||||
)
|
||||
proc.wait(timeout=3)
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
proc.stdout.close()
|
||||
proc.stderr.close()
|
||||
|
||||
def test_created_inode_replacement_fails_without_removing_replacement(self):
|
||||
verification = self.issue / "verification"
|
||||
verification.mkdir(mode=0o700)
|
||||
real_open = verification_root._open_private_child
|
||||
|
||||
def replace_before_open(parent_fd, name, label):
|
||||
os.rename(name, f"{name}.original", src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
|
||||
os.mkdir(name, 0o700, dir_fd=parent_fd)
|
||||
return real_open(parent_fd, name, label)
|
||||
|
||||
with self.broker(), patch.object(
|
||||
verification_root, "_open_private_child", side_effect=replace_before_open
|
||||
):
|
||||
with self.assertRaisesRegex(verification_root.VerificationError, "changed run directory"):
|
||||
verification_root.prepare(
|
||||
self.root, "alice", "ledger", 7, "race-run", self.handle
|
||||
)
|
||||
self.assertTrue((verification / "race-run").is_dir())
|
||||
|
||||
def test_replaced_new_verification_parent_is_not_removed_on_run_failure(self):
|
||||
real_create = verification_root._create_private_child
|
||||
calls = 0
|
||||
|
||||
def replace_parent_before_run(parent_fd, name, label):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return real_create(parent_fd, name, label)
|
||||
os.rename(
|
||||
self.issue / "verification",
|
||||
self.issue / "verification.original",
|
||||
)
|
||||
(self.issue / "verification").mkdir(mode=0o700)
|
||||
raise verification_root.VerificationError("forced run failure")
|
||||
|
||||
with self.broker(), patch.object(
|
||||
verification_root, "_create_private_child", side_effect=replace_parent_before_run
|
||||
):
|
||||
with self.assertRaisesRegex(verification_root.VerificationError, "forced run failure"):
|
||||
verification_root.prepare(
|
||||
self.root, "alice", "ledger", 7, "race-parent", self.handle
|
||||
)
|
||||
self.assertTrue((self.issue / "verification").is_dir())
|
||||
self.assertTrue((self.issue / "verification.original").is_dir())
|
||||
|
||||
def test_cli_is_directly_executable_and_emits_verified_modes(self):
|
||||
script = Path(verification_root.__file__)
|
||||
help_result = subprocess.run(
|
||||
[str(script), "prepare", "--help"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
self.assertEqual(help_result.returncode, 0, help_result.stderr)
|
||||
for option in ("--root", "--owner", "--repository", "--issue", "--run-id", "--handle"):
|
||||
self.assertIn(option, help_result.stdout)
|
||||
|
||||
output = io.StringIO()
|
||||
with self.broker(), contextlib.redirect_stdout(output):
|
||||
code = verification_root.main(
|
||||
[
|
||||
"prepare",
|
||||
"--root",
|
||||
str(self.root),
|
||||
"--owner",
|
||||
"alice",
|
||||
"--repository",
|
||||
"ledger",
|
||||
"--issue",
|
||||
"7",
|
||||
"--run-id",
|
||||
"run-cli",
|
||||
"--handle",
|
||||
str(self.handle),
|
||||
]
|
||||
)
|
||||
self.assertEqual(code, 0)
|
||||
record = json.loads(output.getvalue())
|
||||
self.assertEqual(record["verification_mode"], "0700")
|
||||
self.assertEqual(record["run_mode"], "0700")
|
||||
self.assertEqual(Path(record["path"]), self.issue / "verification/run-cli")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user