| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "strings" |
| 9 | |
| 10 | "github.com/aws/aws-sdk-go-v2/aws" |
| 11 | awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| 12 | "github.com/carlosframework/platform/internal/safefile" |
| 13 | ) |
| 14 | |
| 15 | var ( |
| 16 | ErrConditionalDeleteUnsupported = errors.New("store: conditional delete unsupported") |
| 17 | ErrInvalidETag = errors.New("store: conditional delete requires one exact etag") |
| 18 | ) |
| 19 | |
| 20 | // ConditionalDeleter is optional. Callers must not replace a refused conditional |
| 21 | // delete with an ordinary Delete: that would discard the observed revision. |
| 22 | type ConditionalDeleter interface { |
| 23 | DeleteIfMatch(ctx context.Context, key, etag string) error |
| 24 | } |
| 25 | |
| 26 | // DeleteIfMatch deletes only the observed revision, or refuses if the backend |
| 27 | // cannot enforce the condition. Known absence is success, not proof that an object |
| 28 | // previously existed. It never falls back to an unconditional delete. |
| 29 | func DeleteIfMatch(ctx context.Context, s Store, key, etag string) error { |
| 30 | if !exactDeleteETag(etag) { |
| 31 | return ErrInvalidETag |
| 32 | } |
| 33 | conditional, ok := s.(ConditionalDeleter) |
| 34 | if !ok { |
| 35 | return ErrConditionalDeleteUnsupported |
| 36 | } |
| 37 | return conditional.DeleteIfMatch(ctx, key, etag) |
| 38 | } |
| 39 | |
| 40 | func exactDeleteETag(etag string) bool { |
| 41 | if len(etag) == 0 || strings.HasPrefix(etag, "W/") { |
| 42 | return false |
| 43 | } |
| 44 | quoted := etag[0] == '"' |
| 45 | if quoted { |
| 46 | if len(etag) < 3 || etag[len(etag)-1] != '"' { |
| 47 | return false |
| 48 | } |
| 49 | etag = etag[1 : len(etag)-1] |
| 50 | } |
| 51 | for _, c := range []byte(etag) { |
| 52 | if c <= 32 || c == 127 || c == '"' || c == '*' || (!quoted && c == ',') { |
| 53 | return false |
| 54 | } |
| 55 | } |
| 56 | return true |
| 57 | } |
| 58 | |
| 59 | func (p prefixStore) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| 60 | return DeleteIfMatch(ctx, p.Store, p.full(key), etag) |
| 61 | } |
| 62 | |
| 63 | func (d *DirStore) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| 64 | err := d.deleteIfMatchRaw(ctx, key, etag) |
| 65 | if err == nil { |
| 66 | d.bump(ctx, key) |
| 67 | } |
| 68 | return err |
| 69 | } |
| 70 | |
| 71 | func (d *DirStore) deleteIfMatchRaw(_ context.Context, key, etag string) error { |
| 72 | if !exactDeleteETag(etag) { |
| 73 | return ErrInvalidETag |
| 74 | } |
| 75 | path, err := d.path(key) |
| 76 | if err != nil { |
| 77 | return err |
| 78 | } |
| 79 | d.mu.Lock() |
| 80 | defer d.mu.Unlock() |
| 81 | body, err := safefile.ReadFile(path) |
| 82 | if errors.Is(err, os.ErrNotExist) { |
| 83 | return nil |
| 84 | } |
| 85 | if err != nil { |
| 86 | return err |
| 87 | } |
| 88 | if etagOf(body) != etag { |
| 89 | return fmt.Errorf("dirstore: %s: %w", key, ErrCASConflict) |
| 90 | } |
| 91 | return safefile.Remove(path) |
| 92 | } |
| 93 | |
| 94 | func (s *S3Store) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| 95 | err := s.deleteIfMatchRaw(ctx, key, etag) |
| 96 | if err == nil { |
| 97 | s.bump(ctx, key) |
| 98 | } |
| 99 | return err |
| 100 | } |
| 101 | |
| 102 | func (s *S3Store) deleteIfMatchRaw(ctx context.Context, key, etag string) error { |
| 103 | if !exactDeleteETag(etag) { |
| 104 | return ErrInvalidETag |
| 105 | } |
| 106 | _, err := s.Client.DeleteObject(ctx, &awss3.DeleteObjectInput{Bucket: &s.Bucket, Key: &key, IfMatch: aws.String(etag)}) |
| 107 | if s3NotFound(err) { |
| 108 | return nil |
| 109 | } |
| 110 | if s3CASConflict(err) { |
| 111 | return fmt.Errorf("s3store: %s: %w", key, ErrCASConflict) |
| 112 | } |
| 113 | return err |
| 114 | } |
| 115 | |
| 116 | var ( |
| 117 | _ ConditionalDeleter = (*DirStore)(nil) |
| 118 | _ ConditionalDeleter = (*S3Store)(nil) |
| 119 | _ ConditionalDeleter = prefixStore{} |
| 120 | ) |
| 121 | |