aboutsummaryrefslogtreecommitdiff
;;; GNU Guix --- Functional package management for GNU
;;; Copyright © 2013-2022 Ludovic Courtès <ludo@gnu.org>
;;; Copyright © 2020 Google LLC
;;; Copyright © 2020 Jakub Kądziołka <kuba@kadziolka.net>
;;; Copyright © 2020, 2021 Maxim Cournoyer <maxim.cournoyer@gmail.com>
;;; Copyright © 2021 Tobias Geerinckx-Rice <me@tobias.gr>
;;; Copyright © 2022 Oleg Pykhalov <go.wigust@gmail.com>
;;;
;;; This file is part of GNU Guix.
;;;
;;; GNU Guix is free software; you can redistribute it and/or modify it
;;; under the terms of the GNU General Public License as published by
;;; the Free Software Foundation; either version 3 of the License, or (at
;;; your option) any later version.
;;;
;;; GNU Guix is distributed in the hope that it will be useful, but
;;; WITHOUT ANY WARRANTY; without even the implied warranty of
;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;;; GNU General Public License for more details.
;;;
;;; You should have received a copy of the GNU General Public License
;;; along with GNU Guix.  If not, see <http://www.gnu.org/licenses/>.

(define-module (gnu system file-systems)
  #:use-module (ice-9 match)
  #:use-module (rnrs bytevectors)
  #:use-module (srfi srfi-1)
  #:use-module (srfi srfi-2)
  #:use-module (srfi srfi-9)
  #:use-module (srfi srfi-26)
  #:use-module (srfi srfi-35)
  #:use-module (srfi srfi-9 gnu)
  #:use-module (guix records)
  #:use-module ((guix diagnostics)
                #:select (source-properties->location leave &fix-hint))
  #:use-module (guix i18n)
  #:use-module (gnu system uuid)
  #:re-export (uuid                               ;backward compatibility
               string->uuid
               uuid->string)
  #:export (file-system
            file-system?
            file-system-device
            file-system-device->string
            file-system-mount-point
            file-system-type
            file-system-needed-for-boot?
            file-system-flags
            file-system-options
            file-system-options->alist
            alist->file-system-options

            file-system-mount?
            file-system-mount-may-fail?
            file-system-check?
            file-system-skip-check-if-clean?
            file-system-repair
            file-system-create-mount-point?
            file-system-dependencies
            file-system-shepherd-requirements
            file-system-location

            file-system-type-predicate
            file-system-mount-point-predicate
            btrfs-subvolume?
            btrfs-store-subvolume-file-name

            file-system-label
            file-system-label?
            file-system-label->string

            file-system->spec
            spec->file-system
            specification->file-system-mapping

            %pseudo-file-system-types
            %fuse-control-file-system
            %binary-format-file-system
            %debug-file-system
            %efivars-file-system
            %shared-memory-file-system
            %pseudo-terminal-file-system
            %tty-gid
            %immutable-store
            %control-groups
            %elogind-file-systems

            %base-file-systems
            %base-live-file-systems
            %container-file-systems

            <file-system-mapping>
            file-system-mapping
            file-system-mapping?
            file-system-mapping-source
            file-system-mapping-target
            file-system-mapping-writable?

            file-system-mapping->bind-mount

            %store-mapping
            %network-configuration-files
            %network-file-mappings

            swap-space
            swap-space?
            swap-space-target
            swap-space-dependencies
            swap-space-priority
            swap-space-discard?))

;;; Commentary:
;;;
;;; Declaring file systems to be mounted.
;;;
;;; Note: this file system is used both in the Shepherd and on the "host
;;; side", so it must not include (gnu packages …) modules.
;;;
;;; Code:

(eval-when (expand load eval)
  (define invalid-file-system-flags
    ;; Note: Keep in sync with 'mount-flags->bit-mask'.
    (let ((known-flags '(read-only
                         bind-mount no-suid no-dev no-exec
                         no-atime no-diratime strict-atime lazy-time
                         shared)))
      (lambda (flags)
        "Return the subset of FLAGS that is invalid."
        (remove (cut memq <> known-flags) flags))))

  (define (%validate-file-system-flags flags location)
    "Raise an error if FLAGS contains invalid mount flags; otherwise return
FLAGS."
    (match (invalid-file-system-flags flags)
      (() flags)
      (invalid
       (leave (source-properties->location location)
              (N_ "invalid file system mount flag:~{ ~s~}~%"
                  "invalid file system mount flags:~{ ~s~}~%"
                  (length invalid))
              invalid)))))

