Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

# 0.11.0

- Keep archive member metadata consistent with the first stored entry selected for content reads
when normalized member names repeat.

- Adapt ordered content-output scheduling to heavy batches, apply byte backpressure, and share the matcher/coordinator CPU allowance while preserving whole-file validation and cancellation.

- Avoid redundant 64 KiB buffer clearing in legacy archive extraction.
Expand Down
4 changes: 4 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,10 @@ EP/JC/P work; candidates are not speedup claims until measured on both platforms
+27% on Mac and +11% on Linux. Eliminate those costs without arbitrary count/size thresholds,
preserving identity, budgets, duplicate semantics, exact-content oracles and the rejected
root-offset variant. Native source identity and streamed-ZIP sizes remain separate open work.
- [x] O19b4a: Make archive index metadata follow the first normalized stored header, matching
content selection before retaining payload locators. Preserve synthesized-parent replacement,
first symlink targets, file/directory types, unique listings and direct-child counts with
dedicated regression tests.
- [ ] O19b4: Qualify reuse of validated plain-TAR payload locators independently of name
preparation. The 10,000-member native search still spends roughly 8-13 seconds replaying
headers. Preserve format validation, exact source identity, duplicate selection, nested and
Expand Down
5 changes: 5 additions & 0 deletions docs/archive-safety-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ without a suitable profile. A dry run can preview supported actions, but it does
blocks. Inspection does not require `--archive-extract`; that flag materializes members for child
commands and therefore involves additional writing and execution capabilities.

When multiple stored members normalize to the same virtual path, inspection uses the first
stored member for both metadata and content. Directory listings show that path once. A directory
synthesized from a child can still receive its first explicit stored entry; later duplicates do
not replace it. This reader rule does not change packing collision or archive-rewrite policy.

## Create a new archive

