From 0b959bf03a682b5374c9073a1f0aac368359efbe Mon Sep 17 00:00:00 2001 From: Paul Johnston Date: Mon, 20 Jul 2026 12:55:12 -0600 Subject: [PATCH 1/5] Harden JVM image layering for production use - make Maven artifact routing deterministic - send ambiguous package ownership to the fallback layer - validate archive paths, output collisions, and layer configuration - propagate tar close errors and safely generate entrypoint scripts - fix manifest parsing and deploy JAR label derivation - align max_layers behavior with its documented contract - prevent generated layer filename collisions - fix Bazel workspace traversal and module dependencies - add production usage documentation and regression tests --- .bazelignore | 1 + MODULE.bazel | 5 +- README.md | 118 +++++++++++++++++ cmd/jar_layerer/README.md | 15 ++- example/hello/BUILD.bazel | 1 - example/hello/MODULE.bazel | 3 +- jvm_image_layers.bzl | 85 +++++++++---- pkg/jarlayer/jarlayer.go | 156 ++++++++++++++++++++--- pkg/jarlayer/jarlayer_test.go | 122 +++++++++++++++++- pkg/jartar/jartar.go | 232 +++++++++++++++++++++++++++++----- pkg/jartar/jartar_test.go | 153 ++++++++++++++++++++-- 11 files changed, 791 insertions(+), 100 deletions(-) create mode 100644 .bazelignore create mode 100644 README.md diff --git a/.bazelignore b/.bazelignore new file mode 100644 index 0000000..c6451bf --- /dev/null +++ b/.bazelignore @@ -0,0 +1 @@ +example/hello diff --git a/MODULE.bazel b/MODULE.bazel index ba52786..3660efe 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,12 +1,13 @@ module( name = "jvm_image", - version = "0.0.0", + version = "0.1.0", compatibility_level = 1, ) -bazel_dep(name = "bazel_skylib", version = "1.7.1") +bazel_dep(name = "bazel_skylib", version = "1.9.0") bazel_dep(name = "gazelle", version = "0.47.0") bazel_dep(name = "rules_go", version = "0.59.0") +bazel_dep(name = "rules_java", version = "8.14.0") bazel_dep(name = "buildifier_prebuilt", version = "8.2.1.2", dev_dependency = True) bazel_dep(name = "bazel_skylib_gazelle_plugin", version = "1.9.0", dev_dependency = True) diff --git a/README.md b/README.md new file mode 100644 index 0000000..587c96a --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# jvm_image + +`jvm_image` provides Bazel rules that turn a `java_binary` or `scala_binary` +runtime into OCI-compatible tar layers. It is intended to be consumed by image +rules such as [`rules_img`](https://github.com/bazel-contrib/rules_img). + +The public API is pre-1.0. Pin clients to a release tag or commit and review +upgrades before changing the pin. + +## Choose a rule + +| Rule | Layout | Use when | +| --- | --- | --- | +| `jvm_jar_layers` | Keeps every runtime JAR intact under `/app/lib` | Recommended. Preserves duplicate resources and matches the normal JVM classpath model. | +| `jvm_image_layers` | Explodes one deploy JAR under `/app` | Use only when the deploy JAR's merged-resource behavior is acceptable. | + +Both rules always produce a fallback tar for unmatched content. With a +`rules_jvm_external` lock file, Maven dependencies can be placed in separate or +grouped layers so application-only changes reuse dependency layers. + +## Install + +Until the module is published to a registry, add a pinned override to the +client's `MODULE.bazel`: + +```starlark +bazel_dep(name = "jvm_image", version = "0.1.0") + +git_override( + module_name = "jvm_image", + commit = "", + remote = "https://github.com/stackb/jvm_image.git", +) +``` + +Do not point production clients at a moving branch. + +## Recommended usage: intact JARs + +```starlark +load("@jvm_image//:jvm_image_layers.bzl", "jvm_jar_layers") + +jvm_jar_layers( + name = "server_layers", + binary = ":server", + maven_lock_file = "//:maven_install.json", + max_layers = 32, +) +``` + +Pass `:server_layers` to the image rule's layer/tar attribute. Configure the +image entrypoint with the binary's main class: + +```starlark +entrypoint = [ + "java", + "-cp", + "@/app/lib/classpath", + "com.example.Server", +] +``` + +The generated `classpath` file is included in the fallback tar. It is also +available through the target's `classpath` output group. + +## Deploy-JAR usage + +```starlark +load("@jvm_image//:jvm_image_layers.bzl", "jvm_image_layers") + +jvm_image_layers( + name = "server_layers", + binary = ":server", + layers = ["com/example/"], + maven_lock_file = "//:maven_install.json", + max_layers = 32, +) +``` + +This rule reads `Main-Class` from `META-INF/MANIFEST.MF` and exposes a generated +launcher through the `entrypoint` output group. The exploded classes are rooted +at `/app` by default. + +## Configuration notes + +- `max_layers` limits Maven-derived tar layers. It excludes explicit prefix + layers and the fallback tar. Set it to `0` to disable Maven-derived layers. +- `layer_strategy = "group_by_prefix"` groups Maven coordinates when their + count exceeds `max_layers`; `"truncate"` sends the excess to the fallback. +- `path_prefix` is a relative archive prefix and must end in `/` when non-empty. + `app_prefix` is the corresponding absolute path inside the image. +- Artifact routing uses the lock file's `packages` map. Unmatched or ambiguous + package ownership goes to the fallback instead of selecting a layer based on + map or ZIP iteration order. +- The intact-JAR classpath format targets Linux containers. Paths containing + whitespace or `:` are rejected because `:` is the classpath separator. + +## Validate locally + +```sh +go test ./... +go vet ./... +staticcheck ./... +bazel test //... + +cd example/hello +bazel build //:image +``` + +The example in [`example/hello`](example/hello) demonstrates the exploded +deploy-JAR rule with `rules_img`. + +## Release checklist + +Before onboarding a client, pin a tested commit, choose and add a repository +license, run both the Go and Bazel suites in CI, and publish a matching `v0.1.x` +tag. A license and hosted release are intentionally not inferred by this +repository. diff --git a/cmd/jar_layerer/README.md b/cmd/jar_layerer/README.md index 14c9078..1ffcb36 100644 --- a/cmd/jar_layerer/README.md +++ b/cmd/jar_layerer/README.md @@ -12,9 +12,9 @@ dropping entries from other JARs. This causes runtime failures when libraries like Typesafe Config or Java's `ServiceLoader` expect to scan all JARs on the classpath for their resources. -`jar_layerer` avoids the problem entirely by keeping each dependency JAR as an -individual file in the container. The JVM loads them via a classpath file, giving -identical behavior to `bazel run` (which already uses individual JARs). +`jar_layerer` avoids the problem by keeping each dependency JAR as an +individual file in the container. The JVM loads them via a classpath file, which +preserves the per-JAR resource layout used by a normal JVM runtime classpath. ## How it works @@ -46,12 +46,13 @@ java ${JAVA_OPTS} -cp @/app/lib/classpath com.example.Main When a Maven lock file is provided, the tool inspects each JAR to determine which Maven artifact it belongs to: -1. Open the JAR (ZIP) and find the first `.class` entry. -2. Derive the Java package from the class path (e.g. +1. Open the JAR (ZIP) and inspect its `.class` entries. +2. Derive Java packages from class paths (e.g. `com/google/common/collect/Lists.class` → `com.google.common.collect`). 3. Look up the package in the lock file's `packages` map to find the artifact ID (e.g. `com.google.guava:guava`). -4. Route the JAR to the corresponding artifact layer tar. +4. Route the JAR when all matches identify one artifact. Ambiguous ownership is + sent to the fallback instead of depending on ZIP or map iteration order. JARs that don't match any artifact go to the fallback tar. @@ -129,5 +130,5 @@ Positional arguments are also accepted as additional JAR paths. | Output | Exploded files in tar layers | Intact JARs in tar layers | | `reference.conf` | Lost (singlejar last-writer-wins) | Preserved (each JAR has its own) | | `META-INF/services` | Lost (singlejar last-writer-wins) | Preserved | -| Runtime behavior | May differ from `bazel run` | Identical to `bazel run` | +| Runtime behavior | May differ from a normal multi-JAR classpath | Preserves the multi-JAR resource layout | | Entrypoint | `java -cp /app MainClass` | `java -cp @/app/lib/classpath MainClass` | diff --git a/example/hello/BUILD.bazel b/example/hello/BUILD.bazel index 5ee28d7..521ba75 100644 --- a/example/hello/BUILD.bazel +++ b/example/hello/BUILD.bazel @@ -12,7 +12,6 @@ java_binary( jvm_image_layers( name = "layers", - # max_layers = -1, app_prefix = "/app", binary = ":hello", maven_lock_file = "maven2_install.json", diff --git a/example/hello/MODULE.bazel b/example/hello/MODULE.bazel index 6b582ca..68fc98e 100644 --- a/example/hello/MODULE.bazel +++ b/example/hello/MODULE.bazel @@ -10,7 +10,7 @@ module( bazel_dep(name = "rules_img", version = "0.3.4") bazel_dep(name = "rules_java", version = "9.6.1") bazel_dep(name = "rules_jvm_external", version = "6.7") -bazel_dep(name = "jvm_image", version = "0.0.0") +bazel_dep(name = "jvm_image", version = "0.1.0") local_path_override( module_name = "jvm_image", path = "../..", @@ -24,7 +24,6 @@ compat_proxy = use_extension("@rules_java//java:rules_java_deps.bzl", "compatibi use_repo(compat_proxy, "compatibility_proxy") toolchains = use_extension("@rules_java//java:extensions.bzl", "toolchains") - use_repo( toolchains, "remotejdk25_linux_toolchain_config_repo", diff --git a/jvm_image_layers.bzl b/jvm_image_layers.bzl index 1d7a2f3..450e6a4 100644 --- a/jvm_image_layers.bzl +++ b/jvm_image_layers.bzl @@ -4,9 +4,11 @@ Two strategies are provided: - jvm_image_layers: Explodes a deploy jar into loose files (fast, but loses duplicate resources like reference.conf). - jvm_jar_layers: Keeps individual dependency JARs intact in the container - (preserves all resources, identical runtime behavior to bazel run). + (preserves the normal multi-JAR resource layout). """ +load("@rules_java//java/common:java_info.bzl", "JavaInfo") + MavenDepsInfo = provider( doc = "Collects maven artifact IDs from jvm_import dependencies.", fields = { @@ -14,7 +16,7 @@ MavenDepsInfo = provider( }, ) -def _maven_deps_aspect_impl(target, ctx): +def _maven_deps_aspect_impl(_target, ctx): artifacts = [] # Check tags for maven_coordinates. @@ -52,6 +54,44 @@ def _sanitize_artifact_id(artifact_id): """Convert an artifact ID to a safe filename component.""" return artifact_id.replace(":", "_") +def _validate_options(max_layers, app_prefix, path_prefix): + """Validate arguments shared by both public macros.""" + if max_layers < 0: + fail("max_layers must be greater than or equal to zero") + if not app_prefix.startswith("/"): + fail("app_prefix must be an absolute container path") + if ".." in app_prefix.split("/") or ":" in app_prefix or "\\" in app_prefix: + fail("app_prefix must be a safe absolute container path without ':'") + if " " in app_prefix or "\t" in app_prefix or "\n" in app_prefix or "\r" in app_prefix: + fail("app_prefix must not contain whitespace") + if path_prefix.startswith("/") or ".." in path_prefix.split("/"): + fail("path_prefix must be relative and must not contain '..'") + if path_prefix and not path_prefix.endswith("/"): + fail("non-empty path_prefix must end with '/'") + +def _validate_layer_prefixes(layers): + seen = {} + for prefix in layers: + if not prefix: + fail("layer prefixes must not be empty") + if prefix.startswith("/") or ".." in prefix.split("/") or "\\" in prefix: + fail("layer prefix %r must be a safe relative archive path" % prefix) + if prefix in seen: + fail("duplicate layer prefix %r" % prefix) + seen[prefix] = True + +def _deploy_jar_label(binary): + """Return the implicit deploy-JAR label for a binary label string.""" + if type(binary) != "string": + fail("binary must be a label string so its deploy JAR can be derived") + if ":" in binary: + pkg, _, target_name = binary.rpartition(":") + return pkg + ":" + target_name + "_deploy.jar" + if binary.startswith("//") or binary.startswith("@"): + target_name = binary.split("/")[-1] + return binary + ":" + target_name + "_deploy.jar" + return binary + "_deploy.jar" + def _group_key(artifact_id, depth): """Extract a grouping key from an artifact ID at the given depth. @@ -138,16 +178,13 @@ def jvm_image_layers( path_prefix: prefix prepended to tar entry paths (default "app/"). **kwargs: additional arguments passed to the underlying rule """ - if ":" in binary: - pkg, _, target_name = binary.rpartition(":") - deploy_jar = pkg + ":" + target_name + "_deploy.jar" - else: - deploy_jar = binary + "_deploy.jar" + _validate_options(max_layers, app_prefix, path_prefix) + _validate_layer_prefixes(layers) _jvm_image_layers( name = name, binary = binary, - deploy_jar = deploy_jar, + deploy_jar = _deploy_jar_label(binary), layers = layers, maven_lock_file = maven_lock_file, max_layers = max_layers, @@ -176,9 +213,9 @@ def _jvm_image_layers_impl(ctx): outputs.append(fallback) # Per-layer output tars (explicit prefix layers). - for prefix in ctx.attr.layers: + for index, prefix in enumerate(ctx.attr.layers): sanitized = _sanitize_prefix(prefix) - layer_out = ctx.actions.declare_file(ctx.label.name + "." + sanitized + ".tar") + layer_out = ctx.actions.declare_file(ctx.label.name + ".explicit_%d.%s.tar" % (index, sanitized)) args.add("--output_layer", prefix + "=" + layer_out.path) outputs.append(layer_out) @@ -189,29 +226,29 @@ def _jvm_image_layers_impl(ctx): args.add("--maven_lock_file", lock_file) artifact_ids = sorted(ctx.attr.binary[MavenDepsInfo].artifacts.to_list()) - available_slots = ctx.attr.max_layers - len(ctx.attr.layers) + available_slots = ctx.attr.max_layers strategy = ctx.attr.layer_strategy if len(artifact_ids) <= available_slots: # Under the limit: one layer per artifact. - for artifact_id in artifact_ids: + for index, artifact_id in enumerate(artifact_ids): sanitized = _sanitize_artifact_id(artifact_id) - artifact_out = ctx.actions.declare_file(ctx.label.name + ".maven." + sanitized + ".tar") + artifact_out = ctx.actions.declare_file(ctx.label.name + ".maven_%d." % index + sanitized + ".tar") args.add("--artifact", artifact_id + "=" + artifact_out.path) outputs.append(artifact_out) elif strategy == "truncate": # Truncate: first N artifacts get layers, rest fall to fallback. - for artifact_id in artifact_ids[:available_slots]: + for index, artifact_id in enumerate(artifact_ids[:available_slots]): sanitized = _sanitize_artifact_id(artifact_id) - artifact_out = ctx.actions.declare_file(ctx.label.name + ".maven." + sanitized + ".tar") + artifact_out = ctx.actions.declare_file(ctx.label.name + ".maven_%d." % index + sanitized + ".tar") args.add("--artifact", artifact_id + "=" + artifact_out.path) outputs.append(artifact_out) elif strategy == "group_by_prefix": # Group by Maven group prefix. groups = _group_artifacts(artifact_ids, available_slots) - for group_name, group_ids in groups: + for index, (group_name, group_ids) in enumerate(groups): sanitized = _sanitize_artifact_id(group_name) - group_out = ctx.actions.declare_file(ctx.label.name + ".maven." + sanitized + ".tar") + group_out = ctx.actions.declare_file(ctx.label.name + ".maven_%d." % index + sanitized + ".tar") if len(group_ids) == 1: args.add("--artifact", group_ids[0] + "=" + group_out.path) else: @@ -315,6 +352,8 @@ def jvm_jar_layers( path_prefix: prefix prepended to tar entry paths (default "app/lib/"). **kwargs: additional arguments passed to the underlying rule """ + _validate_options(max_layers, app_prefix, path_prefix) + _jvm_jar_layers( name = name, binary = binary, @@ -364,22 +403,22 @@ def _jvm_jar_layers_impl(ctx): strategy = ctx.attr.layer_strategy if len(artifact_ids) <= available_slots: - for artifact_id in artifact_ids: + for index, artifact_id in enumerate(artifact_ids): sanitized = _sanitize_artifact_id(artifact_id) - artifact_out = ctx.actions.declare_file(ctx.label.name + ".maven." + sanitized + ".tar") + artifact_out = ctx.actions.declare_file(ctx.label.name + ".maven_%d." % index + sanitized + ".tar") args.add("--artifact_layer", artifact_id + "=" + artifact_out.path) tar_outputs.append(artifact_out) elif strategy == "truncate": - for artifact_id in artifact_ids[:available_slots]: + for index, artifact_id in enumerate(artifact_ids[:available_slots]): sanitized = _sanitize_artifact_id(artifact_id) - artifact_out = ctx.actions.declare_file(ctx.label.name + ".maven." + sanitized + ".tar") + artifact_out = ctx.actions.declare_file(ctx.label.name + ".maven_%d." % index + sanitized + ".tar") args.add("--artifact_layer", artifact_id + "=" + artifact_out.path) tar_outputs.append(artifact_out) elif strategy == "group_by_prefix": groups = _group_artifacts(artifact_ids, available_slots) - for group_name, group_ids in groups: + for index, (group_name, group_ids) in enumerate(groups): sanitized = _sanitize_artifact_id(group_name) - group_out = ctx.actions.declare_file(ctx.label.name + ".maven." + sanitized + ".tar") + group_out = ctx.actions.declare_file(ctx.label.name + ".maven_%d." % index + sanitized + ".tar") if len(group_ids) == 1: args.add("--artifact_layer", group_ids[0] + "=" + group_out.path) else: diff --git a/pkg/jarlayer/jarlayer.go b/pkg/jarlayer/jarlayer.go index d26c3e2..2e56bcd 100644 --- a/pkg/jarlayer/jarlayer.go +++ b/pkg/jarlayer/jarlayer.go @@ -1,13 +1,16 @@ +// Package jarlayer places intact JVM runtime JARs into OCI-compatible tar layers. package jarlayer import ( "archive/tar" "archive/zip" "encoding/json" + "errors" "fmt" "io" "os" "path" + "sort" "strings" ) @@ -48,7 +51,11 @@ type MavenLockFile struct { // For each JAR, the tool inspects its ZIP entries to find a .class file, // derives the package name, and matches it against the lock file to determine // which artifact (and thus which layer) the JAR belongs to. -func LayerJars(opts LayerOptions) error { +func LayerJars(opts LayerOptions) (retErr error) { + if err := validateOptions(opts); err != nil { + return err + } + // Parse lock file to build package→artifact_id mapping. pkgToArtifact, err := buildPackageMap(opts.LockFilePath) if err != nil { @@ -58,6 +65,16 @@ func LayerJars(opts LayerOptions) error { // Build artifact_id→tar writer mapping. artifactToWriter := make(map[string]*layerWriter) writersByPath := make(map[string]*layerWriter) + var openWriters []*layerWriter + defer func() { + var closeErr error + for i := len(openWriters) - 1; i >= 0; i-- { + closeErr = errors.Join(closeErr, openWriters[i].Close()) + } + if closeErr != nil { + retErr = errors.Join(retErr, fmt.Errorf("closing output tar: %w", closeErr)) + } + }() for _, al := range opts.ArtifactLayers { lw, ok := writersByPath[al.OutputPath] @@ -66,8 +83,8 @@ func LayerJars(opts LayerOptions) error { if err != nil { return err } - defer lw.Close() writersByPath[al.OutputPath] = lw + openWriters = append(openWriters, lw) } for _, id := range al.IDs { artifactToWriter[id] = lw @@ -79,7 +96,7 @@ func LayerJars(opts LayerOptions) error { if err != nil { return fmt.Errorf("creating fallback tar: %w", err) } - defer fallback.Close() + openWriters = append(openWriters, fallback) // Track written directories to avoid duplicates across JARs. writtenDirs := make(map[string]map[string]bool) // writer path -> set of dirs @@ -105,6 +122,9 @@ func LayerJars(opts LayerOptions) error { // Determine a unique tar entry name from the Bazel path. jarName := uniqueJarName(jarPath) + if strings.ContainsAny(jarName, "/:\x00\r\n\t ") || jarName == "." || jarName == ".." { + return fmt.Errorf("JAR %s produces unsupported classpath name %q", jarPath, jarName) + } if usedNames[jarName] { // Collision fallback: append numeric suffix. ext := path.Ext(jarName) @@ -133,7 +153,7 @@ func LayerJars(opts LayerOptions) error { } // Record classpath entry. - classpathEntries = append(classpathEntries, opts.AppPrefix+"/"+jarName) + classpathEntries = append(classpathEntries, path.Join(opts.AppPrefix, jarName)) } // Write classpath file. @@ -163,8 +183,9 @@ func LayerJars(opts LayerOptions) error { return nil } -// buildPackageMap parses the lock file and returns a mapping from -// Java package prefix (e.g., "com/google/common/collect/") to artifact ID. +// buildPackageMap parses the lock file and returns a mapping from Java package +// prefix (e.g., "com/google/common/collect/") to artifact ID. Split packages +// map to the empty string so they cannot cause nondeterministic routing. func buildPackageMap(lockFilePath string) (map[string]string, error) { if lockFilePath == "" { return nil, nil @@ -181,9 +202,24 @@ func buildPackageMap(lockFilePath string) (map[string]string, error) { } result := make(map[string]string) - for artifactID, packages := range lf.Packages { + artifactIDs := make([]string, 0, len(lf.Packages)) + for artifactID := range lf.Packages { + artifactIDs = append(artifactIDs, artifactID) + } + sort.Strings(artifactIDs) + for _, artifactID := range artifactIDs { + packages := lf.Packages[artifactID] for _, pkg := range packages { prefix := strings.ReplaceAll(pkg, ".", "/") + "/" + if err := validateArchivePrefix(prefix); err != nil { + return nil, fmt.Errorf("invalid package %q for artifact %q: %w", pkg, artifactID, err) + } + if existing, ok := result[prefix]; ok && existing != artifactID { + // An empty value marks a split package. It cannot identify a JAR + // by itself, but another unique package in the JAR still can. + result[prefix] = "" + continue + } result[prefix] = artifactID } } @@ -191,8 +227,9 @@ func buildPackageMap(lockFilePath string) (map[string]string, error) { return result, nil } -// identifyArtifact opens a JAR and finds the first .class entry to determine -// which Maven artifact it belongs to via the package→artifact mapping. +// identifyArtifact opens a JAR and inspects its .class entries to determine +// which Maven artifact it belongs to via the package→artifact mapping. JARs +// matching multiple artifacts are left unmatched for fallback routing. func identifyArtifact(jarPath string, pkgToArtifact map[string]string) (string, error) { if pkgToArtifact == nil { return "", nil @@ -204,6 +241,7 @@ func identifyArtifact(jarPath string, pkgToArtifact map[string]string) (string, } defer zr.Close() + matches := make(map[string]bool) for _, f := range zr.File { if !strings.HasSuffix(f.Name, ".class") { continue @@ -219,7 +257,10 @@ func identifyArtifact(jarPath string, pkgToArtifact map[string]string) (string, // Walk up the package hierarchy to find a match. for pkg != "" { if artifactID, ok := pkgToArtifact[pkg]; ok { - return artifactID, nil + if artifactID != "" { + matches[artifactID] = true + } + break } // Try parent: "com/google/common/collect/" → "com/google/common/" trimmed := strings.TrimSuffix(pkg, "/") @@ -230,10 +271,18 @@ func identifyArtifact(jarPath string, pkgToArtifact map[string]string) (string, pkg = trimmed[:lastSlash+1] } - // First class entry didn't match; keep trying other entries. } - return "", nil // no match found + if len(matches) == 0 { + return "", nil + } + if len(matches) > 1 { + return "", nil + } + for artifactID := range matches { + return artifactID, nil + } + return "", errors.New("internal error resolving artifact match") } type layerWriter struct { @@ -255,11 +304,86 @@ func newLayerWriter(outputPath string) (*layerWriter, error) { } func (lw *layerWriter) Close() error { - if err := lw.tw.Close(); err != nil { - lw.file.Close() - return err + tarErr := lw.tw.Close() + fileErr := lw.file.Close() + return errors.Join(tarErr, fileErr) +} + +func validateOptions(opts LayerOptions) error { + if opts.FallbackPath == "" { + return errors.New("fallback output path is required") + } + if err := validateArchivePrefix(opts.PathPrefix); err != nil { + return fmt.Errorf("invalid path prefix: %w", err) + } + if opts.AppPrefix == "" || !path.IsAbs(opts.AppPrefix) { + return errors.New("app prefix must be an absolute container path") + } + if strings.ContainsAny(opts.AppPrefix, ":\\\x00\r\n\t ") || hasParentPathSegment(opts.AppPrefix) { + return errors.New("app prefix contains a classpath separator or invalid character") + } + + outputs := map[string]string{opts.FallbackPath: "fallback output"} + if opts.ClasspathPath != "" { + outputs[opts.ClasspathPath] = "classpath output" + if opts.ClasspathPath == opts.FallbackPath { + return fmt.Errorf("output path %q is shared by fallback and classpath outputs", opts.FallbackPath) + } + } + artifactIDs := make(map[string]string) + for i, layer := range opts.ArtifactLayers { + if layer.OutputPath == "" { + return fmt.Errorf("artifact layer %d has an empty output path", i) + } + if len(layer.IDs) == 0 { + return fmt.Errorf("artifact layer %d has no artifact IDs", i) + } + if owner, ok := outputs[layer.OutputPath]; ok { + return fmt.Errorf("output path %q is shared by %s and an artifact layer", layer.OutputPath, owner) + } + for _, id := range layer.IDs { + if id == "" { + return fmt.Errorf("artifact layer %d has an empty artifact ID", i) + } + if outputPath, ok := artifactIDs[id]; ok && outputPath != layer.OutputPath { + return fmt.Errorf("artifact %q is assigned to multiple outputs (%s and %s)", id, outputPath, layer.OutputPath) + } + artifactIDs[id] = layer.OutputPath + } + } + if len(opts.ArtifactLayers) > 0 && opts.LockFilePath == "" { + return errors.New("maven lock file is required when artifact layers are configured") + } + return nil +} + +func validateArchivePrefix(prefix string) error { + if prefix == "" { + return nil + } + if strings.ContainsRune(prefix, '\x00') || strings.ContainsAny(prefix, "\\\r\n") { + return errors.New("path contains an invalid character") + } + if path.IsAbs(prefix) { + return errors.New("path must be relative") + } + if !strings.HasSuffix(prefix, "/") { + return errors.New("non-empty prefix must end with a slash") + } + cleaned := path.Clean(prefix) + if cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return errors.New("path must not escape the archive root") + } + return nil +} + +func hasParentPathSegment(name string) bool { + for _, part := range strings.Split(name, "/") { + if part == ".." { + return true + } } - return lw.file.Close() + return false } // ensureParentDirs writes directory entries for all path components of prefix diff --git a/pkg/jarlayer/jarlayer_test.go b/pkg/jarlayer/jarlayer_test.go index 1b033b6..8cf4714 100644 --- a/pkg/jarlayer/jarlayer_test.go +++ b/pkg/jarlayer/jarlayer_test.go @@ -162,8 +162,8 @@ func TestLayerJars_ArtifactRouting(t *testing.T) { // Lock file maps artifact IDs to packages. lockPath := filepath.Join(dir, "lock.json") createLockFile(t, lockPath, map[string][]string{ - "com.google.guava:guava": {"com.google.common.collect", "com.google.common.base"}, - "org.apache.pekko:pekko-actor_2.13": {"org.apache.pekko.actor"}, + "com.google.guava:guava": {"com.google.common.collect", "com.google.common.base"}, + "org.apache.pekko:pekko-actor_2.13": {"org.apache.pekko.actor"}, }) guavaLayerPath := filepath.Join(dir, "guava.tar") @@ -221,7 +221,7 @@ func TestLayerJars_GroupedArtifacts(t *testing.T) { lockPath := filepath.Join(dir, "lock.json") createLockFile(t, lockPath, map[string][]string{ - "com.google.guava:guava": {"com.google.common.collect"}, + "com.google.guava:guava": {"com.google.common.collect"}, "com.google.protobuf:protobuf-java": {"com.google.protobuf"}, }) @@ -439,3 +439,119 @@ func keys(m map[string]string) []string { } return result } + +func TestLayerJars_NormalizesAppPrefix(t *testing.T) { + dir := t.TempDir() + jarPath := filepath.Join(dir, "dep.jar") + createTestJar(t, jarPath, map[string]string{"com/example/Dep.class": "dep"}) + cpPath := filepath.Join(dir, "classpath") + + if err := LayerJars(LayerOptions{ + JarPaths: []string{jarPath}, + FallbackPath: filepath.Join(dir, "fallback.tar"), + ClasspathPath: cpPath, + AppPrefix: "/app/lib/", + PathPrefix: "app/lib/", + }); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(cpPath) + if err != nil { + t.Fatal(err) + } + if got, want := string(data), "/app/lib/dep.jar"; got != want { + t.Fatalf("classpath = %q, want %q", got, want) + } +} + +func TestLayerJars_RejectsConflictingOutputs(t *testing.T) { + dir := t.TempDir() + shared := filepath.Join(dir, "shared") + err := LayerJars(LayerOptions{ + FallbackPath: shared, + ClasspathPath: shared, + AppPrefix: "/app/lib", + PathPrefix: "app/lib/", + }) + if err == nil || !strings.Contains(err.Error(), "shared") { + t.Fatalf("LayerJars() error = %v, want shared output error", err) + } +} + +func TestLayerJars_ArtifactLayersRequireLockFile(t *testing.T) { + dir := t.TempDir() + err := LayerJars(LayerOptions{ + FallbackPath: filepath.Join(dir, "fallback.tar"), + AppPrefix: "/app/lib", + PathPrefix: "app/lib/", + ArtifactLayers: []ArtifactLayer{{ + IDs: []string{"com.example:dep"}, + OutputPath: filepath.Join(dir, "dep.tar"), + }}, + }) + if err == nil || !strings.Contains(err.Error(), "lock file is required") { + t.Fatalf("LayerJars() error = %v, want missing lock file error", err) + } +} + +func TestBuildPackageMap_MarksAmbiguousPackage(t *testing.T) { + dir := t.TempDir() + lockPath := filepath.Join(dir, "lock.json") + createLockFile(t, lockPath, map[string][]string{ + "com.example:first": {"com.example"}, + "com.example:second": {"com.example"}, + }) + + pkgMap, err := buildPackageMap(lockPath) + if err != nil { + t.Fatal(err) + } + if artifactID, ok := pkgMap["com/example/"]; !ok || artifactID != "" { + t.Fatalf("ambiguous package mapping = %q, %v; want empty marker", artifactID, ok) + } +} + +func TestIdentifyArtifact_AmbiguousJarUsesFallback(t *testing.T) { + dir := t.TempDir() + jarPath := filepath.Join(dir, "ambiguous.jar") + createTestJar(t, jarPath, map[string]string{ + "com/first/One.class": "one", + "org/second/Two.class": "two", + }) + + artifactID, err := identifyArtifact(jarPath, map[string]string{ + "com/first/": "com.example:first", + "org/second/": "org.example:second", + }) + if err != nil { + t.Fatal(err) + } + if artifactID != "" { + t.Fatalf("identifyArtifact() = %q, want fallback", artifactID) + } +} + +func TestLayerJars_RejectsUnsafePathPrefix(t *testing.T) { + dir := t.TempDir() + err := LayerJars(LayerOptions{ + FallbackPath: filepath.Join(dir, "fallback.tar"), + AppPrefix: "/app/lib", + PathPrefix: "../app/", + }) + if err == nil || !strings.Contains(err.Error(), "escape the archive root") { + t.Fatalf("LayerJars() error = %v, want unsafe prefix error", err) + } +} + +func TestLayerJars_RejectsUnsafeAppPrefix(t *testing.T) { + dir := t.TempDir() + err := LayerJars(LayerOptions{ + FallbackPath: filepath.Join(dir, "fallback.tar"), + AppPrefix: "/app/../etc", + PathPrefix: "app/lib/", + }) + if err == nil || !strings.Contains(err.Error(), "app prefix") { + t.Fatalf("LayerJars() error = %v, want invalid app prefix error", err) + } +} diff --git a/pkg/jartar/jartar.go b/pkg/jartar/jartar.go index 04bc794..037dea4 100644 --- a/pkg/jartar/jartar.go +++ b/pkg/jartar/jartar.go @@ -1,3 +1,4 @@ +// Package jartar splits an executable JAR into OCI-compatible tar layers. package jartar import ( @@ -5,9 +6,13 @@ import ( "archive/zip" "bufio" "encoding/json" + "errors" "fmt" "io" + "math" "os" + "path" + "sort" "strings" ) @@ -49,7 +54,11 @@ type SplitOptions struct { // Split reads a JAR file and distributes entries across layer tars. // Routing priority: explicit layers first, then artifact-derived prefixes, then fallback. // All output tars are always written, even if empty. -func Split(opts SplitOptions) (*SplitResult, error) { +func Split(opts SplitOptions) (result *SplitResult, retErr error) { + if err := validateOptions(opts); err != nil { + return nil, err + } + zr, err := zip.OpenReader(opts.InputPath) if err != nil { return nil, fmt.Errorf("opening jar: %w", err) @@ -63,7 +72,18 @@ func Split(opts SplitOptions) (*SplitResult, error) { } // Build artifact prefix map if lock file is provided. - var artifactPrefixMap map[string]*tar.Writer // path prefix -> tar writer + var artifactRoutes []artifactRoute + var openWriters []*writerState + defer func() { + var closeErr error + for i := len(openWriters) - 1; i >= 0; i-- { + closeErr = errors.Join(closeErr, openWriters[i].Close()) + } + if closeErr != nil { + result = nil + retErr = errors.Join(retErr, fmt.Errorf("closing output tar: %w", closeErr)) + } + }() if opts.MavenLockFilePath != "" && len(opts.Artifacts) > 0 { lockFile, err := parseLockFile(opts.MavenLockFilePath) @@ -71,56 +91,71 @@ func Split(opts SplitOptions) (*SplitResult, error) { return nil, err } - artifactPrefixMap = make(map[string]*tar.Writer) - // Deduplicate writers by output path so grouped artifacts share one tar. - writersByPath := make(map[string]*tar.Writer) + writersByPath := make(map[string]*writerState) + routesByPrefix := make(map[string]artifactRoute) for _, a := range opts.Artifacts { - tw, ok := writersByPath[a.OutputPath] + lw, ok := writersByPath[a.OutputPath] if !ok { - f, err := os.Create(a.OutputPath) + lw, err = newWriterState(a.OutputPath) if err != nil { return nil, fmt.Errorf("creating artifact output %s: %w", a.OutputPath, err) } - defer f.Close() - tw = tar.NewWriter(f) - defer tw.Close() - writersByPath[a.OutputPath] = tw + writersByPath[a.OutputPath] = lw + openWriters = append(openWriters, lw) } // Map each package prefix for this artifact to the shared writer. for _, pkg := range lockFile.Packages[a.ID] { prefix := strings.ReplaceAll(pkg, ".", "/") + "/" - artifactPrefixMap[prefix] = tw + if err := validateArchivePath(prefix, true); err != nil { + return nil, fmt.Errorf("invalid package %q for artifact %q: %w", pkg, a.ID, err) + } + route := artifactRoute{prefix: prefix, outputPath: a.OutputPath, tw: lw.tw} + if existing, ok := routesByPrefix[prefix]; ok { + if existing.outputPath == "" || existing.outputPath == route.outputPath { + continue + } + // Split packages cannot be attributed safely after a deploy JAR + // has been merged. Keep them in the fallback layer. + routesByPrefix[prefix] = artifactRoute{prefix: prefix} + continue + } + routesByPrefix[prefix] = route } } + for _, route := range routesByPrefix { + artifactRoutes = append(artifactRoutes, route) + } + sort.Slice(artifactRoutes, func(i, j int) bool { + if len(artifactRoutes[i].prefix) != len(artifactRoutes[j].prefix) { + return len(artifactRoutes[i].prefix) > len(artifactRoutes[j].prefix) + } + return artifactRoutes[i].prefix < artifactRoutes[j].prefix + }) } // Open explicit layer tar writers. - layerWriters := make([]writerState, len(opts.Layers)) + layerWriters := make([]*writerState, len(opts.Layers)) for i, l := range opts.Layers { - f, err := os.Create(l.OutputPath) + lw, err := newWriterState(l.OutputPath) if err != nil { return nil, fmt.Errorf("creating layer output %s: %w", l.OutputPath, err) } - defer f.Close() - tw := tar.NewWriter(f) - defer tw.Close() - layerWriters[i] = writerState{file: f, tw: tw} + openWriters = append(openWriters, lw) + layerWriters[i] = lw } // Open fallback tar writer. - fallbackFile, err := os.Create(opts.FallbackPath) + fallback, err := newWriterState(opts.FallbackPath) if err != nil { return nil, fmt.Errorf("creating fallback output: %w", err) } - defer fallbackFile.Close() - fallbackTw := tar.NewWriter(fallbackFile) - defer fallbackTw.Close() + openWriters = append(openWriters, fallback) for _, f := range zr.File { - tw := resolveWriter(f.Name, opts.Layers, layerWriters, artifactPrefixMap, fallbackTw) + tw := resolveWriter(f.Name, opts.Layers, layerWriters, artifactRoutes, fallback.tw) if err := writeEntry(tw, f, opts.PathPrefix); err != nil { return nil, fmt.Errorf("writing entry %s: %w", f.Name, err) } @@ -148,8 +183,8 @@ func Split(opts SplitOptions) (*SplitResult, error) { func resolveWriter( name string, layers []Layer, - layerWriters []writerState, - artifactPrefixMap map[string]*tar.Writer, + layerWriters []*writerState, + artifactRoutes []artifactRoute, fallback *tar.Writer, ) *tar.Writer { // Check explicit layers first. @@ -162,11 +197,12 @@ func resolveWriter( // Check artifact-derived prefixes. // Also check if the entry is an ancestor directory of a prefix // (e.g. entry "com/google/" is ancestor of prefix "com/google/common/collect/"). - if artifactPrefixMap != nil { - for prefix, tw := range artifactPrefixMap { - if strings.HasPrefix(name, prefix) || strings.HasPrefix(prefix, name) { - return tw + for _, route := range artifactRoutes { + if strings.HasPrefix(name, route.prefix) || strings.HasPrefix(route.prefix, name) { + if route.tw == nil { + return fallback } + return route.tw } } @@ -178,6 +214,116 @@ type writerState struct { tw *tar.Writer } +type artifactRoute struct { + prefix string + outputPath string + tw *tar.Writer +} + +func newWriterState(outputPath string) (*writerState, error) { + f, err := os.Create(outputPath) + if err != nil { + return nil, err + } + return &writerState{file: f, tw: tar.NewWriter(f)}, nil +} + +func (w *writerState) Close() error { + tarErr := w.tw.Close() + fileErr := w.file.Close() + return errors.Join(tarErr, fileErr) +} + +func validateOptions(opts SplitOptions) error { + if opts.InputPath == "" { + return errors.New("input path is required") + } + if opts.FallbackPath == "" { + return errors.New("fallback output path is required") + } + if err := validateArchivePath(opts.PathPrefix, true); err != nil { + return fmt.Errorf("invalid path prefix: %w", err) + } + if opts.EntrypointPath != "" && opts.AppPrefix != "" { + if !path.IsAbs(opts.AppPrefix) { + return errors.New("app prefix must be an absolute container path") + } + if strings.ContainsAny(opts.AppPrefix, ":\\\x00\r\n\t ") || hasParentPathSegment(opts.AppPrefix) { + return errors.New("app prefix contains an invalid character") + } + } + + outputs := map[string]string{opts.FallbackPath: "fallback output"} + for i, layer := range opts.Layers { + if layer.Prefix == "" { + return fmt.Errorf("layer %d has an empty prefix", i) + } + if err := validateArchivePath(layer.Prefix, false); err != nil { + return fmt.Errorf("invalid layer prefix %q: %w", layer.Prefix, err) + } + if layer.OutputPath == "" { + return fmt.Errorf("layer %q has an empty output path", layer.Prefix) + } + if owner, ok := outputs[layer.OutputPath]; ok { + return fmt.Errorf("output path %q is shared by %s and layer %q", layer.OutputPath, owner, layer.Prefix) + } + outputs[layer.OutputPath] = fmt.Sprintf("layer %q", layer.Prefix) + } + + artifactIDs := make(map[string]string) + for i, artifact := range opts.Artifacts { + if artifact.ID == "" { + return fmt.Errorf("artifact %d has an empty ID", i) + } + if artifact.OutputPath == "" { + return fmt.Errorf("artifact %q has an empty output path", artifact.ID) + } + if owner, ok := outputs[artifact.OutputPath]; ok { + return fmt.Errorf("output path %q is shared by %s and artifact %q", artifact.OutputPath, owner, artifact.ID) + } + if outputPath, ok := artifactIDs[artifact.ID]; ok && outputPath != artifact.OutputPath { + return fmt.Errorf("artifact %q is assigned to multiple outputs (%s and %s)", artifact.ID, outputPath, artifact.OutputPath) + } + artifactIDs[artifact.ID] = artifact.OutputPath + } + if len(opts.Artifacts) > 0 && opts.MavenLockFilePath == "" { + return errors.New("maven lock file is required when artifact layers are configured") + } + return nil +} + +func validateArchivePath(name string, allowEmpty bool) error { + if name == "" { + if allowEmpty { + return nil + } + return errors.New("path must not be empty") + } + if strings.ContainsRune(name, '\x00') || strings.ContainsAny(name, "\\\r\n") { + return errors.New("path contains an invalid character") + } + if path.IsAbs(name) { + return errors.New("path must be relative") + } + if allowEmpty && !strings.HasSuffix(name, "/") { + return errors.New("non-empty prefix must end with a slash") + } + cleaned := path.Clean(name) + if cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return errors.New("path must not escape the archive root") + } + return nil +} + +func hasParentPathSegment(name string) bool { + for _, part := range strings.Split(name, "/") { + if part == ".." { + return true + } + } + return false +} + func parseLockFile(path string) (*MavenLockFile, error) { data, err := os.ReadFile(path) if err != nil { @@ -194,7 +340,7 @@ func parseLockFile(path string) (*MavenLockFile, error) { // META-INF/MANIFEST.MF. Returns empty string if no manifest or no Main-Class. func parseMainClass(zr *zip.ReadCloser) (string, error) { for _, f := range zr.File { - if f.Name == "META-INF/MANIFEST.MF" { + if strings.EqualFold(f.Name, "META-INF/MANIFEST.MF") { rc, err := f.Open() if err != nil { return "", fmt.Errorf("opening MANIFEST.MF: %w", err) @@ -226,8 +372,13 @@ func extractMainClass(manifest string) string { } } for _, line := range lines { - if strings.HasPrefix(line, "Main-Class:") { - return strings.TrimSpace(strings.TrimPrefix(line, "Main-Class:")) + // Only attributes in the main section apply to the JAR itself. + if line == "" || line == "\r" { + break + } + name, value, ok := strings.Cut(line, ":") + if ok && strings.EqualFold(name, "Main-Class") { + return strings.TrimSpace(value) } } return "" @@ -236,11 +387,24 @@ func extractMainClass(manifest string) string { // writeEntrypoint generates a shell script that runs the exploded JAR // using java -cp. func writeEntrypoint(path, appPrefix, mainClass string) error { - script := fmt.Sprintf("#!/bin/sh\nexec java ${JAVA_OPTS} -cp %s %s \"$@\"\n", appPrefix, mainClass) - return os.WriteFile(path, []byte(script), 0755) + script := fmt.Sprintf("#!/bin/sh\nexec java ${JAVA_OPTS} -cp %s %s \"$@\"\n", shellQuote(appPrefix), shellQuote(mainClass)) + if err := os.WriteFile(path, []byte(script), 0755); err != nil { + return err + } + return os.Chmod(path, 0755) +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" } func writeEntry(tw *tar.Writer, f *zip.File, pathPrefix string) error { + if err := validateArchivePath(f.Name, false); err != nil { + return fmt.Errorf("invalid archive path: %w", err) + } + if f.UncompressedSize64 > math.MaxInt64 { + return fmt.Errorf("entry is too large: %d bytes", f.UncompressedSize64) + } info := f.FileInfo() isDir := info.IsDir() || strings.HasSuffix(f.Name, "/") diff --git a/pkg/jartar/jartar_test.go b/pkg/jartar/jartar_test.go index 7a69fba..b332be5 100644 --- a/pkg/jartar/jartar_test.go +++ b/pkg/jartar/jartar_test.go @@ -89,7 +89,7 @@ func TestSplit_NoLayers(t *testing.T) { fallbackPath := filepath.Join(dir, "fallback.tar") createTestJar(t, jarPath, map[string]string{ - "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", + "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", "com/example/Main.class": "main-class-bytes", }) @@ -117,7 +117,7 @@ func TestSplit_WithLayers(t *testing.T) { exampleLayerPath := filepath.Join(dir, "example.tar") createTestJar(t, jarPath, map[string]string{ - "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", + "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", "com/google/common/collect/Lists.class": "google-class-bytes", "com/google/common/base/Strings.class": "google-strings-bytes", "com/example/Main.class": "main-class-bytes", @@ -245,7 +245,7 @@ func TestSplit_ArtifactLayers(t *testing.T) { // Include directory entries (as JARs typically do) to verify they route // to artifact layers rather than leaking to the fallback. createTestJar(t, jarPath, map[string]string{ - "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", + "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", "com/": "", "com/google/": "", "com/google/common/": "", @@ -460,7 +460,7 @@ func TestSplit_EntrypointGeneration(t *testing.T) { entrypointPath := filepath.Join(dir, "entrypoint.sh") createTestJar(t, jarPath, map[string]string{ - "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\nMain-Class: com.example.Main\n", + "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\nMain-Class: com.example.Main\n", "com/example/Main.class": "main-class-bytes", }) @@ -484,7 +484,7 @@ func TestSplit_EntrypointGeneration(t *testing.T) { t.Fatal(err) } script := string(data) - wantScript := "#!/bin/sh\nexec java ${JAVA_OPTS} -cp /app com.example.Main \"$@\"\n" + wantScript := "#!/bin/sh\nexec java ${JAVA_OPTS} -cp '/app' 'com.example.Main' \"$@\"\n" if script != wantScript { t.Errorf("entrypoint script:\ngot: %q\nwant: %q", script, wantScript) } @@ -522,13 +522,42 @@ func TestSplit_EntrypointNoManifest(t *testing.T) { } } +func TestSplit_EntrypointMakesExistingFileExecutable(t *testing.T) { + dir := t.TempDir() + jarPath := filepath.Join(dir, "test.jar") + entrypointPath := filepath.Join(dir, "entrypoint.sh") + createTestJar(t, jarPath, map[string]string{ + "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\nMain-Class: com.example.Main\n", + }) + if err := os.WriteFile(entrypointPath, []byte("old"), 0644); err != nil { + t.Fatal(err) + } + + _, err := Split(SplitOptions{ + InputPath: jarPath, + FallbackPath: filepath.Join(dir, "fallback.tar"), + EntrypointPath: entrypointPath, + AppPrefix: "/app", + }) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(entrypointPath) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0755 { + t.Fatalf("entrypoint mode = %04o, want 0755", info.Mode().Perm()) + } +} + func TestSplit_PathPrefix(t *testing.T) { dir := t.TempDir() jarPath := filepath.Join(dir, "test.jar") fallbackPath := filepath.Join(dir, "fallback.tar") createTestJar(t, jarPath, map[string]string{ - "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", + "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", "com/example/Main.class": "main-class-bytes", }) @@ -557,11 +586,11 @@ func TestSplit_GroupedArtifacts(t *testing.T) { lockFilePath := filepath.Join(dir, "lock.json") createTestJar(t, jarPath, map[string]string{ - "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", - "com/google/common/collect/Lists.class": "lists-bytes", + "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", + "com/google/common/collect/Lists.class": "lists-bytes", "com/google/common/util/concurrent/internal/InternalFutureFailureAccess.class": "fa-bytes", - "javax/annotation/Nonnull.class": "nonnull-bytes", - "example/Main.class": "main-bytes", + "javax/annotation/Nonnull.class": "nonnull-bytes", + "example/Main.class": "main-bytes", }) createLockFile(t, lockFilePath, map[string][]string{ @@ -651,8 +680,8 @@ func TestSplit_DirectoryPermissions(t *testing.T) { // ZIP directory entries typically have mode bits that include os.ModeDir // but zero permission bits. The splitter must ensure directories get 0755. createTestJar(t, jarPath, map[string]string{ - "META-INF/": "", - "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", + "META-INF/": "", + "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", "com/": "", "com/example/": "", "com/example/Main.class": "main-class-bytes", @@ -774,3 +803,103 @@ func keys(m map[string]string) []string { } return result } + +func TestExtractMainClass_MainSectionAndCaseInsensitive(t *testing.T) { + manifest := "Manifest-Version: 1.0\r\nmain-class: com.example.Main\r\n\r\nName: other\r\nMain-Class: ignored.Entry\r\n" + if got := extractMainClass(manifest); got != "com.example.Main" { + t.Fatalf("extractMainClass() = %q, want %q", got, "com.example.Main") + } + + manifest = "Manifest-Version: 1.0\n\nName: other\nMain-Class: ignored.Entry\n" + if got := extractMainClass(manifest); got != "" { + t.Fatalf("extractMainClass() = %q, want empty value for entry-section attribute", got) + } +} + +func TestSplit_RejectsUnsafeArchivePath(t *testing.T) { + dir := t.TempDir() + jarPath := filepath.Join(dir, "unsafe.jar") + createTestJar(t, jarPath, map[string]string{"../escape.class": "bad"}) + + _, err := Split(SplitOptions{ + InputPath: jarPath, + FallbackPath: filepath.Join(dir, "fallback.tar"), + }) + if err == nil || !strings.Contains(err.Error(), "escape the archive root") { + t.Fatalf("Split() error = %v, want unsafe archive path error", err) + } +} + +func TestSplit_RejectsConflictingOutputPaths(t *testing.T) { + dir := t.TempDir() + shared := filepath.Join(dir, "shared.tar") + _, err := Split(SplitOptions{ + InputPath: filepath.Join(dir, "does-not-matter.jar"), + FallbackPath: shared, + Layers: []Layer{{Prefix: "com/", OutputPath: shared}}, + }) + if err == nil || !strings.Contains(err.Error(), "shared") { + t.Fatalf("Split() error = %v, want shared output error", err) + } +} + +func TestSplit_ArtifactLayersRequireLockFile(t *testing.T) { + dir := t.TempDir() + _, err := Split(SplitOptions{ + InputPath: filepath.Join(dir, "does-not-matter.jar"), + FallbackPath: filepath.Join(dir, "fallback.tar"), + Artifacts: []Artifact{{ + ID: "com.example:dep", + OutputPath: filepath.Join(dir, "dep.tar"), + }}, + }) + if err == nil || !strings.Contains(err.Error(), "lock file is required") { + t.Fatalf("Split() error = %v, want missing lock file error", err) + } +} + +func TestSplit_RejectsUnsafeEntrypointAppPrefix(t *testing.T) { + dir := t.TempDir() + _, err := Split(SplitOptions{ + InputPath: filepath.Join(dir, "does-not-matter.jar"), + FallbackPath: filepath.Join(dir, "fallback.tar"), + EntrypointPath: filepath.Join(dir, "entrypoint.sh"), + AppPrefix: "/app/../etc", + }) + if err == nil || !strings.Contains(err.Error(), "app prefix") { + t.Fatalf("Split() error = %v, want invalid app prefix error", err) + } +} + +func TestSplit_AmbiguousPackageUsesFallback(t *testing.T) { + dir := t.TempDir() + jarPath := filepath.Join(dir, "test.jar") + lockPath := filepath.Join(dir, "lock.json") + createTestJar(t, jarPath, map[string]string{"com/example/Main.class": "main"}) + createLockFile(t, lockPath, map[string][]string{ + "com.example:first": {"com.example"}, + "com.example:second": {"com.example"}, + }) + + firstPath := filepath.Join(dir, "first.tar") + secondPath := filepath.Join(dir, "second.tar") + fallbackPath := filepath.Join(dir, "fallback.tar") + _, err := Split(SplitOptions{ + InputPath: jarPath, + FallbackPath: fallbackPath, + MavenLockFilePath: lockPath, + Artifacts: []Artifact{ + {ID: "com.example:first", OutputPath: firstPath}, + {ID: "com.example:second", OutputPath: secondPath}, + }, + }) + if err != nil { + t.Fatal(err) + } + if _, ok := readTar(t, fallbackPath)["com/example/Main.class"]; !ok { + t.Fatal("ambiguous package entry was not written to fallback") + } + if len(readTar(t, firstPath)) != 0 || len(readTar(t, secondPath)) != 0 { + t.Fatal("ambiguous package entry was written to an artifact layer") + } +} From 519f08b9e4851c8fc8851ac58f77419562d03bc4 Mon Sep 17 00:00:00 2001 From: Paul Johnston Date: Mon, 20 Jul 2026 13:18:09 -0600 Subject: [PATCH 2/5] Add initial gh actions CI --- .github/workflows/ci.yml | 82 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..56e5475 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + merge_group: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + # Do not run untrusted fork code on an organization-owned runner. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + name: Go and Bazel + runs-on: + - self-hosted + timeout-minutes: 45 + + steps: + - name: Check out repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + cache: true + cache-dependency-path: go.mod + go-version-file: go.mod + + - name: Set up Bazel + uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # 0.19.0 + with: + bazelisk-cache: true + bazelisk-version: 1.29.0 + cache-save: ${{ github.event_name != 'pull_request' }} + disk-cache: ${{ github.workflow }} + repository-cache: | + - MODULE.bazel + - MODULE.bazel.lock + - example/hello/MODULE.bazel + - example/hello/MODULE.bazel.lock + + - name: Configure Go tool directory + shell: bash + run: | + set -euo pipefail + tool_bin="$RUNNER_TEMP/jvm_image/bin" + mkdir -p "$tool_bin" + echo "GOBIN=$tool_bin" >> "$GITHUB_ENV" + echo "$tool_bin" >> "$GITHUB_PATH" + + - name: Install pinned Staticcheck + run: go install honnef.co/go/tools/cmd/staticcheck@v0.5.1 + + - name: Test Go packages + run: go test ./... -count=1 + + - name: Vet Go packages + run: go vet ./... + + - name: Run Staticcheck + run: staticcheck ./... + + - name: Test Bazel targets + run: bazel test //... --test_output=errors + + - name: Build example image + working-directory: example/hello + run: bazel build //:image + + - name: Verify generated files are current + run: git diff --exit-code From c50907b0ebb0fc4f4b124773c392d117387169bb Mon Sep 17 00:00:00 2001 From: Paul Johnston Date: Mon, 20 Jul 2026 13:40:08 -0600 Subject: [PATCH 3/5] chore: upgrade Go and Bazel toolchains - upgrade Go to 1.25.12 and Bazel to 8.7.0 - refresh root and example module lockfiles - update Staticcheck to 2026.1 - include go.mod in the Bazel repository-cache key - add the CI status badge and tighten release guidance --- .bazelversion | 2 +- .github/workflows/ci.yml | 3 +- MODULE.bazel.lock | 330 ++++++++++++++++--------------- README.md | 17 +- example/hello/.bazelversion | 2 +- example/hello/MODULE.bazel.lock | 332 ++++++++++++++++---------------- go.mod | 2 +- 7 files changed, 341 insertions(+), 347 deletions(-) diff --git a/.bazelversion b/.bazelversion index f9c71a5..df5119e 100644 --- a/.bazelversion +++ b/.bazelversion @@ -1 +1 @@ -8.5.1 +8.7.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56e5475..cd2a87b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,7 @@ jobs: cache-save: ${{ github.event_name != 'pull_request' }} disk-cache: ${{ github.workflow }} repository-cache: | + - go.mod - MODULE.bazel - MODULE.bazel.lock - example/hello/MODULE.bazel @@ -60,7 +61,7 @@ jobs: echo "$tool_bin" >> "$GITHUB_PATH" - name: Install pinned Staticcheck - run: go install honnef.co/go/tools/cmd/staticcheck@v0.5.1 + run: go install honnef.co/go/tools/cmd/staticcheck@v0.7.0 - name: Test Go packages run: go test ./... -count=1 diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 696386a..2470980 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -170,7 +170,7 @@ "moduleExtensions": { "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { "general": { - "bzlTransitiveDigest": "rL/34P1aFDq2GqVC2zCFgQ8nTuOC6ziogocpvG50Qz8=", + "bzlTransitiveDigest": "03Qju4tW0vE+0RBuZGuV2A4Hx6AiSkdNahYvworx2aM=", "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, @@ -235,172 +235,6 @@ }, "facts": { "@@rules_go+//go:extensions.bzl%go_sdk": { - "1.23.6": { - "aix_ppc64": [ - "go1.23.6.aix-ppc64.tar.gz", - "adec10f4ba56591f523aa04851f7f6900b1c61508dfa6b80e62717a8e6684a5c" - ], - "darwin_amd64": [ - "go1.23.6.darwin-amd64.tar.gz", - "782da50ce8ec5e98fac2cd3cdc6a1d7130d093294fc310038f651444232a3fb0" - ], - "darwin_arm64": [ - "go1.23.6.darwin-arm64.tar.gz", - "5cae2450a1708aeb0333237a155640d5562abaf195defebc4306054565536221" - ], - "dragonfly_amd64": [ - "go1.23.6.dragonfly-amd64.tar.gz", - "d52efb3020d9332477ade98163c03d2f2fe3e051b0e7e01f0e167412c66de0cb" - ], - "freebsd_386": [ - "go1.23.6.freebsd-386.tar.gz", - "d3287706b5823712ac6cf7dff684a556cff98163ef60e7b275abe3388c17aac7" - ], - "freebsd_amd64": [ - "go1.23.6.freebsd-amd64.tar.gz", - "ebb4c6a9b0673dbdabc439877779ed6add16575e21bd0a7955c33f692789aef6" - ], - "freebsd_arm": [ - "go1.23.6.freebsd-arm.tar.gz", - "b7241584afb0b161c09148f8fde16171bb743e47b99d451fbc5f5217ec7a88b6" - ], - "freebsd_arm64": [ - "go1.23.6.freebsd-arm64.tar.gz", - "004718b53cedd7955d1b1dc4053539fcd1053c031f5f3374334a22befd1f8310" - ], - "freebsd_riscv64": [ - "go1.23.6.freebsd-riscv64.tar.gz", - "ca026ec8a30dd0c18164f40e1ce21bd725e2445f11699177d05815189a38de7a" - ], - "illumos_amd64": [ - "go1.23.6.illumos-amd64.tar.gz", - "7db973efa3fb2e48e45059b855721550fce8e90803e7373d3efd37b88dd821e8" - ], - "linux_386": [ - "go1.23.6.linux-386.tar.gz", - "e61f87693169c0bbcc43363128f1e929b9dff0b7f448573f1bdd4e4a0b9687ba" - ], - "linux_amd64": [ - "go1.23.6.linux-amd64.tar.gz", - "9379441ea310de000f33a4dc767bd966e72ab2826270e038e78b2c53c2e7802d" - ], - "linux_arm64": [ - "go1.23.6.linux-arm64.tar.gz", - "561c780e8f4a8955d32bf72e46af0b5ee5e0debe1e4633df9a03781878219202" - ], - "linux_armv6l": [ - "go1.23.6.linux-armv6l.tar.gz", - "27a4611010c16b8c4f37ade3aada55bd5781998f02f348b164302fd5eea4eb74" - ], - "linux_loong64": [ - "go1.23.6.linux-loong64.tar.gz", - "c459226424372abc2b35957cc8955dad348330714f7605093325dbb73e33c750" - ], - "linux_mips": [ - "go1.23.6.linux-mips.tar.gz", - "e2a0aff70b958a3463a7d47132a2d0238369f64578d4f7f95e679e3a5af05622" - ], - "linux_mips64": [ - "go1.23.6.linux-mips64.tar.gz", - "7d30ec7db056311d420bf930c16abcae13c0f41c26a202868f279721ec3c2f2f" - ], - "linux_mips64le": [ - "go1.23.6.linux-mips64le.tar.gz", - "74ca7bc475bcc084c6718b74df024d7de9612932cea8a6dc75e29d3a5315a23a" - ], - "linux_mipsle": [ - "go1.23.6.linux-mipsle.tar.gz", - "09bf935a14e9f59a20499989438b1655453480016bdbcb10406acf4df2678ccb" - ], - "linux_ppc64": [ - "go1.23.6.linux-ppc64.tar.gz", - "5cb2f6a5090276c72c5eda8a55896f5a3d6ea0f28d10fa1a50e8318640f02d6c" - ], - "linux_ppc64le": [ - "go1.23.6.linux-ppc64le.tar.gz", - "0f817201e83d78ddbfa27f5f78d9b72450b92cc21d5e045145efacd0d3244a99" - ], - "linux_riscv64": [ - "go1.23.6.linux-riscv64.tar.gz", - "f95f7f817ab22ecab4503d0704d6449ea1aa26a595f57bf9b9f94ddf2aa7c1f3" - ], - "linux_s390x": [ - "go1.23.6.linux-s390x.tar.gz", - "321e7ed0d5416f731479c52fa7610b52b8079a8061967bd48cec6d66f671a60e" - ], - "netbsd_386": [ - "go1.23.6.netbsd-386.tar.gz", - "92d678fb8e1eeeb8c6af6f22e4e5494652dcbb4a320113fc08325cb9956a2d4c" - ], - "netbsd_amd64": [ - "go1.23.6.netbsd-amd64.tar.gz", - "86ba51e7bb26b30ea6a8d88ddb79d8e8c83b4116200040ecb7a5a44cf90a8c5c" - ], - "netbsd_arm": [ - "go1.23.6.netbsd-arm.tar.gz", - "4b974c35345100f0be6ea66afab2781de91ee9882117314126eaf0ae90fd3816" - ], - "netbsd_arm64": [ - "go1.23.6.netbsd-arm64.tar.gz", - "53e3589fc38e787a493ea038961f8e40803714dbb42754c1713b00099c12e9b9" - ], - "openbsd_386": [ - "go1.23.6.openbsd-386.tar.gz", - "6d2317b3a8505ccebff8f72d943f2ac9b82c115632e54a53a786eff24ced56d9" - ], - "openbsd_amd64": [ - "go1.23.6.openbsd-amd64.tar.gz", - "f699e707d95a984fcc00361d91aecdb413d3c75e18235156ffba7a89edf68aae" - ], - "openbsd_arm": [ - "go1.23.6.openbsd-arm.tar.gz", - "3c1cf6ab893657d0bf1942e40ce115acfd27cbce1ccb9bc88fd9cd21ca3d489f" - ], - "openbsd_arm64": [ - "go1.23.6.openbsd-arm64.tar.gz", - "cc0875535d14001f2da23ae9af89025b28c466e8f4f4c63f991ebb6f4b02f66c" - ], - "openbsd_ppc64": [ - "go1.23.6.openbsd-ppc64.tar.gz", - "64de80e29ca66cb566cbf8be030bf8599953af4e48402eab724cbe0a08b40602" - ], - "openbsd_riscv64": [ - "go1.23.6.openbsd-riscv64.tar.gz", - "c398a6b43c569f34bb4a2d16b52f8010eaac9a2a82ecac0602b4338e35cef377" - ], - "plan9_386": [ - "go1.23.6.plan9-386.tar.gz", - "10998b6b130bb7b542b407f0db42b86a913b111f8fa86d44394beaace4d45f01" - ], - "plan9_amd64": [ - "go1.23.6.plan9-amd64.tar.gz", - "9fbe8065436d8d12c02f19f64f51c9107da3a7a4ac46ab5777e182e9fe88c32f" - ], - "plan9_arm": [ - "go1.23.6.plan9-arm.tar.gz", - "8e3c826b884daee2de37e3b070d7eac4cea5d68edab8db09910e22201c75db83" - ], - "solaris_amd64": [ - "go1.23.6.solaris-amd64.tar.gz", - "b619eff63fec86daaea92ca170559e448a58b8ba0b92eef1971bc14e92ea86a7" - ], - "windows_386": [ - "go1.23.6.windows-386.zip", - "96820c0f5d464dd694543329e9b4d413b17c821c03a055717a29e6735b44c2d8" - ], - "windows_amd64": [ - "go1.23.6.windows-amd64.zip", - "53fec1586850b2cf5ad6438341ff7adc5f6700dd3ec1cfa3f5e8b141df190243" - ], - "windows_arm": [ - "go1.23.6.windows-arm.zip", - "22c2518c45c20018afa20d5376dc9fd7a7e74367240ed7b5209e79a30b5c4218" - ], - "windows_arm64": [ - "go1.23.6.windows-arm64.zip", - "a2d2ec1b3759552bdd9cdf58858f91dfbfd6ab3a472f00b5255acbed30b1aa41" - ] - }, "1.25.0": { "aix_ppc64": [ "go1.25.0.aix-ppc64.tar.gz", @@ -562,6 +396,168 @@ "go1.25.0.windows-arm64.zip", "27bab004c72b3d7bd05a69b6ec0fc54a309b4b78cc569dd963d8b3ec28bfdb8c" ] + }, + "1.25.12": { + "aix_ppc64": [ + "go1.25.12.aix-ppc64.tar.gz", + "70b4b6509ed60735eff6ed9496824ad9f96201fdf5bd190184123f5908f224ef" + ], + "darwin_amd64": [ + "go1.25.12.darwin-amd64.tar.gz", + "00a2e743b82bccec03c51c4b0f7e46d5fec52184075fd6c5183c3bb39ae9fb00" + ], + "darwin_arm64": [ + "go1.25.12.darwin-arm64.tar.gz", + "fa2c88bbcf64bd3b2aef355f026cfec6d3a4a01c132f999c8f8c964eb767164f" + ], + "dragonfly_amd64": [ + "go1.25.12.dragonfly-amd64.tar.gz", + "2124cfbc1cf0b949eb819478365d4d665f84297a60f327cc65f75f61b2629b96" + ], + "freebsd_386": [ + "go1.25.12.freebsd-386.tar.gz", + "bdb8ab506428e9633653e67c13d582a6230406f01037023b327427c66b058ff2" + ], + "freebsd_amd64": [ + "go1.25.12.freebsd-amd64.tar.gz", + "4433a424d466d47d1716df69e6a77c65b1a34d82121488014e4656d73740ebb0" + ], + "freebsd_arm": [ + "go1.25.12.freebsd-arm.tar.gz", + "429fbcb46468a66d1cd24129a1b6163167676aaae8e0989a08ba95ba6aae65c4" + ], + "freebsd_arm64": [ + "go1.25.12.freebsd-arm64.tar.gz", + "c0e31a4cb827fd20fac950bcb4688fd94cdd1491dbdcff7c3754ac8f3b136eb1" + ], + "freebsd_riscv64": [ + "go1.25.12.freebsd-riscv64.tar.gz", + "0ea50c6d43e809d46c2c5504e97ef11822f7ca137625171924cda971449b5373" + ], + "illumos_amd64": [ + "go1.25.12.illumos-amd64.tar.gz", + "d74c214e0c3ae8f9db23f4ec6f6b2f6cb300c3e4b9d840b82fa517af629c455d" + ], + "linux_386": [ + "go1.25.12.linux-386.tar.gz", + "b71e88ae779850dc2afcd0f2e2208798652311dfda72fdef5d05721d601b8fd3" + ], + "linux_amd64": [ + "go1.25.12.linux-amd64.tar.gz", + "234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1" + ], + "linux_arm64": [ + "go1.25.12.linux-arm64.tar.gz", + "8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2" + ], + "linux_armv6l": [ + "go1.25.12.linux-armv6l.tar.gz", + "6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1" + ], + "linux_loong64": [ + "go1.25.12.linux-loong64.tar.gz", + "0c6b2b7db8509d1df189433709ddc8fe84c12843e3713508e9ca04dc9e75ddeb" + ], + "linux_mips": [ + "go1.25.12.linux-mips.tar.gz", + "5abc63471425ab8b3408f596cd547d28d374f6dc860a4cbd6de79497123db4f3" + ], + "linux_mips64": [ + "go1.25.12.linux-mips64.tar.gz", + "a18f7a5ac799ed8ac264b63707e7fa475b1b81da6bbdd41c394fcfd69cc6b736" + ], + "linux_mips64le": [ + "go1.25.12.linux-mips64le.tar.gz", + "5bde8a5d4c05b428fca3e6022dc41d4c735858be9c6945e57473da00fcddd0cc" + ], + "linux_mipsle": [ + "go1.25.12.linux-mipsle.tar.gz", + "33c243d2b0700589e75a410ebab5b64580a477e908872abc46eef840e6ea535a" + ], + "linux_ppc64": [ + "go1.25.12.linux-ppc64.tar.gz", + "b5288a7adb38540177146b47aa43bc75f1936c5e14026d6b6e531b534a49eb1a" + ], + "linux_ppc64le": [ + "go1.25.12.linux-ppc64le.tar.gz", + "64adb4ddefef4f0a6f11af550547f39bf510350da69ab308438a21eacfde97ad" + ], + "linux_riscv64": [ + "go1.25.12.linux-riscv64.tar.gz", + "26919d62d21b0bee9c5c67ad76ace462edd16c2c128a983d942cb45b0bf7693c" + ], + "linux_s390x": [ + "go1.25.12.linux-s390x.tar.gz", + "09875de1d6cb3237437112271b9df3a96bac9fcd10cbf9bc777c00e67f4e3e3d" + ], + "netbsd_386": [ + "go1.25.12.netbsd-386.tar.gz", + "2f19f4afc3d6551804228484569ec1ebf4a57a91cda75d746384aa71d5016be7" + ], + "netbsd_amd64": [ + "go1.25.12.netbsd-amd64.tar.gz", + "d2237237d34058658187455d4f05f8f4f5f2118b33827308ad8313ab0f00a693" + ], + "netbsd_arm": [ + "go1.25.12.netbsd-arm.tar.gz", + "4d44c8141a829541c3499db6665a47be2c6fdb97903575058809a2a8be53af89" + ], + "netbsd_arm64": [ + "go1.25.12.netbsd-arm64.tar.gz", + "fc96791f8b9cdaea544e827dc15574731afd28b7378053e801b3bd466df6dd9d" + ], + "openbsd_386": [ + "go1.25.12.openbsd-386.tar.gz", + "e9ced39c191409207a211c6013b9a156bd90dbd5a76ebd8c66dc3bf69bcb2f9e" + ], + "openbsd_amd64": [ + "go1.25.12.openbsd-amd64.tar.gz", + "bae7c016f0aff806c9af08ecff96b59a232144c96b1d25f4c5f6c85c8e0dbf01" + ], + "openbsd_arm": [ + "go1.25.12.openbsd-arm.tar.gz", + "8517c4bca20e975acdd5a2f5e425cd2bec737bbb6d7ee18d11e3a1545014267e" + ], + "openbsd_arm64": [ + "go1.25.12.openbsd-arm64.tar.gz", + "ba4ac29243f43b85a52f12ed7dd02b767e4524785fb6d04fececb7ce125aaa82" + ], + "openbsd_ppc64": [ + "go1.25.12.openbsd-ppc64.tar.gz", + "dca067f41d00ba805342062ab21b88831a06a5682e9aaec1c990c175a5656c6b" + ], + "openbsd_riscv64": [ + "go1.25.12.openbsd-riscv64.tar.gz", + "4f1f5376464fc91f32cd253dadfe3535ff57d1729b24e6ab014fb83964dfe10f" + ], + "plan9_386": [ + "go1.25.12.plan9-386.tar.gz", + "c938792ec65ba33592deea6673265d509aaf9b5297119bc91ae973fb6beb62b9" + ], + "plan9_amd64": [ + "go1.25.12.plan9-amd64.tar.gz", + "5aec7ab115c1c6cd049a64f1617ef91d24ea07c8b0f40436d531b42d546f5e37" + ], + "plan9_arm": [ + "go1.25.12.plan9-arm.tar.gz", + "efe1dcb9750507260a28688a749c72f35c3160646f08a80606f38c3d30d74a12" + ], + "solaris_amd64": [ + "go1.25.12.solaris-amd64.tar.gz", + "83c42cfedcc0bb03fd601d692d75b085406c9a7f5ff505bf41030f2734b62ed2" + ], + "windows_386": [ + "go1.25.12.windows-386.zip", + "18abdf76719f5f84eaa35eeaf87b467a02ed075a8632ad941f10aa7a3d0de713" + ], + "windows_amd64": [ + "go1.25.12.windows-amd64.zip", + "d5dc82da351b00e5eedd04f41356817d674cc4308131f0f638a5b14c5c3af4cb" + ], + "windows_arm64": [ + "go1.25.12.windows-arm64.zip", + "054f046a5fa31fdcc9491cc19065cbf43bf521d805bbe298ae8d65dd981fca84" + ] } } } diff --git a/README.md b/README.md index 587c96a..57d1acf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # jvm_image +[![CI](https://github.com/stackb/jvm_image/actions/workflows/ci.yml/badge.svg)](https://github.com/stackb/jvm_image/actions/workflows/ci.yml) + `jvm_image` provides Bazel rules that turn a `java_binary` or `scala_binary` runtime into OCI-compatible tar layers. It is intended to be consumed by image rules such as [`rules_img`](https://github.com/bazel-contrib/rules_img). @@ -9,10 +11,10 @@ upgrades before changing the pin. ## Choose a rule -| Rule | Layout | Use when | -| --- | --- | --- | -| `jvm_jar_layers` | Keeps every runtime JAR intact under `/app/lib` | Recommended. Preserves duplicate resources and matches the normal JVM classpath model. | -| `jvm_image_layers` | Explodes one deploy JAR under `/app` | Use only when the deploy JAR's merged-resource behavior is acceptable. | +| Rule | Layout | Use when | +|--------------------|-------------------------------------------------|----------------------------------------------------------------------------------------| +| `jvm_jar_layers` | Keeps every runtime JAR intact under `/app/lib` | Recommended. Preserves duplicate resources and matches the normal JVM classpath model. | +| `jvm_image_layers` | Explodes one deploy JAR under `/app` | Use only when the deploy JAR's merged-resource behavior is acceptable. | Both rules always produce a fallback tar for unmatched content. With a `rules_jvm_external` lock file, Maven dependencies can be placed in separate or @@ -112,7 +114,6 @@ deploy-JAR rule with `rules_img`. ## Release checklist -Before onboarding a client, pin a tested commit, choose and add a repository -license, run both the Go and Bazel suites in CI, and publish a matching `v0.1.x` -tag. A license and hosted release are intentionally not inferred by this -repository. +Before onboarding a client, pin a green commit, choose and add a repository +license, and publish a matching `v0.1.x` tag. A license and hosted release are +intentionally not inferred by this repository. diff --git a/example/hello/.bazelversion b/example/hello/.bazelversion index f9c71a5..df5119e 100644 --- a/example/hello/.bazelversion +++ b/example/hello/.bazelversion @@ -1 +1 @@ -8.5.1 +8.7.0 diff --git a/example/hello/MODULE.bazel.lock b/example/hello/MODULE.bazel.lock index b479822..7d43754 100644 --- a/example/hello/MODULE.bazel.lock +++ b/example/hello/MODULE.bazel.lock @@ -220,7 +220,7 @@ "moduleExtensions": { "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { "general": { - "bzlTransitiveDigest": "rL/34P1aFDq2GqVC2zCFgQ8nTuOC6ziogocpvG50Qz8=", + "bzlTransitiveDigest": "03Qju4tW0vE+0RBuZGuV2A4Hx6AiSkdNahYvworx2aM=", "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, @@ -284,7 +284,7 @@ }, "@@rules_python+//python/uv:uv.bzl%uv": { "general": { - "bzlTransitiveDigest": "8vT1ddXtljNxYD0tJkksqzeKE6xqx4Ix+tXthAppjTI=", + "bzlTransitiveDigest": "xfNZ/WmfkC9N/pNH0cmucTOrqBa966d9iMmmX54m1UM=", "usagesDigest": "icnInV8HDGrRQf9x8RMfxWfBHgT3OgRlYovS/9POEJw=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, @@ -326,172 +326,6 @@ }, "facts": { "@@rules_go+//go:extensions.bzl%go_sdk": { - "1.23.6": { - "aix_ppc64": [ - "go1.23.6.aix-ppc64.tar.gz", - "adec10f4ba56591f523aa04851f7f6900b1c61508dfa6b80e62717a8e6684a5c" - ], - "darwin_amd64": [ - "go1.23.6.darwin-amd64.tar.gz", - "782da50ce8ec5e98fac2cd3cdc6a1d7130d093294fc310038f651444232a3fb0" - ], - "darwin_arm64": [ - "go1.23.6.darwin-arm64.tar.gz", - "5cae2450a1708aeb0333237a155640d5562abaf195defebc4306054565536221" - ], - "dragonfly_amd64": [ - "go1.23.6.dragonfly-amd64.tar.gz", - "d52efb3020d9332477ade98163c03d2f2fe3e051b0e7e01f0e167412c66de0cb" - ], - "freebsd_386": [ - "go1.23.6.freebsd-386.tar.gz", - "d3287706b5823712ac6cf7dff684a556cff98163ef60e7b275abe3388c17aac7" - ], - "freebsd_amd64": [ - "go1.23.6.freebsd-amd64.tar.gz", - "ebb4c6a9b0673dbdabc439877779ed6add16575e21bd0a7955c33f692789aef6" - ], - "freebsd_arm": [ - "go1.23.6.freebsd-arm.tar.gz", - "b7241584afb0b161c09148f8fde16171bb743e47b99d451fbc5f5217ec7a88b6" - ], - "freebsd_arm64": [ - "go1.23.6.freebsd-arm64.tar.gz", - "004718b53cedd7955d1b1dc4053539fcd1053c031f5f3374334a22befd1f8310" - ], - "freebsd_riscv64": [ - "go1.23.6.freebsd-riscv64.tar.gz", - "ca026ec8a30dd0c18164f40e1ce21bd725e2445f11699177d05815189a38de7a" - ], - "illumos_amd64": [ - "go1.23.6.illumos-amd64.tar.gz", - "7db973efa3fb2e48e45059b855721550fce8e90803e7373d3efd37b88dd821e8" - ], - "linux_386": [ - "go1.23.6.linux-386.tar.gz", - "e61f87693169c0bbcc43363128f1e929b9dff0b7f448573f1bdd4e4a0b9687ba" - ], - "linux_amd64": [ - "go1.23.6.linux-amd64.tar.gz", - "9379441ea310de000f33a4dc767bd966e72ab2826270e038e78b2c53c2e7802d" - ], - "linux_arm64": [ - "go1.23.6.linux-arm64.tar.gz", - "561c780e8f4a8955d32bf72e46af0b5ee5e0debe1e4633df9a03781878219202" - ], - "linux_armv6l": [ - "go1.23.6.linux-armv6l.tar.gz", - "27a4611010c16b8c4f37ade3aada55bd5781998f02f348b164302fd5eea4eb74" - ], - "linux_loong64": [ - "go1.23.6.linux-loong64.tar.gz", - "c459226424372abc2b35957cc8955dad348330714f7605093325dbb73e33c750" - ], - "linux_mips": [ - "go1.23.6.linux-mips.tar.gz", - "e2a0aff70b958a3463a7d47132a2d0238369f64578d4f7f95e679e3a5af05622" - ], - "linux_mips64": [ - "go1.23.6.linux-mips64.tar.gz", - "7d30ec7db056311d420bf930c16abcae13c0f41c26a202868f279721ec3c2f2f" - ], - "linux_mips64le": [ - "go1.23.6.linux-mips64le.tar.gz", - "74ca7bc475bcc084c6718b74df024d7de9612932cea8a6dc75e29d3a5315a23a" - ], - "linux_mipsle": [ - "go1.23.6.linux-mipsle.tar.gz", - "09bf935a14e9f59a20499989438b1655453480016bdbcb10406acf4df2678ccb" - ], - "linux_ppc64": [ - "go1.23.6.linux-ppc64.tar.gz", - "5cb2f6a5090276c72c5eda8a55896f5a3d6ea0f28d10fa1a50e8318640f02d6c" - ], - "linux_ppc64le": [ - "go1.23.6.linux-ppc64le.tar.gz", - "0f817201e83d78ddbfa27f5f78d9b72450b92cc21d5e045145efacd0d3244a99" - ], - "linux_riscv64": [ - "go1.23.6.linux-riscv64.tar.gz", - "f95f7f817ab22ecab4503d0704d6449ea1aa26a595f57bf9b9f94ddf2aa7c1f3" - ], - "linux_s390x": [ - "go1.23.6.linux-s390x.tar.gz", - "321e7ed0d5416f731479c52fa7610b52b8079a8061967bd48cec6d66f671a60e" - ], - "netbsd_386": [ - "go1.23.6.netbsd-386.tar.gz", - "92d678fb8e1eeeb8c6af6f22e4e5494652dcbb4a320113fc08325cb9956a2d4c" - ], - "netbsd_amd64": [ - "go1.23.6.netbsd-amd64.tar.gz", - "86ba51e7bb26b30ea6a8d88ddb79d8e8c83b4116200040ecb7a5a44cf90a8c5c" - ], - "netbsd_arm": [ - "go1.23.6.netbsd-arm.tar.gz", - "4b974c35345100f0be6ea66afab2781de91ee9882117314126eaf0ae90fd3816" - ], - "netbsd_arm64": [ - "go1.23.6.netbsd-arm64.tar.gz", - "53e3589fc38e787a493ea038961f8e40803714dbb42754c1713b00099c12e9b9" - ], - "openbsd_386": [ - "go1.23.6.openbsd-386.tar.gz", - "6d2317b3a8505ccebff8f72d943f2ac9b82c115632e54a53a786eff24ced56d9" - ], - "openbsd_amd64": [ - "go1.23.6.openbsd-amd64.tar.gz", - "f699e707d95a984fcc00361d91aecdb413d3c75e18235156ffba7a89edf68aae" - ], - "openbsd_arm": [ - "go1.23.6.openbsd-arm.tar.gz", - "3c1cf6ab893657d0bf1942e40ce115acfd27cbce1ccb9bc88fd9cd21ca3d489f" - ], - "openbsd_arm64": [ - "go1.23.6.openbsd-arm64.tar.gz", - "cc0875535d14001f2da23ae9af89025b28c466e8f4f4c63f991ebb6f4b02f66c" - ], - "openbsd_ppc64": [ - "go1.23.6.openbsd-ppc64.tar.gz", - "64de80e29ca66cb566cbf8be030bf8599953af4e48402eab724cbe0a08b40602" - ], - "openbsd_riscv64": [ - "go1.23.6.openbsd-riscv64.tar.gz", - "c398a6b43c569f34bb4a2d16b52f8010eaac9a2a82ecac0602b4338e35cef377" - ], - "plan9_386": [ - "go1.23.6.plan9-386.tar.gz", - "10998b6b130bb7b542b407f0db42b86a913b111f8fa86d44394beaace4d45f01" - ], - "plan9_amd64": [ - "go1.23.6.plan9-amd64.tar.gz", - "9fbe8065436d8d12c02f19f64f51c9107da3a7a4ac46ab5777e182e9fe88c32f" - ], - "plan9_arm": [ - "go1.23.6.plan9-arm.tar.gz", - "8e3c826b884daee2de37e3b070d7eac4cea5d68edab8db09910e22201c75db83" - ], - "solaris_amd64": [ - "go1.23.6.solaris-amd64.tar.gz", - "b619eff63fec86daaea92ca170559e448a58b8ba0b92eef1971bc14e92ea86a7" - ], - "windows_386": [ - "go1.23.6.windows-386.zip", - "96820c0f5d464dd694543329e9b4d413b17c821c03a055717a29e6735b44c2d8" - ], - "windows_amd64": [ - "go1.23.6.windows-amd64.zip", - "53fec1586850b2cf5ad6438341ff7adc5f6700dd3ec1cfa3f5e8b141df190243" - ], - "windows_arm": [ - "go1.23.6.windows-arm.zip", - "22c2518c45c20018afa20d5376dc9fd7a7e74367240ed7b5209e79a30b5c4218" - ], - "windows_arm64": [ - "go1.23.6.windows-arm64.zip", - "a2d2ec1b3759552bdd9cdf58858f91dfbfd6ab3a472f00b5255acbed30b1aa41" - ] - }, "1.25.0": { "aix_ppc64": [ "go1.25.0.aix-ppc64.tar.gz", @@ -653,6 +487,168 @@ "go1.25.0.windows-arm64.zip", "27bab004c72b3d7bd05a69b6ec0fc54a309b4b78cc569dd963d8b3ec28bfdb8c" ] + }, + "1.25.12": { + "aix_ppc64": [ + "go1.25.12.aix-ppc64.tar.gz", + "70b4b6509ed60735eff6ed9496824ad9f96201fdf5bd190184123f5908f224ef" + ], + "darwin_amd64": [ + "go1.25.12.darwin-amd64.tar.gz", + "00a2e743b82bccec03c51c4b0f7e46d5fec52184075fd6c5183c3bb39ae9fb00" + ], + "darwin_arm64": [ + "go1.25.12.darwin-arm64.tar.gz", + "fa2c88bbcf64bd3b2aef355f026cfec6d3a4a01c132f999c8f8c964eb767164f" + ], + "dragonfly_amd64": [ + "go1.25.12.dragonfly-amd64.tar.gz", + "2124cfbc1cf0b949eb819478365d4d665f84297a60f327cc65f75f61b2629b96" + ], + "freebsd_386": [ + "go1.25.12.freebsd-386.tar.gz", + "bdb8ab506428e9633653e67c13d582a6230406f01037023b327427c66b058ff2" + ], + "freebsd_amd64": [ + "go1.25.12.freebsd-amd64.tar.gz", + "4433a424d466d47d1716df69e6a77c65b1a34d82121488014e4656d73740ebb0" + ], + "freebsd_arm": [ + "go1.25.12.freebsd-arm.tar.gz", + "429fbcb46468a66d1cd24129a1b6163167676aaae8e0989a08ba95ba6aae65c4" + ], + "freebsd_arm64": [ + "go1.25.12.freebsd-arm64.tar.gz", + "c0e31a4cb827fd20fac950bcb4688fd94cdd1491dbdcff7c3754ac8f3b136eb1" + ], + "freebsd_riscv64": [ + "go1.25.12.freebsd-riscv64.tar.gz", + "0ea50c6d43e809d46c2c5504e97ef11822f7ca137625171924cda971449b5373" + ], + "illumos_amd64": [ + "go1.25.12.illumos-amd64.tar.gz", + "d74c214e0c3ae8f9db23f4ec6f6b2f6cb300c3e4b9d840b82fa517af629c455d" + ], + "linux_386": [ + "go1.25.12.linux-386.tar.gz", + "b71e88ae779850dc2afcd0f2e2208798652311dfda72fdef5d05721d601b8fd3" + ], + "linux_amd64": [ + "go1.25.12.linux-amd64.tar.gz", + "234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1" + ], + "linux_arm64": [ + "go1.25.12.linux-arm64.tar.gz", + "8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2" + ], + "linux_armv6l": [ + "go1.25.12.linux-armv6l.tar.gz", + "6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1" + ], + "linux_loong64": [ + "go1.25.12.linux-loong64.tar.gz", + "0c6b2b7db8509d1df189433709ddc8fe84c12843e3713508e9ca04dc9e75ddeb" + ], + "linux_mips": [ + "go1.25.12.linux-mips.tar.gz", + "5abc63471425ab8b3408f596cd547d28d374f6dc860a4cbd6de79497123db4f3" + ], + "linux_mips64": [ + "go1.25.12.linux-mips64.tar.gz", + "a18f7a5ac799ed8ac264b63707e7fa475b1b81da6bbdd41c394fcfd69cc6b736" + ], + "linux_mips64le": [ + "go1.25.12.linux-mips64le.tar.gz", + "5bde8a5d4c05b428fca3e6022dc41d4c735858be9c6945e57473da00fcddd0cc" + ], + "linux_mipsle": [ + "go1.25.12.linux-mipsle.tar.gz", + "33c243d2b0700589e75a410ebab5b64580a477e908872abc46eef840e6ea535a" + ], + "linux_ppc64": [ + "go1.25.12.linux-ppc64.tar.gz", + "b5288a7adb38540177146b47aa43bc75f1936c5e14026d6b6e531b534a49eb1a" + ], + "linux_ppc64le": [ + "go1.25.12.linux-ppc64le.tar.gz", + "64adb4ddefef4f0a6f11af550547f39bf510350da69ab308438a21eacfde97ad" + ], + "linux_riscv64": [ + "go1.25.12.linux-riscv64.tar.gz", + "26919d62d21b0bee9c5c67ad76ace462edd16c2c128a983d942cb45b0bf7693c" + ], + "linux_s390x": [ + "go1.25.12.linux-s390x.tar.gz", + "09875de1d6cb3237437112271b9df3a96bac9fcd10cbf9bc777c00e67f4e3e3d" + ], + "netbsd_386": [ + "go1.25.12.netbsd-386.tar.gz", + "2f19f4afc3d6551804228484569ec1ebf4a57a91cda75d746384aa71d5016be7" + ], + "netbsd_amd64": [ + "go1.25.12.netbsd-amd64.tar.gz", + "d2237237d34058658187455d4f05f8f4f5f2118b33827308ad8313ab0f00a693" + ], + "netbsd_arm": [ + "go1.25.12.netbsd-arm.tar.gz", + "4d44c8141a829541c3499db6665a47be2c6fdb97903575058809a2a8be53af89" + ], + "netbsd_arm64": [ + "go1.25.12.netbsd-arm64.tar.gz", + "fc96791f8b9cdaea544e827dc15574731afd28b7378053e801b3bd466df6dd9d" + ], + "openbsd_386": [ + "go1.25.12.openbsd-386.tar.gz", + "e9ced39c191409207a211c6013b9a156bd90dbd5a76ebd8c66dc3bf69bcb2f9e" + ], + "openbsd_amd64": [ + "go1.25.12.openbsd-amd64.tar.gz", + "bae7c016f0aff806c9af08ecff96b59a232144c96b1d25f4c5f6c85c8e0dbf01" + ], + "openbsd_arm": [ + "go1.25.12.openbsd-arm.tar.gz", + "8517c4bca20e975acdd5a2f5e425cd2bec737bbb6d7ee18d11e3a1545014267e" + ], + "openbsd_arm64": [ + "go1.25.12.openbsd-arm64.tar.gz", + "ba4ac29243f43b85a52f12ed7dd02b767e4524785fb6d04fececb7ce125aaa82" + ], + "openbsd_ppc64": [ + "go1.25.12.openbsd-ppc64.tar.gz", + "dca067f41d00ba805342062ab21b88831a06a5682e9aaec1c990c175a5656c6b" + ], + "openbsd_riscv64": [ + "go1.25.12.openbsd-riscv64.tar.gz", + "4f1f5376464fc91f32cd253dadfe3535ff57d1729b24e6ab014fb83964dfe10f" + ], + "plan9_386": [ + "go1.25.12.plan9-386.tar.gz", + "c938792ec65ba33592deea6673265d509aaf9b5297119bc91ae973fb6beb62b9" + ], + "plan9_amd64": [ + "go1.25.12.plan9-amd64.tar.gz", + "5aec7ab115c1c6cd049a64f1617ef91d24ea07c8b0f40436d531b42d546f5e37" + ], + "plan9_arm": [ + "go1.25.12.plan9-arm.tar.gz", + "efe1dcb9750507260a28688a749c72f35c3160646f08a80606f38c3d30d74a12" + ], + "solaris_amd64": [ + "go1.25.12.solaris-amd64.tar.gz", + "83c42cfedcc0bb03fd601d692d75b085406c9a7f5ff505bf41030f2734b62ed2" + ], + "windows_386": [ + "go1.25.12.windows-386.zip", + "18abdf76719f5f84eaa35eeaf87b467a02ed075a8632ad941f10aa7a3d0de713" + ], + "windows_amd64": [ + "go1.25.12.windows-amd64.zip", + "d5dc82da351b00e5eedd04f41356817d674cc4308131f0f638a5b14c5c3af4cb" + ], + "windows_arm64": [ + "go1.25.12.windows-arm64.zip", + "054f046a5fa31fdcc9491cc19065cbf43bf521d805bbe298ae8d65dd981fca84" + ] } } } diff --git a/go.mod b/go.mod index f0db816..0cf47aa 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/stackb/jvm_image -go 1.23.6 +go 1.25.12 From 4977cdd1577d36341600bd47f96b10b7cdbb354e Mon Sep 17 00:00:00 2001 From: Paul Johnston Date: Mon, 20 Jul 2026 14:00:05 -0600 Subject: [PATCH 4/5] refactor!: replace jvm_image_layers with jvm_jar_layers - migrate the example to intact runtime JAR layers - collect binary classpaths using JavaRuntimeClasspathInfo - fail when a binary exposes no runtime JARs - remove the deploy-JAR splitter and jartar package - rename the public Starlark entry point - update documentation and fix Buildifier warnings --- BUILD.bazel | 6 +- MODULE.bazel | 3 +- README.md | 43 +- cmd/executable_jar_splitter/BUILD.bazel | 15 - cmd/executable_jar_splitter/main.go | 97 --- cmd/jar_layerer/README.md | 15 +- example/hello/BUILD.bazel | 13 +- jvm_image_layers.bzl => jvm_jar_layers.bzl | 228 +----- pkg/jartar/BUILD.bazel | 14 - pkg/jartar/jartar.go | 455 ----------- pkg/jartar/jartar_test.go | 905 --------------------- 11 files changed, 33 insertions(+), 1761 deletions(-) delete mode 100644 cmd/executable_jar_splitter/BUILD.bazel delete mode 100644 cmd/executable_jar_splitter/main.go rename jvm_image_layers.bzl => jvm_jar_layers.bzl (51%) delete mode 100644 pkg/jartar/BUILD.bazel delete mode 100644 pkg/jartar/jartar.go delete mode 100644 pkg/jartar/jartar_test.go diff --git a/BUILD.bazel b/BUILD.bazel index 2d9f7be..8c8c69f 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -2,13 +2,13 @@ load("@bazel_skylib//:bzl_library.bzl", "bzl_library") exports_files([ "MODULE.bazel", - "jvm_image_layers.bzl", + "jvm_jar_layers.bzl", ]) # gazelle:prefix github.com/stackb/jvm_image bzl_library( - name = "jvm_image_layers", - srcs = ["jvm_image_layers.bzl"], + name = "jvm_jar_layers", + srcs = ["jvm_jar_layers.bzl"], visibility = ["//visibility:public"], ) diff --git a/MODULE.bazel b/MODULE.bazel index 3660efe..a78567c 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,7 +1,8 @@ +"""Module definition and development toolchain configuration for jvm_image.""" + module( name = "jvm_image", version = "0.1.0", - compatibility_level = 1, ) bazel_dep(name = "bazel_skylib", version = "1.9.0") diff --git a/README.md b/README.md index 57d1acf..25a62cb 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,16 @@ [![CI](https://github.com/stackb/jvm_image/actions/workflows/ci.yml/badge.svg)](https://github.com/stackb/jvm_image/actions/workflows/ci.yml) -`jvm_image` provides Bazel rules that turn a `java_binary` or `scala_binary` +`jvm_image` provides a Bazel rule that turns a `java_binary` or `scala_binary` runtime into OCI-compatible tar layers. It is intended to be consumed by image rules such as [`rules_img`](https://github.com/bazel-contrib/rules_img). The public API is pre-1.0. Pin clients to a release tag or commit and review upgrades before changing the pin. -## Choose a rule - -| Rule | Layout | Use when | -|--------------------|-------------------------------------------------|----------------------------------------------------------------------------------------| -| `jvm_jar_layers` | Keeps every runtime JAR intact under `/app/lib` | Recommended. Preserves duplicate resources and matches the normal JVM classpath model. | -| `jvm_image_layers` | Explodes one deploy JAR under `/app` | Use only when the deploy JAR's merged-resource behavior is acceptable. | - -Both rules always produce a fallback tar for unmatched content. With a +`jvm_jar_layers` keeps every runtime JAR intact under `/app/lib`, preserving +duplicate resources and the normal JVM classpath model. It always produces a +fallback tar for unmatched content. With a `rules_jvm_external` lock file, Maven dependencies can be placed in separate or grouped layers so application-only changes reuse dependency layers. @@ -37,10 +32,10 @@ git_override( Do not point production clients at a moving branch. -## Recommended usage: intact JARs +## Usage ```starlark -load("@jvm_image//:jvm_image_layers.bzl", "jvm_jar_layers") +load("@jvm_image//:jvm_jar_layers.bzl", "jvm_jar_layers") jvm_jar_layers( name = "server_layers", @@ -65,28 +60,10 @@ entrypoint = [ The generated `classpath` file is included in the fallback tar. It is also available through the target's `classpath` output group. -## Deploy-JAR usage - -```starlark -load("@jvm_image//:jvm_image_layers.bzl", "jvm_image_layers") - -jvm_image_layers( - name = "server_layers", - binary = ":server", - layers = ["com/example/"], - maven_lock_file = "//:maven_install.json", - max_layers = 32, -) -``` - -This rule reads `Main-Class` from `META-INF/MANIFEST.MF` and exposes a generated -launcher through the `entrypoint` output group. The exploded classes are rooted -at `/app` by default. - ## Configuration notes -- `max_layers` limits Maven-derived tar layers. It excludes explicit prefix - layers and the fallback tar. Set it to `0` to disable Maven-derived layers. +- `max_layers` limits Maven-derived tar layers. It excludes the fallback tar. + Set it to `0` to disable Maven-derived layers. - `layer_strategy = "group_by_prefix"` groups Maven coordinates when their count exceeds `max_layers`; `"truncate"` sends the excess to the fallback. - `path_prefix` is a relative archive prefix and must end in `/` when non-empty. @@ -109,8 +86,8 @@ cd example/hello bazel build //:image ``` -The example in [`example/hello`](example/hello) demonstrates the exploded -deploy-JAR rule with `rules_img`. +The example in [`example/hello`](example/hello) demonstrates `jvm_jar_layers` +with `rules_img`. ## Release checklist diff --git a/cmd/executable_jar_splitter/BUILD.bazel b/cmd/executable_jar_splitter/BUILD.bazel deleted file mode 100644 index 80d9db1..0000000 --- a/cmd/executable_jar_splitter/BUILD.bazel +++ /dev/null @@ -1,15 +0,0 @@ -load("@rules_go//go:def.bzl", "go_binary", "go_library") - -go_library( - name = "executable_jar_splitter_lib", - srcs = ["main.go"], - importpath = "github.com/stackb/jvm_image/cmd/executable_jar_splitter", - visibility = ["//visibility:private"], - deps = ["//pkg/jartar"], -) - -go_binary( - name = "executable_jar_splitter", - embed = [":executable_jar_splitter_lib"], - visibility = ["//visibility:public"], -) diff --git a/cmd/executable_jar_splitter/main.go b/cmd/executable_jar_splitter/main.go deleted file mode 100644 index 7e4a229..0000000 --- a/cmd/executable_jar_splitter/main.go +++ /dev/null @@ -1,97 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "strings" - - "github.com/stackb/jvm_image/pkg/jartar" -) - -type repeatedFlag []string - -func (f *repeatedFlag) String() string { return strings.Join(*f, ", ") } -func (f *repeatedFlag) Set(value string) error { - *f = append(*f, value) - return nil -} - -func main() { - input := flag.String("input", "", "path to input JAR file") - output := flag.String("output", "", "path to fallback output tar file") - mavenLockFile := flag.String("maven_lock_file", "", "path to maven lock file JSON") - entrypoint := flag.String("entrypoint", "", "path to write entrypoint shell script") - appPrefix := flag.String("app_prefix", "/app", "classpath prefix in the container") - pathPrefix := flag.String("path_prefix", "", "prefix prepended to tar entry paths") - var outputLayers repeatedFlag - flag.Var(&outputLayers, "output_layer", "PREFIX=path.tar (repeatable)") - var artifacts repeatedFlag - flag.Var(&artifacts, "artifact", "ARTIFACT_ID=path.tar (repeatable)") - var artifactGroups repeatedFlag - flag.Var(&artifactGroups, "artifact_group", "ID1,ID2,...=path.tar (repeatable)") - flag.Parse() - - if *input == "" || *output == "" { - fmt.Fprintf(os.Stderr, "both --input and --output are required\n") - os.Exit(1) - } - - opts := jartar.SplitOptions{ - InputPath: *input, - FallbackPath: *output, - MavenLockFilePath: *mavenLockFile, - EntrypointPath: *entrypoint, - AppPrefix: *appPrefix, - PathPrefix: *pathPrefix, - } - - for _, ol := range outputLayers { - prefix, path, ok := strings.Cut(ol, "=") - if !ok || prefix == "" || path == "" { - fmt.Fprintf(os.Stderr, "invalid --output_layer value %q: expected PREFIX=path.tar\n", ol) - os.Exit(1) - } - opts.Layers = append(opts.Layers, jartar.Layer{ - Prefix: prefix, - OutputPath: path, - }) - } - - for _, a := range artifacts { - id, path, ok := strings.Cut(a, "=") - if !ok || id == "" || path == "" { - fmt.Fprintf(os.Stderr, "invalid --artifact value %q: expected ARTIFACT_ID=path.tar\n", a) - os.Exit(1) - } - opts.Artifacts = append(opts.Artifacts, jartar.Artifact{ - ID: id, - OutputPath: path, - }) - } - - // --artifact_group=ID1,ID2,...=path.tar - // Multiple artifact IDs sharing the same output tar. - for _, ag := range artifactGroups { - ids, path, ok := strings.Cut(ag, "=") - if !ok || ids == "" || path == "" { - fmt.Fprintf(os.Stderr, "invalid --artifact_group value %q: expected ID1,ID2,...=path.tar\n", ag) - os.Exit(1) - } - for _, id := range strings.Split(ids, ",") { - id = strings.TrimSpace(id) - if id == "" { - continue - } - opts.Artifacts = append(opts.Artifacts, jartar.Artifact{ - ID: id, - OutputPath: path, - }) - } - } - - if _, err := jartar.Split(opts); err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) - } -} diff --git a/cmd/jar_layerer/README.md b/cmd/jar_layerer/README.md index 1ffcb36..e48cc62 100644 --- a/cmd/jar_layerer/README.md +++ b/cmd/jar_layerer/README.md @@ -88,10 +88,10 @@ from the full Bazel output path: ## Starlark integration -The `jvm_jar_layers` rule in `jvm_image_layers.bzl` drives this tool: +The `jvm_jar_layers` rule in `jvm_jar_layers.bzl` drives this tool: ```starlark -load("@jvm_image//:jvm_image_layers.bzl", "jvm_jar_layers") +load("@jvm_image//:jvm_jar_layers.bzl", "jvm_jar_layers") jvm_jar_layers( name = "my_layers", @@ -121,14 +121,3 @@ The rule: | `--artifact_group_layer` | `ID1,ID2,...=path.tar` — multiple artifacts sharing a layer (repeatable) | Positional arguments are also accepted as additional JAR paths. - -## Comparison with executable_jar_splitter - -| | `executable_jar_splitter` | `jar_layerer` | -|---|---|---| -| Input | Single deploy JAR (merged) | Individual dependency JARs | -| Output | Exploded files in tar layers | Intact JARs in tar layers | -| `reference.conf` | Lost (singlejar last-writer-wins) | Preserved (each JAR has its own) | -| `META-INF/services` | Lost (singlejar last-writer-wins) | Preserved | -| Runtime behavior | May differ from a normal multi-JAR classpath | Preserves the multi-JAR resource layout | -| Entrypoint | `java -cp /app MainClass` | `java -cp @/app/lib/classpath MainClass` | diff --git a/example/hello/BUILD.bazel b/example/hello/BUILD.bazel index 521ba75..487a12d 100644 --- a/example/hello/BUILD.bazel +++ b/example/hello/BUILD.bazel @@ -1,4 +1,4 @@ -load("@jvm_image//:jvm_image_layers.bzl", "jvm_image_layers") +load("@jvm_image//:jvm_jar_layers.bzl", "jvm_jar_layers") load("@rules_img//img:image.bzl", "image_manifest") load("@rules_img//img:load.bzl", "image_load") load("@rules_java//java:java_binary.bzl", "java_binary") @@ -10,26 +10,19 @@ java_binary( deps = ["@maven2//:com_google_guava_guava"], ) -jvm_image_layers( +jvm_jar_layers( name = "layers", - app_prefix = "/app", binary = ":hello", maven_lock_file = "maven2_install.json", ) -filegroup( - name = "entrypoint", - srcs = [":layers"], - output_group = "entrypoint", -) - image_manifest( name = "image", base = "@distroless_java//:image", entrypoint = [ "java", "-cp", - "/app", + "@/app/lib/classpath", "example.hello.Main", ], layers = [":layers"], diff --git a/jvm_image_layers.bzl b/jvm_jar_layers.bzl similarity index 51% rename from jvm_image_layers.bzl rename to jvm_jar_layers.bzl index 450e6a4..89e3e49 100644 --- a/jvm_image_layers.bzl +++ b/jvm_jar_layers.bzl @@ -1,12 +1,6 @@ -"""Rules for converting JVM binaries into layered container tarballs. - -Two strategies are provided: -- jvm_image_layers: Explodes a deploy jar into loose files (fast, but loses - duplicate resources like reference.conf). -- jvm_jar_layers: Keeps individual dependency JARs intact in the container - (preserves the normal multi-JAR resource layout). -""" +"""Bazel rule for converting a JVM binary's runtime JARs into container layers.""" +load("@rules_java//java/common:java_common.bzl", "java_common") load("@rules_java//java/common:java_info.bzl", "JavaInfo") MavenDepsInfo = provider( @@ -46,16 +40,12 @@ _maven_deps_aspect = aspect( attr_aspects = ["deps", "exports", "runtime_deps"], ) -def _sanitize_prefix(prefix): - """Convert a path prefix to a safe filename component.""" - return prefix.replace("/", "_").strip("_") - def _sanitize_artifact_id(artifact_id): """Convert an artifact ID to a safe filename component.""" return artifact_id.replace(":", "_") def _validate_options(max_layers, app_prefix, path_prefix): - """Validate arguments shared by both public macros.""" + """Validate public macro arguments.""" if max_layers < 0: fail("max_layers must be greater than or equal to zero") if not app_prefix.startswith("/"): @@ -69,28 +59,11 @@ def _validate_options(max_layers, app_prefix, path_prefix): if path_prefix and not path_prefix.endswith("/"): fail("non-empty path_prefix must end with '/'") -def _validate_layer_prefixes(layers): - seen = {} - for prefix in layers: - if not prefix: - fail("layer prefixes must not be empty") - if prefix.startswith("/") or ".." in prefix.split("/") or "\\" in prefix: - fail("layer prefix %r must be a safe relative archive path" % prefix) - if prefix in seen: - fail("duplicate layer prefix %r" % prefix) - seen[prefix] = True - -def _deploy_jar_label(binary): - """Return the implicit deploy-JAR label for a binary label string.""" - if type(binary) != "string": - fail("binary must be a label string so its deploy JAR can be derived") - if ":" in binary: - pkg, _, target_name = binary.rpartition(":") - return pkg + ":" + target_name + "_deploy.jar" - if binary.startswith("//") or binary.startswith("@"): - target_name = binary.split("/")[-1] - return binary + ":" + target_name + "_deploy.jar" - return binary + "_deploy.jar" +def _runtime_jars(binary): + """Return the runtime classpath exposed by a JVM binary target.""" + if hasattr(java_common, "JavaRuntimeClasspathInfo") and java_common.JavaRuntimeClasspathInfo in binary: + return binary[java_common.JavaRuntimeClasspathInfo].runtime_classpath.to_list() + return binary[JavaInfo].transitive_runtime_jars.to_list() def _group_key(artifact_id, depth): """Extract a grouping key from an artifact ID at the given depth. @@ -148,180 +121,6 @@ def _group_artifacts(artifact_ids, max_groups): # max_groups is 0: no artifact layers at all. return [] -def jvm_image_layers( - name, - binary, - layers = [], - maven_lock_file = None, - max_layers = 121, - layer_strategy = "group_by_prefix", - app_prefix = "/app", - path_prefix = "app/", - **kwargs): - """Creates layered tarballs from a java_binary or scala_binary deploy jar. - - Args: - name: target name - binary: label of a java_binary or scala_binary target - layers: list of path prefix strings; entries matching a prefix go into - a separate tar layer. Unmatched entries go to the fallback tar. - maven_lock_file: optional label of a maven lock file JSON. When set, - the aspect collects maven artifact IDs from deps and the tool - creates per-artifact tar layers using package prefixes from the - lock file. - max_layers: maximum number of artifact layers to generate (default 121). - Does not count explicit layers or the fallback tar. - layer_strategy: strategy when artifacts exceed max_layers. - "truncate": keep first N artifacts alphabetically, rest go to fallback. - "group_by_prefix": group artifacts by Maven group ID prefix (default). - app_prefix: classpath prefix inside the container (default "/app"). - path_prefix: prefix prepended to tar entry paths (default "app/"). - **kwargs: additional arguments passed to the underlying rule - """ - _validate_options(max_layers, app_prefix, path_prefix) - _validate_layer_prefixes(layers) - - _jvm_image_layers( - name = name, - binary = binary, - deploy_jar = _deploy_jar_label(binary), - layers = layers, - maven_lock_file = maven_lock_file, - max_layers = max_layers, - layer_strategy = layer_strategy, - app_prefix = app_prefix, - path_prefix = path_prefix, - **kwargs - ) - -def _jvm_image_layers_impl(ctx): - deploy_jar = ctx.file.deploy_jar - outputs = [] - inputs = [deploy_jar] - args = ctx.actions.args() - args.add("--input", deploy_jar) - - # Entrypoint shell script. - entrypoint = ctx.actions.declare_file(ctx.label.name + "_entrypoint.sh") - args.add("--entrypoint", entrypoint) - args.add("--app_prefix", ctx.attr.app_prefix) - args.add("--path_prefix", ctx.attr.path_prefix) - - # Fallback output tar (entries not matching any layer or artifact prefix). - fallback = ctx.actions.declare_file(ctx.label.name + ".tar") - args.add("--output", fallback) - outputs.append(fallback) - - # Per-layer output tars (explicit prefix layers). - for index, prefix in enumerate(ctx.attr.layers): - sanitized = _sanitize_prefix(prefix) - layer_out = ctx.actions.declare_file(ctx.label.name + ".explicit_%d.%s.tar" % (index, sanitized)) - args.add("--output_layer", prefix + "=" + layer_out.path) - outputs.append(layer_out) - - # Maven artifact layers via aspect. - if ctx.file.maven_lock_file: - lock_file = ctx.file.maven_lock_file - inputs.append(lock_file) - args.add("--maven_lock_file", lock_file) - - artifact_ids = sorted(ctx.attr.binary[MavenDepsInfo].artifacts.to_list()) - available_slots = ctx.attr.max_layers - strategy = ctx.attr.layer_strategy - - if len(artifact_ids) <= available_slots: - # Under the limit: one layer per artifact. - for index, artifact_id in enumerate(artifact_ids): - sanitized = _sanitize_artifact_id(artifact_id) - artifact_out = ctx.actions.declare_file(ctx.label.name + ".maven_%d." % index + sanitized + ".tar") - args.add("--artifact", artifact_id + "=" + artifact_out.path) - outputs.append(artifact_out) - elif strategy == "truncate": - # Truncate: first N artifacts get layers, rest fall to fallback. - for index, artifact_id in enumerate(artifact_ids[:available_slots]): - sanitized = _sanitize_artifact_id(artifact_id) - artifact_out = ctx.actions.declare_file(ctx.label.name + ".maven_%d." % index + sanitized + ".tar") - args.add("--artifact", artifact_id + "=" + artifact_out.path) - outputs.append(artifact_out) - elif strategy == "group_by_prefix": - # Group by Maven group prefix. - groups = _group_artifacts(artifact_ids, available_slots) - for index, (group_name, group_ids) in enumerate(groups): - sanitized = _sanitize_artifact_id(group_name) - group_out = ctx.actions.declare_file(ctx.label.name + ".maven_%d." % index + sanitized + ".tar") - if len(group_ids) == 1: - args.add("--artifact", group_ids[0] + "=" + group_out.path) - else: - args.add("--artifact_group", ",".join(group_ids) + "=" + group_out.path) - outputs.append(group_out) - - ctx.actions.run( - inputs = inputs, - outputs = outputs + [entrypoint], - executable = ctx.executable._tool, - arguments = [args], - mnemonic = "JvmImageLayers", - progress_message = "Splitting deploy jar into layers: %s" % ctx.label, - ) - - return [ - DefaultInfo(files = depset(outputs)), - OutputGroupInfo( - entrypoint = depset([entrypoint]), - ), - ] - -_jvm_image_layers = rule( - implementation = _jvm_image_layers_impl, - attrs = { - "binary": attr.label( - mandatory = True, - aspects = [_maven_deps_aspect], - doc = "The java_binary or scala_binary target.", - ), - "deploy_jar": attr.label( - mandatory = True, - allow_single_file = [".jar"], - doc = "The _deploy.jar implicit output of the java_ or scala_binary.", - ), - "layers": attr.string_list( - default = [], - doc = "Path prefixes for layer splitting. Each prefix gets its own output tar.", - ), - "maven_lock_file": attr.label( - allow_single_file = [".json"], - doc = "Maven lock file JSON for artifact-based layer splitting.", - ), - "max_layers": attr.int( - default = 121, - doc = "Maximum number of artifact layers. Does not count explicit layers or fallback.", - ), - "layer_strategy": attr.string( - default = "group_by_prefix", - values = ["truncate", "group_by_prefix"], - doc = "Strategy when artifacts exceed max_layers: 'truncate' or 'group_by_prefix'.", - ), - "app_prefix": attr.string( - doc = "Classpath prefix inside the container.", - mandatory = True, - ), - "path_prefix": attr.string( - default = "app/", - doc = "Path prefix prepended to tar entry names.", - ), - "_tool": attr.label( - default = "//cmd/executable_jar_splitter", - executable = True, - cfg = "exec", - doc = "The executable_jar_splitter Go binary.", - ), - }, -) - -# --------------------------------------------------------------------------- -# jvm_jar_layers: Keep individual JARs intact (preserves reference.conf etc.) -# --------------------------------------------------------------------------- - def jvm_jar_layers( name, binary, @@ -333,10 +132,8 @@ def jvm_jar_layers( **kwargs): """Creates layered tarballs containing individual dependency JARs. - Unlike jvm_image_layers which explodes the deploy jar into loose files, - this rule preserves each dependency JAR intact. This avoids resource - merge conflicts (reference.conf, META-INF/services/*) that occur when - singlejar merges duplicate entries. + This preserves each dependency JAR intact, avoiding resource merge + conflicts involving files such as reference.conf and META-INF/services/*. The container classpath uses Java's @file syntax to reference a classpath file listing all JARs. @@ -366,8 +163,9 @@ def jvm_jar_layers( ) def _jvm_jar_layers_impl(ctx): - # Collect all runtime JARs from the binary's JavaInfo. - runtime_jars = ctx.attr.binary[JavaInfo].transitive_runtime_jars.to_list() + runtime_jars = _runtime_jars(ctx.attr.binary) + if not runtime_jars: + fail("%s exposes no runtime JARs" % ctx.attr.binary.label) # Write a file listing all JAR paths for the tool to read. jar_list = ctx.actions.declare_file(ctx.label.name + "_jars.txt") diff --git a/pkg/jartar/BUILD.bazel b/pkg/jartar/BUILD.bazel deleted file mode 100644 index bf050f4..0000000 --- a/pkg/jartar/BUILD.bazel +++ /dev/null @@ -1,14 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "jartar", - srcs = ["jartar.go"], - importpath = "github.com/stackb/jvm_image/pkg/jartar", - visibility = ["//visibility:public"], -) - -go_test( - name = "jartar_test", - srcs = ["jartar_test.go"], - embed = [":jartar"], -) diff --git a/pkg/jartar/jartar.go b/pkg/jartar/jartar.go deleted file mode 100644 index 037dea4..0000000 --- a/pkg/jartar/jartar.go +++ /dev/null @@ -1,455 +0,0 @@ -// Package jartar splits an executable JAR into OCI-compatible tar layers. -package jartar - -import ( - "archive/tar" - "archive/zip" - "bufio" - "encoding/json" - "errors" - "fmt" - "io" - "math" - "os" - "path" - "sort" - "strings" -) - -// Layer defines an output layer with a path prefix and output file path. -type Layer struct { - Prefix string - OutputPath string -} - -// Artifact defines an artifact-based layer with its output path. -// Multiple artifacts may share the same OutputPath when grouped. -type Artifact struct { - ID string // e.g. "com.google.guava:guava" - OutputPath string -} - -// MavenLockFile represents the relevant parts of the maven lock file JSON. -type MavenLockFile struct { - Packages map[string][]string `json:"packages"` -} - -// SplitResult contains metadata extracted during the split operation. -type SplitResult struct { - MainClass string // e.g. "com.example.Main" -} - -// SplitOptions configures how a JAR is split into layered tars. -type SplitOptions struct { - InputPath string - FallbackPath string - Layers []Layer - MavenLockFilePath string - Artifacts []Artifact - PathPrefix string // prefix prepended to tar entry paths, e.g. "app/" - EntrypointPath string // path to write entrypoint shell script (optional) - AppPrefix string // classpath prefix in container, e.g. "/app" -} - -// Split reads a JAR file and distributes entries across layer tars. -// Routing priority: explicit layers first, then artifact-derived prefixes, then fallback. -// All output tars are always written, even if empty. -func Split(opts SplitOptions) (result *SplitResult, retErr error) { - if err := validateOptions(opts); err != nil { - return nil, err - } - - zr, err := zip.OpenReader(opts.InputPath) - if err != nil { - return nil, fmt.Errorf("opening jar: %w", err) - } - defer zr.Close() - - // Extract Main-Class from manifest. - mainClass, err := parseMainClass(zr) - if err != nil { - return nil, err - } - - // Build artifact prefix map if lock file is provided. - var artifactRoutes []artifactRoute - var openWriters []*writerState - defer func() { - var closeErr error - for i := len(openWriters) - 1; i >= 0; i-- { - closeErr = errors.Join(closeErr, openWriters[i].Close()) - } - if closeErr != nil { - result = nil - retErr = errors.Join(retErr, fmt.Errorf("closing output tar: %w", closeErr)) - } - }() - - if opts.MavenLockFilePath != "" && len(opts.Artifacts) > 0 { - lockFile, err := parseLockFile(opts.MavenLockFilePath) - if err != nil { - return nil, err - } - - // Deduplicate writers by output path so grouped artifacts share one tar. - writersByPath := make(map[string]*writerState) - routesByPrefix := make(map[string]artifactRoute) - - for _, a := range opts.Artifacts { - lw, ok := writersByPath[a.OutputPath] - if !ok { - lw, err = newWriterState(a.OutputPath) - if err != nil { - return nil, fmt.Errorf("creating artifact output %s: %w", a.OutputPath, err) - } - writersByPath[a.OutputPath] = lw - openWriters = append(openWriters, lw) - } - - // Map each package prefix for this artifact to the shared writer. - for _, pkg := range lockFile.Packages[a.ID] { - prefix := strings.ReplaceAll(pkg, ".", "/") + "/" - if err := validateArchivePath(prefix, true); err != nil { - return nil, fmt.Errorf("invalid package %q for artifact %q: %w", pkg, a.ID, err) - } - route := artifactRoute{prefix: prefix, outputPath: a.OutputPath, tw: lw.tw} - if existing, ok := routesByPrefix[prefix]; ok { - if existing.outputPath == "" || existing.outputPath == route.outputPath { - continue - } - // Split packages cannot be attributed safely after a deploy JAR - // has been merged. Keep them in the fallback layer. - routesByPrefix[prefix] = artifactRoute{prefix: prefix} - continue - } - routesByPrefix[prefix] = route - } - } - for _, route := range routesByPrefix { - artifactRoutes = append(artifactRoutes, route) - } - sort.Slice(artifactRoutes, func(i, j int) bool { - if len(artifactRoutes[i].prefix) != len(artifactRoutes[j].prefix) { - return len(artifactRoutes[i].prefix) > len(artifactRoutes[j].prefix) - } - return artifactRoutes[i].prefix < artifactRoutes[j].prefix - }) - } - - // Open explicit layer tar writers. - layerWriters := make([]*writerState, len(opts.Layers)) - for i, l := range opts.Layers { - lw, err := newWriterState(l.OutputPath) - if err != nil { - return nil, fmt.Errorf("creating layer output %s: %w", l.OutputPath, err) - } - openWriters = append(openWriters, lw) - layerWriters[i] = lw - } - - // Open fallback tar writer. - fallback, err := newWriterState(opts.FallbackPath) - if err != nil { - return nil, fmt.Errorf("creating fallback output: %w", err) - } - openWriters = append(openWriters, fallback) - - for _, f := range zr.File { - tw := resolveWriter(f.Name, opts.Layers, layerWriters, artifactRoutes, fallback.tw) - if err := writeEntry(tw, f, opts.PathPrefix); err != nil { - return nil, fmt.Errorf("writing entry %s: %w", f.Name, err) - } - } - - // Generate entrypoint script if requested. - if opts.EntrypointPath != "" { - if mainClass == "" { - return nil, fmt.Errorf("no Main-Class found in MANIFEST.MF; cannot generate entrypoint") - } - appPrefix := opts.AppPrefix - if appPrefix == "" { - appPrefix = "/app" - } - if err := writeEntrypoint(opts.EntrypointPath, appPrefix, mainClass); err != nil { - return nil, fmt.Errorf("writing entrypoint: %w", err) - } - } - - return &SplitResult{MainClass: mainClass}, nil -} - -// resolveWriter determines which tar writer should receive the given entry. -// Priority: explicit layers first, then artifact-derived prefixes, then fallback. -func resolveWriter( - name string, - layers []Layer, - layerWriters []*writerState, - artifactRoutes []artifactRoute, - fallback *tar.Writer, -) *tar.Writer { - // Check explicit layers first. - for i, l := range layers { - if strings.HasPrefix(name, l.Prefix) { - return layerWriters[i].tw - } - } - - // Check artifact-derived prefixes. - // Also check if the entry is an ancestor directory of a prefix - // (e.g. entry "com/google/" is ancestor of prefix "com/google/common/collect/"). - for _, route := range artifactRoutes { - if strings.HasPrefix(name, route.prefix) || strings.HasPrefix(route.prefix, name) { - if route.tw == nil { - return fallback - } - return route.tw - } - } - - return fallback -} - -type writerState struct { - file *os.File - tw *tar.Writer -} - -type artifactRoute struct { - prefix string - outputPath string - tw *tar.Writer -} - -func newWriterState(outputPath string) (*writerState, error) { - f, err := os.Create(outputPath) - if err != nil { - return nil, err - } - return &writerState{file: f, tw: tar.NewWriter(f)}, nil -} - -func (w *writerState) Close() error { - tarErr := w.tw.Close() - fileErr := w.file.Close() - return errors.Join(tarErr, fileErr) -} - -func validateOptions(opts SplitOptions) error { - if opts.InputPath == "" { - return errors.New("input path is required") - } - if opts.FallbackPath == "" { - return errors.New("fallback output path is required") - } - if err := validateArchivePath(opts.PathPrefix, true); err != nil { - return fmt.Errorf("invalid path prefix: %w", err) - } - if opts.EntrypointPath != "" && opts.AppPrefix != "" { - if !path.IsAbs(opts.AppPrefix) { - return errors.New("app prefix must be an absolute container path") - } - if strings.ContainsAny(opts.AppPrefix, ":\\\x00\r\n\t ") || hasParentPathSegment(opts.AppPrefix) { - return errors.New("app prefix contains an invalid character") - } - } - - outputs := map[string]string{opts.FallbackPath: "fallback output"} - for i, layer := range opts.Layers { - if layer.Prefix == "" { - return fmt.Errorf("layer %d has an empty prefix", i) - } - if err := validateArchivePath(layer.Prefix, false); err != nil { - return fmt.Errorf("invalid layer prefix %q: %w", layer.Prefix, err) - } - if layer.OutputPath == "" { - return fmt.Errorf("layer %q has an empty output path", layer.Prefix) - } - if owner, ok := outputs[layer.OutputPath]; ok { - return fmt.Errorf("output path %q is shared by %s and layer %q", layer.OutputPath, owner, layer.Prefix) - } - outputs[layer.OutputPath] = fmt.Sprintf("layer %q", layer.Prefix) - } - - artifactIDs := make(map[string]string) - for i, artifact := range opts.Artifacts { - if artifact.ID == "" { - return fmt.Errorf("artifact %d has an empty ID", i) - } - if artifact.OutputPath == "" { - return fmt.Errorf("artifact %q has an empty output path", artifact.ID) - } - if owner, ok := outputs[artifact.OutputPath]; ok { - return fmt.Errorf("output path %q is shared by %s and artifact %q", artifact.OutputPath, owner, artifact.ID) - } - if outputPath, ok := artifactIDs[artifact.ID]; ok && outputPath != artifact.OutputPath { - return fmt.Errorf("artifact %q is assigned to multiple outputs (%s and %s)", artifact.ID, outputPath, artifact.OutputPath) - } - artifactIDs[artifact.ID] = artifact.OutputPath - } - if len(opts.Artifacts) > 0 && opts.MavenLockFilePath == "" { - return errors.New("maven lock file is required when artifact layers are configured") - } - return nil -} - -func validateArchivePath(name string, allowEmpty bool) error { - if name == "" { - if allowEmpty { - return nil - } - return errors.New("path must not be empty") - } - if strings.ContainsRune(name, '\x00') || strings.ContainsAny(name, "\\\r\n") { - return errors.New("path contains an invalid character") - } - if path.IsAbs(name) { - return errors.New("path must be relative") - } - if allowEmpty && !strings.HasSuffix(name, "/") { - return errors.New("non-empty prefix must end with a slash") - } - cleaned := path.Clean(name) - if cleaned == ".." || strings.HasPrefix(cleaned, "../") { - return errors.New("path must not escape the archive root") - } - return nil -} - -func hasParentPathSegment(name string) bool { - for _, part := range strings.Split(name, "/") { - if part == ".." { - return true - } - } - return false -} - -func parseLockFile(path string) (*MavenLockFile, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("reading maven lock file: %w", err) - } - var lf MavenLockFile - if err := json.Unmarshal(data, &lf); err != nil { - return nil, fmt.Errorf("parsing maven lock file: %w", err) - } - return &lf, nil -} - -// parseMainClass finds and extracts the Main-Class attribute from the JAR's -// META-INF/MANIFEST.MF. Returns empty string if no manifest or no Main-Class. -func parseMainClass(zr *zip.ReadCloser) (string, error) { - for _, f := range zr.File { - if strings.EqualFold(f.Name, "META-INF/MANIFEST.MF") { - rc, err := f.Open() - if err != nil { - return "", fmt.Errorf("opening MANIFEST.MF: %w", err) - } - defer rc.Close() - data, err := io.ReadAll(rc) - if err != nil { - return "", fmt.Errorf("reading MANIFEST.MF: %w", err) - } - return extractMainClass(string(data)), nil - } - } - return "", nil -} - -// extractMainClass parses a MANIFEST.MF body and returns the Main-Class value. -// Handles the MANIFEST.MF continuation line format where lines >72 bytes are -// split with a newline followed by a single leading space. -func extractMainClass(manifest string) string { - // First, join continuation lines (lines starting with a single space). - var lines []string - scanner := bufio.NewScanner(strings.NewReader(manifest)) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, " ") && len(lines) > 0 { - lines[len(lines)-1] += line[1:] // append without the leading space - } else { - lines = append(lines, line) - } - } - for _, line := range lines { - // Only attributes in the main section apply to the JAR itself. - if line == "" || line == "\r" { - break - } - name, value, ok := strings.Cut(line, ":") - if ok && strings.EqualFold(name, "Main-Class") { - return strings.TrimSpace(value) - } - } - return "" -} - -// writeEntrypoint generates a shell script that runs the exploded JAR -// using java -cp. -func writeEntrypoint(path, appPrefix, mainClass string) error { - script := fmt.Sprintf("#!/bin/sh\nexec java ${JAVA_OPTS} -cp %s %s \"$@\"\n", shellQuote(appPrefix), shellQuote(mainClass)) - if err := os.WriteFile(path, []byte(script), 0755); err != nil { - return err - } - return os.Chmod(path, 0755) -} - -func shellQuote(value string) string { - return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" -} - -func writeEntry(tw *tar.Writer, f *zip.File, pathPrefix string) error { - if err := validateArchivePath(f.Name, false); err != nil { - return fmt.Errorf("invalid archive path: %w", err) - } - if f.UncompressedSize64 > math.MaxInt64 { - return fmt.Errorf("entry is too large: %d bytes", f.UncompressedSize64) - } - info := f.FileInfo() - isDir := info.IsDir() || strings.HasSuffix(f.Name, "/") - - perm := info.Mode().Perm() - if isDir { - // Ensure directories always have execute bits set. - // ZIP entries from jar tools often have zero or read-only permissions - // for directories, which prevents traversal when extracted. - if perm == 0 { - perm = 0755 - } else { - perm |= 0111 - } - } else if perm == 0 { - perm = 0644 - } - - hdr := &tar.Header{ - Name: pathPrefix + f.Name, - ModTime: f.Modified, - Mode: int64(perm), - } - - if isDir { - hdr.Typeflag = tar.TypeDir - } else { - hdr.Typeflag = tar.TypeReg - hdr.Size = int64(f.UncompressedSize64) - } - - if err := tw.WriteHeader(hdr); err != nil { - return err - } - - if !isDir { - rc, err := f.Open() - if err != nil { - return err - } - defer rc.Close() - - if _, err := io.Copy(tw, rc); err != nil { - return err - } - } - - return nil -} diff --git a/pkg/jartar/jartar_test.go b/pkg/jartar/jartar_test.go deleted file mode 100644 index b332be5..0000000 --- a/pkg/jartar/jartar_test.go +++ /dev/null @@ -1,905 +0,0 @@ -package jartar - -import ( - "archive/tar" - "archive/zip" - "encoding/json" - "io" - "os" - "path/filepath" - "strings" - "testing" -) - -// createTestJar creates a jar (zip) file at the given path with the specified entries. -// Each entry is a name->content pair. Names ending in "/" are directories. -func createTestJar(t *testing.T, path string, entries map[string]string) { - t.Helper() - f, err := os.Create(path) - if err != nil { - t.Fatal(err) - } - defer f.Close() - - zw := zip.NewWriter(f) - for name, content := range entries { - w, err := zw.Create(name) - if err != nil { - t.Fatal(err) - } - if content != "" { - if _, err := w.Write([]byte(content)); err != nil { - t.Fatal(err) - } - } - } - if err := zw.Close(); err != nil { - t.Fatal(err) - } -} - -// readTar reads a tar file and returns a map of entry name -> content. -func readTar(t *testing.T, path string) map[string]string { - t.Helper() - f, err := os.Open(path) - if err != nil { - t.Fatal(err) - } - defer f.Close() - - result := make(map[string]string) - tr := tar.NewReader(f) - for { - hdr, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - t.Fatal(err) - } - if hdr.Typeflag == tar.TypeReg { - data, err := io.ReadAll(tr) - if err != nil { - t.Fatal(err) - } - result[hdr.Name] = string(data) - } else { - result[hdr.Name] = "" - } - } - return result -} - -// createLockFile creates a maven lock file JSON with the given packages map. -func createLockFile(t *testing.T, path string, packages map[string][]string) { - t.Helper() - lf := MavenLockFile{Packages: packages} - data, err := json.Marshal(lf) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, data, 0644); err != nil { - t.Fatal(err) - } -} - -func TestSplit_NoLayers(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "test.jar") - fallbackPath := filepath.Join(dir, "fallback.tar") - - createTestJar(t, jarPath, map[string]string{ - "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", - "com/example/Main.class": "main-class-bytes", - }) - - if _, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: fallbackPath, - }); err != nil { - t.Fatal(err) - } - - entries := readTar(t, fallbackPath) - if _, ok := entries["META-INF/MANIFEST.MF"]; !ok { - t.Error("expected META-INF/MANIFEST.MF in fallback tar") - } - if _, ok := entries["com/example/Main.class"]; !ok { - t.Error("expected com/example/Main.class in fallback tar") - } -} - -func TestSplit_WithLayers(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "test.jar") - fallbackPath := filepath.Join(dir, "fallback.tar") - googleLayerPath := filepath.Join(dir, "google.tar") - exampleLayerPath := filepath.Join(dir, "example.tar") - - createTestJar(t, jarPath, map[string]string{ - "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", - "com/google/common/collect/Lists.class": "google-class-bytes", - "com/google/common/base/Strings.class": "google-strings-bytes", - "com/example/Main.class": "main-class-bytes", - "org/other/Lib.class": "other-bytes", - }) - - if _, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: fallbackPath, - Layers: []Layer{ - {Prefix: "com/google/", OutputPath: googleLayerPath}, - {Prefix: "com/example/", OutputPath: exampleLayerPath}, - }, - }); err != nil { - t.Fatal(err) - } - - googleEntries := readTar(t, googleLayerPath) - if len(googleEntries) != 2 { - t.Errorf("google layer: got %d entries, want 2", len(googleEntries)) - } - if _, ok := googleEntries["com/google/common/collect/Lists.class"]; !ok { - t.Error("expected Lists.class in google layer") - } - if _, ok := googleEntries["com/google/common/base/Strings.class"]; !ok { - t.Error("expected Strings.class in google layer") - } - - exampleEntries := readTar(t, exampleLayerPath) - if len(exampleEntries) != 1 { - t.Errorf("example layer: got %d entries, want 1", len(exampleEntries)) - } - if _, ok := exampleEntries["com/example/Main.class"]; !ok { - t.Error("expected Main.class in example layer") - } - - fallbackEntries := readTar(t, fallbackPath) - if _, ok := fallbackEntries["META-INF/MANIFEST.MF"]; !ok { - t.Error("expected MANIFEST.MF in fallback tar") - } - if _, ok := fallbackEntries["org/other/Lib.class"]; !ok { - t.Error("expected Lib.class in fallback tar") - } - for name := range fallbackEntries { - if name == "com/google/common/collect/Lists.class" || - name == "com/google/common/base/Strings.class" || - name == "com/example/Main.class" { - t.Errorf("unexpected entry %s in fallback tar", name) - } - } -} - -func TestSplit_EmptyLayers(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "test.jar") - fallbackPath := filepath.Join(dir, "fallback.tar") - emptyLayerPath := filepath.Join(dir, "empty.tar") - - createTestJar(t, jarPath, map[string]string{ - "com/example/Main.class": "main-class-bytes", - }) - - if _, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: fallbackPath, - Layers: []Layer{ - {Prefix: "org/nonexistent/", OutputPath: emptyLayerPath}, - }, - }); err != nil { - t.Fatal(err) - } - - emptyEntries := readTar(t, emptyLayerPath) - if len(emptyEntries) != 0 { - t.Errorf("empty layer: got %d entries, want 0", len(emptyEntries)) - } - - fallbackEntries := readTar(t, fallbackPath) - if _, ok := fallbackEntries["com/example/Main.class"]; !ok { - t.Error("expected Main.class in fallback tar") - } -} - -func TestSplit_FirstMatchWins(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "test.jar") - fallbackPath := filepath.Join(dir, "fallback.tar") - broadLayerPath := filepath.Join(dir, "broad.tar") - narrowLayerPath := filepath.Join(dir, "narrow.tar") - - createTestJar(t, jarPath, map[string]string{ - "com/google/common/base/Strings.class": "strings-bytes", - }) - - if _, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: fallbackPath, - Layers: []Layer{ - {Prefix: "com/", OutputPath: broadLayerPath}, - {Prefix: "com/google/", OutputPath: narrowLayerPath}, - }, - }); err != nil { - t.Fatal(err) - } - - broadEntries := readTar(t, broadLayerPath) - if _, ok := broadEntries["com/google/common/base/Strings.class"]; !ok { - t.Error("expected Strings.class in broad layer (first match wins)") - } - - narrowEntries := readTar(t, narrowLayerPath) - if len(narrowEntries) != 0 { - t.Errorf("narrow layer: got %d entries, want 0 (broad should have won)", len(narrowEntries)) - } -} - -func TestSplit_ArtifactLayers(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "test.jar") - fallbackPath := filepath.Join(dir, "fallback.tar") - guavaPath := filepath.Join(dir, "guava.tar") - jsr305Path := filepath.Join(dir, "jsr305.tar") - lockFilePath := filepath.Join(dir, "lock.json") - - // Include directory entries (as JARs typically do) to verify they route - // to artifact layers rather than leaking to the fallback. - createTestJar(t, jarPath, map[string]string{ - "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", - "com/": "", - "com/google/": "", - "com/google/common/": "", - "com/google/common/collect/": "", - "com/google/common/collect/Lists.class": "lists-bytes", - "com/google/common/base/": "", - "com/google/common/base/Strings.class": "strings-bytes", - "javax/": "", - "javax/annotation/": "", - "javax/annotation/Nonnull.class": "nonnull-bytes", - "example/": "", - "example/Main.class": "main-bytes", - }) - - createLockFile(t, lockFilePath, map[string][]string{ - "com.google.guava:guava": { - "com.google.common.collect", - "com.google.common.base", - }, - "com.google.code.findbugs:jsr305": { - "javax.annotation", - }, - }) - - if _, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: fallbackPath, - MavenLockFilePath: lockFilePath, - Artifacts: []Artifact{ - {ID: "com.google.guava:guava", OutputPath: guavaPath}, - {ID: "com.google.code.findbugs:jsr305", OutputPath: jsr305Path}, - }, - }); err != nil { - t.Fatal(err) - } - - guavaEntries := readTar(t, guavaPath) - if _, ok := guavaEntries["com/google/common/collect/Lists.class"]; !ok { - t.Error("expected Lists.class in guava layer") - } - if _, ok := guavaEntries["com/google/common/base/Strings.class"]; !ok { - t.Error("expected Strings.class in guava layer") - } - // Should also contain ancestor directory entries. - if _, ok := guavaEntries["com/google/common/collect/"]; !ok { - t.Error("expected com/google/common/collect/ dir in guava layer") - } - if _, ok := guavaEntries["com/google/common/base/"]; !ok { - t.Error("expected com/google/common/base/ dir in guava layer") - } - - jsr305Entries := readTar(t, jsr305Path) - if _, ok := jsr305Entries["javax/annotation/Nonnull.class"]; !ok { - t.Error("expected Nonnull.class in jsr305 layer") - } - if _, ok := jsr305Entries["javax/annotation/"]; !ok { - t.Error("expected javax/annotation/ dir in jsr305 layer") - } - - // Fallback should have META-INF, example dir + class. - // Shared ancestor dirs (com/, javax/) may go to an artifact layer. - fallbackEntries := readTar(t, fallbackPath) - if _, ok := fallbackEntries["META-INF/MANIFEST.MF"]; !ok { - t.Error("expected MANIFEST.MF in fallback") - } - if _, ok := fallbackEntries["example/Main.class"]; !ok { - t.Error("expected Main.class in fallback") - } - // Verify no artifact files leaked to fallback. - for name := range fallbackEntries { - if strings.HasPrefix(name, "com/google/") || strings.HasPrefix(name, "javax/annotation/") { - t.Errorf("unexpected artifact entry %s in fallback tar", name) - } - } -} - -func TestSplit_ExplicitLayersTakePriorityOverArtifacts(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "test.jar") - fallbackPath := filepath.Join(dir, "fallback.tar") - explicitLayerPath := filepath.Join(dir, "explicit.tar") - artifactPath := filepath.Join(dir, "artifact.tar") - lockFilePath := filepath.Join(dir, "lock.json") - - createTestJar(t, jarPath, map[string]string{ - "com/google/common/collect/Lists.class": "lists-bytes", - }) - - createLockFile(t, lockFilePath, map[string][]string{ - "com.google.guava:guava": { - "com.google.common.collect", - }, - }) - - // Explicit layer with broader prefix should win over artifact. - if _, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: fallbackPath, - MavenLockFilePath: lockFilePath, - Layers: []Layer{ - {Prefix: "com/google/", OutputPath: explicitLayerPath}, - }, - Artifacts: []Artifact{ - {ID: "com.google.guava:guava", OutputPath: artifactPath}, - }, - }); err != nil { - t.Fatal(err) - } - - explicitEntries := readTar(t, explicitLayerPath) - if _, ok := explicitEntries["com/google/common/collect/Lists.class"]; !ok { - t.Error("expected Lists.class in explicit layer (explicit takes priority)") - } - - artifactEntries := readTar(t, artifactPath) - if len(artifactEntries) != 0 { - t.Errorf("artifact layer: got %d entries, want 0 (explicit should have won)", len(artifactEntries)) - } -} - -func TestSplit_ArtifactNotInLockFile(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "test.jar") - fallbackPath := filepath.Join(dir, "fallback.tar") - artifactPath := filepath.Join(dir, "artifact.tar") - lockFilePath := filepath.Join(dir, "lock.json") - - createTestJar(t, jarPath, map[string]string{ - "com/example/Main.class": "main-bytes", - }) - - // Lock file has no packages for this artifact. - createLockFile(t, lockFilePath, map[string][]string{}) - - if _, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: fallbackPath, - MavenLockFilePath: lockFilePath, - Artifacts: []Artifact{ - {ID: "com.unknown:lib", OutputPath: artifactPath}, - }, - }); err != nil { - t.Fatal(err) - } - - // Artifact tar should be empty (no packages matched). - artifactEntries := readTar(t, artifactPath) - if len(artifactEntries) != 0 { - t.Errorf("artifact layer: got %d entries, want 0", len(artifactEntries)) - } - - // Everything goes to fallback. - fallbackEntries := readTar(t, fallbackPath) - if _, ok := fallbackEntries["com/example/Main.class"]; !ok { - t.Error("expected Main.class in fallback") - } -} - -func TestExtractMainClass(t *testing.T) { - tests := []struct { - name string - manifest string - want string - }{ - { - name: "standard", - manifest: "Manifest-Version: 1.0\nMain-Class: com.example.Main\n", - want: "com.example.Main", - }, - { - name: "with carriage return", - manifest: "Manifest-Version: 1.0\r\nMain-Class: com.example.Main\r\n", - want: "com.example.Main", - }, - { - name: "continuation line", - manifest: "Manifest-Version: 1.0\n" + - "Main-Class: com.example.very.long.package.name.MainApplicat\n" + - " ion\n", - want: "com.example.very.long.package.name.MainApplication", - }, - { - name: "missing main class", - manifest: "Manifest-Version: 1.0\n", - want: "", - }, - { - name: "empty manifest", - manifest: "", - want: "", - }, - { - name: "extra whitespace", - manifest: "Main-Class: com.example.Main \n", - want: "com.example.Main", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := extractMainClass(tt.manifest) - if got != tt.want { - t.Errorf("extractMainClass() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestSplit_EntrypointGeneration(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "test.jar") - fallbackPath := filepath.Join(dir, "fallback.tar") - entrypointPath := filepath.Join(dir, "entrypoint.sh") - - createTestJar(t, jarPath, map[string]string{ - "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\nMain-Class: com.example.Main\n", - "com/example/Main.class": "main-class-bytes", - }) - - result, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: fallbackPath, - EntrypointPath: entrypointPath, - AppPrefix: "/app", - }) - if err != nil { - t.Fatal(err) - } - - if result.MainClass != "com.example.Main" { - t.Errorf("MainClass = %q, want %q", result.MainClass, "com.example.Main") - } - - // Verify entrypoint file content. - data, err := os.ReadFile(entrypointPath) - if err != nil { - t.Fatal(err) - } - script := string(data) - wantScript := "#!/bin/sh\nexec java ${JAVA_OPTS} -cp '/app' 'com.example.Main' \"$@\"\n" - if script != wantScript { - t.Errorf("entrypoint script:\ngot: %q\nwant: %q", script, wantScript) - } - - // Verify file is executable. - info, err := os.Stat(entrypointPath) - if err != nil { - t.Fatal(err) - } - if info.Mode().Perm()&0111 == 0 { - t.Errorf("entrypoint should be executable, got mode %v", info.Mode().Perm()) - } -} - -func TestSplit_EntrypointNoManifest(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "test.jar") - fallbackPath := filepath.Join(dir, "fallback.tar") - entrypointPath := filepath.Join(dir, "entrypoint.sh") - - createTestJar(t, jarPath, map[string]string{ - "com/example/Main.class": "main-class-bytes", - }) - - _, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: fallbackPath, - EntrypointPath: entrypointPath, - }) - if err == nil { - t.Fatal("expected error when no MANIFEST.MF and entrypoint requested") - } - if !strings.Contains(err.Error(), "no Main-Class") { - t.Errorf("unexpected error: %v", err) - } -} - -func TestSplit_EntrypointMakesExistingFileExecutable(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "test.jar") - entrypointPath := filepath.Join(dir, "entrypoint.sh") - createTestJar(t, jarPath, map[string]string{ - "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\nMain-Class: com.example.Main\n", - }) - if err := os.WriteFile(entrypointPath, []byte("old"), 0644); err != nil { - t.Fatal(err) - } - - _, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: filepath.Join(dir, "fallback.tar"), - EntrypointPath: entrypointPath, - AppPrefix: "/app", - }) - if err != nil { - t.Fatal(err) - } - info, err := os.Stat(entrypointPath) - if err != nil { - t.Fatal(err) - } - if info.Mode().Perm() != 0755 { - t.Fatalf("entrypoint mode = %04o, want 0755", info.Mode().Perm()) - } -} - -func TestSplit_PathPrefix(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "test.jar") - fallbackPath := filepath.Join(dir, "fallback.tar") - - createTestJar(t, jarPath, map[string]string{ - "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", - "com/example/Main.class": "main-class-bytes", - }) - - if _, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: fallbackPath, - PathPrefix: "app/", - }); err != nil { - t.Fatal(err) - } - - entries := readTar(t, fallbackPath) - if _, ok := entries["app/META-INF/MANIFEST.MF"]; !ok { - t.Errorf("expected app/META-INF/MANIFEST.MF, got keys: %v", keys(entries)) - } - if _, ok := entries["app/com/example/Main.class"]; !ok { - t.Errorf("expected app/com/example/Main.class, got keys: %v", keys(entries)) - } -} - -func TestSplit_GroupedArtifacts(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "test.jar") - fallbackPath := filepath.Join(dir, "fallback.tar") - groupPath := filepath.Join(dir, "google_group.tar") - lockFilePath := filepath.Join(dir, "lock.json") - - createTestJar(t, jarPath, map[string]string{ - "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", - "com/google/common/collect/Lists.class": "lists-bytes", - "com/google/common/util/concurrent/internal/InternalFutureFailureAccess.class": "fa-bytes", - "javax/annotation/Nonnull.class": "nonnull-bytes", - "example/Main.class": "main-bytes", - }) - - createLockFile(t, lockFilePath, map[string][]string{ - "com.google.guava:guava": { - "com.google.common.collect", - }, - "com.google.guava:failureaccess": { - "com.google.common.util.concurrent.internal", - }, - "com.google.code.findbugs:jsr305": { - "javax.annotation", - }, - }) - - // Group guava + failureaccess + jsr305 all into one tar (simulating group_by_prefix). - // Multiple artifact IDs share the same OutputPath. - if _, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: fallbackPath, - MavenLockFilePath: lockFilePath, - Artifacts: []Artifact{ - {ID: "com.google.guava:guava", OutputPath: groupPath}, - {ID: "com.google.guava:failureaccess", OutputPath: groupPath}, - {ID: "com.google.code.findbugs:jsr305", OutputPath: groupPath}, - }, - }); err != nil { - t.Fatal(err) - } - - // All three artifacts' classes should be in the shared group tar. - groupEntries := readTar(t, groupPath) - if _, ok := groupEntries["com/google/common/collect/Lists.class"]; !ok { - t.Error("expected Lists.class in group layer") - } - if _, ok := groupEntries["com/google/common/util/concurrent/internal/InternalFutureFailureAccess.class"]; !ok { - t.Error("expected InternalFutureFailureAccess.class in group layer") - } - if _, ok := groupEntries["javax/annotation/Nonnull.class"]; !ok { - t.Error("expected Nonnull.class in group layer") - } - if len(groupEntries) != 3 { - t.Errorf("group layer: got %d entries, want 3: %v", len(groupEntries), keys(groupEntries)) - } - - // Fallback should have only META-INF and example. - fallbackEntries := readTar(t, fallbackPath) - if _, ok := fallbackEntries["META-INF/MANIFEST.MF"]; !ok { - t.Error("expected MANIFEST.MF in fallback") - } - if _, ok := fallbackEntries["example/Main.class"]; !ok { - t.Error("expected Main.class in fallback") - } - if len(fallbackEntries) != 2 { - t.Errorf("fallback: got %d entries, want 2: %v", len(fallbackEntries), keys(fallbackEntries)) - } -} - -// readTarHeaders reads a tar file and returns a map of entry name -> tar header. -func readTarHeaders(t *testing.T, path string) map[string]*tar.Header { - t.Helper() - f, err := os.Open(path) - if err != nil { - t.Fatal(err) - } - defer f.Close() - - result := make(map[string]*tar.Header) - tr := tar.NewReader(f) - for { - hdr, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - t.Fatal(err) - } - result[hdr.Name] = hdr - } - return result -} - -func TestSplit_DirectoryPermissions(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "test.jar") - fallbackPath := filepath.Join(dir, "fallback.tar") - - // ZIP directory entries typically have mode bits that include os.ModeDir - // but zero permission bits. The splitter must ensure directories get 0755. - createTestJar(t, jarPath, map[string]string{ - "META-INF/": "", - "META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n", - "com/": "", - "com/example/": "", - "com/example/Main.class": "main-class-bytes", - }) - - if _, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: fallbackPath, - }); err != nil { - t.Fatal(err) - } - - headers := readTarHeaders(t, fallbackPath) - - // Verify directory entries have execute bit set. - for _, dirName := range []string{"META-INF/", "com/", "com/example/"} { - hdr, ok := headers[dirName] - if !ok { - t.Errorf("missing directory entry %s", dirName) - continue - } - mode := os.FileMode(hdr.Mode) - if mode&0111 == 0 { - t.Errorf("directory %s has no execute bit: %04o", dirName, mode) - } - } - - // Verify regular file entries preserve their permissions from the ZIP. - for _, fileName := range []string{"META-INF/MANIFEST.MF", "com/example/Main.class"} { - hdr, ok := headers[fileName] - if !ok { - t.Errorf("missing file entry %s", fileName) - continue - } - mode := os.FileMode(hdr.Mode) - if mode&0400 == 0 { - t.Errorf("file %s is not readable: %04o", fileName, mode) - } - } -} - -func TestSplit_DirectoryPermissionsWithPathPrefix(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "test.jar") - fallbackPath := filepath.Join(dir, "fallback.tar") - - createTestJar(t, jarPath, map[string]string{ - "com/": "", - "com/example/": "", - "com/example/Main.class": "main-class-bytes", - }) - - if _, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: fallbackPath, - PathPrefix: "app/", - }); err != nil { - t.Fatal(err) - } - - headers := readTarHeaders(t, fallbackPath) - - for _, dirName := range []string{"app/com/", "app/com/example/"} { - hdr, ok := headers[dirName] - if !ok { - t.Errorf("missing directory entry %s", dirName) - continue - } - mode := os.FileMode(hdr.Mode) - if mode&0111 == 0 { - t.Errorf("directory %s has no execute bit: %04o", dirName, mode) - } - } -} - -func TestSplit_DirectoryPermissionsInLayers(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "test.jar") - fallbackPath := filepath.Join(dir, "fallback.tar") - layerPath := filepath.Join(dir, "layer.tar") - - createTestJar(t, jarPath, map[string]string{ - "com/": "", - "com/google/": "", - "com/google/common/": "", - "com/google/common/collect/": "", - "com/google/common/collect/Lists.class": "lists-bytes", - }) - - if _, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: fallbackPath, - Layers: []Layer{ - {Prefix: "com/google/", OutputPath: layerPath}, - }, - }); err != nil { - t.Fatal(err) - } - - headers := readTarHeaders(t, layerPath) - - for _, dirName := range []string{"com/google/", "com/google/common/", "com/google/common/collect/"} { - hdr, ok := headers[dirName] - if !ok { - t.Errorf("missing directory entry %s in layer", dirName) - continue - } - mode := os.FileMode(hdr.Mode) - if mode&0111 == 0 { - t.Errorf("directory %s in layer has no execute bit: %04o", dirName, mode) - } - } -} - -func keys(m map[string]string) []string { - result := make([]string, 0, len(m)) - for k := range m { - result = append(result, k) - } - return result -} - -func TestExtractMainClass_MainSectionAndCaseInsensitive(t *testing.T) { - manifest := "Manifest-Version: 1.0\r\nmain-class: com.example.Main\r\n\r\nName: other\r\nMain-Class: ignored.Entry\r\n" - if got := extractMainClass(manifest); got != "com.example.Main" { - t.Fatalf("extractMainClass() = %q, want %q", got, "com.example.Main") - } - - manifest = "Manifest-Version: 1.0\n\nName: other\nMain-Class: ignored.Entry\n" - if got := extractMainClass(manifest); got != "" { - t.Fatalf("extractMainClass() = %q, want empty value for entry-section attribute", got) - } -} - -func TestSplit_RejectsUnsafeArchivePath(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "unsafe.jar") - createTestJar(t, jarPath, map[string]string{"../escape.class": "bad"}) - - _, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: filepath.Join(dir, "fallback.tar"), - }) - if err == nil || !strings.Contains(err.Error(), "escape the archive root") { - t.Fatalf("Split() error = %v, want unsafe archive path error", err) - } -} - -func TestSplit_RejectsConflictingOutputPaths(t *testing.T) { - dir := t.TempDir() - shared := filepath.Join(dir, "shared.tar") - _, err := Split(SplitOptions{ - InputPath: filepath.Join(dir, "does-not-matter.jar"), - FallbackPath: shared, - Layers: []Layer{{Prefix: "com/", OutputPath: shared}}, - }) - if err == nil || !strings.Contains(err.Error(), "shared") { - t.Fatalf("Split() error = %v, want shared output error", err) - } -} - -func TestSplit_ArtifactLayersRequireLockFile(t *testing.T) { - dir := t.TempDir() - _, err := Split(SplitOptions{ - InputPath: filepath.Join(dir, "does-not-matter.jar"), - FallbackPath: filepath.Join(dir, "fallback.tar"), - Artifacts: []Artifact{{ - ID: "com.example:dep", - OutputPath: filepath.Join(dir, "dep.tar"), - }}, - }) - if err == nil || !strings.Contains(err.Error(), "lock file is required") { - t.Fatalf("Split() error = %v, want missing lock file error", err) - } -} - -func TestSplit_RejectsUnsafeEntrypointAppPrefix(t *testing.T) { - dir := t.TempDir() - _, err := Split(SplitOptions{ - InputPath: filepath.Join(dir, "does-not-matter.jar"), - FallbackPath: filepath.Join(dir, "fallback.tar"), - EntrypointPath: filepath.Join(dir, "entrypoint.sh"), - AppPrefix: "/app/../etc", - }) - if err == nil || !strings.Contains(err.Error(), "app prefix") { - t.Fatalf("Split() error = %v, want invalid app prefix error", err) - } -} - -func TestSplit_AmbiguousPackageUsesFallback(t *testing.T) { - dir := t.TempDir() - jarPath := filepath.Join(dir, "test.jar") - lockPath := filepath.Join(dir, "lock.json") - createTestJar(t, jarPath, map[string]string{"com/example/Main.class": "main"}) - createLockFile(t, lockPath, map[string][]string{ - "com.example:first": {"com.example"}, - "com.example:second": {"com.example"}, - }) - - firstPath := filepath.Join(dir, "first.tar") - secondPath := filepath.Join(dir, "second.tar") - fallbackPath := filepath.Join(dir, "fallback.tar") - _, err := Split(SplitOptions{ - InputPath: jarPath, - FallbackPath: fallbackPath, - MavenLockFilePath: lockPath, - Artifacts: []Artifact{ - {ID: "com.example:first", OutputPath: firstPath}, - {ID: "com.example:second", OutputPath: secondPath}, - }, - }) - if err != nil { - t.Fatal(err) - } - if _, ok := readTar(t, fallbackPath)["com/example/Main.class"]; !ok { - t.Fatal("ambiguous package entry was not written to fallback") - } - if len(readTar(t, firstPath)) != 0 || len(readTar(t, secondPath)) != 0 { - t.Fatal("ambiguous package entry was written to an artifact layer") - } -} From b4d24b5308fb0e27e48f5c4f30fb24405a5b1b12 Mon Sep 17 00:00:00 2001 From: Paul Johnston Date: Mon, 20 Jul 2026 14:09:29 -0600 Subject: [PATCH 5/5] renames, docs cleanup --- BUILD.bazel | 8 -------- README.md | 36 +++++++++++++++++++++++------------- cmd/jar_layerer/README.md | 22 +++++++++++++--------- jvm_jar_layers.bzl | 25 +++++++++++++------------ 4 files changed, 49 insertions(+), 42 deletions(-) diff --git a/BUILD.bazel b/BUILD.bazel index 8c8c69f..6112de5 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -1,14 +1,6 @@ -load("@bazel_skylib//:bzl_library.bzl", "bzl_library") - exports_files([ "MODULE.bazel", "jvm_jar_layers.bzl", ]) # gazelle:prefix github.com/stackb/jvm_image - -bzl_library( - name = "jvm_jar_layers", - srcs = ["jvm_jar_layers.bzl"], - visibility = ["//visibility:public"], -) diff --git a/README.md b/README.md index 25a62cb..a092db7 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,12 @@ The public API is pre-1.0. Pin clients to a release tag or commit and review upgrades before changing the pin. `jvm_jar_layers` keeps every runtime JAR intact under `/app/lib`, preserving -duplicate resources and the normal JVM classpath model. It always produces a -fallback tar for unmatched content. With a -`rules_jvm_external` lock file, Maven dependencies can be placed in separate or -grouped layers so application-only changes reuse dependency layers. +duplicate resources and the normal JVM classpath model. Maven is not required: +without Maven metadata, the rule places the runtime classpath and its classpath +file in one fallback tar. With a `rules_jvm_external` lock file, Maven +dependencies can instead be placed in separate or grouped layers so +application-only changes retain the same dependency-layer digests for +registry- and fleet-wide cache reuse. ## Install @@ -37,6 +39,16 @@ Do not point production clients at a moving branch. ```starlark load("@jvm_image//:jvm_jar_layers.bzl", "jvm_jar_layers") +jvm_jar_layers( + name = "server_layers", + binary = ":server", +) +``` + +To partition recognized Maven dependencies into stable cacheable layers, pass +the lock file produced by a `rules_jvm_external` `maven.install` repository: + +```starlark jvm_jar_layers( name = "server_layers", binary = ":server", @@ -58,12 +70,16 @@ entrypoint = [ ``` The generated `classpath` file is included in the fallback tar. It is also -available through the target's `classpath` output group. +available through the target's `classpath` output group. The `binary` must +provide a non-empty JVM runtime classpath; analysis fails otherwise. ## Configuration notes -- `max_layers` limits Maven-derived tar layers. It excludes the fallback tar. - Set it to `0` to disable Maven-derived layers. +- `maven_lock_file` is optional. When omitted, every runtime JAR goes to the + fallback tar. +- `max_layers` limits Maven-derived tar layers. It excludes the fallback tar + and has no effect without `maven_lock_file`. Set it to `0` to disable + Maven-derived layers. - `layer_strategy = "group_by_prefix"` groups Maven coordinates when their count exceeds `max_layers`; `"truncate"` sends the excess to the fallback. - `path_prefix` is a relative archive prefix and must end in `/` when non-empty. @@ -88,9 +104,3 @@ bazel build //:image The example in [`example/hello`](example/hello) demonstrates `jvm_jar_layers` with `rules_img`. - -## Release checklist - -Before onboarding a client, pin a green commit, choose and add a repository -license, and publish a matching `v0.1.x` tag. A license and hosted release are -intentionally not inferred by this repository. diff --git a/cmd/jar_layerer/README.md b/cmd/jar_layerer/README.md index e48cc62..3097552 100644 --- a/cmd/jar_layerer/README.md +++ b/cmd/jar_layerer/README.md @@ -1,6 +1,6 @@ # jar_layerer -Distributes individual JVM dependency JARs across OCI container layer tarballs +Distributes individual JVM runtime JARs across OCI container layer tarballs for efficient caching, while preserving each JAR intact. ## Why this exists @@ -20,7 +20,7 @@ preserves the per-JAR resource layout used by a normal JVM runtime classpath. ``` scala_binary - └─ JavaInfo.transitive_runtime_jars (individual JARs from Bazel) + └─ JVM runtime classpath (individual JARs from Bazel) │ ▼ jar_layerer @@ -31,9 +31,9 @@ scala_binary └─ fallback.tar ← unmatched JARs + classpath file ``` -Each output tar contains intact `.jar` files under a configurable path prefix -(default `app/lib/`). A classpath file is written both to disk (for Bazel) and -into the fallback tar (so it's present in the container at runtime). +JAR entries are stored intact under a configurable path prefix (default +`app/lib/`). A classpath file is written both to disk (for Bazel) and into the +fallback tar (so it's present in the container at runtime). The container entrypoint uses Java's `@file` syntax to read the classpath: @@ -43,8 +43,9 @@ java ${JAVA_OPTS} -cp @/app/lib/classpath com.example.Main ## Artifact routing -When a Maven lock file is provided, the tool inspects each JAR to determine -which Maven artifact it belongs to: +Maven integration is optional. When a `rules_jvm_external` lock file is +provided, the tool inspects each JAR to determine which Maven artifact it +belongs to: 1. Open the JAR (ZIP) and inspect its `.class` entries. 2. Derive Java packages from class paths (e.g. @@ -101,8 +102,11 @@ jvm_jar_layers( ``` The rule: -- Collects `transitive_runtime_jars` from the binary's `JavaInfo` provider. -- Uses `_maven_deps_aspect` to collect Maven artifact IDs from `maven_coordinates=` tags. + +- Collects the binary's runtime classpath from `JavaRuntimeClasspathInfo`, with + `JavaInfo.transitive_runtime_jars` as a compatibility fallback. +- Uses `_maven_artifacts_aspect` and `_MavenArtifactsInfo` to collect Maven + artifact IDs from `maven_coordinates=` tags. - Writes a jar list file and passes it to `jar_layerer`. - Declares per-artifact (or per-group) output tars. - Outputs all tars via `DefaultInfo` and the classpath file via `OutputGroupInfo`. diff --git a/jvm_jar_layers.bzl b/jvm_jar_layers.bzl index 89e3e49..9e5e272 100644 --- a/jvm_jar_layers.bzl +++ b/jvm_jar_layers.bzl @@ -3,14 +3,14 @@ load("@rules_java//java/common:java_common.bzl", "java_common") load("@rules_java//java/common:java_info.bzl", "JavaInfo") -MavenDepsInfo = provider( - doc = "Collects maven artifact IDs from jvm_import dependencies.", +_MavenArtifactsInfo = provider( + doc = "Collects Maven artifact IDs from jvm_import dependencies.", fields = { "artifacts": "depset of artifact ID strings (group:name)", }, ) -def _maven_deps_aspect_impl(_target, ctx): +def _maven_artifacts_aspect_impl(_target, ctx): artifacts = [] # Check tags for maven_coordinates. @@ -28,15 +28,15 @@ def _maven_deps_aspect_impl(_target, ctx): for attr_name in ("deps", "exports", "runtime_deps"): if hasattr(ctx.rule.attr, attr_name): for dep in getattr(ctx.rule.attr, attr_name): - if MavenDepsInfo in dep: - transitive.append(dep[MavenDepsInfo].artifacts) + if _MavenArtifactsInfo in dep: + transitive.append(dep[_MavenArtifactsInfo].artifacts) - return [MavenDepsInfo( + return [_MavenArtifactsInfo( artifacts = depset(direct = artifacts, transitive = transitive), )] -_maven_deps_aspect = aspect( - implementation = _maven_deps_aspect_impl, +_maven_artifacts_aspect = aspect( + implementation = _maven_artifacts_aspect_impl, attr_aspects = ["deps", "exports", "runtime_deps"], ) @@ -141,8 +141,9 @@ def jvm_jar_layers( Args: name: target name binary: label of a java_binary or scala_binary target - maven_lock_file: optional label of a maven lock file JSON for - artifact-based layer grouping. + maven_lock_file: optional label of a rules_jvm_external lock file JSON + for Maven artifact-based layer grouping. When omitted, all runtime + JARs are written to the fallback tar. max_layers: maximum number of artifact layers (default 121). layer_strategy: strategy when artifacts exceed max_layers. app_prefix: classpath prefix inside the container (default "/app/lib"). @@ -196,7 +197,7 @@ def _jvm_jar_layers_impl(ctx): inputs.append(lock_file) args.add("--maven_lock_file", lock_file) - artifact_ids = sorted(ctx.attr.binary[MavenDepsInfo].artifacts.to_list()) + artifact_ids = sorted(ctx.attr.binary[_MavenArtifactsInfo].artifacts.to_list()) available_slots = ctx.attr.max_layers strategy = ctx.attr.layer_strategy @@ -246,7 +247,7 @@ _jvm_jar_layers = rule( attrs = { "binary": attr.label( mandatory = True, - aspects = [_maven_deps_aspect], + aspects = [_maven_artifacts_aspect], doc = "The java_binary or scala_binary target.", ), "maven_lock_file": attr.label(