diff --git a/DNN Platform/Modules/DnnExportImportLibrary/Repository/ExportImportRepository.cs b/DNN Platform/Modules/DnnExportImportLibrary/Repository/ExportImportRepository.cs
index d246a7f2a84..0dc1b78240b 100644
--- a/DNN Platform/Modules/DnnExportImportLibrary/Repository/ExportImportRepository.cs
+++ b/DNN Platform/Modules/DnnExportImportLibrary/Repository/ExportImportRepository.cs
@@ -5,24 +5,50 @@ namespace Dnn.ExportImport.Repository
{
using System;
using System.Collections.Generic;
+ using System.IO;
using System.Linq;
using System.Linq.Expressions;
+ using System.Reflection;
using System.Threading;
using Dnn.ExportImport.Dto;
using Dnn.ExportImport.Interfaces;
using LiteDB;
- ///
+ ///
public class ExportImportRepository : IExportImportRepository
{
+ // Legacy (LiteDB v3.x/v4.x, on-disk "v7") files carry this ASCII signature starting at byte
+ // offset 25, with the file-format version stored at byte offset 52. This mirrors the internal
+ // LiteDB.Engine.FileReaderV7.IsVersion check and lets us detect a legacy export database without
+ // opening it through the broken upgrade/rebuild path.
+ private const string LegacyFileSignature = "** This is a LiteDB file **";
+ private const int LegacySignatureOffset = 25;
+ private const int LegacyVersionOffset = 52;
+ private const byte LegacyFileVersion = 7;
+
private LiteDatabase liteDb;
+ private string migratedDbFileName;
/// Initializes a new instance of the class.
/// The LiteDB connection string.
public ExportImportRepository(string dbFileName)
{
- this.liteDb = new LiteDatabase(new ConnectionString(dbFileName) { Upgrade = true });
+ // A DNN 9.10.2-era (LiteDB 3.x on-disk format) export database is upgraded/rebuilt in place
+ // when opened with Upgrade = true. LiteDB 5.0.21 has a bug in that rebuild path
+ // (IndexService.FindAll loop guard) that throws "Detected loop in FindAll({0})" for any
+ // collection larger than ~2,550 records, blocking the import before it starts. To avoid the
+ // broken rebuild, detect a legacy-format file and migrate it into a fresh LiteDB 5.x database
+ // by streaming documents through normal inserts (which do not use the broken guard), then open
+ // the migrated copy. Native 5.x databases (and small legacy ones that still fail to migrate)
+ // fall back to the original Upgrade = true fast path with no behavior change.
+ var fileToOpen = dbFileName;
+ if (IsLegacyFormatFile(dbFileName))
+ {
+ fileToOpen = this.TryMigrateLegacyDatabase(dbFileName) ?? dbFileName;
+ }
+
+ this.liteDb = new LiteDatabase(new ConnectionString(fileToOpen) { Upgrade = true });
this.liteDb.Mapper.EmptyStringToNull = false;
this.liteDb.Mapper.TrimWhitespace = false;
}
@@ -33,14 +59,13 @@ public ExportImportRepository(string dbFileName)
this.Dispose(false);
}
- ///
+ ///
public void Dispose()
{
this.Dispose(true);
- GC.SuppressFinalize(this);
}
- ///
+ ///
public T AddSingleItem(T item)
where T : class
{
@@ -49,7 +74,7 @@ public T AddSingleItem(T item)
return item;
}
- ///
+ ///
public T UpdateSingleItem(T item)
where T : class
{
@@ -58,7 +83,7 @@ public T UpdateSingleItem(T item)
return item;
}
- ///
+ ///
public T GetSingleItem()
where T : class
{
@@ -67,7 +92,7 @@ public T GetSingleItem()
return collection.FindById(first);
}
- ///
+ ///
public T CreateItem(T item, int? referenceId)
where T : BasicExportImportDto
{
@@ -86,7 +111,7 @@ public T CreateItem(T item, int? referenceId)
return item;
}
- ///
+ ///
public void CreateItems(IEnumerable items, int? referenceId = null)
where T : BasicExportImportDto
{
@@ -110,14 +135,14 @@ public void CreateItems(IEnumerable items, int? referenceId = null)
collection.Insert(allItems);
}
- ///
+ ///
public T GetItem(Expression> predicate)
where T : BasicExportImportDto
{
return this.InternalGetItems(predicate).FirstOrDefault();
}
- ///
+ ///
public IEnumerable GetItems(
Expression> predicate,
Func orderKeySelector = null,
@@ -129,7 +154,7 @@ public IEnumerable GetItems(
return this.InternalGetItems(predicate, orderKeySelector, asc, skip, max);
}
- ///
+ ///
public int GetCount()
where T : BasicExportImportDto
{
@@ -137,7 +162,7 @@ public int GetCount()
return collection?.Count() ?? 0;
}
- ///
+ ///
public int GetCount(Expression> predicate)
where T : BasicExportImportDto
{
@@ -145,7 +170,7 @@ public int GetCount(Expression> predicate)
return collection?.Count(predicate) ?? 0;
}
- ///
+ ///
public void RebuildIndex(Expression> predicate, bool unique = false)
where T : BasicExportImportDto
{
@@ -153,7 +178,7 @@ public void RebuildIndex(Expression> predicate, bool unique =
collection.EnsureIndex(predicate, unique);
}
- ///
+ ///
public IEnumerable GetAllItems(
Func orderKeySelector = null, bool asc = true, int? skip = null, int? max = null)
where T : BasicExportImportDto
@@ -161,7 +186,7 @@ public IEnumerable GetAllItems(
return this.InternalGetItems(null, orderKeySelector, asc, skip, max);
}
- ///
+ ///
public T GetItem(int id)
where T : BasicExportImportDto
{
@@ -169,7 +194,7 @@ public T GetItem(int id)
return collection.FindById(id);
}
- ///
+ ///
public IEnumerable GetItems(IEnumerable idList)
where T : BasicExportImportDto
{
@@ -177,7 +202,7 @@ public IEnumerable GetItems(IEnumerable idList)
return this.InternalGetItems(predicate);
}
- ///
+ ///
public IEnumerable GetRelatedItems(int referenceId)
where T : BasicExportImportDto
{
@@ -185,7 +210,7 @@ public IEnumerable GetRelatedItems(int referenceId)
return this.InternalGetItems(predicate);
}
- ///
+ ///
public IEnumerable FindItems(Expression> predicate)
where T : BasicExportImportDto
{
@@ -193,7 +218,7 @@ public IEnumerable FindItems(Expression> predicate)
return collection.Find(predicate);
}
- ///
+ ///
public void UpdateItem(T item)
where T : BasicExportImportDto
{
@@ -211,7 +236,7 @@ public void UpdateItem(T item)
collection.Update(item);
}
- ///
+ ///
public void UpdateItems(IEnumerable items)
where T : BasicExportImportDto
{
@@ -225,7 +250,7 @@ public void UpdateItems(IEnumerable items)
collection.Update(allItems);
}
- ///
+ ///
public bool DeleteItem(int id)
where T : BasicExportImportDto
{
@@ -239,7 +264,7 @@ public bool DeleteItem(int id)
return collection.Delete(id);
}
- ///
+ ///
public void DeleteItems(Expression> deleteExpression)
where T : BasicExportImportDto
{
@@ -250,7 +275,7 @@ public void DeleteItems(Expression> deleteExpression)
}
}
- ///
+ ///
public void CleanUpLocal(string collectionName)
{
if (!this.liteDb.CollectionExists(collectionName))
@@ -267,12 +292,158 @@ public void CleanUpLocal(string collectionName)
collection.Update(documentsToUpdate);
}
- protected virtual void Dispose(bool disposing)
+ /// Determines whether the given file is a legacy (LiteDB v3.x/v4.x, on-disk "v7") database.
+ /// The database file path.
+ /// true when the file exists and carries the legacy LiteDB file signature and version.
+ private static bool IsLegacyFormatFile(string dbFileName)
+ {
+ try
+ {
+ if (string.IsNullOrEmpty(dbFileName) || !File.Exists(dbFileName))
+ {
+ return false;
+ }
+
+ var header = new byte[LegacyVersionOffset + 1];
+ using (var stream = new FileStream(dbFileName, FileMode.Open, FileAccess.Read, FileShare.Read))
+ {
+ var read = stream.Read(header, 0, header.Length);
+ if (read < header.Length)
+ {
+ return false;
+ }
+ }
+
+ var signature = System.Text.Encoding.UTF8.GetString(header, LegacySignatureOffset, LegacyFileSignature.Length);
+ return signature == LegacyFileSignature && header[LegacyVersionOffset] == LegacyFileVersion;
+ }
+ catch
+ {
+ // If the header cannot be read for any reason, treat the file as non-legacy and let the
+ // normal open path handle (and report) any problem.
+ return false;
+ }
+ }
+
+ ///
+ /// Reads every collection and document from a legacy-format LiteDB database using LiteDB's own
+ /// legacy reader (FileReaderV7) and writes them into a fresh LiteDB 5.x database via normal
+ /// inserts, sidestepping the broken in-place rebuild. Documents are inserted with their original
+ /// _id values preserved.
+ ///
+ /// The legacy database file to migrate.
+ /// The path to the migrated 5.x database, or null if migration was not possible.
+ private string TryMigrateLegacyDatabase(string sourceDbFileName)
+ {
+ var targetDbFileName = sourceDbFileName + ".migrated";
+ try
+ {
+ if (File.Exists(targetDbFileName))
+ {
+ File.Delete(targetDbFileName);
+ }
+
+ var liteDbAssembly = typeof(LiteDatabase).Assembly;
+ var engineSettingsType = liteDbAssembly.GetType("LiteDB.Engine.EngineSettings");
+ var fileReaderType = liteDbAssembly.GetType("LiteDB.Engine.FileReaderV7");
+ if (engineSettingsType == null || fileReaderType == null)
+ {
+ return null;
+ }
+
+ var engineSettings = Activator.CreateInstance(engineSettingsType);
+ engineSettingsType.GetProperty("Filename").SetValue(engineSettings, sourceDbFileName);
+
+ var reader = (IDisposable)Activator.CreateInstance(
+ fileReaderType,
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
+ null,
+ new[] { engineSettings },
+ null);
+
+ using (reader)
+ {
+ fileReaderType.GetMethod("Open").Invoke(reader, null);
+ var getCollections = fileReaderType.GetMethod("GetCollections");
+ var getDocuments = fileReaderType.GetMethod("GetDocuments");
+
+ using (var targetDb = new LiteDatabase(new ConnectionString(targetDbFileName)))
+ {
+ var collectionNames = (IEnumerable)getCollections.Invoke(reader, null);
+ foreach (var collectionName in collectionNames.ToList())
+ {
+ var documents = (IEnumerable)getDocuments.Invoke(reader, new object[] { collectionName });
+ var target = targetDb.GetCollection(collectionName);
+
+ // Insert in batches to bound memory while streaming large collections.
+ foreach (var batch in Batch(documents, 2000))
+ {
+ target.Insert(batch);
+ }
+ }
+
+ targetDb.Checkpoint();
+ }
+ }
+
+ this.migratedDbFileName = targetDbFileName;
+ return targetDbFileName;
+ }
+ catch
+ {
+ // If anything about the legacy migration fails, discard the partial copy and fall back to
+ // the original open path so behavior is never worse than before this fix.
+ this.SafeDeleteMigratedFile(targetDbFileName);
+ this.migratedDbFileName = null;
+ return null;
+ }
+ }
+
+ private static IEnumerable> Batch(IEnumerable source, int size)
+ {
+ var bucket = new List(size);
+ foreach (var item in source)
+ {
+ bucket.Add(item);
+ if (bucket.Count == size)
+ {
+ yield return bucket;
+ bucket = new List(size);
+ }
+ }
+
+ if (bucket.Count > 0)
+ {
+ yield return bucket;
+ }
+ }
+
+ private void SafeDeleteMigratedFile(string path)
{
- if (disposing)
+ try
+ {
+ if (!string.IsNullOrEmpty(path) && File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+ catch
+ {
+ // Best-effort cleanup of the temporary migrated copy; ignore failures.
+ }
+ }
+
+ private void Dispose(bool isDisposing)
+ {
+ var temp = Interlocked.Exchange(ref this.liteDb, null);
+ temp?.Dispose();
+
+ var migrated = Interlocked.Exchange(ref this.migratedDbFileName, null);
+ this.SafeDeleteMigratedFile(migrated);
+
+ if (isDisposing)
{
- var temp = Interlocked.Exchange(ref this.liteDb, null);
- temp?.Dispose();
+ GC.SuppressFinalize(this);
}
}
diff --git a/DNN Platform/Tests/DotNetNuke.Tests.Modules/DotNetNuke.Tests.Modules.csproj b/DNN Platform/Tests/DotNetNuke.Tests.Modules/DotNetNuke.Tests.Modules.csproj
index 0ddf8ee475a..b8fcb296b92 100644
--- a/DNN Platform/Tests/DotNetNuke.Tests.Modules/DotNetNuke.Tests.Modules.csproj
+++ b/DNN Platform/Tests/DotNetNuke.Tests.Modules/DotNetNuke.Tests.Modules.csproj
@@ -1,40 +1,50 @@
-
-
-
- net48
- false
- latest
- true
- false
-
-
-
-
-
-
-
-
-
-
- runtime; build; native; contentfiles; analyzers; buildtransitive
- all
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ net48
+ false
+ latest
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DNN Platform/Tests/DotNetNuke.Tests.Modules/ExportImport/ExportImportRepositoryTests.cs b/DNN Platform/Tests/DotNetNuke.Tests.Modules/ExportImport/ExportImportRepositoryTests.cs
new file mode 100644
index 00000000000..a3f0d95138d
--- /dev/null
+++ b/DNN Platform/Tests/DotNetNuke.Tests.Modules/ExportImport/ExportImportRepositoryTests.cs
@@ -0,0 +1,140 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information
+
+namespace DotNetNuke.Tests.Modules.ExportImport
+{
+ using System;
+ using System.IO;
+ using System.Linq;
+ using System.Reflection;
+
+ using Dnn.ExportImport.Dto.Assets;
+ using Dnn.ExportImport.Repository;
+
+ using NUnit.Framework;
+
+ ///
+ /// Tests for opening legacy (LiteDB v3.x, on-disk "v7")
+ /// export databases produced by DNN 9.10.2-era instances.
+ ///
+ ///
+ /// Regression coverage for the site import failure where VerifyImportPackage returned HTTP 400
+ /// Package is not valid. Technical Details:Detected loop in FindAll({0}) when importing a legacy
+ /// package whose largest collection exceeds the LiteDB 5.0.21 rebuild loop-guard threshold (~2,550 rows).
+ /// The fixtures are genuine LiteDB v3.1.0 files (format version 7): legacy_v3_large.dnndb holds
+ /// 3,000 ExportFolder rows (above the threshold — reproduces the original failure on the old
+ /// Upgrade = true path) and legacy_v3_small.dnndb holds 50 rows (below the threshold —
+ /// regression check that small legacy packages keep working).
+ ///
+ [TestFixture]
+ public class ExportImportRepositoryTests
+ {
+ private const int LargeFixtureFolderCount = 3000;
+ private const int SmallFixtureFolderCount = 50;
+
+ private string workingDirectory;
+
+ [SetUp]
+ public void SetUp()
+ {
+ this.workingDirectory = Path.Combine(Path.GetTempPath(), "DnnExportImportTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(this.workingDirectory);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ try
+ {
+ if (Directory.Exists(this.workingDirectory))
+ {
+ Directory.Delete(this.workingDirectory, true);
+ }
+ }
+ catch
+ {
+ // Best-effort cleanup of the per-test working directory.
+ }
+ }
+
+ [Test]
+ public void Constructor_WithLargeLegacyDatabase_OpensAndReturnsAllRows()
+ {
+ // Arrange
+ var dbPath = this.CopyFixture("legacy_v3_large.dnndb");
+
+ // Act & Assert - the old Upgrade=true path threw "Detected loop in FindAll({0})" here.
+ using (var repository = new ExportImportRepository(dbPath))
+ {
+ Assert.That(repository.GetCount(), Is.EqualTo(LargeFixtureFolderCount));
+
+ var all = repository.GetAllItems().ToList();
+ Assert.That(all, Has.Count.EqualTo(LargeFixtureFolderCount));
+
+ // Documents and their identity survive the legacy-to-5.x migration.
+ var first = repository.GetItem(1);
+ Assert.That(first, Is.Not.Null);
+ Assert.That(first.FolderPath, Is.EqualTo("F/1"));
+
+ var last = repository.GetItem(LargeFixtureFolderCount);
+ Assert.That(last, Is.Not.Null);
+ Assert.That(last.FolderPath, Is.EqualTo("F/" + LargeFixtureFolderCount));
+
+ // Predicate queries (used throughout the import) work against the migrated database.
+ var referenced = repository.GetItems(f => f.ReferenceId == 5).ToList();
+ Assert.That(referenced, Is.Not.Empty);
+ Assert.That(referenced, Has.All.Matches(f => f.ReferenceId == 5));
+ }
+ }
+
+ [Test]
+ public void Constructor_WithSmallLegacyDatabase_OpensWithoutRegression()
+ {
+ // Arrange
+ var dbPath = this.CopyFixture("legacy_v3_small.dnndb");
+
+ // Act & Assert - below the LiteDB rebuild threshold; must keep working.
+ using (var repository = new ExportImportRepository(dbPath))
+ {
+ Assert.That(repository.GetCount(), Is.EqualTo(SmallFixtureFolderCount));
+ Assert.That(repository.GetAllItems().ToList(), Has.Count.EqualTo(SmallFixtureFolderCount));
+ }
+ }
+
+ [Test]
+ public void Constructor_WithNativeFiveDatabase_OpensWithoutRegression()
+ {
+ // Arrange - a freshly created (native LiteDB 5.x) database, i.e. the common 10.x -> 10.x case.
+ var dbPath = Path.Combine(this.workingDirectory, "native_v5.dnndb");
+ using (var repository = new ExportImportRepository(dbPath))
+ {
+ for (var i = 1; i <= 100; i++)
+ {
+ repository.CreateItem(new ExportFolder { FolderPath = "N/" + i }, null);
+ }
+ }
+
+ // Act & Assert - reopening a native 5.x file must not trigger any migration and must round-trip.
+ using (var repository = new ExportImportRepository(dbPath))
+ {
+ Assert.That(repository.GetCount(), Is.EqualTo(100));
+ }
+ }
+
+ private string CopyFixture(string fixtureName)
+ {
+ var source = Path.Combine(TestDataDirectory(), fixtureName);
+ Assert.That(File.Exists(source), Is.True, $"Missing test fixture: {source}");
+ var destination = Path.Combine(this.workingDirectory, fixtureName);
+ File.Copy(source, destination, true);
+ return destination;
+ }
+
+ private static string TestDataDirectory()
+ {
+ var assemblyDir = Path.GetDirectoryName(new Uri(typeof(ExportImportRepositoryTests).Assembly.CodeBase).LocalPath);
+ return Path.Combine(assemblyDir, "ExportImport", "TestData");
+ }
+ }
+}
diff --git a/DNN Platform/Tests/DotNetNuke.Tests.Modules/ExportImport/TestData/README.md b/DNN Platform/Tests/DotNetNuke.Tests.Modules/ExportImport/TestData/README.md
new file mode 100644
index 00000000000..1b1250aa4c7
--- /dev/null
+++ b/DNN Platform/Tests/DotNetNuke.Tests.Modules/ExportImport/TestData/README.md
@@ -0,0 +1,18 @@
+# Legacy Site Export/Import test fixtures
+
+These are genuine **LiteDB v3.1.0** database files (on-disk format version 7), the format produced by
+DNN 9.10.2-era Site Export/Import (`DotNetNuke.SiteExportImport.Library` 9.10.2). They are used by
+`ExportImportRepositoryTests` to reproduce and guard against the site-import failure where
+`VerifyImportPackage` returned HTTP 400 `Package is not valid. Technical Details:Detected loop in
+FindAll({0})` (eng-maintenance #22222).
+
+LiteDB 5.0.21 (the version DNN 10.x pins) cannot *write* the v3 format, so these fixtures are checked
+in as binary rather than generated at test time.
+
+| File | `ExportFolder` rows | Purpose |
+| --- | --- | --- |
+| `legacy_v3_large.dnndb` | 3,000 | Above the ~2,550 LiteDB rebuild loop-guard threshold. Opening this with the old `Upgrade = true` path throws `Detected loop in FindAll({0})`; the fix must open it and return all rows. |
+| `legacy_v3_small.dnndb` | 50 | Below the threshold. Regression check that small legacy packages still open. |
+
+Each `ExportFolder` document has `_id` (1..N), `FolderPath` = `F/`, and `ReferenceId` = ` % 10`.
+Both fixtures also contain a small `ExportPackage` collection.
diff --git a/DNN Platform/Tests/DotNetNuke.Tests.Modules/ExportImport/TestData/legacy_v3_large.dnndb b/DNN Platform/Tests/DotNetNuke.Tests.Modules/ExportImport/TestData/legacy_v3_large.dnndb
new file mode 100644
index 00000000000..86ce9da2365
Binary files /dev/null and b/DNN Platform/Tests/DotNetNuke.Tests.Modules/ExportImport/TestData/legacy_v3_large.dnndb differ
diff --git a/DNN Platform/Tests/DotNetNuke.Tests.Modules/ExportImport/TestData/legacy_v3_small.dnndb b/DNN Platform/Tests/DotNetNuke.Tests.Modules/ExportImport/TestData/legacy_v3_small.dnndb
new file mode 100644
index 00000000000..f26f50a04cc
Binary files /dev/null and b/DNN Platform/Tests/DotNetNuke.Tests.Modules/ExportImport/TestData/legacy_v3_small.dnndb differ