#lang racket/base
(require racket/file
         racket/contract/base
         racket/port
         "private/strip-prefix.rkt")

(provide
 (contract-out
  [untar (->* ((or/c path-string? input-port?))
              (#:dest 
               (or/c #f path-string?)
               #:strip-count exact-nonnegative-integer?
               #:filter (path? (or/c path? #f)
                               symbol? exact-integer? (or/c path? #f)
                               exact-nonnegative-integer? exact-nonnegative-integer?
                               . -> . any/c))
              void?)]))

(define-logger untar)

(define (untar in 
               #:dest [dest #f]
               #:strip-count [strip-count 0]
               #:filter [filter void])
  ((if (input-port? in)
       (lambda (in f) (f in))
       call-with-input-file*)
   in
   (lambda (in)
     (let loop ([delays null])
       (define bstr (peek-bytes 512 0 in))
       (if (for/and ([b (in-bytes bstr)]) (zero? b))
           (for ([delay (in-list (reverse delays))])
             (delay))
           (loop (untar-one-from-port in delays
                                      dest strip-count filter)))))))

(define (read-bytes* n in)
  (define s (read-bytes n in))
  (unless (and (bytes? s)
               (= n (bytes-length s)))
    (error 'untar "unexpected EOF"))
  s)

(define (untar-one-from-port in delays
                             dest strip-count filter)
  (define name-bytes (read-bytes* 100 in))
  (define mode (tar-bytes->number (read-bytes* 8 in) in))
  (define owner (tar-bytes->number (read-bytes* 8 in) in))
  (define group (tar-bytes->number (read-bytes* 8 in) in))
  (define size (tar-bytes->number (read-bytes* 12 in) in))
  (define mod-time (tar-bytes->number (read-bytes* 12 in) in))
  (define checksum-bytes (read-bytes* 8 in))
  (define type (case (integer->char (read-byte in))
                 [(#\0) 'file]
                 [(#\1) 'hard-link]
                 [(#\2) 'link]
                 [(#\3) 'character-special]
                 [(#\4) 'block-special]
                 [(#\5) 'dir]
                 [(#\6) 'fifo]
                 [(#\7) 'contiguous-file]
                 [(#\g) 'extended-header]
                 [(#\x) 'extended-header-for-next]
                 [else 'unknown]))
  (define link-target-bytes (read-bytes* 100 in))
  (define ustar? (bytes=? #"ustar\00000" (read-bytes* 8 in)))
  (define owner-bytes (read-bytes* 32 in))
  (define group-bytes (read-bytes* 32 in))
  (define device-major-bytes (read-bytes* 8 in))
  (define device-minor-bytes (read-bytes* 8 in))
  (define filename-prefix-bytes (read-bytes* 155 in))
  (define base-filename (bytes->path
                         (if ustar?
                             (bytes-append (nul-terminated filename-prefix-bytes)
                                           (nul-terminated name-bytes))
                             (nul-terminated name-bytes))))
  (when (absolute-path? base-filename)
    (error 'untar "won't extract a file with an absolute path: ~e" base-filename))
  (define stripped-filename (strip-prefix base-filename strip-count))
  (define filename (and stripped-filename
                        (if dest
                            (build-path dest stripped-filename)
                            stripped-filename)))
  (define link-target (and (eq? type 'link)
                           (bytes->path (nul-terminated link-target-bytes))))
  (read-bytes* 12 in) ; padding
  (define create?
    (filter base-filename filename type size link-target mod-time mode))
  (define total-len (* (ceiling (/ size 512)) 512))
  (cond
   [(and filename create?)
    (case type
      [(dir)
       (log-untar-info "directory: ~a" filename)
       (make-directory* filename)
       (cons
        ;; delay directory meta-data updates until after any contained
        ;; files are written
        (lambda ()
          (try-file-op
           (lambda ()
             (file-or-directory-permissions* filename mode #t)))
          (try-file-op
           (lambda ()
             (file-or-directory-modify-seconds* filename mod-time #t))))
        delays)]
      [(file)
       (log-untar-info "file: ~a" filename)
       (define-values (base name dir?) (split-path filename))
       (when (path? base) (make-directory* base))
       (call-with-output-file*
        filename
        #:exists 'truncate
        (lambda (out)
          (copy-bytes size in out)))
       (try-file-op
        (lambda ()
          (file-or-directory-permissions* filename mode #f)))
       (try-file-op
        (lambda ()
          (file-or-directory-modify-seconds* filename mod-time #f)))
       (copy-bytes (- total-len size) in #f)
       delays]
      [(link)
       (log-untar-info "link: ~a" filename)
       (define-values (base name dir?) (split-path filename))
       (when (path? base) (make-directory* base))
       (when (file-exists? filename) (delete-file filename))
       (make-file-or-directory-link link-target filename)
       delays]
      [else
       (log-untar-info "ignored ~a: ~a" type filename)
       (copy-bytes total-len in #f)
       delays])]
   [else
    (copy-bytes total-len in #f)
    delays]))

(define (copy-bytes amt in out)
  (let ([bstr (make-bytes (min amt 4096))])
    (let loop ([amt amt])
      (unless (zero? amt)
        (define size (min amt 4096))
        (unless (= (read-bytes! bstr in 0 size) size)
          (error 'untar "unexpected EOF"))
        (when out
          (write-bytes bstr out 0 size))
        (loop (- amt size))))))

(define (tar-bytes->number bstr in)
  (define len (bytes-length bstr))
  (cond
   [(bitwise-bit-set? (bytes-ref bstr 0) 7)
    ;; base-256:
    (for/fold ([v 0]) ([i (in-range 1 len)])
      (+ (* v 256) v))]
   [else
    ;; traditional:
    (define skip-tail
      (- len
         (for/or ([i (in-range len 0 -1)])
           (case (integer->char (bytes-ref bstr (sub1 i)))
             [(#\space #\nul) #f]
             [else i]))))
    (for/fold ([v 0]) ([i (in-range (- len skip-tail))])
      (define b (bytes-ref bstr i))
      (if (<= (char->integer #\0) b (char->integer #\7))
          (+ (* v 8) (- b (char->integer #\0)))
          (error 'untar "bad number ~e at ~a" bstr (file-position in))))]))

(define (nul-terminated bstr)
  (subbytes bstr
            0
            (or (for/or ([i (in-range (bytes-length bstr))])
                  (and (zero? (bytes-ref bstr i))
                       i))
                (bytes-length bstr))))

(define (try-file-op thunk)
  (with-handlers ([exn:fail:filesystem?
                   (lambda (exn)
                     (log-untar-error (exn-message exn))
                     (void))])
    (thunk)))

(define (file-or-directory-modify-seconds* filename mod-time dir?)
  (unless (and dir? (eq? (system-type) 'windows))
    (file-or-directory-modify-seconds filename mod-time)))

(define (file-or-directory-permissions* file perms dir?)
  (file-or-directory-permissions file
                                 (case (system-type)
                                   [(windows)
                                    ;; corce perms to be the same for user, group, and others
                                    (define user-perms (bitwise-and #o700))
                                    (bitwise-ior user-perms
                                                 (arithmetic-shift user-perms -3)
                                                 (arithmetic-shift user-perms -6))]
                                   [else perms])))

