Repository: xroche/httrack (2026-07-16T21:43:57Z)

Issue:
httrack <URL> --build-top-index exits 255 with the usage screen instead of crawling
Any option whose expansion contains a lowercase `i` is mistaken for `-i` (continue) when it appears after the URL. The URL list is wiped and httrack prints the usage screen:

```
$ httrack http://example.com/ -O /tmp/d --build-top-index ; echo $?
(usage screen)
255
```

In `htscoremain.c` the pass that counts URLs also detects `-i`. For anything `cmdl_opt()` calls an option it runs `strchr(tmp_argv[0], 'i')` and sets `argv_url = -1` on a match. That test happens after `optalias_check()` has expanded the long aliases, so `--build-top-index` becomes `-%i` and `--protocol N` becomes `-@iN`, and both match the bare letter. `argv_url` ends up at 0, no URL survives, and the run reaches the usage screen. The same value also means "reload `hts-cache/doit.log`" further down, so inside an existing project directory this resumes the previous crawl instead of failing.

Argument order changes how visible the failure is, but the option is misparsed either way. Placed before the URL, `-%i` still sets `opt->quiet = 1`, which is the other half of the `-i` handling; the URL that follows resets the counter and the crawl then proceeds normally.

I instrumented both `strchr` sites and confirmed the matches at runtime. `-%i` / `--build-top-index` and `-@iN` / `--protocol N` are affected. The neighbouring `strchr(tmp_argv[0], 'q')` has the same bug, where `-%q` / `--include-query-string` quietly turns on quiet mode.

Filters such as `+*.gif` are unaffected, since `cmdl_opt()`'s `*`, `.` and `/` heuristic keeps them out of the option branch. `-i`, `-iC1`, `-iC2`, `--continue` and `--update` are all meant to wipe the URL list, so their current behaviour is correct and should stay.



Discussion:

Patch:
diff --git a/src/htscoremain.c b/src/htscoremain.c
index 314b99fc1..ccd3ba72f 100644
--- a/src/htscoremain.c
+++ b/src/htscoremain.c
@@ -113,6 +113,7 @@ HTSEXT_API int hts_main(int argc, char **argv) {
 }
 
 static int hts_main_internal(int argc, char **argv, httrackp * opt);
