Repository: xroche/httrack (2026-07-27T13:10:37Z)

Issue:
A 304 revisit loses its WARC request and response headers, and cache_read_including_broken returns freed pointers
Two pre-existing problems found while reviewing #808, both outside that PR's scope and unchanged by it.

A 304 revisit WARC record loses `warc_reqhdr` and `warc_resphdr`. The struct assignment in the `HTTP_NOT_MODIFIED` branch overwrites them before `back_finalize` reaches `warc_write_backtransaction` (src/htswarc.c:1864 and :1873), so the revisit record is written without the headers it should carry.

`cache_read_including_broken` (src/htscache.c:420-433) returns a struct whose `adr` and `headers` `back_clear_entry` has just freed. Any caller that reads them is using freed memory.


Discussion:

Patch:
diff --git a/src/htsback.c b/src/htsback.c
index c7460c8b..77efafee 100644
--- a/src/htsback.c
+++ b/src/htsback.c
@@ -4082,6 +4082,11 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache,
 
                       memset(&tmp, 0, sizeof(tmp));
                       back_connxfr(&back[i].r, &tmp);
+                      /* a real 304's headers belong to the revisit record, so
+                         they must survive the swap (#826); a forced one has
+                         none */
+                      if (server_sent_304)
+                        warc_move_request(&back[i].r, &tmp);
                       /* the cache entry overwrites the whole struct, so drop
                          what the 304 response still owns first (#782) */
                       back_free_response(&back[i].r);
@@ -4090,6 +4095,7 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache,
                                    back[i].url_sav, back[i].location_buffer);
                       back[i].r.location = back[i].location_buffer;
                       back_connxfr(&tmp, &back[i].r);
+                      warc_move_request(&tmp, &back[i].r);
                     }
 
                     // hack:
