test: isolate BusyBox standalone applets before shell scenarios

This commit is contained in:
2026-10-12 23:25:39 +08:00
parent be1e50fd54
commit 2f2f6db4d6
4 changed files with 63 additions and 7 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ export const escapeHTML = value => String(value).replace(/[&<>"']/g, c => ({'&':
export function commandsFor(config) { export function commandsFor(config) {
const fetch = `curl -fsSL '${config.baseUrl}/script.sh' | sh -s --`; const fetch = `curl -fsSL '${config.baseUrl}/script.sh' | sh -s --`;
return [ return [
{id:'install',title:'安装并定时同步',description:'更新当前用户的公钥并设置定时同步;保留已有自定义计划,新安装默认每 15 分钟一次。',command:`${fetch} -c`}, {id:'install',title:'安装并定时同步',description:`更新当前用户的公钥并设置定时同步;保留已有自定义计划,新安装计划为 ${config.defaultCron || '*/15 * * * *'}。`,command:`${fetch} -c`},
{id:'once',title:'仅更新一次公钥',description:'更新为当前发布的公钥,不设置定时任务。',command:`${fetch} -o`}, {id:'once',title:'仅更新一次公钥',description:'更新为当前发布的公钥,不设置定时任务。',command:`${fetch} -o`},
{id:'help',title:'查看帮助',description:'先了解全部参数与行为,不修改配置。',command:`${fetch} -h`}, {id:'help',title:'查看帮助',description:'先了解全部参数与行为,不修改配置。',command:`${fetch} -h`},
{id:'preview',title:'预览公钥更新',description:'校验当前发布并预览将要修改的文件,不应用更新。',command:`${fetch} -o --dry-run`}, {id:'preview',title:'预览公钥更新',description:'校验当前发布并预览将要修改的文件,不应用更新。',command:`${fetch} -o --dry-run`},
+1 -1
View File
@@ -30,7 +30,7 @@ usage() {
Usage: sh conf-sshd.sh [options] Usage: sh conf-sshd.sh [options]
(no options), -o, --only-update-keys Validate and sync managed public keys only (no options), -o, --only-update-keys Validate and sync managed public keys only
-c, --cron ["five fields"|false] Sync and schedule; bare -c keeps an existing -c, --cron ["five fields"|false] Sync and schedule; bare -c keeps an existing
schedule, otherwise every 15 minutes. schedule, otherwise the configured default.
-c false only removes this script's schedule. -c false only removes this script's schedule.
-u, --update-self Explicitly update the installed script only -u, --update-self Explicitly update the installed script only
--uninstall Remove own schedule/script; preserve keys/config --uninstall Remove own schedule/script; preserve keys/config
+1 -1
View File
@@ -37,7 +37,7 @@ test('HTML escapes comments; commands use explicit script endpoint and separate
const keys=await parseKeys(fixture.trim()+' <script>alert(1)</script>'); const keys=await parseKeys(fixture.trim()+' <script>alert(1)</script>');
const html=renderKeys(keys);assert.ok(html.includes('&lt;script&gt;'));assert.ok(!html.includes('<script>')); const html=renderKeys(keys);assert.ok(html.includes('&lt;script&gt;'));assert.ok(!html.includes('<script>'));
const commands=commandsFor(config);assert.ok(commands.find(x=>x.id==='install').command.endsWith(' -c')); const commands=commandsFor(config);assert.ok(commands.find(x=>x.id==='install').command.endsWith(' -c'));
assert.ok(commands.find(x=>x.id==='root-on').advanced);assert.ok(commands.find(x=>x.id==='uninstall').advanced); assert.ok(commands.find(x=>x.id==='root-on').advanced);assert.ok(commandsFor({...config,defaultCron:'0 */6 * * *'})[0].description.includes('0 */6 * * *'));assert.ok(commands.find(x=>x.id==='uninstall').advanced);
assert.ok(commands.every(x=>!x.command.includes('github.io')&&!x.command.includes('.keys'))); assert.ok(commands.every(x=>!x.command.includes('github.io')&&!x.command.includes('.keys')));
}); });
test('build creates consistent assets/release; production empty is blocked',async()=>{ test('build creates consistent assets/release; production empty is blocked',async()=>{
+60 -4
View File
@@ -7,6 +7,7 @@ import base64
import struct import struct
import signal import signal
import os import os
import re
from pathlib import Path from pathlib import Path
import shutil import shutil
import shlex import shlex
@@ -56,6 +57,9 @@ class ShellTests(unittest.TestCase):
TEST_ROOT=str(self.root), TEST_RESPONSE=str(self.payload), TEST_ROOT=str(self.root), TEST_RESPONSE=str(self.payload),
TEST_UPDATE=str(self.update), TEST_CRONTAB=str(self.cron), TEST_UPDATE=str(self.update), TEST_CRONTAB=str(self.cron),
TEST_CALLS=str(self.calls)) TEST_CALLS=str(self.calls))
real_id = shutil.which('id')
self.assertIsNotNone(real_id)
self.mock('id', f'#!/bin/sh\nexec {shlex.quote(real_id)} "$@"\n')
self.mock('curl', '''#!/bin/sh self.mock('curl', '''#!/bin/sh
printf 'curl %s\\n' "$*" >> "$TEST_CALLS" printf 'curl %s\\n' "$*" >> "$TEST_CALLS"
[ "${TEST_CURL_ERROR:-0}" = 0 ] || exit "$TEST_CURL_ERROR" [ "${TEST_CURL_ERROR:-0}" = 0 ] || exit "$TEST_CURL_ERROR"
@@ -88,12 +92,47 @@ fi
def mock(self, name, text): def mock(self, name, text):
path = self.bin / name path = self.bin / name
# Probes use invalid real-tool arguments, so a failed interception can
# never mutate the actual account or crontab. Every mock answers before
# its ordinary test-specific implementation runs.
first, rest = text.split('\n', 1)
text = (first + '\n'
'if [ "${1-}" = --conf-sshd-isolation-probe ]; then\n'
f' printf \'%s\\n\' {shlex.quote("conf-sshd-mock:" + name)}\n'
' exit 0\n'
'fi\n' + rest)
path.write_text(text) path.write_text(text)
path.chmod(0o700) path.chmod(0o700)
return path return path
def isolation_prelude(self):
# BusyBox FEATURE_SH_STANDALONE may run applets before consulting PATH.
# Shell functions take precedence over applets, and an absolute mock
# path cannot resolve to an applet. Build fresh for every invocation so
# test-specific id/stat/cp/cat/dd/mv mocks are intercepted as well.
lines = []
for path in sorted(self.bin.iterdir()):
if re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', path.name):
lines.append(f'{path.name}() {{ {shlex.quote(str(path))} "$@"; }}')
# Hyphenated command names such as apt-get are not POSIX function
# identifiers and have no BusyBox applets; their PATH mocks remain.
for name in ('id', 'crontab'):
lines.extend([
f'if [ "$({name} --conf-sshd-isolation-probe)" != conf-sshd-mock:{name} ]; then',
f' printf \'%s\\n\' \'TEST ISOLATION FAILED: {name} mock was bypassed\' >&2',
' exit 97',
'fi',
])
return '\n'.join(lines) + '\n'
def script_command(self, *args, script=None):
# Preserve the production script's arguments and $0 while loading it
# in a shell whose command resolution has passed the isolation probes.
return [*SHELL, '-c', self.isolation_prelude() + '. "$0"',
str(script or self.script), *args]
def run_script(self, *args, ok=True, env=None, script=None): def run_script(self, *args, ok=True, env=None, script=None):
completed = subprocess.run([*SHELL, str(script or self.script), *args], completed = subprocess.run(self.script_command(*args, script=script),
env=self.env | (env or {}), capture_output=True, text=True, env=self.env | (env or {}), capture_output=True, text=True,
timeout=15) timeout=15)
if ok: if ok:
@@ -126,6 +165,23 @@ fi
self.assertEqual(SOURCE.count('@@DEFAULT_CRON@@'), 1) self.assertEqual(SOURCE.count('@@DEFAULT_CRON@@'), 1)
subprocess.run([*SHELL, '-n', str(self.script)], check=True) subprocess.run([*SHELL, '-n', str(self.script)], check=True)
def test_isolation_probe_aborts_before_installer_when_mock_is_bypassed(self):
for name in ('id', 'crontab'):
with self.subTest(name=name):
mock_path = self.bin / name
original = mock_path.read_text()
try:
# Simulate a broken interception without calling real tools.
mock_path.write_text('#!/bin/sh\nprintf "wrong-tool\\n"\n')
result = self.run_script('-c', ok=False)
self.assertEqual(result.returncode, 97)
self.assertIn(f'TEST ISOLATION FAILED: {name}', result.stderr)
self.assertFalse((self.home / '.conf-sshd').exists())
self.assertFalse(self.auth.exists())
self.assertEqual(self.calls.read_text(), '')
finally:
mock_path.write_text(original)
def test_default_sync_has_no_system_or_cron_side_effects(self): def test_default_sync_has_no_system_or_cron_side_effects(self):
old_cron = self.cron.read_bytes() old_cron = self.cron.read_bytes()
self.run_script() self.run_script()
@@ -383,7 +439,7 @@ fi
def test_concurrent_run_refuses_and_signal_cleanup(self): def test_concurrent_run_refuses_and_signal_cleanup(self):
env = self.env | {'TEST_CURL_WAIT': '1'} env = self.env | {'TEST_CURL_WAIT': '1'}
first = subprocess.Popen([*SHELL, str(self.script), '-o'], env=env, first = subprocess.Popen(self.script_command('-o'), env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try: try:
deadline = time.monotonic() + 5 deadline = time.monotonic() + 5
@@ -526,7 +582,7 @@ fi
def test_piped_install_ignores_same_named_unrelated_cwd_file(self): def test_piped_install_ignores_same_named_unrelated_cwd_file(self):
decoy = self.root / 'sh' decoy = self.root / 'sh'
decoy.write_text(SCRIPT + '\n# UNTRUSTED CWD DECOY\n') decoy.write_text(SCRIPT + '\n# UNTRUSTED CWD DECOY\n')
completed = subprocess.run([*SHELL, '-s', '--', '-c'], input=SCRIPT, completed = subprocess.run([*SHELL, '-s', '--', '-c'], input=self.isolation_prelude() + SCRIPT,
cwd=self.root, env=self.env, capture_output=True, text=True, timeout=15) cwd=self.root, env=self.env, capture_output=True, text=True, timeout=15)
self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr) self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr)
self.assertNotIn('UNTRUSTED CWD DECOY', self.installed.read_text()) self.assertNotIn('UNTRUSTED CWD DECOY', self.installed.read_text())
@@ -617,7 +673,7 @@ fi
self.run_script('-u', ok=False) self.run_script('-u', ok=False)
def test_signal_removes_own_lock_and_temporary_files(self): def test_signal_removes_own_lock_and_temporary_files(self):
first = subprocess.Popen([*SHELL, str(self.script), '-o'], first = subprocess.Popen(self.script_command('-o'),
env=self.env | {'TEST_CURL_WAIT': '1'}, env=self.env | {'TEST_CURL_WAIT': '1'},
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
start_new_session=True) start_new_session=True)