+static hts_boolean cmdl_shortopt_has(const char *s, char c);
 
 // Main, récupère les paramètres et appelle le robot
 HTSEXT_API int hts_main2(int argc, char **argv, httrackp * opt) {
@@ -304,12 +305,12 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
                      hts_get_version_info(opt));
               return 0;
             } else {
-              if (strncmp(tmp_argv[0], "--", 2)) {      /* pas */
-                if ((strchr(tmp_argv[0], 'q') != NULL))
-                  opt->quiet = 1;       // ne pas poser de questions! (nohup par exemple)
-                if ((strchr(tmp_argv[0], 'i') != NULL)) {       // doit.log!
-                  argv_url = -1;        /* forcer */
-                  opt->quiet = 1;
+              if (strncmp(tmp_argv[0], "--", 2)) { /* not a long option */
+                if (cmdl_shortopt_has(tmp_argv[0], 'q'))
+                  opt->quiet = HTS_TRUE; // never ask questions (nohup)
+                if (cmdl_shortopt_has(tmp_argv[0], 'i')) { // doit.log!
+                  argv_url = -1;
+                  opt->quiet = HTS_TRUE;
                 }
               } else if (strcmp(tmp_argv[0] + 2, "quiet") == 0) {
                 opt->quiet = 1; // ne pas poser de questions! (nohup par exemple)
@@ -2807,6 +2808,32 @@ int check_path(String * s, char *defaultname) {
   return return_value;
 }
 
+/* Does the short-option cluster s carry c from the main option set (-i, -iC2,
+   -%Mi)? Walked as the parser does below: %, &, @ and # each take the letter
+   after them into another set, so the i of -%i is not the main-set -i. */
+static hts_boolean cmdl_shortopt_has(const char *s, char c) {
+  const char *com;
+
+  if (s[0] != '-' || s[1] == '-')
+    return HTS_FALSE;
+  for (com = s + 1; *com != '\0'; com++) {
+    switch (*com) {
+    case '%':
+    case '&':
+    case '@':
+    case '#':
+      if (*(com + 1) != '\0')
+        com++; /* skip the other set's letter */
+      break;
+    default:
+      if (*com == c)
+        return HTS_TRUE;
+      break;
+    }
+  }
+  return HTS_FALSE;
+}
+
 // détermine si l'argument est une option
 int cmdl_opt(char *s) {
   if (s[0] == '-') {            // c'est peut être une option


Test patch:
diff --git a/tests/01_engine-cmdline.test b/tests/01_engine-cmdline.test
index 549bd97a1..cb04465e7 100755
--- a/tests/01_engine-cmdline.test
+++ b/tests/01_engine-cmdline.test
@@ -54,6 +54,13 @@ refused() {
         ! echo "FAIL: $1 (exit $RC)" || exit 1
 }
 
+# assert continue mode was entered: it drops the URL list, so with no cache to
+# resume the run ends on the usage screen rather than on any other error
+continued() {
+    { test "$RC" -ne 0 && grep -q 'usage:' "$1/.log"; } ||
+        ! echo "FAIL: $2 (exit $RC)" || exit 1
+}
+
 # a value past the old 126/256 caps but within the cap is accepted, on both the
 # short and long form of each option
 long=$(nchars 900)
@@ -102,4 +109,50 @@ for bad in nan nan:5 5:nan inf 10:5 99999; do
     refused "#185: invalid --pause '$bad' not refused cleanly"
 done
 
+# An option is not -i (continue) merely because its name contains an 'i' (#615).
+# These used to wipe the URL given before them and exit on the usage screen.
+run "$tmp/ord-bti" --build-top-index
+accepted "$tmp/ord-bti" "#615: --build-top-index after the URL wiped the URL list"
+run "$tmp/ord-bti-s" "-%i"
+accepted "$tmp/ord-bti-s" "#615: -%i after the URL wiped the URL list"
+run "$tmp/ord-proto" --protocol 2
+accepted "$tmp/ord-proto" "#615: --protocol after the URL wiped the URL list"
+run "$tmp/ord-proto-s" "-@i2"
+accepted "$tmp/ord-proto-s" "#615: -@i2 after the URL wiped the URL list"
+
+# %, &, @ and # take the letter after them into another option set, wherever
+# they sit in the cluster, so the i of -q%i is not the main-set -i either.
+run "$tmp/ord-qpi" "-q%i"
+accepted "$tmp/ord-qpi" "#615: -q%i after the URL wiped the URL list"
+run "$tmp/ord-qai" "-q@i"
+accepted "$tmp/ord-qai" "#615: -q@i after the URL wiped the URL list"
+
+# -%q only forces quiet mode, which nothing here can see: a run whose stdout is
+# not a tty is quiet from the start (htscoremain.c:174). Just check it crawls.
+run "$tmp/ord-iqs" "-%q"
+accepted "$tmp/ord-iqs" "#615: -%q after the URL broke the crawl"
+
+# The real -i still forces continue mode and drops the URL list, in every form:
+# a fix that merely stopped looking for 'i' would pass the checks above.
+run "$tmp/cont-s" -i
+continued "$tmp/cont-s" "#615: -i after the URL no longer forces continue mode"
+run "$tmp/cont-c" -iC2
+continued "$tmp/cont-c" "#615: -iC2 after the URL no longer forces continue mode"
+run "$tmp/cont-l" --continue
+continued "$tmp/cont-l" "#615: --continue after the URL no longer forces continue mode"
+run "$tmp/cont-u" --update
+continued "$tmp/cont-u" "#615: --update after the URL no longer forces continue mode"
+
+# ...including where another set's letter precedes it in the cluster.
+run "$tmp/cont-pq" "-%qi"
+continued "$tmp/cont-pq" "#615: -%qi after the URL no longer forces continue mode"
+run "$tmp/cont-pm" "-%Mi"
+continued "$tmp/cont-pm" "#615: -%Mi after the URL no longer forces continue mode"
+
+# Placed before the URL these always worked; they must keep working.
+run_only "$tmp/pre-bti" "-%i" "file://$tmp/index.html"
+accepted "$tmp/pre-bti" "#615: -%i before the URL broke the crawl"
+run_only "$tmp/pre-proto" "-@i2" "file://$tmp/index.html"
+accepted "$tmp/pre-proto" "#615: -@i2 before the URL broke the crawl"
+
 exit 0