RKNnoVPN/runtime/cmd/rknnovpn-runtime/main_test.go
loop-uh 2ccfe8ce0a
Some checks failed
RKNnoVPN Linux and Android CI / policy-and-go (push) Successful in 14s
RKNnoVPN Linux and Android CI / android-debug (push) Successful in 2m5s
Full build and Forgejo release / Privacy Lint (push) Successful in 3s
Full build and Forgejo release / Local-first Runtime Guardrail (push) Successful in 2s
Full build and Forgejo release / Go Tests (push) Successful in 13s
Full build and Forgejo release / Resolve sing-box release (push) Successful in 1s
Full build and Forgejo release / Resolve Xray-core release (push) Successful in 1s
Full build and Forgejo release / Android Guardrails & Tests (push) Failing after 16s
Full build and Forgejo release / Build APK (push) Has been skipped
Full build and Forgejo release / Build Runtime CLI (arm64) (push) Successful in 11s
Full build and Forgejo release / Build Runtime CLI (armv7) (push) Successful in 11s
Full build and Forgejo release / Build sing-box (arm64) (push) Failing after 2s
Full build and Forgejo release / Build sing-box (armv7) (push) Failing after 2s
Full build and Forgejo release / Build Xray-core (arm64) (push) Failing after 3s
Full build and Forgejo release / Build Xray-core (armv7) (push) Failing after 2s
Full build and Forgejo release / Build Magisk Module (push) Has been skipped
Full build and Forgejo release / Create Release (push) Has been skipped
Перенести выпуск RKNnoVPN на Forgejo
2026-08-07 03:08:36 +03:00

515 lines
17 KiB
Go

