diff --git a/.github/workflows/action-tests.yml b/.github/workflows/action-tests.yml index 86cb1c0..b34d352 100644 --- a/.github/workflows/action-tests.yml +++ b/.github/workflows/action-tests.yml @@ -54,13 +54,13 @@ jobs: - name: Verify if: | steps.execute.outputs.cache-hit != 'false' || - steps.execute.outputs.all-package-version-list != 'fonts-liberation2=1:2.1.5-3,gir1.2-atk-1.0=2.52.0-1build1,gir1.2-freedesktop=1.80.1-1,gir1.2-gdkpixbuf-2.0=2.42.10+dfsg-3ubuntu3.2,gir1.2-gtk-3.0=3.24.41-4ubuntu1.3,gir1.2-harfbuzz-0.0=8.3.0-2build2,gir1.2-pango-1.0=1.52.1+ds-1build1,graphviz=2.42.2-9ubuntu0.1,libann0=1.1.2+doc-9build1,libblas3=3.12.0-3build1.1,libcdt5=2.42.2-9ubuntu0.1,libcgraph6=2.42.2-9ubuntu0.1,libgts-0.7-5t64=0.7.6+darcs121130-5.2build1,libgts-bin=0.7.6+darcs121130-5.2build1,libgvc6=2.42.2-9ubuntu0.1,libgvpr2=2.42.2-9ubuntu0.1,libharfbuzz-gobject0=8.3.0-2build2,liblab-gamut1=2.42.2-9ubuntu0.1,liblapack3=3.12.0-3build1.1,libpangoxft-1.0-0=1.52.1+ds-1build1,libpathplan4=2.42.2-9ubuntu0.1,python3-cairo=1.25.1-2build2,python3-gi-cairo=3.48.2-1,python3-numpy=1:1.26.4+ds-6ubuntu1,xdot=1.3-1' + steps.execute.outputs.all-package-version-list != 'fonts-liberation2=1:2.1.5-3,gir1.2-atk-1.0=2.52.0-1build1,gir1.2-freedesktop=1.80.1-1,gir1.2-gdkpixbuf-2.0=2.42.10+dfsg-3ubuntu3.3,gir1.2-gtk-3.0=3.24.41-4ubuntu1.3,gir1.2-harfbuzz-0.0=8.3.0-2build2,gir1.2-pango-1.0=1.52.1+ds-1build1,graphviz=2.42.2-9ubuntu0.1,libann0=1.1.2+doc-9build1,libblas3=3.12.0-3build1.1,libcdt5=2.42.2-9ubuntu0.1,libcgraph6=2.42.2-9ubuntu0.1,libgts-0.7-5t64=0.7.6+darcs121130-5.2build1,libgts-bin=0.7.6+darcs121130-5.2build1,libgvc6=2.42.2-9ubuntu0.1,libgvpr2=2.42.2-9ubuntu0.1,libharfbuzz-gobject0=8.3.0-2build2,liblab-gamut1=2.42.2-9ubuntu0.1,liblapack3=3.12.0-3build1.1,libpangoxft-1.0-0=1.52.1+ds-1build1,libpathplan4=2.42.2-9ubuntu0.1,python3-cairo=1.25.1-2build2,python3-gi-cairo=3.48.2-1,python3-numpy=1:1.26.4+ds-6ubuntu1,xdot=1.3-1' run: | echo "cache-hit = ${{ steps.execute.outputs.cache-hit }}" echo "package-version-list = ${{ steps.execute.outputs.package-version-list }}" echo "all-package-version-list = ${{ steps.execute.outputs.all-package-version-list }}" echo "diff all-package-version-list" - diff <(echo "${{ steps.execute.outputs.all-package-version-list }}" ) <(echo "fonts-liberation2=1:2.1.5-3,gir1.2-atk-1.0=2.52.0-1build1,gir1.2-freedesktop=1.80.1-1,gir1.2-gdkpixbuf-2.0=2.42.10+dfsg-3ubuntu3.2,gir1.2-gtk-3.0=3.24.41-4ubuntu1.3,gir1.2-harfbuzz-0.0=8.3.0-2build2,gir1.2-pango-1.0=1.52.1+ds-1build1,graphviz=2.42.2-9ubuntu0.1,libann0=1.1.2+doc-9build1,libblas3=3.12.0-3build1.1,libcdt5=2.42.2-9ubuntu0.1,libcgraph6=2.42.2-9ubuntu0.1,libgts-0.7-5t64=0.7.6+darcs121130-5.2build1,libgts-bin=0.7.6+darcs121130-5.2build1,libgvc6=2.42.2-9ubuntu0.1,libgvpr2=2.42.2-9ubuntu0.1,libharfbuzz-gobject0=8.3.0-2build2,liblab-gamut1=2.42.2-9ubuntu0.1,liblapack3=3.12.0-3build1.1,libpangoxft-1.0-0=1.52.1+ds-1build1,libpathplan4=2.42.2-9ubuntu0.1,python3-cairo=1.25.1-2build2,python3-gi-cairo=3.48.2-1,python3-numpy=1:1.26.4+ds-6ubuntu1,xdot=1.3-1") + diff <(echo "${{ steps.execute.outputs.all-package-version-list }}" ) <(echo "fonts-liberation2=1:2.1.5-3,gir1.2-atk-1.0=2.52.0-1build1,gir1.2-freedesktop=1.80.1-1,gir1.2-gdkpixbuf-2.0=2.42.10+dfsg-3ubuntu3.3,gir1.2-gtk-3.0=3.24.41-4ubuntu1.3,gir1.2-harfbuzz-0.0=8.3.0-2build2,gir1.2-pango-1.0=1.52.1+ds-1build1,graphviz=2.42.2-9ubuntu0.1,libann0=1.1.2+doc-9build1,libblas3=3.12.0-3build1.1,libcdt5=2.42.2-9ubuntu0.1,libcgraph6=2.42.2-9ubuntu0.1,libgts-0.7-5t64=0.7.6+darcs121130-5.2build1,libgts-bin=0.7.6+darcs121130-5.2build1,libgvc6=2.42.2-9ubuntu0.1,libgvpr2=2.42.2-9ubuntu0.1,libharfbuzz-gobject0=8.3.0-2build2,liblab-gamut1=2.42.2-9ubuntu0.1,liblapack3=3.12.0-3build1.1,libpangoxft-1.0-0=1.52.1+ds-1build1,libpathplan4=2.42.2-9ubuntu0.1,python3-cairo=1.25.1-2build2,python3-gi-cairo=3.48.2-1,python3-numpy=1:1.26.4+ds-6ubuntu1,xdot=1.3-1") exit 1 shell: bash diff --git a/src/cmd/apt_query/main_test.go b/src/cmd/apt_query/main_test.go index 49e040d..c2a7aca 100644 --- a/src/cmd/apt_query/main_test.go +++ b/src/cmd/apt_query/main_test.go @@ -68,3 +68,12 @@ func TestNormalizedList_VirtualPackagesExists_StdoutsConcretePackage(t *testing. result := cmdtesting.New(t, createReplayLogs).Run("normalized-list", "libvips") result.ExpectSuccessfulOut("libvips42=8.9.1-2") } + +// Regression test for https://github.com/awalsh128/cache-apt-pkgs-action/issues/218: +// a real package (ghostscript) that apt-cache show reports as purely virtual on stale apt lists +// must resolve to itself (ghostscript=), not to a mangled section-header token such as +// "Reverse=Provides:". +func TestNormalizedList_RealPackageMisidentifiedAsVirtual_StdoutsCorrectPackage(t *testing.T) { + result := cmdtesting.New(t, createReplayLogs).Run("normalized-list", "ghostscript") + result.ExpectSuccessfulOut("ghostscript=10.02.1~dfsg1-0ubuntu7.8") +} diff --git a/src/cmd/apt_query/testlogs/testnormalizedlist_realpackagemisidentifiedasvirtual_stdoutscorrectpackage.log b/src/cmd/apt_query/testlogs/testnormalizedlist_realpackagemisidentifiedasvirtual_stdoutscorrectpackage.log new file mode 100644 index 0000000..2289402 --- /dev/null +++ b/src/cmd/apt_query/testlogs/testnormalizedlist_realpackagemisidentifiedasvirtual_stdoutscorrectpackage.log @@ -0,0 +1,19 @@ +2025/03/15 22:29:14 Debug log created at /home/awalsh128/cache-apt-pkgs-action/src/cmd/apt_query/apt_query.log +2025/03/15 22:29:14 EXECUTION-OBJ-START +{ + "Cmd": "apt-cache --quiet=0 --no-all-versions show ghostscript", + "Stdout": "", + "Stderr": "N: Can't select candidate version from package ghostscript as it has no candidate\nN: Can't select versions from package 'ghostscript' as it is purely virtual\nN: No packages found\n", + "CombinedOut": "N: Can't select candidate version from package ghostscript as it has no candidate\nN: Can't select versions from package 'ghostscript' as it is purely virtual\nN: No packages found\n", + "ExitCode": 0 +} +EXECUTION-OBJ-END +2025/03/15 22:29:14 EXECUTION-OBJ-START +{ + "Cmd": "apt-cache showpkg ghostscript", + "Stdout": "Package: ghostscript\nVersions: \n10.02.1~dfsg1-0ubuntu7.8 (/var/lib/dpkg/info/ghostscript.list)\n Description Language: \n File: /var/lib/apt/lists/example\n MD5: ab0ac55fd3cdba396944cd65f6d36654\n\nReverse Depends: \n cups,ghostscript\nDependencies: \n10.02.1~dfsg1-0ubuntu7.8 - libgs10 (5 10.02.1~dfsg1-0ubuntu7.8) libc6 (2 2.34)\nProvides: \n10.02.1~dfsg1-0ubuntu7.8 - postscript-viewer (= ) ghostscript-x (= 10.02.1~dfsg1-0ubuntu7.8)\nReverse Provides: \n", + "Stderr": "", + "CombinedOut": "Package: ghostscript\nVersions: \n10.02.1~dfsg1-0ubuntu7.8 (/var/lib/dpkg/info/ghostscript.list)\n Description Language: \n File: /var/lib/apt/lists/example\n MD5: ab0ac55fd3cdba396944cd65f6d36654\n\nReverse Depends: \n cups,ghostscript\nDependencies: \n10.02.1~dfsg1-0ubuntu7.8 - libgs10 (5 10.02.1~dfsg1-0ubuntu7.8) libc6 (2 2.34)\nProvides: \n10.02.1~dfsg1-0ubuntu7.8 - postscript-viewer (= ) ghostscript-x (= 10.02.1~dfsg1-0ubuntu7.8)\nReverse Provides: \n", + "ExitCode": 0 +} +EXECUTION-OBJ-END diff --git a/src/internal/common/apt.go b/src/internal/common/apt.go index bc20d19..56465e5 100644 --- a/src/internal/common/apt.go +++ b/src/internal/common/apt.go @@ -32,6 +32,9 @@ func isErrLine(line string) bool { } // Resolves virtual packages names to their concrete one. +// Falls back to returning the package itself (with version from the Versions section) when the +// Reverse Provides section is empty, which handles real packages that are mistakenly treated as +// virtual due to stale apt lists. func getNonVirtualPackage(executor exec.Executor, name string) (pkg *AptPackage, err error) { execution := executor.Exec("apt-cache", "showpkg", name) err = execution.Error() @@ -40,28 +43,81 @@ func getNonVirtualPackage(executor exec.Executor, name string) (pkg *AptPackage, return pkg, err } + const reverseProvides = "Reverse Provides:" + const versions = "Versions:" + + inVersions := false inReverseProvides := false + var firstVersion string + for _, line := range strings.Split(execution.CombinedOut, "\n") { trimmed := strings.TrimSpace(line) if trimmed == "" { continue } - if trimmed == "Reverse Provides:" { - inReverseProvides = true + if strings.HasPrefix(trimmed, "W: ") || isErrLine(trimmed) { continue } - if !inReverseProvides || strings.HasPrefix(trimmed, "W: ") || isErrLine(trimmed) { + if trimmed == versions { + inVersions = true + inReverseProvides = false + continue + } + if trimmed == reverseProvides { + inReverseProvides = true + inVersions = false + continue + } + // Handle "Reverse Provides: " format where the header and first entry + // appear on the same line. If the inline content is parseable (≥2 words), return it + // immediately. If it is malformed or absent (< 2 words), treat the line as a plain + // section header so that subsequent lines are still scanned for entries. + if strings.HasPrefix(trimmed, reverseProvides+" ") { + entry := strings.TrimSpace(trimmed[len(reverseProvides)+1:]) + if entry != "" { + // Split into at most 3 parts: name, version, and any trailing metadata (e.g. "(= )") + splitLine := GetSplitLine(entry, " ", 3) + if len(splitLine.Words) >= 2 { + return &AptPackage{Name: splitLine.Words[0], Version: splitLine.Words[1]}, nil + } + } + // No parseable entry on this line; fall through to entry-scanning mode so + // subsequent lines in this section are still examined. + inReverseProvides = true + inVersions = false continue } if strings.HasSuffix(trimmed, ":") { - break - } - splitLine := GetSplitLine(trimmed, " ", 3) - if len(splitLine.Words) < 2 { + // Some other section header — reset tracking flags. + inVersions = false + inReverseProvides = false continue } - return &AptPackage{Name: splitLine.Words[0], Version: splitLine.Words[1]}, nil + // Capture the first version listed for this package (used as fallback below). + // Split into at most 2 parts: version and the file-path metadata that follows. + if inVersions && firstVersion == "" { + splitLine := GetSplitLine(trimmed, " ", 2) + if len(splitLine.Words) >= 1 && splitLine.Words[0] != "" { + firstVersion = splitLine.Words[0] + } + } + if inReverseProvides { + // Split into at most 3 parts: name, version, and any trailing metadata (e.g. "(= )"). + splitLine := GetSplitLine(trimmed, " ", 3) + if len(splitLine.Words) < 2 { + continue + } + return &AptPackage{Name: splitLine.Words[0], Version: splitLine.Words[1]}, nil + } } + + // If no Reverse Provides entries were found but the package itself has a known version, + // it is a real (non-virtual) package that was mistakenly treated as virtual (e.g. because + // apt-cache show was run against stale lists). Return it directly. + if firstVersion != "" { + return &AptPackage{Name: name, Version: firstVersion}, nil + } + return pkg, fmt.Errorf("unable to parse reverse provides package name and version from apt-cache showpkg output below:\n%s", execution.CombinedOut) } diff --git a/src/internal/common/apt_test.go b/src/internal/common/apt_test.go index d8aaa2c..38991dc 100644 --- a/src/internal/common/apt_test.go +++ b/src/internal/common/apt_test.go @@ -51,3 +51,116 @@ func TestGetNonVirtualPackage_WithWarningsInReverseProvides(t *testing.T) { t.Fatalf("unexpected package.\nexpected: %+v\nactual: %+v", expected, *pkg) } } + +// Regression test for https://github.com/awalsh128/cache-apt-pkgs-action/issues/218 +// A real package (ghostscript) that is mis-identified as purely virtual due to stale apt lists +// has an empty "Reverse Provides:" section in apt-cache showpkg. getNonVirtualPackage must +// fall back to the "Versions:" section and return the package itself rather than failing or +// returning the section header as the package name. +func TestGetNonVirtualPackage_WithRealPackageMisidentifiedAsVirtual(t *testing.T) { + executor := mockExecutor{ + executions: map[string]*execpkg.Execution{ + "apt-cache showpkg ghostscript": { + Cmd: "apt-cache showpkg ghostscript", + CombinedOut: strings.Join([]string{ + "Package: ghostscript", + "Versions: ", + "10.02.1~dfsg1-0ubuntu7.8 (/var/lib/dpkg/info/ghostscript.list)", + " Description Language: ", + " File: /var/lib/apt/lists/example", + " MD5: abc123", + "", + "Reverse Depends: ", + " cups,ghostscript", + "Dependencies: ", + "10.02.1~dfsg1-0ubuntu7.8 - libgs10 (5 10.02.1~dfsg1-0ubuntu7.8)", + "Provides: ", + "10.02.1~dfsg1-0ubuntu7.8 - postscript-viewer (= ) ghostscript-x (= 10.02.1~dfsg1-0ubuntu7.8)", + "Reverse Provides: ", + "", + }, "\n"), + ExitCode: 0, + }, + }, + } + + pkg, err := getNonVirtualPackage(executor, "ghostscript") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pkg == nil { + t.Fatal("expected package but got nil") + } + + expected := AptPackage{Name: "ghostscript", Version: "10.02.1~dfsg1-0ubuntu7.8"} + if *pkg != expected { + t.Fatalf("unexpected package.\nexpected: %+v\nactual: %+v", expected, *pkg) + } +} + +// Regression test: when the "Reverse Provides:" header and entry appear on the same line +// (e.g. "Reverse Provides: provider 1.0"), the entry must be parsed correctly and the +// section header must not be treated as the package name. +func TestGetNonVirtualPackage_WithInlineReverseProvides(t *testing.T) { + executor := mockExecutor{ + executions: map[string]*execpkg.Execution{ + "apt-cache showpkg somevirtual": { + Cmd: "apt-cache showpkg somevirtual", + CombinedOut: strings.Join([]string{ + "Package: somevirtual", + "Versions: ", + "", + "Reverse Provides: concrete-pkg 2.0.0 (= 2.0.0)", + "", + }, "\n"), + ExitCode: 0, + }, + }, + } + + pkg, err := getNonVirtualPackage(executor, "somevirtual") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pkg == nil { + t.Fatal("expected package but got nil") + } + + expected := AptPackage{Name: "concrete-pkg", Version: "2.0.0"} + if *pkg != expected { + t.Fatalf("unexpected package.\nexpected: %+v\nactual: %+v", expected, *pkg) + } +} + +// Edge case: "Reverse Provides: " header with no parseable inline entry falls back to +// scanning subsequent lines, then to the Versions fallback if still no entries are found. +func TestGetNonVirtualPackage_WithInlineHeaderNoEntry(t *testing.T) { + executor := mockExecutor{ + executions: map[string]*execpkg.Execution{ + "apt-cache showpkg realpkg": { + Cmd: "apt-cache showpkg realpkg", + CombinedOut: strings.Join([]string{ + "Package: realpkg", + "Versions: ", + "1.2.3 (/var/lib/dpkg/info/realpkg.list)", + "Reverse Provides: ", + "", + }, "\n"), + ExitCode: 0, + }, + }, + } + + pkg, err := getNonVirtualPackage(executor, "realpkg") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pkg == nil { + t.Fatal("expected package but got nil") + } + + expected := AptPackage{Name: "realpkg", Version: "1.2.3"} + if *pkg != expected { + t.Fatalf("unexpected package.\nexpected: %+v\nactual: %+v", expected, *pkg) + } +}