#![forbid(unsafe_code)]
#![warn(clippy::pedantic)]
#![feature(is_some_and)]
#![feature(split_array)]
#![feature(yeet_expr)]
#![feature(try_blocks)]
#![feature(let_chains)]
#![feature(once_cell)]
#![feature(type_changing_struct_update)]
use core::fmt;
use std::{
fmt::Write as _,
io::{self, BufRead},
marker::PhantomData,
str::FromStr,
};
use crc::{Digest, CRC_32_ISO_HDLC};
use crypto::{decode_secrets_in_text, decrypt_encryption_key, generate_boostrap_key};
use data_encoding::BASE64;
pub mod crypto;
pub mod errors;
pub mod fritz_base32;
use errors::{Error, FormatError, SpecialLineError, EOF};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct File {
pub name: String,
pub content: FileContent,
}
impl File {
fn update_crc(&self, crc: &mut Digest<u32>) {
crc.update(self.name.as_bytes());
crc.update(b"\0");
match &self.content {
FileContent::Plain(s) => {
if let Some((text, _last_line)) = s.rsplit_once('\n') {
crc.update(text.as_bytes());
}
}
FileContent::Binary(data) => crc.update(data),
}
}
fn parse_file_entry(
mut lines: impl Iterator<Item = Result<String, std::io::Error>>,
) -> Result<File, Error> {
let line = lines
.next()
.ok_or(FormatError::ExpectedStartOfFileSection { got: EOF })??;
let SpecialLine::StartOfFile { kind, name } = line.parse()? else {
do yeet FormatError::ExpectedStartOfFileSection { got: line.into() };
};
let mut content = match kind {
ContentKind::Plain => FileContent::Plain(String::new()),
ContentKind::Binary => FileContent::Binary(Vec::new()),
};
for line in lines {
let line = line?;
if let Ok(special) = line.parse() {
let SpecialLine::EndOfFile = special else {
do yeet FormatError::ExpectedEndOfFileSection { got: special };
};
break;
}
content.extend_with_line(&line)?;
}
Ok(File { name, content })
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ContentKind {
Plain,
Binary,
}
impl From<&FileContent> for ContentKind {
fn from(content: &FileContent) -> Self {
match content {
FileContent::Plain(_) => ContentKind::Plain,
FileContent::Binary(_) => ContentKind::Binary,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileContent {
Plain(String),
Binary(Vec<u8>),
}
impl FileContent {
fn extend_with_line(&mut self, line: &str) -> Result<(), Error> {
match self {
FileContent::Plain(s) => {
let line = line.replace(r"\\", r"\");
writeln!(s, "{line}").unwrap();
}
FileContent::Binary(v) => {
let data = BASE64
.decode(line.as_bytes())
.map_err(FormatError::InvalidBinaryData)?;
v.extend_from_slice(&data);
}
}
Ok(())
}
pub fn decrypt(&self, encryption_key: [u8; 32]) -> Result<Option<Self>, Error> {
match self {
FileContent::Plain(s) => {
Ok(decode_secrets_in_text(s, encryption_key)?.map(FileContent::Plain))
}
FileContent::Binary(_) => Ok(None),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SpecialLine {
StartOfExport { model: String },
EndOfExport { crc: u32 },
StartOfFile { name: String, kind: ContentKind },
EndOfFile,
}
impl SpecialLine {
fn parse_inner(line: &str) -> Result<Self, SpecialLineError> {
if let Some(model) = line.strip_suffix(" CONFIGURATION EXPORT") {
Ok(Self::StartOfExport {
model: model.to_string(),
})
} else if let Some(name) = line.strip_prefix("CFGFILE:") {
Ok(Self::StartOfFile {
name: name.to_string(),
kind: ContentKind::Plain,
})
} else if let Some(name) = line.strip_prefix("B64FILE:") {
Ok(Self::StartOfFile {
name: name.to_string(),
kind: ContentKind::Binary,
})
} else if let Some(line) = line.strip_suffix(" ****") {
if line == "END OF FILE" {
Ok(Self::EndOfFile)
} else if let Some(crc_str) = line.strip_prefix("END OF EXPORT ") {
let crc: Option<[u8; 4]> = try { hex::decode(crc_str).ok()?.try_into().ok()? };
let crc = crc.ok_or(SpecialLineError::MalformedCrc {
crc: crc_str.to_string(),
})?;
let crc = u32::from_be_bytes(crc);
Ok(Self::EndOfExport { crc })
} else {
Err(SpecialLineError::UnknownFormat)
}
} else {
Err(SpecialLineError::UnknownFormat)
}
}
#[must_use]
pub fn check(line: &str) -> bool {
line.starts_with("**** ")
}
}
impl FromStr for SpecialLine {
type Err = Error;
fn from_str(line: &str) -> Result<Self, Self::Err> {
let line = line
.strip_prefix("**** ")
.ok_or(Error::FormatError(FormatError::ExpectedSpecialLine))?;
Self::parse_inner(line).map_err(|kind| {
FormatError::InvalidSpecialLine {
line: line.to_string(),
kind,
}
.into()
})
}
}
impl fmt::Display for SpecialLine {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "**** ")?;
match self {
SpecialLine::StartOfExport { model } => writeln!(f, "{model} CONFIGURATION EXPORT"),
SpecialLine::EndOfExport { crc } => writeln!(f, "END OF EXPORT {crc:08X} ****"),
SpecialLine::StartOfFile { name, kind } => match kind {
ContentKind::Plain => writeln!(f, "CFGFILE:{name}"),
ContentKind::Binary => writeln!(f, "B64FILE:{name}"),
},
SpecialLine::EndOfFile => writeln!(f, "END OF FILE ****"),
}
}
}
pub mod markers {
mod private {
pub trait Sealed {}
}
pub trait CrcState: private::Sealed {}
pub struct Verified;
impl private::Sealed for Verified {}
impl CrcState for Verified {}
pub struct Unverified;
impl private::Sealed for Unverified {}
impl CrcState for Unverified {}
}
use markers::{CrcState, Unverified, Verified};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfigBackup<S: CrcState> {
model: String,
properties: Vec<(String, String)>,
files: Vec<File>,
crc: u32,
state: PhantomData<S>,
}
impl ConfigBackup<Unverified> {
pub fn load<R: BufRead>(input: R) -> Result<Self, Error> {
let mut lines = input.lines().peekable();
let header = lines
.next()
.ok_or(FormatError::ExpectedHeader { got: EOF })??;
let SpecialLine::StartOfExport { model } = header.parse()? else {
return Err(FormatError::ExpectedHeader { got: header.into() }.into());
};
let mut properties = Vec::new();
while !lines
.peek()
.is_some_and(|line| line.as_ref().is_ok_and(|s| SpecialLine::check(s)))
{
let line = lines
.next()
.ok_or(FormatError::ExpectedProperty { got: EOF })??;
let Some((key, value)) = line.split_once('=') else {
return Err(FormatError::ExpectedProperty { got: line.into() }.into())
};
properties.push((key.to_string(), value.to_string()));
}
let mut files = Vec::new();
let encountered_crc = loop {
if let Some(Ok(line)) = lines.peek() &&
let SpecialLine::EndOfExport { crc: encountered_crc } = line.parse()?
{
lines.next();
break encountered_crc;
}
let file = File::parse_file_entry(&mut lines)?;
files.push(file);
};
Ok(Self {
model,
properties,
files,
crc: encountered_crc,
state: PhantomData,
})
}
#[allow(clippy::missing_errors_doc, clippy::result_large_err)]
pub fn verify_crc(self) -> Result<ConfigBackup<Verified>, (Self, Error)> {
let calculated_crc = self.calculate_crc();
if calculated_crc == self.crc {
Ok(ConfigBackup::<Verified> {
state: PhantomData,
..self
})
} else {
let e = FormatError::InvalidCrc {
expected: self.crc,
calculated: calculated_crc,
}
.into();
Err((self, e))
}
}
#[must_use]
pub fn update_crc(self) -> ConfigBackup<Verified> {
ConfigBackup::<Verified> {
state: PhantomData,
crc: self.calculate_crc(),
..self
}
}
}
impl ConfigBackup<Verified> {
pub fn serialize<W: io::Write>(self, mut w: W) -> Result<(), io::Error> {
debug_assert_eq!(self.crc, self.calculate_crc());
write!(w, "{}", SpecialLine::StartOfExport { model: self.model })?;
for (key, value) in self.properties {
writeln!(w, "{key}={value}")?;
}
for file in self.files {
write!(
w,
"{}",
SpecialLine::StartOfFile {
name: file.name,
kind: ContentKind::from(&file.content),
}
)?;
match file.content {
FileContent::Plain(s) => write!(w, "{}", s.replace('\\', r"\\"))?,
FileContent::Binary(data) => {
if data.is_empty() {
writeln!(w)?;
} else {
for chunk in data.chunks(80 * 6 / 8) {
writeln!(w, "{}", BASE64.encode(chunk))?;
}
}
}
}
write!(w, "{}", SpecialLine::EndOfFile)?;
}
write!(w, "{}", SpecialLine::EndOfExport { crc: self.crc })?;
Ok(())
}
}
impl<S: CrcState> ConfigBackup<S> {
#[must_use]
fn calculate_crc(&self) -> u32 {
let crc = crc::Crc::<u32>::new(&CRC_32_ISO_HDLC);
let mut crc = crc.digest();
for (key, value) in &self.properties {
crc.update(key.as_bytes());
crc.update(value.as_bytes());
crc.update(b"\0");
}
for file in &self.files {
file.update_crc(&mut crc);
}
crc.finalize()
}
#[must_use]
pub fn files(&self) -> &[File] {
self.files.as_ref()
}
#[must_use]
pub fn properties(&self) -> &[(String, String)] {
self.properties.as_ref()
}
#[must_use]
pub fn model(&self) -> &str {
self.model.as_ref()
}
#[must_use]
pub fn crc(&self) -> u32 {
self.crc
}
pub fn decrypt(mut self, password: &str) -> Result<ConfigBackup<Unverified>, Error> {
let bootstrap_key = generate_boostrap_key(password);
let encryption_key = self
.properties
.iter()
.find_map(|(name, val)| (name == "Password").then_some(val))
.ok_or(FormatError::MissingPasswordProperty)?;
let encryption_key = decrypt_encryption_key(encryption_key, bootstrap_key)?;
for file in &mut self.files {
if let Some(content) = file.content.decrypt(encryption_key)? {
file.content = content;
}
}
Ok(ConfigBackup::<Unverified> {
state: PhantomData,
..self
})
}
}