Skip to main content

kasl/api/
kasl_server.rs

1//! kasl-server client: the team server this agent reports to.
2//!
3//! Unlike the other clients here, this one has no session to manage. The
4//! server authenticates an agent by a long-lived bearer token that an
5//! administrator issues once (ADR 0004 in kasl-server), so there is nothing
6//! to log into and nothing to cache - the token goes in the OS keyring and
7//! every request carries it.
8//!
9//! ```rust,no_run
10//! # use kasl::api::kasl_server::KaslServer;
11//! # use kasl::libs::config::KaslServerConfig;
12//! # async fn f() -> anyhow::Result<()> {
13//! let config = KaslServerConfig {
14//!     url: "https://kasl.example.com".to_string(),
15//!     ca_certificate: None,
16//!     pulse: false,
17//! };
18//!
19//! let server = KaslServer::new(&config)?;
20//! let health = server.health().await?;
21//! println!("kasl-server {}", health.version);
22//! # Ok(())
23//! # }
24//! ```
25
26use crate::libs::config::KaslServerConfig;
27use anyhow::{Context, Result, bail};
28use chrono::{DateTime, FixedOffset, NaiveDate, Utc};
29use reqwest::{Client, StatusCode};
30use serde::{Deserialize, Serialize};
31use std::fs;
32use std::time::Duration;
33
34/// Keyring credential holding the agent token.
35///
36/// Named like the other secrets so it shows up beside them in the platform's
37/// credential UI; the leading dot and `_secret` suffix are what
38/// [`Secret::new`](crate::libs::secret::Secret::new) trims into the account
39/// name.
40pub const AGENT_TOKEN_SECRET: &str = ".kasl_server_secret";
41
42/// Prompt shown when the agent token is missing from the keyring.
43pub const AGENT_TOKEN_PROMPT: &str = "Enter the agent token issued by your kasl-server administrator";
44
45/// How long to wait on a request before giving up.
46///
47/// Short on purpose: every call here is a foreground command the user is
48/// waiting on, and a self-hosted server that has not answered in this long is
49/// down rather than slow.
50const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
51
52/// What `GET /health` reports.
53#[derive(Debug, Clone, Deserialize)]
54pub struct Health {
55    /// `ok` when the server considers itself serviceable.
56    pub status: String,
57
58    /// The server's own version - the product version, which the web UI and
59    /// the API share.
60    pub version: String,
61
62    /// Whether the server reached its database on this request.
63    pub database: String,
64}
65
66/// The identity behind a token, as `GET /api/v1/agent/whoami` reports it.
67///
68/// Used to confirm a token belongs to whom the user expects: connecting with
69/// a colleague's token would otherwise succeed silently and file this
70/// machine's days under their name.
71#[derive(Debug, Clone, Deserialize)]
72pub struct AgentIdentity {
73    /// Display name of the employee the token reports for.
74    pub user_name: String,
75
76    /// The label the administrator gave this agent, typically the machine.
77    pub agent_name: String,
78
79    /// The API version the server serves this path under.
80    pub api_version: String,
81
82    /// The server's own version.
83    pub server_version: String,
84}
85
86/// The privacy manifest, as `GET /api/v1/privacy/agent` reports it.
87///
88/// Generated on the server from the level it actually enforces at ingest
89/// (ADR 0011 in kasl-server), which is the whole point of reading it rather
90/// than describing the server from this side: a manifest written into kasl
91/// would describe the server kasl was built against, not the one this
92/// machine reports to.
93///
94/// Every field is owned by the server. Nothing here is defaulted or filled
95/// in locally - a manifest this agent could complete on its own would be one
96/// it could also get wrong.
97#[derive(Debug, Clone, Deserialize)]
98pub struct PrivacyManifest {
99    /// The installation's level: `full`, `moderate` or `coarse`.
100    pub level: String,
101
102    /// One sentence an employee can read without knowing the levels exist.
103    pub summary: String,
104
105    /// What is kept, field by field.
106    pub stored: Vec<StoredKind>,
107
108    /// Named explicitly, because a reader cannot tell "we do not collect
109    /// this" from "this was left off the list".
110    pub never_collected: Vec<String>,
111
112    /// Who can see a given person's data.
113    pub visible_to: Vec<String>,
114
115    /// How long it is kept.
116    pub retention: String,
117
118    /// What changing the level does - and does not do - to what is stored.
119    pub on_change: String,
120
121    /// When the level was last set, if the server says.
122    #[serde(default)]
123    pub updated_at: Option<DateTime<Utc>>,
124}
125
126/// One kind of data the server holds, in the manifest's own words.
127#[derive(Debug, Clone, Deserialize)]
128pub struct StoredKind {
129    pub what: String,
130    pub detail: String,
131}
132
133/// What this agent claims its person is doing right now.
134///
135/// Mirrors the server's `agent_state` enum (ADR 0014 in kasl-server), wire
136/// names included. The server's live status is wider - `offline` and
137/// `unknown` - but those are what it concludes from silence, and an agent
138/// cannot claim them.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "lowercase")]
141pub enum AgentState {
142    /// In an open workday, and not inside a pause.
143    Working,
144    /// In an open workday, inside a pause.
145    Paused,
146    /// Running, but not in a workday: before it starts, or after it ended.
147    Idle,
148}
149
150impl AgentState {
151    /// The wire name, which is also how the state is stored locally.
152    pub fn as_str(&self) -> &'static str {
153        match self {
154            AgentState::Working => "working",
155            AgentState::Paused => "paused",
156            AgentState::Idle => "idle",
157        }
158    }
159
160    /// Reads a stored wire name back; anything else is `None`, not a guess.
161    pub fn parse(name: &str) -> Option<Self> {
162        match name {
163            "working" => Some(AgentState::Working),
164            "paused" => Some(AgentState::Paused),
165            "idle" => Some(AgentState::Idle),
166            _ => None,
167        }
168    }
169}
170
171impl std::fmt::Display for AgentState {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        f.pad(self.as_str())
174    }
175}
176
177/// One pulse, as `POST /api/v1/agent/heartbeat` takes it.
178///
179/// The state and the moment, and nothing else: not the task, not the reason
180/// for a break. Those belong to the day, under the privacy level that governs
181/// it, and a pulse that carried them would be a live feed of what someone is
182/// doing this minute (ADR 0014 in kasl-server).
183#[derive(Debug, Clone, Serialize)]
184pub struct Pulse {
185    pub state: AgentState,
186
187    /// When the state was observed, with this machine's UTC offset like every
188    /// instant in this API. The server ages a pulse by its own clock, so this
189    /// is what it measures the skew against, not what it trusts.
190    pub at: DateTime<FixedOffset>,
191}
192
193/// What the server answers to a pulse.
194///
195/// The cadence comes from here rather than from configuration: the interval
196/// and the point at which the server stops believing a pulse have to agree,
197/// and only the server can own that.
198#[derive(Debug, Clone, Deserialize)]
199pub struct PulseAccepted {
200    /// Seconds until the next pulse is wanted.
201    pub interval_seconds: i64,
202
203    /// After how many seconds of silence the server shows this person as
204    /// offline.
205    pub stale_after_seconds: i64,
206
207    /// The state as recorded, echoed back.
208    pub state: AgentState,
209
210    /// How far this machine's clock is from the server's, in seconds,
211    /// positive when this machine is ahead.
212    pub clock_skew_seconds: i64,
213}
214
215/// One day as this agent recorded it, in the shape the server accepts.
216///
217/// Field names and types mirror the ingest contract (ADR 0004 in
218/// kasl-server) rather than kasl's own model, so a change on either side
219/// shows up as a compile error here rather than as a `400` in the field.
220///
221/// Every instant carries a UTC offset. kasl stores bare wall-clock text,
222/// which is unambiguous on one laptop and meaningless across a team; the
223/// offset is attached when the day is assembled, and a day whose offset
224/// cannot be determined is not sent (ADR 0003).
225#[derive(Debug, Clone, Serialize)]
226pub struct DayUpload {
227    /// The employee's own calendar date, sent rather than derived: near
228    /// midnight the date of `started_at` and the date the work belongs to
229    /// disagree, and the agent is the side that knows which is meant.
230    pub date: NaiveDate,
231
232    /// When the day started.
233    pub started_at: DateTime<FixedOffset>,
234
235    /// When it ended; absent while the day is still open.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub ended_at: Option<DateTime<FixedOffset>>,
238
239    pub pauses: Vec<PauseUpload>,
240
241    pub tasks: Vec<TaskUpload>,
242
243    /// Declares `tasks` to be everything this agent holds for the date, so a
244    /// task the employee deleted here is deleted there too (ADR 0005).
245    ///
246    /// kasl always sends the whole date, so this is always true. It is a
247    /// field rather than a constant because the server defaults it to false
248    /// for agents that predate it, and saying it explicitly is what
249    /// distinguishes "I have nothing more" from "I did not mention".
250    pub tasks_are_complete: bool,
251}
252
253/// One break, as the server takes it.
254#[derive(Debug, Clone, Serialize)]
255pub struct PauseUpload {
256    pub started_at: DateTime<FixedOffset>,
257
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub ended_at: Option<DateTime<FixedOffset>>,
260
261    /// Seconds. Sent explicitly because kasl merges neighbouring pauses
262    /// before reporting them, so this is not always `ended_at - started_at`.
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub duration_seconds: Option<i32>,
265
266    /// A break the employee entered by hand - kasl's `protected` flag.
267    pub manual: bool,
268
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub reason: Option<String>,
271}
272
273/// One task, keyed by the ids this agent knows it by.
274#[derive(Debug, Clone, Serialize)]
275pub struct TaskUpload {
276    /// This agent's row id: the key a re-upload matches on, so a corrected
277    /// task updates the stored row instead of piling up beside it.
278    pub agent_task_id: i32,
279
280    /// This agent's `task_id`, tying the same work across several days.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub agent_group_id: Option<i32>,
283
284    pub recorded_at: DateTime<FixedOffset>,
285
286    pub name: String,
287
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub comment: Option<String>,
290
291    /// Percent complete, 0..=100.
292    pub completeness: i16,
293}
294
295/// What the server reports about a day it stored.
296#[derive(Debug, Clone, Deserialize)]
297pub struct DayAccepted {
298    /// The server's own id for the day, which this agent does not otherwise
299    /// know: worth printing so a day can be looked up on the other side.
300    pub workday_id: String,
301
302    pub date: NaiveDate,
303
304    pub pauses: usize,
305
306    pub tasks: usize,
307
308    /// Tasks the server dropped because this upload declared its set
309    /// authoritative. Non-zero means deletions here reached the server.
310    #[serde(default)]
311    pub deleted_tasks: u64,
312
313    /// The installation's privacy level, always reported - an agent should be
314    /// able to tell a server that keeps everything from one whose policy it
315    /// has not read (ADR 0011).
316    #[serde(default)]
317    pub privacy_level: Option<String>,
318}
319
320/// A stretch of days in one request - what an agent sends after time offline.
321#[derive(Debug, Clone, Serialize)]
322pub struct BatchUpload {
323    pub days: Vec<DayUpload>,
324}
325
326/// What came of a batch.
327///
328/// The counts are read first because the status will not tell: a batch
329/// answers `200` even when days inside it were refused (ADR 0005). A client
330/// that checks only the status believes a rejected day arrived.
331#[derive(Debug, Clone, Deserialize)]
332pub struct BatchResult {
333    pub accepted: usize,
334
335    pub rejected: usize,
336
337    /// One entry per day sent, in the order they were sent.
338    #[serde(default)]
339    pub results: Vec<DayResult>,
340}
341
342/// One day's fate inside a batch.
343///
344/// Tagged by `status` to match what the server serializes. An unrecognized
345/// tag is a contract the two sides no longer share, and is surfaced rather
346/// than silently treated as either outcome.
347#[derive(Debug, Clone, Deserialize)]
348#[serde(tag = "status", rename_all = "lowercase")]
349pub enum DayResult {
350    Accepted {
351        #[serde(flatten)]
352        day: DayAccepted,
353    },
354    Rejected {
355        date: NaiveDate,
356        error: String,
357    },
358}
359
360/// Why an upload failed, and whether sending the same bytes again could ever
361/// work.
362///
363/// The distinction is the server's own (ADR 0005) and it is the whole reason
364/// this is an enum rather than a message: `4xx` means the payload will never
365/// be accepted as sent, so a queue must stop asking; `5xx` and `429` mean the
366/// server could not answer this time, so it must keep the day and try later.
367#[derive(Debug)]
368pub enum UploadError {
369    /// The server refused the payload itself. Retrying is pointless.
370    Rejected { status: StatusCode, message: String },
371
372    /// The server could not answer, or answered that it was unavailable.
373    /// The day is still worth sending.
374    Retryable { message: String },
375}
376
377impl UploadError {
378    /// Whether sending this day again could succeed.
379    pub fn is_retryable(&self) -> bool {
380        matches!(self, UploadError::Retryable { .. })
381    }
382}
383
384impl std::fmt::Display for UploadError {
385    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386        match self {
387            UploadError::Rejected { status, message } => write!(f, "the server refused the day ({}): {}", status, message),
388            UploadError::Retryable { message } => write!(f, "{}", message),
389        }
390    }
391}
392
393impl std::error::Error for UploadError {}
394
395/// A client bound to one kasl-server instance.
396#[derive(Debug, Clone)]
397pub struct KaslServer {
398    client: Client,
399
400    /// Base URL without a trailing slash, so paths append cleanly.
401    base_url: String,
402}
403
404impl KaslServer {
405    /// Builds a client for the configured server.
406    ///
407    /// A configured CA certificate is added to the trust store rather than
408    /// replacing it: a company CA for the server and public CAs for
409    /// everything else is the normal self-hosted arrangement.
410    pub fn new(config: &KaslServerConfig) -> Result<Self> {
411        let mut builder = Client::builder().timeout(REQUEST_TIMEOUT);
412
413        if let Some(path) = &config.ca_certificate {
414            let pem = fs::read(path).with_context(|| format!("cannot read the CA certificate at '{}'", path))?;
415
416            // `Certificate::from_pem` defers parsing to the TLS backend and
417            // accepts anything here - an empty file, a DER file saved with a
418            // .pem name, a text file. The failure then surfaces at the first
419            // request as an opaque TLS error, pointing at the network rather
420            // than at the file. Checked here, where the path is still in hand.
421            if !looks_like_pem_certificate(&pem) {
422                bail!(
423                    "'{}' does not contain a PEM-encoded certificate (expected a -----BEGIN CERTIFICATE----- block)",
424                    path
425                );
426            }
427
428            let certificate = reqwest::Certificate::from_pem(&pem).with_context(|| format!("'{}' is not a PEM-encoded certificate", path))?;
429            builder = builder.add_root_certificate(certificate);
430        }
431
432        Ok(Self {
433            client: builder.build().context("cannot build the HTTP client for kasl-server")?,
434            base_url: normalize_url(&config.url),
435        })
436    }
437
438    /// Asks the server whether it is serviceable, and which version it runs.
439    ///
440    /// Unauthenticated: this is the call that tells a misspelled URL from a
441    /// bad token, so it must not need the token to answer.
442    pub async fn health(&self) -> Result<Health> {
443        let url = format!("{}/health", self.base_url);
444        let response = self
445            .client
446            .get(&url)
447            .send()
448            .await
449            .with_context(|| format!("cannot reach kasl-server at {}", self.base_url))?;
450
451        let status = response.status();
452        if !status.is_success() {
453            bail!("{} answered {} instead of a health report", self.base_url, status);
454        }
455
456        // A URL that points at something else entirely - a proxy, a parked
457        // domain - answers 200 with a page. Insisting on the documented shape
458        // keeps that from reading as a healthy server.
459        response
460            .json::<Health>()
461            .await
462            .with_context(|| format!("{} answered, but not like a kasl-server", self.base_url))
463    }
464
465    /// Resolves the agent token to the person it reports for.
466    ///
467    /// Doubles as the token check: the server refuses an unknown, revoked, or
468    /// deactivated token with `401`, which is reported as such rather than as
469    /// a transport failure.
470    pub async fn identify(&self, token: &str) -> Result<AgentIdentity> {
471        let url = format!("{}/api/v1/agent/whoami", self.base_url);
472        let response = self
473            .client
474            .get(&url)
475            .bearer_auth(token)
476            .send()
477            .await
478            .with_context(|| format!("cannot reach kasl-server at {}", self.base_url))?;
479
480        match response.status() {
481            StatusCode::OK => response.json::<AgentIdentity>().await.context("cannot read the server's answer"),
482            StatusCode::UNAUTHORIZED => bail!("the server rejected this token - it may be mistyped, revoked, or issued for a deactivated account"),
483            status => bail!("the server answered {} when asked whose token this is", status),
484        }
485    }
486
487    /// Reads the installation's privacy manifest from
488    /// `GET /api/v1/privacy/agent`.
489    ///
490    /// Answered to the agent token rather than to a login, which is the point
491    /// of the route existing at all: the employee finds out what the server
492    /// keeps about them from the CLI they already run, instead of signing
493    /// into the server that watches them in order to ask.
494    ///
495    /// A `404` means the server predates the route and is reported as such.
496    /// It is the one failure here with a different fix - the administrator
497    /// upgrading the server, not the employee doing anything - and reading it
498    /// as "no manifest" would let an old server look like one that keeps
499    /// nothing. In practice it should not be reachable: `connect` already
500    /// needs 0.14.1 for `whoami`, and the route arrived four minors before
501    /// that. It is handled anyway, because "should not be reachable" is a
502    /// statement about today's floor and not about the wire.
503    pub async fn privacy(&self, token: &str) -> Result<PrivacyManifest> {
504        let url = format!("{}/api/v1/privacy/agent", self.base_url);
505        let response = self
506            .client
507            .get(&url)
508            .bearer_auth(token)
509            .send()
510            .await
511            .with_context(|| format!("cannot reach kasl-server at {}", self.base_url))?;
512
513        match response.status() {
514            StatusCode::OK => response.json::<PrivacyManifest>().await.context("cannot read the server's privacy manifest"),
515            StatusCode::UNAUTHORIZED => bail!("the server rejected this token - it may be mistyped, revoked, or issued for a deactivated account"),
516            StatusCode::NOT_FOUND => bail!("this server does not publish a privacy manifest to agents - the route arrived in kasl-server 0.10.0"),
517            status => bail!("the server answered {} when asked what it stores", status),
518        }
519    }
520
521    /// Sends one day to `POST /api/v1/days`.
522    ///
523    /// The last upload wins on the server, so re-sending a day corrects it
524    /// and sending the same day twice changes nothing (ADR 0004). That makes
525    /// a retry safe by construction, which is what the failure split here is
526    /// for: [`UploadError`] separates a payload the server will never take
527    /// from a server that could not answer this time.
528    pub async fn upload_day(&self, token: &str, day: &DayUpload) -> Result<DayAccepted, UploadError> {
529        let url = format!("{}/api/v1/days", self.base_url);
530        let response = match self.client.post(&url).bearer_auth(token).json(day).send().await {
531            Ok(response) => response,
532            // Nothing was answered: DNS, TLS, a refused connection, a timeout.
533            // The day is untouched on the server and worth sending again.
534            Err(error) => {
535                return Err(UploadError::Retryable {
536                    message: format!("cannot reach kasl-server at {}: {}", self.base_url, error),
537                });
538            }
539        };
540
541        let status = response.status();
542        if status.is_success() {
543            // A 2xx whose body is not a day report means the address answers
544            // for something other than this endpoint. Retrying a URL that is
545            // wrong would never come good, so it is a rejection.
546            return response.json::<DayAccepted>().await.map_err(|error| UploadError::Rejected {
547                status,
548                message: format!("the server accepted the day but answered unreadably: {}", error),
549            });
550        }
551
552        // Read the body before classifying: the server explains a refusal
553        // there ("tasks[0]: name is empty"), and a status alone would leave
554        // the user with nothing to fix.
555        let message = response.text().await.unwrap_or_default();
556        Err(classify_failure(status, describe_failure(status, &message)))
557    }
558
559    /// Sends several days to `POST /api/v1/days/batch`.
560    ///
561    /// The reason a backlog goes this way rather than as a loop of single
562    /// uploads is not the round trips - it is that the agent learns about the
563    /// run as a whole. Each day is written in its own transaction there, so
564    /// one day the server will never accept does not hold back the rest
565    /// (ADR 0005).
566    ///
567    /// The error here covers the *request*: a batch that never arrived, or a
568    /// server that refused the whole shape of it. The fate of the days inside
569    /// a batch that did arrive is in [`BatchResult`], and has to be read per
570    /// day - a `200` here means the request was processed, not that every day
571    /// in it was stored.
572    pub async fn upload_batch(&self, token: &str, days: &[DayUpload]) -> Result<BatchResult, UploadError> {
573        let url = format!("{}/api/v1/days/batch", self.base_url);
574        let batch = BatchUpload { days: days.to_vec() };
575
576        let response = match self.client.post(&url).bearer_auth(token).json(&batch).send().await {
577            Ok(response) => response,
578            Err(error) => {
579                return Err(UploadError::Retryable {
580                    message: format!("cannot reach kasl-server at {}: {}", self.base_url, error),
581                });
582            }
583        };
584
585        let status = response.status();
586        if status.is_success() {
587            return response.json::<BatchResult>().await.map_err(|error| UploadError::Rejected {
588                status,
589                message: format!("the server accepted the batch but answered unreadably: {}", error),
590            });
591        }
592
593        // A batch too large for the server to take is refused whole with 413.
594        // It is a rejection of this request, not of the days: the caller
595        // splits the backlog and the same days go again in smaller groups.
596        let message = response.text().await.unwrap_or_default();
597        Err(classify_failure(status, describe_failure(status, &message)))
598    }
599
600    /// Sends one pulse to `POST /api/v1/agent/heartbeat`.
601    ///
602    /// Failures split the way uploads do (ADR 0005): a server that could not
603    /// answer is worth asking again at the next interval, a refusal is not
604    /// worth asking again soon. A `404` is a server older than the route and
605    /// is named as such, because its fix is the administrator's - and it is
606    /// the one failure an employee who just turned the pulse on is likely to
607    /// meet.
608    pub async fn heartbeat(&self, token: &str, pulse: &Pulse) -> Result<PulseAccepted, UploadError> {
609        let url = format!("{}/api/v1/agent/heartbeat", self.base_url);
610        let response = match self.client.post(&url).bearer_auth(token).json(pulse).send().await {
611            Ok(response) => response,
612            Err(error) => {
613                return Err(UploadError::Retryable {
614                    message: format!("cannot reach kasl-server at {}: {}", self.base_url, error),
615                });
616            }
617        };
618
619        let status = response.status();
620        if status.is_success() {
621            return response.json::<PulseAccepted>().await.map_err(|error| UploadError::Rejected {
622                status,
623                message: format!("the server took the pulse but answered unreadably: {}", error),
624            });
625        }
626
627        if status == StatusCode::NOT_FOUND {
628            return Err(UploadError::Rejected {
629                status,
630                message: "this server does not take a pulse - the route arrived in kasl-server 0.17.0".to_string(),
631            });
632        }
633
634        let message = response.text().await.unwrap_or_default();
635        Err(classify_failure(status, describe_failure(status, &message)))
636    }
637
638    /// The base URL this client talks to, as stored.
639    pub fn base_url(&self) -> &str {
640        &self.base_url
641    }
642}
643
644/// Sorts a failed status into "never going to work" and "try later".
645///
646/// The server's own rule, not a guess (ADR 0005): `4xx` will not be accepted
647/// as sent, `5xx` and `429` are worth repeating. `429` sits inside the `4xx`
648/// range and is the single exception to it.
649///
650/// Shared by both upload paths so the single day and the batch can never
651/// drift into disagreeing about which failures are worth a retry.
652fn classify_failure(status: StatusCode, message: String) -> UploadError {
653    if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
654        UploadError::Retryable { message }
655    } else {
656        UploadError::Rejected { status, message }
657    }
658}
659
660/// Trims a user-typed URL into the form the client stores.
661///
662/// Only the trailing slash is removed. Guessing a scheme is deliberately not
663/// done here: `http://` and `https://` differ by whether the token crosses
664/// the network in the clear, which is not a default worth inventing on the
665/// user's behalf.
666pub fn normalize_url(url: &str) -> String {
667    url.trim().trim_end_matches('/').to_string()
668}
669
670/// Extracts the sentence a person can act on from a failed response.
671///
672/// The server answers errors as JSON (`{"error": "..."}`), and showing that
673/// wrapper verbatim buries the sentence that matters. A body in any other
674/// shape is passed through as-is rather than dropped: an error from a proxy
675/// in front of the server is still the most informative thing available.
676///
677/// The status is deliberately *not* added here. It is already carried by the
678/// error and printed once when the failure is displayed, and some of the
679/// server's own messages open with it too - stamping it on again produced
680/// "the server refused the day (401 Unauthorized): 401 Unauthorized: the
681/// token is not recognized", which reads as three different problems.
682fn describe_failure(status: StatusCode, body: &str) -> String {
683    let body = body.trim();
684    if body.is_empty() {
685        return format!("the server gave no explanation ({})", status);
686    }
687
688    serde_json::from_str::<serde_json::Value>(body)
689        .ok()
690        .and_then(|value| value.get("error").and_then(|error| error.as_str()).map(str::to_string))
691        .unwrap_or_else(|| body.chars().take(300).collect())
692}
693
694/// Whether a file's bytes carry a PEM certificate block.
695///
696/// A deliberately shallow check. It catches the mistakes people actually make
697/// (the wrong file, an empty file, a DER export named `.pem`) and leaves
698/// judging the certificate itself to the TLS backend, which is the only thing
699/// qualified to do so.
700fn looks_like_pem_certificate(pem: &[u8]) -> bool {
701    // Text search over bytes rather than a UTF-8 conversion: a PEM file is
702    // ASCII, but a binary file that is not valid UTF-8 should fail this check
703    // rather than fail to be examined.
704    pem.windows(BEGIN_CERTIFICATE.len()).any(|window| window == BEGIN_CERTIFICATE)
705}
706
707/// The header opening a PEM certificate block.
708const BEGIN_CERTIFICATE: &[u8] = b"-----BEGIN CERTIFICATE-----";
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713
714    #[test]
715    fn normalize_url_drops_a_trailing_slash() {
716        assert_eq!(normalize_url("https://kasl.example.com/"), "https://kasl.example.com");
717        assert_eq!(normalize_url("https://kasl.example.com"), "https://kasl.example.com");
718    }
719
720    #[test]
721    fn normalize_url_trims_surrounding_whitespace() {
722        // Pasting a URL from a chat message routinely brings a space along.
723        assert_eq!(normalize_url("  https://kasl.example.com/  "), "https://kasl.example.com");
724    }
725
726    #[test]
727    fn normalize_url_keeps_a_path_prefix() {
728        // A server behind a reverse proxy can live under a sub-path, and
729        // dropping it would send every request to the proxy's root.
730        assert_eq!(normalize_url("https://intranet.example.com/kasl/"), "https://intranet.example.com/kasl");
731    }
732
733    #[test]
734    fn a_client_is_built_without_a_certificate() {
735        let config = KaslServerConfig {
736            url: "https://kasl.example.com/".to_string(),
737            ca_certificate: None,
738            pulse: false,
739        };
740
741        let server = KaslServer::new(&config).unwrap();
742        assert_eq!(server.base_url(), "https://kasl.example.com");
743    }
744
745    #[test]
746    fn a_missing_certificate_file_is_reported_by_path() {
747        let config = KaslServerConfig {
748            url: "https://kasl.example.com".to_string(),
749            ca_certificate: Some("/nonexistent/company-ca.pem".to_string()),
750            pulse: false,
751        };
752
753        let error = KaslServer::new(&config).unwrap_err().to_string();
754        assert!(error.contains("company-ca.pem"), "the error should name the file: {}", error);
755    }
756
757    /// Writes `bytes` to a uniquely named file and hands back its path.
758    fn certificate_file(name: &str, bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf) {
759        let dir = std::env::temp_dir().join(format!("kasl-ca-test-{}-{}", std::process::id(), name));
760        fs::create_dir_all(&dir).unwrap();
761        let path = dir.join(format!("{name}.pem"));
762        fs::write(&path, bytes).unwrap();
763        (dir, path)
764    }
765
766    /// Every shape of "not a certificate" a person actually hands over.
767    ///
768    /// `reqwest::Certificate::from_pem` accepts all of these without
769    /// complaint - it defers parsing to the TLS backend - so each one used to
770    /// connect happily and fail later as an opaque TLS error.
771    #[test]
772    fn a_certificate_that_is_not_pem_is_refused() {
773        for (name, bytes) in [
774            ("plain-text", &b"this is not a certificate"[..]),
775            ("empty", &b""[..]),
776            ("wrong-pem-block", &b"-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----\n"[..]),
777            // A DER export saved with a .pem name: binary, and not valid UTF-8.
778            ("der-as-pem", &[0x30u8, 0x82, 0x01, 0x0a, 0xff, 0xfe][..]),
779        ] {
780            let (dir, path) = certificate_file(name, bytes);
781
782            let config = KaslServerConfig {
783                url: "https://kasl.example.com".to_string(),
784                ca_certificate: Some(path.to_string_lossy().into_owned()),
785                pulse: false,
786            };
787
788            let error = match KaslServer::new(&config) {
789                Ok(_) => panic!("'{name}' should not have been accepted as a certificate"),
790                Err(error) => error.to_string(),
791            };
792            assert!(error.contains("PEM"), "the error for '{}' should say the file is not PEM: {}", name, error);
793            assert!(error.contains(name), "the error for '{}' should name the file: {}", name, error);
794
795            let _ = fs::remove_dir_all(&dir);
796        }
797    }
798
799    #[test]
800    fn a_real_certificate_block_is_accepted() {
801        // The counterpart to the test above: the check must not refuse the
802        // file it exists to let through. Body content is left to the TLS
803        // backend - what is asserted here is that a PEM block gets that far.
804        let (dir, path) = certificate_file("company-ca", b"-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKZ\n-----END CERTIFICATE-----\n");
805
806        let config = KaslServerConfig {
807            url: "https://kasl.example.com".to_string(),
808            ca_certificate: Some(path.to_string_lossy().into_owned()),
809            pulse: false,
810        };
811
812        // Accepted by our check; whether the bytes decode is the backend's
813        // call, and either answer here means the shallow check let it through.
814        let _ = KaslServer::new(&config);
815
816        let _ = fs::remove_dir_all(&dir);
817    }
818}