2626
2727import org .junit .Test ;
2828
29+ import org .apache .cassandra .config .CassandraRelevantProperties ;
30+ import org .apache .cassandra .distributed .shared .WithProperties ;
31+ import org .apache .cassandra .io .util .File ;
32+
2933import static java .lang .String .format ;
34+ import static org .apache .cassandra .service .snapshot .SnapshotType .USER ;
35+ import static org .assertj .core .api .Assertions .assertThatCode ;
36+ import static org .assertj .core .api .Assertions .assertThatThrownBy ;
3037import static org .junit .Assert .assertEquals ;
3138
3239public class SnapshotOptionsTest
3340{
41+ @ Test
42+ public void testSnapshotNameValidation ()
43+ {
44+ String sep = File .pathSeparator ();
45+
46+ try (WithProperties p = new WithProperties ().set (CassandraRelevantProperties .SNAPSHOT_NAME_VALIDATION , true ))
47+ {
48+ // Previously-allowed alphanumerics, '-' and '_' must still be accepted.
49+ validate ("atag" , USER );
50+ validate ("a-tag" , USER );
51+ validate ("a_tag" , USER );
52+ validate ("a_tag" + Instant .now ().toEpochMilli (), USER );
53+ validate ("a_tag_1and_something2-more" , USER );
54+ validate ("a" .repeat (255 ), USER );
55+
56+ // AWS S3 "Safe characters" newly accepted by the relaxed allowlist:
57+ // ! . * ' ( )
58+ // See https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html#object-key-guidelines
59+ validate ("snap.2026-05-20" , USER );
60+ validate ("important!" , USER );
61+ validate ("backup*" , USER );
62+ validate ("o'snap" , USER );
63+ validate ("snap(1)" , USER );
64+ validate ("!._-*'()" , USER );
65+ // Dots embedded in a name are not traversal: with '/' excluded, "a..tag" is just a literal directory.
66+ validate ("a..tag" , USER );
67+
68+ assertThatThrownBy (() -> validate ("a" .repeat (256 ), USER ))
69+ .isInstanceOf (IllegalArgumentException .class )
70+ .hasMessage ("Snapshot name must not be more than 255 characters long for " +
71+ "resolved snapshot name (got 256 characters for \" " + "a" .repeat (256 ) + "\" )" );
72+
73+ // this would append timestamp + type which would violate < 255 when tag is 250 chars long only
74+ assertThatThrownBy (() -> validate ("a" .repeat (250 ), SnapshotType .UPGRADE ))
75+ .isInstanceOf (IllegalArgumentException .class )
76+ .hasMessageContaining ("Snapshot name must not be more than 255 characters long" );
77+
78+ // '/' is not in the S3-safe set; this is what kills traversal attempts like "../../mysnapshot".
79+ assertThatThrownBy (() -> validate ('a' + sep + "tag" , USER ))
80+ .isInstanceOf (IllegalArgumentException .class )
81+ .hasMessage ("Snapshot name cannot contain " + sep );
82+
83+ // Other characters outside the S3-safe set must still be rejected.
84+ assertThatThrownBy (() -> validate ("a tag" , USER ))
85+ .isInstanceOf (IllegalArgumentException .class )
86+ .hasMessage ("Snapshot name contains illegal characters: a tag" );
87+ assertThatThrownBy (() -> validate ("a:tag" , USER ))
88+ .isInstanceOf (IllegalArgumentException .class )
89+ .hasMessage ("Snapshot name contains illegal characters: a:tag" );
90+
91+ // "." and ".." pass the charset check but resolve to the snapshots/ dir itself
92+ // and its parent (the live table dir) respectively, so they must be rejected as reserved.
93+ assertThatThrownBy (() -> validate ("." , USER ))
94+ .isInstanceOf (IllegalArgumentException .class )
95+ .hasMessage ("Snapshot name '.' is reserved" );
96+
97+ assertThatThrownBy (() -> validate (".." , USER ))
98+ .isInstanceOf (IllegalArgumentException .class )
99+ .hasMessage ("Snapshot name '..' is reserved" );
100+ }
101+
102+ try (WithProperties p = new WithProperties ().set (CassandraRelevantProperties .SNAPSHOT_NAME_VALIDATION , false ))
103+ {
104+ // Previously-rejected characters are now accepted: space, ':', and other non-S3-safe chars.
105+ assertThatCode (() -> validate ("a tag" , USER )).doesNotThrowAnyException ();
106+ assertThatCode (() -> validate ("a:tag" , USER )).doesNotThrowAnyException ();
107+
108+ assertThatCode (() -> validate ("a" .repeat (256 ), USER )).doesNotThrowAnyException ();
109+ assertThatCode (() -> validate ("a" .repeat (250 ), SnapshotType .UPGRADE )).doesNotThrowAnyException ();
110+
111+ // Path separator and "." / ".." rejections are unconditional — they guard against
112+ // traversal regardless of the toggle.
113+ assertThatThrownBy (() -> validate ('a' + sep + "tag" , USER ))
114+ .isInstanceOf (IllegalArgumentException .class )
115+ .hasMessage ("Snapshot name cannot contain " + sep );
116+ assertThatThrownBy (() -> validate ("." , USER ))
117+ .isInstanceOf (IllegalArgumentException .class )
118+ .hasMessage ("Snapshot name '.' is reserved" );
119+ assertThatThrownBy (() -> validate (".." , USER ))
120+ .isInstanceOf (IllegalArgumentException .class )
121+ .hasMessage ("Snapshot name '..' is reserved" );
122+ }
123+ }
124+
125+ private void validate (String tag , SnapshotType type )
126+ {
127+ new SnapshotOptions .Builder (tag , type , s -> true , "ks.tb" ).rateLimiter (RateLimiter .create (1 )).build ();
128+ }
129+
34130 @ Test
35131 public void testSnapshotName ()
36132 {
37- List <SnapshotType > sameNameTypes = List .of (SnapshotType .DIAGNOSTICS , SnapshotType .REPAIR , SnapshotType . USER );
133+ List <SnapshotType > sameNameTypes = List .of (SnapshotType .DIAGNOSTICS , SnapshotType .REPAIR , USER );
38134
39135 for (SnapshotType type : sameNameTypes )
40136 {
41137 SnapshotOptions options = SnapshotOptions .systemSnapshot ("a_name" , type , "ks.tb" )
42138 .rateLimiter (RateLimiter .create (5 ))
43139 .build ();
44140
45- String snapshotName = options .getSnapshotName (Instant .now ());
141+ String snapshotName = SnapshotOptions .getSnapshotName (type , options . tag , Instant .now ());
46142 assertEquals ("a_name" , snapshotName );
47143 }
48144
@@ -57,7 +153,7 @@ public void testSnapshotName()
57153
58154 Instant now = Instant .now ();
59155
60- String snapshotName = options .getSnapshotName (now );
156+ String snapshotName = SnapshotOptions .getSnapshotName (options . type , options . tag , now );
61157
62158 assertEquals (format ("%d-%s-%s" , now .toEpochMilli (), type .label , "a_name" ), snapshotName );
63159 }
0 commit comments