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