diff --git a/src/htscache.c b/src/htscache.c
index d76ee850..9e16a4b0 100644
--- a/src/htscache.c
+++ b/src/htscache.c
@@ -424,6 +424,11 @@ htsblk cache_read_including_broken(httrackp *opt, cache_back *cache,
 
     if (back_unserialize_ref(opt, adr, fil, &itemback) == 0) {
       r = itemback->r;
+      /* header fields only, like cache_readex(): the entry torn down below
+         owns these (#826) */
+      r.adr = NULL;
+      r.headers = NULL;
+      r.location = NULL;
       if (return_save != NULL)
         strlcpybuff(return_save, itemback->url_sav, HTS_URLMAXSIZE * 2);
       /* cleanup */
diff --git a/src/htscache.h b/src/htscache.h
index 4742110f..d8f5e3da 100644
--- a/src/htscache.h
+++ b/src/htscache.h
@@ -67,7 +67,9 @@ htsblk cache_read(httrackp * opt, cache_back * cache, const char *adr,
 htsblk cache_read_ro(httrackp * opt, cache_back * cache, const char *adr,
                      const char *fil, const char *save, char *location);
 /* Like cache_read, but also yields entries whose transfer broke; return_save
-   (optional, HTS_URLMAXSIZE*2) receives the entry's recorded save name. */
+   (optional, HTS_URLMAXSIZE*2) receives the entry's recorded save name.
+   Header fields only: adr, headers and location come back NULL, so the caller
+   owns and frees nothing. */
 htsblk cache_read_including_broken(httrackp *opt, cache_back *cache,
                                    const char *adr, const char *fil,
                                    char *return_save);
diff --git a/src/htswarc.c b/src/htswarc.c
index 2a265996..1f57e4a4 100644
--- a/src/htswarc.c
+++ b/src/htswarc.c
@@ -1360,6 +1360,17 @@ void warc_free_request(htsblk *r) {
   }
 }
 
+void warc_move_request(htsblk *src, htsblk *dst) {
+  if (src == NULL || dst == NULL || src == dst)
+    return;
+  freet(dst->warc_reqhdr);
+  freet(dst->warc_resphdr);
+  dst->warc_reqhdr = src->warc_reqhdr;
+  dst->warc_resphdr = src->warc_resphdr;
+  src->warc_reqhdr = NULL;
+  src->warc_resphdr = NULL;
+}
+
 void warc_adopt_rawspool(htsblk *r, const char *tmpfile_path) {
   if (r != NULL) {
     LLint rawsize;
@@ -1664,8 +1675,8 @@ int warc_write_transaction(warc_writer *w, const char *target_uri,
                            const char *ip, const char *req_hdr,
                            const char *resp_hdr, const char *body,
                            size_t body_len, const char *body_path,
-                           int statuscode, int is_update_unchanged,
-                           int truncated) {
+                           const char *content_type, int statuscode,
+                           int is_update_unchanged, int truncated) {
   wbuf http;
   char resp_id[64];
   char pdig[33];
@@ -1688,6 +1699,9 @@ int warc_write_transaction(warc_writer *w, const char *target_uri,
    */
   snprintf(statusbuf, sizeof(statusbuf), "%d", statuscode);
   http_header_value(resp_hdr, "Content-Type", mimebuf, sizeof(mimebuf));
+  /* a 304 declares no type, so index the one only the caller knows */
+  if (mimebuf[0] == '\0' && content_type != NULL)
+    strlncatbuff(mimebuf, content_type, sizeof(mimebuf), sizeof(mimebuf) - 1);
 
   /* A payload exists (for digesting) unless this is a bodyless 304. */
   has_payload = (body_len > 0 && (body != NULL || body_path != NULL) &&
@@ -1891,6 +1905,7 @@ void warc_write_backtransaction(httrackp *opt, lien_back *back) {
   }
 
   warc_write_transaction(w, uri, ip, back->r.warc_reqhdr, resp_hdr, body,
-                         body_len, body_path, back->r.statuscode, is_unchanged,
+                         body_len, body_path, back->r.contenttype,
+                         back->r.statuscode, is_unchanged,
                          back->r.warc_truncated);
 }
diff --git a/src/htswarc.h b/src/htswarc.h
index 57b97e05..b913ba80 100644
--- a/src/htswarc.h
+++ b/src/htswarc.h
@@ -68,6 +68,10 @@ void warc_stash_response(htsblk *r, const char *resphdr);
 /* Free both stashed header blocks (idempotent, NULL-safe). */
 void warc_free_request(htsblk *r);
 
+/* Move both stashed header blocks from src to dst, freeing dst's own. For the
+   304 path, which replaces the whole htsblk with the cache entry. */
+void warc_move_request(htsblk *src, htsblk *dst);
+
 /* Adopt the de-chunked compressed spool at tmpfile_path onto
    r->warc_rawpath/warc_rawsize (strdupt; frees any prior) so the WARC record
    stores the body verbatim. No-op leaving warc_rawpath NULL when tmpfile_path
@@ -104,6 +108,8 @@ int warc_surt(const char *url, char *out, size_t outsz);
    resp_hdr:    raw received response header block (status line + headers).
    body/body_len: decoded in-memory body, or NULL when on disk.
    body_path:   file re-read for the body when body==NULL (may be NULL).
+   content_type: CDXJ mime when resp_hdr declares none, as a 304 does; NULL
+                to leave the index line without one.
    is_update_unchanged: nonzero for a 304 server-not-modified revisit.
    truncated: a WARC_TRUNC_* reason to tag a cap-truncated body, else 0.
    The body is stored verbatim: Content-Encoding is kept and Content-Length set
@@ -113,8 +119,8 @@ int warc_write_transaction(warc_writer *w, const char *target_uri,
                            const char *ip, const char *req_hdr,
                            const char *resp_hdr, const char *body,
                            size_t body_len, const char *body_path,
-                           int statuscode, int is_update_unchanged,
-                           int truncated);
+                           const char *content_type, int statuscode,
+                           int is_update_unchanged, int truncated);
 
 /* Write one non-HTTP capture as a single WARC 'resource' record: the block is
    the raw payload (no HTTP envelope), Content-Type is the payload's own MIME.


Test patch:
diff --git a/src/htscache_selftest.c b/src/htscache_selftest.c
index 7ef7ce04..17abf6be 100644
--- a/src/htscache_selftest.c
+++ b/src/htscache_selftest.c
@@ -309,6 +309,72 @@ static int disk_fallback_selftest(httrackp *opt) {
   return fail;
 }
 
+/* A cache miss sends cache_read_including_broken() to the serialized reference
+   of an interrupted transfer, whose entry it then frees: what it returns must
+   not point into that entry (#826). */
+static int broken_ref_selftest(httrackp *opt) {
+  int fail = 0;
+  cache_back cache;
+  lien_back *entry;
+  htsblk r;
+  char save[HTS_URLMAXSIZE * 2];
+  const char *const adr = "example.com";
+  const char *const fil = "/interrupted.html";
+  static const char body[] = "<html><body>half a p";
+  static const char headers[] =
+      "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n";
+
+  entry = calloct(1, sizeof(lien_back));
+  strcpybuff(entry->url_adr, adr);
+  strcpybuff(entry->url_fil, fil);
+  concat(entry->url_sav, sizeof(entry->url_sav),
+         StringBuff(opt->path_html_utf8), "example.com/interrupted.html");
+  hts_init_htsblk(&entry->r);
+  entry->r.statuscode = 200;
+  entry->r.size = (LLint) (sizeof(body) - 1);
+  strcpybuff(entry->r.msg, "OK");
+  strcpybuff(entry->r.contenttype, "text/html");
+  entry->r.location = entry->location_buffer;
+  entry->r.adr = strdupt(body);
+  entry->r.headers = strdupt(headers);
+  if (back_serialize_ref(opt, entry) != 0) {
+    fprintf(stderr, "%s: broken-ref: cannot write the reference\n",
+            selftest_tag);
+    fail++;
+  }
+  freet(entry->r.adr);
+  freet(entry->r.headers);
+  freet(entry);
+
+  selftest_open_for_read(&cache, opt);
+  save[0] = '\0';
+  r = cache_read_including_broken(opt, &cache, adr, fil, save);
+  selftest_close(&cache);
+
+  if (r.statuscode != 200 || strcmp(r.contenttype, "text/html") != 0) {
+    fprintf(stderr,
+            "%s: broken-ref: statuscode %d type '%s', want 200/text/html"
+            " (reference not read back)\n",
+            selftest_tag, r.statuscode, r.contenttype);
+    fail++;
+  }
+  if (strstr(save, "interrupted.html") == NULL) {
+    fprintf(stderr, "%s: broken-ref: save name '%s' lost\n", selftest_tag,
+            save);
+    fail++;
+  }
+  /* the entry owned all three and is gone, so a non-NULL one is dangling */
+  if (r.adr != NULL || r.headers != NULL || r.location != NULL) {
+    fprintf(stderr,
+            "%s: broken-ref: returned freed adr/headers/location"
+            " (%p/%p/%p)\n",
+            selftest_tag, (void *) r.adr, (void *) r.headers,
+            (void *) r.location);
+    fail++;
+  }
+  return fail;
+}
+
 typedef struct {
   size_t budget;  /**< bytes allowed through before writes start failing */
   int fail_errno; /**< errno set on the failing write (ENOSPC, EIO, ...) */
@@ -800,6 +866,9 @@ int cache_selftests(httrackp *opt, const char *dir) {
   /* pass 5: the disk-fallback read path (X-In-Cache: 0, body on disk) */
   failures += disk_fallback_selftest(opt);
 
+  /* pass 6: the broken-transfer reference fallback */
+  failures += broken_ref_selftest(opt);
+
   for (i = 0; i < large_count; i++) {
     freet(large_body[i]);
   }
diff --git a/src/htsselftest.c b/src/htsselftest.c
index 4df937a1..eee9180c 100644
--- a/src/htsselftest.c
+++ b/src/htsselftest.c
@@ -4442,14 +4442,14 @@ static int st_warc(httrackp *opt, int argc, char **argv) {
       "GET /a.html HTTP/1.1\r\nHost: test.local\r\n\r\n",
       "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n"
       "Transfer-Encoding : chunked\r\nContent-Length: 999\r\n\r\n",
-      a_body, sizeof(a_body) - 1, NULL, 200, 0, 0);
+      a_body, sizeof(a_body) - 1, NULL, NULL, 200, 0, 0);
 
   /* 302 redirect: header-only, no body. */
   warc_write_transaction(
       w, "http://test.local/r", "127.0.0.1",
       "GET /r HTTP/1.1\r\nHost: test.local\r\n\r\n",
       "HTTP/1.1 302 Found\r\nLocation: http://test.local/a.html\r\n\r\n", NULL,
-      0, NULL, 302, 0, 0);
+      0, NULL, NULL, 302, 0, 0);
 
   /* 200 binary, chunked coding on the wire (already de-chunked here). */
   warc_write_transaction(
@@ -4457,7 +4457,7 @@ static int st_warc(httrackp *opt, int argc, char **argv) {
       "GET /b.bin HTTP/1.1\r\nHost: test.local\r\n\r\n",
       "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n"
       "Transfer-Encoding: chunked\r\n\r\n",
-      "\x00\x01\x02\x03\x04", 5, NULL, 200, 0, 0);
+      "\x00\x01\x02\x03\x04", 5, NULL, NULL, 200, 0, 0);
 
   /* 200 with a body shorter than the declared Content-Length (rewritten). */
   warc_write_transaction(
@@ -4465,20 +4465,20 @@ static int st_warc(httrackp *opt, int argc, char **argv) {
       "GET /trunc HTTP/1.1\r\nHost: test.local\r\n\r\n",
       "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: "
       "100\r\n\r\n",
-      "short", 5, NULL, 200, 0, 0);
+      "short", 5, NULL, NULL, 200, 0, 0);
 
   /* Same payload as a.html at a new URL: identical-payload-digest revisit
      (OpenSSL builds only; a plain build writes a second full response). */
   warc_write_transaction(w, "http://test.local/a2.html", "127.0.0.1",
                          "GET /a2.html HTTP/1.1\r\nHost: test.local\r\n\r\n",
                          "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n",
-                         a_body, sizeof(a_body) - 1, NULL, 200, 0, 0);
+                         a_body, sizeof(a_body) - 1, NULL, NULL, 200, 0, 0);
 
   /* 304 revisit with an EMPTY response-header block: the block is just the
      2-byte separator, so declared Content-Length must be exactly 2 (F3). */
   warc_write_transaction(w, "http://test.local/nm", "127.0.0.1",
                          "GET /nm HTTP/1.1\r\nHost: test.local\r\n\r\n", "",
-                         NULL, 0, NULL, 304, 1, 0);
+                         NULL, 0, NULL, NULL, 304, 1, 0);
 
   warc_close(w);
 
@@ -4649,13 +4649,13 @@ static int st_warc_trunc(httrackp *opt, int argc, char **argv) {
       w, "http://test.local/big.bin", "127.0.0.1",
       "GET /big.bin HTTP/1.1\r\nHost: test.local\r\n\r\n",
       "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\r\n", body,
-      sizeof(body) - 1, NULL, 200, 0, WARC_TRUNC_LENGTH);
+      sizeof(body) - 1, NULL, NULL, 200, 0, WARC_TRUNC_LENGTH);
   warc_write_transaction(
       w, "http://test.local/big.gz", "127.0.0.1",
       "GET /big.gz HTTP/1.1\r\nHost: test.local\r\n\r\n",
       "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Encoding: "
       "gzip\r\n\r\n",
-      (const char *) gz, sizeof(gz), NULL, 200, 0, WARC_TRUNC_TIME);
+      (const char *) gz, sizeof(gz), NULL, NULL, 200, 0, WARC_TRUNC_TIME);
   warc_close(w);
 
   data = warc_slurp(path, &data_len);
@@ -4809,7 +4809,7 @@ static int st_warc_rotate(httrackp *opt, int argc, char **argv) {
     warc_write_transaction(
         w, uri, "127.0.0.1", "GET / HTTP/1.1\r\nHost: test.local\r\n\r\n",
         "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\r\n",
-        (const char *) body, sizeof(body), NULL, 200, 0, 0);
+        (const char *) body, sizeof(body), NULL, NULL, 200, 0, 0);
   }
   warc_close(w);
   opt->warc_max_size = saved_max;
@@ -4891,7 +4891,7 @@ static int st_warc_verbatim(httrackp *opt, int argc, char **argv) {
       "GET /z.html HTTP/1.1\r\nHost: test.local\r\n\r\n",
       "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Encoding: "
       "gzip\r\nTransfer-Encoding: chunked\r\nContent-Length: 999\r\n\r\n",
-      (const char *) a_gz, sizeof(a_gz), NULL, 200, 0, 0);
+      (const char *) a_gz, sizeof(a_gz), NULL, NULL, 200, 0, 0);
   warc_close(w);
 
   data = warc_slurp(path, &data_len);
