LCOV - code coverage report
Current view: top level - src/app - config_wizard.c (source / functions) Coverage Total Hit
Test: coverage-functional.info Lines: 50.4 % 129 65
Test Date: 2026-04-20 19:54:24 Functions: 100.0 % 7 7

            Line data    Source code
       1              : /* SPDX-License-Identifier: GPL-3.0-or-later */
       2              : /* Copyright 2026 Peter Csaszar */
       3              : 
       4              : /**
       5              :  * @file app/config_wizard.c
       6              :  * @brief First-run interactive config wizard (FEAT-37).
       7              :  *
       8              :  * Shared by tg-cli, tg-cli-ro, and tg-tui via `<binary> login`.
       9              :  * Interactive path: prompts the user for api_id and api_hash on a TTY,
      10              :  * suppressing echo for the hash.
      11              :  * Batch path: validates the values from argv without any prompts.
      12              :  * On success writes ~/.config/tg-cli/config.ini atomically (tmp+rename+fsync)
      13              :  * and chmods it 0600.
      14              :  */
      15              : 
      16              : #include "app/config_wizard.h"
      17              : 
      18              : #include "fs_util.h"
      19              : #include "platform/path.h"
      20              : #include "platform/terminal.h"
      21              : 
      22              : #include <ctype.h>
      23              : #include <errno.h>
      24              : #include <fcntl.h>
      25              : #include <stdio.h>
      26              : #include <stdlib.h>
      27              : #include <string.h>
      28              : #include <sys/stat.h>
      29              : #include <unistd.h>
      30              : 
      31              : #define API_HASH_LEN 32
      32              : #define MAX_RETRIES   3
      33              : 
      34              : /* ---- Validation helpers ---- */
      35              : 
      36              : /**
      37              :  * @brief Parse @p s as a positive 32-bit decimal integer.
      38              :  * @return The parsed value, or -1 on error.
      39              :  */
      40            9 : static int parse_api_id(const char *s) {
      41            9 :     if (!s || !*s) return -1;
      42            9 :     char *end = NULL;
      43            9 :     long v = strtol(s, &end, 10);
      44            9 :     if (!end || *end != '\0') return -1;
      45            8 :     if (v <= 0 || v > 0x7FFFFFFF) return -1;
      46            6 :     return (int)v;
      47              : }
      48              : 
      49              : /**
      50              :  * @brief Validate @p s as a 32-char lowercase hex string.
      51              :  * @return 0 if valid, -1 otherwise.
      52              :  */
      53            6 : static int validate_api_hash(const char *s) {
      54            6 :     if (!s) return -1;
      55            6 :     size_t n = strlen(s);
      56            6 :     if (n != API_HASH_LEN) return -1;
      57          101 :     for (size_t i = 0; i < n; i++) {
      58           98 :         unsigned char c = (unsigned char)s[i];
      59           98 :         if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')))
      60            2 :             return -1;
      61              :     }
      62            3 :     return 0;
      63              : }
      64              : 
      65              : /* ---- Config file write ---- */
      66              : 
      67              : /**
      68              :  * @brief Determine the config directory path (without creating it).
      69              :  *
      70              :  * Result is written into @p buf (length @p cap).
      71              :  * @return 0 on success, -1 if the platform directory is unknown.
      72              :  */
      73            3 : static int get_config_dir(char *buf, size_t cap) {
      74            3 :     const char *base = platform_config_dir();
      75            3 :     if (!base) return -1;
      76            3 :     snprintf(buf, cap, "%s/tg-cli", base);
      77            3 :     return 0;
      78              : }
      79              : 
      80              : /**
      81              :  * @brief Check whether @p path is a non-empty regular file.
      82              :  * @return 1 if the file exists and has size > 0, 0 otherwise.
      83              :  */
      84            2 : static int file_nonempty(const char *path) {
      85              :     struct stat st;
      86            2 :     if (stat(path, &st) != 0) return 0;
      87            1 :     return (S_ISREG(st.st_mode) && st.st_size > 0) ? 1 : 0;
      88              : }
      89              : 
      90              : /**
      91              :  * @brief Atomically write api_id + api_hash to config.ini.
      92              :  *
      93              :  * Writes to a temp file, fsyncs, then renames over the destination.
      94              :  * Sets the final file to mode 0600.
      95              :  *
      96              :  * @return 0 on success, -1 on any I/O error.
      97              :  */
      98            2 : static int write_config(const char *dir, int api_id, const char *api_hash) {
      99              :     char path[1024];
     100              :     char tmp_path[1040];
     101            2 :     snprintf(path,     sizeof(path),     "%s/config.ini", dir);
     102            2 :     snprintf(tmp_path, sizeof(tmp_path), "%s/config.ini.tmp", dir);
     103              : 
     104              :     /* Create the directory if needed. */
     105            2 :     if (fs_mkdir_p(dir, 0700) != 0) {
     106            0 :         fprintf(stderr, "login: cannot create config dir %s: %s\n",
     107            0 :                 dir, strerror(errno));
     108            0 :         return -1;
     109              :     }
     110              : 
     111              :     /* Write temp file. */
     112            2 :     int fd = open(tmp_path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
     113            2 :     if (fd < 0) {
     114            0 :         fprintf(stderr, "login: cannot open %s: %s\n", tmp_path, strerror(errno));
     115            0 :         return -1;
     116              :     }
     117              : 
     118            2 :     FILE *fp = fdopen(fd, "w");
     119            2 :     if (!fp) {
     120            0 :         fprintf(stderr, "login: fdopen failed: %s\n", strerror(errno));
     121            0 :         close(fd);
     122            0 :         return -1;
     123              :     }
     124              : 
     125            2 :     fprintf(fp, "# tg-cli configuration\n");
     126            2 :     fprintf(fp, "# Generated by `login` wizard. Do NOT share this file.\n");
     127            2 :     fprintf(fp, "api_id=%d\n", api_id);
     128            2 :     fprintf(fp, "api_hash=%s\n", api_hash);
     129              : 
     130            2 :     if (fflush(fp) != 0) {
     131            0 :         fprintf(stderr, "login: fflush failed: %s\n", strerror(errno));
     132            0 :         fclose(fp);
     133            0 :         return -1;
     134              :     }
     135            2 :     if (fsync(fileno(fp)) != 0) {
     136            0 :         fprintf(stderr, "login: fsync failed: %s\n", strerror(errno));
     137            0 :         fclose(fp);
     138            0 :         return -1;
     139              :     }
     140            2 :     fclose(fp);
     141              : 
     142              :     /* Atomic rename over destination. */
     143            2 :     if (rename(tmp_path, path) != 0) {
     144            0 :         fprintf(stderr, "login: rename failed: %s\n", strerror(errno));
     145            0 :         return -1;
     146              :     }
     147              : 
     148              :     /* Ensure correct permissions even if the file existed before. */
     149            2 :     chmod(path, 0600);
     150              : 
     151            2 :     return 0;
     152              : }
     153              : 
     154              : /* ---- Interactive wizard ---- */
     155              : 
     156            1 : int config_wizard_run_interactive(void) {
     157            1 :     if (!isatty(STDIN_FILENO)) {
     158            1 :         fprintf(stderr,
     159              :                 "login: stdin is not a TTY — use batch mode:\n"
     160              :                 "  <binary> login --api-id N --api-hash HEX\n");
     161            1 :         return -1;
     162              :     }
     163              : 
     164              :     /* Determine and check config path upfront. */
     165              :     char cfg_dir[1024];
     166            0 :     if (get_config_dir(cfg_dir, sizeof(cfg_dir)) != 0) {
     167            0 :         fprintf(stderr, "login: cannot determine config directory\n");
     168            0 :         return -1;
     169              :     }
     170              :     char cfg_path[1040];
     171            0 :     snprintf(cfg_path, sizeof(cfg_path), "%s/config.ini", cfg_dir);
     172              : 
     173            0 :     if (file_nonempty(cfg_path)) {
     174            0 :         fprintf(stderr,
     175              :                 "login: %s already exists and is non-empty.\n"
     176              :                 "       Pass --force to overwrite, or edit it manually.\n",
     177              :                 cfg_path);
     178            0 :         return -1;
     179              :     }
     180              : 
     181            0 :     printf(
     182              :         "\nWelcome to tg-cli!  This one-time setup records your Telegram API\n"
     183              :         "credentials into %s (mode 0600).\n"
     184              :         "\n"
     185              :         "You need an api_id and api_hash from https://my.telegram.org\n"
     186              :         "For a step-by-step guide see: docs/user/setup-my-telegram-org.md\n"
     187              :         "\n"
     188              :         "  1. Log in with your phone number and the code Telegram sends.\n"
     189              :         "  2. Go to 'API development tools'.\n"
     190              :         "  3. Fill out the 'Create new application' form:\n"
     191              :         "      - App title: anything, e.g. \"my tg-cli\"\n"
     192              :         "      - Short name: any 5+ char slug\n"
     193              :         "      - Platform: Desktop\n"
     194              :         "      - Description: optional\n"
     195              :         "  4. Copy the numeric 'App api_id' and the 32-char 'App api_hash'.\n"
     196              :         "  5. Paste them below.\n"
     197              :         "\n"
     198              :         "Press Ctrl-C to abort at any time.\n"
     199              :         "\n",
     200              :         cfg_path
     201              :     );
     202              : 
     203              :     /* Prompt for api_id with retries. */
     204            0 :     int api_id = -1;
     205            0 :     for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
     206              :         char buf[64];
     207            0 :         printf("Enter your api_id: ");
     208            0 :         fflush(stdout);
     209            0 :         if (!fgets(buf, sizeof(buf), stdin)) {
     210            0 :             fprintf(stderr, "\nlogin: aborted\n");
     211            0 :             return -1;
     212              :         }
     213              :         /* Strip trailing newline. */
     214            0 :         size_t n = strlen(buf);
     215            0 :         while (n > 0 && (buf[n-1] == '\n' || buf[n-1] == '\r')) buf[--n] = '\0';
     216              : 
     217            0 :         api_id = parse_api_id(buf);
     218            0 :         if (api_id > 0) break;
     219              : 
     220            0 :         fprintf(stderr,
     221              :                 "  Error: api_id must be a positive integer (e.g. 12345).\n");
     222            0 :         if (attempt == MAX_RETRIES - 1) {
     223            0 :             fprintf(stderr, "login: too many invalid attempts\n");
     224            0 :             return -1;
     225              :         }
     226              :     }
     227              : 
     228              :     /* Prompt for api_hash with echo suppressed and retries. */
     229              :     char api_hash[API_HASH_LEN + 4]; /* +4 for NUL + possible \r\n */
     230            0 :     memset(api_hash, 0, sizeof(api_hash));
     231            0 :     int hash_ok = 0;
     232            0 :     for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
     233            0 :         int n = terminal_read_password("Enter your api_hash: ",
     234              :                                         api_hash, sizeof(api_hash));
     235            0 :         if (n < 0) {
     236            0 :             fprintf(stderr, "\nlogin: aborted\n");
     237            0 :             return -1;
     238              :         }
     239              :         /* terminal_read_password already NUL-terminates; strip any trailing
     240              :          * newline that some implementations might leave. */
     241            0 :         size_t slen = strlen(api_hash);
     242            0 :         while (slen > 0 && (api_hash[slen-1] == '\n' || api_hash[slen-1] == '\r'))
     243            0 :             api_hash[--slen] = '\0';
     244              : 
     245            0 :         if (validate_api_hash(api_hash) == 0) {
     246            0 :             hash_ok = 1;
     247            0 :             break;
     248              :         }
     249            0 :         fprintf(stderr,
     250              :                 "  Error: api_hash must be exactly 32 lowercase hex "
     251              :                 "characters ([0-9a-f]).\n");
     252            0 :         if (attempt == MAX_RETRIES - 1) {
     253            0 :             fprintf(stderr, "login: too many invalid attempts\n");
     254            0 :             return -1;
     255              :         }
     256              :     }
     257            0 :     if (!hash_ok) return -1;
     258              : 
     259            0 :     printf("Verifying... OK (format valid)\n");
     260              : 
     261            0 :     if (write_config(cfg_dir, api_id, api_hash) != 0) return -1;
     262              : 
     263            0 :     printf("Saved to %s (mode 0600).\n", cfg_path);
     264            0 :     printf("Next: run the binary and enter your phone number to complete login.\n\n");
     265            0 :     return 0;
     266              : }
     267              : 
     268              : /* ---- Batch wizard ---- */
     269              : 
     270           11 : int config_wizard_run_batch(const char *api_id_str, const char *api_hash_str,
     271              :                              int force) {
     272           11 :     if (!api_id_str || !api_hash_str) {
     273            2 :         fprintf(stderr,
     274              :                 "login: --api-id and --api-hash are both required in batch mode\n");
     275            2 :         return -1;
     276              :     }
     277              : 
     278            9 :     int api_id = parse_api_id(api_id_str);
     279            9 :     if (api_id <= 0) {
     280            3 :         fprintf(stderr,
     281              :                 "login: --api-id must be a positive integer, got: %s\n",
     282              :                 api_id_str);
     283            3 :         return -1;
     284              :     }
     285              : 
     286            6 :     if (validate_api_hash(api_hash_str) != 0) {
     287            3 :         fprintf(stderr,
     288              :                 "login: --api-hash must be exactly 32 lowercase hex chars "
     289              :                 "([0-9a-f]), got: %s\n",
     290              :                 api_hash_str);
     291            3 :         return -1;
     292              :     }
     293              : 
     294              :     char cfg_dir[1024];
     295            3 :     if (get_config_dir(cfg_dir, sizeof(cfg_dir)) != 0) {
     296            0 :         fprintf(stderr, "login: cannot determine config directory\n");
     297            0 :         return -1;
     298              :     }
     299              : 
     300              :     char cfg_path[1040];
     301            3 :     snprintf(cfg_path, sizeof(cfg_path), "%s/config.ini", cfg_dir);
     302              : 
     303            3 :     if (!force && file_nonempty(cfg_path)) {
     304            1 :         fprintf(stderr,
     305              :                 "login: %s already exists and is non-empty.\n"
     306              :                 "       Pass --force to overwrite.\n",
     307              :                 cfg_path);
     308            1 :         return -1;
     309              :     }
     310              : 
     311            2 :     if (write_config(cfg_dir, api_id, api_hash_str) != 0) return -1;
     312              : 
     313            2 :     printf("Saved to %s (mode 0600).\n", cfg_path);
     314            2 :     return 0;
     315              : }
        

Generated by: LCOV version 2.0-1