@specific.dev/spectest 0.131.7 → 0.131.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,7 +22,10 @@ each part and its ordering, and the composite checksum survives HEAD/GET for
22
22
  reconciliation. Completion conditions use the backend's atomic conditional write.
23
23
  Overwriting an object does not inherit the previous object's checksum metadata.
24
24
  This patch supports SHA256/COMPOSITE; it does not implement every S3 checksum
25
- algorithm or streaming checksum trailers.
25
+ algorithm or streaming checksum trailers. It also supports UploadPartCopy from
26
+ one backend snapshot, including closed byte ranges, exact ETag conditions,
27
+ version selection and SHA256 part receipts. Date-based copy conditions are
28
+ explicitly unsupported (501), rather than silently ignored.
26
29
 
27
30
  Run `./test.sh` from this directory to exercise real HTTP requests against the
28
31
  same patched sources used in the image, including corrupt retries, conditional
@@ -197,3 +197,86 @@ func TestMultipartAbortAndLegacyUpload(t *testing.T) {
197
197
  t.Fatal("legacy multipart changed")
198
198
  }
199
199
  }
200
+
201
+ func TestUploadPartCopyRetainsExactSourceAndRejectsStaleRetry(t *testing.T) {
202
+ p := newProbe(t)
203
+ source := bytes.Repeat([]byte("0123456789"), 600000)
204
+ put := p.request("PUT", "/uploads/staging%20%2B%25.csv", source, nil, 200)
205
+ path := p.start("copied", false)
206
+ headers := map[string]string{"x-amz-copy-source": "/uploads/staging%20%2B%25.csv", "x-amz-copy-source-if-match": put.header.Get("ETag"), "x-amz-copy-source-range": "bytes=3-5242882"}
207
+ res := p.request("PUT", path+"&partNumber=1", nil, headers, 200)
208
+ var part struct {
209
+ XMLName xml.Name
210
+ ETag string
211
+ LastModified string
212
+ }
213
+ if err := xml.Unmarshal(res.body, &part); err != nil || part.XMLName.Local != "CopyPartResult" || part.ETag == "" || part.LastModified == "" {
214
+ t.Fatalf("copy part receipt: %s: %v", res.body, err)
215
+ }
216
+ first := fmt.Sprintf("<Part><PartNumber>1</PartNumber><ETag>%s</ETag></Part>", part.ETag)
217
+ headers["x-amz-copy-source-if-match"] = `"stale"`
218
+ p.request("PUT", path+"&partNumber=1", nil, headers, 412)
219
+ headers["x-amz-copy-source-if-match"] = put.header.Get("ETag")
220
+ for _, invalid := range []string{"bytes=6000000-6000001", "bytes=3-6000000", "bytes=5-3", "bytes=-3", "bytes=3-", "bytes=0-1,3-4"} {
221
+ headers["x-amz-copy-source-range"] = invalid
222
+ p.request("PUT", path+"&partNumber=1", nil, headers, 416)
223
+ }
224
+ headers["x-amz-copy-source-range"] = "bytes=5242883-5999999"
225
+ res = p.request("PUT", path+"&partNumber=2", nil, headers, 200)
226
+ if err := xml.Unmarshal(res.body, &part); err != nil {
227
+ t.Fatal(err)
228
+ }
229
+ second := fmt.Sprintf("<Part><PartNumber>2</PartNumber><ETag>%s</ETag></Part>", part.ETag)
230
+ p.request("POST", path, completion(first, second), nil, 200)
231
+ got := p.request("GET", "/uploads/copied", nil, nil, 200)
232
+ if !bytes.Equal(got.body, source[3:]) {
233
+ t.Fatal("copy changed the retained byte range")
234
+ }
235
+ p.request("PUT", path+"&partNumber=2", nil, headers, 404)
236
+ }
237
+
238
+ func TestUploadPartCopyVersionChecksumAndAbort(t *testing.T) {
239
+ p := newProbe(t)
240
+ p.request("PUT", "/uploads?versioning", []byte(`<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>`), nil, 200)
241
+ original := []byte("retained original version")
242
+ source := p.request("PUT", "/uploads/versioned", original, nil, 200)
243
+ version := source.header.Get("x-amz-version-id")
244
+ if version == "" {
245
+ t.Fatal("source has no version")
246
+ }
247
+ p.request("PUT", "/uploads/versioned", []byte("replacement"), nil, 200)
248
+ path := p.start("version-copy", true)
249
+ headers := map[string]string{"x-amz-copy-source": "/uploads/versioned?versionId=" + version, "x-amz-copy-source-if-match": source.header.Get("ETag")}
250
+ res := p.request("PUT", path+"&partNumber=1", nil, headers, 200)
251
+ var part struct {
252
+ ETag string
253
+ ChecksumSHA256 string
254
+ }
255
+ if err := xml.Unmarshal(res.body, &part); err != nil {
256
+ t.Fatal(err)
257
+ }
258
+ if part.ChecksumSHA256 != sum(original) {
259
+ t.Fatal("copied version checksum differs")
260
+ }
261
+ headers["x-amz-copy-source-if-none-match"] = source.header.Get("ETag")
262
+ p.request("PUT", path+"&partNumber=1", nil, headers, 412)
263
+ delete(headers, "x-amz-copy-source-if-none-match")
264
+ headers["x-amz-copy-source-if-modified-since"] = "Sat, 31 Oct 2026 00:00:00 GMT"
265
+ p.request("PUT", path+"&partNumber=1", nil, headers, 501)
266
+ delete(headers, "x-amz-copy-source-if-modified-since")
267
+ descriptor := fmt.Sprintf("<Part><PartNumber>1</PartNumber><ETag>%s</ETag><ChecksumSHA256>%s</ChecksumSHA256></Part>", part.ETag, part.ChecksumSHA256)
268
+ digest := sha256.Sum256(original)
269
+ composite := sum(digest[:]) + "-1"
270
+ p.request("POST", path, completion(descriptor), map[string]string{"x-amz-checksum-type": "COMPOSITE", "x-amz-checksum-sha256": composite}, 200)
271
+ got := p.request("GET", "/uploads/version-copy", nil, nil, 200)
272
+ if !bytes.Equal(got.body, original) || got.header.Get("x-amz-checksum-sha256") != composite {
273
+ t.Fatal("versioned copy bytes or composite receipt changed")
274
+ }
275
+ path = p.start("aborted-copy", false)
276
+ p.request("DELETE", path, nil, nil, 204)
277
+ p.request("PUT", path+"&partNumber=1", nil, headers, 404)
278
+ headers["x-amz-copy-source"] = "/uploads/missing"
279
+ path = p.start("missing-copy", false)
280
+ p.request("PUT", path+"&partNumber=1", nil, headers, 404)
281
+ p.request("DELETE", path, nil, nil, 204)
282
+ }
@@ -239,3 +239,114 @@
239
239
  // new metadata overwrites old but keep the rest
