From 05a7159c183b0d71c777a9f1a4d5878004b5c51b Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Tue, 6 Oct 2026 13:29:01 +0800 Subject: [PATCH] Ask for missing credentials and create the project's own .env .env becomes optional. A credential it does not provide (an absent file, an absent or empty key) is asked, without echo: GITEA_TOKEN at the start, GITHUB_PAT and GITHUB_USER once GitHub is chosen. An invalid value is asked again and never shown; when input ends the run stops before any request. Asked tokens are registered for redaction at once. After the local project exists the script asks (default no) whether to create a .env in it. On a yes it holds only the needed keys, is created private (mode 600) from the start, is excluded from git through .git/info/exclude (no tracked file changes), is never replaced without a second yes and is never written when git tracks it. The summary names the keys, never the values. New library files credentials.sh and envfile.sh; README, .env.example and the security decisions updated; tests cover every case. Task: MIL-005#1 Task: MIL-005#2 Task: MIL-005#3 Task: MIL-005#4 Task: MIL-005#5 Closes #35 Closes #36 Closes #37 Closes #38 Closes #39 Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 7 +- README.md | 38 ++++- src/create-project.sh | 21 ++- src/lib/apply.sh | 1 + src/lib/config.sh | 36 ++--- src/lib/constants.sh | 4 +- src/lib/credentials.sh | 42 ++++++ src/lib/envfile.sh | 97 ++++++++++++ src/lib/plan.sh | 1 + src/lib/prompts.sh | 23 ++- tests/test-config.sh | 13 -- tests/test-credentials.sh | 305 ++++++++++++++++++++++++++++++++++++++ tests/test-security.sh | 6 +- 13 files changed, 544 insertions(+), 50 deletions(-) create mode 100644 src/lib/credentials.sh create mode 100644 src/lib/envfile.sh create mode 100644 tests/test-credentials.sh diff --git a/.env.example b/.env.example index 1102a41..cb60066 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ # RepoFoundry credentials. Placeholders only: never put a real value in this # file or commit one. # +# Everything in this file is optional: a credential that is missing here is +# asked for when the script runs (the token is not echoed). +# # Copy this file to .env, fill in the values and keep it private # (chmod 600 .env on Linux and macOS). .env is ignored by git. The file is # read as plain KEY=VALUE lines and never executed. Values may be wrapped in @@ -13,7 +16,7 @@ GITHUB_PAT= ######################################## -# Secrets for workframe +# Secrets for framework ######################################## # GitHub account the token belongs to. It identifies who authenticates; it is @@ -21,6 +24,6 @@ GITHUB_PAT= # belong to an organization. GITHUB_USER= -# Gitea access token (required). It needs permission to create repositories +# Gitea access token. It needs permission to create repositories # for the chosen owner and to manage the repository's push mirror. GITEA_TOKEN= diff --git a/README.md b/README.md index b7e4826..04fbccf 100644 --- a/README.md +++ b/README.md @@ -129,14 +129,40 @@ src/create-project.sh --apply # asks only "Create these now (y/n) [n]" | Key | Meaning | | --- | --- | -| `GITEA_TOKEN` | Gitea access token (required) | +| `GITEA_TOKEN` | Gitea access token | | `GITHUB_PAT` | GitHub personal access token (only when you choose GitHub) | -| `GITHUB_USER` | the GitHub account the token belongs to; only a default for the owner prompt | +| `GITHUB_USER` | the GitHub account the token belongs to (only when you choose GitHub) | `.env` is ignored by git. The script warns if it is readable by other users or not ignored by git. See [Token permissions](#token-permissions) for what each token needs. +`.env` is optional, and so is each key in it. A credential that is not +provided (the file is missing, the key is absent or its value is empty) is +asked for: the Gitea token at the start, the GitHub token and account name once +you choose GitHub. A token is read without echo and checked like one read from +`.env`; a refused value is asked again and never shown. If input ends before a +valid value is entered, the run stops before any request to a host. + +### The project's own `.env` + +When the project exists, the script asks whether to create a `.env` in it +(default no). On a yes the file holds only the credentials the project needs: +`GITEA_TOKEN`, and `GITHUB_PAT` and `GITHUB_USER` when you chose GitHub, as read +from your `.env` or typed. + +- The file is created readable by you only (mode 600), never readable by + others even for a moment, and is never written by anything else. +- Git ignores it: the script adds `.env` to `.git/info/exclude` of the new + project. No tracked file changes and nothing is committed. +- An existing `.env` in the project is never replaced without a second yes, and + a `.env` that git already tracks is never written. +- The summary names the keys, never the values. + +This is the one place the script writes a token to disk. It is plain text: keep +the project directory private, do not copy the file around, and say no if you +do not need it. Tokens are written nowhere else. + ## Usage ```bash @@ -264,7 +290,9 @@ script stops before it creates anything when a preflight check is refused. ## Security decisions - **Tokens never appear** in output, logs, remote URLs, `.git/config`, - `.gitmodules`, command lines or leftover files. They go to `curl` through a + `.gitmodules`, command lines or leftover files, and are written to disk only + in the new project's own `.env`, after a yes (see + [The project's own `.env`](#the-projects-own-env)). They go to `curl` through a private configuration file that is removed right after the request, and to `git` (HTTPS fetch only) through a `GIT_ASKPASS` helper and the environment of that one command. Output is filtered, so even a server message that echoes @@ -368,11 +396,13 @@ file. The files are loaded from that directory only, by a fixed path. | `json.sh` | the little JSON the script reads and writes | | `http.sh` | the one place that runs `curl`; tokens stay off the command line | | `api.sh` | GitHub and Gitea API calls and reporting a refused call | -| `prompts.sh` | interactive questions with validation | +| `prompts.sh` | interactive questions with validation (secrets are read without echo) | +| `credentials.sh` | asking for a credential that `.env` does not provide | | `project.sh` | the project details: asking for them and showing them | | `hosts.sh` | names, links and remote addresses of the repositories | | `preflight.sh` | read-only checks of both hosts | | `steps.sh` | the outcome of each step and the final report | +| `envfile.sh` | the new project's own `.env`: created private, ignored by git, never replaced without a yes | | `plan.sh` | printing what the script is about to do | | `repositories.sh` | creating the GitHub and Gitea repositories | | `mirror.sh` | the Gitea to GitHub push mirror | diff --git a/src/create-project.sh b/src/create-project.sh index f0ad673..085c2d3 100644 --- a/src/create-project.sh +++ b/src/create-project.sh @@ -11,7 +11,9 @@ # repository, remotes (no credential in any address), the framework as a # submodule, the framework's skills and git hooks (and the plan gate if # chosen) and its templates. Choosing GitHub also applies the AGPL-3.0 -# license to the Gitea repository. No commit is made in the new project. +# license to the Gitea repository. After a yes (default no) it also writes +# the new project's own .env with the credentials the project needs. No +# commit is made in the new project. # # Dry run by default # Without --apply the script only reads from GitHub and Gitea (GET @@ -27,7 +29,8 @@ # Options # --apply create the repositories and the mirror (after a final yes) # --config FILE service addresses (default: config.env in the project root) -# --env FILE credentials (default: .env in the project root) +# --env FILE credentials (default: .env in the project root); optional: +# a credential it does not provide is asked, not echoed # -h, --help show this help # --version show the version # @@ -36,7 +39,8 @@ # the optional GITEA_SSH_PORT (default 10022), MIRROR_INTERVAL # (default 10m0s) and FRAMEWORK_REPO (default # TirSystem/SQA-QC-Framework, the submodule's OWNER/NAME) -# .env GITHUB_PAT, GITHUB_USER, GITEA_TOKEN +# .env GITHUB_PAT, GITHUB_USER, GITEA_TOKEN (all optional, each +# asked when missing) # # Environment # REPOFOUNDRY_NAME project name used in messages (default: RepoFoundry) @@ -65,8 +69,8 @@ # This file is the entry point. The work is split by responsibility into # the files in lib/ next to it (one job per file, see the first lines of # each file): constants, output, temp, util, validate, config, tools, json, -# http, api, prompts, project, hosts, preflight, steps, plan, repositories, -# mirror, git, localproject, framework, apply and cli. The files are loaded +# http, api, prompts, credentials, project, hosts, preflight, steps, plan, +# repositories, mirror, git, localproject, framework, envfile, apply and cli. The files are loaded # from this directory only. # # Exit codes @@ -121,6 +125,8 @@ source "$SCRIPT_DIR/lib/http.sh" source "$SCRIPT_DIR/lib/api.sh" # shellcheck source=lib/prompts.sh source "$SCRIPT_DIR/lib/prompts.sh" +# shellcheck source=lib/credentials.sh +source "$SCRIPT_DIR/lib/credentials.sh" # shellcheck source=lib/project.sh source "$SCRIPT_DIR/lib/project.sh" # shellcheck source=lib/hosts.sh @@ -141,6 +147,8 @@ source "$SCRIPT_DIR/lib/git.sh" source "$SCRIPT_DIR/lib/localproject.sh" # shellcheck source=lib/framework.sh source "$SCRIPT_DIR/lib/framework.sh" +# shellcheck source=lib/envfile.sh +source "$SCRIPT_DIR/lib/envfile.sh" # shellcheck source=lib/apply.sh source "$SCRIPT_DIR/lib/apply.sh" # shellcheck source=lib/cli.sh @@ -166,9 +174,10 @@ main() { check_tools setup_temp_dir load_configuration + collect_credentials GITEA_TOKEN collect_project_details if ((PROJECT[has_github])); then - require_github_credentials + collect_credentials GITHUB_PAT GITHUB_USER fi init_steps print_summary diff --git a/src/lib/apply.sh b/src/lib/apply.sh index 124e11e..8d2426e 100644 --- a/src/lib/apply.sh +++ b/src/lib/apply.sh @@ -19,6 +19,7 @@ create_all() { add_framework install_framework copy_templates + create_env_file } # confirm_framework_access: the framework comes over SSH. Without SSH the diff --git a/src/lib/config.sh b/src/lib/config.sh index 6f691f3..93bb4e7 100644 --- a/src/lib/config.sh +++ b/src/lib/config.sh @@ -4,7 +4,7 @@ # # Part of create-project.sh: sourced by it, never run on its own. # -# Provides: unquote_value, parse_env_file, parse_env_entry, validate_config, check_preset, check_preset_choice, validate_project_presets, validate_credentials, require_github_credentials, warn_if_env_unsafe, load_configuration +# Provides: unquote_value, parse_env_file, parse_env_entry, validate_config, check_preset, check_preset_choice, validate_project_presets, validate_credentials, warn_if_env_unsafe, load_configuration # unquote_value RAW: strip matching quotes (or a trailing " # comment" on an # unquoted value) and return the value in REPLY. Fails on unbalanced quotes. @@ -144,20 +144,23 @@ validate_project_presets() { check_preset_choice ENABLE_PLAN_GATE yes no } +# validate_credentials: check the credentials that .env provides. A credential +# that is not provided is not an error: it is asked later (collect_credentials). validate_credentials() { - if [[ -z ${CREDENTIALS[GITEA_TOKEN]:-} ]]; then - die "GITEA_TOKEN is missing in $ENV_FILE (see .env.example)" - fi # Register secrets first so that no later message can show them. - SECRET_VALUES+=("${CREDENTIALS[GITEA_TOKEN]}") + if [[ -n ${CREDENTIALS[GITEA_TOKEN]:-} ]]; then + SECRET_VALUES+=("${CREDENTIALS[GITEA_TOKEN]}") + fi if [[ -n ${CREDENTIALS[GITHUB_PAT]:-} ]]; then SECRET_VALUES+=("${CREDENTIALS[GITHUB_PAT]}") fi - is_valid_token "${CREDENTIALS[GITEA_TOKEN]}" || - die "GITEA_TOKEN in $ENV_FILE is not a valid token (8 to 255 letters, digits or _ . ~ + / = -)" + if [[ -n ${CREDENTIALS[GITEA_TOKEN]:-} ]] && + ! is_valid_token "${CREDENTIALS[GITEA_TOKEN]}"; then + die "GITEA_TOKEN in $ENV_FILE is not a valid token ($HINT_TOKEN)" + fi if [[ -n ${CREDENTIALS[GITHUB_PAT]:-} ]] && ! is_valid_token "${CREDENTIALS[GITHUB_PAT]}"; then - die "GITHUB_PAT in $ENV_FILE is not a valid token (8 to 255 letters, digits or _ . ~ + / = -)" + die "GITHUB_PAT in $ENV_FILE is not a valid token ($HINT_TOKEN)" fi if [[ -n ${CREDENTIALS[GITHUB_USER]:-} ]] && ! is_valid_github_owner "${CREDENTIALS[GITHUB_USER]}"; then @@ -165,16 +168,6 @@ validate_credentials() { fi } -# GitHub credentials are only needed when the Maintainer chose GitHub. -require_github_credentials() { - local key - for key in GITHUB_PAT GITHUB_USER; do - if [[ -z ${CREDENTIALS[$key]:-} ]]; then - die "GitHub was chosen but $key is missing in $ENV_FILE (see .env.example)" - fi - done -} - warn_if_env_unsafe() { local file="$1" dir mode case "$(uname -s 2>/dev/null || true)" in @@ -200,7 +193,10 @@ warn_if_env_unsafe() { load_configuration() { parse_env_file "$CONFIG_FILE" CONFIG_KEYS CONFIG validate_config - parse_env_file "$ENV_FILE" CREDENTIAL_KEYS CREDENTIALS + # .env is optional: a credential it does not provide is asked. + if [[ -e $ENV_FILE ]]; then + parse_env_file "$ENV_FILE" CREDENTIAL_KEYS CREDENTIALS + warn_if_env_unsafe "$ENV_FILE" + fi validate_credentials - warn_if_env_unsafe "$ENV_FILE" } diff --git a/src/lib/constants.sh b/src/lib/constants.sh index 5e0aa16..78b4290 100644 --- a/src/lib/constants.sh +++ b/src/lib/constants.sh @@ -26,8 +26,10 @@ readonly HINT_DESCRIPTION="at most $MAX_DESCRIPTION_LENGTH characters and no con readonly HINT_GITEA_OWNER="use letters, digits, '.', '_' or '-' (at most 39)" readonly HINT_GITHUB_OWNER="use letters, digits or '-' (at most 39)" readonly HINT_DIRECTORY="must not be empty, start with '-' or contain control characters" +readonly HINT_TOKEN="8 to 255 letters, digits or _ . ~ + / = -" +readonly ENV_FILE_NAME=".env" readonly PLAN_STEPS=("GitHub repository" "Gitea repository" "Push mirror" - "Local project" "Framework" "Skills and hooks" "Templates") + "Local project" "Framework" "Skills and hooks" "Templates" "Project .env") # shellcheck disable=SC2034 # read through namerefs (parse_env_file) readonly CONFIG_KEYS=(GITHUB_API_URL GITHUB_WEB_URL GITEA_URL GITEA_API_URL GITEA_SSH_PORT MIRROR_INTERVAL FRAMEWORK_REPO diff --git a/src/lib/credentials.sh b/src/lib/credentials.sh new file mode 100644 index 0000000..f40ff64 --- /dev/null +++ b/src/lib/credentials.sh @@ -0,0 +1,42 @@ +# shellcheck shell=bash +# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh +# credentials.sh - Asking for a credential that .env does not provide. +# +# Part of create-project.sh: sourced by it, never run on its own. +# +# Provides: credential_label, collect_credentials + +# credential_label KEY: the name of a credential as the Maintainer sees it. +credential_label() { + case "$1" in + GITEA_TOKEN) printf 'Gitea access token' ;; + GITHUB_PAT) printf 'GitHub personal access token' ;; + GITHUB_USER) printf 'GitHub account name (the account the token belongs to)' ;; + *) printf '%s' "$1" ;; + esac +} + +# collect_credentials KEY...: ask for each credential that is not already +# provided. A token is read without echo and registered as a secret at once, +# so no later message can show it; the GitHub account name is not secret and +# is read like any other answer. An empty value in .env counts as not provided. +collect_credentials() { + local key + for key in "$@"; do + if [[ -n ${CREDENTIALS[$key]:-} ]]; then + continue + fi + case "$key" in + GITHUB_USER) + prompt_value "$(credential_label "$key")" "" is_valid_github_owner \ + "use letters, digits or '-' (at most 39)" + ;; + *) + prompt_secret "$(credential_label "$key")" is_valid_token "$HINT_TOKEN" + SECRET_VALUES+=("$REPLY") + ;; + esac + CREDENTIALS[$key]="$REPLY" + REPLY="" + done +} diff --git a/src/lib/envfile.sh b/src/lib/envfile.sh new file mode 100644 index 0000000..f2b67eb --- /dev/null +++ b/src/lib/envfile.sh @@ -0,0 +1,97 @@ +# shellcheck shell=bash +# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh +# envfile.sh - The .env file of the new project: the one place a credential is written. +# +# Part of create-project.sh: sourced by it, never run on its own. +# +# Provides: env_file_keys, env_file_key_list, exclude_env_file, write_env_file, create_env_file + +# env_file_keys: the credentials the new project needs, one per line: the +# Gitea token, and the GitHub token and account name when GitHub was chosen. +env_file_keys() { + printf '%s\n' GITEA_TOKEN + if ((PROJECT[has_github])); then + printf '%s\n' GITHUB_PAT GITHUB_USER + fi +} + +# env_file_key_list: the same keys on one line, for messages. +env_file_key_list() { + local keys + keys="$(env_file_keys | tr '\n' ' ')" + printf '%s' "${keys% }" +} + +# exclude_env_file DIR: make git ignore .env in DIR without touching a tracked +# file: the entry goes into .git/info/exclude, which is never committed. It +# does nothing when .env is already ignored. +exclude_env_file() { + local dir="$1" gitdir exclude + if git_project "$dir" check-ignore -q -- "$ENV_FILE_NAME"; then + return 0 + fi + gitdir="$(git_project "$dir" rev-parse --absolute-git-dir)" + exclude="$gitdir/info/exclude" + mkdir -p -- "$gitdir/info" + # Start on a fresh line when the file does not end with one. + if [[ -s $exclude && -n "$(tail -c 1 -- "$exclude")" ]]; then + printf '\n' >>"$exclude" + fi + printf '%s\n' "# RepoFoundry: the credentials file of this project" "$ENV_FILE_NAME" >>"$exclude" + git_project "$dir" check-ignore -q -- "$ENV_FILE_NAME" || + die "could not make git ignore $ENV_FILE_NAME in $dir; nothing was written to it" +} + +# write_env_file DIR IS_REPLACE: write the credentials to DIR/.env. The file is +# created private (mode 600) from the start, never readable by others, even +# for a moment: it is written under umask 077 as a temporary file next to the +# target and moved into place. An existing file is only replaced when +# IS_REPLACE is 1, and a file that appears in the meantime is never replaced. +write_env_file() { + local dir="$1" is_replace="$2" target tmp key + target="$dir/$ENV_FILE_NAME" + tmp="$(umask 077 && mktemp "$dir/$ENV_FILE_NAME.XXXXXX")" + TEMP_FILES+=("$tmp") + { + while IFS= read -r key; do + printf '%s=%s\n' "$key" "${CREDENTIALS[$key]}" + done < <(env_file_keys) + } >"$tmp" + if ((is_replace)); then + mv -f -- "$tmp" "$target" + else + mv -n -- "$tmp" "$target" + if [[ -e $tmp ]]; then + die "$target appeared while it was being written; it was not replaced" + fi + fi +} + +# create_env_file: the last step. Only after a yes (default no) is the .env +# written, and an existing one is only replaced after another yes. Nothing +# printed names a value, only the keys. +create_env_file() { + local label="Project .env" dir="${PROJECT[directory]}" keys is_replace=0 + keys="$(env_file_key_list)" + begin_step "$label" + prompt_yes_no "Create a $ENV_FILE_NAME file in the project with the credentials it needs ($keys); only you can read it and git ignores it" n + if ! ((REPLY)); then + finish_step "$label" "skipped" "(you declined)" + return 0 + fi + if git_project "$dir" ls-files --error-unmatch -- "$ENV_FILE_NAME" >/dev/null 2>&1; then + finish_step "$label" "skipped" "($ENV_FILE_NAME is tracked by git; it was not written)" + return 0 + fi + if [[ -e $dir/$ENV_FILE_NAME || -L $dir/$ENV_FILE_NAME ]]; then + prompt_yes_no "$ENV_FILE_NAME already exists in the project. Replace it" n + if ! ((REPLY)); then + finish_step "$label" "kept" "(the existing $ENV_FILE_NAME was left as it was)" + return 0 + fi + is_replace=1 + fi + exclude_env_file "$dir" + write_env_file "$dir" "$is_replace" + finish_step "$label" "created" "($keys; only you can read it, git ignores it)" +} diff --git a/src/lib/plan.sh b/src/lib/plan.sh index f735c6b..9cfac3f 100644 --- a/src/lib/plan.sh +++ b/src/lib/plan.sh @@ -54,4 +54,5 @@ print_plan() { else say "$(printf ' %-18s: %s' "Framework" "NOT possible without SSH to Gitea; you will be asked whether to go on without it")" fi + say "$(printf ' %-18s: %s' "Project .env" "you are asked whether to create it ($(env_file_key_list))")" } diff --git a/src/lib/prompts.sh b/src/lib/prompts.sh index 39c3250..7bccb47 100644 --- a/src/lib/prompts.sh +++ b/src/lib/prompts.sh @@ -4,7 +4,7 @@ # # Part of create-project.sh: sourced by it, never run on its own. # -# Provides: prompt_value, prompt_choice, prompt_yes_no +# Provides: prompt_value, prompt_secret, prompt_choice, prompt_yes_no # prompt_value LABEL DEFAULT VALIDATOR HINT: ask until VALIDATOR accepts the # answer; the accepted answer is returned in REPLY. @@ -27,6 +27,27 @@ prompt_value() { done } +# prompt_secret LABEL VALIDATOR HINT: like prompt_value for a secret. What is +# typed is not shown (read -s) and a refused answer is never repeated in the +# message. An empty answer is refused; there is no default. +prompt_secret() { + local label="$1" validator="$2" hint="$3" answer + while true; do + printf '%s (input is hidden): ' "$label" >&2 + IFS= read -rs answer || { + printf '\n' >&2 + die "no input available for '$label'" + } + printf '\n' >&2 # the newline that hidden input did not echo + answer="$(trim "$answer")" + if [[ -n $answer ]] && "$validator" "$answer"; then + REPLY="$answer" + return 0 + fi + warn "invalid $label: $hint" + done +} + # prompt_choice LABEL DEFAULT CHOICE...: the answer is returned in REPLY. prompt_choice() { local label="$1" default="$2" answer diff --git a/tests/test-config.sh b/tests/test-config.sh index fded59d..b5b0ddb 100644 --- a/tests/test-config.sh +++ b/tests/test-config.sh @@ -96,7 +96,6 @@ validate_credentials" assert_status "$case_name" 1 "$STATUS" assert_contains "$case_name message" "$ERR" "$expected" done <<'EOF' -no Gitea token|GITHUB_USER=octo\n|GITEA_TOKEN is missing token too short|GITEA_TOKEN=short\n|not a valid token token with a backslash|GITEA_TOKEN=abc\\defgh12345\n|not a valid token bad GitHub token|GITEA_TOKEN=abcdefgh12345\nGITHUB_PAT=bad token\n|GITHUB_PAT @@ -104,18 +103,6 @@ bad GitHub user|GITEA_TOKEN=abcdefgh12345\nGITHUB_USER=-bad-\n|GITHUB_USER EOF } -test_github_credentials_required_only_when_chosen() { - printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/e.env" - run_lib "" "parse_env_file \"$WORK/e.env\" CREDENTIAL_KEYS CREDENTIALS -validate_credentials -echo no-github-ok -ENV_FILE=\"$WORK/e.env\" -require_github_credentials" - assert_contains "Gitea-only .env is valid" "$OUT" "no-github-ok" - assert_status "GitHub credentials missing" 1 "$STATUS" - assert_contains "names the missing key" "$ERR" "GITHUB_PAT is missing" -} - test_validators() { local fn value expected while IFS='|' read -r fn value expected; do diff --git a/tests/test-credentials.sh b/tests/test-credentials.sh new file mode 100644 index 0000000..a98a642 --- /dev/null +++ b/tests/test-credentials.sh @@ -0,0 +1,305 @@ +#!/usr/bin/env bash +# test-credentials.sh - tests for the credentials that .env does not provide +# and for the .env file of the new project (MIL-005): they are asked without +# echo, the project .env is only written after a yes, owner-only and ignored +# by git, and no token appears anywhere else. Sourced by run-tests.sh. + +# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose + +readonly NL=$'\n' + +# credentials_input: the answers of a run with no .env at all and GitHub +# chosen: the Gitea token, the details, then the GitHub token and account. +credentials_input() { + printf '%s' "$FAKE_GITEA_TOKEN$NL$ANSWERS_GITHUB$FAKE_GITHUB_PAT${NL}octo-user$NL" +} + +run_dry_cred() { + run_cli "$1" --config "$WORK/config.env" --env "$WORK/.env" +} + +run_apply_cred() { + run_cli "$1" --apply --config "$WORK/config.env" --env "$WORK/.env" +} + +# without_env: remove the .env of the fixtures. +without_env() { + rm -f -- "$WORK/.env" +} + +# env_mode FILE: the permission bits, empty where the platform has none. +env_mode() { + case "$(uname -s 2>/dev/null || true)" in + MINGW* | MSYS* | CYGWIN*) ;; + *) stat -c '%a' -- "$1" 2>/dev/null || stat -f '%Lp' -- "$1" 2>/dev/null || true ;; + esac +} + +# ------------------------------------------------------------ prompt_secret + +test_prompt_secret_asks_again_and_never_repeats_the_answer() { + run_lib $'short\n\nlongenoughtoken1\n' \ + 'prompt_secret "Gitea access token" is_valid_token "needs 8 characters"; echo "[$REPLY]"' + assert_status "valid answer found" 0 "$STATUS" + assert_eq "valid answer returned" "[longenoughtoken1]" "$OUT" + assert_contains "told why" "$ERR" "invalid Gitea access token: needs 8 characters" + assert_not_contains "refused answer not repeated" "$ERR" "short" + assert_contains "says the input is hidden" "$ERR" "(input is hidden)" +} + +test_prompt_secret_stops_when_input_ends() { + run_lib "" 'prompt_secret "Gitea access token" is_valid_token "x" "$WORK/e.env" + run_lib "$FAKE_GITEA_TOKEN$NL" \ + 'parse_env_file "'"$WORK"'/e.env" CREDENTIAL_KEYS CREDENTIALS +validate_credentials +collect_credentials GITEA_TOKEN +echo asked-ok' + assert_status "empty value tolerated" 0 "$STATUS" + assert_contains "asked instead" "$ERR" "Gitea access token" +} + +test_validate_credentials_no_longer_requires_any() { + printf '# nothing\n' >"$WORK/e.env" + run_lib "" 'parse_env_file "'"$WORK"'/e.env" CREDENTIAL_KEYS CREDENTIALS +validate_credentials +echo fine' + assert_status "no credential required" 0 "$STATUS" + assert_eq "no error" "fine" "$OUT" + printf 'GITEA_TOKEN=short\n' >"$WORK/e.env" + run_lib "" 'parse_env_file "'"$WORK"'/e.env" CREDENTIAL_KEYS CREDENTIALS +validate_credentials' + assert_status "a provided bad token is still refused" 1 "$STATUS" + assert_contains "named" "$ERR" "GITEA_TOKEN" +} + +# --------------------------------------------------------------- the run + +test_env_is_optional_and_the_token_is_asked_without_echo() { + setup_hosts + without_env + run_dry_cred "$FAKE_GITEA_TOKEN$NL$ANSWERS_GITEA_ONLY" + assert_status "dry run without .env" 0 "$STATUS" + assert_contains "token asked" "$ERR" "Gitea access token (input is hidden)" + assert_contains "plan printed" "$OUT" "Plan:" + assert_not_contains "token not shown" "$OUT$ERR" "$FAKE_GITEA_TOKEN" + assert_not_contains "only reads" "$(calls)" "POST" +} + +test_a_provided_credential_is_not_asked() { + setup_hosts + run_dry_cred "$ANSWERS_GITHUB" + assert_status "all provided" 0 "$STATUS" + assert_not_contains "no token prompt" "$ERR" "(input is hidden)" + assert_not_contains "no account prompt" "$ERR" "GitHub account name" +} + +test_github_credentials_are_asked_only_when_github_is_chosen() { + setup_hosts + printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env" + run_dry_cred "$ANSWERS_GITEA_ONLY" + assert_status "Gitea only" 0 "$STATUS" + assert_not_contains "no GitHub token asked" "$ERR" "GitHub personal access token" + assert_not_contains "no GitHub account asked" "$ERR" "GitHub account name" + run_dry_cred "$ANSWERS_GITHUB$FAKE_GITHUB_PAT${NL}octo-user$NL" + assert_status "GitHub chosen" 0 "$STATUS" + assert_contains "GitHub token asked" "$ERR" "GitHub personal access token (input is hidden)" + assert_contains "GitHub account asked" "$ERR" "GitHub account name" + assert_not_contains "token not shown" "$OUT$ERR" "$FAKE_GITHUB_PAT" +} + +test_an_invalid_asked_value_is_asked_again_and_never_shown() { + setup_hosts + without_env + run_dry_cred "bad token${NL}$FAKE_GITEA_TOKEN$NL$ANSWERS_GITEA_ONLY" + assert_status "second answer accepted" 0 "$STATUS" + assert_contains "told it is invalid" "$ERR" "invalid Gitea access token" + assert_not_contains "refused value not shown" "$ERR$OUT" "bad token" +} + +test_input_that_ends_stops_before_any_request() { + setup_hosts + without_env + run_dry_cred "" + assert_status "stopped" 1 "$STATUS" + assert_contains "key named" "$ERR" "no input available for 'Gitea access token'" + assert_eq "no request made" "" "$(calls)" + assert_not_contains "no creation report" "$OUT" "This is what exists now" +} + +test_asked_tokens_do_not_leak_under_bash_x() { + setup_hosts + without_env + STATUS=0 + PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" -x "$SCRIPT" \ + --config "$WORK/config.env" --env "$WORK/.env" <<<"$(credentials_input)" \ + >"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$? + assert_status "run under bash -x" 0 "$STATUS" + assert_not_contains "no Gitea token in the trace" "$(cat "$WORK/err.txt" "$WORK/out.txt")" "$FAKE_GITEA_TOKEN" + assert_not_contains "no GitHub token in the trace" "$(cat "$WORK/err.txt" "$WORK/out.txt")" "$FAKE_GITHUB_PAT" +} + +# ---------------------------------------------------------- the project .env + +test_the_dry_run_names_the_env_step_and_writes_nothing() { + setup_hosts + run_dry_cred "$ANSWERS_GITHUB" + assert_contains "plan line" "$OUT" "Project .env : you are asked whether to create it (GITEA_TOKEN GITHUB_PAT GITHUB_USER)" + assert_file_missing "no .env" "$WORK/my-app/.env" +} + +test_the_project_env_is_not_created_without_a_yes() { + setup_hosts + run_apply_cred "${ANSWERS_GITHUB}y$NL$NL" + assert_status "run" 0 "$STATUS" + assert_file_missing "default is no" "$WORK/my-app/.env" + assert_contains "reported" "$OUT" "Project .env : skipped (you declined)" + setup_hosts + run_apply_cred "${ANSWERS_GITHUB}y${NL}n$NL" + assert_file_missing "explicit no" "$WORK/my-app/.env" +} + +test_the_project_env_holds_only_the_needed_keys_and_is_private() { + setup_hosts + run_apply_cred "${ANSWERS_GITHUB}y${NL}y$NL" + assert_status "run" 0 "$STATUS" + assert_file_exists ".env created" "$WORK/my-app/.env" + assert_eq "exactly the needed keys" "GITEA_TOKEN=$FAKE_GITEA_TOKEN${NL}GITHUB_PAT=$FAKE_GITHUB_PAT${NL}GITHUB_USER=octo-user" "$(cat "$WORK/my-app/.env")" + assert_eq "owner-only" "$(env_mode "$WORK/my-app/.env")" "$([[ -z "$(env_mode "$WORK/my-app/.env")" ]] || echo 600)" + assert_contains "reported with the keys, not the values" "$OUT" "Project .env : created (GITEA_TOKEN GITHUB_PAT GITHUB_USER;" + assert_not_contains "no token in the output" "$OUT$ERR" "$FAKE_GITEA_TOKEN" + assert_not_contains "no GitHub token in the output" "$OUT$ERR" "$FAKE_GITHUB_PAT" + # Git ignores it without any tracked file changing. + check + if ! git -C "$WORK/my-app" check-ignore -q -- .env; then + fail ".env is not ignored by git" + fi + assert_not_contains "not listed by git status" "$(git -C "$WORK/my-app" status --porcelain)" ".env" + assert_contains "excluded locally" "$(cat "$WORK/my-app/.git/info/exclude")" ".env" + check + if [[ -e $WORK/my-app/.gitignore ]]; then + fail "a .gitignore was written; only .git/info/exclude may change" + fi + # No temporary file is left behind. + assert_eq "no leftover file" "" "$(find "$WORK/my-app" -maxdepth 1 -name '.env.*' -print)" +} + +test_the_project_env_without_github_holds_only_the_gitea_token() { + setup_hosts + run_apply_cred "${ANSWERS_GITEA_ONLY}y${NL}y$NL" + assert_status "run" 0 "$STATUS" + assert_eq "one key" "GITEA_TOKEN=$FAKE_GITEA_TOKEN" "$(cat "$WORK/my-app/.env")" + assert_contains "reported" "$OUT" "Project .env : created (GITEA_TOKEN;" +} + +test_asked_credentials_are_what_the_project_env_holds() { + setup_hosts + without_env + run_apply_cred "$(credentials_input)${NL}y${NL}y$NL" + assert_status "run" 0 "$STATUS" + assert_eq "the asked values" "GITEA_TOKEN=$FAKE_GITEA_TOKEN${NL}GITHUB_PAT=$FAKE_GITHUB_PAT${NL}GITHUB_USER=octo-user" "$(cat "$WORK/my-app/.env")" + assert_not_contains "no token in the output" "$OUT$ERR" "$FAKE_GITEA_TOKEN" +} + +test_no_token_is_in_any_file_but_the_project_env() { + setup_hosts + without_env + run_apply_cred "$(credentials_input)${NL}y${NL}y$NL" + assert_status "run" 0 "$STATUS" + local hits + # The new project (with its .git folder), the script's temporary directory + # and its output; the stub curl's own request log is not part of the product. + hits="$(grep -rIl -F -e "$FAKE_GITEA_TOKEN" -e "$FAKE_GITHUB_PAT" "$WORK/my-app" "$WORK/tmp" "$WORK/out.txt" "$WORK/err.txt" 2>/dev/null | + grep -v -e '/my-app/\.env$' || true)" + assert_eq "only the project .env holds a token" "" "$hits" +} + +test_an_existing_env_is_kept_unless_the_maintainer_says_replace() { + setup_hosts + mkdir -p "$WORK/my-app" + printf 'keep\n' >"$WORK/my-app/.env" + run_apply_cred "${ANSWERS_GITHUB}y${NL}y${NL}y${NL}n$NL" + assert_status "declined replacing" 0 "$STATUS" + assert_eq "unchanged" "keep" "$(cat "$WORK/my-app/.env")" + assert_contains "reported" "$OUT" "Project .env : kept (the existing .env was left as it was)" + remove_workdir + new_workdir + setup_hosts + mkdir -p "$WORK/my-app" + printf 'keep\n' >"$WORK/my-app/.env" + run_apply_cred "${ANSWERS_GITHUB}y${NL}y${NL}y${NL}y$NL" + assert_status "agreed to replace" 0 "$STATUS" + assert_contains "replaced" "$(cat "$WORK/my-app/.env")" "GITEA_TOKEN=$FAKE_GITEA_TOKEN" + assert_eq "private after replacing" "$(env_mode "$WORK/my-app/.env")" "$([[ -z "$(env_mode "$WORK/my-app/.env")" ]] || echo 600)" +} + +test_a_tracked_env_is_never_written() { + mkdir -p "$WORK/p" + git -C "$WORK/p" init -q + printf 'tracked\n' >"$WORK/p/.env" + git -C "$WORK/p" add .env + git -C "$WORK/p" -c user.name=t -c user.email=t@example.test commit -q -m init + run_lib "y$NL" \ + 'PROJECT[directory]="'"$WORK"'/p"; PROJECT[has_github]=0 +CREDENTIALS[GITEA_TOKEN]=giteaFAKEtoken1234567890 +init_steps +create_env_file +echo "${STEP_STATUS["Project .env"]}"' + assert_status "run" 0 "$STATUS" + assert_eq "skipped" "skipped" "$OUT" + assert_eq "unchanged" "tracked" "$(cat "$WORK/p/.env")" + assert_contains "says why" "$(cat "$WORK/err.txt" "$WORK/out.txt")" "skipped" +} + +test_exclude_is_added_once_and_keeps_the_existing_entries() { + mkdir -p "$WORK/p" + git -C "$WORK/p" init -q + printf 'build/' >"$WORK/p/.git/info/exclude" # no trailing newline + run_lib "" \ + 'exclude_env_file "'"$WORK"'/p" +exclude_env_file "'"$WORK"'/p" +echo done' + assert_status "run" 0 "$STATUS" + local exclude + exclude="$(cat "$WORK/p/.git/info/exclude")" + assert_contains "old entry kept" "$exclude" "build/" + assert_eq "entry added once" "1" "$(grep -c '^\.env$' "$WORK/p/.git/info/exclude")" + assert_eq "old entry still on its own line" "1" "$(grep -c '^build/$' "$WORK/p/.git/info/exclude")" +} + +test_exclude_does_nothing_when_env_is_already_ignored() { + mkdir -p "$WORK/p" + git -C "$WORK/p" init -q + printf '.env\n' >"$WORK/p/.gitignore" + run_lib "" 'exclude_env_file "'"$WORK"'/p"; echo done' + assert_status "run" 0 "$STATUS" + assert_eq "exclude file untouched" "0" "$(grep -c '^\.env$' "$WORK/p/.git/info/exclude" || true)" +} diff --git a/tests/test-security.sh b/tests/test-security.sh index 9ad23a0..c8a32d6 100644 --- a/tests/test-security.sh +++ b/tests/test-security.sh @@ -47,12 +47,12 @@ test_full_run_without_github() { assert_contains "plan says GitHub is not used" "$OUT" "GitHub repository : not used" } -test_github_chosen_without_credentials_fails() { +test_github_chosen_without_credentials_stops_when_input_ends() { write_fixtures printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env" run_cli "$ANSWERS_GITHUB" --config "$WORK/config.env" --env "$WORK/.env" - assert_status "missing GitHub credentials" 1 "$STATUS" - assert_contains "names the key" "$ERR" "GITHUB_PAT is missing" + assert_status "missing GitHub credentials, no answer" 1 "$STATUS" + assert_contains "names the credential" "$ERR" "no input available for 'GitHub personal access token'" assert_not_contains "no token in the error" "$OUT$ERR" "$FAKE_GITEA_TOKEN" assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)" }