параша

This commit is contained in:
itexpert228 2026-03-13 10:21:38 +03:00
parent 8db46a6b45
commit 38e92af8a3
No known key found for this signature in database
9 changed files with 779 additions and 11 deletions

View file

@ -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

View file

@ -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
View 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")
}
}

View file

@ -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
View 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
View 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)),
}
}

View file

@ -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.") {
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 == "" {

View file

@ -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

Binary file not shown.