Repository: canonical/snapcraft

Issue:
Add regression test for incorrect project variable expansion
### Bug Description

This is the same as https://github.com/canonical/craft-parts/issues/541, except for core20 snaps.

The ordering of the [dictionary](https://github.com/snapcore/snapcraft/blob/c8e9b2e423cb0702d240814b21224843541c9d2b/snapcraft_legacy/internal/pluginhandler/_part_environment.py#L74) happens to be correct but there are no comments or regression tests.

### To Reproduce

n/a

### Environment

n/a

### snapcraft.yaml

```shell
n/a
```


### Relevant log output

```shell
n/a
```


### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/snapcraft_legacy/internal/lifecycle/_runner.py b/snapcraft_legacy/internal/lifecycle/_runner.py
--- a/snapcraft_legacy/internal/lifecycle/_runner.py
+++ b/snapcraft_legacy/internal/lifecycle/_runner.py
@@ -1,6 +1,6 @@
 # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
 #
-# Copyright (C) 2015-2020 Canonical Ltd
+# Copyright (C) 2015-2020, 2023 Canonical Ltd
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 3 as
@@ -163,6 +163,12 @@ def _replace_in_part(part):
             get_snapcraft_part_directory_environment(part)
         )
 
+        # order is important - for example, `SNAPCRAFT_PART_SRC_WORK` should be
+        # evaluated before `SNAPCRAFT_PART_SRC` to avoid premature variable expansion
+        replacements = dict(
+            sorted(replacements.items(), key=lambda item: len(item[0]), reverse=True)
+        )
+
         value = project_loader.replace_attr(value, replacements)
         setattr(part.plugin.options, key, value)
 
diff --git a/snapcraft_legacy/internal/project_loader/_config.py b/snapcraft_legacy/internal/project_loader/_config.py
--- a/snapcraft_legacy/internal/project_loader/_config.py
+++ b/snapcraft_legacy/internal/project_loader/_config.py
@@ -351,6 +351,15 @@ def _expand_env(self, snapcraft_yaml):
                 get_snapcraft_global_environment(self.project)
             )
 
+            # order is important - for example, `SNAPCRAFT_ARCH_TRIPLET_BUILD_{ON|FOR}`
+            # should be evaluated before `SNAPCRAFT_ARCH_TRIPLET` to avoid premature
+            # variable expansion
+            replacements = dict(
+                sorted(
+                    replacements.items(), key=lambda item: len(item[0]), reverse=True
+                )
+            )
+
             snapcraft_yaml[key] = replace_attr(snapcraft_yaml[key], replacements)
         return snapcraft_yaml
 


Test patch:
diff --git a/tests/legacy/unit/lifecycle/test_lifecycle.py b/tests/legacy/unit/lifecycle/test_lifecycle.py
--- a/tests/legacy/unit/lifecycle/test_lifecycle.py
+++ b/tests/legacy/unit/lifecycle/test_lifecycle.py
@@ -1,6 +1,6 @@
 # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
 #
-# Copyright (C) 2015-2020 Canonical Ltd
+# Copyright (C) 2015-2020, 2023 Canonical Ltd
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 3 as
@@ -17,6 +17,7 @@
 import logging
 import os
 import textwrap
+from pathlib import Path
 from unittest import mock
 
 import fixtures
@@ -91,6 +92,50 @@ def test_dependency_is_staged_when_required(self, mock_install_build_snaps):
             Contains("'part2' has dependencies that need to be staged: part1"),
         )
 
