diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c760934..579aabb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,6 +48,8 @@ jobs: ./git2cpp -v - name: Run tests + env: + GIT2CPP_TEST_PRIVATE_TOKEN: ${{ secrets.GIT2CPP_TEST_PRIVATE_TOKEN }} run: | pytest -v @@ -76,6 +78,8 @@ jobs: run: cmake --build . --parallel 8 - name: Run tests + env: + GIT2CPP_TEST_PRIVATE_TOKEN: ${{ secrets.GIT2CPP_TEST_PRIVATE_TOKEN }} run: | pytest -v diff --git a/CMakeLists.txt b/CMakeLists.txt index 30c36bc..086c956 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,10 +88,12 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/utils/ansi_code.hpp ${GIT2CPP_SOURCE_DIR}/utils/common.cpp ${GIT2CPP_SOURCE_DIR}/utils/common.hpp + ${GIT2CPP_SOURCE_DIR}/utils/credentials.cpp + ${GIT2CPP_SOURCE_DIR}/utils/credentials.hpp ${GIT2CPP_SOURCE_DIR}/utils/git_exception.cpp ${GIT2CPP_SOURCE_DIR}/utils/git_exception.hpp - ${GIT2CPP_SOURCE_DIR}/utils/output.cpp - ${GIT2CPP_SOURCE_DIR}/utils/output.hpp + ${GIT2CPP_SOURCE_DIR}/utils/input_output.cpp + ${GIT2CPP_SOURCE_DIR}/utils/input_output.hpp ${GIT2CPP_SOURCE_DIR}/utils/progress.cpp ${GIT2CPP_SOURCE_DIR}/utils/progress.hpp ${GIT2CPP_SOURCE_DIR}/utils/terminal_pager.cpp diff --git a/src/subcommand/clone_subcommand.cpp b/src/subcommand/clone_subcommand.cpp index acc6f14..926628e 100644 --- a/src/subcommand/clone_subcommand.cpp +++ b/src/subcommand/clone_subcommand.cpp @@ -1,7 +1,8 @@ #include #include "../subcommand/clone_subcommand.hpp" -#include "../utils/output.hpp" +#include "../utils/credentials.hpp" +#include "../utils/input_output.hpp" #include "../utils/progress.hpp" #include "../wrapper/repository_wrapper.hpp" @@ -42,6 +43,7 @@ void clone_subcommand::run() checkout_opts.progress_cb = checkout_progress; checkout_opts.progress_payload = &pd; clone_opts.checkout_opts = checkout_opts; + clone_opts.fetch_opts.callbacks.credentials = user_credentials; clone_opts.fetch_opts.callbacks.sideband_progress = sideband_progress; clone_opts.fetch_opts.callbacks.transfer_progress = fetch_progress; clone_opts.fetch_opts.callbacks.payload = &pd; diff --git a/src/subcommand/commit_subcommand.cpp b/src/subcommand/commit_subcommand.cpp index bca7f39..ceb4038 100644 --- a/src/subcommand/commit_subcommand.cpp +++ b/src/subcommand/commit_subcommand.cpp @@ -2,6 +2,7 @@ #include #include "../subcommand/commit_subcommand.hpp" +#include "../utils/input_output.hpp" #include "../wrapper/index_wrapper.hpp" #include "../wrapper/repository_wrapper.hpp" @@ -24,8 +25,7 @@ void commit_subcommand::run() if (m_commit_message.empty()) { - std::cout << "Please enter a commit message:" << std::endl; - std::getline(std::cin, m_commit_message); + m_commit_message = prompt_input("Please enter a commit message:\n"); if (m_commit_message.empty()) { throw std::runtime_error("Aborting, no commit message specified."); diff --git a/src/subcommand/fetch_subcommand.cpp b/src/subcommand/fetch_subcommand.cpp index 0662970..77d0bc3 100644 --- a/src/subcommand/fetch_subcommand.cpp +++ b/src/subcommand/fetch_subcommand.cpp @@ -3,7 +3,8 @@ #include #include "../subcommand/fetch_subcommand.hpp" -#include "../utils/output.hpp" +#include "../utils/credentials.hpp" +#include "../utils/input_output.hpp" #include "../utils/progress.hpp" #include "../wrapper/repository_wrapper.hpp" @@ -34,6 +35,7 @@ void fetch_subcommand::run() git_indexer_progress pd = {0}; git_fetch_options fetch_opts = GIT_FETCH_OPTIONS_INIT; + fetch_opts.callbacks.credentials = user_credentials; fetch_opts.callbacks.sideband_progress = sideband_progress; fetch_opts.callbacks.transfer_progress = fetch_progress; fetch_opts.callbacks.payload = &pd; diff --git a/src/subcommand/push_subcommand.cpp b/src/subcommand/push_subcommand.cpp index be04267..56b4346 100644 --- a/src/subcommand/push_subcommand.cpp +++ b/src/subcommand/push_subcommand.cpp @@ -3,6 +3,7 @@ #include #include "../subcommand/push_subcommand.hpp" +#include "../utils/credentials.hpp" #include "../utils/progress.hpp" #include "../wrapper/repository_wrapper.hpp" @@ -27,6 +28,7 @@ void push_subcommand::run() auto remote = repo.find_remote(remote_name); git_push_options push_opts = GIT_PUSH_OPTIONS_INIT; + push_opts.callbacks.credentials = user_credentials; push_opts.callbacks.push_transfer_progress = push_transfer_progress; push_opts.callbacks.push_update_reference = push_update_reference; diff --git a/src/utils/credentials.cpp b/src/utils/credentials.cpp new file mode 100644 index 0000000..8992087 --- /dev/null +++ b/src/utils/credentials.cpp @@ -0,0 +1,39 @@ +#include +#include + +#include "credentials.hpp" +#include "input_output.hpp" + +// git_credential_acquire_cb +int user_credentials( + git_credential** out, + const char* url, + const char* username_from_url, + unsigned int allowed_types, + void* payload) +{ + // Check for cached credentials here, if desired. + // It might be necessary to make this function stateful to avoid repeating unnecessary checks. + + *out = nullptr; + + if (allowed_types & GIT_CREDENTIAL_USERPASS_PLAINTEXT) { + std::string username = username_from_url ? username_from_url : prompt_input("Username: "); + if (username.empty()) { + giterr_set_str(GIT_ERROR_HTTP, "No username specified"); + return GIT_EAUTH; + } + + std::string password = prompt_input("Password: ", false); + if (password.empty()) { + giterr_set_str(GIT_ERROR_HTTP, "No password specified"); + return GIT_EAUTH; + } + + // If successful, this will create and return a git_credential* in the out argument. + return git_credential_userpass_plaintext_new(out, username.c_str(), password.c_str()); + } + + giterr_set_str(GIT_ERROR_HTTP, "Unexpected credentials request"); + return GIT_ERROR; +} diff --git a/src/utils/credentials.hpp b/src/utils/credentials.hpp new file mode 100644 index 0000000..452195b --- /dev/null +++ b/src/utils/credentials.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include + +// Libgit2 callback of type git_credential_acquire_cb to obtain user credentials +// (username and password) to authenticate remote https access. +int user_credentials( + git_credential** out, + const char* url, + const char* username_from_url, + unsigned int allowed_types, + void* payload +); diff --git a/src/utils/input_output.cpp b/src/utils/input_output.cpp new file mode 100644 index 0000000..3225f4d --- /dev/null +++ b/src/utils/input_output.cpp @@ -0,0 +1,74 @@ +#include "ansi_code.hpp" +#include "input_output.hpp" + +// OS-specific libraries. +#include + +cursor_hider::cursor_hider(bool hide /* = true */) + : m_hide(hide) +{ + std::cout << (m_hide ? ansi_code::hide_cursor : ansi_code::show_cursor); +} + +cursor_hider::~cursor_hider() +{ + std::cout << (m_hide ? ansi_code::show_cursor : ansi_code::hide_cursor); +} + + +alternative_buffer::alternative_buffer() +{ + tcgetattr(fileno(stdin), &m_previous_termios); + auto new_termios = m_previous_termios; + // Disable canonical mode (buffered I/O) and echo from stdin to stdout. + new_termios.c_lflag &= (~ICANON & ~ECHO); + tcsetattr(fileno(stdin), TCSANOW, &new_termios); + + std::cout << ansi_code::enable_alternative_buffer; +} + +alternative_buffer::~alternative_buffer() +{ + std::cout << ansi_code::disable_alternative_buffer; + + // Restore previous termios settings. + tcsetattr(fileno(stdin), TCSANOW, &m_previous_termios); +} + +echo_control::echo_control(bool echo) + : m_echo(echo) +{ + if (!m_echo) { + tcgetattr(fileno(stdin), &m_previous_termios); + auto new_termios = m_previous_termios; + new_termios.c_lflag &= ~ECHO; + tcsetattr(fileno(stdin), TCSANOW, &new_termios); + } +} + +echo_control::~echo_control() +{ + if (!m_echo) { + // Restore previous termios settings. + tcsetattr(fileno(stdin), TCSANOW, &m_previous_termios); + } +} + + +std::string prompt_input(const std::string_view prompt, bool echo /* = true */) +{ + std::cout << prompt; + + echo_control ec(echo); + std::string input; + + cursor_hider ch(false); // Re-enable cursor if currently hidden. + std::getline(std::cin, input); + + if (!echo) { + std::cout << std::endl; + } + + // Maybe sanitise input, removing escape codes? + return input; +} diff --git a/src/utils/input_output.hpp b/src/utils/input_output.hpp new file mode 100644 index 0000000..fabc27d --- /dev/null +++ b/src/utils/input_output.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include "common.hpp" + +// OS-specific libraries. +#include + +// Scope object to hide the cursor. This avoids +// cursor twinkling when rewritting the same line +// too frequently. +// If you are within a cursor_hider context you can +// reenable the cursor using cursor_hider(false). +class cursor_hider : noncopyable_nonmovable +{ +public: + cursor_hider(bool hide = true); + + ~cursor_hider(); + +private: + bool m_hide; +}; + +// Scope object to use alternative output buffer for +// fullscreen interactive terminal input/output. +class alternative_buffer : noncopyable_nonmovable +{ +public: + alternative_buffer(); + + ~alternative_buffer(); + +private: + struct termios m_previous_termios; +}; + +// Scope object to control echo of stdin to stdout. +// This should be disabled when entering passwords for example. +class echo_control : noncopyable_nonmovable +{ +public: + echo_control(bool echo); + + ~echo_control(); + +private: + bool m_echo; + struct termios m_previous_termios; +}; + +// Display a prompt on stdout and return newline-terminated input received on +// stdin from the user. The `echo` argument controls whether stdin is echoed +// to stdout, use `false` for passwords. +std::string prompt_input(const std::string_view prompt, bool echo = true); diff --git a/src/utils/output.cpp b/src/utils/output.cpp deleted file mode 100644 index 71584b1..0000000 --- a/src/utils/output.cpp +++ /dev/null @@ -1,23 +0,0 @@ -#include "output.hpp" - -// OS-specific libraries. -#include - -alternative_buffer::alternative_buffer() -{ - tcgetattr(fileno(stdin), &m_previous_termios); - auto new_termios = m_previous_termios; - // Disable canonical mode (buffered I/O) and echo from stdin to stdout. - new_termios.c_lflag &= (~ICANON & ~ECHO); - tcsetattr(fileno(stdin), TCSANOW, &new_termios); - - std::cout << ansi_code::enable_alternative_buffer; -} - -alternative_buffer::~alternative_buffer() -{ - std::cout << ansi_code::disable_alternative_buffer; - - // Restore previous termios settings. - tcsetattr(fileno(stdin), TCSANOW, &m_previous_termios); -} diff --git a/src/utils/output.hpp b/src/utils/output.hpp deleted file mode 100644 index 803c20d..0000000 --- a/src/utils/output.hpp +++ /dev/null @@ -1,37 +0,0 @@ -#pragma once - -#include -#include "ansi_code.hpp" -#include "common.hpp" - -// OS-specific libraries. -#include - -// Scope object to hide the cursor. This avoids -// cursor twinkling when rewritting the same line -// too frequently. -struct cursor_hider : noncopyable_nonmovable -{ - cursor_hider() - { - std::cout << ansi_code::hide_cursor; - } - - ~cursor_hider() - { - std::cout << ansi_code::show_cursor; - } -}; - -// Scope object to use alternative output buffer for -// fullscreen interactive terminal input/output. -class alternative_buffer : noncopyable_nonmovable -{ -public: - alternative_buffer(); - - ~alternative_buffer(); - -private: - struct termios m_previous_termios; -}; diff --git a/src/utils/terminal_pager.cpp b/src/utils/terminal_pager.cpp index 3bec415..7b5dcc7 100644 --- a/src/utils/terminal_pager.cpp +++ b/src/utils/terminal_pager.cpp @@ -10,7 +10,7 @@ #include #include "ansi_code.hpp" -#include "output.hpp" +#include "input_output.hpp" #include "terminal_pager.hpp" #include "common.hpp" diff --git a/test/conftest.py b/test/conftest.py index ea11f67..48b8852 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -46,3 +46,21 @@ def commit_env_config(monkeypatch): subprocess.run(["export", f"{key}='{value}'"], check=True) else: monkeypatch.setenv(key, value) + + +@pytest.fixture(scope="session") +def private_test_repo(): + # Fixture containing everything needed to access private github repo. + # GIT2CPP_TEST_PRIVATE_TOKEN is the fine-grained Personal Access Token for private test repo. + # If this is not available as an environment variable, tests that use this fixture are skipped. + token = os.getenv('GIT2CPP_TEST_PRIVATE_TOKEN') + if token is None: + pytest.skip("No token for private test repo GIT2CPP_TEST_PRIVATE_TOKEN") + repo_name = "git2cpp-test-private" + org_name = "QuantStack" + return { + "repo_name": repo_name, + "http_url": f"http://github.com/{org_name}/{repo_name}", + "https_url": f"https://github.com/{org_name}/{repo_name}", + "token": token + } diff --git a/test/test_clone.py b/test/test_clone.py index ff9ccec..63cb405 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -1,10 +1,11 @@ +import pytest import subprocess -url = "https://github.com/xtensor-stack/xtl.git" +xtl_url = "https://github.com/xtensor-stack/xtl.git" def test_clone(git2cpp_path, tmp_path, run_in_tmp_path): - clone_cmd = [git2cpp_path, "clone", url] + clone_cmd = [git2cpp_path, "clone", xtl_url] p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_clone.returncode == 0 @@ -13,7 +14,7 @@ def test_clone(git2cpp_path, tmp_path, run_in_tmp_path): def test_clone_is_bare(git2cpp_path, tmp_path, run_in_tmp_path): - clone_cmd = [git2cpp_path, "clone", "--bare", url] + clone_cmd = [git2cpp_path, "clone", "--bare", xtl_url] p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_clone.returncode == 0 @@ -31,7 +32,7 @@ def test_clone_is_bare(git2cpp_path, tmp_path, run_in_tmp_path): def test_clone_shallow(git2cpp_path, tmp_path, run_in_tmp_path): - clone_cmd = [git2cpp_path, "clone", "--depth", "1", url] + clone_cmd = [git2cpp_path, "clone", "--depth", "1", xtl_url] p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) assert p_clone.returncode == 0 assert (tmp_path / "xtl").exists() @@ -42,3 +43,84 @@ def test_clone_shallow(git2cpp_path, tmp_path, run_in_tmp_path): p_log = subprocess.run(cmd_log, capture_output=True, cwd=xtl_path, text=True) assert p_log.returncode == 0 assert p_log.stdout.count("Author") == 1 + + +@pytest.mark.parametrize("protocol", ["http", "https"]) +def test_clone_private_repo(git2cpp_path, tmp_path, run_in_tmp_path, private_test_repo, protocol): + # Succeeds with correct credentials. + # Note that http succeeds by redirecting to https. + username = "abc" # Can be any non-empty string. + password = private_test_repo['token'] + input = f"{username}\n{password}" + repo_path = tmp_path / private_test_repo['repo_name'] + url = private_test_repo['https_url' if protocol == 'https' else 'http_url'] + + clone_cmd = [git2cpp_path, "clone", url] + p_clone = subprocess.run(clone_cmd, capture_output=True, text=True, input=input) + assert p_clone.returncode == 0 + assert repo_path.exists() + # Single request for username and password. + assert p_clone.stdout.count("Username:") == 1 + assert p_clone.stdout.count("Password:") == 1 + + status_cmd = [git2cpp_path, "status"] + p_status = subprocess.run(status_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_status.returncode == 0 + assert "On branch main" in p_status.stdout + assert "Your branch is up to date with 'origin/main'" in p_status.stdout + + +def test_clone_private_repo_fails_then_succeeds( + git2cpp_path, tmp_path, run_in_tmp_path, private_test_repo +): + # Fails with wrong credentials, then succeeds with correct ones. + username = "xyz" # Can be any non-empty string. + password = private_test_repo['token'] + input = "\n".join(["wrong1", "wrong2", username, password]) + repo_path = tmp_path / private_test_repo['repo_name'] + + clone_cmd = [git2cpp_path, "clone", private_test_repo['https_url']] + p_clone = subprocess.run(clone_cmd, capture_output=True, text=True, input=input) + assert p_clone.returncode == 0 + assert repo_path.exists() + # Two requests for username and password. + assert p_clone.stdout.count("Username:") == 2 + assert p_clone.stdout.count("Password:") == 2 + + status_cmd = [git2cpp_path, "status"] + p_status = subprocess.run(status_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_status.returncode == 0 + assert "On branch main" in p_status.stdout + assert "Your branch is up to date with 'origin/main'" in p_status.stdout + + +def test_clone_private_repo_fails_on_no_username( + git2cpp_path, tmp_path, run_in_tmp_path, private_test_repo +): + input = "" + repo_path = tmp_path / private_test_repo['repo_name'] + + clone_cmd = [git2cpp_path, "clone", private_test_repo['https_url']] + p_clone = subprocess.run(clone_cmd, capture_output=True, text=True, input=input) + + assert p_clone.returncode != 0 + assert "No username specified" in p_clone.stderr + assert not repo_path.exists() + assert p_clone.stdout.count("Username:") == 1 + assert p_clone.stdout.count("Password:") == 0 + + +def test_clone_private_repo_fails_on_no_password( + git2cpp_path, tmp_path, run_in_tmp_path, private_test_repo +): + input = "username\n" # Note no password after the \n + repo_path = tmp_path / private_test_repo['repo_name'] + + clone_cmd = [git2cpp_path, "clone", private_test_repo['https_url']] + p_clone = subprocess.run(clone_cmd, capture_output=True, text=True, input=input) + + assert p_clone.returncode != 0 + assert "No password specified" in p_clone.stderr + assert not repo_path.exists() + assert p_clone.stdout.count("Username:") == 1 + assert p_clone.stdout.count("Password:") == 1 diff --git a/test/test_fetch.py b/test/test_fetch.py new file mode 100644 index 0000000..6cfb9a5 --- /dev/null +++ b/test/test_fetch.py @@ -0,0 +1,29 @@ +import pytest +import subprocess + + +@pytest.mark.parametrize("protocol", ["http", "https"]) +def test_fetch_private_repo(git2cpp_path, tmp_path, run_in_tmp_path, private_test_repo, protocol): + # Note that http succeeds by redirecting to https. + init_cmd = [git2cpp_path, "init", "."] + p_init = subprocess.run(init_cmd, capture_output=True, text=True) + assert p_init.returncode == 0 + assert (tmp_path / ".git").exists() + + url = private_test_repo['https_url' if protocol == 'https' else 'http_url'] + remote_cmd = [git2cpp_path, "remote", "add", "origin", url] + p_remote = subprocess.run(remote_cmd, capture_output=True, text=True) + assert p_remote.returncode == 0 + + # First fetch with wrong password which fails, then correct password which succeeds. + username = "abc" # Can be any non-empty string. + password = private_test_repo['token'] + input = f"{username}\nwrong_password\n{username}\n{password}" + fetch_cmd = [git2cpp_path, "fetch", "origin"] + p_fetch = subprocess.run(fetch_cmd, capture_output=True, text=True, input=input) + assert p_fetch.returncode == 0 + + branch_cmd = [git2cpp_path, "branch", "--all"] + p_branch = subprocess.run(branch_cmd, capture_output=True, text=True) + assert p_branch.returncode == 0 + assert "origin/main" in p_branch.stdout diff --git a/test/test_push.py b/test/test_push.py new file mode 100644 index 0000000..dd138e4 --- /dev/null +++ b/test/test_push.py @@ -0,0 +1,62 @@ +import subprocess +from uuid import uuid4 + + +def test_push_private_repo(git2cpp_path, tmp_path, run_in_tmp_path, private_test_repo): + # Unique branch name to avoid branch name collisions on remote repo. + branch_name = f"test-{uuid4()}" + + # Start of test follows test_clone_private_repo, then creates a new local branch and pushes + # that to the remote. + username = "abc" # Can be any non-empty string. + password = private_test_repo['token'] + input = f"{username}\n{password}" + repo_path = tmp_path / private_test_repo['repo_name'] + url = private_test_repo['https_url'] + + clone_cmd = [git2cpp_path, "clone", url] + p_clone = subprocess.run(clone_cmd, capture_output=True, text=True, input=input) + assert p_clone.returncode == 0 + assert repo_path.exists() + # Single request for username and password. + assert p_clone.stdout.count("Username:") == 1 + assert p_clone.stdout.count("Password:") == 1 + + status_cmd = [git2cpp_path, "status"] + p_status = subprocess.run(status_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_status.returncode == 0 + assert "On branch main" in p_status.stdout + assert "Your branch is up to date with 'origin/main'" in p_status.stdout + + checkout_cmd = [git2cpp_path, "checkout", "-b", branch_name] + p_checkout = subprocess.run(checkout_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_checkout.returncode == 0 + + p_status = subprocess.run(status_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_status.returncode == 0 + assert f"On branch {branch_name}" in p_status.stdout + + (repo_path / "new_file.txt").write_text("Some text") + add_cmd = [git2cpp_path, "add", "new_file.txt"] + p_add = subprocess.run(add_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_add.returncode == 0 + + commit_cmd = [git2cpp_path, "commit", "-m", "This is my commit message"] + p_commit = subprocess.run(commit_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_commit.returncode == 0 + + log_cmd = [git2cpp_path, "log", "-n1"] + p_log = subprocess.run(log_cmd, cwd=repo_path, capture_output=True, text=True) + assert p_log.returncode == 0 + assert p_log.stdout.count("Author:") == 1 + assert p_log.stdout.count("Date:") == 1 + assert p_log.stdout.count("This is my commit message") == 1 + + # push with incorrect credentials to check it fails, then with correct to check it works. + input = f"${username}\ndef\n{username}\n{password}" + push_cmd = [git2cpp_path, "push", "origin"] + p_push = subprocess.run(push_cmd, cwd=repo_path, capture_output=True, text=True, input=input) + assert p_push.returncode == 0 + assert p_push.stdout.count("Username:") == 2 + assert p_push.stdout.count("Password:") == 2 + assert "Pushed to origin" in p_push.stdout