fix: close TOCTOU in CREATE path; add anti-forgery, loose-track, and case-sensitivity tests
FindOrCreateRelease now returns (ReleaseDto, bool WasCreated); the CREATE path in UploadAsync rejects WasCreated=false as a duplicate rather than silently attaching on a lost race.
This commit is contained in:
@@ -59,8 +59,12 @@ public interface ITrackService
|
||||
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating
|
||||
/// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK
|
||||
/// resolution so a track lands on a shared release rather than duplicating release-cardinal data.
|
||||
/// The <c>WasCreated</c> flag in the result is <see langword="true"/> when a new row was inserted
|
||||
/// and <see langword="false"/> when an existing row was found (including after a lost concurrent-insert
|
||||
/// race). The CREATE path in <c>UnifiedTrackService.UploadAsync</c> uses this to turn a
|
||||
/// "found existing" outcome into a duplicate rejection rather than a silent attach.
|
||||
/// </summary>
|
||||
Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
|
||||
Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> FindOrCreateRelease(
|
||||
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -164,14 +164,14 @@ public class TrackManager
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
|
||||
public async Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> FindOrCreateRelease(
|
||||
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
|
||||
if (existing is not null)
|
||||
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(existing));
|
||||
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(existing), false));
|
||||
|
||||
// The natural key (title + artist) is authoritative — override whatever the caller put
|
||||
// in releaseData so a typo upstream cannot create a release that won't be found again.
|
||||
@@ -186,21 +186,21 @@ public class TrackManager
|
||||
try
|
||||
{
|
||||
var added = await Repository.AddReleaseAsync(entity, cancellationToken);
|
||||
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(added));
|
||||
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(added), true));
|
||||
}
|
||||
catch (ClassifiedDbException ex) when (ex.Error.Category == DbErrorCategory.UniqueViolation)
|
||||
{
|
||||
// Concurrent upload inserted the same (title, artist) between our read and write.
|
||||
// Re-query and return the winning row. Should not return null here since the
|
||||
// constraint just fired, but re-throw if it does so the caller sees an error.
|
||||
// Re-query and return the winning row as WasCreated=false so the caller (UploadAsync
|
||||
// CREATE path) treats the lost race as a duplicate rather than silently attaching.
|
||||
var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
|
||||
if (race is null) throw;
|
||||
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(race));
|
||||
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(race), false));
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<ReleaseDto>.CreateFailResult(e.Message);
|
||||
return ResultContainer<(ReleaseDto, bool)>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,13 +302,13 @@ public class TrackManager
|
||||
if (newTrack.Release is { } release && !string.IsNullOrWhiteSpace(release.Title))
|
||||
{
|
||||
var resolved = await FindOrCreateRelease(release.Title, release.Artist, release);
|
||||
if (!resolved.Success || resolved.Value is null)
|
||||
if (!resolved.Success)
|
||||
{
|
||||
var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release.";
|
||||
return ResultContainer<TrackDto>.CreateFailResult(error);
|
||||
}
|
||||
|
||||
newTrack.ReleaseId = resolved.Value.Id;
|
||||
newTrack.ReleaseId = resolved.Value.Release.Id;
|
||||
}
|
||||
|
||||
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));
|
||||
|
||||
Reference in New Issue
Block a user