diff --git a/buildmodule.go b/buildmodule.go index b7a2b45..86ad5bf 100644 --- a/buildmodule.go +++ b/buildmodule.go @@ -18,8 +18,10 @@ package libpak import ( "fmt" + "net/url" "os" "reflect" + "runtime" "sort" "strconv" "strings" @@ -501,6 +503,15 @@ func (d *DependencyResolver) Resolve(id string, version string) (BuildModuleDepe return BuildModuleDependency{}, fmt.Errorf("unable to parse version %s\n%w", c.Version, err) } + // filter out deps that do not match the current running architecture + arch, err := archFromPURL(c.PURL) + if err != nil { + return BuildModuleDependency{}, fmt.Errorf("unable to compare arch\n%w", err) + } + if arch != archFromSystem() { + continue + } + if c.ID == id && vc.Check(v) && d.contains(c.Stacks, d.StackID) { candidates = append(candidates, c) } @@ -529,6 +540,33 @@ func (d *DependencyResolver) Resolve(id string, version string) (BuildModuleDepe return candidate, nil } +func archFromPURL(rawPURL string) (string, error) { + if len(strings.TrimSpace(rawPURL)) == 0 { + return "amd64", nil + } + + purl, err := url.Parse(rawPURL) + if err != nil { + return "", fmt.Errorf("unable to parse PURL\n%w", err) + } + + queryParams := purl.Query() + if arch, ok := queryParams["arch"]; ok { + return arch[0], nil + } + + return archFromSystem(), nil +} + +func archFromSystem() string { + archFromEnv, ok := os.LookupEnv("BP_ARCH") + if !ok { + archFromEnv = runtime.GOARCH + } + + return archFromEnv +} + func (DependencyResolver) contains(candidates []string, value string) bool { if len(candidates) == 0 { return true diff --git a/buildmodule_test.go b/buildmodule_test.go index 6c8aa22..786c466 100644 --- a/buildmodule_test.go +++ b/buildmodule_test.go @@ -283,6 +283,10 @@ func testBuildpack(t *testing.T, context spec.G, it spec.S) { resolver libpak.DependencyResolver ) + it.Before(func() { + t.Setenv("BP_ARCH", "amd64") // force for test consistency + }) + context("Resolve", func() { it("filters by id", func() { resolver.Dependencies = []libpak.BuildModuleDependency{ @@ -315,6 +319,69 @@ func testBuildpack(t *testing.T, context spec.G, it spec.S) { })) }) + it("filters by arch", func() { + resolver.Dependencies = []libpak.BuildModuleDependency{ + { + ID: "test-id-1", + Name: "test-name", + Version: "1.0", + URI: "test-uri-amd64", + SHA256: "test-sha256", + Stacks: []string{"test-stack-1", "test-stack-2"}, + PURL: "pkg:generic/bellsoft-jdk@8.0.382?arch=amd64", + }, + { + ID: "test-id-1", + Name: "test-name", + Version: "1.0", + URI: "test-uri-arm64", + SHA256: "test-sha256", + Stacks: []string{"test-stack-1", "test-stack-2"}, + PURL: "pkg:generic/bellsoft-jdk@8.0.382?arch=arm64", + }, + } + resolver.StackID = "test-stack-1" + + t.Setenv("BP_ARCH", "arm64") + + Expect(resolver.Resolve("test-id-1", "1.0")).To(Equal(libpak.BuildModuleDependency{ + ID: "test-id-1", + Name: "test-name", + Version: "1.0", + URI: "test-uri-arm64", + SHA256: "test-sha256", + Stacks: []string{"test-stack-1", "test-stack-2"}, + PURL: "pkg:generic/bellsoft-jdk@8.0.382?arch=arm64", + })) + }) + + it("filters by arch where arch should match any", func() { + resolver.Dependencies = []libpak.BuildModuleDependency{ + { + ID: "test-id-1", + Name: "test-name", + Version: "1.0", + URI: "test-uri", + SHA256: "test-sha256", + Stacks: []string{"test-stack-1", "test-stack-2"}, + PURL: "pkg:generic/spring-cloud-bindings@1.2.3", + }, + } + resolver.StackID = "test-stack-1" + + t.Setenv("BP_ARCH", "arm64") + + Expect(resolver.Resolve("test-id-1", "1.0")).To(Equal(libpak.BuildModuleDependency{ + ID: "test-id-1", + Name: "test-name", + Version: "1.0", + URI: "test-uri", + SHA256: "test-sha256", + Stacks: []string{"test-stack-1", "test-stack-2"}, + PURL: "pkg:generic/spring-cloud-bindings@1.2.3", + })) + }) + it("filters by version constraint", func() { resolver.Dependencies = []libpak.BuildModuleDependency{ { diff --git a/crush/crush.go b/crush/crush.go index 59677a5..245640f 100644 --- a/crush/crush.go +++ b/crush/crush.go @@ -103,6 +103,84 @@ func CreateTarGz(destination io.Writer, source string) error { return CreateTar(gz, source) } +// CreateJar heavily inspired by: https://gosamples.dev/zip-file/ +// Be aware that this function does not create a MANIFEST.MF file, not does it strictly enforce jar format +// in regard to elements that need to be STORE'd versus other that need to be DEFLATE'd; here everything is STORE'd +// Finally, source path must end with a trailing "/" +func CreateJar(source, target string) error { + // 1. Create a ZIP file and zip.Writer + f, err := os.Create(target) + if err != nil { + return err + } + defer f.Close() + + writer := zip.NewWriter(f) + defer writer.Close() + + // 2. Go through all the files of the source + return filepath.Walk(source, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + absolutePath := "" + + if info.Mode()&os.ModeSymlink == os.ModeSymlink { + if absolutePath, err = filepath.EvalSymlinks(path); err != nil { + return fmt.Errorf("unable to eval symlink %s\n%w", absolutePath, err) + } + if file, err := os.Open(absolutePath); err != nil { + return fmt.Errorf("unable to open %s\n%w", absolutePath, err) + } else { + if info, err = file.Stat(); err != nil { + return fmt.Errorf("unable to stat %s\n%w", absolutePath, err) + } + } + } + + // 3. Create a local file header + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + + // set compression + header.Method = zip.Store + // 4. Set relative path of a file as the header name + header.Name, err = filepath.Rel(source, path) + if err != nil { + return err + } + if info.IsDir() { + header.Name += "/" + } + + // 5. Create writer for the file header and save content of the file + headerWriter, err := writer.CreateHeader(header) + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + if absolutePath != "" { + path = absolutePath + } + + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + _, err = io.Copy(headerWriter, f) + writer.Flush() + return err + }) +} + // Extract decompresses and extract source files to a destination directory or path. For archives, an arbitrary number of top-level directory // components can be stripped from each path. func Extract(source io.Reader, destination string, stripComponents int) error { diff --git a/crush/crush_test.go b/crush/crush_test.go index 5c1f001..4c190bc 100644 --- a/crush/crush_test.go +++ b/crush/crush_test.go @@ -96,6 +96,32 @@ func testCrush(t *testing.T, context spec.G, it spec.S) { Expect(filepath.Join(testPath, "dirA", "fileC.txt")).To(BeARegularFile()) Expect(os.Readlink(filepath.Join(testPath, "dirA", "fileD.txt"))).To(Equal(filepath.Join(path, "dirA", "fileC.txt"))) }) + + it("writes a JAR", func() { + cwd, _ := os.Getwd() + Expect(os.MkdirAll(filepath.Join(path, "META-INF"), 0700)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(path, "META-INF", "MANIFEST.MF"), []byte(` + Spring-Boot-Version: 3.3.1 + Spring-Boot-Classes: BOOT-INF/classes + Spring-Boot-Lib: BOOT-INF/lib + `), 0600)).To(Succeed()) + Expect(os.MkdirAll(filepath.Join(path, "BOOT-INF"), 0700)).To(Succeed()) + Expect(os.MkdirAll(filepath.Join(path, "BOOT-INF", "classes"), 0700)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(path, "BOOT-INF", "classes", "OtherClass.class"), []byte(""), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(path, "BOOT-INF", "classes", "YetOther.class"), []byte(""), 0600)).To(Succeed()) + Expect(os.MkdirAll(filepath.Join(path, "BOOT-INF", "lib"), 0700)).To(Succeed()) + os.Symlink(filepath.Join(cwd, "testdata", "test-archive.jar"), filepath.Join(path, "BOOT-INF", "lib", "test-archive.jar")) + + Expect(crush.CreateJar(path+"/", out.Name()+".jar")).To(Succeed()) + + in, err := os.Open(out.Name() + ".jar") + Expect(err).NotTo(HaveOccurred()) + + Expect(crush.Extract(in, testPath, 0)).To(Succeed()) + Expect(filepath.Join(testPath, "BOOT-INF", "classes", "OtherClass.class")).To(BeARegularFile()) + Expect(filepath.Join(testPath, "META-INF", "MANIFEST.MF")).To(BeARegularFile()) + Expect(filepath.Join(testPath, "BOOT-INF", "lib", "test-archive.jar")).To(BeARegularFile()) + }) }) context("Extract", func() { diff --git a/crush/testdata/test-archive.jar b/crush/testdata/test-archive.jar new file mode 100644 index 0000000..f83509d Binary files /dev/null and b/crush/testdata/test-archive.jar differ diff --git a/dependency_cache.go b/dependency_cache.go index 4df16c7..70f5536 100644 --- a/dependency_cache.go +++ b/dependency_cache.go @@ -18,6 +18,7 @@ package libpak import ( "crypto/sha256" + "crypto/tls" "encoding/hex" "fmt" "io" @@ -46,8 +47,8 @@ type HTTPClientTimeouts struct { ExpectContinueTimeout time.Duration } -// DependencyCache allows a user to get an artifact either from a buildmodule's cache, a previous download, or to download -// directly. +// DependencyCache allows a user to get an artifact either from a buildpack's cache, a previous download, +// a mirror registry, or to download directly. type DependencyCache struct { // CachePath is the location where the buildmodule has cached its dependencies. CachePath string @@ -66,19 +67,31 @@ type DependencyCache struct { // httpClientTimeouts contains the timeout values used by HTTP client HTTPClientTimeouts HTTPClientTimeouts + + // Alternative sources used for downloading dependencies. + DependencyMirrors map[string]string } -// NewDependencyCache creates a new instance setting the default cache path (/dependencies) and user -// agent (/). +// NewDependencyCache creates a new instance setting the default cache path (/dependencies) and user +// agent (/). +// +// Mappings will be read from any libcnb.Binding in the context with type "dependency-mappings". +// +// In some environments, many dependencies might need to be downloaded from a (local) mirror registry or filesystem. +// Such alternative locations can be configured using bindings of type "dependency-mirror", avoiding too many "dependency-mapping" bindings. +// Environment variables named "BP_DEPENDENCY_MIRROR" (default) or "BP_DEPENDENCY_MIRROR_" (hostname-specific mirror) +// can also be used for the same purpose. func NewDependencyCache(buildModuleID string, buildModuleVersion string, buildModulePath string, platformBindings libcnb.Bindings, logger log.Logger) (DependencyCache, error) { cache := DependencyCache{ - CachePath: filepath.Join(buildModulePath, "dependencies"), - DownloadPath: os.TempDir(), - Logger: logger, - Mappings: map[string]string{}, - UserAgent: fmt.Sprintf("%s/%s", buildModuleID, buildModuleVersion), + CachePath: filepath.Join(buildModulePath, "dependencies"), + DownloadPath: os.TempDir(), + UserAgent: fmt.Sprintf("%s/%s", buildModuleID, buildModuleVersion), + Mappings: map[string]string{}, + DependencyMirrors: map[string]string{}, + Logger: logger, } - mappings, err := mappingsFromBindings(platformBindings) + + mappings, err := filterBindingsByType(platformBindings, "dependency-mapping") if err != nil { return DependencyCache{}, fmt.Errorf("unable to process dependency-mapping bindings\n%w", err) } @@ -90,6 +103,12 @@ func NewDependencyCache(buildModuleID string, buildModuleVersion string, buildMo } cache.HTTPClientTimeouts = *clientTimeouts + bindingMirrors, err := filterBindingsByType(platformBindings, "dependency-mirror") + if err != nil { + return DependencyCache{}, fmt.Errorf("unable to process dependency-mirror bindings\n%w", err) + } + cache.setDependencyMirrors(bindingMirrors) + return cache, nil } @@ -133,19 +152,63 @@ func customizeHTTPClientTimeouts() (*HTTPClientTimeouts, error) { }, nil } -func mappingsFromBindings(bindings libcnb.Bindings) (map[string]string, error) { - mappings := map[string]string{} +func (d *DependencyCache) setDependencyMirrors(bindingMirrors map[string]string) { + // Initialize with mirrors from bindings. + d.DependencyMirrors = bindingMirrors + // Add mirrors from env variables and override duplicate hostnames set in bindings. + envs := os.Environ() + for _, env := range envs { + envPair := strings.SplitN(env, "=", 2) + if len(envPair) != 2 { + continue + } + hostnameSuffix, isMirror := strings.CutPrefix(envPair[0], "BP_DEPENDENCY_MIRROR") + if isMirror { + hostnameEncoded, _ := strings.CutPrefix(hostnameSuffix, "_") + if strings.ToLower(hostnameEncoded) == "default" { + d.Logger.Bodyf("%s with illegal hostname 'default'. Please use BP_DEPENDENCY_MIRROR to set a default.", + color.YellowString("Ignored dependency mirror")) + continue + } + d.DependencyMirrors[decodeHostnameEnv(hostnameEncoded, d)] = envPair[1] + } + } +} + +// Takes an encoded hostname (from env key) and returns the decoded version in lower case. +// Replaces double underscores (__) with one dash (-) and single underscores (_) with one period (.). +func decodeHostnameEnv(encodedHostname string, d *DependencyCache) string { + if strings.ContainsAny(encodedHostname, "-.") || encodedHostname != strings.ToUpper(encodedHostname) { + d.Logger.Bodyf("%s These will be allowed but for best results across different shells, you should replace . characters with _ characters "+ + "and - characters with __, and use all upper case letters. The buildpack will convert these back before using the mirror.", + color.YellowString("You have invalid characters in your mirror host environment variable.")) + } + var decodedHostname string + if encodedHostname == "" { + decodedHostname = "default" + } else { + decodedHostname = strings.ReplaceAll(strings.ReplaceAll(encodedHostname, "__", "-"), "_", ".") + } + return strings.ToLower(decodedHostname) +} + +// Returns a key/value map with all entries for a given binding type. +// An error is returned if multiple entries are found using the same key (e.g. duplicate digests in dependency mappings). +func filterBindingsByType(bindings libcnb.Bindings, bindingType string) (map[string]string, error) { + filteredBindings := map[string]string{} + for _, binding := range bindings { - if strings.ToLower(binding.Type) == "dependency-mapping" { - for digest, uri := range binding.Secret { - if _, ok := mappings[digest]; ok { - return nil, fmt.Errorf("multiple mappings for digest %q", digest) + if strings.ToLower(binding.Type) == bindingType { + for key, value := range binding.Secret { + if _, ok := filteredBindings[strings.ToLower(key)]; ok { + return nil, fmt.Errorf("multiple %s bindings found with duplicate keys %s", binding.Type, key) } - mappings[digest] = uri + filteredBindings[strings.ToLower(key)] = value } } } - return mappings, nil + + return filteredBindings, nil } // RequestModifierFunc is a callback that enables modification of a download request before it is sent. It is often @@ -162,26 +225,48 @@ type RequestModifierFunc func(request *http.Request) (*http.Request, error) // download, skipping all the caches. func (d *DependencyCache) Artifact(dependency BuildModuleDependency, mods ...RequestModifierFunc) (*os.File, error) { var ( - artifact string - file string - uri = dependency.URI + artifact string + file string + isBinding bool + uri = dependency.URI + urlP *url.URL ) for d, u := range d.Mappings { if d == dependency.SHA256 { + isBinding = true uri = u break } } + urlP, err := url.Parse(uri) + if err != nil { + d.Logger.Debugf("URI format invalid\n%w", err) + return nil, fmt.Errorf("unable to parse URI. see DEBUG log level") + } + + mirror := d.DependencyMirrors["default"] + mirrorHostSpecific := d.DependencyMirrors[urlP.Hostname()] + if mirrorHostSpecific != "" { + mirror = mirrorHostSpecific + } + + if isBinding && mirror != "" { + d.Logger.Bodyf("Both dependency mirror and bindings are present. %s Please remove dependency map bindings if you wish to use the mirror.", + color.YellowString("Mirror is being ignored.")) + } else { + d.setDependencyMirror(urlP, mirror) + } + if dependency.SHA256 == "" { d.Logger.Headerf("%s Dependency has no SHA256. Skipping cache.", color.New(color.FgYellow, color.Bold).Sprint("Warning:")) - d.Logger.Bodyf("%s from %s", color.YellowString("Downloading"), uri) + d.Logger.Bodyf("%s from %s", color.YellowString("Downloading"), urlP.Redacted()) artifact = filepath.Join(d.DownloadPath, filepath.Base(uri)) - if err := d.download(uri, artifact, mods...); err != nil { - return nil, fmt.Errorf("unable to download %s\n%w", uri, err) + if err := d.download(urlP, artifact, mods...); err != nil { + return nil, fmt.Errorf("unable to download %s\n%w", urlP.Redacted(), err) } return os.Open(artifact) @@ -189,14 +274,13 @@ func (d *DependencyCache) Artifact(dependency BuildModuleDependency, mods ...Req file = filepath.Join(d.CachePath, fmt.Sprintf("%s.toml", dependency.SHA256)) exists, err := sherpa.Exists(file) - if err != nil { return nil, fmt.Errorf("unable to read %s\n%w", file, err) } if exists { d.Logger.Bodyf("%s cached download from buildpack", color.GreenString("Reusing")) - return os.Open(filepath.Join(d.CachePath, dependency.SHA256, filepath.Base(uri))) + return os.Open(filepath.Join(d.CachePath, dependency.SHA256, filepath.Base(urlP.Path))) } file = filepath.Join(d.DownloadPath, fmt.Sprintf("%s.toml", dependency.SHA256)) @@ -208,13 +292,13 @@ func (d *DependencyCache) Artifact(dependency BuildModuleDependency, mods ...Req if exists { d.Logger.Bodyf("%s previously cached download", color.GreenString("Reusing")) - return os.Open(filepath.Join(d.DownloadPath, dependency.SHA256, filepath.Base(uri))) + return os.Open(filepath.Join(d.DownloadPath, dependency.SHA256, filepath.Base(urlP.Path))) } - d.Logger.Bodyf("%s from %s", color.YellowString("Downloading"), uri) + d.Logger.Bodyf("%s from %s", color.YellowString("Downloading"), urlP.Redacted()) artifact = filepath.Join(d.DownloadPath, dependency.SHA256, filepath.Base(uri)) - if err := d.download(uri, artifact, mods...); err != nil { - return nil, fmt.Errorf("unable to download %s\n%w", uri, err) + if err := d.download(urlP, artifact, mods...); err != nil { + return nil, fmt.Errorf("unable to download %s\n%w", urlP.Redacted(), err) } d.Logger.Body("Verifying checksum") @@ -240,17 +324,12 @@ func (d *DependencyCache) Artifact(dependency BuildModuleDependency, mods ...Req return os.Open(artifact) } -func (d DependencyCache) download(uri string, destination string, mods ...RequestModifierFunc) error { - url, err := url.Parse(uri) - if err != nil { - return fmt.Errorf("unable to parse URI %s\n%w", uri, err) - } - +func (d DependencyCache) download(url *url.URL, destination string, mods ...RequestModifierFunc) error { if url.Scheme == "file" { return d.downloadFile(url.Path, destination) } - return d.downloadHTTP(uri, destination, mods...) + return d.downloadHTTP(url, destination, mods...) } func (d DependencyCache) downloadFile(source string, destination string) error { @@ -277,10 +356,33 @@ func (d DependencyCache) downloadFile(source string, destination string) error { return nil } -func (d DependencyCache) downloadHTTP(uri string, destination string, mods ...RequestModifierFunc) error { - req, err := http.NewRequest("GET", uri, nil) +func (d DependencyCache) downloadHTTP(url *url.URL, destination string, mods ...RequestModifierFunc) error { + var httpClient *http.Client + if (strings.EqualFold(url.Hostname(), "localhost")) || (strings.EqualFold(url.Hostname(), "127.0.0.1")) { + httpClient = &http.Client{ + Transport: &http.Transport{ + // #nosec G402 - we believe this to be safe as it's only for localhost/127.0.0.1 + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + } else { + httpClient = &http.Client{ + Transport: &http.Transport{ + Dial: (&net.Dialer{ + Timeout: d.HTTPClientTimeouts.DialerTimeout, + KeepAlive: d.HTTPClientTimeouts.DialerKeepAlive, + }).Dial, + TLSHandshakeTimeout: d.HTTPClientTimeouts.TLSHandshakeTimeout, + ResponseHeaderTimeout: d.HTTPClientTimeouts.ResponseHeaderTimeout, + ExpectContinueTimeout: d.HTTPClientTimeouts.ExpectContinueTimeout, + Proxy: http.ProxyFromEnvironment, + }, + } + } + + req, err := http.NewRequest("GET", url.String(), nil) if err != nil { - return fmt.Errorf("unable to create new GET request for %s\n%w", uri, err) + return fmt.Errorf("unable to create new GET request for %s\n%w", url.Redacted(), err) } if d.UserAgent != "" { @@ -290,30 +392,18 @@ func (d DependencyCache) downloadHTTP(uri string, destination string, mods ...Re for _, m := range mods { req, err = m(req) if err != nil { - return fmt.Errorf("unable to modify request\n%w", err) + return fmt.Errorf("unable to request %s\n%w", url.Redacted(), err) } } - client := http.Client{ - Transport: &http.Transport{ - Dial: (&net.Dialer{ - Timeout: d.HTTPClientTimeouts.DialerTimeout, - KeepAlive: d.HTTPClientTimeouts.DialerKeepAlive, - }).Dial, - TLSHandshakeTimeout: d.HTTPClientTimeouts.TLSHandshakeTimeout, - ResponseHeaderTimeout: d.HTTPClientTimeouts.ResponseHeaderTimeout, - ExpectContinueTimeout: d.HTTPClientTimeouts.ExpectContinueTimeout, - Proxy: http.ProxyFromEnvironment, - }, - } - resp, err := client.Do(req) + resp, err := httpClient.Do(req) if err != nil { - return fmt.Errorf("unable to request %s\n%w", uri, err) + return fmt.Errorf("unable to request %s\n%w", url.Redacted(), err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode > 299 { - return fmt.Errorf("could not download %s: %d", uri, resp.StatusCode) + return fmt.Errorf("could not download %s: %d", url.Redacted(), resp.StatusCode) } if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { @@ -327,7 +417,7 @@ func (d DependencyCache) downloadHTTP(uri string, destination string, mods ...Re defer out.Close() if _, err := io.Copy(out, resp.Body); err != nil { - return fmt.Errorf("unable to copy from %s to %s\n%w", uri, destination, err) + return fmt.Errorf("unable to copy from %s to %s\n%w", url.Redacted(), destination, err) } return nil @@ -354,3 +444,55 @@ func (DependencyCache) verify(path string, expected string) error { return nil } + +func (d DependencyCache) setDependencyMirror(urlD *url.URL, mirror string) { + if mirror != "" { + d.Logger.Bodyf("%s Download URIs will be overridden.", color.GreenString("Dependency mirror found.")) + mirrorArgs := parseMirror(mirror) + urlOverride, err := url.ParseRequestURI(mirrorArgs["mirror"]) + + if strings.ToLower(urlOverride.Scheme) == "https" || strings.ToLower(urlOverride.Scheme) == "file" { + urlD.Scheme = urlOverride.Scheme + urlD.User = urlOverride.User + urlD.Path = strings.Replace(urlOverride.Path, "{originalHost}", urlD.Hostname(), 1) + strings.Replace(urlD.Path, mirrorArgs["skip-path"], "", 1) + urlD.Host = urlOverride.Host + } else { + d.Logger.Debugf("Dependency mirror URI is invalid: %s\n%w", mirror, err) + d.Logger.Bodyf("%s is ignored. Have you used one of the supported schemes https:// or file://?", color.YellowString("Invalid dependency mirror")) + } + } +} + +// Parses a raw mirror string into a map of arguments. +func parseMirror(mirror string) map[string]string { + mirrorArgs := map[string]string{ + "mirror": mirror, + "skip-path": "", + } + + // Split mirror string at commas and extract specified arguments. + for _, arg := range strings.Split(mirror, ",") { + argPair := strings.SplitN(arg, "=", 2) + // If a URI is provided without the key 'mirror=', still treat it as the 'mirror' argument. + // This addresses backwards compatibility and user experience as most mirrors won't need any additional arguments. + if len(argPair) == 1 && (strings.HasPrefix(argPair[0], "https") || strings.HasPrefix(argPair[0], "file")) { + mirrorArgs["mirror"] = argPair[0] + } + // Add all provided arguments to key/value map. + if len(argPair) == 2 { + mirrorArgs[argPair[0]] = argPair[1] + } + } + + // Unescape mirror arguments to support URL-encoded strings. + tmp, err := url.PathUnescape(mirrorArgs["mirror"]) + if err == nil { + mirrorArgs["mirror"] = tmp + } + tmp, err = url.PathUnescape(mirrorArgs["skip-path"]) + if err == nil { + mirrorArgs["skip-path"] = tmp + } + + return mirrorArgs +} diff --git a/dependency_cache_test.go b/dependency_cache_test.go index d8785bc..8039418 100644 --- a/dependency_cache_test.go +++ b/dependency_cache_test.go @@ -17,9 +17,11 @@ package libpak_test import ( + "bytes" "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "testing" @@ -148,6 +150,45 @@ func testDependencyCache(t *testing.T, context spec.G, it spec.S) { }) }) }) + + context("dependency mirror from environment variable", func() { + it.Before(func() { + t.Setenv("BP_DEPENDENCY_MIRROR", "https://env-var-mirror.acme.com") + t.Setenv("BP_DEPENDENCY_MIRROR_EXAMP__LE_COM", "https://examp-le.com") + }) + + it("uses BP_DEPENDENCY_MIRROR environment variable", func() { + dependencyCache, err := libpak.NewDependencyCache(ctx.Buildpack.Info.ID, ctx.Buildpack.Info.Version, ctx.Buildpack.Path, ctx.Platform.Bindings, log.NewDiscardLogger()) + Expect(err).NotTo(HaveOccurred()) + Expect(dependencyCache.DependencyMirrors["default"]).To(Equal("https://env-var-mirror.acme.com")) + Expect(dependencyCache.DependencyMirrors["examp-le.com"]).To(Equal("https://examp-le.com")) + }) + }) + + context("dependency mirror from binding and environment variable", func() { + it.Before(func() { + t.Setenv("BP_DEPENDENCY_MIRROR_EXAMP__LE_COM", "https://examp-le.com") + ctx.Platform.Bindings = append(ctx.Platform.Bindings, libcnb.Binding{ + Type: "dependency-mirror", + Secret: map[string]string{ + "default": "https://bindings-mirror.acme.com", + "examp-le.com": "https://invalid.com", + }, + }) + }) + + it("uses dependency-mirror binding", func() { + dependencyCache, err := libpak.NewDependencyCache(ctx.Buildpack.Info.ID, ctx.Buildpack.Info.Version, ctx.Buildpack.Path, ctx.Platform.Bindings, log.NewDiscardLogger()) + Expect(err).NotTo(HaveOccurred()) + Expect(dependencyCache.DependencyMirrors["default"]).To(Equal("https://bindings-mirror.acme.com")) + }) + + it("environment variable overrides binding", func() { + dependencyCache, err := libpak.NewDependencyCache(ctx.Buildpack.Info.ID, ctx.Buildpack.Info.Version, ctx.Buildpack.Path, ctx.Platform.Bindings, log.NewDiscardLogger()) + Expect(err).NotTo(HaveOccurred()) + Expect(dependencyCache.DependencyMirrors["examp-le.com"]).To(Equal("https://examp-le.com")) + }) + }) }) context("artifacts", func() { @@ -319,6 +360,184 @@ func testDependencyCache(t *testing.T, context spec.G, it spec.S) { }) }) + context("dependency mirror is used https", func() { + var mirrorServer *ghttp.Server + + it.Before(func() { + mirrorServer = ghttp.NewTLSServer() + dependencyCache.DependencyMirrors = map[string]string{} + }) + + it.After(func() { + mirrorServer.Close() + }) + + it("downloads from https mirror", func() { + url, err := url.Parse(mirrorServer.URL()) + Expect(err).NotTo(HaveOccurred()) + mirrorServer.AppendHandlers(ghttp.CombineHandlers( + ghttp.VerifyBasicAuth("username", "password"), + ghttp.VerifyRequest(http.MethodGet, "/foo/bar/test-path", ""), + ghttp.RespondWith(http.StatusOK, "test-fixture"), + )) + + dependencyCache.DependencyMirrors["default"] = url.Scheme + "://" + "username:password@" + url.Host + "/foo/bar" + a, err := dependencyCache.Artifact(dependency) + Expect(err).NotTo(HaveOccurred()) + + Expect(io.ReadAll(a)).To(Equal([]byte("test-fixture"))) + }) + + it("downloads from https mirror preserving hostname", func() { + url, err := url.Parse(mirrorServer.URL()) + Expect(err).NotTo(HaveOccurred()) + mirrorServer.AppendHandlers(ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, "/"+url.Hostname()+"/test-path", ""), + ghttp.RespondWith(http.StatusOK, "test-fixture"), + )) + + dependencyCache.DependencyMirrors["default"] = url.Scheme + "://" + url.Host + "/{originalHost}" + a, err := dependencyCache.Artifact(dependency) + Expect(err).NotTo(HaveOccurred()) + + Expect(io.ReadAll(a)).To(Equal([]byte("test-fixture"))) + }) + + it("downloads from https mirror host specific", func() { + url, err := url.Parse(mirrorServer.URL()) + Expect(err).NotTo(HaveOccurred()) + mirrorServer.AppendHandlers(ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, "/host-specific/test-path", ""), + ghttp.RespondWith(http.StatusOK, "test-fixture"), + )) + + dependencyCache.DependencyMirrors["127.0.0.1"] = url.Scheme + "://" + url.Host + "/host-specific" + a, err := dependencyCache.Artifact(dependency) + Expect(err).NotTo(HaveOccurred()) + + Expect(io.ReadAll(a)).To(Equal([]byte("test-fixture"))) + }) + }) + + context("dependency mirror is used file", func() { + var ( + mirrorPath string + mirrorPathPreservedHost string + ) + + it.Before(func() { + var err error + mirrorPath, err = os.MkdirTemp("", "mirror-path") + Expect(err).NotTo(HaveOccurred()) + originalURL, err := url.Parse(dependency.URI) + Expect(err).NotTo(HaveOccurred()) + mirrorPathPreservedHost = filepath.Join(mirrorPath, originalURL.Hostname(), "prefix") + Expect(os.MkdirAll(mirrorPathPreservedHost, os.ModePerm)).NotTo(HaveOccurred()) + dependencyCache.DependencyMirrors = map[string]string{} + }) + + it.After(func() { + Expect(os.RemoveAll(mirrorPath)).To(Succeed()) + }) + + it("downloads from file mirror", func() { + mirrorFile := filepath.Join(mirrorPath, "test-path") + Expect(os.WriteFile(mirrorFile, []byte("test-fixture"), 0600)).ToNot(HaveOccurred()) + + dependencyCache.DependencyMirrors["default"] = "file://" + mirrorPath + a, err := dependencyCache.Artifact(dependency) + Expect(err).NotTo(HaveOccurred()) + + Expect(io.ReadAll(a)).To(Equal([]byte("test-fixture"))) + }) + + it("downloads from file mirror preserving hostname", func() { + mirrorFilePreservedHost := filepath.Join(mirrorPathPreservedHost, "test-path") + Expect(os.WriteFile(mirrorFilePreservedHost, []byte("test-fixture"), 0600)).ToNot(HaveOccurred()) + + dependencyCache.DependencyMirrors["default"] = "file://" + mirrorPath + "/{originalHost}" + "/prefix" + a, err := dependencyCache.Artifact(dependency) + Expect(err).NotTo(HaveOccurred()) + + Expect(io.ReadAll(a)).To(Equal([]byte("test-fixture"))) + }) + }) + + context("dependency mirror with additional arguments", func() { + var mirrorServer *ghttp.Server + + it.Before(func() { + mirrorServer = ghttp.NewTLSServer() + dependencyCache.DependencyMirrors = map[string]string{} + }) + + it.After(func() { + mirrorServer.Close() + }) + + it("downloads from escaped mirror", func() { + mirrorURL, err := url.Parse(mirrorServer.URL()) + Expect(err).NotTo(HaveOccurred()) + mirrorServer.AppendHandlers(ghttp.CombineHandlers( + ghttp.VerifyBasicAuth("user", "pa$$word,"), + ghttp.VerifyRequest(http.MethodGet, "/escaped/test-path", ""), + ghttp.RespondWith(http.StatusOK, "test-fixture"), + )) + + dependencyCache.DependencyMirrors["127.0.0.1"] = mirrorURL.Scheme + "://user%3Apa%24%24word%2C%40" + mirrorURL.Host + "%2Fescaped" + a, err := dependencyCache.Artifact(dependency) + Expect(err).NotTo(HaveOccurred()) + + Expect(io.ReadAll(a)).To(Equal([]byte("test-fixture"))) + }) + + it("respects skip-path argument without mirror= key", func() { + mirrorURL, err := url.Parse(mirrorServer.URL()) + Expect(err).NotTo(HaveOccurred()) + mirrorServer.AppendHandlers(ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, "/test-skip", ""), + ghttp.RespondWith(http.StatusOK, "test-fixture"), + )) + + dependencyCache.DependencyMirrors["127.0.0.1"] = mirrorURL.Scheme + "://" + mirrorURL.Host + "/test-skip,skip-path=/test-path" + a, err := dependencyCache.Artifact(dependency) + Expect(err).NotTo(HaveOccurred()) + + Expect(io.ReadAll(a)).To(Equal([]byte("test-fixture"))) + }) + + it("respects skip-path argument with mirror= key", func() { + mirrorURL, err := url.Parse(mirrorServer.URL()) + Expect(err).NotTo(HaveOccurred()) + mirrorServer.AppendHandlers(ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, "/test-skip", ""), + ghttp.RespondWith(http.StatusOK, "test-fixture"), + )) + + dependencyCache.DependencyMirrors["127.0.0.1"] = "mirror=" + mirrorURL.Scheme + "://" + mirrorURL.Host + "/test-skip,skip-path=/test-path" + a, err := dependencyCache.Artifact(dependency) + Expect(err).NotTo(HaveOccurred()) + + Expect(io.ReadAll(a)).To(Equal([]byte("test-fixture"))) + }) + + it("respects skip-path argument when URL encoded", func() { + mirrorURL, err := url.Parse(mirrorServer.URL()) + Expect(err).NotTo(HaveOccurred()) + mirrorServer.AppendHandlers(ghttp.CombineHandlers( + ghttp.VerifyRequest(http.MethodGet, "/test-skip", ""), + ghttp.RespondWith(http.StatusOK, "test-fixture"), + )) + + dependencyCache.DependencyMirrors["127.0.0.1"] = mirrorURL.Scheme + "://" + mirrorURL.Host + "/test-skip,skip-path=/test%2Cpath" + dependency.URI = fmt.Sprintf("%s/test,path", server.URL()) + a, err := dependencyCache.Artifact(dependency) + Expect(err).NotTo(HaveOccurred()) + + Expect(io.ReadAll(a)).To(Equal([]byte("test-fixture"))) + }) + }) + it("fails with invalid SHA256", func() { server.AppendHandlers(ghttp.RespondWith(http.StatusOK, "invalid-fixture")) @@ -368,5 +587,58 @@ func testDependencyCache(t *testing.T, context spec.G, it spec.S) { Expect(io.ReadAll(a)).To(Equal([]byte("test-fixture"))) }) + + context("hides credentials from logs", func() { + it("skips cache with empty SHA256", func() { + copyFile(filepath.Join("testdata", "test-file"), filepath.Join(cachePath, dependency.SHA256, "test-path")) + writeTOML(filepath.Join(cachePath, fmt.Sprintf("%s.toml", dependency.SHA256)), dependency) + copyFile(filepath.Join("testdata", "test-file"), filepath.Join(downloadPath, dependency.SHA256, "test-path")) + writeTOML(filepath.Join(downloadPath, fmt.Sprintf("%s.toml", dependency.SHA256)), dependency) + + dependency.SHA256 = "" + server.AppendHandlers(ghttp.RespondWith(http.StatusOK, "alternate-fixture")) + + var logBuffer bytes.Buffer + dependencyCache.Logger = log.NewPaketoLogger(&logBuffer) + + a, err := dependencyCache.Artifact(dependency) + Expect(err).NotTo(HaveOccurred()) + + Expect(io.ReadAll(a)).To(Equal([]byte("alternate-fixture"))) + Expect(logBuffer.String()).To(ContainSubstring("Dependency has no SHA256")) + Expect(logBuffer.String()).NotTo(ContainSubstring("password")) + }) + + it("hide uri credentials from log", func() { + server.AppendHandlers(ghttp.CombineHandlers( + ghttp.RespondWith(http.StatusOK, "test-fixture"), + )) + + url, err := url.Parse(dependency.URI) + Expect(err).NotTo(HaveOccurred()) + credentials := "username:password" + uriWithBasicCreds := url.Scheme + "://" + credentials + "@" + url.Hostname() + ":" + url.Port() + url.Path + dependency.URI = uriWithBasicCreds + + var logBuffer bytes.Buffer + dependencyCache.Logger = log.NewPaketoLogger(&logBuffer) + + // Make sure the password is not part of the log output. + a, errA := dependencyCache.Artifact(dependency) + Expect(errA).NotTo(HaveOccurred()) + Expect(a).NotTo(BeNil()) + Expect(logBuffer.String()).To(ContainSubstring("Verifying checksum")) + Expect(logBuffer.String()).NotTo(ContainSubstring("password")) + logBuffer.Reset() + + // Make sure the password is not part of the log output when an error occurs. + dependency.SHA256 = "576dd8416de5619ea001d9662291d62444d1292a38e96956bc4651c01f14bca1" + dependency.URI = "://username:password@acme.com" + b, errB := dependencyCache.Artifact(dependency) + Expect(errB).To(HaveOccurred()) + Expect(b).To(BeNil()) + Expect(logBuffer.String()).NotTo(ContainSubstring("password")) + }) + }) }) } diff --git a/layer.go b/layer.go index 8557848..09e3f0b 100644 --- a/layer.go +++ b/layer.go @@ -22,6 +22,7 @@ import ( "os" "path/filepath" "reflect" + "time" "github.com/BurntSushi/toml" "github.com/heroku/color" @@ -157,7 +158,73 @@ func (l *LayerContributor) checkIfMetadataMatches(layer libcnb.Layer) (map[strin l.Logger.Debugf("Expected metadata: %+v", expected) l.Logger.Debugf("Actual metadata: %+v", layer.Metadata) - return expected, reflect.DeepEqual(expected, layer.Metadata), nil + match, err := l.Equals(expected, layer.Metadata) + if err != nil { + return map[string]interface{}{}, false, fmt.Errorf("unable to compare metadata\n%w", err) + } + return expected, match, nil +} + +func (l *LayerContributor) Equals(expectedM map[string]interface{}, layerM map[string]interface{}) (bool, error) { + // TODO Do we want the Equals method to modify the underlying maps? Else we need to make a copy here. + + if err := l.normalizeDependencyDeprecationDate(expectedM); err != nil { + return false, fmt.Errorf("%w (expected layer)", err) + } + + if err := l.normalizeDependencyDeprecationDate(layerM); err != nil { + return false, fmt.Errorf("%w (actual layer)", err) + } + + return reflect.DeepEqual(expectedM, layerM), nil +} + +// normalizeDependencyDeprecationDate makes sure the dependency deprecation date is represented as a time.Time object +// in the map whenever it exists. +func (l *LayerContributor) normalizeDependencyDeprecationDate(input map[string]interface{}) error { + if dep, ok := input["dependency"].(map[string]interface{}); ok { + for k, v := range dep { + if k == "deprecation_date" { + if err := l.replaceDeprecationDate(dep, v); err != nil { + return err + } + break + } + } + } else if deprDate, ok := input["deprecation_date"]; ok { + if err := l.replaceDeprecationDate(input, deprDate); err != nil { + return err + } + } + return nil +} + +func (l *LayerContributor) replaceDeprecationDate(metadata map[string]interface{}, value interface{}) error { + deprecationDate, err := l.parseDeprecationDate(value) + if err != nil { + return err + } + metadata["deprecation_date"] = deprecationDate + return nil +} + +// parseDeprecationDate accepts both string and time.Time as input, and returns +// a truncated time.Time value. +func (l *LayerContributor) parseDeprecationDate(v interface{}) (deprecationDate time.Time, err error) { + switch vDate := v.(type) { + case time.Time: + deprecationDate = vDate + case string: + deprecationDate, err = time.Parse(time.RFC3339, vDate) + if err != nil { + return time.Time{}, fmt.Errorf("unable to parse deprecation_date %s", vDate) + } + default: + return time.Time{}, fmt.Errorf("unexpected type %T for deprecation_date %v", v, v) + } + + deprecationDate = deprecationDate.Truncate(time.Second).In(time.UTC) + return } func (l *LayerContributor) checkIfLayerRestored(layer libcnb.Layer) (bool, error) { @@ -240,7 +307,8 @@ func (d *DependencyLayerContributor) Contribute(layer *libcnb.Layer, f Dependenc return lc.Contribute(layer, func(_ *libcnb.Layer) error { artifact, err := d.DependencyCache.Artifact(d.Dependency, d.RequestModifierFuncs...) if err != nil { - return fmt.Errorf("unable to get dependency %s\n%w", d.Dependency.ID, err) + d.Logger.Debugf("fetching dependency %s failed\n%w", d.Dependency.Name, err) + return fmt.Errorf("unable to get dependency %s. see DEBUG log level", d.Dependency.Name) } defer artifact.Close() diff --git a/layer_test.go b/layer_test.go index b46458d..5389cb0 100644 --- a/layer_test.go +++ b/layer_test.go @@ -408,6 +408,239 @@ func testLayer(t *testing.T, context spec.G, it spec.S) { Expect(called).To(BeFalse()) }) + it("does not call function with non-matching deprecation_date format", func() { + dependency = libpak.BuildModuleDependency{ + ID: "test-id", + Name: "test-name", + Version: "1.1.1", + URI: fmt.Sprintf("%s/test-path", server.URL()), + SHA256: "576dd8416de5619ea001d9662291d62444d1292a38e96956bc4651c01f14bca1", + Stacks: []string{"test-stack"}, + Licenses: []libpak.BuildModuleDependencyLicense{ + { + Type: "test-type", + URI: "test-uri", + }, + }, + CPEs: []string{"cpe:2.3:a:some:jre:11.0.2:*:*:*:*:*:*:*"}, + PURL: "pkg:generic/some-java11@11.0.2?arch=amd64", + DeprecationDate: dependency.DeprecationDate, // parsed as '2021-04-01 00:00:00 +0000 UTC' + } + dlc.ExpectedMetadata = map[string]interface{}{"dependency": dependency} + + layer.Metadata = map[string]interface{}{"dependency": map[string]interface{}{ + "id": dependency.ID, + "name": dependency.Name, + "version": dependency.Version, + "uri": dependency.URI, + "sha256": dependency.SHA256, + "stacks": []interface{}{dependency.Stacks[0]}, + "licenses": []map[string]interface{}{ + { + "type": dependency.Licenses[0].Type, + "uri": dependency.Licenses[0].URI, + }, + }, + "cpes": []interface{}{"cpe:2.3:a:some:jre:11.0.2:*:*:*:*:*:*:*"}, + "purl": "pkg:generic/some-java11@11.0.2?arch=amd64", + "deprecation_date": "2021-04-01T00:00:00Z", // does not match without truncation + }} + + var called bool + + err := dlc.Contribute(layer, func(layer *libcnb.Layer, artifact *os.File) error { + defer artifact.Close() + + called = true + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(called).To(BeFalse()) + }) + + it("gracefully handles a deprecationDate in time.Time format in actual layer metadata", func() { + // reusing It: does not call function with non-matching deprecation_date format + // but this time with a deprecationDate formatted as time.Time in the actual layer metadata + actualDeprecationDate, _ := time.Parse(time.RFC3339, "2021-04-01T00:00:00Z") + + dependency = libpak.BuildModuleDependency{ + ID: "test-id", + Name: "test-name", + Version: "1.1.1", + URI: fmt.Sprintf("%s/test-path", server.URL()), + SHA256: "576dd8416de5619ea001d9662291d62444d1292a38e96956bc4651c01f14bca1", + Stacks: []string{"test-stack"}, + Licenses: []libpak.BuildModuleDependencyLicense{ + { + Type: "test-type", + URI: "test-uri", + }, + }, + CPEs: []string{"cpe:2.3:a:some:jre:11.0.2:*:*:*:*:*:*:*"}, + PURL: "pkg:generic/some-java11@11.0.2?arch=amd64", + DeprecationDate: dependency.DeprecationDate, // parsed as '2021-04-01 00:00:00 +0000 UTC' + } + dlc.ExpectedMetadata = map[string]interface{}{"dependency": dependency} + + layer.Metadata = map[string]interface{}{"dependency": map[string]interface{}{ + "id": dependency.ID, + "name": dependency.Name, + "version": dependency.Version, + "uri": dependency.URI, + "sha256": dependency.SHA256, + "stacks": []interface{}{dependency.Stacks[0]}, + "licenses": []map[string]interface{}{ + { + "type": dependency.Licenses[0].Type, + "uri": dependency.Licenses[0].URI, + }, + }, + "cpes": []interface{}{"cpe:2.3:a:some:jre:11.0.2:*:*:*:*:*:*:*"}, + "purl": "pkg:generic/some-java11@11.0.2?arch=amd64", + "deprecation_date": actualDeprecationDate, // does not match without truncation + }} + + var called bool + + err := dlc.Contribute(layer, func(layer *libcnb.Layer, artifact *os.File) error { + defer artifact.Close() + + called = true + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(called).To(BeFalse()) + }) + + it("does not panic on unsupported deprecationDate format in layer metadata", func() { + // Unexpected type (not string or time.Time) + actualDeprecationDate := 1234 + + dependency = libpak.BuildModuleDependency{ + ID: "test-id", + DeprecationDate: dependency.DeprecationDate, // parsed as '2021-04-01 00:00:00 +0000 UTC' + } + dlc.ExpectedMetadata = map[string]interface{}{"dependency": dependency} + + layer.Metadata = map[string]interface{}{"dependency": map[string]interface{}{ + "id": dependency.ID, + "deprecation_date": actualDeprecationDate, // does not match without truncation + }} + + var called bool + + err := dlc.Contribute(layer, func(layer *libcnb.Layer, artifact *os.File) error { + defer artifact.Close() + + called = true + return nil + }) + Expect(err).To(MatchError(ContainSubstring("unexpected type int for deprecation_date"))) + Expect(called).To(BeFalse()) + }) + + it("does not contribute when deprecation_date is found on metadata map root", func() { + dependency = libpak.BuildModuleDependency{ + ID: "test-id", + Name: "test-name", + Version: "1.1.1", + URI: fmt.Sprintf("%s/test-path", server.URL()), + SHA256: "576dd8416de5619ea001d9662291d62444d1292a38e96956bc4651c01f14bca1", + Stacks: []string{"test-stack"}, + Licenses: []libpak.BuildModuleDependencyLicense{ + { + Type: "test-type", + URI: "test-uri", + }, + }, + CPEs: []string{"cpe:2.3:a:some:jre:11.0.2:*:*:*:*:*:*:*"}, + PURL: "pkg:generic/some-java11@11.0.2?arch=amd64", + } + dlc.ExpectedMetadata = dependency + + layer.Metadata = map[string]interface{}{ + "id": dependency.ID, + "name": dependency.Name, + "version": dependency.Version, + "uri": dependency.URI, + "sha256": dependency.SHA256, + "stacks": []interface{}{dependency.Stacks[0]}, + "licenses": []map[string]interface{}{ + { + "type": dependency.Licenses[0].Type, + "uri": dependency.Licenses[0].URI, + }, + }, + "cpes": []interface{}{"cpe:2.3:a:some:jre:11.0.2:*:*:*:*:*:*:*"}, + "purl": "pkg:generic/some-java11@11.0.2?arch=amd64", + "deprecation_date": "0001-01-01T00:00:00Z", + } + + var called bool + + err := dlc.Contribute(layer, func(layer *libcnb.Layer, artifact *os.File) error { + defer artifact.Close() + + called = true + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(called).To(BeFalse()) + }) + + it("does not call function with missing deprecation_date", func() { + dependency = libpak.BuildModuleDependency{ + ID: "test-id", + Name: "test-name", + Version: "1.1.1", + URI: fmt.Sprintf("%s/test-path", server.URL()), + SHA256: "576dd8416de5619ea001d9662291d62444d1292a38e96956bc4651c01f14bca1", + Stacks: []string{"test-stack"}, + Licenses: []libpak.BuildModuleDependencyLicense{ + { + Type: "test-type", + URI: "test-uri", + }, + }, + CPEs: []string{"cpe:2.3:a:some:jre:11.0.2:*:*:*:*:*:*:*"}, + PURL: "pkg:generic/some-java11@11.0.2?arch=amd64", + } + dlc.ExpectedMetadata = map[string]interface{}{"dependency": dependency} + + layer.Metadata = map[string]interface{}{"dependency": map[string]interface{}{ + "id": dependency.ID, + "name": dependency.Name, + "version": dependency.Version, + "uri": dependency.URI, + "sha256": dependency.SHA256, + "stacks": []interface{}{dependency.Stacks[0]}, + "licenses": []map[string]interface{}{ + { + "type": dependency.Licenses[0].Type, + "uri": dependency.Licenses[0].URI, + }, + }, + "cpes": []interface{}{"cpe:2.3:a:some:jre:11.0.2:*:*:*:*:*:*:*"}, + "purl": "pkg:generic/some-java11@11.0.2?arch=amd64", + "deprecation_date": "0001-01-01T00:00:00Z", + }} + + var called bool + + err := dlc.Contribute(layer, func(layer *libcnb.Layer, artifact *os.File) error { + defer artifact.Close() + + called = true + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(called).To(BeFalse()) + }) + it("returns function error", func() { server.AppendHandlers(ghttp.RespondWith(http.StatusOK, "test-fixture"))