nap_core/provider/
http.rs1use anyhow::{Context, Result, bail};
3use reqwest::Url;
4use std::io::Write;
5use std::path::Path;
6
7pub fn validate_origin(value: &str) -> Result<Url> {
8 let url = Url::parse(value).context("invalid Lore HTTP origin")?;
9 if !matches!(url.scheme(), "http" | "https")
10 || url.host_str().is_none()
11 || !url.username().is_empty()
12 || url.password().is_some()
13 || url.query().is_some()
14 || url.fragment().is_some()
15 || !matches!(url.path(), "" | "/")
16 {
17 bail!(
18 "Lore HTTP URL must be an http(s) origin without credentials, path, query, or fragment"
19 );
20 }
21 Ok(url)
22}
23
24pub fn default_origin(remote: &str) -> Result<String> {
27 if remote.is_empty() {
28 return Ok("http://127.0.0.1:41339".into());
29 }
30 let rpc = Url::parse(remote).context("invalid Lore remote URL")?;
31 if !rpc.username().is_empty()
32 || rpc.password().is_some()
33 || rpc.query().is_some()
34 || rpc.fragment().is_some()
35 {
36 bail!("Lore remote URL must not contain credentials, query, or fragment");
37 }
38 let secure = match rpc.scheme() {
39 "lore" | "grpc" | "http" => false,
40 "lores" | "grpcs" | "https" => true,
41 _ => bail!("unsupported Lore remote scheme"),
42 };
43 let host = rpc.host().context("Lore remote URL has no host")?;
44 let cloud = rpc.host_str() == Some("lore.portals.works");
45 let edge = cloud || (secure && matches!(rpc.port(), None | Some(443)));
46 let scheme = if secure || cloud { "https" } else { "http" };
47 let port = if edge { "" } else { ":41339" };
48 Ok(format!("{scheme}://{host}{port}"))
49}
50
51fn same_server(a: &str, b: &str) -> bool {
52 fn identity(value: &str) -> Option<(bool, String, u16)> {
53 let url = Url::parse(value).ok()?;
54 let secure = match url.scheme() {
55 "lore" | "grpc" | "http" => false,
56 "lores" | "grpcs" | "https" => true,
57 _ => return None,
58 };
59 Some((
60 secure,
61 url.host_str()?.into(),
62 url.port().unwrap_or(if secure { 443 } else { 41337 }),
63 ))
64 }
65 match (identity(a), identity(b)) {
66 (Some(a), Some(b)) => a == b,
67 _ => a == b,
68 }
69}
70
71pub fn configured_origin(nap_home: &Path, remote: &str) -> Result<String> {
74 let path = nap_home.join("provider.toml");
75 let mut config: toml::Value = match std::fs::read_to_string(&path) {
76 Ok(text) => toml::from_str(&text).context("invalid provider.toml")?,
77 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return default_origin(remote),
78 Err(e) => return Err(e.into()),
79 };
80 let configured_remote = config
81 .get("remote_url")
82 .and_then(toml::Value::as_str)
83 .unwrap_or_else(
84 || match config.get("provider_type").and_then(toml::Value::as_str) {
85 Some("local") => "lore://localhost:41337",
86 Some("portals-cloud") => super::portals_cloud::PORTALS_CLOUD_URL,
87 _ => "",
88 },
89 );
90 if !same_server(configured_remote, remote) {
91 return default_origin(remote);
92 }
93 if let Some(value) = config.get("http_url").and_then(toml::Value::as_str) {
94 validate_origin(value)?;
95 return Ok(value.trim_end_matches('/').into());
96 }
97 let origin = default_origin(remote)?;
98 config
99 .as_table_mut()
100 .context("provider config must be a table")?
101 .insert("http_url".into(), toml::Value::String(origin.clone()));
102 let mut file = tempfile::NamedTempFile::new_in(nap_home)?;
103 file.write_all(toml::to_string_pretty(&config)?.as_bytes())?;
104 file.persist(path)
105 .context("failed to save Lore HTTP endpoint")?;
106 Ok(origin)
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 #[test]
113 fn legacy_local_and_cloud_configs_receive_http_origin() {
114 for (provider, remote, origin) in [
115 (
116 "local",
117 "grpc://localhost:41337/repo",
118 "http://localhost:41339",
119 ),
120 (
121 "portals-cloud",
122 "grpcs://lore.portals.works/repo",
123 "https://lore.portals.works",
124 ),
125 ] {
126 let dir = tempfile::tempdir().unwrap();
127 let path = dir.path().join("provider.toml");
128 std::fs::write(&path, format!("provider_type = {provider:?}\n")).unwrap();
129 assert_eq!(configured_origin(dir.path(), remote).unwrap(), origin);
130 let config: toml::Value =
131 toml::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
132 assert_eq!(config["http_url"].as_str(), Some(origin));
133 }
134 }
135 #[test]
136 fn origins_cover_supported_transports_and_ipv6() {
137 for (remote, expected) in [
138 ("", "http://127.0.0.1:41339"),
139 (
140 "lore://100.105.14.118:41337/repo",
141 "http://100.105.14.118:41339",
142 ),
143 ("grpc://[::1]:41337", "http://[::1]:41339"),
144 ("lores://example.com:41337", "https://example.com:41339"),
145 ("grpcs://example.com", "https://example.com"),
146 (
147 "grpcs://lore.portals.works/repo",
148 "https://lore.portals.works",
149 ),
150 (
151 "lore://lore.portals.works.attacker.test:41337",
152 "http://lore.portals.works.attacker.test:41339",
153 ),
154 ] {
155 assert_eq!(default_origin(remote).unwrap(), expected);
156 }
157 assert!(default_origin("lore://user:secret@host").is_err());
158 assert!(validate_origin("https://host/path").is_err());
159 }
160 #[test]
161 fn migration_is_idempotent_and_does_not_cross_servers() {
162 let dir = tempfile::tempdir().unwrap();
163 let path = dir.path().join("provider.toml");
164 std::fs::write(&path, "provider_type = \"remote\"\nremote_url = \"lore://host:41337\"\nworkspace_id = \"default\"\ncustom = 1\n").unwrap();
165 assert_eq!(
166 configured_origin(dir.path(), "lore://host:41337/repo").unwrap(),
167 "http://host:41339"
168 );
169 let first = std::fs::read_to_string(&path).unwrap();
170 configured_origin(dir.path(), "lore://host:41337").unwrap();
171 assert_eq!(std::fs::read_to_string(&path).unwrap(), first);
172 assert!(first.contains("custom = 1"));
173 assert_eq!(
174 configured_origin(dir.path(), "lore://other:41337").unwrap(),
175 "http://other:41339"
176 );
177 let custom = first.replace("http://host:41339", "https://downloads.example.com");
178 std::fs::write(&path, custom).unwrap();
179 assert_eq!(
180 configured_origin(dir.path(), "lore://host:41337").unwrap(),
181 "https://downloads.example.com"
182 );
183 }
184}