Compare commits

...

9 Commits

Author SHA1 Message Date
Xavier Roche
5751991897 Merge origin/master after #784; bound test 102's crawls
Every other network crawl runs under a deadline through local-crawl.sh. This
test drives its own FTP fixture directly, so a wedge would hang the job until
CI cancels it and discards the log (#796).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-27 12:17:46 +02:00
Xavier Roche
95f8ebaa44 hts_rename_over() deletes its destination when the source is missing (#784)
The unlink-then-rename fallback in `hts_rename_over()` is there because Windows' rename() refuses an existing target, but it fired on any failed rename, ENOENT on the source included. A caller moving a temp file it never managed to write lost the destination and got `HTS_FALSE` back, which reads as "nothing happened". #754 unified four hand-rolled copies into this helper, so every call site inherited it.

The unlink now runs only for EEXIST, and only with a source that exists. EEXIST is the value that matters: the CRT maps ERROR_ALREADY_EXISTS there and keeps EACCES for a source another process holds, so accepting EACCES as well would have deleted the destination for a failure it had no part in, and the retry would then fail with the destination already gone. The source check is belt and braces for a CRT that reports neither. `hts_rename_utf8()` preserves errno across its free() calls now, since the gate reads it.

The existing call sites all derive their source's existence from a create that had to succeed first, so the bug was latent rather than live, but three of those destinations are user data: a mirrored file, a finished .wacz, and a rewritten page nothing will re-fetch. What is left of the window after this is #790.

The selftest probes what rename() does to an existing target and asserts against the regime it finds, so it runs three ways on Linux: native, then under an LD_PRELOAD rename() with Windows' shape, then under one reporting a locked source. The harness pins the expected regime per platform, so no leg can pass having exercised the other half. Seven mutants of the gate were each confirmed to fail it.

Closes #779

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 12:07:12 +02:00
Xavier Roche
3a096c589a x[strlen(x) - 1] indexes before the buffer on an empty string (#780)
Three bugs have already come out of the same one-liner: `x[strlen(x) - 1]` indexes one byte before the buffer when the string turns out to be empty (#729, #730, #768). Rather than wait for a fourth, this replaces the idiom everywhere with helpers that cannot underflow.

All 48 occurrences were reclassified by tracing each guard to where it actually lives instead of to the two lines above the index. 44 were already guarded, often a dozen lines up or in the caller, and one sits inside a commented-out block. The rest have no guard at the site, and two of those are reachable from a crawl.

`htscore.c:2194` is the one that matters: a one-byte stack out-of-bounds write in the end-of-mirror purge, confirmed under ASan and UBSan. `linput()` reads `old.lst` in 999-byte chunks, so a 1000-byte line comes back as 999 bytes plus a one-byte tail; for that tail `line + 1` is empty, and without `-O` so is `path_html`, which leaves `file` empty when the index runs. The crawled site controls the save-path length and therefore the line length. It needs a second run over the same project, which is what a re-crawl does. As controls, a 900-byte path and the same path with `-O` both stay clean.

`htsparse.c:2009` is a one-byte overread in the HTML parser, reachable from `<a href="   ">`, where the guard sits on the wrong side of the `&&`. The write on the next line is accidentally safe for the same reason. #768 itself turned out not to be reachable from a crawl, only through the `-#test=savename` hook, so I would not call that one a security fix.

The helpers live in `htssafe.h` beside the other bounded string operations, so no file needed a new include. Guards doing more than an emptiness check are kept, since folding those in would append a separator to an empty string.

`-#test=lastchar` checks the helpers against a poisoned neighbouring byte, including the `/`-before-the-buffer case from #768, and putting the missing length check back makes it fail. It also greps the source, so reverting any converted site fails instead of leaving the self-test green. `97_local-purge-longpath` drives the purge site end to end; on unfixed code the sanitized CI leg aborts at `htscore.c:2194`.

Built from identical source paths, 61 of 72 objects are byte-identical, one differs only in debug info, and the remaining 10 are the files edited.

The pointer spelling `x + strlen(x) - 1` has 25 occurrences and is filed separately as #781.

Closes #770
Closes #768

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 11:44:43 +02:00
Xavier Roche
1a4a5082b7 The --errpage placeholder block has been unreachable since 3.20.2 (#783)
The stand-in body `httpmirror()` builds for an error page has been unreachable since the 3.20.2 import: both guards say the opposite of their comments, so the block runs only when there is no save name and the URL *is* `/robots.txt`. `create_html_warning` is never assigned, so the HTML arm is dead outright. The GIF arm fires only if the user maps `.txt` to `image/gif` with `--assume`, and it then swaps `r.adr` for a 1070-byte buffer while leaving `r.size` at the error body's length, so the `robots_parse()` call below over-reads the heap.

Repair is not the one-character fix the inverted guards suggest. `filesave()` below writes `r.size` bytes, so uninverting the guards without also setting `r.size` moves the same over-read into the user's mirror. A correct repair would then replace every server error body with HTTrack's 2003 template by default, which is what `23_local-errpage.test` asserts is kept. Nobody has asked for the placeholder in twenty years, and #17 asked for the opposite.

`--errpage` itself is untouched: `store_errpage` keeps the server's error body and the normal save writes it. A byte-level differential against a master build over five error shapes under both `-o1` and `-o0` gives identical mirrors, and rebuilt objects differ only in `htscore.o`. The new test drives the one input that reached the block, and fails on master in a plain build rather than only under the sanitizers. Separately, the `-o` help text promises a generated page the engine has never produced; that predates this change and is filed as #787.

Closes #769

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 11:31:47 +02:00
Xavier Roche
5378fe529a Merge remote-tracking branch 'origin/master' into fix/ftp-refetch-truncates
Signed-off-by: Xavier Roche <roche@httrack.com>

# Conflicts:
#	src/htsback.c
2026-07-27 11:15:13 +02:00
Xavier Roche
d7312da326 tests: renumber the FTP re-fetch test past master
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-27 11:13:17 +02:00
Xavier Roche
1ad59352f2 Do not swap out a backlog slot that still owns a temporary
back_cleanup_background() moves a ready slot to the on-disk table via
back_clear_entry(), which unlinks back->tmpfile. A failed re-fetch sits
at STATUS_READY unfinalized, so its .bak was deleted before
back_finalize() could put it back, losing the previous copy about half
the time under load.

Covered by -#test=backswap, the crawl-level race needing concurrency the
suite cannot pin down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-27 11:12:22 +02:00
Xavier Roche
4fd6767a8f A stale .bak silently disables the re-fetch backup on Windows (#776)
`back_finalize()` moves an existing file aside to `<file>.bak` before truncating it, so an aborted re-fetch can put the previous copy back. That rename was a bare `RENAME`, and Windows refuses to rename onto an existing target, so a `.bak` outliving a killed run made every later re-fetch of that URL fail the rename, take the `tmpfile = NULL` branch with nothing logged, and truncate the live file with no backup at all. The #77 guard was off for that file, silently and for good. `hts_rename_over()` (#754) unlinks and retries, which fixes it.

Worth a look, because clobbering a stale `.bak` is not free. A run killed between the rename-aside and the finalize leaves the previous complete copy in `.bak` and a partial in the live file, and the next re-fetch now overwrites the good one. It still looks like the right trade: POSIX `rename()` has behaved exactly this way since #77 landed, so the alternative is leaving Windows with a guard that stays dead until someone deletes the file by hand, and a leftover `.bak` is engine garbage that nothing advertises or ever restores. The clobber is logged now, so it is at least visible.

Any failure to create the backup is logged too, instead of quietly disabling the safety net. `htscache.c`'s static `hts_rename()` wrapper and the hand-rolled `old.zip` unlink at its only call site go the same way, which removes a copy of the unlink-then-rename idiom rather than adding a fifth.

Test 101 plants both kinds of leftover before the update pass: a stale file, which has to be clobbered so the `-M` abort can still restore the pass-1 copy, and a directory, which cannot be clobbered and has to be reported instead. Only the Windows leg arms the first half, since POSIX clobbers on its own; the second is armed everywhere. Test 37 gains a live update pass so the dead pass rotates onto an existing `old.zip`, which nothing covered.

Two older bugs on the same path came out of the review and are filed separately: #774 (the `.bak` name collides with a mirrored file of the same name, reproduced) and #775 (a failed `filecreate()` on a chunked re-fetch commits the backup away).

Closes #758

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 11:10:17 +02:00
Xavier Roche
1ef3458b2e An FTP re-fetch truncated the mirrored file before the transfer
filecreate() emptied url_sav before a single byte had arrived, so a read
error, a timeout, a short body or a local write error destroyed the
previous copy. HTTP has taken a .bak aside since the #77 follow-up; FTP
never did.

Lift that backup out of back_wait()'s direct-to-disk path into
back_refetch_backup() and call it from the FTP transfer too, so both
inherit the same restore in back_finalize() rather than growing a second
copy of the idiom.

Closes #771

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-27 10:24:29 +02:00
32 changed files with 1144 additions and 315 deletions

View File

@@ -225,11 +225,12 @@ jobs:
# Every gate here exits 77, so an all-skipped suite would report green having
# tested nothing: pin the skips, and floor the passes in case the glob empties.
# footer-overflow skips on Windows (needs a path past MAX_PATH); crange pending #581;
# footer-overflow and purge-longpath skip on Windows (need a path past MAX_PATH);
# crange pending #581;
# webdav-mime needs a reapable background listener, which MSYS cannot give it;
# badmtime needs a filesystem that stores an mtime past gmtime's range;
# single-file ends on a GUI half needing htsserver, which this job does not build.
expected_skips=" 01_engine-footer-overflow.test 48_local-crange-memresume.test 71_local-crange-repaircache.test 79_local-proxytrack-webdav-mime.test 88_local-proxytrack-badmtime.test 94_local-single-file.test"
expected_skips=" 01_engine-footer-overflow.test 100_local-purge-longpath.test 48_local-crange-memresume.test 71_local-crange-repaircache.test 79_local-proxytrack-webdav-mime.test 88_local-proxytrack-badmtime.test 94_local-single-file.test"
[ "$pass" -ge 90 ] || { echo "::error::only $pass tests passed ($skip skipped)"; exit 1; }
[ "$skipped" = "$expected_skips" ] || { echo "::error::unexpected skips:$skipped"; exit 1; }
[ "$fail" -eq 0 ] || { echo "::error::failing:$failed"; exit 1; }

View File

@@ -487,141 +487,6 @@ regen:
#define HTS_DATA_UNKNOWN_HTML_LEN 0
#define HTS_DATA_ERROR_HTML "<html>"LF\
"<html xmlns=\"http://www.w3.org/1999/xhtml\" lang=\"en\">"LF\
""LF\
"<head>"LF\
" <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" />"LF\
" <meta name=\"description\" content=\"HTTrack is an easy-to-use website mirror utility. It allows you to download a World Wide website from the Internet to a local directory,building recursively all structures, getting html, images, and other files from the server to your computer. Links are rebuiltrelatively so that you can freely browse to the local site (works with any browser). You can mirror several sites together so that you can jump from one toanother. You can, also, update an existing mirror site, or resume an interrupted download. The robot is fully configurable, with an integrated help\" />"LF\
" <meta name=\"keywords\" content=\"httrack, HTTRACK, HTTrack, winhttrack, WINHTTRACK, WinHTTrack, offline browser, web mirror utility, aspirateur web, surf offline, web capture, www mirror utility, browse offline, local site builder, website mirroring, aspirateur www, internet grabber, capture de site web, internet tool, hors connexion, unix, dos, windows 95, windows 98, solaris, ibm580, AIX 4.0, HTS, HTGet, web aspirator, web aspirateur, libre, GPL, GNU, free software\" />"LF\
" <title>Page not retrieved! - HTTrack Website Copier</title>"LF\
" <style type=\"text/css\">"LF\
" <!--"LF\
""LF\
"body {"LF\
" margin: 0; padding: 0; margin-bottom: 15px; margin-top: 8px;"LF\
" background: #77b;"LF\
"}"LF\
"body, td {"LF\
" font: 14px \"Trebuchet MS\", Verdana, Arial, Helvetica, sans-serif;"LF\
" }"LF\
""LF\
"#subTitle {"LF\
" background: #000; color: #fff; padding: 4px; font-weight: bold; "LF\
" }"LF\
""LF\
"#siteNavigation a, #siteNavigation .current {"LF\
" font-weight: bold; color: #448;"LF\
" }"LF\
"#siteNavigation a:link { text-decoration: none; }"LF\
"#siteNavigation a:visited { text-decoration: none; }"LF\
""LF\
"#siteNavigation .current { background-color: #ccd; }"LF\
""LF\
"#siteNavigation a:hover { text-decoration: none; background-color: #fff; color: #000; }"LF\
"#siteNavigation a:active { text-decoration: none; background-color: #ccc; }"LF\
""LF\
""LF\
"a:link { text-decoration: underline; color: #00f; }"LF\
"a:visited { text-decoration: underline; color: #000; }"LF\
"a:hover { text-decoration: underline; color: #c00; }"LF\
"a:active { text-decoration: underline; }"LF\
""LF\
"#pageContent {"LF\
" clear: both;"LF\
" border-bottom: 6px solid #000;"LF\
" padding: 10px; padding-top: 20px;"LF\
" line-height: 1.65em;"LF\
" background-image: url(backblue.gif);"LF\
" background-repeat: no-repeat;"LF\
" background-position: top right;"LF\
" }"LF\
""LF\
"#pageContent, #siteNavigation {"LF\
" background-color: #ccd;"LF\
" }"LF\
""LF\
""LF\
".imgLeft { float: left; margin-right: 10px; margin-bottom: 10px; }"LF\
".imgRight { float: right; margin-left: 10px; margin-bottom: 10px; }"LF\
""LF\
"hr { height: 1px; color: #000; background-color: #000; margin-bottom: 15px; }"LF\
""LF\
"h1 { margin: 0; font-weight: bold; font-size: 2em; }"LF\
"h2 { margin: 0; font-weight: bold; font-size: 1.6em; }"LF\
"h3 { margin: 0; font-weight: bold; font-size: 1.3em; }"LF\
"h4 { margin: 0; font-weight: bold; font-size: 1.18em; }"LF\
""LF\
".blak { background-color: #000; }"LF\
".hide { display: none; }"LF\
".tableWidth { min-width: 400px; }"LF\
""LF\
".tblRegular { border-collapse: collapse; }"LF\
".tblRegular td { padding: 6px; background-image: url(fade.gif); border: 2px solid #99c; }"LF\
".tblHeaderColor, .tblHeaderColor td { background: #99c; }"LF\
".tblNoBorder td { border: 0; }"LF\
""LF\
""LF\
"// -->"LF\
"</style>"LF\
""LF\
"</head>"LF\
""LF\
"<table width=\"76%%\" border=\"0\" align=\"center\" cellspacing=\"0\" cellpadding=\"3\" class=\"tableWidth\">"LF\
" <tr>"LF\
" <td id=\"subTitle\">HTTrack Website Copier - Open Source offline browser</td>"LF\
" </tr>"LF\
"</table>"LF\
"<table width=\"76%%\" border=\"0\" align=\"center\" cellspacing=\"0\" cellpadding=\"0\" class=\"tableWidth\">"LF\
"<tr class=\"blak\">"LF\
"<td>"LF\
" <table width=\"100%%\" border=\"0\" align=\"center\" cellspacing=\"1\" cellpadding=\"0\">"LF\
" <tr>"LF\
" <td colspan=\"6\"> "LF\
" <table width=\"100%%\" border=\"0\" align=\"center\" cellspacing=\"0\" cellpadding=\"10\">"LF\
" <tr> "LF\
" <td id=\"pageContent\"> "LF\
"<!-- ==================== End prologue ==================== -->"LF\
"<h1><strong><u>Oops!...</u></strong></h1>"LF\
"<h3>This page has <font color=\"red\"><em>not</em></font> been retrieved by HTTrack Website Copier (%s). </h3>"LF\
"<script language=\"Javascript\">"LF\
"<!--"LF\
" var loc=document.location.toString();"LF\
" if (loc) {"LF\
" var pos=loc.indexOf('link=');"LF\
" if (pos>0) {"LF\
" document.write('Clic to the link <b>below</b> to go to the online location!<br><a href=\"'+loc.substring(pos+5)+'\">'+loc.substring(pos+5)+'</a><br>');"LF\
" } else"LF\
" document.write('(no location defined)');"LF\
" }"LF\
"// -->"LF\
"</script>"LF\
"<h6 align=\"right\">Mirror by HTTrack Website Copier</h6>"LF\
"</body>"LF\
"</html>"LF\
"<!-- ==================== Start epilogue ==================== -->"LF\
" </td>"LF\
" </tr>"LF\
" </table>"LF\
" </td>"LF\
" </tr>"LF\
" </table>"LF\
"</td>"LF\
"</tr>"LF\
"</table>"LF\
""LF\
"<table width=\"76%%\" height=\"100%%\" border=\"0\" align=\"center\" valign=\"bottom\" cellspacing=\"0\" cellpadding=\"0\">"LF\
" <tr>"LF\
" <td id=\"footer\"><small>&copy; 2014 Xavier Roche & other contributors - Web Design: Kauler Leto.</small></td>"LF\
" </tr>"LF\
"</table>"LF\
""LF\
"</body>"LF\
""LF\
"</html>"LF\
""LF\
""LF
// image gif "unknown"
#define HTS_DATA_UNKNOWN_GIF \
"\x47\x49\x46\x38\x39\x61\x20\x0\x20\x0\xf7\xff\x0\xc0\xc0\xc0\xff\x0\x0\xfc\x3\x0\xf8\x6\x0\xf6\x9\x0\xf2\xc\x0\xf0\xf\x0\xf0\xe\x0\xed\x11\x0\xec\x13\x0\xeb\x14\x0\xe9\x15\x0\xe8\x18\x0\xe6\x18\x0\xe5\x1a\x0\xe3\x1c\x0\xe2\x1d\x0\xe1\x1e\x0\xdf\x20\x0\xdd\x23\x0\xdd\x22\x0\xdb\x23\x0\xda\x25\x0\xd9\x25\x0\xd8\x27\x0\xd6\x29\x0\xd5\x2a\x0\xd3\x2c\x0\xd2\x2d\x0"\

View File

@@ -340,12 +340,61 @@ static int back_index_ready(httrackp * opt, struct_back * sback, const char *adr
}
static int slot_can_be_cached_on_disk(const lien_back * back) {
/* A pending backup or spool means the slot is not finalized, and the swap
would unlink it through back_clear_entry() (#771). */
if (back->tmpfile != NULL && back->tmpfile[0] != '\0')
return 0;
return (back->status == STATUS_READY && back->locked == 0
&& back->url_sav[0] != '\0'
&& strcmp(back->url_sav, BACK_ADD_TEST) != 0);
/* Note: not checking !IS_DELAYED_EXT(back->url_sav) or it will quickly cause the slots to be filled! */
}
int back_selftest_slot_swap(void) {
lien_back back;
int err = 0;
#define CHECK(want, why) \
do { \
if (slot_can_be_cached_on_disk(&back) != (want)) { \
fprintf(stderr, "backswap: expected %d for %s\n", (want), (why)); \
err = 1; \
} \
} while (0)
memset(&back, 0, sizeof(back));
back.status = STATUS_READY;
strcpybuff(back.url_sav, "/tmp/httrack-selftest.bin");
CHECK(1, "a plain ready slot");
back.tmpfile = back.tmpfile_buffer;
strcpybuff(back.tmpfile_buffer, "/tmp/httrack-selftest.bin.bak");
CHECK(0, "a slot still holding a re-fetch backup");
/* Callers clear a spent temporary by emptying the name, not the pointer. */
back.tmpfile_buffer[0] = '\0';
CHECK(1, "a slot whose temporary was already dropped");
back.tmpfile = NULL;
back.locked = 1;
CHECK(0, "a locked slot");
back.locked = 0;
back.status = STATUS_TRANSFER;
CHECK(0, "a slot still transferring");
back.status = STATUS_READY;
back.url_sav[0] = '\0';
CHECK(0, "a slot with no save name");
strcpybuff(back.url_sav, BACK_ADD_TEST);
CHECK(0, "the dummy test slot");
#undef CHECK
printf("backswap self-test: %s\n", err ? "FAIL" : "OK");
return err;
}
/* Put all backing entries that are ready in the storage hashtable to spare space and CPU */
int back_cleanup_background(httrackp * opt, cache_back * cache,
struct_back * sback) {
@@ -585,6 +634,29 @@ static int create_back_tmpfile(httrackp *opt, lien_back *const back,
return 0;
}
/* Note: utf-8 */
void back_refetch_backup(httrackp *opt, lien_back *const back) {
back->tmpfile = NULL;
if (fexist_utf8(back->url_sav)) {
hts_boolean saved = HTS_FALSE;
if (create_back_tmpfile(opt, back, "bak") == 0) {
/* clobber a .bak a killed run left behind, or the guard stays off for
good (#758) */
if (fexist_utf8(back->tmpfile))
hts_log_print(opt, LOG_WARNING, "replacing leftover backup %s",
back->tmpfile);
saved = hts_rename_over(back->url_sav, back->tmpfile);
}
if (!saved) {
hts_log_print(opt, LOG_WARNING | LOG_ERRNO,
"could not back up %s; an aborted re-fetch will lose it",
back->url_sav);
back->tmpfile = NULL;
}
}
}
/* Did the fetch fail to produce a response, as opposed to the engine
deliberately passing the resource over? Only the latter may be purged. */
static hts_boolean back_transfer_failed(const int statuscode) {
@@ -3164,20 +3236,7 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache,
back[i].url_sav, 1, 1,
back[i].r.notmodified);
back[i].r.compressed = 0;
/* Re-fetch over an existing file (#77 follow-up):
move the good copy aside before truncating it
so an aborted transfer can restore it. url_sav
is still written normally (file list intact).
*/
back[i].tmpfile = NULL;
if (fexist_utf8(back[i].url_sav)) {
if (create_back_tmpfile(opt, &back[i], "bak") !=
0 ||
RENAME(back[i].url_sav, back[i].tmpfile) !=
0) {
back[i].tmpfile = NULL;
}
}
back_refetch_backup(opt, &back[i]);
if ((back[i].r.out =
filecreate(&opt->state.strc,
back[i].url_sav)) == NULL) {

View File

@@ -139,6 +139,12 @@ int back_trylive(httrackp * opt, cache_back * cache, struct_back * sback,
const int p);
int back_finalize(httrackp * opt, cache_back * cache, struct_back * sback,
const int p);
/* Move the previous copy of back->url_sav to back->tmpfile so back_finalize()
can put it back when the re-fetch fails (#77 follow-up). Call right before
truncating url_sav; tmpfile stays NULL when there is nothing to save. */
void back_refetch_backup(httrackp *opt, lien_back *const back);
/* -#test=backswap: slots eligible for the on-disk ready table. */
int back_selftest_slot_swap(void);
void back_info(struct_back * sback, int i, int j, FILE * fp);
void back_infostr(struct_back *sback, int i, int j, char *s, size_t size);
LLint back_transferred(LLint add, struct_back * sback);

View File

@@ -855,13 +855,6 @@ static htsblk cache_readex_new(httrackp * opt, cache_back * cache,
return r;
}
// lecture d'un fichier dans le cache
// si save==null alors test unqiquement
static int hts_rename(httrackp * opt, const char *a, const char *b) {
hts_log_print(opt, LOG_DEBUG, "Cache: rename %s -> %s (%p %p)", a, b, a, b);
return RENAME(a, b);
}
/* Open the cache ZIP via hts_fopen_utf8 so a non-ASCII path_log isn't mangled
to ANSI (#630); 64-bit funcs keep multi-GB caches whole on Windows LLP64. */
static voidpf ZCALLBACK hts_zip_fopen_utf8(voidpf opaque, const void *filename,
@@ -991,29 +984,15 @@ void cache_init(cache_back * cache, httrackp * opt) {
OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_log),
"hts-cache/new.zip")))) { // a previous cache exists.. rename it
/* Remove OLD cache */
if (fexist_utf8(fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_log),
"hts-cache/old.zip"))) {
if (UNLINK(fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_log), "hts-cache/old.zip")) !=
0) {
hts_log_print(opt, LOG_WARNING | LOG_ERRNO,
"Cache: error while moving previous cache");
}
}
/* Rename */
if (hts_rename
(opt,
fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt), StringBuff(opt->path_log),
"hts-cache/new.zip"), fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_log),
"hts-cache/old.zip")) != 0) {
if (!hts_rename_over(
fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_log), "hts-cache/new.zip"),
fconcat(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
StringBuff(opt->path_log), "hts-cache/old.zip"))) {
hts_log_print(opt, LOG_WARNING | LOG_ERRNO,
"Cache: error while moving previous cache");
} else {
hts_log_print(opt, LOG_DEBUG, "Cache: successfully renamed");
hts_log_print(opt, LOG_DEBUG, "Cache: rotated new.zip to old.zip");
}
}
} else {

View File

@@ -676,7 +676,7 @@ int cache_selftests(httrackp *opt, const char *dir) {
char base[HTS_URLMAXSIZE];
strcpybuff(base, dir);
if (base[0] != '\0' && base[strlen(base) - 1] != '/') {
if (base[0] != '\0' && hts_lastchar(base) != '/') {
strcatbuff(base, "/");
}
StringCopy(opt->path_log, base);
@@ -856,7 +856,7 @@ static void golden_setup(httrackp *opt, const char *dir) {
char base[HTS_URLMAXSIZE];
strcpybuff(base, dir);
if (base[0] != '\0' && base[strlen(base) - 1] != '/') {
if (base[0] != '\0' && hts_lastchar(base) != '/') {
strcatbuff(base, "/");
}
StringCopy(opt->path_log, base);

View File

@@ -773,7 +773,7 @@ int httpmirror(char *url1, httrackp * opt) {
// sauter les + sans rien après..
if (strnotempty(tempo)) {
if ((plus == 0) && (type == 1)) { // implicite: *www.edf.fr par exemple
if (tempo[strlen(tempo) - 1] != '*') {
if (hts_lastchar(tempo) != '*') {
strcatbuff(tempo, "*"); // ajouter un *
}
}
@@ -1819,60 +1819,6 @@ int httpmirror(char *url1, httrackp * opt) {
// -- -- --
// sauver fichier
/* En cas d'erreur, vérifier que fichier d'erreur existe */
if (strnotempty(savename()) == 0) { // chemin de sauvegarde existant
if (strcmp(urlfil(), "/robots.txt") == 0) { // pas robots.txt
if (store_errpage) { // c'est une page d'erreur
int create_html_warning = 0;
int create_gif_warning = 0;
switch (ishtml(opt, urlfil())) { /* pas fichier html */
case 0: /* non html */
{
char buff[256];
guess_httptype_sized(opt, buff, sizeof(buff), urlfil());
if (strcmp(buff, "image/gif") == 0)
create_gif_warning = 1;
}
break;
case 1: /* html */
if (!r.adr) {
}
break;
default: /* don't know.. */
break;
}
/* Créer message d'erreur ? */
if (create_html_warning) {
char *adr =
(char *) malloct(strlen(HTS_DATA_ERROR_HTML) + 1100);
hts_log_print(opt, LOG_INFO, "Creating HTML warning file (%s)",
r.msg);
if (adr) {
if (r.adr) {
freet(r.adr);
r.adr = NULL;
}
sprintf(adr, HTS_DATA_ERROR_HTML, r.msg);
r.adr = adr;
}
} else if (create_gif_warning) {
char *adr = (char *) malloct(HTS_DATA_UNKNOWN_GIF_LEN);
hts_log_print(opt, LOG_INFO, "Creating GIF dummy file (%s)",
r.msg);
if (r.adr) {
freet(r.adr);
r.adr = NULL;
}
memcpy(adr, HTS_DATA_UNKNOWN_GIF, HTS_DATA_UNKNOWN_GIF_LEN);
r.adr = adr;
}
}
}
}
if (strnotempty(savename()) == 0) { // pas de chemin de sauvegarde
if (strcmp(urlfil(), "/robots.txt") == 0) { // robots.txt
char BIGSTK sitemaps[8192];
@@ -2190,7 +2136,9 @@ int httpmirror(char *url1, httrackp * opt) {
continue;
strcpybuff(file, StringBuff(opt->path_html));
strcatbuff(file, line + 1);
file[strlen(file) - 1] = '\0';
/* strip filenote()'s ']', absent when linput() truncated the
line */
hts_striplastchar(file, ']');
hts_changes_previous(opt, file + StringLength(opt->path_html));
if (!strstr(adr, line)) { // not found in the new list?
if (fexist_utf8(file)) { // still on disk
@@ -2217,12 +2165,11 @@ int httpmirror(char *url1, httrackp * opt) {
fseek(old_lst, 0, SEEK_SET);
while(!feof(old_lst)) {
linput(old_lst, line, 1000);
while(strnotempty(line) && (line[strlen(line) - 1] != '/')
&& (line[strlen(line) - 1] != '\\')) {
line[strlen(line) - 1] = '\0';
while (strnotempty(line) && (hts_lastchar(line) != '/') &&
(hts_lastchar(line) != '\\')) {
hts_choplastchar(line);
}
if (strnotempty(line))
line[strlen(line) - 1] = '\0';
hts_choplastchar(line);
if (strnotempty(line))
if (!strstr(adr, line)) { // non trouvé?
char BIGSTK file[HTS_URLMAXSIZE * 2];
@@ -2235,13 +2182,12 @@ int httpmirror(char *url1, httrackp * opt) {
if (opt->log) {
hts_log_print(opt, LOG_INFO, "Purging directory %s/",
file);
while(strnotempty(file)
&& (file[strlen(file) - 1] != '/')
&& (file[strlen(file) - 1] != '\\')) {
file[strlen(file) - 1] = '\0';
while (strnotempty(file) &&
(hts_lastchar(file) != '/') &&
(hts_lastchar(file) != '\\')) {
hts_choplastchar(file);
}
if (strnotempty(file))
file[strlen(file) - 1] = '\0';
hts_choplastchar(file);
}
}
}

View File

@@ -357,7 +357,7 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
char BIGSTK tempo[HTS_CDLMAXSIZE];
strcpybuff(tempo, argv[na] + 1);
if (tempo[0] == '\0' || tempo[strlen(tempo) - 1] != '"') {
if (hts_lastchar(tempo) != '"') {
char BIGSTK s[HTS_CDLMAXSIZE];
sprintf(s, "Missing quote in %s", argv[na]);
@@ -365,7 +365,7 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
htsmain_free();
return -1;
}
tempo[strlen(tempo) - 1] = '\0';
hts_choplastchar(tempo);
/* tempo is argv[na] minus its surrounding quotes, so it fits in place
*/
strlcpybuff(argv[na], tempo, strlen(argv[na]) + 1);
@@ -863,7 +863,7 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
char BIGSTK tempo[HTS_CDLMAXSIZE + 256];
strcpybuff(tempo, argv[na] + 1);
if (tempo[0] == '\0' || tempo[strlen(tempo) - 1] != '"') {
if (hts_lastchar(tempo) != '"') {
char s[HTS_CDLMAXSIZE + 256];
sprintf(s, "Missing quote in %s", argv[na]);
@@ -871,7 +871,7 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
htsmain_free();
return -1;
}
tempo[strlen(tempo) - 1] = '\0';
hts_choplastchar(tempo);
/* tempo is argv[na] minus its surrounding quotes, so it fits in place
*/
strlcpybuff(argv[na], tempo, strlen(argv[na]) + 1);
@@ -2724,10 +2724,7 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
char *a;
strcpybuff(rpath, StringBuff(opt->path_html));
if (rpath[0]) {
if (rpath[strlen(rpath) - 1] == '/')
rpath[strlen(rpath) - 1] = '\0';
}
hts_striplastchar(rpath, '/');
a = strrchr(rpath, '/');
if (a) {
*a = '\0';

View File

@@ -624,6 +624,9 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) {
} else {
file_notify(opt, back->url_adr, back->url_fil, back->url_sav, 1, 1,
0);
/* Every failure exit below would else leave the mirror truncated
(#771); the resume branch appends and needs no backup. */
back_refetch_backup(opt, back);
back->r.fp = filecreate(&opt->state.strc, back->url_sav);
}
strcpybuff(back->info, "receiving");

View File

@@ -83,9 +83,8 @@ void infomsg(const char *msg) {
/* try the flag as-is, then strip a trailing N as the numeric-arg
placeholder (cN -> c); this order keeps -%N from becoming -% */
p = optreal_find(cmd);
if (p < 0 && (int) strlen(cmd) > 2 &&
cmd[strlen(cmd) - 1] == 'N') {
cmd[strlen(cmd) - 1] = '\0';
if (p < 0 && (int) strlen(cmd) > 2 && hts_lastchar(cmd) == 'N') {
hts_striplastchar(cmd, 'N');
p = optreal_find(cmd);
}
if (p >= 0) {
@@ -211,7 +210,7 @@ void help_wizard(httrackp * opt) {
strcatbuff(str, "/websites/");
}
if (strnotempty(str))
if ((str[strlen(str) - 1] != '/') && (str[strlen(str) - 1] != '\\'))
if ((hts_lastchar(str) != '/') && (hts_lastchar(str) != '\\'))
strcatbuff(str, "/");
strcatbuff(stropt2, "-O \"");
strcatbuff(stropt2, str);

View File

@@ -5078,7 +5078,7 @@ static int hts_dns_resolve_nocache_list(const char *const hostname,
if (!strnotempty(hostname) || max <= 0) {
return 0;
}
if ((hostname[0] == '[') && (hostname[strlen(hostname) - 1] == ']')) {
if ((hostname[0] == '[') && (hts_lastchar(hostname) == ']')) {
size_t size = strlen(hostname);
char *copy = malloct(size + 1);
int count;
@@ -5491,9 +5491,8 @@ void cut_path(char *fullpath, char *path, size_t path_size, char *pname,
size_t pname_size) {
path[0] = pname[0] = '\0';
if (strnotempty(fullpath)) {
if ((fullpath[strlen(fullpath) - 1] == '/')
|| (fullpath[strlen(fullpath) - 1] == '\\'))
fullpath[strlen(fullpath) - 1] = '\0';
if (!hts_striplastchar(fullpath, '/'))
hts_striplastchar(fullpath, '\\');
if (strlen(fullpath) > 1) {
char *a;
@@ -6648,9 +6647,12 @@ int hts_rename_utf8(const char *oldpath, const char *newpath) {
LPWSTR wnewpath = hts_pathToUCS2(newpath);
if (woldpath != NULL && wnewpath != NULL) {
const int result = _wrename(woldpath, wnewpath);
/* Save errno: callers key off it (#779) and free() may clobber it. */
const int err = errno;
free(woldpath);
free(wnewpath);
errno = err;
return result;
} else {
if (woldpath != NULL)

View File

@@ -437,9 +437,7 @@ int url_savename(lien_adrfilsave *const afs,
strcpybuff(fil_complete_patche, normfil);
// Version avec ou sans /
if (fil_complete_patche[strlen(fil_complete_patche) - 1] == '/')
fil_complete_patche[strlen(fil_complete_patche) - 1] = '\0';
else
if (!hts_striplastchar(fil_complete_patche, '/'))
strcatbuff(fil_complete_patche, "/");
i = hash_read(hash, normadr, fil_complete_patche, HASH_STRUCT_ORIGINAL_ADR_PATH); // recherche table 2 (former->adr+former->fil)
if (i >= 0) {
@@ -515,7 +513,8 @@ int url_savename(lien_adrfilsave *const afs,
&& protocol != PROTOCOL_FTP
) {
// tester type avec requète HEAD si on ne connait pas le type du fichier
if (!((opt->check_type == 1) && (fil[strlen(fil) - 1] == '/'))) // slash doit être html?
if (!((opt->check_type == 1) &&
(hts_lastchar(fil) == '/'))) // slash doit être html?
if (opt->savename_delayed == HTS_SAVENAME_DELAYED_HARD ||
ishtml(opt, fil) < 0) { // unsure whether it's html or a file
// lire dans le cache
@@ -810,7 +809,7 @@ int url_savename(lien_adrfilsave *const afs,
// - - - DEBUT NOMMAGE - - -
// Donner nom par défaut?
if (fil[strlen(fil) - 1] == '/') {
if (hts_lastchar(fil) == '/') {
if (!strfield(adr_complete, "ftp://")
) {
strcatbuff(fil, DEFAULT_HTML); // nommer page par défaut!!
@@ -1284,7 +1283,7 @@ int url_savename(lien_adrfilsave *const afs,
hts_lowcase(afs->save);
if (afs->save[strlen(afs->save) - 1] == '/')
if (hts_lastchar(afs->save) == '/')
strcatbuff(afs->save, DEFAULT_HTML); // nommer page par défaut!!
}

View File

@@ -1701,7 +1701,8 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
#endif
) // ok pas de problème
url_ok = 1;
else if (tempo[strlen(tempo) - 1] == '/') { // un slash: ok..
else if (hts_lastchar(tempo) ==
'/') { // un slash: ok..
if (inscript) // sinon si pas javascript, méfiance (répertoire style base?)
url_ok = 1;
}
@@ -2005,9 +2006,8 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
if (eadr - html - 1 < HTS_URLMAXSIZE) { // pas trop long?
strncpy(lien, html, eadr - html - 1);
lien[eadr - html - 1] = '\0';
// supprimer les espaces
while((lien[strlen(lien) - 1] == ' ') && (strnotempty(lien)))
lien[strlen(lien) - 1] = '\0';
while (hts_striplastchar(lien, ' ')) {
}
} else
lien[0] = '\0'; // erreur
@@ -2207,7 +2207,7 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
// supposition dangereuse?
// OUI!!
#if HTS_TILDE_SLASH
if (lien[strlen(lien) - 1] != '/') {
if (hts_lastchar(lien) != '/') {
char *a = lien + strlen(lien) - 1;
// éviter aussi index~1.html
@@ -2272,7 +2272,7 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
// Vérifier les codebase=applet (au lieu de applet/)
if (p_type == -2) { // codebase
if (strnotempty(lien)) {
if (lien[strlen(lien) - 1] != '/') { // pas répertoire
if (hts_lastchar(lien) != '/') { // pas répertoire
strcatbuff(lien, "/");
}
}
@@ -2688,9 +2688,11 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
int cat_data_len = 0;
// ajouter lien external
switch ((link_has_authority(afs.af.adr)) ? 1
: ((afs.af.fil[strlen(afs.af.fil) - 1] ==
'/') ? 1 : (ishtml(opt, afs.af.fil)))) {
switch ((link_has_authority(afs.af.adr))
? 1
: ((hts_lastchar(afs.af.fil) == '/')
? 1
: (ishtml(opt, afs.af.fil)))) {
case 1:
case -2: // html ou répertoire
if (opt->getmode & HTS_GETMODE_HTML) {
@@ -2733,7 +2735,7 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
cat_data_len = HTS_DATA_UNKNOWN_HTML_LEN;
}
break;
} // html,gif
} // html,gif
if (patch_it) {
char BIGSTK save[HTS_URLMAXSIZE * 2];

View File

@@ -539,6 +539,36 @@ static HTS_INLINE HTS_UNUSED HTS_PRINTF_FUN(3, 4) void slprintfbuff_clip(
#define sprintfbuff(ARR, ...) slprintfbuff((ARR), sizeof(ARR), __VA_ARGS__)
#endif
/* Last character of s, or '\0' when s is empty. Replaces s[strlen(s) - 1],
which indexes one byte before the buffer on an empty string. */
static HTS_INLINE HTS_UNUSED char hts_lastchar(const char *s) {
const size_t len = strlen(s);
return len != 0 ? s[len - 1] : '\0';
}
/* Drop a trailing c from s if present; HTS_TRUE if one was dropped. */
static HTS_INLINE HTS_UNUSED hts_boolean hts_striplastchar(char *s, char c) {
const size_t len = strlen(s);
if (len != 0 && s[len - 1] == c) {
s[len - 1] = '\0';
return HTS_TRUE;
}
return HTS_FALSE;
}
/* Drop the last character of s whatever it is; HTS_TRUE if s was not empty. */
static HTS_INLINE HTS_UNUSED hts_boolean hts_choplastchar(char *s) {
const size_t len = strlen(s);
if (len != 0) {
s[len - 1] = '\0';
return HTS_TRUE;
}
return HTS_FALSE;
}
/* Thin aliases over the libc allocator/memcpy (historical "t" suffix); no
added bounds checking. freet() also NULLs the freed pointer and tolerates
NULL. memcpybuff() despite the name is a raw memcpy: the caller owns the

View File

@@ -5955,6 +5955,104 @@ static int st_mirrorio(httrackp *opt, int argc, char **argv) {
return 0;
}
static void ro_put(const char *path, const char *data) {
FILE *const fp = FOPEN(path, "wb");
assertf(fp != NULL);
assertf(fwrite(data, 1, strlen(data), fp) == strlen(data));
fclose(fp);
}
/* HTS_TRUE if path holds exactly data. */
static hts_boolean ro_is(const char *path, const char *data) {
char buf[64];
FILE *const fp = FOPEN(path, "rb");
size_t n;
if (fp == NULL)
return HTS_FALSE;
n = fread(buf, 1, sizeof(buf), fp);
fclose(fp);
return n == strlen(data) && memcmp(buf, data, n) == 0 ? HTS_TRUE : HTS_FALSE;
}
// -#test=renameover <dir>: hts_rename_over() must replace an existing dst and
// never delete one it did not replace (#779). Which half is live depends on
// what rename() does to an existing target, so probe that and name the regime.
static int st_renameover(httrackp *opt, int argc, char **argv) {
(void) opt;
if (argc < 1) {
fprintf(stderr, "renameover: needs a writable base dir\n");
return 1;
}
char src[HTS_URLMAXSIZE * 2], dst[HTS_URLMAXSIZE * 2];
int err = 0;
fconcat(src, sizeof(src), argv[0], "renameover-src.bin");
fconcat(dst, sizeof(dst), argv[0], "renameover-dst.bin");
(void) UNLINK(src);
(void) UNLINK(dst);
ro_put(src, "probe");
ro_put(dst, "probe");
const int probe = RENAME(src, dst) == 0 ? 0 : errno;
/* Only a target in the way is something the unlink can clear. */
const hts_boolean replaceable = probe == 0 || probe == EEXIST;
printf("renameover: regime %s\n",
probe == 0 ? "clobber" : (probe == EEXIST ? "fallback" : "refused"));
(void) UNLINK(src);
(void) UNLINK(dst);
ro_put(src, "new");
ro_put(dst, "old");
if (replaceable) {
/* An existing dst must still be replaced: the unlink is for this. */
if (!hts_rename_over(src, dst)) {
fprintf(stderr, "renameover: replacing an existing dst failed: %s\n",
strerror(errno));
err++;
} else if (!ro_is(dst, "new") || fexist_utf8(src)) {
fprintf(stderr, "renameover: dst was not replaced by src\n");
err++;
}
} else {
/* A failure the unlink cannot fix must leave dst as it was. */
if (hts_rename_over(src, dst)) {
fprintf(stderr, "renameover: an unfixable failure reported success\n");
err++;
}
if (!ro_is(dst, "old")) {
fprintf(stderr, "renameover: an unfixable failure destroyed dst\n");
err++;
}
}
/* A missing src must leave dst alone and report failure. */
(void) UNLINK(src);
ro_put(dst, "keep");
if (hts_rename_over(src, dst)) {
fprintf(stderr, "renameover: a missing src reported success\n");
err++;
}
if (!ro_is(dst, "keep")) {
fprintf(stderr, "renameover: a missing src destroyed dst\n");
err++;
}
/* Same, with dst absent too: nothing to lose, still a failure. */
(void) UNLINK(dst);
if (hts_rename_over(src, dst)) {
fprintf(stderr, "renameover: a missing src and dst reported success\n");
err++;
}
(void) UNLINK(dst);
printf("renameover: %s\n", err ? "FAIL" : "OK");
return err;
}
// -#test=direnum <dir>: enumerate a long+non-ASCII directory via the
// opendir/readdir wrappers; children must round-trip as UTF-8 (#133,#630).
static int st_direnum(httrackp *opt, int argc, char **argv) {
@@ -6268,6 +6366,13 @@ static void threadwait_gated_thread(void *arg) {
hts_mutexrelease(&threadwait_lock);
}
static int st_backswap(httrackp *opt, int argc, char **argv) {
(void) opt;
(void) argc;
(void) argv;
return back_selftest_slot_swap();
}
static int st_threadwait(httrackp *opt, int argc, char **argv) {
int err = 0;
int i, round;
@@ -6374,7 +6479,7 @@ static int st_changes_race(httrackp *opt, int argc, char **argv) {
return 1;
}
strcpybuff(base, argv[0]);
if (base[0] != '\0' && base[strlen(base) - 1] != '/')
if (base[0] != '\0' && hts_lastchar(base) != '/')
strcatbuff(base, "/");
StringCopy(opt->path_html, base);
StringCopy(opt->path_html_utf8, base);
@@ -6445,6 +6550,88 @@ static int st_changes_race(httrackp *opt, int argc, char **argv) {
return err;
}
/* The x[strlen(x) - 1] class (#770). The string starts mid-arena so the byte
it must not touch is a real neighbour; poisoned with '#', not 0, or a stray
NUL terminator would read as untouched. */
static int st_lastchar(httrackp *opt, int argc, char **argv) {
enum { off = 8 };
char arena[16];
char *const s = &arena[off];
const int guard = off - 1; /* what the old idiom clobbers */
int err = 0;
(void) opt;
(void) argc;
(void) argv;
#define REPOISON(str) \
do { \
memset(arena, '#', sizeof(arena)); \
strlcpybuff(s, (str), sizeof(arena) - off); \
} while (0)
#define CHECK(cond) \
do { \
if (!(cond)) { \
printf(" FAIL line %d: %s\n", __LINE__, #cond); \
err = 1; \
} \
} while (0)
/* the empty string: every helper must report "nothing" and touch nothing */
REPOISON("");
CHECK(hts_lastchar(s) == '\0');
CHECK(arena[guard] == '#');
REPOISON("");
CHECK(hts_striplastchar(s, '/') == HTS_FALSE);
CHECK(arena[guard] == '#');
CHECK(s[0] == '\0');
REPOISON("");
CHECK(hts_choplastchar(s) == HTS_FALSE);
CHECK(arena[guard] == '#');
CHECK(s[0] == '\0');
/* a '/' sitting where the underflow would land must not be mistaken for the
string's own last byte -- this is the #768 shape */
REPOISON("");
arena[guard] = '/';
CHECK(hts_lastchar(s) == '\0');
CHECK(hts_striplastchar(s, '/') == HTS_FALSE);
CHECK(arena[guard] == '/');
/* non-empty: ordinary behaviour */
REPOISON("ab/");
CHECK(hts_lastchar(s) == '/');
CHECK(hts_striplastchar(s, '/') == HTS_TRUE);
CHECK(strcmp(s, "ab") == 0);
CHECK(hts_striplastchar(s, '/') == HTS_FALSE);
CHECK(strcmp(s, "ab") == 0);
CHECK(hts_choplastchar(s) == HTS_TRUE);
CHECK(strcmp(s, "a") == 0);
CHECK(hts_choplastchar(s) == HTS_TRUE);
CHECK(s[0] == '\0');
CHECK(arena[guard] == '#');
/* one-character string: the boundary the guards get wrong */
REPOISON("/");
CHECK(hts_lastchar(s) == '/');
CHECK(hts_striplastchar(s, '/') == HTS_TRUE);
CHECK(s[0] == '\0');
CHECK(arena[guard] == '#');
/* control: the canary must be able to fail, or the checks above prove
nothing. Clobber it exactly as the unguarded idiom would. */
REPOISON("");
s[-1] = '\0';
CHECK(arena[guard] != '#');
#undef REPOISON
#undef CHECK
printf("lastchar self-test: %s\n", err ? "FAIL" : "OK");
return err;
}
/* ------------------------------------------------------------ */
/* Registry: name -> handler, with a usage hint and a one-line description. */
/* ------------------------------------------------------------ */
@@ -6500,12 +6687,16 @@ static const struct selftest_entry {
{"strsafe", "[overflow|overflow-buff|overflow-src [str]]",
"bounded string-op self-test", st_strsafe},
{"copyopt", "", "copy_htsopt option-copy self-test", st_copyopt},
{"lastchar", "", "last-char helpers never index before the buffer (#770)",
st_lastchar},
{"changes", "", "--changes bucket accounting and JSON escaping (#714)",
st_changes},
{"changes-race", "<dir>", "--changes under a late transfer thread (#714)",
st_changes_race},
{"threadwait", "", "htsthread_wait() joins threads spawned just before it",
st_threadwait},
{"backswap", "", "which backlog slots may be swapped to the ready table",
st_backswap},
{"pause", "", "randomized inter-file pause target self-test", st_pause},
{"relative", "<link> <curr-file>", "relative link between two paths",
st_relative},
@@ -6603,6 +6794,10 @@ static const struct selftest_entry {
{"mirrorio", "<dir>",
"round-trip a long+non-ASCII path through the mirror I/O wrappers",
st_mirrorio},
{"renameover", "<dir>",
"hts_rename_over(): replace dst, but never delete a dst it did not "
"replace",
st_renameover},
{"direnum", "<dir>",
"enumerate a long+non-ASCII directory through opendir/readdir",
st_direnum},

View File

@@ -1050,9 +1050,9 @@ int smallserver(T_SOC soc, char *url, char *method, char *data, char *path) {
if (!linput(fp, line, sizeof(line) - 2)) {
*str = '\0';
}
if (*str && str[strlen(str) - 1] == '\\') {
if (hts_lastchar(str) == '\\') {
nocr = 1;
str[strlen(str) - 1] = '\0';
hts_striplastchar(str, '\\');
}
while(*str) {
char *pos;
@@ -1169,11 +1169,8 @@ int smallserver(T_SOC soc, char *url, char *method, char *data, char *path) {
char *rpath = (char *) adr;
//find_handle h;
if (rpath[0]) {
if (rpath[strlen(rpath) - 1] == '/') {
rpath[strlen(rpath) - 1] = '\0'; /* note: patching stored (inhash) value */
}
}
/* note: patching stored (inhash) value */
hts_striplastchar(rpath, '/');
{
const char *profiles = hts_getcategories(rpath, 0);
const char *categ = hts_getcategories(rpath, 1);

View File

@@ -985,10 +985,7 @@ HTSEXT_API int hts_buildtopindex(httrackp * opt, const char *path,
&& toptemplate_bodycat) {
strcpybuff(rpath, path);
if (rpath[0]) {
if (rpath[strlen(rpath) - 1] == '/')
rpath[strlen(rpath) - 1] = '\0';
}
hts_striplastchar(rpath, '/');
fpo = fopen(fconcat(catbuff, sizeof(catbuff), rpath, "/index.html"), "wb");
if (fpo) {
@@ -1201,11 +1198,8 @@ HTSEXT_API char *hts_getcategories(char *path, int type) {
find_handle h;
coucal hashCateg = NULL;
if (rpath[0]) {
if (rpath[strlen(rpath) - 1] == '/') {
rpath[strlen(rpath) - 1] = '\0'; /* note: patching stored (inhash) value */
}
}
/* note: patching stored (inhash) value */
hts_striplastchar(rpath, '/');
h = hts_findfirst(rpath);
if (h) {
String iname = STRING_EMPTY;
@@ -1302,7 +1296,7 @@ HTSEXT_API find_handle hts_findfirst(char *path) {
strcpybuff(rpath, path);
if (rpath[0]) {
if (rpath[strlen(rpath) - 1] != '\\')
if (hts_lastchar(rpath) != '\\')
strcatbuff(rpath, "\\");
}
strcatbuff(rpath, "*.*");
@@ -1314,7 +1308,7 @@ HTSEXT_API find_handle hts_findfirst(char *path) {
strcpybuff(find->path, path);
{
if (find->path[0]) {
if (find->path[strlen(find->path) - 1] != '/')
if (hts_lastchar(find->path) != '/')
strcatbuff(find->path, "/");
}
}
@@ -1451,7 +1445,14 @@ hts_boolean hts_rename_over(const char *src, const char *dst) {
fconv(cdst, sizeof(cdst), dst);
if (RENAME(csrc, cdst) == 0)
return HTS_TRUE;
/* RENAME does not clobber an existing target on Windows. */
/* Only a dst in the way is something the unlink can clear, and the CRT maps
that to EEXIST; it keeps EACCES for a src another process holds, where
removing dst would lose a file the retry cannot replace (#790). The src
check covers a CRT that reports neither. */
const int err = errno;
if (err != EEXIST || !fexist_utf8(src))
return HTS_FALSE;
(void) UNLINK(cdst);
return RENAME(csrc, cdst) == 0 ? HTS_TRUE : HTS_FALSE;
}

View File

@@ -138,7 +138,9 @@ HTSEXT_API hts_boolean hts_findisfile(find_handle find);
HTSEXT_API hts_boolean hts_findissystem(find_handle find);
/* Move src onto dst, replacing an existing dst; HTS_TRUE on success. Both
paths are fconv()'d. */
paths are fconv()'d. dst is removed only to make room for a src that exists,
so a caller whose src was never written keeps its dst; a retry that still
fails does not (#790). */
hts_boolean hts_rename_over(const char *src, const char *dst);
#endif

View File

@@ -1552,8 +1552,8 @@ static hts_boolean warc_commit(warc_writer *w) {
if (!w->protect_prev)
return HTS_TRUE; /* nothing was there to lose: written in place */
/* hts_rename_over unlinks its destination when the source is missing, so
every segment has to be on disk before the first rename. */
/* All or nothing: a swap stopping halfway would mix this run's segments with
the previous one's, so require every temp before renaming any. */
swap = w->opened && !w->failed && w->unbacked_revisits == 0;
for (s = 0; s < nseg && swap; s++) {
snprintf(tmpbuf, sizeof(tmpbuf), "%s" WARC_TMP_SUFFIX,

View File

@@ -0,0 +1,8 @@
#!/bin/bash
#
set -euo pipefail
# A ready slot still owning a temporary must stay in memory: swapping it out
# clears the entry, which unlinks the re-fetch backup (#771).
httrack -O /dev/null -#test=backswap | grep -q "backswap self-test: OK"

View File

@@ -0,0 +1,35 @@
#!/bin/bash
#
# Runs the lastchar self-test (#770), then keeps the idiom it replaced from
# coming back: x[strlen(x) - 1] indexes one byte before the buffer when x is
# empty, and the guard is never where the index is.
set -eu
: "${top_srcdir:=..}"
out=$(httrack -#test=lastchar) || {
echo "httrack -#test=lastchar exited non-zero: $out" >&2
exit 1
}
# Exact-match the success line so a renamed/removed test can't pass silently.
test "$out" = "lastchar self-test: OK" || {
echo "expected 'lastchar self-test: OK', got: $out" >&2
exit 1
}
# The self-test only covers the helpers, so guard the class at the source: the
# lone survivor is inside a comment block in htsname.c. coucal is vendored, so
# its idioms are not ours to police.
hits=$(command grep -rn 'strlen([^)]*) *- *1\]' "$top_srcdir/src" \
--include='*.c' --include='*.h' --exclude-dir=coucal |
grep -v 'htsname\.c:.*save\[strlen(save)-1\]' |
grep -v 'Replaces s\[strlen(s) - 1\]' |
grep -v 'The x\[strlen(x) - 1\] class' || true)
test -z "$hits" || {
echo "x[strlen(x) - 1] reintroduced; use hts_lastchar/hts_striplastchar/hts_choplastchar:" >&2
echo "$hits" >&2
exit 1
}

View File

@@ -0,0 +1,55 @@
#!/bin/bash
#
set -euo pipefail
# Drives -#test=renameover: hts_rename_over() must replace an existing dst, and
# must leave dst alone when the rename failed for a reason removing dst cannot
# fix (#779). The selftest prints the regime it detected; pin it per platform so
# a leg cannot pass having tested the other half.
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
case "$(uname -s)" in
MINGW* | MSYS_NT*) want=fallback ;; # native rename() refuses an existing target
*) want=clobber ;;
esac
out=$(httrack -O /dev/null -#test=renameover "$dir")
echo "$out" | grep -q "renameover: OK"
echo "$out" | grep -q "renameover: regime $want"
if [ "$(uname -s)" != "Linux" ]; then
echo "renameover: LD_PRELOAD interposition is Linux-only here, skipping"
exit 0
fi
# A --disable-shared build has nothing to preload; anything else missing is a
# build problem, not a skip, or the interposed legs would pass vacuously.
if [ ! -r "${RENAMEFAIL_LA:-}" ]; then
echo "renameover: ${RENAMEFAIL_LA:-\$RENAMEFAIL_LA} was not built" >&2
exit 1
fi
if grep -q "^dlname=''" "$RENAMEFAIL_LA"; then
echo "renameover: static-only build, skipping the interposed legs"
exit 0
fi
if [ ! -r "${RENAMEFAIL_LIB:-}" ]; then
echo "renameover: ${RENAMEFAIL_LIB:-\$RENAMEFAIL_LIB} was not built" >&2
exit 1
fi
# An LD_PRELOAD library loads ahead of the executable's own libasan, which ASan
# refuses by default. The shim allocates nothing, so the ordering is harmless.
export ASAN_OPTIONS="${ASAN_OPTIONS:+$ASAN_OPTIONS:}verify_asan_link_order=0"
# The unlink fallback is dead code on POSIX, so borrow Windows' rename().
out=$(LD_PRELOAD="$RENAMEFAIL_LIB" httrack -O /dev/null \
-#test=renameover "$dir")
echo "$out" | grep -q "renameover: OK"
echo "$out" | grep -q "renameover: regime fallback"
# A source another process holds fails with EACCES, which dst had no part in.
out=$(RENAMEFAIL_MODE=locked LD_PRELOAD="$RENAMEFAIL_LIB" httrack -O /dev/null \
-#test=renameover "$dir")
echo "$out" | grep -q "renameover: OK"
echo "$out" | grep -q "renameover: regime refused"

View File

@@ -0,0 +1,111 @@
#!/bin/bash
#
# The end-of-mirror purge read old.lst with linput(fp, line, 1000), which caps a
# chunk at 999 bytes. filenote() writes "[<save path>]", so a 998-byte save path
# makes a 1000-byte line that comes back as 999 bytes plus a one-byte "]" tail.
# For that tail `line + 1` is empty, and with no -O so is path_html, so the purge
# indexed file[-1] (#770). Needs no -O, hence not local-crawl.sh.
#
# The OOB write lands in stack padding, so only the sanitized CI leg fails on
# unfixed code; the length assertion below is what keeps this from passing
# vacuously if the save path ever stops landing on the chunk boundary.
set -euo pipefail
: "${top_srcdir:=..}"
# shellcheck source=tests/testlib.sh
. "$top_srcdir/tests/testlib.sh"
# python3 is required; mirror check-network.sh's skip-with-77 convention.
python=$(find_python) || ! echo "python3 not found; skipping" >&2 || exit 77
# Resolve httrack now: the harness prepends a RELATIVE dir to PATH, so the cd
# below would otherwise silently fall through to an installed copy.
httrack_bin=$(command -v httrack)
test -n "$httrack_bin" || {
echo "httrack not on PATH" >&2
exit 1
}
httrack_bin=$(cd "$(dirname "$httrack_bin")" && pwd)/$(basename "$httrack_bin")
work=$(mktemp -d)
trap 'kill -9 "${spid:-}" 2>/dev/null || true; rm -rf "$work"' EXIT
mkdir -p "$work/root" "$work/proj"
"$python" "$top_srcdir/tests/local-server.py" --root "$work/root" \
>"$work/server.out" 2>"$work/server.err" &
spid=$!
port=
for _ in $(seq 1 100); do
port=$(sed -n 's/^PORT \([0-9]*\)$/\1/p' "$work/server.out" 2>/dev/null || true)
test -n "$port" && break
sleep 0.1
done
test -n "$port" || {
echo "local-server.py did not announce a port" >&2
exit 1
}
# Save path is "127.0.0.1_<port>/" + the URL path; target 998 bytes total.
prefix="127.0.0.1_${port}/"
need=$((998 - ${#prefix}))
# macOS caps PATH_MAX at 1024, so neither the docroot nor the mirror can hold a
# 998-byte path, and httrack's own HTS_MAX_PATH_LEN clamps below it too. The
# trigger is simply not reachable there.
pathmax=$(getconf PATH_MAX / 2>/dev/null || echo 4096)
test "$pathmax" -ge $((need + ${#work} + 128)) || {
echo "PATH_MAX $pathmax too small for a $need-byte path; skipping" >&2
exit 77
}
# Same on Win32, where MAX_PATH clamps the save path; getconf answers for the
# MSYS emulation, not the Win32 API httrack calls, so it cannot see this.
case "$(uname -s)" in
MINGW* | MSYS* | CYGWIN*)
echo "Win32 MAX_PATH cannot hold a $need-byte save path; skipping" >&2
exit 77
;;
esac
urlpath=""
while test $((need - ${#urlpath})) -gt 60; do
urlpath="${urlpath}$(printf 'd%02d' ${#urlpath})$(printf 'a%.0s' $(seq 1 46))/"
done
urlpath="${urlpath}$(printf 'f%.0s' $(seq 1 $((need - ${#urlpath} - 5)))).html"
test ${#urlpath} -eq "$need"
mkdir -p "$work/root/$(dirname "$urlpath")"
printf '<html><body>leaf</body></html>\n' >"$work/root/$urlpath"
printf '<html><body><a href="/%s">x</a></body></html>\n' "$urlpath" \
>"$work/root/index.html"
cd "$work/proj" # no -O: opt->path_html stays empty
"$httrack_bin" "http://127.0.0.1:$port/" -q -%v0 -s0 -r3 >"$work/crawl1.log" 2>&1 || true
lst=$work/proj/hts-cache/new.lst
test -f "$lst" || {
echo "pass 1 produced no new.lst" >&2
exit 1
}
# Arms the test: without a line of exactly 1000 bytes linput() never splits and
# the purge never sees a one-byte tail.
awk 'length == 1000 { found = 1 } END { exit !found }' "$lst" || {
echo "no 1000-byte line in new.lst; the chunk split is not exercised" >&2
awk '{ print length }' "$lst" | sort -n | tail -3 >&2
echo "--- urlpath len ${#urlpath}, prefix $prefix" >&2
tail -20 "$work/crawl1.log" >&2
exit 1
}
# Pass 2 renames new.lst to old.lst and runs it through the purge loop.
"$httrack_bin" "http://127.0.0.1:$port/" -q -%v0 -s0 -r3 >"$work/crawl2.log" 2>&1
test -s "$work/proj/$prefix$urlpath" || {
echo "the mirrored leaf did not survive the purge" >&2
exit 1
}

View File

@@ -0,0 +1,27 @@
#!/bin/bash
#
# A .bak left behind by a killed run must not disable the #77 re-fetch backup
# (#758): a leftover file (slow.bin.bak) has to be clobbered so the -M abort can
# still restore the pass-1 copy, and a leftover the engine cannot clobber
# (fast.bin.bak, planted as a directory) has to be reported instead of silently
# truncating with no safety net. Only the Windows leg arms the first half:
# POSIX rename() clobbers on its own.
set -euo pipefail
: "${top_srcdir:=..}"
bash "$top_srcdir/tests/local-crawl.sh" \
--plant-file bigtrunc/slow.bin.bak \
--plant-dir bigtrunc/fast.bin.bak \
--rerun-args '--update -M400000' \
--log-found 'More than 400000 bytes have been transferred.. giving up' \
--log-found 'replacing leftover backup .*slow\.bin\.bak' \
--log-found 'could not back up .*fast\.bin' \
--log-not-found 'could not back up .*slow\.bin' \
--log-not-found 'could not restore' \
--found bigtrunc/slow.bin \
--found bigtrunc/fast.bin \
--file-min-bytes bigtrunc/slow.bin 655360 \
--file-not-matches bigtrunc/slow.bin 'stale-leftover' \
httrack 'BASEURL/bigtrunc/index.html' -c2

View File

@@ -0,0 +1,163 @@
#!/bin/bash
#
# An FTP re-fetch used to truncate the mirror before the transfer, so a failed
# one destroyed the previous copy (#771). keep.bin is cut short and empty.bin
# gets nothing; both must keep their old bytes. stay.bin takes the same path and
# completes, so a fix that merely stopped writing would fail here.
set -euo pipefail
: "${top_srcdir:=..}"
testdir=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=tests/testlib.sh
. "${testdir}/testlib.sh"
python=$(find_python) || ! echo "python3 not found; skipping" >&2 || exit 77
command -v httrack >/dev/null || {
echo "could not find httrack" >&2
exit 1
}
server=$(nativepath "${testdir}/ftp-server.py")
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/httrack_ftp.XXXXXX")
serverpid=
cleanup() {
stop_server "$serverpid"
rm -rf "$tmpdir"
}
trap cleanup EXIT HUP INT QUIT PIPE TERM
root="${tmpdir}/root"
out="${tmpdir}/crawl"
mode="${tmpdir}/mode"
report="${out}/hts-changes.json"
mkdir -p "$root" "$out"
# The two generations differ in length, or a re-fetch that resumed at EOF
# instead of truncating would leave the same bytes and pass for the wrong reason.
write_bodies() {
local gen="$1" fill="$2" name
for name in keep empty stay; do
"$python" -c 'import sys; sys.stdout.buffer.write(
("%s-FTP-%s " % (sys.argv[1].upper(), sys.argv[2])).encode()
+ sys.argv[2][-1].encode() * int(sys.argv[3]))' "$name" "$gen" "$fill" \
>"${root}/${name}.bin"
done
}
write_bodies V1 4096
: >"$mode"
serverlog="${tmpdir}/server.out"
"$python" "$server" --root "$(nativepath "$root")" \
--mode-file "$(nativepath "$mode")" >"$serverlog" 2>&1 &
serverpid=$!
port=
for _ in $(seq 1 300); do
line=$(grep -m1 '^PORT ' "$serverlog" 2>/dev/null) && port="${line#PORT }" && break
kill -0 "$serverpid" 2>/dev/null || {
echo "ftp server exited early: $(cat "$serverlog")" >&2
exit 1
}
sleep 0.1
done
test -n "$port" || {
echo "could not discover ftp server port: $(cat "$serverlog")" >&2
exit 1
}
host="127.0.0.1_${port}"
urls=()
for name in keep empty stay; do
urls+=("ftp://127.0.0.1:${port}/${name}.bin")
done
# -c1: a parallel FTP crawl loses whole transfers to a separate, older bug in
# the backlog slot swap (#797), which would flake this test on a loaded runner.
common=(--quiet --disable-security-limits --robots=0 --timeout=20 --max-time=120
--retries=1 -c1 --changes)
fail() {
echo "FAIL: $*" >&2
test ! -f "$report" || cat "$report" >&2
exit 1
}
ok() { echo "OK: $*"; }
size_of() { wc -c <"$1" | tr -d '[:space:]'; }
# Files listed under a report bucket, one "file:size" per line.
listed() {
"$python" - "$report" "$1" <<'EOF' | tr -d '\r'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as fp:
report = json.load(fp)
for entry in report[sys.argv[2]]:
print("%s:%s" % (entry["file"], entry.get("size", "none")))
EOF
}
# --- pass 1: a healthy mirror ------------------------------------------------
# Bounded like every local-crawl.sh pass: a wedged crawl must fail the test, not
# hang the job until CI cancels it and discards the log (#796).
run_with_timeout 300 httrack "${urls[@]}" -O "$out" "${common[@]}" \
>"${tmpdir}/log1" 2>&1
for name in keep empty stay; do
test -s "${out}/${host}/${name}.bin" || fail "pass 1 did not mirror ${name}.bin"
done
mkdir "${tmpdir}/snap"
cp "${out}/${host}"/*.bin "${tmpdir}/snap/"
ok "pass 1 mirrored three files"
# --- pass 2: the same three re-fetched, two of them failing -------------------
# norest throughout: an accepted REST sends the client down the append path,
# which is not the one that truncates.
write_bodies V2 6000
cat >"$mode" <<'EOF'
/keep.bin truncate norest
/empty.bin empty norest
/stay.bin norest
EOF
run_with_timeout 300 httrack "${urls[@]}" -O "$out" "${common[@]}" --update \
>"${tmpdir}/log2" 2>&1
# Both failures have to be the transfer dying mid-body: a connect or lookup
# failure never opens the file, and everything below would pass vacuously.
for name in keep empty; do
grep -aqE "Error:.*\"FTP file incomplete\".*${name}\.bin" "${out}/hts-log.txt" ||
fail "${name}.bin did not fail during the transfer"
done
ok "both re-fetches reached the body and failed there"
for name in keep empty; do
cmp -s "${out}/${host}/${name}.bin" "${tmpdir}/snap/${name}.bin" ||
fail "${name}.bin differs from the copy pass 1 left ($(size_of \
"${out}/${host}/${name}.bin") bytes now)"
done
ok "a failed FTP transfer left the previously mirrored bytes alone"
grep -aq "FTP-V2 " "${out}/${host}/stay.bin" ||
fail "stay.bin was not refreshed; the re-fetch writes nothing at all now"
ok "a successful FTP re-fetch still replaces the file"
leftover=$(find "${out}/${host}" -name '*.bak')
test -z "$leftover" || fail "backup left behind: ${leftover}"
ok "no backup temporary survived the pass"
# --purge-old is on by default: a kept file the run never replaced must still be
# in new.lst, or it is deleted as gone (#746).
for name in keep empty stay; do
test -f "${out}/${host}/${name}.bin" || fail "${name}.bin was purged"
done
ok "the kept copies survived the update purge"
# --- the change report is the mirror's own account of the pass ----------------
test -s "$report" || fail "pass 2 wrote no ${report}"
test -z "$(listed gone)" || fail "the report calls something gone: $(listed gone)"
for name in keep empty; do
listed unchanged | grep -qx "${host}/${name}.bin:$(size_of "${tmpdir}/snap/${name}.bin")" ||
fail "${name}.bin is not reported unchanged at its previous size"
if listed changed | grep -q "^${host}/${name}.bin:"; then
fail "${name}.bin is reported changed as well"
fi
done
listed changed | grep -qx "${host}/stay.bin:$(size_of "${out}/${host}/stay.bin")" ||
fail "stay.bin is not reported changed"
ok "the change report calls the kept files unchanged and the refreshed one changed"

View File

@@ -1,12 +1,14 @@
#!/bin/bash
#
# An update run against a dead server must not destroy the cache: the no-data
# rollback restores the previous hts-cache generation (zip caches lost it).
# rollback restores the previous hts-cache generation (zip caches lost it). The
# live update pass leaves an old.zip behind, so the dead pass rotates onto an
# existing generation instead of into empty space.
set -eu
: "${top_srcdir:=..}"
bash "$top_srcdir/tests/local-crawl.sh" --errors 0 --rerun-dead \
bash "$top_srcdir/tests/local-crawl.sh" --errors 0 --rerun --rerun-dead \
--found 'simple/basic.html' \
httrack 'BASEURL/simple/basic.html'

View File

@@ -0,0 +1,17 @@
#!/bin/bash
# Issue #769: a failed robots.txt probe under -o1, with .txt assumed image/gif,
# still drives the --sitemap fallback and substitutes no placeholder body.
set -eu
: "${top_srcdir:=..}"
# -Z: both the removed block's own log line and the failed probe that gates
# this test's coverage are debug-level.
bash "$top_srcdir/tests/local-crawl.sh" --errors 0 \
--found 'sitemapdir/orphan1.html' \
--log-found 'No robots\.txt rules at' \
--log-found 'listed by 127\.0\.0\.1:[0-9]+/sitemap\.xml' \
--log-not-found 'Creating GIF dummy file' \
httrack 'BASEURL/sitemapdir/start.html' --sitemap -r2 -o1 -Z \
--assume 'txt=image/gif' -F 'errorrobots-agent'

View File

@@ -1,11 +1,12 @@
# Committed binary fixture read by 01_zlib-cache-golden.test. List it
# explicitly: automake does not expand wildcards in EXTRA_DIST, so a glob would
# silently drop it from the dist tarball and break "make distcheck".
EXTRA_DIST = $(TESTS) crawl-test.sh run-all-tests.sh check-network.sh \
EXTRA_DIST = $(TESTS) renamefail.c crawl-test.sh run-all-tests.sh check-network.sh \
proxy-https-server.py socks5-server.py proxy-connect-server.py \
proxytestlib.py tls-stall-server.py warc-validate.py wacz-validate.py \
pty-resize.py \
local-crawl.sh local-server.py testlib.sh server.crt server.key \
local-crawl.sh local-server.py ftp-server.py testlib.sh \
server.crt server.key \
server-root/simple/basic.html server-root/simple/link.html \
server-root/stripquery/index.html server-root/stripquery/a.html \
server-root/fraglink/index.html server-root/fraglink/target.html \
@@ -21,6 +22,15 @@ TESTS_ENVIRONMENT += BROTLI_ENABLED=$(BROTLI_ENABLED)
TESTS_ENVIRONMENT += ZSTD_ENABLED=$(ZSTD_ENABLED)
TESTS_ENVIRONMENT += V6_SUPPORT=$(V6_SUPPORT)
TESTS_ENVIRONMENT += top_srcdir=$(top_srcdir)
TESTS_ENVIRONMENT += RENAMEFAIL_LA=$(abs_builddir)/librenamefail.la
TESTS_ENVIRONMENT += RENAMEFAIL_LIB=$(abs_builddir)/$(LT_CV_OBJDIR)/librenamefail.so
# rename() interposer for 01_engine-renameover.test; -rpath is what makes
# libtool build a shared module rather than a static-only convenience library.
check_LTLIBRARIES = librenamefail.la
librenamefail_la_SOURCES = renamefail.c
librenamefail_la_LDFLAGS = -module -avoid-version -rpath $(abs_builddir)
librenamefail_la_LIBADD = $(DL_LIBS)
TEST_EXTENSIONS = .test
# Run each .test through bash instead of execve()ing it. This lets "make check"
@@ -49,6 +59,7 @@ TESTS = \
01_engine-filterdual.test \
01_engine-ftp-line.test \
01_engine-ftp-userpass.test \
01_engine-backswap.test \
01_engine-cacheindex.test \
01_engine-hashtable.test \
01_engine-header.test \
@@ -59,6 +70,7 @@ TESTS = \
01_engine-identabs.test \
01_engine-escape-room.test \
01_engine-inplace-escape.test \
01_engine-lastchar.test \
01_engine-makeindex.test \
01_engine-mime.test \
01_engine-parse.test \
@@ -74,6 +86,7 @@ TESTS = \
01_engine-direnum.test \
01_engine-cookieimport.test \
01_engine-relative.test \
01_engine-renameover.test \
01_engine-robots.test \
01_engine-savename.test \
01_engine-selftest-dispatch.test \
@@ -200,6 +213,10 @@ TESTS = \
95_local-sitemap.test \
96_local-refetch-keep.test \
97_local-warc-update-keep.test \
98_local-warc-segments.test
98_local-warc-segments.test \
99_local-robots-error.test \
100_local-purge-longpath.test \
101_local-update-stale-bak.test \
102_local-ftp-refetch.test
CLEANFILES = check-network_sh.cache

227
tests/ftp-server.py Normal file
View File

@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Minimal FTP server for the crawl tests: PASV only, binary RETR, LIST.
Prints "PORT <n>" once bound, like local-server.py. --mode-file is re-read
before every transfer, so a test can flip one path's behaviour between passes
without restarting the server and moving the port (which names the mirror
directory). Each line is "<path> <mode>...", path "*" matching everything:
truncate send a prefix of the body, then drop the data connection
empty open the data connection and send nothing
norest answer REST with 500, so the client re-fetches from scratch
"""
import argparse
import os
import socket
import sys
import threading
def reply(conn, text):
conn.sendall((text + "\r\n").encode("utf-8", "replace"))
class Session(threading.Thread):
def __init__(self, conn, root, mode_file, log):
threading.Thread.__init__(self, daemon=True)
self.conn = conn
self.root = root
self.mode_file = mode_file
self.log = log
self.pasv = None
self.rest = 0
self.path = "/" # named by SIZE/RETR; REST carries no path
def modes(self):
if not self.mode_file:
return set()
found = set()
try:
with open(self.mode_file, encoding="utf-8") as fp:
for line in fp:
words = line.split()
if words and words[0] in ("*", self.path):
found |= set(words[1:])
except OSError:
pass
return found
# Resolve an FTP path argument under the root, refusing escapes.
def resolve(self, arg):
arg = arg.strip().strip('"')
self.path = "/" + arg.lstrip("/")
path = os.path.normpath(os.path.join(self.root, arg.lstrip("/")))
if path != self.root and not path.startswith(self.root + os.sep):
return None
return path
def open_pasv(self):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.bind(("127.0.0.1", 0))
srv.listen(1)
self.pasv = srv
port = srv.getsockname()[1]
return "227 Entering Passive Mode (127,0,0,1,%d,%d)" % (port >> 8, port & 0xFF)
def accept_data(self):
if self.pasv is None:
return None
self.pasv.settimeout(30)
try:
data, _ = self.pasv.accept()
except (OSError, socket.timeout):
data = None
self.pasv.close()
self.pasv = None
return data
def send_body(self, body, modes):
data = self.accept_data()
if data is None:
reply(self.conn, "425 no data connection")
return
try:
if "empty" in modes:
pass
elif "truncate" in modes:
data.sendall(body[: max(1, len(body) // 8)])
else:
data.sendall(body)
except OSError:
pass
data.close()
if modes & {"empty", "truncate"}:
reply(self.conn, "426 transfer aborted")
else:
reply(self.conn, "226 Transfer complete")
def do_retr(self, arg):
path = self.resolve(arg)
if path is None or not os.path.isfile(path):
reply(self.conn, "550 no such file")
return
with open(path, "rb") as fp:
body = fp.read()
modes = self.modes()
reply(self.conn, "150 opening binary mode")
self.send_body(body[self.rest :], modes)
self.rest = 0
def do_list(self, arg):
if arg.startswith("-A"):
arg = arg[2:].strip()
path = self.resolve(arg or "/")
if path is None or not os.path.isdir(path):
reply(self.conn, "550 no such directory")
return
lines = []
for name in sorted(os.listdir(path)):
full = os.path.join(path, name)
if os.path.isdir(full):
lines.append("drwxr-xr-x 2 ftp ftp 4096 Jan 01 00:00 %s" % name)
else:
lines.append(
"-rw-r--r-- 1 ftp ftp %d Jan 01 00:00 %s"
% (os.path.getsize(full), name)
)
reply(self.conn, "150 opening ASCII mode")
self.send_body(("\r\n".join(lines) + "\r\n").encode("utf-8"), set())
def dispatch(self, verb, arg):
conn = self.conn
if verb in ("USER", "PASS", "TYPE", "NOOP"):
reply(conn, "200 ok")
elif verb == "SYST":
reply(conn, "215 UNIX Type: L8")
elif verb == "PWD":
reply(conn, '257 "/"')
elif verb == "CWD":
reply(conn, "250 ok")
elif verb == "PASV":
reply(conn, self.open_pasv())
elif verb == "EPSV":
reply(conn, "500 not supported")
elif verb == "SIZE":
path = self.resolve(arg)
if path and os.path.isfile(path):
reply(conn, "213 %d" % os.path.getsize(path))
else:
reply(conn, "550 no such file")
elif verb == "REST":
if "norest" in self.modes():
reply(conn, "500 REST not understood")
else:
try:
self.rest = int(arg)
except ValueError:
self.rest = 0
reply(conn, "350 restarting")
elif verb == "RETR":
self.do_retr(arg)
elif verb in ("LIST", "NLST"):
self.do_list(arg)
elif verb == "QUIT":
reply(conn, "221 bye")
return False
else:
reply(conn, "500 unknown command")
return True
def run(self):
conn = self.conn
buf = b""
try:
reply(conn, "220 httrack test ftp")
while True:
while b"\r\n" not in buf:
chunk = conn.recv(4096)
if not chunk:
return
buf += chunk
line, buf = buf.split(b"\r\n", 1)
line = line.decode("utf-8", "replace")
self.log(line)
verb, _, arg = line.partition(" ")
if not self.dispatch(verb.upper(), arg):
return
except OSError:
return
finally:
if self.pasv is not None:
self.pasv.close()
conn.close()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--root", required=True)
ap.add_argument("--mode-file")
ap.add_argument("--log")
args = ap.parse_args()
logfp = open(args.log, "a", encoding="utf-8") if args.log else None
log_lock = threading.Lock()
def log(line):
if logfp:
with log_lock:
logfp.write(line + "\n")
logfp.flush()
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(16)
sys.stdout.reconfigure(newline="\n") # the launcher parses PORT, CRLF breaks it
sys.stdout.write("PORT %d\n" % srv.getsockname()[1])
sys.stdout.flush()
root = os.path.abspath(args.root)
while True:
conn, _ = srv.accept()
Session(conn, root, args.mode_file, log).start()
if __name__ == "__main__":
main()

View File

@@ -40,6 +40,9 @@
# rewritten. Both also require no *.tmp left behind, and take an optional
# --archive-min-files N guarding against a scenario that silently stopped
# producing the segments it means to check.
# --plant-file/--plant-dir drop a regular file (holding $plant_poison) or a
# directory at PATH under the host root between the passes, to hand the second
# pass leftovers a killed run would have left (#758).
set -u
@@ -68,6 +71,7 @@ serverpid=
crawlpid=
wacz_poisoned=
wacz_poison="stale-wacz-that-a-second-pass-must-replace"
plant_poison="stale-leftover-that-a-second-pass-must-clobber"
function warning {
echo "** $*" >&2
@@ -96,6 +100,18 @@ function cleanup {
fi
}
hostroot=
function find_hostroot {
local cand
for cand in "${mirrorroot}/127.0.0.1_${port}" "${mirrorroot}/127.0.0.1"; do
if test -d "$cand"; then
hostroot="$cand"
return 0
fi
done
die "could not find host root under $out"
}
function assert_equals {
info "$1"
if test ! "$2" == "$3"; then
@@ -118,6 +134,7 @@ tmpdir=$(mktemp -d "${tmptopdir}/httrack_local.XXXXXX") || die "could not create
# --- parse leading control flags --------------------------------------------
declare -a audit=()
declare -a cookies=()
declare -a plants=()
scheme=http
pos=0
args=("$@")
@@ -157,6 +174,10 @@ while test "$pos" -lt "$nargs"; do
pos=$((pos + 1))
cookies+=("${args[$pos]}")
;;
--plant-file | --plant-dir)
plants+=("${args[$pos]}" "${args[$((pos + 1))]}")
pos=$((pos + 1))
;;
--rerun-args)
pos=$((pos + 1))
rerun_args="${args[$pos]}"
@@ -314,6 +335,23 @@ if test -z "$archive_kept" && test -n "$wacz_validate" && test -n "${rerun}${rer
test -z "$wacz_poisoned" || echo "$wacz_poison" >"$wacz_poisoned"
fi
# --- plant leftovers the second pass has to deal with ------------------------
if test "${#plants[@]}" -gt 0; then
find_hostroot
i=0
while test "$i" -lt "${#plants[@]}"; do
path="${hostroot}/${plants[$((i + 1))]}"
info "planting ${plants[$i]} ${plants[$((i + 1))]}"
if test "${plants[$i]}" = "--plant-dir"; then
mkdir -p "$path" || die "could not create $path"
else
echo "$plant_poison" >"$path" || die "could not write $path"
fi
result "OK"
i=$((i + 2))
done
fi
# --- optional second pass: re-mirror into the same dir (cache/update path) ----
if test -n "$rerun"; then
info "re-running httrack (update pass)"
@@ -418,14 +456,7 @@ if test -n "$rerun_dead"; then
fi
# --- discover the single host root (127.0.0.1_<port> or 127.0.0.1) -----------
hostroot=
for cand in "${mirrorroot}/127.0.0.1_${port}" "${mirrorroot}/127.0.0.1"; do
if test -d "$cand"; then
hostroot="$cand"
break
fi
done
test -n "$hostroot" || die "could not find host root under $out"
find_hostroot
debug "host root: $hostroot"
# --- optional WARC validation (stdlib validator, no warcio) ------------------

View File

@@ -555,10 +555,22 @@ class Handler(SimpleHTTPRequestHandler):
# ... and one that points the sitemap at the site root, to check a subtree
# crawl is not widened by where the site chooses to put its sitemap.
SCOPE_SITEMAP_UA = "scopesitemap"
# ... and one whose probe fails, with a body past the 1070 bytes that
# issue #769's over-read needed.
ERROR_ROBOTS_UA = "errorrobots"
def route_robots(self):
# The Sitemap: record is group-independent; only --sitemap acts on it.
ua = self.headers.get("User-Agent") or ""
if self.ERROR_ROBOTS_UA in ua:
body = b"# " + b"x" * 4000 + b"\n"
self.send_response(404, "Not Found")
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
return
host = self.headers.get("Host")
body = "User-agent: *\nDisallow:\n"
if self.DENY_DECLARED_UA in ua:

41
tests/renamefail.c Normal file
View File

@@ -0,0 +1,41 @@
/* Borrows Windows' rename() for 01_engine-renameover.test, since POSIX cannot
reach hts_rename_over()'s unlink fallback: an existing target is refused with
EEXIST, and RENAMEFAIL_MODE=locked reports EACCES instead, as the CRT does
for a source another process holds. */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <errno.h>
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
/* The tree builds with -fvisibility=hidden, which would hide the interposer. */
#define SHIM_EXPORT __attribute__((visibility("default")))
SHIM_EXPORT int rename(const char *oldpath, const char *newpath);
SHIM_EXPORT int rename(const char *oldpath, const char *newpath) {
static int (*real_rename)(const char *, const char *) = NULL;
const char *const mode = getenv("RENAMEFAIL_MODE");
const int locked = mode != NULL && strcmp(mode, "locked") == 0;
struct stat st;
if (locked) {
errno = EACCES;
return -1;
}
if (stat(newpath, &st) == 0) {
errno = EEXIST;
return -1;
}
if (real_rename == NULL) {
*(void **) &real_rename = dlsym(RTLD_NEXT, "rename");
if (real_rename == NULL) {
errno = ENOSYS;
return -1;
}
}
return real_rename(oldpath, newpath);
}