Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Force the temporary file before exposing it

When the host loses power after moveReplacement(temp, target) but before copyCompleteContents forces the preserved inode, data.save(...) has only closed the temporary file and has not forced its contents to stable storage. The rename can therefore survive while some YAML data blocks do not, leaving the live path empty or partial; on the next save, recoverPreservedInode copies that damaged path over the known-good preserved inode. Force the serialized temporary file before the first move, and durably sync the containing directory where supported.

Useful? React with 👍 / 👎.

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<Path> 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;
Comment thread
BenCodez marked this conversation as resolved.
Resolved
}

// 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve non-ACL metadata before replacing Windows files

On an ACL-capable non-POSIX filesystem such as NTFS, this successful branch copies only the ACL and owner and then replaces the destination inode. Existing DOS attributes such as hidden/system/archive and user-defined attributes or alternate data streams are consequently discarded after the first save, whereas the previous in-place writer retained them. Preserve the remaining supported attribute views or use the metadata-bearing inode commit path for these destinations.

Useful? React with 👍 / 👎.

} 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<java.nio.file.attribute.PosixFilePermission> 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);
Comment on lines +225 to +226

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize commits that share a destination

When two threads or server processes save the same resolved YAML concurrently, both transactions use this single fixed preserved-inode path, so one can delete or relink the other transaction's backup while its subsequent copy and move operations are still running. The interleaved operations can then copy into the wrong inode, lose the destination's metadata, throw after partially completing a save, or produce inconsistent contents; use destination-scoped cross-process synchronization or transaction-unique sidecars with ownership validation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — this is valid and is not fixed on current head 1fd4a6229d0b6559ac4f662ad09413b3e5ac8457. The fixed .advancedcore-preserved-inode sidecar can collide across concurrent writers, so this thread should remain open until the transaction is serialized per resolved destination or uses uniquely owned sidecars. I’m requesting a fresh Codex review of the current head now to catch any additional persistence issues before the next patch.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall back when hard links are unavailable

When an existing YAML resides on a POSIX-view filesystem that does not support hard links, such as some network or virtual mounts, every save reaches this call and fails even though creating the sibling temporary file and replacing the destination may be supported. Because createLink has no fallback, both ServerData and reward persistence stop working after the file exists; fall back to metadata-preserving replacement or the compatible in-place path when hard-link creation is unsupported.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — this is valid and is not fixed on current head 1fd4a6229d0b6559ac4f662ad09413b3e5ac8457. Existing POSIX files currently depend on Files.createLink(...); mounts that expose POSIX attributes but reject hard links need a safe fallback rather than failing the save. I’m leaving this thread open and requesting a fresh Codex review of the current head now so any other filesystem compatibility issues are surfaced before the next patch.

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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

}
});
}
}
}
Loading
Loading