Skip to content
Merged
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
73 changes: 73 additions & 0 deletions dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2947,6 +2947,51 @@ public void Show_PreindexedStaleIndex_RefreshesBeforeLatestSelection()
}
}

[Fact]
public void Show_PreindexedStaleIndex_NotModifiedKeepsLocalIndex()
{
const string packageId = "Test.NotModifiedPackage";
var initialCatalog = PreindexedCatalogFixture.Create(packageId, "1.0.0");
var refreshedCatalog = PreindexedCatalogFixture.Create(packageId, "1.1.0", "1.0.0");

using var server = new TestPreindexedSourceServer(refreshedCatalog.MsixBytes, initialCatalog.Files.Concat(refreshedCatalog.Files))
{
ETag = "\"same-index\"",
LastModified = "Wed, 03 Jun 2026 12:00:00 GMT",
};
var appRoot = TestPaths.CreateTempAppRoot();
try
{
using var repo = Repository.Open(new RepositoryOptions
{
AppRoot = appRoot,
PreIndexedSourceAutoUpdateInterval = TimeSpan.FromDays(1),
});
ReplaceSources(repo, ("test", server.Url, SourceKind.PreIndexed));
var source = repo.ListSources().Single(source => source.Name == "test");
source.ETag = server.ETag;
source.LastModified = server.LastModified;
var indexPath = WritePreindexedIndex(appRoot, repo, "test", initialCatalog.IndexBytes);
File.SetLastWriteTimeUtc(indexPath, DateTime.UtcNow.AddDays(-1));

var result = repo.ShowManifest(new PackageQuery
{
Id = packageId,
Exact = true,
Source = "test",
});

Assert.Equal("1.0.0", result.PackageVersion);
Assert.Equal(1, server.SourceUpdateRequests);
Assert.Equal(server.ETag, source.ETag);
Assert.Equal(server.LastModified, source.LastModified);
}
finally
{
TestPaths.DeleteAppRoot(appRoot);
}
}

[Fact]
public void Show_PreindexedFreshIndexMtime_DoesNotRefreshForStaleSourceMetadata()
{
Expand Down Expand Up @@ -3509,6 +3554,8 @@ public TestPreindexedSourceServer(byte[]? msixBytes, IEnumerable<KeyValuePair<st

public string Url { get; }
public int SourceUpdateRequests { get; private set; }
public string? ETag { get; init; }
public string? LastModified { get; init; }

public void SetMsixBytes(byte[]? msixBytes) => _msixBytes = msixBytes;

Expand All @@ -3530,9 +3577,26 @@ private async Task HandleRequestsAsync()
continue;
}

if (ETag is not null &&
context.Request.Headers["If-None-Match"]?.Split(',').Select(value => value.Trim()).Contains(ETag) == true)
{
context.Response.StatusCode = 304;
AddValidatorHeaders(context.Response);
continue;
}

if (LastModified is not null &&
string.Equals(context.Request.Headers["If-Modified-Since"], LastModified, StringComparison.OrdinalIgnoreCase))
{
context.Response.StatusCode = 304;
AddValidatorHeaders(context.Response);
continue;
}

context.Response.StatusCode = 200;
context.Response.ContentType = "application/octet-stream";
context.Response.Headers.Add("x-ms-meta-sourceversion", "test-source-version");
AddValidatorHeaders(context.Response);
context.Response.ContentLength64 = _msixBytes.Length;
await context.Response.OutputStream.WriteAsync(_msixBytes, _cts.Token);
continue;
Expand Down Expand Up @@ -3565,6 +3629,15 @@ private async Task HandleRequestsAsync()
}
}

private void AddValidatorHeaders(HttpListenerResponse response)
{
if (ETag is not null)
response.Headers.Add("ETag", ETag);

if (LastModified is not null)
response.Headers.Add("Last-Modified", LastModified);
}

public void Dispose()
{
_cts.Cancel();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public void SavePackagedStore_OutputIsLoadableByParsePackagedSourceStore()
["Name"] = "winget",
["LastUpdate"] = 1700000000L,
["SourceVersion"] = "v2.0",
["ETag"] = "\"etag-v2\"",
["LastModified"] = "Wed, 03 Jun 2026 12:00:00 GMT",
},
},
});
Expand All @@ -57,6 +59,8 @@ public void SavePackagedStore_OutputIsLoadableByParsePackagedSourceStore()
Assert.False(source.Explicit);
Assert.Equal(0, source.Priority);
Assert.Equal("v2.0", source.SourceVersion);
Assert.Equal("\"etag-v2\"", source.ETag);
Assert.Equal("Wed, 03 Jun 2026 12:00:00 GMT", source.LastModified);
Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1700000000L).UtcDateTime, source.LastUpdate);
}

Expand Down
2 changes: 2 additions & 0 deletions dotnet/src/Devolutions.Pinget.Core/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public record SourceRecord
public int Priority { get; set; }
public DateTime? LastUpdate { get; set; }
public string? SourceVersion { get; set; }
public string? ETag { get; set; }
public string? LastModified { get; set; }
}

