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+ // Only alphanumerics, '-', '_' and '.' are 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+ validate ("snap.2026-05-20" , USER );
56+ // Dots embedded in a name are not traversal: with '/' excluded, "a..tag" is just a literal directory.
57+ validate ("a..tag" , USER );
58+
59+ assertThatThrownBy (() -> validate ("a" .repeat (256 ), USER ))
60+ .isInstanceOf (IllegalArgumentException .class )
61+ .hasMessage ("Snapshot name must not be more than 255 characters long for " +
62+ "resolved snapshot name (got 256 characters for \" " + "a" .repeat (256 ) + "\" )" );
63+
64+ // this would append timestamp + type which would violate < 255 when tag is 250 chars long only
65+ assertThatThrownBy (() -> validate ("a" .repeat (250 ), SnapshotType .UPGRADE ))
66+ .isInstanceOf (IllegalArgumentException .class )
67+ .hasMessageContaining ("Snapshot name must not be more than 255 characters long" );
68+
69+ // '/' is not in the allowed set; this is what kills traversal attempts like "../../mysnapshot".
70+ assertThatThrownBy (() -> validate ('a' + sep + "tag" , USER ))
71+ .isInstanceOf (IllegalArgumentException .class )
72+ .hasMessage ("Snapshot name cannot contain " + sep );
73+
74+ // The shell-significant S3 "safe" characters (! * ' ( )) are deliberately NOT allowed.
75+ assertThatThrownBy (() -> validate ("important!" , USER ))
76+ .isInstanceOf (IllegalArgumentException .class )
77+ .hasMessageContaining ("Snapshot name contains illegal characters: important!" );
78+ assertThatThrownBy (() -> validate ("backup*" , USER ))
79+ .isInstanceOf (IllegalArgumentException .class )
80+ .hasMessageContaining ("Snapshot name contains illegal characters: backup*" );
81+ assertThatThrownBy (() -> validate ("o'snap" , USER ))
82+ .isInstanceOf (IllegalArgumentException .class )
83+ .hasMessageContaining ("Snapshot name contains illegal characters: o'snap" );
84+ assertThatThrownBy (() -> validate ("snap(1)" , USER ))
85+ .isInstanceOf (IllegalArgumentException .class )
86+ .hasMessageContaining ("Snapshot name contains illegal characters: snap(1)" );
87+
88+ // Other characters outside the allowed set must still be rejected.
89+ assertThatThrownBy (() -> validate ("a tag" , USER ))
90+ .isInstanceOf (IllegalArgumentException .class )
91+ .hasMessageContaining ("Snapshot name contains illegal characters: a tag" );
92+ assertThatThrownBy (() -> validate ("a:tag" , USER ))
93+ .isInstanceOf (IllegalArgumentException .class )
94+ .hasMessageContaining ("Snapshot name contains illegal characters: a:tag" );
95+
96+ // "." and ".." pass the charset check but resolve to the snapshots/ dir itself
97+ // and its parent (the live table dir) respectively, so they must be rejected as reserved.
98+ assertThatThrownBy (() -> validate ("." , USER ))
99+ .isInstanceOf (IllegalArgumentException .class )
100+ .hasMessage ("Snapshot name '.' is reserved" );
101+
102+ assertThatThrownBy (() -> validate (".." , USER ))
103+ .isInstanceOf (IllegalArgumentException .class )
104+ .hasMessage ("Snapshot name '..' is reserved" );
105+
106+ // "+" is part of the allowed set because it can appear in a Cassandra version
107+ // (build metadata, e.g. "7.0.0+abc123"), which ends up in system snapshot names.
108+ // The character check applies uniformly to all snapshot types, so "+" is accepted
109+ // for system and user snapshots alike.
110+ validate ("this_is_system_snapshot-7.0.0+abc123" , SnapshotType .UPGRADE );
111+ validate ("this_is_snapshot-7.0.0+abc123" , USER );
112+ }
113+
114+ try (WithProperties p = new WithProperties ().set (CassandraRelevantProperties .SNAPSHOT_NAME_VALIDATION , false ))
115+ {
116+ // The character check is bypassed entirely: space, ':', and the now-disallowed
117+ // shell-significant characters (! * ' ( )) are all accepted.
118+ assertThatCode (() -> validate ("a tag" , USER )).doesNotThrowAnyException ();
119+ assertThatCode (() -> validate ("a:tag" , USER )).doesNotThrowAnyException ();
120+ assertThatCode (() -> validate ("important!" , USER )).doesNotThrowAnyException ();
121+ assertThatCode (() -> validate ("snap(1)" , USER )).doesNotThrowAnyException ();
122+
123+ assertThatCode (() -> validate ("a" .repeat (256 ), USER )).doesNotThrowAnyException ();
124+ assertThatCode (() -> validate ("a" .repeat (250 ), SnapshotType .UPGRADE )).doesNotThrowAnyException ();
125+
126+ // Path separator and "." / ".." rejections are unconditional — they guard against
127+ // traversal regardless of the toggle.
128+ assertThatThrownBy (() -> validate ('a' + sep + "tag" , USER ))
129+ .isInstanceOf (IllegalArgumentException .class )
130+ .hasMessage ("Snapshot name cannot contain " + sep );
131+ assertThatThrownBy (() -> validate ("." , USER ))
132+ .isInstanceOf (IllegalArgumentException .class )
133+ .hasMessage ("Snapshot name '.' is reserved" );
134+ assertThatThrownBy (() -> validate (".." , USER ))
135+ .isInstanceOf (IllegalArgumentException .class )
136+ .hasMessage ("Snapshot name '..' is reserved" );
137+ }
138+ }
139+
140+ private void validate (String tag , SnapshotType type )
141+ {
142+ new SnapshotOptions .Builder (tag , type , s -> true , "ks.tb" ).rateLimiter (RateLimiter .create (1 )).build ();
143+ }
144+
34145 @ Test
35146 public void testSnapshotName ()
36147 {
37- List <SnapshotType > sameNameTypes = List .of (SnapshotType .DIAGNOSTICS , SnapshotType .REPAIR , SnapshotType . USER );
148+ List <SnapshotType > sameNameTypes = List .of (SnapshotType .DIAGNOSTICS , SnapshotType .REPAIR , USER );
38149
39150 for (SnapshotType type : sameNameTypes )
40151 {
41152 SnapshotOptions options = SnapshotOptions .systemSnapshot ("a_name" , type , "ks.tb" )
42153 .rateLimiter (RateLimiter .create (5 ))
43154 .build ();
44155
45- String snapshotName = options .getSnapshotName (Instant .now ());
156+ String snapshotName = SnapshotOptions .getSnapshotName (type , options . tag , Instant .now ());
46157 assertEquals ("a_name" , snapshotName );
47158 }
48159
@@ -57,7 +168,7 @@ public void testSnapshotName()
57168
58169 Instant now = Instant .now ();
59170
60- String snapshotName = options .getSnapshotName (now );
171+ String snapshotName = SnapshotOptions .getSnapshotName (options . type , options . tag , now );
61172
62173 assertEquals (format ("%d-%s-%s" , now .toEpochMilli (), type .label , "a_name" ), snapshotName );
63174 }
0 commit comments