Skip to content

Commit 16ef179

Browse files
authored
Feature: Add apt origin (Debian support) (#275)
1 parent 04e6fee commit 16ef179

14 files changed

Lines changed: 522 additions & 59 deletions

File tree

internal/origins/apt/constants.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package apt
2+
3+
const (
4+
fieldInstalledSize = "Installed-Size"
5+
fieldPackage = "Package"
6+
fieldVersion = "Version"
7+
fieldArchitecture = "Architecture"
8+
fieldDescription = "Description"
9+
fieldHomepage = "Homepage"
10+
fieldMaintainer = "Maintainer"
11+
fieldConflicts = "Conflicts"
12+
fieldBreaks = "Breaks"
13+
fieldReplaces = "Replaces"
14+
fieldDepends = "Depends"
15+
fieldPreDepends = "Pre-Depends"
16+
fieldReccommends = "Reccommends"
17+
fieldSuggests = "Suggests"
18+
fieldProvides = "Provides"
19+
fieldPriority = "Priority"
20+
fieldEssential = "Essential"
21+
22+
dpkgPath = "/var/lib/dpkg/status"
23+
24+
installReasonPath = "/var/lib/apt/extended_states"
25+
packagePrefix = "Package:"
26+
autoInstallPrefix = "Auto-Installed:"
27+
28+
licensePath = "/usr/share/doc"
29+
licenseFileName = "copyright"
30+
filesPrefix = "Files:"
31+
licensePrefix = "License:"
32+
33+
pkgModRoot = "/var/lib/dpkg/info"
34+
listExt = ".list"
35+
md5SumsExt = ".md5sums"
36+
)

internal/origins/apt/driver.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package apt
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"qp/internal/pkgdata"
7+
)
8+
9+
type AptDriver struct{}
10+
11+
func (d *AptDriver) Name() string {
12+
return "apt"
13+
}
14+
15+
func (d *AptDriver) Detect() bool {
16+
_, err := os.Stat(dpkgPath)
17+
return err == nil
18+
}
19+
20+
func (d *AptDriver) Load() ([]*pkgdata.PkgInfo, error) {
21+
return fetchPackages(d.Name())
22+
}
23+
24+
func (d *AptDriver) ResolveDeps(pkgs []*pkgdata.PkgInfo) ([]*pkgdata.PkgInfo, error) {
25+
return pkgdata.ResolveDependencyGraph(pkgs, nil)
26+
}
27+
28+
func (d *AptDriver) LoadCache(path string, modTime int64) ([]*pkgdata.PkgInfo, error) {
29+
return pkgdata.LoadProtoCache(path, modTime)
30+
}
31+
32+
func (d *AptDriver) SaveCache(
33+
path string,
34+
pkgs []*pkgdata.PkgInfo,
35+
modTime int64,
36+
) error {
37+
return pkgdata.SaveProtoCache(pkgs, path, modTime)
38+
}
39+
40+
func (d *AptDriver) SourceModified() (int64, error) {
41+
dirInfo, err := os.Stat(dpkgPath)
42+
if err != nil {
43+
return 0, fmt.Errorf("failed to read %s DB mod time: %v", d.Name(), err)
44+
}
45+
46+
return dirInfo.ModTime().Unix(), nil
47+
}

internal/origins/apt/fetch.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package apt
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"qp/internal/pkgdata"
8+
)
9+
10+
func fetchPackages(origin string) ([]*pkgdata.PkgInfo, error) {
11+
file, err := os.Open(dpkgPath)
12+
if err != nil {
13+
return nil, fmt.Errorf("failed to open status file: %w", err)
14+
}
15+
defer file.Close()
16+
17+
data, err := io.ReadAll(file)
18+
if err != nil {
19+
return nil, fmt.Errorf("failed to read status file: %w", err)
20+
}
21+
22+
return parseStatusFile(data, origin)
23+
}

