Skip to content

Commit

Permalink
feat: add macOS socket support and enable macOS CI (#65)
Browse files Browse the repository at this point in the history
---------

Signed-off-by: Jericho Tolentino <68654047+jericht@users.noreply.github.com>
Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com>
Co-authored-by: Morgan Epp <60796713+epmog@users.noreply.github.com>
  • Loading branch information
jericht and epmog committed Feb 29, 2024
1 parent e8ed830 commit 4da070d
Show file tree
Hide file tree
Showing 9 changed files with 159 additions and 19 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/reuse_python_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11']
os: ["ubuntu-latest", "windows-latest"]
os: ["ubuntu-latest", "windows-latest", "macos-latest"]
env:
PYTHON: ${{ matrix.python-version }}
CODEARTIFACT_REGION: "us-west-2"
Expand Down Expand Up @@ -48,8 +48,8 @@ jobs:
aws-region: us-west-2
mask-aws-account-id: true

- name: Setup CodeArtifact Linux
if: ${{ matrix.os == 'ubuntu-latest'}}
- name: Setup CodeArtifact Unix
if: ${{ matrix.os == 'ubuntu-latest' || matrix.os == 'macos-latest' }}
run: |
CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token --domain ${{ secrets.CODEARTIFACT_DOMAIN }} --domain-owner ${{ secrets.CODEARTIFACT_ACCOUNT_ID }} --query authorizationToken --output text --region us-west-2)
echo "::add-mask::$CODEARTIFACT_AUTH_TOKEN"
Expand Down
45 changes: 40 additions & 5 deletions src/openjd/adaptor_runtime/_http/request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
import urllib.parse as urllib_parse
from dataclasses import dataclass
from http import HTTPStatus, server
from typing import Callable, Type
from typing import Any, Callable, Type

from .._osname import OSName
from .exceptions import UnsupportedPlatformException

_logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -120,13 +121,27 @@ def _authenticate(self) -> bool:
"Failed to handle request because it was not made through a UNIX socket"
)

peercred_opt_level: Any
peercred_opt: Any
cred_cls: Any
if OSName.is_macos(): # pragma: no cover
# SOL_LOCAL is not defined in Python's socket module, need to hardcode it
# source: https://github.com/apple-oss-distributions/xnu/blob/1031c584a5e37aff177559b9f69dbd3c8c3fd30a/bsd/sys/un.h#L85
peercred_opt_level = 0 # type: ignore[attr-defined]
peercred_opt = socket.LOCAL_PEERCRED # type: ignore[attr-defined]
cred_cls = XUCred
else: # pragma: no cover
peercred_opt_level = socket.SOL_SOCKET # type: ignore[attr-defined]
peercred_opt = socket.SO_PEERCRED # type: ignore[attr-defined]
cred_cls = UCred

# Get the credentials of the peer process
cred_buffer = self.connection.getsockopt(
socket.SOL_SOCKET, # type: ignore[attr-defined]
socket.SO_PEERCRED, # type: ignore[attr-defined]
socket.CMSG_SPACE(ctypes.sizeof(UCred)), # type: ignore[attr-defined]
peercred_opt_level,
peercred_opt,
socket.CMSG_SPACE(ctypes.sizeof(cred_cls)), # type: ignore[attr-defined]
)
peer_cred = UCred.from_buffer_copy(cred_buffer)
peer_cred = cred_cls.from_buffer_copy(cred_buffer)

# Only allow connections from a process running as the same user
return peer_cred.uid == os.getuid() # type: ignore[attr-defined]
Expand All @@ -149,6 +164,26 @@ def __str__(self): # pragma: no cover
return f"pid:{self.pid} uid:{self.uid} gid:{self.gid}"


class XUCred(ctypes.Structure):
"""
Represents the xucred struct returned from the LOCAL_PEERCRED socket option.
For more info, see LOCAL_PEERCRED in the unix(4) man page
"""

_fields_ = [
("version", ctypes.c_uint),
("uid", ctypes.c_uint),
("ngroups", ctypes.c_short),
# cr_groups is a uint array of NGROUPS elements, which is defined as 16
# source:
# - https://github.com/apple-oss-distributions/xnu/blob/1031c584a5e37aff177559b9f69dbd3c8c3fd30a/bsd/sys/ucred.h#L207
# - https://github.com/apple-oss-distributions/xnu/blob/1031c584a5e37aff177559b9f69dbd3c8c3fd30a/bsd/sys/param.h#L100
# - https://github.com/apple-oss-distributions/xnu/blob/1031c584a5e37aff177559b9f69dbd3c8c3fd30a/bsd/sys/syslimits.h#L100
("groups", ctypes.c_uint * 16),
]


