Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ For dev or build tags, use the same logical version string embedded in the tag.

## [Unreleased]

- YAML validation now accepts the documented `transfers.groups` placement and
applies the same top-level `groups` precedence as runtime configuration.
- The web footer and README now offer direct PayPal and Ko-fi links for
supporting slskdN development.
- GitLab CI configuration now keeps the Arch package-smoke sudoers command as
Expand Down
85 changes: 84 additions & 1 deletion src/slskd/Core/API/Controllers/OptionsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ namespace slskd.Core.API
{
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Asp.Versioning;
Expand All @@ -33,6 +34,7 @@ namespace slskd.Core.API
using Serilog;
using slskd.Core.Security;
using slskd.Validation;
using YamlDotNet.RepresentationModel;
using IOFile = System.IO.File;

/// <summary>
Expand Down Expand Up @@ -269,7 +271,7 @@ private bool TryValidateYaml(string yaml, out string error)

try
{
var options = yaml.FromYaml<Options>();
var options = NormalizeCompatibilityYaml(yaml).FromYaml<Options>();

if (options is null)
{
Expand All @@ -294,6 +296,87 @@ private bool TryValidateYaml(string yaml, out string error)
return true;
}

private static string NormalizeCompatibilityYaml(string yaml)
{
var stream = new YamlStream();

using (var reader = new StringReader(yaml))
{
stream.Load(reader);
}

if (stream.Documents.Count == 0 || stream.Documents[0].RootNode is not YamlMappingNode root ||
!TryGetMappingChild(root, "transfers", out _, out var transfersNode) ||
transfersNode is not YamlMappingNode transfers ||
!TryGetMappingChild(transfers, "groups", out var transfersGroupsKey, out var transfersGroupsNode))
{
return yaml;
}

transfers.Children.Remove(transfersGroupsKey);

if (TryGetMappingChild(root, "groups", out _, out var rootGroupsNode))
{
if (rootGroupsNode is YamlMappingNode rootGroups && transfersGroupsNode is YamlMappingNode transfersGroups)
{
MergeMissingYamlChildren(rootGroups, transfersGroups);
}
}
else
{
root.Children.Add(new YamlScalarNode("groups"), transfersGroupsNode);
}

using var writer = new StringWriter();
stream.Save(writer, assignAnchors: false);
return writer.ToString();
}

private static void MergeMissingYamlChildren(YamlMappingNode target, YamlMappingNode source)
{
foreach (var child in source.Children.ToArray())
{
var name = (child.Key as YamlScalarNode)?.Value;
if (name is not null && TryGetMappingChild(target, name, out _, out var existing))
{
if (existing is YamlMappingNode existingMap && child.Value is YamlMappingNode childMap)
{
MergeMissingYamlChildren(existingMap, childMap);
}

continue;
}

target.Children.Add(child.Key, child.Value);
}
}

private static bool TryGetMappingChild(
YamlMappingNode mapping,
string name,
out YamlNode key,
out YamlNode value)
{
var normalizedName = NormalizeYamlKey(name);

foreach (var child in mapping.Children)
{
if (child.Key is YamlScalarNode scalar && NormalizeYamlKey(scalar.Value) == normalizedName)
{
key = child.Key;
value = child.Value;
return true;
}
}

key = null!;
value = null!;
return false;
}

private static string NormalizeYamlKey(string? value)
=> (value ?? string.Empty).Replace("_", string.Empty).Replace("-", string.Empty).ToLowerInvariant();

private static string RedactConfigurationDebugView(string debugView)
{
return Regex.Replace(
Expand Down
68 changes: 68 additions & 0 deletions tests/slskd.Tests.Unit/Core/API/OptionsControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,74 @@ public void ValidateYamlFile_WithInvalidOptions_DoesNotLeakValidationMessage()
Assert.Equal("Invalid YAML configuration", ok.Value);
}

[Fact]
public void ValidateYamlFile_AcceptsDocumentedTransfersGroupsPlacement()
{
var controller = CreateController();
var yaml = $$"""
transfers:
groups:
blacklisted:
patterns:
- '^(?<stem>case)\k<stem>peer$'
directories:
incomplete: '{{Path.GetTempPath()}}'
downloads: '{{Path.GetTempPath()}}'
web:
content_path: .
""";
var result = controller.ValidateYamlFile(yaml);

Assert.IsType<OkResult>(result);
}

[Fact]
public void ValidateYamlFile_ValidatesPatternsUnderTransfersGroupsPlacement()
{
var controller = CreateController();

var result = controller.ValidateYamlFile($$"""
transfers:
groups:
blacklisted:
patterns:
- '[invalid'
directories:
incomplete: '{{Path.GetTempPath()}}'
downloads: '{{Path.GetTempPath()}}'
web:
content_path: .
""");

var ok = Assert.IsType<OkObjectResult>(result);
Assert.Equal("Invalid YAML configuration", ok.Value);
}

[Fact]
public void ValidateYamlFile_TopLevelGroupsOverrideTransfersGroupsCompatibilityValues()
{
var controller = CreateController();

var result = controller.ValidateYamlFile($$"""
transfers:
groups:
blacklisted:
patterns:
- '[invalid'
groups:
blacklisted:
patterns:
- '^valid$'
directories:
incomplete: '{{Path.GetTempPath()}}'
downloads: '{{Path.GetTempPath()}}'
web:
content_path: .
""");

Assert.IsType<OkResult>(result);
}

[Fact]
public void ApplyOverlay_WithInvalidOverlay_DoesNotLeakValidationMessage()
{
Expand Down
Loading