CVE-2026-119266
Overview
Background
- `StorageDirectory`
- Browser-process helper (
storage/browser/quota/storage_directory.cc) that manages the on-diskWebStoragebucket directories, including “dooming” (atomically renaming aside) and later clearing them. - `DoomPath()` / `DoomBucket()` / `Doom()`
- “Doom” renames a live directory to a sibling
<name>-doomedname so it can be deleted asynchronously byClearDoomed*(). - `base::Move` fallback
base::Move(src, dst)first triesrename(2); if that fails (e.g.EISDIRwhendstalready exists as a directory) it falls back tobase::CopyDirectory, which follows symbolic links and copies the link target’s contents.- `base::ReplaceFile` (POSIX)
- Performs a direct
rename(2)with no copy fallback, so a symlink is renamed (not followed). - `base::FileEnumerator::DIRECTORIES` vs `NAMES_ONLY`
DIRECTORIESskips entries that are symlinks to files / nonexistent targets;NAMES_ONLYreturns every matching name.- `QuotaDatabase::DeleteBucketData`
- Dooms a bucket directory then deletes the DB row; early-returns on error.
Root Cause Analysis
StorageDirectory::DoomPath() pre-created the destination with base::CreateTemporaryDirInDir(...) and then called base::Move(path, doomed_dir). When path had been swapped for a symbolic link, rename(2) onto the already-existing doomed_dir directory fails with EISDIR, so base::Move fell back to the symlink-following base::CopyDirectory, copying the link target’s contents into the profile’s WebStorage area — i.e. the browser process reads/writes through an attacker-planted link to an external location. Two related defects compounded it: DoomPath guarded with base::PathExists(path) (which follows symlinks), so a dangling symlink returned early and was never removed; and EnumerateDoomedDirectories/EnumerateDoomedBuckets used base::FileEnumerator::DIRECTORIES, which skips doomed symlinks-to-files or broken links, so ClearDoomed*() never cleaned them up (leaving a persistent link and leaking the follow behavior). Separately, QuotaDatabase::DeleteBucketData early-returned on failure without calling ClearDoomedBuckets(), leaving doomed entries uncleared.
The fix rewrites DoomPath to rename path onto a fresh, non-pre-created <name>-doomed-<uuid> name: on POSIX via base::ReplaceFile (a direct rename(2) with no copy fallback — a real directory moves, a planted symlink is renamed without being followed and later unlinked), and on non-POSIX via base::DeletePathRecursively for links or base::Move onto the non-existent target. The existence guard now also checks base::IsLink(path) so dangling symlinks are doomed too. Both enumerators switch to NAMES_ONLY so any doomed entry (including symlinks) reaches base::DeletePathRecursively. DeleteBucketData now calls ClearDoomedBuckets() on its error-return paths. POSIX regression tests assert that dooming a bucket/storage symlink renames the link (not the target), clears it, and leaves the external target file untouched.
The advisory’s “Use after free in Quota” is not substantiated by the diff: there is no object-lifetime/freed-memory bug. The code change fixes a symlink-following file operation (via base::Move’s CopyDirectory fallback) and incomplete symlink cleanup/enumeration in the browser process — a filesystem/path-handling flaw. Noted as an advisory/diff discrepancy.
base::Move, whose rename(2)→CopyDirectory fallback follows symlinks; a symlink planted where a bucket/storage directory was expected would be followed by the browser process, copying an external target into the profile, and broken/file symlinks escaped both the existence guard and the doomed-entry cleanup.Attack Path
- Plant a symlink in WebStorage
Attacker with write access to the profile’s
WebStoragearea replaces a bucket (or storage) directory path with a symbolic link to an external target (inferred precondition; the diff proves the following/cleanup behavior). - Trigger a doom
Normal operation dooms that path:
DoomBucket/Doom→DoomPath→base::Move(path, doomed_dir)wheredoomed_dirwas just pre-created as a directory. - `rename(2)` fails, copy fallback follows the link
EISDIRmakesbase::Movefall back tobase::CopyDirectory, which resolves the symlink and copies the external target’s contents into the profile; dangling links instead escape via thePathExistsguard and persist. - Arbitrary read-into-profile / persistent link The browser process ingests attacker-chosen external content (information disclosure into the profile) and/or leaves an uncleared link, with browser-process privileges.
Impact Assessment
WebStorage directory (local attacker or a prior foothold), so it is a local integrity/disclosure issue rather than remote RCE. The gained primitive is symlink-directed directory copy/read performed by the browser process during bucket/storage dooming, plus incomplete cleanup of planted links. It is not a use-after-free despite the advisory label, and not RCE; strength is bounded by what the browser-process identity can reach via the planted link.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifstorage/browser/quota/quota_database.cc |
modified | |
TEST_Fstorage/browser/quota/storage_directory_unittest.cc |
modified |
Files Changed
storage/browser/quota/storage_directory.ccstorage/browser/quota/quota_database_unittest.ccstorage/browser/quota/storage_directory_unittest.ccstorage/browser/quota/quota_database.cc
Audit Directions
- `base::Move` copy fallbackAudit callers that
base::Moveonto a pre-existing/pre-created destination where the source could be attacker-controlled; theCopyDirectoryfallback follows symlinks. - Existence checks before deletionReplace
base::PathExists-only guards withIsLink-aware checks wherever dangling symlinks must still be removed (companion CVE-2026-119265 is the same class inMoveLegacyDatabase). - FileEnumerator filtersReview
FileEnumerator::DIRECTORIES/FILESuses in cleanup code that must also catch symlinks (preferNAMES_ONLY). - Error-path cleanupCheck storage error returns (
DeleteBucketDataand peers) for leaked doomed directories not passed toClearDoomed*().
Patch
From 71e66ee87824aafdddb32ec7971165f8aa7997cb Mon Sep 17 00:00:00 2001 From: Eriko Kurimoto <[email protected]> Date: Thu, 22 Oct 2026 23:51:34 -0700 Subject: [PATCH] Avoid CopyDirectory fallback in StorageDirectory::DoomPath Previously, StorageDirectory::DoomPath pre-created a temporary target directory with base::CreateTemporaryDirInDir and then called base::Move(path, doomed_dir). When `path` is a symbolic link, moving it onto an existing directory fails `rename(2)` with EISDIR and falls back to base::CopyDirectory, which follows the link. In addition: - base::PathExists follows symlinks, so a dangling symlink at `path` returned early from DoomPath without being removed. - EnumerateDoomedDirectories and EnumerateDoomedBuckets used base::FileEnumerator::DIRECTORIES, skipping doomed symlinks to files or nonexistent targets. - QuotaDatabase::DeleteBucketData returned early on failure without clearing doomed bucket directories. This CL: - Renames `path` onto a fresh `<name>-doomed-<uuid>` path without pre-creating the destination directory, using base::ReplaceFile on POSIX (so `rename(2)` does not fall back to CopyDirectory) and base::Move (after unlinking reparse points) on non-POSIX platforms, and checks base::IsLink so dangling symlinks are also doomed. - Uses base::FileEnumerator::NAMES_ONLY in EnumerateDoomedDirectories and EnumerateDoomedBuckets so any doomed entry is passed to base::DeletePathRecursively. - Calls ClearDoomedBuckets() on early-return error paths in QuotaDatabase::DeleteBucketData. TAG=agy CONV=b03dcafb-e302-4850-a029-78a6d8b7c1b2 Bug: 575202553 Change-Id: I575202553a7f4070f631e29a8b0ab2041285d940 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8603890 Auto-Submit: Eriko Kurimoto <[email protected]> Reviewed-by: Ming-Ying Chung <[email protected]> Commit-Queue: Ming-Ying Chung <[email protected]> Cr-Commit-Position: refs/heads/main@{#1725545} --- diff --git a/storage/browser/quota/quota_database.cc b/storage/browser/quota/quota_database.cc index 85e8da6..b4d082a 100644 --- a/storage/browser/quota/quota_database.cc +++ b/storage/browser/quota/quota_database.cc @@ -586,6 +586,7 @@ // directory deletion fails. `storage_directory_` may be nullptr for // in-memory only. if (storage_directory_ && !storage_directory_->DoomBucket(bucket)) { + storage_directory_->ClearDoomedBuckets(); return base::unexpected(QuotaError::kFileOperationError); } @@ -596,6 +597,9 @@ statement.BindInt64(0, bucket.id.value()); if (!statement.Step()) { + if (storage_directory_) { + storage_directory_->ClearDoomedBuckets(); + } return base::unexpected(QuotaError::kDatabaseError); } diff --git a/storage/browser/quota/storage_directory.cc b/storage/browser/quota/storage_directory.cc index cc7fc74..8d88c0b 100644 --- a/storage/browser/quota/storage_directory.cc +++ b/storage/browser/quota/storage_directory.cc @@ -8,6 +8,8 @@ #include "base/files/file_util.h" #include "base/notreached.h" #include "base/strings/strcat.h" +#include "base/uuid.h" +#include "build/build_config.h" #include "components/services/storage/public/cpp/constants.h" #include "storage/browser/quota/storage_directory_util.h" @@ -59,20 +61,43 @@ } bool StorageDirectory::DoomPath(const base::FilePath& path) { - if (!base::PathExists(path)) + if (!base::PathExists(path) && !base::IsLink(path)) { return true; + } - base::FilePath doomed_dir; - base::CreateTemporaryDirInDir( - path.DirName(), base::StrCat({path.BaseName().value(), kDoomedPathName}), - &doomed_dir); + // Atomically rename `path` onto a fresh name that does not exist yet. + // + // Do not pre-create the destination directory: when `rename(2)` fails (e.g. + // with `EISDIR` on POSIX because `path` has been swapped for a symlink and + // the destination already exists as a directory), `base::Move()` falls back + // to the symlink-following `base::CopyDirectory()` and copies the link target + // into the profile directory. On POSIX, `base::ReplaceFile()` performs a + // direct `rename(2)` without a copy fallback: a real directory moves as + // before, while a planted symlink is renamed without being followed and then + // unlinked by `ClearDoomed*()`. On Windows, `base::ReplaceFile()` creates a + // temporary backup file and calls Win32 `::ReplaceFile()` (which fails for + // directories and logs a failed `Windows.ReplaceFileResult` histogram sample + // before falling back to `::MoveFile()`), so remove reparse points directly + // and use `base::Move()` onto the non-existent `doomed_dir`. + base::FilePath doomed_dir = path.DirName().Append(base::StrCat( + {path.BaseName().value(), kDoomedPathName, + base::FilePath::FromASCII( + base::Uuid::GenerateRandomV4().AsLowercaseString()) + .value()})); +#if BUILDFLAG(IS_POSIX) + return base::ReplaceFile(path, doomed_dir, /*error=*/nullptr); +#else + if (base::IsLink(path)) { + return base::DeletePathRecursively(path); + } return base::Move(path, doomed_dir); +#endif } std::set<base::FilePath> StorageDirectory::EnumerateDoomedDirectories() { base::FileEnumerator enumerator( web_storage_path_.DirName(), - /*recursive=*/false, base::FileEnumerator::DIRECTORIES, + /*recursive=*/false, base::FileEnumerator::NAMES_ONLY, base::StrCat( {kWebStorageDirectory, kDoomedPathName, FILE_PATH_LITERAL("*")})); @@ -85,7 +110,7 @@ std::set<base::FilePath> StorageDirectory::EnumerateDoomedBuckets() { base::FileEnumerator enumerator( - web_storage_path_, /*recursive=*/false, base::FileEnumerator::DIRECTORIES, + web_storage_path_, /*recursive=*/false, base::FileEnumerator::NAMES_ONLY, base::StrCat( {FILE_PATH_LITERAL("*"), kDoomedPathName, FILE_PATH_LITERAL("*")})); diff --git a/storage/browser/quota/quota_database_unittest.cc b/storage/browser/quota/quota_database_unittest.cc index c47a3f8c..b15b33d8 100644 --- a/storage/browser/quota/quota_database_unittest.cc +++ b/storage/browser/quota/quota_database_unittest.cc @@ -15,6 +15,7 @@ #include "base/compiler_specific.h" #include "base/containers/flat_map.h" +#include "base/files/file_enumerator.h" #include "base/files/file_util.h" #include "base/files/scoped_temp_dir.h" #include "base/functional/bind.h" @@ -814,6 +815,19 @@ ASSERT_TRUE(db->DeleteBucketData(bucket).has_value()); ASSERT_FALSE(base::PathExists(bucket_path)); + + // Deleting a bucket that is missing from the database should still clean up + // its doomed directory on the error path. + ASSERT_TRUE(base::CreateDirectory(bucket_path)); + ASSERT_TRUE( + base::WriteFile(bucket_path.AppendASCII("orphan"), "orphan_data")); + EXPECT_THAT(db->DeleteBucketData(bucket), + base::test::ErrorIs(QuotaError::kDatabaseError)); + EXPECT_FALSE(base::PathExists(bucket_path)); + base::FileEnumerator enumerator( + db->GetStoragePath(), /*recursive=*/false, + base::FileEnumerator::NAMES_ONLY, FILE_PATH_LITERAL("*-doomed-*")); + EXPECT_TRUE(enumerator.Next().empty()); } } diff --git a/storage/browser/quota/storage_directory_unittest.cc b/storage/browser/quota/storage_directory_unittest.cc index 7d727ea3..9337e47 100644 --- a/storage/browser/quota/storage_directory_unittest.cc +++ b/storage/browser/quota/storage_directory_unittest.cc @@ -9,6 +9,7 @@ #include "base/files/file_path.h" #include "base/files/file_util.h" #include "base/files/scoped_temp_dir.h" +#include "build/build_config.h" #include "components/services/storage/public/cpp/buckets/bucket_locator.h" #include "storage/browser/quota/quota_client_type.h" #include "storage/browser/quota/storage_directory.h" @@ -168,4 +169,106 @@ EXPECT_EQ(directories.size(), 0u); } +#if BUILDFLAG(IS_POSIX) +TEST_F(StorageDirectoryTest, DoomAndClearBucketSymlinkDoesNotFollowTarget) { + base::FilePath external_dir = + temp_directory_.GetPath().AppendASCII("external_target"); + ASSERT_TRUE(base::CreateDirectory(external_dir)); + base::FilePath secret_file = external_dir.AppendASCII("secret.txt"); + ASSERT_TRUE(base::WriteFile(secret_file, "secret_content")); + + ASSERT_TRUE(storage_directory_->Create()); + + BucketLocator bucket( + BucketId(1), + blink::StorageKey::CreateFromStringForTesting("http://example/"), + /*is_default=*/false); + base::FilePath bucket_path = + CreateBucketPath(storage_directory_->path().DirName(), bucket); + ASSERT_TRUE(base::CreateSymbolicLink(external_dir, bucket_path)); + + ASSERT_TRUE(storage_directory_->DoomBucket(bucket)); + EXPECT_FALSE(base::PathExists(bucket_path)); + EXPECT_FALSE(base::IsLink(bucket_path)); + + std::set<base::FilePath> doomed_buckets = + storage_directory_->EnumerateDoomedBucketsForTesting(); + ASSERT_EQ(doomed_buckets.size(), 1u); + // The doomed entry must be the renamed symlink itself, not a copied + // directory. + EXPECT_TRUE(base::IsLink(*doomed_buckets.begin())); + + storage_directory_->ClearDoomedBuckets(); + EXPECT_TRUE(storage_directory_->EnumerateDoomedBucketsForTesting().empty()); + + // The external target directory and file must remain untouched. + std::string contents; + EXPECT_TRUE(base::ReadFileToString(secret_file, &contents)); + EXPECT_EQ(contents, "secret_content"); +} + +TEST_F(StorageDirectoryTest, DoomAndClearBucketFileAndBrokenSymlinks) { + base::FilePath external_file = + temp_directory_.GetPath().AppendASCII("external_file.txt"); + ASSERT_TRUE(base::WriteFile(external_file, "file_content")); + base::FilePath nonexistent_target = + temp_directory_.GetPath().AppendASCII("nonexistent"); + + ASSERT_TRUE(storage_directory_->Create()); + + BucketLocator bucket1( + BucketId(1), + blink::StorageKey::CreateFromStringForTesting("http://example/"), + /*is_default=*/false); + BucketLocator bucket2( + BucketId(2), + blink::StorageKey::CreateFromStringForTesting("http://example/"), + /*is_default=*/false); + + base::FilePath bucket1_path = + CreateBucketPath(storage_directory_->path().DirName(), bucket1); + base::FilePath bucket2_path = + CreateBucketPath(storage_directory_->path().DirName(), bucket2); + ASSERT_TRUE(base::CreateSymbolicLink(external_file, bucket1_path)); + ASSERT_TRUE(base::CreateSymbolicLink(nonexistent_target, bucket2_path)); + + ASSERT_TRUE(storage_directory_->DoomBucket(bucket1)); + ASSERT_TRUE(storage_directory_->DoomBucket(bucket2)); + EXPECT_FALSE(base::IsLink(bucket1_path)); + EXPECT_FALSE(base::IsLink(bucket2_path)); + + std::set<base::FilePath> doomed_buckets = + storage_directory_->EnumerateDoomedBucketsForTesting(); + EXPECT_EQ(doomed_buckets.size(), 2u); + + storage_directory_->ClearDoomedBuckets(); + EXPECT_TRUE(storage_directory_->EnumerateDoomedBucketsForTesting().empty()); + EXPECT_TRUE(base::PathExists(external_file)); +} + +TEST_F(StorageDirectoryTest, DoomAndClearStorageSymlinkDoesNotFollowTarget) { + base::FilePath external_dir = + temp_directory_.GetPath().AppendASCII("external_target"); + ASSERT_TRUE(base::CreateDirectory(external_dir)); + base::FilePath secret_file = external_dir.AppendASCII("secret.txt"); + ASSERT_TRUE(base::WriteFile(secret_file, "secret_content")); + + ASSERT_TRUE( + base::CreateSymbolicLink(external_dir, storage_directory_->path())); + + ASSERT_TRUE(storage_directory_->Doom()); + EXPECT_FALSE(base::IsLink(storage_directory_->path())); + + std::set<base::FilePath> doomed_dirs = + storage_directory_->EnumerateDoomedDirectoriesForTesting(); + ASSERT_EQ(doomed_dirs.size(), 1u); + EXPECT_TRUE(base::IsLink(*doomed_dirs.begin())); + + storage_directory_->ClearDoomed(); + EXPECT_TRUE( + storage_directory_->EnumerateDoomedDirectoriesForTesting().empty()); + EXPECT_TRUE(base::PathExists(secret_file)); +} +#endif // BUILDFLAG(IS_POSIX) + } // namespace storage
Regression Test / PoC
diff --git a/storage/browser/quota/quota_database_unittest.cc b/storage/browser/quota/quota_database_unittest.cc
index c47a3f8c..b15b33d8 100644
--- a/storage/browser/quota/quota_database_unittest.cc
+++ b/storage/browser/quota/quota_database_unittest.cc
@@ -15,6 +15,7 @@
#include "base/compiler_specific.h"
#include "base/containers/flat_map.h"
+#include "base/files/file_enumerator.h"
#include "base/files/file_util.h"
#include "base/files/scoped_temp_dir.h"
#include "base/functional/bind.h"
@@ -814,6 +815,19 @@
ASSERT_TRUE(db->DeleteBucketData(bucket).has_value());
ASSERT_FALSE(base::PathExists(bucket_path));
+
+ // Deleting a bucket that is missing from the database should still clean up
+ // its doomed directory on the error path.
+ ASSERT_TRUE(base::CreateDirectory(bucket_path));
+ ASSERT_TRUE(
+ base::WriteFile(bucket_path.AppendASCII("orphan"), "orphan_data"));
+ EXPECT_THAT(db->DeleteBucketData(bucket),
+ base::test::ErrorIs(QuotaError::kDatabaseError));
+ EXPECT_FALSE(base::PathExists(bucket_path));
+ base::FileEnumerator enumerator(
+ db->GetStoragePath(), /*recursive=*/false,
+ base::FileEnumerator::NAMES_ONLY, FILE_PATH_LITERAL("*-doomed-*"));
+ EXPECT_TRUE(enumerator.Next().empty());
}
}
diff --git a/storage/browser/quota/storage_directory_unittest.cc b/storage/browser/quota/storage_directory_unittest.cc
index 7d727ea3..9337e47 100644
--- a/storage/browser/quota/storage_directory_unittest.cc
+++ b/storage/browser/quota/storage_directory_unittest.cc
@@ -9,6 +9,7 @@
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/files/scoped_temp_dir.h"
+#include "build/build_config.h"
#include "components/services/storage/public/cpp/buckets/bucket_locator.h"
#include "storage/browser/quota/quota_client_type.h"
#include "storage/browser/quota/storage_directory.h"
@@ -168,4 +169,106 @@
EXPECT_EQ(directories.size(), 0u);
}
+#if BUILDFLAG(IS_POSIX)
+TEST_F(StorageDirectoryTest, DoomAndClearBucketSymlinkDoesNotFollowTarget) {
+ base::FilePath external_dir =
+ temp_directory_.GetPath().AppendASCII("external_target");
+ ASSERT_TRUE(base::CreateDirectory(external_dir));
+ base::FilePath secret_file = external_dir.AppendASCII("secret.txt");
+ ASSERT_TRUE(base::WriteFile(secret_file, "secret_content"));
+
+ ASSERT_TRUE(storage_directory_->Create());
+
+ BucketLocator bucket(
+ BucketId(1),
+ blink::StorageKey::CreateFromStringForTesting("http://example/"),
+ /*is_default=*/false);
+ base::FilePath bucket_path =
+ CreateBucketPath(storage_directory_->path().DirName(), bucket);
+ ASSERT_TRUE(base::CreateSymbolicLink(external_dir, bucket_path));
+
+ ASSERT_TRUE(storage_directory_->DoomBucket(bucket));
+ EXPECT_FALSE(base::PathExists(bucket_path));
+ EXPECT_FALSE(base::IsLink(bucket_path));
+
+ std::set<base::FilePath> doomed_buckets =
+ storage_directory_->EnumerateDoomedBucketsForTesting();
+ ASSERT_EQ(doomed_buckets.size(), 1u);
+ // The doomed entry must be the renamed symlink itself, not a copied
+ // directory.
+ EXPECT_TRUE(base::IsLink(*doomed_buckets.begin()));
+
+ storage_directory_->ClearDoomedBuckets();
+ EXPECT_TRUE(storage_directory_->EnumerateDoomedBucketsForTesting().empty());
+
+ // The external target directory and file must remain untouched.
+ std::string contents;
+ EXPECT_TRUE(base::ReadFileToString(secret_file, &contents));
+ EXPECT_EQ(contents, "secret_content");
+}
+
+TEST_F(StorageDirectoryTest, DoomAndClearBucketFileAndBrokenSymlinks) {
+ base::FilePath external_file =
+ temp_directory_.GetPath().AppendASCII("external_file.txt");
+ ASSERT_TRUE(base::WriteFile(external_file, "file_content"));
+ base::FilePath nonexistent_target =
+ temp_directory_.GetPath().AppendASCII("nonexistent");
+
+ ASSERT_TRUE(storage_directory_->Create());
+
+ BucketLocator bucket1(
+ BucketId(1),
+ blink::StorageKey::CreateFromStringForTesting("http://example/"),
+ /*is_default=*/false);
+ BucketLocator bucket2(
+ BucketId(2),
+ blink::StorageKey::CreateFromStringForTesting("http://example/"),
+ /*is_default=*/false);
+
+ base::FilePath bucket1_path =
+ CreateBucketPath(storage_directory_->path().DirName(), bucket1);
+ base::FilePath bucket2_path =
+ CreateBucketPath(storage_directory_->path().DirName(), bucket2);
+ ASSERT_TRUE(base::CreateSymbolicLink(external_file, bucket1_path));
+ ASSERT_TRUE(base::CreateSymbolicLink(nonexistent_target, bucket2_path));
+
+ ASSERT_TRUE(storage_directory_->DoomBucket(bucket1));
+ ASSERT_TRUE(storage_directory_->DoomBucket(bucket2));
+ EXPECT_FALSE(base::IsLink(bucket1_path));
+ EXPECT_FALSE(base::IsLink(bucket2_path));
+
+ std::set<base::FilePath> doomed_buckets =
+ storage_directory_->EnumerateDoomedBucketsForTesting();
+ EXPECT_EQ(doomed_buckets.size(), 2u);
+
+ storage_directory_->ClearDoomedBuckets();
+ EXPECT_TRUE(storage_directory_->EnumerateDoomedBucketsForTesting().empty());
+ EXPECT_TRUE(base::PathExists(external_file));
+}
+
+TEST_F(StorageDirectoryTest, DoomAndClearStorageSymlinkDoesNotFollowTarget) {
+ base::FilePath external_dir =
+ temp_directory_.GetPath().AppendASCII("external_target");
+ ASSERT_TRUE(base::CreateDirectory(external_dir));
+ base::FilePath secret_file = external_dir.AppendASCII("secret.txt");
+ ASSERT_TRUE(base::WriteFile(secret_file, "secret_content"));
+
+ ASSERT_TRUE(
+ base::CreateSymbolicLink(external_dir, storage_directory_->path()));
+
+ ASSERT_TRUE(storage_directory_->Doom());
+ EXPECT_FALSE(base::IsLink(storage_directory_->path()));
+
+ std::set<base::FilePath> doomed_dirs =
+ storage_directory_->EnumerateDoomedDirectoriesForTesting();
+ ASSERT_EQ(doomed_dirs.size(), 1u);
+ EXPECT_TRUE(base::IsLink(*doomed_dirs.begin()));
+
+ storage_directory_->ClearDoomed();
+ EXPECT_TRUE(
+ storage_directory_->EnumerateDoomedDirectoriesForTesting().empty());
+ EXPECT_TRUE(base::PathExists(secret_file));
+}
+#endif // BUILDFLAG(IS_POSIX)
+
} // namespace storage