233 lines
6.4 KiB
Go
233 lines
6.4 KiB
Go
package aptrepo
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"server-deploy/internal/installplan"
|
|
)
|
|
|
|
var metadataError = errors.New("invalid Docker repository metadata")
|
|
|
|
var pinnedPackageNames = [...]string{"docker-ce", "docker-ce-cli", "containerd.io", "docker-buildx-plugin", "docker-compose-plugin"}
|
|
|
|
// Resolve parses Release bytes already authenticated by the caller and binds
|
|
// explicitly pinned packages to that Release. It does not verify signatures.
|
|
func Resolve(release, index []byte, suite, arch string, versions map[string]string, now time.Time) (installplan.Lock, error) {
|
|
if len(release) > 1<<20 || len(index) > 16<<20 || len(versions) != len(pinnedPackageNames) {
|
|
return installplan.Lock{}, metadataError
|
|
}
|
|
for _, name := range pinnedPackageNames {
|
|
if versions[name] == "" {
|
|
return installplan.Lock{}, metadataError
|
|
}
|
|
}
|
|
|
|
var fields map[string]string
|
|
if err := parseControl(release, func(stanza map[string]string) error {
|
|
if fields != nil {
|
|
return metadataError
|
|
}
|
|
fields = stanza
|
|
return nil
|
|
}); err != nil {
|
|
return installplan.Lock{}, metadataError
|
|
}
|
|
if fields["suite"] != suite || fields["origin"] != "Docker" || fields["label"] != "Docker CE" || !hasWord(fields["architectures"], arch) || !hasWord(fields["components"], "stable") {
|
|
return installplan.Lock{}, metadataError
|
|
}
|
|
date, err := time.Parse(time.RFC1123Z, fields["date"])
|
|
if err != nil || date.After(now.Add(10*time.Minute)) || date.Before(now.Add(-30*24*time.Hour)) {
|
|
return installplan.Lock{}, metadataError
|
|
}
|
|
if value, ok := fields["valid-until"]; ok {
|
|
expiry, err := time.Parse(time.RFC1123Z, value)
|
|
if err != nil || !expiry.After(now) || expiry.Before(date) {
|
|
return installplan.Lock{}, metadataError
|
|
}
|
|
}
|
|
// Bind the exact uncompressed index bytes before interpreting any records.
|
|
indexSum := sha256.Sum256(index)
|
|
expectedPath := "stable/binary-" + arch + "/Packages"
|
|
seenPaths := make(map[string]bool)
|
|
matched := false
|
|
for _, line := range strings.Split(fields["sha256"], "\n") {
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
entry := strings.Fields(line)
|
|
if len(entry) != 3 || !validSHA256(entry[0]) || seenPaths[entry[2]] {
|
|
return installplan.Lock{}, metadataError
|
|
}
|
|
seenPaths[entry[2]] = true
|
|
size, err := decimalSize(entry[1])
|
|
if err != nil {
|
|
return installplan.Lock{}, metadataError
|
|
}
|
|
if entry[2] == expectedPath {
|
|
if size != uint64(len(index)) || entry[0] != hex.EncodeToString(indexSum[:]) {
|
|
return installplan.Lock{}, metadataError
|
|
}
|
|
matched = true
|
|
}
|
|
}
|
|
if !matched {
|
|
return installplan.Lock{}, metadataError
|
|
}
|
|
|
|
selected := make(map[string]installplan.Package)
|
|
seenRecords := make(map[[3]string]bool)
|
|
if err := parseControl(index, func(record map[string]string) error {
|
|
identity := [3]string{record["package"], record["version"], record["architecture"]}
|
|
if identity[0] != "" && identity[1] != "" && identity[2] != "" {
|
|
if seenRecords[identity] {
|
|
return metadataError
|
|
}
|
|
seenRecords[identity] = true
|
|
}
|
|
version, target := versions[identity[0]]
|
|
if !target {
|
|
return nil
|
|
}
|
|
for _, field := range []string{"package", "version", "architecture", "filename", "size", "sha256"} {
|
|
if record[field] == "" || strings.Contains(record[field], "\n") {
|
|
return metadataError
|
|
}
|
|
}
|
|
if identity[1] != version || identity[2] != arch {
|
|
return nil
|
|
}
|
|
size, err := decimalSize(record["size"])
|
|
if err != nil {
|
|
return metadataError
|
|
}
|
|
selected[identity[0]] = installplan.Package{
|
|
Name: identity[0], Version: version, Filename: record["filename"],
|
|
Size: size, Digest: "sha256:" + record["sha256"],
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return installplan.Lock{}, metadataError
|
|
}
|
|
|
|
releaseSum := sha256.Sum256(release)
|
|
lock := installplan.Lock{
|
|
ProtocolVersion: 1, Repository: "https://download.docker.com/linux/ubuntu",
|
|
Suite: suite, Architecture: arch, ReleaseDigest: "sha256:" + hex.EncodeToString(releaseSum[:]),
|
|
}
|
|
for _, name := range pinnedPackageNames {
|
|
p, ok := selected[name]
|
|
if !ok {
|
|
return installplan.Lock{}, metadataError
|
|
}
|
|
lock.Packages = append(lock.Packages, p)
|
|
}
|
|
if _, err := installplan.Validate(lock, suite, arch); err != nil {
|
|
return installplan.Lock{}, metadataError
|
|
}
|
|
return lock, nil
|
|
}
|
|
|
|
// parseControl preserves continuation boundaries and rejects duplicate fields
|
|
// before invoking visit. Processing one stanza at a time bounds retained values.
|
|
func parseControl(raw []byte, visit func(map[string]string) error) error {
|
|
if !utf8.Valid(raw) {
|
|
return metadataError
|
|
}
|
|
text := strings.ReplaceAll(string(raw), "\r\n", "\n")
|
|
for _, r := range text {
|
|
if unicode.IsControl(r) && r != '\n' && r != '\t' {
|
|
return metadataError
|
|
}
|
|
}
|
|
fields := make(map[string]*strings.Builder)
|
|
current := ""
|
|
flush := func() error {
|
|
if len(fields) == 0 {
|
|
return nil
|
|
}
|
|
stanza := make(map[string]string, len(fields))
|
|
for name, value := range fields {
|
|
stanza[name] = value.String()
|
|
}
|
|
if err := visit(stanza); err != nil {
|
|
return err
|
|
}
|
|
fields = make(map[string]*strings.Builder)
|
|
current = ""
|
|
return nil
|
|
}
|
|
for line := range strings.SplitSeq(text, "\n") {
|
|
if line == "" {
|
|
if err := flush(); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if line[0] == ' ' || line[0] == '\t' {
|
|
if current == "" {
|
|
return metadataError
|
|
}
|
|
fields[current].WriteByte('\n')
|
|
fields[current].WriteString(strings.Trim(line, " \t"))
|
|
continue
|
|
}
|
|
name, value, ok := strings.Cut(line, ":")
|
|
if !ok || name == "" {
|
|
return metadataError
|
|
}
|
|
for _, r := range name {
|
|
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-') {
|
|
return metadataError
|
|
}
|
|
}
|
|
current = strings.ToLower(name)
|
|
if _, exists := fields[current]; exists {
|
|
return metadataError
|
|
}
|
|
fields[current] = &strings.Builder{}
|
|
fields[current].WriteString(strings.Trim(value, " \t"))
|
|
}
|
|
return flush()
|
|
}
|
|
|
|
func hasWord(value, word string) bool {
|
|
for _, item := range strings.Fields(value) {
|
|
if item == word {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func decimalSize(value string) (uint64, error) {
|
|
for _, r := range value {
|
|
if r < '0' || r > '9' {
|
|
return 0, metadataError
|
|
}
|
|
}
|
|
size, err := strconv.ParseUint(value, 10, 64)
|
|
if err != nil {
|
|
return 0, metadataError
|
|
}
|
|
return size, nil
|
|
}
|
|
|
|
func validSHA256(value string) bool {
|
|
if len(value) != 64 {
|
|
return false
|
|
}
|
|
for _, r := range value {
|
|
if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|