Skip to content

Commit f3e01f9

Browse files
committed
Merge FEATURE-ci-windows-staging: Repair DOS-prefix staged package checks
2 parents 71008fd + a7e8996 commit f3e01f9

4 files changed

Lines changed: 326 additions & 10 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ build-san/
8787
!scripts/audit-v3-installed-package.py
8888
!scripts/check-v3-installed-consumer.py
8989
!scripts/test-v3-installed-package.py
90+
!scripts/test-v3-stage-install.py
9091
__pycache__/
9192
!scripts/run-v3-http3-gates.py
9293
!test/integ/http3_client_aioquic.py

‎Makefile.am‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,20 @@ check-headers:
8787
# when invoking standalone outside of `make check`.
8888
CHECK_INSTALL_STAGE = $(abs_top_builddir)/.install-stage
8989

90+
# DESTDIR cannot precede a DOS drive specification: MSYS would pass an
91+
# embedded "D:" to native install tools. Map D:/path to /D/path only in
92+
# these private stages. Keep configured paths (including pkg-config) intact.
93+
check_stage_dir = $(shell printf '%s\n' '$(1)' | sed 's,^\([A-Za-z]\):/,/\1/,')
94+
CHECK_STAGE_INCLUDEDIR = $(call check_stage_dir,$(includedir))
95+
CHECK_STAGE_INSTALL_DIRS = bindir libdir includedir pkgconfigdir cmakemoduledir mandir $(if $(man3dir),man3dir)
96+
CHECK_STAGE_INSTALL_FLAGS = $(foreach dir,$(CHECK_STAGE_INSTALL_DIRS),$(dir)="$(call check_stage_dir,$($(dir)))")
97+
9098
check-install-layout:
9199
@echo "=== check-install-layout: staged install must hide detail/ and *_impl.hpp ==="
92100
@if test "$(CHECK_INSTALL_SHARED)" != "yes"; then \
93101
rm -rf $(CHECK_INSTALL_STAGE); \
94102
mkdir -p $(CHECK_INSTALL_STAGE); \
95-
$(MAKE) $(AM_MAKEFLAGS) install DESTDIR=$(CHECK_INSTALL_STAGE) >$(CHECK_INSTALL_STAGE)/install.log 2>&1 || { \
103+
$(MAKE) $(AM_MAKEFLAGS) install $(CHECK_STAGE_INSTALL_FLAGS) DESTDIR=$(CHECK_INSTALL_STAGE) >$(CHECK_INSTALL_STAGE)/install.log 2>&1 || { \
96104
echo "FAIL: staged install failed"; \
97105
cat $(CHECK_INSTALL_STAGE)/install.log; \
98106
rm -rf $(CHECK_INSTALL_STAGE); \
@@ -125,12 +133,12 @@ check-install-layout:
125133
@# `ln -s` falls back to a file copy unless MSYS=winsymlinks is set).
126134
@# Either form satisfies the "includes #include <httpserverpp> resolve to
127135
@# the umbrella" property the install hook is establishing.
128-
@if ! test -e $(CHECK_INSTALL_STAGE)$(includedir)/httpserverpp; then \
136+
@if ! test -e $(CHECK_INSTALL_STAGE)$(CHECK_STAGE_INCLUDEDIR)/httpserverpp; then \
129137
echo "FAIL: httpserverpp alias not installed under $(includedir)"; \
130138
if test "$(CHECK_INSTALL_SHARED)" != "yes"; then rm -rf $(CHECK_INSTALL_STAGE); fi; \
131139
exit 1; \
132140
fi
133-
@$(CXX) -std=c++20 -I$(CHECK_INSTALL_STAGE)$(includedir) -c $(top_srcdir)/test/headers/consumer_v3_readiness.cpp -o /dev/null
141+
@$(CXX) -std=c++20 -I$(CHECK_INSTALL_STAGE)$(CHECK_STAGE_INCLUDEDIR) -c $(top_srcdir)/test/headers/consumer_v3_readiness.cpp -o /dev/null
134142
@if test "$(CHECK_INSTALL_SHARED)" != "yes"; then rm -rf $(CHECK_INSTALL_STAGE); fi
135143
@echo " PASS: staged install layout and standalone readiness consumer are clean"
136144

@@ -145,7 +153,7 @@ check-install-layout:
145153
# pristine public include tree -- exactly what packagers and
146154
# downstream consumers see.
147155
# 2. Preprocess test/headers/consumer_umbrella_no_backend.cpp using
148-
# ONLY -I$(CHECK_HYGIENE_STAGE)$(includedir) plus $(CPPFLAGS) (so
156+
# ONLY -I$(CHECK_HYGIENE_STAGE)$(CHECK_STAGE_INCLUDEDIR) plus $(CPPFLAGS) (so
149157
# e.g. /opt/homebrew/include is on the search path -- the grep
150158
# below NEEDS to resolve <microhttpd.h> if the umbrella pulls it
151159
# in, otherwise we couldn't detect the leak).
@@ -185,7 +193,7 @@ check-install-layout:
185193
# sync with the #ifdef ladder in test/unit/header_hygiene_test.cpp.
186194
HEADER_HYGIENE_FORBIDDEN = microhttpd\.h|gnutls/gnutls\.h|sys/socket\.h|sys/uio\.h
187195
CHECK_HYGIENE_STAGE = $(abs_top_builddir)/.hygiene-stage
188-
CHECK_HYGIENE_CXX = $(CXX) -std=c++20 -E -I$(CHECK_HYGIENE_STAGE)$(includedir) $(CPPFLAGS)
196+
CHECK_HYGIENE_CXX = $(CXX) -std=c++20 -E -I$(CHECK_HYGIENE_STAGE)$(CHECK_STAGE_INCLUDEDIR) $(CPPFLAGS)
189197
# Override on the command line to run against an in-flight umbrella:
190198
# make check-hygiene HEADER_HYGIENE_STRICT=no
191199
HEADER_HYGIENE_STRICT ?= yes
@@ -207,7 +215,7 @@ $(HYGIENE_STAMP): $(wildcard $(top_srcdir)/src/httpserver/*.hpp) \
207215
$(top_srcdir)/Makefile.am \
208216
$(top_srcdir)/test/headers/consumer_umbrella_no_backend.cpp
209217
@rm -rf $(CHECK_HYGIENE_STAGE)
210-
@$(MAKE) $(AM_MAKEFLAGS) install DESTDIR=$(CHECK_HYGIENE_STAGE) >check-hygiene-install.log 2>&1 || { \
218+
@$(MAKE) $(AM_MAKEFLAGS) install $(CHECK_STAGE_INSTALL_FLAGS) DESTDIR=$(CHECK_HYGIENE_STAGE) >check-hygiene-install.log 2>&1 || { \
211219
echo "FAIL: staged install failed"; cat check-hygiene-install.log; \
212220
rm -f check-hygiene-install.log; rm -rf $(CHECK_HYGIENE_STAGE); exit 1; }
213221
@rm -f check-hygiene-install.log
@@ -223,7 +231,7 @@ check-hygiene:
223231
echo " Always pair CHECK_HYGIENE_SHARED=yes with CHECK_HYGIENE_STAGE=<valid-dir>."; \
224232
exit 1; \
225233
fi; \
226-
if ! test -f "$(CHECK_HYGIENE_STAGE)$(includedir)/httpserver.hpp"; then \
234+
if ! test -f "$(CHECK_HYGIENE_STAGE)$(CHECK_STAGE_INCLUDEDIR)/httpserver.hpp"; then \
227235
echo "FAIL: CHECK_HYGIENE_STAGE exists but does not contain $(includedir)/httpserver.hpp"; \
228236
echo " Was the staged install complete before invoking check-hygiene?"; \
229237
exit 1; \
@@ -278,15 +286,15 @@ check-hygiene:
278286
# still runs the full test suite plus the header / example / lint gates, but
279287
# skips the source-only doc gates whose inputs it cannot satisfy. Release runners
280288
# may provide a verified specs/ tooling bundle; it is not a source checkout.
281-
check-local: check-v3-compiled-operations check-headers check-examples lint-warning-suppressions lint-littletest-skip-exit-code lint-fence-balance check-v3-native-linkage
289+
check-local: check-v3-compiled-operations check-headers check-examples lint-warning-suppressions lint-littletest-skip-exit-code lint-fence-balance check-v3-native-linkage check-v3-stage-install
282290
@if test -d "$(top_srcdir)/specs" && test -e "$(top_srcdir)/.git"; then \
283291
$(MAKE) $(AM_MAKEFLAGS) check-readme check-release-notes check-doxygen ; \
284292
else \
285293
echo "check-local: skipping README/release-notes/doxygen gates (source checkout documentation; distributed tarball)"; \
286294
fi
287295
@echo "=== Shared staged install for check-install-layout and check-hygiene ==="
288296
@rm -rf "$(abs_top_builddir)/.shared-check-stage"
289-
@$(MAKE) $(AM_MAKEFLAGS) install DESTDIR="$(abs_top_builddir)/.shared-check-stage" >check-shared-install.log 2>&1 || { \
297+
@$(MAKE) $(AM_MAKEFLAGS) install $(CHECK_STAGE_INSTALL_FLAGS) DESTDIR="$(abs_top_builddir)/.shared-check-stage" >check-shared-install.log 2>&1 || { \
290298
echo "FAIL: shared staged install failed"; cat check-shared-install.log; \
291299
rm -f check-shared-install.log; rm -rf "$(abs_top_builddir)/.shared-check-stage"; exit 1; }
292300
@rm -f check-shared-install.log
@@ -490,7 +498,7 @@ EXTRA_DIST += scripts/run-v3-http2-gates.py docs/task-145-http2-evidence.md
490498

491499
# TASK-148: policy fixtures are local; the fresh installed matrix is opt-in.
492500
EXTRA_DIST += scripts/audit-v3-installed-package.py scripts/check-v3-installed-consumer.py scripts/test-v3-installed-package.py scripts/v3-installed-dependency-policy.json docs/task-148-installed-consumer-evidence.md
493-
check-v3-installed-package-fixtures:
501+
check-v3-installed-package-fixtures: check-v3-stage-install
494502
@python3 $(top_srcdir)/scripts/test-v3-installed-package.py
495503
@python3 $(top_srcdir)/scripts/test-v3-installed-consumer.py
496504
@python3 $(top_srcdir)/scripts/test-v3-package-workflow.py
@@ -502,6 +510,12 @@ check-v3-installed-consumer:
502510

503511
.PHONY: check-v3-installed-package-fixtures check-v3-installed-consumer
504512

513+
EXTRA_DIST += scripts/test-v3-stage-install.py
514+
check-v3-stage-install:
515+
@CXX="$(CXX)" python3 $(top_srcdir)/scripts/test-v3-stage-install.py
516+
517+
.PHONY: check-v3-stage-install
518+
505519
EXTRA_DIST += docs/task-151-quic-codecs.md
506520
EXTRA_DIST += docs/task-176-quic-stateful-evidence.md
507521

‎scripts/test-v3-stage-install.py‎

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
#!/usr/bin/env python3
2+
"""Exercise configured staging recipes and real Automake/libtool installation."""
3+
import os
4+
from pathlib import Path
5+
import shlex
6+
import shutil
7+
import subprocess
8+
import tempfile
9+
import unittest
10+
11+
12+
SOURCE = Path(__file__).resolve().parent.parent
13+
MAKE = shutil.which('gmake') or shutil.which('make')
14+
15+
16+
class StagedInstallRecipes(unittest.TestCase):
17+
@classmethod
18+
def setUpClass(cls):
19+
if not MAKE:
20+
raise RuntimeError('GNU make is required')
21+
cls.temporary = tempfile.TemporaryDirectory(prefix='v3-stage-install-')
22+
cls.addClassCleanup(cls.temporary.cleanup)
23+
cls.build = Path(cls.temporary.name).resolve()
24+
cls.environment = {key: value for key, value in os.environ.items()
25+
if key not in ('MAKEFLAGS', 'MFLAGS', 'MAKELEVEL')}
26+
for tool in ('install', 'ranlib'):
27+
boundary = cls.build / f'native-{tool}-boundary.sh'
28+
boundary.write_text(
29+
'#!/bin/sh\n'
30+
'for destination do :; done\n'
31+
'# MSYS cannot pass an embedded drive specification to native tools.\n'
32+
'case "$destination" in\n'
33+
f' /*[a-zA-Z]:/*) echo "native {tool} rejects embedded drive: $destination" >&2; exit 1 ;;\n'
34+
'esac\n'
35+
f'printf "%s\\n" "$destination" >> {tool}-destinations.log\n'
36+
f'exec {shlex.quote(Path(shutil.which(tool)).as_posix())} "$@"\n')
37+
boundary.chmod(0o755)
38+
result = subprocess.run(
39+
['sh', (SOURCE / 'configure').as_posix(), '--disable-v3-tls',
40+
'--disable-examples', '--prefix=D:/ci/prefix',
41+
'RANLIB=' + (cls.build / 'native-ranlib-boundary.sh').as_posix()], cwd=cls.build,
42+
env=cls.environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
43+
if result.returncode:
44+
raise RuntimeError(result.stdout)
45+
cls.installer = cls.build / 'native-install-boundary.sh'
46+
# A tiny real library reaches Automake/libtool's archive and DLL install
47+
# paths without compiling the unrelated protocol engines in each fixture.
48+
(cls.build / 'src/stagefixture.cpp').write_text(
49+
'#ifdef _WIN32\n__declspec(dllexport)\n#endif\n'
50+
'int stagefixture() { return 1; }\n')
51+
compiler = shlex.split(os.environ.get('CXX', 'c++'))
52+
for arguments in [
53+
['--mode=compile'] + compiler + ['-c', 'stagefixture.cpp', '-o', 'stagefixture.lo'],
54+
['--mode=link'] + compiler + ['-o', 'libstagefixture.la', 'stagefixture.lo',
55+
'-rpath', '/stagefixture', '-no-undefined']]:
56+
result = subprocess.run(['sh', '../libtool', '--tag=CXX'] + arguments,
57+
cwd=cls.build / 'src', env=cls.environment, stdout=subprocess.PIPE,
58+
stderr=subprocess.STDOUT, text=True)
59+
if result.returncode:
60+
raise RuntimeError(result.stdout)
61+
62+
def setUp(self):
63+
for name in ('.hygiene-stage', '.install-stage', '.shared-check-stage', 'missing-stage'):
64+
shutil.rmtree(self.build / name, ignore_errors=True)
65+
for path in self.build.rglob('install-destinations.log'):
66+
path.unlink()
67+
68+
def make(self, target, *variables, shared=False):
69+
# Exercise the production Makefile, recursive install rules and alias hook.
70+
# Only the fixture archive replaces the unrelated protocol engines.
71+
command = [MAKE, '--no-print-directory', target,
72+
'lib_LTLIBRARIES=libstagefixture.la', 'noinst_LTLIBRARIES=', 'noinst_PROGRAMS=',
73+
'INSTALL=' + self.installer.as_posix()]
74+
if shared:
75+
# Only check-local's staged-install body is under test here.
76+
text = (SOURCE / 'Makefile.am').read_text()
77+
prerequisites = text.split('\ncheck-local:', 1)[1].split('\n', 1)[0].split()
78+
for name in prerequisites:
79+
command.extend(['-o', name])
80+
command.append('AM_MAKEFLAGS=-o check-readme -o check-release-notes -o check-doxygen')
81+
return subprocess.run(command + list(variables), cwd=self.build,
82+
env=self.environment, stdout=subprocess.PIPE,
83+
stderr=subprocess.STDOUT, text=True)
84+
85+
def assert_passed(self, result):
86+
self.assertEqual(0, result.returncode, result.stdout)
87+
88+
def installed(self, path):
89+
return self.build / '.hygiene-stage' / path
90+
91+
def install_count(self):
92+
return sum(line.endswith('/lib/pkgconfig')
93+
for path in self.build.rglob('install-destinations.log')
94+
for line in path.read_text().splitlines())
95+
96+
def test_dos_prefix_standalone_checks_use_real_installed_headers(self):
97+
self.assert_passed(self.make('check-hygiene'))
98+
include = self.installed('D/ci/prefix/include')
99+
self.assertTrue((include / 'httpserverpp').is_file())
100+
self.assertTrue((include / 'httpserver/server/readiness.hpp').is_file())
101+
self.assertTrue(self.installed('D/ci/prefix/lib/libstagefixture.a').is_file())
102+
self.assertIn('prefix=D:/ci/prefix',
103+
self.installed('D/ci/prefix/lib/pkgconfig/libhttpserver.pc').read_text())
104+
self.assertTrue(self.installed('D/ci/prefix/share/man/man3/libhttpserver.3').is_file())
105+
self.assertTrue(self.installed('D/ci/prefix/share/cmake/Modules/FindLibHttpServer.cmake').is_file())
106+
self.assert_passed(self.make('check-install-layout'))
107+
self.assertFalse((self.build / '.install-stage').exists())
108+
109+
def test_dos_prefix_shared_check_local_installs_once_and_cleans_up(self):
110+
result = self.make('check-local', shared=True)
111+
self.assert_passed(result)
112+
self.assertIn('PASS: staged install layout', result.stdout)
113+
self.assertIn('PASS: no forbidden headers', result.stdout)
114+
self.assertEqual(1, self.install_count())
115+
self.assertFalse((self.build / '.shared-check-stage').exists())
116+
117+
def test_standalone_hygiene_reuses_its_stamp(self):
118+
self.assert_passed(self.make('check-hygiene'))
119+
self.assert_passed(self.make('check-hygiene'))
120+
self.assertEqual(1, self.install_count())
121+
122+
def test_unix_and_custom_dos_install_directories(self):
123+
for prefix, include, library, data, man in [
124+
('/opt/package', '/custom/headers', '/custom/lib64', '/custom/data', '/custom/manual'),
125+
('D:/ci/prefix', 'E:/custom/headers', 'F:/custom/lib64', 'G:/custom/data', 'H:/custom/manual')]:
126+
with self.subTest(prefix=prefix):
127+
variables = [f'prefix={prefix}', f'includedir={include}', f'libdir={library}',
128+
f'datadir={data}', f'man3dir={man}']
129+
self.assert_passed(self.make('check-hygiene', *variables))
130+
relative = include.replace(':', '').lstrip('/')
131+
self.assertTrue(self.installed(relative + '/httpserver.hpp').is_file())
132+
self.assertTrue(self.installed(library.replace(':', '').lstrip('/') +
133+
'/pkgconfig/libhttpserver.pc').is_file())
134+
self.assertTrue(self.installed(data.replace(':', '').lstrip('/') +
135+
'/cmake/Modules/FindLibHttpServer.cmake').is_file())
136+
self.assertTrue(self.installed(man.replace(':', '').lstrip('/') +
137+
'/libhttpserver.3').is_file())
138+
self.assert_passed(self.make('check-install-layout', *variables))
139+
shutil.rmtree(self.build / '.hygiene-stage')
140+
141+
def test_shared_hygiene_rejects_missing_and_incomplete_stage(self):
142+
stage = self.build / 'missing-stage'
143+
for exists in (False, True):
144+
if exists:
145+
stage.mkdir()
146+
result = self.make('check-hygiene', 'CHECK_HYGIENE_SHARED=yes',
147+
'CHECK_HYGIENE_STAGE=' + stage.as_posix())
148+
self.assertNotEqual(0, result.returncode, result.stdout)
149+
self.assertIn('does not contain' if exists else 'does not exist', result.stdout)
150+
151+
def test_install_failure_cleans_standalone_stages(self):
152+
for target, directory in [('check-hygiene', '.hygiene-stage'),
153+
('check-install-layout', '.install-stage')]:
154+
with self.subTest(target=target):
155+
result = self.make(target, 'INSTALL=false')
156+
self.assertNotEqual(0, result.returncode, result.stdout)
157+
self.assertIn('FAIL: staged install failed', result.stdout)
158+
self.assertFalse((self.build / directory).exists())
159+
160+
def test_installed_backend_header_leak_is_rejected(self):
161+
self.assert_passed(self.make('check-hygiene'))
162+
include = self.installed('D/ci/prefix/include')
163+
(include / 'microhttpd.h').write_text('/* forbidden provider fixture */\n')
164+
with (include / 'httpserver.hpp').open('a') as output:
165+
output.write('\n#include <microhttpd.h>\n')
166+
result = self.make('check-hygiene', 'CHECK_HYGIENE_SHARED=yes')
167+
self.assertNotEqual(0, result.returncode, result.stdout)
168+
self.assertIn('FAIL: forbidden headers leaked', result.stdout)
169+
170+
def test_layout_rejects_private_headers_missing_alias_and_broken_readiness(self):
171+
self.assert_passed(self.make('check-hygiene'))
172+
stage = self.build / '.hygiene-stage'
173+
include = self.installed('D/ci/prefix/include')
174+
variables = ['CHECK_INSTALL_SHARED=yes', 'CHECK_INSTALL_STAGE=' + stage.as_posix()]
175+
for name, message in [('detail', 'detail/ directory leaked'),
176+
('bad_impl.hpp', '*_impl.hpp file leaked')]:
177+
path = include / name
178+
if name == 'detail':
179+
path.mkdir()
180+
else:
181+
path.touch()
182+
result = self.make('check-install-layout', *variables)
183+
self.assertNotEqual(0, result.returncode, result.stdout)
184+
self.assertIn(message, result.stdout)
185+
if path.is_dir():
186+
path.rmdir()
187+
else:
188+
path.unlink()
189+
alias = include / 'httpserverpp'
190+
content = alias.read_bytes()
191+
alias.unlink()
192+
result = self.make('check-install-layout', *variables)
193+
self.assertNotEqual(0, result.returncode, result.stdout)
194+
self.assertIn('alias not installed', result.stdout)
195+
alias.write_bytes(content)
196+
with (include / 'httpserver/server/readiness.hpp').open('a') as output:
197+
output.write('\n#error readiness fixture must fail\n')
198+
result = self.make('check-install-layout', *variables)
199+
self.assertNotEqual(0, result.returncode, result.stdout)
200+
self.assertIn('readiness fixture must fail', result.stdout)
201+
202+
203+
if __name__ == '__main__':
204+
unittest.main()

0 commit comments

Comments
 (0)