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::{Ctx, HttpResponse, PluginStep, RequestPluginResult};
18use pingora::proxy::Session;
19use snafu::Snafu;
20use std::cell::RefCell;
21use std::fmt::Write;
22use std::str::FromStr;
23use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
24use std::time::Duration;
25
26#[derive(Debug, Snafu)]
27pub enum Error {
28    #[snafu(display("Plugin {category} invalid, message: {message}"))]
29    Invalid { category: String, message: String },
30    #[snafu(display("Plugin {category} not found"))]
31    NotFound { category: String },
32    #[snafu(display("Plugin {category}, base64 decode error {source}"))]
33    Base64Decode {
34        category: String,
35        source: base64::DecodeError,
36    },
37    #[snafu(display("Plugin {category}, exceed limit {value}/{max}"))]
38    Exceed {
39        category: String,
40        max: f64,
41        value: f64,
42    },
43    #[snafu(display("Plugin {category}, regex error {source}"))]
44    Regex {
45        category: String,
46        source: Box<fancy_regex::Error>,
47    },
48    #[snafu(display("Plugin {category}, base64 decode error {source}"))]
49    ParseDuration {
50        category: String,
51        source: humantime::DurationError,
52    },
53}
54
55thread_local! {
56    /// The config values of the plugin being built that were not of the
57    /// type their key takes. `None` when no plugin is being built.
58    static WRONG_TYPES: RefCell<Option<Vec<String>>> =
59        const { RefCell::new(None) };
60}
61
62/// Notes that the value of `key` is not `expected`.
63///
64/// The getters below answer a value of the wrong type with the default:
65/// `max = "100"` was a limit of 0, `encodings = ["gzip"]` no encoding at
66/// all, a number in a list of keys a key that is not there. Nothing said
67/// so. They still answer the default, and [`build_plugin`] turns the note
68/// into the error of the plugin that is being built.
69fn note_wrong_type(key: &str, expected: &str, value: &toml::Value) {
70    WRONG_TYPES.with_borrow_mut(|notes| {
71        if let Some(notes) = notes {
72            // The value is what tells a typo apart, but not when it is a
73            // credential: this note ends up in the log and in the
74            // notification of a failed reload.
75            let shown = if pingap_config::is_secret_key(key) {
76                String::new()
77            } else {
78                format!(" {value}")
79            };
80            notes.push(format!(
81                "{key} must be {expected}, got {}{shown}",
82                value.type_str()
83            ));
84        }
85    });
86}
87
88/// The error [`build_plugin`] is going to give for the values of the wrong
89/// type noted so far, for a constructor that has something to hold back
90/// until its configuration is known to be good.
91pub(crate) fn wrong_types_error(category: &str) -> Option<Error> {
92    WRONG_TYPES.with_borrow(|notes| {
93        let notes = notes.as_ref().filter(|notes| !notes.is_empty())?;
94        Some(Error::Invalid {
95            category: category.to_string(),
96            message: notes.join(", "),
97        })
98    })
99}
100
101/// Builds one plugin with `build`, and fails when a config value it read
102/// was not of the type its key takes.
103///
104/// Building a plugin is one synchronous call, which is what lets the notes
105/// be kept per thread instead of being handed through every getter.
106pub(crate) fn build_plugin<T>(
107    category: &str,
108    build: impl FnOnce() -> Result<T, Error>,
109) -> Result<T, Error> {
110    /// Puts back what was there, on a panic too.
111    struct Restore(Option<Vec<String>>);
112    impl Drop for Restore {
113        fn drop(&mut self) {
114            WRONG_TYPES.set(self.0.take());
115        }
116    }
117    let restore = Restore(WRONG_TYPES.replace(Some(vec![])));
118    let result = build();
119    let notes = WRONG_TYPES.take().unwrap_or_default();
120    drop(restore);
121    // The plugin's own complaint comes first: it is the more specific.
122    let plugin = result?;
123    if notes.is_empty() {
124        return Ok(plugin);
125    }
126    Err(Error::Invalid {
127        category: category.to_string(),
128        message: notes.join(", "),
129    })
130}
131
132/// Helper functions for accessing plugin configuration values
133pub fn get_str_conf(value: &PluginConf, key: &str) -> String {
134    match value.get(key) {
135        Some(toml::Value::String(item)) => item.clone(),
136        Some(other) => {
137            note_wrong_type(key, "a string", other);
138            String::new()
139        },
140        None => String::new(),
141    }
142}
143
144/// Helper functions for accessing plugin configuration values
145pub fn get_duration_conf(value: &PluginConf, key: &str) -> Option<Duration> {
146    let item = value.get(key)?;
147    let duration = item.as_str().and_then(|s| parse_duration(s).ok());
148    if duration.is_none() {
149        note_wrong_type(key, "a duration such as \"10s\"", item);
150    }
151    duration
152}
153
154/// The name one plugin instance keeps something of a request under: its
155/// response body handler, or the value it hands to a later step
156/// (`Ctx::set_plugin_value`).
157///
158/// The handlers of a request are kept by name, and the name used to be the
159/// plugin's category. Two plugins of one category on a location then shared
160/// a single slot: the second replaced the handler of the first, and both ran
161/// the one that was left, so its rules were applied twice and the other's
162/// not at all.
163pub fn new_body_handler_id(prefix: &str) -> String {
164    static NEXT_ID: AtomicU64 = AtomicU64::new(0);
165    format!("{prefix}{}", NEXT_ID.fetch_add(1, Ordering::Relaxed))
166}
167
168/// A list of strings. One string on its own is a list of one: that is how
169/// a single value reads in KDL (`ip_list "1.2.3.4"`), and it used to count
170/// as no list at all, which left an allow or deny list empty without a
171/// word.
172pub fn get_str_slice_conf(value: &PluginConf, key: &str) -> Vec<String> {
173    match value.get(key) {
174        Some(toml::Value::Array(arr)) => arr
175            .iter()
176            .filter_map(|item| {
177                let text = item.as_str();
178                if text.is_none() {
179                    note_wrong_type(key, "a list of strings", item);
180                }
181                text
182            })
183            .map(String::from) // same as .map(|s| s.to_string())
184            .collect(),
185        // An empty string is an empty list, not a list of one empty item.
186        Some(toml::Value::String(item)) => {
187            if item.is_empty() {
188                vec![]
189            } else {
190                vec![item.clone()]
191            }
192        },
193        Some(other) => {
194            note_wrong_type(key, "a list of strings", other);
195            vec![]
196        },
197        None => vec![],
198    }
199}
200
201pub(crate) use pingap_core::decode_query_value;
202
203pub(crate) fn get_bool_conf(value: &PluginConf, key: &str) -> bool {
204    match value.get(key) {
205        Some(toml::Value::Boolean(item)) => *item,
206        Some(other) => {
207            note_wrong_type(key, "true or false", other);
208            false
209        },
210        None => false,
211    }
212}
213
214pub fn get_int_conf(value: &PluginConf, key: &str) -> i64 {
215    get_int_conf_or_default(value, key, 0)
216}
217
218pub fn get_int_conf_or_default(
219    value: &PluginConf,
220    key: &str,
221    default_value: i64,
222) -> i64 {
223    match value.get(key) {
224        Some(toml::Value::Integer(item)) => *item,
225        Some(other) => {
226            note_wrong_type(key, "an integer", other);
227            default_value
228        },
229        None => default_value,
230    }
231}
232
233pub fn get_step_conf(
234    value: &PluginConf,
235    default_value: PluginStep,
236) -> PluginStep {
237    value
238        .get("step")
239        .and_then(|v| v.as_str())
240        .and_then(|s| PluginStep::from_str(s).ok())
241        .unwrap_or(default_value)
242}
243
244/// Resolves `step`, rejecting a value the plugin does not implement.
245///
246/// `get_step_conf` falls back to the default for an unknown or unsupported
247/// value, which turns a misconfigured `step` into a plugin that quietly never
248/// runs. Plugins that implement only some of the steps should use this so the
249/// mistake surfaces at `pingap -t` instead.
250pub fn get_step_conf_in(
251    value: &PluginConf,
252    category: &str,
253    default_value: PluginStep,
254    allowed: &[PluginStep],
255) -> Result<PluginStep, Error> {
256    let Some(step) = value.get("step").and_then(|v| v.as_str()) else {
257        return Ok(default_value);
258    };
259    let invalid = || Error::Invalid {
260        category: category.to_string(),
261        message: format!(
262            "Invalid step({step}), expect one of: {}",
263            allowed
264                .iter()
265                .map(|item| item.to_string())
266                .collect::<Vec<_>>()
267                .join(", ")
268        ),
269    };
270    let step = PluginStep::from_str(step).map_err(|_| invalid())?;
271    if !allowed.contains(&step) {
272        return Err(invalid());
273    }
274    Ok(step)
275}
276
277/// Whether a restriction list is a whitelist or a blacklist.
278///
279/// Parsed rather than compared literally: `type` used to be tested against the
280/// string `deny`, so every other spelling — including `Deny` — silently selected
281/// allow mode and inverted the policy.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
283pub(crate) enum RestrictionCategory {
284    #[default]
285    Allow,
286    Deny,
287}
288
289impl RestrictionCategory {
290    /// Given whether the request matched the configured list, returns whether
291    /// it is allowed through.
292    #[inline]
293    pub(crate) fn allows(&self, found: bool) -> bool {
294        match self {
295            Self::Allow => found,
296            Self::Deny => !found,
297        }
298    }
299}
300
301/// Parses the `type` of a restriction plugin. Absent means `allow`, which is
302/// the documented default; anything that is not `allow` or `deny` is rejected.
303pub(crate) fn get_restriction_category_conf(
304    value: &PluginConf,
305    category: &str,
306) -> Result<RestrictionCategory, Error> {
307    match get_str_conf(value, "type").to_lowercase().as_str() {
308        "" | "allow" => Ok(RestrictionCategory::Allow),
309        "deny" => Ok(RestrictionCategory::Deny),
310        other => Err(Error::Invalid {
311            category: category.to_string(),
312            message: format!("Invalid type({other}), expect allow or deny"),
313        }),
314    }
315}
316
317/// The address a refusal is logged with: the one the plugins that go by
318/// an address decide by. Through trusted proxies that is the client's,
319/// without them the peer's own; `X-Forwarded-For` is then whatever the
320/// client wrote in it.
321fn verified_address(session: &Session, ctx: &Ctx) -> String {
322    if pingap_core::has_trusted_proxies() {
323        return ctx
324            .conn
325            .client_ip
326            .clone()
327            .unwrap_or_else(|| pingap_core::get_client_ip(session));
328    }
329    ctx.conn.remote_addr.clone().unwrap_or_default()
330}
331
332/// How many lines of one kind a plugin logs in a second of the clock: the
333/// refusals of a dry run, the answers a mirror gave otherwise. The others
334/// are counted, and the count goes with its next line: a rule that would
335/// turn away a flood does not log one.
336const LINES_PER_SECOND: u32 = 10;
337
338/// What keeps the lines of one kind a plugin logs to [`LINES_PER_SECOND`].
339#[derive(Default)]
340pub(crate) struct LineBudget {
341    /// The second whose lines `logged` counts.
342    second: AtomicU64,
343    logged: AtomicU32,
344    /// The lines not logged since the last one that was.
345    skipped: AtomicU64,
346}
347
348impl LineBudget {
349    /// Whether a line in the second `now` is logged, and with the number
350    /// of those before it that were not. Under contention a second may
351    /// get a line or two more than its share.
352    pub(crate) fn take_line(&self, now: u64) -> Option<u64> {
353        let second = self.second.load(Ordering::Relaxed);
354        if second != now
355            && self
356                .second
357                .compare_exchange(
358                    second,
359                    now,
360                    Ordering::Relaxed,
361                    Ordering::Relaxed,
362                )
363                .is_ok()
364        {
365            self.logged.store(0, Ordering::Relaxed);
366        }
367        if self.logged.fetch_add(1, Ordering::Relaxed) < LINES_PER_SECOND {
368            return Some(self.skipped.swap(0, Ordering::Relaxed));
369        }
370        self.skipped.fetch_add(1, Ordering::Relaxed);
371        None
372    }
373}
374
375/// The `dry_run` of a plugin that turns requests away.
376#[derive(Default)]
377pub(crate) struct DryRun {
378    on: bool,
379    lines: LineBudget,
380}
381
382impl DryRun {
383    pub(crate) fn new(value: &PluginConf) -> Self {
384        Self {
385            on: get_bool_conf(value, "dry_run"),
386            ..Default::default()
387        }
388    }
389
390    pub(crate) fn is_on(&self) -> bool {
391        self.on
392    }
393
394    /// What the plugin does with a request it would turn away: answers it
395    /// with `response`, or - in a dry run - lets it through and logs what
396    /// it would have answered, with `matched`, what of the request it went
397    /// by. A new rule can be watched on real traffic that way before it
398    /// refuses anyone. `matched` is only worked out for a line that is
399    /// logged.
400    pub(crate) fn refuse(
401        &self,
402        category: &str,
403        session: &Session,
404        ctx: &mut Ctx,
405        response: HttpResponse,
406        matched: impl FnOnce(&mut Ctx) -> String,
407    ) -> RequestPluginResult {
408        if !self.on {
409            return RequestPluginResult::Respond(response);
410        }
411        let Some(skipped) = self.lines.take_line(pingap_core::now_sec()) else {
412            return RequestPluginResult::Continue;
413        };
414        let matched = matched(ctx);
415        let header = session.req_header();
416        tracing::warn!(
417            category,
418            location = &*ctx.upstream.location,
419            status = response.status.as_u16(),
420            reason = %String::from_utf8_lossy(&response.body),
421            matched,
422            client_ip = verified_address(session, ctx),
423            method = header.method.as_str(),
424            uri = %header.uri,
425            request_id = ctx.state.request_id.as_deref().unwrap_or_default(),
426            skipped,
427            "dry run, the request would have been refused"
428        );
429        RequestPluginResult::Continue
430    }
431}
432
433/// What the plugin `conf` describes has that the plugin does not read: a
434/// setting that has no effect, by the plugin's own account. For the check
435/// of a configuration, which says it once.
436pub fn unread_keys(conf: &PluginConf) -> Vec<String> {
437    match get_str_conf(conf, "category").as_str() {
438        "limit" => limit::unread_keys(conf),
439        _ => vec![],
440    }
441}
442
443/// Whether the plugin `conf` describes goes by the JA4 fingerprint of the
444/// connection, which only a server with `ja4 = true` collects.
445pub fn needs_ja4(conf: &PluginConf) -> bool {
446    match get_str_conf(conf, "category").as_str() {
447        "ja4_restriction" => true,
448        "limit" => {
449            matches!(get_str_conf(conf, "tag").as_str(), "ja4" | "ip_ja4")
450        },
451        _ => false,
452    }
453}
454
455/// Whether the response is a part of a body and not the body: a `206`, or
456/// anything that says which bytes it is.
457///
458/// A plugin that rewrites the body - compresses it, substitutes in it,
459/// encodes it anew - has nothing to do with a part. What it would make of
460/// one is no part of anything: the hundred bytes asked for come back as
461/// the gzip of those hundred bytes, under a `Content-Range` that still
462/// counts in the bytes of the original.
463pub fn is_partial_content(resp: &pingora::http::ResponseHeader) -> bool {
464    resp.status == http::StatusCode::PARTIAL_CONTENT
465        || resp.headers.contains_key(http::header::CONTENT_RANGE)
466}
467
468/// Takes back what a response can no longer say of itself once its body
469/// is going to be another: that ranges of it can be asked for, and that
470/// its `ETag` names these bytes.
471///
472/// The upstream's `Accept-Ranges` and strong `ETag` are about the body it
473/// sent. Left on the rewritten one, a client resumed a download with a
474/// range of the original, or took two different bodies for the same bytes.
475pub fn body_will_change(resp: &mut pingora::http::ResponseHeader) {
476    resp.remove_header(&http::header::ACCEPT_RANGES);
477    weaken_etag(resp);
478}
479
480/// Makes a strong `ETag` a weak one, `true` when it changed the header.
481///
482/// The validator is weakened rather than removed, as nginx and pingora's
483/// own compression do it: it still tells whether the resource changed.
484/// One that is not a quoted string is no validator, and is removed.
485pub fn weaken_etag(resp: &mut pingora::http::ResponseHeader) -> bool {
486    let Some(etag) = resp.headers.get(http::header::ETAG) else {
487        return false;
488    };
489    let value = etag.as_bytes();
490    if value.starts_with(b"W/") {
491        return false;
492    }
493    let weak = value
494        .starts_with(b"\"")
495        .then(|| [b"W/", value].concat())
496        .and_then(|weak| http::HeaderValue::from_bytes(&weak).ok());
497    match weak {
498        Some(weak) => {
499            let _ = resp.insert_header(http::header::ETAG, weak);
500        },
501        None => {
502            resp.remove_header(&http::header::ETAG);
503        },
504    }
505    true
506}
507
508/// Returns true if `accept_encoding` lists `coding` as an acceptable encoding.
509///
510/// Matches on comma/`;`-delimited token boundaries (so `x-gzip` does not match
511/// `gzip`) and treats an explicit `q=0` as "not acceptable". Shared by the
512/// `accept_encoding` and `compression` plugins so the two cannot disagree about
513/// what the client accepts.
514pub fn accepts_encoding(accept_encoding: &str, coding: &str) -> bool {
515    accept_encoding.split(',').any(|part| {
516        let mut segments = part.split(';');
517        let name = segments.next().unwrap_or_default().trim();
518        if !name.eq_ignore_ascii_case(coding) {
519            return false;
520        }
521        // Acceptable unless the token is explicitly weighted q=0.
522        !segments.any(|seg| {
523            let seg = seg.trim();
524            seg.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("q="))
525                && seg[2..].trim().parse::<f32>().is_ok_and(|q| q <= 0.0)
526        })
527    })
528}
529
530/// Generates a unique hash key for a plugin configuration to detect changes.
531///
532/// # Arguments
533/// * `conf` - The plugin configuration to hash
534///
535/// # Returns
536/// A string containing the CRC32 hash of the sorted configuration key-value pairs
537pub fn get_hash_key(conf: &PluginConf) -> String {
538    let mut items: Vec<_> = conf.iter().collect();
539    // sort by key
540    items.sort_unstable_by_key(|(k, _)| *k);
541
542    // pre-allocate capacity to reduce subsequent memory reallocation.
543    let mut buf = String::with_capacity(256);
544    for (i, (key, value)) in items.iter().enumerate() {
545        if i > 0 {
546            buf.push('\n');
547        }
548        // use write! macro to write the formatted string directly into the buffer, avoid format! to produce temporary String.
549        // because writing to String will not fail, so it can be safely.
550        let _ = write!(&mut buf, "{key}:{value}");
551    }
552
553    let hash = crc32fast::hash(buf.as_bytes());
554    format!("{hash:X}")
555}
556
557/// Registers a plugin with the global plugin factory inside a pre-main
558/// constructor. Collapses the identical `#[ctor(unsafe)] fn init()` block
559/// that every plugin module would otherwise repeat.
560macro_rules! register_plugin {
561    ($category:literal, $ty:ty) => {
562        #[::ctor::ctor(unsafe)]
563        fn init() {
564            $crate::get_plugin_factory().register($category, |params| {
565                Ok(::std::sync::Arc::new(<$ty>::new(params)?))
566            });
567        }
568    };
569}
570
571mod accept_encoding;
572mod bandwidth_limit;
573mod basic_auth;
574mod cache;
575mod combined_auth;
576mod compression;
577mod cors;
578mod csrf;
579mod directory;
580mod error_page;
581mod forward_auth;
582#[cfg(feature = "geo")]
583mod geo_restriction;
584mod hmac_auth;
585mod ip_restriction;
586mod ja4_restriction;
587mod jwt;
588mod key_auth;
589mod limit;
590mod maintenance;
591mod mirror;
592mod mock;
593mod oidc;
594mod ping;
595mod redirect;
596mod referer_restriction;
597mod request_headers;
598mod request_id;
599mod response_headers;
600mod sub_filter;
601mod traffic_splitting;
602mod ua_restriction;
603mod uri_block;
604
605mod plugin;
606
607pub use plugin::get_plugin_factory;
608
609#[cfg(test)]
610mod tests {
611    use super::{accepts_encoding, get_plugin_factory, get_str_slice_conf};
612    use pingap_config::PluginConf;
613    use pingap_core::{Ctx, PluginStep, RequestPluginResult};
614    use pingora::proxy::Session;
615    use pretty_assertions::assert_eq;
616
617    /// Regression: the address a dry run logs is the one the decision is
618    /// made by. It was the first of `X-Forwarded-For`, which without
619    /// trusted proxies is whatever the client wrote.
620    #[tokio::test]
621    async fn test_dry_run_logs_the_verified_address() {
622        let input =
623            b"GET / HTTP/1.1\r\nHost: a\r\nX-Forwarded-For: 8.8.8.8\r\n\r\n";
624        let mock_io = tokio_test::io::Builder::new().read(input).build();
625        let mut session = Session::new_h1(Box::new(mock_io));
626        session.read_request().await.unwrap();
627        let mut ctx = Ctx::default();
628        ctx.conn.remote_addr = Some("1.1.1.1".to_string());
629        assert_eq!("1.1.1.1", super::verified_address(&session, &ctx));
630    }
631
632    #[test]
633    fn test_needs_ja4() {
634        let needs = |conf: &str| {
635            super::needs_ja4(&toml::from_str::<PluginConf>(conf).unwrap())
636        };
637        assert_eq!(true, needs("category = \"ja4_restriction\""));
638        assert_eq!(true, needs("category = \"limit\"\ntag = \"ja4\""));
639        assert_eq!(true, needs("category = \"limit\"\ntag = \"ip_ja4\""));
640        assert_eq!(false, needs("category = \"limit\""));
641        assert_eq!(false, needs("category = \"limit\"\ntag = \"header\""));
642        assert_eq!(false, needs("category = \"ip_restriction\""));
643    }
644
645    /// Ten lines a second at most, and the next line that is logged says
646    /// how many were not.
647    #[test]
648    fn test_lines_per_second() {
649        let dry_run = super::DryRun::new(
650            &toml::from_str::<PluginConf>("dry_run = true").unwrap(),
651        );
652        assert_eq!(true, dry_run.is_on());
653        let budget = super::LineBudget::default();
654        let lines =
655            |now| (0..15).map(|_| budget.take_line(now)).collect::<Vec<_>>();
656        let mut expected = vec![Some(0); 10];
657        expected.extend([None; 5]);
658        assert_eq!(expected, lines(100));
659        // the next second: its first line carries the five
660        expected[0] = Some(5);
661        assert_eq!(expected, lines(101));
662        assert_eq!(Some(5), budget.take_line(102));
663        assert_eq!(Some(0), budget.take_line(102));
664    }
665
666    /// Every plugin that turns requests away has a dry run: the request
667    /// it refuses without one goes on with one.
668    #[tokio::test]
669    async fn test_dry_run_lets_the_request_through() {
670        let mut cases = vec![
671            (
672                "category = \"ip_restriction\"\nip_list = [\"1.1.1.1\"]\ntype = \"deny\"",
673                "",
674            ),
675            (
676                "category = \"ua_restriction\"\nua_list = [\"^curl\"]\ntype = \"deny\"",
677                "User-Agent: curl/8.0\r\n",
678            ),
679            (
680                "category = \"referer_restriction\"\nreferer_list = [\"bad.example\"]\ntype = \"deny\"",
681                "Referer: https://bad.example/page\r\n",
682            ),
683            ("category = \"uri_block\"\npaths = [\"^/admin\"]", ""),
684            ("category = \"limit\"\nmax = 0", ""),
685            // an allow list, and a plain http request: no fingerprint
686            ("category = \"ja4_restriction\"\nja4_list = [\"^t13\"]", ""),
687        ];
688        if cfg!(feature = "geo") {
689            cases.push((
690                "category = \"geo_restriction\"\ncountry_codes = [\"CN\"]\ntype = \"allow\"",
691                "",
692            ));
693        }
694        for (conf, headers) in cases {
695            for dry_run in [false, true] {
696                let conf = format!("{conf}\ndry_run = {dry_run}");
697                let plugin = get_plugin_factory()
698                    .create(&toml::from_str::<PluginConf>(&conf).unwrap())
699                    .unwrap();
700                let input = format!(
701                    "GET /admin/users HTTP/1.1\r\nHost: example.com\r\n{headers}\r\n"
702                );
703                let mock_io = tokio_test::io::Builder::new()
704                    .read(input.as_bytes())
705                    .build();
706                let mut session = Session::new_h1(Box::new(mock_io));
707                session.read_request().await.unwrap();
708                let mut ctx = Ctx::default();
709                ctx.conn.remote_addr = Some("1.1.1.1".to_string());
710                let result = plugin
711                    .handle_request(PluginStep::Request, &mut session, &mut ctx)
712                    .await
713                    .unwrap();
714                assert_eq!(
715                    dry_run,
716                    result == RequestPluginResult::Continue,
717                    "{conf}"
718                );
719            }
720        }
721    }
722
723    /// Regression: a value of the wrong type was read as the default, and
724    /// the plugin ran with a limit of 0, an empty list or a flag left off.
725    #[test]
726    fn test_body_will_change() {
727        use pingora::http::ResponseHeader;
728        let changed = |etag: Option<&str>| {
729            let mut resp = ResponseHeader::build(200, None).unwrap();
730            resp.append_header("Accept-Ranges", "bytes").unwrap();
731            if let Some(etag) = etag {
732                resp.append_header("ETag", etag).unwrap();
733            }
734            super::body_will_change(&mut resp);
735            assert_eq!(false, resp.headers.contains_key("Accept-Ranges"));
736            resp.headers
737                .get("ETag")
738                .map(|value| value.to_str().unwrap().to_string())
739        };
740        assert_eq!(Some("W/\"v1\"".to_string()), changed(Some("\"v1\"")));
741        assert_eq!(Some("W/\"v1\"".to_string()), changed(Some("W/\"v1\"")));
742        // Not a validator to begin with.
743        assert_eq!(None, changed(Some("v1")));
744        assert_eq!(None, changed(None));
745
746        let mut part = ResponseHeader::build(206, None).unwrap();
747        assert_eq!(true, super::is_partial_content(&part));
748        part.set_status(200).unwrap();
749        assert_eq!(false, super::is_partial_content(&part));
750        part.append_header("Content-Range", "bytes 0-9/100")
751            .unwrap();
752        assert_eq!(true, super::is_partial_content(&part));
753    }
754
755    #[test]
756    fn test_wrong_typed_values_are_rejected() {
757        let create = |conf: &str| {
758            get_plugin_factory()
759                .create(&toml::from_str::<PluginConf>(conf).unwrap())
760                .map(|_| ())
761                .map_err(|e| e.to_string())
762        };
763        let limit = "category = \"limit\"\ntag = \"ip\"\n";
764        let key_auth = "category = \"key_auth\"\nheader = \"X-Key\"\n";
765        for (conf, expected) in [
766            (
767                format!("{limit}max = \"100\""),
768                "Plugin limit invalid, message: max must be an integer, got string \"100\"",
769            ),
770            (
771                format!("{limit}max = 10\ninterval = 10"),
772                "interval must be a string, got integer 10",
773            ),
774            // The value of what is a credential is not repeated: this
775            // message goes to the log and to the webhook.
776            (
777                format!("{key_auth}keys = [\"a\", 123]"),
778                "keys must be a list of strings, got integer",
779            ),
780            (
781                format!("{key_auth}keys = [\"a\"]\nhide_credentials = \"true\""),
782                "hide_credentials must be true or false, got string \"true\"",
783            ),
784            (
785                "category = \"forward_auth\"\nauth_url = \"http://127.0.0.1/\"\ntimeout = \"10\"".to_string(),
786                "timeout must be a duration such as \"10s\", got string \"10\"",
787            ),
788        ] {
789            let err = create(&conf).unwrap_err();
790            assert_eq!(true, err.contains(expected), "{conf}: {err}");
791        }
792        // The plugin's own error is the one reported when it has one.
793        let err =
794            create(&format!("{limit}max = -1\ninterval = 10")).unwrap_err();
795        assert_eq!(true, err.contains("max must not be negative"), "{err}");
796
797        assert_eq!(Ok(()), create(&format!("{limit}max = 100")));
798        assert_eq!(Ok(()), create(&format!("{key_auth}keys = \"a\"")));
799        // Nothing is left over for the plugin built next on this thread.
800        assert_eq!(Ok(()), create(&format!("{limit}max = 100")));
801    }
802
803    /// Outside the factory the getters answer the default, as they did.
804    #[test]
805    fn test_getters_default_outside_the_factory() {
806        let conf: PluginConf =
807            toml::from_str("max = \"100\"\nkeys = 1\nflag = \"true\"").unwrap();
808        assert_eq!(0, super::get_int_conf(&conf, "max"));
809        assert_eq!(true, get_str_slice_conf(&conf, "keys").is_empty());
810        assert_eq!(false, super::get_bool_conf(&conf, "flag"));
811        assert_eq!("", super::get_str_conf(&conf, "keys"));
812        assert_eq!(None, super::get_duration_conf(&conf, "max"));
813    }
814
815    #[test]
816    fn test_get_str_slice_conf() {
817        let conf: PluginConf = toml::from_str(
818            r#"
819list = ["a", "b"]
820one = ["a"]
821bare = "a"
822empty = []
823blank = ""
824number = 1
825"#,
826        )
827        .unwrap();
828        assert_eq!(vec!["a", "b"], get_str_slice_conf(&conf, "list"));
829        assert_eq!(vec!["a"], get_str_slice_conf(&conf, "one"));
830        // One value written without the brackets, as KDL has it.
831        assert_eq!(vec!["a"], get_str_slice_conf(&conf, "bare"));
832        for key in ["empty", "blank", "number", "missing"] {
833            assert_eq!(
834                true,
835                get_str_slice_conf(&conf, key).is_empty(),
836                "{key}"
837            );
838        }
839    }
840
841    #[test]
842    fn test_accepts_encoding() {
843        assert_eq!(true, accepts_encoding("gzip, br", "br"));
844        assert_eq!(true, accepts_encoding("gzip, deflate, br;q=0.9", "br"));
845        assert_eq!(true, accepts_encoding("BR", "br"));
846        // Substring false-matches must be rejected.
847        assert_eq!(false, accepts_encoding("x-gzip", "gzip"));
848        assert_eq!(false, accepts_encoding("gzipx", "gzip"));
849        // Explicit q=0 means "not acceptable".
850        assert_eq!(false, accepts_encoding("br;q=0", "br"));
851    }
852}