feat: filter apt sources down to security suites for security-only patching

This commit is contained in:
2026-09-15 07:43:28 +00:00
parent 3aa24bb938
commit 5f5e19e23e
2 changed files with 191 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
package updates
import (
"sort"
"strings"
)
// isSecuritySuite reports whether an apt suite carries security fixes. Debian
// 11+ and every supported Ubuntu name them "<codename>-security".
func isSecuritySuite(s string) bool { return strings.HasSuffix(s, "-security") }
// securitySources reduces a host's apt source files to only the entries that
// point at a security suite, so an upgrade run against them installs security
// fixes and nothing else.
//
// It is a pure function of file contents so it is tested on any platform. The
// caller writes list to a *.list file and deb822 to a *.sources file in a
// temporary SourceParts directory: keeping deb822 paragraphs as deb822 means
// an inline Signed-By key block survives verbatim, which a conversion to
// one-line format could not carry.
//
// ok is false when no security suite exists at all. The caller must then
// report unsupported, never fall back to installing everything.
func securitySources(files map[string]string) (list string, deb822 string, ok bool) {
paths := make([]string, 0, len(files))
for p := range files {
paths = append(paths, p)
}
sort.Strings(paths) // deterministic output
var lb, db strings.Builder
for _, p := range paths {
if strings.HasSuffix(p, ".sources") {
db.WriteString(filterDeb822(files[p]))
} else {
lb.WriteString(filterOneLine(files[p]))
}
}
list, deb822 = lb.String(), db.String()
return list, deb822, list != "" || deb822 != ""
}
func filterOneLine(content string) string {
var b strings.Builder
for _, raw := range strings.Split(content, "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 || fields[0] != "deb" {
continue
}
i := 1
if strings.HasPrefix(fields[i], "[") {
// Options run until the token that closes the bracket.
for i < len(fields) && !strings.HasSuffix(fields[i], "]") {
i++
}
i++
}
// fields[i] is the URI, fields[i+1] the suite.
if i+1 < len(fields) && isSecuritySuite(fields[i+1]) {
b.WriteString(line)
b.WriteString("\n")
}
}
return b.String()
}
func filterDeb822(content string) string {
var b strings.Builder
for _, para := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n\n") {
lines := strings.Split(strings.Trim(para, "\n"), "\n")
var out []string
isDeb, enabled, kept := false, true, false
for _, l := range lines {
key, val, found := strings.Cut(l, ":")
k := strings.ToLower(strings.TrimSpace(key))
v := strings.TrimSpace(val)
switch {
case found && k == "types":
for _, t := range strings.Fields(v) {
if t == "deb" {
isDeb = true
}
}
case found && k == "enabled":
enabled = strings.ToLower(v) != "no"
case found && k == "suites":
var sec []string
for _, s := range strings.Fields(v) {
if isSecuritySuite(s) {
sec = append(sec, s)
}
}
if len(sec) == 0 {
continue // drop the line; the paragraph is dropped below
}
kept = true
l = "Suites: " + strings.Join(sec, " ")
}
out = append(out, l)
}
if isDeb && enabled && kept {
b.WriteString(strings.Join(out, "\n"))
b.WriteString("\n\n")
}
}
return b.String()
}
+80
View File
@@ -0,0 +1,80 @@
package updates
import (
"strings"
"testing"
)
func TestSecuritySourcesOneLine(t *testing.T) {
files := map[string]string{
"/etc/apt/sources.list": `# comment
deb http://deb.debian.org/debian bookworm main
deb http://deb.debian.org/debian bookworm-updates main
deb http://security.debian.org/debian-security bookworm-security main contrib
deb [arch=amd64 signed-by=/usr/share/keyrings/x.gpg] http://archive.ubuntu.com/ubuntu jammy-security main
deb-src http://security.debian.org/debian-security bookworm-security main
`,
}
list, d822, ok := securitySources(files)
if !ok {
t.Fatal("ok = false, want true")
}
if d822 != "" {
t.Fatalf("deb822 = %q, want empty", d822)
}
want := "deb http://security.debian.org/debian-security bookworm-security main contrib\n" +
"deb [arch=amd64 signed-by=/usr/share/keyrings/x.gpg] http://archive.ubuntu.com/ubuntu jammy-security main\n"
if list != want {
t.Fatalf("list =\n%s\nwant\n%s", list, want)
}
}
func TestSecuritySourcesDeb822(t *testing.T) {
files := map[string]string{
"/etc/apt/sources.list.d/ubuntu.sources": `Types: deb
URIs: http://archive.ubuntu.com/ubuntu/
Suites: noble noble-updates noble-backports
Components: main restricted
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
Types: deb
URIs: http://security.ubuntu.com/ubuntu/
Suites: noble-security
Components: main restricted
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
`,
}
list, d822, ok := securitySources(files)
if !ok || list != "" {
t.Fatalf("ok=%v list=%q", ok, list)
}
if !strings.Contains(d822, "Suites: noble-security") || strings.Contains(d822, "noble-updates") {
t.Fatalf("deb822 wrong:\n%s", d822)
}
if !strings.Contains(d822, "Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg") {
t.Fatalf("Signed-By must be kept verbatim:\n%s", d822)
}
}
// A paragraph listing several suites keeps only the security ones.
func TestSecuritySourcesDeb822MixedSuites(t *testing.T) {
files := map[string]string{"/x.sources": "Types: deb\nURIs: http://a/\nSuites: noble noble-security\nComponents: main\n"}
_, d822, ok := securitySources(files)
if !ok || !strings.Contains(d822, "Suites: noble-security\n") || strings.Contains(d822, "Suites: noble noble") {
t.Fatalf("got ok=%v\n%s", ok, d822)
}
}
func TestSecuritySourcesDisabledParagraphIgnored(t *testing.T) {
files := map[string]string{"/x.sources": "Types: deb\nURIs: http://a/\nSuites: noble-security\nComponents: main\nEnabled: no\n"}
if _, _, ok := securitySources(files); ok {
t.Fatal("a disabled paragraph must not count")
}
}
func TestSecuritySourcesNone(t *testing.T) {
files := map[string]string{"/etc/apt/sources.list": "deb http://mirror/debian bookworm main\n"}
if _, _, ok := securitySources(files); ok {
t.Fatal("ok = true with no security suite, want false")
}
}