Skip to content

Conversation

@mpereiraesaa
Copy link

@mpereiraesaa mpereiraesaa commented Nov 13, 2025

Just an FTP payload stage 2 already tested on my PS5 in the latest fw 12.20 👍

Summary by CodeRabbit

  • New Features

    • Added FTP server support with authentication, directory navigation, file browsing, upload/download capabilities, directory management, and administrative operations. Supports both passive and active connection modes for flexible data transfers.
  • Chores

    • Updated configuration settings for file tracking.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 13, 2025

Walkthrough

This PR introduces a comprehensive FTP server payload implementation with control and data channel handling for file operations, updates logging in a bootstrap module, and modifies .gitignore entries to exclude a persistent directory while removing an obsolete ignore pattern.

Changes

Cohort / File(s) Summary
Ignore patterns
.gitignore
Removed "-saveindex" entry; added "persistent" ignore rule
FTP server implementation
payloads/ftp_server.py
New file: Full FTP server with FTPState and FTPServer classes. Implements 20+ FTP commands (USER, PASS, PWD, CWD, LIST, RETR, STOR, APPE, MKD, RMD, DELE, RNFR, RNTO, SITE, etc.); handles active/passive data channels; includes socket I/O helpers, directory listing, file operations, path sanitization, and connection lifecycle management
Logging integration
stage2.py
Replaced print() statement with structured log() call from utils.rp

Sequence Diagram

sequenceDiagram
    participant Client
    participant FTPServer
    participant CtrlConn as Control Channel
    participant DataConn as Data Channel
    participant FileSystem

    Client->>FTPServer: Connect
    FTPServer->>CtrlConn: Accept connection
    CtrlConn->>Client: 220 (Ready)
    
    Client->>CtrlConn: USER command
    CtrlConn->>FTPServer: handle_USER()
    FTPServer->>Client: 331 (User OK)
    
    Client->>CtrlConn: PASS command
    CtrlConn->>FTPServer: handle_PASS()
    FTPServer->>Client: 230 (Login OK)
    
    Client->>CtrlConn: PASV command
    CtrlConn->>FTPServer: handle_PASV()
    FTPServer->>DataConn: Create listening socket
    FTPServer->>Client: 227 (Passive mode ready)
    
    Client->>DataConn: Connect (data channel)
    
    Client->>CtrlConn: LIST command
    CtrlConn->>FTPServer: handle_LIST()
    FTPServer->>FileSystem: Read directory
    FileSystem-->>FTPServer: Directory entries
    FTPServer->>DataConn: Send listing
    FTPServer->>Client: 226 (Transfer complete)
    
    Client->>CtrlConn: QUIT command
    CtrlConn->>FTPServer: Close connection

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

  • payloads/ftp_server.py: Dense FTP protocol implementation requiring verification of:

    • RFC 959 compliance for all 20+ command handlers
    • Path traversal sanitization logic (critical security consideration)
    • Active/passive mode socket management and edge cases
    • Data transfer loops with proper error handling and reply codes
    • Buffer management in recv_line() and file I/O operations
    • State machine correctness across command sequences
  • stage2.py: Single logging substitution (trivial in isolation but context-dependent)

  • Heterogeneous changes across three distinct areas require separate reasoning for each file type

Poem

🐰 A server hops to life, with FTP in its stride,
Accepting connections far and wide,
Files dance through passive and active modes,
While directories list and transfers unload,
The persistent dreams come alive, hooray! 📁✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.61% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title '[STAGE 2] FTP payload' directly and clearly summarizes the main addition—a comprehensive FTP server payload implementation for Stage 2—matching the primary change in the changeset.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Sorry, something went wrong.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 446478f and c66f12d.

📒 Files selected for processing (3)
  • .gitignore (1 hunks)
  • payloads/ftp_server.py (1 hunks)
  • stage2.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
stage2.py (1)
src/utils/rp.py (1)
  • log (45-52)
payloads/ftp_server.py (3)
src/structure.py (3)
  • Structure (9-46)
  • create (23-25)
  • get_field_raw (90-93)
