#!/usr/bin/env python3 """Проверка настоящего build.sh на маленьком проекте Autotools, без изменения исходников uTun.""" from pathlib import Path import json import os import shutil import subprocess import sys import tempfile import time script = Path(sys.argv[1]).resolve() base = Path(tempfile.mkdtemp(prefix='utun-build-tests-')) log = base / 'commands.log' environment = {**os.environ, 'ASAN_OPTIONS': 'detect_leaks=0'} environment.pop('MAKEFLAGS', None) environment.pop('MSYSTEM', None) for name in ['CC', 'CFLAGS', 'CPPFLAGS', 'LDFLAGS', 'LIBS', 'DEBUG_FLAGS']: environment.pop(name, None) checks = [] def write(name, content): path = base / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content) return path def run(args, ok=True, extra_environment=None): result = subprocess.run(args, cwd=base, env={**environment, **(extra_environment or {})}, text=True, capture_output=True) with log.open('a') as output: output.write(f'{args!r}\nexit={result.returncode}\n{result.stdout}{result.stderr}\n') if ok and result.returncode: raise AssertionError(f'{args!r}: exit={result.returncode}; see {log}\n{result.stderr[-1500:]}') return result def build(*args, **kwargs): return run(['bash', 'build.sh', '--no-gui', '-j', '2', *args], **kwargs) def check(name, condition): if not condition: raise AssertionError(f'{name}; see {log}') checks.append(name) print(f'[PASS] build.sh: {name}', flush=True) def times(): return {str(p.relative_to(base)): p.stat().st_mtime_ns for p in base.rglob('*.o')} def value(): return run(['./src/utun']).stdout.strip() try: shutil.copyfile(script, base / 'build.sh') write('configure.ac', '''AC_INIT([build-test], [1.0]) AC_CONFIG_SRCDIR([src/main.c]) AC_CONFIG_HEADERS([config.h]) AC_CONFIG_MACRO_DIR([m4]) AM_INIT_AUTOMAKE([foreign subdir-objects]) AC_PROG_CC AC_PROG_RANLIB AM_PROG_AR AC_ARG_WITH([ffmpeg], [], [], [with_ffmpeg=no]) AC_ARG_ENABLE([hardening], [], [], [enable_hardening=no]) if test "x$enable_hardening" = xyes; then CFLAGS="$CFLAGS -D_FORTIFY_SOURCE=2 -fstack-protector-strong -fPIE" LDFLAGS="$LDFLAGS -Wl,-z,relro -Wl,-z,now -pie" fi TEST_CONFIGURE_OPTION AC_CONFIG_FILES([Makefile lib/Makefile lib/libopus/Makefile src/Makefile tests/Makefile]) AC_OUTPUT ''') write('m4/test.m4', 'AC_DEFUN([TEST_CONFIGURE_OPTION], [AC_DEFINE([TEST_OPTION], [1], [Test option])])\n') write('Makefile.am', 'SUBDIRS = lib lib/libopus src tests\n') write('lib/Makefile.am', 'noinst_LIBRARIES = libfixture.a\nlibfixture_a_SOURCES = fixture.c\n') write('lib/fixture.c', 'int fixture(void) { return 0; }\n') write('lib/libopus/Makefile.am', '''noinst_LIBRARIES = libopus_internal.a libopus_internal_a_SOURCES = opus.c CFLAGS = -g -Og -fno-omit-frame-pointer -fno-pie -fPIC ''') write('lib/libopus/opus.c', 'int opus_fixture(void) { return 0; }\n') write('src/Makefile.am', '''bin_PROGRAMS = utun utun_SOURCES = main.c worker.c api.h nested.h AM_CFLAGS = -DLOCAL_OPTION=1 utun_LDADD = ../lib/libfixture.a ../lib/libopus/libopus_internal.a ''') write('src/main.c', '''#include #include "api.h" int worker(void); int fixture(void); int opus_fixture(void); int main(void) { printf("%d\\n", worker() + fixture() + opus_fixture()); return 0; } ''') write('src/worker.c', '#include "api.h"\nint worker(void) { return VALUE + LOCAL_OPTION; }\n') write('src/api.h', '#include "nested.h"\n') write('src/nested.h', '#define VALUE 10\n') write('tests/Makefile.am', 'noinst_PROGRAMS = probe\nprobe_SOURCES = probe.c\n') write('tests/probe.c', 'int main(void) { return 0; }\n') package_tool = write('bin/pkg-config', '''#!/bin/bash [[ "${BUILD_TEST_FFMPEG:-0}" = 1 ]] || exit 1 case "$1" in --exists) exit 0 ;; --modversion) echo 1.0 ;; --cflags|--libs-only-L) exit 0 ;; *) exit 1 ;; esac ''') package_tool.chmod(0o755) environment['PATH'] = str(base / 'bin') + os.pathsep + environment['PATH'] result = build() check('fresh build generates, configures and builds dependencies', value() == '11' and (base / 'lib/libopus/libopus_internal.a').exists()) check('normal build includes tests', (base / 'tests/probe').exists()) initial = times() result = build() check('repeat build does not regenerate, configure or compile', initial == times() and 'INFO: configuring:' not in result.stdout and 'INFO: regenerating' not in result.stdout) time.sleep(1.1) write('src/nested.h', '#define VALUE 20\n') result = build() current = times() check('transitive header rebuilds both dependent objects', current['src/main.o'] > initial['src/main.o'] and current['src/worker.o'] > initial['src/worker.o']) check('header change preserves unrelated objects and configuration', current['lib/fixture.o'] == initial['lib/fixture.o'] and 'INFO: configuring:' not in result.stdout and value() == '21') initial = times() write('src/unused.h', '#define UNUSED 1\n') build() check('unrelated header does not recompile', initial == times()) for mode in ['asan', 'hardened', 'normal']: args = [] if mode == 'normal' else ['--' + mode] result = build(*args) symbols = run(['nm', '-u', 'src/worker.o']).stdout check(f'{mode}: changing mode reconfigures and cleans', 'INFO: configuring:' in result.stdout and 'INFO: cleaning objects:' in result.stdout) check(f'{mode}: object instrumentation matches mode', ('__asan_' in symbols) == (mode == 'asan')) if mode == 'asan': check('ASAN skips tests and preserves unsanitized Opus', not (base / 'tests/probe').exists() and '__asan_' not in run(['nm', '-u', 'lib/libopus/opus.o']).stdout) initial = times() result = build(*args) check(f'{mode}: repeat build is incremental', initial == times() and 'INFO: configuring:' not in result.stdout and 'INFO: regenerating' not in result.stdout) result = build('CFLAGS=-g -O0 -DUSER_OPTION=1') check('quoted compiler flags are passed intact through configure', 'CFLAGS = -g -O0 -DUSER_OPTION=1' in (base / 'src/Makefile').read_text()) initial = times() build('CFLAGS=-g -O0 -DUSER_OPTION=1') check('repeat build with quoted flags is incremental', initial == times()) build() time.sleep(1.1) write('src/Makefile.am', (base / 'src/Makefile.am').read_text().replace('LOCAL_OPTION=1', 'LOCAL_OPTION=2')) result = build() check('Makefile.am flag change rebuilds stale objects', 'INFO: regenerating' in result.stdout and value() == '22') macro = base / 'm4/test.m4' saved_time = macro.stat() write('m4/test.m4', macro.read_text().replace('[1]', '[2]')) os.utime(macro, ns=(saved_time.st_atime_ns, saved_time.st_mtime_ns)) build() check('macro content change is detected with unchanged timestamp', '#define TEST_OPTION 2' in (base / 'config.h').read_text()) result = build(extra_environment={'BUILD_TEST_FFMPEG': '1'}) check('newly available optional dependency reconfigures', 'INFO: configuring:' in result.stdout and '--with-ffmpeg' in run(['./config.status', '--config']).stdout) result = build() check('disappearing optional dependency reconfigures', 'INFO: configuring:' in result.stdout and '--without-ffmpeg' in run(['./config.status', '--config']).stdout) (base / 'src/.deps/worker.Po').unlink() result = build() check('missing dependencies force rebuild without configure', 'missing_dependencies=1' in result.stdout and 'INFO: configuring:' not in result.stdout) write('src/.deps/worker.Po', '# dummy\n') result = build() check('dummy dependencies force rebuild', 'missing_dependencies=1' in result.stdout) result = build('--clean') check('explicit clean does not reconfigure', 'INFO: cleaning objects:' in result.stdout and 'INFO: configuring:' not in result.stdout) result = build('--full') check('explicit full regenerates, configures and rebuilds', 'INFO: regenerating' in result.stdout and 'INFO: configuring:' in result.stdout) (base / 'src/Makefile').unlink() result = build() check('missing child Makefile is recreated', (base / 'src/Makefile').exists() and 'INFO: configuring:' in result.stdout) run(['./configure', 'CFLAGS=-O0']) result = build() check('external configure is detected', 'INFO: configuring:' in result.stdout and 'CFLAGS = -g -O2' in (base / 'src/Makefile').read_text()) check('conflicting modes fail', build('--asan', '--hardened', ok=False).returncode != 0) check('invalid job count fails', build('-j0', ok=False).returncode != 0) state = (base / '.utun-build-state').read_bytes() original = (base / 'src/worker.c').read_text() write('src/worker.c', original + '\n#error deliberate_failure\n') check('compiler failure propagates without committing state', build(ok=False).returncode != 0 and (base / '.utun-build-state').read_bytes() == state) write('src/worker.c', original) check('build recovers from compiler failure', build().returncode == 0 and value() == '22') original = (base / 'configure.ac').read_text() write('configure.ac', original + '\nm4_fatal([deliberate_autoconf_failure])\n') check('autoreconf failure propagates through tee', build(ok=False).returncode != 0 and (base / '.utun-build-state').read_bytes() == state) write('configure.ac', original) build() write('tools/chatgui/build/CMakeCache.txt', 'placeholder\n') cmake = write('bin/cmake', '#!/bin/bash\nprintf "%s\\n" "$*" > gui-arguments\nexit 9\n') cmake.chmod(0o755) result = run(['bash', 'build.sh', '-j', '3'], ok=False) check('GUI failure propagates and success is not printed', result.returncode != 0 and 'Build completed successfully!' not in result.stdout) check('GUI receives selected job count', (base / 'gui-arguments').read_text().strip() == '--build tools/chatgui/build --parallel 3') print(f'[PASS] build.sh: {len(checks)} checks completed', flush=True) shutil.rmtree(base) except Exception: print(f'[FAIL] build.sh: diagnostic files retained in {base}', file=sys.stderr) raise