@@ -5057,7 +5057,7 @@ static int st_warc_longurl(httrackp *opt, int argc, char **argv) {
     /* Distinct payloads: identical ones dedupe into revisit records. */
     snprintf(body, sizeof(body), "<html><body>%04d</body></html>\n", (int) len);
     if (warc_write_transaction(w, uri, NULL, NULL, resp_hdr, body, strlen(body),
-                               NULL, 200, 0, 0) != 0) {
+                               NULL, NULL, 200, 0, 0) != 0) {
       fprintf(stderr, "warc-longurl: write failed at length %d\n", (int) len);
       err = 1;
     }
@@ -5117,7 +5117,7 @@ static int st_warc_cdx(httrackp *opt, int argc, char **argv) {
   unsigned char *warc = NULL, *cdx = NULL;
   size_t warc_len = 0, cdx_len = 0;
   hts_boolean saved_cdx;
-  int err = 0, nlines = 0;
+  int err = 0, nlines = 0, nm_lines = 0;
   const char *lp, *cend;
   char prev[2048];
 
@@ -5134,20 +5134,27 @@ static int st_warc_cdx(httrackp *opt, int argc, char **argv) {
   warc_write_transaction(w, "http://www.example.com/one", "127.0.0.1",
                          "GET /one HTTP/1.1\r\nHost: www.example.com\r\n\r\n",
                          "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n",
-                         "one body\n", 9, NULL, 200, 0, 0);
+                         "one body\n", 9, NULL, NULL, 200, 0, 0);
   warc_write_resource(w, "ftp://files.example.com/data.bin", "127.0.0.1",
                       "application/octet-stream", "\x00\x01\x02\x03", 4, NULL,
                       0);
   warc_write_transaction(w, "http://alpha.example.com/two", "127.0.0.1",
                          "GET /two HTTP/1.1\r\nHost: alpha.example.com\r\n\r\n",
                          "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n",
-                         "two body\n", 9, NULL, 200, 0, 0);
+                         "two body\n", 9, NULL, NULL, 200, 0, 0);
   /* Same payload as /one at a new URL: identical-payload-digest revisit under
      OpenSSL, a full response otherwise; either way one index line. */
   warc_write_transaction(w, "http://zeta.example.com/dup", "127.0.0.1",
                          "GET /dup HTTP/1.1\r\nHost: zeta.example.com\r\n\r\n",
                          "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n",
-                         "one body\n", 9, NULL, 200, 0, 0);
+                         "one body\n", 9, NULL, NULL, 200, 0, 0);
+  /* A 304 declares no type, so the index line takes the caller's (#826). */
+  warc_write_transaction(w, "http://nm.example.com/kept", "127.0.0.1",
+                         "GET /kept HTTP/1.1\r\nHost: nm.example.com\r\n"
+                         "If-Modified-Since: Mon, 01 Jan 2024 00:00:00 GMT\r\n"
+                         "\r\n",
+                         "HTTP/1.1 304 Not Modified\r\n\r\n", NULL, 0, NULL,
+                         "text/html", 200, 1, 0);
   warc_close(w);
   opt->warc_cdx = saved_cdx;
 