payloads/updater_for_up_to_2.x.x.py (1)
  • sc (16-34)
src/utils/rp.py (1)
  • log (45-52)
🪛 Ruff (0.14.4)
payloads/ftp_server.py

32-32: Comment contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF003)


129-129: Multiple statements on one line (colon)

(E701)


130-130: Multiple statements on one line (colon)

(E701)


131-131: Multiple statements on one line (colon)

(E701)


132-132: Multiple statements on one line (colon)

(E701)


133-133: Multiple statements on one line (colon)

(E701)


134-134: Multiple statements on one line (colon)

(E701)


135-135: Multiple statements on one line (colon)

(E701)


136-136: Multiple statements on one line (colon)

(E701)


202-205: Create your own exception

(TRY002)


230-233: Create your own exception

(TRY002)


237-240: Create your own exception

(TRY002)


272-272: Do not catch blind exception: Exception

(BLE001)


287-287: Do not catch blind exception: Exception

(BLE001)


299-299: Do not catch blind exception: Exception

(BLE001)


304-304: Undefined name unicode

(F821)


308-308: Undefined name unicode

(F821)


309-309: Do not catch blind exception: Exception

(BLE001)


310-310: Undefined name unichr

(F821)


314-314: Do not catch blind exception: Exception

(BLE001)


315-315: Undefined name unichr

(F821)


318-318: Undefined name unicode

(F821)


366-367: try-except-pass detected, consider logging the exception

(S110)


366-366: Do not catch blind exception: Exception

(BLE001)


503-503: Do not catch blind exception: Exception

(BLE001)


556-557: try-except-pass detected, consider logging the exception

(S110)


556-556: Do not catch blind exception: Exception

(BLE001)


569-570: try-except-pass detected, consider logging the exception

(S110)


569-569: Do not catch blind exception: Exception

(BLE001)


826-826: Do not catch blind exception: Exception

(BLE001)


960-961: try-except-pass detected, consider logging the exception

(S110)


960-960: Do not catch blind exception: Exception

(BLE001)


970-970: Do not catch blind exception: Exception

(BLE001)


1051-1051: Do not catch blind exception: Exception

(BLE001)


1051-1051: Local variable e is assigned to but never used

Remove assignment to unused variable e

(F841)


1060-1061: try-except-pass detected, consider logging the exception

(S110)


1060-1060: Do not catch blind exception: Exception

(BLE001)


1065-1066: try-except-pass detected, consider logging the exception

(S110)


1065-1065: Do not catch blind exception: Exception

(BLE001)


1075-1078: Create your own exception

(TRY002)


1100-1103: Create your own exception

(TRY002)


1107-1110: Create your own exception

(TRY002)


1112-1112: Possible binding to all interfaces

(S104)


1118-1119: try-except-pass detected, consider logging the exception

(S110)


1118-1118: Do not catch blind exception: Exception

(BLE001)


1139-1140: try-except-pass detected, consider logging the exception

(S110)


1139-1139: Do not catch blind exception: Exception

(BLE001)

Comment on lines +435 to +456
arg = line[4:].strip() if len(line) > 4 else None
if not arg:
self.ftp_send_ctrl(
"500 Syntax error, command unrecognized.\r\n"
)
return

if arg == "/":
self.state.cur_path = "/"
self.ftp_send_ctrl(
"250 Requested file action okay, completed.\r\n"
)
return

if arg == "..":
self.state.cur_path = dir_up(self.state.cur_path)
if self.state.cur_path != "/":
self.state.cur_path = "/"
self.ftp_send_ctrl(
"250 Requested file action okay, completed.\r\n"
)
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix CWD .. handling

Walking up from /a/b/c should land at /a/b, but the current branch forces everything above the root back to /. Drop the extra reset so CWD .. truly moves one level up.

