Repository: canonical/snapcraft

Issue:
Abstract socket in listen-stream cause validation failure
### Bug Description

When trying to declare an abstract socket in a `snapcraft.yaml` file it caused validation to fail, even though it's supported by `snapd` [[1]](https://snapcraft.io/docs/snapcraft-app-and-service-metadata) [[2]](https://forum.snapcraft.io/t/socket-activation-support/2050/7). I was able to trace this back to an incomplete regex used for validation that doesn't support the possibility of sockets in abstract namespaces [here](https://github.com/snapcore/snapcraft/blob/dbe76f21d0e670811a05f6fa7838e704bbfaa52d/snapcraft/projects.py#L175-L195).

### To Reproduce

Try to build the `snapcraft.yaml` file with `snapcraft -v`.

### Environment

Ubuntu 23.04, snapcraft 7.4.3

### snapcraft.yaml

```shell
name: lldpad
base: core22
confinement: devmode
description: 'Redacted'
summary: Link Layer Discovery Protocol Implementation (Runtime)
version: 1.1+git20221028.aa18720-1-snap
parts:
  lldpad:
    plugin: nil
    stage-packages:
    - lldpad
apps:
  lldpad:
    command: /usr/sbin/lldpad
    daemon: simple
    plugs:
      - network-bind
      - browser-support
    sockets:
      unix:
        listen-stream: "@snap.lldpad./com/intel/lldpad"
```


### Relevant log output

```shell
Starting Snapcraft 7.4.3
Logging execution to '/home/phckopper/.local/state/snapcraft/log/snapcraft-20230719-203033.689783.log'
Running on amd64 for amd64
Bad snapcraft.yaml content:
- '@snap.lldpad./com/intel/lldpad' is not a valid socket path (e.g. /tmp/mysocket.sock). (in field 'apps.lldpad.sockets.unix.listen-stream')
Full execution log: '/home/phckopper/.local/state/snapcraft/log/snapcraft-20230719-203033.689783.log
```


### Additional context

[How snapd validates internally](https://github.com/snapcore/snapd/blob/876953a4a80ee319aa4e175eaf81b0eefec6748c/snap/validate.go#L202)


Discussion:

Resolving patch:
diff --git a/snapcraft/projects.py b/snapcraft/projects.py
--- a/snapcraft/projects.py
+++ b/snapcraft/projects.py
@@ -187,7 +187,9 @@ def _validate_list_stream(cls, listen_stream):
                     f"{listen_stream!r} is not an integer between 1 and 65535 (inclusive)."
                 )
         elif isinstance(listen_stream, str):
-            if not re.match(r"^[A-Za-z0-9/._#:$-]*$", listen_stream):
+            if not listen_stream.startswith("@snap.") and not re.match(
+                r"^[A-Za-z0-9/._#:$-]*$", listen_stream
+            ):
                 raise ValueError(
                     f"{listen_stream!r} is not a valid socket path (e.g. /tmp/mysocket.sock)."
                 )


Test patch:
diff --git a/tests/unit/test_projects.py b/tests/unit/test_projects.py
--- a/tests/unit/test_projects.py
+++ b/tests/unit/test_projects.py
@@ -1053,7 +1053,9 @@ def test_app_command_chain(self, command_chain, app_yaml_data):
             assert project.apps is not None
             assert project.apps["app1"].command_chain == command_chain
 
-    @pytest.mark.parametrize("listen_stream", [1, 100, 65535, "/tmp/mysocket.sock"])
+    @pytest.mark.parametrize(
+        "listen_stream", [1, 100, 65535, "/tmp/mysocket.sock", "@snap.foo"]
+    )
     def test_app_sockets_valid_listen_stream(self, listen_stream, socket_yaml_data):
         data = socket_yaml_data(listen_stream=listen_stream)
 
@@ -1063,13 +1065,25 @@ def test_app_sockets_valid_listen_stream(self, listen_stream, socket_yaml_data):
         assert project.apps["app1"].sockets["socket1"].listen_stream == listen_stream
 
     @pytest.mark.parametrize("listen_stream", [-1, 0, 65536])
-    def test_app_sockets_invalid_listen_stream(self, listen_stream, socket_yaml_data):
+    def test_app_sockets_invalid_int_listen_stream(
+        self, listen_stream, socket_yaml_data
+    ):
         data = socket_yaml_data(listen_stream=listen_stream)
 
         error = f".*{listen_stream} is not an integer between 1 and 65535"
         with pytest.raises(errors.ProjectValidationError, match=error):
             Project.unmarshal(data)
 
+    @pytest.mark.parametrize("listen_stream", ["@foo"])
+    def test_app_sockets_invalid_socket_listen_stream(
+        self, listen_stream, socket_yaml_data
+    ):
+        data = socket_yaml_data(listen_stream=listen_stream)
+
+        error = f".*{listen_stream!r} is not a valid socket path.*"
+        with pytest.raises(errors.ProjectValidationError, match=error):
+            Project.unmarshal(data)
+
     def test_app_sockets_missing_listen_stream(self, socket_yaml_data):
         data = socket_yaml_data()