1use std::collections::{HashMap, HashSet};
13use std::time::Duration;
14
15use crate::communication::MessageProcessingError;
16use crate::node_set::NodeSet;
17use crate::propagation::{PropagationChannel, PropagationResult, PropagationTreeRouter};
18use crate::topology::TopologyTree;
19use crate::worker::{WorkerError, WorkerState};
20
21fn nodeset_from_strings(names: &[String]) -> String {
23 names.join(",")
24}
25
26pub const SNAME_STDERR: &str = "stderr";
28pub const SNAME_STDOUT: &str = "stdout";
30
31pub const UNTAR_CMD_FMT: &str = "tar -xf - -C '%s'";
36
37pub const TAR_CMD_FMT: &str = "tar -cf - -C '%s' \
43--transform \"s,^\\([^/]*\\)[/]*,\\1.$(hostname -s)/,\" \
44'%s' | base64 -w 65536";
45
46pub fn shell_escape_single_quoted(value: &str) -> String {
51 value.replace('\'', "'\"'\"'")
52}
53
54pub fn untar_command(dest: &str) -> String {
56 UNTAR_CMD_FMT.replacen("%s", &shell_escape_single_quoted(dest), 1)
57}
58
59pub fn tar_command(srcdir: &str, srcbase: &str) -> String {
61 TAR_CMD_FMT
62 .replacen("%s", &shell_escape_single_quoted(srcdir), 1)
63 .replacen("%s", &shell_escape_single_quoted(srcbase), 1)
64}
65
66fn posix_dirname(path: &str) -> &str {
69 let trimmed = path.trim_end_matches('/');
70 if trimmed.is_empty() {
71 return if path.starts_with('/') { "/" } else { "" };
73 }
74 match trimmed.rfind('/') {
75 Some(0) => "/",
76 Some(pos) => &trimmed[..pos],
77 None => "",
78 }
79}
80
81fn posix_basename(path: &str) -> &str {
83 let trimmed = path.trim_end_matches('/');
84 match trimmed.rfind('/') {
85 Some(pos) => &trimmed[pos + 1..],
86 None => trimmed,
87 }
88}
89
90fn posix_normpath(path: &str) -> String {
94 let absolute = path.starts_with('/');
95 let mut out: Vec<&str> = Vec::new();
96 for comp in path.split('/') {
97 match comp {
98 "" | "." => {}
99 ".." => {
100 if out.last().is_some_and(|last| *last != "..") {
101 out.pop();
102 } else if !absolute {
103 out.push("..");
104 }
105 }
106 _ => out.push(comp),
107 }
108 }
109 let joined = out.join("/");
110 match (absolute, joined.is_empty()) {
111 (true, true) => "/".to_string(),
112 (true, false) => format!("/{}", joined),
113 (false, true) => ".".to_string(),
114 (false, false) => joined,
115 }
116}
117
118#[derive(Debug, Clone)]
120pub struct TreeWorkerConfig {
121 pub fanout: usize,
123 pub timeout: Option<Duration>,
125 pub stderr: bool,
127 pub remote: bool,
129 pub source: Option<String>,
131 pub dest: Option<String>,
133 pub reverse: bool,
135 pub preserve: bool,
137}
138
139impl Default for TreeWorkerConfig {
140 fn default() -> Self {
141 Self {
142 fanout: 64,
143 timeout: None,
144 stderr: false,
145 remote: true,
146 source: None,
147 dest: None,
148 reverse: false,
149 preserve: false,
150 }
151 }
152}
153
154#[derive(Debug)]
156struct GatewayState {
157 channel: PropagationChannel,
159 active_targets: HashSet<String>,
161 started: bool,
163 ctl_unsent_targets: Vec<String>,
171}
172
173#[derive(Debug, Clone)]
175pub enum TreeWorkerEvent {
176 Started,
179 Pickup {
184 node: String,
186 },
187 StdOut {
189 node: String,
190 gateway: String,
191 data: Vec<u8>,
192 },
193 StdErr {
195 node: String,
196 gateway: String,
197 data: Vec<u8>,
198 },
199 Close {
201 node: String,
202 gateway: String,
203 rc: i32,
204 },
205 Timeout { node: String, gateway: String },
207 Routing {
209 event: String,
210 gateway: String,
211 targets: String,
212 },
213}
214
215#[derive(Debug, Default)]
218struct RcopyState {
219 encoded_tail: Vec<u8>,
221 tar_bytes: Vec<u8>,
223}
224
225pub struct TreeWorker {
234 nodes: NodeSet,
236 command: Option<String>,
238 config: TreeWorkerConfig,
240 state: WorkerState,
242 router: Option<PropagationTreeRouter>,
244 gateways: HashMap<String, GatewayState>,
246 retcodes: HashMap<String, i32>,
248 events: Vec<TreeWorkerEvent>,
250 close_count: usize,
252 target_count: usize,
254 has_timeout: bool,
256 worker_id: u64,
258 timeout_nodes: HashSet<String>,
260 initialized: bool,
262 aborted: bool,
265 pending_pickups: Vec<String>,
268 start_count: usize,
271 child_count: usize,
275 rcopy_states: HashMap<String, RcopyState>,
277}
278
279impl TreeWorker {
280 pub fn new(nodes: NodeSet, command: String, config: TreeWorkerConfig) -> Self {
287 Self::with_command(nodes, Some(command), config)
288 .expect("command is always provided to TreeWorker::new")
289 }
290
291 pub fn new_copy(nodes: NodeSet, config: TreeWorkerConfig) -> Result<Self, WorkerError> {
300 Self::with_command(nodes, None, config)
301 }
302
303 fn with_command(
305 nodes: NodeSet,
306 command: Option<String>,
307 mut config: TreeWorkerConfig,
308 ) -> Result<Self, WorkerError> {
309 static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
310
311 if command.is_none() && config.source.is_none() {
312 return Err(WorkerError::General(
313 "missing command or source parameter in TreeWorker constructor".to_string(),
314 ));
315 }
316
317 if config.source.is_some() {
321 config.stderr = true;
322 }
323
324 Ok(Self {
325 nodes,
326 command,
327 config,
328 state: WorkerState::Pending,
329 router: None,
330 gateways: HashMap::new(),
331 retcodes: HashMap::new(),
332 events: Vec::new(),
333 close_count: 0,
334 target_count: 0,
335 has_timeout: false,
336 worker_id: NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
337 timeout_nodes: HashSet::new(),
338 initialized: false,
339 aborted: false,
340 pending_pickups: Vec::new(),
341 start_count: 0,
342 child_count: 0,
343 rcopy_states: HashMap::new(),
344 })
345 }
346
347 pub fn set_router(&mut self, router: PropagationTreeRouter) {
352 self.router = Some(router);
353 }
354
355 pub fn set_topology(&mut self, root: &str, topology: &TopologyTree) -> Result<(), WorkerError> {
364 let router = PropagationTreeRouter::new(root, topology, self.config.fanout)
365 .map_err(|e| WorkerError::General(format!("topology error: {}", e)))?;
366 self.router = Some(router);
367 Ok(())
368 }
369
370 pub fn nodes(&self) -> &NodeSet {
372 &self.nodes
373 }
374
375 pub fn command(&self) -> Option<&str> {
377 self.command.as_deref()
378 }
379
380 pub fn state(&self) -> WorkerState {
382 self.state
383 }
384
385 pub fn retcodes(&self) -> &HashMap<String, i32> {
387 &self.retcodes
388 }
389
390 pub fn events(&self) -> &[TreeWorkerEvent] {
392 &self.events
393 }
394
395 pub fn drain_events(&mut self) -> Vec<TreeWorkerEvent> {
397 std::mem::take(&mut self.events)
398 }
399
400 pub fn num_timeout(&self) -> usize {
402 self.timeout_nodes.len()
403 }
404
405 pub fn timeout_nodes(&self) -> &HashSet<String> {
407 &self.timeout_nodes
408 }
409
410 pub fn has_timeout(&self) -> bool {
412 self.has_timeout
413 }
414
415 pub fn is_done(&self) -> bool {
417 self.state == WorkerState::Done || self.state == WorkerState::Aborted
418 }
419
420 fn distribute(&self) -> Result<Vec<(NodeSet, NodeSet)>, WorkerError> {
425 self.distribute_nodes(&self.nodes)
426 }
427
428 fn distribute_nodes(&self, nodes: &NodeSet) -> Result<Vec<(NodeSet, NodeSet)>, WorkerError> {
431 let router = self.router.as_ref().ok_or_else(|| {
432 WorkerError::General("router not set; call set_router() or set_topology()".to_string())
433 })?;
434
435 let mut distribution: HashMap<String, NodeSet> = HashMap::new();
436
437 for (gw, dstset) in router.dispatch(nodes) {
438 let gw_str = gw.to_string();
439 distribution
440 .entry(gw_str)
441 .and_modify(|ns| ns.update(&dstset))
442 .or_insert(dstset);
443 }
444
445 Ok(distribution
446 .into_iter()
447 .map(|(k, v)| {
448 let gw = NodeSet::parse(&k).unwrap_or_default();
449 (gw, v)
450 })
451 .collect())
452 }
453
454 fn remote_shell_command(&self) -> Option<String> {
460 if let Some(ref cmd) = self.command {
461 return Some(cmd.clone());
462 }
463 let source = self.config.source.as_ref()?;
464 if self.config.reverse {
465 let srcdir = posix_dirname(source);
467 let normed = posix_normpath(source);
468 let srcbase = posix_basename(&normed);
469 Some(tar_command(srcdir, srcbase))
470 } else {
471 let dest = self.config.dest.as_deref().unwrap_or("");
473 Some(untar_command(dest))
474 }
475 }
476
477 pub fn start(&mut self) -> Result<Vec<(NodeSet, NodeSet)>, WorkerError> {
482 if self.state != WorkerState::Pending {
483 return Err(WorkerError::General(
484 "worker already started or aborted".to_string(),
485 ));
486 }
487
488 self.state = WorkerState::Running;
489
490 let next_hops = self.distribute()?;
491
492 for (gw, targets) in &next_hops {
493 let target_count = targets.len();
494 self.target_count += target_count;
495
496 let gw_str = gw.to_string();
497 let targets_str = targets.to_string();
498
499 if gw_str == targets_str {
501 self.child_count += 1;
505 continue;
506 }
507
508 let channel = PropagationChannel::new(&gw_str);
510 let gw_state = GatewayState {
511 channel,
512 active_targets: targets.iter().collect(),
513 started: false,
514 ctl_unsent_targets: Vec::new(),
515 };
516 self.gateways.insert(gw_str, gw_state);
517 }
518
519 self.check_ini();
522
523 Ok(next_hops)
524 }
525
526 pub fn start_gateway(
532 &mut self,
533 gateway: &str,
534 topology_bytes: &[u8],
535 ) -> Result<(), WorkerError> {
536 let command = self.remote_shell_command();
537 let worker_id = self.worker_id;
538 {
539 let gw_state = self
540 .gateways
541 .get_mut(gateway)
542 .ok_or_else(|| WorkerError::General(format!("unknown gateway: {}", gateway)))?;
543
544 if gw_state.started {
545 return Err(WorkerError::General(format!(
546 "gateway {} already started",
547 gateway
548 )));
549 }
550
551 gw_state.channel.start(topology_bytes);
552 gw_state.started = true;
553
554 if let Some(ref cmd) = command {
556 let mut targets: Vec<String> = gw_state.active_targets.iter().cloned().collect();
557 targets.sort();
558 let targets_str = nodeset_from_strings(&targets);
559 gw_state
560 .channel
561 .shell(&targets_str, cmd.as_bytes(), worker_id);
562 gw_state.ctl_unsent_targets.extend(targets);
565 }
566 }
567
568 self.sync_gateway_pickups(gateway);
569
570 Ok(())
571 }
572
573 pub fn sync_gateway_pickups(&mut self, gateway: &str) {
584 let Some(gw_state) = self.gateways.get_mut(gateway) else {
585 return;
586 };
587 if !gw_state.channel.channel.setup || gw_state.ctl_unsent_targets.is_empty() {
588 return;
589 }
590 let targets = std::mem::take(&mut gw_state.ctl_unsent_targets);
591 for node in targets {
592 self.emit_pickup(&node);
593 }
594 }
595
596 pub fn emit_pickup(&mut self, node: &str) {
605 if self.aborted {
606 return;
607 }
608 if self.initialized {
609 self.events.push(TreeWorkerEvent::Pickup {
610 node: node.to_string(),
611 });
612 } else {
613 self.pending_pickups.push(node.to_string());
615 }
616 }
617
618 pub fn notify_child_start(&mut self) {
623 self.start_count += 1;
624 self.check_ini();
625 }
626
627 pub fn check_ini(&mut self) {
635 if !self.initialized && self.start_count >= self.child_count {
636 self.initialized = true;
637 self.events.push(TreeWorkerEvent::Started);
638 let pending = std::mem::take(&mut self.pending_pickups);
639 for node in pending {
640 if self.aborted {
641 break;
642 }
643 self.events.push(TreeWorkerEvent::Pickup { node });
644 }
645 }
646 }
647
648 pub fn mark_unreachable(&mut self, node: &str) {
652 if let Some(ref mut router) = self.router {
653 router.mark_unreachable(node);
654 }
655 }
656
657 pub fn relaunch(
672 &mut self,
673 previous_gateway: &str,
674 ) -> Result<Vec<(NodeSet, NodeSet)>, WorkerError> {
675 let gw_state = self.gateways.remove(previous_gateway).ok_or_else(|| {
676 WorkerError::General(format!("unknown gateway: {}", previous_gateway))
677 })?;
678
679 let mut targets: Vec<String> = gw_state.active_targets.into_iter().collect();
680 targets.sort();
681 let target_count = targets.len();
682 self.target_count = self.target_count.saturating_sub(target_count);
687
688 self.events.push(TreeWorkerEvent::Routing {
690 event: "reroute".to_string(),
691 gateway: previous_gateway.to_string(),
692 targets: targets.join(","),
693 });
694
695 let targets_ns = NodeSet::parse(&targets.join(","))
697 .map_err(|e| WorkerError::General(format!("invalid targets: {}", e)))?;
698 let next_hops = self.distribute_nodes(&targets_ns)?;
699
700 for (gw, hop_targets) in &next_hops {
701 self.target_count += hop_targets.len();
702 let gw_str = gw.to_string();
703 if gw_str == hop_targets.to_string() {
704 self.child_count += 1;
706 continue;
707 }
708 let new_targets: HashSet<String> = hop_targets.iter().collect();
709 match self.gateways.get_mut(&gw_str) {
710 Some(existing) => {
711 let mut ts: Vec<String> = new_targets.iter().cloned().collect();
715 ts.sort();
716 existing.active_targets.extend(new_targets);
717 self.shell_on_channel(&gw_str, &ts)?;
718 }
719 None => {
720 let channel = PropagationChannel::new(&gw_str);
721 self.gateways.insert(
722 gw_str,
723 GatewayState {
724 channel,
725 active_targets: new_targets,
726 started: false,
727 ctl_unsent_targets: Vec::new(),
728 },
729 );
730 }
731 }
732 }
733
734 self.check_ini();
735
736 Ok(next_hops)
737 }
738
739 fn shell_on_channel(&mut self, gateway: &str, targets: &[String]) -> Result<(), WorkerError> {
744 let command = self.remote_shell_command();
745 let worker_id = self.worker_id;
746 {
747 let gw_state = self
748 .gateways
749 .get_mut(gateway)
750 .ok_or_else(|| WorkerError::General(format!("unknown gateway: {}", gateway)))?;
751
752 if let Some(ref cmd) = command {
753 let targets_str = nodeset_from_strings(targets);
754 gw_state
755 .channel
756 .shell(&targets_str, cmd.as_bytes(), worker_id);
757 gw_state.ctl_unsent_targets.extend(targets.iter().cloned());
758 }
759 }
760 self.sync_gateway_pickups(gateway);
761 Ok(())
762 }
763
764 pub fn process_result(
769 &mut self,
770 gateway: &str,
771 result: PropagationResult,
772 ) -> Result<bool, WorkerError> {
773 self.sync_gateway_pickups(gateway);
776
777 match result {
778 PropagationResult::StdOut { ref node, ref data } => {
779 self.events.push(TreeWorkerEvent::StdOut {
780 node: node.clone(),
781 gateway: gateway.to_string(),
782 data: data.clone(),
783 });
784 }
785 PropagationResult::StdErr { ref node, ref data } => {
786 self.events.push(TreeWorkerEvent::StdErr {
787 node: node.clone(),
788 gateway: gateway.to_string(),
789 data: data.clone(),
790 });
791 }
792 PropagationResult::Retcode { ref node, rc } => {
793 self.retcodes.insert(node.clone(), rc);
794 self.close_count += 1;
795
796 if let Some(gw_state) = self.gateways.get_mut(gateway) {
798 gw_state.active_targets.remove(node);
799 }
800
801 self.events.push(TreeWorkerEvent::Close {
802 node: node.clone(),
803 gateway: gateway.to_string(),
804 rc,
805 });
806 }
807 PropagationResult::Timeout { ref node } => {
808 self.timeout_nodes.insert(node.clone());
809 self.has_timeout = true;
810 self.close_count += 1;
811
812 if let Some(gw_state) = self.gateways.get_mut(gateway) {
813 gw_state.active_targets.remove(node);
814 }
815
816 self.events.push(TreeWorkerEvent::Timeout {
817 node: node.clone(),
818 gateway: gateway.to_string(),
819 });
820 }
821 PropagationResult::Routing {
822 ref event,
823 ref gateway,
824 ref targets,
825 } => {
826 self.events.push(TreeWorkerEvent::Routing {
827 event: event.clone(),
828 gateway: gateway.clone(),
829 targets: targets.clone(),
830 });
831 }
832 }
833
834 let done = self.close_count >= self.target_count && self.target_count > 0;
836 if done {
837 self.state = WorkerState::Done;
838 }
839
840 self.check_gateway_fini(gateway);
842
843 Ok(done)
844 }
845
846 pub fn record_direct_close(&mut self, node: &str, rc: i32) {
850 self.retcodes.insert(node.to_string(), rc);
851 self.close_count += 1;
852
853 if self.close_count >= self.target_count && self.target_count > 0 {
854 self.state = WorkerState::Done;
855 }
856 }
857
858 pub fn record_direct_timeout(&mut self, node: &str) {
860 self.timeout_nodes.insert(node.to_string());
861 self.has_timeout = true;
862 self.close_count += 1;
863
864 if self.close_count >= self.target_count && self.target_count > 0 {
865 self.state = WorkerState::Done;
866 }
867 }
868
869 fn check_gateway_fini(&mut self, gateway: &str) {
871 if let Some(gw_state) = self.gateways.get(gateway) {
872 if gw_state.active_targets.is_empty() {
873 }
876 }
877 }
878
879 pub fn write_remote(&mut self, buf: &[u8]) {
885 let worker_id = self.worker_id;
886 for (_, gw_state) in self.gateways.iter_mut() {
887 if !gw_state.active_targets.is_empty() {
888 let targets: Vec<String> = gw_state.active_targets.iter().cloned().collect();
889 let targets_str = nodeset_from_strings(&targets);
890 gw_state.channel.write(&targets_str, buf, worker_id);
891 }
892 }
893 }
894
895 pub fn set_write_eof_remote(&mut self) {
900 let worker_id = self.worker_id;
901 for (_, gw_state) in self.gateways.iter_mut() {
902 if !gw_state.active_targets.is_empty() {
903 let targets: Vec<String> = gw_state.active_targets.iter().cloned().collect();
904 let targets_str = nodeset_from_strings(&targets);
905 gw_state.channel.set_write_eof(&targets_str, worker_id);
906 }
907 }
908 }
909
910 pub fn abort(&mut self) {
916 self.aborted = true;
919 self.pending_pickups.clear();
920
921 if self.is_done() {
922 return;
923 }
924
925 for (gateway, gw_state) in self.gateways.iter_mut() {
927 let remaining: Vec<String> = gw_state.active_targets.drain().collect();
928 for node in remaining {
929 self.retcodes.insert(node.clone(), 76); self.close_count += 1;
931 self.events.push(TreeWorkerEvent::Close {
932 node,
933 gateway: gateway.clone(),
934 rc: 76,
935 });
936 }
937 }
938
939 self.state = WorkerState::Aborted;
940 }
941
942 pub fn is_aborted(&self) -> bool {
944 self.aborted
945 }
946
947 pub fn is_initialized(&self) -> bool {
949 self.initialized
950 }
951
952 pub fn pending_pickup_count(&self) -> usize {
954 self.pending_pickups.len()
955 }
956
957 pub fn config(&self) -> &TreeWorkerConfig {
959 &self.config
960 }
961
962 pub fn stderr(&self) -> bool {
967 self.config.stderr
968 }
969
970 pub fn report_gateway_stderr(&mut self, gateway: &str, nodes: &NodeSet, buf: &[u8]) {
978 let mut data = buf.to_vec();
979 data.push(b'\n');
980 let mut lines: Vec<&[u8]> = data.split(|b| *b == b'\n').collect();
984 lines.pop();
985 for line in lines {
986 let line = match line.last() {
987 Some(b'\r') => &line[..line.len() - 1],
988 _ => line,
989 };
990 for node in nodes.iter() {
991 self.events.push(TreeWorkerEvent::StdErr {
992 node,
993 gateway: gateway.to_string(),
994 data: line.to_vec(),
995 });
996 }
997 }
998 }
999
1000 pub fn report_gateway_error(&mut self, gateway: &str, reason: &str) {
1009 let gateway_ns = NodeSet::parse(gateway).unwrap_or_default();
1010 self.report_gateway_stderr(gateway, &gateway_ns, reason.as_bytes());
1011 }
1012
1013 pub fn process_channel_error(&mut self, gateway: &str, err: &MessageProcessingError) {
1022 self.report_gateway_error(gateway, &err.to_string());
1023 }
1024
1025 pub fn on_remote_node_msgline(&mut self, node: &str, msg: &[u8], sname: &str, gateway: &str) {
1034 let is_rcopy_data =
1035 self.config.source.is_some() && self.config.reverse && sname == SNAME_STDOUT;
1036 if !is_rcopy_data {
1037 let event = if sname == SNAME_STDERR {
1038 TreeWorkerEvent::StdErr {
1039 node: node.to_string(),
1040 gateway: gateway.to_string(),
1041 data: msg.to_vec(),
1042 }
1043 } else {
1044 TreeWorkerEvent::StdOut {
1045 node: node.to_string(),
1046 gateway: gateway.to_string(),
1047 data: msg.to_vec(),
1048 }
1049 };
1050 self.events.push(event);
1051 return;
1052 }
1053
1054 let state = self.rcopy_states.entry(node.to_string()).or_default();
1056 state.encoded_tail.extend_from_slice(msg);
1057 let decodable = state.encoded_tail.len() / 4 * 4;
1059 let chunk: Vec<u8> = state.encoded_tail.drain(..decodable).collect();
1060 if let Ok(text) = std::str::from_utf8(&chunk) {
1061 if let Ok(decoded) = crate::communication::base64_decode(text) {
1064 state.tar_bytes.extend_from_slice(&decoded);
1065 }
1066 }
1069 }
1070
1071 pub fn finalize_rcopy_node(&mut self, node: &str, gateway: &str) -> Result<usize, WorkerError> {
1085 if !(self.config.source.is_some() && self.config.reverse) {
1086 return Ok(0);
1087 }
1088 let Some(state) = self.rcopy_states.remove(node) else {
1089 return Ok(0);
1091 };
1092
1093 let dest =
1094 self.config.dest.clone().ok_or_else(|| {
1095 WorkerError::General("missing dest parameter for rcopy".to_string())
1096 })?;
1097
1098 let mut tar_bytes = state.tar_bytes;
1102 tar_bytes.extend_from_slice(&state.encoded_tail);
1103
1104 match extract_tar(
1105 std::path::Path::new(&dest),
1106 &tar_bytes,
1107 TarExtractFilter::FullyTrusted,
1108 ) {
1109 Ok(written) => Ok(written),
1110 Err(err) => {
1111 self.events.push(TreeWorkerEvent::StdErr {
1114 node: node.to_string(),
1115 gateway: gateway.to_string(),
1116 data: err.message.into_bytes(),
1117 });
1118 Ok(0)
1119 }
1120 }
1121 }
1122
1123 pub fn active_gateway_count(&self) -> usize {
1125 self.gateways
1126 .values()
1127 .filter(|gs| !gs.active_targets.is_empty())
1128 .count()
1129 }
1130
1131 pub fn active_gateways(&self) -> Vec<&str> {
1133 self.gateways
1134 .iter()
1135 .filter(|(_, gs)| !gs.active_targets.is_empty())
1136 .map(|(name, _)| name.as_str())
1137 .collect()
1138 }
1139
1140 pub fn num_nodes(&self) -> usize {
1142 self.nodes.len()
1143 }
1144
1145 pub fn worker_id(&self) -> u64 {
1147 self.worker_id
1148 }
1149}
1150
1151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1164pub enum TarExtractFilter {
1165 FullyTrusted,
1170}
1171
1172#[derive(Debug, Clone)]
1177pub struct TarExtractError {
1178 pub message: String,
1180}
1181
1182impl std::fmt::Display for TarExtractError {
1183 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1184 write!(f, "{}", self.message)
1185 }
1186}
1187
1188impl std::error::Error for TarExtractError {}
1189
1190impl From<std::io::Error> for TarExtractError {
1191 fn from(err: std::io::Error) -> Self {
1192 Self {
1193 message: err.to_string(),
1194 }
1195 }
1196}
1197
1198impl TarExtractError {
1199 fn corrupt(reason: &str) -> Self {
1200 Self {
1201 message: reason.to_string(),
1202 }
1203 }
1204}
1205
1206const TAR_BLOCK: usize = 512;
1208
1209struct TarHeader {
1211 name: String,
1213 mode: u32,
1215 typeflag: u8,
1217 linkname: String,
1219}
1220
1221fn parse_tar_number(field: &[u8]) -> Option<u64> {
1223 if field.is_empty() {
1224 return None;
1225 }
1226 if field[0] & 0x80 != 0 {
1228 let mut value: u64 = (field[0] & 0x7F) as u64;
1229 for byte in &field[1..] {
1230 value = value.checked_shl(8)? | u64::from(*byte);
1231 }
1232 return Some(value);
1233 }
1234 let text: String = field
1236 .iter()
1237 .take_while(|b| **b != 0 && **b != b' ')
1238 .map(|b| *b as char)
1239 .collect();
1240 if text.is_empty() {
1241 return Some(0);
1242 }
1243 u64::from_str_radix(&text, 8).ok()
1244}
1245
1246pub fn extract_tar(
1258 dest: &std::path::Path,
1259 bytes: &[u8],
1260 _filter: TarExtractFilter,
1261) -> Result<usize, TarExtractError> {
1262 use std::io::Write;
1263 use std::os::unix::fs::PermissionsExt;
1264
1265 if bytes.is_empty() {
1266 return Err(TarExtractError::corrupt("empty file"));
1268 }
1269
1270 let mut written = 0usize;
1271 let mut offset = 0usize;
1272 let mut pending_name: Option<String> = None;
1274 let mut pending_linkname: Option<String> = None;
1275 let mut dir_modes: Vec<(std::path::PathBuf, u32)> = Vec::new();
1278
1279 let mut terminated = false;
1280 while offset + TAR_BLOCK <= bytes.len() {
1281 let block = &bytes[offset..offset + TAR_BLOCK];
1282
1283 if block.iter().all(|b| *b == 0) {
1285 terminated = true;
1286 break;
1287 }
1288
1289 let stored = parse_tar_number(&block[148..156])
1291 .ok_or_else(|| TarExtractError::corrupt("bad checksum field"))?;
1292 let computed: u64 = block[..148]
1293 .iter()
1294 .chain(std::iter::repeat(&b' ').take(8))
1295 .chain(block[156..].iter())
1296 .map(|b| u64::from(*b))
1297 .sum();
1298 if stored != computed {
1299 return Err(TarExtractError::corrupt("bad checksum"));
1300 }
1301
1302 let mut name_bytes: Vec<u8> = block[0..100]
1303 .iter()
1304 .take_while(|b| **b != 0)
1305 .cloned()
1306 .collect();
1307 if &block[257..262] == b"ustar" {
1309 let prefix: Vec<u8> = block[345..500]
1310 .iter()
1311 .take_while(|b| **b != 0)
1312 .cloned()
1313 .collect();
1314 if !prefix.is_empty() {
1315 let mut full = prefix;
1316 full.push(b'/');
1317 full.extend_from_slice(&name_bytes);
1318 name_bytes = full;
1319 }
1320 }
1321 let name = String::from_utf8_lossy(&name_bytes).into_owned();
1322 let mode = parse_tar_number(&block[100..108]).unwrap_or(0) as u32;
1323 let size = parse_tar_number(&block[124..136])
1324 .ok_or_else(|| TarExtractError::corrupt("bad size field"))?;
1325 let typeflag = block[156];
1326 let linkname = String::from_utf8_lossy(
1327 &block[157..257]
1328 .iter()
1329 .take_while(|b| **b != 0)
1330 .cloned()
1331 .collect::<Vec<u8>>(),
1332 )
1333 .into_owned();
1334
1335 offset += TAR_BLOCK;
1336 let data_blocks = (size as usize).div_ceil(TAR_BLOCK);
1337 let data_len = data_blocks * TAR_BLOCK;
1338 if offset + data_len > bytes.len() {
1339 return Err(TarExtractError::corrupt("truncated tar archive"));
1340 }
1341 let payload = &bytes[offset..offset + size as usize];
1342 offset += data_len;
1343
1344 match typeflag {
1346 b'L' => {
1347 pending_name = Some(
1348 String::from_utf8_lossy(payload)
1349 .trim_end_matches('\0')
1350 .to_string(),
1351 );
1352 continue;
1353 }
1354 b'K' => {
1355 pending_linkname = Some(
1356 String::from_utf8_lossy(payload)
1357 .trim_end_matches('\0')
1358 .to_string(),
1359 );
1360 continue;
1361 }
1362 b'x' | b'g' => {
1363 for (key, value) in parse_pax_records(payload) {
1365 if typeflag == b'x' {
1366 match key.as_str() {
1367 "path" => pending_name = Some(value),
1368 "linkpath" => pending_linkname = Some(value),
1369 _ => {}
1370 }
1371 }
1372 }
1373 continue;
1374 }
1375 _ => {}
1376 }
1377
1378 let header = TarHeader {
1379 name: pending_name.take().unwrap_or(name),
1380 mode,
1381 typeflag,
1382 linkname: pending_linkname.take().unwrap_or(linkname),
1383 };
1384
1385 match header.typeflag {
1386 b'0' | 0 | b'7' => {
1388 let path = dest.join(&header.name);
1389 if let Some(parent) = path.parent() {
1390 std::fs::create_dir_all(parent)?;
1391 }
1392 let mut file = std::fs::File::create(&path)?;
1393 file.write_all(payload)?;
1394 file.set_permissions(std::fs::Permissions::from_mode(header.mode & 0o7777))?;
1396 written += payload.len();
1397 }
1398 b'5' => {
1399 let path = dest.join(&header.name);
1400 std::fs::create_dir_all(&path)?;
1401 dir_modes.push((path, header.mode));
1402 }
1403 b'2' => {
1404 let path = dest.join(&header.name);
1405 if let Some(parent) = path.parent() {
1406 std::fs::create_dir_all(parent)?;
1407 }
1408 std::os::unix::fs::symlink(&header.linkname, &path)?;
1409 }
1410 b'1' => {
1411 let path = dest.join(&header.name);
1412 if let Some(parent) = path.parent() {
1413 std::fs::create_dir_all(parent)?;
1414 }
1415 std::fs::hard_link(dest.join(&header.linkname), &path)?;
1416 }
1417 other => {
1418 return Err(TarExtractError::corrupt(&format!(
1419 "unsupported tar member type '{}' for '{}'",
1420 other as char, header.name
1421 )));
1422 }
1423 }
1424 }
1425
1426 if !terminated && bytes[offset..].iter().any(|b| *b != 0) {
1432 return Err(TarExtractError::corrupt("truncated tar header"));
1433 }
1434
1435 dir_modes.sort_by_key(|(path, _)| std::cmp::Reverse(path.components().count()));
1437 for (path, mode) in dir_modes {
1438 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode & 0o7777))?;
1439 }
1440
1441 Ok(written)
1442}
1443
1444fn parse_pax_records(payload: &[u8]) -> Vec<(String, String)> {
1446 let mut records = Vec::new();
1447 let text = String::from_utf8_lossy(payload);
1448 let mut rest = text.as_ref();
1449 while !rest.is_empty() {
1450 let Some(space) = rest.find(' ') else { break };
1452 let Ok(len) = rest[..space].parse::<usize>() else {
1453 break;
1454 };
1455 if len == 0 || len > rest.len() {
1456 break;
1457 }
1458 let record = &rest[space + 1..len];
1459 if let Some(eq) = record.find('=') {
1460 let key = record[..eq].to_string();
1461 let value = record[eq + 1..].trim_end_matches('\n').to_string();
1462 records.push((key, value));
1463 }
1464 rest = &rest[len..];
1465 }
1466 records
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471 use super::*;
1472
1473 fn make_nodeset(s: &str) -> NodeSet {
1474 NodeSet::parse(s).unwrap()
1475 }
1476
1477 #[test]
1478 fn test_tree_worker_config_default() {
1479 let config = TreeWorkerConfig::default();
1480 assert_eq!(config.fanout, 64);
1481 assert!(config.timeout.is_none());
1482 assert!(!config.stderr);
1483 assert!(config.remote);
1484 assert!(config.source.is_none());
1485 assert!(config.dest.is_none());
1486 assert!(!config.reverse);
1487 assert!(!config.preserve);
1488 }
1489
1490 #[test]
1491 fn test_tree_worker_creation() {
1492 let nodes = make_nodeset("node[1-10]");
1493 let config = TreeWorkerConfig::default();
1494 let worker = TreeWorker::new(nodes.clone(), "hostname".to_string(), config);
1495
1496 assert_eq!(worker.state(), WorkerState::Pending);
1497 assert_eq!(worker.num_nodes(), 10);
1498 assert_eq!(worker.command(), Some("hostname"));
1499 assert!(worker.retcodes().is_empty());
1500 assert!(worker.events().is_empty());
1501 assert!(!worker.has_timeout());
1502 assert_eq!(worker.num_timeout(), 0);
1503 }
1504
1505 #[test]
1506 fn test_tree_worker_start_without_router() {
1507 let nodes = make_nodeset("node[1-3]");
1508 let config = TreeWorkerConfig::default();
1509 let mut worker = TreeWorker::new(nodes, "echo hi".to_string(), config);
1510
1511 let result = worker.start();
1512 assert!(result.is_err());
1513 assert!(result.unwrap_err().to_string().contains("router not set"));
1514 }
1515
1516 #[test]
1517 fn test_tree_worker_abort() {
1518 let nodes = make_nodeset("node[1-5]");
1519 let config = TreeWorkerConfig::default();
1520 let mut worker = TreeWorker::new(nodes, "echo hi".to_string(), config);
1521
1522 worker.abort();
1523 assert_eq!(worker.state(), WorkerState::Aborted);
1524 assert!(worker.is_done());
1525 }
1526
1527 #[test]
1528 fn test_tree_worker_record_direct_close() {
1529 let nodes = make_nodeset("node[1-3]");
1530 let config = TreeWorkerConfig::default();
1531 let mut worker = TreeWorker::new(nodes, "echo hi".to_string(), config);
1532
1533 worker.state = WorkerState::Running;
1535 worker.target_count = 3;
1536
1537 worker.record_direct_close("node1", 0);
1538 assert_eq!(worker.retcodes().get("node1"), Some(&0));
1539 assert!(!worker.is_done());
1540
1541 worker.record_direct_close("node2", 0);
1542 assert!(!worker.is_done());
1543
1544 worker.record_direct_close("node3", 1);
1545 assert!(worker.is_done());
1546 assert_eq!(worker.retcodes().get("node3"), Some(&1));
1547 }
1548
1549 #[test]
1550 fn test_tree_worker_record_direct_timeout() {
1551 let nodes = make_nodeset("node[1-2]");
1552 let config = TreeWorkerConfig::default();
1553 let mut worker = TreeWorker::new(nodes, "sleep 100".to_string(), config);
1554
1555 worker.state = WorkerState::Running;
1556 worker.target_count = 2;
1557
1558 worker.record_direct_timeout("node1");
1559 assert!(worker.has_timeout());
1560 assert_eq!(worker.num_timeout(), 1);
1561 assert!(worker.timeout_nodes().contains("node1"));
1562 assert!(!worker.is_done());
1563
1564 worker.record_direct_close("node2", 0);
1565 assert!(worker.is_done());
1566 }
1567
1568 #[test]
1569 fn test_tree_worker_process_result_stdout() {
1570 let nodes = make_nodeset("node[1-2]");
1571 let config = TreeWorkerConfig::default();
1572 let mut worker = TreeWorker::new(nodes, "echo hi".to_string(), config);
1573
1574 worker.state = WorkerState::Running;
1575 worker.target_count = 2;
1576
1577 let result = PropagationResult::StdOut {
1578 node: "node1".to_string(),
1579 data: b"hello world".to_vec(),
1580 };
1581
1582 let done = worker.process_result("gw1", result).unwrap();
1583 assert!(!done);
1584 assert_eq!(worker.events().len(), 1);
1585
1586 match &worker.events()[0] {
1587 TreeWorkerEvent::StdOut { node, data, .. } => {
1588 assert_eq!(node, "node1");
1589 assert_eq!(data, b"hello world");
1590 }
1591 _ => panic!("expected StdOut event"),
1592 }
1593 }
1594
1595 #[test]
1596 fn test_tree_worker_process_result_retcode() {
1597 let nodes = make_nodeset("node[1-2]");
1598 let config = TreeWorkerConfig::default();
1599 let mut worker = TreeWorker::new(nodes, "echo hi".to_string(), config);
1600
1601 worker.state = WorkerState::Running;
1602 worker.target_count = 2;
1603
1604 let result1 = PropagationResult::Retcode {
1606 node: "node1".to_string(),
1607 rc: 0,
1608 };
1609 let done = worker.process_result("gw1", result1).unwrap();
1610 assert!(!done);
1611
1612 let result2 = PropagationResult::Retcode {
1614 node: "node2".to_string(),
1615 rc: 42,
1616 };
1617 let done = worker.process_result("gw1", result2).unwrap();
1618 assert!(done);
1619 assert_eq!(worker.state(), WorkerState::Done);
1620 assert_eq!(worker.retcodes().get("node2"), Some(&42));
1621 }
1622
1623 #[test]
1624 fn test_tree_worker_process_result_timeout() {
1625 let nodes = make_nodeset("node1");
1626 let config = TreeWorkerConfig::default();
1627 let mut worker = TreeWorker::new(nodes, "sleep 999".to_string(), config);
1628
1629 worker.state = WorkerState::Running;
1630 worker.target_count = 1;
1631
1632 let result = PropagationResult::Timeout {
1633 node: "node1".to_string(),
1634 };
1635 let done = worker.process_result("gw1", result).unwrap();
1636 assert!(done);
1637 assert!(worker.has_timeout());
1638 assert!(worker.timeout_nodes().contains("node1"));
1639 }
1640
1641 #[test]
1642 fn test_tree_worker_drain_events() {
1643 let nodes = make_nodeset("node1");
1644 let config = TreeWorkerConfig::default();
1645 let mut worker = TreeWorker::new(nodes, "echo hi".to_string(), config);
1646
1647 worker.state = WorkerState::Running;
1648 worker.target_count = 1;
1649
1650 let result = PropagationResult::StdOut {
1651 node: "node1".to_string(),
1652 data: b"data".to_vec(),
1653 };
1654 worker.process_result("gw1", result).unwrap();
1655
1656 assert_eq!(worker.events().len(), 1);
1657 let drained = worker.drain_events();
1658 assert_eq!(drained.len(), 1);
1659 assert!(worker.events().is_empty());
1660 }
1661
1662 #[test]
1663 fn test_tree_worker_unique_ids() {
1664 let nodes = make_nodeset("node1");
1665 let config1 = TreeWorkerConfig::default();
1666 let config2 = TreeWorkerConfig::default();
1667 let w1 = TreeWorker::new(nodes.clone(), "a".to_string(), config1);
1668 let w2 = TreeWorker::new(nodes, "b".to_string(), config2);
1669 assert_ne!(w1.worker_id(), w2.worker_id());
1670 }
1671
1672 #[test]
1673 fn test_tree_worker_active_gateways_empty() {
1674 let nodes = make_nodeset("node[1-3]");
1675 let config = TreeWorkerConfig::default();
1676 let worker = TreeWorker::new(nodes, "echo hi".to_string(), config);
1677
1678 assert_eq!(worker.active_gateway_count(), 0);
1679 assert!(worker.active_gateways().is_empty());
1680 }
1681
1682 #[test]
1683 fn test_tree_worker_routing_event() {
1684 let nodes = make_nodeset("node1");
1685 let config = TreeWorkerConfig::default();
1686 let mut worker = TreeWorker::new(nodes, "echo".to_string(), config);
1687
1688 worker.state = WorkerState::Running;
1689 worker.target_count = 1;
1690
1691 let result = PropagationResult::Routing {
1692 event: "reroute".to_string(),
1693 gateway: "gw1".to_string(),
1694 targets: "node1".to_string(),
1695 };
1696 worker.process_result("gw1", result).unwrap();
1697
1698 assert_eq!(worker.events().len(), 1);
1699 match &worker.events()[0] {
1700 TreeWorkerEvent::Routing {
1701 event,
1702 gateway,
1703 targets,
1704 } => {
1705 assert_eq!(event, "reroute");
1706 assert_eq!(gateway, "gw1");
1707 assert_eq!(targets, "node1");
1708 }
1709 _ => panic!("expected Routing event"),
1710 }
1711 }
1712
1713 use crate::communication::Message;
1721 use crate::topology::TopologyGraph;
1722
1723 fn build_gw2_topology() -> TopologyTree {
1726 let mut graph = TopologyGraph::new();
1727 graph
1728 .add_route(make_nodeset("admin"), make_nodeset("gw[1-2]"))
1729 .unwrap();
1730 graph
1731 .add_route(make_nodeset("gw[1-2]"), make_nodeset("node[1-2]"))
1732 .unwrap();
1733 graph.to_tree("admin").unwrap()
1734 }
1735
1736 fn ack_gateway(worker: &mut TreeWorker, gateway: &str) {
1739 worker
1740 .gateways
1741 .get_mut(gateway)
1742 .unwrap()
1743 .channel
1744 .recv(Message::ack(0))
1745 .unwrap();
1746 }
1747
1748 fn pickup_nodes(worker: &TreeWorker) -> Vec<String> {
1751 worker
1752 .events()
1753 .iter()
1754 .filter_map(|e| match e {
1755 TreeWorkerEvent::Pickup { node } => Some(node.clone()),
1756 _ => None,
1757 })
1758 .collect()
1759 }
1760
1761 fn tar_header_block(name: &str, mode: u32, size: usize, typeflag: u8) -> Vec<u8> {
1763 assert!(name.len() <= 100);
1764 let mut h = vec![0u8; TAR_BLOCK];
1765 h[..name.len()].copy_from_slice(name.as_bytes());
1766 let mode_s = format!("{:o}", mode);
1767 h[100..100 + mode_s.len()].copy_from_slice(mode_s.as_bytes());
1768 let size_s = format!("{:o}", size);
1769 h[124..124 + size_s.len()].copy_from_slice(size_s.as_bytes());
1770 h[156] = typeflag;
1771 h[257..262].copy_from_slice(b"ustar");
1772 for b in h.iter_mut().take(156).skip(148) {
1773 *b = b' ';
1774 }
1775 let sum: u64 = h.iter().map(|b| u64::from(*b)).sum();
1776 let chk = format!("{:o}\0 ", sum);
1777 h[148..148 + chk.len()].copy_from_slice(chk.as_bytes());
1778 h
1779 }
1780
1781 fn make_tar(dirs: &[(&str, u32)], files: &[(&str, u32, &[u8])]) -> Vec<u8> {
1783 let mut out = Vec::new();
1784 for (name, mode) in dirs {
1785 out.extend(tar_header_block(name, *mode, 0, b'5'));
1786 }
1787 for (name, mode, data) in files {
1788 out.extend(tar_header_block(name, *mode, data.len(), b'0'));
1789 out.extend_from_slice(data);
1790 let pad = (TAR_BLOCK - data.len() % TAR_BLOCK) % TAR_BLOCK;
1791 out.extend(std::iter::repeat(0u8).take(pad));
1792 }
1793 out.extend(std::iter::repeat(0u8).take(2 * TAR_BLOCK));
1795 out
1796 }
1797
1798 fn rcopy_config(dest: &std::path::Path) -> TreeWorkerConfig {
1800 TreeWorkerConfig {
1801 source: Some("/remote/src".to_string()),
1802 dest: Some(dest.to_string_lossy().into_owned()),
1803 reverse: true,
1804 ..TreeWorkerConfig::default()
1805 }
1806 }
1807
1808 #[test]
1811 fn test_pickup_buffered_until_check_ini_flushes_in_order() {
1812 let nodes = make_nodeset("node[1-2]");
1815 let mut worker = TreeWorker::new(nodes, "echo".to_string(), TreeWorkerConfig::default());
1816
1817 worker.emit_pickup("node1");
1818 worker.emit_pickup("node2");
1819 assert!(worker.events().is_empty());
1820 assert_eq!(worker.pending_pickup_count(), 2);
1821
1822 worker.check_ini();
1823 assert!(worker.is_initialized());
1824 assert_eq!(worker.pending_pickup_count(), 0);
1825 assert!(matches!(worker.events()[0], TreeWorkerEvent::Started));
1826 assert!(matches!(&worker.events()[1], TreeWorkerEvent::Pickup { node } if node == "node1"));
1827 assert!(matches!(&worker.events()[2], TreeWorkerEvent::Pickup { node } if node == "node2"));
1828
1829 worker.check_ini();
1831 assert_eq!(worker.events().len(), 3);
1832 }
1833
1834 #[test]
1835 fn test_ev_start_waits_for_direct_children() {
1836 let mut graph = TopologyGraph::new();
1840 graph
1841 .add_route(make_nodeset("admin"), make_nodeset("gw1"))
1842 .unwrap();
1843 graph
1844 .add_route(make_nodeset("gw1"), make_nodeset("node2"))
1845 .unwrap();
1846 let tree = graph.to_tree("admin").unwrap();
1847
1848 let mut worker = TreeWorker::new(
1850 make_nodeset("node[1-2]"),
1851 "echo".to_string(),
1852 TreeWorkerConfig::default(),
1853 );
1854 worker.set_topology("admin", &tree).unwrap();
1855 let hops = worker.start().unwrap();
1856 assert_eq!(hops.len(), 2);
1857 assert!(!worker.is_initialized());
1858
1859 worker.emit_pickup("node1");
1861 assert!(worker.events().is_empty());
1862
1863 worker.notify_child_start();
1864 assert!(worker.is_initialized());
1865 assert!(matches!(worker.events()[0], TreeWorkerEvent::Started));
1866 assert!(matches!(&worker.events()[1], TreeWorkerEvent::Pickup { node } if node == "node1"));
1867 }
1868
1869 #[test]
1870 fn test_pickup_not_fired_while_ctl_queued_before_setup() {
1871 let tree = build_gw2_topology();
1875 let mut worker = TreeWorker::new(
1876 make_nodeset("node[1-2]"),
1877 "echo Lorem Ipsum".to_string(),
1878 TreeWorkerConfig::default(),
1879 );
1880 worker.set_topology("admin", &tree).unwrap();
1881 let hops = worker.start().unwrap();
1882 assert_eq!(hops.len(), 1);
1883 let gw = hops[0].0.to_string();
1884
1885 worker.start_gateway(&gw, b"topo").unwrap();
1886 worker.sync_gateway_pickups(&gw);
1887 assert!(
1888 pickup_nodes(&worker).is_empty(),
1889 "queued-but-unsent CTL must not fire ev_pickup"
1890 );
1891
1892 ack_gateway(&mut worker, &gw);
1894 worker.sync_gateway_pickups(&gw);
1895 let mut pickups = pickup_nodes(&worker);
1896 pickups.sort();
1897 assert_eq!(pickups, vec!["node1".to_string(), "node2".to_string()]);
1898
1899 worker.sync_gateway_pickups(&gw);
1901 assert_eq!(pickup_nodes(&worker).len(), 2);
1902 }
1903
1904 #[test]
1905 fn test_tree_run_abort_on_start_no_pickups() {
1906 let nodes = make_nodeset("node1");
1909 let mut worker = TreeWorker::new(nodes, "echo".to_string(), TreeWorkerConfig::default());
1910
1911 worker.emit_pickup("node1"); worker.abort(); worker.check_ini();
1914
1915 assert!(worker.is_aborted());
1916 assert_eq!(worker.state(), WorkerState::Aborted);
1917 assert_eq!(
1918 worker
1919 .events()
1920 .iter()
1921 .filter(|e| matches!(e, TreeWorkerEvent::Started))
1922 .count(),
1923 1,
1924 "ev_start fires even when the worker is aborted"
1925 );
1926 assert!(pickup_nodes(&worker).is_empty());
1927 }
1928
1929 #[test]
1930 fn test_tree_run_abort_on_start_multi_no_pickups() {
1931 let tree = build_gw2_topology();
1935 let mut worker = TreeWorker::new(
1936 make_nodeset("node[1-2]"),
1937 "echo Lorem Ipsum".to_string(),
1938 TreeWorkerConfig::default(),
1939 );
1940 worker.set_topology("admin", &tree).unwrap();
1941 worker.start().unwrap();
1942 worker.start_gateway("gw1", b"topo").unwrap();
1943 worker.abort();
1945
1946 assert!(pickup_nodes(&worker).is_empty());
1947 let protocol_closes = worker
1948 .events()
1949 .iter()
1950 .filter(|e| matches!(e, TreeWorkerEvent::Close { rc: 76, .. }))
1951 .count();
1952 assert_eq!(protocol_closes, 2, "gateway targets must get EX_PROTOCOL");
1953 }
1954
1955 #[test]
1956 fn test_no_pickups_after_abort() {
1957 let nodes = make_nodeset("node[1-3]");
1960 let mut worker = TreeWorker::new(nodes, "echo".to_string(), TreeWorkerConfig::default());
1961
1962 worker.emit_pickup("node1");
1963 worker.check_ini();
1964 assert_eq!(pickup_nodes(&worker), vec!["node1".to_string()]);
1965
1966 worker.emit_pickup("node2");
1968 assert_eq!(
1969 pickup_nodes(&worker),
1970 vec!["node1".to_string(), "node2".to_string()]
1971 );
1972
1973 worker.abort();
1975 worker.emit_pickup("node3");
1976 assert_eq!(
1977 pickup_nodes(&worker),
1978 vec!["node1".to_string(), "node2".to_string()]
1979 );
1980 }
1981
1982 #[test]
1983 fn test_relaunch_emits_reroute_and_redispatches() {
1984 let tree = build_gw2_topology();
1987 let mut worker = TreeWorker::new(
1988 make_nodeset("node[1-2]"),
1989 "echo".to_string(),
1990 TreeWorkerConfig::default(),
1991 );
1992 worker.set_topology("admin", &tree).unwrap();
1993 worker.start().unwrap();
1994 worker.start_gateway("gw1", b"topo").unwrap();
1995 assert_eq!(worker.target_count, 2);
1996
1997 worker.mark_unreachable("gw1");
1998 let hops = worker.relaunch("gw1").unwrap();
1999
2000 assert_eq!(hops.len(), 1);
2001 assert_eq!(hops[0].0.to_string(), "gw2");
2002 assert_eq!(hops[0].1.to_string(), "node[1-2]");
2003 assert_eq!(worker.target_count, 2, "target count preserved");
2004 assert!(!worker.gateways.contains_key("gw1"));
2005 assert!(worker.gateways.contains_key("gw2"));
2006
2007 let routing = worker
2008 .events()
2009 .iter()
2010 .find_map(|e| match e {
2011 TreeWorkerEvent::Routing {
2012 event,
2013 gateway,
2014 targets,
2015 } => Some((event, gateway, targets)),
2016 _ => None,
2017 })
2018 .expect("missing reroute event");
2019 assert_eq!(routing.0, "reroute");
2020 assert_eq!(routing.1, "gw1");
2021 assert_eq!(routing.2, "node1,node2");
2022 }
2023
2024 #[test]
2025 fn test_tree_run_no_double_pickup_under_reroute() {
2026 let tree = build_gw2_topology();
2029 let mut worker = TreeWorker::new(
2030 make_nodeset("node[1-2]"),
2031 "echo Lorem Ipsum".to_string(),
2032 TreeWorkerConfig::default(),
2033 );
2034 worker.set_topology("admin", &tree).unwrap();
2035 worker.start().unwrap();
2036 worker.start_gateway("gw1", b"topo").unwrap();
2037
2038 worker.mark_unreachable("gw1");
2041 let hops = worker.relaunch("gw1").unwrap();
2042 let survivor = hops[0].0.to_string();
2043 assert_eq!(survivor, "gw2");
2044
2045 worker.start_gateway(&survivor, b"topo").unwrap();
2046 worker.sync_gateway_pickups(&survivor);
2047 assert!(pickup_nodes(&worker).is_empty());
2048
2049 ack_gateway(&mut worker, &survivor);
2050 worker.sync_gateway_pickups(&survivor);
2051
2052 let mut pickups = pickup_nodes(&worker);
2054 pickups.sort();
2055 assert_eq!(pickups, vec!["node1".to_string(), "node2".to_string()]);
2056 }
2057
2058 #[test]
2059 fn test_tree_run_pickup_after_reroute() {
2060 let tree = build_gw2_topology();
2063 let mut worker = TreeWorker::new(
2064 make_nodeset("node[1-2]"),
2065 "echo Lorem Ipsum".to_string(),
2066 TreeWorkerConfig::default(),
2067 );
2068 worker.set_topology("admin", &tree).unwrap();
2069 worker.start().unwrap();
2070 worker.start_gateway("gw1", b"topo").unwrap();
2071
2072 worker.mark_unreachable("gw1");
2073 worker.relaunch("gw1").unwrap();
2074 worker.start_gateway("gw2", b"topo").unwrap();
2075 ack_gateway(&mut worker, "gw2");
2076 worker.sync_gateway_pickups("gw2");
2077
2078 let events = worker.events();
2079 let routing_idx = events
2080 .iter()
2081 .position(|e| matches!(e, TreeWorkerEvent::Routing { event, .. } if event == "reroute"))
2082 .expect("missing routing event");
2083 let started_idx = events
2084 .iter()
2085 .position(|e| matches!(e, TreeWorkerEvent::Started))
2086 .expect("missing started event");
2087 assert!(routing_idx > started_idx, "ev_start must come first");
2088
2089 for target in ["node1", "node2"] {
2090 let pickup_idx = events
2091 .iter()
2092 .position(|e| matches!(e, TreeWorkerEvent::Pickup { node } if node == target))
2093 .unwrap_or_else(|| panic!("missing ev_pickup for rerouted target {}", target));
2094 assert!(
2095 pickup_idx > routing_idx,
2096 "ev_pickup for rerouted target {} fired before _ev_routing",
2097 target
2098 );
2099 }
2100 }
2101
2102 #[test]
2103 fn test_relaunch_onto_existing_gateway_fires_pickup_on_send() {
2104 let tree = build_gw2_topology();
2108 let mut worker = TreeWorker::new(
2109 make_nodeset("node[1-2]"),
2110 "echo".to_string(),
2111 TreeWorkerConfig::default(),
2112 );
2113 worker.set_topology("admin", &tree).unwrap();
2114 worker.start().unwrap();
2115 worker.start_gateway("gw1", b"topo").unwrap();
2116
2117 let mut channel = PropagationChannel::new("gw2");
2119 channel.start(b"topo");
2120 channel.recv(Message::ack(0)).unwrap();
2121 assert!(channel.channel.setup);
2122 worker.gateways.insert(
2123 "gw2".to_string(),
2124 GatewayState {
2125 channel,
2126 active_targets: HashSet::new(),
2127 started: true,
2128 ctl_unsent_targets: Vec::new(),
2129 },
2130 );
2131
2132 worker.mark_unreachable("gw1");
2133 worker.relaunch("gw1").unwrap();
2134
2135 let mut pickups = pickup_nodes(&worker);
2138 pickups.sort();
2139 assert_eq!(pickups, vec!["node1".to_string(), "node2".to_string()]);
2140 assert_eq!(worker.gateways["gw2"].active_targets.len(), 2);
2141 }
2142
2143 #[test]
2146 fn test_tree_copy_forces_separate_stderr() {
2147 let mut cfg = TreeWorkerConfig::default();
2150 cfg.source = Some("/tmp/src".to_string());
2151 cfg.dest = Some("/tmp/dst".to_string());
2152 let worker = TreeWorker::new_copy(make_nodeset("node1"), cfg).unwrap();
2153 assert!(worker.stderr(), "forward copy must separate stderr");
2154
2155 let mut cfg = TreeWorkerConfig::default();
2156 cfg.source = Some("/tmp/src".to_string());
2157 cfg.dest = Some("/tmp/dst".to_string());
2158 cfg.reverse = true;
2159 let worker = TreeWorker::new_copy(make_nodeset("node1"), cfg).unwrap();
2160 assert!(worker.stderr(), "rcopy must separate stderr");
2161
2162 let worker = TreeWorker::new(
2164 make_nodeset("node1"),
2165 "echo".to_string(),
2166 TreeWorkerConfig::default(),
2167 );
2168 assert!(!worker.stderr());
2169 }
2170
2171 #[test]
2172 fn test_tree_worker_missing_arguments() {
2173 let result = TreeWorker::new_copy(make_nodeset("node1"), TreeWorkerConfig::default());
2176 assert!(result.is_err());
2177 let err = result.err().expect("expected constructor error");
2178 assert!(err.to_string().contains("missing command or source"));
2179 }
2180
2181 #[test]
2182 fn test_untar_command_quoting() {
2183 assert_eq!(untar_command("/tmp/dest"), "tar -xf - -C '/tmp/dest'");
2184 assert_eq!(untar_command("/tmp/a'b"), "tar -xf - -C '/tmp/a'\"'\"'b'");
2186 }
2187
2188 #[test]
2189 fn test_tar_command_quoting() {
2190 assert_eq!(
2191 tar_command("/tmp/src", "base"),
2192 "tar -cf - -C '/tmp/src' \
2193--transform \"s,^\\([^/]*\\)[/]*,\\1.$(hostname -s)/,\" \
2194'base' | base64 -w 65536"
2195 );
2196 assert!(tar_command("/tmp/a'b", "c'd").contains("'\"'\"'"));
2197 }
2198
2199 #[test]
2200 fn test_remote_shell_command_modes() {
2201 let worker = TreeWorker::new(
2203 make_nodeset("node1"),
2204 "uname -a".to_string(),
2205 TreeWorkerConfig::default(),
2206 );
2207 assert_eq!(worker.remote_shell_command().as_deref(), Some("uname -a"));
2208
2209 let mut cfg = TreeWorkerConfig::default();
2211 cfg.source = Some("/tmp/src".to_string());
2212 cfg.dest = Some("/tmp/dst".to_string());
2213 let worker = TreeWorker::new_copy(make_nodeset("node1"), cfg).unwrap();
2214 assert_eq!(
2215 worker.remote_shell_command().as_deref(),
2216 Some("tar -xf - -C '/tmp/dst'")
2217 );
2218
2219 let mut cfg = TreeWorkerConfig::default();
2221 cfg.source = Some("/data/dir".to_string());
2222 cfg.dest = Some("/tmp/dst".to_string());
2223 cfg.reverse = true;
2224 let worker = TreeWorker::new_copy(make_nodeset("node1"), cfg).unwrap();
2225 assert_eq!(
2226 worker.remote_shell_command().as_deref(),
2227 Some(tar_command("/data", "dir").as_str())
2228 );
2229 }
2230
2231 #[test]
2234 fn test_rcopy_msgline_accumulates_base64() {
2235 let destdir = tempfile::tempdir().unwrap();
2239 let mut worker =
2240 TreeWorker::new_copy(make_nodeset("node1"), rcopy_config(destdir.path())).unwrap();
2241
2242 worker.on_remote_node_msgline("node1", b"TWF", SNAME_STDOUT, "gw1");
2243 assert!(worker
2244 .rcopy_states
2245 .get("node1")
2246 .unwrap()
2247 .tar_bytes
2248 .is_empty());
2249 worker.on_remote_node_msgline("node1", b"u", SNAME_STDOUT, "gw1");
2250 assert_eq!(worker.rcopy_states.get("node1").unwrap().tar_bytes, b"Man");
2251
2252 worker.on_remote_node_msgline("node1", b"oops", SNAME_STDERR, "gw1");
2254 assert!(worker
2255 .events()
2256 .iter()
2257 .any(|e| matches!(e, TreeWorkerEvent::StdErr { node, data, .. }
2258 if node == "node1" && data == b"oops")));
2259 assert!(!worker
2261 .events()
2262 .iter()
2263 .any(|e| matches!(e, TreeWorkerEvent::StdOut { .. })));
2264 }
2265
2266 #[test]
2267 fn test_msgline_passthrough_for_command_worker() {
2268 let mut worker = TreeWorker::new(
2269 make_nodeset("node1"),
2270 "echo".to_string(),
2271 TreeWorkerConfig::default(),
2272 );
2273 worker.on_remote_node_msgline("node1", b"out", SNAME_STDOUT, "gw1");
2274 worker.on_remote_node_msgline("node1", b"err", SNAME_STDERR, "gw1");
2275 assert!(
2276 matches!(&worker.events()[0], TreeWorkerEvent::StdOut { data, .. } if data == b"out")
2277 );
2278 assert!(
2279 matches!(&worker.events()[1], TreeWorkerEvent::StdErr { data, .. } if data == b"err")
2280 );
2281 }
2282
2283 #[test]
2284 fn test_rcopy_finalize_extracts_and_preserves_mode() {
2285 use std::os::unix::fs::PermissionsExt;
2289
2290 let destdir = tempfile::tempdir().unwrap();
2291 let mut worker =
2292 TreeWorker::new_copy(make_nodeset("node1"), rcopy_config(destdir.path())).unwrap();
2293
2294 let tar = make_tar(&[], &[("file.txt", 0o664, b"mode-check")]);
2295 let b64 = crate::communication::base64_encode(&tar);
2296 for chunk in b64.as_bytes().chunks(7) {
2298 worker.on_remote_node_msgline("node1", chunk, SNAME_STDOUT, "gw1");
2299 }
2300
2301 let written = worker.finalize_rcopy_node("node1", "gw1").unwrap();
2302 assert_eq!(written, b"mode-check".len());
2303
2304 let extracted = destdir.path().join("file.txt");
2305 assert_eq!(std::fs::read(&extracted).unwrap(), b"mode-check");
2306 assert_eq!(
2307 std::fs::metadata(&extracted).unwrap().permissions().mode() & 0o777,
2308 0o664
2309 );
2310 assert!(worker.events().is_empty());
2311 }
2312
2313 #[test]
2314 fn test_rcopy_finalize_corrupt_tar_reported_as_stderr() {
2315 let destdir = tempfile::tempdir().unwrap();
2319 let mut worker =
2320 TreeWorker::new_copy(make_nodeset("node1"), rcopy_config(destdir.path())).unwrap();
2321
2322 let mut tar = make_tar(&[], &[("file.txt", 0o644, b"data")]);
2323 tar[10] ^= 0xFF; let b64 = crate::communication::base64_encode(&tar);
2325 worker.on_remote_node_msgline("node1", b64.as_bytes(), SNAME_STDOUT, "gw1");
2326
2327 worker.finalize_rcopy_node("node1", "gw1").unwrap();
2328
2329 assert_eq!(worker.events().len(), 1);
2330 match &worker.events()[0] {
2331 TreeWorkerEvent::StdErr {
2332 node,
2333 gateway,
2334 data,
2335 } => {
2336 assert_eq!(node, "node1");
2337 assert_eq!(gateway, "gw1");
2338 assert!(!data.is_empty(), "error reported as bytes");
2339 assert!(String::from_utf8_lossy(data).contains("checksum"));
2340 }
2341 e => panic!("expected StdErr event, got {:?}", e),
2342 }
2343 assert!(std::fs::read_dir(destdir.path()).unwrap().next().is_none());
2345 }
2346
2347 #[test]
2348 fn test_rcopy_finalize_conflict_reported_as_stderr() {
2349 let destdir = tempfile::tempdir().unwrap();
2353 std::fs::write(destdir.path().join("conf"), b"conflict").unwrap();
2354
2355 let mut worker =
2356 TreeWorker::new_copy(make_nodeset("node1"), rcopy_config(destdir.path())).unwrap();
2357
2358 let tar = make_tar(&[("conf", 0o755)], &[("conf/file.txt", 0o644, b"x")]);
2359 let b64 = crate::communication::base64_encode(&tar);
2360 worker.on_remote_node_msgline("node1", b64.as_bytes(), SNAME_STDOUT, "gw1");
2361
2362 worker.finalize_rcopy_node("node1", "gw1").unwrap();
2363 assert_eq!(worker.events().len(), 1);
2364 assert!(
2365 matches!(&worker.events()[0], TreeWorkerEvent::StdErr { node, data, .. }
2366 if node == "node1" && !data.is_empty())
2367 );
2368 }
2369
2370 #[test]
2371 fn test_rcopy_finalize_raw_tail_ignored_like_upstream() {
2372 let destdir = tempfile::tempdir().unwrap();
2377 let mut worker =
2378 TreeWorker::new_copy(make_nodeset("node1"), rcopy_config(destdir.path())).unwrap();
2379
2380 let tar = make_tar(&[], &[("file.txt", 0o644, b"ok")]);
2381 let mut b64 = crate::communication::base64_encode(&tar);
2382 b64.push('A'); worker.on_remote_node_msgline("node1", b64.as_bytes(), SNAME_STDOUT, "gw1");
2384
2385 let written = worker.finalize_rcopy_node("node1", "gw1").unwrap();
2386 assert_eq!(written, 2);
2387 assert_eq!(
2388 std::fs::read(destdir.path().join("file.txt")).unwrap(),
2389 b"ok"
2390 );
2391 assert!(worker.events().is_empty());
2392 }
2393
2394 #[test]
2395 fn test_rcopy_finalize_without_data_is_noop() {
2396 let destdir = tempfile::tempdir().unwrap();
2398 let mut worker =
2399 TreeWorker::new_copy(make_nodeset("node1"), rcopy_config(destdir.path())).unwrap();
2400 assert_eq!(worker.finalize_rcopy_node("node1", "gw1").unwrap(), 0);
2401 assert!(worker.events().is_empty());
2402
2403 let mut worker = TreeWorker::new(
2405 make_nodeset("node1"),
2406 "echo".to_string(),
2407 TreeWorkerConfig::default(),
2408 );
2409 assert_eq!(worker.finalize_rcopy_node("node1", "gw1").unwrap(), 0);
2410 }
2411
2412 #[test]
2415 fn test_report_gateway_error_before_channel_setup() {
2416 let mut worker = TreeWorker::new(
2420 make_nodeset("node1"),
2421 "echo".to_string(),
2422 TreeWorkerConfig::default(),
2423 );
2424 worker.report_gateway_error("gw1", "invalid configuration data");
2425
2426 assert_eq!(worker.events().len(), 1);
2427 match &worker.events()[0] {
2428 TreeWorkerEvent::StdErr {
2429 node,
2430 gateway,
2431 data,
2432 } => {
2433 assert_eq!(node, "gw1");
2434 assert_eq!(gateway, "gw1");
2435 assert_eq!(data, b"invalid configuration data");
2436 }
2437 e => panic!("expected StdErr event, got {:?}", e),
2438 }
2439 }
2440
2441 #[test]
2442 fn test_report_gateway_stderr_splits_lines_and_keeps_empty() {
2443 let mut worker = TreeWorker::new(
2446 make_nodeset("node[1-2]"),
2447 "echo".to_string(),
2448 TreeWorkerConfig::default(),
2449 );
2450 let nodes = make_nodeset("node[1-2]");
2451 worker.report_gateway_stderr("gw1", &nodes, b"line1\nline2");
2452
2453 let got: Vec<(String, Vec<u8>)> = worker
2454 .events()
2455 .iter()
2456 .filter_map(|e| match e {
2457 TreeWorkerEvent::StdErr { node, data, .. } => Some((node.clone(), data.clone())),
2458 _ => None,
2459 })
2460 .collect();
2461 assert_eq!(
2462 got,
2463 vec![
2464 ("node1".to_string(), b"line1".to_vec()),
2465 ("node2".to_string(), b"line1".to_vec()),
2466 ("node1".to_string(), b"line2".to_vec()),
2467 ("node2".to_string(), b"line2".to_vec()),
2468 ]
2469 );
2470
2471 worker.drain_events();
2472 worker.report_gateway_stderr("gw1", &make_nodeset("node1"), b"");
2473 assert_eq!(worker.events().len(), 1);
2474 assert!(
2475 matches!(&worker.events()[0], TreeWorkerEvent::StdErr { data, .. } if data.is_empty())
2476 );
2477 }
2478
2479 #[test]
2480 fn test_process_channel_error_surfaces_message_not_crash() {
2481 let mut worker = TreeWorker::new(
2485 make_nodeset("node1"),
2486 "echo".to_string(),
2487 TreeWorkerConfig::default(),
2488 );
2489
2490 worker.process_channel_error("gw1", &MessageProcessingError::UnknownType("ABC".into()));
2491 worker.process_channel_error("gw1", &MessageProcessingError::NoType);
2492
2493 assert_eq!(
2494 worker.state(),
2495 WorkerState::Pending,
2496 "worker must not crash"
2497 );
2498 assert_eq!(worker.events().len(), 2);
2499 assert!(
2500 matches!(&worker.events()[0], TreeWorkerEvent::StdErr { node, data, .. }
2501 if node == "gw1" && data == b"Unknown message type ABC")
2502 );
2503 assert!(
2504 matches!(&worker.events()[1], TreeWorkerEvent::StdErr { node, data, .. }
2505 if node == "gw1" && data == b"Unknown message with no type")
2506 );
2507 }
2508}