+    @mock.patch("snapcraft_legacy.repo.snaps.install_snaps")
+    def test_part_directory_variable_order(self, mock_install_build_snaps):
+        """The largest replacements should occur first.
+
+        $SNAPCRAFT_PART_SRC_WORK should be replaced before $SNAPCRAFT_PART_SRC
+        """
+        part_src = Path("parts/part1/src").absolute()
+        project_config = self.make_snapcraft_project(
+            textwrap.dedent(
+                """\
+                parts:
+                  part1:
+                    plugin: nil
+                    source: .
+                    source-subdir: foo/
+                    override-build: |
+                        cat << EOF >> $SNAPCRAFT_PART_INSTALL/part-variables.txt
+                        SNAPCRAFT_PART_SRC: $SNAPCRAFT_PART_SRC
+                        SNAPCRAFT_PART_SRC: ${SNAPCRAFT_PART_SRC}
+                        SNAPCRAFT_PART_SRC_WORK: $SNAPCRAFT_PART_SRC_WORK
+                        SNAPCRAFT_PART_SRC_WORK: ${SNAPCRAFT_PART_SRC_WORK}
+                        EOF
+                """
+            )
+        )
+
+        lifecycle.execute(steps.BUILD, project_config, part_names=["part1"])
+
+        data = Path("parts/part1/install/part-variables.txt").read_text()
+
+        self.assertThat(
+            data,
+            Equals(
+                textwrap.dedent(
+                    f"""\
+                    SNAPCRAFT_PART_SRC: {str(part_src)}
+                    SNAPCRAFT_PART_SRC: {str(part_src)}
+                    SNAPCRAFT_PART_SRC_WORK: {str(part_src)}/foo/
+                    SNAPCRAFT_PART_SRC_WORK: {str(part_src)}/foo/
+                    """
+                )
+            ),
+        )
+
     @mock.patch("snapcraft_legacy.repo.snaps.install_snaps")
     def test_no_exception_when_dependency_is_required_but_already_staged(
         self, mock_install_build_snaps
diff --git a/tests/legacy/unit/pluginhandler/test_environment.py b/tests/legacy/unit/pluginhandler/test_environment.py
--- a/tests/legacy/unit/pluginhandler/test_environment.py
+++ b/tests/legacy/unit/pluginhandler/test_environment.py
@@ -31,7 +31,7 @@ def mock_platform(mocker):
     mocker.patch("platform.machine", return_value="aarch64")
 
 
-def test_no_build_for_arch(tmp_path):
+def test_build_for_arch(tmp_path):
     """build-for envvars should exist when the build-for arch can be determined."""
     snapcraft_yaml = SnapcraftYaml(
         tmp_path,
diff --git a/tests/legacy/unit/project_loader/test_config.py b/tests/legacy/unit/project_loader/test_config.py
--- a/tests/legacy/unit/project_loader/test_config.py
+++ b/tests/legacy/unit/project_loader/test_config.py
@@ -129,6 +129,50 @@ def test_replace_snapcraft_variables(self):
             },
         )
 
+    def test_replace_snapcraft_variables_order(self):
+        """The largest replacements should occur first.
+
+        $SNAPCRAFT_ARCH_TRIPLET_BUILD_{ON|FOR} should be replaced before
+        $SNAPCRAFT_ARCH_TRIPLET.
+        """
+        project_config = self.make_snapcraft_project(
+            dedent(
+                """\
+            name: project-name
+            base: core20
+            version: "1"
+            summary: test
+            description: test
+            confinement: strict
+            environment:
+              SNAPCRAFT_ARCH_TRIPLET: ${SNAPCRAFT_ARCH_TRIPLET}
+              SNAPCRAFT_ARCH_BUILD_FOR: ${SNAPCRAFT_ARCH_BUILD_FOR}
+              SNAPCRAFT_ARCH_TRIPLET_BUILD_FOR: ${SNAPCRAFT_ARCH_TRIPLET_BUILD_FOR}
+              SNAPCRAFT_ARCH_BUILD_ON: ${SNAPCRAFT_ARCH_BUILD_ON}
+              SNAPCRAFT_ARCH_TRIPLET_BUILD_ON: ${SNAPCRAFT_ARCH_TRIPLET_BUILD_ON}
+              SNAPCRAFT_TARGET_ARCH: ${SNAPCRAFT_TARGET_ARCH}
+
+            parts:
+              test:
+                plugin: nil
+            """
+            )
+        )
+
+        self.assertThat(
+            project_config.data["environment"],
+            Equals(
+                {
+                    "SNAPCRAFT_ARCH_BUILD_FOR": "amd64",
+                    "SNAPCRAFT_ARCH_BUILD_ON": "amd64",
+                    "SNAPCRAFT_ARCH_TRIPLET": "x86_64-linux-gnu",
+                    "SNAPCRAFT_ARCH_TRIPLET_BUILD_FOR": "x86_64-linux-gnu",
+                    "SNAPCRAFT_ARCH_TRIPLET_BUILD_ON": "x86_64-linux-gnu",
+                    "SNAPCRAFT_TARGET_ARCH": "amd64",
+                }
+            ),
+        )
+
 
 class DependenciesTest(ProjectLoaderBaseTest):
     def setUp(self):