(define-syntax validate-file-system-flags
  (lambda (s)
    "Validate the given file system mount flags, raising an error if invalid
flags are found."
    (syntax-case s (quote)
      ((_ (quote (symbols ...)))                  ;validate at expansion time
       (begin
         (%validate-file-system-flags (syntax->datum #'(symbols ...))
                                      (syntax-source s))
         #'(quote (symbols ...))))
      ((_ flags)
       #`(%validate-file-system-flags flags
                                      '#,(datum->syntax s (syntax-source s))))
      (id
       (identifier? #'id)
       #'%validate-file-system-flags))))

;; File system declaration.
(define-record-type* <file-system> file-system
  make-file-system
  file-system?
  (device                file-system-device)               ; string | <uuid> | <file-system-label>
  (mount-point           file-system-mount-point)          ; string
  (type                  file-system-type)                 ; string
  (flags                 file-system-flags                 ; list of symbols
                         (default '())
                         (sanitize validate-file-system-flags))
  (options               file-system-options               ; string or #f
                         (default #f))
  (mount?                file-system-mount?                ; Boolean
                         (default #t))
  (mount-may-fail?       file-system-mount-may-fail?       ; Boolean
                         (default #f))
  (needed-for-boot?      %file-system-needed-for-boot?     ; Boolean
                         (default #f))
  (check?                file-system-check?                ; Boolean
                         (default #t))
  (skip-check-if-clean?  file-system-skip-check-if-clean?  ; Boolean
                         (default #t))
  (repair                file-system-repair                ; symbol or #f
                         (default 'preen))
  (create-mount-point?   file-system-create-mount-point?   ; Boolean
                         (default #f))
  (dependencies          file-system-dependencies          ; list of <file-system>
                         (default '()))                    ; or <mapped-device>
  (shepherd-requirements file-system-shepherd-requirements ; list of symbols
                         (default '()))
  (location              file-system-location
                         (default (current-source-location))
                         (innate)))

;; A file system label for use in the 'device' field.
(define-record-type <file-system-label>
  (file-system-label label)
  file-system-label?
  (label file-system-label->string))

(set-record-type-printer! <file-system-label>
                          (lambda (obj port)
                            (format port "#<file-system-label ~s>"
                                    (file-system-label->string obj))))

;; Note: This module is used both on the build side and on the host side.
;; Arrange not to pull (guix store) and (guix config) because the latter
;; differs from user to user.
(define (%store-prefix)
  "Return the store prefix."
  ;; Note: If we have (guix store database) in the search path and we do *not*
  ;; have (guix store) proper, 'resolve-module' returns an empty (guix store)
  ;; with one sub-module.
  (cond ((and=> (parameterize ((current-warning-port (%make-void-port "w0")))
                  (resolve-module '(guix store) #:ensure #f))
                (lambda (store)
                  (module-variable store '%store-prefix)))
         =>
         (lambda (variable)
           ((variable-ref variable))))
        ((getenv "NIX_STORE")
         => identity)
        (else
         "/gnu/store")))

(define %not-slash
  (char-set-complement (char-set #\/)))

(define (file-prefix? file1 file2)
  "Return #t if FILE1 denotes the name of a file that is a parent of FILE2.
FILE1 and FILE2 must both be either absolute or relative file names, else #f
is returned.

For example:

  (file-prefix? \"/gnu\" \"/gnu/store\")
  => #t

  (file-prefix? \"/gn\" \"/gnu/store\")
  => #f
"
  (define (absolute? file)
    (string-prefix? "/" file))

  (if (or (every absolute? (list file1 file2))
          (every (negate absolute?) (list file1 file2)))
      (let loop ((file1 (string-tokenize file1 %not-slash))
                 (file2 (string-tokenize file2 %not-slash)))
        (match file1
          (()
           #t)
          ((head1 tail1 ...)
           (match file2
             ((head2 tail2 ...)
              (and (string=? head1 head2) (loop tail1 tail2)))
             (()
              #f)))))
      ;; FILE1 and FILE2 are a mix of absolute and relative file names.
      #f))

(define (file-name-depth file-name)
  (length (string-tokenize file-name %not-slash)))

(define* (file-system-device->string device #:key uuid-type)
  "Return the string representations of the DEVICE field of a <file-system>
record.  When the device is a UUID, its representation is chosen depending on
UUID-TYPE, a symbol such as 'dce or 'iso9660."
  (match device
    ((? file-system-label?)
     (file-system-label->string device))
    ((? uuid?)
     (if uuid-type
         (uuid->string (uuid-bytevector device) uuid-type)
         (uuid->string device)))
    ((? string?)
     device)))

(define (file-system-options->alist string)
  "Translate the option string format of a <file-system> record into an
association list of options or option/value pairs."
  (if string
      (let ((options (string-split string #\,)))
        (map (lambda (param)
               (let ((=index (string-index param #\=)))
                 (if =index
                     (cons (string-take param =index)
                           (string-drop param (1+ =index)))
                     param)))
             options))
      '()))

(define (alist->file-system-options options)
  "Return the string representation of OPTIONS, an association list.  The
string obtained can be used as the option field of a <file-system> record."
  (if (null? options)
      #f
      (string-join (map (match-lambda
                          ((key . value)
                           (string-append key "=" value))
                          (key
                           key))
                        options)
                   ",")))

(define (file-system-needed-for-boot? fs)
  "Return true if FS has the 'needed-for-boot?' flag set, or if it holds the
store--e.g., if FS is the root file system."
  (or (%file-system-needed-for-boot? fs)
      (and (file-prefix? (file-system-mount-point fs) (%store-prefix))
           (not (memq 'bind-mount (file-system-flags fs))))))

(define (file-system->spec fs)
  "Return a list corresponding to file-system FS that can be passed to the
initrd code."
  (match fs
    (($ <file-system> device mount-point type flags options mount?
                      mount-may-fail? needed-for-boot?
                      check? skip-check-if-clean? repair)
     ;; Note: Add new fields towards the end for compatibility.
     (list (cond ((uuid? device)
                  `(uuid ,(uuid-type device) ,(uuid-bytevector device)))
                 ((file-system-label? device)
                  `(file-system-label ,(file-system-label->string device)))
                 (else device))
           mount-point type flags options mount-may-fail?
           check? skip-check-if-clean? repair))))

(define (spec->file-system sexp)
  "Deserialize SEXP, a list, to the corresponding <file-system> object."
  (match sexp
    ((device mount-point type flags options mount-may-fail?
             check? skip-check-if-clean? repair
             _ ...)                               ;placeholder for new fields
     (file-system
       (device (match device
                 (('uuid (? symbol? type) (? bytevector? bv))
                  (bytevector->uuid bv type))
                 (('file-system-label (? string? label))
                  (file-system-label label))
                 (_
                  device)))
       (mount-point mount-point) (type type)
       (flags flags) (options options)
       (mount-may-fail? mount-may-fail?)
       (check? check?)
       (skip-check-if-clean? skip-check-if-clean?)
       (repair repair)))))

(define (specification->file-system-mapping spec writable?)
  "Read the SPEC and return the corresponding <file-system-mapping>.  SPEC is
a string of the form \"SOURCE\" or \"SOURCE=TARGET\".  The former specifies
that SOURCE from the host should be mounted at SOURCE in the other system.
The latter format specifies that SOURCE from the host should be mounted at
TARGET in the other system."
  (let ((index (string-index spec #\=)))
    (if index
        (file-system-mapping
         (source (substring spec 0 index))
         (target (substring spec (+ 1 index)))
         (writable? writable?))
        (file-system-mapping
         (source spec)
         (target spec)
         (writable? writable?)))))


;;;
;;; Common file systems.
;;;

(define %pseudo-file-system-types
  ;; List of know pseudo file system types.  This is used when validating file
  ;; system definitions.
  '("binfmt_misc" "cgroup" "cgroup2" "debugfs" "devpts" "devtmpfs" "efivarfs" "fusectl"
    "hugetlbfs" "overlay" "proc" "securityfs" "sysfs" "tmpfs" "tracefs" "virtiofs" "xenfs"))

(define %fuse-control-file-system
  ;; Control file system for Linux' file systems in user-space (FUSE).
  (file-system
    (device "fusectl")
    (mount-point "/sys/fs/fuse/connections")
    (type "fusectl")
    (check? #f)))

(define %binary-format-file-system
  ;; Support for arbitrary executable binary format.
  (file-system
    (device "binfmt_misc")
    (mount-point "/proc/sys/fs/binfmt_misc")
    (type "binfmt_misc")
    (check? #f)))

(define %debug-file-system
  (file-system
    (type "debugfs")
    (device "none")
    (mount-point "/sys/kernel/debug")
    (check? #f)
    (create-mount-point? #t)))

(define %efivars-file-system
  ;; Support for EFI variables file system.
  (file-system
    (device "efivarfs")
    (mount-point "/sys/firmware/efi/efivars")
    (type "efivarfs")
    (mount-may-fail? #t)
    (needed-for-boot? #f)
    (check? #f)))

(define %tty-gid
  ;; ID of the 'tty' group.  Allocate it statically to make it easy to refer
  ;; to it from here and from the 'tty' group definitions.
  996)

(define %pseudo-terminal-file-system
  ;; The pseudo-terminal file system.  It needs to be mounted so that
  ;; statfs(2) returns DEVPTS_SUPER_MAGIC like libc's getpt(3) expects (and
  ;; thus openpty(3) and its users, such as xterm.)
  (file-system
    (device "none")
    (mount-point "/dev/pts")
    (type "devpts")
    (check? #f)
    (needed-for-boot? #f)
    (create-mount-point? #t)
    (options (string-append "gid=" (number->string %tty-gid) ",mode=620"))))

(define %shared-memory-file-system
  ;; Shared memory.
  (file-system
    (device "tmpfs")
    (mount-point "/dev/shm")
    (type "tmpfs")
    (check? #f)
    (flags '(no-suid no-dev))
    (options "size=50%")                         ;TODO: make size configurable
    (create-mount-point? #t)))

(define %immutable-store
  ;; Read-only store to avoid users or daemons accidentally modifying it.
  ;; 'guix-daemon' has provisions to remount it read-write in its own name
  ;; space.
  (file-system
    (device (%store-prefix))
    (mount-point (%store-prefix))
    (type "none")
    (check? #f)
    (flags '(read-only bind-mount no-atime))))

(define %control-groups
  ;; The cgroup2 file system.
  (list (file-system
          (device "none")
	  (mount-point "/sys/fs/cgroup")
	  (type "cgroup2")
	  (check? #f)
	  (create-mount-point? #f))))

(define %elogind-file-systems
  ;; We don't use systemd, but these file systems are needed for elogind,
  ;; which was extracted from systemd.
  (append
   (list (file-system
           (device "none")
           (mount-point "/run/systemd")
           (type "tmpfs")
           (check? #f)
           (flags '(no-suid no-dev no-exec))
           (options "mode=0755")
           (create-mount-point? #t))
         (file-system
           (device "none")
           (mount-point "/run/user")
           (type "tmpfs")
           (check? #f)
           (flags '(no-suid no-dev no-exec))
           (options "mode=0755")
           (create-mount-point? #t))
         ;; Elogind uses cgroups to organize processes, allowing it to map PIDs
         ;; to sessions.  Elogind's cgroup hierarchy isn't associated with any
         ;; resource controller ("subsystem").
         (file-system
           (device "cgroup")
           (mount-point "/sys/fs/cgroup/elogind")
           (type "cgroup")
           (check? #f)
           (options "none,name=elogind")
           (create-mount-point? #t)
           (dependencies (list (car %control-groups)))))
   %control-groups))

(define %base-file-systems
  ;; List of basic file systems to be mounted.  Note that /proc and /sys are
  ;; currently mounted by the initrd.
  (list %pseudo-terminal-file-system
        %debug-file-system
        %shared-memory-file-system
        %efivars-file-system
        %immutable-store))

(define %base-live-file-systems
  ;; This is the bare minimum to use live file-systems.
  ;; Used in installation-os.
  (list (file-system
          (mount-point "/")
          (device (file-system-label "Guix_image"))
          (type "ext4"))

        ;; Make /tmp a tmpfs instead of keeping the overlayfs.  This
        ;; originally was used for unionfs because FUSE creates
        ;; '.fuse_hiddenXYZ' files for each open file, and this confuses
        ;; Guix's test suite, for instance (see
        ;; <http://bugs.gnu.org/23056>).  We keep this for overlayfs to be
        ;; on the safe side.
        (file-system
          (mount-point "/tmp")
          (device "none")
          (type "tmpfs")
          (check? #f))))

;; File systems for Linux containers differ from %base-file-systems in that
;; they impose additional restrictions such as no-exec or need different
;; options to function properly.
;;
;; The file system flags and options conform to the libcontainer
;; specification:
;; https://github.com/docker/libcontainer/blob/master/SPEC.md#filesystem
(define %container-file-systems
  (list
   ;; Pseudo-terminal file system.
   (file-system
     (device "none")
     (mount-point "/dev/pts")
     (type "devpts")
     (flags '(no-exec no-suid))
     (needed-for-boot? #t)
     (create-mount-point? #t)
     (check? #f)
     (options "newinstance,ptmxmode=0666,mode=620"))
   ;; Shared memory file system.
   (file-system
     (device "tmpfs")
     (mount-point "/dev/shm")
     (type "tmpfs")
     (flags '(no-exec no-suid no-dev))
     (options "mode=1777,size=65536k")
     (needed-for-boot? #t)
     (create-mount-point? #t)
     (check? #f))
   ;; Message queue file system.
   (file-system
     (device "mqueue")
     (mount-point "/dev/mqueue")
     (type "mqueue")
     (flags '(no-exec no-suid no-dev))
     (needed-for-boot? #t)
     (create-mount-point? #t)
     (check? #f))))


;;;
;;; Shared file systems, for VMs/containers.
;;;

;; Mapping of host file system SOURCE to mount point TARGET in the guest.
(define-record-type* <file-system-mapping> file-system-mapping
  make-file-system-mapping
  file-system-mapping?
  (source    file-system-mapping-source)          ;string
  (target    file-system-mapping-target)          ;string
  (writable? file-system-mapping-writable?        ;Boolean
             (default #f)))

(define (file-system-mapping->bind-mount mapping)
  "Return a file system that realizes MAPPING, a <file-system-mapping>, using
a bind mount."
  (match mapping
    (($ <file-system-mapping> source target writable?)
     (file-system
       (mount-point target)
       (device source)
       (type "none")
       (flags (if writable?
                  '(bind-mount)
                  '(bind-mount read-only)))
       (check? #f)
       (create-mount-point? #t)))))

(define %store-mapping
  ;; Mapping of the host's store into the guest.
  (file-system-mapping
   (source (%store-prefix))
   (target (%store-prefix))
   (writable? #f)))

(define %network-configuration-files
  ;; List of essential network configuration files.
  '("/etc/resolv.conf"
    "/etc/nsswitch.conf"
    "/etc/services"
    "/etc/hosts"))

(define %network-file-mappings
  ;; List of file mappings for essential network files.
  (filter-map (lambda (file)
                (file-system-mapping
                 (source file)
                 (target file)
                 ;; XXX: On some GNU/Linux systems, /etc/resolv.conf is a
                 ;; symlink to a file in a tmpfs which, for an unknown reason,
                 ;; cannot be bind mounted read-only within the container.
                 (writable? (string=? file "/etc/resolv.conf"))))
              %network-configuration-files))

(define (file-system-type-predicate type)
  "Return a predicate that, when passed a file system, returns #t if that file
system has the given TYPE."
  (lambda (fs)
    (string=? (file-system-type fs) type)))

(define (file-system-mount-point-predicate mount-point)
  "Return a predicate that, when passed a file system, returns #t if that file
system has the given MOUNT-POINT."
  (lambda (fs)
    (string=? (file-system-mount-point fs) mount-point)))


;;;
;;; Btrfs specific helpers.
;;;

(define (btrfs-subvolume? fs)
  "Predicate to check if FS, a file-system object, is a Btrfs subvolume."
  (and-let* ((btrfs-file-system? (string= "btrfs" (file-system-type fs)))
             (option-keys (map (match-lambda
                                 ((key . value) key)
                                 (key key))
                               (file-system-options->alist
                                (file-system-options fs)))))
    (find (cut string-prefix? "subvol" <>) option-keys)))

(define (btrfs-store-subvolume-file-name file-systems)
  "Return the subvolume file name within the Btrfs top level onto which the
store is located, else #f."

  (define (prepend-slash/maybe s)
    (if (string=? "/" (string-take s 1))
        s
        (string-append "/" s)))

  (and-let* ((btrfs-subvolume-fs (filter btrfs-subvolume? file-systems))
             (btrfs-subvolume-fs*
              (sort btrfs-subvolume-fs
                    (lambda (fs1 fs2)
                      (> (file-name-depth (file-system-mount-point fs1))
                         (file-name-depth (file-system-mount-point fs2))))))
             (store-subvolume-fs
              (find (lambda (fs) (file-prefix? (file-system-mount-point fs)
                                               (%store-prefix)))
                    btrfs-subvolume-fs*))
             (options (file-system-options->alist
                       (file-system-options store-subvolume-fs))))
    ;; XXX: Deriving the subvolume name based from a subvolume ID is not
    ;; supported, as we'd need to query the actual file system.
    (or (and=> (assoc-ref options "subvol") prepend-slash/maybe)
        (raise (condition
                (&message
                 (message "The store is on a Btrfs subvolume, but the \
subvolume name is unknown."))
                (&fix-hint
                 (hint
                  (G_ "Use the @code{subvol} Btrfs file system option."))))))))


;;;
;;; Swap space
;;;

(define-record-type* <swap-space> swap-space make-swap-space
  swap-space?
  this-swap-space
  (target swap-space-target)
  (dependencies swap-space-dependencies
                (default '()))
  (priority swap-space-priority
            (default #f))
  (discard? swap-space-discard?
           (default #f)))

;;; file-systems.scm ends here
2'>772 773 774 775 776 777 778 779 780 781 782 783 784 785
;;; GNU Guix --- Functional package management for GNU
;;; Copyright © 2015 David Thompson <davet@gnu.org>
;;; Copyright © 2020 by Amar M. Singh <nly@disroot.org>
;;; Copyright © 2016-2022 Ludovic Courtès <ludo@gnu.org>
;;;
;;; This file is part of GNU Guix.
;;;
;;; GNU Guix is free software; you can redistribute it and/or modify it
;;; under the terms of the GNU General Public License as published by
;;; the Free Software Foundation; either version 3 of the License, or (at
;;; your option) any later version.
;;;
;;; GNU Guix is distributed in the hope that it will be useful, but
;;; WITHOUT ANY WARRANTY; without even the implied warranty of
;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;;; GNU General Public License for more details.
;;;
;;; You should have received a copy of the GNU General Public License
;;; along with GNU Guix.  If not, see <http://www.gnu.org/licenses/>.

;; Avoid interference.
(unsetenv "http_proxy")

(define-module (test-publish)
  #:use-module (guix scripts publish)
  #:use-module (guix tests)
  #:use-module (guix config)
  #:use-module (guix utils)
  #:use-module (gcrypt hash)
  #:use-module (guix store)
  #:use-module (guix derivations)
  #:use-module (guix gexp)
  #:use-module (guix base32)
  #:use-module (guix base64)
  #:use-module ((guix records) #:select (recutils->alist))
  #:use-module ((guix serialization) #:select (restore-file))
  #:use-module (gcrypt pk-crypto)
  #:use-module ((guix pki) #:select (%public-key-file %private-key-file))
  #:use-module (zlib)
  #:use-module (lzlib)
  #:autoload   (zstd) (call-with-zstd-input-port)
  #:use-module (web uri)
  #:use-module (web client)
  #:use-module (web request)
  #:use-module (web response)
  #:use-module ((guix http-client) #:select (http-multiple-get))
  #:use-module (rnrs bytevectors)
  #:use-module (ice-9 binary-ports)
  #:use-module (srfi srfi-1)
  #:use-module (srfi srfi-26)
  #:use-module (srfi srfi-64)
  #:use-module (srfi srfi-71)
  #:use-module (ice-9 threads)
  #:use-module (ice-9 format)
  #:use-module (ice-9 match)
  #:use-module (ice-9 rdelim))

(define %store
  (open-connection-for-tests))

(define (zstd-supported?)
  (resolve-module '(zstd) #t #f #:ensure #f))

(define %reference (add-text-to-store %store "ref" "foo"))

(define %item (add-text-to-store %store "item" "bar" (list %reference)))

(define (http-get-body uri)
  (call-with-values (lambda () (http-get uri))
    (lambda (response body) body)))

(define (http-get-port uri)
  (let ((socket (open-socket-for-uri uri)))
    ;; Make sure to use an unbuffered port so that we can then peek at the
    ;; underlying file descriptor via 'call-with-gzip-input-port'.
    (setvbuf socket 'none)
    (call-with-values
        (lambda ()
          (http-get uri #:port socket #:streaming? #t))
      (lambda (response port)
        ;; Don't (setvbuf port 'none) because of <http://bugs.gnu.org/19610>
        ;; (PORT might be a custom binary input port).
        port))))

(define (publish-uri route)
  (string-append "http://localhost:6789" route))

(define-syntax-rule (with-separate-output-ports exp ...)
  ;; Since ports aren't thread-safe in Guile 2.0, duplicate the output and
  ;; error ports to make sure the two threads don't end up stepping on each
  ;; other's toes.
  (with-output-to-port (duplicate-port (current-output-port) "w")
    (lambda ()
      (with-error-to-port (duplicate-port (current-error-port) "w")
        (lambda ()
          exp ...)))))

;; Run a local publishing server in a separate thread.
(with-separate-output-ports
 (call-with-new-thread
  (lambda ()
    (guix-publish "--port=6789" "-C0"))))     ;attempt to avoid port collision

(define (wait-until-ready port)
  ;; Wait until the server is accepting connections.
  (let ((conn (socket PF_INET SOCK_STREAM 0)))
    (let loop ()
      (unless (false-if-exception
               (connect conn AF_INET (inet-pton AF_INET "127.0.0.1") port))
        (loop)))))

(define (wait-for-file file)
  ;; Wait until FILE shows up.
  (let loop ((i 20))
    (cond ((file-exists? file)
           #t)
          ((zero? i)
           (error "file didn't show up" file))
          (else
           (pk 'wait-for-file file)
           (sleep 1)
           (loop (- i 1))))))

(define %gzip-magic-bytes
  ;; Magic bytes of gzip file.
  #vu8(#x1f #x8b))

;; Wait until the two servers are ready.
(wait-until-ready 6789)

;; Initialize the public/private key SRFI-39 parameters.
(%public-key (read-file-sexp %public-key-file))
(%private-key (read-file-sexp %private-key-file))


(test-begin "publish")

(test-equal "/nix-cache-info"
  (format #f "StoreDir: ~a\nWantMassQuery: 0\nPriority: 100\n"
          %store-directory)
  (http-get-body (publish-uri "/nix-cache-info")))

(test-equal "/*.narinfo"
  (let* ((info (query-path-info %store %item))
         (unsigned-info
          (format #f
                  "StorePath: ~a
NarHash: sha256:~a
NarSize: ~d
References: ~a~%"
                  %item
                  (bytevector->nix-base32-string
                   (path-info-hash info))
                  (path-info-nar-size info)
                  (basename (first (path-info-references info)))))
         (signature (base64-encode
                     (string->utf8
                      (canonical-sexp->string
                       (signed-string unsigned-info))))))
    (format #f "~aSignature: 1;~a;~a
URL: nar/~a
Compression: none
FileSize: ~a\n"
            unsigned-info (gethostname) signature
            (basename %item)
            (path-info-nar-size info)))
  (utf8->string
   (http-get-body
    (publish-uri
     (string-append "/" (store-path-hash-part %item) ".narinfo")))))

(test-equal "/*.narinfo pipeline"
  (make-list 500 200)
  ;; Make sure clients can pipeline requests and correct responses, in the
  ;; right order.  See <https://issues.guix.gnu.org/54723>.
  (let* ((uri (string->uri (publish-uri
                            (string-append "/"
                                           (store-path-hash-part %item)
                                           ".narinfo"))))
         (_ expected (http-get uri #:streaming? #f #:decode-body? #f)))
    (http-multiple-get (string->uri (publish-uri ""))
                       (lambda (request response port result)
                         (and (bytevector=? expected
                                            (get-bytevector-n port
                                                              (response-content-length
                                                               response)))
                              (cons (response-code response) result)))
                       '()
                       (make-list 500 (build-request uri))
                       #:batch-size 77)))

(test-equal "/*.narinfo with properly encoded '+' sign"
  ;; See <http://bugs.gnu.org/21888>.
  (let* ((item (add-text-to-store %store "fake-gtk+" "Congrats!"))
         (info (query-path-info %store item))
         (unsigned-info
          (format #f
                  "StorePath: ~a
NarHash: sha256:~a
NarSize: ~d
References: ~%"
                  item
                  (bytevector->nix-base32-string
                   (path-info-hash info))
                  (path-info-nar-size info)))
         (signature (base64-encode
                     (string->utf8
                      (canonical-sexp->string
                       (signed-string unsigned-info))))))
    (format #f "~aSignature: 1;~a;~a
URL: nar/~a
Compression: none
FileSize: ~a~%"
            unsigned-info (gethostname) signature
            (uri-encode (basename item))
            (path-info-nar-size info)))

  (let ((item (add-text-to-store %store "fake-gtk+" "Congrats!")))
    (utf8->string
     (http-get-body
      (publish-uri
       (string-append "/" (store-path-hash-part item) ".narinfo"))))))

(test-equal "/nar/*"
  "bar"
  (call-with-temporary-output-file
   (lambda (temp port)
     (let ((nar (utf8->string
                 (http-get-body
                  (publish-uri
                   (string-append "/nar/" (basename %item)))))))
       (call-with-input-string nar (cut restore-file <> temp)))
     (call-with-input-file temp read-string))))

(test-equal "/nar/gzip/*"
  "bar"
  (call-with-temporary-output-file
   (lambda (temp port)
     (let ((nar (http-get-port
                 (publish-uri
                  (string-append "/nar/gzip/" (basename %item))))))
       (call-with-gzip-input-port nar
         (cut restore-file <> temp)))
     (call-with-input-file temp read-string))))

(test-equal "/nar/gzip/* is really gzip"
  %gzip-magic-bytes
  ;; Since 'gzdopen' (aka. 'call-with-gzip-input-port') transparently reads
  ;; uncompressed gzip, the test above doesn't check whether it's actually
  ;; gzip.  This is what this test does.  See <https://bugs.gnu.org/30184>.
  (let ((nar (http-get-port
              (publish-uri
               (string-append "/nar/gzip/" (basename %item))))))
    (get-bytevector-n nar (bytevector-length %gzip-magic-bytes))))

(test-equal "/nar/lzip/*"
  "bar"
  (call-with-temporary-output-file
   (lambda (temp port)
     (let ((nar (http-get-port
                 (publish-uri
                  (string-append "/nar/lzip/" (basename %item))))))
       (call-with-lzip-input-port nar
         (cut restore-file <> temp)))
     (call-with-input-file temp read-string))))

(unless (zstd-supported?) (test-skip 1))
(test-equal "/nar/zstd/*"
  "bar"
  (call-with-temporary-output-file
   (lambda (temp port)
     (let ((nar (http-get-port
                 (publish-uri
                  (string-append "/nar/zstd/" (basename %item))))))
       (call-with-zstd-input-port nar
         (cut restore-file <> temp)))
     (call-with-input-file temp read-string))))

(test-equal "/*.narinfo with compression"
  `(("StorePath" . ,%item)
    ("URL" . ,(string-append "nar/gzip/" (basename %item)))
    ("Compression" . "gzip"))
  (let ((thread (with-separate-output-ports
                 (call-with-new-thread
                  (lambda ()
                    (guix-publish "--port=6799" "-C5"))))))
    (wait-until-ready 6799)
    (let* ((url  (string-append "http://localhost:6799/"
                                (store-path-hash-part %item) ".narinfo"))
           (body (http-get-port url)))
      (filter (lambda (item)
                (match item
                  (("Compression" . _) #t)
                  (("StorePath" . _)  #t)
                  (("URL" . _) #t)
                  (_ #f)))
              (recutils->alist body)))))

(test-equal "/*.narinfo with lzip compression"
  `(("StorePath" . ,%item)
    ("URL" . ,(string-append "nar/lzip/" (basename %item)))
    ("Compression" . "lzip"))
  (let ((thread (with-separate-output-ports
                 (call-with-new-thread
                  (lambda ()
                    (guix-publish "--port=6790" "-Clzip"))))))
    (wait-until-ready 6790)
    (let* ((url  (string-append "http://localhost:6790/"
                                (store-path-hash-part %item) ".narinfo"))
           (body (http-get-port url)))
      (filter (lambda (item)
                (match item
                  (("Compression" . _) #t)
                  (("StorePath" . _)  #t)
                  (("URL" . _) #t)
                  (_ #f)))
              (recutils->alist body)))))

(test-equal "/*.narinfo for a compressed file"
  '("none" "nar")          ;compression-less nar
  ;; Assume 'guix publish -C' is already running on port 6799.
  (let* ((item (add-text-to-store %store "fake.tar.gz"
                                  "This is a fake compressed file."))
         (url  (string-append "http://localhost:6799/"
                              (store-path-hash-part item) ".narinfo"))
         (body (http-get-port url))
         (info (recutils->alist body)))
    (list (assoc-ref info "Compression")
          (dirname (assoc-ref info "URL")))))

(test-equal "/*.narinfo with lzip + gzip"
  `((("StorePath" . ,%item)
     ("URL" . ,(string-append "nar/gzip/" (basename %item)))
     ("Compression" . "gzip")
     ("URL" . ,(string-append "nar/lzip/" (basename %item)))
     ("Compression" . "lzip"))
    200
    200)
  (call-with-temporary-directory
   (lambda (cache)
     (let ((thread (with-separate-output-ports
                    (call-with-new-thread
                     (lambda ()
                       (guix-publish "--port=6793" "-Cgzip:2" "-Clzip:2"))))))
       (wait-until-ready 6793)
       (let* ((base "http://localhost:6793/")
              (part (store-path-hash-part %item))
              (url  (string-append base part ".narinfo"))
              (body (http-get-port url)))
         (list (filter (match-lambda
                         (("StorePath" . _) #t)
                         (("URL" . _) #t)
                         (("Compression" . _) #t)
                         (_ #f))
                       (recutils->alist body))
               (response-code
                (http-get (string-append base "nar/gzip/"
                                         (basename %item))))
               (response-code
                (http-get (string-append base "nar/lzip/"
                                         (basename %item))))))))))

(test-equal "custom nar path"
  ;; Serve nars at /foo/bar/chbouib instead of /nar.
  (list `(("StorePath" . ,%item)
          ("URL" . ,(string-append "foo/bar/chbouib/" (basename %item)))
          ("Compression" . "none"))
        200
        404)
  (let ((thread (with-separate-output-ports
                 (call-with-new-thread
                  (lambda ()
                    (guix-publish "--port=6798" "-C0"
                                  "--nar-path=///foo/bar//chbouib/"))))))
    (wait-until-ready 6798)
    (let* ((base    "http://localhost:6798/")
           (part    (store-path-hash-part %item))
           (url     (string-append base part ".narinfo"))
           (nar-url (string-append base "foo/bar/chbouib/"
                                   (basename %item)))
           (body    (http-get-port url)))
      (list (filter (lambda (item)
                      (match item
                        (("Compression" . _) #t)
                        (("StorePath" . _)  #t)
                        (("URL" . _) #t)
                        (_ #f)))
                    (recutils->alist body))
            (response-code (http-get nar-url))
            (response-code
             (http-get (string-append base "nar/" (basename %item))))))))

(test-equal "/nar/ with properly encoded '+' sign"
  "Congrats!"
  (let ((item (add-text-to-store %store "fake-gtk+" "Congrats!")))
    (call-with-temporary-output-file
     (lambda (temp port)
       (let ((nar (utf8->string
                   (http-get-body
                    (publish-uri
                     (string-append "/nar/" (uri-encode (basename item))))))))
         (call-with-input-string nar (cut restore-file <> temp)))
       (call-with-input-file temp read-string)))))

(test-equal "/nar/invalid"
  404
  (begin
    (call-with-output-file (string-append (%store-prefix) "/invalid")
      (lambda (port)
        (display "This file is not a valid store item." port)))
    (response-code (http-get (publish-uri (string-append "/nar/invalid"))))))

(test-equal "/file/NAME/sha256/HASH"
  "Hello, Guix world!"
  (let* ((data "Hello, Guix world!")
         (hash (call-with-input-string data port-sha256))
         (drv  (run-with-store %store
                 (gexp->derivation "the-file.txt"
                                   #~(call-with-output-file #$output
                                       (lambda (port)
                                         (display #$data port)))
                                   #:hash-algo 'sha256
                                   #:hash hash)))
         (out  (build-derivations %store (list drv))))
    (utf8->string
     (http-get-body
      (publish-uri
       (string-append "/file/the-file.txt/sha256/"
                      (bytevector->nix-base32-string hash)))))))

(test-equal "/file/NAME/sha256/INVALID-NIX-BASE32-STRING"
  404
  (let ((uri (publish-uri
              "/file/the-file.txt/sha256/not-a-nix-base32-string")))
    (response-code (http-get uri))))

(test-equal "/file/NAME/sha256/INVALID-HASH"
  404
  (let ((uri (publish-uri
              (string-append "/file/the-file.txt/sha256/"
                             (bytevector->nix-base32-string
                              (call-with-input-string "" port-sha256))))))
    (response-code (http-get uri))))

(test-equal "with cache"
  (list #t
        `(("StorePath" . ,%item)
          ("URL" . ,(string-append "nar/gzip/" (basename %item)))
          ("Compression" . "gzip"))
        200                                       ;nar/gzip/…
        #t                                        ;Content-Length
        #t                                        ;FileSize
        404)                                      ;nar/…
  (call-with-temporary-directory
   (lambda (cache)
     (let ((thread (with-separate-output-ports
                    (call-with-new-thread
                     (lambda ()
                       (guix-publish "--port=6797" "-C2"
                                     (string-append "--cache=" cache)
                                     "--cache-bypass-threshold=0"))))))
       (wait-until-ready 6797)
       (let* ((base     "http://localhost:6797/")
              (part     (store-path-hash-part %item))
              (url      (string-append base part ".narinfo"))
              (nar-url  (string-append base "nar/gzip/" (basename %item)))
              (cached   (string-append cache "/gzip/" (basename %item)
                                       ".narinfo"))
              (nar      (string-append cache "/gzip/"
                                       (basename %item) ".nar"))
              (response (http-get url)))
         (and (= 404 (response-code response))

              ;; We should get an explicitly short TTL for 404 in this case
              ;; because it's going to become 200 shortly.
              (match (assq-ref (response-headers response) 'cache-control)
                ((('max-age . ttl))
                 (< ttl 3600)))

              (wait-for-file cached)

              ;; Both the narinfo and nar should be world-readable.
              (= #o444 (logand #o444 (stat:perms (lstat cached))))
              (= #o444 (logand #o444 (stat:perms (lstat nar))))

              (let* ((body         (http-get-port url))
                     (compressed   (http-get nar-url))
                     (uncompressed (http-get (string-append base "nar/"
                                                            (basename %item))))
                     (narinfo      (recutils->alist body)))
                (list (file-exists? nar)
                      (filter (lambda (item)
                                (match item
                                  (("Compression" . _) #t)
                                  (("StorePath" . _)  #t)
                                  (("URL" . _) #t)
                                  (_ #f)))
                              narinfo)
                      (response-code compressed)
                      (= (response-content-length compressed)
                         (stat:size (stat nar)))
                      (= (string->number
                          (assoc-ref narinfo "FileSize"))
                         (stat:size (stat nar)))
                      (response-code uncompressed)))))))))

(test-equal "with cache, lzip + gzip"
  '(200 200 404)
  (call-with-temporary-directory
   (lambda (cache)
     (let ((thread (with-separate-output-ports
                    (call-with-new-thread
                     (lambda ()
                       (guix-publish "--port=6794" "-Cgzip:2" "-Clzip:2"
                                     (string-append "--cache=" cache)
                                     "--cache-bypass-threshold=0"))))))
       (wait-until-ready 6794)
       (let* ((base     "http://localhost:6794/")
              (part     (store-path-hash-part %item))
              (url      (string-append base part ".narinfo"))
              (nar-url  (cute string-append "nar/" <> "/"
                              (basename %item)))
              (cached   (cute string-append cache "/" <> "/"
                              (basename %item) ".narinfo"))
              (nar      (cute string-append cache "/" <> "/"
                              (basename %item) ".nar"))
              (response (http-get url)))
         (wait-for-file (cached "gzip"))
         (let* ((body         (http-get-port url))
                (narinfo      (recutils->alist body))
                (uncompressed (string-append base "nar/"
                                             (basename %item))))
           (and (file-exists? (nar "gzip"))
                (file-exists? (nar "lzip"))
                (match (pk 'narinfo/gzip+lzip narinfo)
                  ((("StorePath" . path)
                    _ ...
                    ("Signature" . _)
                    ("URL" . gzip-url)
                    ("Compression" . "gzip")
                    ("FileSize" . (= string->number gzip-size))
                    ("URL" . lzip-url)
                    ("Compression" . "lzip")
                    ("FileSize" . (= string->number lzip-size)))
                   (and (string=? gzip-url (nar-url "gzip"))
                        (string=? lzip-url (nar-url "lzip"))
                        (= gzip-size
                           (stat:size (stat (nar "gzip"))))
                        (= lzip-size
                           (stat:size (stat (nar "lzip")))))))
                (list (response-code
                       (http-get (string-append base (nar-url "gzip"))))
                      (response-code
                       (http-get (string-append base (nar-url "lzip"))))
                      (response-code
                       (http-get uncompressed))))))))))

(let ((item (add-text-to-store %store "fake-compressed-thing.tar.gz"
                               (random-text))))
  (test-equal "with cache, uncompressed"
    (list #t
          (* 42 3600)                             ;TTL on narinfo
          `(("StorePath" . ,item)
            ("URL" . ,(string-append "nar/" (basename item)))
            ("Compression" . "none"))
          200                                     ;nar/…
          (* 42 3600)                             ;TTL on nar/…
          (path-info-nar-size
           (query-path-info %store item))         ;FileSize
          404)                                    ;nar/gzip/…
    (call-with-temporary-directory
     (lambda (cache)
       (let ((thread (with-separate-output-ports
                      (call-with-new-thread
                       (lambda ()
                         (guix-publish "--port=6796" "-C2" "--ttl=42h"
                                       (string-append "--cache=" cache)
                                       "--cache-bypass-threshold=0"))))))
         (wait-until-ready 6796)
         (let* ((base     "http://localhost:6796/")
                (part     (store-path-hash-part item))
                (url      (string-append base part ".narinfo"))
                (cached   (string-append cache "/none/"
                                         (basename item) ".narinfo"))
                (nar      (string-append cache "/none/"
                                         (basename item) ".nar"))
                (response (http-get url)))
           (and (= 404 (response-code response))

                (wait-for-file cached)
                (let* ((response     (http-get url))
                       (body         (http-get-port url))
                       (compressed   (http-get (string-append base "nar/gzip/"
                                                              (basename item))))
                       (uncompressed (http-get (string-append base "nar/"
                                                              (basename item))))
                       (narinfo      (recutils->alist body)))
                  (list (file-exists? nar)
                        (match (assq-ref (response-headers response)
                                         'cache-control)
                          ((('max-age . ttl)) ttl)
                          (_ #f))

                        (filter (lambda (item)
                                  (match item
                                    (("Compression" . _) #t)
                                    (("StorePath" . _)  #t)
                                    (("URL" . _) #t)
                                    (_ #f)))
                                narinfo)
                        (response-code uncompressed)
                        (match (assq-ref (response-headers uncompressed)
                                         'cache-control)
                          ((('max-age . ttl)) ttl)
                          (_ #f))

                        (string->number
                         (assoc-ref narinfo "FileSize"))
                        (response-code compressed))))))))))

(test-equal "with cache, vanishing item"         ;<https://bugs.gnu.org/33897>
  200
  (call-with-temporary-directory
   (lambda (cache)
     (let ((thread (with-separate-output-ports
                    (call-with-new-thread
                     (lambda ()
                       (guix-publish "--port=6795"
                                     (string-append "--cache=" cache)))))))
       (wait-until-ready 6795)

       ;; Make sure that, even if ITEM disappears, we're still able to fetch
       ;; it.
       (let* ((base     "http://localhost:6795/")
              (item     (add-text-to-store %store "random" (random-text)))
              (part     (store-path-hash-part item))
              (url      (string-append base part ".narinfo"))
              (cached   (string-append cache "/gzip/"
                                       (basename item)
                                       ".narinfo"))
              (response (http-get url)))
         (and (= 200 (response-code response))    ;we're below the threshold
              (wait-for-file cached)
              (begin
                (delete-paths %store (list item))
                (response-code (pk 'response (http-get url))))))))))

(test-equal "with cache, cache bypass"
  200
  (call-with-temporary-directory
   (lambda (cache)
     (let ((thread (with-separate-output-ports
                    (call-with-new-thread
                     (lambda ()
                       (guix-publish "--port=6788" "-C" "gzip"
                                     (string-append "--cache=" cache)))))))
       (wait-until-ready 6788)

       (let* ((base     "http://localhost:6788/")
              (item     (add-text-to-store %store "random" (random-text)))
              (part     (store-path-hash-part item))
              (narinfo  (string-append base part ".narinfo"))
              (nar      (string-append base "nar/gzip/" (basename item)))
              (cached   (string-append cache "/gzip/" (basename item)
                                       ".narinfo")))
         ;; We're below the default cache bypass threshold, so NAR and NARINFO
         ;; should immediately return 200.  The NARINFO request should trigger
         ;; caching, and the next request to NAR should return 200 as well.
         (and (let ((response (pk 'r1 (http-get nar))))
                (and (= 200 (response-code response))
                     (not (response-content-length response)))) ;not known
              (= 200 (response-code (http-get narinfo)))
              (begin
                (wait-for-file cached)
                (let ((response (pk 'r2 (http-get nar))))
                  (and (> (response-content-length response)
                          (stat:size (stat item)))
                       (response-code response))))))))))

(test-equal "with cache, cache bypass, unmapped hash part"
  200

  ;; This test reproduces the bug described in <https://bugs.gnu.org/44442>:
  ;; the daemon connection would be closed as a side effect of a nar request
  ;; for a non-existing file name.
  (call-with-temporary-directory
   (lambda (cache)
     (let ((thread (with-separate-output-ports
                    (call-with-new-thread
                     (lambda ()
                       (guix-publish "--port=6787" "-C" "gzip"
                                     (string-append "--cache=" cache)))))))
       (wait-until-ready 6787)

       (let* ((base     "http://localhost:6787/")
              (item     (add-text-to-store %store "random" (random-text)))
              (part     (store-path-hash-part item))
              (narinfo  (string-append base part ".narinfo"))
              (nar      (string-append base "nar/gzip/" (basename item)))
              (cached   (string-append cache "/gzip/" (basename item)
                                       ".narinfo")))
         ;; The first response used to be 500 and to terminate the daemon
         ;; connection as a side effect.
         (and (= (response-code
                  (http-get (string-append base "nar/gzip/"
                                           (make-string 32 #\e)
                                           "-does-not-exist")))
                 404)
              (= 200 (response-code (http-get nar)))
              (= 200 (response-code (http-get narinfo)))
              (begin
                (wait-for-file cached)
                (response-code (http-get nar)))))))))

(test-equal "/log/NAME"
  `(200 #t text/plain (gzip))
  (let ((drv (run-with-store %store
               (gexp->derivation "with-log"
                                 #~(call-with-output-file #$output
                                     (lambda (port)
                                       (display "Hello, build log!"
                                                (current-error-port))
                                       (display #$(random-text) port)))))))
    (build-derivations %store (list drv))
    (let* ((response (http-get
                      (publish-uri (string-append "/log/"
                                                  (basename (derivation->output-path drv))))
                      #:decode-body? #f))
           (base     (basename (derivation-file-name drv)))
           (log      (string-append (dirname %state-directory)
                                    "/log/guix/drvs/" (string-take base 2)
                                    "/" (string-drop base 2) ".gz")))
      (list (response-code response)
            (= (response-content-length response) (stat:size (stat log)))
            (first (response-content-type response))
            (response-content-encoding response)))))

(test-equal "negative TTL"
  `(404 42)

  (call-with-temporary-directory
   (lambda (cache)
     (let ((thread (with-separate-output-ports
                    (call-with-new-thread
                     (lambda ()
                       (guix-publish "--port=6786" "-C0"
                                     "--negative-ttl=42s"))))))
       (wait-until-ready 6786)

       (let* ((base     "http://localhost:6786/")
              (url      (string-append base (make-string 32 #\z)
                                       ".narinfo"))
              (response (http-get url)))
         (list (response-code response)
               (match (assq-ref (response-headers response) 'cache-control)
                 ((('max-age . ttl)) ttl)
                 (_ #f))))))))

(test-equal "no negative TTL"
  `(404 #f)
  (let* ((uri      (publish-uri
                    (string-append "/" (make-string 32 #\z)
                                   ".narinfo")))
         (response (http-get uri)))
    (list (response-code response)
          (assq-ref (response-headers response) 'cache-control))))

(test-equal "/log/NAME not found"
  404
  (let ((uri (publish-uri "/log/does-not-exist")))
    (response-code (http-get uri))))

(test-equal "/signing-key.pub"
  200
  (response-code (http-get (publish-uri "/signing-key.pub"))))

(test-equal "non-GET query"
  '(200 404)
  (let ((path (string-append "/" (store-path-hash-part %item)
                             ".narinfo")))
    (map response-code
         (list (http-get (publish-uri path))
               (http-post (publish-uri path))))))

(test-end "publish")