параша
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
|
||||
zsvo install /path/to/name-version.pkg.tar.zst
|
||||
|
||||
# Auto-build from Debian source and install
|
||||
zsvo install neofetch
|
||||
|
||||
# Install to custom root
|
||||
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).
|
||||
- `{{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`).
|
||||
- `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`).
|
||||
|
||||
## Architecture
|
||||
|
|
|
|||
142
cmd/install.go
142
cmd/install.go
|
|
@ -2,32 +2,84 @@ package cmd
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"zsvo/pkg/builder"
|
||||
"zsvo/pkg/debian"
|
||||
"zsvo/pkg/installer"
|
||||
"zsvo/pkg/recipe"
|
||||
)
|
||||
|
||||
var InstallCmd = &cobra.Command{
|
||||
Use: "install <package> [package...]",
|
||||
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),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Create installer
|
||||
rootDir, _ := cmd.Flags().GetString("root")
|
||||
if 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)
|
||||
|
||||
// Install packages
|
||||
if len(args) == 1 {
|
||||
fmt.Printf("Installing package from %s...\n", args[0])
|
||||
if len(installTargets) == 1 {
|
||||
fmt.Printf("Installing package from %s...\n", installTargets[0])
|
||||
} 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)
|
||||
}
|
||||
|
||||
|
|
@ -38,4 +90,80 @@ var InstallCmd = &cobra.Command{
|
|||
|
||||
func init() {
|
||||
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:
|
||||
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
|
||||
remove Remove installed package(s)
|
||||
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"):]
|
||||
if strings.HasPrefix(rest, ".tar.") {
|
||||
if hasTarSuffix(rest) {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(rest, "-") && strings.Contains(rest, ".tar.") {
|
||||
return true
|
||||
if strings.HasPrefix(rest, "-") {
|
||||
tarIdx := strings.Index(rest, ".tar")
|
||||
if tarIdx >= 0 {
|
||||
componentTar := rest[tarIdx:]
|
||||
if hasTarSuffix(componentTar) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
raw = strings.TrimSpace(strings.ToLower(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)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
|
|
|
|||
BIN
zsvo
BIN
zsvo
Binary file not shown.
Loading…
Reference in a new issue