1use std::path::PathBuf;
16
17use thiserror::Error;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Ecosystem {
31 Npm,
32 Pypi,
33 Go,
34 Crates,
36 Maven,
38}
39
40impl Ecosystem {
41 pub fn parse(s: &str) -> Option<Self> {
42 match s {
43 "npm" => Some(Self::Npm),
44 "pypi" => Some(Self::Pypi),
45 "go" => Some(Self::Go),
46 "crates" => Some(Self::Crates),
50 "maven" => Some(Self::Maven),
51 _ => None,
52 }
53 }
54 pub fn as_str(self) -> &'static str {
55 match self {
56 Self::Npm => "npm",
57 Self::Pypi => "pypi",
58 Self::Go => "go",
59 Self::Crates => "crates",
60 Self::Maven => "maven",
61 }
62 }
63}
64
65impl Ecosystem {
66 pub const ALL: &'static [Ecosystem] = &[
67 Ecosystem::Npm,
68 Ecosystem::Pypi,
69 Ecosystem::Go,
70 Ecosystem::Crates,
71 Ecosystem::Maven,
72 ];
73
74 pub fn supported_list() -> String {
75 Self::ALL
76 .iter()
77 .map(|e| e.as_str())
78 .collect::<Vec<_>>()
79 .join(", ")
80 }
81}
82
83#[derive(Debug)]
87pub struct ProxyConfig {
88 pub ecosystem: Ecosystem,
89 pub config_blob: String,
90 pub canonical_location: PathBuf,
91}
92
93#[derive(Debug, Error)]
94pub enum ProxyConfigError {
95 #[error("home directory not discoverable; cannot resolve canonical location for {0:?}")]
96 HomeDirUnavailable(Ecosystem),
97 #[error(
101 "inline_token requires a non-empty api_key (would emit broken auth header for {0:?})"
102 )]
103 InlineTokenEmpty(Ecosystem),
104 #[error(
113 "pypi config emit requires a resolved API key or --emit-netrc — pip does not \
114 expand ${{CLEANLIBRARY_API_KEY}} in pip.conf, so a placeholder would 401 every \
115 request. Run `cleanlib login --api-key <KEY>` first (or set CLEANLIBRARY_API_KEY \
116 in the env before `cleanlib config init`)."
117 )]
118 PypiRequiresResolvedKey,
119}
120
121pub struct EmitOptions {
123 pub endpoint: String,
126 pub scope: Option<String>,
128 pub inline_token: bool,
137 pub api_key: Option<String>,
141 pub emit_netrc: bool,
149}
150
151pub fn emit(ecosystem: Ecosystem, opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
152 if opts.inline_token {
157 let has_usable_key = opts
158 .api_key
159 .as_deref()
160 .map(|k| !k.trim().is_empty())
161 .unwrap_or(false);
162 if !has_usable_key {
163 return Err(ProxyConfigError::InlineTokenEmpty(ecosystem));
164 }
165 }
166 match ecosystem {
167 Ecosystem::Npm => emit_npm(opts),
168 Ecosystem::Pypi => emit_pypi(opts),
169 Ecosystem::Go => emit_go(opts),
170 Ecosystem::Crates => emit_crates(opts),
171 Ecosystem::Maven => emit_maven(opts),
172 }
173}
174
175fn token_expression(opts: &EmitOptions) -> String {
176 if opts.inline_token {
177 opts.api_key.clone().unwrap_or_default()
181 } else {
182 "${CLEANLIBRARY_API_KEY}".to_string()
183 }
184}
185
186fn endpoint_host(endpoint: &str) -> &str {
187 endpoint
188 .trim_end_matches('/')
189 .trim_start_matches("https://")
190 .trim_start_matches("http://")
191}
192
193fn emit_npm(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
194 let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Npm))?;
195 let endpoint = opts.endpoint.trim_end_matches('/');
196 let registry_url = format!("{}/npm/", endpoint);
197 let host = endpoint_host(endpoint);
198 let token = token_expression(opts);
199
200 let config_blob = match opts.scope.as_deref() {
201 Some(scope) => format!(
202 "{scope}:registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
203 ),
204 None => format!(
205 "registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
206 ),
207 };
208
209 Ok(ProxyConfig {
210 ecosystem: Ecosystem::Npm,
211 config_blob,
212 canonical_location: home.join(".npmrc"),
213 })
214}
215
216fn percent_encode_userinfo(s: &str) -> String {
223 let mut out = String::with_capacity(s.len());
224 for byte in s.as_bytes() {
225 let c = *byte;
226 if c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'~') {
227 out.push(c as char);
228 } else {
229 out.push_str(&format!("%{:02X}", c));
230 }
231 }
232 out
233}
234
235fn emit_pypi(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
259 let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Pypi))?;
260 let endpoint = opts.endpoint.trim_end_matches('/');
261 let host = endpoint_host(endpoint);
262
263 let api_key = opts
266 .api_key
267 .as_deref()
268 .map(str::trim)
269 .filter(|k| !k.is_empty());
270
271 if opts.emit_netrc {
272 let key = api_key.ok_or(ProxyConfigError::PypiRequiresResolvedKey)?;
278 let pip_conf = format!(
279 "[global]\nindex-url = https://{host}/pypi/simple/\nextra-index-url =\n\n[install]\ntrusted-host = {host}\n",
280 );
281 let netrc_dest = home.join(".netrc");
282 let netrc_block = format!(
287 "machine {host}\n login {key}\n password \"\"\n",
288 );
289 let config_blob = format!(
290 "# === CleanLibrary pypi proxy (--emit-netrc) ===\n\
291 # Two-file emit: pip.conf carries no credentials; ~/.netrc carries the key.\n\
292 # pip reads ~/.netrc natively — no ${{VAR}} expansion needed (CLEANLIB-758).\n\
293 #\n\
294 # --- write this half to ~/.config/pip/pip.conf ---\n\
295 {pip_conf}\n\
296 # --- append this half to {netrc_dest_display} (chmod 600) ---\n\
297 {netrc_block}",
298 netrc_dest_display = netrc_dest.display(),
299 );
300 return Ok(ProxyConfig {
301 ecosystem: Ecosystem::Pypi,
302 config_blob,
303 canonical_location: PathBuf::new(),
305 });
306 }
307
308 let key = api_key.ok_or(ProxyConfigError::PypiRequiresResolvedKey)?;
311 let encoded_key = percent_encode_userinfo(key);
312 let config_blob = format!(
313 "[global]\nindex-url = https://{encoded_key}@{host}/pypi/simple/\nextra-index-url =\n\n[install]\ntrusted-host = {host}\n",
314 );
315
316 Ok(ProxyConfig {
317 ecosystem: Ecosystem::Pypi,
318 config_blob,
319 canonical_location: home.join(".config").join("pip").join("pip.conf"),
320 })
321}
322
323fn emit_go(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
324 let endpoint = opts.endpoint.trim_end_matches('/');
325 let token = token_expression(opts);
326
327 let config_blob = format!(
330 "# CleanLibrary Go proxy — append to your shell config (~/.bashrc, ~/.zshrc, fish config)\n# or run the equivalent `go env -w GOPROXY=...` / `go env -w GOAUTH=...` invocations.\nexport GOPROXY={endpoint}/go,direct\nexport GOAUTH=\"Authorization: Bearer {token}\"\n",
331 );
332
333 Ok(ProxyConfig {
334 ecosystem: Ecosystem::Go,
335 config_blob,
336 canonical_location: PathBuf::new(),
338 })
339}
340
341fn emit_crates(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
352 let endpoint = opts.endpoint.trim_end_matches('/');
353 let token = token_expression(opts);
354
355 let config_blob = format!(
358 "# CleanLibrary cargo (crates.io) proxy\n#\n# Registry entry — add to ~/.cargo/config.toml (per-user) or\n# <workspace>/.cargo/config.toml (per-workspace):\n[registries.cleanlibrary]\nindex = \"sparse+{endpoint}/crates/\"\n\n# Token — MUST live in ~/.cargo/credentials.toml (never config.toml):\n[registries.cleanlibrary]\ntoken = \"Bearer {token}\"\n\n# Then publish/install: cargo <cmd> --registry cleanlibrary\n",
359 );
360
361 Ok(ProxyConfig {
362 ecosystem: Ecosystem::Crates,
363 config_blob,
364 canonical_location: PathBuf::new(),
366 })
367}
368
369fn emit_maven(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
381 let endpoint = opts.endpoint.trim_end_matches('/');
382 let token = token_expression(opts);
383
384 let config_blob = format!(
385 "<!-- CleanLibrary Maven proxy — merge into ~/.m2/settings.xml (or a project-scoped -s file) -->\n<!-- <settings> root element assumed to exist. -->\n<mirrors>\n <mirror>\n <id>cleanlibrary</id>\n <name>CleanLibrary Maven mirror</name>\n <url>{endpoint}/maven/</url>\n <mirrorOf>*</mirrorOf>\n </mirror>\n</mirrors>\n<servers>\n <server>\n <id>cleanlibrary</id>\n <configuration>\n <httpHeaders>\n <property>\n <name>Authorization</name>\n <value>Bearer {token}</value>\n </property>\n </httpHeaders>\n </configuration>\n </server>\n</servers>\n",
386 );
387
388 Ok(ProxyConfig {
389 ecosystem: Ecosystem::Maven,
390 config_blob,
391 canonical_location: PathBuf::new(),
393 })
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 fn opts(endpoint: &str) -> EmitOptions {
401 EmitOptions {
402 endpoint: endpoint.to_string(),
403 scope: None,
404 inline_token: false,
405 api_key: None,
406 emit_netrc: false,
407 }
408 }
409
410 fn opts_with_key(endpoint: &str, key: &str) -> EmitOptions {
415 EmitOptions {
416 endpoint: endpoint.to_string(),
417 scope: None,
418 inline_token: false,
419 api_key: Some(key.to_string()),
420 emit_netrc: false,
421 }
422 }
423
424 #[test]
425 fn ecosystem_parse_vocab_locked() {
426 assert_eq!(Ecosystem::parse("npm"), Some(Ecosystem::Npm));
427 assert_eq!(Ecosystem::parse("pypi"), Some(Ecosystem::Pypi));
428 assert_eq!(Ecosystem::parse("go"), Some(Ecosystem::Go));
429 assert_eq!(Ecosystem::parse("crates"), Some(Ecosystem::Crates));
431 assert_eq!(Ecosystem::parse("maven"), Some(Ecosystem::Maven));
432 assert_eq!(Ecosystem::parse("NPM"), None);
434 assert_eq!(Ecosystem::parse("PyPI"), None);
435 assert_eq!(Ecosystem::parse("pip"), None);
436 assert_eq!(Ecosystem::parse("golang"), None);
437 assert_eq!(Ecosystem::parse("cargo"), None);
439 assert_eq!(Ecosystem::parse("Crates"), None);
440 assert_eq!(Ecosystem::parse("CRATES"), None);
441 assert_eq!(Ecosystem::parse("Maven"), None);
442 assert_eq!(Ecosystem::parse("MAVEN"), None);
443 assert_eq!(Ecosystem::parse("mvn"), None);
444 }
445
446 #[test]
447 fn ecosystem_as_str_roundtrips_lowercase() {
448 for e in Ecosystem::ALL {
451 assert_eq!(Ecosystem::parse(e.as_str()), Some(*e), "roundtrip failed for {:?}", e);
452 }
453 }
454
455 #[test]
456 fn supported_list_includes_crates_and_maven() {
457 let list = Ecosystem::supported_list();
463 for expected in &["npm", "pypi", "go", "crates", "maven"] {
464 assert!(
465 list.contains(expected),
466 "supported_list must advertise `{}`; got: {}",
467 expected,
468 list
469 );
470 }
471 }
472
473 #[test]
474 fn npm_emit_shell_expansion_default() {
475 let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
476 assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
477 assert!(blob.contains("//cleanapp.clnstrt.dev/npm/:_authToken=${CLEANLIBRARY_API_KEY}"));
478 assert!(blob.contains("always-auth=true"));
479 }
480
481 #[test]
482 fn npm_emit_with_scope() {
483 let mut o = opts("https://cleanapp.clnstrt.dev");
484 o.scope = Some("@my-org".to_string());
485 let blob = emit_npm(&o).unwrap().config_blob;
486 assert!(blob.contains("@my-org:registry=https://cleanapp.clnstrt.dev/npm/"));
487 }
488
489 #[test]
490 fn npm_emit_inline_token() {
491 let mut o = opts("https://cleanapp.clnstrt.dev");
492 o.inline_token = true;
493 o.api_key = Some("cs_live_smoke".to_string());
494 let blob = emit_npm(&o).unwrap().config_blob;
495 assert!(blob.contains("_authToken=cs_live_smoke"));
496 assert!(!blob.contains("${CLEANLIBRARY_API_KEY}"));
497 }
498
499 #[test]
507 fn pypi_emit_default_embeds_resolved_key_in_url_userinfo() {
508 let blob = emit_pypi(&opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc"))
509 .unwrap()
510 .config_blob;
511 assert!(
512 blob.contains("index-url = https://cs_live_abc@cleanapp.clnstrt.dev/pypi/simple/"),
513 "resolved key must land in the URL userinfo; got:\n{blob}"
514 );
515 assert!(blob.contains("trusted-host = cleanapp.clnstrt.dev"));
516 assert!(
517 !blob.contains("${CLEANLIBRARY_API_KEY}"),
518 "pypi emit must never leave the placeholder in the URL — pip cannot expand it"
519 );
520 }
521
522 #[test]
523 fn pypi_emit_percent_encodes_key_with_reserved_characters() {
524 let blob = emit_pypi(&opts_with_key("https://cleanapp.clnstrt.dev", "k@e:y/1"))
528 .unwrap()
529 .config_blob;
530 assert!(
532 blob.contains("k%40e%3Ay%2F1@cleanapp.clnstrt.dev"),
533 "percent-encode reserved chars in userinfo; got:\n{blob}"
534 );
535 }
536
537 #[test]
538 fn pypi_emit_refuses_default_when_key_missing() {
539 let err = emit_pypi(&opts("https://cleanapp.clnstrt.dev")).unwrap_err();
544 assert!(matches!(err, ProxyConfigError::PypiRequiresResolvedKey));
545 }
546
547 #[test]
548 fn pypi_emit_refuses_default_on_whitespace_only_key() {
549 let mut o = opts_with_key("https://cleanapp.clnstrt.dev", " \t\n");
550 o.api_key = Some(" \t\n".to_string());
552 let err = emit_pypi(&o).unwrap_err();
553 assert!(matches!(err, ProxyConfigError::PypiRequiresResolvedKey));
554 }
555
556 #[test]
557 fn pypi_emit_netrc_branch_produces_credential_free_pip_conf() {
558 let mut o = opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc");
559 o.emit_netrc = true;
560 let cfg = emit_pypi(&o).unwrap();
561 let blob = &cfg.config_blob;
562 assert!(
564 blob.contains("index-url = https://cleanapp.clnstrt.dev/pypi/simple/"),
565 "pip.conf half must carry no credentials; got:\n{blob}"
566 );
567 assert!(
568 !blob.contains("@cleanapp.clnstrt.dev"),
569 "pip.conf URL must not carry an @-userinfo; got:\n{blob}"
570 );
571 assert!(
573 blob.contains("machine cleanapp.clnstrt.dev\n login cs_live_abc\n"),
574 ".netrc half must carry a machine block with the resolved key; got:\n{blob}"
575 );
576 assert!(
578 cfg.canonical_location.as_os_str().is_empty(),
579 "--emit-netrc must not silently write two files off one canonical target"
580 );
581 }
582
583 #[test]
584 fn pypi_emit_netrc_still_requires_a_key() {
585 let mut o = opts("https://cleanapp.clnstrt.dev");
588 o.emit_netrc = true;
589 let err = emit_pypi(&o).unwrap_err();
590 assert!(matches!(err, ProxyConfigError::PypiRequiresResolvedKey));
591 }
592
593 #[test]
594 fn pypi_emit_inline_token_true_and_default_produce_same_url_userinfo() {
595 let mut o_inline = opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc");
601 o_inline.inline_token = true;
602 let blob_inline = emit_pypi(&o_inline).unwrap().config_blob;
603 let blob_default =
604 emit_pypi(&opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc"))
605 .unwrap()
606 .config_blob;
607 assert_eq!(blob_inline, blob_default);
608 }
609
610 #[test]
611 fn go_emit_env_form() {
612 let blob = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
613 assert!(blob.contains("export GOPROXY=https://cleanapp.clnstrt.dev/go,direct"));
614 assert!(blob.contains("export GOAUTH=\"Authorization: Bearer ${CLEANLIBRARY_API_KEY}\""));
615 }
616
617 #[test]
618 fn go_emit_has_no_canonical_location() {
619 let cfg = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap();
620 assert!(cfg.canonical_location.as_os_str().is_empty());
621 }
622
623 #[test]
624 fn endpoint_trailing_slash_tolerated() {
625 let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev/")).unwrap().config_blob;
626 assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
628 assert!(!blob.contains("//npm/"));
629 }
630
631 #[test]
635 fn emit_rejects_inline_token_with_none_api_key() {
636 let mut o = opts("https://cleanapp.clnstrt.dev");
637 o.inline_token = true;
638 o.api_key = None;
639 let err = emit(Ecosystem::Npm, &o).unwrap_err();
640 assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Npm)));
641 }
642
643 #[test]
644 fn emit_rejects_inline_token_with_empty_string_api_key() {
645 let mut o = opts("https://cleanapp.clnstrt.dev");
646 o.inline_token = true;
647 o.api_key = Some(String::new());
648 let err = emit(Ecosystem::Pypi, &o).unwrap_err();
649 assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Pypi)));
650 }
651
652 #[test]
653 fn emit_rejects_inline_token_with_whitespace_only_api_key() {
654 let mut o = opts("https://cleanapp.clnstrt.dev");
655 o.inline_token = true;
656 o.api_key = Some(" \t\n".to_string());
657 let err = emit(Ecosystem::Go, &o).unwrap_err();
658 assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Go)));
659 }
660
661 #[test]
662 fn emit_accepts_inline_token_with_valid_api_key() {
663 let mut o = opts("https://cleanapp.clnstrt.dev");
664 o.inline_token = true;
665 o.api_key = Some("std_001".to_string());
666 let blob = emit(Ecosystem::Npm, &o).unwrap().config_blob;
667 assert!(blob.contains("_authToken=std_001"));
669 assert!(!blob.contains("_authToken=\n"));
671 assert!(!blob.contains("_authToken= "));
672 }
673
674 #[test]
675 fn emit_shell_expansion_path_unaffected_by_empty_key_for_env_expanding_ecosystems() {
676 let mut o = opts("https://cleanapp.clnstrt.dev");
688 o.inline_token = false;
689 o.api_key = None;
690 assert!(emit(Ecosystem::Npm, &o).is_ok());
691 assert!(emit(Ecosystem::Go, &o).is_ok());
692 assert!(emit(Ecosystem::Crates, &o).is_ok());
694 assert!(emit(Ecosystem::Maven, &o).is_ok());
695 }
697
698 #[test]
701 fn crates_emit_registry_url_and_placeholder_token() {
702 let blob = emit_crates(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
703 assert!(
705 blob.contains("index = \"sparse+https://cleanapp.clnstrt.dev/crates/\""),
706 "crates blob missing sparse index; got:\n{blob}"
707 );
708 assert!(
709 blob.contains("[registries.cleanlibrary]"),
710 "crates blob missing [registries.cleanlibrary]; got:\n{blob}"
711 );
712 assert!(
714 blob.contains("token = \"Bearer ${CLEANLIBRARY_API_KEY}\""),
715 "crates blob missing token placeholder; got:\n{blob}"
716 );
717 }
718
719 #[test]
720 fn crates_emit_inline_token_embeds_key() {
721 let mut o = opts("https://cleanapp.clnstrt.dev");
722 o.inline_token = true;
723 o.api_key = Some("cs_live_smoke".to_string());
724 let blob = emit_crates(&o).unwrap().config_blob;
725 assert!(
726 blob.contains("token = \"Bearer cs_live_smoke\""),
727 "inline_token must embed the key in the credentials.toml block; got:\n{blob}"
728 );
729 assert!(
733 !blob.contains("${CLEANLIBRARY_API_KEY}"),
734 "inline_token blob must NOT retain the placeholder"
735 );
736 }
737
738 #[test]
739 fn crates_emit_has_no_canonical_location() {
740 let cfg = emit_crates(&opts("https://cleanapp.clnstrt.dev")).unwrap();
744 assert!(cfg.canonical_location.as_os_str().is_empty());
745 }
746
747 #[test]
750 fn maven_emit_mirror_url_and_placeholder_token() {
751 let blob = emit_maven(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
752 assert!(
753 blob.contains("<url>https://cleanapp.clnstrt.dev/maven/</url>"),
754 "maven blob missing mirror URL; got:\n{blob}"
755 );
756 assert!(
757 blob.contains("<mirrorOf>*</mirrorOf>"),
758 "maven blob must divert every repo through the CleanLibrary mirror; got:\n{blob}"
759 );
760 assert!(
761 blob.contains("<value>Bearer ${CLEANLIBRARY_API_KEY}</value>"),
762 "maven blob must carry Bearer placeholder in the Authorization header; got:\n{blob}"
763 );
764 }
765
766 #[test]
767 fn maven_emit_inline_token_embeds_key() {
768 let mut o = opts("https://cleanapp.clnstrt.dev");
769 o.inline_token = true;
770 o.api_key = Some("cs_live_smoke".to_string());
771 let blob = emit_maven(&o).unwrap().config_blob;
772 assert!(
773 blob.contains("<value>Bearer cs_live_smoke</value>"),
774 "inline_token must embed the key in the httpHeaders block; got:\n{blob}"
775 );
776 assert!(
777 !blob.contains("${CLEANLIBRARY_API_KEY}"),
778 "inline_token blob must NOT retain the placeholder"
779 );
780 }
781
782 #[test]
783 fn maven_emit_has_no_canonical_location() {
784 let cfg = emit_maven(&opts("https://cleanapp.clnstrt.dev")).unwrap();
787 assert!(cfg.canonical_location.as_os_str().is_empty());
788 }
789
790 #[test]
791 fn crates_and_maven_endpoint_trailing_slash_tolerated() {
792 let crates_blob = emit_crates(&opts("https://cleanapp.clnstrt.dev/"))
796 .unwrap()
797 .config_blob;
798 assert!(crates_blob.contains("sparse+https://cleanapp.clnstrt.dev/crates/"));
799 assert!(!crates_blob.contains("//crates/"));
800
801 let maven_blob = emit_maven(&opts("https://cleanapp.clnstrt.dev/"))
802 .unwrap()
803 .config_blob;
804 assert!(maven_blob.contains("<url>https://cleanapp.clnstrt.dev/maven/</url>"));
805 assert!(!maven_blob.contains("//maven/"));
806 }
807}