Compare commits

..

2 Commits

Author SHA1 Message Date
Xavier Roche
3fbb15663f Broaden matching-system test coverage
Adds near-miss negative cases for the arms the filter tests skipped
(word order, exact tails, range boundaries, class case sensitivity,
empty star runs, descending ranges, *[param], rule ordering, failed
size tests), first coverage of mime: filters via a new
-#test=filtermime self-test, and a -#test=filtermemo differential:
20000 seeded random pattern/subject/size cases where the memoized
matcher must agree with a new no-memo oracle (strjoker_nomemo) on
result, *size and *size_flag, asserting both polarities occur.
Corrupting the memo indexing by hand makes the differential fail on
case 8, so it genuinely guards the memo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-10 09:06:27 +02:00
Xavier Roche
e751138c80 Fix catastrophic backtracking in the strjoker filter matcher
The recursive wildcard matcher re-explored the same (subject, pattern)
positions exponentially on *-heavy patterns against a non-matching
subject: 9 stars against 50 characters already took 149 seconds, about
7x per added star. Subjects come from crawled URLs, so a hostile site
could stall matching against a user's filters (ReDoS).

Memoize failures instead: one bit per (subject, pattern) offset pair,
checked before and recorded after each recursion, which bounds the
work polynomially. A pair that failed once fails forever (*size is
only written on the success path), so the memo is sound and the
matcher's semantics are unchanged; a differential run of 9000 random
pattern/subject/size cases old vs new is byte-identical. The bitmap
lives on the stack for common sizes, on the heap above 2 KB.

The engine test adds the pathological pattern (instant now, hung
before) plus a case where a later star's dead-end must re-extend an
earlier star, which guards against a single-backtrack-point rewrite.
A corpus seed lets the fuzz replay job cover the same shape.

Closes #501

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-10 08:51:38 +02:00
5 changed files with 113 additions and 68 deletions

View File

