Skip to main content

netget/server/rsync/
actions.rs

1//! What the model decides for an rsync daemon: which modules exist, what a module holds at the
2//! path a client asked for, and which uploaded files to take. The model supplies every byte it
3//! serves and is handed every byte it takes; nothing is read from or written to disk.
4use crate::llm::actions::{
5    protocol_trait::{ActionResult, Protocol, Server},
6    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
7};
8use crate::protocol::{log_template::LogTemplate, EventType, SpawnContext};
9use crate::state::app_state::AppState;
10use anyhow::{bail, ensure, Context, Result};
11use serde_json::{json, Value};
12use std::sync::LazyLock;
13
14pub const MODULES: &str = "rsync_modules";
15pub const ENTRIES: &str = "rsync_send_entries";
16pub const REFUSE: &str = "rsync_refuse";
17pub const ACCEPT_UPLOAD: &str = "rsync_accept_upload";
18/// Modules one listing may name.
19pub const MAX_MODULES: usize = 256;
20
21#[derive(Default, Clone)]
22pub struct RsyncProtocol;
23
24impl RsyncProtocol {
25    pub fn new() -> Self {
26        Self
27    }
28}
29
30pub fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
31    Parameter {
32        name: name.into(),
33        type_hint: type_hint.into(),
34        description: description.into(),
35        required,
36    }
37}
38
39pub fn action(
40    name: &str,
41    description: &str,
42    parameters: Vec<Parameter>,
43    example: Value,
44) -> ActionDefinition {
45    let info = match name {
46        MODULES => "-> rsync module list".to_string(),
47        ENTRIES => "-> rsync file list".to_string(),
48        REFUSE => "-> rsync refusal: {message}".to_string(),
49        ACCEPT_UPLOAD => "-> rsync upload accepted".to_string(),
50        other => format!("-> rsync {}", other.trim_start_matches("rsync_")),
51    };
52    ActionDefinition {
53        name: name.into(),
54        description: description.into(),
55        parameters,
56        example,
57        log_template: Some(LogTemplate::new().with_info(info)),
58    }
59}
60
61pub fn modules_action() -> ActionDefinition {
62    action(
63        MODULES,
64        "Answer the module listing (what `rsync rsync://host/` prints): each module's name and comment.",
65        vec![p("modules", "array", "Modules, each {name, comment}; name is one word", true)],
66        json!({"type":MODULES,"modules":[{"name":"pub","comment":"public files"}]}),
67    )
68}
69
70pub fn entries_action() -> ActionDefinition {
71    action(
72        ENTRIES,
73        "Answer with what the module holds at and under the requested path. The daemon sends the right part of it, in rsync's order.",
74        vec![p(
75            "entries",
76            "array",
77            "Each {path (relative to the module root, e.g. docs/a.txt), type (file, dir or symlink), content (files), encoding (utf8 or hex), target (symlinks), mode (octal string, e.g. \"644\"), mtime (unix seconds)}",
78            true,
79        )],
80        json!({"type":ENTRIES,"entries":[
81            {"path":"hello.txt","type":"file","content":"hello\n"},
82            {"path":"docs","type":"dir"},
83            {"path":"docs/readme.md","type":"file","content":"# Readme\n","mode":"644"}]}),
84    )
85}
86
87pub fn refuse_action() -> ActionDefinition {
88    action(
89        REFUSE,
90        "Refuse the request; the client prints the message and exits with an error.",
91        vec![p(
92            "message",
93            "string",
94            "Why, e.g. Unknown module 'x' or permission denied",
95            true,
96        )],
97        json!({"type":REFUSE,"message":"Unknown module 'secret'"}),
98    )
99}
100
101pub fn accept_upload_action() -> ActionDefinition {
102    action(
103        ACCEPT_UPLOAD,
104        "Accept an upload. Give `paths` to take only those files (the rest are refused, each with an error the client prints); leave it out to take every file. The files' contents arrive afterwards in rsync_upload_complete. Nothing is written to disk.",
105        vec![p(
106            "paths",
107            "array",
108            "Optional: the files to take, as module-relative paths from the event's files list",
109            false,
110        )],
111        json!({"type":ACCEPT_UPLOAD,"paths":["incoming/report.txt"]}),
112    )
113}
114
115pub static UPLOAD_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
116    EventType::new(
117        "rsync_upload_request",
118        "A client is uploading (rsync files rsync://host/module/path/). Its file list is here, without contents. Answer rsync_accept_upload to take all or some of the files, or rsync_refuse.",
119        accept_upload_action().example.clone(),
120    )
121    .with_parameters(vec![
122        p("module", "string", "The module named in the request", true),
123        p("path", "string", "The destination inside the module (empty for its root)", true),
124        p("files", "array", "Each {path (module-relative), type (file, dir or symlink), size, mode (octal string), target (symlinks)}", true),
125        p("file_count", "number", "Regular files in the list", true),
126        p("total_bytes", "number", "Their sizes added up", true),
127        p("client", "string", "The client's address and port", true),
128    ])
129    .with_actions(vec![accept_upload_action(), refuse_action()])
130});
131
132pub static UPLOAD_COMPLETE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
133    EventType::new(
134        "rsync_upload_complete",
135        "An upload finished: the files that were taken, with their contents (each checked against the client's MD4), and the ones that were not. Nothing was written to disk; record what matters in memory if it should be served later.",
136        json!({"type":"set_memory","value":"received incoming/report.txt"}),
137    )
138    .with_parameters(vec![
139        p("module", "string", "The module uploaded to", true),
140        p("path", "string", "The destination inside the module", true),
141        p("files", "array", "Each received file {path, size, content, encoding (utf8 or hex)}; directories and symlinks the list named are included without content", true),
142        p("refused", "array", "Files not taken, each {path, reason}", true),
143        p("client", "string", "The client's address and port", true),
144    ])
145    .with_no_actions()
146});
147
148pub static LIST_MODULES_EVENT: LazyLock<EventType> = LazyLock::new(|| {
149    EventType::new(
150        "rsync_list_modules",
151        "A client asked which modules this daemon offers (rsync rsync://host/). Answer rsync_modules.",
152        modules_action().example.clone(),
153    )
154    .with_parameters(vec![p("client", "string", "The client's address and port", true)])
155    .with_actions(vec![modules_action()])
156});
157
158pub static REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
159    EventType::new(
160        "rsync_request",
161        "A client asked for a path in a module, to list it or download it. Answer rsync_send_entries with what is there, or rsync_refuse.",
162        entries_action().example.clone(),
163    )
164    .with_parameters(vec![
165        p("module", "string", "The module named in the request", true),
166        p("path", "string", "The path inside the module (empty for its root; a trailing / means the directory's contents)", true),
167        p("recursive", "boolean", "Whether the whole subtree is wanted (-r)", true),
168        p("list_only", "boolean", "Whether the client said --list-only (it also lists when it names no destination)", true),
169        p("client", "string", "The client's address and port", true),
170    ])
171    .with_actions(vec![entries_action(), refuse_action()])
172});
173
174/// Check an action's shape (the daemon applies it to the request it answers).
175pub fn check(v: &Value) -> Result<()> {
176    match v["type"].as_str().unwrap_or_default() {
177        MODULES => {
178            let list = v["modules"].as_array().context("modules is an array")?;
179            ensure!(list.len() <= MAX_MODULES, "more than {MAX_MODULES} modules");
180            for m in list {
181                let name = m["name"].as_str().context("each module needs a name")?;
182                ensure!(
183                    !name.is_empty()
184                        && name.len() <= 64
185                        && !name.contains(['/', ' ', '\t', '\n', '\r']),
186                    "module name {name:?} must be one word without /"
187                );
188            }
189        }
190        ENTRIES => {
191            let list = v["entries"].as_array().context("entries is an array")?;
192            ensure!(list.len() <= super::wire::MAX_ENTRIES, "too many entries");
193            for e in list {
194                super::wire::entry_from_json(e, 0)?;
195            }
196        }
197        REFUSE => {
198            v["message"]
199                .as_str()
200                .context("rsync_refuse needs a message")?;
201        }
202        ACCEPT_UPLOAD => {
203            if let Some(paths) = v.get("paths") {
204                let list = paths.as_array().context("paths is an array")?;
205                ensure!(list.len() <= super::wire::MAX_ENTRIES, "too many paths");
206                ensure!(list.iter().all(|p| p.is_string()), "every path is a string");
207            }
208        }
209        other => bail!("Unknown rsync action {other:?}"),
210    }
211    Ok(())
212}
213
214impl Protocol for RsyncProtocol {
215    fn protocol_name(&self) -> &'static str {
216        "rsync"
217    }
218    fn stack_name(&self) -> &'static str {
219        "ETH>IP>TCP>rsync"
220    }
221    fn keywords(&self) -> Vec<&'static str> {
222        vec![
223            "rsync",
224            "rsyncd",
225            "rsync daemon",
226            "rsync://",
227            "873",
228            "file sync",
229        ]
230    }
231    fn description(&self) -> &'static str {
232        "rsync daemon (rsync://, protocol 29): modules whose listings and file contents the model supplies, for stock rsync clients to list and download, and uploads whose files the model chooses to take"
233    }
234    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
235        Vec::new()
236    }
237    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
238        vec![
239            modules_action(),
240            entries_action(),
241            refuse_action(),
242            accept_upload_action(),
243        ]
244    }
245    fn get_event_types(&self) -> Vec<EventType> {
246        vec![
247            LIST_MODULES_EVENT.clone(),
248            REQUEST_EVENT.clone(),
249            UPLOAD_REQUEST_EVENT.clone(),
250            UPLOAD_COMPLETE_EVENT.clone(),
251        ]
252    }
253    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
254        vec![
255            ParameterDefinition {
256                name: "motd".into(),
257                type_hint: "string".into(),
258                description: "Message of the day, sent after the greeting (rsync prints it)".into(),
259                required: false,
260                example: json!("Welcome to the NetGet mirror"),
261                default: None,
262            },
263            ParameterDefinition {
264                name: "idle_timeout_secs".into(),
265                type_hint: "number".into(),
266                description: "Seconds to wait for the client's next line or request".into(),
267                required: false,
268                example: json!(60),
269                default: Some(json!(super::IDLE_TIMEOUT.as_secs())),
270            },
271        ]
272    }
273    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
274        use crate::protocol::metadata::*;
275        ProtocolMetadataV2::builder()
276            .state(DevelopmentState::Experimental)
277            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(873))
278            .well_known_port(873)
279            .answers_on_failure()
280            .request_only("rsync carries one request per connection on a strictly sequenced binary stream; a message injected into it would desynchronise the client")
281            .implementation("Hand-rolled rsync daemon protocol pinned at 29 (src/server/rsync/wire.rs): @RSYNCD handshake and MOTD, module listing, newline-terminated arguments, the raw checksum seed, multiplexed output, the protocol-29 file list in rsync's f_name_cmp order, whole-file transfers as literal tokens with MD4(seed ‖ data), dry runs, -c list checksums, and the three-NDX_DONE end of run with stats")
282            .llm_control("Which modules exist, and the files, directories and symlinks a module holds (with their contents) at each requested path; refusals")
283            .e2e_testing("tests/server/rsync: the stock rsync 3.2.7 client — module listing, --list-only, -a and -r downloads compared byte for byte, a dry run, a symlink, a refusal, and an upload refused as read-only; upload_test.rs: pushes of a tree and of one file with -a, the model taking part of an upload with the rest refused per file, contents compared byte for byte, and uploads refused (the model, --delete, -H, the per-file size bound); upload_bounds_test.rs: a hand-written protocol-29 client sending more than a file's listed size, more file data than an upload may carry, an oversized file list, a refused upload, and a multi-byte path across the message cut")
284            .notes("Uploads are offered to the model, which takes all or some files; their contents reach it in rsync_upload_complete and nothing is written to disk. An upload names one destination; --delete and -H are refused for it. Bounds: 1 MiB per uploaded file and no more than its listed size (a file sending more is refused), 8 MiB of declared sizes and of file data actually received and 1000 files per upload, and an upload's file list at most 2000 entries and 1 MiB of names and symlink targets. A refused upload raises no rsync_upload_complete. -z, -R, -U, -N, -s and --files-from are refused rather than half-supported. No authentication (every module is open). Block matching against an existing destination file is not done: every file is sent whole, which is correct, just not minimal.")
285            .max_inbound_bytes(super::MAX_INBOUND_BYTES)
286            .build()
287    }
288    fn example_prompt(&self) -> &'static str {
289        "Run an rsync daemon with a module 'pub' holding a README and a docs directory"
290    }
291    fn get_startup_examples(&self) -> StartupExamples {
292        let base = json!({"type":"open_server","base_stack":"rsync","port":0,
293            "instruction":"Offer one module, pub, holding hello.txt (hello) and docs/readme.md"});
294        let mut static_example = base.clone();
295        static_example["event_handlers"] = json!([
296            {"event_pattern":"rsync_list_modules","handler":{"type":"static","actions":[modules_action().example]}},
297            {"event_pattern":"rsync_request","handler":{"type":"static","actions":[entries_action().example]}}
298        ]);
299        let mut scripted = base.clone();
300        scripted["event_handlers"] = json!([{"event_pattern":"*","handler":{"type":"script","language":"python",
301            "code":"import json,sys\ni=json.load(sys.stdin); t=i['event_type_id']; e=i['event']\nif t=='rsync_list_modules': a=[{'type':'rsync_modules','modules':[{'name':'pub','comment':'public'}]}]\nelif e['module']!='pub': a=[{'type':'rsync_refuse','message':\"Unknown module '%s'\" % e['module']}]\nelse: a=[{'type':'rsync_send_entries','entries':[{'path':'hello.txt','type':'file','content':'hello\\n'}]}]\nprint(json.dumps({'actions':a}))"}}]);
302        StartupExamples::new(base, scripted, static_example)
303    }
304    fn group_name(&self) -> &'static str {
305        "File Transfer"
306    }
307}
308
309impl Server for RsyncProtocol {
310    fn spawn(
311        &self,
312        ctx: SpawnContext,
313    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
314    {
315        Box::pin(super::spawn(ctx))
316    }
317    fn execute_action(&self, action: Value) -> Result<ActionResult> {
318        check(&action)?;
319        // The connection applies it to the request it answers.
320        Ok(ActionResult::Custom {
321            name: action["type"].as_str().unwrap_or_default().to_string(),
322            data: action,
323        })
324    }
325}