fix: address CUPS runtime review findings and add regression tests

This commit is contained in:
Codex
2026-09-11 13:19:37 +08:00
parent 6051b05676
commit 5f49605d24
7 changed files with 490 additions and 38 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.py[cod]
.test-work/
+23
View File
@@ -181,7 +181,30 @@ lpinfo -v | grep usb
- Gitea: https://git.workyai.cn/237899745/S905L3A - Gitea: https://git.workyai.cn/237899745/S905L3A
- Gitee: https://gitee.com/yu-yon/S905L3A(镜像) - Gitee: https://gitee.com/yu-yon/S905L3A(镜像)
## 回归测试
需要 Python 3.9+、Flask 和 Bash。在仓库根目录运行:
```bash
python3 -B -m unittest discover -s tests -v
```
测试会模拟系统命令和驱动安装,不会修改真实网络、安装驱动或重启服务。
覆盖守护锁释放与连续失败恢复、多网卡地址选择、APT 等待/超时、PDF 备用驱动、DEB 架构筛选和基础访问控制。
通过这些测试不等于通过实机验证;USB 打印、AirPrint、NetworkManager/netplan 切换仍需在目标设备验证。
## 已部署设备升级提示
更新 Git 仓库不会自动更新 `/opt` 下正在运行的文件。已安装设备需要部署更新后的
`watchdog/network-watchdog.sh``watchdog/print-watchdog.sh``/opt/cups-watchdog/`
以及 `cups-driver-manager/driver_manager.py``/opt/cups-driver-manager/`,保留原有配置和账号密码。
旧版守护脚本可能在 `/run/cups-watchdog/` 留下 `network.lock``print.lock` 目录。
部署新文件后重启设备一次,可清除 `/run` 中的旧锁并加载新代码。
不要在守护脚本仍运行时直接删除锁目录,以免破坏并发互斥。
## 更新日志 ## 更新日志
- 2026-09-11: 修复守护锁、多网卡静态 IP、APT 等待、PDF 备用驱动和多架构 DEB 安装问题,添加隔离回归测试。
- 2024-12-01: 添加卸载功能、优化打印速度 - 2024-12-01: 添加卸载功能、优化打印速度
- 2024-11-30: 修复 CSS 兼容 CUPS 2.4.7、添加 PDF 打印机选项 - 2024-11-30: 修复 CSS 兼容 CUPS 2.4.7、添加 PDF 打印机选项
+43 -4
View File
@@ -216,6 +216,47 @@ def install_deb(filepath):
return results return results
def install_deb_bundle(filepaths):
"""先检查包元数据,仅安装本机架构和架构无关的 DEB。"""
host = run_command(['dpkg', '--print-architecture'])
architecture = host['stdout'].strip()
if not host['success'] or not architecture:
return [('检测系统架构', {
**host, 'success': False, 'returncode': host['returncode'] or 1,
'stderr': host['stderr'] or '无法检测 dpkg 系统架构,未安装任何驱动包'
})]
results = []
compatible = []
# 完成全部检查后再安装,避免读取损坏的包时已经修改了系统。
for filepath in filepaths:
metadata = run_command(['dpkg-deb', '--field', str(filepath), 'Architecture'])
package_architecture = metadata['stdout'].strip()
if not metadata['success'] or not package_architecture:
results.append((f'检查 DEB 架构: {Path(filepath).name}', {
**metadata, 'success': False, 'returncode': metadata['returncode'] or 1,
'stderr': metadata['stderr'] or '无法读取包的 Architecture 字段,未安装任何驱动包'
}))
return results
if package_architecture in (architecture, 'all'):
compatible.append(str(filepath))
else:
results.append(('跳过其他架构 DEB', {
'success': True, 'returncode': 0, 'stderr': '',
'stdout': f'{Path(filepath).name}: {package_architecture},当前系统为 {architecture}'
}))
if not compatible:
results.append(('选择兼容 DEB', {
'success': False, 'returncode': 1, 'stdout': '',
'stderr': f'未找到适用于 {architecture} 或 all 架构的驱动包'
}))
return results
for filepath in compatible:
results.extend(install_deb(filepath))
return results
def install_ppd(filepath): def install_ppd(filepath):
"""安装 .ppd 文件""" """安装 .ppd 文件"""
results = [] results = []
@@ -266,9 +307,7 @@ def install_extracted_dir(extract_dir):
deb_files = find_files_by_suffix(extract_dir, ('.deb',)) deb_files = find_files_by_suffix(extract_dir, ('.deb',))
if deb_files: if deb_files:
for deb_file in deb_files: return install_deb_bundle(deb_files)
results.extend(install_deb(str(deb_file)))
return results
rpm_files = find_files_by_suffix(extract_dir, ('.rpm',)) rpm_files = find_files_by_suffix(extract_dir, ('.rpm',))
if rpm_files: if rpm_files:
@@ -424,7 +463,7 @@ def install_script(filepath):
def install_driver(filepath, file_type): def install_driver(filepath, file_type):
"""根据文件类型安装驱动""" """根据文件类型安装驱动"""
if file_type == 'deb': if file_type == 'deb':
return install_deb(filepath) return install_deb_bundle([filepath])
elif file_type == 'ppd': elif file_type == 'ppd':
return install_ppd(filepath) return install_ppd(filepath)
elif file_type in ('tar.gz', 'tar', 'tgz'): elif file_type in ('tar.gz', 'tar', 'tgz'):
+42 -32
View File
@@ -77,16 +77,15 @@ fix_apt_lock() {
local count=0 local count=0
while pgrep -x "unattended-upgr" > /dev/null 2>&1 && [ $count -lt 30 ]; do while pgrep -x "unattended-upgr" > /dev/null 2>&1 && [ $count -lt 30 ]; do
sleep 1 sleep 1
((count++)) count=$((count + 1))
echo -ne "\r 等待自动更新进程结束... ${count}s" echo -ne "\r 等待自动更新进程结束... ${count}s"
done done
echo "" echo ""
# 如果还在运行,强制结束 # 不强制中断正在写入软件包数据库的进程。
if pgrep -x "unattended-upgr" > /dev/null 2>&1; then if pgrep -x "unattended-upgr" > /dev/null 2>&1; then
warn "强制结束自动更新进程..." warn "等待自动更新超时,请稍后重新运行安装脚本"
killall unattended-upgr 2>/dev/null || true return 1
sleep 2
fi fi
success "自动更新服务已停止" success "自动更新服务已停止"
@@ -98,17 +97,17 @@ fix_apt_lock() {
local count=0 local count=0
while fuser /var/lib/dpkg/lock-frontend > /dev/null 2>&1 && [ $count -lt 60 ]; do while fuser /var/lib/dpkg/lock-frontend > /dev/null 2>&1 && [ $count -lt 60 ]; do
sleep 1 sleep 1
((count++)) count=$((count + 1))
echo -ne "\r 等待 apt 锁释放... ${count}s" echo -ne "\r 等待 apt 锁释放... ${count}s"
done done
echo "" echo ""
if fuser /var/lib/dpkg/lock-frontend > /dev/null 2>&1; then
warn "等待 apt 锁超时,请稍后重新运行安装脚本"
return 1
fi
fi fi
# 清理可能残留的锁文件 # 锁由内核随进程释放;删除锁文件会破坏其他 apt/dpkg 进程的互斥。
rm -f /var/lib/dpkg/lock-frontend 2>/dev/null || true
rm -f /var/lib/dpkg/lock 2>/dev/null || true
rm -f /var/cache/apt/archives/lock 2>/dev/null || true
# 修复可能中断的安装 # 修复可能中断的安装
dpkg --configure -a 2>/dev/null || true dpkg --configure -a 2>/dev/null || true
@@ -244,23 +243,28 @@ EOF
# 获取本机IP地址 # 获取本机IP地址
get_ip() { get_ip() {
ip addr show | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | grep -v '127.0.0.1' | head -n1 local iface="${1:-$(get_interface)}"
[ -n "$iface" ] || return 1
ip -4 -o addr show dev "$iface" scope global | awk '{split($4, address, "/"); print address[1]; exit}'
} }
# 获取默认网关 # 获取默认网关
get_gateway() { get_gateway() {
ip route | grep default | awk '{print $3}' | head -n1 local iface="${1:-$(get_interface)}"
[ -n "$iface" ] || return 1
ip -4 route show default dev "$iface" | awk '{for (i=1; i<NF; i++) if ($i == "via") {print $(i+1); exit}}'
} }
# 获取主网络接口名称 # 获取主网络接口名称
get_interface() { get_interface() {
ip route | grep default | awk '{print $5}' | head -n1 ip -4 route show default | awk '{for (i=1; i<NF; i++) if ($i == "dev") {print $(i+1); exit}}'
} }
# 获取子网掩码(CIDR格式) # 获取子网掩码(CIDR格式)
get_netmask() { get_netmask() {
local iface=$(get_interface) local iface="${1:-$(get_interface)}"
ip addr show "$iface" | grep -oP 'inet \K[\d.]+/\d+' | head -n1 | cut -d'/' -f2 [ -n "$iface" ] || return 1
ip -4 -o addr show dev "$iface" scope global | awk '{split($4, address, "/"); print address[2]; exit}'
} }
# 获取DNS服务器 # 获取DNS服务器
@@ -277,12 +281,13 @@ configure_static_ip() {
echo -e "${YELLOW}═══════════════════════════════════════════════════════════${NC}" echo -e "${YELLOW}═══════════════════════════════════════════════════════════${NC}"
echo "" echo ""
local current_ip=$(get_ip)
local current_gateway=$(get_gateway)
local current_interface=$(get_interface) local current_interface=$(get_interface)
local current_netmask=$(get_netmask) local current_ip=$(get_ip "$current_interface")
local current_gateway=$(get_gateway "$current_interface")
local current_netmask=$(get_netmask "$current_interface")
local current_dns=$(get_dns) local current_dns=$(get_dns)
[ -n "$current_interface" ] || error "未找到 IPv4 默认路由对应的网卡,无法设置静态 IP"
echo -e "${BLUE}当前网络配置:${NC}" echo -e "${BLUE}当前网络配置:${NC}"
echo " 接口名称: $current_interface" echo " 接口名称: $current_interface"
echo " IP 地址: $current_ip" echo " IP 地址: $current_ip"
@@ -305,6 +310,9 @@ configure_static_ip() {
local static_dns="" local static_dns=""
if [[ "$use_current" =~ ^[Yy]$ ]]; then if [[ "$use_current" =~ ^[Yy]$ ]]; then
if [ -z "$current_ip" ] || [ -z "$current_netmask" ] || [ -z "$current_gateway" ]; then
error "当前网卡的 IPv4 地址、掩码或网关不完整,未修改网络配置"
fi
static_ip="$current_ip" static_ip="$current_ip"
static_gateway="$current_gateway" static_gateway="$current_gateway"
static_netmask="$current_netmask" static_netmask="$current_netmask"
@@ -1368,10 +1376,10 @@ install_watchdogs() {
[ -n "$tmp_dir" ] && rm -rf "$tmp_dir" [ -n "$tmp_dir" ] && rm -rf "$tmp_dir"
local current_ip=$(get_ip)
local current_gateway=$(get_gateway)
local current_interface=$(get_interface) local current_interface=$(get_interface)
local current_netmask=$(get_netmask) local current_ip=$(get_ip "$current_interface")
local current_gateway=$(get_gateway "$current_interface")
local current_netmask=$(get_netmask "$current_interface")
local current_dns=$(get_dns) local current_dns=$(get_dns)
local current_host_octet="${current_ip##*.}" local current_host_octet="${current_ip##*.}"
current_dns="${current_dns:-114.114.114.114 223.5.5.5}" current_dns="${current_dns:-114.114.114.114 223.5.5.5}"
@@ -1451,24 +1459,26 @@ install_pdf_printer() {
else else
# 添加 PDF 虚拟打印机 # 添加 PDF 虚拟打印机
# 使用 lpadmin 命令创建打印机 # 使用 lpadmin 命令创建打印机
lpadmin -p PDF \ if ! lpadmin -p PDF \
-v cups-pdf:/ \ -v cups-pdf:/ \
-E \ -E \
-m lsb/usr/cups-pdf/CUPS-PDF_opt.ppd \ -m lsb/usr/cups-pdf/CUPS-PDF_opt.ppd \
-D "虚拟PDF打印机 (测试用)" \ -D "虚拟PDF打印机 (测试用)" \
-L "本地" \ -L "本地" \
-o printer-is-shared=true -o printer-is-shared=true; then
# 从 CUPS 实际提供的模型中选备用 PPD,不创建无法转换 PDF 的 raw 队列。
# 如果上面的 PPD 文件不存在,尝试其他路径 local pdf_model
if ! lpstat -p PDF 2>/dev/null | grep -q "PDF"; then pdf_model=$(lpinfo -m 2>/dev/null | awk 'tolower($0) ~ /cups-pdf/ && $1 != "lsb/usr/cups-pdf/CUPS-PDF_opt.ppd" {print $1; exit}')
# 尝试使用通用 PPD if [ -z "$pdf_model" ] || ! lpadmin -p PDF \
lpadmin -p PDF \
-v cups-pdf:/ \ -v cups-pdf:/ \
-E \ -E \
-m raw \ -m "$pdf_model" \
-D "虚拟PDF打印机 (测试用)" \ -D "虚拟PDF打印机 (测试用)" \
-L "本地" \ -L "本地" \
-o printer-is-shared=true 2>/dev/null || true -o printer-is-shared=true; then
warn "未能使用可用的 CUPS-PDF 驱动创建队列,跳过虚拟 PDF 打印机"
return 0
fi
fi fi
# 启用打印机 # 启用打印机
@@ -1596,7 +1606,7 @@ main() {
show_banner show_banner
check_root check_root
LOCAL_IP=$(get_ip) LOCAL_IP=$(get_ip) || error "未找到 IPv4 默认路由对应的网卡"
info "检测到本机IP: ${LOCAL_IP}" info "检测到本机IP: ${LOCAL_IP}"
echo "" echo ""
+373
View File
@@ -0,0 +1,373 @@
"""Safe regression tests: system commands and driver installation are mocked."""
import base64
import importlib.util
import io
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
import zipfile
ROOT = Path(__file__).resolve().parents[1]
sys.dont_write_bytecode = True
def bash_path():
candidate = os.environ.get("BASH_PATH") or shutil.which("bash")
if not candidate and os.name == "nt":
candidate = "C:/Program Files/Git/bin/bash.exe"
if not candidate or not Path(candidate).is_file():
raise unittest.SkipTest("Bash is required for shell regression tests")
return candidate
def shell_path(path):
value = str(path.resolve()).replace("\\", "/")
if os.name == "nt" and value[1:2] == ":":
return "/" + value[0].lower() + value[2:]
return value
def function(source, name):
match = re.search(rf"^{re.escape(name)}\(\) \{{\n.*?^\}}", source, re.M | re.S)
if not match:
raise AssertionError(f"Shell function not found: {name}")
return match.group(0)
class IsolatedTest(unittest.TestCase):
def setUp(self):
workspace = ROOT / ".test-work"
workspace.mkdir(exist_ok=True)
self.temp = tempfile.TemporaryDirectory(dir=workspace)
self.directory = Path(self.temp.name).resolve()
self.assertTrue(self.directory.is_relative_to(workspace.resolve()))
self.addCleanup(self.temp.cleanup)
class ShellTests(IsolatedTest):
def setUp(self):
super().setUp()
self.bash = bash_path()
self.setup = (ROOT / "setup_cups.sh").read_text(encoding="utf-8")
def run_shell(self, source, env=None):
return subprocess.run(
[self.bash, "--noprofile", "--norc", "-s"], input=source,
text=True, encoding="utf-8", errors="replace", capture_output=True,
env={**os.environ, **(env or {})}, timeout=15,
)
def test_shell_syntax(self):
for path in [ROOT / "setup_cups.sh", *sorted((ROOT / "watchdog").glob("*.sh"))]:
with self.subTest(script=path.name):
result = subprocess.run(
[self.bash, "-n"], input=path.read_text(encoding="utf-8"),
capture_output=True, text=True, encoding="utf-8", timeout=10,
)
self.assertEqual(result.returncode, 0, result.stderr)
def watchdog(self, kind, failed=False):
state = self.directory / kind
log = self.directory / f"{kind}.log"
config = self.directory / f"{kind}.conf"
config.write_text(
f'STATE_DIR="{shell_path(state)}"\nLOG_FILE="{shell_path(log)}"\n'
'SERVICES=""\nCHECK_CUPS_HTTP=0\nCHECK_LPSTAT=0\n', encoding="utf-8",
)
source = (ROOT / "watchdog" / f"{kind}-watchdog.sh").read_text(encoding="utf-8")
preamble = 'ip() { echo "default via 192.168.1.1 dev fixture0"; }; ping() { return 0; };\n'
if failed:
head, dispatch = source.rsplit('case "${1:-check}" in', 1)
recovery = shell_path(self.directory / "recoveries")
# Replace only the OS-changing boundary; keep lock/count/threshold logic intact.
if kind == "network":
overrides = f'connectivity_ok() {{ return 1; }}\nrecover_after_failure() {{ echo recovered >> "{recovery}"; }}\n'
else:
overrides = f'check_http() {{ return 1; }}\nrestart_print_stack() {{ echo recovered >> "{recovery}"; }}\n'
source = head + overrides + 'case "${1:-check}" in' + dispatch
return preamble + source, {f"CUPS_{kind.upper()}_WATCHDOG_CONFIG": shell_path(config)}, state
def test_watchdogs_release_locks_after_every_check(self):
for kind in ("network", "print"):
with self.subTest(watchdog=kind):
source, env, state = self.watchdog(kind)
for _ in range(3):
result = self.run_shell(source, env)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stderr, "")
self.assertNotIn("another run is active", result.stdout)
self.assertFalse((state / f"{kind}.lock").exists())
def test_watchdogs_preserve_an_existing_lock(self):
for kind in ("network", "print"):
with self.subTest(watchdog=kind):
source, env, state = self.watchdog(kind)
lock = state / f"{kind}.lock"
lock.mkdir(parents=True)
result = self.run_shell(source, env)
self.assertEqual(result.returncode, 0)
self.assertIn("another run is active", result.stdout)
self.assertTrue(lock.is_dir())
def test_watchdogs_release_their_lock_on_termination(self):
for kind in ("network", "print"):
with self.subTest(watchdog=kind):
state = self.directory / kind
state.mkdir()
source = (ROOT / "watchdog" / f"{kind}-watchdog.sh").read_text(encoding="utf-8")
script = f'set -u\nSTATE_DIR="{shell_path(state)}"\n'
script += function(source, "acquire_lock") + '\nacquire_lock\nkill -TERM "$$"\n'
result = self.run_shell(script)
self.assertEqual(result.returncode, 143, result.stderr)
self.assertEqual(result.stderr, "")
self.assertFalse((state / f"{kind}.lock").exists())
def test_watchdogs_recover_after_consecutive_failures(self):
for kind, threshold in (("network", 3), ("print", 2)):
with self.subTest(watchdog=kind):
source, env, state = self.watchdog(kind, failed=True)
count_before = (self.directory / "recoveries").read_text().count("recovered") if (self.directory / "recoveries").exists() else 0
for _ in range(threshold):
result = self.run_shell(source, env)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stderr, "")
self.assertEqual((state / f"{kind}.fail_count").read_text().strip(), "0")
self.assertEqual((self.directory / "recoveries").read_text().count("recovered"), count_before + 1)
def apt_fixture(self, busy_tool, release_after):
return (
'set -e\ninfo() { :; }; warn() { :; }; success() { :; };\n'
'pgrep() { return 1; }; fuser() { return 1; }; systemctl() { :; };\n'
'rm() { echo UNEXPECTED_RM; exit 99; }; killall() { echo UNEXPECTED_KILL; exit 99; };\n'
'dpkg() { echo DPKG_REACHED; }; sleeps=0; polls=0;\n'
'trap \'echo "WAITED=$sleeps"\' EXIT\n'
'sleep() { sleeps=$((sleeps + 1)); };\n'
f'{busy_tool}() {{ polls=$((polls + 1)); [ "$polls" -le {release_after} ]; }}\n'
+ function(self.setup, "fix_apt_lock")
+ '\nfix_apt_lock\nprintf "DONE sleeps=%s\\n" "$sleeps"\n'
)
def test_apt_waits_then_continues(self):
for tool in ("fuser", "pgrep"):
with self.subTest(tool=tool):
result = self.run_shell(self.apt_fixture(tool, 3))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("DPKG_REACHED", result.stdout)
self.assertIn("DONE sleeps=2", result.stdout)
self.assertNotIn("UNEXPECTED", result.stdout)
def test_apt_wait_timeout_does_not_remove_locks_or_kill(self):
for tool in ("fuser", "pgrep"):
with self.subTest(tool=tool):
result = self.run_shell(self.apt_fixture(tool, 1000))
self.assertEqual(result.returncode, 1, result.stderr)
self.assertNotIn("DPKG_REACHED", result.stdout)
self.assertNotIn("UNEXPECTED", result.stdout)
self.assertIn("WAITED=60" if tool == "fuser" else "WAITED=30", result.stdout)
def ip_fixture(self, missing=False):
preamble = '''ip() {
case "$*" in
"-4 route show default"|"-4 route show default dev eth0"|"route")
echo 'default via 192.168.1.1 dev eth0 proto dhcp src 192.168.1.219 metric 100' ;;
"-4 route show default dev wlan0") echo 'default via 10.0.0.1 dev wlan0' ;;
"-4 -o addr show dev eth0 scope global"|"addr show eth0")
echo '3: eth0 inet 192.168.1.219/24 brd 192.168.1.255 scope global eth0' ;;
"-4 -o addr show dev wlan0 scope global") echo '4: wlan0 inet 10.0.0.20/16 scope global wlan0' ;;
"addr show") printf '2: docker0\n inet 172.17.0.1/16 scope global docker0\n3: eth0\n inet 192.168.1.219/24 scope global eth0\n' ;;
*) return 1 ;;
esac
}
'''
if missing:
preamble = "ip() { return 0; }\n"
return preamble + "\n".join(function(self.setup, name) for name in ("get_ip", "get_gateway", "get_interface", "get_netmask"))
def test_default_interface_address_is_not_docker_address(self):
result = self.run_shell(self.ip_fixture() + '\nprintf "%s %s %s %s" "$(get_interface)" "$(get_ip)" "$(get_netmask)" "$(get_gateway)"\n')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "eth0 192.168.1.219 24 192.168.1.1")
def test_explicit_interface_is_used_for_all_parameters(self):
result = self.run_shell(self.ip_fixture() + '\nprintf "%s %s %s" "$(get_ip wlan0)" "$(get_netmask wlan0)" "$(get_gateway wlan0)"\n')
self.assertEqual(result.stdout, "10.0.0.20 16 10.0.0.1")
def test_missing_route_does_not_use_an_unrelated_address(self):
for name in ("get_ip", "get_gateway", "get_netmask"):
with self.subTest(helper=name):
result = self.run_shell(self.ip_fixture(missing=True) + f"\n{name}\n")
self.assertNotEqual(result.returncode, 0)
self.assertEqual(result.stdout, "")
def pdf_fixture(self, mode):
source = function(self.setup, "install_pdf_printer").replace(
"/etc/cups/cups-pdf.conf", shell_path(self.directory / "absent.conf"))
return f'MODE={mode}\n' + '''set -e
info() { :; }; warn() { echo WARNING; }; success() { :; }; sleep() { :; }; apt() { :; }
calls=0; ready=0
if [ "$MODE" = existing ]; then ready=1; fi
lpstat() { if [ "$ready" = 1 ]; then echo 'printer PDF is idle'; else return 1; fi; }
lpinfo() { if [ "$MODE" != missing ]; then echo 'generic/CUPS-PDF.ppd Generic CUPS-PDF Printer'; fi; }
lpadmin() {
calls=$((calls + 1))
echo "LPADMIN $*"
if [ "$MODE" = primary ] || { [ "$MODE" = fallback ] && [ "$calls" = 2 ]; }; then ready=1; return 0; fi
return 1
}
cupsenable() { :; }; cupsaccept() { :; }
''' + source + '\ninstall_pdf_printer\necho "DONE calls=$calls ready=$ready"\n'
def test_pdf_primary_model(self):
result = self.run_shell(self.pdf_fixture("primary"))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("DONE calls=1 ready=1", result.stdout)
def test_pdf_existing_queue_is_not_recreated(self):
result = self.run_shell(self.pdf_fixture("existing"))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("DONE calls=0 ready=1", result.stdout)
def test_pdf_fallback_uses_discovered_model(self):
result = self.run_shell(self.pdf_fixture("fallback"))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("-m generic/CUPS-PDF.ppd", result.stdout)
self.assertIn("DONE calls=2 ready=1", result.stdout)
self.assertNotIn("-m raw", result.stdout)
def test_pdf_unavailable_models_do_not_abort_remaining_setup(self):
for mode, calls in (("missing", 1), ("broken", 2)):
with self.subTest(mode=mode):
result = self.run_shell(self.pdf_fixture(mode))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(f"DONE calls={calls} ready=0", result.stdout)
self.assertIn("WARNING", result.stdout)
self.assertNotIn("-m raw", result.stdout)
class DriverManagerTests(IsolatedTest):
@classmethod
def setUpClass(cls):
spec = importlib.util.spec_from_file_location("driver_manager", ROOT / "cups-driver-manager" / "driver_manager.py")
cls.dm = importlib.util.module_from_spec(spec)
with patch("os.makedirs"):
spec.loader.exec_module(cls.dm)
def setUp(self):
super().setUp()
self.uploads = self.directory / "uploads"
self.uploads.mkdir()
self.dm.app.config.update(TESTING=True, UPLOAD_FOLDER=str(self.uploads))
self.client = self.dm.app.test_client()
token = base64.b64encode(f"{self.dm.ADMIN_USERNAME}:{self.dm.ADMIN_PASSWORD}".encode()).decode()
self.headers = {"Authorization": "Basic " + token}
self.architectures = {"a-amd64.deb": "arm64", "b-arm64.deb": "amd64", "common.deb": "all"}
@staticmethod
def result(success=True, stdout="", stderr=""):
return {"success": success, "stdout": stdout, "stderr": stderr, "returncode": 0 if success else 1}
def command(self, cmd, **kwargs):
if cmd == ['dpkg', '--print-architecture']:
return self.result(stdout="arm64\n")
if cmd[:2] == ['dpkg-deb', '--field'] and cmd[-1] == 'Architecture':
architecture = self.architectures[Path(cmd[2]).name]
return self.result(stdout=architecture + "\n") if architecture else self.result(False, stderr="Invalid DEB")
raise AssertionError(f"Unexpected system command: {cmd}")
def archive_request(self, names):
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
for name in names:
archive.writestr("vendor/" + name, b"mock DEB metadata is supplied by the test")
buffer.seek(0)
installed = []
def install(path):
installed.append(Path(path).name)
return [("mock installation", self.result())]
real_mkdtemp = tempfile.mkdtemp
with patch.object(self.dm, "run_command", side_effect=self.command), \
patch.object(self.dm, "install_deb", side_effect=install), \
patch.object(self.dm.tempfile, "mkdtemp", side_effect=lambda prefix: real_mkdtemp(prefix=prefix, dir=self.directory)):
response = self.client.post("/api/install", headers=self.headers,
data={"driver_file": (buffer, "drivers.zip")})
self.assertEqual(response.status_code, 200)
self.assertEqual(list(self.uploads.iterdir()), [])
return response.json, installed
def test_archive_uses_metadata_not_filename_and_includes_all(self):
response, installed = self.archive_request(self.architectures)
self.assertTrue(response['success'], response)
self.assertEqual(installed, ["a-amd64.deb", "common.deb"])
def test_archive_with_no_compatible_packages_fails_without_installation(self):
response, installed = self.archive_request(["b-arm64.deb"])
self.assertFalse(response['success'])
self.assertEqual(installed, [])
def test_architecture_independent_bundle_is_supported(self):
response, installed = self.archive_request(["common.deb"])
self.assertTrue(response['success'], response)
self.assertEqual(installed, ["common.deb"])
def test_corrupt_metadata_prevents_partial_installation(self):
self.architectures["common.deb"] = None
response, installed = self.archive_request(["a-amd64.deb", "common.deb"])
self.assertFalse(response['success'])
self.assertEqual(installed, [])
def test_architecture_detection_failure_does_not_install(self):
with patch.object(self.dm, "run_command", return_value=self.result(False, stderr="dpkg unavailable")), \
patch.object(self.dm, "install_deb") as install:
results = self.dm.install_driver("driver.deb", "deb")
self.assertFalse(all(step[1]['success'] for step in results))
install.assert_not_called()
def test_direct_foreign_deb_is_rejected_before_dependency_repair(self):
with patch.object(self.dm, "run_command", side_effect=self.command), \
patch.object(self.dm, "install_deb") as install:
results = self.dm.install_driver("b-arm64.deb", "deb")
self.assertFalse(all(step[1]['success'] for step in results))
install.assert_not_called()
def test_deb_dependency_repair_still_retries(self):
with patch.object(self.dm, "run_command", side_effect=[self.result(False), self.result(), self.result()]) as command:
results = self.dm.install_deb("native.deb")
self.assertTrue(all(step[1]['success'] for step in results))
self.assertEqual(command.call_args_list[-1].args[0], ['dpkg', '-i', 'native.deb'])
def test_auth_and_private_network_filter(self):
self.assertEqual(self.client.get("/").status_code, 401)
self.assertEqual(self.client.get("/", headers=self.headers).status_code, 200)
self.assertEqual(self.client.get("/", headers=self.headers, environ_overrides={"REMOTE_ADDR": "8.8.8.8"}).status_code, 403)
def test_invalid_upload_is_rejected_without_dispatch(self):
with patch.object(self.dm, "install_driver") as install:
response = self.client.post("/api/install", headers=self.headers,
data={"driver_file": (io.BytesIO(b"test"), "bad.txt")})
self.assertFalse(response.json['success'])
install.assert_not_called()
def test_tar_traversal_is_rejected(self):
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w") as archive:
entry = tarfile.TarInfo("../escape.txt")
entry.size = 1
archive.addfile(entry, io.BytesIO(b"x"))
buffer.seek(0)
with tarfile.open(fileobj=buffer) as archive, self.assertRaises(ValueError):
self.dm.safe_extract_tar(archive, str(self.directory / "extract"))
if __name__ == "__main__":
unittest.main()
+3 -1
View File
@@ -46,7 +46,9 @@ acquire_lock() {
echo "$(date '+%Y-%m-%d %H:%M:%S') [network-watchdog] another run is active" echo "$(date '+%Y-%m-%d %H:%M:%S') [network-watchdog] another run is active"
exit 0 exit 0
fi fi
trap 'rmdir "$lock_dir" 2>/dev/null || true' EXIT trap 'rmdir "$STATE_DIR/network.lock" 2>/dev/null || true' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
} }
rotate_log() { rotate_log() {
+3 -1
View File
@@ -35,7 +35,9 @@ acquire_lock() {
echo "$(date '+%Y-%m-%d %H:%M:%S') [print-watchdog] another run is active" echo "$(date '+%Y-%m-%d %H:%M:%S') [print-watchdog] another run is active"
exit 0 exit 0
fi fi
trap 'rmdir "$lock_dir" 2>/dev/null || true' EXIT trap 'rmdir "$STATE_DIR/print.lock" 2>/dev/null || true' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
} }
rotate_log() { rotate_log() {