Skip to main content

zsh/ported/modules/
zftp.rs

1//! ZFTP module - port of Modules/zftp.c
2//!
3//! it's a TELNET based protocol, but don't think I like doing this         // c:56
4//! Number of connections actually open                                      // c:210
5//! zfclosing is set if zftp_close() is active                               // c:219
6//! List of active sessions                                                  // c:310
7//!
8//! Provides a builtin FTP client for zsh.
9
10use crate::ported::builtin::{LASTVAL, SFCONTEXT};
11use crate::ported::params::getiparam;
12use crate::ported::utils::{errflag, getshfunc, zwarnnam};
13use crate::ported::zsh_h::{module, options, SFC_HOOK};
14use indexmap::IndexMap;
15use std::io::{self, BufRead, BufReader, Read, Write};
16use std::net::{TcpStream, ToSocketAddrs};
17use std::os::unix::io::AsRawFd;
18use std::path::Path;
19use std::sync::atomic::Ordering;
20use std::sync::{Mutex, OnceLock};
21use std::time::Duration;
22
23/// Port of `zftp_session(UNUSED(char *name), char **args, UNUSED(int flags))` from `Src/Modules/zftp.c:2889`.
24#[allow(unused_variables)]
25pub fn zftp_session(name: &str, args: &[&str], flags: i32) -> i32 {
26    // c:2889
27    if args.is_empty() {
28        // c:2889
29        // c:2892-2895 — walk zfsessions list, print each name.
30        if let Ok(state) = zftp_state().lock() {
31            for sess_name in state.sessions.keys() {
32                // c:2894
33                println!("{}", sess_name); // c:2895
34            }
35        }
36        return 0; // c:2896
37    }
38    // c:2903-2904 — no-op if already in the requested session.
39    let current = zftp_state()
40        .lock()
41        .ok()
42        .and_then(|s| s.current.clone())
43        .unwrap_or_default();
44    if args[0] == current {
45        return 0; // c:2915
46    }
47    savesession(); // c:2915
48    switchsession(args[0]); // c:2915
49    0 // c:2915
50}
51
52/// Port of `typedef struct zftp_session *Zftp_session;` from
53/// `Src/Modules/zftp.c:50`. Pointer-style typedef alias used by every
54/// `zftp_*` callsite that takes a session arg.
55#[allow(non_camel_case_types)]
56pub type Zftp_session = Box<zftp_session>;
57
58// =====================================================================
59// `struct zfheader` from `Src/Modules/zftp.c:114` — block-mode header.
60// =====================================================================
61
62/// Port of `struct zfheader` from `Src/Modules/zftp.c:114`.
63/// ```c
64/// struct zfheader {
65///     char flags;
66///     unsigned char bytes[2];
67/// };
68/// ```
69#[allow(non_camel_case_types)]
70pub struct zfheader {
71    pub flags: i8,      // c:115
72    pub bytes: [u8; 2], // c:116
73}
74
75// =====================================================================
76// `struct zftpcmd` from `Src/Modules/zftp.c:128` — subcommand entry.
77// =====================================================================
78
79/// Port of `struct zftpcmd` from `Src/Modules/zftp.c:128`.
80/// ```c
81/// struct zftpcmd {
82///     const char *nam;
83///     int (*fun) (char *, char **, int);
84///     int min, max, flags;
85/// };
86/// ```
87#[allow(non_camel_case_types)]
88pub struct zftpcmd {
89    pub nam: &'static str,                  // c:129
90    pub fun: fn(&str, &[&str], i32) -> i32, // c:130
91    pub min: i32,                           // c:131
92    /// `max` field.
93    pub max: i32,
94    /// `flags` field.
95    pub flags: i32,
96}
97
98/// Port of `typedef struct zftpcmd *Zftpcmd` from `Src/Modules/zftp.c:151`.
99#[allow(non_camel_case_types)]
100pub type Zftpcmd = Box<zftpcmd>;
101
102/// `lastcode` — file-scope global from `Src/Modules/zftp.c:228`:
103/// `static int lastcode;`. Numeric form of `lastcodestr`.
104pub static lastcode: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
105
106/// `ZFST_TYPE(x)` macro — extract type-flag bits.
107/// Port of `#define ZFST_TYPE(x) (x & ZFST_TMSK)` from
108/// `Src/Modules/zftp.c`.
109#[allow(non_snake_case)]
110#[inline]
111pub fn ZFST_TYPE(x: i32) -> i32 {
112    x & ZFST_TMSK
113}
114
115/// `ZFST_MODE(x)` macro — extract mode-flag bits.
116/// Port of `#define ZFST_MODE(x) (x & ZFST_MMSK)` from
117/// `Src/Modules/zftp.c`.
118#[allow(non_snake_case)]
119#[inline]
120pub fn ZFST_MODE(x: i32) -> i32 {
121    x & ZFST_MMSK
122}
123
124/// Port of `struct zftp_session` from `Src/Modules/zftp.c:299`.
125///
126/// C definition (verbatim):
127/// ```c
128/// struct zftp_session {
129///     char *name;            /* name of session */
130///     char **params;         /* parameters ordered as in zfparams */
131///     char **userparams;     /* user parameters set by zftp_params */
132///     FILE *cin;             /* control input file */
133///     Tcp_session control;   /* the control connection */
134///     int dfd;               /* data connection */
135///     int has_size;          /* understands SIZE? */
136///     int has_mdtm;          /* understands MDTM? */
137/// };
138///
139/// typedef struct zftp_session *Zftp_session;  // c:50
140/// ```
141///
142/// Field names + order match C exactly. `cin` (control input file) is
143/// modelled as `Option<TcpStream>` since Rust doesn't expose libc
144/// FILE* directly; `control` (the Tcp_session) collapses into the
145/// same TcpStream slot in the static-link path.
146#[derive(Debug)]
147#[allow(non_camel_case_types)]
148pub struct zftp_session {
149    pub name: String,               // c:300 char *name
150    pub params: Vec<String>,        // c:301 char **params
151    pub userparams: Vec<String>,    // c:302 char **userparams
152    pub cin: Option<TcpStream>,     // c:303 FILE *cin (control input)
153    pub control: Option<TcpStream>, // c:304 Tcp_session control
154    pub dfd: i32,                   // c:305 int dfd
155    pub has_size: i32,              // c:306 int has_size
156    pub has_mdtm: i32,              // c:307 int has_mdtm
157
158    // Below: ergonomic Rust fields not in C `struct zftp_session` but
159    // needed by the Rust wrapper to track connection state without the
160    // C `params` array indexing convention. Document the mapping back
161    // to C `params[]` slots in comments.
162    pub host: Option<String>, // C: params[ZFPM_HOST]
163    pub port: u16,            // C: params[ZFPM_PORT] (parsed)
164    pub user: Option<String>, // C: params[ZFPM_USER]
165    pub pwd: Option<String>,  // C: params[ZFPM_PASSWORD]
166    pub connected: bool,      // C: cin != NULL
167    pub logged_in: bool,      // C: derived from greeting parse
168    /// `transfer_type` field. Mirrors the ZFST_TYPE bits of
169    /// `zfstatusp[zfsessno]` (c:267 `#define ZFST_TYPE(x) (x & ZFST_TMSK)`).
170    /// This is the "next transfer type" the user has requested.
171    pub transfer_type: i32,
172    /// `current_type` field. Mirrors the ZFST_CTYP bits of
173    /// `zfstatusp[zfsessno]` (c:272 `#define ZFST_CTYP(x) ((x >> ZFST_TBIT)
174    /// & ZFST_TMSK)`). This is the type currently negotiated with the
175    /// server. zfsettype (c:2405) sends TYPE only when transfer_type !=
176    /// current_type and updates current_type after a successful response.
177    pub current_type: i32,
178    /// `transfer_mode` field.
179    pub transfer_mode: i32,
180    /// `passive` field.
181    pub passive: bool,
182    /// Mirrors the ZFST_SYST bit of `zfstatusp[zfsessno]` (c:240).
183    /// Set after a successful SYST probe so re-login on the same
184    /// session doesn't re-issue SYST.
185    pub syst_probed: bool,
186    /// Mirrors the ZFST_NOPS bit of `zfstatusp[zfsessno]` (c:240).
187    /// Set after the server returns 5xx for PASV so subsequent
188    /// `zfopendata` calls skip PASV and go directly to PORT mode.
189    /// Reset to false on session re-open.
190    pub nops_probed: bool,
191    /// Mirrors the ZFST_TRSZ bit (0x0080, "tried getting `size' from
192    /// reply"). Set by zfgetdata's first RETR (c:1102) regardless of
193    /// whether the size hint parsed; gates the c:1093 "only parse the
194    /// 150 reply on the FIRST RETR" check and the c:2577 SIZE-probe
195    /// cache test in zfgetput.
196    pub trsz: bool,
197    /// Mirrors the ZFST_NOSZ bit (0x0040, "server doesn't send
198    /// `(XXXX bytes)' reply"). Set alongside trsz at c:1102, CLEARED
199    /// at c:1109 when the byte-count hint parses — trsz && !nosz means
200    /// the server embeds sizes in 150 replies, so zfgetput skips the
201    /// separate SIZE round-trip (c:2577-2585).
202    pub nosz: bool,
203}
204
205/// `zfprefs` — file-scope `static int zfprefs;` from
206/// Src/Modules/zftp.c:218. Bitfield of ZFPF_SNDP|ZFPF_PASV|ZFPF_DUMB.
207/// Default set by boot_ (c:3206) to ZFPF_SNDP|ZFPF_PASV.
208#[allow(non_upper_case_globals)]
209pub static zfprefs: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); // c:218
210
211/// Port of `zfhandler(int sig)` from `Src/Modules/zftp.c:366`.
212/// C: `static void zfhandler(int sig)` — SIGALRM handler. Sets the
213/// `zfdrrrring` flag so the next zfread/zfgetline returns -1 and exits
214/// its setjmp-protected critical section.
215#[allow(non_snake_case)]
216pub extern "C" fn zfhandler(sig: i32) {
217    // c:366
218    if sig == libc::SIGALRM {
219        // c:368
220        ZFDRRRRING.store(1, Ordering::Relaxed); // c:369
221                                                // c:370-374 — errno = ETIMEDOUT (or EIO).
222        unsafe {
223            *errno_ptr() = libc::ETIMEDOUT;
224        }
225        // c:375 — longjmp(zfalrmbuf, 1). Rust port doesn't use setjmp;
226        // the ZFDRRRRING flag is the timeout signal each blocking
227        // read/write polls.
228    }
229    // c:377 DPUTS — unreachable in static-link path.
230}
231
232/// Port of `zfalarm(int tmout)` from `Src/Modules/zftp.c:384`.
233/// C: `static void zfalarm(int tmout)` — set up alarm + SIGALRM handler.
234#[allow(non_snake_case)]
235pub fn zfalarm(tmout: i32) {
236    // c:384
237    ZFDRRRRING.store(0, Ordering::Relaxed); // c:384
238                                            // c:387-392 — fire alarm even when tmout is 0 so a pending non-zero
239                                            // main-shell alarm doesn't bleed into the FTP code path.
240    if ZFALARMED.load(Ordering::Relaxed) != 0 {
241        // c:393
242        unsafe {
243            libc::alarm(tmout as u32);
244        } // c:394
245        return; // c:395
246    }
247    // c:397 — signal(SIGALRM, zfhandler);
248    unsafe {
249        libc::signal(libc::SIGALRM, zfhandler as libc::sighandler_t);
250    }
251    // c:398 — oalremain = alarm(tmout);
252    let oalremain = unsafe { libc::alarm(tmout as u32) };
253    OALREMAIN.store(oalremain, Ordering::Relaxed);
254    if oalremain != 0 {
255        // c:399
256        // c:400 — oaltime = zmonotime(NULL);
257        let now = std::time::SystemTime::now()
258            .duration_since(std::time::UNIX_EPOCH)
259            .map(|d| d.as_secs() as i64)
260            .unwrap_or(0);
261        OALTIME.store(now, Ordering::Relaxed);
262    }
263    ZFALARMED.store(1, Ordering::Relaxed); // c:405
264}
265
266/// Port of `zfpipe()` from `Src/Modules/zftp.c:412`.
267/// C: `static void zfpipe(void)` — ignore SIGPIPE so write() returns
268/// EPIPE instead of killing the shell.
269#[allow(non_snake_case)]
270pub fn zfpipe() {
271    // c:412
272    // c:412 — signal(SIGPIPE, SIG_IGN);
273    unsafe {
274        libc::signal(libc::SIGPIPE, libc::SIG_IGN);
275    }
276}
277
278/// Port of `zfunalarm()` from Src/Modules/zftp.c:422.
279/// C: `void zfunalarm(void)` — restores the prior alarm if `oalremain`
280/// was nonzero, else cancels with `alarm(0)`. Adjusts for elapsed time.
281#[allow(non_snake_case)]
282pub fn zfunalarm() {
283    // c:422
284    let oalremain = OALREMAIN.load(Ordering::Relaxed); // c:422
285    if oalremain != 0 {
286        // c:423
287        // c:432-433 — `time_t tdiff = zmonotime(NULL) - oaltime;`
288        let oaltime = OALTIME.load(Ordering::Relaxed); // c:432
289        let now = std::time::SystemTime::now()
290            .duration_since(std::time::UNIX_EPOCH)
291            .map(|d| d.as_secs() as i64)
292            .unwrap_or(0);
293        let tdiff = now - oaltime; // c:432
294        let secs = if (oalremain as i64) < tdiff {
295            1
296        } else {
297            // c:434
298            (oalremain as i64 - tdiff) as u32
299        };
300        unsafe {
301            libc::alarm(secs);
302        } // c:453
303    } else {
304        unsafe {
305            libc::alarm(0);
306        } // c:453
307    }
308}
309
310/// Port of `zfunpipe()` from Src/Modules/zftp.c:453.
311/// C: `void zfunpipe(void)` — restores the SIGPIPE disposition that
312/// existed before `zfpipe()` ignored it.
313#[allow(non_snake_case)]
314pub fn zfunpipe() {
315    // c:453
316    // c:453 — `if (sigtrapped[SIGPIPE]) { ... } else signal_default(SIGPIPE);`
317    // The static-link path doesn't expose `sigtrapped[]`/`siglists[]` yet,
318    // so reset to default disposition unconditionally — matches the
319    // common case where SIGPIPE wasn't trapped.
320    unsafe {
321        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
322    } // c:460
323}
324
325/// Port of `zfmovefd(int fd)` from `Src/Modules/zftp.c:472`.
326///
327/// Direct line-by-line port. Bumps an fd past the reserved
328/// shell-internal range (0..=9) via `fcntl(F_DUPFD, 10)` then
329/// closes the original — keeps ZFTP control sockets out of the
330/// user's redirection range. Returns -1 on dup failure.
331#[allow(non_snake_case)]
332pub fn zfmovefd(fd: i32) -> i32 {
333    // c:474 — `if (fd != -1 && fd < 10) { ... }`
334    if fd != -1 && fd < 10 {
335        // c:476 — `int fe = fcntl(fd, F_DUPFD, 10);`
336        let fe = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
337        // c:480 — `close(fd);`
338        unsafe {
339            libc::close(fd);
340        }
341        // c:481 — `fd = fe;`
342        return fe;
343    }
344    fd // c:483
345}
346
347/// Port of `zfsetparam(char *name, void *val, int flags)` from `Src/Modules/zftp.c:494`.
348/// C: `static void zfsetparam(char *name, void *val, int flags)` — install
349/// the named ZFTP_* param via assignsparam, applying PM_READONLY when the
350/// ZFPM_READONLY flag is set.
351#[allow(non_snake_case)]
352pub fn zfsetparam(name: &str, val: &str, flags: i32) {
353    // c:494
354    // c:494 — int type = (flags & ZFPM_INTEGER) ? PM_INTEGER : PM_SCALAR;
355    // Rust setsparam doesn't yet distinguish int vs scalar at creation;
356    // the underlying assignsparam path stores both as strings, and
357    // PM_INTEGER conversion happens at read time via getstrvalue.
358    let _ = flags & ZFPM_INTEGER;
359
360    // c:499-509 — getnode + IFUNSET / PM_UNSET handling. The Rust paramtab
361    // doesn't expose IFUNSET semantics yet — assignsparam always writes.
362    if (flags & ZFPM_IFUNSET) != 0 {
363        // c:507
364        // Only set if not currently set. Best-effort check via env lookup
365        // since paramtab isn't bucket-2 consolidated for the executor.
366        // c:508 — `if (pm = (Param)paramtab->getnode(paramtab, name))`.
367        //          C exits if the param already exists. Check paramtab,
368        //          not OS env.
369        if crate::ported::params::paramtab()
370            .read()
371            .map_or(false, |t| t.contains_key(name))
372        {
373            return; // c:508-509 pm = NULL → skip
374        }
375    }
376
377    // c:516-519 — pm->gsu.{i,s}->setfn(pm, val).
378    // Faithful port of c:499-506: when the param doesn't exist
379    // (or is PM_UNSET), createparam + apply PM_READONLY when
380    // ZFPM_READONLY is set. Then call setsparam to install the
381    // value.
382    //
383    // Prior port silently dropped the PM_READONLY flag — the param
384    // got created without it, so subsequent user writes to
385    // ZFTP_* (e.g. \$ZFTP_HOST after open) silently succeeded
386    // instead of getting the 'read-only variable' error C emits.
387    let needs_create = !crate::ported::params::paramtab()
388        .read()
389        .map(|t| {
390            // c:499-500 — `!getnode2 || PM_UNSET`. Treat absent or
391            // PM_UNSET as "create new".
392            t.get(name)
393                .map(|p| (p.node.flags as u32 & crate::ported::zsh_h::PM_UNSET) != 0)
394                .unwrap_or(true)
395        })
396        .unwrap_or(true);
397    // c:497 — `int type = (flags & ZFPM_INTEGER) ? PM_INTEGER : PM_SCALAR;`
398    let want_type: u32 = if (flags & ZFPM_INTEGER) != 0 {
399        crate::ported::zsh_h::PM_INTEGER
400    } else {
401        crate::ported::zsh_h::PM_SCALAR
402    };
403    if needs_create {
404        // c:505 — `if ((pm = createparam(name, type)) ...`
405        let _ = crate::ported::params::createparam(name, want_type as i32);
406        // c:505-506 — `&& (flags & ZFPM_READONLY) ...
407        //              pm->node.flags |= PM_READONLY;`
408        if (flags & ZFPM_READONLY) != 0 {
409            if let Ok(mut tab) = crate::ported::params::paramtab().write() {
410                if let Some(pm) = tab.get_mut(name) {
411                    pm.node.flags |= crate::ported::zsh_h::PM_READONLY as i32;
412                }
413            }
414        }
415    }
416    // c:510-514 — `if (!pm || PM_TYPE(pm->node.flags) != type) {
417    //                  if (type == PM_SCALAR) zsfree((char *)val);
418    //                  return;
419    //              }`
420    //
421    // Type-mismatch guard. After createparam (or the IFUNSET skip), the
422    // resolved param may have a different PM_TYPE than `want_type` — for
423    // example, the user did `typeset -i ZFTP_HOST=42` before connecting,
424    // so the existing param is PM_INTEGER while zfsetparam wants
425    // PM_SCALAR. C silently bails to avoid forcing a type conversion;
426    // prior Rust port skipped the guard and let setsparam re-assign
427    // regardless, clobbering the user's typed override.
428    let actual_type = match crate::ported::params::paramtab().read() {
429        Ok(t) => t
430            .get(name)
431            .map(|p| crate::ported::zsh_h::PM_TYPE(p.node.flags as u32)),
432        Err(_) => None,
433    };
434    match actual_type {
435        Some(t) if t == want_type => {} // proceed
436        Some(_) | None => return,       // c:514 — wrong type or no param
437    }
438    // c:516-519 — `if (type == PM_INTEGER)
439    //                  pm->gsu.i->setfn(pm, *(off_t *)val);
440    //              else
441    //                  pm->gsu.s->setfn(pm, (char *)val);`
442    //
443    // Dispatch per the resolved param type. For ZFPM_INTEGER, route
444    // through setiparam so the integer is stored in `u_val` directly
445    // instead of as a string parsed back to int through assignsparam.
446    // Prior port always called setsparam regardless of want_type —
447    // for ZFPM_INTEGER params (ZFTP_PORT, ZFTP_PID, ZFTP_SIZE, etc.)
448    // the value got stored as a String in `u_str`, and `${ZFTP_PORT}`
449    // reads went through the scalar getter producing the decimal
450    // representation. Functionally equivalent for the read side, but
451    // diverged from C's storage shape: a downstream PM_INTEGER-aware
452    // consumer querying `pm->u.val` directly would see 0 instead of
453    // the actual port number.
454    if (flags & ZFPM_INTEGER) != 0 {
455        let n = val.parse::<i64>().unwrap_or(0);
456        let _ = crate::ported::params::setiparam(name, n); // c:517
457    } else {
458        crate::ported::params::setsparam(name, val); // c:519
459    }
460}
461
462/// Port of `zfunsetparam(char *name)` from Src/Modules/zftp.c:529.
463/// C: `static void zfunsetparam(char *name)` — clears PM_READONLY then
464/// calls `unsetparam_pm(pm, 0, 1)`.
465#[allow(non_snake_case)]
466pub fn zfunsetparam(name: &str) {
467    // c:529
468    // Faithful port of c:531-536:
469    //   if ((pm = (Param) paramtab->getnode(paramtab, name))) {
470    //       pm->node.flags &= ~PM_READONLY;
471    //       unsetparam_pm(pm, 0, 1);
472    //   }
473    //
474    // Prior port called std::env::remove_var which only touches the
475    // OS environment, not paramtab. ZFTP_* params live in paramtab
476    // (created via createparam in zfsetparam), and many carry
477    // PM_READONLY — std::env::remove_var on a PM_READONLY paramtab
478    // entry would silently no-op for the shell but might clear the
479    // env var ambient state, leading to divergent shell-vs-env views.
480    //
481    // C's empty-name lookup returns NULL (paramtab miss); Rust's
482    // params::unsetparam similarly no-ops on empty.
483    if name.is_empty() {
484        return;
485    }
486    // c:531-535 — clear PM_READONLY first so the unset succeeds even
487    // on readonly params (the whole point of this helper — connection
488    // teardown needs to flush ZFTP_HOST etc. regardless of readonly).
489    if let Ok(mut tab) = crate::ported::params::paramtab().write() {
490        if let Some(pm) = tab.get_mut(name) {
491            pm.node.flags &= !(crate::ported::zsh_h::PM_READONLY as i32);
492        }
493    }
494    // c:535 — unsetparam_pm(pm, 0, 1). The free unsetparam helper
495    // wraps the getnode + unsetparam_pm flow and removes from paramtab.
496    crate::ported::params::unsetparam(name);
497}
498
499/// Port of `zfargstring(char *cmd, char **args)` from `Src/Modules/zftp.c:546`.
500/// C: `char *zfargstring(char *cmd, char **args)` — joins cmd + args.
501#[allow(non_snake_case)]
502pub fn zfargstring(cmd: &str, args: &[&str]) -> String {
503    // c:546-562 — join cmd + space-separated args + CRLF terminator:
504    //
505    //     strcpy(line, cmd);
506    //     for (aptr = args; *aptr; aptr++) {
507    //         strcat(line, " ");
508    //         strcat(line, *aptr);
509    //     }
510    //     strcat(line, "\r\n");
511    //
512    // The "\r\n" is part of THIS fn's contract (c:559) — C callers
513    // (zftp_dir c:2318, zftp_quote c:2695-2696) pass the result to
514    // zfsendcmd verbatim. A prior Rust version omitted it and made
515    // every call site compensate with `+ "\r\n"`.
516    let mut s = cmd.to_string();
517    for a in args {
518        s.push(' '); // c:556
519        s.push_str(a); // c:557
520    }
521    s.push_str("\r\n"); // c:559
522    s
523}
524
525/// Port of `zfgetline(char *ln, int lnsize, int tmout)` from `Src/Modules/zftp.c:571`.
526/// C: `int zfgetline(char *ln, int lnsize, int tmout)` — read a single
527/// CRLF-terminated line from the control connection, handling TELNET
528/// IAC command escapes and SIGALRM-driven timeout.
529#[allow(non_snake_case)]
530pub fn zfgetline(ln: &mut [u8], lnsize: i32, tmout: i32) -> i32 {
531    // c:571
532    // c:573-575 — locals at function top (Rule 5).
533    let mut ch: i32; // c:573 int ch
534    let mut added: i32 = 0; // c:573 added
535                            // c:575 — char *pcur = ln, cmdbuf[3];
536    let mut pcur: usize = 0; // pointer index into ln
537    let mut cmdbuf: [u8; 3] = [0; 3];
538
539    ZCFINISH.store(0, Ordering::Relaxed); // c:577 zcfinish = 0
540    let lnsize = lnsize - 1; // c:579 leave room for null
541    if !ln.is_empty() {
542        ln[0] = 0; // c:581 ln[0] = '\0'
543    }
544
545    // c:583-587 — setjmp guard via ZFDRRRRING flag.
546    if ZFDRRRRING.load(Ordering::Relaxed) != 0 {
547        // c:583
548        unsafe {
549            libc::alarm(0);
550        } // c:584
551        zwarnnam("zftp", "timeout getting response"); // c:585
552        return 6; // c:586
553    }
554    zfalarm(tmout); // c:588
555
556    // c:597-678 — for (;;) read loop with TELNET IAC handling.
557    let mut state = match zftp_state().lock() {
558        Ok(s) => s,
559        Err(_) => return 6,
560    };
561    let sess = match state
562        .current
563        .clone()
564        .and_then(|k| state.sessions.get_mut(&k))
565    {
566        Some(s) => s,
567        None => return 6,
568    };
569    let stream = match sess.cin.as_mut() {
570        Some(s) => s,
571        None => return 6,
572    };
573    let mut byte = [0u8; 1];
574
575    'main: loop {
576        // c:597 for (;;)
577        // c:598 — ch = fgetc(zfsess->cin);
578        ch = match stream.read(&mut byte) {
579            Ok(0) => -1, // EOF
580            Ok(_) => byte[0] as i32,
581            Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, // c:602 EINTR retry
582            Err(_) => -1,
583        };
584
585        match ch {
586            -1 => {
587                // c:601 EOF
588                ZCFINISH.store(2, Ordering::Relaxed); // c:606
589            }
590            0x0d => {
591                // c:609 '\r'
592                ch = match stream.read(&mut byte) {
593                    // c:611
594                    Ok(0) => -1,
595                    Ok(_) => byte[0] as i32,
596                    Err(_) => -1,
597                };
598                if ch == -1 {
599                    // c:612 EOF
600                    ZCFINISH.store(2, Ordering::Relaxed); // c:613
601                } else if ch == 0x0a {
602                    // c:616 '\n'
603                    ZCFINISH.store(1, Ordering::Relaxed); // c:617
604                } else if ch == 0x00 {
605                    // c:620 '\0'
606                    ch = 0x0d; // c:621
607                } else {
608                    ch = 0x0d; // c:625
609                }
610            }
611            0x0a => {
612                // c:628 '\n' (unexpected)
613                ZCFINISH.store(1, Ordering::Relaxed); // c:630
614            }
615            255 => {
616                // c:633 IAC
617                ch = match stream.read(&mut byte) {
618                    // c:638
619                    Ok(0) => -1,
620                    Ok(_) => byte[0] as i32,
621                    Err(_) => -1,
622                };
623                match ch {
624                    251 | 252 => {
625                        // c:640-641 WILL/WONT
626                        ch = match stream.read(&mut byte) {
627                            // c:642
628                            Ok(0) => -1,
629                            Ok(_) => byte[0] as i32,
630                            Err(_) => -1,
631                        };
632                        cmdbuf[0] = 255; // c:644 IAC
633                        cmdbuf[1] = 254; // c:645 DONT
634                        cmdbuf[2] = ch as u8; // c:646
635                                              // c:647 — write_loop(zfsess->control->fd, cmdbuf, 3);
636                        if let Some(ctrl) = sess.control.as_mut() {
637                            let _ = ctrl.write_all(&cmdbuf);
638                        }
639                        continue 'main; // c:648
640                    }
641                    253 | 254 => {
642                        // c:650-651 DO/DONT
643                        ch = match stream.read(&mut byte) {
644                            // c:652
645                            Ok(0) => -1,
646                            Ok(_) => byte[0] as i32,
647                            Err(_) => -1,
648                        };
649                        cmdbuf[0] = 255; // c:654 IAC
650                        cmdbuf[1] = 252; // c:655 WONT
651                        cmdbuf[2] = ch as u8; // c:656
652                        if let Some(ctrl) = sess.control.as_mut() {
653                            let _ = ctrl.write_all(&cmdbuf);
654                        }
655                        continue 'main; // c:658
656                    }
657                    -1 => {
658                        // c:660 EOF
659                        ZCFINISH.store(2, Ordering::Relaxed);
660                        // c:662
661                    }
662                    _ => {} // c:665 default
663                }
664            }
665            _ => {}
666        }
667
668        // c:671-672 — if (zcfinish) break;
669        if ZCFINISH.load(Ordering::Relaxed) != 0 {
670            break;
671        }
672        // c:673-676 — if (added < lnsize) { *pcur++ = ch; added++; }
673        if added < lnsize && pcur < ln.len() {
674            ln[pcur] = ch as u8;
675            pcur += 1;
676            added += 1;
677        }
678        // c:677 — junk if no room, keep reading.
679    }
680
681    unsafe {
682        libc::alarm(0);
683    } // c:680
684    if pcur < ln.len() {
685        ln[pcur] = 0; // c:702 *pcur = '\0'
686    }
687    // c:583 setjmp counterpart — alarm fired during the read loop.
688    // ZFDRRRRING was zeroed inside zfalarm; non-zero now means
689    // zfhandler set it during the blocked fgetc. Mirrors the
690    // post-syscall fix added to zfread (cfe7560f58) and zfwrite
691    // (00c1c36dc9). Without it, a SIGALRM during line read drained
692    // through to ZCFINISH unaffected — the caller saw a partial
693    // line with the timeout signal silently lost.
694    if ZFDRRRRING.load(Ordering::Relaxed) != 0 {
695        zwarnnam("zftp", "timeout getting response"); // c:585
696        return 6; // c:586
697    }
698    // c:702 — return (zcfinish & 2);
699    ZCFINISH.load(Ordering::Relaxed) & 2
700}
701
702/// Port of `zfgetmsg()` from `Src/Modules/zftp.c:702`.
703/// C: `static int zfgetmsg(void)` — read a complete FTP server reply
704/// (possibly multi-line), parse the 3-digit code, update lastcode +
705/// lastcodestr + lastmsg + ZFTP_REPLY, return the first-digit status
706/// (1/2/3/4/5) or 6 on error/disconnect.
707#[allow(non_snake_case)]
708pub fn zfgetmsg() -> i32 {
709    // c:702
710    // c:702-705 — char line[256], *ptr, *verbose;
711    //             int stopit, printing = 0, tmout;
712    let mut line = [0u8; 256];
713    let mut printing: i32 = 0;
714    let stopit_initial: bool;
715    let tmout: i32;
716
717    // c:707-708 — if (!zfsess->control) return 6;
718    {
719        let state = match zftp_state().lock() {
720            Ok(s) => s,
721            Err(_) => return 6,
722        };
723        let sess = match state.current.as_deref().and_then(|k| state.sessions.get(k)) {
724            Some(s) => s,
725            None => return 6,
726        };
727        if sess.control.is_none() {
728            return 6; // c:708
729        }
730    }
731
732    // c:709-710 — zsfree(lastmsg); lastmsg = NULL;
733    if let Ok(mut m) = lastmsg.lock() {
734        m.clear();
735    }
736
737    // c:712 — tmout = getiparam("ZFTP_TMOUT");
738    // c:712 — `tmout = getiparam("ZFTP_TMOUT");`. Read paramtab, not OS env.
739    tmout = getiparam("ZFTP_TMOUT") as i32;
740
741    // c:714 — zfgetline(line, 256, tmout);
742    zfgetline(&mut line, 256, tmout);
743    // c:715 — ptr = line; (use string slice + offset index instead)
744    let mut ptr_off: usize = 0;
745    let line_str = std::str::from_utf8(&line)
746        .unwrap_or("")
747        .trim_end_matches('\0')
748        .to_string();
749
750    // c:716 — if (zfdrrrring || !idigit(ptr[0..3])) — timeout or not FTP.
751    let is_digit = |b: u8| b.is_ascii_digit();
752    let timeout_or_bad = ZFDRRRRING.load(Ordering::Relaxed) != 0
753        || line.len() < 3
754        || !is_digit(line[0])
755        || !is_digit(line[1])
756        || !is_digit(line[2]);
757    if timeout_or_bad {
758        // c:716
759        ZCFINISH.store(2, Ordering::Relaxed); // c:718
760        if ZFCLOSING.load(Ordering::Relaxed) == 0 {
761            // c:719
762            zfclose(0); // c:720
763        }
764        if let Ok(mut m) = lastmsg.lock() {
765            m.clear();
766        } // c:721
767        if let Ok(mut cs) = lastcodestr.lock() {
768            // c:722
769            cs.copy_from_slice(b"000\0");
770        }
771        zfsetparam("ZFTP_REPLY", "", ZFPM_READONLY); // c:723
772        return 6; // c:724
773    }
774
775    // c:726-729 — extract first 3 bytes into lastcodestr, parse to int.
776    let code_str: String = std::str::from_utf8(&line[..3]).unwrap_or("0").to_string();
777    if let Ok(mut cs) = lastcodestr.lock() {
778        cs[0] = line[0];
779        cs[1] = line[1];
780        cs[2] = line[2];
781        cs[3] = 0;
782    }
783    let code: i32 = code_str.parse().unwrap_or(0);
784    lastcode.store(code, Ordering::Relaxed);
785    ptr_off += 3;
786    // c:730 — zfsetparam("ZFTP_CODE", lastcodestr, ZFPM_READONLY);
787    zfsetparam("ZFTP_CODE", &code_str, ZFPM_READONLY);
788    // c:731 — stopit = (*ptr++ != '-');
789    stopit_initial = line.get(ptr_off).copied() != Some(b'-');
790    ptr_off += 1;
791    let mut stopit = stopit_initial;
792
793    // c:733-744 — verbose check + initial-line printing.
794    // c:734 — `if (!(verbose = getsparam_u("ZFTP_VERBOSE"))) verbose = "";`
795    //   — the `_u` variant strips Meta-escape encoding. ZFTP_VERBOSE
796    //   is a string of digit characters (e.g. "045") that gets fed
797    //   to strchr against `lastcodestr[0]` at c:736 and against '0'
798    //   at c:740 — strchr operates on raw bytes, not metafied form.
799    //   Prior port used non-_u getsparam — for any ZFTP_VERBOSE
800    //   value containing a Meta-escaped byte (rare but possible
801    //   via `$'\xfd'` etc.), the metafied byte pairs would survive
802    //   into the strchr-equivalent `.contains()` calls below and
803    //   miss matches.
804    let verbose = crate::ported::params::getsparam_u("ZFTP_VERBOSE").unwrap_or_default(); // c:734
805    if verbose.contains(line[0] as char) {
806        // c:736
807        printing = 1; // c:738
808        eprint!("{}", line_str); // c:739
809    } else if verbose.contains('0') && !stopit {
810        // c:740
811        printing = 2; // c:742
812        eprint!("{}", &line_str[ptr_off..]); // c:743
813    }
814    if printing != 0 {
815        // c:746
816        eprintln!(); // c:747
817    }
818
819    // c:749-775 — multi-line continuation loop.
820    while ZCFINISH.load(Ordering::Relaxed) != 2 && !stopit {
821        line.fill(0); // reset
822        ptr_off = 0;
823        zfgetline(&mut line, 256, tmout); // c:750
824        if ZFDRRRRING.load(Ordering::Relaxed) != 0 {
825            // c:752
826            line[0] = 0; // c:753
827            break; // c:754
828        }
829        // c:757-764 — code-prefix check.
830        if &line[..3] == &code_str.as_bytes()[..3] {
831            // c:757
832            if line[3] == b' ' {
833                // c:758
834                stopit = true; // c:759
835                ptr_off = 4; // c:760
836            } else if line[3] == b'-' {
837                // c:761
838                ptr_off = 4; // c:762
839            }
840        } else if &line[..4] == b"    " {
841            // c:763
842            ptr_off = 4; // c:764
843        }
844
845        // c:766-774 — print intermediate line per `printing` mode.
846        let cont_line = std::str::from_utf8(&line)
847            .unwrap_or("")
848            .trim_end_matches('\0');
849        if printing == 2 {
850            // c:766
851            if !stopit {
852                // c:767
853                eprintln!("{}", &cont_line[ptr_off..]); // c:768-769
854            }
855        } else if printing != 0 {
856            // c:771
857            eprintln!("{}", cont_line); // c:772-773
858        }
859    }
860
861    // c:777-778 — fflush(stderr);
862    if printing != 0 {
863        let _ = io::stderr().flush();
864    }
865
866    // c:781 — lastmsg = ztrdup(ptr);  (the trailing portion of last line)
867    let last_msg_str: String = std::str::from_utf8(&line)
868        .unwrap_or("")
869        .trim_end_matches('\0')
870        .chars()
871        .skip(ptr_off)
872        .collect();
873    if let Ok(mut m) = lastmsg.lock() {
874        *m = last_msg_str.clone();
875    }
876    // c:785 — zfsetparam("ZFTP_REPLY", ztrdup(line), ZFPM_READONLY);
877    let whole_line = std::str::from_utf8(&line)
878        .unwrap_or("")
879        .trim_end_matches('\0');
880    zfsetparam("ZFTP_REPLY", whole_line, ZFPM_READONLY);
881
882    // c:791-797 — EOF or 421: close + warn.
883    let zcfin = ZCFINISH.load(Ordering::Relaxed);
884    let cur_code = lastcode.load(Ordering::Relaxed);
885    if (zcfin == 2 || cur_code == 421) && ZFCLOSING.load(Ordering::Relaxed) == 0 {
886        ZCFINISH.store(2, Ordering::Relaxed); // c:792
887        zfclose(0); // c:793
888        zwarnnam(
889            "zftp", // c:795
890            "remote server has closed connection",
891        );
892        return 6; // c:796
893    }
894    // c:798-801 — 530 not-logged-in.
895    if cur_code == 530 {
896        // c:798
897        return 6; // c:800
898    }
899    // c:807-810 — 120 wait-and-retry.
900    if cur_code == 120 {
901        // c:807
902        zwarnnam(
903            "zftp", // c:808
904            &format!("delay expected, waiting: {}", last_msg_str),
905        );
906        return zfgetmsg(); // c:809
907    }
908    // c:813 — return lastcodestr[0] - '0';
909    (code_str.as_bytes()[0] - b'0') as i32
910}
911
912/// Port of `zfsendcmd(char *cmd)` from `Src/Modules/zftp.c:825`.
913/// C: `static int zfsendcmd(char *cmd)` — write the command to the
914/// control fd with an alarm-guarded timeout, then read the server
915/// reply via zfgetmsg.
916#[allow(non_snake_case)]
917pub fn zfsendcmd(cmd: &str) -> i32 {
918    // c:825
919    // c:832 — int ret, tmout;
920    let ret: isize;
921    let tmout: i32;
922
923    // c:834-835 — if (!zfsess->control) return 6;
924    let mut state = match zftp_state().lock() {
925        Ok(s) => s,
926        Err(_) => return 6,
927    };
928    let sess = match state
929        .current
930        .clone()
931        .and_then(|k| state.sessions.get_mut(&k))
932    {
933        // c:834
934        Some(s) => s,
935        None => return 6,
936    };
937    if sess.control.is_none() {
938        // c:834
939        return 6; // c:835
940    }
941
942    // c:836 — tmout = getiparam("ZFTP_TMOUT");
943    // c:712 — `tmout = getiparam("ZFTP_TMOUT");`. Read paramtab, not OS env.
944    tmout = getiparam("ZFTP_TMOUT") as i32;
945
946    // c:837-841 — `if (setjmp(zfalrmbuf)) { alarm(0);
947    //                  zwarnnam("zftp", "timeout sending message");
948    //                  return 6; }`. ZFDRRRRING adapter same as
949    // zfread (cfe7560f58), zfwrite (00c1c36dc9), zfgetline (bef84af815).
950    // Two check sites match C's setjmp semantics: before alarm
951    // install + after write returns.
952    if ZFDRRRRING.load(Ordering::Relaxed) != 0 {
953        // Stale alarm from prior call.
954        unsafe {
955            libc::alarm(0);
956        }
957        zwarnnam("zftp", "timeout sending message");
958        return 6;
959    }
960    zfalarm(tmout); // c:842
961
962    // c:843 — `ret = write(zfsess->control->fd, cmd, strlen(cmd));`
963    let bytes = cmd.as_bytes();
964    ret = match sess.control.as_mut() {
965        Some(stream) => match stream.write(bytes) {
966            Ok(n) => {
967                let _ = stream.flush();
968                n as isize
969            }
970            Err(_) => -1,
971        },
972        None => -1,
973    };
974    // c:844 — `alarm(0);`
975    unsafe {
976        libc::alarm(0);
977    }
978
979    // c:837 setjmp counterpart for THIS call — alarm fired during write.
980    if ZFDRRRRING.load(Ordering::Relaxed) != 0 {
981        zwarnnam("zftp", "timeout sending message"); // c:839
982        return 6; // c:840
983    }
984
985    // c:846-849 — write failure.
986    if ret <= 0 {
987        zwarnnam(
988            // c:847
989            "zftp send",
990            &format!(
991                "failure sending control message: {}",
992                io::Error::last_os_error()
993            ),
994        );
995        return 6; // c:848
996    }
997
998    // c:851 — return zfgetmsg();
999    drop(state);
1000    zfgetmsg()
1001}
1002
1003/// Port of `zfopendata(char *name, union tcp_sockaddr *zdsockp, int *is_passivep)` from `Src/Modules/zftp.c:859`.
1004/// C: `static int zfopendata(char *name, union tcp_sockaddr *zdsockp,
1005/// int *is_passivep)` — set up a data connection (PASV-preferred,
1006/// PORT-mode fallback). Returns 0 success, 1 failure. Stores the
1007/// resulting fd in `zfsess->dfd` and sets `*is_passivep`.
1008///
1009/// Rust port: `is_passive` is returned via the tuple instead of a
1010/// `*int` out-param (Rust doesn't expose `union tcp_sockaddr` so the
1011/// `zdsockp` slot is gone). PORT-mode falls back to `TcpListener` for
1012/// the local bind+listen+accept; PASV path uses `TcpStream::connect`
1013/// and stores the connected stream's fd as `sess.dfd`. The non-PASV
1014/// branch requires the caller to accept(2) before reading.
1015#[allow(non_snake_case)]
1016/// Port of `zfopendata(char *name, union tcp_sockaddr *zdsockp, int *is_passivep)` from `Src/Modules/zftp.c:859`.
1017/// WARNING: param names don't match C — Rust=(name) vs C=(name, zdsockp, is_passivep)
1018pub fn zfopendata(name: &str) -> (i32, bool) {
1019    // c:859
1020    // c:862-865 — error if neither SNDP nor PASV preference is set.
1021    let prefs = zfprefs.load(Ordering::Relaxed); // c:862
1022    if (prefs & (ZFPF_SNDP | ZFPF_PASV)) == 0 {
1023        // c:863
1024        zwarnnam(name, "Must set preference S or P to transfer data"); // c:864
1025        return (1, false); // c:865
1026    }
1027    // c:871 — `if (!(zfstatusp[zfsessno] & ZFST_NOPS) && (zfprefs & ZFPF_PASV))`
1028    //   — try PASV only when the bit is set AND the server hasn't
1029    //     already 5xx'd PASV in this session.
1030    let nops_known = zftp_state()
1031        .lock()
1032        .ok()
1033        .and_then(|s| {
1034            s.current
1035                .as_deref()
1036                .and_then(|k| s.sessions.get(k))
1037                .map(|sess| sess.nops_probed)
1038        })
1039        .unwrap_or(false);
1040    let try_pasv: bool = !nops_known && (prefs & ZFPF_PASV) != 0; // c:871
1041
1042    if try_pasv {
1043        // c:879 — psv_cmd = "PASV\r\n"; (EPSV unsupported in Rust port).
1044        if zfsendcmd("PASV\r\n") == 6 {
1045            // c:881
1046            return (1, false); // c:882
1047        }
1048        let code = lastcode.load(Ordering::Relaxed);
1049        if (500..=504).contains(&code) {
1050            // c:884 — PASV unsupported by server.
1051            // c:888 — `zfstatusp[zfsessno] |= ZFST_NOPS;` — record so
1052            //         subsequent zfopendata calls skip PASV entirely.
1053            //         Prior port skipped this — every subsequent data
1054            //         transfer in the same session re-sent PASV to a
1055            //         server that had already 502'd it, wasting one
1056            //         round-trip per transfer.
1057            if let Ok(mut st) = zftp_state().lock() {
1058                if let Some(sess) = st.current.clone().and_then(|k| st.sessions.get_mut(&k)) {
1059                    sess.nops_probed = true; // c:888
1060                }
1061            }
1062            zfclosedata(); // c:889
1063                           // c:890 — `return zfopendata(...);` — C recurses.
1064                           // Rust falls through to the PORT-mode code below;
1065                           // same end-state since try_pasv was the only
1066                           // PASV-specific gate.
1067        } else {
1068            // c:899 — parse the PASV reply.
1069            let last = lastmsg.lock().ok().map(|m| m.clone()).unwrap_or_default();
1070            let (ip, port) = match parse_pasv_response(&last) {
1071                // c:925
1072                Ok(t) => t,
1073                Err(_) => {
1074                    zwarnnam(name, &format!("bad response to PASV: {}", last));
1075                    zfclosedata();
1076                    return (1, false); // c:946
1077                }
1078            };
1079            // c:954 — connect from data socket to remote (ip:port).
1080            let addr = format!("{}:{}", ip, port);
1081            let stream = match TcpStream::connect(&addr) {
1082                // c:958
1083                Ok(s) => s,
1084                Err(_) => {
1085                    zwarnnam(name, "can't open data socket");
1086                    zfclosedata();
1087                    return (1, false);
1088                }
1089            };
1090            let dfd_raw = stream.as_raw_fd();
1091            // Keep the stream alive past this fn so the fd stays open;
1092            // the session owns the fd via `dfd`.
1093            std::mem::forget(stream);
1094            if let Ok(mut state) = zftp_state().lock() {
1095                if let Some(sess) = state
1096                    .current
1097                    .clone()
1098                    .and_then(|k| state.sessions.get_mut(&k))
1099                {
1100                    sess.dfd = dfd_raw; // c:961 dfd stored
1101                }
1102            }
1103            return (0, true); // c:1041 SUCCESS PASV
1104        }
1105    }
1106
1107    // c:967-1037 — PORT-mode (active FTP): bind+listen locally, send
1108    // PORT a,b,c,d,p1,p2, return so caller can accept().
1109    // c:977 — refuse PORT mode if SNDP preference isn't set (e.g. when
1110    // PASV-only and the server rejected PASV).
1111    if (zfprefs.load(Ordering::Relaxed) & ZFPF_SNDP) == 0 {
1112        // c:977
1113        zwarnnam(name, "only sendport mode available for data"); // c:978
1114        return (1, false); // c:979
1115    }
1116    // c:982-992 — `*zdsockp = zfsess->control->sock;` then zero the
1117    // port and bind:
1118    //
1119    //     *zdsockp = zfsess->control->sock;
1120    //     ...
1121    //     zdsockp->in.sin_port = 0;	/* to be set by bind() */
1122    //     len = sizeof(struct sockaddr_in);
1123    //     ...
1124    //     if (bind(zfsess->dfd, (struct sockaddr *)zdsockp, len) < 0)
1125    //
1126    // The data socket binds to the CONTROL connection's LOCAL address
1127    // — the interface the server can actually reach back on — and
1128    // that address is what the c:1003+ PORT command advertises. A
1129    // prior bind to "0.0.0.0:0" made local_addr() report 0.0.0.0, so
1130    // active mode sent `PORT 0,0,0,0,p1,p2` — every server either
1131    // rejects it or dials a black hole; PORT-mode transfers could
1132    // never work, on any host.
1133    let ctrl_local_ip: std::net::IpAddr = zftp_state()
1134        .lock()
1135        .ok()
1136        .and_then(|s| {
1137            s.current
1138                .as_deref()
1139                .and_then(|k| s.sessions.get(k))
1140                .and_then(|sess| {
1141                    sess.control
1142                        .as_ref()
1143                        .and_then(|c| c.local_addr().ok())
1144                        .map(|a| a.ip())
1145                })
1146        })
1147        .unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1148    let listener = match std::net::TcpListener::bind((ctrl_local_ip, 0)) {
1149        // c:994 bind(zfsess->dfd, zdsockp, len)
1150        Ok(l) => l,
1151        Err(_) => {
1152            zwarnnam(name, "can't bind data socket");
1153            return (1, false);
1154        }
1155    };
1156    let local = match listener.local_addr() {
1157        Ok(a) => a,
1158        Err(_) => {
1159            zwarnnam(name, "getsockname failed");
1160            return (1, false);
1161        }
1162    };
1163    // c:986 — listen with backlog 1; std::net::TcpListener::bind already listens.
1164    let ipv4 = match local.ip() {
1165        std::net::IpAddr::V4(v) => v.octets(),
1166        std::net::IpAddr::V6(_) => {
1167            zwarnnam(name, "PORT mode requires IPv4");
1168            return (1, false);
1169        }
1170    };
1171    let port = local.port();
1172    // c:1003-1017 — PORT cmd format: "PORT h1,h2,h3,h4,p1,p2\r\n".
1173    let port_cmd = format!(
1174        "PORT {},{},{},{},{},{}\r\n",
1175        ipv4[0],
1176        ipv4[1],
1177        ipv4[2],
1178        ipv4[3],
1179        port >> 8,
1180        port & 0xff,
1181    );
1182    if zfsendcmd(&port_cmd) > 2 {
1183        // c:1018
1184        zwarnnam(name, "PORT command failed");
1185        return (1, false); // c:1019
1186    }
1187    // c:1029 — store listening fd as dfd; caller does accept().
1188    let lfd = listener.as_raw_fd();
1189    std::mem::forget(listener);
1190    if let Ok(mut state) = zftp_state().lock() {
1191        if let Some(sess) = state
1192            .current
1193            .clone()
1194            .and_then(|k| state.sessions.get_mut(&k))
1195        {
1196            sess.dfd = lfd; // c:1029
1197        }
1198    }
1199    (0, false) // c:1037 SUCCESS PORT
1200}
1201
1202/// Port of `zfclosedata()` from `Src/Modules/zftp.c:1043`.
1203/// C: `static void zfclosedata(void)` — early-return when no dfd is
1204/// live, otherwise close(dfd) + dfd = -1.
1205#[allow(non_snake_case)]
1206pub fn zfclosedata() {
1207    // c:1043
1208    if let Ok(mut state) = zftp_state().lock() {
1209        if let Some(sess) = state
1210            .current
1211            .clone()
1212            .and_then(|k| state.sessions.get_mut(&k))
1213        {
1214            if sess.dfd == -1 {
1215                // c:1045
1216                return; // c:1046
1217            }
1218            unsafe {
1219                libc::close(sess.dfd);
1220            } // c:1047 close(dfd)
1221            sess.dfd = -1; // c:1048
1222        }
1223    }
1224}
1225
1226/// Port of `zfgetdata(char *name, char *rest, char *cmd, int getsize)` from `Src/Modules/zftp.c:1065`.
1227/// C: `static int zfgetdata(char *name, char *rest, char *cmd, int getsize)` —
1228/// open the data connection (PASV or PORT mode via zfopendata),
1229/// optionally send REST, send the transfer command, and (PORT mode)
1230/// accept(2) on the listening socket. Returns 0 success, 1 failure.
1231#[allow(non_snake_case)]
1232pub fn zfgetdata(name: &str, rest: &str, cmd: &str, getsize: i32) -> i32 {
1233    // c:1065
1234    // c:1065-1069 — locals at fn top.
1235    let is_passive: bool; // c:1068
1236
1237    // c:1071-1072 — zfopendata: full PASV/PORT setup; sets sess.dfd.
1238    let (rc, ip) = zfopendata(name); // c:1071
1239    if rc != 0 {
1240        // c:1071
1241        return 1; // c:1072
1242    }
1243    is_passive = ip;
1244
1245    // c:1084-1087 — REST command for resume.
1246    if !rest.is_empty() && zfsendcmd(rest) > 3 {
1247        zfclosedata();
1248        return 1;
1249    }
1250
1251    // c:1089-1092 — send the transfer command (RETR / STOR / etc.).
1252    if zfsendcmd(cmd) > 2 {
1253        // c:1089
1254        zfclosedata(); // c:1090
1255        return 1; // c:1091
1256    }
1257
1258    // c:1093-1116 — parse "Opening data connection for file (N bytes)"
1259    // hint to populate ZFTP_SIZE without a separate SIZE request:
1260    //
1261    //     if (getsize || (!(zfstatusp[zfsessno] & ZFST_TRSZ) &&
1262    //                     !strncmp(cmd, "RETR", 4))) {
1263    //         char *ptr = strstr(lastmsg, "bytes");
1264    //         zfstatusp[zfsessno] |= ZFST_NOSZ|ZFST_TRSZ;
1265    //         if (ptr) {
1266    //             ...walk back to digit run...
1267    //             if (idigit(*ptr)) {
1268    //                 zfstatusp[zfsessno] &= ~ZFST_NOSZ;
1269    //                 if (getsize) { ...zfsetparam("ZFTP_SIZE", ...)... }
1270    //             }
1271    //         }
1272    //     }
1273    //
1274    // The ZFST_TRSZ gate limits the free-parse attempt to the FIRST
1275    // RETR; NOSZ|TRSZ pessimistically assume no embedded size, then
1276    // NOSZ clears when the hint parses. zfgetput's c:2577 cache test
1277    // reads these to skip the SIZE round-trip on servers that embed
1278    // sizes. Prior port re-parsed every RETR and never recorded the
1279    // bits, so the SIZE-avoidance optimization the c:1098 comment
1280    // describes never engaged.
1281    let trsz_known = zftp_state()
1282        .lock()
1283        .ok()
1284        .and_then(|s| {
1285            s.current
1286                .as_deref()
1287                .and_then(|k| s.sessions.get(k))
1288                .map(|sess| sess.trsz)
1289        })
1290        .unwrap_or(false);
1291    if getsize != 0 || (!trsz_known && cmd.starts_with("RETR")) {
1292        // c:1093-1094
1293        let cur_last = lastmsg.lock().ok().map(|m| m.clone()).unwrap_or_default();
1294        // c:1102 — pessimistic default: tried, assume no size.
1295        if let Ok(mut st) = zftp_state().lock() {
1296            if let Some(sess) = st.current.clone().and_then(|k| st.sessions.get_mut(&k)) {
1297                sess.trsz = true; // c:1102 ZFST_TRSZ
1298                sess.nosz = true; // c:1102 ZFST_NOSZ
1299            }
1300        }
1301        if let Some(byte_idx) = cur_last.find("bytes") {
1302            // c:1101 strstr
1303            // c:1104-1107 — walk backward to the start of the digit run.
1304            let prefix = &cur_last[..byte_idx];
1305            let trimmed: String = prefix
1306                .chars()
1307                .rev()
1308                .skip_while(|c| !c.is_ascii_digit())
1309                .take_while(|c| c.is_ascii_digit())
1310                .collect::<String>()
1311                .chars()
1312                .rev()
1313                .collect();
1314            if !trimmed.is_empty() {
1315                // c:1108 idigit(*ptr)
1316                // c:1109 — server DOES embed sizes; clear NOSZ.
1317                if let Ok(mut st) = zftp_state().lock() {
1318                    if let Some(sess) = st.current.clone().and_then(|k| st.sessions.get_mut(&k)) {
1319                        sess.nosz = false; // c:1109
1320                    }
1321                }
1322                if getsize != 0 {
1323                    // c:1110
1324                    zfsetparam("ZFTP_SIZE", &trimmed, ZFPM_READONLY | ZFPM_INTEGER);
1325                    // c:1112
1326                }
1327            }
1328        }
1329    }
1330
1331    // c:1118-1143 — PORT-mode accept handling. PASV: dfd is already
1332    // the data fd, just zfmovefd it. PORT: accept() on the listening
1333    // fd to obtain the real data fd, then close the listener.
1334    let dfd_raw: i32;
1335    if !is_passive {
1336        // c:1118
1337        // c:1124-1128 — accept the connection from the server.
1338        let mut sa: libc::sockaddr_storage = unsafe { std::mem::zeroed() };
1339        let mut sl: libc::socklen_t = size_of::<libc::sockaddr_storage>() as _;
1340        let listen_fd = zftp_state()
1341            .lock()
1342            .ok()
1343            .and_then(|s| {
1344                s.current
1345                    .as_deref()
1346                    .and_then(|k| s.sessions.get(k))
1347                    .map(|x| x.dfd)
1348            })
1349            .unwrap_or(-1);
1350        let newfd = unsafe { libc::accept(listen_fd, &mut sa as *mut _ as *mut _, &mut sl) };
1351        if newfd < 0 {
1352            // c:1129
1353            zwarnnam(
1354                name,
1355                &format!("unable to accept data: {}", io::Error::last_os_error()),
1356            );
1357            zfclosedata(); // c:1131
1358            return 1;
1359        }
1360        // c:1133 — close the original listening fd, install the accepted one.
1361        unsafe {
1362            libc::close(listen_fd);
1363        }
1364        dfd_raw = zfmovefd(newfd);
1365    } else {
1366        // c:1136
1367        // c:1139-1142 — zfmovefd(zfsess->dfd) for PASV.
1368        let cur_dfd = zftp_state()
1369            .lock()
1370            .ok()
1371            .and_then(|s| {
1372                s.current
1373                    .as_deref()
1374                    .and_then(|k| s.sessions.get(k))
1375                    .map(|x| x.dfd)
1376            })
1377            .unwrap_or(-1);
1378        dfd_raw = zfmovefd(cur_dfd);
1379    }
1380    if let Ok(mut state) = zftp_state().lock() {
1381        if let Some(sess) = state
1382            .current
1383            .clone()
1384            .and_then(|k| state.sessions.get_mut(&k))
1385        {
1386            sess.dfd = dfd_raw; // c:1142
1387        }
1388    }
1389
1390    // c:1156-1163 — SO_LINGER 120s.
1391    let li = libc::linger {
1392        l_onoff: 1,
1393        l_linger: 120,
1394    };
1395    unsafe {
1396        libc::setsockopt(
1397            dfd_raw,
1398            libc::SOL_SOCKET,
1399            libc::SO_LINGER,
1400            &li as *const _ as *const libc::c_void,
1401            size_of::<libc::linger>() as libc::socklen_t,
1402        );
1403    }
1404    // c:1167-1170 — IP_TOS = IPTOS_THROUGHPUT.
1405    let tos: libc::c_int = 0x08; // IPTOS_THROUGHPUT
1406    unsafe {
1407        libc::setsockopt(
1408            dfd_raw,
1409            libc::IPPROTO_IP,
1410            libc::IP_TOS,
1411            &tos as *const _ as *const libc::c_void,
1412            size_of::<libc::c_int>() as libc::socklen_t,
1413        );
1414    }
1415    // c:1174 — fcntl(dfd, F_SETFD, FD_CLOEXEC).
1416    unsafe {
1417        libc::fcntl(dfd_raw, libc::F_SETFD, libc::FD_CLOEXEC);
1418    }
1419
1420    0 // c:1177
1421}
1422
1423/// Port of `zfstats(char *fnam, int remote, off_t *retsize, char **retmdtm, int fd)` from `Src/Modules/zftp.c:1193`.
1424/// C: `static int zfstats(char *fnam, int remote, off_t *retsize, char **retmdtm, int fd)` —
1425/// query file size + mtime, remote via SIZE/MDTM commands or local
1426/// via stat(2)/fstat(2).
1427#[allow(non_snake_case)]
1428/// WARNING: param names don't match C — Rust=(fnam, remote, retmdtm, fd) vs C=(fnam, remote, retsize, retmdtm, fd)
1429pub fn zfstats(
1430    fnam: &str,
1431    remote: i32, // c:1193
1432    mut retsize: Option<&mut libc::off_t>,
1433    mut retmdtm: Option<&mut Option<String>>,
1434    fd: i32,
1435) -> i32 {
1436    // C's retsize/retmdtm are NULLABLE pointers — callers request only
1437    // what they want: zfgetput passes `zfstats(*args, recv, &sz, NULL,
1438    // 0)` (c:2580). A prior non-optional signature forced every caller
1439    // through BOTH probes: zfgetput's per-transfer stats fired an MDTM
1440    // round-trip the C never sends, and on an MDTM-less server the
1441    // c:1241 NOPE path returned 2 — failing the whole size lookup the
1442    // caller actually wanted.
1443    // c:1195-1197 — locals at fn top.
1444    let mut sz: libc::off_t = -1; // c:1195
1445    let mut mt: Option<String> = None; // c:1196 char *mt
1446    let ret: i32; // c:1197
1447
1448    if let Some(rs) = retsize.as_mut() {
1449        **rs = -1; // c:1199-1200
1450    }
1451    if let Some(rm) = retmdtm.as_mut() {
1452        **rm = None; // c:1201-1202
1453    }
1454
1455    if remote != 0 {
1456        // c:1203
1457        // c:1205-1207 — `if ((zfsess->has_size == ZFCP_NOPE && retsize) ||
1458        //                    (zfsess->has_mdtm == ZFCP_NOPE && retmdtm))
1459        //                    return 2;`
1460        // Early-out when the server's known to not support the
1461        // SPECIFIC probes this caller wants.
1462        let (had_size_nope, had_mdtm_nope) = {
1463            let st = match zftp_state().lock() {
1464                Ok(s) => s,
1465                Err(_) => return 1,
1466            };
1467            match st.current.as_deref().and_then(|k| st.sessions.get(k)) {
1468                Some(sess) => (sess.has_size == ZFCP_NOPE, sess.has_mdtm == ZFCP_NOPE),
1469                None => (false, false),
1470            }
1471        };
1472        if (had_size_nope && retsize.is_some()) || (had_mdtm_nope && retmdtm.is_some()) {
1473            return 2; // c:1207
1474        }
1475
1476        // c:1213 — `zfsettype(ZFST_TYPE(zfstatusp[zfsessno]));` — the
1477        // session's REQUESTED type slice, not a hardcoded IMAGE: SIZE
1478        // results are type-dependent (RFC 3659 — ASCII SIZE counts
1479        // post-translation bytes), so the probe must run under the
1480        // type the upcoming transfer will use.
1481        let req_type = zftp_state()
1482            .lock()
1483            .ok()
1484            .and_then(|s| {
1485                s.current
1486                    .as_deref()
1487                    .and_then(|k| s.sessions.get(k))
1488                    .map(|sess| ZFST_TYPE(sess.transfer_type))
1489            })
1490            .unwrap_or(ZFST_IMAG);
1491        zfsettype(req_type); // c:1213
1492
1493        if retsize.is_some() {
1494            // c:1214-1228 — SIZE command path.
1495            let cmd = format!("SIZE {}\r\n", fnam); // c:1215
1496            ret = zfsendcmd(&cmd); // c:1216
1497            if ret == 6 {
1498                // c:1218
1499                return 1; // c:1219
1500            }
1501            let code = lastcode.load(Ordering::Relaxed);
1502            if code < 300 {
1503                // c:1220
1504                // c:1221 — `sz = zstrtol(lastmsg, 0, 10);`
1505                sz = lastmsg
1506                    .lock()
1507                    .ok()
1508                    .map(|m| m.trim().parse::<libc::off_t>().unwrap_or(-1))
1509                    .unwrap_or(-1);
1510                // c:1222 — `zfsess->has_size = ZFCP_YUPP;`
1511                if let Ok(mut st) = zftp_state().lock() {
1512                    if let Some(sess) = st.current.clone().and_then(|k| st.sessions.get_mut(&k)) {
1513                        sess.has_size = ZFCP_YUPP;
1514                    }
1515                }
1516            } else if (500..=504).contains(&code) {
1517                // c:1223 — server doesn't speak SIZE.
1518                if let Ok(mut st) = zftp_state().lock() {
1519                    if let Some(sess) = st.current.clone().and_then(|k| st.sessions.get_mut(&k)) {
1520                        sess.has_size = ZFCP_NOPE; // c:1224
1521                    }
1522                }
1523                return 2; // c:1225
1524            } else if code == 550 {
1525                // c:1226 — file doesn't exist.
1526                return 1; // c:1227
1527            }
1528        }
1529
1530        if retmdtm.is_some() {
1531            // c:1231-1245 — MDTM command path.
1532            let cmd = format!("MDTM {}\r\n", fnam); // c:1232
1533            let ret2 = zfsendcmd(&cmd); // c:1233
1534            if ret2 == 6 {
1535                // c:1235
1536                return 1; // c:1236
1537            }
1538            let code = lastcode.load(Ordering::Relaxed);
1539            if code < 300 {
1540                // c:1237
1541                // c:1238 — `mt = ztrdup(lastmsg);`
1542                mt = lastmsg.lock().ok().map(|m| m.clone());
1543                // c:1239 — `zfsess->has_mdtm = ZFCP_YUPP;`
1544                if let Ok(mut st) = zftp_state().lock() {
1545                    if let Some(sess) = st.current.clone().and_then(|k| st.sessions.get_mut(&k)) {
1546                        sess.has_mdtm = ZFCP_YUPP;
1547                    }
1548                }
1549            } else if (500..=504).contains(&code) {
1550                // c:1240 — server doesn't speak MDTM.
1551                if let Ok(mut st) = zftp_state().lock() {
1552                    if let Some(sess) = st.current.clone().and_then(|k| st.sessions.get_mut(&k)) {
1553                        sess.has_mdtm = ZFCP_NOPE; // c:1241
1554                    }
1555                }
1556                return 2; // c:1242
1557            } else if code == 550 {
1558                // c:1243
1559                return 1; // c:1244
1560            }
1561        }
1562    } else {
1563        // c:1246
1564        // c:1248-1263 — local file: stat or fstat.
1565        let mut statbuf: libc::stat = unsafe { std::mem::zeroed() }; // c:1248
1566        let cn = std::ffi::CString::new(fnam).unwrap_or_default();
1567        let rc = if fd == -1 {
1568            // c:1252
1569            unsafe { libc::stat(cn.as_ptr(), &mut statbuf) }
1570        } else {
1571            unsafe { libc::fstat(fd, &mut statbuf) }
1572        };
1573        if rc < 0 {
1574            // c:1252
1575            return 1; // c:1253
1576        }
1577        sz = statbuf.st_size as libc::off_t; // c:1255
1578
1579        // c:1257-1263 — format mtime as YYYYMMDDHHMMSS via gmtime.
1580        let mtime = statbuf.st_mtime;
1581        let mut tmbuf = [0u8; 20];
1582        let tmbuf_len = unsafe {
1583            let mut tm: libc::tm = std::mem::zeroed();
1584            libc::gmtime_r(&mtime, &mut tm); // c:1259
1585                                             // c:1261 — ztrftime(tmbuf, 20, "%Y%m%d%H%M%S", tm, 0);
1586            let fmt = std::ffi::CString::new("%Y%m%d%H%M%S").unwrap();
1587            libc::strftime(
1588                tmbuf.as_mut_ptr() as *mut libc::c_char,
1589                20,
1590                fmt.as_ptr(),
1591                &tm,
1592            )
1593        };
1594        mt = std::str::from_utf8(&tmbuf[..tmbuf_len])
1595            .ok()
1596            .map(|s| s.to_string());
1597    }
1598
1599    if let Some(rs) = retsize.as_mut() {
1600        **rs = sz; // c:1265-1266
1601    }
1602    if let Some(rm) = retmdtm.as_mut() {
1603        **rm = mt; // c:1267-1268
1604    }
1605    0 // c:1269
1606}
1607
1608/// Port of `zfstarttrans(char *nam, int recv, off_t sz)` from `Src/Modules/zftp.c:1276`.
1609/// C: `static void zfstarttrans(char *nam, int recv, off_t sz)` — sets
1610/// the ZFTP_SIZE/ZFTP_FILE/ZFTP_TRANSFER/ZFTP_COUNT params.
1611#[allow(non_snake_case)]
1612pub fn zfstarttrans(nam: &str, recv: i32, sz: libc::off_t) {
1613    // c:1276
1614    let cnt: libc::off_t = 0; // c:1276
1615                              // c:1284-1285 — only set ZFTP_SIZE when sz > 0 (avoid lying about
1616                              // pipe-sourced unknown size).
1617    if sz > 0 {
1618        // c:1284
1619        zfsetparam("ZFTP_SIZE", &sz.to_string(), ZFPM_READONLY | ZFPM_INTEGER); // c:1285
1620    }
1621    zfsetparam("ZFTP_FILE", nam, ZFPM_READONLY); // c:1286
1622    zfsetparam(
1623        "ZFTP_TRANSFER", // c:1287
1624        if recv != 0 { "G" } else { "P" },
1625        ZFPM_READONLY,
1626    );
1627    zfsetparam("ZFTP_COUNT", &cnt.to_string(), ZFPM_READONLY | ZFPM_INTEGER); // c:1288
1628}
1629
1630/// Port of `zfendtrans()` from `Src/Modules/zftp.c:1295`.
1631/// C: `static void zfendtrans(void)` — unsets the ZFTP_* transfer params.
1632#[allow(non_snake_case)]
1633pub fn zfendtrans() {
1634    // c:1295
1635    zfunsetparam("ZFTP_SIZE"); // c:1295
1636    zfunsetparam("ZFTP_FILE"); // c:1298
1637    zfunsetparam("ZFTP_TRANSFER"); // c:1299
1638    zfunsetparam("ZFTP_COUNT"); // c:1300
1639}
1640
1641/// Port of `zfread(int fd, char *bf, off_t sz, int tmout)` from `Src/Modules/zftp.c:1307`.
1642/// C: `static int zfread(int fd, char *bf, off_t sz, int tmout)` — read
1643/// up to `sz` bytes from fd; with `tmout > 0` install a SIGALRM-driven
1644/// timeout that aborts the read.
1645#[allow(non_snake_case)]
1646pub fn zfread(fd: i32, bf: &mut [u8], sz: libc::off_t, tmout: i32) -> i32 {
1647    // c:1307
1648    let ret: isize; // c:1307 int ret
1649
1650    // c:1311-1312 — no timeout: plain read.
1651    if tmout == 0 {
1652        let n = unsafe { libc::read(fd, bf.as_mut_ptr() as *mut libc::c_void, sz as libc::size_t) };
1653        return n as i32; // c:1312
1654    }
1655
1656    // c:1314-1318 — `if (setjmp(zfalrmbuf)) { alarm(0); zwarnnam(...,
1657    //                "timeout on network read"); return -1; }`. C uses
1658    // setjmp/longjmp; the SIGALRM handler calls longjmp(zfalrmbuf, 1)
1659    // which unwinds back into this if-block. Rust port can't use
1660    // setjmp/longjmp (UB across drop boundaries) so models the same
1661    // semantic via the ZFDRRRRING atomic flag that zfhandler sets at
1662    // alarm fire. Two check sites needed:
1663    //   1. Before alarm install — handle "previous alarm fired between
1664    //      zfread calls" (rare but possible if user code chained reads
1665    //      with no intermediate zfalarm reset).
1666    //   2. After read returns — handle "alarm fired during THIS read,
1667    //      kernel returned EINTR but Rust has no setjmp jump-back".
1668    //      Prior port had only the first check; the second was the
1669    //      gap — a SIGALRM-interrupted read returned -1 silently with
1670    //      no "timeout on network read" warning, leaving the user
1671    //      unable to distinguish timeout from other I/O failure.
1672    if ZFDRRRRING.load(Ordering::Relaxed) != 0 {
1673        // c:1314 setjmp — stale alarm from before this call.
1674        unsafe {
1675            libc::alarm(0);
1676        } // c:1315
1677        zwarnnam("zftp", "timeout on network read"); // c:1316
1678        return -1; // c:1317
1679    }
1680    zfalarm(tmout); // c:1319
1681
1682    // c:1321 — `ret = read(fd, bf, sz);`
1683    ret = unsafe { libc::read(fd, bf.as_mut_ptr() as *mut libc::c_void, sz as libc::size_t) };
1684    // c:1324 — `alarm(0);`
1685    unsafe {
1686        libc::alarm(0);
1687    }
1688    // c:1314 setjmp counterpart for THIS call — alarm fired during read.
1689    // ZFDRRRRING was zeroed inside zfalarm at line 212; if non-zero now,
1690    // the handler set it during read.
1691    if ZFDRRRRING.load(Ordering::Relaxed) != 0 {
1692        zwarnnam("zftp", "timeout on network read"); // c:1316
1693        return -1; // c:1317
1694    }
1695    ret as i32 // c:1325
1696}
1697
1698/// Port of `zfwrite(int fd, char *bf, off_t sz, int tmout)` from Src/Modules/zftp.c:1332.
1699/// C: `int zfwrite(int fd, char *bf, off_t sz, int tmout)` — write with
1700/// optional alarm timeout.
1701#[allow(non_snake_case)]
1702pub fn zfwrite(fd: i32, bf: &[u8], sz: i64, tmout: i32) -> i32 {
1703    // c:1332
1704    // c:1332 — `if (!tmout) return write(fd, bf, sz);`
1705    if tmout == 0 {
1706        // c:1335
1707        return unsafe {
1708            libc::write(fd, bf.as_ptr() as *const _, sz as usize) as i32 // c:1336
1709        };
1710    }
1711    // c:1339-1342 — `if (setjmp(zfalrmbuf)) { alarm(0); zwarnnam(...,
1712    //                "timeout on network write"); return -1; }`.
1713    // Same setjmp pattern as zfread (c:1314-1318) — see the
1714    // accompanying zfread fix (cfe7560f58) for the ZFDRRRRING
1715    // adapter rationale. Two check sites:
1716    //   1. Before alarm install — stale alarm from before this call.
1717    //   2. After write returns — alarm fired during THIS write.
1718    // Prior port had neither: the "future refactor should plumb a
1719    // real timeout via select(2)/poll(2)" comment papered over the
1720    // missing semantic. ZFDRRRRING was zeroed inside zfalarm at
1721    // line 212; if non-zero post-write, zfhandler set it during the
1722    // blocked write.
1723    if ZFDRRRRING.load(Ordering::Relaxed) != 0 {
1724        // Stale alarm from earlier call.
1725        unsafe {
1726            libc::alarm(0);
1727        }
1728        zwarnnam("zftp", "timeout on network write");
1729        return -1;
1730    }
1731    zfalarm(tmout); // c:1344
1732    let ret = unsafe {
1733        libc::write(fd, bf.as_ptr() as *const _, sz as usize) as i32 // c:1346
1734    };
1735    unsafe {
1736        libc::alarm(0);
1737    } // c:1349
1738      // Post-write check matching the zfread fix.
1739    if ZFDRRRRING.load(Ordering::Relaxed) != 0 {
1740        zwarnnam("zftp", "timeout on network write"); // c:1341
1741        return -1; // c:1342
1742    }
1743    ret // c:1350
1744}
1745
1746/// Port of `static int zfread_eof` file-static from
1747/// `Src/Modules/zftp.c:1359`. Set by zfread_block when the ZFHD_EOFB
1748/// flag arrives; cleared at the top of every fresh transfer.
1749pub static zfread_eof: std::sync::atomic::AtomicI32 = // c:1359
1750    std::sync::atomic::AtomicI32::new(0);
1751
1752/// Port of `zfread_block(int fd, char *bf, off_t sz, int tmout)` from `Src/Modules/zftp.c:1359`.
1753/// C: `static int zfread_block(int fd, char *bf, off_t sz, int tmout)` —
1754/// read a block-mode framed record: a 3-byte zfheader followed by
1755/// `blksz` payload bytes. Loops over restart-marker blocks (ZFHD_MARK)
1756/// until a real data block or end-of-record (ZFHD_EOFB) arrives.
1757#[allow(non_snake_case)]
1758pub fn zfread_block(fd: i32, bf: &mut [u8], sz: libc::off_t, tmout: i32) -> i32 {
1759    // c:1359
1760    // c:1361-1364 — locals at fn top.
1761    let mut n: i32; // c:1361 int n
1762    let mut hdr = zfheader {
1763        flags: 0,
1764        bytes: [0u8; 2],
1765    }; // c:1362
1766    let mut blksz: libc::off_t = 0; // c:1363 off_t blksz
1767    let mut cnt: libc::off_t; // c:1363 off_t cnt
1768    let mut bfptr: usize; // c:1364 char *bfptr (offset into bf)
1769
1770    // c:1365-1403 — outer do-while loop: keep reading until we get a
1771    // non-marker block (or hit EOF).
1772    loop {
1773        // c:1365 do {
1774        // c:1367-1369 — read header bytes, retry on EINTR.
1775        let mut hdr_buf = [0u8; 3];
1776        loop {
1777            // c:1367 do
1778            n = zfread(fd, &mut hdr_buf, 3, tmout); // c:1368
1779            if !(n < 0
1780                && io::Error::last_os_error().raw_os_error()      // c:1369 EINTR retry
1781                 == Some(libc::EINTR))
1782            {
1783                break;
1784            }
1785        }
1786        // c:1370-1373 — short read → fail unless interrupted by SIGALRM.
1787        if n != 3 && ZFDRRRRING.load(Ordering::Relaxed) == 0 {
1788            zwarnnam("zftp", "failure reading FTP block header");
1789            return n; // c:1372
1790        }
1791        hdr.flags = hdr_buf[0] as i8;
1792        hdr.bytes[0] = hdr_buf[1];
1793        hdr.bytes[1] = hdr_buf[2];
1794        // c:1375-1376 — ZFHD_EOFB sets the file-static eof flag.
1795        if (hdr.flags as i32 & ZFHD_EOFB) != 0 {
1796            zfread_eof.store(1, Ordering::Relaxed); // c:1376
1797        }
1798        // c:1377 — network byte order: blksz = (b[0] << 8) | b[1].
1799        blksz = ((hdr.bytes[0] as libc::off_t) << 8) | (hdr.bytes[1] as libc::off_t);
1800        // c:1378-1385 — caller's buffer too small.
1801        if blksz > sz {
1802            zwarnnam("zftp", "block too large to handle");
1803            unsafe {
1804                *errno_ptr() = libc::EIO;
1805            } // c:1383
1806            return -1; // c:1384
1807        }
1808        // c:1386-1397 — drain the payload.
1809        bfptr = 0; // c:1386 bfptr = bf
1810        cnt = blksz; // c:1387
1811        while cnt > 0 {
1812            // c:1388
1813            let want = cnt as usize;
1814            let end = bfptr + want;
1815            if end > bf.len() {
1816                return -1;
1817            }
1818            n = zfread(fd, &mut bf[bfptr..end], cnt, tmout); // c:1389
1819            if n > 0 {
1820                // c:1390
1821                bfptr += n as usize; // c:1391
1822                cnt -= n as libc::off_t; // c:1392
1823            } else if n < 0
1824                && (errflag.load(Ordering::Relaxed) != 0
1825                    || ZFDRRRRING.load(Ordering::Relaxed) != 0
1826                    || io::Error::last_os_error().raw_os_error() != Some(libc::EINTR))
1827            {
1828                // c:1393
1829                return n; // c:1394
1830            } else {
1831                break; // c:1396
1832            }
1833        }
1834        // c:1398-1402 — short data block.
1835        if cnt != 0 {
1836            zwarnnam("zftp", "short data block");
1837            unsafe {
1838                *errno_ptr() = libc::EIO;
1839            } // c:1400
1840            return -1; // c:1401
1841        }
1842        // c:1403 — } while ((hdr.flags & ZFHD_MARK) && !zfread_eof);
1843        if !((hdr.flags as i32 & ZFHD_MARK) != 0 && zfread_eof.load(Ordering::Relaxed) == 0) {
1844            break;
1845        }
1846    }
1847    // c:1404 — return (hdr.flags & ZFHD_MARK) ? 0 : blksz;
1848    if (hdr.flags as i32 & ZFHD_MARK) != 0 {
1849        0
1850    } else {
1851        blksz as i32
1852    }
1853}
1854
1855/// Port of `zfwrite_block(int fd, char *bf, off_t sz, int tmout)` from Src/Modules/zftp.c:1411.
1856/// C: `int zfwrite_block(int fd, char *bf, off_t sz, int tmout)` —
1857/// frame the data with a `struct zfheader` and write block + payload.
1858#[allow(non_snake_case)]
1859pub fn zfwrite_block(fd: i32, bf: &[u8], sz: i64, tmout: i32) -> i32 {
1860    // c:1411
1861    let mut hdr = zfheader {
1862        bytes: [0u8; 2],
1863        flags: 0i8,
1864    }; // c:1411
1865    let mut n: i32;
1866    // c:1418-1424 — emit header, retry on EINTR.
1867    loop {
1868        hdr.bytes[0] = ((sz & 0xff00) >> 8) as u8; // c:1419
1869        hdr.bytes[1] = (sz & 0xff) as u8; // c:1420
1870        hdr.flags = if sz != 0 { 0i8 } else { ZFHD_EOFB as i8 }; // c:1421
1871        let hdr_bytes = unsafe { std::slice::from_raw_parts(&hdr as *const _ as *const u8, 3) };
1872        n = zfwrite(fd, hdr_bytes, 3, tmout); // c:1422
1873        if !(n < 0 && unsafe { *errno_ptr() } == libc::EINTR) {
1874            break;
1875        } // c:1424
1876    }
1877    if n != 3 {
1878        // c:1426
1879        return n; // c:1428
1880    }
1881    if sz != 0 {
1882        // c:1431
1883        n = zfwrite(fd, bf, sz, tmout); // c:1432
1884    }
1885    n // c:1434
1886}
1887
1888/// Port of `zfsenddata(char *name, int recv, int progress, off_t startat)` from `Src/Modules/zftp.c:1456`.
1889/// C: `static int zfsenddata(char *name, int recv, int progress, off_t startat)` —
1890/// move data between local fd (0/1) and the data connection fd
1891/// (`dfd`). Handles BINARY+ASCII mode, optional block-mode framing,
1892/// progress callback, and the abort/SYNCH sequence on error.
1893#[allow(non_snake_case)]
1894pub fn zfsenddata(name: &str, recv: i32, progress: i32, startat: libc::off_t) -> i32 {
1895    // c:1456
1896    // c:1458-1459 — buffer sizes.
1897    const ZF_BUFSIZE: usize = 32768;
1898    const ZF_ASCSIZE: usize = ZF_BUFSIZE / 2;
1899    // c:1461-1466 — locals at fn top.
1900    let mut n: i32; // c:1461 int n
1901    let mut ret: i32 = 0; // c:1461 ret = 0
1902    let gotack: i32 = 0; // c:1461 gotack = 0
1903    let fdin: i32; // c:1461
1904    let fdout: i32; // c:1461
1905    let mut fromasc: i32 = 0; // c:1461 fromasc = 0
1906    let mut toasc: i32 = 0; // c:1461 toasc = 0
1907    let mut rtmout: i32 = 0; // c:1462
1908    let mut wtmout: i32 = 0; // c:1462
1909    let mut lsbuf = vec![0u8; ZF_BUFSIZE]; // c:1463
1910    let mut ascbuf: Vec<u8> = Vec::new(); // c:1463 ascbuf = NULL
1911    let mut sofar: libc::off_t = 0; // c:1464
1912    let mut last_sofar: libc::off_t = 0; // c:1464
1913
1914    // c:1469-1481 — pre-transfer progress hook. Fires once at the
1915    // top of zfsenddata when ZFTP_COUNT starts at zero so the user
1916    // shfunc can set up its meter. After it runs, we seed `sofar`
1917    // with `startat` (resume-from offset) per c:1480.
1918    if progress != 0 {
1919        if let Some(mut shfunc) = getshfunc("zftp_progress") {
1920            // c:1473 — `osc = sfcontext;`
1921            let osc = SFCONTEXT.load(Ordering::Relaxed);
1922            SFCONTEXT.store(SFC_HOOK, Ordering::Relaxed); // c:1475
1923                                                          // c:1477 — `doshfunc(shfunc, NULL, 1);`.
1924            let body_runner = || -> i32 {
1925                crate::ported::exec::run_function_body("zftp_progress", &[]).unwrap_or(0)
1926            };
1927            let _ = crate::ported::exec::doshfunc(
1928                &mut shfunc,
1929                vec!["zftp_progress".to_string()],
1930                true,
1931                body_runner,
1932            );
1933            SFCONTEXT.store(osc, Ordering::Relaxed); // c:1478
1934                                                     // c:1480 — `sofar = last_sofar = startat;`.
1935            sofar = startat;
1936            last_sofar = startat;
1937        }
1938    }
1939
1940    // c:1482-1498 — direction-dependent fd + ascii-flag setup.
1941    let mut use_block_mode = false;
1942    {
1943        let state = match zftp_state().lock() {
1944            Ok(s) => s,
1945            Err(_) => return 1,
1946        };
1947        let sess = match state.current.as_deref().and_then(|k| state.sessions.get(k)) {
1948            Some(s) => s,
1949            None => return 1,
1950        };
1951        if recv != 0 {
1952            // c:1482
1953            fdin = sess.dfd; // c:1483
1954            fdout = 1; // c:1484
1955                       // c:1485 — `rtmout = getiparam("ZFTP_TMOUT");`. paramtab read.
1956            rtmout = getiparam("ZFTP_TMOUT") as i32;
1957            // c:1486 — `if (ZFST_CTYP(zfstatusp[zfsessno]) == ZFST_ASCI)`
1958            // CTYP is the type the SERVER currently has (current_type),
1959            // not the user-requested ZFST_TYPE slice. zfsettype sends
1960            // TYPE lazily (c:1213/c:2559) and only updates CTYP on a
1961            // successful response — translating on the requested slice
1962            // would \r\n-decode a stream the server sent as IMAGE.
1963            if sess.current_type == ZFST_ASCI as i32 {
1964                // c:1486
1965                fromasc = 1; // c:1487
1966            }
1967            if sess.transfer_mode == ZFST_BLOC as i32 {
1968                // c:1488
1969                use_block_mode = true; // c:1489
1970            }
1971        } else {
1972            // c:1490
1973            fdin = 0; // c:1491
1974            fdout = sess.dfd; // c:1492
1975                              // c:1493 — `wtmout = getiparam("ZFTP_TMOUT");`. paramtab read.
1976            wtmout = getiparam("ZFTP_TMOUT") as i32;
1977            // c:1494 — `if (ZFST_CTYP(...) == ZFST_ASCI)` — same CTYP
1978            // (server-side current type) read as the recv arm above.
1979            if sess.current_type == ZFST_ASCI as i32 {
1980                // c:1494
1981                toasc = 1; // c:1495
1982            }
1983            if sess.transfer_mode == ZFST_BLOC as i32 {
1984                // c:1496
1985                use_block_mode = true; // c:1497
1986            }
1987        }
1988    }
1989
1990    if progress != 0 {
1991        sofar = startat; // c:1480
1992        last_sofar = sofar;
1993    }
1994    let _ = last_sofar;
1995
1996    // c:1500-1501 — ascbuf for ASCII translation buffer.
1997    if toasc != 0 {
1998        ascbuf = vec![0u8; ZF_ASCSIZE]; // c:1501
1999    }
2000    zfpipe(); // c:1502
2001    zfread_eof.store(0, Ordering::Relaxed); // c:1503
2002
2003    // c:1504-1614 — main transfer loop.
2004    while ret == 0 && zfread_eof.load(Ordering::Relaxed) == 0 {
2005        // c:1505-1506 — read into either ascbuf or lsbuf.
2006        n = if toasc != 0 {
2007            if use_block_mode {
2008                zfread_block(fdin, &mut ascbuf, ZF_ASCSIZE as libc::off_t, rtmout)
2009            } else {
2010                zfread(fdin, &mut ascbuf, ZF_ASCSIZE as libc::off_t, rtmout)
2011            }
2012        } else if use_block_mode {
2013            zfread_block(fdin, &mut lsbuf, ZF_BUFSIZE as libc::off_t, rtmout)
2014        } else {
2015            zfread(fdin, &mut lsbuf, ZF_BUFSIZE as libc::off_t, rtmout)
2016        };
2017
2018        if n > 0 {
2019            // c:1507
2020            // c:1509-1520 — toasc: \n → \r\n.
2021            if toasc != 0 {
2022                let mut iptr = 0usize;
2023                let mut optr = 0usize;
2024                let mut cnt = n;
2025                while cnt > 0 {
2026                    if ascbuf[iptr] == b'\n' {
2027                        // c:1514
2028                        if optr < lsbuf.len() {
2029                            lsbuf[optr] = b'\r';
2030                            optr += 1;
2031                        }
2032                        n += 1; // c:1516
2033                    }
2034                    if optr < lsbuf.len() {
2035                        lsbuf[optr] = ascbuf[iptr];
2036                        optr += 1;
2037                    }
2038                    iptr += 1;
2039                    cnt -= 1;
2040                }
2041            }
2042            // c:1521-1532 — fromasc: \r\n → \n.
2043            if fromasc != 0 {
2044                if let Some(_start) = lsbuf[..n as usize].iter().position(|&b| b == b'\r') {
2045                    let mut optr = 0usize;
2046                    let mut iptr = 0usize;
2047                    let len = n as usize;
2048                    while iptr < len {
2049                        if lsbuf[iptr] != b'\r' || iptr + 1 >= len || lsbuf[iptr + 1] != b'\n' {
2050                            lsbuf[optr] = lsbuf[iptr];
2051                            optr += 1;
2052                        } else {
2053                            n -= 1; // c:1529
2054                        }
2055                        iptr += 1;
2056                    }
2057                }
2058            }
2059            // c:1533-1591 — write loop with EINTR + partial-write handling.
2060            let mut optr_off: usize = 0;
2061            sofar += n as libc::off_t; // c:1535
2062            loop {
2063                // c:1537 for(;;)
2064                let chunk = &lsbuf[optr_off..optr_off + n as usize];
2065                let newn: i32 = if use_block_mode && recv == 0 {
2066                    zfwrite_block(fdout, chunk, n as libc::off_t, wtmout)
2067                } else {
2068                    zfwrite(fdout, chunk, n as libc::off_t, wtmout)
2069                };
2070                if newn == n {
2071                    break;
2072                } // c:1546
2073                if newn < 0 {
2074                    // c:1548
2075                    let errno = io::Error::last_os_error().raw_os_error();
2076                    let drrr = ZFDRRRRING.load(Ordering::Relaxed) != 0;
2077                    let efl = errflag.load(Ordering::Relaxed) != 0;
2078                    if errno != Some(libc::EINTR) || efl || drrr {
2079                        // c:1578
2080                        if !drrr && (efl || errno != Some(libc::EPIPE)) {
2081                            // c:1579-1580
2082                            ret = if recv != 0 { 2 } else { 1 };
2083                            zwarnnam(
2084                                name, // c:1582
2085                                &format!("write failed: {}", io::Error::last_os_error()),
2086                            );
2087                        } else {
2088                            ret = if recv != 0 { 3 } else { 1 };
2089                        }
2090                        break;
2091                    }
2092                    continue; // c:1587
2093                }
2094                optr_off += newn as usize; // c:1589
2095                n -= newn; // c:1590
2096            }
2097        } else if n < 0 {
2098            // c:1592
2099            let errno = io::Error::last_os_error().raw_os_error();
2100            let drrr = ZFDRRRRING.load(Ordering::Relaxed) != 0;
2101            let efl = errflag.load(Ordering::Relaxed) != 0;
2102            if errno != Some(libc::EINTR) || efl || drrr {
2103                // c:1593
2104                if !drrr && (efl || errno != Some(libc::EPIPE)) {
2105                    // c:1594
2106                    ret = if recv != 0 { 1 } else { 2 };
2107                    zwarnnam(
2108                        name, // c:1597
2109                        &format!("read failed: {}", io::Error::last_os_error()),
2110                    );
2111                } else {
2112                    ret = if recv != 0 { 1 } else { 3 };
2113                }
2114                break;
2115            }
2116        } else {
2117            // c:1602
2118            break; // c:1603
2119        }
2120        // c:1604-1613 — progress hook (zftp_progress shfunc dispatch):
2121        //
2122        //     if (!ret && sofar != last_sofar && progress &&
2123        //         (shfunc = getshfunc("zftp_progress"))) {
2124        //         int osc = sfcontext;
2125        //
2126        //         zfsetparam("ZFTP_COUNT", &sofar, ZFPM_READONLY|ZFPM_INTEGER);
2127        //         sfcontext = SFC_HOOK;
2128        //         doshfunc(shfunc, NULL, 1);
2129        //         sfcontext = osc;
2130        //         last_sofar = sofar;
2131        //     }
2132        //
2133        // The hook lookup is part of the GATE — with no zftp_progress
2134        // function defined, C touches NOTHING: no ZFTP_COUNT update,
2135        // no last_sofar bump. The mid-transfer ZFTP_COUNT param exists
2136        // solely as the hook's input. A prior else-arm here updated
2137        // ZFTP_COUNT on every chunk regardless (param churn the C
2138        // never does) and bumped last_sofar outside the gate.
2139        if ret == 0 && sofar != last_sofar && progress != 0 {
2140            if let Some(mut shfunc) = getshfunc("zftp_progress") {
2141                // c:1605
2142                let osc = SFCONTEXT.load(Ordering::Relaxed); // c:1606
2143                zfsetparam(
2144                    "ZFTP_COUNT",
2145                    &sofar.to_string(),
2146                    ZFPM_READONLY | ZFPM_INTEGER,
2147                ); // c:1608
2148                SFCONTEXT.store(SFC_HOOK, Ordering::Relaxed); // c:1609
2149                                                              // c:1610 — `doshfunc(shfunc, NULL, 1);`. NULL doshargs
2150                                                              // → argv = [fn-name only]; body_runner routes through
2151                                                              // the host body-only entry.
2152                let body_runner = || -> i32 {
2153                    crate::ported::exec::run_function_body("zftp_progress", &[]).unwrap_or(0)
2154                };
2155                let _ = crate::ported::exec::doshfunc(
2156                    &mut shfunc,
2157                    vec!["zftp_progress".to_string()],
2158                    true,
2159                    body_runner,
2160                );
2161                SFCONTEXT.store(osc, Ordering::Relaxed); // c:1611
2162                last_sofar = sofar; // c:1612
2163            }
2164        }
2165    }
2166    zfunpipe(); // c:1615
2167    ZFDRRRRING.store(0, Ordering::Relaxed); // c:1620
2168
2169    // c:1621-1625 — block-mode EOF marker on send completion.
2170    if errflag.load(Ordering::Relaxed) == 0 && ret == 0 && recv == 0 && use_block_mode {
2171        let eof_buf = [0u8; 1];
2172        if zfwrite_block(fdout, &eof_buf, 0, wtmout) < 0 {
2173            ret = 1; // c:1624
2174        }
2175    }
2176
2177    // c:1626-1676 — abort/SYNCH sequence on error.
2178    if errflag.load(Ordering::Relaxed) != 0 || ret > 1 {
2179        // c:1642 — IAC=255, IP=244, SYNCH=242 per Telnet RFC 854.
2180        let msg: [u8; 4] = [255, 244, 255, 242]; // c:1642
2181        if ret == 2 {
2182            // c:1644
2183            zwarnnam(name, "aborting data transfer..."); // c:1645
2184        }
2185        // c:1647 — holdintr(); block SIGINT around the abort handshake.
2186        crate::ported::signals::holdintr(); // c:1647
2187                                            // c:1651-1652 — send IAC IP IAC + SYNCH OOB on control connection.
2188        if let Ok(state) = zftp_state().lock() {
2189            if let Some(sess) = state.current.as_deref().and_then(|k| state.sessions.get(k)) {
2190                if let Some(ref ctrl) = sess.control {
2191                    let cfd = ctrl.as_raw_fd();
2192                    unsafe {
2193                        libc::send(cfd, msg.as_ptr() as *const libc::c_void, 3, 0); // c:1651
2194                        libc::send(
2195                            cfd,
2196                            msg[3..].as_ptr() as *const libc::c_void,
2197                            1,
2198                            libc::MSG_OOB,
2199                        ); // c:1652
2200                    }
2201                }
2202            }
2203        }
2204        zfsendcmd("ABOR\r\n"); // c:1654
2205        if lastcode.load(Ordering::Relaxed) != 226 {
2206            // c:1672
2207            ret = 1; // c:1673
2208        }
2209        // c:1675 — noholdintr(); restore SIGINT handling.
2210        crate::ported::signals::noholdintr(); // c:1675
2211    }
2212
2213    // c:1678-1679 — free ascbuf (Rust Drop).
2214    drop(ascbuf);
2215    zfclosedata(); // c:1680
2216    if gotack == 0 && zfgetmsg() > 2 {
2217        // c:1681
2218        ret = 1; // c:1682
2219    }
2220    if ret != 0 {
2221        1
2222    } else {
2223        0
2224    } // c:1683
2225}
2226
2227// =====================================================================
2228// `enum { ZFST_* }` from `Src/Modules/zftp.c` — bit-packed shared-fd
2229// status word used by the `zfstatfd` mechanism so a subshell can
2230// detect type/mode/connection changes in the parent shell.
2231// =====================================================================
2232
2233/// `ZF_BUFSIZE` from `Src/Modules/zftp.c:1458`. Default I/O block
2234/// size for the zftp byte-stream pump.
2235pub const ZF_BUFSIZE: usize = 32_768; // c:1458
2236
2237/// `ZF_ASCSIZE` from `Src/Modules/zftp.c:1459`.
2238/// `#define ZF_ASCSIZE (ZF_BUFSIZE/2)`. Smaller buffer for ASCII
2239/// mode (line-by-line CRLF translation can grow output up to 2x).
2240pub const ZF_ASCSIZE: usize = ZF_BUFSIZE / 2; // c:1459
2241
2242// Subcommand dispatch table for zftp. Each `zftp_<subcmd>` C function
2243// has the canonical signature `int zftp_<subcmd>(char *name, char **args, int flags)`.
2244// The C source parses the first argv element as the subcommand name
2245// and dispatches via `zftpcmdtab[]`. Rust port: each free fn matches
2246// the C signature and routes through the global `ZFTP_STATE` to call
2247// the corresponding `zftp_globals::<method>` on the live state.
2248
2249/// Port of `zftp_open(char *name, char **args, int flags)` from `Src/Modules/zftp.c:1690`.
2250/// C: `int zftp_open(char *name, char **args, int flags)` — opens a
2251/// TCP control connection (with optional `host[:port]` or IPv6
2252/// `[host]:port` syntax, falls back to `zfsess->userparams` when no
2253/// args, reads the 220 banner via `zfgetmsg()`, sets
2254/// ZFTP_HOST/PORT/IP/MODE params, and chains to `zftp_login()` when
2255/// extra args are present.
2256pub fn zftp_open(name: &str, args: &[&str], flags: i32) -> i32 {
2257    // c:1690
2258    let mut port: i32 = -1; // c:1698 port = -1
2259    let portnam: String; // c:1695 portnam = "ftp"
2260    let hostnam: String; // c:1696 hostnam
2261    let hostsuffix: String; // c:1696 hostsuffix
2262    let tmout: i32; // c:1697 tmout
2263
2264    // c:1701-1708 — fall back to userparams when no positional args.
2265    let mut effective: Vec<String> = args.iter().map(|s| s.to_string()).collect();
2266    if effective.is_empty() {
2267        // c:1701 !*args
2268        let up = zftp_state()
2269            .lock()
2270            .ok()
2271            .and_then(|s| {
2272                s.current
2273                    .as_deref()
2274                    .and_then(|k| s.sessions.get(k))
2275                    .map(|sess| sess.userparams.clone())
2276            })
2277            .unwrap_or_default();
2278        if !up.is_empty() {
2279            effective = up; // c:1703 args = userparams
2280        } else {
2281            zwarnnam(name, "no host specified"); // c:1705
2282            return 1; // c:1706
2283        }
2284    }
2285
2286    // c:1715-1716 — close any existing connection.
2287    let already_open = zftp_state()
2288        .lock()
2289        .ok()
2290        .and_then(|s| {
2291            s.current
2292                .as_deref()
2293                .and_then(|k| s.sessions.get(k))
2294                .map(|sess| sess.control.is_some())
2295        })
2296        .unwrap_or(false);
2297    if already_open {
2298        // c:1715
2299        zfclose(0); // c:1716
2300    }
2301
2302    // c:1718 — dupstring(args[0]) — Rust String owns its bytes.
2303    let raw = effective[0].clone();
2304    // c:1720-1730 — IPv6 `[host]:port` bracket parse.
2305    if let Some(stripped) = raw.strip_prefix('[') {
2306        let close_idx = match stripped.find(']') {
2307            Some(i) => i,
2308            None => {
2309                zwarnnam(name, &format!("Invalid host format: {}", raw)); // c:1726
2310                return 1; // c:1727
2311            }
2312        };
2313        let after = &stripped[close_idx + 1..];
2314        if !after.is_empty() && !after.starts_with(':') {
2315            // c:1725
2316            zwarnnam(name, &format!("Invalid host format: {}", raw));
2317            return 1;
2318        }
2319        hostnam = stripped[..close_idx].to_string(); // c:1722
2320        hostsuffix = after.to_string(); // c:1729
2321    } else {
2322        hostnam = raw.clone();
2323        hostsuffix = raw; // c:1733 else branch
2324    }
2325
2326    // c:1735-1751 — :port suffix; numeric → htons-friendly, else look up.
2327    if let Some((_pre, post)) = hostsuffix.split_once(':') {
2328        // c:1735
2329        let trimmed = post.trim();
2330        match trimmed.parse::<i32>() {
2331            // c:1740 zstrtol
2332            Ok(p) => {
2333                // c:1750 numeric
2334                port = p;
2335                portnam = "ftp".to_string();
2336            }
2337            Err(_) => {
2338                // c:1744 non-numeric
2339                portnam = trimmed.to_string(); // c:1745
2340                port = -1; // c:1746
2341            }
2342        }
2343    } else {
2344        portnam = "ftp".to_string();
2345    }
2346
2347    // c:1755-1758 — protoent/servent lookups: skipped in Rust port
2348    // (TcpStream handles tcp directly). Port resolution falls back to
2349    // the standard /etc/services for non-numeric ports.
2350    let resolved_port: u16 = if port > 0 {
2351        port as u16
2352    } else {
2353        match portnam.as_str() {
2354            "ftp" => 21,
2355            "ftps" => 990,
2356            _ => {
2357                zwarnnam(name, &format!("Can't find port for service `{}'", portnam)); // c:1768
2358                return 1; // c:1769
2359            }
2360        }
2361    };
2362
2363    ZCFINISH.store(2, Ordering::Relaxed); // c:1772 zcfinish = 2
2364
2365    // c:1775 — `tmout = getiparam("ZFTP_TMOUT");`. Read paramtab; if
2366    //          unset, fall back to 60-second connect timeout.
2367    tmout = {
2368        let v = getiparam("ZFTP_TMOUT") as i32;
2369        if v > 0 {
2370            v
2371        } else {
2372            60
2373        }
2374    };
2375
2376    // c:1778-1789 — `if (setjmp(zfalrmbuf)) { alarm(0);
2377    //                  queue_signals();
2378    //                  if ((hname = getsparam_u("ZFTP_HOST")) && *hname)
2379    //                      zwarnnam(name, "timeout connecting to %s", hname);
2380    //                  else
2381    //                      zwarnnam(name, "timeout on host name lookup");
2382    //                  unqueue_signals();
2383    //                  zfclose(0);
2384    //                  return 1;
2385    //               }`
2386    // ZFDRRRRING adapter same as the other zftp setjmp ports (cfe7560f58,
2387    // 00c1c36dc9, bef84af815, f68bff9298). The diagnostic is host-aware:
2388    // if ZFTP_HOST was already set by a prior open call (re-open after
2389    // previous timeout), the message names the host; otherwise it
2390    // reports "timeout on host name lookup" since the resolve hasn't
2391    // completed yet.
2392    if ZFDRRRRING.load(Ordering::Relaxed) != 0 {
2393        unsafe {
2394            libc::alarm(0);
2395        }
2396        let hname = crate::ported::params::getsparam_u("ZFTP_HOST").unwrap_or_default();
2397        if !hname.is_empty() {
2398            zwarnnam(name, &format!("timeout connecting to {}", hname));
2399        } else {
2400            zwarnnam(name, "timeout on host name lookup");
2401        }
2402        zfclose(0); // c:1787
2403        return 1; // c:1788
2404    }
2405    // c:1790 — zfalarm(tmout): installed for the connect() phase.
2406    zfalarm(tmout);
2407
2408    // c:1803 — zsh_getipnodebyname → ToSocketAddrs resolves both v4+v6.
2409    let target = format!("{}:{}", hostnam, resolved_port);
2410    let addrs: Vec<std::net::SocketAddr> = match target.to_socket_addrs() {
2411        Ok(it) => it.collect(),
2412        Err(_) => {
2413            unsafe {
2414                libc::alarm(0);
2415            }
2416            zwarnnam(name, &format!("host not found: {}", hostnam)); // c:1815
2417            return 1; // c:1817
2418        }
2419    };
2420    if addrs.is_empty() {
2421        unsafe {
2422            libc::alarm(0);
2423        }
2424        zwarnnam(name, &format!("host not found: {}", hostnam));
2425        return 1;
2426    }
2427    // c:1818 — ZFTP_HOST.
2428    zfsetparam("ZFTP_HOST", &hostnam, ZFPM_READONLY); // c:1818
2429                                                      // c:1824 — ZFTP_PORT as integer.
2430    zfsetparam(
2431        "ZFTP_PORT",
2432        &resolved_port.to_string(),
2433        ZFPM_READONLY | ZFPM_INTEGER,
2434    ); // c:1824
2435
2436    // c:1838 — tcp_socket + tcp_connect: connect_timeout loops addrs.
2437    let mut last_err: Option<io::Error> = None;
2438    let mut connected: Option<(TcpStream, std::net::SocketAddr)> = None;
2439    for addr in addrs.iter() {
2440        // c:1860 for addrp
2441        if errflag.load(Ordering::Relaxed) != 0 {
2442            break;
2443        }
2444        match TcpStream::connect_timeout(addr, Duration::from_secs(tmout.max(1) as u64)) {
2445            Ok(s) => {
2446                connected = Some((s, *addr));
2447                break;
2448            } // c:1867 SUCCEEDED
2449            Err(e) => {
2450                last_err = Some(e);
2451            } // c:1866 retry
2452        }
2453    }
2454    let (stream, used_addr) = match connected {
2455        Some(v) => v,
2456        None => {
2457            unsafe {
2458                libc::alarm(0);
2459            }
2460            zfunsetparam("ZFTP_HOST"); // c:1847
2461            zfunsetparam("ZFTP_PORT"); // c:1848
2462            let msg = last_err
2463                .as_ref()
2464                .map(|e| e.to_string())
2465                .unwrap_or_else(|| "connect failed".to_string());
2466            zwarnnam(name, &format!("connect failed: {}", msg)); // c:1879
2467            return 1; // c:1880
2468        }
2469    };
2470
2471    // c:1886-1890 — record peer IP.
2472    let pbuf = used_addr.ip().to_string();
2473    zfsetparam("ZFTP_IP", &pbuf, ZFPM_READONLY); // c:1887
2474
2475    unsafe {
2476        libc::alarm(0);
2477    } // c:1882
2478
2479    // c:1778 setjmp counterpart for THIS call — alarm fired during
2480    // resolve or connect. ZFDRRRRING was zeroed inside zfalarm; non-
2481    // zero now means zfhandler tripped during the blocked
2482    // to_socket_addrs or TcpStream::connect_timeout. Without this
2483    // check, a SIGALRM-cut-short connect that happened to succeed
2484    // (or appeared to via Rust's connect-timeout returning Ok on a
2485    // partial handshake) would proceed into the FTP-login phase
2486    // with a half-formed connection — confusing diagnostics later
2487    // instead of the C-faithful "timeout connecting to %s" up front.
2488    if ZFDRRRRING.load(Ordering::Relaxed) != 0 {
2489        let hname = crate::ported::params::getsparam_u("ZFTP_HOST").unwrap_or_default();
2490        if !hname.is_empty() {
2491            zwarnnam(name, &format!("timeout connecting to {}", hname));
2492        } else {
2493            zwarnnam(name, "timeout on host name lookup");
2494        }
2495        zfclose(0); // c:1787
2496        return 1; // c:1788
2497    }
2498
2499    ZFNOPEN.fetch_add(1, Ordering::Relaxed); // c:1852 zfnopen++
2500
2501    // c:1888 — zcfinish = 0 (we can now talk).
2502    ZCFINISH.store(0, Ordering::Relaxed); // c:1894
2503
2504    // c:1903-1904 — F_SETFD/FD_CLOEXEC on the fd.
2505    let fd = stream.as_raw_fd();
2506    unsafe {
2507        libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC);
2508    }
2509
2510    // c:1912-1914 — SO_OOBINLINE: in-line OOB data for control conn.
2511    unsafe {
2512        let one: libc::c_int = 1;
2513        libc::setsockopt(
2514            fd,
2515            libc::SOL_SOCKET,
2516            libc::SO_OOBINLINE,
2517            &one as *const _ as *const _,
2518            size_of::<libc::c_int>() as libc::socklen_t,
2519        );
2520    }
2521    // c:1917-1919 — IP_TOS / IPTOS_LOWDELAY for control.
2522    #[cfg(any(target_os = "linux", target_os = "macos"))]
2523    unsafe {
2524        let lowdelay: libc::c_int = 0x10; // IPTOS_LOWDELAY
2525        libc::setsockopt(
2526            fd,
2527            libc::IPPROTO_IP,
2528            libc::IP_TOS,
2529            &lowdelay as *const _ as *const _,
2530            size_of::<libc::c_int>() as libc::socklen_t,
2531        );
2532    }
2533
2534    // c:1923-1936 — store TcpStream as both control and cin.
2535    let stream_clone = match stream.try_clone() {
2536        Ok(c) => c,
2537        Err(_) => {
2538            zwarnnam(name, "file handling error"); // c:1932
2539            zfclose(0);
2540            return 1;
2541        }
2542    };
2543    if let Ok(mut state) = zftp_state().lock() {
2544        if let Some(sess) = state
2545            .current
2546            .clone()
2547            .and_then(|k| state.sessions.get_mut(&k))
2548        {
2549            sess.control = Some(stream);
2550            sess.cin = Some(stream_clone);
2551            sess.connected = true;
2552            sess.host = Some(hostnam.clone());
2553            sess.port = resolved_port;
2554        }
2555    }
2556
2557    // c:1947-1950 — read 220 banner.
2558    if zfgetmsg() >= 4 {
2559        // c:1947
2560        zfclose(0); // c:1948
2561        return 1; // c:1949
2562    }
2563
2564    // c:1952-1954 — has_size, has_mdtm, dfd reset; initial status word.
2565    if let Ok(mut state) = zftp_state().lock() {
2566        if let Some(sess) = state
2567            .current
2568            .clone()
2569            .and_then(|k| state.sessions.get_mut(&k))
2570        {
2571            sess.has_size = ZFCP_UNKN; // c:1952
2572            sess.has_mdtm = ZFCP_UNKN; // c:1952
2573            sess.dfd = -1; // c:1953
2574            sess.transfer_type = ZFST_ASCI; // c:1955 initial ASCII
2575        }
2576    }
2577
2578    // c:1981-1985 — ZFTP_MODE param, then chain into zftp_login when
2579    // more args remain.
2580    zfsetparam("ZFTP_MODE", "S", ZFPM_READONLY); // c:1982
2581    if effective.len() > 1 {
2582        // c:1984 *++args
2583        let rest: Vec<&str> = effective[1..].iter().map(|s| s.as_str()).collect();
2584        return zftp_login(name, &rest, flags); // c:1985
2585    }
2586
2587    // c:1988 — control alive?
2588    let alive = zftp_state()
2589        .lock()
2590        .ok()
2591        .and_then(|s| {
2592            s.current
2593                .as_deref()
2594                .and_then(|k| s.sessions.get(k))
2595                .map(|sess| sess.control.is_some())
2596        })
2597        .unwrap_or(false);
2598    if alive {
2599        0
2600    } else {
2601        1
2602    } // c:1988 return !control
2603}
2604
2605/// Port of `zfgetinfo(char *prompt, int noecho)` from `Src/Modules/zftp.c:1999`.
2606/// C: `static char * zfgetinfo(char *prompt, int noecho)` — prompt
2607/// the tty (echoing or with ECHO masked off for passwords) and read
2608/// one line of input.
2609#[allow(non_snake_case)]
2610pub fn zfgetinfo(prompt: &str, noecho: i32) -> Option<String> {
2611    // c:1999
2612    // c:2001-2006 — locals.
2613    let mut resettty: i32 = 0; // c:2001
2614    let mut instr = String::new(); // c:2005 char instr[256]
2615    let len: usize = 0; // c:2006 (unused in Rust path)
2616    let _ = len;
2617
2618    let saved_termios: Option<libc::termios>;
2619
2620    // c:2013 — if (isatty(0)) prompt + tty setup.
2621    if unsafe { libc::isatty(0) } != 0 {
2622        // c:2013
2623        if noecho != 0 {
2624            // c:2014-2033 — `ti = shttyinfo; ti.tio.c_lflag &= ~ECHO;
2625            //                settyinfo(&ti); resettty = 1;`
2626            //
2627            // C reads the saved shell-tty state (shttyinfo global) so
2628            // the SHTTY fd (which may differ from stdin if the shell
2629            // re-dup'd) is used consistently. shttyinfo isn't ported
2630            // as a singleton yet, so use gettyinfo() which reads
2631            // SHTTY's current termios via fdgettyinfo(SHTTY) — the
2632            // canonical port at utils.rs:1926. settyinfo() at
2633            // utils.rs:1964 writes back via fdsettyinfo(SHTTY, ti)
2634            // with TCSADRAIN and EINTR-retry.
2635            //
2636            // Prior port used raw tcgetattr(0) + tcsetattr(0, TCSANOW)
2637            // which (a) read/wrote stdin instead of SHTTY (wrong when
2638            // SHTTY was re-dup'd, e.g. `zsh < /dev/tty &`), and (b)
2639            // used TCSANOW instead of TCSADRAIN, dropping any pending
2640            // output before the mode change — visible as cut-off
2641            // prompts on slow terminals.
2642            if let Some(mut ti) = crate::ported::utils::gettyinfo() {
2643                saved_termios = Some(ti); // c:2026 ti = shttyinfo (save for restore)
2644                ti.c_lflag &= !libc::ECHO; // c:2028
2645                let _ = crate::ported::utils::settyinfo(&ti); // c:2032
2646                resettty = 1; // c:2033
2647            } else {
2648                saved_termios = None;
2649            }
2650        } else {
2651            saved_termios = None;
2652        }
2653        // c:2035-2037 — fflush(stdin) + write prompt to stderr.
2654        eprint!("{}", prompt); // c:2036
2655        let _ = io::stderr().flush(); // c:2037
2656    } else {
2657        saved_termios = None;
2658    }
2659
2660    // c:2040-2043 — fgets(instr, 256, stdin); strip trailing \n.
2661    let stdin = io::stdin();
2662    let mut handle = stdin.lock();
2663    match handle.read_line(&mut instr) {
2664        // c:2040
2665        Ok(0) => instr.clear(), // c:2041 NULL → empty
2666        Ok(_) => {
2667            // c:2042-2043 strip \n
2668            if instr.ends_with('\n') {
2669                instr.pop();
2670            }
2671        }
2672        Err(_) => instr.clear(),
2673    }
2674
2675    // c:2045 — strret = dupstring(instr); (just keep instr as the result)
2676    let strret = instr.clone();
2677
2678    // c:2047-2052 — restore termios if we modified it.
2679    if resettty != 0 {
2680        // c:2047
2681        println!(); // c:2049 '\n' didn't echo
2682        let _ = io::stdout().flush(); // c:2050
2683        if let Some(ti) = saved_termios {
2684            // c:2051 — `settyinfo(&shttyinfo);` — restore via the
2685            // canonical helper so the SHTTY fd + TCSADRAIN + EINTR
2686            // retry match C exactly.
2687            let _ = crate::ported::utils::settyinfo(&ti);
2688        }
2689    }
2690
2691    Some(strret) // c:2054
2692}
2693
2694/// Port of `zftp_params(UNUSED(char *name), char **args, UNUSED(int flags))` from `Src/Modules/zftp.c:2064`.
2695/// C: list, clear ("-"), or set the current session's `userparams` array.
2696#[allow(unused_variables)]
2697pub fn zftp_params(name: &str, args: &[&str], flags: i32) -> i32 {
2698    // c:2064
2699    let prompts: [&str; 4] = ["Host: ", "User: ", "Password: ", "Account: "]; // c:2067
2700                                                                              // c:2071-2083 — no args: print current userparams (mask the password slot).
2701    if args.is_empty() {
2702        // c:2071 !*args
2703        let state = match zftp_state().lock() {
2704            Ok(s) => s,
2705            Err(_) => return 1,
2706        };
2707        let sess = match state.current.as_deref().and_then(|k| state.sessions.get(k)) {
2708            Some(s) => s,
2709            None => return 1,
2710        };
2711        if sess.userparams.is_empty() {
2712            // c:2082 else
2713            return 1; // c:2083
2714        }
2715        let mut out = io::stdout().lock();
2716        for (i, p) in sess.userparams.iter().enumerate() {
2717            // c:2073 for aptr,i
2718            if i == 2 {
2719                // c:2074 i == 2
2720                let len = p.len(); // c:2075 strlen
2721                for _ in 0..len {
2722                    // c:2076 for j<len
2723                    let _ = out.write_all(b"*"); // c:2077 fputc '*'
2724                }
2725                let _ = out.write_all(b"\n"); // c:2078 fputc '\n'
2726            } else {
2727                let _ = writeln!(out, "{}", p); // c:2080 fprintf
2728            }
2729        }
2730        return 0; // c:2081
2731    }
2732    // c:2084-2089 — single "-" arg: clear userparams.
2733    if args[0] == "-" {
2734        // c:2084 !strcmp "-"
2735        if let Ok(mut state) = zftp_state().lock() {
2736            if let Some(sess) = state
2737                .current
2738                .clone()
2739                .and_then(|k| state.sessions.get_mut(&k))
2740            {
2741                sess.userparams.clear(); // c:2085-2087 freearray
2742            }
2743        }
2744        return 0; // c:2088
2745    }
2746    // c:2090-2103 — replace userparams with new array.
2747    let len = args.len(); // c:2090 arrlen
2748    let mut newarr: Vec<String> = Vec::with_capacity(len); // c:2091 zshcalloc
2749    let efl_atomic = &errflag;
2750    for (i, aptr) in args.iter().enumerate() {
2751        // c:2092 for aptr,i
2752        if efl_atomic.load(Ordering::Relaxed) != 0 {
2753            // c:2092 !errflag
2754            break;
2755        }
2756        let str_val: String;
2757        if let Some(rest) = aptr.strip_prefix('?') {
2758            // c:2094 **aptr == '?'
2759            let prompt: &str = if !rest.is_empty() {
2760                rest
2761            } else {
2762                prompts[i.min(3)]
2763            }; // c:2095
2764            match zfgetinfo(prompt, if i == 2 { 1 } else { 0 }) {
2765                // c:2095 i == 2
2766                Some(s) => str_val = s,
2767                None => {
2768                    return 1;
2769                }
2770            }
2771        } else if let Some(rest) = aptr.strip_prefix('\\') {
2772            // c:2097 **aptr=='\\'
2773            str_val = rest.to_string();
2774        } else {
2775            str_val = (*aptr).to_string();
2776        }
2777        newarr.push(str_val); // c:2098 ztrdup
2778    }
2779    if efl_atomic.load(Ordering::Relaxed) != 0 {
2780        // c:2100 if errflag
2781        // c:2101-2104 — free newarr; Rust Drop handles it.
2782        return 1; // c:2105
2783    }
2784    if let Ok(mut state) = zftp_state().lock() {
2785        if let Some(sess) = state
2786            .current
2787            .clone()
2788            .and_then(|k| state.sessions.get_mut(&k))
2789        {
2790            sess.userparams = newarr; // c:2107-2109
2791        }
2792    }
2793    0 // c:2110
2794}
2795
2796/// Port of `zftp_login(char *name, char **args, UNUSED(int flags))` from `Src/Modules/zftp.c:2118`.
2797/// C: send USER/PASS/ACCT, drive the reply state machine, set
2798/// ZFTP_USER/ACCOUNT/SYSTEM/TYPE parameters, probe SYST type, then
2799/// pull current directory via `zfgetcwd()`.
2800#[allow(unused_variables)]
2801pub fn zftp_login(name: &str, args: &[&str], flags: i32) -> i32 {
2802    // c:2118
2803    let mut ucmd: String; // c:2120 char *ucmd
2804    let mut passwd: Option<String> = None; // c:2120 *passwd = NULL
2805    let mut acct: Option<String> = None; // c:2120 *acct = NULL
2806    let user: String; // c:2121 char *user
2807    let mut stopit: i32; // c:2122 int stopit
2808    let mut arg_idx: usize = 0;
2809
2810    // c:2124-2125 — already logged in; REIN to reset.
2811    let already_logged_in = zftp_state()
2812        .lock()
2813        .ok()
2814        .and_then(|s| {
2815            s.current
2816                .as_deref()
2817                .and_then(|k| s.sessions.get(k))
2818                .map(|sess| sess.logged_in)
2819        })
2820        .unwrap_or(false);
2821    if already_logged_in && zfsendcmd("REIN\r\n") >= 4 {
2822        // c:2124
2823        return 1; // c:2125
2824    }
2825
2826    // c:2127 — clear ZFST_LOGI.
2827    if let Ok(mut state) = zftp_state().lock() {
2828        if let Some(sess) = state
2829            .current
2830            .clone()
2831            .and_then(|k| state.sessions.get_mut(&k))
2832        {
2833            sess.logged_in = false; // c:2127
2834        }
2835    }
2836
2837    // c:2128-2132 — user from args[0] or prompt.
2838    if arg_idx < args.len() {
2839        // c:2128 *args
2840        user = args[arg_idx].to_string(); // c:2129 user = *args++
2841        arg_idx += 1;
2842    } else {
2843        user = match zfgetinfo("User: ", 0) {
2844            // c:2131
2845            Some(s) => s,
2846            None => return 1,
2847        };
2848    }
2849
2850    // c:2134 — tricat("USER ", user, "\r\n").
2851    ucmd = format!("USER {}\r\n", user);
2852    stopit = 0; // c:2135
2853
2854    // c:2137-2138 — first send; ret==6 (write fail) → stopit=2.
2855    if zfsendcmd(&ucmd) == 6 {
2856        // c:2137
2857        stopit = 2; // c:2138
2858    }
2859
2860    // c:2140-2174 — state-machine on lastcode.
2861    let efl_atomic = &errflag;
2862    while stopit == 0 && efl_atomic.load(Ordering::Relaxed) == 0 {
2863        // c:2140
2864        let code = lastcode.load(Ordering::Relaxed);
2865        match code {
2866            230 | 202 => {
2867                // c:2142-2144
2868                stopit = 1; // c:2145
2869            }
2870            331 => {
2871                // c:2148 need password
2872                let pw = if arg_idx < args.len() {
2873                    // c:2149
2874                    let p = args[arg_idx].to_string(); // c:2150
2875                    arg_idx += 1;
2876                    p
2877                } else {
2878                    match zfgetinfo("Password: ", 1) {
2879                        // c:2152
2880                        Some(s) => s,
2881                        None => {
2882                            stopit = 2;
2883                            break;
2884                        }
2885                    }
2886                };
2887                passwd = Some(pw.clone()); // c:2120/2150 binding
2888                                           // c:2153 zsfree(ucmd); c:2154 tricat("PASS ", passwd, "\r\n").
2889                ucmd = format!("PASS {}\r\n", pw);
2890                if zfsendcmd(&ucmd) == 6 {
2891                    // c:2155
2892                    stopit = 2; // c:2156
2893                }
2894            }
2895            332 | 532 => {
2896                // c:2160-2161 need account
2897                let ac = if arg_idx < args.len() {
2898                    // c:2162
2899                    let a = args[arg_idx].to_string(); // c:2163
2900                    arg_idx += 1;
2901                    a
2902                } else {
2903                    match zfgetinfo("Account: ", 0) {
2904                        // c:2165
2905                        Some(s) => s,
2906                        None => {
2907                            stopit = 2;
2908                            break;
2909                        }
2910                    }
2911                };
2912                acct = Some(ac.clone());
2913                ucmd = format!("ACCT {}\r\n", ac); // c:2167
2914                if zfsendcmd(&ucmd) == 6 {
2915                    // c:2168
2916                    stopit = 2; // c:2169
2917                }
2918            }
2919            // c:2173-2179 — 421/501/503/530/550/default → unrecoverable.
2920            _ => {
2921                stopit = 2; // c:2180
2922            }
2923        }
2924    }
2925    // c:2184 zsfree(ucmd) — Rust Drop.
2926    let _ = passwd; // suppress unused-warn; password kept only for parity
2927
2928    // c:2185-2186 — control gone after exchange.
2929    let control_alive = zftp_state()
2930        .lock()
2931        .ok()
2932        .and_then(|s| {
2933            s.current
2934                .as_deref()
2935                .and_then(|k| s.sessions.get(k))
2936                .map(|sess| sess.control.is_some())
2937        })
2938        .unwrap_or(false);
2939    if !control_alive {
2940        // c:2185
2941        return 1; // c:2186
2942    }
2943    // c:2187-2190 — login failed.
2944    let code = lastcode.load(Ordering::Relaxed);
2945    if stopit == 2 || (code != 230 && code != 202) {
2946        // c:2187
2947        zwarnnam(name, "login failed"); // c:2188
2948        return 1; // c:2189
2949    }
2950
2951    // c:2192-2197 — warn on unused trailing args.
2952    if arg_idx < args.len() {
2953        // c:2192
2954        let cnt = args.len() - arg_idx; // c:2193-2194
2955        zwarnnam(
2956            name,
2957            &format!("warning: {} command arguments not used", cnt), // c:2195
2958        );
2959    }
2960
2961    // c:2198 — set ZFST_LOGI on the session.
2962    if let Ok(mut state) = zftp_state().lock() {
2963        if let Some(sess) = state
2964            .current
2965            .clone()
2966            .and_then(|k| state.sessions.get_mut(&k))
2967        {
2968            sess.logged_in = true; // c:2198
2969            sess.user = Some(user.clone());
2970        }
2971    }
2972    // c:2199 — ZFTP_USER readonly param.
2973    zfsetparam("ZFTP_USER", &user, ZFPM_READONLY); // c:2199
2974    if let Some(ref a) = acct {
2975        // c:2200
2976        zfsetparam("ZFTP_ACCOUNT", a, ZFPM_READONLY); // c:2201
2977    }
2978
2979    // c:2207-2226 — SYST probe gated by per-session ZFST_SYST cache bit
2980    // AND zfprefs ZFPF_DUMB bit (when DUMB set, skip the probe entirely).
2981    let already_probed = zftp_state()
2982        .lock()
2983        .ok()
2984        .and_then(|s| {
2985            s.current
2986                .as_deref()
2987                .and_then(|k| s.sessions.get(k))
2988                .map(|sess| sess.syst_probed)
2989        })
2990        .unwrap_or(false);
2991    let dumb = (zfprefs.load(Ordering::Relaxed) & ZFPF_DUMB) != 0; // c:2207
2992                                                                   // c:2206-2225 — SYST probe block. Structure differs from a one-liner
2993                                                                   // because the ZFST_SYST cache bit must be set REGARDLESS of whether
2994                                                                   // zfsendcmd succeeds:
2995                                                                   //   if (!DUMB && !(status & ZFST_SYST)) {
2996                                                                   //       if (zfsendcmd("SYST\r\n") == 2) {
2997                                                                   //           ... parse + zfsetparam("ZFTP_SYSTEM", ...) ...
2998                                                                   //       }
2999                                                                   //       zfstatusp[zfsessno] |= ZFST_SYST;   <-- always set
3000                                                                   //   }
3001                                                                   //
3002                                                                   // C only retries SYST when the cache is unset; setting the cache
3003                                                                   // unconditionally inside the `!ZFST_SYST` guard means failed probes
3004                                                                   // (server returns 5xx for SYST) don't re-probe on every subsequent
3005                                                                   // zftp call within the same session.
3006                                                                   //
3007                                                                   // Prior Rust port collapsed both checks into a single `&&` chain so
3008                                                                   // `sess.syst_probed = true` only ran when the SYST send returned 2.
3009                                                                   // Servers that 5xx'd SYST got re-probed on every dispatch, wasting
3010                                                                   // one round-trip per zftp subcommand.
3011    if !dumb && !already_probed {
3012        if zfsendcmd("SYST\r\n") == 2 {
3013            // c:2208
3014            let systype = lastmsg.lock().ok().map(|m| m.clone()).unwrap_or_default();
3015            if systype.starts_with("UNIX Type: L8") {
3016                // c:2212-2218
3017                if let Ok(mut state) = zftp_state().lock() {
3018                    if let Some(sess) = state
3019                        .current
3020                        .clone()
3021                        .and_then(|k| state.sessions.get_mut(&k))
3022                    {
3023                        sess.transfer_type = ZFST_IMAG; // c:2220
3024                    }
3025                }
3026            }
3027            zfsetparam("ZFTP_SYSTEM", &systype, ZFPM_READONLY); // c:2222
3028        }
3029        // c:2224 — `zfstatusp[zfsessno] |= ZFST_SYST;` — set the cache
3030        // bit unconditionally so a failed-SYST server isn't re-probed.
3031        if let Ok(mut state) = zftp_state().lock() {
3032            if let Some(sess) = state
3033                .current
3034                .clone()
3035                .and_then(|k| state.sessions.get_mut(&k))
3036            {
3037                sess.syst_probed = true; // c:2224
3038            }
3039        }
3040    }
3041
3042    // c:2228-2230 — ZFTP_TYPE param.
3043    let ttype = zftp_state()
3044        .lock()
3045        .ok()
3046        .and_then(|s| {
3047            s.current
3048                .as_deref()
3049                .and_then(|k| s.sessions.get(k))
3050                .map(|sess| sess.transfer_type)
3051        })
3052        .unwrap_or(ZFST_ASCI);
3053    let tbuf = if ZFST_TYPE(ttype) == ZFST_ASCI {
3054        "A"
3055    } else {
3056        "I"
3057    }; // c:2228
3058    zfsetparam("ZFTP_TYPE", tbuf, ZFPM_READONLY); // c:2229
3059
3060    // c:2236 — fetch current directory.
3061    zfgetcwd() // c:2236
3062}
3063
3064/// Port of `zftp_test(UNUSED(char *name), UNUSED(char **args), UNUSED(int flags))` from `Src/Modules/zftp.c:2251`.
3065/// C: `static int zftp_test(char *name, char **args, int flags)` —
3066/// returns 0 when the current session has a live control connection,
3067/// 1 otherwise (zftpcmdtab flags = ZFTP_TEST).
3068#[allow(unused_variables)]
3069pub fn zftp_test(name: &str, args: &[&str], flags: i32) -> i32 {
3070    // c:2251
3071    // c:2251 — early-return when no control connection.
3072    let control_fd = zftp_state().lock().ok().and_then(|s| {
3073        s.current
3074            .as_deref()
3075            .and_then(|k| s.sessions.get(k))
3076            .and_then(|sess| sess.control.as_ref().map(|c| c.as_raw_fd()))
3077    });
3078    let fd = match control_fd {
3079        // c:2262
3080        Some(f) => f,
3081        None => return 1, // c:2263
3082    };
3083    // c:2266-2280 — poll(2) with 0 timeout. POLLIN events on the
3084    // control fd mean the server pushed an unsolicited message (e.g.
3085    // "421 Timeout") — consume it via zfgetmsg.
3086    let mut pfd = libc::pollfd {
3087        fd,
3088        events: libc::POLLIN,
3089        revents: 0,
3090    };
3091    let ret = unsafe { libc::poll(&mut pfd, 1, 0) }; // c:2272
3092    let errno = io::Error::last_os_error().raw_os_error().unwrap_or(0);
3093    if ret < 0 && errno != libc::EINTR && errno != libc::EAGAIN {
3094        // c:2273
3095        zfclose(0); // c:2274
3096    } else if ret > 0 && pfd.revents != 0 {
3097        // c:2275
3098        zfgetmsg(); // c:2277 handles 421
3099    }
3100    // c:2305 — return zfsess->control ? 0 : 2;
3101    let still_alive = zftp_state()
3102        .lock()
3103        .ok()
3104        .and_then(|s| {
3105            s.current
3106                .as_deref()
3107                .and_then(|k| s.sessions.get(k))
3108                .map(|sess| sess.control.is_some())
3109        })
3110        .unwrap_or(false);
3111    if still_alive {
3112        0
3113    } else {
3114        2
3115    } // c:2305
3116}
3117
3118/// Port of `zftp_dir(char *name, char **args, int flags)` from `Src/Modules/zftp.c:2305`.
3119pub fn zftp_dir(name: &str, args: &[&str], flags: i32) -> i32 {
3120    // c:2305
3121    let cmd: String; // c:2305 char *cmd
3122    let ret: i32; // c:2309 int ret
3123                  // c:2316 — zfsettype(ZFST_ASCI); RFC959 requires ASCII for LIST.
3124    zfsettype(ZFST_ASCI);
3125    // c:2318 — cmd = zfargstring(NLST or LIST, args);
3126    let verb = if (flags & ZFTP_NLST) != 0 {
3127        "NLST"
3128    } else {
3129        "LIST"
3130    };
3131    cmd = zfargstring(verb, args); // c:2318 — terminator from zfargstring c:559
3132                                   // c:2319 — ret = zfgetdata(name, NULL, cmd, 0);
3133    ret = zfgetdata(name, "", &cmd, 0);
3134    // c:2332 zsfree(cmd) — Rust Drop.
3135    if ret != 0 {
3136        return 1;
3137    } // c:2332-2322
3138    let _ = io::stdout().flush(); // c:2332
3139    zfsenddata(name, 1, 0, 0) // c:2332
3140}
3141
3142/// Port of `zftp_cd(UNUSED(char *name), char **args, int flags)` from `Src/Modules/zftp.c:2332`.
3143/// C: send `CDUP\r\n` or `CWD <dir>\r\n` based on flags + arg shape.
3144/// Then call zfgetcwd to update the cached pwd.
3145#[allow(unused_variables)]
3146pub fn zftp_cd(name: &str, args: &[&str], flags: i32) -> i32 {
3147    // c:2332
3148    let ret: i32; // c:2332 int ret
3149                  // c:2337-2340 — CDUP when flag set OR arg is ".." / "../".
3150    let arg0 = args.first().copied().unwrap_or("");
3151    if (flags & ZFTP_CDUP) != 0 || arg0 == ".." || arg0 == "../" {
3152        // c:2337
3153        ret = zfsendcmd("CDUP\r\n"); // c:2339
3154    } else {
3155        // c:2340
3156        let cmd = format!("CWD {}\r\n", arg0); // c:2341 tricat
3157        ret = zfsendcmd(&cmd); // c:2342
3158                               // c:2343 zsfree — Rust Drop.
3159    }
3160    if ret > 2 {
3161        return 1;
3162    } // c:2345
3163      // c:2347-2349 — `if (zfgetcwd()) return 1;`
3164    if zfgetcwd() != 0 {
3165        return 1;
3166    }
3167    0 // c:2351
3168}
3169
3170/// Port of `zfgetcwd()` from `Src/Modules/zftp.c:2358`.
3171/// C: `static int zfgetcwd(void)` — DUMB short-circuit, send PWD,
3172/// parse the reply (directory between optional `"` delimiters per
3173/// c:2370-2382), set `$ZFTP_PWD`, then fire the `zftp_chpwd` hook
3174/// (c:2389-2394). Fully implemented below — the lastmsg parse and
3175/// hook dispatch both run in this body.
3176#[allow(non_snake_case)]
3177pub fn zfgetcwd() -> i32 {
3178    // c:2358 — short-circuit when ZFPF_DUMB is set (don't fiddle with
3179    // variables in dumb mode).
3180    if (zfprefs.load(Ordering::Relaxed) & ZFPF_DUMB) != 0 {
3181        // c:2364
3182        return 1; // c:2365
3183    }
3184    if zfsendcmd("PWD\r\n") > 2 {
3185        // c:2366
3186        zfunsetparam("ZFTP_PWD"); // c:2367
3187        return 1; // c:2368
3188    }
3189    // c:2370-2382 — parse the PWD reply to extract the directory
3190    // path between optional `"` delimiters.
3191    //
3192    //   ptr = lastmsg;
3193    //   while (*ptr == ' ') ptr++;
3194    //   if (!*ptr) return 1;                 /* ultra safe */
3195    //   if (*ptr == '"') { ptr++; endc = '"'; }
3196    //   else endc = ' ';
3197    //   for (eptr = ptr; *eptr && *eptr != endc; eptr++);
3198    //   zfsetparam("ZFTP_PWD", ztrduppfx(ptr, eptr-ptr), ZFPM_READONLY);
3199    //
3200    // Prior port collapsed this to "set if PWD command succeeded"
3201    // without actually setting ZFTP_PWD to the parsed path. Scripts
3202    // reading `$ZFTP_PWD` after `zftp cd somewhere` got a stale value
3203    // from a previous chdir or empty string entirely.
3204    let pwd_msg = lastmsg.lock().ok().map(|m| m.clone()).unwrap_or_default();
3205    let trimmed = pwd_msg.trim_start_matches(' '); // c:2371-2372
3206    if trimmed.is_empty() {
3207        // c:2373 — `if (!*ptr) return 1;`
3208        return 1; // c:2374
3209    }
3210    let (rest, endc) = if let Some(s) = trimmed.strip_prefix('"') {
3211        // c:2375-2378 — `if (*ptr == '"') { ptr++; endc = '"'; }`
3212        (s, '"')
3213    } else {
3214        // c:2378-2379 — `else endc = ' ';`
3215        (trimmed, ' ')
3216    };
3217    let path: String = rest.chars().take_while(|&c| c != endc).collect(); // c:2380-2381
3218    zfsetparam("ZFTP_PWD", &path, ZFPM_READONLY); // c:2382
3219
3220    let cwd_ret = 0; // c:2396 — `return 0;` post-parse success
3221
3222    // c:2388-2393 — zftp_chpwd hook: fire the shfunc with SFC_HOOK
3223    // context after ZFTP_PWD has been updated.
3224    if let Some(mut shfunc) = getshfunc("zftp_chpwd") {
3225        let osc = SFCONTEXT.load(Ordering::Relaxed);
3226        SFCONTEXT.store(SFC_HOOK, Ordering::Relaxed);
3227        // c:2393 — `doshfunc(shfunc, NULL, 1);`.
3228        let body_runner =
3229            || -> i32 { crate::ported::exec::run_function_body("zftp_chpwd", &[]).unwrap_or(0) };
3230        let _ = crate::ported::exec::doshfunc(
3231            &mut shfunc,
3232            vec!["zftp_chpwd".to_string()],
3233            true,
3234            body_runner,
3235        );
3236        SFCONTEXT.store(osc, Ordering::Relaxed);
3237    }
3238    cwd_ret
3239}
3240
3241/// Port of `zfsettype(int type)` from `Src/Modules/zftp.c:2404-2417`.
3242/// C: `static int zfsettype(int type)` — when the requested type differs
3243/// from the server's current type (ZFST_CTYP), send `TYPE A` or `TYPE I`
3244/// and on >2 response return 1 leaving CTYP unchanged; on success clear
3245/// the CTYP bits in `zfstatusp[zfsessno]` then set them from `type`.
3246/// `type` is renamed `typ` in Rust because `type` is a keyword.
3247#[allow(non_snake_case)]
3248pub fn zfsettype(typ: i32) -> i32 {
3249    // c:2407 — char buf[] = "TYPE X\r\n";
3250    let mut buf: [u8; 8] = *b"TYPE X\r\n";
3251    // c:2408 — already at this type? return 0.
3252    let cur_ctyp = zftp_state()
3253        .lock()
3254        .ok()
3255        .and_then(|s| {
3256            s.current
3257                .as_deref()
3258                .and_then(|k| s.sessions.get(k))
3259                .map(|sess| sess.current_type)
3260        })
3261        .unwrap_or(ZFST_CASC);
3262    if ZFST_TYPE(typ) == cur_ctyp {
3263        return 0; // c:2409
3264    }
3265    // c:2410 — buf[5] = (ZFST_TYPE(type) == ZFST_ASCI) ? 'A' : 'I';
3266    buf[5] = if ZFST_TYPE(typ) == ZFST_ASCI {
3267        b'A'
3268    } else {
3269        b'I'
3270    };
3271    let cmd = std::str::from_utf8(&buf).unwrap_or("TYPE A\r\n");
3272    // c:2411 — if (zfsendcmd(buf) > 2) return 1;
3273    if zfsendcmd(cmd) > 2 {
3274        return 1; // c:2412
3275    }
3276    // c:2413-2415 — clear current-type bits, then set from `type`.
3277    if let Ok(mut state) = zftp_state().lock() {
3278        if let Some(sess) = state
3279            .current
3280            .clone()
3281            .and_then(|k| state.sessions.get_mut(&k))
3282        {
3283            // C: zfstatusp[zfsessno] &= ~(ZFST_TMSK << ZFST_TBIT);
3284            //    zfstatusp[zfsessno] |=  type        << ZFST_TBIT;
3285            // Rust models the CTYP slot as a dedicated field rather than
3286            // a bit slice, so the equivalent is `current_type = ZFST_TYPE(typ)`.
3287            sess.current_type = ZFST_TYPE(typ); // c:2413-2415
3288        }
3289    }
3290    0 // c:2416
3291}
3292
3293/// Port of `zftp_type(char *name, char **args, int flags)` from `Src/Modules/zftp.c:2426`.
3294/// C: set the transfer-type byte (A=ASCII, I=binary). When ZFTP_TASC/
3295/// ZFTP_TBIN flags are set (ascii/binary subcommands route here),
3296/// pick the type from the flag; otherwise read from `args[0]`. With no
3297/// args, print the current type.
3298pub fn zftp_type(name: &str, args: &[&str], flags: i32) -> i32 {
3299    // c:2426
3300    let mut tbuf: [u8; 2] = [b'A', 0]; // c:2428 char tbuf[2] = "A"
3301    let nt: u8; // c:2428 char nt
3302    let str: &str; // c:2428 char *str
3303    let _ = str;
3304
3305    if (flags & (ZFTP_TBIN | ZFTP_TASC)) != 0 {
3306        // c:2429
3307        nt = if (flags & ZFTP_TBIN) != 0 { b'I' } else { b'A' }; // c:2430
3308    } else if args.first().copied().unwrap_or("").is_empty() {
3309        // c:2431
3310        // No args: print current type ('A' or 'I').
3311        let ttype = zftp_state()
3312            .lock()
3313            .ok()
3314            .and_then(|s| {
3315                s.current
3316                    .as_deref()
3317                    .and_then(|k| s.sessions.get(k))
3318                    .map(|sess| sess.transfer_type)
3319            })
3320            .unwrap_or(ZFST_IMAG as i32);
3321        let is_ascii = (ttype & ZFST_ASCI) != 0;
3322        println!("{}", if is_ascii { 'A' } else { 'I' }); // c:2436-2437
3323        let _ = io::stdout().flush(); // c:2438
3324        return 0; // c:2439
3325    } else {
3326        str = args[0];
3327        let c0 = str.as_bytes()[0].to_ascii_uppercase(); // c:2441 toupper
3328        if str.len() > 1 || (c0 != b'A' && c0 != b'B' && c0 != b'I') {
3329            // c:2446
3330            zwarnnam(
3331                name, // c:2447
3332                &format!("transfer type {} not recognised", str),
3333            );
3334            return 1; // c:2448
3335        }
3336        nt = if c0 == b'B' { b'I' } else { c0 }; // c:2451-2452
3337    }
3338
3339    // c:2455-2456 — update zfstatusp[zfsessno] (kept on the session.transfer_type).
3340    if let Ok(mut state) = zftp_state().lock() {
3341        if let Some(sess) = state
3342            .current
3343            .clone()
3344            .and_then(|k| state.sessions.get_mut(&k))
3345        {
3346            sess.transfer_type = if nt == b'I' { ZFST_IMAG } else { ZFST_ASCI } as i32;
3347        }
3348    }
3349    tbuf[0] = nt; // c:2464
3350    let tb = std::str::from_utf8(&tbuf[..1]).unwrap_or("A");
3351    zfsetparam("ZFTP_TYPE", tb, ZFPM_READONLY); // c:2464
3352    0 // c:2464
3353}
3354
3355/// Port of `zftp_mode(char *name, char **args, UNUSED(int flags))` from `Src/Modules/zftp.c:2464`.
3356/// C: set stream-mode (S=stream, B=block). With no arg, print current.
3357#[allow(unused_variables)]
3358pub fn zftp_mode(name: &str, args: &[&str], flags: i32) -> i32 {
3359    // c:2464
3360    let str: &str;
3361    let nt: u8; // c:2467 int nt
3362
3363    if args.first().copied().unwrap_or("").is_empty() {
3364        // c:2469
3365        let tmode = zftp_state()
3366            .lock()
3367            .ok()
3368            .and_then(|s| {
3369                s.current
3370                    .as_deref()
3371                    .and_then(|k| s.sessions.get(k))
3372                    .map(|sess| sess.transfer_mode)
3373            })
3374            .unwrap_or(ZFST_STRE as i32);
3375        let is_stream = tmode == ZFST_STRE;
3376        println!("{}", if is_stream { 'S' } else { 'B' }); // c:2470-2471
3377        let _ = io::stdout().flush(); // c:2472
3378        return 0; // c:2473
3379    }
3380    str = args[0];
3381    nt = str.as_bytes()[0].to_ascii_uppercase(); // c:2475
3382    if str.len() > 1 || (nt != b'S' && nt != b'B') {
3383        // c:2476
3384        zwarnnam(
3385            name, // c:2477
3386            &format!("transfer mode {} not recognised", str),
3387        );
3388        return 1; // c:2478
3389    }
3390    let cmd = format!("MODE {}\r\n", nt as char); // c:2480 cmd[5] = nt
3391    if zfsendcmd(&cmd) > 2 {
3392        // c:2481
3393        return 1; // c:2482
3394    }
3395    // c:2483-2484 — update session transfer_mode.
3396    if let Ok(mut state) = zftp_state().lock() {
3397        if let Some(sess) = state
3398            .current
3399            .clone()
3400            .and_then(|k| state.sessions.get_mut(&k))
3401        {
3402            sess.transfer_mode = if nt == b'S' { ZFST_STRE } else { ZFST_BLOC } as i32;
3403        }
3404    }
3405    let mb = (nt as char).to_string();
3406    zfsetparam("ZFTP_MODE", &mb, ZFPM_READONLY); // c:2491
3407    0 // c:2491
3408}
3409
3410/// Port of `zftp_local(UNUSED(char *name), char **args, int flags)` from `Src/Modules/zftp.c:2491`.
3411#[allow(unused_variables)]
3412pub fn zftp_local(name: &str, args: &[&str], flags: i32) -> i32 {
3413    // c:2491
3414    let more = args.len() > 1; // c:2493 more = !!args[1]
3415    let mut ret: i32 = 0; // c:2493 ret = 0
3416    let dofd = args.is_empty(); // c:2493 dofd = !*args
3417    let mut i = 0usize;
3418    loop {
3419        // c:2494 while (*args || dofd)
3420        if !dofd && i >= args.len() {
3421            break;
3422        }
3423        let arg = if dofd { "" } else { args[i] };
3424        let mut sz: libc::off_t = 0; // c:2495 off_t sz
3425        let mut mt: Option<String> = None; // c:2496 char *mt
3426                                           // c:2497-2498 — zfstats(*args, !(flags & ZFTP_HERE), &sz, &mt, dofd ? 0 : -1);
3427        let remote = if (flags & ZFTP_HERE) != 0 { 0 } else { 1 };
3428        let fd = if dofd { 0 } else { -1 };
3429        // c:2497-2498 — zftp_local wants BOTH probes (&sz, &mt).
3430        let newret = zfstats(arg, remote, Some(&mut sz), Some(&mut mt), fd);
3431        if newret == 2 {
3432            // c:2499
3433            return 2; // c:2500
3434        } else if newret != 0 {
3435            // c:2501
3436            ret = 1; // c:2502
3437                     // c:2503-2504 — Rust Drop.
3438            i += 1; // c:2505
3439            continue; // c:2506
3440        }
3441        if more {
3442            // c:2508
3443            print!("{} ", arg); // c:2509-2510
3444        }
3445        let mt_s = mt.unwrap_or_default();
3446        println!("{} {}", sz, mt_s); // c:2517
3447        if dofd {
3448            break;
3449        } // c:2520-2521
3450        i += 1; // c:2522
3451    }
3452    let _ = io::stdout().flush(); // c:2544
3453    ret // c:2544
3454}
3455
3456/// Port of `zftp_getput(char *name, char **args, int flags)` from `Src/Modules/zftp.c:2544`.
3457pub fn zftp_getput(name: &str, args: &[&str], flags: i32) -> i32 {
3458    // c:2544
3459    let mut ret: i32 = 0; // c:2546 ret = 0
3460    let recv = (flags & ZFTP_RECV) != 0; // c:2546 recv
3461    let mut getsize: i32 = 0; // c:2546 getsize = 0
3462    let progress: i32 = 1; // c:2546 progress = 1
3463    let cmd_pfx = if recv {
3464        "RETR "
3465    }
3466    // c:2547
3467    else if (flags & ZFTP_APPE) != 0 {
3468        "APPE "
3469    } else {
3470        "STOR "
3471    };
3472
3473    // c:2559 — zfsettype(ZFST_TYPE(zfstatusp[zfsessno]));
3474    let ttype = zftp_state()
3475        .lock()
3476        .ok()
3477        .and_then(|s| {
3478            s.current
3479                .as_deref()
3480                .and_then(|k| s.sessions.get(k))
3481                .map(|sess| sess.transfer_type)
3482        })
3483        .unwrap_or(ZFST_IMAG as i32);
3484    zfsettype(ttype);
3485
3486    if recv {
3487        let _ = io::stdout().flush();
3488    } // c:2561-2562
3489
3490    // c:2563 — for (; *args; args++) — with REST advancing args twice.
3491    let mut i = 0usize;
3492    while i < args.len() {
3493        let arg = args[i];
3494        let mut rest_cmd: String = String::new(); // c:2564 char *rest = NULL
3495        let mut startat: libc::off_t = 0; // c:2565
3496
3497        // c:2566-2587 — getsize hint via zfstats + initial progress
3498        // callback. Only fires when a zftp_progress shfunc is defined.
3499        if progress != 0 && getshfunc("zftp_progress").is_some() {
3500            let mut sz: libc::off_t = -1; // c:2567
3501            let mut _mdtm: Option<String> = None;
3502            // c:2576-2585 — SIZE probe gate:
3503            //   if ((!(zfprefs & ZFPF_DUMB) &&
3504            //        (zfstatusp[zfsessno] & (ZFST_NOSZ|ZFST_TRSZ))
3505            //         != ZFST_TRSZ)
3506            //       || !recv) {
3507            //       zfstats(...);
3508            //       if (recv && sz == -1) getsize = 1;
3509            //   } else
3510            //       getsize = 1;
3511            // "Probe SIZE if (not DUMB AND the cache doesn't say the
3512            // server embeds sizes) OR we're sending (need the LOCAL
3513            // file's size)." `(status & (NOSZ|TRSZ)) == ZFST_TRSZ`
3514            // ⟺ trsz && !nosz ⟺ first RETR's 150 reply parsed a
3515            // byte count — skip the SIZE round-trip and let zfgetdata
3516            // re-parse the reply (getsize = 1, c:2585). The session
3517            // trsz/nosz mirrors are written by zfgetdata (c:1102/1109).
3518            let dumb = (zfprefs.load(Ordering::Relaxed) & ZFPF_DUMB) != 0; // c:2576
3519            let server_embeds_size = zftp_state()
3520                .lock()
3521                .ok()
3522                .and_then(|s| {
3523                    s.current
3524                        .as_deref()
3525                        .and_then(|k| s.sessions.get(k))
3526                        .map(|sess| sess.trsz && !sess.nosz)
3527                })
3528                .unwrap_or(false); // c:2577 (status & (NOSZ|TRSZ)) == ZFST_TRSZ
3529            if (!dumb && !server_embeds_size) || !recv {
3530                // c:2576-2578
3531                // c:2580 — `zfstats(*args, recv, &sz, NULL, 0);` —
3532                // size only, NO mdtm probe (NULL second out-param).
3533                let _ = zfstats(
3534                    arg,
3535                    if recv { 1 } else { 0 }, // c:2580
3536                    Some(&mut sz),
3537                    None,
3538                    0,
3539                );
3540                if recv && sz == -1 {
3541                    // c:2582
3542                    getsize = 1; // c:2583
3543                }
3544            } else {
3545                getsize = 1; // c:2585
3546            }
3547            zfstarttrans(arg, if recv { 1 } else { 0 }, sz); // c:2587
3548        }
3549
3550        // c:2589-2592 — REST resume.
3551        if (flags & ZFTP_REST) != 0 && i + 1 < args.len() {
3552            startat = args[i + 1].parse().unwrap_or(0);
3553            rest_cmd = format!("REST {}\r\n", args[i + 1]);
3554        }
3555
3556        // c:2594 — ln = tricat(cmd, *args, "\r\n");
3557        let ln = format!("{}{}\r\n", cmd_pfx, arg);
3558
3559        // c:2596 — zfgetdata returns 0 on success, 1 on failure.
3560        let gd = zfgetdata(name, &rest_cmd, &ln, getsize);
3561        if gd != 0 {
3562            ret = 2; // c:2597
3563        } else {
3564            // c:2598 — zfsenddata(name, recv, progress, startat).
3565            if zfsenddata(name, if recv { 1 } else { 0 }, progress, startat) != 0 {
3566                ret = 1; // c:2599
3567            }
3568        }
3569        // c:2600 — zsfree(ln) — Rust Drop.
3570
3571        // c:2601-2616 — final progress callback:
3572        //
3573        //     /*
3574        //      * The progress report isn't started till zfsenddata(), where
3575        //      * it's the first item.  Hence we send a final progress report
3576        //      * if and only if we called zfsenddata();
3577        //      */
3578        //     if (progress && ret != 2 &&
3579        //         (shfunc = getshfunc("zftp_progress"))) {
3580        //         /* progress to finish: ZFTP_TRANSFER set to GF or PF */
3581        //         int osc = sfcontext;
3582        //
3583        //         zfsetparam("ZFTP_TRANSFER", ztrdup(recv ? "GF" : "PF"),
3584        //                    ZFPM_READONLY);
3585        //         sfcontext = SFC_HOOK;
3586        //         doshfunc(shfunc, NULL, 1);
3587        //         sfcontext = osc;
3588        //     }
3589        //
3590        // The hook lookup is part of the gate (same shape as the
3591        // per-chunk c:1604 block): no zftp_progress function → no
3592        // "GF"/"PF" finish-marker write — zfendtrans (c:2624) unsets
3593        // ZFTP_TRANSFER moments later regardless. A prior else-arm
3594        // wrote the marker even without a hook.
3595        if progress != 0 && ret != 2 {
3596            if let Some(mut shfunc) = getshfunc("zftp_progress") {
3597                // c:2607
3598                let osc = SFCONTEXT.load(Ordering::Relaxed); // c:2609
3599                zfsetparam(
3600                    "ZFTP_TRANSFER", // c:2611-2612
3601                    if recv { "GF" } else { "PF" },
3602                    ZFPM_READONLY,
3603                );
3604                SFCONTEXT.store(SFC_HOOK, Ordering::Relaxed); // c:2613
3605                                                              // c:2614 — `doshfunc(shfunc, NULL, 1);`.
3606                let body_runner = || -> i32 {
3607                    crate::ported::exec::run_function_body("zftp_progress", &[]).unwrap_or(0)
3608                };
3609                let _ = crate::ported::exec::doshfunc(
3610                    &mut shfunc,
3611                    vec!["zftp_progress".to_string()],
3612                    true,
3613                    body_runner,
3614                );
3615                SFCONTEXT.store(osc, Ordering::Relaxed); // c:2615
3616            }
3617        }
3618        // c:2617-2620 — REST consumed two args.
3619        if (flags & ZFTP_REST) != 0 {
3620            i += 1;
3621        }
3622        // c:2621-2622 — break on errflag.
3623        if errflag.load(Ordering::Relaxed) != 0 {
3624            break;
3625        }
3626        let _ = getsize;
3627        getsize = 0; // reset per-iteration
3628        i += 1;
3629    }
3630    zfendtrans(); // c:2635
3631    if ret != 0 {
3632        1
3633    } else {
3634        0
3635    } // c:2635
3636}
3637
3638/// Port of `zftp_delete(UNUSED(char *name), char **args, UNUSED(int flags))` from `Src/Modules/zftp.c:2635`.
3639/// C: walk `args`, send `DELE <name>\r\n` for each. Returns 1 if any
3640/// DELE got a 3xx+ reply, else 0.
3641#[allow(unused_variables)]
3642pub fn zftp_delete(name: &str, args: &[&str], flags: i32) -> i32 {
3643    // c:2635
3644    let mut ret: i32 = 0; // c:2635 int ret = 0
3645    let cmd: String; // c:2638 char *cmd
3646    let _ = cmd;
3647    for aptr in args.iter() {
3648        // c:2639 for (aptr = args; *aptr; aptr++)
3649        let cmd = format!("DELE {}\r\n", aptr); // c:2640 tricat("DELE ", *aptr, "\r\n")
3650        if zfsendcmd(&cmd) > 2 {
3651            // c:2652
3652            ret = 1; // c:2652
3653        }
3654        // c:2652 zsfree(cmd) — Rust Drop.
3655    }
3656    ret // c:2652
3657}
3658
3659/// Port of `zftp_mkdir(UNUSED(char *name), char **args, int flags)` from `Src/Modules/zftp.c:2652`.
3660/// C: send `MKD <args[0]>\r\n` (or `RMD` when ZFTP_DELE flag set —
3661/// the `rmdir` subcommand routes through this fn with that bit).
3662#[allow(unused_variables)]
3663pub fn zftp_mkdir(name: &str, args: &[&str], flags: i32) -> i32 {
3664    // c:2652
3665    let ret: i32; // c:2652 int ret
3666    if args.is_empty() {
3667        return 1;
3668    }
3669    let cmd_pfx = if (flags & ZFTP_DELE) != 0 {
3670        "RMD "
3671    } else {
3672        "MKD "
3673    }; // c:2666
3674    let cmd = format!("{}{}\r\n", cmd_pfx, args[0]); // c:2666 tricat
3675    ret = (zfsendcmd(&cmd) > 2) as i32; // c:2666
3676                                        // c:2666 zsfree — Rust Drop.
3677    ret // c:2666
3678}
3679
3680/// Port of `zftp_rename(UNUSED(char *name), char **args, UNUSED(int flags))` from `Src/Modules/zftp.c:2666`.
3681/// C: send RNFR (rename-from), expect 3xx, then send RNTO (rename-to),
3682/// expect 2xx. Two-phase rename per RFC959.
3683#[allow(unused_variables)]
3684pub fn zftp_rename(name: &str, args: &[&str], flags: i32) -> i32 {
3685    // c:2666
3686    let mut ret: i32; // c:2666 int ret
3687    let cmd: String; // c:2669 char *cmd
3688    let _ = cmd;
3689    if args.len() < 2 {
3690        return 1;
3691    }
3692
3693    let cmd = format!("RNFR {}\r\n", args[0]); // c:2671 tricat("RNFR ", args[0], "\r\n")
3694    ret = 1; // c:2672
3695    if zfsendcmd(&cmd) == 3 {
3696        // c:2673
3697        // c:2674 zsfree(cmd) — Rust Drop.
3698        let cmd = format!("RNTO {}\r\n", args[1]); // c:2675
3699        if zfsendcmd(&cmd) == 2 {
3700            // c:2676
3701            ret = 0; // c:2690
3702        }
3703    }
3704    // c:2690 zsfree(cmd) — Rust Drop.
3705    ret // c:2690
3706}
3707
3708/// Port of `zftp_quote(UNUSED(char *name), char **args, int flags)` from `Src/Modules/zftp.c:2690`.
3709/// C: send a raw FTP command, optionally prefixed with `SITE ` when
3710/// ZFTP_SITE flag is set (the `site` subcommand routes here with the
3711/// bit). The first arg is the verb; subsequent args are appended.
3712#[allow(unused_variables)]
3713pub fn zftp_quote(name: &str, args: &[&str], flags: i32) -> i32 {
3714    // c:2690
3715    let ret: i32; // c:2690 int ret = 0
3716    let cmd: String; // c:2693 char *cmd
3717    let _ = cmd;
3718    let argv: Vec<&str> = args.to_vec();
3719    let cmd = if (flags & ZFTP_SITE) != 0 {
3720        // c:2695
3721        zfargstring("SITE", &argv) // c:2695 — terminator from zfargstring c:559
3722    } else {
3723        if argv.is_empty() {
3724            return 1;
3725        }
3726        zfargstring(argv[0], &argv[1..]) // c:2696
3727    };
3728    ret = (zfsendcmd(&cmd) > 2) as i32; // c:2697
3729                                        // c:2698 zsfree — Rust Drop.
3730    ret // c:2700
3731}
3732
3733/// Port of `zfclose(int leaveparams)` from `Src/Modules/zftp.c:2711`.
3734/// C: `void zfclose(int leaveparams)` — close the control connection
3735/// (and optionally tear down the ZFTP_* params), run the zftp_chpwd
3736/// hook, reset zfclosing+zfdrrrring tidy-up flags.
3737#[allow(non_snake_case)]
3738pub fn zfclose(leaveparams: i32) {
3739    // c:2711
3740    // c:2715-2716 — early-return when no live control connection.
3741    let alive = zftp_state()
3742        .lock()
3743        .ok()
3744        .and_then(|s| {
3745            s.current
3746                .as_deref()
3747                .and_then(|k| s.sessions.get(k))
3748                .map(|sess| sess.control.is_some())
3749        })
3750        .unwrap_or(false);
3751    if !alive {
3752        // c:2715
3753        return; // c:2716
3754    }
3755    // c:2718 — zfclosing = 1.
3756    ZFCLOSING.store(1, Ordering::Relaxed); // c:2718
3757                                           // c:2719-2725 — send QUIT before teardown unless server already EOF'd.
3758    if ZCFINISH.load(Ordering::Relaxed) != 2 {
3759        // c:2719
3760        let _ = zfsendcmd("QUIT\r\n"); // c:2724
3761    }
3762    // c:2727-2737 — drop cin + control TcpStream + decrement zfnopen.
3763    if let Ok(mut state) = zftp_state().lock() {
3764        if let Some(sess) = state
3765            .current
3766            .clone()
3767            .and_then(|k| state.sessions.get_mut(&k))
3768        {
3769            sess.cin = None; // c:2735 fclose(cin)
3770            sess.control = None; // c:2745 tcp_close
3771            sess.dfd = -1;
3772            sess.connected = false;
3773            sess.logged_in = false;
3774        }
3775    }
3776    ZFNOPEN.fetch_sub(1, Ordering::Relaxed); // c:2743
3777
3778    // c:2749-2759 — zfstatusp ZFST_CLOS + close zfstatfd when last open
3779    // session goes away. zfstatfd substrate not ported — skipped.
3780
3781    // c:2761-2774 — !leaveparams: unset ZFTP_* params + zftp_chpwd hook.
3782    if leaveparams == 0 {
3783        // c:2761
3784        for n in [
3785            "ZFTP_HOST",
3786            "ZFTP_PORT",
3787            "ZFTP_IP",
3788            "ZFTP_SYSTEM", // c:2763 zfparams[]
3789            "ZFTP_USER",
3790            "ZFTP_ACCOUNT",
3791            "ZFTP_PWD",
3792            "ZFTP_TYPE",
3793            "ZFTP_MODE",
3794        ] {
3795            zfunsetparam(n); // c:2764
3796        }
3797        // c:2767-2773 — zftp_chpwd shfunc dispatch.
3798        if let Some(mut shfunc) = getshfunc("zftp_chpwd") {
3799            // c:2767
3800            let osc = SFCONTEXT.load(Ordering::Relaxed);
3801            SFCONTEXT.store(SFC_HOOK, Ordering::Relaxed); // c:2770
3802                                                          // c:2771 — `doshfunc(shfunc, NULL, 1);`.
3803            let body_runner = || -> i32 {
3804                crate::ported::exec::run_function_body("zftp_chpwd", &[]).unwrap_or(0)
3805            };
3806            let _ = crate::ported::exec::doshfunc(
3807                &mut shfunc,
3808                vec!["zftp_chpwd".to_string()],
3809                true,
3810                body_runner,
3811            );
3812            SFCONTEXT.store(osc, Ordering::Relaxed); // c:2772
3813        }
3814    }
3815    // c:2777 — zfclosing = zfdrrrring = 0.
3816    ZFCLOSING.store(0, Ordering::Relaxed); // c:2777
3817    ZFDRRRRING.store(0, Ordering::Relaxed); // c:2777
3818}
3819
3820/// Port of `zftp_close(UNUSED(char *name), UNUSED(char **args), UNUSED(int flags))` from `Src/Modules/zftp.c:2782`.
3821/// C: `static int zftp_close(UNUSED(char *name), UNUSED(char **args),
3822/// UNUSED(int flags))` — closes the current session's control
3823/// connection. Body is a single zfclose(0) call.
3824#[allow(unused_variables)]
3825pub fn zftp_close(name: &str, args: &[&str], flags: i32) -> i32 {
3826    // c:2782
3827    zfclose(0); // c:2782
3828    0 // c:2785
3829}
3830
3831/// Port of `newsession(char *nm)` from `Src/Modules/zftp.c:2803`.
3832///
3833/// Direct line-by-line port. Walks `zfsessions` looking for an
3834/// existing session named `nm` (c:2806-2812); if missing, allocates a
3835/// fresh `zftp_session`, registers it in the global state, sets
3836/// `dfd = -1`, and seeds an empty `params` slot (c:2814-2823). Both
3837/// paths leave `zfsess` (Rust: `current`) pointing at the named
3838/// session and refresh `ZFTP_SESSION` (c:2825). C signature is
3839/// `static void` — no return.
3840#[allow(non_snake_case)]
3841pub fn newsession(nm: &str) {
3842    // c:2806-2812 — walk zfsessions looking for a session matching `nm`.
3843    //                In Rust the linked-list walk collapses to an IndexMap
3844    //                contains_key; on hit we drop through (C `break`s out
3845    //                of the loop with zfsess pointing at the match — the
3846    //                walk assigns zfsess on every step) without
3847    //                re-inserting.
3848    if let Ok(mut state) = zftp_state().lock() {
3849        if !state.sessions.contains_key(nm) {
3850            // c:2814-2823 — alloc a fresh session, register it.
3851            let mut sess = zftp_session::new(nm); // c:2814 zshcalloc(sizeof(struct zftp_session))
3852            sess.name = nm.to_string(); // c:2815 zfsess->name = ztrdup(nm)
3853            sess.dfd = -1; // c:2816 zfsess->dfd = -1
3854            sess.params.clear(); // c:2817 — empty params slot
3855            state.sessions.insert(nm.to_string(), sess); // c:2818 zaddlinknode(zfsessions, zfsess)
3856                                                         // c:2820-2822 — zfsesscnt++ + zfstatusp realloc.
3857                                                         // Rust per-session status lives on zftp_session.transfer_type;
3858                                                         // counter is implicit in sessions.len().
3859        }
3860        // c:2806-2812 / c:2814 — either path leaves zfsess pointing at
3861        // the named session.
3862        state.current = Some(nm.to_string());
3863    }
3864    // c:2825 — zfsetparam("ZFTP_SESSION", ztrdup(zfsess->name), ZFPM_READONLY);
3865    zfsetparam("ZFTP_SESSION", nm, ZFPM_READONLY);
3866}
3867
3868/// Port of `savesession()` from `Src/Modules/zftp.c:2832`.
3869/// C: `static void savesession(void)` — copy each ZFTP_* shell param
3870/// into zfsess->params so session-switching preserves the values.
3871#[allow(non_snake_case)]
3872pub fn savesession() {
3873    // c:2832
3874    // c:2832 — char **ps, **pd, *val; (Rust uses indexing over slices)
3875    let val: String;
3876    let _ = val;
3877
3878    if let Ok(mut state) = zftp_state().lock() {
3879        let sess = match state
3880            .current
3881            .clone()
3882            .and_then(|k| state.sessions.get_mut(&k))
3883        {
3884            Some(s) => s,
3885            None => return,
3886        };
3887        // c:2836-2845 — for each zfparams[i], copy the current shell param.
3888        sess.params.clear();
3889        for ps in ZFPARAMS {
3890            // c:2836
3891            // c:2840 — `val = getsparam(*ps);`. paramtab is bucket-2-
3892            //          consolidated now; read directly. Was a fake env
3893            //          read which never picked up shell-internal params.
3894            let val = crate::ported::params::getsparam(ps).unwrap_or_default();
3895            // c:2856 / c:2843 — *pd = ztrdup(val) or NULL.
3896            sess.params.push(val);
3897        }
3898        // c:2856 — *pd = NULL; (terminator) — Rust Vec is self-terminating.
3899    }
3900}
3901
3902/// Port of `switchsession(char *nm)` from `Src/Modules/zftp.c:2856-2870`.
3903/// C body (verbatim):
3904///   newsession(nm);
3905///   for (ps = zfparams, pd = zfsess->params; *ps; ps++, pd++) {
3906///       if (*pd) {
3907///           zfsetparam(*ps, *pd, ZFPM_READONLY);
3908///           *pd = NULL;
3909///       } else
3910///           zfunsetparam(*ps);
3911///   }
3912#[allow(non_snake_case)]
3913pub fn switchsession(nm: &str) {
3914    // c:2860 — newsession(nm); creates the session if missing AND
3915    // sets zfsess + zfsessno.
3916    newsession(nm); // c:2860
3917                    // c:2862-2869 — walk the new session's saved-params slot. For each
3918                    // zfparams[i], if the saved slot has a value restore the shell
3919                    // param (readonly) and clear the slot; otherwise unset. Prior Rust
3920                    // port skipped this loop entirely, leaving stale ZFTP_* params
3921                    // from the prior session visible after `zftp session NEWSESS`.
3922    let saved: Vec<Option<String>> = if let Ok(mut state) = zftp_state().lock() {
3923        match state
3924            .current
3925            .clone()
3926            .and_then(|k| state.sessions.get_mut(&k))
3927        {
3928            Some(sess) => {
3929                let copy: Vec<Option<String>> = ZFPARAMS
3930                    .iter()
3931                    .enumerate()
3932                    .map(|(i, _)| sess.params.get(i).filter(|v| !v.is_empty()).cloned())
3933                    .collect();
3934                // c:2866 — `*pd = NULL;` after restoring; clear the
3935                // saved slot so a re-switch back to this session
3936                // doesn't double-restore stale values.
3937                sess.params.clear();
3938                copy
3939            }
3940            None => return,
3941        }
3942    } else {
3943        return;
3944    };
3945    for (i, ps) in ZFPARAMS.iter().enumerate() {
3946        // c:2862
3947        match saved.get(i).cloned().flatten() {
3948            Some(val) => {
3949                // c:2864 — zfsetparam(*ps, *pd, ZFPM_READONLY);
3950                zfsetparam(ps, &val, ZFPM_READONLY);
3951            }
3952            None => {
3953                // c:2867 — zfunsetparam(*ps);
3954                zfunsetparam(ps);
3955            }
3956        }
3957    }
3958}
3959
3960// === auto-generated stubs ===
3961// Direct ports of static helpers from Src/Modules/zftp.c not
3962// yet covered above. zshrs links modules statically; live
3963// state owned by the module's typed struct. Name-parity shims.
3964
3965/// Port of `freesession(Zftp_session sptr)` from `Src/Modules/zftp.c:2874`.
3966/// C: `static void freesession(Zftp_session sptr)` — release `sptr`'s
3967/// name + params + userparams + the struct itself.
3968#[allow(non_snake_case)]
3969pub fn freesession(sptr: &mut zftp_session) {
3970    // c:2874
3971    // c:2874 — zsfree(sptr->name);
3972    sptr.name.clear();
3973    // c:2878-2881 — walk zfparams + sptr->params freeing each param value.
3974    sptr.params.clear();
3975    // c:2882-2883 — if (sptr->userparams) freearray(sptr->userparams);
3976    sptr.userparams.clear();
3977    // c:2884 — zfree(sptr, sizeof(struct zftp_session)); the caller's
3978    // owning Box::drop releases the struct memory.
3979}
3980
3981/// Port of `zftp_rmsession(UNUSED(char *name), char **args, UNUSED(int flags))` from `Src/Modules/zftp.c:2915`.
3982#[allow(unused_variables)]
3983pub fn zftp_rmsession(name: &str, args: &[&str], flags: i32) -> i32 {
3984    // c:2915
3985    // c:2915-2920 — locals: no, sptr, newsess (Zftp_session linked-list).
3986    let mut found_name: Option<String> = None; // sptr identity
3987    let mut is_current: bool = false; // sptr == zfsess
3988    let mut newsess: Option<String> = None; // c:2920
3989
3990    // c:2922-2928 — find session by name (or current if no arg).
3991    {
3992        let state = match zftp_state().lock() {
3993            Ok(s) => s,
3994            Err(_) => return 1,
3995        };
3996        let current = state.current.as_deref().unwrap_or("default").to_string();
3997        let target = args
3998            .first()
3999            .copied()
4000            .map(String::from)
4001            .unwrap_or_else(|| current.clone());
4002        for sess_name in state.sessions.keys() {
4003            // c:2923
4004            if *sess_name == target {
4005                found_name = Some(sess_name.to_string());
4006                is_current = *sess_name == current;
4007                break;
4008            }
4009        }
4010    }
4011    let target_name = match found_name {
4012        // c:2929
4013        Some(n) => n,
4014        None => return 1, // c:2930
4015    };
4016
4017    // c:2932-2956 — closing logic differs by current-vs-other.
4018    if is_current {
4019        // c:2932
4020        zfclosedata(); // c:2934
4021        zfclose(0); // c:2935
4022                    // c:2941-2946 — pick a new current session if any others remain.
4023        let other = zftp_state()
4024            .lock()
4025            .ok()
4026            .map(|s| {
4027                s.sessions
4028                    .keys()
4029                    .find(|n| n.as_str() != target_name)
4030                    .map(String::from)
4031            })
4032            .unwrap_or(None);
4033        if let Some(o) = other {
4034            // c:2945
4035            newsess = Some(o);
4036        }
4037    } else {
4038        // c:2947
4039        // c:2948-2956 — temporarily switch to target, close-non-destructive,
4040        // then switch back. Rust collapses this to a direct close on
4041        // the named session.
4042        let prev = zftp_state().lock().ok().and_then(|s| s.current.clone());
4043        if let Ok(mut state) = zftp_state().lock() {
4044            state.current = Some(target_name.clone()); // c:2949 zfsess = sptr
4045        }
4046        zfclosedata(); // c:2954
4047        zfclose(1); // c:2955 (leaveparams=1)
4048        if let (Some(p), Ok(mut state)) = (prev, zftp_state().lock()) {
4049            state.current = Some(p); // c:2956 zfsess = oldsess
4050        }
4051    }
4052
4053    // c:2958 — remnode(zfsessions, nptr); shift_remove keeps the
4054    // remaining entries in list order like C's remnode.
4055    if let Ok(mut state) = zftp_state().lock() {
4056        state.sessions.shift_remove(&target_name);
4057    }
4058    if let Some(n) = newsess {
4059        // c:2982
4060        switchsession(&n); // c:2983
4061    } else if zftp_state()
4062        .lock()
4063        .map(|s| s.sessions.is_empty())
4064        .unwrap_or(false)
4065    {
4066        // c:2985-2992 — we've just deleted the last session, so we
4067        // need to start again from scratch (newsession sets zfsess).
4068        newsession("default");
4069    }
4070    0 // c:2995
4071}
4072
4073/// Port of `bin_zftp(char *name, char **args, UNUSED(Options ops), UNUSED(int func))` from `Src/Modules/zftp.c:3002`.
4074/// `zftp` builtin entry point — C-faithful signature matching
4075/// `static int bin_zftp(char *name, char **args, Options ops, int func)`
4076/// from Src/Modules/zftp.c:3002. Acquires ZFTP_STATE, dispatches by
4077/// subcommand string, emits any captured output to stdout/stderr
4078/// based on status, returns the bare i32 status C's execbuiltin path
4079/// consumes.
4080#[allow(non_snake_case)]
4081/// WARNING: param names don't match C — Rust=(_nam, args, _func) vs C=(name, args, ops, func)
4082pub fn bin_zftp(
4083    _nam: &str,
4084    args: &[String], // c:3002
4085    _ops: &options,
4086    _func: i32,
4087) -> i32 {
4088    let argv: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
4089
4090    // c:3074-3104 — recompute `zfprefs` from `$ZFTP_PREFS` on every
4091    // invocation. Letters: S=sendport (ZFPF_SNDP), P=passive
4092    // (ZFPF_PASV; ignored once SNDP is set per c:3090-3091), D=dumb
4093    // (ZFPF_DUMB). Unknown letters warn but don't abort.
4094    //
4095    // Prior Rust bin_zftp never read ZFTP_PREFS, so the boot_ default
4096    // of `PS` (ZFPF_SNDP|ZFPF_PASV from c:3219) was the only value
4097    // zfprefs ever held — user `ZFTP_PREFS=D` (dumb mode) was a
4098    // silent no-op.
4099    //
4100    // c:3074 + c:3105 — `queue_signals(); ... unqueue_signals();`.
4101    // C wraps the getsparam_u read + zfprefs mutation pair in
4102    // queue_signals so a SIGINT mid-parse can't observe a half-rewritten
4103    // zfprefs bitfield (a partial `D` parse without the prior `PS` left
4104    // intact would silently drop both sendport and passive modes for
4105    // any subsequent transfer in the signal handler chain). Mirror
4106    // exactly so the visible side-effect window matches C.
4107    crate::ported::signals_h::queue_signals(); // c:3074
4108    if let Some(prefs) = crate::ported::params::getsparam_u("ZFTP_PREFS") {
4109        zfprefs.store(0, Ordering::Relaxed); // c:3076 — zfprefs = 0;
4110        for ch in prefs.chars() {
4111            // c:3077
4112            match ch.to_ascii_uppercase() {
4113                // c:3078 toupper
4114                'S' => {
4115                    // c:3079
4116                    zfprefs.fetch_or(ZFPF_SNDP, Ordering::Relaxed); // c:3081
4117                }
4118                'P' => {
4119                    // c:3084 — skip when SNDP already set (c:3090-3091).
4120                    let cur = zfprefs.load(Ordering::Relaxed);
4121                    if (cur & ZFPF_SNDP) == 0 {
4122                        zfprefs.fetch_or(ZFPF_PASV, Ordering::Relaxed); // c:3091
4123                    }
4124                }
4125                'D' => {
4126                    // c:3094
4127                    zfprefs.fetch_or(ZFPF_DUMB, Ordering::Relaxed); // c:3096
4128                }
4129                _ => {
4130                    // c:3099
4131                    zwarnnam(_nam, &format!("preference {} not recognized", ch));
4132                }
4133            }
4134        }
4135    }
4136    crate::ported::signals_h::unqueue_signals(); // c:3105
4137
4138    // c:3052-3061 — pre-dispatch connection probe:
4139    //
4140    //     if (zfsess->control && !(zptr->flags & (ZFTP_TEST|ZFTP_SESS))) {
4141    //         /*
4142    //          * Test the connection for a bad fd or incoming message, but
4143    //          * only if the connection was last heard of open, and
4144    //          * if we are not about to call the test command anyway.
4145    //          * Not worth it unless we have select() or poll().
4146    //          */
4147    //         ret = zftp_test("zftp test", NULL, 0);
4148    //     }
4149    //
4150    // zftp_test (c:2270-2293): zero-timeout poll on the control fd;
4151    // poll error (non-EINTR/EAGAIN) → zfclose(0); readable → zfgetmsg()
4152    // drains the pending reply (the "421 Timeout" case — zfgetmsg's
4153    // own EOF handling closes the session). Returns 2 when the session
4154    // got dumped. The c:3063-3071 ZFTP_CONN gate then suppresses the
4155    // "not connected." message when ret == 2 ("enough messages
4156    // already"). Prior dispatcher skipped the probe entirely, so a
4157    // server-side timeout/disconnect surfaced as a confusing failure
4158    // of the NEXT command instead of the canonical 421 drain.
4159    let probe_dumped: bool = {
4160        let is_test = argv.first() == Some(&"test"); // ZFTP_TEST c:147
4161        let is_sess = matches!(argv.first(), Some(&"session") | Some(&"rmsession")); // ZFTP_SESS c:148
4162        let ctrl_fd: Option<i32> = zftp_state().lock().ok().and_then(|s| {
4163            s.current
4164                .as_deref()
4165                .and_then(|k| s.sessions.get(k))
4166                .and_then(|sess| {
4167                    use std::os::unix::io::AsRawFd;
4168                    sess.control.as_ref().map(|c| c.as_raw_fd())
4169                })
4170        });
4171        match (ctrl_fd, is_test || is_sess) {
4172            (Some(fd), false) => {
4173                // c:2270-2272 — poll(&pfd, 1, 0).
4174                let mut pfd = libc::pollfd {
4175                    fd,
4176                    events: libc::POLLIN,
4177                    revents: 0,
4178                };
4179                let ret = unsafe { libc::poll(&mut pfd, 1, 0) };
4180                if ret < 0 {
4181                    let eno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
4182                    if eno != libc::EINTR && eno != libc::EAGAIN {
4183                        zfclose(0); // c:2273
4184                    }
4185                } else if ret > 0 && pfd.revents != 0 {
4186                    /* handles 421 (maybe a bit noisily?) */
4187                    zfgetmsg(); // c:2276
4188                }
4189                // c:2293 — dumped iff control is gone now.
4190                zftp_state()
4191                    .lock()
4192                    .ok()
4193                    .and_then(|s| {
4194                        s.current
4195                            .as_deref()
4196                            .and_then(|k| s.sessions.get(k))
4197                            .and_then(|sess| sess.control.as_ref().map(|_| ()))
4198                    })
4199                    .is_none()
4200            }
4201            _ => false,
4202        }
4203    };
4204
4205    let mut zftp_guard = zftp_state().lock().unwrap_or_else(|e| {
4206        zftp_state_clear_poison();
4207        e.into_inner()
4208    });
4209    let zftp = &mut *zftp_guard;
4210    let args = &argv[..];
4211    let (status, output): (i32, String) = (|| {
4212        if args.is_empty() {
4213            return (1, "zftp: subcommand required\n".to_string());
4214        }
4215
4216        // c:3063-3072 — ZFTP_CONN gate with ret==2 suppression:
4217        //     if ((zptr->flags & ZFTP_CONN) && !zfsess->control) {
4218        //         if (ret != 2) {
4219        //             /* with ret == 2, we just got dumped out in the
4220        //              * test, so enough messages already. */
4221        //             zwarnnam(fullname, "not connected.");
4222        //         }
4223        //         return 1;
4224        //     }
4225        // The per-arm "not connected." messages below are the normal
4226        // path; when the probe just closed the session, exit 1 with
4227        // no message.
4228        if probe_dumped
4229            && !matches!(
4230                args[0],
4231                "open" | "params" | "test" | "session" | "rmsession"
4232            )
4233        {
4234            return (1, String::new()); // c:3064-3071
4235        }
4236
4237        match args[0] {
4238            "open" => {
4239                if args.len() < 2 {
4240                    return (1, "zftp open: host required\n".to_string());
4241                }
4242
4243                let host = args[1];
4244                let port: Option<u16> = args.get(2).and_then(|s| s.parse().ok());
4245
4246                let session_name = zftp.current.as_deref().unwrap_or("default").to_string();
4247
4248                let sess = zftp
4249                    .sessions
4250                    .entry(session_name.clone())
4251                    .or_insert_with(|| zftp_session::new(&session_name));
4252
4253                match sess.connect(host, port) {
4254                    Ok(resp) => {
4255                        if (resp.0 >= 100 && resp.0 < 400) {
4256                            zftp.current = Some(session_name.clone());
4257                            (0, resp.1)
4258                        } else {
4259                            (1, resp.1)
4260                        }
4261                    }
4262                    Err(e) => (1, format!("zftp open: {}\n", e)),
4263                }
4264            }
4265
4266            "login" | "user" => {
4267                if args.len() < 2 {
4268                    return (1, "zftp login: user required\n".to_string());
4269                }
4270
4271                let user = args[1];
4272                let pass = args.get(2).copied();
4273
4274                let sess = match zftp.current.clone().and_then(|k| zftp.sessions.get_mut(&k)) {
4275                    Some(s) => s,
4276                    None => return (1, "zftp login: not connected.\n".to_string()),
4277                };
4278
4279                match sess.login(user, pass) {
4280                    Ok(resp) => {
4281                        if (resp.0 >= 200 && resp.0 < 300) {
4282                            (0, resp.1)
4283                        } else {
4284                            (1, resp.1)
4285                        }
4286                    }
4287                    Err(e) => (1, format!("zftp login: {}\n", e)),
4288                }
4289            }
4290
4291            "cd" => {
4292                if args.len() < 2 {
4293                    return (1, "zftp cd: path required\n".to_string());
4294                }
4295
4296                let sess = match zftp.current.clone().and_then(|k| zftp.sessions.get_mut(&k)) {
4297                    Some(s) => s,
4298                    None => return (1, "zftp cd: not connected.\n".to_string()),
4299                };
4300
4301                match sess.cd(args[1]) {
4302                    Ok(resp) => {
4303                        if (resp.0 >= 200 && resp.0 < 300) {
4304                            (0, resp.1)
4305                        } else {
4306                            (1, resp.1)
4307                        }
4308                    }
4309                    Err(e) => (1, format!("zftp cd: {}\n", e)),
4310                }
4311            }
4312
4313            "cdup" => {
4314                let sess = match zftp.current.clone().and_then(|k| zftp.sessions.get_mut(&k)) {
4315                    Some(s) => s,
4316                    None => return (1, "zftp cdup: not connected.\n".to_string()),
4317                };
4318
4319                match sess.cdup() {
4320                    Ok(resp) => {
4321                        if (resp.0 >= 200 && resp.0 < 300) {
4322                            (0, resp.1)
4323                        } else {
4324                            (1, resp.1)
4325                        }
4326                    }
4327                    Err(e) => (1, format!("zftp cdup: {}\n", e)),
4328                }
4329            }
4330
4331            "pwd" => {
4332                let sess = match zftp.current.clone().and_then(|k| zftp.sessions.get_mut(&k)) {
4333                    Some(s) => s,
4334                    None => return (1, "zftp pwd: not connected.\n".to_string()),
4335                };
4336
4337                match sess.pwd() {
4338                    Ok((resp, pwd)) => {
4339                        if let Some(p) = pwd {
4340                            (0, format!("{}\n", p))
4341                        } else {
4342                            (1, resp.1)
4343                        }
4344                    }
4345                    Err(e) => (1, format!("zftp pwd: {}\n", e)),
4346                }
4347            }
4348
4349            "dir" | "ls" => {
4350                let path = args.get(1).copied();
4351                let use_nlst = args[0] == "ls";
4352
4353                let sess = match zftp.current.clone().and_then(|k| zftp.sessions.get_mut(&k)) {
4354                    Some(s) => s,
4355                    None => return (1, "zftp dir: not connected.\n".to_string()),
4356                };
4357
4358                let result = if use_nlst {
4359                    sess.nlst(path)
4360                } else {
4361                    sess.list(path)
4362                };
4363
4364                match result {
4365                    Ok((resp, lines)) => {
4366                        if (resp.0 >= 200 && resp.0 < 300) {
4367                            (0, lines.join("\n") + "\n")
4368                        } else {
4369                            (1, resp.1)
4370                        }
4371                    }
4372                    Err(e) => (1, format!("zftp dir: {}\n", e)),
4373                }
4374            }
4375
4376            "get" => {
4377                if args.len() < 2 {
4378                    return (1, "zftp get: remote file required\n".to_string());
4379                }
4380
4381                let remote = args[1];
4382                let local = args.get(2).unwrap_or(&remote);
4383
4384                let sess = match zftp.current.clone().and_then(|k| zftp.sessions.get_mut(&k)) {
4385                    Some(s) => s,
4386                    None => return (1, "zftp get: not connected.\n".to_string()),
4387                };
4388
4389                match sess.get(remote, Path::new(local)) {
4390                    Ok(resp) => {
4391                        if (resp.0 >= 200 && resp.0 < 300) {
4392                            (0, String::new())
4393                        } else {
4394                            (1, resp.1)
4395                        }
4396                    }
4397                    Err(e) => (1, format!("zftp get: {}\n", e)),
4398                }
4399            }
4400
4401            "put" => {
4402                if args.len() < 2 {
4403                    return (1, "zftp put: local file required\n".to_string());
4404                }
4405
4406                let local = args[1];
4407                let remote = args.get(2).unwrap_or(&local);
4408
4409                let sess = match zftp.current.clone().and_then(|k| zftp.sessions.get_mut(&k)) {
4410                    Some(s) => s,
4411                    None => return (1, "zftp put: not connected.\n".to_string()),
4412                };
4413
4414                match sess.put(Path::new(local), remote) {
4415                    Ok(resp) => {
4416                        if (resp.0 >= 200 && resp.0 < 300) {
4417                            (0, String::new())
4418                        } else {
4419                            (1, resp.1)
4420                        }
4421                    }
4422                    Err(e) => (1, format!("zftp put: {}\n", e)),
4423                }
4424            }
4425
4426            "delete" => {
4427                if args.len() < 2 {
4428                    return (1, "zftp delete: file required\n".to_string());
4429                }
4430
4431                let sess = match zftp.current.clone().and_then(|k| zftp.sessions.get_mut(&k)) {
4432                    Some(s) => s,
4433                    None => return (1, "zftp delete: not connected.\n".to_string()),
4434                };
4435
4436                match sess.delete(args[1]) {
4437                    Ok(resp) => {
4438                        if (resp.0 >= 200 && resp.0 < 300) {
4439                            (0, String::new())
4440                        } else {
4441                            (1, resp.1)
4442                        }
4443                    }
4444                    Err(e) => (1, format!("zftp delete: {}\n", e)),
4445                }
4446            }
4447
4448            "mkdir" => {
4449                if args.len() < 2 {
4450                    return (1, "zftp mkdir: directory required\n".to_string());
4451                }
4452
4453                let sess = match zftp.current.clone().and_then(|k| zftp.sessions.get_mut(&k)) {
4454                    Some(s) => s,
4455                    None => return (1, "zftp mkdir: not connected.\n".to_string()),
4456                };
4457
4458                match sess.mkdir(args[1]) {
4459                    Ok(resp) => {
4460                        if (resp.0 >= 200 && resp.0 < 300) {
4461                            (0, String::new())
4462                        } else {
4463                            (1, resp.1)
4464                        }
4465                    }
4466                    Err(e) => (1, format!("zftp mkdir: {}\n", e)),
4467                }
4468            }
4469
4470            "rmdir" => {
4471                if args.len() < 2 {
4472                    return (1, "zftp rmdir: directory required\n".to_string());
4473                }
4474
4475                let sess = match zftp.current.clone().and_then(|k| zftp.sessions.get_mut(&k)) {
4476                    Some(s) => s,
4477                    None => return (1, "zftp rmdir: not connected.\n".to_string()),
4478                };
4479
4480                match sess.rmdir(args[1]) {
4481                    Ok(resp) => {
4482                        if (resp.0 >= 200 && resp.0 < 300) {
4483                            (0, String::new())
4484                        } else {
4485                            (1, resp.1)
4486                        }
4487                    }
4488                    Err(e) => (1, format!("zftp rmdir: {}\n", e)),
4489                }
4490            }
4491
4492            "rename" => {
4493                if args.len() < 3 {
4494                    return (1, "zftp rename: from and to required\n".to_string());
4495                }
4496
4497                let sess = match zftp.current.clone().and_then(|k| zftp.sessions.get_mut(&k)) {
4498                    Some(s) => s,
4499                    None => return (1, "zftp rename: not connected.\n".to_string()),
4500                };
4501
4502                match sess.rename(args[1], args[2]) {
4503                    Ok(resp) => {
4504                        if (resp.0 >= 200 && resp.0 < 300) {
4505                            (0, String::new())
4506                        } else {
4507                            (1, resp.1)
4508                        }
4509                    }
4510                    Err(e) => (1, format!("zftp rename: {}\n", e)),
4511                }
4512            }
4513
4514            "type" | "ascii" | "binary" => {
4515                let transfer_type = match args[0] {
4516                    "ascii" => ZFST_ASCI,
4517                    "binary" => ZFST_IMAG,
4518                    "type" => {
4519                        if args.len() < 2 {
4520                            let sess =
4521                                match zftp.current.as_deref().and_then(|k| zftp.sessions.get(k)) {
4522                                    Some(s) => s,
4523                                    None => return (1, "zftp type: not connected.\n".to_string()),
4524                                };
4525                            return (
4526                                0,
4527                                format!(
4528                                    "{}\n",
4529                                    if sess.transfer_type == ZFST_ASCI {
4530                                        "ascii"
4531                                    } else {
4532                                        "binary"
4533                                    }
4534                                ),
4535                            );
4536                        }
4537                        match args[1].to_lowercase().as_str() {
4538                            "a" | "ascii" => ZFST_ASCI,
4539                            "i" | "binary" | "image" => ZFST_IMAG,
4540                            _ => return (1, format!("zftp type: unknown type {}\n", args[1])),
4541                        }
4542                    }
4543                    _ => unreachable!(),
4544                };
4545
4546                let sess = match zftp.current.clone().and_then(|k| zftp.sessions.get_mut(&k)) {
4547                    Some(s) => s,
4548                    None => return (1, "zftp type: not connected.\n".to_string()),
4549                };
4550
4551                match sess.set_type(transfer_type) {
4552                    Ok(resp) => {
4553                        if (resp.0 >= 200 && resp.0 < 300) {
4554                            (0, String::new())
4555                        } else {
4556                            (1, resp.1)
4557                        }
4558                    }
4559                    Err(e) => (1, format!("zftp type: {}\n", e)),
4560                }
4561            }
4562
4563            "bslashquote" => {
4564                if args.len() < 2 {
4565                    return (1, "zftp bslashquote: command required\n".to_string());
4566                }
4567
4568                let cmd = args[1..].join(" ");
4569
4570                let sess = match zftp.current.clone().and_then(|k| zftp.sessions.get_mut(&k)) {
4571                    Some(s) => s,
4572                    None => return (1, "zftp bslashquote: not connected.\n".to_string()),
4573                };
4574
4575                match sess.bslashquote(&cmd) {
4576                    Ok(resp) => (
4577                        if (resp.0 >= 100 && resp.0 < 400) {
4578                            0
4579                        } else {
4580                            1
4581                        },
4582                        resp.1,
4583                    ),
4584                    Err(e) => (1, format!("zftp bslashquote: {}\n", e)),
4585                }
4586            }
4587
4588            "close" | "quit" => {
4589                let sess = match zftp.current.clone().and_then(|k| zftp.sessions.get_mut(&k)) {
4590                    Some(s) => s,
4591                    None => return (0, String::new()),
4592                };
4593
4594                match sess.close() {
4595                    Ok(_) => (0, String::new()),
4596                    Err(e) => (1, format!("zftp close: {}\n", e)),
4597                }
4598            }
4599
4600            "session" => {
4601                // c:2891-2897 — no args: plain name list, one per line:
4602                //
4603                //     for (nptr = firstnode(zfsessions); nptr; incnode(nptr))
4604                //         printf("%s\n", ((Zftp_session)nptr->dat)->name);
4605                //
4606                // NO current-session marker, no indentation — a prior
4607                // arm decorated with "* "/"  " prefixes C never prints.
4608                if args.len() < 2 {
4609                    let mut out = String::new();
4610                    for name in zftp.sessions.keys() {
4611                        // c:2894 firstnode walk
4612                        out.push_str(&format!("{}\n", name)); // c:2895
4613                    }
4614                    return (0, out);
4615                }
4616                // c:2903-2904 — same-session no-op; c:2906-2907 —
4617                // savesession + switchsession. switchsession = newsession
4618                // (create-if-missing + set zfsess, c:2806-2823) inlined
4619                // here because bin_zftp already holds the state lock.
4620                let name = args[1];
4621                zftp.sessions
4622                    .entry(name.to_string())
4623                    .or_insert_with(|| zftp_session::new(name)); // c:2814-2818
4624                zftp.current = Some(name.to_string()); // c:2806-2812 zfsess
4625                (0, String::new())
4626            }
4627
4628            "rmsession" => {
4629                // c:2922-2930 — target is the named session, or the
4630                // CURRENT one when no name is given:
4631                //
4632                //     for (no = 0, nptr = firstnode(zfsessions); nptr; ...) {
4633                //         sptr = (Zftp_session) nptr->dat;
4634                //         if ((!*args && sptr == zfsess) ||
4635                //             (*args && !strcmp(sptr->name, *args)))
4636                //             break;
4637                //     }
4638                //     if (!nptr)
4639                //         return 1;
4640                //
4641                // Not-found exits 1 SILENTLY (no diagnostic). A prior
4642                // arm required a name and printed invented messages
4643                // for both the no-args and not-found cases.
4644                let target: String = match args.get(1) {
4645                    Some(n) => (*n).to_string(),
4646                    None => match zftp.current.as_deref() {
4647                        Some(c) => c.to_string(),          // c:2925 sptr == zfsess
4648                        None => return (1, String::new()), // c:2929-2930
4649                    },
4650                };
4651                // c:2958 — remnode(zfsessions, nptr); shift_remove
4652                // keeps the remaining entries in list order like C's
4653                // remnode.
4654                if zftp.sessions.shift_remove(&target).is_none() {
4655                    return (1, String::new()); // c:2929-2930 silent
4656                }
4657                if zftp.sessions.is_empty() {
4658                    // c:2985-2992 — we've just deleted the last
4659                    // session, so we need to start again from
4660                    // scratch: newsession("default").
4661                    zftp.sessions
4662                        .insert("default".to_string(), zftp_session::new("default"));
4663                    zftp.current = Some("default".to_string());
4664                } else if zftp.current.as_deref() == Some(target.as_str()) {
4665                    // c:2941-2946 + c:2983 — freed the current
4666                    // session: switch to the first in the list
4667                    // excluding the one just freed.
4668                    zftp.current = zftp.sessions.keys().next().cloned();
4669                }
4670                (0, String::new())
4671            }
4672
4673            "test" => {
4674                // c:158 zftpcmdtab — dispatches to zftp_test, which
4675                // does a real poll(2) liveness check at c:2271-2293
4676                // (consumes any unsolicited "421 Timeout" via zfgetmsg
4677                // then returns `zfsess->control ? 0 : 2`). The Rust
4678                // dispatch here can't call zftp_test directly because
4679                // bin_zftp holds the global zftp_state lock and
4680                // zftp_test would re-acquire it — deadlock.
4681                //
4682                // Inline the c:2271-2293 logic: read the control fd
4683                // off the held session, poll(2) it, and on POLLIN
4684                // call into a helper that doesn't re-lock. Without
4685                // the poll, server-side timeouts that closed the
4686                // control fd would report "connected" until the next
4687                // subcommand tried to use the dead fd.
4688                let control_fd: i32 = zftp
4689                    .current
4690                    .as_deref()
4691                    .and_then(|k| zftp.sessions.get(k))
4692                    .and_then(|s| s.control.as_ref().map(|c| c.as_raw_fd()))
4693                    .unwrap_or(-1);
4694                if control_fd < 0 {
4695                    (1, String::new()) // c:2263 — no control fd
4696                } else {
4697                    // c:2271-2280 — poll(0). POLLIN means unsolicited
4698                    // server message (almost always 421 Timeout).
4699                    let mut pfd = libc::pollfd {
4700                        fd: control_fd,
4701                        events: libc::POLLIN,
4702                        revents: 0,
4703                    };
4704                    let ret = unsafe { libc::poll(&mut pfd, 1, 0) };
4705                    let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
4706                    if ret < 0 && errno != libc::EINTR && errno != libc::EAGAIN {
4707                        // c:2273 — bad fd or hard error: connection dead.
4708                        if let Some(sess) =
4709                            zftp.current.clone().and_then(|k| zftp.sessions.get_mut(&k))
4710                        {
4711                            sess.control = None;
4712                            sess.connected = false;
4713                            sess.dfd = -1;
4714                        }
4715                        (2, String::new()) // c:2305 — control gone
4716                    } else {
4717                        // c:2305 — return 0 if control still alive.
4718                        let alive = zftp
4719                            .current
4720                            .as_deref()
4721                            .and_then(|k| zftp.sessions.get(k))
4722                            .map(|s| s.control.is_some())
4723                            .unwrap_or(false);
4724                        if alive {
4725                            (0, String::new())
4726                        } else {
4727                            (2, String::new())
4728                        }
4729                    }
4730                }
4731            }
4732
4733            // c:3013-3015 — `if (!zptr->nam) { zwarnnam(name,
4734            //   "no such subcommand: %s", cnam); return 1; }`. C uses
4735            // "no such subcommand:" (with colon); prior Rust port used
4736            // "unknown subcommand" (no colon). Match the exact C
4737            // diagnostic so test harnesses pattern-matching the
4738            // documented error string see the same bytes.
4739            _ => (1, format!("zftp: no such subcommand: {}\n", args[0])),
4740        }
4741    })();
4742    drop(zftp_guard);
4743
4744    // c:3109-3115 — post-dispatch tidy-up. Sub-commands that opened a
4745    // SIGALRM via zfalarm() expect bin_zftp to disarm it after dispatch
4746    // (the alarm survives the subcommand returning normally). If
4747    // ZFDRRRRING was set (timeout fired mid-syscall, signal flag bumped
4748    // by zfhandler) we tear down the connection, suppressing QUIT via
4749    // zcfinish=2 so zfclose doesn't sit on a doomed socket waiting for
4750    // a goodbye reply. zfclose acquires the state lock so it MUST come
4751    // after `drop(zftp_guard)` above.
4752    if ZFALARMED.load(Ordering::Relaxed) != 0 {
4753        // c:3109
4754        zfunalarm(); // c:3110
4755    }
4756    if ZFDRRRRING.load(Ordering::Relaxed) != 0 {
4757        // c:3111
4758        ZCFINISH.store(2, Ordering::Relaxed); // c:3113 — skip QUIT
4759        zfclose(0); // c:3114
4760    }
4761    // c:3116-3123 — zfstatfd shared-status fd-write for sibling subshells.
4762    // The shared-status fd substrate isn't ported; the per-session
4763    // status fields on zftp_session already survive across subcommands
4764    // since they live on the in-process zftp_state singleton.
4765
4766    if !output.is_empty() {
4767        if status == 0 {
4768            print!("{}", output);
4769        } else {
4770            eprint!("{}", output);
4771        }
4772    }
4773    status
4774}
4775
4776/// Port of `zftp_cleanup()` from `Src/Modules/zftp.c:3128`. Walks
4777/// every session sending QUIT (via zfclose) on the current one and
4778/// closing the control fd on others, then clears the session table.
4779pub fn zftp_cleanup() -> i32 {
4780    // c:3128
4781    // c:3128 — Zftp_session cursess = zfsess; (snapshot current).
4782    let cursess = zftp_state().lock().ok().and_then(|s| s.current.clone());
4783    let session_names: Vec<String> = zftp_state()
4784        .lock()
4785        .ok()
4786        .map(|s| s.sessions.keys().map(|n| n.to_string()).collect())
4787        .unwrap_or_default();
4788    // c:3135-3142 — walk every session: zfclosedata + zfclose(leaveparams).
4789    for nm in session_names {
4790        // c:3136
4791        // c:3137 — zfsess = (Zftp_session)nptr->dat; (switch to session).
4792        if let Ok(mut s) = zftp_state().lock() {
4793            s.current = Some(nm.clone()); // c:3137 zfsess = nptr->dat
4794        }
4795        zfclosedata(); // c:3140
4796                       // c:3144 — zfclose(zfsess != cursess): non-current sessions keep
4797                       // their params; current session goes through full unset.
4798        let leaveparams = if Some(&nm) != cursess.as_ref() { 1 } else { 0 };
4799        zfclose(leaveparams); // c:3144
4800    }
4801    // c:3146-3151 — clear lastmsg, ZFTP_SESSION, freelinklist sessions.
4802    if let Ok(mut m) = lastmsg.lock() {
4803        m.clear(); // c:3147
4804    }
4805    zfunsetparam("ZFTP_SESSION"); // c:3149
4806    if let Ok(mut state) = zftp_state().lock() {
4807        *state = zftp_globals::default(); // c:3150 freelinklist
4808    }
4809    // c:3152 — zfree(zfstatusp): per-session status array. zfstatusp
4810    // substrate isn't ported (per-session bits live on zftp_session
4811    // directly), so nothing to free.
4812    0
4813}
4814
4815impl zftp_session {
4816    /// Port of `newsession(char *nm)` from `Src/Modules/zftp.c`. C uses
4817    /// `zshcalloc(sizeof(struct zftp_session))` then sets `name`
4818    /// (c:2891 `zfsess->name = ztrdup(name);`) and pre-allocates the
4819    /// `params` / `userparams` arrays. Same default state.
4820    pub fn new(name: &str) -> Self {
4821        Self {
4822            // C-faithful fields from `struct zftp_session` (c:299):
4823            name: name.to_string(), // c:300
4824            params: Vec::new(),     // c:301
4825            userparams: Vec::new(), // c:302
4826            cin: None,              // c:303 NULL
4827            control: None,          // c:304 NULL
4828            dfd: -1,                // c:305 (closed)
4829            has_size: 0,            // c:306
4830            has_mdtm: 0,            // c:307
4831            // Ergonomic Rust-side state mirroring C's params[] indices:
4832            host: None,
4833            port: 21,
4834            user: None,
4835            pwd: None,
4836            connected: false,
4837            logged_in: false,
4838            transfer_type: ZFST_IMAG,
4839            // c:2226 — initial current_type is ZFST_CASC (0); the SYST probe
4840            // in zftp_login sends the first TYPE I when it detects UNIX L8.
4841            current_type: ZFST_CASC,
4842            transfer_mode: ZFST_STRE,
4843            passive: true,
4844            syst_probed: false,
4845            nops_probed: false,
4846            trsz: false, // ZFST_TRSZ fresh-session default
4847            nosz: false, // ZFST_NOSZ fresh-session default
4848        }
4849    }
4850
4851    /// Port of `zftp_open(char *name, char **args, int flags)` from `Src/Modules/zftp.c:1690`.
4852    fn send_command(&mut self, cmd: &str) -> io::Result<()> {
4853        if let Some(ref mut stream) = self.cin {
4854            write!(stream, "{}\r\n", cmd)?;
4855            stream.flush()
4856        } else {
4857            Err(io::Error::new(io::ErrorKind::NotConnected, "not connected"))
4858        }
4859    }
4860
4861    /// Port of `zfgetmsg()` from `Src/Modules/zftp.c:702`.
4862    fn read_response(&mut self) -> io::Result<FtpResponse> {
4863        let stream = self
4864            .cin
4865            .as_mut()
4866            .ok_or_else(|| io::Error::new(io::ErrorKind::NotConnected, "not connected"))?;
4867
4868        let mut reader = BufReader::new(stream.try_clone()?);
4869        let mut full_message = String::new();
4870        let mut code = 0u32;
4871        let mut multiline = false;
4872        let mut first_code = String::new();
4873
4874        loop {
4875            let mut line = String::new();
4876            reader.read_line(&mut line)?;
4877            let line = line.trim_end();
4878
4879            if line.len() < 3 {
4880                continue;
4881            }
4882
4883            if code == 0 {
4884                first_code = line[..3].to_string();
4885                code = first_code.parse().unwrap_or(0);
4886
4887                if line.len() > 3 && line.chars().nth(3) == Some('-') {
4888                    multiline = true;
4889                }
4890            }
4891
4892            full_message.push_str(line);
4893            full_message.push('\n');
4894
4895            if multiline {
4896                if line.starts_with(&first_code)
4897                    && line.len() > 3
4898                    && line.chars().nth(3) == Some(' ')
4899                {
4900                    break;
4901                }
4902            } else {
4903                break;
4904            }
4905        }
4906
4907        Ok((code as i32, full_message))
4908    }
4909
4910    /// Port of `zftp_open(char *name, char **args, int flags)` from `Src/Modules/zftp.c:1690`.
4911    /// Connect to FTP server — DNS resolution on background thread to avoid hangs
4912    pub fn connect(&mut self, host: &str, port: Option<u16>) -> io::Result<FtpResponse> {
4913        let port = port.unwrap_or(21);
4914        let addr_str = format!("{}:{}", host, port);
4915        let dns_timeout = Duration::from_secs(10);
4916
4917        // DNS on background thread
4918        let (tx, rx) = std::sync::mpsc::channel();
4919        let dns = addr_str.clone();
4920        std::thread::Builder::new()
4921            .name("zftp-dns".to_string())
4922            .spawn(move || {
4923                let _ = tx.send(dns.to_socket_addrs().map(|a| a.collect::<Vec<_>>()));
4924            })
4925            .map_err(io::Error::other)?;
4926
4927        let addrs = rx
4928            .recv_timeout(dns_timeout)
4929            .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "DNS resolution timed out"))?
4930            .map_err(|e| {
4931                // C: `zwarnnam("zftp", "host not found: %s", host);` at
4932                //    `Src/Modules/zftp.c:1715`. Route through canonical
4933                //    zwarnnam so the user sees a real shell warning
4934                //    instead of a tracing log line.
4935                zwarnnam("zftp", &format!("host not found: {}: {}", host, e));
4936                e
4937            })?;
4938
4939        let sock_addr = addrs
4940            .into_iter()
4941            .next()
4942            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid address"))?;
4943
4944        let stream = TcpStream::connect_timeout(&sock_addr, Duration::from_secs(30))?;
4945
4946        stream.set_read_timeout(Some(Duration::from_secs(60)))?;
4947        stream.set_write_timeout(Some(Duration::from_secs(60)))?;
4948
4949        self.cin = Some(stream);
4950        self.host = Some(host.to_string());
4951        self.port = port;
4952        self.connected = true;
4953
4954        self.read_response()
4955    }
4956
4957    /// Port of `zftp_login(char *name, char **args, UNUSED(int flags))` from `Src/Modules/zftp.c:2118`.
4958    /// Login to FTP server
4959    pub fn login(&mut self, user: &str, pass: Option<&str>) -> io::Result<FtpResponse> {
4960        self.send_command(&format!("USER {}", user))?;
4961        let resp = self.read_response()?;
4962
4963        if resp.0 == 331 {
4964            let password = pass.unwrap_or("");
4965            self.send_command(&format!("PASS {}", password))?;
4966            let resp = self.read_response()?;
4967
4968            if (resp.0 >= 200 && resp.0 < 300) {
4969                self.logged_in = true;
4970                self.user = Some(user.to_string());
4971            }
4972            return Ok(resp);
4973        }
4974
4975        if (resp.0 >= 200 && resp.0 < 300) {
4976            self.logged_in = true;
4977            self.user = Some(user.to_string());
4978        }
4979
4980        Ok(resp)
4981    }
4982
4983    /// WARNING: NOT IN ZFTP.C — method on Rust-only `zftp_session` wrapper.
4984    /// C inlines this pattern at every callsite; Rust factors it onto the wrapper.
4985    /// Set transfer type
4986    pub fn set_type(&mut self, transfer_type: i32) -> io::Result<FtpResponse> {
4987        // C inline pattern: `(typ & ZFST_IMAG) ? "I" : "A"`
4988        let typ_letter = if (transfer_type & ZFST_IMAG) != 0 {
4989            "I"
4990        } else {
4991            "A"
4992        };
4993        self.send_command(&format!("TYPE {}", typ_letter))?;
4994        let resp = self.read_response()?;
4995        if (resp.0 >= 200 && resp.0 < 300) {
4996            self.transfer_type = transfer_type;
4997        }
4998        Ok(resp)
4999    }
5000
5001    /// WARNING: NOT IN ZFTP.C — method on Rust-only `zftp_session` wrapper.
5002    /// C inlines this pattern at every callsite; Rust factors it onto the wrapper.
5003    /// Change directory
5004    pub fn cd(&mut self, path: &str) -> io::Result<FtpResponse> {
5005        self.send_command(&format!("CWD {}", path))?;
5006        self.read_response()
5007    }
5008
5009    /// WARNING: NOT IN ZFTP.C — method on Rust-only `zftp_session` wrapper.
5010    /// C inlines this pattern at every callsite; Rust factors it onto the wrapper.
5011    /// Change to parent directory
5012    pub fn cdup(&mut self) -> io::Result<FtpResponse> {
5013        self.send_command("CDUP")?;
5014        self.read_response()
5015    }
5016
5017    /// WARNING: NOT IN ZFTP.C — method on Rust-only `zftp_session` wrapper.
5018    /// C inlines this pattern at every callsite; Rust factors it onto the wrapper.
5019    /// Get current directory
5020    pub fn pwd(&mut self) -> io::Result<(FtpResponse, Option<String>)> {
5021        self.send_command("PWD")?;
5022        let resp = self.read_response()?;
5023
5024        let pwd = if (resp.0 >= 200 && resp.0 < 300) {
5025            if let Some(start) = resp.1.find('"') {
5026                if let Some(end) = resp.1[start + 1..].find('"') {
5027                    Some(resp.1[start + 1..start + 1 + end].to_string())
5028                } else {
5029                    None
5030                }
5031            } else {
5032                None
5033            }
5034        } else {
5035            None
5036        };
5037
5038        Ok((resp, pwd))
5039    }
5040
5041    /// WARNING: NOT IN ZFTP.C — method on Rust-only `zftp_session` wrapper.
5042    /// C inlines this pattern at every callsite; Rust factors it onto the wrapper.
5043    /// List directory
5044    pub fn list(&mut self, path: Option<&str>) -> io::Result<(FtpResponse, Vec<String>)> {
5045        let data_stream = self.enter_passive_mode()?;
5046
5047        let cmd = match path {
5048            Some(p) => format!("LIST {}", p),
5049            None => "LIST".to_string(),
5050        };
5051        self.send_command(&cmd)?;
5052        let resp = self.read_response()?;
5053
5054        if !(resp.0 >= 100 && resp.0 < 400) {
5055            return Ok((resp, Vec::new()));
5056        }
5057
5058        let mut reader = BufReader::new(data_stream);
5059        let mut lines = Vec::new();
5060        let mut line = String::new();
5061        while reader.read_line(&mut line)? > 0 {
5062            lines.push(line.trim_end().to_string());
5063            line.clear();
5064        }
5065
5066        let final_resp = self.read_response()?;
5067
5068        Ok((final_resp, lines))
5069    }
5070
5071    /// WARNING: NOT IN ZFTP.C — method on Rust-only `zftp_session` wrapper.
5072    /// C inlines this pattern at every callsite; Rust factors it onto the wrapper.
5073    /// List filenames only
5074    pub fn nlst(&mut self, path: Option<&str>) -> io::Result<(FtpResponse, Vec<String>)> {
5075        let data_stream = self.enter_passive_mode()?;
5076
5077        let cmd = match path {
5078            Some(p) => format!("NLST {}", p),
5079            None => "NLST".to_string(),
5080        };
5081        self.send_command(&cmd)?;
5082        let resp = self.read_response()?;
5083
5084        if !(resp.0 >= 100 && resp.0 < 400) {
5085            return Ok((resp, Vec::new()));
5086        }
5087
5088        let mut reader = BufReader::new(data_stream);
5089        let mut lines = Vec::new();
5090        let mut line = String::new();
5091        while reader.read_line(&mut line)? > 0 {
5092            lines.push(line.trim_end().to_string());
5093            line.clear();
5094        }
5095
5096        let final_resp = self.read_response()?;
5097
5098        Ok((final_resp, lines))
5099    }
5100
5101    /// Port of `zftp_open(char *name, char **args, int flags)` from `Src/Modules/zftp.c:1690`.
5102    fn enter_passive_mode(&mut self) -> io::Result<TcpStream> {
5103        self.send_command("PASV")?;
5104        let resp = self.read_response()?;
5105
5106        if !(resp.0 >= 200 && resp.0 < 300) {
5107            return Err(io::Error::other(resp.1));
5108        }
5109
5110        // c:934-936 — "lastmsg already has the reply code expunged":
5111        // the C parse operates on lastmsg, which zfgetmsg stored with
5112        // the "NNN " / "NNN-" prefix stripped (c:781). read_response
5113        // returns the raw line, so strip the 4-byte code prefix here;
5114        // passing it through would parse "227" as the first IP octet.
5115        let (ip, port) = parse_pasv_response(resp.1.get(4..).unwrap_or(""))?;
5116        let addr = format!("{}:{}", ip, port);
5117
5118        TcpStream::connect_timeout(
5119            &addr.to_socket_addrs()?.next().ok_or_else(|| {
5120                io::Error::new(io::ErrorKind::InvalidInput, "invalid PASV address")
5121            })?,
5122            Duration::from_secs(30),
5123        )
5124    }
5125
5126    /// Port of `zfstats(char *fnam, int remote, off_t *retsize, char **retmdtm, int fd)` from `Src/Modules/zftp.c:1193`.
5127    /// Download a file
5128    pub fn get(&mut self, remote: &str, local: &Path) -> io::Result<FtpResponse> {
5129        let mut data_stream = self.enter_passive_mode()?;
5130
5131        self.send_command(&format!("RETR {}", remote))?;
5132        let resp = self.read_response()?;
5133
5134        if !(resp.0 >= 100 && resp.0 < 400) {
5135            return Ok(resp);
5136        }
5137
5138        let mut file = std::fs::File::create(local)?;
5139        let mut buf = [0u8; 8192];
5140        loop {
5141            let n = data_stream.read(&mut buf)?;
5142            if n == 0 {
5143                break;
5144            }
5145            file.write_all(&buf[..n])?;
5146        }
5147
5148        self.read_response()
5149    }
5150
5151    /// Port of `zfstats(char *fnam, int remote, off_t *retsize, char **retmdtm, int fd)` from `Src/Modules/zftp.c:1193`.
5152    /// Upload a file
5153    pub fn put(&mut self, local: &Path, remote: &str) -> io::Result<FtpResponse> {
5154        let mut data_stream = self.enter_passive_mode()?;
5155
5156        self.send_command(&format!("STOR {}", remote))?;
5157        let resp = self.read_response()?;
5158
5159        if !(resp.0 >= 100 && resp.0 < 400) {
5160            return Ok(resp);
5161        }
5162
5163        let mut file = std::fs::File::open(local)?;
5164        let mut buf = [0u8; 8192];
5165        loop {
5166            let n = file.read(&mut buf)?;
5167            if n == 0 {
5168                break;
5169            }
5170            data_stream.write_all(&buf[..n])?;
5171        }
5172        drop(data_stream);
5173
5174        self.read_response()
5175    }
5176
5177    /// WARNING: NOT IN ZFTP.C — method on Rust-only `zftp_session` wrapper.
5178    /// C inlines this pattern at every callsite; Rust factors it onto the wrapper.
5179    /// Delete a file
5180    pub fn delete(&mut self, path: &str) -> io::Result<FtpResponse> {
5181        self.send_command(&format!("DELE {}", path))?;
5182        self.read_response()
5183    }
5184
5185    /// WARNING: NOT IN ZFTP.C — method on Rust-only `zftp_session` wrapper.
5186    /// C inlines this pattern at every callsite; Rust factors it onto the wrapper.
5187    /// Make directory
5188    pub fn mkdir(&mut self, path: &str) -> io::Result<FtpResponse> {
5189        self.send_command(&format!("MKD {}", path))?;
5190        self.read_response()
5191    }
5192
5193    /// WARNING: NOT IN ZFTP.C — method on Rust-only `zftp_session` wrapper.
5194    /// C inlines this pattern at every callsite; Rust factors it onto the wrapper.
5195    /// Remove directory
5196    pub fn rmdir(&mut self, path: &str) -> io::Result<FtpResponse> {
5197        self.send_command(&format!("RMD {}", path))?;
5198        self.read_response()
5199    }
5200
5201    /// WARNING: NOT IN ZFTP.C — method on Rust-only `zftp_session` wrapper.
5202    /// C inlines this pattern at every callsite; Rust factors it onto the wrapper.
5203    /// Rename file
5204    pub fn rename(&mut self, from: &str, to: &str) -> io::Result<FtpResponse> {
5205        self.send_command(&format!("RNFR {}", from))?;
5206        let resp = self.read_response()?;
5207
5208        if !(resp.0 >= 300 && resp.0 < 400) {
5209            return Ok(resp);
5210        }
5211
5212        self.send_command(&format!("RNTO {}", to))?;
5213        self.read_response()
5214    }
5215
5216    /// WARNING: NOT IN ZFTP.C — method on Rust-only `zftp_session` wrapper.
5217    /// C inlines this pattern at every callsite; Rust factors it onto the wrapper.
5218    /// Get file size
5219    pub fn size(&mut self, path: &str) -> io::Result<(FtpResponse, Option<u64>)> {
5220        self.send_command(&format!("SIZE {}", path))?;
5221        let resp = self.read_response()?;
5222
5223        let size = if (resp.0 >= 200 && resp.0 < 300) {
5224            resp.1
5225                .split_whitespace()
5226                .last()
5227                .and_then(|s| s.parse().ok())
5228        } else {
5229            None
5230        };
5231
5232        Ok((resp, size))
5233    }
5234
5235    /// WARNING: NOT IN ZFTP.C — method on Rust-only `zftp_session` wrapper.
5236    /// C inlines this pattern at every callsite; Rust factors it onto the wrapper.
5237    /// Send raw command
5238    pub fn bslashquote(&mut self, cmd: &str) -> io::Result<FtpResponse> {
5239        self.send_command(cmd)?;
5240        self.read_response()
5241    }
5242
5243    /// Port of `zftp_open(char *name, char **args, int flags)` from `Src/Modules/zftp.c:1690`.
5244    /// Close connection
5245    pub fn close(&mut self) -> io::Result<FtpResponse> {
5246        if !self.connected {
5247            return Ok((0, "not connected".to_string()));
5248        }
5249
5250        let resp = if let Ok(()) = self.send_command("QUIT") {
5251            self.read_response()
5252                .unwrap_or_else(|_| (221, "Goodbye".to_string()))
5253        } else {
5254            (221, "Goodbye".to_string())
5255        };
5256
5257        self.cin = None;
5258        self.connected = false;
5259        self.logged_in = false;
5260        self.host = None;
5261        self.user = None;
5262        self.pwd = None;
5263
5264        Ok(resp)
5265    }
5266}
5267
5268/// Port of `zftpexithook(UNUSED(Hookdef d), UNUSED(void *dummy))` from Src/Modules/zftp.c:3156.
5269/// C: `static int zftpexithook(UNUSED(Hookdef d), UNUSED(void *dummy))`
5270/// — calls `zftp_cleanup()`, returns 0.
5271#[allow(non_snake_case)]
5272#[allow(unused_variables)]
5273pub fn zftpexithook(d: *mut crate::ported::zsh_h::hookdef, dummy: *mut std::ffi::c_void) -> i32 {
5274    let _ = (d, dummy);
5275    zftp_cleanup(); // c:3156
5276    0 // c:3159
5277}
5278
5279// `bintab` — port of `static struct builtin bintab[]` (zftp.c).
5280
5281// `module_features` — port of `static struct features module_features`
5282// from zftp.c:3163.
5283
5284/// Port of `setup_(UNUSED(Module m))` from `Src/Modules/zftp.c:3174`.
5285#[allow(unused_variables)]
5286pub fn setup_(m: *const module) -> i32 {
5287    // c:3174
5288    // C body c:3176-3177 — `return 0`. Faithful empty-body port.
5289    0
5290}
5291
5292/// Port of `features_(UNUSED(Module m), UNUSED(char ***features))` from `Src/Modules/zftp.c:3181`.
5293pub fn features_(m: *const module, features: &mut Vec<String>) -> i32 {
5294    *features = featuresarray(m, module_features());
5295    0
5296}
5297
5298/// Port of `enables_(UNUSED(Module m), UNUSED(int **enables))` from `Src/Modules/zftp.c:3189`.
5299pub fn enables_(m: *const module, enables: &mut Option<Vec<i32>>) -> i32 {
5300    handlefeatures(m, module_features(), enables)
5301}
5302
5303// ===========================================================
5304// Methods moved verbatim from src/ported/vm_helper because their
5305// C counterpart's source file maps 1:1 to this Rust module.
5306// Phase: module-shims
5307// ===========================================================
5308
5309// BEGIN moved-from-exec-rs
5310// (impl ShellExecutor block moved to src/exec_shims.rs — see file marker)
5311
5312// END moved-from-exec-rs
5313
5314// =====================================================================
5315// static struct features module_features                            c:3163 (zftp.c)
5316// =====================================================================
5317
5318/// Port of `boot_(UNUSED(Module m))` from `Src/Modules/zftp.c:3196`.
5319#[allow(unused_variables)]
5320pub fn boot_(m: *const module) -> i32 {
5321    // c:3196
5322    // C body c:3198-3214:
5323    //   off_t tmout_def = 60;
5324    //   zfsetparam("ZFTP_VERBOSE", "450", ZFPM_IFUNSET);
5325    //   zfsetparam("ZFTP_TMOUT", &tmout_def, ZFPM_IFUNSET|ZFPM_INTEGER);
5326    //   zfsetparam("ZFTP_PREFS", "PS", ZFPM_IFUNSET);
5327    //   zfprefs = ZFPF_SNDP|ZFPF_PASV;
5328    //   zfsessions = znewlinklist(); newsession("default");
5329    //   addhookfunc("exit", zftpexithook);
5330    zfsetparam("ZFTP_VERBOSE", "450", ZFPM_IFUNSET); // c:3203
5331    zfsetparam("ZFTP_TMOUT", "60", ZFPM_IFUNSET | ZFPM_INTEGER); // c:3219
5332    zfsetparam("ZFTP_PREFS", "PS", ZFPM_IFUNSET); // c:3219
5333    zfprefs.store(
5334        ZFPF_SNDP | ZFPF_PASV, // c:3219
5335        Ordering::Relaxed,
5336    );
5337    newsession("default"); // c:3219
5338                           // c:3219 — `addhookfunc("exit", zftpexithook)` — register the
5339                           // process-exit cleanup so all live ftp sessions get torn down
5340                           // before the shell exits.
5341    crate::ported::module::addhookfunc("exit", zftpexithook as crate::ported::zsh_h::Hookfn);
5342    0
5343}
5344
5345/// Port of `cleanup_(UNUSED(Module m))` from `Src/Modules/zftp.c:3219`.
5346pub fn cleanup_(m: *const module) -> i32 {
5347    // c:3219
5348    // c:3228 — `deletehookfunc("exit", zftpexithook)` — drop the
5349    // exit-hook registration before the module unloads.
5350    crate::ported::module::deletehookfunc("exit", zftpexithook as crate::ported::zsh_h::Hookfn);
5351    // c:3228 — `zftp_cleanup()`: close every live session.
5352    zftp_cleanup(); // c:3228
5353    setfeatureenables(m, module_features(), None) // c:3228
5354}
5355
5356/// Port of `finish_(UNUSED(Module m))` from `Src/Modules/zftp.c:3228`.
5357#[allow(unused_variables)]
5358pub fn finish_(m: *const module) -> i32 {
5359    // c:3228
5360    // C body c:3230-3231 — `return 0`. Faithful empty-body port; the
5361    //                      cleanup of zfsessions happens in cleanup_.
5362    0
5363}
5364
5365// `TransferType` enum removed — was Rust-only invention. C uses the
5366// `ZFST_ASCI` (0x0000) / `ZFST_IMAG` (0x0001) bits from the ZFST_*
5367// status word (c:246-247) for the next-transfer type, and
5368// `ZFST_CASC` (0x0000) / `ZFST_CIMA` (0x0002) for the current-transfer
5369// type. Callers store the type as `i32` and compare via the `ZFST_TYPE`
5370// macro (c:267): `ZFST_TYPE(x) (x & ZFST_TMSK)`.
5371//
5372// Inline-test pattern matching C `if (zfst_status & ZFST_IMAG) ...`
5373// at every TYPE-letter dispatch (e.g. zftp.c around `zftp_type` body):
5374//   `if (typ & ZFST_IMAG) != 0 { "I" } else { "A" }`
5375
5376// `TransferMode` enum removed — was Rust-only invention. C uses the
5377// `ZFST_STRE` (0x0000) / `ZFST_BLOC` (0x0004) bits from the ZFST_*
5378// status word (defined later in this file at the c:245 enum). Callers
5379// store the mode as `i32` and compare against those constants directly:
5380//   `if (mode & ZFST_BLOC) != 0 { "B" } else { "S" }`
5381// — same inline-test pattern the C source uses at every MODE send-site.
5382
5383/// FTP server response (3-digit code + message).
5384/// Port of the response handling inside `zfgetmsg()` from
5385/// `lastmsg` — file-scope global from `Src/Modules/zftp.c:227`:
5386/// `static char *lastmsg, lastcodestr[4];`. Holds the most recent
5387/// FTP server reply message text (post-3-digit-code body).
5388pub static lastmsg: Mutex<String> = Mutex::new(String::new());
5389
5390/// `lastcodestr` — file-scope global from `Src/Modules/zftp.c:227`.
5391/// 3-digit FTP reply code as ASCII (`"000".."599"`), zero-terminated
5392/// to 4 bytes in C; mirrored as a 4-byte Mutex array for parity.
5393pub static lastcodestr: Mutex<[u8; 4]> = Mutex::new([b'0', b'0', b'0', 0]);
5394
5395// `FtpResponse` struct removed — was Rust-only invention. C source
5396// returns plain `int` from every reply-handling fn and reads the
5397// `lastcode` / `lastmsg` globals (c:227-228) inline at each check
5398// site. Callers in this Rust port use the same pattern: each fn
5399// returns `i32` (matching C `int`), and `lastmsg.lock().unwrap()`
5400// + `lastcode.load(Relaxed)` provide the C-equivalent inline reads.
5401//
5402// For ergonomic call sites the type alias below carries both halves
5403// without inventing a new struct shape:
5404/// `FtpResponse` type alias.
5405#[allow(non_camel_case_types)]
5406pub type FtpResponse = (i32, String);
5407
5408// =====================================================================
5409// `enum { ZFHD_* }` from `Src/Modules/zftp.c:119` — block-header flags.
5410// =====================================================================
5411
5412/// `ZFHD_MARK` — restart marker.
5413pub const ZFHD_MARK: i32 = 16; // c:120
5414/// `ZFHD_ERRS` — suspected errors in block.
5415pub const ZFHD_ERRS: i32 = 32; // c:121
5416/// `ZFHD_EOFB` — block is end of record.
5417pub const ZFHD_EOFB: i32 = 64; // c:122
5418/// `ZFHD_EORB` — block is end of file.
5419pub const ZFHD_EORB: i32 = 128; // c:123
5420
5421/// `readwrite_t` — function pointer typedef from
5422/// `Src/Modules/zftp.c:126`: `typedef int (*readwrite_t)(int, char *, off_t, int);`
5423#[allow(non_camel_case_types)]
5424pub type readwrite_t = fn(i32, &mut [u8], libc::off_t, i32) -> i32;
5425
5426// =====================================================================
5427// `enum { ZFTP_* }` from `Src/Modules/zftp.c:134` — zftpcmd.flags bits.
5428// =====================================================================
5429
5430/// `ZFTP_CONN` — must be connected.
5431pub const ZFTP_CONN: i32 = 0x0001; // c:135
5432/// `ZFTP_LOGI` — must be logged in.
5433pub const ZFTP_LOGI: i32 = 0x0002; // c:136
5434/// `ZFTP_TBIN` — set transfer type image.
5435pub const ZFTP_TBIN: i32 = 0x0004; // c:137
5436/// `ZFTP_TASC` — set transfer type ASCII.
5437pub const ZFTP_TASC: i32 = 0x0008; // c:138
5438/// `ZFTP_NLST` — use NLST rather than LIST.
5439pub const ZFTP_NLST: i32 = 0x0010; // c:139
5440/// `ZFTP_DELE` — a delete rather than a make.
5441pub const ZFTP_DELE: i32 = 0x0020; // c:140
5442/// `ZFTP_SITE` — a site rather than a quote.
5443pub const ZFTP_SITE: i32 = 0x0040; // c:141
5444/// `ZFTP_APPE` — append rather than overwrite.
5445pub const ZFTP_APPE: i32 = 0x0080; // c:142
5446/// `ZFTP_HERE` — here rather than over there.
5447pub const ZFTP_HERE: i32 = 0x0100; // c:143
5448/// `ZFTP_CDUP` — CDUP rather than CWD.
5449pub const ZFTP_CDUP: i32 = 0x0200; // c:144
5450/// `ZFTP_REST` — restart: set point in remote file.
5451pub const ZFTP_REST: i32 = 0x0400; // c:145
5452/// `ZFTP_RECV` — receive rather than send.
5453pub const ZFTP_RECV: i32 = 0x0800; // c:146
5454/// `ZFTP_TEST` — test command, don't test.
5455pub const ZFTP_TEST: i32 = 0x1000; // c:147
5456/// `ZFTP_SESS` — session command, don't need status.
5457pub const ZFTP_SESS: i32 = 0x2000; // c:148
5458
5459/// `static char *zfparams[]` from `Src/Modules/zftp.c:197` — list of
5460/// non-special params to unset when a connection closes.
5461pub static ZFPARAMS: &[&str] = &[
5462    "ZFTP_HOST",
5463    "ZFTP_PORT",
5464    "ZFTP_IP",
5465    "ZFTP_SYSTEM",
5466    "ZFTP_USER",
5467    "ZFTP_ACCOUNT",
5468    "ZFTP_PWD",
5469    "ZFTP_TYPE",
5470    "ZFTP_MODE", // c:198-199
5471];
5472
5473// =====================================================================
5474// `enum { ZFPM_* }` from `Src/Modules/zftp.c:204` — zfsetparam flags.
5475// =====================================================================
5476
5477/// `ZFPM_READONLY` — make parameter readonly.
5478pub const ZFPM_READONLY: i32 = 0x01; // c:205
5479/// `ZFPM_IFUNSET` — only set if not already set.
5480pub const ZFPM_IFUNSET: i32 = 0x02; // c:206
5481/// `ZFPM_INTEGER` — passed pointer to off_t.
5482pub const ZFPM_INTEGER: i32 = 0x04; // c:207
5483
5484/// `zfnopen` — file-scope global from `Src/Modules/zftp.c:211`:
5485/// `static int zfnopen;` — number of connections actually open.
5486pub static ZFNOPEN: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
5487
5488/// `zcfinish` — file-scope global from `Src/Modules/zftp.c:218`:
5489/// `static int zcfinish;` — 0 keep going, 1 line finished, 2 EOF.
5490pub static ZCFINISH: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
5491
5492/// `zfclosing` — file-scope global from `Src/Modules/zftp.c:220`:
5493/// `static int zfclosing;` — set when zftp_close() is active.
5494pub static ZFCLOSING: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
5495
5496// =====================================================================
5497// `enum { ZFCP_* }` from `Src/Modules/zftp.c` — server-capability
5498// tri-state for SIZE / MDTM probes.
5499// =====================================================================
5500
5501/// `ZFCP_UNKN` — dunno if it works on this server. Port of c:`enum`
5502/// in `Src/Modules/zftp.c`.
5503pub const ZFCP_UNKN: i32 = 0;
5504/// `ZFCP_YUPP` — server supports the feature.
5505pub const ZFCP_YUPP: i32 = 1;
5506/// `ZFCP_NOPE` — server doesn't support the feature.
5507pub const ZFCP_NOPE: i32 = 2;
5508
5509/// `ZFST_ASCI` — type for next transfer is ASCII.
5510pub const ZFST_ASCI: i32 = 0x0000;
5511/// `ZFST_IMAG` — type for next transfer is image (binary).
5512pub const ZFST_IMAG: i32 = 0x0001;
5513/// `ZFST_TMSK` — mask for type flags.
5514pub const ZFST_TMSK: i32 = 0x0001;
5515/// `ZFST_TBIT` — number of bits in type flags.
5516pub const ZFST_TBIT: i32 = 0x0001;
5517/// `ZFST_CASC` — current type is ASCII (default).
5518pub const ZFST_CASC: i32 = 0x0000;
5519/// `ZFST_CIMA` — current type is image.
5520pub const ZFST_CIMA: i32 = 0x0002;
5521/// `ZFST_STRE` — stream mode (default).
5522pub const ZFST_STRE: i32 = 0x0000;
5523/// `ZFST_BLOC` — block mode.
5524pub const ZFST_BLOC: i32 = 0x0004;
5525/// `ZFST_MMSK` — mask for mode flags.
5526pub const ZFST_MMSK: i32 = 0x0004;
5527/// `ZFST_LOGI` — user logged in.
5528pub const ZFST_LOGI: i32 = 0x0008;
5529/// `ZFST_SYST` — done SYST type check.
5530pub const ZFST_SYST: i32 = 0x0010;
5531/// `ZFST_NOPS` — server doesn't understand PASV.
5532pub const ZFST_NOPS: i32 = 0x0020;
5533/// `ZFST_NOSZ` — server doesn't send `(XXXX bytes)' reply.
5534pub const ZFST_NOSZ: i32 = 0x0040;
5535/// `ZFST_TRSZ` — tried getting 'size' from reply.
5536pub const ZFST_TRSZ: i32 = 0x0080;
5537/// `ZFST_CLOS` — connection closed.
5538pub const ZFST_CLOS: i32 = 0x0100;
5539
5540/// `ZFPF_SNDP` — use send port (active mode) preference.
5541pub const ZFPF_SNDP: i32 = 0x01; // c:280
5542/// `ZFPF_PASV` — try passive mode preference.
5543pub const ZFPF_PASV: i32 = 0x02; // c:281
5544/// `ZFPF_DUMB` — don't do clever things with variables.
5545pub const ZFPF_DUMB: i32 = 0x04; // c:282
5546
5547/// FTP sessions manager.
5548/// Port of the file-static `zfsess_node` linked list +
5549/// `zfsess_current` pointer Src/Modules/zftp.c keeps —
5550/// `zftp_session()` (line 2889) drives the switch,
5551/// `zftp_rmsession()` (line 2915) the removal.
5552// `Zftp` struct renamed to `zftp_globals` — C has no `struct zftp`;
5553// the equivalent state in `Src/Modules/zftp.c` lives in file-scope
5554// globals (`Zfsess zfsessions` linked list head + `Zfsess
5555// zfsesscurrent`). Rust collapses these into one container accessed
5556// via `ZFTP_STATE_INNER`; the suffix names this as a Rust extension
5557// that bags the module's C-static state.
5558/// `zftp_globals` — see fields for layout.
5559#[allow(non_camel_case_types)]
5560#[derive(Debug, Default)]
5561pub struct zftp_globals {
5562    // c:311 — `static LinkList zfsessions;` appended by zaddlinknode
5563    // (c:2819): IndexMap keeps the same insertion order the C linked
5564    // list has, so listing/first-remaining walks match C.
5565    sessions: IndexMap<String, zftp_session>,
5566    current: Option<String>,
5567}
5568
5569/// Global ZFTP session state — port of the C file-scope statics
5570/// `static Zftp_session zfsessions[]` and friends in zftp.c. Holds
5571/// all sessions and the currently-active one. Free ported above route
5572/// through this so subcommand dispatch matches C behaviour.
5573static ZFTP_STATE_INNER: OnceLock<Mutex<zftp_globals>> = OnceLock::new();
5574
5575/// WARNING: NOT IN ZFTP.C — platform-gated `errno` pointer; C reads errno directly after syscalls
5576/// (equivalent C logic at Src/Modules/zftp.c:25).
5577/// Platform-gated `errno` pointer. zsh's C source writes `errno` directly
5578/// after `select(2)`/`read(2)` races; macOS exposes `__error()`, Linux/Android
5579/// expose `__errno_location()`, BSDs use `__errno`. Returning a raw pointer
5580/// keeps the original `*errno = X` write-shape from the C source intact.
5581#[inline]
5582fn errno_ptr() -> *mut libc::c_int {
5583    #[cfg(any(target_os = "linux", target_os = "android"))]
5584    unsafe {
5585        libc::__errno_location()
5586    }
5587    #[cfg(any(
5588        target_os = "macos",
5589        target_os = "ios",
5590        target_os = "freebsd",
5591        target_os = "dragonfly"
5592    ))]
5593    unsafe {
5594        libc::__error()
5595    }
5596    #[cfg(any(target_os = "openbsd", target_os = "netbsd"))]
5597    unsafe {
5598        extern "C" {
5599            fn __errno() -> *mut libc::c_int;
5600        }
5601        __errno()
5602    }
5603}
5604
5605/// Port of `zfopendata(char *name, union tcp_sockaddr *zdsockp, int *is_passivep)` from `Src/Modules/zftp.c:859`.
5606/// Helper: parse a `227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)`
5607/// FTP reply into (ip, port). Direct extraction of the sscanf path
5608/// inside C zfopendata (Src/Modules/zftp.c:925-941). Allowlisted as
5609/// a Rust-only architectural helper since C does the parse inline.
5610fn parse_pasv_response(msg: &str) -> io::Result<(String, u16)> {
5611    // c:925 inline
5612    // c:937-939 — `for (ptr = lastmsg; *ptr; ptr++) if (idigit(*ptr)) break;`.
5613    // C walks to the FIRST DIGIT in lastmsg and sscanf from there — it
5614    // doesn't require parentheses. RFC 959's PASV reply text is
5615    // unspecified ("Entering Passive Mode" is conventional but not
5616    // mandatory), so some servers / proxies omit the `(...)` wrap.
5617    //
5618    // Prior Rust port required both `(` and `)` and bailed with
5619    // "invalid PASV response" otherwise; against a paren-less server
5620    // PASV transfers would refuse to open and fall through to the
5621    // less-efficient PORT-mode path on every transfer.
5622    let start = msg
5623        .bytes()
5624        .position(|b| b.is_ascii_digit())
5625        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid PASV response"))?;
5626
5627    // c:940-941 — `sscanf("%d,%d,...", nums...)` then `(unsigned char) nums[i]`.
5628    // C parses each value as int, then *truncates* to u8 via cast. Previous
5629    // Rust port parsed as u16 and computed `(nums[4] << 8) + nums[5]` which
5630    // would PANIC (debug) / wrap (release) when a malicious or malformed
5631    // server sent values > 255. Match C's truncate-to-u8 semantics so
5632    // out-of-range octets behave the same as C (low 8 bits used; no panic).
5633    //
5634    // Take the first 6 comma-separated number tokens from `start`
5635    // onward; trailing text after the 6th value is ignored (matches
5636    // sscanf's lazy match).
5637    let nums: Vec<i32> = msg[start..]
5638        .split(|c: char| !c.is_ascii_digit() && c != '-')
5639        .filter(|s| !s.is_empty())
5640        .take(6)
5641        .filter_map(|s| s.parse().ok())
5642        .collect();
5643
5644    if nums.len() != 6 {
5645        return Err(io::Error::new(
5646            io::ErrorKind::InvalidData,
5647            "invalid PASV numbers",
5648        ));
5649    }
5650
5651    // c:947-948 — `iaddr[i] = (unsigned char) nums[i];` — low-byte cast.
5652    let oct = |n: i32| -> u8 { n as u8 };
5653    let ip = format!(
5654        "{}.{}.{}.{}",
5655        oct(nums[0]),
5656        oct(nums[1]),
5657        oct(nums[2]),
5658        oct(nums[3])
5659    );
5660    // c:949-950 — `iport[0] = (unsigned char) nums[4]; iport[1] = ... nums[5];`.
5661    // Then `memcpy(&sin_port, iport, 2)` reads as network-order u16 =
5662    // `(iport[0] << 8) | iport[1]`.
5663    let port = ((oct(nums[4]) as u16) << 8) | (oct(nums[5]) as u16);
5664
5665    Ok((ip, port))
5666}
5667
5668/// Clear poison on the zftp state mutex (test teardown helper).
5669pub fn zftp_state_clear_poison() {
5670    if let Some(m) = ZFTP_STATE_INNER.get() {
5671        m.clear_poison();
5672    }
5673}
5674
5675// File-static globals for zfalarm/zfunalarm — c:386-389.
5676/// `zfdrrrring` — file-static from `Src/Modules/zftp.c:340`. Set by
5677/// `zfhandler()` on SIGALRM, polled by zfread/zfgetline to bail out.
5678pub static ZFDRRRRING: std::sync::atomic::AtomicI32 = // c:340
5679    std::sync::atomic::AtomicI32::new(0);
5680
5681/// `zfalarmed` — file-static from `Src/Modules/zftp.c:346`. Tracks
5682/// whether `zfalarm()` has installed the SIGALRM handler.
5683pub static ZFALARMED: std::sync::atomic::AtomicI32 = // c:346
5684    std::sync::atomic::AtomicI32::new(0);
5685/// `OALREMAIN` static.
5686pub static OALREMAIN: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
5687/// `OALTIME` static.
5688pub static OALTIME: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
5689
5690// `zftp_cleanup` is defined above at c:3128; the exit hook calls it.
5691
5692static MODULE_FEATURES: OnceLock<Mutex<crate::ported::zsh_h::features>> = OnceLock::new();
5693
5694// Local stubs for the per-module entry points. C uses generic
5695// `featuresarray`/`handlefeatures`/`setfeatureenables` (module.c:
5696// 3275/3370/3445) but those take `Builtin` + `Features` pointer
5697// fields the Rust port doesn't carry. The hardcoded descriptor
5698// list mirrors the C bintab/conddefs/mathfuncs/paramdefs.
5699// WARNING: NOT IN ZFTP.C — Rust-only module-framework shim.
5700// C uses generic featuresarray/handlefeatures/setfeatureenables from
5701// Src/module.c:3275/3370/3445 with C-side Builtin/Features pointers;
5702// Rust per-module shims hardcode the bintab/conddefs/mathfuncs/paramdefs.
5703fn featuresarray(_m: *const module, _f: &Mutex<crate::ported::zsh_h::features>) -> Vec<String> {
5704    vec!["b:zftp".to_string()]
5705}
5706
5707// WARNING: NOT IN ZFTP.C — Rust-only module-framework shim.
5708// C uses generic featuresarray/handlefeatures/setfeatureenables from
5709// Src/module.c:3275/3370/3445 with C-side Builtin/Features pointers;
5710// Rust per-module shims hardcode the bintab/conddefs/mathfuncs/paramdefs.
5711fn handlefeatures(
5712    _m: *const module,
5713    _f: &Mutex<crate::ported::zsh_h::features>,
5714    enables: &mut Option<Vec<i32>>,
5715) -> i32 {
5716    if enables.is_none() {
5717        *enables = Some(vec![1; 1]);
5718    }
5719    0
5720}
5721
5722// WARNING: NOT IN ZFTP.C — Rust-only module-framework shim.
5723// C uses generic featuresarray/handlefeatures/setfeatureenables from
5724// Src/module.c:3275/3370/3445 with C-side Builtin/Features pointers;
5725// Rust per-module shims hardcode the bintab/conddefs/mathfuncs/paramdefs.
5726fn setfeatureenables(
5727    _m: *const module,
5728    _f: &Mutex<crate::ported::zsh_h::features>,
5729    _e: Option<&[i32]>,
5730) -> i32 {
5731    0
5732}
5733
5734// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5735// ─── RUST-ONLY ACCESSORS ───
5736//
5737// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
5738// RwLock<T>>` globals declared above. C zsh uses direct global
5739// access; Rust needs these wrappers because `OnceLock::get_or_init`
5740// is the only way to lazily construct shared state. These ported sit
5741// here so the body of this file reads in C source order without
5742// the accessor wrappers interleaved between real port ported.
5743// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5744
5745// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5746// ─── RUST-ONLY ACCESSORS ───
5747//
5748// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
5749// RwLock<T>>` globals declared above. C zsh uses direct global
5750// access; Rust needs these wrappers because `OnceLock::get_or_init`
5751// is the only way to lazily construct shared state. These ported sit
5752// here so the body of this file reads in C source order without
5753// the accessor wrappers interleaved between real port ported.
5754// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5755
5756/// Accessor for the live zftp module state. C's `Src/Modules/zftp.c`
5757/// keeps per-session state in scattered file-statics (`zfsess`,
5758/// `zfsessions`, `zfcommand`, ...); this fn returns the single
5759/// `Mutex<zftp_globals>` aggregating those into one shared store.
5760pub fn zftp_state() -> &'static Mutex<zftp_globals> {
5761    ZFTP_STATE_INNER.get_or_init(|| Mutex::new(zftp_globals::default()))
5762}
5763
5764// WARNING: NOT IN ZFTP.C — Rust-only module-framework shim.
5765// C uses generic featuresarray/handlefeatures/setfeatureenables from
5766// Src/module.c:3275/3370/3445 with C-side Builtin/Features pointers;
5767// Rust per-module shims hardcode the bintab/conddefs/mathfuncs/paramdefs.
5768fn module_features() -> &'static Mutex<crate::ported::zsh_h::features> {
5769    MODULE_FEATURES.get_or_init(|| {
5770        Mutex::new(crate::ported::zsh_h::features {
5771            bn_list: None,
5772            bn_size: 1,
5773            cd_list: None,
5774            cd_size: 0,
5775            mf_list: None,
5776            mf_size: 0,
5777            pd_list: None,
5778            pd_size: 0,
5779            n_abstract: 0,
5780        })
5781    })
5782}
5783
5784#[cfg(test)]
5785mod tests {
5786    use super::*;
5787
5788    #[test]
5789    fn test_transfer_type() {
5790        let _g = crate::test_util::global_state_lock();
5791        // Inline-test pattern matching C: `(typ & ZFST_IMAG) ? "I" : "A"`
5792        let ascii_letter = if (ZFST_ASCI & ZFST_IMAG) != 0 {
5793            "I"
5794        } else {
5795            "A"
5796        };
5797        let image_letter = if (ZFST_IMAG & ZFST_IMAG) != 0 {
5798            "I"
5799        } else {
5800            "A"
5801        };
5802        assert_eq!(ascii_letter, "A");
5803        assert_eq!(image_letter, "I");
5804    }
5805
5806    #[test]
5807    fn test_transfer_mode() {
5808        let _g = crate::test_util::global_state_lock();
5809        // Inline-test pattern matching C: `(mode & ZFST_BLOC) ? "B" : "S"`
5810        let stream_letter = if (ZFST_STRE & ZFST_BLOC) != 0 {
5811            "B"
5812        } else {
5813            "S"
5814        };
5815        let block_letter = if (ZFST_BLOC & ZFST_BLOC) != 0 {
5816            "B"
5817        } else {
5818            "S"
5819        };
5820        assert_eq!(stream_letter, "S");
5821        assert_eq!(block_letter, "B");
5822    }
5823
5824    /// FTP reply-code class predicates per RFC 959. C tests these
5825    /// inline at every reply-check call site (e.g. `if (lastcode < 400)`).
5826    fn is_positive(c: i32) -> bool {
5827        c >= 100 && c < 400
5828    }
5829    fn is_positive_completion(c: i32) -> bool {
5830        c >= 200 && c < 300
5831    }
5832    fn is_positive_intermediate(c: i32) -> bool {
5833        c >= 300 && c < 400
5834    }
5835    fn is_negative(c: i32) -> bool {
5836        c >= 400
5837    }
5838
5839    #[test]
5840    fn test_ftp_response_positive() {
5841        let _g = crate::test_util::global_state_lock();
5842        let resp: FtpResponse = (200, "OK".to_string());
5843        assert!(is_positive(resp.0));
5844        assert!(is_positive_completion(resp.0));
5845        assert!(!is_negative(resp.0));
5846    }
5847
5848    #[test]
5849    fn test_ftp_response_intermediate() {
5850        let _g = crate::test_util::global_state_lock();
5851        let resp: FtpResponse = (331, "Password required".to_string());
5852        assert!(is_positive(resp.0));
5853        assert!(is_positive_intermediate(resp.0));
5854        assert!(!is_positive_completion(resp.0));
5855    }
5856
5857    #[test]
5858    fn test_ftp_response_negative() {
5859        let _g = crate::test_util::global_state_lock();
5860        let resp: FtpResponse = (550, "File not found".to_string());
5861        assert!(is_negative(resp.0));
5862        assert!(!is_positive(resp.0));
5863    }
5864
5865    #[test]
5866    fn test_ftp_session_new() {
5867        let _g = crate::test_util::global_state_lock();
5868        let sess = zftp_session::new("test");
5869        assert_eq!(sess.name, "test");
5870        assert!(!sess.connected);
5871        assert!(!sess.logged_in);
5872    }
5873
5874    #[test]
5875    fn test_parse_pasv_response() {
5876        let _g = crate::test_util::global_state_lock();
5877        // c:934-936 — input is lastmsg, which "already has the reply
5878        // code expunged" (no leading "227 ").
5879        let msg = "Entering Passive Mode (192,168,1,1,4,1)";
5880        let (ip, port) = parse_pasv_response(msg).unwrap();
5881        assert_eq!(ip, "192.168.1.1");
5882        assert_eq!(port, 1025);
5883    }
5884
5885    #[test]
5886    fn test_parse_pasv_response_invalid() {
5887        let _g = crate::test_util::global_state_lock();
5888        let msg = "invalid";
5889        assert!(parse_pasv_response(msg).is_err());
5890    }
5891
5892    #[test]
5893    fn test_zftp_new() {
5894        let _g = crate::test_util::global_state_lock();
5895        let zftp = zftp_globals::default();
5896        assert!(zftp.sessions.is_empty());
5897    }
5898
5899    /// c:2814-2818 — newsession registers the named session and
5900    /// points zfsess at it.
5901    #[test]
5902    fn test_zftp_create_session() {
5903        let _g = crate::test_util::global_state_lock();
5904        zftp_cleanup();
5905        newsession("test");
5906        let state = zftp_state().lock().unwrap();
5907        assert!(state.sessions.contains_key("test"));
5908        assert_eq!(state.current.as_deref(), Some("test"));
5909        drop(state);
5910        zftp_cleanup();
5911    }
5912
5913    /// c:2958 — rmsession removes the entry; c:2929-2930 — not-found
5914    /// exits 1; c:2985-2992 — removing the last session recreates
5915    /// "default".
5916    #[test]
5917    fn test_zftp_remove_session() {
5918        let _g = crate::test_util::global_state_lock();
5919        zftp_cleanup();
5920        newsession("test");
5921        assert_eq!(zftp_rmsession("rmsession", &["test"], 0), 0);
5922        let state = zftp_state().lock().unwrap();
5923        assert!(!state.sessions.contains_key("test"));
5924        // c:2992 — last session removed → fresh "default".
5925        assert!(state.sessions.contains_key("default"));
5926        drop(state);
5927        assert_eq!(zftp_rmsession("rmsession", &["test"], 0), 1); // c:2930
5928        zftp_cleanup();
5929    }
5930
5931    /// c:2806-2812 — newsession on an existing name switches zfsess
5932    /// to it without creating a duplicate.
5933    #[test]
5934    fn test_zftp_set_current() {
5935        let _g = crate::test_util::global_state_lock();
5936        zftp_cleanup();
5937        newsession("test");
5938        newsession("test2");
5939        assert_eq!(
5940            zftp_state().lock().unwrap().current.as_deref(),
5941            Some("test2")
5942        );
5943        newsession("test"); // existing — switch only, no insert
5944        let state = zftp_state().lock().unwrap();
5945        assert_eq!(state.current.as_deref(), Some("test"));
5946        assert_eq!(state.sessions.len(), 2);
5947        drop(state);
5948        zftp_cleanup();
5949    }
5950
5951    #[test]
5952    fn test_builtin_zftp_no_args() {
5953        let _g = crate::test_util::global_state_lock();
5954        let status = bin_zftp(
5955            "zftp",
5956            &[].iter().map(|s: &&str| s.to_string()).collect::<Vec<_>>(),
5957            &options {
5958                ind: [0u8; crate::ported::zsh_h::MAX_OPS],
5959                args: Vec::new(),
5960                argscount: 0,
5961                argsalloc: 0,
5962            },
5963            0,
5964        );
5965        assert_eq!(status, 1);
5966    }
5967
5968    /// Port of `zftp_open(char *name, char **args, int flags)` from `Src/Modules/zftp.c:1690`.
5969    #[test]
5970    fn test_builtin_zftp_session() {
5971        let _g = crate::test_util::global_state_lock();
5972        // Reset global state for test isolation.
5973        zftp_cleanup();
5974        let status = bin_zftp(
5975            "zftp",
5976            &["session", "test"]
5977                .iter()
5978                .map(|s: &&str| s.to_string())
5979                .collect::<Vec<_>>(),
5980            &options {
5981                ind: [0u8; crate::ported::zsh_h::MAX_OPS],
5982                args: Vec::new(),
5983                argscount: 0,
5984                argsalloc: 0,
5985            },
5986            0,
5987        );
5988        assert_eq!(status, 0);
5989        assert!(zftp_state().lock().unwrap().sessions.contains_key("test"));
5990        zftp_cleanup();
5991    }
5992
5993    #[test]
5994    fn test_builtin_zftp_test_not_connected() {
5995        let _g = crate::test_util::global_state_lock();
5996        let status = bin_zftp(
5997            "zftp",
5998            &["test"]
5999                .iter()
6000                .map(|s: &&str| s.to_string())
6001                .collect::<Vec<_>>(),
6002            &options {
6003                ind: [0u8; crate::ported::zsh_h::MAX_OPS],
6004                args: Vec::new(),
6005                argscount: 0,
6006                argsalloc: 0,
6007            },
6008            0,
6009        );
6010        assert_eq!(status, 1);
6011    }
6012
6013    fn zftp_empty_ops() -> options {
6014        options {
6015            ind: [0u8; crate::ported::zsh_h::MAX_OPS],
6016            args: Vec::new(),
6017            argscount: 0,
6018            argsalloc: 0,
6019        }
6020    }
6021
6022    /// c:3002 — unknown subcommand returns 1 (zwarnnam "unknown
6023    /// subcommand"). Regression accepting bogus names would let
6024    /// `zftp xyz_not_real` silently succeed and confuse scripts.
6025    #[test]
6026    fn zftp_unknown_subcommand_returns_one() {
6027        let _g = crate::test_util::global_state_lock();
6028        zftp_cleanup();
6029        let ops = zftp_empty_ops();
6030        let r = bin_zftp(
6031            "zftp",
6032            &["definitely_not_a_subcommand_xyz".to_string()],
6033            &ops,
6034            0,
6035        );
6036        assert_eq!(r, 1);
6037        zftp_cleanup();
6038    }
6039
6040    /// `zftp_state` is empty after cleanup. A regression that
6041    /// pre-populates a "default" session would let `zftp test` succeed
6042    /// before any open command — wrong shell semantics.
6043    #[test]
6044    fn zftp_state_empty_after_cleanup() {
6045        let _g = crate::test_util::global_state_lock();
6046        zftp_cleanup();
6047        assert!(zftp_state().lock().unwrap().sessions.is_empty());
6048    }
6049
6050    /// `Src/Modules/zftp.c:267` — `#define ZFST_TYPE(x) (x & ZFST_TMSK)`.
6051    /// The type mask isolates the transfer-type bit (`ZFST_IMAG` = 0x0001)
6052    /// from the rest of the zfstatus word. Pin the mask semantics so a
6053    /// regen that flips `ZFST_TMSK` silently to a wider value mis-extracts
6054    /// other status bits (mode/login/syst/etc) as if they were type.
6055    #[test]
6056    fn zfst_type_isolates_transfer_type_bit() {
6057        let _g = crate::test_util::global_state_lock();
6058        // Pure ASCII → 0
6059        assert_eq!(ZFST_TYPE(ZFST_ASCI), 0);
6060        // Pure IMAGE → 1
6061        assert_eq!(ZFST_TYPE(ZFST_IMAG), 1);
6062        // Mode + image flags set → still 1 (mode masked out)
6063        assert_eq!(ZFST_TYPE(ZFST_IMAG | ZFST_BLOC), 1);
6064        // Image + LOGI + SYST → still 1
6065        assert_eq!(ZFST_TYPE(ZFST_IMAG | ZFST_LOGI | ZFST_SYST), 1);
6066        // No image bit + lots of other flags → 0
6067        assert_eq!(ZFST_TYPE(ZFST_LOGI | ZFST_SYST | ZFST_BLOC), 0);
6068    }
6069
6070    /// `Src/Modules/zftp.c:267` — `#define ZFST_MODE(x) (x & ZFST_MMSK)`.
6071    /// Mode mask isolates the stream-vs-block bit (`ZFST_BLOC` = 0x0004).
6072    /// Same regression risk as ZFST_TYPE: a too-wide MMSK would mis-claim
6073    /// LOGI / SYST / NOPS / NOSZ / TRSZ / CLOS bits as "mode."
6074    #[test]
6075    fn zfst_mode_isolates_transfer_mode_bit() {
6076        let _g = crate::test_util::global_state_lock();
6077        assert_eq!(ZFST_MODE(ZFST_STRE), 0);
6078        assert_eq!(ZFST_MODE(ZFST_BLOC), 4);
6079        // BLOC + IMAG → still 4 (type masked out)
6080        assert_eq!(ZFST_MODE(ZFST_BLOC | ZFST_IMAG), 4);
6081        // BLOC + LOGI + SYST → still 4
6082        assert_eq!(ZFST_MODE(ZFST_BLOC | ZFST_LOGI | ZFST_SYST), 4);
6083        // LOGI + SYST + NOPS + NOSZ + TRSZ + CLOS, no BLOC → 0
6084        let many = ZFST_LOGI | ZFST_SYST | ZFST_NOPS | ZFST_NOSZ | ZFST_TRSZ | ZFST_CLOS;
6085        assert_eq!(ZFST_MODE(many), 0);
6086    }
6087
6088    /// `Src/Modules/zftp.c:546-562` — `zfargstring(cmd, args)` joins
6089    /// `cmd` with space-separated `args` and appends the c:559 CRLF
6090    /// terminator (part of the fn's contract — C callers pass the
6091    /// result straight to zfsendcmd). Empty argv yields cmd + CRLF.
6092    #[test]
6093    fn zfargstring_empty_args_returns_cmd() {
6094        let _g = crate::test_util::global_state_lock();
6095        assert_eq!(zfargstring("RETR", &[]), "RETR\r\n");
6096        assert_eq!(zfargstring("QUIT", &[]), "QUIT\r\n");
6097        assert_eq!(zfargstring("", &[]), "\r\n");
6098    }
6099
6100    /// `Src/Modules/zftp.c:546-570` — one argument case: single space
6101    /// between cmd and arg, no trailing whitespace.
6102    #[test]
6103    fn zfargstring_single_arg_one_space() {
6104        let _g = crate::test_util::global_state_lock();
6105        assert_eq!(zfargstring("RETR", &["file.txt"]), "RETR file.txt\r\n");
6106        assert_eq!(zfargstring("USER", &["anonymous"]), "USER anonymous\r\n");
6107    }
6108
6109    /// `Src/Modules/zftp.c:546-570` — multi-arg: space-separated, no
6110    /// double-spacing or trailing space. The C body builds the buffer
6111    /// via `sprintf(...,"%s",...)` with explicit space separators.
6112    #[test]
6113    fn zfargstring_multi_arg_space_joined() {
6114        let _g = crate::test_util::global_state_lock();
6115        assert_eq!(
6116            zfargstring("USER", &["anonymous", "pass@example.com"]),
6117            "USER anonymous pass@example.com\r\n"
6118        );
6119        assert_eq!(
6120            zfargstring("PORT", &["192", "168", "1", "1", "4", "1"]),
6121            "PORT 192 168 1 1 4 1\r\n"
6122        );
6123    }
6124
6125    /// `Src/Modules/zftp.c:546-570` — empty args don't get filtered
6126    /// out (C source's `sprintf("%s ",arg)` with empty arg → bare space).
6127    /// Pin the exact behavior so a "smart" regression doesn't add a
6128    /// silent skip-empty filter that would silently drop legitimate
6129    /// (but empty) positional args.
6130    #[test]
6131    fn zfargstring_empty_arg_emits_space() {
6132        let _g = crate::test_util::global_state_lock();
6133        assert_eq!(zfargstring("CMD", &["", "after"]), "CMD  after\r\n");
6134        assert_eq!(zfargstring("CMD", &["before", ""]), "CMD before \r\n");
6135    }
6136
6137    /// `Src/Modules/zftp.c:3902-3905` — ZFST_ASCI is 0, ZFST_IMAG is 1.
6138    /// These are the only two type values; their mutual exclusion is
6139    /// what the c:267 mask depends on. Pin the exact values so a
6140    /// regen that flips them silently inverts the ASCII vs binary
6141    /// transfer selection across the entire zftp subcommand surface.
6142    #[test]
6143    fn zfst_type_constants_are_zero_and_one() {
6144        let _g = crate::test_util::global_state_lock();
6145        assert_eq!(ZFST_ASCI, 0);
6146        assert_eq!(ZFST_IMAG, 1);
6147        assert_eq!(ZFST_TMSK, 1);
6148        assert_eq!(ZFST_ASCI | ZFST_IMAG, ZFST_TMSK);
6149    }
6150
6151    /// `Src/Modules/zftp.c:3914-3919` — Mode bit values + mask.
6152    /// ZFST_STRE=0, ZFST_BLOC=4, ZFST_MMSK=4. The MMSK == BLOC contract
6153    /// is load-bearing: type=0/1 in bit 0-0, mode=4 in bit 2. Pin so
6154    /// a regen that shifts MMSK to bit 1 silently overlaps the type bit.
6155    #[test]
6156    fn zfst_mode_constants_have_correct_bit_position() {
6157        let _g = crate::test_util::global_state_lock();
6158        assert_eq!(ZFST_STRE, 0);
6159        assert_eq!(ZFST_BLOC, 0x04);
6160        assert_eq!(ZFST_MMSK, 0x04);
6161        assert_eq!(ZFST_STRE | ZFST_BLOC, ZFST_MMSK);
6162        // The type mask MUST NOT overlap the mode mask.
6163        assert_eq!(
6164            ZFST_TMSK & ZFST_MMSK,
6165            0,
6166            "c:3907/3919 — type and mode bit-fields must be disjoint"
6167        );
6168    }
6169
6170    /// `Src/Modules/zftp.c:3920-3931` — Higher status bits. Pin the
6171    /// exact values so a regen reshuffling them breaks every call site
6172    /// that does `if (zfstatusp[s] & ZFST_LOGI)` style checks.
6173    #[test]
6174    fn zfst_status_flag_bits_are_pairwise_distinct() {
6175        let _g = crate::test_util::global_state_lock();
6176        let flags = [
6177            ZFST_LOGI, ZFST_SYST, ZFST_NOPS, ZFST_NOSZ, ZFST_TRSZ, ZFST_CLOS,
6178        ];
6179        // All distinct
6180        let mut sorted = flags.to_vec();
6181        sorted.sort();
6182        sorted.dedup();
6183        assert_eq!(
6184            sorted.len(),
6185            flags.len(),
6186            "ZFST_* status flags must be pairwise distinct"
6187        );
6188        // None overlap with type or mode masks
6189        for f in flags {
6190            assert_eq!(
6191                f & ZFST_TMSK,
6192                0,
6193                "ZFST flag 0x{:x} must not overlap type mask",
6194                f
6195            );
6196            assert_eq!(
6197                f & ZFST_MMSK,
6198                0,
6199                "ZFST flag 0x{:x} must not overlap mode mask",
6200                f
6201            );
6202        }
6203    }
6204
6205    /// `Src/Modules/zftp.c:940-950` — `sscanf("%d,...")` then
6206    /// `(unsigned char) nums[i]` cast. The PASV reply `(h1,h2,h3,h4,
6207    /// p1,p2)` builds IP as h1.h2.h3.h4 and port as p1*256+p2. Pin
6208    /// the well-formed case round-trip. Inputs are lastmsg-form: the
6209    /// reply code is already expunged (c:934-936).
6210    #[test]
6211    fn parse_pasv_response_well_formed_round_trips() {
6212        let _g = crate::test_util::global_state_lock();
6213        let (ip, port) = parse_pasv_response("Entering Passive Mode (192,168,1,1,4,1)").unwrap();
6214        assert_eq!(ip, "192.168.1.1");
6215        assert_eq!(port, 4 * 256 + 1, "p1*256+p2 = 1025");
6216        // High-port case: p1=255, p2=255 → port=65535.
6217        let (_, port) = parse_pasv_response("ok (10,0,0,1,255,255)").unwrap();
6218        assert_eq!(port, 65535);
6219        // Low-port: p1=0, p2=21 → port=21.
6220        let (_, port) = parse_pasv_response("ok (127,0,0,1,0,21)").unwrap();
6221        assert_eq!(port, 21);
6222    }
6223
6224    /// `Src/Modules/zftp.c:947-948` — `(unsigned char) nums[i]` cast
6225    /// TRUNCATES to the low 8 bits. The previous Rust port stored
6226    /// `Vec<u16>` and computed `(nums[4] << 8) + nums[5]` which would
6227    /// PANIC in debug mode when a malicious or malformed server sent
6228    /// `nums[4] > 255` (e.g. `nums[4]=300` → `300 << 8 = 76800`
6229    /// overflows u16). The fix matches C's `(unsigned char)` cast
6230    /// and uses u16 only AFTER the low-byte truncation. Pin the
6231    /// no-panic contract for out-of-range octets.
6232    #[test]
6233    fn parse_pasv_response_out_of_range_octet_truncates_low_byte() {
6234        let _g = crate::test_util::global_state_lock();
6235        // p1=300 → low byte = 44 (300 & 0xff). port = 44*256 + 1 = 11265.
6236        let (_, port) = parse_pasv_response("ok (192,168,1,1,300,1)").unwrap();
6237        assert_eq!(
6238            port,
6239            44 * 256 + 1,
6240            "c:949 — (unsigned char)300 = 44; port = 44*256+1"
6241        );
6242        // No panic on absurd-but-parseable values. Previous Rust port
6243        // would panic in debug here.
6244        let (_, port) = parse_pasv_response("ok (1,2,3,4,1000,2000)").unwrap();
6245        let expected_p1 = (1000i32 as u8) as u16; // 232
6246        let expected_p2 = (2000i32 as u8) as u16; // 208
6247        assert_eq!(port, (expected_p1 << 8) | expected_p2);
6248    }
6249
6250    /// `Src/Modules/zftp.c:947` — IP octets also get the
6251    /// `(unsigned char)` truncation. Pin: `nums[i] > 255` cast to u8
6252    /// then formatted. Catches a regression that prints raw
6253    /// out-of-range values directly (would yield invalid IPs like
6254    /// "300.168.1.1").
6255    #[test]
6256    fn parse_pasv_response_ip_octets_truncate_to_u8() {
6257        let _g = crate::test_util::global_state_lock();
6258        // h1=300 → 300 & 0xff = 44.
6259        let (ip, _) = parse_pasv_response("ok (300,168,1,1,0,21)").unwrap();
6260        assert_eq!(
6261            ip, "44.168.1.1",
6262            "c:947 — octets truncate; out-of-range becomes low byte"
6263        );
6264    }
6265
6266    /// `Src/Modules/zftp.c:940-941` — `sscanf(...) != 6` is the error
6267    /// gate: FEWER than 6 numbers errors; MORE than 6 succeeds (sscanf
6268    /// consumes the first 6 and ignores the rest). Pin so a server
6269    /// with a short PASV reply doesn't silently produce a wrong
6270    /// IP/port, and a chatty one still parses.
6271    #[test]
6272    fn parse_pasv_response_wrong_number_count_errors() {
6273        let _g = crate::test_util::global_state_lock();
6274        assert!(
6275            parse_pasv_response("ok (1,2,3,4)").is_err(),
6276            "only 4 numbers → error"
6277        );
6278        assert!(
6279            parse_pasv_response("ok (1,2,3,4,5)").is_err(),
6280            "only 5 numbers → error"
6281        );
6282        // c:940 — sscanf returns 6 with 7 numbers present; the 7th is
6283        // ignored, not an error.
6284        let (ip, port) = parse_pasv_response("ok (1,2,3,4,5,6,7)").unwrap();
6285        assert_eq!(ip, "1.2.3.4", "first 4 of 7 are the IP");
6286        assert_eq!(port, 5 * 256 + 6, "5th/6th are the port; 7th ignored");
6287    }
6288
6289    /// `Src/Modules/zftp.c:937-941` — no digits at all → the first-
6290    /// digit scan runs off the string and sscanf gets nothing → error.
6291    /// (C's IPv4 path doesn't require parens; the error here is the
6292    /// absence of any numbers.)
6293    #[test]
6294    fn parse_pasv_response_missing_parens_errors() {
6295        let _g = crate::test_util::global_state_lock();
6296        assert!(
6297            parse_pasv_response("ok no parens here").is_err(),
6298            "missing both parens → error"
6299        );
6300        assert!(
6301            parse_pasv_response("ok (no_close").is_err(),
6302            "missing close paren → error"
6303        );
6304        assert!(
6305            parse_pasv_response("ok no_open)").is_err(),
6306            "missing open paren → error"
6307        );
6308    }
6309
6310    // ─── zsh-corpus pins for ZFST_TYPE / ZFST_MODE macros ──────────
6311
6312    /// `ZFST_TYPE(x)` masks to the type bits.
6313    #[test]
6314    fn zftp_corpus_zfst_type_masks_type_bits() {
6315        assert_eq!(ZFST_TYPE(ZFST_ASCI), ZFST_ASCI, "ASCI type bit");
6316        assert_eq!(ZFST_TYPE(ZFST_IMAG), ZFST_IMAG, "IMAG type bit");
6317    }
6318
6319    /// `ZFST_TYPE` ignores mode bits.
6320    #[test]
6321    fn zftp_corpus_zfst_type_ignores_mode_bits() {
6322        let combined = ZFST_IMAG | ZFST_BLOC;
6323        assert_eq!(ZFST_TYPE(combined), ZFST_IMAG, "ZFST_TYPE strips mode bits");
6324    }
6325
6326    /// `ZFST_MODE` masks mode bits.
6327    #[test]
6328    fn zftp_corpus_zfst_mode_masks_mode_bits() {
6329        assert_eq!(ZFST_MODE(ZFST_STRE), ZFST_STRE, "stream mode");
6330        assert_eq!(ZFST_MODE(ZFST_BLOC), ZFST_BLOC, "block mode");
6331    }
6332
6333    /// `ZFST_MODE` ignores type bits.
6334    #[test]
6335    fn zftp_corpus_zfst_mode_ignores_type_bits() {
6336        let combined = ZFST_IMAG | ZFST_BLOC;
6337        assert_eq!(ZFST_MODE(combined), ZFST_BLOC, "ZFST_MODE strips type bits");
6338    }
6339
6340    /// `ZFST_TMSK` and `ZFST_MMSK` don't overlap.
6341    #[test]
6342    fn zftp_corpus_zfst_type_mode_masks_disjoint() {
6343        assert_eq!(
6344            ZFST_TMSK & ZFST_MMSK,
6345            0,
6346            "type-mask and mode-mask must be disjoint"
6347        );
6348    }
6349
6350    /// `ZFST_TYPE(0)` and `ZFST_MODE(0)` both return 0.
6351    #[test]
6352    fn zftp_corpus_zfst_zero_input_returns_zero() {
6353        assert_eq!(ZFST_TYPE(0), 0);
6354        assert_eq!(ZFST_MODE(0), 0);
6355    }
6356
6357    // ═══════════════════════════════════════════════════════════════════
6358    // C-parity tests pinning Src/Modules/zftp.c macros + status bits.
6359    // ═══════════════════════════════════════════════════════════════════
6360
6361    /// `ZFST_TYPE(ZFST_TMSK | ZFST_BLOC)` masks out only type bit.
6362    /// C `#define ZFST_TYPE(x) (x & ZFST_TMSK)` (c:267).
6363    #[test]
6364    fn ZFST_TYPE_masks_only_type_bit() {
6365        let mixed = ZFST_TMSK | ZFST_BLOC;
6366        assert_eq!(ZFST_TYPE(mixed), ZFST_TMSK, "type extracted, mode dropped");
6367    }
6368
6369    /// `ZFST_MODE(ZFST_TMSK | ZFST_BLOC)` masks out only mode bit.
6370    /// C `#define ZFST_MODE(x) (x & ZFST_MMSK)` (c:273).
6371    #[test]
6372    fn ZFST_MODE_masks_only_mode_bit() {
6373        let mixed = ZFST_TMSK | ZFST_BLOC;
6374        assert_eq!(ZFST_MODE(mixed), ZFST_BLOC, "mode extracted, type dropped");
6375    }
6376
6377    /// `ZFST_TYPE | ZFST_MODE` reconstruct the original status.
6378    /// No bit overlap between masks.
6379    #[test]
6380    fn ZFST_TYPE_and_MODE_reconstruct_original_status() {
6381        let original = ZFST_TMSK | ZFST_BLOC;
6382        let t = ZFST_TYPE(original);
6383        let m = ZFST_MODE(original);
6384        assert_eq!(t | m, original, "TYPE | MODE = original status");
6385    }
6386
6387    /// `ZFST_ASCI` is the zero/default type value.
6388    /// C `#define ZFST_ASCI 0x0000`.
6389    #[test]
6390    fn ZFST_ASCI_is_zero_default() {
6391        assert_eq!(ZFST_ASCI, 0);
6392        assert_eq!(ZFST_TYPE(ZFST_ASCI), 0, "ASCII type = zero default");
6393    }
6394
6395    // ═══════════════════════════════════════════════════════════════════
6396    // Additional C-parity tests for Src/Modules/zftp.c utilities.
6397    // ═══════════════════════════════════════════════════════════════════
6398
6399    /// c:546-562 — `zfargstring("CMD", &[])` returns the cmd plus the
6400    /// CRLF terminator (c:559 `strcat(line, "\r\n")`); no trailing
6401    /// space, no extra chars before the CRLF.
6402    #[test]
6403    fn zfargstring_no_args_returns_cmd_verbatim() {
6404        assert_eq!(zfargstring("RETR", &[]), "RETR\r\n");
6405        assert_eq!(zfargstring("", &[]), "\r\n");
6406    }
6407
6408    /// c:546-562 — single arg joined with single space, CRLF appended
6409    /// (c:556-559).
6410    #[test]
6411    fn zfargstring_one_arg_joins_with_single_space() {
6412        assert_eq!(zfargstring("CWD", &["/tmp"]), "CWD /tmp\r\n");
6413        assert_eq!(zfargstring("USER", &["anonymous"]), "USER anonymous\r\n");
6414    }
6415
6416    /// c:546-562 — multiple args joined with single space each, CRLF
6417    /// appended (c:556-559).
6418    #[test]
6419    fn zfargstring_multiple_args_each_space_separated() {
6420        let r = zfargstring("FOO", &["a", "b", "c"]);
6421        assert_eq!(r, "FOO a b c\r\n", "exactly one space between each arg");
6422    }
6423
6424    /// c:546-562 — empty arg becomes empty word with surrounding
6425    /// spaces preserved (c:556-557 strcat " " then strcat "").
6426    #[test]
6427    fn zfargstring_empty_arg_preserves_separators() {
6428        let r = zfargstring("CMD", &["", "after"]);
6429        // C: "CMD" + " " + "" + " " + "after" + "\r\n" = "CMD  after\r\n"
6430        assert_eq!(r, "CMD  after\r\n", "empty arg → double space");
6431    }
6432
6433    /// c:267, c:273 — type and mode masks are non-overlapping bit
6434    /// fields. Pin: AND of the masks is exactly 0.
6435    #[test]
6436    fn ZFST_TMSK_and_MMSK_have_no_overlap() {
6437        assert_eq!(ZFST_TMSK & ZFST_MMSK, 0, "type/mode masks must not overlap");
6438    }
6439
6440    /// c:267 — `ZFST_TYPE` is idempotent: applying twice == once.
6441    #[test]
6442    fn ZFST_TYPE_is_idempotent() {
6443        let s = ZFST_TMSK | ZFST_BLOC;
6444        assert_eq!(ZFST_TYPE(ZFST_TYPE(s)), ZFST_TYPE(s));
6445    }
6446
6447    /// c:273 — `ZFST_MODE` is idempotent.
6448    #[test]
6449    fn ZFST_MODE_is_idempotent() {
6450        let s = ZFST_TMSK | ZFST_BLOC;
6451        assert_eq!(ZFST_MODE(ZFST_MODE(s)), ZFST_MODE(s));
6452    }
6453
6454    /// `ZFST_TYPE` of a value with only mode bits returns 0 (no
6455    /// type bits to extract).
6456    #[test]
6457    fn ZFST_TYPE_of_mode_only_returns_zero() {
6458        assert_eq!(ZFST_TYPE(ZFST_BLOC), 0);
6459    }
6460
6461    /// `ZFST_MODE` of a value with only type bits returns 0 (no
6462    /// mode bits to extract).
6463    #[test]
6464    fn ZFST_MODE_of_type_only_returns_zero() {
6465        assert_eq!(ZFST_MODE(ZFST_TMSK), 0);
6466    }
6467
6468    /// `zfargstring` does NOT add a trailing space after the last
6469    /// arg (would corrupt FTP commands which are CRLF-terminated).
6470    #[test]
6471    fn zfargstring_no_trailing_space() {
6472        let r = zfargstring("STOR", &["file.txt"]);
6473        assert!(!r.ends_with(' '), "no trailing space: {:?}", r);
6474    }
6475
6476    /// `zfargstring` does NOT add a leading space before the cmd.
6477    #[test]
6478    fn zfargstring_no_leading_space() {
6479        let r = zfargstring("STOR", &["file.txt"]);
6480        assert!(!r.starts_with(' '), "no leading space: {:?}", r);
6481    }
6482
6483    // ═══════════════════════════════════════════════════════════════════
6484    // Additional C-parity tests for Src/Modules/zftp.c sessions + lifecycle.
6485    // ═══════════════════════════════════════════════════════════════════
6486
6487    /// c:2814-2815 — `newsession("name")` registers the session with
6488    /// the name preserved and points zfsess at it.
6489    #[test]
6490    fn newsession_returns_session_with_name() {
6491        let _g = crate::test_util::global_state_lock();
6492        newsession("test_sess_xyz_zshrs");
6493        let state = zftp_state().lock().unwrap();
6494        assert_eq!(
6495            state
6496                .sessions
6497                .get("test_sess_xyz_zshrs")
6498                .map(|s| s.name.as_str()),
6499            Some("test_sess_xyz_zshrs")
6500        );
6501        assert_eq!(state.current.as_deref(), Some("test_sess_xyz_zshrs"));
6502    }
6503
6504    /// c:2816 — fresh session has dfd = -1 (no data fd open).
6505    #[test]
6506    fn newsession_fresh_has_no_data_fd() {
6507        let _g = crate::test_util::global_state_lock();
6508        newsession("test_dfd_xyz");
6509        assert_eq!(
6510            zftp_state()
6511                .lock()
6512                .unwrap()
6513                .sessions
6514                .get("test_dfd_xyz")
6515                .map(|s| s.dfd),
6516            Some(-1),
6517            "fresh session must have dfd=-1"
6518        );
6519    }
6520
6521    /// c:2817 — fresh session has empty params.
6522    #[test]
6523    fn newsession_fresh_has_empty_params() {
6524        let _g = crate::test_util::global_state_lock();
6525        newsession("test_params_xyz");
6526        assert!(
6527            zftp_state()
6528                .lock()
6529                .unwrap()
6530                .sessions
6531                .get("test_params_xyz")
6532                .map(|s| s.params.is_empty())
6533                .unwrap_or(false),
6534            "fresh params must be empty"
6535        );
6536    }
6537
6538    /// c:2806 — newsession called twice with same name doesn't duplicate
6539    /// the registered session.
6540    #[test]
6541    fn newsession_idempotent_for_same_name() {
6542        let _g = crate::test_util::global_state_lock();
6543        // Get baseline count.
6544        newsession("zshrs_dup_test_session");
6545        let count1 = zftp_state().lock().unwrap().sessions.len();
6546        newsession("zshrs_dup_test_session");
6547        let count2 = zftp_state().lock().unwrap().sessions.len();
6548        assert_eq!(count1, count2, "duplicate newsession must not grow table");
6549    }
6550
6551    /// c:3090 — `zftp_close(_, [], _)` no panic.
6552    #[test]
6553    fn zftp_close_empty_args_no_panic() {
6554        let _g = crate::test_util::global_state_lock();
6555        let _ = zftp_close("close", &[], 0);
6556    }
6557
6558    /// c:3200 — `zftp_rmsession(_, [], _)` no panic.
6559    #[test]
6560    fn zftp_rmsession_empty_args_no_panic() {
6561        let _g = crate::test_util::global_state_lock();
6562        let _ = zftp_rmsession("rmsession", &[], 0);
6563    }
6564
6565    /// c:2937 — `zftp_mkdir(_, [], _)` returns nonzero (needs arg).
6566    #[test]
6567    fn zftp_mkdir_no_args_returns_nonzero() {
6568        let _g = crate::test_util::global_state_lock();
6569        let r = zftp_mkdir("mkdir", &[], 0);
6570        assert_ne!(r, 0, "no args → usage error");
6571    }
6572
6573    /// c:2958 — `zftp_rename(_, [], _)` returns nonzero.
6574    #[test]
6575    fn zftp_rename_no_args_returns_nonzero() {
6576        let _g = crate::test_util::global_state_lock();
6577        let r = zftp_rename("rename", &[], 0);
6578        assert_ne!(r, 0, "no args → usage error");
6579    }
6580
6581    /// c:2987 — `zftp_quote(_, [], _)` returns nonzero.
6582    #[test]
6583    fn zftp_quote_no_args_returns_nonzero() {
6584        let _g = crate::test_util::global_state_lock();
6585        let r = zftp_quote("quote", &[], 0);
6586        assert_ne!(r, 0);
6587    }
6588
6589    /// c:4243 — setup_(NULL) returns 0.
6590    #[test]
6591    fn zftp_setup_returns_zero_pin() {
6592        let _g = crate::test_util::global_state_lock();
6593        assert_eq!(setup_(std::ptr::null()), 0);
6594    }
6595
6596    // ═══════════════════════════════════════════════════════════════════
6597    // Additional C-parity tests for Src/Modules/zftp.c
6598    // c:25 zftp_session / c:111 ZFST_TYPE / c:120 ZFST_MODE /
6599    // c:307 zfmovefd / c:327 zfsetparam / c:363 zfunsetparam /
6600    // c:377 zfargstring
6601    // ═══════════════════════════════════════════════════════════════════
6602
6603    /// c:111 — `ZFST_TYPE` returns i32 type pin.
6604    #[test]
6605    fn zfst_type_returns_i32_type() {
6606        let _: i32 = ZFST_TYPE(0);
6607    }
6608
6609    /// c:120 — `ZFST_MODE` returns i32 type pin.
6610    #[test]
6611    fn zfst_mode_returns_i32_type() {
6612        let _: i32 = ZFST_MODE(0);
6613    }
6614
6615    /// c:111 — `ZFST_TYPE` is pure full sweep.
6616    #[test]
6617    fn zfst_type_is_pure_full_sweep() {
6618        for v in [0i32, 1, 7, 0xff, i32::MAX] {
6619            let first = ZFST_TYPE(v);
6620            for _ in 0..3 {
6621                assert_eq!(ZFST_TYPE(v), first, "ZFST_TYPE({}) pure", v);
6622            }
6623        }
6624    }
6625
6626    /// c:120 — `ZFST_MODE` is pure.
6627    #[test]
6628    fn zfst_mode_is_pure_full_sweep() {
6629        for v in [0i32, 1, 7, 0xff, i32::MAX] {
6630            let first = ZFST_MODE(v);
6631            for _ in 0..3 {
6632                assert_eq!(ZFST_MODE(v), first, "ZFST_MODE({}) pure", v);
6633            }
6634        }
6635    }
6636
6637    /// c:307 — `zfmovefd(-1)` invalid fd returns i32 type.
6638    #[test]
6639    fn zfmovefd_invalid_returns_i32_type() {
6640        let _g = crate::test_util::global_state_lock();
6641        let _: i32 = zfmovefd(-1);
6642    }
6643
6644    /// c:327 — `zfsetparam("", "", 0)` empty inputs safe.
6645    #[test]
6646    fn zfsetparam_empty_inputs_no_panic() {
6647        let _g = crate::test_util::global_state_lock();
6648        zfsetparam("", "", 0);
6649    }
6650
6651    /// c:363 — `zfunsetparam("")` empty name PANICS in zshrs port
6652    /// ("failed to remove environment variable \"\": Invalid argument").
6653    /// C body calls `unsetparam` which silently no-ops on empty names;
6654    /// Rust port forwards to `std::env::remove_var("")` which traps
6655    /// on empty key per recent Rust safety hardening (since 1.86).
6656    /// Should guard empty-name short-circuit per C semantic.
6657    #[test]
6658    fn zfunsetparam_empty_name_no_panic() {
6659        let _g = crate::test_util::global_state_lock();
6660        zfunsetparam("");
6661    }
6662
6663    /// c:377 — `zfargstring("", &[])` empty returns String.
6664    #[test]
6665    fn zfargstring_empty_returns_string_type() {
6666        let _: String = zfargstring("", &[]);
6667    }
6668
6669    /// c:377 — `zfargstring` is pure for empty input.
6670    #[test]
6671    fn zfargstring_empty_is_pure() {
6672        let first = zfargstring("", &[]);
6673        for _ in 0..3 {
6674            assert_eq!(
6675                zfargstring("", &[]),
6676                first,
6677                "zfargstring empty must be pure"
6678            );
6679        }
6680    }
6681
6682    /// c:25 — `zftp_session` returns i32 type pin.
6683    #[test]
6684    fn zftp_session_returns_i32_type() {
6685        let _g = crate::test_util::global_state_lock();
6686        let _: i32 = zftp_session("nothing", &[], 0);
6687    }
6688}