Add custom decoder support for importers (#1146)

This commit is contained in:
Leendert de Borst
2025-08-26 15:03:26 +02:00
committed by Leendert de Borst
parent ad3c0323b9
commit 2fccb162e6
6 changed files with 52 additions and 61 deletions

View File

@@ -68,7 +68,6 @@
<EmbeddedResource Include="TestData\Exports\strongbox.csv" />
<EmbeddedResource Include="TestData\Exports\keepass.csv" />
<EmbeddedResource Include="TestData\Exports\keepass_special_chars.csv" />
<EmbeddedResource Include="TestData\Exports\keepass_escaped_quotes.csv" />
<EmbeddedResource Include="TestData\Exports\keepassxc.csv" />
<EmbeddedResource Include="TestData\Exports\1password_8.csv" />
<EmbeddedResource Include="TestData\Exports\protonpass.csv" />

View File

@@ -1,3 +0,0 @@
"Account","Login Name","Password","Web Site","Comments"
"Sample Entry","User Name","Password","https://keepass.info/","Notes"
"Entry with backslash quotes","testuser","mypass123","https://example.com","This note has \"escaped quotes\" and other stuff"
Can't render this file because it contains an unexpected character in line 3 and column 92.

View File

@@ -1,4 +1,4 @@
"Account","Login Name","Password","Web Site","Comments"
"Sample Entry","User Name","Password","https://keepass.info/","Notes"
"Sample Entry #2","Michael321","12345","https://keepass.info/help/kb/testform.html",""
"Entry with notes special chars","","DVfIsb4TGkL7oKCwyiet","","Note \"with double quotes\"'as'd as/d/asd/ z's'sd a8e89A)_@()@'\":ÄS\"d';asd;á'sd"
"Entry with \"notes\" special chars","","DVfIsb4TGkL7oKCwyiet","","Note \"with quotes\"'as'd as/d/asd/ z's'sd a8e89A)_@()@'"":ÄS""d';asd;á'sd"
Can't render this file because it contains an unexpected character in line 4 and column 70.

View File

@@ -336,14 +336,14 @@ public class ImportExportTests
Assert.That(importedCredentials, Has.Count.EqualTo(3));
// Test the entry with special characters and double quotes
var specialEntry = importedCredentials.First(c => c.ServiceName == "Entry with notes special chars");
var specialEntry = importedCredentials.First(c => c.ServiceName?.StartsWith("Entry with") ?? false);
Assert.Multiple(() =>
{
Assert.That(specialEntry.ServiceName, Is.EqualTo("Entry with notes special chars"));
Assert.That(specialEntry.ServiceName, Is.EqualTo("Entry with \"notes\" special chars"));
Assert.That(specialEntry.ServiceUrl, Is.Empty);
Assert.That(specialEntry.Username, Is.Empty);
Assert.That(specialEntry.Password, Is.EqualTo("DVfIsb4TGkL7oKCwyiet"));
Assert.That(specialEntry.Notes, Does.Contain("\"Note with double quotes\""));
Assert.That(specialEntry.Notes, Does.Contain("\"with quotes\""));
Assert.That(specialEntry.Notes, Does.Contain("as'd as/d/asd/ z"));
Assert.That(specialEntry.Notes, Does.Contain("asd;á'sd"));
});
@@ -360,41 +360,6 @@ public class ImportExportTests
});
}
/// <summary>
/// Test case for importing credentials from KeePass CSV with backslash-escaped quotes.
/// </summary>
/// <returns>Async task.</returns>
[Test]
public async Task ImportCredentialsFromKeePassCsvWithEscapedQuotes()
{
// Arrange
var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.keepass_escaped_quotes.csv");
// Act
var importedCredentials = await KeePassImporter.ImportFromCsvAsync(fileContent);
// Assert
Assert.That(importedCredentials, Has.Count.EqualTo(2));
// Test the entry with escaped quotes
var escapedQuotesEntry = importedCredentials.First(c => c.ServiceName == "Entry with backslash quotes");
Assert.Multiple(() =>
{
Assert.That(escapedQuotesEntry.ServiceName, Is.EqualTo("Entry with backslash quotes"));
Assert.That(escapedQuotesEntry.ServiceUrl, Is.EqualTo("https://example.com"));
Assert.That(escapedQuotesEntry.Username, Is.EqualTo("testuser"));
Assert.That(escapedQuotesEntry.Password, Is.EqualTo("mypass123"));
// The CSV decoding should have converted \" to " properly
Assert.That(escapedQuotesEntry.Notes, Does.Contain("This note has"));
Assert.That(escapedQuotesEntry.Notes, Does.Contain("\"escaped quotes\""));
Assert.That(escapedQuotesEntry.Notes, Does.Contain("and other stuff"));
// Verify that the backslashes before quotes have been properly decoded
Assert.That(escapedQuotesEntry.Notes, Does.Not.Contain("\\\""));
});
}
/// <summary>
/// Test case for importing credentials from KeePassXC CSV and ensuring all values are present.
/// </summary>

View File

