@@ -84,11 +84,40 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
8484 * branches exist for) can carry `"365"` where the schema says number.
8585 */
8686function coerceCfnNumber ( value : unknown ) : number | undefined {
87- if ( value === undefined || value === null || value === '' ) return undefined ;
87+ // Screen the TYPE first: `Number([])` is 0 and `Number([5])` is 5, so an
88+ // unresolved-intrinsic array would coerce to a plausible-looking day count.
89+ // That is the same class `isPlainObject` blocks on the object path.
90+ if ( typeof value !== 'number' && typeof value !== 'string' ) return undefined ;
91+ if ( value === '' ) return undefined ;
8892 const n = Number ( value ) ;
8993 return Number . isFinite ( n ) ? n : undefined ;
9094}
9195
96+ /**
97+ * Merge a legacy SINGULAR action object into its modern plural array.
98+ *
99+ * S3 rejects two transitions with the same `StorageClass` and fails the whole
100+ * `PutBucketLifecycleConfiguration`, so a blind concatenation is unsafe. But
101+ * dropping the singular wholesale loses a legitimate template: a plural
102+ * `[{GLACIER, 30}]` alongside a singular `{DEEP_ARCHIVE, 90}` is two different
103+ * classes, which S3 accepts. So: keep both, let the PLURAL win on a
104+ * StorageClass collision, and say so rather than dropping in silence.
105+ */
106+ function mergeLegacySingular (
107+ plural : unknown ,
108+ singular : unknown ,
109+ onCollision : ( storageClass : string ) => void
110+ ) : Array < Record < string , unknown > > {
111+ const out = ( Array . isArray ( plural ) ? plural : [ ] ) . filter ( isPlainObject ) ;
112+ if ( ! isPlainObject ( singular ) ) return out ;
113+ const sc = singular [ 'StorageClass' ] ;
114+ if ( typeof sc === 'string' && out . some ( ( e ) => e [ 'StorageClass' ] === sc ) ) {
115+ onCollision ( sc ) ;
116+ return out ;
117+ }
118+ return [ ...out , singular ] ;
119+ }
120+
92121/** Coerce a CFn boolean, which may arrive as the string `"true"` / `"false"`. */
93122function coerceCfnBoolean ( value : unknown ) : boolean | undefined {
94123 if ( typeof value === 'boolean' ) return value ;
@@ -432,12 +461,16 @@ export class S3BucketProvider implements ResourceProvider {
432461 // object with every member `undefined` for an empty / unresolved
433462 // `Expiration`, so an existence check would drop the marker AND leave the
434463 // rule action-less — the exact failure this block exists to prevent.
464+ // Only TRUE engages. `false` is a legal synth (CDK's own validation is
465+ // truthy-gated), and treating it as a request would both warn about a
466+ // cleanup nobody asked for and, on a marker-only rule, emit
467+ // `Expiration: { ExpiredObjectDeleteMarker: false }` — action-less again.
435468 const ruleLevelDeleteMarker = coerceCfnBoolean ( rule [ 'ExpiredObjectDeleteMarker' ] ) ;
436- if ( ruleLevelDeleteMarker !== undefined ) {
469+ if ( ruleLevelDeleteMarker === true ) {
437470 const exp = sdkRule . Expiration as Record < string , unknown > | undefined ;
438471 const hasDaysOrDate = exp ?. [ 'Days' ] !== undefined || exp ?. [ 'Date' ] !== undefined ;
439472 if ( ! hasDaysOrDate ) {
440- sdkRule . Expiration = { ExpiredObjectDeleteMarker : ruleLevelDeleteMarker } ;
473+ sdkRule . Expiration = { ExpiredObjectDeleteMarker : true } ;
441474 } else {
442475 // S3 rejects ExpiredObjectDeleteMarker combined with Days / Date, so
443476 // one of the two has to go. Warn instead of dropping in silence.
@@ -452,7 +485,9 @@ export class S3BucketProvider implements ResourceProvider {
452485 // NoncurrentVersionExpiration. The CFn schema ALSO still accepts the
453486 // legacy scalar `NoncurrentVersionExpirationInDays` alongside the modern
454487 // object form; the object wins when both are present (issue #1388).
455- const nve = rule [ 'NoncurrentVersionExpiration' ] as Record < string , unknown > | undefined ;
488+ const nve = isPlainObject ( rule [ 'NoncurrentVersionExpiration' ] )
489+ ? rule [ 'NoncurrentVersionExpiration' ]
490+ : undefined ;
456491 const legacyNveDays = rule [ 'NoncurrentVersionExpirationInDays' ] ;
457492 if ( nve ) {
458493 sdkRule . NoncurrentVersionExpiration = {
@@ -468,8 +503,8 @@ export class S3BucketProvider implements ResourceProvider {
468503
469504 // NoncurrentVersionTransitions, plus the legacy singular
470505 // `NoncurrentVersionTransition` object the CFn schema still accepts.
471- // Both may appear on one rule, so they are concatenated rather than
472- // treated as alternatives .
506+ // Both may appear on one rule; they are MERGED, with the plural winning
507+ // on a StorageClass collision (see `mergeLegacySingular`) .
473508 const toSdkNvt = ( nvt : Record < string , unknown > ) : Record < string , unknown > => ( {
474509 // CFn spells the day count `TransitionInDays` on BOTH the singular
475510 // `NoncurrentVersionTransition` and the plural
@@ -489,25 +524,19 @@ export class S3BucketProvider implements ResourceProvider {
489524 const singularNvt = rule [ 'NoncurrentVersionTransition' ] as
490525 | Record < string , unknown >
491526 | undefined ;
492- // The PLURAL array wins when both forms are present, matching the
493- // NoncurrentVersionExpiration policy 30 lines above. Concatenating them
494- // instead would be a REGRESSION: S3 rejects two transitions with the same
495- // StorageClass (`InvalidRequest: Found two transitions with the same
496- // storage class`) and fails the whole PutBucketLifecycleConfiguration,
497- // whereas pre-fix such a template deployed because the singular form was
498- // simply ignored.
499- const allNvts =
500- Array . isArray ( nvts ) && nvts . length > 0
501- ? nvts
502- : isPlainObject ( singularNvt )
503- ? [ singularNvt ]
504- : [ ] ;
527+ const allNvts = mergeLegacySingular ( nvts , singularNvt , ( sc ) =>
528+ this . logger . warn (
529+ `Lifecycle rule '${ ( rule [ 'Id' ] as string ) ?? '<unnamed>' } ' on ${ bucketName } declares ` +
530+ `both NoncurrentVersionTransitions and the legacy NoncurrentVersionTransition for ` +
531+ `storage class ${ sc } ; S3 rejects duplicates, so the legacy singular was ignored.`
532+ )
533+ ) ;
505534 if ( allNvts . length > 0 ) {
506535 sdkRule . NoncurrentVersionTransitions = allNvts . map ( toSdkNvt ) ;
507536 }
508537
509- // Transitions, plus the legacy singular `Transition` object. Same
510- // concatenation rule as the noncurrent-version pair above.
538+ // Transitions, plus the legacy singular `Transition` object. Same merge
539+ // rule as the noncurrent-version pair above.
511540 const toSdkTransition = ( t : Record < string , unknown > ) : Record < string , unknown > => ( {
512541 Days : ( t [ 'TransitionInDays' ] ?? t [ 'Days' ] ) as number | undefined ,
513542 Date :
@@ -518,20 +547,21 @@ export class S3BucketProvider implements ResourceProvider {
518547 } ) ;
519548 const transitions = rule [ 'Transitions' ] as Array < Record < string , unknown > > | undefined ;
520549 const singularTransition = rule [ 'Transition' ] as Record < string , unknown > | undefined ;
521- // Plural wins over the legacy singular — see the NVT note above for why
522- // concatenating is not safe.
523- const allTransitions =
524- Array . isArray ( transitions ) && transitions . length > 0
525- ? transitions
526- : isPlainObject ( singularTransition )
527- ? [ singularTransition ]
528- : [ ] ;
550+ const allTransitions = mergeLegacySingular ( transitions , singularTransition , ( sc ) =>
551+ this . logger . warn (
552+ `Lifecycle rule '${ ( rule [ 'Id' ] as string ) ?? '<unnamed>' } ' on ${ bucketName } declares ` +
553+ `both Transitions and the legacy Transition for storage class ${ sc } ; S3 rejects ` +
554+ `duplicates, so the legacy singular was ignored.`
555+ )
556+ ) ;
529557 if ( allTransitions . length > 0 ) {
530558 sdkRule . Transitions = allTransitions . map ( toSdkTransition ) ;
531559 }
532560
533561 // AbortIncompleteMultipartUpload
534- const abort = rule [ 'AbortIncompleteMultipartUpload' ] as Record < string , unknown > | undefined ;
562+ const abort = isPlainObject ( rule [ 'AbortIncompleteMultipartUpload' ] )
563+ ? rule [ 'AbortIncompleteMultipartUpload' ]
564+ : undefined ;
535565 if ( abort ) {
536566 sdkRule . AbortIncompleteMultipartUpload = {
537567 DaysAfterInitiation : abort [ 'DaysAfterInitiation' ] as number | undefined ,
0 commit comments