BackupManager.kt
package de.pflugradts.passbird.application.process.backup
import de.pflugradts.kotlinextensions.tryCatching
import de.pflugradts.passbird.application.Directory
import de.pflugradts.passbird.application.FileName
import de.pflugradts.passbird.application.RunContext
import de.pflugradts.passbird.application.configuration.ReadableConfiguration
import de.pflugradts.passbird.application.configuration.ReadableConfiguration.Companion.CONFIGURATION_FILENAME
import de.pflugradts.passbird.application.configuration.ReadableConfiguration.Companion.KEYSTORE_FILENAME
import de.pflugradts.passbird.application.configuration.ReadableConfiguration.Companion.PASSWORD_TREE_FILENAME
import de.pflugradts.passbird.application.passwordtree.PasswordTreeEnvelope
import de.pflugradts.passbird.application.process.Finalizer
import de.pflugradts.passbird.application.toDirectory
import de.pflugradts.passbird.application.toFileName
import de.pflugradts.passbird.application.util.SystemOperation
import de.pflugradts.passbird.domain.model.shell.EncryptedShell.Companion.encryptedShellOf
import de.pflugradts.passbird.domain.model.shell.Shell
import de.pflugradts.passbird.domain.service.password.encryption.CryptoProvider
import de.pflugradts.passbird.domain.service.password.tree.PasswordTreeAdapterPort
import java.nio.file.Path
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
private val backupTimestampFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")
private const val BACKUP_TIMESTAMP_PATTERN = "\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2}(?:_\\d+)?"
class BackupManager constructor(
private val configuration: ReadableConfiguration,
private val runContext: RunContext,
private val systemOperation: SystemOperation,
private val cryptoProvider: CryptoProvider,
private val passwordTreeEnvelope: PasswordTreeEnvelope,
private val passwordTreeAdapterPort: PasswordTreeAdapterPort,
) : Finalizer {
private val backupConfiguration get() = configuration.application.backup
override fun run() {
listOf(
Triple(backupConfiguration.configuration, runContext.homeDirectory, CONFIGURATION_FILENAME),
Triple(backupConfiguration.passwordTree, configuration.adapter.passwordTree.location.toDirectory(), PASSWORD_TREE_FILENAME),
Triple(backupConfiguration.keyStore, configuration.adapter.keyStore.location.toDirectory(), KEYSTORE_FILENAME),
).forEach { (settings, directory, fileName) ->
if (settings.enabled && numberOfBackups(settings) > 0) {
val current = systemOperation.resolvePath(directory, fileName.toFileName())
ensurePasswordTreeExists(current, fileName)
if (!systemOperation.exists(current)) return@forEach
val limit = numberOfBackups(settings)
val backupDirectory = systemOperation.getPath(runContext.homeDirectory)
.resolve(settings.location ?: backupConfiguration.location)
.toString().toDirectory()
if (!systemOperation.exists(backupDirectory)) systemOperation.createDirectory(backupDirectory)
val backups = systemOperation.getFileNames(backupDirectory).filter {
it.value.matches("${fileName.stem()}_$BACKUP_TIMESTAMP_PATTERN\\.${fileName.extension()}".toRegex())
}.sortedBy { it.value }
val backupWasCreated = if (fileName == PASSWORD_TREE_FILENAME) {
backupPasswordTreeIfChanged(current, directory, fileName, backupDirectory, backups)
} else if (backups.isNotEmpty()) {
backupIfFileContentHasChanged(current, directory, fileName, backupDirectory, backups)
} else {
backup(directory, fileName, backupDirectory)
true
}
if (backupWasCreated) {
backups.take(0.coerceAtLeast((backups.size + 1) - limit)).forEach {
systemOperation.delete(systemOperation.resolvePath(backupDirectory, it))
}
}
}
}
}
private fun ensurePasswordTreeExists(current: Path, fileName: String) {
if (fileName == PASSWORD_TREE_FILENAME && !systemOperation.exists(current)) {
passwordTreeAdapterPort.sync(passwordTreeAdapterPort.restore())
}
}
private fun numberOfBackups(settings: ReadableConfiguration.BackupSettings) =
settings.numberOfBackups ?: configuration.application.backup.numberOfBackups
private fun fileContentHasChanged(current: Path, lastBackup: Path) =
!systemOperation.readBytesFromFile(current).contentEquals(systemOperation.readBytesFromFile(lastBackup))
private fun backupIfFileContentHasChanged(
current: Path,
directory: Directory,
fileName: String,
backupDirectory: Directory,
backups: List<FileName>,
): Boolean {
val lastBackup = systemOperation.resolvePath(backupDirectory, backups.last())
return if (fileContentHasChanged(current, lastBackup)) {
backup(directory, fileName, backupDirectory)
true
} else {
false
}
}
private fun backupPasswordTreeIfChanged(
current: Path,
directory: Directory,
fileName: String,
backupDirectory: Directory,
backups: List<FileName>,
): Boolean {
val currentShell = readComparablePasswordTreeShellOrNull(current) ?: return false
return try {
if (backups.isEmpty()) {
backup(directory, fileName, backupDirectory)
true
} else {
val lastBackup = systemOperation.resolvePath(backupDirectory, backups.last())
val lastBackupShell = readComparablePasswordTreeShellOrNull(lastBackup)
try {
if (lastBackupShell == null || currentShell != lastBackupShell) {
backup(directory, fileName, backupDirectory)
true
} else {
false
}
} finally {
lastBackupShell?.scramble()
}
}
} finally {
currentShell.scramble()
}
}
private fun readComparablePasswordTreeShellOrNull(path: Path): Shell? = tryCatching {
systemOperation.readBytesFromFile(path).let {
if (!passwordTreeEnvelope.isCurrent(it)) return@tryCatching null
cryptoProvider.decrypt(
encryptedShellOf(passwordTreeEnvelope.unwrap(it)),
)
}
}.getOrNull()
private fun backup(directory: Directory, fileName: String, backupDirectory: Directory) {
val timestamp = LocalDateTime.now(systemOperation.clock).format(backupTimestampFormatter)
val backupName = backupName(fileName, backupDirectory, timestamp)
systemOperation.copyTo(
systemOperation.resolvePath(directory, fileName.toFileName()),
systemOperation.resolvePath(backupDirectory, backupName.toFileName()),
)
}
private fun backupName(fileName: String, backupDirectory: Directory, timestamp: String): String {
val backupNamePrefix = "${fileName.stem()}_$timestamp"
val suffixes = systemOperation.getFileNames(backupDirectory).mapNotNull {
it.value.backupSuffixFor(backupNamePrefix, fileName.extension())
}
val collisionSuffix = suffixes.maxOrNull()?.let { "_${(it + 1).toString().padStart(3, '0')}" }.orEmpty()
return "$backupNamePrefix$collisionSuffix.${fileName.extension()}"
}
}
private fun String.stem() = substring(0, indexOf("."))
private fun String.extension() = substring(indexOf(".") + 1)
private fun String.backupSuffixFor(backupNamePrefix: String, extension: String): Long? {
val regex = "${Regex.escape(backupNamePrefix)}(?:_(\\d+))?\\.${Regex.escape(extension)}".toRegex()
val match = regex.matchEntire(this) ?: return null
return match.groupValues[1].takeIf { it.isNotEmpty() }?.toLong() ?: 0
}