@@ -56,8 +56,9 @@ public static class BaseImporter
/// </summary>
/// <typeparam name="T">The CSV record type.</typeparam>
/// <param name="fileContent">The CSV file content.</param>
/// <param name="customDecoder">Optional custom field decoder function.</param>
/// <returns>A list of parsed CSV records.</returns>
public static async Task<List<T>> ImportCsvDataAsync<T>(string fileContent)
public static async Task<List<T>> ImportCsvDataAsync<T>(string fileContent, Func<string, string>? customDecoder = null)
{
using var reader = new StringReader(fileContent);
using var csv = new CsvReader(reader, CreateCsvConfiguration());
@@ -72,7 +73,7 @@ public static class BaseImporter
lineNumber++;
// Process CSV field decoding for escaped quotes and other special characters
DecodeFields(record);
DecodeFields(record, customDecoder);
records.Add(record);
}
@@ -96,7 +97,8 @@ public static class BaseImporter
/// Specifically handles CSV-encoded double quotes and other escape sequences.
/// </summary>
/// <param name="record">The CSV record to process.</param>
private static void DecodeFields<T>(T record)
/// <param name="customDecoder">Optional custom decoder function for importer-specific decoding.</param>
private static void DecodeFields<T>(T record, Func<string, string>? customDecoder = null)
{
if (record == null) return;
@@ -110,7 +112,7 @@ public static class BaseImporter
var value = property.GetValue(record) as string;
if (!string.IsNullOrEmpty(value))
{
var decodedValue = DecodeCsvField(value);
var decodedValue = customDecoder?.Invoke(value) ?? DecodeCsvField(value);
property.SetValue(record, decodedValue);
}
}
@@ -118,7 +120,7 @@ public static class BaseImporter
}
/// <summary>
/// Decodes a CSV field value by handling escaped quotes and other CSV encoding.
/// Decodes a CSV field value by handling standard CSV escaped quotes.
/// </summary>
/// <param name="value">The CSV field value.</param>
/// <returns>The decoded value.</returns>
@@ -127,22 +129,11 @@ public static class BaseImporter
if (string.IsNullOrEmpty(value))
return value;
// Handle CSV-escaped double quotes: "" becomes "
// But be careful not to affect other legitimate escape sequences
var decoded = value;
// Replace CSV-style escaped quotes (two consecutive quotes) with single quotes
// Handle standard CSV-style escaped quotes (two consecutive quotes) -> single quote
decoded = decoded.Replace("\"\"", "\"");
// Handle common backslash escape sequences that might come from KeePass or other sources
// Only handle specific known escape sequences to avoid breaking valid backslashes
decoded = decoded.Replace("\\\"", "\""); // Escaped quote
decoded = decoded.Replace("\\n", "\n"); // Escaped newline
decoded = decoded.Replace("\\r", "\r"); // Escaped carriage return
decoded = decoded.Replace("\\t", "\t"); // Escaped tab
// Don't replace all backslashes as they might be legitimate (e.g., in file paths)
return decoded;
}
/// <summary>

View File

@@ -12,12 +12,50 @@ using AliasVault.ImportExport.Models.Imports;
using CsvHelper;
using CsvHelper.Configuration;
using System.Globalization;
using System.Text.RegularExpressions;
/// <summary>
/// Imports credentials from KeePass.
/// </summary>
public static class KeePassImporter
{
/// <summary>
/// Decodes KeePass 1.x specific field encoding.
/// KeePass 1.x rules: Quotes (") in strings are encoded as \" (two characters).
/// Backslashes (\) are encoded as \\ (two characters).
/// </summary>
/// <param name="value">The field value to decode.</param>
/// <returns>The decoded value.</returns>
private static string DecodeKeePassField(string value)
{
if (string.IsNullOrEmpty(value))
return value;
var decoded = value;
// Handle standard CSV-style escaped quotes first (two consecutive quotes) -> single quote
decoded = decoded.Replace("\"\"", "\"");
// Handle KeePass 1.x specific encoding rules
// Backslashes (\) are encoded as \\ -> \
decoded = decoded.Replace("\\\\", "\\");
// Quotes (") in strings are encoded as \" -> "
decoded = decoded.Replace("\\\"", "\"");
// Special handling for the case where the CSV parser has already partially processed
// the escaped quotes, leaving single backslashes that should be quotes
// This handles the case where \with should become "with
if (decoded.Contains("\\"))
{
// Look for standalone backslashes that should be quotes
// This is a fallback for malformed or partially-parsed KeePass data
decoded = Regex.Replace(decoded, @"\\(?![\\\""])", "\"");
}
return decoded;
}
/// <summary>
/// Imports KeePass CSV file and converts contents to list of ImportedCredential model objects.
/// </summary>
@@ -25,7 +63,8 @@ public static class KeePassImporter
/// <returns>The imported list of ImportedCredential objects.</returns>
public static async Task<List<ImportedCredential>> ImportFromCsvAsync(string fileContent)
{
var records = await BaseImporter.ImportCsvDataAsync<KeePassCsvRecord>(fileContent);
// Use KeePass-specific field decoder for proper handling of KeePass 1.x encoding
var records = await BaseImporter.ImportCsvDataAsync<KeePassCsvRecord>(fileContent, DecodeKeePassField);
var credentials = new List<ImportedCredential>();
foreach (var record in records)