;;; ftp.scm -- an FTP client library for the Scheme Shell ;;; This file is part of the Scheme Untergrund Networking package. ;;; Copyright (c) 1998 by Eric Marsden. ;;; Copyright (c) 2003 by Mike Sperber ;;; For copyright information, see the file COPYING which comes with ;;; the distribution. ;; The following rfc959 commands are not implemented: ;; ;; * ACCT (account; this is ignored by most servers) ;; * SMNT (structure mount, for mounting another filesystem) ;; * REIN (reinitialize connection) ;; * LOGOUT (quit without interrupting ongoing transfers) ;; * STRU (file structure) ;; * ALLO (allocate space on server) ;;; Related work ====================================================== ;; ;; * rfc959 describes the FTP protocol; see ;; http://www.ietf.org/rfc/rfc959.txt ;; ;; * /anonymous@sunsite.unc.edu:/pub/Linux/libs/ftplib.tar.gz is a ;; library similar to this one, written in C, by Thomas Pfau ;; ;; * FTP.pm is a Perl module with similar functionality (available ;; from http://www.perl.com/CPAN) ;; ;; * XEmacs gets transparent remote file access from EFS. ;; However, it cheats by using /usr/bin/ftp. ;; ;; * Siod (a small-footprint Scheme implementation by George Carette) ;; comes with a file ftp.scm with a small subset of these functions ;; defined ;;; TODO ============================================================ ;; ;; * Unix-specific commands such as SITE UMASK, SITE CHMOD ;; * improved error handling ;; Communication is initiated by the client. The server responds to ;; each request with a three digit status code and an explanatory ;; message, and occasionally with data (which is sent via a separate, ;; one-off channel). The client starts by opening a command connection ;; to a well known port on the server machine. Messages send to the ;; server are of the form ;; ;; CMD [ arg ] ;; ;; Replies from the server are of the form ;; ;; xyz Informative message ;; ;; where xyz is a three digit code which indicates whether the ;; operation succeeded or not, whether the server is waiting for more ;; data, etc. The server may also send multiline messages of the form ;; ;; xyz- Start of multiline message ;; [ + More information ]* ;; xyz End of multiline message ;; ;; Some of the procedures in this module extract useful information ;; from the server's reply, such as the size of a file, or the name of ;; the directory we have moved to. These procedures return either the ;; extracted information, or #f to indicate failure. Other procedures ;; return a "status", which is either the server's reply as a string, ;; or #f to signify failure. ;; beware, the log file contains password information! (define (ftp-connect host login password passive? . args) (let-optionals* args ((log #f)) (let* ((hst-info (host-info host)) (hostname (host-info:name hst-info)) (srvc-info (service-info "ftp" "tcp")) (sock (socket-connect protocol-family/internet socket-type/stream hostname (service-info:port srvc-info))) (connection (make-ftp-connection hostname sock passive? log))) (ftp-log connection (string-append "-- " (date->string (date)) ": opened ftp connection to " hostname)) (ftp-read-reply connection (exactly-code "220")) ; the initial welcome banner (ftp-login connection login password) connection))) ;; Send user information to the remote host. Args are login ;; and password. If they are not provided, the Netrc module is used to ;; try to determine a login and password for the server. (define (ftp-login connection login password) (let* ((netrc-record #f) (get-netrc-record (lambda () (cond (netrc-record) (else (set! netrc-record (netrc-machine-entry (ftp-connection-host-name connection) #t)) netrc-record))))) (let ((login (or login (netrc-entry-login (get-netrc-record))))) (let ((reply (ftp-send-command connection (build-command "USER" login) (lambda (code) (or (string=? code "331") ; "User name okay, need password." (string=? code "230")))))) ; "User logged in, proceed." (if (string-prefix? "331" reply) ; "User name okay, need password." (ftp-send-command connection (build-command "PASS" (or password (netrc-entry-password (get-netrc-record)))) (exactly-code "230"))))))) (define-enumerated-type ftp-type :ftp-type ftp-type? ftp-types ftp-type-name ftp-type-index (binary ascii)) (define (ftp-set-type! connection type) (let ((ttype (cond ((eq? type (ftp-type binary)) "I") ((eq? type (ftp-type ascii)) "A")))) (ftp-send-command connection (build-command "TYPE" ttype)) (values))) (define (ftp-rename connection oldname newname) (ftp-send-command connection (build-command "RNFR " oldname) (code-with-prefix "35")) (ftp-send-command connection (build-command "RNTO" newname) (code-with-prefix "25")) (values)) (define (ftp-delete connection file) (ftp-send-command connection (build-command "DELE" file) (code-with-prefix "25")) (values)) ;;: connection x string -> status (define (ftp-cd connection dir) (ftp-send-command connection (build-command "CWD" dir)) (values)) ;;: connection -> status (define (ftp-cdup connection) (ftp-send-command connection "CDUP" (exactly-code "250")) (values)) ;;: on success return the new directory as a string (define (ftp-pwd connection) (let ((reply (ftp-send-command connection "PWD" (exactly-code "257")))) (cond ((regexp-search (rx (seq bos (= 3 digit) #\space (* (~ #\")) #\" (submatch (* (~ #\"))) #\")) reply) => (lambda (match) (match:substring match 1)))))) (define (ftp-rmdir connection dir) (ftp-send-command connection (build-command "RMD " dir)) (values)) (define (ftp-mkdir connection dir) (ftp-send-command connection (build-command "MKD ~a" dir)) (values)) ;; On success return a Scsh date record. This message is not part of ;; rfc959 but seems to be supported by many ftp servers (it's useful ;; for mirroring) (define (ftp-modification-time connection file) (let* ((reply (ftp-send-command connection (build-command "MDTM" file))) (timestr (substring reply 4 (string-length reply)))) (let ((year (substring timestr 0 4)) (month (substring timestr 4 6)) (mday (substring timestr 6 8)) (hour (substring timestr 8 10)) (min (substring timestr 10 12)) (sec (substring timestr 12 14))) (make-date (string->number sec) (string->number min) (string->number hour) (string->number mday) (string->number month) (- (string->number year) 1900))))) ;; On success return the size of the file in bytes. ;;: connection x string -> integer (define (ftp-size connection file) (let* ((reply (ftp-send-command connection (build-command "SIZE" file)))) (string->number (substring reply 4 (string-length reply))))) ;; Abort the current data transfer. Maybe we should close the data ;; socket? (define (ftp-abort connection) (ftp-send-command connection "ABOR") (values)) (define (ftp-quit connection) (ftp-send-command connection "QUIT" (exactly-code "221")) (close-socket (ftp-connection-command-socket connection))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; The following commands require the use of a data connection as well ;; as the command connection. The command and the server's reply are ;; transmitted via the command connection, while the data is ;; transmitted via the data connection (you could have guessed that, ;; right?). ;; ;; The data socket is created by the client, who sends a PORT command ;; to the server to indicate on which port it is ready to accept a ;; connection. The port command specifies an IP number and a port ;; number, in the form of 4+2 comma-separated bytes. The server then ;; initiates the data transfer. A fresh data connection is created for ;; each data transfer (unlike the command connection which stays open ;; during the entire conversation with the server). ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (ftp-ls connection . maybe-dir) (with-data-connection connection (lambda () (ftp-send-command connection (apply build-command "NLST" maybe-dir) (code-with-prefix "1"))) (lambda (data-socket) (port->lines (socket:inport data-socket))))) (define (find-port-arg string) (cond ((regexp-search (rx (: (+ digit) (= 5 (: #\, (+ digit))))) string) => (lambda (match) (match:substring match 0))))) (define (ftp-dir connection . maybe-dir) (with-data-connection connection (lambda () (ftp-send-command connection (apply build-command "LIST" maybe-dir) (code-with-prefix "1"))) (lambda (data-socket) (port->lines (socket:inport data-socket))))) (define (port->lines port) (let loop ((reverse-lines '())) (let ((line (read-crlf-line port))) (if (eof-object? line) (reverse reverse-lines) (loop (cons line reverse-lines)))))) (define (ftp-get connection remote-file act) (with-data-connection connection (lambda () (ftp-send-command connection (build-command "RETR" remote-file) (exactly-code "150"))) (lambda (data-socket) (act (socket:inport data-socket))))) ;; FIXME: should have an optional argument :rename which defaults to ;; false, which would make us upload to a temporary name and rename at ;; the end of the upload. This atomicity is important for ftp or http ;; servers which are serving a load, and to avoid problems with "no ;; space on device". (define (ftp-put connection remote-file act) (with-data-connection connection (lambda () (ftp-send-command connection (build-command "STOR" remote-file) (exactly-code "150"))) (lambda (data-socket) (act (socket:outport data-socket))))) (define (ftp-append connection remote-file act) (with-data-connection connection (lambda () (ftp-send-command connection (build-command "APPE" remote-file) (exactly-code "150"))) (lambda (data-socket) (act (socket:outport data-socket))))) ;; send a command verbatim to the remote server and wait for a ;; reply. (define (ftp-quot connection cmd) (ftp-send-command connection cmd)) ;; ------------------------------------------------------------------------ ;; no exported procedures below (define (with-data-connection connection command-thunk proc) (if (ftp-connection-passive-mode? connection) (let* ((pasv-reply (ftp-send-command connection "PASV" (exactly-code "227"))) (port-arg (find-port-arg pasv-reply))) (call-with-values (lambda () (parse-port-arg port-arg)) (lambda (address port) (let ((data-socket (create-socket protocol-family/internet socket-type/stream))) (set-socket-option data-socket level/socket socket/reuse-address #t) (connect-socket data-socket (internet-address->socket-address address port)) (command-thunk) (let ((retval (proc data-socket))) (close-socket data-socket) (ftp-read-reply connection) retval))))) (let* ((sock (create-socket protocol-family/internet socket-type/stream)) (sockaddr (internet-address->socket-address internet-address/any 0))) ; 0 to accept any port (set-socket-option sock level/socket socket/reuse-address #t) (set-socket-option sock level/socket socket/linger 120) (bind-socket sock sockaddr) (listen-socket sock 0) (ftp-send-command connection ; send PORT command (ftp-build-PORT-string (socket-local-address sock))) (command-thunk) (receive (data-socket data-socket-address) (accept-connection sock) (let ((retval (proc data-socket))) (close-socket data-socket) (close-socket sock) (ftp-read-reply connection) retval))))) ;; TODO: Unix-specific commands ;; SITE UMASK 002 ;; SITE IDLE 60 ;; SITE CHMOD 755 filename ;; SITE HELP ;; We cache the login and password to be able to relogin automatically ;; if we lose the connection (a la ange-ftp). Not implemented. (define-record-type ftp-connection :ftp-connection (make-ftp-connection host-name command-socket passive-mode? logfd) ftp-connection? (host-name ftp-connection-host-name) (command-socket ftp-connection-command-socket) (passive-mode? ftp-connection-passive-mode?) (logfd ftp-connection-logfd)) (define-condition-type 'ftp-error '(error)) (define ftp-error? (condition-predicate 'ftp-error)) (define (ftp-build-PORT-string sockaddr) (let* ((hst-info (host-info (system-name))) (ip-address (car (host-info:addresses hst-info)))) (receive (hst-address srvc-port) (socket-address->internet-address sockaddr) (string-append "PORT " (format-internet-host-address ip-address ",") "," (format-port srvc-port))))) (define (ftp-send-command connection command . maybe-expected) (let* ((sock (ftp-connection-command-socket connection)) (out (socket:outport sock))) (write-string command out) (write-crlf out) (ftp-log connection (string-append "<- " command)) (apply ftp-read-reply connection maybe-expected))) (define any-code (lambda (code) #t)) (define (code-with-prefix prefix) (lambda (code) (string-prefix? prefix code))) (define (exactly-code the-code) (lambda (code) (string=? code the-code))) ;; This is where we check that the server's 3 digit status code ;; corresponds to what we expected. ;; EXPECTED? is a predicate on reply codes. If the server's reply ;; doesn't satisfy EXPECTED?, we raise an FTP-ERROR. (define (ftp-read-reply connection . maybe-expected) (let-optionals* maybe-expected ((expected? (code-with-prefix "2"))) (let* ((sock (ftp-connection-command-socket connection)) (in (socket:inport sock)) (reply (read-crlf-line in)) (code (substring reply 0 3))) (ftp-log connection (string-append "-> " reply)) (if (not (expected? code)) (signal 'ftp-error reply)) ;; handle multi-line replies (if (char=? (string-ref reply 3) #\-) (let ((end-prefix (string-append code " "))) (let loop () (let* ((line (read-crlf-line in)) (reply (string-join (list reply line "\n")))) (ftp-log connection (string-append "-> " line)) (if (string-prefix? end-prefix line) reply (loop))))) reply)))) (define (build-command str . opt-args) (string-join (cons str opt-args))) (define (ftp-log connection line) (cond ((ftp-connection-logfd connection) => (lambda (log) (write-string line log) (write-string "\n" log) (force-output log)))))