Issue #71: refine dtbo unpack/pack

dtbo unpack:
    save image info
    decompile dtb to dts and yaml dts
    print summary
dtbo pack:
    compile dts to dtb
    print summary
boot v0-v4 pack:
    print summary
pull/80/head v12.0
cfig 4 years ago
parent 1e2592c1c4
commit a358bd6a7b
No known key found for this signature in database
GPG Key ID: B104C307F0FDABB7

@ -87,7 +87,7 @@ jobs:
run: python -c "import sys; print(sys.version)"
- name: choco
run: choco install openssl
run: choco install openssl dtc-msys2
- name: Unit Test
run: ./gradlew.bat check && ./gradlew.bat clean

@ -8,14 +8,15 @@ A tool for reverse engineering Android ROM images.
#### install required packages
Mac: `brew install lz4 xz dtc`
Linux: `sudo apt install git device-tree-compiler lz4 xz-utils zlib1g-dev openjdk-11-jdk gcc g++ python3 python-is-python3`
Mac: `brew install lz4 xz dtc`
Windows Subsystem for Linux(WSL): `sudo apt install git device-tree-compiler lz4 xz-utils zlib1g-dev openjdk-11-jdk gcc g++ python`
Windows: Make sure you have `python3`, `JDK9+` and `openssl` properly installed.
An easy way is to install [Anaconda](https://www.anaconda.com/products/individual#windows) and [Oracle JDK 11](https://www.oracle.com/java/technologies/javase-jdk11-downloads.html), then run the program under anaconda PowerShell.
Or install them with chocolate: `choco install openssl dtc-msys2`
#### Parsing and packing

@ -58,6 +58,7 @@ application {
tasks.withType<KotlinCompile>().all {
kotlinOptions {
freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
freeCompilerArgs += "-Xopt-in=kotlin.ExperimentalUnsignedTypes"
jvmTarget = "11"
}
}

@ -147,7 +147,11 @@ class AndroidCpio {
entry.statMode = itemConfig[0].statMode
}
else -> {
throw IllegalArgumentException("${entry.name} has multiple exact-match fsConfig")
//Issue #73: https://github.com/cfig/Android_boot_image_editor/issues/73
//Reason: cpio may have multiple entries with the same name, that's ugly!
//throw IllegalArgumentException("${entry.name} has multiple exact-match fsConfig")
log.warn("${entry.name} has multiple exact-match fsConfig")
entry.statMode = itemConfig[0].statMode
}
}
}

@ -16,12 +16,13 @@ package cfig.bootimg.v2
import avb.AVBInfo
import cfig.Avb
import cfig.utils.EnvironmentVerifier
import cfig.bootimg.Common
import cfig.bootimg.Common.Companion.deleleIfExists
import cfig.bootimg.Signer
import cfig.bootimg.v3.VendorBoot
import cfig.helper.Helper
import cfig.packable.VBMetaParser
import cfig.utils.EnvironmentVerifier
import com.fasterxml.jackson.databind.ObjectMapper
import de.vandermeer.asciitable.AsciiTable
import org.apache.commons.exec.CommandLine
@ -516,4 +517,14 @@ data class BootV2(
}
return this
}
fun printPackSummary(): BootV2 {
VendorBoot.printPackSummary(info.output)
return this
}
fun updateVbmeta(): BootV2 {
Avb.updateVbmeta(info.output)
return this
}
}

