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,101 @@ 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+ // Only alphanumerics, '-', '_' and '.' are accepted.
75+ assertThatCode (() -> cfs .snapshot ("snap.2026-05-20" )).doesNotThrowAnyException ();
76+ // Dots embedded in a name are not traversal: with '/' excluded, "a..tag" is just a literal directory.
77+ assertThatCode (() -> cfs .snapshot ("a..tag" )).doesNotThrowAnyException ();
78+
79+ // "+" is part of the allowed set because it can appear in a Cassandra version
80+ // (build metadata, e.g. "7.0.0+abc123"), which ends up in system snapshot names.
81+ assertThatCode (() -> cfs .snapshot ("this_is_snapshot-7.0.0+abc123" )).doesNotThrowAnyException ();
82+
83+ String tooLong = repeat ('a' , SchemaConstants .FILENAME_LENGTH + 1 );
84+ assertThatThrownBy (() -> cfs .snapshot (tooLong ))
85+ .isInstanceOf (IllegalArgumentException .class )
86+ .hasMessage ("Snapshot name must not be more than 255 characters long for " +
87+ "resolved snapshot name (got 256 characters for \" " + tooLong + "\" )" );
88+
89+ // '/' is not in the allowed set; this is what kills traversal attempts like "../../mysnapshot".
90+ assertThatThrownBy (() -> cfs .snapshot ("a" + sep + "tag" ))
91+ .isInstanceOf (IllegalArgumentException .class )
92+ .hasMessage ("Snapshot name cannot contain " + sep );
93+
94+ // The shell-significant S3 "safe" characters (! * ' ( )) are deliberately NOT allowed.
95+ assertThatThrownBy (() -> cfs .snapshot ("important!" ))
96+ .isInstanceOf (IllegalArgumentException .class )
97+ .hasMessage ("Snapshot name contains illegal characters: important!" );
98+ assertThatThrownBy (() -> cfs .snapshot ("backup*" ))
99+ .isInstanceOf (IllegalArgumentException .class )
100+ .hasMessage ("Snapshot name contains illegal characters: backup*" );
101+ assertThatThrownBy (() -> cfs .snapshot ("o'snap" ))
102+ .isInstanceOf (IllegalArgumentException .class )
103+ .hasMessage ("Snapshot name contains illegal characters: o'snap" );
104+ assertThatThrownBy (() -> cfs .snapshot ("snap(1)" ))
105+ .isInstanceOf (IllegalArgumentException .class )
106+ .hasMessage ("Snapshot name contains illegal characters: snap(1)" );
107+
108+ // Other characters outside the allowed set must still be rejected.
109+ assertThatThrownBy (() -> cfs .snapshot ("a tag" ))
110+ .isInstanceOf (IllegalArgumentException .class )
111+ .hasMessage ("Snapshot name contains illegal characters: a tag" );
112+ assertThatThrownBy (() -> cfs .snapshot ("a:tag" ))
113+ .isInstanceOf (IllegalArgumentException .class )
114+ .hasMessage ("Snapshot name contains illegal characters: a:tag" );
115+
116+ // "." and ".." pass the charset check but resolve to the snapshots/ dir itself
117+ // and its parent (the live table dir) respectively, so they must be rejected as reserved.
118+ assertThatThrownBy (() -> cfs .snapshot ("." ))
119+ .isInstanceOf (IllegalArgumentException .class )
120+ .hasMessage ("Snapshot name '.' is reserved" );
121+ assertThatThrownBy (() -> cfs .snapshot (".." ))
122+ .isInstanceOf (IllegalArgumentException .class )
123+ .hasMessage ("Snapshot name '..' is reserved" );
124+ }
125+
126+ try (WithProperties p = new WithProperties ().set (CassandraRelevantProperties .SNAPSHOT_NAME_VALIDATION , false ))
127+ {
128+ // The character check is bypassed entirely: space, ':', and the now-disallowed
129+ // shell-significant characters (! * ' ( )) are all accepted.
130+ assertThatCode (() -> cfs .snapshot ("a tag" )).doesNotThrowAnyException ();
131+ assertThatCode (() -> cfs .snapshot ("a:tag" )).doesNotThrowAnyException ();
132+ assertThatCode (() -> cfs .snapshot ("important!" )).doesNotThrowAnyException ();
133+ assertThatCode (() -> cfs .snapshot ("snap(1)" )).doesNotThrowAnyException ();
134+
135+ // Path separator and "." / ".." rejections are unconditional — they guard against
136+ // traversal regardless of the toggle.
137+ assertThatThrownBy (() -> cfs .snapshot ("a" + sep + "tag" ))
138+ .isInstanceOf (IllegalArgumentException .class )
139+ .hasMessage ("Snapshot name cannot contain " + sep );
140+ assertThatThrownBy (() -> cfs .snapshot ("." ))
141+ .isInstanceOf (IllegalArgumentException .class )
142+ .hasMessage ("Snapshot name '.' is reserved" );
143+ assertThatThrownBy (() -> cfs .snapshot (".." ))
144+ .isInstanceOf (IllegalArgumentException .class )
145+ .hasMessage ("Snapshot name '..' is reserved" );
146+ }
147+ }
148+
149+ private static String repeat (char c , int times )
150+ {
151+ char [] chars = new char [times ];
152+ java .util .Arrays .fill (chars , c );
153+ return new String (chars );
154+ }
46155}
0 commit comments