From 86f50e0356e4b661be43c2aeba97a67d83910095 Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Wed, 20 Dec 2023 09:03:04 +0000 Subject: [PATCH] feat: add semver checking to client requests (#1456) * feat: add semver checking to client requests This enforces that the client and the server run the same major version in order to sync successfully. We're using the `Atuin-Version` http header to transfer this information If the user is not on the same MAJOR, then they will see an error like this > Atuin version mismatch! In order to successfully sync, the client and the server must run the same *major* version > Client: 17.1.0 > Server: 18.1.0 > Error: could not sync records due to version mismatch This change means two things 1. We will now only increment major versions if there is a breaking change for sync 2. We can now add breaking changes to sync, for any version >17.1.0. Clients will fail in a meaningful way. * lint, fmt, etc * only check for client newer than server * Add version header to client too --- Cargo.lock | 2 ++ atuin-client/src/api_client.rs | 57 ++++++++++++++++++++++++++++++++-- atuin-common/Cargo.toml | 3 ++ atuin-common/src/api.rs | 11 +++++++ atuin-server/src/router.rs | 15 +++++++-- 5 files changed, 84 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3605105..f84e25c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,8 +249,10 @@ name = "atuin-common" version = "17.1.0" dependencies = [ "eyre", + "lazy_static", "pretty_assertions", "rand", + "semver", "serde", "sqlx", "time", diff --git a/atuin-client/src/api_client.rs b/atuin-client/src/api_client.rs index ef966f5c..ae8df5ad 100644 --- a/atuin-client/src/api_client.rs +++ b/atuin-client/src/api_client.rs @@ -5,10 +5,9 @@ use std::time::Duration; use eyre::{bail, Result}; use reqwest::{ header::{HeaderMap, AUTHORIZATION, USER_AGENT}, - StatusCode, Url, + Response, StatusCode, Url, }; -use atuin_common::record::{EncryptedData, HostId, Record, RecordId}; use atuin_common::{ api::{ AddHistoryRequest, CountResponse, DeleteHistoryRequest, ErrorResponse, IndexResponse, @@ -16,6 +15,10 @@ use atuin_common::{ }, record::RecordIndex, }; +use atuin_common::{ + api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ATUIN_VERSION}, + record::{EncryptedData, HostId, Record, RecordId}, +}; use semver::Version; use time::format_description::well_known::Rfc3339; use time::OffsetDateTime; @@ -52,10 +55,15 @@ pub async fn register( let resp = client .post(url) .header(USER_AGENT, APP_USER_AGENT) + .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION) .json(&map) .send() .await?; + if !ensure_version(&resp)? { + bail!("could not register user due to version mismatch"); + } + if !resp.status().is_success() { let error = resp.json::().await?; bail!("failed to register user: {}", error.reason); @@ -76,6 +84,10 @@ pub async fn login(address: &str, req: LoginRequest) -> Result { .send() .await?; + if !ensure_version(&resp)? { + bail!("could not login due to version mismatch"); + } + if resp.status() != reqwest::StatusCode::OK { let error = resp.json::().await?; bail!("invalid login details: {}", error.reason); @@ -106,6 +118,31 @@ pub async fn latest_version() -> Result { Ok(version) } +pub fn ensure_version(response: &Response) -> Result { + let version = response.headers().get(ATUIN_HEADER_VERSION); + + let version = if let Some(version) = version { + match version.to_str() { + Ok(v) => Version::parse(v), + Err(e) => bail!("failed to parse server version: {:?}", e), + } + } else { + // if there is no version header, then the newest this server can possibly be is 17.1.0 + Version::parse("17.1.0") + }?; + + // If the client is newer than the server + if version.major < ATUIN_VERSION.major { + println!("Atuin version mismatch! In order to successfully sync, the server needs to run a newer version of Atuin"); + println!("Client: {}", ATUIN_CARGO_VERSION); + println!("Server: {}", version); + + return Ok(false); + } + + Ok(true) +} + impl<'a> Client<'a> { pub fn new( sync_addr: &'a str, @@ -116,6 +153,9 @@ impl<'a> Client<'a> { let mut headers = HeaderMap::new(); headers.insert(AUTHORIZATION, format!("Token {session_token}").parse()?); + // used for semver server check + headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?); + Ok(Client { sync_addr, client: reqwest::Client::builder() @@ -133,6 +173,10 @@ impl<'a> Client<'a> { let resp = self.client.get(url).send().await?; + if !ensure_version(&resp)? { + bail!("could not sync due to version mismatch"); + } + if resp.status() != StatusCode::OK { bail!("failed to get count (are you logged in?)"); } @@ -148,6 +192,10 @@ impl<'a> Client<'a> { let resp = self.client.get(url).send().await?; + if !ensure_version(&resp)? { + bail!("could not sync due to version mismatch"); + } + if resp.status() != StatusCode::OK { bail!("failed to get status (are you logged in?)"); } @@ -262,6 +310,11 @@ impl<'a> Client<'a> { let url = Url::parse(url.as_str())?; let resp = self.client.get(url).send().await?; + + if !ensure_version(&resp)? { + bail!("could not sync records due to version mismatch"); + } + let index = resp.json().await?; Ok(index) diff --git a/atuin-common/Cargo.toml b/atuin-common/Cargo.toml index 88c3022e..847cea96 100644 --- a/atuin-common/Cargo.toml +++ b/atuin-common/Cargo.toml @@ -20,6 +20,9 @@ rand = { workspace = true } typed-builder = { workspace = true } eyre = { workspace = true } sqlx = { workspace = true } +semver = { workspace = true } + +lazy_static = "1.4.0" [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/atuin-common/src/api.rs b/atuin-common/src/api.rs index ddcc0b09..b608937f 100644 --- a/atuin-common/src/api.rs +++ b/atuin-common/src/api.rs @@ -1,7 +1,18 @@ +use lazy_static::lazy_static; +use semver::Version; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use time::OffsetDateTime; +// the usage of X- has been deprecated for quite along time, it turns out +pub static ATUIN_HEADER_VERSION: &str = "Atuin-Version"; +pub static ATUIN_CARGO_VERSION: &str = env!("CARGO_PKG_VERSION"); + +lazy_static! { + pub static ref ATUIN_VERSION: Version = + Version::parse(ATUIN_CARGO_VERSION).expect("failed to parse self semver"); +} + #[derive(Debug, Serialize, Deserialize)] pub struct UserResponse { pub username: String, diff --git a/atuin-server/src/router.rs b/atuin-server/src/router.rs index 90e726d3..581886de 100644 --- a/atuin-server/src/router.rs +++ b/atuin-server/src/router.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use atuin_common::api::ErrorResponse; +use atuin_common::api::{ErrorResponse, ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION}; use axum::{ extract::FromRequestParts, http::Request, @@ -91,6 +91,16 @@ async fn clacks_overhead(request: Request, next: Next) -> Response { response } +/// Ensure that we only try and sync with clients on the same major version +async fn semver(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + response + .headers_mut() + .insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse().unwrap()); + + response +} + #[derive(Clone)] pub struct AppState { pub database: DB, @@ -126,6 +136,7 @@ pub fn router(database: DB, settings: Settings) -> R ServiceBuilder::new() .layer(axum::middleware::from_fn(clacks_overhead)) .layer(TraceLayer::new_for_http()) - .layer(axum::middleware::from_fn(metrics::track_metrics)), + .layer(axum::middleware::from_fn(metrics::track_metrics)) + .layer(axum::middleware::from_fn(semver)), ) }