Created
June 26, 2026 19:44
-
-
Save tknerr/bf6df67348f5b1693f39811843c121c9 to your computer and use it in GitHub Desktop.
Monkey-patches for making wconrad/ftpd working on Windows
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # | |
| # See https://github.com/wconrad/ftpd (the snippet below has been tested with ftpd gem 2.1.0) | |
| # | |
| # Monkey patches below were AI generated, basic functionality of the patched ftpd server | |
| # has been succesfully tested on Windows 11 using FileZilla as an FTP client | |
| # | |
| require 'ftpd' | |
| require 'tmpdir' | |
| require 'etc' | |
| # Silence the noisy Ruby 3.2 "forwarding to private method" warnings that the | |
| # ftpd gem triggers internally via Forwardable. They are harmless and unrelated | |
| # to anything we can fix in the gem. | |
| module SuppressForwardableWarnings | |
| def warn(message, *args, **kwargs) | |
| return if message.to_s.include?('forwardable.rb') && | |
| message.to_s.include?('forwarding to private method') | |
| super | |
| end | |
| end | |
| Warning.extend(SuppressForwardableWarnings) | |
| # -------------------------------------------------------------------------- | |
| # Windows fix #1: keep the virtual FTP namespace POSIX-based. | |
| # | |
| # Every ftpd command handler normalizes the requested path with | |
| # File.expand_path(argument, name_prefix) | |
| # to resolve "." and ".." against the session's current directory. On Windows | |
| # File.expand_path treats a leading "/" as NOT absolute (it has no drive) and | |
| # helpfully injects the current drive letter: | |
| # File.expand_path('/howto', '/') # => "C:/howto" (Windows) | |
| # # => "/howto" (POSIX) | |
| # That drive letter then leaks into the session's name_prefix, so PWD reports | |
| # "C:\howto", FileZilla treats it as a new root, and subsequent LIST/CWD globs | |
| # resolve against the wrong place (empty or "550 No such file or directory"). | |
| # | |
| # We make the virtual namespace behave identically on every OS by routing | |
| # File.expand_path through a pure POSIX implementation whenever the base path | |
| # is a driveless absolute path (i.e. the virtual "/..." namespace). Physical | |
| # disk paths always carry a drive letter (or a relative base) on Windows, so | |
| # they keep Ruby's native behavior untouched. | |
| module PosixVirtualPath | |
| module_function | |
| # True for the virtual FTP namespace: rooted at "/", no Windows drive, not | |
| # a UNC path. | |
| def virtual?(base) | |
| base = base.to_s | |
| base.start_with?('/') && base !~ %r{\A[A-Za-z]:} && base !~ %r{\A//} | |
| end | |
| # Pure POSIX File.expand_path: join + collapse "." and ".." segments | |
| # without ever introducing a drive letter or backslashes. | |
| def expand(path, base) | |
| combined = path.to_s.start_with?('/') ? path.to_s : "#{base}/#{path}" | |
| segments = [] | |
| combined.tr('\\', '/').split('/').each do |segment| | |
| case segment | |
| when '', '.' then next | |
| when '..' then segments.pop | |
| else segments << segment | |
| end | |
| end | |
| '/' + segments.join('/') | |
| end | |
| end | |
| class << File | |
| alias_method :__expand_path_native, :expand_path | |
| # Redirect virtual-namespace expansions to the POSIX implementation while | |
| # leaving physical disk paths on Ruby's native (OS-specific) behavior. | |
| def expand_path(path, base = Dir.pwd) | |
| if PosixVirtualPath.virtual?(base) | |
| PosixVirtualPath.expand(path, base) | |
| else | |
| __expand_path_native(path, base) | |
| end | |
| end | |
| # Ruby 3.2 removed File.exists?; restore it for any gem code that still | |
| # uses it. | |
| alias_method :exists?, :exist? | |
| end | |
| # Windows fix #2: file listings. | |
| # | |
| # DiskFileSystem#file_info resolves the owner/group columns via | |
| # Etc.getpwuid / Etc.getgrgid. Those POSIX lookups return nil on Windows, | |
| # causing a NoMethodError during LIST. Fall back to the numeric id. | |
| class WindowsSafeDiskFileSystem < Ftpd::DiskFileSystem | |
| private | |
| def uid_name(uid) | |
| entry = Etc.getpwuid(uid) rescue nil | |
| entry ? entry.name : uid.to_s | |
| end | |
| def gid_name(gid) | |
| entry = Etc.getgrgid(gid) rescue nil | |
| entry ? entry.name : gid.to_s | |
| end | |
| end | |
| class FtpDriver | |
| def initialize(user, password, data_dir) | |
| @user = user | |
| @password = password | |
| @data_dir = data_dir | |
| end | |
| def authenticate(user, password, _account) | |
| user == @user && (password.nil? || password == @password) | |
| end | |
| def file_system(_user) | |
| WindowsSafeDiskFileSystem.new(@data_dir) | |
| end | |
| end | |
| current_dir = File.expand_path(File.dirname(File.dirname(__FILE__))) | |
| driver = FtpDriver.new('user', 'pass', current_dir) | |
| server = Ftpd::FtpServer.new(driver) | |
| server.start | |
| puts "Server listening on port #{server.bound_port}" | |
| gets | |
| server.stop |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment