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}