Skip to main content

artifactize_core/auth/
remote.rs

1//! Remote review store configuration and its bearer token.
2//!
3//! The remote is configured only by `$STATE/remote.json` and the environment, never by
4//! `index.artf`. Callers must never print or log the token.
5#[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";
27/// Bound bearer-header size while allowing token formats larger than this server's
28/// generated tokens; validation is identical for environment and saved tokens.
29const MAX_TOKEN_BYTES: usize = 4096;
30/// A URL/share declaration is small; bound its read so malformed local config
31/// cannot cause unbounded allocation. The existing parser sees one extra byte.
32const MAX_CONFIG_BYTES: u64 = 64 * 1024;
33/// Remote reuse is optional; fail an unreachable connection quickly so local work resumes.
34const CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
35/// Bound each review-store request, including response reads, independently of provider work.
36const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
37/// Clip server-supplied client-error explanations before they enter CLI/audit diagnostics.
38const 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    /// Reduced records without argv, captured output, tool calls or paths.
44    #[default]
45    Summary,
46    /// Also the saved execution as is.
47    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
73/// A resolved remote; its token is private and never serialized.
74pub 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/// A permission a remote token grants, as the server names it. A scope this version does
89/// not know is kept by its name, so it is still shown, but it grants nothing here.
90#[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    /// Network errors, timeouts and 5xx, on which callers may fail open.
130    pub unavailable: bool,
131    /// The server sent an HTTP response.
132    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
140/// Accept HTTPS, or plain HTTP only on loopback; never credentials, queries or fragments.
141pub 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
197/// Resolve the remote from the environment and `$STATE/remote.json` without network access.
198/// `None` means no remote is configured or `ARTIFACTIZE_REMOTE=off`.
199pub 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            // A token is bound to the origin it was issued for.
226            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
256/// rustls reports handshake and certificate failures as InvalidData I/O errors, possibly
257/// nested inside other I/O errors whose `source` skips them.
258fn 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
287/// A store's own text (an error, a record it could not decode), safe to show and save: the
288/// shared rule for server text that may echo the token this request carried.
289fn 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        // One client per process reuses connections and the loaded trust store.
296        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    /// 401/403 and other client errors fail closed; 5xx and transport outages are unavailable.
311    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                // A client error explains itself, for example a store of another schema.
335                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    /// Ask the server who this token is; works without a token to test reachability.
370    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    /// Decode each record at the HTTP boundary; malformed entries remain individually skippable.
376    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            // A decode error quotes the record's own fields and values: server text.
391            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    /// Append a record to its key's history; `false` when the store already has this execution.
400    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
412/// Verify a token from stdin against the store, then save it and `remote.json`.
413pub 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/// What `remote status` reports: where the remote is, what it shares, where its token comes
454/// from, and whether the store answers to it. Never the token.
455#[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/// What asking the store who the token belongs to came to.
464#[derive(Debug, Clone)]
465pub enum Connection {
466    /// The token was accepted.
467    SignedIn {
468        principal: crate::types::TokenName,
469        scopes: Vec<Scope>,
470    },
471    /// The store answered with an HTTP response, but not with an accepted token.
472    Refused { error: String },
473    /// The store sent no HTTP response.
474    Unreachable { error: String },
475}
476
477impl Status {
478    /// The token was accepted.
479    pub fn signed_in(&self) -> bool {
480        matches!(self.connection, Connection::SignedIn { .. })
481    }
482
483    /// The store sent an HTTP response, whether or not it accepted the token.
484    pub fn reachable(&self) -> bool {
485        !matches!(self.connection, Connection::Unreachable { .. })
486    }
487
488    /// Why the token was not accepted.
489    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
497/// The fields of `remote status --json`, which stay as they were.
498impl 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
530/// Resolve the remote and ask the store who its token belongs to; `None` when no remote is
531/// configured (or `ARTIFACTIZE_REMOTE=off`).
532pub 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
556/// Forget the stored token and `remote.json`; the server keeps the token until revoked.
557pub 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}