zsvo/pkg/debian/source.go
2026-03-13 10:21:38 +03:00

367 lines
7.9 KiB
Go

package debian
import (
"bufio"
"compress/gzip"
"fmt"
"io"
"net/http"
"path"
"regexp"
"strings"
"time"
"github.com/ulikunitz/xz"
)
var packageNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]*$`)
const defaultDebianMirror = "https://deb.debian.org/debian"
var (
defaultSuites = []string{"stable", "testing", "unstable"}
defaultComponents = []string{"main", "contrib", "non-free", "non-free-firmware"}
)
// SourceInfo describes Debian source package coordinates resolved over HTTP.
type SourceInfo struct {
RequestedPackage string
SourcePackage string
DSCURL string
DSCSHA256 string
DebianVersion string
UpstreamVersion string
Suite string
Component string
}
// Resolver queries Debian source metadata over HTTP.
type Resolver struct {
client *http.Client
mirrors []string
suites []string
components []string
}
// ResolverOption customizes resolver behavior.
type ResolverOption func(*Resolver)
func WithHTTPClient(client *http.Client) ResolverOption {
return func(r *Resolver) {
if client != nil {
r.client = client
}
}
}
func WithMirrors(mirrors []string) ResolverOption {
return func(r *Resolver) {
r.mirrors = normalizeList(mirrors)
}
}
func WithSuites(suites []string) ResolverOption {
return func(r *Resolver) {
r.suites = normalizeList(suites)
}
}
func WithComponents(components []string) ResolverOption {
return func(r *Resolver) {
r.components = normalizeList(components)
}
}
func NewResolver(opts ...ResolverOption) *Resolver {
r := &Resolver{
client: &http.Client{Timeout: 30 * time.Second},
mirrors: []string{
defaultDebianMirror,
},
suites: append([]string(nil), defaultSuites...),
components: append([]string(nil), defaultComponents...),
}
for _, opt := range opts {
if opt != nil {
opt(r)
}
}
if len(r.mirrors) == 0 {
r.mirrors = []string{defaultDebianMirror}
}
if len(r.suites) == 0 {
r.suites = append([]string(nil), defaultSuites...)
}
if len(r.components) == 0 {
r.components = append([]string(nil), defaultComponents...)
}
return r
}
func (r *Resolver) ResolveSource(pkg string) (*SourceInfo, error) {
pkg = strings.TrimSpace(strings.ToLower(pkg))
if !packageNamePattern.MatchString(pkg) {
return nil, fmt.Errorf("invalid package name %q", pkg)
}
checked := make([]string, 0, len(r.mirrors)*len(r.suites)*len(r.components))
for _, mirror := range r.mirrors {
for _, suite := range r.suites {
for _, component := range r.components {
record, err := r.findPackageInIndex(mirror, suite, component, pkg)
checked = append(checked, fmt.Sprintf("%s:%s/%s", mirror, suite, component))
if err != nil {
continue
}
return &SourceInfo{
RequestedPackage: pkg,
SourcePackage: record.Package,
DSCURL: strings.TrimRight(mirror, "/") + "/" + path.Join(record.Directory, record.DSCName),
DSCSHA256: record.DSCSHA256,
DebianVersion: record.Version,
UpstreamVersion: normalizeUpstreamVersion(record.Version),
Suite: suite,
Component: component,
}, nil
}
}
}
return nil, fmt.Errorf(
"source package %s not found via HTTP (checked: %s)",
pkg,
strings.Join(checked, ", "),
)
}
type sourceRecord struct {
Package string
Version string
Directory string
DSCName string
DSCSHA256 string
}
func (r *Resolver) findPackageInIndex(mirror, suite, component, pkg string) (*sourceRecord, error) {
variants := []struct {
ext string
decoder func(io.Reader) (io.Reader, error)
}{
{ext: "xz", decoder: decodeXZ},
{ext: "gz", decoder: decodeGzip},
{ext: "", decoder: passthrough},
}
var firstErr error
for _, v := range variants {
indexURL := buildSourcesURL(mirror, suite, component, v.ext)
record, err := r.findInSingleIndex(indexURL, v.decoder, pkg)
if err == nil {
return record, nil
}
if firstErr == nil {
firstErr = err
}
}
if firstErr == nil {
firstErr = fmt.Errorf("could not read sources index")
}
return nil, firstErr
}
func (r *Resolver) findInSingleIndex(indexURL string, decoder func(io.Reader) (io.Reader, error), pkg string) (*sourceRecord, error) {
resp, err := r.client.Get(indexURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("http %d", resp.StatusCode)
}
reader, err := decoder(resp.Body)
if err != nil {
return nil, err
}
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
paragraph := make([]string, 0, 32)
flush := func() (*sourceRecord, bool, error) {
if len(paragraph) == 0 {
return nil, false, nil
}
rec, err := parseSourcesParagraph(paragraph)
paragraph = paragraph[:0]
if err != nil {
return nil, false, nil
}
if rec.Package == pkg {
return &rec, true, nil
}
return nil, false, nil
}
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
rec, ok, err := flush()
if err != nil {
return nil, err
}
if ok {
return rec, nil
}
continue
}
paragraph = append(paragraph, line)
}
if err := scanner.Err(); err != nil {
return nil, err
}
rec, ok, err := flush()
if err != nil {
return nil, err
}
if ok {
return rec, nil
}
return nil, fmt.Errorf("package not found in %s", indexURL)
}
func buildSourcesURL(mirror, suite, component, ext string) string {
mirror = strings.TrimRight(strings.TrimSpace(mirror), "/")
suite = strings.Trim(strings.TrimSpace(suite), "/")
component = strings.Trim(strings.TrimSpace(component), "/")
name := "Sources"
if ext != "" {
name += "." + ext
}
return mirror + "/" + path.Join("dists", suite, component, "source", name)
}
func parseSourcesParagraph(lines []string) (sourceRecord, error) {
fields := make(map[string]string)
var currentKey string
for _, raw := range lines {
if strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") {
if currentKey == "" {
continue
}
fields[currentKey] += "\n" + strings.TrimSpace(raw)
continue
}
idx := strings.IndexByte(raw, ':')
if idx <= 0 {
continue
}
key := strings.TrimSpace(raw[:idx])
value := strings.TrimSpace(raw[idx+1:])
fields[key] = value
currentKey = key
}
pkg := fields["Package"]
ver := fields["Version"]
dir := fields["Directory"]
if pkg == "" || ver == "" || dir == "" {
return sourceRecord{}, fmt.Errorf("missing required fields")
}
dscName, dscHash := parseChecksumsForDSC(fields["Checksums-Sha256"])
if dscName == "" {
return sourceRecord{}, fmt.Errorf("no dsc in checksums")
}
dir = strings.Trim(strings.TrimSpace(dir), "/")
return sourceRecord{
Package: pkg,
Version: ver,
Directory: dir,
DSCName: dscName,
DSCSHA256: dscHash,
}, nil
}
func parseChecksumsForDSC(raw string) (string, string) {
for _, line := range strings.Split(raw, "\n") {
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) < 3 {
continue
}
name := fields[2]
if strings.HasSuffix(strings.ToLower(name), ".dsc") {
return name, strings.ToLower(fields[0])
}
}
return "", ""
}
func decodeXZ(r io.Reader) (io.Reader, error) {
return xz.NewReader(r)
}
func decodeGzip(r io.Reader) (io.Reader, error) {
return gzip.NewReader(r)
}
func passthrough(r io.Reader) (io.Reader, error) {
return r, nil
}
func normalizeUpstreamVersion(debianVersion string) string {
v := strings.TrimSpace(debianVersion)
if v == "" {
return "0"
}
if idx := strings.IndexByte(v, ':'); idx >= 0 {
v = v[idx+1:]
}
if idx := strings.LastIndex(v, "-"); idx > 0 {
v = v[:idx]
}
replacer := strings.NewReplacer(
"/", "_",
":", "_",
" ", "_",
)
v = strings.TrimSpace(replacer.Replace(v))
if v == "" {
return "0"
}
return v
}
func normalizeList(in []string) []string {
set := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if _, exists := set[s]; exists {
continue
}
set[s] = struct{}{}
out = append(out, s)
}
return out
}