Skip to main content

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