diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..d67416c --- /dev/null +++ b/.clang-format @@ -0,0 +1,6 @@ +BasedOnStyle: LLVM +IndentWidth: 4 +ColumnLimit: 100 +SortIncludes: false +AllowShortIfStatementsOnASingleLine: Never +BreakBeforeBraces: Attach diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e4deb98 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.DS_Store +ld_preload +ld_preload.so +smoke-artifacts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9c81396 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.go] +indent_style = tab + +[*.{c,h}] +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..44869ad --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint-and-test: + name: Lint and Test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.26.0" + cache: true + + - name: Install clang-format + run: sudo apt-get update && sudo apt-get install -y clang-format + + - name: Install Go tools + run: make tools + + - name: Verify formatting + run: make fmt-check + + - name: Lint and unit test + run: make lint + + macos-sanity: + name: macOS Sanity + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Build and test + run: | + go test ./... + make build + + docker-smoke: + name: Docker Smoke + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Run Docker smoke harness + run: make docker-smoke + + - name: Upload smoke artifacts + if: failure() + uses: actions/upload-artifact@v7 + with: + name: smoke-artifacts-${{ github.run_id }} + path: smoke-artifacts/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.gitignore b/.gitignore index 7da29c3..f2e4819 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,4 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins ld_preload -ld_preload.h ld_preload.so - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file -go.work -go.work.sum - -# env file -.env - -# IDE files -.vscode/ \ No newline at end of file +smoke/fixture +smoke-artifacts/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..977d921 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,72 @@ +version: "2" + +run: + issues-exit-code: 1 + tests: true + +output: + formats: + text: + path: stdout + print-linter-name: true + print-issued-lines: true + +linters: + enable: + - bidichk + - bodyclose + - errcheck + - errorlint + - gocheckcompilerdirectives + - gocritic + - gosec + - govet + - ineffassign + - misspell + - nakedret + - nilerr + - revive + - staticcheck + - unconvert + - unused + - wastedassign + + settings: + errcheck: + check-type-assertions: false + check-blank: false + gocritic: + enabled-checks: + - deferInLoop + disabled-checks: + - ifElseChain + - elseif + nakedret: + max-func-lines: 30 + + exclusions: + generated: lax + rules: + - linters: + - revive + text: package-comments + - linters: + - revive + text: var-naming + - linters: + - revive + text: unused-parameter + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + new: false + +formatters: + enable: + - gofmt + settings: + gofmt: + simplify: true + exclusions: + generated: lax diff --git a/Dockerfile.smoke b/Dockerfile.smoke new file mode 100644 index 0000000..f9def14 --- /dev/null +++ b/Dockerfile.smoke @@ -0,0 +1,16 @@ +ARG GO_BASE_IMAGE=golang:1.26-bookworm + +FROM ${GO_BASE_IMAGE} + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential \ + coreutils \ + strace \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +COPY . . + +CMD ["./smoke/run.sh"] diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..86bf591 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,11 @@ +pre-commit: + commands: + fmt-check: + run: make fmt-check + lint: + run: make lint + +pre-push: + commands: + docker-smoke: + run: make docker-smoke diff --git a/makefile b/makefile index 778127f..15051ed 100644 --- a/makefile +++ b/makefile @@ -1,26 +1,121 @@ -# Define variables APP_NAME := ld_preload SRC := . SO_NAME := ld_preload.so +GOFMT_FILES := $(shell find . -name '*.go' -not -path './vendor/*') +C_FILES := $(shell find . \( -name '*.c' -o -name '*.h' \) -not -path './vendor/*') +GOLANGCI_LINT_VERSION := v2.4.0 +STATICCHECK_VERSION := 2025.1.1 +LEFTHOOK_VERSION := v1.12.4 +GOBIN ?= $(or $(shell go env GOBIN),$(firstword $(subst :, ,$(shell go env GOPATH)))/bin) +GOLANGCI_LINT := $(GOBIN)/golangci-lint +STATICCHECK := $(GOBIN)/staticcheck +LEFTHOOK := $(GOBIN)/lefthook +SMOKE_IMAGE_BOOKWORM := golang:1.26-bookworm +SMOKE_IMAGE_BULLSEYE := golang:1.24-bullseye +SMOKE_TAG_BOOKWORM := ld_preload-smoke:bookworm +SMOKE_TAG_BULLSEYE := ld_preload-smoke:bullseye + +.DEFAULT_GOAL := help + +.PHONY: help +help: + @printf "Usage:\n" + @printf " make %-12s %s\n" "build" "Build the executable and shared object" + @printf " make %-12s %s\n" "fmt" "Format Go and C sources" + @printf " make %-12s %s\n" "fmt-check" "Verify formatting without changing files" + @printf " make %-12s %s\n" "lint" "Run Go linters and tests" + @printf " make %-12s %s\n" "lint-fix" "Apply supported auto-fixes" + @printf " make %-12s %s\n" "docker-smoke" "Run Linux LD_PRELOAD smoke tests in both Docker images" + @printf " make %-12s %s\n" "docker-smoke-bookworm" "Run smoke tests in Debian bookworm" + @printf " make %-12s %s\n" "docker-smoke-bullseye" "Run smoke tests in Debian bullseye" + @printf " make %-12s %s\n" "hooks" "Install local git hooks with lefthook" + @printf " make %-12s %s\n" "tools" "Install Go-based developer tools" + @printf " make %-12s %s\n" "clean" "Remove build artifacts" -# Default target .PHONY: all all: build -# Build target: creates both an executable and a shared object .PHONY: build build: - # Build executable go build -o $(APP_NAME) $(SRC) - # Build shared object - go build -o $(SO_NAME) -buildmode=c-shared $(SRC) + @if [ "$$(uname -s)" = "Linux" ]; then \ + go build -o $(SO_NAME) -buildmode=c-shared $(SRC); \ + else \ + printf '%s\n' 'Skipping shared object build on non-Linux host.'; \ + fi -# Run target: implicitly calls build, then runs the application .PHONY: run run: build ./$(APP_NAME) -# Clean target: removes the executable and shared object +.PHONY: tools +tools: + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) + go install honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION) + go install github.com/evilmartians/lefthook@$(LEFTHOOK_VERSION) + +.PHONY: hooks +hooks: + $(LEFTHOOK) install + +.PHONY: fmt +fmt: + gofmt -w $(GOFMT_FILES) + @if command -v clang-format >/dev/null 2>&1; then \ + clang-format -i $(C_FILES); \ + else \ + printf '%s\n' 'clang-format not found; Go files formatted, C files skipped.'; \ + fi + +.PHONY: fmt-check +fmt-check: + @test -z "$$(gofmt -l $(GOFMT_FILES))" || \ + (printf '%s\n' 'Go files need formatting:'; gofmt -l $(GOFMT_FILES); exit 1) + @if command -v clang-format >/dev/null 2>&1; then \ + tmpdir=$$(mktemp -d); \ + failed=0; \ + for file in $(C_FILES); do \ + out="$$tmpdir/$$(basename "$$file")"; \ + clang-format "$$file" > "$$out"; \ + if ! cmp -s "$$file" "$$out"; then \ + printf '%s\n' "$$file"; \ + failed=1; \ + fi; \ + done; \ + rm -rf "$$tmpdir"; \ + test $$failed -eq 0 || (printf '%s\n' 'C files need formatting.'; exit 1); \ + else \ + printf '%s\n' 'clang-format not found; skipping C format check.'; \ + fi + +.PHONY: lint +lint: + $(GOLANGCI_LINT) run + $(STATICCHECK) ./... + go test ./... + +.PHONY: lint-fix +lint-fix: + $(GOLANGCI_LINT) run --fix + $(MAKE) fmt + +.PHONY: docker-smoke +docker-smoke: docker-smoke-bookworm docker-smoke-bullseye + +.PHONY: docker-smoke-bookworm +docker-smoke-bookworm: + chmod +x smoke/run.sh + docker build --build-arg GO_BASE_IMAGE=$(SMOKE_IMAGE_BOOKWORM) -f Dockerfile.smoke -t $(SMOKE_TAG_BOOKWORM) . + mkdir -p smoke-artifacts/bookworm + docker run --rm -e SMOKE_SUITE=bookworm -v "$(CURDIR)/smoke-artifacts:/workspace/smoke-artifacts" $(SMOKE_TAG_BOOKWORM) + +.PHONY: docker-smoke-bullseye +docker-smoke-bullseye: + chmod +x smoke/run.sh + docker build --build-arg GO_BASE_IMAGE=$(SMOKE_IMAGE_BULLSEYE) -f Dockerfile.smoke -t $(SMOKE_TAG_BULLSEYE) . + mkdir -p smoke-artifacts/bullseye + docker run --rm -e SMOKE_SUITE=bullseye -v "$(CURDIR)/smoke-artifacts:/workspace/smoke-artifacts" $(SMOKE_TAG_BULLSEYE) + .PHONY: clean clean: - rm -f $(APP_NAME) $(SO_NAME) + rm -f $(APP_NAME) $(SO_NAME) ld_preload.h diff --git a/rtld.c b/rtld_linux.c similarity index 83% rename from rtld.c rename to rtld_linux.c index 0be5095..1387a5d 100644 --- a/rtld.c +++ b/rtld_linux.c @@ -1,6 +1,3 @@ -//go:build linux -// +build linux - #define _GNU_SOURCE #include "rtld.h" @@ -111,17 +108,15 @@ static void resolve_symbols(void) { real_write = (write_fn)dlsym(RTLD_NEXT, "write"); } -static void ensure_symbols(void) { - pthread_once(&resolve_once, resolve_symbols); -} +static void ensure_symbols(void) { pthread_once(&resolve_once, resolve_symbols); } -#define REQUIRE_SYMBOL(sym) \ - do { \ - ensure_symbols(); \ - if ((sym) == NULL) {\ - errno = ENOSYS; \ - return -1; \ - } \ +#define REQUIRE_SYMBOL(sym) \ + do { \ + ensure_symbols(); \ + if ((sym) == NULL) { \ + errno = ENOSYS; \ + return -1; \ + } \ } while (0) int orig___fxstat(int ver, int fd, struct stat *cstat) { @@ -284,9 +279,7 @@ ssize_t orig_write(int fd, const void *buf, size_t count) { return real_write(fd, buf, count); } -int __fxstat(int ver, int fd, struct stat *cstat) { - return orig___fxstat(ver, fd, cstat); -} +int __fxstat(int ver, int fd, struct stat *cstat) { return orig___fxstat(ver, fd, cstat); } int __fxstatat(int ver, int dirfd, const char *pathname, struct stat *cstat, int flags) { return orig___fxstatat(ver, dirfd, pathname, cstat, flags); @@ -300,29 +293,19 @@ int __xstat(int ver, const char *pathname, struct stat *cstat) { return orig___xstat(ver, pathname, cstat); } -int access(const char *pathname, int mode) { - return orig_access(pathname, mode); -} +int access(const char *pathname, int mode) { return orig_access(pathname, mode); } -int chmod(const char *pathname, mode_t mode) { - return orig_chmod(pathname, mode); -} +int chmod(const char *pathname, mode_t mode) { return orig_chmod(pathname, mode); } int chown(const char *pathname, uid_t owner, gid_t group) { return orig_chown(pathname, owner, group); } -int close(int fd) { - return orig_close(fd); -} +int close(int fd) { return orig_close(fd); } -int creat(const char *pathname, mode_t mode) { - return orig_creat(pathname, mode); -} +int creat(const char *pathname, mode_t mode) { return orig_creat(pathname, mode); } -int euidaccess(const char *pathname, int mode) { - return orig_euidaccess(pathname, mode); -} +int euidaccess(const char *pathname, int mode) { return orig_euidaccess(pathname, mode); } int faccessat(int dirfd, const char *pathname, int mode, int flags) { return orig_faccessat(dirfd, pathname, mode, flags); @@ -332,9 +315,7 @@ ssize_t fgetxattr(int fd, const char *name, void *value, size_t size) { return orig_fgetxattr(fd, name, value, size); } -int fstat(int fd, struct stat *cstat) { - return orig_fstat(fd, cstat); -} +int fstat(int fd, struct stat *cstat) { return orig_fstat(fd, cstat); } ssize_t getxattr(const char *pathname, const char *name, void *value, size_t size) { return orig_getxattr(pathname, name, value, size); @@ -344,21 +325,13 @@ ssize_t lgetxattr(const char *pathname, const char *name, void *value, size_t si return orig_lgetxattr(pathname, name, value, size); } -int link(const char *oldpath, const char *newpath) { - return orig_link(oldpath, newpath); -} +int link(const char *oldpath, const char *newpath) { return orig_link(oldpath, newpath); } -off_t lseek(int fd, off_t offset, int whence) { - return orig_lseek(fd, offset, whence); -} +off_t lseek(int fd, off_t offset, int whence) { return orig_lseek(fd, offset, whence); } -int mkdir(const char *pathname, mode_t mode) { - return orig_mkdir(pathname, mode); -} +int mkdir(const char *pathname, mode_t mode) { return orig_mkdir(pathname, mode); } -int mknod(const char *pathname, mode_t mode, dev_t dev) { - return orig_mknod(pathname, mode, dev); -} +int mknod(const char *pathname, mode_t mode, dev_t dev) { return orig_mknod(pathname, mode, dev); } int mknodat(int dirfd, const char *pathname, mode_t mode, dev_t dev) { return orig_mknodat(dirfd, pathname, mode, dev); @@ -398,34 +371,20 @@ ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset) { return orig_pwrite(fd, buf, count, offset); } -ssize_t read(int fd, void *buf, size_t count) { - return orig_read(fd, buf, count); -} +ssize_t read(int fd, void *buf, size_t count) { return orig_read(fd, buf, count); } ssize_t readlink(const char *pathname, char *buf, size_t bufsiz) { return orig_readlink(pathname, buf, bufsiz); } -int rename(const char *oldpath, const char *newpath) { - return orig_rename(oldpath, newpath); -} +int rename(const char *oldpath, const char *newpath) { return orig_rename(oldpath, newpath); } -int rmdir(const char *pathname) { - return orig_rmdir(pathname); -} +int rmdir(const char *pathname) { return orig_rmdir(pathname); } -int symlink(const char *target, const char *linkpath) { - return orig_symlink(target, linkpath); -} +int symlink(const char *target, const char *linkpath) { return orig_symlink(target, linkpath); } -int truncate(const char *pathname, off_t length) { - return orig_truncate(pathname, length); -} +int truncate(const char *pathname, off_t length) { return orig_truncate(pathname, length); } -int unlink(const char *pathname) { - return orig_unlink(pathname); -} +int unlink(const char *pathname) { return orig_unlink(pathname); } -ssize_t write(int fd, const void *buf, size_t count) { - return orig_write(fd, buf, count); -} +ssize_t write(int fd, const void *buf, size_t count) { return orig_write(fd, buf, count); } diff --git a/smoke/fixture.c b/smoke/fixture.c new file mode 100644 index 0000000..90c56a7 --- /dev/null +++ b/smoke/fixture.c @@ -0,0 +1,437 @@ +#define _GNU_SOURCE + +#ifndef __linux__ + +#include + +int main(void) { + puts("linux-only smoke fixture"); + return 0; +} + +#else + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char payload[] = "ld_preload smoke payload"; + +static void die(const char *message) { + fprintf(stderr, "%s: %s\n", message, strerror(errno)); + exit(1); +} + +static void fail_message(const char *message) { + fprintf(stderr, "%s\n", message); + exit(1); +} + +static void make_path(char *dst, size_t dst_len, const char *dir, const char *name) { + if (snprintf(dst, dst_len, "%s/%s", dir, name) >= (int)dst_len) { + fail_message("path too long"); + } +} + +static void expect_errno_int(const char *label, int actual, int expected) { + if (actual != expected) { + fprintf(stderr, "%s: expected errno %d, got %d\n", label, expected, actual); + exit(1); + } +} + +static void expect_size(const char *label, off_t actual, off_t expected) { + if (actual != expected) { + fprintf(stderr, "%s: expected size %lld, got %lld\n", label, (long long)expected, + (long long)actual); + exit(1); + } +} + +static void expect_bytes(const char *label, const char *actual, const char *expected, size_t len) { + if (memcmp(actual, expected, len) != 0) { + fprintf(stderr, "%s: payload mismatch\n", label); + exit(1); + } +} + +static int is_optional_xattr_errno(int err) { + return err == ENOTSUP || err == EOPNOTSUPP || err == EPERM; +} + +static void create_scratch_dir(char *dir_buf, size_t dir_buf_len) { + char template[] = "/tmp/ld_preload_smoke.XXXXXX"; + char *dir = mkdtemp(template); + if (dir == NULL) { + die("mkdtemp"); + } + if (snprintf(dir_buf, dir_buf_len, "%s", dir) >= (int)dir_buf_len) { + fail_message("scratch dir too long"); + } +} + +static void cleanup_path(const char *path) { + if (unlink(path) != 0 && errno != ENOENT) { + die("unlink cleanup"); + } +} + +static void cleanup_dir(const char *path) { + if (rmdir(path) != 0 && errno != ENOENT) { + die("rmdir cleanup"); + } +} + +static void run_basic(void) { + char dir[PATH_MAX]; + char file_path[PATH_MAX]; + char buf[128]; + struct stat st; + int fd; + int dirfd; + int fd_at; + ssize_t n; + + create_scratch_dir(dir, sizeof(dir)); + make_path(file_path, sizeof(file_path), dir, "basic.txt"); + + fd = creat(file_path, 0644); + if (fd < 0) { + die("creat"); + } + + n = write(fd, payload, sizeof(payload) - 1); + if (n != (ssize_t)(sizeof(payload) - 1)) { + die("write"); + } + + if (close(fd) != 0) { + die("close after creat"); + } + + if (access(file_path, R_OK | W_OK) != 0) { + die("access"); + } + if (euidaccess(file_path, R_OK) != 0) { + die("euidaccess"); + } + + fd = open(file_path, O_RDWR); + if (fd < 0) { + die("open"); + } + + if (lseek(fd, 0, SEEK_SET) != 0) { + die("lseek rewind"); + } + + memset(buf, 0, sizeof(buf)); + n = read(fd, buf, sizeof(buf)); + if (n != (ssize_t)(sizeof(payload) - 1)) { + die("read after lseek"); + } + expect_bytes("read after lseek", buf, payload, sizeof(payload) - 1); + + if (pwrite(fd, "PRE", 3, 0) != 3) { + die("pwrite"); + } + memset(buf, 0, sizeof(buf)); + if (pread(fd, buf, sizeof(payload) - 1, 0) != (ssize_t)(sizeof(payload) - 1)) { + die("pread"); + } + expect_bytes("pread prefix", buf, "PRE", 3); + + if (fstat(fd, &st) != 0) { + die("fstat"); + } + expect_size("fstat size", st.st_size, sizeof(payload) - 1); + + dirfd = open(dir, O_RDONLY | O_DIRECTORY); + if (dirfd < 0) { + die("open dir"); + } + + if (faccessat(dirfd, "basic.txt", R_OK, 0) != 0) { + die("faccessat"); + } + + fd_at = openat(dirfd, "basic.txt", O_RDONLY); + if (fd_at < 0) { + die("openat"); + } + if (close(fd_at) != 0) { + die("close openat"); + } + + if (stat(file_path, &st) != 0) { + die("stat"); + } + expect_size("stat size", st.st_size, sizeof(payload) - 1); + + if (close(fd) != 0) { + die("close"); + } + + if (truncate(file_path, 7) != 0) { + die("truncate"); + } + if (stat(file_path, &st) != 0) { + die("stat after truncate"); + } + expect_size("truncate size", st.st_size, 7); + + if (close(dirfd) != 0) { + die("close dir"); + } + + cleanup_path(file_path); + cleanup_dir(dir); +} + +static void run_metadata(void) { + char dir[PATH_MAX]; + char nested_dir[PATH_MAX]; + char file_path[PATH_MAX]; + char renamed_path[PATH_MAX]; + char symlink_path[PATH_MAX]; + char hardlink_path[PATH_MAX]; + char link_target[PATH_MAX]; + struct stat st; + int fd; + ssize_t n; + + create_scratch_dir(dir, sizeof(dir)); + make_path(nested_dir, sizeof(nested_dir), dir, "nested"); + make_path(file_path, sizeof(file_path), nested_dir, "source.txt"); + make_path(renamed_path, sizeof(renamed_path), nested_dir, "renamed.txt"); + make_path(symlink_path, sizeof(symlink_path), dir, "source.lnk"); + make_path(hardlink_path, sizeof(hardlink_path), dir, "source.hard"); + + if (mkdir(nested_dir, 0755) != 0) { + die("mkdir"); + } + + fd = creat(file_path, 0644); + if (fd < 0) { + die("creat metadata"); + } + n = write(fd, payload, sizeof(payload) - 1); + if (n != (ssize_t)(sizeof(payload) - 1)) { + die("write metadata"); + } + if (close(fd) != 0) { + die("close metadata"); + } + + if (rename(file_path, renamed_path) != 0) { + die("rename"); + } + if (chmod(renamed_path, 0600) != 0) { + die("chmod"); + } + if (stat(renamed_path, &st) != 0) { + die("stat renamed"); + } + if ((st.st_mode & 0777) != 0600) { + fail_message("chmod mode mismatch"); + } + + if (symlink(renamed_path, symlink_path) != 0) { + die("symlink"); + } + if (lstat(symlink_path, &st) != 0) { + die("lstat symlink"); + } + if (!S_ISLNK(st.st_mode)) { + fail_message("lstat did not report symlink"); + } + + memset(link_target, 0, sizeof(link_target)); + n = readlink(symlink_path, link_target, sizeof(link_target) - 1); + if (n < 0) { + die("readlink"); + } + link_target[n] = '\0'; + if (strcmp(link_target, renamed_path) != 0) { + fail_message("readlink target mismatch"); + } + + if (link(renamed_path, hardlink_path) != 0) { + die("link"); + } + if (stat(hardlink_path, &st) != 0) { + die("stat hardlink"); + } + if (st.st_nlink < 2) { + fail_message("hardlink count mismatch"); + } + + cleanup_path(hardlink_path); + cleanup_path(symlink_path); + cleanup_path(renamed_path); + cleanup_dir(nested_dir); + cleanup_dir(dir); +} + +static void run_missing(void) { + char dir[PATH_MAX]; + char missing_path[PATH_MAX]; + int dirfd; + struct stat st; + char buf[16]; + + create_scratch_dir(dir, sizeof(dir)); + make_path(missing_path, sizeof(missing_path), dir, "missing.txt"); + + errno = 0; + if (access(missing_path, F_OK) != -1) { + fail_message("access missing unexpectedly succeeded"); + } + expect_errno_int("access missing", errno, ENOENT); + + errno = 0; + if (open(missing_path, O_RDONLY) != -1) { + fail_message("open missing unexpectedly succeeded"); + } + expect_errno_int("open missing", errno, ENOENT); + + errno = 0; + if (stat(missing_path, &st) != -1) { + fail_message("stat missing unexpectedly succeeded"); + } + expect_errno_int("stat missing", errno, ENOENT); + + errno = 0; + if (lstat(missing_path, &st) != -1) { + fail_message("lstat missing unexpectedly succeeded"); + } + expect_errno_int("lstat missing", errno, ENOENT); + + errno = 0; + if (readlink(missing_path, buf, sizeof(buf)) != -1) { + fail_message("readlink missing unexpectedly succeeded"); + } + expect_errno_int("readlink missing", errno, ENOENT); + + errno = 0; + if (getxattr(missing_path, "user.ld_preload", buf, sizeof(buf)) != -1) { + fail_message("getxattr missing unexpectedly succeeded"); + } + expect_errno_int("getxattr missing", errno, ENOENT); + + dirfd = open(dir, O_RDONLY | O_DIRECTORY); + if (dirfd < 0) { + die("open dir missing"); + } + + errno = 0; + if (openat(dirfd, "missing.txt", O_RDONLY) != -1) { + fail_message("openat missing unexpectedly succeeded"); + } + expect_errno_int("openat missing", errno, ENOENT); + + errno = 0; + if (faccessat(dirfd, "missing.txt", R_OK, 0) != -1) { + fail_message("faccessat missing unexpectedly succeeded"); + } + expect_errno_int("faccessat missing", errno, ENOENT); + + if (close(dirfd) != 0) { + die("close dir missing"); + } + cleanup_dir(dir); +} + +static void run_xattr(void) { + char dir[PATH_MAX]; + char file_path[PATH_MAX]; + int fd; + ssize_t n; + char buf[64]; + const char *attr_name = "user.ld_preload"; + const char *attr_value = "xattr-value"; + + create_scratch_dir(dir, sizeof(dir)); + make_path(file_path, sizeof(file_path), dir, "xattr.txt"); + + fd = creat(file_path, 0644); + if (fd < 0) { + die("creat xattr"); + } + n = write(fd, payload, sizeof(payload) - 1); + if (n != (ssize_t)(sizeof(payload) - 1)) { + die("write xattr"); + } + + if (setxattr(file_path, attr_name, attr_value, strlen(attr_value), 0) != 0) { + if (is_optional_xattr_errno(errno)) { + printf("SKIP xattr setup unsupported: %s\n", strerror(errno)); + close(fd); + cleanup_path(file_path); + cleanup_dir(dir); + return; + } + die("setxattr"); + } + + memset(buf, 0, sizeof(buf)); + n = getxattr(file_path, attr_name, buf, sizeof(buf)); + if (n != (ssize_t)strlen(attr_value)) { + die("getxattr"); + } + expect_bytes("getxattr", buf, attr_value, strlen(attr_value)); + + memset(buf, 0, sizeof(buf)); + n = fgetxattr(fd, attr_name, buf, sizeof(buf)); + if (n != (ssize_t)strlen(attr_value)) { + die("fgetxattr"); + } + expect_bytes("fgetxattr", buf, attr_value, strlen(attr_value)); + + errno = 0; + if (lgetxattr(file_path, attr_name, buf, sizeof(buf)) != (ssize_t)strlen(attr_value)) { + die("lgetxattr regular file"); + } + + if (close(fd) != 0) { + die("close xattr"); + } + cleanup_path(file_path); + cleanup_dir(dir); +} + +int main(int argc, char **argv) { + if (argc != 2) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + + if (strcmp(argv[1], "basic") == 0) { + run_basic(); + return 0; + } + if (strcmp(argv[1], "metadata") == 0) { + run_metadata(); + return 0; + } + if (strcmp(argv[1], "missing") == 0) { + run_missing(); + return 0; + } + if (strcmp(argv[1], "xattr") == 0) { + run_xattr(); + return 0; + } + + fprintf(stderr, "unknown scenario: %s\n", argv[1]); + return 2; +} + +#endif diff --git a/smoke/run.sh b/smoke/run.sh new file mode 100755 index 0000000..c4a25cf --- /dev/null +++ b/smoke/run.sh @@ -0,0 +1,64 @@ +#!/bin/sh + +set -eu + +repo_root=/workspace +suite_name="${SMOKE_SUITE:-default}" +artifact_dir="$repo_root/smoke-artifacts/$suite_name" +fixture_bin="$repo_root/smoke/fixture" +fixture_src="$repo_root/smoke/fixture.c" +shared_object="$repo_root/ld_preload.so" + +mkdir -p "$artifact_dir" +rm -f "$artifact_dir"/* + +echo "==> building shared object" +go build -o "$shared_object" -buildmode=c-shared "$repo_root" + +echo "==> building smoke fixture" +cc -Wall -Wextra -Werror -O2 -o "$fixture_bin" "$fixture_src" + +run_case() { + mode="$1" + name="$2" + preload="$3" + shift + shift + shift + + log_prefix="$artifact_dir/${mode}.${name}" + echo "==> smoke case: ${mode}.${name}" + + if [ "$preload" = "yes" ]; then + set -- env LD_PRELOAD="$shared_object" "$@" + fi + + if timeout 10s "$@" >"$log_prefix.stdout" 2>"$log_prefix.stderr"; then + return 0 + fi + + echo "case failed: ${mode}.${name}" + timeout 10s strace -ff -o "$log_prefix.strace" "$@" \ + >"$log_prefix.strace.stdout" 2>"$log_prefix.strace.stderr" || true + echo "artifacts saved under $artifact_dir" + return 1 +} + +run_pair() { + name="$1" + shift + + run_case plain "$name" no "$@" + run_case preload "$name" yes "$@" +} + +run_pair true /bin/true +run_pair ls /bin/ls /tmp +run_pair stat /usr/bin/stat /tmp +run_pair cat-hosts /bin/cat /etc/hosts +run_pair fixture-basic "$fixture_bin" basic +run_pair fixture-metadata "$fixture_bin" metadata +run_pair fixture-missing "$fixture_bin" missing +run_pair fixture-xattr "$fixture_bin" xattr + +echo "smoke harness passed for $suite_name" diff --git a/terminal.go b/terminal.go index 3defc82..1daf857 100644 --- a/terminal.go +++ b/terminal.go @@ -4,54 +4,76 @@ import ( "fmt" "os" "os/exec" + "path/filepath" ) -func shell() { - // Get the current environment variables - env := os.Environ() +var ( + currentEnv = os.Environ + currentGetwd = os.Getwd + currentShell = func() string { return os.Getenv("SHELL") } + execCommand = exec.Command + inputFile = os.Stdin + outputFile = os.Stdout + errorFile = os.Stderr + makeRawFn = makeRaw + restoreTerminalFn = restoreTerminal +) + +func writeShellError(args ...any) { + _, _ = fmt.Fprintln(errorFile, args...) +} - // Append LD_PRELOAD flags to the environment - preload := "ld_preload.so" - // Get the current working directory - dir, err := os.Getwd() +func buildShellCommand() (*exec.Cmd, error) { + env := currentEnv() + + dir, err := currentGetwd() if err != nil { - fmt.Println("Error getting the current directory:", err) - return + return nil, fmt.Errorf("get current directory: %w", err) } - env = append(env, "LD_PRELOAD="+dir+"/"+preload) + + env = append(env, "LD_PRELOAD="+filepath.Join(dir, "ld_preload.so")) env = append(env, "PROMPT_COMMAND=printf \"[ldp] - \"") - // Define the shell to execute - shell := os.Getenv("SHELL") + shell := currentShell() if shell == "" { shell = "/bin/bash" } + shell = filepath.Clean(shell) - // Run the shell with the modified environment - cmd := exec.Command(shell, "-i") + // #nosec G204: the shell path comes from the user environment and is normalized before exec. + cmd := execCommand(shell, "-i") cmd.Env = env - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd.Stdin = inputFile + cmd.Stdout = outputFile + cmd.Stderr = errorFile + + return cmd, nil +} + +func shell() { + cmd, err := buildShellCommand() + if err != nil { + writeShellError("Error building shell command:", err) + return + } // Set terminal to raw mode - oldState, err := makeRaw(os.Stdin) + oldState, err := makeRawFn(inputFile) if err != nil { - fmt.Println("Error setting terminal to raw mode:", err) + writeShellError("Error setting terminal to raw mode:", err) return } - defer restoreTerminal(os.Stdin, oldState) + defer restoreTerminalFn(inputFile, oldState) // Start the shell process if err := cmd.Start(); err != nil { - fmt.Println("Error starting shell:", err) + writeShellError("Error starting shell:", err) return } // Wait for the shell process to finish if err := cmd.Wait(); err != nil { - fmt.Println("Error waiting for shell:", err) + writeShellError("Error waiting for shell:", err) return } - } diff --git a/terminal_bsd.go b/terminal_bsd.go index bf3b5a7..5e162ff 100644 --- a/terminal_bsd.go +++ b/terminal_bsd.go @@ -4,6 +4,7 @@ package main import ( + "fmt" "os" "golang.org/x/sys/unix" @@ -27,5 +28,7 @@ func makeRaw(f *os.File) (*unix.Termios, error) { // restoreTerminal restores the terminal to its previous state on BSD and macOS. func restoreTerminal(f *os.File, oldState *unix.Termios) { - unix.IoctlSetTermios(int(f.Fd()), unix.TIOCSETA, oldState) + if err := unix.IoctlSetTermios(int(f.Fd()), unix.TIOCSETA, oldState); err != nil { + _, _ = fmt.Fprintf(errorFile, "ioctl restore termios: %v\n", err) + } } diff --git a/terminal_linux.go b/terminal_linux.go index f2aa82f..1f42df4 100644 --- a/terminal_linux.go +++ b/terminal_linux.go @@ -14,7 +14,7 @@ import ( func makeRaw(f *os.File) (*unix.Termios, error) { oldState, err := unix.IoctlGetTermios(int(f.Fd()), unix.TCGETS) if err != nil { - return nil, fmt.Errorf("ioctl get termios: %v", err) + return nil, fmt.Errorf("ioctl get termios: %w", err) } // newState := *oldState diff --git a/terminal_test.go b/terminal_test.go new file mode 100644 index 0000000..9d7cfc0 --- /dev/null +++ b/terminal_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func resetTerminalGlobals() { + currentEnv = os.Environ + currentGetwd = os.Getwd + currentShell = func() string { return os.Getenv("SHELL") } + execCommand = exec.Command + inputFile = os.Stdin + outputFile = os.Stdout + errorFile = os.Stderr + makeRawFn = makeRaw + restoreTerminalFn = restoreTerminal +} + +func TestBuildShellCommandUsesShellEnvAndPreload(t *testing.T) { + t.Cleanup(resetTerminalGlobals) + + tmpDir := t.TempDir() + currentEnv = func() []string { return []string{"BASE=value"} } + currentGetwd = func() (string, error) { return tmpDir, nil } + currentShell = func() string { return "/bin/../bin/zsh" } + + cmd, err := buildShellCommand() + if err != nil { + t.Fatalf("buildShellCommand returned error: %v", err) + } + + if got, want := cmd.Path, "/bin/zsh"; got != want { + t.Fatalf("unexpected shell path: got %q want %q", got, want) + } + + if len(cmd.Args) != 2 || cmd.Args[1] != "-i" { + t.Fatalf("unexpected args: %#v", cmd.Args) + } + + env := strings.Join(cmd.Env, "\n") + if !strings.Contains(env, "BASE=value") { + t.Fatalf("base env missing: %v", cmd.Env) + } + + preload := "LD_PRELOAD=" + filepath.Join(tmpDir, "ld_preload.so") + if !strings.Contains(env, preload) { + t.Fatalf("ld preload env missing: %v", cmd.Env) + } + + if !strings.Contains(env, `PROMPT_COMMAND=printf "[ldp] - "`) { + t.Fatalf("prompt command missing: %v", cmd.Env) + } +} + +func TestBuildShellCommandDefaultsToBash(t *testing.T) { + t.Cleanup(resetTerminalGlobals) + + currentEnv = func() []string { return nil } + currentGetwd = func() (string, error) { return "/tmp", nil } + currentShell = func() string { return "" } + + cmd, err := buildShellCommand() + if err != nil { + t.Fatalf("buildShellCommand returned error: %v", err) + } + + if got, want := cmd.Path, "/bin/bash"; got != want { + t.Fatalf("unexpected default shell path: got %q want %q", got, want) + } +} + +func TestBuildShellCommandReturnsGetwdError(t *testing.T) { + t.Cleanup(resetTerminalGlobals) + + currentGetwd = func() (string, error) { return "", errors.New("boom") } + + _, err := buildShellCommand() + if err == nil { + t.Fatal("expected error from buildShellCommand") + } + + if !strings.Contains(err.Error(), "get current directory") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestShellReturnsWhenBuildFails(t *testing.T) { + t.Cleanup(resetTerminalGlobals) + + currentGetwd = func() (string, error) { return "", errors.New("boom") } + errorFile = tempOutputFile(t) + + shell() + + if got := readOutputFile(t, errorFile); !strings.Contains(got, "Error building shell command:") { + t.Fatalf("expected build error message, got %q", got) + } +} + +func TestShellReturnsWhenMakeRawFails(t *testing.T) { + t.Cleanup(resetTerminalGlobals) + + currentEnv = func() []string { return nil } + currentGetwd = func() (string, error) { return "/tmp", nil } + currentShell = func() string { return "/bin/sh" } + errorFile = tempOutputFile(t) + makeRawFn = func(*os.File) (*unix.Termios, error) { + return nil, errors.New("raw mode failed") + } + + shell() + + if got := readOutputFile(t, errorFile); !strings.Contains(got, "Error setting terminal to raw mode: raw mode failed") { + t.Fatalf("expected makeRaw error message, got %q", got) + } +} + +func tempOutputFile(t *testing.T) *os.File { + t.Helper() + + f, err := os.CreateTemp(t.TempDir(), "stderr-*") + if err != nil { + t.Fatalf("CreateTemp failed: %v", err) + } + t.Cleanup(func() { _ = f.Close() }) + return f +} + +func readOutputFile(t *testing.T, f *os.File) string { + t.Helper() + + if _, err := f.Seek(0, 0); err != nil { + t.Fatalf("Seek failed: %v", err) + } + data, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + return string(data) +}