//go:build !windows package installtests // This file is a deliberately separate qualification packet. The live test is // never part of ordinary `go test`: it mutates users, systemd units, and local // container-runtime state and therefore requires an exact disposable-host // marker plus an explicit opt-in. The host-side wrapper creates the isolated // Ubuntu 24.04 systemd containers and never mounts the host runtime socket. import ( "context" "crypto/sha256" "debug/buildinfo" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http/httptest" "os" "os/exec" "path/filepath" "runtime" "slices" "sort" "strconv" "strings" "syscall" "testing" "time" agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" ) const ( rootlessQualOptIn = "PULSE_SECURE_RUNTIME_ROOTLESS_QUALIFICATION" rootlessQualOptInValue = "disposable-v1" rootlessQualMarker = "/etc/pulse-secure-runtime-rootless-qualification" rootlessQualReceiptPath = "/opt/pulse/result/rootless-receipt.json" rootlessQualResultDir = "/opt/pulse/result" rootlessQualFixture = "pulse-rootless-qualification-fixture:v1" rootlessQualRunningName = "pulse-rootless-running" rootlessQualExitedName = "pulse-rootless-exited" ) var rootlessQualScenarioOrder = []string{ "fresh_install", "legacy_migration", "collector_restart", "daemon_restart", "socket_loss_helper_fallback", "direct_recovery", "dual_socket_ambiguity_refusal", "exact_pin_recovery", "telemetry_parity", "authority_isolation", "cleanup", } type rootlessQualReceipt struct { SchemaVersion int `json:"schema_version"` Kind string `json:"kind"` Result string `json:"result"` SourceCommit string `json:"source_commit"` StartedAt string `json:"started_at"` CompletedAt string `json:"completed_at"` SourceHashes map[string]string `json:"source_hashes"` Artifacts rootlessQualArtifacts `json:"artifacts"` Runs []rootlessQualRun `json:"runs"` } type rootlessQualHost struct { MachineID string `json:"machine_id"` Architecture string `json:"architecture"` Kernel string `json:"kernel"` SystemdVersion string `json:"systemd_version"` } type rootlessQualArtifact struct { PathBasename string `json:"path_basename"` SHA256 string `json:"sha256"` Package string `json:"package"` GoVersion string `json:"go_version"` VCSRevision string `json:"vcs_revision"` VCSModified bool `json:"vcs_modified"` } type rootlessQualInstallerArtifact struct { PathBasename string `json:"path_basename"` SHA256 string `json:"sha256"` } type rootlessQualArtifacts struct { QualificationTest rootlessQualArtifact `json:"qualification_test"` Collector rootlessQualArtifact `json:"collector"` Helper rootlessQualArtifact `json:"helper"` Installer rootlessQualInstallerArtifact `json:"installer"` } type rootlessQualRun struct { Host rootlessQualHost `json:"host"` Runtime rootlessQualRuntime `json:"runtime"` Scenarios []rootlessQualScenario `json:"scenarios"` } type rootlessQualRuntime struct { Runtime string `json:"runtime"` RuntimeVersion string `json:"runtime_version"` DaemonID string `json:"daemon_id"` CollectorUID int `json:"collector_uid"` SocketPath string `json:"socket_path"` SocketUID int `json:"socket_uid"` SocketGID int `json:"socket_gid"` SocketMode string `json:"socket_mode"` DaemonRootless bool `json:"daemon_rootless"` SocketType string `json:"socket_type"` SocketSymlink bool `json:"socket_symlink"` } type rootlessQualScenario struct { Name string `json:"name"` Result string `json:"result"` StartedAt string `json:"started_at"` CompletedAt string `json:"completed_at"` ReportSequence *uint64 `json:"report_sequence"` ReportStreamID *string `json:"report_stream_id"` Evidence map[string]any `json:"evidence"` } type rootlessQualDaemon struct { runtime string rootlessUnit string rootfulUnit string rootlessSock string rootfulSock string uid int home string } func TestSecureRuntimeRootlessQualification(t *testing.T) { if os.Getenv(rootlessQualOptIn) != rootlessQualOptInValue { t.Skip("run through scripts/run-secure-runtime-rootless-qualification.sh inside its disposable systemd container") } runtimeKind := strings.TrimSpace(os.Getenv("PULSE_ROOTLESS_RUNTIME")) receiptPath := strings.TrimSpace(os.Getenv("PULSE_ROOTLESS_RECEIPT")) rootlessQualRequireDisposableHost(t, runtimeKind, receiptPath) collector := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_COLLECTOR") collectorSignature := secureRuntimeReadSignature(t, "PULSE_SECURE_RUNTIME_COLLECTOR_SIGNATURE") helper := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_HELPER") collectorVersion := secureRuntimeArtifactVersion(t, "PULSE_SECURE_RUNTIME_COLLECTOR") installerPath := strings.TrimSpace(os.Getenv("PULSE_SECURE_RUNTIME_INSTALLER")) if !filepath.IsAbs(installerPath) { t.Fatalf("PULSE_SECURE_RUNTIME_INSTALLER must be absolute: %q", installerPath) } started := time.Now().UTC() fixture := newSecureRuntimeLabFixture(collector, collectorSignature, helper, nil, collectorVersion) defer fixture.actionServer.Shutdown() server := httptest.NewServer(fixture) defer server.Close() collectorCredential := secureRuntimeLabToken daemon := rootlessQualPrepareDaemons(t, runtimeKind) defer rootlessQualBestEffortStop(daemon.rootlessUnit, daemon.rootfulUnit, rootlessQualOtherUnit(runtimeKind), fmt.Sprintf("user@%d.service", daemon.uid)) rootlessQualStartRootful(t, daemon) rootlessQualStartRootless(t, daemon) identity := rootlessQualReadIdentityRecord(t, daemon) if err := os.Remove(daemon.rootlessSock + ".qualification-identity"); err != nil { t.Fatalf("remove transient runtime identity sidecar: %v", err) } rootlessQualCreateFixture(t, daemon, false) rootlessQualCreateFixture(t, daemon, true) daemonRootlessObserved := rootlessQualDaemonRootless(t, daemon) if !daemonRootlessObserved { t.Fatalf("%s daemon did not independently report rootless operation", runtimeKind) } rootfulBaseline := rootlessQualRuntimeBaseline(t, daemon, false) rootlessBaseline := rootlessQualRuntimeBaseline(t, daemon, true) if rootfulBaseline.SemanticDigest != rootlessBaseline.SemanticDigest || rootfulBaseline.Count != 2 { t.Fatalf("separate same-family baselines differ: rootful=%+v rootless=%+v", rootfulBaseline, rootlessBaseline) } var scenarios []rootlessQualScenario appendScenario := func(name string, began time.Time, report *agentsdocker.Report, evidence map[string]any) { scenario := rootlessQualScenario{Name: name, Result: "passed", StartedAt: began.Format(time.RFC3339Nano), CompletedAt: time.Now().UTC().Format(time.RFC3339Nano), Evidence: evidence} if report != nil { stream, sequence, ok := agentshost.ParseReportSequenceID(report.SequenceID) if !ok { t.Fatalf("scenario %s received invalid sequence ID %q", name, report.SequenceID) } scenario.ReportSequence = &sequence scenario.ReportStreamID = &stream } scenarios = append(scenarios, scenario) } // Prove a fresh safe-profile install first, then remove it before creating // the legacy root profile. Both paths therefore begin without installed // Pulse files; the surrounding outer container itself is also fresh. freshStarted := time.Now().UTC() secureRuntimeRunInstaller(t, installerPath, server.URL, "--least-privilege", "--enable-privileged-helper", "--enable-docker") freshReport := rootlessQualWaitDirect(t, fixture, freshStarted, runtimeKind, rootlessBaseline.SemanticDigest, 75*time.Second) secureRuntimeAssertSafeProfile(t) secureRuntimeAssertHelperProtocol(t) freshDigest := rootlessQualDigestReport(freshReport.Report) freshPID := secureRuntimeCollectorMainPID(t) daemonIDBefore := rootlessQualDaemonID(t, daemon, true) appendScenario("fresh_install", freshStarted, &freshReport.Report, rootlessQualDirectEvidence(daemon, identity, freshPID, daemonIDBefore, daemonRootlessObserved, freshDigest)) rootlessQualUninstallPulse(t, installerPath, server.URL, collectorCredential) rootlessQualAssertPulseRemoved(t) if registered, revoked, uninstalls := fixture.collectorLifecycleSnapshot(); registered || !revoked || uninstalls != 1 { t.Fatalf("fresh collector uninstall was not durably modeled: registered=%t revoked=%t uninstalls=%d", registered, revoked, uninstalls) } fixture.replaceCollectorCredential(secureRuntimeLabTokenV2, secureRuntimeCollectorBindingV2) collectorCredential = secureRuntimeLabTokenV2 migrationStarted := time.Now().UTC() secureRuntimeRunInstallerWithCollectorCredential(t, installerPath, server.URL, collectorCredential, "--enable-commands", "--command-authority", "command-capable", "--enable-docker") legacyReport := rootlessQualWaitReport(t, fixture, migrationStarted, 75*time.Second, func(report agentsdocker.Report) bool { return rootlessQualComplete(report) && report.Host.CollectionMode == "" && report.Host.Runtime == runtimeKind }) secureRuntimeAssertRootCommandProfile(t) legacyPID := secureRuntimeCollectorMainPID(t) legacyDigest := rootlessQualSemanticDigest(legacyReport.Report) if legacyDigest != rootfulBaseline.SemanticDigest { t.Fatalf("legacy root report differs from the separate rootful runtime baseline: report=%s baseline=%s", legacyDigest, rootfulBaseline.SemanticDigest) } applyStarted := time.Now().UTC() secureRuntimeRunInstallerWithCollectorCredential(t, installerPath, server.URL, collectorCredential, "--safe-profile-apply") migratedReport := rootlessQualWaitDirect(t, fixture, applyStarted, runtimeKind, rootlessBaseline.SemanticDigest, 75*time.Second) secureRuntimeAssertSafeProfile(t) secureRuntimeAssertHelperProtocol(t) migratedDigest := rootlessQualDigestReport(migratedReport.Report) if freshDigest.SemanticDigest != rootlessBaseline.SemanticDigest || migratedDigest.SemanticDigest != rootlessBaseline.SemanticDigest { t.Fatalf("direct report semantic parity failed: baseline=%s fresh=%s migration=%s", rootlessBaseline.SemanticDigest, freshDigest.SemanticDigest, migratedDigest.SemanticDigest) } migratedPID := secureRuntimeCollectorMainPID(t) if migratedPID == legacyPID { t.Fatalf("safe-profile migration did not replace legacy collector PID %d", legacyPID) } migrationEvidence := rootlessQualDirectEvidence(daemon, identity, migratedPID, daemonIDBefore, daemonRootlessObserved, migratedDigest) migrationEvidence["legacy_profile"] = "root-command-capable" migrationEvidence["target_profile"] = "typed-helper-monitoring-only" migrationEvidence["authority_reduced"] = true migrationEvidence["legacy_collector_pid"] = legacyPID appendScenario("legacy_migration", migrationStarted, &migratedReport.Report, migrationEvidence) restartStarted := time.Now().UTC() collectorPIDBefore := migratedPID streamBefore, _, _ := agentshost.ParseReportSequenceID(migratedReport.Report.SequenceID) secureRuntimeCommand(t, 20*time.Second, "systemctl", "restart", "pulse-agent.service") collectorPIDAfter := secureRuntimeCollectorMainPID(t) restartedCollectorReport := rootlessQualWaitDirect(t, fixture, restartStarted, runtimeKind, rootlessBaseline.SemanticDigest, 75*time.Second) streamAfter, _, _ := agentshost.ParseReportSequenceID(restartedCollectorReport.Report.SequenceID) restartedCollectorDigest := rootlessQualDigestReport(restartedCollectorReport.Report) if collectorPIDAfter == collectorPIDBefore || streamAfter == streamBefore || restartedCollectorDigest.SemanticDigest != rootlessBaseline.SemanticDigest { t.Fatalf("collector restart did not replace process/stream with parity: pid=%d/%d stream=%s/%s", collectorPIDBefore, collectorPIDAfter, streamBefore, streamAfter) } restartEvidence := rootlessQualDirectEvidence(daemon, identity, collectorPIDAfter, daemonIDBefore, daemonRootlessObserved, restartedCollectorDigest) restartEvidence["previous_collector_pid"] = collectorPIDBefore restartEvidence["previous_report_stream_id"] = streamBefore appendScenario("collector_restart", restartStarted, &restartedCollectorReport.Report, restartEvidence) daemonRestartStarted := time.Now().UTC() daemonPIDBefore, invocationBefore := rootlessQualUnitIdentity(t, daemon.rootlessUnit) rootlessQualStopUnit(t, daemon.rootlessUnit) rootlessQualStartRootless(t, daemon) daemonPIDAfter, invocationAfter := rootlessQualUnitIdentity(t, daemon.rootlessUnit) daemonIDAfter := rootlessQualDaemonID(t, daemon, true) daemonRootlessAfterRestart := rootlessQualDaemonRootless(t, daemon) daemonRestartReport := rootlessQualWaitDirect(t, fixture, daemonRestartStarted, runtimeKind, rootlessBaseline.SemanticDigest, 75*time.Second) daemonRestartDigest := rootlessQualDigestReport(daemonRestartReport.Report) if daemonPIDBefore == daemonPIDAfter || invocationBefore == invocationAfter || daemonIDBefore != daemonIDAfter || daemonRestartDigest.SemanticDigest != rootlessBaseline.SemanticDigest { t.Fatalf("daemon restart identity/parity mismatch: pid=%d/%d invocation=%s/%s daemon=%s/%s", daemonPIDBefore, daemonPIDAfter, invocationBefore, invocationAfter, daemonIDBefore, daemonIDAfter) } daemonEvidence := rootlessQualDirectEvidence(daemon, identity, collectorPIDAfter, daemonIDAfter, daemonRootlessAfterRestart, daemonRestartDigest) daemonEvidence["previous_daemon_pid"] = daemonPIDBefore daemonEvidence["daemon_pid"] = daemonPIDAfter daemonEvidence["previous_daemon_invocation_id"] = invocationBefore daemonEvidence["daemon_invocation_id"] = invocationAfter appendScenario("daemon_restart", daemonRestartStarted, &daemonRestartReport.Report, daemonEvidence) lossStarted := time.Now().UTC() rootlessQualStopUnit(t, daemon.rootlessUnit) helperReport := rootlessQualWaitReport(t, fixture, lossStarted, 90*time.Second, func(report agentsdocker.Report) bool { return rootlessQualComplete(report) && report.Host.CollectionMode == agentsdocker.CollectionModeTypedHelperSummary && report.Host.Runtime == runtimeKind }) rootlessQualAssertHelperSummaryOnly(t, helperReport.Report, runtimeKind) helperDigest := rootlessQualDigestReport(helperReport.Report) if helperDigest.SemanticDigest != rootfulBaseline.SemanticDigest || secureRuntimeCollectorMainPID(t) != collectorPIDAfter { t.Fatalf("typed-helper fallback did not preserve rootful semantic baseline/collector PID") } appendScenario("socket_loss_helper_fallback", lossStarted, &helperReport.Report, map[string]any{ "collector_pid": collectorPIDAfter, "collection_mode": agentsdocker.CollectionModeTypedHelperSummary, "direct_runtime_available": false, "helper_fallback": true, "inventory_complete": true, "inventory_count": helperDigest.Count, "rootful_baseline_inventory_count": rootfulBaseline.Count, "semantic_sha256": helperDigest.SemanticDigest, "rootful_baseline_semantic_sha256": rootfulBaseline.SemanticDigest, "full_fields_present": false, "stats_present": false, "secondary_structure_sha256": "", "container_actions_enabled": false, "container_updates_enabled": false, "collector_restart_count": 0, }) recoveryStarted := time.Now().UTC() rootlessQualStartRootless(t, daemon) recoveryReport := rootlessQualWaitStableDirect(t, fixture, recoveryStarted, runtimeKind, rootlessBaseline.SemanticDigest, daemonRestartDigest, 90*time.Second) recoveryDigest := rootlessQualDigestReport(recoveryReport.Report) daemonRootlessAfterRecovery := rootlessQualDaemonRootless(t, daemon) if !rootlessQualStableDigestEqual(recoveryDigest, daemonRestartDigest) || secureRuntimeCollectorMainPID(t) != collectorPIDAfter || rootlessQualDaemonID(t, daemon, true) != daemonIDBefore { t.Fatalf("direct recovery did not restore the exact prior rootless telemetry/identity") } appendScenario("direct_recovery", recoveryStarted, &recoveryReport.Report, rootlessQualDirectEvidence(daemon, identity, collectorPIDAfter, daemonIDBefore, daemonRootlessAfterRecovery, recoveryDigest)) ambiguityStarted := time.Now().UTC() otherUnit, otherSocket := rootlessQualStartOtherRootless(t, daemon) defer rootlessQualBestEffortStop(otherUnit) if _, err := os.Lstat(otherSocket); err != nil { t.Fatalf("second live rootless socket missing: %v", err) } liveSockets := rootlessQualDualSocketEvidence(t, daemon.uid) secureRuntimeAssertHelperProtocol(t) ambiguityOutput := rootlessQualRunUnpinnedCollector(t, server.URL, collectorCredential, 6*time.Second) if !strings.Contains(strings.ToLower(ambiguityOutput), "ambiguous collector-owned rootless runtime endpoints") { t.Fatalf("unpinned collector did not fail closed on dual sockets:\n%s", ambiguityOutput) } appendScenario("dual_socket_ambiguity_refusal", ambiguityStarted, nil, map[string]any{ "protected_collector_pid": collectorPIDAfter, "live_sockets": liveSockets, "probe_kind": "separate-unpinned-collector", "admission_refused": true, "fail_closed": true, "daemon_probe_count": 0, "container_actions_enabled": false, "collector_restart_count": 0, }) rootlessQualStopUnit(t, otherUnit) pinStarted := time.Now().UTC() rootlessQualStopUnit(t, daemon.rootlessUnit) unitBefore := rootlessQualServicePin(t, runtimeKind) pinFallback := rootlessQualWaitReport(t, fixture, pinStarted, 90*time.Second, func(report agentsdocker.Report) bool { return rootlessQualComplete(report) && report.Host.CollectionMode == agentsdocker.CollectionModeTypedHelperSummary && report.Host.Runtime == runtimeKind }) previousUpdateStream, _, _ := agentshost.ParseReportSequenceID(pinFallback.Report.SequenceID) updateStarted := time.Now().UTC() secureRuntimeRunInstallerWithCollectorCredential(t, installerPath, server.URL, collectorCredential, "--update") unitWhileAbsent := rootlessQualServicePin(t, runtimeKind) if unitBefore != daemon.rootlessSock || unitWhileAbsent != unitBefore { t.Fatalf("offline update lost exact rootless pin: before=%q after=%q", unitBefore, unitWhileAbsent) } collectorPIDAfterUpdate := secureRuntimeCollectorMainPID(t) postUpdateFallback := rootlessQualWaitReport(t, fixture, updateStarted, 90*time.Second, func(report agentsdocker.Report) bool { stream, _, ok := agentshost.ParseReportSequenceID(report.SequenceID) return ok && stream != previousUpdateStream && rootlessQualComplete(report) && report.Host.CollectionMode == agentsdocker.CollectionModeTypedHelperSummary && report.Host.Runtime == runtimeKind }) rootlessQualStartRootless(t, daemon) pinReport := rootlessQualWaitStableDirect(t, fixture, postUpdateFallback.ReceivedAt, runtimeKind, rootlessBaseline.SemanticDigest, recoveryDigest, 90*time.Second) pinDigest := rootlessQualDigestReport(pinReport.Report) daemonRootlessAfterPinRecovery := rootlessQualDaemonRootless(t, daemon) if !rootlessQualStableDigestEqual(pinDigest, recoveryDigest) || rootlessQualServicePin(t, runtimeKind) != daemon.rootlessSock { t.Fatalf("exact pin recovery did not return to the same endpoint and telemetry") } pinEvidence := rootlessQualDirectEvidence(daemon, identity, collectorPIDAfterUpdate, daemonIDBefore, daemonRootlessAfterPinRecovery, pinDigest) _, pinFallbackSequence, _ := agentshost.ParseReportSequenceID(postUpdateFallback.Report.SequenceID) _, pinRecoverySequence, _ := agentshost.ParseReportSequenceID(pinReport.Report.SequenceID) pinEvidence["previous_collector_pid"] = collectorPIDAfter pinEvidence["previous_report_stream_id"] = previousUpdateStream pinEvidence["pin_source"] = "root-owned-systemd-unit" pinEvidence["pinned_socket_path"] = unitBefore pinEvidence["socket_absent_observed"] = true pinEvidence["fallback_report_sequence"] = pinFallbackSequence pinEvidence["recovery_report_sequence"] = pinRecoverySequence pinEvidence["recovered_socket_path"] = daemon.rootlessSock pinEvidence["selected_socket_path"] = rootlessQualServicePin(t, runtimeKind) pinEvidence["recovered_socket_uid"] = identity.SocketUID pinEvidence["recovered_socket_gid"] = identity.SocketGID pinEvidence["recovered_socket_mode"] = identity.SocketMode pinEvidence["recovered_socket_type"] = "unix" pinEvidence["recovered_socket_symlink"] = false pinEvidence["candidate_count"] = 1 pinEvidence["daemon_probe_count"] = 1 pinEvidence["collector_restart_count"] = 1 appendScenario("exact_pin_recovery", pinStarted, &pinReport.Report, pinEvidence) parityStarted := time.Now().UTC() parityReport := rootlessQualWaitDirect(t, fixture, parityStarted, runtimeKind, rootlessBaseline.SemanticDigest, 75*time.Second) parityDigest := rootlessQualDigestReport(parityReport.Report) appendScenario("telemetry_parity", parityStarted, &parityReport.Report, map[string]any{ "collector_pid": collectorPIDAfterUpdate, "baseline_kind": "root-client-same-rootless-daemon", "baseline_inventory_count": rootlessBaseline.Count, "collector_inventory_count": parityDigest.Count, "baseline_semantic_sha256": rootlessBaseline.SemanticDigest, "collector_semantic_sha256": parityDigest.SemanticDigest, "collector_full_fields_present": parityDigest.FullFieldsPresent, "collector_stats_present": parityDigest.StatsPresent, "collector_secondary_inventory_present": parityDigest.SecondaryInventoryPresent, }) authorityStarted := time.Now().UTC() collectorUID := rootlessQualUID(t, "pulse-agent") rootfulDenied := rootlessQualRootfulAccessDenied(t, daemon) groups := strings.Fields(rootlessQualCommand(t, 10*time.Second, "id", "-nG", "pulse-agent")) for _, group := range groups { if group == "docker" || group == "podman" { t.Fatalf("collector retained daemon group %q", group) } } if !rootfulDenied || secureRuntimeCollectorProcessUID(t) != collectorUID || secureRuntimeCollectorHasArgument("--enable-commands") { t.Fatal("authority isolation did not remain exact") } helperNetworkDenied := rootlessQualAssertHelperNetworkDenied(t) commandSessionPresent := fixture.actionServer.IsAgentConnectedForOrganization(secureRuntimeLabOrgID, secureRuntimeLabAgentID) if !helperNetworkDenied || commandSessionPresent || fixture.authorityReductionCount() < 1 { t.Fatalf("authority evidence mismatch: helper_network_denied=%t command_session=%t reductions=%d", helperNetworkDenied, commandSessionPresent, fixture.authorityReductionCount()) } appendScenario("authority_isolation", authorityStarted, nil, map[string]any{ "collector_pid": collectorPIDAfterUpdate, "collector_uid": collectorUID, "effective_uid": collectorUID, "effective_root": false, "safe_profile_enabled": true, "commands_enabled": false, "privileged_helper_enabled": true, "reduction_request_observed": true, "collector_command_transport_present": false, "collector_command_session_present": false, "container_actions_enabled": false, "container_updates_enabled": false, "rootful_socket_access": false, "helper_network_access": false, }) cleanupStarted := time.Now().UTC() rootlessQualUninstallPulse(t, installerPath, server.URL, collectorCredential) if registered, revoked, uninstalls := fixture.collectorLifecycleSnapshot(); registered || !revoked || uninstalls != 2 { t.Fatalf("final collector uninstall was not durably modeled: registered=%t revoked=%t uninstalls=%d", registered, revoked, uninstalls) } rootlessQualRemoveFixtures(t, daemon) rootlessQualStopUnit(t, daemon.rootlessUnit) rootlessQualStopUnit(t, daemon.rootfulUnit) rootlessQualResetRuntimeStorage(t, daemon) rootlessQualStopUserManager(t, daemon) rootlessQualWaitRuntimeMountsReleased(t, daemon, 30*time.Second) rootlessQualRemoveRuntimeState(t, daemon) rootlessQualAssertPulseRemoved(t) for _, socket := range []string{daemon.rootlessSock, daemon.rootfulSock} { if _, err := os.Lstat(socket); !errors.Is(err, os.ErrNotExist) { t.Fatalf("cleanup left socket %s: %v", socket, err) } } userStateClean := rootlessQualUserStateClean(daemon) if !userStateClean { t.Fatal("cleanup left dedicated rootless runtime state") } appendScenario("cleanup", cleanupStarted, nil, map[string]any{ "runtime_stopped": true, "socket_absent": true, "fixtures_removed": true, "user_state_clean": userStateClean, }) artifacts := rootlessQualArtifactIdentities(t, installerPath) sourceHashes := rootlessQualSourceHashes(t) receipt := rootlessQualReceipt{ SchemaVersion: 1, Kind: "pulse-secure-runtime-rootless-qualification", Result: "passed", SourceCommit: strings.TrimSpace(os.Getenv("PULSE_ROOTLESS_SOURCE_COMMIT")), StartedAt: started.Format(time.RFC3339Nano), CompletedAt: time.Now().UTC().Format(time.RFC3339Nano), SourceHashes: sourceHashes, Artifacts: artifacts, Runs: []rootlessQualRun{{ Host: rootlessQualHost{ MachineID: strings.TrimSpace(string(rootlessQualReadFile(t, "/etc/machine-id"))), Architecture: runtime.GOARCH, Kernel: rootlessQualCommand(t, 10*time.Second, "uname", "-srvmo"), SystemdVersion: strings.SplitN(rootlessQualCommand(t, 10*time.Second, "systemctl", "--version"), "\n", 2)[0], }, Runtime: rootlessQualRuntime{ Runtime: runtimeKind, RuntimeVersion: identity.RuntimeVersion, DaemonID: daemonIDBefore, CollectorUID: daemon.uid, SocketPath: daemon.rootlessSock, SocketUID: identity.SocketUID, SocketGID: identity.SocketGID, SocketMode: identity.SocketMode, DaemonRootless: daemonRootlessObserved, SocketType: "unix", SocketSymlink: false, }, Scenarios: scenarios, }}, } if err := rootlessQualValidateReceipt(receipt, 1); err != nil { t.Fatalf("generated receipt failed validation: %v", err) } rootlessQualWriteJSON(t, receiptPath, receipt) } type rootlessQualBaseline struct { Count int SemanticDigest string } type rootlessQualReportDigest struct { Count int InventoryDigest string SemanticDigest string StatsDigest string StatsPresent bool FullFieldsPresent bool SecondaryDigest string SecondaryInventoryPresent bool } type rootlessQualContainerSemantic struct { Name string Image string State string } type rootlessQualIdentity struct { RuntimeVersion string `json:"runtime_version"` SocketUID int `json:"socket_uid"` SocketGID int `json:"socket_gid"` SocketMode string `json:"socket_mode"` } func rootlessQualDirectEvidence(d rootlessQualDaemon, identity rootlessQualIdentity, collectorPID int, daemonID string, daemonRootless bool, digest rootlessQualReportDigest) map[string]any { return map[string]any{ "collector_pid": collectorPID, "service_pid": collectorPID, "collection_path": "collector-owned-rootless-socket", "inventory_complete": true, "inventory_count": digest.Count, "semantic_sha256": digest.SemanticDigest, "full_fields_present": digest.FullFieldsPresent, "stats_present": digest.StatsPresent, "secondary_structure_sha256": digest.SecondaryDigest, "daemon_id": daemonID, "daemon_rootless": daemonRootless, "socket_path": d.rootlessSock, "socket_uid": identity.SocketUID, "socket_gid": identity.SocketGID, "socket_mode": identity.SocketMode, "socket_type": "unix", "socket_symlink": false, } } func rootlessQualRequireDisposableHost(t *testing.T, runtimeKind, receiptPath string) { t.Helper() if os.Geteuid() != 0 || (runtimeKind != "docker" && runtimeKind != "podman") { t.Fatalf("qualification requires root and PULSE_ROOTLESS_RUNTIME=docker|podman") } marker, err := os.ReadFile(rootlessQualMarker) if err != nil || strings.TrimSpace(string(marker)) != rootlessQualOptInValue { t.Fatalf("disposable marker is absent or invalid: %v", err) } if receiptPath != rootlessQualReceiptPath { t.Fatalf("PULSE_ROOTLESS_RECEIPT must use the dedicated durable output path %q: %q", rootlessQualReceiptPath, receiptPath) } resultInfo, err := os.Lstat(rootlessQualResultDir) if err != nil { t.Fatalf("inspect rootless qualification result directory: %v", err) } resultStat, ok := resultInfo.Sys().(*syscall.Stat_t) if !ok || !resultInfo.IsDir() || resultInfo.Mode()&os.ModeSymlink != 0 || resultInfo.Mode().Perm() != 0o700 || resultStat.Uid != 0 { t.Fatalf("rootless qualification result directory must be a root-owned, non-symlink directory with mode 0700: mode=%s stat=%#v", resultInfo.Mode(), resultInfo.Sys()) } osRelease := string(rootlessQualReadFile(t, "/etc/os-release")) if !strings.Contains(osRelease, "VERSION_ID=\"24.04\"") && !strings.Contains(osRelease, "VERSION_ID=24.04") { t.Fatal("qualification host is not Ubuntu 24.04") } if _, err := os.Stat("/run/systemd/system"); err != nil { t.Fatalf("qualification host is not booted under systemd: %v", err) } if routes := string(rootlessQualReadFile(t, "/proc/net/route")); rootlessQualHasDefaultRoute(routes) { t.Fatal("qualification workload must run with outer-container networking disabled") } } func rootlessQualHasDefaultRoute(routes string) bool { for _, line := range strings.Split(routes, "\n") { fields := strings.Fields(line) if len(fields) >= 2 && fields[1] == "00000000" { return true } } return false } func rootlessQualPrepareDaemons(t *testing.T, runtimeKind string) rootlessQualDaemon { t.Helper() if _, err := exec.Command("id", "-u", "pulse-agent").Output(); err != nil { rootlessQualCommand(t, 10*time.Second, "useradd", "--system", "--create-home", "--home-dir", "/var/lib/pulse-rootless", "--shell", "/usr/sbin/nologin", "pulse-agent") } uid := rootlessQualUID(t, "pulse-agent") home := "/var/lib/pulse-rootless" rootlessQualWriteSubID(t, "/etc/subuid", "pulse-agent:100000:65536") rootlessQualWriteSubID(t, "/etc/subgid", "pulse-agent:100000:65536") paths := []string{filepath.Join("/run/user", strconv.Itoa(uid)), filepath.Join(home, "fixture"), filepath.Join(home, "docker"), filepath.Join(home, "podman")} for _, path := range paths { rootlessQualCommand(t, 10*time.Second, "install", "-d", "-o", "pulse-agent", "-g", "pulse-agent", "-m", "0700", path) } rootlessQualCommand(t, 10*time.Second, "loginctl", "enable-linger", "pulse-agent") userUnit := fmt.Sprintf("user@%d.service", uid) rootlessQualCommand(t, 20*time.Second, "systemctl", "start", userUnit) active := rootlessQualCommand(t, 10*time.Second, "systemctl", "show", userUnit, "--property=ActiveState", "--value") delegated := rootlessQualCommand(t, 10*time.Second, "systemctl", "show", userUnit, "--property=Delegate", "--value") controlGroup := rootlessQualCommand(t, 10*time.Second, "systemctl", "show", userUnit, "--property=ControlGroup", "--value") expectedControlGroup := fmt.Sprintf("/user.slice/user-%d.slice/user@%d.service", uid, uid) if active != "active" || delegated != "yes" || controlGroup != expectedControlGroup { t.Fatalf("rootless user manager is not exactly delegated: active=%q delegate=%q control_group=%q", active, delegated, controlGroup) } containerfile := "FROM scratch\nCOPY busybox /busybox\nENTRYPOINT [\"/busybox\"]\n" if err := os.WriteFile(filepath.Join(home, "fixture", "Containerfile"), []byte(containerfile), 0o600); err != nil { t.Fatal(err) } busybox, err := os.ReadFile("/bin/busybox") if err != nil { t.Fatalf("read offline busybox fixture: %v", err) } if err := os.WriteFile(filepath.Join(home, "fixture", "busybox"), busybox, 0o755); err != nil { t.Fatal(err) } rootlessQualCommand(t, 10*time.Second, "chown", "-R", "pulse-agent:pulse-agent", home) d := rootlessQualDaemon{runtime: runtimeKind, uid: uid, home: home, rootlessUnit: "pulse-rootless-" + runtimeKind, rootfulUnit: "pulse-rootful-" + runtimeKind} if runtimeKind == "docker" { d.rootlessSock = filepath.Join("/run/user", strconv.Itoa(uid), "docker.sock") d.rootfulSock = "/var/run/docker.sock" } else { d.rootlessSock = filepath.Join("/run/user", strconv.Itoa(uid), "podman", "podman.sock") d.rootfulSock = "/run/podman/podman.sock" } return d } func rootlessQualStartRootless(t *testing.T, d rootlessQualDaemon) { t.Helper() rootlessQualBestEffortStop(d.rootlessUnit) if d.runtime == "docker" { rootlessQualCommand(t, 20*time.Second, "systemd-run", rootlessQualDockerStartArgs(d)...) } else { rootlessQualCommand(t, 20*time.Second, "install", "-d", "-o", "pulse-agent", "-g", "pulse-agent", "-m", "0700", filepath.Dir(d.rootlessSock)) rootlessQualCommand(t, 20*time.Second, "systemd-run", "--quiet", "--collect", "--unit", d.rootlessUnit, "--property=Type=exec", "--property=User=pulse-agent", "--property=Group=pulse-agent", "--", "/usr/bin/env", "HOME="+d.home, "XDG_RUNTIME_DIR="+filepath.Join("/run/user", strconv.Itoa(d.uid)), "/usr/bin/podman", "system", "service", "--time=0", "unix://"+d.rootlessSock) } rootlessQualWaitSocket(t, d.rootlessSock) rootlessQualRuntimePing(t, d, true) rootlessQualCaptureIdentity(t, d) } func rootlessQualDockerStartArgs(d rootlessQualDaemon) []string { return []string{"--quiet", "--collect", "--unit", d.rootlessUnit, "--property=Type=exec", "--property=User=pulse-agent", "--property=Group=pulse-agent", "--", "/usr/bin/env", "HOME=" + d.home, "XDG_RUNTIME_DIR=" + filepath.Dir(d.rootlessSock), "DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns", "DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=none", "/usr/bin/dockerd-rootless.sh", "--host=unix://" + d.rootlessSock, "--data-root=" + filepath.Join(d.home, "docker", "data"), "--exec-root=" + filepath.Join(d.home, "docker", "exec"), "--pidfile=" + filepath.Join(d.home, "docker", "dockerd.pid"), "--storage-driver=vfs", "--iptables=false", "--bridge=none"} } func rootlessQualStartRootful(t *testing.T, d rootlessQualDaemon) { t.Helper() rootlessQualBestEffortStop(d.rootfulUnit) rootlessQualCommand(t, 10*time.Second, "install", "-d", "-o", "root", "-g", "root", "-m", "0755", filepath.Dir(d.rootfulSock)) if d.runtime == "docker" { rootlessQualCommand(t, 20*time.Second, "systemd-run", "--quiet", "--collect", "--unit", d.rootfulUnit, "--property=Type=exec", "--", "/usr/bin/dockerd", "--host=unix://"+d.rootfulSock, "--data-root=/var/lib/pulse-rootful-docker", "--exec-root=/run/pulse-rootful-docker", "--pidfile=/run/pulse-rootful-docker.pid", "--storage-driver=vfs", "--iptables=false", "--bridge=none") } else { rootlessQualCommand(t, 20*time.Second, "systemd-run", "--quiet", "--collect", "--unit", d.rootfulUnit, "--property=Type=exec", "--", "/usr/bin/podman", "system", "service", "--time=0", "unix://"+d.rootfulSock) } rootlessQualWaitSocket(t, d.rootfulSock) rootlessQualCommand(t, 10*time.Second, "chmod", "0660", d.rootfulSock) rootlessQualRuntimePing(t, d, false) } func rootlessQualStartOtherRootless(t *testing.T, d rootlessQualDaemon) (string, string) { t.Helper() other := d if d.runtime == "docker" { other.runtime, other.rootlessUnit = "podman", "pulse-rootless-podman-ambiguity" other.rootlessSock = filepath.Join("/run/user", strconv.Itoa(d.uid), "podman", "podman.sock") } else { other.runtime, other.rootlessUnit = "docker", "pulse-rootless-docker-ambiguity" other.rootlessSock = filepath.Join("/run/user", strconv.Itoa(d.uid), "docker.sock") } rootlessQualStartRootless(t, other) return other.rootlessUnit, other.rootlessSock } func rootlessQualOtherUnit(runtimeKind string) string { if runtimeKind == "docker" { return "pulse-rootless-podman-ambiguity" } return "pulse-rootless-docker-ambiguity" } func rootlessQualCreateFixture(t *testing.T, d rootlessQualDaemon, rootless bool) { t.Helper() cli := rootlessQualCLI(d, rootless) rootlessQualCommand(t, 2*time.Minute, cli[0], append(cli[1:], "build", "--network=none", "-t", rootlessQualFixture, "-f", filepath.Join(d.home, "fixture", "Containerfile"), filepath.Join(d.home, "fixture"))...) rootlessQualCommand(t, 30*time.Second, cli[0], append(cli[1:], rootlessQualRunningFixtureArgs()...)...) if out, err := rootlessQualCommandError(30*time.Second, cli[0], append(cli[1:], "run", "--name", rootlessQualExitedName, rootlessQualFixture, "true")...); err != nil { t.Fatalf("create exited fixture: %v\n%s", err, out) } } func rootlessQualRunningFixtureArgs() []string { return []string{"run", "-d", "--restart=always", "--name", rootlessQualRunningName, rootlessQualFixture, "sleep", "3600"} } func rootlessQualCLI(d rootlessQualDaemon, rootless bool) []string { if d.runtime == "docker" { host := d.rootfulSock if rootless { host = d.rootlessSock return []string{"runuser", "-u", "pulse-agent", "--", "env", "HOME=" + d.home, "XDG_RUNTIME_DIR=" + filepath.Dir(host), "DOCKER_HOST=unix://" + host, "docker"} } return []string{"docker", "--host", "unix://" + host} } host := d.rootfulSock if rootless { host = d.rootlessSock return []string{"runuser", "-u", "pulse-agent", "--", "env", "HOME=" + d.home, "XDG_RUNTIME_DIR=" + filepath.Join("/run/user", strconv.Itoa(d.uid)), "podman", "--url", "unix://" + host} } return []string{"podman", "--url", "unix://" + host} } func rootlessQualRuntimePing(t *testing.T, d rootlessQualDaemon, rootless bool) { t.Helper() cli := rootlessQualCLI(d, rootless) rootlessQualCommand(t, 30*time.Second, cli[0], append(cli[1:], "info")...) } func rootlessQualRuntimeBaseline(t *testing.T, d rootlessQualDaemon, rootless bool) rootlessQualBaseline { t.Helper() cli := rootlessQualCLI(d, rootless) format := "{{.Names}}|{{.Image}}|{{.State}}" out := rootlessQualCommand(t, 30*time.Second, cli[0], append(cli[1:], "ps", "-a", "--format", format)...) return rootlessQualBaselineFromPSOutput(out) } func rootlessQualBaselineFromPSOutput(out string) rootlessQualBaseline { lines := strings.Split(strings.TrimSpace(out), "\n") var normalized []rootlessQualContainerSemantic for _, line := range lines { parts := strings.SplitN(strings.TrimSpace(line), "|", 3) if len(parts) == 3 { normalized = append(normalized, rootlessQualContainerSemantic{ Name: strings.TrimSpace(strings.TrimPrefix(parts[0], "/")), Image: strings.TrimSpace(parts[1]), State: strings.TrimSpace(parts[2]), }) } } sort.Slice(normalized, func(i, j int) bool { return normalized[i].Name < normalized[j].Name }) return rootlessQualBaseline{Count: len(normalized), SemanticDigest: rootlessQualHashJSON(normalized)} } func rootlessQualWaitDirect(t *testing.T, fixture *secureRuntimeLabFixture, after time.Time, runtimeKind, semanticDigest string, timeout time.Duration) secureRuntimeDockerReport { t.Helper() return rootlessQualWaitReport(t, fixture, after, timeout, func(report agentsdocker.Report) bool { return rootlessQualDirectReportMatches(report, runtimeKind, semanticDigest) }) } func rootlessQualWaitStableDirect(t *testing.T, fixture *secureRuntimeLabFixture, after time.Time, runtimeKind, semanticDigest string, stableDigest rootlessQualReportDigest, timeout time.Duration) secureRuntimeDockerReport { t.Helper() return rootlessQualWaitReport(t, fixture, after, timeout, func(report agentsdocker.Report) bool { return rootlessQualDirectReportMatches(report, runtimeKind, semanticDigest) && rootlessQualStableDigestEqual(rootlessQualDigestReport(report), stableDigest) }) } func rootlessQualDirectReportMatches(report agentsdocker.Report, runtimeKind, semanticDigest string) bool { return rootlessQualComplete(report) && report.Host.CollectionMode != agentsdocker.CollectionModeTypedHelperSummary && report.Host.Runtime == runtimeKind && len(report.Containers) == 2 && rootlessQualSemanticDigest(report) == semanticDigest } func rootlessQualWaitReport(t *testing.T, fixture *secureRuntimeLabFixture, after time.Time, timeout time.Duration, predicate func(agentsdocker.Report) bool) secureRuntimeDockerReport { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { for _, candidate := range fixture.dockerSnapshot() { if candidate.ReceivedAt.After(after) && predicate(candidate.Report) { return candidate } } time.Sleep(200 * time.Millisecond) } t.Fatalf("timed out waiting for container report after %s", after.Format(time.RFC3339Nano)) return secureRuntimeDockerReport{} } func rootlessQualComplete(report agentsdocker.Report) bool { return report.InventoryComplete == nil || *report.InventoryComplete } func rootlessQualAssertHelperSummaryOnly(t *testing.T, report agentsdocker.Report, runtimeKind string) { t.Helper() if report.InventoryComplete == nil || !*report.InventoryComplete { t.Fatal("typed-helper runtime summary did not declare complete inventory") } if report.Host.Runtime != runtimeKind || report.Host.CollectionMode != agentsdocker.CollectionModeTypedHelperSummary { t.Fatalf("typed-helper collection posture = runtime:%q mode:%q", report.Host.Runtime, report.Host.CollectionMode) } if len(report.Images) != 0 || len(report.Volumes) != 0 || len(report.Networks) != 0 || len(report.Services) != 0 || len(report.Tasks) != 0 || len(report.Nodes) != 0 || len(report.Secrets) != 0 || len(report.Configs) != 0 || report.StorageUsage != nil { t.Fatal("typed-helper runtime summary fabricated unsupported secondary inventories") } for _, container := range report.Containers { if container.ImageDigest != "" || container.Health != "" || len(container.HealthcheckTargets) != 0 || container.CPUPercent != 0 || container.MemoryUsageBytes != 0 || container.MemoryLimitBytes != 0 || container.MemoryPercent != 0 || container.UptimeSeconds != 0 || container.RestartCount != 0 || container.ExitCode != 0 || container.OOMKilled != nil || container.StartedAt != nil || container.FinishedAt != nil || len(container.Ports) != 0 || len(container.Labels) != 0 || len(container.Env) != 0 || len(container.Networks) != 0 || container.NetworkRXBytes != 0 || container.NetworkTXBytes != 0 || container.WritableLayerBytes != 0 || container.RootFilesystemBytes != 0 || container.BlockIO != nil || len(container.Mounts) != 0 || container.Podman != nil || container.UpdateStatus != nil { t.Fatalf("typed-helper container %q escaped the summary-only boundary: %+v", container.ID, container) } } } func rootlessQualDigestReport(report agentsdocker.Report) rootlessQualReportDigest { type stats struct { Name string MemoryLimited bool OOMKnown bool RuntimeDetails bool } semanticRows := make([]rootlessQualContainerSemantic, 0, len(report.Containers)) statsRows := make([]stats, 0, len(report.Containers)) fullFieldsPresent := len(report.Containers) > 0 runningStatsPresent := false for _, item := range report.Containers { semanticRows = append(semanticRows, rootlessQualContainerSemantic{Name: item.Name, Image: item.Image, State: item.State}) statsRows = append(statsRows, stats{Name: item.Name, MemoryLimited: item.MemoryLimitBytes > 0, OOMKnown: item.OOMKilled != nil, RuntimeDetails: item.StartedAt != nil || item.FinishedAt != nil}) if item.CreatedAt.IsZero() || item.Status == "" || item.OOMKilled == nil || (item.StartedAt == nil && item.FinishedAt == nil) { fullFieldsPresent = false } if strings.EqualFold(item.State, "running") && (item.MemoryUsageBytes > 0 || item.MemoryLimitBytes > 0 || item.CPUPercent != 0 || item.NetworkRXBytes > 0 || item.NetworkTXBytes > 0 || item.BlockIO != nil) { runningStatsPresent = true } } sort.Slice(semanticRows, func(i, j int) bool { return semanticRows[i].Name < semanticRows[j].Name }) sort.Slice(statsRows, func(i, j int) bool { return statsRows[i].Name < statsRows[j].Name }) imageNames := make([]string, 0, len(report.Images)) for _, image := range report.Images { imageNames = append(imageNames, strings.Join(image.RepoTags, ",")) } volumeNames := make([]string, 0, len(report.Volumes)) for _, volume := range report.Volumes { volumeNames = append(volumeNames, volume.Name) } networkNames := make([]string, 0, len(report.Networks)) for _, network := range report.Networks { networkNames = append(networkNames, network.Name) } sort.Strings(imageNames) sort.Strings(volumeNames) sort.Strings(networkNames) secondary := map[string]any{"images": imageNames, "volumes": volumeNames, "networks": networkNames} inventory := map[string]any{"containers": semanticRows, "runtime": report.Host.Runtime, "mode": report.Host.CollectionMode} return rootlessQualReportDigest{ Count: len(report.Containers), InventoryDigest: rootlessQualHashJSON(inventory), SemanticDigest: rootlessQualHashJSON(semanticRows), StatsDigest: rootlessQualHashJSON(statsRows), StatsPresent: runningStatsPresent, FullFieldsPresent: fullFieldsPresent, SecondaryDigest: rootlessQualHashJSON(secondary), SecondaryInventoryPresent: len(report.Images) > 0 && len(report.Networks) > 0, } } func rootlessQualSemanticDigest(report agentsdocker.Report) string { return rootlessQualDigestReport(report).SemanticDigest } func rootlessQualStableDigestEqual(left, right rootlessQualReportDigest) bool { return left.Count == right.Count && left.InventoryDigest == right.InventoryDigest && left.SemanticDigest == right.SemanticDigest && left.SecondaryDigest == right.SecondaryDigest && left.StatsPresent && right.StatsPresent } func rootlessQualHashJSON(value any) string { raw, err := json.Marshal(value) if err != nil { panic(err) } sum := sha256.Sum256(raw) return hex.EncodeToString(sum[:]) } func rootlessQualDaemonID(t *testing.T, d rootlessQualDaemon, rootless bool) string { t.Helper() cli := rootlessQualCLI(d, rootless) if d.runtime == "docker" { return rootlessQualCommand(t, 30*time.Second, cli[0], append(cli[1:], "info", "--format", "{{.ID}}")...) } info := rootlessQualCommand(t, 30*time.Second, cli[0], append(cli[1:], "info", "--format", "json")...) var decoded map[string]any if err := json.Unmarshal([]byte(info), &decoded); err != nil { t.Fatalf("decode podman info: %v", err) } store, _ := decoded["store"].(map[string]any) identity := map[string]any{"graphRoot": store["graphRoot"], "runRoot": store["runRoot"], "runtime": d.runtime} return rootlessQualHashJSON(identity) } func rootlessQualDaemonRootless(t *testing.T, d rootlessQualDaemon) bool { t.Helper() cli := rootlessQualCLI(d, true) if d.runtime == "docker" { securityOptions := rootlessQualCommand(t, 30*time.Second, cli[0], append(cli[1:], "info", "--format", "{{json .SecurityOptions}}")...) return strings.Contains(strings.ToLower(securityOptions), "rootless") } rootless := rootlessQualCommand(t, 30*time.Second, cli[0], append(cli[1:], "info", "--format", "{{.Host.Security.Rootless}}")...) return strings.EqualFold(strings.TrimSpace(rootless), "true") } func rootlessQualUnitIdentity(t *testing.T, unit string) (int, string) { t.Helper() pidText := rootlessQualCommand(t, 10*time.Second, "systemctl", "show", unit, "--property=MainPID", "--value") pid, err := strconv.Atoi(pidText) if err != nil || pid <= 1 { t.Fatalf("unit %s returned invalid MainPID %q", unit, pidText) } invocation := rootlessQualCommand(t, 10*time.Second, "systemctl", "show", unit, "--property=InvocationID", "--value") if len(invocation) != 32 { t.Fatalf("unit %s returned invalid InvocationID %q", unit, invocation) } return pid, invocation } func rootlessQualRunUnpinnedCollector(t *testing.T, serverURL, collectorCredential string, duration time.Duration) string { t.Helper() protectedPID := secureRuntimeCollectorMainPID(t) stateDir, err := os.MkdirTemp("/tmp", "pulse-unpinned-probe-") if err != nil { t.Fatal(err) } defer os.RemoveAll(stateDir) collectorUID := rootlessQualUID(t, "pulse-agent") collectorGID := rootlessQualGID(t, "pulse-agent") if err := os.Chown(stateDir, collectorUID, collectorGID); err != nil { t.Fatalf("chown ambiguity probe state: %v", err) } if err := os.Chmod(stateDir, 0o700); err != nil { t.Fatalf("chmod ambiguity probe state: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), duration) defer cancel() cmd := rootlessQualProcessGroupCommand(ctx, "runuser", rootlessQualUnpinnedCollectorArgs(serverURL, collectorCredential, stateDir, collectorUID)...) out, err := cmd.CombinedOutput() if err == nil || !errors.Is(ctx.Err(), context.DeadlineExceeded) { t.Fatalf("unpinned ambiguity probe did not remain alive until bounded cancellation: err=%v\n%s", err, out) } if afterPID := secureRuntimeCollectorMainPID(t); afterPID != protectedPID { t.Fatalf("separate ambiguity probe disturbed protected collector: before=%d after=%d", protectedPID, afterPID) } return string(out) } func rootlessQualUnpinnedCollectorArgs(serverURL, collectorCredential, stateDir string, collectorUID int) []string { return []string{ "-u", "pulse-agent", "--", "env", "-i", "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME=/var/lib/pulse-rootless", "XDG_RUNTIME_DIR=" + filepath.Join("/run/user", strconv.Itoa(collectorUID)), "PULSE_DOCKER_RUNTIME=auto", "PULSE_AGENT_HELPER_SOCKET=/run/pulse-agent/helper.sock", "PULSE_URL=" + serverURL, "PULSE_TOKEN=" + collectorCredential, "PULSE_INTERVAL=1s", "PULSE_AGENT_ID=" + secureRuntimeLabAgentID, "PULSE_HOSTNAME=" + secureRuntimeLabHostname, "PULSE_ENABLE_HOST=false", "PULSE_ENABLE_DOCKER=true", "PULSE_ENABLE_COMMANDS=false", "PULSE_AGENT_ALLOW_PLAINTEXT_HTTP=true", "/usr/local/bin/pulse-agent", "--state-dir", stateDir, "--health-addr", "", } } func rootlessQualProcessGroupCommand(ctx context.Context, name string, args ...string) *exec.Cmd { cmd := exec.CommandContext(ctx, name, args...) // The ambiguity probe crosses runuser before starting the collector. Killing // only runuser leaves the collector holding CombinedOutput's pipe open. cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.Cancel = func() error { if cmd.Process == nil { return os.ErrProcessDone } if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil { if errors.Is(err, syscall.ESRCH) { return os.ErrProcessDone } return err } return nil } return cmd } func TestRootlessQualificationCancellationKillsProbeProcessGroup(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() started := time.Now() cmd := rootlessQualProcessGroupCommand(ctx, "sh", "-c", "sleep 30 & child=$!; echo $child; wait") out, err := cmd.CombinedOutput() if err == nil || !errors.Is(ctx.Err(), context.DeadlineExceeded) { t.Fatalf("process-group probe did not reach bounded cancellation: err=%v output=%q", err, out) } if elapsed := time.Since(started); elapsed > 3*time.Second { t.Fatalf("process-group cancellation waited for an inherited output pipe: elapsed=%s output=%q", elapsed, out) } childPID, parseErr := strconv.Atoi(strings.TrimSpace(string(out))) if parseErr != nil || childPID <= 1 { t.Fatalf("process-group probe returned invalid child PID %q: %v", out, parseErr) } deadline := time.Now().Add(time.Second) for { err = syscall.Kill(childPID, 0) if errors.Is(err, syscall.ESRCH) { break } if time.Now().After(deadline) { t.Fatalf("probe child %d survived process-group cancellation: %v", childPID, err) } time.Sleep(10 * time.Millisecond) } } func TestRootlessQualificationUnpinnedProbeUsesCleanAutomaticRuntimeEnvironment(t *testing.T) { args := rootlessQualUnpinnedCollectorArgs("http://127.0.0.1:8080", "test-token", "/tmp/test-state", 996) if len(args) < 5 || !slices.Equal(args[:5], []string{"-u", "pulse-agent", "--", "env", "-i"}) { t.Fatalf("unpinned probe does not start with a clean runuser environment: %q", args) } if !slices.Contains(args, "PULSE_DOCKER_RUNTIME=auto") { t.Fatalf("unpinned probe does not force automatic runtime selection: %q", args) } if !slices.Contains(args, "PULSE_AGENT_HELPER_SOCKET=/run/pulse-agent/helper.sock") { t.Fatalf("unpinned probe does not exercise the installed typed-helper boundary: %q", args) } for _, arg := range args { for _, forbidden := range []string{"DOCKER_HOST=", "PODMAN_HOST=", "CONTAINER_HOST="} { if strings.HasPrefix(arg, forbidden) { t.Fatalf("unpinned probe retained endpoint selector %q", arg) } } } } func rootlessQualServicePin(t *testing.T, runtimeKind string) string { t.Helper() environment := secureRuntimeSystemdProperty(t, "Environment") key := "DOCKER_HOST=unix://" if runtimeKind == "podman" { key = "CONTAINER_HOST=unix://" } for _, field := range strings.Fields(environment) { field = strings.Trim(field, `"`) if strings.HasPrefix(field, key) { return strings.TrimPrefix(field, key) } } t.Fatalf("collector unit lacks %s pin: %s", key, environment) return "" } func rootlessQualRootfulAccessDenied(t *testing.T, d rootlessQualDaemon) bool { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "runuser", "-u", "pulse-agent", "--", "curl", "-fsS", "--max-time", "2", "--unix-socket", d.rootfulSock, "http://runtime/_ping") return cmd.Run() != nil } func rootlessQualDualSocketEvidence(t *testing.T, uid int) []map[string]any { t.Helper() result := make([]map[string]any, 0, 2) for _, item := range []struct{ runtime, path string }{ {"docker", filepath.Join("/run/user", strconv.Itoa(uid), "docker.sock")}, {"podman", filepath.Join("/run/user", strconv.Itoa(uid), "podman", "podman.sock")}, } { socketUID, socketGID, mode := rootlessQualSocketIdentity(t, item.path) if socketUID != uid { t.Fatalf("rootless %s socket is owned by UID %d, want collector UID %d", item.runtime, socketUID, uid) } result = append(result, map[string]any{ "runtime": item.runtime, "path": item.path, "uid": socketUID, "gid": socketGID, "mode": mode, "type": "unix", "symlink": false, }) _ = os.Remove(item.path + ".qualification-identity") } return result } func rootlessQualAssertHelperNetworkDenied(t *testing.T) bool { t.Helper() observations := secureRuntimeAssertHelperLoopbackNetworkDenied(t) return observations["helper_namespace_connection"] == "denied" && observations["host_canary_reachable"] == true } func rootlessQualCaptureIdentity(t *testing.T, d rootlessQualDaemon) { t.Helper() uid, gid, mode := rootlessQualSocketIdentity(t, d.rootlessSock) cli := rootlessQualCLI(d, true) version := rootlessQualCommand(t, 30*time.Second, cli[0], append(cli[1:], "version", "--format", "{{.Server.Version}}")...) if d.runtime == "podman" { version = rootlessQualCommand(t, 30*time.Second, cli[0], append(cli[1:], "version", "--format", "{{.Server.Version}}")...) } rootlessQualWriteJSON(t, d.rootlessSock+".qualification-identity", rootlessQualIdentity{RuntimeVersion: version, SocketUID: uid, SocketGID: gid, SocketMode: mode}) } func rootlessQualReadIdentityRecord(t *testing.T, d rootlessQualDaemon) rootlessQualIdentity { t.Helper() path := d.rootlessSock + ".qualification-identity" raw := rootlessQualReadFile(t, path) var identity rootlessQualIdentity if err := json.Unmarshal(raw, &identity); err != nil { t.Fatalf("decode runtime identity: %v", err) } return identity } func rootlessQualSocketIdentity(t *testing.T, path string) (int, int, string) { t.Helper() info, err := os.Lstat(path) if err != nil { t.Fatalf("inspect socket identity %s: %v", path, err) } if info.Mode()&os.ModeSocket == 0 || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o600 != 0o600 || info.Mode().Perm()&0o006 != 0 { t.Fatalf("unsafe rootless socket mode at %s: %s", path, info.Mode()) } stat, ok := info.Sys().(*syscall.Stat_t) if !ok { t.Fatalf("socket %s lacks Unix stat identity", path) } return int(stat.Uid), int(stat.Gid), fmt.Sprintf("%04o", info.Mode().Perm()) } func rootlessQualArtifactIdentities(t *testing.T, installerPath string) rootlessQualArtifacts { t.Helper() executable, err := os.Executable() if err != nil { t.Fatal(err) } collectorPath := strings.TrimSpace(os.Getenv("PULSE_SECURE_RUNTIME_COLLECTOR")) helperPath := strings.TrimSpace(os.Getenv("PULSE_SECURE_RUNTIME_HELPER")) return rootlessQualArtifacts{ QualificationTest: rootlessQualGoArtifact(t, executable, "dockeragent.test"), Collector: rootlessQualGoArtifact(t, collectorPath, "pulse-agent"), Helper: rootlessQualGoArtifact(t, helperPath, "pulse-agent-helper"), Installer: rootlessQualInstallerArtifact{ PathBasename: filepath.Base(installerPath), SHA256: secureRuntimeHash(rootlessQualReadFile(t, installerPath)), }, } } func rootlessQualGoArtifact(t *testing.T, path, basename string) rootlessQualArtifact { t.Helper() info, err := buildinfo.ReadFile(path) if err != nil { t.Fatalf("read Go build metadata for %s: %v", path, err) } artifact := rootlessQualArtifact{ PathBasename: filepath.Base(path), SHA256: secureRuntimeHash(rootlessQualReadFile(t, path)), Package: info.Path, GoVersion: info.GoVersion, } for _, setting := range info.Settings { switch setting.Key { case "vcs.revision": artifact.VCSRevision = setting.Value case "vcs.modified": artifact.VCSModified = setting.Value == "true" } } wantCommit := strings.TrimSpace(os.Getenv("PULSE_ROOTLESS_SOURCE_COMMIT")) if artifact.PathBasename != basename || artifact.Package == "" || artifact.VCSRevision != wantCommit || artifact.VCSModified { t.Fatalf("qualification artifact is not an exact clean source build: %+v", artifact) } return artifact } func rootlessQualSourceHashes(t *testing.T) map[string]string { t.Helper() path := strings.TrimSpace(os.Getenv("PULSE_ROOTLESS_SOURCE_HASHES")) if !filepath.IsAbs(path) { t.Fatalf("PULSE_ROOTLESS_SOURCE_HASHES must be absolute: %q", path) } var hashes map[string]string if err := json.Unmarshal(rootlessQualReadFile(t, path), &hashes); err != nil { t.Fatalf("decode source hashes: %v", err) } if len(hashes) == 0 { t.Fatal("source hash map is empty") } return hashes } func rootlessQualUninstallPulse(t *testing.T, installerPath, serverURL, collectorCredential string) { t.Helper() secureRuntimeRunInstallerWithCollectorCredential(t, installerPath, serverURL, collectorCredential, "--uninstall") } func rootlessQualAssertPulseRemoved(t *testing.T) { t.Helper() for _, path := range secureRuntimeInstalledPaths { if _, err := os.Lstat(path); err == nil { t.Fatalf("Pulse cleanup left %s", path) } else if !errors.Is(err, os.ErrNotExist) { t.Fatalf("inspect cleanup path %s: %v", path, err) } } } func rootlessQualRemoveRuntimeState(t *testing.T, d rootlessQualDaemon) { t.Helper() for _, path := range rootlessQualRuntimeStateRoots(d) { if err := os.RemoveAll(path); err != nil { t.Fatalf("remove runtime state %s: %v", path, err) } } } func rootlessQualRuntimeStateRoots(d rootlessQualDaemon) []string { return []string{d.home, filepath.Join("/run/user", strconv.Itoa(d.uid)), "/var/lib/pulse-rootful-docker", "/var/lib/containers/storage"} } func rootlessQualWaitRuntimeMountsReleased(t *testing.T, d rootlessQualDaemon, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) var remaining []string for { mountInfo, err := os.ReadFile("/proc/self/mountinfo") if err != nil { t.Fatalf("read kernel mount table before runtime-state deletion: %v", err) } remaining, err = rootlessQualMountPointsBelow(string(mountInfo), rootlessQualRuntimeStateRoots(d)) if err != nil { t.Fatalf("parse kernel mount table before runtime-state deletion: %v", err) } if len(remaining) == 0 { return } if time.Now().After(deadline) { t.Fatalf("runtime mounts remain after service shutdown: %q", remaining) } time.Sleep(100 * time.Millisecond) } } func rootlessQualMountPointsBelow(mountInfo string, roots []string) ([]string, error) { cleanRoots := make([]string, 0, len(roots)) for _, root := range roots { if root = filepath.Clean(strings.TrimSpace(root)); root != "." { cleanRoots = append(cleanRoots, root) } } var matches []string for lineNumber, line := range strings.Split(mountInfo, "\n") { if strings.TrimSpace(line) == "" { continue } fields := strings.Fields(line) if len(fields) < 6 { return nil, fmt.Errorf("line %d has %d fields", lineNumber+1, len(fields)) } mountPoint, err := rootlessQualDecodeMountInfoPath(fields[4]) if err != nil { return nil, fmt.Errorf("line %d mount point: %w", lineNumber+1, err) } mountPoint = filepath.Clean(mountPoint) for _, root := range cleanRoots { if mountPoint == root || root == string(filepath.Separator) || strings.HasPrefix(mountPoint, root+string(filepath.Separator)) { matches = append(matches, mountPoint) break } } } sort.Strings(matches) return slices.Compact(matches), nil } func rootlessQualDecodeMountInfoPath(value string) (string, error) { var decoded strings.Builder decoded.Grow(len(value)) for i := 0; i < len(value); i++ { if value[i] != '\\' { decoded.WriteByte(value[i]) continue } if i+3 >= len(value) || value[i+1] < '0' || value[i+1] > '7' || value[i+2] < '0' || value[i+2] > '7' || value[i+3] < '0' || value[i+3] > '7' { return "", fmt.Errorf("invalid mountinfo escape at byte %d", i) } decoded.WriteByte((value[i+1]-'0')*64 + (value[i+2]-'0')*8 + value[i+3] - '0') i += 3 } return decoded.String(), nil } func rootlessQualRemoveFixtures(t *testing.T, d rootlessQualDaemon) { t.Helper() rootlessQualRemoveFixtureSet(t, rootlessQualCLI(d, true)) rootlessQualRemoveFixtureSet(t, rootlessQualCLI(d, false)) } func rootlessQualResetRuntimeStorage(t *testing.T, d rootlessQualDaemon) { t.Helper() for _, command := range rootlessQualRuntimeResetCommands(d) { if len(command) == 0 { t.Fatal("empty runtime storage reset command") } rootlessQualCommand(t, 60*time.Second, command[0], command[1:]...) } } func rootlessQualRuntimeResetCommands(d rootlessQualDaemon) [][]string { if d.runtime != "podman" { return nil } const cleanPath = "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" return [][]string{ {"runuser", "-u", "pulse-agent", "--", "env", "-i", "HOME=" + d.home, "XDG_RUNTIME_DIR=" + filepath.Join("/run/user", strconv.Itoa(d.uid)), cleanPath, "/usr/bin/podman", "system", "reset", "--force"}, {"/usr/bin/env", "-i", cleanPath, "/usr/bin/podman", "system", "reset", "--force"}, } } func rootlessQualRemoveFixtureSet(t *testing.T, cli []string) { t.Helper() if len(cli) == 0 { t.Fatal("empty runtime CLI for fixture cleanup") } if out, err := rootlessQualCommandError(30*time.Second, cli[0], append(cli[1:], "rm", "-f", rootlessQualRunningName, rootlessQualExitedName)...); err != nil { t.Fatalf("remove runtime fixtures: %v\n%s", err, out) } remaining := rootlessQualCommand(t, 30*time.Second, cli[0], append(cli[1:], "ps", "-a", "--format", "{{.Names}}")...) for _, name := range strings.Fields(remaining) { name = strings.TrimPrefix(name, "/") if name == rootlessQualRunningName || name == rootlessQualExitedName { t.Fatalf("runtime fixture %s remains after removal", name) } } } func rootlessQualStopUserManager(t *testing.T, d rootlessQualDaemon) { t.Helper() rootlessQualCommand(t, 10*time.Second, "loginctl", "disable-linger", "pulse-agent") rootlessQualStopUnit(t, fmt.Sprintf("user@%d.service", d.uid)) } func rootlessQualUserStateClean(d rootlessQualDaemon) bool { for _, path := range []string{d.home, filepath.Join("/run/user", strconv.Itoa(d.uid)), "/var/lib/pulse-rootful-docker", "/var/lib/containers/storage", "/var/lib/systemd/linger/pulse-agent"} { if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { return false } } return true } func rootlessQualStopUnit(t *testing.T, unit string) { t.Helper() rootlessQualCommand(t, 30*time.Second, "systemctl", "stop", unit) resetOutput, resetErr := rootlessQualCommandError(30*time.Second, "systemctl", "reset-failed", unit) if resetErr == nil { return } loadState, stateErr := rootlessQualCommandError(30*time.Second, "systemctl", "show", "--property=LoadState", "--value", unit) if stateErr == nil && strings.TrimSpace(loadState) == "not-found" { return } if rootlessQualResetFailureIsAlreadyUnloaded(resetOutput, unit) { return } t.Fatalf("systemctl reset-failed %s: %v\n%s", unit, resetErr, resetOutput) } func rootlessQualResetFailureIsAlreadyUnloaded(resetOutput, unit string) bool { message := strings.ToLower(resetOutput) unit = strings.ToLower(strings.TrimSpace(unit)) if unit == "" || !strings.Contains(message, "failed to reset failed state") || !strings.Contains(message, unit) { return false } return strings.Contains(message, "not loaded") || strings.Contains(message, "not found") } func TestRootlessQualificationAcceptsAlreadyUnloadedUnitAfterSuccessfulStop(t *testing.T) { const unit = "user@996.service" if !rootlessQualResetFailureIsAlreadyUnloaded( "Failed to reset failed state of unit user@996.service: Unit user@996.service not loaded.", unit, ) { t.Fatal("already-unloaded inactive unit was not accepted") } for _, test := range []struct { name string output string unit string }{ {name: "different unit", output: "Failed to reset failed state of unit user@997.service: Unit user@997.service not loaded.", unit: unit}, {name: "different failure", output: "Failed to reset failed state of unit user@996.service: Access denied.", unit: unit}, {name: "unrelated diagnostic", output: "Unit user@996.service not loaded.", unit: unit}, {name: "empty unit", output: "Failed to reset failed state: Unit not loaded.", unit: ""}, } { t.Run(test.name, func(t *testing.T) { if rootlessQualResetFailureIsAlreadyUnloaded(test.output, test.unit) { t.Fatal("unsafe reset failure was accepted") } }) } } func rootlessQualBestEffortStop(units ...string) { for _, unit := range units { if unit != "" { _ = exec.Command("systemctl", "stop", unit).Run() _ = exec.Command("systemctl", "reset-failed", unit).Run() } } } func rootlessQualWaitSocket(t *testing.T, path string) { t.Helper() deadline := time.Now().Add(45 * time.Second) for time.Now().Before(deadline) { if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSocket != 0 && info.Mode()&os.ModeSymlink == 0 { return } time.Sleep(200 * time.Millisecond) } t.Fatalf("runtime socket did not appear: %s", path) } func rootlessQualUID(t *testing.T, user string) int { t.Helper() uidText := rootlessQualCommand(t, 10*time.Second, "id", "-u", user) uid, err := strconv.Atoi(uidText) if err != nil { t.Fatalf("parse UID %q: %v", uidText, err) } return uid } func rootlessQualGID(t *testing.T, user string) int { t.Helper() gidText := rootlessQualCommand(t, 10*time.Second, "id", "-g", user) gid, err := strconv.Atoi(gidText) if err != nil { t.Fatalf("parse GID %q: %v", gidText, err) } return gid } func rootlessQualWriteSubID(t *testing.T, path, line string) { t.Helper() raw, err := os.ReadFile(path) if err != nil && !errors.Is(err, os.ErrNotExist) { t.Fatal(err) } if strings.Contains(string(raw), strings.SplitN(line, ":", 2)[0]+":") { return } f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { t.Fatal(err) } defer f.Close() if _, err := fmt.Fprintln(f, line); err != nil { t.Fatal(err) } } func rootlessQualCommand(t *testing.T, timeout time.Duration, name string, args ...string) string { t.Helper() out, err := rootlessQualCommandError(timeout, name, args...) if err != nil { t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, out) } return strings.TrimSpace(out) } func rootlessQualCommandError(timeout time.Duration, name string, args ...string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() out, err := exec.CommandContext(ctx, name, args...).CombinedOutput() if ctx.Err() != nil { return string(out), fmt.Errorf("command timed out: %w", ctx.Err()) } return string(out), err } func rootlessQualReadFile(t *testing.T, path string) []byte { t.Helper() f, err := os.Open(path) if err != nil { t.Fatal(err) } defer f.Close() raw, err := io.ReadAll(io.LimitReader(f, 64<<20)) if err != nil { t.Fatal(err) } return raw } func rootlessQualWriteJSON(t *testing.T, path string, value any) { t.Helper() raw, err := json.MarshalIndent(value, "", " ") if err != nil { t.Fatal(err) } tmp := path + ".tmp" if err := os.WriteFile(tmp, append(raw, '\n'), 0o600); err != nil { t.Fatal(err) } if err := os.Rename(tmp, path); err != nil { t.Fatal(err) } } func rootlessQualValidateReceipt(receipt rootlessQualReceipt, wantRuns int) error { if receipt.SchemaVersion != 1 || receipt.Kind != "pulse-secure-runtime-rootless-qualification" || receipt.Result != "passed" { return errors.New("invalid top-level qualification identity") } if len(receipt.SourceCommit) != 40 || len(receipt.Runs) != wantRuns || receipt.Artifacts.QualificationTest.PathBasename != "dockeragent.test" || receipt.Artifacts.QualificationTest.VCSModified || len(receipt.SourceHashes) == 0 { return errors.New("invalid source, artifact, or runtime binding") } seen := map[string]bool{} for _, run := range receipt.Runs { rt := run.Runtime if seen[rt.Runtime] || (rt.Runtime != "docker" && rt.Runtime != "podman") || !rt.DaemonRootless || rt.CollectorUID <= 0 || rt.SocketUID != rt.CollectorUID || rt.SocketPath == "" || rt.DaemonID == "" || rt.SocketType != "unix" || rt.SocketSymlink { return fmt.Errorf("invalid runtime identity for %q", rt.Runtime) } seen[rt.Runtime] = true if rt.SocketMode != "0600" && rt.SocketMode != "0660" { return fmt.Errorf("runtime %s socket mode is not owner-private", rt.Runtime) } if len(run.Scenarios) != len(rootlessQualScenarioOrder) { return fmt.Errorf("runtime %s scenario count = %d", rt.Runtime, len(run.Scenarios)) } for i, scenario := range run.Scenarios { if scenario.Name != rootlessQualScenarioOrder[i] || scenario.Result != "passed" || scenario.StartedAt == "" || scenario.CompletedAt == "" || len(scenario.Evidence) == 0 { return fmt.Errorf("runtime %s scenario %d is invalid", rt.Runtime, i) } reportExpected := scenario.Name != "dual_socket_ambiguity_refusal" && scenario.Name != "authority_isolation" && scenario.Name != "cleanup" if reportExpected != (scenario.ReportSequence != nil && scenario.ReportStreamID != nil && *scenario.ReportStreamID != "") { return fmt.Errorf("runtime %s scenario %s report binding is invalid", rt.Runtime, scenario.Name) } } } return nil } func TestRootlessQualificationReceiptContract(t *testing.T) { now := time.Now().UTC().Format(time.RFC3339Nano) sequence := uint64(1) scenarios := make([]rootlessQualScenario, 0, len(rootlessQualScenarioOrder)) for _, name := range rootlessQualScenarioOrder { scenario := rootlessQualScenario{Name: name, Result: "passed", StartedAt: now, CompletedAt: now, Evidence: map[string]any{"observed": true}} if name != "dual_socket_ambiguity_refusal" && name != "authority_isolation" && name != "cleanup" { scenario.ReportSequence = &sequence stream := "stream" scenario.ReportStreamID = &stream } scenarios = append(scenarios, scenario) } receipt := rootlessQualReceipt{ SchemaVersion: 1, Kind: "pulse-secure-runtime-rootless-qualification", Result: "passed", SourceCommit: strings.Repeat("a", 40), SourceHashes: map[string]string{"source.go": strings.Repeat("b", 64)}, Artifacts: rootlessQualArtifacts{QualificationTest: rootlessQualArtifact{PathBasename: "dockeragent.test", VCSRevision: strings.Repeat("a", 40)}}, Runs: []rootlessQualRun{{Runtime: rootlessQualRuntime{Runtime: "docker", DaemonID: "daemon", CollectorUID: 1000, SocketUID: 1000, SocketMode: "0600", SocketPath: "/run/user/1000/docker.sock", SocketType: "unix", DaemonRootless: true}, Scenarios: scenarios}}, } if err := rootlessQualValidateReceipt(receipt, 1); err != nil { t.Fatal(err) } receipt.Runs[0].Scenarios[3].ReportSequence = nil if err := rootlessQualValidateReceipt(receipt, 1); err == nil { t.Fatal("receipt validator accepted a report-producing scenario without a sequence") } } func TestRootlessQualificationBaselineUsesReportSemanticShape(t *testing.T) { baseline := rootlessQualBaselineFromPSOutput(strings.Join([]string{ "/pulse-rootless-running|pulse-rootless-qualification:v1|running", "pulse-rootless-exited|pulse-rootless-qualification:v1|exited", }, "\n")) report := agentsdocker.Report{Containers: []agentsdocker.Container{ {Name: "pulse-rootless-exited", Image: "pulse-rootless-qualification:v1", State: "exited"}, {Name: "pulse-rootless-running", Image: "pulse-rootless-qualification:v1", State: "running"}, }} digest := rootlessQualDigestReport(report) if baseline.Count != digest.Count || baseline.SemanticDigest != digest.SemanticDigest { t.Fatalf("runtime baseline and report semantics diverged: baseline=%+v report=%+v", baseline, digest) } } func TestRootlessQualificationStableDirectMatchWaitsForFullTelemetry(t *testing.T) { now := time.Now().UTC() oomKilled := false complete := true full := agentsdocker.Report{ Host: agentsdocker.HostInfo{Runtime: "docker"}, InventoryComplete: &complete, Containers: []agentsdocker.Container{ {Name: "pulse-rootless-running", Image: rootlessQualFixture, State: "running", Status: "Up", CreatedAt: now, StartedAt: &now, OOMKilled: &oomKilled, MemoryLimitBytes: 1024}, {Name: "pulse-rootless-exited", Image: rootlessQualFixture, State: "exited", Status: "Exited", CreatedAt: now, FinishedAt: &now, OOMKilled: &oomKilled}, }, Images: []agentsdocker.Image{{RepoTags: []string{rootlessQualFixture}}}, Networks: []agentsdocker.Network{{Name: "bridge"}}, } semanticDigest := rootlessQualSemanticDigest(full) stableDigest := rootlessQualDigestReport(full) semanticOnly := full semanticOnly.Containers = slices.Clone(full.Containers) semanticOnly.Containers[0].MemoryLimitBytes = 0 semanticOnly.Images = nil semanticOnly.Networks = nil if !rootlessQualDirectReportMatches(semanticOnly, "docker", semanticDigest) { t.Fatal("semantic-only direct report no longer matches the base recovery condition") } if rootlessQualStableDigestEqual(rootlessQualDigestReport(semanticOnly), stableDigest) { t.Fatal("semantic-only direct report satisfied evidence-grade telemetry parity") } if !rootlessQualStableDigestEqual(rootlessQualDigestReport(full), stableDigest) { t.Fatal("full direct report did not satisfy evidence-grade telemetry parity") } } func TestRootlessQualificationFixtureRemovalPrecedesStateDeletion(t *testing.T) { temporary := t.TempDir() logPath := filepath.Join(temporary, "runtime.log") runtimePath := filepath.Join(temporary, "runtime") script := `#!/bin/sh printf '%s\n' "$*" >>"$ROOTLESS_QUAL_RUNTIME_LOG" for arg in "$@"; do case "$arg" in rm|ps) exit 0 ;; esac done exit 2 ` if err := os.WriteFile(runtimePath, []byte(script), 0o755); err != nil { t.Fatal(err) } t.Setenv("ROOTLESS_QUAL_RUNTIME_LOG", logPath) rootlessQualRemoveFixtureSet(t, []string{runtimePath, "--url", "unix:///run/test.sock"}) commands := strings.Split(strings.TrimSpace(string(rootlessQualReadFile(t, logPath))), "\n") want := []string{ "--url unix:///run/test.sock rm -f " + rootlessQualRunningName + " " + rootlessQualExitedName, "--url unix:///run/test.sock ps -a --format {{.Names}}", } if !slices.Equal(commands, want) { t.Fatalf("fixture cleanup command order = %q, want %q", commands, want) } } func TestRootlessQualificationMountReleaseUsesExactKernelPaths(t *testing.T) { mountInfo := strings.Join([]string{ "36 25 0:32 / /var/lib/containers/storage rw,relatime - overlay overlay rw", "37 36 0:33 / /var/lib/containers/storage/overlay/abc/merged rw,relatime - overlay overlay rw", "38 25 0:34 / /var/lib/containers/storage-other rw,relatime - tmpfs tmpfs rw", "39 25 0:35 / /var/lib/pulse\\040rootless/data rw,relatime - tmpfs tmpfs rw", "40 25 0:36 / /var/lib/pulse\\134rootless/data rw,relatime - tmpfs tmpfs rw", }, "\n") got, err := rootlessQualMountPointsBelow(mountInfo, []string{ "/var/lib/containers/storage", "/var/lib/pulse rootless", `/var/lib/pulse\rootless`, }) if err != nil { t.Fatal(err) } want := []string{ "/var/lib/containers/storage", "/var/lib/containers/storage/overlay/abc/merged", "/var/lib/pulse rootless/data", `/var/lib/pulse\rootless/data`, } if !slices.Equal(got, want) { t.Fatalf("mounts beneath runtime roots = %q, want %q", got, want) } if _, err := rootlessQualMountPointsBelow("1 2 3", []string{"/var/lib/containers/storage"}); err == nil { t.Fatal("malformed mountinfo line was accepted") } if _, err := rootlessQualMountPointsBelow("1 2 3 4 /bad\\09x 6", []string{"/bad"}); err == nil { t.Fatal("malformed mountinfo escape was accepted") } } func TestRootlessQualificationPodmanResetsBothStorageIdentitiesLocally(t *testing.T) { d := rootlessQualDaemon{runtime: "podman", uid: 996, home: "/var/lib/pulse-rootless"} got := rootlessQualRuntimeResetCommands(d) want := [][]string{ {"runuser", "-u", "pulse-agent", "--", "env", "-i", "HOME=/var/lib/pulse-rootless", "XDG_RUNTIME_DIR=/run/user/996", "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "/usr/bin/podman", "system", "reset", "--force"}, {"/usr/bin/env", "-i", "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "/usr/bin/podman", "system", "reset", "--force"}, } if !slices.EqualFunc(got, want, slices.Equal[[]string]) { t.Fatalf("Podman reset commands = %q, want %q", got, want) } if commands := rootlessQualRuntimeResetCommands(rootlessQualDaemon{runtime: "docker"}); len(commands) != 0 { t.Fatalf("Docker cleanup unexpectedly gained Podman reset commands: %q", commands) } } func TestRootlessQualificationStopAcceptsCollectedTransientUnit(t *testing.T) { tempDir := t.TempDir() logPath := filepath.Join(tempDir, "systemctl.log") systemctlPath := filepath.Join(tempDir, "systemctl") script := `#!/bin/sh printf '%s\n' "$*" >>"$ROOTLESS_QUAL_SYSTEMCTL_LOG" case "$1" in stop) exit 0 ;; reset-failed) echo 'Unit is not loaded.' >&2; exit 1 ;; show) echo 'not-found'; exit 0 ;; esac exit 2 ` if err := os.WriteFile(systemctlPath, []byte(script), 0o755); err != nil { t.Fatal(err) } t.Setenv("ROOTLESS_QUAL_SYSTEMCTL_LOG", logPath) t.Setenv("PATH", tempDir+string(os.PathListSeparator)+os.Getenv("PATH")) rootlessQualStopUnit(t, "pulse-rootless-docker") logText := string(rootlessQualReadFile(t, logPath)) for _, command := range []string{ "stop pulse-rootless-docker", "reset-failed pulse-rootless-docker", "show --property=LoadState --value pulse-rootless-docker", } { if !strings.Contains(logText, command) { t.Fatalf("systemctl lifecycle did not execute %q: %s", command, logText) } } } func TestRootlessQualificationRunningFixtureRestartsWithDaemon(t *testing.T) { args := rootlessQualRunningFixtureArgs() if !slices.Contains(args, "--restart=always") { t.Fatalf("running qualification fixture lacks daemon-restart policy: %q", args) } } func TestRootlessQualificationGoSchemaPassesPythonValidator(t *testing.T) { commit := strings.Repeat("a", 40) digest := strings.Repeat("b", 64) started := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC) receipt := rootlessQualReceipt{ SchemaVersion: 1, Kind: "pulse-secure-runtime-rootless-qualification", Result: "passed", SourceCommit: commit, StartedAt: started.Format(time.RFC3339Nano), CompletedAt: started.Add(2 * time.Minute).Format(time.RFC3339Nano), SourceHashes: map[string]string{"internal/dockeragent/agent.go": digest, "scripts/install.sh": digest}, Artifacts: rootlessQualArtifacts{ QualificationTest: rootlessQualArtifact{PathBasename: "dockeragent.test", SHA256: digest, Package: "github.com/rcourtman/pulse-go-rewrite/scripts/installtests.test", GoVersion: "go1.25.0", VCSRevision: commit}, Collector: rootlessQualArtifact{PathBasename: "pulse-agent", SHA256: digest, Package: "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent", GoVersion: "go1.25.0", VCSRevision: commit}, Helper: rootlessQualArtifact{PathBasename: "pulse-agent-helper", SHA256: digest, Package: "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent-helper", GoVersion: "go1.25.0", VCSRevision: commit}, Installer: rootlessQualInstallerArtifact{PathBasename: "install.sh", SHA256: digest}, }, } for index, runtimeKind := range []string{"docker", "podman"} { receipt.Runs = append(receipt.Runs, rootlessQualValidatorFixtureRun(runtimeKind, 1000, index, started.Add(time.Duration(index)*time.Minute), digest)) } path := filepath.Join(t.TempDir(), "receipt.json") rootlessQualWriteJSON(t, path, receipt) validator := repoFile("scripts", "release_control", "secure_runtime_rootless_attestation_v1.py") program := `import importlib.util, pathlib, sys spec=importlib.util.spec_from_file_location("validator", pathlib.Path(sys.argv[1])) module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module) module.parse_receipt_bytes(pathlib.Path(sys.argv[2]).read_bytes()) ` cmd := exec.Command("python3", "-I", "-c", program, validator, path) if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("Go receipt schema failed the real Python validator: %v\n%s", err, output) } } func rootlessQualValidatorFixtureRun(runtimeKind string, uid, index int, began time.Time, digest string) rootlessQualRun { socketPath := filepath.Join("/run/user", strconv.Itoa(uid), "docker.sock") if runtimeKind == "podman" { socketPath = filepath.Join("/run/user", strconv.Itoa(uid), "podman", "podman.sock") } daemonID := runtimeKind + "-daemon" direct := func(pid int) map[string]any { return map[string]any{ "collector_pid": pid, "service_pid": pid, "collection_path": "collector-owned-rootless-socket", "inventory_complete": true, "inventory_count": 2, "semantic_sha256": digest, "full_fields_present": true, "stats_present": true, "secondary_structure_sha256": digest, "daemon_id": daemonID, "daemon_rootless": true, "socket_path": socketPath, "socket_uid": uid, "socket_gid": uid, "socket_mode": "0600", "socket_type": "unix", "socket_symlink": false, } } stream := func(value string) *string { return &value } sequence := func(value uint64) *uint64 { return &value } makeScenario := func(offset int, name string, streamID *string, seq *uint64, evidence map[string]any) rootlessQualScenario { start := began.Add(time.Duration(offset) * time.Second) return rootlessQualScenario{Name: name, Result: "passed", StartedAt: start.Format(time.RFC3339Nano), CompletedAt: start.Add(time.Second).Format(time.RFC3339Nano), ReportStreamID: streamID, ReportSequence: seq, Evidence: evidence} } fresh := direct(100 + index*10) migration := direct(101 + index*10) migration["legacy_profile"] = "root-command-capable" migration["target_profile"] = "typed-helper-monitoring-only" migration["authority_reduced"] = true migration["legacy_collector_pid"] = 150 + index*10 restart := direct(102 + index*10) restart["previous_collector_pid"] = 101 + index*10 restart["previous_report_stream_id"] = fmt.Sprintf("%s-migration-%d", runtimeKind, index) daemon := direct(102 + index*10) daemon["previous_daemon_pid"] = 200 + index*10 daemon["daemon_pid"] = 201 + index*10 daemon["previous_daemon_invocation_id"] = fmt.Sprintf("%s-old-invocation", runtimeKind) daemon["daemon_invocation_id"] = fmt.Sprintf("%s-new-invocation", runtimeKind) fallback := map[string]any{ "collector_pid": 102 + index*10, "collection_mode": "typed-helper-summary", "direct_runtime_available": false, "helper_fallback": true, "inventory_complete": true, "inventory_count": 2, "rootful_baseline_inventory_count": 2, "semantic_sha256": digest, "rootful_baseline_semantic_sha256": digest, "full_fields_present": false, "stats_present": false, "secondary_structure_sha256": "", "container_actions_enabled": false, "container_updates_enabled": false, "collector_restart_count": 0, } liveSockets := []map[string]any{ {"runtime": "docker", "path": filepath.Join("/run/user", strconv.Itoa(uid), "docker.sock"), "uid": uid, "gid": uid, "mode": "0600", "type": "unix", "symlink": false}, {"runtime": "podman", "path": filepath.Join("/run/user", strconv.Itoa(uid), "podman", "podman.sock"), "uid": uid, "gid": uid, "mode": "0600", "type": "unix", "symlink": false}, } ambiguity := map[string]any{ "protected_collector_pid": 102 + index*10, "live_sockets": liveSockets, "probe_kind": "separate-unpinned-collector", "admission_refused": true, "fail_closed": true, "daemon_probe_count": 0, "container_actions_enabled": false, "collector_restart_count": 0, } pin := direct(103 + index*10) pin["previous_collector_pid"] = 102 + index*10 pin["previous_report_stream_id"] = fmt.Sprintf("%s-restart-%d", runtimeKind, index) pin["pin_source"] = "root-owned-systemd-unit" pin["pinned_socket_path"] = socketPath pin["socket_absent_observed"] = true pin["fallback_report_sequence"] = 1 pin["recovery_report_sequence"] = 2 pin["recovered_socket_path"] = socketPath pin["selected_socket_path"] = socketPath pin["recovered_socket_uid"] = uid pin["recovered_socket_gid"] = uid pin["recovered_socket_mode"] = "0600" pin["recovered_socket_type"] = "unix" pin["recovered_socket_symlink"] = false pin["candidate_count"] = 1 pin["daemon_probe_count"] = 1 pin["collector_restart_count"] = 1 parity := map[string]any{ "collector_pid": 103 + index*10, "baseline_kind": "root-client-same-rootless-daemon", "baseline_inventory_count": 2, "collector_inventory_count": 2, "baseline_semantic_sha256": digest, "collector_semantic_sha256": digest, "collector_full_fields_present": true, "collector_stats_present": true, "collector_secondary_inventory_present": true, } authority := map[string]any{ "collector_pid": 103 + index*10, "collector_uid": uid, "effective_uid": uid, "effective_root": false, "safe_profile_enabled": true, "commands_enabled": false, "privileged_helper_enabled": true, "reduction_request_observed": true, "collector_command_transport_present": false, "collector_command_session_present": false, "container_actions_enabled": false, "container_updates_enabled": false, "rootful_socket_access": false, "helper_network_access": false, } run := rootlessQualRun{ Host: rootlessQualHost{MachineID: fmt.Sprintf("machine-%s", runtimeKind), Architecture: "amd64", Kernel: "Linux fixture", SystemdVersion: "systemd 255"}, Runtime: rootlessQualRuntime{Runtime: runtimeKind, RuntimeVersion: "1.0.0", DaemonID: daemonID, CollectorUID: uid, SocketPath: socketPath, SocketUID: uid, SocketGID: uid, SocketMode: "0600", SocketType: "unix", DaemonRootless: true}, } run.Scenarios = []rootlessQualScenario{ makeScenario(0, "fresh_install", stream(fmt.Sprintf("%s-fresh-%d", runtimeKind, index)), sequence(1), fresh), makeScenario(2, "legacy_migration", stream(fmt.Sprintf("%s-migration-%d", runtimeKind, index)), sequence(1), migration), makeScenario(4, "collector_restart", stream(fmt.Sprintf("%s-restart-%d", runtimeKind, index)), sequence(1), restart), makeScenario(6, "daemon_restart", stream(fmt.Sprintf("%s-restart-%d", runtimeKind, index)), sequence(2), daemon), makeScenario(8, "socket_loss_helper_fallback", stream(fmt.Sprintf("%s-restart-%d", runtimeKind, index)), sequence(3), fallback), makeScenario(10, "direct_recovery", stream(fmt.Sprintf("%s-restart-%d", runtimeKind, index)), sequence(4), direct(102+index*10)), makeScenario(12, "dual_socket_ambiguity_refusal", nil, nil, ambiguity), makeScenario(14, "exact_pin_recovery", stream(fmt.Sprintf("%s-update-%d", runtimeKind, index)), sequence(2), pin), makeScenario(16, "telemetry_parity", stream(fmt.Sprintf("%s-update-%d", runtimeKind, index)), sequence(3), parity), makeScenario(18, "authority_isolation", nil, nil, authority), makeScenario(20, "cleanup", nil, nil, map[string]any{"runtime_stopped": true, "socket_absent": true, "fixtures_removed": true, "user_state_clean": true}), } return run } func TestRootlessQualificationGuardAndWrapperInvariants(t *testing.T) { if rootlessQualHasDefaultRoute("Iface Destination Gateway\neth0 00000000 0100007F") != true || rootlessQualHasDefaultRoute("Iface Destination Gateway\nlo 0000007F 00000000") { t.Fatal("default-route refusal parser drifted") } raw, err := os.ReadFile(repoFile("scripts", "run-secure-runtime-rootless-qualification.sh")) if err != nil { t.Fatal(err) } script := string(raw) for _, required := range []string{ `PULSE_ROOTLESS_UBUNTU_IMAGE`, `^ubuntu@sha256:`, `--network none`, `--cgroupns=private`, `--tmpfs /run`, `PULSE_SECURE_RUNTIME_ROOTLESS_QUALIFICATION=disposable-v1`, `dockeragent.test`, `run_runtime docker`, `run_runtime podman`, `PULSE_ROOTLESS_RUNTIME=${runtime_name}`, `--privileged`, `pulse-secure-runtime-rootless-qualification`, `qualification result != \"passed\"`, `openssl pkeyutl -sign -rawin -inkey`, `qualification output directory must have exact mode 0700`, `install -d -m 0700 /opt/pulse/packet /opt/pulse/result`, `PULSE_ROOTLESS_RECEIPT=/opt/pulse/result/rootless-receipt.json`, `docker cp "${container_id}:/opt/pulse/result/rootless-receipt.json"`, `capture_qualification_container_diagnostics`, `journalctl --no-pager -n 2000`, `302a300506032b6570032100`, `len(spki) != len(prefix) + 32`, `machine_id="$(openssl rand -hex 16)"`, `docker cp "${machine_id_file}" "${container_id}:/etc/machine-id"`, } { if !strings.Contains(script, required) { t.Fatalf("rootless qualification wrapper missing %q", required) } } if count := strings.Count(script, `-buildvcs=true`); count != 3 { t.Fatalf("rootless qualification wrapper must require VCS metadata for all three Go artifacts: got %d", count) } for _, forbidden := range []string{"/var/run/docker.sock:", "/run/podman/podman.sock:", "/sys/fs/cgroup:/sys/fs/cgroup", "/run/rootless-receipt.json", "--cgroupns=host", "-v $", "--volume"} { if strings.Contains(script, forbidden) { t.Fatalf("rootless qualification wrapper contains forbidden host-runtime mount marker %q", forbidden) } } if strings.Contains(script, "--private-key") || strings.Contains(script, "update_seed=") { t.Fatal("rootless wrapper must not put its ephemeral signing key in a process argument") } createIndex := strings.Index(script, `container_id="$(docker create`) trackIndex := strings.Index(script, `CONTAINER_IDS+=("${container_id}")`) if createIndex < 0 || trackIndex < 0 || trackIndex < createIndex { t.Fatal("rootless wrapper must track the exact container ID only after docker create succeeds") } packetDirectoryIndex := strings.Index(script, `install -d -m 0700 /opt/pulse/packet /opt/pulse/result`) packetCopyIndex := strings.Index(script, `docker cp "${PACKET_DIR}/." "${container_id}:/opt/pulse/packet"`) if packetDirectoryIndex < 0 || packetCopyIndex < 0 || packetCopyIndex < packetDirectoryIndex { t.Fatal("rootless wrapper must create the private packet destination in the image before artifact injection") } machineIDCopyIndex := strings.Index(script, `docker cp "${machine_id_file}" "${container_id}:/etc/machine-id"`) containerStartIndex := strings.Index(script, `docker start "${container_id}"`) if machineIDCopyIndex < 0 || containerStartIndex < 0 || machineIDCopyIndex > packetCopyIndex || machineIDCopyIndex > containerStartIndex { t.Fatal("rootless wrapper must install a per-host machine ID before packet injection and first systemd boot") } } func TestRootlessQualificationDockerCommandUsesSupportedNetworkDriver(t *testing.T) { d := rootlessQualDaemon{ rootlessUnit: "pulse-rootless-docker", rootlessSock: "/run/user/996/docker.sock", home: "/var/lib/pulse-rootless", } command := strings.Join(rootlessQualDockerStartArgs(d), "\x00") if !strings.Contains(command, "DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns") || !strings.Contains(command, "DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=none") { t.Fatalf("rootless Docker command does not select the supported contained drivers: %q", command) } if strings.Contains(command, "DOCKERD_ROOTLESS_ROOTLESSKIT_NET=host") { t.Fatalf("rootless Docker command selected the unsupported host network driver: %q", command) } } func runRootlessQualificationWithFakeDocker(t *testing.T, mode string) (string, string, error) { t.Helper() if _, err := exec.LookPath("python3"); err != nil { t.Skip("python3 is required for the wrapper ownership regression") } temporary := t.TempDir() fakeBin := filepath.Join(temporary, "bin") outputDir := filepath.Join(temporary, "output") if err := os.MkdirAll(fakeBin, 0o755); err != nil { t.Fatal(err) } if err := os.Mkdir(outputDir, 0o700); err != nil { t.Fatal(err) } writeExecutable := func(name, body string) { t.Helper() if err := os.WriteFile(filepath.Join(fakeBin, name), []byte(body), 0o755); err != nil { t.Fatalf("write fake %s: %v", name, err) } } writeExecutable("git", `#!/bin/sh case " $* " in *" branch --show-current "*) printf '%s\n' main ;; *" status --porcelain "*) : ;; *" rev-parse HEAD "*) printf '%040d\n' 0 | tr 0 a ;; *) printf 'unexpected fake git invocation: %s\n' "$*" >&2; exit 64 ;; esac `) writeExecutable("go", `#!/bin/sh if [ "${1:-}" = env ] && [ "${2:-}" = GOARCH ]; then printf '%s\n' amd64 exit 0 fi output='' while [ "$#" -gt 0 ]; do if [ "$1" = -o ]; then output="$2" break fi shift done [ -n "$output" ] || exit 65 printf '%s\n' fake-go-artifact >"$output" chmod 0700 "$output" `) writeExecutable("openssl", `#!/bin/sh command_name="${1:-}" shift || true case "$command_name" in rand) printf '%032d\n' 0 | tr 0 a ;; genpkey) while [ "$#" -gt 0 ]; do if [ "$1" = -out ]; then printf '%s\n' fake-private-key >"$2"; exit 0; fi shift done exit 66 ;; pkey) while [ "$#" -gt 0 ]; do if [ "$1" = -out ]; then python3 - "$2" <<'PY' from pathlib import Path import sys Path(sys.argv[1]).write_bytes(bytes.fromhex("302a300506032b6570032100") + bytes(32)) PY exit 0 fi shift done exit 67 ;; pkeyutl) printf '%s' fake-signature ;; base64) python3 -c 'import base64,sys; sys.stdout.write(base64.b64encode(sys.stdin.buffer.read()).decode())' ;; *) exit 68 ;; esac `) dockerLog := filepath.Join(temporary, "docker.log") dockerState := filepath.Join(temporary, "docker.state") dockerLabel := filepath.Join(temporary, "docker.label") writeExecutable("docker", `#!/bin/sh printf '%s\n' "$*" >>"$PULSE_FAKE_DOCKER_LOG" container_id='cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc' case "${1:-}" in info) exit 0 ;; build) printf '%s\n' fake-build ; exit 0 ;; image) printf '%s\n' '[{}]' ; exit 0 ;; create) if [ "$PULSE_FAKE_DOCKER_MODE" = name-conflict ]; then printf '%s\n' 'Conflict. The container name is already in use.' >&2 exit 125 fi previous='' for argument in "$@"; do if [ "$previous" = --label ]; then printf '%s\n' "${argument#*=}" >"$PULSE_FAKE_DOCKER_LABEL" fi previous="$argument" done : >"$PULSE_FAKE_DOCKER_STATE" printf '%s\n' "$container_id" ;; cp) case "${2:-}" in *:*) printf '%s\n' '{}' >"$3" ;; esac ;; start) exit 0 ;; exec) case " $* " in *" ip route "*) exit 1 ;; *) exit 0 ;; esac ;; inspect) case " $* " in *".Mounts"*) exit 0 ;; *".Config.Labels"*) [ "$PULSE_FAKE_DOCKER_MODE" != inspect-failure ] || exit 1 cat "$PULSE_FAKE_DOCKER_LABEL" ;; *) exit 70 ;; esac ;; rm) [ "$PULSE_FAKE_DOCKER_MODE" != rm-failure ] || exit 1 rm -f "$PULSE_FAKE_DOCKER_STATE" ;; ps) [ ! -f "$PULSE_FAKE_DOCKER_STATE" ] || printf '%s\n' "$container_id" ;; logs) exit 0 ;; *) printf 'unexpected fake docker invocation: %s\n' "$*" >&2; exit 69 ;; esac `) commit := strings.Repeat("a", 40) command := exec.Command("bash", repoFile("scripts", "run-secure-runtime-rootless-qualification.sh")) command.Env = append(os.Environ(), "PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"), "PULSE_FAKE_DOCKER_LOG="+dockerLog, "PULSE_FAKE_DOCKER_STATE="+dockerState, "PULSE_FAKE_DOCKER_LABEL="+dockerLabel, "PULSE_FAKE_DOCKER_MODE="+mode, "PULSE_ROOTLESS_UBUNTU_IMAGE=ubuntu@sha256:"+strings.Repeat("b", 64), "PULSE_ROOTLESS_QUALIFICATION_OUTPUT_DIR="+outputDir, "PULSE_ROOTLESS_QUALIFICATION_CONFIRM=I_HAVE_VERIFIED_THESE_ARE_DISPOSABLE_ROOTLESS_SYSTEMD_CONTAINERS_COMMIT_"+commit, ) output, err := command.CombinedOutput() logBytes, readErr := os.ReadFile(dockerLog) if readErr != nil { t.Fatal(readErr) } return string(output), string(logBytes), err } func TestRootlessQualificationNameConflictDoesNotRemoveContainer(t *testing.T) { output, logText, err := runRootlessQualificationWithFakeDocker(t, "name-conflict") if err == nil { t.Fatalf("wrapper unexpectedly succeeded after Docker name conflict: %s", output) } if !strings.Contains(logText, "create --name pulse-rootless-qual-docker-") { t.Fatalf("fake Docker did not reach the name-conflict create; docker log=%s wrapper output=%s", logText, output) } for _, line := range strings.Split(logText, "\n") { if strings.HasPrefix(line, "rm ") { t.Fatalf("name-conflict cleanup attempted to remove an unowned container: %s", logText) } } } func TestRootlessQualificationCleanupFailureCannotEmitPassingPacket(t *testing.T) { for _, mode := range []string{"inspect-failure", "rm-failure"} { t.Run(mode, func(t *testing.T) { output, logText, err := runRootlessQualificationWithFakeDocker(t, mode) if err == nil { t.Fatalf("wrapper unexpectedly succeeded after %s: %s", mode, output) } if strings.Contains(output, "Rootless qualification passed:") { t.Fatalf("wrapper emitted a passing packet after %s: %s", mode, output) } if !strings.Contains(logText, ".Config.Labels") { t.Fatalf("wrapper did not reach strict ownership verification after %s: %s", mode, logText) } if mode == "rm-failure" && !strings.Contains(logText, "rm -f cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc") { t.Fatalf("wrapper did not exercise strict removal failure: %s", logText) } }) } }