214 lines
6.2 KiB
Go
Raw Permalink Normal View History

2026-03-12 20:51:38 -07:00
package system
import (
"fmt"
"os/exec"
"strconv"
"strings"
)
// ── Types ───────────────────────────────────────────────────────────
type PackageInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Size int64 `json:"size"` // installed size in bytes
SizeStr string `json:"size_str"` // human-readable
Status string `json:"status,omitempty"`
}
// ── APT Operations ──────────────────────────────────────────────────
// PackageUpdate runs `apt-get update` to refresh the package index.
func PackageUpdate() (string, error) {
out, err := exec.Command("apt-get", "update", "-qq").CombinedOutput()
output := string(out)
if err != nil {
return output, fmt.Errorf("apt-get update failed: %w (%s)", err, output)
}
return output, nil
}
// PackageInstall installs one or more packages with apt-get install -y.
// Package names are passed as separate arguments to avoid shell injection.
func PackageInstall(packages ...string) (string, error) {
if len(packages) == 0 {
return "", fmt.Errorf("no packages specified")
}
for _, pkg := range packages {
if err := validatePackageName(pkg); err != nil {
return "", err
}
}
args := append([]string{"install", "-y"}, packages...)
out, err := exec.Command("apt-get", args...).CombinedOutput()
output := string(out)
if err != nil {
return output, fmt.Errorf("apt-get install failed: %w (%s)", err, output)
}
return output, nil
}
// PackageRemove removes one or more packages with apt-get remove -y.
func PackageRemove(packages ...string) (string, error) {
if len(packages) == 0 {
return "", fmt.Errorf("no packages specified")
}
for _, pkg := range packages {
if err := validatePackageName(pkg); err != nil {
return "", err
}
}
args := append([]string{"remove", "-y"}, packages...)
out, err := exec.Command("apt-get", args...).CombinedOutput()
output := string(out)
if err != nil {
return output, fmt.Errorf("apt-get remove failed: %w (%s)", err, output)
}
return output, nil
}
// PackageListInstalled returns all installed packages via dpkg-query.
func PackageListInstalled() ([]PackageInfo, error) {
// dpkg-query format: name\tversion\tinstalled-size (in kB)
out, err := exec.Command(
"dpkg-query",
"--show",
"--showformat=${Package}\t${Version}\t${Installed-Size}\n",
).CombinedOutput()
if err != nil {
return nil, fmt.Errorf("dpkg-query failed: %w (%s)", err, string(out))
}
var packages []PackageInfo
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) < 3 {
continue
}
// Installed-Size from dpkg is in kibibytes
sizeKB, _ := strconv.ParseInt(strings.TrimSpace(fields[2]), 10, 64)
sizeBytes := sizeKB * 1024
packages = append(packages, PackageInfo{
Name: fields[0],
Version: fields[1],
Size: sizeBytes,
SizeStr: humanBytes(uint64(sizeBytes)),
})
}
return packages, nil
}
// PackageIsInstalled checks if a single package is installed using dpkg -l.
func PackageIsInstalled(pkg string) bool {
if err := validatePackageName(pkg); err != nil {
return false
}
out, err := exec.Command("dpkg", "-l", pkg).CombinedOutput()
if err != nil {
return false
}
// dpkg -l output has lines starting with "ii" for installed packages
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 && fields[0] == "ii" && fields[1] == pkg {
return true
}
}
return false
}
// PackageUpgrade runs `apt-get upgrade -y` to upgrade all packages.
func PackageUpgrade() (string, error) {
out, err := exec.Command("apt-get", "upgrade", "-y").CombinedOutput()
output := string(out)
if err != nil {
return output, fmt.Errorf("apt-get upgrade failed: %w (%s)", err, output)
}
return output, nil
}
// PackageSecurityUpdates returns a list of packages with available security updates.
func PackageSecurityUpdates() ([]PackageInfo, error) {
// apt list --upgradable outputs lines like:
// package/suite version arch [upgradable from: old-version]
out, err := exec.Command("apt", "list", "--upgradable").CombinedOutput()
if err != nil {
// apt list may return exit code 1 even with valid output
if len(out) == 0 {
return nil, fmt.Errorf("apt list --upgradable failed: %w", err)
}
}
var securityPkgs []PackageInfo
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, line := range lines {
// Skip header/warning lines
if strings.HasPrefix(line, "Listing") || strings.HasPrefix(line, "WARNING") || strings.TrimSpace(line) == "" {
continue
}
// Filter for security updates: look for "-security" in the suite name
if !strings.Contains(line, "-security") {
continue
}
// Parse: "name/suite version arch [upgradable from: old]"
slashIdx := strings.Index(line, "/")
if slashIdx < 0 {
continue
}
name := line[:slashIdx]
// Get version from the fields after the suite
rest := line[slashIdx+1:]
fields := strings.Fields(rest)
var version string
if len(fields) >= 2 {
version = fields[1]
}
securityPkgs = append(securityPkgs, PackageInfo{
Name: name,
Version: version,
Status: "security-update",
})
}
return securityPkgs, nil
}
// ── Helpers ─────────────────────────────────────────────────────────
// validatePackageName does basic validation to prevent obvious injection attempts.
// Package names in Debian must consist of lowercase alphanumerics, +, -, . and
// must be at least 2 characters long.
func validatePackageName(pkg string) error {
if len(pkg) < 2 {
return fmt.Errorf("invalid package name %q: too short", pkg)
}
if len(pkg) > 128 {
return fmt.Errorf("invalid package name %q: too long", pkg)
}
for _, c := range pkg {
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.' || c == ':') {
return fmt.Errorf("invalid character %q in package name %q", c, pkg)
}
}
return nil
}