diff --git a/tests/spread/general/project-variables/expected-env-part.txt b/tests/spread/general/project-variables/expected-env-part.txt
new file mode 100644
--- /dev/null
+++ b/tests/spread/general/project-variables/expected-env-part.txt
@@ -0,0 +1,10 @@
+SNAPCRAFT_PART_SRC_1: .*parts/my-part/src
+SNAPCRAFT_PART_SRC_2: .*parts/my-part/src
+SNAPCRAFT_PART_SRC_WORK_1: .*parts/my-part/src/foo
+SNAPCRAFT_PART_SRC_WORK_2: .*parts/my-part/src/foo
+SNAPCRAFT_ARCH_TRIPLET_1: x86_64-linux-gnu
+SNAPCRAFT_ARCH_TRIPLET_2: x86_64-linux-gnu
+SNAPCRAFT_ARCH_TRIPLET_BUILD_FOR_1: arm-linux-gnueabihf
+SNAPCRAFT_ARCH_TRIPLET_BUILD_FOR_2: arm-linux-gnueabihf
+SNAPCRAFT_ARCH_TRIPLET_BUILD_ON_1: x86_64-linux-gnu
+SNAPCRAFT_ARCH_TRIPLET_BUILD_ON_2: x86_64-linux-gnu
diff --git a/tests/spread/general/project-variables/expected-env.txt b/tests/spread/general/project-variables/expected-env.txt
new file mode 100644
--- /dev/null
+++ b/tests/spread/general/project-variables/expected-env.txt
@@ -0,0 +1,6 @@
+SNAPCRAFT_ARCH_TRIPLET_1: x86_64-linux-gnu
+SNAPCRAFT_ARCH_TRIPLET_2: x86_64-linux-gnu
+SNAPCRAFT_ARCH_TRIPLET_BUILD_FOR_1: arm-linux-gnueabihf
+SNAPCRAFT_ARCH_TRIPLET_BUILD_FOR_2: arm-linux-gnueabihf
+SNAPCRAFT_ARCH_TRIPLET_BUILD_ON_1: x86_64-linux-gnu
+SNAPCRAFT_ARCH_TRIPLET_BUILD_ON_2: x86_64-linux-gnu
diff --git a/tests/spread/general/project-variables/snap/snapcraft.yaml b/tests/spread/general/project-variables/snap/snapcraft.yaml
new file mode 100644
--- /dev/null
+++ b/tests/spread/general/project-variables/snap/snapcraft.yaml
@@ -0,0 +1,41 @@
+name: project-variables
+base: core20
+version: '1.0'
+summary: test
+description: |
+  Project variables should be evaluated in $VAR or ${VAR} form.
+  Larger variable names should be evaluated first (`SNAPCRAFT_ARCH_TRIPLET_BUILD_{ON|FOR}`
+  should be evaluated before `SNAPCRAFT_ARCH_TRIPLET`.
+grade: stable
+confinement: strict
+
+architectures:
+  - build-on: amd64
+    run-on: armhf
+
+environment:
+  SNAPCRAFT_ARCH_TRIPLET_1: $SNAPCRAFT_ARCH_TRIPLET
+  SNAPCRAFT_ARCH_TRIPLET_2: ${SNAPCRAFT_ARCH_TRIPLET}
+  SNAPCRAFT_ARCH_TRIPLET_BUILD_FOR_1: $SNAPCRAFT_ARCH_TRIPLET_BUILD_FOR
+  SNAPCRAFT_ARCH_TRIPLET_BUILD_FOR_2: ${SNAPCRAFT_ARCH_TRIPLET_BUILD_FOR}
+  SNAPCRAFT_ARCH_TRIPLET_BUILD_ON_1: $SNAPCRAFT_ARCH_TRIPLET_BUILD_ON
+  SNAPCRAFT_ARCH_TRIPLET_BUILD_ON_2: ${SNAPCRAFT_ARCH_TRIPLET_BUILD_ON}
+
+parts:
+  my-part:
+    plugin: nil
+    source: .
+    source-subdir: foo
+    override-build: |
+      cat << EOF >> $SNAPCRAFT_PART_INSTALL/part-variables.txt
+      SNAPCRAFT_PART_SRC_1: $SNAPCRAFT_PART_SRC
+      SNAPCRAFT_PART_SRC_2: ${SNAPCRAFT_PART_SRC}
+      SNAPCRAFT_PART_SRC_WORK_1: $SNAPCRAFT_PART_SRC_WORK
+      SNAPCRAFT_PART_SRC_WORK_2: ${SNAPCRAFT_PART_SRC_WORK}
+      SNAPCRAFT_ARCH_TRIPLET_1: $SNAPCRAFT_ARCH_TRIPLET
+      SNAPCRAFT_ARCH_TRIPLET_2: ${SNAPCRAFT_ARCH_TRIPLET}
+      SNAPCRAFT_ARCH_TRIPLET_BUILD_FOR_1: $SNAPCRAFT_ARCH_TRIPLET_BUILD_FOR
+      SNAPCRAFT_ARCH_TRIPLET_BUILD_FOR_2: ${SNAPCRAFT_ARCH_TRIPLET_BUILD_FOR}
+      SNAPCRAFT_ARCH_TRIPLET_BUILD_ON_1: $SNAPCRAFT_ARCH_TRIPLET_BUILD_ON
+      SNAPCRAFT_ARCH_TRIPLET_BUILD_ON_2: ${SNAPCRAFT_ARCH_TRIPLET_BUILD_ON}
+      EOF
diff --git a/tests/spread/general/project-variables/task.yaml b/tests/spread/general/project-variables/task.yaml
new file mode 100644
--- /dev/null
+++ b/tests/spread/general/project-variables/task.yaml
@@ -0,0 +1,28 @@
+summary: Test snapcraft project variables
+
+systems:
+  - ubuntu-20.04-64
+  - ubuntu-20.04-amd64
+
+restore: |
+  snapcraft clean --destructive-mode
+  rm -f ./*.snap
+
+execute: |
+  snapcraft prime --destructive-mode
+
+  # verify variables were evaluated in the environment in the snap.yaml
+  while read -r expected_envvar; do
+    if ! grep -q "$expected_envvar" prime/meta/snap.yaml; then
+      echo "Did not find '$expected_envvar' in 'prime/meta/snap.yaml'."
+      exit 1
+    fi
+  done < expected-env.txt
+
+  # verify variables were evaluated inside a part's override script
+  while read -r expected_envvar_part; do
+    if ! grep -q "$expected_envvar_part" prime/part-variables.txt; then
+      echo "Did not find '$expected_envvar_part' in 'prime/part-variables.txt'."
+      exit 1
+    fi
+  done < expected-env-part.txt