1#[cfg(test)]
6mod lookup_tests;
7
8use std::{
9 fs,
10 io::{Read, Write},
11 net::IpAddr,
12 path::Path,
13 sync::OnceLock,
14 time::Duration,
15};
16
17use reqwest::{Method, Response, StatusCode, Url};
18use serde::{Deserialize, Serialize};
19use serde_json::{Value, json};
20
21use super::storage::{Storage, Tokens};
22use crate::remote::Record;
23
24pub const CONFIG: &str = "remote.json";
25const TOKEN: &str = "remote-token.json";
26const LOGIN: &str = "run `artifactize remote login URL` or set ARTIFACTIZE_REMOTE_TOKEN";
27const MAX_TOKEN_BYTES: usize = 4096;
30const MAX_CONFIG_BYTES: u64 = 64 * 1024;
33const CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
35const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
37const MAX_ERROR_CHARS: usize = 300;
39
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
41#[serde(rename_all = "lowercase")]
42pub enum Share {
43 #[default]
45 Summary,
46 Full,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "lowercase")]
52pub enum TokenSource {
53 Env,
54 File,
55 None,
56}
57
58#[derive(Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60struct Config {
61 url: String,
62 #[serde(default)]
63 share: Share,
64}
65
66#[derive(Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68struct StoredToken {
69 url: String,
70 token: String,
71}
72
73pub struct Remote {
75 pub url: Url,
76 pub share: Share,
77 pub token_source: TokenSource,
78 token: Option<String>,
79 client: OnceLock<reqwest::Client>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct Principal {
84 pub principal: crate::types::TokenName,
85 pub scopes: Vec<Scope>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(from = "String", into = "String")]
92pub enum Scope {
93 Read,
94 Publish,
95 Human,
96 Other(String),
97}
98
99impl From<String> for Scope {
100 fn from(name: String) -> Self {
101 match name.as_str() {
102 "read" => Self::Read,
103 "publish" => Self::Publish,
104 "human" => Self::Human,
105 _ => Self::Other(name),
106 }
107 }
108}
109
110impl From<Scope> for String {
111 fn from(scope: Scope) -> Self {
112 scope.to_string()
113 }
114}
115
116impl std::fmt::Display for Scope {
117 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 formatter.write_str(match self {
119 Self::Read => "read",
120 Self::Publish => "publish",
121 Self::Human => "human",
122 Self::Other(name) => name,
123 })
124 }
125}
126
127#[derive(Debug)]
128pub struct Failure {
129 pub unavailable: bool,
131 pub responded: bool,
133 pub message: String,
134}
135
136fn variable(name: &str) -> Option<String> {
137 crate::platform::environment::var_text(name).filter(|value| !value.is_empty())
138}
139
140pub fn parse_url(value: &str) -> Result<Url, String> {
142 let mut url = Url::parse(value).map_err(|_| "Invalid remote URL.".to_owned())?;
143 if !url.username().is_empty()
144 || url.password().is_some()
145 || url.query().is_some()
146 || url.fragment().is_some()
147 {
148 return Err("The remote URL must not contain credentials, a query or a fragment.".into());
149 }
150 let loopback = match url.host() {
151 Some(url::Host::Domain(host)) => host == "localhost",
152 Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip).is_loopback(),
153 Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip).is_loopback(),
154 None => false,
155 };
156 if !((url.scheme() == "https" && url.host().is_some()) || (url.scheme() == "http" && loopback))
157 {
158 return Err("The remote URL must use https:// (http:// only on loopback).".into());
159 }
160 if !url.path().ends_with('/') {
161 url.set_path(&format!("{}/", url.path()));
162 }
163 Ok(url)
164}
165
166fn valid_token(token: &str) -> Result<(), String> {
167 if (1..=MAX_TOKEN_BYTES).contains(&token.len())
168 && token.bytes().all(|byte| byte.is_ascii_graphic())
169 {
170 Ok(())
171 } else {
172 Err("A remote token is 1–4096 printable ASCII characters without spaces.".into())
173 }
174}
175
176fn read_config(state: &Path) -> Result<Option<Config>, String> {
177 let file = match crate::platform::open_no_follow(
178 fs::OpenOptions::new().read(true),
179 &state.join(CONFIG),
180 ) {
181 Ok(file) => file,
182 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
183 Err(error) => return Err(format!("Cannot read {CONFIG}: {error}")),
184 };
185 if !file.metadata().map_err(|e| e.to_string())?.is_file() {
186 return Err(format!("{CONFIG} must be a regular file."));
187 }
188 let mut data = Vec::new();
189 file.take(MAX_CONFIG_BYTES + 1)
190 .read_to_end(&mut data)
191 .map_err(|e| e.to_string())?;
192 serde_json::from_slice(&data).map(Some).map_err(|_| {
193 format!("Invalid {CONFIG}; expected {{\"url\":\"https://...\",\"share\":\"summary\"}}.")
194 })
195}
196
197pub fn remote(state: Option<&Path>, repo: Option<&Path>) -> Result<Option<Remote>, String> {
200 let state_dir = crate::store::state_dir(state)?;
201 let config = read_config(&state_dir)?;
202 let url = match (variable("ARTIFACTIZE_REMOTE"), &config) {
203 (Some(url), _) if url == "off" => return Ok(None),
204 (Some(url), _) => url,
205 (None, Some(config)) => config.url.clone(),
206 (None, None) => return Ok(None),
207 };
208 let url = parse_url(&url)?;
209 let share = match variable("ARTIFACTIZE_REMOTE_SHARE") {
210 Some(share) => serde_json::from_value(serde_json::Value::String(share))
211 .map_err(|_| "ARTIFACTIZE_REMOTE_SHARE must be summary or full.".to_owned())?,
212 None => config.map(|config| config.share).unwrap_or_default(),
213 };
214 let (token, token_source) = if let Some(token) = variable("ARTIFACTIZE_REMOTE_TOKEN") {
215 valid_token(&token).map_err(|e| format!("ARTIFACTIZE_REMOTE_TOKEN: {e}"))?;
216 (Some(token), TokenSource::Env)
217 } else {
218 match Storage::inspect(Some(&state_dir), repo, Tokens::Remote)?
219 .read::<StoredToken>(TOKEN)?
220 {
221 Some(stored) if parse_url(&stored.url)?.origin() == url.origin() => {
222 valid_token(&stored.token)?;
223 (Some(stored.token), TokenSource::File)
224 }
225 Some(_) => {
227 return Err(format!(
228 "The stored remote token was issued for another store; {LOGIN}."
229 ));
230 }
231 None => (None, TokenSource::None),
232 }
233 };
234 Ok(Some(Remote {
235 url,
236 share,
237 token_source,
238 token,
239 client: OnceLock::new(),
240 }))
241}
242
243fn client() -> Result<reqwest::Client, Failure> {
244 reqwest::Client::builder()
245 .connect_timeout(CONNECT_TIMEOUT)
246 .timeout(REQUEST_TIMEOUT)
247 .redirect(reqwest::redirect::Policy::none())
248 .build()
249 .map_err(|error| Failure {
250 unavailable: false,
251 responded: false,
252 message: error.to_string(),
253 })
254}
255
256fn tls(error: &(dyn std::error::Error + 'static)) -> bool {
259 match error.downcast_ref::<std::io::Error>() {
260 Some(io) => {
261 io.kind() == std::io::ErrorKind::InvalidData
262 || io.get_ref().is_some_and(|inner| tls(inner))
263 }
264 None => error.source().is_some_and(tls),
265 }
266}
267
268fn transport(error: reqwest::Error) -> Failure {
269 let tls = tls(&error);
270 let cause = std::iter::successors(Some(&error as &dyn std::error::Error), |error| {
271 error.source()
272 })
273 .last()
274 .map(ToString::to_string)
275 .unwrap_or_default();
276 Failure {
277 unavailable: !tls && (error.is_connect() || error.is_timeout()),
278 responded: false,
279 message: if tls {
280 format!("TLS with the remote store failed: {cause}")
281 } else {
282 format!("Remote store is unreachable: {cause}")
283 },
284 }
285}
286
287fn diagnostic(text: &str, token: Option<&str>) -> String {
290 crate::secret::server_text(text, token.as_slice(), MAX_ERROR_CHARS)
291}
292
293impl Remote {
294 fn request(&self, method: Method, route: &str) -> Result<reqwest::RequestBuilder, Failure> {
295 let client = match self.client.get() {
297 Some(client) => client,
298 None => {
299 let built = client()?;
300 self.client.get_or_init(|| built)
301 }
302 };
303 let mut request = client.request(method, self.url.join(route).expect("relative route"));
304 if let Some(token) = &self.token {
305 request = request.bearer_auth(token);
306 }
307 Ok(request)
308 }
309
310 async fn send(&self, request: reqwest::RequestBuilder) -> Result<Response, Failure> {
312 let response = request.send().await.map_err(transport)?;
313 let status = response.status();
314 if status.is_success() {
315 return Ok(response);
316 }
317 let failure = |unavailable, message: String| Failure {
318 unavailable,
319 responded: true,
320 message,
321 };
322 Err(match status {
323 StatusCode::UNAUTHORIZED if self.token.is_none() => {
324 failure(false, format!("No remote token; {LOGIN}."))
325 }
326 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => failure(
327 false,
328 format!(
329 "The remote store rejected the token (HTTP {}).",
330 status.as_u16()
331 ),
332 ),
333 _ => {
334 let detail = if status.is_client_error() {
336 response.json::<Value>().await.ok().and_then(|body| {
337 body["error"]
338 .as_str()
339 .map(|error| diagnostic(error, self.token.as_deref()))
340 })
341 } else {
342 None
343 };
344 failure(
345 status.is_server_error(),
346 match detail {
347 Some(detail) => format!(
348 "The remote store answered HTTP {}: {detail}",
349 status.as_u16()
350 ),
351 None => format!("The remote store answered HTTP {}.", status.as_u16()),
352 },
353 )
354 }
355 })
356 }
357
358 async fn json<T: serde::de::DeserializeOwned>(
359 response: Response,
360 route: &str,
361 ) -> Result<T, Failure> {
362 response.json().await.map_err(|_| Failure {
363 unavailable: false,
364 responded: true,
365 message: format!("Invalid remote {route} response."),
366 })
367 }
368
369 pub async fn whoami(&self) -> Result<Principal, Failure> {
371 let response = self.send(self.request(Method::GET, "v1/whoami")?).await?;
372 Self::json(response, "whoami").await
373 }
374
375 pub async fn lookup(
377 &self,
378 keys: &[crate::types::ReuseKey],
379 ) -> Result<Vec<Result<Record, String>>, Failure> {
380 #[derive(Deserialize)]
381 struct Found {
382 entries: Vec<Value>,
383 }
384 let mut entries = Vec::new();
385 for chunk in keys.chunks(crate::server::MAX_LOOKUP_KEYS) {
386 let request = self
387 .request(Method::POST, "v1/lookup")?
388 .json(&json!({ "keys": chunk }));
389 let found: Found = Self::json(self.send(request).await?, "lookup").await?;
390 entries.extend(found.entries.into_iter().map(|entry| {
392 serde_json::from_value::<Record>(entry)
393 .map_err(|error| diagnostic(&error.to_string(), self.token.as_deref()))
394 }));
395 }
396 Ok(entries)
397 }
398
399 pub async fn publish(&self, record: &Record) -> Result<bool, Failure> {
401 #[derive(Deserialize)]
402 struct Published {
403 created: bool,
404 }
405 let route = format!("v1/entries/{}", record.key);
406 let request = self.request(Method::PUT, &route)?.json(record);
407 let published: Published = Self::json(self.send(request).await?, "publish").await?;
408 Ok(published.created)
409 }
410}
411
412pub async fn login(
414 state: Option<&Path>,
415 repo: Option<&Path>,
416 url: Url,
417 share: Share,
418 token: String,
419) -> Result<(Remote, Principal), String> {
420 valid_token(&token)?;
421 let remote = Remote {
422 url,
423 share,
424 token_source: TokenSource::File,
425 token: Some(token),
426 client: OnceLock::new(),
427 };
428 let principal = remote.whoami().await.map_err(|failure| failure.message)?;
429 let storage = Storage::new(state, repo, Tokens::Remote)?;
430 storage.save(
431 TOKEN,
432 &StoredToken {
433 url: remote.url.to_string(),
434 token: remote.token.clone().expect("login token"),
435 },
436 )?;
437 let state = crate::store::state_dir(state)?;
438 let mut file = tempfile::NamedTempFile::new_in(&state).map_err(|e| e.to_string())?;
439 serde_json::to_writer(
440 &mut file,
441 &Config {
442 url: remote.url.to_string(),
443 share,
444 },
445 )
446 .map_err(|e| e.to_string())?;
447 file.flush().map_err(|e| e.to_string())?;
448 file.persist(state.join(CONFIG))
449 .map_err(|e| e.error.to_string())?;
450 Ok((remote, principal))
451}
452
453#[derive(Debug, Clone)]
456pub struct Status {
457 pub url: String,
458 pub share: Share,
459 pub token_source: TokenSource,
460 pub connection: Connection,
461}
462
463#[derive(Debug, Clone)]
465pub enum Connection {
466 SignedIn {
468 principal: crate::types::TokenName,
469 scopes: Vec<Scope>,
470 },
471 Refused { error: String },
473 Unreachable { error: String },
475}
476
477impl Status {
478 pub fn signed_in(&self) -> bool {
480 matches!(self.connection, Connection::SignedIn { .. })
481 }
482
483 pub fn reachable(&self) -> bool {
485 !matches!(self.connection, Connection::Unreachable { .. })
486 }
487
488 pub fn error(&self) -> Option<&str> {
490 match &self.connection {
491 Connection::SignedIn { .. } => None,
492 Connection::Refused { error } | Connection::Unreachable { error } => Some(error),
493 }
494 }
495}
496
497impl Serialize for Status {
499 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
500 #[derive(Serialize)]
501 #[serde(rename_all = "camelCase")]
502 struct Wire<'a> {
503 url: &'a str,
504 share: Share,
505 token_source: TokenSource,
506 reachable: bool,
507 principal: Option<&'a crate::types::TokenName>,
508 scopes: Option<&'a [Scope]>,
509 error: Option<&'a str>,
510 }
511 let (principal, scopes) = match &self.connection {
512 Connection::SignedIn { principal, scopes } => {
513 (Some(principal), Some(scopes.as_slice()))
514 }
515 _ => (None, None),
516 };
517 Wire {
518 url: &self.url,
519 share: self.share,
520 token_source: self.token_source,
521 reachable: self.reachable(),
522 principal,
523 scopes,
524 error: self.error(),
525 }
526 .serialize(serializer)
527 }
528}
529
530pub async fn status(state: Option<&Path>, repo: Option<&Path>) -> Result<Option<Status>, String> {
533 let Some(remote) = remote(state, repo)? else {
534 return Ok(None);
535 };
536 let connection = match remote.whoami().await {
537 Ok(principal) => Connection::SignedIn {
538 principal: principal.principal,
539 scopes: principal.scopes,
540 },
541 Err(failure) if failure.responded => Connection::Refused {
542 error: failure.message,
543 },
544 Err(failure) => Connection::Unreachable {
545 error: failure.message,
546 },
547 };
548 Ok(Some(Status {
549 url: remote.url.to_string(),
550 share: remote.share,
551 token_source: remote.token_source,
552 connection,
553 }))
554}
555
556pub fn logout(state: Option<&Path>, repo: Option<&Path>) -> Result<(), String> {
558 Storage::new(state, repo, Tokens::Remote)?.remove(TOKEN)?;
559 match fs::remove_file(crate::store::state_dir(state)?.join(CONFIG)) {
560 Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(error.to_string()),
561 _ => Ok(()),
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 #[test]
570 fn nested_invalid_data_is_a_tls_failure() {
571 use std::io::{Error, ErrorKind};
572 let handshake = Error::other(Error::new(ErrorKind::InvalidData, "bad certificate"));
573 assert!(tls(&handshake));
574 assert!(!tls(&Error::from(ErrorKind::ConnectionRefused)));
575 }
576
577 #[test]
578 fn urls_need_https_except_on_loopback() {
579 for url in [
580 "https://reviews.example",
581 "https://reviews.example/team/",
582 "http://127.0.0.1:8417",
583 "http://[::1]:8417/",
584 "http://localhost:8417/",
585 ] {
586 assert!(parse_url(url).unwrap().path().ends_with('/'), "{url}");
587 }
588 for url in [
589 "http://reviews.example/",
590 "http://10.0.0.1/",
591 "https://user:secret@reviews.example/",
592 "https://reviews.example/?token=x",
593 "ftp://reviews.example/",
594 "reviews.example",
595 ] {
596 assert!(parse_url(url).is_err(), "{url}");
597 }
598 }
599}