Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-pages.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorfeistel <6742251-feistel@users.noreply.gitlab.com>2021-11-16 04:16:51 +0300
committerJaime Martinez <jmartinez@gitlab.com>2021-11-16 04:16:51 +0300
commit85621f69f855e43afe983e2ca107e921aa14c8c8 (patch)
tree670f67b327fb18afd399ed9f1231fdf6bc1f114b /internal
parentd7562ebf963c4c5f2069899776231ed882e5ed75 (diff)
feat: handle extra headers when serving from compressed zip archive
Related to https://gitlab.com/gitlab-org/gitlab-pages/-/issues/466 Changelog: added
Diffstat (limited to 'internal')
-rw-r--r--internal/serving/disk/reader.go5
-rw-r--r--internal/serving/disk/zip/serving_test.go23
-rw-r--r--internal/vfs/serving/LICENSE27
-rw-r--r--internal/vfs/serving/export_test.go12
-rw-r--r--internal/vfs/serving/main_test.go133
-rw-r--r--internal/vfs/serving/serving.go264
-rw-r--r--internal/vfs/serving/serving_test.go290
7 files changed, 751 insertions, 3 deletions
diff --git a/internal/serving/disk/reader.go b/internal/serving/disk/reader.go
index 26425063..06ff67fb 100644
--- a/internal/serving/disk/reader.go
+++ b/internal/serving/disk/reader.go
@@ -18,6 +18,7 @@ import (
"gitlab.com/gitlab-org/gitlab-pages/internal/serving"
"gitlab.com/gitlab-org/gitlab-pages/internal/serving/disk/symlink"
"gitlab.com/gitlab-org/gitlab-pages/internal/vfs"
+ vfsServing "gitlab.com/gitlab-org/gitlab-pages/internal/vfs/serving"
)
// Reader is a disk access driver
@@ -235,10 +236,8 @@ func (reader *Reader) serveFile(ctx context.Context, w http.ResponseWriter, r *h
if rs, ok := file.(vfs.SeekableFile); ok {
http.ServeContent(w, r, origPath, fi.ModTime(), rs)
} else {
- // compressed files will be served by io.Copy
- // TODO: Add extra headers https://gitlab.com/gitlab-org/gitlab-pages/-/issues/466
w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
- io.Copy(w, file)
+ vfsServing.ServeCompressedFile(w, r, fi.ModTime(), file)
}
return true
diff --git a/internal/serving/disk/zip/serving_test.go b/internal/serving/disk/zip/serving_test.go
index ed18e6c3..f16bbada 100644
--- a/internal/serving/disk/zip/serving_test.go
+++ b/internal/serving/disk/zip/serving_test.go
@@ -31,6 +31,7 @@ func TestZip_ServeFileHTTP(t *testing.T) {
path string
expectedStatus int
expectedBody string
+ extraHeaders http.Header
}{
"accessing /index.html": {
vfsPath: httpURL,
@@ -53,6 +54,22 @@ func TestZip_ServeFileHTTP(t *testing.T) {
expectedStatus: http.StatusOK,
expectedBody: "zip.gitlab.io/project/index.html\n",
},
+ "accessing / If-Modified-Since": {
+ vfsPath: httpURL,
+ path: "/",
+ expectedStatus: http.StatusNotModified,
+ extraHeaders: http.Header{
+ "If-Modified-Since": {time.Now().Format(http.TimeFormat)},
+ },
+ },
+ "accessing / If-Unmodified-Since": {
+ vfsPath: httpURL,
+ path: "/",
+ expectedStatus: http.StatusPreconditionFailed,
+ extraHeaders: http.Header{
+ "If-Unmodified-Since": {time.Now().AddDate(-10, 0, 0).Format(http.TimeFormat)},
+ },
+ },
"accessing / from disk": {
vfsPath: fileURL,
sha256: "15c5438164ec67bb2225f68d7d7a2e0b608035264e5275b7e3302641aa25a528",
@@ -114,6 +131,8 @@ func TestZip_ServeFileHTTP(t *testing.T) {
w.Code = 0 // ensure that code is not set, and it is being set by handler
r := httptest.NewRequest("GET", "http://zip.gitlab.io/zip"+test.path, nil)
+ r.Header = test.extraHeaders
+
handler := serving.Handler{
Writer: w,
Request: r,
@@ -140,6 +159,10 @@ func TestZip_ServeFileHTTP(t *testing.T) {
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
+ if test.expectedStatus == http.StatusOK {
+ require.NotEmpty(t, resp.Header.Get("Last-Modified"))
+ }
+
require.Contains(t, string(body), test.expectedBody)
})
}
diff --git a/internal/vfs/serving/LICENSE b/internal/vfs/serving/LICENSE
new file mode 100644
index 00000000..6a66aea5
--- /dev/null
+++ b/internal/vfs/serving/LICENSE
@@ -0,0 +1,27 @@
+Copyright (c) 2009 The Go Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/internal/vfs/serving/export_test.go b/internal/vfs/serving/export_test.go
new file mode 100644
index 00000000..2742825a
--- /dev/null
+++ b/internal/vfs/serving/export_test.go
@@ -0,0 +1,12 @@
+// Copyright 2011 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Bridge package to expose http internals to tests in the http_test
+// package.
+
+package serving
+
+var (
+ ExportScanETag = scanETag
+)
diff --git a/internal/vfs/serving/main_test.go b/internal/vfs/serving/main_test.go
new file mode 100644
index 00000000..56babdc8
--- /dev/null
+++ b/internal/vfs/serving/main_test.go
@@ -0,0 +1,133 @@
+// Copyright 2013 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//nolint
+package serving_test
+
+import (
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "runtime"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+)
+
+var quietLog = log.New(io.Discard, "", 0)
+
+func TestMain(m *testing.M) {
+ v := m.Run()
+ if v == 0 && goroutineLeaked() {
+ os.Exit(1)
+ }
+ os.Exit(v)
+}
+
+func interestingGoroutines() (gs []string) {
+ buf := make([]byte, 2<<20)
+ buf = buf[:runtime.Stack(buf, true)]
+ for _, g := range strings.Split(string(buf), "\n\n") {
+ sl := strings.SplitN(g, "\n", 2)
+ if len(sl) != 2 {
+ continue
+ }
+ stack := strings.TrimSpace(sl[1])
+ if stack == "" ||
+ strings.Contains(stack, "testing.(*M).before.func1") ||
+ strings.Contains(stack, "os/signal.signal_recv") ||
+ strings.Contains(stack, "created by net.startServer") ||
+ strings.Contains(stack, "created by testing.RunTests") ||
+ strings.Contains(stack, "closeWriteAndWait") ||
+ strings.Contains(stack, "testing.Main(") ||
+ // These only show up with GOTRACEBACK=2; Issue 5005 (comment 28)
+ strings.Contains(stack, "runtime.goexit") ||
+ strings.Contains(stack, "created by runtime.gc") ||
+ strings.Contains(stack, "vfs/serving_test.interestingGoroutines") ||
+ strings.Contains(stack, "runtime.MHeap_Scavenger") {
+ continue
+ }
+ gs = append(gs, stack)
+ }
+ sort.Strings(gs)
+ return
+}
+
+// Verify the other tests didn't leave any goroutines running.
+func goroutineLeaked() bool {
+ if testing.Short() || runningBenchmarks() {
+ // Don't worry about goroutine leaks in -short mode or in
+ // benchmark mode. Too distracting when there are false positives.
+ return false
+ }
+
+ var stackCount map[string]int
+ for i := 0; i < 5; i++ {
+ n := 0
+ stackCount = make(map[string]int)
+ gs := interestingGoroutines()
+ for _, g := range gs {
+ stackCount[g]++
+ n++
+ }
+ if n == 0 {
+ return false
+ }
+ // Wait for goroutines to schedule and die off:
+ time.Sleep(100 * time.Millisecond)
+ }
+ fmt.Fprintf(os.Stderr, "Too many goroutines running after net/http test(s).\n")
+ for stack, count := range stackCount {
+ fmt.Fprintf(os.Stderr, "%d instances of:\n%s\n", count, stack)
+ }
+ return true
+}
+
+func runningBenchmarks() bool {
+ for i, arg := range os.Args {
+ if strings.HasPrefix(arg, "-test.bench=") && !strings.HasSuffix(arg, "=") {
+ return true
+ }
+ if arg == "-test.bench" && i < len(os.Args)-1 && os.Args[i+1] != "" {
+ return true
+ }
+ }
+ return false
+}
+
+func afterTest(t testing.TB) {
+ http.DefaultTransport.(*http.Transport).CloseIdleConnections()
+ if testing.Short() {
+ return
+ }
+ var bad string
+ badSubstring := map[string]string{
+ ").readLoop(": "a Transport",
+ ").writeLoop(": "a Transport",
+ "created by net/http/httptest.(*Server).Start": "an httptest.Server",
+ "timeoutHandler": "a TimeoutHandler",
+ "net.(*netFD).connect(": "a timing out dial",
+ ").noteClientGone(": "a closenotifier sender",
+ }
+ var stacks string
+ for i := 0; i < 10; i++ {
+ bad = ""
+ stacks = strings.Join(interestingGoroutines(), "\n\n")
+ for substr, what := range badSubstring {
+ if strings.Contains(stacks, substr) {
+ bad = what
+ }
+ }
+ if bad == "" {
+ return
+ }
+ // Bad stuff found, but goroutines might just still be
+ // shutting down, so give it some time.
+ time.Sleep(250 * time.Millisecond)
+ }
+ t.Errorf("Test appears to have leaked %s:\n%s", bad, stacks)
+}
diff --git a/internal/vfs/serving/serving.go b/internal/vfs/serving/serving.go
new file mode 100644
index 00000000..b797fed5
--- /dev/null
+++ b/internal/vfs/serving/serving.go
@@ -0,0 +1,264 @@
+// Copyright 2009 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//nolint
+package serving
+
+import (
+ "errors"
+ "io"
+ "net/http"
+ "net/textproto"
+ "strings"
+ "time"
+
+ "gitlab.com/gitlab-org/gitlab-pages/internal/httperrors"
+ "gitlab.com/gitlab-org/gitlab-pages/internal/logging"
+ "gitlab.com/gitlab-org/gitlab-pages/internal/vfs"
+ "gitlab.com/gitlab-org/labkit/errortracking"
+)
+
+var errUnknownContentType = errors.New("serving: unknown content type")
+
+// ServeCompressedFile is a modified version of https://github.com/golang/go/blob/go1.16.10/src/net/http/fs.go#L192
+func ServeCompressedFile(w http.ResponseWriter, req *http.Request, modtime time.Time, content vfs.File) {
+ serveContent(w, req, modtime, content)
+}
+
+// serveContent is a modified version of https://github.com/golang/go/blob/go1.16.10/src/net/http/fs.go#L221
+// this function relies on the assumption that a Content-Type header is set
+func serveContent(w http.ResponseWriter, r *http.Request, modtime time.Time, content vfs.File) {
+ setLastModified(w, modtime)
+ done := checkPreconditions(w, r, modtime)
+ if done {
+ return
+ }
+
+ code := http.StatusOK
+
+ _, haveType := w.Header()["Content-Type"]
+ if !haveType {
+ // this shouldn't happen
+ errortracking.Capture(errUnknownContentType, errortracking.WithRequest(r))
+ logging.LogRequest(r).WithError(errUnknownContentType).Error("could not serve content")
+ httperrors.Serve500(w)
+
+ return
+ }
+
+ w.WriteHeader(code)
+
+ if r.Method != "HEAD" {
+ io.Copy(w, content)
+ }
+}
+
+// scanETag determines if a syntactically valid ETag is present at s. If so,
+// the ETag and remaining text after consuming ETag is returned. Otherwise,
+// it returns "", "".
+func scanETag(s string) (etag string, remain string) {
+ s = textproto.TrimString(s)
+ start := 0
+ if strings.HasPrefix(s, "W/") {
+ start = 2
+ }
+ if len(s[start:]) < 2 || s[start] != '"' {
+ return "", ""
+ }
+ // ETag is either W/"text" or "text".
+ // See RFC 7232 2.3.
+ for i := start + 1; i < len(s); i++ {
+ c := s[i]
+ switch {
+ // Character values allowed in ETags.
+ case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80:
+ case c == '"':
+ return s[:i+1], s[i+1:]
+ default:
+ return "", ""
+ }
+ }
+ return "", ""
+}
+
+// etagStrongMatch reports whether a and b match using strong ETag comparison.
+// Assumes a and b are valid ETags.
+func etagStrongMatch(a, b string) bool {
+ return a == b && a != "" && a[0] == '"'
+}
+
+// etagWeakMatch reports whether a and b match using weak ETag comparison.
+// Assumes a and b are valid ETags.
+func etagWeakMatch(a, b string) bool {
+ return strings.TrimPrefix(a, "W/") == strings.TrimPrefix(b, "W/")
+}
+
+// condResult is the result of an HTTP request precondition check.
+// See https://tools.ietf.org/html/rfc7232 section 3.
+type condResult int
+
+const (
+ condNone condResult = iota
+ condTrue
+ condFalse
+)
+
+func checkIfMatch(w http.ResponseWriter, r *http.Request) condResult {
+ im := r.Header.Get("If-Match")
+ if im == "" {
+ return condNone
+ }
+ for {
+ im = textproto.TrimString(im)
+ if len(im) == 0 {
+ break
+ }
+ if im[0] == ',' {
+ im = im[1:]
+ continue
+ }
+ if im[0] == '*' {
+ return condTrue
+ }
+ etag, remain := scanETag(im)
+ if etag == "" {
+ break
+ }
+ if etagStrongMatch(etag, w.Header().Get("Etag")) {
+ return condTrue
+ }
+ im = remain
+ }
+
+ return condFalse
+}
+
+func checkIfUnmodifiedSince(r *http.Request, modtime time.Time) condResult {
+ ius := r.Header.Get("If-Unmodified-Since")
+ if ius == "" || isZeroTime(modtime) {
+ return condNone
+ }
+ t, err := http.ParseTime(ius)
+ if err != nil {
+ return condNone
+ }
+
+ // The Last-Modified header truncates sub-second precision so
+ // the modtime needs to be truncated too.
+ modtime = modtime.Truncate(time.Second)
+ if modtime.Before(t) || modtime.Equal(t) {
+ return condTrue
+ }
+ return condFalse
+}
+
+func checkIfNoneMatch(w http.ResponseWriter, r *http.Request) condResult {
+ inm := r.Header.Get("If-None-Match")
+ if inm == "" {
+ return condNone
+ }
+ buf := inm
+ for {
+ buf = textproto.TrimString(buf)
+ if len(buf) == 0 {
+ break
+ }
+ if buf[0] == ',' {
+ buf = buf[1:]
+ continue
+ }
+ if buf[0] == '*' {
+ return condFalse
+ }
+ etag, remain := scanETag(buf)
+ if etag == "" {
+ break
+ }
+ if etagWeakMatch(etag, w.Header().Get("Etag")) {
+ return condFalse
+ }
+ buf = remain
+ }
+ return condTrue
+}
+
+func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult {
+ if r.Method != "GET" && r.Method != "HEAD" {
+ return condNone
+ }
+ ims := r.Header.Get("If-Modified-Since")
+ if ims == "" || isZeroTime(modtime) {
+ return condNone
+ }
+ t, err := http.ParseTime(ims)
+ if err != nil {
+ return condNone
+ }
+ // The Last-Modified header truncates sub-second precision so
+ // the modtime needs to be truncated too.
+ modtime = modtime.Truncate(time.Second)
+ if modtime.Before(t) || modtime.Equal(t) {
+ return condFalse
+ }
+ return condTrue
+}
+
+var unixEpochTime = time.Unix(0, 0)
+
+// isZeroTime reports whether t is obviously unspecified (either zero or Unix()=0).
+func isZeroTime(t time.Time) bool {
+ return t.IsZero() || t.Equal(unixEpochTime)
+}
+
+func setLastModified(w http.ResponseWriter, modtime time.Time) {
+ if !isZeroTime(modtime) {
+ w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat))
+ }
+}
+
+func writeNotModified(w http.ResponseWriter) {
+ // RFC 7232 section 4.1:
+ // a sender SHOULD NOT generate representation metadata other than the
+ // above listed fields unless said metadata exists for the purpose of
+ // guiding cache updates (e.g., Last-Modified might be useful if the
+ // response does not have an ETag field).
+ h := w.Header()
+ delete(h, "Content-Type")
+ delete(h, "Content-Length")
+ if h.Get("Etag") != "" {
+ delete(h, "Last-Modified")
+ }
+ w.WriteHeader(http.StatusNotModified)
+}
+
+// checkPreconditions evaluates request preconditions and reports whether a precondition
+// resulted in sending StatusNotModified or StatusPreconditionFailed.
+func checkPreconditions(w http.ResponseWriter, r *http.Request, modtime time.Time) (done bool) {
+ // This function carefully follows RFC 7232 section 6.
+ ch := checkIfMatch(w, r)
+ if ch == condNone {
+ ch = checkIfUnmodifiedSince(r, modtime)
+ }
+ if ch == condFalse {
+ w.WriteHeader(http.StatusPreconditionFailed)
+ return true
+ }
+ switch checkIfNoneMatch(w, r) {
+ case condFalse:
+ if r.Method == "GET" || r.Method == "HEAD" {
+ writeNotModified(w)
+ return true
+ } else {
+ w.WriteHeader(http.StatusPreconditionFailed)
+ return true
+ }
+ case condNone:
+ if checkIfModifiedSince(r, modtime) == condFalse {
+ writeNotModified(w)
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/internal/vfs/serving/serving_test.go b/internal/vfs/serving/serving_test.go
new file mode 100644
index 00000000..01ce60fc
--- /dev/null
+++ b/internal/vfs/serving/serving_test.go
@@ -0,0 +1,290 @@
+// Copyright 2010 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//nolint
+package serving_test
+
+import (
+ "io"
+ "io/fs"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "gitlab.com/gitlab-org/gitlab-pages/internal/vfs"
+ "gitlab.com/gitlab-org/gitlab-pages/internal/vfs/serving"
+)
+
+var (
+ style = io.NopCloser(strings.NewReader("p{text-transform: none;}"))
+ index = io.NopCloser(strings.NewReader("<!doctype html><meta charset=utf-8><title>hello</title>"))
+ lastMod = time.Now()
+)
+
+func TestServeContent(t *testing.T) {
+ defer afterTest(t)
+ type serveParam struct {
+ file vfs.File
+ modtime time.Time
+ contentType string
+ etag string
+ }
+ servec := make(chan serveParam, 1)
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ p := <-servec
+ if p.etag != "" {
+ w.Header().Set("ETag", p.etag)
+ }
+ if p.contentType != "" {
+ w.Header().Set("Content-Type", p.contentType)
+ }
+ serving.ServeCompressedFile(w, r, p.modtime, p.file)
+ }))
+ defer ts.Close()
+
+ type testCase struct {
+ // One of file or content must be set:
+ file vfs.File
+
+ modtime time.Time
+ serveETag string // optional
+ serveContentType string // optional
+ reqHeader map[string]string
+ wantLastMod string
+ wantContentType string
+ wantContentRange string
+ wantStatus int
+ }
+ tests := map[string]testCase{
+ "no_last_modified": {
+ file: style,
+ serveContentType: "text/css; charset=utf-8",
+ wantContentType: "text/css; charset=utf-8",
+ wantStatus: 200,
+ },
+ "with_last_modified": {
+ file: index,
+ serveContentType: "text/html; charset=utf-8",
+ wantContentType: "text/html; charset=utf-8",
+ modtime: lastMod,
+ wantLastMod: lastMod.UTC().Format(http.TimeFormat),
+ wantStatus: 200,
+ },
+ "not_modified_modtime": {
+ file: style,
+ serveETag: `"foo"`, // Last-Modified sent only when no ETag
+ modtime: lastMod,
+ reqHeader: map[string]string{
+ "If-Modified-Since": lastMod.UTC().Format(http.TimeFormat),
+ },
+ wantStatus: 304,
+ },
+ "not_modified_modtime_with_contenttype": {
+ file: style,
+ serveContentType: "text/css", // explicit content type
+ serveETag: `"foo"`, // Last-Modified sent only when no ETag
+ modtime: lastMod,
+ reqHeader: map[string]string{
+ "If-Modified-Since": lastMod.UTC().Format(http.TimeFormat),
+ },
+ wantStatus: 304,
+ },
+ "not_modified_etag": {
+ file: style,
+ serveETag: `"foo"`,
+ reqHeader: map[string]string{
+ "If-None-Match": `"foo"`,
+ },
+ wantStatus: 304,
+ },
+ "if_none_match_mismatch": {
+ file: style,
+ serveETag: `"foo"`,
+ reqHeader: map[string]string{
+ "If-None-Match": `"Foo"`,
+ },
+ wantStatus: 200,
+ wantContentType: "text/css; charset=utf-8",
+ serveContentType: "text/css; charset=utf-8",
+ },
+ "if_none_match_malformed": {
+ file: style,
+ serveETag: `"foo"`,
+ reqHeader: map[string]string{
+ "If-None-Match": `,`,
+ },
+ wantStatus: 200,
+ wantContentType: "text/css; charset=utf-8",
+ serveContentType: "text/css; charset=utf-8",
+ },
+ "ifmatch_matches": {
+ file: style,
+ serveETag: `"A"`,
+ reqHeader: map[string]string{
+ "If-Match": `"Z", "A"`,
+ },
+ wantStatus: 200,
+ wantContentType: "text/css; charset=utf-8",
+ serveContentType: "text/css; charset=utf-8",
+ },
+ "ifmatch_star": {
+ file: style,
+ serveETag: `"A"`,
+ reqHeader: map[string]string{
+ "If-Match": `*`,
+ },
+ wantStatus: 200,
+ wantContentType: "text/css; charset=utf-8",
+ serveContentType: "text/css; charset=utf-8",
+ },
+ "ifmatch_failed": {
+ file: style,
+ serveETag: `"A"`,
+ reqHeader: map[string]string{
+ "If-Match": `"B"`,
+ },
+ wantStatus: 412,
+ },
+ "ifmatch_fails_on_weak_etag": {
+ file: style,
+ serveETag: `W/"A"`,
+ reqHeader: map[string]string{
+ "If-Match": `W/"A"`,
+ },
+ wantStatus: 412,
+ },
+ "if_unmodified_since_true": {
+ file: style,
+ modtime: lastMod,
+ reqHeader: map[string]string{
+ "If-Unmodified-Since": lastMod.UTC().Format(http.TimeFormat),
+ },
+ wantStatus: 200,
+ wantContentType: "text/css; charset=utf-8",
+ serveContentType: "text/css; charset=utf-8",
+ wantLastMod: lastMod.UTC().Format(http.TimeFormat),
+ },
+ "if_unmodified_since_false": {
+ file: style,
+ modtime: lastMod,
+ reqHeader: map[string]string{
+ "If-Unmodified-Since": lastMod.Add(-2 * time.Second).UTC().Format(http.TimeFormat),
+ },
+ wantStatus: 412,
+ wantLastMod: lastMod.UTC().Format(http.TimeFormat),
+ },
+ // additional tests
+ "missin_content_type": {
+ file: index,
+ wantContentType: "text/html; charset=utf-8",
+ wantStatus: http.StatusInternalServerError,
+ },
+ "if_modified_since_malformed": {
+ file: style,
+ modtime: lastMod,
+ wantLastMod: lastMod.UTC().Format(http.TimeFormat),
+ reqHeader: map[string]string{
+ "If-Modified-Since": "foo",
+ },
+ wantStatus: http.StatusOK,
+ wantContentType: "text/css; charset=utf-8",
+ serveContentType: "text/css; charset=utf-8",
+ },
+ "if_unmodified_since_malformed": {
+ file: style,
+ modtime: lastMod,
+ wantLastMod: lastMod.UTC().Format(http.TimeFormat),
+ reqHeader: map[string]string{
+ "If-Unmodified-Since": "foo",
+ },
+ wantStatus: http.StatusOK,
+ wantContentType: "text/css; charset=utf-8",
+ serveContentType: "text/css; charset=utf-8",
+ },
+ "if_modified_since_true": {
+ file: style,
+ modtime: lastMod,
+ reqHeader: map[string]string{
+ "If-Modified-Since": lastMod.Add(-2 * time.Second).UTC().Format(http.TimeFormat),
+ },
+ wantStatus: http.StatusOK,
+ wantLastMod: lastMod.UTC().Format(http.TimeFormat),
+ wantContentType: "text/css; charset=utf-8",
+ serveContentType: "text/css; charset=utf-8",
+ },
+ }
+ for testName, tt := range tests {
+ for _, method := range []string{"GET", "HEAD"} {
+ servec <- serveParam{
+ file: tt.file,
+ modtime: tt.modtime,
+ etag: tt.serveETag,
+ contentType: tt.serveContentType,
+ }
+ req, err := http.NewRequest(method, ts.URL, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for k, v := range tt.reqHeader {
+ req.Header.Set(k, v)
+ }
+
+ c := ts.Client()
+ res, err := c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ io.Copy(io.Discard, res.Body)
+ res.Body.Close()
+ if res.StatusCode != tt.wantStatus {
+ t.Errorf("test %q using %q: got status = %d; want %d", testName, method, res.StatusCode, tt.wantStatus)
+ }
+ if g, e := res.Header.Get("Content-Type"), tt.wantContentType; g != e {
+ t.Errorf("test %q using %q: got content-type = %q, want %q", testName, method, g, e)
+ }
+ if g, e := res.Header.Get("Content-Range"), tt.wantContentRange; g != e {
+ t.Errorf("test %q using %q: got content-range = %q, want %q", testName, method, g, e)
+ }
+ if g, e := res.Header.Get("Last-Modified"), tt.wantLastMod; g != e {
+ t.Errorf("test %q using %q: got last-modified = %q, want %q", testName, method, g, e)
+ }
+ }
+ }
+}
+
+type panicOnSeek struct{ io.ReadSeeker }
+
+func Test_scanETag(t *testing.T) {
+ tests := []struct {
+ in string
+ wantETag string
+ wantRemain string
+ }{
+ {`W/"etag-1"`, `W/"etag-1"`, ""},
+ {`"etag-2"`, `"etag-2"`, ""},
+ {`"etag-1", "etag-2"`, `"etag-1"`, `, "etag-2"`},
+ {"", "", ""},
+ {"W/", "", ""},
+ {`W/"truc`, "", ""},
+ {`w/"case-sensitive"`, "", ""},
+ {`"spaced etag"`, "", ""},
+ }
+ for _, test := range tests {
+ etag, remain := serving.ExportScanETag(test.in)
+ if etag != test.wantETag || remain != test.wantRemain {
+ t.Errorf("scanETag(%q)=%q %q, want %q %q", test.in, etag, remain, test.wantETag, test.wantRemain)
+ }
+ }
+}
+
+func mustStat(t *testing.T, fileName string) fs.FileInfo {
+ fi, err := os.Stat(fileName)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return fi
+}