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