1use 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#[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#[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 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 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 pub fn host_match(&self) -> &str {
74 self.host_match.host()
75 }
76
77 pub fn host_replacement(&self) -> &str {
79 self.host_replacement.host()
80 }
81
82 pub fn path_prefix_match(&self) -> &str {
84 &self.path_prefix_match
85 }
86
87 pub fn path_prefix_replacement(&self) -> &str {
90 &self.path_prefix_replacement
91 }
92
93 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 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 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 ],
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 "fuchsia-pkg://fuchsia.com/rolldicer" => None,
533
534 "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 "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 "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 "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}