240
240
  // TODO: check how metadata can be deleted?!
241
241
  if _, ok := meta[k]; !ok {
242
+ --- a/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
243
+ +++ b/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
244
+ @@ -960,6 +960,10 @@
245
+ return ErrInvalidPart
246
+ }
247
+
248
+ + if r.Header.Get("x-amz-copy-source") != "" {
249
+ + return g.copyMultipartUploadPart(bucket, object, uploadID, int(partNumber), w, r)
250
+ + }
251
+ +
252
+ size, err := strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64)
253
+ if err != nil || size <= 0 {
254
+ return ErrMissingContentLength
255
+ --- /dev/null
256
+ +++ b/vendor/github.com/johannesboyne/gofakes3/upload_part_copy.go
257
+ @@ -0,0 +1,95 @@
258
+ +package gofakes3
259
+ +
260
+ +import (
261
+ + "crypto/sha256"
262
+ + "encoding/base64"
263
+ + "encoding/xml"
264
+ + "io"
265
+ + "net/http"
266
+ + "net/url"
267
+ + "strings"
268
+ +)
269
+ +
270
+ +// Copy from one backend snapshot: a separate HEAD followed by GET could race
271
+ +// an overwrite and copy bytes that no longer match the caller's condition.
272
+ +func (g *GoFakeS3) copyMultipartUploadPart(bucket, object string, uploadID UploadID, number int, w http.ResponseWriter, r *http.Request) error {
273
+ + source, query, _ := strings.Cut(r.Header.Get("x-amz-copy-source"), "?")
274
+ + source, err := url.PathUnescape(source)
275
+ + if err != nil {
276
+ + return ErrInvalidArgument
277
+ + }
278
+ + parts := strings.SplitN(strings.TrimPrefix(source, "/"), "/", 2)
279
+ + if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
280
+ + return ErrInvalidArgument
281
+ + }
282
+ + params, err := url.ParseQuery(query)
283
+ + if err != nil {
284
+ + return ErrInvalidArgument
285
+ + }
286
+ + for key := range params {
287
+ + if key != "versionId" {
288
+ + return ErrInvalidArgument
289
+ + }
290
+ + }
291
+ + // Do not silently ignore conditions this compatibility subset cannot apply.
292
+ + for _, key := range []string{"x-amz-copy-source-if-modified-since", "x-amz-copy-source-if-unmodified-since"} {
293
+ + if r.Header.Get(key) != "" {
294
+ + return ErrNotImplemented
295
+ + }
296
+ + }
297
+ + var src *Object
298
+ + if version := params.Get("versionId"); version != "" {
299
+ + if g.versioned == nil {
300
+ + return ErrNotImplemented
301
+ + }
302
+ + src, err = g.versioned.GetObjectVersion(parts[0], parts[1], VersionID(version), nil)
303
+ + } else {
304
+ + src, err = g.storage.GetObject(parts[0], parts[1], nil)
305
+ + }
306
+ + if err != nil {
307
+ + return err
308
+ + }
309
+ + if src == nil || src.Contents == nil {
310
+ + return ErrInternal
311
+ + }
312
+ + defer src.Contents.Close()
313
+ + if src.IsDeleteMarker {
314
+ + return ErrInvalidArgument
315
+ + }
316
+ + tag := FormatETag(src.Hash)
317
+ + if expected := r.Header.Get("x-amz-copy-source-if-match"); expected != "" && expected != tag {
318
+ + return ErrPreconditionFailed
319
+ + }
320
+ + if expected := r.Header.Get("x-amz-copy-source-if-none-match"); expected != "" && expected == tag {
321
+ + return ErrPreconditionFailed
322
+ + }
323
+ + start, size := int64(0), src.Size
324
+ + if value := r.Header.Get("x-amz-copy-source-range"); value != "" {
325
+ + span, err := parseRangeHeader(value)
326
+ + if err != nil || span == nil || span.FromEnd || span.End == RangeNoEnd || span.End >= src.Size {
327
+ + return ErrInvalidRange
328
+ + }
329
+ + start, size = span.Start, span.End-span.Start+1
330
+ + }
331
+ + if size <= 0 {
332
+ + return ErrInvalidRange
333
+ + }
334
+ + if _, err := io.CopyN(io.Discard, src.Contents, start); err != nil {
335
+ + return err
336
+ + }
337
+ + digest := sha256.New()
338
+ + body := io.TeeReader(io.LimitReader(src.Contents, size), digest)
339
+ + etag, err := g.uploader.UploadPart(bucket, object, uploadID, number, size, body)
340
+ + if err != nil {
341
+ + return err
342
+ + }
343
+ + if src.VersionID != "" {
344
+ + w.Header().Set("x-amz-copy-source-version-id", string(src.VersionID))
345
+ + }
346
+ + return g.xmlEncoder(w).Encode(struct {
347
+ + XMLName xml.Name `xml:"CopyPartResult"`
348
+ + ETag string `xml:"ETag"`
349
+ + LastModified ContentTime `xml:"LastModified"`
350
+ + ChecksumSHA256 string `xml:"ChecksumSHA256"`
351
+ + }{ETag: etag, LastModified: NewContentTime(g.timeSource.Now()), ChecksumSHA256: base64.StdEncoding.EncodeToString(digest.Sum(nil))})
352
+ +}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.7",
3
+ "version": "0.131.8",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,10 @@ each part and its ordering, and the composite checksum survives HEAD/GET for
22
22
  reconciliation. Completion conditions use the backend's atomic conditional write.
