diff --git a/Cargo.lock b/Cargo.lock index 772565de..454bd6e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1893,7 +1893,7 @@ dependencies = [ [[package]] name = "ntnt" -version = "0.5.2" +version = "0.5.3" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 829c8890..a5c66ce3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ntnt" -version = "0.5.2" +version = "0.5.3" edition = "2021" authors = ["NTNT Language Team"] description = "NTNT (Intent) - A programming language designed for AI-driven development" diff --git a/design-docs/dd-047-std-netmon.md b/design-docs/dd-047-std-netmon.md index 000521f2..41b64419 100644 --- a/design-docs/dd-047-std-netmon.md +++ b/design-docs/dd-047-std-netmon.md @@ -484,7 +484,7 @@ Ok(map { `Counter64` values use decimal strings so ntnt's signed 64-bit `Int` cannot truncate them, including legal values above `i64::MAX`. Binary octet strings and opaque values use lowercase hex with explicit encoding metadata. Protocol exceptions such as `no_such_object` retain their type and use `None` as the value. Agent `error_status`/`error_index`, transport timeouts, malformed BER, and authentication mismatches return `Err(String)` rather than partial telemetry. -#### `snmp_walk(target, auth, oid, opts?) -> Result` *(next slice)* +#### `snmp_walk(target, auth, oid, opts?) -> Result` *(implemented in v0.5.3)* Walk a numeric subtree with strict row, request, byte, and result caps. This follows GET rather than sharing its first compatibility commit. Low-level transport stays numeric and never resolves a mutable MIB symbol implicitly. @@ -517,7 +517,7 @@ Ok(map { }) ``` -`requests` counts logical GETNEXT cursors, including look-ahead. `attempts` counts every transmitted datagram, including retries. GETNEXT requests use one cursor and require exactly one response varbind. Every accepted OID must be lexicographically greater than the prior cursor. Equal, descending, or repeated OIDs are protocol errors. +`requests` counts logical GETNEXT cursors, including look-ahead. `attempts` counts every transmitted datagram, including retries. GETNEXT requests use one cursor and require exactly one response varbind. Every accepted ordinary-value OID must be lexicographically greater than the prior cursor. Equal, descending, or repeated OIDs are protocol errors. Terminal exception OIDs must equal the requested cursor. A same-agent datagram carrying an older request ID is ignored on the same connected socket while the current attempt deadline and cumulative receive-byte budget continue to apply. The one global deadline begins before first request construction and covers every cursor, retry, decode, normalization step, mandatory look-ahead, and final result build. Terminal behavior is normative: @@ -1019,7 +1019,7 @@ Some of these may become useful later, but they carry OS permissions, abuse risk - [x] **Slice 0 — standard-library packaging and security contract** - [x] **Slice 1A — bounded SNMPv2c GET** -- [ ] **Slice 1B — bounded numeric SNMP WALK** +- [x] **Slice 1B — bounded numeric SNMP WALK** - [ ] **Slice 1C — canonical MIB catalog compiler, runtime registry, profiles, and plans** - [ ] **PR 2 — device recognition and inventory execution** - [ ] **PR 3 — interface inventory and counters** @@ -1074,20 +1074,20 @@ Acceptance: Scope: -- [ ] `snmp_walk(target, auth, oid, opts?) -> Result`. -- [ ] GETNEXT with one cursor and exactly one correlated response varbind. -- [ ] Strict subtree, monotonic-order, result, request, datagram, cumulative-byte, and normalized-output enforcement. -- [ ] Explicit `complete` and `stop_reason` output, including bounded look-ahead at `max_results`. -- [ ] Loop, equal/descending OID, malformed-order, `endOfMibView`, and out-of-subtree handling. -- [ ] One whole-operation deadline covering retries, decode, normalization, and result construction. -- [ ] Reuse Slice 1A auth, target policy, packet caps, and normalization contracts. -- [ ] GETBULK only after equivalent fixture coverage. +- [x] `snmp_walk(target, auth, oid, opts?) -> Result`. +- [x] GETNEXT with one cursor and exactly one correlated response varbind. +- [x] Strict subtree, monotonic-order, result, request, datagram, cumulative-byte, and normalized-output enforcement. +- [x] Explicit `complete` and `stop_reason` output, including bounded look-ahead at `max_results`. +- [x] Loop, equal/descending OID, malformed-order, `endOfMibView`, and out-of-subtree handling. +- [x] One whole-operation deadline covering retries, decode, normalization, and result construction. +- [x] Reuse Slice 1A auth, target policy, packet caps, and normalization contracts. +- [x] GETBULK remains deferred until equivalent fixture coverage exists. Acceptance: -- [ ] No walk can silently return a truncated table as complete. -- [ ] Mid-walk transport/protocol failure returns `Err` rather than apparently complete telemetry. -- [ ] Independent UDP fixtures cover malicious loops, subtree escape, retries, caps, and valid termination. +- [x] No walk can silently return a truncated table as complete. +- [x] Mid-walk transport/protocol failure returns `Err` rather than apparently complete telemetry. +- [x] Independent UDP fixtures cover malicious loops, subtree escape, retries, caps, forged sources, delayed request IDs, and valid termination. ### Slice 1C — Canonical MIB Catalog Compiler and Runtime Registry diff --git a/docs/IAL_REFERENCE.md b/docs/IAL_REFERENCE.md index 39fdbb1e..f137d3de 100644 --- a/docs/IAL_REFERENCE.md +++ b/docs/IAL_REFERENCE.md @@ -2,7 +2,7 @@ > **Auto-generated from [ial.toml](ial.toml)** - Do not edit directly. > -> Last updated: v0.5.2 +> Last updated: v0.5.3 IAL is a term rewriting engine that translates natural language assertions into executable tests diff --git a/docs/INDEX.md b/docs/INDEX.md index 55d2ede6..b8eb5118 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -39,6 +39,7 @@ Source files: | Document | Description | |----------|-------------| | [Roadmap](../ROADMAP.md) | Implementation phases and progress | +| [v0.5.3 Release Notes](release-notes/v0.5.3.md) | Bounded numeric SNMPv2c GETNEXT WALK with strict completion and resource ceilings | | [v0.5.2 Release Notes](release-notes/v0.5.2.md) | Gated, bounded SNMPv2c GET through the new explicitly imported `std/netmon` module | | [v0.5.1 Release Notes](release-notes/v0.5.1.md) | Passwordless magic-link flow, opaque provider-neutral secrets, compatibility guarantees, and auth hardening | | [v0.5.0 Release Notes](release-notes/v0.5.0.md) | Verification, validation, email, and multi-worker improvements | diff --git a/docs/RUNTIME_REFERENCE.md b/docs/RUNTIME_REFERENCE.md index 755b6e66..8dec1a0f 100644 --- a/docs/RUNTIME_REFERENCE.md +++ b/docs/RUNTIME_REFERENCE.md @@ -2,7 +2,7 @@ > **Auto-generated from [runtime.toml](runtime.toml)** - Do not edit directly. > -> Last updated: v0.5.2 +> Last updated: v0.5.3 Runtime configuration, environment variables, and CLI commands for NTNT diff --git a/docs/STDLIB_REFERENCE.md b/docs/STDLIB_REFERENCE.md index 25744189..e7927074 100644 --- a/docs/STDLIB_REFERENCE.md +++ b/docs/STDLIB_REFERENCE.md @@ -2,7 +2,7 @@ > **Auto-generated from source code doc comments** - Do not edit directly. > -> Last updated: v0.5.2 +> Last updated: v0.5.3 ## Table of Contents @@ -10301,7 +10301,7 @@ traceroute("example.com", map { "method": "tcp", "port": 443 }) // TCP-SYN trac Bounded network-monitoring protocols with opaque credentials and normalized results ```ntnt -import { snmp_get } from "std/netmon" +import { snmp_get, snmp_walk } from "std/netmon" ``` ### Functions @@ -10309,6 +10309,7 @@ import { snmp_get } from "std/netmon" | Function | Description | |----------|-------------| | [`snmp_get`](#snmpget) | Reads one bounded set of numeric OIDs from an SNMP agent. Slice 1 supports SNMPv2c only. The strict auth map must contain `version: "2c"` and a `community` Secret, normally returned by `require_secret()`; plaintext community strings are rejected. Unknown auth and option keys are rejected. SNMPv2c does not encrypt or authenticate its community or payload; use this slice only on trusted management networks or protected tunnels. | +| [`snmp_walk`](#snmpwalk) | Walks one numeric OID subtree with bounded SNMPv2c GETNEXT requests. The community must be an opaque Secret. SNMPv2c is plaintext; use a trusted management network or protected tunnel. Every call requires `NTNT_NETMON_ENABLE=1`; private targets also require the shared process opt-in and `allow_private: true`. | #### `snmp_get` @@ -10347,6 +10348,44 @@ snmp_get("10.0.50.1", map { "version": "2c", "community": require_secret("SNMP_C --- +#### `snmp_walk` + +```ntnt +snmp_walk(target: String, auth: Map, oid: String, opts?: Map) -> Result +``` + +Walks one numeric OID subtree with bounded SNMPv2c GETNEXT requests. The community must be an opaque Secret. SNMPv2c is plaintext; use a trusted management network or protected tunnel. Every call requires `NTNT_NETMON_ENABLE=1`; private targets also require the shared process opt-in and `allow_private: true`. + +The closed options map accepts the common SNMP options plus `max_results` (default 256, hard maximum 2048) and `on_limit` (`"error"`, the default, or `"partial"`). One global deadline covers all cursors, retries, response validation, the mandatory limit look-ahead, normalization, and result construction. Requests and responses are capped at 8 KiB, cumulative received bytes at 8 MiB, and conservative normalized output at 4 MiB. + +A successful map has exactly: `target: String`, `address: String`, `port: Int`, `version: String` (`"2c"`), `root_oid: String`, `duration_ms: Int`, `requests: Int`, `attempts: Int`, `complete: Bool`, `stop_reason: String`, and `values: Array`. Each value uses the same normalized `oid`, `type`, `value`, and optional `encoding` fields as `snmp_get`. `stop_reason` is one of `out_of_subtree`, `end_of_mib_view`, `no_such_object`, `no_such_instance`, or `max_results`. Only `max_results` has `complete: false`, and it is returned only when `on_limit: "partial"`; all transport, protocol, ordering, deadline, and byte-budget failures are `Err(String)` without prior-row telemetry. + +**Parameters:** + +- `target` — Literal IPv4 or IPv6 address without a port +- `auth` — Strict map with version (`"2c"`) and community (Secret) +- `oid` — Numeric root OID in dotted notation +- `opts` — Optional strict map with port, timeout_ms, retries, allow_private, max_results, and on_limit + +**Returns:** Result whose Ok map has exactly target, address, port, version, root_oid, duration_ms, requests, attempts, complete, stop_reason, and values + +**Examples:** + +```ntnt +snmp_walk("10.0.50.1", map { "version": "2c", "community": require_secret("SNMP_COMMUNITY") }, "1.3.6.1.2.1.2.2", map { "allow_private": true, "max_results": 128 }) // Walk a bounded interface table +``` + +**Errors:** + +- **RuntimeError**: std/netmon is disabled — *Fix: Set NTNT_NETMON_ENABLE=1 for the process* +- **RuntimeError**: snmp_walk() auth.community must be Secret — *Fix: Load it with std/secrets.require_secret()* + +**See also:** `snmp_get`, `require_secret`, `net_capabilities` + +*Since v0.5.3* + +--- + ## std/path File path manipulation and resolution diff --git a/docs/SYNTAX_REFERENCE.md b/docs/SYNTAX_REFERENCE.md index 5b4d0b62..c332b48e 100644 --- a/docs/SYNTAX_REFERENCE.md +++ b/docs/SYNTAX_REFERENCE.md @@ -2,7 +2,7 @@ > **Auto-generated from [syntax.toml](syntax.toml)** - Do not edit directly. > -> Last updated: v0.5.2 +> Last updated: v0.5.3 ## Table of Contents diff --git a/docs/release-notes/v0.5.3.md b/docs/release-notes/v0.5.3.md new file mode 100644 index 00000000..99301b0e --- /dev/null +++ b/docs/release-notes/v0.5.3.md @@ -0,0 +1,38 @@ +# NTNT v0.5.3 Release Notes + +v0.5.3 adds bounded numeric SNMPv2c GETNEXT walks to the explicitly imported `std/netmon` module. It extends the v0.5.2 transport and credential boundary without adding MIB parsing, DNS targets, GETBULK, SNMP SET, or background network behavior. + +## Added + +- `snmp_walk(target, auth, oid, opts?) -> Result` for one numeric OID subtree. +- Strict GETNEXT cursor progression with exactly one correlated response varbind per logical request. +- Explicit successful completion reasons: + - `out_of_subtree` + - `end_of_mib_view` + - `no_such_object` + - `no_such_instance` +- Mandatory bounded look-ahead at `max_results`, distinguishing exact completion from true truncation. +- Optional `on_limit: "partial"` for result-limit truncation only. Transport, protocol, ordering, deadline, and byte-budget failures still return `Err(String)` without prior-row telemetry. +- Stable result metadata: target/address/port/version/root OID, duration, logical requests, datagram attempts, completion state, stop reason, and ordered normalized values. + +## Bounds and security + +- SNMPv2c communities remain opaque `Secret` values loaded through `std/secrets`. +- Live calls still require `NTNT_NETMON_ENABLE=1`. +- Private targets still require both `NTNT_NET_ALLOW_PRIVATE=1` and call-local `allow_private: true`. +- Targets remain literal IPv4/IPv6 addresses and use connected UDP sockets. +- One operation-wide timeout covers every cursor, retry, decode, normalization step, look-ahead, and result construction. +- Requests and individual responses are capped at 8 KiB. +- Cumulative received datagrams are capped at 8 MiB and conservative normalized output at 4 MiB. +- `max_results` is 1–2,048, retries are 0–3, and option preflight enforces `(max_results + 1) * (retries + 1) <= 4,096`. +- Equal, repeated, descending, mismatched terminal-exception, malformed, uncorrelated, and oversized responses fail closed. +- Same-agent packets carrying stale request IDs are ignored only within the current connected attempt, while deadline and receive-byte budgets continue to apply. + +SNMPv2c remains plaintext on the wire. Use it only on trusted management networks, VPNs, or protected tunnels. + +## Compatibility + +- Existing `snmp_get` behavior and result normalization remain compatible. +- `std/netmon` remains explicitly imported and is not added to the prelude. +- No network traffic occurs on import. +- Numeric WALK does not load or resolve MIB symbols. The separately designed immutable MIB catalog pipeline remains a later delivery slice. diff --git a/examples/netmon_snmp.tnt b/examples/netmon_snmp.tnt index fa123568..9c942641 100644 --- a/examples/netmon_snmp.tnt +++ b/examples/netmon_snmp.tnt @@ -1,5 +1,5 @@ -// std/netmon Slice 1: bounded SNMPv2c GET without performing traffic on import. -import { snmp_get } from "std/netmon" +// std/netmon Slice 1B: bounded SNMPv2c GET and GETNEXT WALK without traffic on import. +import { snmp_get, snmp_walk } from "std/netmon" fn read_system_info( target: String, @@ -28,6 +28,30 @@ requires len(target) > 0 ) } +fn walk_interface_table( + target: String, + community: Secret, + allow_private: Bool +) -> Result, String> +requires len(target) > 0 +{ + return snmp_walk( + target, + map { + "version": "2c", + "community": community + }, + "1.3.6.1.2.1.2.2.1", // IF-MIB::ifEntry + map { + "timeout_ms": 5000, + "retries": 0, + "allow_private": allow_private, + "max_results": 512, + "on_limit": "partial" + } + ) +} + // At the application boundary, enable protocol calls with NTNT_NETMON_ENABLE=1. // Private targets also require NTNT_NET_ALLOW_PRIVATE=1 and allow_private: true. // Slice 1 accepts literal IPv4 and IPv6 targets; hostname resolution is deferred. diff --git a/src/stdlib/netmon.rs b/src/stdlib/netmon.rs index 6349283e..b056ebe0 100644 --- a/src/stdlib/netmon.rs +++ b/src/stdlib/netmon.rs @@ -7,7 +7,10 @@ #[path = "netmon_codec.rs"] mod codec; -use self::codec::{decode_response, encode_get_request, DecodedValue}; +use self::codec::{ + decode_response, decode_response_allow_stale, encode_get_next_request, encode_get_request, + DecodedValue, +}; use crate::error::{IntentError, Result}; use crate::interpreter::Value; use crate::stdlib::net::enforce_resolved_target_policy; @@ -30,6 +33,11 @@ const MAX_OID_BYTES: usize = 255; const MAX_OID_SEGMENTS: usize = 128; const MAX_REQUEST_BYTES: usize = 8 * 1024; const MAX_RESPONSE_BYTES: usize = 8 * 1024; +const DEFAULT_MAX_RESULTS: usize = 256; +const MAX_WALK_RESULTS: usize = 2_048; +const MAX_WALK_OPERATIONS: usize = 4_096; +const MAX_WALK_RESPONSE_BYTES: usize = 8 * 1024 * 1024; +const MAX_WALK_OUTPUT_BYTES: usize = 4 * 1024 * 1024; #[derive(Debug, Clone, Copy)] struct SnmpOptions { @@ -39,6 +47,19 @@ struct SnmpOptions { allow_private: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OnLimit { + Error, + Partial, +} + +#[derive(Debug, Clone, Copy)] +struct SnmpWalkOptions { + common: SnmpOptions, + max_results: usize, + on_limit: OnLimit, +} + struct SnmpV2cAuth<'a> { community: &'a str, } @@ -94,9 +115,502 @@ pub fn init() -> HashMap { }, ); + // @ntnt snmp_walk + // @module std/netmon + // @signature snmp_walk(target: String, auth: Map, oid: String, opts?: Map) -> Result + // Walks one numeric OID subtree with bounded SNMPv2c GETNEXT requests. The + // community must be an opaque Secret. SNMPv2c is plaintext; use a trusted + // management network or protected tunnel. Every call requires + // `NTNT_NETMON_ENABLE=1`; private targets also require the shared process + // opt-in and `allow_private: true`. + // + // The closed options map accepts the common SNMP options plus `max_results` + // (default 256, hard maximum 2048) and `on_limit` (`"error"`, the default, + // or `"partial"`). One global deadline covers all cursors, retries, response + // validation, the mandatory limit look-ahead, normalization, and result + // construction. Requests and responses are capped at 8 KiB, cumulative + // received bytes at 8 MiB, and conservative normalized output at 4 MiB. + // + // A successful map has exactly: `target: String`, `address: String`, + // `port: Int`, `version: String` (`"2c"`), `root_oid: String`, + // `duration_ms: Int`, `requests: Int`, `attempts: Int`, `complete: Bool`, + // `stop_reason: String`, and `values: Array`. Each value uses the same + // normalized `oid`, `type`, `value`, and optional `encoding` fields as + // `snmp_get`. `stop_reason` is one of `out_of_subtree`, `end_of_mib_view`, + // `no_such_object`, `no_such_instance`, or `max_results`. Only + // `max_results` has `complete: false`, and it is returned only when + // `on_limit: "partial"`; all transport, protocol, ordering, deadline, and + // byte-budget failures are `Err(String)` without prior-row telemetry. + // @param target Literal IPv4 or IPv6 address without a port + // @param auth Strict map with version (`"2c"`) and community (Secret) + // @param oid Numeric root OID in dotted notation + // @param opts Optional strict map with port, timeout_ms, retries, allow_private, max_results, and on_limit + // @returns Result whose Ok map has exactly target, address, port, version, root_oid, duration_ms, requests, attempts, complete, stop_reason, and values + // @error RuntimeError ~ "std/netmon is disabled" fix: "Set NTNT_NETMON_ENABLE=1 for the process" + // @error RuntimeError ~ "snmp_walk() auth.community must be Secret" fix: "Load it with std/secrets.require_secret()" + // @see_also snmp_get, require_secret, net_capabilities + // @since v0.5.3 + // @tags #network, #monitoring, #snmp, #security + // @example snmp_walk("10.0.50.1", map { "version": "2c", "community": require_secret("SNMP_COMMUNITY") }, "1.3.6.1.2.1.2.2", map { "allow_private": true, "max_results": 128 }) ~ "Walk a bounded interface table" + module.insert( + "snmp_walk".to_string(), + Value::NativeFunction { + name: "snmp_walk".to_string(), + arity: 3, + max_arity: 4, + requires: None, + func: snmp_walk_fn, + }, + ); + module } +fn snmp_walk_fn(args: &[Value]) -> Result { + let target = expect_string(&args[0], "snmp_walk() argument 1")?; + let auth = expect_map(&args[1], "snmp_walk() argument 2")?; + let oid = expect_string(&args[2], "snmp_walk() argument 3")?; + let opts = match args.get(3) { + Some(value) => Some(expect_map(value, "snmp_walk() argument 4")?), + None => None, + }; + + Ok(match snmp_walk(target, auth, oid, opts) { + Ok(result) => Value::ok(Value::Map(result)), + Err(error) => Value::err(Value::String(error)), + }) +} + +struct WalkReply { + oid: Vec, + value: Value, + terminal_reason: Option<&'static str>, + normalized_size: usize, +} + +fn snmp_walk( + target: &str, + auth: &HashMap, + oid: &str, + opts: Option<&HashMap>, +) -> std::result::Result, String> { + require_netmon_enabled()?; + let target_ip = parse_target(target).map_err(as_walk_error)?; + let auth = parse_v2c_auth(auth).map_err(as_walk_error)?; + let options = parse_walk_options(opts).map_err(as_walk_error)?; + let root = parse_single_oid(oid).map_err(as_walk_error)?; + let address = SocketAddr::new(target_ip, options.common.port); + enforce_resolved_target_policy( + &[(options.common.port, address)], + options.common.allow_private, + )?; + + let started = Instant::now(); + let deadline = started + options.common.timeout; + let mut cursor = root.arcs.clone(); + let mut values = Vec::with_capacity(options.max_results); + let mut requests = 0usize; + let mut attempts = 0usize; + let mut received_bytes = 0usize; + let mut output_bytes = conservative_walk_envelope_size(target, address, &root.canonical); + + loop { + remaining_until(deadline, "SNMP WALK global timeout expired")?; + requests += 1; + let reply = walk_cursor_request( + address, + auth.community.as_bytes(), + &cursor, + options.common.retries, + deadline, + &mut attempts, + &mut received_bytes, + )?; + + if let Some(reason) = reply.terminal_reason { + return finish_walk( + target, + address, + &root.canonical, + started, + deadline, + requests, + attempts, + true, + reason, + values, + ); + } + if !oid_is_in_subtree(&reply.oid, &root.arcs) { + return finish_walk( + target, + address, + &root.canonical, + started, + deadline, + requests, + attempts, + true, + "out_of_subtree", + values, + ); + } + if values.len() == options.max_results { + return match options.on_limit { + OnLimit::Error => Err(format!( + "SNMP WALK exceeded max_results ({})", + options.max_results + )), + OnLimit::Partial => finish_walk( + target, + address, + &root.canonical, + started, + deadline, + requests, + attempts, + false, + "max_results", + values, + ), + }; + } + output_bytes = add_walk_budget( + output_bytes, + reply.normalized_size, + MAX_WALK_OUTPUT_BYTES, + "normalized output", + )?; + values.push(reply.value); + cursor = reply.oid; + } +} + +#[allow(clippy::too_many_arguments)] +fn walk_cursor_request( + address: SocketAddr, + community: &[u8], + cursor: &[u32], + retries: usize, + deadline: Instant, + attempts: &mut usize, + received_bytes: &mut usize, +) -> std::result::Result { + let attempt_limit = retries.saturating_add(1).clamp(1, MAX_ATTEMPTS); + let mut last_error = "request failed".to_string(); + + for attempt in 0..attempt_limit { + let remaining = remaining_until(deadline, "SNMP WALK global timeout expired")?; + let remaining_attempts = attempt_limit - attempt; + let attempt_budget = remaining / remaining_attempts as u32; + let attempt_deadline = Instant::now() + .checked_add(attempt_budget) + .map(|candidate| candidate.min(deadline)) + .unwrap_or(deadline); + let request_id = rand::random::() & i32::MAX; + let request = encode_bounded_get_next_request(request_id, community, cursor)?; + *attempts += 1; + + let packet = match send_walk_and_receive( + address, + request, + attempt_deadline, + request_id, + community, + received_bytes, + ) { + Ok(packet) => packet, + Err(error) => { + if error.starts_with("SNMP WALK cumulative responses") { + return Err(error); + } + last_error = error; + continue; + } + }; + let response = match decode_response(packet.as_slice(), request_id, community) { + Ok(response) => response, + Err(error) => { + last_error = error; + continue; + } + }; + if response.error_status != 0 { + return Err(format!( + "SNMP agent returned error status {} at varbind index {}", + response.error_status, response.error_index + )); + } + if response.varbinds.len() != 1 { + return Err(format!( + "SNMP WALK response returned {} varbind(s), expected exactly 1", + response.varbinds.len() + )); + } + let varbind = response + .varbinds + .into_iter() + .next() + .expect("checked one varbind"); + let terminal_reason = match &varbind.value { + DecodedValue::EndOfMibView => Some("end_of_mib_view"), + DecodedValue::NoSuchObject => Some("no_such_object"), + DecodedValue::NoSuchInstance => Some("no_such_instance"), + _ => None, + }; + if terminal_reason.is_some() && varbind.oid.as_slice() != cursor { + return Err(format!( + "SNMP WALK terminal exception OID {} must match cursor {}", + format_oid(&varbind.oid), + format_oid(cursor) + )); + } + if terminal_reason.is_none() && varbind.oid.as_slice() <= cursor { + return Err(format!( + "SNMP WALK response OID {} must be strictly increasing after {}", + format_oid(&varbind.oid), + format_oid(cursor) + )); + } + let canonical = format_oid(&varbind.oid); + let value = normalize_varbind(&canonical, varbind.value)?; + let normalized_size = conservative_value_size(&value); + remaining_until( + deadline, + "SNMP WALK global timeout expired while decoding response", + )?; + return Ok(WalkReply { + oid: varbind.oid, + value, + terminal_reason, + normalized_size, + }); + } + + Err(format!( + "SNMP WALK request failed after {attempt_limit} bounded attempt(s): {last_error}" + )) +} + +fn encode_bounded_get_next_request( + request_id: i32, + community: &[u8], + oid: &[u32], +) -> std::result::Result>, String> { + let request = encode_get_next_request(request_id, community, oid)?; + if request.len() > MAX_REQUEST_BYTES { + return Err(format!( + "snmp_walk() encoded request exceeds {MAX_REQUEST_BYTES} bytes" + )); + } + Ok(request) +} + +#[allow(clippy::too_many_arguments)] +fn finish_walk( + target: &str, + address: SocketAddr, + root_oid: &str, + started: Instant, + deadline: Instant, + requests: usize, + attempts: usize, + complete: bool, + stop_reason: &str, + values: Vec, +) -> std::result::Result, String> { + remaining_until( + deadline, + "SNMP WALK global timeout expired before result construction", + )?; + let mut result = HashMap::new(); + result.insert("target".to_string(), Value::String(target.to_string())); + result.insert( + "address".to_string(), + Value::String(address.ip().to_string()), + ); + result.insert("port".to_string(), Value::Int(i64::from(address.port()))); + result.insert("version".to_string(), Value::String("2c".to_string())); + result.insert("root_oid".to_string(), Value::String(root_oid.to_string())); + result.insert("duration_ms".to_string(), Value::Int(elapsed_ms(started))); + result.insert("requests".to_string(), Value::Int(requests as i64)); + result.insert("attempts".to_string(), Value::Int(attempts as i64)); + result.insert("complete".to_string(), Value::Bool(complete)); + result.insert( + "stop_reason".to_string(), + Value::String(stop_reason.to_string()), + ); + result.insert("values".to_string(), Value::Array(values)); + let final_output_size = conservative_map_size(&result); + if final_output_size > MAX_WALK_OUTPUT_BYTES { + return Err(format!( + "SNMP WALK normalized output exceeds {MAX_WALK_OUTPUT_BYTES} bytes" + )); + } + remaining_until( + deadline, + "SNMP WALK global timeout expired while constructing result", + )?; + Ok(result) +} + +fn conservative_walk_envelope_size(target: &str, address: SocketAddr, root_oid: &str) -> usize { + let envelope = HashMap::from([ + ("target".to_string(), Value::String(target.to_string())), + ( + "address".to_string(), + Value::String(address.ip().to_string()), + ), + ("port".to_string(), Value::Int(i64::from(address.port()))), + ("version".to_string(), Value::String("2c".to_string())), + ("root_oid".to_string(), Value::String(root_oid.to_string())), + ("duration_ms".to_string(), Value::Int(0)), + ("requests".to_string(), Value::Int(0)), + ("attempts".to_string(), Value::Int(0)), + ("complete".to_string(), Value::Bool(false)), + ( + "stop_reason".to_string(), + Value::String("no_such_instance".to_string()), + ), + ("values".to_string(), Value::Array(Vec::new())), + ]); + conservative_map_size(&envelope) +} + +fn oid_is_in_subtree(oid: &[u32], root: &[u32]) -> bool { + oid.starts_with(root) +} + +fn conservative_value_size(value: &Value) -> usize { + match value { + Value::String(value) => value.len().saturating_add(32), + Value::Array(values) => values.iter().fold(32usize, |size, value| { + size.saturating_add(conservative_value_size(value)) + }), + Value::Map(values) => conservative_map_size(values), + _ => 64, + } +} + +fn conservative_map_size(values: &HashMap) -> usize { + values.iter().fold(64usize, |size, (key, value)| { + size.saturating_add(key.len()) + .saturating_add(conservative_value_size(value)) + .saturating_add(32) + }) +} + +fn add_walk_budget( + current: usize, + amount: usize, + maximum: usize, + label: &str, +) -> std::result::Result { + let total = current + .checked_add(amount) + .ok_or_else(|| format!("SNMP WALK {label} budget overflow"))?; + if total > maximum { + return Err(format!("SNMP WALK {label} exceed {maximum} bytes")); + } + Ok(total) +} + +fn parse_walk_options( + opts: Option<&HashMap>, +) -> std::result::Result { + let empty = HashMap::new(); + let opts = opts.unwrap_or(&empty); + reject_unknown_keys( + opts, + &[ + "port", + "timeout_ms", + "retries", + "allow_private", + "max_results", + "on_limit", + ], + "options", + )?; + let port = parse_bounded_int( + opts, + "port", + i64::from(DEFAULT_SNMP_PORT), + 1, + i64::from(u16::MAX), + )? as u16; + let timeout_ms = parse_bounded_int( + opts, + "timeout_ms", + DEFAULT_TIMEOUT_MS as i64, + MIN_TIMEOUT_MS as i64, + MAX_TIMEOUT_MS as i64, + )? as u64; + let retries = parse_bounded_int(opts, "retries", 0, 0, MAX_RETRIES as i64)? as usize; + let allow_private = match opts.get("allow_private") { + Some(Value::Bool(value)) => *value, + Some(value) => { + return Err(format!( + "snmp_walk() options.allow_private must be Bool, got {}", + value.type_name() + )) + } + None => false, + }; + let max_results = parse_bounded_int( + opts, + "max_results", + DEFAULT_MAX_RESULTS as i64, + 1, + MAX_WALK_RESULTS as i64, + )? as usize; + let on_limit = match opts.get("on_limit") { + Some(Value::String(value)) if value == "error" => OnLimit::Error, + Some(Value::String(value)) if value == "partial" => OnLimit::Partial, + Some(Value::String(_)) => { + return Err("snmp_walk() options.on_limit must be 'error' or 'partial'".to_string()) + } + Some(value) => { + return Err(format!( + "snmp_walk() options.on_limit must be String, got {}", + value.type_name() + )) + } + None => OnLimit::Error, + }; + let operations = max_results + .checked_add(1) + .and_then(|requests| requests.checked_mul(retries + 1)) + .ok_or_else(|| "snmp_walk() operation budget overflow".to_string())?; + if operations > MAX_WALK_OPERATIONS { + return Err(format!( + "snmp_walk() requires {operations} possible attempts; maximum is {MAX_WALK_OPERATIONS}" + )); + } + Ok(SnmpWalkOptions { + common: SnmpOptions { + port, + timeout: Duration::from_millis(timeout_ms), + retries, + allow_private, + }, + max_results, + on_limit, + }) +} + +fn parse_single_oid(raw: &str) -> std::result::Result { + parse_oids(&[Value::String(raw.to_string())]).and_then(|mut parsed| { + parsed + .pop() + .ok_or_else(|| "snmp_walk() oid is required".to_string()) + }) +} + +fn as_walk_error(error: String) -> String { + error.replace("snmp_get()", "snmp_walk()") +} + fn snmp_get_fn(args: &[Value]) -> Result { let target = expect_string(&args[0], "snmp_get() argument 1")?; let auth = expect_map(&args[1], "snmp_get() argument 2")?; @@ -233,6 +747,122 @@ fn encode_bounded_request( Ok(request) } +fn send_walk_and_receive( + address: SocketAddr, + request: Zeroizing>, + attempt_deadline: Instant, + expected_request_id: i32, + expected_community: &[u8], + received_bytes: &mut usize, +) -> std::result::Result>, String> { + let bind_address = match address { + SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), + }; + let socket = + UdpSocket::bind(bind_address).map_err(|error| format!("SNMP UDP bind failed: {error}"))?; + socket + .connect(address) + .map_err(|error| format!("SNMP UDP connect failed: {error}"))?; + socket + .set_write_timeout(Some(remaining_until( + attempt_deadline, + "attempt timeout expired before send", + )?)) + .map_err(|error| format!("SNMP UDP write-timeout setup failed: {error}"))?; + let sent = socket + .send(request.as_slice()) + .map_err(|error| format!("SNMP UDP send failed: {error}"))?; + if sent != request.len() { + return Err(format!( + "SNMP UDP send wrote {sent} bytes, expected {}", + request.len() + )); + } + drop(request); + + let mut packet = Zeroizing::new(vec![0_u8; MAX_RESPONSE_BYTES + 1]); + loop { + socket + .set_read_timeout(Some(remaining_until( + attempt_deadline, + "attempt timeout expired after send", + )?)) + .map_err(|error| format!("SNMP UDP read-timeout setup failed: {error}"))?; + packet.resize(MAX_RESPONSE_BYTES + 1, 0); + let received = match socket.recv(packet.as_mut_slice()) { + Ok(received) => received, + Err(error) if is_oversized_datagram_error(&error) => { + *received_bytes = add_walk_budget( + *received_bytes, + MAX_RESPONSE_BYTES + 1, + MAX_WALK_RESPONSE_BYTES, + "cumulative responses", + )?; + return Err(format!("SNMP response exceeds {MAX_RESPONSE_BYTES} bytes")); + } + Err(error) => return Err(format!("SNMP UDP receive failed: {error}")), + }; + *received_bytes = add_walk_budget( + *received_bytes, + received, + MAX_WALK_RESPONSE_BYTES, + "cumulative responses", + )?; + if Instant::now() >= attempt_deadline { + return Err("SNMP attempt timeout expired while receiving response".to_string()); + } + if received > MAX_RESPONSE_BYTES { + return Err(format!("SNMP response exceeds {MAX_RESPONSE_BYTES} bytes")); + } + packet.truncate(received); + match decode_response_allow_stale( + packet.as_slice(), + expected_request_id, + expected_community, + ) { + Ok(Some(_)) => return Ok(packet), + Ok(None) => continue, + Err(error) => return Err(error), + } + } +} + +fn is_oversized_datagram_error(error: &std::io::Error) -> bool { + #[cfg(any(target_os = "linux", target_os = "android"))] + { + error.raw_os_error() == Some(90) + } + #[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd" + ))] + { + error.raw_os_error() == Some(40) + } + #[cfg(windows)] + { + error.raw_os_error() == Some(10_040) + } + #[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + windows + )))] + { + let _ = error; + false + } +} + fn send_and_receive( address: SocketAddr, request: Zeroizing>, @@ -760,4 +1390,103 @@ mod tests { .expect_err("oversized request must fail before transport"); assert!(error.contains("encoded request exceeds")); } + + #[test] + fn walk_cumulative_receive_and_output_budgets_are_exact_and_overflow_safe() { + assert_eq!( + add_walk_budget( + MAX_WALK_RESPONSE_BYTES - 1, + 1, + MAX_WALK_RESPONSE_BYTES, + "cumulative responses", + ) + .expect("exact receive limit is allowed"), + MAX_WALK_RESPONSE_BYTES + ); + assert!(add_walk_budget( + MAX_WALK_RESPONSE_BYTES, + 1, + MAX_WALK_RESPONSE_BYTES, + "cumulative responses", + ) + .expect_err("receive limit plus one must fail") + .contains("cumulative responses")); + assert_eq!( + add_walk_budget( + MAX_WALK_OUTPUT_BYTES - 1, + 1, + MAX_WALK_OUTPUT_BYTES, + "normalized output", + ) + .expect("exact output limit is allowed"), + MAX_WALK_OUTPUT_BYTES + ); + assert!( + add_walk_budget(usize::MAX, 1, MAX_WALK_OUTPUT_BYTES, "normalized output",) + .expect_err("counter overflow must fail") + .contains("overflow") + ); + } + + #[test] + fn walk_receive_budget_charges_oversized_datagram_before_rejection() { + let server = UdpSocket::bind("127.0.0.1:0").expect("bind fixture"); + let address = server.local_addr().expect("fixture address"); + let fixture = std::thread::spawn(move || { + let mut request = [0u8; 16]; + let (_, peer) = server.recv_from(&mut request).expect("receive request"); + server + .send_to(&vec![0u8; MAX_RESPONSE_BYTES + 1], peer) + .expect("send oversized response"); + }); + let mut received_bytes = MAX_WALK_RESPONSE_BYTES - MAX_RESPONSE_BYTES; + let error = send_walk_and_receive( + address, + Zeroizing::new(vec![0]), + Instant::now() + Duration::from_secs(1), + 7, + b"secret", + &mut received_bytes, + ) + .expect_err("oversized datagram must consume the cumulative budget first"); + fixture.join().expect("fixture thread"); + assert!(error.contains("cumulative responses"), "{error}"); + } + + #[test] + fn finish_walk_enforces_the_exact_conservative_output_boundary() { + let address: SocketAddr = "127.0.0.1:161".parse().expect("socket address"); + let finish = |payload_len: usize| { + let started = Instant::now(); + finish_walk( + "127.0.0.1", + address, + "1.3.6.1.2.1", + started, + started + Duration::from_secs(5), + 1, + 1, + true, + "end_of_mib_view", + vec![Value::String("x".repeat(payload_len))], + ) + }; + + let mut accepted = 0usize; + let mut rejected = MAX_WALK_OUTPUT_BYTES + 1; + while accepted + 1 < rejected { + let midpoint = accepted + (rejected - accepted) / 2; + if finish(midpoint).is_ok() { + accepted = midpoint; + } else { + rejected = midpoint; + } + } + + let exact = finish(accepted).expect("exact conservative output limit must pass"); + assert_eq!(conservative_map_size(&exact), MAX_WALK_OUTPUT_BYTES); + assert!(finish(accepted + 1) + .expect_err("one byte beyond conservative output limit must fail") + .contains("normalized output exceeds")); + } } diff --git a/src/stdlib/netmon_codec.rs b/src/stdlib/netmon_codec.rs index 13b92849..353a3421 100644 --- a/src/stdlib/netmon_codec.rs +++ b/src/stdlib/netmon_codec.rs @@ -13,6 +13,7 @@ const TAG_TIMETICKS: u8 = 0x43; const TAG_OPAQUE: u8 = 0x44; const TAG_COUNTER64: u8 = 0x46; const TAG_GET_REQUEST: u8 = 0xa0; +const TAG_GET_NEXT_REQUEST: u8 = 0xa1; const TAG_GET_RESPONSE: u8 = 0xa2; const TAG_NO_SUCH_OBJECT: u8 = 0x80; const TAG_NO_SUCH_INSTANCE: u8 = 0x81; @@ -53,6 +54,29 @@ pub(crate) fn encode_get_request( request_id: i32, community: &[u8], oids: &[Vec], +) -> Result>, String> { + encode_request(request_id, community, oids, TAG_GET_REQUEST) +} + +pub(crate) fn encode_get_next_request( + request_id: i32, + community: &[u8], + oid: &[u32], +) -> Result>, String> { + let oid = oid.to_vec(); + encode_request( + request_id, + community, + std::slice::from_ref(&oid), + TAG_GET_NEXT_REQUEST, + ) +} + +fn encode_request( + request_id: i32, + community: &[u8], + oids: &[Vec], + pdu_tag: u8, ) -> Result>, String> { let mut varbind_list = Vec::new(); for oid in oids { @@ -76,7 +100,7 @@ pub(crate) fn encode_get_request( let mut message = Zeroizing::new(Vec::with_capacity(message_capacity)); append_tlv(&mut message, TAG_INTEGER, &[1]); append_tlv(&mut message, TAG_OCTET_STRING, community); - append_tlv(&mut message, TAG_GET_REQUEST, &pdu); + append_tlv(&mut message, pdu_tag, &pdu); debug_assert_eq!(message.len(), message_capacity); let request_capacity = tlv_size(message.len()); @@ -91,6 +115,24 @@ pub(crate) fn decode_response<'a>( expected_request_id: i32, expected_community: &[u8], ) -> Result, String> { + decode_response_internal(packet, expected_request_id, expected_community, false)? + .ok_or_else(|| "SNMP response request id mismatch".to_string()) +} + +pub(crate) fn decode_response_allow_stale<'a>( + packet: &'a [u8], + expected_request_id: i32, + expected_community: &[u8], +) -> Result>, String> { + decode_response_internal(packet, expected_request_id, expected_community, true) +} + +fn decode_response_internal<'a>( + packet: &'a [u8], + expected_request_id: i32, + expected_community: &[u8], + allow_stale_request_id: bool, +) -> Result>, String> { let mut packet_reader = Reader::new(packet); let message_bytes = packet_reader.expect(TAG_SEQUENCE, "SNMP message")?; packet_reader.finish("SNMP datagram")?; @@ -117,12 +159,20 @@ pub(crate) fn decode_response<'a>( let mut pdu = Reader::new(pdu_bytes); let request_id = decode_signed_integer(pdu.expect(TAG_INTEGER, "request id")?)?; if request_id != i64::from(expected_request_id) { + if allow_stale_request_id { + return Ok(None); + } return Err(format!( "SNMP response request id mismatch: expected {expected_request_id}, got {request_id}" )); } let error_status = decode_nonnegative_u32(pdu.expect(TAG_INTEGER, "error status")?)?; let error_index = decode_nonnegative_u32(pdu.expect(TAG_INTEGER, "error index")?)?; + if error_status == 0 && error_index != 0 { + return Err(format!( + "SNMP response error index must be zero when error status is zero, got {error_index}" + )); + } let varbind_list = pdu.expect(TAG_SEQUENCE, "varbind list")?; pdu.finish("SNMP response PDU")?; @@ -138,11 +188,11 @@ pub(crate) fn decode_response<'a>( varbinds.push(DecodedVarbind { oid, value }); } - Ok(DecodedResponse { + Ok(Some(DecodedResponse { error_status, error_index, varbinds, - }) + })) } fn decode_value<'a>(tag: u8, bytes: &'a [u8]) -> Result, String> { @@ -471,6 +521,8 @@ mod tests { const RESPONSE_HEX: &str = "30550201010406736563726574a248020412345678020100020100303a300d06082b060102010101000101ff301506082b06010201010300460900ffffffffffffffff301206082b060102010105000406726f75746572"; const REQUEST_HEX: &str = "30370201010406736563726574a02a020412345678020100020100301c300c06082b060102010101000500300c06082b060102010105000500"; + const GET_NEXT_REQUEST_HEX: &str = + "30290201010406736563726574a11c020412345678020100020100300e300c06082b060102010101000500"; fn response() -> Vec { hex::decode(RESPONSE_HEX).expect("valid golden response") @@ -490,6 +542,13 @@ mod tests { assert_eq!(hex::encode(encoded.as_slice()), REQUEST_HEX); } + #[test] + fn encodes_canonical_v2c_get_next_request() { + let encoded = encode_get_next_request(0x1234_5678, b"secret", &[1, 3, 6, 1, 2, 1, 1, 1, 0]) + .expect("GETNEXT request encodes"); + assert_eq!(hex::encode(encoded.as_slice()), GET_NEXT_REQUEST_HEX); + } + #[test] fn decodes_boolean_and_full_unsigned_counter64_from_golden_wire_bytes() { let packet = response(); @@ -512,6 +571,9 @@ mod tests { assert!(decode_response(&packet, 7, b"secret") .expect_err("request id mismatch") .contains("request id")); + assert!(decode_response_allow_stale(&packet, 7, b"secret") + .expect("stale response is classified") + .is_none()); assert!(decode_response(&packet, 0x1234_5678, b"wrong") .expect_err("community mismatch") .contains("community")); diff --git a/src/typechecker.rs b/src/typechecker.rs index eef0779b..0c8bad7f 100644 --- a/src/typechecker.rs +++ b/src/typechecker.rs @@ -4560,6 +4560,17 @@ fn get_module_signatures(module: &str) -> HashMap { "target" => Type::String, "auth" => map.clone(), "oids" => Type::Array(Box::new(Type::String)), + "opts" => map.clone() + ], + result_map.clone(), + required(3) + ); + sig!( + "snmp_walk", + [ + "target" => Type::String, + "auth" => map.clone(), + "oid" => Type::String, "opts" => map ], result_map, diff --git a/tests/std_netmon_tests.rs b/tests/std_netmon_tests.rs index 71210beb..c1a5f1f0 100644 --- a/tests/std_netmon_tests.rs +++ b/tests/std_netmon_tests.rs @@ -23,18 +23,8 @@ fn unique_test_file(prefix: &str) -> String { .to_string() } -fn ntnt_binary() -> String { - let exe = std::env::consts::EXE_SUFFIX; - for path in [ - format!("./target/debug/ntnt{exe}"), - format!("./target/dev-release/ntnt{exe}"), - format!("./target/release/ntnt{exe}"), - ] { - if std::path::Path::new(&path).exists() { - return path; - } - } - panic!("No ntnt binary found. Run 'cargo build' first."); +fn ntnt_binary() -> &'static str { + env!("CARGO_BIN_EXE_ntnt") } fn run_ntnt_code(code: &str, envs: &[(&str, &str)]) -> (String, String, i32) { @@ -104,6 +94,7 @@ fn read_tlv(bytes: &[u8], offset: &mut usize) -> Option<(u8, usize, Range struct RequestLayout { pdu_tag_offset: usize, version_value_offset: usize, + error_index_value_offset: usize, } fn inspect_get_request(bytes: &[u8]) -> Option { @@ -137,7 +128,7 @@ fn inspect_get_request(bytes: &[u8]) -> Option { || error_status_tag != 0x02 || &bytes[error_status] != [0] || error_index_tag != 0x02 - || &bytes[error_index] != [0] + || bytes[error_index.clone()] != [0] || varbind_list_tag != 0x30 || pdu_offset != pdu.end { @@ -171,6 +162,7 @@ fn inspect_get_request(bytes: &[u8]) -> Option { Some(RequestLayout { pdu_tag_offset, version_value_offset: version.start, + error_index_value_offset: error_index.start, }) } @@ -191,6 +183,7 @@ enum AgentBehavior { WrongVersion, TrailingBytes, OversizedResponse, + NonzeroSuccessIndex, } fn start_mock_snmp_agent(behavior: AgentBehavior) -> (u16, std::thread::JoinHandle) { @@ -239,6 +232,10 @@ fn start_mock_snmp_agent(behavior: AgentBehavior) -> (u16, std::thread::JoinHand } AgentBehavior::TrailingBytes => response.push(0), AgentBehavior::OversizedResponse => response = vec![0; 8 * 1024 + 1], + AgentBehavior::NonzeroSuccessIndex => { + let layout = inspect_get_request(&request[..length]).expect("validated layout"); + response[layout.error_index_value_offset] = 1; + } AgentBehavior::Respond | AgentBehavior::DropFirst | AgentBehavior::Silent => {} } let _ = socket.send_to(&response, peer); @@ -369,6 +366,10 @@ fn snmp_get_rejects_unrequested_oid_wrong_version_and_trailing_bytes() { AgentBehavior::OversizedResponse, "response exceeds 8192 bytes", ), + ( + AgentBehavior::NonzeroSuccessIndex, + "error index must be zero", + ), ] { let (port, agent) = start_mock_snmp_agent(behavior); let source = snmp_source(port, 500, 0, r#"print("unexpected success")"#); diff --git a/tests/std_netmon_walk_tests.rs b/tests/std_netmon_walk_tests.rs new file mode 100644 index 00000000..680e49b2 --- /dev/null +++ b/tests/std_netmon_walk_tests.rs @@ -0,0 +1,893 @@ +//! End-to-end tests for bounded SNMPv2c GETNEXT WALK. +//! +//! The UDP fixture hand-encodes responses and independently parses request BER; +//! it does not reuse the production codec. + +use std::fs; +use std::io::Write; +use std::net::UdpSocket; +use std::ops::Range; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +const SECRET_CANARY: &str = "walk-community-canary-never-render"; +static TEST_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn unique_test_file() -> String { + let counter = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + std::env::temp_dir() + .join(format!( + "ntnt_std_netmon_walk_{}_{}.tnt", + std::process::id(), + counter + )) + .to_string_lossy() + .to_string() +} + +fn ntnt_binary() -> &'static str { + env!("CARGO_BIN_EXE_ntnt") +} + +fn run_ntnt_code(code: &str, envs: &[(&str, &str)]) -> (String, String, i32) { + let test_file = unique_test_file(); + let mut file = fs::File::create(&test_file).expect("create test source"); + writeln!(file, "{code}").expect("write test source"); + drop(file); + + let mut command = Command::new(ntnt_binary()); + command + .args(["run", &test_file]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .env("NTNT_ENV", "development") + .env("APP_ENV", "development") + .env("NTNT_SECRETS_PROVIDER", "env") + .env_remove("NTNT_NETMON_ENABLE") + .env_remove("NTNT_NET_ALLOW_PRIVATE") + .env_remove("NTNT_TYPE_MODE") + .env_remove("NTNT_LINT_MODE") + .env_remove("NTNT_STRICT") + .env_remove("NTNT_OOB_MODE"); + for (key, value) in envs { + command.env(key, value); + } + let output = command.output().expect("run ntnt"); + let _ = fs::remove_file(test_file); + ( + String::from_utf8_lossy(&output.stdout).to_string(), + String::from_utf8_lossy(&output.stderr).to_string(), + output.status.code().unwrap_or(-1), + ) +} + +fn read_length(bytes: &[u8], offset: &mut usize) -> Option { + let first = *bytes.get(*offset)?; + *offset += 1; + if first & 0x80 == 0 { + return Some(usize::from(first)); + } + let count = usize::from(first & 0x7f); + if count == 0 || count > std::mem::size_of::() { + return None; + } + let mut length = 0usize; + for _ in 0..count { + length = length.checked_mul(256)?; + length = length.checked_add(usize::from(*bytes.get(*offset)?))?; + *offset += 1; + } + Some(length) +} + +fn read_tlv(bytes: &[u8], offset: &mut usize) -> Option<(u8, Range)> { + let tag = *bytes.get(*offset)?; + *offset += 1; + let length = read_length(bytes, offset)?; + let start = *offset; + let end = start.checked_add(length)?; + if end > bytes.len() { + return None; + } + *offset = end; + Some((tag, start..end)) +} + +fn append_length(output: &mut Vec, length: usize) { + if length < 128 { + output.push(length as u8); + return; + } + let bytes = length.to_be_bytes(); + let start = bytes + .iter() + .position(|byte| *byte != 0) + .unwrap_or(bytes.len() - 1); + output.push(0x80 | (bytes.len() - start) as u8); + output.extend_from_slice(&bytes[start..]); +} + +fn tlv(tag: u8, content: &[u8]) -> Vec { + let mut output = vec![tag]; + append_length(&mut output, content.len()); + output.extend_from_slice(content); + output +} + +fn encode_base128(mut value: u64, output: &mut Vec) { + let mut bytes = [0u8; 10]; + let mut index = bytes.len() - 1; + bytes[index] = (value & 0x7f) as u8; + value >>= 7; + while value != 0 { + index -= 1; + bytes[index] = ((value & 0x7f) as u8) | 0x80; + value >>= 7; + } + output.extend_from_slice(&bytes[index..]); +} + +fn encode_oid(arcs: &[u32]) -> Vec { + let mut output = Vec::new(); + encode_base128(u64::from(arcs[0]) * 40 + u64::from(arcs[1]), &mut output); + for arc in &arcs[2..] { + encode_base128(u64::from(*arc), &mut output); + } + output +} + +fn decode_oid(bytes: &[u8]) -> Option> { + let mut encoded_arcs = Vec::new(); + let mut value = 0u64; + for byte in bytes { + value = value.checked_shl(7)?.checked_add(u64::from(byte & 0x7f))?; + if byte & 0x80 == 0 { + encoded_arcs.push(u32::try_from(value).ok()?); + value = 0; + } + } + if value != 0 || encoded_arcs.is_empty() { + return None; + } + let combined = encoded_arcs.remove(0); + let (first, second) = if combined < 40 { + (0, combined) + } else if combined < 80 { + (1, combined - 40) + } else { + (2, combined - 80) + }; + let mut arcs = vec![first, second]; + arcs.extend(encoded_arcs); + Some(arcs) +} + +#[derive(Debug)] +struct ParsedRequest { + request_id: Vec, + community: Vec, + cursor: Vec, + pdu_tag: u8, +} + +fn parse_request(bytes: &[u8]) -> Option { + let mut root_offset = 0; + let (outer_tag, outer) = read_tlv(bytes, &mut root_offset)?; + if outer_tag != 0x30 || root_offset != bytes.len() { + return None; + } + + let mut message_offset = outer.start; + let (version_tag, version) = read_tlv(bytes, &mut message_offset)?; + let (community_tag, community) = read_tlv(bytes, &mut message_offset)?; + let (pdu_tag, pdu) = read_tlv(bytes, &mut message_offset)?; + if version_tag != 0x02 + || bytes[version] != [1] + || community_tag != 0x04 + || message_offset != outer.end + { + return None; + } + + let mut pdu_offset = pdu.start; + let (request_id_tag, request_id) = read_tlv(bytes, &mut pdu_offset)?; + let (error_status_tag, error_status) = read_tlv(bytes, &mut pdu_offset)?; + let (error_index_tag, error_index) = read_tlv(bytes, &mut pdu_offset)?; + let (list_tag, list) = read_tlv(bytes, &mut pdu_offset)?; + if request_id_tag != 0x02 + || request_id.is_empty() + || error_status_tag != 0x02 + || bytes[error_status] != [0] + || error_index_tag != 0x02 + || bytes[error_index] != [0] + || list_tag != 0x30 + || pdu_offset != pdu.end + { + return None; + } + + let mut list_offset = list.start; + let (varbind_tag, varbind) = read_tlv(bytes, &mut list_offset)?; + if varbind_tag != 0x30 || list_offset != list.end { + return None; + } + let mut varbind_offset = varbind.start; + let (oid_tag, oid) = read_tlv(bytes, &mut varbind_offset)?; + let (null_tag, null) = read_tlv(bytes, &mut varbind_offset)?; + if oid_tag != 0x06 || null_tag != 0x05 || !null.is_empty() || varbind_offset != varbind.end { + return None; + } + + Some(ParsedRequest { + request_id: bytes[request_id].to_vec(), + community: bytes[community].to_vec(), + cursor: decode_oid(&bytes[oid])?, + pdu_tag, + }) +} + +#[derive(Clone)] +enum ValueSpec { + Integer(u8), + Octets(Vec), + EndOfMibView, + NoSuchObject, + NoSuchInstance, +} + +#[derive(Clone, Copy, Debug)] +enum Corruption { + None, + WrongVersion, + WrongCommunity, + WrongPdu, + WrongRequestId, + Trailing, + Malformed, + MultipleVarbinds, + Oversized, + AgentError, + NonzeroSuccessIndex, +} + +#[derive(Clone)] +enum Action { + Respond { + oid: Vec, + value: ValueSpec, + corruption: Corruption, + }, + RememberResponse { + oid: Vec, + value: ValueSpec, + }, + SendRememberedThenRespond { + oid: Vec, + value: ValueSpec, + }, + ForgedThenRespond { + oid: Vec, + value: ValueSpec, + }, + Drop, +} + +fn encoded_value(value: &ValueSpec) -> Vec { + match value { + ValueSpec::Integer(value) => tlv(0x02, &[*value]), + ValueSpec::Octets(value) => tlv(0x04, value), + ValueSpec::EndOfMibView => tlv(0x82, &[]), + ValueSpec::NoSuchObject => tlv(0x80, &[]), + ValueSpec::NoSuchInstance => tlv(0x81, &[]), + } +} + +fn build_response( + request: &ParsedRequest, + oid: &[u32], + value: &ValueSpec, + corruption: Corruption, +) -> Vec { + if matches!(corruption, Corruption::Oversized) { + return vec![0; 8 * 1024 + 1]; + } + + let mut varbind_content = tlv(0x06, &encode_oid(oid)); + varbind_content.extend_from_slice(&encoded_value(value)); + let varbind = tlv(0x30, &varbind_content); + let mut list_content = varbind.clone(); + if matches!(corruption, Corruption::MultipleVarbinds) { + list_content.extend_from_slice(&varbind); + } + + let request_id = if matches!(corruption, Corruption::WrongRequestId) { + vec![0x7f] + } else { + request.request_id.clone() + }; + let mut pdu_content = tlv(0x02, &request_id); + let error_status = if matches!(corruption, Corruption::AgentError) { + 5 + } else { + 0 + }; + let error_index = if matches!( + corruption, + Corruption::AgentError | Corruption::NonzeroSuccessIndex + ) { + 1 + } else { + 0 + }; + pdu_content.extend_from_slice(&tlv(0x02, &[error_status])); + pdu_content.extend_from_slice(&tlv(0x02, &[error_index])); + pdu_content.extend_from_slice(&tlv(0x30, &list_content)); + let pdu_tag = if matches!(corruption, Corruption::WrongPdu) { + 0xa0 + } else { + 0xa2 + }; + + let version = if matches!(corruption, Corruption::WrongVersion) { + 0 + } else { + 1 + }; + let community = if matches!(corruption, Corruption::WrongCommunity) { + b"wrong".as_slice() + } else { + request.community.as_slice() + }; + let mut message = tlv(0x02, &[version]); + message.extend_from_slice(&tlv(0x04, community)); + message.extend_from_slice(&tlv(pdu_tag, &pdu_content)); + let mut response = tlv(0x30, &message); + if matches!(corruption, Corruption::Trailing) { + response.push(0); + } + if matches!(corruption, Corruption::Malformed) { + response[1] = response[1].saturating_add(1); + } + response +} + +#[derive(Debug)] +struct AgentReport { + requests: usize, + cursors: Vec>, + pdu_tags: Vec, +} + +fn start_agent(actions: Vec) -> (u16, std::thread::JoinHandle) { + let socket = UdpSocket::bind("127.0.0.1:0").expect("bind mock WALK agent"); + socket + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("set fixture timeout"); + let port = socket.local_addr().expect("fixture address").port(); + let handle = std::thread::spawn(move || { + let mut report = AgentReport { + requests: 0, + cursors: Vec::new(), + pdu_tags: Vec::new(), + }; + let mut packet = [0u8; 65_535]; + let mut remembered_response: Option> = None; + for action in actions { + let Ok((length, peer)) = socket.recv_from(&mut packet) else { + break; + }; + report.requests += 1; + let Some(request) = parse_request(&packet[..length]) else { + break; + }; + assert_eq!( + request.community.as_slice(), + SECRET_CANARY.as_bytes(), + "WALK request carried an unexpected community" + ); + report.cursors.push(request.cursor.clone()); + report.pdu_tags.push(request.pdu_tag); + match action { + Action::Respond { + oid, + value, + corruption, + } => { + let response = build_response(&request, &oid, &value, corruption); + let _ = socket.send_to(&response, peer); + } + Action::RememberResponse { oid, value } => { + remembered_response = + Some(build_response(&request, &oid, &value, Corruption::None)); + } + Action::SendRememberedThenRespond { oid, value } => { + let old = remembered_response + .take() + .expect("fixture remembered response"); + let current = build_response(&request, &oid, &value, Corruption::None); + let _ = socket.send_to(&old, peer); + let _ = socket.send_to(¤t, peer); + } + Action::ForgedThenRespond { oid, value } => { + let response = build_response(&request, &oid, &value, Corruption::None); + let attacker = UdpSocket::bind("127.0.0.1:0").expect("bind forged source"); + let _ = attacker.send_to(&response, peer); + let _ = socket.send_to(&response, peer); + } + Action::Drop => {} + } + } + report + }); + (port, handle) +} + +fn enabled_env() -> [(&'static str, &'static str); 3] { + [ + ("SNMP_TEST_COMMUNITY", SECRET_CANARY), + ("NTNT_NETMON_ENABLE", "1"), + ("NTNT_NET_ALLOW_PRIVATE", "1"), + ] +} + +fn walk_source(port: u16, options: &str, body: &str) -> String { + format!( + r#" +import {{ require_secret }} from "std/secrets" +import {{ snmp_walk }} from "std/netmon" + +let auth = map {{ + "version": "2c", + "community": require_secret("SNMP_TEST_COMMUNITY") +}} +match snmp_walk( + "127.0.0.1", + auth, + "1.3.6.1.2.1.2.2", + map {{ + "port": {port}, + "timeout_ms": 1000, + "allow_private": true, + {options} + }} +) {{ + Ok(result) => {{ {body} }}, + Err(error) => print("ERR: " + error) +}} +"# + ) +} + +fn response(oid: &[u32], value: ValueSpec) -> Action { + corrupted_response(oid, value, Corruption::None) +} + +fn corrupted_response(oid: &[u32], value: ValueSpec, corruption: Corruption) -> Action { + Action::Respond { + oid: oid.to_vec(), + value, + corruption, + } +} + +#[test] +fn snmp_walk_collects_ordered_rows_and_stops_outside_the_root() { + let root = vec![1, 3, 6, 1, 2, 1, 2, 2]; + let first = vec![1, 3, 6, 1, 2, 1, 2, 2, 1, 1]; + let second = vec![1, 3, 6, 1, 2, 1, 2, 2, 1, 2]; + let outside = vec![1, 3, 6, 1, 2, 1, 3, 1]; + let (port, agent) = start_agent(vec![ + response(&first, ValueSpec::Integer(7)), + response(&second, ValueSpec::Octets(b"eth0".to_vec())), + response(&outside, ValueSpec::Integer(9)), + ]); + let source = walk_source( + port, + r#""max_results": 10, "retries": 0"#, + r#" +let values = result["values"] +print(result["root_oid"]) +print(result["requests"]) +print(result["attempts"]) +print(result["complete"]) +print(result["stop_reason"]) +print(len(values)) +print(values[0]["oid"]) +print(values[1]["value"]) +"#, + ); + let (stdout, stderr, code) = run_ntnt_code(&source, &enabled_env()); + let report = agent.join().expect("fixture thread"); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert_eq!(report.requests, 3); + assert_eq!(report.pdu_tags, [0xa1, 0xa1, 0xa1]); + assert_eq!(report.cursors, [root, first.clone(), second.clone()]); + assert_eq!( + stdout.lines().map(str::trim).collect::>(), + [ + "1.3.6.1.2.1.2.2", + "3", + "3", + "true", + "out_of_subtree", + "2", + "1.3.6.1.2.1.2.2.1.1", + "eth0", + ] + ); + assert!(!stdout.contains(SECRET_CANARY)); + assert!(!stderr.contains(SECRET_CANARY)); +} + +#[test] +fn snmp_walk_ignores_forged_source_and_accepts_connected_agent_response() { + let first = vec![1, 3, 6, 1, 2, 1, 2, 2, 1]; + let outside = vec![1, 3, 6, 1, 2, 1, 3]; + let (port, agent) = start_agent(vec![ + Action::ForgedThenRespond { + oid: first.clone(), + value: ValueSpec::Integer(1), + }, + response(&outside, ValueSpec::Integer(2)), + ]); + let source = walk_source( + port, + r#""max_results": 10, "retries": 0"#, + r#"print(len(result["values"]))"#, + ); + let (stdout, stderr, code) = run_ntnt_code(&source, &enabled_env()); + let report = agent.join().expect("fixture thread"); + assert_eq!(report.requests, 2); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert_eq!(stdout.trim(), "1"); +} + +#[test] +fn snmp_walk_ignores_delayed_old_attempt_before_current_response() { + let first = vec![1, 3, 6, 1, 2, 1, 2, 2, 1]; + let outside = vec![1, 3, 6, 1, 2, 1, 3]; + let (port, agent) = start_agent(vec![ + Action::RememberResponse { + oid: first.clone(), + value: ValueSpec::Integer(1), + }, + Action::SendRememberedThenRespond { + oid: first, + value: ValueSpec::Integer(1), + }, + response(&outside, ValueSpec::Integer(2)), + ]); + let source = walk_source( + port, + r#""max_results": 10, "retries": 1, "timeout_ms": 1000"#, + r#" +print(result["requests"]) +print(result["attempts"]) +print(len(result["values"])) +"#, + ); + let (stdout, stderr, code) = run_ntnt_code(&source, &enabled_env()); + let report = agent.join().expect("fixture thread"); + assert_eq!(report.requests, 3); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert_eq!( + stdout.lines().map(str::trim).collect::>(), + ["2", "3", "1"] + ); +} + +#[test] +fn snmp_walk_omits_protocol_exceptions_and_completes_empty_walk() { + let root = vec![1, 3, 6, 1, 2, 1, 2, 2]; + for (value, reason) in [ + (ValueSpec::EndOfMibView, "end_of_mib_view"), + (ValueSpec::NoSuchObject, "no_such_object"), + (ValueSpec::NoSuchInstance, "no_such_instance"), + ] { + let (port, agent) = start_agent(vec![response(&root, value)]); + let source = walk_source( + port, + r#""max_results": 10, "retries": 0"#, + r#" +print(result["complete"]) +print(result["stop_reason"]) +print(len(result["values"])) +"#, + ); + let (stdout, stderr, code) = run_ntnt_code(&source, &enabled_env()); + let report = agent.join().expect("fixture thread"); + assert_eq!(report.requests, 1); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert_eq!( + stdout.lines().map(str::trim).collect::>(), + ["true", reason, "0"] + ); + assert!(!stdout.contains(SECRET_CANARY)); + assert!(!stderr.contains(SECRET_CANARY)); + } +} + +#[test] +fn snmp_walk_rejects_terminal_exception_with_mismatched_cursor_oid() { + let mismatched = vec![1, 3, 6, 1, 2, 1, 2, 2, 99]; + let (port, agent) = start_agent(vec![response(&mismatched, ValueSpec::EndOfMibView)]); + let source = walk_source( + port, + r#""max_results": 10, "retries": 0"#, + r#"print("unexpected success")"#, + ); + let (stdout, stderr, code) = run_ntnt_code(&source, &enabled_env()); + let report = agent.join().expect("fixture thread"); + assert_eq!(report.requests, 1); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert!(stdout.contains("terminal exception OID"), "{stdout}"); + assert!(!stdout.contains(SECRET_CANARY)); + assert!(!stderr.contains(SECRET_CANARY)); +} + +#[test] +fn snmp_walk_exact_max_uses_lookahead_terminal_reason() { + let first = vec![1, 3, 6, 1, 2, 1, 2, 2, 1]; + let second = vec![1, 3, 6, 1, 2, 1, 2, 2, 2]; + let (port, agent) = start_agent(vec![ + response(&first, ValueSpec::Integer(1)), + response(&second, ValueSpec::Integer(2)), + response(&second, ValueSpec::EndOfMibView), + ]); + let source = walk_source( + port, + r#""max_results": 2, "retries": 0"#, + r#" +print(result["complete"]) +print(result["stop_reason"]) +print(result["requests"]) +print(len(result["values"])) +"#, + ); + let (stdout, stderr, code) = run_ntnt_code(&source, &enabled_env()); + let report = agent.join().expect("fixture thread"); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert_eq!(report.requests, 3, "look-ahead request is mandatory"); + assert_eq!( + stdout.lines().map(str::trim).collect::>(), + ["true", "end_of_mib_view", "3", "2"] + ); +} + +#[test] +fn snmp_walk_partial_limit_omits_valid_lookahead() { + let first = vec![1, 3, 6, 1, 2, 1, 2, 2, 1]; + let lookahead = vec![1, 3, 6, 1, 2, 1, 2, 2, 2]; + let (port, agent) = start_agent(vec![ + response(&first, ValueSpec::Integer(1)), + response(&lookahead, ValueSpec::Integer(2)), + ]); + let source = walk_source( + port, + r#""max_results": 1, "on_limit": "partial", "retries": 0"#, + r#" +print(result["complete"]) +print(result["stop_reason"]) +print(result["requests"]) +print(len(result["values"])) +print(result["values"][0]["oid"]) +"#, + ); + let (stdout, stderr, code) = run_ntnt_code(&source, &enabled_env()); + let report = agent.join().expect("fixture thread"); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert_eq!(report.requests, 2); + assert_eq!( + stdout.lines().map(str::trim).collect::>(), + ["false", "max_results", "2", "1", "1.3.6.1.2.1.2.2.1",] + ); +} + +#[test] +fn snmp_walk_limit_error_returns_no_partial_telemetry() { + let first = vec![1, 3, 6, 1, 2, 1, 2, 2, 1]; + let lookahead = vec![1, 3, 6, 1, 2, 1, 2, 2, 2]; + let (port, agent) = start_agent(vec![ + response(&first, ValueSpec::Integer(1)), + response(&lookahead, ValueSpec::Integer(2)), + ]); + let source = walk_source( + port, + r#""max_results": 1, "retries": 0"#, + r#"print("unexpected success")"#, + ); + let (stdout, stderr, code) = run_ntnt_code(&source, &enabled_env()); + let report = agent.join().expect("fixture thread"); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert_eq!(report.requests, 2); + assert_eq!(stdout.trim(), "ERR: SNMP WALK exceeded max_results (1)"); + assert!(!stdout.contains("1.3.6.1.2.1.2.2.1")); + assert!(!stdout.contains(SECRET_CANARY)); + assert!(!stderr.contains(SECRET_CANARY)); +} + +#[test] +fn snmp_walk_rejects_equal_repeated_and_descending_oids() { + let root = vec![1, 3, 6, 1, 2, 1, 2, 2]; + let first = vec![1, 3, 6, 1, 2, 1, 2, 2, 2]; + let descending = vec![1, 3, 6, 1, 2, 1, 2, 2, 1]; + let cases = vec![ + vec![response(&root, ValueSpec::Integer(1))], + vec![ + response(&first, ValueSpec::Integer(1)), + response(&first, ValueSpec::Integer(2)), + ], + vec![ + response(&first, ValueSpec::Integer(1)), + response(&descending, ValueSpec::Integer(2)), + ], + ]; + + for actions in cases { + let (port, agent) = start_agent(actions); + let source = walk_source( + port, + r#""max_results": 10, "retries": 0"#, + r#"print("unexpected success")"#, + ); + let (stdout, stderr, code) = run_ntnt_code(&source, &enabled_env()); + let _ = agent.join().expect("fixture thread"); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert!( + stdout.contains("strictly increasing"), + "unexpected ordering result: {stdout}" + ); + assert!(!stdout.contains(SECRET_CANARY)); + assert!(!stderr.contains(SECRET_CANARY)); + } +} + +#[test] +fn snmp_walk_rejects_malformed_mismatched_and_oversized_responses() { + let row = vec![1, 3, 6, 1, 2, 1, 2, 2, 1]; + let cases = [ + (Corruption::WrongVersion, "version"), + (Corruption::WrongCommunity, "community"), + (Corruption::WrongPdu, "PDU type"), + (Corruption::WrongRequestId, "failed after 1 bounded attempt"), + (Corruption::Trailing, "datagram"), + (Corruption::Malformed, "truncated"), + (Corruption::MultipleVarbinds, "exactly 1"), + (Corruption::Oversized, "exceeds 8192 bytes"), + (Corruption::AgentError, "error status"), + (Corruption::NonzeroSuccessIndex, "error index"), + ]; + + for (corruption, expected) in cases { + let (port, agent) = start_agent(vec![corrupted_response( + &row, + ValueSpec::Integer(1), + corruption, + )]); + let source = walk_source( + port, + r#""max_results": 10, "retries": 0"#, + r#"print("unexpected success")"#, + ); + let (stdout, stderr, code) = run_ntnt_code(&source, &enabled_env()); + let report = agent.join().expect("fixture thread"); + assert_eq!(report.requests, 1); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert!( + stdout.contains(expected), + "expected {expected:?} for {corruption:?}, got {stdout:?}" + ); + assert!(!stdout.contains(SECRET_CANARY)); + assert!(!stderr.contains(SECRET_CANARY)); + } +} + +#[test] +fn snmp_walk_discards_prior_rows_when_a_later_response_is_invalid() { + let first = vec![1, 3, 6, 1, 2, 1, 2, 2, 1]; + let second = vec![1, 3, 6, 1, 2, 1, 2, 2, 2]; + let (port, agent) = start_agent(vec![ + response(&first, ValueSpec::Integer(1)), + corrupted_response(&second, ValueSpec::Integer(2), Corruption::WrongCommunity), + ]); + let source = walk_source( + port, + r#""max_results": 10, "retries": 0"#, + r#"print("unexpected success")"#, + ); + let (stdout, stderr, code) = run_ntnt_code(&source, &enabled_env()); + let report = agent.join().expect("fixture thread"); + assert_eq!(report.requests, 2); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert!(stdout.contains("community mismatch"), "{stdout}"); + assert!(!stdout.contains("1.3.6.1.2.1.2.2.1")); + assert!(!stdout.contains(SECRET_CANARY)); + assert!(!stderr.contains(SECRET_CANARY)); +} + +#[test] +fn snmp_walk_retry_budget_remains_inside_one_global_deadline() { + let (port, agent) = start_agent(vec![Action::Drop, Action::Drop]); + let source = walk_source( + port, + r#""max_results": 10, "retries": 1, "timeout_ms": 150"#, + r#"print("unexpected success")"#, + ); + let started = Instant::now(); + let (stdout, stderr, code) = run_ntnt_code(&source, &enabled_env()); + let elapsed = started.elapsed(); + let report = agent.join().expect("fixture thread"); + assert_eq!(report.requests, 2); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert!(stdout.contains("global timeout") || stdout.contains("bounded attempt")); + assert!( + elapsed < Duration::from_millis(700), + "deadline multiplied across retries: {elapsed:?}" + ); + assert!(!stdout.contains(SECRET_CANARY)); + assert!(!stderr.contains(SECRET_CANARY)); +} + +#[test] +fn snmp_walk_preflight_and_policy_fail_before_transport() { + let source = walk_source( + 9, + r#""max_results": 2048, "retries": 1"#, + r#"print("unexpected success")"#, + ); + let (stdout, stderr, code) = run_ntnt_code(&source, &enabled_env()); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert!(stdout.contains("4098 possible attempts"), "{stdout}"); + + let source = walk_source( + 9, + r#""max_results": 10, "retries": 0, "unknown": true"#, + r#"print("unexpected success")"#, + ); + let (stdout, stderr, code) = run_ntnt_code(&source, &enabled_env()); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert!(stdout.contains("unknown field"), "{stdout}"); + + let source = walk_source( + 9, + r#""max_results": 10, "retries": 0"#, + r#"print("unexpected success")"#, + ); + let env = [("SNMP_TEST_COMMUNITY", SECRET_CANARY)]; + let (stdout, stderr, code) = run_ntnt_code(&source, &env); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert!(stdout.contains("std/netmon is disabled"), "{stdout}"); + + let env = [ + ("SNMP_TEST_COMMUNITY", SECRET_CANARY), + ("NTNT_NETMON_ENABLE", "1"), + ]; + let (stdout, stderr, code) = run_ntnt_code(&source, &env); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert!( + stdout.contains("private") || stdout.contains("loopback"), + "{stdout}" + ); + + for text in [stdout, stderr] { + assert!(!text.contains(SECRET_CANARY)); + } +} + +#[test] +fn snmp_walk_rejects_plaintext_community_without_rendering_it() { + let source = r#" +import { snmp_walk } from "std/netmon" +let auth = map { "version": "2c", "community": "walk-community-canary-never-render" } +match snmp_walk("127.0.0.1", auth, "1.3.6.1", map { "allow_private": true }) { + Ok(_) => print("unexpected success"), + Err(error) => print("ERR: " + error) +} +"#; + let env = [("NTNT_NETMON_ENABLE", "1"), ("NTNT_NET_ALLOW_PRIVATE", "1")]; + let (stdout, stderr, code) = run_ntnt_code(source, &env); + assert_eq!(code, 0, "stderr: {stderr}\nstdout: {stdout}"); + assert!(stdout.contains("must be Secret"), "{stdout}"); + assert!(!stdout.contains(SECRET_CANARY)); + assert!(!stderr.contains(SECRET_CANARY)); +} diff --git a/tests/type_checker_tests.rs b/tests/type_checker_tests.rs index 80207ce1..59f769f1 100644 --- a/tests/type_checker_tests.rs +++ b/tests/type_checker_tests.rs @@ -1222,6 +1222,54 @@ let bad_oids = snmp_get("router.example.com", map {}, "1.3.6.1.2.1.1.1.0") assert_ne!(exit_code, 0); } +#[test] +fn test_std_netmon_snmp_walk_signature_accepts_valid_usage() { + let source = r#" +import { require_secret } from "std/secrets" +import { snmp_walk } from "std/netmon" + +let auth = map { + "version": "2c", + "community": require_secret("SNMP_COMMUNITY") +} +let result: Result, String> = snmp_walk( + "10.0.0.1", + auth, + "1.3.6.1.2.1.2.2", + map { "max_results": 128, "on_limit": "partial" } +) +"#; + let (stdout, _stderr, _exit_code) = lint_code(source); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let errors = json["summary"]["errors"].as_i64().unwrap_or(0); + assert_eq!( + errors, 0, + "valid std/netmon WALK usage should not type error: {stdout}" + ); +} + +#[test] +fn test_std_netmon_snmp_walk_signature_rejects_wrong_argument_types() { + let source = r#" +import { snmp_walk } from "std/netmon" + +let bad_target = snmp_walk(123, map {}, "1.3.6.1") +let bad_auth = snmp_walk("10.0.0.1", "public", "1.3.6.1") +let bad_oid = snmp_walk("10.0.0.1", map {}, ["1.3.6.1"]) +let bad_opts = snmp_walk("10.0.0.1", map {}, "1.3.6.1", 5) +let too_few = snmp_walk("10.0.0.1", map {}) +let too_many = snmp_walk("10.0.0.1", map {}, "1.3.6.1", map {}, map {}) +"#; + let (stdout, _stderr, exit_code) = lint_code(source); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let errors = json["summary"]["errors"].as_i64().unwrap_or(0); + assert!( + errors >= 6, + "wrong std/netmon WALK argument types or arity should error: {stdout}" + ); + assert_ne!(exit_code, 0); +} + // =========================================================================== // Unknown-method lint (DD-063 Rec 4b) // ===========================================================================