44import com .typesafe .config .ConfigFactory ;
55import java .io .File ;
66import java .io .IOException ;
7+ import java .io .UncheckedIOException ;
78import java .nio .file .Files ;
9+ import java .nio .file .LinkOption ;
810import java .nio .file .Path ;
911import java .nio .file .Paths ;
1012import java .nio .file .StandardCopyOption ;
11- import java .util .Arrays ;
13+ import java .nio .file .attribute .BasicFileAttributes ;
14+ import java .util .ArrayList ;
1215import java .util .HashSet ;
1316import java .util .List ;
14- import java .util .Objects ;
1517import java .util .Set ;
1618import java .util .concurrent .Callable ;
19+ import java .util .concurrent .atomic .AtomicBoolean ;
1720import java .util .stream .Collectors ;
21+ import java .util .stream .Stream ;
1822import lombok .extern .slf4j .Slf4j ;
1923import me .tongfei .progressbar .ProgressBar ;
2024import org .tron .plugins .utils .FileUtils ;
@@ -76,77 +80,187 @@ public Integer call() throws Exception {
7680 printNotExist ();
7781 return 0 ;
7882 }
79- List <Property > toBeMove = dbs .stream ()
80- .map (c -> {
81- try {
82- return new Property (c .getString (NAME_CONFIG_KEY ),
83- Paths .get (database .toString (), dbPath , c .getString (NAME_CONFIG_KEY )),
84- Paths .get (c .getString (PATH_CONFIG_KEY ), dbPath , c .getString (NAME_CONFIG_KEY )));
85- } catch (IOException e ) {
86- spec .commandLine ().getErr ().println (e );
87- }
88- return null ;
89- }).filter (Objects ::nonNull )
90- .filter (p -> !p .destination .equals (p .original )).collect (Collectors .toList ());
83+ // Canonical path validation lives here, once every option has its final
84+ // value; ConfigConverter is limited to database-independent checks, so
85+ // option order ('-c' before or after '-d') must not change the result.
86+ List <Property > toBeMove = new ArrayList <>();
87+ for (Config c : dbs ) {
88+ try {
89+ toBeMove .add (new Property (c .getString (NAME_CONFIG_KEY ),
90+ Paths .get (database .toString (), dbPath , c .getString (NAME_CONFIG_KEY )),
91+ Paths .get (c .getString (PATH_CONFIG_KEY ), dbPath , c .getString (NAME_CONFIG_KEY ))));
92+ } catch (IOException e ) {
93+ spec .commandLine ().getErr ().println (e );
94+ return 2 ;
95+ }
96+ }
97+ try {
98+ checkNoNesting (toBeMove );
99+ } catch (IllegalArgumentException e ) {
100+ spec .commandLine ().getErr ().println (e .getMessage ());
101+ return 2 ;
102+ }
91103
92- if (toBeMove .isEmpty ()) {
93- printNotExist ();
94- return 0 ;
104+ boolean allCopied = ProgressBar .wrap (toBeMove .stream (), "copy task" )
105+ .map (this ::copy ).reduce (Boolean .TRUE , Boolean ::logicalAnd );
106+ if (!allCopied ) {
107+ cleanupDestinations (toBeMove );
108+ return 1 ;
95109 }
96- toBeMove = toBeMove .stream ()
97- .filter (property -> {
98- if (property .destination .toFile ().exists ()) {
99- spec .commandLine ().getOut ().println (String .format ("%s already exist,skip." ,
100- property .destination ));
101- return false ;
102- } else {
103- return true ;
104- }
105- }).collect (Collectors .toList ());
106110
107- if (toBeMove .isEmpty ()) {
108- printNotExist ();
109- return 0 ;
111+ boolean allMoved = ProgressBar .wrap (toBeMove .stream (), "link task" )
112+ .map (this ::replaceSourceWithLink ).reduce (Boolean .TRUE , Boolean ::logicalAnd );
113+ if (!allMoved ) {
114+ return 1 ;
110115 }
111- ProgressBar .wrap (toBeMove .stream (), "mv task" ).forEach (this ::run );
112116 spec .commandLine ().getOut ().println ("move db done." );
113-
114117 } else {
115118 printNotExist ();
116119 return 0 ;
117120 }
118121 return 0 ;
119122 }
120123
121- private void run (Property p ) {
122- if (p .destination .toFile ().mkdirs ()) {
123- ProgressBar .wrap (Arrays .stream (Objects .requireNonNull (p .original .toFile ().listFiles ()))
124- .filter (File ::isFile ).map (File ::getName ).parallel (), p .name ).forEach (file -> {
125- Path original = Paths .get (p .original .toString (), file );
126- Path destination = Paths .get (p .destination .toString (), file );
127- try {
128- Files .copy (original , destination ,
129- StandardCopyOption .REPLACE_EXISTING );
130- } catch (IOException e ) {
131- spec .commandLine ().getErr ().println (e );
132- }
133- });
124+ private boolean copy (Property p ) {
125+ if (!p .destination .toFile ().mkdirs ()) {
126+ spec .commandLine ().getErr ().println (String .format ("%s create failed." , p .destination ));
127+ return false ;
128+ }
129+
130+ AtomicBoolean hasError = new AtomicBoolean (false );
131+ try (Stream <Path > files = Files .walk (p .original )) {
132+ ProgressBar .wrap (files .parallel (), p .name ).forEach (source -> {
133+ try {
134+ copyEntry (p , source );
135+ } catch (IOException e ) {
136+ hasError .set (true );
137+ spec .commandLine ().getErr ().println (e );
138+ }
139+ });
140+ } catch (IOException | UncheckedIOException e ) {
141+ hasError .set (true );
142+ spec .commandLine ().getErr ().println (e );
143+ }
144+
145+ if (hasError .get ()) {
146+ spec .commandLine ().getErr ().println (String .format (
147+ "%s copy to %s failed, source kept." ,
148+ p .original , p .destination ));
149+ return false ;
150+ }
151+ return true ;
152+ }
153+
154+ // Classify every entry WITHOUT following links: a symlink or special file
155+ // inside the db dir has no safe copy semantics and must fail the move before
156+ // the source is deleted. Directories are created explicitly so empty (nested)
157+ // directories survive the move; an attribute read failure counts as an error
158+ // instead of silently skipping the entry.
159+ private void copyEntry (Property p , Path source ) throws IOException {
160+ BasicFileAttributes attributes = Files .readAttributes (
161+ source , BasicFileAttributes .class , LinkOption .NOFOLLOW_LINKS );
162+ Path destination = p .destination .resolve (p .original .relativize (source ));
163+ if (attributes .isDirectory ()) {
164+ Files .createDirectories (destination );
165+ } else if (attributes .isRegularFile ()) {
166+ Files .createDirectories (destination .getParent ());
167+ Files .copy (source , destination , StandardCopyOption .REPLACE_EXISTING );
168+ } else {
169+ throw new IOException (String .format (
170+ "%s is neither a regular file nor a directory, can not be moved." , source ));
171+ }
172+ }
173+
174+ private boolean replaceSourceWithLink (Property p ) {
175+ try {
176+ if (!FileUtils .deleteDirNoFollowLinks (p .original .toFile ())) {
177+ spec .commandLine ().getErr ().println (String .format (
178+ "%s delete failed and may be incomplete; the only complete copy is at %s, keep it." ,
179+ p .original , p .destination ));
180+ printRecoveryHint (p );
181+ return false ;
182+ }
183+ Files .createSymbolicLink (p .original , p .destination );
184+ return true ;
185+ } catch (IOException | RuntimeException x ) {
186+ spec .commandLine ().getErr ().println (x );
187+ spec .commandLine ().getErr ().println (String .format (
188+ "%s move failed; the complete copy is at %s, keep it." ,
189+ p .original , p .destination ));
190+ printRecoveryHint (p );
191+ return false ;
192+ }
193+ }
194+
195+ private void printRecoveryHint (Property p ) {
196+ spec .commandLine ().getErr ().println (String .format (
197+ "To recover manually: remove %s if present, then create a symbolic link at %s"
198+ + " pointing to %s." ,
199+ p .original , p .original , p .destination ));
200+ }
201+
202+ private void cleanupDestinations (List <Property > properties ) {
203+ boolean allCleaned = properties .stream ().map (property -> {
204+ File destination = property .destination .toFile ();
205+ // Fail closed: only a confirmed-absent path (no dangling link) is done.
206+ if (Files .notExists (destination .toPath (), LinkOption .NOFOLLOW_LINKS )) {
207+ return true ;
208+ }
134209 try {
135- if (FileUtils .deleteDir ( p . original . toFile () )) {
136- Files . createSymbolicLink ( p . original , p . destination ) ;
210+ if (FileUtils .deleteDirNoFollowLinks ( destination )) {
211+ return true ;
137212 }
138- } catch (IOException | UnsupportedOperationException x ) {
139- spec .commandLine ().getErr ().println (x );
213+ } catch (RuntimeException e ) {
214+ spec .commandLine ().getErr ().println (e );
140215 }
216+ spec .commandLine ().getErr ().println (String .format (
217+ "%s cleanup failed; remove the leftover copy before retrying." ,
218+ property .destination ));
219+ return false ;
220+ }).reduce (Boolean .TRUE , Boolean ::logicalAnd );
221+
222+ if (allCleaned ) {
223+ spec .commandLine ().getErr ().println (
224+ "move db failed; all source databases were kept, please retry." );
141225 } else {
142- spec .commandLine ().getErr ().println (String .format ("%s create failed." , p .destination ));
226+ spec .commandLine ().getErr ().println (
227+ "move db failed; all source databases were kept, but leftover copies remain." );
143228 }
144229 }
145230
146231 private void printNotExist () {
147232 spec .commandLine ().getErr ().println (NOT_FIND );
148233 }
149234
235+ // Reject any overlap in the canonical path graph before mutating anything:
236+ // a destination inside ANY source would be wiped when that source is deleted
237+ // in the link phase; overlapping sources make the parent's deletion remove
238+ // the child's fresh link; overlapping destinations interleave two copies.
239+ private static void checkNoNesting (List <Property > properties ) {
240+ for (Property a : properties ) {
241+ for (Property b : properties ) {
242+ if (b .destination .startsWith (a .original )) {
243+ throw new IllegalArgumentException (String .format (
244+ "destination [%s] can not be inside original [%s],please check!" ,
245+ b .destination , a .original ));
246+ }
247+ if (a == b ) {
248+ continue ;
249+ }
250+ if (b .original .startsWith (a .original )) {
251+ throw new IllegalArgumentException (String .format (
252+ "original [%s] can not overlap original [%s],please check!" ,
253+ b .original , a .original ));
254+ }
255+ if (b .destination .startsWith (a .destination )) {
256+ throw new IllegalArgumentException (String .format (
257+ "destination [%s] can not overlap destination [%s],please check!" ,
258+ b .destination , a .destination ));
259+ }
260+ }
261+ }
262+ }
263+
150264
151265 static class Property {
152266
@@ -167,7 +281,9 @@ public Property(String name, Path original, Path destination) throws IOException
167281 throw new IOException (original + " is symbolicLink!" );
168282 }
169283 this .destination = destination .toFile ().getCanonicalFile ().toPath ();
170- if (this .destination .toFile ().exists ()) {
284+ // Fail closed: File.exists() follows links, so a dangling symlink at the
285+ // destination would pass as absent only to fail mkdirs on every retry.
286+ if (!Files .notExists (this .destination , LinkOption .NOFOLLOW_LINKS )) {
171287 throw new IOException (this .destination + " already exist!" );
172288 }
173289 if (this .destination .equals (this .original )) {
@@ -195,25 +311,22 @@ public Config convert(String value) throws Exception {
195311 if (dbs .isEmpty ()) {
196312 throw notFind ;
197313 }
198- String dbPath = config .hasPath (DB_DIRECTORY_CONFIG_KEY )
199- ? config .getString (DB_DIRECTORY_CONFIG_KEY ) : DEFAULT_DB_DIRECTORY ;
200-
201314 dbs = dbs .stream ()
202315 .filter (c -> c .hasPath (NAME_CONFIG_KEY ) && c .hasPath (PATH_CONFIG_KEY ))
203316 .collect (Collectors .toList ());
204317
205318 if (dbs .isEmpty ()) {
206319 throw notFind ;
207320 }
208- Set <String > toBeMove = new HashSet <>();
321+ // Only database-independent checks may run at conversion time: the
322+ // static `database` option may not have its final value yet ('-c'
323+ // can be parsed before '-d'). Path validation happens in call().
324+ Set <String > names = new HashSet <>();
209325 for (Config c : dbs ) {
210- if (!toBeMove .add (new Property (c .getString (NAME_CONFIG_KEY ),
211- Paths .get (database .toString (), dbPath , c .getString (NAME_CONFIG_KEY )),
212- Paths .get (c .getString (PATH_CONFIG_KEY ), dbPath ,
213- c .getString (NAME_CONFIG_KEY ))).name )) {
326+ String name = c .getString (NAME_CONFIG_KEY );
327+ if (!names .add (name )) {
214328 throw new IllegalArgumentException (
215- "DB config has duplicate key:[" + c .getString (NAME_CONFIG_KEY )
216- + "],please check! " );
329+ "DB config has duplicate key:[" + name + "],please check! " );
217330 }
218331 }
219332 } else {
0 commit comments