passgen: restructure things into a lib and a main

This commit is contained in:
Luke Granger-Brown 2023-01-08 04:29:44 +00:00
parent 6796dfad18
commit 73956a5f70
11 changed files with 1185 additions and 879 deletions

View file

@ -1646,6 +1646,7 @@ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
"clap", "clap",
"futures-util",
"google-walletobjects1", "google-walletobjects1",
"http", "http",
"hyper", "hyper",

View file

@ -10,6 +10,7 @@ anyhow = { version = "1.0.68", features = ["backtrace"] }
async-trait = "0.1.61" async-trait = "0.1.61"
chrono = "0.4.23" chrono = "0.4.23"
clap = { version = "4.0.32", features = ["cargo", "derive"] } clap = { version = "4.0.32", features = ["cargo", "derive"] }
futures-util = "0.3.25"
google-walletobjects1 = "4.0.4" google-walletobjects1 = "4.0.4"
http = "0.2.8" http = "0.2.8"
hyper = "0.14.23" hyper = "0.14.23"

View file

@ -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<String>,
}
#[derive(Serialize, Deserialize)]
struct AddToWalletPayload {
#[serde(skip_serializing_if = "Vec::is_empty", rename = "flightClasses")]
flight_classes: Vec<AddToWalletPayloadObject>,
#[serde(skip_serializing_if = "Vec::is_empty", rename = "flightObjects")]
flight_objects: Vec<AddToWalletPayloadObject>,
}
#[derive(Serialize, Deserialize)]
pub struct AddToWalletClaims {
origins: Vec<String>,
#[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<PassClassIdentifier>,
pub flight_passes: Vec<PassIdentifier>,
}
impl AddToWallet {
pub fn to_claims(&self) -> JWTClaims<AddToWalletClaims> {
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<String> {
let claims = self.to_claims();
let token = sa.key_pair.sign(claims)?;
Ok(format!("https://pay.google.com/gp/v/save/{}", token))
}
}

View file

@ -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<T: Clone> {
async fn insert_now(&self, value: &T) -> Result<bool>;
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<S> InsertOrUpdate<FlightClass> for FlightclasMethods<'_, S>
where
S: tower_service::Service<http::Uri> + 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<Box<dyn std::error::Error + Send + Sync>>,
{
async fn insert_now(&self, value: &FlightClass) -> Result<bool> {
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<S> InsertOrUpdate<FlightObject> for FlightobjectMethods<'_, S>
where
S: tower_service::Service<http::Uri> + 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<Box<dyn std::error::Error + Send + Sync>>,
{
async fn insert_now(&self, value: &FlightObject) -> Result<bool> {
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<T: Clone>(into: &dyn InsertOrUpdate<T>, 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<T: Clone>(
into: Arc<&dyn InsertOrUpdate<T>>,
value: &T,
) -> Result<()> {
insert_or_update_now(*into.as_ref(), value).await?;
Ok(())
}
pub async fn insert_or_update_many<T: Clone>(
into: &dyn InsertOrUpdate<T>,
values: Vec<T>,
) -> Result<()> {
let i = Arc::new(into);
try_join_all(
values
.iter()
.map(|c| insert_or_update_now_arc(i.clone(), c)),
)
.await?;
Ok(())
}

7
rust/passgen/src/lib.rs Normal file
View file

@ -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;

View file

@ -1,33 +1,19 @@
#![allow(dead_code)]
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use chrono::naive::NaiveDate;
use chrono::Datelike;
use chrono::Local; use chrono::Local;
use std::cmp::min;
use clap::{Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
use google_walletobjects1::api::{FlightClass, FlightObject};
use async_trait::async_trait; use serde::Deserialize;
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 tokio::try_join; 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)] #[derive(Parser)]
#[command(author, version, about, long_about = None, propagate_version = true, subcommand_required = true, arg_required_else_help = true)] #[command(author, version, about, long_about = None, propagate_version = true, subcommand_required = true, arg_required_else_help = true)]
struct Cli { struct Cli {
@ -95,742 +81,11 @@ struct UploadScan {
image: PathBuf, 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<String> {
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<<DefaultHyperClient as HyperClientBuilder>::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<ServiceAccount> {
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<T> { async fn load_thing<'a, T: Deserialize<'a>>(path: &PathBuf) -> Result<T> {
let data = tokio::fs::read(path).await?; let data = tokio::fs::read(path).await?;
let mut deser = serde_json::Deserializer::from_reader(data.as_slice()); let mut deser = serde_json::Deserializer::from_reader(data.as_slice());
let parsed: T = T::deserialize(&mut deser)?; let parsed: T = T::deserialize(&mut deser)?;
return Ok(parsed); Ok(parsed)
}
#[async_trait]
trait InsertOrUpdate<T: Clone> {
async fn insert_now(&self, value: &T) -> Result<bool>;
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<S> InsertOrUpdate<FlightClass> for FlightclasMethods<'_, S>
where
S: tower_service::Service<http::Uri> + 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<Box<dyn std::error::Error + Send + Sync>>,
{
async fn insert_now(&self, value: &FlightClass) -> Result<bool> {
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<S> InsertOrUpdate<FlightObject> for FlightobjectMethods<'_, S>
where
S: tower_service::Service<http::Uri> + 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<Box<dyn std::error::Error + Send + Sync>>,
{
async fn insert_now(&self, value: &FlightObject) -> Result<bool> {
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<String>,
}
#[derive(Serialize, Deserialize)]
struct AddToWalletPayload {
#[serde(skip_serializing_if = "Vec::is_empty", rename = "flightClasses")]
flight_classes: Vec<AddToWalletPayloadObject>,
#[serde(skip_serializing_if = "Vec::is_empty", rename = "flightObjects")]
flight_objects: Vec<AddToWalletPayloadObject>,
}
#[derive(Serialize, Deserialize)]
struct AddToWalletClaims {
origins: Vec<String>,
#[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<PassengerStatus> {
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<DocumentationVerificationStatus> {
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<PassengerDescription> {
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<CheckInSource> {
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<char>,
passenger_description: Option<PassengerDescription>,
checkin_source: Option<CheckInSource>,
boarding_pass_source: Option<CheckInSource>,
date_of_issue: Option<NaiveDate>,
document_type: Option<char>,
boarding_pass_issuing_airline: Option<String>,
baggage_tag_plate_number: Option<String>,
legs: Vec<BoardingPassLeg>,
security_data_type: u8,
security_data: Vec<u8>,
}
fn parse_boarding_pass_date(
from_boarding_pass_str: &str,
nearby_date: &NaiveDate,
) -> Result<NaiveDate> {
// 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<NaiveDate>) -> Option<u64> {
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<u8>, nearby_date: &NaiveDate) -> Result<BoardingPass> {
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<String>,
document_serial_number: Option<String>,
selectee_indicator: Option<char>,
documentation_verification: Option<DocumentationVerificationStatus>,
marketing_carrier: Option<String>,
frequent_flyer_airline: Option<String>,
frequent_flyer_number: Option<String>,
id_ad_indicator: Option<char>,
free_baggage_allowance: Option<String>,
airline_data: Option<Vec<u8>>,
}
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(())
}
} }
#[tokio::main] #[tokio::main]
@ -839,17 +94,14 @@ async fn main() -> Result<()> {
let command = cli.command.unwrap(); let command = cli.command.unwrap();
match &command { if let Commands::Scan { image } = &command {
Commands::Scan { image } => { println!("{}", scan(image)?);
println!("{}", scan(&image)?); return Ok(());
return Ok(());
}
_ => {}
}; };
let service_account_file = cli let service_account_file = cli
.service_account_file .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 sa = load_service_account(&service_account_file).await?;
let hub = google_walletobjects1::Walletobjects::new( let hub = google_walletobjects1::Walletobjects::new(
@ -861,7 +113,7 @@ async fn main() -> Result<()> {
.enable_http2() .enable_http2()
.build(), .build(),
), ),
sa.authenticator, sa.authenticator.clone(),
); );
match &command { match &command {
@ -900,49 +152,27 @@ async fn main() -> Result<()> {
class.review_status = Some("UNDER_REVIEW".to_string()); class.review_status = Some("UNDER_REVIEW".to_string());
// Try to create the class first... // Try to create the class first...
let created_class: bool = hub.flightclass().insert_now(&class).await?; insert_or_update_now(&hub.flightclass(), &class).await?;
if !created_class {
// Already exists.
hub.flightclass().update_now(&class).await?;
}
// Then the pass... // Then the pass...
let created_pass: bool = hub.flightobject().insert_now(&pass).await?; insert_or_update_now(&hub.flightobject(), &pass).await?;
if !created_pass {
// Already exists.
hub.flightobject().update_now(&pass).await?;
}
// Now generate the URL. // Now generate the URL.
let claims: JWTClaims<AddToWalletClaims> = JWTClaims { println!(
issued_at: None, "{}",
expires_at: None, AddToWallet {
invalid_before: None, service_account_email: sa.service_account_name.to_string(),
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!("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(()) Ok(())
} }
@ -950,88 +180,15 @@ async fn main() -> Result<()> {
let scanned = scan(&upload_scan.image)?; let scanned = scan(&upload_scan.image)?;
let boarding_pass = let boarding_pass =
BoardingPass::parse(scanned.as_bytes().to_vec(), &Local::now().date_naive())?; BoardingPass::parse(scanned.as_bytes().to_vec(), &Local::now().date_naive())?;
if boarding_pass.legs.len() > 1 { let (classes, passes) = passes_from_barcode(&cli.issuer_id, boarding_pass)?;
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 id_prefix = format!( // Create all the classes.
"{}.{}{}-{}-{}{}", insert_or_update_many(&hub.flightclass(), classes).await?;
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);
let mut class = FlightClass::default(); // Create all the objects.
class.id = Some(class_id.clone()); insert_or_update_many(&hub.flightobject(), passes).await?;
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());
Ok(()) 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()),
}),
}
}

View file

@ -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<PassengerStatus> {
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<DocumentationVerificationStatus> {
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<PassengerDescription> {
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<CheckInSource> {
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<char>,
pub passenger_description: Option<PassengerDescription>,
pub checkin_source: Option<CheckInSource>,
pub boarding_pass_source: Option<CheckInSource>,
pub date_of_issue: Option<NaiveDate>,
pub document_type: Option<char>,
pub boarding_pass_issuing_airline: Option<String>,
pub baggage_tag_plate_number: Option<String>,
pub legs: Vec<BoardingPassLeg>,
pub security_data_type: u8,
pub security_data: Vec<u8>,
}
fn parse_boarding_pass_date(
from_boarding_pass_str: &str,
nearby_date: &NaiveDate,
) -> Result<NaiveDate> {
// 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<NaiveDate>) -> Option<u64> {
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<u8>, nearby_date: &NaiveDate) -> Result<BoardingPass> {
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<String>,
pub document_serial_number: Option<String>,
pub selectee_indicator: Option<char>,
pub documentation_verification: Option<DocumentationVerificationStatus>,
pub marketing_carrier: Option<String>,
pub frequent_flyer_airline: Option<String>,
pub frequent_flyer_number: Option<String>,
pub id_ad_indicator: Option<char>,
pub free_baggage_allowance: Option<String>,
pub airline_data: Option<Vec<u8>>,
}
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()
);
}
}

View file

@ -0,0 +1,18 @@
use std::path::PathBuf;
use anyhow::{anyhow, Result};
pub fn scan(image_path: &PathBuf) -> Result<String> {
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())
}

View file

@ -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<<DefaultHyperClient as HyperClientBuilder>::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<ServiceAccount> {
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,
})
}

View file

@ -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"),
},
};

View file

@ -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<FlightClass>, Vec<FlightObject>)> {
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()
}
}