This commit is contained in:
Mateusz Gruszczyński committed 2026-10-06 23:08:43 +02:00
1 parent 5d2aaeabe6
commit 9d6614c1b4
62 files changed
+558 -230

No files matched your search

+17 -42
View File
@@ -1,5 +1,15 @@
//! Embedded Markdown documents for the authenticated Reader.
macro_rules! reader_doc {
($path:literal, $category:literal) => {
(
$path,
$category,
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", $path)),
)
};
}
pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
(
"README.md",
@@ -399,48 +409,13 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"/docs/V0.25.1_AUDIT.md"
)),
),
(
"docs/V0.26.6_IMPLEMENTATION.md",
"technical",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.26.6_IMPLEMENTATION.md")),
),
(
"docs/V0.26.5_IMPLEMENTATION.md",
"technical",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.26.5_IMPLEMENTATION.md")),
),
(
"docs/V0.26.4_IMPLEMENTATION.md",
"technical",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.26.4_IMPLEMENTATION.md"
)),
),
(
"docs/V0.26.3_IMPLEMENTATION.md",
"technical",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.26.3_IMPLEMENTATION.md"
)),
),
(
"docs/V0.26.2_IMPLEMENTATION.md",
"technical",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.26.2_IMPLEMENTATION.md"
)),
),
(
"docs/V0.26.1_IMPLEMENTATION.md",
"technical",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.26.1_IMPLEMENTATION.md"
)),
),
reader_doc!("docs/V0.26.7_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.26.6_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.26.5_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.26.4_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.26.3_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.26.2_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.26.1_IMPLEMENTATION.md", "technical"),
(
"docs/V0.25.5_AUDIT.md",
"audits",
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.26.6",
"version": "0.26.7",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
},
"servers": [
+1 -1
View File
@@ -6,7 +6,7 @@ function renderSettings() {
if (!settingsDirty.has("response-form")) {
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
$("expose-request-id").checked = !!diagnostics.request_id;
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.26.6";
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.26.7";
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
+27 -5
View File
@@ -10,6 +10,19 @@
const scope = () => $("cache-scope").value;
const domainFilter = () => $("cache-domain-filter").value;
const number = v => fmt(v ?? 0);
const occupancy = (used, limit) => {
const boundedLimit = Number(limit) || 0;
if (boundedLimit <= 0) return 0;
return Math.min(100, Math.max(0, (Number(used) || 0) / boundedLimit * 100));
};
const setMeter = (id, used, limit) => {
const value = occupancy(used, limit), meter = $(id);
if (meter) {
meter.value = value;
meter.setAttribute("aria-valuetext", `${value.toFixed(1)}%`);
}
return value;
};
const operationMessage = (id, value = "", error = false) => {
const box = $(id);
if (!box) return;
@@ -111,18 +124,27 @@
const total = field => groups.reduce((n, row) => n + (row[field] || 0), 0), all = scope() === "*";
const hits = total("hits"), misses = total("misses");
const hotEntries = all ? (m.entries || 0) : total("hot_entries"), coldEntries = all ? (m.cold_entries || 0) : total("cold_entries");
const hotEntryLimit = Number(data.settings?.max_entries) || 0;
const hotEntryPercent = setMeter("cp-hot-entries-meter", hotEntries, hotEntryLimit);
$("cp-entries").textContent = number(hotEntries);
$("cp-capacity").textContent = `${t("cacheGlobalLimit")}: ${number(data.settings?.max_entries)}`;
$("cp-capacity").textContent = `${t("cacheGlobalLimit")}: ${number(hotEntryLimit)} · ${hotEntryPercent.toFixed(1)}%`;
const resident = all ? (m.hot_allocated_bytes ?? m.resident_bytes ?? 0) : total("resident_bytes");
const logical = all ? (m.hot_logical_bytes ?? 0) : total("logical_bytes");
const hotLimit = m.hot_limit_bytes ?? data.settings?.max_bytes ?? 0;
$("cp-bytes").textContent = fmtBytes(resident); $("cp-memory").value = resident / Math.max(1, hotLimit) * 100;
$("cp-memory-detail").textContent = `${t("cacheLogicalShort")}: ${fmtBytes(logical)} · ${t("cacheLimitShort")}: ${fmtBytes(hotLimit)}`;
const hotPercent = setMeter("cp-memory", resident, hotLimit);
$("cp-bytes").textContent = fmtBytes(resident);
$("cp-memory-detail").textContent = `${t("cacheLogicalShort")}: ${fmtBytes(logical)} · ${t("cacheLimitShort")}: ${fmtBytes(hotLimit)} · ${hotPercent.toFixed(1)}%`;
$("cp-transient").textContent = `${fmtBytes(m.transient_bytes)} / ${fmtBytes(m.transient_limit_bytes ?? data.settings?.transient_max_bytes)}`;
$("cp-transient-detail").textContent = `${t("cacheFillShort")}: ${fmtBytes(m.fill_bytes_active)} · ${t("cacheQueueShort")}: ${fmtBytes(m.cold_queue_bytes)} / ${fmtBytes(m.cold_queue_limit_bytes ?? data.settings?.cold_queue_max_bytes)} · ${t("cacheIoShort")}: ${fmtBytes((Number(m.cold_write_bytes_active) || 0) + (Number(m.cold_read_bytes_active) || 0))}`;
const coldBytes = all ? (m.cold_bytes || 0) : total("cold_bytes");
$("cp-cold-entries").textContent = number(coldEntries); $("cp-cold-capacity").textContent = `${t("cacheGlobalLimit")}: ${number(data.settings?.cold_max_entries || 0)}`;
$("cp-cold-bytes").textContent = fmtBytes(coldBytes); $("cp-cold-disk").value = coldBytes / Math.max(1, data.settings?.cold_max_bytes || 1) * 100;
const coldEntryLimit = Number(data.settings?.cold_max_entries) || 0;
const coldEntryPercent = setMeter("cp-cold-entries-meter", coldEntries, coldEntryLimit);
$("cp-cold-entries").textContent = number(coldEntries);
$("cp-cold-capacity").textContent = `${t("cacheGlobalLimit")}: ${number(coldEntryLimit)} · ${coldEntryPercent.toFixed(1)}%`;
const coldLimit = Number(data.settings?.cold_max_bytes) || 0;
const coldPercent = setMeter("cp-cold-disk", coldBytes, coldLimit);
$("cp-cold-bytes").textContent = fmtBytes(coldBytes);
$("cp-cold-disk-detail").textContent = `${t("cacheLimitShort")}: ${fmtBytes(coldLimit)} · ${coldPercent.toFixed(1)}%`;
$("cp-ratio").textContent = `${fmt((all ? (m.hit_ratio || 0) : (hits + misses ? hits / (hits + misses) : 0)) * 100, 1)}%`;
$("cp-requests").textContent = `${number(all ? m.hits : hits)} HIT / ${number(all ? m.misses : misses)} MISS`;
const bypasses = all ? (Number(m.bypasses) || 0) : total("bypasses");
+14 -9
View File
@@ -1320,8 +1320,10 @@
</div>
</details>
<div class="stats-grid cache-stats">
<article class="stat"><span data-i18n="cacheHotObjects">Hot objects</span><strong
id="cp-entries">-</strong><small id="cp-capacity">-</small></article>
<article class="stat"><span data-i18n="cacheHotObjects" id="cache-hot-objects-label">Hot objects</span><strong
id="cp-entries">-</strong><small id="cp-capacity">-</small><progress
aria-labelledby="cache-hot-objects-label" id="cp-hot-entries-meter" max="100"
value="0"></progress></article>
<article class="stat"><span data-i18n="cacheHotMemory" id="cache-memory-label">Hot
memory</span><strong id="cp-bytes">-</strong><small
id="cp-memory-detail">-</small><progress aria-labelledby="cache-memory-label"
@@ -1329,10 +1331,13 @@
</article>
<article class="stat"><span data-i18n="cacheTransientMemory">Transient cache RAM</span><strong
id="cp-transient">-</strong><small id="cp-transient-detail">-</small></article>
<article class="stat"><span data-i18n="cacheColdObjects">Cold objects</span><strong
id="cp-cold-entries">-</strong><small id="cp-cold-capacity">-</small></article>
<article class="stat"><span data-i18n="cacheColdDisk">Cold disk</span><strong
id="cp-cold-bytes">-</strong><progress id="cp-cold-disk" max="100" value="0"></progress>
<article class="stat"><span data-i18n="cacheColdObjects" id="cache-cold-objects-label">Cold objects</span><strong
id="cp-cold-entries">-</strong><small id="cp-cold-capacity">-</small><progress
aria-labelledby="cache-cold-objects-label" id="cp-cold-entries-meter" max="100"
value="0"></progress></article>
<article class="stat"><span data-i18n="cacheColdDisk" id="cache-cold-disk-label">Cold disk</span><strong
id="cp-cold-bytes">-</strong><small id="cp-cold-disk-detail">-</small><progress
aria-labelledby="cache-cold-disk-label" id="cp-cold-disk" max="100" value="0"></progress>
</article>
<article class="stat"><span data-i18n="cacheHitRatio">Hit ratio</span><strong
id="cp-ratio">-</strong><small id="cp-requests">-</small></article>
@@ -1919,7 +1924,7 @@
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
</select>
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
id="server-value" maxlength="256" placeholder="RustProxy/v0.26.6"
id="server-value" maxlength="256" placeholder="RustProxy/v0.26.7"
spellcheck="false" />
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
<div class="switch-grid">
@@ -2718,7 +2723,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.26.6</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.26.7</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -3429,7 +3434,7 @@ max_execution_time = 30</code></pre>
<article class="card about-card">
<div class="about-product">
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
<div><span class="small-label">RustProxy</span><strong>0.26.6</strong></div>
<div><span class="small-label">RustProxy</span><strong>0.26.7</strong></div>
</div>
<dl class="about-meta">
<div>
+1 -1
View File
@@ -72,7 +72,7 @@
for (const direction of ["request", "response"]) {
const rules = value[direction] || []; if (!Array.isArray(rules) || rules.length > 32) fail(); for (const rule of rules) {
if (!rule || typeof rule !== "object" || Object.keys(rule).some(k => !["op", "name", "value"].includes(k)) || !["set", "append", "remove"].includes(rule.op) || typeof rule.name !== "string" || rule.name.length > 128 || !/^[-!#$%&'*+.^_`|~0-9A-Za-z]+$/.test(rule.name)) fail();
const name = rule.name.toLowerCase(); if (["host", "connection", "content-length", "transfer-encoding", "te", "trailer", "content-encoding", "content-range", "upgrade", "keep-alive", "proxy-connection", "proxy-authorization", "proxy-authenticate", "forwarded", "x-real-ip", "x-request-id", "x-rust-proxy-backend", "x-rust-proxy-backend-time", "x-rustproxy-backend-time"].includes(name) || name.startsWith("x-forwarded-") || name.startsWith("sec-websocket-")) throw new Error(t("headersProtected"));
const name = rule.name.toLowerCase(); if (["host", "age", "connection", "content-length", "transfer-encoding", "te", "trailer", "content-encoding", "content-range", "upgrade", "keep-alive", "proxy-connection", "proxy-authorization", "proxy-authenticate", "forwarded", "x-real-ip", "x-request-id", "x-rust-proxy-backend", "x-rust-proxy-backend-time", "x-rustproxy-backend-time"].includes(name) || name.startsWith("x-forwarded-") || name.startsWith("sec-websocket-")) throw new Error(t("headersProtected"));
if (rule.op === "remove") { if (Object.hasOwn(rule, "value") && rule.value !== null) fail(); } else if (typeof rule.value !== "string" || rule.value.length > 8192 || /[\r\n\0]/.test(rule.value)) fail();
}
} return value;
+1 -1
View File
@@ -47,7 +47,7 @@ pub enum StickyMode {
Cookie,
IpHash,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Keepalive {
pub enabled: bool,
+18 -4
View File
@@ -61,10 +61,24 @@ impl Pool {
&previous.settings.passive_health,
))
.ok();
if same_origin && same_health && (old.definition.enabled || !member.definition.enabled)
{
// This pool has not been published or cloned into routes yet.
if let Some(member) = Arc::get_mut(member) {
let same_clients = member.backend.client.reusable_with(&old.backend.client)
&& member
.backend
.websocket_client
.reusable_with(&old.backend.websocket_client);
// This pool has not been published or cloned into routes yet.
if let Some(member) = Arc::get_mut(member) {
if same_clients {
// Preserve the live Hyper pools and, with them, generation
// age/request/rotation state across unrelated snapshot
// rebuilds. Changed origins, address policy, keepalive or
// connect timeout still receive newly compiled clients.
member.backend = old.backend.clone();
}
if same_origin
&& same_health
&& (old.definition.enabled || !member.definition.enabled)
{
member.state = old.state.clone();
member.token = old.token.clone();
}
+17
View File
@@ -98,6 +98,23 @@ fn upstream_pool_recycle_limits_are_bounded_and_can_be_disabled() {
assert!(settings.validate().is_ok());
}
#[test]
fn compatible_pool_rebuild_reuses_upstream_clients() {
let previous = pool(Algorithm::LeastConnections, StickyMode::Off);
let mut rebuilt = pool(Algorithm::LeastConnections, StickyMode::Off);
let rebuilt_mut = Arc::get_mut(&mut rebuilt).expect("fresh pool must be uniquely owned");
assert!(!Arc::ptr_eq(
&rebuilt_mut.members[0].backend,
&previous.members[0].backend
));
rebuilt_mut.reuse_observations(&previous);
assert!(Arc::ptr_eq(
&rebuilt_mut.members[0].backend,
&previous.members[0].backend
));
}
#[test]
fn retry_policy_rejects_unbounded_or_non_5xx_configuration() {
let mut settings = Settings::default();
+24
View File
@@ -349,6 +349,30 @@
assert_eq!(cache.purge(None), 1);
assert!(cache.get(&t).is_none());
}
#[tokio::test]
async fn cache_hit_and_dashboard_age_advance_from_storage_clock() {
let cache = Cache::new(1);
let t = ticket(&cache, &HeaderMap::new());
fill(&cache, t.clone(), "payload", &settings()).await;
{
let mut store = cache.store.lock().unwrap_or_else(|e| e.into_inner());
let entry = store.entries.values_mut().next().expect("stored entry");
entry.received = Instant::now() - Duration::from_secs(5);
}
let hit = cache.get(&t).expect("fresh cached response");
assert!(
hit.response.headers()["age"]
.to_str()
.unwrap()
.parse::<u64>()
.unwrap()
>= 5
);
assert!(cache.dashboard(None, 0, 50).objects[0].age_seconds >= 5);
}
#[test]
fn defaults_enable_static_cookie_boost_for_common_assets() {
let settings = Settings::default();
+8 -1
View File
@@ -61,6 +61,12 @@ impl Settings {
#[derive(Clone)]
pub struct Skip;
/// Internal proof that a request carrying Cookie may still be compressed.
/// Only shared-cache HITs accepted by cache policy and local static Web Server
/// responses set this marker; arbitrary authenticated/dynamic responses do not.
#[derive(Clone, Copy)]
pub struct AllowCookie;
/// Encoding applied by RustProxy itself. This marker is kept in response
/// extensions so accounting never has to infer the selected codec from a
/// header that later response rules may rewrite.
@@ -132,7 +138,8 @@ fn eligible(
|| response.headers().contains_key("upgrade")
|| request.contains_key("range")
|| request.contains_key("authorization")
|| request.contains_key("cookie")
|| (request.contains_key("cookie")
&& response.extensions().get::<AllowCookie>().is_none())
|| no_transform(request)
|| no_transform(response.headers())
{
+36
View File
@@ -122,6 +122,42 @@ mod tests {
}
}
#[tokio::test]
async fn cache_safe_cookie_marker_allows_compression_without_weakening_default_guard() {
let settings = Settings {
gzip: true,
..Default::default()
};
let mut headers = HeaderMap::new();
headers.insert("accept-encoding", "gzip".parse().unwrap());
headers.insert("cookie", "session=secret".parse().unwrap());
let blocked = encode(response(), &headers, &Method::GET, &settings).await;
assert!(!blocked.headers().contains_key("content-encoding"));
let mut safe = response();
safe.extensions_mut().insert(AllowCookie);
let encoded = encode(safe, &headers, &Method::GET, &settings).await;
assert_eq!(encoded.headers()["content-encoding"], "gzip");
}
#[tokio::test]
async fn age_header_survives_downstream_compression() {
let settings = Settings {
gzip: true,
..Default::default()
};
let mut headers = HeaderMap::new();
headers.insert("accept-encoding", "gzip".parse().unwrap());
let mut cached = response();
cached.headers_mut().insert("age", "37".parse().unwrap());
let encoded = encode(cached, &headers, &Method::GET, &settings).await;
assert_eq!(encoded.headers()["content-encoding"], "gzip");
assert_eq!(encoded.headers()["age"], "37");
}
#[tokio::test]
async fn no_transform_personal_and_sse_are_not_compressed() {
let s = Settings {
+1 -1
View File
@@ -128,7 +128,7 @@ pub fn compile_with_defaults(
) -> Result<Snapshot> {
compile_reusing(config, revision, policy, defaults, None)
}
/// Rebuild clients and route policies, but retain compatible health observations,
/// Rebuild route policies and retain compatible backend clients, health observations,
/// full-exchange counters and affinity tokens through unrelated config edits.
pub fn compile_reusing(
config: Config,
+1 -1
View File
@@ -93,7 +93,7 @@ impl Default for AclProfile {
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Backend {
#[serde(default)]
+1
View File
@@ -52,6 +52,7 @@ fn protected(name: &str) -> bool {
matches!(
name,
"host"
| "age"
| "connection"
| "content-length"
| "transfer-encoding"
+1 -1
View File
@@ -41,7 +41,7 @@ use zeroize::Zeroizing;
#[command(
name = "rust-proxy",
version,
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.26.6"
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.26.7"
)]
struct Cli {
#[arg(
+1 -1
View File
@@ -239,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
}
});
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
println!("RustProxy live logs 0.26.6 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
println!("RustProxy live logs 0.26.7 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
if interactive { print!("> "); io::stdout().flush()?; }
let mut filters = LiveLogFilters::default();
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
+16
View File
@@ -278,6 +278,22 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
{
response.headers_mut().remove("alt-svc");
}
// Cookie-bearing requests are normally excluded from compression to avoid
// compressing potentially private dynamic content. The cache can explicitly
// ignore cookies for configured static extensions, though; once that request
// is a shared-cache HIT, cache policy has already proven the stored response
// contains no Set-Cookie/private/no-store metadata. Local Web Server static
// responses are equally independent of request cookies. Allow compression in
// those two bounded cases so cached/static browser assets still negotiate
// Content-Encoding.
if original_headers.contains_key("cookie")
&& (exchange.cache_status.load(Ordering::Relaxed) == 1
|| response.extensions().get::<StaticSourceResponse>().is_some())
{
response
.extensions_mut()
.insert(crate::compression::AllowCookie);
}
response = crate::compression::encode(
response,
&original_headers,
+3 -1
View File
@@ -35,7 +35,7 @@ pub use client::{
client, client_with_keepalive, client_with_timeout, ClientRotationStats, ProxyClient,
};
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AddressPolicy {
pub allow_private: bool,
pub accept_local_listener_risk: bool,
@@ -77,6 +77,8 @@ impl AddressPolicy {
let addresses = local_addresses();
self.interfaces_known = addresses.is_some();
self.local_ips = addresses.unwrap_or_default();
self.local_ips.sort_unstable();
self.local_ips.dedup();
self
}
pub fn with_bindings(mut self, bindings: &[SocketAddr]) -> Self {
+9 -1
View File
@@ -33,7 +33,7 @@ fn monotonic_millis() -> u64 {
.min(u64::MAX as u128) as u64
}
#[derive(Clone)]
#[derive(Clone, PartialEq, Eq)]
struct ClientFactory {
backend: Backend,
policy: AddressPolicy,
@@ -104,6 +104,14 @@ impl ProxyClient {
self.inner.stats()
}
/// Whether a newly compiled backend client has exactly the same connection
/// policy and lifecycle settings as this one. Compatible clients can be
/// retained across unrelated snapshot rebuilds so generation age and
/// rotation counters are not restarted from zero.
pub(crate) fn reusable_with(&self, other: &Self) -> bool {
self.inner.factory == other.inner.factory
}
/// Recycle an aged connection pool even when no user request arrives.
pub(crate) fn maintain_rotation(&self) {
self.inner.maintain_rotation();
+1 -1
View File
@@ -159,7 +159,7 @@ pub(super) fn fastcgi_params(
if context.tls { "on" } else { "off" }.into(),
);
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.26.6".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.26.7".into());
params.insert("REDIRECT_STATUS".into(), "200".into());
if let Some(value) = headers
.get(CONTENT_TYPE)
+1 -1
View File
@@ -185,7 +185,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u
"<a class=\"{class}\" href=\"{href}\"><span class=\"type\">{type_label}</span><span class=\"name\">{label}{slash}</span><span class=\"kind\">{kind}</span></a>"
));
}
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.26.6</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.26.7</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.into_bytes()
}
+1 -1
View File
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
let body = std::str::from_utf8(&body).unwrap();
assert!(body.contains("file.txt"));
assert!(body.contains("DIRECTORY LISTING"));
assert!(body.contains("RustProxy <b>0.26.6</b>"));
assert!(body.contains("RustProxy <b>0.26.7</b>"));
}
#[tokio::test]