Skip to main content

hardy_ipn_legacy_filter/
lib.rs

1/*!
2IPN 2-element legacy encoding filter
3
4This Egress WriteFilter rewrites IPN 3-element EIDs to legacy 2-element format
5for peers that require the older encoding.
6*/
7
8use hardy_bpa::async_trait;
9
10/// Configuration for IPN 2-element legacy encoding filter
11#[derive(Debug, Clone)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13#[cfg_attr(feature = "serde", serde(default))]
14#[derive(Default)]
15pub struct Config(
16    /// EID patterns for next-hops requiring legacy IPN encoding
17    pub Vec<hardy_eid_patterns::EidPattern>,
18);
19
20/// Egress WriteFilter that rewrites IPN 3-element EIDs to legacy 2-element format.
21///
22/// # Example
23///
24/// ```ignore
25/// let filter = IpnLegacyFilter::new(peer_patterns);
26/// bpa.register_filter(
27///     hardy_bpa::filters::Hook::Egress,
28///     "ipn-legacy",
29///     &[],
30///     hardy_bpa::filters::Filter::Write(Arc::new(filter)),
31/// )?;
32/// ```
33pub struct IpnLegacyFilter {
34    peer_patterns: Vec<hardy_eid_patterns::EidPattern>,
35}
36
37impl IpnLegacyFilter {
38    /// Create a new IPN legacy encoding filter.
39    ///
40    /// The caller should check that `peer_patterns` is not empty before
41    /// constructing the filter (an empty filter would be a no-op).
42    pub fn new(peer_patterns: Vec<hardy_eid_patterns::EidPattern>) -> Self {
43        Self { peer_patterns }
44    }
45}
46
47#[async_trait]
48impl hardy_bpa::filters::WriteFilter for IpnLegacyFilter {
49    async fn filter(
50        &self,
51        bundle: &hardy_bpa::bundle::Bundle,
52        data: &[u8],
53    ) -> Result<hardy_bpa::filters::RewriteResult, hardy_bpa::Error> {
54        // Check if next-hop requires legacy encoding
55        let Some(next_hop) = &bundle.metadata.read_only.next_hop else {
56            return Ok(hardy_bpa::filters::RewriteResult::Continue(None, None));
57        };
58
59        if !self.peer_patterns.iter().any(|p| p.matches(next_hop)) {
60            return Ok(hardy_bpa::filters::RewriteResult::Continue(None, None));
61        }
62
63        // Check if rewriting is needed
64        let needs_source = matches!(bundle.bundle.id.source, hardy_bpv7::eid::Eid::Ipn { .. });
65        let needs_dest = matches!(bundle.bundle.destination, hardy_bpv7::eid::Eid::Ipn { .. });
66
67        if !needs_source && !needs_dest {
68            return Ok(hardy_bpa::filters::RewriteResult::Continue(None, None));
69        }
70
71        // Use Editor to rewrite EIDs
72        let mut editor = hardy_bpv7::editor::Editor::new(&bundle.bundle, data);
73
74        if let hardy_bpv7::eid::Eid::Ipn {
75            fqnn,
76            service_number,
77        } = &bundle.bundle.id.source
78        {
79            editor = editor
80                .with_source(hardy_bpv7::eid::Eid::LegacyIpn {
81                    fqnn: fqnn.clone(),
82                    service_number: *service_number,
83                })
84                .map_err(|(_, e)| e)?;
85        }
86
87        if let hardy_bpv7::eid::Eid::Ipn {
88            fqnn,
89            service_number,
90        } = &bundle.bundle.destination
91        {
92            editor = editor
93                .with_destination(hardy_bpv7::eid::Eid::LegacyIpn {
94                    fqnn: fqnn.clone(),
95                    service_number: *service_number,
96                })
97                .map_err(|(_, e)| e)?;
98        }
99
100        let new_data = editor.rebuild()?;
101
102        Ok(hardy_bpa::filters::RewriteResult::Continue(
103            None,
104            Some(new_data),
105        ))
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use hardy_bpa::bundle::{Bundle, BundleMetadata};
113    use hardy_bpa::filters::{RewriteResult, WriteFilter};
114    use hardy_bpv7::eid::Eid;
115
116    fn make_config(patterns: &[&str]) -> Config {
117        Config(patterns.iter().map(|p| p.parse().unwrap()).collect())
118    }
119
120    fn make_bundle(source: &str, dest: &str, next_hop: Option<&str>) -> (Bundle, Vec<u8>) {
121        let src: Eid = source.parse().unwrap();
122        let dst: Eid = dest.parse().unwrap();
123
124        let (bpv7_bundle, data) = hardy_bpv7::builder::Builder::new(src, dst)
125            .with_payload(std::borrow::Cow::Borrowed(b"test"))
126            .build(hardy_bpv7::creation_timestamp::CreationTimestamp::now())
127            .unwrap();
128
129        let mut metadata = BundleMetadata::default();
130        metadata.read_only.next_hop = next_hop.map(|nh| nh.parse().unwrap());
131
132        let bundle = Bundle {
133            bundle: bpv7_bundle,
134            metadata,
135        };
136        (bundle, data.into())
137    }
138
139    fn make_filter(patterns: &[&str]) -> IpnLegacyFilter {
140        IpnLegacyFilter::new(make_config(patterns).0)
141    }
142
143    // IPNF-06b: No next-hop — no rewrite.
144    #[tokio::test]
145    async fn test_no_next_hop() {
146        let filter = make_filter(&["ipn:*.*"]);
147        let (bundle, data) = make_bundle("ipn:1.1.1", "ipn:1.2.1", None);
148
149        let result = filter.filter(&bundle, &data).await.unwrap();
150        assert!(
151            matches!(result, RewriteResult::Continue(None, None)),
152            "No next-hop should mean no rewrite"
153        );
154    }
155
156    // IPNF-06c: DTN source and destination — no rewrite even with matching next-hop.
157    #[tokio::test]
158    async fn test_dtn_no_rewrite() {
159        let filter = make_filter(&["ipn:*.*"]);
160        let (bundle, data) = make_bundle("dtn://node-a/svc", "dtn://node-b/svc", Some("ipn:0.3.0"));
161
162        let result = filter.filter(&bundle, &data).await.unwrap();
163        assert!(
164            matches!(result, RewriteResult::Continue(None, None)),
165            "DTN EIDs should not be rewritten"
166        );
167    }
168
169    // -------------------------------------------------------------------
170    // Core 4 tests: allocator_id × matching/non-matching
171    // -------------------------------------------------------------------
172
173    // IPNF-01: allocator_id=0, non-matching next-hop — no rewrite.
174    #[tokio::test]
175    async fn test_alloc0_non_matching() {
176        let filter = make_filter(&["ipn:0.99.*"]);
177        let (bundle, data) = make_bundle("ipn:0.1.1", "ipn:0.2.1", Some("ipn:0.3.0"));
178
179        let result = filter.filter(&bundle, &data).await.unwrap();
180        assert!(
181            matches!(result, RewriteResult::Continue(None, None)),
182            "Non-matching next-hop should mean no rewrite"
183        );
184    }
185
186    // IPNF-02: allocator_id=0, matching next-hop — filter runs but bytes
187    // are unchanged because the Builder already uses legacy 2-element
188    // encoding when allocator_id=0.
189    #[tokio::test]
190    async fn test_alloc0_matching() {
191        let filter = make_filter(&["ipn:*.*"]);
192        let (bundle, data) = make_bundle("ipn:0.1.1", "ipn:0.2.1", Some("ipn:0.3.0"));
193
194        let result = filter.filter(&bundle, &data).await.unwrap();
195        let RewriteResult::Continue(None, Some(new_data)) = result else {
196            panic!("Expected rewrite path, got {result:?}");
197        };
198
199        // With allocator_id=0, Builder already produces legacy encoding,
200        // so the output should be identical (idempotent rewrite).
201        assert_eq!(
202            data,
203            new_data.as_ref(),
204            "allocator_id=0: rewrite should be idempotent"
205        );
206    }
207
208    // IPNF-03: allocator_id!=0, non-matching next-hop — no rewrite.
209    #[tokio::test]
210    async fn test_alloc1_non_matching() {
211        let filter = make_filter(&["ipn:0.99.*"]);
212        let (bundle, data) = make_bundle("ipn:1.1.1", "ipn:1.2.1", Some("ipn:0.3.0"));
213
214        let result = filter.filter(&bundle, &data).await.unwrap();
215        assert!(
216            matches!(result, RewriteResult::Continue(None, None)),
217            "Non-matching next-hop should mean no rewrite"
218        );
219    }
220
221    // IPNF-04: allocator_id!=0, matching next-hop — bytes change because
222    // 3-element [2, [1, 1, 1]] is rewritten to legacy 2-element.
223    #[tokio::test]
224    async fn test_alloc1_matching() {
225        let filter = make_filter(&["ipn:*.*"]);
226        let (bundle, data) = make_bundle("ipn:1.1.1", "ipn:1.2.1", Some("ipn:0.3.0"));
227
228        let result = filter.filter(&bundle, &data).await.unwrap();
229        let RewriteResult::Continue(None, Some(new_data)) = result else {
230            panic!("Expected rewrite path, got {result:?}");
231        };
232
233        // Wire format should change: 3-element → 2-element encoding
234        assert_ne!(
235            data,
236            new_data.as_ref(),
237            "allocator_id!=0: 3-element should be rewritten to 2-element"
238        );
239
240        // Verify the output is a valid bundle with legacy EIDs
241        let parsed =
242            hardy_bpv7::bundle::ParsedBundle::parse(&new_data, hardy_bpv7::bpsec::no_keys).unwrap();
243
244        assert!(
245            matches!(parsed.bundle.id.source, Eid::LegacyIpn { .. }),
246            "Source should be LegacyIpn, got {:?}",
247            parsed.bundle.id.source
248        );
249        assert!(
250            matches!(parsed.bundle.destination, Eid::LegacyIpn { .. }),
251            "Destination should be LegacyIpn, got {:?}",
252            parsed.bundle.destination
253        );
254    }
255}