From 21bf58985ee3b931c52ea10ffef067859b7e2156 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 18:28:31 +0000 Subject: [PATCH] Pin apt child process locale in UpdatesCollector apt list --upgradable localises its output via gettext. Collect built the child environment by appending to os.Environ(), so on any non-English locale (e.g. de_DE) apt prints "aktualisierbar von:" instead of "upgradable from:", the parser regex never matches, and Updates.Count silently reports 0 forever with no error or log warning. Build the child environment explicitly instead of inheriting the host's: set LC_ALL, LANG and LANGUAGE to force English/C output, and preserve PATH so the bare "apt" command name still resolves via exec.LookPath. --- internal/collector/updates.go | 13 ++++++++- internal/collector/updates_test.go | 47 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/internal/collector/updates.go b/internal/collector/updates.go index 498cadf..5b7a45a 100644 --- a/internal/collector/updates.go +++ b/internal/collector/updates.go @@ -104,7 +104,18 @@ func (c *UpdatesCollector) Collect(ctx context.Context) (Updates, error) { defer cancel() cmd := exec.CommandContext(ctx, c.aptPath, "list", "--upgradable") - cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive") + // apt localises its output via gettext; the parser above matches the + // English strings, so pin the locale rather than inheriting the + // operator's. LC_ALL beats LANG and LANGUAGE, but all three are set so + // no inherited value can leak through. PATH must be preserved because + // aptPath is typically the bare string "apt", resolved via PATH. + cmd.Env = []string{ + "LC_ALL=C", + "LANG=C", + "LANGUAGE=", + "DEBIAN_FRONTEND=noninteractive", + "PATH=" + os.Getenv("PATH"), + } out, err := cmd.Output() if err != nil { return Updates{}, fmt.Errorf("run apt list --upgradable: %w", err) diff --git a/internal/collector/updates_test.go b/internal/collector/updates_test.go index 957f63b..0e19d36 100644 --- a/internal/collector/updates_test.go +++ b/internal/collector/updates_test.go @@ -2,8 +2,10 @@ package collector import ( "context" + "fmt" "os" "path/filepath" + "strings" "testing" "time" ) @@ -92,3 +94,48 @@ func TestUpdatesCollector_Collect_StalenessDetection(t *testing.T) { t.Fatalf("expected Stale=true for a 48h old cache with a 6h threshold, got %+v", updates) } } + +func TestUpdatesCollector_Collect_PinsLocale(t *testing.T) { + dir := t.TempDir() + envFile := filepath.Join(dir, "env.out") + aptStub := writeFakeVcgencmd(t, dir, "fake-apt", fmt.Sprintf("env > %q", envFile)) + + c := &UpdatesCollector{ + aptPath: aptStub, + listsDir: filepath.Join(dir, "missing-lists"), + now: time.Now, + } + + if _, err := c.Collect(context.Background()); err != nil { + t.Fatalf("Collect: %v", err) + } + + out, err := os.ReadFile(envFile) + if err != nil { + t.Fatalf("read captured child environment: %v", err) + } + + env := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + env[k] = v + } + + if env["LC_ALL"] != "C" { + t.Errorf("LC_ALL = %q, want C", env["LC_ALL"]) + } + if env["LANG"] != "C" { + t.Errorf("LANG = %q, want C", env["LANG"]) + } + if v, ok := env["LANGUAGE"]; ok && v != "" { + t.Errorf("LANGUAGE = %q, want empty", v) + } + // Regression guard: PATH must survive, otherwise "apt" (a bare command + // name, resolved via PATH) would not be executable at all. + if env["PATH"] == "" { + t.Error("PATH not present in child environment; apt would not be resolvable") + } +}