```sh
Expand Down
17 changes: 17 additions & 0 deletions extra_modules/archive/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,23 @@ cc_test(
],
)

cc_test(
name = "archive_duplicate_metadata_test",
srcs = ["archive_duplicate_metadata_test.cc"],
deps = [
":archive_fs_cc",
":memory_archive_test_cc",
"@abseil-cpp//absl/status",
"@abseil-cpp//absl/status:statusor",
"@googletest//:gtest",
"@googletest//:gtest_main",
"@mboworks_mbo//mbo/status:status_macros_cc",
"@mboworks_mbo//mbo/testing:matchers_cc",
"@mboworks_mbo//mbo/testing:status_cc",
"@xff_extras_api//:vfs_cc",
],
)

cc_test(
name = "archive_member_key_test",
srcs = ["archive_member_key_test.cc"],
Expand Down
159 changes: 159 additions & 0 deletions extra_modules/archive/archive_duplicate_metadata_test.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors
// SPDX-License-Identifier: Apache-2.0

#include <array>
#include <span>
#include <string_view>
#include <utility>

#include "absl/status/status.h"
#include "absl/status/statusor.h"
#include "gmock/gmock.h"
#include "gtest/gtest.h"
#include "mbo/status/status_macros.h"
#include "mbo/testing/matchers.h"
#include "mbo/testing/status.h"
#include "xff/archive/archive_fs.h"
#include "xff/archive/memory_archive_test.h"
#include "xff/vfs/entry.h"
#include "xff/vfs/filesystem.h"

namespace xff::archive {
namespace {
using ::mbo::testing::EqualsText;
using ::mbo::testing::IsOk;
using ::mbo::testing::IsOkAndHolds;
using ::mbo::testing::StatusIs;
using ::testing::AllOf;
using ::testing::ElementsAre;
using ::testing::Eq;
using ::testing::Field;
using ::testing::SizeIs;

struct ArchiveDuplicateMetadataTest : ::testing::Test {
static constexpr std::string_view kContainer = "duplicates.tar";
static constexpr std::string_view kMember = "duplicates.tar!shared";

static absl::StatusOr<ArchiveFileSystem> Open(std::span<const MemoryArchiveMember> members) {
MBO_ASSIGN_OR_RETURN(auto bytes, MakeMemoryTar(members));
return ArchiveFileSystem::OpenBytes(kContainer, std::move(bytes));
}
};

TEST_F(ArchiveDuplicateMetadataTest, FirstRegularSizeDescribesTheContentAndDirectoryObservation) {
const auto members = std::to_array<MemoryArchiveMember>({
{
.path = "./shared",
.content = "x",
},
{
.path = "shared",
.content = "replacement",
},
});
MBO_ASSERT_OK_AND_ASSIGN(const auto fs, Open(members));
EXPECT_THAT(fs.Stat(kMember, false), IsOkAndHolds(Field("size", &vfs::Metadata::size, Eq(1U))));
EXPECT_THAT(fs.ReadContent(kMember), IsOkAndHolds(EqualsText("x")));
MBO_ASSERT_OK_AND_ASSIGN(const auto observed, fs.ReadDirMetadata(kContainer, {}));
EXPECT_THAT(observed.entries, SizeIs(1));
EXPECT_THAT(
observed.observations,
ElementsAre(AllOf(
Field("status", &vfs::MetadataObservation::status, IsOk()),
Field("value", &vfs::MetadataObservation::value, Field("size", &vfs::Metadata::size, Eq(1U))))));
}

TEST_F(ArchiveDuplicateMetadataTest, FirstRegularFileIsNotReplacedByADirectory) {
const auto members = std::to_array<MemoryArchiveMember>({
{
.path = "shared",
.content = "first",
},
{
.path = "./shared/",
.directory = true,
},
});
MBO_ASSERT_OK_AND_ASSIGN(const auto fs, Open(members));
EXPECT_THAT(
fs.Stat(kMember, false), IsOkAndHolds(AllOf(
Field("type", &vfs::Metadata::type, Eq(vfs::FileType::kRegular)),
Field("mode", &vfs::Metadata::mode, Eq(0640U)))));
EXPECT_THAT(fs.ReadContent(kMember), IsOkAndHolds(EqualsText("first")));
}

TEST_F(ArchiveDuplicateMetadataTest, FirstDirectoryIsNotReplacedByARegularFile) {
const auto members = std::to_array<MemoryArchiveMember>({
{
.path = "shared/",
.directory = true,
},
{
.path = "./shared",
.content = "later",
},
});
MBO_ASSERT_OK_AND_ASSIGN(const auto fs, Open(members));
EXPECT_THAT(
fs.Stat(kMember, false), IsOkAndHolds(AllOf(
Field("type", &vfs::Metadata::type, Eq(vfs::FileType::kDirectory)),
Field("mode", &vfs::Metadata::mode, Eq(0755U)))));
EXPECT_THAT(fs.ReadContent(kMember), StatusIs(absl::StatusCode::kFailedPrecondition));
}

TEST_F(ArchiveDuplicateMetadataTest, FirstSymlinkIsNotReplacedByARegularFile) {
const auto members = std::to_array<MemoryArchiveMember>({
{
.path = "./shared",
.symlink_target = "first-target",
},
{
.path = "shared",
.content = "later",
},
});
MBO_ASSERT_OK_AND_ASSIGN(const auto fs, Open(members));
EXPECT_THAT(fs.Stat(kMember, false), IsOkAndHolds(Field("type", &vfs::Metadata::type, Eq(vfs::FileType::kSymlink))));
EXPECT_THAT(fs.ReadLink(kMember), IsOkAndHolds(EqualsText("first-target")));
EXPECT_THAT(fs.ReadContent(kMember), StatusIs(absl::StatusCode::kFailedPrecondition));
}

TEST_F(ArchiveDuplicateMetadataTest, FirstSymlinkTargetIsRetained) {
const auto members = std::to_array<MemoryArchiveMember>({
{
.path = "shared",
.symlink_target = "first-target",
},
{
.path = "./shared",
.symlink_target = "later-target",
},
});
MBO_ASSERT_OK_AND_ASSIGN(const auto fs, Open(members));
EXPECT_THAT(fs.ReadLink(kMember), IsOkAndHolds(EqualsText("first-target")));
}

TEST_F(ArchiveDuplicateMetadataTest, FirstStoredDirectoryReplacesItsSynthesizedPlaceholder) {
const auto members = std::to_array<MemoryArchiveMember>({
{
.path = "shared/leaf",
},
{
.path = "./shared/",
.directory = true,
},
{
.path = "shared",
.content = "later",
},
});
MBO_ASSERT_OK_AND_ASSIGN(const auto fs, Open(members));
EXPECT_THAT(
fs.Stat(kMember, false), IsOkAndHolds(AllOf(
Field("type", &vfs::Metadata::type, Eq(vfs::FileType::kDirectory)),
Field("mode", &vfs::Metadata::mode, Eq(0755U)))));
EXPECT_THAT(fs.ReadDir(kMember), IsOkAndHolds(SizeIs(1)));
}

} // namespace
} // namespace xff::archive
8 changes: 7 additions & 1 deletion extra_modules/archive/archive_fs.cc
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <optional>
#include <ranges>
Expand Down Expand Up @@ -305,7 +306,7 @@ ArchiveFileSystem ArchiveFileSystem::Index(
node.metadata.type = is_dir ? vfs::FileType::kDirectory
: member.is_symlink ? vfs::FileType::kSymlink
: vfs::FileType::kRegular;
node.metadata.size = static_cast<std::uint64_t>(member.size < 0 ? 0 : member.size);
node.metadata.size = static_cast<std::uint64_t>(std::max<std::int64_t>(0, member.size));
node.metadata.mode = member.mode != 0 ? member.mode : (is_dir ? 0555U : 0444U);
node.metadata.mtime = absl::FromUnixSeconds(member.mtime);
node.metadata.dev = device;
Expand All @@ -317,6 +318,11 @@ ArchiveFileSystem ArchiveFileSystem::Index(
node.metadata.source = vfs::Source::kArchiveMember;
node.link_target = member.link_target;
const auto [entry_it, inserted] = fs.nodes_.try_emplace(key);
// Content readers select the first stored header. A synthesized ancestor may still
// receive its first explicit header, without allowing later duplicates to replace it.
if (!inserted && !entry_it->second.synthesized) {
continue;
}
const std::uint32_t direct_children = entry_it->second.direct_child_count;
entry_it->second = std::move(node);
entry_it->second.direct_child_count = direct_children;
Expand Down
Loading