diff --git a/ClawdDotNet.slnx b/ClawdDotNet.slnx index ccfa616..5bbb274 100644 --- a/ClawdDotNet.slnx +++ b/ClawdDotNet.slnx @@ -17,6 +17,7 @@ + diff --git a/docs/Bestandsaufnahme-2026-07.md b/docs/Bestandsaufnahme-2026-07.md index e16b787..6347135 100644 --- a/docs/Bestandsaufnahme-2026-07.md +++ b/docs/Bestandsaufnahme-2026-07.md @@ -528,8 +528,8 @@ Siehe K3. 2. ~~B3 `maxTokens`-Semantik (bricht produktiv ab)~~ ✅ behoben 2b. ~~B14 System-Prompt-Duplikat~~ ✅ behoben 3. ~~B2 Race Condition im Chat-Kontext~~ ✅ behoben -4. S2 yt-dlp-Injection -5. S3 API-Key-Leak +4. ~~S2 yt-dlp-Injection~~ ✅ behoben +5. ~~S3 API-Key-Leak~~ ✅ behoben **Kurzfristig — größter Nutzen pro Aufwand** 6. ~~T1 Prompt-Caching~~ ✅ umgesetzt (inkl. T9 `cached_tokens`) @@ -542,7 +542,7 @@ Siehe K3. 11. T4 Proaktiv statt reaktiv kompaktieren **Mittelfristig — Fundament** -11. S1 DatabaseTool absichern +11. ~~S1 DatabaseTool absichern~~ ✅ behoben (`SqlGuard`) 12. Memory-Tool (K1) 13. F-A1 Freigabe-Workflow + F-A2 Audit-Log 14. K3 Testprojekt diff --git a/src/ClawdDotNet.Tools.Database/DatabaseTool.cs b/src/ClawdDotNet.Tools.Database/DatabaseTool.cs index e0331f4..6b3533f 100644 --- a/src/ClawdDotNet.Tools.Database/DatabaseTool.cs +++ b/src/ClawdDotNet.Tools.Database/DatabaseTool.cs @@ -95,22 +95,22 @@ public sealed class DatabaseTool : IAgentTool var accessLevel = GetAccessLevel(context); - // Sicherheitsprüfungen - if (IsAdminAttempt(sql)) - { - if (accessLevel != DatabaseAccessLevel.Admin) - return ToolResult.Fail("Sicherheitsfehler: Strukturänderungen (DDL) sind für diesen Agenten nicht erlaubt."); - } - else if (IsWriteAttempt(sql)) - { - if (accessLevel == DatabaseAccessLevel.ReadOnly) - return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff ist für diesen Agenten deaktiviert (ReadOnly)."); - } + // Statement analysieren: Kommentare, Mehrfach-Statements und String-Literale + // werden dabei behandelt, bevor Operation und Tabellen bestimmt werden. + var inspection = SqlGuard.Inspect(sql, GetAllowedTables(context)); - // Tabellen-Whitelist-Prüfung - if (!IsTableAllowed(sql, context)) + if (!inspection.IsValid) + return ToolResult.Fail($"Sicherheitsfehler: {inspection.Error}"); + + switch (inspection.Operation) { - return ToolResult.Fail("Sicherheitsfehler: Zugriff auf eine oder mehrere Tabellen im Statement ist nicht erlaubt."); + case SqlOperation.Schema when accessLevel != DatabaseAccessLevel.Admin: + return ToolResult.Fail( + "Sicherheitsfehler: Strukturänderungen (DDL) sind für diesen Agenten nicht erlaubt."); + + case SqlOperation.Write when accessLevel == DatabaseAccessLevel.ReadOnly: + return ToolResult.Fail( + "Sicherheitsfehler: Schreibzugriff ist für diesen Agenten deaktiviert (ReadOnly)."); } using DbConnection conn = type switch @@ -152,7 +152,7 @@ public sealed class DatabaseTool : IAgentTool var collectionName = input.TryGetProperty("collection", out var c) ? c.GetString() : null; if (string.IsNullOrWhiteSpace(collectionName)) return ToolResult.Fail("'collection' ist erforderlich."); - if (!IsTableAllowed(collectionName, context)) + if (!IsCollectionAllowed(collectionName, context)) { return ToolResult.Fail($"Sicherheitsfehler: Zugriff auf Collection '{collectionName}' ist nicht erlaubt."); } @@ -186,18 +186,6 @@ public sealed class DatabaseTool : IAgentTool } } - private bool IsWriteAttempt(string sql) - { - var lower = sql.ToLowerInvariant(); - return lower.Contains("insert") || lower.Contains("update") || lower.Contains("delete"); - } - - private bool IsAdminAttempt(string sql) - { - var lower = sql.ToLowerInvariant(); - return lower.Contains("drop") || lower.Contains("alter") || lower.Contains("create") || lower.Contains("truncate"); - } - private DatabaseAccessLevel GetAccessLevel(AgentToolContext context) { if (context.ToolConfig.TryGetValue("accessLevel", out var val) && val != null) @@ -215,16 +203,24 @@ public sealed class DatabaseTool : IAgentTool return DatabaseAccessLevel.ReadOnly; } - private bool IsTableAllowed(string input, AgentToolContext context) + internal static List GetAllowedTables(AgentToolContext context) { - if (!context.ToolConfig.TryGetValue("allowedTables", out var val) || val is not JsonElement je) + if (!context.ToolConfig.TryGetValue("allowedTables", out var val) || val is not JsonElement je + || je.ValueKind != JsonValueKind.Array) { - return false; + return []; } - var allowed = je.EnumerateArray().Select(x => x.GetString()?.ToLowerInvariant()).ToList(); - var inputLower = input.ToLowerInvariant(); - - return allowed.Any(t => t != null && inputLower.Contains(t)); + return je.EnumerateArray() + .Select(x => x.GetString()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToList()!; } + + /// + /// MongoDB kennt kein SQL — hier genügt der exakte Vergleich des Collection-Namens. + /// Bisher wurde auch hier per Teilzeichenkette geprüft. + /// + private static bool IsCollectionAllowed(string collection, AgentToolContext context) + => GetAllowedTables(context).Contains(collection, StringComparer.OrdinalIgnoreCase); } diff --git a/src/ClawdDotNet.Tools.Database/SqlGuard.cs b/src/ClawdDotNet.Tools.Database/SqlGuard.cs new file mode 100644 index 0000000..0300ef3 --- /dev/null +++ b/src/ClawdDotNet.Tools.Database/SqlGuard.cs @@ -0,0 +1,251 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace ClawdDotNet.Tools.Database; + +public enum SqlOperation +{ + Unknown, + Read, // SELECT, SHOW, DESCRIBE, EXPLAIN, WITH + Write, // INSERT, UPDATE, DELETE, MERGE, REPLACE + Schema // CREATE, DROP, ALTER, TRUNCATE, GRANT, … +} + +public sealed record SqlInspection( + bool IsValid, + string? Error, + SqlOperation Operation, + IReadOnlyList Tables); + +/// +/// Prüft LLM-erzeugtes SQL, bevor es ausgeführt wird. +/// +/// Die frühere Prüfung suchte den erlaubten Tabellennamen als Teilzeichenkette +/// irgendwo im Statement — auch in einem Kommentar oder String-Literal. Bei +/// allowedTables ["prices"] und ReadWrite genügte deshalb +/// +/// DELETE FROM users -- prices +/// +/// um eine beliebige andere Tabelle zu löschen. Ebenso wurde die Operation über +/// Teilzeichenketten bestimmt, wodurch harmlose Abfragen wie +/// SELECT … WHERE note='update' fälschlich als Schreibzugriff galten. +/// +/// Diese Klasse geht anders vor: +/// 1. Kommentare und String-Literale werden entfernt, bevor irgendetwas geprüft wird. +/// 2. Mehrere Statements in einem Aufruf werden abgelehnt. +/// 3. Die Operation ergibt sich aus dem ersten Schlüsselwort, nicht aus Vorkommen. +/// 4. Tabellennamen werden gezielt hinter FROM/JOIN/INTO/UPDATE/… extrahiert; +/// JEDER davon muss auf der Whitelist stehen. +/// +public static class SqlGuard +{ + private static readonly HashSet ReadKeywords = + new(StringComparer.OrdinalIgnoreCase) { "SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "WITH" }; + + private static readonly HashSet WriteKeywords = + new(StringComparer.OrdinalIgnoreCase) { "INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE", "UPSERT" }; + + private static readonly HashSet SchemaKeywords = + new(StringComparer.OrdinalIgnoreCase) + { + "CREATE", "DROP", "ALTER", "TRUNCATE", "GRANT", "REVOKE", + "RENAME", "ATTACH", "DETACH", "PRAGMA", "SET", "CALL", "EXEC", "EXECUTE" + }; + + /// Schlüsselwörter, nach denen eine Tabellenliste folgt. + private static readonly Regex TableKeyword = new( + @"\b(?:FROM|JOIN|INTO|UPDATE|TABLE)\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// Beendet eine Tabellenliste. + private static readonly Regex ListTerminator = new( + @"\b(?:WHERE|GROUP|ORDER|HAVING|LIMIT|ON|USING|SET|VALUES|UNION|EXCEPT|INTERSECT|" + + @"JOIN|INNER|LEFT|RIGHT|FULL|CROSS|OUTER|NATURAL|SELECT|RETURNING|WINDOW|FETCH|OFFSET|" + + @"ADD|DROP|RENAME|MODIFY|ALTER)\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// Erster Bezeichner eines Listeneintrags — ein evtl. folgender Alias wird ignoriert. + private static readonly Regex LeadingIdentifier = new( + @"^[\s`\[""]*(?[A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)?)", + RegexOptions.Compiled); + + public static SqlInspection Inspect(string? sql, IReadOnlyCollection allowedTables) + { + if (string.IsNullOrWhiteSpace(sql)) + return Invalid("Leeres SQL-Statement."); + + // Kommentare sind für maschinell erzeugtes SQL ohne Nutzen, aber das ideale + // Versteck, um eine erlaubte Tabelle vorzutäuschen. + if (ContainsComment(sql)) + return Invalid("SQL-Kommentare sind nicht erlaubt."); + + var normalized = RemoveStringLiterals(sql); + + if (HasMultipleStatements(normalized)) + return Invalid("Mehrere Statements in einem Aufruf sind nicht erlaubt."); + + var operation = DetermineOperation(normalized); + if (operation == SqlOperation.Unknown) + return Invalid("Statement-Art konnte nicht bestimmt werden."); + + var tables = ExtractTables(normalized); + if (tables.Count == 0) + return new SqlInspection(false, "Keine Tabelle im Statement erkannt.", operation, tables); + + // Fail closed: ohne Whitelist wird nichts durchgelassen. + if (allowedTables.Count == 0) + return new SqlInspection(false, "Keine Tabellen freigegeben (allowedTables fehlt).", operation, tables); + + var allowed = new HashSet(allowedTables, StringComparer.OrdinalIgnoreCase); + var denied = tables.Where(t => !allowed.Contains(StripSchema(t))).ToList(); + + if (denied.Count > 0) + { + return new SqlInspection(false, + $"Zugriff auf nicht freigegebene Tabelle(n): {string.Join(", ", denied)}. " + + $"Erlaubt sind: {string.Join(", ", allowedTables)}.", + operation, tables); + } + + return new SqlInspection(true, null, operation, tables); + } + + // ─── Bausteine ─── + + private static SqlInspection Invalid(string error) + => new(false, error, SqlOperation.Unknown, []); + + private static bool ContainsComment(string sql) + => sql.Contains("--", StringComparison.Ordinal) + || sql.Contains("/*", StringComparison.Ordinal) + || sql.Contains('#'); + + /// + /// Ersetzt den Inhalt von String-Literalen durch Leerraum, damit dort weder + /// Schlüsselwörter noch Tabellennamen erkannt werden. + /// + private static string RemoveStringLiterals(string sql) + { + var sb = new StringBuilder(sql.Length); + var inSingle = false; + var inDouble = false; + + for (var i = 0; i < sql.Length; i++) + { + var c = sql[i]; + + if (inSingle) + { + if (c == '\'') + { + // Verdoppeltes Apostroph ist ein escaptes Zeichen, kein Ende. + if (i + 1 < sql.Length && sql[i + 1] == '\'') { i++; continue; } + inSingle = false; + sb.Append(' '); + } + continue; + } + + if (inDouble) + { + if (c == '"') + { + if (i + 1 < sql.Length && sql[i + 1] == '"') { i++; continue; } + inDouble = false; + sb.Append(' '); + } + continue; + } + + switch (c) + { + case '\'': + inSingle = true; + sb.Append(' '); + break; + case '"': + inDouble = true; + sb.Append(' '); + break; + default: + sb.Append(c); + break; + } + } + + return sb.ToString(); + } + + private static bool HasMultipleStatements(string normalized) + { + var trimmed = normalized.TrimEnd(); + var semicolon = trimmed.IndexOf(';'); + + // Ein abschließendes Semikolon ist in Ordnung, alles danach nicht. + return semicolon >= 0 && semicolon < trimmed.Length - 1; + } + + private static SqlOperation DetermineOperation(string normalized) + { + var firstWord = normalized + .Split([' ', '\t', '\r', '\n', '(', ';'], StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + + if (firstWord is null) + return SqlOperation.Unknown; + + if (SchemaKeywords.Contains(firstWord)) return SqlOperation.Schema; + if (WriteKeywords.Contains(firstWord)) return SqlOperation.Write; + if (ReadKeywords.Contains(firstWord)) return SqlOperation.Read; + + return SqlOperation.Unknown; + } + + private static List ExtractTables(string normalized) + { + var tables = new List(); + + foreach (Match keyword in TableKeyword.Matches(normalized)) + { + var rest = normalized[(keyword.Index + keyword.Length)..]; + tables.AddRange(ParseTableList(rest)); + } + + return tables.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + /// + /// Liest die auf ein Schlüsselwort folgende Tabellenliste — auch mehrere durch Komma + /// getrennte Einträge samt Aliassen ("FROM prices p, users u"). + /// + private static IEnumerable ParseTableList(string rest) + { + // Die Liste endet am nächsten Schlüsselwort oder an einer öffnenden Klammer + // (Spaltenliste bei INSERT, Unterabfrage bei FROM). + var end = rest.Length; + + var terminator = ListTerminator.Match(rest); + if (terminator.Success) + end = Math.Min(end, terminator.Index); + + var parenthesis = rest.IndexOf('('); + if (parenthesis >= 0) + end = Math.Min(end, parenthesis); + + var segment = rest[..end]; + + foreach (var entry in segment.Split(',')) + { + var match = LeadingIdentifier.Match(entry); + if (match.Success) + yield return match.Groups["table"].Value; + } + } + + /// "schema.tabelle" → "tabelle" (die Whitelist listet Tabellennamen). + private static string StripSchema(string table) + { + var dot = table.LastIndexOf('.'); + return dot >= 0 ? table[(dot + 1)..] : table; + } +} diff --git a/src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs b/src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs index 3e5c73e..0c220a6 100644 --- a/src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs +++ b/src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs @@ -293,6 +293,9 @@ public sealed class DirectApiTool : IAgentTool private static async Task<(JsonElement? json, ToolResult? error)> SafeGetJsonAsync( HttpClient http, string url, CancellationToken ct) { + // Auch Fehlermeldungen gehen an das Modell — der Schlüssel darf darin nicht auftauchen. + var safeUrl = UrlSanitizer.Sanitize(url); + HttpResponseMessage response; try { @@ -300,7 +303,7 @@ public sealed class DirectApiTool : IAgentTool } catch (HttpRequestException ex) { - return (null, ToolResult.Fail($"API nicht erreichbar: {url} → {ex.Message}")); + return (null, ToolResult.Fail($"API nicht erreichbar: {safeUrl} → {ex.Message}")); } var body = await response.Content.ReadAsStringAsync(ct); @@ -309,7 +312,7 @@ public sealed class DirectApiTool : IAgentTool { var preview = body.Length > 300 ? body[..300] + "…" : body; return (null, ToolResult.Fail( - $"API Fehler: HTTP {(int)response.StatusCode} {response.ReasonPhrase} von {url}\nAntwort: {preview}")); + $"API Fehler: HTTP {(int)response.StatusCode} {response.ReasonPhrase} von {safeUrl}\nAntwort: {preview}")); } // Prüfen ob die Antwort überhaupt JSON ist @@ -318,7 +321,7 @@ public sealed class DirectApiTool : IAgentTool { var preview = body.Length > 300 ? body[..300] + "…" : body; return (null, ToolResult.Fail( - $"API hat kein JSON zurückgegeben ({url}). Antwort: {preview}")); + $"API hat kein JSON zurückgegeben ({safeUrl}). Antwort: {preview}")); } try @@ -330,7 +333,7 @@ public sealed class DirectApiTool : IAgentTool { var preview = body.Length > 300 ? body[..300] + "…" : body; return (null, ToolResult.Fail( - $"Ungültiges JSON von {url}: {ex.Message}\nAntwort: {preview}")); + $"Ungültiges JSON von {safeUrl}: {ex.Message}\nAntwort: {preview}")); } } @@ -348,7 +351,9 @@ public sealed class DirectApiTool : IAgentTool { fetchedAt = fetchedAt, dataAsOf = dataAsOf, - source = source, + // Ohne Bereinigung ginge der API-Schlüssel im Query-String an das Modell, + // in den persistierten Kontext und in die Logs. + source = UrlSanitizer.Sanitize(source), data = data }; return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); diff --git a/src/ClawdDotNet.Tools.DirectAPI/UrlSanitizer.cs b/src/ClawdDotNet.Tools.DirectAPI/UrlSanitizer.cs new file mode 100644 index 0000000..b981ee5 --- /dev/null +++ b/src/ClawdDotNet.Tools.DirectAPI/UrlSanitizer.cs @@ -0,0 +1,63 @@ +namespace ClawdDotNet.Tools.DirectAPI; + +/// +/// Entfernt Zugangsdaten aus URLs, bevor diese das Tool verlassen. +/// +/// Hintergrund: Die abgerufene URL wurde als "source" an das Modell zurückgegeben — +/// samt "apikey=" im Query-String. Damit landete der Schlüssel im Konversationskontext, +/// wurde bei jedem Folgeschritt erneut an den Anbieter gesendet, in ChatContext.json +/// auf die Platte geschrieben und in die Logs übernommen. +/// +public static class UrlSanitizer +{ + private static readonly string[] SensitiveParameters = + [ + "apikey", "api_key", "key", "token", "access_token", "apitoken", + "secret", "password", "auth", "signature", "sig" + ]; + + private const string Mask = "***"; + + /// + /// Ersetzt die Werte sicherheitsrelevanter Query-Parameter durch eine Maske. + /// Der Rest der URL bleibt lesbar, damit die Herkunft der Daten nachvollziehbar ist. + /// + public static string Sanitize(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + return url ?? ""; + + var queryStart = url.IndexOf('?'); + if (queryStart < 0) + return url; + + var baseUrl = url[..queryStart]; + var query = url[(queryStart + 1)..]; + + // Fragment abtrennen, damit es hinten wieder angehängt werden kann. + var fragment = ""; + var fragmentStart = query.IndexOf('#'); + if (fragmentStart >= 0) + { + fragment = query[fragmentStart..]; + query = query[..fragmentStart]; + } + + var parts = query.Split('&'); + for (var i = 0; i < parts.Length; i++) + { + var separator = parts[i].IndexOf('='); + if (separator < 0) + continue; + + var name = parts[i][..separator]; + if (IsSensitive(name)) + parts[i] = name + "=" + Mask; + } + + return baseUrl + "?" + string.Join("&", parts) + fragment; + } + + private static bool IsSensitive(string parameterName) + => SensitiveParameters.Contains(parameterName.Trim().ToLowerInvariant()); +} diff --git a/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs b/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs index c59a37d..497e908 100644 --- a/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs +++ b/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs @@ -415,36 +415,24 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider // --- Helper --- - /// - /// Löst Channel-Angaben auf: Handle (@Name / Name), Channel-URL, oder volle URL. - /// - private static string ResolveChannelUrl(string channelInput) - { - var input = channelInput.Trim(); - - // Bereits eine URL → direkt nutzen - if (input.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - input.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) - return input; - - // Handle ohne @ → hinzufügen - if (!input.StartsWith('@')) - input = "@" + input; - - return $"https://www.youtube.com/{input}/videos"; - } + // Channel-Auflösung liegt jetzt in YouTubeUrl.TryResolveChannelUrl — dort wird die + // Eingabe validiert, bevor sie an einen externen Prozess geht. private async Task GetLatestVideoIdAsync(string channelInput, string ytDlpPath, CancellationToken ct) { try { - var channelUrl = ResolveChannelUrl(channelInput); - var psi = new ProcessStartInfo(ytDlpPath, $"--print \"%(id)s\" --playlist-end 1 {channelUrl}") + if (!YouTubeUrl.TryResolveChannelUrl(channelInput, out var channelUrl, out _)) + return null; + + var psi = new ProcessStartInfo(ytDlpPath) { RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true }; + foreach (var arg in YouTubeUrl.BuildLatestVideoIdArgs(channelUrl)) + psi.ArgumentList.Add(arg); using var process = Process.Start(psi); if (process == null) return null; @@ -464,30 +452,39 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider { try { + // Eingabe validieren, bevor irgendetwas an einen externen Prozess geht. + if (!YouTubeUrl.TryResolveChannelUrl(url, out var safeUrl, out var urlError)) + throw new ArgumentException($"Ungültige YouTube-Adresse: {urlError}"); + var targetDir = Path.Combine(workspacePath, "YTTranscript"); var tmpDir = Path.Combine(targetDir, "tmp"); if (!Directory.Exists(tmpDir)) Directory.CreateDirectory(tmpDir); // 1. Video-ID ermitteln - var videoId = await GetLatestVideoIdAsync(url, ytDlpPath, ct); + var videoId = await GetLatestVideoIdAsync(safeUrl, ytDlpPath, ct); if (string.IsNullOrWhiteSpace(videoId)) videoId = Guid.NewGuid().ToString(); // 2. Audio herunterladen string audioFile; + var ffmpegDir = !string.IsNullOrEmpty(ffmpegPath) && ffmpegPath != "ffmpeg" + ? Path.GetDirectoryName(ffmpegPath) + : null; + if (hasFfmpeg) { // Mit ffmpeg: yt-dlp konvertiert direkt zu mp3 audioFile = Path.Combine(tmpDir, $"{videoId}.mp3"); - var ffmpegLocArg = !string.IsNullOrEmpty(ffmpegPath) && ffmpegPath != "ffmpeg" - ? $"--ffmpeg-location \"{Path.GetDirectoryName(ffmpegPath)}\" " - : ""; - await RunYtDlpAsync(ytDlpPath, $"{ffmpegLocArg}-x --audio-format mp3 -o \"{audioFile}\" {url}", logger, ct); + await RunYtDlpAsync(ytDlpPath, + YouTubeUrl.BuildAudioDownloadArgs(audioFile, safeUrl, ffmpegDir, convertToMp3: true), + logger, ct); } else { // Ohne ffmpeg: Audio im Originalformat herunterladen var outputTemplate = Path.Combine(tmpDir, $"{videoId}.%(ext)s"); - await RunYtDlpAsync(ytDlpPath, $"-x -o \"{outputTemplate}\" {url}", logger, ct); + await RunYtDlpAsync(ytDlpPath, + YouTubeUrl.BuildAudioDownloadArgs(outputTemplate, safeUrl, null, convertToMp3: false), + logger, ct); audioFile = ""; // wird unten gesucht } @@ -540,16 +537,22 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider } } - private async Task RunYtDlpAsync(string ytDlpPath, string arguments, ILogger logger, CancellationToken ct) + /// + /// Startet yt-dlp. Die Argumente werden einzeln übergeben (ArgumentList), damit + /// kein Wert versehentlich als weitere Option interpretiert werden kann. + /// + private async Task RunYtDlpAsync(string ytDlpPath, List arguments, ILogger logger, CancellationToken ct) { try { - var psi = new ProcessStartInfo(ytDlpPath, arguments) + var psi = new ProcessStartInfo(ytDlpPath) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true }; + foreach (var arg in arguments) + psi.ArgumentList.Add(arg); using var process = Process.Start(psi); if (process != null) @@ -596,13 +599,24 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider // ffmpeg: 20-Minuten-Segmente als mp3 (unter 25 MB Whisper-Limit bei q:a 5) var chunkPattern = Path.Combine(chunkDir, "chunk_%03d.mp3"); var ffmpegExe = !string.IsNullOrEmpty(ffmpegPath) ? ffmpegPath : "ffmpeg"; - var psi = new ProcessStartInfo(ffmpegExe, - $"-i \"{filePath}\" -f segment -segment_time 1200 -c:a libmp3lame -q:a 5 \"{chunkPattern}\"") + var psi = new ProcessStartInfo(ffmpegExe) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true }; + foreach (var arg in new[] + { + "-i", filePath, + "-f", "segment", + "-segment_time", "1200", + "-c:a", "libmp3lame", + "-q:a", "5", + chunkPattern + }) + { + psi.ArgumentList.Add(arg); + } using var process = Process.Start(psi); if (process is not null) diff --git a/src/ClawdDotNet.Tools.SocialMediaManager/YouTubeUrl.cs b/src/ClawdDotNet.Tools.SocialMediaManager/YouTubeUrl.cs new file mode 100644 index 0000000..ac4d7c1 --- /dev/null +++ b/src/ClawdDotNet.Tools.SocialMediaManager/YouTubeUrl.cs @@ -0,0 +1,150 @@ +using System.Text.RegularExpressions; + +namespace ClawdDotNet.Tools.SocialMediaManager; + +/// +/// Prüft und normalisiert YouTube-Adressen, bevor sie an yt-dlp übergeben werden. +/// +/// Hintergrund (S2): Die Eingabe wurde ungeprüft in eine Argument-Zeichenkette +/// interpoliert. UseShellExecute = false verhindert zwar Shell-Metazeichen, +/// nicht aber Options-Injection: yt-dlp kennt --exec, das beliebige Befehle nach +/// dem Download ausführt. Ein Wert wie +/// --exec "cmd /c …" https://youtube.com/… führte damit zu Codeausführung. +/// +/// Kritisch ist das, weil der Agent untrusted Inhalte verarbeitet (Videotitel, Posts, +/// Mails) — eine Prompt-Injection darin kann ihn dazu bringen, genau so einen Wert zu +/// setzen. +/// +public static class YouTubeUrl +{ + private static readonly string[] AllowedHosts = + [ + "youtube.com", "www.youtube.com", "m.youtube.com", + "music.youtube.com", "youtu.be", "www.youtu.be" + ]; + + /// Handles wie "@ChannelName" — bewusst eng gefasst. + private static readonly Regex HandlePattern = new( + @"^@?[A-Za-z0-9._-]{1,100}$", RegexOptions.Compiled); + + /// + /// Löst eine Channel-Angabe zu einer vollständigen URL auf. + /// Akzeptiert ein Handle ("@Name" oder "Name") oder eine YouTube-URL. + /// + public static bool TryResolveChannelUrl(string? input, out string url, out string? error) + { + url = ""; + error = null; + + var value = input?.Trim() ?? ""; + + if (value.Length == 0) + { + error = "Kanal-Angabe fehlt."; + return false; + } + + // Alles, was wie eine Option aussieht, sofort ablehnen — auch wenn die + // Argumentübergabe inzwischen sauber quotet. + if (value.StartsWith('-')) + { + error = "Kanal-Angabe darf nicht mit '-' beginnen."; + return false; + } + + if (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + if (!TryValidateUrl(value, out error)) + return false; + + url = value; + return true; + } + + if (!HandlePattern.IsMatch(value)) + { + error = "Ungültiges Kanal-Handle. Erlaubt sind Buchstaben, Ziffern, Punkt, " + + "Bindestrich und Unterstrich."; + return false; + } + + var handle = value.StartsWith('@') ? value : "@" + value; + url = $"https://www.youtube.com/{handle}/videos"; + return true; + } + + /// Prüft eine Video- oder Kanal-URL gegen die erlaubten Hosts. + public static bool TryValidateUrl(string? input, out string? error) + { + error = null; + var value = input?.Trim() ?? ""; + + if (value.StartsWith('-')) + { + error = "URL darf nicht mit '-' beginnen."; + return false; + } + + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) + { + error = "Keine gültige URL."; + return false; + } + + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + { + error = $"Nicht unterstütztes Schema '{uri.Scheme}'. Erlaubt sind http und https."; + return false; + } + + if (!AllowedHosts.Contains(uri.Host, StringComparer.OrdinalIgnoreCase)) + { + error = $"Host '{uri.Host}' ist nicht erlaubt. Zulässig sind nur YouTube-Adressen."; + return false; + } + + return true; + } + + /// + /// Baut die Argumentliste, um die neueste Video-Id eines Kanals abzufragen. + /// Die Liste wird an ProcessStartInfo.ArgumentList übergeben, wo jedes + /// Element als genau ein Argument ankommt. + /// + public static List BuildLatestVideoIdArgs(string channelUrl) => + [ + "--print", "%(id)s", + "--playlist-end", "1", + "--", // beendet die Optionsliste + channelUrl + ]; + + /// Baut die Argumentliste für den Audio-Download. + public static List BuildAudioDownloadArgs( + string outputPath, string videoUrl, string? ffmpegDirectory, bool convertToMp3) + { + var args = new List(); + + if (!string.IsNullOrEmpty(ffmpegDirectory)) + { + args.Add("--ffmpeg-location"); + args.Add(ffmpegDirectory); + } + + args.Add("-x"); + + if (convertToMp3) + { + args.Add("--audio-format"); + args.Add("mp3"); + } + + args.Add("-o"); + args.Add(outputPath); + args.Add("--"); + args.Add(videoUrl); + + return args; + } +} diff --git a/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj b/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj new file mode 100644 index 0000000..03fb159 --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + ClawdDotNet.Tools.Tests + false + true + + + + + + + + + + + + + + + + + + + diff --git a/tests/ClawdDotNet.Tools.Tests/Database/SqlGuardTests.cs b/tests/ClawdDotNet.Tools.Tests/Database/SqlGuardTests.cs new file mode 100644 index 0000000..683451a --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/Database/SqlGuardTests.cs @@ -0,0 +1,158 @@ +using ClawdDotNet.Tools.Database; +using Shouldly; + +namespace ClawdDotNet.Tools.Tests.Database; + +/// +/// S1 aus der Bestandsaufnahme: Die Tabellen-Whitelist suchte den erlaubten Namen als +/// Teilzeichenkette irgendwo im Statement — auch in einem Kommentar. Bei +/// allowedTables ["prices"] genügte deshalb "DELETE FROM users -- prices", um eine +/// beliebige andere Tabelle zu löschen. +/// +/// Die Gegenproben sind genauso wichtig wie die Angriffe: Ein zu strenger Filter, der +/// legitime Abfragen blockiert, wäre im Betrieb genauso unbrauchbar. +/// +public sealed class SqlGuardTests +{ + private static readonly string[] Allowed = ["prices", "signals", "portfolio"]; + + private static SqlInspection Inspect(string sql) => SqlGuard.Inspect(sql, Allowed); + + // ═══════════════════════════════════════════════════════════ + // Angriffe — alle müssen abgelehnt werden + // ═══════════════════════════════════════════════════════════ + + [Theory] + [InlineData("DELETE FROM users -- prices")] + [InlineData("DELETE FROM users /* prices */")] + [InlineData("DROP TABLE users -- prices")] + [InlineData("UPDATE users SET admin=1 -- prices")] + [InlineData("SELECT * FROM users # prices")] + public void Kommentare_koennen_keine_erlaubte_Tabelle_vortaeuschen(string sql) + { + var result = Inspect(sql); + + result.IsValid.ShouldBeFalse($"'{sql}' muss abgelehnt werden"); + } + + [Theory] + [InlineData("SELECT * FROM users WHERE note = 'prices'")] + [InlineData("INSERT INTO users (name) VALUES ('prices')")] + [InlineData("SELECT * FROM users WHERE a = \"prices\"")] + public void String_Literale_koennen_keine_erlaubte_Tabelle_vortaeuschen(string sql) + { + Inspect(sql).IsValid.ShouldBeFalse(); + } + + [Theory] + [InlineData("SELECT * FROM prices; DROP TABLE users")] + [InlineData("SELECT * FROM prices; DELETE FROM users")] + [InlineData("SELECT 1; SELECT 2")] + public void Mehrere_Statements_werden_abgelehnt(string sql) + { + var result = Inspect(sql); + + result.IsValid.ShouldBeFalse(); + result.Error.ShouldContain("Mehrere Statements"); + } + + [Theory] + [InlineData("SELECT * FROM prices JOIN users ON users.id = prices.uid")] + [InlineData("SELECT * FROM prices, users")] + [InlineData("INSERT INTO users SELECT * FROM prices")] + public void Ein_einziger_unerlaubter_Join_reicht_zur_Ablehnung(string sql) + { + // Entscheidend: JEDE referenzierte Tabelle muss erlaubt sein, nicht irgendeine. + var result = Inspect(sql); + + result.IsValid.ShouldBeFalse(); + result.Error.ShouldContain("users"); + } + + [Fact] + public void Ohne_Whitelist_wird_nichts_durchgelassen() + { + SqlGuard.Inspect("SELECT * FROM prices", []).IsValid.ShouldBeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Leere_Statements_werden_abgelehnt(string? sql) + { + SqlGuard.Inspect(sql, Allowed).IsValid.ShouldBeFalse(); + } + + // ═══════════════════════════════════════════════════════════ + // Gegenproben — legitime Abfragen müssen durchgehen + // ═══════════════════════════════════════════════════════════ + + [Theory] + [InlineData("SELECT * FROM prices")] + [InlineData("SELECT ticker, close FROM prices WHERE ticker = 'NVDA' ORDER BY ts DESC LIMIT 10")] + [InlineData("SELECT * FROM prices JOIN signals ON signals.ticker = prices.ticker")] + [InlineData("select * from PRICES")] + [InlineData("SELECT * FROM prices;")] + public void Erlaubte_Leseabfragen_gehen_durch(string sql) + { + var result = Inspect(sql); + + result.IsValid.ShouldBeTrue(result.Error); + result.Operation.ShouldBe(SqlOperation.Read); + } + + [Theory] + [InlineData("SELECT * FROM prices WHERE note = 'update'")] + [InlineData("SELECT * FROM prices WHERE kommentar = 'bitte delete beachten'")] + [InlineData("SELECT * FROM prices WHERE aktion = 'drop'")] + public void Harmlose_Schluesselwoerter_in_Werten_gelten_nicht_als_Schreibzugriff(string sql) + { + // Der alte Substring-Test stufte diese Abfragen fälschlich als Schreib- bzw. + // Strukturzugriff ein und blockierte sie für ReadOnly-Agenten. + var result = Inspect(sql); + + result.IsValid.ShouldBeTrue(result.Error); + result.Operation.ShouldBe(SqlOperation.Read); + } + + [Theory] + [InlineData("UPDATE prices SET close = 1 WHERE id = 2", SqlOperation.Write)] + [InlineData("INSERT INTO signals (ticker) VALUES ('NVDA')", SqlOperation.Write)] + [InlineData("DELETE FROM portfolio WHERE id = 5", SqlOperation.Write)] + [InlineData("DROP TABLE prices", SqlOperation.Schema)] + [InlineData("ALTER TABLE prices ADD COLUMN x INT", SqlOperation.Schema)] + [InlineData("TRUNCATE TABLE prices", SqlOperation.Schema)] + public void Die_Operation_wird_am_ersten_Schluesselwort_bestimmt(string sql, SqlOperation expected) + { + var result = Inspect(sql); + + result.Operation.ShouldBe(expected); + result.IsValid.ShouldBeTrue(result.Error); + } + + [Fact] + public void Schema_qualifizierte_Namen_werden_erkannt() + { + var result = Inspect("SELECT * FROM crypto.prices WHERE id = 1"); + + result.IsValid.ShouldBeTrue(result.Error); + } + + [Fact] + public void Der_Fehlertext_nennt_die_abgelehnte_Tabelle_und_die_erlaubten() + { + var result = Inspect("SELECT * FROM geheim"); + + result.Error.ShouldContain("geheim"); + result.Error.ShouldContain("prices"); + } + + [Fact] + public void Escaptes_Apostroph_im_Literal_bricht_die_Erkennung_nicht() + { + var result = Inspect("SELECT * FROM prices WHERE name = 'O''Brien'"); + + result.IsValid.ShouldBeTrue(result.Error); + } +} diff --git a/tests/ClawdDotNet.Tools.Tests/DirectAPI/UrlSanitizerTests.cs b/tests/ClawdDotNet.Tools.Tests/DirectAPI/UrlSanitizerTests.cs new file mode 100644 index 0000000..3338ea4 --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/DirectAPI/UrlSanitizerTests.cs @@ -0,0 +1,95 @@ +using ClawdDotNet.Tools.DirectAPI; +using Shouldly; + +namespace ClawdDotNet.Tools.Tests.DirectAPI; + +/// +/// S3 aus der Bestandsaufnahme: Die abgerufene URL ging als "source" an das Modell — +/// inklusive apikey= im Query-String. Der Schlüssel landete damit im Kontext, wurde +/// bei jedem Folgeschritt erneut gesendet und in ChatContext.json geschrieben. +/// +public sealed class UrlSanitizerTests +{ + private const string Secret = "GEHEIM-abc123XYZ"; + + [Theory] + [InlineData("https://api.twelvedata.com/quote?symbol=NVDA&apikey=GEHEIM-abc123XYZ")] + [InlineData("https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=X&apikey=GEHEIM-abc123XYZ")] + [InlineData("https://api.example.com/v1?api_key=GEHEIM-abc123XYZ&x=1")] + [InlineData("https://api.example.com/v1?token=GEHEIM-abc123XYZ")] + [InlineData("https://api.example.com/v1?access_token=GEHEIM-abc123XYZ")] + [InlineData("https://api.example.com/v1?KEY=GEHEIM-abc123XYZ")] + [InlineData("https://api.example.com/v1?ApiKey=GEHEIM-abc123XYZ&other=2")] + [InlineData("https://api.example.com/v1?secret=GEHEIM-abc123XYZ")] + [InlineData("https://api.example.com/v1?a=1&apikey=GEHEIM-abc123XYZ#fragment")] + public void Sensible_Parameter_werden_maskiert(string url) + { + var sanitized = UrlSanitizer.Sanitize(url); + + sanitized.ShouldNotContain(Secret); + sanitized.ShouldContain("***"); + } + + [Fact] + public void Harmlose_Parameter_bleiben_lesbar() + { + var sanitized = UrlSanitizer.Sanitize( + "https://api.twelvedata.com/quote?symbol=NVDA&interval=1day&apikey=GEHEIM-abc123XYZ"); + + sanitized.ShouldContain("symbol=NVDA"); + sanitized.ShouldContain("interval=1day"); + sanitized.ShouldContain("apikey=***"); + } + + [Fact] + public void Der_Pfad_bleibt_erhalten() + { + var sanitized = UrlSanitizer.Sanitize("https://api.massive.com/v2/aggs/ticker/NVDA?apikey=x"); + + sanitized.ShouldStartWith("https://api.massive.com/v2/aggs/ticker/NVDA?"); + } + + [Fact] + public void Ein_Fragment_bleibt_erhalten() + { + var sanitized = UrlSanitizer.Sanitize("https://x.de/a?apikey=geheim#abschnitt"); + + sanitized.ShouldEndWith("#abschnitt"); + sanitized.ShouldNotContain("geheim"); + } + + [Theory] + [InlineData("https://api.example.com/v1")] + [InlineData("https://api.example.com/v1?symbol=NVDA")] + public void URLs_ohne_sensible_Parameter_bleiben_unveraendert(string url) + { + UrlSanitizer.Sanitize(url).ShouldBe(url); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Leere_Eingaben_fuehren_nicht_zu_einem_Fehler(string? url) + { + Should.NotThrow(() => UrlSanitizer.Sanitize(url)); + } + + [Fact] + public void Ein_Parameter_ohne_Wert_stoert_nicht() + { + var sanitized = UrlSanitizer.Sanitize("https://x.de/a?flag&apikey=geheim"); + + sanitized.ShouldContain("flag"); + sanitized.ShouldNotContain("geheim"); + } + + [Fact] + public void Ein_Schluessel_als_Teil_eines_anderen_Namens_wird_nicht_faelschlich_maskiert() + { + // "monkey" enthält "key", ist aber kein sensibler Parameter. + var sanitized = UrlSanitizer.Sanitize("https://x.de/a?monkey=banane"); + + sanitized.ShouldBe("https://x.de/a?monkey=banane"); + } +} diff --git a/tests/ClawdDotNet.Tools.Tests/GlobalUsings.cs b/tests/ClawdDotNet.Tools.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/ClawdDotNet.Tools.Tests/SocialMedia/YouTubeUrlTests.cs b/tests/ClawdDotNet.Tools.Tests/SocialMedia/YouTubeUrlTests.cs new file mode 100644 index 0000000..a3ca8b4 --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/SocialMedia/YouTubeUrlTests.cs @@ -0,0 +1,139 @@ +using ClawdDotNet.Tools.SocialMediaManager; +using Shouldly; + +namespace ClawdDotNet.Tools.Tests.SocialMedia; + +/// +/// S2 aus der Bestandsaufnahme: Die Kanal-/Video-Angabe wurde ungeprüft in eine +/// Argument-Zeichenkette für yt-dlp interpoliert. UseShellExecute=false verhindert +/// Shell-Metazeichen, nicht aber Options-Injection — yt-dlp kennt --exec, das beliebige +/// Befehle ausführt. +/// +/// Die Angriffsfälle bleiben hier dauerhaft als Testfälle dokumentiert. +/// +public sealed class YouTubeUrlTests +{ + // ═══════════════════════════════════════════════════════════ + // Angriffe + // ═══════════════════════════════════════════════════════════ + + [Theory] + [InlineData("--exec cmd.exe https://www.youtube.com/@kanal")] + [InlineData("--exec=calc.exe")] + [InlineData("-o /tmp/evil")] + [InlineData("--config-location /tmp/evil.conf")] + [InlineData("--paths /windows/system32")] + [InlineData("-")] + public void Optionsartige_Eingaben_werden_abgelehnt(string input) + { + YouTubeUrl.TryResolveChannelUrl(input, out _, out var error).ShouldBeFalse(); + error.ShouldNotBeNull(); + } + + [Theory] + [InlineData("https://evil.com/video")] + [InlineData("https://youtube.com.attacker.net/@kanal")] + [InlineData("https://notyoutube.com/@kanal")] + [InlineData("http://192.168.178.10:8418/Richard/ClawdDotNet.git")] + [InlineData("file:///C:/Windows/win.ini")] + [InlineData("ftp://example.com/datei")] + public void Fremde_Hosts_und_Schemata_werden_abgelehnt(string url) + { + YouTubeUrl.TryResolveChannelUrl(url, out _, out var error).ShouldBeFalse(); + error.ShouldNotBeNull(); + } + + [Theory] + [InlineData("kanal name")] // Leerzeichen + [InlineData("kanal\"name")] // Anführungszeichen + [InlineData("kanal\nname")] // Zeilenumbruch + [InlineData("kanal;name")] + [InlineData("kanal&name")] + [InlineData("../../etc/passwd")] + [InlineData("@kanal/../../evil")] + public void Unsaubere_Handles_werden_abgelehnt(string input) + { + YouTubeUrl.TryResolveChannelUrl(input, out _, out var error).ShouldBeFalse(); + error.ShouldNotBeNull(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Leere_Eingaben_werden_abgelehnt(string? input) + { + YouTubeUrl.TryResolveChannelUrl(input, out _, out _).ShouldBeFalse(); + } + + // ═══════════════════════════════════════════════════════════ + // Gegenproben — legitime Eingaben müssen weiter funktionieren + // ═══════════════════════════════════════════════════════════ + + [Theory] + [InlineData("@Computerphile", "https://www.youtube.com/@Computerphile/videos")] + [InlineData("Computerphile", "https://www.youtube.com/@Computerphile/videos")] + [InlineData("kanal_mit-punkt.name", "https://www.youtube.com/@kanal_mit-punkt.name/videos")] + public void Handles_werden_zu_Kanal_URLs(string input, string expected) + { + YouTubeUrl.TryResolveChannelUrl(input, out var url, out var error).ShouldBeTrue(error); + url.ShouldBe(expected); + } + + [Theory] + [InlineData("https://www.youtube.com/watch?v=dQw4w9WgXcQ")] + [InlineData("https://youtube.com/@kanal/videos")] + [InlineData("https://m.youtube.com/watch?v=abc")] + [InlineData("https://music.youtube.com/watch?v=abc")] + [InlineData("https://youtu.be/dQw4w9WgXcQ")] + public void Echte_YouTube_URLs_werden_durchgelassen(string url) + { + YouTubeUrl.TryResolveChannelUrl(url, out var resolved, out var error).ShouldBeTrue(error); + resolved.ShouldBe(url); + } + + // ═══════════════════════════════════════════════════════════ + // Argumentbildung + // ═══════════════════════════════════════════════════════════ + + [Fact] + public void Die_URL_steht_hinter_dem_Optionsende_Trenner() + { + // "--" beendet die Optionsliste. Selbst wenn ein Wert wie eine Option aussähe, + // würde yt-dlp ihn danach als Adresse behandeln. + var args = YouTubeUrl.BuildLatestVideoIdArgs("https://www.youtube.com/@kanal/videos"); + + var separator = args.IndexOf("--"); + separator.ShouldBeGreaterThanOrEqualTo(0); + args[^1].ShouldBe("https://www.youtube.com/@kanal/videos"); + args.IndexOf(args[^1]).ShouldBeGreaterThan(separator); + } + + [Fact] + public void Jedes_Argument_ist_ein_eigener_Eintrag() + { + // Entscheidend: Kein Eintrag darf mehrere Argumente enthalten — sonst könnten + // sie beim Start wieder aufgetrennt werden. + var args = YouTubeUrl.BuildAudioDownloadArgs( + @"C:\Pfad mit Leerzeichen\datei.mp3", + "https://www.youtube.com/watch?v=abc", + @"C:\ffmpeg\bin", + convertToMp3: true); + + args.ShouldContain(@"C:\Pfad mit Leerzeichen\datei.mp3"); + args.ShouldContain(@"C:\ffmpeg\bin"); + args.ShouldContain("--audio-format"); + args.ShouldContain("mp3"); + args[^1].ShouldBe("https://www.youtube.com/watch?v=abc"); + } + + [Fact] + public void Ohne_ffmpeg_entfaellt_die_Konvertierung() + { + var args = YouTubeUrl.BuildAudioDownloadArgs( + "/tmp/x.%(ext)s", "https://youtu.be/abc", null, convertToMp3: false); + + args.ShouldNotContain("--audio-format"); + args.ShouldNotContain("--ffmpeg-location"); + } +}