feat: filter apt sources down to security suites for security-only patching
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user