diff --git a/components/lifecycle/new-form.tsx b/components/lifecycle/new-form.tsx index e2e8cfe9..a024b70c 100644 --- a/components/lifecycle/new-form.tsx +++ b/components/lifecycle/new-form.tsx @@ -19,6 +19,7 @@ import { isMissingBucketConfiguration } from "@/lib/bucket-configuration" import { buildCurrentVersionExpirationRules, buildLifecycleFilter, + buildNoncurrentVersionExpirationRule, findIncompleteLifecycleTag, getBucketVersioningMode, hasCompleteLifecycleTags, @@ -127,10 +128,6 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }: } }, [open, loadTiers, loadVersioningStatus, tiersReloadVersion, versioningReloadVersion]) - useEffect(() => { - if (activeTab !== "expire" || versionType !== "current") setExpiredDeleteMark(false) - }, [activeTab, versionType]) - useEffect(() => { if (!hasVersionHistory) { setVersionType("current") @@ -186,7 +183,7 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }: if (incompleteTagIndex >= 0) { errors.tags = tags[incompleteTagIndex].key.trim() ? t("Please enter tag value") : t("Please enter key name") } - if (activeTab === "expire" && versionType === "current" && expiredDeleteMark && hasCompleteLifecycleTags(tags)) { + if (activeTab === "expire" && expiredDeleteMark && hasCompleteLifecycleTags(tags)) { errors.deleteMarker = `${t("Delete Marker Handling")}: ${t("Tags")} ${t("Unsupported")}` } @@ -243,8 +240,7 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }: if (activeTab === "expire") { if (versionType === "non-current") { - baseRule.NoncurrentVersionExpiration = { NoncurrentDays: daysValue } - newRules = [baseRule] + newRules = [buildNoncurrentVersionExpirationRule(baseId, daysValue, filter, expiredDeleteMark)] } else { newRules = buildCurrentVersionExpirationRules(baseId, daysValue, filter, expiredDeleteMark) } @@ -490,7 +486,7 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }: - {hasVersionHistory && versionType === "current" && ( + {hasVersionHistory && (
{t("Advanced Settings")} @@ -539,7 +535,7 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }: ) : null} - {activeTab === "expire" && expiredDeleteMark ? ( + {activeTab === "expire" && versionType === "current" && expiredDeleteMark ? (

{t("2 lifecycle rules will be created.")}

) : null} diff --git a/lib/bucket-lifecycle.ts b/lib/bucket-lifecycle.ts index 2a17b6af..d26f5e54 100644 --- a/lib/bucket-lifecycle.ts +++ b/lib/bucket-lifecycle.ts @@ -68,3 +68,18 @@ export function buildCurrentVersionExpirationRules( return rules } + +export function buildNoncurrentVersionExpirationRule( + id: string, + days: number, + filter: Record, + cleanupExpiredDeleteMarkers: boolean, +): Record { + return { + ID: id, + Status: "Enabled", + Filter: filter, + NoncurrentVersionExpiration: { NoncurrentDays: days }, + ...(cleanupExpiredDeleteMarkers ? { Expiration: { ExpiredObjectDeleteMarker: true } } : {}), + } +} diff --git a/tests/lib/bucket-settings-safety.test.js b/tests/lib/bucket-settings-safety.test.js index 81d48686..e90a755d 100644 --- a/tests/lib/bucket-settings-safety.test.js +++ b/tests/lib/bucket-settings-safety.test.js @@ -11,6 +11,7 @@ import { import { buildCurrentVersionExpirationRules, buildLifecycleFilter, + buildNoncurrentVersionExpirationRule, findIncompleteLifecycleTag, getBucketVersioningMode, hasCompleteLifecycleTags, @@ -155,6 +156,39 @@ test("AWS SDK serializes expiration days and delete-marker cleanup as two valid assert.deepEqual(expirations, ["30", "true"]) }) +test("noncurrent expiration and delete-marker cleanup serialize as one rule without current expiry", async () => { + let requestBody = "" + const client = new S3Client({ + region: "us-east-1", + endpoint: "http://127.0.0.1:9000", + credentials: { accessKeyId: "test", secretAccessKey: "test" }, + requestHandler: { + async handle(request) { + requestBody = typeof request.body === "string" ? request.body : new TextDecoder().decode(request.body) + return { response: { statusCode: 200, headers: {}, body: new Uint8Array() } } + }, + }, + }) + + await client.send( + new PutBucketLifecycleConfigurationCommand({ + Bucket: "test-bucket", + LifecycleConfiguration: { + Rules: [buildNoncurrentVersionExpirationRule("rule", 1, { Prefix: "" }, true)], + }, + }), + ) + client.destroy() + + assert.equal((requestBody.match(//g) ?? []).length, 1) + assert.match( + requestBody, + /1<\/NoncurrentDays><\/NoncurrentVersionExpiration>/, + ) + assert.match(requestBody, /true<\/ExpiredObjectDeleteMarker><\/Expiration>/) + assert.doesNotMatch(requestBody, /<(?:Days|Date)>/) +}) + test("lifecycle helpers preserve suspended versioning and reject partial tag pairs", () => { assert.equal(MAX_LIFECYCLE_RULES, 1000) assert.equal(getBucketVersioningMode("Enabled"), "enabled") @@ -171,11 +205,19 @@ test("lifecycle helpers preserve suspended versioning and reject partial tag pai lifecycleFormSource, /buildCurrentVersionExpirationRules\(baseId, daysValue, filter, expiredDeleteMark\)/, ) + assert.match( + lifecycleFormSource, + /buildNoncurrentVersionExpirationRule\(baseId, daysValue, filter, expiredDeleteMark\)/, + ) assert.match(lifecycleFormSource, /Rules: \[\.\.\.existingRules, \.\.\.newRules\]/) assert.match(lifecycleFormSource, /existingRules\.length \+ newRules\.length > MAX_LIFECYCLE_RULES/) assert.match(lifecycleFormSource, /const hasVersionHistory = versioningMode !== "unversioned"/) - assert.match(lifecycleFormSource, /expiredDeleteMark && hasCompleteLifecycleTags\(tags\)/) + assert.match(lifecycleFormSource, /activeTab === "expire" && expiredDeleteMark && hasCompleteLifecycleTags\(tags\)/) assert.match(lifecycleFormSource, /setExpiredDeleteMark\(checked === true\)/) + assert.doesNotMatch( + lifecycleFormSource, + /activeTab !== "expire" \|\| versionType !== "current"\) setExpiredDeleteMark\(false\)/, + ) }) test("bucket reads fail closed and stale responses cannot update the active bucket", () => {