diff --git a/Makefile b/Makefile index 203cb04..8b3b4ef 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ CFLAGS += -O2 -Wall -g `pkg-config --cflags libxml-2.0 libusb-1.0` LDFLAGS += `pkg-config --libs libxml-2.0 libusb-1.0` prefix := /usr/local -QDL_SRCS := firehose.c io.c qdl.c sahara.c util.c patch.c program.c read.c sim.c ufs.c usb.c ux.c oscompat.c +QDL_SRCS := firehose.c io.c qdl.c sahara.c util.c patch.c program.c read.c sha2.c sim.c ufs.c usb.c ux.c oscompat.c vip.c QDL_OBJS := $(QDL_SRCS:.c=.o) RAMDUMP_SRCS := ramdump.c sahara.c io.c sim.c usb.c util.c ux.c oscompat.c diff --git a/firehose.c b/firehose.c index 65ece95..3f22623 100644 --- a/firehose.c +++ b/firehose.c @@ -50,6 +50,7 @@ #include "qdl.h" #include "ufs.h" #include "oscompat.h" +#include "vip.h" enum { FIREHOSE_ACK = 0, @@ -183,9 +184,11 @@ static int firehose_write(struct qdl_device *qdl, xmlDoc *doc) xmlDocDumpMemory(doc, &s, &len); + vip_gen_chunk_init(qdl); + for (;;) { ux_debug("FIREHOSE WRITE: %s\n", s); - + vip_gen_chunk_update(qdl, s, len); ret = qdl_write(qdl, s, len); saved_errno = errno; @@ -202,6 +205,7 @@ static int firehose_write(struct qdl_device *qdl, xmlDoc *doc) } } xmlFree(s); + vip_gen_chunk_store(qdl); return ret < 0 ? -saved_errno : 0; } @@ -428,6 +432,11 @@ static int firehose_program(struct qdl_device *qdl, struct program *program, int program->filename, program->sector_size * num_sectors); while (left > 0) { + /* + * We should calculate hash for every raw packet sent, + * not for the whole binary. + */ + vip_gen_chunk_init(qdl); chunk_size = MIN(max_payload_size / program->sector_size, left); n = read(fd, buf, chunk_size * program->sector_size); @@ -439,6 +448,7 @@ static int firehose_program(struct qdl_device *qdl, struct program *program, int if (n < max_payload_size) memset(buf + n, 0, max_payload_size - n); + vip_gen_chunk_update(qdl, buf, chunk_size * program->sector_size); n = qdl_write(qdl, buf, chunk_size * program->sector_size); if (n < 0) { ux_err("USB write failed for data chunk\n"); @@ -452,6 +462,7 @@ static int firehose_program(struct qdl_device *qdl, struct program *program, int } left -= chunk_size; + vip_gen_chunk_store(qdl); ux_progress("%s", num_sectors - left, num_sectors, program->label); } diff --git a/qdl.c b/qdl.c index 06145f0..56525b0 100644 --- a/qdl.c +++ b/qdl.c @@ -43,6 +43,7 @@ #include "program.h" #include "ufs.h" #include "oscompat.h" +#include "vip.h" #ifdef _WIN32 const char *__progname = "qdl"; @@ -107,7 +108,7 @@ static void print_usage(void) { extern const char *__progname; fprintf(stderr, - "%s [--debug] [--dry-run] [--version] [--allow-missing] [--storage ] [--finalize-provisioning] [--include ] [--serial ] [--out-chunk-size ] [ ...]\n", + "%s [--debug] [--dry-run] [--version] [--allow-missing] [--storage ] [--finalize-provisioning] [--include ] [--serial ] [--out-chunk-size ] [--create-digests ] [ ...]\n", __progname); } @@ -120,6 +121,7 @@ int main(int argc, char **argv) char *prog_mbn, *storage="ufs"; char *incdir = NULL; char *serial = NULL; + const char *vip_generate_dir= NULL; int type; int ret; int opt; @@ -141,6 +143,7 @@ int main(int argc, char **argv) {"allow-missing", no_argument, 0, 'f'}, {"allow-fusing", no_argument, 0, 'c'}, {"dry-run", no_argument, 0, 'n'}, + {"create-digests", required_argument, 0, 't'}, {0, 0, 0, 0} }; @@ -152,6 +155,11 @@ int main(int argc, char **argv) case 'n': qdl_dev_type = QDL_DEVICE_SIM; break; + case 't': + vip_generate_dir = optarg; + /* we also enforce dry-run mode */ + qdl_dev_type = QDL_DEVICE_SIM; + break; case 'v': print_version(); return 0; @@ -197,6 +205,12 @@ int main(int argc, char **argv) if (out_chunk_size) qdl_set_out_chunk_size(qdl, out_chunk_size); + if (vip_generate_dir) { + ret = vip_gen_init(qdl, vip_generate_dir); + if (ret) + goto out_cleanup; + } + ux_init(); if (qdl_debug) @@ -254,6 +268,9 @@ int main(int argc, char **argv) goto out_cleanup; out_cleanup: + if (vip_generate_dir) + vip_gen_finalize(qdl); + qdl_close(qdl); free_programs(); free_patches(); diff --git a/qdl.h b/qdl.h index d7d6880..15ec6e5 100644 --- a/qdl.h +++ b/qdl.h @@ -8,6 +8,10 @@ #include "read.h" #include +#define container_of(ptr, typecast, member) ({ \ + void *_ptr = (void *)(ptr); \ + ((typecast *)(_ptr - offsetof(typecast, member))); }) + #define MAPPING_SZ 64 enum QDL_DEVICE_TYPE diff --git a/sim.c b/sim.c index 350779a..e935797 100644 --- a/sim.c +++ b/sim.c @@ -30,11 +30,13 @@ #include #include -#include "qdl.h" +#include "sim.h" struct qdl_device_sim { struct qdl_device base; + struct vip_table_generator *vip_gen; + bool create_digests; }; static int sim_open(struct qdl_device *qdl, const char *serial) @@ -80,4 +82,35 @@ struct qdl_device *sim_init(void) qdl->set_out_chunk_size = sim_set_out_chunk_size; return qdl; +} + +struct vip_table_generator *sim_get_vip_generator(struct qdl_device *qdl) +{ + struct qdl_device_sim *qdl_sim; + + if (qdl->dev_type != QDL_DEVICE_SIM) + return NULL; + + qdl_sim = container_of(qdl, struct qdl_device_sim, base); + + if (!qdl_sim->create_digests) + return NULL; + + return qdl_sim->vip_gen; +} + +bool sim_set_digest_generation(bool create_digests, struct qdl_device *qdl, + struct vip_table_generator *vip_gen) +{ + struct qdl_device_sim *qdl_sim; + + if (qdl->dev_type != QDL_DEVICE_SIM) + return false; + + qdl_sim = container_of(qdl, struct qdl_device_sim, base); + + qdl_sim->create_digests = create_digests; + qdl_sim->vip_gen = vip_gen; + + return true; } \ No newline at end of file diff --git a/sim.h b/sim.h new file mode 100644 index 0000000..f758bc4 --- /dev/null +++ b/sim.h @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025, Qualcomm Innovation Center, Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +#ifndef __SIM_H__ +#define __SIM_H__ + +#include "qdl.h" +#include "vip.h" + +struct vip_table_generator *sim_get_vip_generator(struct qdl_device *qdl); +bool sim_set_digest_generation(bool create_digests, struct qdl_device *qdl, + struct vip_table_generator *vip_gen); + +#endif /* __SIM_H__ */ \ No newline at end of file diff --git a/usb.c b/usb.c index 1db1227..83a4637 100644 --- a/usb.c +++ b/usb.c @@ -9,10 +9,6 @@ #include "qdl.h" -#define container_of(ptr, typecast, member) ({ \ - void *_ptr = (void *)(ptr); \ - ((typecast *)(_ptr - offsetof(typecast, member))); }) - #define DEFAULT_OUT_CHUNK_SIZE (1024 * 1024) struct qdl_device_usb diff --git a/vip.c b/vip.c new file mode 100644 index 0000000..27b547e --- /dev/null +++ b/vip.c @@ -0,0 +1,368 @@ +/* + * Copyright (c) 2025, Qualcomm Innovation Center, Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +#include +#include +#include +#include +#include + +#include "sim.h" + +#define DIGEST_FULL_TABLE_FILE "DIGEST_TABLE.bin" +#define CHAINED_TABLE_FILE_PREF "ChainedTableOfDigests" +#define CHAINED_TABLE_FILE_MAX_NAME 64 +#define DIGEST_TABLE_TO_SIGN_FILE "DigestsToSign.bin" +#define MAX_DIGESTS_PER_SIGNED_FILE 54 +#define MAX_DIGESTS_PER_SIGNED_TABLE (MAX_DIGESTS_PER_SIGNED_FILE - 1) +#define MAX_DIGESTS_PER_CHAINED_FILE 256 +#define MAX_DIGESTS_PER_CHAINED_TABLE (MAX_DIGESTS_PER_CHAINED_FILE - 1) +#define MAX_DIGESTS_PER_BUF 16 + +struct vip_table_generator +{ + unsigned char hash[SHA256_DIGEST_LENGTH]; + + SHA2_CTX ctx; + + FILE *digest_table_fd; + size_t digest_num_written; + + const char *path; +}; + +static void print_digest(unsigned char *buf) +{ + char hex_str[SHA256_DIGEST_STRING_LENGTH]; + + for (size_t i = 0; i < SHA256_DIGEST_LENGTH; ++i) + sprintf(hex_str + i * 2, "%02x", buf[i]); + + hex_str[SHA256_DIGEST_STRING_LENGTH - 1] = '\0'; + + ux_debug("FIREHOSE PACKET SHA256: %s\n", hex_str); +} + +int vip_gen_init(struct qdl_device *qdl, const char *path) +{ + struct vip_table_generator *vip_gen; + struct stat st; + char filepath[PATH_MAX]; + + if (qdl->dev_type != QDL_DEVICE_SIM) { + ux_err("Should be executed in simulation dry-run mode\n"); + return -1; + } + + if (stat(path, &st) || !S_ISDIR(st.st_mode)) { + ux_err("Directory '%s' to store VIP tables doesn't exist\n", path); + } + + vip_gen = malloc(sizeof(struct vip_table_generator)); + if (!vip_gen) { + ux_err("Can't allocate memory for vip_table_generator\n"); + return -1; + } + if (!sim_set_digest_generation(true, qdl, vip_gen)) { + ux_err("Can't enable digest table generation\n"); + goto out_cleanup; + } + vip_gen->digest_num_written = 0; + vip_gen->path = path; + + snprintf(filepath, sizeof(filepath), "%s/%s", path, DIGEST_FULL_TABLE_FILE); + + vip_gen->digest_table_fd = fopen(filepath, "wb"); + if (!vip_gen->digest_table_fd) { + ux_err("Can't create %s file\n", filepath); + goto out_cleanup; + } + + return 0; +out_cleanup: + free(vip_gen); + sim_set_digest_generation(false, qdl, NULL); + + return -1; +} + +void vip_gen_chunk_init(struct qdl_device *qdl) +{ + struct vip_table_generator *vip_gen; + + vip_gen = sim_get_vip_generator(qdl); + if (!vip_gen) + return; + + SHA256Init(&vip_gen->ctx); +} + +void vip_gen_chunk_update(struct qdl_device *qdl, const void *buf, size_t len) +{ + struct vip_table_generator *vip_gen; + + vip_gen = sim_get_vip_generator(qdl); + if (!vip_gen) + return; + + SHA256Update(&vip_gen->ctx, (uint8_t *)buf, len); +} + +void vip_gen_chunk_store(struct qdl_device *qdl) +{ + struct vip_table_generator *vip_gen; + + vip_gen = sim_get_vip_generator(qdl); + if (!vip_gen) + return; + + SHA256Final(vip_gen->hash, &vip_gen->ctx); + + print_digest(vip_gen->hash); + + if (fwrite(vip_gen->hash, SHA256_DIGEST_LENGTH, 1, vip_gen->digest_table_fd) != 1) { + ux_err("Failed to write digest to the " DIGEST_FULL_TABLE_FILE); + goto out_cleanup; + } + + vip_gen->digest_num_written++; + + return; + +out_cleanup: + fclose(vip_gen->digest_table_fd); +} + +static int write_output_file(const char *filename, bool append, const void *data, size_t len) +{ + FILE *fp; + char *mode = "wb"; + + if (append) + mode = "ab"; + + fp = fopen(filename, mode); + if (!fp) { + ux_err("Failed to open file for appending\n"); + return -1; + } + + if (fwrite(data, 1, len, fp) != len) { + ux_err("Failed to append to file\n"); + fclose(fp); + return -1; + } + + fclose(fp); + + return 0; +} + +static int calculate_hash_of_file(const char *filename, unsigned char *hash) +{ + unsigned char buf[1024]; + SHA2_CTX ctx; + + FILE *fp = fopen(filename, "rb"); + if (!fp) { + ux_err("Failed to open file for hashing\n"); + return -1; + } + + SHA256Init(&ctx); + + size_t bytes; + while ((bytes = fread(buf, 1, sizeof(buf), fp)) > 0) { + SHA256Update(&ctx, (uint8_t *)buf, bytes); + } + + fclose(fp); + + SHA256Final(hash, &ctx); + + return 0; +} + +static int write_digests_to_table(char *src_table, char *dest_table, size_t start_digest, size_t count) +{ + const size_t elem_size = SHA256_DIGEST_LENGTH; + unsigned char buf[MAX_DIGESTS_PER_BUF * SHA256_DIGEST_LENGTH]; + size_t written = 0; + int ret; + + int fd = open(src_table, O_RDONLY); + if (fd < 0) { + ux_err("Failed to open %s for reading\n", src_table); + return -1; + } + + /* Seek to offset of start_digest */ + size_t offset = elem_size * start_digest; + if (lseek(fd, offset, SEEK_SET) != offset) { + ux_err("Failed to seek in %s\n", src_table); + goto out_cleanup; + } + + while (written < (count * elem_size)) { + size_t to_read = count * elem_size - written; + if (to_read > sizeof(buf)) + to_read = sizeof(buf); + + size_t bytes = read(fd, buf, to_read); + if (bytes < 0 || (size_t) bytes != to_read) { + ux_err("Failed to read from %s\n", src_table); + goto out_cleanup; + } + + ret = write_output_file(dest_table, (written != 0), buf, bytes); + if (ret < 0) { + ux_err("Can't write digests to %s\n", dest_table); + goto out_cleanup; + } + + written += to_read; + } + close(fd); + + return 0; +out_cleanup: + close(fd); + + return -1; +} + +static int create_chained_tables(struct vip_table_generator *vip_gen) +{ + size_t chained_num = 0; + size_t tosign_count = 0; + size_t total_digests = vip_gen->digest_num_written; + char src_table[PATH_MAX]; + char dest_table[PATH_MAX]; + unsigned char hash[SHA256_DIGEST_LENGTH]; + int ret; + + snprintf(src_table, sizeof(src_table), "%s/%s", vip_gen->path, DIGEST_FULL_TABLE_FILE); + + /* Step 1: Write digest table to DigestsToSign.bin */ + snprintf(dest_table, sizeof(dest_table), "%s/%s", + vip_gen->path, DIGEST_TABLE_TO_SIGN_FILE); + tosign_count = total_digests < MAX_DIGESTS_PER_SIGNED_TABLE ? total_digests : + MAX_DIGESTS_PER_SIGNED_TABLE; + + ret = write_digests_to_table(src_table, dest_table, 0, tosign_count); + if (ret) { + ux_err("Writing digests to %s failed\n", dest_table); + return ret; + } + + /* Step 2: Write remaining digests to ChainedTableOfDigests.bin */ + if (total_digests > MAX_DIGESTS_PER_SIGNED_TABLE) { + size_t remaining_digests = total_digests - MAX_DIGESTS_PER_SIGNED_TABLE; + + while (remaining_digests > 0) { + size_t table_digests = remaining_digests > MAX_DIGESTS_PER_CHAINED_TABLE ? + MAX_DIGESTS_PER_CHAINED_TABLE : remaining_digests; + + snprintf(dest_table, sizeof(dest_table), + "%s/%s%zu.bin", vip_gen->path, + CHAINED_TABLE_FILE_PREF, chained_num); + + ret = write_digests_to_table(src_table, dest_table, + total_digests - remaining_digests, + table_digests); + if (ret) { + ux_err("Writing digests to %s failed\n", dest_table); + return ret; + } + + remaining_digests -= table_digests; + if (!remaining_digests) { + /* Add zero (the packet can't be multiple of 512 bytes) */ + ret = write_output_file(dest_table, true, "\0", 1); + if (ret < 0) { + ux_err("Can't write 0 to %s\n", dest_table); + return ret; + } + } + chained_num++; + } + } + + /* Step 3: Recursively hash and append backwards */ + for (ssize_t i = chained_num - 1; i >= 0; --i) { + snprintf(src_table, sizeof(src_table), + "%s/%s%zd.bin", vip_gen->path, + CHAINED_TABLE_FILE_PREF, i); + ret = calculate_hash_of_file(src_table, hash); + if (ret < 0) { + ux_err("Failed to hash %s\n", src_table); + return ret; + } + + if (i == 0) { + snprintf(dest_table, sizeof(dest_table), "%s/%s", + vip_gen->path, DIGEST_TABLE_TO_SIGN_FILE); + ret = write_output_file(dest_table, true, hash, SHA256_DIGEST_LENGTH); + if (ret < 0) { + ux_err("Failed to append hash to %s\n", dest_table); + return ret; + } + } else { + snprintf(dest_table, sizeof(dest_table), + "%s/%s%zd.bin", vip_gen->path, + CHAINED_TABLE_FILE_PREF, (i - 1)); + ret = write_output_file(dest_table, true, hash, SHA256_DIGEST_LENGTH); + if (ret < 0) { + ux_err("Failed to append hash to %s\n", dest_table); + return ret; + } + } + } + + return 0; +} + +void vip_gen_finalize(struct qdl_device *qdl) +{ + struct vip_table_generator *vip_gen; + + vip_gen = sim_get_vip_generator(qdl); + if (!vip_gen) + return; + + fclose(vip_gen->digest_table_fd); + + ux_debug("VIP TABLE DIGESTS: %lu\n", vip_gen->digest_num_written); + + if (create_chained_tables(vip_gen) < 0) + ux_err("Error occured when creating table of digests\n"); + + free(vip_gen); + sim_set_digest_generation(false, qdl, NULL); +} \ No newline at end of file diff --git a/vip.h b/vip.h new file mode 100644 index 0000000..5031be1 --- /dev/null +++ b/vip.h @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025, Qualcomm Innovation Center, Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +#ifndef __VIP_H__ +#define __VIP_H__ + +#include "sha2.h" + +struct vip_table_generator; + +int vip_gen_init(struct qdl_device *qdl, const char *path); +void vip_gen_chunk_init(struct qdl_device *qdl); +void vip_gen_chunk_update(struct qdl_device *qdl, const void *buf, size_t len); +void vip_gen_chunk_store(struct qdl_device *qdl); +void vip_gen_finalize(struct qdl_device *qdl); + +#endif /* __VIP_H__ */ \ No newline at end of file