2121import java .nio .file .Files ;
2222import java .nio .file .StandardOpenOption ;
2323
24+ import org .junit .Before ;
2425import org .junit .Test ;
2526
27+ import org .apache .cassandra .config .CassandraRelevantProperties ;
2628import org .apache .cassandra .cql3 .CQLTester ;
29+ import org .apache .cassandra .distributed .shared .WithProperties ;
2730import org .apache .cassandra .io .sstable .format .SSTableFormat .Components ;
2831import org .apache .cassandra .io .sstable .format .SSTableReader ;
2932import org .apache .cassandra .io .util .File ;
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 (ColumnFamilyStore .FlushReason .UNIT_TESTS );
3951 for (SSTableReader sstable : getCurrentColumnFamilyStore ().getLiveSSTables ())
4052 {
@@ -43,4 +55,87 @@ 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 .pathSeparator ();
64+
65+ try (WithProperties p = new WithProperties ().set (CassandraRelevantProperties .SNAPSHOT_NAME_VALIDATION , true ))
66+ {
67+ // Previously-allowed alphanumerics, '-' and '_' must still be accepted.
68+ assertThatCode (() -> cfs .snapshot ("atag" )).doesNotThrowAnyException ();
69+ assertThatCode (() -> cfs .snapshot ("a-tag" )).doesNotThrowAnyException ();
70+ assertThatCode (() -> cfs .snapshot ("a_tag" )).doesNotThrowAnyException ();
71+ assertThatCode (() -> cfs .snapshot ("a_tag_1and_something2-more" )).doesNotThrowAnyException ();
72+ assertThatCode (() -> cfs .snapshot (repeat ('a' , SchemaConstants .FILENAME_LENGTH ))).doesNotThrowAnyException ();
73+
74+ // AWS S3 "Safe characters" accepted by the relaxed allowlist:
75+ // ! . * ' ( )
76+ // See https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html#object-key-guidelines
77+ assertThatCode (() -> cfs .snapshot ("snap.2026-05-20" )).doesNotThrowAnyException ();
78+ assertThatCode (() -> cfs .snapshot ("important!" )).doesNotThrowAnyException ();
79+ assertThatCode (() -> cfs .snapshot ("backup*" )).doesNotThrowAnyException ();
80+ assertThatCode (() -> cfs .snapshot ("o'snap" )).doesNotThrowAnyException ();
81+ assertThatCode (() -> cfs .snapshot ("snap(1)" )).doesNotThrowAnyException ();
82+ assertThatCode (() -> cfs .snapshot ("!._-*'()" )).doesNotThrowAnyException ();
83+ // Dots embedded in a name are not traversal: with '/' excluded, "a..tag" is just a literal directory.
84+ assertThatCode (() -> cfs .snapshot ("a..tag" )).doesNotThrowAnyException ();
85+
86+ String tooLong = repeat ('a' , SchemaConstants .FILENAME_LENGTH + 1 );
87+ assertThatThrownBy (() -> cfs .snapshot (tooLong ))
88+ .isInstanceOf (IllegalArgumentException .class )
89+ .hasMessage ("Snapshot name must not be more than 255 characters long for " +
90+ "resolved snapshot name (got 256 characters for \" " + tooLong + "\" )" );
91+
92+ // '/' is not in the S3-safe set; this is what kills traversal attempts like "../../mysnapshot".
93+ assertThatThrownBy (() -> cfs .snapshot ("a" + sep + "tag" ))
94+ .isInstanceOf (IllegalArgumentException .class )
95+ .hasMessage ("Snapshot name cannot contain " + sep );
96+
97+ // Other characters outside the S3-safe set must still be rejected.
98+ assertThatThrownBy (() -> cfs .snapshot ("a tag" ))
99+ .isInstanceOf (IllegalArgumentException .class )
100+ .hasMessage ("Snapshot name contains illegal characters: a tag" );
101+ assertThatThrownBy (() -> cfs .snapshot ("a:tag" ))
102+ .isInstanceOf (IllegalArgumentException .class )
103+ .hasMessage ("Snapshot name contains illegal characters: a:tag" );
104+
105+ // "." and ".." pass the charset check but resolve to the snapshots/ dir itself
106+ // and its parent (the live table dir) respectively, so they must be rejected as reserved.
107+ assertThatThrownBy (() -> cfs .snapshot ("." ))
108+ .isInstanceOf (IllegalArgumentException .class )
109+ .hasMessage ("Snapshot name '.' is reserved" );
110+ assertThatThrownBy (() -> cfs .snapshot (".." ))
111+ .isInstanceOf (IllegalArgumentException .class )
112+ .hasMessage ("Snapshot name '..' is reserved" );
113+ }
114+
115+ try (WithProperties p = new WithProperties ().set (CassandraRelevantProperties .SNAPSHOT_NAME_VALIDATION , false ))
116+ {
117+ // Previously-rejected characters are now accepted: space, ':', and other non-S3-safe chars.
118+ assertThatCode (() -> cfs .snapshot ("a tag" )).doesNotThrowAnyException ();
119+ assertThatCode (() -> cfs .snapshot ("a:tag" )).doesNotThrowAnyException ();
120+
121+ // Path separator and "." / ".." rejections are unconditional — they guard against
122+ // traversal regardless of the toggle.
123+ assertThatThrownBy (() -> cfs .snapshot ("a" + sep + "tag" ))
124+ .isInstanceOf (IllegalArgumentException .class )
125+ .hasMessage ("Snapshot name cannot contain " + sep );
126+ assertThatThrownBy (() -> cfs .snapshot ("." ))
127+ .isInstanceOf (IllegalArgumentException .class )
128+ .hasMessage ("Snapshot name '.' is reserved" );
129+ assertThatThrownBy (() -> cfs .snapshot (".." ))
130+ .isInstanceOf (IllegalArgumentException .class )
131+ .hasMessage ("Snapshot name '..' is reserved" );
132+ }
133+ }
134+
135+ private static String repeat (char c , int times )
136+ {
137+ char [] chars = new char [times ];
138+ java .util .Arrays .fill (chars , c );
139+ return new String (chars );
140+ }
46141}
0 commit comments