From a9bf0b0f8b6a95075c30dd25b52674b222154d93 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 11 Feb 2026 08:41:06 -0600 Subject: [PATCH] Switch from Alpine (musl) to Debian slim (glibc) to fix SNMP SIGSEGV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit musl libc has weak fork-safety guarantees in multi-threaded processes. When Tokio's runtime is running and we fork() for SNMP operations, musl's internal state can be inconsistent in the child, causing SIGSEGV on every SNMP walk to Ubiquiti devices. glibc handles this via pthread_atfork. - Dockerfile: Alpine → rust:1.93-bookworm build + debian:12-slim runtime - CI: Remove ensure-netsnmp-base/manifest jobs, simplify build deps - Delete Dockerfile.netsnmp and netsnmp-base.yml workflow - Add NULL pointer guards in snmp_helper.c for fork-safety - Add SNMP exception type handling (NoSuchObject/Instance/EndOfMibView) - Add crash reproduction tests --- .github/workflows/ci.yml | 87 +-- .github/workflows/netsnmp-base.yml | 99 --- Dockerfile | 52 +- Dockerfile.netsnmp | 68 -- native/snmp_helper.c | 86 ++- tests/snmp_crash_test.rs | 1009 ++++++++++++++++++++++++++++ 6 files changed, 1108 insertions(+), 293 deletions(-) delete mode 100644 .github/workflows/netsnmp-base.yml delete mode 100644 Dockerfile.netsnmp create mode 100644 tests/snmp_crash_test.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a8f9b0..459c27f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,93 +22,10 @@ env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} DOCKERHUB_IMAGE: gmcintire/towerops-agent - NETSNMP_IMAGE: ghcr.io/towerops-app/netsnmp-alpine:5.9.5.2 CARGO_TERM_COLOR: always RUSTFLAGS: -C link-arg=-fuse-ld=lld jobs: - # Ensure the pre-built net-snmp base image exists in GHCR. - # First run: builds from source (~5 min per arch). After that: skips (~5s check). - ensure-netsnmp-base: - name: Ensure net-snmp base (${{ matrix.arch }}) - runs-on: ${{ matrix.runner }} - permissions: - contents: read - packages: write - strategy: - fail-fast: false - matrix: - include: - - arch: amd64 - runner: blacksmith-4vcpu-ubuntu-2404 - - arch: arm64 - runner: blacksmith-4vcpu-ubuntu-2404-arm - steps: - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Check if base image exists - id: check - run: | - if docker buildx imagetools inspect ${{ env.NETSNMP_IMAGE }}-${{ matrix.arch }} > /dev/null 2>&1; then - echo "exists=true" >> $GITHUB_OUTPUT - else - echo "exists=false" >> $GITHUB_OUTPUT - fi - - - name: Checkout - if: steps.check.outputs.exists == 'false' - uses: actions/checkout@v4 - - - name: Setup Blacksmith Builder - if: steps.check.outputs.exists == 'false' - uses: useblacksmith/setup-docker-builder@v1 - - - name: Build and push base image - if: steps.check.outputs.exists == 'false' - uses: useblacksmith/build-push-action@v2 - with: - context: . - file: Dockerfile.netsnmp - push: true - provenance: false - tags: ${{ env.NETSNMP_IMAGE }}-${{ matrix.arch }} - - ensure-netsnmp-manifest: - name: Ensure net-snmp manifest - needs: ensure-netsnmp-base - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - packages: write - steps: - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Check if manifest exists - id: check - run: | - if docker buildx imagetools inspect ${{ env.NETSNMP_IMAGE }} > /dev/null 2>&1; then - echo "exists=true" >> $GITHUB_OUTPUT - else - echo "exists=false" >> $GITHUB_OUTPUT - fi - - - name: Create multi-arch manifest - if: steps.check.outputs.exists == 'false' - run: | - docker buildx imagetools create \ - -t ${{ env.NETSNMP_IMAGE }} \ - ${{ env.NETSNMP_IMAGE }}-amd64 \ - ${{ env.NETSNMP_IMAGE }}-arm64 - test: name: Test runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -167,7 +84,7 @@ jobs: build-branch: name: Build (Branch) - needs: [test, ensure-netsnmp-manifest] + needs: [test] runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.event_name == 'push' && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') permissions: @@ -240,7 +157,7 @@ jobs: # Build architecture-specific images in parallel (native runners) build-release: name: Build (${{ matrix.arch }}) - needs: [test, version, ensure-netsnmp-manifest] + needs: [test, version] runs-on: ${{ matrix.runner }} if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') permissions: diff --git a/.github/workflows/netsnmp-base.yml b/.github/workflows/netsnmp-base.yml deleted file mode 100644 index 794119c..0000000 --- a/.github/workflows/netsnmp-base.yml +++ /dev/null @@ -1,99 +0,0 @@ -# Builds the net-snmp base image used by the agent Dockerfile. -# Only needs to run when the net-snmp version changes. -# -# Trigger manually or on changes to Dockerfile.netsnmp. - -name: Build net-snmp base image - -on: - push: - branches: [main] - paths: [Dockerfile.netsnmp] - workflow_dispatch: - inputs: - netsnmp_version: - description: "Net-SNMP version to build" - default: "5.9.5.2" - required: true - -env: - REGISTRY: ghcr.io - IMAGE_NAME: towerops-app/netsnmp-alpine - -jobs: - build: - name: Build (${{ matrix.arch }}) - runs-on: ${{ matrix.runner }} - permissions: - contents: read - packages: write - strategy: - fail-fast: false - matrix: - include: - - platform: linux/amd64 - arch: amd64 - runner: blacksmith-4vcpu-ubuntu-2404 - - platform: linux/arm64 - arch: arm64 - runner: blacksmith-4vcpu-ubuntu-2404-arm - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Blacksmith Builder - uses: useblacksmith/setup-docker-builder@v1 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: useblacksmith/build-push-action@v2 - with: - context: . - file: Dockerfile.netsnmp - platforms: ${{ matrix.platform }} - push: true - provenance: false - build-args: | - NETSNMP_VERSION=${{ inputs.netsnmp_version || '5.9.5.2' }} - tags: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.netsnmp_version || '5.9.5.2' }}-${{ matrix.arch }} - - manifest: - name: Create Manifest - needs: build - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - packages: write - steps: - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Create multi-arch manifest - run: | - VERSION="${{ inputs.netsnmp_version || '5.9.5.2' }}" - IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" - - docker buildx imagetools create -t ${IMAGE}:${VERSION} \ - ${IMAGE}:${VERSION}-amd64 \ - ${IMAGE}:${VERSION}-arm64 - - docker buildx imagetools create -t ${IMAGE}:latest \ - ${IMAGE}:${VERSION}-amd64 \ - ${IMAGE}:${VERSION}-arm64 - - - name: Summary - run: | - echo "### net-snmp base image built" >> $GITHUB_STEP_SUMMARY - echo "Version: ${{ inputs.netsnmp_version || '5.9.5.2' }}" >> $GITHUB_STEP_SUMMARY - echo "Image: \`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.netsnmp_version || '5.9.5.2' }}\`" >> $GITHUB_STEP_SUMMARY diff --git a/Dockerfile b/Dockerfile index bd674db..36edbda 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,6 @@ # syntax=docker/dockerfile:1.4 -# Pre-built net-snmp 5.9.5.2 (fixes segfaults in Alpine's 5.9.4) -# Built from Dockerfile.netsnmp via .github/workflows/netsnmp-base.yml -FROM ghcr.io/towerops-app/netsnmp-alpine:5.9.5.2 AS netsnmp - -# Build stage -FROM rust:1.93-alpine AS builder +# Build stage - Debian bookworm with glibc (fixes musl fork-safety SIGSEGV) +FROM rust:1.93-bookworm AS builder # Build arguments provided by Docker buildx ARG TARGETPLATFORM @@ -13,20 +9,23 @@ ARG VERSION=0.1.0-unknown WORKDIR /app -# Install build dependencies (net-snmp headers/libs from pre-built image) -RUN apk add --no-cache musl-dev protobuf-dev openssl-dev openssl-libs-static cmake perl g++ -COPY --from=netsnmp /usr/include/net-snmp /usr/include/net-snmp -COPY --from=netsnmp /usr/lib/libnetsnmp* /usr/lib/ -COPY --from=netsnmp /usr/lib/libsnmp* /usr/lib/ +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + protobuf-compiler \ + libsnmp-dev \ + cmake \ + g++ \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* -# Determine Rust target based on platform and add it +# Determine Rust target based on platform RUN case "$TARGETPLATFORM" in \ - "linux/amd64") RUST_TARGET="x86_64-unknown-linux-musl" ;; \ - "linux/arm64") RUST_TARGET="aarch64-unknown-linux-musl" ;; \ + "linux/amd64") RUST_TARGET="x86_64-unknown-linux-gnu" ;; \ + "linux/arm64") RUST_TARGET="aarch64-unknown-linux-gnu" ;; \ *) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \ esac && \ - echo "$RUST_TARGET" > /tmp/rust-target && \ - rustup target add "$RUST_TARGET" + echo "$RUST_TARGET" > /tmp/rust-target # Copy manifests and build files COPY Cargo.toml Cargo.lock build.rs ./ @@ -59,23 +58,24 @@ RUN --mount=type=cache,id=cargo-registry-${TARGETARCH},target=/usr/local/cargo/r BUILD_VERSION="$VERSION" cargo build --release --target "$RUST_TARGET" && \ cp "target/$RUST_TARGET/release/towerops-agent" /tmp/towerops-agent -# Runtime stage - must match builder's Alpine version for ABI compatibility -# (rust:1.93-alpine uses Alpine 3.23) -FROM alpine:3.23 +# Runtime stage - Debian slim with glibc +FROM debian:12-slim # Install runtime dependencies -# iputils provides ping with setuid root (doesn't require CAP_NET_RAW) -RUN apk add --no-cache ca-certificates iputils openssl - -# Copy net-snmp 5.9.5.2 shared libraries from pre-built image -COPY --from=netsnmp /usr/lib/libnetsnmp.so* /usr/lib/ +# iputils-ping provides ping with setuid root (doesn't require CAP_NET_RAW) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + iputils-ping \ + libsnmp40 \ + openssl \ + && rm -rf /var/lib/apt/lists/* # Copy binary from builder COPY --from=builder /tmp/towerops-agent /usr/local/bin/towerops-agent # Create non-root user -RUN addgroup -g 1000 towerops && \ - adduser -D -u 1000 -G towerops towerops +RUN groupadd -g 1000 towerops && \ + useradd -u 1000 -g towerops -s /bin/false towerops # Allow non-root user to overwrite binary during self-update RUN chown towerops /usr/local/bin/towerops-agent diff --git a/Dockerfile.netsnmp b/Dockerfile.netsnmp deleted file mode 100644 index 8bd1aa8..0000000 --- a/Dockerfile.netsnmp +++ /dev/null @@ -1,68 +0,0 @@ -# syntax=docker/dockerfile:1.4 -# Base image with net-snmp 5.9.5.2 compiled from source -# Alpine 3.23 ships 5.9.4 which has segfault bugs in varbind construction -# and buffer overflows in octet string handling. -# -# Build and push with: -# docker buildx build -f Dockerfile.netsnmp --platform linux/amd64,linux/arm64 \ -# -t ghcr.io/towerops-app/netsnmp-alpine:5.9.5.2 --push . -# -# Or use the GitHub Actions workflow: .github/workflows/netsnmp-base.yml - -FROM alpine:3.23 AS builder - -ARG NETSNMP_VERSION=5.9.5.2 - -# Install build dependencies -RUN apk add --no-cache \ - gcc \ - g++ \ - make \ - musl-dev \ - openssl-dev \ - linux-headers \ - perl \ - curl \ - file - -# Download and extract net-snmp source -RUN curl -fsSL "https://downloads.sourceforge.net/project/net-snmp/net-snmp/${NETSNMP_VERSION}/net-snmp-${NETSNMP_VERSION}.tar.gz" \ - -o /tmp/net-snmp.tar.gz && \ - tar xzf /tmp/net-snmp.tar.gz -C /tmp && \ - rm /tmp/net-snmp.tar.gz - -WORKDIR /tmp/net-snmp-${NETSNMP_VERSION} - -# Configure with minimal features (we only need libnetsnmp) -RUN ./configure \ - --prefix=/usr \ - --sysconfdir=/etc \ - --with-defaults \ - --with-openssl \ - --enable-shared \ - --enable-static \ - --disable-applications \ - --disable-manuals \ - --disable-scripts \ - --without-perl-modules \ - --without-python-modules \ - --disable-embedded-perl \ - --with-default-snmp-version=3 - -# Build and install -RUN make -j$(nproc) && \ - make install DESTDIR=/netsnmp-install - -# Final stage: slim image with just the compiled artifacts -FROM alpine:3.23 - -# Install runtime dependency (OpenSSL) that net-snmp links against -RUN apk add --no-cache openssl-dev musl-dev - -# Copy compiled net-snmp (headers, static libs, shared libs) -COPY --from=builder /netsnmp-install/usr/include/net-snmp /usr/include/net-snmp -COPY --from=builder /netsnmp-install/usr/lib/libnetsnmp* /usr/lib/ -COPY --from=builder /netsnmp-install/usr/lib/libsnmp* /usr/lib/ - -# Ensure shared library cache is updated -RUN ldconfig /usr/lib 2>/dev/null || true diff --git a/native/snmp_helper.c b/native/snmp_helper.c index 21839e0..68897a4 100644 --- a/native/snmp_helper.c +++ b/native/snmp_helper.c @@ -282,13 +282,30 @@ int snmp_get( struct variable_list *var = response->variables; *value_type = var->type; + // Handle SNMP exception types (NoSuchObject, NoSuchInstance, EndOfMibView) + if (var->type == SNMP_NOSUCHOBJECT || + var->type == SNMP_NOSUCHINSTANCE || + var->type == SNMP_ENDOFMIBVIEW) { + if (error_buf && error_buf_len > 0) { + const char *label = var->type == SNMP_NOSUCHOBJECT ? "noSuchObject" : + var->type == SNMP_NOSUCHINSTANCE ? "noSuchInstance" : + "endOfMibView"; + snprintf(error_buf, error_buf_len, "%s", label); + } + snmp_free_pdu(response); + return -1; + } + switch (var->type) { case ASN_OCTET_STR: case ASN_OPAQUE: case ASN_IPADDRESS: - if (var->val_len <= value_buf_len) { + if (var->val.string && var->val_len > 0 && + var->val_len <= value_buf_len) { memcpy(value_buf, var->val.string, var->val_len); result = (int)var->val_len; + } else if (!var->val.string || var->val_len == 0) { + result = 0; // Empty string } else { if (error_buf && error_buf_len > 0) { snprintf(error_buf, error_buf_len, "Buffer too small"); @@ -301,31 +318,33 @@ int snmp_get( case ASN_GAUGE: case ASN_TIMETICKS: case ASN_UINTEGER: - if (sizeof(long) <= value_buf_len) { + if (var->val.integer && sizeof(long) <= value_buf_len) { *((long*)value_buf) = *var->val.integer; result = sizeof(long); } break; case ASN_COUNTER64: - if (sizeof(struct counter64) <= value_buf_len) { + if (var->val.counter64 && + sizeof(struct counter64) <= value_buf_len) { memcpy(value_buf, var->val.counter64, sizeof(struct counter64)); result = sizeof(struct counter64); } break; case ASN_OBJECT_ID: - // Convert OID to string representation - { + if (var->val.objid && var->val_len > 0) { char oid_buf[256]; - snprint_objid(oid_buf, sizeof(oid_buf), var->val.objid, var->val_len / sizeof(oid)); + snprint_objid(oid_buf, sizeof(oid_buf), var->val.objid, + var->val_len / sizeof(oid)); size_t oid_str_len = strlen(oid_buf); if (oid_str_len <= value_buf_len) { memcpy(value_buf, oid_buf, oid_str_len); result = (int)oid_str_len; } else { if (error_buf && error_buf_len > 0) { - snprintf(error_buf, error_buf_len, "Buffer too small for OID string"); + snprintf(error_buf, error_buf_len, + "Buffer too small for OID string"); } } } @@ -339,7 +358,8 @@ int snmp_get( default: // Unknown type if (error_buf && error_buf_len > 0) { - snprintf(error_buf, error_buf_len, "Unsupported type: %d", var->type); + snprintf(error_buf, error_buf_len, + "Unsupported type: %d", var->type); } break; } @@ -412,13 +432,39 @@ int snmp_walk( break; } + // Handle SNMP exception types that indicate end-of-data or missing values. + // These have type 0x80 (NoSuchObject), 0x81 (NoSuchInstance), + // 0x82 (EndOfMibView) and their val pointers may be NULL. + if (var->type == SNMP_NOSUCHOBJECT || + var->type == SNMP_NOSUCHINSTANCE) { + // Object/instance doesn't exist at this index - skip and continue walk + // Update OID for next iteration + if (var->name_length <= MAX_OID_LEN) { + memcpy(name, var->name, var->name_length * sizeof(oid)); + name_length = var->name_length; + } else { + running = 0; + } + snmp_free_pdu(response); + continue; + } + + if (var->type == SNMP_ENDOFMIBVIEW) { + // No more data available - terminate the walk + snmp_free_pdu(response); + break; + } + // Store result snmp_walk_result_t *res = &results[*num_results]; // Convert OID to string snprint_objid(res->oid, sizeof(res->oid), var->name, var->name_length); - // Store value + // Store value - check for NULL val pointers before every dereference. + // After fork() from a multi-threaded process, net-snmp's internal state + // can be inconsistent, potentially leaving val pointers NULL even for + // standard types. res->value_type = var->type; res->value_len = 0; @@ -426,17 +472,18 @@ int snmp_walk( case ASN_OCTET_STR: case ASN_OPAQUE: case ASN_IPADDRESS: - if (var->val_len <= sizeof(res->value)) { + if (var->val.string && var->val_len > 0 && + var->val_len <= sizeof(res->value)) { memcpy(res->value, var->val.string, var->val_len); res->value_len = var->val_len; } break; case ASN_OBJECT_ID: - // Convert OID to string representation - { + if (var->val.objid && var->val_len > 0) { char oid_buf[256]; - snprint_objid(oid_buf, sizeof(oid_buf), var->val.objid, var->val_len / sizeof(oid)); + snprint_objid(oid_buf, sizeof(oid_buf), var->val.objid, + var->val_len / sizeof(oid)); size_t oid_str_len = strlen(oid_buf); if (oid_str_len < sizeof(res->value)) { memcpy(res->value, oid_buf, oid_str_len); @@ -450,18 +497,27 @@ int snmp_walk( case ASN_GAUGE: case ASN_TIMETICKS: case ASN_UINTEGER: - if (sizeof(long) <= sizeof(res->value)) { + if (var->val.integer && sizeof(long) <= sizeof(res->value)) { *((long*)res->value) = *var->val.integer; res->value_len = sizeof(long); } break; case ASN_COUNTER64: - if (sizeof(struct counter64) <= sizeof(res->value)) { + if (var->val.counter64 && + sizeof(struct counter64) <= sizeof(res->value)) { memcpy(res->value, var->val.counter64, sizeof(struct counter64)); res->value_len = sizeof(struct counter64); } break; + + case ASN_NULL: + // NULL values are valid but contain no data - skip + break; + + default: + // Unknown or unsupported type - skip silently + break; } if (res->value_len > 0) { diff --git a/tests/snmp_crash_test.rs b/tests/snmp_crash_test.rs new file mode 100644 index 0000000..8595031 --- /dev/null +++ b/tests/snmp_crash_test.rs @@ -0,0 +1,1009 @@ +//! Integration tests for SNMP crash scenarios. +//! +//! These tests use a mock SNMP UDP server that returns crafted BER-encoded +//! responses to exercise all value type handling paths in snmp_helper.c, +//! particularly the `snmp_walk` switch statement where NULL pointer +//! dereferences and unhandled exception types can cause SIGSEGV. + +use std::ffi::{c_char, CStr, CString}; +use std::net::UdpSocket; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; + +// ─── FFI declarations matching snmp_helper.h ──────────────────────────────── + +#[repr(C)] +struct SnmpWalkResult { + oid: [u8; 256], + value: [u8; 1024], + value_len: usize, + value_type: i32, +} + +#[repr(C)] +struct SnmpIsolatedWalkHeader { + status: i32, + num_results: u32, + child_signal: i32, + error_buf: [c_char; 512], +} + +#[repr(C)] +struct SnmpIsolatedGetResult { + status: i32, + value_type: i32, + child_signal: i32, + error_buf: [c_char; 512], + value_buf: [u8; 1024], +} + +extern "C" { + fn snmp_walk_isolated( + ip_address: *const c_char, + port: u16, + community: *const c_char, + version: i32, + timeout_us: i64, + retries: i32, + v3_config: *const std::ffi::c_void, + oid_str: *const c_char, + header: *mut SnmpIsolatedWalkHeader, + results: *mut SnmpWalkResult, + max_results: usize, + ); + + fn snmp_get_isolated( + ip_address: *const c_char, + port: u16, + community: *const c_char, + version: i32, + timeout_us: i64, + retries: i32, + v3_config: *const std::ffi::c_void, + oid_str: *const c_char, + result: *mut SnmpIsolatedGetResult, + ); +} + +// ─── BER encoding helpers ─────────────────────────────────────────────────── + +/// BER ASN.1 type tags +const BER_SEQUENCE: u8 = 0x30; +const BER_INTEGER: u8 = 0x02; +const BER_OCTET_STRING: u8 = 0x04; +const BER_NULL: u8 = 0x05; +const BER_OID: u8 = 0x06; +const BER_IPADDRESS: u8 = 0x40; // Application[0], primitive +const BER_COUNTER32: u8 = 0x41; // Application[1], primitive +const BER_GAUGE32: u8 = 0x42; // Application[2], primitive +const BER_TIMETICKS: u8 = 0x43; // Application[3], primitive +const BER_OPAQUE: u8 = 0x44; // Application[4], primitive +const BER_COUNTER64: u8 = 0x46; // Application[6], primitive + +const SNMP_GET_RESPONSE: u8 = 0xA2; +const SNMP_GET_NEXT_REQUEST: u8 = 0xA1; + +// SNMP exception types (context-specific, primitive) +const SNMP_NOSUCHOBJECT: u8 = 0x80; +const SNMP_NOSUCHINSTANCE: u8 = 0x81; +const SNMP_ENDOFMIBVIEW: u8 = 0x82; + +fn ber_encode_length(len: usize) -> Vec { + if len < 128 { + vec![len as u8] + } else if len < 256 { + vec![0x81, len as u8] + } else { + vec![0x82, (len >> 8) as u8, len as u8] + } +} + +fn ber_encode_tlv(tag: u8, content: &[u8]) -> Vec { + let mut result = vec![tag]; + result.extend(ber_encode_length(content.len())); + result.extend(content); + result +} + +fn ber_encode_integer(value: i64) -> Vec { + // Encode integer value in minimum bytes, two's complement + let mut bytes = Vec::new(); + if value == 0 { + bytes.push(0); + } else if value > 0 { + let mut v = value; + while v > 0 { + bytes.push((v & 0xFF) as u8); + v >>= 8; + } + // Add leading zero if high bit set (would be negative) + if bytes.last().unwrap() & 0x80 != 0 { + bytes.push(0); + } + bytes.reverse(); + } else { + let mut v = value; + loop { + bytes.push((v & 0xFF) as u8); + v >>= 8; + if v == -1 && (bytes.last().unwrap() & 0x80) != 0 { + break; + } + } + bytes.reverse(); + } + ber_encode_tlv(BER_INTEGER, &bytes) +} + +fn ber_encode_unsigned32(tag: u8, value: u32) -> Vec { + let mut bytes = value.to_be_bytes().to_vec(); + // Remove leading zeros but keep at least one byte + while bytes.len() > 1 && bytes[0] == 0 && (bytes[1] & 0x80) == 0 { + bytes.remove(0); + } + // Add leading zero if high bit set (ASN.1 unsigned encoding) + if bytes[0] & 0x80 != 0 { + bytes.insert(0, 0); + } + ber_encode_tlv(tag, &bytes) +} + +fn ber_encode_counter64(value: u64) -> Vec { + let mut bytes = value.to_be_bytes().to_vec(); + while bytes.len() > 1 && bytes[0] == 0 && (bytes[1] & 0x80) == 0 { + bytes.remove(0); + } + if bytes[0] & 0x80 != 0 { + bytes.insert(0, 0); + } + ber_encode_tlv(BER_COUNTER64, &bytes) +} + +fn ber_encode_oid(components: &[u32]) -> Vec { + if components.len() < 2 { + return ber_encode_tlv(BER_OID, &[]); + } + let mut encoded = vec![(40 * components[0] + components[1]) as u8]; + for &c in &components[2..] { + if c < 128 { + encoded.push(c as u8); + } else { + // Base-128 encoding with continuation bits + let mut temp = Vec::new(); + let mut v = c; + temp.push((v & 0x7F) as u8); + v >>= 7; + while v > 0 { + temp.push((v & 0x7F) as u8 | 0x80); + v >>= 7; + } + temp.reverse(); + encoded.extend(temp); + } + } + ber_encode_tlv(BER_OID, &encoded) +} + +fn ber_encode_octet_string(value: &[u8]) -> Vec { + ber_encode_tlv(BER_OCTET_STRING, value) +} + +fn ber_encode_null() -> Vec { + vec![BER_NULL, 0x00] +} + +/// Build an SNMP GetResponse PDU with one varbind. +fn build_snmp_response( + request_id: i64, + community: &[u8], + oid_components: &[u32], + value_encoding: &[u8], // Pre-encoded TLV for the value +) -> Vec { + // VarBind: SEQUENCE { OID, value } + let varbind_content = [ber_encode_oid(oid_components).as_slice(), value_encoding].concat(); + let varbind = ber_encode_tlv(BER_SEQUENCE, &varbind_content); + + // VarBindList: SEQUENCE OF VarBind + let varbind_list = ber_encode_tlv(BER_SEQUENCE, &varbind); + + // GetResponse-PDU: [2] { request-id, error-status(0), error-index(0), varbind-list } + let pdu_content = [ + ber_encode_integer(request_id).as_slice(), + &ber_encode_integer(0), // error-status = noError + &ber_encode_integer(0), // error-index = 0 + &varbind_list, + ] + .concat(); + let pdu = ber_encode_tlv(SNMP_GET_RESPONSE, &pdu_content); + + // SNMP Message: SEQUENCE { version, community, pdu } + let msg_content = [ + ber_encode_integer(1).as_slice(), // version = 1 (SNMPv2c) + &ber_encode_tlv(BER_OCTET_STRING, community), + &pdu, + ] + .concat(); + + ber_encode_tlv(BER_SEQUENCE, &msg_content) +} + +// ─── BER decoding helpers (minimal, for parsing incoming requests) ────────── + +fn ber_decode_tlv(data: &[u8]) -> Option<(u8, &[u8], &[u8])> { + if data.len() < 2 { + return None; + } + let tag = data[0]; + let (length, header_len) = if data[1] < 128 { + (data[1] as usize, 2) + } else if data[1] == 0x81 && data.len() >= 3 { + (data[2] as usize, 3) + } else if data[1] == 0x82 && data.len() >= 4 { + (((data[2] as usize) << 8) | data[3] as usize, 4) + } else { + return None; + }; + + if header_len + length > data.len() { + return None; + } + + let content = &data[header_len..header_len + length]; + let rest = &data[header_len + length..]; + Some((tag, content, rest)) +} + +fn ber_decode_integer(data: &[u8]) -> Option<(i64, &[u8])> { + let (tag, content, rest) = ber_decode_tlv(data)?; + if tag != BER_INTEGER || content.is_empty() { + return None; + } + let mut value: i64 = if content[0] & 0x80 != 0 { -1 } else { 0 }; + for &byte in content { + value = (value << 8) | byte as i64; + } + Some((value, rest)) +} + +/// Parse an incoming SNMP request enough to extract request-id and the first OID. +fn parse_snmp_request(data: &[u8]) -> Option<(i64, Vec)> { + // Outer SEQUENCE + let (_tag, msg_content, _) = ber_decode_tlv(data)?; + + // Skip version (INTEGER) + let (_, rest) = ber_decode_integer(msg_content)?; + + // Skip community (OCTET STRING) + let (_, community_content, rest) = ber_decode_tlv(rest)?; + let _ = community_content; + + // PDU (GetNextRequest = 0xA1 or GetRequest = 0xA0) + let (pdu_tag, pdu_content, _) = ber_decode_tlv(rest)?; + if pdu_tag != SNMP_GET_NEXT_REQUEST && pdu_tag != 0xA0 { + return None; + } + + // Request ID + let (request_id, rest) = ber_decode_integer(pdu_content)?; + + // Skip error-status, error-index + let (_, rest) = ber_decode_integer(rest)?; + let (_, rest) = ber_decode_integer(rest)?; + + // VarBindList SEQUENCE + let (_, vbl_content, _) = ber_decode_tlv(rest)?; + + // First VarBind SEQUENCE + let (_, vb_content, _) = ber_decode_tlv(vbl_content)?; + + // OID - return raw bytes for comparison + let (tag, oid_content, _) = ber_decode_tlv(vb_content)?; + if tag != BER_OID { + return None; + } + + Some((request_id, oid_content.to_vec())) +} + +// ─── Mock SNMP server ────────────────────────────────────────────────────── + +/// Configuration for a varbind response from the mock server. +#[derive(Clone)] +struct MockVarbind { + /// OID to return in the response (the "next" OID in the walk) + response_oid: Vec, + /// Pre-encoded TLV for the value + value_tlv: Vec, +} + +/// A mock SNMP UDP server that returns crafted responses. +struct MockSnmpServer { + port: u16, + stop: Arc, + handle: Option>, +} + +impl MockSnmpServer { + /// Start a mock server that returns the given varbinds in sequence. + /// After all varbinds are exhausted, returns an OID outside the subtree + /// (2.0) to terminate the walk. + fn start(varbinds: Vec) -> Self { + let socket = UdpSocket::bind("127.0.0.1:0").expect("bind mock SNMP server"); + let port = socket.local_addr().unwrap().port(); + socket + .set_read_timeout(Some(std::time::Duration::from_millis(500))) + .unwrap(); + + let stop = Arc::new(AtomicBool::new(false)); + let stop_clone = stop.clone(); + + let handle = thread::spawn(move || { + let community = b"public"; + let mut request_count = 0usize; + let mut buf = [0u8; 4096]; + + while !stop_clone.load(Ordering::Relaxed) { + let (len, src) = match socket.recv_from(&mut buf) { + Ok(v) => v, + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(_) => break, + }; + + let data = &buf[..len]; + + // Parse request to get request-id + let request_id = match parse_snmp_request(data) { + Some((id, _oid)) => id, + None => continue, + }; + + // Build response + let response = if request_count < varbinds.len() { + let vb = &varbinds[request_count]; + build_snmp_response(request_id, community, &vb.response_oid, &vb.value_tlv) + } else { + // Return OID outside subtree to end the walk + // Use OID 2.0 which is outside any 1.x subtree + build_snmp_response(request_id, community, &[2, 0], &ber_encode_null()) + }; + + let _ = socket.send_to(&response, src); + request_count += 1; + } + }); + + MockSnmpServer { + port, + stop, + handle: Some(handle), + } + } +} + +impl Drop for MockSnmpServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(h) = self.handle.take() { + let _ = h.join(); + } + } +} + +// ─── Test helpers ────────────────────────────────────────────────────────── + +const TEST_TIMEOUT_US: i64 = 2_000_000; // 2 seconds for mock tests +const TEST_RETRIES: i32 = 1; +const MAX_RESULTS: usize = 100; + +/// The base OID we walk in all tests: 1.3.6.1.2.1.1 (system subtree) +/// OID within the subtree for test responses: 1.3.6.1.2.1.1.1.0 +const RESPONSE_OID_1: &[u32] = &[1, 3, 6, 1, 2, 1, 1, 1, 0]; +/// Second OID within the subtree: 1.3.6.1.2.1.1.2.0 +const RESPONSE_OID_2: &[u32] = &[1, 3, 6, 1, 2, 1, 1, 2, 0]; +/// Third OID: 1.3.6.1.2.1.1.3.0 +const RESPONSE_OID_3: &[u32] = &[1, 3, 6, 1, 2, 1, 1, 3, 0]; + +fn do_walk_isolated(port: u16, oid: &str) -> (SnmpIsolatedWalkHeader, Vec) { + let ip = CString::new("127.0.0.1").unwrap(); + let community = CString::new("public").unwrap(); + let oid_cstr = CString::new(oid).unwrap(); + + let mut header = SnmpIsolatedWalkHeader { + status: -1, + num_results: 0, + child_signal: 0, + error_buf: [0; 512], + }; + + let mut results: Vec = (0..MAX_RESULTS) + .map(|_| SnmpWalkResult { + oid: [0; 256], + value: [0; 1024], + value_len: 0, + value_type: 0, + }) + .collect(); + + unsafe { + snmp_walk_isolated( + ip.as_ptr(), + port, + community.as_ptr(), + 2, // SNMPv2c + TEST_TIMEOUT_US, + TEST_RETRIES, + std::ptr::null(), + oid_cstr.as_ptr(), + &mut header, + results.as_mut_ptr(), + MAX_RESULTS, + ); + } + + (header, results) +} + +fn do_get_isolated(port: u16, oid: &str) -> SnmpIsolatedGetResult { + let ip = CString::new("127.0.0.1").unwrap(); + let community = CString::new("public").unwrap(); + let oid_cstr = CString::new(oid).unwrap(); + + let mut result = SnmpIsolatedGetResult { + status: -1, + value_type: 0, + child_signal: 0, + error_buf: [0; 512], + value_buf: [0; 1024], + }; + + unsafe { + snmp_get_isolated( + ip.as_ptr(), + port, + community.as_ptr(), + 2, // SNMPv2c + TEST_TIMEOUT_US, + TEST_RETRIES, + std::ptr::null(), + oid_cstr.as_ptr(), + &mut result, + ); + } + + result +} + +fn header_error(header: &SnmpIsolatedWalkHeader) -> String { + unsafe { + CStr::from_ptr(header.error_buf.as_ptr()) + .to_string_lossy() + .to_string() + } +} + +fn get_error(result: &SnmpIsolatedGetResult) -> String { + unsafe { + CStr::from_ptr(result.error_buf.as_ptr()) + .to_string_lossy() + .to_string() + } +} + +fn assert_no_crash(header: &SnmpIsolatedWalkHeader, scenario: &str) { + assert_ne!( + header.status, + -2, + "{}: child process crashed with signal {} ({})", + scenario, + header.child_signal, + header_error(header) + ); +} + +fn assert_get_no_crash(result: &SnmpIsolatedGetResult, scenario: &str) { + assert_ne!( + result.status, + -2, + "{}: child process crashed with signal {} ({})", + scenario, + result.child_signal, + get_error(result) + ); +} + +// ─── Walk tests with exception types ─────────────────────────────────────── + +#[test] +fn test_walk_nosuchobject_does_not_crash() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: vec![SNMP_NOSUCHOBJECT, 0x00], // NoSuchObject, length 0 + }]); + + let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "NoSuchObject"); +} + +#[test] +fn test_walk_nosuchinstance_does_not_crash() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: vec![SNMP_NOSUCHINSTANCE, 0x00], // NoSuchInstance, length 0 + }]); + + let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "NoSuchInstance"); +} + +#[test] +fn test_walk_endofmibview_does_not_crash() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: vec![SNMP_ENDOFMIBVIEW, 0x00], // EndOfMibView, length 0 + }]); + + let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "EndOfMibView"); +} + +// ─── Walk tests with NULL type ───────────────────────────────────────────── + +#[test] +fn test_walk_null_value_does_not_crash() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_null(), + }]); + + let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "ASN_NULL"); +} + +// ─── Walk tests with standard types ──────────────────────────────────────── + +#[test] +fn test_walk_integer_value() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_integer(42), + }]); + + let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "Integer"); + assert_eq!(header.status, 0, "walk should succeed"); + assert!(header.num_results >= 1, "should have at least 1 result"); + assert_eq!(results[0].value_type, BER_INTEGER as i32); +} + +#[test] +fn test_walk_octet_string_value() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_octet_string(b"Hello SNMP"), + }]); + + let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "OctetString"); + assert_eq!(header.status, 0); + assert!(header.num_results >= 1); + assert_eq!(results[0].value_type, BER_OCTET_STRING as i32); + assert_eq!(results[0].value_len, 10); + assert_eq!(&results[0].value[..10], b"Hello SNMP"); +} + +#[test] +fn test_walk_empty_octet_string_does_not_crash() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_octet_string(b""), // Empty string + }]); + + let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "EmptyOctetString"); + // Empty strings get value_len=0, which means result is skipped + // This is acceptable behavior +} + +#[test] +fn test_walk_binary_octet_string() { + // Simulate a binary value like a MAC address (common in LLDP) + let mac = vec![0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E]; + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_octet_string(&mac), + }]); + + let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "BinaryOctetString"); + assert_eq!(header.status, 0); + assert!(header.num_results >= 1); + assert_eq!(&results[0].value[..6], &mac[..]); +} + +#[test] +fn test_walk_counter32_value() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_unsigned32(BER_COUNTER32, 123456), + }]); + + let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "Counter32"); + assert_eq!(header.status, 0); + assert!(header.num_results >= 1); + assert_eq!(results[0].value_type, BER_COUNTER32 as i32); +} + +#[test] +fn test_walk_gauge32_value() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_unsigned32(BER_GAUGE32, 99999), + }]); + + let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "Gauge32"); + assert_eq!(header.status, 0); + assert!(header.num_results >= 1); +} + +#[test] +fn test_walk_timeticks_value() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_unsigned32(BER_TIMETICKS, 500000), + }]); + + let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "TimeTicks"); + assert_eq!(header.status, 0); + assert!(header.num_results >= 1); +} + +#[test] +fn test_walk_counter64_value() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_counter64(0x0001_0000_0000_ABCD), + }]); + + let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "Counter64"); + assert_eq!(header.status, 0); + assert!(header.num_results >= 1); + assert_eq!(results[0].value_type, BER_COUNTER64 as i32); +} + +#[test] +fn test_walk_oid_value() { + // Value is itself an OID (e.g., sysObjectID) + let oid_value = ber_encode_oid(&[1, 3, 6, 1, 4, 1, 41112, 1, 4]); // Ubiquiti OID + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: oid_value, + }]); + + let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "OID value"); + assert_eq!(header.status, 0); + assert!(header.num_results >= 1); + assert_eq!(results[0].value_type, BER_OID as i32); +} + +#[test] +fn test_walk_ipaddress_value() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_tlv(BER_IPADDRESS, &[10, 0, 0, 1]), + }]); + + let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "IpAddress"); + assert_eq!(header.status, 0); + assert!(header.num_results >= 1); +} + +#[test] +fn test_walk_opaque_value() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_tlv(BER_OPAQUE, &[0x9F, 0x78, 0x04, 0x42, 0x8C, 0xCC, 0xCD]), + }]); + + let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "Opaque"); + assert_eq!(header.status, 0); + // Opaque values may or may not be returned depending on net-snmp's parsing +} + +// ─── Walk tests with edge cases ──────────────────────────────────────────── + +#[test] +fn test_walk_unknown_type_does_not_crash() { + // Use a type tag not in the switch statement (e.g., BIT STRING = 0x03) + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_tlv(0x03, &[0x00, 0xFF, 0xAA]), // BIT STRING + }]); + + let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "UnknownType(BIT_STRING)"); +} + +#[test] +fn test_walk_large_octet_string_does_not_crash() { + // Value larger than the 1024-byte result buffer + let large_value = vec![0x41; 2000]; // 2000 bytes of 'A' + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_octet_string(&large_value), + }]); + + let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "LargeOctetString"); + // Large values should be skipped (not overflow the buffer) +} + +#[test] +fn test_walk_zero_integer_does_not_crash() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_integer(0), + }]); + + let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "ZeroInteger"); + assert_eq!(header.status, 0); + assert!(header.num_results >= 1); +} + +#[test] +fn test_walk_negative_integer_does_not_crash() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_integer(-1), + }]); + + let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "NegativeInteger"); + assert_eq!(header.status, 0); + assert!(header.num_results >= 1); +} + +#[test] +fn test_walk_max_counter64_does_not_crash() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_counter64(u64::MAX), + }]); + + let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "MaxCounter64"); +} + +// ─── Walk tests with mixed types (simulating real device responses) ──────── + +#[test] +fn test_walk_mixed_types_like_real_device() { + // Simulate a realistic SNMP walk returning various system MIB values + let server = MockSnmpServer::start(vec![ + // sysDescr.0 = OctetString + MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_octet_string(b"EdgeSwitch 24-Port 250W"), + }, + // sysObjectID.0 = OID + MockVarbind { + response_oid: RESPONSE_OID_2.to_vec(), + value_tlv: ber_encode_oid(&[1, 3, 6, 1, 4, 1, 41112, 1, 6]), + }, + // sysUpTime.0 = TimeTicks + MockVarbind { + response_oid: RESPONSE_OID_3.to_vec(), + value_tlv: ber_encode_unsigned32(BER_TIMETICKS, 123456789), + }, + ]); + + let (header, results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "MixedTypes"); + assert_eq!(header.status, 0); + assert_eq!(header.num_results, 3, "should have 3 results"); + + // Verify types + assert_eq!(results[0].value_type, BER_OCTET_STRING as i32); + assert_eq!(results[1].value_type, BER_OID as i32); + assert_eq!(results[2].value_type, BER_TIMETICKS as i32); +} + +#[test] +fn test_walk_mixed_with_exceptions() { + // Simulate walk where some OIDs return exceptions (common on Ubiquiti) + let server = MockSnmpServer::start(vec![ + // First result: normal string + MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_octet_string(b"Normal value"), + }, + // Second result: NoSuchInstance (device doesn't implement this OID) + MockVarbind { + response_oid: RESPONSE_OID_2.to_vec(), + value_tlv: vec![SNMP_NOSUCHINSTANCE, 0x00], + }, + // Third result: normal integer after the exception + MockVarbind { + response_oid: RESPONSE_OID_3.to_vec(), + value_tlv: ber_encode_integer(100), + }, + ]); + + let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "MixedWithExceptions"); + assert_eq!(header.status, 0); + // Exception values get value_len=0 so they're skipped + // We should get at least the normal values +} + +// ─── Walk test simulating LLDP responses (Ubiquiti-like) ─────────────────── + +#[test] +fn test_walk_lldp_binary_chassis_id() { + // LLDP lldpRemChassisId returns binary MAC address + // OID: 1.0.8802.1.1.2.1.4.1.1.5.0.1 + let lldp_base: Vec = vec![1, 0, 8802, 1, 1, 2, 1, 4, 1, 1]; + let mut oid1 = lldp_base.clone(); + oid1.extend(&[5, 0, 1]); + + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: oid1, + value_tlv: ber_encode_octet_string(&[0x04, 0xF0, 0x21, 0xBE, 0xAC, 0x10]), // MAC address + }]); + + let (header, _results) = do_walk_isolated(server.port, "1.0.8802.1.1.2.1.4.1.1"); + assert_no_crash(&header, "LLDP binary chassis ID"); +} + +#[test] +fn test_walk_lldp_with_all_exception_types() { + // Some Ubiquiti devices return exceptions for LLDP sub-OIDs + let lldp_base: Vec = vec![1, 0, 8802, 1, 1, 2, 1, 4, 1, 1]; + let mut oid1 = lldp_base.clone(); + oid1.extend(&[1, 0, 1]); + let mut oid2 = lldp_base.clone(); + oid2.extend(&[2, 0, 1]); + let mut oid3 = lldp_base.clone(); + oid3.extend(&[3, 0, 1]); + + let server = MockSnmpServer::start(vec![ + MockVarbind { + response_oid: oid1, + value_tlv: vec![SNMP_NOSUCHOBJECT, 0x00], + }, + MockVarbind { + response_oid: oid2, + value_tlv: vec![SNMP_NOSUCHINSTANCE, 0x00], + }, + MockVarbind { + response_oid: oid3, + value_tlv: vec![SNMP_ENDOFMIBVIEW, 0x00], + }, + ]); + + let (header, _results) = do_walk_isolated(server.port, "1.0.8802.1.1.2.1.4.1.1"); + assert_no_crash(&header, "LLDP all exception types"); +} + +// ─── GET tests with exception types ──────────────────────────────────────── + +// Note: GET requests use GetRequest (0xA0), and the mock server responds to +// both 0xA0 and 0xA1. But `snmp_get_isolated` sends a GET PDU (0xA0), +// and the mock needs to handle that. Since we configured the mock to accept +// both tags, this should work. However, GET operations send a GetRequest, +// not GetNextRequest, so we need our mock to handle 0xA0 too. +// The mock's parse_snmp_request already accepts both 0xA0 and 0xA1. + +// For GET tests, the mock returns exactly one response (no walk iteration). + +#[test] +fn test_get_nosuchobject_does_not_crash() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: vec![SNMP_NOSUCHOBJECT, 0x00], + }]); + + let result = do_get_isolated(server.port, "1.3.6.1.2.1.1.1.0"); + assert_get_no_crash(&result, "GET NoSuchObject"); +} + +#[test] +fn test_get_nosuchinstance_does_not_crash() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: vec![SNMP_NOSUCHINSTANCE, 0x00], + }]); + + let result = do_get_isolated(server.port, "1.3.6.1.2.1.1.1.0"); + assert_get_no_crash(&result, "GET NoSuchInstance"); +} + +#[test] +fn test_get_endofmibview_does_not_crash() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: vec![SNMP_ENDOFMIBVIEW, 0x00], + }]); + + let result = do_get_isolated(server.port, "1.3.6.1.2.1.1.1.0"); + assert_get_no_crash(&result, "GET EndOfMibView"); +} + +#[test] +fn test_get_null_value_does_not_crash() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_null(), + }]); + + let result = do_get_isolated(server.port, "1.3.6.1.2.1.1.1.0"); + assert_get_no_crash(&result, "GET NULL"); +} + +#[test] +fn test_get_normal_string() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_octet_string(b"test value"), + }]); + + let result = do_get_isolated(server.port, "1.3.6.1.2.1.1.1.0"); + assert_get_no_crash(&result, "GET string"); + assert!(result.status >= 0, "GET should succeed"); + assert_eq!(result.value_type, BER_OCTET_STRING as i32); +} + +#[test] +fn test_get_empty_octet_string_does_not_crash() { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_octet_string(b""), + }]); + + let result = do_get_isolated(server.port, "1.3.6.1.2.1.1.1.0"); + assert_get_no_crash(&result, "GET empty string"); +} + +// ─── Stress / concurrent tests ───────────────────────────────────────────── + +#[test] +fn test_walk_many_sequential_operations() { + // Run multiple walks to the same mock to verify no resource leaks + for i in 0..5 { + let server = MockSnmpServer::start(vec![MockVarbind { + response_oid: RESPONSE_OID_1.to_vec(), + value_tlv: ber_encode_integer(i), + }]); + + let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, &format!("Sequential walk {}", i)); + } +} + +#[test] +fn test_walk_many_results() { + // Walk that returns many results to test the results buffer handling + let mut varbinds = Vec::new(); + for i in 0..50 { + let mut oid = vec![1u32, 3, 6, 1, 2, 1, 1, 1]; + oid.push(i); + varbinds.push(MockVarbind { + response_oid: oid, + value_tlv: ber_encode_integer(i as i64), + }); + } + + let server = MockSnmpServer::start(varbinds); + let (header, _results) = do_walk_isolated(server.port, "1.3.6.1.2.1.1"); + assert_no_crash(&header, "ManyResults"); + assert_eq!(header.status, 0); + assert_eq!(header.num_results, 50); +}