Skip to main content

consortium/worker/
tree.rs

1//! TreeWorker implementation for tree-based command propagation.
2//!
3//! Provides TreeWorker, which distributes command execution across a cluster
4//! topology using gateway nodes and propagation channels. Mirrors
5//! ClusterShell's TreeWorker class.
6//!
7//! The TreeWorker uses:
8//! - [`PropagationTreeRouter`] to determine next-hop gateways
9//! - [`PropagationChannel`] to communicate with gateway processes
10//! - Direct `SshWorker`/`ExecWorker` for leaf nodes reachable without gateways
11
12use 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
21/// Build a NodeSet from a collection of node name strings.
22fn nodeset_from_strings(names: &[String]) -> String {
23    names.join(",")
24}
25
26/// `SNAME_STDERR` equivalent: stream name attributed to stderr data.
27pub const SNAME_STDERR: &str = "stderr";
28/// `SNAME_STDOUT` equivalent: stream name attributed to stdout data.
29pub const SNAME_STDOUT: &str = "stdout";
30
31/// Remote untar command format for forward tree copy (UNTAR_CMD_FMT).
32///
33/// Single quotes are essential (mirrors ClusterShell); use
34/// [`shell_escape_single_quoted`] on the interpolated path.
35pub const UNTAR_CMD_FMT: &str = "tar -xf - -C '%s'";
36
37/// Remote tar|base64 command format for tree rcopy (TAR_CMD_FMT).
38///
39/// Args are (srcdir, srcbase). The remote side archives from `srcdir`,
40/// renames the top-level entry to `<name>.<short-hostname>/` and emits
41/// base64 with 64 KiB lines on stdout.
42pub const TAR_CMD_FMT: &str = "tar -cf - -C '%s' \
43--transform \"s,^\\([^/]*\\)[/]*,\\1.$(hostname -s)/,\" \
44'%s' | base64 -w 65536";
45
46/// Escape a string for inclusion between single quotes in a shell command.
47///
48/// Mirrors the `'`-within-`''` escaping used by ClusterShell's
49/// `_copy_remote`: each `'` becomes `'"'"'`.
50pub fn shell_escape_single_quoted(value: &str) -> String {
51    value.replace('\'', "'\"'\"'")
52}
53
54/// Build the remote untar command used for forward tree copy.
55pub fn untar_command(dest: &str) -> String {
56    UNTAR_CMD_FMT.replacen("%s", &shell_escape_single_quoted(dest), 1)
57}
58
59/// Build the remote tar|base64 command used for tree rcopy.
60pub 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
66/// POSIX-style dirname (upstream Python os.path.dirname semantics for
67/// the inputs ClusterShell produces).
68fn posix_dirname(path: &str) -> &str {
69    let trimmed = path.trim_end_matches('/');
70    if trimmed.is_empty() {
71        // path was only slashes: os.path.dirname('/') == '/'
72        return if path.starts_with('/') { "/" } else { "" };
73    }
74    match trimmed.rfind('/') {
75        Some(0) => "/",
76        Some(pos) => &trimmed[..pos],
77        None => "",
78    }
79}
80
81/// POSIX-style basename of a normalized path (os.path.basename).
82fn 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
90/// Minimal os.path.normpath for absolute/relative POSIX paths: collapses
91/// duplicate slashes, resolves `.` and `..` components, strips any
92/// trailing slash. Does not resolve symlinks (like os.path.normpath).
93fn 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/// Configuration for tree-based execution.
119#[derive(Debug, Clone)]
120pub struct TreeWorkerConfig {
121    /// Maximum concurrent operations per gateway.
122    pub fanout: usize,
123    /// Command timeout per node.
124    pub timeout: Option<Duration>,
125    /// Whether to capture stderr.
126    pub stderr: bool,
127    /// Whether targets are remote (SSH) or local.
128    pub remote: bool,
129    /// Whether this is a copy operation.
130    pub source: Option<String>,
131    /// Destination for copy operations.
132    pub dest: Option<String>,
133    /// Whether this is a reverse copy (remote -> local).
134    pub reverse: bool,
135    /// Whether to preserve file attributes in copy.
136    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/// Tracks the state of a gateway connection.
155#[derive(Debug)]
156struct GatewayState {
157    /// The propagation channel for this gateway.
158    channel: PropagationChannel,
159    /// Active target nodes being processed through this gateway.
160    active_targets: HashSet<String>,
161    /// Whether the gateway channel has been started.
162    started: bool,
163    /// Targets whose shell control message has been handed to the
164    /// channel but not yet confirmed sent on the wire.
165    ///
166    /// `ev_pickup` for a node fires only once its control message has
167    /// actually left the local process — i.e. when it can no longer be
168    /// rerouted (upstream ClusterShell #594): a control message queued
169    /// behind an un-ACKed configuration message must not trigger it.
170    ctl_unsent_targets: Vec<String>,
171}
172
173/// Result from a remote node received through the tree.
174#[derive(Debug, Clone)]
175pub enum TreeWorkerEvent {
176    /// The meta worker has started (`ev_start` equivalent). Always
177    /// emitted before any [`TreeWorkerEvent::Pickup`] event.
178    Started,
179    /// A node's command can no longer be rerouted (`ev_pickup`
180    /// equivalent). Fires at most once per node, only after the control
181    /// message carrying its command has actually been sent (upstream
182    /// ClusterShell #594).
183    Pickup {
184        /// Node that was picked up.
185        node: String,
186    },
187    /// Node produced stdout output.
188    StdOut {
189        node: String,
190        gateway: String,
191        data: Vec<u8>,
192    },
193    /// Node produced stderr output.
194    StdErr {
195        node: String,
196        gateway: String,
197        data: Vec<u8>,
198    },
199    /// Node completed with a return code.
200    Close {
201        node: String,
202        gateway: String,
203        rc: i32,
204    },
205    /// Node timed out.
206    Timeout { node: String, gateway: String },
207    /// Routing event (reroute, unreachable, etc.)
208    Routing {
209        event: String,
210        gateway: String,
211        targets: String,
212    },
213}
214
215/// Per-node rcopy reassembly state (upstream `TreeWorker._rcopy_bufs` /
216/// `TreeWorker._rcopy_tars`).
217#[derive(Debug, Default)]
218struct RcopyState {
219    /// Leftover base64 characters not yet decodable (fewer than 4).
220    encoded_tail: Vec<u8>,
221    /// Decoded tar bytes accumulated so far.
222    tar_bytes: Vec<u8>,
223}
224
225/// Worker that distributes command execution across a cluster topology.
226///
227/// TreeWorker uses [`PropagationTreeRouter`] to determine routing through
228/// gateway nodes, and [`PropagationChannel`] to communicate with gateways.
229/// Nodes directly reachable (same hop as root) are executed via direct
230/// workers (SSH or local).
231///
232/// Mirrors ClusterShell's `TreeWorker` class.
233pub struct TreeWorker {
234    /// Target nodes for this worker.
235    nodes: NodeSet,
236    /// Command to execute (mutually exclusive with source).
237    command: Option<String>,
238    /// Worker configuration.
239    config: TreeWorkerConfig,
240    /// Current worker state.
241    state: WorkerState,
242    /// Router for determining next-hop gateways.
243    router: Option<PropagationTreeRouter>,
244    /// Gateway states indexed by gateway name.
245    gateways: HashMap<String, GatewayState>,
246    /// Return codes collected per node.
247    retcodes: HashMap<String, i32>,
248    /// Events collected from remote nodes.
249    events: Vec<TreeWorkerEvent>,
250    /// Count of closed/completed targets.
251    close_count: usize,
252    /// Total target count (for completion tracking).
253    target_count: usize,
254    /// Whether any node has timed out.
255    has_timeout: bool,
256    /// Unique worker ID for channel communication.
257    worker_id: u64,
258    /// Nodes that timed out.
259    timeout_nodes: HashSet<String>,
260    /// `ev_start` has fired (upstream `TreeWorker._initialized`).
261    initialized: bool,
262    /// Worker has been aborted (upstream `TreeWorker._aborted`); gates
263    /// all future pickup emission (#594).
264    aborted: bool,
265    /// Pickup events buffered before `ev_start` fires, flushed by
266    /// [`TreeWorker::check_ini`] (upstream `TreeWorker._pending_pickups`).
267    pending_pickups: Vec<String>,
268    /// Number of direct child workers that have started (upstream
269    /// `TreeWorker._start_count`).
270    start_count: usize,
271    /// Number of direct child workers expected (upstream
272    /// `TreeWorker._child_count`): one per directly reachable target
273    /// group. Gateway-mediated targets do not count.
274    child_count: usize,
275    /// Per-node rcopy reassembly state (reverse copy only).
276    rcopy_states: HashMap<String, RcopyState>,
277}
278
279impl TreeWorker {
280    /// Create a new TreeWorker for command execution.
281    ///
282    /// # Arguments
283    /// * `nodes` - Target nodes to execute on.
284    /// * `command` - Command to execute on each node.
285    /// * `config` - Tree worker configuration.
286    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    /// Create a new TreeWorker for a copy or reverse-copy (rcopy)
292    /// operation.
293    ///
294    /// `config.source` must be set (mirrors the upstream `ValueError`
295    /// raised when neither command nor source is provided).
296    ///
297    /// # Errors
298    /// Returns `WorkerError::General` if `config.source` is `None`.
299    pub fn new_copy(nodes: NodeSet, config: TreeWorkerConfig) -> Result<Self, WorkerError> {
300        Self::with_command(nodes, None, config)
301    }
302
303    /// Shared constructor (upstream `TreeWorker.__init__`).
304    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        // copy/rcopy: keep internal tar/scp errors on a separate stderr
318        // stream instead of merging them into stdout (which also carries
319        // rcopy data) — upstream ClusterShell #622 (commit 5ed4c34).
320        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    /// Set the topology router for this worker.
348    ///
349    /// Must be called before `start()`. The router determines how commands
350    /// are distributed across gateway nodes.
351    pub fn set_router(&mut self, router: PropagationTreeRouter) {
352        self.router = Some(router);
353    }
354
355    /// Create a router from a topology tree.
356    ///
357    /// # Arguments
358    /// * `root` - The root node name (typically the local hostname).
359    /// * `topology` - The cluster topology tree.
360    ///
361    /// # Errors
362    /// Returns `WorkerError` if the root node is not found in the topology.
363    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    /// Get the target nodes.
371    pub fn nodes(&self) -> &NodeSet {
372        &self.nodes
373    }
374
375    /// Get the command being executed.
376    pub fn command(&self) -> Option<&str> {
377        self.command.as_deref()
378    }
379
380    /// Get the current state.
381    pub fn state(&self) -> WorkerState {
382        self.state
383    }
384
385    /// Get collected return codes.
386    pub fn retcodes(&self) -> &HashMap<String, i32> {
387        &self.retcodes
388    }
389
390    /// Get collected events from remote nodes.
391    pub fn events(&self) -> &[TreeWorkerEvent] {
392        &self.events
393    }
394
395    /// Drain collected events.
396    pub fn drain_events(&mut self) -> Vec<TreeWorkerEvent> {
397        std::mem::take(&mut self.events)
398    }
399
400    /// Get the number of timed out nodes.
401    pub fn num_timeout(&self) -> usize {
402        self.timeout_nodes.len()
403    }
404
405    /// Get timed out nodes.
406    pub fn timeout_nodes(&self) -> &HashSet<String> {
407        &self.timeout_nodes
408    }
409
410    /// Whether any node has timed out.
411    pub fn has_timeout(&self) -> bool {
412        self.has_timeout
413    }
414
415    /// Check if the worker is done (all targets completed or timed out).
416    pub fn is_done(&self) -> bool {
417        self.state == WorkerState::Done || self.state == WorkerState::Aborted
418    }
419
420    /// Distribute target nodes across next-hop gateways.
421    ///
422    /// Returns a list of (gateway, targets) pairs. When gateway == targets,
423    /// the nodes are directly reachable (no gateway needed).
424    fn distribute(&self) -> Result<Vec<(NodeSet, NodeSet)>, WorkerError> {
425        self.distribute_nodes(&self.nodes)
426    }
427
428    /// Distribute an arbitrary nodeset across next-hop gateways
429    /// (upstream `TreeWorker._distribute`).
430    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    /// The command to run on remote targets through a gateway, if any.
455    ///
456    /// Mirrors upstream `_execute_remote` (command mode) and
457    /// `_copy_remote` (copy/rcopy mode): copy operations send a tar
458    /// pipeline command to the targets instead of a user command.
459    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            // rcopy: archive remotely, transfer base64 tar on stdout
466            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            // forward copy: untar from stdin at the destination
472            let dest = self.config.dest.as_deref().unwrap_or("");
473            Some(untar_command(dest))
474        }
475    }
476
477    /// Start the tree worker — distribute work across gateways.
478    ///
479    /// This sets up propagation channels for gateway nodes and prepares
480    /// direct execution for leaf nodes.
481    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            // Check if gateway == targets (direct execution, no gateway needed)
500            if gw_str == targets_str {
501                // Direct targets — caller should spawn SSH/local workers for
502                // these; each spawned child worker counts towards _child_count
503                // (upstream TreeWorker._launch).
504                self.child_count += 1;
505                continue;
506            }
507
508            // Gateway-mediated execution: set up propagation channel
509            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        // With no direct children this fires ev_start immediately, like
520        // upstream's `_start()`: `_launch()` then `_check_ini()`.
521        self.check_ini();
522
523        Ok(next_hops)
524    }
525
526    /// Start a gateway's propagation channel with topology data.
527    ///
528    /// # Arguments
529    /// * `gateway` - The gateway node name.
530    /// * `topology_bytes` - Serialized topology data to send to gateway.
531    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            // Send the shell command (command mode) or tar pipeline (copy mode).
555            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                // ev_pickup is fired by sync_gateway_pickups() once the control
563                // message has actually been sent on the wire (#594).
564                gw_state.ctl_unsent_targets.extend(targets);
565            }
566        }
567
568        self.sync_gateway_pickups(gateway);
569
570        Ok(())
571    }
572
573    /// Emit pickups for gateway targets whose control message has
574    /// actually been sent on the wire.
575    ///
576    /// Mirrors `PropagationChannel._send_ctl` firing `_emit_pickup`
577    /// after `channel.send` (upstream ClusterShell #594): once channel
578    /// setup is complete, a queued control message has left the local
579    /// process and its targets can no longer be rerouted. Call this
580    /// after the channel processes the configuration ACK (and after
581    /// [`TreeWorker::start_gateway`]); it is a no-op while the control
582    /// message is still queued behind an un-ACKed configuration.
583    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    /// Fire `ev_pickup` for a node, or queue it until `ev_start` fires.
597    ///
598    /// Mirrors upstream `TreeWorker._emit_pickup` (#594): direct child
599    /// workers route pickups through `MetaWorkerEventHandler.ev_pickup`;
600    /// gateway-routed targets route them through the propagation channel
601    /// once the control message leaves the local process (see
602    /// [`TreeWorker::sync_gateway_pickups`]). Emission is skipped once
603    /// the worker has been aborted.
604    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            // ev_start has not fired yet; defer to preserve ordering
614            self.pending_pickups.push(node.to_string());
615        }
616    }
617
618    /// Notify the meta worker that a direct child worker has started.
619    ///
620    /// `MetaWorkerEventHandler.ev_start` equivalent: bumps the started
621    /// count and re-evaluates initialization.
622    pub fn notify_child_start(&mut self) {
623        self.start_count += 1;
624        self.check_ini();
625    }
626
627    /// Fire `ev_start` once all direct child workers have started, then
628    /// flush pickup events buffered during launch.
629    ///
630    /// Mirrors upstream `TreeWorker._check_ini` (#594): this part runs
631    /// once. If the worker is aborted on (or before) `ev_start`, the
632    /// flush stops at the first aborted check and no pickups are
633    /// emitted.
634    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    /// Mark a node as unreachable in the router, so later dispatches and
649    /// relaunches avoid it (upstream `PropagationTreeRouter
650    /// .mark_unreachable` pass-through used before `_relaunch`).
651    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    /// Redistribute and relaunch targets that were running on a failed
658    /// gateway (upstream `TreeWorker._relaunch`).
659    ///
660    /// Emits a `reroute` routing event BEFORE re-dispatching, so a
661    /// rerouted target's `ev_pickup` can only fire afterwards — and,
662    /// because pickup requires the control message to have been sent,
663    /// exactly once (#594). Upstream only reroutes targets whose control
664    /// message never left the failed channel; callers should do the same
665    /// (a target already picked up on the failed gateway must not be
666    /// relaunched).
667    ///
668    /// Returns the new (gateway, targets) distribution; entries with
669    /// gateway == targets are directly reachable and must be handled by
670    /// the caller like any direct target.
671    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        // Any control message queued but never sent on the failed channel
683        // dies with it: no pickup was ever emitted for these targets (#594).
684        // (gw_state is dropped here, closing the channel state.)
685
686        self.target_count = self.target_count.saturating_sub(target_count);
687
688        // Routing event fires before re-dispatch (upstream _ev_routing).
689        self.events.push(TreeWorkerEvent::Routing {
690            event: "reroute".to_string(),
691            gateway: previous_gateway.to_string(),
692            targets: targets.join(","),
693        });
694
695        // Re-dispatch the orphaned targets (upstream `_launch(targets)`).
696        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                // became directly reachable — caller spawns a child worker
705                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                    // Channel to this gateway already exists (it serves other
712                    // targets): send the command for the relaunched targets on
713                    // it, like upstream `_execute_remote` reusing the pchannel.
714                    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    /// Send this worker's command to `targets` over an existing gateway
740    /// channel (used when a relaunch lands on an already-started gateway).
741    /// Pickups fire via [`TreeWorker::sync_gateway_pickups`] once the
742    /// control message is actually sent.
743    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    /// Process a propagation result from a gateway.
765    ///
766    /// Call this when the gateway channel receives results.
767    /// Returns true if the worker is now complete.
768    pub fn process_result(
769        &mut self,
770        gateway: &str,
771        result: PropagationResult,
772    ) -> Result<bool, WorkerError> {
773        // Results only arrive after the channel is set up, so any queued
774        // control message has been sent by now (#594).
775        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                // Remove from active targets
797                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        // Check if we're done
835        let done = self.close_count >= self.target_count && self.target_count > 0;
836        if done {
837            self.state = WorkerState::Done;
838        }
839
840        // Check if gateway has no more active targets
841        self.check_gateway_fini(gateway);
842
843        Ok(done)
844    }
845
846    /// Record completion of a direct (non-gateway) node.
847    ///
848    /// Called when a direct SSH/local worker reports node completion.
849    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    /// Record a direct node timeout.
859    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    /// Check if a gateway has finished (no more active targets).
870    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                // Gateway is done — could trigger cleanup
874                // The actual channel close is handled by the caller
875            }
876        }
877    }
878
879    /// Write data to all active gateway channels.
880    ///
881    /// Like upstream `TreeWorker._write_remote`, this never fires
882    /// `ev_pickup`: write traffic on an already-picked-up node must not
883    /// double-fire (#594).
884    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    /// Send write EOF to all active gateway channels.
896    ///
897    /// Like upstream `TreeWorker._set_write_eof_remote`, this never
898    /// fires `ev_pickup` (#594).
899    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    /// Abort the tree worker.
911    ///
912    /// Marks all remaining gateway targets as failed with EX_PROTOCOL (76).
913    /// Also stops pending and future pickup events (#594): buffered
914    /// pickups are dropped and [`TreeWorker::emit_pickup`] becomes a no-op.
915    pub fn abort(&mut self) {
916        // Stop pending and future pickup events (#594). Upstream sets
917        // _aborted unconditionally, even for an already-finished worker.
918        self.aborted = true;
919        self.pending_pickups.clear();
920
921        if self.is_done() {
922            return;
923        }
924
925        // Close all gateway channels
926        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); // EX_PROTOCOL
930                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    /// Whether the worker has been aborted (upstream `_aborted`).
943    pub fn is_aborted(&self) -> bool {
944        self.aborted
945    }
946
947    /// Whether `ev_start` has fired (upstream `_initialized`).
948    pub fn is_initialized(&self) -> bool {
949        self.initialized
950    }
951
952    /// Number of pickup events currently buffered before `ev_start`.
953    pub fn pending_pickup_count(&self) -> usize {
954        self.pending_pickups.len()
955    }
956
957    /// Get the worker configuration.
958    pub fn config(&self) -> &TreeWorkerConfig {
959        &self.config
960    }
961
962    /// Whether stderr is captured separately from stdout.
963    ///
964    /// Always true for copy/rcopy workers: internal tar/scp errors are
965    /// kept off stdout (upstream ClusterShell #622).
966    pub fn stderr(&self) -> bool {
967        self.config.stderr
968    }
969
970    /// Report a gateway-originated error buffer as stderr lines
971    /// attributed to `nodes`, as seen from `gateway`.
972    ///
973    /// Mirrors `PropagationChannel._report_stderr` delivering to this
974    /// meta worker (upstream ClusterShell 90d3195): a trailing newline
975    /// is appended first so an empty buffer still yields one (empty)
976    /// line — `splitlines()` would drop it entirely (#249).
977    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        // split() on the trailing newline always leaves a final empty
981        // piece; drop it to match str.splitlines(). A trailing \r per
982        // line is stripped as well (\r\n line endings).
983        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    /// Report an error message received from a gateway before channel
1001    /// setup completed (e.g. the gateway rejected the configuration
1002    /// payload).
1003    ///
1004    /// Mirrors upstream 90d3195: such errors used to be logged and
1005    /// dropped; they are now reported as stderr of the gateway node
1006    /// itself, so they reach `ev_read`/`ev_error` on this worker
1007    /// instead of vanishing.
1008    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    /// Handle a [`MessageProcessingError`] raised while parsing traffic
1014    /// from `gateway` (e.g. a malformed or unknown gateway message).
1015    ///
1016    /// Mirrors the upstream initiator-side `Channel._do_read` path
1017    /// (commit a027c3e): the error is fed back as a stderr message from
1018    /// the gateway node instead of crashing the worker. The worker
1019    /// state is left untouched; closing the failed channel is the
1020    /// driver's responsibility.
1021    pub fn process_channel_error(&mut self, gateway: &str, err: &MessageProcessingError) {
1022        self.report_gateway_error(gateway, &err.to_string());
1023    }
1024
1025    /// Process a message line received from a remote node through a
1026    /// gateway (upstream `TreeWorker._on_remote_node_msgline`).
1027    ///
1028    /// For rcopy workers, `stdout` carries the base64-encoded tar stream
1029    /// and is accumulated for later extraction instead of being reported
1030    /// (partial base64 decoding requires a multiple of 4 characters;
1031    /// trailing chars are kept for the next message). Everything else is
1032    /// recorded as a stdout/stderr event.
1033    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        // rcopy only: we expect base64 encoded tar content on stdout
1055        let state = self.rcopy_states.entry(node.to_string()).or_default();
1056        state.encoded_tail.extend_from_slice(msg);
1057        // partial base64 decoding requires a multiple of 4 characters
1058        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            // base64_decode is RFC 4648 relaxed (ignores newlines), like
1062            // Python's base64.b64decode discarding non-alphabet chars.
1063            if let Ok(decoded) = crate::communication::base64_decode(text) {
1064                state.tar_bytes.extend_from_slice(&decoded);
1065            }
1066            // Undecodable chunks are dropped here; the resulting corrupt
1067            // tar stream is reported at finalize time (upstream 973870b).
1068        }
1069    }
1070
1071    /// Finalize rcopy for a node whose remote side has closed: extract
1072    /// the accumulated tar stream into the destination directory.
1073    ///
1074    /// Mirrors the reverse-copy branch of upstream
1075    /// `TreeWorker._on_remote_node_close`. Extraction uses
1076    /// [`TarExtractFilter::FullyTrusted`] semantics (PEP 706, upstream
1077    /// d2fcd16). Any failure — corrupt archive, I/O error — is reported
1078    /// as a stderr event attributed to the node carrying the gateway's
1079    /// message (upstream 973870b), never panicked or silently dropped.
1080    ///
1081    /// Returns the number of extracted bytes on success; errors are
1082    /// reported through events, so this only fails on worker misuse
1083    /// (missing destination).
1084    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            // no rcopy buffer received from this node
1090            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        // Upstream flushes the raw leftover base64 tail into the archive
1099        // before extracting; mirror that (it is empty for well-formed
1100        // streams).
1101        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                // Report the extraction error as stderr bytes (as bytes,
1112                // like any other message — upstream 973870b).
1113                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    /// Get the number of active gateways.
1124    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    /// Get active gateway names.
1132    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    /// Get the total number of target nodes.
1141    pub fn num_nodes(&self) -> usize {
1142        self.nodes.len()
1143    }
1144
1145    /// Get the worker ID.
1146    pub fn worker_id(&self) -> u64 {
1147        self.worker_id
1148    }
1149}
1150
1151// ============================================================================
1152// rcopy tar extraction (PEP 706 port)
1153// ============================================================================
1154
1155/// Explicit trust level for tar extraction (PEP 706 `filter=` port).
1156///
1157/// Upstream ClusterShell passes `filter='fully_trusted'` to
1158/// `tarfile.extractall()` (commit d2fcd16): rcopy tarballs are built by
1159/// trusted ClusterShell gateways, and the `tar`/`data` filters would
1160/// strip `S_IWGRP`/`S_IWOTH`, downgrading modes like 0664 to 0644.
1161/// Naming the trust decision in the type keeps it explicit at the call
1162/// site instead of silently legacy.
1163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1164pub enum TarExtractFilter {
1165    /// Preserve legacy extraction behavior for trusted archives:
1166    /// stored permission bits are honored as-is (no umask, no
1167    /// stripping) and member paths are not sanitized. Only use with
1168    /// archives from trusted sources (ClusterShell gateways).
1169    FullyTrusted,
1170}
1171
1172/// Error raised when rcopy tar extraction fails.
1173///
1174/// The Display message mirrors what upstream reports as the node's
1175/// stderr (`str(ex).encode()` in `_on_remote_node_close`).
1176#[derive(Debug, Clone)]
1177pub struct TarExtractError {
1178    /// Human-readable failure reason (I/O error, corrupt archive, ...).
1179    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
1206/// Size of a tar header/data block.
1207const TAR_BLOCK: usize = 512;
1208
1209/// Parsed header fields needed for extraction.
1210struct TarHeader {
1211    /// Member path (prefix + name, GNU longname or pax path applied).
1212    name: String,
1213    /// Permission bits (octal mode field).
1214    mode: u32,
1215    /// Typeflag byte.
1216    typeflag: u8,
1217    /// Link target for symlink/hardlink members.
1218    linkname: String,
1219}
1220
1221/// Parse an octal-or-base256 numeric tar header field.
1222fn parse_tar_number(field: &[u8]) -> Option<u64> {
1223    if field.is_empty() {
1224        return None;
1225    }
1226    // base-256 (GNU large values): high bit of first byte set
1227    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    // octal ASCII, terminated by NUL/space, possibly space-padded
1235    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
1246/// Extract a ustar archive into `dest` with explicit trust semantics.
1247///
1248/// Returns the total number of payload bytes written. Failures mirror
1249/// the exception kinds upstream reports as node stderr (commit 973870b):
1250/// corrupt archives (Python `tarfile.TarError`) and I/O failures
1251/// (`IOError`/`OSError`) both surface as [`TarExtractError`].
1252///
1253/// With [`TarExtractFilter::FullyTrusted`], stored permission bits are
1254/// applied verbatim (`mode & 0o7777`, ignoring umask) and member paths
1255/// are used unsanitized — matching PEP 706 `fully_trusted`, which
1256/// preserves the pre-3.12 behavior for trusted ClusterShell gateways.
1257pub 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        // tarfile.ReadError("empty file") on a zero-byte archive
1267        return Err(TarExtractError::corrupt("empty file"));
1268    }
1269
1270    let mut written = 0usize;
1271    let mut offset = 0usize;
1272    // GNU longname/longlink carried over to the next header
1273    let mut pending_name: Option<String> = None;
1274    let mut pending_linkname: Option<String> = None;
1275    // directory modes are applied after all members are extracted, like
1276    // tarfile delaying directory chmod (dirs may be read-only)
1277    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        // a zero block terminates the archive
1284        if block.iter().all(|b| *b == 0) {
1285            terminated = true;
1286            break;
1287        }
1288
1289        // verify header checksum: chksum field counts as 8 spaces
1290        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        // ustar prefix (ignored for old v7 headers without magic)
1308        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        // GNU/pax name overrides apply to the next real header
1345        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                // pax extended header: honor path=/linkpath= records
1364                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            // regular file (also '\0' and contiguous '7')
1387            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                // fully_trusted: honor stored mode bits verbatim
1395                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    // Without a terminating zero block, non-zero trailing bytes mean a
1427    // truncated final header (tarfile.ReadError). Content AFTER a zero
1428    // block is ignored like tarfile does — this is what makes upstream's
1429    // raw leftover-tail flush at finalize time harmless (#594-era quirk
1430    // preserved in TreeWorker::finalize_rcopy_node).
1431    if !terminated && bytes[offset..].iter().any(|b| *b != 0) {
1432        return Err(TarExtractError::corrupt("truncated tar header"));
1433    }
1434
1435    // apply delayed directory modes (deepest first)
1436    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
1444/// Parse pax extended-header records (`LEN KEY=VALUE\n`).
1445fn 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        // record length is the decimal prefix up to the first space
1451        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        // Simulate that 3 direct targets were started
1534        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        // First node completes
1605        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        // Second node completes
1613        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    // ====================================================================
1714    // Upstream TreeWorkerTest mirrors (1.10 changes, #594/#622/PEP 706)
1715    //
1716    // The upstream tests need live SSH gateways; these are logic-level
1717    // equivalents around message handling (see mission notes).
1718    // ====================================================================
1719
1720    use crate::communication::Message;
1721    use crate::topology::TopologyGraph;
1722
1723    /// admin -> gw[1-2] -> node[1-2]: two gateways reaching the same
1724    /// targets, like the upstream GW2F1F test topology.
1725    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    /// ACK a started gateway channel's configuration, which completes
1737    /// setup and dequeues the queued shell control message.
1738    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    /// Nodes that fired a Pickup event, in emission order (one entry per
1749    /// ev_pickup call, not deduplicated — like upstream's pickup_nodes).
1750    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    /// Build a ustar header block with a correct checksum.
1762    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    /// Build a minimal ustar archive from directory and file members.
1782    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        // two terminating zero blocks
1794        out.extend(std::iter::repeat(0u8).take(2 * TAR_BLOCK));
1795        out
1796    }
1797
1798    /// rcopy worker configuration over a temporary destination dir.
1799    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    // -- ev_pickup semantics (#594, upstream commit 9e688cc) --------------
1809
1810    #[test]
1811    fn test_pickup_buffered_until_check_ini_flushes_in_order() {
1812        // Pickups emitted before ev_start are deferred, then flushed in
1813        // order right after Started (upstream _pending_pickups/_check_ini).
1814        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        // this part is called once (upstream _initialized gate)
1830        worker.check_ini();
1831        assert_eq!(worker.events().len(), 3);
1832    }
1833
1834    #[test]
1835    fn test_ev_start_waits_for_direct_children() {
1836        // Direct child workers delay ev_start until they have all started
1837        // (upstream _start_count >= _child_count); a direct pickup
1838        // emitted meanwhile is buffered and flushed after Started.
1839        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        // node2 is behind gw1; node1 is not in any route -> direct
1849        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        // direct child's ev_pickup arrives before its ev_start: buffered
1860        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        // Core #594 semantics: ev_pickup fires only when the command can
1872        // no longer be rerouted — a control message queued behind an
1873        // un-ACKed configuration must not trigger it.
1874        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        // After the configuration ACK the CTL leaves the process...
1893        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        // ...and never twice (1:1 ev_pickup-per-node invariant)
1900        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        // Mirror of upstream test_tree_run_abort_on_start (#594):
1907        // aborted on ev_start -> ev_start fires, ev_pickup does not.
1908        let nodes = make_nodeset("node1");
1909        let mut worker = TreeWorker::new(nodes, "echo".to_string(), TreeWorkerConfig::default());
1910
1911        worker.emit_pickup("node1"); // buffered before ev_start
1912        worker.abort(); // handler aborts during ev_start upstream
1913        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        // Mirror of upstream test_tree_run_abort_on_start_multi (#594):
1932        // buffered pickups for >1 node are all cleared; gateway targets
1933        // hang up with EX_PROTOCOL.
1934        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        // CTL queued behind un-ACKed CFG: pickups buffered/unsent
1944        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        // _emit_pickup gates on _aborted (#594): post-abort pickups are
1958        // dropped and any buffered ones are cleared.
1959        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        // post-init pickups fire immediately...
1967        worker.emit_pickup("node2");
1968        assert_eq!(
1969            pickup_nodes(&worker),
1970            vec!["node1".to_string(), "node2".to_string()]
1971        );
1972
1973        // ...but after abort they are dropped entirely
1974        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        // relaunch() mirrors upstream _relaunch: routing event first,
1985        // then targets re-dispatched away from the failed gateway.
1986        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        // Mirror of upstream test_tree_run_gw2f1_no_double_pickup_under_
2027        // reroute (#594): a rerouted target fires ev_pickup at most once.
2028        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        // gateway fails before setup: its queued CTL never left, so no
2039        // pickup has fired and the targets can be rerouted
2040        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        // list (not set) comparison catches accidental double-fires
2053        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        // Mirror of upstream test_tree_run_gw2f1_pickup_after_reroute
2061        // (#594): a rerouted target's ev_pickup fires after _ev_routing.
2062        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        // When rerouted targets land on a gateway whose channel is
2105        // already set up (it serves other targets), the command is sent
2106        // immediately and ev_pickup fires at once (#594).
2107        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        // simulate a second, already-established channel to gw2
2118        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        // the surviving channel was already set up, so the relaunched
2136        // targets were picked up immediately after the reroute event
2137        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    // -- separate stderr on forward copy (#622, upstream commit 5ed4c34) --
2144
2145    #[test]
2146    fn test_tree_copy_forces_separate_stderr() {
2147        // copy/rcopy: internal tar/scp errors are kept on a separate
2148        // stderr stream instead of merging into stdout (upstream #622).
2149        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        // command workers keep the requested setting
2163        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        // Mirror of upstream test_tree_worker_missing_arguments: a copy
2174        // worker without source is rejected like Python's ValueError.
2175        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        // single quotes are escaped the upstream way: ' -> '"'"'
2185        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        // command mode passes the user command through
2202        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        // forward copy sends the untar pipeline
2210        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        // rcopy sends the tar|base64 pipeline built from dirname/basename
2220        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    // -- rcopy receive/extract (upstream 973870b, d2fcd16) ----------------
2232
2233    #[test]
2234    fn test_rcopy_msgline_accumulates_base64() {
2235        // Mirror of upstream _on_remote_node_msgline: rcopy stdout is
2236        // accumulated as base64 tar data (decodable only on 4-char
2237        // boundaries); other streams pass through as events.
2238        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        // stderr still passes through as an event
2253        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        // the tar stream itself is never reported as stdout
2260        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        // Mirror of upstream test_tree_rcopy_preserves_mode (PEP 706,
2286        // commit d2fcd16): fully_trusted extraction keeps group-write
2287        // mode bits (0664 is not downgraded to 0644).
2288        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        // awkward chunk sizes exercise the 4-char boundary logic
2297        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        // Mirror of upstream rcopy extraction error reporting (973870b):
2316        // a corrupt archive surfaces as a stderr event carrying the error
2317        // message as bytes — not an exception, not a crash.
2318        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; // break the header checksum
2324        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        // nothing was extracted
2344        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        // Mirror of upstream _tree_rcopy_dir_error: a conflicting regular
2350        // file at an extract path fails on the I/O path (PEP 3151
2351        // IOError/OSError branch of 973870b).
2352        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        // Upstream flushes the raw undecodable base64 tail into the
2373        // archive before extracting; tarfile stops at the zero blocks so
2374        // the tail is harmless. Mirror: trailing base64 leftover after a
2375        // valid archive must not fail extraction.
2376        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'); // one undecodable leftover char (< 4)
2383        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        // upstream logs "no rcopy buffer received from %s" — no error
2397        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        // non-rcopy workers finalize as a no-op too
2404        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    // -- gateway errors (upstream 90d3195, a027c3e) -----------------------
2413
2414    #[test]
2415    fn test_report_gateway_error_before_channel_setup() {
2416        // Mirror of upstream 90d3195: an ErrorMessage reason received
2417        // before the channel ACK is reported as the gateway's own stderr
2418        // instead of being logged and dropped.
2419        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        // _report_stderr line semantics (upstream 90d3195 + #249):
2444        // per line, per node; an empty buffer still yields one empty line.
2445        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        // Mirror of upstream a027c3e: a MessageProcessingError from
2482        // malformed gateway traffic is reported as gateway stderr; the
2483        // worker itself survives.
2484        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}