diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/files/AtomicYamlWriter.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/files/AtomicYamlWriter.java new file mode 100644 index 0000000000..78afe9945c --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/files/AtomicYamlWriter.java @@ -0,0 +1,312 @@ +package com.bencodez.advancedcore.api.misc.files; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.AclFileAttributeView; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFileAttributes; +import java.nio.file.attribute.UserPrincipal; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.configuration.file.FileConfiguration; + +/** + * Writes YAML data to a sibling temporary file before committing it to the + * destination. Serialization always completes before the existing file can be + * modified. Existing files whose security metadata cannot be recreated safely + * are committed through a preserved hard-link inode so the live path always + * points at a complete old or new file. + */ +public final class AtomicYamlWriter { + + private static final int MAX_SYMLINK_DEPTH = 32; + private static final String PRESERVED_SUFFIX = ".advancedcore-preserved-inode"; + + private AtomicYamlWriter() { + } + + public static void save(File file, FileConfiguration data) throws IOException { + if (file == null) { + throw new IllegalArgumentException("file cannot be null"); + } + if (data == null) { + throw new IllegalArgumentException("data cannot be null"); + } + + Path requestedTarget = file.toPath().toAbsolutePath(); + Path requestedParent = requestedTarget.getParent(); + if (requestedParent != null) { + Files.createDirectories(requestedParent); + } + + Path target = resolveWriteTarget(requestedTarget); + Path parent = target.getParent(); + if (parent == null) { + parent = Path.of(".").toAbsolutePath(); + } + Files.createDirectories(parent); + + Path preserved = preservedPath(target); + recoverPreservedInode(target, preserved); + + Path temp; + try { + temp = Files.createTempFile(parent, "." + target.getFileName() + ".", ".tmp"); + } catch (IOException siblingCreationFailure) { + if (Files.exists(target) && Files.isWritable(target)) { + saveSerializedInPlace(target, data, siblingCreationFailure); + return; + } + throw siblingCreationFailure; + } + + boolean committed = false; + try { + data.save(temp.toFile()); + if (prepareReplacementMetadata(target, temp)) { + moveReplacement(temp, target); + } else { + commitThroughPreservedInode(temp, target, preserved); + } + committed = true; + } finally { + if (!committed) { + Files.deleteIfExists(temp); + } + } + } + + static Path resolveWriteTarget(Path requestedTarget) throws IOException { + return resolveWriteTarget(requestedTarget.toAbsolutePath(), new HashSet<>(), 0); + } + + private static Path resolveWriteTarget(Path current, Set visited, int depth) throws IOException { + if (depth > MAX_SYMLINK_DEPTH || !visited.add(current)) { + throw new IOException("Cyclic or excessively deep symbolic link while resolving " + current); + } + + // If the complete path exists, let the filesystem resolve every component. + // This is important for paths such as dirlink/../target.yml: lexical + // normalization before following dirlink can select a different file. + if (Files.exists(current)) { + return current.toRealPath(); + } + + // Files.exists follows links and therefore reports false for both a broken + // final link and a symlink cycle. Inspect the directory entry itself before + // recursing, while keeping a visited/depth guard for cycles. + if (Files.isSymbolicLink(current)) { + Path link = Files.readSymbolicLink(current); + Path linkedTarget = link.isAbsolute() ? link : current.getParent().resolve(link); + return resolveWriteTarget(linkedTarget, visited, depth + 1); + } + + Path parent = current.getParent(); + if (parent != null && Files.exists(parent)) { + return parent.toRealPath().resolve(current.getFileName()); + } + return current; + } + + private static boolean prepareReplacementMetadata(Path target, Path temp) throws IOException { + if (!Files.exists(target)) { + preserveNormalCreationPermissions(target, temp); + return true; + } + + // Java's POSIX view exposes owner/group/mode, but not Linux extended POSIX + // ACL entries. Use the preserved-inode commit path so those ACLs and other + // inode metadata survive without truncating the live destination. + PosixFileAttributeView targetPosix = Files.getFileAttributeView(target, PosixFileAttributeView.class); + if (targetPosix != null) { + applyPosixMetadataBestEffort(target, temp); + return false; + } + + AclFileAttributeView targetAcl = Files.getFileAttributeView(target, AclFileAttributeView.class); + AclFileAttributeView tempAcl = Files.getFileAttributeView(temp, AclFileAttributeView.class); + if (targetAcl != null && tempAcl != null) { + try { + tempAcl.setAcl(targetAcl.getAcl()); + UserPrincipal targetOwner = targetAcl.getOwner(); + if (!targetOwner.equals(tempAcl.getOwner())) { + tempAcl.setOwner(targetOwner); + } + return true; + } catch (IOException | SecurityException e) { + return false; + } + } + + return true; + } + + private static void preserveNormalCreationPermissions(Path target, Path temp) throws IOException { + PosixFileAttributeView tempPosix = Files.getFileAttributeView(temp, PosixFileAttributeView.class); + if (tempPosix == null) { + return; + } + + Path parent = target.getParent(); + if (parent == null) { + return; + } + Path probe = parent.resolve("." + target.getFileName() + "." + UUID.randomUUID() + ".mode-probe"); + try { + Files.createFile(probe); + Set permissions = Files + .readAttributes(probe, PosixFileAttributes.class).permissions(); + tempPosix.setPermissions(permissions); + } finally { + Files.deleteIfExists(probe); + } + } + + private static void applyPosixMetadataBestEffort(Path target, Path temp) { + try { + PosixFileAttributes attributes = Files.readAttributes(target, PosixFileAttributes.class); + PosixFileAttributeView tempPosix = Files.getFileAttributeView(temp, PosixFileAttributeView.class); + if (tempPosix == null) { + return; + } + try { + tempPosix.setPermissions(attributes.permissions()); + } catch (IOException | SecurityException ignored) { + } + try { + tempPosix.setGroup(attributes.group()); + } catch (IOException | SecurityException ignored) { + } + try { + if (!attributes.owner().equals(tempPosix.getOwner())) { + tempPosix.setOwner(attributes.owner()); + } + } catch (IOException | SecurityException ignored) { + } + } catch (IOException | SecurityException ignored) { + } + } + + private static Path preservedPath(Path target) { + return target.getParent().resolve("." + target.getFileName() + PRESERVED_SUFFIX); + } + + private static void recoverPreservedInode(Path target, Path preserved) throws IOException { + if (!Files.exists(preserved)) { + return; + } + if (Files.isSymbolicLink(preserved)) { + throw new IOException("Refusing to follow preserved-inode symlink: " + preserved); + } + if (!Files.exists(target)) { + moveReplacement(preserved, target); + return; + } + if (Files.isSameFile(target, preserved)) { + Files.delete(preserved); + return; + } + + copyCompleteContents(target, preserved); + moveReplacement(preserved, target); + } + + private static void commitThroughPreservedInode(Path temp, Path target, Path preserved) throws IOException { + Files.deleteIfExists(preserved); + Files.createLink(preserved, target); + boolean livePathReplaced = false; + try { + // The fully serialized temp becomes the live path first. If any later copy + // into the preserved inode fails, the live path still contains a complete + // new YAML rather than a truncated file. + moveReplacement(temp, target); + livePathReplaced = true; + + // Refill the preserved original inode while it is off-path, then atomically + // restore that inode to the live path so POSIX ACLs/ownership/xattrs survive. + copyCompleteContents(target, preserved); + moveReplacement(preserved, target); + } catch (IOException | RuntimeException e) { + if (!livePathReplaced) { + Files.deleteIfExists(preserved); + } + // Once the live path has been replaced, leave the preserved inode in place + // on failure. A later save will repair it from the complete live file before + // attempting another commit. + throw e; + } + } + + private static void saveSerializedInPlace(Path target, FileConfiguration data, IOException siblingCreationFailure) + throws IOException { + byte[] replacement = data.saveToString().getBytes(StandardCharsets.UTF_8); + byte[] original = null; + try { + original = Files.readAllBytes(target); + } catch (IOException | SecurityException ignored) { + // The legacy save path required only write access. Preserve that compatibility + // even when this process cannot read the old file for rollback. + } + + try { + writeCompleteBytes(target, replacement); + } catch (IOException writeFailure) { + writeFailure.addSuppressed(siblingCreationFailure); + if (original != null) { + try { + writeCompleteBytes(target, original); + } catch (IOException rollbackFailure) { + writeFailure.addSuppressed(rollbackFailure); + } + } + throw writeFailure; + } + } + + private static void writeCompleteBytes(Path target, byte[] bytes) throws IOException { + try (FileChannel output = FileChannel.open(target, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + output.position(0L); + while (buffer.hasRemaining()) { + output.write(buffer); + } + output.truncate(output.position()); + output.force(true); + } + } + + private static void copyCompleteContents(Path source, Path destination) throws IOException { + try (FileChannel input = FileChannel.open(source, StandardOpenOption.READ); + FileChannel output = FileChannel.open(destination, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.allocateDirect(64 * 1024); + output.position(0L); + while (input.read(buffer) != -1) { + buffer.flip(); + while (buffer.hasRemaining()) { + output.write(buffer); + } + buffer.clear(); + } + output.truncate(output.position()); + output.force(true); + } + } + + private static void moveReplacement(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/files/FilesManager.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/files/FilesManager.java index d83a2bf043..b66312d6d6 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/files/FilesManager.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/files/FilesManager.java @@ -11,51 +11,43 @@ import net.md_5.bungee.api.ChatColor; -// TODO: Auto-generated Javadoc /** - * The Class FilesManager. + * Handles asynchronous file writes for AdvancedCore. */ public class FilesManager { - /** The instance. */ - static FilesManager instance = new FilesManager(); + private static final FilesManager instance = new FilesManager(); - /** - * Gets the single instance of FilesManager. - * - * @return single instance of FilesManager - */ public static FilesManager getInstance() { return instance; } - /** The plugin. */ - AdvancedCorePlugin plugin = AdvancedCorePlugin.getInstance(); - - /** - * Instantiates a new files manager. - */ private FilesManager() { } /** - * Edits the file. + * Saves the supplied configuration asynchronously. The replacement is written to + * a temporary sibling file before it replaces the destination, preserving the + * previous file if serialization fails. * * @param file the file * @param data the data */ public void editFile(File file, FileConfiguration data) { - FileThread.getInstance().run(new Runnable() { - - @Override - public void run() { - try { - data.save(file); - } catch (IOException e) { - Bukkit.getServer().getLogger().severe(ChatColor.RED + "Could not save " + file.getName()); + FileThread.getInstance().run(() -> { + try { + AtomicYamlWriter.save(file, data); + } catch (IOException | RuntimeException e) { + AdvancedCorePlugin plugin = AdvancedCorePlugin.getInstance(); + if (plugin != null) { + plugin.getLogger().severe("Could not save " + (file == null ? "null file" : file.getName()) + + ": " + e.getMessage()); + plugin.debug(e); + } else { + Bukkit.getServer().getLogger().severe(ChatColor.RED + "Could not save " + + (file == null ? "null file" : file.getName()) + ": " + e.getMessage()); } - } }); } -} \ No newline at end of file +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/data/ServerData.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/data/ServerData.java index 7eb06c2b43..7c05f21c01 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/data/ServerData.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/data/ServerData.java @@ -1,122 +1,94 @@ -package com.bencodez.advancedcore.data; - -import java.io.File; - -import org.bukkit.plugin.Plugin; - -import com.bencodez.advancedcore.AdvancedCorePlugin; -import com.bencodez.simpleapi.file.YMLFile; - -// TODO: Auto-generated Javadoc -/** - * The Class ServerData. - */ -public class ServerData extends YMLFile { - public ServerData(AdvancedCorePlugin plugin) { - super(plugin, new File(plugin.getDataFolder(), "ServerData.yml")); - } - - public long getLastUpdated() { - return getData().getLong("LastUpdated", -1); - } - - /** - * Gets the plugin version. - * - * @param plugin the plugin - * @return the plugin version - */ - public String getPluginVersion(Plugin plugin) { - return getData().getString("PluginVersions." + plugin.getName(), ""); - } - - /** - * Gets the prev day. - * - * @return the prev day - */ - public int getPrevDay() { - return getData().getInt("PrevDay", -1); - } - - /** - * Gets the prev month. - * - * @return the prev month - */ - public String getPrevMonth() { - return getData().getString("Month", ""); - } - - /** - * Gets the prev week day. - * - * @return the prev week day - */ - public int getPrevWeekDay() { - return getData().getInt("PrevWeek", -1); - } - - public boolean isIgnoreTime() { - return getData().getBoolean("IgnoreTime", false); - } - - @Override - public void onFileCreation() { - } - - public void setData(String path, Object value) { - getData().set(path, value); - saveData(); - } - - public void setIgnoreTime(boolean value) { - getData().set("IgnoreTime", value); - saveData(); - } - - public void setLastUpdated() { - getData().set("LastUpdated", System.currentTimeMillis()); - saveData(); - } - - /** - * Sets the plugin version. - * - * @param plugin the new plugin version - */ - public void setPluginVersion(Plugin plugin) { - getData().set("PluginVersions." + plugin.getName(), plugin.getDescription().getVersion()); - saveData(); - } - - /** - * Sets the prev day. - * - * @param day the new prev day - */ - public void setPrevDay(int day) { - getData().set("PrevDay", day); - saveData(); - } - - /** - * Sets the prev month. - * - * @param month the new prev month - */ - public void setPrevMonth(String month) { - getData().set("Month", month); - saveData(); - } - - /** - * Sets the prev week day. - * - * @param week the new prev week day - */ - public void setPrevWeekDay(int week) { - getData().set("PrevWeek", week); - saveData(); - } -} +package com.bencodez.advancedcore.data; + +import java.io.File; +import java.io.IOException; + +import org.bukkit.plugin.Plugin; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.misc.files.AtomicYamlWriter; +import com.bencodez.simpleapi.file.YMLFile; + +/** + * Persistent server-level AdvancedCore data. + */ +public class ServerData extends YMLFile { + public ServerData(AdvancedCorePlugin plugin) { + super(plugin, new File(plugin.getDataFolder(), "ServerData.yml")); + } + + public long getLastUpdated() { + return getData().getLong("LastUpdated", -1); + } + + public String getPluginVersion(Plugin plugin) { + return getData().getString("PluginVersions." + plugin.getName(), ""); + } + + public int getPrevDay() { + return getData().getInt("PrevDay", -1); + } + + public String getPrevMonth() { + return getData().getString("Month", ""); + } + + public int getPrevWeekDay() { + return getData().getInt("PrevWeek", -1); + } + + public boolean isIgnoreTime() { + return getData().getBoolean("IgnoreTime", false); + } + + @Override + public void onFileCreation() { + } + + @Override + public void saveData() { + try { + AtomicYamlWriter.save(getdFile(), getData()); + } catch (IOException | RuntimeException e) { + getPlugin().getLogger().severe("Failed to save " + getdFile().getName() + ": " + e.getMessage()); + if (getPlugin() instanceof AdvancedCorePlugin) { + ((AdvancedCorePlugin) getPlugin()).debug(e); + } + } + } + + public void setData(String path, Object value) { + getData().set(path, value); + saveData(); + } + + public void setIgnoreTime(boolean value) { + getData().set("IgnoreTime", value); + saveData(); + } + + public void setLastUpdated() { + getData().set("LastUpdated", System.currentTimeMillis()); + saveData(); + } + + public void setPluginVersion(Plugin plugin) { + getData().set("PluginVersions." + plugin.getName(), plugin.getDescription().getVersion()); + saveData(); + } + + public void setPrevDay(int day) { + getData().set("PrevDay", day); + saveData(); + } + + public void setPrevMonth(String month) { + getData().set("Month", month); + saveData(); + } + + public void setPrevWeekDay(int week) { + getData().set("PrevWeek", week); + saveData(); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/files/AtomicYamlWriterSecurityMetadataTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/files/AtomicYamlWriterSecurityMetadataTest.java new file mode 100644 index 0000000000..2c372d30ae --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/files/AtomicYamlWriterSecurityMetadataTest.java @@ -0,0 +1,79 @@ +package com.bencodez.advancedcore.tests.files; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.nio.file.FileStore; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.AclFileAttributeView; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.PosixFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.util.EnumSet; +import java.util.Set; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.advancedcore.api.misc.files.AtomicYamlWriter; + +public class AtomicYamlWriterSecurityMetadataTest { + + @TempDir + Path tempDir; + + @Test + public void preservedInodeCommitKeepsExistingPosixMetadata() throws Exception { + FileStore store = Files.getFileStore(tempDir); + assumeTrue(store.supportsFileAttributeView("posix"), "POSIX attributes are not available"); + + Path target = tempDir.resolve("fallback.yml"); + Files.writeString(target, "Old: true\n"); + Set expected = EnumSet.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.GROUP_READ); + Files.setPosixFilePermissions(target, expected); + PosixFileAttributes before = Files.readAttributes(target, PosixFileAttributes.class); + Object fileKeyBefore = Files.readAttributes(target, BasicFileAttributes.class).fileKey(); + + YamlConfiguration replacement = new YamlConfiguration(); + replacement.set("New", true); + AtomicYamlWriter.save(target.toFile(), replacement); + + PosixFileAttributes after = Files.readAttributes(target, PosixFileAttributes.class); + Object fileKeyAfter = Files.readAttributes(target, BasicFileAttributes.class).fileKey(); + assertEquals(expected, after.permissions()); + assertEquals(before.owner(), after.owner()); + assertEquals(before.group(), after.group()); + if (fileKeyBefore != null && fileKeyAfter != null) { + assertEquals(fileKeyBefore, fileKeyAfter, + "the completed save must restore the original metadata-bearing inode"); + } + assertTrue(YamlConfiguration.loadConfiguration(target.toFile()).getBoolean("New")); + assertFalse(Files.exists(tempDir.resolve(".fallback.yml.advancedcore-preserved-inode"))); + } + + @Test + public void savePreservesAclMetadataWhenProviderSupportsIt() throws Exception { + FileStore store = Files.getFileStore(tempDir); + assumeTrue(store.supportsFileAttributeView("acl"), "ACL attributes are not available"); + + Path target = tempDir.resolve("acl.yml"); + Files.writeString(target, "Old: true\n"); + AclFileAttributeView beforeView = Files.getFileAttributeView(target, AclFileAttributeView.class); + assumeTrue(beforeView != null, "ACL view is unavailable"); + var aclBefore = beforeView.getAcl(); + var ownerBefore = beforeView.getOwner(); + + YamlConfiguration replacement = new YamlConfiguration(); + replacement.set("New", true); + AtomicYamlWriter.save(target.toFile(), replacement); + + AclFileAttributeView afterView = Files.getFileAttributeView(target, AclFileAttributeView.class); + assertEquals(ownerBefore, afterView.getOwner()); + assertEquals(aclBefore, afterView.getAcl()); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/files/AtomicYamlWriterTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/files/AtomicYamlWriterTest.java new file mode 100644 index 0000000000..1bed0982be --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/files/AtomicYamlWriterTest.java @@ -0,0 +1,257 @@ +package com.bencodez.advancedcore.tests.files; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.FileStore; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.PosixFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.util.EnumSet; +import java.util.Set; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.advancedcore.api.misc.files.AtomicYamlWriter; + +public class AtomicYamlWriterTest { + + @TempDir + Path tempDir; + + @Test + public void replacesYamlWithoutLeavingTemporaryFiles() throws Exception { + File file = tempDir.resolve("ServerData.yml").toFile(); + YamlConfiguration oldData = new YamlConfiguration(); + oldData.set("Old", true); + oldData.save(file); + + YamlConfiguration replacement = new YamlConfiguration(); + replacement.set("New.Value", 42); + AtomicYamlWriter.save(file, replacement); + + YamlConfiguration loaded = YamlConfiguration.loadConfiguration(file); + assertFalse(loaded.contains("Old")); + assertEquals(42, loaded.getInt("New.Value")); + assertEquals(1L, Files.list(tempDir).count()); + } + + @Test + public void createsMissingParentDirectory() throws Exception { + File file = tempDir.resolve("nested/data.yml").toFile(); + YamlConfiguration data = new YamlConfiguration(); + data.set("Ready", true); + + AtomicYamlWriter.save(file, data); + + assertTrue(file.isFile()); + assertTrue(YamlConfiguration.loadConfiguration(file).getBoolean("Ready")); + } + + @Test + public void newPosixFileUsesNormalCreationPermissions() throws Exception { + FileStore store = Files.getFileStore(tempDir); + assumeTrue(store.supportsFileAttributeView("posix"), "POSIX attributes are not available"); + + Path normalCreate = tempDir.resolve("normal-create.yml"); + Files.createFile(normalCreate); + Set expected = Files.getPosixFilePermissions(normalCreate); + Files.delete(normalCreate); + + Path target = tempDir.resolve("atomic-create.yml"); + YamlConfiguration data = new YamlConfiguration(); + data.set("Ready", true); + AtomicYamlWriter.save(target.toFile(), data); + + assertEquals(expected, Files.getPosixFilePermissions(target), + "first-time saves should honor the same process umask as a normal file creation"); + } + + @Test + public void preservesDestinationSymlink() throws Exception { + Path sharedDir = tempDir.resolve("shared"); + Path localDir = tempDir.resolve("local"); + Files.createDirectories(sharedDir); + Files.createDirectories(localDir); + Path sharedTarget = sharedDir.resolve("ServerData.yml"); + YamlConfiguration original = new YamlConfiguration(); + original.set("Old", true); + original.save(sharedTarget.toFile()); + + Path link = localDir.resolve("ServerData.yml"); + try { + Files.createSymbolicLink(link, localDir.relativize(sharedTarget)); + } catch (UnsupportedOperationException | SecurityException e) { + assumeTrue(false, "symbolic links are not supported by this test environment"); + } + + YamlConfiguration replacement = new YamlConfiguration(); + replacement.set("New", true); + AtomicYamlWriter.save(link.toFile(), replacement); + + assertTrue(Files.isSymbolicLink(link), "replacement must not replace the symlink itself"); + assertTrue(YamlConfiguration.loadConfiguration(sharedTarget.toFile()).getBoolean("New")); + assertFalse(YamlConfiguration.loadConfiguration(sharedTarget.toFile()).contains("Old")); + } + + @Test + public void resolvesDotDotAfterIntermediateSymlinkUsingFilesystemSemantics() throws Exception { + Path local = tempDir.resolve("local"); + Path elsewhere = tempDir.resolve("elsewhere"); + Path nested = elsewhere.resolve("nested"); + Files.createDirectories(local); + Files.createDirectories(nested); + + Path directoryLink = local.resolve("dirlink"); + Path fileLink = local.resolve("ServerData.yml"); + try { + Files.createSymbolicLink(directoryLink, local.relativize(nested)); + Files.createSymbolicLink(fileLink, Path.of("dirlink/../target.yml")); + } catch (UnsupportedOperationException | SecurityException e) { + assumeTrue(false, "symbolic links are not supported by this test environment"); + } + + Path expectedTarget = elsewhere.resolve("target.yml"); + YamlConfiguration original = new YamlConfiguration(); + original.set("Old", true); + original.save(expectedTarget.toFile()); + + YamlConfiguration replacement = new YamlConfiguration(); + replacement.set("New", true); + AtomicYamlWriter.save(fileLink.toFile(), replacement); + + assertTrue(YamlConfiguration.loadConfiguration(expectedTarget.toFile()).getBoolean("New")); + assertFalse(Files.exists(local.resolve("target.yml")), + "lexical normalization must not redirect a symlink containing an intermediate link plus .."); + } + + @Test + public void rejectsCyclicSymlinkChains() throws Exception { + Path first = tempDir.resolve("first.yml"); + Path second = tempDir.resolve("second.yml"); + try { + Files.createSymbolicLink(first, second.getFileName()); + Files.createSymbolicLink(second, first.getFileName()); + } catch (UnsupportedOperationException | SecurityException e) { + assumeTrue(false, "symbolic links are not supported by this test environment"); + } + + YamlConfiguration replacement = new YamlConfiguration(); + replacement.set("New", true); + + IOException error = assertThrows(IOException.class, () -> AtomicYamlWriter.save(first.toFile(), replacement)); + assertTrue(error.getMessage().contains("Cyclic") || error.getMessage().contains("deep")); + } + + @Test + public void writableExistingFileSavesWhenParentDirectoryIsNotWritable() throws Exception { + FileStore store = Files.getFileStore(tempDir); + assumeTrue(store.supportsFileAttributeView("posix"), "POSIX attributes are not available"); + + Path lockedDir = tempDir.resolve("locked"); + Files.createDirectory(lockedDir); + Path target = lockedDir.resolve("ServerData.yml"); + Files.writeString(target, "Old: true\n"); + + Set originalDirPermissions = Files.getPosixFilePermissions(lockedDir); + Files.setPosixFilePermissions(target, + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + Files.setPosixFilePermissions(lockedDir, + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_EXECUTE)); + + try { + assumeTrue(!Files.isWritable(lockedDir), + "test environment still permits sibling creation in a non-writable directory"); + assumeTrue(Files.isWritable(target), + "existing target must remain writable for compatibility fallback"); + + YamlConfiguration replacement = new YamlConfiguration(); + replacement.set("New", true); + AtomicYamlWriter.save(target.toFile(), replacement); + + YamlConfiguration loaded = YamlConfiguration.loadConfiguration(target.toFile()); + assertTrue(loaded.getBoolean("New")); + assertFalse(loaded.contains("Old")); + } finally { + Files.setPosixFilePermissions(lockedDir, originalDirPermissions); + } + } + + @Test + public void preservesExistingPosixPermissionsOwnershipAndInode() throws Exception { + Path target = tempDir.resolve("permissions.yml"); + FileStore store = Files.getFileStore(tempDir); + assumeTrue(store.supportsFileAttributeView("posix"), "POSIX attributes are not available"); + + YamlConfiguration original = new YamlConfiguration(); + original.set("Old", true); + original.save(target.toFile()); + Set expected = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, + PosixFilePermission.GROUP_READ); + Files.setPosixFilePermissions(target, expected); + PosixFileAttributes before = Files.readAttributes(target, PosixFileAttributes.class); + Object beforeFileKey = Files.readAttributes(target, BasicFileAttributes.class).fileKey(); + + YamlConfiguration replacement = new YamlConfiguration(); + replacement.set("New", true); + AtomicYamlWriter.save(target.toFile(), replacement); + + PosixFileAttributes after = Files.readAttributes(target, PosixFileAttributes.class); + Object afterFileKey = Files.readAttributes(target, BasicFileAttributes.class).fileKey(); + assertEquals(expected, after.permissions()); + assertEquals(before.owner(), after.owner(), "save must preserve the file owner"); + assertEquals(before.group(), after.group(), "save must preserve the configured POSIX group"); + if (beforeFileKey != null && afterFileKey != null) { + assertEquals(beforeFileKey, afterFileKey, + "existing POSIX files must retain their inode so extended POSIX ACLs are not discarded"); + } + assertFalse(Files.exists(tempDir.resolve(".permissions.yml.advancedcore-preserved-inode"))); + } + + @Test + public void recoversPreservedInodeAfterInterruptedPosixCommit() throws Exception { + FileStore store = Files.getFileStore(tempDir); + assumeTrue(store.supportsFileAttributeView("posix"), "POSIX attributes are not available"); + + Path target = tempDir.resolve("recovery.yml"); + YamlConfiguration original = new YamlConfiguration(); + original.set("Old", true); + original.save(target.toFile()); + Object originalFileKey = Files.readAttributes(target, BasicFileAttributes.class).fileKey(); + + Path preserved = tempDir.resolve(".recovery.yml.advancedcore-preserved-inode"); + try { + Files.createLink(preserved, target); + } catch (UnsupportedOperationException | SecurityException e) { + assumeTrue(false, "hard links are not supported by this test environment"); + } + + Path simulatedLiveReplacement = tempDir.resolve("simulated-new.yml"); + YamlConfiguration alreadyCommitted = new YamlConfiguration(); + alreadyCommitted.set("Committed", true); + alreadyCommitted.save(simulatedLiveReplacement.toFile()); + Files.move(simulatedLiveReplacement, target, StandardCopyOption.REPLACE_EXISTING); + + YamlConfiguration nextSave = new YamlConfiguration(); + nextSave.set("Final", true); + AtomicYamlWriter.save(target.toFile(), nextSave); + + assertTrue(YamlConfiguration.loadConfiguration(target.toFile()).getBoolean("Final")); + assertFalse(Files.exists(preserved)); + Object finalFileKey = Files.readAttributes(target, BasicFileAttributes.class).fileKey(); + if (originalFileKey != null && finalFileKey != null) { + assertEquals(originalFileKey, finalFileKey, + "recovery should restore the preserved inode before the next successful commit"); + } + } +}