diff --git a/README.md b/README.md index e57ebc2..8981f61 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 diff --git a/cmd/install.go b/cmd/install.go index 693bdaa..e7a0f26 100644 --- a/cmd/install.go +++ b/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...]", 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}, + } } diff --git a/cmd/install_test.go b/cmd/install_test.go new file mode 100644 index 0000000..4625542 --- /dev/null +++ b/cmd/install_test.go @@ -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") + } +} diff --git a/main.go b/main.go index 60a380c..63f1065 100644 --- a/main.go +++ b/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 diff --git a/pkg/debian/source.go b/pkg/debian/source.go new file mode 100644 index 0000000..099a7a6 --- /dev/null +++ b/pkg/debian/source.go @@ -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 +} diff --git a/pkg/debian/source_test.go b/pkg/debian/source_test.go new file mode 100644 index 0000000..3396243 --- /dev/null +++ b/pkg/debian/source_test.go @@ -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)), + } +} diff --git a/pkg/fetcher/fetcher.go b/pkg/fetcher/fetcher.go index 0575594..753595e 100644 --- a/pkg/fetcher/fetcher.go +++ b/pkg/fetcher/fetcher.go @@ -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 == "" { diff --git a/pkg/fetcher/fetcher_test.go b/pkg/fetcher/fetcher_test.go index 0dcd692..91d3bfd 100644 --- a/pkg/fetcher/fetcher_test.go +++ b/pkg/fetcher/fetcher_test.go @@ -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) { diff --git a/zsvo b/zsvo index daf16ce..5778443 100755 Binary files a/zsvo and b/zsvo differ