@@ -5200,6 +5207,11 @@ static int st_warc_cdx(httrackp *opt, int argc, char **argv) {
     }
     memcpy(url, us, urllen);
     url[urllen] = '\0';
+    if (strstr(url, "nm.example.com") != NULL) {
+      nm_lines++;
+      if (strstr(line, "\"mime\": \"text/html\"") == NULL)
+        err = 1;
+    }
     if (len == 0 || off > warc_len || len > warc_len - off) {
       err = 1;
       goto nextline;
@@ -5223,8 +5235,8 @@ static int st_warc_cdx(httrackp *opt, int argc, char **argv) {
   }
   freet(warc);
   freet(cdx);
-  if (nlines != 4)
-    err = 1; /* 3 responses/revisits + 1 resource; no warcinfo/request */
+  if (nlines != 5 || nm_lines != 1)
+    err = 1; /* 4 responses/revisits + 1 resource; no warcinfo/request */
   printf("warc-cdx: %d index lines: %s\n", nlines, err ? "FAIL" : "OK");
   return err;
 }
@@ -5291,13 +5303,13 @@ static int st_warc_wacz(httrackp *opt, int argc, char **argv) {
   warc_write_transaction(w, "http://www.example.com/", "127.0.0.1",
                          "GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n",
                          "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n",
-                         "<html>home</html>\n", 18, NULL, 200, 0, 0);
+                         "<html>home</html>\n", 18, NULL, NULL, 200, 0, 0);
   warc_write_transaction(
       w, "http://www.example.com/data.bin", "127.0.0.1",
       "GET /data.bin HTTP/1.1\r\nHost: www.example.com\r\n\r\n",
       "HTTP/1.1 200 OK\r\n"
       "Content-Type: application/octet-stream\r\n\r\n",
-      "\x00\x01\x02\x03\x04", 5, NULL, 200, 0, 0);
+      "\x00\x01\x02\x03\x04", 5, NULL, NULL, 200, 0, 0);
   warc_close(w);
 
   /* Unzip every member in-process. */
