Skip to main content

pingap_plugin/
lib.rs

1// Copyright 2024-2025 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use humantime::parse_duration;
16use pingap_config::PluginConf;
17use pingap_core::PluginStep;
18use snafu::Snafu;
19use std::fmt::Write;
20use std::str::FromStr;
21use std::time::Duration;
22
23#[derive(Debug, Snafu)]
24pub enum Error {
25    #[snafu(display("Plugin {category} invalid, message: {message}"))]
26    Invalid { category: String, message: String },
27    #[snafu(display("Plugin {category} not found"))]
28    NotFound { category: String },
29    #[snafu(display("Plugin {category}, base64 decode error {source}"))]
30    Base64Decode {
31        category: String,
32        source: base64::DecodeError,
33    },
34    #[snafu(display("Plugin {category}, exceed limit {value}/{max}"))]
35    Exceed {
36        category: String,
37        max: f64,
38        value: f64,
39    },
40    #[snafu(display("Plugin {category}, regex error {source}"))]
41    Regex {
42        category: String,
43        source: Box<fancy_regex::Error>,
44    },
45    #[snafu(display("Plugin {category}, base64 decode error {source}"))]
46    ParseDuration {
47        category: String,
48        source: humantime::DurationError,
49    },
50}
51
52/// Helper functions for accessing plugin configuration values
53pub fn get_str_conf(value: &PluginConf, key: &str) -> String {
54    value
55        .get(key)
56        .and_then(|v| v.as_str())
57        .unwrap_or("")
58        .to_string()
59}
60
61/// Helper functions for accessing plugin configuration values
62pub fn get_duration_conf(value: &PluginConf, key: &str) -> Option<Duration> {
63    value
64        .get(key)
65        .and_then(|v| v.as_str())
66        .and_then(|s| parse_duration(s).ok())
67}
68
69pub fn get_str_slice_conf(value: &PluginConf, key: &str) -> Vec<String> {
70    value
71        .get(key)
72        .and_then(|v| v.as_array())
73        .map(|arr| {
74            arr.iter()
75                .filter_map(|item| item.as_str())
76                .map(String::from) // same as .map(|s| s.to_string())
77                .collect()
78        })
79        .unwrap_or_default()
80}
81
82pub(crate) fn get_bool_conf(value: &PluginConf, key: &str) -> bool {
83    value.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
84}
85
86pub fn get_int_conf(value: &PluginConf, key: &str) -> i64 {
87    get_int_conf_or_default(value, key, 0)
88}
89
90pub fn get_int_conf_or_default(
91    value: &PluginConf,
92    key: &str,
93    default_value: i64,
94) -> i64 {
95    value
96        .get(key)
97        .and_then(|v| v.as_integer()) // assume PluginConf value can be converted to i64
98        .unwrap_or(default_value)
99}
100
101pub fn get_step_conf(
102    value: &PluginConf,
103    default_value: PluginStep,
104) -> PluginStep {
105    value
106        .get("step")
107        .and_then(|v| v.as_str())
108        .and_then(|s| PluginStep::from_str(s).ok())
109        .unwrap_or(default_value)
110}
111
112/// Resolves `step`, rejecting a value the plugin does not implement.
113///
114/// `get_step_conf` falls back to the default for an unknown or unsupported
115/// value, which turns a misconfigured `step` into a plugin that quietly never
116/// runs. Plugins that implement only some of the steps should use this so the
117/// mistake surfaces at `pingap -t` instead.
118pub fn get_step_conf_in(
119    value: &PluginConf,
120    category: &str,
121    default_value: PluginStep,
122    allowed: &[PluginStep],
123) -> Result<PluginStep, Error> {
124    let Some(step) = value.get("step").and_then(|v| v.as_str()) else {
125        return Ok(default_value);
126    };
127    let invalid = || Error::Invalid {
128        category: category.to_string(),
129        message: format!(
130            "Invalid step({step}), expect one of: {}",
131            allowed
132                .iter()
133                .map(|item| item.to_string())
134                .collect::<Vec<_>>()
135                .join(", ")
136        ),
137    };
138    let step = PluginStep::from_str(step).map_err(|_| invalid())?;
139    if !allowed.contains(&step) {
140        return Err(invalid());
141    }
142    Ok(step)
143}
144
145/// Whether a restriction list is a whitelist or a blacklist.
146///
147/// Parsed rather than compared literally: `type` used to be tested against the
148/// string `deny`, so every other spelling — including `Deny` — silently selected
149/// allow mode and inverted the policy.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
151pub(crate) enum RestrictionCategory {
152    #[default]
153    Allow,
154    Deny,
155}
156
157impl RestrictionCategory {
158    /// Given whether the request matched the configured list, returns whether
159    /// it is allowed through.
160    #[inline]
161    pub(crate) fn allows(&self, found: bool) -> bool {
162        match self {
163            Self::Allow => found,
164            Self::Deny => !found,
165        }
166    }
167}
168
169/// Parses the `type` of a restriction plugin. Absent means `allow`, which is
170/// the documented default; anything that is not `allow` or `deny` is rejected.
171pub(crate) fn get_restriction_category_conf(
172    value: &PluginConf,
173    category: &str,
174) -> Result<RestrictionCategory, Error> {
175    match get_str_conf(value, "type").to_lowercase().as_str() {
176        "" | "allow" => Ok(RestrictionCategory::Allow),
177        "deny" => Ok(RestrictionCategory::Deny),
178        other => Err(Error::Invalid {
179            category: category.to_string(),
180            message: format!("Invalid type({other}), expect allow or deny"),
181        }),
182    }
183}
184
185/// Returns true if `accept_encoding` lists `coding` as an acceptable encoding.
186///
187/// Matches on comma/`;`-delimited token boundaries (so `x-gzip` does not match
188/// `gzip`) and treats an explicit `q=0` as "not acceptable". Shared by the
189/// `accept_encoding` and `compression` plugins so the two cannot disagree about
190/// what the client accepts.
191pub(crate) fn accepts_encoding(accept_encoding: &str, coding: &str) -> bool {
192    accept_encoding.split(',').any(|part| {
193        let mut segments = part.split(';');
194        let name = segments.next().unwrap_or_default().trim();
195        if !name.eq_ignore_ascii_case(coding) {
196            return false;
197        }
198        // Acceptable unless the token is explicitly weighted q=0.
199        !segments.any(|seg| {
200            let seg = seg.trim();
201            seg.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("q="))
202                && seg[2..].trim().parse::<f32>().is_ok_and(|q| q <= 0.0)
203        })
204    })
205}
206
207/// Generates a unique hash key for a plugin configuration to detect changes.
208///
209/// # Arguments
210/// * `conf` - The plugin configuration to hash
211///
212/// # Returns
213/// A string containing the CRC32 hash of the sorted configuration key-value pairs
214pub fn get_hash_key(conf: &PluginConf) -> String {
215    let mut items: Vec<_> = conf.iter().collect();
216    // sort by key
217    items.sort_unstable_by_key(|(k, _)| *k);
218
219    // pre-allocate capacity to reduce subsequent memory reallocation.
220    let mut buf = String::with_capacity(256);
221    for (i, (key, value)) in items.iter().enumerate() {
222        if i > 0 {
223            buf.push('\n');
224        }
225        // use write! macro to write the formatted string directly into the buffer, avoid format! to produce temporary String.
226        // because writing to String will not fail, so it can be safely.
227        let _ = write!(&mut buf, "{key}:{value}");
228    }
229
230    let hash = crc32fast::hash(buf.as_bytes());
231    format!("{hash:X}")
232}
233
234/// Registers a plugin with the global plugin factory inside a pre-main
235/// constructor. Collapses the identical `#[ctor(unsafe)] fn init()` block
236/// that every plugin module would otherwise repeat.
237macro_rules! register_plugin {
238    ($category:literal, $ty:ty) => {
239        #[::ctor::ctor(unsafe)]
240        fn init() {
241            $crate::get_plugin_factory().register($category, |params| {
242                Ok(::std::sync::Arc::new(<$ty>::new(params)?))
243            });
244        }
245    };
246}
247
248mod accept_encoding;
249mod basic_auth;
250mod cache;
251mod combined_auth;
252mod compression;
253mod cors;
254mod csrf;
255mod directory;
256mod forward_auth;
257#[cfg(feature = "geo")]
258mod geo_restriction;
259mod ip_restriction;
260mod jwt;
261mod key_auth;
262mod limit;
263mod mock;
264mod ping;
265mod redirect;
266mod referer_restriction;
267mod request_id;
268mod response_headers;
269mod sub_filter;
270mod traffic_splitting;
271mod ua_restriction;
272
273mod plugin;
274
275pub use plugin::get_plugin_factory;
276
277#[cfg(test)]
278mod tests {
279    use super::accepts_encoding;
280    use pretty_assertions::assert_eq;
281
282    #[test]
283    fn test_accepts_encoding() {
284        assert_eq!(true, accepts_encoding("gzip, br", "br"));
285        assert_eq!(true, accepts_encoding("gzip, deflate, br;q=0.9", "br"));
286        assert_eq!(true, accepts_encoding("BR", "br"));
287        // Substring false-matches must be rejected.
288        assert_eq!(false, accepts_encoding("x-gzip", "gzip"));
289        assert_eq!(false, accepts_encoding("gzipx", "gzip"));
290        // Explicit q=0 means "not acceptable".
291        assert_eq!(false, accepts_encoding("br;q=0", "br"));
292    }
293}