fidl_fuchsia_pkg_rewrite_ext/
rule.rs

1// Copyright 2019 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5use crate::errors::{RuleDecodeError, RuleParseError};
6use fidl_fuchsia_pkg_rewrite as fidl;
7use fuchsia_url::{AbsolutePackageUrl, ParseError, RepositoryUrl};
8use serde::{Deserialize, Serialize};
9
10/// A `Rule` can be used to re-write parts of a [`AbsolutePackageUrl`].
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub struct Rule {
13    host_match: RepositoryUrl,
14    host_replacement: RepositoryUrl,
15    path_prefix_match: String,
16    path_prefix_replacement: String,
17}
18
19/// Wraper for serializing rewrite rules to the on-disk JSON format.
20#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
21#[serde(tag = "version", content = "content", deny_unknown_fields)]
22pub enum RuleConfig {
23    #[allow(missing_docs)]
24    #[serde(rename = "1")]
25    Version1(Vec<Rule>),
26}
27
28impl Rule {
29    /// Creates a new `Rule`.
30    pub fn new(
31        host_match: impl Into<String>,
32        host_replacement: impl Into<String>,
33        path_prefix_match: impl Into<String>,
34        path_prefix_replacement: impl Into<String>,
35    ) -> Result<Self, RuleParseError> {
36        Self::new_impl(
37            host_match.into(),
38            host_replacement.into(),
39            path_prefix_match.into(),
40            path_prefix_replacement.into(),
41        )
42    }
43
44    fn new_impl(
45        host_match: String,
46        host_replacement: String,
47        path_prefix_match: String,
48        path_prefix_replacement: String,
49    ) -> Result<Self, RuleParseError> {
50        let host_match =
51            RepositoryUrl::parse_host(host_match).map_err(|_| RuleParseError::InvalidHost)?;
52        let host_replacement =
53            RepositoryUrl::parse_host(host_replacement).map_err(|_| RuleParseError::InvalidHost)?;
54
55        if !path_prefix_match.starts_with('/') {
56            return Err(RuleParseError::InvalidPath);
57        }
58        if !path_prefix_replacement.starts_with('/') {
59            return Err(RuleParseError::InvalidPath);
60        }
61
62        // Literal matches should have a literal replacement and prefix matches should have a
63        // prefix replacement.
64        if path_prefix_match.ends_with('/') != path_prefix_replacement.ends_with('/') {
65            return Err(RuleParseError::InconsistentPaths);
66        }
67
68        Ok(Self { host_match, host_replacement, path_prefix_match, path_prefix_replacement })
69    }
70
71    /// The exact hostname to match.
72    pub fn host_match(&self) -> &str {
73        self.host_match.host()
74    }
75
76    /// The new hostname to replace the matched `host_match` with.
77    pub fn host_replacement(&self) -> &str {
78        self.host_replacement.host()
79    }
80
81    /// The absolute path to a package or directory to match against.
82    pub fn path_prefix_match(&self) -> &str {
83        &self.path_prefix_match
84    }
85
86    /// The absolute path to a single package or a directory to replace the
87    /// matched `path_prefix_match` with.
88    pub fn path_prefix_replacement(&self) -> &str {
89        &self.path_prefix_replacement
90    }
91
92    /// Apply this `Rule` to the given [`AbsolutePackageUrl`].
93    ///
94    /// In order for a `Rule` to match a particular fuchsia-pkg:// URI, `host` must match `uri`'s
95    /// host exactly and `path` must prefix match the `uri`'s path at a '/' boundary.  If `path`
96    /// doesn't end in a '/', it must match the entire `uri` path.
97    ///
98    /// When a `Rule` does match the given `uri`, it will replace the matched hostname and path
99    /// with the given replacement strings, preserving the unmatched part of the path, the hash
100    /// query parameter, and any fragment.
101    pub fn apply(
102        &self,
103        uri: &AbsolutePackageUrl,
104    ) -> Option<Result<AbsolutePackageUrl, ParseError>> {
105        if uri.host() != self.host_match.host() {
106            return None;
107        }
108
109        let full_path = uri.path();
110        let new_path = if self.path_prefix_match.ends_with('/') {
111            let rest = full_path.strip_prefix(&self.path_prefix_match)?;
112
113            format!("{}{}", self.path_prefix_replacement, rest)
114        } else {
115            if full_path != self.path_prefix_match {
116                return None;
117            }
118
119            self.path_prefix_replacement.clone()
120        };
121
122        Some(AbsolutePackageUrl::new_with_path(
123            self.host_replacement.clone(),
124            &new_path,
125            uri.hash(),
126        ))
127    }
128}
129
130impl TryFrom<fidl::Rule> for Rule {
131    type Error = RuleDecodeError;
132    fn try_from(rule: fidl::Rule) -> Result<Self, Self::Error> {
133        let rule = match rule {
134            fidl::Rule::Literal(rule) => rule,
135            _ => return Err(RuleDecodeError::UnknownVariant),
136        };
137
138        Ok(Rule::new(
139            rule.host_match,
140            rule.host_replacement,
141            rule.path_prefix_match,
142            rule.path_prefix_replacement,
143        )?)
144    }
145}
146
147impl From<Rule> for fidl::Rule {
148    fn from(rule: Rule) -> Self {
149        fidl::Rule::Literal(fidl::LiteralRule {
150            host_match: rule.host_match.into_host(),
151            host_replacement: rule.host_replacement.into_host(),
152            path_prefix_match: rule.path_prefix_match,
153            path_prefix_replacement: rule.path_prefix_replacement,
154        })
155    }
156}
157
158impl serde::Serialize for Rule {
159    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
160    where
161        S: serde::Serializer,
162    {
163        #[derive(Serialize)]
164        struct TempRule<'a> {
165            host_match: &'a str,
166            host_replacement: &'a str,
167            path_prefix_match: &'a str,
168            path_prefix_replacement: &'a str,
169        }
170
171        TempRule {
172            host_match: self.host_match(),
173            host_replacement: self.host_replacement(),
174            path_prefix_match: &self.path_prefix_match,
175            path_prefix_replacement: &self.path_prefix_replacement,
176        }
177        .serialize(serializer)
178    }
179}
180
181impl<'de> serde::Deserialize<'de> for Rule {
182    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
183    where
184        D: serde::Deserializer<'de>,
185    {
186        #[derive(Deserialize)]
187        struct TempRule {
188            host_match: String,
189            host_replacement: String,
190            path_prefix_match: String,
191            path_prefix_replacement: String,
192        }
193
194        let t = TempRule::deserialize(deserializer)?;
195        Rule::new(t.host_match, t.host_replacement, t.path_prefix_match, t.path_prefix_replacement)
196            .map_err(|e| serde::de::Error::custom(e.to_string()))
197    }
198}
199
200#[cfg(test)]
201mod serde_tests {
202    use super::*;
203
204    use serde_json::json;
205
206    macro_rules! rule {
207        ($host_match:expr => $host_replacement:expr,
208         $path_prefix_match:expr => $path_prefix_replacement:expr) => {
209            Rule::new($host_match, $host_replacement, $path_prefix_match, $path_prefix_replacement)
210                .unwrap()
211        };
212    }
213
214    macro_rules! assert_error_contains {
215        ($err:expr, $text:expr,) => {
216            let error_message = $err.to_string();
217            assert!(
218                error_message.contains($text),
219                r#"error message did not contain "{}", was actually "{}""#,
220                $text,
221                error_message
222            );
223        };
224    }
225
226    #[test]
227    fn test_rejects_malformed_fidl() {
228        let as_fidl = fidl::Rule::Literal(fidl::LiteralRule {
229            host_match: "example.com".to_owned(),
230            host_replacement: "example.com".to_owned(),
231            path_prefix_match: "/test/".to_owned(),
232            path_prefix_replacement: "/test".to_owned(),
233        });
234        assert_eq!(
235            Rule::try_from(as_fidl),
236            Err(RuleDecodeError::ParseError(RuleParseError::InconsistentPaths))
237        );
238
239        let as_fidl = fidl::Rule::Literal(fidl::LiteralRule {
240            host_match: "example.com".to_owned(),
241            host_replacement: "example.com".to_owned(),
242            path_prefix_match: "/test".to_owned(),
243            path_prefix_replacement: "test".to_owned(),
244        });
245        assert_eq!(
246            Rule::try_from(as_fidl),
247            Err(RuleDecodeError::ParseError(RuleParseError::InvalidPath))
248        );
249    }
250
251    #[test]
252    fn test_rejects_unknown_fidl_variant() {
253        let as_fidl = fidl::Rule::unknown_variant_for_testing();
254        assert_eq!(Rule::try_from(as_fidl), Err(RuleDecodeError::UnknownVariant));
255    }
256
257    #[test]
258    fn test_rejects_unknown_json_version() {
259        let json = json!({
260            "version": "9001",
261            "content": "the future",
262        });
263        assert_error_contains!(
264            serde_json::from_str::<RuleConfig>(json.to_string().as_str()).unwrap_err(),
265            "unknown variant",
266        );
267    }
268
269    #[test]
270    fn test_rejects_malformed_json() {
271        let json = json!({
272            "version": "1",
273            "content": [{
274                "host_match":              "example.com",
275                "host_replacement":        "example.com",
276                "path_prefix_match":       "/test/",
277                "path_prefix_replacement": "/test",
278            }]
279        });
280
281        assert_error_contains!(
282            serde_json::from_str::<Rule>(json["content"][0].to_string().as_str()).unwrap_err(),
283            "paths should both be a prefix match or both be a literal match",
284        );
285        assert_error_contains!(
286            serde_json::from_str::<RuleConfig>(json.to_string().as_str()).unwrap_err(),
287            "paths should both be a prefix match or both be a literal match",
288        );
289
290        let json = json!({
291            "version": "1",
292            "content": [{
293                "host_match":              "example.com",
294                "host_replacement":        "example.com",
295                "path_prefix_match":       "test",
296                "path_prefix_replacement": "/test",
297            }]
298        });
299
300        assert_error_contains!(
301            serde_json::from_str::<Rule>(json["content"][0].to_string().as_str()).unwrap_err(),
302            "paths must start with",
303        );
304        assert_error_contains!(
305            serde_json::from_str::<RuleConfig>(json.to_string().as_str()).unwrap_err(),
306            "paths must start with",
307        );
308    }
309
310    #[test]
311    fn test_parse_all_foo_to_bar_rules() {
312        let json = json!({
313            "version": "1",
314            "content": [{
315                "host_match":              "example.com",
316                "host_replacement":        "example.com",
317                "path_prefix_match":       "/foo",
318                "path_prefix_replacement": "/bar",
319            },{
320                "host_match":              "example.com",
321                "host_replacement":        "example.com",
322                "path_prefix_match":       "/foo/",
323                "path_prefix_replacement": "/bar/",
324            }]
325        });
326
327        let expected = RuleConfig::Version1(vec![
328            rule!("example.com" => "example.com", "/foo" => "/bar"),
329            rule!("example.com" => "example.com", "/foo/" => "/bar/"),
330        ]);
331
332        assert_eq!(
333            serde_json::from_str::<RuleConfig>(json.to_string().as_str()).unwrap(),
334            expected
335        );
336
337        assert_eq!(serde_json::to_value(expected).unwrap(), json);
338    }
339}
340
341#[cfg(test)]
342mod rule_tests {
343    use super::*;
344    use assert_matches::assert_matches;
345
346    macro_rules! test_new_error {
347        (
348            $(
349                $test_name:ident => {
350                    host = $host_match:expr => $host_replacement:expr,
351                    path = $path_prefix_match:expr => $path_prefix_replacement:expr,
352                    error = $error:expr,
353                }
354            )+
355        ) => {
356            $(
357
358                #[test]
359                fn $test_name() {
360                    let error = Rule::new(
361                        $host_match,
362                        $host_replacement,
363                        $path_prefix_match,
364                        $path_prefix_replacement,
365                    )
366                    .expect_err("should have failed to parse");
367                    assert_eq!(error, $error);
368
369                    let error = Rule::new(
370                        $host_replacement,
371                        $host_match,
372                        $path_prefix_replacement,
373                        $path_prefix_match,
374                    )
375                    .expect_err("should have failed to parse");
376                    assert_eq!(error, $error);
377                }
378            )+
379        }
380    }
381
382    test_new_error! {
383        test_err_empty_host => {
384            host = "" => "example.com",
385            path = "/" => "/",
386            error = RuleParseError::InvalidHost,
387        }
388        test_err_invalid_host_match_uppercase => {
389            host = "EXAMPLE.ORG" => "example.com",
390            path = "/" => "/",
391            error = RuleParseError::InvalidHost,
392        }
393        test_err_invalid_host_replacement_uppercase => {
394            host = "example.org" => "EXAMPLE.COM",
395            path = "/" => "/",
396            error = RuleParseError::InvalidHost,
397        }
398        test_err_empty_path => {
399            host = "fuchsia.com" => "fuchsia.com",
400            path = "" => "rolldice",
401            error = RuleParseError::InvalidPath,
402        }
403        test_err_relative_path => {
404            host = "example.com" => "example.com",
405            path = "/rolldice" => "rolldice",
406            error = RuleParseError::InvalidPath,
407        }
408        test_err_inconsistent_match_type => {
409            host = "example.com" => "example.com",
410            path = "/rolldice/" => "/fortune",
411            error = RuleParseError::InconsistentPaths,
412        }
413    }
414
415    // Assumes apply creates a valid AbsolutePackageUrl if it matches
416    macro_rules! test_apply {
417        (
418            $(
419                $test_name:ident => {
420                    host = $host_match:expr => $host_replacement:expr,
421                    path = $path_prefix_match:expr => $path_prefix_replacement:expr,
422                    cases = [ $(
423                        $input:expr => $output:expr,
424                    )+ ],
425                }
426            )+
427        ) => {
428            $(
429
430                #[test]
431                fn $test_name() {
432                    let rule = Rule::new(
433                        $host_match.to_owned(),
434                        $host_replacement.to_owned(),
435                        $path_prefix_match.to_owned(),
436                        $path_prefix_replacement.to_owned()
437                    )
438                    .unwrap();
439
440                    $(
441                        let input = AbsolutePackageUrl::parse($input).unwrap();
442                        let output: Option<&str> = $output;
443                        let output = output.map(|s| AbsolutePackageUrl::parse(s).unwrap());
444                        assert_eq!(
445                            rule.apply(&input).map(|res| res.unwrap()),
446                            output,
447                            "\n\nusing rule {:?}\nexpected {}\nto map to {},\nbut got {:?}\n\n",
448                            rule,
449                            $input,
450                            stringify!($output),
451                            rule.apply(&input).map(|x| x.map(|uri| uri.to_string())),
452                        );
453                    )+
454                }
455            )+
456        }
457    }
458
459    test_apply! {
460        test_nop => {
461            host = "fuchsia.com" => "fuchsia.com",
462            path = "/" => "/",
463            cases = [
464                "fuchsia-pkg://fuchsia.com/rolldice" => Some("fuchsia-pkg://fuchsia.com/rolldice"),
465                "fuchsia-pkg://fuchsia.com/rolldice/0" => Some("fuchsia-pkg://fuchsia.com/rolldice/0"),
466                "fuchsia-pkg://fuchsia.com/foo/0?hash=00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" => Some(
467                "fuchsia-pkg://fuchsia.com/foo/0?hash=00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"),
468
469                "fuchsia-pkg://example.com/rolldice" => None,
470                "fuchsia-pkg://example.com/rolldice/0" => None,
471            ],
472        }
473        test_inject_subdomain => {
474            host = "fuchsia.com" => "test.fuchsia.com",
475            path = "/" => "/",
476            cases = [
477                "fuchsia-pkg://fuchsia.com/rolldice" => Some("fuchsia-pkg://test.fuchsia.com/rolldice"),
478                "fuchsia-pkg://fuchsia.com/rolldice/0" => Some("fuchsia-pkg://test.fuchsia.com/rolldice/0"),
479
480                "fuchsia-pkg://example.com/rolldice" => None,
481                "fuchsia-pkg://example.com/rolldice/0" => None,
482            ],
483        }
484        test_inject_subdir => {
485            host = "fuchsia.com" => "fuchsia.com",
486            path = "/foo" => "/foo/bar",
487            cases = [
488                "fuchsia-pkg://fuchsia.com/foo" => Some("fuchsia-pkg://fuchsia.com/foo/bar"),
489                // TODO not supported until fuchsia-pkg URIs allow arbitrary package paths
490                //"fuchsia-pkg://fuchsia.com/foo/0" => Some("fuchsia-pkg://fuchsia.com/foo/bar/0")),
491            ],
492        }
493        test_inject_parent_dir => {
494            host = "fuchsia.com" => "fuchsia.com",
495            path = "/foo" => "/bar/foo",
496            cases = [
497                "fuchsia-pkg://fuchsia.com/foo" => Some("fuchsia-pkg://fuchsia.com/bar/foo"),
498            ],
499        }
500        test_replace_host => {
501            host = "fuchsia.com" => "example.com",
502            path = "/" => "/",
503            cases = [
504                "fuchsia-pkg://fuchsia.com/rolldice" => Some("fuchsia-pkg://example.com/rolldice"),
505                "fuchsia-pkg://fuchsia.com/rolldice/0" => Some("fuchsia-pkg://example.com/rolldice/0"),
506
507                "fuchsia-pkg://example.com/rolldice" => None,
508                "fuchsia-pkg://example.com/rolldice/0" => None,
509            ],
510        }
511        test_replace_host_for_single_package => {
512            host = "fuchsia.com" => "example.com",
513            path = "/rolldice" => "/rolldice",
514            cases = [
515                "fuchsia-pkg://fuchsia.com/rolldice" => Some("fuchsia-pkg://example.com/rolldice"),
516
517                // this path pattern is a literal match
518                "fuchsia-pkg://fuchsia.com/rolldicer" => None,
519
520                // unrelated packages don't match
521                "fuchsia-pkg://fuchsia.com/fortune" => None,
522            ],
523        }
524        test_replace_host_for_package_prefix => {
525            host = "fuchsia.com" => "example.com",
526            path = "/rolldice/" => "/rolldice/",
527            cases = [
528                "fuchsia-pkg://fuchsia.com/rolldice/0" => Some("fuchsia-pkg://example.com/rolldice/0"),
529                "fuchsia-pkg://fuchsia.com/rolldice/stable" => Some("fuchsia-pkg://example.com/rolldice/stable"),
530
531                // package with same name as directory doesn't match
532                "fuchsia-pkg://fuchsia.com/rolldice" => None,
533            ],
534        }
535        test_rename_package => {
536            host = "fuchsia.com" => "fuchsia.com",
537            path = "/fake" => "/real",
538            cases = [
539                "fuchsia-pkg://fuchsia.com/fake" => Some("fuchsia-pkg://fuchsia.com/real"),
540
541                // not the same packages
542                "fuchsia-pkg://fuchsia.com/fakeout" => None,
543            ],
544        }
545        test_rename_directory => {
546            host = "fuchsia.com" => "fuchsia.com",
547            path = "/fake/" => "/real/",
548            cases = [
549                "fuchsia-pkg://fuchsia.com/fake/0" => Some("fuchsia-pkg://fuchsia.com/real/0"),
550                "fuchsia-pkg://fuchsia.com/fake/package" => Some("fuchsia-pkg://fuchsia.com/real/package"),
551
552                // a package called "fake", not a directory.
553                "fuchsia-pkg://fuchsia.com/fake" => None,
554            ],
555        }
556    }
557
558    #[test]
559    fn test_apply_creates_invalid_url() {
560        let rule = Rule::new("fuchsia.com", "fuchsia.com", "/", "/a+b/").unwrap();
561        assert_matches!(
562            rule.apply(&"fuchsia-pkg://fuchsia.com/foo".parse().unwrap()),
563            Some(Err(ParseError::InvalidName(_)))
564        );
565    }
566}