Seregon/zftpd

Zero-copy FTP/HTTP Daemon compatible with all POSIX systems

C/11.0 KB/No license
src/exfat_unpacker.c
zftpd / src / exfat_unpacker.c
1/*
2 * Feature-test macros must appear before any system include.
3 * _FILE_OFFSET_BITS=64: enables fseeko/ftello on 32-bit hosts.
4 * _POSIX_C_SOURCE=200809L: exposes fseeko, fdopen, strcasecmp etc. in strict C99 mode.
5 */
6#ifndef _FILE_OFFSET_BITS
7# define _FILE_OFFSET_BITS 64
8#endif
9#ifndef _POSIX_C_SOURCE
10# define _POSIX_C_SOURCE 200809L
11#endif
12 
13/*
14 * exfat_unpacker — exFAT filesystem image parser
15 * Copyright (C) 2025 seregonwar
16 *
17 * This program is free software: you can redistribute it and/or modify
18 * it under the terms of the GNU General Public License as published by
19 * the Free Software Foundation, either version 3 of the License, or
20 * (at your option) any later version.
21 *
22 * This program is distributed in the hope that it will be useful,
23 * but WITHOUT ANY WARRANTY; without even the implied warranty of
24 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
25 * GNU General Public License for more details.
26 *
27 * You should have received a copy of the GNU General Public License
28 * along with this program. If not, see <https://www.gnu.org/licenses/>.
29 *
30 * exfat_unpacker.c — read-only exFAT filesystem image parser
31 *
32 * Fixes applied over the original version:
33 * 1. Boot sector: volume_flags uint32→uint16; bytes_per_sector_shift now
34 * correctly at 0x6C instead of 0x6E.
35 * 2. File entry struct trimmed to exactly 32 bytes; phantom fields removed.
36 * 3. Filename entry: name_length/name_hash moved to stream extension where
37 * they actually live; out-of-bounds reads eliminated.
38 * 4. exfat_read_directory: follows the cluster chain (was reading only the
39 * first cluster, silently truncating large directories).
40 * 5. exfat_parse_file_entry: gets NameLength from stream extension;
41 * accumulates chars across multiple filename entries; respects the
42 * secondary_count to avoid walking beyond the entry set.
43 * 6. File extraction: uses a single stack buffer (EXFAT_IO_CHUNK_SIZE)
44 * instead of per-cluster malloc; respects the NoFatChain flag.
45 * 7. Added exfat_extract_to_buffer() for in-memory extraction.
46 * 8. fseeko() used for large-file offsets (> 2 GiB).
47 */
48 
49#include "exfat_unpacker.h"
50#include <errno.h>
51#if defined(_MSC_VER)
52# include <io.h>
53# include <fcntl.h>
54# define dup _dup
55# define fdopen _fdopen
56# define close _close
57# define write _write
58# define fseeko _fseeki64
59# define ftello _ftelli64
60# define lseek64 _lseeki64
61#else
62# include <unistd.h> /* write(), close() */
63# define lseek64 lseek
64#endif
65 
66/* ── Boot sector ────────────────────────────────────────────────────────── */
67 
68int exfat_read_boot_sector(exfat_context_t *ctx) {
69 if (!ctx || !ctx->image_file)
70 return -1;
71 
72 if (fseeko(ctx->image_file, 0, SEEK_SET) != 0) {
73 perror("[exFAT] fseeko boot sector");
74 return -1;
75 }
76 if (fread(&ctx->boot_sector, sizeof(exfat_boot_sector_t), 1,
77 ctx->image_file) != 1) {
78 perror("[exFAT] fread boot sector");
79 return -1;
80 }
81 if (exfat_validate_boot_sector(&ctx->boot_sector) != 0)
82 return -1;
83 
84 /*
85 * Derive working constants.
86 * Shift values are validated by exfat_validate_boot_sector().
87 */
88 ctx->bytes_per_sector = 1U << ctx->boot_sector.bytes_per_sector_shift;
89 ctx->sectors_per_cluster = 1U << ctx->boot_sector.sectors_per_cluster_shift;
90 ctx->bytes_per_cluster = ctx->bytes_per_sector * ctx->sectors_per_cluster;
91 ctx->cluster_heap_offset_bytes =
92 (uint64_t)ctx->boot_sector.cluster_heap_offset * ctx->bytes_per_sector;
93 
94 return 0;
95}
96 
97int exfat_validate_boot_sector(const exfat_boot_sector_t *boot) {
98 if (!boot)
99 return -1;
100 
101 /* FileSystemName must be "EXFAT " (with 3 trailing spaces) */
102 if (memcmp(boot->fs_name, EXFAT_FS_NAME, EXFAT_FS_NAME_LEN) != 0) {
103 fprintf(stderr, "[exFAT] invalid filesystem name\n");
104 return -1;
105 }
106 if (boot->boot_signature != EXFAT_BOOT_SIGNATURE) {
107 fprintf(stderr, "[exFAT] invalid boot signature: 0x%04X\n",
108 boot->boot_signature);
109 return -1;
110 }
111 /* Per spec §3.1.13: BytesPerSectorShift in [9, 12] */
112 if (boot->bytes_per_sector_shift < 9 || boot->bytes_per_sector_shift > 12) {
113 fprintf(stderr, "[exFAT] BytesPerSectorShift=%u out of range [9,12]\n",
114 boot->bytes_per_sector_shift);
115 return -1;
116 }
117 /* Per spec §3.1.14: BytesPerSectorShift + SectorsPerClusterShift <= 25 */
118 if ((unsigned)boot->bytes_per_sector_shift +
119 (unsigned)boot->sectors_per_cluster_shift > 25U) {
120 fprintf(stderr,
121 "[exFAT] shift sum %u+%u > 25 (cluster too large)\n",
122 boot->bytes_per_sector_shift,
123 boot->sectors_per_cluster_shift);
124 return -1;
125 }
126 return 0;
127}
128 
129void exfat_print_boot_sector(const exfat_boot_sector_t *boot) {
130 if (!boot) return;
131 printf("=== exFAT Boot Sector ===\n");
132 printf(" FileSystemName : %.8s\n", boot->fs_name);
133 printf(" FatOffset : %u sectors\n", boot->fat_offset);
134 printf(" FatLength : %u sectors\n", boot->fat_length);
135 printf(" ClusterHeapOffset : %u sectors\n", boot->cluster_heap_offset);
136 printf(" TotalClusters : %u\n", boot->total_clusters);
137 printf(" RootDirFirstCluster : %u\n", boot->root_dir_first_cluster);
138 printf(" VolumeSerialNumber : 0x%08X\n", boot->volume_serial);
139 printf(" FileSystemRevision : 0x%04X\n", boot->fs_revision);
140 printf(" BytesPerSectorShift : %u (= %u bytes/sector)\n",
141 boot->bytes_per_sector_shift, 1U << boot->bytes_per_sector_shift);
142 printf(" SectorsPerClusterShift: %u (= %u sectors/cluster)\n",
143 boot->sectors_per_cluster_shift,
144 1U << boot->sectors_per_cluster_shift);
145 printf(" NumberOfFats : %u\n", boot->num_fats);
146 printf(" PercentInUse : %u%%\n", boot->percent_in_use);
147 printf("\n");
148}
149 
150/* ── FAT ────────────────────────────────────────────────────────────────── */
151 
152int exfat_read_fat(exfat_context_t *ctx) {
153 if (!ctx || !ctx->image_file)
154 return -1;
155 
156 uint64_t fat_offset_bytes =
157 (uint64_t)ctx->boot_sector.fat_offset * ctx->bytes_per_sector;
158 uint64_t fat_size_bytes =
159 (uint64_t)ctx->boot_sector.fat_length * ctx->bytes_per_sector;
160 
161 /* Sanity: FAT must cover at least total_clusters+2 entries */
162 size_t min_entries = (size_t)ctx->boot_sector.total_clusters + 2U;
163 size_t available = (size_t)(fat_size_bytes / sizeof(uint32_t));
164 if (available < min_entries) {
165 fprintf(stderr,
166 "[exFAT] FAT too small: %zu entries, need %zu\n",
167 available, min_entries);
168 return -1;
169 }
170 
171 ctx->fat_table = (uint32_t *)malloc(fat_size_bytes);
172 if (!ctx->fat_table) {
173 perror("[exFAT] malloc FAT");
174 return -1;
175 }
176 ctx->fat_entries = available;
177 
178 if (fseeko(ctx->image_file, (off_t)fat_offset_bytes, SEEK_SET) != 0) {
179 perror("[exFAT] fseeko FAT");
180 free(ctx->fat_table);
181 ctx->fat_table = NULL;
182 return -1;
183 }
184 if (fread(ctx->fat_table, fat_size_bytes, 1, ctx->image_file) != 1) {
185 perror("[exFAT] fread FAT");
186 free(ctx->fat_table);
187 ctx->fat_table = NULL;
188 return -1;
189 }
190 return 0;
191}
192 
193uint32_t exfat_get_next_cluster(const exfat_context_t *ctx, uint32_t cluster) {
194 if (!ctx || !ctx->fat_table)
195 return EXFAT_FAT_END_OF_CHAIN;
196 if (cluster >= (uint32_t)ctx->fat_entries)
197 return EXFAT_FAT_END_OF_CHAIN;
198 return ctx->fat_table[cluster];
199}
200 
201void exfat_free_fat(exfat_context_t *ctx) {
202 if (ctx && ctx->fat_table) {
203 free(ctx->fat_table);
204 ctx->fat_table = NULL;
205 ctx->fat_entries = 0;
206 }
207}
208 
209/* ── Cluster I/O ────────────────────────────────────────────────────────── */
210 
211uint64_t exfat_get_cluster_offset(const exfat_context_t *ctx, uint32_t cluster) {
212 if (!ctx) return 0;
213 return ctx->cluster_heap_offset_bytes +
214 (uint64_t)(cluster - 2U) * ctx->bytes_per_cluster;
215}
216 
217ssize_t exfat_read_cluster(exfat_context_t *ctx, uint32_t cluster,
218 uint8_t *buffer, size_t size) {
219 if (!ctx || !ctx->image_file || !buffer)
220 return -1;
221 if (!exfat_is_valid_cluster(cluster)) {
222 fprintf(stderr, "[exFAT] invalid cluster %u\n", cluster);
223 return -1;
224 }
225 uint64_t offset = exfat_get_cluster_offset(ctx, cluster);
226 size_t bytes_to_read = (size < ctx->bytes_per_cluster) ?
227 size : ctx->bytes_per_cluster;
228 
229 if (fseeko(ctx->image_file, (off_t)offset, SEEK_SET) != 0) {
230 perror("[exFAT] fseeko cluster");
231 return -1;
232 }
233 size_t got = fread(buffer, 1, bytes_to_read, ctx->image_file);
234 return (ssize_t)got;
235}
236 
237/* ── Directory parsing ──────────────────────────────────────────────────── */
238 
239int exfat_utf16_to_utf8(const uint16_t *utf16, int nchars,
240 char *utf8, size_t utf8_size) {
241 if (!utf16 || !utf8 || utf8_size == 0)
242 return -1;
243 
244 size_t out = 0;
245 for (int i = 0; i < nchars && out < utf8_size - 1U; i++) {
246 uint16_t ch = utf16[i];
247 if (ch == 0) break;
248 
249 if (ch < 0x80U) {
250 utf8[out++] = (char)ch;
251 } else if (ch < 0x800U) {
252 if (out + 2 >= utf8_size) break;
253 utf8[out++] = (char)(0xC0U | (ch >> 6));
254 utf8[out++] = (char)(0x80U | (ch & 0x3FU));
255 } else {
256 if (out + 3 >= utf8_size) break;
257 utf8[out++] = (char)(0xE0U | (ch >> 12));
258 utf8[out++] = (char)(0x80U | ((ch >> 6) & 0x3FU));
259 utf8[out++] = (char)(0x80U | (ch & 0x3FU));
260 }
261 }
262 utf8[out] = '\0';
263 return 0;
264}
265 
266/*
267 * Parse one complete directory entry set (0x85 + secondaries).
268 *
269 * @param data flat buffer containing one or more 32-byte entries
270 * @param offset byte offset of the 0x85 entry within data[]
271 * @param data_len total valid bytes in data[]
272 * @param info output
273 *
274 * @return total bytes consumed by this entry set (= (1+secondary_count)*32),
275 * or -1 if the entry is not a 0x85 file/dir entry or data is short.
276 */
277int exfat_parse_file_entry(const uint8_t *data, size_t offset, size_t data_len,
278 exfat_file_info_t *info) {
279 if (!data || !info || offset >= data_len)
280 return -1;
281 
282 const exfat_file_entry_t *fe =
283 (const exfat_file_entry_t *)(data + offset);
284 
285 if (fe->entry_type != EXFAT_ENTRY_TYPE_FILE_DIR)
286 return -1;
287 
288 uint8_t secondary_count = fe->secondary_count;
289 size_t total_bytes = (size_t)(1 + secondary_count) * EXFAT_ENTRY_SIZE;
290 
291 if (offset + total_bytes > data_len)
292 return -1; /* entry set extends beyond buffer */
293 
294 memset(info, 0, sizeof(exfat_file_info_t));
295 info->attributes = fe->file_attributes;
296 info->create_time = fe->create_time;
297 info->last_modified_time= fe->last_modified_time;
298 info->last_accessed_time= fe->last_accessed_time;
299 info->is_directory = exfat_is_directory(fe->file_attributes);
300 
301 /* Walk secondary entries ─────────────────────────────────────────── */
302 int name_chars_collected = 0; /* chars accumulated so far */
303 int name_length = 0; /* from stream extension */
304 char name_buf[EXFAT_MAX_FILENAME_LEN * 4 + 1]; /* worst-case UTF-8 */
305 size_t name_buf_used = 0;
306 
307 for (int s = 0; s < (int)secondary_count; s++) {
308 const uint8_t *sec = data + offset + (size_t)(s + 1) * EXFAT_ENTRY_SIZE;
309 uint8_t stype = sec[0];
310 
311 if (stype == EXFAT_ENTRY_TYPE_STREAM_EXT) {
312 const exfat_stream_ext_t *sx = (const exfat_stream_ext_t *)sec;
313 info->first_cluster = sx->first_cluster;
314 info->data_length = sx->data_length;
315 info->no_fat_chain = (sx->flags & EXFAT_FLAG_NO_FAT_CHAIN) ? 1 : 0;
316 name_length = (int)sx->name_length; /* total chars */
317 
318 } else if (stype == EXFAT_ENTRY_TYPE_FILENAME) {
319 const exfat_filename_entry_t *fn =
320 (const exfat_filename_entry_t *)sec;
321 
322 /*
323 * Each filename entry carries up to 15 UTF-16LE code units.
324 * Collect until we reach name_length chars (from stream ext).
325 * This correctly handles filenames longer than 15 chars that
326 * span multiple filename entries.
327 */
328 int chars_remaining = name_length - name_chars_collected;
329 if (chars_remaining <= 0)
330 break;
331 
332 int chars_here = (chars_remaining < 15) ? chars_remaining : 15;
333 
334 /*
335 * Copy the packed uint16_t array to a local aligned buffer before
336 * passing to exfat_utf16_to_utf8(). Taking the address of a packed
337 * struct member directly causes -Waddress-of-packed-member and
338 * potential UB on architectures that require aligned 16-bit reads.
339 */
340 uint16_t name_copy[15];
341 memcpy(name_copy, fn->file_name, (size_t)chars_here * sizeof(uint16_t));
342 
343 char chunk[15 * 4 + 1];
344 exfat_utf16_to_utf8(name_copy, chars_here,
345 chunk, sizeof(chunk));
346 
347 size_t chunk_len = strlen(chunk);
348 if (name_buf_used + chunk_len < sizeof(name_buf)) {
349 memcpy(name_buf + name_buf_used, chunk, chunk_len);
350 name_buf_used += chunk_len;
351 name_chars_collected += chars_here;
352 }
353 }
354 }
355 
356 name_buf[name_buf_used] = '\0';
357 /* Copy into the fixed-size filename field; snprintf ensures NUL */
358 snprintf(info->filename, sizeof(info->filename), "%s", name_buf);
359 
360 return (int)total_bytes;
361}
362 
363/*
364 * Read all directory entries from a cluster chain.
365 *
366 * FIX: the original only read the first cluster. This implementation
367 * follows the FAT chain (or advances contiguously for NoFatChain dirs)
368 * until the chain ends or max_entries is reached.
369 *
370 * @note Directories themselves never set NoFatChain in practice, but we
371 * handle it for correctness.
372 */
373int exfat_read_directory(exfat_context_t *ctx, uint32_t first_cluster,
374 exfat_file_info_t *entries, int max_entries) {
375 if (!ctx || !entries || max_entries <= 0)
376 return -1;
377 
378 uint8_t *cluster_buf = (uint8_t *)malloc(ctx->bytes_per_cluster);
379 if (!cluster_buf) {
380 perror("[exFAT] malloc directory buffer");
381 return -1;
382 }
383 
384 int entry_count = 0;
385 uint32_t cluster = first_cluster;
386 
387 while (exfat_is_valid_cluster(cluster) && entry_count < max_entries) {
388 ssize_t got = exfat_read_cluster(ctx, cluster, cluster_buf,
389 ctx->bytes_per_cluster);
390 if (got <= 0)
391 break;
392 
393 size_t offset = 0;
394 while (offset + EXFAT_ENTRY_SIZE <= (size_t)got &&
395 entry_count < max_entries) {
396 
397 uint8_t etype = cluster_buf[offset];
398 
399 if (etype == EXFAT_ENTRY_TYPE_EOD)
400 goto done; /* end-of-directory — stop scanning */
401 
402 if (etype != EXFAT_ENTRY_TYPE_FILE_DIR) {
403 offset += EXFAT_ENTRY_SIZE;
404 continue;
405 }
406 
407 int consumed = exfat_parse_file_entry(
408 cluster_buf, offset, (size_t)got,
409 &entries[entry_count]);
410 
411 if (consumed <= 0) {
412 offset += EXFAT_ENTRY_SIZE;
413 continue;
414 }
415 
416 /* Only count entries with a non-empty filename */
417 if (entries[entry_count].filename[0] != '\0')
418 entry_count++;
419 
420 offset += (size_t)consumed;
421 }
422 
423 /* Advance to next cluster */
424 uint32_t next = exfat_get_next_cluster(ctx, cluster);
425 if (exfat_is_end_of_chain(next))
426 break;
427 cluster = next;
428 }
429 
430done:
431 free(cluster_buf);
432 return entry_count;
433}
434 
435/* ── File extraction helpers ────────────────────────────────────────────── */
436 
437/*
438 * Core read loop shared by all three extraction functions.
439 *
440 * Reads the file described by `info` in EXFAT_IO_CHUNK_SIZE blocks and
441 * calls write_cb(write_arg, buf, len) for each. Returns 0 on success.
442 *
443 * NoFatChain support: if info->no_fat_chain is set, advance clusters
444 * sequentially instead of following the FAT.
445 */
446typedef int (*exfat_write_cb_t)(void *arg, const uint8_t *buf, size_t len);
447 
448static int exfat_read_file_data(exfat_context_t *ctx,
449 const exfat_file_info_t *info,
450 exfat_write_cb_t write_cb, void *write_arg) {
451 uint8_t io_buf[EXFAT_IO_CHUNK_SIZE]; /* fixed stack buffer, no malloc */
452 uint64_t remaining = info->data_length;
453 uint32_t cluster = info->first_cluster;
454 
455 if (!exfat_is_valid_cluster(cluster) && remaining > 0) {
456 fprintf(stderr, "[exFAT] invalid first cluster %u\n", cluster);
457 return -1;
458 }
459 
460 while (remaining > 0 && exfat_is_valid_cluster(cluster)) {
461 uint64_t cluster_off = exfat_get_cluster_offset(ctx, cluster);
462 
463 /* How much of this cluster do we still need? */
464 uint64_t want = (remaining < ctx->bytes_per_cluster) ?
465 remaining : ctx->bytes_per_cluster;
466 
467 /* Read in EXFAT_IO_CHUNK_SIZE pieces within the cluster */
468 uint64_t cluster_done = 0;
469 while (cluster_done < want) {
470 uint64_t chunk = want - cluster_done;
471 if (chunk > EXFAT_IO_CHUNK_SIZE)
472 chunk = EXFAT_IO_CHUNK_SIZE;
473 
474 uint64_t abs_off = cluster_off + cluster_done;
475 if (fseeko(ctx->image_file, (off_t)abs_off, SEEK_SET) != 0) {
476 perror("[exFAT] fseeko file data");
477 return -1;
478 }
479 size_t got = fread(io_buf, 1, (size_t)chunk, ctx->image_file);
480 if (got == 0)
481 return -1; /* short read */
482 
483 if (write_cb(write_arg, io_buf, got) != 0)
484 return -1;
485 
486 cluster_done += got;
487 }
488 
489 remaining -= want;
490 if (remaining == 0)
491 break;
492 
493 /* Advance to next cluster */
494 if (info->no_fat_chain) {
495 cluster++; /* contiguous layout */
496 } else {
497 uint32_t next = exfat_get_next_cluster(ctx, cluster);
498 if (exfat_is_end_of_chain(next)) {
499 if (remaining > 0)
500 fprintf(stderr, "[exFAT] chain ended with %llu bytes left\n",
501 (unsigned long long)remaining);
502 break;
503 }
504 cluster = next;
505 }
506 }
507 return 0;
508}
509 
510/* write_cb for FILE* output */
511static int write_cb_file(void *arg, const uint8_t *buf, size_t len) {
512 FILE *f = (FILE *)arg;
513 return (fwrite(buf, 1, len, f) == len) ? 0 : -1;
514}
515 
516/* write_cb for fd output */
517static int write_cb_fd(void *arg, const uint8_t *buf, size_t len) {
518 int fd = *(int *)arg;
519 while (len > 0) {
520 ssize_t n = write(fd, buf, len);
521 if (n <= 0) { perror("[exFAT] write"); return -1; }
522 buf += n;
523 len -= (size_t)n;
524 }
525 return 0;
526}
527 
528/* write_cb for in-memory output */
529typedef struct { uint8_t *buf; size_t pos; size_t cap; } mem_ctx_t;
530static int write_cb_mem(void *arg, const uint8_t *buf, size_t len) {
531 mem_ctx_t *m = (mem_ctx_t *)arg;
532 if (m->pos + len > m->cap) {
533 size_t can_copy = m->cap - m->pos;
534 memcpy(m->buf + m->pos, buf, can_copy);
535 m->pos += can_copy;
536 return -1; /* buffer full */
537 }
538 memcpy(m->buf + m->pos, buf, len);
539 m->pos += len;
540 return 0;
541}
542 
543int exfat_extract_file(exfat_context_t *ctx, const exfat_file_info_t *info,
544 const char *output_path) {
545 if (!ctx || !info || !output_path)
546 return -1;
547 FILE *f = fopen(output_path, "wb");
548 if (!f) { perror("[exFAT] fopen output"); return -1; }
549 int rc = exfat_read_file_data(ctx, info, write_cb_file, f);
550 fclose(f);
551 return rc;
552}
553 
554int exfat_extract_file_fd(exfat_context_t *ctx, const exfat_file_info_t *info,
555 int output_fd) {
556 if (!ctx || !info || output_fd < 0)
557 return -1;
558 return exfat_read_file_data(ctx, info, write_cb_fd, &output_fd);
559}
560 
561/*
562 * Extract file content directly into a caller-provided buffer.
563 *
564 * @return number of bytes written on success, -1 on error (including
565 * truncation when buf_size < info->data_length).
566 */
567ssize_t exfat_extract_to_buffer(exfat_context_t *ctx,
568 const exfat_file_info_t *info,
569 uint8_t *buf, size_t buf_size) {
570 if (!ctx || !info || !buf || buf_size == 0)
571 return -1;
572 if (info->data_length > buf_size) {
573 fprintf(stderr, "[exFAT] buffer too small: need %llu, have %zu\n",
574 (unsigned long long)info->data_length, buf_size);
575 return -1;
576 }
577 mem_ctx_t m = { buf, 0, buf_size };
578 if (exfat_read_file_data(ctx, info, write_cb_mem, &m) != 0)
579 return -1;
580 return (ssize_t)m.pos;
581}
582 
583/* ── Context lifecycle ──────────────────────────────────────────────────── */
584 
585int exfat_init(exfat_context_t *ctx, const char *image_path) {
586 if (!ctx || !image_path)
587 return -1;
588 
589 memset(ctx, 0, sizeof(exfat_context_t));
590 ctx->image_file = fopen(image_path, "rb");
591 if (!ctx->image_file) {
592 perror("[exFAT] fopen image");
593 return -1;
594 }
595 if (exfat_read_boot_sector(ctx) != 0 || exfat_read_fat(ctx) != 0) {
596 exfat_cleanup(ctx);
597 return -1;
598 }
599 return 0;
600}
601 
602void exfat_cleanup(exfat_context_t *ctx) {
603 if (!ctx) return;
604 if (ctx->image_file) {
605 fclose(ctx->image_file);
606 ctx->image_file = NULL;
607 }
608 exfat_free_fat(ctx);
609}
610 
611/* ── Utility ────────────────────────────────────────────────────────────── */
612 
613void exfat_print_file_info(const exfat_file_info_t *info) {
614 if (!info) return;
615 char sz[32];
616 exfat_format_size(info->data_length, sz, sizeof(sz));
617 printf("%s %-40s %10s cluster=%u%s\n",
618 info->is_directory ? "DIR " : "FILE",
619 info->filename, sz, info->first_cluster,
620 info->no_fat_chain ? " [contiguous]" : "");
621}
622 
623time_t exfat_dos_time_to_unix(uint32_t dos_time) {
624 struct tm t;
625 memset(&t, 0, sizeof(t));
626 t.tm_sec = (int)((dos_time & 0x1FU) * 2);
627 t.tm_min = (int)((dos_time >> 5) & 0x3FU);
628 t.tm_hour = (int)((dos_time >> 11) & 0x1FU);
629 t.tm_mday = (int)((dos_time >> 16) & 0x1FU);
630 t.tm_mon = (int)(((dos_time >> 21) & 0x0FU) - 1U);
631 t.tm_year = (int)(((dos_time >> 25) & 0x7FU) + 80U);
632 t.tm_isdst = -1;
633 return mktime(&t);
634}
635 
636void exfat_format_size(uint64_t size, char *buf, size_t buf_size) {
637 if (!buf || buf_size == 0) return;
638 static const char *units[] = { "B", "KB", "MB", "GB", "TB" };
639 double d = (double)size;
640 int u = 0;
641 while (d >= 1024.0 && u < 4) { d /= 1024.0; u++; }
642 snprintf(buf, buf_size, "%.2f %s", d, units[u]);
643}
644