package updates import ( "sort" "strings" ) // isSecuritySuite reports whether an apt suite carries security fixes. Debian // 11+ and every supported Ubuntu name them "-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() } // deb822Fields groups a paragraph's lines into fields. A line that starts // with a space or tab continues the field above it (a folded or multi-line // value, such as a long Suites list or an inline Signed-By key block). func deb822Fields(para string) [][]string { var fields [][]string for _, l := range strings.Split(strings.Trim(para, "\n"), "\n") { if (strings.HasPrefix(l, " ") || strings.HasPrefix(l, "\t")) && len(fields) > 0 { fields[len(fields)-1] = append(fields[len(fields)-1], l) continue } fields = append(fields, []string{l}) } return fields } func filterDeb822(content string) string { var b strings.Builder for _, para := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n\n") { var out []string isDeb, enabled, kept := false, true, false for _, field := range deb822Fields(para) { key, val, found := strings.Cut(field[0], ":") 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": // The value runs across every continuation line. The filtered // result is written back as one line and the continuation // lines are dropped with the rest of the original field. all := strings.Fields(strings.Join(append([]string{v}, field[1:]...), " ")) var sec []string for _, s := range all { if isSecuritySuite(s) { sec = append(sec, s) } } if len(sec) == 0 { continue // drop the field; the paragraph is dropped below } kept = true out = append(out, "Suites: "+strings.Join(sec, " ")) continue } // Every other field, continuation lines included, stays verbatim. out = append(out, field...) } if isDeb && enabled && kept { b.WriteString(strings.Join(out, "\n")) b.WriteString("\n\n") } } return b.String() }