diff --git a/.bazelrc b/.bazelrc index 6cde0884c..bdd0e91a9 100644 --- a/.bazelrc +++ b/.bazelrc @@ -26,6 +26,13 @@ build --java_runtime_version=remotejdk_11 # https://github.com/GoogleContainerTools/rules_distroless/actions/runs/7118944984/job/19382981899?pr=9#step:8:51 common:linux --sandbox_tmpfs_path=/tmp + +# Attach validation actions to catch merged-usr convention errors in image layers. +# The aspect uses attr_aspects = ["tars"], so it propagates along the tars dependency +# chain of every oci_image target: oci_image -> pkg_tar/tar -> individual packages. +# Bazel automatically requests the _validation output group at the end of the build. +common --aspects=//private/util:validate_usr_symlinks.bzl%validate_usr_symlinks + # Load any settings specific to the current user. # .bazelrc.user should appear in .gitignore so that settings are not shared with team members # This needs to be last statement in this diff --git a/MODULE.bazel b/MODULE.bazel index 4c8845f64..4b977c407 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -11,9 +11,10 @@ bazel_dep(name = "gazelle", version = "0.38.0") bazel_dep(name = "rules_rust", version = "0.63.0") bazel_dep(name = "container_structure_test", version = "1.19.1") bazel_dep(name = "rules_oci", version = "2.2.7") -bazel_dep(name = "rules_distroless", version = "0.6.2") +bazel_dep(name = "rules_distroless", version = "0.8.0") bazel_dep(name = "rules_python", version = "1.5.3") bazel_dep(name = "rules_cc", version = "0.2.4") +bazel_dep(name = "gawk", version = "5.3.2.bcr.5") ### OCI ### # Note: rules_oci registers toolchains in its MODULE.bazel diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index c389a8bb8..5c20ecc31 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -59,7 +59,8 @@ "https://bcr.bazel.build/modules/container_structure_test/1.19.1/source.json": "98bd2b2fc172fdc8a9af68f80cf74588ab3a8ab334ccd66415c681570cfab780", "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8", "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.3/MODULE.bazel": "f1b7bb2dd53e8f2ef984b39485ec8a44e9076dda5c4b8efd2fb4c6a6e856a31d", - "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.3/source.json": "ebe931bfe362e4b41e59ee00a528db6074157ff2ced92eb9e970acab2e1089c9", + "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.5/MODULE.bazel": "adb2b28dce321ea13d151b8a7057800b1e3dd6c26f65b2be8703574b1dfcad74", + "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.5/source.json": "450cd9d6c79bc8abb4096e4fffa3b5302c903949db22931411546b89708ca612", "https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8", "https://bcr.bazel.build/modules/gazelle/0.33.0/MODULE.bazel": "a13a0f279b462b784fb8dd52a4074526c4a2afe70e114c7d09066097a46b3350", "https://bcr.bazel.build/modules/gazelle/0.34.0/MODULE.bazel": "abdd8ce4d70978933209db92e436deb3a8b737859e9354fb5fd11fb5c2004c8a", @@ -116,8 +117,8 @@ "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", "https://bcr.bazel.build/modules/rules_cc/0.2.4/source.json": "2bd87ef9b41d4753eadf65175745737135cba0e70b479bdc204ef0c67404d0c4", - "https://bcr.bazel.build/modules/rules_distroless/0.6.2/MODULE.bazel": "eafe8b473c0c78038d9a6e1dbd9278b7bf83457ddc78207297483bfbe1012d56", - "https://bcr.bazel.build/modules/rules_distroless/0.6.2/source.json": "830d7a25ce382e571e58791fbed3b47e6da3237805624bab8997ae59d763bdc2", + "https://bcr.bazel.build/modules/rules_distroless/0.8.0/MODULE.bazel": "a3e473943b685190e20668f5149f62a116fdcbeba66c7b9778f71d2aed4533c1", + "https://bcr.bazel.build/modules/rules_distroless/0.8.0/source.json": "a6931e20d97a13675693adb8b6cd3a0e6eb5218db43dfc83b75566ddc4158efc", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/source.json": "c8b1e2c717646f1702290959a3302a178fb639d987ab61d548105019f11e527e", @@ -183,7 +184,8 @@ "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b", "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592", - "https://bcr.bazel.build/modules/rules_shell/0.4.1/source.json": "4757bd277fe1567763991c4425b483477bb82e35e777a56fd846eb5cceda324a", + "https://bcr.bazel.build/modules/rules_shell/0.5.1/MODULE.bazel": "8b61403b53a9cbfa06e52b12a4153454056472c54245b1272f6dbf3b7ab8a40c", + "https://bcr.bazel.build/modules/rules_shell/0.5.1/source.json": "a1c05b75b9136510a5528c0d7e9b0ca7d469662b015ea5cbb42e8a13b6628c89", "https://bcr.bazel.build/modules/stardoc/0.5.0/MODULE.bazel": "f9f1f46ba8d9c3362648eea571c6f9100680efc44913618811b58cc9c02cd678", "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c", @@ -1323,7 +1325,7 @@ "@@yq.bzl~//yq:extensions.bzl%yq": { "general": { "bzlTransitiveDigest": "61Uz+o5PnlY0jJfPZEUNqsKxnM/UCLeWsn5VVCc8u5Y=", - "usagesDigest": "QPK7lu2+tSsNK2XlRCJLSmJbDRi4Fd7RUpmlb0VJxRM=", + "usagesDigest": "o7DFzmGH1ts/lXfAGd6j8rLEO0Jmd/ScTHZfAJ2gD6M=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, "envVariables": {}, diff --git a/private/util/BUILD b/private/util/BUILD index e69de29bb..087026d06 100644 --- a/private/util/BUILD +++ b/private/util/BUILD @@ -0,0 +1,11 @@ +exports_files(["validate_usr_symlinks.awk"]) + +sh_test( + name = "validate_usr_symlinks_test", + srcs = ["validate_usr_symlinks_test.sh"], + data = [ + "validate_usr_symlinks.awk", + "@bazel_tools//tools/bash/runfiles", + "@gawk", + ], +) diff --git a/private/util/validate_usr_symlinks.awk b/private/util/validate_usr_symlinks.awk new file mode 100644 index 000000000..a51ac9f03 --- /dev/null +++ b/private/util/validate_usr_symlinks.awk @@ -0,0 +1,39 @@ +BEGIN { + # Mapping from root-level path to expected symlink destination. + # https://github.com/floppym/merge-usr/blob/15dd02207bdee7ca6720d7024e8c0ffdc166ed23/merge-usr#L17-L25 + # Note: Debian does NOT merge /usr/sbin into /usr/bin, so /sbin -> usr/sbin. + expected["bin"] = "usr/bin" + expected["sbin"] = "usr/sbin" + expected["lib"] = "usr/lib" + expected["lib32"] = "usr/lib32" + expected["lib64"] = "usr/lib64" + expected["libx32"] = "usr/libx32" + prefixes = "bin|sbin|lib|lib32|lib64|libx32" +} +{ + original_path = $1 + path = original_path + # Normalize: strip leading ./ or / + sub(/^\.\//, "", path) + sub(/^\//, "", path) + + if (path in expected) { + if ($0 !~ /type=link/) { + VIOLATIONS[original_path] = original_path " is not a symlink (must link to " expected[path] ")" + } else if (match($0, / link=([^ \t]+)/, dest) && dest[1] != expected[path]) { + VIOLATIONS[original_path] = original_path " symlinks to '" dest[1] "' instead of '" expected[path] "'" + } + } else if (path ~ ("^(" prefixes ")/")) { + VIOLATIONS[original_path] = original_path " found under a merged-usr symlink path (should not exist)" + } +} +END { + for (violation in VIOLATIONS) { + print "VIOLATION: " VIOLATIONS[violation] " violates usr-merge convention." + print violation + } + if (length(VIOLATIONS) > 0) { + exit 1 + } + print "" > validation_output_file +} diff --git a/private/util/validate_usr_symlinks.bzl b/private/util/validate_usr_symlinks.bzl new file mode 100644 index 000000000..6faa55055 --- /dev/null +++ b/private/util/validate_usr_symlinks.bzl @@ -0,0 +1,66 @@ +"Bazel aspect to validate merged-usr conventions in tar files." + +load("@aspect_bazel_lib//lib:tar.bzl", "tar_lib") + +# https://wiki.gentoo.org/wiki/Merge-usr +# https://salsa.debian.org/md/usrmerge/raw/master/debian/README.Debian +# https://www.freedesktop.org/wiki/Software/systemd/TheCaseForTheUsrMerge/ +# Mapping taken from https://github.com/floppym/merge-usr/blob/15dd02207bdee7ca6720d7024e8c0ffdc166ed23/merge-usr#L17-L25 +# https://salsa.debian.org/md/usrmerge/-/tree/master/debian?ref_type=heads + +def _validate_usr_symlink_impl(target, ctx): + if target.label.name.find("debian12") != -1: + return [] + + if not hasattr(ctx.rule.files, "tars"): + return [] + bsdtar = ctx.toolchains[tar_lib.toolchain_type] + + output = ctx.actions.declare_file(target.label.name + ".mtree") + + args = ctx.actions.args() + args.add("--create") + args.add("--file", output) + args.add("--format=mtree") + args.add_all(ctx.rule.files.tars, format_each = "@%s") + + ctx.actions.run( + executable = bsdtar.tarinfo.binary, + inputs = ctx.rule.files.tars, + outputs = [output], + tools = bsdtar.default.files, + arguments = [args], + mnemonic = "PackageListing", + ) + + validation_output = ctx.actions.declare_file(target.label.name + ".validation") + ctx.actions.run( + executable = ctx.executable._awk, + inputs = [output, ctx.file._validate_symlinks], + outputs = [validation_output], + arguments = [ + "-v", + "validation_output_file=" + validation_output.path, + "-f", + ctx.file._validate_symlinks.path, + output.path, + ], + mnemonic = "ValidateUsrSymlinks", + ) + + return [ + OutputGroupInfo(_validation = depset([validation_output])), + ] + +validate_usr_symlinks = aspect( + implementation = _validate_usr_symlink_impl, + attrs = { + "_awk": attr.label(default = "@gawk//:gawk", cfg = "exec", executable = True), + "_validate_symlinks": attr.label( + default = "//private/util:validate_usr_symlinks.awk", + allow_single_file = True, + ), + }, + attr_aspects = ["tars"], + toolchains = [tar_lib.toolchain_type], +) diff --git a/private/util/validate_usr_symlinks_test.sh b/private/util/validate_usr_symlinks_test.sh new file mode 100755 index 000000000..9d391a770 --- /dev/null +++ b/private/util/validate_usr_symlinks_test.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Bazel runfiles resolution +# shellcheck source=/dev/null +source "${RUNFILES_DIR:-/dev/null}/bazel_tools/tools/bash/runfiles/runfiles.bash" 2>/dev/null || + source "$(dirname "$0")/../bazel_tools/tools/bash/runfiles/runfiles.bash" 2>/dev/null || + { echo >&2 "ERROR: cannot find runfiles.bash"; exit 1; } + +GAWK="$(rlocation gawk/gawk)" +AWK_SCRIPT="$(rlocation distroless/private/util/validate_usr_symlinks.awk)" + +run() { + printf '%s\n' "$1" | "$GAWK" -v validation_output_file=/dev/null -f "$AWK_SCRIPT" +} + +fail() { echo "FAIL: $*" >&2; exit 1; } + +# --- passing cases --- + +run "./bin type=link mode=0777 nlink=1 uid=0 gid=0 link=usr/bin" \ + || fail "./bin -> usr/bin should pass" + +run "/bin type=link mode=0777 nlink=1 uid=0 gid=0 link=usr/bin" \ + || fail "/bin -> usr/bin should pass" + +run "bin type=link mode=0777 nlink=1 uid=0 gid=0 link=usr/bin" \ + || fail "bin -> usr/bin should pass" + +run "./sbin type=link mode=0777 nlink=1 uid=0 gid=0 link=usr/sbin" \ + || fail "./sbin -> usr/sbin should pass" + +run "./lib type=link mode=0777 nlink=1 uid=0 gid=0 link=usr/lib" \ + || fail "./lib -> usr/lib should pass" + +run "./lib32 type=link mode=0777 nlink=1 uid=0 gid=0 link=usr/lib32" \ + || fail "./lib32 -> usr/lib32 should pass" + +run "./lib64 type=link mode=0777 nlink=1 uid=0 gid=0 link=usr/lib64" \ + || fail "./lib64 -> usr/lib64 should pass" + +run "./lib64 type=link mode=0777 nlink=1 uid=0 gid=0 link=usr/lib64 +./usr/bin/ls type=file mode=0755 nlink=1 uid=0 gid=0 size=12345" \ + || fail "content under ./usr/ alongside valid symlinks should pass" + +# --- failing cases --- + +run "./bin type=dir mode=0755 nlink=2 uid=0 gid=0" \ + && fail "./bin as a directory should fail" || true + +run "./bin type=link mode=0777 nlink=1 uid=0 gid=0 link=usr/sbin" \ + && fail "./bin -> usr/sbin should fail" || true + +run "bin type=link mode=0777 nlink=1 uid=0 gid=0 link=usr/fin" \ + && fail "bin -> usr/fin should fail" || true + +run "/bin type=link mode=0777 nlink=1 uid=0 gid=0 link=usr/fin" \ + && fail "/bin -> usr/fin should fail" || true + +run "./sbin type=link mode=0777 nlink=1 uid=0 gid=0 link=usr/bin" \ + && fail "./sbin -> usr/bin should fail (Debian keeps sbin separate)" || true + +run "./lib/libfoo.so.1 type=file mode=0644 nlink=1 uid=0 gid=0 size=4096" \ + && fail "content under ./lib/ should fail" || true + +run "lib/libfoo.so.1 type=file mode=0644 nlink=1 uid=0 gid=0 size=4096" \ + && fail "content under lib/ should fail" || true + +run "/lib/libfoo.so.1 type=file mode=0644 nlink=1 uid=0 gid=0 size=4096" \ + && fail "content under /lib/ should fail" || true + +run "./bin/ls type=file mode=0755 nlink=1 uid=0 gid=0 size=12345" \ + && fail "content under ./bin/ should fail" || true + +echo "All tests passed."