-        if arg == "..":
-            self.state.cur_path = dir_up(self.state.cur_path)
-            if self.state.cur_path != "/":
-                self.state.cur_path = "/"
+        if arg == "..":
+            self.state.cur_path = dir_up(self.state.cur_path)
             self.ftp_send_ctrl(
                 "250 Requested file action okay, completed.\r\n"
             )
             return
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
arg = line[4:].strip() if len(line) > 4 else None
if not arg:
self.ftp_send_ctrl(
"500 Syntax error, command unrecognized.\r\n"
)
return
if arg == "/":
self.state.cur_path = "/"
self.ftp_send_ctrl(
"250 Requested file action okay, completed.\r\n"
)
return
if arg == "..":
self.state.cur_path = dir_up(self.state.cur_path)
if self.state.cur_path != "/":
self.state.cur_path = "/"
self.ftp_send_ctrl(
"250 Requested file action okay, completed.\r\n"
)
return
arg = line[4:].strip() if len(line) > 4 else None
if not arg:
self.ftp_send_ctrl(
"500 Syntax error, command unrecognized.\r\n"
)
return
if arg == "/":
self.state.cur_path = "/"
self.ftp_send_ctrl(
"250 Requested file action okay, completed.\r\n"
)
return
if arg == "..":
self.state.cur_path = dir_up(self.state.cur_path)
self.ftp_send_ctrl(
"250 Requested file action okay, completed.\r\n"
)
return
🤖 Prompt for AI Agents
In payloads/ftp_server.py around lines 435 to 456, the CWD ".." branch currently
sets self.state.cur_path = dir_up(self.state.cur_path) but then incorrectly
forces any non-root result back to "/"—remove that extra reset so the result of
dir_up is preserved; simply assign the parent path returned by dir_up and keep
the existing response send, no additional conditional overwrite is needed.

Comment on lines +729 to +754
fd = self._open_for_send(full_path)
if fd < 0:
self.ftp_send_ctrl("550 File not found\r\n")
return

if self.state.restore_point and self.state.restore_point >= 0:
sc.syscalls.lseek(fd, self.state.restore_point, 0) # SEEK_SET=0

chunk = bytearray(8192)
self.ftp_send_ctrl(
"150 Opening Image mode data transfer\r\n"
)
if not self.ftp_open_data():
sc.syscalls.close(fd)
return

while True:
n = u64_to_i64(sc.syscalls.read(fd, chunk, len(chunk)))
if n <= 0:
break
self.ftp_send_data(chunk[:n])

self.ftp_close_data()
sc.syscalls.close(fd)
self.ftp_send_ctrl("226 Transfer completed\r\n")

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clear REST offset after RETR transfers

self.state.restore_point survives every RETR, so a later download without a new REST command skips the first chunk of data. REST markers are single-use and must be reset (effectively REST 0) once the transfer finishes or aborts.(rfc-editor.org) Make sure all exit paths restore self.state.restore_point = -1.

         if fd < 0:
             self.ftp_send_ctrl("550 File not found\r\n")
+            self.state.restore_point = -1
             return
@@
         if not self.ftp_open_data():
+            self.state.restore_point = -1
             sc.syscalls.close(fd)
             return
@@
         self.ftp_send_ctrl("226 Transfer completed\r\n")
+        self.state.restore_point = -1
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fd = self._open_for_send(full_path)
if fd < 0:
self.ftp_send_ctrl("550 File not found\r\n")
return
if self.state.restore_point and self.state.restore_point >= 0:
sc.syscalls.lseek(fd, self.state.restore_point, 0) # SEEK_SET=0
chunk = bytearray(8192)
self.ftp_send_ctrl(
"150 Opening Image mode data transfer\r\n"
)
if not self.ftp_open_data():
sc.syscalls.close(fd)
return
while True:
n = u64_to_i64(sc.syscalls.read(fd, chunk, len(chunk)))
if n <= 0:
break
self.ftp_send_data(chunk[:n])
self.ftp_close_data()
sc.syscalls.close(fd)
self.ftp_send_ctrl("226 Transfer completed\r\n")
fd = self._open_for_send(full_path)
if fd < 0:
self.ftp_send_ctrl("550 File not found\r\n")
self.state.restore_point = -1
return
if self.state.restore_point and self.state.restore_point >= 0:
sc.syscalls.lseek(fd, self.state.restore_point, 0) # SEEK_SET=0
chunk = bytearray(8192)
self.ftp_send_ctrl(
"150 Opening Image mode data transfer\r\n"
)
if not self.ftp_open_data():
self.state.restore_point = -1
sc.syscalls.close(fd)
return
while True:
n = u64_to_i64(sc.syscalls.read(fd, chunk, len(chunk)))
if n <= 0:
break
self.ftp_send_data(chunk[:n])
self.ftp_close_data()
sc.syscalls.close(fd)
self.ftp_send_ctrl("226 Transfer completed\r\n")
self.state.restore_point = -1
🤖 Prompt for AI Agents
In payloads/ftp_server.py around lines 729 to 754, the REST offset
(self.state.restore_point) is left set after RETR transfers and on error paths;
REST markers must be single-use and reset to -1 once a transfer finishes or
aborts. Wrap the data-transfer sequence in a try/finally (or ensure every early
return path) and set self.state.restore_point = -1 in the finally block (or
immediately before each return on errors) so that after successful completion,
failure to open data, file-not-found, or any other exit the restore_point is
cleared.

