From c52756d17c241f15ef7dc1d0ba496cdd06a1d67d Mon Sep 17 00:00:00 2001 From: gregorbednov Date: Thu, 9 Apr 2026 21:50:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B2=20=C2=AB?= =?UTF-8?q?src=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/alphabet.rs | 433 ++++++++++++++++++++++++++++++++++++++++++++++ src/app.rs | 156 +++++++++++++++++ src/ciphertext.rs | 54 ++++++ src/cli.rs | 56 ++++++ src/decoder.rs | 202 +++++++++++++++++++++ 5 files changed, 901 insertions(+) create mode 100644 src/alphabet.rs create mode 100644 src/app.rs create mode 100644 src/ciphertext.rs create mode 100644 src/cli.rs create mode 100644 src/decoder.rs diff --git a/src/alphabet.rs b/src/alphabet.rs new file mode 100644 index 0000000..3df87e4 --- /dev/null +++ b/src/alphabet.rs @@ -0,0 +1,433 @@ +use bitvec::prelude::{BitSlice, BitVec}; +use rand::{CryptoRng, Rng, RngExt}; +use std::io::{Error, ErrorKind, Result}; + +const MAGIC: [u8; 4] = [b'E', b'C', b'A', b'1']; + +pub struct BitFlip { + pub index: usize, + pub old: bool, + pub new: bool, +} + +pub struct DecipherPatch { + pub symbol: char, + pub distance: usize, + pub flips: Vec, +} + +pub struct Alphabet { + r: R, + chars: String, + dict: BitVec, + len: usize, + mut_rate: usize, +} + +impl Alphabet { + + fn symbol_index(&self, c: char) -> Result { + self.chars + .chars() + .position(|x| x == c) + .ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("symbol {:?} not found", c))) +} + +pub fn symbol_bits(&self, c: char) -> Result> { + let index = self.symbol_index(c)?; + let start = index * self.len; + let end = (index + 1) * self.len; + Ok(self.dict[start..end].to_bitvec()) +} + +pub fn replace_symbol_bits( + &mut self, + c: char, + bits: &BitSlice, +) -> Result> { + if bits.len() < self.len { + return Err(Error::new( + ErrorKind::InvalidInput, + "not enough bits for symbol replacement", + )); + } + + let index = self.symbol_index(c)?; + let start = index * self.len; + let end = (index + 1) * self.len; + + let old = self.dict[start..end].to_bitvec(); + + for i in 0..self.len { + self.dict.set(start + i, bits[i]); + } + + Ok(old) +} + +pub fn replace_symbol_bits_by_index( + &mut self, + index: usize, + bits: &BitSlice, +) -> Result> { + if bits.len() < self.len { + return Err(Error::new( + ErrorKind::InvalidInput, + "not enough bits for symbol replacement", + )); + } + + let start = index * self.len; + let end = (index + 1) * self.len; + + if end > self.dict.len() { + return Err(Error::new(ErrorKind::InvalidInput, "symbol index out of range")); + } + + let old = self.dict[start..end].to_bitvec(); + + for i in 0..self.len { + self.dict.set(start + i, bits[i]); + } + + Ok(old) +} + +pub fn symbol_distance(&self, c: char, bits: &BitSlice) -> Result { + if bits.len() < self.len { + return Err(Error::new( + ErrorKind::InvalidInput, + "not enough bits for one symbol", + )); + } + + let index = self.symbol_index(c)?; + let start = index * self.len; + let end = (index + 1) * self.len; + let letter = &self.dict[start..end]; + + Ok(bits[..self.len] + .iter() + .zip(letter.iter()) + .filter(|(a, b)| *a != *b) + .count()) +} + + pub fn apply_symbol_choice( + &self, + bits: &mut BitSlice, + symbol: char, + ) -> Result { + if bits.len() < self.len { + return Err(Error::new( + ErrorKind::InvalidInput, + "not enough bits for one symbol", + )); + } + + let index = self + .chars + .chars() + .position(|x| x == symbol) + .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "symbol not found in alphabet"))?; + + let start = index * self.len; + let end = (index + 1) * self.len; + let letter = &self.dict[start..end]; + + let mut flips = Vec::new(); + let mut distance = 0; + + for i in 0..self.len { + let old = bits[i]; + let new = letter[i]; + + if old != new { + distance += 1; + bits.set(i, new); + flips.push(BitFlip { index: i, old, new }); + } + } + + Ok(DecipherPatch { + symbol, + distance, + flips, + }) + } + + pub fn best_symbol_distance_no_effect(&self, bits: &BitSlice) -> Result<(char, usize)> { + self.decipher_candidates_no_effect(bits, 1)? + .into_iter() + .next() + .ok_or_else(|| Error::new(ErrorKind::InvalidData, "alphabet is empty")) + } + + pub fn generate(mut rng: R, chars: String, len: usize, mut_rate: usize) -> Self { + let str_len = chars.chars().count(); + let dict_bit_len = len * str_len + rng.random_range(0..len); + let dict_byte_len = dict_bit_len.div_ceil(8); + let mut random_bytes = vec![0u8; dict_byte_len]; + rng.fill(&mut random_bytes[..]); + let mut dict = BitVec::::from_vec(random_bytes); + dict.truncate(dict_bit_len); + Alphabet { + r: rng, + chars, + dict, + len, + mut_rate, + } + } + + pub fn into_bytes(self) -> Vec { + let dict_bit_len = (self.dict.len() as u64).to_le_bytes(); + let d = self.dict.into_vec(); + let s = self.chars.into_bytes(); + let bl = (self.len as u64).to_le_bytes(); + let sl_bytes = (s.len() as u64).to_le_bytes(); + let mut_rate = (self.mut_rate as u64).to_le_bytes(); + + [ + &MAGIC[..], + &bl[..], + &sl_bytes[..], + &dict_bit_len[..], + &mut_rate[..], + &s[..], + &d[..], + ] + .concat() + } + + pub fn symbol_len(&self) -> usize { + self.len + } + + pub fn from_bytes(rng: R, bytes: &[u8]) -> Result { + const HEADER_LEN: usize = 4 + 8 + 8 + 8 + 8; + + if bytes.len() < HEADER_LEN { + return Err(Error::new(ErrorKind::InvalidData, "file too short")); + } + + if bytes[0..4] != MAGIC { + return Err(Error::new(ErrorKind::InvalidData, "bad magic")); + } + + let bl = u64::from_le_bytes( + bytes[4..12] + .try_into() + .map_err(|_| Error::new(ErrorKind::InvalidData, "bad len field"))?, + ) as usize; + + let sl_bytes = u64::from_le_bytes( + bytes[12..20] + .try_into() + .map_err(|_| Error::new(ErrorKind::InvalidData, "bad string length field"))?, + ) as usize; + + let dict_bit_len = u64::from_le_bytes( + bytes[20..28] + .try_into() + .map_err(|_| Error::new(ErrorKind::InvalidData, "bad dict bit length field"))?, + ) as usize; + + let mut_rate = u64::from_le_bytes( + bytes[28..36] + .try_into() + .map_err(|_| Error::new(ErrorKind::InvalidData, "bad mut_rate field"))?, + ) as usize; + + let dict_byte_len = dict_bit_len.div_ceil(8); + let expected_len = HEADER_LEN + sl_bytes + dict_byte_len; + + if bytes.len() < expected_len { + return Err(Error::new(ErrorKind::InvalidData, "wrong file size")); + } + + let s_start = HEADER_LEN; + let s_end = s_start + sl_bytes; + let d_start = s_end; + let d_end = d_start + dict_byte_len; + + let chars = String::from_utf8(bytes[s_start..s_end].to_vec()) + .map_err(|_| Error::new(ErrorKind::InvalidData, "chars is not valid UTF-8"))?; + + let mut dict = BitVec::::from_vec(bytes[d_start..d_end].to_vec()); + dict.truncate(dict_bit_len); + + Ok(Alphabet { + r: rng, + chars, + dict, + len: bl, + mut_rate, + }) + } + + fn cipher_no_effect(&self, c: char) -> Result> { + let index = self.chars.chars().position(|x| x == c).ok_or_else(|| { + Error::new(ErrorKind::InvalidInput, format!("symbol {:?} not found", c)) + })?; + let start = index * self.len; + let end = (index + 1) * self.len; + let letter = self.dict[start..end].to_bitvec(); + + Ok(letter) + } + + pub fn decipher_candidates_no_effect( + &self, + bits: &bitvec::slice::BitSlice, + top: usize, + ) -> Result> { + if bits.len() < self.len { + return Err(Error::new( + ErrorKind::InvalidInput, + "not enough bits for one symbol", + )); + } + + let needle = &bits[..self.len]; + let mut candidates = Vec::new(); + + for (index, ch) in self.chars.chars().enumerate() { + let start = index * self.len; + let end = (index + 1) * self.len; + let letter = &self.dict[start..end]; + + let distance = needle + .iter() + .zip(letter.iter()) + .filter(|(a, b)| *a != *b) + .count(); + + candidates.push((ch, distance)); + } + + candidates.sort_by_key(|(_, distance)| *distance); + + if candidates.len() > top { + candidates.truncate(top); + } + + Ok(candidates) + } + + pub fn apply_symbol_repair( + &self, + bits: &mut bitvec::slice::BitSlice, + ) -> Result { + if bits.len() < self.len { + return Err(Error::new( + ErrorKind::InvalidInput, + "not enough bits for one symbol", + )); + } + + let best = self + .decipher_candidates_no_effect(bits, 1)? + .into_iter() + .next() + .ok_or_else(|| Error::new(ErrorKind::InvalidData, "alphabet is empty"))?; + + let (symbol, distance) = best; + + let index = self + .chars + .chars() + .position(|x| x == symbol) + .ok_or_else(|| Error::new(ErrorKind::InvalidData, "best symbol not found"))?; + + let start = index * self.len; + let end = (index + 1) * self.len; + let letter = &self.dict[start..end]; + + let mut flips = Vec::new(); + + for i in 0..self.len { + let old = bits[i]; + let new = letter[i]; + + if old != new { + bits.set(i, new); + flips.push(BitFlip { index: i, old, new }); + } + } + + Ok(DecipherPatch { + symbol, + distance, + flips, + }) + } + + pub fn undo_patch( + &self, + bits: &mut bitvec::slice::BitSlice, + patch: &DecipherPatch, + ) -> Result<()> { + if bits.len() < self.len { + return Err(Error::new( + ErrorKind::InvalidInput, + "not enough bits for one symbol", + )); + } + + for flip in &patch.flips { + bits.set(flip.index, flip.old); + } + + Ok(()) + } + + fn mutate(&mut self) { + let end = self.dict.len(); + + if end == 0 { + return; + } + + let v: Vec = (0..self.mut_rate) + .map(|_| self.r.random_range(0..end)) + .collect(); + + for i in v { + let val = self.dict[i]; + self.dict.set(i, !val); + } + } +} +pub trait MutationallyCipherable { + fn cipher(self, a: &mut Alphabet) -> Result>; +} +impl MutationallyCipherable for char { + fn cipher(self, a: &mut Alphabet) -> Result> { + let res = a.cipher_no_effect(self)?; + a.mutate(); + Ok(res) + } +} +impl MutationallyCipherable for String { + fn cipher(self, a: &mut Alphabet) -> Result> { + let mut res: BitVec = BitVec::new(); + for c in self.chars() { + res.extend(c.cipher(a)?) + } + Ok(res) + } +} +impl MutationallyCipherable for &str { + fn cipher(self, a: &mut Alphabet) -> Result> { + let mut res: BitVec = BitVec::new(); + for c in self.chars() { + res.extend(c.cipher(a)?) + } + let tail_len = a.r.random_range(..a.dict.len()); + for _ in 1..tail_len { + res.push(a.r.random_bool(0.5)); + } + Ok(res) + } +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..e5ed9c2 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,156 @@ +use crate::alphabet::{Alphabet, MutationallyCipherable}; +use crate::ciphertext::Ciphertext; +use crate::storage::AlphabetFile; +use bitvec::prelude::BitVec; +use rand_chacha::ChaCha20Rng; +use std::fs; +use std::fs::File; +use std::io::{self, Error, ErrorKind, Read, Write}; +use std::path::PathBuf; + +fn parse_bitvec_input(s: &str) -> io::Result> { + let mut bits = BitVec::::new(); + + for ch in s.chars() { + match ch { + '0' => bits.push(false), + '1' => bits.push(true), + c if c.is_whitespace() => {} + other => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("unexpected character in bitstream: {:?}", other), + )); + } + } + } + + Ok(bits) +} + +fn read_input(file: Option) -> io::Result { + let mut s = String::new(); + + match file { + Some(path) => File::open(path)?.read_to_string(&mut s)?, + None => io::stdin().read_to_string(&mut s)?, + }; + + Ok(s) +} + +pub fn generate_cmd( + path: PathBuf, + chars: Option, + chars_file: Option, + len: usize, + mut_rate: usize, +) -> io::Result<()> { + if len == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "len must be > 0", + )); + } + + let rng: ChaCha20Rng = rand::make_rng(); + let chars = read_chars(chars, chars_file)?; + let alphabet = Alphabet::generate(rng, chars, len, mut_rate); + let af = AlphabetFile::create(path, alphabet)?; + af.save() +} + +pub fn cipher_cmd( + alphabet_path: PathBuf, + file: Option, + output: Option, +) -> io::Result<()> { + let rng: ChaCha20Rng = rand::make_rng(); + let mut af = AlphabetFile::open(alphabet_path, rng)?; + let input = read_input(file)?; + let bits = input + .as_str() + .cipher(&mut af.dict) + .map_err(io::Error::other)?; + + let ciphertext = Ciphertext::from_bits(bits); + let bytes = ciphertext.into_bytes(); + write_output_bytes(output, &bytes) +} + +fn read_chars(chars: Option, chars_file: Option) -> io::Result { + if let Some(path) = chars_file { + let s = fs::read_to_string(path)?; + if s.is_empty() { + return Err(Error::new(ErrorKind::InvalidInput, "chars file is empty")); + } + return Ok(s); + } + + if let Some(s) = chars { + if s.is_empty() { + return Err(Error::new(ErrorKind::InvalidInput, "chars is empty")); + } + return Ok(s); + } + + Ok("abcdefghijklmoprqstuvwxyz ".to_string()) +} + +fn read_input_bytes(file: Option) -> io::Result> { + let mut buf = Vec::new(); + + match file { + Some(path) => File::open(path)?.read_to_end(&mut buf)?, + None => io::stdin().read_to_end(&mut buf)?, + }; + + Ok(buf) +} + +pub fn decipher_cmd( + alphabet_path: PathBuf, + file: Option, + output: Option, +) -> io::Result<()> { + let rng: ChaCha20Rng = rand::make_rng(); + let af = AlphabetFile::open(alphabet_path, rng)?; + let bytes = read_input_bytes(file)?; + let mut bits = Ciphertext::from_bytes(&bytes) + .map_err(io::Error::other)? + .bits; + + let mut out = String::new(); + let mut pos = 0usize; + let sym_len = af.dict.symbol_len(); + + while pos + sym_len <= bits.len() { + let end = pos + sym_len; + let patch = af + .dict + .apply_symbol_repair(&mut bits[pos..end]) + .map_err(io::Error::other)?; + + out.push(patch.symbol); + pos = end; + } + + write_output_text(output, &out) +} + +fn write_output_bytes(output: Option, bytes: &[u8]) -> io::Result<()> { + match output { + Some(path) => fs::write(path, bytes), + None => io::stdout().write_all(bytes), + } +} + +fn write_output_text(output: Option, text: &str) -> io::Result<()> { + match output { + Some(path) => fs::write(path, text), + None => { + println!("{text}"); + Ok(()) + } + } +} diff --git a/src/ciphertext.rs b/src/ciphertext.rs new file mode 100644 index 0000000..ab96f67 --- /dev/null +++ b/src/ciphertext.rs @@ -0,0 +1,54 @@ +use bitvec::prelude::BitVec; +use std::io::{Error, ErrorKind, Result}; + +const MAGIC: [u8; 4] = [b'E', b'C', b'T', b'1']; + +pub struct Ciphertext { + pub bits: BitVec, +} + +impl Ciphertext { + pub fn from_bits(bits: BitVec) -> Self { + Self { bits } + } + + pub fn into_bytes(self) -> Vec { + let bit_len = (self.bits.len() as u64).to_le_bytes(); + let payload = self.bits.into_vec(); + + [&MAGIC[..], &bit_len[..], &payload[..]].concat() + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + const HEADER_LEN: usize = 4 + 8; + + if bytes.len() < HEADER_LEN { + return Err(Error::new(ErrorKind::InvalidData, "ciphertext too short")); + } + + if bytes[0..4] != MAGIC { + return Err(Error::new(ErrorKind::InvalidData, "bad ciphertext magic")); + } + + let bit_len = u64::from_le_bytes( + bytes[4..12] + .try_into() + .map_err(|_| Error::new(ErrorKind::InvalidData, "bad ciphertext bit length"))?, + ) as usize; + + let payload = bytes[12..].to_vec(); + let expected_payload_len = bit_len.div_ceil(8); + + if payload.len() < expected_payload_len { + return Err(Error::new( + ErrorKind::InvalidData, + "ciphertext payload too short", + )); + } + + let mut bits = BitVec::::from_vec(payload); + bits.truncate(bit_len); + + Ok(Self { bits }) + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..861c87a --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,56 @@ +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "cli")] +#[command(version)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + Generate { + path: PathBuf, + + #[arg(long = "chars")] + chars: Option, + + #[arg(long = "chars-file")] + chars_file: Option, + + #[arg(long = "len", default_value_t = 8)] + len: usize, + + #[arg(long = "mut-rate", default_value_t = 8)] + mut_rate: usize, + }, + Cipher { + #[arg(short = 'a', long = "alphabet")] + alphabet: PathBuf, + + #[arg(short = 'f', long = "file")] + file: Option, + + #[arg(short = 'o', long = "output")] + output: Option, + }, + Decipher { + #[arg(short = 'a', long = "alphabet")] + alphabet: PathBuf, + + #[arg(short = 'f', long = "file")] + file: Option, + + #[arg(short = 'o', long = "output")] + output: Option, + }, + Tui { + #[arg(short = 'a', long = "alphabet")] + alphabet: PathBuf, + + #[arg(short = 'f', long = "file")] + file: PathBuf, + }, +} diff --git a/src/decoder.rs b/src/decoder.rs new file mode 100644 index 0000000..9b597f7 --- /dev/null +++ b/src/decoder.rs @@ -0,0 +1,202 @@ +use crate::alphabet::Alphabet; +use bitvec::prelude::BitVec; +use rand::SeedableRng; +use rand_chacha::ChaCha20Rng; +use std::io::{Error, ErrorKind, Result}; + +pub struct CandidateScore { + pub symbol: char, + pub distance: usize, + pub future_distance: usize, +} + +pub enum RankingMode { + LocalFirst, + FutureFirst, +} + +pub struct DecoderState { + alphabet_bytes: Vec, + rng_seed: ::Seed, + base_ciphertext: BitVec, + + pub cursor: usize, + pub text_scroll: usize, + pub choices: Vec>, + pub rendered_text: String, + pub working_bits: BitVec, + pub ranking_mode: RankingMode, +} + +impl DecoderState { + pub fn new( + alphabet_bytes: Vec, + rng_seed: ::Seed, + ciphertext: BitVec, + ) -> Result { + let rng = ChaCha20Rng::from_seed(rng_seed); + let alphabet = Alphabet::from_bytes(rng, &alphabet_bytes)?; + let symbol_count = ciphertext.len() / alphabet.symbol_len(); + + let mut this = Self { + alphabet_bytes, + rng_seed, + base_ciphertext: ciphertext.clone(), + cursor: 0, + text_scroll: 0, + choices: vec![None; symbol_count], + rendered_text: String::new(), + working_bits: ciphertext, + ranking_mode: RankingMode::LocalFirst, +}; + + + this.rebuild_all()?; + Ok(this) + } + + fn make_alphabet(&self) -> Result> { + let rng = ChaCha20Rng::from_seed(self.rng_seed); + Alphabet::from_bytes(rng, &self.alphabet_bytes) + } + + pub fn ensure_cursor_visible(&mut self, visible_symbols: usize) { + if self.cursor < self.text_scroll { + self.text_scroll = self.cursor; + } else if self.cursor >= self.text_scroll + visible_symbols { + self.text_scroll = self.cursor + 1 - visible_symbols; + } + } + + pub fn symbol_count(&self) -> usize { + self.choices.len() + } + + pub fn move_left(&mut self, visible: usize) { + if self.cursor > 0 { + self.cursor -= 1; + } + self.ensure_cursor_visible(visible); +} + +pub fn move_right(&mut self, visible: usize) { + if self.cursor + 1 < self.symbol_count() { + self.cursor += 1; + } + self.ensure_cursor_visible(visible); +} + + pub fn set_ranking_mode(&mut self, mode: RankingMode) { + self.ranking_mode = mode; + } + + fn current_range(&self, symbol_len: usize) -> std::ops::Range { + let start = self.cursor * symbol_len; + let end = start + symbol_len; + start..end + } + + pub fn current_candidates(&self) -> Result> { + let mut alphabet = self.make_alphabet()?; + let len = alphabet.symbol_len(); + + for i in 0..self.cursor { + let start = i * len; + let end = start + len; + let chunk = &self.base_ciphertext[start..end]; + + let chosen = if let Some(ch) = self.choices[i] { + ch + } else { + alphabet.best_symbol_distance_no_effect(chunk)?.0 + }; + + alphabet.replace_symbol_bits(chosen, chunk)?; + } + + let cur = self.current_range(len); + let top = alphabet.decipher_candidates_no_effect(&self.base_ciphertext[cur], 5)?; + + let mut out = Vec::new(); + for (symbol, distance) in top { + let future_distance = self.simulate_future_distance(symbol)?; + out.push(CandidateScore { + symbol, + distance, + future_distance, + }); + } + + match self.ranking_mode { + RankingMode::LocalFirst => out.sort_by_key(|c| (c.distance, c.future_distance)), + RankingMode::FutureFirst => out.sort_by_key(|c| (c.future_distance, c.distance)), + } + + Ok(out) + } + + fn simulate_future_distance(&self, chosen_symbol: char) -> Result { + let mut alphabet = self.make_alphabet()?; + let len = alphabet.symbol_len(); + let mut total = 0usize; + + for i in 0..self.symbol_count() { + let start = i * len; + let end = start + len; + let chunk = &self.base_ciphertext[start..end]; + + let symbol = if i == self.cursor { + chosen_symbol + } else if let Some(ch) = self.choices[i] { + ch + } else { + alphabet.best_symbol_distance_no_effect(chunk)?.0 + }; + + total += alphabet.symbol_distance(symbol, chunk)?; + alphabet.replace_symbol_bits(symbol, chunk)?; + } + + Ok(total) +} + + pub fn apply_candidate(&mut self, rank: usize) -> Result<()> { + let candidates = self.current_candidates()?; + let cand = candidates + .get(rank) + .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "candidate rank out of range"))?; + + self.choices[self.cursor] = Some(cand.symbol); + self.rebuild_all() + } + + pub fn undo_current(&mut self) -> Result<()> { + self.choices[self.cursor] = None; + self.rebuild_all() + } + + pub fn rebuild_all(&mut self) -> Result<()> { + let mut alphabet = self.make_alphabet()?; + let len = alphabet.symbol_len(); + let mut out = String::new(); + + for i in 0..self.symbol_count() { + let start = i * len; + let end = start + len; + let chunk = &self.base_ciphertext[start..end]; + + let symbol = if let Some(ch) = self.choices[i] { + ch + } else { + alphabet.best_symbol_distance_no_effect(chunk)?.0 + }; + + out.push(symbol); + alphabet.replace_symbol_bits(symbol, chunk)?; + } + + self.rendered_text = out; + self.working_bits = self.base_ciphertext.clone(); + Ok(()) + } +}