параша
This commit is contained in:
parent
8db46a6b45
commit
38e92af8a3
9 changed files with 779 additions and 11 deletions
|
|
@ -15,6 +15,9 @@ zsvo build recipes/zlib.yaml
|
||||||
# Install package built by zsvo
|
# Install package built by zsvo
|
||||||
zsvo install /path/to/name-version.pkg.tar.zst
|
zsvo install /path/to/name-version.pkg.tar.zst
|
||||||
|
|
||||||
|
# Auto-build from Debian source and install
|
||||||
|
zsvo install neofetch
|
||||||
|
|
||||||
# Install to custom root
|
# Install to custom root
|
||||||
zsvo install --root /mnt/root /path/to/name-version.pkg.tar.zst
|
zsvo install --root /mnt/root /path/to/name-version.pkg.tar.zst
|
||||||
|
|
||||||
|
|
@ -71,6 +74,7 @@ Notes:
|
||||||
- `deps` are simple package names (no version solver yet).
|
- `deps` are simple package names (no version solver yet).
|
||||||
- `{{pkgdir}}` (and `${pkgdir}`) points to staging DESTDIR.
|
- `{{pkgdir}}` (and `${pkgdir}`) points to staging DESTDIR.
|
||||||
- If `source.debian_dsc` (or `.dsc` URL) is used, zsvo downloads only `*.orig*.tar.*` archives from that source package and ignores Debian patch archives (`debian.tar.*` / `diff.gz`).
|
- If `source.debian_dsc` (or `.dsc` URL) is used, zsvo downloads only `*.orig*.tar.*` archives from that source package and ignores Debian patch archives (`debian.tar.*` / `diff.gz`).
|
||||||
|
- `zsvo install <name>` auto-resolves Debian source over HTTP from Debian `Sources` indexes, builds package in `--work-dir` and installs it.
|
||||||
- Package metadata is stored in `.zsvo.yml` (not `.PKGINFO`).
|
- Package metadata is stored in `.zsvo.yml` (not `.PKGINFO`).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
|
||||||
142
cmd/install.go
142
cmd/install.go
|
|
@ -2,32 +2,84 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"zsvo/pkg/builder"
|
||||||
|
"zsvo/pkg/debian"
|
||||||
"zsvo/pkg/installer"
|
"zsvo/pkg/installer"
|
||||||
|
"zsvo/pkg/recipe"
|
||||||
)
|
)
|
||||||
|
|
||||||
var InstallCmd = &cobra.Command{
|
var InstallCmd = &cobra.Command{
|
||||||
Use: "install <package> [package...]",
|
Use: "install <package> [package...]",
|
||||||
Short: "Install package(s)",
|
Short: "Install package(s)",
|
||||||
Long: `Install one or more packages from package files`,
|
Long: `Install one or more packages from local files or auto-build from Debian source by package name`,
|
||||||
Args: cobra.MinimumNArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
// Create installer
|
|
||||||
rootDir, _ := cmd.Flags().GetString("root")
|
rootDir, _ := cmd.Flags().GetString("root")
|
||||||
if rootDir == "" {
|
if rootDir == "" {
|
||||||
rootDir = "/"
|
rootDir = "/"
|
||||||
}
|
}
|
||||||
|
workDir, _ := cmd.Flags().GetString("work-dir")
|
||||||
|
if strings.TrimSpace(workDir) == "" {
|
||||||
|
workDir = "/tmp/pkg-work"
|
||||||
|
}
|
||||||
|
autoSource, _ := cmd.Flags().GetBool("auto-source")
|
||||||
|
|
||||||
|
installTargets := make([]string, 0, len(args))
|
||||||
|
var resolver *debian.Resolver
|
||||||
|
var b *builder.Builder
|
||||||
|
if autoSource {
|
||||||
|
resolver = debian.NewResolver()
|
||||||
|
b = builder.NewBuilder(workDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, target := range args {
|
||||||
|
isFile, err := isInstallFileTarget(target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if isFile {
|
||||||
|
installTargets = append(installTargets, target)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !autoSource {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s is not a package file; pass a file path or enable --auto-source",
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Resolving Debian source for %s...\n", target)
|
||||||
|
srcInfo, err := resolver.ResolveSource(target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rcp := autoRecipeFromDebian(srcInfo)
|
||||||
|
fmt.Printf("Building %s from %s...\n", rcp.GetPackageName(), srcInfo.DSCURL)
|
||||||
|
if err := b.Build(rcp); err != nil {
|
||||||
|
return fmt.Errorf("failed to auto-build %s: %w", target, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
builtPackage := filepath.Join(rcp.GetPackageDir(workDir), rcp.GetPackageFileName())
|
||||||
|
fmt.Printf("Built package: %s\n", builtPackage)
|
||||||
|
installTargets = append(installTargets, builtPackage)
|
||||||
|
}
|
||||||
|
|
||||||
i := installer.NewInstaller(rootDir)
|
i := installer.NewInstaller(rootDir)
|
||||||
|
|
||||||
// Install packages
|
if len(installTargets) == 1 {
|
||||||
if len(args) == 1 {
|
fmt.Printf("Installing package from %s...\n", installTargets[0])
|
||||||
fmt.Printf("Installing package from %s...\n", args[0])
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("Installing %d packages...\n", len(args))
|
fmt.Printf("Installing %d packages...\n", len(installTargets))
|
||||||
}
|
}
|
||||||
if err := i.InstallMany(args); err != nil {
|
if err := i.InstallMany(installTargets); err != nil {
|
||||||
return fmt.Errorf("failed to install packages: %w", err)
|
return fmt.Errorf("failed to install packages: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,4 +90,80 @@ var InstallCmd = &cobra.Command{
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
InstallCmd.Flags().StringP("root", "r", "/", "Root directory for installation")
|
InstallCmd.Flags().StringP("root", "r", "/", "Root directory for installation")
|
||||||
|
InstallCmd.Flags().StringP("work-dir", "w", "/tmp/pkg-work", "Working directory for source builds")
|
||||||
|
InstallCmd.Flags().Bool("auto-source", true, "Auto-build package names from Debian source")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInstallFileTarget(target string) (bool, error) {
|
||||||
|
target = strings.TrimSpace(target)
|
||||||
|
if target == "" {
|
||||||
|
return false, fmt.Errorf("install target cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit file-like targets are validated as paths.
|
||||||
|
if looksLikeFilePath(target) {
|
||||||
|
info, err := os.Stat(target)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("package file %s not found: %w", target, err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return false, fmt.Errorf("package file %s is a directory", target)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a same-name local file exists, use it as package archive.
|
||||||
|
if info, err := os.Stat(target); err == nil {
|
||||||
|
if info.IsDir() {
|
||||||
|
return false, fmt.Errorf("package file %s is a directory", target)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeFilePath(target string) bool {
|
||||||
|
return strings.Contains(target, string(os.PathSeparator)) ||
|
||||||
|
strings.HasPrefix(target, ".") ||
|
||||||
|
strings.HasSuffix(target, ".pkg.tar.zst") ||
|
||||||
|
strings.HasSuffix(target, ".zov")
|
||||||
|
}
|
||||||
|
|
||||||
|
func autoRecipeFromDebian(src *debian.SourceInfo) *recipe.Recipe {
|
||||||
|
name := src.SourcePackage
|
||||||
|
if name == "" {
|
||||||
|
name = src.RequestedPackage
|
||||||
|
}
|
||||||
|
version := src.UpstreamVersion
|
||||||
|
if version == "" {
|
||||||
|
version = "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
installCmd := fmt.Sprintf(
|
||||||
|
"if [ -f build/cmake_install.cmake ]; then DESTDIR={{pkgdir}} cmake --install build; "+
|
||||||
|
"elif [ -f build/meson-private/coredata.dat ]; then DESTDIR={{pkgdir}} meson install -C build; "+
|
||||||
|
"elif [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then make DESTDIR={{pkgdir}} PREFIX=/usr install; "+
|
||||||
|
"elif [ -f %s ]; then install -Dm755 %s {{pkgdir}}/usr/bin/%s; fi",
|
||||||
|
name,
|
||||||
|
name,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
|
||||||
|
return &recipe.Recipe{
|
||||||
|
Name: name,
|
||||||
|
Version: version,
|
||||||
|
Description: fmt.Sprintf("Auto-generated build recipe from Debian source %s", src.DSCURL),
|
||||||
|
Source: recipe.Source{
|
||||||
|
DebianDSC: src.DSCURL,
|
||||||
|
Sha256: src.DSCSHA256,
|
||||||
|
},
|
||||||
|
Build: []string{
|
||||||
|
"[ -f configure ] && ./configure --prefix=/usr || true",
|
||||||
|
"if [ -f CMakeLists.txt ]; then cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr && cmake --build build -j${jobs}; " +
|
||||||
|
"elif [ -f meson.build ]; then meson setup build --prefix=/usr && meson compile -C build -j${jobs}; " +
|
||||||
|
"elif [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then make -j${jobs}; fi",
|
||||||
|
},
|
||||||
|
Install: []string{installCmd},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
cmd/install_test.go
Normal file
71
cmd/install_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"zsvo/pkg/debian"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsInstallFileTarget_File(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
pkgPath := filepath.Join(tmpDir, "demo.pkg.tar.zst")
|
||||||
|
if err := os.WriteFile(pkgPath, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write temp package: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
isFile, err := isInstallFileTarget(pkgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("isInstallFileTarget() error = %v", err)
|
||||||
|
}
|
||||||
|
if !isFile {
|
||||||
|
t.Fatalf("expected file target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsInstallFileTarget_Name(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
isFile, err := isInstallFileTarget("neofetch")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("isInstallFileTarget() error = %v", err)
|
||||||
|
}
|
||||||
|
if isFile {
|
||||||
|
t.Fatalf("expected package-name target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsInstallFileTarget_MissingPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := isInstallFileTarget("./missing.pkg.tar.zst")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for missing file path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoRecipeFromDebian(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := autoRecipeFromDebian(&debian.SourceInfo{
|
||||||
|
RequestedPackage: "neofetch",
|
||||||
|
SourcePackage: "neofetch",
|
||||||
|
DSCURL: "https://deb.debian.org/debian/pool/main/n/neofetch/neofetch_7.1.0-4.dsc",
|
||||||
|
DSCSHA256: "deadbeef",
|
||||||
|
DebianVersion: "7.1.0-4",
|
||||||
|
UpstreamVersion: "7.1.0",
|
||||||
|
})
|
||||||
|
|
||||||
|
if r.Name != "neofetch" || r.Version != "7.1.0" {
|
||||||
|
t.Fatalf("unexpected recipe identity: %s %s", r.Name, r.Version)
|
||||||
|
}
|
||||||
|
if r.Source.DebianDSC == "" {
|
||||||
|
t.Fatalf("expected debian dsc source")
|
||||||
|
}
|
||||||
|
if len(r.Build) == 0 || len(r.Install) == 0 {
|
||||||
|
t.Fatalf("expected auto recipe build/install commands")
|
||||||
|
}
|
||||||
|
}
|
||||||
2
main.go
2
main.go
|
|
@ -15,7 +15,7 @@ var rootCmd = &cobra.Command{
|
||||||
|
|
||||||
Available commands:
|
Available commands:
|
||||||
build Build a package from recipe
|
build Build a package from recipe
|
||||||
install Install package(s) from package files
|
install Install package(s) from local files or auto-build by name
|
||||||
upgrade Upgrade package(s) from package files
|
upgrade Upgrade package(s) from package files
|
||||||
remove Remove installed package(s)
|
remove Remove installed package(s)
|
||||||
list List installed packages
|
list List installed packages
|
||||||
|
|
|
||||||
367
pkg/debian/source.go
Normal file
367
pkg/debian/source.go
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
156
pkg/debian/source_test.go
Normal file
156
pkg/debian/source_test.go
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
package debian
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return f(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSourceHTTPFallbackToGzip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sources := strings.TrimSpace(`
|
||||||
|
Package: neofetch
|
||||||
|
Version: 7.1.0-4
|
||||||
|
Directory: pool/main/n/neofetch
|
||||||
|
Checksums-Sha256:
|
||||||
|
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1978 neofetch_7.1.0-4.dsc
|
||||||
|
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 73791 neofetch_7.1.0.orig.tar.gz
|
||||||
|
`) + "\n"
|
||||||
|
|
||||||
|
var gz bytes.Buffer
|
||||||
|
zw := gzip.NewWriter(&gz)
|
||||||
|
if _, err := zw.Write([]byte(sources)); err != nil {
|
||||||
|
t.Fatalf("failed to write gzip sources: %v", err)
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
t.Fatalf("failed to close gzip writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
switch req.URL.Path {
|
||||||
|
case "/debian/dists/stable/main/source/Sources.xz":
|
||||||
|
return httpResponse(req, http.StatusNotFound, []byte("not found")), nil
|
||||||
|
case "/debian/dists/stable/main/source/Sources.gz":
|
||||||
|
return httpResponse(req, http.StatusOK, gz.Bytes()), nil
|
||||||
|
default:
|
||||||
|
return httpResponse(req, http.StatusNotFound, []byte("missing")), nil
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
r := NewResolver(
|
||||||
|
WithHTTPClient(client),
|
||||||
|
WithMirrors([]string{"https://mirror.example/debian"}),
|
||||||
|
WithSuites([]string{"stable"}),
|
||||||
|
WithComponents([]string{"main"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
info, err := r.ResolveSource("neofetch")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveSource() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.SourcePackage != "neofetch" {
|
||||||
|
t.Fatalf("unexpected source package: %s", info.SourcePackage)
|
||||||
|
}
|
||||||
|
if info.DebianVersion != "7.1.0-4" {
|
||||||
|
t.Fatalf("unexpected debian version: %s", info.DebianVersion)
|
||||||
|
}
|
||||||
|
if info.UpstreamVersion != "7.1.0" {
|
||||||
|
t.Fatalf("unexpected upstream version: %s", info.UpstreamVersion)
|
||||||
|
}
|
||||||
|
wantURL := "https://mirror.example/debian/pool/main/n/neofetch/neofetch_7.1.0-4.dsc"
|
||||||
|
if info.DSCURL != wantURL {
|
||||||
|
t.Fatalf("unexpected dsc url:\nwant: %s\ngot: %s", wantURL, info.DSCURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSourceHTTPNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return httpResponse(req, http.StatusNotFound, []byte("not found")), nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
r := NewResolver(
|
||||||
|
WithHTTPClient(client),
|
||||||
|
WithMirrors([]string{"https://mirror.example/debian"}),
|
||||||
|
WithSuites([]string{"stable"}),
|
||||||
|
WithComponents([]string{"main"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err := r.ResolveSource("no-such-package")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected not found error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not found") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSourcesParagraph(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
paragraph := []string{
|
||||||
|
"Package: foo",
|
||||||
|
"Version: 1:2.3.4-5",
|
||||||
|
"Directory: pool/main/f/foo",
|
||||||
|
"Checksums-Sha256:",
|
||||||
|
" aaaa 10 foo_1:2.3.4-5.dsc",
|
||||||
|
" bbbb 20 foo_2.3.4.orig.tar.gz",
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := parseSourcesParagraph(paragraph)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseSourcesParagraph() error = %v", err)
|
||||||
|
}
|
||||||
|
if rec.Package != "foo" || rec.Version != "1:2.3.4-5" {
|
||||||
|
t.Fatalf("unexpected record identity: %s %s", rec.Package, rec.Version)
|
||||||
|
}
|
||||||
|
if rec.Directory != "pool/main/f/foo" {
|
||||||
|
t.Fatalf("unexpected directory: %s", rec.Directory)
|
||||||
|
}
|
||||||
|
if rec.DSCName != "foo_1:2.3.4-5.dsc" {
|
||||||
|
t.Fatalf("unexpected dsc name: %s", rec.DSCName)
|
||||||
|
}
|
||||||
|
if rec.DSCSHA256 != "aaaa" {
|
||||||
|
t.Fatalf("unexpected dsc hash: %s", rec.DSCSHA256)
|
||||||
|
}
|
||||||
|
if got := normalizeUpstreamVersion(rec.Version); got != "2.3.4" {
|
||||||
|
t.Fatalf("unexpected normalized version: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSourceInvalidPackageName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := NewResolver()
|
||||||
|
_, err := r.ResolveSource("bad/name")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected package name validation error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpResponse(req *http.Request, status int, body []byte) *http.Response {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Body: io.NopCloser(bytes.NewReader(body)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
Request: req,
|
||||||
|
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -287,15 +287,31 @@ func isDebianOrigArchive(name string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
rest := name[idx+len(".orig"):]
|
rest := name[idx+len(".orig"):]
|
||||||
if strings.HasPrefix(rest, ".tar.") {
|
if hasTarSuffix(rest) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(rest, "-") && strings.Contains(rest, ".tar.") {
|
if strings.HasPrefix(rest, "-") {
|
||||||
return true
|
tarIdx := strings.Index(rest, ".tar")
|
||||||
|
if tarIdx >= 0 {
|
||||||
|
componentTar := rest[tarIdx:]
|
||||||
|
if hasTarSuffix(componentTar) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasTarSuffix(s string) bool {
|
||||||
|
if !strings.HasPrefix(s, ".tar") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(s) == len(".tar") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(s[len(".tar"):], ".")
|
||||||
|
}
|
||||||
|
|
||||||
func isDebianDSCURL(raw string) bool {
|
func isDebianDSCURL(raw string) bool {
|
||||||
raw = strings.TrimSpace(strings.ToLower(raw))
|
raw = strings.TrimSpace(strings.ToLower(raw))
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,32 @@ func TestDownloadAndExtractDebianDSCChecksumMismatch(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsDebianOrigArchive(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "pkg_1.0.orig.tar.gz", want: true},
|
||||||
|
{name: "pkg_1.0.orig.tar.xz", want: true},
|
||||||
|
{name: "pkg_1.0.orig.tar", want: true},
|
||||||
|
{name: "pkg_1.0.orig-data.tar.zst", want: true},
|
||||||
|
{name: "pkg_1.0-1.debian.tar.xz", want: false},
|
||||||
|
{name: "pkg_1.0-1.diff.gz", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := isDebianOrigArchive(tt.name); got != tt.want {
|
||||||
|
t.Fatalf("isDebianOrigArchive(%q) = %v, want %v", tt.name, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
|
|
||||||
BIN
zsvo
BIN
zsvo
Binary file not shown.
Loading…
Reference in a new issue