use std::collections::HashMap;
use std::fs;
use std::path::Path;
use super::super::Layout;
use super::lines::{Lines, Raw};
use super::message::Account;
use crate::failure::{Doing, Failure};
pub(crate) struct Control {
lines: Lines,
at: Layout,
_guard: JoinFifo,
partial: HashMap<String, Vec<u8>>,
}
struct JoinFifo(String);
impl Drop for JoinFifo {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
pub(crate) struct Announced {
pub token: String,
pub account: Account,
}
impl Control {
pub(crate) fn open(at: Layout) -> Result<Self, Failure> {
let join = at.join();
super::mkfifo(Path::new(&join))?;
let _guard = JoinFifo(join.clone());
Ok(Self {
lines: Lines::open_read_write(Path::new(&join))?,
at,
_guard,
partial: HashMap::new(),
})
}
pub(crate) async fn next(&mut self) -> Result<Announced, Failure> {
loop {
let raw = self.lines.next().await?.ok_or_else(|| {
Failure::new(
"reading the control fifo",
"it reached end of input",
)
})?;
if let Some(announced) = self.frame(raw)? {
return Ok(announced);
}
}
}
pub(crate) fn close(mut self) -> Result<(), Failure> {
for raw in self.lines.drain()? {
if let Some(Announced { token, .. }) = self.frame(raw)? {
let up = self.at.up(&token);
drop(Lines::open(Path::new(&up))?);
fs::remove_file(&up).doing(|| format!("removing {up}"))?;
}
}
for token in self.partial.keys() {
let up = self.at.up(token);
fs::remove_file(&up).doing(|| format!("removing {up}"))?;
}
let join = self.at.join();
fs::remove_file(&join).doing(|| format!("removing {join}"))?;
self.lines.finish()
}
fn frame(&mut self, raw: Raw) -> Result<Option<Announced>, Failure> {
let frame = Frame::read(&raw.bytes)?;
let mut bytes = self.partial.remove(frame.token).unwrap_or_default();
bytes.extend_from_slice(frame.chunk);
if !frame.last {
self.partial.insert(frame.token.to_string(), bytes);
return Ok(None);
}
let text = String::from_utf8(bytes).doing(|| {
format!(
"reading the announcement of {} as text",
frame.token
)
})?;
let account = Account::read(&text, raw.heard_at)?;
Ok(Some(Announced {
token: frame.token.to_string(),
account,
}))
}
}
struct Frame<'a> {
token: &'a str,
last: bool,
chunk: &'a [u8],
}
impl<'a> Frame<'a> {
fn read(bytes: &'a [u8]) -> Result<Self, Failure> {
let refused = || {
let shown = String::from_utf8_lossy(bytes);
Failure::new(
"reading the control fifo",
format!("{shown:?} is not a frame"),
)
};
let space = bytes
.iter()
.position(|byte| *byte == b' ')
.ok_or_else(refused)?;
let token = std::str::from_utf8(&bytes[..space]).map_err(|_| refused())?;
let names_a_file = !token.is_empty() && !token.contains(['/', '\0']) && !token.contains(char::is_whitespace);
if !names_a_file {
return Err(refused());
}
let (last, chunk) = match &bytes[space..] {
[b' ', b'+', b' ', chunk @ ..] => (false, chunk),
[b' ', b'.', b' ', chunk @ ..] => (true, chunk),
_ => return Err(refused()),
};
Ok(Self { token, last, chunk })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rig::wire::{Micros, field};
use bash_strings::emit_array;
fn raw(text: impl AsRef<[u8]>) -> Raw {
Raw {
bytes: text.as_ref().to_vec(),
heard_at: Micros(7),
}
}
fn account(zero: &str) -> String {
emit_array(&["at=1.000002", "zero", zero].map(String::from))
}
#[tokio::test]
async fn frames_reassemble_per_token_and_in_bytes() {
let dir = tempfile::tempdir().unwrap();
let at = Layout::new(dir.path().to_path_buf()).unwrap();
let mut control = Control::open(at).unwrap();
let (one, two) = (
account("€uro.bash"),
account("plain.bash"),
);
let split = one.find('€').unwrap() + 1;
let (head, tail) = one.as_bytes().split_at(split);
let mut frame = |line: Vec<u8>| control.frame(raw(line)).unwrap();
assert!(
frame([b"A + ".as_slice(), head].concat()).is_none(),
"more to come"
);
assert!(frame([b"B + ".as_slice(), &two.as_bytes()[..3]].concat()).is_none());
let a = frame([b"A . ".as_slice(), tail].concat()).expect("A is whole");
let b = frame([b"B . ".as_slice(), &two.as_bytes()[3..]].concat()).expect("B is whole");
assert_eq!(a.token, "A");
assert_eq!(
field(&a.account.words, "zero"),
Some("€uro.bash")
);
assert_eq!(b.token, "B");
assert_eq!(
field(&b.account.words, "zero"),
Some("plain.bash")
);
assert!(
control.partial.is_empty(),
"nothing left over"
);
}
#[test]
fn a_frame_the_protocol_did_not_write_is_refused() {
for bad in [
"",
"A",
"A +",
"A x chunk",
" . chunk",
"a/b . chunk",
"a\tb . chunk",
] {
assert!(
Frame::read(bad.as_bytes()).is_err(),
"{bad:?} should not read as a frame"
);
}
let frame = Frame::read(b"A . ").unwrap();
assert!(
frame.last && frame.chunk.is_empty(),
"an empty last chunk is a frame"
);
}
}