Compare commits
3 Commits
v0.1.0
...
c802c76ace
Author | SHA1 | Date | |
---|---|---|---|
c802c76ace
|
|||
9f6fad8466
|
|||
571e952897
|
@@ -1,16 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
end_of_line = lf
|
|
||||||
indent_size = 4
|
|
||||||
indent_style = space
|
|
||||||
insert_final_newline = false
|
|
||||||
max_line_length = 120
|
|
||||||
tab_width = 4
|
|
||||||
|
|
||||||
[{*.yaml,*.yml}]
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[{*.kt,*.kts}]
|
|
||||||
ij_kotlin_packages_to_use_import_on_demand = org.junit.jupiter.api,aws.sdk.kotlin.services.s3,kotlinx.coroutines,java.io,ziputils
|
|
9
.github/workflows/publish.yaml
vendored
9
.github/workflows/publish.yaml
vendored
@@ -1,8 +1,11 @@
|
|||||||
name: Publish Workflow
|
name: Publish Workflow
|
||||||
on:
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
push:
|
push:
|
||||||
tags:
|
branches:
|
||||||
- v*
|
- main
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
name: Publish library
|
name: Publish library
|
||||||
@@ -38,7 +41,7 @@ jobs:
|
|||||||
run: ./gradlew shadowJar
|
run: ./gradlew shadowJar
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ShadowJAR
|
name: ShadowJAR
|
||||||
path: build/libs/*-all.jar
|
path: build/libs/*-all.jar
|
124
.idea/uiDesigner.xml
generated
124
.idea/uiDesigner.xml
generated
@@ -1,124 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Palette2">
|
|
||||||
<group name="Swing">
|
|
||||||
<item class="com.intellij.uiDesigner.HSpacer" tooltip-text="Horizontal Spacer" icon="/com/intellij/uiDesigner/icons/hspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="1" hsize-policy="6" anchor="0" fill="1" />
|
|
||||||
</item>
|
|
||||||
<item class="com.intellij.uiDesigner.VSpacer" tooltip-text="Vertical Spacer" icon="/com/intellij/uiDesigner/icons/vspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="1" anchor="0" fill="2" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JPanel" icon="/com/intellij/uiDesigner/icons/panel.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JScrollPane" icon="/com/intellij/uiDesigner/icons/scrollPane.svg" removable="false" auto-create-binding="false" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="7" hsize-policy="7" anchor="0" fill="3" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JButton" icon="/com/intellij/uiDesigner/icons/button.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="3" anchor="0" fill="1" />
|
|
||||||
<initial-values>
|
|
||||||
<property name="text" value="Button" />
|
|
||||||
</initial-values>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JRadioButton" icon="/com/intellij/uiDesigner/icons/radioButton.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
|
|
||||||
<initial-values>
|
|
||||||
<property name="text" value="RadioButton" />
|
|
||||||
</initial-values>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JCheckBox" icon="/com/intellij/uiDesigner/icons/checkBox.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
|
|
||||||
<initial-values>
|
|
||||||
<property name="text" value="CheckBox" />
|
|
||||||
</initial-values>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JLabel" icon="/com/intellij/uiDesigner/icons/label.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="0" anchor="8" fill="0" />
|
|
||||||
<initial-values>
|
|
||||||
<property name="text" value="Label" />
|
|
||||||
</initial-values>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JTextField" icon="/com/intellij/uiDesigner/icons/textField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
|
|
||||||
<preferred-size width="150" height="-1" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JPasswordField" icon="/com/intellij/uiDesigner/icons/passwordField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
|
|
||||||
<preferred-size width="150" height="-1" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JFormattedTextField" icon="/com/intellij/uiDesigner/icons/formattedTextField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
|
|
||||||
<preferred-size width="150" height="-1" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JTextArea" icon="/com/intellij/uiDesigner/icons/textArea.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
|
||||||
<preferred-size width="150" height="50" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JTextPane" icon="/com/intellij/uiDesigner/icons/textPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
|
||||||
<preferred-size width="150" height="50" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JEditorPane" icon="/com/intellij/uiDesigner/icons/editorPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
|
||||||
<preferred-size width="150" height="50" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JComboBox" icon="/com/intellij/uiDesigner/icons/comboBox.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="2" anchor="8" fill="1" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JTable" icon="/com/intellij/uiDesigner/icons/table.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
|
||||||
<preferred-size width="150" height="50" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JList" icon="/com/intellij/uiDesigner/icons/list.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="2" anchor="0" fill="3">
|
|
||||||
<preferred-size width="150" height="50" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JTree" icon="/com/intellij/uiDesigner/icons/tree.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
|
||||||
<preferred-size width="150" height="50" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JTabbedPane" icon="/com/intellij/uiDesigner/icons/tabbedPane.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
|
|
||||||
<preferred-size width="200" height="200" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JSplitPane" icon="/com/intellij/uiDesigner/icons/splitPane.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
|
|
||||||
<preferred-size width="200" height="200" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JSpinner" icon="/com/intellij/uiDesigner/icons/spinner.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JSlider" icon="/com/intellij/uiDesigner/icons/slider.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JSeparator" icon="/com/intellij/uiDesigner/icons/separator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JProgressBar" icon="/com/intellij/uiDesigner/icons/progressbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JToolBar" icon="/com/intellij/uiDesigner/icons/toolbar.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1">
|
|
||||||
<preferred-size width="-1" height="20" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JToolBar$Separator" icon="/com/intellij/uiDesigner/icons/toolbarSeparator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="0" anchor="0" fill="1" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JScrollBar" icon="/com/intellij/uiDesigner/icons/scrollbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="0" anchor="0" fill="2" />
|
|
||||||
</item>
|
|
||||||
</group>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
58
README.md
58
README.md
@@ -6,64 +6,6 @@ an AWS S3 bucket.
|
|||||||
This tool is released as a JAR in the [release page](https://git.koval.net/cyclane/teamcity-executors-test-task/releases).
|
This tool is released as a JAR in the [release page](https://git.koval.net/cyclane/teamcity-executors-test-task/releases).
|
||||||
Use `java -jar <backup-jar-name>.jar --help` for more detailed usage instructions.
|
Use `java -jar <backup-jar-name>.jar --help` for more detailed usage instructions.
|
||||||
|
|
||||||
### --help
|
|
||||||
```
|
|
||||||
Usage: s3backup-tool [<options>] <command> [<args>]...
|
|
||||||
|
|
||||||
A simple AWS S3 backup tool. This tool assumes credentials are properly configured using aws-cli.
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-h, --help Show this message and exit
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
create Create a backup of a file or directory.
|
|
||||||
restore Restore a backup from AWS S3.
|
|
||||||
restore-file Restore a single file from a backup from AWS S3.
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Subcommands
|
|
||||||
```
|
|
||||||
Usage: s3backup-tool create [<options>] <source> <bucket>
|
|
||||||
|
|
||||||
Create a backup of a file or directory.
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-h, --help Show this message and exit
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
<source> File or directory to backup
|
|
||||||
<bucket> Name of S3 bucket to backup to
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
Usage: s3backup-tool restore [<options>] <bucket> <backupkey> <destination>
|
|
||||||
|
|
||||||
Restore a backup from AWS S3.
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-h, --help Show this message and exit
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
<bucket> Name of S3 bucket to restore the backup from
|
|
||||||
<backupkey> The S3 key of the backup to restore
|
|
||||||
<destination> Directory to restore to
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
Usage: s3backup-tool restore-file [<options>] <bucket> <backupkey> <filepath> <destination>
|
|
||||||
|
|
||||||
Restore a single file from a backup from AWS S3.
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-h, --help Show this message and exit
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
<bucket> Name of S3 bucket to restore the backup from
|
|
||||||
<backupkey> The S3 key of the backup to restore
|
|
||||||
<filepath> File path within the backup
|
|
||||||
<destination> Directory to restore to
|
|
||||||
```
|
|
||||||
|
|
||||||
## Assumptions
|
## Assumptions
|
||||||
1. This test task is not interested in re-implementations of common libraries (AWS SDK, Clikt, Gradle Shadow, ...)
|
1. This test task is not interested in re-implementations of common libraries (AWS SDK, Clikt, Gradle Shadow, ...)
|
||||||
2. The last part (restoration of a single file) should be optimised so that only the part of the blob required for this
|
2. The last part (restoration of a single file) should be optimised so that only the part of the blob required for this
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
|
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
application
|
|
||||||
kotlin("jvm") version "1.9.21"
|
kotlin("jvm") version "1.9.21"
|
||||||
id("org.jlleitschuh.gradle.ktlint") version "12.0.3"
|
|
||||||
id("com.github.johnrengelman.shadow") version "8.1.1"
|
id("com.github.johnrengelman.shadow") version "8.1.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +16,6 @@ dependencies {
|
|||||||
implementation("aws.sdk.kotlin:s3:1.0.25")
|
implementation("aws.sdk.kotlin:s3:1.0.25")
|
||||||
implementation("org.slf4j:slf4j-simple:2.0.9")
|
implementation("org.slf4j:slf4j-simple:2.0.9")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
||||||
implementation("com.github.ajalt.clikt:clikt:4.2.1")
|
|
||||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +25,8 @@ tasks.test {
|
|||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(17)
|
jvmToolchain(17)
|
||||||
}
|
}
|
||||||
application {
|
tasks.jar {
|
||||||
mainClass.set("MainKt")
|
manifest {
|
||||||
|
attributes("Main-Class" to "backup.MainKt")
|
||||||
|
}
|
||||||
}
|
}
|
@@ -2,3 +2,4 @@ plugins {
|
|||||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
|
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
|
||||||
}
|
}
|
||||||
rootProject.name = "teamcity-executors-test-task"
|
rootProject.name = "teamcity-executors-test-task"
|
||||||
|
|
||||||
|
@@ -1,80 +0,0 @@
|
|||||||
import aws.sdk.kotlin.services.s3.S3Client
|
|
||||||
import backup.BackupClient
|
|
||||||
import com.github.ajalt.clikt.core.CliktCommand
|
|
||||||
import com.github.ajalt.clikt.core.subcommands
|
|
||||||
import com.github.ajalt.clikt.parameters.arguments.argument
|
|
||||||
import com.github.ajalt.clikt.parameters.arguments.help
|
|
||||||
import com.github.ajalt.clikt.parameters.types.file
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlin.system.exitProcess
|
|
||||||
|
|
||||||
fun main(args: Array<String>) =
|
|
||||||
runBlocking {
|
|
||||||
S3Client.fromEnvironment().use { s3 ->
|
|
||||||
S3BackupTool()
|
|
||||||
.subcommands(
|
|
||||||
Create(s3),
|
|
||||||
Restore(s3),
|
|
||||||
RestoreFile(s3),
|
|
||||||
)
|
|
||||||
.main(args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class S3BackupTool : CliktCommand(
|
|
||||||
help = "A simple AWS S3 backup tool. This tool assumes credentials are properly configured using aws-cli.",
|
|
||||||
) {
|
|
||||||
override fun run() {
|
|
||||||
shortHelp(currentContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Create(val s3: S3Client) : CliktCommand(
|
|
||||||
help = "Create a backup of a file or directory.",
|
|
||||||
) {
|
|
||||||
val source by argument().file(mustExist = true).help("File or directory to backup")
|
|
||||||
val bucket by argument().help("Name of S3 bucket to backup to")
|
|
||||||
|
|
||||||
override fun run() =
|
|
||||||
runBlocking {
|
|
||||||
val backupKey = BackupClient(s3, bucket).upload(source)
|
|
||||||
echo("Successfully created backup with key '$backupKey'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Restore(val s3: S3Client) : CliktCommand(
|
|
||||||
help = "Restore a backup from AWS S3.",
|
|
||||||
) {
|
|
||||||
val bucket by argument().help("Name of S3 bucket to restore the backup from")
|
|
||||||
val backupKey by argument().help("The S3 key of the backup to restore")
|
|
||||||
val destination by argument().file(mustExist = true).help("Directory to restore to")
|
|
||||||
|
|
||||||
override fun run() =
|
|
||||||
runBlocking {
|
|
||||||
if (!destination.isDirectory) {
|
|
||||||
echo("Destination must be an existing directory", err = true)
|
|
||||||
exitProcess(1)
|
|
||||||
}
|
|
||||||
BackupClient(s3, bucket).restore(destination.toPath(), backupKey)
|
|
||||||
echo("Successfully restored backup '$backupKey' to '$destination'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RestoreFile(val s3: S3Client) : CliktCommand(
|
|
||||||
help = "Restore a single file from a backup from AWS S3.",
|
|
||||||
) {
|
|
||||||
val bucket by argument().help("Name of S3 bucket to restore the backup from")
|
|
||||||
val backupKey by argument().help("The S3 key of the backup to restore")
|
|
||||||
val filePath by argument().help("File path within the backup")
|
|
||||||
val destination by argument().file(mustExist = true).help("Directory to restore to")
|
|
||||||
|
|
||||||
override fun run() =
|
|
||||||
runBlocking {
|
|
||||||
if (!destination.isDirectory) {
|
|
||||||
echo("Destination must be an existing directory", err = true)
|
|
||||||
exitProcess(1)
|
|
||||||
}
|
|
||||||
BackupClient(s3, bucket).restoreFile(destination.toPath(), backupKey, filePath)
|
|
||||||
echo("Successfully restored '$filePath' from backup '$backupKey' to '$destination'")
|
|
||||||
}
|
|
||||||
}
|
|
@@ -24,25 +24,16 @@ import java.util.zip.ZipInputStream
|
|||||||
import java.util.zip.ZipOutputStream
|
import java.util.zip.ZipOutputStream
|
||||||
import kotlin.io.path.createDirectory
|
import kotlin.io.path.createDirectory
|
||||||
|
|
||||||
/**
|
|
||||||
* AWS S3 backup client.
|
|
||||||
*/
|
|
||||||
class BackupClient(
|
class BackupClient(
|
||||||
private val s3: S3Client,
|
private val s3: S3Client,
|
||||||
private val bucketName: String,
|
private val bucketName: String,
|
||||||
private val bufSize: Int = 1024 * 1024 * 32,
|
private val bufSize: Int = 1024 * 1024 * 100
|
||||||
) {
|
) {
|
||||||
/**
|
suspend fun upload(file: File) = coroutineScope {
|
||||||
* Upload a file/directory backup to AWS S3.
|
val backupKey = "${file.name}/${Instant.now()}.zip"
|
||||||
* @param file The File object for the file or directory.
|
|
||||||
*/
|
|
||||||
suspend fun upload(file: File) =
|
|
||||||
coroutineScope {
|
|
||||||
val backupKey = "${file.canonicalFile.name}/${Instant.now()}.zip"
|
|
||||||
PipedInputStream().use { inputStream ->
|
PipedInputStream().use { inputStream ->
|
||||||
val outputStream = PipedOutputStream(inputStream)
|
val outputStream = PipedOutputStream(inputStream)
|
||||||
val zipper =
|
val zipper = launch(Dispatchers.IO) {
|
||||||
launch(Dispatchers.IO) {
|
|
||||||
file.compressToZip(outputStream)
|
file.compressToZip(outputStream)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +43,7 @@ class BackupClient(
|
|||||||
// Large upload, use multipart
|
// Large upload, use multipart
|
||||||
// TODO: multipart uploads can be asynchronous, which would improve
|
// TODO: multipart uploads can be asynchronous, which would improve
|
||||||
// performance a little bit for big uploads.
|
// performance a little bit for big uploads.
|
||||||
val upload =
|
val upload = s3.createMultipartUpload {
|
||||||
s3.createMultipartUpload {
|
|
||||||
bucket = bucketName
|
bucket = bucketName
|
||||||
key = backupKey
|
key = backupKey
|
||||||
}
|
}
|
||||||
@@ -62,14 +52,13 @@ class BackupClient(
|
|||||||
var number = 1
|
var number = 1
|
||||||
var bytesRead = initialRead
|
var bytesRead = initialRead
|
||||||
while (bytesRead > 0) {
|
while (bytesRead > 0) {
|
||||||
val part =
|
val part = s3.uploadPart {
|
||||||
s3.uploadPart {
|
|
||||||
bucket = bucketName
|
bucket = bucketName
|
||||||
key = backupKey
|
key = backupKey
|
||||||
partNumber = number
|
partNumber = number
|
||||||
uploadId = upload.uploadId
|
uploadId = upload.uploadId
|
||||||
body = ByteStream.fromBytes(data.take(bytesRead))
|
body = ByteStream.fromBytes(data.take(bytesRead))
|
||||||
}.toCompletedPart(number)
|
}.asCompletedPart(number)
|
||||||
uploadParts.add(part)
|
uploadParts.add(part)
|
||||||
number++
|
number++
|
||||||
bytesRead = inputStream.readNBytes(data, 0, bufSize)
|
bytesRead = inputStream.readNBytes(data, 0, bufSize)
|
||||||
@@ -78,8 +67,7 @@ class BackupClient(
|
|||||||
bucket = bucketName
|
bucket = bucketName
|
||||||
key = backupKey
|
key = backupKey
|
||||||
uploadId = upload.uploadId
|
uploadId = upload.uploadId
|
||||||
multipartUpload =
|
multipartUpload = CompletedMultipartUpload {
|
||||||
CompletedMultipartUpload {
|
|
||||||
parts = uploadParts
|
parts = uploadParts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,44 +92,24 @@ class BackupClient(
|
|||||||
backupKey
|
backupKey
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
suspend fun restore(destination: Path, backupKey: String) = coroutineScope {
|
||||||
* Restore a backup from AWS S3.
|
val req = GetObjectRequest {
|
||||||
* @param destination The destination directory path for the backup contents.
|
|
||||||
* @param backupKey The S3 key of the backup.
|
|
||||||
*/
|
|
||||||
suspend fun restore(
|
|
||||||
destination: Path,
|
|
||||||
backupKey: String,
|
|
||||||
) = coroutineScope {
|
|
||||||
val req =
|
|
||||||
GetObjectRequest {
|
|
||||||
bucket = bucketName
|
bucket = bucketName
|
||||||
key = backupKey
|
key = backupKey
|
||||||
}
|
}
|
||||||
s3.getObject(req) { resp ->
|
s3.getObject(req) { resp ->
|
||||||
ZipInputStream(
|
ZipInputStream(
|
||||||
resp.body?.toInputStream()
|
resp.body?.toInputStream()
|
||||||
?: throw IOException("S3 response is missing body"),
|
?: throw IOException("S3 response is missing body")
|
||||||
).use { zipStream ->
|
).use { zipStream ->
|
||||||
zipStream.decompress { destination.resolve(it) }
|
zipStream.decompress { destination.resolve(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
suspend fun restoreFile(destination: Path, backupKey: String, fileName: String) = coroutineScope {
|
||||||
* Restore a single file from a backup from AWS S3.
|
|
||||||
* @param destination The destination directory path for the file from the backup.
|
|
||||||
* @param backupKey The S3 key of the backup.
|
|
||||||
* @param fileName The full name of the file to restore (including directories if it was under a subdirectory).
|
|
||||||
*/
|
|
||||||
suspend fun restoreFile(
|
|
||||||
destination: Path,
|
|
||||||
backupKey: String,
|
|
||||||
fileName: String,
|
|
||||||
) = coroutineScope {
|
|
||||||
// For byte ranges refer to https://pkware.cachefly.net/webdocs/APPNOTE/APPNOTE-6.3.9.TXT
|
// For byte ranges refer to https://pkware.cachefly.net/webdocs/APPNOTE/APPNOTE-6.3.9.TXT
|
||||||
val eocdReq =
|
val eocdReq = GetObjectRequest {
|
||||||
GetObjectRequest {
|
|
||||||
bucket = bucketName
|
bucket = bucketName
|
||||||
key = backupKey
|
key = backupKey
|
||||||
// Assumption: EOCD has an empty comment
|
// Assumption: EOCD has an empty comment
|
||||||
@@ -149,17 +117,14 @@ class BackupClient(
|
|||||||
// in which case this function would error anyway, so it should be fine to have this edge-case.
|
// in which case this function would error anyway, so it should be fine to have this edge-case.
|
||||||
range = "bytes=-${EndOfCentralDirectoryRecord.SIZE + EndOfCentralDirectoryLocator.SIZE}"
|
range = "bytes=-${EndOfCentralDirectoryRecord.SIZE + EndOfCentralDirectoryLocator.SIZE}"
|
||||||
}
|
}
|
||||||
val eocdBytes =
|
val eocdBytes = s3.getObject(eocdReq) { resp ->
|
||||||
s3.getObject(eocdReq) { resp ->
|
|
||||||
val bytes = resp.body?.toByteArray() ?: throw IOException("S3 response is missing body")
|
val bytes = resp.body?.toByteArray() ?: throw IOException("S3 response is missing body")
|
||||||
bytes
|
bytes
|
||||||
}
|
}
|
||||||
val eocd = EndOfCentralDirectoryRecord.fromByteArray(eocdBytes, EndOfCentralDirectoryLocator.SIZE)
|
val eocd = EndOfCentralDirectoryRecord.fromByteArray(eocdBytes, EndOfCentralDirectoryLocator.SIZE)
|
||||||
val eocd64 =
|
val eocd64 = if (eocd.eocd64Required()) {
|
||||||
if (eocd.eocd64Required()) {
|
|
||||||
val locator = EndOfCentralDirectoryLocator.fromByteArray(eocdBytes, 0)
|
val locator = EndOfCentralDirectoryLocator.fromByteArray(eocdBytes, 0)
|
||||||
val eocd64Req =
|
val eocd64Req = GetObjectRequest {
|
||||||
GetObjectRequest {
|
|
||||||
bucket = bucketName
|
bucket = bucketName
|
||||||
key = backupKey
|
key = backupKey
|
||||||
range = "bytes=${locator.endOfCentralDirectory64Offset}-"
|
range = "bytes=${locator.endOfCentralDirectory64Offset}-"
|
||||||
@@ -168,25 +133,18 @@ class BackupClient(
|
|||||||
val bytes = resp.body?.toByteArray() ?: throw IOException("S3 response is missing body")
|
val bytes = resp.body?.toByteArray() ?: throw IOException("S3 response is missing body")
|
||||||
EndOfCentralDirectoryRecord64.fromByteArray(bytes, 0)
|
EndOfCentralDirectoryRecord64.fromByteArray(bytes, 0)
|
||||||
}
|
}
|
||||||
} else {
|
} else null
|
||||||
null
|
val cenOffset = if (eocd.centralDirectoryOffset == 0xffffffffU && eocd64 != null) {
|
||||||
}
|
|
||||||
val cenOffset =
|
|
||||||
if (eocd.centralDirectoryOffset == 0xffffffffU && eocd64 != null) {
|
|
||||||
eocd64.centralDirectoryOffset
|
eocd64.centralDirectoryOffset
|
||||||
} else {
|
} else eocd.centralDirectoryOffset.toULong()
|
||||||
eocd.centralDirectoryOffset.toULong()
|
val censReq = GetObjectRequest {
|
||||||
}
|
|
||||||
val censReq =
|
|
||||||
GetObjectRequest {
|
|
||||||
bucket = bucketName
|
bucket = bucketName
|
||||||
key = backupKey
|
key = backupKey
|
||||||
// We only know where to fetch until if we've also fetched EOCD64 (which isn't always the case).
|
// We only know where to fetch until if we've also fetched EOCD64 (which isn't always the case).
|
||||||
// So just over-fetch a little bit, these headers aren't that big anyway.
|
// So just over-fetch a little bit, these headers aren't that big anyway.
|
||||||
range = "bytes=$cenOffset-"
|
range = "bytes=${cenOffset}-"
|
||||||
}
|
}
|
||||||
val cen =
|
val cen = s3.getObject(censReq) { resp ->
|
||||||
s3.getObject(censReq) { resp ->
|
|
||||||
val bytes = resp.body?.toByteArray() ?: throw IOException("S3 response is missing body")
|
val bytes = resp.body?.toByteArray() ?: throw IOException("S3 response is missing body")
|
||||||
var p = 0
|
var p = 0
|
||||||
while (p < bytes.size) {
|
while (p < bytes.size) {
|
||||||
@@ -194,26 +152,23 @@ class BackupClient(
|
|||||||
val cen = CentralDirectoryFileHeader.fromByteArray(bytes, p)
|
val cen = CentralDirectoryFileHeader.fromByteArray(bytes, p)
|
||||||
p += cen.size
|
p += cen.size
|
||||||
if (cen.fileName == fileName) return@getObject cen
|
if (cen.fileName == fileName) return@getObject cen
|
||||||
} catch (_: InvalidDataException) {
|
} catch (_: InvalidSignatureException) {
|
||||||
return@getObject null
|
return@getObject null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
null
|
null
|
||||||
} ?: throw FileNotFoundException("File '$fileName' not found in backup")
|
} ?: throw FileNotFoundException("File '${fileName}' not found in backup")
|
||||||
|
|
||||||
val localHeaderOffset =
|
val localHeaderOffset = cen.extraFieldRecords.firstNotNullOfOrNull {
|
||||||
cen.extraFieldRecords.firstNotNullOfOrNull {
|
|
||||||
if (it is Zip64ExtraFieldRecord && it.localHeaderOffset != null) it else null
|
if (it is Zip64ExtraFieldRecord && it.localHeaderOffset != null) it else null
|
||||||
}?.localHeaderOffset ?: cen.localHeaderOffset.toULong()
|
}?.localHeaderOffset ?: cen.localHeaderOffset.toULong()
|
||||||
val compressedSize =
|
val compressedSize = cen.extraFieldRecords.firstNotNullOfOrNull {
|
||||||
cen.extraFieldRecords.firstNotNullOfOrNull {
|
|
||||||
if (it is Zip64ExtraFieldRecord && it.compressedSize != null) it else null
|
if (it is Zip64ExtraFieldRecord && it.compressedSize != null) it else null
|
||||||
}?.compressedSize ?: cen.compressedSize.toULong()
|
}?.compressedSize ?: cen.compressedSize.toULong()
|
||||||
val req =
|
val req = GetObjectRequest {
|
||||||
GetObjectRequest {
|
|
||||||
bucket = bucketName
|
bucket = bucketName
|
||||||
key = backupKey
|
key = backupKey
|
||||||
range = "bytes=$localHeaderOffset-${
|
range = "bytes=${localHeaderOffset}-${
|
||||||
// Add CEN min size (46 bytes) so that the next CEN / LOC header is seen by the ZipInputStream
|
// Add CEN min size (46 bytes) so that the next CEN / LOC header is seen by the ZipInputStream
|
||||||
// and so it can see the current entry has stopped.
|
// and so it can see the current entry has stopped.
|
||||||
// Note: yes ZipInputStream should know the exact content length from the LOC, but it was still sending
|
// Note: yes ZipInputStream should know the exact content length from the LOC, but it was still sending
|
||||||
@@ -224,20 +179,15 @@ class BackupClient(
|
|||||||
s3.getObject(req) { resp ->
|
s3.getObject(req) { resp ->
|
||||||
ZipInputStream(
|
ZipInputStream(
|
||||||
resp.body?.toInputStream()
|
resp.body?.toInputStream()
|
||||||
?: throw IOException("S3 response is missing body"),
|
?: throw IOException("S3 response is missing body")
|
||||||
).use { zipStream ->
|
).use { zipStream ->
|
||||||
zipStream.decompress(limit = 1) { name -> destination.resolve(name.takeLastWhile { it != '/' }) }
|
zipStream.decompress { name -> destination.resolve(name.takeLastWhile { it != '/' }) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun UploadPartResponse.asCompletedPart(number: Int): CompletedPart {
|
||||||
* Convert an UploadPartResponse to a CompletedPart.
|
|
||||||
* @param number The part number that was used for this part upload.
|
|
||||||
* @return The CompletedPart object.
|
|
||||||
*/
|
|
||||||
private fun UploadPartResponse.toCompletedPart(number: Int): CompletedPart {
|
|
||||||
val part = this
|
val part = this
|
||||||
return CompletedPart {
|
return CompletedPart {
|
||||||
partNumber = number
|
partNumber = number
|
||||||
@@ -249,29 +199,16 @@ private fun UploadPartResponse.toCompletedPart(number: Int): CompletedPart {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Take first `n` items from the beginning of a ByteArray.
|
|
||||||
* @param n The number of items to take.
|
|
||||||
* @return A ByteArray of the first `n` items.
|
|
||||||
*/
|
|
||||||
private fun ByteArray.take(n: Int) =
|
private fun ByteArray.take(n: Int) =
|
||||||
if (n == size) {
|
if (n == size) this // No copy
|
||||||
this // No copy
|
else asList().subList(0, n).toByteArray() // TODO: One copy (toByteArray()), not sure how to do 0 copies here
|
||||||
} else {
|
|
||||||
asList().subList(0, n).toByteArray() // TODO: One copy (toByteArray()), not sure how to do 0 copies here
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
private fun File.compressToZip(outputStream: OutputStream) = ZipOutputStream(outputStream).use { zipStream ->
|
||||||
* Compress a file or directory as a ZIP file to an `OutputStream`.
|
val parentDir = this.absoluteFile.parent + "/"
|
||||||
* @param outputStream The `OutputStream` to write the ZIP file contents to.
|
|
||||||
*/
|
|
||||||
private fun File.compressToZip(outputStream: OutputStream) =
|
|
||||||
ZipOutputStream(outputStream).use { zipStream ->
|
|
||||||
val parentDir = this.canonicalFile.parent + "/"
|
|
||||||
val fileQueue = ArrayDeque<File>()
|
val fileQueue = ArrayDeque<File>()
|
||||||
fileQueue.add(this)
|
fileQueue.add(this)
|
||||||
fileQueue.forEach { subFile ->
|
fileQueue.forEach { subFile ->
|
||||||
val path = subFile.canonicalPath.removePrefix(parentDir)
|
val path = subFile.absolutePath.removePrefix(parentDir)
|
||||||
val subFiles = subFile.listFiles()
|
val subFiles = subFile.listFiles()
|
||||||
if (subFiles != null) { // Is a directory
|
if (subFiles != null) { // Is a directory
|
||||||
val entry = ZipEntry("$path/")
|
val entry = ZipEntry("$path/")
|
||||||
@@ -289,18 +226,11 @@ private fun File.compressToZip(outputStream: OutputStream) =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Decompress `ZipInputStream` contents to specified destination paths.
|
|
||||||
* @param bufSize The buffer size to use for writing the decompressed files.
|
|
||||||
* @param entryNameToPath A function to convert ZIP entry names to destination `Path`s.
|
|
||||||
*/
|
|
||||||
private fun ZipInputStream.decompress(
|
private fun ZipInputStream.decompress(
|
||||||
bufSize: Int = 1024 * 1024,
|
bufSize: Int = 1024 * 1024,
|
||||||
limit: Int? = null,
|
entryNameToPath: (String) -> Path
|
||||||
entryNameToPath: (String) -> Path,
|
|
||||||
) {
|
) {
|
||||||
var entry = this.nextEntry
|
var entry = this.nextEntry
|
||||||
var count = 1
|
|
||||||
while (entry != null) {
|
while (entry != null) {
|
||||||
val path = entryNameToPath(entry.name)
|
val path = entryNameToPath(entry.name)
|
||||||
if (entry.isDirectory) {
|
if (entry.isDirectory) {
|
||||||
@@ -316,22 +246,11 @@ private fun ZipInputStream.decompress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
applyZipAttributes(entry, path)
|
applyZipAttributes(entry, path)
|
||||||
// This is here, not in while loop, since we do not want to read more from the input stream.
|
|
||||||
// But this.nextEntry will read from the input stream.
|
|
||||||
if (limit != null && count++ >= limit) return
|
|
||||||
entry = this.nextEntry
|
entry = this.nextEntry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun setZipAttributes(entry: ZipEntry, path: Path) {
|
||||||
* Set a `ZipEntry`'s attributes given a file's path.
|
|
||||||
* @param entry The `ZipEntry` to set attributes of.
|
|
||||||
* @param path The `Path` of the file to get the attributes from.
|
|
||||||
*/
|
|
||||||
private fun setZipAttributes(
|
|
||||||
entry: ZipEntry,
|
|
||||||
path: Path,
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
val attrs = Files.getFileAttributeView(path, BasicFileAttributeView::class.java).readAttributes()
|
val attrs = Files.getFileAttributeView(path, BasicFileAttributeView::class.java).readAttributes()
|
||||||
entry.setCreationTime(attrs.creationTime())
|
entry.setCreationTime(attrs.creationTime())
|
||||||
@@ -341,15 +260,7 @@ private fun setZipAttributes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun applyZipAttributes(entry: ZipEntry, path: Path) {
|
||||||
* Set a file's attributes given a `ZipEntry`.
|
|
||||||
* @param entry The `ZipEntry` to get the attributes from.
|
|
||||||
* @param path The `Path` of the file to set the attributes of.
|
|
||||||
*/
|
|
||||||
private fun applyZipAttributes(
|
|
||||||
entry: ZipEntry,
|
|
||||||
path: Path,
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
val attrs = Files.getFileAttributeView(path, BasicFileAttributeView::class.java)
|
val attrs = Files.getFileAttributeView(path, BasicFileAttributeView::class.java)
|
||||||
attrs.setTimes(entry.lastModifiedTime, entry.lastAccessTime, entry.creationTime)
|
attrs.setTimes(entry.lastModifiedTime, entry.lastAccessTime, entry.creationTime)
|
||||||
|
14
src/main/kotlin/backup/main.kt
Normal file
14
src/main/kotlin/backup/main.kt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import aws.sdk.kotlin.services.s3.S3Client
|
||||||
|
import aws.sdk.kotlin.services.s3.model.ListBucketsRequest
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.io.path.Path
|
||||||
|
|
||||||
|
fun main() = runBlocking {
|
||||||
|
S3Client.fromEnvironment().use { s3 ->
|
||||||
|
val backupClient = BackupClient(s3, "teamcity-executors-test-task", 1024 * 1024 * 10)
|
||||||
|
}
|
||||||
|
}
|
@@ -3,9 +3,6 @@ package ziputils
|
|||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a partial ZIP central directory file header.
|
|
||||||
*/
|
|
||||||
internal class CentralDirectoryFileHeader(
|
internal class CentralDirectoryFileHeader(
|
||||||
val compressedSize: UInt,
|
val compressedSize: UInt,
|
||||||
val uncompressedSize: UInt,
|
val uncompressedSize: UInt,
|
||||||
@@ -15,7 +12,7 @@ internal class CentralDirectoryFileHeader(
|
|||||||
val disk: UShort,
|
val disk: UShort,
|
||||||
val localHeaderOffset: UInt,
|
val localHeaderOffset: UInt,
|
||||||
val fileName: String,
|
val fileName: String,
|
||||||
val extraFieldRecords: List<ExtraFieldRecord>,
|
val extraFieldRecords: List<ExtraFieldRecord>
|
||||||
) {
|
) {
|
||||||
val size: Int
|
val size: Int
|
||||||
get() = SIZE + nameLength.toInt() + extraFieldLength.toInt() + commentLength.toInt()
|
get() = SIZE + nameLength.toInt() + extraFieldLength.toInt() + commentLength.toInt()
|
||||||
@@ -25,92 +22,69 @@ internal class CentralDirectoryFileHeader(
|
|||||||
const val SIZE = 46
|
const val SIZE = 46
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create `CentralDirectoryFileHeader` from raw byte data.
|
* Create CentralDirectoryFileHeader from raw byte data.
|
||||||
* @throws InvalidDataException provided `ByteArray` is not a supported CEN.
|
* @throws InvalidDataException provided ByteArray is not a supported CEN.
|
||||||
* @param data Raw byte data.
|
|
||||||
* @param offset Skip first <offset> bytes in data array.
|
|
||||||
* @return A `CentralDirectoryFileHeader`.
|
|
||||||
*/
|
*/
|
||||||
@Throws(InvalidDataException::class)
|
@Throws(InvalidDataException::class)
|
||||||
fun fromByteArray(
|
fun fromByteArray(data: ByteArray, offset: Int): CentralDirectoryFileHeader {
|
||||||
data: ByteArray,
|
|
||||||
offset: Int,
|
|
||||||
): CentralDirectoryFileHeader {
|
|
||||||
if (data.size - offset < SIZE) {
|
if (data.size - offset < SIZE) {
|
||||||
throw InvalidDataException("CEN must be at least 46 bytes")
|
throw InvalidDataException("CEN must be at least 46 bytes")
|
||||||
}
|
}
|
||||||
val buf = ByteBuffer.wrap(data, offset, 46).order(ByteOrder.LITTLE_ENDIAN)
|
val buf = ByteBuffer.wrap(data, offset, 46).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
if (buf.getInt().toUInt() != SIGNATURE) {
|
if (buf.getInt().toUInt() != SIGNATURE) {
|
||||||
throw InvalidDataException("Invalid signature")
|
throw InvalidSignatureException("Invalid signature")
|
||||||
}
|
}
|
||||||
|
|
||||||
val extraFieldRecords = mutableListOf<ExtraFieldRecord>()
|
val extraFieldRecords = mutableListOf<ExtraFieldRecord>()
|
||||||
val nameLength = buf.getShort(offset + 28).toUShort()
|
val nameLength = buf.getShort(offset + 28).toUShort()
|
||||||
buf.position(offset + 20)
|
buf.position(offset + 20)
|
||||||
val cen =
|
val cen = CentralDirectoryFileHeader(
|
||||||
CentralDirectoryFileHeader(
|
|
||||||
compressedSize = buf.getInt().toUInt(),
|
compressedSize = buf.getInt().toUInt(),
|
||||||
uncompressedSize = buf.getInt().toUInt(),
|
uncompressedSize = buf.getInt().toUInt(),
|
||||||
nameLength =
|
nameLength = nameLength
|
||||||
nameLength
|
|
||||||
.also { buf.position(offset + 30) },
|
.also { buf.position(offset + 30) },
|
||||||
extraFieldLength = buf.getShort().toUShort(),
|
extraFieldLength = buf.getShort().toUShort(),
|
||||||
commentLength = buf.getShort().toUShort(),
|
commentLength = buf.getShort().toUShort(),
|
||||||
disk =
|
disk = buf.getShort().toUShort()
|
||||||
buf.getShort().toUShort()
|
|
||||||
.also { buf.position(offset + 42) },
|
.also { buf.position(offset + 42) },
|
||||||
localHeaderOffset = buf.getInt().toUInt(),
|
localHeaderOffset = buf.getInt().toUInt(),
|
||||||
fileName = String(data.sliceArray(offset + SIZE..<offset + SIZE + nameLength.toInt())),
|
fileName = String(data.sliceArray(offset + SIZE..<offset + SIZE + nameLength.toInt())),
|
||||||
extraFieldRecords = extraFieldRecords,
|
extraFieldRecords = extraFieldRecords
|
||||||
)
|
)
|
||||||
if (data.size - offset < cen.size) {
|
if (data.size - offset < cen.size) {
|
||||||
throw InvalidDataException("CEN is too short")
|
throw InvalidDataException("CEN is too short")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse extra field records
|
// Parse extra field records
|
||||||
val extraFieldsBuf =
|
val extraFieldsBuf = ByteBuffer.wrap(
|
||||||
ByteBuffer.wrap(
|
data, offset + SIZE + cen.nameLength.toInt(), cen.extraFieldLength.toInt()
|
||||||
data,
|
|
||||||
offset + SIZE + cen.nameLength.toInt(),
|
|
||||||
cen.extraFieldLength.toInt(),
|
|
||||||
).order(ByteOrder.LITTLE_ENDIAN)
|
).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
while (extraFieldsBuf.remaining() > 0) {
|
while (extraFieldsBuf.remaining() > 0) {
|
||||||
val id = extraFieldsBuf.getShort().toUShort()
|
val id = extraFieldsBuf.getShort().toUShort()
|
||||||
val size = extraFieldsBuf.getShort().toUShort()
|
val size = extraFieldsBuf.getShort().toUShort()
|
||||||
extraFieldRecords.add(
|
extraFieldRecords.add(when (id) {
|
||||||
when (id) {
|
|
||||||
Zip64ExtraFieldRecord.ID -> {
|
Zip64ExtraFieldRecord.ID -> {
|
||||||
Zip64ExtraFieldRecord(
|
Zip64ExtraFieldRecord(
|
||||||
size,
|
size,
|
||||||
if (cen.uncompressedSize == 0xffffffffU) {
|
if (cen.uncompressedSize == 0xffffffffU) {
|
||||||
extraFieldsBuf.getLong().toULong()
|
extraFieldsBuf.getLong().toULong()
|
||||||
} else {
|
} else null,
|
||||||
null
|
|
||||||
},
|
|
||||||
if (cen.compressedSize == 0xffffffffU) {
|
if (cen.compressedSize == 0xffffffffU) {
|
||||||
extraFieldsBuf.getLong().toULong()
|
extraFieldsBuf.getLong().toULong()
|
||||||
} else {
|
} else null,
|
||||||
null
|
|
||||||
},
|
|
||||||
if (cen.localHeaderOffset == 0xffffffffU) {
|
if (cen.localHeaderOffset == 0xffffffffU) {
|
||||||
extraFieldsBuf.getLong().toULong()
|
extraFieldsBuf.getLong().toULong()
|
||||||
} else {
|
} else null,
|
||||||
null
|
|
||||||
},
|
|
||||||
if (cen.disk == 0xffffU.toUShort()) {
|
if (cen.disk == 0xffffU.toUShort()) {
|
||||||
extraFieldsBuf.getInt().toUInt()
|
extraFieldsBuf.getInt().toUInt()
|
||||||
} else {
|
} else null
|
||||||
null
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
extraFieldsBuf.position(extraFieldsBuf.position() + size.toInt())
|
extraFieldsBuf.position(extraFieldsBuf.position() + size.toInt())
|
||||||
ExtraFieldRecord(id, size)
|
ExtraFieldRecord(id, size)
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return cen
|
return cen
|
||||||
|
@@ -3,34 +3,20 @@ package ziputils
|
|||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a partial ZIP64 end of central directory locator.
|
|
||||||
*/
|
|
||||||
internal class EndOfCentralDirectoryLocator(
|
internal class EndOfCentralDirectoryLocator(
|
||||||
val endOfCentralDirectory64Offset: ULong,
|
val endOfCentralDirectory64Offset: ULong
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val SIGNATURE = 0x07064b50U
|
const val SIGNATURE = 0x07064b50U
|
||||||
const val SIZE = 20
|
const val SIZE = 20
|
||||||
|
|
||||||
/**
|
|
||||||
* Create `EndOfCentralDirectoryLocator` from raw byte data.
|
|
||||||
* @throws InvalidDataException Provided `ByteArray` is not a supported EOCD locator.
|
|
||||||
* @param data Raw byte data.
|
|
||||||
* @param offset Skip first <offset> bytes in data array.
|
|
||||||
* @return A `EndOfCentralDirectoryLocator`.
|
|
||||||
*/
|
|
||||||
@Throws(InvalidDataException::class)
|
@Throws(InvalidDataException::class)
|
||||||
fun fromByteArray(
|
fun fromByteArray(data: ByteArray, offset: Int): EndOfCentralDirectoryLocator {
|
||||||
data: ByteArray,
|
|
||||||
offset: Int,
|
|
||||||
): EndOfCentralDirectoryLocator {
|
|
||||||
if (data.size - offset < SIZE) {
|
if (data.size - offset < SIZE) {
|
||||||
throw InvalidDataException("EOCD64 locator must be at least 20 bytes")
|
throw InvalidDataException("EOCD64 locator must be at least 20 bytes")
|
||||||
}
|
}
|
||||||
val buf = ByteBuffer.wrap(data, offset, SIZE).order(ByteOrder.LITTLE_ENDIAN)
|
val buf = ByteBuffer.wrap(data, offset, SIZE).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
if (buf.getInt().toUInt() != SIGNATURE) {
|
if (buf.getInt().toUInt() != SIGNATURE) {
|
||||||
throw InvalidDataException("Invalid signature")
|
throw InvalidSignatureException("Invalid signature")
|
||||||
}
|
}
|
||||||
buf.position(offset + 8)
|
buf.position(offset + 8)
|
||||||
return EndOfCentralDirectoryLocator(buf.getLong().toULong())
|
return EndOfCentralDirectoryLocator(buf.getLong().toULong())
|
||||||
|
@@ -4,39 +4,34 @@ import java.nio.ByteBuffer
|
|||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a partial ZIP end of central directory record.
|
* Partial End of Central Directory record class.
|
||||||
|
* Only supports data required by the backup tool.
|
||||||
*/
|
*/
|
||||||
internal class EndOfCentralDirectoryRecord(
|
internal class EndOfCentralDirectoryRecord(
|
||||||
val centralDirectoryOffset: UInt,
|
val centralDirectoryOffset: UInt
|
||||||
) {
|
) {
|
||||||
fun eocd64Required(): Boolean = centralDirectoryOffset == 0xffffffffU
|
fun eocd64Required(): Boolean =
|
||||||
|
centralDirectoryOffset == 0xffffffffU
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val SIGNATURE = 0x06054b50U
|
const val SIGNATURE = 0x06054b50U
|
||||||
const val SIZE = 22
|
const val SIZE = 22
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create `EndOfCentralDirectoryRecord` from raw byte data.
|
* Create EndOfCentralDirectoryRecord from raw byte data.
|
||||||
* @throws InvalidDataException Provided `ByteArray` is not a supported EOCD64.
|
* @throws InvalidDataException provided ByteArray is not a supported EOCD64.
|
||||||
* @param data Raw byte data.
|
|
||||||
* @param offset Skip first <offset> bytes in data array.
|
|
||||||
* @return A `EndOfCentralDirectoryRecord`.
|
|
||||||
*/
|
*/
|
||||||
@Throws(InvalidDataException::class)
|
@Throws(InvalidDataException::class)
|
||||||
fun fromByteArray(
|
fun fromByteArray(data: ByteArray, offset: Int): EndOfCentralDirectoryRecord {
|
||||||
data: ByteArray,
|
|
||||||
offset: Int,
|
|
||||||
): EndOfCentralDirectoryRecord {
|
|
||||||
if (data.size - offset < SIZE) {
|
if (data.size - offset < SIZE) {
|
||||||
throw InvalidDataException("EOCD must be at least 22 bytes")
|
throw InvalidDataException("EOCD must be at least 22 bytes")
|
||||||
}
|
}
|
||||||
val buf = ByteBuffer.wrap(data, offset, SIZE).order(ByteOrder.LITTLE_ENDIAN)
|
val buf = ByteBuffer.wrap(data, offset, SIZE).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
if (buf.getInt().toUInt() != SIGNATURE) {
|
if (buf.getInt().toUInt() != SIGNATURE) {
|
||||||
throw InvalidDataException("Invalid signature")
|
throw InvalidSignatureException("Invalid signature")
|
||||||
}
|
}
|
||||||
buf.position(offset + 16)
|
buf.position(offset + 16)
|
||||||
return EndOfCentralDirectoryRecord(
|
return EndOfCentralDirectoryRecord(
|
||||||
centralDirectoryOffset = buf.getInt().toUInt(),
|
centralDirectoryOffset = buf.getInt().toUInt()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,37 +4,31 @@ import java.nio.ByteBuffer
|
|||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a partial ZIP64 end of central directory record.
|
* Partial End of Central Directory record (ZIP64) class.
|
||||||
|
* Only supports data required by the backup tool.
|
||||||
*/
|
*/
|
||||||
internal class EndOfCentralDirectoryRecord64(
|
internal class EndOfCentralDirectoryRecord64(
|
||||||
val centralDirectoryOffset: ULong,
|
val centralDirectoryOffset: ULong
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val SIGNATURE = 0x06064b50U
|
const val SIGNATURE = 0x06064b50U
|
||||||
const val SIZE = 56
|
const val SIZE = 56
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create `EndOfCentralDirectoryRecord64` from raw byte data.
|
* Create EndOfCentralDirectoryRecord64 from raw byte data.
|
||||||
* @throws InvalidDataException Provided `ByteArray` is not a supported EOCD.
|
* @throws InvalidDataException provided ByteArray is not a supported EOCD.
|
||||||
* @param data Raw byte data.
|
|
||||||
* @param offset Skip first <offset> bytes in data array.
|
|
||||||
* @return A `EndOfCentralDirectoryRecord64`.
|
|
||||||
*/
|
*/
|
||||||
@Throws(InvalidDataException::class)
|
@Throws(InvalidDataException::class)
|
||||||
fun fromByteArray(
|
fun fromByteArray(data: ByteArray, offset: Int): EndOfCentralDirectoryRecord64 {
|
||||||
data: ByteArray,
|
|
||||||
offset: Int,
|
|
||||||
): EndOfCentralDirectoryRecord64 {
|
|
||||||
if (data.size - offset < SIZE) {
|
if (data.size - offset < SIZE) {
|
||||||
throw InvalidDataException("EOCD64 must be at least 56 bytes")
|
throw InvalidDataException("EOCD64 must be at least 56 bytes")
|
||||||
}
|
}
|
||||||
val buf = ByteBuffer.wrap(data, offset, SIZE).order(ByteOrder.LITTLE_ENDIAN)
|
val buf = ByteBuffer.wrap(data, offset, SIZE).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
if (buf.getInt().toUInt() != SIGNATURE) {
|
if (buf.getInt().toUInt() != SIGNATURE) {
|
||||||
throw InvalidDataException("Invalid signature")
|
throw InvalidSignatureException("Invalid signature")
|
||||||
}
|
}
|
||||||
buf.position(offset + 48)
|
buf.position(offset + 48)
|
||||||
return EndOfCentralDirectoryRecord64(
|
return EndOfCentralDirectoryRecord64(
|
||||||
centralDirectoryOffset = buf.getLong().toULong(),
|
centralDirectoryOffset = buf.getLong().toULong()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
4
src/main/kotlin/ziputils/Exceptions.kt
Normal file
4
src/main/kotlin/ziputils/Exceptions.kt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
package ziputils
|
||||||
|
|
||||||
|
class InvalidDataException(message: String): Exception(message)
|
||||||
|
class InvalidSignatureException(message: String): Exception(message)
|
@@ -1,9 +1,6 @@
|
|||||||
package ziputils
|
package ziputils
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a partial ZIP extra field record.
|
|
||||||
*/
|
|
||||||
internal open class ExtraFieldRecord(
|
internal open class ExtraFieldRecord(
|
||||||
val id: UShort,
|
val id: UShort,
|
||||||
val size: UShort,
|
val size: UShort
|
||||||
)
|
)
|
@@ -1,6 +0,0 @@
|
|||||||
package ziputils
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents an invalid raw byte data exception.
|
|
||||||
*/
|
|
||||||
class InvalidDataException(message: String) : Exception(message)
|
|
@@ -1,14 +1,11 @@
|
|||||||
package ziputils
|
package ziputils
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a ZIP ZIP64 extra field record (ID 0x0001).
|
|
||||||
*/
|
|
||||||
internal class Zip64ExtraFieldRecord(
|
internal class Zip64ExtraFieldRecord(
|
||||||
size: UShort,
|
size: UShort,
|
||||||
val uncompressedSize: ULong?,
|
val uncompressedSize: ULong?,
|
||||||
val compressedSize: ULong?,
|
val compressedSize: ULong?,
|
||||||
val localHeaderOffset: ULong?,
|
val localHeaderOffset: ULong?,
|
||||||
val disk: UInt?,
|
val disk: UInt?
|
||||||
): ExtraFieldRecord(ID, size) {
|
): ExtraFieldRecord(ID, size) {
|
||||||
companion object {
|
companion object {
|
||||||
const val ID: UShort = 0x0001U
|
const val ID: UShort = 0x0001U
|
||||||
|
Reference in New Issue
Block a user