From 6c8d0d367b1a0c3de8b136ff92b3533c577205e2 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 5 Mar 2026 09:33:36 +0000 Subject: [PATCH] fix(apt): fix extractURL multi-token options parsing and add unit tests - Fix bug in extractURL where multi-token option blocks like [arch=amd64 signed-by=/path/key.gpg] were not properly skipped, causing the option value to be returned instead of the URL - Add comprehensive unit tests for parseList, parseSearch, parseInfo edge cases (empty input, missing fields, special characters) - Add unit tests for normalizeName and parseArch covering all supported Debian architectures and edge cases - Add unit tests for formatTargets, buildArgs (all option combos), and extractURL (basic, options, signed-by) - Coverage: apt package 7.5% -> 17.1% --- apt/capabilities_linux.go | 18 ++-- apt/helpers_linux_test.go | 199 ++++++++++++++++++++++++++++++++++++++ apt/normalize_test.go | 75 ++++++++++++++ apt/parse_test.go | 196 +++++++++++++++++++++++++++++++++++++ 4 files changed, 482 insertions(+), 6 deletions(-) create mode 100644 apt/helpers_linux_test.go create mode 100644 apt/normalize_test.go create mode 100644 apt/parse_test.go diff --git a/apt/capabilities_linux.go b/apt/capabilities_linux.go index 8b6adf4..ae73d44 100644 --- a/apt/capabilities_linux.go +++ b/apt/capabilities_linux.go @@ -271,19 +271,25 @@ func listRepos(_ context.Context) ([]snack.Repository, error) { // extractURL pulls the URL from a deb/deb-src line. func extractURL(line string) string { fields := strings.Fields(line) + inOptions := false for i, f := range fields { if i == 0 { continue // skip deb/deb-src } - if strings.HasPrefix(f, "[") { - // skip options block - for ; i < len(fields); i++ { - if strings.HasSuffix(fields[i], "]") { - break - } + if inOptions { + if strings.HasSuffix(f, "]") { + inOptions = false } continue } + if strings.HasPrefix(f, "[") { + if strings.HasSuffix(f, "]") { + // Single-token options like [arch=amd64] + continue + } + inOptions = true + continue + } return f } return "" diff --git a/apt/helpers_linux_test.go b/apt/helpers_linux_test.go new file mode 100644 index 0000000..b1626b9 --- /dev/null +++ b/apt/helpers_linux_test.go @@ -0,0 +1,199 @@ +//go:build linux + +package apt + +import ( + "testing" + + "github.com/gogrlx/snack" +) + +func TestFormatTargets(t *testing.T) { + tests := []struct { + name string + targets []snack.Target + want []string + }{ + { + name: "empty", + targets: nil, + want: []string{}, + }, + { + name: "name_only", + targets: []snack.Target{{Name: "curl"}}, + want: []string{"curl"}, + }, + { + name: "with_version", + targets: []snack.Target{{Name: "curl", Version: "7.88"}}, + want: []string{"curl=7.88"}, + }, + { + name: "mixed", + targets: []snack.Target{ + {Name: "curl"}, + {Name: "bash", Version: "5.2-1"}, + {Name: "vim"}, + }, + want: []string{"curl", "bash=5.2-1", "vim"}, + }, + { + name: "version_with_epoch", + targets: []snack.Target{{Name: "systemd", Version: "1:252-2"}}, + want: []string{"systemd=1:252-2"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatTargets(tt.targets) + if len(got) != len(tt.want) { + t.Fatalf("formatTargets() returned %d args, want %d", len(got), len(tt.want)) + } + for i, g := range got { + if g != tt.want[i] { + t.Errorf("formatTargets()[%d] = %q, want %q", i, g, tt.want[i]) + } + } + }) + } +} + +func TestBuildArgs(t *testing.T) { + tests := []struct { + name string + cmd string + pkgs []snack.Target + opts []snack.Option + want []string + }{ + { + name: "basic_install", + cmd: "install", + pkgs: []snack.Target{{Name: "curl"}}, + want: []string{"apt-get", "install", "curl"}, + }, + { + name: "install_with_yes", + cmd: "install", + pkgs: []snack.Target{{Name: "curl"}}, + opts: []snack.Option{snack.WithAssumeYes()}, + want: []string{"apt-get", "install", "-y", "curl"}, + }, + { + name: "install_with_dry_run", + cmd: "install", + pkgs: []snack.Target{{Name: "curl"}}, + opts: []snack.Option{snack.WithDryRun()}, + want: []string{"apt-get", "install", "--dry-run", "curl"}, + }, + { + name: "install_with_sudo", + cmd: "install", + pkgs: []snack.Target{{Name: "curl"}}, + opts: []snack.Option{snack.WithSudo()}, + want: []string{"sudo", "apt-get", "install", "curl"}, + }, + { + name: "install_with_reinstall", + cmd: "install", + pkgs: []snack.Target{{Name: "curl"}}, + opts: []snack.Option{snack.WithReinstall()}, + want: []string{"apt-get", "install", "--reinstall", "curl"}, + }, + { + name: "remove_no_reinstall_flag", + cmd: "remove", + pkgs: []snack.Target{{Name: "curl"}}, + opts: []snack.Option{snack.WithReinstall()}, + want: []string{"apt-get", "remove", "curl"}, // --reinstall only on install + }, + { + name: "install_from_repo", + cmd: "install", + pkgs: []snack.Target{{Name: "curl"}}, + opts: []snack.Option{snack.WithFromRepo("stable")}, + want: []string{"apt-get", "install", "-t", "stable", "curl"}, + }, + { + name: "all_options", + cmd: "install", + pkgs: []snack.Target{{Name: "curl", Version: "7.88"}}, + opts: []snack.Option{snack.WithSudo(), snack.WithAssumeYes(), snack.WithDryRun(), snack.WithFromRepo("sid"), snack.WithReinstall()}, + want: []string{"sudo", "apt-get", "install", "-y", "--dry-run", "-t", "sid", "--reinstall", "curl=7.88"}, + }, + { + name: "multiple_packages", + cmd: "install", + pkgs: []snack.Target{{Name: "curl"}, {Name: "wget"}, {Name: "bash", Version: "5.2"}}, + want: []string{"apt-get", "install", "curl", "wget", "bash=5.2"}, + }, + { + name: "upgrade_no_packages", + cmd: "upgrade", + pkgs: nil, + opts: []snack.Option{snack.WithAssumeYes()}, + want: []string{"apt-get", "upgrade", "-y"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildArgs(tt.cmd, tt.pkgs, tt.opts...) + if len(got) != len(tt.want) { + t.Fatalf("buildArgs() = %v (len %d), want %v (len %d)", got, len(got), tt.want, len(tt.want)) + } + for i, g := range got { + if g != tt.want[i] { + t.Errorf("buildArgs()[%d] = %q, want %q", i, g, tt.want[i]) + } + } + }) + } +} + +func TestExtractURL(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "basic_deb", + input: "deb http://archive.ubuntu.com/ubuntu/ jammy main", + want: "http://archive.ubuntu.com/ubuntu/", + }, + { + name: "deb_src", + input: "deb-src http://archive.ubuntu.com/ubuntu/ jammy main", + want: "http://archive.ubuntu.com/ubuntu/", + }, + { + name: "with_options", + input: "deb [arch=amd64] https://apt.example.com/repo stable main", + want: "https://apt.example.com/repo", + }, + { + name: "with_signed_by", + input: "deb [arch=amd64 signed-by=/etc/apt/keyrings/key.gpg] https://repo.example.com/deb stable main", + want: "https://repo.example.com/deb", + }, + { + name: "just_type", + input: "deb", + want: "", + }, + { + name: "empty_options_bracket", + input: "deb [] http://example.com/repo stable", + want: "http://example.com/repo", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractURL(tt.input) + if got != tt.want { + t.Errorf("extractURL(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/apt/normalize_test.go b/apt/normalize_test.go new file mode 100644 index 0000000..4880ef8 --- /dev/null +++ b/apt/normalize_test.go @@ -0,0 +1,75 @@ +package apt + +import "testing" + +func TestNormalizeName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"curl", "curl"}, + {"curl:amd64", "curl"}, + {"bash:arm64", "bash"}, + {"python3:i386", "python3"}, + {"libc6:armhf", "libc6"}, + {"pkg:armel", "pkg"}, + {"pkg:mips", "pkg"}, + {"pkg:mipsel", "pkg"}, + {"pkg:mips64el", "pkg"}, + {"pkg:ppc64el", "pkg"}, + {"pkg:s390x", "pkg"}, + {"pkg:all", "pkg"}, + {"pkg:any", "pkg"}, + // Colon that isn't a known arch should be kept + {"pkg:unknown", "pkg:unknown"}, + {"libstdc++6:amd64", "libstdc++6"}, + // No colon + {"", ""}, + // Multiple colons — only last one checked + {"a:b:amd64", "a:b"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := normalizeName(tt.input) + if got != tt.want { + t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestParseArch(t *testing.T) { + tests := []struct { + input string + wantName string + wantArch string + }{ + {"curl:amd64", "curl", "amd64"}, + {"bash:arm64", "bash", "arm64"}, + {"python3", "python3", ""}, + {"curl:i386", "curl", "i386"}, + {"pkg:armhf", "pkg", "armhf"}, + {"pkg:armel", "pkg", "armel"}, + {"pkg:mips", "pkg", "mips"}, + {"pkg:mipsel", "pkg", "mipsel"}, + {"pkg:mips64el", "pkg", "mips64el"}, + {"pkg:ppc64el", "pkg", "ppc64el"}, + {"pkg:s390x", "pkg", "s390x"}, + {"pkg:all", "pkg", "all"}, + {"pkg:any", "pkg", "any"}, + // Unknown arch — not split + {"pkg:foobar", "pkg:foobar", ""}, + {"", "", ""}, + // Multiple colons + {"a:b:arm64", "a:b", "arm64"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + name, arch := parseArch(tt.input) + if name != tt.wantName || arch != tt.wantArch { + t.Errorf("parseArch(%q) = (%q, %q), want (%q, %q)", + tt.input, name, arch, tt.wantName, tt.wantArch) + } + }) + } +} diff --git a/apt/parse_test.go b/apt/parse_test.go new file mode 100644 index 0000000..42c40cd --- /dev/null +++ b/apt/parse_test.go @@ -0,0 +1,196 @@ +package apt + +import ( + "testing" + + "github.com/gogrlx/snack" +) + +func TestParseList_EdgeCases(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + {"empty", "", 0}, + {"whitespace_only", " \n \n ", 0}, + {"single_tab_field", "bash", 0}, // needs at least 2 tab-separated fields + {"no_description", "bash\t5.2-1", 1}, // 2 fields is OK + {"with_description", "bash\t5.2-1\tGNU Bourne Again SHell", 1}, + {"blank_lines_mixed", "\nbash\t5.2-1\n\ncurl\t7.88\n\n", 2}, + {"trailing_newline", "bash\t5.2-1\n", 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parseList(tt.input) + if len(pkgs) != tt.want { + t.Errorf("parseList() returned %d packages, want %d", len(pkgs), tt.want) + } + }) + } + + // Verify fields are populated correctly + t.Run("field_values", func(t *testing.T) { + input := "bash\t5.2-1\tGNU Bourne Again SHell" + pkgs := parseList(input) + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + p := pkgs[0] + if p.Name != "bash" { + t.Errorf("Name = %q, want %q", p.Name, "bash") + } + if p.Version != "5.2-1" { + t.Errorf("Version = %q, want %q", p.Version, "5.2-1") + } + if p.Description != "GNU Bourne Again SHell" { + t.Errorf("Description = %q, want %q", p.Description, "GNU Bourne Again SHell") + } + if !p.Installed { + t.Error("expected Installed=true") + } + }) + + t.Run("no_description_fields", func(t *testing.T) { + pkgs := parseList("vim\t9.0") + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Description != "" { + t.Errorf("Description = %q, want empty", pkgs[0].Description) + } + }) + + t.Run("description_with_tabs", func(t *testing.T) { + // Third field captures everything after the second tab + pkgs := parseList("pkg\t1.0\tdesc\twith\ttabs") + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Description != "desc\twith\ttabs" { + t.Errorf("Description = %q, want %q", pkgs[0].Description, "desc\twith\ttabs") + } + }) +} + +func TestParseSearch_EdgeCases(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + {"empty", "", 0}, + {"whitespace_only", " \n ", 0}, + {"no_dash_separator", "vim", 1}, // still parses, just no description + {"normal", "vim - Vi IMproved", 1}, + {"blank_lines", "\nvim - Vi IMproved\n\nnano - small editor\n", 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkgs := parseSearch(tt.input) + if len(pkgs) != tt.want { + t.Errorf("parseSearch() returned %d packages, want %d", len(pkgs), tt.want) + } + }) + } + + t.Run("no_description", func(t *testing.T) { + pkgs := parseSearch("vim") + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "vim" { + t.Errorf("Name = %q, want %q", pkgs[0].Name, "vim") + } + if pkgs[0].Description != "" { + t.Errorf("Description = %q, want empty", pkgs[0].Description) + } + }) + + t.Run("description_with_dashes", func(t *testing.T) { + // Only splits on first " - " + pkgs := parseSearch("gcc - GNU C Compiler - version 12") + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "gcc" { + t.Errorf("Name = %q, want %q", pkgs[0].Name, "gcc") + } + if pkgs[0].Description != "GNU C Compiler - version 12" { + t.Errorf("Description = %q, want %q", pkgs[0].Description, "GNU C Compiler - version 12") + } + }) + + t.Run("whitespace_trimming", func(t *testing.T) { + pkgs := parseSearch(" vim - Vi IMproved ") + if len(pkgs) != 1 { + t.Fatalf("expected 1 package, got %d", len(pkgs)) + } + if pkgs[0].Name != "vim" { + t.Errorf("Name = %q, want %q", pkgs[0].Name, "vim") + } + if pkgs[0].Description != "Vi IMproved" { + t.Errorf("Description = %q, want %q", pkgs[0].Description, "Vi IMproved") + } + }) +} + +func TestParseInfo_EdgeCases(t *testing.T) { + t.Run("all_fields", func(t *testing.T) { + input := "Package: bash\nVersion: 5.2-1\nArchitecture: amd64\nDescription: GNU Bourne Again SHell\n" + p, err := parseInfo(input) + if err != nil { + t.Fatal(err) + } + if p.Name != "bash" || p.Version != "5.2-1" || p.Arch != "amd64" || p.Description != "GNU Bourne Again SHell" { + t.Errorf("unexpected package: %+v", p) + } + }) + + t.Run("empty_returns_not_found", func(t *testing.T) { + _, err := parseInfo("") + if err != snack.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } + }) + + t.Run("no_package_field", func(t *testing.T) { + _, err := parseInfo("Version: 1.0\nArchitecture: amd64\n") + if err != snack.ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } + }) + + t.Run("extra_fields_ignored", func(t *testing.T) { + input := "Package: curl\nMaintainer: someone\nVersion: 7.88\nPriority: optional\n" + p, err := parseInfo(input) + if err != nil { + t.Fatal(err) + } + if p.Name != "curl" || p.Version != "7.88" { + t.Errorf("unexpected: %+v", p) + } + }) + + t.Run("lines_without_colon", func(t *testing.T) { + input := "Package: vim\nsome continuation line\nVersion: 9.0\n" + p, err := parseInfo(input) + if err != nil { + t.Fatal(err) + } + if p.Name != "vim" || p.Version != "9.0" { + t.Errorf("unexpected: %+v", p) + } + }) + + t.Run("version_with_epoch", func(t *testing.T) { + input := "Package: systemd\nVersion: 1:252-2\nArchitecture: amd64\n" + p, err := parseInfo(input) + if err != nil { + t.Fatal(err) + } + if p.Version != "1:252-2" { + t.Errorf("Version = %q, want %q", p.Version, "1:252-2") + } + }) +}