@dataclass
class HTTPResponse:
"""
Expand Down
21 changes: 21 additions & 0 deletions src/openjd/adaptor_runtime/_http/sockets.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,29 @@ def verify_socket_path(self, path: str) -> None:
)


class MacOSSocketDirectories(SocketDirectories):
"""
Specialization for socket paths in macOS systems.
"""

# This is based on the max length of socket names to 104 bytes
# See https://github.com/apple-oss-distributions/xnu/blob/1031c584a5e37aff177559b9f69dbd3c8c3fd30a/bsd/sys/un.h#L79
_socket_path_max_length = 104
_socket_dir_max_length = _socket_path_max_length - _PID_MAX_LENGTH_PADDED

def verify_socket_path(self, path: str) -> None:
path_length = len(path.encode("utf-8"))
if path_length > self._socket_dir_max_length:
raise NonvalidSocketPathException(
"Socket base directory path too big. The maximum allowed size is "
f"{self._socket_dir_max_length} bytes, but the directory has a size of "
f"{path_length}: {path}"
)


_os_map: dict[str, type[SocketDirectories]] = {
OSName.LINUX: LinuxSocketDirectories,
OSName.MACOS: MacOSSocketDirectories,
}


Expand Down
3 changes: 2 additions & 1 deletion src/openjd/adaptor_runtime/_osname.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ def __new__(cls, *args, **kw):
return str.__new__(cls, *args, **kw)

@staticmethod
def is_macos(name: str) -> bool:
def is_macos(name: Optional[str] = None) -> bool:
name = OSName._get_os_name() if name is None else name
return OSName.resolve_os_name(name) == OSName.MACOS

@staticmethod
Expand Down
44 changes: 40 additions & 4 deletions src/openjd/adaptor_runtime_client/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import socket as _socket
import ctypes as _ctypes
import os as _os
from sys import platform
from typing import Any
from http.client import HTTPConnection as _HTTPConnection


Expand All @@ -29,6 +31,26 @@ def __str__(self): # pragma: no cover
return f"pid:{self.pid} uid:{self.uid} gid:{self.gid}"


class XUCred(_ctypes.Structure):
"""
Represents the xucred struct returned from the LOCAL_PEERCRED socket option.
For more info, see LOCAL_PEERCRED in the unix(4) man page
"""

_fields_ = [
("version", _ctypes.c_uint),
("uid", _ctypes.c_uint),
("ngroups", _ctypes.c_short),
# cr_groups is a uint array of NGROUPS elements, which is defined as 16
# source:
# - https://github.com/apple-oss-distributions/xnu/blob/1031c584a5e37aff177559b9f69dbd3c8c3fd30a/bsd/sys/ucred.h#L207
# - https://github.com/apple-oss-distributions/xnu/blob/1031c584a5e37aff177559b9f69dbd3c8c3fd30a/bsd/sys/param.h#L100
# - https://github.com/apple-oss-distributions/xnu/blob/1031c584a5e37aff177559b9f69dbd3c8c3fd30a/bsd/sys/syslimits.h#L100
("groups", _ctypes.c_uint * 16),
]


class UnixHTTPConnection(_HTTPConnection): # pragma: no cover
"""
Specialization of http.client.HTTPConnection class that uses a UNIX domain socket.
Expand Down Expand Up @@ -61,13 +83,27 @@ def _authenticate(self) -> bool:
"Failed to handle request because it was not made through a UNIX socket"
)

peercred_opt_level: Any
peercred_opt: Any
cred_cls: Any
if platform == "darwin":
# SOL_LOCAL is not defined in Python's socket module, need to hardcode it
# source: https://github.com/apple-oss-distributions/xnu/blob/1031c584a5e37aff177559b9f69dbd3c8c3fd30a/bsd/sys/un.h#L85
peercred_opt_level = 0 # type: ignore[attr-defined]
peercred_opt = _socket.LOCAL_PEERCRED # type: ignore[attr-defined]
cred_cls = XUCred
else:
peercred_opt_level = _socket.SOL_SOCKET # type: ignore[attr-defined]
peercred_opt = _socket.SO_PEERCRED # type: ignore[attr-defined]
cred_cls = UCred

# Get the credentials of the peer process
cred_buffer = self.sock.getsockopt(
_socket.SOL_SOCKET, # type: ignore[attr-defined]
_socket.SO_PEERCRED, # type: ignore[attr-defined]
_socket.CMSG_SPACE(_ctypes.sizeof(UCred)), # type: ignore[attr-defined]
peercred_opt_level,
peercred_opt,
_socket.CMSG_SPACE(_ctypes.sizeof(cred_cls)), # type: ignore[attr-defined]
)
peer_cred = UCred.from_buffer_copy(cred_buffer)
peer_cred = cred_cls.from_buffer_copy(cred_buffer)

