use std::{fs::File, io::Read, path::Path}; use log::*; use secrecy::SecretString; use serde::{de::Error, Deserialize}; use steamguard::SteamGuardAccount; use thiserror::Error; use crate::encryption::EncryptionScheme; use super::{ legacy::{SdaAccount, SdaManifest}, manifest::ManifestV1, steamv2::SteamMobileV2, winauth::parse_winauth_exports, EntryLoader, Manifest, }; pub(crate) fn load_and_migrate( manifest_path: &Path, passkey: Option<&SecretString>, ) -> Result<(Manifest, Vec), MigrationError> { backup_file(manifest_path)?; let parent = manifest_path.parent().unwrap(); parent.read_dir()?.for_each(|e| { let entry = e.unwrap(); if entry.file_type().unwrap().is_file() { let path = entry.path(); let Some(ext) = path.extension() else { return; }; if ext == "maFile" { backup_file(&path).unwrap(); } } }); do_migrate(manifest_path, passkey) } fn do_migrate( manifest_path: &Path, passkey: Option<&SecretString>, ) -> Result<(Manifest, Vec), MigrationError> { let mut file = File::open(manifest_path)?; let mut buffer = String::new(); file.read_to_string(&mut buffer)?; let mut manifest: MigratingManifest = deserialize_manifest(buffer).map_err(MigrationError::ManifestDeserializeFailed)?; if manifest.is_encrypted() && passkey.is_none() { return Err(MigrationError::MissingPasskey { keyring_id: None }); } else if !manifest.is_encrypted() && passkey.is_some() { // no custom error because this is an edge case, mostly user error return Err(MigrationError::UnexpectedError(anyhow::anyhow!("A passkey was provided but the manifest is not encrypted. Aborting migration because it would encrypt the maFiles, and you probably didn't mean to do that."))); } let folder = manifest_path.parent().unwrap(); let mut accounts = manifest.load_all_accounts(folder, passkey)?; while !manifest.is_latest() { manifest = manifest.upgrade(); for account in accounts.iter_mut() { *account = account.clone().upgrade(); } } // HACK: force account names onto manifest entries let mut manifest: Manifest = manifest.into(); let accounts: Vec = accounts.into_iter().map(|a| a.into()).collect(); for (i, entry) in manifest.entries.iter_mut().enumerate() { entry.account_name = accounts[i].account_name.to_lowercase(); } Ok((manifest, accounts)) } fn backup_file(path: &Path) -> anyhow::Result<()> { let backup_path = Path::join( path.parent().unwrap(), format!("{}.bak", path.file_name().unwrap().to_str().unwrap()), ); std::fs::copy(path, backup_path)?; Ok(()) } #[derive(Debug, Error)] pub(crate) enum MigrationError { #[error("Passkey is required to decrypt manifest")] MissingPasskey { keyring_id: Option }, #[error("Failed to deserialize manifest: {0}")] ManifestDeserializeFailed(serde_path_to_error::Error), #[error("IO error when upgrading manifest: {0}")] IoError(#[from] std::io::Error), #[error("An unexpected error occurred during manifest migration: {0}")] UnexpectedError(#[from] anyhow::Error), } #[derive(Debug)] enum MigratingManifest { Sda(SdaManifest), ManifestV1(ManifestV1), } impl MigratingManifest { pub fn upgrade(self) -> Self { match self { Self::Sda(sda) => Self::ManifestV1(sda.into()), Self::ManifestV1(_) => self, } } pub fn is_latest(&self) -> bool { matches!(self, Self::ManifestV1(_)) } pub fn is_encrypted(&self) -> bool { match self { Self::Sda(manifest) => manifest.entries.iter().any(|e| e.encryption.is_some()), Self::ManifestV1(manifest) => manifest.entries.iter().any(|e| e.encryption.is_some()), } } pub fn load_all_accounts( &self, folder: &Path, passkey: Option<&SecretString>, ) -> anyhow::Result> { debug!("loading all accounts for migration"); let accounts = match self { Self::Sda(sda) => { let (accounts, errors) = sda .entries .iter() .map(|e| { let params: Option = e.encryption.clone().map(|e| e.into()); e.load(&Path::join(folder, &e.filename), passkey, params.as_ref()) }) .partition::, _>(Result::is_ok); let accounts: Vec<_> = accounts.into_iter().map(Result::unwrap).collect(); let errors: Vec<_> = errors.into_iter().map(Result::unwrap_err).collect(); if !errors.is_empty() { return Err(anyhow::anyhow!( "Failed to load some accounts: {:?}", errors )); } accounts .into_iter() .map(ExternalAccount::Sda) .map(MigratingAccount::External) .collect() } Self::ManifestV1(manifest) => { let (accounts, errors) = manifest .entries .iter() .map(|e| { e.load( &Path::join(folder, &e.filename), passkey, e.encryption.as_ref(), ) }) .partition::, _>(Result::is_ok); let accounts: Vec<_> = accounts.into_iter().map(Result::unwrap).collect(); let errors: Vec<_> = errors.into_iter().map(Result::unwrap_err).collect(); if !errors.is_empty() { return Err(anyhow::anyhow!( "Failed to load some accounts: {:?}", errors )); } accounts .into_iter() .map(MigratingAccount::ManifestV1) .collect() } }; Ok(accounts) } } impl From for Manifest { fn from(migrating: MigratingManifest) -> Self { match migrating { MigratingManifest::ManifestV1(manifest) => manifest, _ => panic!("Manifest is not at the latest version!"), } } } #[derive(Deserialize)] struct JustVersion { version: Option, } fn deserialize_manifest( text: String, ) -> Result> { let mut deser = serde_json::Deserializer::from_str(&text); let version: JustVersion = serde_path_to_error::deserialize(&mut deser)?; debug!("deserializing manifest: version {:?}", version.version); let mut deser = serde_json::Deserializer::from_str(&text); match version.version { Some(1) => { let manifest: ManifestV1 = serde_path_to_error::deserialize(&mut deser)?; Ok(MigratingManifest::ManifestV1(manifest)) } None => { let manifest: SdaManifest = serde_path_to_error::deserialize(&mut deser)?; Ok(MigratingManifest::Sda(manifest)) } _ => { // HACK: there's no way to construct the Path type, so we create it by forcing a deserialize error #[derive(Debug)] struct Dummy; impl<'de> Deserialize<'de> for Dummy { fn deserialize>(_: D) -> Result { Err(D::Error::custom("Unknown manifest version".to_string())) } } let mut deser = serde_json::Deserializer::from_str(""); let err = serde_path_to_error::deserialize::<_, Dummy>(&mut deser).unwrap_err(); Err(err) } } } #[derive(Debug, Clone)] #[allow(clippy::large_enum_variant)] enum MigratingAccount { External(ExternalAccount), ManifestV1(SteamGuardAccount), } impl MigratingAccount { pub fn upgrade(self) -> Self { match self { Self::External(account) => Self::ManifestV1(account.into()), Self::ManifestV1(_) => self, } } pub fn is_latest(&self) -> bool { matches!(self, Self::ManifestV1(_)) } } impl From for SteamGuardAccount { fn from(migrating: MigratingAccount) -> Self { match migrating { MigratingAccount::ManifestV1(account) => account, _ => panic!("Account is not at the latest version!"), } } } pub fn load_and_upgrade_external_accounts(path: &Path) -> anyhow::Result> { let mut file = File::open(path)?; let mut buf = vec![]; file.read_to_end(&mut buf)?; let mut deser = serde_json::Deserializer::from_slice(&buf); let accounts = match serde_path_to_error::deserialize(&mut deser) { Ok(account) => { vec![MigratingAccount::External(account)] } Err(json_err) => { // the file is not JSON, so it's probably a winauth export match parse_winauth_exports(buf) { Ok(accounts) => accounts .into_iter() .map(MigratingAccount::External) .collect(), Err(winauth_err) => { bail!( "Failed to parse as JSON: {}\nFailed to parse as Winauth export: {}", json_err, winauth_err ) } } } }; Ok(accounts .into_iter() .map(|mut account| { while !account.is_latest() { account = account.upgrade(); } account.into() }) .collect()) } #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] #[allow(clippy::large_enum_variant)] pub(crate) enum ExternalAccount { Sda(SdaAccount), SteamMobileV2(SteamMobileV2), } impl From for SteamGuardAccount { fn from(account: ExternalAccount) -> Self { match account { ExternalAccount::Sda(account) => account.into(), ExternalAccount::SteamMobileV2(account) => account.into(), } } } #[cfg(test)] mod tests { use crate::{accountmanager::CURRENT_MANIFEST_VERSION, AccountManager}; use super::*; #[test] fn should_migrate_to_latest_version() -> anyhow::Result<()> { #[derive(Debug)] struct Test { manifest: &'static str, passkey: Option, } let cases = vec![ Test { manifest: "src/fixtures/maFiles/compat/1-account/manifest.json", passkey: None, }, Test { manifest: "src/fixtures/maFiles/compat/1-account-encrypted/manifest.json", passkey: Some(SecretString::new("password".into())), }, Test { manifest: "src/fixtures/maFiles/compat/2-account/manifest.json", passkey: None, }, Test { manifest: "src/fixtures/maFiles/compat/missing-account-name/manifest.json", passkey: None, }, Test { manifest: "src/fixtures/maFiles/compat/no-webcookie/manifest.json", passkey: None, }, Test { manifest: "src/fixtures/maFiles/compat/null-oauthtoken/manifest.json", passkey: None, }, Test { manifest: "src/fixtures/maFiles/compat/difficult-migration/manifest.json", passkey: None, }, ]; for case in cases { eprintln!("testing: {:?}", case); let (manifest, accounts) = do_migrate(Path::new(case.manifest), case.passkey.as_ref())?; assert_eq!(manifest.version, CURRENT_MANIFEST_VERSION); assert_eq!(manifest.entries[0].account_name, "example"); assert_eq!(manifest.entries[0].steam_id, 1234); assert_eq!(accounts[0].account_name, "example"); assert_eq!(accounts[0].steam_id, 1234); } Ok(()) } #[test] fn should_migrate_single_accounts() -> anyhow::Result<()> { #[derive(Debug)] struct Test { mafile: &'static str, account_name: &'static str, steam_id: u64, } let cases = vec![ Test { mafile: "src/fixtures/maFiles/compat/1-account/1234.maFile", account_name: "example", steam_id: 1234, }, Test { mafile: "src/fixtures/maFiles/compat/2-account/1234.maFile", account_name: "example", steam_id: 1234, }, Test { mafile: "src/fixtures/maFiles/compat/2-account/5678.maFile", account_name: "example2", steam_id: 5678, }, Test { mafile: "src/fixtures/maFiles/compat/missing-account-name/1234.maFile", account_name: "example", steam_id: 1234, }, Test { mafile: "src/fixtures/maFiles/compat/no-webcookie/nowebcookie.maFile", account_name: "example", steam_id: 1234, }, Test { mafile: "src/fixtures/maFiles/compat/null-oauthtoken/nulloauthtoken.maFile", account_name: "example", steam_id: 1234, }, Test { mafile: "src/fixtures/maFiles/compat/steamv2/sample.maFile", account_name: "afarihm", steam_id: 76561199441992970, }, Test { mafile: "src/fixtures/maFiles/compat/winauth/exports.txt", account_name: "example", steam_id: 1234, }, ]; for case in cases { eprintln!("testing: {:?}", case); let accounts = load_and_upgrade_external_accounts(Path::new(case.mafile))?; let account = accounts[0].clone(); assert_eq!(account.account_name, case.account_name); assert_eq!(account.steam_id, case.steam_id); } Ok(()) } #[test] fn should_migrate_to_latest_version_save_and_load_again() -> anyhow::Result<()> { #[derive(Debug)] struct Test { dir: &'static str, passkey: Option, } let cases = vec![ Test { dir: "src/fixtures/maFiles/compat/1-account/", passkey: None, }, Test { dir: "src/fixtures/maFiles/compat/1-account-encrypted/", passkey: Some(SecretString::new("password".into())), }, Test { dir: "src/fixtures/maFiles/compat/2-account/", passkey: None, }, Test { dir: "src/fixtures/maFiles/compat/missing-account-name/", passkey: None, }, Test { dir: "src/fixtures/maFiles/compat/no-webcookie/", passkey: None, }, Test { dir: "src/fixtures/maFiles/compat/null-oauthtoken/", passkey: None, }, ]; for case in cases { eprintln!("testing: {:?}", case); let temp = tempdir::TempDir::new("steamguard-cli-test")?; for file in std::fs::read_dir(case.dir)? { let file = file?; let path = file.path(); let dest = temp.path().join(path.file_name().unwrap()); std::fs::copy(&path, dest)?; } let (manifest, accounts) = do_migrate( Path::join(temp.path(), "manifest.json").as_path(), case.passkey.as_ref(), )?; assert_eq!(manifest.version, CURRENT_MANIFEST_VERSION); assert_eq!(manifest.entries[0].account_name, "example"); assert_eq!(manifest.entries[0].steam_id, 1234); assert_eq!(accounts[0].account_name, "example"); assert_eq!(accounts[0].steam_id, 1234); let mut manager = AccountManager::from_manifest(manifest, temp.path().to_str().unwrap().to_owned()); manager.submit_passkey(case.passkey.clone()); manager.register_accounts(accounts); manager.save()?; let path = Path::join(temp.path(), "manifest.json"); let mut manager = AccountManager::load(path.as_path())?; manager.submit_passkey(case.passkey.clone()); manager.load_accounts()?; let account = manager.get_or_load_account("example")?; let account = account.lock().unwrap(); assert_eq!(account.account_name, "example"); assert_eq!(account.steam_id, 1234); } Ok(()) } }