#!/usr/bin/env python3 """Compare isolated job cwd/env/PATH inputs; never install a scheduled job.""" import argparse import json import platform import subprocess import sys import tempfile from datetime import datetime, timezone from pathlib import Path def main(): parser = argparse.ArgumentParser() parser.add_argument('--output', required=True) output = Path(parser.parse_args().output) if output.exists(): raise SystemExit('Existing evidence will not be overwritten.') if platform.system() != 'Linux': raise SystemExit('This experiment requires Linux and /bin/sh.') python = str(Path(sys.executable).absolute()) with tempfile.TemporaryDirectory(prefix='punchblog-job-env-') as directory: root = Path(directory) app, elsewhere, bindir, empty = [root / x for x in ('app', 'elsewhere', 'bin', 'empty')] for path in (app, elsewhere, bindir, empty): path.mkdir() (app / 'settings.json').write_text('{"message":"settings-ok"}\n') helper = bindir / 'punchblog-lab-helper' helper.write_text('#!/bin/sh\nprintf "helper-ok\\n"\n') helper.chmod(0o700) job = app / 'job.py' job.write_text('''import json, os, subprocess, sys from pathlib import Path mode = sys.argv[1] try: if mode in ('relative', 'anchored'): base = Path('.') if mode == 'relative' else Path(__file__).resolve().parent value = json.loads((base / 'settings.json').read_text())['message'] elif mode == 'env': value = os.environ['PUNCHBLOG_LAB_REGION'] else: target = 'punchblog-lab-helper' if mode == 'bare' else sys.argv[2] value = subprocess.run([target], capture_output=True, text=True, check=True, timeout=3).stdout.strip() print(json.dumps({'value': value})) except (OSError, KeyError) as error: print(json.dumps({'error': type(error).__name__, 'target': error.filename if isinstance(error, OSError) else str(error)})) sys.exit(1) ''') # Synthetic allowlisted environments only; no inherited credentials. minimal = {'PATH': str(empty), 'LANG': 'C.UTF-8'} cases = [ ('relative_app', 'relative', app, minimal, 0, 'settings-ok'), ('relative_elsewhere', 'relative', elsewhere, minimal, 1, 'FileNotFoundError'), ('anchored_elsewhere', 'anchored', elsewhere, minimal, 0, 'settings-ok'), ('env_missing', 'env', app, minimal, 1, 'KeyError'), ('env_explicit', 'env', app, dict(minimal, PUNCHBLOG_LAB_REGION='lab-region'), 0, 'lab-region'), ('helper_on_path', 'bare', app, dict(minimal, PATH=str(bindir)), 0, 'helper-ok'), ('helper_off_path', 'bare', app, minimal, 1, 'FileNotFoundError'), ('helper_absolute', 'absolute', elsewhere, minimal, 0, 'helper-ok'), ] records = [] for name, mode, cwd, env, expected_exit, expected_value in cases: completed = subprocess.run( [python, str(job), mode, str(helper)], cwd=cwd, env=env, capture_output=True, text=True, timeout=5, ) result = json.loads(completed.stdout) assert completed.returncode == expected_exit, (name, completed.stderr) assert result.get('error', result.get('value')) == expected_value, (name, result) assert completed.stderr == '', (name, completed.stderr) records.append({ 'case': name, 'mode': mode, 'cwd': cwd.name, 'path_contains_helper': env['PATH'] == str(bindir), 'region_present': 'PUNCHBLOG_LAB_REGION' in env, 'exit_code': completed.returncode, 'result': result, }) labels = { 'relative_app': '상대 설정 경로, app에서 실행', 'relative_elsewhere': '상대 설정 경로, elsewhere에서 실행', 'anchored_elsewhere': '스크립트 기준 설정 경로, elsewhere에서 실행', 'env_missing': '필수 환경변수 없음', 'env_explicit': '필수 환경변수 명시', 'helper_on_path': '보조 명령 폴더가 PATH에 있음', 'helper_off_path': 'PATH가 실험용 빈 폴더', 'helper_absolute': '빈 PATH, 보조 명령을 절대 경로로 호출', } record = { 'schema_version': 1, 'observed_at': datetime.now(timezone.utc).isoformat(), 'environment': {'os': platform.system(), 'python': platform.python_version()}, 'method': '같은 Python과 스크립트의 절대 경로를 사용해 자식 프로세스를 8번 실행했다. 작업 디렉터리와 합성 환경변수를 명시했으며 운영 환경변수는 상속하지 않았다.', 'limits': 'cron이나 systemd 타이머의 실제 실행, 운영 작업, 로그인 셸, 가상환경, 권한과 시간대 차이는 시험하지 않았다. 빈 PATH 폴더는 실패 조건을 구분하려고 만든 실험용 설정이다.', 'observations': records, 'tables': {'executions': { 'headers': ['조건', '종료 코드', '확인한 결과'], 'rows': [[labels[r['case']], r['exit_code'], r['result'].get('value', r['result'].get('error'))] for r in records], }}, } with output.open('x', encoding='utf-8') as stream: json.dump(record, stream, ensure_ascii=False, indent=2) stream.write('\n') print(json.dumps({'cases': len(records), 'result': 'passed', 'output': str(output)})) if __name__ == '__main__': main()