diff --git a/tests/122_local-warc-revisit-headers.test b/tests/122_local-warc-revisit-headers.test
new file mode 100755
index 00000000..c6d2ba9f
--- /dev/null
+++ b/tests/122_local-warc-revisit-headers.test
@@ -0,0 +1,20 @@
+#!/bin/bash
+#
+# A server-not-modified revisit stands for a 304 exchange, so its record has to
+# carry it: the 304 response block plus the concurrent request record holding the
+# conditional header that drew it (#826). The update pass archives under a name
+# of its own, or a revisit-only pass keeps the previous file instead (#759).
+
+set -eu
+
+: "${top_srcdir:=..}"
+
+export WARC_VALIDATE_UPDATE="warc-revisit.warc.gz"
+export WARC_VALIDATE_NORESP="index.html page.html"
+export WARC_VALIDATE_EXCHANGE=1
+
+bash "$top_srcdir/tests/local-crawl.sh" --errors 0 --warc-validate \
+    --rerun-args '--warc-file warc-revisit' \
+    --log-found 'no files updated' \
+    --found 'mini304/index.html' --found 'mini304/page.html' \
+    httrack 'BASEURL/mini304/index.html' --warc-file warc-out
diff --git a/tests/local-crawl.sh b/tests/local-crawl.sh
index ad2eb6df..2ef1b205 100755
--- a/tests/local-crawl.sh
+++ b/tests/local-crawl.sh
@@ -461,7 +461,8 @@ debug "host root: $hostroot"
 
 # --- optional WARC validation (stdlib validator, no warcio) ------------------
 # WARC_VALIDATE_BODY="URLSUB=HEX" byte-checks a fresh-crawl response body;
