Compare commits

..

5 Commits

Author SHA1 Message Date
Xavier Roche
915aa8d6ba proxyurl test: percent-encoded specials and UTF-8
Pin that the host/port split is byte-transparent: a percent-encoded '@' (%40) or
':' (%3A) in the userinfo stays encoded and never mis-splits host or port, a
second raw '@' is handled by the last-'@' host rule, and UTF-8 bytes in the
username and host label pass through intact. Decoding happens later at auth time,
not in the parser. Raw UTF-8 CLI args match the existing 01_engine-idna.test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-14 13:53:50 +02:00
Xavier Roche
6856cdd5c4 hts_parse_proxy: scan from one-past-end, avoid forming arg-1
The backward port scan started at arg+strlen(arg)-1 and decremented while
a >= authority, so on any argument with no ':' or '@' in the authority (every
bare-host proxy, and -P "") it stepped a to arg-1: a pointer formed before the
string, which is undefined behavior. It is not a memory-safety bug (never
dereferenced, no wrap, so UBSan stays green), but it is latent UB reachable from
-P.

Scan from one-past-end and test a[-1] with a > authority instead, so no pointer
below the string is ever formed. Guard name_size == 0 against a future
small-buffer caller (the name_size - 1 truncation would otherwise underflow).

Add a userinfo-free socks explicit-port case and a lone ':' boundary case to the
self-test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-14 13:49:01 +02:00
Xavier Roche
01d274c388 Parse -P proxy URLs scheme-aware, and recognize socks5 schemes
The -P argument parser scanned backward for the port ':' from the end of the
string with no notion of a scheme, so a scheme-prefixed proxy with no explicit
port ("-P http://proxy") mistook the scheme's own colon for a port separator:
the name came out as "http" with a garbage port. A port or a "user:pass@" prefix
happened to shield the scan, so this only bit the no-port case.

