Skip to content
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
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,38 @@ jobs:
- name: Run unit + realtime tests under RTSan
run: ctest --test-dir build-rtsan --output-on-failure

freestanding:
name: Freestanding profile (${{ matrix.cxx }}, -fno-exceptions -fno-rtti)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { cc: gcc, cxx: g++ }
- { cc: clang, cxx: clang++ }
steps:
- uses: actions/checkout@v4

# Builds only the embedded-profile smoke test: the parse/serialize core
# with C++ exceptions and RTTI disabled and the hosted-only facilities
# dropped (-DOSCTAP_FREESTANDING). This is the standing guard that the
# core stays buildable for embedded targets (e.g. a Raspberry Pi Pico 2W;
# see docs/EMBEDDED_PICO2W.md). The OSCTAP_THROW seam routes validation
# failures to a fatal handler when exceptions are off.
- name: Configure (freestanding profile)
run: >
cmake -S . -B build-freestanding -DOSCPACK_BUILD_EXAMPLES=OFF
-DOSCTAP_FREESTANDING=ON -DOSCTAP_WARNINGS_AS_ERRORS=ON
env:
CC: ${{ matrix.cc }}
CXX: ${{ matrix.cxx }}

- name: Build
run: cmake --build build-freestanding --target OscFreestandingTest

- name: Run freestanding smoke test
run: ctest --test-dir build-freestanding -R OscFreestandingTest --output-on-failure

tsan:
name: ThreadSanitizer (receive loop concurrency)
runs-on: ubuntu-latest
Expand Down
19 changes: 19 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ enable_testing()
set(OSCPACK_BUILD_EXAMPLES ON CACHE BOOL "Should we build examples")
set(OSCTAP_RTSAN OFF CACHE BOOL "Build the realtime-safety test under RealtimeSanitizer + function-effect analysis (requires Clang >= 20)")
set(OSCTAP_TSAN OFF CACHE BOOL "Build the concurrency test under ThreadSanitizer (POSIX; GCC/Clang)")
set(OSCTAP_FREESTANDING OFF CACHE BOOL "Build the freestanding/embedded-profile smoke test with exceptions and RTTI disabled (Phase 2 'Reach')")

set(CMAKE_INCLUDE_CURRENT_DIR 1)
set(CMAKE_POSITION_INDEPENDENT_CODE 1)
Expand Down Expand Up @@ -92,6 +93,24 @@ if(OSCPACK_BUILD_EXAMPLES)
#target_link_libraries(SimpleSend oscpack)
endif()

# Freestanding / embedded profile (Phase 2 "Reach"). Builds a single smoke test
# of the parse/serialize core with C++ exceptions and RTTI disabled and the
# hosted-only facilities dropped (-DOSCTAP_FREESTANDING). The flags are PRIVATE
# to this target so the exception-based tests above are unaffected; this target
# is the standing guard that the core stays embedded-buildable (e.g. a Raspberry
# Pi Pico 2W — see docs/EMBEDDED_PICO2W.md).
if(OSCTAP_FREESTANDING)
add_executable(OscFreestandingTest tests/OscFreestandingTest.cpp)
target_link_libraries(OscFreestandingTest oscpack)
target_compile_definitions(OscFreestandingTest PRIVATE OSCTAP_FREESTANDING)
if(MSVC)
target_compile_options(OscFreestandingTest PRIVATE /EHs-c- /GR-)
else()
target_compile_options(OscFreestandingTest PRIVATE -fno-exceptions -fno-rtti)
endif()
add_test(NAME OscFreestandingTest COMMAND OscFreestandingTest)
endif()

set(OSCTAP_BUILD_FUZZERS OFF CACHE BOOL "Build the libFuzzer fuzz target (requires Clang)")
set(OSCTAP_FUZZER_STANDALONE OFF CACHE BOOL "Build the fuzz target with the standalone driver instead of libFuzzer (works with g++/ASan, no fuzzer runtime needed)")

Expand Down
29 changes: 24 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ OscTap is the actively-maintained, security-hardened, modern-C++ continuation of
document is the source of truth for the rebrand and the work plan. See
[`docs/HERITAGE.md`](docs/HERITAGE.md) for lineage and credits.

