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");