@@ -1646,104 +1646,138 @@ int back_add(struct_back * sback, httrackp * opt, cache_back * cache, const char
}
}
// tester cache
if ((strcmp(adr, "file://")) /* pas fichier */
&& ((!test) ||
(cache->type == 1)) /* cache prioritaire, laisser passer en test! */
&&
((strnotempty(save)) || (strcmp(fil, "/robots.txt") ==
0))) { // si en test on ne doit pas utiliser le
// cache sinon telescopage avec le 302..
if ((strcmp(adr, "file://")) /* pas fichier */
&&((!test) || (cache->type == 1)) /* cache prioritaire, laisser passer en test! */
&&((strnotempty(save)) || (strcmp(fil, "/robots.txt") == 0))) { // si en test on ne doit pas utiliser le cache sinon telescopage avec le 302..
#if HTS_FAST_CACHE
intptr_t hash_pos;
int hash_pos_return = 0;
#else
char *a = NULL;
#endif
#if HTS_FAST_CACHE
if (cache->hashtable) {
#else
if (cache->use) {
#endif
char BIGSTK buff[HTS_URLMAXSIZE * 4];
#if HTS_FAST_CACHE
strcpybuff(buff, adr);
strcatbuff(buff, fil);
hash_pos_return = coucal_read(cache->hashtable, buff, &hash_pos);
#else
buff[0] = '\0';
strcatbuff(buff, "\n");
strcatbuff(buff, adr);
strcatbuff(buff, "\n");
strcatbuff(buff, fil);
strcatbuff(buff, "\n");
a = strstr(cache->use, buff);
#endif
// Ok, noté en cache->. mais bien présent dans le cache ou sur disque?
#if HTS_FAST_CACHE
// negative values when data is not in cache
if (hash_pos_return < 0) {
if (!test) { // not test mode
/* note: no check with IS_DELAYED_EXT() enabled - postcheck by
* client please! */
if (save[0] != '\0' && !IS_DELAYED_EXT(save) &&
fsize_utf8(fconv(catbuff, sizeof(catbuff), save)) <=
0) { // final file missing or empty
int found = 0;
#else
if (a) {
#endif
if (!test) { // non mode test
#if HTS_FAST_CACHE==0
int pos = -1;
/* It is possible that the file has been moved due to changes in
* build structure */
{
char BIGSTK previous_save[HTS_URLMAXSIZE * 2];
htsblk r;
a += strlen(buff);
sscanf(a, "%d", &pos); // lire position
#endif
previous_save[0] = '\0';
r = cache_readex(opt, cache, adr, fil, /*head */ NULL,
#if HTS_FAST_CACHE==0
if (pos < 0) { // pas de mise en cache data, vérifier existence
#endif
/* note: no check with IS_DELAYED_EXT() enabled - postcheck by client please! */
if (save[0] != '\0' && !IS_DELAYED_EXT(save) && fsize_utf8(fconv(catbuff, sizeof(catbuff), save)) <= 0) { // fichier final n'existe pas ou est vide!
int found = 0;
/* It is possible that the file has been moved due to changes in build structure */
{
char BIGSTK previous_save[HTS_URLMAXSIZE * 2];
htsblk r;
previous_save[0] = '\0';
r =
cache_readex(opt, cache, adr, fil, /*head */ NULL,
/*bound to back[p] (temporary) */
back[p].location_buffer, previous_save, /*ro */
1);
/* Is supposed to be on disk only */
if (r.is_write && previous_save[0] != '\0') {
/* Exists, but with another (old) filename: rename (almost)
* silently */
if (strcmp(previous_save, save) != 0 &&
fexist_utf8(
fconv(catbuff, sizeof(catbuff), previous_save))) {
rename(fconv(catbuff, sizeof(catbuff), previous_save),
fconv(catbuff2, sizeof(catbuff2), save));
if (fexist_utf8(fconv(catbuff, sizeof(catbuff), save))) {
found = 1;
hts_log_print(opt, LOG_DEBUG,
"File '%s' has been renamed since last "
"mirror to '%s' ; applying changes",
previous_save, save);
} else {
hts_log_print(opt, LOG_ERROR,
"Could not rename '%s' to '%s' ; will have "
"to retransfer it",
previous_save, save);
/* Is supposed to be on disk only */
if (r.is_write && previous_save[0] != '\0') {
/* Exists, but with another (old) filename: rename (almost) silently */
if (strcmp(previous_save, save) != 0
&& fexist_utf8(fconv(catbuff, sizeof(catbuff), previous_save))) {
rename(fconv(catbuff, sizeof(catbuff), previous_save),
fconv(catbuff2, sizeof(catbuff2), save));
if (fexist_utf8(fconv(catbuff, sizeof(catbuff), save))) {
found = 1;
hts_log_print(opt, LOG_DEBUG,
"File '%s' has been renamed since last mirror to '%s' ; applying changes",
previous_save, save);
} else {
hts_log_print(opt, LOG_ERROR,
"Could not rename '%s' to '%s' ; will have to retransfer it",
previous_save, save);
}
}
}
back[p].location_buffer[0] = '\0';
}
back[p].location_buffer[0] = '\0';
}
/* Not found ? */
if (!found) {
// invalidate: gone from disk, force a refetch
hash_pos_return = 0;
if (opt->norecatch) {
if (!fexist_utf8(fconv(
catbuff, sizeof(catbuff),
save))) { // declared but missing: user erased it
FILE *fp =
FOPEN(fconv(catbuff, sizeof(catbuff), save), "wb");
/* Not found ? */
if (!found) {
#if HTS_FAST_CACHE
hash_pos_return = 0;
#else
a = NULL;
#endif
// dévalider car non présent sur disque dans structure originale!!!
// sinon, le fichier est ok à priori, mais on renverra un if-modified-since pour
// en être sûr
if (opt->norecatch) { // tester norecatch
if (!fexist_utf8(fconv(catbuff, sizeof(catbuff), save))) { // fichier existe pas mais déclaré: on l'a effacé
FILE *fp = FOPEN(fconv(catbuff, sizeof(catbuff), save), "wb");
if (fp)
fclose(fp);
if (fp)
fclose(fp);
hts_log_print(opt, LOG_WARNING,
"Previous file '%s' not found (erased by user ?), ignoring: %s%s",
save, back[p].url_adr, back[p].url_fil);
}
} else {
hts_log_print(opt, LOG_WARNING,
"Previous file '%s' not found (erased by "
"user ?), ignoring: %s%s",
"Previous file '%s' not found (erased by user ?), recatching: %s%s",
save, back[p].url_adr, back[p].url_fil);
}
} else {
hts_log_print(opt, LOG_WARNING,
"Previous file '%s' not found (erased by user "
"?), recatching: %s%s",
save, back[p].url_adr, back[p].url_fil);
}
}
} // fsize() <= 0
} // fsize() <= 0
#if HTS_FAST_CACHE==0
}
#endif
}
}
//
} else {
#if HTS_FAST_CACHE
hash_pos_return = 0;
#else
a = NULL;
#endif
}
if (hash_pos_return) { // in cache, with data
// Existe pas en cache, ou bien pas de cache présent
#if HTS_FAST_CACHE
if (hash_pos_return) { // OK existe en cache (et données aussi)!
#else
if (a != NULL) { // OK existe en cache (et données aussi)!
#endif
const int cache_is_prioritary = cache->type == 1
|| opt->state.stop != 0;
if (cache_is_prioritary) { // cache prioritaire (pas de test if-modified..)
@@ -1864,8 +1898,7 @@ int back_add(struct_back * sback, httrackp * opt, cache_back * cache, const char
#endif
}
}
/* Not in cache ; maybe in temporary cache ? Warning: non-movable
"url_sav" */
/* Not in cache ; maybe in temporary cache ? Warning: non-movable "url_sav" */
else if (back_unserialize_ref(opt, adr, fil, &itemback) == 0) {
const off_t file_size = fsize_utf8(itemback->url_sav);

View File

@@ -491,7 +491,11 @@ static htsblk cache_readex_new(httrackp * opt, cache_back * cache,
hash_pos_return = coucal_read(cache->hashtable, buff, &hash_pos);
/* avoid errors on data entries */
if (adr[0] == '/' && adr[1] == '/' && adr[2] == '[') {
#if HTS_FAST_CACHE
hash_pos_return = 0;
#else
a = NULL;
#endif
}
if (hash_pos_return != 0) {

View File

@@ -76,6 +76,9 @@ Please visit our Website: http://www.httrack.com
// always direct-to-disk (0/1)
#define HTS_DIRECTDISK_ALWAYS 1
// fast cache (build hash table)
#define HTS_FAST_CACHE 1
// le > peut être considéré comme un tag de fermeture de commentaire (<!-- > est
// valide)
#define GT_ENDS_COMMENT 1

View File

@@ -134,6 +134,10 @@ RUN_CALLBACK0(opt, end); \
back_delete_all(opt, &cache, sback); \
back_free(&sback); \
checkrobots_free(&robots); \
if (cache.use) { \
freet(cache.use); \
cache.use = NULL; \
} \
if (cache.zipOutput) { \
zipClose(cache.zipOutput, \
"Created by HTTrack Website Copier/" HTTRACK_VERSION); \

View File

@@ -194,6 +194,7 @@ struct cache_back {
int type;
int ro; /**< read-only: no new cache is written */
char *use; /**< in-memory list of cached adr+fil keys */
FILE *lst; /**< file list, used for purge */
FILE *txt; /**< human-readable file list (info) */
char lastmodified[256];