Extract the parse into hts_parse_proxy(), which skips any "scheme://" before
hunting the port, so the scheme colon is never read as a port. Teach
jump_protocol_const the socks5/socks5h schemes (so the proxy host resolves with
the scheme stripped) and default the port by scheme (1080 for socks, 8080
otherwise). This is the parsing groundwork for SOCKS5 support (#563); the
handshake lands separately.

Add a -#test=proxyurl self-test and tests/01_engine-proxyurl.test covering bare
hosts, user:pass, the scheme-colon regression, and the socks defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-14 13:42:22 +02:00
Xavier Roche
bc4b2c7b8e Keep the mirrored file when a coded re-fetch fails to decode (#560)
* Keep the mirrored file when a coded re-fetch fails to decode

An --update re-fetch of a content-coded URL decoded straight into url_sav: the
live mirror was truncated (filecreateempty) before the decode was even attempted,
so a truncated gzip, a corrupt br/zstd stream, a decode-size overrun or a coding
we have no decoder for destroyed the good copy we were merely refreshing. The
uncompressed path has been rollback-safe since #522, but its backup is explicitly
skipped for coded bodies, so nothing covered them.

Decode into a temporary file instead and only commit it over the mirror once the
decode succeeded; on failure url_sav is never touched. The failure path also has
to filenote() the surviving copy, or the end-of-update purge (in old.lst, absent
from new.lst) would delete the very file the decode took care not to overwrite.

Closes #557

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Never drop the decoded body when the mirror replace fails, and probe the good path

replace_file() unlinks the destination and retries when the first rename is
refused (Windows never clobbers). If that retry also fails, the previous copy is
gone, so deleting the decoded temp as well destroyed the last copy of the body:
keep it and log where it went, the way the backup restore does. That restore is
the same remove-then-rename, so it now shares the helper.

The test grew the missing positive control: freshdisk.bin decodes on both passes
on the direct-to-disk path, so the update pass renames a decoded temp over an
existing mirror file, which no case exercised. The leftover-temporaries sweep in
local-crawl.sh now covers the .z/.u decode temps for every crawl test, not just
the three names this one asserted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Restore the 0644 mode on a decoded direct-to-disk file

Committing the decode by renaming the temp bypassed filecreate(), which is what
chmods the mirrored file to HTS_ACCESS_FILE. Under a restrictive umask a coded
binary landed 0600 while every other mirrored file stayed 0644, so a mirror
served from a web root or shared with a group silently lost read access. chmod
it at the rename, as the plain direct-to-disk path already does.

The test now crawls under umask 077 and asserts the mode, via a --file-mode
assertion in local-crawl.sh; without the chmod it fails with 0600.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Pin the codec-update assertions to the URLs and to fresh content

The exact error count did not say which links failed: an engine that logged one
URL twice while another failed silently still counted three and passed. Match the
three decode errors per URL instead. The content checks only looked for the new
marker, so a fix that appended the decoded body to the old file rather than
replacing it would pass too; assert the pass-1 marker is gone. Also assert the
mode on a kept file, not just on the two rewritten ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:10:28 +02:00
Xavier Roche
4fafe283b4 Store the .rc blobs LF-normalized, as .gitattributes declares (#561)
.gitattributes marks *.rc as "text eol=crlf": the blob is LF, the checkout is
CRLF. The two resource scripts added in #558 went in with CRLF blobs, so git
renormalizes them on every add and every checkout reports them as modified.
Renormalized with git add --renormalize; version.rc was already correct. The
working-tree files stay CRLF, which is what the Windows toolchain reads.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:42:19 +02:00
8 changed files with 123 additions and 22 deletions

View File

@@ -2253,21 +2253,12 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
htsmain_free();
return -1;
} else {
char *a;
char BIGSTK pname[HTS_URLMAXSIZE * 2];
na++;
opt->proxy.active = 1;
// Rechercher MAIS en partant de la fin à cause de user:pass@proxy:port
a = argv[na] + strlen(argv[na]) - 1;
while((a > argv[na]) && (*a != ':') && (*a != '@'))
a--;
if (*a == ':') { // un port est présent, <proxy>:port
sscanf(a + 1, "%d", &opt->proxy.port);
StringCopyN(opt->proxy.name, argv[na], (int) (a - argv[na]));
} else { // <proxy>
opt->proxy.port = 8080;
StringCopy(opt->proxy.name, argv[na]);
}
hts_parse_proxy(argv[na], pname, sizeof(pname), &opt->proxy.port);
StringCopy(opt->proxy.name, pname);
}
break;
case 'F': // user-agent field

View File

@@ -3866,6 +3866,10 @@ const char *jump_protocol_const(const char *source) {
source += p;
else if ((p = strfield(source, "file:")))
source += p;
else if ((p = strfield(source, "socks5h:")))
source += p;
else if ((p = strfield(source, "socks5:")))
source += p;
// net_path
if (strncmp(source, "//", 2) == 0)
source += 2;
@@ -3874,6 +3878,43 @@ const char *jump_protocol_const(const char *source) {
DECLARE_NON_CONST_VERSION(jump_protocol)
// default proxy port for a -P argument, keyed on the scheme
static int proxy_default_port(const char *arg) {
if (strfield(arg, "socks5h:") || strfield(arg, "socks5:"))
return 1080;
return 8080;
}
void hts_parse_proxy(const char *arg, char *name, size_t name_size, int *port) {
const char *authority = strstr(arg, "://");
const char *a;
size_t namelen;
if (name_size == 0)
return;
// scan back to the port ':' (or userinfo '@'), but never past the authority,
// so a scheme's own colon is not read as a port separator; inspect a[-1] from
// one-past-end so no pointer below the string is ever formed
authority = (authority != NULL) ? authority + 3 : arg;
a = arg + strlen(arg);
while (a > authority && a[-1] != ':' && a[-1] != '@')
a--;
if (a > authority && a[-1] == ':') {
int p = -1;
sscanf(a, "%d", &p);
*port = (p > 0) ? p : proxy_default_port(arg);
namelen = (size_t) (a - 1 - arg);
} else {
*port = proxy_default_port(arg);
namelen = strlen(arg);
}
if (namelen >= name_size) // arg is user-controlled: truncate, don't overflow
namelen = name_size - 1;
memcpy(name, arg, namelen);
name[namelen] = '\0';
}
// codage base 64 a vers b
void code64(unsigned char *a, int size_a, unsigned char *b, int crlf) {
int i1 = 0, i2 = 0, i3 = 0, i4 = 0;

View File

@@ -289,6 +289,12 @@ int may_unknown2(httrackp * opt, const char *mime, const char *filename);
const char *strrchr_limit(const char *s, char c, const char *limit);
char *jump_protocol(char *source);
const char *jump_protocol_const(const char *source);
/* Split a -P proxy argument "[scheme://][user:pass@]host[:port]" into the proxy
host string (scheme and any user:pass kept, for later stripping and auth),
written NUL-terminated into name[name_size] (truncated to fit), and the port
in *port. The port defaults by scheme: 1080 for socks5/socks5h, else 8080. */
void hts_parse_proxy(const char *arg, char *name, size_t name_size, int *port);
void code64(unsigned char *a, int size_a, unsigned char *b, int crlf);
#define copychar(catbuff,a) concat(catbuff,(a),NULL)

View File

@@ -1308,6 +1308,22 @@ static int st_identurl(httrackp *opt, int argc, char **argv) {
return 0;
}
static int st_proxyurl(httrackp *opt, int argc, char **argv) {
char BIGSTK name[HTS_URLMAXSIZE * 2];
int port = -1;
(void) opt;
if (argc < 1) {
fprintf(stderr, "proxyurl: needs a proxy argument\n");
return 1;
}
hts_parse_proxy(argv[0], name, sizeof(name), &port);
// host= is what the connect actually resolves (scheme + user:pass stripped)
printf("name=%s port=%d host=%s\n", name, port,
jump_identification_const(name));
return 0;
}
/* Regression for the one-byte fil[] overflow: a 2047-byte hostless "?"-URL used
to abort in strncat_safe_ when the missing leading '/' pushed fil to 2048. */
static int st_identabs(httrackp *opt, int argc, char **argv) {
@@ -2756,6 +2772,8 @@ static const struct selftest_entry {
{"resolve", "<link> <adr> <fil>", "resolve a link against an origin",
st_resolve},
{"identurl", "<url>", "split an absolute URL into (adr, fil)", st_identurl},
{"proxyurl", "<proxy-arg>", "parse a -P proxy URL into host/port",
st_proxyurl},
{"identabs", "", "ident_url_absolute one-byte fil[] overflow self-test",
st_identabs},
{"header", "<raw-header-line> ...", "response header-line parsing",

View File

@@ -1,5 +1,5 @@
// Version resource for httrack.exe, the command line program. See version.rc.
#define VER_FILE_DESCRIPTION "HTTrack Website Copier (command line)"
#define VER_ORIGINAL_FILENAME "httrack.exe"
#define VER_FILETYPE VFT_APP
#include "version.rc"
// Version resource for httrack.exe, the command line program. See version.rc.
#define VER_FILE_DESCRIPTION "HTTrack Website Copier (command line)"
#define VER_ORIGINAL_FILENAME "httrack.exe"
#define VER_FILETYPE VFT_APP
#include "version.rc"

View File

@@ -1,5 +1,5 @@
// Version resource for libhttrack.dll. See version.rc.
#define VER_FILE_DESCRIPTION "HTTrack Website Copier engine"
#define VER_ORIGINAL_FILENAME "libhttrack.dll"
#define VER_FILETYPE VFT_DLL
#include "version.rc"
// Version resource for libhttrack.dll. See version.rc.
#define VER_FILE_DESCRIPTION "HTTrack Website Copier engine"
#define VER_ORIGINAL_FILENAME "libhttrack.dll"
#define VER_FILETYPE VFT_DLL
#include "version.rc"

44
tests/01_engine-proxyurl.test Executable file
View File

@@ -0,0 +1,44 @@
#!/bin/bash
#
set -euo pipefail
# hts_parse_proxy splits a -P argument "[scheme://][user:pass@]host[:port]" into
# the proxy host string and port. -#test=proxyurl <arg> prints
# "name=.. port=.. host=..", where host= is what the connect resolves.
p() {
test "$(httrack -O /dev/null -#test=proxyurl "$1")" == "$2" || exit 1
}
# bare host, with and without an explicit port
p 'proxy.example.com:8080' 'name=proxy.example.com port=8080 host=proxy.example.com'
p 'proxy.example.com' 'name=proxy.example.com port=8080 host=proxy.example.com'
# user:pass@ is kept in name (for proxy auth) and stripped from host
p 'user:pass@proxy:3128' 'name=user:pass@proxy port=3128 host=proxy'
p 'user:pass@proxy' 'name=user:pass@proxy port=8080 host=proxy'
# a scheme colon must not be read as a port: http://host with no port used to
# parse to name=http with a garbage port
p 'http://proxy:8080' 'name=http://proxy port=8080 host=proxy'
p 'http://proxy' 'name=http://proxy port=8080 host=proxy'
# socks5/socks5h default to 1080 and resolve to the bare host
p 'socks5://host:1080' 'name=socks5://host port=1080 host=host'
p 'socks5://host' 'name=socks5://host port=1080 host=host'
p 'socks5h://host' 'name=socks5h://host port=1080 host=host'
# explicit port wins over the 1080 default, with and without userinfo
p 'socks5://host:9050' 'name=socks5://host port=9050 host=host'
p 'socks5://user:pass@host:9050' 'name=socks5://user:pass@host port=9050 host=host'
# the split is byte-transparent: percent-escapes stay encoded (decoded later at
# auth time), only structural ASCII ':'/'@' delimit, and the last '@' ends the
# userinfo. So %40/%3A and UTF-8 bytes never mis-split host or port.
p 'socks5://user:p%40ss@host:1080' 'name=socks5://user:p%40ss@host port=1080 host=host'
p 'socks5://us%3Aer:pass@host:1080' 'name=socks5://us%3Aer:pass@host port=1080 host=host'
p 'socks5://a@b:c@host:1080' 'name=socks5://a@b:c@host port=1080 host=host'
p 'socks5://naïve:pass@héllo:1080' 'name=socks5://naïve:pass@héllo port=1080 host=héllo'
# a lone ':' must not underflow the backward scan
p ':' 'name= port=8080 host='

View File

@@ -46,6 +46,7 @@ TESTS = \
01_engine-header.test \
01_engine-idna.test \
01_engine-identurl.test \
01_engine-proxyurl.test \
01_engine-identabs.test \
01_engine-escape-room.test \
01_engine-inplace-escape.test \