1use crate::llm::actions::{
2 client_trait::{Client, ClientActionResult},
3 protocol_trait::Protocol,
4 ActionDefinition, ParameterDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::lpd::{
8 actions::{action, parameter},
9 wire,
10};
11use crate::state::app_state::AppState;
12use anyhow::{bail, ensure, Result};
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16#[derive(Default)]
17pub struct LpdClientProtocol;
18impl LpdClientProtocol {
19 pub fn new() -> Self {
20 Self
21 }
22}
23
24pub const DEFAULT_HOST: &str = "netget";
25pub const DEFAULT_USER: &str = "netget";
26pub const MAX_DOCUMENT_BYTES: usize = 4 * 1024 * 1024;
28
29fn print_action() -> ActionDefinition {
30 action(
31 "lpd_print",
32 "Submit a text document as one print job on its own connection; the result arrives as lpd_print_result.",
33 vec![
34 parameter("queue", "string", "Queue name on the LPD server, such as raw", true),
35 parameter("text", "string", "Document text, sent as the job's data file", true),
36 parameter("job_name", "string", "Job name shown by lpq (J line)", false),
37 parameter("format", "string", "Print format letter: f (plain text, default), l (literal), o (PostScript)", false),
38 parameter(
39 "order",
40 "string",
41 "Which file to send first: control_first (default, as LPRng does) or data_first (as BSD lpr does)",
42 false,
43 ),
44 ],
45 json!({"type":"lpd_print","queue":"raw","text":"Hello printer","job_name":"hello"}),
46 )
47}
48
49fn queue_action() -> ActionDefinition {
50 action(
51 "lpd_queue",
52 "Ask for a queue listing (lpq); the server's text arrives as lpd_reply.",
53 vec![
54 parameter(
55 "queue",
56 "string",
57 "Name of the print queue on the server",
58 true,
59 ),
60 parameter("long", "boolean", "True for the long format", false),
61 parameter(
62 "list",
63 "array",
64 "Users or job numbers to restrict the listing to",
65 false,
66 ),
67 ],
68 json!({"type":"lpd_queue","queue":"raw"}),
69 )
70}
71
72fn remove_action() -> ActionDefinition {
73 action(
74 "lpd_remove",
75 "Ask the server to remove jobs (lprm); the server's text arrives as lpd_reply.",
76 vec![
77 parameter(
78 "queue",
79 "string",
80 "Name of the print queue on the server",
81 true,
82 ),
83 parameter(
84 "agent",
85 "string",
86 "User on whose behalf the jobs are removed",
87 true,
88 ),
89 parameter(
90 "jobs",
91 "array",
92 "Job numbers or user names to remove; empty means the agent's active job",
93 false,
94 ),
95 ],
96 json!({"type":"lpd_remove","queue":"raw","agent":"alice","jobs":["42"]}),
97 )
98}
99
100fn start_action() -> ActionDefinition {
101 action(
102 "lpd_start_queue",
103 "Ask the server to print any waiting jobs on a queue. LPD defines no reply.",
104 vec![parameter(
105 "queue",
106 "string",
107 "Name of the print queue on the server",
108 true,
109 )],
110 json!({"type":"lpd_start_queue","queue":"raw"}),
111 )
112}
113
114fn disconnect_action() -> ActionDefinition {
115 action(
116 "disconnect",
117 "Stop this LPD client",
118 vec![],
119 json!({"type":"disconnect"}),
120 )
121}
122
123fn actions() -> Vec<ActionDefinition> {
124 vec![
125 print_action(),
126 queue_action(),
127 remove_action(),
128 start_action(),
129 disconnect_action(),
130 ]
131}
132
133pub static READY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
134 EventType::new(
135 "lpd_ready",
136 "The LPD server is reachable. LPD carries one command per connection, so each action dials anew.",
137 print_action().example.clone(),
138 )
139 .with_parameters(vec![parameter("remote_addr", "string", "LPD server address", true)])
140 .with_actions(actions())
141});
142
143pub static PRINT_RESULT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
144 EventType::new(
145 "lpd_print_result",
146 "Outcome of one lpd_print: accepted is true only when the server acknowledged every step with 0",
147 disconnect_action().example.clone(),
148 )
149 .with_parameters(vec![
150 parameter("queue", "string", "Queue the job was sent to", true),
151 parameter("job_id", "string", "Three-digit job number", true),
152 parameter("accepted", "boolean", "True when the server queued the job", true),
153 parameter("refused_at", "string", "queue, control, data or final when the server refused; null when accepted", false),
154 ])
155 .with_actions(actions())
156});
157
158pub static REPLY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
159 EventType::new(
160 "lpd_reply",
161 "Text the server sent for a queue listing, removal or print-waiting request",
162 disconnect_action().example.clone(),
163 )
164 .with_parameters(vec![
165 parameter("command", "string", "queue, remove or start_queue", true),
166 parameter(
167 "queue",
168 "string",
169 "Name of the print queue on the server",
170 true,
171 ),
172 parameter(
173 "text",
174 "string",
175 "The server's reply text (empty for start_queue)",
176 true,
177 ),
178 ])
179 .with_actions(actions())
180});
181
182#[derive(Debug, Clone)]
184pub enum Command {
185 Print {
186 queue: String,
187 text: String,
188 job_name: Option<String>,
189 format: char,
190 data_first: bool,
191 },
192 Queue {
193 queue: String,
194 long: bool,
195 list: Vec<String>,
196 },
197 Remove {
198 queue: String,
199 agent: String,
200 jobs: Vec<String>,
201 },
202 Start {
203 queue: String,
204 },
205}
206
207fn token(v: &Value, key: &str) -> Result<String> {
208 v[key]
209 .as_str()
210 .filter(|s| wire::valid_token(s))
211 .map(str::to_string)
212 .ok_or_else(|| anyhow::anyhow!("{key} must be a name without spaces"))
213}
214
215fn tokens(v: &Value, key: &str) -> Result<Vec<String>> {
216 match v.get(key) {
217 None | Some(Value::Null) => Ok(Vec::new()),
218 Some(Value::Array(items)) => {
219 ensure!(items.len() <= 64, "{key} lists at most 64 entries");
220 items
221 .iter()
222 .map(|item| match item {
223 Value::String(s) if wire::valid_token(s) => Ok(s.clone()),
224 Value::Number(n) => Ok(n.to_string()),
225 _ => bail!("{key} entries must be names or numbers without spaces"),
226 })
227 .collect()
228 }
229 _ => bail!("{key} must be an array"),
230 }
231}
232
233impl Command {
234 pub fn from_action(v: &Value) -> Result<Self> {
235 match v["type"].as_str() {
236 Some("lpd_print") => {
237 let text = v["text"]
238 .as_str()
239 .ok_or_else(|| anyhow::anyhow!("text must be a string"))?;
240 ensure!(
241 text.len() <= MAX_DOCUMENT_BYTES,
242 "text exceeds {MAX_DOCUMENT_BYTES} bytes"
243 );
244 let job_name = match v.get("job_name") {
245 None | Some(Value::Null) => None,
246 Some(Value::String(s))
247 if s.len() <= 99 && !crate::utils::sanitize::has_controls(s) =>
248 {
249 Some(s.clone())
250 }
251 _ => bail!("job_name must be a single line of at most 99 characters"),
252 };
253 let format = match v.get("format").and_then(Value::as_str) {
254 None => 'f',
255 Some(f) if f.len() == 1 && "cdfglnoprtvz".contains(f) => {
256 f.chars().next().unwrap_or('f')
257 }
258 Some(_) => {
259 bail!("format must be one RFC 1179 format letter, such as f, l or o")
260 }
261 };
262 let data_first = match v.get("order").and_then(Value::as_str) {
263 None | Some("control_first") => false,
264 Some("data_first") => true,
265 Some(_) => bail!("order must be control_first or data_first"),
266 };
267 Ok(Command::Print {
268 queue: token(v, "queue")?,
269 text: text.to_string(),
270 job_name,
271 format,
272 data_first,
273 })
274 }
275 Some("lpd_queue") => Ok(Command::Queue {
276 queue: token(v, "queue")?,
277 long: v.get("long").and_then(Value::as_bool).unwrap_or(false),
278 list: tokens(v, "list")?,
279 }),
280 Some("lpd_remove") => Ok(Command::Remove {
281 queue: token(v, "queue")?,
282 agent: token(v, "agent")?,
283 jobs: tokens(v, "jobs")?,
284 }),
285 Some("lpd_start_queue") => Ok(Command::Start {
286 queue: token(v, "queue")?,
287 }),
288 _ => bail!("Unknown LPD client action"),
289 }
290 }
291}
292
293impl Protocol for LpdClientProtocol {
294 fn protocol_name(&self) -> &'static str {
295 "LPD"
296 }
297 fn stack_name(&self) -> &'static str {
298 "ETH>IP>TCP>LPD"
299 }
300 fn keywords(&self) -> Vec<&'static str> {
301 vec!["lpd", "lpr", "lpq", "lprm", "rfc1179", "line printer"]
302 }
303 fn description(&self) -> &'static str {
304 "LPD client that submits print jobs, lists queues and removes jobs"
305 }
306 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
307 actions()
308 }
309 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
310 vec![]
311 }
312 fn get_event_types(&self) -> Vec<EventType> {
313 vec![
314 READY_EVENT.clone(),
315 PRINT_RESULT_EVENT.clone(),
316 REPLY_EVENT.clone(),
317 ]
318 }
319 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
320 vec![
321 ParameterDefinition {
322 name: "host".into(),
323 type_hint: "string".into(),
324 description: "Host name written into control files (H line) and job file names"
325 .into(),
326 required: false,
327 example: json!("workstation"),
328 default: Some(json!(DEFAULT_HOST)),
329 },
330 ParameterDefinition {
331 name: "user".into(),
332 type_hint: "string".into(),
333 description: "User name written into control files (P line)".into(),
334 required: false,
335 example: json!("alice"),
336 default: Some(json!(DEFAULT_USER)),
337 },
338 ]
339 }
340 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
341 use crate::protocol::metadata::*;
342 ProtocolMetadataV2::builder()
343 .state(DevelopmentState::Experimental)
344 .privilege_requirement(PrivilegeRequirement::None)
345 .well_known_port(515)
346 .implementation("Tokio TCP, one connection per command: receive job with control and data files in either order, short and long queue listings, removal, print-waiting")
347 .llm_control("Which documents to print on which queue, listings to ask for, jobs to remove, and what to do with each reply")
348 .e2e_testing("tests/client/lpd: scripted fixture asserting control files and acknowledgements; LPRng lpd as the independent server")
349 .notes("Connects from an unprivileged source port, so a server enforcing RFC 1179's 721-731 source ports will refuse it. Text documents only (up to 4 MiB). Replies are bounded to 1 MiB; connect, write and acknowledgement deadlines are 30 s.")
350 .max_inbound_bytes(wire::MAX_REPLY_BYTES)
351 .build()
352 }
353 fn example_prompt(&self) -> &'static str {
354 "Connect to LPD at 127.0.0.1:5515 and print a test page on queue raw"
355 }
356 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
357 let llm = json!({"type":"open_client","protocol":"lpd","remote_addr":"127.0.0.1:5515","instruction":"Print a test page on queue raw"});
358 let mut static_example = llm.clone();
359 static_example["event_handlers"] = json!([
360 {"event_pattern":"lpd_ready","handler":{"type":"static","actions":[{"type":"lpd_print","queue":"raw","text":"Test page","job_name":"test"}]}},
361 {"event_pattern":"lpd_print_result","handler":{"type":"static","actions":[{"type":"lpd_queue","queue":"raw"}]}},
362 {"event_pattern":"lpd_reply","handler":{"type":"static","actions":[]}}
363 ]);
364 let mut scripted = static_example.clone();
365 scripted["event_handlers"][0]["handler"] = json!({"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[{'type':'lpd_print','queue':'raw','text':'Test page'}]}))"});
366 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
367 }
368 fn group_name(&self) -> &'static str {
369 "Web & File"
370 }
371}
372
373impl Client for LpdClientProtocol {
374 fn connect(
375 &self,
376 ctx: ConnectContext,
377 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
378 {
379 Box::pin(super::connect(ctx))
380 }
381 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
382 match v["type"].as_str() {
383 Some("disconnect") => Ok(ClientActionResult::Disconnect),
384 Some(name) => {
385 Command::from_action(&v)?;
386 Ok(ClientActionResult::Custom {
387 name: name.to_string(),
388 data: v,
389 })
390 }
391 None => bail!("Unknown LPD client action"),
392 }
393 }
394}