Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion extra_modules/archive/archive_reader.cc
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
#include <archive.h>
#include <archive_entry.h>

#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <memory>
Expand All @@ -33,6 +35,10 @@
namespace xff::archive {
namespace {

// The streaming block size for both the header walk and the content read: one value, so the two
// entry points cannot drift apart.
constexpr std::size_t kBlockSize = 64 * 1'024;

// libarchive's BSD-2-Clause notice, plus the permissive codec closure it links (zlib, bzip2,
// liblzma, lz4, zstd). Registered from this TU, so the notice appears exactly in the builds that
// link the archive extra. mbedtls is deliberately not enabled, so no crypto arm is listed.
Expand Down Expand Up @@ -131,12 +137,81 @@ absl::StatusOr<std::vector<Member>> ListMembersOfFile(std::string_view path) {
}
// 64 KiB blocks: large enough to keep syscalls down on big archives, small enough that listing a
// tiny one costs nothing. The C API needs a NUL-terminated path.
constexpr std::size_t kBlockSize = 64 * 1'024;
const std::string path_string(path);
if (::archive_read_open_filename(handle.get(), path_string.c_str(), kBlockSize) != ARCHIVE_OK) {
return absl::InvalidArgumentError(absl::StrCat("not a readable archive: ", LastError(handle.get())));
}
return ReadMembers(handle.get());
}

namespace {

// The member name in comparable form. Tar writes the SAME member several ways: `dir/x` and `./dir/x`
// for a file, and a directory as `dir/` with a trailing slash - so a lookup for `dir` must find it
// (and then be told it has no content, rather than "no such member").
std::string_view NormalizedMemberName(std::string_view path) {
while (path.starts_with("./")) {
path.remove_prefix(2);
}
while (path.size() > 1 && path.ends_with('/')) {
path.remove_suffix(1);
}
return path;
}

} // namespace

absl::StatusOr<std::string> ReadMemberOfFile(std::string_view path, std::string_view member, std::uint64_t max_bytes) {
const ArchivePtr handle = NewReader();
if (handle == nullptr) {
return absl::ResourceExhaustedError("cannot allocate a libarchive reader");
}
const std::string path_string(path);
if (::archive_read_open_filename(handle.get(), path_string.c_str(), kBlockSize) != ARCHIVE_OK) {
return absl::InvalidArgumentError(absl::StrCat("not a readable archive: ", LastError(handle.get())));
}
const std::string_view wanted = NormalizedMemberName(member);
struct ::archive_entry* entry = nullptr;
while (true) {
const int status = ::archive_read_next_header(handle.get(), &entry);
if (status == ARCHIVE_EOF) {
return absl::NotFoundError(absl::StrCat("no such member in ", path, ": ", member));
}
if (status != ARCHIVE_OK && status != ARCHIVE_WARN) {
return absl::DataLossError(absl::StrCat("archive read failed: ", LastError(handle.get())));
}
const char* const stored = ::archive_entry_pathname(entry);
if (stored == nullptr || NormalizedMemberName(stored) != wanted) {
continue; // not this one; libarchive skips its data on the next header read
}
if (::archive_entry_filetype(entry) != AE_IFREG) {
// A directory or symlink has no content. Saying so beats returning an empty string, which a
// content predicate could not distinguish from a genuinely empty file.
return absl::FailedPreconditionError(absl::StrCat("member is not a regular file: ", member));
}
std::string contents;
// The header's size is a HINT for reserve() only - never a trusted length. A crafted archive can
// understate it, so the loop below is what actually bounds the read.
const std::int64_t hint = ::archive_entry_size(entry);
if (hint > 0) {
const std::uint64_t reserve = static_cast<std::uint64_t>(hint);
contents.reserve(max_bytes != 0 ? std::min<std::uint64_t>(reserve, max_bytes) : reserve);
}
std::array<char, kBlockSize> buffer{};
while (true) {
const ::ssize_t read = ::archive_read_data(handle.get(), buffer.data(), buffer.size());
if (read == 0) {
return contents; // end of this member's data
}
if (read < 0) {
return absl::DataLossError(absl::StrCat("reading member ", member, " failed: ", LastError(handle.get())));
}
if (max_bytes != 0 && contents.size() + static_cast<std::uint64_t>(read) > max_bytes) {
return absl::ResourceExhaustedError(absl::StrCat("member ", member, " exceeds the ", max_bytes, " byte limit"));
}
contents.append(buffer.data(), static_cast<std::size_t>(read));
}
}
}

} // namespace xff::archive
21 changes: 21 additions & 0 deletions extra_modules/archive/archive_reader.h
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,27 @@ absl::StatusOr<std::vector<Member>> ListMembers(std::string_view bytes);
// whole archive in memory (the form the walk uses). Same error contract as ListMembers.
absl::StatusOr<std::vector<Member>> ListMembersOfFile(std::string_view path);

// Reads ONE member's uncompressed content out of the archive file at `path`. This is what lets the
// content predicates (`-grep`, `-content`, `{hash}`) work on members; listing alone cannot, since it
// deliberately never touches member data.
//
// `member` is matched against the stored path in normalized form: a leading `./` is ignored on either
// side, and so is a trailing `/`, because tar writes the same member several ways (`dir/x` vs
// `./dir/x`, and a directory as `dir/`). So a lookup for `dir` FINDS the directory and is told it has
// no content, rather than misreporting "no such member". Streaming, single pass, stopping at the
// match, so reading an early member of a huge archive does not decompress the rest.
//
// Errors, all distinguishable on purpose: InvalidArgument when `path` is not an archive libarchive
// can open, NotFound when the archive has no such member, FailedPrecondition when the member exists
// but is not a regular file (a directory or symlink has no content to read), DataLoss when the
// archive opens but the read fails part way, and ResourceExhausted when `max_bytes` (0 = unlimited)
// would be exceeded - a decompression-bomb guard, since a small member header can promise a huge
// expansion.
absl::StatusOr<std::string> ReadMemberOfFile(
std::string_view path,
std::string_view member,
std::uint64_t max_bytes = 0);

} // namespace xff::archive