Comment on lines +755 to +799
def _recv_file(self, full_path, append=False):
fd = self._open_for_recv(full_path, append=append)
if fd < 0:
self.ftp_send_ctrl("500 Error opening file\r\n")
return

self.ftp_send_ctrl(
"150 Opening Image mode data transfer\r\n"
)
if not self.ftp_open_data():
sc.syscalls.close(fd)
return

chunk = bytearray(8192)
while True:
# read from data connection into chunk
if self.state.conn_type == "active":
n = u64_to_i64(
sc.syscalls.read(
self.state.data_sock,
chunk,
len(chunk),
)
)
else:
n = u64_to_i64(
sc.syscalls.read(
self.state.pasv_accepted_sock,
chunk,
len(chunk),
)
)
if n <= 0:
break

w = u64_to_i64(
sc.syscalls.write(fd, chunk[:n], n)
)
if w < n:
self.ftp_send_ctrl("550 File write error\r\n")
break

self.ftp_close_data()
sc.syscalls.close(fd)
self.ftp_send_ctrl("226 Transfer completed\r\n")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reset REST state after STOR/APPE

When a client resumes an upload with REST, the server must treat the restart marker as one-shot; leaving restore_point untouched means the next store starts mid-file even without REST. Clear the marker on every return path in _recv_file to honour the protocol.(rfc-editor.org)

         fd = self._open_for_recv(full_path, append=append)
         if fd < 0:
             self.ftp_send_ctrl("500 Error opening file\r\n")
+            self.state.restore_point = -1
             return
@@
         if not self.ftp_open_data():
             sc.syscalls.close(fd)
+            self.state.restore_point = -1
             return
@@
         self.ftp_close_data()
         sc.syscalls.close(fd)
         self.ftp_send_ctrl("226 Transfer completed\r\n")
+        self.state.restore_point = -1
🤖 Prompt for AI Agents
In payloads/ftp_server.py around lines 755 to 799, _recv_file currently leaves
self.state.restore_point set when returning, which causes subsequent STOR/APPE
to resume incorrectly; clear the restart marker on every exit path by setting
self.state.restore_point = None (or the equivalent cleared value used elsewhere)
immediately before each return and also after the normal completion path so the
restart marker is one-shot per RFC3659.

Comment on lines +928 to +949
def handle_RNTO(self, line):
arg = line[5:].strip()
if not self.state.rname:
self.ftp_send_ctrl(
"503 Bad sequence of commands\r\n"
)
return

old_full = sanitize_path(self.state.cur_path, self.state.rname)
new_full = sanitize_path(self.state.cur_path, arg)
ret = u64_to_i64(
sc.syscalls.rename(
old_full.encode("utf-8") + b"\0",
new_full.encode("utf-8") + b"\0",
)
)
if ret < 0:
self.ftp_send_ctrl(
"550 Error renaming file\r\n"
)
else:
self.ftp_send_ctrl("226 Renamed file\r\n")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clear remembered RNFR name after RNTO