> Status: planning / Phase 0 in progress on branch `claude/oscpack-review-audit-h8n268`.
> Status: Phase 0 and Phase 1 complete. Phase 2 ("Reach") prep in progress — the
> freestanding/embedded profile groundwork has landed (see Phase 2 below); the
> remaining Reach items stay demand-gated.

## Why OscTap exists

Expand Down Expand Up @@ -140,10 +142,27 @@ See [Sanitizer strategy](#sanitizer-strategy) for scope and rationale.
| MSan | optional | later | catches uninitialized-memory reads (cf. the past "uninitialized OSC address bytes" fix); high setup friction (instrumented libc++), so not a standing job. |

### Phase 2 — Reach (only as demand appears)
- [ ] QEMU aarch64 / armv7 CI.
- [ ] Android NDK build.
- [ ] Freestanding/embedded profile (no exceptions/RTTI option).
- [ ] Multicast receive (cherry-pick from `stephram/oscpack`).
- [x] **Freestanding/embedded profile (no exceptions/RTTI option)** — *groundwork
landed.* A single build seam (`osc/OscConfig.h`) auto-detects
`OSCTAP_HAS_EXCEPTIONS` and routes every core `throw` through `OSCTAP_THROW`
(a plain `throw` when exceptions are on; a non-returning, user-overridable
fatal handler — `OSCTAP_FATAL_HANDLER`, default `std::abort()` — when they are
off). `OSCTAP_FREESTANDING` drops the hosted-only facilities (`<iostream>`, the
`std::vector`-backed `OwnedMessage`, the `std::string` `operator<<`). The
`OSCTAP_FREESTANDING` CMake option builds `tests/OscFreestandingTest.cpp` with
`-fno-exceptions -fno-rtti`; a `freestanding` CI job (GCC + Clang) keeps it
green. Hosted builds are byte-for-byte unchanged. **Demand signal: Raspberry
Pi Pico 2W (RP2350)** — see [`docs/EMBEDDED_PICO2W.md`](docs/EMBEDDED_PICO2W.md).
Deferred: a **non-throwing `TryInit`/validate** entry point so a no-exceptions
build can *reject* untrusted packets by returning an error instead of aborting
(today, malformed input on an exceptions-off build is fatal — safe only on a
trusted link; open networks should keep exceptions on and `catch`).
- [ ] QEMU aarch64 / armv7 CI. *(Demand-gated — adds standing CI surface; stand up
only when a real big-endian/cross target needs it. The freestanding profile
already exercises the no-OS code paths without QEMU's cost.)*
- [ ] Android NDK build. *(Demand-gated — pursue when an Android consumer appears.)*
- [ ] Multicast receive (cherry-pick from `stephram/oscpack`). *(Self-contained;
the next demand-driven feature pickup after the freestanding groundwork.)*

## Milestones → GitHub

Expand Down
194 changes: 194 additions & 0 deletions docs/EMBEDDED_PICO2W.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# OscTap on the Raspberry Pi Pico 2W (RP2350)

> Status: Phase 2 ("Reach") groundwork. The freestanding profile this guide uses
> is landed and CI-guarded; the Pico SDK glue below is an integration recipe, not
> a vendored board build. See [`../ROADMAP.md`](../ROADMAP.md).

The Pico 2W pairs the dual-core **RP2350** (Arm Cortex-M33 @ 150 MHz) with the
**CYW43439** wireless chip, so it can speak OSC over Wi-Fi/UDP. OscTap's core —
the `osc/` headers — is header-only, dependency-free, allocation-free on the
read/serialize hot path, and freestanding-friendly, which makes it a natural fit
for an embedded OSC endpoint.

This guide shows how to build the OscTap **core** against the Pico SDK + lwIP,
using the freestanding profile (exceptions/RTTI off) introduced for Phase 2.

## What you use — and what you don't

| OscTap layer | On the Pico 2W |
|--------------|----------------|
| `osc/` core — `OscReceivedElements.h`, `OscOutboundPacketStream.h`, `OscTypes.h`, `OscUtilities.h` | **Use it.** Parses/builds OSC into/out of a plain byte buffer. |
| `ip/` sockets — `ip/posix`, `ip/win32` (`UdpSocket`, `NetworkingUtils`) | **Don't use it.** Those are POSIX/WinSock backends. On the Pico, networking is **lwIP** (the SDK's TCP/IP stack), so you call lwIP's UDP API directly and hand the payload to the OscTap core. |

The split is deliberate: the OSC wire format is the reusable part; the transport
is whatever your platform provides.

## The freestanding profile (`OSCTAP_FREESTANDING`)

The Pico SDK builds C++ **without exceptions or RTTI by default**
(`PICO_CXX_ENABLE_EXCEPTIONS=0`, `PICO_CXX_ENABLE_RTTI=0`). OscTap supports that
through a single build seam (`osc/OscConfig.h`):

- `OSCTAP_HAS_EXCEPTIONS` is auto-detected from the compiler. Under
`-fno-exceptions` it becomes `0` and every `throw` in the library is routed
through `OSCTAP_THROW`.
- `OSCTAP_FREESTANDING` (you define it) drops the hosted-only conveniences:
`<iostream>`, the `std::vector`-backed `OwnedMessage`, and the
`operator<<(const std::string&)` overload. Pass `const char*`, a string
literal, or `osctap::string_view` instead.

That combination is exactly what the `freestanding` CI job and
`tests/OscFreestandingTest.cpp` build and run on every push, so the core is kept
embedded-buildable.

### Handling validation when exceptions are off

OSC packets arriving off the network are **untrusted input**. With exceptions
enabled, OscTap's parser *throws* (`MalformedPacketException`,
`MalformedMessageException`, `MalformedBundleException`) when it rejects a bad
packet, and you `catch` it to drop that packet. With exceptions **disabled**,
there is nothing to catch — `OSCTAP_THROW` instead calls a fatal handler that
**must not return** (the default `std::abort()`s).

