From 73956a5f7028dba980f90f4493060bfd642765b0 Mon Sep 17 00:00:00 2001 From: Luke Granger-Brown Date: Sun, 8 Jan 2023 04:29:44 +0000 Subject: [PATCH] passgen: restructure things into a lib and a main --- rust/passgen/Cargo.lock | 1 + rust/passgen/Cargo.toml | 1 + rust/passgen/src/add_to_wallet.rs | 93 +++ rust/passgen/src/insert_or_update.rs | 133 ++++ rust/passgen/src/lib.rs | 7 + rust/passgen/src/main.rs | 915 ++------------------------- rust/passgen/src/resolution792.rs | 723 +++++++++++++++++++++ rust/passgen/src/scanner.rs | 18 + rust/passgen/src/service_account.rs | 42 ++ rust/passgen/src/static_data.rs | 37 ++ rust/passgen/src/walletobjects.rs | 94 +++ 11 files changed, 1185 insertions(+), 879 deletions(-) create mode 100644 rust/passgen/src/add_to_wallet.rs create mode 100644 rust/passgen/src/insert_or_update.rs create mode 100644 rust/passgen/src/lib.rs create mode 100644 rust/passgen/src/resolution792.rs create mode 100644 rust/passgen/src/scanner.rs create mode 100644 rust/passgen/src/service_account.rs create mode 100644 rust/passgen/src/static_data.rs create mode 100644 rust/passgen/src/walletobjects.rs diff --git a/rust/passgen/Cargo.lock b/rust/passgen/Cargo.lock index 484372453b..19ed65a246 100644 --- a/rust/passgen/Cargo.lock +++ b/rust/passgen/Cargo.lock @@ -1646,6 +1646,7 @@ dependencies = [ "async-trait", "chrono", "clap", + "futures-util", "google-walletobjects1", "http", "hyper", diff --git a/rust/passgen/Cargo.toml b/rust/passgen/Cargo.toml index 633d13b583..fbc8986c87 100644 --- a/rust/passgen/Cargo.toml +++ b/rust/passgen/Cargo.toml @@ -10,6 +10,7 @@ anyhow = { version = "1.0.68", features = ["backtrace"] } async-trait = "0.1.61" chrono = "0.4.23" clap = { version = "4.0.32", features = ["cargo", "derive"] } +futures-util = "0.3.25" google-walletobjects1 = "4.0.4" http = "0.2.8" hyper = "0.14.23" diff --git a/rust/passgen/src/add_to_wallet.rs b/rust/passgen/src/add_to_wallet.rs new file mode 100644 index 0000000000..fe159af94f --- /dev/null +++ b/rust/passgen/src/add_to_wallet.rs @@ -0,0 +1,93 @@ +use crate::service_account::ServiceAccount; + +use anyhow::Result; +use jwt_simple::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +struct AddToWalletPayloadObject { + id: String, + + #[serde(rename = "classId", skip_serializing_if = "Option::is_none")] + class_id: Option, +} + +#[derive(Serialize, Deserialize)] +struct AddToWalletPayload { + #[serde(skip_serializing_if = "Vec::is_empty", rename = "flightClasses")] + flight_classes: Vec, + + #[serde(skip_serializing_if = "Vec::is_empty", rename = "flightObjects")] + flight_objects: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct AddToWalletClaims { + origins: Vec, + + #[serde(rename = "typ")] + jwt_type: String, + + payload: AddToWalletPayload, +} + +pub struct PassClassIdentifier { + pub id: String, +} + +pub struct PassIdentifier { + pub id: String, + pub class_id: String, +} + +pub struct AddToWallet { + pub service_account_email: String, + + pub flight_classes: Vec, + pub flight_passes: Vec, +} + +impl AddToWallet { + pub fn to_claims(&self) -> JWTClaims { + JWTClaims { + issued_at: None, + expires_at: None, + invalid_before: None, + audiences: Some(jwt_simple::claims::Audiences::AsString( + "google".to_string(), + )), + issuer: Some(self.service_account_email.to_string()), + jwt_id: None, + subject: None, + nonce: None, + custom: AddToWalletClaims { + jwt_type: "savetowallet".to_string(), + origins: vec!["www.lukegb.com".to_string()], + payload: AddToWalletPayload { + flight_classes: self + .flight_classes + .iter() + .map(|c| AddToWalletPayloadObject { + id: c.id.to_string(), + class_id: None, + }) + .collect(), + flight_objects: self + .flight_passes + .iter() + .map(|o| AddToWalletPayloadObject { + id: o.id.to_string(), + class_id: Some(o.class_id.to_string()), + }) + .collect(), + }, + }, + } + } + + pub fn to_url(&self, sa: &ServiceAccount) -> Result { + let claims = self.to_claims(); + let token = sa.key_pair.sign(claims)?; + Ok(format!("https://pay.google.com/gp/v/save/{}", token)) + } +} diff --git a/rust/passgen/src/insert_or_update.rs b/rust/passgen/src/insert_or_update.rs new file mode 100644 index 0000000000..56f418acfb --- /dev/null +++ b/rust/passgen/src/insert_or_update.rs @@ -0,0 +1,133 @@ +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use futures_util::future::try_join_all; +use google_walletobjects1::api::{ + FlightClass, FlightObject, FlightclasMethods, FlightobjectMethods, +}; +use std::sync::Arc; + +#[async_trait] +pub trait InsertOrUpdate { + async fn insert_now(&self, value: &T) -> Result; + async fn update_now(&self, value: &T) -> Result<()>; +} + +fn is_already_exists(err: &google_walletobjects1::Error) -> bool { + match err { + google_walletobjects1::Error::BadRequest(v) => match v.pointer("/error/code") { + Some(num) => num == 409, + None => false, + }, + _ => false, + } +} + +#[async_trait] +impl InsertOrUpdate for FlightclasMethods<'_, S> +where + S: tower_service::Service + Clone + Send + Sync + 'static, + S::Response: hyper::client::connect::Connection + + tokio::io::AsyncRead + + tokio::io::AsyncWrite + + Send + + Unpin + + 'static, + S::Future: Send + Unpin + 'static, + S::Error: Into>, +{ + async fn insert_now(&self, value: &FlightClass) -> Result { + self.insert(value.clone()) + .doit() + .await + .and(Ok(true)) + .or_else(|e| { + if is_already_exists(&e) { + Ok(false) + } else { + Err(anyhow!(e)) + } + }) + } + async fn update_now(&self, value: &FlightClass) -> Result<()> { + self.update( + value.clone(), + &value + .id + .clone() + .ok_or_else(|| anyhow!("no id on FlightClass"))?, + ) + .doit() + .await?; + Ok(()) + } +} + +#[async_trait] +impl InsertOrUpdate for FlightobjectMethods<'_, S> +where + S: tower_service::Service + Clone + Send + Sync + 'static, + S::Response: hyper::client::connect::Connection + + tokio::io::AsyncRead + + tokio::io::AsyncWrite + + Send + + Unpin + + 'static, + S::Future: Send + Unpin + 'static, + S::Error: Into>, +{ + async fn insert_now(&self, value: &FlightObject) -> Result { + self.insert(value.clone()) + .doit() + .await + .and(Ok(true)) + .or_else(|e| { + if is_already_exists(&e) { + Ok(false) + } else { + Err(anyhow!(e)) + } + }) + } + async fn update_now(&self, value: &FlightObject) -> Result<()> { + self.update( + value.clone(), + &value + .id + .clone() + .ok_or_else(|| anyhow!("no id on FlightObject"))?, + ) + .doit() + .await?; + Ok(()) + } +} + +pub async fn insert_or_update_now(into: &dyn InsertOrUpdate, value: &T) -> Result<()> { + let created: bool = into.insert_now(value).await?; + if !created { + into.update_now(value).await?; + } + Ok(()) +} + +pub async fn insert_or_update_now_arc( + into: Arc<&dyn InsertOrUpdate>, + value: &T, +) -> Result<()> { + insert_or_update_now(*into.as_ref(), value).await?; + Ok(()) +} + +pub async fn insert_or_update_many( + into: &dyn InsertOrUpdate, + values: Vec, +) -> Result<()> { + let i = Arc::new(into); + try_join_all( + values + .iter() + .map(|c| insert_or_update_now_arc(i.clone(), c)), + ) + .await?; + Ok(()) +} diff --git a/rust/passgen/src/lib.rs b/rust/passgen/src/lib.rs new file mode 100644 index 0000000000..fc5e9ac7eb --- /dev/null +++ b/rust/passgen/src/lib.rs @@ -0,0 +1,7 @@ +pub mod add_to_wallet; +pub mod insert_or_update; +pub mod resolution792; +pub mod scanner; +pub mod service_account; +pub mod static_data; +pub mod walletobjects; diff --git a/rust/passgen/src/main.rs b/rust/passgen/src/main.rs index 5915432f0f..452a53c6b8 100644 --- a/rust/passgen/src/main.rs +++ b/rust/passgen/src/main.rs @@ -1,33 +1,19 @@ -#![allow(dead_code)] - use std::path::PathBuf; use anyhow::{anyhow, Result}; -use chrono::naive::NaiveDate; -use chrono::Datelike; use chrono::Local; -use std::cmp::min; - use clap::{Args, Parser, Subcommand}; - -use async_trait::async_trait; -use google_walletobjects1::api::{ - FlightClass, FlightObject, FlightclasMethods, FlightobjectMethods, -}; -use google_walletobjects1::oauth2::authenticator::{ - Authenticator, DefaultHyperClient, HyperClientBuilder, -}; -use google_walletobjects1::oauth2::{read_service_account_key, ServiceAccountAuthenticator}; -use jwt_simple::algorithms::{RS256KeyPair, RSAKeyPairLike}; -use jwt_simple::claims::JWTClaims; -use strum::IntoEnumIterator; -use strum_macros::EnumIter; - -use serde::{Deserialize, Serialize}; - -use phf::phf_map; +use google_walletobjects1::api::{FlightClass, FlightObject}; +use serde::Deserialize; use tokio::try_join; +use passgen::add_to_wallet::{AddToWallet, PassClassIdentifier, PassIdentifier}; +use passgen::insert_or_update::*; +use passgen::resolution792::BoardingPass; +use passgen::scanner::scan; +use passgen::service_account::load_service_account; +use passgen::walletobjects::*; + #[derive(Parser)] #[command(author, version, about, long_about = None, propagate_version = true, subcommand_required = true, arg_required_else_help = true)] struct Cli { @@ -95,742 +81,11 @@ struct UploadScan { image: PathBuf, } -struct AirlineDefinition<'a> { - iata_code: &'a str, - name: &'a str, - boarding_policy: &'a str, - seat_class_policy: &'a str, - - boarding_pass_background_colour: &'a str, - - frequent_flyer_program_name: Option<&'a str>, - - logo_url: &'a str, - alliance_logo_url: Option<&'a str>, - hero_image_logo_url: Option<&'a str>, - boarding_privilege_logo_url: Option<&'a str>, -} - -const TSA_PRECHECK_LOGO: &str = "https://p.lukegb.com/raw/MiserablyDirectPiglet.jpg"; - -static AIRLINE_DATA: phf::Map<&'static str, AirlineDefinition<'static>> = phf_map! { - "VS" => AirlineDefinition{ - iata_code: "VS", - name: "Virgin Atlantic", - boarding_policy: "GROUP_BASED", - seat_class_policy: "CABIN_BASED", - - boarding_pass_background_colour: "#4f145b", - - frequent_flyer_program_name: Some("Flying Club"), - - logo_url: "https://p.lukegb.com/raw/VirtuallyCrispViper.png", - alliance_logo_url: None, - hero_image_logo_url: Some("https://p.lukegb.com/raw/FormerlyDistinctToad.png"), - boarding_privilege_logo_url: Some("https://p.lukegb.com/raw/DefinitelyVerifiedTitmouse.png"), - }, -}; - -fn scan(image_path: &PathBuf) -> Result { - let image_path_str = (*image_path).to_str().ok_or(anyhow!("invalid path"))?; - rxing::helpers::detect_in_file(image_path_str, Some(rxing::BarcodeFormat::AZTEC)) - .map_err(|e| { - anyhow!( - "could not parse Aztec barcode from image {}: {}", - image_path_str, - e - ) - }) - .map(|r| r.getText().clone()) -} - -struct ServiceAccount { - service_account_name: String, - authenticator: Authenticator<::Connector>, - key_pair: RS256KeyPair, -} - -#[derive(Deserialize)] -struct ServiceAccountJSON { - // We omit a lot of fields we don't care about. - /// Service Account email - client_email: String, - - /// RSA private key - private_key: String, -} - -async fn load_service_account(path: &PathBuf) -> Result { - let creds = read_service_account_key(path).await?; - let sa = ServiceAccountAuthenticator::builder(creds).build().await?; - - let sa_data = tokio::fs::read(path).await?; - let sa_parsed: ServiceAccountJSON = serde_json::from_slice(&sa_data[..])?; - let key_pair = RS256KeyPair::from_pem(&sa_parsed.private_key)?; - - Ok(ServiceAccount { - service_account_name: sa_parsed.client_email, - authenticator: sa, - key_pair: key_pair, - }) -} - async fn load_thing<'a, T: Deserialize<'a>>(path: &PathBuf) -> Result { let data = tokio::fs::read(path).await?; let mut deser = serde_json::Deserializer::from_reader(data.as_slice()); let parsed: T = T::deserialize(&mut deser)?; - return Ok(parsed); -} - -#[async_trait] -trait InsertOrUpdate { - async fn insert_now(&self, value: &T) -> Result; - async fn update_now(&self, value: &T) -> Result<()>; -} - -fn is_already_exists(err: &google_walletobjects1::Error) -> bool { - match err { - google_walletobjects1::Error::BadRequest(v) => match v.pointer("/error/code") { - Some(num) => num == 409, - None => false, - }, - _ => false, - } -} - -#[async_trait] -impl InsertOrUpdate for FlightclasMethods<'_, S> -where - S: tower_service::Service + Clone + Send + Sync + 'static, - S::Response: hyper::client::connect::Connection - + tokio::io::AsyncRead - + tokio::io::AsyncWrite - + Send - + Unpin - + 'static, - S::Future: Send + Unpin + 'static, - S::Error: Into>, -{ - async fn insert_now(&self, value: &FlightClass) -> Result { - self.insert(value.clone()) - .doit() - .await - .and(Ok(true)) - .or_else(|e| { - if is_already_exists(&e) { - Ok(false) - } else { - Err(anyhow!(e)) - } - }) - } - async fn update_now(&self, value: &FlightClass) -> Result<()> { - self.update( - value.clone(), - &value.id.clone().ok_or(anyhow!("no id on FlightClass"))?, - ) - .doit() - .await?; - Ok(()) - } -} - -#[async_trait] -impl InsertOrUpdate for FlightobjectMethods<'_, S> -where - S: tower_service::Service + Clone + Send + Sync + 'static, - S::Response: hyper::client::connect::Connection - + tokio::io::AsyncRead - + tokio::io::AsyncWrite - + Send - + Unpin - + 'static, - S::Future: Send + Unpin + 'static, - S::Error: Into>, -{ - async fn insert_now(&self, value: &FlightObject) -> Result { - self.insert(value.clone()) - .doit() - .await - .and(Ok(true)) - .or_else(|e| { - if is_already_exists(&e) { - Ok(false) - } else { - Err(anyhow!(e)) - } - }) - } - async fn update_now(&self, value: &FlightObject) -> Result<()> { - self.update( - value.clone(), - &value.id.clone().ok_or(anyhow!("no id on FlightObject"))?, - ) - .doit() - .await?; - Ok(()) - } -} - -#[derive(Serialize, Deserialize)] -struct AddToWalletPayloadObject { - id: String, - - #[serde(rename = "classId", skip_serializing_if = "Option::is_none")] - class_id: Option, -} - -#[derive(Serialize, Deserialize)] -struct AddToWalletPayload { - #[serde(skip_serializing_if = "Vec::is_empty", rename = "flightClasses")] - flight_classes: Vec, - - #[serde(skip_serializing_if = "Vec::is_empty", rename = "flightObjects")] - flight_objects: Vec, -} - -#[derive(Serialize, Deserialize)] -struct AddToWalletClaims { - origins: Vec, - - #[serde(rename = "typ")] - jwt_type: String, - - payload: AddToWalletPayload, -} - -#[derive(Clone, Copy, Debug, EnumIter)] -#[repr(u8)] -enum PassengerStatus { - NotCheckedIn = b'0', - CheckedIn = b'1', - BagsCheckedAndNotCheckedIn = b'2', - BagsCheckedAndCheckedIn = b'3', - PastSecurity = b'4', - PastGate = b'5', - Transit = b'6', - Standby = b'7', - BoardingDataRevalidated = b'8', - OriginalBoardingLineUsedWhenTicketIssued = b'9', - UpOrDownGradingPending = b'A', -} - -impl PassengerStatus { - fn from(c: u8) -> Result { - for v in PassengerStatus::iter() { - if v as u8 == c { - return Ok(v); - } - } - Err(anyhow!( - "{} ({}) is not a valid PassengerStatus value", - c, - c as char - )) - } -} - -#[derive(Clone, Copy, Debug, EnumIter)] -#[repr(u8)] -enum DocumentationVerificationStatus { - NotRequired = b'0', - Required = b'1', - Performed = b'2', -} - -impl DocumentationVerificationStatus { - fn from(c: u8) -> Result { - for v in DocumentationVerificationStatus::iter() { - if v as u8 == c { - return Ok(v); - } - } - Err(anyhow!( - "{} ({}) is not a valid DocumentationVerificationStatus value", - c, - c as char - )) - } -} - -#[derive(Clone, Copy, Debug, EnumIter)] -#[repr(u8)] -enum PassengerDescription { - Adult = b'0', - Male = b'1', - Female = b'2', - Child = b'3', - Infant = b'4', - NoPassenger = b'5', // cabin baggage - AdultWithInfant = b'6', - UnaccompaniedMinor = b'7', -} - -impl PassengerDescription { - fn from(c: u8) -> Result { - for v in PassengerDescription::iter() { - if v as u8 == c { - return Ok(v); - } - } - Err(anyhow!( - "{} ({}) is not a valid PassengerDescription value", - c, - c as char - )) - } -} - -#[derive(Clone, Copy, Debug, EnumIter)] -#[repr(u8)] -enum CheckInSource { - Web = b'W', - AirportKiosk = b'K', - RemoteKiosk = b'R', - MobileDevice = b'M', - AirportAgent = b'O', - TownAgent = b'T', - ThirdPartyVendor = b'V', -} - -impl CheckInSource { - fn from(c: u8) -> Result { - for v in CheckInSource::iter() { - if v as u8 == c { - return Ok(v); - } - } - Err(anyhow!( - "{} ({}) is not a valid CheckInSource value", - c, - c as char - )) - } -} - -#[derive(Debug)] -struct BoardingPass { - passenger_name: String, - eticket_indicator: char, - - version_number: Option, - passenger_description: Option, - checkin_source: Option, - boarding_pass_source: Option, - date_of_issue: Option, - document_type: Option, - boarding_pass_issuing_airline: Option, - baggage_tag_plate_number: Option, - - legs: Vec, - - security_data_type: u8, - security_data: Vec, -} - -fn parse_boarding_pass_date( - from_boarding_pass_str: &str, - nearby_date: &NaiveDate, -) -> Result { - // Range is 0 to 9999 (although not really). - let from_boarding_pass: u16 = from_boarding_pass_str.parse()?; - - match from_boarding_pass_str.len() { - 4 => Ok(NaiveDate::from_yo_opt( - (nearby_date.year() / 10 * 10) + ((from_boarding_pass / 1000) as i32), - (from_boarding_pass % 1000).into(), - ) - .ok_or(anyhow!("could not parse date {}", from_boarding_pass_str))?), - 3 => { - // Find the one that's the fewest days away. - let year = nearby_date.year(); - let day: u32 = (from_boarding_pass % 1000).into(); - let mut options = vec![ - NaiveDate::from_yo_opt(year, day), - NaiveDate::from_yo_opt(year - 1, day), - NaiveDate::from_yo_opt(year + 1, day), - ]; - options.sort_by(|a, b| { - days_between(nearby_date, a) - .unwrap_or(9999) - .cmp(&days_between(nearby_date, b).unwrap_or(9999)) - }); - Ok(options[0].unwrap()) - } - _ => Err(anyhow!( - "boarding pass date {} is wrong length", - from_boarding_pass_str - )), - } -} - -fn days_between(nearby_date: &NaiveDate, maybe_date: &Option) -> Option { - if maybe_date.is_none() { - return None; - } - let is_date = maybe_date.as_ref().unwrap(); - let days_between = (*nearby_date - *is_date).num_days(); - Some(if days_between < 0 { - (-days_between) as u64 - } else { - days_between as u64 - }) -} - -impl BoardingPass { - fn parse(data: Vec, nearby_date: &NaiveDate) -> Result { - if data[0] != b'M' { - return Err(anyhow!( - "format code was {} ({}), not M", - data[0] as char, - data[0] - )); - } - let leg_count = match data[1] { - b'1' => Ok(1), - b'2' => Ok(2), - b'3' => Ok(3), - b'4' => Ok(4), - _ => Err(anyhow!( - "leg count was {} ({}), should be 1 to 4", - data[1] as char, - data[1] - )), - }?; - - let passenger_name: String = std::str::from_utf8(&data[2..22])?.trim_end().to_string(); - let eticket_indicator: char = data[22] as char; - - // First leg mandatory data: - let (first_leg, mut p) = BoardingPassLeg::parse_mandatory(&data[23..], nearby_date)?; - p += 23; - - // We now have enough to start composing the boardingpass. - let mut boarding_pass = BoardingPass { - passenger_name, - eticket_indicator, - - version_number: None, - passenger_description: None, - checkin_source: None, - boarding_pass_source: None, - date_of_issue: None, - document_type: None, - boarding_pass_issuing_airline: None, - baggage_tag_plate_number: None, - - legs: vec![first_leg], - - security_data_type: 0xff, - security_data: vec![], - }; - - // Header + first leg optional data (why, IATA, why) - let optional_data_size: usize = - usize::from_str_radix(std::str::from_utf8(&data[p..p + 2])?, 16)?; - let mut optional_data = &data[p + 2..p + 2 + optional_data_size]; - p += 2 + optional_data_size; - if optional_data.len() >= 1 { - if optional_data[0] != b'>' { - return Err(anyhow!("expected Beginning of version number ('>') at beginning of conditional fields, got {} ({})", optional_data[0], optional_data[0] as char)); - } - optional_data = &optional_data[1..]; - } - if optional_data.len() >= 1 { - boarding_pass.version_number = Some(optional_data[0] as char); - optional_data = &optional_data[1..]; - } - if optional_data.len() >= 1 { - let optional_data_unique_size: usize = - usize::from_str_radix(std::str::from_utf8(&optional_data[0..2])?, 16)?; - let mut optional_data_unique = &optional_data[2..optional_data_unique_size + 2]; - if optional_data_unique.len() >= 1 { - boarding_pass.passenger_description = if optional_data_unique[0] == b' ' { - None - } else { - Some(PassengerDescription::from(optional_data_unique[0])?) - }; - optional_data_unique = &optional_data_unique[1..]; - } - if optional_data_unique.len() >= 1 { - boarding_pass.checkin_source = if optional_data_unique[0] == b' ' { - None - } else { - Some(CheckInSource::from(optional_data_unique[0])?) - }; - optional_data_unique = &optional_data_unique[1..]; - } - if optional_data_unique.len() >= 1 { - boarding_pass.boarding_pass_source = if optional_data_unique[0] == b' ' { - None - } else { - Some(CheckInSource::from(optional_data_unique[0])?) - }; - optional_data_unique = &optional_data_unique[1..]; - } - if optional_data_unique.len() >= 4 { - let date_of_issue_str = std::str::from_utf8(&optional_data_unique[0..4])?; - boarding_pass.date_of_issue = if date_of_issue_str == " " { - None - } else { - Some(parse_boarding_pass_date(date_of_issue_str, nearby_date)?) - }; - optional_data_unique = &optional_data_unique[4..]; - } - // TODO: the above might fall through if we don't have 4 bytes of data - if optional_data_unique.len() >= 1 { - boarding_pass.document_type = if optional_data_unique[0] == b' ' { - None - } else { - Some(optional_data_unique[0] as char) - }; - optional_data_unique = &optional_data_unique[1..]; - } - if optional_data_unique.len() >= 3 { - let issuing_airline_str = - std::str::from_utf8(&optional_data_unique[0..3])?.trim_end(); - boarding_pass.boarding_pass_issuing_airline = if issuing_airline_str == "" { - None - } else { - Some(issuing_airline_str.to_string()) - }; - optional_data_unique = &optional_data_unique[3..]; - } - // TODO: the above might fall through if we don't have 3 bytes of data - let bag_tags_str_len = min(13, optional_data_unique.len()); - let bag_tags_str = - std::str::from_utf8(&optional_data_unique[0..bag_tags_str_len])?.trim_end(); - boarding_pass.baggage_tag_plate_number = if bag_tags_str == "" { - None - } else { - Some(bag_tags_str.to_string()) - }; - optional_data_unique = &optional_data_unique[bag_tags_str_len..]; - if optional_data_unique.len() != 0 { - return Err(anyhow!( - "trailing unique optional data ({} bytes)", - optional_data_unique.len() - )); - } - optional_data = &optional_data[optional_data_unique_size + 2..]; - } - - // Now for the repeated optional data for the first leg. - boarding_pass.legs[0].add_optional(optional_data)?; - - // Now any additional legs(!) - for _legnum in 1..leg_count { - let (mut leg, leg_bytes) = BoardingPassLeg::parse_mandatory(&data[p..], nearby_date)?; - p += leg_bytes; - - let optional_data_size: usize = - usize::from_str_radix(std::str::from_utf8(&data[p..p + 2])?, 16)?; - let optional_data = &data[p + 2..p + 2 + optional_data_size]; - p += 2 + optional_data_size; - - leg.add_optional(optional_data)?; - boarding_pass.legs.push(leg); - } - - // What's left is the security data. - if data[p] != b'^' { - return Err(anyhow!( - "expected security data beginning indicator (^) but got {} ({})", - data[p], - data[p] as char - )); - } - boarding_pass.security_data_type = data[p + 1]; - let security_data_size: usize = - usize::from_str_radix(std::str::from_utf8(&data[p + 2..p + 4])?, 16)?; - boarding_pass.security_data = data[p + 4..p + 4 + security_data_size].to_vec(); - if data.len() > p + 4 + security_data_size { - return Err(anyhow!( - "trailing bytes after security data (expected {} bytes of security data)", - security_data_size - )); - } - - Ok(boarding_pass) - } -} - -#[derive(Debug)] -struct BoardingPassLeg { - operating_carrier_pnr: String, // 7, left justified w/ trailing blanks - origin_airport: String, // 3 - destination_airport: String, // 3 - operating_carrier: String, // 3, left justified w/ trailing blanks - flight_number: String, // 5, NNNN[a] (leading zeros + alpha/blank on last digit) - date_of_flight: NaiveDate, // 3, leading zeros (this is the day in the year e.g. 001 = Jan 1st, - // 365 == Dec 31st) - compartment_code: char, - seat_number: String, // 4, NNNa (leading zeros) - check_in_sequence: String, // NNNN[f] (leading zeros + alpha/blank on last digit) - passenger_status: PassengerStatus, - - airline_numeric_code: Option, - document_serial_number: Option, - selectee_indicator: Option, - documentation_verification: Option, - marketing_carrier: Option, - frequent_flyer_airline: Option, - frequent_flyer_number: Option, - id_ad_indicator: Option, - free_baggage_allowance: Option, - airline_data: Option>, -} - -impl BoardingPassLeg { - fn parse_mandatory(data: &[u8], nearby_date: &NaiveDate) -> Result<(BoardingPassLeg, usize)> { - let operating_carrier_pnr: String = - std::str::from_utf8(&data[0..7])?.trim_end().to_string(); - let origin_airport: String = std::str::from_utf8(&data[7..10])?.to_string(); - let destination_airport: String = std::str::from_utf8(&data[10..13])?.to_string(); - let operating_carrier: String = std::str::from_utf8(&data[13..16])?.trim_end().to_string(); - let flight_number: String = std::str::from_utf8(&data[16..21])? - .trim_end() - .trim_start_matches('0') - .to_string(); - let date_of_flight_str: &str = std::str::from_utf8(&data[21..24])?; - let date_of_flight: NaiveDate = parse_boarding_pass_date(date_of_flight_str, nearby_date)?; - let compartment_code: char = data[24] as char; - let seat_number: String = std::str::from_utf8(&data[25..29])? - .trim_start_matches('0') - .to_string(); - let check_in_sequence: String = std::str::from_utf8(&data[29..34])? - .trim_end() - .trim_start_matches('0') - .to_string(); - let passenger_status: PassengerStatus = PassengerStatus::from(data[34])?; - Ok(( - BoardingPassLeg { - operating_carrier_pnr, - origin_airport, - destination_airport, - operating_carrier, - flight_number, - date_of_flight, - compartment_code, - seat_number, - check_in_sequence, - passenger_status, - - airline_numeric_code: None, - document_serial_number: None, - selectee_indicator: None, - documentation_verification: None, - marketing_carrier: None, - frequent_flyer_airline: None, - frequent_flyer_number: None, - id_ad_indicator: None, - free_baggage_allowance: None, - airline_data: None, - }, - 35, - )) - } - - fn add_optional(&mut self, data: &[u8]) -> Result<()> { - let structured_data_size: usize = - usize::from_str_radix(std::str::from_utf8(&data[0..2])?, 16)?; - let mut structured_data = &data[2..structured_data_size + 2]; - - if structured_data.len() >= 3 { - let airline_code_str = std::str::from_utf8(&structured_data[0..3])?.trim_end(); - self.airline_numeric_code = if airline_code_str == "" { - None - } else { - Some(airline_code_str.to_string()) - }; - structured_data = &structured_data[3..]; - } - if structured_data.len() >= 10 { - let document_serial_number_str = - std::str::from_utf8(&structured_data[0..10])?.trim_end(); - self.document_serial_number = if document_serial_number_str == "" { - None - } else { - Some(document_serial_number_str.to_string()) - }; - structured_data = &structured_data[10..]; - } - if structured_data.len() >= 1 { - self.selectee_indicator = if structured_data[0] == b' ' { - None - } else { - Some(structured_data[0] as char) - }; - structured_data = &structured_data[1..]; - } - if structured_data.len() >= 1 { - self.documentation_verification = if structured_data[0] == b' ' { - None - } else { - Some(DocumentationVerificationStatus::from(structured_data[0])?) - }; - structured_data = &structured_data[1..]; - } - if structured_data.len() >= 3 { - let marketing_carrier_str = std::str::from_utf8(&structured_data[0..3])?.trim_end(); - self.marketing_carrier = if marketing_carrier_str == "" { - None - } else { - Some(marketing_carrier_str.to_string()) - }; - structured_data = &structured_data[3..]; - } - if structured_data.len() >= 3 { - let frequent_flyer_airline_str = - std::str::from_utf8(&structured_data[0..3])?.trim_end(); - self.frequent_flyer_airline = if frequent_flyer_airline_str == "" { - None - } else { - Some(frequent_flyer_airline_str.to_string()) - }; - structured_data = &structured_data[3..]; - } - if structured_data.len() >= 16 { - let frequent_flyer_number_str = - std::str::from_utf8(&structured_data[0..16])?.trim_end(); - self.frequent_flyer_number = if frequent_flyer_number_str == "" { - None - } else { - Some(frequent_flyer_number_str.to_string()) - }; - structured_data = &structured_data[16..]; - } - if structured_data.len() >= 1 { - self.id_ad_indicator = if structured_data[0] == b' ' { - None - } else { - Some(structured_data[0] as char) - }; - structured_data = &structured_data[1..]; - } - if structured_data.len() >= 3 { - let free_baggage_allowance_str = - std::str::from_utf8(&structured_data[0..3])?.trim_end(); - self.free_baggage_allowance = if free_baggage_allowance_str == "" { - None - } else { - Some(free_baggage_allowance_str.to_string()) - }; - structured_data = &structured_data[3..]; - } - if structured_data.len() != 0 { - return Err(anyhow!( - "trailing data in structured data section ({} bytes)", - structured_data.len() - )); - } - - let unstructured_data = &data[structured_data_size + 2..]; - if unstructured_data.len() > 0 { - self.airline_data = Some(unstructured_data.to_vec()); - } - Ok(()) - } + Ok(parsed) } #[tokio::main] @@ -839,17 +94,14 @@ async fn main() -> Result<()> { let command = cli.command.unwrap(); - match &command { - Commands::Scan { image } => { - println!("{}", scan(&image)?); - return Ok(()); - } - _ => {} + if let Commands::Scan { image } = &command { + println!("{}", scan(image)?); + return Ok(()); }; let service_account_file = cli .service_account_file - .ok_or(anyhow!("--service-account-file must be set"))?; + .ok_or_else(|| anyhow!("--service-account-file must be set"))?; let sa = load_service_account(&service_account_file).await?; let hub = google_walletobjects1::Walletobjects::new( @@ -861,7 +113,7 @@ async fn main() -> Result<()> { .enable_http2() .build(), ), - sa.authenticator, + sa.authenticator.clone(), ); match &command { @@ -900,49 +152,27 @@ async fn main() -> Result<()> { class.review_status = Some("UNDER_REVIEW".to_string()); // Try to create the class first... - let created_class: bool = hub.flightclass().insert_now(&class).await?; - if !created_class { - // Already exists. - hub.flightclass().update_now(&class).await?; - } + insert_or_update_now(&hub.flightclass(), &class).await?; // Then the pass... - let created_pass: bool = hub.flightobject().insert_now(&pass).await?; - if !created_pass { - // Already exists. - hub.flightobject().update_now(&pass).await?; - } + insert_or_update_now(&hub.flightobject(), &pass).await?; // Now generate the URL. - let claims: JWTClaims = JWTClaims { - issued_at: None, - expires_at: None, - invalid_before: None, - audiences: Some(jwt_simple::claims::Audiences::AsString( - "google".to_string(), - )), - issuer: Some(sa.service_account_name), - jwt_id: None, - subject: None, - nonce: None, - custom: AddToWalletClaims { - jwt_type: "savetowallet".to_string(), - origins: vec!["www.lukegb.com".to_string()], - payload: AddToWalletPayload { - flight_classes: vec![AddToWalletPayloadObject { - id: class.id.clone().unwrap(), - class_id: None, - }], - flight_objects: vec![AddToWalletPayloadObject { - id: pass.id.unwrap(), - class_id: Some(class.id.unwrap()), - }], - }, - }, - }; - let token = sa.key_pair.sign(claims)?; + println!( + "{}", + AddToWallet { + service_account_email: sa.service_account_name.to_string(), - println!("https://pay.google.com/gp/v/save/{}", token); + flight_classes: vec![PassClassIdentifier { + id: class.id.clone().unwrap(), + }], + flight_passes: vec![PassIdentifier { + id: pass.id.unwrap(), + class_id: class.id.unwrap(), + }], + } + .to_url(&sa)? + ); Ok(()) } @@ -950,88 +180,15 @@ async fn main() -> Result<()> { let scanned = scan(&upload_scan.image)?; let boarding_pass = BoardingPass::parse(scanned.as_bytes().to_vec(), &Local::now().date_naive())?; - if boarding_pass.legs.len() > 1 { - return Err(anyhow!("multi-leg trips aren't supported yet")); - } - let leg = &boarding_pass.legs[0]; - let airline_data = AIRLINE_DATA.get(&leg.operating_carrier).ok_or(anyhow!( - "no AIRLINE_DATA has been provided for {} yet", - leg.operating_carrier - ))?; + let (classes, passes) = passes_from_barcode(&cli.issuer_id, boarding_pass)?; - let id_prefix = format!( - "{}.{}{}-{}-{}{}", - cli.issuer_id, - leg.operating_carrier, - leg.flight_number, - leg.date_of_flight, - leg.origin_airport, - leg.destination_airport - ); - let class_id = &id_prefix; - let pass_id = format!("{}-{}", &id_prefix, leg.check_in_sequence); + // Create all the classes. + insert_or_update_many(&hub.flightclass(), classes).await?; - let mut class = FlightClass::default(); - class.id = Some(class_id.clone()); - class.issuer_name = Some(airline_data.name.to_string()); - class.hex_background_color = - Some(airline_data.boarding_pass_background_colour.to_string()); - class.review_status = Some("UNDER_REVIEW".to_string()); - class.boarding_and_seating_policy = - Some(google_walletobjects1::api::BoardingAndSeatingPolicy { - boarding_policy: Some(airline_data.boarding_policy.to_string()), - seat_class_policy: Some(airline_data.seat_class_policy.to_string()), - kind: None, - }); - class.origin = Some(airport(&leg.origin_airport)); - class.destination = Some(airport(&leg.destination_airport)); - let mut flight_header = google_walletobjects1::api::FlightHeader::default(); - let mut flight_header_carrier = google_walletobjects1::api::FlightCarrier::default(); - flight_header_carrier.airline_alliance_logo = - airline_data.alliance_logo_url.map(url_to_image); - flight_header_carrier.airline_logo = Some(url_to_image(airline_data.logo_url)); - flight_header_carrier.airline_name = - Some(to_localized_string("en-GB", airline_data.name)); - flight_header_carrier.carrier_iata_code = Some(leg.operating_carrier.to_string()); - flight_header.carrier = Some(flight_header_carrier); - flight_header.flight_number = Some(leg.flight_number.to_string()); - class.flight_header = Some(flight_header); - - let mut pass = FlightObject::default(); - pass.id = Some(pass_id.to_string()); - pass.class_id = Some(class_id.clone()); + // Create all the objects. + insert_or_update_many(&hub.flightobject(), passes).await?; Ok(()) } } } - -fn airport(iata_code: &str) -> google_walletobjects1::api::AirportInfo { - let mut info = google_walletobjects1::api::AirportInfo::default(); - info.airport_iata_code = Some(iata_code.to_string()); - info -} - -fn to_localized_string(locale: &str, s: &str) -> google_walletobjects1::api::LocalizedString { - google_walletobjects1::api::LocalizedString { - default_value: Some(google_walletobjects1::api::TranslatedString { - kind: None, - language: Some(locale.to_string()), - value: Some(s.to_string()), - }), - kind: None, - translated_values: None, - } -} - -fn url_to_image(url: &str) -> google_walletobjects1::api::Image { - google_walletobjects1::api::Image { - kind: None, - content_description: None, - source_uri: Some(google_walletobjects1::api::ImageUri { - description: None, - localized_description: None, - uri: Some(url.to_string()), - }), - } -} diff --git a/rust/passgen/src/resolution792.rs b/rust/passgen/src/resolution792.rs new file mode 100644 index 0000000000..3379a23ac3 --- /dev/null +++ b/rust/passgen/src/resolution792.rs @@ -0,0 +1,723 @@ +use anyhow::{anyhow, Result}; +use chrono::naive::NaiveDate; +use chrono::Datelike; +use std::cmp::min; + +use strum::IntoEnumIterator; +use strum_macros::EnumIter; + +#[derive(Clone, Copy, Debug, EnumIter)] +#[repr(u8)] +pub enum PassengerStatus { + NotCheckedIn = b'0', + CheckedIn = b'1', + BagsCheckedAndNotCheckedIn = b'2', + BagsCheckedAndCheckedIn = b'3', + PastSecurity = b'4', + PastGate = b'5', + Transit = b'6', + Standby = b'7', + BoardingDataRevalidated = b'8', + OriginalBoardingLineUsedWhenTicketIssued = b'9', + UpOrDownGradingPending = b'A', +} + +impl PassengerStatus { + fn from(c: u8) -> Result { + for v in PassengerStatus::iter() { + if v as u8 == c { + return Ok(v); + } + } + Err(anyhow!( + "{} ({}) is not a valid PassengerStatus value", + c, + c as char + )) + } +} + +#[derive(Clone, Copy, Debug, EnumIter)] +#[repr(u8)] +pub enum DocumentationVerificationStatus { + NotRequired = b'0', + Required = b'1', + Performed = b'2', +} + +impl DocumentationVerificationStatus { + fn from(c: u8) -> Result { + for v in DocumentationVerificationStatus::iter() { + if v as u8 == c { + return Ok(v); + } + } + Err(anyhow!( + "{} ({}) is not a valid DocumentationVerificationStatus value", + c, + c as char + )) + } +} + +#[derive(Clone, Copy, Debug, EnumIter)] +#[repr(u8)] +pub enum PassengerDescription { + Adult = b'0', + Male = b'1', + Female = b'2', + Child = b'3', + Infant = b'4', + NoPassenger = b'5', // cabin baggage + AdultWithInfant = b'6', + UnaccompaniedMinor = b'7', +} + +impl PassengerDescription { + fn from(c: u8) -> Result { + for v in PassengerDescription::iter() { + if v as u8 == c { + return Ok(v); + } + } + Err(anyhow!( + "{} ({}) is not a valid PassengerDescription value", + c, + c as char + )) + } +} + +#[derive(Clone, Copy, Debug, EnumIter)] +#[repr(u8)] +pub enum CheckInSource { + Web = b'W', + AirportKiosk = b'K', + RemoteKiosk = b'R', + MobileDevice = b'M', + AirportAgent = b'O', + TownAgent = b'T', + ThirdPartyVendor = b'V', +} + +impl CheckInSource { + fn from(c: u8) -> Result { + for v in CheckInSource::iter() { + if v as u8 == c { + return Ok(v); + } + } + Err(anyhow!( + "{} ({}) is not a valid CheckInSource value", + c, + c as char + )) + } +} + +#[derive(Debug)] +pub struct BoardingPass { + pub passenger_name: String, + pub eticket_indicator: char, + + pub version_number: Option, + pub passenger_description: Option, + pub checkin_source: Option, + pub boarding_pass_source: Option, + pub date_of_issue: Option, + pub document_type: Option, + pub boarding_pass_issuing_airline: Option, + pub baggage_tag_plate_number: Option, + + pub legs: Vec, + + pub security_data_type: u8, + pub security_data: Vec, +} + +fn parse_boarding_pass_date( + from_boarding_pass_str: &str, + nearby_date: &NaiveDate, +) -> Result { + // Range is 0 to 9999 (although not really). + let from_boarding_pass: u16 = from_boarding_pass_str.parse()?; + + match from_boarding_pass_str.len() { + 4 => { + // We will look 5 years into the past and 4 years into the future. + // e.g. if the current year is 2025, we will use 2020-2029. + let year_digit = (from_boarding_pass / 1000) as i32; + let nearby_year_digit = nearby_date.year() % 10; + let mut year = nearby_date.year() - nearby_year_digit + year_digit; + let diff = nearby_date.year() - year; + + if diff > 5 { + year += 10; + } else if diff < -4 { + year -= 10; + } + + return NaiveDate::from_yo_opt(year, (from_boarding_pass % 1000).into()) + .ok_or_else(|| anyhow!("could not parse date {}", from_boarding_pass_str)); + } + 3 => { + // Find the one that's the fewest days away. + let year = nearby_date.year(); + let day: u32 = (from_boarding_pass % 1000).into(); + let mut options = vec![ + NaiveDate::from_yo_opt(year, day), + NaiveDate::from_yo_opt(year - 1, day), + NaiveDate::from_yo_opt(year + 1, day), + ]; + options.sort_by(|a, b| { + days_between(nearby_date, a) + .unwrap_or(9999) + .cmp(&days_between(nearby_date, b).unwrap_or(9999)) + }); + Ok(options[0].unwrap()) + } + _ => Err(anyhow!( + "boarding pass date {} is wrong length", + from_boarding_pass_str + )), + } +} + +fn days_between(nearby_date: &NaiveDate, maybe_date: &Option) -> Option { + if maybe_date.is_none() { + return None; + } + let is_date = maybe_date.as_ref().unwrap(); + let days_between = (*nearby_date - *is_date).num_days(); + Some(if days_between < 0 { + (-days_between) as u64 + } else { + days_between as u64 + }) +} + +impl BoardingPass { + pub fn parse(data: Vec, nearby_date: &NaiveDate) -> Result { + if data[0] != b'M' { + return Err(anyhow!( + "format code was {} ({}), not M", + data[0] as char, + data[0] + )); + } + let leg_count = match data[1] { + b'1' => Ok(1), + b'2' => Ok(2), + b'3' => Ok(3), + b'4' => Ok(4), + _ => Err(anyhow!( + "leg count was {} ({}), should be 1 to 4", + data[1] as char, + data[1] + )), + }?; + + let passenger_name: String = std::str::from_utf8(&data[2..22])?.trim_end().to_string(); + let eticket_indicator: char = data[22] as char; + + // First leg mandatory data: + let (first_leg, mut p) = BoardingPassLeg::parse_mandatory(&data[23..], nearby_date)?; + p += 23; + + // We now have enough to start composing the boardingpass. + let mut boarding_pass = BoardingPass { + passenger_name, + eticket_indicator, + + version_number: None, + passenger_description: None, + checkin_source: None, + boarding_pass_source: None, + date_of_issue: None, + document_type: None, + boarding_pass_issuing_airline: None, + baggage_tag_plate_number: None, + + legs: vec![first_leg], + + security_data_type: 0xff, + security_data: vec![], + }; + + // Header + first leg optional data (why, IATA, why) + let optional_data_size: usize = + usize::from_str_radix(std::str::from_utf8(&data[p..p + 2])?, 16)?; + let mut optional_data = &data[p + 2..p + 2 + optional_data_size]; + p += 2 + optional_data_size; + if !optional_data.is_empty() { + if optional_data[0] != b'>' { + return Err(anyhow!("expected Beginning of version number ('>') at beginning of conditional fields, got {} ({})", optional_data[0], optional_data[0] as char)); + } + optional_data = &optional_data[1..]; + } + if !optional_data.is_empty() { + boarding_pass.version_number = Some(optional_data[0] as char); + optional_data = &optional_data[1..]; + } + if !optional_data.is_empty() { + let optional_data_unique_size: usize = + usize::from_str_radix(std::str::from_utf8(&optional_data[0..2])?, 16)?; + let mut optional_data_unique = &optional_data[2..optional_data_unique_size + 2]; + if !optional_data_unique.is_empty() { + boarding_pass.passenger_description = if optional_data_unique[0] == b' ' { + None + } else { + Some(PassengerDescription::from(optional_data_unique[0])?) + }; + optional_data_unique = &optional_data_unique[1..]; + } + if !optional_data_unique.is_empty() { + boarding_pass.checkin_source = if optional_data_unique[0] == b' ' { + None + } else { + Some(CheckInSource::from(optional_data_unique[0])?) + }; + optional_data_unique = &optional_data_unique[1..]; + } + if !optional_data_unique.is_empty() { + boarding_pass.boarding_pass_source = if optional_data_unique[0] == b' ' { + None + } else { + Some(CheckInSource::from(optional_data_unique[0])?) + }; + optional_data_unique = &optional_data_unique[1..]; + } + if optional_data_unique.len() >= 4 { + let date_of_issue_str = std::str::from_utf8(&optional_data_unique[0..4])?; + boarding_pass.date_of_issue = if date_of_issue_str == " " { + None + } else { + Some(parse_boarding_pass_date(date_of_issue_str, nearby_date)?) + }; + optional_data_unique = &optional_data_unique[4..]; + } + // TODO: the above might fall through if we don't have 4 bytes of data + if !optional_data_unique.is_empty() { + boarding_pass.document_type = if optional_data_unique[0] == b' ' { + None + } else { + Some(optional_data_unique[0] as char) + }; + optional_data_unique = &optional_data_unique[1..]; + } + if optional_data_unique.len() >= 3 { + let issuing_airline_str = + std::str::from_utf8(&optional_data_unique[0..3])?.trim_end(); + boarding_pass.boarding_pass_issuing_airline = if issuing_airline_str.is_empty() { + None + } else { + Some(issuing_airline_str.to_string()) + }; + optional_data_unique = &optional_data_unique[3..]; + } + // TODO: the above might fall through if we don't have 3 bytes of data + let bag_tags_str_len = min(13, optional_data_unique.len()); + let bag_tags_str = + std::str::from_utf8(&optional_data_unique[0..bag_tags_str_len])?.trim_end(); + boarding_pass.baggage_tag_plate_number = if bag_tags_str.is_empty() { + None + } else { + Some(bag_tags_str.to_string()) + }; + optional_data_unique = &optional_data_unique[bag_tags_str_len..]; + if !optional_data_unique.is_empty() { + return Err(anyhow!( + "trailing unique optional data ({} bytes)", + optional_data_unique.len() + )); + } + optional_data = &optional_data[optional_data_unique_size + 2..]; + } + + // Now for the repeated optional data for the first leg. + boarding_pass.legs[0].add_optional(optional_data)?; + + // Now any additional legs(!) + for _legnum in 1..leg_count { + let (mut leg, leg_bytes) = BoardingPassLeg::parse_mandatory(&data[p..], nearby_date)?; + p += leg_bytes; + + let optional_data_size: usize = + usize::from_str_radix(std::str::from_utf8(&data[p..p + 2])?, 16)?; + let optional_data = &data[p + 2..p + 2 + optional_data_size]; + p += 2 + optional_data_size; + + leg.add_optional(optional_data)?; + boarding_pass.legs.push(leg); + } + + // What's left is the security data. + if data[p] != b'^' { + return Err(anyhow!( + "expected security data beginning indicator (^) but got {} ({})", + data[p], + data[p] as char + )); + } + boarding_pass.security_data_type = data[p + 1]; + let security_data_size: usize = + usize::from_str_radix(std::str::from_utf8(&data[p + 2..p + 4])?, 16)?; + boarding_pass.security_data = data[p + 4..p + 4 + security_data_size].to_vec(); + if data.len() > p + 4 + security_data_size { + return Err(anyhow!( + "trailing bytes after security data (expected {} bytes of security data)", + security_data_size + )); + } + + Ok(boarding_pass) + } +} + +#[derive(Debug)] +pub struct BoardingPassLeg { + pub operating_carrier_pnr: String, // 7, left justified w/ trailing blanks + pub origin_airport: String, // 3 + pub destination_airport: String, // 3 + pub operating_carrier: String, // 3, left justified w/ trailing blanks + pub flight_number: String, // 5, NNNN[a] (leading zeros + alpha/blank on last digit) + pub date_of_flight: NaiveDate, // 3, leading zeros (this is the day in the year e.g. 001 = Jan 1st, + // 365 == Dec 31st) + pub compartment_code: char, + pub seat_number: String, // 4, NNNa (leading zeros) + pub check_in_sequence: String, // NNNN[f] (leading zeros + alpha/blank on last digit) + pub passenger_status: PassengerStatus, + + pub airline_numeric_code: Option, + pub document_serial_number: Option, + pub selectee_indicator: Option, + pub documentation_verification: Option, + pub marketing_carrier: Option, + pub frequent_flyer_airline: Option, + pub frequent_flyer_number: Option, + pub id_ad_indicator: Option, + pub free_baggage_allowance: Option, + pub airline_data: Option>, +} + +impl BoardingPassLeg { + fn parse_mandatory(data: &[u8], nearby_date: &NaiveDate) -> Result<(BoardingPassLeg, usize)> { + let operating_carrier_pnr: String = + std::str::from_utf8(&data[0..7])?.trim_end().to_string(); + let origin_airport: String = std::str::from_utf8(&data[7..10])?.to_string(); + let destination_airport: String = std::str::from_utf8(&data[10..13])?.to_string(); + let operating_carrier: String = std::str::from_utf8(&data[13..16])?.trim_end().to_string(); + let flight_number: String = std::str::from_utf8(&data[16..21])? + .trim_end() + .trim_start_matches('0') + .to_string(); + let date_of_flight_str: &str = std::str::from_utf8(&data[21..24])?; + let date_of_flight: NaiveDate = parse_boarding_pass_date(date_of_flight_str, nearby_date)?; + let compartment_code: char = data[24] as char; + let seat_number: String = std::str::from_utf8(&data[25..29])? + .trim_start_matches('0') + .to_string(); + let check_in_sequence: String = std::str::from_utf8(&data[29..34])? + .trim_end() + .trim_start_matches('0') + .to_string(); + let passenger_status: PassengerStatus = PassengerStatus::from(data[34])?; + Ok(( + BoardingPassLeg { + operating_carrier_pnr, + origin_airport, + destination_airport, + operating_carrier, + flight_number, + date_of_flight, + compartment_code, + seat_number, + check_in_sequence, + passenger_status, + + airline_numeric_code: None, + document_serial_number: None, + selectee_indicator: None, + documentation_verification: None, + marketing_carrier: None, + frequent_flyer_airline: None, + frequent_flyer_number: None, + id_ad_indicator: None, + free_baggage_allowance: None, + airline_data: None, + }, + 35, + )) + } + + fn add_optional(&mut self, data: &[u8]) -> Result<()> { + let structured_data_size: usize = + usize::from_str_radix(std::str::from_utf8(&data[0..2])?, 16)?; + let mut structured_data = &data[2..structured_data_size + 2]; + + if structured_data.len() >= 3 { + let airline_code_str = std::str::from_utf8(&structured_data[0..3])?.trim_end(); + self.airline_numeric_code = if airline_code_str.is_empty() { + None + } else { + Some(airline_code_str.to_string()) + }; + structured_data = &structured_data[3..]; + } + if structured_data.len() >= 10 { + let document_serial_number_str = + std::str::from_utf8(&structured_data[0..10])?.trim_end(); + self.document_serial_number = if document_serial_number_str.is_empty() { + None + } else { + Some(document_serial_number_str.to_string()) + }; + structured_data = &structured_data[10..]; + } + if !structured_data.is_empty() { + self.selectee_indicator = if structured_data[0] == b' ' { + None + } else { + Some(structured_data[0] as char) + }; + structured_data = &structured_data[1..]; + } + if !structured_data.is_empty() { + self.documentation_verification = if structured_data[0] == b' ' { + None + } else { + Some(DocumentationVerificationStatus::from(structured_data[0])?) + }; + structured_data = &structured_data[1..]; + } + if structured_data.len() >= 3 { + let marketing_carrier_str = std::str::from_utf8(&structured_data[0..3])?.trim_end(); + self.marketing_carrier = if marketing_carrier_str.is_empty() { + None + } else { + Some(marketing_carrier_str.to_string()) + }; + structured_data = &structured_data[3..]; + } + if structured_data.len() >= 3 { + let frequent_flyer_airline_str = + std::str::from_utf8(&structured_data[0..3])?.trim_end(); + self.frequent_flyer_airline = if frequent_flyer_airline_str.is_empty() { + None + } else { + Some(frequent_flyer_airline_str.to_string()) + }; + structured_data = &structured_data[3..]; + } + if structured_data.len() >= 16 { + let frequent_flyer_number_str = + std::str::from_utf8(&structured_data[0..16])?.trim_end(); + self.frequent_flyer_number = if frequent_flyer_number_str.is_empty() { + None + } else { + Some(frequent_flyer_number_str.to_string()) + }; + structured_data = &structured_data[16..]; + } + if !structured_data.is_empty() { + self.id_ad_indicator = if structured_data[0] == b' ' { + None + } else { + Some(structured_data[0] as char) + }; + structured_data = &structured_data[1..]; + } + if structured_data.len() >= 3 { + let free_baggage_allowance_str = + std::str::from_utf8(&structured_data[0..3])?.trim_end(); + self.free_baggage_allowance = if free_baggage_allowance_str.is_empty() { + None + } else { + Some(free_baggage_allowance_str.to_string()) + }; + structured_data = &structured_data[3..]; + } + if !structured_data.is_empty() { + return Err(anyhow!( + "trailing data in structured data section ({} bytes)", + structured_data.len() + )); + } + + let unstructured_data = &data[structured_data_size + 2..]; + if !unstructured_data.is_empty() { + self.airline_data = Some(unstructured_data.to_vec()); + } + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_boarding_pass_date_yearless_end_of_year() { + let jan01st2022 = NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(); + let dec31st2021 = NaiveDate::from_ymd_opt(2021, 12, 31).unwrap(); + + assert_eq!( + parse_boarding_pass_date("001", &jan01st2022).unwrap(), + NaiveDate::from_ymd_opt(2022, 1, 1).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("001", &dec31st2021).unwrap(), + NaiveDate::from_ymd_opt(2022, 1, 1).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("030", &jan01st2022).unwrap(), + NaiveDate::from_ymd_opt(2022, 1, 30).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("030", &dec31st2021).unwrap(), + NaiveDate::from_ymd_opt(2022, 1, 30).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("090", &jan01st2022).unwrap(), + NaiveDate::from_ymd_opt(2022, 3, 31).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("090", &dec31st2021).unwrap(), + NaiveDate::from_ymd_opt(2022, 3, 31).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("180", &jan01st2022).unwrap(), + NaiveDate::from_ymd_opt(2022, 6, 29).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("180", &dec31st2021).unwrap(), + NaiveDate::from_ymd_opt(2022, 6, 29).unwrap() + ); + + // We snap back to 2021. + assert_eq!( + parse_boarding_pass_date("200", &jan01st2022).unwrap(), + NaiveDate::from_ymd_opt(2021, 7, 19).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("200", &dec31st2021).unwrap(), + NaiveDate::from_ymd_opt(2021, 7, 19).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("365", &jan01st2022).unwrap(), + NaiveDate::from_ymd_opt(2021, 12, 31).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("365", &dec31st2021).unwrap(), + NaiveDate::from_ymd_opt(2021, 12, 31).unwrap() + ); + } + + #[test] + fn test_boarding_pass_date_yearless_mid_year() { + let jun15th2022 = NaiveDate::from_ymd_opt(2022, 6, 15).unwrap(); + + assert_eq!( + parse_boarding_pass_date("001", &jun15th2022).unwrap(), + NaiveDate::from_ymd_opt(2022, 1, 1).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("030", &jun15th2022).unwrap(), + NaiveDate::from_ymd_opt(2022, 1, 30).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("090", &jun15th2022).unwrap(), + NaiveDate::from_ymd_opt(2022, 3, 31).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("180", &jun15th2022).unwrap(), + NaiveDate::from_ymd_opt(2022, 6, 29).unwrap() + ); + + // We snap back to 2021. + assert_eq!( + parse_boarding_pass_date("200", &jun15th2022).unwrap(), + NaiveDate::from_ymd_opt(2022, 7, 19).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("365", &jun15th2022).unwrap(), + NaiveDate::from_ymd_opt(2021, 12, 31).unwrap() + ); + } + + #[test] + fn test_boarding_pass_date_yearful() { + let jan01st2020 = NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(); + let jan01st2022 = NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(); + let jan01st2025 = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + let jan01st2029 = NaiveDate::from_ymd_opt(2029, 1, 1).unwrap(); + + assert_eq!( + parse_boarding_pass_date("0001", &jan01st2020).unwrap(), + NaiveDate::from_ymd_opt(2020, 1, 1).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("0001", &jan01st2022).unwrap(), + NaiveDate::from_ymd_opt(2020, 1, 1).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("0001", &jan01st2025).unwrap(), + NaiveDate::from_ymd_opt(2020, 1, 1).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("0001", &jan01st2029).unwrap(), + NaiveDate::from_ymd_opt(2030, 1, 1).unwrap() + ); + + assert_eq!( + parse_boarding_pass_date("2001", &jan01st2020).unwrap(), + NaiveDate::from_ymd_opt(2022, 1, 1).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("2001", &jan01st2022).unwrap(), + NaiveDate::from_ymd_opt(2022, 1, 1).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("2001", &jan01st2025).unwrap(), + NaiveDate::from_ymd_opt(2022, 1, 1).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("2001", &jan01st2029).unwrap(), + NaiveDate::from_ymd_opt(2032, 1, 1).unwrap() + ); + + assert_eq!( + parse_boarding_pass_date("5001", &jan01st2020).unwrap(), + NaiveDate::from_ymd_opt(2015, 1, 1).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("5001", &jan01st2022).unwrap(), + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("5001", &jan01st2025).unwrap(), + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("5001", &jan01st2029).unwrap(), + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap() + ); + + assert_eq!( + parse_boarding_pass_date("9001", &jan01st2020).unwrap(), + NaiveDate::from_ymd_opt(2019, 1, 1).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("9001", &jan01st2022).unwrap(), + NaiveDate::from_ymd_opt(2019, 1, 1).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("9001", &jan01st2025).unwrap(), + NaiveDate::from_ymd_opt(2029, 1, 1).unwrap() + ); + assert_eq!( + parse_boarding_pass_date("9001", &jan01st2029).unwrap(), + NaiveDate::from_ymd_opt(2029, 1, 1).unwrap() + ); + } +} diff --git a/rust/passgen/src/scanner.rs b/rust/passgen/src/scanner.rs new file mode 100644 index 0000000000..dbea238e8c --- /dev/null +++ b/rust/passgen/src/scanner.rs @@ -0,0 +1,18 @@ +use std::path::PathBuf; + +use anyhow::{anyhow, Result}; + +pub fn scan(image_path: &PathBuf) -> Result { + let image_path_str = (*image_path) + .to_str() + .ok_or_else(|| anyhow!("invalid path"))?; + rxing::helpers::detect_in_file(image_path_str, Some(rxing::BarcodeFormat::AZTEC)) + .map_err(|e| { + anyhow!( + "could not parse Aztec barcode from image {}: {}", + image_path_str, + e + ) + }) + .map(|r| r.getText().clone()) +} diff --git a/rust/passgen/src/service_account.rs b/rust/passgen/src/service_account.rs new file mode 100644 index 0000000000..35093529f2 --- /dev/null +++ b/rust/passgen/src/service_account.rs @@ -0,0 +1,42 @@ +use std::path::PathBuf; + +use anyhow::Result; + +use google_walletobjects1::oauth2::authenticator::{ + Authenticator, DefaultHyperClient, HyperClientBuilder, +}; +use google_walletobjects1::oauth2::{read_service_account_key, ServiceAccountAuthenticator}; +use jwt_simple::algorithms::RS256KeyPair; + +use serde::Deserialize; + +pub struct ServiceAccount { + pub service_account_name: String, + pub authenticator: Authenticator<::Connector>, + pub key_pair: RS256KeyPair, +} + +#[derive(Deserialize)] +struct ServiceAccountJSON { + // We omit a lot of fields we don't care about. + /// Service Account email + client_email: String, + + /// RSA private key + private_key: String, +} + +pub async fn load_service_account(path: &PathBuf) -> Result { + let creds = read_service_account_key(path).await?; + let sa = ServiceAccountAuthenticator::builder(creds).build().await?; + + let sa_data = tokio::fs::read(path).await?; + let sa_parsed: ServiceAccountJSON = serde_json::from_slice(&sa_data[..])?; + let key_pair = RS256KeyPair::from_pem(&sa_parsed.private_key)?; + + Ok(ServiceAccount { + service_account_name: sa_parsed.client_email, + authenticator: sa, + key_pair, + }) +} diff --git a/rust/passgen/src/static_data.rs b/rust/passgen/src/static_data.rs new file mode 100644 index 0000000000..6bfb46536e --- /dev/null +++ b/rust/passgen/src/static_data.rs @@ -0,0 +1,37 @@ +use phf::phf_map; + +pub struct AirlineDefinition<'a> { + pub iata_code: &'a str, + pub name: &'a str, + pub boarding_policy: &'a str, + pub seat_class_policy: &'a str, + + pub boarding_pass_background_colour: &'a str, + + pub frequent_flyer_program_name: Option<&'a str>, + + pub logo_url: &'a str, + pub alliance_logo_url: Option<&'a str>, + pub hero_image_logo_url: Option<&'a str>, + pub boarding_privilege_logo_url: Option<&'a str>, +} + +pub const TSA_PRECHECK_LOGO: &str = "https://p.lukegb.com/raw/MiserablyDirectPiglet.jpg"; + +pub static AIRLINE_DATA: phf::Map<&'static str, AirlineDefinition<'static>> = phf_map! { + "VS" => AirlineDefinition{ + iata_code: "VS", + name: "Virgin Atlantic", + boarding_policy: "GROUP_BASED", + seat_class_policy: "CABIN_BASED", + + boarding_pass_background_colour: "#4f145b", + + frequent_flyer_program_name: Some("Flying Club"), + + logo_url: "https://p.lukegb.com/raw/VirtuallyCrispViper.png", + alliance_logo_url: None, + hero_image_logo_url: Some("https://p.lukegb.com/raw/FormerlyDistinctToad.png"), + boarding_privilege_logo_url: Some("https://p.lukegb.com/raw/DefinitelyVerifiedTitmouse.png"), + }, +}; diff --git a/rust/passgen/src/walletobjects.rs b/rust/passgen/src/walletobjects.rs new file mode 100644 index 0000000000..20cb187caf --- /dev/null +++ b/rust/passgen/src/walletobjects.rs @@ -0,0 +1,94 @@ +use anyhow::{anyhow, Result}; +pub use google_walletobjects1::api::*; + +use crate::resolution792::BoardingPass; +use crate::static_data::AIRLINE_DATA; + +pub fn passes_from_barcode( + issuer_id: &str, + boarding_pass: BoardingPass, +) -> Result<(Vec, Vec)> { + if boarding_pass.legs.len() > 1 { + return Err(anyhow!("multi-leg trips aren't supported yet")); + } + let leg = &boarding_pass.legs[0]; + let airline_data = AIRLINE_DATA.get(&leg.operating_carrier).ok_or_else(|| { + anyhow!( + "no AIRLINE_DATA has been provided for {} yet", + leg.operating_carrier + ) + })?; + + let id_prefix = format!( + "{}.{}{}-{}-{}{}", + issuer_id, + leg.operating_carrier, + leg.flight_number, + leg.date_of_flight, + leg.origin_airport, + leg.destination_airport + ); + let class_id = &id_prefix; + let pass_id = format!("{}-{}", &id_prefix, leg.check_in_sequence); + + let class = FlightClass { + id: Some(class_id.clone()), + issuer_name: Some(airline_data.name.to_string()), + hex_background_color: Some(airline_data.boarding_pass_background_colour.to_string()), + review_status: Some("UNDER_REVIEW".to_string()), + boarding_and_seating_policy: Some(BoardingAndSeatingPolicy { + boarding_policy: Some(airline_data.boarding_policy.to_string()), + seat_class_policy: Some(airline_data.seat_class_policy.to_string()), + ..Default::default() + }), + origin: Some(AirportInfo { + airport_iata_code: Some(leg.origin_airport.clone()), + ..Default::default() + }), + destination: Some(AirportInfo { + airport_iata_code: Some(leg.destination_airport.clone()), + ..Default::default() + }), + flight_header: Some(FlightHeader { + carrier: Some(FlightCarrier { + airline_alliance_logo: airline_data.alliance_logo_url.map(url_to_image), + airline_logo: Some(url_to_image(airline_data.logo_url)), + airline_name: Some(localized_string("en-GB", airline_data.name)), + carrier_iata_code: Some(leg.operating_carrier.to_string()), + ..Default::default() + }), + flight_number: Some(leg.flight_number.to_string()), + ..Default::default() + }), + ..Default::default() + }; + + let pass = FlightObject { + id: Some(pass_id), + class_id: Some(class_id.clone()), + ..Default::default() + }; + + Ok((vec![class], vec![pass])) +} + +pub fn localized_string(locale: &str, s: &str) -> LocalizedString { + LocalizedString { + default_value: Some(TranslatedString { + language: Some(locale.to_string()), + value: Some(s.to_string()), + ..Default::default() + }), + ..Default::default() + } +} + +pub fn url_to_image(url: &str) -> Image { + Image { + source_uri: Some(ImageUri { + uri: Some(url.to_string()), + ..Default::default() + }), + ..Default::default() + } +}