1use crate::errors::{RuleDecodeError, RuleParseError};
6use fidl_fuchsia_pkg_rewrite as fidl;
7use fuchsia_url::{AbsolutePackageUrl, ParseError, RepositoryUrl};
8use serde::{Deserialize, Serialize};
9
10#[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#[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 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 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 pub fn host_match(&self) -> &str {
73 self.host_match.host()
74 }
75
76 pub fn host_replacement(&self) -> &str {
78 self.host_replacement.host()
79 }
80
81 pub fn path_prefix_match(&self) -> &str {
83 &self.path_prefix_match
84 }
85
86 pub fn path_prefix_replacement(&self) -> &str {
89 &self.path_prefix_replacement
90 }
91
92 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 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 ],
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 "fuchsia-pkg://fuchsia.com/rolldicer" => None,
519
520 "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 "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 "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 "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}