extractFileToDisk function
Extracts the archive at inputPath into outputPath
throwOnError throws ArchiveException on damaged or cut data.
verify also checks checksums at a tiny cost of ~5-10% speed decrease
depending on archive file, and a mismatch throws
ArchiveChecksumException. throwOnError defaults to verify, so
verify alone throws too; pass throwOnError: false with it to have
failures reported without throwing.
Without throwOnError, decodeBytes returns partial data and decodeStream
returns false.
If neither option is specified, damaged or incomplete entries are skipped, and only complete ones are extracted
callback is called for each entry once it has been written, and not for
an entry that is refused, such as one whose path points outside
outputPath. A symbolic link is reported when it is recorded: links are
created after all other entries, and one that then resolves outside
outputPath is removed. For a compressed tar the entry's content has gone
by then and cannot be read from the entry; it is in the file on disk.
A symlink to an absolute path points outside outputPath, so it is
skipped unless allowAbsoluteSymlinks is set
maxSize limits the total size of the files written. An entry that would
take it past that throws an ArchiveLimitException before it is written.
A zip entry is never decoded past the size the archive gives for it.
Implementation
Future<void> extractFileToDisk(String inputPath, String outputPath,
{String? password,
int? bufferSize,
ArchiveCallback? callback,
bool verify = false,
bool? throwOnError,
bool allowAbsoluteSymlinks = false,
int? maxSize}) async {
final strict = throwOnError ?? verify;
final archivePath = inputPath;
const String extensionMsg =
'.tar.gz, .tgz, .tar.bz2, .tbz, .tar.xz, .txz, .tar.zst, .tzst, .tar '
'or .zip';
// get the extension of the input file with up to 2 components
// e.g. for file.tar.gz, it will return '.tar.gz'
final archiveExt = getInputExtension(archivePath);
// Check header to avoid attempts for all formats
RandomAccessFile? raf;
Uint8List headerBytes;
try {
raf = await File(inputPath).open(mode: FileMode.read);
headerBytes = await raf.read(CodecsRecognizer.headerBytes);
} catch (_) {
headerBytes = Uint8List(0);
} finally {
try {
await raf?.close();
} catch (_) {
/* Do nothing */
}
}
ArchiveFormat recognized = CodecsRecognizer.recognize(headerBytes);
if (recognized == ArchiveFormat.unknown) {
for (final ArchiveFormat format in ArchiveFormat.values) {
final ArchiveExtension? aExt = CodecsRecognizer.extensionOf(format);
if (aExt == null) continue;
if (archiveExt == '.${aExt.defaultName}' ||
(aExt.tar != null && archiveExt == '.${aExt.tar}') ||
(aExt.tarShort != null && archiveExt == '.${aExt.tarShort}')) {
recognized = format;
break;
}
}
}
if (recognized == ArchiveFormat.unknown) {
throw ArgumentError.value(
inputPath,
'inputPath',
'No file extension detected, must end with $extensionMsg',
);
}
// Each of these throws where the archive ran out part way through.
// If neither `verify` nor `throwOnError` is set, exceptions are suppressed,
// and only complete tar entries are extracted
final InputStream Function(InputStream input)? lazy = switch (recognized) {
ArchiveFormat.gzip => (input) =>
GZipDecoder().decodeLazy(input, verify: verify),
ArchiveFormat.bzip2 => (input) =>
BZip2Decoder().decodeLazy(input, verify: verify),
ArchiveFormat.xz => (input) =>
XZDecoder().decodeLazy(input, verify: verify),
ArchiveFormat.zstd => (input) =>
ZstdDecoder().decodeLazy(input, verify: verify),
_ => null,
};
InputStream? toClose;
try {
if (lazy != null || recognized == ArchiveFormat.tar) {
final input = InputFileStream(inputPath);
toClose = input;
final writer = EntryWriter(outputPath,
bufferSize: bufferSize,
maxSize: maxSize,
setPermissions: true,
throwOnError: strict,
allowAbsoluteSymlinks: allowAbsoluteSymlinks);
Object? callbackError;
void write(ArchiveFile file) {
// Rejected paths and failed entries are not extracted, so they must
// not be reported as successfully written to a caller.
if (writer.write(file) == null) {
return;
}
if (callback != null) {
try {
callback(file);
} catch (error) {
callbackError = error;
rethrow;
}
}
}
// tar has no magic bytes. The file under the gzip or zstd might not be a
// tar at all. The header checksum rejects it. Without the check a
// .sql.gz is extracted as tar entries
// Truncated tar entry is deleted once it is written. bsdtar and python
// tarfile leave partial file on disk, but they report error too
try {
final tar = lazy?.call(input) ?? input;
final first = tar.peekBytes(512).toUint8List();
if (first.any((b) => b != 0) &&
(first.length < 512 ||
!tarHeaderChecksumMatches(first) ||
!tarHeaderSizeIsNumber(first))) {
throw ArgumentError.value(
inputPath, 'inputPath', 'Is not a tar archive');
}
TarDecoder().decodeStream(tar,
verify: true,
throwOnError: true,
keepEntries: false,
callback: write);
// The tar ends before the compressed data does, and the decoders only
// find a cut or damaged end once they reach it
if (strict && lazy != null) {
while (!tar.isEOS) {
tar.readBytes(1 << 16).toUint8List();
}
}
} catch (error) {
if (strict ||
!isDecodeDataError(error) ||
error is ArchiveLimitException ||
identical(error, callbackError) ||
identical(error, writer.limitError)) {
rethrow;
}
}
writer.finish();
return;
}
Archive archive;
if (recognized == ArchiveFormat.zip) {
final input = InputFileStream(archivePath);
toClose = input;
archive = ZipDecoder().decodeStream(input,
verify: verify, throwOnError: strict, password: password);
} else {
throw ArgumentError.value(
inputPath, 'inputPath', 'Must end $extensionMsg');
}
final writer = EntryWriter(outputPath,
bufferSize: bufferSize,
maxSize: maxSize,
setPermissions: true,
throwOnError: strict,
allowAbsoluteSymlinks: allowAbsoluteSymlinks);
for (final file in archive) {
// As for tar, an entry refused for pointing outside [outputPath] is not
// reported as written
if (writer.write(file) != null) {
callback?.call(file);
}
}
writer.finish();
await archive.clear();
} finally {
// The input handle is scoped to this call. If an error occurs midway, it
// is closed here
await toClose?.close();
}
}