Skip to main content

async_snmp/message/
community.rs

1//! Community-based SNMP message format (v1/v2c).
2//!
3//! V1 and V2c messages share the same structure:
4//! `SEQUENCE { version INTEGER, community OCTET STRING, pdu PDU }`
5//!
6//! The only difference is the version number (0 for v1, 1 for v2c).
7//! `SNMPv1` Trap PDUs (tag 0xA4) have a distinct wire format from standard PDUs
8//! and are represented by the `CommunityPdu::TrapV1` variant.
9
10use crate::ber::{Decoder, EncodeBuf, tag};
11use crate::error::internal::DecodeErrorKind;
12use crate::error::{Error, Result, UNKNOWN_TARGET};
13use crate::pdu::{GetBulkPdu, Pdu, PduType, TrapV1Pdu};
14use crate::version::Version;
15use bytes::Bytes;
16
17/// PDU carried inside a community (v1/v2c) message.
18///
19/// `SNMPv1` Trap PDUs have a different wire layout from all other PDU types,
20/// so they are decoded into a distinct variant.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum CommunityPdu {
23    /// Standard PDU (Get, `GetNext`, Response, Set, `GetBulk`, Inform, `TrapV2`, Report).
24    Standard(Pdu),
25    /// `SNMPv1` Trap PDU (distinct wire format, only valid in V1 messages).
26    TrapV1(TrapV1Pdu),
27}
28
29impl CommunityPdu {
30    /// Return a reference to the standard PDU, or `None` if this is a `TrapV1`.
31    #[must_use]
32    pub fn standard(&self) -> Option<&Pdu> {
33        match self {
34            Self::Standard(p) => Some(p),
35            Self::TrapV1(_) => None,
36        }
37    }
38
39    /// Return a reference to the `TrapV1` PDU, or `None` if this is a standard PDU.
40    #[must_use]
41    pub fn trap_v1(&self) -> Option<&TrapV1Pdu> {
42        match self {
43            Self::TrapV1(t) => Some(t),
44            Self::Standard(_) => None,
45        }
46    }
47
48    /// Return the PDU type.
49    #[must_use]
50    pub fn pdu_type(&self) -> PduType {
51        match self {
52            Self::Standard(p) => p.pdu_type,
53            Self::TrapV1(_) => PduType::TrapV1,
54        }
55    }
56
57    /// Encode to BER.
58    pub(crate) fn encode(&self, buf: &mut EncodeBuf) {
59        match self {
60            Self::Standard(p) => p.encode(buf),
61            Self::TrapV1(t) => t.encode(buf),
62        }
63    }
64}
65
66impl From<Pdu> for CommunityPdu {
67    fn from(p: Pdu) -> Self {
68        Self::Standard(p)
69    }
70}
71
72impl From<TrapV1Pdu> for CommunityPdu {
73    fn from(t: TrapV1Pdu) -> Self {
74        Self::TrapV1(t)
75    }
76}
77
78/// Check whether a standard PDU type is valid in a community message of the
79/// given version.
80///
81/// SNMPv1 (RFC 1157) defines GetRequest, GetNextRequest, Response, SetRequest,
82/// and the v1 Trap. GETBULK, InformRequest, SNMPv2-Trap, and Report are SNMPv2
83/// constructs (RFC 3416) and are not valid in an SNMPv1 message. The v1 Trap PDU
84/// is handled separately via its distinct wire tag and is never a `Standard` PDU
85/// here, so it is not represented in this check.
86fn pdu_type_valid_for_version(pdu_type: PduType, version: Version) -> bool {
87    match version {
88        Version::V1 => matches!(
89            pdu_type,
90            PduType::GetRequest | PduType::GetNextRequest | PduType::Response | PduType::SetRequest
91        ),
92        // V2c permits the full SNMPv2 PDU set. TrapV1 is dispatched by tag before
93        // reaching this check, so it can never appear as a Standard PDU.
94        Version::V2c => pdu_type != PduType::TrapV1,
95        // V3 messages are not decoded through this path.
96        Version::V3 => false,
97    }
98}
99
100/// Community-based SNMP message (v1/v2c).
101///
102/// This unified type handles both `SNMPv1` and `SNMPv2c` messages,
103/// which share identical structure but differ in version number.
104/// The `pdu` field is a `CommunityPdu` that can hold either a standard
105/// PDU or a `TrapV1` PDU.
106#[derive(Debug, Clone)]
107pub struct CommunityMessage {
108    /// SNMP version (V1 or V2c)
109    pub version: Version,
110    /// Community string for authentication
111    pub community: Bytes,
112    /// Protocol data unit
113    pub pdu: CommunityPdu,
114}
115
116impl CommunityMessage {
117    /// Create a new community message with a standard PDU.
118    ///
119    /// # Panics
120    /// Panics if version is V3 (use `V3Message` instead).
121    pub fn new(version: Version, community: impl Into<Bytes>, pdu: Pdu) -> Self {
122        assert!(
123            matches!(version, Version::V1 | Version::V2c),
124            "CommunityMessage only supports V1/V2c, not {version:?}"
125        );
126        Self {
127            version,
128            community: community.into(),
129            pdu: CommunityPdu::Standard(pdu),
130        }
131    }
132
133    /// Create a V2c message (convenience constructor).
134    pub fn v2c(community: impl Into<Bytes>, pdu: Pdu) -> Self {
135        Self::new(Version::V2c, community, pdu)
136    }
137
138    /// Create a V1 message with a standard PDU (convenience constructor).
139    pub fn v1(community: impl Into<Bytes>, pdu: Pdu) -> Self {
140        Self::new(Version::V1, community, pdu)
141    }
142
143    /// Create a V1 message carrying a `TrapV1` PDU.
144    pub fn v1_trap(community: impl Into<Bytes>, trap: TrapV1Pdu) -> Self {
145        Self {
146            version: Version::V1,
147            community: community.into(),
148            pdu: CommunityPdu::TrapV1(trap),
149        }
150    }
151
152    /// Encode to BER.
153    pub fn encode(&self) -> Bytes {
154        let mut buf = EncodeBuf::new();
155
156        buf.push_sequence(|buf| {
157            self.pdu.encode(buf);
158            buf.push_octet_string(&self.community);
159            buf.push_integer(self.version.as_i32());
160        });
161
162        buf.finish()
163    }
164
165    /// Decode from BER.
166    ///
167    /// Returns the message with the version parsed from the data.
168    pub fn decode(data: Bytes) -> Result<Self> {
169        let mut decoder = Decoder::new(data);
170        Self::decode_from(&mut decoder)
171    }
172
173    /// Decode from an existing decoder (used by Message dispatcher).
174    pub(crate) fn decode_from(decoder: &mut Decoder) -> Result<Self> {
175        let mut seq = decoder.read_sequence()?;
176
177        let version_num = seq.read_integer()?;
178        let version = Version::from_i32(version_num).ok_or_else(|| {
179            tracing::debug!(target: "async_snmp::ber", { offset = seq.offset(), kind = %DecodeErrorKind::UnknownVersion(version_num) }, "decode error");
180            Error::MalformedResponse {
181                target: UNKNOWN_TARGET,
182            }
183            .boxed()
184        })?;
185
186        Self::decode_from_sequence(&mut seq, version)
187    }
188
189    /// Decode from a sequence decoder where version has already been read.
190    pub(crate) fn decode_from_sequence(seq: &mut Decoder, version: Version) -> Result<Self> {
191        if version == Version::V3 {
192            tracing::debug!(target: "async_snmp::ber", { offset = seq.offset(), kind = %DecodeErrorKind::UnknownVersion(3) }, "decode error");
193            return Err(Error::MalformedResponse {
194                target: UNKNOWN_TARGET,
195            }
196            .boxed());
197        }
198
199        let community = seq.read_octet_string()?;
200
201        // Peek at the PDU tag to dispatch between standard and TrapV1 layouts.
202        let pdu_tag = seq.peek_tag().ok_or_else(|| {
203            tracing::debug!(target: "async_snmp::ber", { offset = seq.offset(), kind = %DecodeErrorKind::TruncatedData }, "truncated community message");
204            Error::MalformedResponse {
205                target: UNKNOWN_TARGET,
206            }
207            .boxed()
208        })?;
209
210        let pdu = if pdu_tag == tag::pdu::TRAP_V1 {
211            // The SNMPv1 Trap PDU (tag 0xA4) is only defined for SNMPv1
212            // (RFC 1157 Section 4.1.6); SNMPv2c uses the SNMPv2-Trap PDU
213            // (tag 0xA7) instead. Reject a v1 Trap carried in a v2c message.
214            if version != Version::V1 {
215                tracing::debug!(target: "async_snmp::ber", { offset = seq.offset(), version = ?version }, "v1 Trap PDU (0xA4) not valid in this version");
216                return Err(Error::MalformedResponse {
217                    target: UNKNOWN_TARGET,
218                }
219                .boxed());
220            }
221            CommunityPdu::TrapV1(TrapV1Pdu::decode(seq)?)
222        } else {
223            let pdu = Pdu::decode(seq)?;
224            // Enforce version<->PDU-type compatibility. GETBULK, InformRequest,
225            // and SNMPv2-Trap are SNMPv2 constructs (RFC 3416) and are not valid
226            // in an SNMPv1 message (RFC 1157).
227            if !pdu_type_valid_for_version(pdu.pdu_type, version) {
228                tracing::debug!(target: "async_snmp::ber", { offset = seq.offset(), version = ?version, pdu_type = %pdu.pdu_type }, "PDU type not valid for SNMP version");
229                return Err(Error::MalformedResponse {
230                    target: UNKNOWN_TARGET,
231                }
232                .boxed());
233            }
234            CommunityPdu::Standard(pdu)
235        };
236
237        Ok(CommunityMessage {
238            version,
239            community,
240            pdu,
241        })
242    }
243
244    /// Consume and return the standard PDU.
245    ///
246    /// Returns `None` if the PDU is a `TrapV1`.
247    pub fn into_pdu(self) -> Option<Pdu> {
248        match self.pdu {
249            CommunityPdu::Standard(p) => Some(p),
250            CommunityPdu::TrapV1(_) => None,
251        }
252    }
253
254    /// Consume and return the `CommunityPdu`.
255    pub fn into_community_pdu(self) -> CommunityPdu {
256        self.pdu
257    }
258
259    /// Encode a GETBULK request message (v2c/v3 only).
260    ///
261    /// GETBULK is not supported in `SNMPv1`.
262    pub fn encode_bulk(version: Version, community: impl Into<Bytes>, pdu: &GetBulkPdu) -> Bytes {
263        debug_assert!(version != Version::V1, "GETBULK not supported in SNMPv1");
264
265        let community = community.into();
266        let mut buf = EncodeBuf::new();
267
268        buf.push_sequence(|buf| {
269            pdu.encode(buf);
270            buf.push_octet_string(&community);
271            buf.push_integer(version.as_i32());
272        });
273
274        buf.finish()
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::oid;
282    use crate::pdu::{GenericTrap, TrapV1Pdu};
283
284    #[test]
285    fn test_v1_trap_roundtrip() {
286        let trap = TrapV1Pdu::new(
287            oid!(1, 3, 6, 1, 4, 1, 9999),
288            [192, 168, 1, 1],
289            GenericTrap::LinkDown,
290            0,
291            12345,
292            vec![],
293        );
294        let msg = CommunityMessage::v1_trap(b"public".as_slice(), trap);
295
296        let encoded = msg.encode();
297        let decoded = CommunityMessage::decode(encoded).unwrap();
298
299        assert_eq!(decoded.version, Version::V1);
300        assert_eq!(decoded.community.as_ref(), b"public");
301        match decoded.pdu {
302            CommunityPdu::TrapV1(ref t) => {
303                assert_eq!(t.enterprise, oid!(1, 3, 6, 1, 4, 1, 9999));
304                assert_eq!(t.agent_addr, [192, 168, 1, 1]);
305                assert_eq!(t.generic_trap, GenericTrap::LinkDown);
306                assert_eq!(t.time_stamp, 12345);
307            }
308            CommunityPdu::Standard(_) => panic!("expected TrapV1 pdu"),
309        }
310    }
311
312    #[test]
313    fn test_v1_roundtrip() {
314        let pdu = Pdu::get_request(42, &[oid!(1, 3, 6, 1, 2, 1, 1, 1, 0)]);
315        let msg = CommunityMessage::v1(b"public".as_slice(), pdu);
316
317        let encoded = msg.encode();
318        let decoded = CommunityMessage::decode(encoded).unwrap();
319
320        assert_eq!(decoded.version, Version::V1);
321        assert_eq!(decoded.community.as_ref(), b"public");
322        assert_eq!(decoded.pdu.standard().unwrap().request_id, 42);
323    }
324
325    #[test]
326    fn test_v2c_roundtrip() {
327        let pdu = Pdu::get_request(123, &[oid!(1, 3, 6, 1, 2, 1, 1, 1, 0)]);
328        let msg = CommunityMessage::v2c(b"private".as_slice(), pdu);
329
330        let encoded = msg.encode();
331        let decoded = CommunityMessage::decode(encoded).unwrap();
332
333        assert_eq!(decoded.version, Version::V2c);
334        assert_eq!(decoded.community.as_ref(), b"private");
335        assert_eq!(decoded.pdu.standard().unwrap().request_id, 123);
336    }
337
338    #[test]
339    fn test_v1_getbulk_rejected() {
340        // GETBULK is an SNMPv2 construct (RFC 3416) and must be rejected in an
341        // SNMPv1 message even though the encoder will emit it.
342        let pdu = Pdu::get_bulk(1, 0, 10, vec![]);
343        let msg = CommunityMessage::new(Version::V1, b"public".as_slice(), pdu);
344        let encoded = msg.encode();
345
346        let result = CommunityMessage::decode(encoded);
347        assert!(
348            result.is_err(),
349            "v1 GETBULK must be rejected, got {result:?}"
350        );
351    }
352
353    #[test]
354    fn test_v1_inform_rejected() {
355        // InformRequest is an SNMPv2 construct and must be rejected in a v1 message.
356        let pdu = Pdu::inform_request(1, 0, &oid!(1, 3, 6, 1, 6, 3, 1, 1, 5, 1), vec![]);
357        let msg = CommunityMessage::new(Version::V1, b"public".as_slice(), pdu);
358        let encoded = msg.encode();
359
360        let result = CommunityMessage::decode(encoded);
361        assert!(
362            result.is_err(),
363            "v1 Inform must be rejected, got {result:?}"
364        );
365    }
366
367    #[test]
368    fn test_v1_trapv2_rejected() {
369        // SNMPv2-Trap must be rejected in a v1 message.
370        let pdu = Pdu::trap_v2(1, 0, &oid!(1, 3, 6, 1, 6, 3, 1, 1, 5, 1), vec![]);
371        let msg = CommunityMessage::new(Version::V1, b"public".as_slice(), pdu);
372        let encoded = msg.encode();
373
374        let result = CommunityMessage::decode(encoded);
375        assert!(
376            result.is_err(),
377            "v1 SNMPv2-Trap must be rejected, got {result:?}"
378        );
379    }
380
381    #[test]
382    fn test_v2c_trapv1_rejected() {
383        // The v1 Trap PDU (tag 0xA4) has a distinct wire layout and is only
384        // defined for SNMPv1; a v2c message carrying it must be rejected.
385        let trap = TrapV1Pdu::new(
386            oid!(1, 3, 6, 1, 4, 1, 9999),
387            [192, 168, 1, 1],
388            GenericTrap::LinkDown,
389            0,
390            12345,
391            vec![],
392        );
393        let mut buf = EncodeBuf::new();
394        buf.push_sequence(|buf| {
395            trap.encode(buf);
396            buf.push_octet_string(b"public");
397            buf.push_integer(Version::V2c.as_i32());
398        });
399        let encoded = buf.finish();
400
401        let result = CommunityMessage::decode(encoded);
402        assert!(
403            result.is_err(),
404            "v2c v1-Trap (0xA4) must be rejected, got {result:?}"
405        );
406    }
407
408    #[test]
409    fn test_version_preserved() {
410        for version in [Version::V1, Version::V2c] {
411            let pdu = Pdu::get_request(1, &[oid!(1, 3, 6, 1)]);
412            let msg = CommunityMessage::new(version, b"test".as_slice(), pdu);
413
414            let encoded = msg.encode();
415            let decoded = CommunityMessage::decode(encoded).unwrap();
416
417            assert_eq!(decoded.version, version);
418        }
419    }
420}