diff --git a/CMakeLists.txt b/CMakeLists.txt index 35cde48..8070918 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,5 +32,6 @@ add_executable(kappa src/tools/format.cpp src/tools/doctor.cpp src/resolve/plan.cpp + src/fetch/fetch.cpp ) target_include_directories(kappa PRIVATE include) diff --git a/examples/foo.kap b/examples/foo.kap index b268465..8975e79 100644 --- a/examples/foo.kap +++ b/examples/foo.kap @@ -5,6 +5,7 @@ package "foo" { const version = "1.2.3" const source = "https://example.com/foo-${version}.tar.gz" + sha256 = "e127a709cba24c76de8936cb7083dd768f28cd37eb010492e2f19b71eb1294e4" license = "MIT" provides = ["libfoo.so.1", "foo"] diff --git a/include/kappa/dsl/ast.hpp b/include/kappa/dsl/ast.hpp index fdf62ef..b2dbd54 100644 --- a/include/kappa/dsl/ast.hpp +++ b/include/kappa/dsl/ast.hpp @@ -63,6 +63,9 @@ struct PackageDef { std::string name; std::string version; std::string source; + std::string sha256; + std::string sha512; + std::string md5; std::string license; std::vector depends; std::vector provides; diff --git a/src/dsl/parser.cpp b/src/dsl/parser.cpp index be236d5..c52b67b 100644 --- a/src/dsl/parser.cpp +++ b/src/dsl/parser.cpp @@ -123,6 +123,22 @@ void Parser::parse_body(PackageDef& pkg) { pkg.license = consume(TokenType::String).lexeme; break; + case TokenType::Ident: + if (current_.lexeme == "sha256") { + advance(); consume(TokenType::Equals); + pkg.sha256 = consume(TokenType::String).lexeme; + } else if (current_.lexeme == "sha512") { + advance(); consume(TokenType::Equals); + pkg.sha512 = consume(TokenType::String).lexeme; + } else if (current_.lexeme == "md5") { + advance(); consume(TokenType::Equals); + pkg.md5 = consume(TokenType::String).lexeme; + } else { + throw ParseError(current_.line, current_.col, + msg_unknown_decl(current_.lexeme)); + } + break; + case TokenType::KwProvides: consume(TokenType::KwProvides); consume(TokenType::Equals); diff --git a/src/fetch/fetch.cpp b/src/fetch/fetch.cpp new file mode 100644 index 0000000..e8ba1f7 --- /dev/null +++ b/src/fetch/fetch.cpp @@ -0,0 +1,199 @@ +#include "kappa/fetch/fetch.hpp" +#include "kappa/paths.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace kappa::fetch { + +namespace fs = std::filesystem; +using namespace std::string_view_literals; + +static int exec_cmd(const std::vector& argv) { + if (argv.empty()) { return -1; } + std::vector cargs; + for (auto& a : argv) { cargs.push_back(const_cast(a.c_str())); } + cargs.push_back(nullptr); + + pid_t pid = fork(); + if (pid == 0) { + execvp(cargs[0], cargs.data()); + _exit(127); + } + if (pid < 0) { return -1; } + int status = 0; + waitpid(pid, &status, 0); + return WIFEXITED(status) ? WEXITSTATUS(status) : -1; +} + +static std::string exec_capture(const std::vector& argv) { + int pipefd[2]; + if (pipe(pipefd) != 0) { return ""; } + + pid_t pid = fork(); + if (pid == 0) { + close(pipefd[0]); + dup2(pipefd[1], STDOUT_FILENO); + close(pipefd[1]); + + std::vector cargs; + for (auto& a : argv) { cargs.push_back(const_cast(a.c_str())); } + cargs.push_back(nullptr); + execvp(cargs[0], cargs.data()); + _exit(127); + } + + if (pid < 0) { close(pipefd[0]); close(pipefd[1]); return ""; } + + close(pipefd[1]); + std::array buf; + std::string result; + ssize_t n; + while ((n = read(pipefd[0], buf.data(), buf.size() - 1)) > 0) { + buf[static_cast(n)] = '\0'; + result += buf.data(); + } + close(pipefd[0]); + waitpid(pid, nullptr, 0); + + if (!result.empty() && result.back() == '\n') { result.pop_back(); } + return result; +} + +static std::string interpret_url(const dsl::PackageDef& pkg) { + auto url = pkg.source; + for (auto& [from, to] : { + std::pair{"${name}"sv, std::string_view(pkg.name)}, + std::pair{"${version}"sv, std::string_view(pkg.version)}}) { + std::size_t pos = 0; + while ((pos = url.find(from, pos)) != std::string::npos) { + url.replace(pos, from.size(), to); + pos += to.size(); + } + } + return url; +} + +static bool verify_hash(const fs::path& file, std::string_view algo, + std::string_view expected) { + if (expected.empty()) { return true; } + std::string tool; + if (algo == "sha256") { tool = "sha256sum"; } + else if (algo == "sha512") { tool = "sha512sum"; } + else if (algo == "md5") { tool = "md5sum"; } + else { return false; } + + auto output = exec_capture({tool, file.string()}); + if (output.empty()) { return false; } + + auto space = output.find(' '); + auto computed = (space != std::string::npos) + ? output.substr(0, space) + : output; + return computed == expected; +} + +FetchResult fetch(const dsl::PackageDef& pkg) { + FetchResult result; + + auto url = interpret_url(pkg); + if (url.empty()) { + result.error = "empty source URL"; + return result; + } + + auto ext_pos = url.rfind('.'); + auto ext = (ext_pos != std::string::npos) + ? url.substr(ext_pos + 1) + : std::string{}; + if (ext == "gz" || ext == "xz" || ext == "zst") { + auto prev = url.rfind('.', ext_pos - 1); + if (prev != std::string::npos) { + std::string compound{url.substr(prev + 1, + ext_pos - prev - 1)}; + ext = compound + "." + ext; + } + } + + auto dest_name = pkg.name + "-" + pkg.version; + auto dest_file = fs::path(paths::temp_dir) / (dest_name + "." + ext); + result.work_dir = fs::path(paths::temp_dir) / dest_name; + + if (ext == "git") { + int rc = exec_cmd({"git", "clone", url, result.work_dir.string()}); + if (rc != 0) { result.error = "git clone failed"; return result; } + } else { + fs::create_directories(paths::temp_dir); + int rc = exec_cmd({"curl", "-L", "-o", dest_file.string(), url}); + if (rc != 0) { result.error = "download failed"; return result; } + + bool verified = false; + for (auto algo : {"sha512", "sha256", "md5"}) { + std::string_view expected; + if (std::string_view(algo) == "sha512") { expected = pkg.sha512; } + else if (std::string_view(algo) == "sha256") { expected = pkg.sha256; } + else { expected = pkg.md5; } + + if (!expected.empty()) { + if (verify_hash(dest_file, algo, expected)) { + verified = true; + } else { + result.error = std::format("{} mismatch", algo); + return result; + } + } + } + if (!verified && !pkg.sha256.empty()) { + result.error = "hash verification failed"; + return result; + } + + auto extract_cmd = std::string{"tar"}; + if (ext == "zip") { + int rc2 = exec_cmd({"unzip", "-o", dest_file.string(), + "-d", result.work_dir.string()}); + if (rc2 != 0) { result.error = "extraction failed"; return result; } + } else { + int rc2 = exec_cmd({"tar", "xf", dest_file.string(), + "-C", paths::temp_dir.string(), + "--no-same-owner", "--no-same-permissions"}); + if (rc2 != 0) { result.error = "extraction failed"; return result; } + } + + fs::remove(dest_file); + } + + for (auto& patch : pkg.patches) { + auto patch_path = patch.url; + auto patch_file = patch_path; + if (patch_path.starts_with("http")) { + auto local = fs::path(paths::temp_dir) + / fs::path(patch_path).filename(); + int rc = exec_cmd({"curl", "-L", "-o", local.string(), + patch_path}); + if (rc != 0) { continue; } + patch_file = local.string(); + } + + if (!patch.sha256.empty()) { + if (!verify_hash(patch_file, "sha256", patch.sha256)) { + result.error = "patch hash mismatch: " + patch.url; + return result; + } + } + + exec_cmd({"patch", "-p" + std::to_string(patch.level), + "-d", result.work_dir.string(), "-i", patch_file}); + } + + return result; +} + +} // namespace kappa::fetch diff --git a/src/main.cpp b/src/main.cpp index 42b4e9f..2fb77d7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2,6 +2,7 @@ #include "kappa/config/eval.hpp" #include "kappa/dsl/parser.hpp" #include "kappa/dsl/system.hpp" +#include "kappa/fetch/fetch.hpp" #include "kappa/resolve/plan.hpp" #include "kappa/tools/doctor.hpp" #include "kappa/tools/format.hpp" @@ -30,6 +31,7 @@ Subcommands: format Format a .kap file to canonical style (printed to stdout) doctor Check a .kap file for issues and warnings resolve Resolve a build plan from a system config + fetch Download and verify source for a package Options: -h, --help Show this help message @@ -99,7 +101,8 @@ int main(int argc, char* argv[]) { || (subcommand == "validate") || (subcommand == "format") || (subcommand == "doctor") - || (subcommand == "resolve"); + || (subcommand == "resolve") + || (subcommand == "fetch"); if (!valid_subcommand) { std::cerr << "error: unknown subcommand '" << subcommand << "'\n\n"; @@ -107,9 +110,18 @@ int main(int argc, char* argv[]) { return 1; } - // Find the file argument (skip flags) const char* file_arg = nullptr; for (int i = 2; i < argc; ++i) { + if (std::string_view(argv[i]) == "-h" + || std::string_view(argv[i]) == "--help") { + print_usage(); + return 0; + } + if (std::string_view(argv[i]) == "-V" + || std::string_view(argv[i]) == "--version") { + std::cout << version << '\n'; + return 0; + } if (!is_flag(argv[i])) { file_arg = argv[i]; break; @@ -287,5 +299,21 @@ int main(int argc, char* argv[]) { } } + if (subcommand == "fetch") { + try { + auto pkg = dsl::parse(source); + auto result = fetch::fetch(pkg); + if (result.ok()) { + std::cout << "fetched to " << result.work_dir << "\n"; + return 0; + } + std::cerr << "fetch failed: " << result.error << "\n"; + return 1; + } catch (const std::runtime_error& e) { + handle_parse_error(file_arg, source, e); + return 1; + } + } + return 1; // unreachable }