# Only allow connections from a process running as the same user
return peer_cred.uid == _os.getuid() # type: ignore[attr-defined]
8 changes: 5 additions & 3 deletions test/openjd/adaptor_runtime/unit/http/test_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ def test_respond_with_body(
mock_wfile.write.assert_called_once_with(body.encode("utf-8"))


@pytest.mark.skipif(not OSName.is_linux(), reason="Linux-specific tests")
@pytest.mark.skipif(not OSName.is_posix(), reason="Posix-specific tests")
class TestAuthentication:
"""
Tests for the RequestHandler authentication
Expand All @@ -148,6 +148,8 @@ class TestAuthenticate:
Tests for the RequestHandler._authenticate() method
"""

cred_cls = request_handler.XUCred if OSName.is_macos() else request_handler.UCred

@pytest.fixture
def mock_handler(self) -> MagicMock:
mock_socket = MagicMock(spec=socket.socket)
Expand All @@ -159,7 +161,7 @@ def mock_handler(self) -> MagicMock:
return mock_handler

@patch.object(request_handler.os, "getuid")
@patch.object(request_handler.UCred, "from_buffer_copy")
@patch.object(cred_cls, "from_buffer_copy")
def test_accepts_same_uid(
self, mock_from_buffer_copy: MagicMock, mock_getuid: MagicMock, mock_handler: MagicMock
) -> None:
Expand All @@ -174,7 +176,7 @@ def test_accepts_same_uid(
assert result

@patch.object(request_handler.os, "getuid")
@patch.object(request_handler.UCred, "from_buffer_copy")
@patch.object(cred_cls, "from_buffer_copy")
def test_rejects_different_uid(
self, mock_from_buffer_copy: MagicMock, mock_getuid: MagicMock, mock_handler: MagicMock
) -> None:
Expand Down
45 changes: 45 additions & 0 deletions test/openjd/adaptor_runtime/unit/http/test_sockets.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import openjd.adaptor_runtime._http.sockets as sockets
from openjd.adaptor_runtime._http.sockets import (
LinuxSocketDirectories,
MacOSSocketDirectories,
NonvalidSocketPathException,
NoSocketPathFoundException,
SocketDirectories,
Expand Down Expand Up @@ -263,3 +264,47 @@ def test_rejects_paths_over_100_bytes(self):
f"{subject._socket_dir_max_length} bytes, but the directory has a size of "
f"{length}: {path}"
)


class TestMacOSSocketDirectories:
@pytest.mark.parametrize(
argnames=["path"],
argvalues=[
["a"],
["a" * 96],
],
ids=["one byte", "96 bytes"],
)
def test_accepts_paths_within_100_bytes(self, path: str):
"""
Verifies the function accepts paths up to 96 bytes (104 byte max - 8 byte padding
for socket name portion (path sep + PID))
"""
# GIVEN
subject = MacOSSocketDirectories()

try:
# WHEN
subject.verify_socket_path(path)
except NonvalidSocketPathException as e:
pytest.fail(f"verify_socket_path raised an error when it should not have: {e}")
else:
# THEN
pass # success

def test_rejects_paths_over_96_bytes(self):
# GIVEN
length = 97
path = "a" * length
subject = MacOSSocketDirectories()

# WHEN
with pytest.raises(NonvalidSocketPathException) as raised_exc:
subject.verify_socket_path(path)

# THEN
assert raised_exc.match(
"Socket base directory path too big. The maximum allowed size is "
f"{subject._socket_dir_max_length} bytes, but the directory has a size of "
f"{length}: {path}"
)
4 changes: 2 additions & 2 deletions test/openjd/adaptor_runtime/unit/utils/test_secure_open.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@
],
)
@patch.object(os, "open")
@pytest.mark.skipif(not OSName.is_linux(), reason="Linux-specific tests")
def test_secure_open_in_linux(mock_os_open, path, open_mode, mask, expected_os_open_kwargs):
@pytest.mark.skipif(not OSName.is_posix(), reason="Posix-specific tests")
def test_secure_open_in_posix(mock_os_open, path, open_mode, mask, expected_os_open_kwargs):
# WHEN
with patch("builtins.open", mock_open()) as mocked_open:
secure_open_kwargs = {"mask": mask} if mask else {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def close(self, args: _Optional[_Dict[str, _Any]]) -> None:


@pytest.mark.skipif(not OSName.is_posix(), reason="Posix-specific tests")
class TestLinuxClientInterface:
class TestPosixClientInterface:
@pytest.mark.parametrize(
argnames=("original_path", "new_path"),
argvalues=[
Expand Down

0 comments on commit 4da070d

Please sign in to comment.