That has a security consequence you must design around:

> On a no-exceptions build, a single malformed packet from anyone who can reach
> the device will trigger the fatal handler — i.e. a remote reset / DoS.

Pick the model that matches your threat surface:

1. **Trusted / closed link** (a fixed sender on a private wire or AP, no exposure
to arbitrary senders): the freestanding no-exceptions build is fine. Define
`OSCTAP_FATAL_HANDLER` to log and reset deliberately — reaching it means a
programming error or a genuinely corrupt link, not routine traffic.

2. **Untrusted / open network**: keep **exceptions enabled** on the Pico
(`pico_enable_exceptions(<target>)` / `PICO_CXX_ENABLE_EXCEPTIONS=1`) and
`catch` the `Malformed*Exception` types to drop bad packets. The OscTap API is
identical either way — `OSCTAP_THROW` is just `throw` here. You still get the
rest of the embedded posture (no heap on the hot path, small code).

Either way, **validation runs in the lwIP receive callback, off any audio/render
thread** — consistent with OscTap's realtime contract (validation may throw and
is not part of the RT hot path; the throw-free `*Unchecked` accessors are).

> Future work (tracked in Phase 2): a non-throwing `TryInit`/validate entry point
> so a no-exceptions build can *reject* untrusted packets by returning an error
> instead of aborting. Until then, option 2 is the safe choice for open networks.

## Build integration (CMake + Pico SDK)

Add the OscTap include root and define the profile. OscTap's core is
header-only, so there is no library to compile or link.

```cmake
# After pico_sdk_init() and your add_executable(osc_demo ...)

target_include_directories(osc_demo PRIVATE
${OSCTAP_DIR}) # repo root: enables <osctap/osc/...> and the
# in-tree quoted "osc/..." includes

target_compile_definitions(osc_demo PRIVATE
OSCTAP_FREESTANDING) # drop hosted-only facilities

# Wi-Fi UDP via lwIP (threadsafe-background or poll arch):
target_link_libraries(osc_demo
pico_stdlib
pico_cyw43_arch_lwip_threadsafe_background)

# Trusted-link model: keep exceptions off (SDK default) and provide a handler.
# Open-network model instead: pico_enable_exceptions(osc_demo)
```

If you choose the trusted-link model, define the fatal handler **before**
including any OscTap header (e.g. via a small `osctap_config.h` you force-include,
or a `-D`):

```cpp
// Logs and resets; must not return.
#define OSCTAP_FATAL_HANDLER(whatCStr) osc_fatal((whatCStr))

[[noreturn]] void osc_fatal(const char* what); // defined in your app
```