internal/origins/apt/license.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package apt
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"qp/internal/pkgdata"
10+
"strings"
11+
)
12+
13+
var licenseHints = []struct {
14+
phrase string
15+
license string
16+
weight int
17+
}{
18+
{"most of the gnu c library", "LGPL-2.1+", 10},
19+
{"gnu c library is free software", "LGPL-2.1+", 10},
20+
{"gnu lesser general public license", "LGPL-2.1+", 8},
21+
{"gnu general public license", "GPL-2+", 6},
22+
{"distributed under the bsd license", "BSD", 5},
23+
{"bsd license", "BSD", 5},
24+
{"bsd-4-clause", "BSD-4-Clause", 4},
25+
{"mit license", "MIT", 3},
26+
{"expat", "Expat", 3},
27+
{"isc license", "ISC", 3},
28+
{"public domain", "public-domain", 2},
29+
{"lgpl", "LGPL", 2},
30+
}
31+
32+
func extractLicense(pkg *pkgdata.PkgInfo) error {
33+
path := filepath.Join(licensePath, pkg.Name, licenseFileName)
34+
file, err := os.Open(path)
35+
if err != nil {
36+
return fmt.Errorf("failed to read license file for %s: %w", pkg.Name, err)
37+
}
38+
defer file.Close()
39+
40+
data, err := io.ReadAll(file)
41+
if err != nil {
42+
return fmt.Errorf("failed to load license file for %s: %w", pkg.Name, err)
43+
}
44+
45+
var fallbackLicense string
46+
47+
for block := range bytes.SplitSeq(data, []byte("\n\n")) {
48+
var isFilesAll bool
49+
var license string
50+
51+
for byteLine := range bytes.SplitSeq(block, []byte("\n")) {
52+
line := string(byteLine)
53+
if strings.HasPrefix(line, filesPrefix) {
54+
isFilesAll = strings.TrimSpace(strings.TrimPrefix(line, filesPrefix)) == "*"
55+
}
56+
57+
if strings.HasPrefix(line, licensePrefix) {
58+
license = strings.TrimSpace(strings.TrimPrefix(line, licensePrefix))
59+
if fallbackLicense == "" {
60+
fallbackLicense = license
61+
}
62+
}
63+
}
64+
65+
if isFilesAll && license != "" {
66+
pkg.License = license
67+
return nil
68+
}
69+
}
70+
71+
if match, ok := matchLicenseText(data); ok {
72+
pkg.License = match
73+
return nil
74+
}
75+
76+
if fallbackLicense != "" {
77+
pkg.License = fallbackLicense
78+
return nil
79+
}
80+
81+
return fmt.Errorf("no license found for %s", pkg.Name)
82+
}
83+
84+
func matchLicenseText(data []byte) (string, bool) {
85+
text := strings.ToLower(string(data))
86+
87+
bestMatch := ""
88+
bestWeight := 0
89+
90+
for _, hint := range licenseHints {
91+
if strings.Contains(text, hint.phrase) && hint.weight > bestWeight {
92+
bestMatch = hint.license
93+
bestWeight = hint.weight
94+
}
95+
}
96+
97+
return bestMatch, bestMatch != ""
98+
}

