From bdfbb30e2c5f2ea983442ea67d1c398d9a2cade8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 3 Jun 2026 12:25:26 -0400 Subject: [PATCH] Avoid unchanged preindexed source downloads Persist HTTP validators for preindexed sources and use them to skip rewriting the cached index when the CDN reports the package has not changed. Mirror WinGet's source-version check so unchanged refresh probes reset local freshness without redownloading the MSIX. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CoreTests.cs | 73 ++++++ .../YamlRoundtripTests.cs | 4 + dotnet/src/Devolutions.Pinget.Core/Models.cs | 2 + .../PreIndexedSource.cs | 87 ++++++- .../src/Devolutions.Pinget.Core/Repository.cs | 2 + .../Devolutions.Pinget.Core/SourceStore.cs | 15 +- rust/crates/pinget-core/src/lib.rs | 222 +++++++++++++++++- 7 files changed, 391 insertions(+), 14 deletions(-) diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index effa361..6a639c9 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -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() { @@ -3509,6 +3554,8 @@ public TestPreindexedSourceServer(byte[]? msixBytes, IEnumerable _msixBytes = msixBytes; @@ -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; @@ -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(); diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/YamlRoundtripTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/YamlRoundtripTests.cs index 199997a..3982b83 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/YamlRoundtripTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/YamlRoundtripTests.cs @@ -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", }, }, }); @@ -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); } diff --git a/dotnet/src/Devolutions.Pinget.Core/Models.cs b/dotnet/src/Devolutions.Pinget.Core/Models.cs index 1226714..56e6a5d 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Models.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Models.cs @@ -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; } } /// diff --git a/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs b/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs index f9bfce1..22ca99d 100644 --- a/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs +++ b/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs @@ -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; @@ -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) @@ -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) @@ -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"); + } + /// /// Replaces with , surviving the /// case where another handle holds the target open. A plain overwriting move uses diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index e83b1f4..408d893 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -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); diff --git a/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs b/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs index 0fdb4ca..b6ad3e4 100644 --- a/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs +++ b/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs @@ -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 @@ -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); diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index eeaf15b..1c10e2a 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -14,7 +14,9 @@ use std::sync::{Arc, Mutex, OnceLock, RwLock}; use anyhow::{Context, Result, anyhow, bail}; use chrono::{DateTime, Duration, Utc}; +use reqwest::StatusCode; use reqwest::blocking::{Client, Response}; +use reqwest::header::{ETAG, HeaderMap, HeaderValue, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED}; use serde::{Deserialize, Serialize}; use serde_json::{Map as JsonMap, Value as JsonValue}; use serde_yaml::{Mapping as YamlMapping, Value as YamlValue}; @@ -102,6 +104,10 @@ pub struct SourceRecord { pub last_update: Option>, #[serde(default)] pub source_version: Option, + #[serde(default)] + pub etag: Option, + #[serde(default)] + pub last_modified: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -123,6 +129,8 @@ impl Default for SourceStore { priority: 0, last_update: None, source_version: None, + etag: None, + last_modified: None, }, SourceRecord { name: "msstore".to_owned(), @@ -134,6 +142,8 @@ impl Default for SourceStore { priority: 0, last_update: None, source_version: None, + etag: None, + last_modified: None, }, ], } @@ -938,6 +948,8 @@ impl Repository { priority, last_update: None, source_version: None, + etag: None, + last_modified: None, }); self.save_store()?; Ok(()) @@ -1019,6 +1031,8 @@ impl Repository { } else { self.store.sources[idx].last_update = None; self.store.sources[idx].source_version = None; + self.store.sources[idx].etag = None; + self.store.sources[idx].last_modified = None; } self.save_store()?; @@ -2962,28 +2976,57 @@ impl Repository { } fn update_preindexed(&mut self, source_index: usize) -> Result { - let source = &mut self.store.sources[source_index]; - let state_dir = source_state_dir(&self.app_root, source); + let source_snapshot = self.store.sources[source_index].clone(); + let state_dir = source_state_dir(&self.app_root, &source_snapshot); fs::create_dir_all(&state_dir).context("failed to create source state directory")?; + let index_path = preindexed_index_path(&self.app_root, &source_snapshot); + let has_local_index = index_path.exists(); let mut last_error = None; for candidate in PREINDEXED_CANDIDATES { - let url = format!("{}/{}", source.arg.trim_end_matches('/'), candidate); - match self.client.get(&url).send() { + let url = format!("{}/{}", source_snapshot.arg.trim_end_matches('/'), candidate); + if has_local_index + && let Some(detail) = + self.try_skip_unchanged_preindexed_by_source_version(source_index, &url, candidate) + { + return Ok(detail); + } + + let mut request = self.client.get(&url); + if has_local_index { + if let Some(etag) = &source_snapshot.etag + && let Ok(value) = HeaderValue::from_str(etag) + { + request = request.header(IF_NONE_MATCH, value); + } + if let Some(last_modified) = &source_snapshot.last_modified + && let Ok(value) = HeaderValue::from_str(last_modified) + { + request = request.header(IF_MODIFIED_SINCE, value); + } + } + + match request.send() { + Ok(response) if response.status() == StatusCode::NOT_MODIFIED && has_local_index => { + let source = &mut self.store.sources[source_index]; + update_preindexed_http_validators(source, response.headers()); + source.last_update = Some(Utc::now()); + return Ok(format!("already up to date from {}", candidate)); + } Ok(response) if response.status().is_success() => { let header_version = response .headers() .get("x-ms-meta-sourceversion") .and_then(|value| value.to_str().ok()) .map(str::to_owned); + let headers = response.headers().clone(); let bytes = response.bytes().context("failed to read preindexed package bytes")?; let payload = bytes.to_vec(); let index_bytes = extract_zip_member(&payload, "Public/index.db") .context("preindexed package did not contain Public/index.db")?; - fs::write(preindexed_package_path(&self.app_root, source), &payload) + fs::write(preindexed_package_path(&self.app_root, &source_snapshot), &payload) .context("failed to persist source package")?; - let index_path = preindexed_index_path(&self.app_root, source); let temp_index_path = state_dir.join(format!( "index.{}.tmp", Utc::now().timestamp_nanos_opt().unwrap_or_default() @@ -2998,8 +3041,10 @@ impl Repository { // SQLite recover against the wrong database. Drop them so the // next open starts clean. remove_sqlite_sidecars(&index_path); + let source = &mut self.store.sources[source_index]; source.last_update = Some(Utc::now()); source.source_version = header_version; + update_preindexed_http_validators(source, &headers); return Ok(format!("downloaded {}", candidate)); } Ok(response) => { @@ -3014,6 +3059,32 @@ impl Repository { Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded"))) } + fn try_skip_unchanged_preindexed_by_source_version( + &mut self, + source_index: usize, + url: &str, + candidate: &str, + ) -> Option { + let current_version = self.store.sources[source_index].source_version.as_deref()?; + let response = self.client.head(url).send().ok()?; + if !response.status().is_success() { + return None; + } + + let header_version = response + .headers() + .get("x-ms-meta-sourceversion") + .and_then(|value| value.to_str().ok())?; + if !header_version.eq_ignore_ascii_case(current_version) { + return None; + } + + let source = &mut self.store.sources[source_index]; + update_preindexed_http_validators(source, response.headers()); + source.last_update = Some(Utc::now()); + Some(format!("already up to date from {} (v{})", candidate, header_version)) + } + fn update_rest(&mut self, source_index: usize) -> Result { let info = self.fetch_rest_information(source_index, true)?; let source = &mut self.store.sources[source_index]; @@ -4401,6 +4472,16 @@ fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf { source_state_dir(app_root, source).join("index.db") } +fn update_preindexed_http_validators(source: &mut SourceRecord, headers: &HeaderMap) { + if let Some(value) = headers.get(ETAG).and_then(|value| value.to_str().ok()) { + source.etag = Some(value.to_owned()); + } + + if let Some(value) = headers.get(LAST_MODIFIED).and_then(|value| value.to_str().ok()) { + source.last_modified = Some(value.to_owned()); + } +} + /// Removes the `-wal` and `-shm` sidecars that SQLite/Turso leaves next to a /// database file. Used after replacing `index.db` so a stale write-ahead log /// from the previous database is not replayed against the new one. @@ -4868,6 +4949,8 @@ fn parse_system_winget_source_record(value: &JsonValue) -> Result priority: optional_json_i32(object, "Priority").unwrap_or_default(), last_update: None, source_version: None, + etag: None, + last_modified: None, }) } @@ -4993,6 +5076,18 @@ fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: O { source.source_version = Some(source_version); } + + if let Some(etag) = entry.get("ETag").and_then(|value| value.clone()) + && !etag.is_empty() + { + source.etag = Some(etag); + } + + if let Some(last_modified) = entry.get("LastModified").and_then(|value| value.clone()) + && !last_modified.is_empty() + { + source.last_modified = Some(last_modified); + } } Some(SourceStore { sources }) @@ -5083,6 +5178,8 @@ fn map_packaged_source_entry(entry: &BTreeMap>) -> Option .unwrap_or_default(), last_update: None, source_version: None, + etag: None, + last_modified: None, }) } @@ -5147,7 +5244,11 @@ fn render_packaged_sources_yaml(store: &SourceStore) -> String { fn render_packaged_metadata_yaml(store: &SourceStore) -> String { let mut lines = vec!["Sources:".to_owned()]; for source in &store.sources { - if source.last_update.is_none() && source.source_version.is_none() { + if source.last_update.is_none() + && source.source_version.is_none() + && source.etag.is_none() + && source.last_modified.is_none() + { continue; } @@ -5158,6 +5259,12 @@ fn render_packaged_metadata_yaml(store: &SourceStore) -> String { if let Some(source_version) = &source.source_version { lines.push(format!(" SourceVersion: {}", yaml_scalar(source_version))); } + if let Some(etag) = &source.etag { + lines.push(format!(" ETag: {}", yaml_scalar(etag))); + } + if let Some(last_modified) = &source.last_modified { + lines.push(format!(" LastModified: {}", yaml_scalar(last_modified))); + } } lines.push(String::new()); lines.join("\n") @@ -9580,6 +9687,8 @@ mod tests { priority: 0, last_update: None, source_version: None, + etag: None, + last_modified: None, }; assert_eq!(store_path(&app_root), app_root.join("sources.json")); @@ -9648,7 +9757,7 @@ mod tests { "Sources:\n - Name: winget\n Type: Microsoft.PreIndexed.Package\n Arg: https://cdn.winget.microsoft.com/cache\n Data: Microsoft.Winget.Source_8wekyb3d8bbwe\n IsTombstone: true\n - Name: corp\n Type: Microsoft.Rest\n Arg: https://packages.contoso.test/api\n Data: Contoso.Rest\n Explicit: true\n Priority: 7\n TrustLevel: 1\n IsTombstone: false\n", ), Some( - "Sources:\n - Name: corp\n LastUpdate: 1700000000\n SourceVersion: 1.2.3\n", + "Sources:\n - Name: corp\n LastUpdate: 1700000000\n SourceVersion: 1.2.3\n ETag: \"etag-v1\"\n LastModified: \"Wed, 03 Jun 2026 12:00:00 GMT\"\n", ), ) .expect("packaged store"); @@ -9665,6 +9774,8 @@ mod tests { assert!(corp.explicit); assert_eq!(corp.priority, 7); assert_eq!(corp.source_version.as_deref(), Some("1.2.3")); + assert_eq!(corp.etag.as_deref(), Some("etag-v1")); + assert_eq!(corp.last_modified.as_deref(), Some("Wed, 03 Jun 2026 12:00:00 GMT")); assert_eq!(corp.last_update, DateTime::::from_timestamp(1_700_000_000, 0)); } @@ -12528,6 +12639,8 @@ Installers: status: u16, body: Vec, source_version: Option, + etag: Option, + last_modified: Option, } struct TestHttpServer { @@ -12577,17 +12690,31 @@ Installers: status: 200, body, source_version: None, + etag: None, + last_modified: None, }, ); } fn set_source_package(&self, body: Vec, source_version: &str) { + self.set_source_package_with_validators(body, source_version, None, None); + } + + fn set_source_package_with_validators( + &self, + body: Vec, + source_version: &str, + etag: Option<&str>, + last_modified: Option<&str>, + ) { self.set_response( "/source.msix", TestHttpResponse { status: 200, body, source_version: Some(source_version.to_owned()), + etag: etag.map(str::to_owned), + last_modified: last_modified.map(str::to_owned), }, ); } @@ -12599,6 +12726,8 @@ Installers: status, body: Vec::new(), source_version: None, + etag: None, + last_modified: None, }, ); } @@ -12653,20 +12782,53 @@ Installers: status: 404, body: Vec::new(), source_version: None, + etag: None, + last_modified: None, }); - let reason = if response.status == 200 { "OK" } else { "Not Found" }; + let not_modified = response.status == 200 + && (response.etag.as_deref().is_some_and(|etag| { + request_header_value(&request, "If-None-Match") + .is_some_and(|value| value.split(',').any(|candidate| candidate.trim() == etag)) + }) || response.last_modified.as_deref().is_some_and(|last_modified| { + request_header_value(&request, "If-Modified-Since") + .is_some_and(|value| value.eq_ignore_ascii_case(last_modified)) + })); + let status = if not_modified { 304 } else { response.status }; + let body = if not_modified { + &[][..] + } else { + response.body.as_slice() + }; + let reason = match status { + 200 => "OK", + 304 => "Not Modified", + _ => "Not Found", + }; let mut headers = format!( "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n", - response.status, + status, reason, - response.body.len() + body.len() ); if let Some(source_version) = response.source_version { headers.push_str(&format!("x-ms-meta-sourceversion: {source_version}\r\n")); } + if let Some(etag) = response.etag { + headers.push_str(&format!("ETag: {etag}\r\n")); + } + if let Some(last_modified) = response.last_modified { + headers.push_str(&format!("Last-Modified: {last_modified}\r\n")); + } headers.push_str("\r\n"); stream.write_all(headers.as_bytes()).expect("write test HTTP headers"); - stream.write_all(&response.body).expect("write test HTTP body"); + stream.write_all(body).expect("write test HTTP body"); + } + + fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> { + request.lines().find_map(|line| { + let (header_name, value) = line.split_once(':')?; + header_name.eq_ignore_ascii_case(name).then(|| value.trim()) + }) } fn test_source_record(source_url: String) -> SourceRecord { @@ -12680,6 +12842,8 @@ Installers: priority: 0, last_update: Some(Utc::now()), source_version: None, + etag: None, + last_modified: None, } } @@ -12886,6 +13050,40 @@ Installers: let _ = fs::remove_dir_all(app_root); } + #[test] + fn preindexed_stale_index_not_modified_keeps_local_index() { + let app_root = temp_app_root("preindexed_ttl_not_modified"); + let server = TestHttpServer::start(); + server.set_status("/source2.msix", 404); + server.set_source_package_with_validators( + make_source_package(&["1.0.0", "2.0.0"]), + "fresh", + Some("\"same-index\""), + Some("Wed, 03 Jun 2026 12:00:00 GMT"), + ); + server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0")); + let mut source = test_source_record(server.url()); + source.last_update = Some(Utc::now() - Duration::days(2)); + source.etag = Some("\"same-index\"".to_owned()); + source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned()); + write_test_preindexed_index(&app_root, &source, &["1.0.0"]); + let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero())); + + let result = repository + .show(&show_test_package(None)) + .expect("show latest from not-modified stale index"); + + let refreshed_source = &repository.list_sources()[0]; + assert_eq!(result.manifest.version, "1.0.0"); + assert_eq!(server.request_count("/source.msix"), 1); + assert_eq!(refreshed_source.etag.as_deref(), Some("\"same-index\"")); + assert_eq!( + refreshed_source.last_modified.as_deref(), + Some("Wed, 03 Jun 2026 12:00:00 GMT") + ); + let _ = fs::remove_dir_all(app_root); + } + #[test] fn preindexed_fresh_index_mtime_skips_refresh_when_metadata_is_stale() { let app_root = temp_app_root("preindexed_fresh_mtime");