## Receiving OSC (lwIP UDP → OscTap)

lwIP hands you the datagram in a `pbuf` from your `udp_recv` callback. Validate +
dispatch there (off the realtime thread), then read with the throw-free
accessors in your hot loop. *Illustrative — adapt names to your SDK version:*

```cpp
#include "osc/OscReceivedElements.h"

static void on_osc_packet(void* /*arg*/, struct udp_pcb* /*pcb*/,
struct pbuf* p, const ip_addr_t* /*addr*/, u16_t /*port*/)
{
if (!p) return;

// Copy the (possibly chained) pbuf into a contiguous, 4-byte-aligned buffer.
alignas(4) static char buf[1472]; // <= typical Ethernet MTU payload
const u16_t n = pbuf_copy_partial(p, buf, sizeof(buf), 0);
pbuf_free(p);

// Open-network model: wrap construction in try/catch (exceptions ON) so a
// malformed packet is dropped, not fatal. Trusted-link model: drop the
// try/catch — construction aborts via OSCTAP_FATAL_HANDLER on bad input.
osctap::ReceivedPacket packet(buf, n);
if (!packet.IsMessage()) return; // (handle bundles similarly)

osctap::ReceivedMessage msg(packet);

if (std::strcmp(msg.AddressPattern(), "/led") == 0) {
// Known-valid from here: realtime-safe, throw-free reads.
auto arg = msg.ArgumentsBegin();
const bool on = arg->AsBoolUnchecked();
cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, on);
}
}

// setup: udp_recv(pcb, on_osc_packet, nullptr);
```

## Sending OSC (OscTap → lwIP UDP)

Serialize into a stack buffer (no heap), then ship it through an lwIP `pbuf`:

```cpp
#include "osc/OscOutboundPacketStream.h"

void send_fader(udp_pcb* pcb, const ip_addr_t* dst, uint16_t port, float v)
{
char buffer[64];
osctap::OutboundPacketStream p(buffer, sizeof(buffer));
p << osctap::BeginMessage("/fader/1") << v << osctap::EndMessage();

struct pbuf* pb = pbuf_alloc(PBUF_TRANSPORT, p.Size(), PBUF_RAM);
std::memcpy(pb->payload, p.Data(), p.Size());
udp_sendto(pcb, pb, dst, port);
pbuf_free(pb);
}
```

`OutboundPacketStream` never allocates and writes only into the buffer you give
it; if the message would overflow that buffer it raises
`OutOfBufferMemoryException` (caught, or fatal, per your exception model), so size
the buffer for your largest message.

## Realtime / no-heap checklist

- Parse/serialize touch **only** your byte buffer — no `malloc` on the OscTap
side. Keep the buffers static or on the stack.
- Read the audio-thread hot path through the `*Unchecked` accessors
(`AsInt32Unchecked`, `AsFloatUnchecked`, …); they are the functions OscTap
annotates realtime-safe (`OSCTAP_REALTIME`).
- Do all **validation** (constructing `ReceivedPacket`/`ReceivedMessage`) in the
network callback, never on the audio render core.
- lwIP's own `pbuf`/heap pools are separate from OscTap; tune them via
`lwipopts.h` as usual.

## See also

- [`../ROADMAP.md`](../ROADMAP.md) — Phase 2 plan and the realtime contract.
- [`STATUS.md`](STATUS.md) — landmines and build matrix.
- `tests/OscFreestandingTest.cpp` — the CI-built proof that the core compiles and
runs with exceptions/RTTI disabled.
31 changes: 31 additions & 0 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ the original conversation. For the full plan and rationale see