internal/origins/apt/parser.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package apt
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"fmt"
7+
"qp/internal/consts"
8+
"qp/internal/pkgdata"
9+
"strconv"
10+
"strings"
11+
)
12+
13+
func parseStatusFile(data []byte, origin string) ([]*pkgdata.PkgInfo, error) {
14+
reasonMap, err := loadInstallReasons()
15+
if err != nil {
16+
return []*pkgdata.PkgInfo{}, err
17+
}
18+
19+
var collectedErrors []error
20+
pkgs := []*pkgdata.PkgInfo{}
21+
22+
for block := range bytes.SplitSeq(data, []byte("\n\n")) {
23+
if len(block) < 1 {
24+
continue
25+
}
26+
27+
pkg, err := parseStatusBlock(block, reasonMap, origin)
28+
if err != nil {
29+
collectedErrors = append(collectedErrors, err)
30+
}
31+
32+
pkgs = append(pkgs, pkg)
33+
}
34+
35+
if len(collectedErrors) > 0 {
36+
return pkgs, errors.Join(collectedErrors...)
37+
}
38+
39+
return pkgs, nil
40+
}
41+
42+
func parseStatusFields(block []byte) map[string]string {
43+
fields := make(map[string]string)
44+
45+
for line := range bytes.SplitSeq(block, []byte("\n")) {
46+
parts := bytes.SplitN(line, []byte(":"), 2)
47+
if len(parts) != 2 {
48+
continue
49+
}
50+
51+
key := strings.TrimSpace(string(parts[0]))
52+
value := strings.TrimSpace(string(parts[1]))
53+
fields[key] = value
54+
}
55+
56+
return fields
57+
}
58+
59+
func parseStatusBlock(block []byte, reasonMap map[string]string, origin string) (*pkgdata.PkgInfo, error) {
60+
fields := parseStatusFields(block)
61+
var collected []error
62+
pkg := &pkgdata.PkgInfo{}
63+
meta := map[string]string{}
64+
65+
for key, value := range fields {
66+
switch key {
67+
case fieldInstalledSize:
68+
size, err := strconv.Atoi(value)
69+
if err != nil {
70+
collected = append(collected, fmt.Errorf("invalid install size for %s: %v", pkg.Name, err))
71+
continue
72+
}
73+
pkg.Size = consts.KB * int64(size)
74+
75+
case fieldPackage:
76+
pkg.Name = value
77+
78+
case fieldVersion:
79+
pkg.Version = value
80+
81+
case fieldArchitecture:
82+
pkg.Arch = value
83+
84+
case fieldDescription:
85+
pkg.Description = value
86+
87+
case fieldHomepage:
88+
pkg.Url = value
89+
90+
case fieldMaintainer:
91+
pkg.Packager = value
92+
93+
case fieldConflicts, fieldBreaks:
94+
pkg.Conflicts = append(pkg.Conflicts, parseRelations(value)...)
95+
96+
case fieldReplaces:
97+
pkg.Replaces = parseRelations(value)
98+
99+
case fieldDepends, fieldPreDepends:
100+
pkg.Depends = append(pkg.Depends, parseRelations(value)...)
101+
102+
case fieldReccommends, fieldSuggests:
103+
pkg.OptDepends = append(pkg.OptDepends, parseRelations(value)...)
104+
105+
case fieldProvides:
106+
pkg.Provides = parseRelations(value)
107+
108+
case fieldPriority, fieldEssential:
109+
meta[key] = value
110+
}
111+
}
112+
113+
if err := getInstallTime(pkg); err != nil {
114+
collected = append(collected, err)
115+
}
116+
if err := extractLicense(pkg); err != nil {
117+
collected = append(collected, err)
118+
}
119+
120+
pkg.Origin = origin
121+
122+
// TODO: for dpkg-only systems, perhaps return "unknown" for non-system packages
123+
if isSystem(meta) {
124+
pkg.Reason = "system"
125+
} else if reasonMap[pkg.Name] == "dependency" {
126+
pkg.Reason = "dependency"
127+
} else {
128+
pkg.Reason = "explicit"
129+
}
130+
131+
return pkg, errors.Join(collected...)
132+
}
133+
134+
func isSystem(meta map[string]string) bool {
135+
priority := strings.ToLower(meta[fieldPriority])
136+
essential := strings.ToLower(meta[fieldEssential])
137+
138+
return priority == "required" || essential == "yes"
139+
}

internal/origins/apt/reasons.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package apt
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"io"
7+
"os"
8+
)
9+
10+
// only works for APT systems, we'll need to add more heuristics to get reasons for dpkg only systems
11+
func loadInstallReasons() (map[string]string, error) {
12+
file, err := os.Open(installReasonPath)
13+
if err != nil {
14+
return map[string]string{}, nil
15+
}
16+
defer file.Close()
17+
18+
data, err := io.ReadAll(file)
19+
if err != nil {
20+
return nil, fmt.Errorf("failed to read install reason file: %w", err)
21+
}
22+
23+
reasonMap := make(map[string]string)
24+
25+
for block := range bytes.SplitSeq(data, []byte("\n\n")) {
26+
var name string
27+
var isAuto bool
28+
29+
for line := range bytes.SplitSeq(block, []byte("\n")) {
30+
if bytes.HasPrefix(line, []byte(packagePrefix)) {
31+
name = string(bytes.TrimSpace(line[len(packagePrefix):]))
32+
} else if bytes.HasPrefix(line, []byte(autoInstallPrefix)) {
33+
val := string(bytes.TrimSpace(line[len(autoInstallPrefix):]))
34+
isAuto = val == "1"
35+
}
36+
}
37+
38+
if name != "" && isAuto {
39+
reasonMap[name] = "dependency"
40+
}
41+
}
42+
43+
return reasonMap, nil
44+
}

0 commit comments

Comments
 (0)