Skip to main content

netget/client/lpd/
actions.rs

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";
26/// Largest document the client will send in one job.
27pub 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/// A validated client command.
183#[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}