#endif // XFF_ARCHIVE_ARCHIVE_READER_H_
69 changes: 69 additions & 0 deletions extra_modules/archive/archive_reader_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@
#include <archive_entry.h>

#include <cstddef>
#include <cstdlib>
#include <fstream>
#include <string>
#include <string_view>
#include <vector>

#include "absl/status/status.h"
#include "absl/strings/str_cat.h"
#include "gmock/gmock.h"
#include "gtest/gtest.h"
#include "mbo/testing/status.h"
Expand Down Expand Up @@ -76,6 +79,34 @@ struct ArchiveReaderTest : ::testing::Test {
buffer.resize(used);
return buffer;
}

// The same bytes on disk: ReadMemberOfFile / ListMembersOfFile stream from a path, so a file is
// needed - built from MakeArchive so both entry points see byte-identical input.
static std::string WriteArchive(const std::vector<FileSpec>& files, std::string_view name) {
const char* const tmp = std::getenv("TEST_TMPDIR");
const std::string path = absl::StrCat(tmp != nullptr ? tmp : "/tmp", "/", name);
const std::string bytes = MakeArchive(files);
std::ofstream(path, std::ios::binary).write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
return path;
}

// A tar carrying an explicit DIRECTORY member, for the "no content to read" case.
static std::string WriteArchiveWithDirectory(std::string_view name) {
const char* const tmp = std::getenv("TEST_TMPDIR");
const std::string path = absl::StrCat(tmp != nullptr ? tmp : "/tmp", "/", name);
struct ::archive* out = ::archive_write_new();
::archive_write_set_format_pax_restricted(out);
::archive_write_open_filename(out, path.c_str());
struct ::archive_entry* dir = ::archive_entry_new();
::archive_entry_set_pathname(dir, "dir");
::archive_entry_set_filetype(dir, AE_IFDIR);
::archive_entry_set_perm(dir, 0755);
::archive_write_header(out, dir);
::archive_entry_free(dir);
::archive_write_close(out);
::archive_write_free(out);
return path;
}
};

TEST_F(ArchiveReaderTest, ListsTarMembersWithTheirPathsAndSizes) {
Expand Down Expand Up @@ -120,5 +151,43 @@ TEST_F(ArchiveReaderTest, RegistersItsLicenseNotice) {
EXPECT_THAT(license::Notices(), Contains(Field("component", &license::Notice::component, "libarchive")));
}

// ReadMemberOfFile: the entry point the content predicates need. Each error state is distinct on
// purpose, so a caller can tell "no such member" from "member has no content" from "bomb guard".
TEST_F(ArchiveReaderTest, ReadMemberOfFileReturnsTheMemberContent) {
const std::string tar = WriteArchive({{.path = "hello.txt", .content = "hello\n"}}, "read.tar");
EXPECT_THAT(ReadMemberOfFile(tar, "hello.txt"), IsOkAndHolds("hello\n"));
}

TEST_F(ArchiveReaderTest, ReadMemberOfFileIgnoresALeadingDotSlashOnEitherSide) {
// Tar streams write both spellings for the same member, so neither side may be authoritative.
const std::string tar = WriteArchive({{.path = "hello.txt", .content = "hello\n"}}, "dotslash.tar");
EXPECT_THAT(ReadMemberOfFile(tar, "./hello.txt"), IsOkAndHolds("hello\n"));
}

TEST_F(ArchiveReaderTest, ReadMemberOfFileReportsAMissingMemberAsNotFound) {
const std::string tar = WriteArchive({{.path = "hello.txt", .content = "hello\n"}}, "missing.tar");
EXPECT_THAT(ReadMemberOfFile(tar, "nope.txt"), StatusIs(absl::StatusCode::kNotFound));
}

TEST_F(ArchiveReaderTest, ReadMemberOfFileRefusesSomethingWithNoContent) {
// A directory member: FailedPrecondition, not an empty string - a content predicate could not
// distinguish an empty string here from a genuinely empty file.
const std::string tar = WriteArchiveWithDirectory("dir.tar");
EXPECT_THAT(ReadMemberOfFile(tar, "dir"), StatusIs(absl::StatusCode::kFailedPrecondition));
}

TEST_F(ArchiveReaderTest, ReadMemberOfFileRejectsANonArchive) {
EXPECT_THAT(ReadMemberOfFile("/etc/hosts", "anything"), StatusIs(absl::StatusCode::kInvalidArgument));
}

TEST_F(ArchiveReaderTest, ReadMemberOfFileEnforcesTheByteLimit) {
// The bomb guard: a small header can promise a huge expansion, so the LOOP bounds the read rather
// than trusting the declared size.
const std::string tar = WriteArchive({{.path = "hello.txt", .content = "hello\n"}}, "limit.tar");
EXPECT_THAT(ReadMemberOfFile(tar, "hello.txt", /*max_bytes=*/2), StatusIs(absl::StatusCode::kResourceExhausted));
// The limit is inclusive: content exactly at the limit is fine.
EXPECT_THAT(ReadMemberOfFile(tar, "hello.txt", /*max_bytes=*/6), IsOkAndHolds("hello\n"));
}

} // namespace
} // namespace xff::archive
Loading