Skip to main content

nap_core/provider/
http.rs

1//! Shared Lore HTTP endpoint selection and provider configuration migration.
2use 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
24/// Standard Lore deployments expose HTTP at 41339, or share the TLS edge at 443.
25/// Operators can persist a custom origin in provider.toml; clients need no flags.
26pub 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
71/// Use configuration only for its own server, never for a repository on another host.
72/// Backfill existing provider files once, preserving custom fields and HTTP origins.
73pub 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}