23
23
  Overwriting an object does not inherit the previous object's checksum metadata.
24
24
  This patch supports SHA256/COMPOSITE; it does not implement every S3 checksum
25
- algorithm or streaming checksum trailers.
25
+ algorithm or streaming checksum trailers. It also supports UploadPartCopy from
26
+ one backend snapshot, including closed byte ranges, exact ETag conditions,
27
+ version selection and SHA256 part receipts. Date-based copy conditions are
28
+ explicitly unsupported (501), rather than silently ignored.
26
29
 
27
30
  Run `./test.sh` from this directory to exercise real HTTP requests against the
28
31
  same patched sources used in the image, including corrupt retries, conditional
@@ -197,3 +197,86 @@ func TestMultipartAbortAndLegacyUpload(t *testing.T) {
197
197
  t.Fatal("legacy multipart changed")
198
198
  }
199
199
  }
200
+
201
+ func TestUploadPartCopyRetainsExactSourceAndRejectsStaleRetry(t *testing.T) {
202
+ p := newProbe(t)
203
+ source := bytes.Repeat([]byte("0123456789"), 600000)
204
+ put := p.request("PUT", "/uploads/staging%20%2B%25.csv", source, nil, 200)
205
+ path := p.start("copied", false)
206
+ headers := map[string]string{"x-amz-copy-source": "/uploads/staging%20%2B%25.csv", "x-amz-copy-source-if-match": put.header.Get("ETag"), "x-amz-copy-source-range": "bytes=3-5242882"}
207
+ res := p.request("PUT", path+"&partNumber=1", nil, headers, 200)
208
+ var part struct {
209
+ XMLName xml.Name
210
+ ETag string
211
+ LastModified string
212
+ }
213
+ if err := xml.Unmarshal(res.body, &part); err != nil || part.XMLName.Local != "CopyPartResult" || part.ETag == "" || part.LastModified == "" {
214
+ t.Fatalf("copy part receipt: %s: %v", res.body, err)
215
+ }
216
+ first := fmt.Sprintf("<Part><PartNumber>1</PartNumber><ETag>%s</ETag></Part>", part.ETag)
217
+ headers["x-amz-copy-source-if-match"] = `"stale"`
218
+ p.request("PUT", path+"&partNumber=1", nil, headers, 412)
219
+ headers["x-amz-copy-source-if-match"] = put.header.Get("ETag")
220
+ for _, invalid := range []string{"bytes=6000000-6000001", "bytes=3-6000000", "bytes=5-3", "bytes=-3", "bytes=3-", "bytes=0-1,3-4"} {
221
+ headers["x-amz-copy-source-range"] = invalid
222
+ p.request("PUT", path+"&partNumber=1", nil, headers, 416)
223
+ }
224
+ headers["x-amz-copy-source-range"] = "bytes=5242883-5999999"
225
+ res = p.request("PUT", path+"&partNumber=2", nil, headers, 200)
226
+ if err := xml.Unmarshal(res.body, &part); err != nil {
227
+ t.Fatal(err)
228
+ }
229
+ second := fmt.Sprintf("<Part><PartNumber>2</PartNumber><ETag>%s</ETag></Part>", part.ETag)
230
+ p.request("POST", path, completion(first, second), nil, 200)
231
+ got := p.request("GET", "/uploads/copied", nil, nil, 200)
232
+ if !bytes.Equal(got.body, source[3:]) {
233
+ t.Fatal("copy changed the retained byte range")
234
+ }
235
+ p.request("PUT", path+"&partNumber=2", nil, headers, 404)
236
+ }
237
+
238
+ func TestUploadPartCopyVersionChecksumAndAbort(t *testing.T) {
239
+ p := newProbe(t)
240
+ p.request("PUT", "/uploads?versioning", []byte(`<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>`), nil, 200)
241
+ original := []byte("retained original version")
242
+ source := p.request("PUT", "/uploads/versioned", original, nil, 200)
243
+ version := source.header.Get("x-amz-version-id")
244
+ if version == "" {
245
+ t.Fatal("source has no version")
246
+ }
247
+ p.request("PUT", "/uploads/versioned", []byte("replacement"), nil, 200)
248
+ path := p.start("version-copy", true)
249
+ headers := map[string]string{"x-amz-copy-source": "/uploads/versioned?versionId=" + version, "x-amz-copy-source-if-match": source.header.Get("ETag")}
250
+ res := p.request("PUT", path+"&partNumber=1", nil, headers, 200)
251
+ var part struct {
252
+ ETag string
253
+ ChecksumSHA256 string
254
+ }
255
+ if err := xml.Unmarshal(res.body, &part); err != nil {
256
+ t.Fatal(err)
257
+ }
258
+ if part.ChecksumSHA256 != sum(original) {
259
+ t.Fatal("copied version checksum differs")
260
+ }
261
+ headers["x-amz-copy-source-if-none-match"] = source.header.Get("ETag")
262
+ p.request("PUT", path+"&partNumber=1", nil, headers, 412)
263
+ delete(headers, "x-amz-copy-source-if-none-match")
264
+ headers["x-amz-copy-source-if-modified-since"] = "Sat, 31 Oct 2026 00:00:00 GMT"
265
+ p.request("PUT", path+"&partNumber=1", nil, headers, 501)
266
+ delete(headers, "x-amz-copy-source-if-modified-since")
267
+ descriptor := fmt.Sprintf("<Part><PartNumber>1</PartNumber><ETag>%s</ETag><ChecksumSHA256>%s</ChecksumSHA256></Part>", part.ETag, part.ChecksumSHA256)
268
+ digest := sha256.Sum256(original)
269
+ composite := sum(digest[:]) + "-1"
270
+ p.request("POST", path, completion(descriptor), map[string]string{"x-amz-checksum-type": "COMPOSITE", "x-amz-checksum-sha256": composite}, 200)
271
+ got := p.request("GET", "/uploads/version-copy", nil, nil, 200)
272
+ if !bytes.Equal(got.body, original) || got.header.Get("x-amz-checksum-sha256") != composite {
273
+ t.Fatal("versioned copy bytes or composite receipt changed")
274
+ }
275
+ path = p.start("aborted-copy", false)
276
+ p.request("DELETE", path, nil, nil, 204)
277
+ p.request("PUT", path+"&partNumber=1", nil, headers, 404)
278
+ headers["x-amz-copy-source"] = "/uploads/missing"
279
+ path = p.start("missing-copy", false)
280
+ p.request("PUT", path+"&partNumber=1", nil, headers, 404)
281
+ p.request("DELETE", path, nil, nil, 204)
282
+ }
@@ -239,3 +239,114 @@
239
239
  // new metadata overwrites old but keep the rest