- **Phase 0 is complete** (security audit fixes, fuzzer, CI, docs, namespace rename).
See the scorecard in `ROADMAP.md`.
- **Phase 1 is complete** (directory rename + shim, ClusterFuzzLite, `bit_cast`/
`constexpr` parsing, warnings-as-errors, RTSan, TSan).
- **Phase 2 ("Reach") prep has started**: the freestanding/embedded profile
groundwork is landed — the `OSCTAP_THROW` seam (`osc/OscConfig.h`), the
`OSCTAP_FREESTANDING` CMake option + `freestanding` CI job, and the Raspberry Pi
Pico 2W guide ([`EMBEDDED_PICO2W.md`](EMBEDDED_PICO2W.md)). The remaining Reach
items (QEMU, Android NDK, multicast) stay demand-gated.
- All six audit findings are fixed with regression tests; see commit history and
`tests/OscUnitTests.cpp` (`test4`/`test5`).
- **CI is the source of truth for build health.** `.github/workflows/ci.yml` builds and
Expand All @@ -34,6 +41,10 @@ cmake --build build-fuzz --target fuzz_parse && ./build-fuzz/fuzz_parse fuzz/cor
# fuzzing — standalone driver (g++, no libFuzzer runtime needed)
cmake -S . -B build-fuzz -DOSCTAP_FUZZER_STANDALONE=ON
cmake --build build-fuzz --target fuzz_parse && ./build-fuzz/fuzz_parse fuzz/corpus/*

# freestanding / embedded profile (exceptions + RTTI off)
cmake -S . -B build-fs -DOSCPACK_BUILD_EXAMPLES=OFF -DOSCTAP_FREESTANDING=ON
cmake --build build-fs --target OscFreestandingTest && ./build-fs/OscFreestandingTest
```

## Landmines — read before changing things
Expand Down Expand Up @@ -74,6 +85,26 @@ cmake --build build-fuzz --target fuzz_parse && ./build-fuzz/fuzz_parse fuzz/cor
`reinterpret_cast<T*>` over the byte buffer, or `#ifdef OSC_HOST_*_ENDIAN`** — that was
the audit-#6 UB, and UBSan guards against it. The byte helpers are `constexpr`; keep the
RT read accessors routed through them (`memcpy` is RTSan/function-effects-safe).
- **`OSCTAP_THROW` is the only way the core raises** (`osc/OscConfig.h`). Every
`throw` in `OscReceivedElements.h`/`OscOutboundPacketStream.h` goes through it so
the library compiles under `-fno-exceptions`. **Do not reintroduce a bare `throw`
in the core** — it breaks the `freestanding` CI job (a bare `throw` is a hard
error under `-fno-exceptions`). With exceptions on, `OSCTAP_THROW(X)` *is* `throw X`,
so hosted behaviour (and the `test4`/`test5` malformed-input asserts) is unchanged.
Under `-fno-exceptions` it calls a non-returning fatal handler (default
`std::abort()`, overridable via `OSCTAP_FATAL_HANDLER`).
- **`OSCTAP_FREESTANDING` drops hosted-only facilities** — `<iostream>`, the
`std::vector`-backed `OwnedMessage`, and the `std::string` `operator<<`. If you add
a new core feature that needs `<iostream>`/`<vector>`/`std::string`, guard it with
`#ifndef OSCTAP_FREESTANDING` (and keep `tests/OscFreestandingTest.cpp` compiling),
or it will break the `freestanding` job. The freestanding flags are **PRIVATE to
the `OscFreestandingTest` target**, so the exception-based tests are unaffected.
Embedded posture and the Pico 2W recipe live in `docs/EMBEDDED_PICO2W.md`.
- **Untrusted input on a no-exceptions build is fatal.** The parser validates by
throwing; with exceptions off there is nothing to catch, so a malformed packet
hits the fatal handler (a remote reset/DoS). Safe only on a trusted link; open
networks should keep exceptions on and `catch` the `Malformed*Exception` types. A
non-throwing `TryInit`/validate is the tracked Phase 2 follow-up.
- **Include guards are still named `INCLUDED_OSCPACK_*`** — cosmetic, left as-is.
- The test harness (`NewMessageBuffer`/`AllocateAligned4`) **intentionally leaks** its
aligned scratch buffers, which is why the ASan job runs with
Expand Down
11 changes: 11 additions & 0 deletions oscpack/osc/OscConfig.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
OscTap compatibility shim.

The library directory and public include prefix moved from <oscpack/...>
to <osctap/...> (the namespace likewise moved oscpack -> osctap, kept as a
deprecated alias). This header redirects the old include path to the new one
so existing <oscpack/osc/OscConfig.h> consumers keep compiling unchanged.

Deprecated: prefer <osctap/osc/OscConfig.h>. See ROADMAP.md / docs/STATUS.md.
*/
#include <osctap/osc/OscConfig.h>
Loading
Loading