1use 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";
18pub 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
174pub 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 Ok(ActionResult::Custom {
321 name: action["type"].as_str().unwrap_or_default().to_string(),
322 data: action,
323 })
324 }
325}