Skip to content

Commit 40342c0

Browse files
frristclaude
andcommitted
feat(s3): implement object lock (per-key version-state tree)
Implements docs/s3-object-lock.md. WORM enforcement lives in versitygw's controller (auth.CheckObjectAccess); the backend stores lock state and returns the exact absent-case sentinels. Per-version retention and legal holds live in a per-key version-state tree beside the prev tree: ObjectLeaf.State roots an MST mapping revSeqKey(seq) to VersionState blocks under the new "/versionstate/0" union arm. The State field joins ObjectLeaf under the existing "/objectleaf/0" key with no compatibility shim, per the repo's dev-only data posture. Mutations rewrite only positional blocks, so manifests stay immutable and versioning invariant 5 holds verbatim. VersionState reserves the Tags field for object tagging (handlers, not format, in the follow-up). Creation-time stamping (PutObject / CopyObject / CompleteMultipartUpload lock headers) and delete-path cleanup run inside the same commits that create and remove versions. The bucket-level lock configuration is a registry column beside versioning; CreateBucket with x-amz-bucket-object-lock-enabled creates the bucket versioned and locked in one insert, and PutBucketVersioning refuses to suspend a lock bucket. Conformance notes pinned by the run: the creation-time header paths report the NoSpaces variant of the missing-configuration error while the four per-version methods report the spaced one; key existence outranks the lock-enabled gate (GetObjectRetention_non_existing_object); the controller passes an absent retain-until header as a pointer to the zero time; and NoSuchVersionError / InvalidArgumentError now pass through mapCommitError verbatim like PreconditionFailedError. itest gains the six lock groups, a WORMProtection xfail table (bucket-policy-dependent bypass cases), and a LockCreation category for lock-enabled-bucket cases from plain-conf groups that need the versioned teardown; the versioning tables absorb the Versioning_* lock rows and promote DeleteObject_non_existing_objects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6ca9436 commit 40342c0

30 files changed

Lines changed: 2074 additions & 128 deletions

bucket/cbor_gen.go

