diff --git a/plugins/src/main/java/common/org/tron/plugins/DbMove.java b/plugins/src/main/java/common/org/tron/plugins/DbMove.java index a5619d2d7ed..6b1c5d12f5a 100644 --- a/plugins/src/main/java/common/org/tron/plugins/DbMove.java +++ b/plugins/src/main/java/common/org/tron/plugins/DbMove.java @@ -4,17 +4,21 @@ import com.typesafe.config.ConfigFactory; import java.io.File; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; -import java.util.Arrays; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; -import java.util.Objects; import java.util.Set; import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; +import java.util.stream.Stream; import lombok.extern.slf4j.Slf4j; import me.tongfei.progressbar.ProgressBar; import org.tron.plugins.utils.FileUtils; @@ -76,41 +80,40 @@ public Integer call() throws Exception { printNotExist(); return 0; } - List toBeMove = dbs.stream() - .map(c -> { - try { - return new Property(c.getString(NAME_CONFIG_KEY), - Paths.get(database.toString(), dbPath, c.getString(NAME_CONFIG_KEY)), - Paths.get(c.getString(PATH_CONFIG_KEY), dbPath, c.getString(NAME_CONFIG_KEY))); - } catch (IOException e) { - spec.commandLine().getErr().println(e); - } - return null; - }).filter(Objects::nonNull) - .filter(p -> !p.destination.equals(p.original)).collect(Collectors.toList()); + // Canonical path validation lives here, once every option has its final + // value; ConfigConverter is limited to database-independent checks, so + // option order ('-c' before or after '-d') must not change the result. + List toBeMove = new ArrayList<>(); + for (Config c : dbs) { + try { + toBeMove.add(new Property(c.getString(NAME_CONFIG_KEY), + Paths.get(database.toString(), dbPath, c.getString(NAME_CONFIG_KEY)), + Paths.get(c.getString(PATH_CONFIG_KEY), dbPath, c.getString(NAME_CONFIG_KEY)))); + } catch (IOException e) { + spec.commandLine().getErr().println(e); + return 2; + } + } + try { + checkNoNesting(toBeMove); + } catch (IllegalArgumentException e) { + spec.commandLine().getErr().println(e.getMessage()); + return 2; + } - if (toBeMove.isEmpty()) { - printNotExist(); - return 0; + boolean allCopied = ProgressBar.wrap(toBeMove.stream(), "copy task") + .map(this::copy).reduce(Boolean.TRUE, Boolean::logicalAnd); + if (!allCopied) { + cleanupDestinations(toBeMove); + return 1; } - toBeMove = toBeMove.stream() - .filter(property -> { - if (property.destination.toFile().exists()) { - spec.commandLine().getOut().println(String.format("%s already exist,skip.", - property.destination)); - return false; - } else { - return true; - } - }).collect(Collectors.toList()); - if (toBeMove.isEmpty()) { - printNotExist(); - return 0; + boolean allMoved = ProgressBar.wrap(toBeMove.stream(), "link task") + .map(this::replaceSourceWithLink).reduce(Boolean.TRUE, Boolean::logicalAnd); + if (!allMoved) { + return 1; } - ProgressBar.wrap(toBeMove.stream(), "mv task").forEach(this::run); spec.commandLine().getOut().println("move db done."); - } else { printNotExist(); return 0; @@ -118,28 +121,110 @@ public Integer call() throws Exception { return 0; } - private void run(Property p) { - if (p.destination.toFile().mkdirs()) { - ProgressBar.wrap(Arrays.stream(Objects.requireNonNull(p.original.toFile().listFiles())) - .filter(File::isFile).map(File::getName).parallel(), p.name).forEach(file -> { - Path original = Paths.get(p.original.toString(), file); - Path destination = Paths.get(p.destination.toString(), file); - try { - Files.copy(original, destination, - StandardCopyOption.REPLACE_EXISTING); - } catch (IOException e) { - spec.commandLine().getErr().println(e); - } - }); + private boolean copy(Property p) { + if (!p.destination.toFile().mkdirs()) { + spec.commandLine().getErr().println(String.format("%s create failed.", p.destination)); + return false; + } + + AtomicBoolean hasError = new AtomicBoolean(false); + try (Stream files = Files.walk(p.original)) { + ProgressBar.wrap(files.parallel(), p.name).forEach(source -> { + try { + copyEntry(p, source); + } catch (IOException e) { + hasError.set(true); + spec.commandLine().getErr().println(e); + } + }); + } catch (IOException | UncheckedIOException e) { + hasError.set(true); + spec.commandLine().getErr().println(e); + } + + if (hasError.get()) { + spec.commandLine().getErr().println(String.format( + "%s copy to %s failed, source kept.", + p.original, p.destination)); + return false; + } + return true; + } + + // Classify every entry WITHOUT following links: a symlink or special file + // inside the db dir has no safe copy semantics and must fail the move before + // the source is deleted. Directories are created explicitly so empty (nested) + // directories survive the move; an attribute read failure counts as an error + // instead of silently skipping the entry. + private void copyEntry(Property p, Path source) throws IOException { + BasicFileAttributes attributes = Files.readAttributes( + source, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + Path destination = p.destination.resolve(p.original.relativize(source)); + if (attributes.isDirectory()) { + Files.createDirectories(destination); + } else if (attributes.isRegularFile()) { + Files.createDirectories(destination.getParent()); + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); + } else { + throw new IOException(String.format( + "%s is neither a regular file nor a directory, can not be moved.", source)); + } + } + + private boolean replaceSourceWithLink(Property p) { + try { + if (!FileUtils.deleteDirNoFollowLinks(p.original.toFile())) { + spec.commandLine().getErr().println(String.format( + "%s delete failed and may be incomplete; the only complete copy is at %s, keep it.", + p.original, p.destination)); + printRecoveryHint(p); + return false; + } + Files.createSymbolicLink(p.original, p.destination); + return true; + } catch (IOException | RuntimeException x) { + spec.commandLine().getErr().println(x); + spec.commandLine().getErr().println(String.format( + "%s move failed; the complete copy is at %s, keep it.", + p.original, p.destination)); + printRecoveryHint(p); + return false; + } + } + + private void printRecoveryHint(Property p) { + spec.commandLine().getErr().println(String.format( + "To recover manually: remove %s if present, then create a symbolic link at %s" + + " pointing to %s.", + p.original, p.original, p.destination)); + } + + private void cleanupDestinations(List properties) { + boolean allCleaned = properties.stream().map(property -> { + File destination = property.destination.toFile(); + // Fail closed: only a confirmed-absent path (no dangling link) is done. + if (Files.notExists(destination.toPath(), LinkOption.NOFOLLOW_LINKS)) { + return true; + } try { - if (FileUtils.deleteDir(p.original.toFile())) { - Files.createSymbolicLink(p.original, p.destination); + if (FileUtils.deleteDirNoFollowLinks(destination)) { + return true; } - } catch (IOException | UnsupportedOperationException x) { - spec.commandLine().getErr().println(x); + } catch (RuntimeException e) { + spec.commandLine().getErr().println(e); } + spec.commandLine().getErr().println(String.format( + "%s cleanup failed; remove the leftover copy before retrying.", + property.destination)); + return false; + }).reduce(Boolean.TRUE, Boolean::logicalAnd); + + if (allCleaned) { + spec.commandLine().getErr().println( + "move db failed; all source databases were kept, please retry."); } else { - spec.commandLine().getErr().println(String.format("%s create failed.", p.destination)); + spec.commandLine().getErr().println( + "move db failed; all source databases were kept, but leftover copies remain."); } } @@ -147,6 +232,35 @@ private void printNotExist() { spec.commandLine().getErr().println(NOT_FIND); } + // Reject any overlap in the canonical path graph before mutating anything: + // a destination inside ANY source would be wiped when that source is deleted + // in the link phase; overlapping sources make the parent's deletion remove + // the child's fresh link; overlapping destinations interleave two copies. + private static void checkNoNesting(List properties) { + for (Property a : properties) { + for (Property b : properties) { + if (b.destination.startsWith(a.original)) { + throw new IllegalArgumentException(String.format( + "destination [%s] can not be inside original [%s],please check!", + b.destination, a.original)); + } + if (a == b) { + continue; + } + if (b.original.startsWith(a.original)) { + throw new IllegalArgumentException(String.format( + "original [%s] can not overlap original [%s],please check!", + b.original, a.original)); + } + if (b.destination.startsWith(a.destination)) { + throw new IllegalArgumentException(String.format( + "destination [%s] can not overlap destination [%s],please check!", + b.destination, a.destination)); + } + } + } + } + static class Property { @@ -167,7 +281,9 @@ public Property(String name, Path original, Path destination) throws IOException throw new IOException(original + " is symbolicLink!"); } this.destination = destination.toFile().getCanonicalFile().toPath(); - if (this.destination.toFile().exists()) { + // Fail closed: File.exists() follows links, so a dangling symlink at the + // destination would pass as absent only to fail mkdirs on every retry. + if (!Files.notExists(this.destination, LinkOption.NOFOLLOW_LINKS)) { throw new IOException(this.destination + " already exist!"); } if (this.destination.equals(this.original)) { @@ -195,9 +311,6 @@ public Config convert(String value) throws Exception { if (dbs.isEmpty()) { throw notFind; } - String dbPath = config.hasPath(DB_DIRECTORY_CONFIG_KEY) - ? config.getString(DB_DIRECTORY_CONFIG_KEY) : DEFAULT_DB_DIRECTORY; - dbs = dbs.stream() .filter(c -> c.hasPath(NAME_CONFIG_KEY) && c.hasPath(PATH_CONFIG_KEY)) .collect(Collectors.toList()); @@ -205,15 +318,15 @@ public Config convert(String value) throws Exception { if (dbs.isEmpty()) { throw notFind; } - Set toBeMove = new HashSet<>(); + // Only database-independent checks may run at conversion time: the + // static `database` option may not have its final value yet ('-c' + // can be parsed before '-d'). Path validation happens in call(). + Set names = new HashSet<>(); for (Config c : dbs) { - if (!toBeMove.add(new Property(c.getString(NAME_CONFIG_KEY), - Paths.get(database.toString(), dbPath, c.getString(NAME_CONFIG_KEY)), - Paths.get(c.getString(PATH_CONFIG_KEY), dbPath, - c.getString(NAME_CONFIG_KEY))).name)) { + String name = c.getString(NAME_CONFIG_KEY); + if (!names.add(name)) { throw new IllegalArgumentException( - "DB config has duplicate key:[" + c.getString(NAME_CONFIG_KEY) - + "],please check! "); + "DB config has duplicate key:[" + name + "],please check! "); } } } else { diff --git a/plugins/src/main/java/common/org/tron/plugins/utils/FileUtils.java b/plugins/src/main/java/common/org/tron/plugins/utils/FileUtils.java index b07b4469dc3..cf509e53621 100644 --- a/plugins/src/main/java/common/org/tron/plugins/utils/FileUtils.java +++ b/plugins/src/main/java/common/org/tron/plugins/utils/FileUtils.java @@ -83,6 +83,31 @@ public static boolean deleteDir(File dir) { return dir.delete(); } + /** + * Delete {@code dir} recursively WITHOUT following symbolic links: a symlink + * is removed itself and its target is left untouched. Use for trees that may + * contain links to data that must survive, e.g. database dirs already turned + * into links by {@code db mv}. {@link #deleteDir(File)} keeps the legacy + * follow-links semantics that DbLite/DbConvert rely on to free space. + */ + public static boolean deleteDirNoFollowLinks(File dir) { + if (Files.isSymbolicLink(dir.toPath())) { + return dir.delete(); + } + if (dir.isDirectory()) { + String[] children = dir.list(); + if (children == null) { + return false; + } + for (String child : children) { + if (!deleteDirNoFollowLinks(new File(dir, child))) { + return false; + } + } + } + return dir.delete(); + } + public static boolean createFileIfNotExists(String filepath) { File file = new File(filepath); if (!file.exists()) { diff --git a/plugins/src/test/java/org/tron/plugins/DbMoveTest.java b/plugins/src/test/java/org/tron/plugins/DbMoveTest.java index ec4f0d545b0..3b3ad70fce3 100644 --- a/plugins/src/test/java/org/tron/plugins/DbMoveTest.java +++ b/plugins/src/test/java/org/tron/plugins/DbMoveTest.java @@ -2,16 +2,24 @@ import java.io.File; import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Objects; import lombok.extern.slf4j.Slf4j; import org.junit.After; import org.junit.Assert; +import org.junit.Assume; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import org.rocksdb.RocksDBException; import org.tron.plugins.utils.DBUtils; +import org.tron.plugins.utils.FileUtils; import org.tron.plugins.utils.db.DbTool; import picocli.CommandLine; @@ -61,6 +69,35 @@ private static String getConfig(String config) { return path == null ? null : path.getPath(); } + /** Write a storage.properties config with the given {name, path} entries. */ + private File writeConfig(String fileName, String[]... entries) throws IOException { + StringBuilder content = new StringBuilder("storage {\n properties = [\n"); + for (String[] entry : entries) { + content.append(" {\n name = \"").append(entry[0]) + .append("\",\n path = \"").append(entry[1]).append("\",\n },\n"); + } + content.append(" ]\n}\n"); + File config = temporaryFolder.newFile(fileName); + Files.write(config.toPath(), content.toString().getBytes(StandardCharsets.UTF_8)); + return config; + } + + /** Create and initialize a RocksDB database folder. */ + private File newDatabase() throws IOException, RocksDBException { + File database = temporaryFolder.newFolder("database"); + init(DbTool.DbType.RocksDB, database.getPath()); + return database; + } + + private static String[] mvArgs(File database, String configPath) { + return new String[] {"db", "mv", "-d", database.getParent(), "-c", configPath}; + } + + /** Run {@code db mv} with a fresh CommandLine and return the exit code. */ + private static int mv(File database, String configPath) { + return new CommandLine(new Toolkit()).execute(mvArgs(database, configPath)); + } + @Test public void testMvForLevelDB() throws RocksDBException, IOException { File database = temporaryFolder.newFolder("database"); @@ -75,14 +112,280 @@ public void testMvForLevelDB() throws RocksDBException, IOException { @Test public void testMvForRocksDB() throws RocksDBException, IOException { - File database = temporaryFolder.newFolder("database"); - init(DbTool.DbType.RocksDB, Paths.get(database.getPath()).toString()); - String[] args = new String[] {"db", "mv", "-d", - database.getParent(), "-c", - getConfig("config.conf")}; + File database = newDatabase(); + Assert.assertEquals(0, mv(database, getConfig("config.conf"))); + Assert.assertEquals(2, mv(database, getConfig("config.conf"))); + } + + @Test + public void testSourceKeptWhenCopyFails() throws RocksDBException, IOException { + File database = newDatabase(); + File accountDir = Paths.get(database.getPath(), ACCOUNT).toFile(); + File marketDir = Paths.get(database.getPath(), DBUtils.MARKET_PAIR_PRICE_TO_ORDER).toFile(); + File victim = Objects.requireNonNull(accountDir.listFiles(File::isFile))[0]; + // Make one source file unreadable so its copy fails part-way through the move. + Assert.assertTrue(victim.setReadable(false, false)); + + String[] args = mvArgs(database, getConfig("config.conf")); + CommandLine cli = new CommandLine(new Toolkit()); + StringWriter output = new StringWriter(); + cli.setOut(new PrintWriter(output)); + try { + // Skip when the platform ignores the read bit (e.g. running as root). + Assume.assumeFalse("file still readable (root?), cannot simulate copy failure", + victim.canRead()); + Assert.assertEquals(1, cli.execute(args)); + + // A failed copy must keep every source intact and roll back all destinations. + Assert.assertTrue("source dir must be kept on copy failure", accountDir.exists()); + Assert.assertFalse("source must not be replaced by a symlink", + Files.isSymbolicLink(accountDir.toPath())); + Assert.assertTrue("source file must still exist", victim.exists()); + Assert.assertTrue("other source dirs must not be moved after a copy failure", + marketDir.exists()); + Assert.assertFalse("other source dirs must not be replaced by symlinks", + Files.isSymbolicLink(marketDir.toPath())); + Assert.assertFalse("partial destination must be removed", + Paths.get(OUTPUT_DIRECTORY, "dest", "database", ACCOUNT).toFile().exists()); + Assert.assertFalse("failure must not be reported as success", + output.toString().contains("move db done.")); + } finally { + victim.setReadable(true, false); + } + + // Once the I/O problem is fixed, the unchanged command must be directly retryable. + Assert.assertEquals(0, cli.execute(args)); + Assert.assertTrue(Files.isSymbolicLink(accountDir.toPath())); + Assert.assertTrue(Files.isSymbolicLink(marketDir.toPath())); + Assert.assertEquals("move db done." + System.lineSeparator(), output.toString()); + } + + @Test + public void testDestinationInsideSourceRejected() throws RocksDBException, IOException { + File database = newDatabase(); + File accountDir = Paths.get(database.getPath(), ACCOUNT).toFile(); + // A path pointing at the source db dir nests the destination inside it; the + // link phase would then wipe the copy together with the source. + File config = writeConfig("nested.conf", + new String[] {ACCOUNT, accountDir.getPath()}); + + Assert.assertEquals(2, mv(database, config.getPath())); + Assert.assertTrue("source dir must be untouched", accountDir.exists()); + Assert.assertFalse("source must not be replaced by a symlink", + Files.isSymbolicLink(accountDir.toPath())); + } + + @Test + public void testOptionOrderConfigFirst() throws RocksDBException, IOException { + File database = newDatabase(); + // '-c' parsed before '-d': path validation must still use the final + // database value, not the stale one visible at conversion time. + String[] args = new String[] {"db", "mv", "-c", + getConfig("config.conf"), "-d", + database.getParent()}; CommandLine cli = new CommandLine(new Toolkit()); Assert.assertEquals(0, cli.execute(args)); - Assert.assertEquals(2, cli.execute(args)); + Assert.assertTrue(Files.isSymbolicLink( + Paths.get(database.getPath(), ACCOUNT))); + Assert.assertTrue(Files.isSymbolicLink( + Paths.get(database.getPath(), DBUtils.MARKET_PAIR_PRICE_TO_ORDER))); + } + + @Test + public void testCrossDbNestingRejected() throws RocksDBException, IOException { + File database = newDatabase(); + File accountDir = Paths.get(database.getPath(), ACCOUNT).toFile(); + File transDir = Paths.get(database.getPath(), TRANS).toFile(); + // trans's destination nests inside account's SOURCE: finalizing account + // would wipe trans's fresh copy before trans is linked. + File config = writeConfig("cross-nested.conf", + new String[] {ACCOUNT, "output-directory-toolkit/dest"}, + new String[] {TRANS, accountDir.getPath()}); + + Assert.assertEquals(2, mv(database, config.getPath())); + Assert.assertTrue("account source must be untouched", accountDir.exists()); + Assert.assertFalse("account must not be replaced by a symlink", + Files.isSymbolicLink(accountDir.toPath())); + Assert.assertTrue("trans source must be untouched", transDir.exists()); + Assert.assertFalse("trans must not be replaced by a symlink", + Files.isSymbolicLink(transDir.toPath())); + } + + @Test + public void testInTreeSymlinkRejected() throws RocksDBException, IOException { + File database = newDatabase(); + File accountDir = Paths.get(database.getPath(), ACCOUNT).toFile(); + File outside = temporaryFolder.newFolder("outside"); + File sentinel = new File(outside, "sentinel"); + Assert.assertTrue(sentinel.createNewFile()); + Files.createSymbolicLink( + Paths.get(accountDir.getPath(), "evil-link"), outside.toPath()); + + Assert.assertEquals(1, mv(database, getConfig("config.conf"))); + // The move must fail without touching the source or the symlink target. + Assert.assertTrue("source dir must be kept", accountDir.exists()); + Assert.assertFalse("source must not be replaced by a symlink", + Files.isSymbolicLink(accountDir.toPath())); + Assert.assertTrue("symlink target must never be touched", sentinel.exists()); + Assert.assertFalse("partial destination must be rolled back", + Paths.get(OUTPUT_DIRECTORY, "dest", "database", ACCOUNT).toFile().exists()); + } + + @Test + public void testDeleteDirNoFollowLinksSparesTarget() throws IOException { + File target = temporaryFolder.newFolder("target"); + File kept = new File(target, "kept"); + Assert.assertTrue(kept.createNewFile()); + File dir = temporaryFolder.newFolder("victim"); + Files.createSymbolicLink(Paths.get(dir.getPath(), "link"), target.toPath()); + + Assert.assertTrue(FileUtils.deleteDirNoFollowLinks(dir)); + Assert.assertFalse(dir.exists()); + Assert.assertTrue("symlink target content must survive", kept.exists()); + } + + @Test + public void testDeleteDirKeepsLegacyFollowSemantics() throws IOException { + File target = temporaryFolder.newFolder("legacy-target"); + File freed = new File(target, "freed"); + Assert.assertTrue(freed.createNewFile()); + File dir = temporaryFolder.newFolder("legacy-victim"); + Files.createSymbolicLink(Paths.get(dir.getPath(), "link"), target.toPath()); + + // DbLite/DbConvert rely on deleteDir following links to free space. + Assert.assertTrue(FileUtils.deleteDir(dir)); + Assert.assertFalse(dir.exists()); + Assert.assertFalse("legacy deleteDir must free the link target's content", + freed.exists()); + } + + @Test + public void testEmptyDirsPreserved() throws RocksDBException, IOException { + File database = newDatabase(); + File emptySub = Paths.get(database.getPath(), ACCOUNT, "archive", "sub").toFile(); + Assert.assertTrue(emptySub.mkdirs()); + + Assert.assertEquals(0, mv(database, getConfig("config.conf"))); + Assert.assertTrue("empty nested dirs must be recreated at the destination", + Paths.get(OUTPUT_DIRECTORY, "dest", "database", ACCOUNT, "archive", "sub") + .toFile().isDirectory()); + } + + @Test + public void testUnreadableSubdirFailsCopy() throws RocksDBException, IOException { + File database = newDatabase(); + File accountDir = Paths.get(database.getPath(), ACCOUNT).toFile(); + File subDir = new File(accountDir, "subdir"); + Assert.assertTrue(subDir.mkdir()); + Assert.assertTrue(new File(subDir, "data").createNewFile()); + Assert.assertTrue(subDir.setReadable(false, false)); + try { + // Skip when the platform ignores the read bit (e.g. running as root). + Assume.assumeFalse("subdir still readable (root?), cannot simulate traversal failure", + subDir.canRead()); + Assert.assertEquals(1, mv(database, getConfig("config.conf"))); + Assert.assertTrue("source dir must be kept on traversal failure", accountDir.exists()); + Assert.assertFalse("source must not be replaced by a symlink", + Files.isSymbolicLink(accountDir.toPath())); + Assert.assertFalse("partial destination must be rolled back", + Paths.get(OUTPUT_DIRECTORY, "dest", "database", ACCOUNT).toFile().exists()); + } finally { + subDir.setReadable(true, false); + } + } + + @Test + public void testOverlappingSourcesRejected() throws RocksDBException, IOException { + File database = newDatabase(); + File accountDir = Paths.get(database.getPath(), ACCOUNT).toFile(); + File subDir = new File(accountDir, "sub"); + Assert.assertTrue(subDir.mkdir()); + + // A '/' in a name lets one source nest inside another: finalizing the + // parent would delete the child's fresh link. Both orders must be rejected. + File parentFirst = writeConfig("overlap-parent-first.conf", + new String[] {ACCOUNT, "output-directory-toolkit/dest"}, + new String[] {ACCOUNT + "/sub", "output-directory-toolkit/dest2"}); + Assert.assertEquals(2, mv(database, parentFirst.getPath())); + File childFirst = writeConfig("overlap-child-first.conf", + new String[] {ACCOUNT + "/sub", "output-directory-toolkit/dest2"}, + new String[] {ACCOUNT, "output-directory-toolkit/dest"}); + Assert.assertEquals(2, mv(database, childFirst.getPath())); + + Assert.assertTrue("parent source must be untouched", accountDir.exists()); + Assert.assertFalse(Files.isSymbolicLink(accountDir.toPath())); + Assert.assertTrue("child source must be untouched", subDir.exists()); + Assert.assertFalse(Files.isSymbolicLink(subDir.toPath())); + Assert.assertFalse("nothing may be copied", + Paths.get(OUTPUT_DIRECTORY, "dest").toFile().exists()); + } + + @Test + public void testOverlappingDestinationsRejected() throws RocksDBException, IOException { + File database = newDatabase(); + + // trans's destination nests inside account's destination. + File config = writeConfig("overlap-dest.conf", + new String[] {ACCOUNT, "output-directory-toolkit/dest"}, + new String[] {TRANS, "output-directory-toolkit/dest/database/" + ACCOUNT}); + Assert.assertEquals(2, mv(database, config.getPath())); + Assert.assertFalse("nothing may be copied", + Paths.get(OUTPUT_DIRECTORY, "dest").toFile().exists()); + } + + @Test + public void testDanglingDestinationLinkRejected() throws RocksDBException, IOException { + File database = newDatabase(); + File destParent = Paths.get(OUTPUT_DIRECTORY, "dest", "database").toFile(); + Assert.assertTrue(destParent.mkdirs()); + // A dangling symlink occupies the destination: File.exists() reports it as + // absent, but mkdirs would fail on it forever. Validation must fail closed. + Path danglingLink = Paths.get(destParent.getPath(), ACCOUNT); + Files.createSymbolicLink(danglingLink, + Paths.get(destParent.getPath(), "no-such-target")); + + Assert.assertEquals(2, mv(database, getConfig("config.conf"))); + Assert.assertTrue("dangling link must be reported, not treated as absent", + Files.isSymbolicLink(danglingLink)); + Assert.assertFalse("nothing may be moved", + Files.isSymbolicLink(Paths.get(database.getPath(), ACCOUNT))); + } + + @Test + public void testRecoveryHintWhenSourceDeleteFails() throws RocksDBException, IOException { + File database = newDatabase(); + File accountDir = Paths.get(database.getPath(), ACCOUNT).toFile(); + Assert.assertTrue(accountDir.setWritable(false, false)); + + StringWriter err = new StringWriter(); + CommandLine cli = new CommandLine(new Toolkit()); + cli.setErr(new PrintWriter(err)); + try { + // Skip when the platform ignores the write bit (e.g. running as root). + Assume.assumeFalse("dir still writable (root?), cannot simulate delete failure", + accountDir.canWrite()); + Assert.assertEquals(1, cli.execute(mvArgs(database, getConfig("config.conf")))); + + // Copy succeeded but finalization failed: source kept, complete copy kept. + Assert.assertTrue("source dir must be kept", accountDir.exists()); + Assert.assertFalse("source must not be replaced by a symlink", + Files.isSymbolicLink(accountDir.toPath())); + File dest = Paths.get(OUTPUT_DIRECTORY, "dest", "database", ACCOUNT).toFile(); + Assert.assertTrue("complete copy must be kept for manual recovery", dest.exists()); + String expectedHint = String.format( + "To recover manually: remove %s if present, then create a symbolic link at %s" + + " pointing to %s.", + accountDir.getCanonicalFile().toPath(), + accountDir.getCanonicalFile().toPath(), + dest.getCanonicalFile().toPath()); + Assert.assertTrue("operator must get exact recovery instructions with real paths", + err.toString().contains(expectedHint)); + // Finalization continues for the remaining dbs. + Assert.assertTrue(Files.isSymbolicLink( + Paths.get(database.getPath(), DBUtils.MARKET_PAIR_PRICE_TO_ORDER))); + } finally { + accountDir.setWritable(true, false); + } } @Test