diff --git a/apt/apt.go b/apt/apt.go index ee2867d..18fe7ad 100644 --- a/apt/apt.go +++ b/apt/apt.go @@ -84,3 +84,126 @@ func (a *Apt) Version(ctx context.Context, pkg string) (string, error) { func (a *Apt) Available() bool { return available() } + +// LatestVersion returns the latest available version of a package. +func (a *Apt) LatestVersion(ctx context.Context, pkg string) (string, error) { + return latestVersion(ctx, pkg) +} + +// ListUpgrades returns packages that have newer versions available. +func (a *Apt) ListUpgrades(ctx context.Context) ([]snack.Package, error) { + return listUpgrades(ctx) +} + +// UpgradeAvailable reports whether a newer version of a package is available. +func (a *Apt) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) { + return upgradeAvailable(ctx, pkg) +} + +// VersionCmp compares two version strings using dpkg's native comparison. +func (a *Apt) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) { + return versionCmp(ctx, ver1, ver2) +} + +// Hold pins packages at their current version. +func (a *Apt) Hold(ctx context.Context, pkgs []string) error { + a.Lock() + defer a.Unlock() + return hold(ctx, pkgs) +} + +// Unhold removes version pins from packages. +func (a *Apt) Unhold(ctx context.Context, pkgs []string) error { + a.Lock() + defer a.Unlock() + return unhold(ctx, pkgs) +} + +// ListHeld returns all currently held packages. +func (a *Apt) ListHeld(ctx context.Context) ([]snack.Package, error) { + return listHeld(ctx) +} + +// Autoremove removes packages no longer required as dependencies. +func (a *Apt) Autoremove(ctx context.Context, opts ...snack.Option) error { + a.Lock() + defer a.Unlock() + return autoremove(ctx, opts...) +} + +// Clean removes cached package files. +func (a *Apt) Clean(ctx context.Context) error { + a.Lock() + defer a.Unlock() + return clean(ctx) +} + +// FileList returns all files installed by a package. +func (a *Apt) FileList(ctx context.Context, pkg string) ([]string, error) { + return fileList(ctx, pkg) +} + +// Owner returns the package that owns a given file path. +func (a *Apt) Owner(ctx context.Context, path string) (string, error) { + return owner(ctx, path) +} + +// ListRepos returns all configured package repositories. +func (a *Apt) ListRepos(ctx context.Context) ([]snack.Repository, error) { + return listRepos(ctx) +} + +// AddRepo adds a new package repository. +func (a *Apt) AddRepo(ctx context.Context, repo snack.Repository) error { + a.Lock() + defer a.Unlock() + return addRepo(ctx, repo) +} + +// RemoveRepo removes a configured repository. +func (a *Apt) RemoveRepo(ctx context.Context, id string) error { + a.Lock() + defer a.Unlock() + return removeRepo(ctx, id) +} + +// AddKey imports a GPG key for package verification. +func (a *Apt) AddKey(ctx context.Context, key string) error { + a.Lock() + defer a.Unlock() + return addKey(ctx, key) +} + +// RemoveKey removes a GPG key. +func (a *Apt) RemoveKey(ctx context.Context, keyID string) error { + a.Lock() + defer a.Unlock() + return removeKey(ctx, keyID) +} + +// ListKeys returns all trusted package signing keys. +func (a *Apt) ListKeys(ctx context.Context) ([]string, error) { + return listKeys(ctx) +} + +// NormalizeName returns the canonical form of a package name. +func (a *Apt) NormalizeName(name string) string { + return normalizeName(name) +} + +// ParseArch extracts the architecture from a package name if present. +func (a *Apt) ParseArch(name string) (string, string) { + return parseArch(name) +} + +// Compile-time interface checks. +var ( + _ snack.Manager = (*Apt)(nil) + _ snack.VersionQuerier = (*Apt)(nil) + _ snack.Holder = (*Apt)(nil) + _ snack.Cleaner = (*Apt)(nil) + _ snack.FileOwner = (*Apt)(nil) + _ snack.RepoManager = (*Apt)(nil) + _ snack.KeyManager = (*Apt)(nil) + _ snack.NameNormalizer = (*Apt)(nil) +) diff --git a/apt/capabilities_linux.go b/apt/capabilities_linux.go new file mode 100644 index 0000000..482b04e --- /dev/null +++ b/apt/capabilities_linux.go @@ -0,0 +1,383 @@ +//go:build linux + +package apt + +import ( + "bufio" + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/gogrlx/snack" +) + +// --- VersionQuerier --- + +func latestVersion(ctx context.Context, pkg string) (string, error) { + cmd := exec.CommandContext(ctx, "apt-cache", "policy", pkg) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound) + } + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Candidate:") { + candidate := strings.TrimSpace(strings.TrimPrefix(line, "Candidate:")) + if candidate == "(none)" { + return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound) + } + return candidate, nil + } + } + return "", fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound) +} + +func listUpgrades(ctx context.Context) ([]snack.Package, error) { + cmd := exec.CommandContext(ctx, "apt", "list", "--upgradable") + cmd.Env = append(os.Environ(), "LANG=C") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("apt list --upgradable: %w", err) + } + var pkgs []snack.Package + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Listing...") { + continue + } + // Format: "pkg/source version arch [upgradable from: old-version]" + slashIdx := strings.Index(line, "/") + if slashIdx < 0 { + continue + } + name := line[:slashIdx] + rest := line[slashIdx+1:] + fields := strings.Fields(rest) + if len(fields) < 2 { + continue + } + p := snack.Package{ + Name: name, + Version: fields[1], + Installed: true, + } + if len(fields) > 2 { + p.Arch = fields[2] + } + pkgs = append(pkgs, p) + } + return pkgs, nil +} + +func upgradeAvailable(ctx context.Context, pkg string) (bool, error) { + cmd := exec.CommandContext(ctx, "apt-cache", "policy", pkg) + out, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotFound) + } + var installed, candidate string + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Installed:") { + installed = strings.TrimSpace(strings.TrimPrefix(line, "Installed:")) + } else if strings.HasPrefix(line, "Candidate:") { + candidate = strings.TrimSpace(strings.TrimPrefix(line, "Candidate:")) + } + } + if installed == "(none)" || installed == "" { + return false, fmt.Errorf("apt-cache policy %s: %w", pkg, snack.ErrNotInstalled) + } + if candidate == "(none)" || candidate == "" || candidate == installed { + return false, nil + } + return true, nil +} + +func versionCmp(ctx context.Context, ver1, ver2 string) (int, error) { + // Check if ver1 < ver2 + cmd := exec.CommandContext(ctx, "dpkg", "--compare-versions", ver1, "lt", ver2) + if err := cmd.Run(); err == nil { + return -1, nil + } + // Check if ver1 = ver2 + cmd = exec.CommandContext(ctx, "dpkg", "--compare-versions", ver1, "eq", ver2) + if err := cmd.Run(); err == nil { + return 0, nil + } + // Must be ver1 > ver2 + return 1, nil +} + +// --- Holder --- + +func hold(ctx context.Context, pkgs []string) error { + args := append([]string{"hold"}, pkgs...) + cmd := exec.CommandContext(ctx, "apt-mark", args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("apt-mark hold: %w: %s", err, stderr.String()) + } + return nil +} + +func unhold(ctx context.Context, pkgs []string) error { + args := append([]string{"unhold"}, pkgs...) + cmd := exec.CommandContext(ctx, "apt-mark", args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("apt-mark unhold: %w: %s", err, stderr.String()) + } + return nil +} + +func listHeld(ctx context.Context) ([]snack.Package, error) { + cmd := exec.CommandContext(ctx, "apt-mark", "showhold") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("apt-mark showhold: %w", err) + } + var pkgs []snack.Package + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + pkgs = append(pkgs, snack.Package{Name: line, Installed: true}) + } + return pkgs, nil +} + +// --- Cleaner --- + +func autoremove(ctx context.Context, opts ...snack.Option) error { + return runAptGet(ctx, "autoremove", nil, append(opts, snack.WithAssumeYes())...) +} + +func clean(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "apt-get", "clean") + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("apt-get clean: %w: %s", err, stderr.String()) + } + return nil +} + +// --- FileOwner --- + +func fileList(ctx context.Context, pkg string) ([]string, error) { + cmd := exec.CommandContext(ctx, "dpkg-query", "-L", pkg) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + errMsg := stderr.String() + if strings.Contains(errMsg, "is not installed") || strings.Contains(errMsg, "not found") { + return nil, fmt.Errorf("dpkg-query -L %s: %w", pkg, snack.ErrNotInstalled) + } + return nil, fmt.Errorf("dpkg-query -L %s: %w: %s", pkg, err, errMsg) + } + var files []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line != "" { + files = append(files, line) + } + } + return files, nil +} + +func owner(ctx context.Context, path string) (string, error) { + cmd := exec.CommandContext(ctx, "dpkg", "-S", path) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("dpkg -S %s: %w", path, snack.ErrNotFound) + } + // Output format: "package: /path/to/file" or "package1, package2: /path" + line := strings.TrimSpace(strings.Split(string(out), "\n")[0]) + colonIdx := strings.Index(line, ":") + if colonIdx < 0 { + return "", fmt.Errorf("dpkg -S %s: unexpected output", path) + } + // Return first package if multiple + pkgPart := line[:colonIdx] + if commaIdx := strings.Index(pkgPart, ","); commaIdx >= 0 { + pkgPart = strings.TrimSpace(pkgPart[:commaIdx]) + } + return strings.TrimSpace(pkgPart), nil +} + +// --- RepoManager --- + +func listRepos(_ context.Context) ([]snack.Repository, error) { + var repos []snack.Repository + + files := []string{"/etc/apt/sources.list"} + // Glob sources.list.d + matches, _ := filepath.Glob("/etc/apt/sources.list.d/*.list") + files = append(files, matches...) + sourcesMatches, _ := filepath.Glob("/etc/apt/sources.list.d/*.sources") + files = append(files, sourcesMatches...) + + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + continue + } + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + enabled := true + // deb822 format (.sources files) not fully parsed; treat as single entry + if strings.HasPrefix(line, "deb ") || strings.HasPrefix(line, "deb-src ") { + repos = append(repos, snack.Repository{ + ID: line, + URL: extractURL(line), + Enabled: enabled, + Type: strings.Fields(line)[0], + }) + } + } + } + return repos, nil +} + +// extractURL pulls the URL from a deb/deb-src line. +func extractURL(line string) string { + fields := strings.Fields(line) + 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 + } + } + continue + } + return f + } + return "" +} + +func addRepo(ctx context.Context, repo snack.Repository) error { + repoLine := repo.URL + if repo.Type != "" { + repoLine = repo.Type + " " + repo.URL + } + cmd := exec.CommandContext(ctx, "add-apt-repository", "-y", repoLine) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("add-apt-repository: %w: %s", err, stderr.String()) + } + return nil +} + +func removeRepo(ctx context.Context, id string) error { + cmd := exec.CommandContext(ctx, "add-apt-repository", "--remove", "-y", id) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("add-apt-repository --remove: %w: %s", err, stderr.String()) + } + return nil +} + +// --- KeyManager --- + +func addKey(ctx context.Context, key string) error { + if strings.HasPrefix(key, "http://") || strings.HasPrefix(key, "https://") { + // Modern approach: download and dearmor to /etc/apt/keyrings/ + name := filepath.Base(key) + name = strings.TrimSuffix(name, filepath.Ext(name)) + ".gpg" + dest := filepath.Join("/etc/apt/keyrings", name) + + // Ensure keyrings dir exists + _ = os.MkdirAll("/etc/apt/keyrings", 0o755) + + // Download and dearmor + shell := fmt.Sprintf("curl -fsSL %q | gpg --dearmor -o %q", key, dest) + cmd := exec.CommandContext(ctx, "sh", "-c", shell) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + // Fallback to apt-key + cmd2 := exec.CommandContext(ctx, "apt-key", "adv", "--fetch-keys", key) + var stderr2 bytes.Buffer + cmd2.Stderr = &stderr2 + if err2 := cmd2.Run(); err2 != nil { + return fmt.Errorf("add key %s: %w: %s", key, err2, stderr2.String()) + } + } + return nil + } + // Treat as key ID or file path + cmd := exec.CommandContext(ctx, "apt-key", "add", key) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("apt-key add: %w: %s", err, stderr.String()) + } + return nil +} + +func removeKey(ctx context.Context, keyID string) error { + // Try removing from keyrings dir first + matches, _ := filepath.Glob("/etc/apt/keyrings/*") + for _, m := range matches { + if strings.Contains(filepath.Base(m), keyID) { + if err := os.Remove(m); err == nil { + return nil + } + } + } + // Fallback to apt-key del + cmd := exec.CommandContext(ctx, "apt-key", "del", keyID) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("apt-key del %s: %w: %s", keyID, err, stderr.String()) + } + return nil +} + +func listKeys(ctx context.Context) ([]string, error) { + var keys []string + + // List keyring files + matches, _ := filepath.Glob("/etc/apt/keyrings/*.gpg") + for _, m := range matches { + keys = append(keys, m) + } + ascMatches, _ := filepath.Glob("/etc/apt/keyrings/*.asc") + keys = append(keys, ascMatches...) + + // Also list apt-key entries + cmd := exec.CommandContext(ctx, "apt-key", "list") + out, _ := cmd.Output() + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + // Key fingerprint lines are hex strings + if len(line) > 0 && !strings.Contains(line, "/") && !strings.Contains(line, "pub") && !strings.Contains(line, "uid") && !strings.Contains(line, "sub") && !strings.HasPrefix(line, "-") { + keys = append(keys, line) + } + } + return keys, nil +} + +// NameNormalizer functions are in normalize.go (no build constraints needed). diff --git a/apt/capabilities_other.go b/apt/capabilities_other.go new file mode 100644 index 0000000..6ac0224 --- /dev/null +++ b/apt/capabilities_other.go @@ -0,0 +1,79 @@ +//go:build !linux + +package apt + +import ( + "context" + + "github.com/gogrlx/snack" +) + +func latestVersion(_ context.Context, _ string) (string, error) { + return "", snack.ErrUnsupportedPlatform +} + +func listUpgrades(_ context.Context) ([]snack.Package, error) { + return nil, snack.ErrUnsupportedPlatform +} + +func upgradeAvailable(_ context.Context, _ string) (bool, error) { + return false, snack.ErrUnsupportedPlatform +} + +func versionCmp(_ context.Context, _, _ string) (int, error) { + return 0, snack.ErrUnsupportedPlatform +} + +func hold(_ context.Context, _ []string) error { + return snack.ErrUnsupportedPlatform +} + +func unhold(_ context.Context, _ []string) error { + return snack.ErrUnsupportedPlatform +} + +func listHeld(_ context.Context) ([]snack.Package, error) { + return nil, snack.ErrUnsupportedPlatform +} + +func autoremove(_ context.Context, _ ...snack.Option) error { + return snack.ErrUnsupportedPlatform +} + +func clean(_ context.Context) error { + return snack.ErrUnsupportedPlatform +} + +func fileList(_ context.Context, _ string) ([]string, error) { + return nil, snack.ErrUnsupportedPlatform +} + +func owner(_ context.Context, _ string) (string, error) { + return "", snack.ErrUnsupportedPlatform +} + +func listRepos(_ context.Context) ([]snack.Repository, error) { + return nil, snack.ErrUnsupportedPlatform +} + +func addRepo(_ context.Context, _ snack.Repository) error { + return snack.ErrUnsupportedPlatform +} + +func removeRepo(_ context.Context, _ string) error { + return snack.ErrUnsupportedPlatform +} + +func addKey(_ context.Context, _ string) error { + return snack.ErrUnsupportedPlatform +} + +func removeKey(_ context.Context, _ string) error { + return snack.ErrUnsupportedPlatform +} + +func listKeys(_ context.Context) ([]string, error) { + return nil, snack.ErrUnsupportedPlatform +} + +// normalizeName and parseArch are in normalize.go (no build constraints). diff --git a/apt/normalize.go b/apt/normalize.go new file mode 100644 index 0000000..e3e9939 --- /dev/null +++ b/apt/normalize.go @@ -0,0 +1,21 @@ +package apt + +import "strings" + +func normalizeName(name string) string { + n, _ := parseArch(name) + return n +} + +func parseArch(name string) (string, string) { + if idx := strings.LastIndex(name, ":"); idx >= 0 { + pkg := name[:idx] + arch := name[idx+1:] + switch arch { + case "amd64", "i386", "arm64", "armhf", "armel", "mips", "mipsel", + "mips64el", "ppc64el", "s390x", "all", "any": + return pkg, arch + } + } + return name, "" +} diff --git a/dpkg/capabilities_linux.go b/dpkg/capabilities_linux.go new file mode 100644 index 0000000..04e9d87 --- /dev/null +++ b/dpkg/capabilities_linux.go @@ -0,0 +1,55 @@ +//go:build linux + +package dpkg + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/gogrlx/snack" +) + +func fileList(ctx context.Context, pkg string) ([]string, error) { + cmd := exec.CommandContext(ctx, "dpkg", "-L", pkg) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + errMsg := stderr.String() + if strings.Contains(errMsg, "is not installed") || strings.Contains(errMsg, "not found") { + return nil, fmt.Errorf("dpkg -L %s: %w", pkg, snack.ErrNotInstalled) + } + return nil, fmt.Errorf("dpkg -L %s: %w: %s", pkg, err, errMsg) + } + var files []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line != "" { + files = append(files, line) + } + } + return files, nil +} + +func owner(ctx context.Context, path string) (string, error) { + cmd := exec.CommandContext(ctx, "dpkg", "-S", path) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("dpkg -S %s: %w", path, snack.ErrNotFound) + } + line := strings.TrimSpace(strings.Split(string(out), "\n")[0]) + colonIdx := strings.Index(line, ":") + if colonIdx < 0 { + return "", fmt.Errorf("dpkg -S %s: unexpected output", path) + } + pkgPart := line[:colonIdx] + if commaIdx := strings.Index(pkgPart, ","); commaIdx >= 0 { + pkgPart = strings.TrimSpace(pkgPart[:commaIdx]) + } + return strings.TrimSpace(pkgPart), nil +} diff --git a/dpkg/capabilities_other.go b/dpkg/capabilities_other.go new file mode 100644 index 0000000..629cac9 --- /dev/null +++ b/dpkg/capabilities_other.go @@ -0,0 +1,19 @@ +//go:build !linux + +package dpkg + +import ( + "context" + + "github.com/gogrlx/snack" +) + +func fileList(_ context.Context, _ string) ([]string, error) { + return nil, snack.ErrUnsupportedPlatform +} + +func owner(_ context.Context, _ string) (string, error) { + return "", snack.ErrUnsupportedPlatform +} + +// normalizeName and parseArch are in normalize.go (no build constraints). diff --git a/dpkg/dpkg.go b/dpkg/dpkg.go index 3f6093a..ed922ed 100644 --- a/dpkg/dpkg.go +++ b/dpkg/dpkg.go @@ -80,3 +80,30 @@ func (d *Dpkg) Version(ctx context.Context, pkg string) (string, error) { func (d *Dpkg) Available() bool { return available() } + +// FileList returns all files installed by a package. +func (d *Dpkg) FileList(ctx context.Context, pkg string) ([]string, error) { + return fileList(ctx, pkg) +} + +// Owner returns the package that owns a given file path. +func (d *Dpkg) Owner(ctx context.Context, path string) (string, error) { + return owner(ctx, path) +} + +// NormalizeName returns the canonical form of a package name. +func (d *Dpkg) NormalizeName(name string) string { + return normalizeName(name) +} + +// ParseArch extracts the architecture from a package name if present. +func (d *Dpkg) ParseArch(name string) (string, string) { + return parseArch(name) +} + +// Compile-time interface checks. +var ( + _ snack.Manager = (*Dpkg)(nil) + _ snack.FileOwner = (*Dpkg)(nil) + _ snack.NameNormalizer = (*Dpkg)(nil) +) diff --git a/dpkg/normalize.go b/dpkg/normalize.go new file mode 100644 index 0000000..2e41c38 --- /dev/null +++ b/dpkg/normalize.go @@ -0,0 +1,21 @@ +package dpkg + +import "strings" + +func normalizeName(name string) string { + n, _ := parseArch(name) + return n +} + +func parseArch(name string) (string, string) { + if idx := strings.LastIndex(name, ":"); idx >= 0 { + pkg := name[:idx] + arch := name[idx+1:] + switch arch { + case "amd64", "i386", "arm64", "armhf", "armel", "mips", "mipsel", + "mips64el", "ppc64el", "s390x", "all", "any": + return pkg, arch + } + } + return name, "" +}