After any RNTO attempt you must require a fresh RNFR; otherwise a client can keep issuing RNTO with a stale self.state.rname, violating the RNFR/RNTO sequence defined in RFC 959.(w3.org) Reset self.state.rname = None on both success and failure.

         if ret < 0:
             self.ftp_send_ctrl(
                 "550 Error renaming file\r\n"
             )
+            self.state.rname = None
         else:
             self.ftp_send_ctrl("226 Renamed file\r\n")
+            self.state.rname = None
🤖 Prompt for AI Agents
In payloads/ftp_server.py around lines 928 to 949, after handling RNTO you must
clear the remembered RNFR name so subsequent RNTOs require a fresh RNFR; modify
the RNTO handler to set self.state.rname = None in both the error and success
paths (i.e., after determining ret < 0 and also after the else branch) so the
RNFR/RNTO sequence is enforced per RFC959.

Comment on lines +998 to +1057
def serve_one(self, ctrl_sock):
self.state.ctrl_sock = ctrl_sock
self.state.is_connected = True
self.ftp_send_ctrl("220 RLL FTP Server\r\n")

handlers = {
"USER": self.handle_USER,
"PASS": self.handle_PASS,
"NOOP": self.handle_NOOP,
"PWD": self.handle_PWD,
"TYPE": self.handle_TYPE,
"PASV": self.handle_PASV,
"LIST": self.handle_LIST,
"CWD": self.handle_CWD,
"CDUP": self.handle_CDUP,
"PORT": self.handle_PORT,
"SYST": self.handle_SYST,
"FEAT": self.handle_FEAT,
"MKD": self.handle_MKD,
"RMD": self.handle_RMD,
"SIZE": self.handle_SIZE,
"DELE": self.handle_DELE,
"RNFR": self.handle_RNFR,
"RNTO": self.handle_RNTO,
"RETR": self.handle_RETR,
"STOR": self.handle_STOR,
"APPE": self.handle_APPE,
"REST": self.handle_REST,
"SITE": self.handle_SITE,
}

while True:
line = self.recv_line(ctrl_sock)
if line is None:
break
line = line.strip()
if not line:
continue

cmd = line.split()[0].upper()
if cmd == "QUIT":
self.ftp_send_ctrl("221 Goodbye\r\n")
break

handler = handlers.get(cmd)
if handler is None:
self.ftp_send_ctrl(
"500 Syntax error, command unrecognized.\r\n"
)
continue

try:
handler(line)
except Exception as e:
# Best-effort robustness
self.ftp_send_ctrl(
"550 Internal server error\r\n"
)

# cleanup
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reset FTP session state per connection

FTPServer keeps a single FTPState object across sessions, so a new client inherits the previous client’s working directory, pending rename, REST offset, etc. That breaks FTP semantics and can expose one user’s state to the next. Re-initialize self.state when a control connection is accepted, copying only the configured root so each session starts clean.

-        self.state.ctrl_sock = ctrl_sock
-        self.state.is_connected = True
+        previous_root = self.state.root_path if self.state else "/"
+        self.state = FTPState()
+        self.state.root_path = previous_root
+        self.state.cur_path = previous_root
+        self.state.ctrl_sock = ctrl_sock
+        self.state.is_connected = True
🧰 Tools
🪛 Ruff (0.14.4)

1051-1051: Do not catch blind exception: Exception

(BLE001)


1051-1051: Local variable e is assigned to but never used

Remove assignment to unused variable e

(F841)

🤖 Prompt for AI Agents
In payloads/ftp_server.py around lines 998 to 1057, the server reuses a single
FTPState across connections which leaks per-session state (cwd, RNFR, REST
offset, etc.) to new clients; reinitialize self.state at the start of serve_one
so each control connection gets a fresh FTPState that preserves only the
configured root (or equivalent configured session defaults). Create a new
FTPState instance (or reset all per-session fields) using the server's
configured root before sending the welcome and handling commands so every
session starts with a clean state.

from utils.rp import log
from constants import SYSCALL

DEBUG = True
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this correct? Setting debug as true?

Copy link
Owner

@Helloyunho Helloyunho left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check and fix reviews from coderabbit, and then it will be all good

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

None yet

2 participants