package main
import (
"archive/zip"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"syscall"
"testing"
"git.zapret.moe/zapretdiscordyoutube/RKNnoVPN/runtime/internal/core"
)
func TestRuntimeCLIContractFixtures(t *testing.T) {
assertJSONShape(t, "version-ok.json", envelope{
OK: true,
Result: runtimeVersionResult{
ReleaseVersion: "v2.3.7",
VersionCode: 2030700,
WireEpoch: wireEpoch,
},
})
assertJSONShape(t, "status-ok.json", envelope{
OK: true,
Result: runtimeStatusResult{
State: "CONNECTED",
Uptime: int64(0),
Traffic: runtimeTrafficResult{
TxBytes: int64(0),
RxBytes: int64(0),
TxRate: int64(0),
RxRate: int64(0),
},
Health: runtimeHealthResult{
Healthy: true,
CoreRunning: true,
DNSOperational: true,
RoutingReady: true,
EgressReady: true,
OperationalHealthy: true,
CheckedAt: int64(1710000000),
},
},
})
assertJSONShape(t, "node-test-ok.json", envelope{
OK: true,
Result: []nodeTestResult{
{
NodeID: "node-1",
LatencyMs: 42,
ResponseMs: intPtr(57),
Throughput: int64Ptr(1024),
Status: "tcp_ok",
},
},
})
assertJSONShape(t, "action-start-ok.json", envelope{
OK: true,
Result: runtimeActionResult{
Accepted: true,
Status: runtimeStatusResult{
State: "CONNECTED",
Uptime: int64(0),
Traffic: runtimeTrafficResult{
TxBytes: int64(0),
RxBytes: int64(0),
TxRate: int64(0),
RxRate: int64(0),
},
Health: runtimeHealthResult{
Healthy: true,
CoreRunning: true,
DNSOperational: true,
RoutingReady: true,
EgressReady: true,
OperationalHealthy: true,
CheckedAt: int64(1710000000),
},
},
Stage: "start",
RuntimeCode: "",
UserMessage: "",
Debug: "",
RollbackApplied: false,
},
})
assertJSONShape(t, "action-start-error.json", envelope{
OK: true,
Result: runtimeActionResult{
Accepted: false,
Status: runtimeStatusResult{
State: "ERROR",
Uptime: int64(0),
Traffic: runtimeTrafficResult{
TxBytes: int64(0),
RxBytes: int64(0),
TxRate: int64(0),
RxRate: int64(0),
},
Health: runtimeHealthResult{
Healthy: false,
CoreRunning: false,
DNSOperational: false,
RoutingReady: false,
EgressReady: false,
OperationalHealthy: false,
LastCode: "RULES_NOT_APPLIED",
LastUserMessage: "RKNnoVPN routing rules could not be applied.",
LastDebug: "iptables denied",
CheckedAt: int64(1710000000),
},
},
Stage: "netstack apply",
RuntimeCode: "RULES_NOT_APPLIED",
UserMessage: "RKNnoVPN routing rules could not be applied.",
Debug: "iptables denied",
RollbackApplied: true,
},
})
}
func TestRuntimeActionResultCapturesRuntimeError(t *testing.T) {
result := runtimeActionResultFrom("start", &core.RuntimeError{
Layer: "netstack apply",
Code: "RULES_NOT_APPLIED",
UserMessage: "RKNnoVPN routing rules could not be applied.",
Debug: "iptables denied",
RollbackApplied: true,
Err: errors.New("iptables denied"),
})
if result.Accepted {
t.Fatal("runtime action error must be a rejected action result")
}
if result.Stage != "netstack apply" {
t.Fatalf("stage = %q", result.Stage)
}
if result.RuntimeCode != "RULES_NOT_APPLIED" {
t.Fatalf("runtime code = %q", result.RuntimeCode)
}
if result.UserMessage != "RKNnoVPN routing rules could not be applied." {
t.Fatalf("user message = %q", result.UserMessage)
}
if result.Debug != "iptables denied" {
t.Fatalf("debug = %q", result.Debug)
}
if !result.RollbackApplied {
t.Fatal("rollback flag must be preserved")
}
if result.Status.State != "ERROR" {
t.Fatalf("status state = %q", result.Status.State)
}
if result.Status.Health.LastCode != result.RuntimeCode {
t.Fatalf("health last code = %q", result.Status.Health.LastCode)
}
}
func TestParseRuntimePIDFileAcceptsStartTime(t *testing.T) {
pid, startTime, err := parseRuntimePIDFile([]byte("123 456\n"))
if err != nil {
t.Fatal(err)
}
if pid != 123 || startTime != "456" {
t.Fatalf("pid=%d startTime=%q", pid, startTime)
}
}
func TestStopPidFileRemovesStalePIDWithoutKillingCurrentProcess(t *testing.T) {
dir := t.TempDir()
pidFile := filepath.Join(dir, "singbox.pid")
if err := os.WriteFile(pidFile, []byte("1\n"), 0o600); err != nil {
t.Fatal(err)
}
stopPidFile(pidFile)
if _, err := os.Stat(pidFile); !os.IsNotExist(err) {
t.Fatalf("invalid pid file should be removed without killing pid 1, stat err=%v", err)
}
if err := os.WriteFile(pidFile, []byte("999999\n"), 0o600); err != nil {
t.Fatal(err)
}
stopPidFile(pidFile)
if _, err := os.Stat(pidFile); !os.IsNotExist(err) {
t.Fatalf("stale missing-process pid file should be removed, stat err=%v", err)
}
if err := os.WriteFile(pidFile, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o600); err != nil {
t.Fatal(err)
}
stopPidFile(pidFile)
if _, err := os.Stat(pidFile); !os.IsNotExist(err) {
t.Fatalf("stale current-process pid file should be removed without killing this process, stat err=%v", err)
}
}
func TestRuntimeRunningIgnoresXrayOnlyPID(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "xray.pid"), []byte(strconv.Itoa(os.Getpid())+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if runtimeRunningFromDir(dir) {
t.Fatal("xray-only pid file must not mark runtime connected")
}
}
func TestRuntimeProcessConfigPathIsCanonicalOnly(t *testing.T) {
if got, want := runtimeProcessConfigPath("sing-box"), filepath.Join(moduleDir, "config", "rendered", "singbox.json"); got != want {
t.Fatalf("sing-box config path = %q, want %q", got, want)
}
if got, want := runtimeProcessConfigPath("xray"), filepath.Join(moduleDir, "config", "rendered", "xray-xhttp.json"); got != want {
t.Fatalf("xray config path = %q, want %q", got, want)
}
if got := runtimeProcessConfigPath("old-hot-swap"); got != "" {
t.Fatalf("unknown runtime binary should not get a config path, got %q", got)
}
}
func TestStageModuleWritesRootManagerUpdateLayout(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "module.zip")
archDir := stagedArchDir()
writeModuleZip(t, zipPath, map[string]string{
"module.prop": "id=rknnovpn\nversion=v2.4.0\n",
"customize.sh": "#!/system/bin/sh\n",
"service.sh": "#!/system/bin/sh\n",
"post-fs-data.sh": "#!/system/bin/sh\n",
"uninstall.sh": "#!/system/bin/sh\n",
"defaults/config.json": "{}\n",
"scripts/dns.sh": "#!/system/bin/sh\n",
"scripts/iptables.sh": "#!/system/bin/sh\n",
"scripts/privacy_guard.sh": "#!/system/bin/sh\n",
"scripts/rescue_reset.sh": "#!/system/bin/sh\n",
"scripts/routing.sh": "#!/system/bin/sh\n",
"scripts/lib/rknnovpn_env.sh": "#!/system/bin/sh\n",
"scripts/lib/rknnovpn_install.sh": "#!/system/bin/sh\n",
"scripts/lib/rknnovpn_installer_flow.sh": "#!/system/bin/sh\n",
"scripts/lib/rknnovpn_iptables_rules.sh": "#!/system/bin/sh\n",
"scripts/lib/rknnovpn_netstack.sh": "#!/system/bin/sh\n",
filepath.ToSlash(filepath.Join("binaries", archDir, "sing-box")): "sing-box",
filepath.ToSlash(filepath.Join("binaries", archDir, "rknnovpn-runtime")): "runtime",
})
staged, err := stageModuleZip(zipPath, filepath.Join(dir, "modules", "rknnovpn"))
if err != nil {
t.Fatal(err)
}
wantDir := filepath.Join(dir, "modules_update", "rknnovpn")
if staged != wantDir {
t.Fatalf("staged dir = %q, want %q", staged, wantDir)
}
if got := readFile(t, filepath.Join(wantDir, "bin", "rknnovpn-runtime")); got != "runtime" {
t.Fatalf("runtime binary = %q", got)
}
if got := readFile(t, filepath.Join(wantDir, "bin", "sing-box")); got != "sing-box" {
t.Fatalf("sing-box binary = %q", got)
}
assertFileMode(t, filepath.Join(wantDir, "customize.sh"), 0o755)
assertFileMode(t, filepath.Join(wantDir, "service.sh"), 0o755)
assertFileMode(t, filepath.Join(wantDir, "scripts", "rescue_reset.sh"), 0o755)
assertFileMode(t, filepath.Join(wantDir, "bin", "rknnovpn-runtime"), 0o750)
if got := readFile(t, filepath.Join(wantDir, "module.prop")); !strings.Contains(got, "id=rknnovpn") {
t.Fatalf("module.prop = %q", got)
}
if _, err := os.Stat(filepath.Join(wantDir, "config", "manual")); err != nil {
t.Fatalf("manual marker missing: %v", err)
}
if got := readFile(t, filepath.Join(wantDir, "config", "config.json")); got != "{}\n" {
t.Fatalf("staged config = %q", got)
}
current, err := os.Readlink(filepath.Join(wantDir, "current"))
if err != nil {
t.Fatalf("current release link missing: %v", err)
}
if current != filepath.Join("releases", "v2.4.0") {
t.Fatalf("current release link = %q", current)
}
manifest := readFile(t, filepath.Join(wantDir, "releases", "v2.4.0", "install-manifest.json"))
for _, want := range []string{`"version": "v2.4.0"`, `"bin/rknnovpn-runtime"`, `"module/module.prop"`, `"module/scripts/lib/rknnovpn_netstack.sh"`} {
if !strings.Contains(manifest, want) {
t.Fatalf("release manifest missing %s: %s", want, manifest)
}
}
if _, err := os.Stat(filepath.Join(dir, "modules", "rknnovpn", "releases", "pending-module.zip")); !os.IsNotExist(err) {
t.Fatalf("legacy pending-module.zip must not be written, stat err=%v", err)
}
}
func TestStageModulePermissionsIgnoreProcessUmask(t *testing.T) {
oldUmask := syscall.Umask(0o077)
t.Cleanup(func() {
syscall.Umask(oldUmask)
})
dir := t.TempDir()
zipPath := filepath.Join(dir, "module.zip")
archDir := stagedArchDir()
writeModuleZip(t, zipPath, map[string]string{
"module.prop": "id=rknnovpn\nversion=v2.4.0\n",
"customize.sh": "#!/system/bin/sh\n",
"service.sh": "#!/system/bin/sh\n",
"post-fs-data.sh": "#!/system/bin/sh\n",
"uninstall.sh": "#!/system/bin/sh\n",
"defaults/config.json": "{}\n",
"scripts/dns.sh": "#!/system/bin/sh\n",
"scripts/iptables.sh": "#!/system/bin/sh\n",
"scripts/privacy_guard.sh": "#!/system/bin/sh\n",
"scripts/rescue_reset.sh": "#!/system/bin/sh\n",
"scripts/routing.sh": "#!/system/bin/sh\n",
"scripts/lib/rknnovpn_env.sh": "#!/system/bin/sh\n",
"scripts/lib/rknnovpn_install.sh": "#!/system/bin/sh\n",
"scripts/lib/rknnovpn_installer_flow.sh": "#!/system/bin/sh\n",
"scripts/lib/rknnovpn_iptables_rules.sh": "#!/system/bin/sh\n",
"scripts/lib/rknnovpn_netstack.sh": "#!/system/bin/sh\n",
filepath.ToSlash(filepath.Join("binaries", archDir, "rknnovpn-runtime")): "runtime",
})
staged, err := stageModuleZip(zipPath, filepath.Join(dir, "modules", "rknnovpn"))
if err != nil {
t.Fatal(err)
}
assertFileMode(t, filepath.Join(staged, "customize.sh"), 0o755)
assertFileMode(t, filepath.Join(staged, "service.sh"), 0o755)
assertFileMode(t, filepath.Join(staged, "scripts", "rescue_reset.sh"), 0o755)
assertFileMode(t, filepath.Join(staged, "bin", "rknnovpn-runtime"), 0o750)
}
func TestStageModuleRejectsArchiveMissingRequiredLibs(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "module.zip")
archDir := stagedArchDir()
writeModuleZip(t, zipPath, map[string]string{
"module.prop": "id=rknnovpn\nversion=v2.4.0\n",
"customize.sh": "#!/system/bin/sh\n",
"service.sh": "#!/system/bin/sh\n",
"post-fs-data.sh": "#!/system/bin/sh\n",
"uninstall.sh": "#!/system/bin/sh\n",
"defaults/config.json": "{}\n",
"scripts/dns.sh": "#!/system/bin/sh\n",
"scripts/iptables.sh": "#!/system/bin/sh\n",
"scripts/privacy_guard.sh": "#!/system/bin/sh\n",
"scripts/rescue_reset.sh": "#!/system/bin/sh\n",
"scripts/routing.sh": "#!/system/bin/sh\n",
"scripts/lib/rknnovpn_env.sh": "#!/system/bin/sh\n",
"scripts/lib/rknnovpn_install.sh": "#!/system/bin/sh\n",
"scripts/lib/rknnovpn_installer_flow.sh": "#!/system/bin/sh\n",
filepath.ToSlash(filepath.Join("binaries", archDir, "rknnovpn-runtime")): "runtime",
})
if _, err := stageModuleZip(zipPath, filepath.Join(dir, "modules", "rknnovpn")); err == nil {
t.Fatal("expected archive missing required scripts/lib files to be rejected")
}
}
func TestStageModuleRejectsZipPathTraversal(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "module.zip")
writeModuleZip(t, zipPath, map[string]string{
"module.prop": "id=rknnovpn\n",
"../escape.sh": "bad",
"scripts/ok.sh": "ok",
})
if _, err := stageModuleZip(zipPath, filepath.Join(dir, "modules", "rknnovpn")); err == nil {
t.Fatal("expected path traversal archive to be rejected")
}
if _, err := os.Stat(filepath.Join(dir, "escape.sh")); !os.IsNotExist(err) {
t.Fatalf("path traversal wrote outside update dir, stat err=%v", err)
}
}
func TestParseTestNodeArgs(t *testing.T) {
args, err := parseTestNodeArgs([]string{
"--profile", "/tmp/profile.json",
"--node-id", "a",
"--node-id", "b",
"--url", "https://example.com/generate_204",
"--mode", "tcp",
})
if err != nil {
t.Fatal(err)
}
if args.ProfilePath != "/tmp/profile.json" {
t.Fatalf("profile path = %q", args.ProfilePath)
}
if len(args.NodeIDs) != 2 || args.NodeIDs[0] != "a" || args.NodeIDs[1] != "b" {
t.Fatalf("node ids = %#v", args.NodeIDs)
}
if args.URL != "https://example.com/generate_204" || args.Mode != "tcp" {
t.Fatalf("url/mode = %q/%q", args.URL, args.Mode)
}
}
func TestParseTestNodeArgsRequiresProfile(t *testing.T) {
if _, err := parseTestNodeArgs([]string{"--node-id", "a"}); err == nil {
t.Fatal("expected missing profile to fail")
}
}
func assertJSONShape(t *testing.T, fixtureName string, actual any) {
t.Helper()
expectedRaw, err := os.ReadFile(filepath.Join("..", "..", "testdata", fixtureName))
if err != nil {
t.Fatal(err)
}
actualRaw, err := json.Marshal(actual)
if err != nil {
t.Fatal(err)
}
var expectedValue any
if err := json.Unmarshal(expectedRaw, &expectedValue); err != nil {
t.Fatalf("decode fixture %s: %v", fixtureName, err)
}
var actualValue any
if err := json.Unmarshal(actualRaw, &actualValue); err != nil {
t.Fatalf("decode actual %s: %v", fixtureName, err)
}
compareJSONShape(t, fixtureName, expectedValue, actualValue)
}
func compareJSONShape(t *testing.T, path string, expected any, actual any) {
t.Helper()
expectedType := reflect.TypeOf(expected)
actualType := reflect.TypeOf(actual)
if expectedType != actualType {
t.Fatalf("%s type = %v, want %v", path, actualType, expectedType)
}
switch expectedValue := expected.(type) {
case map[string]any:
actualValue := actual.(map[string]any)
if len(actualValue) != len(expectedValue) {
t.Fatalf("%s key count = %d, want %d; actual=%v expected=%v", path, len(actualValue), len(expectedValue), actualValue, expectedValue)
}
for key, expectedChild := range expectedValue {
actualChild, ok := actualValue[key]
if !ok {
t.Fatalf("%s missing key %q", path, key)
}
compareJSONShape(t, path+"."+key, expectedChild, actualChild)
}
case []any:
actualValue := actual.([]any)
if len(expectedValue) == 0 {
if len(actualValue) != 0 {
t.Fatalf("%s array length = %d, want empty", path, len(actualValue))
}
return
}
if len(actualValue) == 0 {
t.Fatalf("%s array is empty", path)
}
compareJSONShape(t, path+"[0]", expectedValue[0], actualValue[0])
default:
return
}
}
func intPtr(value int) *int {
return &value
}
func int64Ptr(value int64) *int64 {
return &value
}
func writeModuleZip(t *testing.T, path string, files map[string]string) {
t.Helper()
file, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
archive := zip.NewWriter(file)
for name, content := range files {
entry, err := archive.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := entry.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
if err := archive.Close(); err != nil {
t.Fatal(err)
}
}
func assertFileMode(t *testing.T, path string, want os.FileMode) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != want {
t.Fatalf("%s mode = %o, want %o", path, got, want)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}