-# WARC_VALIDATE_NORESP="URLSUB..." asserts those assets are revisits post-update.
+# WARC_VALIDATE_NORESP="URLSUB..." asserts those assets are revisits post-update;
+# WARC_VALIDATE_EXCHANGE=1 asserts each revisit carries its 304 request/response.
 if test -n "$warc_validate"; then
     validator=$(nativepath "${testdir}/warc-validate.py")
     warc=$(find "$mirrorroot" -maxdepth 2 \( -name '*.warc.gz' -o -name '*.warc' \) 2>/dev/null | sort | tail -n1)
@@ -479,7 +480,10 @@ if test -n "$warc_validate"; then
     # body and keeps Content-Encoding, instead of expecting a decoded body.
     test -n "${WARC_VALIDATE_VERBATIM:-}" && bodyargs+=(--verbatim)
     info "validating fresh WARC (response bodies)"
-    "$python" "$validator" "$(nativepath "$fresh")" "${bodyargs[@]}" >&2 ||
+    # macOS bash 3.2 calls an empty array unbound under set -u, and a caller
+    # asking only for the revisit checks leaves this one empty.
+    "$python" "$validator" "$(nativepath "$fresh")" \
+        ${bodyargs[@]+"${bodyargs[@]}"} >&2 ||
         die "fresh WARC validation failed"
     result "OK"
 