/// <summary>
Expand Down
87 changes: 86 additions & 1 deletion dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.IO.Compression;
using System.Net;
using System.Net.Http.Headers;
using System.Threading;
using Microsoft.Data.Sqlite;
using YamlDotNet.Core;
Expand All @@ -25,9 +27,24 @@ public static string Update(HttpClient client, SourceRecord source, string? appR
foreach (var candidate in MsixCandidates)
{
var url = $"{source.Arg.TrimEnd('/')}/{candidate}";
var hasLocalIndex = File.Exists(IndexPath(source, appRoot));
try
{
using var response = client.GetAsync(url).GetAwaiter().GetResult();
if (hasLocalIndex && TrySkipUnchangedBySourceVersion(client, url, source, candidate, out var detail))
return detail;

using var request = new HttpRequestMessage(HttpMethod.Get, url);
if (hasLocalIndex)
AddConditionalHeaders(request, source);

using var response = client.Send(request, HttpCompletionOption.ResponseHeadersRead);
if (response.StatusCode == HttpStatusCode.NotModified && hasLocalIndex)
{
UpdateHttpValidators(source, response);
source.LastUpdate = DateTime.UtcNow;
return $"Already up to date from {candidate}";
}

if (!response.IsSuccessStatusCode) continue;

var headerVersion = response.Headers.TryGetValues("x-ms-meta-sourceversion", out var values)
Expand Down Expand Up @@ -61,6 +78,8 @@ public static string Update(HttpClient client, SourceRecord source, string? appR
}

source.SourceVersion = headerVersion;
UpdateHttpValidators(source, response);
source.LastUpdate = DateTime.UtcNow;
return $"Updated from {candidate}" + (headerVersion != null ? $" (v{headerVersion})" : "");
}
catch (Exception ex)
Expand All @@ -75,6 +94,72 @@ public static string Update(HttpClient client, SourceRecord source, string? appR

private const int ReplaceRetryAttempts = 5;

private static bool TrySkipUnchangedBySourceVersion(
HttpClient client,
string url,
SourceRecord source,
string candidate,
out string detail)
{
detail = string.Empty;
if (string.IsNullOrWhiteSpace(source.SourceVersion))
return false;

try
{
using var request = new HttpRequestMessage(HttpMethod.Head, url);
using var response = client.Send(request, HttpCompletionOption.ResponseHeadersRead);
if (!response.IsSuccessStatusCode)
return false;

var headerVersion = response.Headers.TryGetValues("x-ms-meta-sourceversion", out var values)
? values.FirstOrDefault()
: null;
if (string.IsNullOrWhiteSpace(headerVersion) ||
!string.Equals(headerVersion, source.SourceVersion, StringComparison.OrdinalIgnoreCase))
{
return false;
}

UpdateHttpValidators(source, response);
source.LastUpdate = DateTime.UtcNow;
detail = $"Already up to date from {candidate} (v{headerVersion})";
return true;
}
catch (HttpRequestException)
{
return false;
}
catch (TaskCanceledException)
{
return false;
}
}

private static void AddConditionalHeaders(HttpRequestMessage request, SourceRecord source)
{
if (!string.IsNullOrWhiteSpace(source.ETag) &&
EntityTagHeaderValue.TryParse(source.ETag, out var eTag))
{
request.Headers.IfNoneMatch.Add(eTag);
}

if (!string.IsNullOrWhiteSpace(source.LastModified) &&
DateTimeOffset.TryParse(source.LastModified, out var lastModified))
{
request.Headers.IfModifiedSince = lastModified;
}
}

private static void UpdateHttpValidators(SourceRecord source, HttpResponseMessage response)
{
if (response.Headers.ETag is not null)
source.ETag = response.Headers.ETag.ToString();

if (response.Content.Headers.LastModified is DateTimeOffset lastModified)
source.LastModified = lastModified.ToString("R");
}

/// <summary>
/// Replaces <paramref name="target"/> with <paramref name="source"/>, surviving the
/// case where another handle holds the target open. A plain overwriting move uses
Expand Down
2 changes: 2 additions & 0 deletions dotnet/src/Devolutions.Pinget.Core/Repository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ public void ResetSource(string name)
{
source.LastUpdate = null;
source.SourceVersion = null;
source.ETag = null;
source.LastModified = null;
}

SourceStoreManager.Save(_store, _appRoot);
Expand Down
15 changes: 14 additions & 1 deletion dotnet/src/Devolutions.Pinget.Core/SourceStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,14 @@ internal static string GetPackagedFileCacheRoot(string? appRoot) =>
var sourceVersion = GetYamlScalar(sourceNode, "SourceVersion");
if (!string.IsNullOrWhiteSpace(sourceVersion))
source.SourceVersion = sourceVersion;

var eTag = GetYamlScalar(sourceNode, "ETag");
if (!string.IsNullOrWhiteSpace(eTag))
source.ETag = eTag;

var lastModified = GetYamlScalar(sourceNode, "LastModified");
if (!string.IsNullOrWhiteSpace(lastModified))
source.LastModified = lastModified;
}

store.Sources = sources.Values
Expand Down Expand Up @@ -284,7 +292,12 @@ private static void SavePackagedStore(string root, SourceStore store)
? new DateTimeOffset(source.LastUpdate.Value).ToUnixTimeSeconds()
: null,
["SourceVersion"] = source.SourceVersion,
}).Where(entry => entry["LastUpdate"] is not null || entry["SourceVersion"] is not null).ToList(),
["ETag"] = source.ETag,
["LastModified"] = source.LastModified,
}).Where(entry => entry["LastUpdate"] is not null ||
entry["SourceVersion"] is not null ||
entry["ETag"] is not null ||
entry["LastModified"] is not null).ToList(),
};

var userSourcesPath = GetPackagedUserSourcesPath(root);
Expand Down
Loading
Loading