zsvo/pkg/debian/source.go
2026-03-14 22:03:19 +03:00

656 lines
16 KiB
Go

package debian
import (
"bufio"
"compress/gzip"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"sync"
"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"} // Только stable для скорости
defaultComponents = []string{"main"} // Только main для скорости
)
// 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
BuildDepends []string // Build dependencies from DSC file
}
const maxAutoBuildDepth = 15 // Увеличим для сложных цепочек зависимостей
const parallelWorkers = 20 // Параллельный поиск зависимостей (HTTP запросы)
const buildWorkers = 4 // Параллельное построение пакетов
// CachedSources holds parsed Sources data for fast lookups
type CachedSources struct {
packages map[string]*sourceRecord // key: package name
path string // cache file path
mu sync.RWMutex
}
// Resolver queries Debian source metadata over HTTP.
type Resolver struct {
client *http.Client
mirrors []string
suites []string
components []string
cache map[string]*SourceInfo // Кеш найденных пакетов
cacheMu sync.RWMutex // Защита кеша
cachedSources map[string]*CachedSources // Кешированные Sources файлы по ключу
sourcesMu sync.RWMutex // Защита cachedSources map
}
// 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,
Transport: &http.Transport{
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
},
mirrors: []string{
defaultDebianMirror, // deb.debian.org (CDN)
},
suites: append([]string(nil), defaultSuites...),
components: append([]string(nil), defaultComponents...),
cache: make(map[string]*SourceInfo),
cachedSources: make(map[string]*CachedSources),
}
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)
}
// Проверяем кеш
r.cacheMu.RLock()
if cached, ok := r.cache[pkg]; ok {
r.cacheMu.RUnlock()
return cached, nil
}
r.cacheMu.RUnlock()
fmt.Printf(" [resolver] Looking up %s...\n", pkg)
start := time.Now()
defer func() {
fmt.Printf(" [resolver] %s lookup took %v\n", pkg, time.Since(start))
}()
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 {
fmt.Printf(" [resolver] Checking %s/%s/%s...\n", mirror, suite, component)
record, err := r.findPackageInIndex(mirror, suite, component, pkg)
checked = append(checked, fmt.Sprintf("%s:%s/%s", mirror, suite, component))
if err != nil {
fmt.Printf(" [resolver] Not found in %s/%s/%s: %v\n", mirror, suite, component, err)
continue
}
result := &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,
BuildDepends: record.BuildDepends,
}
// Сохраняем в кеш
r.cacheMu.Lock()
r.cache[pkg] = result
r.cacheMu.Unlock()
fmt.Printf(" [resolver] ✓ Found %s in %s/%s/%s\n", pkg, mirror, suite, component)
return result, 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
Binaries []string
BuildDepends []string
}
func (r *Resolver) findPackageInIndex(mirror, suite, component, pkg string) (*sourceRecord, error) {
// First, ensure Sources file is loaded into memory
cached, err := r.loadCachedSources(mirror, suite, component)
if err != nil {
return nil, err
}
// Lookup from in-memory map (O(1) instead of HTTP scan)
if rec, found := r.lookupFromCache(pkg, cached); found {
return rec, nil
}
return nil, fmt.Errorf("package not found in cache")
}
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 || containsSourceBinary(rec.Binaries, 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,
Binaries: parseCommaSeparatedField(fields["Binary"]),
BuildDepends: parseCommaSeparatedField(fields["Build-Depends"]),
}, nil
}
func parseCommaSeparatedField(raw string) []string {
parts := strings.Split(strings.TrimSpace(raw), ",")
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
part = strings.TrimSpace(strings.ToLower(part))
if part == "" {
continue
}
if _, exists := seen[part]; exists {
continue
}
seen[part] = struct{}{}
out = append(out, part)
}
return out
}
func containsSourceBinary(names []string, wanted string) bool {
wanted = strings.TrimSpace(strings.ToLower(wanted))
if wanted == "" {
return false
}
for _, name := range names {
if strings.TrimSpace(strings.ToLower(name)) == wanted {
return true
}
}
return false
}
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:]
}
// Find the last dash that separates upstream version from Debian revision
// For versions like "1.0-1-ubuntu1", we want to cut at the first dash to get "1.0"
if idx := strings.Index(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
}
// cacheDir returns the cache directory for zsvo
func cacheDir() string {
if dir := os.Getenv("ZSVO_CACHE"); dir != "" {
return dir
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".cache", "zsvo")
}
return "/var/cache/zsvo"
}
// ensureCacheDir creates the cache directory if it doesn't exist
func ensureCacheDir() error {
dir := cacheDir()
return os.MkdirAll(dir, 0755)
}
// cachePath returns the path for a cached Sources file
func (r *Resolver) cachePath(mirror, suite, component string) string {
host := strings.ReplaceAll(strings.TrimPrefix(mirror, "https://"), "/", "_")
return filepath.Join(cacheDir(), fmt.Sprintf("Sources_%s_%s_%s.xz", host, suite, component))
}
// cacheKey returns a unique key for mirror/suite/component combination
func (r *Resolver) cacheKey(mirror, suite, component string) string {
return fmt.Sprintf("%s:%s:%s", mirror, suite, component)
}
// loadCachedSources loads Sources.xz from cache or downloads it if not exists
func (r *Resolver) loadCachedSources(mirror, suite, component string) (*CachedSources, error) {
key := r.cacheKey(mirror, suite, component)
// Fast path: check if already loaded
r.sourcesMu.RLock()
if cached, exists := r.cachedSources[key]; exists {
r.sourcesMu.RUnlock()
return cached, nil
}
r.sourcesMu.RUnlock()
// Slow path: load with write lock
r.sourcesMu.Lock()
defer r.sourcesMu.Unlock()
// Double-check after acquiring write lock
if cached, exists := r.cachedSources[key]; exists {
return cached, nil
}
cachePath := r.cachePath(mirror, suite, component)
cached := &CachedSources{
packages: make(map[string]*sourceRecord),
path: cachePath,
}
// Try to load from disk cache first
if _, err := os.Stat(cachePath); err == nil {
// File exists on disk, parse it
if err := r.parseSourcesFileToCache(cachePath, mirror, cached); err == nil {
r.cachedSources[key] = cached
return cached, nil
}
}
// Download from HTTP
url := buildSourcesURL(mirror, suite, component, "xz")
if err := r.downloadSources(url, cachePath); err != nil {
// Try gz
url = buildSourcesURL(mirror, suite, component, "gz")
cachePath = strings.TrimSuffix(cachePath, ".xz") + ".gz"
if err := r.downloadSources(url, cachePath); err != nil {
// Try uncompressed
url = buildSourcesURL(mirror, suite, component, "")
cachePath = strings.TrimSuffix(cachePath, ".gz")
if err := r.downloadSources(url, cachePath); err != nil {
return nil, err
}
}
}
// Parse the downloaded file
if err := r.parseSourcesFileToCache(cachePath, mirror, cached); err != nil {
return nil, err
}
r.cachedSources[key] = cached
return cached, nil
}
// downloadSources downloads a Sources file from URL to local path
func (r *Resolver) downloadSources(url, localPath string) error {
fmt.Printf(" [resolver] Downloading %s...\n", url)
start := time.Now()
resp, err := r.client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("http %d", resp.StatusCode)
}
if err := ensureCacheDir(); err != nil {
return err
}
file, err := os.Create(localPath)
if err != nil {
return err
}
defer file.Close()
if _, err := io.Copy(file, resp.Body); err != nil {
os.Remove(localPath)
return err
}
fmt.Printf(" [resolver] Downloaded in %v\n", time.Since(start))
return nil
}
// parseSourcesFileToCache parses a local Sources file into specific CachedSources instance
func (r *Resolver) parseSourcesFileToCache(path, mirror string, cached *CachedSources) error {
fmt.Printf(" [resolver] Parsing %s...\n", filepath.Base(path))
start := time.Now()
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
var reader io.Reader = file
// Decompress if needed
if strings.HasSuffix(path, ".xz") {
r, err := xz.NewReader(file)
if err != nil {
return err
}
reader = r
} else if strings.HasSuffix(path, ".gz") {
r, err := gzip.NewReader(file)
if err != nil {
return err
}
defer r.Close()
reader = r
}
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
paragraph := make([]string, 0, 32)
count := 0
flush := func() {
if len(paragraph) == 0 {
return
}
rec, err := parseSourcesParagraph(paragraph)
paragraph = paragraph[:0]
if err != nil {
return
}
cached.mu.Lock()
cached.packages[rec.Package] = &rec
// Also index by binary names
for _, bin := range rec.Binaries {
bin = strings.TrimSpace(strings.ToLower(bin))
if bin != "" && bin != rec.Package {
cached.packages[bin] = &rec
}
}
cached.mu.Unlock()
count++
}
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
flush()
continue
}
paragraph = append(paragraph, line)
}
flush()
if err := scanner.Err(); err != nil {
return err
}
cached.path = path
fmt.Printf(" [resolver] Parsed %d packages in %v\n", count, time.Since(start))
return nil
}
// lookupFromCache searches for a package in the in-memory cache
func (r *Resolver) lookupFromCache(pkg string, cached *CachedSources) (*sourceRecord, bool) {
cached.mu.RLock()
defer cached.mu.RUnlock()
if rec, ok := cached.packages[pkg]; ok {
return rec, true
}
return nil, false
}