@@ -493,6 +497,7 @@ if test -n "$warc_validate"; then
         for sub in ${WARC_VALIDATE_NORESP:-}; do
             revargs+=(--no-response-for "$sub")
         done
+        test -n "${WARC_VALIDATE_EXCHANGE:-}" && revargs+=(--revisit-exchange)
         info "validating update WARC (revisits)"
         "$python" "$validator" "$(nativepath "$upd")" "${revargs[@]}" >&2 ||
             die "update WARC validation failed"
diff --git a/tests/tests-list.mk b/tests/tests-list.mk
index ef4ef5be..32aa1219 100644
--- a/tests/tests-list.mk
+++ b/tests/tests-list.mk
@@ -195,5 +195,6 @@ TESTS += 96_local-refetch-keep.test
 TESTS += 97_local-warc-update-keep.test
 TESTS += 98_local-warc-segments.test
 TESTS += 99_local-robots-error.test
+TESTS += 122_local-warc-revisit-headers.test
 TESTS += 119_local-proxytrack-ndx-fields2.test
 TESTS += 118_local-proxytrack-arcwrite.test
diff --git a/tests/warc-validate.py b/tests/warc-validate.py
index e10540a9..fe424030 100755
--- a/tests/warc-validate.py
+++ b/tests/warc-validate.py
@@ -11,6 +11,10 @@
 #                             WARC-Payload-Digest matching sha1(body) when present
 #   --no-response-for SUB     the asset containing SUB must be a revisit: no
 #                             response may target it, and a revisit must
+#   --revisit-exchange        every server-not-modified revisit carries the 304
+#                             exchange it stands for: a 304 status line in its
+#                             block, and a concurrent request record holding the
+#                             conditional header that drew it
 #   --verbatim                compressed asset: --expect-body-hex instead keeps
 #                             Content-Encoding, checks the HTTP Content-Length is
 #                             the stored (compressed) length, asserts the stored
@@ -101,6 +105,7 @@ def check_body_verbatim(rec, http_hdr, body, sub, want, digests_emitted):
 def main():
     argv = sys.argv[1:]
     expect_revisit = "--expect-revisit" in argv
+    revisit_exchange = "--revisit-exchange" in argv
     verbatim = "--verbatim" in argv
     body_specs = [s.split("=", 1) for s in opt_values(argv, "--expect-body-hex")]
     no_resp = opt_values(argv, "--no-response-for")
@@ -116,6 +121,8 @@ def main():
     total = revisits = responses = infos = 0
     body_hits = {sub: False for sub, _ in body_specs}
     revisit_hits = {sub: False for sub in no_resp}
+    requests = {}  # WARC-Concurrent-To -> request block
+    exchanges = []  # (shown uri, record id) of the server-not-modified revisits
     for rec in records(data):
         total += 1
         if not rec.startswith(b"WARC/1."):
@@ -176,6 +183,26 @@ def main():
             for sub in no_resp:
                 if sub.encode() in uri:
                     revisit_hits[sub] = True
+            if revisit_exchange and b"server-not-modified" in profile:
+                block = rec[hdr_end : hdr_end + block_len]
+                status = block.split(b"\r\n", 1)[0].split()
+                if len(status) < 2 or status[1] != b"304":
+                    sys.exit(
+                        "revisit for %s does not carry the 304 it stands for: %r"
+                        % (shown, block.split(b"\r\n", 1)[0])
+                    )
+                exchanges.append((shown, field(header, b"WARC-Record-ID")))
+        elif wtype == b"request":
+            conc = field(header, b"WARC-Concurrent-To")
+            if conc is not None:
+                requests[conc] = rec[hdr_end : hdr_end + block_len].lower()
+
+    for shown, rec_id in exchanges:
+        req = requests.get(rec_id)
+        if req is None:
+            sys.exit("revisit for %s has no request record" % shown)
+        if b"if-modified-since:" not in req and b"if-none-match:" not in req:
+            sys.exit("request record for %s carries no conditional header" % shown)
 
     if total < 1:
         sys.exit("no records found")