Skip to main content

netget/server/ninep/
actions.rs

1use crate::llm::actions::{
2    protocol_trait::{ActionResult, Protocol, Server},
3    ActionDefinition, Parameter, ParameterDefinition,
4};
5use crate::protocol::log_template::LogTemplate;
6use crate::protocol::{EventType, SpawnContext};
7use crate::state::app_state::AppState;
8use anyhow::{bail, ensure, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct NinepProtocol;
14impl NinepProtocol {
15    pub fn new() -> Self {
16        Self
17    }
18}
19
20pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
21    Parameter {
22        name: name.into(),
23        type_hint: kind.into(),
24        description: description.into(),
25        required,
26    }
27}
28
29pub fn action(
30    name: &str,
31    description: &str,
32    parameters: Vec<Parameter>,
33    example: Value,
34) -> ActionDefinition {
35    let log_template = match name {
36        "ninep_entry" => LogTemplate::new().with_info("-> 9P entry {kind} size={size}"),
37        "ninep_listing" => LogTemplate::new().with_info("-> 9P listing {preview(entries,120)}"),
38        "ninep_content" => LogTemplate::new().with_info("-> 9P content {preview(data,80)}"),
39        "ninep_error" => LogTemplate::new().with_info("-> 9P error {message}"),
40        _ => LogTemplate::new().with_info(format!("-> 9P {name}")),
41    };
42    ActionDefinition {
43        name: name.into(),
44        description: description.into(),
45        parameters,
46        example,
47        log_template: Some(log_template),
48    }
49}
50
51fn entry_action() -> ActionDefinition {
52    action(
53        "ninep_entry",
54        "Say that the path exists and what it is. Rust builds the qid and stat from these fields.",
55        vec![
56            parameter("kind", "string", "What the path is: file or dir", true),
57            parameter(
58                "size",
59                "number",
60                "File length in bytes (0 for a directory)",
61                false,
62            ),
63            parameter(
64                "mode",
65                "number",
66                "Permission bits, e.g. 420 (0644) for a file or 493 (0755) for a directory",
67                false,
68            ),
69            parameter("mtime", "number", "Modification time, Unix seconds", false),
70            parameter(
71                "owner",
72                "string",
73                "Owner and group name shown in listings, default netget",
74                false,
75            ),
76        ],
77        json!({"type":"ninep_entry","kind":"file","size":12}),
78    )
79}
80
81fn not_found_action() -> ActionDefinition {
82    action(
83        "ninep_not_found",
84        "Say that nothing exists at this path; the client gets 'file does not exist'.",
85        vec![],
86        json!({"type":"ninep_not_found"}),
87    )
88}
89
90fn listing_action() -> ActionDefinition {
91    action(
92        "ninep_listing",
93        "List a directory's entries. Rust encodes them as stat entries and pages them to the client.",
94        vec![parameter(
95            "entries",
96            "array",
97            "At most 1024 entries: [{name, kind: file|dir, size?, mode?, mtime?, owner?}]",
98            true,
99        )],
100        json!({"type":"ninep_listing","entries":[{"name":"readme.txt","kind":"file","size":12},{"name":"docs","kind":"dir"}]}),
101    )
102}
103
104fn content_action() -> ActionDefinition {
105    action(
106        "ninep_content",
107        "Supply a file's whole content (at most 1 MiB). Rust serves each read from it at the client's offset.",
108        vec![
109            parameter("data", "string", "The file content", true),
110            parameter("encoding", "string", "utf8 (default) or hex for binary content", false),
111        ],
112        json!({"type":"ninep_content","data":"hello world\n"}),
113    )
114}
115
116fn ok_action() -> ActionDefinition {
117    action(
118        "ninep_ok",
119        "Accept the change (a write, create, remove or stat change); the client is told it succeeded.",
120        vec![],
121        json!({"type":"ninep_ok"}),
122    )
123}
124
125fn error_action() -> ActionDefinition {
126    action(
127        "ninep_error",
128        "Refuse the request with an error string the client shows, e.g. 'permission denied'.",
129        vec![parameter(
130            "message",
131            "string",
132            "Error text, at most 255 bytes",
133            true,
134        )],
135        json!({"type":"ninep_error","message":"permission denied"}),
136    )
137}
138
139fn path_param() -> Parameter {
140    parameter(
141        "path",
142        "string",
143        "Absolute path, e.g. /docs/readme.txt",
144        true,
145    )
146}
147
148fn uname_param() -> Parameter {
149    parameter("uname", "string", "User name the client attached as", true)
150}
151
152pub static STAT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
153    EventType::new(
154        "ninep_stat",
155        "The client walked to or asked about a path; say whether it exists and what it is.",
156        entry_action().example.clone(),
157    )
158    .with_parameters(vec![path_param(), uname_param()])
159    .with_actions(vec![entry_action(), not_found_action(), error_action()])
160});
161
162pub static LIST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
163    EventType::new(
164        "ninep_list",
165        "The client is reading a directory; list its entries.",
166        listing_action().example.clone(),
167    )
168    .with_parameters(vec![path_param(), uname_param()])
169    .with_actions(vec![listing_action(), error_action()])
170});
171
172pub static READ_EVENT: LazyLock<EventType> = LazyLock::new(|| {
173    EventType::new(
174        "ninep_read",
175        "The client is reading a file from its start; supply the whole content.",
176        content_action().example.clone(),
177    )
178    .with_parameters(vec![path_param(), uname_param()])
179    .with_actions(vec![content_action(), error_action()])
180});
181
182pub static WRITE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
183    EventType::new(
184        "ninep_write",
185        "The client wrote bytes into a file at an offset; accept or refuse them.",
186        ok_action().example.clone(),
187    )
188    .with_parameters(vec![
189        path_param(),
190        parameter("offset", "number", "Byte offset of the write", true),
191        parameter("data", "string", "The bytes written, as text", true),
192        parameter(
193            "encoding",
194            "string",
195            "utf8, or hex when the bytes are not UTF-8",
196            true,
197        ),
198        uname_param(),
199    ])
200    .with_actions(vec![ok_action(), error_action()])
201});
202
203pub static CREATE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
204    EventType::new(
205        "ninep_create",
206        "The client is creating a file or directory; accept or refuse.",
207        ok_action().example.clone(),
208    )
209    .with_parameters(vec![
210        path_param(),
211        parameter("kind", "string", "What the path is: file or dir", true),
212        parameter("mode", "number", "Permission bits requested", true),
213        uname_param(),
214    ])
215    .with_actions(vec![ok_action(), error_action()])
216});
217
218pub static REMOVE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
219    EventType::new(
220        "ninep_remove",
221        "The client is removing a file or directory; accept or refuse.",
222        ok_action().example.clone(),
223    )
224    .with_parameters(vec![path_param(), uname_param()])
225    .with_actions(vec![ok_action(), error_action()])
226});
227
228pub static WSTAT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
229    EventType::new(
230        "ninep_wstat",
231        "The client is renaming, truncating or changing the mode of a path; accept or refuse.",
232        ok_action().example.clone(),
233    )
234    .with_parameters(vec![
235        path_param(),
236        parameter(
237            "name",
238            "string",
239            "New name in the same directory, when renaming",
240            false,
241        ),
242        parameter(
243            "length",
244            "number",
245            "New length, when truncating (0 for OTRUNC)",
246            false,
247        ),
248        parameter(
249            "mode",
250            "number",
251            "New permission bits, when changing them",
252            false,
253        ),
254        uname_param(),
255    ])
256    .with_actions(vec![ok_action(), error_action()])
257});
258
259/// Validate one listing or stat entry; also used for the listing's elements.
260pub fn check_entry(v: &Value, named: bool) -> Result<()> {
261    ensure!(
262        matches!(v["kind"].as_str(), Some("file" | "dir")),
263        "kind must be file or dir"
264    );
265    if named {
266        let name = v["name"]
267            .as_str()
268            .ok_or_else(|| anyhow::anyhow!("each entry needs a name"))?;
269        super::wire::check_name(name)?;
270        ensure!(name != "..", "a listing cannot contain ..");
271    }
272    for key in ["size", "mode", "mtime"] {
273        ensure!(
274            v.get(key).is_none_or(Value::is_u64),
275            "{key} must be a non-negative integer"
276        );
277    }
278    ensure!(
279        v["mode"].as_u64().is_none_or(|m| m <= 0o777),
280        "mode holds permission bits only (at most 0777)"
281    );
282    ensure!(
283        v["mtime"].as_u64().is_none_or(|m| m <= u64::from(u32::MAX)),
284        "mtime must fit 32 bits"
285    );
286    ensure!(
287        v.get("owner")
288            .is_none_or(|o| o.as_str().is_some_and(|s| !s.is_empty() && s.len() <= 64)),
289        "owner must be 1 to 64 bytes"
290    );
291    Ok(())
292}
293
294impl Protocol for NinepProtocol {
295    fn protocol_name(&self) -> &'static str {
296        "9P"
297    }
298    fn stack_name(&self) -> &'static str {
299        "ETH>IP>TCP>9P"
300    }
301    fn description(&self) -> &'static str {
302        "9P2000 file server (Plan 9's file protocol) whose files and directories the handler supplies"
303    }
304    fn keywords(&self) -> Vec<&'static str> {
305        vec!["9p", "9p2000", "plan 9", "styx", "ninep"]
306    }
307    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
308        vec![]
309    }
310    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
311        vec![
312            entry_action(),
313            not_found_action(),
314            listing_action(),
315            content_action(),
316            ok_action(),
317            error_action(),
318        ]
319    }
320    fn get_event_types(&self) -> Vec<EventType> {
321        vec![
322            STAT_EVENT.clone(),
323            LIST_EVENT.clone(),
324            READ_EVENT.clone(),
325            WRITE_EVENT.clone(),
326            CREATE_EVENT.clone(),
327            REMOVE_EVENT.clone(),
328            WSTAT_EVENT.clone(),
329        ]
330    }
331    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
332        vec![ParameterDefinition {
333            name: "idle_timeout_secs".into(),
334            type_hint: "number".into(),
335            description: "Seconds a connection may stay silent between messages (1..=86400)".into(),
336            required: false,
337            example: json!(600),
338            default: Some(json!(super::IDLE_TIMEOUT.as_secs())),
339        }]
340    }
341    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
342        use crate::protocol::metadata::*;
343        ProtocolMetadataV2::builder()
344            .state(DevelopmentState::Experimental)
345            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(564))
346            .well_known_port(564)
347            .implementation("9P2000 over Tokio TCP, hand-written: version, attach (no auth), walk with partial results and .., open/create/read/write/clunk/remove/stat/wstat, flush, directory reads paged as whole stat entries")
348            .llm_control("Whether each path exists and what it is, directory listings, file contents, and whether each write, create, remove, rename or truncate is accepted")
349            .e2e_testing("tests/server/ninep: raw messages and bounds; 9fans.net/go's plan9/client and knusbaum/go9p's client (Go) as independent clients")
350            .notes("9P2000 only: a 9P2000.u or 9P2000.L client is offered plain 9P2000 and Linux's v9fs will refuse it. No authentication (Tauth is refused, every attach succeeds); uname is passed to the handler. Nothing is stored: a file's content is asked for on each read from offset 0 and served from that answer for the rest of the fid's reads. msize at most 65536, 256 fids per connection, 16 elements per walk, 1 MiB per file, 1024 entries per directory. A handler failure answers Rerror with a generic message, never a fabricated file.")
351            .request_only("Every reply answers the T-message with the same tag; the server never speaks first")
352            .answers_on_failure()
353            .max_inbound_bytes(super::wire::MAX_MSIZE as usize)
354            .build()
355    }
356    fn example_prompt(&self) -> &'static str {
357        "9P file server on port 5640 exporting a docs directory with a readme"
358    }
359    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
360        let llm = json!({"type":"open_server","base_stack":"9p","port":5640,"instruction":"Export /docs containing readme.txt with a short welcome text; refuse writes"});
361        let mut scripted = llm.clone();
362        scripted["event_handlers"] = json!([
363            {"event_pattern":"ninep_stat","handler":{"type":"script","language":"python","code":"import json,sys\np=json.load(sys.stdin)['event']['path']\nt={'/docs':{'type':'ninep_entry','kind':'dir'},'/docs/readme.txt':{'type':'ninep_entry','kind':'file','size':8}}\nprint(json.dumps({'actions':[t.get(p,{'type':'ninep_not_found'})]}))"}},
364            {"event_pattern":"ninep_list","handler":{"type":"script","language":"python","code":"import json,sys\np=json.load(sys.stdin)['event']['path']\ne=[{'name':'docs','kind':'dir'}] if p=='/' else [{'name':'readme.txt','kind':'file','size':8}]\nprint(json.dumps({'actions':[{'type':'ninep_listing','entries':e}]}))"}},
365            {"event_pattern":"ninep_read","handler":{"type":"static","actions":[{"type":"ninep_content","data":"welcome\n"}]}},
366            {"event_pattern":"*","handler":{"type":"static","actions":[{"type":"ninep_error","message":"permission denied"}]}}
367        ]);
368        let mut static_example = llm.clone();
369        static_example["event_handlers"] = json!([
370            {"event_pattern":"ninep_stat","handler":{"type":"static","actions":[{"type":"ninep_entry","kind":"file","size":6}]}},
371            {"event_pattern":"ninep_list","handler":{"type":"static","actions":[{"type":"ninep_listing","entries":[{"name":"hello","kind":"file","size":6}]}]}},
372            {"event_pattern":"ninep_read","handler":{"type":"static","actions":[{"type":"ninep_content","data":"hello\n"}]}},
373            {"event_pattern":"*","handler":{"type":"static","actions":[{"type":"ninep_error","message":"read-only file system"}]}}
374        ]);
375        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
376    }
377    fn group_name(&self) -> &'static str {
378        "Application"
379    }
380}
381
382impl Server for NinepProtocol {
383    fn spawn(
384        &self,
385        ctx: SpawnContext,
386    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
387    {
388        Box::pin(super::spawn(ctx))
389    }
390    fn execute_action(&self, v: Value) -> Result<ActionResult> {
391        let name = v["type"].as_str().unwrap_or_default().to_string();
392        match name.as_str() {
393            "ninep_entry" => check_entry(&v, false)?,
394            "ninep_listing" => {
395                let entries = v["entries"]
396                    .as_array()
397                    .ok_or_else(|| anyhow::anyhow!("entries must be an array"))?;
398                ensure!(
399                    entries.len() <= super::wire::MAX_ENTRIES,
400                    "a listing holds at most {} entries",
401                    super::wire::MAX_ENTRIES
402                );
403                for e in entries {
404                    check_entry(e, true)?;
405                }
406            }
407            "ninep_content" => {
408                let data = v["data"]
409                    .as_str()
410                    .ok_or_else(|| anyhow::anyhow!("data must be a string"))?;
411                let bytes = super::wire::from_text(data, v["encoding"].as_str())?;
412                ensure!(
413                    bytes.len() <= super::wire::MAX_CONTENT,
414                    "content exceeds {} bytes",
415                    super::wire::MAX_CONTENT
416                );
417            }
418            "ninep_error" => {
419                let message = v["message"]
420                    .as_str()
421                    .ok_or_else(|| anyhow::anyhow!("message must be a string"))?;
422                ensure!(
423                    !message.is_empty() && message.len() <= 255,
424                    "message must be 1 to 255 bytes"
425                );
426            }
427            "ninep_not_found" | "ninep_ok" => {}
428            _ => bail!("Unknown 9P server action"),
429        }
430        Ok(ActionResult::Custom { name, data: v })
431    }
432}