From 6d6e1016ccb870f889c409db05ac3cf495b82f99 Mon Sep 17 00:00:00 2001 From: Alejandro R Mosteo Date: Mon, 4 Sep 2023 10:56:25 +0200 Subject: [PATCH] Add compiler version to build hash (#1442) * Update pins and dependencies * Include GPR externals in hash computation * Avoid recursivity when loading environment * Test of hashing externals * Add compiler version to the build hash * Tests for compiler in build hash input * Improvements to test --- src/alire/alire-builds-hashes.adb | 9 +++ src/alire/alire-roots.adb | 10 ++- src/alire/alire-roots.ads | 5 ++ src/alire/alire-toolchains-solutions.adb | 61 ++++++++++++++- src/alire/alire-toolchains-solutions.ads | 11 ++- testsuite/drivers/alr.py | 21 ++++-- testsuite/drivers/builds.py | 8 ++ testsuite/drivers/helpers.py | 2 +- .../gnat_external/gnat_external-external.toml | 12 +++ .../gn/gnat_native/gnat_native-7777.0.0.toml | 11 +++ .../gn/gnat_native/gnat_native-8888.0.0.toml | 15 ++++ .../he/hello/hello-1.0.0.toml | 16 ++++ .../he/hello/hello-1.0.1.toml | 29 +++++++ .../fixtures/build_hash_index/index.toml | 1 + .../li/libhello/libhello-1.0.0.toml | 23 ++++++ .../ma/make/make-external.toml | 8 ++ .../tests/build/hashes/compiler-input/test.py | 75 +++++++++++++++++++ .../build/hashes/compiler-input/test.yaml | 4 + .../build/hashes/compiler-missing/test.py | 19 +++++ .../build/hashes/compiler-missing/test.yaml | 4 + .../build/hashes/hashing-inputs/test.yaml | 2 +- testsuite/tests/config/shared-deps/test.yaml | 2 +- .../dockerized/misc/default-cache/test.py | 2 +- 23 files changed, 333 insertions(+), 17 deletions(-) create mode 100644 testsuite/fixtures/build_hash_index/gn/gnat_external/gnat_external-external.toml create mode 100644 testsuite/fixtures/build_hash_index/gn/gnat_native/gnat_native-7777.0.0.toml create mode 100644 testsuite/fixtures/build_hash_index/gn/gnat_native/gnat_native-8888.0.0.toml create mode 100644 testsuite/fixtures/build_hash_index/he/hello/hello-1.0.0.toml create mode 100644 testsuite/fixtures/build_hash_index/he/hello/hello-1.0.1.toml create mode 100644 testsuite/fixtures/build_hash_index/index.toml create mode 100644 testsuite/fixtures/build_hash_index/li/libhello/libhello-1.0.0.toml create mode 100644 testsuite/fixtures/build_hash_index/ma/make/make-external.toml create mode 100644 testsuite/tests/build/hashes/compiler-input/test.py create mode 100644 testsuite/tests/build/hashes/compiler-input/test.yaml create mode 100644 testsuite/tests/build/hashes/compiler-missing/test.py create mode 100644 testsuite/tests/build/hashes/compiler-missing/test.yaml diff --git a/src/alire/alire-builds-hashes.adb b/src/alire/alire-builds-hashes.adb index 5c1dce89..5bc120eb 100644 --- a/src/alire/alire-builds-hashes.adb +++ b/src/alire/alire-builds-hashes.adb @@ -147,6 +147,15 @@ package body Alire.Builds.Hashes is -- Configuration variables -- TBD + -- Compiler version. Changing compiler will result in incompatible + -- ALI files and produce rebuilds, so it must be part of the hash. + -- Incidentally, this serves to separate by cross-target too. + declare + Compiler : constant Releases.Release := Root.Compiler; + begin + Add ("version", Compiler.Name.As_String, Compiler.Version.Image); + end; + -- Dependencies recursive hash? Since a crate can use a dependency -- config spec, it is possible in the worst case for a crate to -- require unique builds that include their dependencies hash diff --git a/src/alire/alire-roots.adb b/src/alire/alire-roots.adb index f52b2662..1080d1d7 100644 --- a/src/alire/alire-roots.adb +++ b/src/alire/alire-roots.adb @@ -2,7 +2,6 @@ with Ada.Unchecked_Deallocation; with Alire.Builds; with Alire.Conditional; -with Alire.Config; with Alire.Dependencies.Containers; with Alire.Directories; with Alire.Environment; @@ -16,7 +15,7 @@ with Alire.Properties.Actions.Executor; with Alire.Roots.Optional; with Alire.Solutions.Diffs; with Alire.Spawn; -with Alire.Toolchains; +with Alire.Toolchains.Solutions; with Alire.User_Pins.Maps; with Alire.Utils.TTY; with Alire.Utils.User_Input; @@ -251,6 +250,13 @@ package body Alire.Roots is return This.Build_Hasher.Hash (Name); end Build_Hash; + -------------- + -- Compiler -- + -------------- + + function Compiler (This : in out Root) return Releases.Release + is (Toolchains.Solutions.Compiler (This.Solution)); + ------------- -- Install -- ------------- diff --git a/src/alire/alire-roots.ads b/src/alire/alire-roots.ads index 4136b2f4..43b25166 100644 --- a/src/alire/alire-roots.ads +++ b/src/alire/alire-roots.ads @@ -92,6 +92,11 @@ package Alire.Roots is function Build_Context (This : in out Root) return Alire.Environment.Context; + function Compiler (This : in out Root) return Releases.Release; + -- Return the compiler that would be used to compile this solution, + -- either because of an explicit dependency or with the actual toolchain + -- configuration. + procedure Export_Build_Environment (This : in out Root); -- Export the build environment (PATH, GPR_PROJECT_PATH) of the given root diff --git a/src/alire/alire-toolchains-solutions.adb b/src/alire/alire-toolchains-solutions.adb index 29414f43..11a53119 100644 --- a/src/alire/alire-toolchains-solutions.adb +++ b/src/alire/alire-toolchains-solutions.adb @@ -2,6 +2,7 @@ with AAA.Strings; with Alire.Index; with Alire.Root; +with Alire.Solver; package body Alire.Toolchains.Solutions is @@ -9,8 +10,9 @@ package body Alire.Toolchains.Solutions is -- Add_Toolchain -- ------------------- - function Add_Toolchain (Solution : Alire.Solutions.Solution) - return Alire.Solutions.Solution + function Add_Toolchain (Solution : Alire.Solutions.Solution; + Deploy : Boolean := True) + return Alire.Solutions.Solution is ------------------------ @@ -46,7 +48,7 @@ package body Alire.Toolchains.Solutions is -- This shouldn't happen normally, but it can happen if the user -- has just changed the cache location. - if not Tool_Is_External (Tool) then + if Deploy and then not Tool_Is_External (Tool) then Redeploy_If_Needed (Tool_Milestone (Tool)); end if; @@ -67,6 +69,59 @@ package body Alire.Toolchains.Solutions is return Result; end Add_Toolchain; + -------------- + -- Compiler -- + -------------- + + function Compiler (Solution : Alire.Solutions.Solution) + return Releases.Release + is + + -------------------------- + -- Environment_Compiler -- + -------------------------- + + function Environment_Compiler return Releases.Release is + begin + Index.Detect_Externals (GNAT_Crate, Root.Platform_Properties); + return Solver.Find (GNAT_External_Crate, + Policy => Solver.Default_Options.Age); + exception + when Query_Unsuccessful => + Raise_Checked_Error + (Errors.New_Wrapper + .Wrap ("Unable to determine compiler version.") + .Wrap ("Check that the workspace solution is complete " + & "and a compiler is available.") + .Get); + end Environment_Compiler; + + begin + if not Solution.Depends_On (GNAT_Crate) then + declare + With_GNAT : constant Alire.Solutions.Solution := + Add_Toolchain (Solution, + Deploy => False); + begin + if not With_GNAT.Depends_On (GNAT_Crate) then + -- This means that no compiler (or None) has been selected + -- with `alr toolchain`, so we return whichever one is in + -- the environment. + return Environment_Compiler; + else + return Compiler (With_GNAT); + end if; + end; + end if; + + -- At this point we have a GNAT in the solution + + Assert (Solution.Releases_Providing (GNAT_Crate).Length in 1, + "Solution contains more than one compiler?"); + + return Solution.Releases_Providing (GNAT_Crate).First_Element; + end Compiler; + --------------------- -- Is_In_Toolchain -- --------------------- diff --git a/src/alire/alire-toolchains-solutions.ads b/src/alire/alire-toolchains-solutions.ads index 56bb976a..ec813b65 100644 --- a/src/alire/alire-toolchains-solutions.ads +++ b/src/alire/alire-toolchains-solutions.ads @@ -5,12 +5,19 @@ package Alire.Toolchains.Solutions is -- Needed to break circularity - function Add_Toolchain (Solution : Alire.Solutions.Solution) + function Add_Toolchain (Solution : Alire.Solutions.Solution; + Deploy : Boolean := True) return Alire.Solutions.Solution; -- If no release in the solution is a compiler/builder, add the configured -- ones (if defined) to the solution. This is used just before launching -- the build, so the configured tools are used despite not being in a - -- regular solution. + -- regular solution. If Deploy, the toolchain will be readied if not + -- already available. + + function Compiler (Solution : Alire.Solutions.Solution) + return Releases.Release; + -- Retrieve the compiler that will be used by this solution, be it because + -- it contains already one, or else checking the selected toolchain. function Is_In_Toolchain (Release : Releases.Release) return Boolean; -- Say if this Release is part of the user-configured toolchain diff --git a/testsuite/drivers/alr.py b/testsuite/drivers/alr.py index 3e40ff62..8a86e2b2 100644 --- a/testsuite/drivers/alr.py +++ b/testsuite/drivers/alr.py @@ -4,15 +4,13 @@ Helpers to run alr in the testsuite. import os import os.path - +import re from shutil import copytree -from e3.os.process import Run, quote_arg + from e3.fs import mkdir +from e3.os.process import Run, quote_arg from e3.testsuite.driver.classic import ProcessResult -import re - - TESTSUITE_ROOT = os.path.dirname(os.path.dirname( os.path.abspath(__file__))) @@ -530,4 +528,15 @@ def alr_builds_dir() -> str: """ Return the path to the builds directory """ - return os.path.join(alr_config_dir(), "cache", "builds") \ No newline at end of file + return os.path.join(alr_config_dir(), "cache", "builds") + + +def external_compiler_version() -> str: + """ + Return the version of the external compiler + """ + # Obtain available compilers + p = run_alr("toolchain") + + # Capture version + return re.search("gnat_external ([0-9.]+)", p.out, re.MULTILINE).group(1) \ No newline at end of file diff --git a/testsuite/drivers/builds.py b/testsuite/drivers/builds.py index ed1bee96..7a2f73d5 100644 --- a/testsuite/drivers/builds.py +++ b/testsuite/drivers/builds.py @@ -4,9 +4,17 @@ Helper functions for the testing of shared builds from glob import glob import os +from shutil import rmtree from drivers.alr import alr_builds_dir +def clear_builds_dir() -> None: + """ + Clear the shared build directory + """ + rmtree(path()) + + def find_dir(crate_name: str) -> str: """ Find the build dir of a crate in the shared build directory diff --git a/testsuite/drivers/helpers.py b/testsuite/drivers/helpers.py index 8112c09f..353cd61e 100644 --- a/testsuite/drivers/helpers.py +++ b/testsuite/drivers/helpers.py @@ -272,4 +272,4 @@ class FileLock(): # Release the file lock import fcntl fcntl.flock(self.lock_file.fileno(), fcntl.LOCK_UN) - self.lock_file.close() + self.lock_file.close() \ No newline at end of file diff --git a/testsuite/fixtures/build_hash_index/gn/gnat_external/gnat_external-external.toml b/testsuite/fixtures/build_hash_index/gn/gnat_external/gnat_external-external.toml new file mode 100644 index 00000000..f1171a6b --- /dev/null +++ b/testsuite/fixtures/build_hash_index/gn/gnat_external/gnat_external-external.toml @@ -0,0 +1,12 @@ +description = "GNAT is a compiler for the Ada programming language" +name = "gnat_external" + +maintainers = ["alejandro@mosteo.com"] +maintainers-logins = ["mosteo"] + +[[external]] +kind = "version-output" +# We look for make instead that should be always installed. +version-command = ["make", "--version"] +version-regexp = ".*Make ([\\d\\.]+).*" +provides = "gnat" diff --git a/testsuite/fixtures/build_hash_index/gn/gnat_native/gnat_native-7777.0.0.toml b/testsuite/fixtures/build_hash_index/gn/gnat_native/gnat_native-7777.0.0.toml new file mode 100644 index 00000000..f1a79578 --- /dev/null +++ b/testsuite/fixtures/build_hash_index/gn/gnat_native/gnat_native-7777.0.0.toml @@ -0,0 +1,11 @@ +description = "Fake GNAT native crate" +name = "gnat_native" +version = "7777.0.0" +maintainers = ["alejandro@mosteo.com"] +maintainers-logins = ["mylogin"] +provides = ["gnat=7777.0"] + +# Test dynamic expression, but for all OSes +[origin."case(os)"."..."] +url = "file:../../../crates/libhello_1.0.0.tgz" +hashes = ["sha512:99fa3a55540d0655c87605b54af732f76a8a363015f183b06e98aa91e54c0e69397872718c5c16f436dd6de0fba506dc50c66d34a0e5c61fb63cb01fa22f35ac"] diff --git a/testsuite/fixtures/build_hash_index/gn/gnat_native/gnat_native-8888.0.0.toml b/testsuite/fixtures/build_hash_index/gn/gnat_native/gnat_native-8888.0.0.toml new file mode 100644 index 00000000..242786d5 --- /dev/null +++ b/testsuite/fixtures/build_hash_index/gn/gnat_native/gnat_native-8888.0.0.toml @@ -0,0 +1,15 @@ +description = "Fake GNAT native crate" +name = "gnat_native" +version = "8888.0.0" +maintainers = ["alejandro@mosteo.com"] +maintainers-logins = ["mylogin"] +provides = ["gnat=8888.0"] + +# Although the compiler is fake, we use this path in some tests +environment.'case(os)'.'windows'.TEST_PATH.append = '${CRATE_ROOT}\bin' +environment.'case(os)'.'...'.TEST_PATH.append = '${CRATE_ROOT}/bin' + +# Test dynamic expression, but for all OSes +[origin."case(os)"."..."] +url = "file:../../../crates/libhello_1.0.0.tgz" +hashes = ["sha512:99fa3a55540d0655c87605b54af732f76a8a363015f183b06e98aa91e54c0e69397872718c5c16f436dd6de0fba506dc50c66d34a0e5c61fb63cb01fa22f35ac"] diff --git a/testsuite/fixtures/build_hash_index/he/hello/hello-1.0.0.toml b/testsuite/fixtures/build_hash_index/he/hello/hello-1.0.0.toml new file mode 100644 index 00000000..bf47cfca --- /dev/null +++ b/testsuite/fixtures/build_hash_index/he/hello/hello-1.0.0.toml @@ -0,0 +1,16 @@ +description = "\"Hello, world!\" demonstration project" +long-description = "This is an example of long description in a multi-line string.\n\nMarkdown formating `can` be used to have \"nice\" display on the website.\n" +name = "hello" +version = "1.0.0" +website = "example.com" +authors = ["Bob", "Alice"] +licenses = "GPL-3.0-only OR MIT" +maintainers = ["alejandro@mosteo.com", "bob@example.com"] +maintainers-logins = ["mylogin"] +tags = ["tag1", "other-tag"] + +[[depends-on]] +libhello = "^1.0" + +[origin] +url = "file:../../../crates/hello_1.0.0" diff --git a/testsuite/fixtures/build_hash_index/he/hello/hello-1.0.1.toml b/testsuite/fixtures/build_hash_index/he/hello/hello-1.0.1.toml new file mode 100644 index 00000000..e67e218c --- /dev/null +++ b/testsuite/fixtures/build_hash_index/he/hello/hello-1.0.1.toml @@ -0,0 +1,29 @@ +description = "\"Hello, world!\" demonstration project" +long-description = "This is an example of long description in a multi-line string.\n\nMarkdown formating `can` be used to have \"nice\" display on the website.\n" +name = "hello" +version = "1.0.1" +website = "example.com" +authors = ["Bob", "Alice"] +licenses = "GPL-3.0-only OR MIT" +maintainers = ["alejandro@mosteo.com", "bob@example.com"] +maintainers-logins = ["mylogin"] +tags = ["tag1", "other-tag"] + +[[depends-on]] +libhello = "^1.0" + +[configuration.variables] +Var1={type="Boolean"} +Var2={type="String", default="str"} +Var3={type="Enum", values=["A", "B"], default="A"} +Var4={type="Integer", default=0} +Var5={type="Integer", first=-1, last=1, default=0} +Var7={type="Real", default=0.0} +Var6={type="Real", first=-1.0, last=1.0, default=0.0} + +[configuration.values] +hello.Var1=true # So far it is possible for a crate to set its own var +libhello.Var1=false + +[origin] +url = "file:../../../crates/hello_1.0.1" diff --git a/testsuite/fixtures/build_hash_index/index.toml b/testsuite/fixtures/build_hash_index/index.toml new file mode 100644 index 00000000..bad265e4 --- /dev/null +++ b/testsuite/fixtures/build_hash_index/index.toml @@ -0,0 +1 @@ +version = "1.1" diff --git a/testsuite/fixtures/build_hash_index/li/libhello/libhello-1.0.0.toml b/testsuite/fixtures/build_hash_index/li/libhello/libhello-1.0.0.toml new file mode 100644 index 00000000..c46f09fa --- /dev/null +++ b/testsuite/fixtures/build_hash_index/li/libhello/libhello-1.0.0.toml @@ -0,0 +1,23 @@ +description = "\"Hello, world!\" demonstration project support library" +name = "libhello" +version = "1.0.0" +maintainers = ["alejandro@mosteo.com"] +maintainers-logins = ["mylogin"] +tags = ["libhello-tag1"] + +[configuration.variables] +Var1={type="Boolean", default=true} + +[gpr-externals] +TEST_GPR_EXTERNAL = ["gpr_ext_A", "gpr_ext_B", "gpr_ext_C"] +TEST_FREEFORM_UNSET = "" # to test build hashing with an unset var + +[gpr-set-externals] +TEST_GPR_EXTERNAL = "gpr_ext_B" +TEST_UNDECLARED = "used_by_another_crate" + +[environment] +TEST_ENV.set = "myenv" + +[origin] +url = "file:../../../crates/libhello_1.0.0" diff --git a/testsuite/fixtures/build_hash_index/ma/make/make-external.toml b/testsuite/fixtures/build_hash_index/ma/make/make-external.toml new file mode 100644 index 00000000..eaf4b23e --- /dev/null +++ b/testsuite/fixtures/build_hash_index/ma/make/make-external.toml @@ -0,0 +1,8 @@ +description = "Utility for directing compilation" +name = "make" +maintainers = ["alejandro@mosteo.com"] +maintainers-logins = ["mylogin"] + +[[external]] +kind = "system" +origin = [] # Empty on purpose to ensure unavailable in tests diff --git a/testsuite/tests/build/hashes/compiler-input/test.py b/testsuite/tests/build/hashes/compiler-input/test.py new file mode 100644 index 00000000..5a127ac3 --- /dev/null +++ b/testsuite/tests/build/hashes/compiler-input/test.py @@ -0,0 +1,75 @@ +""" +Check compiler version in hashing input +""" + +import sys + +from drivers.alr import external_compiler_version, run_alr, init_local_crate, alr_with +from drivers.asserts import assert_match +from drivers.builds import clear_builds_dir, hash_input + + +def check_hash(signature: str) -> None: + """ + Check that the given signature is present in the hash inputs + """ + assert_match(f".*{signature}.*", hash_input("crate_real")) + + +# The first test is to check that the external compiler is used when no +# explicit compiler is selected. + +# Disable compiler selection, so the external is used +run_alr("toolchain", "--disable-assistant") + +# Enable shared dependencies +run_alr("config", "--set", "--global", "dependencies.shared", "true") + +# Init a crate without explicit compiler dependency +init_local_crate("xxx") +alr_with("crate_real") # A regular crate in the index +run_alr("update") # Ensure the hash inputs are written to disk + +# Check the external compiler is in the hash inputs +check_hash(f"version:gnat_external={external_compiler_version()}") + + +# Next, check that selecting a compiler results in it being used + +# Select the default preferred compiler, in this index is gnat_native=8888 +run_alr("toolchain", "--select", "gnat_native") +# Clear the build cache so we are able to locate the new hash +clear_builds_dir() +run_alr("update") +run_alr("update") +# Twice necessary because otherwise the hash inputs cannot be written (during +# the first update the destination folder does not yet exist, and the crate +# sync would remove the hash inputs file) + +# Check the expected compiler is in the hash inputs +check_hash("version:gnat_native=8888.0.0") + + +# Next, check with an explicit compiler in the dependencies. Note that we give +# the virtual dependency, but the actual native one is used for the hash. + +clear_builds_dir() +alr_with("gnat=7777") # Downgrade the compiler with an explicit dependency +run_alr("update") + +# Check the expected compiler is in the hash inputs +check_hash("version:gnat_native=7777.0.0") + + +# Finally, check that having two explicit dependencies on the compiler (one +# virtual and another real) does not cause a conflict in compiler detection. +# The same compiler as in the previous step should be used for the hash. + +clear_builds_dir() +alr_with("gnat_native") +run_alr("update") +check_hash("version:gnat_native=7777.0.0") + + +print('SUCCESS') +sys.exit(0) diff --git a/testsuite/tests/build/hashes/compiler-input/test.yaml b/testsuite/tests/build/hashes/compiler-input/test.yaml new file mode 100644 index 00000000..8185c03b --- /dev/null +++ b/testsuite/tests/build/hashes/compiler-input/test.yaml @@ -0,0 +1,4 @@ +driver: python-script +indexes: + toolchain_index: + in_fixtures: true diff --git a/testsuite/tests/build/hashes/compiler-missing/test.py b/testsuite/tests/build/hashes/compiler-missing/test.py new file mode 100644 index 00000000..caa3f7f5 --- /dev/null +++ b/testsuite/tests/build/hashes/compiler-missing/test.py @@ -0,0 +1,19 @@ +""" +Check that when no compiler is available we cannot compute the build hash +""" + + +from drivers.alr import run_alr, init_local_crate +from drivers.asserts import assert_match + +# The index in this test has no compilers configured; hence we cannot locate +# even the default external compiler. + +run_alr("config", "--set", "--global", "dependencies.shared", "true") + +# Init a crate without explicit compiler dependency +init_local_crate("xxx") +p = run_alr("with", "libhello", complain_on_error=False) # This should fail +assert_match(".*Unable to determine compiler version", p.out) + +print("SUCCESS") diff --git a/testsuite/tests/build/hashes/compiler-missing/test.yaml b/testsuite/tests/build/hashes/compiler-missing/test.yaml new file mode 100644 index 00000000..8929d590 --- /dev/null +++ b/testsuite/tests/build/hashes/compiler-missing/test.yaml @@ -0,0 +1,4 @@ +driver: python-script +indexes: + basic_index: + in_fixtures: true diff --git a/testsuite/tests/build/hashes/hashing-inputs/test.yaml b/testsuite/tests/build/hashes/hashing-inputs/test.yaml index 872fc127..8e25447d 100644 --- a/testsuite/tests/build/hashes/hashing-inputs/test.yaml +++ b/testsuite/tests/build/hashes/hashing-inputs/test.yaml @@ -1,3 +1,3 @@ driver: python-script indexes: - basic_index: {} + build_hash_index: {} diff --git a/testsuite/tests/config/shared-deps/test.yaml b/testsuite/tests/config/shared-deps/test.yaml index 872fc127..8e25447d 100644 --- a/testsuite/tests/config/shared-deps/test.yaml +++ b/testsuite/tests/config/shared-deps/test.yaml @@ -1,3 +1,3 @@ driver: python-script indexes: - basic_index: {} + build_hash_index: {} diff --git a/testsuite/tests/dockerized/misc/default-cache/test.py b/testsuite/tests/dockerized/misc/default-cache/test.py index 1a2367a4..1c5e0a74 100644 --- a/testsuite/tests/dockerized/misc/default-cache/test.py +++ b/testsuite/tests/dockerized/misc/default-cache/test.py @@ -36,7 +36,7 @@ assert \ # Shared builds # We hardcode this hash so we detect unwilling changes to our hashing scheme -hash = "e66592c9a181de97dc3a342cf76378f6ffa6667d7c1864c74d98bec8ffaf4f3d" +hash = "7b5ad18029d4984b4076f4910c699700e7a325ab0c3dc786ccf89c3c6035212f" assert \ os.path.isdir(f"{base}/builds/crate_real_1.0.0_filesystem_{hash}"), \ f"Shared build not found at the expected location: f{contents(base)}" -- 2.39.5