From 1be936cf1a3c7fa157ac7fe284cd82836301b154 Mon Sep 17 00:00:00 2001 From: Tallak Tveide Date: Wed, 14 Oct 2026 00:48:07 +0200 Subject: [PATCH 1/3] Raw Ethernet on macOS through BPF --- .gitignore | 4 + CHANGELOG.md | 7 + README.md | 7 + lib/ethercat/bus/link/redundant.ex | 4 +- lib/ethercat/bus/link/single.ex | 5 +- lib/ethercat/bus/transport.ex | 15 +- lib/ethercat/bus/transport/bpf.ex | 308 +++++++++++++++++++++++ mix.exs | 2 +- priv/bpf/ethercat_bpf.c | 268 ++++++++++++++++++++ test/ethercat/bus/transport/bpf_test.exs | 176 +++++++++++++ test/test_helper.exs | 36 +++ 11 files changed, 826 insertions(+), 6 deletions(-) create mode 100644 lib/ethercat/bus/transport/bpf.ex create mode 100644 priv/bpf/ethercat_bpf.c create mode 100644 test/ethercat/bus/transport/bpf_test.exs diff --git a/.gitignore b/.gitignore index dedb3e7..d83bfcc 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ todo/ # Local AI assistant settings .claude/settings.local.json + +# The macOS port program, compiled from priv/bpf/ethercat_bpf.c when it is first used. +/priv/bpf/ethercat_bpf +/priv/bpf/*.building diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a9374f..4f594a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Raw Ethernet on macOS: `{:raw, %{interface: "en7"}}` uses the new + `EtherCAT.Bus.Transport.Bpf`, which sends and receives EtherCAT frames through + a BPF device with a small C port program (`priv/bpf/ethercat_bpf.c`), compiled + with the system's C compiler on first use. `EtherCAT.Bus.Transport.raw/0` + gives the raw transport of the operating system. Linux is unchanged. + ## [0.5.0] - 2026-10-05 ### Breaking changes diff --git a/README.md b/README.md index aee8d70..0ec5b40 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,13 @@ Restart IEx/the application after granting the capability. Replace `eth0` with your interface. Scan **before** opening a master session; scanning assigns station addresses and must not run against an active ring. +On macOS, for a rack on a desk, the same `{:raw, %{interface: "en7"}}` backend +works through a BPF device and a small C port program, which is compiled with +the system's C compiler the first time it is used. It needs read and write +access to `/dev/bpf*`: root, or the `access_bpf` group that Wireshark's ChmodBPF +sets up. See `EtherCAT.Bus.Transport.Bpf`. The raw simulator and redundant +rings are Linux only. + ```elixir backend = {:raw, %{interface: "eth0"}} {:ok, scan} = EtherCAT.Scan.scan(backend) diff --git a/lib/ethercat/bus/link/redundant.ex b/lib/ethercat/bus/link/redundant.ex index 21a9a15..4b239ae 100644 --- a/lib/ethercat/bus/link/redundant.ex +++ b/lib/ethercat/bus/link/redundant.ex @@ -90,7 +90,7 @@ defmodule EtherCAT.Bus.Link.Redundant do alias EtherCAT.Bus.Frame alias EtherCAT.Bus.Link alias EtherCAT.Bus.Link.{RedundantMerge, Submission} - alias EtherCAT.Bus.Transport.RawSocket + alias EtherCAT.Bus.Transport alias EtherCAT.Telemetry @max_dispatch_errors 3 @@ -1086,6 +1086,6 @@ defmodule EtherCAT.Bus.Link.Redundant do end defp resolve_transport_mod(opts) do - opts[:transport_mod] || RawSocket + opts[:transport_mod] || Transport.raw() end end diff --git a/lib/ethercat/bus/link/single.ex b/lib/ethercat/bus/link/single.ex index 81926ba..864841a 100644 --- a/lib/ethercat/bus/link/single.ex +++ b/lib/ethercat/bus/link/single.ex @@ -19,7 +19,8 @@ defmodule EtherCAT.Bus.Link.Single do alias EtherCAT.Bus.{Datagram, Frame} alias EtherCAT.Bus.Link alias EtherCAT.Bus.Link.Submission - alias EtherCAT.Bus.Transport.{RawSocket, UdpSocket} + alias EtherCAT.Bus.Transport + alias EtherCAT.Bus.Transport.UdpSocket alias EtherCAT.Telemetry @max_dispatch_errors 3 @@ -476,7 +477,7 @@ defmodule EtherCAT.Bus.Link.Single do opts[:transport_mod] || case opts[:transport] do :udp -> UdpSocket - _ -> RawSocket + _ -> Transport.raw() end end end diff --git a/lib/ethercat/bus/transport.ex b/lib/ethercat/bus/transport.ex index aa60c89..59156cc 100644 --- a/lib/ethercat/bus/transport.ex +++ b/lib/ethercat/bus/transport.ex @@ -7,7 +7,8 @@ defmodule EtherCAT.Bus.Transport do ## Implementations - - `EtherCAT.Bus.Transport.RawSocket` — AF_PACKET raw Ethernet + - `EtherCAT.Bus.Transport.RawSocket` — AF_PACKET raw Ethernet, on Linux + - `EtherCAT.Bus.Transport.Bpf` — BPF raw Ethernet through a port program, on macOS - `EtherCAT.Bus.Transport.UdpSocket` — UDP/IP encapsulation (spec §2.6) ## Async receive flow @@ -25,6 +26,18 @@ defmodule EtherCAT.Bus.Transport do @type t :: struct() + @doc """ + The raw Ethernet transport of this operating system: `EtherCAT.Bus.Transport.Bpf` on macOS, + which has no `AF_PACKET` sockets, and `EtherCAT.Bus.Transport.RawSocket` everywhere else. + """ + @spec raw() :: module() + def raw do + case :os.type() do + {:unix, :darwin} -> EtherCAT.Bus.Transport.Bpf + _other -> EtherCAT.Bus.Transport.RawSocket + end + end + @doc "Open a transport from keyword options." @callback open(keyword()) :: {:ok, t()} | {:error, term()} diff --git a/lib/ethercat/bus/transport/bpf.ex b/lib/ethercat/bus/transport/bpf.ex new file mode 100644 index 0000000..fad71f8 --- /dev/null +++ b/lib/ethercat/bus/transport/bpf.ex @@ -0,0 +1,308 @@ +defmodule EtherCAT.Bus.Transport.Bpf do + @moduledoc """ + Raw Ethernet transport for EtherCAT on macOS, through a BPF device. + + macOS has no `AF_PACKET` sockets, which `EtherCAT.Bus.Transport.RawSocket` uses on Linux. Whole + Ethernet frames are sent and received through a BPF device (`/dev/bpfN`) instead, with `ioctl` + calls the BEAM cannot make. So this transport starts a small C program as an Erlang port, a + process of its own, which opens the device and passes frames of EtherType 0x88A4 both ways. It is + what `{:raw, %{interface: "en7"}}` uses on macOS; see `EtherCAT.Bus.Transport.raw/0`. + + Implements the `EtherCAT.Bus.Transport` behaviour. A frame that arrives is in the mailbox of the + process that opened the transport at once, so `set_active_once/1` and `rearm/1` have nothing to + arm, and `drain/1` takes the frames that are waiting there out of it. + + ## The port program + + The program is `priv/bpf/ethercat_bpf.c`. It is compiled with the system's C compiler the first + time a transport is opened, and again when its source is newer, so it needs the Xcode command + line tools (`xcode-select --install`) and nothing at build time, on any platform. + + ## Access + + Opening a BPF device needs read and write access to `/dev/bpf*`: root, or membership of the group + `access_bpf`, which Wireshark's ChmodBPF sets up. + + ## Timing + + A received frame is stamped when the BEAM takes it from the port, not by the kernel as on Linux, + so the time it took to come through the port program, some tens of microseconds, is part of + every round trip. macOS is for a rack on a desk: for timing that matters, run on Linux. + """ + + @behaviour EtherCAT.Bus.Transport + + # Suppress conflict with Kernel.send/2 — this module defines its own send/2 + import Kernel, except: [send: 2] + + alias EtherCAT.Telemetry + + @ethertype 0x88A4 + @broadcast_mac <<0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF>> + @zero_mac <<0, 0, 0, 0, 0, 0>> + @min_frame_size 60 + @open_timeout_ms 5_000 + + defstruct [:port, :interface, :src_mac] + + @type t :: %__MODULE__{ + port: port() | nil, + interface: String.t(), + src_mac: <<_::48>> + } + + @impl true + @doc "Start the port program on the given interface, and wait until it has the device open." + @spec open(keyword()) :: {:ok, t()} | {:error, term()} + def open(opts) do + interface = Keyword.fetch!(opts, :interface) + + with {:ok, program} <- program() do + port = + Port.open({:spawn_executable, program}, [ + :binary, + :exit_status, + {:packet, 2}, + args: [interface] + ]) + + receive do + {^port, {:data, <>}} -> + {:ok, %__MODULE__{port: port, interface: interface, src_mac: src_mac}} + + {^port, {:data, <>}} -> + await_exit(port) + {:error, {:bpf, reason}} + + {^port, {:exit_status, status}} -> + {:error, {:bpf_port_exited, status}} + after + @open_timeout_ms -> + close_port(port) + {:error, :bpf_port_timeout} + end + end + end + + @impl true + @doc "Send an EtherCAT payload wrapped in an Ethernet frame." + @spec send(t(), binary()) :: {:ok, integer()} | {:error, term()} + def send(%__MODULE__{port: nil}, _ecat_payload), do: {:error, :closed} + + def send(%__MODULE__{port: port, src_mac: src}, ecat_payload) do + tx_at = System.monotonic_time() + + frame_body = << + @broadcast_mac::binary, + src::binary-size(6), + @ethertype::big-unsigned-16, + ecat_payload::binary + >> + + pad_needed = max(0, @min_frame_size - byte_size(frame_body)) + frame = <> + + try do + Port.command(port, frame) + {:ok, tx_at} + rescue + ArgumentError -> {:error, :closed} + end + end + + @impl true + @doc "Nothing to arm: the port program delivers every frame as it comes." + @spec set_active_once(t()) :: :ok + def set_active_once(%__MODULE__{}), do: :ok + + @impl true + @doc "Nothing to re-arm: a frame behind the one just read is in the mailbox already." + @spec rearm(t()) :: :ok + def rearm(%__MODULE__{}), do: :ok + + @impl true + @doc """ + Match a message from this transport's port program. + + A received frame has its Ethernet headers stripped, and comes back as the EtherCAT payload with + an rx timestamp and the frame's source MAC address (6 bytes). A frame that could not be sent, or + a port program that has ended, is an error. Returns `:ignore` for all other messages. + """ + @spec match(t(), term()) :: {:ok, binary(), integer(), binary()} | {:error, term()} | :ignore + def match(%__MODULE__{port: port} = transport, {port, {:data, <>}}) + when is_port(port) do + rx_at = System.monotonic_time() + + case strip_ethernet_headers(frame) do + {:ok, ecat_payload, frame_src_mac} -> + {:ok, ecat_payload, rx_at, frame_src_mac} + + {:error, reason} -> + Telemetry.frame_dropped(transport.interface, byte_size(frame), reason) + :ignore + end + end + + def match(%__MODULE__{port: port}, {port, {:data, <>}}) when is_port(port), + do: {:error, {:send_failed, reason}} + + def match(%__MODULE__{port: port}, {port, {:exit_status, status}}) when is_port(port), + do: {:error, {:bpf_port_exited, status}} + + def match(%__MODULE__{}, _msg), do: :ignore + + @impl true + @doc "Returns this NIC's source MAC address (6 bytes)." + @spec src_mac(t()) :: <<_::48>> + def src_mac(%__MODULE__{src_mac: mac}), do: mac + + @impl true + @doc "Take the frames that have arrived out of the mailbox." + @spec drain(t()) :: :ok + def drain(%__MODULE__{port: nil}), do: :ok + + def drain(%__MODULE__{port: port} = transport) do + receive do + {^port, {:data, <>}} -> drain(transport) + after + 0 -> :ok + end + end + + @impl true + @doc "Stop the port program. Returns the struct with `port` set to `nil`." + @spec close(t()) :: t() + def close(%__MODULE__{port: nil} = transport), do: transport + + def close(%__MODULE__{port: port} = transport) do + close_port(port) + %{transport | port: nil} + end + + @impl true + @doc "Returns `true` when the port program is running." + @spec open?(t()) :: boolean() + def open?(%__MODULE__{port: nil}), do: false + def open?(%__MODULE__{}), do: true + + @impl true + @doc "Returns the network interface name for telemetry and link monitoring." + @spec name(t()) :: String.t() + def name(%__MODULE__{interface: interface}), do: interface + + @impl true + @doc "Returns the interface name used by the bus link monitor." + @spec interface(t()) :: String.t() + def interface(%__MODULE__{interface: interface}), do: interface + + @doc """ + The port program, compiled when it is not there or older than its source. + + Returns `{:ok, path}`, or `{:error, reason}` when there is no C compiler or it failed. + """ + @spec program() :: {:ok, String.t()} | {:error, term()} + def program do + source = Application.app_dir(:ethercat, "priv/bpf/ethercat_bpf.c") + + # Beside its source where that can be written, as in a project's own build, and else in the + # temporary directory, under the version of the source it was built from. + targets = [ + Path.rootname(source), + Path.join(System.tmp_dir!(), "ethercat_bpf-#{source |> File.read!() |> :erlang.phash2()}") + ] + + case Enum.find(targets, &fresh?(&1, source)) do + nil -> build(source, targets, nil) + built -> {:ok, built} + end + end + + # -- private ---------------------------------------------------------------- + + defp fresh?(target, source) do + with {:ok, %{mtime: built, type: :regular}} <- File.stat(target, time: :posix), + {:ok, %{mtime: written}} <- File.stat(source, time: :posix) do + built >= written + else + _missing -> false + end + end + + defp build(_source, [], last_error), do: {:error, last_error} + + defp build(source, [target | others], _last_error) do + case System.find_executable("cc") do + nil -> + {:error, :no_c_compiler} + + cc -> + # Built under a name of its own and moved into place, so that two transports that open at + # once never run a half-written program. + building = "#{target}.#{System.unique_integer([:positive])}.building" + + case System.cmd(cc, ["-O2", "-o", building, source], stderr_to_stdout: true) do + {_output, 0} -> + case File.rename(building, target) do + :ok -> {:ok, target} + {:error, reason} -> build(source, others, {:bpf_build_failed, reason}) + end + + {output, _status} -> + File.rm(building) + build(source, others, {:bpf_build_failed, String.trim(output)}) + end + end + end + + defp close_port(port) do + try do + Port.close(port) + rescue + ArgumentError -> :ok + end + + flush(port) + end + + defp await_exit(port) do + receive do + {^port, {:exit_status, _status}} -> :ok + after + 1_000 -> close_port(port) + end + end + + # What a port that is closed had still sent. + defp flush(port) do + receive do + {^port, _message} -> flush(port) + after + 0 -> :ok + end + end + + defp strip_ethernet_headers(<< + _dst::binary-size(6), + src::binary-size(6), + 0x88A4::big-unsigned-16, + rest::binary + >>) do + if valid_source_mac?(src), do: {:ok, rest, src}, else: {:error, :invalid_source_mac} + end + + # VLAN-tagged frame (802.1Q) + defp strip_ethernet_headers(<< + _dst::binary-size(6), + src::binary-size(6), + 0x8100::big-unsigned-16, + _vlan::binary-size(2), + 0x88A4::big-unsigned-16, + rest::binary + >>) do + if valid_source_mac?(src), do: {:ok, rest, src}, else: {:error, :invalid_source_mac} + end + + defp strip_ethernet_headers(_), do: {:error, :not_ethercat} + + defp valid_source_mac?(mac), do: mac not in [@broadcast_mac, @zero_mac] +end diff --git a/mix.exs b/mix.exs index 3abbb0c..37ee176 100644 --- a/mix.exs +++ b/mix.exs @@ -61,7 +61,7 @@ defmodule EtherCAT.MixProject do [ licenses: ["Apache-2.0"], links: %{"GitHub" => @source_url}, - files: ~w(lib mix.exs README.md LICENSE CHANGELOG.md usage-rules.md) + files: ~w(lib priv/bpf/ethercat_bpf.c mix.exs README.md LICENSE CHANGELOG.md usage-rules.md) ] end diff --git a/priv/bpf/ethercat_bpf.c b/priv/bpf/ethercat_bpf.c new file mode 100644 index 0000000..c166bc6 --- /dev/null +++ b/priv/bpf/ethercat_bpf.c @@ -0,0 +1,268 @@ +/* + * ethercat_bpf: raw EtherCAT frames for the BEAM on macOS. + * + * macOS has no AF_PACKET sockets. A program sends and receives whole Ethernet frames through a BPF + * device, /dev/bpfN, which takes ioctl calls the BEAM cannot make. This program does that as an + * Erlang port, a process of its own: EtherCAT.Bus.Transport.Bpf starts it with the name of the + * interface, and the two talk over standard input and output in packets with a two-byte length. + * + * to the BEAM: 'R' mac[6] ready, with the hardware address of the interface + * 'F' frame a frame that was received, from its Ethernet header on + * 'S' text a frame could not be sent + * 'E' text it could not start; it then ends with status 1 + * from the BEAM: frame a frame to send, from its Ethernet header on + * + * Only frames of EtherType 0x88A4 are received, with or without one VLAN tag, and never the frames + * this machine sent itself. It ends when its standard input is closed. + * + * Opening a BPF device needs read and write access to /dev/bpf*: root, or the group access_bpf that + * Wireshark's ChmodBPF sets up. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define ETHERTYPE_ETHERCAT 0x88A4 +#define ETHERTYPE_VLAN 0x8100 +#define MAX_PACKET 65535 + +static int write_all(int fd, const void *data, size_t size) { + const uint8_t *at = data; + + while (size > 0) { + ssize_t written = write(fd, at, size); + + if (written < 0) { + if (errno == EINTR) continue; + return -1; + } + + at += written; + size -= (size_t)written; + } + + return 0; +} + +/* 1 when all of it was read, 0 at the end of the input, -1 on an error. */ +static int read_all(int fd, void *data, size_t size) { + uint8_t *at = data; + + while (size > 0) { + ssize_t got = read(fd, at, size); + + if (got == 0) return 0; + + if (got < 0) { + if (errno == EINTR) continue; + return -1; + } + + at += got; + size -= (size_t)got; + } + + return 1; +} + +/* One packet to the BEAM: a tag and what goes with it. The BEAM is gone when it cannot be written. */ +static void emit(char tag, const void *data, size_t size) { + uint8_t head[3]; + + if (size > MAX_PACKET - 1) size = MAX_PACKET - 1; + + head[0] = (uint8_t)((size + 1) >> 8); + head[1] = (uint8_t)((size + 1) & 0xFF); + head[2] = (uint8_t)tag; + + if (write_all(STDOUT_FILENO, head, sizeof head) < 0 || + write_all(STDOUT_FILENO, data, size) < 0) + exit(0); +} + +static void fail(const char *what) { + char text[256]; + int length = snprintf(text, sizeof text, "%s: %s", what, strerror(errno)); + + emit('E', text, (size_t)length); + exit(1); +} + +static int open_bpf(void) { + char path[32]; + int denied = 0; + + for (int n = 0; n < 256; n++) { + snprintf(path, sizeof path, "/dev/bpf%d", n); + + int fd = open(path, O_RDWR); + if (fd >= 0) return fd; + + if (errno == EACCES || errno == EPERM) denied = 1; + if (errno == ENOENT) break; + } + + errno = denied ? EACCES : EBUSY; + return -1; +} + +/* The hardware address of the interface, 0 when it has none of six bytes. */ +static int hardware_address(const char *name, uint8_t mac[6]) { + struct ifaddrs *all; + int found = 0; + + if (getifaddrs(&all) < 0) return 0; + + for (struct ifaddrs *each = all; each != NULL; each = each->ifa_next) { + if (each->ifa_addr == NULL || each->ifa_addr->sa_family != AF_LINK) continue; + if (strcmp(each->ifa_name, name) != 0) continue; + + struct sockaddr_dl *link = (struct sockaddr_dl *)each->ifa_addr; + + if (link->sdl_alen == 6) { + memcpy(mac, LLADDR(link), 6); + found = 1; + } + + break; + } + + freeifaddrs(all); + return found; +} + +int main(int argc, char **argv) { + if (argc != 2) { + errno = EINVAL; + fail("usage: ethercat_bpf INTERFACE"); + } + + const char *name = argv[1]; + + if (strlen(name) >= IFNAMSIZ) { + errno = ENAMETOOLONG; + fail(name); + } + + int bpf = open_bpf(); + if (bpf < 0) fail("cannot open a /dev/bpf device"); + + /* The size of the buffer is set before the device is bound to an interface, and read back. */ + u_int size = 1 << 17; + ioctl(bpf, BIOCSBLEN, &size); + + struct ifreq interface; + memset(&interface, 0, sizeof interface); + strlcpy(interface.ifr_name, name, sizeof interface.ifr_name); + if (ioctl(bpf, BIOCSETIF, &interface) < 0) fail(name); + + u_int kind; + if (ioctl(bpf, BIOCGDLT, &kind) < 0) fail("BIOCGDLT"); + + if (kind != DLT_EN10MB) { + errno = EPROTOTYPE; + fail("not an Ethernet interface"); + } + + u_int on = 1, off = 0; + /* A read gives a frame as soon as it has come, and not when the buffer is full. */ + if (ioctl(bpf, BIOCIMMEDIATE, &on) < 0) fail("BIOCIMMEDIATE"); + /* What is sent on the interface from this machine is not received here. The tests build a + listener that does receive it, to see what the program sends without a second interface. */ +#ifdef ETHERCAT_BPF_SEE_SENT + if (ioctl(bpf, BIOCSSEESENT, &on) < 0) fail("BIOCSSEESENT"); + (void)off; +#else + if (ioctl(bpf, BIOCSSEESENT, &off) < 0) fail("BIOCSSEESENT"); +#endif + /* The source address of a frame is sent as it was written. */ + if (ioctl(bpf, BIOCSHDRCMPLT, &on) < 0) fail("BIOCSHDRCMPLT"); + + struct bpf_insn only_ethercat[] = { + BPF_STMT(BPF_LD + BPF_H + BPF_ABS, 12), + BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, ETHERTYPE_ETHERCAT, 3, 0), + BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, ETHERTYPE_VLAN, 0, 3), + BPF_STMT(BPF_LD + BPF_H + BPF_ABS, 16), + BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, ETHERTYPE_ETHERCAT, 0, 1), + BPF_STMT(BPF_RET + BPF_K, (u_int)-1), + BPF_STMT(BPF_RET + BPF_K, 0), + }; + + struct bpf_program filter = { + .bf_len = sizeof only_ethercat / sizeof only_ethercat[0], + .bf_insns = only_ethercat, + }; + + if (ioctl(bpf, BIOCSETF, &filter) < 0) fail("BIOCSETF"); + if (ioctl(bpf, BIOCGBLEN, &size) < 0) fail("BIOCGBLEN"); + + uint8_t mac[6]; + + if (!hardware_address(name, mac)) { + errno = EADDRNOTAVAIL; + fail("the interface has no hardware address"); + } + + uint8_t *received = malloc(size); + uint8_t *sending = malloc(MAX_PACKET); + + if (received == NULL || sending == NULL) fail("malloc"); + + emit('R', mac, sizeof mac); + + for (;;) { + fd_set ready; + FD_ZERO(&ready); + FD_SET(STDIN_FILENO, &ready); + FD_SET(bpf, &ready); + + if (select(bpf + 1, &ready, NULL, NULL, NULL) < 0) { + if (errno == EINTR) continue; + return 2; + } + + if (FD_ISSET(bpf, &ready)) { + ssize_t got = read(bpf, received, size); + + if (got < 0 && errno != EINTR && errno != EAGAIN) return 2; + + /* One read may give several frames, each after a header that says how long it is. */ + for (uint8_t *at = received; got > 0 && at < received + got;) { + struct bpf_hdr *head = (struct bpf_hdr *)at; + + emit('F', at + head->bh_hdrlen, head->bh_caplen); + at += BPF_WORDALIGN(head->bh_hdrlen + head->bh_caplen); + } + } + + if (FD_ISSET(STDIN_FILENO, &ready)) { + uint8_t length[2]; + + if (read_all(STDIN_FILENO, length, sizeof length) <= 0) return 0; + + size_t frame = ((size_t)length[0] << 8) | length[1]; + + if (read_all(STDIN_FILENO, sending, frame) <= 0) return 0; + + if (write(bpf, sending, frame) < 0) { + const char *why = strerror(errno); + emit('S', why, strlen(why)); + } + } + } +} diff --git a/test/ethercat/bus/transport/bpf_test.exs b/test/ethercat/bus/transport/bpf_test.exs new file mode 100644 index 0000000..f098f1f --- /dev/null +++ b/test/ethercat/bus/transport/bpf_test.exs @@ -0,0 +1,176 @@ +defmodule EtherCAT.Bus.Transport.BpfTest do + use ExUnit.Case, async: false + + alias EtherCAT.Bus.Transport + alias EtherCAT.Bus.Transport.Bpf + + @moduletag :bpf + + @ethertype 0x88A4 + @broadcast <<0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF>> + + setup_all do + interface = System.fetch_env!("ETHERCAT_BPF_INTERFACE") + + # A second port program on the same interface, built to receive what the machine itself sends: + # it shows what the transport puts on the wire without a second interface to receive it on. + source = Application.app_dir(:ethercat, "priv/bpf/ethercat_bpf.c") + + listener = + Path.join(System.tmp_dir!(), "ethercat_bpf_listener_#{System.unique_integer([:positive])}") + + {_output, 0} = + System.cmd("cc", ["-O2", "-DETHERCAT_BPF_SEE_SENT=1", "-o", listener, source], + stderr_to_stdout: true + ) + + on_exit(fn -> File.rm(listener) end) + %{interface: interface, listener: listener} + end + + defp listen(%{interface: interface, listener: listener}) do + port = + Port.open({:spawn_executable, listener}, [ + :binary, + :exit_status, + {:packet, 2}, + args: [interface] + ]) + + assert_receive {^port, {:data, <>}}, 2_000 + port + end + + # Writes a whole Ethernet frame on the interface, as another station might have sent it. + defp inject(port, dst, src, ethertype, payload) do + body = <> + Port.command(port, <>) + end + + test "it is the raw transport of macOS" do + assert Transport.raw() == Bpf + end + + test "the port program is built once, and found again" do + assert {:ok, program} = Bpf.program() + assert File.regular?(program) + %{mtime: built} = File.stat!(program, time: :posix) + assert {:ok, ^program} = Bpf.program() + assert %{mtime: ^built} = File.stat!(program, time: :posix) + end + + test "it opens the interface with its hardware address, and closes", %{interface: interface} do + assert {:ok, %Bpf{interface: ^interface, src_mac: <<_::48>> = mac} = transport} = + Bpf.open(interface: interface) + + assert Bpf.open?(transport) + assert Bpf.src_mac(transport) == mac + assert Bpf.name(transport) == interface + assert Bpf.interface(transport) == interface + assert :ok = Bpf.set_active_once(transport) + assert :ok = Bpf.rearm(transport) + assert :ok = Bpf.drain(transport) + + closed = Bpf.close(transport) + refute Bpf.open?(closed) + assert Bpf.send(closed, <<1, 2, 3>>) == {:error, :closed} + assert Bpf.close(closed) == closed + assert Bpf.drain(closed) == :ok + refute_receive {_port, {:exit_status, _status}}, 50 + end + + test "an interface that is not there is an error, with nothing left running" do + assert {:error, {:bpf, reason}} = Bpf.open(interface: "nope0") + assert reason =~ "nope0" + refute_receive {_port, _message}, 50 + end + + test "a payload goes out as an EtherCAT frame, and the transport does not hear itself", + context do + listener = listen(context) + {:ok, transport} = Bpf.open(interface: context.interface) + payloads = for n <- 1..5, do: <> <> :binary.copy(<>, n * 20) + + for payload <- payloads do + assert {:ok, tx_at} = Bpf.send(transport, payload) + assert is_integer(tx_at) + end + + for payload <- payloads do + assert_receive {^listener, {:data, <>}}, 1_000 + <> = frame + assert {dst, src, type} == {@broadcast, transport.src_mac, @ethertype} + assert String.starts_with?(rest, payload) + # A short frame is padded to the least an Ethernet frame may be. + assert byte_size(frame) == max(60, 14 + byte_size(payload)) + end + + port = transport.port + refute_receive {^port, _message}, 100 + Bpf.close(transport) + Port.close(listener) + end + + test "a frame that arrives is matched to its payload, and what is not EtherCAT never arrives", + context do + {:ok, transport} = Bpf.open(interface: context.interface) + # With see-sent on, what one port program writes is what the other receives. + {:ok, peer} = Bpf.open(interface: context.interface) + sender = listen(context) + port = transport.port + station = <<0x02, 0x00, 0x5E, 0x10, 0x00, 0x01>> + + # The transport proper never receives what this machine sends: so it is tried on the listener's + # kind of program, by reading that program's messages with the transport's own match. + seeing = %{transport | port: sender} + + inject(peer.port, @broadcast, station, 0x0806, <<1, 2, 3, 4>>) + inject(peer.port, @broadcast, station, @ethertype, <<0xAA, 0xBB, 0xCC>>) + inject(peer.port, @broadcast, station, 0x8100, <<0, 5, @ethertype::16, 0xDD, 0xEE>>) + inject(peer.port, @broadcast, <<0::48>>, @ethertype, <<0x11>>) + + assert_receive {^sender, {:data, <>}} = first, 1_000 + assert {:ok, <<0xAA, 0xBB, 0xCC, _pad::binary>>, rx_at, ^station} = Bpf.match(seeing, first) + assert is_integer(rx_at) + + # One VLAN tag is seen through. + assert_receive {^sender, {:data, <>}} = tagged, 1_000 + assert {:ok, <<0xDD, 0xEE, _pad::binary>>, _rx_at, ^station} = Bpf.match(seeing, tagged) + + # A frame from no station is dropped by the transport; the ARP frame never came this far. + assert_receive {^sender, {:data, <>}} = nobody, 1_000 + assert Bpf.match(seeing, nobody) == :ignore + refute_receive {^sender, {:data, _frame}}, 100 + + # What is not from the transport's own port is not its message. + assert Bpf.match(transport, first) == :ignore + assert Bpf.match(transport, :anything) == :ignore + refute_receive {^port, _message}, 50 + + Bpf.close(transport) + Bpf.close(peer) + Port.close(sender) + end + + test "frames that wait are drained, and a port program that ends is an error", context do + {:ok, transport} = Bpf.open(interface: context.interface) + port = transport.port + + # Frames in the mailbox, as they are when they arrive with nobody waiting. + for n <- 1..3, do: send(self(), {port, {:data, <>}}) + assert :ok = Bpf.drain(transport) + refute_receive {^port, {:data, _frame}}, 20 + + assert Bpf.match(transport, {port, {:data, <>}}) == + {:error, {:send_failed, "Network is down"}} + + {:os_pid, os_pid} = Port.info(port, :os_pid) + System.cmd("kill", ["-9", "#{os_pid}"]) + assert_receive {^port, {:exit_status, status}} = gone, 2_000 + assert Bpf.match(transport, gone) == {:error, {:bpf_port_exited, status}} + assert Bpf.send(transport, <<1>>) == {:error, :closed} + + closed = Bpf.close(transport) + refute Bpf.open?(closed) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 048fa33..7d55973 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -13,6 +13,41 @@ defmodule EtherCAT.TestHelper do ) end + # The macOS raw transport is tested on the interface ETHERCAT_BPF_INTERFACE names: one that is not + # on a network, such as a USB Ethernet adapter with no cable in it, as the test sends frames on it. + def bpf_excludes do + interface = System.get_env("ETHERCAT_BPF_INTERFACE") + + cond do + :os.type() != {:unix, :darwin} -> + [:bpf] + + interface in [nil, ""] -> + IO.puts( + :stderr, + "warning: excluding :bpf tests. Set ETHERCAT_BPF_INTERFACE to an Ethernet interface " <> + "that is not on a network, such as a USB adapter with no cable in it." + ) + + [:bpf] + + true -> + case EtherCAT.Bus.Transport.Bpf.open(interface: interface) do + {:ok, transport} -> + EtherCAT.Bus.Transport.Bpf.close(transport) + [] + + {:error, reason} -> + IO.puts( + :stderr, + "warning: excluding :bpf tests: #{interface} cannot be opened, #{inspect(reason)}" + ) + + [:bpf] + end + end + end + def raw_socket_redundant_excludes do exclusion_for(:raw_socket_redundant, "redundant raw simulator", redundant_raw_interfaces()) end @@ -210,6 +245,7 @@ ExUnit.start( formatters: [ExUnit.CLIFormatter, EtherCAT.TestProgressFormatter], exclude: [:hardware] ++ + EtherCAT.TestHelper.bpf_excludes() ++ EtherCAT.TestHelper.raw_socket_excludes() ++ EtherCAT.TestHelper.raw_socket_redundant_excludes() ++ EtherCAT.TestHelper.raw_socket_redundant_toggle_excludes() From d8d59e38be49a21c40904fbb5564ec61669292c9 Mon Sep 17 00:00:00 2001 From: Jannik Becher Date: Thu, 15 Oct 2026 14:57:31 +0200 Subject: [PATCH 2/3] fix(bpf): secure helper builds and validate macOS wire transport Compile into atomically created owner-only temporary directories and reuse only builds published by this VM. Ignore pre-existing executables, serialize concurrent builds, and rebuild on source-content changes. Report exited helper ports as closed before explicit transport cleanup. Exercise the production BPF receive path over an isolated macOS feth pair in the shared mix ci workflow. Configured BPF setup failures now fail instead of silently excluding the tests. Document the fixture and remove the test-only see-sent helper variant. Validation: mix ci passed locally (484 tests; 30 hardware/platform tests excluded), including six portable build-security/lifecycle regressions. macOS wire validation follows on the PR runner before merge. Co-authored-by: Codex --- .github/workflows/ci.yml | 20 ++++ .gitignore | 4 - CHANGELOG.md | 3 + README.md | 23 +++++ lib/ethercat/bus/transport/bpf.ex | 57 ++--------- lib/ethercat/bus/transport/bpf/program.ex | 84 ++++++++++++++++ priv/bpf/ethercat_bpf.c | 8 +- .../bus/transport/bpf_lifecycle_test.exs | 21 ++++ .../bus/transport/bpf_program_test.exs | 72 ++++++++++++++ test/ethercat/bus/transport/bpf_test.exs | 95 +++++++------------ test/test_helper.exs | 22 +---- 11 files changed, 268 insertions(+), 141 deletions(-) create mode 100644 lib/ethercat/bus/transport/bpf/program.ex create mode 100644 test/ethercat/bus/transport/bpf_lifecycle_test.exs create mode 100644 test/ethercat/bus/transport/bpf_program_test.exs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 300930f..ad35bed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,5 +61,25 @@ jobs: if: runner.os == 'macOS' run: sudo ifconfig lo0 alias 127.0.0.2 + - name: Create isolated BPF test interfaces + if: runner.os == 'macOS' + run: | + sudo ifconfig feth0 create + sudo ifconfig feth1 create + sudo ifconfig feth1 peer feth0 + sudo ifconfig feth0 up + sudo ifconfig feth1 up + # Only this ephemeral runner gets direct BPF access. No physical NIC, + # IP address, bridge, or Internet sharing is used by these tests. + sudo chown "$(id -u):$(id -g)" /dev/bpf* + echo "ETHERCAT_BPF_INTERFACE=feth0" >> "$GITHUB_ENV" + echo "ETHERCAT_BPF_PEER_INTERFACE=feth1" >> "$GITHUB_ENV" + - name: Run CI run: mix ci + + - name: Remove BPF test interfaces + if: always() && runner.os == 'macOS' + run: | + sudo ifconfig feth0 destroy + sudo ifconfig feth1 destroy diff --git a/.gitignore b/.gitignore index d83bfcc..dedb3e7 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,3 @@ todo/ # Local AI assistant settings .claude/settings.local.json - -# The macOS port program, compiled from priv/bpf/ethercat_bpf.c when it is first used. -/priv/bpf/ethercat_bpf -/priv/bpf/*.building diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a9aafb..13536b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 a BPF device with a small C port program (`priv/bpf/ethercat_bpf.c`), compiled with the system's C compiler on first use. `EtherCAT.Bus.Transport.raw/0` gives the raw transport of the operating system. Linux is unchanged (`1be936c`). + Helper builds use owner-only temporary directories and a VM-local cache rather + than trusting pre-existing executables; helper exits are reported as closed + transports (`pending`). ### Fixed - CoE mailbox response polling uses a one-second monotonic deadline rather than diff --git a/README.md b/README.md index 0ec5b40..9310469 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,29 @@ The driver comparison demonstrates a shared signal catalogue without changing the public callbacks. The command above explicitly excludes raw transports. Hardware and raw-socket tests need separate setup. +### macOS BPF tests without hardware + +With the Xcode command line tools and read/write access to `/dev/bpf*` (see +`EtherCAT.Bus.Transport.Bpf`), create an isolated virtual Ethernet pair: + +```bash +sudo ifconfig feth0 create +sudo ifconfig feth1 create +sudo ifconfig feth1 peer feth0 +sudo ifconfig feth0 up +sudo ifconfig feth1 up +ETHERCAT_BPF_INTERFACE=feth0 ETHERCAT_BPF_PEER_INTERFACE=feth1 mix ci +sudo ifconfig feth0 destroy +sudo ifconfig feth1 destroy +``` + +Do not attach these interfaces to a bridge or use physical adapters. These tests +send raw frames and check actual receive filtering, VLANs, padding, no self-receive, +and helper lifecycle. CI provisions this pair automatically and runs the same +`mix ci` on Linux and macOS. Without the BPF environment variables, wire tests are +excluded; once configured, setup or helper errors fail the suite. Portable helper +build-security and port-liveness tests run on both platforms. + ## Pick your next task - **Write a driver:** [callback contract and catalogue pattern](https://hexdocs.pm/ethercat/0.5.0/EtherCAT.Driver.html). diff --git a/lib/ethercat/bus/transport/bpf.ex b/lib/ethercat/bus/transport/bpf.ex index fad71f8..d510563 100644 --- a/lib/ethercat/bus/transport/bpf.ex +++ b/lib/ethercat/bus/transport/bpf.ex @@ -15,8 +15,10 @@ defmodule EtherCAT.Bus.Transport.Bpf do ## The port program The program is `priv/bpf/ethercat_bpf.c`. It is compiled with the system's C compiler the first - time a transport is opened, and again when its source is newer, so it needs the Xcode command - line tools (`xcode-select --install`) and nothing at build time, on any platform. + time a transport is opened in each VM, and again when its source changes. It needs the Xcode + command line tools (`xcode-select --install`) and nothing at build time, on any platform. + Each build uses a new owner-only temporary directory; existing on-disk executables are never + trusted as a cache. Successful builds remain in the OS temporary directory for reuse by this VM. ## Access @@ -183,7 +185,7 @@ defmodule EtherCAT.Bus.Transport.Bpf do @doc "Returns `true` when the port program is running." @spec open?(t()) :: boolean() def open?(%__MODULE__{port: nil}), do: false - def open?(%__MODULE__{}), do: true + def open?(%__MODULE__{port: port}), do: Port.info(port) != nil @impl true @doc "Returns the network interface name for telemetry and link monitoring." @@ -196,7 +198,7 @@ defmodule EtherCAT.Bus.Transport.Bpf do def interface(%__MODULE__{interface: interface}), do: interface @doc """ - The port program, compiled when it is not there or older than its source. + The port program, compiled securely once per VM and source revision. Returns `{:ok, path}`, or `{:error, reason}` when there is no C compiler or it failed. """ @@ -204,56 +206,11 @@ defmodule EtherCAT.Bus.Transport.Bpf do def program do source = Application.app_dir(:ethercat, "priv/bpf/ethercat_bpf.c") - # Beside its source where that can be written, as in a project's own build, and else in the - # temporary directory, under the version of the source it was built from. - targets = [ - Path.rootname(source), - Path.join(System.tmp_dir!(), "ethercat_bpf-#{source |> File.read!() |> :erlang.phash2()}") - ] - - case Enum.find(targets, &fresh?(&1, source)) do - nil -> build(source, targets, nil) - built -> {:ok, built} - end + EtherCAT.Bus.Transport.Bpf.Program.ensure(source) end # -- private ---------------------------------------------------------------- - defp fresh?(target, source) do - with {:ok, %{mtime: built, type: :regular}} <- File.stat(target, time: :posix), - {:ok, %{mtime: written}} <- File.stat(source, time: :posix) do - built >= written - else - _missing -> false - end - end - - defp build(_source, [], last_error), do: {:error, last_error} - - defp build(source, [target | others], _last_error) do - case System.find_executable("cc") do - nil -> - {:error, :no_c_compiler} - - cc -> - # Built under a name of its own and moved into place, so that two transports that open at - # once never run a half-written program. - building = "#{target}.#{System.unique_integer([:positive])}.building" - - case System.cmd(cc, ["-O2", "-o", building, source], stderr_to_stdout: true) do - {_output, 0} -> - case File.rename(building, target) do - :ok -> {:ok, target} - {:error, reason} -> build(source, others, {:bpf_build_failed, reason}) - end - - {output, _status} -> - File.rm(building) - build(source, others, {:bpf_build_failed, String.trim(output)}) - end - end - end - defp close_port(port) do try do Port.close(port) diff --git a/lib/ethercat/bus/transport/bpf/program.ex b/lib/ethercat/bus/transport/bpf/program.ex new file mode 100644 index 0000000..70f6301 --- /dev/null +++ b/lib/ethercat/bus/transport/bpf/program.ex @@ -0,0 +1,84 @@ +defmodule EtherCAT.Bus.Transport.Bpf.Program do + @moduledoc false + + # Cache only builds created by this VM, never a path discovered on disk. The + # lock keeps concurrent opens from compiling or publishing the same revision. + @spec ensure(String.t()) :: {:ok, String.t()} | {:error, term()} + def ensure(source) do + with {:ok, contents} <- File.read(source) do + :global.trans( + {{__MODULE__, source}, self()}, + fn -> + cached_or_build(source, contents) + end, + [node()] + ) + end + end + + defp cached_or_build(source, contents) do + key = {__MODULE__, source} + + case :persistent_term.get(key, nil) do + {^contents, program} -> + if File.regular?(program), do: {:ok, program}, else: build_and_cache(key, contents) + + _missing_or_changed -> + build_and_cache(key, contents) + end + end + + defp build_and_cache(key, contents) do + with {:ok, program} <- build(contents) do + :persistent_term.put(key, {contents, program}) + {:ok, program} + end + end + + defp build(contents) do + case System.find_executable("cc") do + nil -> {:error, :no_c_compiler} + cc -> build_in_private_directory(cc, contents) + end + end + + defp build_in_private_directory(cc, contents) do + # mktemp creates the directory atomically with mode 0700. mkdir followed by + # chmod leaves a window in which another user can plant files or symlinks. + template = Path.join(System.tmp_dir!(), "ethercat-bpf.XXXXXXXXXX") + + case System.cmd("mktemp", ["-d", template], stderr_to_stdout: true) do + {directory, 0} -> compile(cc, String.trim(directory), contents) + {output, _status} -> {:error, {:bpf_build_failed, String.trim(output)}} + end + rescue + error in ErlangError -> {:error, {:bpf_build_failed, error.original}} + end + + defp compile(cc, directory, contents) do + source = Path.join(directory, "ethercat_bpf.c") + program = Path.join(directory, "ethercat_bpf") + + result = + with :ok <- File.write(source, contents), + {_output, 0} <- System.cmd(cc, ["-O2", "-o", program, source], stderr_to_stdout: true) do + {:ok, program} + else + {:error, reason} -> {:error, {:bpf_build_failed, reason}} + {output, _status} -> {:error, {:bpf_build_failed, String.trim(output)}} + end + + case result do + {:ok, _program} -> + result + + {:error, _reason} -> + File.rm_rf(directory) + result + end + rescue + error in ErlangError -> + File.rm_rf(directory) + {:error, {:bpf_build_failed, error.original}} + end +end diff --git a/priv/bpf/ethercat_bpf.c b/priv/bpf/ethercat_bpf.c index c166bc6..9f152e2 100644 --- a/priv/bpf/ethercat_bpf.c +++ b/priv/bpf/ethercat_bpf.c @@ -182,14 +182,8 @@ int main(int argc, char **argv) { u_int on = 1, off = 0; /* A read gives a frame as soon as it has come, and not when the buffer is full. */ if (ioctl(bpf, BIOCIMMEDIATE, &on) < 0) fail("BIOCIMMEDIATE"); - /* What is sent on the interface from this machine is not received here. The tests build a - listener that does receive it, to see what the program sends without a second interface. */ -#ifdef ETHERCAT_BPF_SEE_SENT - if (ioctl(bpf, BIOCSSEESENT, &on) < 0) fail("BIOCSSEESENT"); - (void)off; -#else + /* Never receive this interface's own transmissions. Tests use a separate feth peer. */ if (ioctl(bpf, BIOCSSEESENT, &off) < 0) fail("BIOCSSEESENT"); -#endif /* The source address of a frame is sent as it was written. */ if (ioctl(bpf, BIOCSHDRCMPLT, &on) < 0) fail("BIOCSHDRCMPLT"); diff --git a/test/ethercat/bus/transport/bpf_lifecycle_test.exs b/test/ethercat/bus/transport/bpf_lifecycle_test.exs new file mode 100644 index 0000000..2b29579 --- /dev/null +++ b/test/ethercat/bus/transport/bpf_lifecycle_test.exs @@ -0,0 +1,21 @@ +defmodule EtherCAT.Bus.Transport.BpfLifecycleTest do + use ExUnit.Case, async: true + + alias EtherCAT.Bus.Transport.Bpf + + test "a helper that exits is no longer open, before explicit close" do + port = + Port.open({:spawn_executable, System.find_executable("sh")}, [ + :exit_status, + args: ["-c", "exit 7"] + ]) + + transport = %Bpf{port: port, interface: "test", src_mac: <<2, 0, 0, 0, 0, 1>>} + + assert_receive {^port, {:exit_status, 7}} = message, 2_000 + assert Bpf.match(transport, message) == {:error, {:bpf_port_exited, 7}} + refute Bpf.open?(transport) + assert Bpf.send(transport, <<1>>) == {:error, :closed} + assert %Bpf{port: nil} = Bpf.close(transport) + end +end diff --git a/test/ethercat/bus/transport/bpf_program_test.exs b/test/ethercat/bus/transport/bpf_program_test.exs new file mode 100644 index 0000000..fcd68bb --- /dev/null +++ b/test/ethercat/bus/transport/bpf_program_test.exs @@ -0,0 +1,72 @@ +defmodule EtherCAT.Bus.Transport.Bpf.ProgramTest do + use ExUnit.Case, async: false + + alias EtherCAT.Bus.Transport.Bpf.Program + + @moduletag :tmp_dir + + setup %{tmp_dir: directory} do + source = Path.join(directory, "helper.c") + File.write!(source, "#include \nint main(void) { puts(\"trusted\"); return 0; }\n") + %{source: source} + end + + test "builds in an owner-only directory and ignores planted executables", %{source: source} do + beside_source = Path.rootname(source) + + predictable = + Path.join(System.tmp_dir!(), "ethercat_bpf-#{:erlang.phash2(File.read!(source))}") + + planted = "#!/bin/sh\necho untrusted\n" + File.write!(beside_source, planted) + File.ln_s!(beside_source, predictable) + on_exit(fn -> File.rm(predictable) end) + + assert {:ok, program} = Program.ensure(source) + on_exit(fn -> File.rm_rf(Path.dirname(program)) end) + refute program in [beside_source, predictable] + assert {"trusted\n", 0} = System.cmd(program, []) + assert File.read!(beside_source) == planted + assert {:ok, ^beside_source} = File.read_link(predictable) + assert %{type: :directory, mode: mode} = File.lstat!(Path.dirname(program)) + assert String.ends_with?(Integer.to_string(mode, 8), "700") + assert {:ok, ^program} = Program.ensure(source) + end + + test "concurrent callers reuse only a completed build", %{source: source} do + results = + 1..8 + |> Task.async_stream(fn _ -> Program.ensure(source) end, max_concurrency: 8) + |> Enum.to_list() + + assert [{:ok, {:ok, program}}] = Enum.uniq(results) + on_exit(fn -> File.rm_rf(Path.dirname(program)) end) + assert {"trusted\n", 0} = System.cmd(program, []) + end + + test "changed source is rebuilt even with the same modification time", %{source: source} do + assert {:ok, first} = Program.ensure(source) + %{mtime: mtime} = File.stat!(source) + File.write!(source, "#include \nint main(void) { puts(\"changed\"); return 0; }\n") + File.touch!(source, mtime) + assert {:ok, second} = Program.ensure(source) + on_exit(fn -> Enum.each([first, second], &File.rm_rf(Path.dirname(&1))) end) + refute second == first + assert {"changed\n", 0} = System.cmd(second, []) + end + + test "compiler failures are returned and not cached", %{source: source} do + File.write!(source, "this is not C") + assert {:error, {:bpf_build_failed, message}} = Program.ensure(source) + assert is_binary(message) + File.write!(source, "int main(void) { return 0; }\n") + assert {:ok, program} = Program.ensure(source) + on_exit(fn -> File.rm_rf(Path.dirname(program)) end) + assert {"", 0} = System.cmd(program, []) + end + + test "missing sources are returned as errors", %{source: source} do + File.rm!(source) + assert Program.ensure(source) == {:error, :enoent} + end +end diff --git a/test/ethercat/bus/transport/bpf_test.exs b/test/ethercat/bus/transport/bpf_test.exs index f098f1f..3e4c07f 100644 --- a/test/ethercat/bus/transport/bpf_test.exs +++ b/test/ethercat/bus/transport/bpf_test.exs @@ -10,40 +10,17 @@ defmodule EtherCAT.Bus.Transport.BpfTest do @broadcast <<0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF>> setup_all do - interface = System.fetch_env!("ETHERCAT_BPF_INTERFACE") - - # A second port program on the same interface, built to receive what the machine itself sends: - # it shows what the transport puts on the wire without a second interface to receive it on. - source = Application.app_dir(:ethercat, "priv/bpf/ethercat_bpf.c") - - listener = - Path.join(System.tmp_dir!(), "ethercat_bpf_listener_#{System.unique_integer([:positive])}") - - {_output, 0} = - System.cmd("cc", ["-O2", "-DETHERCAT_BPF_SEE_SENT=1", "-o", listener, source], - stderr_to_stdout: true - ) - - on_exit(fn -> File.rm(listener) end) - %{interface: interface, listener: listener} + # Use an isolated feth pair, never a physical NIC or the runner's network. + %{ + interface: System.fetch_env!("ETHERCAT_BPF_INTERFACE"), + peer: System.fetch_env!("ETHERCAT_BPF_PEER_INTERFACE") + } end - defp listen(%{interface: interface, listener: listener}) do - port = - Port.open({:spawn_executable, listener}, [ - :binary, - :exit_status, - {:packet, 2}, - args: [interface] - ]) - - assert_receive {^port, {:data, <>}}, 2_000 - port - end - - # Writes a whole Ethernet frame on the interface, as another station might have sent it. - defp inject(port, dst, src, ethertype, payload) do - body = <> + # Writes a whole Ethernet frame through the peer, so it arrives on the + # transport's real receive path with the production BIOCSSEESENT setting. + defp inject(port, src, ethertype, payload) do + body = <<@broadcast::binary, src::binary, ethertype::16, payload::binary>> Port.command(port, <>) end @@ -87,9 +64,10 @@ defmodule EtherCAT.Bus.Transport.BpfTest do test "a payload goes out as an EtherCAT frame, and the transport does not hear itself", context do - listener = listen(context) + {:ok, listener} = Bpf.open(interface: context.peer) {:ok, transport} = Bpf.open(interface: context.interface) payloads = for n <- 1..5, do: <> <> :binary.copy(<>, n * 20) + listener_port = listener.port for payload <- payloads do assert {:ok, tx_at} = Bpf.send(transport, payload) @@ -97,66 +75,56 @@ defmodule EtherCAT.Bus.Transport.BpfTest do end for payload <- payloads do - assert_receive {^listener, {:data, <>}}, 1_000 + assert_receive {^listener_port, {:data, <>}}, 2_000 <> = frame assert {dst, src, type} == {@broadcast, transport.src_mac, @ethertype} assert String.starts_with?(rest, payload) - # A short frame is padded to the least an Ethernet frame may be. assert byte_size(frame) == max(60, 14 + byte_size(payload)) end port = transport.port refute_receive {^port, _message}, 100 Bpf.close(transport) - Port.close(listener) + Bpf.close(listener) end - test "a frame that arrives is matched to its payload, and what is not EtherCAT never arrives", - context do + test "incoming frames are filtered and matched, including VLAN and invalid sources", context do {:ok, transport} = Bpf.open(interface: context.interface) - # With see-sent on, what one port program writes is what the other receives. - {:ok, peer} = Bpf.open(interface: context.interface) - sender = listen(context) + {:ok, peer} = Bpf.open(interface: context.peer) port = transport.port station = <<0x02, 0x00, 0x5E, 0x10, 0x00, 0x01>> - # The transport proper never receives what this machine sends: so it is tried on the listener's - # kind of program, by reading that program's messages with the transport's own match. - seeing = %{transport | port: sender} + inject(peer.port, station, 0x0806, <<1, 2, 3, 4>>) + inject(peer.port, station, @ethertype, <<0xAA, 0xBB, 0xCC>>) + inject(peer.port, station, 0x8100, <<0, 5, @ethertype::16, 0xDD, 0xEE>>) + inject(peer.port, <<0::48>>, @ethertype, <<0x11>>) - inject(peer.port, @broadcast, station, 0x0806, <<1, 2, 3, 4>>) - inject(peer.port, @broadcast, station, @ethertype, <<0xAA, 0xBB, 0xCC>>) - inject(peer.port, @broadcast, station, 0x8100, <<0, 5, @ethertype::16, 0xDD, 0xEE>>) - inject(peer.port, @broadcast, <<0::48>>, @ethertype, <<0x11>>) + assert_receive {^port, {:data, <>}} = first, 2_000 - assert_receive {^sender, {:data, <>}} = first, 1_000 - assert {:ok, <<0xAA, 0xBB, 0xCC, _pad::binary>>, rx_at, ^station} = Bpf.match(seeing, first) - assert is_integer(rx_at) + assert {:ok, <<0xAA, 0xBB, 0xCC, _pad::binary>>, rx_at, ^station} = + Bpf.match(transport, first) - # One VLAN tag is seen through. - assert_receive {^sender, {:data, <>}} = tagged, 1_000 - assert {:ok, <<0xDD, 0xEE, _pad::binary>>, _rx_at, ^station} = Bpf.match(seeing, tagged) + assert is_integer(rx_at) - # A frame from no station is dropped by the transport; the ARP frame never came this far. - assert_receive {^sender, {:data, <>}} = nobody, 1_000 - assert Bpf.match(seeing, nobody) == :ignore - refute_receive {^sender, {:data, _frame}}, 100 + assert_receive {^port, {:data, <>}} = tagged, 2_000 + assert {:ok, <<0xDD, 0xEE, _pad::binary>>, _rx_at, ^station} = Bpf.match(transport, tagged) - # What is not from the transport's own port is not its message. - assert Bpf.match(transport, first) == :ignore + # The ARP frame never passed the kernel filter; the zero source is rejected + # by the transport rather than accepted as a station response. + assert_receive {^port, {:data, <>}} = nobody, 2_000 + assert Bpf.match(transport, nobody) == :ignore + refute_receive {^port, {:data, _frame}}, 100 + assert Bpf.match(peer, first) == :ignore assert Bpf.match(transport, :anything) == :ignore - refute_receive {^port, _message}, 50 Bpf.close(transport) Bpf.close(peer) - Port.close(sender) end test "frames that wait are drained, and a port program that ends is an error", context do {:ok, transport} = Bpf.open(interface: context.interface) port = transport.port - # Frames in the mailbox, as they are when they arrive with nobody waiting. for n <- 1..3, do: send(self(), {port, {:data, <>}}) assert :ok = Bpf.drain(transport) refute_receive {^port, {:data, _frame}}, 20 @@ -168,6 +136,7 @@ defmodule EtherCAT.Bus.Transport.BpfTest do System.cmd("kill", ["-9", "#{os_pid}"]) assert_receive {^port, {:exit_status, status}} = gone, 2_000 assert Bpf.match(transport, gone) == {:error, {:bpf_port_exited, status}} + refute Bpf.open?(transport) assert Bpf.send(transport, <<1>>) == {:error, :closed} closed = Bpf.close(transport) diff --git a/test/test_helper.exs b/test/test_helper.exs index 7d55973..f038c1f 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -13,8 +13,8 @@ defmodule EtherCAT.TestHelper do ) end - # The macOS raw transport is tested on the interface ETHERCAT_BPF_INTERFACE names: one that is not - # on a network, such as a USB Ethernet adapter with no cable in it, as the test sends frames on it. + # BPF tests require an isolated feth pair. Once explicitly configured, setup + # or helper failures must fail the tests, never silently exclude them. def bpf_excludes do interface = System.get_env("ETHERCAT_BPF_INTERFACE") @@ -25,26 +25,14 @@ defmodule EtherCAT.TestHelper do interface in [nil, ""] -> IO.puts( :stderr, - "warning: excluding :bpf tests. Set ETHERCAT_BPF_INTERFACE to an Ethernet interface " <> - "that is not on a network, such as a USB adapter with no cable in it." + "warning: excluding :bpf tests. Set ETHERCAT_BPF_INTERFACE and " <> + "ETHERCAT_BPF_PEER_INTERFACE to an isolated feth pair (see README.md)." ) [:bpf] true -> - case EtherCAT.Bus.Transport.Bpf.open(interface: interface) do - {:ok, transport} -> - EtherCAT.Bus.Transport.Bpf.close(transport) - [] - - {:error, reason} -> - IO.puts( - :stderr, - "warning: excluding :bpf tests: #{interface} cannot be opened, #{inspect(reason)}" - ) - - [:bpf] - end + [] end end From 779fed990cb91ead8488f7d52b519514084e84b9 Mon Sep 17 00:00:00 2001 From: Jannik Becher Date: Thu, 15 Oct 2026 15:02:13 +0200 Subject: [PATCH 3/3] docs: record the BPF hardening commit in the changelog Replace the pending reference with the validated implementation commit. No code changes; validation remains the preceding successful mix ci run. Co-authored-by: Codex --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13536b2..36c284b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 gives the raw transport of the operating system. Linux is unchanged (`1be936c`). Helper builds use owner-only temporary directories and a VM-local cache rather than trusting pre-existing executables; helper exits are reported as closed - transports (`pending`). + transports (`d8d59e3`). ### Fixed - CoE mailbox response polling uses a one-second monotonic deadline rather than