Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add macOS socket support and enable macOS CI #65

Merged
merged 2 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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]
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