240
240
  // TODO: check how metadata can be deleted?!
241
241
  if _, ok := meta[k]; !ok {
242
+ --- a/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
243
+ +++ b/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
244
+ @@ -960,6 +960,10 @@
245
+ return ErrInvalidPart
246
+ }
247
+
248
+ + if r.Header.Get("x-amz-copy-source") != "" {
249
+ + return g.copyMultipartUploadPart(bucket, object, uploadID, int(partNumber), w, r)
250
+ + }
251
+ +
252
+ size, err := strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64)
253
+ if err != nil || size <= 0 {
254
+ return ErrMissingContentLength
255
+ --- /dev/null
256
+ +++ b/vendor/github.com/johannesboyne/gofakes3/upload_part_copy.go
257
+ @@ -0,0 +1,95 @@
258
+ +package gofakes3
259
+ +
260
+ +import (
261
+ + "crypto/sha256"
262
+ + "encoding/base64"
263
+ + "encoding/xml"
264
+ + "io"
265
+ + "net/http"
266
+ + "net/url"
267
+ + "strings"
268
+ +)
269
+ +
270
+ +// Copy from one backend snapshot: a separate HEAD followed by GET could race
271
+ +// an overwrite and copy bytes that no longer match the caller's condition.
272
+ +func (g *GoFakeS3) copyMultipartUploadPart(bucket, object string, uploadID UploadID, number int, w http.ResponseWriter, r *http.Request) error {
273
+ + source, query, _ := strings.Cut(r.Header.Get("x-amz-copy-source"), "?")
274
+ + source, err := url.PathUnescape(source)
275
+ + if err != nil {
276
+ + return ErrInvalidArgument
277
+ + }
278
+ + parts := strings.SplitN(strings.TrimPrefix(source, "/"), "/", 2)
279
+ + if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
280
+ + return ErrInvalidArgument
281
+ + }
282
+ + params, err := url.ParseQuery(query)
283
+ + if err != nil {
284
+ + return ErrInvalidArgument
285
+ + }
286
+ + for key := range params {
287
+ + if key != "versionId" {
288
+ + return ErrInvalidArgument
289
+ + }
290
+ + }
291
+ + // Do not silently ignore conditions this compatibility subset cannot apply.
292
+ + for _, key := range []string{"x-amz-copy-source-if-modified-since", "x-amz-copy-source-if-unmodified-since"} {
293
+ + if r.Header.Get(key) != "" {
294
+ + return ErrNotImplemented
295
+ + }
296
+ + }
297
+ + var src *Object
298
+ + if version := params.Get("versionId"); version != "" {
299
+ + if g.versioned == nil {
300
+ + return ErrNotImplemented
301
+ + }
302
+ + src, err = g.versioned.GetObjectVersion(parts[0], parts[1], VersionID(version), nil)
303
+ + } else {
304
+ + src, err = g.storage.GetObject(parts[0], parts[1], nil)
305
+ + }
306
+ + if err != nil {
307
+ + return err
308
+ + }
309
+ + if src == nil || src.Contents == nil {
310
+ + return ErrInternal
311
+ + }
312
+ + defer src.Contents.Close()
313
+ + if src.IsDeleteMarker {
314
+ + return ErrInvalidArgument
315
+ + }
316
+ + tag := FormatETag(src.Hash)
317
+ + if expected := r.Header.Get("x-amz-copy-source-if-match"); expected != "" && expected != tag {
318
+ + return ErrPreconditionFailed
319
+ + }
320
+ + if expected := r.Header.Get("x-amz-copy-source-if-none-match"); expected != "" && expected == tag {
321
+ + return ErrPreconditionFailed
322
+ + }
323
+ + start, size := int64(0), src.Size
324
+ + if value := r.Header.Get("x-amz-copy-source-range"); value != "" {
325
+ + span, err := parseRangeHeader(value)
326
+ + if err != nil || span == nil || span.FromEnd || span.End == RangeNoEnd || span.End >= src.Size {
327
+ + return ErrInvalidRange
328
+ + }
329
+ + start, size = span.Start, span.End-span.Start+1
330
+ + }
331
+ + if size <= 0 {
332
+ + return ErrInvalidRange
333
+ + }
334
+ + if _, err := io.CopyN(io.Discard, src.Contents, start); err != nil {
335
+ + return err
336
+ + }
337
+ + digest := sha256.New()
338
+ + body := io.TeeReader(io.LimitReader(src.Contents, size), digest)
339
+ + etag, err := g.uploader.UploadPart(bucket, object, uploadID, number, size, body)
340
+ + if err != nil {
341
+ + return err
342
+ + }
343
+ + if src.VersionID != "" {
344
+ + w.Header().Set("x-amz-copy-source-version-id", string(src.VersionID))
345
+ + }
346
+ + return g.xmlEncoder(w).Encode(struct {
347
+ + XMLName xml.Name `xml:"CopyPartResult"`
348
+ + ETag string `xml:"ETag"`
349
+ + LastModified ContentTime `xml:"LastModified"`
350
+ + ChecksumSHA256 string `xml:"ChecksumSHA256"`
351
+ + }{ETag: etag, LastModified: NewContentTime(g.timeSource.Now()), ChecksumSHA256: base64.StdEncoding.EncodeToString(digest.Sum(nil))})
352
+ +}