@ -290,6 +290,16 @@ data class BootV3(
return this
}
fun printPackSummary(): BootV3 {
VendorBoot.printPackSummary(info.output)
return this
}
fun updateVbmeta(): BootV3 {
Avb.updateVbmeta(info.output)
return this
}
private fun toCommandLine(): CommandLine {
val cmdPrefix = if (EnvironmentVerifier().isWindows) "python " else ""
return CommandLine.parse(cmdPrefix + Helper.prop("mkbootimg")).let { ret ->

@ -208,6 +208,30 @@ data class VendorBoot(
ret.info.imageSize = File(fileName).length()
return ret
}
fun printPackSummary(imageName: String) {
val tableHeader = AsciiTable().apply {
addRule()
addRow("What", "Where")
addRule()
}
val tab = AsciiTable().let {
it.addRule()
it.addRow("re-packed $imageName", "$imageName.signed")
it.addRule()
it
}
if (File("vbmeta.img").exists()) {
if (File("vbmeta.img.signed").exists()) {
tab.addRow("re-packed vbmeta", "vbmeta.img.signed")
} else {
tab.addRow("re-packed vbmeta", "-")
}
tab.addRule()
}
log.info("\n\t\t\tPack Summary of ${imageName}\n{}\n{}", tableHeader.render(), tab.render())
}
}
fun pack(): VendorBoot {
@ -290,6 +314,11 @@ data class VendorBoot(
return this
}
fun updateVbmeta(): VendorBoot {
Avb.updateVbmeta(info.output)
return this
}
private fun toHeader(): VendorBootHeader {
return VendorBootHeader(
headerVersion = info.headerVersion,
@ -352,7 +381,7 @@ data class VendorBoot(
return this
}
fun printSummary(): VendorBoot {
fun printUnpackSummary(): VendorBoot {
val tableHeader = AsciiTable().apply {
addRule()
addRow("What", "Where")
@ -399,6 +428,11 @@ data class VendorBoot(
return this
}
fun printPackSummary(): VendorBoot {
printPackSummary(info.output)
return this
}
private fun toCommandLine(): CommandLine {
val cmdPrefix = if (EnvironmentVerifier().isWindows) "python " else ""
return CommandLine.parse(cmdPrefix + Helper.prop("mkbootimg")).apply {

@ -19,7 +19,6 @@ import cfig.Avb
import cfig.bootimg.Common.Companion.probeHeaderVersion
import cfig.bootimg.v2.BootV2
import cfig.bootimg.v3.BootV3
import cfig.helper.Helper
import com.fasterxml.jackson.databind.ObjectMapper
import de.vandermeer.asciitable.AsciiTable
import org.slf4j.LoggerFactory
@ -29,7 +28,6 @@ import java.io.FileInputStream
class BootImgParser : IPackable {
override val loopNo: Int
get() = 0
private val workDir = Helper.prop("workDir")
override fun capabilities(): List<String> {
return listOf("^boot(-debug)?\\.img$", "^recovery\\.img$", "^recovery-two-step\\.img$")
@ -57,7 +55,7 @@ class BootImgParser : IPackable {
}
override fun pack(fileName: String) {
val cfgFile = workDir + fileName.removeSuffix(".img") + ".json"
val cfgFile = outDir + fileName.removeSuffix(".img") + ".json"
log.info("Loading config from $cfgFile")
if (!File(cfgFile).exists()) {
val tab = AsciiTable().let {
@ -74,24 +72,16 @@ class BootImgParser : IPackable {
ObjectMapper().readValue(File(cfgFile), BootV2::class.java)
.pack()
.sign()
.updateVbmeta()
.printPackSummary()
3, 4 ->
ObjectMapper().readValue(File(cfgFile), BootV3::class.java)
.pack()
.sign(fileName)
.let {
val tab = AsciiTable().let { tab ->
tab.addRule()
val outFileSuffix =
if (File(Avb.getJsonFileName(it.info.output)).exists()) ".signed" else ".clear"
tab.addRow("${it.info.output}${outFileSuffix} is ready")
tab.addRule()
tab
}
log.info("\n{}", tab.render())
}
.updateVbmeta()
.printPackSummary()
else -> throw IllegalArgumentException("do not support header version $hv")
}
Avb.updateVbmeta(fileName)
}
override fun flash(fileName: String, deviceName: String) {

@ -14,14 +14,14 @@
package cfig.packable
import avb.blob.Footer
import cfig.utils.EnvironmentVerifier
import cfig.utils.DTC
import cfig.helper.Helper
import cfig.utils.DTC
import cfig.utils.EnvironmentVerifier
import com.fasterxml.jackson.databind.ObjectMapper
import org.apache.commons.exec.CommandLine
import org.apache.commons.exec.DefaultExecutor
import org.slf4j.LoggerFactory
import utils.Dtbo
import java.io.File
import java.io.FileInputStream
import java.util.*
@ -34,7 +34,6 @@ class DtboParser(val workDir: File) : IPackable {
private val log = LoggerFactory.getLogger(DtboParser::class.java)
private val envv = EnvironmentVerifier()
private val outDir = Helper.prop("workDir")
private val dtboMaker = Helper.prop("dtboMaker")
override fun capabilities(): List<String> {
@ -43,32 +42,39 @@ class DtboParser(val workDir: File) : IPackable {
override fun unpack(fileName: String) {
cleanUp()
val dtbPath = File("$outDir/dtb").path
val headerPath = File("$outDir/dtbo.header").path
val cmdPrefix = if (EnvironmentVerifier().isWindows) "python " else ""
val cmd = CommandLine.parse("$cmdPrefix$dtboMaker dump $fileName").let {
it.addArguments("--dtb $dtbPath")
it.addArguments("--output $headerPath")
}
execInDirectory(cmd, this.workDir)
Dtbo.parse(fileName)
.unpack(outDir)
.extractVBMeta()
.printSummary()
}
val props = Properties().apply {
FileInputStream(File(headerPath)).use { fis ->
load(fis)
}
}
if (envv.hasDtc) {
for (i in 0 until Integer.parseInt(props.getProperty("dt_entry_count"))) {
val inputDtb = "$dtbPath.$i"
val outputSrc = File(outDir + "/" + File(inputDtb).name + ".src").path
DTC().decompile(inputDtb, outputSrc)
override fun pack(fileName: String) {
ObjectMapper().readValue(File(outDir + "dtbo.json"), Dtbo::class.java)
.pack()
.sign()
.updateVbmeta()
.printPackSummary()
}
override fun `@verify`(fileName: String) {
super.`@verify`(fileName)
}
private fun execInDirectory(cmd: CommandLine, inWorkDir: File) {
DefaultExecutor().let {
it.workingDirectory = inWorkDir
try {
log.info(cmd.toString())
it.execute(cmd)
} catch (e: org.apache.commons.exec.ExecuteException) {
log.error("can not exec command")
return
}
} else {
log.error("'dtc' is unavailable, task aborted")
}
}
override fun pack(fileName: String) {
@Deprecated("for debugging purpose only")
fun packLegacy(fileName: String) {
if (!envv.hasDtc) {
log.error("'dtc' is unavailable, task aborted")
return
@ -91,33 +97,31 @@ class DtboParser(val workDir: File) : IPackable {
execInDirectory(cmd, this.workDir)
}
override fun `@verify`(fileName: String) {
super.`@verify`(fileName)
}
@Deprecated("for debugging purpose only")
fun unpackLegacy(fileName: String) {
cleanUp()
val dtbPath = File("$outDir/dtb").path
val headerPath = File("$outDir/dtbo.header").path
val cmdPrefix = if (EnvironmentVerifier().isWindows) "python " else ""
val cmd = CommandLine.parse("$cmdPrefix$dtboMaker dump $fileName").let {
it.addArguments("--dtb $dtbPath")
it.addArguments("--output $headerPath")
}
execInDirectory(cmd, this.workDir)
// invoked solely by reflection
fun `@footer`(fileName: String) {
FileInputStream(fileName).use { fis ->
fis.skip(File(fileName).length() - Footer.SIZE)
try {
val footer = Footer(fis)
log.info("\n" + ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(footer))
} catch (e: IllegalArgumentException) {
log.info("image $fileName has no AVB Footer")
val props = Properties().apply {
FileInputStream(File(headerPath)).use { fis ->
load(fis)
}
}
}
private fun execInDirectory(cmd: CommandLine, inWorkDir: File) {
DefaultExecutor().let {
it.workingDirectory = inWorkDir
try {
log.info(cmd.toString())
it.execute(cmd)
} catch (e: org.apache.commons.exec.ExecuteException) {
log.error("can not exec command")
return
if (envv.hasDtc) {
for (i in 0 until Integer.parseInt(props.getProperty("dt_entry_count"))) {
val inputDtb = "$dtbPath.$i"
val outputSrc = File(outDir + "/" + File(inputDtb).name + ".src").path
DTC().decompile(inputDtb, outputSrc)
}
} else {
log.error("'dtc' is unavailable, task aborted")
}
}
}

@ -25,6 +25,9 @@ import java.io.File
interface IPackable {
val loopNo: Int
val outDir: String
get() = Helper.prop("workDir")
fun capabilities(): List<String> {
return listOf("^dtbo\\.img$")
}

@ -16,7 +16,6 @@ package cfig.packable
import avb.AVBInfo
import cfig.Avb
import cfig.helper.Helper
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory
import java.io.File
@ -33,7 +32,7 @@ class VBMetaParser: IPackable {
}
override fun cleanUp() {
File(Helper.prop("workDir")).mkdirs()
File(outDir).mkdirs()
}
override fun unpack(fileName: String) {

@ -14,8 +14,6 @@
package cfig.packable
import cfig.Avb
import cfig.helper.Helper
import cfig.bootimg.v3.VendorBoot
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory
@ -24,7 +22,6 @@ import java.io.File
class VendorBootParser : IPackable {
override val loopNo: Int = 0
private val log = LoggerFactory.getLogger(VendorBootParser::class.java)
private val workDir = Helper.prop("workDir")
override fun capabilities(): List<String> {
return listOf("^vendor_boot(-debug)?\\.img$")
}
@ -32,20 +29,21 @@ class VendorBootParser : IPackable {
override fun unpack(fileName: String) {
cleanUp()
val vb = VendorBoot
.parse(fileName)
.extractImages()
.extractVBMeta()
.printSummary()
.parse(fileName)
.extractImages()
.extractVBMeta()
.printUnpackSummary()
log.debug(vb.toString())
}
override fun pack(fileName: String) {
val cfgFile = "$workDir/${fileName.removeSuffix(".img")}.json"
val cfgFile = "$outDir/${fileName.removeSuffix(".img")}.json"
log.info("Loading config from $cfgFile")
ObjectMapper().readValue(File(cfgFile), VendorBoot::class.java)
.pack()
.sign()
Avb.updateVbmeta(fileName)
.pack()
.sign()
.updateVbmeta()
.printPackSummary()
}
override fun `@verify`(fileName: String) {

@ -23,17 +23,16 @@ class DTC {
fun decompile(dtbFile: String, outFile: String): Boolean {
log.info("parsing DTB: $dtbFile")
val cmd = CommandLine.parse("dtc -q -I dtb -O dts").let {
it.addArguments("$dtbFile")
it.addArguments("-o $outFile")
}
CommandLine.parse("fdtdump").let {
it.addArguments("$dtbFile")
}
//CommandLine.parse("fdtdump").let {
// it.addArguments("$dtbFile")
//}
//dtb-> dts
DefaultExecutor().let {
try {
val cmd = CommandLine.parse("dtc -q -I dtb -O dts").apply {
addArguments(dtbFile)
addArguments("-o $outFile")
}
it.execute(cmd)
log.info(cmd.toString())
} catch (e: org.apache.commons.exec.ExecuteException) {
@ -41,13 +40,27 @@ class DTC {
return false
}
}
//dts -> yaml
DefaultExecutor().let {
try {
val cmd = CommandLine.parse("dtc -q -I dts -O yaml").apply {
addArguments(outFile)
addArguments("-o $outFile.yaml")
}
it.execute(cmd)
log.info(cmd.toString())
} catch (e: org.apache.commons.exec.ExecuteException) {
log.error("can not transform DTS: $outFile")
return false
}
}
return true
}
fun compile(dtsFile: String, outFile: String): Boolean {
log.info("compiling DTS: $dtsFile")
val cmd = CommandLine.parse("dtc -q -I dts -O dtb").let {
it.addArguments("$dtsFile")
it.addArguments(dtsFile)
it.addArguments("-o $outFile")
}

@ -0,0 +1,258 @@
package utils
import avb.AVBInfo
import cfig.Avb
import cfig.bootimg.Common
import cfig.bootimg.Signer
import cfig.bootimg.v3.VendorBoot
import cfig.helper.Helper
import cfig.io.Struct3
import cfig.packable.VBMetaParser
import cfig.utils.DTC
import com.fasterxml.jackson.databind.ObjectMapper
import de.vandermeer.asciitable.AsciiTable
import org.slf4j.LoggerFactory
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.InputStream
class Dtbo(
var info: DtboInfo = DtboInfo(),
var header: DtboHeader = DtboHeader(),
var dtEntries: MutableList<DeviceTreeTableEntry> = mutableListOf()
) {
class DtboInfo(
var output: String = "",
var json: String = "",
var imageSize: Int = 0
)
// part I: header
data class DtboHeader(
var totalSize: Int = 0,
var headerSize: Int = 0,
var entrySize: Int = 0,
var entryCount: Int = 0,
var entryOffset: Int = 0,
var pageSize: Int = 0,
var version: Int = 0
) {
companion object {
const val magic = 0xd7b7ab1e
private const val FORMAT_STRING = ">I7i"
internal const val SIZE = 32
init {
assert(Struct3(FORMAT_STRING).calcSize() == SIZE)
}
}
constructor(iS: InputStream?) : this() {
if (iS == null) {
return
}
val info = Struct3(FORMAT_STRING).unpack(iS)
assert(8 == info.size)
if ((info[0] as UInt).toLong() != magic) {
throw IllegalArgumentException("stream doesn't look like DTBO header")
}
totalSize = info[1] as Int
headerSize = info[2] as Int
if (headerSize != DtboHeader.SIZE) {
log.warn("headerSize $headerSize != ${DtboHeader.SIZE}")
}
entrySize = info[3] as Int
if (entrySize != DeviceTreeTableEntry.SIZE) {
log.warn("entrySize $entrySize != ${DeviceTreeTableEntry.SIZE}")
}
entryCount = info[4] as Int
entryOffset = info[5] as Int
pageSize = info[6] as Int
version = info[7] as Int
}
fun encode(): ByteArray {
return Struct3(FORMAT_STRING).pack(
magic,
totalSize,
headerSize,
entrySize,
entryCount,
entryOffset,
pageSize,
version
)
}
}
// part II: dt entry table
data class DeviceTreeTableEntry(
var sequenceNo: Int = 0,
var entrySize: Int = 0,
var entryOffset: Int = 0,
var id: Int = 0,
var rev: Int = 0,
var flags: Int = 0,
var reserved1: Int = 0,
var reserved2: Int = 0,
var reserved3: Int = 0,
) {
companion object {
private const val FORMAT_STRING = ">8i"
internal const val SIZE = 32
init {
assert(Struct3(FORMAT_STRING).calcSize() == SIZE)
}
}
constructor(iS: InputStream) : this() {
val info = Struct3(FORMAT_STRING).unpack(iS)
assert(8 == info.size)
entrySize = info[0] as Int
entryOffset = info[1] as Int
id = info[2] as Int
rev = info[3] as Int
flags = info[4] as Int
reserved1 = info[5] as Int
reserved2 = info[6] as Int
reserved3 = info[7] as Int
}
fun encode(): ByteArray {
return Struct3(FORMAT_STRING).pack(
entrySize,
entryOffset,
id,
rev,
flags,
reserved1,
reserved2,
reserved3
)
}
}
companion object {
fun parse(fileName: String): Dtbo {
val ret = Dtbo()
ret.info.output = fileName
ret.info.imageSize = File(fileName).length().toInt()
ret.info.json = fileName.removeSuffix(".img") + ".json"
FileInputStream(fileName).use { fis ->
ret.header = DtboHeader(fis)
for (i in 0 until ret.header.entryCount) {
ret.dtEntries.add(DeviceTreeTableEntry(fis).apply { sequenceNo = i })
}
}
return ret
}
private val log = LoggerFactory.getLogger(Dtbo::class.java)
private val outDir = Helper.prop("workDir")
}
fun extractVBMeta(): Dtbo {
try {
AVBInfo.parseFrom(info.output).dumpDefault(info.output)
} catch (e: Exception) {
log.error("extraceVBMeta(): $e")
}
if (File("vbmeta.img").exists()) {
log.warn("Found vbmeta.img, parsing ...")
VBMetaParser().unpack("vbmeta.img")
}
return this
}
fun unpack(outDir: String): Dtbo {
File("${outDir}dt").mkdir()
ObjectMapper().writerWithDefaultPrettyPrinter().writeValue(File("${outDir}dtbo.json"), this)
dtEntries.forEach {
Common.dumpDtb(Helper.Slice(info.output, it.entryOffset, it.entrySize, "${outDir}dt/dt.${it.sequenceNo}"))
}
return this
}
fun pack(): Dtbo {
FileOutputStream(info.output + ".clear").use { fos ->
// Part I
this.header.entryCount = this.dtEntries.size
this.header.totalSize = (DtboHeader.SIZE
+ (header.entryCount * DeviceTreeTableEntry.SIZE)
+ this.dtEntries.sumOf { File("${outDir}dt/dt.${it.sequenceNo}").length() })
.toInt()
// Part II - a
for (index in 0 until dtEntries.size) {
DTC().compile("${outDir}dt/dt.${index}.src", "${outDir}dt/dt.${index}")
}
// Part II - b
var offset = DtboHeader.SIZE + (header.entryCount * DeviceTreeTableEntry.SIZE)
this.dtEntries.forEachIndexed { index, deviceTreeTableEntry ->
deviceTreeTableEntry.entrySize = File("${outDir}dt/dt.${index}").length().toInt()
deviceTreeTableEntry.entryOffset = offset
offset += deviceTreeTableEntry.entrySize
}
// + Part I
fos.write(header.encode())
// + Part II
this.dtEntries.forEach {
fos.write(it.encode())
}
// + Part III
for (index in 0 until dtEntries.size) {
fos.write(File("${outDir}dt/dt.${index}").readBytes())
}
}
return this
}
fun printSummary(): Dtbo {
val tableHeader = AsciiTable().apply {
addRule()
addRow("What", "Where")
addRule()
}
val tab = AsciiTable().let {
it.addRule()
it.addRow("image info", outDir + info.output.removeSuffix(".img") + ".json")
it.addRule()
it.addRow("device-tree blob (${this.header.entryCount} blobs)", "${outDir}dt/dt.*")
it.addRow("\\-- device-tree source ", "${outDir}dt/dt.*.src")
it.addRule()
it.addRow("AVB info", Avb.getJsonFileName(info.output))
it.addRule()
it
}
val tabVBMeta = AsciiTable().let {
if (File("vbmeta.img").exists()) {
it.addRule()
it.addRow("vbmeta.img", Avb.getJsonFileName("vbmeta.img"))
it.addRule()
"\n" + it.render()
} else {
""
}
}
log.info("\n\t\t\tUnpack Summary of ${info.output}\n{}\n{}{}", tableHeader.render(), tab.render(), tabVBMeta)
return this
}
fun sign(): Dtbo {
val avbtool = String.format(Helper.prop("avbtool"), "v1.2")
Signer.signAVB(info.output, info.imageSize.toLong(), avbtool)
return this
}
fun updateVbmeta(): Dtbo {
Avb.updateVbmeta(info.output)
return this
}
fun printPackSummary(): Dtbo {
VendorBoot.printPackSummary(info.output)
return this
}
}

@ -48,6 +48,9 @@ dependencies {
}
tasks.withType<KotlinCompile>().all {
kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
kotlinOptions.jvmTarget = "11"
kotlinOptions {
freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
freeCompilerArgs += "-Xopt-in=kotlin.ExperimentalUnsignedTypes"
jvmTarget = "11"
}
}

@ -1,7 +1,7 @@
#!/usr/bin/env python3
import shutil, os.path, json, subprocess, hashlib, glob
import unittest, logging, sys, lzma, time
import unittest, logging, sys, lzma, time, platform
successLogo = """
+----------------------------------+
@ -145,7 +145,14 @@ def main():
#########################################
# resource_2
#########################################
cleanUp()
verifySingleJson("%s/issue_59/recovery.json" % resDir2, func = lambda: shutil.rmtree("build/unzip_boot/root", ignore_errors = False))
# Issue 71: dtbo
if platform.system() != "Darwin":
verifySingleDir(resDir2, "issue_71")
verifySingleDir(resDir2, "issue_71/redfin")
else:
log.info("dtbo not fully supported on MacOS, skip testing")
log.info(successLogo)

@ -1 +1 @@
Subproject commit 4957dc9c53ea905f28b82c8ee65f738b6a88297c
Subproject commit f79ff7099a342261444797ecb4054806c9dcca22
Loading…
Cancel
Save