2222import java .nio .file .Files ;
2323import java .nio .file .StandardOpenOption ;
2424
25+ import org .junit .Before ;
2526import org .junit .Test ;
2627
28+ import org .apache .cassandra .config .CassandraRelevantProperties ;
2729import org .apache .cassandra .cql3 .CQLTester ;
30+ import org .apache .cassandra .distributed .shared .WithProperties ;
2831import org .apache .cassandra .io .sstable .Component ;
2932import org .apache .cassandra .io .sstable .format .SSTableReader ;
33+ import org .apache .cassandra .schema .SchemaConstants ;
34+
35+ import static org .assertj .core .api .Assertions .assertThatCode ;
36+ import static org .assertj .core .api .Assertions .assertThatThrownBy ;
3037
3138public class SnapshotTest extends CQLTester
3239{
33- @ Test
34- public void testEmptyTOC () throws Throwable
40+ @ Before
41+ public void setUpTable () throws Throwable
3542 {
3643 createTable ("create table %s (id int primary key, k int)" );
3744 execute ("insert into %s (id, k) values (1,1)" );
45+ }
46+
47+ @ Test
48+ public void testEmptyTOC () throws Throwable
49+ {
3850 getCurrentColumnFamilyStore ().forceBlockingFlush ();
3951 for (SSTableReader sstable : getCurrentColumnFamilyStore ().getLiveSSTables ())
4052 {
@@ -43,4 +55,91 @@ public void testEmptyTOC() throws Throwable
4355 }
4456 getCurrentColumnFamilyStore ().snapshot ("hello" );
4557 }
58+
59+ @ Test
60+ public void testSnapshotNameValidation ()
61+ {
62+ ColumnFamilyStore cfs = getCurrentColumnFamilyStore ();
63+ String sep = File .separator ;
64+
65+ try (WithProperties p = new WithProperties ())
66+ {
67+ p .set (CassandraRelevantProperties .SNAPSHOT_NAME_VALIDATION , true );
68+
69+ // Previously-allowed alphanumerics, '-' and '_' must still be accepted.
70+ assertThatCode (() -> cfs .snapshot ("atag" )).doesNotThrowAnyException ();
71+ assertThatCode (() -> cfs .snapshot ("a-tag" )).doesNotThrowAnyException ();
72+ assertThatCode (() -> cfs .snapshot ("a_tag" )).doesNotThrowAnyException ();
73+ assertThatCode (() -> cfs .snapshot ("a_tag_1and_something2-more" )).doesNotThrowAnyException ();
74+ assertThatCode (() -> cfs .snapshot (repeat ('a' , SchemaConstants .FILENAME_LENGTH ))).doesNotThrowAnyException ();
75+
76+ // AWS S3 "Safe characters" accepted by the relaxed allowlist:
77+ // ! . * ' ( )
78+ // See https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html#object-key-guidelines
79+ assertThatCode (() -> cfs .snapshot ("snap.2026-05-20" )).doesNotThrowAnyException ();
80+ assertThatCode (() -> cfs .snapshot ("important!" )).doesNotThrowAnyException ();
81+ assertThatCode (() -> cfs .snapshot ("backup*" )).doesNotThrowAnyException ();
82+ assertThatCode (() -> cfs .snapshot ("o'snap" )).doesNotThrowAnyException ();
83+ assertThatCode (() -> cfs .snapshot ("snap(1)" )).doesNotThrowAnyException ();
84+ assertThatCode (() -> cfs .snapshot ("!._-*'()" )).doesNotThrowAnyException ();
85+ // Dots embedded in a name are not traversal: with '/' excluded, "a..tag" is just a literal directory.
86+ assertThatCode (() -> cfs .snapshot ("a..tag" )).doesNotThrowAnyException ();
87+
88+ String tooLong = repeat ('a' , SchemaConstants .FILENAME_LENGTH + 1 );
89+ assertThatThrownBy (() -> cfs .snapshot (tooLong ))
90+ .isInstanceOf (IllegalArgumentException .class )
91+ .hasMessage ("Snapshot name must not be more than 255 characters long for " +
92+ "resolved snapshot name (got 256 characters for \" " + tooLong + "\" )" );
93+
94+ // '/' is not in the S3-safe set; this is what kills traversal attempts like "../../mysnapshot".
95+ assertThatThrownBy (() -> cfs .snapshot ("a" + sep + "tag" ))
96+ .isInstanceOf (IllegalArgumentException .class )
97+ .hasMessage ("Snapshot name cannot contain " + sep );
98+
99+ // Other characters outside the S3-safe set must still be rejected.
100+ assertThatThrownBy (() -> cfs .snapshot ("a tag" ))
101+ .isInstanceOf (IllegalArgumentException .class )
102+ .hasMessage ("Snapshot name contains illegal characters: a tag" );
103+ assertThatThrownBy (() -> cfs .snapshot ("a:tag" ))
104+ .isInstanceOf (IllegalArgumentException .class )
105+ .hasMessage ("Snapshot name contains illegal characters: a:tag" );
106+
107+ // "." and ".." pass the charset check but resolve to the snapshots/ dir itself
108+ // and its parent (the live table dir) respectively, so they must be rejected as reserved.
109+ assertThatThrownBy (() -> cfs .snapshot ("." ))
110+ .isInstanceOf (IllegalArgumentException .class )
111+ .hasMessage ("Snapshot name '.' is reserved" );
112+ assertThatThrownBy (() -> cfs .snapshot (".." ))
113+ .isInstanceOf (IllegalArgumentException .class )
114+ .hasMessage ("Snapshot name '..' is reserved" );
115+ }
116+
117+ try (WithProperties p = new WithProperties ())
118+ {
119+ p .set (CassandraRelevantProperties .SNAPSHOT_NAME_VALIDATION , false );
120+
121+ // Previously-rejected characters are now accepted: space, ':', and other non-S3-safe chars.
122+ assertThatCode (() -> cfs .snapshot ("a tag" )).doesNotThrowAnyException ();
123+ assertThatCode (() -> cfs .snapshot ("a:tag" )).doesNotThrowAnyException ();
124+
125+ // Path separator and "." / ".." rejections are unconditional — they guard against
126+ // traversal regardless of the toggle.
127+ assertThatThrownBy (() -> cfs .snapshot ("a" + sep + "tag" ))
128+ .isInstanceOf (IllegalArgumentException .class )
129+ .hasMessage ("Snapshot name cannot contain " + sep );
130+ assertThatThrownBy (() -> cfs .snapshot ("." ))
131+ .isInstanceOf (IllegalArgumentException .class )
132+ .hasMessage ("Snapshot name '.' is reserved" );
133+ assertThatThrownBy (() -> cfs .snapshot (".." ))
134+ .isInstanceOf (IllegalArgumentException .class )
135+ .hasMessage ("Snapshot name '..' is reserved" );
136+ }
137+ }
138+
139+ private static String repeat (char c , int times )
140+ {
141+ char [] chars = new char [times ];
142+ java .util .Arrays .fill (chars , c );
143+ return new String (chars );
144+ }
46145}
0 commit comments