DbFileRewriter.java
/*
Copyright (c) 2025 James Ahlborn
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package com.healthmarketscience.jackcess.crypt.impl;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
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.PosixFileAttributeView;
import java.nio.file.attribute.PosixFilePermission;
import java.util.Set;
/**
* Runs a whole file rewrite of a database, and holds the page level file
* work the rewrites share. Adding or removing encryption touches every page
* and cannot be undone half way, so the work always builds a separate file
* and the original is replaced only once that file is complete. An
* interrupted rewrite leaves the original as it was.
*
* @author James Ahlborn
*/
public class DbFileRewriter
{
private static final String TEMP_PREFIX = "jackcess";
private static final String TEMP_SUFFIX = ".tmp";
private DbFileRewriter() {}
/**
* The work one rewrite performs, reading the source database and writing
* the destination. The destination file exists and is empty when this
* runs.
*/
@FunctionalInterface
public interface Rewrite
{
void apply(Path srcFile, Path dstFile) throws IOException;
}
/**
* Rewrites the given database into the given destination, which must not be
* the source and is overwritten if it exists.
*/
public static void rewriteTo(Path srcFile, Path dstFile, Rewrite op)
throws IOException
{
if(Files.exists(dstFile)) {
// a symbolic link is followed to the database it names, the same as an
// in place rewrite does
dstFile = dstFile.toRealPath();
if(Files.isSameFile(srcFile, dstFile)) {
throw new IllegalArgumentException(
"Destination is the source database, use the in place form " +
"instead");
}
}
rewrite(srcFile, dstFile, op);
}
/**
* Rewrites the given database in place, replacing it with the result.
* <p>
* The replacement gives the database a new identity on disk, so any hard
* link to it keeps the contents the rewrite started with, and an open handle
* on it keeps reading the old file. The database must not be in use.
*/
public static void rewriteInPlace(Path dbFile, Rewrite op)
throws IOException
{
// a symbolic link is followed to the database it names. Moving over the
// link itself would replace it with a regular file and leave the database
// it pointed at holding what the rewrite started with
dbFile = dbFile.toRealPath();
rewrite(dbFile, dbFile, op);
}
/**
* Builds the result in a temp file and moves it over the destination, which
* is the source itself for a rewrite in place. The temp file is built
* beside the destination so that the move stays within one file system,
* which is what lets it be atomic.
*/
private static void rewrite(Path srcFile, Path dstFile, Rewrite op)
throws IOException
{
Path tmpFile = createTempFile(dstFile);
try {
op.apply(srcFile, tmpFile);
copyPermissions(srcFile, tmpFile);
force(tmpFile);
move(tmpFile, dstFile);
tmpFile = null;
} finally {
deleteQuietly(tmpFile);
}
}
/**
* Fills the given buffer with the given page, and fails if the file ends
* first.
*/
static void readPage(FileChannel channel, ByteBuffer page, int pageNumber,
int pageSize)
throws IOException
{
page.clear();
long offset = pageOffset(pageNumber, pageSize);
int bytesRead = 0;
while(bytesRead < pageSize) {
int read = channel.read(page, offset + bytesRead);
if(read <= 0) {
// the file ended before the page did, or the buffer is too small to
// hold one. Without this the loop would make no progress
throw new IOException(
"Failed to read page " + pageNumber + ", got " + bytesRead +
" of " + pageSize + " bytes");
}
bytesRead += read;
}
}
/**
* Writes the given buffer as the given page.
*/
static void writePage(FileChannel channel, ByteBuffer page, int pageNumber,
int pageSize)
throws IOException
{
page.rewind();
page.limit(pageSize);
long offset = pageOffset(pageNumber, pageSize);
int bytesWritten = 0;
while(bytesWritten < pageSize) {
int written = channel.write(page, offset + bytesWritten);
if(written <= 0) {
throw new IOException(
"Failed to write page " + pageNumber + ", wrote " + bytesWritten +
" of " + pageSize + " bytes");
}
bytesWritten += written;
}
}
/**
* Returns how many whole pages the given database holds, and fails if it
* does not end on a page boundary.
*/
static int countPages(Path dbFile, int pageSize) throws IOException
{
long size = Files.size(dbFile);
if((size % pageSize) != 0) {
throw new IOException(
"Database length " + size + " is not a whole number of " +
pageSize + " byte pages");
}
return (int)(size / pageSize);
}
private static long pageOffset(int pageNumber, int pageSize) {
return (long)pageNumber * (long)pageSize;
}
private static Path createTempFile(Path nearFile) throws IOException
{
Path dir = nearFile.toAbsolutePath().getParent();
if(dir == null) {
throw new IOException("Cannot find the directory holding " + nearFile);
}
return Files.createTempFile(dir, TEMP_PREFIX, TEMP_SUFFIX);
}
/**
* Gives the new file the permissions of the one it replaces. A fresh temp
* file is readable only by its owner, which is not what the database had.
*/
private static void copyPermissions(Path srcFile, Path dstFile)
throws IOException
{
if(!Files.getFileStore(dstFile).supportsFileAttributeView(
PosixFileAttributeView.class)) {
return;
}
Set<PosixFilePermission> perms = Files.getPosixFilePermissions(srcFile);
Files.setPosixFilePermissions(dstFile, perms);
}
/**
* Puts the new file on the disk before it is moved into place. A rename
* can reach the disk ahead of the data it renames, which would leave the
* database holding whatever the blocks contained after a power loss.
* <p>
* The file is opened again rather than forced through the channel which
* wrote it, because the ordering belongs to the move rather than to any one
* rewrite. A sync covers the file and not the handle, so a fresh channel
* reaches what a closed one wrote.
*/
private static void force(Path file) throws IOException
{
try(FileChannel channel = FileChannel.open(file,
StandardOpenOption.WRITE)) {
channel.force(true);
}
}
private static void move(Path tmpFile, Path dstFile) throws IOException
{
try {
Files.move(tmpFile, dstFile, StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
} catch(AtomicMoveNotSupportedException e) {
// the file system cannot do it in one step, so the replacement has a
// window in which the destination is incomplete
Files.move(tmpFile, dstFile, StandardCopyOption.REPLACE_EXISTING);
}
}
private static void deleteQuietly(Path file)
{
if(file == null) {
return;
}
try {
Files.deleteIfExists(file);
} catch(IOException ignored) {
// the rewrite already failed, and the temp file is not the reason
}
}
}