Lines changed: 397 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bucket/leaf.go

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ type VersionNode struct {
1919

2020
// ObjectLeaf is the per-key version group, stored under the "/objectleaf/0"
2121
// union key (docs/s3-versioning.md §2.1). A key's top-MST value points at one
22-
// once the key has been superseded; until then the value is its manifest
23-
// (under "/objectmanifest/0").
22+
// once the key has been superseded or has received version state
23+
// (docs/s3-object-lock.md §4.1); until then the value is its manifest (under
24+
// "/objectmanifest/0").
2425
type ObjectLeaf struct {
2526
// Current is the head version — what GET/HEAD/ListObjects resolve with
2627
// a single leaf read, no descent into Prev.
@@ -36,6 +37,46 @@ type ObjectLeaf struct {
3637
// because Prev is keyed by Seq and the null version's id ("null") does
3738
// not encode its Seq.
3839
NullSeq uint64 `cborgen:"n"`
40+
41+
// State is the root of the per-key version-state MST: revSeqKey(seq) →
42+
// the version's VersionState block CID (docs/s3-object-lock.md §4.1).
43+
// Nil when no version of this key carries explicit state, which is what
44+
// makes state-free keys free to read.
45+
State *cid.Cid `cborgen:"st"`
46+
}
47+
48+
// LegalHold values for VersionState.LegalHold. S3 distinguishes never-set
49+
// (a 400 from GetObjectLegalHold) from an explicit OFF (a 200), so the
50+
// stored state is tri-valued.
51+
const (
52+
LegalHoldUnset uint8 = 0
53+
LegalHoldOff uint8 = 1
54+
LegalHoldOn uint8 = 2
55+
)
56+
57+
// VersionState is one version's mutable service state, stored as its own
58+
// catalog block under the "/versionstate/0" union key (docs/s3-object-lock.md
59+
// §4.1). Object lock is its first tenant; the block is the home for
60+
// per-version state that mutates after the version is created. Mutations
61+
// merge: each operation replaces the fields it owns and carries every other
62+
// field verbatim; a mutation that leaves every field absent deletes the
63+
// version's state-tree entry.
64+
type VersionState struct {
65+
// Retention is the stored retention document (docs/s3-object-lock.md §2),
66+
// verbatim; nil when retention has never been set.
67+
Retention []byte `cborgen:"r"`
68+
// LegalHold is tri-valued: LegalHoldUnset, LegalHoldOff, LegalHoldOn.
69+
LegalHold uint8 `cborgen:"h"`
70+
// Tags is reserved for object tagging (planned follow-up). Declared now
71+
// so every rewrite round-trips it under the merge rule; the tagging
72+
// feature adds handlers, not format. Nil until it lands.
73+
Tags map[string]string `cborgen:"t"`
74+
}
75+
76+
// Empty reports whether every field is absent — the elision condition: an
77+
// empty state block is removed from the tree rather than stored.
78+
func (s *VersionState) Empty() bool {
79+
return s.Retention == nil && s.LegalHold == LegalHoldUnset && len(s.Tags) == 0
3980
}
4081

4182
// ValueUnion is the keyed union every catalog value block is encoded as
@@ -144,3 +185,38 @@ func (e *EnvelopedLeaf) UnmarshalCBOR(r io.Reader) error {
144185
e.Leaf = v.Leaf
145186
return nil
146187
}
188+
189+
// StateUnion is the keyed union for version-state blocks — the values of a
190+
// leaf's state tree (docs/s3-object-lock.md §4.1). A separate union from
191+
// ValueUnion so a state block decoded through ObjectValue (or vice versa)
192+
// fails loudly with no arm matched. cbor-gen generates its codec; decode
193+
// through EnvelopedVersionState, which requires the arm.
194+
type StateUnion struct {
195+
State *VersionState `cborgen:"/versionstate/0,omitempty"`
196+
}
197+
198+
// EnvelopedVersionState reads/writes one version-state block under its
199+
// "/versionstate/0" union key.
200+
type EnvelopedVersionState struct {
201+
State *VersionState
202+
}
203+
204+
func (e *EnvelopedVersionState) MarshalCBOR(w io.Writer) error {
205+
if e.State == nil {
206+
return fmt.Errorf("bucket: version-state block must carry a state")
207+
}
208+
u := StateUnion{State: e.State}
209+
return u.MarshalCBOR(w)
210+
}
211+
212+
func (e *EnvelopedVersionState) UnmarshalCBOR(r io.Reader) error {
213+
var u StateUnion
214+
if err := u.UnmarshalCBOR(r); err != nil {
215+
return err
216+
}
217+
if u.State == nil {
218+
return fmt.Errorf("bucket: block is not an enveloped version state")
219+
}
220+
e.State = u.State
221+
return nil
222+
}

bucket/leaf_test.go

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@ func testCid(t *testing.T, s string) cid.Cid {
2424
// decodes back with its fields intact.
2525
func TestObjectValue_LeafRoundTrip(t *testing.T) {
2626
prev := testCid(t, "prev-root")
27+
state := testCid(t, "state-root")
2728
leaf := &ObjectLeaf{
2829
Current: VersionNode{Seq: 9, VersionID: "01ARZ3NDEKTSV4RRFFQ69G5FAV", Manifest: testCid(t, "mf")},
2930
Prev: &prev,
3031
NullSeq: 4,
32+
State: &state,
3133
}
3234
var buf bytes.Buffer
3335
if err := LeafValue(leaf).MarshalCBOR(&buf); err != nil {
@@ -46,7 +48,8 @@ func TestObjectValue_LeafRoundTrip(t *testing.T) {
4648
}
4749
got := val.Leaf
4850
if got.Current != leaf.Current || got.NullSeq != leaf.NullSeq ||
49-
got.Prev == nil || !got.Prev.Equals(prev) {
51+
got.Prev == nil || !got.Prev.Equals(prev) ||
52+
got.State == nil || !got.State.Equals(state) {
5053
t.Fatalf("leaf round-trip = %+v, want %+v", got, leaf)
5154
}
5255

@@ -148,3 +151,52 @@ func TestObjectValue_RejectsNonUnionBlocks(t *testing.T) {
148151
t.Fatal("marshal of zero-arm value: err = nil, want rejection")
149152
}
150153
}
154+
155+
// TestVersionState_RoundTrip pins the version-state block: the
156+
// "/versionstate/0" union key, the tri-valued hold, the verbatim retention
157+
// bytes, and the reserved Tags field surviving a decode/re-encode cycle (the
158+
// §4.1 merge rule depends on unowned fields being carried).
159+
func TestVersionState_RoundTrip(t *testing.T) {
160+
st := &VersionState{
161+
Retention: []byte(`{"Mode":"GOVERNANCE","RetainUntilDate":"2027-01-02T15:04:05Z"}`),
162+
LegalHold: LegalHoldOff,
163+
Tags: map[string]string{"team": "forge", "env": "dev"},
164+
}
165+
var buf bytes.Buffer
166+
if err := (&EnvelopedVersionState{State: st}).MarshalCBOR(&buf); err != nil {
167+
t.Fatalf("marshal: %v", err)
168+
}
169+
if buf.Bytes()[0] != 0xa1 {
170+
t.Fatalf("header = %#x, want 0xa1 (single-entry map)", buf.Bytes()[0])
171+
}
172+
173+
var env EnvelopedVersionState
174+
if err := env.UnmarshalCBOR(bytes.NewReader(buf.Bytes())); err != nil {
175+
t.Fatalf("unmarshal: %v", err)
176+
}
177+
got := env.State
178+
if !bytes.Equal(got.Retention, st.Retention) || got.LegalHold != LegalHoldOff ||
179+
got.Tags["team"] != "forge" || got.Tags["env"] != "dev" || len(got.Tags) != 2 {
180+
t.Fatalf("round-trip = %+v, want %+v", got, st)
181+
}
182+
if got.Empty() {
183+
t.Fatal("populated state reports Empty")
184+
}
185+
if !(&VersionState{}).Empty() {
186+
t.Fatal("zero state does not report Empty")
187+
}
188+
189+
// The strict decoders reject cross-type blocks: a value block is not a
190+
// state block, and a state block is not a value block.
191+
var lbuf bytes.Buffer
192+
if err := LeafValue(&ObjectLeaf{Current: VersionNode{Manifest: testCid(t, "m")}}).MarshalCBOR(&lbuf); err != nil {
193+
t.Fatalf("leaf marshal: %v", err)
194+
}
195+
if err := env.UnmarshalCBOR(bytes.NewReader(lbuf.Bytes())); err == nil {
196+
t.Fatal("EnvelopedVersionState.UnmarshalCBOR(leaf block) = nil error, want rejection")
197+
}
198+
var val ObjectValue
199+
if err := val.UnmarshalCBOR(bytes.NewReader(buf.Bytes())); err == nil {
200+
t.Fatal("ObjectValue.UnmarshalCBOR(state block) = nil error, want rejection")
201+
}
202+
}

gen/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ func main() {
2020
bucket.ObjectLeaf{},
2121
bucket.VersionNode{},
2222
bucket.ValueUnion{},
23+
bucket.VersionState{},
24+
bucket.StateUnion{},
2325
); err != nil {
2426
panic(err)
2527
}

inmem/segments_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/multiformats/go-multihash"
99

1010
"github.com/fil-forge/ingot/blockstore"
11+
"github.com/fil-forge/ingot/registry"
1112
"github.com/fil-forge/libforge/testutil"
1213
)
1314

@@ -28,7 +29,7 @@ func TestMarkSegmentShipped_GuardsForgeRootOnRoot(t *testing.T) {
2829
ctx := context.Background()
2930
m := NewMemStore()
3031

31-
if err := m.Create(ctx, "bk", testutil.RandomDID(t)); err != nil {
32+
if err := m.Create(ctx, "bk", testutil.RandomDID(t), registry.CreateState{}); err != nil {
3233
t.Fatalf("Create: %v", err)
3334
}
3435
committed := testCid(t, "committed-root")
@@ -61,7 +62,7 @@ func TestMarkSegmentShipped_GuardsForgeRootOnRoot(t *testing.T) {
6162

6263
// A segment carrying ONLY a stale op-root must not advance forge_root at all.
6364
m2 := NewMemStore()
64-
_ = m2.Create(ctx, "bk2", testutil.RandomDID(t))
65+
_ = m2.Create(ctx, "bk2", testutil.RandomDID(t), registry.CreateState{})
6566
_ = m2.CASRoot(ctx, "bk2", cid.Undef, committed)
6667
seq2, _ := m2.NextSegmentSeq(ctx)
6768
_ = m2.InsertSegmentOpen(ctx, blockstore.PlaneCatalog, seq2, "bk2")

inmem/store.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,18 +163,23 @@ func (m *MemStore) ListBuckets(ctx context.Context, req s3.Request) (*bucket.Lis
163163

164164
// Registry methods ===========================================================
165165

166-
func (m *MemStore) Create(_ context.Context, name string, space did.DID) error {
166+
func (m *MemStore) Create(_ context.Context, name string, space did.DID, init registry.CreateState) error {
167167
m.mu.Lock()
168168
defer m.mu.Unlock()
169169
if _, ok := m.buckets[name]; ok {
170170
return registry.ErrExists
171171
}
172+
v := init.Versioning
173+
if v == "" {
174+
v = registry.VersioningUnversioned
175+
}
172176
// Stamped here for parity with the buckets.created_at column default.
173177
m.buckets[name] = &registry.State{
174-
Name: name,
175-
Space: space,
176-
Versioning: registry.VersioningUnversioned,
177-
CreatedAt: time.Now().UTC(),
178+
Name: name,
179+
Space: space,
180+
Versioning: v,
181+
ObjectLockConfig: init.ObjectLockConfig,
182+
CreatedAt: time.Now().UTC(),
178183
}
179184
return nil
180185
}
@@ -240,6 +245,17 @@ func (m *MemStore) SetVersioning(_ context.Context, name string, v registry.Vers
240245
return nil
241246
}
242247

248+
func (m *MemStore) SetObjectLockConfig(_ context.Context, name string, cfg []byte) error {
249+
m.mu.Lock()
250+
defer m.mu.Unlock()
251+
s, ok := m.buckets[name]
252+
if !ok {
253+
return registry.ErrNotFound
254+
}
255+
s.ObjectLockConfig = cfg
256+
return nil
257+
}
258+
243259
func (m *MemStore) AllocVersionSeq(_ context.Context, name string) (uint64, error) {
244260
m.mu.Lock()
245261
defer m.mu.Unlock()

inmem/store_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
s3 "github.com/fil-forge/libforge/commands/s3"
99
"github.com/fil-forge/libforge/commands/s3/bucket"
1010
"github.com/fil-forge/libforge/testutil"
11+
12+
"github.com/fil-forge/ingot/registry"
1113
)
1214

1315
// TestMemStoreListBucketsPagination covers the local pagination semantics of
@@ -20,7 +22,7 @@ func TestMemStoreListBucketsPagination(t *testing.T) {
2022
m := NewMemStore()
2123
for _, name := range []string{"apple", "apricot", "banana", "cherry"} {
2224
space := testutil.RandomDID(t)
23-
if err := m.Create(ctx, name, space); err != nil {
25+
if err := m.Create(ctx, name, space, registry.CreateState{}); err != nil {
2426
t.Fatalf("Create %q: %v", name, err)
2527
}
2628
}

itest/versity_bucket_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
// the in-memory harness was removed).
1313

1414
var createBucketPass = []forgeCase{
15+
{name: "default_object_lock", fn: integration.CreateBucket_default_object_lock},
1516
{name: "invalid_bucket_name", fn: integration.CreateBucket_invalid_bucket_name},
1617
{name: "invalid_canned_acl", fn: integration.CreateBucket_invalid_canned_acl},
1718
{name: "invalid_ownership", fn: integration.CreateBucket_invalid_ownership},
@@ -27,7 +28,6 @@ var createBucketXFail = []forgeCase{
2728
{name: "invalid_location_constraint", fn: integration.CreateBucket_invalid_location_constraint},
2829
{name: "as_user", fn: integration.CreateBucket_as_user},
2930
{name: "default_acl", fn: integration.CreateBucket_default_acl},
30-
{name: "default_object_lock", fn: integration.CreateBucket_default_object_lock},
3131
{name: "duplicate_keys", fn: integration.CreateBucket_duplicate_keys},
3232
{name: "existing_bucket", fn: integration.CreateBucket_existing_bucket},
3333
{name: "invalid_tags", fn: integration.CreateBucket_invalid_tags},

0 commit comments

Comments
 (0)