diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cca4227..9e8c070a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Changes: Bugfixes: - When using a Swift source, pseudo-directories are now recognized and transferred correctly. +- When uploading a segmented object to the target, expiration dates are now also applied to the segments. + If you used an older version of swift-http-import to transfer files with expiration dates using segmented uploading, + you will have to clean up those segments manually once the objects themselves have expired. # v2.4.0 (2018-06-14) diff --git a/pkg/objects/file.go b/pkg/objects/file.go index 1f37f669..9237a64e 100644 --- a/pkg/objects/file.go +++ b/pkg/objects/file.go @@ -278,7 +278,11 @@ func (f File) uploadLargeObject(body io.Reader, hdr schwift.ObjectHeaders, clean DeleteSegments: cleanupOldSegments, }) if err == nil { - err = lo.Append(body, int64(f.Job.Segmenting.SegmentSize)) + XDeleteAtHeader := schwift.NewObjectHeaders() + if hdr.ExpiresAt().Exists() { + XDeleteAtHeader.ExpiresAt().Set(hdr.ExpiresAt().Get()) + } + err = lo.Append(body, int64(f.Job.Segmenting.SegmentSize), XDeleteAtHeader.ToOpts()) } if err == nil { err = lo.WriteManifest(hdr.ToOpts()) diff --git a/tests.sh b/tests.sh index 1a525221..e9163a44 100755 --- a/tests.sh +++ b/tests.sh @@ -367,6 +367,10 @@ step 'Test 7: Object expiration' upload_file_from_stdin expires.txt -H 'X-Delete-At: 2000000000' <<-EOF This will expire soon. EOF +upload_file_from_stdin expires-with-segments.txt -H 'X-Delete-At: 2000000000' <<-EOF + This will expire soon. + This will expire soon. +EOF if [ "$1" = http ]; then echo ">> Test skipped (works only with Swift source)." @@ -377,21 +381,42 @@ mirror <<-EOF jobs: - from: ${SOURCE_SPEC} to: { container: ${CONTAINER_BASE}-test7 } - only: 'expires.txt' + only: 'expires.*txt' expiration: delay_seconds: 42 + segmenting: + container: ${CONTAINER_BASE}-test7-segments + min_bytes: 30 + segment_bytes: 30 EOF expect test7 <<-EOF +>> expires-with-segments.txt +This will expire soon. +This will expire soon. >> expires.txt This will expire soon. EOF -EXPIRY_TIMESTAMP="$(swift stat ${CONTAINER_BASE}-test7 expires.txt | awk '/X-Delete-At:/ { print $2 }')" -if [ "${EXPIRY_TIMESTAMP}" != 2000000042 ]; then - echo -e "\e[1;31m>>\e[0;31m Expected file to expire at timestamp 2000000042, but expires at timestamp '${EXPIRY_TIMESTAMP}' instead.\e[0m" +for OBJECT_NAME in expires.txt expires-with-segments.txt; do + EXPIRY_TIMESTAMP="$(swift stat ${CONTAINER_BASE}-test7 ${OBJECT_NAME} | awk '/X-Delete-At:/ { print $2 }')" + if [ "${EXPIRY_TIMESTAMP}" != 2000000042 ]; then + echo -e "\e[1;31m>>\e[0;31m Expected file \"${OBJECT_NAME}\" to expire at timestamp 2000000042, but expires at timestamp '${EXPIRY_TIMESTAMP}' instead.\e[0m" + exit 1 + fi +done + +# also check that expiration dates are applied to the segments as well +swift list ${CONTAINER_BASE}-test7-segments | while read OBJECT_NAME; do + EXPIRY_TIMESTAMP="$(swift stat ${CONTAINER_BASE}-test7-segments ${OBJECT_NAME} | awk '/X-Delete-At:/ { print $2 }')" + if [ "${EXPIRY_TIMESTAMP}" != 2000000042 ]; then + echo -e "\e[1;31m>>\e[0;31m Expected segment '${OBJECT_NAME}' to expire at timestamp 2000000042, but expires at timestamp '${EXPIRY_TIMESTAMP}' instead.\e[0m" + exit 1 + fi +done || ( + echo -e "\e[1;31m>>\e[0;31m Expected object 'expires-with-segments.txt' to be an SLO, but it's not segmented.\e[0m" exit 1 -fi +) fi # end of: if [ "$1" = http ] @@ -415,6 +440,7 @@ mirror <<-EOF segment_bytes: 20 # less than job.segmenting.min_bytes, but also more # than the smallest files (to exercise all code paths) to: { container: ${CONTAINER_BASE}-test8 } + except: 'expires-with-segments.txt' segmenting: container: ${CONTAINER_BASE}-test8-segments min_bytes: 30 diff --git a/vendor/github.com/majewsky/schwift/Makefile b/vendor/github.com/majewsky/schwift/Makefile index b93d8f07..277d243f 100644 --- a/vendor/github.com/majewsky/schwift/Makefile +++ b/vendor/github.com/majewsky/schwift/Makefile @@ -21,7 +21,7 @@ COVERPKGS = $(PKG),$(PKG)/gopherschwift # comma-separated list of packages for w static-tests: FORCE @echo '>> gofmt...' - @if s="$$(gofmt -s -l $$(find -name \*.go) 2>/dev/null)" && test -n "$$s"; then echo "$$s"; false; fi + @if s="$$(gofmt -s -l $$(find . -name \*.go) 2>/dev/null)" && test -n "$$s"; then echo "$$s"; false; fi @echo '>> golint...' @if s="$$(golint $(TESTPKGS) 2>/dev/null)" && test -n "$$s"; then echo "$$s"; false; fi @echo '>> govet...' diff --git a/vendor/github.com/majewsky/schwift/README.md b/vendor/github.com/majewsky/schwift/README.md index 970a6bfa..00767a07 100644 --- a/vendor/github.com/majewsky/schwift/README.md +++ b/vendor/github.com/majewsky/schwift/README.md @@ -7,7 +7,7 @@ frustrated with the inflexible API design of [`ncw/swift`](https://github.com/nc bottom](#why-another-swift-client-library) for details. This library is currently in **beta**: It's already used by some projects, and I'm working towards a -stable 1.0 version with API compatbility promises, but [a few things are still +stable 1.0 version with API compatibility promises, but [a few things are still missing](https://github.com/majewsky/schwift/issues/1). ## Installation diff --git a/vendor/github.com/majewsky/schwift/container.go b/vendor/github.com/majewsky/schwift/container.go index 147400d2..4c94f874 100644 --- a/vendor/github.com/majewsky/schwift/container.go +++ b/vendor/github.com/majewsky/schwift/container.go @@ -201,3 +201,12 @@ func (c *Container) EnsureExists() (*Container, error) { func (c *Container) Objects() *ObjectIterator { return &ObjectIterator{Container: c} } + +//URL returns the canonical URL for this container on the server. This is +//particularly useful when the ReadACL on the account or container is set to +//allow anonymous read access. +func (c *Container) URL() (string, error) { + return Request{ + ContainerName: c.name, + }.URL(c.a.backend, nil) +} diff --git a/vendor/github.com/majewsky/schwift/largeobject.go b/vendor/github.com/majewsky/schwift/largeobject.go index 0d22734e..f58828cc 100644 --- a/vendor/github.com/majewsky/schwift/largeobject.go +++ b/vendor/github.com/majewsky/schwift/largeobject.go @@ -661,7 +661,7 @@ func (lo *LargeObject) AddSegment(segment SegmentInfo) error { // //This function uploads segment objects, so it may return any error that //Object.Upload() returns, see documentation over there. -func (lo *LargeObject) Append(contents io.Reader, segmentSizeBytes int64) error { +func (lo *LargeObject) Append(contents io.Reader, segmentSizeBytes int64, opts *RequestOptions) error { if segmentSizeBytes < 0 { panic("segmentSizeBytes may not be negative") } @@ -690,7 +690,7 @@ func (lo *LargeObject) Append(contents io.Reader, segmentSizeBytes int64) error } obj := lo.NextSegmentObject() - err := obj.Upload(&tracker, nil, nil) + err := obj.Upload(&tracker, nil, opts) if err != nil { return err } diff --git a/vendor/github.com/majewsky/schwift/object.go b/vendor/github.com/majewsky/schwift/object.go index 9140bdc6..86697190 100644 --- a/vendor/github.com/majewsky/schwift/object.go +++ b/vendor/github.com/majewsky/schwift/object.go @@ -20,7 +20,9 @@ package schwift import ( "bytes" + "crypto/hmac" "crypto/md5" + "crypto/sha1" "encoding/hex" "fmt" "hash" @@ -28,6 +30,7 @@ import ( "net/http" "net/url" "strings" + "time" ) //Object represents a Swift object. Instances are usually obtained by @@ -295,13 +298,6 @@ func tryComputeContentLength(content io.Reader) *uint64 { return nil } -//This covers both bytes.Reader and strings.Reader in a way that is compatible -//with earlier versions of Go that don't have strings.Reader yet. -type likeBytesReader interface { - io.WriterTo - io.Seeker -} - func tryComputeEtag(content io.Reader, headers ObjectHeaders) { h := headers.Etag() if h.Exists() { @@ -316,11 +312,11 @@ func tryComputeEtag(content io.Reader, headers ObjectHeaders) { //so this one is easy sum := md5.Sum(r.Bytes()) h.Set(hex.EncodeToString(sum[:])) - case likeBytesReader: + case io.ReadSeeker: //bytes.Reader does not have such a method, but it is an io.Seeker, so we //can read the entire thing and then seek back to where we started hash := md5.New() - n, _ := r.WriteTo(hash) + n, _ := io.Copy(hash, r) r.Seek(-n, io.SeekCurrent) h.Set(hex.EncodeToString(hash.Sum(nil))) } @@ -580,3 +576,60 @@ func (o *Object) SymlinkHeaders() (headers ObjectHeaders, target *Object, err er target = targetAccount.Container(fields[0]).Object(fields[1]) return *o.symlinkHeaders, target, nil } + +//URL returns the canonical URL for the object on the server. This is +//particularly useful when the ReadACL on the account or container is set to +//allow anonymous read access. +func (o *Object) URL() (string, error) { + return Request{ + ContainerName: o.c.name, + ObjectName: o.name, + }.URL(o.c.a.backend, nil) +} + +//TempURL is like Object.URL, but includes a token with a limited lifetime (as +//specified by the `expires` argument) that permits anonymous access to this +//object using the given HTTP method. This works only when the tempurl +//middleware is set up on the server, and if the given `key` matches one of the +//tempurl keys for this object's container or account. +// +//For example, if the ReadACL both on the account and container do not permit +//anonymous read access (which is the default behavior): +// +// var o *schwift.Object +// ... +// resp, err := http.Get(o.URL()) +// //After this, resp.StatusCode == 401 (Unauthorized) +// //because anonymous access is forbidden. +// +// //But if the container or account has a tempurl key... +// key := "supersecretkey" +// hdr := NewContainerHeaders() +// hdr.TempURLKey().Set(key) +// c := o.Container() +// err := c.Update(hdr, nil) +// +// //...we can use it to generate temporary URLs. +// url := o.TempURL(key, "GET", time.Now().Add(10 * time.Minute)) +// resp, err := http.Get(url) +// //This time, resp.StatusCode == 200 because the URL includes a token. +// +func (o *Object) TempURL(key, method string, expires time.Time) (string, error) { + urlStr, err := o.URL() + if err != nil { + return "", err + } + u, err := url.Parse(urlStr) + if err != nil { + return "", err + } + + payload := fmt.Sprintf("%s\n%d\n%s", method, expires.Unix(), u.Path) + mac := hmac.New(sha1.New, []byte(key)) + mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + + u.RawQuery = fmt.Sprintf("temp_url_sig=%s&temp_url_expires=%d", + signature, expires.Unix()) + return u.String(), nil +} diff --git a/vendor/github.com/majewsky/schwift/object_test.go b/vendor/github.com/majewsky/schwift/object_test.go new file mode 100644 index 00000000..50bf6223 --- /dev/null +++ b/vendor/github.com/majewsky/schwift/object_test.go @@ -0,0 +1,58 @@ +/****************************************************************************** +* +* Copyright 2018 Stefan Majewsky +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +* +******************************************************************************/ + +package schwift + +import ( + "net/http" + "testing" + "time" +) + +type tempurlBogusBackend struct{} + +func (tempurlBogusBackend) EndpointURL() string { + return "https://example.com/v1/AUTH_example/" +} +func (tempurlBogusBackend) Clone(newEndpointURL string) Backend { + panic("unimplemented") +} +func (tempurlBogusBackend) Do(req *http.Request) (*http.Response, error) { + panic("unimplemented") +} + +func TestObjectTempURL(t *testing.T) { + //setup a bogus backend, account, container and object with exact names to + //reproducibly generate a temp URL + account, err := InitializeAccount(tempurlBogusBackend{}) + if err != nil { + t.Fatal(err.Error()) + } + + actualURL, err := account.Container("foo").Object("bar").TempURL("supersecretkey", "GET", time.Unix(1e9, 0)) + if err != nil { + t.Fatal(err.Error()) + } + + expectedURL := "https://example.com/v1/AUTH_example/foo/bar?temp_url_sig=ed44d92005345aee463c884d76d4850ef6d2778d&temp_url_expires=1000000000" + if actualURL != expectedURL { + t.Error("temp URL generation failed") + t.Logf("expected: %s\n", expectedURL) + t.Logf("actual: %s\n", actualURL) + } +} diff --git a/vendor/github.com/majewsky/schwift/request.go b/vendor/github.com/majewsky/schwift/request.go index 2f86df22..0277f9af 100644 --- a/vendor/github.com/majewsky/schwift/request.go +++ b/vendor/github.com/majewsky/schwift/request.go @@ -19,6 +19,7 @@ package schwift import ( + "context" "io" "io/ioutil" "net/http" @@ -41,6 +42,7 @@ import ( type RequestOptions struct { Headers Headers Values url.Values + Context context.Context } func cloneRequestOptions(orig *RequestOptions, additional Headers) *RequestOptions { @@ -55,6 +57,7 @@ func cloneRequestOptions(orig *RequestOptions, additional Headers) *RequestOptio for k, v := range orig.Values { result.Values[k] = v } + result.Context = orig.Context } for k, v := range additional { result.Headers[k] = v @@ -124,6 +127,9 @@ func (r Request) Do(backend Backend) (*http.Response, error) { for k, v := range r.Options.Headers { req.Header[k] = []string{v} } + if r.Options.Context != nil { + req = req.WithContext(r.Options.Context) + } } if r.Body != nil { req.Header.Set("Expect", "100-continue") diff --git a/vendor/github.com/majewsky/schwift/tests/largeobject_test.go b/vendor/github.com/majewsky/schwift/tests/largeobject_test.go index a8c18e68..1932741a 100644 --- a/vendor/github.com/majewsky/schwift/tests/largeobject_test.go +++ b/vendor/github.com/majewsky/schwift/tests/largeobject_test.go @@ -23,6 +23,7 @@ import ( "fmt" "strings" "testing" + "time" "github.com/majewsky/schwift" ) @@ -52,7 +53,7 @@ func TestLargeObjectsBasic(t *testing.T) { Strategy: strategy, }, nil) expectSuccess(t, err) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1+segment2)), 128)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1+segment2)), 128, nil)) expectSuccess(t, lo.WriteManifest(nil)) expectObjectContent(t, obj, []byte(segment1+segment2)) @@ -74,7 +75,7 @@ func TestLargeObjectsBasic(t *testing.T) { expectSuccess(t, err) expectLargeObjectSetup(t, lo, strategy, fmt.Sprintf("%s/%s-segments/", c.Name(), strategyStr)) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment3+segment4)), 128)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment3+segment4)), 128, nil)) expectSuccess(t, lo.WriteManifest(nil)) expectObjectContent(t, obj, []byte(segment1+segment2+segment3+segment4)) @@ -118,7 +119,7 @@ func TestLargeObjectsBasic(t *testing.T) { expectSuccess(t, err) expectObjectNames(t, names) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment3+segment4)), 128)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment3+segment4)), 128, nil)) expectSuccess(t, lo.WriteManifest(nil)) expectObjectContent(t, obj, []byte(segment3+segment4)) @@ -139,6 +140,42 @@ func TestLargeObjectsBasic(t *testing.T) { }) } +func TestLargeObjectExpiration(t *testing.T) { + testWithContainer(t, func(c *schwift.Container) { + foreachLargeObjectStrategy(func(strategy schwift.LargeObjectStrategy, strategyStr string) { + segment := getRandomSegmentContent(128) + obj := c.Object(strategyStr + "-largeobject") + lo, err := obj.AsNewLargeObject(schwift.SegmentingOptions{ + SegmentContainer: c, + SegmentPrefix: strategyStr + "-segments/", + Strategy: strategy, + }, nil) + expectSuccess(t, err) + + delay := time.Duration(3600) * time.Second + expirationTime := time.Now().Add(delay) + headers := schwift.NewObjectHeaders() + headers.ExpiresAt().Set(expirationTime) + + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment)), 128, headers.ToOpts())) + expectSuccess(t, lo.WriteManifest(headers.ToOpts())) + + //check object expiration + hdr, err := obj.Headers() + expectSuccess(t, err) + objectExpiration := hdr.ExpiresAt().Get().Format("2006-01-02 15:04:05 +00:00 MST") + expectString(t, objectExpiration, expirationTime.Format("2006-01-02 15:04:05 +00:00 MST")) + + //check segment expiration + hdr, err = c.Object(strategyStr + "-segments/0000000000000001").Headers() + expectSuccess(t, err) + objectExpiration = hdr.ExpiresAt().Get().Format("2006-01-02 15:04:05 +00:00 MST") + expectString(t, objectExpiration, expirationTime.Format("2006-01-02 15:04:05 +00:00 MST")) + + }) + }) +} + func TestTruncateDuringOverwrite(t *testing.T) { foreachLargeObjectStrategy(func(strategy schwift.LargeObjectStrategy, strategyStr string) { testWithContainer(t, func(c *schwift.Container) { @@ -154,8 +191,8 @@ func TestTruncateDuringOverwrite(t *testing.T) { segment1 := getRandomSegmentContent(128) segment2 := getRandomSegmentContent(128) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1)), 0)) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment2)), 0)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1)), 0, nil)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment2)), 0, nil)) expectSuccess(t, lo.WriteManifest(nil)) expectObjectExistence(t, c.Object("segments/0000000000000001"), true) @@ -169,8 +206,8 @@ func TestTruncateDuringOverwrite(t *testing.T) { DeleteSegments: true, }) expectSuccess(t, err) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1)), 0)) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment2)), 0)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1)), 0, nil)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment2)), 0, nil)) expectSuccess(t, lo.WriteManifest(nil)) expectObjectExistence(t, c.Object("segments/0000000000000001"), false) @@ -203,9 +240,9 @@ func TestSLOWithDataSegment(t *testing.T) { dataSegment := schwift.SegmentInfo{Data: []byte("---")} segment2 := getRandomSegmentContent(128) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1)), 0)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1)), 0, nil)) expectSuccess(t, lo.AddSegment(dataSegment)) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment2)), 0)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment2)), 0, nil)) expectSuccess(t, lo.WriteManifest(nil)) expectObjectContent(t, o, []byte(segment1+string(dataSegment.Data)+segment2)) @@ -303,8 +340,8 @@ func TestSLOGuessSegmentPrefix(t *testing.T) { segment1 := getRandomSegmentContent(128) segment2 := getRandomSegmentContent(128) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1)), 0)) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment2)), 0)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1)), 0, nil)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment2)), 0, nil)) expectSuccess(t, lo.WriteManifest(nil)) //now create a fresh SLO and check if it infers the correct SegmentPrefix @@ -330,8 +367,8 @@ func TestDeleteLargeObjectAndKeepSegments(t *testing.T) { segment1 := getRandomSegmentContent(128) segment2 := getRandomSegmentContent(128) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1)), 0)) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment2)), 0)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1)), 0, nil)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment2)), 0, nil)) expectSuccess(t, lo.WriteManifest(nil)) //test deletion that keeps segments @@ -363,8 +400,8 @@ func TestDeleteLargeObjectIncludingSegments(t *testing.T) { segment1 := getRandomSegmentContent(128) segment2 := getRandomSegmentContent(128) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1)), 0)) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment2)), 0)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1)), 0, nil)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment2)), 0, nil)) expectSuccess(t, lo.WriteManifest(nil)) //test deletion that keeps segments @@ -394,8 +431,8 @@ func TestOverwriteLargeObjectAndKeepSegments(t *testing.T) { segment1 := getRandomSegmentContent(128) segment2 := getRandomSegmentContent(128) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1)), 0)) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment2)), 0)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1)), 0, nil)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment2)), 0, nil)) expectSuccess(t, lo.WriteManifest(nil)) //test overwriting that keeps segments @@ -427,8 +464,8 @@ func TestOverwriteLargeObjectIncludingSegments(t *testing.T) { segment1 := getRandomSegmentContent(128) segment2 := getRandomSegmentContent(128) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1)), 0)) - expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment2)), 0)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment1)), 0, nil)) + expectSuccess(t, lo.Append(bytes.NewReader([]byte(segment2)), 0, nil)) expectSuccess(t, lo.WriteManifest(nil)) //test overwriting that deletes segments diff --git a/vendor/pins/github.com_majewsky_schwift b/vendor/pins/github.com_majewsky_schwift index e790b483..74162f67 100644 --- a/vendor/pins/github.com_majewsky_schwift +++ b/vendor/pins/github.com_majewsky_schwift @@ -1 +1 @@ -97bf1aaad66aa180c67bbc375c1f82aca10a5c3a +e1b3d5e2efc913f52df74291c0c90efb346faa0e