19249: Test handling of crlf for properties rendered in headers.
[arvados.git] / services / keep-web / s3_test.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package keepweb
6
7 import (
8         "bytes"
9         "context"
10         "crypto/rand"
11         "crypto/sha256"
12         "fmt"
13         "io/ioutil"
14         "mime"
15         "net/http"
16         "net/http/httptest"
17         "net/url"
18         "os"
19         "os/exec"
20         "strings"
21         "sync"
22         "time"
23
24         "git.arvados.org/arvados.git/sdk/go/arvados"
25         "git.arvados.org/arvados.git/sdk/go/arvadosclient"
26         "git.arvados.org/arvados.git/sdk/go/arvadostest"
27         "git.arvados.org/arvados.git/sdk/go/keepclient"
28         "github.com/AdRoll/goamz/aws"
29         "github.com/AdRoll/goamz/s3"
30         aws_aws "github.com/aws/aws-sdk-go/aws"
31         aws_credentials "github.com/aws/aws-sdk-go/aws/credentials"
32         aws_session "github.com/aws/aws-sdk-go/aws/session"
33         aws_s3 "github.com/aws/aws-sdk-go/service/s3"
34         check "gopkg.in/check.v1"
35 )
36
37 type s3stage struct {
38         arv        *arvados.Client
39         ac         *arvadosclient.ArvadosClient
40         kc         *keepclient.KeepClient
41         proj       arvados.Group
42         projbucket *s3.Bucket
43         subproj    arvados.Group
44         coll       arvados.Collection
45         collbucket *s3.Bucket
46 }
47
48 func (s *IntegrationSuite) s3setup(c *check.C) s3stage {
49         var proj, subproj arvados.Group
50         var coll arvados.Collection
51         arv := arvados.NewClientFromEnv()
52         arv.AuthToken = arvadostest.ActiveToken
53         err := arv.RequestAndDecode(&proj, "POST", "arvados/v1/groups", nil, map[string]interface{}{
54                 "group": map[string]interface{}{
55                         "group_class": "project",
56                         "name":        "keep-web s3 test",
57                         "properties": map[string]interface{}{
58                                 "project-properties-key": "project properties value",
59                         },
60                 },
61                 "ensure_unique_name": true,
62         })
63         c.Assert(err, check.IsNil)
64         err = arv.RequestAndDecode(&subproj, "POST", "arvados/v1/groups", nil, map[string]interface{}{
65                 "group": map[string]interface{}{
66                         "owner_uuid":  proj.UUID,
67                         "group_class": "project",
68                         "name":        "keep-web s3 test subproject",
69                         "properties": map[string]interface{}{
70                                 "subproject_properties_key": "subproject properties value",
71                                 "invalid header key":        "this value will not be returned because key contains spaces",
72                         },
73                 },
74         })
75         c.Assert(err, check.IsNil)
76         err = arv.RequestAndDecode(&coll, "POST", "arvados/v1/collections", nil, map[string]interface{}{"collection": map[string]interface{}{
77                 "owner_uuid":    proj.UUID,
78                 "name":          "keep-web s3 test collection",
79                 "manifest_text": ". d41d8cd98f00b204e9800998ecf8427e+0 0:0:emptyfile\n./emptydir d41d8cd98f00b204e9800998ecf8427e+0 0:0:.\n",
80                 "properties": map[string]interface{}{
81                         "string":   "string value",
82                         "array":    []string{"element1", "element2"},
83                         "object":   map[string]interface{}{"key": map[string]interface{}{"key2": "value⛵"}},
84                         "nonascii": "⛵",
85                         "newline":  "foo\r\nX-Bad: header",
86                 },
87         }})
88         c.Assert(err, check.IsNil)
89         ac, err := arvadosclient.New(arv)
90         c.Assert(err, check.IsNil)
91         kc, err := keepclient.MakeKeepClient(ac)
92         c.Assert(err, check.IsNil)
93         fs, err := coll.FileSystem(arv, kc)
94         c.Assert(err, check.IsNil)
95         f, err := fs.OpenFile("sailboat.txt", os.O_CREATE|os.O_WRONLY, 0644)
96         c.Assert(err, check.IsNil)
97         _, err = f.Write([]byte("⛵\n"))
98         c.Assert(err, check.IsNil)
99         err = f.Close()
100         c.Assert(err, check.IsNil)
101         err = fs.Sync()
102         c.Assert(err, check.IsNil)
103         err = arv.RequestAndDecode(&coll, "GET", "arvados/v1/collections/"+coll.UUID, nil, nil)
104         c.Assert(err, check.IsNil)
105
106         auth := aws.NewAuth(arvadostest.ActiveTokenUUID, arvadostest.ActiveToken, "", time.Now().Add(time.Hour))
107         region := aws.Region{
108                 Name:       "zzzzz",
109                 S3Endpoint: s.testServer.URL,
110         }
111         client := s3.New(*auth, region)
112         client.Signature = aws.V4Signature
113         return s3stage{
114                 arv:  arv,
115                 ac:   ac,
116                 kc:   kc,
117                 proj: proj,
118                 projbucket: &s3.Bucket{
119                         S3:   client,
120                         Name: proj.UUID,
121                 },
122                 subproj: subproj,
123                 coll:    coll,
124                 collbucket: &s3.Bucket{
125                         S3:   client,
126                         Name: coll.UUID,
127                 },
128         }
129 }
130
131 func (stage s3stage) teardown(c *check.C) {
132         if stage.coll.UUID != "" {
133                 err := stage.arv.RequestAndDecode(&stage.coll, "DELETE", "arvados/v1/collections/"+stage.coll.UUID, nil, nil)
134                 c.Check(err, check.IsNil)
135         }
136         if stage.proj.UUID != "" {
137                 err := stage.arv.RequestAndDecode(&stage.proj, "DELETE", "arvados/v1/groups/"+stage.proj.UUID, nil, nil)
138                 c.Check(err, check.IsNil)
139         }
140 }
141
142 func (s *IntegrationSuite) TestS3Signatures(c *check.C) {
143         stage := s.s3setup(c)
144         defer stage.teardown(c)
145
146         bucket := stage.collbucket
147         for _, trial := range []struct {
148                 success   bool
149                 signature int
150                 accesskey string
151                 secretkey string
152         }{
153                 {true, aws.V2Signature, arvadostest.ActiveToken, "none"},
154                 {true, aws.V2Signature, url.QueryEscape(arvadostest.ActiveTokenV2), "none"},
155                 {true, aws.V2Signature, strings.Replace(arvadostest.ActiveTokenV2, "/", "_", -1), "none"},
156                 {false, aws.V2Signature, "none", "none"},
157                 {false, aws.V2Signature, "none", arvadostest.ActiveToken},
158
159                 {true, aws.V4Signature, arvadostest.ActiveTokenUUID, arvadostest.ActiveToken},
160                 {true, aws.V4Signature, arvadostest.ActiveToken, arvadostest.ActiveToken},
161                 {true, aws.V4Signature, url.QueryEscape(arvadostest.ActiveTokenV2), url.QueryEscape(arvadostest.ActiveTokenV2)},
162                 {true, aws.V4Signature, strings.Replace(arvadostest.ActiveTokenV2, "/", "_", -1), strings.Replace(arvadostest.ActiveTokenV2, "/", "_", -1)},
163                 {false, aws.V4Signature, arvadostest.ActiveToken, ""},
164                 {false, aws.V4Signature, arvadostest.ActiveToken, "none"},
165                 {false, aws.V4Signature, "none", arvadostest.ActiveToken},
166                 {false, aws.V4Signature, "none", "none"},
167         } {
168                 c.Logf("%#v", trial)
169                 bucket.S3.Auth = *(aws.NewAuth(trial.accesskey, trial.secretkey, "", time.Now().Add(time.Hour)))
170                 bucket.S3.Signature = trial.signature
171                 _, err := bucket.GetReader("emptyfile")
172                 if trial.success {
173                         c.Check(err, check.IsNil)
174                 } else {
175                         c.Check(err, check.NotNil)
176                 }
177         }
178 }
179
180 func (s *IntegrationSuite) TestS3HeadBucket(c *check.C) {
181         stage := s.s3setup(c)
182         defer stage.teardown(c)
183
184         for _, bucket := range []*s3.Bucket{stage.collbucket, stage.projbucket} {
185                 c.Logf("bucket %s", bucket.Name)
186                 exists, err := bucket.Exists("")
187                 c.Check(err, check.IsNil)
188                 c.Check(exists, check.Equals, true)
189         }
190 }
191
192 func (s *IntegrationSuite) TestS3CollectionGetObject(c *check.C) {
193         stage := s.s3setup(c)
194         defer stage.teardown(c)
195         s.testS3GetObject(c, stage.collbucket, "")
196 }
197 func (s *IntegrationSuite) TestS3ProjectGetObject(c *check.C) {
198         stage := s.s3setup(c)
199         defer stage.teardown(c)
200         s.testS3GetObject(c, stage.projbucket, stage.coll.Name+"/")
201 }
202 func (s *IntegrationSuite) testS3GetObject(c *check.C, bucket *s3.Bucket, prefix string) {
203         rdr, err := bucket.GetReader(prefix + "emptyfile")
204         c.Assert(err, check.IsNil)
205         buf, err := ioutil.ReadAll(rdr)
206         c.Check(err, check.IsNil)
207         c.Check(len(buf), check.Equals, 0)
208         err = rdr.Close()
209         c.Check(err, check.IsNil)
210
211         // GetObject
212         rdr, err = bucket.GetReader(prefix + "missingfile")
213         c.Check(err.(*s3.Error).StatusCode, check.Equals, 404)
214         c.Check(err.(*s3.Error).Code, check.Equals, `NoSuchKey`)
215         c.Check(err, check.ErrorMatches, `The specified key does not exist.`)
216
217         // HeadObject
218         exists, err := bucket.Exists(prefix + "missingfile")
219         c.Check(err, check.IsNil)
220         c.Check(exists, check.Equals, false)
221
222         // GetObject
223         rdr, err = bucket.GetReader(prefix + "sailboat.txt")
224         c.Assert(err, check.IsNil)
225         buf, err = ioutil.ReadAll(rdr)
226         c.Check(err, check.IsNil)
227         c.Check(buf, check.DeepEquals, []byte("⛵\n"))
228         err = rdr.Close()
229         c.Check(err, check.IsNil)
230
231         // HeadObject
232         resp, err := bucket.Head(prefix+"sailboat.txt", nil)
233         c.Check(err, check.IsNil)
234         c.Check(resp.StatusCode, check.Equals, http.StatusOK)
235         c.Check(resp.ContentLength, check.Equals, int64(4))
236
237         // HeadObject with superfluous leading slashes
238         exists, err = bucket.Exists(prefix + "//sailboat.txt")
239         c.Check(err, check.IsNil)
240         c.Check(exists, check.Equals, true)
241 }
242
243 func (s *IntegrationSuite) checkMetaEquals(c *check.C, hdr http.Header, expect map[string]string) {
244         got := map[string]string{}
245         for hk, hv := range hdr {
246                 if k := strings.TrimPrefix(hk, "X-Amz-Meta-"); k != hk && len(hv) == 1 {
247                         got[k] = hv[0]
248                 }
249         }
250         c.Check(got, check.DeepEquals, expect)
251 }
252
253 func (s *IntegrationSuite) TestS3PropertiesAsMetadata(c *check.C) {
254         stage := s.s3setup(c)
255         defer stage.teardown(c)
256
257         expectCollectionTags := map[string]string{
258                 "String":   "string value",
259                 "Array":    `["element1","element2"]`,
260                 "Object":   mime.BEncoding.Encode("UTF-8", `{"key":{"key2":"value⛵"}}`),
261                 "Nonascii": "=?UTF-8?b?4pu1?=",
262                 "Newline":  "foo  X-Bad: header",
263         }
264         expectSubprojectTags := map[string]string{
265                 "Subproject_properties_key": "subproject properties value",
266         }
267         expectProjectTags := map[string]string{
268                 "Project-Properties-Key": "project properties value",
269         }
270
271         c.Log("HEAD object with metadata from collection")
272         resp, err := stage.collbucket.Head("sailboat.txt", nil)
273         c.Assert(err, check.IsNil)
274         s.checkMetaEquals(c, resp.Header, expectCollectionTags)
275
276         c.Log("GET object with metadata from collection")
277         rdr, hdr, err := stage.collbucket.GetReaderWithHeaders("sailboat.txt")
278         c.Assert(err, check.IsNil)
279         content, err := ioutil.ReadAll(rdr)
280         c.Check(err, check.IsNil)
281         rdr.Close()
282         c.Check(content, check.HasLen, 4)
283         s.checkMetaEquals(c, hdr, expectCollectionTags)
284
285         c.Log("HEAD bucket with metadata from collection")
286         resp, err = stage.collbucket.Head("/", nil)
287         c.Assert(err, check.IsNil)
288         s.checkMetaEquals(c, resp.Header, expectCollectionTags)
289
290         c.Log("HEAD directory placeholder with metadata from collection")
291         resp, err = stage.projbucket.Head("keep-web s3 test collection/", nil)
292         c.Assert(err, check.IsNil)
293         s.checkMetaEquals(c, resp.Header, expectCollectionTags)
294
295         c.Log("HEAD file with metadata from collection")
296         resp, err = stage.projbucket.Head("keep-web s3 test collection/sailboat.txt", nil)
297         c.Assert(err, check.IsNil)
298         s.checkMetaEquals(c, resp.Header, expectCollectionTags)
299
300         c.Log("HEAD directory placeholder with metadata from subproject")
301         resp, err = stage.projbucket.Head("keep-web s3 test subproject/", nil)
302         c.Assert(err, check.IsNil)
303         s.checkMetaEquals(c, resp.Header, expectSubprojectTags)
304
305         c.Log("HEAD bucket with metadata from project")
306         resp, err = stage.projbucket.Head("/", nil)
307         c.Assert(err, check.IsNil)
308         s.checkMetaEquals(c, resp.Header, expectProjectTags)
309 }
310
311 func (s *IntegrationSuite) TestS3CollectionPutObjectSuccess(c *check.C) {
312         stage := s.s3setup(c)
313         defer stage.teardown(c)
314         s.testS3PutObjectSuccess(c, stage.collbucket, "")
315 }
316 func (s *IntegrationSuite) TestS3ProjectPutObjectSuccess(c *check.C) {
317         stage := s.s3setup(c)
318         defer stage.teardown(c)
319         s.testS3PutObjectSuccess(c, stage.projbucket, stage.coll.Name+"/")
320 }
321 func (s *IntegrationSuite) testS3PutObjectSuccess(c *check.C, bucket *s3.Bucket, prefix string) {
322         for _, trial := range []struct {
323                 path        string
324                 size        int
325                 contentType string
326         }{
327                 {
328                         path:        "newfile",
329                         size:        128000000,
330                         contentType: "application/octet-stream",
331                 }, {
332                         path:        "newdir/newfile",
333                         size:        1 << 26,
334                         contentType: "application/octet-stream",
335                 }, {
336                         path:        "/aaa",
337                         size:        2,
338                         contentType: "application/octet-stream",
339                 }, {
340                         path:        "//bbb",
341                         size:        2,
342                         contentType: "application/octet-stream",
343                 }, {
344                         path:        "ccc//",
345                         size:        0,
346                         contentType: "application/x-directory",
347                 }, {
348                         path:        "newdir1/newdir2/newfile",
349                         size:        0,
350                         contentType: "application/octet-stream",
351                 }, {
352                         path:        "newdir1/newdir2/newdir3/",
353                         size:        0,
354                         contentType: "application/x-directory",
355                 },
356         } {
357                 c.Logf("=== %v", trial)
358
359                 objname := prefix + trial.path
360
361                 _, err := bucket.GetReader(objname)
362                 if !c.Check(err, check.NotNil) {
363                         continue
364                 }
365                 c.Check(err.(*s3.Error).StatusCode, check.Equals, 404)
366                 c.Check(err.(*s3.Error).Code, check.Equals, `NoSuchKey`)
367                 if !c.Check(err, check.ErrorMatches, `The specified key does not exist.`) {
368                         continue
369                 }
370
371                 buf := make([]byte, trial.size)
372                 rand.Read(buf)
373
374                 err = bucket.PutReader(objname, bytes.NewReader(buf), int64(len(buf)), trial.contentType, s3.Private, s3.Options{})
375                 c.Check(err, check.IsNil)
376
377                 rdr, err := bucket.GetReader(objname)
378                 if strings.HasSuffix(trial.path, "/") && !s.handler.Cluster.Collections.S3FolderObjects {
379                         c.Check(err, check.NotNil)
380                         continue
381                 } else if !c.Check(err, check.IsNil) {
382                         continue
383                 }
384                 buf2, err := ioutil.ReadAll(rdr)
385                 c.Check(err, check.IsNil)
386                 c.Check(buf2, check.HasLen, len(buf))
387                 c.Check(bytes.Equal(buf, buf2), check.Equals, true)
388         }
389 }
390
391 func (s *IntegrationSuite) TestS3ProjectPutObjectNotSupported(c *check.C) {
392         stage := s.s3setup(c)
393         defer stage.teardown(c)
394         bucket := stage.projbucket
395
396         for _, trial := range []struct {
397                 path        string
398                 size        int
399                 contentType string
400         }{
401                 {
402                         path:        "newfile",
403                         size:        1234,
404                         contentType: "application/octet-stream",
405                 }, {
406                         path:        "newdir/newfile",
407                         size:        1234,
408                         contentType: "application/octet-stream",
409                 }, {
410                         path:        "newdir2/",
411                         size:        0,
412                         contentType: "application/x-directory",
413                 },
414         } {
415                 c.Logf("=== %v", trial)
416
417                 _, err := bucket.GetReader(trial.path)
418                 c.Check(err.(*s3.Error).StatusCode, check.Equals, 404)
419                 c.Check(err.(*s3.Error).Code, check.Equals, `NoSuchKey`)
420                 c.Assert(err, check.ErrorMatches, `The specified key does not exist.`)
421
422                 buf := make([]byte, trial.size)
423                 rand.Read(buf)
424
425                 err = bucket.PutReader(trial.path, bytes.NewReader(buf), int64(len(buf)), trial.contentType, s3.Private, s3.Options{})
426                 c.Check(err.(*s3.Error).StatusCode, check.Equals, 400)
427                 c.Check(err.(*s3.Error).Code, check.Equals, `InvalidArgument`)
428                 c.Check(err, check.ErrorMatches, `(mkdir "/by_id/zzzzz-j7d0g-[a-z0-9]{15}/newdir2?"|open "/zzzzz-j7d0g-[a-z0-9]{15}/newfile") failed: invalid (argument|operation)`)
429
430                 _, err = bucket.GetReader(trial.path)
431                 c.Check(err.(*s3.Error).StatusCode, check.Equals, 404)
432                 c.Check(err.(*s3.Error).Code, check.Equals, `NoSuchKey`)
433                 c.Assert(err, check.ErrorMatches, `The specified key does not exist.`)
434         }
435 }
436
437 func (s *IntegrationSuite) TestS3CollectionDeleteObject(c *check.C) {
438         stage := s.s3setup(c)
439         defer stage.teardown(c)
440         s.testS3DeleteObject(c, stage.collbucket, "")
441 }
442 func (s *IntegrationSuite) TestS3ProjectDeleteObject(c *check.C) {
443         stage := s.s3setup(c)
444         defer stage.teardown(c)
445         s.testS3DeleteObject(c, stage.projbucket, stage.coll.Name+"/")
446 }
447 func (s *IntegrationSuite) testS3DeleteObject(c *check.C, bucket *s3.Bucket, prefix string) {
448         s.handler.Cluster.Collections.S3FolderObjects = true
449         for _, trial := range []struct {
450                 path string
451         }{
452                 {"/"},
453                 {"nonexistentfile"},
454                 {"emptyfile"},
455                 {"sailboat.txt"},
456                 {"sailboat.txt/"},
457                 {"emptydir"},
458                 {"emptydir/"},
459         } {
460                 objname := prefix + trial.path
461                 comment := check.Commentf("objname %q", objname)
462
463                 err := bucket.Del(objname)
464                 if trial.path == "/" {
465                         c.Check(err, check.NotNil)
466                         continue
467                 }
468                 c.Check(err, check.IsNil, comment)
469                 _, err = bucket.GetReader(objname)
470                 c.Check(err, check.NotNil, comment)
471         }
472 }
473
474 func (s *IntegrationSuite) TestS3CollectionPutObjectFailure(c *check.C) {
475         stage := s.s3setup(c)
476         defer stage.teardown(c)
477         s.testS3PutObjectFailure(c, stage.collbucket, "")
478 }
479 func (s *IntegrationSuite) TestS3ProjectPutObjectFailure(c *check.C) {
480         stage := s.s3setup(c)
481         defer stage.teardown(c)
482         s.testS3PutObjectFailure(c, stage.projbucket, stage.coll.Name+"/")
483 }
484 func (s *IntegrationSuite) testS3PutObjectFailure(c *check.C, bucket *s3.Bucket, prefix string) {
485         s.handler.Cluster.Collections.S3FolderObjects = false
486
487         var wg sync.WaitGroup
488         for _, trial := range []struct {
489                 path string
490         }{
491                 {
492                         path: "emptyfile/newname", // emptyfile exists, see s3setup()
493                 }, {
494                         path: "emptyfile/", // emptyfile exists, see s3setup()
495                 }, {
496                         path: "emptydir", // dir already exists, see s3setup()
497                 }, {
498                         path: "emptydir/",
499                 }, {
500                         path: "emptydir//",
501                 }, {
502                         path: "newdir/",
503                 }, {
504                         path: "newdir//",
505                 }, {
506                         path: "/",
507                 }, {
508                         path: "//",
509                 }, {
510                         path: "",
511                 },
512         } {
513                 trial := trial
514                 wg.Add(1)
515                 go func() {
516                         defer wg.Done()
517                         c.Logf("=== %v", trial)
518
519                         objname := prefix + trial.path
520
521                         buf := make([]byte, 1234)
522                         rand.Read(buf)
523
524                         err := bucket.PutReader(objname, bytes.NewReader(buf), int64(len(buf)), "application/octet-stream", s3.Private, s3.Options{})
525                         if !c.Check(err, check.ErrorMatches, `(invalid object name.*|open ".*" failed.*|object name conflicts with existing object|Missing object name in PUT request.)`, check.Commentf("PUT %q should fail", objname)) {
526                                 return
527                         }
528
529                         if objname != "" && objname != "/" {
530                                 _, err = bucket.GetReader(objname)
531                                 c.Check(err.(*s3.Error).StatusCode, check.Equals, 404)
532                                 c.Check(err.(*s3.Error).Code, check.Equals, `NoSuchKey`)
533                                 c.Check(err, check.ErrorMatches, `The specified key does not exist.`, check.Commentf("GET %q should return 404", objname))
534                         }
535                 }()
536         }
537         wg.Wait()
538 }
539
540 func (stage *s3stage) writeBigDirs(c *check.C, dirs int, filesPerDir int) {
541         fs, err := stage.coll.FileSystem(stage.arv, stage.kc)
542         c.Assert(err, check.IsNil)
543         for d := 0; d < dirs; d++ {
544                 dir := fmt.Sprintf("dir%d", d)
545                 c.Assert(fs.Mkdir(dir, 0755), check.IsNil)
546                 for i := 0; i < filesPerDir; i++ {
547                         f, err := fs.OpenFile(fmt.Sprintf("%s/file%d.txt", dir, i), os.O_CREATE|os.O_WRONLY, 0644)
548                         c.Assert(err, check.IsNil)
549                         c.Assert(f.Close(), check.IsNil)
550                 }
551         }
552         c.Assert(fs.Sync(), check.IsNil)
553 }
554
555 func (s *IntegrationSuite) sign(c *check.C, req *http.Request, key, secret string) {
556         scope := "20200202/zzzzz/service/aws4_request"
557         signedHeaders := "date"
558         req.Header.Set("Date", time.Now().UTC().Format(time.RFC1123))
559         stringToSign, err := s3stringToSign(s3SignAlgorithm, scope, signedHeaders, req)
560         c.Assert(err, check.IsNil)
561         sig, err := s3signature(secret, scope, signedHeaders, stringToSign)
562         c.Assert(err, check.IsNil)
563         req.Header.Set("Authorization", s3SignAlgorithm+" Credential="+key+"/"+scope+", SignedHeaders="+signedHeaders+", Signature="+sig)
564 }
565
566 func (s *IntegrationSuite) TestS3VirtualHostStyleRequests(c *check.C) {
567         stage := s.s3setup(c)
568         defer stage.teardown(c)
569         for _, trial := range []struct {
570                 url            string
571                 method         string
572                 body           string
573                 responseCode   int
574                 responseRegexp []string
575         }{
576                 {
577                         url:            "https://" + stage.collbucket.Name + ".example.com/",
578                         method:         "GET",
579                         responseCode:   http.StatusOK,
580                         responseRegexp: []string{`(?ms).*sailboat\.txt.*`},
581                 },
582                 {
583                         url:            "https://" + strings.Replace(stage.coll.PortableDataHash, "+", "-", -1) + ".example.com/",
584                         method:         "GET",
585                         responseCode:   http.StatusOK,
586                         responseRegexp: []string{`(?ms).*sailboat\.txt.*`},
587                 },
588                 {
589                         url:            "https://" + stage.projbucket.Name + ".example.com/?prefix=" + stage.coll.Name + "/&delimiter=/",
590                         method:         "GET",
591                         responseCode:   http.StatusOK,
592                         responseRegexp: []string{`(?ms).*sailboat\.txt.*`},
593                 },
594                 {
595                         url:            "https://" + stage.projbucket.Name + ".example.com/" + stage.coll.Name + "/sailboat.txt",
596                         method:         "GET",
597                         responseCode:   http.StatusOK,
598                         responseRegexp: []string{`⛵\n`},
599                 },
600                 {
601                         url:          "https://" + stage.projbucket.Name + ".example.com/" + stage.coll.Name + "/beep",
602                         method:       "PUT",
603                         body:         "boop",
604                         responseCode: http.StatusOK,
605                 },
606                 {
607                         url:            "https://" + stage.projbucket.Name + ".example.com/" + stage.coll.Name + "/beep",
608                         method:         "GET",
609                         responseCode:   http.StatusOK,
610                         responseRegexp: []string{`boop`},
611                 },
612                 {
613                         url:          "https://" + stage.projbucket.Name + ".example.com/" + stage.coll.Name + "//boop",
614                         method:       "GET",
615                         responseCode: http.StatusNotFound,
616                 },
617                 {
618                         url:          "https://" + stage.projbucket.Name + ".example.com/" + stage.coll.Name + "//boop",
619                         method:       "PUT",
620                         body:         "boop",
621                         responseCode: http.StatusOK,
622                 },
623                 {
624                         url:            "https://" + stage.projbucket.Name + ".example.com/" + stage.coll.Name + "//boop",
625                         method:         "GET",
626                         responseCode:   http.StatusOK,
627                         responseRegexp: []string{`boop`},
628                 },
629         } {
630                 url, err := url.Parse(trial.url)
631                 c.Assert(err, check.IsNil)
632                 req, err := http.NewRequest(trial.method, url.String(), bytes.NewReader([]byte(trial.body)))
633                 c.Assert(err, check.IsNil)
634                 s.sign(c, req, arvadostest.ActiveTokenUUID, arvadostest.ActiveToken)
635                 rr := httptest.NewRecorder()
636                 s.handler.ServeHTTP(rr, req)
637                 resp := rr.Result()
638                 c.Check(resp.StatusCode, check.Equals, trial.responseCode)
639                 body, err := ioutil.ReadAll(resp.Body)
640                 c.Assert(err, check.IsNil)
641                 for _, re := range trial.responseRegexp {
642                         c.Check(string(body), check.Matches, re)
643                 }
644         }
645 }
646
647 func (s *IntegrationSuite) TestS3NormalizeURIForSignature(c *check.C) {
648         stage := s.s3setup(c)
649         defer stage.teardown(c)
650         for _, trial := range []struct {
651                 rawPath        string
652                 normalizedPath string
653         }{
654                 {"/foo", "/foo"},                           // boring case
655                 {"/foo%5fbar", "/foo_bar"},                 // _ must not be escaped
656                 {"/foo%2fbar", "/foo/bar"},                 // / must not be escaped
657                 {"/(foo)/[];,", "/%28foo%29/%5B%5D%3B%2C"}, // ()[];, must be escaped
658                 {"/foo%5bbar", "/foo%5Bbar"},               // %XX must be uppercase
659                 {"//foo///.bar", "/foo/.bar"},              // "//" and "///" must be squashed to "/"
660         } {
661                 c.Logf("trial %q", trial)
662
663                 date := time.Now().UTC().Format("20060102T150405Z")
664                 scope := "20200202/zzzzz/S3/aws4_request"
665                 canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", "GET", trial.normalizedPath, "", "host:host.example.com\n", "host", "")
666                 c.Logf("canonicalRequest %q", canonicalRequest)
667                 expect := fmt.Sprintf("%s\n%s\n%s\n%s", s3SignAlgorithm, date, scope, hashdigest(sha256.New(), canonicalRequest))
668                 c.Logf("expected stringToSign %q", expect)
669
670                 req, err := http.NewRequest("GET", "https://host.example.com"+trial.rawPath, nil)
671                 req.Header.Set("X-Amz-Date", date)
672                 req.Host = "host.example.com"
673                 c.Assert(err, check.IsNil)
674
675                 obtained, err := s3stringToSign(s3SignAlgorithm, scope, "host", req)
676                 if !c.Check(err, check.IsNil) {
677                         continue
678                 }
679                 c.Check(obtained, check.Equals, expect)
680         }
681 }
682
683 func (s *IntegrationSuite) TestS3GetBucketLocation(c *check.C) {
684         stage := s.s3setup(c)
685         defer stage.teardown(c)
686         for _, bucket := range []*s3.Bucket{stage.collbucket, stage.projbucket} {
687                 req, err := http.NewRequest("GET", bucket.URL("/"), nil)
688                 c.Check(err, check.IsNil)
689                 req.Header.Set("Authorization", "AWS "+arvadostest.ActiveTokenV2+":none")
690                 req.URL.RawQuery = "location"
691                 resp, err := http.DefaultClient.Do(req)
692                 c.Assert(err, check.IsNil)
693                 c.Check(resp.Header.Get("Content-Type"), check.Equals, "application/xml")
694                 buf, err := ioutil.ReadAll(resp.Body)
695                 c.Assert(err, check.IsNil)
696                 c.Check(string(buf), check.Equals, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<LocationConstraint><LocationConstraint xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">zzzzz</LocationConstraint></LocationConstraint>\n")
697         }
698 }
699
700 func (s *IntegrationSuite) TestS3GetBucketVersioning(c *check.C) {
701         stage := s.s3setup(c)
702         defer stage.teardown(c)
703         for _, bucket := range []*s3.Bucket{stage.collbucket, stage.projbucket} {
704                 req, err := http.NewRequest("GET", bucket.URL("/"), nil)
705                 c.Check(err, check.IsNil)
706                 req.Header.Set("Authorization", "AWS "+arvadostest.ActiveTokenV2+":none")
707                 req.URL.RawQuery = "versioning"
708                 resp, err := http.DefaultClient.Do(req)
709                 c.Assert(err, check.IsNil)
710                 c.Check(resp.Header.Get("Content-Type"), check.Equals, "application/xml")
711                 buf, err := ioutil.ReadAll(resp.Body)
712                 c.Assert(err, check.IsNil)
713                 c.Check(string(buf), check.Equals, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<VersioningConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"/>\n")
714         }
715 }
716
717 func (s *IntegrationSuite) TestS3UnsupportedAPIs(c *check.C) {
718         stage := s.s3setup(c)
719         defer stage.teardown(c)
720         for _, trial := range []struct {
721                 method   string
722                 path     string
723                 rawquery string
724         }{
725                 {"GET", "/", "acl&versionId=1234"},    // GetBucketAcl
726                 {"GET", "/foo", "acl&versionId=1234"}, // GetObjectAcl
727                 {"PUT", "/", "acl"},                   // PutBucketAcl
728                 {"PUT", "/foo", "acl"},                // PutObjectAcl
729                 {"DELETE", "/", "tagging"},            // DeleteBucketTagging
730                 {"DELETE", "/foo", "tagging"},         // DeleteObjectTagging
731         } {
732                 for _, bucket := range []*s3.Bucket{stage.collbucket, stage.projbucket} {
733                         c.Logf("trial %v bucket %v", trial, bucket)
734                         req, err := http.NewRequest(trial.method, bucket.URL(trial.path), nil)
735                         c.Check(err, check.IsNil)
736                         req.Header.Set("Authorization", "AWS "+arvadostest.ActiveTokenV2+":none")
737                         req.URL.RawQuery = trial.rawquery
738                         resp, err := http.DefaultClient.Do(req)
739                         c.Assert(err, check.IsNil)
740                         c.Check(resp.Header.Get("Content-Type"), check.Equals, "application/xml")
741                         buf, err := ioutil.ReadAll(resp.Body)
742                         c.Assert(err, check.IsNil)
743                         c.Check(string(buf), check.Matches, "(?ms).*InvalidRequest.*API not supported.*")
744                 }
745         }
746 }
747
748 // If there are no CommonPrefixes entries, the CommonPrefixes XML tag
749 // should not appear at all.
750 func (s *IntegrationSuite) TestS3ListNoCommonPrefixes(c *check.C) {
751         stage := s.s3setup(c)
752         defer stage.teardown(c)
753
754         req, err := http.NewRequest("GET", stage.collbucket.URL("/"), nil)
755         c.Assert(err, check.IsNil)
756         req.Header.Set("Authorization", "AWS "+arvadostest.ActiveTokenV2+":none")
757         req.URL.RawQuery = "prefix=asdfasdfasdf&delimiter=/"
758         resp, err := http.DefaultClient.Do(req)
759         c.Assert(err, check.IsNil)
760         buf, err := ioutil.ReadAll(resp.Body)
761         c.Assert(err, check.IsNil)
762         c.Check(string(buf), check.Not(check.Matches), `(?ms).*CommonPrefixes.*`)
763 }
764
765 // If there is no delimiter in the request, or the results are not
766 // truncated, the NextMarker XML tag should not appear in the response
767 // body.
768 func (s *IntegrationSuite) TestS3ListNoNextMarker(c *check.C) {
769         stage := s.s3setup(c)
770         defer stage.teardown(c)
771
772         for _, query := range []string{"prefix=e&delimiter=/", ""} {
773                 req, err := http.NewRequest("GET", stage.collbucket.URL("/"), nil)
774                 c.Assert(err, check.IsNil)
775                 req.Header.Set("Authorization", "AWS "+arvadostest.ActiveTokenV2+":none")
776                 req.URL.RawQuery = query
777                 resp, err := http.DefaultClient.Do(req)
778                 c.Assert(err, check.IsNil)
779                 buf, err := ioutil.ReadAll(resp.Body)
780                 c.Assert(err, check.IsNil)
781                 c.Check(string(buf), check.Not(check.Matches), `(?ms).*NextMarker.*`)
782         }
783 }
784
785 // List response should include KeyCount field.
786 func (s *IntegrationSuite) TestS3ListKeyCount(c *check.C) {
787         stage := s.s3setup(c)
788         defer stage.teardown(c)
789
790         req, err := http.NewRequest("GET", stage.collbucket.URL("/"), nil)
791         c.Assert(err, check.IsNil)
792         req.Header.Set("Authorization", "AWS "+arvadostest.ActiveTokenV2+":none")
793         req.URL.RawQuery = "prefix=&delimiter=/"
794         resp, err := http.DefaultClient.Do(req)
795         c.Assert(err, check.IsNil)
796         buf, err := ioutil.ReadAll(resp.Body)
797         c.Assert(err, check.IsNil)
798         c.Check(string(buf), check.Matches, `(?ms).*<KeyCount>2</KeyCount>.*`)
799 }
800
801 func (s *IntegrationSuite) TestS3CollectionList(c *check.C) {
802         stage := s.s3setup(c)
803         defer stage.teardown(c)
804
805         var markers int
806         for markers, s.handler.Cluster.Collections.S3FolderObjects = range []bool{false, true} {
807                 dirs := 2
808                 filesPerDir := 1001
809                 stage.writeBigDirs(c, dirs, filesPerDir)
810                 // Total # objects is:
811                 //                 2 file entries from s3setup (emptyfile and sailboat.txt)
812                 //                +1 fake "directory" marker from s3setup (emptydir) (if enabled)
813                 //             +dirs fake "directory" marker from writeBigDirs (dir0/, dir1/) (if enabled)
814                 // +filesPerDir*dirs file entries from writeBigDirs (dir0/file0.txt, etc.)
815                 s.testS3List(c, stage.collbucket, "", 4000, markers+2+(filesPerDir+markers)*dirs)
816                 s.testS3List(c, stage.collbucket, "", 131, markers+2+(filesPerDir+markers)*dirs)
817                 s.testS3List(c, stage.collbucket, "dir0/", 71, filesPerDir+markers)
818         }
819 }
820 func (s *IntegrationSuite) testS3List(c *check.C, bucket *s3.Bucket, prefix string, pageSize, expectFiles int) {
821         c.Logf("testS3List: prefix=%q pageSize=%d S3FolderObjects=%v", prefix, pageSize, s.handler.Cluster.Collections.S3FolderObjects)
822         expectPageSize := pageSize
823         if expectPageSize > 1000 {
824                 expectPageSize = 1000
825         }
826         gotKeys := map[string]s3.Key{}
827         nextMarker := ""
828         pages := 0
829         for {
830                 resp, err := bucket.List(prefix, "", nextMarker, pageSize)
831                 if !c.Check(err, check.IsNil) {
832                         break
833                 }
834                 c.Check(len(resp.Contents) <= expectPageSize, check.Equals, true)
835                 if pages++; !c.Check(pages <= (expectFiles/expectPageSize)+1, check.Equals, true) {
836                         break
837                 }
838                 for _, key := range resp.Contents {
839                         gotKeys[key.Key] = key
840                         if strings.Contains(key.Key, "sailboat.txt") {
841                                 c.Check(key.Size, check.Equals, int64(4))
842                         }
843                 }
844                 if !resp.IsTruncated {
845                         c.Check(resp.NextMarker, check.Equals, "")
846                         break
847                 }
848                 if !c.Check(resp.NextMarker, check.Not(check.Equals), "") {
849                         break
850                 }
851                 nextMarker = resp.NextMarker
852         }
853         c.Check(len(gotKeys), check.Equals, expectFiles)
854 }
855
856 func (s *IntegrationSuite) TestS3CollectionListRollup(c *check.C) {
857         for _, s.handler.Cluster.Collections.S3FolderObjects = range []bool{false, true} {
858                 s.testS3CollectionListRollup(c)
859         }
860 }
861
862 func (s *IntegrationSuite) testS3CollectionListRollup(c *check.C) {
863         stage := s.s3setup(c)
864         defer stage.teardown(c)
865
866         dirs := 2
867         filesPerDir := 500
868         stage.writeBigDirs(c, dirs, filesPerDir)
869         err := stage.collbucket.PutReader("dingbats", &bytes.Buffer{}, 0, "application/octet-stream", s3.Private, s3.Options{})
870         c.Assert(err, check.IsNil)
871         var allfiles []string
872         for marker := ""; ; {
873                 resp, err := stage.collbucket.List("", "", marker, 20000)
874                 c.Check(err, check.IsNil)
875                 for _, key := range resp.Contents {
876                         if len(allfiles) == 0 || allfiles[len(allfiles)-1] != key.Key {
877                                 allfiles = append(allfiles, key.Key)
878                         }
879                 }
880                 marker = resp.NextMarker
881                 if marker == "" {
882                         break
883                 }
884         }
885         markers := 0
886         if s.handler.Cluster.Collections.S3FolderObjects {
887                 markers = 1
888         }
889         c.Check(allfiles, check.HasLen, dirs*(filesPerDir+markers)+3+markers)
890
891         gotDirMarker := map[string]bool{}
892         for _, name := range allfiles {
893                 isDirMarker := strings.HasSuffix(name, "/")
894                 if markers == 0 {
895                         c.Check(isDirMarker, check.Equals, false, check.Commentf("name %q", name))
896                 } else if isDirMarker {
897                         gotDirMarker[name] = true
898                 } else if i := strings.LastIndex(name, "/"); i >= 0 {
899                         c.Check(gotDirMarker[name[:i+1]], check.Equals, true, check.Commentf("name %q", name))
900                         gotDirMarker[name[:i+1]] = true // skip redundant complaints about this dir marker
901                 }
902         }
903
904         for _, trial := range []struct {
905                 prefix    string
906                 delimiter string
907                 marker    string
908         }{
909                 {"", "", ""},
910                 {"di", "/", ""},
911                 {"di", "r", ""},
912                 {"di", "n", ""},
913                 {"dir0", "/", ""},
914                 {"dir0/", "/", ""},
915                 {"dir0/f", "/", ""},
916                 {"dir0", "", ""},
917                 {"dir0/", "", ""},
918                 {"dir0/f", "", ""},
919                 {"dir0", "/", "dir0/file14.txt"},       // no commonprefixes
920                 {"", "", "dir0/file14.txt"},            // middle page, skip walking dir1
921                 {"", "", "dir1/file14.txt"},            // middle page, skip walking dir0
922                 {"", "", "dir1/file498.txt"},           // last page of results
923                 {"dir1/file", "", "dir1/file498.txt"},  // last page of results, with prefix
924                 {"dir1/file", "/", "dir1/file498.txt"}, // last page of results, with prefix + delimiter
925                 {"dir1", "Z", "dir1/file498.txt"},      // delimiter "Z" never appears
926                 {"dir2", "/", ""},                      // prefix "dir2" does not exist
927                 {"", "/", ""},
928         } {
929                 c.Logf("\n\n=== trial %+v markers=%d", trial, markers)
930
931                 maxKeys := 20
932                 resp, err := stage.collbucket.List(trial.prefix, trial.delimiter, trial.marker, maxKeys)
933                 c.Check(err, check.IsNil)
934                 if resp.IsTruncated && trial.delimiter == "" {
935                         // goamz List method fills in the missing
936                         // NextMarker field if resp.IsTruncated, so
937                         // now we can't really tell whether it was
938                         // sent by the server or by goamz. In cases
939                         // where it should be empty but isn't, assume
940                         // it's goamz's fault.
941                         resp.NextMarker = ""
942                 }
943
944                 var expectKeys []string
945                 var expectPrefixes []string
946                 var expectNextMarker string
947                 var expectTruncated bool
948                 for _, key := range allfiles {
949                         full := len(expectKeys)+len(expectPrefixes) >= maxKeys
950                         if !strings.HasPrefix(key, trial.prefix) || key < trial.marker {
951                                 continue
952                         } else if idx := strings.Index(key[len(trial.prefix):], trial.delimiter); trial.delimiter != "" && idx >= 0 {
953                                 prefix := key[:len(trial.prefix)+idx+1]
954                                 if len(expectPrefixes) > 0 && expectPrefixes[len(expectPrefixes)-1] == prefix {
955                                         // same prefix as previous key
956                                 } else if full {
957                                         expectNextMarker = key
958                                         expectTruncated = true
959                                 } else {
960                                         expectPrefixes = append(expectPrefixes, prefix)
961                                 }
962                         } else if full {
963                                 if trial.delimiter != "" {
964                                         expectNextMarker = key
965                                 }
966                                 expectTruncated = true
967                                 break
968                         } else {
969                                 expectKeys = append(expectKeys, key)
970                         }
971                 }
972
973                 var gotKeys []string
974                 for _, key := range resp.Contents {
975                         gotKeys = append(gotKeys, key.Key)
976                 }
977                 var gotPrefixes []string
978                 for _, prefix := range resp.CommonPrefixes {
979                         gotPrefixes = append(gotPrefixes, prefix)
980                 }
981                 commentf := check.Commentf("trial %+v markers=%d", trial, markers)
982                 c.Check(gotKeys, check.DeepEquals, expectKeys, commentf)
983                 c.Check(gotPrefixes, check.DeepEquals, expectPrefixes, commentf)
984                 c.Check(resp.NextMarker, check.Equals, expectNextMarker, commentf)
985                 c.Check(resp.IsTruncated, check.Equals, expectTruncated, commentf)
986                 c.Logf("=== trial %+v keys %q prefixes %q nextMarker %q", trial, gotKeys, gotPrefixes, resp.NextMarker)
987         }
988 }
989
990 func (s *IntegrationSuite) TestS3ListObjectsV2(c *check.C) {
991         stage := s.s3setup(c)
992         defer stage.teardown(c)
993         dirs := 2
994         filesPerDir := 40
995         stage.writeBigDirs(c, dirs, filesPerDir)
996
997         sess := aws_session.Must(aws_session.NewSession(&aws_aws.Config{
998                 Region:           aws_aws.String("auto"),
999                 Endpoint:         aws_aws.String(s.testServer.URL),
1000                 Credentials:      aws_credentials.NewStaticCredentials(url.QueryEscape(arvadostest.ActiveTokenV2), url.QueryEscape(arvadostest.ActiveTokenV2), ""),
1001                 S3ForcePathStyle: aws_aws.Bool(true),
1002         }))
1003
1004         stringOrNil := func(s string) *string {
1005                 if s == "" {
1006                         return nil
1007                 } else {
1008                         return &s
1009                 }
1010         }
1011
1012         client := aws_s3.New(sess)
1013         ctx := context.Background()
1014
1015         for _, trial := range []struct {
1016                 prefix               string
1017                 delimiter            string
1018                 startAfter           string
1019                 maxKeys              int
1020                 expectKeys           int
1021                 expectCommonPrefixes map[string]bool
1022         }{
1023                 {
1024                         // Expect {filesPerDir plus the dir itself}
1025                         // for each dir, plus emptydir, emptyfile, and
1026                         // sailboat.txt.
1027                         expectKeys: (filesPerDir+1)*dirs + 3,
1028                 },
1029                 {
1030                         maxKeys:    15,
1031                         expectKeys: (filesPerDir+1)*dirs + 3,
1032                 },
1033                 {
1034                         startAfter: "dir0/z",
1035                         maxKeys:    15,
1036                         // Expect {filesPerDir plus the dir itself}
1037                         // for each dir except dir0, plus emptydir,
1038                         // emptyfile, and sailboat.txt.
1039                         expectKeys: (filesPerDir+1)*(dirs-1) + 3,
1040                 },
1041                 {
1042                         maxKeys:              1,
1043                         delimiter:            "/",
1044                         expectKeys:           2, // emptyfile, sailboat.txt
1045                         expectCommonPrefixes: map[string]bool{"dir0/": true, "dir1/": true, "emptydir/": true},
1046                 },
1047                 {
1048                         startAfter:           "dir0/z",
1049                         maxKeys:              15,
1050                         delimiter:            "/",
1051                         expectKeys:           2, // emptyfile, sailboat.txt
1052                         expectCommonPrefixes: map[string]bool{"dir1/": true, "emptydir/": true},
1053                 },
1054                 {
1055                         startAfter:           "dir0/file10.txt",
1056                         maxKeys:              15,
1057                         delimiter:            "/",
1058                         expectKeys:           2,
1059                         expectCommonPrefixes: map[string]bool{"dir0/": true, "dir1/": true, "emptydir/": true},
1060                 },
1061                 {
1062                         startAfter:           "dir0/file10.txt",
1063                         maxKeys:              15,
1064                         prefix:               "d",
1065                         delimiter:            "/",
1066                         expectKeys:           0,
1067                         expectCommonPrefixes: map[string]bool{"dir0/": true, "dir1/": true},
1068                 },
1069         } {
1070                 c.Logf("[trial %+v]", trial)
1071                 params := aws_s3.ListObjectsV2Input{
1072                         Bucket:     aws_aws.String(stage.collbucket.Name),
1073                         Prefix:     stringOrNil(trial.prefix),
1074                         Delimiter:  stringOrNil(trial.delimiter),
1075                         StartAfter: stringOrNil(trial.startAfter),
1076                         MaxKeys:    aws_aws.Int64(int64(trial.maxKeys)),
1077                 }
1078                 keySeen := map[string]bool{}
1079                 prefixSeen := map[string]bool{}
1080                 for {
1081                         result, err := client.ListObjectsV2WithContext(ctx, &params)
1082                         if !c.Check(err, check.IsNil) {
1083                                 break
1084                         }
1085                         c.Check(result.Name, check.DeepEquals, aws_aws.String(stage.collbucket.Name))
1086                         c.Check(result.Prefix, check.DeepEquals, aws_aws.String(trial.prefix))
1087                         c.Check(result.Delimiter, check.DeepEquals, aws_aws.String(trial.delimiter))
1088                         // The following two fields are expected to be
1089                         // nil (i.e., no tag in XML response) rather
1090                         // than "" when the corresponding request
1091                         // field was empty or nil.
1092                         c.Check(result.StartAfter, check.DeepEquals, stringOrNil(trial.startAfter))
1093                         c.Check(result.ContinuationToken, check.DeepEquals, params.ContinuationToken)
1094
1095                         if trial.maxKeys > 0 {
1096                                 c.Check(result.MaxKeys, check.DeepEquals, aws_aws.Int64(int64(trial.maxKeys)))
1097                                 c.Check(len(result.Contents)+len(result.CommonPrefixes) <= trial.maxKeys, check.Equals, true)
1098                         } else {
1099                                 c.Check(result.MaxKeys, check.DeepEquals, aws_aws.Int64(int64(s3MaxKeys)))
1100                         }
1101
1102                         for _, ent := range result.Contents {
1103                                 c.Assert(ent.Key, check.NotNil)
1104                                 c.Check(*ent.Key > trial.startAfter, check.Equals, true)
1105                                 c.Check(keySeen[*ent.Key], check.Equals, false, check.Commentf("dup key %q", *ent.Key))
1106                                 keySeen[*ent.Key] = true
1107                         }
1108                         for _, ent := range result.CommonPrefixes {
1109                                 c.Assert(ent.Prefix, check.NotNil)
1110                                 c.Check(strings.HasSuffix(*ent.Prefix, trial.delimiter), check.Equals, true, check.Commentf("bad CommonPrefix %q", *ent.Prefix))
1111                                 if strings.HasPrefix(trial.startAfter, *ent.Prefix) {
1112                                         // If we asked for
1113                                         // startAfter=dir0/file10.txt,
1114                                         // we expect dir0/ to be
1115                                         // returned as a common prefix
1116                                 } else {
1117                                         c.Check(*ent.Prefix > trial.startAfter, check.Equals, true)
1118                                 }
1119                                 c.Check(prefixSeen[*ent.Prefix], check.Equals, false, check.Commentf("dup common prefix %q", *ent.Prefix))
1120                                 prefixSeen[*ent.Prefix] = true
1121                         }
1122                         if *result.IsTruncated && c.Check(result.NextContinuationToken, check.Not(check.Equals), "") {
1123                                 params.ContinuationToken = aws_aws.String(*result.NextContinuationToken)
1124                         } else {
1125                                 break
1126                         }
1127                 }
1128                 c.Check(keySeen, check.HasLen, trial.expectKeys)
1129                 c.Check(prefixSeen, check.HasLen, len(trial.expectCommonPrefixes))
1130                 if len(trial.expectCommonPrefixes) > 0 {
1131                         c.Check(prefixSeen, check.DeepEquals, trial.expectCommonPrefixes)
1132                 }
1133         }
1134 }
1135
1136 func (s *IntegrationSuite) TestS3ListObjectsV2EncodingTypeURL(c *check.C) {
1137         stage := s.s3setup(c)
1138         defer stage.teardown(c)
1139         dirs := 2
1140         filesPerDir := 40
1141         stage.writeBigDirs(c, dirs, filesPerDir)
1142
1143         sess := aws_session.Must(aws_session.NewSession(&aws_aws.Config{
1144                 Region:           aws_aws.String("auto"),
1145                 Endpoint:         aws_aws.String(s.testServer.URL),
1146                 Credentials:      aws_credentials.NewStaticCredentials(url.QueryEscape(arvadostest.ActiveTokenV2), url.QueryEscape(arvadostest.ActiveTokenV2), ""),
1147                 S3ForcePathStyle: aws_aws.Bool(true),
1148         }))
1149
1150         client := aws_s3.New(sess)
1151         ctx := context.Background()
1152
1153         result, err := client.ListObjectsV2WithContext(ctx, &aws_s3.ListObjectsV2Input{
1154                 Bucket:       aws_aws.String(stage.collbucket.Name),
1155                 Prefix:       aws_aws.String("dir0/"),
1156                 Delimiter:    aws_aws.String("/"),
1157                 StartAfter:   aws_aws.String("dir0/"),
1158                 EncodingType: aws_aws.String("url"),
1159         })
1160         c.Assert(err, check.IsNil)
1161         c.Check(*result.Prefix, check.Equals, "dir0%2F")
1162         c.Check(*result.Delimiter, check.Equals, "%2F")
1163         c.Check(*result.StartAfter, check.Equals, "dir0%2F")
1164         for _, ent := range result.Contents {
1165                 c.Check(*ent.Key, check.Matches, "dir0%2F.*")
1166         }
1167         result, err = client.ListObjectsV2WithContext(ctx, &aws_s3.ListObjectsV2Input{
1168                 Bucket:       aws_aws.String(stage.collbucket.Name),
1169                 Delimiter:    aws_aws.String("/"),
1170                 EncodingType: aws_aws.String("url"),
1171         })
1172         c.Assert(err, check.IsNil)
1173         c.Check(*result.Delimiter, check.Equals, "%2F")
1174         c.Check(result.CommonPrefixes, check.HasLen, dirs+1)
1175         for _, ent := range result.CommonPrefixes {
1176                 c.Check(*ent.Prefix, check.Matches, ".*%2F")
1177         }
1178 }
1179
1180 // TestS3cmd checks compatibility with the s3cmd command line tool, if
1181 // it's installed. As of Debian buster, s3cmd is only in backports, so
1182 // `arvados-server install` don't install it, and this test skips if
1183 // it's not installed.
1184 func (s *IntegrationSuite) TestS3cmd(c *check.C) {
1185         if _, err := exec.LookPath("s3cmd"); err != nil {
1186                 c.Skip("s3cmd not found")
1187                 return
1188         }
1189
1190         stage := s.s3setup(c)
1191         defer stage.teardown(c)
1192
1193         cmd := exec.Command("s3cmd", "--no-ssl", "--host="+s.testServer.URL[7:], "--host-bucket="+s.testServer.URL[7:], "--access_key="+arvadostest.ActiveTokenUUID, "--secret_key="+arvadostest.ActiveToken, "ls", "s3://"+arvadostest.FooCollection)
1194         buf, err := cmd.CombinedOutput()
1195         c.Check(err, check.IsNil)
1196         c.Check(string(buf), check.Matches, `.* 3 +s3://`+arvadostest.FooCollection+`/foo\n`)
1197
1198         // This tests whether s3cmd's path normalization agrees with
1199         // keep-web's signature verification wrt chars like "|"
1200         // (neither reserved nor unreserved) and "," (not normally
1201         // percent-encoded in a path).
1202         tmpfile := c.MkDir() + "/dstfile"
1203         cmd = exec.Command("s3cmd", "--no-ssl", "--host="+s.testServer.URL[7:], "--host-bucket="+s.testServer.URL[7:], "--access_key="+arvadostest.ActiveTokenUUID, "--secret_key="+arvadostest.ActiveToken, "get", "s3://"+arvadostest.FooCollection+"/foo,;$[|]bar", tmpfile)
1204         buf, err = cmd.CombinedOutput()
1205         c.Check(err, check.NotNil)
1206         c.Check(string(buf), check.Matches, `(?ms).*NoSuchKey.*\n`)
1207 }
1208
1209 func (s *IntegrationSuite) TestS3BucketInHost(c *check.C) {
1210         stage := s.s3setup(c)
1211         defer stage.teardown(c)
1212
1213         hdr, body, _ := s.runCurl(c, "AWS "+arvadostest.ActiveTokenV2+":none", stage.coll.UUID+".collections.example.com", "/sailboat.txt")
1214         c.Check(hdr, check.Matches, `(?s)HTTP/1.1 200 OK\r\n.*`)
1215         c.Check(body, check.Equals, "⛵\n")
1216 }