use std::fs;
use std::hash::{BuildHasher, Hasher, RandomState};
use std::io::{Read, Write};
use std::net::{Ipv4Addr, TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant, SystemTime};
use serde_json::{Value, json};
use crate::error::{
EXIT_CANCELLED, EXIT_FORMAT_MISMATCH, EXIT_INPUT_DECODE, EXIT_MODEL_NOT_FOUND, EXIT_TIMEOUT,
EXIT_USAGE, FocrError, FocrResult,
};
use crate::native_engine::{DecodeOverrides, PreprocessOverrides};
use crate::{LayoutSpan, OcrEngine, RecognizedDocument};
const DEFAULT_IDLE: Duration = Duration::from_secs(600);
const DEFAULT_CLIENT_READ_TIMEOUT: Duration = Duration::from_secs(600);
const DEFAULT_SPAWN_WAIT: Duration = Duration::from_secs(30);
const MAX_REPLY_BYTES: usize = 64 * 1024 * 1024;
const MAX_REQUEST_BYTES: usize = 1024 * 1024;
const PROTOCOL: u64 = 1;
const MAX_CONFIG_SECS: u64 = 31_536_000;
const ENV_FINGERPRINT_EXEMPT: &[&str] = &[
"FOCR_NO_RESIDENT",
"FOCR_RESIDENT_IDLE_SECS",
"FOCR_RESIDENT_DIR",
"FOCR_RESIDENT_LOG",
"FOCR_RESIDENT_SPAWN_WAIT_SECS",
"FOCR_RESIDENT_CLIENT_TIMEOUT_SECS",
"FOCR_NO_PROGRESS",
"FOCR_RUN_STORE",
"FOCR_MANIFEST_URL",
"FOCR_TIMING",
];
fn client_read_timeout() -> Duration {
std::env::var("FOCR_RESIDENT_CLIENT_TIMEOUT_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.filter(|&seconds| seconds > 0)
.map_or(DEFAULT_CLIENT_READ_TIMEOUT, |seconds| {
Duration::from_secs(seconds.min(MAX_CONFIG_SECS))
})
}
fn spawn_wait() -> Duration {
std::env::var("FOCR_RESIDENT_SPAWN_WAIT_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.map_or(DEFAULT_SPAWN_WAIT, |seconds| {
Duration::from_secs(seconds.min(MAX_CONFIG_SECS))
})
}
fn idle_period() -> Duration {
std::env::var("FOCR_RESIDENT_IDLE_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.filter(|&seconds| seconds > 0)
.map_or(DEFAULT_IDLE, |seconds| {
Duration::from_secs(seconds.min(MAX_CONFIG_SECS))
})
}
#[must_use]
pub fn enabled(no_resident_flag: bool) -> bool {
if no_resident_flag {
return false;
}
!matches!(
std::env::var("FOCR_NO_RESIDENT").ok().as_deref(),
Some("1") | Some("true")
)
}
fn resident_dir() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("FOCR_RESIDENT_DIR") {
return Some(PathBuf::from(dir));
}
crate::dist::cache_root()
}
fn root_digest(root: &Path) -> u64 {
let canonical = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in canonical.to_string_lossy().as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
fn state_path(root: &Path) -> Option<PathBuf> {
resident_dir().map(|dir| dir.join(format!("resident-{:016x}.json", root_digest(root))))
}
struct DaemonState {
port: u16,
token: String,
}
fn read_state(root: &Path) -> Option<DaemonState> {
let path = state_path(root)?;
let raw = fs::read_to_string(path).ok()?;
let value: Value = serde_json::from_str(&raw).ok()?;
Some(DaemonState {
port: u16::try_from(value.get("port")?.as_u64()?).ok()?,
token: value.get("token")?.as_str()?.to_owned(),
})
}
fn write_state(root: &Path, port: u16, token: &str) -> std::io::Result<PathBuf> {
let path = state_path(root).ok_or_else(|| {
std::io::Error::other("no cache directory and no FOCR_RESIDENT_DIR; cannot go resident")
})?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let body = json!({
"port": port,
"token": token,
"pid": std::process::id(),
"version": env!("CARGO_PKG_VERSION"),
"model_root": root.to_string_lossy(),
})
.to_string();
let staging = path.with_extension("json.tmp");
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&staging)?;
file.write_all(body.as_bytes())?;
}
#[cfg(not(unix))]
fs::write(&staging, &body)?;
fs::rename(&staging, &path)?;
Ok(path)
}
fn fresh_token() -> String {
let mut token = String::with_capacity(32);
for _ in 0..2 {
let mut hasher = RandomState::new().build_hasher();
hasher.write_u128(std::time::UNIX_EPOCH.elapsed().map_or(0, |d| d.as_nanos()));
hasher.write_u32(std::process::id());
token.push_str(&format!("{:016x}", hasher.finish()));
}
token
}
fn artifact_stamp(root: &Path) -> (u64, u64) {
let Ok(meta) = fs::metadata(root) else {
return (0, 0);
};
let mtime = meta
.modified()
.ok()
.and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
.map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX));
(mtime, meta.len())
}
fn env_fingerprint() -> Value {
let mut map = std::collections::BTreeMap::new();
for (key, value) in std::env::vars() {
if key.starts_with("FOCR_") && !ENV_FINGERPRINT_EXEMPT.contains(&key.as_str()) {
map.insert(key, Value::String(value));
}
}
Value::Object(map.into_iter().collect())
}
pub struct ResidentRequest<'a> {
pub image: &'a Path,
pub model: PathBuf,
pub decode: DecodeOverrides,
pub preprocess: PreprocessOverrides,
pub format: bool,
pub question: Option<&'a str>,
}
fn options_value(request: &ResidentRequest<'_>) -> Value {
json!({
"decode": {
"max_length": request.decode.max_length,
"temperature": request.decode.temperature,
"no_repeat_ngram": request.decode.no_repeat_ngram,
"ngram_window": request.decode.ngram_window,
},
"preprocess": {
"base_size": request.preprocess.base_size,
"image_size": request.preprocess.image_size,
"gundam": request.preprocess.gundam,
},
"format": request.format,
"question": request.question,
})
}
pub fn try_recognize(request: &ResidentRequest<'_>) -> FocrResult<Option<RecognizedDocument>> {
match connect(&request.model) {
Some(stream) => roundtrip(stream, request),
None => Ok(None),
}
}
enum ConnectFailure {
NoState,
Refused,
Other,
}
fn connect(root: &Path) -> Option<TcpStream> {
state_path(root)?;
let mut last = ConnectFailure::NoState;
for attempt in 0..3 {
if attempt > 0 {
std::thread::sleep(Duration::from_millis(300));
}
match connect_once(root) {
Ok(stream) => return Some(stream),
Err(ConnectFailure::NoState) => {
last = ConnectFailure::NoState;
break;
}
Err(failure) => last = failure,
}
}
match last {
ConnectFailure::Refused => {
if let Some(path) = state_path(root)
&& path.exists()
{
let _ = fs::remove_file(&path);
}
}
ConnectFailure::NoState => {}
ConnectFailure::Other => return None,
}
spawn_daemon(root)?;
let deadline = Instant::now() + spawn_wait();
while Instant::now() < deadline {
if let Ok(stream) = connect_once(root) {
return Some(stream);
}
std::thread::sleep(Duration::from_millis(50));
}
None
}
fn connect_once(root: &Path) -> Result<TcpStream, ConnectFailure> {
let state = read_state(root).ok_or(ConnectFailure::NoState)?;
let stream = TcpStream::connect_timeout(
&(Ipv4Addr::LOCALHOST, state.port).into(),
Duration::from_millis(1000),
)
.map_err(|error| {
if error.kind() == std::io::ErrorKind::ConnectionRefused {
ConnectFailure::Refused
} else {
ConnectFailure::Other
}
})?;
stream
.set_read_timeout(Some(Duration::from_millis(500)))
.map_err(|_| ConnectFailure::Other)?;
stream.set_nodelay(true).ok();
Ok(stream)
}
fn spawn_daemon(root: &Path) -> Option<()> {
let exe = std::env::current_exe().ok()?;
let mut command = Command::new(exe);
command
.arg("resident-daemon")
.arg("--model-root")
.arg(root)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
if let Ok(log) = std::env::var("FOCR_RESIDENT_LOG")
&& let Ok(file) = fs::OpenOptions::new().create(true).append(true).open(&log)
&& let Ok(err) = file.try_clone()
{
command.stdout(file).stderr(err);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x0000_0008 | 0x0800_0000);
}
command.spawn().ok().map(|_child| ())
}
fn roundtrip(
mut stream: TcpStream,
request: &ResidentRequest<'_>,
) -> FocrResult<Option<RecognizedDocument>> {
let Some(state) = read_state(&request.model) else {
return Ok(None);
};
let image = std::path::absolute(request.image).unwrap_or_else(|_| request.image.to_path_buf());
let header = json!({
"protocol": PROTOCOL,
"op": "ocr",
"token": state.token,
"version": env!("CARGO_PKG_VERSION"),
"model_root": request.model.to_string_lossy(),
"image": image.to_string_lossy(),
"options": options_value(request),
"env": env_fingerprint(),
});
if stream
.write_all(format!("{header}\n").as_bytes())
.and_then(|()| stream.flush())
.is_err()
{
return Ok(None);
}
let overall_deadline = Instant::now() + client_read_timeout();
let mut buffer: Vec<u8> = Vec::new();
let mut chunk = [0_u8; 65536];
let newline = loop {
crate::cancel_checkpoint()?;
if Instant::now() >= overall_deadline {
return Ok(None);
}
match stream.read(&mut chunk) {
Ok(0) => return Ok(None),
Ok(count) => {
buffer.extend_from_slice(&chunk[..count]);
if let Some(position) = buffer.iter().position(|&byte| byte == b'\n') {
break position;
}
if buffer.len() >= MAX_REPLY_BYTES {
return Ok(None);
}
}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) => {}
Err(_) => return Ok(None),
}
};
let Ok(reply) = serde_json::from_slice::<Value>(&buffer[..newline]) else {
return Ok(None);
};
if reply.get("ok").and_then(Value::as_bool) == Some(true) {
let markdown = reply
.get("markdown")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let layout = reply
.get("layout")
.and_then(Value::as_array)
.map(|spans| {
spans
.iter()
.filter_map(|span| {
let label = span.get("label")?.as_str()?.to_owned();
let boxes = span
.get("boxes")?
.as_array()?
.iter()
.filter_map(|b| {
let coords = b.as_array()?;
if coords.len() != 4 {
return None;
}
let mut out = [0_i64; 4];
for (slot, coord) in out.iter_mut().zip(coords) {
*slot = coord.as_i64()?;
}
Some(out)
})
.collect();
Some(LayoutSpan { label, boxes })
})
.collect()
})
.unwrap_or_default();
return Ok(Some(RecognizedDocument { markdown, layout }));
}
match reply.get("kind").and_then(Value::as_str) {
Some("ocr") => {
let code = reply
.get("exit_code")
.and_then(Value::as_i64)
.and_then(|c| i32::try_from(c).ok())
.unwrap_or(crate::error::EXIT_GENERIC);
let message = reply
.get("message")
.and_then(Value::as_str)
.unwrap_or("resident recognition failed")
.to_owned();
Err(wire_error(code, message))
}
_ => Ok(None),
}
}
fn wire_error(exit_code: i32, message: String) -> FocrError {
match exit_code {
EXIT_USAGE => FocrError::Usage(message),
EXIT_MODEL_NOT_FOUND => FocrError::ModelNotFound(message),
EXIT_INPUT_DECODE => FocrError::InputDecode(message),
EXIT_TIMEOUT => FocrError::Timeout(message),
EXIT_CANCELLED => FocrError::Cancelled,
EXIT_FORMAT_MISMATCH => FocrError::FormatMismatch(message),
_ => FocrError::Other(anyhow::anyhow!(message)),
}
}
struct ResidentEngine {
engine: OcrEngine,
fingerprint: String,
stamp: (u64, u64),
}
pub fn run_daemon(model_root: &Path) -> FocrResult<()> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
.map_err(|error| FocrError::Other(anyhow::anyhow!("resident daemon bind: {error}")))?;
let port = listener
.local_addr()
.map_err(|error| FocrError::Other(anyhow::anyhow!("resident daemon addr: {error}")))?
.port();
let token = fresh_token();
let state_file = write_state(model_root, port, &token)
.map_err(|error| FocrError::Other(anyhow::anyhow!("resident state write: {error}")))?;
listener
.set_nonblocking(true)
.map_err(|error| FocrError::Other(anyhow::anyhow!("resident socket mode: {error}")))?;
eprintln!(
"focr resident daemon serving {} on 127.0.0.1:{port}",
model_root.display()
);
let idle = idle_period();
let mut resident: Option<ResidentEngine> = None;
let mut deadline = Instant::now() + idle;
loop {
match listener.accept() {
Ok((stream, _peer)) => {
crate::reset_shutdown();
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
handle_connection(stream, model_root, &token, port, &mut resident);
}));
if outcome.is_err() {
eprintln!(
"focr resident daemon: request panicked; connection dropped, model kept"
);
}
deadline = Instant::now() + idle;
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if Instant::now() >= deadline {
eprintln!("focr resident daemon idle exit");
remove_state_if_ours(&state_file, port);
return Ok(());
}
std::thread::sleep(Duration::from_millis(100));
}
Err(_) => {
remove_state_if_ours(&state_file, port);
return Ok(());
}
}
}
}
fn remove_state_if_ours(state_file: &Path, port: u16) {
let ours = fs::read_to_string(state_file)
.ok()
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
.and_then(|value| value.get("port")?.as_u64())
.is_some_and(|recorded| recorded == u64::from(port));
if ours {
let _ = fs::remove_file(state_file);
}
}
fn decode_overrides_from_wire(options: &Value) -> DecodeOverrides {
let decode = options.get("decode").cloned().unwrap_or(Value::Null);
let usize_of = |key: &str| {
decode
.get(key)
.and_then(Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
};
DecodeOverrides {
max_length: usize_of("max_length"),
temperature: decode
.get("temperature")
.and_then(Value::as_f64)
.map(|t| t as f32),
no_repeat_ngram: usize_of("no_repeat_ngram"),
ngram_window: usize_of("ngram_window"),
}
}
fn preprocess_overrides_from_wire(options: &Value) -> PreprocessOverrides {
let preprocess = options.get("preprocess").cloned().unwrap_or(Value::Null);
let usize_of = |key: &str| {
preprocess
.get(key)
.and_then(Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
};
PreprocessOverrides {
base_size: usize_of("base_size"),
image_size: usize_of("image_size"),
gundam: preprocess.get("gundam").and_then(Value::as_bool),
}
}
fn load_fingerprint(options: &Value) -> String {
json!({
"decode": options.get("decode").cloned().unwrap_or(Value::Null),
"preprocess": options.get("preprocess").cloned().unwrap_or(Value::Null),
})
.to_string()
}
fn handle_connection(
stream: TcpStream,
model_root: &Path,
token: &str,
port: u16,
resident: &mut Option<ResidentEngine>,
) {
let _ = stream.set_nonblocking(false);
let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(60)));
let _ = stream.set_nodelay(true);
const REQUEST_DEADLINE: Duration = Duration::from_secs(30);
let mut stream = stream;
let deadline = Instant::now() + REQUEST_DEADLINE;
let mut buffer: Vec<u8> = Vec::new();
let mut chunk = [0_u8; 4096];
let newline = loop {
if Instant::now() >= deadline {
return;
}
match stream.read(&mut chunk) {
Ok(0) => return,
Ok(count) => {
buffer.extend_from_slice(&chunk[..count]);
if let Some(position) = buffer.iter().position(|&byte| byte == b'\n') {
break position;
}
if buffer.len() >= MAX_REQUEST_BYTES {
let reply =
json!({ "ok": false, "kind": "request", "message": "request too large" });
let _ = stream.write_all(format!("{reply}\n").as_bytes());
return;
}
}
Err(_) => return,
}
};
let Ok(request) = serde_json::from_slice::<Value>(&buffer[..newline]) else {
return;
};
let refuse = |stream: &mut TcpStream, kind: &str, message: &str| {
let reply = json!({ "ok": false, "kind": kind, "message": message });
let _ = stream.write_all(format!("{reply}\n").as_bytes());
};
let token_matches = |candidate: &str| {
let (a, b) = (candidate.as_bytes(), token.as_bytes());
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0_u8, |acc, (x, y)| acc | (x ^ y)) == 0
};
if !request
.get("token")
.and_then(Value::as_str)
.is_some_and(token_matches)
{
refuse(&mut stream, "auth", "bad token");
return;
}
if request.get("protocol").and_then(Value::as_u64) != Some(PROTOCOL)
|| request.get("version").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION"))
{
refuse(&mut stream, "version", "resident daemon version mismatch");
if let Some(state) = state_path(model_root) {
remove_state_if_ours(&state, port);
}
std::process::exit(0);
}
if let Some(wire_root) = request.get("model_root").and_then(Value::as_str) {
let wire_canonical =
fs::canonicalize(wire_root).unwrap_or_else(|_| PathBuf::from(wire_root));
let own_canonical =
fs::canonicalize(model_root).unwrap_or_else(|_| model_root.to_path_buf());
if wire_canonical != own_canonical {
refuse(
&mut stream,
"request",
"resident daemon serves a different model",
);
return;
}
}
if request.get("env").cloned().unwrap_or_else(|| json!({})) != env_fingerprint() {
refuse(
&mut stream,
"env",
"inference environment differs from the resident daemon's",
);
return;
}
let stamp_now = artifact_stamp(model_root);
if let Some(engine) = resident.as_ref()
&& engine.stamp != stamp_now
{
refuse(&mut stream, "stale", "model artifact changed since load");
if let Some(state) = state_path(model_root) {
remove_state_if_ours(&state, port);
}
std::process::exit(0);
}
let options = request.get("options").cloned().unwrap_or_else(|| json!({}));
let fingerprint = load_fingerprint(&options);
if resident
.as_ref()
.is_some_and(|engine| engine.fingerprint != fingerprint)
{
*resident = None;
}
crate::native_engine::set_decode_overrides(decode_overrides_from_wire(&options));
crate::native_engine::set_preprocess_overrides(preprocess_overrides_from_wire(&options));
crate::native_engine::force_got_format(
options
.get("format")
.and_then(Value::as_bool)
.unwrap_or(false),
);
crate::native_engine::set_smolvlm2_question(
options
.get("question")
.and_then(Value::as_str)
.map(str::to_owned),
);
if resident.is_none() {
match OcrEngine::new() {
Ok(engine) => {
*resident = Some(ResidentEngine {
engine,
fingerprint,
stamp: stamp_now,
});
}
Err(error) => {
refuse(
&mut stream,
"engine",
&format!("resident engine start failed: {error}"),
);
return;
}
}
}
let Some(engine) = resident.as_ref() else {
return;
};
let image = PathBuf::from(request.get("image").and_then(Value::as_str).unwrap_or(""));
if image.as_os_str().is_empty() {
refuse(&mut stream, "request", "missing image path");
return;
}
match engine
.engine
.recognize_with_layout_model(model_root, &image)
{
Ok(document) => {
let layout: Vec<Value> = document
.layout
.iter()
.map(|span| json!({ "label": span.label, "boxes": span.boxes }))
.collect();
let reply = json!({
"ok": true,
"markdown": document.markdown,
"layout": layout,
});
let _ = stream.write_all(format!("{reply}\n").as_bytes());
let _ = stream.flush();
}
Err(error) => {
let reply = json!({
"ok": false,
"kind": "ocr",
"exit_code": error.exit_code(),
"message": error.to_string(),
});
let _ = stream.write_all(format!("{reply}\n").as_bytes());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn root_digest_distinguishes_roots_and_is_stable() {
let a = root_digest(Path::new("/tmp/model-a.focrq"));
let b = root_digest(Path::new("/tmp/model-b.focrq"));
assert_ne!(a, b);
assert_eq!(a, root_digest(Path::new("/tmp/model-a.focrq")));
}
#[test]
fn tokens_are_distinct_and_hex() {
let one = fresh_token();
let two = fresh_token();
assert_eq!(one.len(), 32);
assert!(one.bytes().all(|b| b.is_ascii_hexdigit()));
assert_ne!(one, two, "two RandomState-seeded tokens collided");
}
#[test]
fn wire_errors_keep_their_exit_class() {
let cases: &[(i32, i32)] = &[
(EXIT_USAGE, EXIT_USAGE),
(EXIT_MODEL_NOT_FOUND, EXIT_MODEL_NOT_FOUND),
(EXIT_INPUT_DECODE, EXIT_INPUT_DECODE),
(EXIT_TIMEOUT, EXIT_TIMEOUT),
(EXIT_CANCELLED, EXIT_CANCELLED),
(EXIT_FORMAT_MISMATCH, EXIT_FORMAT_MISMATCH),
(crate::error::EXIT_GENERIC, crate::error::EXIT_GENERIC),
(99, crate::error::EXIT_GENERIC),
];
for &(wire, expected) in cases {
assert_eq!(wire_error(wire, "x".into()).exit_code(), expected);
}
}
#[test]
fn options_round_trip_through_the_wire_shape() {
let request = ResidentRequest {
image: Path::new("/tmp/x.png"),
model: PathBuf::from("/tmp/m.focrq"),
decode: DecodeOverrides {
max_length: Some(4096),
temperature: Some(0.5),
no_repeat_ngram: None,
ngram_window: Some(512),
},
preprocess: PreprocessOverrides {
base_size: Some(1024),
image_size: None,
gundam: Some(true),
},
format: true,
question: Some("what?"),
};
let wire = options_value(&request);
let decode = decode_overrides_from_wire(&wire);
assert_eq!(decode.max_length, Some(4096));
assert_eq!(decode.temperature, Some(0.5));
assert_eq!(decode.no_repeat_ngram, None);
assert_eq!(decode.ngram_window, Some(512));
let preprocess = preprocess_overrides_from_wire(&wire);
assert_eq!(preprocess.base_size, Some(1024));
assert_eq!(preprocess.image_size, None);
assert_eq!(preprocess.gundam, Some(true));
assert_eq!(wire.get("format").and_then(Value::as_bool), Some(true));
assert_eq!(wire.get("question").and_then(Value::as_str), Some("what?"));
}
#[test]
fn load_fingerprint_ignores_forward_read_options() {
let base = json!({
"decode": {"max_length": 100},
"preprocess": {"gundam": null},
"format": false,
"question": null,
});
let mut format_flipped = base.clone();
format_flipped["format"] = json!(true);
assert_eq!(load_fingerprint(&base), load_fingerprint(&format_flipped));
let mut decode_changed = base.clone();
decode_changed["decode"]["max_length"] = json!(200);
assert_ne!(load_fingerprint(&base), load_fingerprint(&decode_changed));
}
#[test]
fn daemon_refuses_bad_token() {
use std::io::{BufRead, BufReader};
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = listener.local_addr().unwrap().port();
let handle = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut resident = None;
handle_connection(
stream,
Path::new("/nonexistent/model.focrq"),
"right-token",
0,
&mut resident,
);
});
let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
let request = json!({
"protocol": PROTOCOL,
"op": "ocr",
"token": "wrong-token",
"version": env!("CARGO_PKG_VERSION"),
"model_root": "/nonexistent/model.focrq",
"image": "/tmp/x.png",
"options": {},
"env": {},
});
stream.write_all(format!("{request}\n").as_bytes()).unwrap();
let mut reply = String::new();
BufReader::new(&mut stream).read_line(&mut reply).unwrap();
let value: Value = serde_json::from_str(&reply).unwrap();
assert_eq!(value.get("ok").and_then(Value::as_bool), Some(false));
assert_eq!(value.get("kind").and_then(Value::as_str), Some("auth"));
handle.join().unwrap();
}
#[test]
fn daemon_refuses_mismatched_env_fingerprint() {
use std::io::{BufRead, BufReader};
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = listener.local_addr().unwrap().port();
let handle = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut resident = None;
handle_connection(
stream,
Path::new("/nonexistent/model.focrq"),
"tok",
0,
&mut resident,
);
});
let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
let request = json!({
"protocol": PROTOCOL,
"op": "ocr",
"token": "tok",
"version": env!("CARGO_PKG_VERSION"),
"model_root": "/nonexistent/model.focrq",
"image": "/tmp/x.png",
"options": {},
"env": {"FOCR_DECODE_INT8": "1", "__focr_test_unlikely": "x"},
});
stream.write_all(format!("{request}\n").as_bytes()).unwrap();
let mut reply = String::new();
BufReader::new(&mut stream).read_line(&mut reply).unwrap();
let value: Value = serde_json::from_str(&reply).unwrap();
assert_eq!(value.get("ok").and_then(Value::as_bool), Some(false));
assert_eq!(value.get("kind").and_then(Value::as_str), Some("env"));
handle.join().unwrap();
}
}