diff --git a/.changes/unreleased/Fixed-20261030-130622.yaml b/.changes/unreleased/Fixed-20261030-130622.yaml new file mode 100644 index 0000000..a9ab37a --- /dev/null +++ b/.changes/unreleased/Fixed-20261030-130622.yaml @@ -0,0 +1,3 @@ +kind: Fixed +body: Application keys without an expiration timestamp now report a zero expiration time instead of 1970-01-01; callers comparing Expires() to 1970-01-01 or Unix() to 0 should use IsZero() (#244). +time: 2026-10-30T13:06:22.194375-06:00 diff --git a/b2/integration_test.go b/b2/integration_test.go index 533a822..339fd25 100644 --- a/b2/integration_test.go +++ b/b2/integration_test.go @@ -1114,6 +1114,9 @@ func TestListBucketsWithKey(t *testing.T) { if err != nil { t.Fatal(err) } + if !key.Expires().IsZero() { + t.Errorf("key.Expires() = %v, want zero time for a key without an expiration", key.Expires()) + } defer func() { if err := key.Delete(ctx); err != nil { t.Error(err) @@ -1340,6 +1343,9 @@ func TestKeyRestrictionAccessors(t *testing.T) { if err != nil { t.Fatalf("Bucket.CreateKey: %v", err) } + if !key.Expires().IsZero() { + t.Errorf("key.Expires() = %v, want zero time for a key without an expiration", key.Expires()) + } defer func() { cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second) defer cleanupCancel() @@ -1371,6 +1377,9 @@ func TestKeyRestrictionAccessors(t *testing.T) { if listed == nil { t.Fatalf("ListKeys did not return key %q", key.ID()) } + if !listed.Expires().IsZero() { + t.Errorf("listed.Expires() = %v, want zero time for a key without an expiration", listed.Expires()) + } assertKeyRestrictions(t, listed, []string{bucket.ID()}, "live-227/") } diff --git a/b2/key.go b/b2/key.go index 68a4aa3..74c1413 100644 --- a/b2/key.go +++ b/b2/key.go @@ -45,7 +45,8 @@ func (k *Key) Prefix() string { return k.k.prefix() } // useless. func (k *Key) Name() string { return k.k.name() } -// Expires returns the expiration date of this application key. +// Expires returns the expiration date of this application key, or the zero time +// if the key does not expire. func (k *Key) Expires() time.Time { return k.k.expires() } // Delete removes the key from B2. diff --git a/b2/key_expiration_wire_test.go b/b2/key_expiration_wire_test.go new file mode 100644 index 0000000..1c5117a --- /dev/null +++ b/b2/key_expiration_wire_test.go @@ -0,0 +1,100 @@ +// Copyright 2026, the Blazer authors +// +// 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 b2 + +import ( + "context" + "io" + "net/http" + "testing" + "time" + + "github.com/Backblaze/blazer/internal/b2wiretest" +) + +var wireKeyExpirationTime = time.Date(2023, time.November, 14, 22, 13, 20, 123000000, time.UTC) + +func newKeyExpirationWire(t *testing.T) (*b2wiretest.Server, *Client) { + t.Helper() + srv := b2wiretest.New(t) + client, err := NewClient(context.Background(), "account", "key", APIBase(srv.URL())) + if err != nil { + t.Fatal(err) + } + return srv, client +} + +func TestKeyExpirationReachesPublicAPI(t *testing.T) { + const noExpiration = `{"accountId":"account","applicationKeyId":"no-expiration","applicationKey":"secret","capabilities":["listBuckets"],"keyName":"no-expiration","expirationTimestamp":null}` + const missingExpiration = `{"accountId":"account","applicationKeyId":"missing-expiration","applicationKey":"secret","capabilities":["listBuckets"],"keyName":"missing-expiration"}` + const expiration = `{"accountId":"account","applicationKeyId":"expiration","applicationKey":"secret","capabilities":["listBuckets"],"keyName":"expiration","expirationTimestamp":1700000000123}` + + t.Run("create without expiration", func(t *testing.T) { + srv, client := newKeyExpirationWire(t) + srv.Reply("b2_create_key", http.StatusOK, noExpiration) + key, err := client.CreateKey(context.Background(), "no-expiration", Capabilities("listBuckets")) + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + if !key.Expires().IsZero() { + t.Errorf("CreateKey Expires = %v, want zero time", key.Expires()) + } + }) + + t.Run("create with missing expiration", func(t *testing.T) { + srv, client := newKeyExpirationWire(t) + srv.Reply("b2_create_key", http.StatusOK, missingExpiration) + key, err := client.CreateKey(context.Background(), "missing-expiration", Capabilities("listBuckets")) + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + if !key.Expires().IsZero() { + t.Errorf("CreateKey missing expiration Expires = %v, want zero time", key.Expires()) + } + }) + + t.Run("create with expiration", func(t *testing.T) { + srv, client := newKeyExpirationWire(t) + srv.Reply("b2_create_key", http.StatusOK, expiration) + key, err := client.CreateKey(context.Background(), "expiration", Capabilities("listBuckets")) + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + if !key.Expires().Equal(wireKeyExpirationTime) { + t.Errorf("CreateKey Expires = %v, want %v", key.Expires(), wireKeyExpirationTime) + } + }) + + t.Run("list keys", func(t *testing.T) { + srv, client := newKeyExpirationWire(t) + srv.Reply("b2_list_keys", http.StatusOK, `{"keys":[`+noExpiration+`,`+missingExpiration+`,`+expiration+`],"nextApplicationKeyId":null}`) + keys, _, err := client.ListKeys(context.Background(), 10, "") + if err != nil && err != io.EOF { + t.Fatalf("ListKeys: %v", err) + } + if len(keys) != 3 { + t.Fatalf("ListKeys returned %d keys, want 3", len(keys)) + } + if !keys[0].Expires().IsZero() { + t.Errorf("ListKeys no-expiration Expires = %v, want zero time", keys[0].Expires()) + } + if !keys[1].Expires().IsZero() { + t.Errorf("ListKeys missing expiration Expires = %v, want zero time", keys[1].Expires()) + } + if !keys[2].Expires().Equal(wireKeyExpirationTime) { + t.Errorf("ListKeys expiration Expires = %v, want %v", keys[2].Expires(), wireKeyExpirationTime) + } + }) +} diff --git a/base/base.go b/base/base.go index 67677cd..d743f71 100644 --- a/base/base.go +++ b/base/base.go @@ -368,6 +368,16 @@ func millitime(t int64) time.Time { return time.Unix(t/1000, t%1000*1e6) } +// b2_create_key and b2_list_keys return null expirationTimestamp for keys that +// never expire; it decodes as zero, which must map to the zero time. Keep +// millitime unchanged because it also converts file timestamps. +func keyExpiration(t int64) time.Time { + if t == 0 { + return time.Time{} + } + return millitime(t) +} + type b2Options struct { transport http.RoundTripper failSomeUploads bool @@ -2037,10 +2047,13 @@ type Key struct { Secret string Name string Capabilities []string - Expires time.Time - BucketIDs []string - Prefix string - b2 *B2 + // Expires is the application key's expiration date. A zero time.Time means + // the key never expires; check that with IsZero, not Unix, which is + // -62135596800 for the zero time. + Expires time.Time + BucketIDs []string + Prefix string + b2 *B2 } func keyBucketIDs(key b2types.Key) []string { @@ -2098,7 +2111,7 @@ func (b *B2) doCreateKeyWithState(ctx context.Context, state authState, apiVersi ID: b2resp.ID, Secret: b2resp.Secret, Capabilities: b2resp.Capabilities, - Expires: millitime(b2resp.Expires), + Expires: keyExpiration(b2resp.Expires), BucketIDs: keyBucketIDs(b2types.Key(*b2resp)), Prefix: b2resp.Prefix, b2: b, @@ -2138,7 +2151,7 @@ func (b *B2) ListKeys(ctx context.Context, max int, next string) ([]*Key, string Name: key.Name, ID: key.ID, Capabilities: key.Capabilities, - Expires: millitime(key.Expires), + Expires: keyExpiration(key.Expires), BucketIDs: keyBucketIDs(key), Prefix: key.Prefix, b2: b, diff --git a/base/key_expiration_test.go b/base/key_expiration_test.go new file mode 100644 index 0000000..41578b1 --- /dev/null +++ b/base/key_expiration_test.go @@ -0,0 +1,83 @@ +// Copyright 2026, the Blazer authors +// +// 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 base + +import ( + "context" + "testing" + "time" +) + +var keyExpirationTime = time.Date(2023, time.November, 14, 22, 13, 20, 123000000, time.UTC) + +func TestKeyExpiration(t *testing.T) { + const noExpirationCreate = `{"accountId":"account-id","applicationKeyId":"no-expiration","applicationKey":"secret","capabilities":["listBuckets"],"keyName":"no-expiration","expirationTimestamp":null}` + const missingExpirationCreate = `{"accountId":"account-id","applicationKeyId":"missing-expiration","applicationKey":"secret","capabilities":["listBuckets"],"keyName":"missing-expiration"}` + const expirationCreate = `{"accountId":"account-id","applicationKeyId":"expiration","applicationKey":"secret","capabilities":["listBuckets"],"keyName":"expiration","expirationTimestamp":1700000000123}` + const listResponse = `{"keys":[` + noExpirationCreate + `,` + missingExpirationCreate + `,` + expirationCreate + `],"nextApplicationKeyId":null}` + + t.Run("create without expiration", func(t *testing.T) { + b := newKeyRestrictionsB2(t, noExpirationCreate, `{"keys":[],"nextApplicationKeyId":null}`) + key, err := b.CreateKey(context.Background(), "no-expiration", []string{"listBuckets"}, 0, "", "") + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + if !key.Expires.IsZero() { + t.Errorf("CreateKey Expires = %v, want zero time", key.Expires) + } + }) + + t.Run("create with missing expiration", func(t *testing.T) { + b := newKeyRestrictionsB2(t, missingExpirationCreate, `{"keys":[],"nextApplicationKeyId":null}`) + key, err := b.CreateKey(context.Background(), "missing-expiration", []string{"listBuckets"}, 0, "", "") + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + if !key.Expires.IsZero() { + t.Errorf("CreateKey missing expiration Expires = %v, want zero time", key.Expires) + } + }) + + t.Run("create with expiration", func(t *testing.T) { + b := newKeyRestrictionsB2(t, expirationCreate, `{"keys":[],"nextApplicationKeyId":null}`) + key, err := b.CreateKey(context.Background(), "expiration", []string{"listBuckets"}, 0, "", "") + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + if !key.Expires.Equal(keyExpirationTime) { + t.Errorf("CreateKey Expires = %v, want %v", key.Expires, keyExpirationTime) + } + }) + + t.Run("list keys", func(t *testing.T) { + b := newKeyRestrictionsB2(t, `{}`, listResponse) + keys, _, err := b.ListKeys(context.Background(), 10, "") + if err != nil { + t.Fatalf("ListKeys: %v", err) + } + if len(keys) != 3 { + t.Fatalf("ListKeys returned %d keys, want 3", len(keys)) + } + if !keys[0].Expires.IsZero() { + t.Errorf("ListKeys no-expiration Expires = %v, want zero time", keys[0].Expires) + } + if !keys[1].Expires.IsZero() { + t.Errorf("ListKeys missing expiration Expires = %v, want zero time", keys[1].Expires) + } + if !keys[2].Expires.Equal(keyExpirationTime) { + t.Errorf("ListKeys expiration Expires = %v, want %v", keys[2].Expires, keyExpirationTime) + } + }) +}