diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f7c380..b62a010 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,24 @@ jobs: run: swift --version - name: Run tests - run: swift run adevcontainerTests + # Prepare generated, ignored Swift payloads before any Swift target builds. + # Build the CLI explicitly for isolated internal-entry subprocess tests. + run: | + sh scripts/build-guest-credential-relay.sh + sh scripts/test-guest-credential-relay.sh + swift build --product adevcontainer + ADEVCONTAINER_CREDENTIAL_TESTS_ONLY=1 swift run adevcontainerTests + swift run adevcontainerTests - name: Release build run: swift build -c release --product adevcontainer + + native-linux-amd64: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build and exercise shipped static amd64 relay + run: | + sh scripts/build-guest-credential-relay.sh --raw-only + sh scripts/test-guest-credential-relay.sh + sh Tests/GuestGitCredentialRelay/build_artifacts_tests.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 602df40..932cc0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,12 @@ jobs: run: bash scripts/inject-version.sh "${{ steps.version.outputs.version }}" - name: Run tests - run: swift run adevcontainerTests + run: | + sh scripts/build-guest-credential-relay.sh + sh scripts/test-guest-credential-relay.sh + swift build --product adevcontainer + ADEVCONTAINER_CREDENTIAL_TESTS_ONLY=1 swift run adevcontainerTests + swift run adevcontainerTests - name: Release build run: swift build -c release --product adevcontainer @@ -59,11 +64,7 @@ jobs: exit 1 fi echo "Using binary: ${BIN}" - mkdir -p dist - cp "${BIN}" dist/adevcontainer - export COPYFILE_DISABLE=1 - tar -C dist -czf adevcontainer-macos-arm64.tar.gz adevcontainer - shasum -a 256 adevcontainer-macos-arm64.tar.gz > adevcontainer-macos-arm64.tar.gz.sha256 + sh scripts/package-release.sh "${BIN}" . SHA256="$(awk '{print $1}' adevcontainer-macos-arm64.tar.gz.sha256)" if [[ ! "$SHA256" =~ ^[0-9a-f]{64}$ ]]; then echo "error: failed to parse sha256 from checksum file: ${SHA256}" >&2 diff --git a/.gitignore b/.gitignore index d67defe..ef2e923 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # SwiftPM .build/ +Sources/ADevContainerLib/Git/Generated/GuestGitCredentialEmbeddedAssets.swift .swiftpm/xcode/xcuserdata/ .swiftpm/configuration/ *.xcodeproj diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2738dd4..06d1c8e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,7 @@ Contributing with AI agents? Use [agents-workspace](https://github.com/wcgomes/a ## Build ```bash +sh scripts/build-guest-credential-relay.sh # authenticated toolchain + both embedded guest targets swift build # binary: .build/debug/adevcontainer swift build -c release # binary: .build/release/adevcontainer ``` @@ -17,12 +18,45 @@ swift build -c release # binary: .build/release/adevcontainer To install from source onto PATH and link the Apple CLI plugin: ```bash +sh scripts/build-guest-credential-relay.sh swift build -c release cp .build/release/adevcontainer /usr/local/bin/ # or another directory on PATH adevcontainer plugin --install # use sudo when the plugin destination requires elevation ``` ## Tests +Safe, synthetic credential checks (no user broker/provider/container): + +```sh +sh scripts/test-guest-credential-relay.sh +sh Tests/GuestGitCredentialRelay/build_artifacts_tests.sh +swift build --product adevcontainer # internal-entry subprocess tests use this binary +ADEVCONTAINER_CREDENTIAL_TESTS_ONLY=1 swift run adevcontainerTests +ADEVCONTAINER_DETACHED_BROKER_TEST=1 ADEVCONTAINER_CREDENTIAL_TESTS_ONLY=1 swift run adevcontainerTests +``` + +The last command adds the isolated detached-broker probe: private context, +test-owned executable copies, synthetic provider, CLI-exit survival, single-owner +concurrency, idle exit, crash containment, and upgrade replacement. It never runs +`launchctl`, creates a plist, or touches production state. + +Native tests have recorded behavioral red/green for absent transport, stdout +backpressure and tampered extracted toolchains. Artifact tests compare independent +output/cache rebuilds, ELF architecture/bounds/no-loader/no-dynamic metadata, +manifest/payload hashes and protocol identity. Generated Swift payloads under +`Sources/ADevContainerLib/Git/Generated/` must not be edited or committed. Details: +[`Tests/GuestGitCredentialRelay/README.md`](Tests/GuestGitCredentialRelay/README.md). +Linux CI uses the pinned compiler/raw-only mode and actually executes the shipped +amd64 ELF; macOS-native parser tests do not prove Linux target execution. The +isolated detached-broker probe runs from test-owned executable copies under the +temporary root and requires no authorization; arm64 guest smoke requires separately +authorized exclusively-created resources. + +Do not run the full suite against an installed/running runtime without explicit +authorization for its integration side effects. Never stop an existing runtime +or user container merely to force integration skips. No real authentication or +host credential erase/reject is part of the synthetic checks. + Plain `swift test` may report “no tests found” on Command Line Tools hosts, which lack `XCTest.framework`; the suite of record is `swift run adevcontainerTests`. On a TTY, clone tests can prompt for git identity; `swift run adevcontainerTests < /dev/null` runs the suite without prompts. - Integration skips cleanly if Apple `container` is unavailable diff --git a/Guest/GitCredentialRelay/main.c b/Guest/GitCredentialRelay/main.c new file mode 100644 index 0000000..c25e8a8 --- /dev/null +++ b/Guest/GitCredentialRelay/main.c @@ -0,0 +1,383 @@ +/* adevcontainer private credential transport, protocol 1. + * All integers are big endian. Header: AGCR, u16 version, u16 operation, + * u64 request, u64 generation, u32 payload size, u32 reserved (zero). + * Payload: repeated u16 field, u16 byte length, UTF-8 value; unique fields + * 1 protocol, 2 host, 3 path, 4 username, 5 password, 6 platform. + * Operations: 1 hello/ack, 2 get, 3 erase, 4 reply (empty = miss). + * The host supplies generation and must echo the hello before serving clients. + */ +#define _POSIX_C_SOURCE 200809L +#define _DARWIN_C_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { HEADER = 32, LIMIT = 65536, CLIENTS = 64, DEADLINE = 15000 }; +#if defined(__aarch64__) || defined(__arm64__) +#define PLATFORM "linux/arm64" +#elif defined(__x86_64__) +#define PLATFORM "linux/amd64" +#else +#error Unsupported guest architecture +#endif +typedef struct { unsigned char bytes[LIMIT]; size_t size, offset; } Buffer; +typedef struct { int fd; uint64_t id; int64_t deadline; Buffer input, output; int waiting; } Client; +static volatile sig_atomic_t stopped; +static void stop_signal(int sig) { (void)sig; stopped = 1; } +static int64_t now_ms(void) { + struct timespec t; if (clock_gettime(CLOCK_MONOTONIC, &t)) return 0; + return (int64_t)t.tv_sec * 1000 + t.tv_nsec / 1000000; +} +static void wipe(void *data, size_t count) { + volatile unsigned char *p = data; while (count--) *p++ = 0; +} +static uint64_t integer(const unsigned char *p, size_t size) { + uint64_t value = 0; while (size--) value = value << 8 | *p++; return value; +} +static void put(unsigned char *p, uint64_t value, size_t size) { + while (size) { p[--size] = (unsigned char)value; value >>= 8; } +} +static void header(Buffer *b, int op, uint64_t id, uint64_t generation, size_t payload) { + memset(b->bytes, 0, HEADER); memcpy(b->bytes, "AGCR", 4); + put(b->bytes + 4, 1, 2); put(b->bytes + 6, (uint64_t)op, 2); + put(b->bytes + 8, id, 8); put(b->bytes + 16, generation, 8); + put(b->bytes + 24, payload, 4); b->size = HEADER + payload; b->offset = 0; +} +static int utf8(const unsigned char *p, size_t n) { + size_t i = 0; + while (i < n) { + unsigned char c = p[i++]; + if (c < 0x20 || c == 0x7f) return 0; + if (c < 0x80) continue; + unsigned need; uint32_t value, min; + if (c >= 0xc2 && c <= 0xdf) { need = 1; value = c & 31; min = 0x80; } + else if (c >= 0xe0 && c <= 0xef) { need = 2; value = c & 15; min = 0x800; } + else if (c >= 0xf0 && c <= 0xf4) { need = 3; value = c & 7; min = 0x10000; } + else return 0; + if (need > n - i) return 0; + while (need--) { c = p[i++]; if ((c & 0xc0) != 0x80) return 0; value = value << 6 | (c & 63); } + if (value < min || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return 0; + } + return 1; +} +static int fields(const unsigned char *p, size_t size, int hello) { + unsigned seen = 0; + while (size) { + if (size < 4) return 0; + unsigned key = (unsigned)integer(p, 2), len = (unsigned)integer(p + 2, 2); + if (!key || key > 6 || (seen & (1u << key)) || len > size - 4) return 0; + if ((hello && key != 6) || (!hello && key == 6) || !utf8(p + 4, len)) return 0; + seen |= 1u << key; p += 4 + len; size -= 4 + len; + } + return !hello || seen == (1u << 6); +} +/* -1 invalid, 0 partial, otherwise total size. */ +static int frame_size(const Buffer *b) { + if (b->size < HEADER) return 0; + if (memcmp(b->bytes, "AGCR\0\1", 6) || integer(b->bytes + 28, 4)) return -1; + uint64_t length = integer(b->bytes + 24, 4), op = integer(b->bytes + 6, 2); + if (length > LIMIT - HEADER || op < 1 || op > 4) return -1; + if (b->size < HEADER + length) return 0; + if (!fields(b->bytes + HEADER, (size_t)length, op == 1)) return -1; + return (int)(HEADER + length); +} +static int nonblock(int fd) { + int flags = fcntl(fd, F_GETFL); return flags < 0 ? -1 : fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} +static int ready(int fd, short events, int64_t deadline) { + while (!stopped) { + int64_t left = deadline - now_ms(); if (left <= 0) return 0; + struct pollfd p = {fd, events, 0}; int n = poll(&p, 1, left > INT_MAX ? INT_MAX : (int)left); + if (n > 0) return !(p.revents & (POLLERR | POLLNVAL)) && (p.revents & (events | POLLHUP)); + if (n < 0 && errno != EINTR) return 0; + } + return 0; +} +static int transfer(int fd, unsigned char *data, size_t count, int writing, int64_t deadline) { + while (count && ready(fd, writing ? POLLOUT : POLLIN, deadline)) { + ssize_t n = writing ? write(fd, data, count) : read(fd, data, count); + if (n > 0) { data += n; count -= (size_t)n; } + else if (!n || (errno != EAGAIN && errno != EINTR)) return 0; + } + return !count; +} +static int credential_output(unsigned char *data, size_t count, int64_t deadline) { +#ifndef __APPLE__ + /* A Linux proc-fd reopen creates a new open file description for a pipe. + * dup/fcntl would change Git's shared stdout flags, even after fork. Never + * reopen regular files: doing so would change their shared file offset. */ + struct stat original, reopened; + if (!fstat(1, &original) && S_ISFIFO(original.st_mode)) { + int fd = open("/proc/self/fd/1", O_WRONLY | O_NONBLOCK | O_CLOEXEC); + if (fd >= 0) { + int ok = !fstat(fd, &reopened) && reopened.st_dev == original.st_dev && reopened.st_ino == original.st_ino + && transfer(fd, data, count, 1, deadline); + close(fd); return ok; + } + } +#endif + /* macOS's /dev/fd is only dup, and procfs need not exist in a Linux guest. + * Contain potentially blocking output in one bounded/reaped writer without + * touching the caller's OFD. Its own alarm also bounds orphan lifetime if + * the supervisor is killed with an unhandleable signal. No exec/env/argv. */ + if (now_ms() >= deadline) return 0; + pid_t writer = fork(); if (writer < 0) return 0; + if (!writer) { + signal(SIGALRM, SIG_DFL); + int64_t remaining = deadline - now_ms(); if (remaining <= 0) _exit(1); + alarm((unsigned)((remaining + 999) / 1000)); + _exit(transfer(1, data, count, 1, deadline) ? 0 : 1); + } + int status = 0; + while (!stopped && now_ms() < deadline) { + pid_t result = waitpid(writer, &status, WNOHANG); + if (result == writer) return WIFEXITED(status) && WEXITSTATUS(status) == 0; + if (result < 0 && errno != EINTR) return 0; + (void)poll(NULL, 0, 5); + } + kill(writer, SIGKILL); while (waitpid(writer, &status, 0) < 0 && errno == EINTR) {} + return 0; +} +static int receive(int fd, Buffer *b, int64_t deadline) { + b->size = HEADER; b->offset = 0; + if (!transfer(fd, b->bytes, HEADER, 0, deadline)) return 0; + uint64_t length = integer(b->bytes + 24, 4); if (length > LIMIT - HEADER) return 0; + if (!transfer(fd, b->bytes + HEADER, (size_t)length, 0, deadline)) return 0; + b->size += (size_t)length; return frame_size(b) == (int)b->size; +} +/* Walk each component without following symlinks. Only the final, user-owned + * directory must be 0700; ancestors such as /private/tmp are not ours to change. */ +static int private_dir(const char *path) { + if (path[0] != '/' || strlen(path) >= PATH_MAX) return -1; + char copy[PATH_MAX]; strcpy(copy, path); int fd = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (fd < 0) return -1; + char *save = NULL, *component = strtok_r(copy, "/", &save); + if (!component) { close(fd); return -1; } + while (component) { + if (!strcmp(component, ".") || !strcmp(component, "..")) { close(fd); return -1; } + int next = openat(fd, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + close(fd); if (next < 0) return -1; fd = next; component = strtok_r(NULL, "/", &save); + } + struct stat st; + if (fstat(fd, &st) || st.st_uid != geteuid() || (st.st_mode & 0777) != 0700) { close(fd); return -1; } + return fd; +} +static int endpoint(const char *dir, struct sockaddr_un *address) { + memset(address, 0, sizeof(*address)); address->sun_family = AF_UNIX; + int n = snprintf(address->sun_path, sizeof(address->sun_path), "%s/credential.sock", dir); + return n > 0 && (size_t)n < sizeof(address->sun_path); +} +static int owned_socket(int dirfd, struct stat *st) { + return !fstatat(dirfd, "credential.sock", st, AT_SYMLINK_NOFOLLOW) && S_ISSOCK(st->st_mode) + && st->st_uid == geteuid() && !(st->st_mode & 0077); +} +static int peer(int fd) { +#ifdef __APPLE__ + uid_t uid; gid_t gid; return !getpeereid(fd, &uid, &gid) && uid == geteuid(); +#else + struct { pid_t pid; uid_t uid; gid_t gid; } credentials; + socklen_t size = sizeof(credentials); + return !getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &credentials, &size) && credentials.uid == geteuid(); +#endif +} +static int git_input(Buffer *b, int op, int64_t deadline) { + Buffer text = {0}; int complete = 0; + while (text.size < LIMIT && ready(0, POLLIN, deadline)) { + ssize_t n = read(0, text.bytes + text.size, LIMIT - text.size); + if (!n) { complete = 1; break; } + if (n < 0) { if (errno == EINTR || errno == EAGAIN) continue; break; } + text.size += (size_t)n; + if ((text.size >= 2 && !memcmp(text.bytes + text.size - 2, "\n\n", 2)) || (text.size == 1 && text.bytes[0] == '\n')) { complete = 1; break; } + } + int ok = complete && text.size < LIMIT; size_t offset = HEADER, start = 0; unsigned seen = 0; + for (size_t i = 0; ok && i < text.size; i++) { + if (text.bytes[i] != '\n') continue; + size_t len = i - start; if (!len) { if (i + 1 != text.size) ok = 0; break; } + unsigned char *line = text.bytes + start, *equal = memchr(line, '=', len); + if (!equal || !utf8(line, len)) { ok = 0; break; } + size_t keysize = (size_t)(equal - line), value = len - keysize - 1; unsigned key = 0; + const char *keys[] = {"", "protocol", "host", "path", "username", "password"}; + for (unsigned k = 1; k <= 5; k++) if (strlen(keys[k]) == keysize && !memcmp(line, keys[k], keysize)) key = k; + if (key) { + if ((seen & (1u << key)) || offset + 4 + value > LIMIT || value > UINT16_MAX) { ok = 0; break; } + seen |= 1u << key; put(b->bytes + offset, key, 2); put(b->bytes + offset + 2, value, 2); + memcpy(b->bytes + offset + 4, equal + 1, value); offset += 4 + value; + } + start = i + 1; + } + if (start < text.size && text.bytes[text.size - 1] != '\n') ok = 0; + wipe(&text, sizeof(text)); if (!ok) return 0; + header(b, op, 0, 0, offset - HEADER); return 1; +} +static int client(const char *dir, const char *operation) { + int op = !strcmp(operation, "get") ? 2 : !strcmp(operation, "erase") ? 3 : !strcmp(operation, "store") ? 0 : -1; + if (op < 0) return 2; + Buffer b = {0}; int64_t deadline = now_ms() + DEADLINE; + if (!git_input(&b, op, deadline)) return 1; + if (!op) { wipe(&b, sizeof(b)); return 0; } + struct sockaddr_un address; int directory = private_dir(dir); struct stat st; + if (directory < 0 || !endpoint(dir, &address) || !owned_socket(directory, &st)) { if (directory >= 0) close(directory); return 1; } + int fd = socket(AF_UNIX, SOCK_STREAM, 0); close(directory); + if (fd < 0) return 1; + int ok = !nonblock(fd); + if (ok && connect(fd, (struct sockaddr *)&address, sizeof(address)) && errno != EINPROGRESS) ok = 0; + if (ok) ok = ready(fd, POLLOUT, deadline) && peer(fd); + if (ok) ok = transfer(fd, b.bytes, b.size, 1, deadline) && receive(fd, &b, deadline) + && integer(b.bytes + 6, 2) == 4 && integer(b.bytes + 8, 8) != 0 && integer(b.bytes + 16, 8) != 0; + close(fd); + if (ok && op == 2) { + size_t length = 0; + for (size_t i = HEADER; i < b.size;) { + unsigned key = (unsigned)integer(b.bytes + i, 2), count = (unsigned)integer(b.bytes + i + 2, 2); i += 4; + const char *name = key == 4 ? "username=" : key == 5 ? "password=" : NULL; + if (name) { + /* Reuse the input region only after assembling in a separate bounded buffer. */ + length += strlen(name) + count + 1; + } + i += count; + } + if (length > LIMIT - 1) ok = 0; + else if (length) { + Buffer output = {0}; + for (size_t i = HEADER; i < b.size;) { + unsigned key = (unsigned)integer(b.bytes + i, 2), count = (unsigned)integer(b.bytes + i + 2, 2); i += 4; + const char *name = key == 4 ? "username=" : key == 5 ? "password=" : NULL; + if (name) { size_t n = strlen(name); memcpy(output.bytes + output.size, name, n); output.size += n; + memcpy(output.bytes + output.size, b.bytes + i, count); output.size += count; output.bytes[output.size++] = '\n'; } + i += count; + } + output.bytes[output.size++] = '\n'; ok = credential_output(output.bytes, output.size, deadline); wipe(&output, sizeof(output)); + } + } + wipe(&b, sizeof(b)); return ok ? 0 : 1; +} +static void close_client(Client *c) { + if (c->fd >= 0) close(c->fd); wipe(c, sizeof(*c)); c->fd = -1; +} +static int flush(int fd, Buffer *b) { + if (b->offset == b->size) { b->offset = b->size = 0; return 1; } + ssize_t n = write(fd, b->bytes + b->offset, b->size - b->offset); + if (n > 0) { wipe(b->bytes + b->offset, (size_t)n); b->offset += (size_t)n; return 1; } + return n < 0 && (errno == EAGAIN || errno == EINTR); +} +static int relay(const char *dir, const char *generation_text) { + char *end; errno = 0; uint64_t generation = strtoull(generation_text, &end, 10); + if (!generation || errno || *end || generation_text[0] == '-') return 2; + struct sockaddr_un address; int directory = private_dir(dir); + if (directory < 0 || !endpoint(dir, &address)) { if (directory >= 0) close(directory); return 1; } + /* Hold a per-directory advisory lock for our entire lifetime, including cleanup. */ + int lock = openat(directory, "relay.lock", O_CREAT | O_RDWR | O_NOFOLLOW | O_CLOEXEC, 0600); + struct stat lock_stat; struct flock lock_request = {.l_type = F_WRLCK, .l_whence = SEEK_SET}; + if (lock < 0 || fstat(lock, &lock_stat) || !S_ISREG(lock_stat.st_mode) || lock_stat.st_uid != geteuid() + || (lock_stat.st_mode & 0777) != 0600 || lock_stat.st_nlink != 1 || fcntl(lock, F_SETLK, &lock_request)) { + if (lock >= 0) close(lock); close(directory); return 1; + } + Buffer host = {0}, sending = {0}; header(&sending, 1, 0, generation, 4 + strlen(PLATFORM)); + put(sending.bytes + HEADER, 6, 2); put(sending.bytes + HEADER + 2, strlen(PLATFORM), 2); + memcpy(sending.bytes + HEADER + 4, PLATFORM, strlen(PLATFORM)); + int64_t deadline = now_ms() + DEADLINE; + int ok = !nonblock(0) && !nonblock(1) && transfer(1, sending.bytes, sending.size, 1, deadline) + && receive(0, &host, deadline) && host.size == sending.size && !memcmp(host.bytes, sending.bytes, host.size); + sending.size = sending.offset = 0; host.size = 0; + int listener = -1, created = 0; struct stat bound = {0}, stale; + if (ok && !fstatat(directory, "credential.sock", &stale, AT_SYMLINK_NOFOLLOW)) { + if (!owned_socket(directory, &stale) || unlinkat(directory, "credential.sock", 0)) ok = 0; + } else if (ok && errno != ENOENT) ok = 0; + if (ok) { + listener = socket(AF_UNIX, SOCK_STREAM, 0); + if (listener < 0 || bind(listener, (struct sockaddr *)&address, sizeof(address))) ok = 0; + else { created = 1; ok = owned_socket(directory, &bound) && !listen(listener, CLIENTS) && !nonblock(listener); } + } + Client *clients = calloc(CLIENTS, sizeof(Client)); + if (!clients) ok = 0; else for (int i = 0; i < CLIENTS; i++) clients[i].fd = -1; + uint64_t next_id = 1; int result = ok ? 0 : 1; + while (ok && !stopped) { + struct pollfd fds[CLIENTS + 3]; + fds[0] = (struct pollfd){0, POLLIN, 0}; + fds[1] = (struct pollfd){1, sending.size ? POLLOUT : 0, 0}; + fds[2] = (struct pollfd){listener, POLLIN, 0}; + for (int i = 0; i < CLIENTS; i++) fds[i + 3] = (struct pollfd){clients[i].fd, + clients[i].output.size ? POLLOUT : clients[i].waiting ? 0 : POLLIN, 0}; + if (poll(fds, CLIENTS + 3, 100) < 0) { if (errno == EINTR) continue; result = 1; break; } + if (fds[0].revents & (POLLIN | POLLHUP | POLLERR)) { + ssize_t n = read(0, host.bytes + host.size, LIMIT - host.size); + if (n == 0) break; + if (n < 0 && errno != EAGAIN && errno != EINTR) { result = 1; break; } + if (n > 0) host.size += (size_t)n; + int size; + while ((size = frame_size(&host)) > 0) { + if (integer(host.bytes + 6, 2) != 4) { ok = 0; break; } + if (integer(host.bytes + 16, 8) == generation) { + uint64_t id = integer(host.bytes + 8, 8); + for (int i = 0; i < CLIENTS; i++) if (clients[i].fd >= 0 && clients[i].waiting && clients[i].id == id && !clients[i].output.size) { + memcpy(clients[i].output.bytes, host.bytes, (size_t)size); clients[i].output.size = (size_t)size; break; + } + } + memmove(host.bytes, host.bytes + size, host.size - (size_t)size); host.size -= (size_t)size; + } + if (size < 0 || !ok) { result = 1; break; } + } + if (fds[1].revents & (POLLERR | POLLHUP | POLLNVAL)) { result = 1; break; } + if (sending.size && (fds[1].revents & POLLOUT) && !flush(1, &sending)) { result = 1; break; } + if (fds[2].revents & POLLIN) { + int fd = accept(listener, NULL, NULL); + if (fd >= 0) { + int slot = -1; for (int i = 0; i < CLIENTS; i++) if (clients[i].fd < 0) { slot = i; break; } + if (slot < 0 || !peer(fd) || nonblock(fd)) close(fd); + else { clients[slot].fd = fd; clients[slot].deadline = now_ms() + DEADLINE; } + } + } + for (int i = 0; i < CLIENTS; i++) { + Client *c = &clients[i]; if (c->fd < 0) continue; + if (now_ms() >= c->deadline || (fds[i + 3].revents & (POLLERR | POLLNVAL | POLLHUP))) { close_client(c); continue; } + if (c->output.size && (fds[i + 3].revents & POLLOUT)) { + if (!flush(c->fd, &c->output) || c->output.offset == c->output.size) close_client(c); + continue; + } + if (!c->waiting && (fds[i + 3].revents & (POLLIN | POLLHUP))) { + ssize_t n = read(c->fd, c->input.bytes + c->input.size, LIMIT - c->input.size); + if (!n || (n < 0 && errno != EAGAIN && errno != EINTR)) { close_client(c); continue; } + if (n > 0) c->input.size += (size_t)n; + } + if (!c->waiting && c->input.size) { + int size = frame_size(&c->input); + if (size < 0 || (size > 0 && ((size_t)size != c->input.size || integer(c->input.bytes + 8, 8) + || integer(c->input.bytes + 16, 8) || (integer(c->input.bytes + 6, 2) != 2 && integer(c->input.bytes + 6, 2) != 3)))) { close_client(c); continue; } + if (size > 0 && !sending.size) { + if (!next_id) { ok = 0; result = 1; break; } + c->id = next_id++; put(c->input.bytes + 8, c->id, 8); put(c->input.bytes + 16, generation, 8); + memcpy(sending.bytes, c->input.bytes, (size_t)size); sending.size = (size_t)size; sending.offset = 0; + wipe(&c->input, sizeof(c->input)); c->waiting = 1; + } + } + } + } + if (clients) { for (int i = 0; i < CLIENTS; i++) close_client(&clients[i]); free(clients); } + if (listener >= 0) close(listener); + struct stat current; + if (created && owned_socket(directory, ¤t) && current.st_dev == bound.st_dev && current.st_ino == bound.st_ino) + (void)unlinkat(directory, "credential.sock", 0); + wipe(&host, sizeof(host)); wipe(&sending, sizeof(sending)); close(lock); close(directory); return result; +} +int main(int argc, char **argv) { + umask(0077); signal(SIGPIPE, SIG_IGN); signal(SIGTERM, stop_signal); signal(SIGINT, stop_signal); + if (argc == 2 && !strcmp(argv[1], "--help")) { + puts("Private adevcontainer Git relay (protocol 1).\nUsage: relay relay PRIVATE_DIRECTORY GENERATION\n relay client PRIVATE_DIRECTORY get|store|erase\nCredentials are accepted only on stdin; directory must be owned by the current user, mode 0700."); return 0; + } + if (argc != 4) { fputs("Invalid private relay invocation; use --help for the internal protocol contract.\n", stderr); return 2; } + if (!strcmp(argv[1], "relay")) return relay(argv[2], argv[3]); + if (!strcmp(argv[1], "client")) return client(argv[2], argv[3]); + fputs("Unknown private relay mode; use --help.\n", stderr); return 2; +} diff --git a/README.md b/README.md index 111b763..e4dd343 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Apple Dev Container CLI (adevcontainer) [![CI](https://github.com/wcgomes/apple-devcontainers/actions/workflows/ci.yml/badge.svg)](https://github.com/wcgomes/apple-devcontainers/actions/workflows/ci.yml) -[![tests](https://img.shields.io/badge/tests-1176%2B-brightgreen)](https://github.com/wcgomes/apple-devcontainers) +[![tests](https://img.shields.io/badge/tests-1202%2B-brightgreen)](https://github.com/wcgomes/apple-devcontainers) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) Native Swift CLI that reads `devcontainer.json` and runs development environments on Apple [`container`](https://github.com/apple/container). @@ -103,7 +103,7 @@ Apple upgrades wipe the plugin directory. PATH `adevcontainer` survives. Restage ### Interactive picker -The `start`, `refresh-credentials`, `exec`, `stop`, `delete`, `purge`, `rebuild`, and `inspect` commands open an interactive picker (picklist) in an interactive TTY when `--name` is not provided and multiple managed containers exist. In a non-interactive terminal, `--name ` is required in this case. +The `start`, `exec`, `stop`, `delete`, `purge`, `rebuild`, and `inspect` commands open an interactive picker (picklist) in an interactive TTY when `--name` is not provided and multiple managed containers exist. In a non-interactive terminal, `--name ` is required in this case. | Command | Purpose | | --- | --- | @@ -114,8 +114,7 @@ The `start`, `refresh-credentials`, `exec`, `stop`, `delete`, `purge`, `rebuild` | `adevcontainer clone [--vscode] [--resume ]` | Clone a repository into a named volume and start its dev container | | `adevcontainer exec [-it] [--name ] [--] [cmd…]` | Open a shell or run a command in a running managed container | | `adevcontainer list [--json]` | List managed dev containers | -| `adevcontainer start [--name ] [--vscode] [--json]` | Start a container by name or with the interactive picker. A real start refreshes git credentials; already running does not | -| `adevcontainer refresh-credentials [--name ]` | Refresh git credentials in a running managed container from the host | +| `adevcontainer start [--name ] [--vscode] [--json]` | Start a managed container; prepare or repair automatic Git credentials, including running reuse | | `adevcontainer stop [--name ]` | Stop a container by name or with the interactive picker | | `adevcontainer inspect [--name ]` | Inspect a container by name or with the interactive picker | | `adevcontainer delete [--name ]` | Remove a container, keeping its managed volumes | @@ -124,6 +123,59 @@ The `start`, `refresh-credentials`, `exec`, `stop`, `delete`, `purge`, `rebuild` | `adevcontainer help []` | Show main usage or per-command help | | `adevcontainer version [--version]` | Print the CLI version | +### Automatic Git credentials + +For discovered HTTP(S) Git destinations, lifecycle commands connect the resolved +container user to the existing host credential provider before hooks; clone does +so before its single in-container full clone. Credentials travel on private +exec/socket channels, not guest token stores, argv or product-created environment +variables. Git/GCM interaction on the host is disabled and acquisition is bounded. + +The same installed CLI starts a per-user broker process on demand, without sudo, +before the first credential-dependent lifecycle work. It runs in its own session, +survives the invoking CLI, and exits after five seconds without demand. Nothing is +installed and nothing restarts it: a crash is recovered by the next `up`, `start`, +clone, or rebuild. The next activation reuses a broker from the same executable and +gracefully replaces one from an older build. Registrations on disk contain +identity/destinations, not credentials; explicit host token overrides live only in +memory and require re-registration after the broker exits. No separate guest package, +Python, socat, GCM, translator or compiler is installed at runtime. + +Authorization binds the discovered scheme/hostname/port, preserving sibling paths. +Legacy HTTP is retained only when discovered; HTTPS never authorizes HTTP and no +unsafe-HTTP provider option is enabled. Azure retains scheme-specific helper and +useHttpPath configuration and receives the actual queried repository path. +Earlier user/legacy helpers remain in order and may answer first; those answers +are outside the product helper's freshness guarantee. Unmarked legacy clone stores +are not deleted. The identified old product-owned seed store is migrated away. + +`start` and `up` on an already-running container repair the automatic +connection without fetch/pull/push or workspace writes. They do not clear +rejection suppression. To clear that finite block, stop the container and +start it again. A provider miss remains a miss. If login is needed, authenticate +on the host and retry. An unchanged token does not prove OAuth renewal. +Replacing or moving the executable is detected by identity. The next ordinary +lifecycle invocation compares the running broker's executable device/inode and +build identity with the invoking executable, retires a mismatched owner, and +starts a successor from the current executable. + +### Source builds + +On macOS 26+ Apple Silicon with Swift/CLT, curl, tar/xz and SHA-256 tools: + +```sh +sh scripts/build-guest-credential-relay.sh +swift build -c release --product adevcontainer +``` + +Preparation downloads only the official checksum-pinned Zig 0.15.2 toolchain into +`.build/toolchains/zig-0.15.2/` (initial HTTPS access to ziglang.org is required). +Every use verifies the archive and extracts a fresh private compiler/sysroot; +installed Zig is not consulted. Both static linux/arm64 and linux/amd64 helpers +are verified and embedded in generated, gitignored Swift source. Build outputs +and toolchains are not runtime dependencies. Source/CI builds must prepare these +assets first; releases remain one relocatable CLI plus license notices. + ## Bring-up recovery When `up` or `clone` cannot bring up a container after an editable `devcontainer.json` exists, diff --git a/Sources/ADevContainerLib/Commands/CloneCommand.swift b/Sources/ADevContainerLib/Commands/CloneCommand.swift index 4a3bd71..86d5e2e 100644 --- a/Sources/ADevContainerLib/Commands/CloneCommand.swift +++ b/Sources/ADevContainerLib/Commands/CloneCommand.swift @@ -605,6 +605,17 @@ public enum CloneCommand { } } + var credentialSessionCompleted = false + var credentialSessionUnregistered = false + func unregisterCredentials() { + guard urlKind == .https, !credentialSessionUnregistered else { return } + credentialSessionUnregistered = true + GitCredentialLifecycle.unregisterSoft(id: id, runtime: runtime) + } + defer { + if !credentialSessionCompleted { unregisterCredentials() } + } + if urlKind == .https { GitCredentialLifecycle.setupSoft(id: id, hostWorkspace: nil, gitURL: url, runtime: runtime, credentials: credentials) } // 10. Full clone INSIDE container, unless reuse is keeping an existing tree. let keepExistingTree: Bool if volumeDisposition == .reuse { @@ -616,6 +627,7 @@ public enum CloneCommand { runtime: runtime ) } catch { + unregisterCredentials() try? runtime.delete(nameOrId: id, force: true) cleanupCreatedWorkspaceVolume() throw BringUpRecovery.eligible(error) @@ -626,18 +638,6 @@ public enum CloneCommand { if keepExistingTree { StatusPrinter.status("Keeping existing workspace tree", item: identity.workspaceVolumeName) - // Reuse skips populate. Only Azure needs a fresh URL-scoped helper; - // other HTTPS hosts keep whatever local store the earlier clone wrote. - if Self.isAzureDevOpsHTTPS(url) { - Self.seedGuestGitCredentials( - url: url, - prepared: nil, - containerId: id, - connectionUser: connectionUser, - credentials: credentials, - runtime: runtime - ) - } } else { StatusPrinter.status("Populating workspace volume") do { @@ -648,10 +648,10 @@ public enum CloneCommand { workspaceFolder: effectiveConfig.workspaceFolder, remoteUser: connectionUser, containerEnv: effectiveConfig.containerEnv, - credentials: credentials, runtime: runtime ) } catch { + unregisterCredentials() try? runtime.delete(nameOrId: id, force: true) cleanupCreatedWorkspaceVolume() if let cli = error as? CLIError { @@ -677,6 +677,7 @@ public enum CloneCommand { ? try runtime.pathExistsInContainer(containerId: id, path: dest) : true } catch { + unregisterCredentials() try? runtime.delete(nameOrId: id, force: true) cleanupCreatedWorkspaceVolume() throw BringUpRecovery.eligible(error) @@ -692,6 +693,7 @@ public enum CloneCommand { runtime: runtime ) } catch { + unregisterCredentials() try? runtime.delete(nameOrId: id, force: true) cleanupCreatedWorkspaceVolume() if let cli = error as? CLIError { @@ -713,6 +715,7 @@ public enum CloneCommand { do { applyAuthor = try runtime.pathExistsInContainer(containerId: id, path: gitDir) } catch { + unregisterCredentials() try? runtime.delete(nameOrId: id, force: true) cleanupCreatedWorkspaceVolume() throw BringUpRecovery.eligible(error) @@ -803,6 +806,7 @@ public enum CloneCommand { throw BringUpRecovery.eligible(error) } if let readyError { throw readyError } + credentialSessionCompleted = true return result } @@ -1337,7 +1341,6 @@ public enum CloneCommand { workspaceFolder: String, remoteUser: String?, containerEnv: [String: String], - credentials: any GitCredentialProviding, runtime: AppleContainerRuntime ) throws { // Normalize URL for clone argv (strip embedded userinfo from scheme:// so @@ -1347,38 +1350,24 @@ public enum CloneCommand { let effectiveCloneURL: String = { switch urlKind { case .https: - // Always use credential-free URL; auth via ASKPASS/env. + // Always use credential-free URL; authentication uses the shared helper. return cloneURL case .ssh, .other: return url } }() - // HTTPS: prefer host credentials when available (private repos). Public repos - // succeed anonymously when fill returns nil. - var httpsCreds: GitHTTPSCredentials? - if urlKind == .https { - httpsCreds = try credentials.fillHTTPS(url: url) - } - - var execEnv = containerEnv + var execEnv = HostGitCredential.strippingCredentialTraces(containerEnv) execEnv["GIT_TERMINAL_PROMPT"] = "0" execEnv["ADEV_CLONE_URL"] = effectiveCloneURL execEnv["ADEV_CLONE_WORKDIR"] = workspaceFolder - let azure = Self.isAzureDevOpsHTTPS(url) - if let creds = httpsCreds { - execEnv["ADEV_CLONE_USER"] = creds.username - execEnv["ADEV_CLONE_PASS"] = creds.password - if !azure, let fields = GitURLClassifier.httpsCredentialFields(for: effectiveCloneURL) { - execEnv["ADEV_CLONE_PROTO"] = fields.protocolName - execEnv["ADEV_CLONE_HOST"] = fields.host - } + execEnv["GCM_INTERACTIVE"] = "Never" + execEnv["GCM_GUI_PROMPT"] = "0" + if urlKind == .https { + execEnv["GIT_ASKPASS"] = "/bin/false" + execEnv["SSH_ASKPASS"] = "/bin/false" } - - let script = inContainerCloneScript( - useHTTPSAskpass: httpsCreds != nil, - configureStore: httpsCreds != nil && !azure - ) + let script = inContainerCloneScript() // Live-tee populate git clone like lifecycle hooks / Features build (framed tool lines). let result = try runtime.exec( nameOrId: containerId, @@ -1395,19 +1384,7 @@ public enum CloneCommand { result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) ].filter { !$0.isEmpty }.joined(separator: " | ") detail = HostGitClient.redactURLUserinfo(in: detail, originalURL: url) - if let user = httpsCreds?.username { - detail = detail.replacingOccurrences(of: user, with: "***") - } - // Never include password material if git echoed it. - if let pass = httpsCreds?.password, !pass.isEmpty { - detail = detail.replacingOccurrences(of: pass, with: "***") - } - let httpsHint: String = { - if httpsCreds == nil { - return "Private HTTPS repos need host git credentials (credential.helper / ADEVCONTAINER_GIT_TOKEN) or an SSH URL; ensure in-container git is installed" - } - return "Ensure host HTTPS credentials are valid and in-container git is installed" - }() + let httpsHint = "Private HTTP(S) repositories need valid host credentials: sign in using the host credential provider or use SSH; ensure in-container Git is installed" throw CLIError( code: CLIErrorCode.populateFailed, message: "In-container git clone failed (exit \(result.exitCode))" @@ -1438,69 +1415,11 @@ public enum CloneCommand { ) } - if azure, let creds = httpsCreds { - seedGuestGitCredentials( - url: url, - prepared: creds, - containerId: containerId, - connectionUser: remoteUser, - credentials: credentials, - runtime: runtime - ) - } } - private static func isAzureDevOpsHTTPS(_ url: String) -> Bool { - guard let fields = GitURLClassifier.httpsCredentialFields(for: url) else { return false } - return GuestGitCredentialSeed.isAzureDevOpsHost(fields.host) - } - - /// Azure-only guest helper. Non-Azure clone keeps local `credential.helper store`. - /// `prepared` is the credential populate already filled; nil fills again (reuse). - /// Soft-fail: a seed error must not delete a cloned workspace. - private static func seedGuestGitCredentials( - url: String, - prepared: GitHTTPSCredentials?, - containerId: String, - connectionUser: String?, - credentials: any GitCredentialProviding, - runtime: AppleContainerRuntime - ) { - guard isAzureDevOpsHTTPS(url) else { return } - let seeder = GuestGitCredentialSeed( - credentials: credentials, - runner: LifecycleRunner.hostProcessRunnerOverride ?? FoundationProcessRunner() - ) - do { - if let prepared { - try seeder.installHTTPSCredential( - containerId: containerId, - url: url, - credentials: prepared, - connectionUser: connectionUser, - runtime: runtime - ) - } else { - try seeder.seed( - containerId: containerId, - hostWorkspace: nil, - gitURL: url, - connectionUser: connectionUser, - runtime: runtime - ) - } - } catch { - StatusPrinter.warning( - "Git credential seeding failed; continuing without forwarded credentials: \(error.localizedDescription)" - ) - } - } - - /// Portable sh script: clone into workspace (handles lost+found-only volumes). - /// Credentials (when set) arrive via env ADEV_CLONE_USER / ADEV_CLONE_PASS — never printed. - /// Non-Azure HTTPS stores them with local `credential.helper store`. Azure does not: - /// `useHttpPath` makes a pathless store miss, so the caller seeds a URL-scoped helper. - private static func inContainerCloneScript(useHTTPSAskpass: Bool, configureStore: Bool) -> String { + /// One full-clone mechanism. Authentication is on demand; no seed/approve, + /// credential environment or persistent token helper is created by clone. + private static func inContainerCloneScript() -> String { // shellcheck-style: set -e; use env vars only. var lines: [String] = [ "set -e", @@ -1510,30 +1429,12 @@ public enum CloneCommand { "export GIT_TERMINAL_PROMPT=0", ] - if useHTTPSAskpass { - lines += [ - // One-shot ASKPASS: reads username/password from env at prompt time. - "ASKPASS=\"$(mktemp)\"", - "trap 'rm -f \"$ASKPASS\"' EXIT", - "cat > \"$ASKPASS\" <<'ASKEOF'", - "#!/bin/sh", - "case \"$1\" in", - " *[Uu]sername*) printf '%s\\n' \"$ADEV_CLONE_USER\" ;;", - " *) printf '%s\\n' \"$ADEV_CLONE_PASS\" ;;", - "esac", - "ASKEOF", - "chmod 700 \"$ASKPASS\"", - "export GIT_ASKPASS=\"$ASKPASS\"", - "export SSH_ASKPASS=\"$ASKPASS\"", - "export GIT_ASKPASS_REQUIRE=force", - ] - } - // Clone into a temp dir on the same volume, then move into WS. // Handles empty mounts and lost+found-only ext volumes. lines += [ "TMP=\"$WS/.adev-clone-tmp-$$\"", "rm -rf \"$TMP\"", + "unset GIT_TRACE GIT_TRACE_PACKET GIT_TRACE_PERFORMANCE GIT_TRACE_SETUP GIT_CURL_VERBOSE GCM_TRACE GH_DEBUG GH_FORCE_TTY GCM_DEBUG", "git clone -- \"$URL\" \"$TMP\"", // Move all entries including dotfiles except . and .. "ls -A \"$TMP\" | while IFS= read -r f; do", @@ -1542,23 +1443,6 @@ public enum CloneCommand { "rmdir \"$TMP\" 2>/dev/null || rm -rf \"$TMP\"", ] - if configureStore { - lines += [ - "git -C \"$WS\" config credential.helper store", - "if [ -n \"${ADEV_CLONE_PROTO:-}\" ] && [ -n \"${ADEV_CLONE_HOST:-}\" ]; then", - " printf 'protocol=%s\\nhost=%s\\nusername=%s\\npassword=%s\\n\\n' \\", - " \"$ADEV_CLONE_PROTO\" \"$ADEV_CLONE_HOST\" \"$ADEV_CLONE_USER\" \"$ADEV_CLONE_PASS\" \\", - " | git -C \"$WS\" credential approve", - "fi", - ] - } - if useHTTPSAskpass { - lines += [ - "rm -f \"$ASKPASS\"", - "trap - EXIT", - ] - } - return lines.joined(separator: "\n") } diff --git a/Sources/ADevContainerLib/Commands/DeleteCommand.swift b/Sources/ADevContainerLib/Commands/DeleteCommand.swift index b0cdcee..983e851 100644 --- a/Sources/ADevContainerLib/Commands/DeleteCommand.swift +++ b/Sources/ADevContainerLib/Commands/DeleteCommand.swift @@ -14,6 +14,7 @@ public enum DeleteCommand { picker: picker ) StatusPrinter.status("Deleting container", item: info.id) + GitCredentialLifecycle.unregisterSoft(id: info.id, runtime: runtime) try runtime.delete(nameOrId: info.id, force: true) print("Deleted \(info.id)") } diff --git a/Sources/ADevContainerLib/Commands/LifecycleRunner.swift b/Sources/ADevContainerLib/Commands/LifecycleRunner.swift index e57b8c4..c946d3a 100644 --- a/Sources/ADevContainerLib/Commands/LifecycleRunner.swift +++ b/Sources/ADevContainerLib/Commands/LifecycleRunner.swift @@ -152,6 +152,7 @@ public enum LifecycleRunner { if let failed = named.first(where: { state.errors[$0.name] != nil }), let error = state.errors[failed.name] { if failurePolicy == .deleteContainerThenFail { + GitCredentialLifecycle.unregisterSoft(id: containerId, runtime: runtime) try? runtime.delete(nameOrId: containerId, force: true) } throw error @@ -169,16 +170,23 @@ public enum LifecycleRunner { emitRunningStatus(property) // Stream hook logs live (teed to host stderr) so long postCreate/etc scripts do not // look stuck. Capture is retained for failure diagnostics; QUIET only silences status. + var environment = HostGitCredential.strippingCredentialTraces(config.containerEnv) + environment["GIT_TERMINAL_PROMPT"] = "0" + environment["GCM_INTERACTIVE"] = "Never" + environment["GCM_GUI_PROMPT"] = "0" + environment["GIT_ASKPASS"] = "/bin/false" + environment["SSH_ASKPASS"] = "/bin/false" let execResult = try runtime.exec( nameOrId: containerId, command: command.execArguments, user: config.connectionUser, workdir: config.workspaceFolder, - env: config.containerEnv, + env: environment, streamOutput: true ) guard execResult.succeeded else { if failurePolicy == .deleteContainerThenFail { + GitCredentialLifecycle.unregisterSoft(id: containerId, runtime: runtime) try? runtime.delete(nameOrId: containerId, force: true) } throw lifecycleError(property: property, execResult: execResult) diff --git a/Sources/ADevContainerLib/Commands/PurgeCommand.swift b/Sources/ADevContainerLib/Commands/PurgeCommand.swift index 71cd573..c5a838b 100644 --- a/Sources/ADevContainerLib/Commands/PurgeCommand.swift +++ b/Sources/ADevContainerLib/Commands/PurgeCommand.swift @@ -64,6 +64,7 @@ public enum PurgeCommand { // 1. Container (missing is OK after resolve — should exist, but treat delete failures carefully) if let container = target.container { StatusPrinter.status("Deleting container", item: container.id) + GitCredentialLifecycle.unregisterSoft(id: container.id, runtime: runtime) do { try runtime.delete(nameOrId: container.id, force: true) print("Removed container \(container.id)") diff --git a/Sources/ADevContainerLib/Commands/RebuildCommand.swift b/Sources/ADevContainerLib/Commands/RebuildCommand.swift index 2291573..81901e1 100644 --- a/Sources/ADevContainerLib/Commands/RebuildCommand.swift +++ b/Sources/ADevContainerLib/Commands/RebuildCommand.swift @@ -711,11 +711,13 @@ public enum RebuildCommand { ?? (selected.name != selected.id ? try runtime.findByName(selected.name) : nil) { StatusPrinter.status("Deleting container", item: existing.id) + GitCredentialLifecycle.unregisterSoft(id: existing.id, runtime: runtime) try runtime.delete(nameOrId: existing.id, force: true) } else if selectedOverride != nil || bindRecoveryEligible || volumeResume != nil { StatusPrinter.status("Old container already removed; continuing rebuild") } else { StatusPrinter.status("Deleting container", item: selected.id) + GitCredentialLifecycle.unregisterSoft(id: selected.id, runtime: runtime) try runtime.delete(nameOrId: selected.id, force: true) } crossedDeleteBoundary = true @@ -987,25 +989,7 @@ public enum RebuildCommand { return try handleOwnershipFailure(error) } - // Forward host git credentials into the new container's store before hooks. - // Bind mode enumerates host remotes; volume mode seeds the stamped git_url. - // Credential seeding remains soft-fail: warn and continue, never delete. - do { - try GuestGitCredentialSeed( - credentials: credentials, - runner: LifecycleRunner.hostProcessRunnerOverride ?? FoundationProcessRunner() - ).seed( - containerId: id, - hostWorkspace: isVolumeMode ? nil : stampedLocalFolder(labels), - gitURL: isVolumeMode ? labels[ContainerIdentity.labelGitURL] : nil, - connectionUser: effectiveConfig.connectionUser, - runtime: runtime - ) - } catch { - StatusPrinter.warning( - "Git credential seeding failed; continuing without forwarded credentials: \(error.localizedDescription)" - ) - } + GitCredentialLifecycle.setupSoft(id: id, hostWorkspace: isVolumeMode ? nil : stampedLocalFolder(labels), gitURL: isVolumeMode ? labels[ContainerIdentity.labelGitURL] : nil, runtime: runtime, credentials: credentials) if capturedLocalIdentity.isComplete { do { diff --git a/Sources/ADevContainerLib/Commands/RecoveryOrchestrator.swift b/Sources/ADevContainerLib/Commands/RecoveryOrchestrator.swift index 0a8c84f..797a7da 100644 --- a/Sources/ADevContainerLib/Commands/RecoveryOrchestrator.swift +++ b/Sources/ADevContainerLib/Commands/RecoveryOrchestrator.swift @@ -377,6 +377,7 @@ public enum RecoveryOrchestrator { ) throws { if let containerID { if let existing = try runtime.findByName(containerID) { + GitCredentialLifecycle.unregisterSoft(id: existing.id, runtime: runtime) try runtime.delete(nameOrId: existing.id, force: true) } } @@ -393,8 +394,10 @@ public enum RecoveryOrchestrator { // (start failures may leave a container that list has not yet reflected in tests, // and LifecycleRunner may already have removed a hook-failed container). if let existing = try runtime.findByName(containerID) { + GitCredentialLifecycle.unregisterSoft(id: existing.id, runtime: runtime) try runtime.delete(nameOrId: existing.id, force: true) } else { + GitCredentialLifecycle.unregisterSoft(id: containerID, runtime: runtime) try? runtime.delete(nameOrId: containerID, force: true) } } diff --git a/Sources/ADevContainerLib/Commands/RefreshCredentialsCommand.swift b/Sources/ADevContainerLib/Commands/RefreshCredentialsCommand.swift deleted file mode 100644 index 6101dec..0000000 --- a/Sources/ADevContainerLib/Commands/RefreshCredentialsCommand.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Foundation - -public enum RefreshCredentialsCommand { - /// Refresh git credentials in an already-running managed container from host - /// `git credential fill`. Does not start, stop, fetch, pull, push, or write the - /// workspace. Credential material enters the guest via stdin only. - public static func run( - name: String? = nil, - runtime: AppleContainerRuntime, - picker: InteractivePicker = .default, - credentials: any GitCredentialProviding = HostGitCredential() - ) throws { - let info = try ManagedContainers.resolveSelection( - name: name, - runtime: runtime, - picker: picker - ) - guard info.isRunning else { - throw CLIError( - code: CLIErrorCode.containerNotRunning, - message: "Container \(info.name) is not running (state: \(info.state))", - hint: "Run '\(CommandSurface.commandPrefix) start --name \(info.name)' to start it and refresh credentials" - ) - } - - StatusPrinter.status("Refreshing git credentials", item: info.name) - let refreshed: Bool - do { - refreshed = try GuestGitCredentialSeed( - credentials: credentials, - runner: LifecycleRunner.hostProcessRunnerOverride ?? FoundationProcessRunner() - ).seedFromLabels( - containerId: info.id, - labels: info.labels, - connectionUser: GuestGitCredentialSeed.connectionUser(from: info.labels), - runtime: runtime - ) - } catch let error as CLIError { - throw CLIError( - code: error.code, - message: "Failed to refresh git credentials in the container (\(error.message))", - hint: error.hint - ) - } - if refreshed { - StatusPrinter.status("Git credentials refreshed", item: info.name) - } else { - StatusPrinter.status("No host git credentials to refresh", item: info.name) - } - } -} diff --git a/Sources/ADevContainerLib/Commands/StartCommand.swift b/Sources/ADevContainerLib/Commands/StartCommand.swift index 5266a4e..d1ed4ec 100644 --- a/Sources/ADevContainerLib/Commands/StartCommand.swift +++ b/Sources/ADevContainerLib/Commands/StartCommand.swift @@ -44,6 +44,7 @@ public enum StartCommand { if info.isRunning { StatusPrinter.status("Container already running", item: info.id) + GitCredentialLifecycle.setupSoft(id: info.id, labels: info.labels, runtime: runtime, credentials: credentials) try openAndPostAttach( options: options, nameOrId: info.id, @@ -85,13 +86,7 @@ public enum StartCommand { } // Stopped → running only. Already-running returned above. Soft-fail: a bad // refresh must not fail start, delete the container, or enter recovery. - GuestGitCredentialSeed.refreshSoft( - containerId: info.id, - labels: info.labels, - connectionUser: GuestGitCredentialSeed.connectionUser(from: info.labels), - runtime: runtime, - credentials: credentials - ) + GitCredentialLifecycle.setupSoft(id: info.id, labels: info.labels, runtime: runtime, credentials: credentials) // Remelt after start for postStart/postAttach. Never run initialize here: // volume config becoming readable must not violate initialize-before-start. if hooksConfig == nil { diff --git a/Sources/ADevContainerLib/Commands/StopCommand.swift b/Sources/ADevContainerLib/Commands/StopCommand.swift index 6b88820..a912026 100644 --- a/Sources/ADevContainerLib/Commands/StopCommand.swift +++ b/Sources/ADevContainerLib/Commands/StopCommand.swift @@ -13,6 +13,7 @@ public enum StopCommand { picker: picker ) + GitCredentialLifecycle.unregisterSoft(id: info.id, runtime: runtime) if !info.isRunning { print("Container \(info.id) already stopped") return diff --git a/Sources/ADevContainerLib/Commands/UpCommand.swift b/Sources/ADevContainerLib/Commands/UpCommand.swift index bf40594..a166785 100644 --- a/Sources/ADevContainerLib/Commands/UpCommand.swift +++ b/Sources/ADevContainerLib/Commands/UpCommand.swift @@ -209,6 +209,7 @@ public enum UpCommand { { StatusPrinter.status("Replacing container", item: existingContainer.name) do { + GitCredentialLifecycle.unregisterSoft(id: existingContainer.id, runtime: runtime) try runtime.delete(nameOrId: existingContainer.id, force: true) } catch { throw BringUpRecovery.eligible( @@ -231,6 +232,7 @@ public enum UpCommand { } else if existing.isRunning { // Reuse running: no feature fetch/build; settings+extensions repair on marker drift; postAttach gated after open. StatusPrinter.status("Reusing running container", item: existing.name) + GitCredentialLifecycle.setupSoft(id: existing.id, labels: existing.labels, runtime: runtime, credentials: credentials) let reuseConfig = configForReuse(resolved.config, labels: existing.labels) _ = VSCodeCustomizationsApply.applySettingsIfNeeded( containerId: existing.id, @@ -258,13 +260,7 @@ public enum UpCommand { } // Stopped → running only. Reuse of an already-running container returned // above. Soft-fail: do not fail up, delete the container, or recover. - GuestGitCredentialSeed.refreshSoft( - containerId: existing.id, - labels: existing.labels, - connectionUser: GuestGitCredentialSeed.connectionUser(from: existing.labels), - runtime: runtime, - credentials: credentials - ) + GitCredentialLifecycle.setupSoft(id: existing.id, labels: existing.labels, runtime: runtime, credentials: credentials) var reuseConfig = configForReuse(resolved.config, labels: existing.labels) PostAttachConfigLoader.mergeFeaturePostAttach( into: &reuseConfig, @@ -522,22 +518,8 @@ public enum UpCommand { throw BringUpRecovery.eligible(error) } - // Forward host git credentials into the fresh container's store before hooks - // (fresh-create bind path only). Soft-fail: warn and continue; never delete. - do { - try GuestGitCredentialSeed( - credentials: credentials, - runner: LifecycleRunner.hostProcessRunnerOverride ?? FoundationProcessRunner() - ).seed( - containerId: id, - hostWorkspace: resolved.workspacePath, - gitURL: nil, - connectionUser: connectionUser, - runtime: runtime - ) - } catch { - StatusPrinter.warning("Git credential seeding failed; continuing without forwarded credentials: \(error.localizedDescription)") - } + // Connected before create hooks; setup failure never enters recovery. + GitCredentialLifecycle.setupSoft(id: id, hostWorkspace: resolved.workspacePath, gitURL: nil, runtime: runtime, credentials: credentials) do { try LifecycleRunner.runCreatePathThroughWaitFor( diff --git a/Sources/ADevContainerLib/Git/GitCredentialBroker.swift b/Sources/ADevContainerLib/Git/GitCredentialBroker.swift new file mode 100644 index 0000000..79a6263 --- /dev/null +++ b/Sources/ADevContainerLib/Git/GitCredentialBroker.swift @@ -0,0 +1,256 @@ +import Foundation + +protocol GitCredentialBrokerChannel: AnyObject, Sendable { + func send(_ frame: GitCredentialFrame) throws + func cancel() +} + +struct GitCredentialBrokerPorts: Sendable { + var context: @Sendable (String) throws -> GitCredentialRuntimeContext + var connect: @Sendable (GitCredentialRegistration, UInt64, @escaping @Sendable (GitCredentialFrame) -> Void, @escaping @Sendable () -> Void) throws -> any GitCredentialBrokerChannel + var acquire: @Sendable (String, String?, TimeInterval) throws -> GitHTTPSCredentials? + var observe: (@Sendable (String, TimeInterval) -> GitCredentialObservation)? = nil +} + +enum GitCredentialObservation: Equatable, Sendable { + case running(GitCredentialRuntimeContext), inactive, unknown +} + +/// Serial state, fair bounded workers, and no persisted credential material. +final class GitCredentialBroker: @unchecked Sendable { + static let idleGrace: TimeInterval = 5 + static let recheckInterval: TimeInterval = 2 + /// Overall bound for cancelling every channel on shutdown. Channels are + /// cancelled concurrently so one slow adapter cannot serialize the exit. + static let shutdownBound: TimeInterval = 1.5 + // All mutable domain state is accessed under lock. Runtime/provider work and + // channel callbacks happen outside it, so one hung adapter cannot own state. + private let lock = NSLock() + private let registry: GitCredentialBrokerRegistry + private let ports: GitCredentialBrokerPorts + private let clock: @Sendable () -> TimeInterval + private var policy = GitCredentialBrokerPolicy() + private var admission = GitCredentialAdmission() + private struct Session { + var registration: GitCredentialRegistration + var generation: UInt64 + var channel: (any GitCredentialBrokerChannel)? + var token: String? + var retryAt: TimeInterval = 0 + var failures = 0 + var validated = true + var revision: UInt64 = 0 + } + private struct Pending { let query: GitCredentialQuery; let deadline: TimeInterval } + private var sessions: [String: Session] = [:] + private var pending: [GitCredentialWork: Pending] = [:] + private var stopped = false + private var idleSince: TimeInterval? + private var generationCounter = UInt64.random(in: 1.. TimeInterval = { ProcessInfo.processInfo.systemUptime }) throws { + self.registry = registry; self.ports = ports; self.clock = clock + policy = GitCredentialBrokerPolicy(clock: clock) + for registration in try registry.load() { + sessions[registration.context.id] = .init(registration: registration, generation: 0, validated: false) + } + if sessions.isEmpty { idleSince = clock() } + } + deinit { shutdown() } + private func saveLocked() throws { try registry.save(sessions.values.map(\.registration)) } + private func nextGenerationLocked() throws -> UInt64 { + guard generationCounter < UInt64.max else { throw GitCredentialFailure.overloaded } + let generation = generationCounter; generationCounter += 1; return generation + } + func register(_ registration: GitCredentialRegistration, override: String? = nil, reset: Bool = false) throws { + guard try ports.context(registration.context.id) == registration.context, + override == nil || (!override!.isEmpty && GitCredentialProtocol.safe(override!) && override!.utf8.count <= 60000) else { throw GitCredentialFailure.invalidContext } + let id = registration.context.id + lock.lock() + guard !stopped else { lock.unlock(); throw GitCredentialFailure.transport } + idleSince = nil + let previous = sessions[id] + let sameIdentity = previous?.registration.context == registration.context + let token = override ?? (sameIdentity ? previous?.token : nil) + var registration = registration + registration.overrideRequired = registration.overrideRequired || (sameIdentity && previous?.registration.overrideRequired == true) || override != nil + guard !registration.overrideRequired || token != nil else { lock.unlock(); throw GitCredentialFailure.invalidContext } + if let previous, previous.registration == registration, previous.channel != nil, previous.token == token { + do { sessions[id]!.revision = try nextGenerationLocked() } catch { lock.unlock(); throw error } + sessions[id]!.validated = true + if reset { policy.reset(id) }; lock.unlock(); return + } + let generation: UInt64 + do { generation = try nextGenerationLocked() } catch { lock.unlock(); throw error } + policy.unregister(id); admission.cancel(session: id); pending = pending.filter { $0.key.session != id } + sessions[id] = .init(registration: registration, generation: generation, token: token, revision: generation) + do { try saveLocked() } catch { sessions.removeValue(forKey: id); lock.unlock(); previous?.channel?.cancel(); throw error } + lock.unlock(); previous?.channel?.cancel() + try connect(id: id, generation: generation) + } + func unregister(_ id: String) throws { + try unregister(id, revision: nil) + } + private func unregister(_ id: String, revision: UInt64?) throws { + lock.lock() + if let revision, sessions[id]?.revision != revision { lock.unlock(); return } + let channel = sessions.removeValue(forKey: id)?.channel + policy.unregister(id); admission.cancel(session: id); pending = pending.filter { $0.key.session != id } + updateIdleLocked() + do { try saveLocked() } catch { lock.unlock(); channel?.cancel(); throw error } + lock.unlock(); channel?.cancel(); schedule() + } + private func updateIdleLocked() { + if sessions.isEmpty && pending.isEmpty { + if idleSince == nil { idleSince = clock() } + } else { idleSince = nil } + } + /// Admission and retirement share this lock. A register losing the boundary + /// gets transport failure and must activate a successor, never a false ack. + func retireIfIdle() -> Bool { + lock.lock(); defer { lock.unlock() } + updateIdleLocked() + guard !stopped, let idleSince, clock() - idleSince >= Self.idleGrace else { return false } + stopped = true + return true + } + private func connect(id: String, generation: UInt64) throws { + lock.lock(); let snapshot = sessions[id]; lock.unlock() + guard let snapshot, snapshot.generation == generation, !snapshot.registration.overrideRequired || snapshot.token != nil else { throw GitCredentialFailure.invalidContext } + do { + guard try ports.context(id) == snapshot.registration.context else { throw GitCredentialFailure.invalidContext } + let channel = try ports.connect(snapshot.registration, generation, { [weak self] frame in + // Identity belongs to this host-established channel, never to a + // generation asserted in a payload from an obsolete peer. + guard frame.generation == generation else { return } + self?.receive(id: id, frame: frame) + }, { [weak self] in self?.disconnected(id: id, generation: generation) }) + lock.lock() + guard !stopped, sessions[id]?.generation == generation else { lock.unlock(); channel.cancel(); throw GitCredentialFailure.transport } + sessions[id]!.channel = channel; sessions[id]!.failures = 0 + policy.register(id, generation: generation, destinations: snapshot.registration.destinations) + lock.unlock() + } catch { disconnected(id: id, generation: generation); throw GitCredentialFailure.transport } + } + private func disconnected(id: String, generation: UInt64) { + lock.lock() + guard sessions[id]?.generation == generation else { lock.unlock(); return } + let channel = sessions[id]!.channel; sessions[id]!.channel = nil + sessions[id]!.failures = min(5, sessions[id]!.failures + 1) + sessions[id]!.retryAt = clock() + min(30, pow(2, Double(sessions[id]!.failures - 1))) + // Fence first, before any asynchronous late completion can run. + policy.unregister(id); admission.cancel(session: id); pending = pending.filter { $0.key.session != id } + lock.unlock(); channel?.cancel(); schedule() + } + private func receive(id: String, frame: GitCredentialFrame) { + lock.lock() + guard !stopped, let session = sessions[id], session.channel != nil, session.validated else { + let reason = stopped ? "stopped" : (sessions[id] == nil ? "no_session" : "no_channel") + GitCredentialDiag.event("broker.drop", "reason=\(reason) req=\(frame.request)") + lock.unlock(); return + } + guard policy.begin(id, generation: frame.generation, request: frame.request), let query = try? GitCredentialQuery(frame: frame) else { + GitCredentialDiag.event("broker.drop", "reason=begin_or_query req=\(frame.request) keys=\(frame.fields.count)") + lock.unlock(); return + } + let channel = session.channel! + if frame.operation == .erase { + policy.erase(id, generation: frame.generation, query: query); lock.unlock() + try? channel.send(.init(operation: .reply, request: frame.request, generation: frame.generation, fields: [:])); return + } + let work = GitCredentialWork(session: id, generation: frame.generation, request: frame.request) + guard policy.authorize(id, generation: frame.generation, query: query) != nil, admission.enqueue(work) else { + GitCredentialDiag.event("broker.miss", "req=\(frame.request) path_len=\(query.path.utf8.count)") + lock.unlock(); try? channel.send(.init(operation: .reply, request: frame.request, generation: frame.generation, fields: [:])); return + } + GitCredentialDiag.event("broker.accept", "req=\(frame.request) path_len=\(query.path.utf8.count)") + pending[work] = .init(query: query, deadline: clock() + 15); lock.unlock(); schedule() + } + private func schedule() { + lock.lock() + var jobs: [(GitCredentialWork, Pending, String, String?)] = [] + while !stopped, let work = admission.next() { + guard let request = pending[work], let session = sessions[work.session], session.generation == work.generation, + let url = policy.authorize(work.session, generation: work.generation, query: request.query) else { admission.complete(work); pending.removeValue(forKey: work); continue } + jobs.append((work, request, url, session.token)) + } + lock.unlock() + for (work, request, url, token) in jobs { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { return } + let started = clock() + let credential = clock() < request.deadline ? (try? ports.acquire(url, token, request.deadline)) : nil + let elapsed = Int((clock() - started) * 1000) + GitCredentialDiag.event("broker.acquire", "req=\(work.request) ms=\(elapsed) result=\(credential == nil ? "absent" : "present")") + complete(work, request: request, credential: credential) + } + } + } + private func complete(_ work: GitCredentialWork, request: Pending, credential: GitHTTPSCredentials?) { + lock.lock(); admission.complete(work); pending.removeValue(forKey: work) + guard !stopped, policy.current(work.session, generation: work.generation), let channel = sessions[work.session]?.channel else { lock.unlock(); schedule(); return } + let result = sessions[work.session]?.validated == true && clock() < request.deadline ? credential.flatMap { policy.deliver(work.session, generation: work.generation, query: request.query, credential: $0) } : nil + let fields: [GitCredentialField: String] = result.map { [.username: $0.username, .password: $0.password] } ?? [:] + GitCredentialDiag.event("broker.reply", "req=\(work.request) delivered=\(result == nil ? "no" : "yes")") + lock.unlock() + do { try channel.send(.init(operation: .reply, request: work.request, generation: work.generation, fields: fields)) } + catch { disconnected(id: work.session, generation: work.generation) } + schedule() + } + func reconcile() { + lock.lock(); let snapshots = sessions; let stopped = self.stopped; lock.unlock() + guard !stopped else { return } + // Share a bounded observation budget across registrations. An outage is + // unknown, not a destructive inference from a failed inspect command. + let deadline = clock() + 1 + for (id, snapshot) in snapshots { + let observation = ports.observe?(id, deadline) ?? ((try? ports.context(id)).map(GitCredentialObservation.running) ?? .unknown) + if observation == .inactive { + try? unregister(id, revision: snapshot.revision); continue + } + guard case .running(let actual) = observation else { + lock.lock() + if sessions[id]?.revision == snapshot.revision { sessions[id]!.validated = false } + lock.unlock(); continue + } + guard actual == snapshot.registration.context else { try? unregister(id, revision: snapshot.revision); continue } + lock.lock() + if sessions[id]?.revision == snapshot.revision { sessions[id]!.validated = true } + lock.unlock() + guard snapshot.channel == nil, clock() >= snapshot.retryAt, !snapshot.registration.overrideRequired || snapshot.token != nil else { continue } + lock.lock() + guard sessions[id]?.revision == snapshot.revision, sessions[id]?.generation == snapshot.generation, sessions[id]?.channel == nil else { lock.unlock(); continue } + guard let generation = try? nextGenerationLocked() else { lock.unlock(); continue } + sessions[id]!.generation = generation; sessions[id]!.revision = generation; lock.unlock() + try? connect(id: id, generation: generation) + } + } + func validate(id: String, sources: [String]) throws -> Bool { + guard sources.count <= 64 else { throw GitCredentialFailure.overloaded } + lock.lock(); let session = sessions[id]; policy.reset(id); lock.unlock() + guard let session, session.channel != nil, session.validated else { throw GitCredentialFailure.invalidContext } + let deadline = clock() + 30 + var acquired = false + for source in sources { + guard let fields = GitURLClassifier.httpsCredentialFields(for: source) else { continue } + let query = GitCredentialQuery(scheme: fields.protocolName, host: fields.host, path: fields.path) + lock.lock(); let url = policy.authorize(id, generation: session.generation, query: query); lock.unlock() + guard let url, clock() < deadline else { throw GitCredentialFailure.invalidContext } + guard let credential = try ports.acquire(url, session.token, min(deadline, clock() + 15)), + !credential.username.isEmpty, !credential.password.isEmpty else { return false } + acquired = true + } + return acquired + } + func shutdown() { + lock.lock(); stopped = true; let channels = sessions.values.compactMap(\.channel) + for id in sessions.keys { policy.unregister(id); admission.cancel(session: id) } + sessions.removeAll(); pending.removeAll(); lock.unlock() + guard !channels.isEmpty else { return } + let group = DispatchGroup() + for channel in channels { + group.enter() + DispatchQueue.global(qos: .utility).async { channel.cancel(); group.leave() } + } + _ = group.wait(timeout: .now() + Self.shutdownBound) + } +} diff --git a/Sources/ADevContainerLib/Git/GitCredentialBrokerEntry.swift b/Sources/ADevContainerLib/Git/GitCredentialBrokerEntry.swift new file mode 100644 index 0000000..79ad4b5 --- /dev/null +++ b/Sources/ADevContainerLib/Git/GitCredentialBrokerEntry.swift @@ -0,0 +1,59 @@ +import Foundation +import Darwin + +private final class GitCredentialServiceStop: @unchecked Sendable { + private let lock = NSLock() + private var stopped = false + func stop() { lock.lock(); stopped = true; lock.unlock() } + func shouldStop() -> Bool { lock.lock(); defer { lock.unlock() }; return stopped } +} + +/// Internal executable entry, deliberately not part of public command parsing/help. +public enum GitCredentialBrokerEntry { + public static func run(arguments: [String]) -> Int32? { + guard arguments.first == "__adevcontainer_git_broker" else { return nil } + do { + if arguments == ["__adevcontainer_git_broker", "__verify_embedded_assets"] { + let assets = try ["linux/arm64", "linux/amd64"].map(GuestGitCredentialArtifact.select) + let manifest: [[String: Any]] = assets.map { ["target": $0.platform, "size": $0.bytes.count, "sha256": $0.sha256, "protocolVersion": GitCredentialProtocol.version] } + FileHandle.standardOutput.write(try JSONSerialization.data(withJSONObject: manifest, options: [.sortedKeys])) + return 0 + } + let context: GitCredentialLaunchContext + if arguments.count == 1 { context = try .production() } + else { + guard arguments.count == 2, let base = realpath(FileManager.default.temporaryDirectory.appendingPathComponent("opencode").path, nil) else { throw GitCredentialFailure.invalidContext } + defer { free(base) } + let path = arguments[1] + guard path.hasPrefix(String(cString: base) + "/"), !path.split(separator: "/").contains(".."), (path as NSString).lastPathComponent == "launch-context.json" else { throw GitCredentialFailure.invalidContext } + let directory = try GitCredentialPrivateFiles.directory((path as NSString).deletingLastPathComponent, create: false); defer { close(directory) } + guard let bytes = try GitCredentialPrivateFiles.read("launch-context.json", directory: directory), + let decoded = try? JSONDecoder().decode(GitCredentialLaunchContext.self, from: bytes), + decoded.label != "com.adevcontainer.git-credentials" else { throw GitCredentialFailure.invalidContext } + context = decoded + } + try context.validate() + let environment: [String: String] + if context.label == "com.adevcontainer.git-credentials" { environment = ProcessInfo.processInfo.environment.filter { $0.key != "ADEVCONTAINER_GIT_TOKEN" } } + else { + guard let isolated = context.providerEnvironment, isolated["GIT_CONFIG_NOSYSTEM"] == "1", isolated["GIT_CONFIG_SYSTEM"] == "/dev/null", + let global = isolated["GIT_CONFIG_GLOBAL"], global.hasPrefix(context.state + "/"), !global.split(separator: "/").contains(".."), + isolated["ADEVCONTAINER_GIT_TOKEN"] == nil, !isolated.keys.contains(where: { $0.hasPrefix("GIT_CONFIG_KEY_") || $0.hasPrefix("GIT_CONFIG_VALUE_") || ["GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS", "GH_TOKEN", "GITHUB_TOKEN"].contains($0) }) else { throw GitCredentialFailure.invalidContext } + environment = isolated + } + let stop = GitCredentialServiceStop() + // A detached session has no controlling terminal, so SIGHUP is + // ignored while SIGTERM/SIGINT keep the same bounded cleanup. + let term = signal(SIGTERM, SIG_IGN), interrupt = signal(SIGINT, SIG_IGN), hangup = signal(SIGHUP, SIG_IGN) + let sources = [SIGTERM, SIGINT].map { number -> DispatchSourceSignal in + let source = DispatchSource.makeSignalSource(signal: number, queue: .global()); source.setEventHandler { stop.stop() }; source.resume(); return source + } + defer { for source in sources { source.cancel() }; signal(SIGTERM, term); signal(SIGINT, interrupt); signal(SIGHUP, hangup) } + try GitCredentialBrokerService(context: context).run(ports: AppleContainerRuntime().credentialBrokerPorts(providerEnvironment: environment), shouldStop: { stop.shouldStop() }) + return 0 + } catch { + FileHandle.standardError.write(Data("Git credential service could not start; retry a lifecycle command to repair registration, or check the private service context.\n".utf8)) + return 1 + } + } +} diff --git a/Sources/ADevContainerLib/Git/GitCredentialBrokerPolicy.swift b/Sources/ADevContainerLib/Git/GitCredentialBrokerPolicy.swift new file mode 100644 index 0000000..abccf5e --- /dev/null +++ b/Sources/ADevContainerLib/Git/GitCredentialBrokerPolicy.swift @@ -0,0 +1,117 @@ +import Foundation +import Crypto + +struct GitCredentialDestination: Hashable, Codable, Sendable { + let scheme: String + let hostname: String + let port: Int + init(scheme: String, host: String) throws { + guard scheme == "http" || scheme == "https", !host.isEmpty, GitCredentialProtocol.safe(host), + !host.contains(where: { $0.isWhitespace }), let components = URLComponents(string: "\(scheme)://\(host)"), + components.user == nil, components.password == nil, components.path.isEmpty, + components.query == nil, components.fragment == nil, let name = components.host, + !name.isEmpty, GitCredentialProtocol.safe(name), !name.contains(where: { $0.isWhitespace }), !name.contains("%"), components.port == nil || (1...65535).contains(components.port!) else { throw GitCredentialFailure.invalidDestination } + self.scheme = scheme; hostname = name.lowercased(); port = components.port ?? (scheme == "https" ? 443 : 80) + } + init(url: String) throws { + guard GitCredentialProtocol.safe(url), let c = URLComponents(string: url), let scheme = c.scheme?.lowercased(), + let host = c.host, GitCredentialProtocol.safe(c.path) else { throw GitCredentialFailure.invalidDestination } + try self.init(scheme: scheme, host: host + (c.port.map { ":\($0)" } ?? "")) + } + var hostField: String { hostname + (port == (scheme == "https" ? 443 : 80) ? "" : ":\(port)") } +} +struct GitCredentialQuery: Equatable, Sendable { + var scheme: String + var host: String + var path: String = "" + var username: String = "" + var password: String = "" + init(scheme: String, host: String, path: String = "", username: String = "", password: String = "") { + self.scheme = scheme; self.host = host; self.path = path; self.username = username; self.password = password + } + init(frame: GitCredentialFrame) throws { + guard frame.operation == .get || frame.operation == .erase, let scheme = frame.fields[.scheme], let host = frame.fields[.host] else { throw GitCredentialFailure.invalidMessage } + self.init(scheme: scheme, host: host, path: frame.fields[.path] ?? "", username: frame.fields[.username] ?? "", password: frame.fields[.password] ?? "") + } +} +struct GitCredentialBrokerPolicy { + private let clock: () -> TimeInterval + private let key = SymmetricKey(size: .bits256) + private struct Session { var generation: UInt64; var destinations: Set; var lastRequest: UInt64 = 0 } + private struct Delivery { let session: String; let scope: String; let fingerprint: Data; let username: String } + private struct Rejection { let session: String; let scope: String; let fingerprint: Data; let until: TimeInterval } + private var sessions: [String: Session] = [:] + private var deliveries: [Delivery] = [] + private var rejections: [Rejection] = [] + init(clock: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { self.clock = clock } + mutating func register(_ session: String, generation: UInt64, destinations: Set) { + unregister(session); guard generation != 0, destinations.count <= 64, sessions.count < 128 else { return } + sessions[session] = .init(generation: generation, destinations: destinations) + } + mutating func unregister(_ session: String) { sessions.removeValue(forKey: session); reset(session) } + func authorize(_ session: String, generation: UInt64, query: GitCredentialQuery) -> String? { + guard current(session, generation: generation), [query.path, query.username, query.password].allSatisfy(GitCredentialProtocol.safe), + let destination = try? GitCredentialDestination(scheme: query.scheme, host: query.host), + sessions[session]!.destinations.contains(destination), destination.hostname != "dev.azure.com" || !query.path.split(separator: "/").isEmpty else { return nil } + var components = URLComponents(); components.scheme = destination.scheme; components.host = destination.hostname + if destination.port != (destination.scheme == "https" ? 443 : 80) { components.port = destination.port } + components.path = query.path.isEmpty ? "" : "/" + query.path + return components.string + } + private func fingerprint(_ value: String) -> Data { Data(HMAC.authenticationCode(for: Data(value.utf8), using: key)) } + mutating func deliver(_ session: String, generation: UInt64, query: GitCredentialQuery, credential: GitHTTPSCredentials) -> GitHTTPSCredentials? { + guard let scope = authorize(session, generation: generation, query: query), !credential.password.isEmpty, + !credential.username.isEmpty, GitCredentialProtocol.safe(credential.password), GitCredentialProtocol.safe(credential.username), + credential.password.utf8.count + credential.username.utf8.count + query.username.utf8.count <= 60000 else { return nil } + let now = clock() + rejections.removeAll { $0.until <= now } + let hash = fingerprint(credential.password) + guard !rejections.contains(where: { $0.session == session && $0.scope == scope && $0.fingerprint == hash }) else { return nil } + let username = query.username.isEmpty ? credential.username : query.username + deliveries.append(.init(session: session, scope: scope, fingerprint: hash, username: username)) + if deliveries.count > 256 { deliveries.removeFirst(deliveries.count - 256) } + return .init(username: username, password: credential.password) + } + mutating func erase(_ session: String, generation: UInt64, query: GitCredentialQuery) { + guard let scope = authorize(session, generation: generation, query: query), !query.password.isEmpty else { return } + let hash = fingerprint(query.password) + guard deliveries.contains(where: { $0.session == session && $0.scope == scope && $0.fingerprint == hash && (query.username.isEmpty || query.username == $0.username) }) else { return } + let now = clock() + rejections.removeAll { $0.until <= now || ($0.session == session && $0.scope == scope && $0.fingerprint == hash) } + rejections.append(.init(session: session, scope: scope, fingerprint: hash, until: now + 30)) + if rejections.count > 256 { rejections.removeFirst(rejections.count - 256) } + } + mutating func reset(_ session: String) { deliveries.removeAll { $0.session == session }; rejections.removeAll { $0.session == session } } + mutating func begin(_ session: String, generation: UInt64, request: UInt64) -> Bool { + guard current(session, generation: generation), request > sessions[session]!.lastRequest else { return false } + sessions[session]!.lastRequest = request; return true + } + func current(_ session: String, generation: UInt64) -> Bool { generation != 0 && sessions[session]?.generation == generation } +} + +struct GitCredentialWork: Hashable, Sendable { + let session: String + let generation: UInt64 + let request: UInt64 +} +struct GitCredentialAdmission { + private let activeLimit: Int + private let queueLimit: Int + private var queued: [GitCredentialWork] = [] + private var active: Set = [] + private var lastSession: String? + init(activeLimit: Int = 8, queueLimit: Int = 64) { self.activeLimit = max(1, activeLimit); self.queueLimit = max(0, queueLimit) } + mutating func enqueue(_ work: GitCredentialWork) -> Bool { + guard queued.count < queueLimit, !queued.contains(work), !active.contains(work) else { return false } + queued.append(work); return true + } + mutating func next() -> GitCredentialWork? { + guard active.count < activeLimit, !queued.isEmpty else { return nil } + let index = queued.firstIndex(where: { $0.session != lastSession }) ?? 0 + let work = queued.remove(at: index); lastSession = work.session; active.insert(work); return work + } + mutating func complete(_ work: GitCredentialWork) { active.remove(work) } + // A canceled worker retains its capacity slot until its child really exits; + // fencing a session must not create unbounded replacement provider jobs. + mutating func cancel(session: String) { queued.removeAll { $0.session == session } } +} diff --git a/Sources/ADevContainerLib/Git/GitCredentialBrokerRegistry.swift b/Sources/ADevContainerLib/Git/GitCredentialBrokerRegistry.swift new file mode 100644 index 0000000..07b41d4 --- /dev/null +++ b/Sources/ADevContainerLib/Git/GitCredentialBrokerRegistry.swift @@ -0,0 +1,103 @@ +import Foundation +import Darwin + +struct GitCredentialRegistration: Codable, Equatable, Sendable { + var context: GitCredentialRuntimeContext + var destinations: Set + var directory: String + var overrideRequired: Bool + var protocolVersion: Int = GitCredentialProtocol.version +} + +/// Owner-private files opened relative to verified directory descriptors. Shared by +/// registry/control IPC so neither follows a user-writable symlink during cleanup. +enum GitCredentialPrivateFiles { + static func directory(_ path: String, create: Bool, privateMode: Bool = true) throws -> Int32 { + guard path.hasPrefix("/"), path.utf8.count < Int(PATH_MAX), !path.utf8.contains(0) else { throw GitCredentialFailure.unsafePath } + let components = path.split(separator: "/").map(String.init) + guard !components.isEmpty, !components.contains("."), !components.contains("..") else { throw GitCredentialFailure.unsafePath } + var fd = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard fd >= 0 else { throw GitCredentialFailure.unsafePath } + do { + for component in components { + var next = openat(fd, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + if next < 0, errno == ENOENT, create { + guard mkdirat(fd, component, 0o700) == 0 || errno == EEXIST else { throw GitCredentialFailure.unsafePath } + next = openat(fd, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + } + guard next >= 0 else { throw GitCredentialFailure.unsafePath } + close(fd); fd = next + } + var st = stat() + guard fstat(fd, &st) == 0, st.st_uid == geteuid(), privateMode ? st.st_mode & 0o777 == 0o700 : st.st_mode & 0o022 == 0 else { throw GitCredentialFailure.unsafePath } + return fd + } catch { close(fd); throw error } + } + private static func valid(_ name: String) -> Bool { !name.isEmpty && !name.contains("/") && !name.utf8.contains(0) && name != "." && name != ".." } + static func read(_ name: String, directory: Int32) throws -> Data? { + guard valid(name) else { throw GitCredentialFailure.unsafePath } + let fd = openat(directory, name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + if fd < 0, errno == ENOENT { return nil } + guard fd >= 0 else { throw GitCredentialFailure.unsafePath }; defer { close(fd) } + var st = stat() + guard fstat(fd, &st) == 0, st.st_mode & S_IFMT == S_IFREG, st.st_uid == geteuid(), st.st_mode & 0o777 == 0o600, + st.st_nlink == 1, st.st_size >= 0, st.st_size <= 1024 * 1024 else { throw GitCredentialFailure.unsafePath } + var data = Data(), buffer = [UInt8](repeating: 0, count: 16384) + while true { + let count = Darwin.read(fd, &buffer, buffer.count) + if count == 0 { return data } + if count < 0 { if errno == EINTR { continue }; throw GitCredentialFailure.unsafePath } + guard count <= 1024 * 1024 - data.count else { throw GitCredentialFailure.unsafePath } + data.append(contentsOf: buffer.prefix(count)) + } + } + static func write(_ bytes: Data, name: String, directory: Int32) throws { + guard valid(name), bytes.count <= 1024 * 1024 else { throw GitCredentialFailure.unsafePath } + _ = try read(name, directory: directory) // Reject existing symlink/nonprivate/multilink objects. + let temporary = ".\(name).\(UUID().uuidString)" + let fd = openat(directory, temporary, O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW | O_CLOEXEC, 0o600) + guard fd >= 0 else { throw GitCredentialFailure.unsafePath } + defer { close(fd); unlinkat(directory, temporary, 0) } + try bytes.withUnsafeBytes { buffer in + var offset = 0 + while offset < bytes.count { + let count = Darwin.write(fd, buffer.baseAddress!.advanced(by: offset), bytes.count - offset) + if count < 0 { if errno == EINTR { continue }; throw GitCredentialFailure.unsafePath } + guard count > 0 else { throw GitCredentialFailure.unsafePath }; offset += count + } + } + guard fsync(fd) == 0, renameat(directory, temporary, directory, name) == 0, fsync(directory) == 0 else { throw GitCredentialFailure.unsafePath } + } +} + +final class GitCredentialBrokerRegistry { + private let path: String + init(path: String) { self.path = path } + func load() throws -> [GitCredentialRegistration] { + let directory = try GitCredentialPrivateFiles.directory(path, create: true); defer { close(directory) } + guard let data = try GitCredentialPrivateFiles.read("registrations.json", directory: directory) else { return [] } + guard let registrations = try? JSONDecoder().decode([GitCredentialRegistration].self, from: data) else { throw GitCredentialFailure.invalidContext } + try validate(registrations); return registrations + } + func save(_ registrations: [GitCredentialRegistration]) throws { + try validate(registrations) + let directory = try GitCredentialPrivateFiles.directory(path, create: true); defer { close(directory) } + let encoder = JSONEncoder(); encoder.outputFormatting = [.sortedKeys] + try GitCredentialPrivateFiles.write(encoder.encode(registrations.sorted { $0.context.id < $1.context.id }), name: "registrations.json", directory: directory) + } + private func validate(_ registrations: [GitCredentialRegistration]) throws { + guard registrations.count <= 128, Set(registrations.map { $0.context.id }).count == registrations.count else { throw GitCredentialFailure.invalidContext } + for registration in registrations { + let context = registration.context + guard registration.protocolVersion == GitCredentialProtocol.version, !context.id.isEmpty, !context.id.hasPrefix("-"), + !context.incarnation.isEmpty, !context.user.isEmpty, ["linux/arm64", "linux/amd64"].contains(context.platform), + context.labels[ContainerIdentity.labelManaged] == "adevcontainer", Set(context.labels.keys).isSubset(of: GitCredentialRuntimeContext.identityLabels), + ([context.id, context.incarnation, context.user] + Array(context.labels.values)).allSatisfy(GitCredentialProtocol.safe), + registration.directory.hasPrefix("/"), !registration.directory.split(separator: "/").contains(".."), + registration.directory.utf8.count <= 85, GitCredentialProtocol.safe(registration.directory), registration.destinations.count <= 64 else { throw GitCredentialFailure.invalidContext } + for destination in registration.destinations { + guard try GitCredentialDestination(scheme: destination.scheme, host: destination.hostField) == destination else { throw GitCredentialFailure.invalidDestination } + } + } + } +} diff --git a/Sources/ADevContainerLib/Git/GitCredentialBrokerService.swift b/Sources/ADevContainerLib/Git/GitCredentialBrokerService.swift new file mode 100644 index 0000000..2f8062b --- /dev/null +++ b/Sources/ADevContainerLib/Git/GitCredentialBrokerService.swift @@ -0,0 +1,167 @@ +import Foundation +import Darwin + +struct GitCredentialLaunchContext: Codable, Equatable, Sendable { + static let productionLabel = "com.adevcontainer.git-credentials" + let label: String + let state: String + let endpoints: String + let executable: String + /// Isolated verification only: the sanitized provider environment handed to + /// the detached process. Production inherits the (token-free) host one. + var providerEnvironment: [String: String]? = nil + var socket: String { endpoints + "/control.sock" } + var isProduction: Bool { label == Self.productionLabel } + static func production() throws -> Self { + let home = FileManager.default.homeDirectoryForCurrentUser.path + guard let physical = realpath(try ExecutableIdentity.currentExecutablePath(), nil) else { throw GitCredentialFailure.invalidContext } + defer { free(physical) } + return .init(label: productionLabel, state: home + "/Library/Application Support/adevcontainer/git-credentials", + endpoints: home + "/.adevcontainer/git-broker", executable: String(cString: physical)) + } + func validate() throws { + guard executable.hasPrefix("/"), FileManager.default.isExecutableFile(atPath: executable), socket.utf8.count < 104, + [state, endpoints, executable].allSatisfy(GitCredentialProtocol.safe) else { throw GitCredentialFailure.invalidContext } + if isProduction { + guard self == (try Self.production()) else { throw GitCredentialFailure.invalidContext } + } else { + let prefix = "com.adevcontainer.git-credentials.test." + guard label.hasPrefix(prefix), UUID(uuidString: String(label.dropFirst(prefix.count))) != nil, + let root = realpath(FileManager.default.temporaryDirectory.appendingPathComponent("opencode").path, nil) else { throw GitCredentialFailure.invalidContext } + defer { free(root) }; let base = String(cString: root) + "/" + guard [state, endpoints].allSatisfy({ $0.hasPrefix(base) && !$0.split(separator: "/").contains("..") }) else { throw GitCredentialFailure.invalidContext } + if let environment = providerEnvironment { + let allowed: Set = ["PATH", "HOME", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_NOSYSTEM"] + guard Set(environment.keys).isSubset(of: allowed), environment["PATH"] == "/usr/bin:/bin:/usr/sbin:/sbin", + environment["HOME"] == state, environment["GIT_CONFIG_SYSTEM"] == "/dev/null", environment["GIT_CONFIG_NOSYSTEM"] == "1", + let global = environment["GIT_CONFIG_GLOBAL"], global.hasPrefix(state + "/"), !global.split(separator: "/").contains(".."), + environment.values.allSatisfy(GitCredentialProtocol.safe) else { throw GitCredentialFailure.invalidContext } + } + } + } +} + +final class GitCredentialServiceLock { + private let fd: Int32 + init(directory: String, name: String = "broker.lock", wait: TimeInterval = 0) throws { + guard ["broker.lock", "activation.lock", "provision.lock"].contains(name) else { throw GitCredentialFailure.unsafePath } + let directory = try GitCredentialPrivateFiles.directory(directory, create: true); defer { close(directory) } + let deadline = ProcessInfo.processInfo.systemUptime + wait + var descriptor: Int32 + repeat { + descriptor = openat(directory, name, O_CREAT | O_RDWR | O_NOFOLLOW | O_CLOEXEC, 0o600) + if descriptor >= 0 { break } + guard errno == ENOENT || errno == EINTR, ProcessInfo.processInfo.systemUptime < deadline else { throw GitCredentialFailure.unsafePath } + Thread.sleep(forTimeInterval: 0.025) + } while true + var st = stat() + guard fstat(descriptor, &st) == 0, st.st_uid == geteuid(), st.st_mode & S_IFMT == S_IFREG, st.st_mode & 0o777 == 0o600, st.st_nlink == 1 else { close(descriptor); throw GitCredentialFailure.unsafePath } + while flock(descriptor, LOCK_EX | LOCK_NB) != 0 { + guard (errno == EWOULDBLOCK || errno == EINTR), ProcessInfo.processInfo.systemUptime < deadline else { close(descriptor); throw GitCredentialFailure.overloaded } + Thread.sleep(forTimeInterval: 0.025) + } + fd = descriptor + } + deinit { close(fd) } +} + +/// Activation adapter. The CLI is the broker's only launcher: it starts the +/// same executable as a detached session when no acceptable owner is serving. +/// Tests pass a unique isolated context plus fake launch/probe ports; no test +/// selects or mutates the real per-user broker. +struct GitCredentialBrokerService: Sendable { + static let replacementBound: TimeInterval = 4 + /// The pre-spawn identity probe is a liveness check, not a request. A + /// connected-but-silent endpoint must fail closed quickly instead of + /// consuming the activation deadline and then spawning over a live owner. + static let probeBound: TimeInterval = 2 + static let readinessInterval: TimeInterval = 0.025 + /// A successor spawned while a retiring owner finishes cleanup waits for + /// the lifetime lock instead of failing startup. + static let lifetimeLockWait: TimeInterval = 5 + let context: GitCredentialLaunchContext + let launcher: any DetachedProcessLaunching + let control: any GitCredentialBrokerProbing + init(context: GitCredentialLaunchContext, launcher: any DetachedProcessLaunching = PosixDetachedProcessLauncher(), + control: any GitCredentialBrokerProbing = GitCredentialControlClient()) { + self.context = context; self.launcher = launcher; self.control = control + } + func ensure(deadline: TimeInterval = ProcessInfo.processInfo.systemUptime + 30) throws { + guard ProcessInfo.processInfo.systemUptime < deadline else { throw GitCredentialFailure.deadline } + try context.validate() + let identity = try ExecutableIdentity.read(context.executable) + // Resolve the sanitized child environment before any side effect so a + // broken isolated context cannot retire a healthy owner and then fail. + let environment = try brokerEnvironment() + let owner = try GitCredentialServiceLock(directory: context.endpoints, name: "activation.lock", wait: max(0, deadline - ProcessInfo.processInfo.systemUptime)) + defer { withExtendedLifetime(owner) {} } + // Keep readiness polling on the full activation deadline: a newly + // started broker may legitimately spend longer reconciling before it + // answers hello. Only this pre-spawn identity check is short. + let probeDeadline = min(deadline, ProcessInfo.processInfo.systemUptime + Self.probeBound) + switch try control.probe(context, deadline: probeDeadline) { + case .owner(let running) where running.matches(identity): return + // An endpoint we cannot authenticate is never killed or replaced. + case .unrecognized: throw GitCredentialFailure.invalidContext + case .owner: try retireOwner(deadline) + case .absent: break + } + // A probe may have consumed the caller's deadline. Recheck before any + // side effect so a timed-out activation cannot still start a successor. + guard ProcessInfo.processInfo.systemUptime < deadline else { throw GitCredentialFailure.deadline } + if !context.isProduction { + let state = try GitCredentialPrivateFiles.directory(context.state, create: true); defer { close(state) } + try GitCredentialPrivateFiles.write(JSONEncoder().encode(context), name: "launch-context.json", directory: state) + } + guard ProcessInfo.processInfo.systemUptime < deadline else { throw GitCredentialFailure.deadline } + let process = try launcher.launch(executable: context.executable, arguments: launchArguments, environment: environment) + defer { launcher.reap(process) } + try awaitOwner(identity, deadline) + } + private var launchArguments: [String] { + var arguments = ["__adevcontainer_git_broker"] + if !context.isProduction { arguments.append(context.state + "/launch-context.json") } + return arguments + } + /// The explicit host token escape hatch never crosses into the broker, so a + /// successor cannot inherit a credential the current session did not re-register. + private func brokerEnvironment() throws -> [String: String] { + if context.isProduction { return ProcessInfo.processInfo.environment.filter { $0.key != "ADEVCONTAINER_GIT_TOKEN" } } + guard let isolated = context.providerEnvironment else { throw GitCredentialFailure.invalidContext } + return isolated + } + /// One bounded graceful replacement. A refusal or hang is a soft setup + /// failure, never an unauthenticated kill or second registry owner. + private func retireOwner(_ deadline: TimeInterval) throws { + guard ProcessInfo.processInfo.systemUptime < deadline else { throw GitCredentialFailure.deadline } + let bound = min(deadline, ProcessInfo.processInfo.systemUptime + Self.replacementBound) + try control.retire(context, deadline: bound) + while true { + guard ProcessInfo.processInfo.systemUptime < bound else { throw GitCredentialFailure.deadline } + switch try control.probe(context, deadline: bound) { + case .absent: return + case .unrecognized: throw GitCredentialFailure.invalidContext + case .owner: Thread.sleep(forTimeInterval: Self.readinessInterval) + } + } + } + /// Readiness poll. A concurrent activation may win the lifetime lock with a + /// different executable; retire it once and keep converging instead of + /// adopting an owner this executable did not start. + private func awaitOwner(_ identity: ExecutableIdentity, _ deadline: TimeInterval) throws { + var retired = false + while true { + guard ProcessInfo.processInfo.systemUptime < deadline else { throw GitCredentialFailure.deadline } + switch try control.probe(context, deadline: deadline) { + case .owner(let running) where running.matches(identity): return + case .absent: break + case .unrecognized: throw GitCredentialFailure.invalidContext + case .owner: + guard !retired else { throw GitCredentialFailure.deadline } + retired = true + try control.retire(context, deadline: min(deadline, ProcessInfo.processInfo.systemUptime + Self.replacementBound)) + } + Thread.sleep(forTimeInterval: Self.readinessInterval) + } + } +} diff --git a/Sources/ADevContainerLib/Git/GitCredentialConnection.swift b/Sources/ADevContainerLib/Git/GitCredentialConnection.swift new file mode 100644 index 0000000..8ed2046 --- /dev/null +++ b/Sources/ADevContainerLib/Git/GitCredentialConnection.swift @@ -0,0 +1,209 @@ +import Foundation +import Crypto + +struct GitCredentialConnection: GitCredentialConnecting { + typealias Control = @Sendable (GitCredentialControlRequest, Bool, TimeInterval) throws -> Bool + let runtime: AppleContainerRuntime + let control: Control + let hostRunner: any ProcessRunning + let gitPathOverride: String?? + let token: String? + let timeout: TimeInterval + private let installationLock: @Sendable (TimeInterval) throws -> GitCredentialServiceLock? + static func namespace(_ context: GitCredentialRuntimeContext) -> String { + let material = [context.id, context.incarnation, context.user, context.platform].joined(separator: "\0") + return "s-" + Data(SHA256.hash(data: Data(material.utf8))).base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + } + init(runtime: AppleContainerRuntime, control: Control? = nil, hostRunner: any ProcessRunning = CredentialProviderProcessRunner(), gitPathOverride: String?? = nil, token: String? = nil, timeout: TimeInterval = 30) { + self.timeout = timeout + self.runtime = runtime; self.hostRunner = hostRunner; self.gitPathOverride = gitPathOverride; self.token = token + self.control = control ?? { request, activate, deadline in try GitCredentialBrokerService(context: .production()).request(request, activate: activate, deadline: deadline) } + if control != nil { self.installationLock = { _ in nil } } + else { + self.installationLock = { deadline in + guard runtime.runner is FoundationProcessRunner else { throw GitCredentialFailure.invalidContext } + return try GitCredentialServiceLock(directory: GitCredentialLaunchContext.production().endpoints, name: "provision.lock", wait: max(0, deadline - ProcessInfo.processInfo.systemUptime)) + } + } + } + @discardableResult func setup(id: String, hostWorkspace: String?, gitURL: String?, refresh: Bool = false) throws -> Bool { + let deadline = ProcessInfo.processInfo.systemUptime + timeout + let sources: [String] + if let workspace = hostWorkspace, !workspace.isEmpty { + let git: String? + if let override = gitPathOverride { git = override } else { git = HostGitClient.whichGit() } + guard let git else { return false } + let selected: any ProcessRunning = (hostRunner as? CredentialProviderProcessRunner)?.withDeadline(deadline) ?? hostRunner + guard let result = try? selected.run(executable: git, arguments: ["-C", workspace, "remote", "-v"], environment: ProcessInfo.processInfo.environment.filter { $0.key != "ADEVCONTAINER_GIT_TOKEN" }, currentDirectory: nil, stdinData: nil), result.succeeded else { return false } + sources = GitCredentialDiscovery.uniqueFetchURLs(from: result.stdoutString) + } else if let gitURL, !gitURL.isEmpty { sources = [gitURL] } else { return false } + let valid = sources.compactMap { source -> (String, GitCredentialDestination)? in + guard let destination = try? GitCredentialDestination(url: source) else { return nil } + return (ContainerIdentity.normalizeGitURL(source), destination) + } + guard !valid.isEmpty else { return false } + let owner = try installationLock(deadline); defer { withExtendedLifetime(owner) {} } + let context = try runtime.credentialContext(id: id, deadline: deadline) + guard !context.id.isEmpty, context.id.utf8.count <= 63, context.id.utf8.allSatisfy({ (97...122).contains($0) || (48...57).contains($0) || $0 == 45 }) else { throw GitCredentialFailure.invalidContext } + let namespace = Self.namespace(context) + let artifact = try GuestGitCredentialArtifact.select(platform: context.platform) + let prepared = try runtime.credentialExec(context: context, command: ["sh", "-ceu", Self.prepareScript, "adevcontainer-git-prepare", context.id, namespace], input: artifact.bytes, deadline: deadline) + if prepared.exitCode == 3 { return false } + if prepared.exitCode == 4 { throw GitCredentialFailure.unsafePath } + guard prepared.succeeded else { throw GitCredentialFailure.transport } + let lines = prepared.stdoutString.split(separator: "\n", omittingEmptySubsequences: false) + guard lines.count == 5, lines.last == "", let uid = UInt32(lines[3]), String(uid) == lines[3] else { throw GitCredentialFailure.invalidContext } + let home = String(lines[0]), directory = String(lines[1]), temporary = String(lines[2]) + guard home.hasPrefix("/"), directory == "/tmp/.adevcontainer-git-\(uid)/\(namespace)", directory.utf8.count <= 85, + temporary.hasPrefix(directory + "/.relay."), !temporary.dropFirst(directory.count + 8).isEmpty, + !temporary.dropFirst(directory.count + 8).contains("/"), [home, directory, temporary].allSatisfy(GitCredentialProtocol.safe), + !home.split(separator: "/").contains("..") else { throw GitCredentialFailure.unsafePath } + defer { _ = try? runtime.credentialExec(context: context, command: ["rm", "-f", "--", temporary], deadline: deadline) } + let readBack = try runtime.credentialExec(context: context, command: ["cat", temporary], deadline: deadline) + guard readBack.succeeded else { throw GitCredentialFailure.transport }; try artifact.verify(readBack: readBack.stdout) + let destinations = Set(valid.map(\.1)) + let azureScopes = destinations.filter { $0.hostname == "dev.azure.com" }.map { "\($0.scheme)://\($0.hostField)" }.sorted() + let global = destinations.contains { $0.hostname != "dev.azure.com" } + let installed = try runtime.credentialExec(context: context, command: ["sh", "-ceu", Self.installScript, "adevcontainer-git-install", home, directory, temporary, global ? "1" : "0", context.id] + azureScopes, input: Data(Self.wrapper.utf8), deadline: deadline) + guard installed.succeeded else { throw GitCredentialFailure.transport } + let registration = GitCredentialRegistration(context: context, destinations: destinations, directory: directory, overrideRequired: token != nil) + let acquired = try control(.init(operation: refresh ? .refresh : .register, registration: registration, token: token, sources: refresh ? valid.map(\.0) : nil), true, deadline) + while true { + if let ready = try? runtime.credentialExec(context: context, command: ["test", "-S", directory + "/credential.sock"], deadline: deadline), ready.succeeded { return acquired } + guard ProcessInfo.processInfo.systemUptime < deadline else { throw GitCredentialFailure.deadline } + Thread.sleep(forTimeInterval: 0.05) + } + } + /// Teardown never activates the broker: with no listener this is a + /// successful no-op, and a later successor revalidates any saved intent. + func unregister(id: String) throws { _ = try control(.init(operation: .unregister, id: id), false, ProcessInfo.processInfo.systemUptime + 2) } + static let wrapper = #""" +#!/bin/sh +set -eu +IFS= read -r container < /proc/sys/kernel/hostname +case "$container" in ''|*[!a-z0-9-]*) exit 1 ;; esac +uid=$(id -u) +base="/tmp/.adevcontainer-git-$uid" +[ ! -L "$base" ] && [ "$(stat -c %u "$base")" = "$uid" ] && [ "$(stat -c %a "$base")" = 700 ] || exit 1 +session="$base/session-$container" +[ ! -L "$session" ] && [ -f "$session" ] && [ "$(stat -c %u "$session")" = "$uid" ] && [ "$(stat -c %a "$session")" = 600 ] || exit 1 +IFS= read -r DIR < "$session" +case "$DIR" in "$base"/s-*) ;; *) exit 1 ;; esac +exec "$DIR/adevcontainer-git-relay" client "$DIR" "$@" + +"""# + /// Tightens a connection-user-owned directory to 0700. Another product path + /// (`mkdir -p` for the VS Code marker) leaves this directory at 0755; refusing + /// that blocks credential setup before any guest endpoint exists. Symlinks and + /// directories owned by someone else are refused and never chmod'd. + static let privateDirectoryRepair = #""" +ensure_private_dir() { + dir=$1; uid=$2 + if [ -L "$dir" ] || { [ -e "$dir" ] && [ ! -d "$dir" ]; }; then return 1; fi + if [ ! -d "$dir" ]; then mkdir "$dir" || return 1; fi + if [ "$(stat -c %u "$dir")" != "$uid" ]; then return 1; fi + if [ "$(stat -c %a "$dir")" != 700 ]; then chmod 700 "$dir" || return 1; fi + [ ! -L "$dir" ] && [ -d "$dir" ] && [ "$(stat -c %u "$dir")" = "$uid" ] && [ "$(stat -c %a "$dir")" = 700 ] +} + +"""# + private static let prepareScript = privateDirectoryRepair + #""" +set -eu +set -f +umask 077 +uid=$(id -u) +expected_container=$1; namespace=$2 +IFS= read -r actual_container < /proc/sys/kernel/hostname +[ "$actual_container" = "$expected_container" ] || exit 1 +command -v git >/dev/null 2>&1 || exit 3 +home= +while IFS=: read -r name password entry_uid gid comment entry_home shell; do + if [ "$entry_uid" = "$uid" ]; then home=$entry_home; break; fi +done < /etc/passwd +[ -n "$home" ] || exit 1 +case "$home" in /*) ;; *) exit 1 ;; esac +rest=${home#/}; current= +while [ -n "$rest" ]; do + part=${rest%%/*} + if [ "$rest" = "$part" ]; then rest=; else rest=${rest#*/}; fi + case "$part" in ''|.|..) exit 1 ;; esac + current="$current/$part" + [ ! -L "$current" ] && [ -d "$current" ] || exit 1 +done +base="/tmp/.adevcontainer-git-$uid" +[ ! -L /tmp ] && [ -d /tmp ] || exit 1 +for dir in "$home/.adevcontainer" "$base" "$base/$namespace"; do + ensure_private_dir "$dir" "$uid" || exit 4 +done +dir="$base/$namespace" +temporary=$(mktemp "$dir/.relay.XXXXXX") +trap 'rm -f -- "$temporary"' EXIT +trap 'exit 1' HUP INT TERM +cat > "$temporary" +chmod 700 "$temporary" +printf '%s\n%s\n%s\n%s\n' "$home" "$dir" "$temporary" "$uid" +trap - EXIT +"""# + static let installScript = #""" +set -eu +umask 077 +escape_ere() { + printf '%s' "$1" | sed -e 's/[\\.^$*+?(){}|]/\\&/g' -e 's/\[/\\[/g' -e 's/\]/\\]/g' +} +unset_exact() { + pattern=$(escape_ere "$1") + status=0 + git config --global --unset-all "$2" "^${pattern}$" >/dev/null 2>&1 || status=$? + [ "$status" -eq 0 ] || [ "$status" -eq 5 ] +} +HOME=$1; export HOME +dir=$2; temporary=$3; global=$4; container=$5; shift 5 +helper="$HOME/.adevcontainer/git-credential-adev" +helper_command=$helper +case "$helper" in + *[!a-zA-Z0-9/_.-]*) + quoted_helper=$(printf '%s' "$helper" | sed "s/'/'\\\\''/g") + helper_command="!exec '$quoted_helper'" ;; +esac +[ ! -L "$helper" ] || exit 1 +[ ! -L "$dir/adevcontainer-git-relay" ] || exit 1 +mv -f -- "$temporary" "$dir/adevcontainer-git-relay" +wrapper=$(mktemp "$HOME/.adevcontainer/.helper.XXXXXX") +trap 'rm -f -- "$wrapper"' EXIT +cat > "$wrapper" +chmod 700 "$wrapper" +mv -f -- "$wrapper" "$helper" +base=${dir%/*} +session="$base/session-$container" +[ ! -L "$session" ] || exit 1 +metadata=$(mktemp "$base/.session.XXXXXX") +trap 'rm -f -- "$wrapper" "$metadata"' EXIT +printf '%s\n' "$dir" > "$metadata" +chmod 600 "$metadata" +mv -f -- "$metadata" "$session" +store="$HOME/.adevcontainer/git-credentials" +# Only the old product-owned path, never the unmarked ~/.git-credentials clone store. +if [ ! -L "$store" ] && [ -f "$store" ] && [ "$(stat -c %u "$store")" = "$(id -u)" ] && [ "$(stat -c %a "$store")" = 600 ]; then + rm -f -- "$store" +fi +if [ "$global" = 1 ]; then + if ! git config --global --get-all credential.helper 2>/dev/null | grep -Fx -- "$helper_command" >/dev/null; then + git config --global --add credential.helper "$helper_command" >/dev/null 2>&1 + fi +fi +for scope in "$@"; do + key="credential.$scope.helper" + # Drop only empty resets and prior product entries. User helpers stay put, + # so a failed --add cannot erase them the way unset-all-then-restore did. + status=0 + git config --global --unset-all "$key" '^$' >/dev/null 2>&1 || status=$? + [ "$status" -eq 0 ] || [ "$status" -eq 5 ] || exit 1 + unset_exact "$helper" "$key" || exit 1 + if [ "$helper_command" != "$helper" ]; then + unset_exact "$helper_command" "$key" || exit 1 + fi + git config --global --add "$key" "$helper_command" >/dev/null 2>&1 + git config --global "credential.$scope.useHttpPath" true >/dev/null 2>&1 +done +"""# +} diff --git a/Sources/ADevContainerLib/Git/GitCredentialControl.swift b/Sources/ADevContainerLib/Git/GitCredentialControl.swift new file mode 100644 index 0000000..abf61a4 --- /dev/null +++ b/Sources/ADevContainerLib/Git/GitCredentialControl.swift @@ -0,0 +1,264 @@ +import Foundation +import Darwin + +struct GitCredentialControlRequest: Codable, Sendable { + enum Operation: String, Codable, Sendable { case register, unregister, refresh, hello, retire } + var operation: Operation + var registration: GitCredentialRegistration? + var id: String? + var token: String? + var sources: [String]? +} +struct GitCredentialControlReply: Codable { + let succeeded: Bool + /// Refresh-only. A miss is not a transport failure and must not be reported as acquired. + let acquired: Bool + /// `hello` only: the authenticated owner's nonsecret process/executable identity. + var identity: ExecutableIdentity? = nil +} + +/// What an activation probe found at the private endpoint. +enum GitCredentialBrokerStatus: Equatable, Sendable { + /// No listener: absent, or a stale socket the kernel refuses to connect to. + case absent + /// An authenticated same-user owner answered `hello`. + case owner(ExecutableIdentity) + /// The endpoint exists but is not a private socket or does not speak the + /// control protocol. Activation fails closed rather than guessing. + case unrecognized +} + +/// The private control socket is the authority for liveness and identity: it +/// already enforces same-user peer credentials and private inode ownership. +protocol GitCredentialBrokerProbing: Sendable { + func probe(_ context: GitCredentialLaunchContext, deadline: TimeInterval) throws -> GitCredentialBrokerStatus + func retire(_ context: GitCredentialLaunchContext, deadline: TimeInterval) throws + func exchange(_ request: GitCredentialControlRequest, context: GitCredentialLaunchContext, deadline: TimeInterval) throws -> GitCredentialControlReply +} + +struct GitCredentialControlClient: GitCredentialBrokerProbing { + func probe(_ context: GitCredentialLaunchContext, deadline: TimeInterval) throws -> GitCredentialBrokerStatus { + let connection: Int32 + do { connection = try connect(context, deadline: deadline) } + catch GitCredentialFailure.transport { return .absent } + catch GitCredentialFailure.unsafePath { return .unrecognized } + defer { close(connection) } + do { + try GitCredentialControlSocket.send(GitCredentialControlRequest(operation: .hello), fd: connection, deadline: deadline) + let reply: GitCredentialControlReply = try GitCredentialControlSocket.receive(connection, deadline: deadline) + guard reply.succeeded, let identity = reply.identity else { return .unrecognized } + return .owner(identity) + } catch GitCredentialFailure.deadline { + // Connected but silent: the endpoint did not speak the control + // protocol. Never treat this as absence — a successor would unlink + // a live owner's socket and leave two processes serving. + return .unrecognized + } catch GitCredentialFailure.transport { + // The owner disappeared between accept and reply: a race, not a + // foreign endpoint. A successor still needs the lifetime lock. + return .absent + } catch { return .unrecognized } + } + func retire(_ context: GitCredentialLaunchContext, deadline: TimeInterval) throws { + let connection = try connect(context, deadline: deadline) + defer { close(connection) } + try GitCredentialControlSocket.send(GitCredentialControlRequest(operation: .retire), fd: connection, deadline: deadline) + let reply: GitCredentialControlReply = try GitCredentialControlSocket.receive(connection, deadline: deadline) + guard reply.succeeded else { throw GitCredentialFailure.transport } + } + func exchange(_ request: GitCredentialControlRequest, context: GitCredentialLaunchContext, deadline: TimeInterval) throws -> GitCredentialControlReply { + let connection = try connect(context, deadline: deadline) + defer { close(connection) } + try GitCredentialControlSocket.send(request, fd: connection, deadline: deadline) + return try GitCredentialControlSocket.receive(connection, deadline: deadline) + } + private func connect(_ context: GitCredentialLaunchContext, deadline: TimeInterval) throws -> Int32 { + guard let directory = try Self.endpoint(context) else { throw GitCredentialFailure.transport } + defer { close(directory) } + var st = stat() + if fstatat(directory, "control.sock", &st, AT_SYMLINK_NOFOLLOW) != 0 { throw errno == ENOENT ? GitCredentialFailure.transport : GitCredentialFailure.unsafePath } + guard st.st_mode & S_IFMT == S_IFSOCK, + st.st_uid == geteuid(), st.st_mode & 0o077 == 0 else { throw GitCredentialFailure.unsafePath } + let fd = socket(AF_UNIX, SOCK_STREAM, 0); guard fd >= 0 else { throw GitCredentialFailure.transport } + do { + try GitCredentialControlSocket.configure(fd) + var address = try GitCredentialControlSocket.address(context.socket) + let result = withUnsafePointer(to: &address) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) } } + if result != 0 { + guard errno == EINPROGRESS else { throw GitCredentialFailure.transport } + var p = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0) + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0, poll(&p, 1, Int32(min(remaining * 1000, 30000))) > 0 else { throw GitCredentialFailure.deadline } + } + var connectionError: Int32 = 0, errorSize = socklen_t(MemoryLayout.size) + guard getsockopt(fd, SOL_SOCKET, SO_ERROR, &connectionError, &errorSize) == 0, connectionError == 0 else { throw GitCredentialFailure.transport } + guard GitCredentialControlSocket.peer(fd) else { throw GitCredentialFailure.unsafePath } + return fd + } catch { close(fd); throw error } + } + /// Missing endpoint directory means no broker has ever run: absence, not an + /// unsafe path. Anything present but not an owner-private directory fails closed. + private static func endpoint(_ context: GitCredentialLaunchContext) throws -> Int32? { + do { return try GitCredentialPrivateFiles.directory(context.endpoints, create: false) } + catch GitCredentialFailure.unsafePath { + var st = stat() + guard lstat(context.endpoints, &st) != 0, errno == ENOENT else { throw GitCredentialFailure.unsafePath } + return nil + } + } +} + +enum GitCredentialControlSocket { + private static let creationLock = NSLock() + static func bindPrivate(_ fd: Int32, address: inout sockaddr_un) -> Int32 { + // A client can discover the socket immediately after bind, before a + // later chmod. Create it private, rather than exposing a transient mode + // that a racing client must correctly reject as unsafe. Serialize the + // brief process-wide umask change across in-process service instances. + creationLock.lock(); defer { creationLock.unlock() } + let previous = umask(0o077); defer { umask(previous) } + return withUnsafePointer(to: &address) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) } } + } + static func address(_ path: String) throws -> sockaddr_un { + guard path.utf8.count < 104, !path.utf8.contains(0) else { throw GitCredentialFailure.unsafePath } + var address = sockaddr_un(); address.sun_family = sa_family_t(AF_UNIX); address.sun_len = UInt8(MemoryLayout.size) + withUnsafeMutableBytes(of: &address.sun_path) { target in target.copyBytes(from: path.utf8CString.map { UInt8(bitPattern: $0) }) } + return address + } + static func peer(_ fd: Int32) -> Bool { + var uid: uid_t = 0, gid: gid_t = 0 + return getpeereid(fd, &uid, &gid) == 0 && uid == geteuid() + } + static func configure(_ fd: Int32) throws { + guard fcntl(fd, F_SETFD, FD_CLOEXEC) == 0, fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK) == 0, + fcntl(fd, F_SETNOSIGPIPE, 1) == 0 else { throw GitCredentialFailure.transport } + } + static func transfer(_ fd: Int32, data: inout Data, writing: Bool, deadline: TimeInterval) throws { + var offset = 0 + while offset < data.count { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { throw GitCredentialFailure.deadline } + var p = pollfd(fd: fd, events: Int16(writing ? POLLOUT : POLLIN), revents: 0) + let ready = poll(&p, 1, Int32(min(remaining * 1000, 1000))) + if ready < 0 { if errno == EINTR { continue }; throw GitCredentialFailure.transport } + if ready == 0 { continue } + let count = data.withUnsafeMutableBytes { bytes in + writing ? Darwin.write(fd, bytes.baseAddress!.advanced(by: offset), bytes.count - offset) + : Darwin.read(fd, bytes.baseAddress!.advanced(by: offset), bytes.count - offset) + } + if count < 0 { if errno == EINTR || errno == EAGAIN { continue }; throw GitCredentialFailure.transport } + guard count > 0 else { throw GitCredentialFailure.transport }; offset += count + } + } + static func receive(_ fd: Int32, deadline: TimeInterval) throws -> T { + var header = Data(repeating: 0, count: 4); try transfer(fd, data: &header, writing: false, deadline: deadline) + let size = Int(GitCredentialProtocol.integer(header, 0, 4)) + guard size > 0, size <= GitCredentialProtocol.limit else { throw GitCredentialFailure.invalidMessage } + var body = Data(repeating: 0, count: size); try transfer(fd, data: &body, writing: false, deadline: deadline) + guard let value = try? JSONDecoder().decode(T.self, from: body) else { throw GitCredentialFailure.invalidMessage }; return value + } + static func send(_ value: T, fd: Int32, deadline: TimeInterval) throws { + var body = try JSONEncoder().encode(value) + guard !body.isEmpty, body.count <= GitCredentialProtocol.limit else { throw GitCredentialFailure.invalidMessage } + var header = Data((0..<4).reversed().map { UInt8(truncatingIfNeeded: body.count >> ($0 * 8)) }) + try transfer(fd, data: &header, writing: true, deadline: deadline); try transfer(fd, data: &body, writing: true, deadline: deadline) + } +} + +extension GitCredentialBrokerService { + func request(_ request: GitCredentialControlRequest, activate: Bool = true, deadline: TimeInterval = ProcessInfo.processInfo.systemUptime + 30) throws -> Bool { + guard ProcessInfo.processInfo.systemUptime < deadline else { throw GitCredentialFailure.deadline } + if activate { try ensure(deadline: deadline) }; try context.validate() + if !activate { + // Teardown never starts, creates, or reconciles a broker. No + // listener is a successful no-op; saved intent is revalidated by a + // successor before any use. + do { return try reply(request, deadline: deadline) } + catch GitCredentialFailure.transport { return false } + } + while true { + do { return try reply(request, deadline: deadline) } + catch GitCredentialFailure.transport { + guard ProcessInfo.processInfo.systemUptime < deadline else { throw GitCredentialFailure.transport } + Thread.sleep(forTimeInterval: 0.05) + try ensure(deadline: deadline) + } + } + } + private func reply(_ request: GitCredentialControlRequest, deadline: TimeInterval) throws -> Bool { + let reply = try control.exchange(request, context: context, deadline: deadline) + guard reply.succeeded else { throw GitCredentialFailure.invalidContext } + return reply.acquired + } + + func run(ports: GitCredentialBrokerPorts, shouldStop: @Sendable () -> Bool) throws { + try context.validate() + let identity = try ExecutableIdentity.current() + let owner: GitCredentialServiceLock + do { owner = try GitCredentialServiceLock(directory: context.endpoints, wait: Self.lifetimeLockWait) } + // A spawned loser exits successfully without touching the winner's endpoint. + catch GitCredentialFailure.overloaded { return } + defer { withExtendedLifetime(owner) {} } + let broker = try GitCredentialBroker(registry: GitCredentialBrokerRegistry(path: context.state), ports: ports) + defer { broker.shutdown() } + let directory = try GitCredentialPrivateFiles.directory(context.endpoints, create: true); defer { close(directory) } + var existing = stat() + if fstatat(directory, "control.sock", &existing, AT_SYMLINK_NOFOLLOW) == 0 { + guard existing.st_mode & S_IFMT == S_IFSOCK, existing.st_uid == geteuid(), existing.st_mode & 0o077 == 0, + unlinkat(directory, "control.sock", 0) == 0 else { throw GitCredentialFailure.unsafePath } + } else if errno != ENOENT { throw GitCredentialFailure.unsafePath } + let listener = socket(AF_UNIX, SOCK_STREAM, 0); guard listener >= 0 else { throw GitCredentialFailure.transport }; defer { close(listener) } + var address = try GitCredentialControlSocket.address(context.socket) + let bound = GitCredentialControlSocket.bindPrivate(listener, address: &address) + guard bound == 0 else { throw GitCredentialFailure.transport } + var inode = stat(); guard fstatat(directory, "control.sock", &inode, AT_SYMLINK_NOFOLLOW) == 0 else { throw GitCredentialFailure.unsafePath } + defer { + // Remove only the private socket this process bound. A successor's + // endpoint and every lock inode are never touched. + var current = stat() + if fstatat(directory, "control.sock", ¤t, AT_SYMLINK_NOFOLLOW) == 0, current.st_ino == inode.st_ino, current.st_dev == inode.st_dev, + current.st_mode & S_IFMT == S_IFSOCK, current.st_uid == geteuid() { unlinkat(directory, "control.sock", 0) } + } + guard fchmodat(directory, "control.sock", 0o600, 0) == 0 else { throw GitCredentialFailure.unsafePath } + try GitCredentialControlSocket.configure(listener) + guard listen(listener, 64) == 0 else { throw GitCredentialFailure.transport } + var nextReconcile: TimeInterval = 0 + var retired = false + while !shouldStop() && !retired { + let now = ProcessInfo.processInfo.systemUptime + if now >= nextReconcile { nextReconcile = now + GitCredentialBroker.recheckInterval; broker.reconcile() } + if broker.retireIfIdle() { return } + var p = pollfd(fd: listener, events: Int16(POLLIN), revents: 0) + let wait = Int32(max(0, min(100, (nextReconcile - ProcessInfo.processInfo.systemUptime) * 1000))) + guard poll(&p, 1, wait) >= 0 || errno == EINTR else { throw GitCredentialFailure.transport } + if p.revents & Int16(POLLIN) == 0 { continue } + let peer = accept(listener, nil, nil); if peer < 0 { continue } + defer { close(peer) } + do { + guard GitCredentialControlSocket.peer(peer) else { throw GitCredentialFailure.unsafePath } + try GitCredentialControlSocket.configure(peer) + let deadline = ProcessInfo.processInfo.systemUptime + 30 + let request: GitCredentialControlRequest = try GitCredentialControlSocket.receive(peer, deadline: deadline) + switch request.operation { + case .hello, .retire: + guard request.registration == nil, request.token == nil, request.id == nil, request.sources == nil else { throw GitCredentialFailure.invalidMessage } + // The reply leaves before the loop exits so the caller knows + // the authenticated owner accepted retirement. + try GitCredentialControlSocket.send(GitCredentialControlReply(succeeded: true, acquired: false, identity: identity), fd: peer, deadline: deadline) + if request.operation == .retire { retired = true } + case .unregister: + guard let id = request.id, request.token == nil else { throw GitCredentialFailure.invalidMessage } + try broker.unregister(id) + try GitCredentialControlSocket.send(GitCredentialControlReply(succeeded: true, acquired: false), fd: peer, deadline: deadline) + case .register, .refresh: + guard let registration = request.registration else { throw GitCredentialFailure.invalidMessage } + try broker.register(registration, override: request.token, reset: request.operation == .refresh) + let acquired = request.operation == .refresh ? try broker.validate(id: registration.context.id, sources: request.sources ?? []) : true + try GitCredentialControlSocket.send(GitCredentialControlReply(succeeded: true, acquired: acquired), fd: peer, deadline: deadline) + } + } catch { + try? GitCredentialControlSocket.send(GitCredentialControlReply(succeeded: false, acquired: false), fd: peer, deadline: ProcessInfo.processInfo.systemUptime + 1) + } + } + } +} diff --git a/Sources/ADevContainerLib/Git/GitCredentialDiscovery.swift b/Sources/ADevContainerLib/Git/GitCredentialDiscovery.swift new file mode 100644 index 0000000..b446b1b --- /dev/null +++ b/Sources/ADevContainerLib/Git/GitCredentialDiscovery.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Nonsecret source discovery for automatic credential setup. +enum GitCredentialDiscovery { + static func uniqueFetchURLs(from output: String) -> [String] { + var seen: Set = [] + var result: [String] = [] + for line in output.split(whereSeparator: \.isNewline) { + let text = String(line) + guard text.hasSuffix("(fetch)") else { continue } + let trimmed = String(text.dropLast("(fetch)".count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split(omittingEmptySubsequences: true, whereSeparator: { $0 == "\t" || $0 == " " }) + guard parts.count >= 2 else { continue } + let url = String(parts[1]) + guard !url.isEmpty, seen.insert(url).inserted else { continue } + result.append(url) + } + return result + } + static func credentialSources(from labels: [String: String]) -> (hostWorkspace: String?, gitURL: String?) { + func nonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + let gitURL = nonEmpty(labels[ContainerIdentity.labelGitURL]) + if nonEmpty(labels[ContainerIdentity.labelWorkspaceMode]) == ContainerIdentity.workspaceModeVolume { return (nil, gitURL) } + guard let folder = nonEmpty(labels[ContainerIdentity.labelLocalFolder]), !folder.hasPrefix("volume://") else { return (nil, gitURL) } + return (folder, nil) + } +} diff --git a/Sources/ADevContainerLib/Git/GitCredentialLifecycle.swift b/Sources/ADevContainerLib/Git/GitCredentialLifecycle.swift new file mode 100644 index 0000000..ca0476a --- /dev/null +++ b/Sources/ADevContainerLib/Git/GitCredentialLifecycle.swift @@ -0,0 +1,41 @@ +import Foundation + +protocol GitCredentialConnecting: Sendable { + @discardableResult func setup(id: String, hostWorkspace: String?, gitURL: String?, refresh: Bool) throws -> Bool + func unregister(id: String) throws +} + +enum GitCredentialLifecycle { + private final class Port: @unchecked Sendable { + private let lock = NSLock() + private var value: (any GitCredentialConnecting)? + func get() -> (any GitCredentialConnecting)? { lock.lock(); defer { lock.unlock() }; return value } + func set(_ value: (any GitCredentialConnecting)?) { lock.lock(); self.value = value; lock.unlock() } + } + private static let port = Port() + static func setTestConnection(_ connection: (any GitCredentialConnecting)?) { port.set(connection) } + private static func connection(runtime: AppleContainerRuntime, credentials: any GitCredentialProviding) throws -> any GitCredentialConnecting { + if let injected = port.get() { return injected } + guard runtime.runner is FoundationProcessRunner else { throw GitCredentialFailure.invalidContext } + let token = (credentials as? HostGitCredential)?.environment["ADEVCONTAINER_GIT_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines) + return GitCredentialConnection(runtime: runtime, hostRunner: LifecycleRunner.hostProcessRunnerOverride ?? CredentialProviderProcessRunner(), token: token?.isEmpty == false ? token : nil) + } + @discardableResult static func setup(id: String, hostWorkspace: String?, gitURL: String?, runtime: AppleContainerRuntime, credentials: any GitCredentialProviding, refresh: Bool = false) throws -> Bool { + try connection(runtime: runtime, credentials: credentials).setup(id: id, hostWorkspace: hostWorkspace, gitURL: gitURL, refresh: refresh) + } + static func setupSoft(id: String, hostWorkspace: String?, gitURL: String?, runtime: AppleContainerRuntime, credentials: any GitCredentialProviding) { + do { _ = try setup(id: id, hostWorkspace: hostWorkspace, gitURL: gitURL, runtime: runtime, credentials: credentials) } + catch { + let reason = (error as? GitCredentialFailure)?.diagnosticCode ?? "unknown failure" + StatusPrinter.warning("Automatic Git credential setup failed (\(reason)); continuing. Run adevcontainer start or up on the running container to retry, or log in on the host.") + } + } + static func setupSoft(id: String, labels: [String: String], runtime: AppleContainerRuntime, credentials: any GitCredentialProviding) { + let sources = GitCredentialDiscovery.credentialSources(from: labels) + setupSoft(id: id, hostWorkspace: sources.hostWorkspace, gitURL: sources.gitURL, runtime: runtime, credentials: credentials) + } + static func unregisterSoft(id: String, runtime: AppleContainerRuntime) { + do { try connection(runtime: runtime, credentials: HostGitCredential(environment: [:])).unregister(id: id) } + catch { /* Runtime teardown retains its primary result; reconciliation also fences the session. */ } + } +} diff --git a/Sources/ADevContainerLib/Git/GitCredentialProtocol.swift b/Sources/ADevContainerLib/Git/GitCredentialProtocol.swift new file mode 100644 index 0000000..cfe54aa --- /dev/null +++ b/Sources/ADevContainerLib/Git/GitCredentialProtocol.swift @@ -0,0 +1,110 @@ +import Foundation + +/// Secret-free trace hook. Never persists: tests and production must not grow diag.log. +enum GitCredentialDiag { + static func event(_ code: String, _ detail: String = "", traceDirectory: String? = nil) {} +} + +enum GitCredentialFailure: Error, Equatable, CustomStringConvertible { + case invalidMessage, invalidDestination, invalidArtifact, unsupportedPlatform + case transport, deadline, overloaded, unsafePath, invalidContext + + var description: String { + "Git credential connection unavailable (\(diagnosticCode)); run adevcontainer start or up on the running container, or log in on the host." + } + /// Stable, secret-free reason. Safe to surface in CLI errors; never includes paths or provider output. + var diagnosticCode: String { + switch self { + case .invalidMessage: "invalid message" + case .invalidDestination: "invalid destination" + case .invalidArtifact: "invalid guest artifact" + case .unsupportedPlatform: "unsupported guest platform" + case .transport: "transport failed" + case .deadline: "deadline exceeded" + case .overloaded: "capacity exceeded" + case .unsafePath: "unsafe private path" + case .invalidContext: "invalid runtime identity" + } + } +} + +enum GitCredentialOperation: UInt16, Sendable { case hello = 1, get = 2, erase = 3, reply = 4 } +enum GitCredentialField: UInt16, Hashable, Sendable { case scheme = 1, host, path, username, password, platform } +struct GitCredentialFrame: Equatable, Sendable { + var operation: GitCredentialOperation + var request: UInt64 + var generation: UInt64 + var fields: [GitCredentialField: String] +} + +enum GitCredentialProtocol { + static let version = 1 + static let headerSize = 32 + static let limit = 65536 + static func safe(_ value: String) -> Bool { + !value.utf8.contains(where: { $0 < 0x20 || $0 == 0x7f }) + } + static func integer(_ bytes: Data, _ offset: Int, _ count: Int) -> UInt64 { + bytes[offset..<(offset + count)].reduce(0) { ($0 << 8) | UInt64($1) } + } + private static func append(_ value: UInt64, count: Int, to bytes: inout Data) { + for index in (0..> (index * 8))) } + } + static func encode(_ frame: GitCredentialFrame) throws -> Data { + var payload = Data() + for field in frame.fields.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + let value = frame.fields[field]! + guard safe(value), value.utf8.count <= Int(UInt16.max), (frame.operation == .hello) == (field == .platform) else { throw GitCredentialFailure.invalidMessage } + append(UInt64(field.rawValue), count: 2, to: &payload) + append(UInt64(value.utf8.count), count: 2, to: &payload); payload.append(contentsOf: value.utf8) + } + guard payload.count <= limit - headerSize, frame.operation != .hello || frame.fields.count == 1 else { throw GitCredentialFailure.invalidMessage } + var bytes = Data([65,71,67,82,0,1]); append(UInt64(frame.operation.rawValue), count: 2, to: &bytes) + append(frame.request, count: 8, to: &bytes); append(frame.generation, count: 8, to: &bytes) + append(UInt64(payload.count), count: 4, to: &bytes); append(0, count: 4, to: &bytes) + bytes.append(payload); return bytes + } + static func size(_ data: Data) throws -> Int? { + guard data.count >= headerSize else { return nil } + guard Array(data.prefix(6)) == [65,71,67,82,0,1], integer(data, 28, 4) == 0, + GitCredentialOperation(rawValue: UInt16(integer(data, 6, 2))) != nil else { throw GitCredentialFailure.invalidMessage } + let count = integer(data, 24, 4) + guard count <= limit - headerSize else { throw GitCredentialFailure.invalidMessage } + return headerSize + Int(count) + } + static func decode(_ data: Data) throws -> GitCredentialFrame { + // Copy to establish zero-based Data indices, including for sliced input. + let data = Data(data) + guard let count = try size(data), count == data.count, + let operation = GitCredentialOperation(rawValue: UInt16(integer(data, 6, 2))) else { throw GitCredentialFailure.invalidMessage } + var offset = headerSize, fields: [GitCredentialField: String] = [:] + while offset < count { + guard count - offset >= 4, let field = GitCredentialField(rawValue: UInt16(integer(data, offset, 2))), + fields[field] == nil, (operation == .hello) == (field == .platform) else { throw GitCredentialFailure.invalidMessage } + let length = Int(integer(data, offset + 2, 2)); offset += 4 + guard length <= count - offset, let value = String(data: data.subdata(in: offset..<(offset + length)), encoding: .utf8), safe(value) else { throw GitCredentialFailure.invalidMessage } + fields[field] = value; offset += length + } + guard operation != .hello || fields.count == 1 else { throw GitCredentialFailure.invalidMessage } + return .init(operation: operation, request: integer(data, 8, 8), generation: integer(data, 16, 8), fields: fields) + } +} + +struct GitCredentialFrameDecoder { + private var bytes = Data() + var isEmpty: Bool { bytes.isEmpty } + mutating func append(_ data: Data) throws -> [GitCredentialFrame] { + var frames: [GitCredentialFrame] = [] + for byte in data { + guard bytes.count < GitCredentialProtocol.limit else { throw GitCredentialFailure.invalidMessage } + bytes.append(byte) + if bytes.count >= GitCredentialProtocol.headerSize, let size = try GitCredentialProtocol.size(bytes), size == bytes.count { + frames.append(try GitCredentialProtocol.decode(bytes)); bytes.removeAll(keepingCapacity: true) + } + // A caller controls read sizes; never collect an unbounded output list. + guard frames.count <= 64 else { throw GitCredentialFailure.overloaded } + } + return frames + } + func finish() throws { if !bytes.isEmpty { throw GitCredentialFailure.invalidMessage } } +} diff --git a/Sources/ADevContainerLib/Git/GitCredentialRelayChannel.swift b/Sources/ADevContainerLib/Git/GitCredentialRelayChannel.swift new file mode 100644 index 0000000..a5611ff --- /dev/null +++ b/Sources/ADevContainerLib/Git/GitCredentialRelayChannel.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Real binary relay transport. The runtime adapter supplies the process; this +/// layer owns only protocol/pipe lifetime and never invokes a container command. +final class GitCredentialRelayChannel: GitCredentialBrokerChannel, @unchecked Sendable { + /// Broker exit is bounded; a relay cancel that cannot reap must return. + static let cancelBound: TimeInterval = 1.5 + private let process: CredentialDuplexProcess + private let generation: UInt64 + private let lock = NSLock() + private var started = false + init(process: CredentialDuplexProcess, platform: String, generation: UInt64) throws { + self.process = process; self.generation = generation + guard generation != 0, ["linux/arm64", "linux/amd64"].contains(platform) else { throw GitCredentialFailure.invalidContext } + var decoder = GitCredentialFrameDecoder() + let deadline = ProcessInfo.processInfo.systemUptime + 30 + while let chunk = try process.read(deadline: deadline) { + let frames = try decoder.append(chunk) + if !frames.isEmpty { + guard frames.count == 1, decoder.isEmpty, frames[0] == GitCredentialFrame(operation: .hello, request: 0, generation: generation, fields: [.platform: platform]) else { throw GitCredentialFailure.invalidMessage } + try process.send(GitCredentialProtocol.encode(frames[0])) + GitCredentialDiag.event("channel.hello", "bytes=\(chunk.count)"); return + } + } + GitCredentialDiag.event("channel.hello_missing") + throw GitCredentialFailure.transport + } + func start(receive: @escaping @Sendable (GitCredentialFrame) -> Void, disconnected: @escaping @Sendable () -> Void) { + lock.lock(); guard !started else { lock.unlock(); return }; started = true; lock.unlock() + GitCredentialDiag.event("channel.start") + DispatchQueue.global(qos: .userInitiated).async { [self] in + defer { GitCredentialDiag.event("channel.stop"); process.cancel(); disconnected() } + var decoder = GitCredentialFrameDecoder() + var partialSince: TimeInterval? + while true { + do { + let now = ProcessInfo.processInfo.systemUptime + if let partialSince, now - partialSince >= 15 { GitCredentialDiag.event("channel.partial_timeout"); return } + guard let chunk = try process.read(deadline: now + 1, cancelOnTimeout: false) else { GitCredentialDiag.event("channel.eof"); return } + GitCredentialDiag.event("channel.chunk", "bytes=\(chunk.count)") + for frame in try decoder.append(chunk) { + let kind = frame.operation == .get ? "get" : frame.operation == .erase ? "erase" : "other" + let accepted = frame.generation == generation && frame.request != 0 && (frame.operation == .get || frame.operation == .erase) + GitCredentialDiag.event("channel.frame", "kind=\(kind) req=\(frame.request) gen_match=\(frame.generation == generation) keys=\(frame.fields.count) accepted=\(accepted)") + guard accepted else { GitCredentialDiag.event("channel.reject"); return } + receive(frame) + } + if decoder.isEmpty { partialSince = nil } else if partialSince == nil { partialSince = now } + } catch GitCredentialFailure.deadline { continue } + catch { GitCredentialDiag.event("channel.error", "code=\((error as? GitCredentialFailure)?.diagnosticCode ?? "other")"); return } + } + } + } + func send(_ frame: GitCredentialFrame) throws { + guard frame.operation == .reply, frame.generation == generation, frame.request != 0 else { + GitCredentialDiag.event("channel.send_reject", "req=\(frame.request) gen_match=\(frame.generation == generation)") + throw GitCredentialFailure.invalidMessage + } + let bytes = try GitCredentialProtocol.encode(frame) + GitCredentialDiag.event("channel.send", "bytes=\(bytes.count) keys=\(frame.fields.count) req=\(frame.request)") + try process.send(bytes) + } + func cancel() { process.cancel(deadline: ProcessInfo.processInfo.systemUptime + Self.cancelBound) } +} diff --git a/Sources/ADevContainerLib/Git/GitURLKind.swift b/Sources/ADevContainerLib/Git/GitURLKind.swift index 2d6b68c..30a572e 100644 --- a/Sources/ADevContainerLib/Git/GitURLKind.swift +++ b/Sources/ADevContainerLib/Git/GitURLKind.swift @@ -41,10 +41,12 @@ public enum GitURLClassifier { /// Returns nil when the URL is not an http(s) form. public static func httpsCredentialFields(for rawURL: String) -> (protocolName: String, host: String, path: String)? { let trimmed = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) - guard let comps = URLComponents(string: trimmed), + guard GitCredentialProtocol.safe(trimmed), let comps = URLComponents(string: trimmed), let scheme = comps.scheme?.lowercased(), scheme == "http" || scheme == "https", - let host = comps.host, !host.isEmpty + let host = comps.host, !host.isEmpty, + let destination = try? GitCredentialDestination(scheme: scheme, host: host + (comps.port.map { ":\($0)" } ?? "")), + GitCredentialProtocol.safe(comps.path) else { return nil } @@ -52,6 +54,6 @@ public enum GitURLClassifier { if path.hasPrefix("/") { path = String(path.dropFirst()) } - return (scheme, host, path) + return (scheme, destination.hostField, path) } } diff --git a/Sources/ADevContainerLib/Git/GuestGitCredentialArtifacts.swift b/Sources/ADevContainerLib/Git/GuestGitCredentialArtifacts.swift new file mode 100644 index 0000000..6409646 --- /dev/null +++ b/Sources/ADevContainerLib/Git/GuestGitCredentialArtifacts.swift @@ -0,0 +1,46 @@ +import Foundation +import Crypto + +struct GuestGitCredentialArtifact { + let platform: String + let bytes: Data + let sha256: String + static func select(platform: String) throws -> Self { + let payload: String, hash: String + switch platform { + case "linux/arm64": payload = GuestGitCredentialEmbeddedAssets.arm64Base64; hash = GuestGitCredentialEmbeddedAssets.arm64SHA256 + case "linux/amd64": payload = GuestGitCredentialEmbeddedAssets.amd64Base64; hash = GuestGitCredentialEmbeddedAssets.amd64SHA256 + default: throw GitCredentialFailure.unsupportedPlatform + } + guard GitCredentialProtocol.version == GuestGitCredentialEmbeddedAssets.protocolVersion, let bytes = Data(base64Encoded: payload) else { throw GitCredentialFailure.invalidArtifact } + let artifact = Self(platform: platform, bytes: bytes, sha256: hash); try artifact.verify(); return artifact + } + func verify(readBack: Data? = nil) throws { + let data = readBack ?? bytes + guard data.count == bytes.count, data.count >= 64, data.count <= 2 * 1024 * 1024, + Self.hash(data) == sha256, Array(data.prefix(7)) == [127,69,76,70,2,1,1] else { throw GitCredentialFailure.invalidArtifact } + func number(_ offset: Int, _ count: Int) throws -> UInt64 { + guard offset >= 0, count <= data.count - offset else { throw GitCredentialFailure.invalidArtifact } + return (0..= 64, offset <= data.count, entrySize == 56, count > 0, count <= 128, count * entrySize <= UInt64(data.count) - offset else { throw GitCredentialFailure.invalidArtifact } + let entryPoint = try number(24, 8); var executable = false + for index in 0..= size, address <= UInt64.max - memory else { throw GitCredentialFailure.invalidArtifact } + if try number(p + 4, 4) & 1 != 0, entryPoint >= address, entryPoint - address < size { executable = true } + } + } + let sections = try number(40, 8), sectionSize = try number(58, 2), sectionCount = try number(60, 2) + guard executable, sections <= data.count, sectionCount <= 4096, sectionCount == 0 || sectionSize == 64, + sectionSize * sectionCount <= UInt64(data.count) - sections else { throw GitCredentialFailure.invalidArtifact } + } + static func hash(_ data: Data) -> String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() } +} diff --git a/Sources/ADevContainerLib/Git/GuestGitCredentialSeed.swift b/Sources/ADevContainerLib/Git/GuestGitCredentialSeed.swift deleted file mode 100644 index 87b8573..0000000 --- a/Sources/ADevContainerLib/Git/GuestGitCredentialSeed.swift +++ /dev/null @@ -1,694 +0,0 @@ -import Foundation - -/// Create-path seeding of the connection user's git credential store in the container. -/// -/// Bind mode enumerates the host workspace's unique HTTPS fetch remotes via -/// `git -C remote -v`; volume mode seeds from the single stamped -/// `devcontainer.git_url`. Credentials are acquired on the host through the shared -/// `GitCredentialProviding` contract and transferred into the container via exec stdin — -/// never argv or environment. Silent no-op (no warning, no exec) when host git is missing, the -/// workspace has no remotes, fill returns nil, or the stamped URL is missing/empty. -/// In-container failures throw (callers soft-fail with a warning). -/// -/// The guest helper matches protocol+host and ignores path, so a later `git fetch` -/// still hits the imported credential when the query includes a path. An expired -/// JWT is a miss and is removed so it is not replayed. `erase` removes the matching -/// protocol+host entries. -/// Non-Azure hosts keep the append-only global helper. `dev.azure.com` gets a -/// URL-scoped helper (empty values removed, product helper exactly once) and -/// `useHttpPath`, so other hosts are unchanged and an inherited GCM cannot abort -/// that host. Host `credential.helper` is never copied into the guest. -public struct GuestGitCredentialSeed { - public var credentials: any GitCredentialProviding - public var runner: any ProcessRunning - /// Override PATH lookup for tests (`nil` outer → real lookup; `.some(nil)` → missing git). - public var gitPathOverride: String?? - - public init( - credentials: any GitCredentialProviding = HostGitCredential(), - runner: any ProcessRunning = FoundationProcessRunner(), - gitPathOverride: String?? = nil - ) { - self.credentials = credentials - self.runner = runner - self.gitPathOverride = gitPathOverride - } - - /// Seed the store in `containerId` as `connectionUser`. `hostWorkspace` and `gitURL` - /// select bind vs volume discovery (exactly one is provided by callers). - /// Returns false on a silent skip (no URL, no fill); throws only on exec failure. - @discardableResult - public func seed( - containerId: String, - hostWorkspace: String?, - gitURL: String?, - connectionUser: String?, - runtime: AppleContainerRuntime - ) throws -> Bool { - let urls: [String] - let workspace = hostWorkspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let stamped = gitURL?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !workspace.isEmpty { - urls = bindFetchURLs(hostWorkspace: workspace) - } else if !stamped.isEmpty { - urls = [stamped] - } else { - return false - } - - var entries: [Entry] = [] - for url in urls { - guard GitURLClassifier.kind(of: url) == .https, - let fields = GitURLClassifier.httpsCredentialFields(for: url) - else { - continue - } - let creds: GitHTTPSCredentials? - do { - creds = try credentials.fillHTTPS(url: url) - } catch { - continue - } - guard let creds else { - continue - } - let entry = Entry( - protocolName: fields.protocolName, - host: fields.host, - username: creds.username, - password: creds.password - ) - if !entries.contains(where: { - $0.protocolName == entry.protocolName - && $0.host == entry.host - && $0.username == entry.username - }) { - entries.append(entry) - } - } - guard !entries.isEmpty else { return false } - try install( - containerId: containerId, - entries: entries, - connectionUser: connectionUser, - runtime: runtime - ) - return true - } - - /// Bind uses the host workspace; volume uses the stamped git URL. - /// A `volume://` local folder is not a host path. - public static func credentialSources( - from labels: [String: String] - ) -> (hostWorkspace: String?, gitURL: String?) { - let mode = labels[ContainerIdentity.labelWorkspaceMode]? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let gitURL = nonEmpty(labels[ContainerIdentity.labelGitURL]) - if mode == ContainerIdentity.workspaceModeVolume { - return (nil, gitURL) - } - let folder = labels[ContainerIdentity.labelLocalFolder]? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if folder.isEmpty || folder.hasPrefix("volume://") { - return (nil, gitURL) - } - return (folder, nil) - } - - public static func connectionUser(from labels: [String: String]) -> String? { - nonEmpty(labels[ContainerIdentity.labelRemoteUser]) - } - - /// Re-seed a running container. Does not start, stop, fetch, pull, or push. - @discardableResult - public func seedFromLabels( - containerId: String, - labels: [String: String], - connectionUser: String?, - runtime: AppleContainerRuntime - ) throws -> Bool { - let sources = Self.credentialSources(from: labels) - return try seed( - containerId: containerId, - hostWorkspace: sources.hostWorkspace, - gitURL: sources.gitURL, - connectionUser: connectionUser, - runtime: runtime - ) - } - - /// Soft-fail refresh for a real start and `up` start-stopped. - /// Never deletes the container, fails the caller, or enters recovery. - public static func refreshSoft( - containerId: String, - labels: [String: String], - connectionUser: String?, - runtime: AppleContainerRuntime, - credentials: any GitCredentialProviding - ) { - do { - try GuestGitCredentialSeed( - credentials: credentials, - runner: LifecycleRunner.hostProcessRunnerOverride ?? FoundationProcessRunner() - ).seedFromLabels( - containerId: containerId, - labels: labels, - connectionUser: connectionUser, - runtime: runtime - ) - } catch { - let detail = (error as? CLIError)?.message ?? error.localizedDescription - StatusPrinter.warning( - "Git credential refresh failed; continuing without forwarded credentials: \(detail)" - ) - } - } - - private static func nonEmpty(_ value: String?) -> String? { - let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed - } - - /// Install the guest helper and approve one credential clone already filled on the host. - /// Does not call `fillHTTPS` again. Non-HTTPS URLs are a silent no-op. - public func installHTTPSCredential( - containerId: String, - url: String, - credentials: GitHTTPSCredentials, - connectionUser: String?, - runtime: AppleContainerRuntime - ) throws { - guard GitURLClassifier.kind(of: url) == .https, - let fields = GitURLClassifier.httpsCredentialFields(for: url) - else { - return - } - try install( - containerId: containerId, - entries: [ - Entry( - protocolName: fields.protocolName, - host: fields.host, - username: credentials.username, - password: credentials.password - ) - ], - connectionUser: connectionUser, - runtime: runtime - ) - } - - /// True only for the Azure DevOps host that keeps the organization in the URL path. - public static func isAzureDevOpsHost(_ host: String) -> Bool { - host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "dev.azure.com" - } - - /// Unique fetch URLs from `git remote -v` output (`\t (fetch)` lines). - static func uniqueFetchURLs(from output: String) -> [String] { - var seen: Set = [] - var result: [String] = [] - for line in output.split(whereSeparator: \.isNewline) { - let text = String(line) - guard text.hasSuffix("(fetch)") else { continue } - let trimmed = String(text.dropLast("(fetch)".count)) - .trimmingCharacters(in: .whitespacesAndNewlines) - let parts = trimmed.split( - omittingEmptySubsequences: true, - whereSeparator: { $0 == "\t" || $0 == " " } - ) - guard parts.count >= 2 else { continue } - let url = String(parts[1]) - guard !url.isEmpty, !seen.contains(url) else { continue } - seen.insert(url) - result.append(url) - } - return result - } - - /// POSIX-sh helpers injected ahead of the action `case`. Expired-JWT removal and - /// erase share one atomic rewrite. Decode failures stay silent and are not a miss. - static func helperSupportScript() -> String { - #""" -drop_blocks() { - mode="$1" - dproto="$2" - dhost="$3" - duser="${4-}" - [ -f "$STORE" ] || return 0 - mkdir -p "$DIR" - tmp="$STORE.tmp" - : > "$tmp" - chmod 600 "$tmp" - dp="" - dh="" - du="" - dpw="" - flush_drop() { - [ -n "$dp" ] || [ -n "$dh" ] || return 0 - drop=0 - if [ "$dp" = "$dproto" ] && [ "$dh" = "$dhost" ]; then - if [ "$mode" = "host" ] || [ "$du" = "$duser" ]; then - drop=1 - fi - fi - if [ "$drop" -eq 0 ]; then - printf 'protocol=%s\nhost=%s\nusername=%s\npassword=%s\n\n' "$dp" "$dh" "$du" "$dpw" >> "$tmp" - fi - dp="" - dh="" - du="" - dpw="" - } - while IFS= read -r line || [ -n "$line" ]; do - case "$line" in - "") - flush_drop - ;; - *=*) - key=${line%%=*} - val=${line#*=} - case "$key" in - protocol) dp="$val" ;; - host) dh="$val" ;; - username) du="$val" ;; - password) dpw="$val" ;; - esac - ;; - esac - done < "$STORE" - flush_drop - chmod 600 "$tmp" - mv "$tmp" "$STORE" -} -jwt_expired() { - pw=$1 - case "$pw" in - *.*.*) ;; - *) return 1 ;; - esac - seg1=${pw%%.*} - rest=${pw#*.} - seg2=${rest%%.*} - seg3=${rest#*.} - [ -n "$seg1" ] && [ -n "$seg2" ] && [ -n "$seg3" ] || return 1 - case "$seg3" in - *.*) return 1 ;; - esac - payload=$(printf '%s' "$seg2" | LC_ALL=C tr '_-' '/+' 2>/dev/null) || return 1 - case $(( ${#payload} % 4 )) in - 2) payload="${payload}==" ;; - 3) payload="${payload}=" ;; - 1) return 1 ;; - esac - json="" - if command -v base64 >/dev/null 2>&1; then - if probe=$(printf 'YQ==' | base64 -d 2>/dev/null) && [ "$probe" = "a" ]; then - json=$(printf '%s' "$payload" | base64 -d 2>/dev/null) || json="" - elif probe=$(printf 'YQ==' | base64 -D 2>/dev/null) && [ "$probe" = "a" ]; then - json=$(printf '%s' "$payload" | base64 -D 2>/dev/null) || json="" - fi - fi - if [ -z "$json" ] && command -v openssl >/dev/null 2>&1; then - if probe=$(printf 'YQ==' | openssl base64 -d -A 2>/dev/null) && [ "$probe" = "a" ]; then - json=$(printf '%s' "$payload" | openssl base64 -d -A 2>/dev/null) || json="" - fi - fi - [ -n "$json" ] || return 1 - rest=$(printf '%s' "$json" | LC_ALL=C tr -d '\n' 2>/dev/null) || return 1 - exp="" - while :; do - case "$rest" in - *'"exp"'*) rest=${rest#*'"exp"'} ;; - *) break ;; - esac - while :; do - case "$rest" in - [[:space:]]*) rest=${rest#?} ;; - *) break ;; - esac - done - case "$rest" in - ':'*) rest=${rest#:} ;; - *) continue ;; - esac - while :; do - case "$rest" in - [[:space:]]*) rest=${rest#?} ;; - *) break ;; - esac - done - exp="" - while :; do - case "$rest" in - [0-9]*) - exp="${exp}${rest%"${rest#?}"}" - rest=${rest#?} - ;; - *) break ;; - esac - done - [ -n "$exp" ] && break - done - [ -n "$exp" ] || return 1 - now=$(date +%s 2>/dev/null) || return 1 - case "$now" in - ''|*[!0-9]*) return 1 ;; - esac - if [ "$exp" -le "$((now + 60))" ]; then - return 0 - fi - return 1 -} -"""# - } - - /// The in-container POSIX-sh credential helper (get/store/erase) that the seed - /// script writes to `$HOME/.adevcontainer/git-credential-adev` (mode 0700). - /// `get` matches the persisted store by (protocol, host), ignoring the queried - /// username and path so `credential.useHttpPath` queries still hit the imported - /// credential. A JWT whose `exp` is within 60 seconds is a miss and that store - /// entry is removed; non-JWTs and undecodable tokens are returned as stored. - /// `store` persists to `$HOME/.adevcontainer/git-credentials` (mode 0600) deduped - /// by (protocol, host, username). `erase` removes protocol+host entries with an - /// atomic rewrite (mode 0600). Store format: one protocol/host/username/password - /// key block per entry, blank-line terminated, so raw values round-trip. - static func helperScript() -> String { - [ - "#!/bin/sh", - "set -e", - "DIR=\"$HOME/.adevcontainer\"", - "STORE=\"$DIR/git-credentials\"", - "qproto=\"\"", - "qhost=\"\"", - "quser=\"\"", - "qpass=\"\"", - "while IFS= read -r line || [ -n \"$line\" ]; do", - " case \"$line\" in", - " *=*)", - " key=${line%%=*}", - " val=${line#*=}", - " case \"$key\" in", - " protocol) qproto=\"$val\" ;;", - " host) qhost=\"$val\" ;;", - " username) quser=\"$val\" ;;", - " password) qpass=\"$val\" ;;", - " esac", - " ;;", - " esac", - "done", - "case \"${1:-}\" in", - "get)", - " [ -n \"$qproto\" ] && [ -n \"$qhost\" ] || exit 0", - " [ -f \"$STORE\" ] || exit 0", - " sp=\"\"", - " sh=\"\"", - " su=\"\"", - " spw=\"\"", - " emit() {", - " if jwt_expired \"$spw\" 2>/dev/null; then", - " drop_blocks user \"$sp\" \"$sh\" \"$su\"", - " exit 0", - " fi", - " if [ -n \"$quser\" ]; then", - " outuser=\"$quser\"", - " else", - " outuser=\"$su\"", - " fi", - " printf 'protocol=%s\\nhost=%s\\nusername=%s\\npassword=%s\\n\\n' \"$sp\" \"$sh\" \"$outuser\" \"$spw\"", - " exit 0", - " }", - " while IFS= read -r line || [ -n \"$line\" ]; do", - " case \"$line\" in", - " \"\")", - " if [ \"$sp\" = \"$qproto\" ] && [ \"$sh\" = \"$qhost\" ]; then", - " emit", - " fi", - " sp=\"\"", - " sh=\"\"", - " su=\"\"", - " spw=\"\"", - " ;;", - " *=*)", - " key=${line%%=*}", - " val=${line#*=}", - " case \"$key\" in", - " protocol) sp=\"$val\" ;;", - " host) sh=\"$val\" ;;", - " username) su=\"$val\" ;;", - " password) spw=\"$val\" ;;", - " esac", - " ;;", - " esac", - " done < \"$STORE\"", - " if [ \"$sp\" = \"$qproto\" ] && [ \"$sh\" = \"$qhost\" ]; then", - " emit", - " fi", - " exit 0", - " ;;", - "store)", - " [ -n \"$qproto\" ] && [ -n \"$qhost\" ] || exit 0", - " mkdir -p \"$DIR\"", - " tmp=\"$STORE.tmp\"", - " : > \"$tmp\"", - " chmod 600 \"$tmp\"", - " if [ -f \"$STORE\" ]; then", - " sp=\"\"", - " sh=\"\"", - " su=\"\"", - " spw=\"\"", - " keep() {", - " printf 'protocol=%s\\nhost=%s\\nusername=%s\\npassword=%s\\n\\n' \"$sp\" \"$sh\" \"$su\" \"$spw\" >> \"$tmp\"", - " }", - " while IFS= read -r line || [ -n \"$line\" ]; do", - " case \"$line\" in", - " \"\")", - " if [ \"$sp\" = \"$qproto\" ] && [ \"$sh\" = \"$qhost\" ] && [ \"$su\" = \"$quser\" ]; then", - " :", - " else", - " keep", - " fi", - " sp=\"\"", - " sh=\"\"", - " su=\"\"", - " spw=\"\"", - " ;;", - " *=*)", - " key=${line%%=*}", - " val=${line#*=}", - " case \"$key\" in", - " protocol) sp=\"$val\" ;;", - " host) sh=\"$val\" ;;", - " username) su=\"$val\" ;;", - " password) spw=\"$val\" ;;", - " esac", - " ;;", - " esac", - " done < \"$STORE\"", - " if [ -n \"$sp\" ] || [ -n \"$sh\" ]; then", - " if [ \"$sp\" = \"$qproto\" ] && [ \"$sh\" = \"$qhost\" ] && [ \"$su\" = \"$quser\" ]; then", - " :", - " else", - " keep", - " fi", - " fi", - " fi", - " printf 'protocol=%s\\nhost=%s\\nusername=%s\\npassword=%s\\n\\n' \"$qproto\" \"$qhost\" \"$quser\" \"$qpass\" >> \"$tmp\"", - " chmod 600 \"$tmp\"", - " mv \"$tmp\" \"$STORE\"", - " exit 0", - " ;;", - "erase)", - " [ -n \"$qproto\" ] && [ -n \"$qhost\" ] || exit 0", - " [ -f \"$STORE\" ] || exit 0", - " drop_blocks host \"$qproto\" \"$qhost\"", - " exit 0", - " ;;", - "esac", - "exit 0" - ].joined(separator: "\n") - .replacingOccurrences( - of: "done\ncase \"${1:-}\" in", - with: "done\n\(Self.helperSupportScript())\ncase \"${1:-}\" in" - ) - } - - /// One in-container script: write the username-agnostic credential helper, then - /// approve stdin blocks. `appendGlobalHelper` is the non-Azure path - /// (`git config --global --add`). `configureAzureHost` ensures the product helper - /// is the only empty-free `dev.azure.com` URL-scoped helper and sets `useHttpPath`. - /// Never writes an empty URL-scoped helper and never resets the global helper list. - /// Entries travel via stdin — never argv or env. - static func seedScript(appendGlobalHelper: Bool = true, configureAzureHost: Bool = false) -> String { - var lines = [ - "set -e", - "uid=\"$(id -u 2>/dev/null)\"", - "resolved_home=\"\"", - "if command -v getent >/dev/null 2>&1; then", - " if passwd_entry=$(getent passwd \"$uid\" 2>/dev/null); then", - " old_ifs=$IFS", - " IFS=:", - " read -r _ _ _ _ _ resolved_home _ </dev/null", - "fi", - "[ -n \"$resolved_home\" ] || exit 1", - "if [ ! -d \"$resolved_home\" ]; then", - " mkdir -p \"$resolved_home\" 2>/dev/null || exit 1", - "fi", - "[ -d \"$resolved_home\" ] || exit 1", - "HOME=\"$resolved_home\"", - "export HOME", - "HELPER=\"$HOME/.adevcontainer/git-credential-adev\"", - "mkdir -p \"$HOME/.adevcontainer\" 2>/dev/null", - "cat > \"$HELPER\" 2>/dev/null <<'ADEV_HELPER_EOF'", - Self.helperScript(), - "ADEV_HELPER_EOF", - "chmod 700 \"$HELPER\" 2>/dev/null", - ] - if appendGlobalHelper { - lines.append("git config --global --add credential.helper \"$HELPER\" >/dev/null 2>&1") - } - if configureAzureHost { - // Drop an empty URL-scoped helper (it resets the chain) and keep every - // other value. The product helper is present exactly once. Do not reset - // the global credential.helper list. - lines += [ - "azure_key=\"credential.https://dev.azure.com.helper\"", - "azure_list=\"$HOME/.adevcontainer/.azure-helper-values\"", - ": > \"$azure_list\"", - "chmod 600 \"$azure_list\"", - "git config --global --get-all \"$azure_key\" > \"$azure_list\" 2>/dev/null || true", - "git config --global --unset-all \"$azure_key\" >/dev/null 2>&1 || true", - "azure_seen=0", - "while IFS= read -r azure_value || [ -n \"$azure_value\" ]; do", - " [ -n \"$azure_value\" ] || continue", - " if [ \"$azure_value\" = \"$HELPER\" ]; then", - " if [ \"$azure_seen\" -eq 0 ]; then", - " git config --global --add \"$azure_key\" \"$azure_value\" >/dev/null 2>&1", - " azure_seen=1", - " fi", - " continue", - " fi", - " git config --global --add \"$azure_key\" \"$azure_value\" >/dev/null 2>&1", - "done < \"$azure_list\"", - "rm -f \"$azure_list\"", - "if [ \"$azure_seen\" -eq 0 ]; then", - " git config --global --add \"$azure_key\" \"$HELPER\" >/dev/null 2>&1", - "fi", - "git config --global credential.https://dev.azure.com.useHttpPath true >/dev/null 2>&1", - ] - } - lines += [ - "protocol=\"\"", - "host=\"\"", - "username=\"\"", - "password=\"\"", - "approve() {", - " [ -n \"$protocol\" ] && [ -n \"$host\" ] || return 0", - " printf 'protocol=%s\\nhost=%s\\nusername=%s\\npassword=%s\\n\\n' \"$protocol\" \"$host\" \"$username\" \"$password\" | git credential approve >/dev/null 2>&1", - "}", - "while IFS= read -r line; do", - " case \"$line\" in", - " \"\")", - " approve", - " protocol=\"\"", - " host=\"\"", - " username=\"\"", - " password=\"\"", - " ;;", - " *=*)", - " key=${line%%=*}", - " value=${line#*=}", - " case \"$key\" in", - " protocol) protocol=\"$value\" ;;", - " host) host=\"$value\" ;;", - " username) username=\"$value\" ;;", - " password) password=\"$value\" ;;", - " esac", - " ;;", - " esac", - "done", - "approve" - ] - return lines.joined(separator: "\n") - } - - private func install( - containerId: String, - entries: [Entry], - connectionUser: String?, - runtime: AppleContainerRuntime - ) throws { - guard !entries.isEmpty else { return } - - let input = entries.map { entry in - "protocol=\(entry.protocolName)\nhost=\(entry.host)\nusername=\(entry.username)\npassword=\(entry.password)\n\n" - }.joined() - - let azure = entries.contains { Self.isAzureDevOpsHost($0.host) } - let other = entries.contains { !Self.isAzureDevOpsHost($0.host) } - let result = try runtime.exec( - nameOrId: containerId, - command: ["sh", "-c", Self.seedScript(appendGlobalHelper: other, configureAzureHost: azure)], - user: connectionUser, - env: [:], - stdinData: Data(input.utf8) - ) - guard result.succeeded else { - let safeHosts = entries.map(\.host).filter { - $0.range(of: "^[A-Za-z0-9.-]+$", options: .regularExpression) != nil - } - let detail = safeHosts.isEmpty ? "" : ": host=\(safeHosts.joined(separator: ","))" - throw CLIError( - code: CLIErrorCode.lifecycleFailed, - message: "Failed to seed git credentials in the container (exit \(result.exitCode))\(detail)", - hint: "Ensure in-container git is installed; the container still works without forwarded credentials" - ) - } - } - - private func bindFetchURLs(hostWorkspace: String) -> [String] { - guard let git = resolveGitPath() else { - return [] - } - let result: ProcessResult - do { - result = try runner.run( - executable: git, - arguments: ["-C", hostWorkspace, "remote", "-v"], - environment: nil, - currentDirectory: nil - ) - } catch { - return [] - } - guard result.succeeded else { - return [] - } - return Self.uniqueFetchURLs(from: result.stdoutString) - } - - private func resolveGitPath() -> String? { - if let override = gitPathOverride { - return override - } - return HostGitClient.whichGit() - } - - private struct Entry { - let protocolName: String - let host: String - let username: String - let password: String - } -} diff --git a/Sources/ADevContainerLib/Git/HostGitCredential.swift b/Sources/ADevContainerLib/Git/HostGitCredential.swift index 94f5403..8cb0281 100644 --- a/Sources/ADevContainerLib/Git/HostGitCredential.swift +++ b/Sources/ADevContainerLib/Git/HostGitCredential.swift @@ -1,6 +1,6 @@ import Foundation -/// HTTPS credentials obtained on the host for a one-shot in-container clone. +/// HTTP(S) credentials acquired on the host through the existing provider chain. public struct GitHTTPSCredentials: Equatable, Sendable { public var username: String public var password: String @@ -11,7 +11,7 @@ public struct GitHTTPSCredentials: Equatable, Sendable { } } -/// Host-side HTTPS credential lookup for clone populate (not GCM product integration). +/// Shared host-side lookup (not a guest GCM integration or OAuth renewal API). public protocol GitCredentialProviding: Sendable { /// Resolve HTTPS credentials for `url` via host git credential helper / fallbacks. /// Returns nil when no credentials are available (caller maps to structured error). @@ -32,7 +32,7 @@ public struct HostGitCredential: GitCredentialProviding { public var ghPathOverride: String?? public init( - runner: any ProcessRunning = FoundationProcessRunner(), + runner: any ProcessRunning = CredentialProviderProcessRunner(), gitPathOverride: String?? = nil, environment: [String: String] = ProcessInfo.processInfo.environment, ghPathOverride: String?? = nil @@ -44,10 +44,21 @@ public struct HostGitCredential: GitCredentialProviding { } public func fillHTTPS(url: String) throws -> GitHTTPSCredentials? { + guard let fields = GitURLClassifier.httpsCredentialFields(for: url), + let destination = try? GitCredentialDestination(scheme: fields.protocolName, host: fields.host), + destination.hostname != "dev.azure.com" || !fields.path.split(separator: "/").isEmpty else { return nil } + var provider = self + if let bounded = runner as? CredentialProviderProcessRunner { + provider.runner = bounded.withDeadline(ProcessInfo.processInfo.systemUptime + bounded.timeout) + } + return try provider.acquire(url: url) + } + + private func acquire(url: String) throws -> GitHTTPSCredentials? { // Escape hatch: explicit token (not primary UX). if let token = environment["ADEVCONTAINER_GIT_TOKEN"]? .trimmingCharacters(in: .whitespacesAndNewlines), - !token.isEmpty + !token.isEmpty, GitCredentialProtocol.safe(token), token.utf8.count <= 60000 { return GitHTTPSCredentials(username: "x-access-token", password: token) } @@ -112,7 +123,8 @@ public struct HostGitCredential: GitCredentialProviding { guard let fields = GitURLClassifier.httpsCredentialFields(for: url) else { return nil } - let host = fields.host.lowercased() + guard let destination = try? GitCredentialDestination(scheme: fields.protocolName, host: fields.host) else { return nil } + let host = destination.hostname guard host == "github.com" || host.hasSuffix(".github.com") else { return nil } @@ -132,7 +144,7 @@ public struct HostGitCredential: GitCredentialProviding { } guard result.succeeded else { return nil } let token = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) - guard !token.isEmpty else { return nil } + guard !token.isEmpty, GitCredentialProtocol.safe(token), token.utf8.count <= 60000 else { return nil } return GitHTTPSCredentials(username: "x-access-token", password: token) } @@ -169,24 +181,38 @@ public struct HostGitCredential: GitCredentialProviding { } private func mergedEnvironment() -> [String: String] { - // Start from process env so PATH helpers work, then overlay explicit map. - var env = ProcessInfo.processInfo.environment - for (k, v) in environment { - env[k] = v - } + // Defaults already capture the parent. An explicitly sanitized service/ + // test environment must not be merged with inherited credential config. + var env = environment env["GIT_TERMINAL_PROMPT"] = "0" + env["GCM_INTERACTIVE"] = "Never" + env["GCM_GUI_PROMPT"] = "0" + env["GIT_ASKPASS"] = "/usr/bin/false" + env["SSH_ASKPASS"] = "/usr/bin/false" + env["GH_PROMPT_DISABLED"] = "1" + env.removeValue(forKey: "ADEVCONTAINER_GIT_TOKEN") + return Self.strippingCredentialTraces(env) + } + + static func strippingCredentialTraces(_ environment: [String: String]) -> [String: String] { + var env = environment + for key in Array(env.keys) where key.hasPrefix("GIT_TRACE") || key.hasPrefix("GCM_TRACE") || ["GIT_CURL_VERBOSE", "GH_DEBUG", "GH_FORCE_TTY", "GCM_DEBUG"].contains(key) { + env.removeValue(forKey: key) + } return env } /// Parse `git credential fill` stdout key=value lines. static func parseCredentialOutput(_ text: String) -> [String: String] { + guard text.utf8.count <= GitCredentialProtocol.limit else { return [:] } var result: [String: String] = [:] - for line in text.split(whereSeparator: \.isNewline) { + for line in text.split(separator: "\n") { let s = String(line) - guard let eq = s.firstIndex(of: "=") else { continue } + guard GitCredentialProtocol.safe(s), let eq = s.firstIndex(of: "=") else { return [:] } let key = String(s[.. = [ContainerIdentity.labelManaged, ContainerIdentity.labelConfigHash, ContainerIdentity.labelRemoteUser, + ContainerIdentity.labelLocalFolder, ContainerIdentity.labelConfigFile, ContainerIdentity.labelWorkspaceMode, ContainerIdentity.labelGitURL] + let id: String + let incarnation: String + let platform: String + let user: String + let labels: [String: String] + static func parse(_ data: Data) throws -> Self { + guard data.count <= GitCredentialProtocol.limit, let object = try? JSONSerialization.jsonObject(with: data) else { throw GitCredentialFailure.invalidContext } + let dictionary: [String: Any]? + if let array = object as? [[String: Any]], array.count == 1 { dictionary = array[0] } else { dictionary = object as? [String: Any] } + guard let root = dictionary, let configuration = root["configuration"] as? [String: Any], + let id = configuration["id"] as? String, !id.isEmpty, GitCredentialProtocol.safe(id), + let labels = configuration["labels"] as? [String: String], labels["devcontainer.managed"] == "adevcontainer", + let state = root["status"] as? [String: Any], state["state"] as? String == "running", + let platform = configuration["platform"] as? [String: Any], platform["os"] as? String == "linux", + let architecture = platform["architecture"] as? String, ["arm64", "amd64"].contains(architecture), + let user = labels[ContainerIdentity.labelRemoteUser], !user.isEmpty, GitCredentialProtocol.safe(user), + let creation = configuration["creationDate"] else { throw GitCredentialFailure.invalidContext } + let incarnation: String + if let number = creation as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(), number.doubleValue.isFinite, number.doubleValue > 0 { + incarnation = "reference:\(number.doubleValue)" + } else if let text = creation as? String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date = formatter.date(from: text) ?? ISO8601DateFormatter().date(from: text) + guard let date, date.timeIntervalSince1970 > 0 else { throw GitCredentialFailure.invalidContext } + incarnation = "unix:\(date.timeIntervalSince1970)" + } else { throw GitCredentialFailure.invalidContext } + var identity = labels.filter { identityLabels.contains($0.key) } + if let gitURL = identity[ContainerIdentity.labelGitURL] { identity[ContainerIdentity.labelGitURL] = ContainerIdentity.normalizeGitURL(gitURL) } + guard identity.values.allSatisfy(GitCredentialProtocol.safe) else { throw GitCredentialFailure.invalidContext } + return .init(id: id, incarnation: incarnation, platform: "linux/\(architecture)", user: user, labels: identity) + } +} public struct ContainerInfo: Equatable, Sendable { public var id: String @@ -122,6 +160,84 @@ enum AppleStaleExec { /// Sole module that invokes the Apple `container` CLI. public struct AppleContainerRuntime: Sendable { + static func credentialExecArguments(context: GitCredentialRuntimeContext, command: [String]) throws -> [String] { + guard !context.id.isEmpty, !context.id.hasPrefix("-"), !context.user.isEmpty, !context.user.hasPrefix("-"), !command.isEmpty, + [context.id, context.user].allSatisfy(GitCredentialProtocol.safe), command.allSatisfy({ !$0.utf8.contains(0) }) else { throw GitCredentialFailure.invalidContext } + return ["exec", "-i", "-u", context.user, context.id] + command + } + func credentialContext(id: String, deadline: TimeInterval? = nil) throws -> GitCredentialRuntimeContext { + guard !id.isEmpty, !id.hasPrefix("-"), GitCredentialProtocol.safe(id) else { throw GitCredentialFailure.invalidContext } + let result = try credentialRun(arguments: ["inspect", id], deadline: deadline) + guard result.succeeded else { throw GitCredentialFailure.invalidContext } + return try GitCredentialRuntimeContext.parse(result.stdout) + } + func credentialObservation(id: String, deadline: TimeInterval? = nil) -> GitCredentialObservation { + guard !id.isEmpty, !id.hasPrefix("-"), GitCredentialProtocol.safe(id) else { return .unknown } + let end = deadline ?? (ProcessInfo.processInfo.systemUptime + 1) + guard let inspect = try? credentialRun(arguments: ["inspect", id], deadline: end) else { return .unknown } + let data: Data + if inspect.succeeded { data = inspect.stdout } + else { + // A failed inspect cannot distinguish removal from runtime downtime. + guard let list = try? credentialRun(arguments: ["list", "--all", "--format", "json"], deadline: end), list.succeeded else { return .unknown } + data = list.stdout + } + guard data.count <= GitCredentialProtocol.limit, let object = try? JSONSerialization.jsonObject(with: data) else { return .unknown } + let entries: [[String: Any]] + if let array = object as? [[String: Any]] { entries = array } + else if inspect.succeeded, let dictionary = object as? [String: Any] { entries = [dictionary] } + else { return .unknown } + // Only well-formed successful inventory can prove absence. Do not parse + // human diagnostics, and never adopt any other entry as a session. + guard entries.allSatisfy({ entry in + guard let configuration = entry["configuration"] as? [String: Any], let id = configuration["id"] as? String, !id.isEmpty, + let status = entry["status"] as? [String: Any], let state = status["state"] as? String, !state.isEmpty else { return false } + return configuration["labels"] == nil || configuration["labels"] is [String: String] + }) else { return .unknown } + let matches = entries.filter { ($0["configuration"] as? [String: Any])?["id"] as? String == id } + guard let entry = matches.first else { return !inspect.succeeded || entries.isEmpty ? .inactive : .unknown } + guard matches.count == 1, let configuration = entry["configuration"] as? [String: Any], + let labels = configuration["labels"] as? [String: String], let status = entry["status"] as? [String: Any], let state = status["state"] as? String else { return .unknown } + if labels[ContainerIdentity.labelManaged] != ContainerIdentity.managedValue || state == "stopped" { return .inactive } + guard let bytes = try? JSONSerialization.data(withJSONObject: entry), let context = try? GitCredentialRuntimeContext.parse(bytes) else { return .unknown } + return .running(context) + } + func credentialRun(arguments: [String], input: Data? = nil, deadline: TimeInterval? = nil) throws -> ProcessResult { + let end = deadline ?? (ProcessInfo.processInfo.systemUptime + 30) + guard ProcessInfo.processInfo.systemUptime < end else { throw GitCredentialFailure.deadline } + let selected: any ProcessRunning = runner is FoundationProcessRunner ? CredentialProviderProcessRunner().withDeadline(end) : runner + do { + let result = try selected.run(executable: executablePath, arguments: arguments, environment: ProcessInfo.processInfo.environment.filter { $0.key != "ADEVCONTAINER_GIT_TOKEN" }, currentDirectory: nil, stdinData: input) + guard ProcessInfo.processInfo.systemUptime < end else { throw GitCredentialFailure.deadline } + return result + } catch let error as GitCredentialFailure { throw error } + catch { throw GitCredentialFailure.transport } + } + func credentialExec(context: GitCredentialRuntimeContext, command: [String], input: Data? = nil, deadline: TimeInterval? = nil) throws -> ProcessResult { + try credentialRun(arguments: Self.credentialExecArguments(context: context, command: command), input: input, deadline: deadline) + } + func credentialDuplex(context: GitCredentialRuntimeContext, command: [String]) throws -> CredentialDuplexProcess { + // Buffered-runner fakes must never accidentally open a real container + // channel. Broker tests inject their transport port explicitly instead. + guard runner is FoundationProcessRunner else { throw GitCredentialFailure.invalidContext } + return try CredentialDuplexProcess(executable: executablePath, arguments: Self.credentialExecArguments(context: context, command: command), environment: ProcessInfo.processInfo.environment.filter { $0.key != "ADEVCONTAINER_GIT_TOKEN" }, guarded: true) + } + func credentialBrokerPorts(providerEnvironment: [String: String] = ProcessInfo.processInfo.environment) -> GitCredentialBrokerPorts { + let sanitized = providerEnvironment.filter { $0.key != "ADEVCONTAINER_GIT_TOKEN" } + return .init(context: { try credentialContext(id: $0) }, connect: { registration, generation, receive, disconnected in + let process = try credentialDuplex(context: registration.context, command: [registration.directory + "/adevcontainer-git-relay", "relay", registration.directory, String(generation)]) + do { + let channel = try GitCredentialRelayChannel(process: process, platform: registration.context.platform, generation: generation) + channel.start(receive: receive, disconnected: disconnected); return channel + } catch { process.cancel(); throw GitCredentialFailure.transport } + }, acquire: { url, token, deadline in + var environment = sanitized + if let token { environment["ADEVCONTAINER_GIT_TOKEN"] = token } + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { throw GitCredentialFailure.deadline } + return try HostGitCredential(runner: CredentialProviderProcessRunner(timeout: remaining), environment: environment).fillHTTPS(url: url) + }, observe: { id, deadline in credentialObservation(id: id, deadline: deadline) }) + } public var executablePath: String public var runner: any ProcessRunning /// Used for interactive/TTY `exec` (inherits host stdio). diff --git a/Sources/ADevContainerLib/Runtime/CredentialDuplexProcess.swift b/Sources/ADevContainerLib/Runtime/CredentialDuplexProcess.swift new file mode 100644 index 0000000..729ce98 --- /dev/null +++ b/Sources/ADevContainerLib/Runtime/CredentialDuplexProcess.swift @@ -0,0 +1,169 @@ +import Foundation +import Darwin + +/// Credential-only process lifetime. Unlike the general runner, never tees payloads. +final class CredentialDuplexProcess: @unchecked Sendable { + /// A SIGKILLed process group normally reaps in milliseconds. The wait stays + /// bounded so one process in an uninterruptible state cannot hang broker + /// shutdown, and a deadline exception never blocks on reaping. + static let cancelBound: TimeInterval = 2 + // State/queue/lifetime are protected by state; exactly one reader owns pipe + // drainage. Descriptors close only after all method references release self. + private let state = NSLock() + private let reader = NSLock() + private let pid: pid_t + private let input: Int32 + private let output: Int32 + private let errors: Int32 + private let alive: Int32? + private let childStatus: Int32? + private var pending = Data() + private var inputClosing = false + private var inputClosed = false + private var result: Int32? + private var outputEOF = false + private var errorEOF = false + + init(executable: String, arguments: [String], environment: [String: String], guarded: Bool = false, deadline: TimeInterval? = nil) throws { + guard executable.hasPrefix("/"), !executable.utf8.contains(0), arguments.allSatisfy({ !$0.utf8.contains(0) }), + environment.allSatisfy({ !$0.key.contains("=") && !$0.key.utf8.contains(0) && !$0.value.utf8.contains(0) }) else { throw GitCredentialFailure.transport } + var pipes: [Int32] = [] + defer { for fd in pipes { Darwin.close(fd) } } + for _ in 0..<(guarded ? 5 : 3) { + var pair: [Int32] = [-1, -1] + guard Darwin.pipe(&pair) == 0 else { throw GitCredentialFailure.transport } + pipes += pair + for fd in pair { guard fcntl(fd, F_SETFD, FD_CLOEXEC) == 0 else { throw GitCredentialFailure.transport } } + } + var actions: posix_spawn_file_actions_t?, attributes: posix_spawnattr_t? + guard posix_spawn_file_actions_init(&actions) == 0 else { throw GitCredentialFailure.transport } + defer { posix_spawn_file_actions_destroy(&actions) } + guard posix_spawnattr_init(&attributes) == 0 else { throw GitCredentialFailure.transport } + defer { posix_spawnattr_destroy(&attributes) } + // Dispatch workers block signals. A child that inherits that mask — GCM in + // particular — waits forever instead of returning a cached credential. + var mask = sigset_t() + guard sigemptyset(&mask) == 0, + posix_spawnattr_setsigmask(&attributes, &mask) == 0, + posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT | POSIX_SPAWN_SETSIGMASK)) == 0, + posix_spawnattr_setpgroup(&attributes, 0) == 0 else { throw GitCredentialFailure.transport } + for (fd, target) in [(pipes[0], Int32(0)), (pipes[3], Int32(1)), (pipes[5], Int32(2))] { + guard posix_spawn_file_actions_adddup2(&actions, fd, target) == 0 else { throw GitCredentialFailure.transport } + } + for (index, fd) in pipes.enumerated() where !guarded || (index != 6 && index != 9) { + guard posix_spawn_file_actions_addclose(&actions, fd) == 0 else { throw GitCredentialFailure.transport } + } + if guarded { + for (fd, target) in [(pipes[6], Int32(3)), (pipes[9], Int32(4))] { + guard posix_spawn_file_actions_adddup2(&actions, fd, target) == 0, + posix_spawn_file_actions_addclose(&actions, fd) == 0 else { throw GitCredentialFailure.transport } + } + } + let launched: String + let launchedArguments: [String] + if guarded { + guard let own = Bundle.main.executableURL else { throw GitCredentialFailure.transport } + let candidate = own.lastPathComponent.contains("Tests") ? own.deletingLastPathComponent().appendingPathComponent("adevcontainer") : own + launched = candidate.resolvingSymlinksInPath().path + launchedArguments = ["__adevcontainer_credential_guard", deadline.map(String.init(describing:)) ?? "none", executable] + arguments + } else { launched = executable; launchedArguments = arguments } + var argv = ([launched] + launchedArguments).map { strdup($0) } + [nil] + // Foundation's ordinary runner merges with parent env. This boundary must + // instead pass the exact sanitized environment, never a token override. + var sanitized = environment; sanitized.removeValue(forKey: "ADEVCONTAINER_GIT_TOKEN") + var envp = sanitized.keys.sorted().map { strdup("\($0)=\(sanitized[$0]!)") } + [nil] + defer { for p in argv { free(p) }; for p in envp { free(p) } } + var child: pid_t = 0 + let status = argv.withUnsafeMutableBufferPointer { argv in envp.withUnsafeMutableBufferPointer { envp in + posix_spawn(&child, launched, &actions, &attributes, argv.baseAddress, envp.baseAddress) + } } + guard status == 0 else { throw GitCredentialFailure.transport } + for fd in [pipes[1], pipes[2], pipes[4]] + (guarded ? [pipes[8]] : []) { + guard fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK) == 0 else { + kill(-child, SIGKILL); var status: Int32 = 0; while waitpid(child, &status, 0) < 0 && errno == EINTR {} + throw GitCredentialFailure.transport + } + } + guard fcntl(pipes[1], F_SETNOSIGPIPE, 1) == 0 else { + kill(-child, SIGKILL); var status: Int32 = 0; while waitpid(child, &status, 0) < 0 && errno == EINTR {} + throw GitCredentialFailure.transport + } + pid = child; input = pipes[1]; output = pipes[2]; errors = pipes[4] + alive = guarded ? pipes[7] : nil; childStatus = guarded ? pipes[8] : nil + for fd in [pipes[0], pipes[3], pipes[5]] + (guarded ? [pipes[6], pipes[9]] : []) { Darwin.close(fd) }; pipes.removeAll() + } + deinit { cancel(); if !inputClosed { Darwin.close(input) }; Darwin.close(output); Darwin.close(errors); if let alive { Darwin.close(alive) }; if let childStatus { Darwin.close(childStatus) } } + func send(_ data: Data) throws { + state.lock(); defer { state.unlock() } + guard result == nil, !inputClosing, data.count <= 1024 * 1024 - pending.count else { throw GitCredentialFailure.overloaded } + pending.append(data) + } + func closeInput() { state.lock(); inputClosing = true; state.unlock() } + func read(deadline: TimeInterval, cancelOnTimeout: Bool = true) throws -> Data? { + reader.lock(); defer { reader.unlock() } + var buffer = [UInt8](repeating: 0, count: 16384) + while true { + if ProcessInfo.processInfo.systemUptime >= deadline { + if cancelOnTimeout { cancel(deadline: ProcessInfo.processInfo.systemUptime) } + throw GitCredentialFailure.deadline + } + state.lock() + if result != nil { state.unlock(); throw GitCredentialFailure.transport } + if inputClosing && pending.isEmpty && !inputClosed { Darwin.close(input); inputClosed = true } + let writable = !inputClosed && !pending.isEmpty + state.unlock() + if outputEOF && errorEOF { return nil } + var fds = [pollfd(fd: outputEOF ? -1 : output, events: Int16(POLLIN), revents: 0), + pollfd(fd: errorEOF ? -1 : errors, events: Int16(POLLIN), revents: 0), + pollfd(fd: writable ? input : -1, events: Int16(POLLOUT), revents: 0)] + let ready = poll(&fds, 3, 25) + if ready < 0 { if errno == EINTR { continue }; cancel(); throw GitCredentialFailure.transport } + if fds[2].revents != 0 { + state.lock() + let written = pending.withUnsafeBytes { Darwin.write(input, $0.baseAddress, $0.count) } + if written > 0 { pending.removeFirst(written) } + let failed = written < 0 && errno != EAGAIN && errno != EINTR + state.unlock() + if failed { cancel(); throw GitCredentialFailure.transport } + } + if fds[1].revents != 0 { + let count = Darwin.read(errors, &buffer, buffer.count) + if count == 0 { errorEOF = true } + else if count < 0 && errno != EAGAIN && errno != EINTR { cancel(); throw GitCredentialFailure.transport } + // Raw stderr is discarded: neither retained nor teed. + } + if fds[0].revents != 0 { + let count = Darwin.read(output, &buffer, buffer.count) + if count > 0 { return Data(buffer.prefix(count)) } + if count == 0 { outputEOF = true } + else if errno != EAGAIN && errno != EINTR { cancel(); throw GitCredentialFailure.transport } + } + } + } + @discardableResult func cancel(deadline: TimeInterval? = nil) -> Int32 { + state.lock(); defer { state.unlock() } + if let result { return result } + let end = deadline ?? ProcessInfo.processInfo.systemUptime + Self.cancelBound + // Spawn atomically established the group. Do not reap before signaling: + // retaining the leader PID prevents a reused PID/group from being killed. + kill(-pid, SIGKILL) + var status: Int32 = 0 + while true { + let waited = waitpid(pid, &status, WNOHANG) + if waited == pid { break } + if waited < 0 && errno != EINTR { break } + guard ProcessInfo.processInfo.systemUptime < end else { + // The group was signaled; never hold an exit path past its + // deadline. A later cancel retries the non-blocking reap. + pending.removeAll(); return 137 + } + Thread.sleep(forTimeInterval: 0.01) + } + var exit = (status & 0x7f) == 0 ? (status >> 8) & 0xff : 128 + (status & 0x7f) + if let childStatus { + var bytes = [UInt8](repeating: 0, count: 8) + if Darwin.read(childStatus, &bytes, bytes.count) == 8, Array(bytes.prefix(7)) == [71,67,83,49,0,0,0] { exit = Int32(bytes[7]) } + } + result = exit; pending.removeAll(); return exit + } +} diff --git a/Sources/ADevContainerLib/Runtime/CredentialProcessGuardian.swift b/Sources/ADevContainerLib/Runtime/CredentialProcessGuardian.swift new file mode 100644 index 0000000..0858e6d --- /dev/null +++ b/Sources/ADevContainerLib/Runtime/CredentialProcessGuardian.swift @@ -0,0 +1,65 @@ +import Foundation +import Darwin + +/// Same-executable lifetime guardian. It keeps the process-group leader alive +/// until the complete provider group is killed, including after broker SIGKILL. +/// FD 3 is a byte-free parent-liveness pipe; FD 4 carries only an exit status. +public enum CredentialProcessGuardianEntry { + public static func run(arguments: [String]) -> Int32? { + guard arguments.first == "__adevcontainer_credential_guard" else { return nil } + guard arguments.count >= 3, getpgrp() == getpid(), arguments[2].hasPrefix("/"), + arguments.allSatisfy({ !$0.utf8.contains(0) }) else { return 1 } + var alive = stat(), status = stat() + guard fstat(3, &alive) == 0, alive.st_mode & S_IFMT == S_IFIFO, + fstat(4, &status) == 0, status.st_mode & S_IFMT == S_IFIFO else { return 1 } + let deadline: TimeInterval + if arguments[1] == "none" { deadline = .infinity } + else { + guard let value = TimeInterval(arguments[1]), value.isFinite else { return 1 }; deadline = value + } + func finish(_ code: Int32) -> Never { + signal(SIGPIPE, SIG_IGN) + let bytes: [UInt8] = [71, 67, 83, 49, 0, 0, 0, UInt8(clamping: code)] + _ = bytes.withUnsafeBytes { Darwin.write(4, $0.baseAddress, $0.count) } + // Killing our own group also terminates descendants that inherited + // stdout/stderr; init reaps orphans when the broker is gone. + kill(0, SIGKILL); _exit(127) + } + guard ProcessInfo.processInfo.systemUptime < deadline else { finish(137) } + var actions: posix_spawn_file_actions_t?, attributes: posix_spawnattr_t? + guard posix_spawn_file_actions_init(&actions) == 0 else { finish(127) } + guard posix_spawnattr_init(&attributes) == 0 else { finish(127) } + // The actual provider inherits this guardian's already-established PG. + var mask = sigset_t() + var inherited = sigset_t() + guard sigemptyset(&mask) == 0, sigprocmask(SIG_SETMASK, nil, &inherited) == 0, + posix_spawnattr_setsigmask(&attributes, &mask) == 0, + posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_CLOEXEC_DEFAULT | POSIX_SPAWN_SETSIGMASK)) == 0 else { finish(127) } + let interactive = ProcessInfo.processInfo.environment["GCM_INTERACTIVE"] + let mode = interactive == "never" || interactive == "Never" ? "disabled" : (interactive == nil ? "unset" : "other") + GitCredentialDiag.event("guardian.spawn", "inherited=\(inherited) interactive=\(mode)") + for fd: Int32 in [0, 1, 2] { + guard posix_spawn_file_actions_addinherit_np(&actions, fd) == 0 else { finish(127) } + } + let executable = arguments[2] + var argv = Array(arguments.dropFirst(2)).map { strdup($0) } + [nil] + let environment = ProcessInfo.processInfo.environment.filter { $0.key != "ADEVCONTAINER_GIT_TOKEN" && $0.key != "__CF_USER_TEXT_ENCODING" } + var envp = environment.keys.sorted().map { strdup("\($0)=\(environment[$0]!)") } + [nil] + var child: pid_t = 0 + let result = argv.withUnsafeMutableBufferPointer { argv in envp.withUnsafeMutableBufferPointer { envp in + posix_spawn(&child, executable, &actions, &attributes, argv.baseAddress, envp.baseAddress) + } } + for pointer in argv { free(pointer) }; for pointer in envp { free(pointer) } + posix_spawn_file_actions_destroy(&actions); posix_spawnattr_destroy(&attributes) + guard result == 0 else { finish(127) } + while true { + var peer = pollfd(fd: 3, events: Int16(POLLIN), revents: 0) + let ready = poll(&peer, 1, 10) + if ProcessInfo.processInfo.systemUptime >= deadline || peer.revents != 0 || (ready < 0 && errno != EINTR) { finish(137) } + var status: Int32 = 0 + let exited = waitpid(child, &status, WNOHANG) + if exited == child { finish((status & 0x7f) == 0 ? (status >> 8) & 0xff : 128 + (status & 0x7f)) } + if exited < 0 && errno != EINTR { finish(127) } + } + } +} diff --git a/Sources/ADevContainerLib/Runtime/CredentialProviderProcessRunner.swift b/Sources/ADevContainerLib/Runtime/CredentialProviderProcessRunner.swift new file mode 100644 index 0000000..faa1d0e --- /dev/null +++ b/Sources/ADevContainerLib/Runtime/CredentialProviderProcessRunner.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Bounded, exact-environment provider runner. No raw output is displayed. +public struct CredentialProviderProcessRunner: ProcessRunning { + var deadline: TimeInterval? + public var timeout: TimeInterval + public init(timeout: TimeInterval = 15) { self.timeout = timeout } + func withDeadline(_ deadline: TimeInterval) -> Self { var copy = self; copy.deadline = deadline; return copy } + public func run(executable: String, arguments: [String], environment: [String: String]?, currentDirectory: String?, stdinData: Data?) throws -> ProcessResult { + guard currentDirectory == nil, (stdinData?.count ?? 0) <= GitCredentialProtocol.limit else { throw GitCredentialFailure.invalidMessage } + let end = deadline ?? (ProcessInfo.processInfo.systemUptime + timeout) + guard end > ProcessInfo.processInfo.systemUptime else { throw GitCredentialFailure.deadline } + let process = try CredentialDuplexProcess(executable: executable, arguments: arguments, environment: environment ?? [:], guarded: true, deadline: end) + defer { process.cancel() } + if let stdinData { try process.send(stdinData) }; process.closeInput() + var output = Data() + while let chunk = try process.read(deadline: end) { + guard chunk.count <= GitCredentialProtocol.limit - output.count else { throw GitCredentialFailure.overloaded } + output.append(chunk) + } + return .init(exitCode: process.cancel(), stdout: output, stderr: Data()) + } +} diff --git a/Sources/ADevContainerLib/Runtime/DetachedProcessLauncher.swift b/Sources/ADevContainerLib/Runtime/DetachedProcessLauncher.swift new file mode 100644 index 0000000..994562b --- /dev/null +++ b/Sources/ADevContainerLib/Runtime/DetachedProcessLauncher.swift @@ -0,0 +1,45 @@ +import Foundation +import Darwin + +/// Starts the broker as its own session leader with no inherited terminal. +/// The parent never waits, so the child outlives the invoking CLI. +protocol DetachedProcessLaunching: Sendable { + /// Returns the child pid. The child owns a new session and `/dev/null` stdio. + @discardableResult func launch(executable: String, arguments: [String], environment: [String: String]) throws -> Int32 + /// Reaps a child that exited during the readiness window; never blocks. + func reap(_ process: Int32) +} + +extension DetachedProcessLaunching { + func reap(_ process: Int32) { _ = waitpid(process, nil, WNOHANG) } +} + +struct PosixDetachedProcessLauncher: DetachedProcessLaunching { + func launch(executable: String, arguments: [String], environment: [String: String]) throws -> Int32 { + guard executable.hasPrefix("/"), arguments.allSatisfy({ !$0.utf8.contains(0) }), + environment.keys.allSatisfy({ !$0.isEmpty && !$0.contains("=") }) else { throw GitCredentialFailure.invalidContext } + var actions: posix_spawn_file_actions_t?, attributes: posix_spawnattr_t? + guard posix_spawn_file_actions_init(&actions) == 0, posix_spawnattr_init(&attributes) == 0 else { throw GitCredentialFailure.transport } + defer { posix_spawn_file_actions_destroy(&actions); posix_spawnattr_destroy(&attributes) } + for descriptor: Int32 in [0, 1, 2] { + guard posix_spawn_file_actions_addopen(&actions, descriptor, "/dev/null", O_RDWR, 0) == 0 else { throw GitCredentialFailure.transport } + } + var mask = sigset_t(), inherited = sigset_t() + guard sigfillset(&mask) == 0, sigemptyset(&inherited) == 0, + posix_spawnattr_setsigdefault(&attributes, &mask) == 0, + posix_spawnattr_setsigmask(&attributes, &inherited) == 0, + posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETSID | POSIX_SPAWN_CLOEXEC_DEFAULT | POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK)) == 0 else { throw GitCredentialFailure.transport } + var argv = ([executable] + arguments).map { strdup($0) } + [nil] + var envp = environment.keys.sorted().map { strdup("\($0)=\(environment[$0]!)") } + [nil] + var child: pid_t = 0 + let result = argv.withUnsafeMutableBufferPointer { argv in + envp.withUnsafeMutableBufferPointer { envp in + posix_spawn(&child, executable, &actions, &attributes, argv.baseAddress, envp.baseAddress) + } + } + for pointer in argv { free(pointer) } + for pointer in envp { free(pointer) } + guard result == 0, child > 0 else { throw GitCredentialFailure.transport } + return child + } +} diff --git a/Sources/ADevContainerLib/Runtime/ProcessRunner.swift b/Sources/ADevContainerLib/Runtime/ProcessRunner.swift index fa22470..4c2c26e 100644 --- a/Sources/ADevContainerLib/Runtime/ProcessRunner.swift +++ b/Sources/ADevContainerLib/Runtime/ProcessRunner.swift @@ -87,6 +87,19 @@ extension ProcessRunning { } } +func sanitizedProcessEnvironment(_ overlay: [String: String]?) -> [String: String] { + var env = ProcessInfo.processInfo.environment + if let overlay { + for (key, value) in overlay { + env[key] = value + } + } + // A nil or partial overlay must not reintroduce the host token. Container + // CLI children run on the host; the guest does not need this variable. + env.removeValue(forKey: "ADEVCONTAINER_GIT_TOKEN") + return env +} + public struct FoundationProcessRunner: StreamTeeingProcessRunning { public init() {} @@ -124,13 +137,7 @@ public struct FoundationProcessRunner: StreamTeeingProcessRunning { process.executableURL = URL(fileURLWithPath: executable) process.arguments = arguments - var env = ProcessInfo.processInfo.environment - if let environment { - for (k, v) in environment { - env[k] = v - } - } - process.environment = env + process.environment = sanitizedProcessEnvironment(environment) if let currentDirectory { process.currentDirectoryURL = URL(fileURLWithPath: currentDirectory) @@ -311,13 +318,7 @@ public struct InteractiveProcessRunner: ProcessRunning { process.executableURL = URL(fileURLWithPath: executable) process.arguments = arguments - var env = ProcessInfo.processInfo.environment - if let environment { - for (k, v) in environment { - env[k] = v - } - } - process.environment = env + process.environment = sanitizedProcessEnvironment(environment) if let currentDirectory { process.currentDirectoryURL = URL(fileURLWithPath: currentDirectory) diff --git a/Sources/ADevContainerLib/Support/CommandSurface.swift b/Sources/ADevContainerLib/Support/CommandSurface.swift index 19c3376..c86ab4d 100644 --- a/Sources/ADevContainerLib/Support/CommandSurface.swift +++ b/Sources/ADevContainerLib/Support/CommandSurface.swift @@ -338,7 +338,7 @@ public enum CommandSurface { CLIError( code: CLIErrorCode.usage, message: "Unknown subcommand '\(subcommand)'", - hint: "Try: doctor | plugin | up | clone | rebuild | list | start | refresh-credentials | exec | stop | delete | purge | inspect" + hint: "Try: doctor | plugin | up | clone | rebuild | list | start | exec | stop | delete | purge | inspect" ) } @@ -360,8 +360,6 @@ public enum CommandSurface { clone Clone repo into volume-mode dev container (managed) list [--json] List managed dev containers (up + clone) start [--name] Start a stopped managed container (initialize + postStart on real start; --json suppresses recovery prompt). A real start refreshes git credentials; already running does not - refresh-credentials [--name] - Refresh git credentials in a running managed container from the host (does not start, stop, fetch, pull, or push) exec [-it] [--name] [--] [cmd...] Run a command (or shell) in a managed dev container stop [--name] Stop a managed dev container (name or picker) delete [--name] Remove container only (not workspace volume) @@ -372,7 +370,7 @@ public enum CommandSurface { Options: -w, --workspace Workspace root for `up` only (default: cwd) - --name Managed container name/id (exec/start/refresh-credentials/stop/delete/purge/rebuild/inspect) + --name Managed container name/id (exec/start/stop/delete/purge/rebuild/inspect) --resume Resume clone from a retained config checkout (clone only) --reuse-volume Keep an existing clone workspace volume (clone only) --replace-volume Delete an existing clone workspace volume and clone fresh (clone only) @@ -420,7 +418,7 @@ public enum CommandSurface { --reuse-volume or --replace-volume and do not delete. Reuse keeps the tree and never deletes that volume, including on later failure. - SSH: needs ssh-agent (SSH_AUTH_SOCK); create --ssh for later push - - HTTPS: host git credential fill one-shot; non-Azure guest credential.helper store; dev.azure.com uses a URL-scoped helper and useHttpPath + - HTTP(S): automatic on-demand host credentials through a private helper; Azure retains URL-scoped useHttpPath. Sign in on the host if interaction is needed. - Auto-adds Features git:1 when config lacks git/common-utils - No --branch / PAT CLI / GCM-in-guest - Bring-up recovery: on an eligible failure (resolve/create/start/ownership/ @@ -512,9 +510,11 @@ public enum CommandSurface { Settings and extensions from customizations.vscode apply by default after create-path hooks (and on reuse / start-stopped when the marker is pending). Starting a stopped matching container refreshes git credentials from the host - before postStart (soft-fail; does not fail up or enter recovery). Reusing an - already-running container does not refresh. Use refresh-credentials for a - container that is already running. + before postStart (soft-fail; does not fail up or enter recovery). Fresh create + connects before create hooks; already-running reuse repairs the automatic + connection without acquiring credentials when healthy. That repair does not + clear rejection suppression. To clear it, stop the container and start it again. + If login is needed, authenticate on the host. Order with --vscode: apply → open → postAttach. Open soft-fail does not skip postAttach. postAttach failure fails up but keeps the container. Apply is soft-fail with marker skip when matched. @@ -533,8 +533,10 @@ public enum CommandSurface { Host git fetches config only; full clone runs inside the container. SSH URLs require ssh-agent (SSH_AUTH_SOCK); create injects --ssh. - HTTPS uses host git credential fill once, then guest credential store. - Optional: ADEVCONTAINER_GIT_TOKEN. No PAT flags / GCM-in-guest. + HTTP(S) uses automatic on-demand host credentials before populate or reused + tree hooks, without guest token stores. Optional: ADEVCONTAINER_GIT_TOKEN + (memory-only host override). No PAT flags / GCM-in-guest. If login is needed, + authenticate on the host and retry; the product never prompts in the guest. If the config has no git/common-utils feature, clone injects ghcr.io/devcontainers/features/git:1 for in-container git. @@ -586,9 +588,11 @@ public enum CommandSurface { Start a stopped managed container. Real start runs host initializeCommand (when a host workspace exists), refreshes git credentials from the host (soft-fail; does not fail start or enter recovery), then config postStartCommand - and remelted feature postStart. Already running does not refresh credentials - and is a success no-op for those hooks. Use refresh-credentials to refresh a - container that is already running. + and remelted feature postStart. Already running repairs the automatic + connection without acquiring credentials when healthy and is a success no-op + for those hooks. That repair does not clear rejection suppression. To clear + it, stop the container and start it again. If login is needed, authenticate + on the host. --vscode: best-effort open VS Code on the labeled remote workspace folder after start (inspect for id/image/folder). Soft-fail open. postAttach runs after a @@ -599,24 +603,6 @@ public enum CommandSurface { and --json fail with that exact rebuild hint. Start never opens an editor or retries start. Not full extension parity. """ - case "refresh-credentials": - return """ - \(cmd) refresh-credentials [--name ] - - Refresh git credentials in a running managed container from the host git - credential store. Bind mode reads the host workspace's HTTPS fetch remotes; - volume mode uses the stamped git URL. Selection matches start (--name or - the interactive picker). - - The container must already be running. If it is stopped, this command fails - and does not start it — run `\(cmd) start --name ` (start also refreshes - credentials). Already-running start and up reuse do not refresh. - - Does not git fetch, pull, or push, and does not write the workspace. - Credential material is read on the host and passed to the guest on stdin only, - never in argv or the environment. An exec failure fails this command. - dev.azure.com keeps useHttpPath and does not install an empty helper reset. - """ case "exec": return """ \(cmd) exec [-it] [--name ] [--] [cmd...] diff --git a/Sources/ADevContainerLib/Support/ExecutableIdentity.swift b/Sources/ADevContainerLib/Support/ExecutableIdentity.swift new file mode 100644 index 0000000..e67f547 --- /dev/null +++ b/Sources/ADevContainerLib/Support/ExecutableIdentity.swift @@ -0,0 +1,101 @@ +import Foundation +import Darwin + +/// Nonsecret identity of a broker executable. Device/inode catch a replaced +/// file (new keg, new copy); the Mach-O build UUID catches an in-place relink +/// that keeps the inode. Path strings never participate. +struct ExecutableIdentity: Codable, Equatable, Sendable { + let executable: String + let device: UInt64 + let inode: UInt64 + /// Mach-O LC_UUID as lowercase hex, or empty for non-Mach-O test wrappers. + let build: String + let protocolVersion: Int + let process: Int32 + + /// Identity of the running process, resolved through the physical path so a + /// symlinked invocation still compares the same file. + static func current() throws -> Self { try read(currentExecutablePath()) } + + /// Physical path of this process, independent of a selected `argv[0]`. + static func currentExecutablePath() throws -> String { + var size = UInt32(PATH_MAX) + var buffer = [CChar](repeating: 0, count: Int(size)) + guard _NSGetExecutablePath(&buffer, &size) == 0 else { throw GitCredentialFailure.invalidContext } + return String(decoding: buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self) + } + + static func read(_ path: String) throws -> Self { + guard path.hasPrefix("/"), let physical = realpath(path, nil) else { throw GitCredentialFailure.invalidContext } + defer { free(physical) } + let resolved = String(cString: physical) + let fd = open(resolved, O_RDONLY | O_NOFOLLOW | O_CLOEXEC) + guard fd >= 0 else { throw GitCredentialFailure.invalidContext } + defer { close(fd) } + var st = stat() + guard fstat(fd, &st) == 0, st.st_mode & S_IFMT == S_IFREG, st.st_nlink >= 1 else { throw GitCredentialFailure.invalidContext } + return .init(executable: resolved, device: UInt64(st.st_dev), inode: UInt64(st.st_ino), build: buildIdentifier(fd, size: st.st_size), + protocolVersion: GitCredentialProtocol.version, process: getpid()) + } + + /// Activation comparison. A non-Mach-O wrapper has no build UUID, so + /// device/inode plus protocol must carry the decision; path equality never + /// participates. + func matches(_ other: Self) -> Bool { + device == other.device && inode == other.inode && protocolVersion == other.protocolVersion + && (build.isEmpty || other.build.isEmpty || build == other.build) + } + + /// 64-bit thin Mach-O, or the first slice of a universal binary. Only the + /// header and load commands are read; a non-Mach-O file reports no build + /// identity, and a truncated header reports none rather than guessing. + private static func buildIdentifier(_ fd: Int32, size: off_t) -> String { + guard size >= 28 else { return "" } + func read(_ offset: Int, _ count: Int) -> [UInt8] { + guard offset >= 0, count > 0 else { return [] } + var bytes = [UInt8](repeating: 0, count: count) + let received = bytes.withUnsafeMutableBytes { pread(fd, $0.baseAddress, count, off_t(offset)) } + return received == count ? bytes : [] + } + func integer(_ bytes: [UInt8], _ offset: Int, bigEndian: Bool) -> UInt32 { + guard offset >= 0, offset + 4 <= bytes.count else { return 0 } + let value = bytes[offset..<(offset + 4)].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + return bigEndian ? value : value.byteSwapped + } + func long(_ bytes: [UInt8], _ offset: Int, bigEndian: Bool) -> UInt64 { + guard offset >= 0, offset + 8 <= bytes.count else { return 0 } + let value = bytes[offset..<(offset + 8)].reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } + return bigEndian ? value : value.byteSwapped + } + let header = read(0, 4096) + guard header.count == 4096 else { return "" } + // fat_arch entries carry the first slice offset after cputype/cpusubtype. + var slice = 0 + switch integer(header, 0, bigEndian: true) { + case 0xcafebabe: slice = Int(integer(header, 16, bigEndian: true)) + case 0xcafebabf: slice = Int(long(header, 16, bigEndian: true)) + case 0xbebafeca: slice = Int(integer(header, 16, bigEndian: false)) + case 0xbfbafeca: slice = Int(long(header, 16, bigEndian: false)) + default: break + } + let bytes = slice == 0 ? header : read(slice, 8192) + guard bytes.count >= 32 else { return "" } + let bigEndian: Bool + switch integer(bytes, 0, bigEndian: true) { + case 0xcffaedfe: bigEndian = false // MH_MAGIC_64, little-endian arch + case 0xfeedfacf: bigEndian = true // MH_MAGIC_64, big-endian arch + default: return "" // script, 32-bit, or unknown: no build identity + } + let commands = Int(integer(bytes, 16, bigEndian: bigEndian)) + var offset = 32 + for _ in 0..= 8, offset + size <= bytes.count else { return "" } + if command == 0x1b, size >= 24 { + return bytes[(offset + 8)..<(offset + 24)].map { String(format: "%02x", $0) }.joined() + } + offset += size + } + return "" + } +} diff --git a/Sources/adevcontainer/AdevcontainerMain.swift b/Sources/adevcontainer/AdevcontainerMain.swift index 0bb3169..0371706 100644 --- a/Sources/adevcontainer/AdevcontainerMain.swift +++ b/Sources/adevcontainer/AdevcontainerMain.swift @@ -6,6 +6,8 @@ struct AdevcontainerMain { static func main() { let processPath = CommandLine.arguments.first ?? "" let raw = Array(CommandLine.arguments.dropFirst()) + if let result = CredentialProcessGuardianEntry.run(arguments: raw) { exit(result) } + if let result = GitCredentialBrokerEntry.run(arguments: raw) { exit(result) } CommandSurface.commandPrefix = CommandSurface.invocationPrefix( processPath: processPath, argv: raw @@ -153,20 +155,6 @@ struct AdevcontainerMain { ) return 0 - case "refresh-credentials": - guard parsed.passthrough.isEmpty else { - throw CLIError( - code: CLIErrorCode.usage, - message: "refresh-credentials does not accept positional arguments", - hint: "Usage: \(CommandSurface.commandPrefix) refresh-credentials [--name ]" - ) - } - try RefreshCredentialsCommand.run( - name: parsed.name, - runtime: runtime - ) - return 0 - case "exec": let command = parsed.passthrough let interactive = diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..a028c4e --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,60 @@ +# Third-party notices + +The embedded Linux Git credential relay variants are statically linked with musl +and the compiler runtime supplied by Zig 0.15.2. These notices accompany the single +host executable in distributions. Zig/LLVM are build tools, not guest prerequisites. +No LLVM compiler, C++ library or unwinder is distributed by this guest build. + +## musl libc + +From the checksum-pinned Zig 0.15.2 archive, `lib/libc/musl/COPYRIGHT`: + +Copyright © 2005-2020 Rich Felker, et al. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +The x86_64 port (Nicholas J. Kain) uses these same MIT terms. The musl +AArch64 string routines additionally carry Copyright (c) 2012-2020, Arm Limited, +under the MIT terms above. The pinned upstream copyright inventory also credits +Copyright © 1999-2019, Arm Limited for AArch64 memcpy/memset code. + +## Zig compiler runtime + +From the checksum-pinned Zig 0.15.2 archive, `LICENSE` (MIT/Expat): + +Copyright (c) Zig contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Tests/GuestGitCredentialRelay/README.md b/Tests/GuestGitCredentialRelay/README.md new file mode 100644 index 0000000..2437809 --- /dev/null +++ b/Tests/GuestGitCredentialRelay/README.md @@ -0,0 +1,60 @@ +# Native credential relay development + +These checks use synthetic strings, local pipes/sockets and build artifacts only. +They do not run Apple `container`, a broker process, launchd, host Git credential helpers or keychain. + +```sh +sh scripts/test-guest-credential-relay.sh +sh scripts/build-guest-credential-relay.sh +sh Tests/GuestGitCredentialRelay/build_artifacts_tests.sh +swift build --product adevcontainer +``` + +macOS arm64 preparation downloads the official Zig 0.15.2 archive into +`.build/toolchains/zig-0.15.2/`, authenticates its pinned SHA-256 before extraction +and on every reuse, and uses that compiler rather than an installed `zig`. +Required source-build tools: CLT (`cc`, Swift/Foundation/CryptoKit), POSIX shell, +curl, tar with xz support and shasum (or sha256sum). The initial archive download +requires HTTPS access to ziglang.org; subsequent builds use the verified archive. +Every invocation extracts a new owner-private tree from that authenticated archive; +extracted compilers, headers and libraries are never reused. Build/test callers +remove their extraction afterwards. Cache contents are not runtime inputs. +Concurrent cache initialization is serialized; an interrupted initializer may +leave `bootstrap.lock`. Remove that exact directory only after confirming no +preparation is running. No global toolchain installation is performed. + +`build-guest-credential-relay.sh` builds baseline `aarch64-linux-musl` and +`x86_64-linux-musl` static executables. It then verifies ELF metadata, including +machine, program/section table bounds, executable entry point and absence of +PT_INTERP/PT_DYNAMIC, and generates a deterministic manifest and base64 Swift +source. The generated Swift file and all build outputs/caches are gitignored. +Do not hand-edit or commit them. The manifest records source SHA-256, protocol, +both toolchain archive pins and individual binary lengths/hashes. + +The artifact tests rebuild into two separate output/cache roots, compare bytes, +verify embedded payloads/metadata against those bytes, and reject wrong-machine, +interpreter, dynamic-segment and truncated ELF inputs. Tests deliberately supply +a broken `zig` on PATH, a corrupt cached archive and tampered extracted compiler/ +auxiliary files. Native macOS tests exercise +a CLT-built executable; they are not Linux-target execution evidence. On Linux +x86-64, first run `sh scripts/build-guest-credential-relay.sh --raw-only`, then +the native harness; it executes the shipped static amd64 output, not a substitute +parser build. Linux does not require Swift or the Apple runtime for these steps. +Actual arm64 Linux execution remains separately authorized smoke work. + +The wire contract is documented at the top of `Guest/GitCredentialRelay/main.c`. +Native fixtures in `relay_tests.c` are the source for the later host codec tests. +The relay requires a private, physical (no symlink components) absolute directory, +checks same-UID peers, locks per-directory ownership, and will only unlink an +owned socket. Its lock file is nonsecret and persists for subsequent reuse. +Clients have a 15-second end-to-end deadline and use one request per connection. +Backpressured Linux pipe output uses an independent nonblocking proc-fd reopen; +other output uses one supervised/reaped writer with an orphan alarm. Neither +changes Git's shared stdout status flags. Tests include a 60-KB reply to a nearly +full pipe, 64-client saturation and (on Linux) pending-disconnect CPU usage. + +**Implementation status:** these are native delivery tests, not the complete +automatic credential product. Swift policy/protocol/provider/runtime/registry and +broker-channel tests are available with +`ADEVCONTAINER_CREDENTIAL_TESTS_ONLY=1 swift run adevcontainerTests`. +Detached broker activation, provisioning/migration, and lifecycle integration are implemented and covered by the synthetic credential suite and the separately invoked isolated detached probe. The separately authorized plantsuite smoke also verified real credential lookups, concurrent Git operations, CLI-exit survival, stop/idle exit, and recovery on the next start after a crash; it does not certify OAuth renewal or the external-stop retirement bound. These results do not certify release packaging or every guest platform. See [the archived execution record](../../specs/changes/archive/20261022-git-broker-detached-lifecycle/tasks.md) for evidence and limits. Do not run the full Swift test suite without checking its real-runtime side effects. diff --git a/Tests/GuestGitCredentialRelay/SMOKE.md b/Tests/GuestGitCredentialRelay/SMOKE.md new file mode 100644 index 0000000..3abade6 --- /dev/null +++ b/Tests/GuestGitCredentialRelay/SMOKE.md @@ -0,0 +1,125 @@ +# Isolated credential smoke + +This procedure is not an automatic check. Do not run it against a user +container, a real Git credential provider, or the production broker state under +`~/Library/Application Support/adevcontainer/git-credentials` and +`~/.adevcontainer/git-broker`. + +## Authorization + +Both gates are required, and neither is set by unit tests: + +```sh +ADEVCONTAINER_CREDENTIAL_SMOKE=1 +ADEVCONTAINER_CREDENTIAL_SMOKE_AUTHORIZED=1 +``` + +Without the second variable the test process must exit before container creation, +keychain, or network authentication. The automatic suite does not execute the live +arm64 guest path even when both variables are set. That execution remains a +separately authorized operator step. + +## Temporary root + +Use only a unique directory under the approved temporary root +(`$TMPDIR/opencode`, resolved with `realpath`). The broker context label must be +`com.adevcontainer.git-credentials.test.`. State, socket, helper, and +provider config must live under that directory. Do not change `HOME` to hide the +production broker and do not write anywhere under `~/Library`. + +## Synthetic provider + +Write a test-owned Git config that selects only a local fixture helper for +`https://credential-smoke.invalid`. The provider environment may contain only +`PATH`, `HOME`, `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM=/dev/null`, and +`GIT_CONFIG_NOSYSTEM=1`. Exclude user and system Git config, inherited +`GIT_CONFIG_COUNT` / `GIT_CONFIG_PARAMETERS`, `ADEVCONTAINER_GIT_TOKEN`, +`GH_TOKEN`, `GITHUB_TOKEN`, and tracing. The fixture password is the literal +`synthetic-smoke`. Do not print it in logs. A successful fill of that host is +not evidence of OAuth renewal or of a real provider. + +## Baseline + +Before the run, record existence and a nonsecret metadata fingerprint of the +production broker state directory. Do not dump credential-store contents. +After success, injected failure, and a handled signal, compare that baseline. +It must be unchanged. + +## Cleanup that the harness can run + +On success, failure, and handled termination: + +1. Terminate only the broker pids recorded in the run manifest (`kill -TERM `). +2. Remove only the helper, provider config, state, and socket root under the + private test root. +3. Remove only containers recorded in the run manifest. +4. Confirm the broker process, control socket, and those containers are absent. + +Do not signal a pid the manifest does not list, do not delete a broad temporary +directory, and never run `container prune` or a broad container/volume delete. + +## SIGKILL + +A killed process cannot run a trap. Before the first activation, and again for +each activation that records a broker pid, the harness prints one secret-free +line with the fixture root, state path, and pid. The broker also writes its pid +to `/pid`. An operator sweep must use only those recorded paths and pids. +Absence of the production broker from those lines is required; do not treat a +crash as permission to repair the user's installation. + +## Isolated detached-broker lifetime test (no containers or real credential acquisition) + +The separate detached probe exercises the real broker/service/control loop with +synthetic runtime observations and in-memory channels. It starts the same test +executable as a detached child from a private context, exactly as the product +does. It never selects the installed CLI, the production context, the Apple +runtime, launchd, or a host credential provider. Its acquisition port returns a +miss unless the fixture explicitly requests the post-upgrade lookup, and every +attempt is recorded; tests that must prove no provider polling require that count +to stay at zero. + +```sh +ADEVCONTAINER_TESTS_SYNTHETIC_ONLY=1 swift run adevcontainerTests +ADEVCONTAINER_DETACHED_BROKER_TEST=1 \ + ADEVCONTAINER_CREDENTIAL_TESTS_ONLY=1 \ + .build/debug/adevcontainerTests +``` + +The probe gate is not set by ordinary tests. Without it the detached suite is +excluded, not counted as empirical evidence. CI runs only the synthetic suite and +does not set the detached gate, so detached results are recorded only when this +command is invoked explicitly. Normal safe regression selection is +`ADEVCONTAINER_TESTS_SYNTHETIC_ONLY=1 swift run adevcontainerTests` with all live +gates unset. + +The fixture creates one short private root below the physical `$TMPDIR/opencode`, +one `com.adevcontainer.git-credentials.test.` context, and test-owned copies +of the test executable. Copies are how an upgrade is simulated: a replacement file +has the same build bytes but a different device/inode. The test never reads or +fingerprints production state or credentials. No containers, network, keychain, +launchd job, plist, or real provider are involved. + +Checks cover: broker started before the consumer and surviving CLI exit; two +concurrent CLIs converging on one process; idle exit in the 5–7-second window with +the control socket removed and no automatic replacement; SIGKILL leaving a stale +socket that is not usable and no replacement during a window longer than the idle +bound, with the next activation restoring the process, the nonsecret registration, +and the channel; replacement of an owner from a different executable with +registrations preserved and no repeated kill of the healthy successor, followed by +a channel lookup the successor serves from the saved registration; graceful exit +removing only its own socket while preserving lock inodes and registrations; a +stale privately owned socket replaced safely; a same-user socket that accepts but +stays silent failing closed without replacing its inode or starting a competing +owner; a symlinked endpoint failing closed without deleting the target; a channel +whose cancel is slow not extending broker shutdown past 7 seconds; and SIGHUP +ignored while SIGTERM exits gracefully. Pending work is cancelled on exit; a +successor does not restore an explicit token override or replay a provider request. + +Before the first activation, stderr prints the exact fixture root, state path, +and pid for recovery after an unhandleable parent termination. On normal +completion, failure, SIGINT, or SIGTERM the parent terminates only recorded pids, +verifies their exit, and then removes the owned root. A parent SIGKILL cannot run cleanup: use only the printed +root and the pids recorded there, never a broad temporary-directory deletion. + +See [the archived execution record](../../specs/changes/archive/20261022-git-broker-detached-lifecycle/tasks.md) for execution evidence +and scenario coverage. diff --git a/Tests/GuestGitCredentialRelay/build_artifacts_tests.sh b/Tests/GuestGitCredentialRelay/build_artifacts_tests.sh new file mode 100644 index 0000000..86997c3 --- /dev/null +++ b/Tests/GuestGitCredentialRelay/build_artifacts_tests.sh @@ -0,0 +1,126 @@ +#!/bin/sh +# No runtime/provider/broker effects. Downloads only the officially pinned local toolchain. +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd -P) +mkdir -p "$root/.build" +work=$(mktemp -d "$root/.build/credential-artifact-tests.XXXXXX") +first_zig= second_zig= third_zig= +cleanup() { + for compiler in "$first_zig" "$second_zig" "$third_zig"; do + [ -n "$compiler" ] || continue + tree=$(dirname "$(dirname "$compiler")") + case "$tree" in "$root/.build/toolchains/zig-0.15.2/verified."*) rm -rf "$tree" ;; esac + done + rm -rf "$work" +} +trap cleanup EXIT +trap 'exit 1' HUP INT TERM +fail() { echo "FAIL: $*" >&2; exit 1; } +must_fail() { if "$@" >"$work/rejected.stdout" 2>"$work/rejected.stderr"; then fail "accepted invalid artifact/toolchain"; fi; } +if [ "${1:-}" = --package ]; then + [ "$#" = 2 ] || fail 'usage: build_artifacts_tests.sh --package CLI_BINARY' + [ -f "$root/scripts/package-release.sh" ] || fail 'release packaging is absent' + sh "$root/scripts/package-release.sh" "$2" "$work/dist" + mkdir "$work/relocated" + tar -xzf "$work/dist/adevcontainer-macos-arm64.tar.gz" -C "$work/relocated" + [ "$(find "$work/relocated" -type f | wc -l | tr -d ' ')" = 3 ] || fail 'archive layout drift' + [ -f "$work/relocated/LICENSE" ] && [ -f "$work/relocated/THIRD_PARTY_NOTICES.md" ] || fail 'missing licenses' + cmp "$root/THIRD_PARTY_NOTICES.md" "$work/relocated/THIRD_PARTY_NOTICES.md" + (cd "$work/relocated" && env -i PATH=/usr/bin:/bin ./adevcontainer __adevcontainer_git_broker __verify_embedded_assets) > "$work/embedded.json" + swift - "$work/embedded.json" "$root/.build/guest-credential-relay/manifest.json" <<'SWIFT' +import Foundation +let actual = try JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[1]))) as! [[String: Any]] +let expected = try JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[2]))) as! [[String: Any]] +precondition(actual.count == 2 && expected.count == 2) +for row in expected { + let target = row["target"] as! String + let found = actual.first { $0["target"] as? String == target }! + precondition(found["sha256"] as? String == row["sha256"] as? String) + precondition(found["size"] as? Int == row["size"] as? Int) + precondition(found["protocolVersion"] as? Int == row["protocolVersion"] as? Int) +} +SWIFT + echo 'PASS packaged notices and relocated embedded guest artifacts' + exit 0 +fi +# An absent generator/build is a behavioral delivery failure, not a compiler failure. +[ -f "$root/scripts/build-guest-credential-relay.sh" ] || fail 'static guest artifact preparation is absent' +[ -f "$root/scripts/prepare-guest-credential-assets.swift" ] || fail 'ELF verifier/generator is absent' +mkdir -p "$work/toolchain" +case "$(uname -s)-$(uname -m)" in + Darwin-arm64) archive=zig-aarch64-macos-0.15.2.tar.xz ;; + Linux-x86_64) archive=zig-x86_64-linux-0.15.2.tar.xz ;; + *) fail 'unsupported build host' ;; +esac +printf 'corrupt synthetic toolchain archive' > "$work/toolchain/$archive" +must_fail sh "$root/scripts/bootstrap-guest-toolchain.sh" --root "$work/toolchain" +grep -q 'checksum' "$work/rejected.stderr" || fail 'bad checksum did not name its fix' +# Every returned compiler/sysroot must come from the authenticated archive, not a +# previously extracted mutable tree. Do not execute the synthetic replacements. +first_zig=$(sh "$root/scripts/bootstrap-guest-toolchain.sh") +printf '#!/bin/sh\nexit 99\n' > "$first_zig" +second_zig=$(sh "$root/scripts/bootstrap-guest-toolchain.sh") +grep -q 'exit 99' "$second_zig" && fail 'reused modified compiler' +printf 'synthetic auxiliary tampering' > "$(dirname "$second_zig")/lib/std/std.zig" +third_zig=$(sh "$root/scripts/bootstrap-guest-toolchain.sh") +grep -q 'synthetic auxiliary tampering' "$(dirname "$third_zig")/lib/std/std.zig" && fail 'reused modified auxiliary file' +# An installed zig (including a deliberately broken one) is never consulted. +mkdir -p "$work/bin" +printf '#!/bin/sh\necho unexpected-installed-zig >&2\nexit 99\n' > "$work/bin/zig" +chmod +x "$work/bin/zig" +PATH="$work/bin:$PATH"; export PATH +sh "$root/scripts/build-guest-credential-relay.sh" --raw-only --output "$work/one" --cache "$work/cache-one" +sh "$root/scripts/build-guest-credential-relay.sh" --raw-only --output "$work/two" --cache "$work/cache-two" +for target in linux-arm64 linux-amd64; do + cmp "$work/one/$target/adevcontainer-git-relay" "$work/two/$target/adevcontainer-git-relay" || fail 'nonreproducible ELF' +done +if [ "$(uname -s)" = Darwin ]; then + swift "$root/scripts/prepare-guest-credential-assets.swift" "$work/one" "$work/one.swift" "$root/Guest/GitCredentialRelay/main.c" + swift "$root/scripts/prepare-guest-credential-assets.swift" "$work/two" "$work/two.swift" "$root/Guest/GitCredentialRelay/main.c" + cmp "$work/one/manifest.json" "$work/two/manifest.json" + cmp "$work/one.swift" "$work/two.swift" + swift - "$work/one.swift" "$work/one" "$root/Guest/GitCredentialRelay/main.c" <<'SWIFT' +import Foundation +import CryptoKit +func digest(_ data: Data) -> String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() } +let generated = try String(contentsOfFile: CommandLine.arguments[1], encoding: .utf8) +func payload(_ key: String) -> Data { + let prefix = "static let \(key) = \"" + let range = generated.range(of: prefix)! + let tail = generated[range.upperBound...] + return Data(base64Encoded: String(tail.prefix(while: { $0 != "\"" })))! +} +let root = URL(fileURLWithPath: CommandLine.arguments[2]) +let manifest = try Data(contentsOf: root.appendingPathComponent("manifest.json")) +precondition(payload("manifestBase64") == manifest) +let rows = try JSONSerialization.jsonObject(with: manifest) as! [[String: Any]] +let sourceDigest = digest(try Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[3]))) +precondition(rows.count == 2) +for (index, arch) in ["arm64", "amd64"].enumerated() { + let raw = try Data(contentsOf: root.appendingPathComponent("linux-\(arch)/adevcontainer-git-relay")) + precondition(payload("\(arch)Base64") == raw) + precondition(rows[index]["sha256"] as? String == digest(raw)) + precondition(rows[index]["size"] as? Int == raw.count) + precondition(rows[index]["target"] as? String == "linux/\(arch)") + precondition(rows[index]["protocolVersion"] as? Int == 1) + precondition(rows[index]["sourceSHA256"] as? String == sourceDigest) +} +SWIFT + # Deliberately mutate ELF machine, program-header type, dynamic dependency and truncation. + cp "$work/one/linux-arm64/adevcontainer-git-relay" "$work/original" + printf '\076\000' | dd of="$work/one/linux-arm64/adevcontainer-git-relay" bs=1 seek=18 conv=notrunc 2>/dev/null + must_fail swift "$root/scripts/prepare-guest-credential-assets.swift" "$work/one" "$work/rejected.swift" "$root/Guest/GitCredentialRelay/main.c" + cp "$work/original" "$work/one/linux-arm64/adevcontainer-git-relay" + # Pinned linker puts the program table at byte 64; generator validates this offset, not this test. + printf '\003\000\000\000' | dd of="$work/one/linux-arm64/adevcontainer-git-relay" bs=1 seek=64 conv=notrunc 2>/dev/null + must_fail swift "$root/scripts/prepare-guest-credential-assets.swift" "$work/one" "$work/rejected.swift" "$root/Guest/GitCredentialRelay/main.c" + cp "$work/original" "$work/one/linux-arm64/adevcontainer-git-relay" + printf '\002\000\000\000' | dd of="$work/one/linux-arm64/adevcontainer-git-relay" bs=1 seek=64 conv=notrunc 2>/dev/null + must_fail swift "$root/scripts/prepare-guest-credential-assets.swift" "$work/one" "$work/rejected.swift" "$root/Guest/GitCredentialRelay/main.c" + dd if="$work/original" of="$work/one/linux-arm64/adevcontainer-git-relay" bs=1 count=80 2>/dev/null + must_fail swift "$root/scripts/prepare-guest-credential-assets.swift" "$work/one" "$work/rejected.swift" "$root/Guest/GitCredentialRelay/main.c" + [ ! -e "$work/rejected.swift" ] || fail 'invalid ELF generated a payload' +fi +grep -q 'musl' "$root/THIRD_PARTY_NOTICES.md" || fail 'missing musl notices' +grep -q 'LLVM' "$root/THIRD_PARTY_NOTICES.md" || fail 'missing compiler runtime notices' +echo 'PASS pinned toolchain, deterministic static targets, ELF rejection, generated identity, notices' diff --git a/Tests/GuestGitCredentialRelay/relay_tests.c b/Tests/GuestGitCredentialRelay/relay_tests.c new file mode 100644 index 0000000..ccfc0ee --- /dev/null +++ b/Tests/GuestGitCredentialRelay/relay_tests.c @@ -0,0 +1,254 @@ +/* Synthetic credentials only. Exercises the executable, including the shipped ELF on Linux. */ +#define _POSIX_C_SOURCE 200809L +#define _DARWIN_C_SOURCE +/* Optimized Zig builds define NDEBUG; this harness requires active assertions. */ +#ifdef NDEBUG +#undef NDEBUG +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { HEADER = 32, LIMIT = 65536 }; +static const char *binary; +typedef struct { pid_t pid; int in, out; } Child; +static Child spawn_output(const char *mode, const char *dir, const char *operation, int *keep) { + int in[2], out[2]; assert(pipe(in) == 0 && pipe(out) == 0); + assert(fcntl(in[0], F_SETFD, FD_CLOEXEC) == 0 && fcntl(in[1], F_SETFD, FD_CLOEXEC) == 0); + assert(fcntl(out[0], F_SETFD, FD_CLOEXEC) == 0 && fcntl(out[1], F_SETFD, FD_CLOEXEC) == 0); + pid_t pid = fork(); assert(pid >= 0); + if (!pid) { + assert(dup2(in[0], 0) >= 0 && dup2(out[1], 1) >= 0); + close(in[0]); close(in[1]); close(out[0]); close(out[1]); + execl(binary, binary, mode, dir, operation, (char *)NULL); + _exit(127); + } + close(in[0]); if (keep) *keep = out[1]; else close(out[1]); + return (Child){pid, in[1], out[0]}; +} +static Child spawn(const char *mode, const char *dir, const char *operation) { return spawn_output(mode, dir, operation, NULL); } +static double monotonic(void) { struct timespec t; assert(clock_gettime(CLOCK_MONOTONIC, &t) == 0); return t.tv_sec + t.tv_nsec / 1e9; } +static void write_all(int fd, const void *data, size_t count) { + const unsigned char *p = data; + while (count) { ssize_t n = write(fd, p, count); if (n < 0) perror("synthetic harness write"); assert(n > 0); p += n; count -= (size_t)n; } +} +static void read_all(int fd, void *data, size_t count) { + unsigned char *p = data; + while (count) { + struct pollfd pollfd = {fd, POLLIN, 0}; assert(poll(&pollfd, 1, 3000) > 0); + ssize_t n = read(fd, p, count); assert(n > 0); p += n; count -= (size_t)n; + } +} +static uint32_t u32(const unsigned char *p) { + return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | p[3]; +} +static size_t frame(int fd, unsigned char *data) { + read_all(fd, data, HEADER); + assert(memcmp(data, "AGCR\0\1", 6) == 0); + size_t length = u32(data + 24); assert(length <= LIMIT - HEADER); + read_all(fd, data + HEADER, length); return HEADER + length; +} +static int finish(Child child) { + close(child.in); close(child.out); + int status; assert(waitpid(child.pid, &status, 0) == child.pid); + return WIFEXITED(status) ? WEXITSTATUS(status) : 128; +} +static void hello(Child relay) { + unsigned char data[LIMIT]; size_t size = frame(relay.out, data); + assert(data[7] == 1 && data[23] == 7); + /* A version/platform checked acknowledgement is required before listening. */ + write_all(relay.in, data, size); +} +static Child query(const char *dir, const char *op, const char *input) { + Child child = spawn("client", dir, op); + size_t offset = 0, count = strlen(input); + while (offset < count) { + ssize_t written = write(child.in, input + offset, count - offset); + if (written < 0 && errno == EPIPE) break; /* Bounded parser may reject oversized input early. */ + assert(written > 0); offset += (size_t)written; + } + close(child.in); child.in = -1; + return child; +} +static void empty(Child child, int success) { + unsigned char byte; struct pollfd p = {child.out, POLLIN, 0}; + assert(poll(&p, 1, 3000) > 0 && read(child.out, &byte, 1) == 0); + assert((finish(child) == 0) == success); +} +static void answer(Child child, const char *expected) { + char output[256] = {0}; size_t count = strlen(expected); + assert(count < sizeof(output)); read_all(child.out, output, count); + assert(!memcmp(output, expected, count)); empty(child, 1); +} +static size_t field(unsigned char *p, unsigned key, const char *value) { + size_t len = strlen(value); assert(len < 256); + p[0] = 0; p[1] = (unsigned char)key; p[2] = 0; p[3] = (unsigned char)len; + memcpy(p + 4, value, len); return len + 4; +} +static void wait_socket(const char *dir) { + char path[256]; snprintf(path, sizeof(path), "%s/credential.sock", dir); + for (int i = 0; i < 300; i++) { + struct stat st; if (lstat(path, &st) == 0 && S_ISSOCK(st.st_mode)) return; + (void)poll(NULL, 0, 10); + } + assert(!"relay socket not created"); +} +static void malformed_client(const char *dir, unsigned char *frame_data, size_t size) { + struct sockaddr_un address = {0}; address.sun_family = AF_UNIX; + assert(snprintf(address.sun_path, sizeof(address.sun_path), "%s/credential.sock", dir) < (int)sizeof(address.sun_path)); + int fd = socket(AF_UNIX, SOCK_STREAM, 0); assert(fd >= 0); + assert(connect(fd, (struct sockaddr *)&address, sizeof(address)) == 0); + write_all(fd, frame_data, size); + assert(shutdown(fd, SHUT_WR) == 0); + struct pollfd p = {fd, POLLIN, 0}; assert(poll(&p, 1, 3000) > 0); + unsigned char byte; assert(read(fd, &byte, 1) == 0); close(fd); +} +int main(int argc, char **argv) { + assert(argc == 2); binary = argv[1]; signal(SIGPIPE, SIG_IGN); alarm(90); + const char *tmp = getenv("TMPDIR"); if (!tmp) tmp = "/tmp"; + char dir[256]; assert(snprintf(dir, sizeof(dir), "%s/adev-relay-XXXXXX", tmp) < (int)sizeof(dir)); + assert(mkdtemp(dir)); assert(chmod(dir, 0700) == 0); + /* This is the initial behavioral red: absent executable cannot return a valid hello. */ + Child relay = spawn("relay", dir, "7"); hello(relay); wait_socket(dir); + const char *input = "protocol=https\nhost=example.invalid:8443\npath=org/répô@x:y\nusername=u@:/é\n\n"; + Child a = query(dir, "get", input), b = query(dir, "get", input); + unsigned char first[LIMIT], second[LIMIT]; size_t n1 = frame(relay.out, first), n2 = frame(relay.out, second); + assert(first[7] == 2 && second[7] == 2 && memcmp(first + 8, second + 8, 8) != 0); + assert(n1 == n2 && memcmp(first + HEADER, second + HEADER, n1 - HEADER) == 0); + /* Replies are intentionally inverted and fragmented; empty answer means a safe miss. */ + second[7] = 4; memset(second + 24, 0, 4); + write_all(relay.in, second, 9); write_all(relay.in, second + 9, HEADER - 9); + first[7] = 4; memset(first + 24, 0, 4); write_all(relay.in, first, HEADER); + empty(a, 1); empty(b, 1); + Child roundtrip = query(dir, "get", input); (void)frame(relay.out, first); + unsigned char fixture[256]; size_t fixture_size = 0; + fixture_size += field(fixture + fixture_size, 1, "https"); + fixture_size += field(fixture + fixture_size, 2, "example.invalid:8443"); + fixture_size += field(fixture + fixture_size, 3, "org/répô@x:y"); + fixture_size += field(fixture + fixture_size, 4, "u@:/é"); + assert(u32(first + 24) == fixture_size && !memcmp(first + HEADER, fixture, fixture_size)); + first[7] = 4; + size_t payload = field(first + HEADER, 4, "u@:/é"); + payload += field(first + HEADER + payload, 5, "synthetic@:/é"); + memset(first + 24, 0, 4); first[27] = (unsigned char)payload; + write_all(relay.in, first, HEADER + payload); + answer(roundtrip, "username=u@:/é\npassword=synthetic@:/é\n\n"); + unsigned char invalid[256] = {0}; memcpy(invalid, "AGCR\0\1\0\2", 8); + invalid[25] = 1; /* 65536-byte payload exceeds the full-frame bound. */ + malformed_client(dir, invalid, HEADER); + invalid[25] = 0; invalid[27] = 10; + (void)field(invalid + HEADER, 4, "x"); (void)field(invalid + HEADER + 5, 4, "y"); + malformed_client(dir, invalid, HEADER + 10); + invalid[27] = 5; invalid[HEADER + 4] = '\n'; malformed_client(dir, invalid, HEADER + 5); + invalid[HEADER + 4] = 0; malformed_client(dir, invalid, HEADER + 5); + invalid[HEADER + 4] = 0xff; malformed_client(dir, invalid, HEADER + 5); + invalid[HEADER + 4] = 'x'; invalid[15] = 1; malformed_client(dir, invalid, HEADER + 5); + invalid[15] = 0; malformed_client(dir, invalid, HEADER - 1); + /* Store never crosses the channel, even if no relay exists. */ + empty(query(dir, "store", "username=synthetic\npassword=synthetic\n\n"), 1); + struct pollfd p = {relay.out, POLLIN, 0}; assert(poll(&p, 1, 30) == 0); + Child erased = query(dir, "erase", input); + size_t ne = frame(relay.out, first); assert(ne > HEADER && first[7] == 3); + first[7] = 4; memset(first + 24, 0, 4); write_all(relay.in, first, HEADER); empty(erased, 1); + /* Backpressure must not extend the 15s deadline or modify the caller's OFD. + * Retain the shared stdout writer and leave only one page of pipe capacity. */ + int shared; Child blocked = spawn_output("client", dir, "get", &shared); + int original_flags = fcntl(shared, F_GETFL); assert(original_flags >= 0); + assert(fcntl(shared, F_SETFL, original_flags | O_NONBLOCK) == 0); + unsigned char fill[4096]; memset(fill, 'x', sizeof(fill)); + while (write(shared, fill, sizeof(fill)) > 0) {} + assert(errno == EAGAIN); assert(read(blocked.out, fill, sizeof(fill)) == (ssize_t)sizeof(fill)); + assert(fcntl(shared, F_SETFL, original_flags) == 0); + double start = monotonic(); write_all(blocked.in, input, strlen(input)); close(blocked.in); blocked.in = -1; + (void)frame(relay.out, first); first[7] = 4; + payload = field(first + HEADER, 4, "synthetic"); + first[HEADER + payload] = 0; first[HEADER + payload + 1] = 5; + first[HEADER + payload + 2] = (unsigned char)(60000 >> 8); first[HEADER + payload + 3] = (unsigned char)60000; + memset(first + HEADER + payload + 4, 'x', 60000); payload += 60004; + for (unsigned i = 0; i < 4; i++) first[24 + i] = (unsigned char)(payload >> ((3 - i) * 8)); + write_all(relay.in, first, HEADER + payload); + int status = 0, exited = 0, unchanged = 1; + while (monotonic() - start < 16) { + int observed = fcntl(shared, F_GETFL); + /* Darwin may expose kernel pipe bookkeeping bits after a large write; + * compare the caller-controlled POSIX status flags, not those bits. */ + if ((observed & (O_NONBLOCK | O_APPEND | O_SYNC)) != (original_flags & (O_NONBLOCK | O_APPEND | O_SYNC))) unchanged = 0; + if (waitpid(blocked.pid, &status, WNOHANG) == blocked.pid) { exited = 1; break; } + (void)poll(NULL, 0, 10); + } + if (!exited) { kill(blocked.pid, SIGKILL); assert(waitpid(blocked.pid, &status, 0) == blocked.pid); } + close(shared); close(blocked.out); + int backpressure_ok = exited && WIFEXITED(status) && WEXITSTATUS(status) != 0 && unchanged; + if (!backpressure_ok) fprintf(stderr, "backpressure exited=%d status=%d unchanged=%d elapsed=%.3f\n", exited, status, unchanged, monotonic() - start); + Child saturated[64]; unsigned char requests[64][HEADER]; + for (int i = 0; i < 64; i++) saturated[i] = query(dir, "get", input); + for (int i = 0; i < 64; i++) { (void)frame(relay.out, first); memcpy(requests[i], first, HEADER); } + empty(query(dir, "get", input), 0); + for (int i = 63; i >= 0; i--) { requests[i][7] = 4; memset(requests[i] + 24, 0, 4); write_all(relay.in, requests[i], HEADER); } + for (int i = 0; i < 64; i++) empty(saturated[i], 1); +#ifdef __linux__ + /* A closed waiter must not leave POLLHUP spinning until its 15s deadline. */ + int disconnected = socket(AF_UNIX, SOCK_STREAM, 0); assert(disconnected >= 0); + struct sockaddr_un endpoint = {0}; endpoint.sun_family = AF_UNIX; + assert(snprintf(endpoint.sun_path, sizeof(endpoint.sun_path), "%s/credential.sock", dir) < (int)sizeof(endpoint.sun_path)); + assert(connect(disconnected, (struct sockaddr *)&endpoint, sizeof(endpoint)) == 0); + unsigned char request[256] = {0}; memcpy(request, "AGCR\0\1\0\2", 8); + size_t request_payload = field(request + HEADER, 1, "https"); request_payload += field(request + HEADER + request_payload, 2, "example.invalid"); + request[27] = (unsigned char)request_payload; write_all(disconnected, request, HEADER + request_payload); + (void)frame(relay.out, first); close(disconnected); + clockid_t child_clock; assert(clock_getcpuclockid(relay.pid, &child_clock) == 0); + struct timespec before, after; assert(clock_gettime(child_clock, &before) == 0); + (void)poll(NULL, 0, 300); assert(clock_gettime(child_clock, &after) == 0); + assert((after.tv_sec - before.tv_sec) + (after.tv_nsec - before.tv_nsec) / 1e9 < 0.1); +#endif + /* Duplicate/conflicting fields, malformed records, and bounded inputs fail closed locally. */ + empty(query(dir, "get", "protocol=https\nprotocol=http\nhost=x\n\n"), 0); + empty(query(dir, "get", "protocol=https\nhost=x\nbroken\n\n"), 0); + char *large = malloc(LIMIT + 100); assert(large); memset(large, 'x', LIMIT + 99); large[LIMIT + 99] = 0; + empty(query(dir, "get", large), 0); free(large); + /* A stale generation cannot satisfy a current waiter. EOF then releases that waiter. */ + Child pending = query(dir, "get", input); (void)frame(relay.out, first); + first[7] = 4; first[23] = 6; memset(first + 24, 0, 4); write_all(relay.in, first, HEADER); + p = (struct pollfd){pending.out, POLLIN, 0}; assert(poll(&p, 1, 30) == 0); + close(relay.in); relay.in = -1; empty(pending, 0); assert(finish(relay) == 0); + char socket_path[256]; snprintf(socket_path, sizeof(socket_path), "%s/credential.sock", dir); + assert(access(socket_path, F_OK) == -1); + /* Never unlink a regular file or symlink, or use a public/foreign directory. */ + int file = open(socket_path, O_CREAT | O_WRONLY, 0600); assert(file >= 0); close(file); + relay = spawn("relay", dir, "7"); hello(relay); empty(relay, 0); assert(access(socket_path, F_OK) == 0); + assert(unlink(socket_path) == 0 && symlink("untouched", socket_path) == 0); + relay = spawn("relay", dir, "7"); hello(relay); empty(relay, 0); + struct stat st; assert(lstat(socket_path, &st) == 0 && S_ISLNK(st.st_mode)); assert(unlink(socket_path) == 0); + assert(chmod(dir, 0755) == 0); empty(spawn("relay", dir, "7"), 0); + assert(chmod(dir, 0700) == 0); + /* Ancestor symlinks are also rejected. */ + char link[280]; snprintf(link, sizeof(link), "%s-link", dir); assert(symlink(dir, link) == 0); + empty(spawn("relay", link, "7"), 0); assert(unlink(link) == 0); + /* A real stale owned socket is replaced, but a second relay cannot steal a live one. */ + int stale = socket(AF_UNIX, SOCK_STREAM, 0); assert(stale >= 0); + struct sockaddr_un address = {0}; address.sun_family = AF_UNIX; + assert(strlen(socket_path) < sizeof(address.sun_path)); strcpy(address.sun_path, socket_path); + assert(bind(stale, (struct sockaddr *)&address, sizeof(address)) == 0); close(stale); + assert(chmod(socket_path, 0600) == 0); + relay = spawn("relay", dir, "7"); hello(relay); wait_socket(dir); + empty(spawn("relay", dir, "7"), 0); + /* Deadlines are exercised with the shipped 15s default, not a test-only product switch. */ + Child timed = query(dir, "get", input); (void)frame(relay.out, first); + struct pollfd timeout = {timed.out, POLLIN, 0}; assert(poll(&timeout, 1, 18000) > 0); empty(timed, 0); + close(relay.in); relay.in = -1; assert(finish(relay) == 0); + char lock[280]; snprintf(lock, sizeof(lock), "%s/relay.lock", dir); assert(unlink(lock) == 0); + assert(rmdir(dir) == 0); assert(backpressure_ok); puts("PASS native relay executable tests"); + return 0; +} diff --git a/Tests/adevcontainerTests/AllCommandTests.swift b/Tests/adevcontainerTests/AllCommandTests.swift index f54b008..1299279 100644 --- a/Tests/adevcontainerTests/AllCommandTests.swift +++ b/Tests/adevcontainerTests/AllCommandTests.swift @@ -725,7 +725,9 @@ nonisolated(unsafe) let upTests: [(String, () throws -> Void)] = [ "root connection user runs no parent fix-up" ) }), - ("upFreshCreateSeedsCredentialsAfterOwnershipBeforeHooks", { + ("upFreshCreateConnectsCredentialsAfterOwnershipBeforeHooks", { + let connection = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(connection); defer { GitCredentialLifecycle.setTestConnection(nil) } let workspace = try TestRepo.makeTempWorkspace(configJSON: """ { "image": "alpine:3.20", @@ -759,7 +761,12 @@ nonisolated(unsafe) let upTests: [(String, () throws -> Void)] = [ return nil } ] - // Deterministic host git enumeration (real git would miss the mock remotes). + mock.handlers.insert({ args in + if args.first == "exec", args.last?.contains("chown") == true { connection.record("ownership") } + if args.first == "exec", args.last?.contains("postCreateCustom") == true { connection.record("hook") } + return nil + }, at: 0) + // Host discovery belongs to the injected connection, not the command. let hostRunner = RecordingHostProcessRunner() hostRunner.handler = { call in if call.arguments == ["-C", workspace.path, "remote", "-v"] { @@ -789,27 +796,20 @@ nonisolated(unsafe) let upTests: [(String, () throws -> Void)] = [ let createIdx = calls.firstIndex { $0.first == "create" }! let startIdx = calls.firstIndex { $0.first == "start" }! let chownIdx = calls.firstIndex { $0.first == "exec" && $0.last?.contains("chown") == true }! - let seedIdx = calls.firstIndex { $0.first == "exec" && $0.last?.contains("credential.helper") == true }! let hookIdx = calls.firstIndex { $0.first == "exec" && $0.last?.contains("postCreateCustom") == true }! try MiniTest.expect(createIdx < startIdx, "create before start") try MiniTest.expect(startIdx < chownIdx, "ownership after start") - try MiniTest.expect(chownIdx < seedIdx, "seeding after ownership block") - try MiniTest.expect(seedIdx < hookIdx, "seeding before first hook exec") - let seedCall = mock.calls[seedIdx] - let seedArgs = seedCall.arguments - try MiniTest.expect(seedArgs.contains("-u") && seedArgs.contains("vscode"), "seeds as connection user") - try MiniTest.expectEqual( - String(data: seedCall.stdinData ?? Data(), encoding: .utf8) ?? "", - "protocol=https\nhost=github.com\nusername=x-access-token\npassword=ghp_secret\n\n" - ) - try MiniTest.expect(!seedArgs.contains(where: { $0.contains("ghp_secret") })) - try MiniTest.expect(seedCall.environment?.values.contains("ghp_secret") != true) - let script = seedArgs.last ?? "" - try MiniTest.expect(script.contains("git config --global --add credential.helper")) - try MiniTest.expect(!script.contains("ghp_secret"), "secrets never in the exec command") - try MiniTest.expect(!script.contains("github.com"), "no host/path in the script") + try MiniTest.expect(chownIdx < hookIdx) + let setup = connection.events.firstIndex(of: "setup")! + try MiniTest.expect(connection.events[.. Void)] = [ "hooks still run after seeding failure") try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }, "seeding failure never deletes") }), - ("upFreshCreateNonRepoWorkspaceSkipsSeedingSilently", { + ("upFreshCreateNonRepoWorkspaceDelegatesDiscoverySilently", { + let connection = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(connection); defer { GitCredentialLifecycle.setTestConnection(nil) } let workspace = try TestRepo.makeTempWorkspace(configJSON: """ { "image": "alpine:3.20", @@ -954,7 +956,9 @@ nonisolated(unsafe) let upTests: [(String, () throws -> Void)] = [ try MiniTest.expect(mock.calls.contains { $0.arguments.first == "exec" && $0.arguments.last?.contains("postCreateCustom") == true }, "hooks still run") }), - ("upReuseRunningSeedsNothing", { + ("upReuseRunningEnsuresConnectionWithoutAcquisition", { + let connection = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(connection); defer { GitCredentialLifecycle.setTestConnection(nil) } let workspace = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) defer { try? FileManager.default.removeItem(at: workspace) } let mock = MockProcessRunner() @@ -999,12 +1003,15 @@ nonisolated(unsafe) let upTests: [(String, () throws -> Void)] = [ try MiniTest.expectEqual(result.outcome, "success") try MiniTest.expect(creds.fillCalls.isEmpty, "reuse must not fill") try MiniTest.expect(host.calls.isEmpty, "reuse must not ask host git") + try MiniTest.expectEqual(connection.events, ["setup"]) try MiniTest.expect( !mock.calls.contains { $0.arguments.first == "exec" && $0.arguments.last?.contains("git-credential-adev") == true }, "reuse must not run a seeding exec" ) }), ("upStartStoppedRefreshesCredentials", { + let connection = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(connection); defer { GitCredentialLifecycle.setTestConnection(nil) } let workspace = try TestRepo.makeTempWorkspace(configJSON: """ { "image": "alpine:3.20", "remoteUser": "vscode", "postStartCommand": "echo postStart" } """) @@ -1056,27 +1063,17 @@ nonisolated(unsafe) let upTests: [(String, () throws -> Void)] = [ credentials: creds ) try MiniTest.expectEqual(result.outcome, "success") - try MiniTest.expectEqual(creds.fillCalls, ["https://dev.azure.com/org/proj/_git/repo"]) - let seeds = mock.calls.filter { - $0.arguments.first == "exec" && $0.arguments.last?.contains("git-credential-adev") == true - } - try MiniTest.expectEqual(seeds.count, 1, "start-stopped refreshes once") - let script = seeds[0].arguments.last ?? "" - try MiniTest.expect(script.contains("credential.https://dev.azure.com.useHttpPath true")) - try MiniTest.expect(!script.contains("credential.https://dev.azure.com.helper \"\"")) - try MiniTest.expect(!script.contains(password)) - try MiniTest.expect(seeds[0].environment?.values.contains(password) != true) - try MiniTest.expectEqual( - String(data: seeds[0].stdinData ?? Data(), encoding: .utf8) ?? "", - "protocol=https\nhost=dev.azure.com\nusername=plantsuite\npassword=\(password)\n\n" - ) + try MiniTest.expect(creds.fillCalls.isEmpty) + try MiniTest.expectEqual(connection.events, ["setup"]) + try MiniTest.expectEqual(connection.setups.first?.hostWorkspace, folder) try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) let calls = mock.calls.map(\.arguments) - let seedIdx = calls.firstIndex { $0.first == "exec" && $0.last?.contains("git-credential-adev") == true } let hookIdx = calls.firstIndex { $0.last?.contains("echo postStart") == true } - try MiniTest.expect(seedIdx != nil && hookIdx != nil && seedIdx! < hookIdx!, "refresh before postStart") + try MiniTest.expect(hookIdx != nil) }), ("upStartStoppedRefreshSoftFails", { + let connection = AutomaticCredentialCommandPort(); connection.fails = true + GitCredentialLifecycle.setTestConnection(connection); defer { GitCredentialLifecycle.setTestConnection(nil) } let workspace = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20", "postStartCommand": "echo postStart" }"#) defer { try? FileManager.default.removeItem(at: workspace) } let mock = MockProcessRunner() @@ -1146,6 +1143,8 @@ nonisolated(unsafe) let upTests: [(String, () throws -> Void)] = [ nonisolated(unsafe) let credentialRefreshCommandTests: [(String, () throws -> Void)] = [ ("startStoppedRefreshesCredentials", { + let connection = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(connection); defer { GitCredentialLifecycle.setTestConnection(nil) } let ws = try TestRepo.makeTempWorkspace(configJSON: """ { "image": "alpine:3.20", "remoteUser": "vscode", "postStartCommand": "echo start-postStart" } """) @@ -1198,25 +1197,17 @@ nonisolated(unsafe) let credentialRefreshCommandTests: [(String, () throws -> Vo credentials: creds ) try MiniTest.expect(mock.calls.contains { $0.arguments.first == "start" }) - let seeds = mock.calls.filter { - $0.arguments.first == "exec" && $0.arguments.last?.contains("git-credential-adev") == true - } - try MiniTest.expectEqual(seeds.count, 1) - try MiniTest.expect(seeds[0].arguments.contains("-u") && seeds[0].arguments.contains("vscode")) - try MiniTest.expect(!seeds[0].arguments.contains { $0.contains(password) }) - try MiniTest.expect(seeds[0].environment?.values.contains(password) != true) - try MiniTest.expectEqual( - String(data: seeds[0].stdinData ?? Data(), encoding: .utf8) ?? "", - "protocol=https\nhost=dev.azure.com\nusername=plantsuite\npassword=\(password)\n\n" - ) - try MiniTest.expect(!(seeds[0].arguments.last ?? "").contains("helper \"\"")) + try MiniTest.expectEqual(connection.events, ["setup"]) + try MiniTest.expectEqual(connection.setups.first?.hostWorkspace, folder) + try MiniTest.expect(creds.fillCalls.isEmpty) let calls = mock.calls.map(\.arguments) - let seedIdx = calls.firstIndex { $0.last?.contains("git-credential-adev") == true } let hookIdx = calls.firstIndex { $0.last?.contains("echo start-postStart") == true } - try MiniTest.expect(seedIdx != nil && hookIdx != nil && seedIdx! < hookIdx!) + try MiniTest.expect(hookIdx != nil) try MiniTest.expect(!host.calls.contains { $0.arguments.contains("fetch") || $0.arguments.contains("pull") || $0.arguments.contains("push") }) }), - ("startAlreadyRunningDoesNotRefreshCredentials", { + ("startAlreadyRunningEnsuresConnectionWithoutAcquisition", { + let connection = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(connection); defer { GitCredentialLifecycle.setTestConnection(nil) } let ws = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) defer { try? FileManager.default.removeItem(at: ws) } let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) @@ -1261,9 +1252,12 @@ nonisolated(unsafe) let credentialRefreshCommandTests: [(String, () throws -> Vo try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" }) try MiniTest.expect(creds.fillCalls.isEmpty) try MiniTest.expect(host.calls.isEmpty) + try MiniTest.expectEqual(connection.events, ["setup"]) try MiniTest.expect(!mock.calls.contains { $0.arguments.last?.contains("git-credential-adev") == true }) }), ("startRefreshSoftFailsAndContinues", { + let connection = AutomaticCredentialCommandPort(); connection.fails = true + GitCredentialLifecycle.setTestConnection(connection); defer { GitCredentialLifecycle.setTestConnection(nil) } let ws = try TestRepo.makeTempWorkspace(configJSON: """ { "image": "alpine:3.20", "postStartCommand": "echo start-postStart" } """) @@ -1337,211 +1331,32 @@ nonisolated(unsafe) let credentialRefreshCommandTests: [(String, () throws -> Vo try MiniTest.expect(!warnings.joined().contains("super-secret-leak")) try MiniTest.expect(!warnings.joined().contains("user:tok")) try MiniTest.expect(!warnings.isEmpty) + try MiniTest.expect(warnings.joined().contains("start or up")) + try MiniTest.expect(!warnings.joined().contains("refresh-credentials")) try MiniTest.expect(mock.calls.contains { $0.arguments.last?.contains("echo start-postStart") == true }) try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) }), - ("refreshCredentialsRunningUsesStdin", { - let ws = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20", "remoteUser": "vscode" }"#) - defer { try? FileManager.default.removeItem(at: ws) } - let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) - var labels = resolved.labels - labels[ContainerIdentity.labelManaged] = ContainerIdentity.managedValue - labels[ContainerIdentity.labelRemoteUser] = "vscode" - let entry = MockProcessRunner.containerListJSON( - id: resolved.containerName, state: "running", labels: labels - ) - let mock = MockProcessRunner() - mock.handlers = [ - { args in - if args.starts(with: ["list"]) { - let data = try! JSONSerialization.data(withJSONObject: [entry]) - return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) - } - if args.first == "exec" { - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - ] - let folder = labels[ContainerIdentity.labelLocalFolder] ?? ws.path - let password = "cmd-refresh-secret" - let host = RecordingHostProcessRunner() - host.handler = { call in - if call.arguments == ["-C", folder, "remote", "-v"] { - return ProcessResult( - exitCode: 0, - stdout: Data("origin\thttps://dev.azure.com/org/proj/_git/repo (fetch)\n".utf8), - stderr: Data() - ) - } - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - let restore = RecordingHostProcessRunner.install(host) - defer { restore() } - let creds = SeedMockCredential() - creds.results["https://dev.azure.com/org/proj/_git/repo"] = .success( - GitHTTPSCredentials(username: "plantsuite", password: password) - ) - let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - try RefreshCredentialsCommand.run( - name: resolved.containerName, - runtime: runtime, - credentials: creds - ) - try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" || $0.arguments.first == "stop" }) - let seeds = mock.calls.filter { - $0.arguments.first == "exec" && $0.arguments.last?.contains("git-credential-adev") == true - } - try MiniTest.expectEqual(seeds.count, 1) - try MiniTest.expect(!seeds[0].arguments.contains { $0.contains(password) }) - try MiniTest.expect(seeds[0].environment?.values.contains(password) != true) - try MiniTest.expect((seeds[0].arguments.last ?? "").contains("useHttpPath true")) - try MiniTest.expect(!(seeds[0].arguments.last ?? "").contains("helper \"\"")) - try MiniTest.expectEqual( - String(data: seeds[0].stdinData ?? Data(), encoding: .utf8) ?? "", - "protocol=https\nhost=dev.azure.com\nusername=plantsuite\npassword=\(password)\n\n" - ) - try MiniTest.expect(!host.calls.contains { - $0.arguments.contains("fetch") || $0.arguments.contains("pull") || $0.arguments.contains("push") - }) - }), - ("refreshCredentialsVolumeUsesStampedGitURL", { - let labels: [String: String] = [ - ContainerIdentity.labelManaged: ContainerIdentity.managedValue, - ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeVolume, - ContainerIdentity.labelLocalFolder: "volume://adev-app-ws", - ContainerIdentity.labelGitURL: "https://dev.azure.com/org/proj/_git/repo", - ContainerIdentity.labelRemoteUser: "vscode" - ] - let entry = MockProcessRunner.containerListJSON( - id: "vol-app", state: "running", labels: labels - ) - let mock = MockProcessRunner() - mock.handlers = [ - { args in - if args.starts(with: ["list"]) { - let data = try! JSONSerialization.data(withJSONObject: [entry]) - return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) - } - if args.first == "exec" { - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - ] - let host = RecordingHostProcessRunner() - host.handler = { _ in - ProcessResult(exitCode: 0, stdout: Data("should-not-run".utf8), stderr: Data()) - } - let restore = RecordingHostProcessRunner.install(host) - defer { restore() } - let creds = SeedMockCredential() - creds.results["https://dev.azure.com/org/proj/_git/repo"] = .success( - GitHTTPSCredentials(username: "plantsuite", password: "vol-secret") - ) - let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - try RefreshCredentialsCommand.run(name: "vol-app", runtime: runtime, credentials: creds) - try MiniTest.expect(host.calls.isEmpty, "volume refresh must not enumerate host remotes") - try MiniTest.expectEqual(creds.fillCalls, ["https://dev.azure.com/org/proj/_git/repo"]) - try MiniTest.expect(mock.calls.contains { $0.arguments.last?.contains("git-credential-adev") == true }) - try MiniTest.expect(!mock.calls.contains { $0.arguments.contains { $0.contains("vol-secret") } }) - }), - ("refreshCredentialsStoppedDoesNotStart", { - let labels: [String: String] = [ - ContainerIdentity.labelManaged: ContainerIdentity.managedValue, - ContainerIdentity.labelLocalFolder: "/work/app" - ] - let entry = MockProcessRunner.containerListJSON( - id: "stopped-app", state: "stopped", labels: labels - ) - let mock = MockProcessRunner() - mock.handlers = [ - { args in - if args.starts(with: ["list"]) { - let data = try! JSONSerialization.data(withJSONObject: [entry]) - return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) - } - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - ] - let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - try MiniTest.expectThrows({ - try RefreshCredentialsCommand.run(name: "stopped-app", runtime: runtime) - }) { error in - let err = error as! CLIError - try MiniTest.expectEqual(err.code, CLIErrorCode.containerNotRunning) - try MiniTest.expect(err.hint?.contains("start --name stopped-app") == true) - } - try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" }) - try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "stop" }) - try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "exec" }) - }), - ("refreshCredentialsExecFailureIsRedacted", { - let labels: [String: String] = [ - ContainerIdentity.labelManaged: ContainerIdentity.managedValue, - ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeBind, - ContainerIdentity.labelLocalFolder: "/work/app" - ] - let entry = MockProcessRunner.containerListJSON( - id: "run-app", state: "running", labels: labels - ) - let mock = MockProcessRunner() - mock.handlers = [ - { args in - if args.starts(with: ["list"]) { - let data = try! JSONSerialization.data(withJSONObject: [entry]) - return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) - } - if args.first == "exec" { - return ProcessResult( - exitCode: 1, - stdout: Data(), - stderr: Data("password=super-secret-leak https://user:tok@dev.azure.com/x\n".utf8) - ) - } - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - ] - let host = RecordingHostProcessRunner() - host.handler = { call in - if call.arguments == ["-C", "/work/app", "remote", "-v"] { - return ProcessResult( - exitCode: 0, - stdout: Data("origin\thttps://dev.azure.com/org/proj/_git/repo (fetch)\n".utf8), - stderr: Data() - ) - } - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - let restore = RecordingHostProcessRunner.install(host) - defer { restore() } - let creds = SeedMockCredential() - creds.results["https://dev.azure.com/org/proj/_git/repo"] = .success( - GitHTTPSCredentials(username: "plantsuite", password: "super-secret-leak") - ) - let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - try MiniTest.expectThrows({ - try RefreshCredentialsCommand.run(name: "run-app", runtime: runtime, credentials: creds) - }) { error in - let err = error as! CLIError - try MiniTest.expect(err.message.contains("refresh")) - try MiniTest.expect(!err.message.contains("super-secret-leak")) - try MiniTest.expect(!err.message.contains("user:tok")) - try MiniTest.expect(!(err.hint ?? "").contains("super-secret-leak")) - } - try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" || $0.arguments.first == "stop" }) - }), - ("refreshCredentialsHelpAndUsage", { + ("credentialRecoveryHelpOmitsRefreshCommand", { let usage = CommandSurface.usageText() - try MiniTest.expect(usage.contains("refresh-credentials")) - let help = CommandSurface.commandHelpText("refresh-credentials") ?? "" - try MiniTest.expect(help.contains("refresh-credentials [--name")) - try MiniTest.expect(help.contains("start --name")) - try MiniTest.expect(help.contains("stdin")) - try MiniTest.expect(help.contains("fetch")) - try MiniTest.expect(help.contains("useHttpPath")) - try MiniTest.expect(CommandSurface.commandHelpText("start")?.contains("refreshes git credentials") == true) - try MiniTest.expect(CommandSurface.commandHelpText("up")?.contains("refreshes git credentials") == true) + try MiniTest.expect(!usage.contains("refresh-credentials")) + try MiniTest.expect(CommandSurface.commandHelpText("refresh-credentials") == nil) + let unknown = CommandSurface.unknownSubcommandError(subcommand: "refresh-credentials") + try MiniTest.expect(unknown.message.contains("Unknown subcommand")) + try MiniTest.expect(!(unknown.hint ?? "").contains("refresh-credentials")) + let start = CommandSurface.commandHelpText("start") ?? "" + let up = CommandSurface.commandHelpText("up") ?? "" + try MiniTest.expect(start.contains("refreshes git credentials")) + try MiniTest.expect(up.contains("refreshes git credentials")) + try MiniTest.expect(start.contains("stop the container and start it again")) + try MiniTest.expect(up.contains("stop the container and start it again")) + try MiniTest.expect(!start.contains("refresh-credentials")) + try MiniTest.expect(!up.contains("refresh-credentials")) + try MiniTest.expect(CommandSurface.commandHelpText("clone")?.contains("on-demand") == true) + try MiniTest.expect(CommandSurface.commandHelpText("clone")?.contains("then guest credential store") == false) + let failure = GitCredentialFailure.transport.description + try MiniTest.expect(failure.contains("start or up")) + try MiniTest.expect(failure.contains("log in on the host")) + try MiniTest.expect(!failure.contains("refresh-credentials")) }) ] diff --git a/Tests/adevcontainerTests/AllUnitTests.swift b/Tests/adevcontainerTests/AllUnitTests.swift index 039db8a..e37b3af 100644 --- a/Tests/adevcontainerTests/AllUnitTests.swift +++ b/Tests/adevcontainerTests/AllUnitTests.swift @@ -6623,120 +6623,9 @@ nonisolated(unsafe) let featuresUnitTests: [(String, () throws -> Void)] = [ }) ] -// MARK: - GuestGitCredentialSeed unit tests +// MARK: - Synthetic credential provider shared by command tests -/// Runs the generated credential helper via `sh` (Linux test env) with HOME pinned -/// to a scratch dir, feeding `input` on stdin like git's credential protocol. -func runCredentialHelper( - helperPath: String, - action: String, - home: String, - input: String, - path: String? = nil -) throws -> ProcessResult { - let resolvedPath = path ?? ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin" - return try FoundationProcessRunner().run( - executable: "/bin/sh", - arguments: [helperPath, action], - environment: ["HOME": home, "PATH": resolvedPath], - currentDirectory: nil, - stdinData: Data(input.utf8) - ) -} - -func credentialJWT(payloadJSON: String) -> String { - func b64(_ text: String) -> String { - Data(text.utf8).base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - return "\(b64(#"{"alg":"none"}"#)).\(b64(payloadJSON)).sig" -} - -func makeSeedScriptFixture() throws -> (root: URL, home: URL, inheritedHome: URL, environment: [String: String]) { - let root = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - let home = root.appendingPathComponent("passwd-home", isDirectory: true) - let inheritedHome = root.appendingPathComponent("inherited-home", isDirectory: true) - let bin = root.appendingPathComponent("bin", isDirectory: true) - try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: inheritedHome, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) - - let id = bin.appendingPathComponent("id") - try """ - #!/bin/sh - [ "$1" = "-u" ] && printf '4242\\n' - """.write(to: id, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: id.path) - - let getent = bin.appendingPathComponent("getent") - try """ - #!/bin/sh - if [ "$1" = "passwd" ] && [ "$2" = "4242" ]; then - printf 'seeduser:x:4242:4242::%s:/bin/sh\\n' '__SEED_HOME__' - exit 0 - fi - exit 1 - """.replacingOccurrences(of: "__SEED_HOME__", with: home.path) - .write(to: getent, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: getent.path) - - let git = bin.appendingPathComponent("git") - try """ - #!/bin/sh - CONFIG="$HOME/git-config" - touch "$CONFIG" - if [ "$1" = "config" ] && [ "$2" = "--global" ]; then - shift 2 - if [ "$1" = "--replace-all" ]; then - key="$2" - val="$3" - awk -F= -v k="$key" '$1 != k' "$CONFIG" > "$CONFIG.tmp" - printf '%s=%s\\n' "$key" "$val" >> "$CONFIG.tmp" - mv "$CONFIG.tmp" "$CONFIG" - exit 0 - fi - if [ "$1" = "--add" ]; then - printf '%s=%s\\n' "$2" "$3" >> "$CONFIG" - exit 0 - fi - if [ "$1" = "--unset-all" ]; then - key="$2" - awk -F= -v k="$key" '$1 != k' "$CONFIG" > "$CONFIG.tmp" - mv "$CONFIG.tmp" "$CONFIG" - exit 0 - fi - if [ "$1" = "--get-all" ]; then - awk -F= -v k="$2" '$1 == k { print substr($0, length(k)+2) }' "$CONFIG" - exit 0 - fi - if [ "$1" = "--list" ]; then - cat "$CONFIG" - exit 0 - fi - printf '%s=%s\\n' "$1" "$2" >> "$CONFIG" - exit 0 - fi - if [ "$1" = "credential" ] && [ "$2" = "approve" ]; then - printf 'approve\\n' >> "$HOME/approve-calls" - "$HOME/.adevcontainer/git-credential-adev" store - exit 0 - fi - exit 0 - """.write(to: git, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: git.path) - - let path = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin" - return ( - root, - home, - inheritedHome, - ["HOME": inheritedHome.path, "PATH": "\(bin.path):\(path)"] - ) -} - -/// Credential provider with per-URL results for seeding tests. +/// Synthetic provider; never contacts host credentials. final class SeedMockCredential: GitCredentialProviding, @unchecked Sendable { var results: [String: Result] = [:] var defaultResult: Result = .success(nil) @@ -6751,1195 +6640,6 @@ final class SeedMockCredential: GitCredentialProviding, @unchecked Sendable { } } -nonisolated(unsafe) let guestGitCredentialSeedTests: [(String, () throws -> Void)] = [ - ("guestSeedParsesUniqueFetchURLs", { - let output = """ - origin\thttps://dev.azure.com/plantsuite/PlantSuite/_git/GitOps (fetch) - origin\thttps://dev.azure.com/plantsuite/PlantSuite/_git/GitOps (push) - upstream\thttps://dev.azure.com/plantsuite/PlantSuite/_git/Other (fetch) - """ - let urls = GuestGitCredentialSeed.uniqueFetchURLs(from: output) - try MiniTest.expectEqual(urls, [ - "https://dev.azure.com/plantsuite/PlantSuite/_git/GitOps", - "https://dev.azure.com/plantsuite/PlantSuite/_git/Other" - ]) - // Duplicate fetch lines dedupe; push-only lines ignored; space-separated form works. - let dup = """ - origin\thttps://example.com/a.git (fetch) - origin\thttps://example.com/a.git (fetch) - origin https://example.com/b.git (fetch) - mirror\thttps://example.com/a.git (push) - """ - try MiniTest.expectEqual(GuestGitCredentialSeed.uniqueFetchURLs(from: dup), [ - "https://example.com/a.git", - "https://example.com/b.git" - ]) - // No fetch lines at all → empty. - try MiniTest.expectEqual( - GuestGitCredentialSeed.uniqueFetchURLs(from: "origin\thttps://example.com/a.git (push)\n"), - [] - ) - try MiniTest.expectEqual(GuestGitCredentialSeed.uniqueFetchURLs(from: ""), []) - }), - ("guestSeedBindFillsUniqueHTTPSRemotesOnly", { - let ws = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: ws) } - let remoteOutput = """ - origin\thttps://dev.azure.com/plantsuite/PlantSuite/_git/GitOps (fetch) - origin\thttps://dev.azure.com/plantsuite/PlantSuite/_git/GitOps (push) - origin\thttps://dev.azure.com/plantsuite/PlantSuite/_git/GitOps (fetch) - other\thttps://dev.azure.com/plantsuite/PlantSuite/_git/Other (fetch) - ssh-origin\tgit@github.com:org/repo.git (fetch) - """ - let mock = MockProcessRunner() - mock.handlers = [ - { args in - if args == ["-C", ws.path, "remote", "-v"] { - return ProcessResult(exitCode: 0, stdout: Data(remoteOutput.utf8), stderr: Data()) - } - if args.first == "exec" { - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - return nil - } - ] - let creds = SeedMockCredential() - creds.results["https://dev.azure.com/plantsuite/PlantSuite/_git/GitOps"] = .success( - GitHTTPSCredentials(username: "plantsuite", password: "s3cret-pass") - ) - creds.results["https://dev.azure.com/plantsuite/PlantSuite/_git/Other"] = .success( - GitHTTPSCredentials(username: "plantsuite", password: "s3cret-pass") - ) - let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - let seed = GuestGitCredentialSeed( - credentials: creds, - runner: mock, - gitPathOverride: .some("/usr/bin/mock-git") - ) - try seed.seed( - containerId: "ctr", - hostWorkspace: ws.path, - gitURL: nil, - connectionUser: nil, - runtime: runtime - ) - // One fill per unique HTTPS remote; SSH skipped; duplicate fetch URL filled once. - try MiniTest.expectEqual(creds.fillCalls, [ - "https://dev.azure.com/plantsuite/PlantSuite/_git/GitOps", - "https://dev.azure.com/plantsuite/PlantSuite/_git/Other" - ]) - let execs = mock.calls.filter { $0.arguments.first == "exec" } - try MiniTest.expectEqual(execs.count, 1, "exactly one seeding exec") - try MiniTest.expectEqual( - String(data: execs[0].stdinData ?? Data(), encoding: .utf8) ?? "", - "protocol=https\nhost=dev.azure.com\nusername=plantsuite\npassword=s3cret-pass\n\n" - ) - }), - ("guestSeedBindDedupesByProtocolHostUsername", { - let ws = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: ws) } - let remoteOutput = """ - origin\thttps://dev.azure.com/plantsuite/PlantSuite/_git/GitOps (fetch) - other\thttps://dev.azure.com/plantsuite/PlantSuite/_git/Other (fetch) - third\thttps://example.com/team/repo.git (fetch) - """ - let mock = MockProcessRunner() - mock.handlers = [ - { args in - if args == ["-C", ws.path, "remote", "-v"] { - return ProcessResult(exitCode: 0, stdout: Data(remoteOutput.utf8), stderr: Data()) - } - if args.first == "exec" { - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - return nil - } - ] - let creds = SeedMockCredential() - creds.results["https://dev.azure.com/plantsuite/PlantSuite/_git/GitOps"] = .success( - GitHTTPSCredentials(username: "plantsuite", password: "a") - ) - creds.results["https://dev.azure.com/plantsuite/PlantSuite/_git/Other"] = .success( - GitHTTPSCredentials(username: "plantsuite", password: "b") - ) - creds.results["https://example.com/team/repo.git"] = .success( - GitHTTPSCredentials(username: "alice", password: "c") - ) - let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - let seed = GuestGitCredentialSeed( - credentials: creds, - runner: mock, - gitPathOverride: .some("/usr/bin/mock-git") - ) - try seed.seed( - containerId: "ctr", - hostWorkspace: ws.path, - gitURL: nil, - connectionUser: nil, - runtime: runtime - ) - try MiniTest.expectEqual(creds.fillCalls.count, 3, "fill still per unique HTTPS remote") - let execs = mock.calls.filter { $0.arguments.first == "exec" } - try MiniTest.expectEqual(execs.count, 1) - let call = execs[0] - let args = call.arguments - // Two entries: (https, dev.azure.com, plantsuite) and (https, example.com, alice). - try MiniTest.expectEqual( - String(data: call.stdinData ?? Data(), encoding: .utf8) ?? "", - "protocol=https\nhost=dev.azure.com\nusername=plantsuite\npassword=a\n\n" - + "protocol=https\nhost=example.com\nusername=alice\npassword=c\n\n" - ) - try MiniTest.expect(!args.contains(where: { $0.contains("ADEV_SEED") })) - let script = args.last ?? "" - try MiniTest.expect(script.contains("git config --global --add credential.helper"), "non-Azure host still appends") - try MiniTest.expect(script.contains("credential.https://dev.azure.com.helper"), "Azure host is URL-scoped") - try MiniTest.expect(script.contains("credential.https://dev.azure.com.useHttpPath true")) - try MiniTest.expect(!script.contains("git config --global --replace-all credential.helper")) - }), - ("guestSeedBindSSHOnlySkipsSilently", { - let ws = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: ws) } - let remoteOutput = """ - origin\tgit@github.com:org/repo.git (fetch) - origin\tssh://git@example.com/team/repo.git (fetch) - """ - let mock = MockProcessRunner() - mock.handlers = [ - { args in - if args == ["-C", ws.path, "remote", "-v"] { - return ProcessResult(exitCode: 0, stdout: Data(remoteOutput.utf8), stderr: Data()) - } - if args.first == "exec" { - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - return nil - } - ] - let creds = SeedMockCredential() - creds.defaultResult = .success(GitHTTPSCredentials(username: "u", password: "p")) - let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - let seed = GuestGitCredentialSeed( - credentials: creds, - runner: mock, - gitPathOverride: .some("/usr/bin/mock-git") - ) - try seed.seed( - containerId: "ctr", - hostWorkspace: ws.path, - gitURL: nil, - connectionUser: nil, - runtime: runtime - ) - try MiniTest.expect(creds.fillCalls.isEmpty, "SSH remotes never fill") - try MiniTest.expect( - !mock.calls.contains { $0.arguments.first == "exec" }, - "SSH-only workspace must not run a seeding exec" - ) - }), - ("guestSeedBindSecretsNeverInExecCommand", { - let ws = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: ws) } - let remoteOutput = """ - origin\thttps://dev.azure.com/plantsuite/PlantSuite/_git/GitOps (fetch) - """ - let mock = MockProcessRunner() - mock.handlers = [ - { args in - if args == ["-C", ws.path, "remote", "-v"] { - return ProcessResult(exitCode: 0, stdout: Data(remoteOutput.utf8), stderr: Data()) - } - if args.first == "exec" { - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - return nil - } - ] - let creds = SeedMockCredential() - let password = "p@ss:wo%rd/sla|sh;$(printf nope)" - creds.results["https://dev.azure.com/plantsuite/PlantSuite/_git/GitOps"] = .success( - GitHTTPSCredentials(username: "plantsuite", password: password) - ) - let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - let seed = GuestGitCredentialSeed( - credentials: creds, - runner: mock, - gitPathOverride: .some("/usr/bin/mock-git") - ) - try seed.seed( - containerId: "ctr", - hostWorkspace: ws.path, - gitURL: nil, - connectionUser: "vscode", - runtime: runtime - ) - let execs = mock.calls.filter { $0.arguments.first == "exec" } - try MiniTest.expectEqual(execs.count, 1) - let call = execs[0] - let args = call.arguments - try MiniTest.expect(args.contains("-u") && args.contains("vscode"), "seeding exec runs as connection user") - // Credentials travel via stdin, never in argv or the command environment. - try MiniTest.expectEqual( - String(data: call.stdinData ?? Data(), encoding: .utf8) ?? "", - "protocol=https\nhost=dev.azure.com\nusername=plantsuite\npassword=\(password)\n\n" - ) - try MiniTest.expect(!args.contains(where: { $0.contains(password) })) - try MiniTest.expect(call.environment?.values.contains(password) != true) - try MiniTest.expect(!args.contains("-e")) - let script = args.last ?? "" - try MiniTest.expect(script.contains("credential.https://dev.azure.com.helper")) - try MiniTest.expect(script.contains("credential.https://dev.azure.com.useHttpPath true")) - try MiniTest.expect(!script.contains("git config --global --replace-all credential.helper")) - try MiniTest.expect(!script.contains("git config --global --add credential.helper")) - try MiniTest.expect(script.contains("git credential approve")) - try MiniTest.expect(script.contains("printf 'protocol=%s\\nhost=%s\\nusername=%s\\npassword=%s\\n\\n'")) - try MiniTest.expect(!script.contains(password)) - try MiniTest.expect(!script.contains("plantsuite")) - try MiniTest.expect(!script.contains("_git"), "approve entries carry no path component") - try MiniTest.expect(!script.contains("GitOps"), "approve entries carry no path component") - }), - ("guestSeedBindSilentSkipNoRemotes", { - let ws = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: ws) } - let mock = MockProcessRunner() - mock.handlers = [ - { args in - if args == ["-C", ws.path, "remote", "-v"] { - return ProcessResult(exitCode: 0, stdout: Data("origin\thttps://x/y.git (push)\n".utf8), stderr: Data()) - } - if args.first == "exec" { - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - return nil - } - ] - let creds = SeedMockCredential() - creds.defaultResult = .success(GitHTTPSCredentials(username: "u", password: "p")) - let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - let seed = GuestGitCredentialSeed( - credentials: creds, - runner: mock, - gitPathOverride: .some("/usr/bin/mock-git") - ) - try seed.seed( - containerId: "ctr", - hostWorkspace: ws.path, - gitURL: nil, - connectionUser: nil, - runtime: runtime - ) - try MiniTest.expect(creds.fillCalls.isEmpty) - try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "exec" }) - }), - ("guestSeedBindSilentSkipNilFill", { - let ws = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: ws) } - let mock = MockProcessRunner() - mock.handlers = [ - { args in - if args == ["-C", ws.path, "remote", "-v"] { - return ProcessResult(exitCode: 0, stdout: Data("origin\thttps://example.com/pub.git (fetch)\n".utf8), stderr: Data()) - } - if args.first == "exec" { - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - return nil - } - ] - let creds = SeedMockCredential() // default nil fill - let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - let seed = GuestGitCredentialSeed( - credentials: creds, - runner: mock, - gitPathOverride: .some("/usr/bin/mock-git") - ) - try seed.seed( - containerId: "ctr", - hostWorkspace: ws.path, - gitURL: nil, - connectionUser: nil, - runtime: runtime - ) - try MiniTest.expectEqual(creds.fillCalls, ["https://example.com/pub.git"]) - try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "exec" }, "nil fill skips silently") - }), - ("guestSeedBindSilentSkipMissingGit", { - let ws = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: ws) } - let mock = MockProcessRunner() - mock.handlers = [ - { args in - if args.first == "exec" { - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - return nil - } - ] - let creds = SeedMockCredential() - creds.defaultResult = .success(GitHTTPSCredentials(username: "u", password: "p")) - let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - let seed = GuestGitCredentialSeed( - credentials: creds, - runner: mock, - gitPathOverride: .some(nil) - ) - try seed.seed( - containerId: "ctr", - hostWorkspace: ws.path, - gitURL: nil, - connectionUser: nil, - runtime: runtime - ) - try MiniTest.expect(creds.fillCalls.isEmpty, "missing host git must not fill") - try MiniTest.expect(mock.calls.isEmpty, "missing host git must not run anything") - }), - ("guestSeedVolumeSeedsFromGitURL", { - let mock = MockProcessRunner() - mock.handlers = [ - { args in - if args.first == "exec" { - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - return nil - } - ] - let creds = SeedMockCredential() - creds.results["https://github.com/org/repo.git"] = .success( - GitHTTPSCredentials(username: "x-access-token", password: "ghp_seed") - ) - let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - let seed = GuestGitCredentialSeed( - credentials: creds, - runner: mock, - gitPathOverride: .some("/usr/bin/mock-git") - ) - try seed.seed( - containerId: "ctr", - hostWorkspace: nil, - gitURL: "https://github.com/org/repo.git", - connectionUser: "root", - runtime: runtime - ) - try MiniTest.expectEqual(creds.fillCalls, ["https://github.com/org/repo.git"]) - // No host remote enumeration in volume mode. - try MiniTest.expect(!mock.calls.contains { $0.arguments.contains("-C") || $0.arguments.contains("remote") }) - let execs = mock.calls.filter { $0.arguments.first == "exec" } - try MiniTest.expectEqual(execs.count, 1) - let call = execs[0] - let args = call.arguments - try MiniTest.expectEqual( - String(data: call.stdinData ?? Data(), encoding: .utf8) ?? "", - "protocol=https\nhost=github.com\nusername=x-access-token\npassword=ghp_seed\n\n" - ) - try MiniTest.expect(!args.contains(where: { $0.contains("ghp_seed") })) - try MiniTest.expect(call.environment?.values.contains("ghp_seed") != true) - try MiniTest.expect(args.contains("-u") && args.contains("root")) - let script = args.last ?? "" - try MiniTest.expect(script.contains("git config --global --add credential.helper")) - try MiniTest.expect(!script.contains("useHttpPath"), "GitHub seed must not set Azure useHttpPath") - try MiniTest.expect(!script.contains("credential.https://dev.azure.com.helper")) - try MiniTest.expect(!script.contains("git config --global --replace-all credential.helper")) - }), - ("guestSeedInstallHTTPSCredentialDoesNotRefill", { - let mock = MockProcessRunner() - mock.handlers = [ - { args in - if args.first == "exec" { - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - return nil - } - ] - let creds = SeedMockCredential() - creds.defaultResult = .success(GitHTTPSCredentials(username: "should-not-fill", password: "nope")) - let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - let seed = GuestGitCredentialSeed( - credentials: creds, - runner: mock, - gitPathOverride: .some("/usr/bin/mock-git") - ) - try seed.installHTTPSCredential( - containerId: "ctr", - url: "https://dev.azure.com/plantsuite/PlantSuite/_git/PlantSuite", - credentials: GitHTTPSCredentials(username: "plantsuite", password: "already-filled"), - connectionUser: "vscode", - runtime: runtime - ) - try MiniTest.expect(creds.fillCalls.isEmpty, "prepared install must not fill again") - let execs = mock.calls.filter { $0.arguments.first == "exec" } - try MiniTest.expectEqual(execs.count, 1) - let script = execs[0].arguments.last ?? "" - try MiniTest.expect(script.contains("credential.https://dev.azure.com.useHttpPath true")) - try MiniTest.expect(script.contains("credential.https://dev.azure.com.helper")) - try MiniTest.expect(!script.contains("git config --global --replace-all credential.helper")) - try MiniTest.expect(!script.contains("git config --global --add credential.helper")) - try MiniTest.expect(!script.contains("already-filled"), "password stays off argv") - try MiniTest.expectEqual( - String(data: execs[0].stdinData ?? Data(), encoding: .utf8) ?? "", - "protocol=https\nhost=dev.azure.com\nusername=plantsuite\npassword=already-filled\n\n" - ) - try MiniTest.expect(!script.contains("_git"), "approve entries carry no path component") - }), - ("guestSeedVolumeMissingOrEmptyGitURLSkips", { - let mock = MockProcessRunner() - mock.handlers = [ - { args in - if args.first == "exec" { - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - return nil - } - ] - let creds = SeedMockCredential() - creds.defaultResult = .success(GitHTTPSCredentials(username: "u", password: "p")) - let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - let seed = GuestGitCredentialSeed( - credentials: creds, - runner: mock, - gitPathOverride: .some("/usr/bin/mock-git") - ) - for url in [nil, "", " "] { - try seed.seed( - containerId: "ctr", - hostWorkspace: nil, - gitURL: url, - connectionUser: nil, - runtime: runtime - ) - } - try MiniTest.expect(creds.fillCalls.isEmpty, "missing/empty git_url must not fill") - try MiniTest.expect(mock.calls.isEmpty, "missing/empty git_url must not exec") - }), - ("guestSeedExecFailureRedactsUserinfoAndCredentialMaterial", { - let ws = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: ws) } - let secretURL = "https://plantsuite:s3cret@dev.azure.com/plantsuite/PlantSuite/_git/GitOps" - let remoteOutput = "origin\t\(secretURL) (fetch)\n" - let mock = MockProcessRunner() - mock.handlers = [ - { args in - if args == ["-C", ws.path, "remote", "-v"] { - return ProcessResult(exitCode: 0, stdout: Data(remoteOutput.utf8), stderr: Data()) - } - if args.first == "exec" { - return ProcessResult( - exitCode: 1, - stdout: Data(), - stderr: Data("fatal: unable to reach '\(secretURL)': remote error; arbitrary-helper-output password=unexpected-secret https://user:unexpected-url-secret@example.com/repo\n".utf8) - ) - } - return nil - } - ] - let creds = SeedMockCredential() - creds.results[secretURL] = .success( - GitHTTPSCredentials(username: "plantsuite", password: "tok-pass") - ) - let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - let seed = GuestGitCredentialSeed( - credentials: creds, - runner: mock, - gitPathOverride: .some("/usr/bin/mock-git") - ) - try MiniTest.expectThrows({ - try seed.seed( - containerId: "ctr", - hostWorkspace: ws.path, - gitURL: nil, - connectionUser: nil, - runtime: runtime - ) - }) { error in - let err = error as! CLIError - try MiniTest.expect(!err.message.contains("s3cret"), "URL userinfo redacted") - try MiniTest.expect(!err.message.contains("plantsuite:s3cret")) - try MiniTest.expect(!err.message.contains("tok-pass"), "credential material redacted") - try MiniTest.expect(!err.message.contains("unexpected-secret"), "unexpected helper output is not re-emitted") - try MiniTest.expect(!err.message.contains("arbitrary-helper-output"), "arbitrary helper output is not re-emitted") - try MiniTest.expect(!err.message.contains("unexpected-url-secret"), "unexpected URL is not re-emitted") - try MiniTest.expect(err.message.contains("dev.azure.com"), "safe host remains readable") - } - // The seeding exec command itself never carried the userinfo URL. - let execs = mock.calls.filter { $0.arguments.first == "exec" } - try MiniTest.expectEqual(execs.count, 1) - let script = execs[0].arguments.last ?? "" - try MiniTest.expect(!script.contains("s3cret")) - try MiniTest.expect(!script.contains("plantsuite:s3cret")) - try MiniTest.expect(!script.contains("git config --global --list")) - try MiniTest.expect(!script.contains("ls -la")) - try MiniTest.expect(!script.contains("sed -E")) - }), - ("guestSeedScriptShape", { - let script = GuestGitCredentialSeed.seedScript() - try MiniTest.expect(script.contains("set -e")) - try MiniTest.expect(script.contains("git config --global --add credential.helper")) - try MiniTest.expect(!script.contains("git config --global --replace-all credential.helper")) - try MiniTest.expect(!script.contains("useHttpPath")) - try MiniTest.expect(script.contains("git credential approve")) - try MiniTest.expect(script.contains("while IFS= read -r line")) - try MiniTest.expect(!script.contains("ADEV_SEED"), "seed script must not read credential env") - try MiniTest.expect(!script.contains("https://"), "default script must not embed a URL") - try MiniTest.expect(!script.contains("ADEV_SEED_0_"), "script must not embed any env value") - let azure = GuestGitCredentialSeed.seedScript(appendGlobalHelper: false, configureAzureHost: true) - try MiniTest.expect(!azure.contains("git config --global --replace-all credential.helper")) - try MiniTest.expect(!azure.contains("git config --global --add credential.helper")) - try MiniTest.expect(!azure.contains("credential.https://dev.azure.com.helper \"\""), "Azure seed must not write an empty helper reset") - try MiniTest.expect(!azure.contains("credential.https://dev.azure.com.helper ''")) - try MiniTest.expect(azure.contains("git config --global --unset-all \"$azure_key\""), "empty URL-scoped helper values are removed") - try MiniTest.expect(azure.contains("git config --global --add \"$azure_key\" \"$HELPER\"")) - try MiniTest.expect(azure.contains("credential.https://dev.azure.com.useHttpPath true")) - guard let removed = azure.range(of: "git config --global --unset-all \"$azure_key\"")?.lowerBound, - let added = azure.range(of: "git config --global --add \"$azure_key\" \"$HELPER\"")?.lowerBound, - let usePath = azure.range(of: "credential.https://dev.azure.com.useHttpPath")?.lowerBound, - let approve = azure.range(of: "git credential approve")?.lowerBound - else { - throw MiniTest.Failure(message: "Azure seed script is missing the URL-scoped helper") - } - try MiniTest.expect(removed < added, "empty helper removal precedes ensuring the product helper") - try MiniTest.expect(added < usePath, "product helper is configured before useHttpPath") - try MiniTest.expect(usePath < approve, "useHttpPath is set before approve") - }), - ("guestSeedScriptResolvesPasswdHomeBeforeCredentialSetup", { - let script = GuestGitCredentialSeed.seedScript() - guard let resolve = script.range(of: "if command -v getent")?.lowerBound, - let fallback = script.range(of: "done < /etc/passwd")?.lowerBound, - let nonEmpty = script.range(of: "[ -n \"$resolved_home\" ] || exit 1")?.lowerBound, - let exportHome = script.range(of: "export HOME")?.lowerBound, - let helper = script.range(of: "HELPER=\"$HOME/.adevcontainer/git-credential-adev\"")?.lowerBound, - let config = script.range(of: "git config --global --add credential.helper")?.lowerBound, - let approve = script.range(of: "git credential approve")?.lowerBound - else { - throw MiniTest.Failure(message: "seed script is missing home/setup operations") - } - try MiniTest.expect(resolve < fallback, "getent lookup precedes passwd fallback") - try MiniTest.expect(fallback < nonEmpty, "passwd fallback precedes home validation") - try MiniTest.expect(nonEmpty < exportHome, "home validates before export") - try MiniTest.expect(exportHome < helper, "resolved HOME exports before helper setup") - try MiniTest.expect(resolve < helper, "passwd home resolves before helper setup") - try MiniTest.expect(helper < config, "helper path is ready before global config") - try MiniTest.expect(config < approve, "global config precedes approve") - try MiniTest.expect(script.contains("getent passwd \"$uid\"")) - try MiniTest.expect(script.contains("done < /etc/passwd"), "portable passwd fallback is present") - try MiniTest.expect(script.contains("[ -n \"$resolved_home\" ] || exit 1")) - try MiniTest.expect(script.contains("mkdir -p \"$resolved_home\" 2>/dev/null || exit 1")) - try MiniTest.expect(script.contains("[ -d \"$resolved_home\" ] || exit 1")) - try MiniTest.expect(script.contains("HOME=\"$resolved_home\"\nexport HOME")) - }), - ("guestSeedScriptUsesResolvedPasswdHomeForConfigAndApprove", { - let fixture = try makeSeedScriptFixture() - defer { try? FileManager.default.removeItem(at: fixture.root) } - let input = "protocol=https\nhost=home.example\nusername=user\npassword=secret\n\n" - let result = try FoundationProcessRunner().run( - executable: "/bin/sh", - arguments: ["-c", GuestGitCredentialSeed.seedScript()], - environment: fixture.environment, - currentDirectory: nil, - stdinData: Data(input.utf8) - ) - try MiniTest.expect(result.succeeded, "seed script runs with a controlled passwd home") - let config = try String(contentsOf: fixture.home.appendingPathComponent("git-config"), encoding: .utf8) - try MiniTest.expect(config.contains("credential.helper=\(fixture.home.path)/.adevcontainer/git-credential-adev")) - try MiniTest.expectEqual( - try String(contentsOf: fixture.home.appendingPathComponent(".adevcontainer/git-credentials"), encoding: .utf8), - input - ) - try MiniTest.expect( - !FileManager.default.fileExists(atPath: fixture.inheritedHome.appendingPathComponent(".adevcontainer").path), - "inherited HOME is not used" - ) - }), - ("guestSeedScriptApprovesAndPersistsMultipleStdinBlocks", { - let fixture = try makeSeedScriptFixture() - defer { try? FileManager.default.removeItem(at: fixture.root) } - let input = "protocol=https\nhost=one.example\nusername=u@ser:one\npassword=p@ss:one%raw\n\n" - + "protocol=https\nhost=two.example\nusername=two|user\npassword=two@pass:raw\n\n" - let result = try FoundationProcessRunner().run( - executable: "/bin/sh", - arguments: ["-c", GuestGitCredentialSeed.seedScript()], - environment: fixture.environment, - currentDirectory: nil, - stdinData: Data(input.utf8) - ) - try MiniTest.expect(result.succeeded, "multi-block seed succeeds") - let approvals = try String(contentsOf: fixture.home.appendingPathComponent("approve-calls"), encoding: .utf8) - try MiniTest.expectEqual(approvals.split(whereSeparator: \.isNewline).count, 2, "both blocks are approved") - try MiniTest.expectEqual( - try String(contentsOf: fixture.home.appendingPathComponent(".adevcontainer/git-credentials"), encoding: .utf8), - input, - "both blocks persist in input order with raw values" - ) - try MiniTest.expect(!result.stdoutString.contains("p@ss:one%raw")) - try MiniTest.expect(!result.stderrString.contains("two@pass:raw")) - }), - ("guestSeedScriptApprovesRawStdin", { - let dir = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: dir) } - let home = dir.appendingPathComponent("home", isDirectory: true) - let inheritedHome = dir.appendingPathComponent("inherited-home", isDirectory: true) - let bin = dir.appendingPathComponent("bin", isDirectory: true) - try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: inheritedHome, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) - let fakeID = bin.appendingPathComponent("id") - try """ - #!/bin/sh - [ "$1" = "-u" ] && printf '4242\\n' - """.write(to: fakeID, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeID.path) - let fakeGetent = bin.appendingPathComponent("getent") - try """ - #!/bin/sh - printf 'seeduser:x:4242:4242::%s:/bin/sh\\n' '__SEED_HOME__' - """.replacingOccurrences(of: "__SEED_HOME__", with: home.path) - .write(to: fakeGetent, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeGetent.path) - let fakeGit = bin.appendingPathComponent("git") - try """ - #!/bin/sh - if [ "$1" = "credential" ] && [ "$2" = "approve" ]; then - cat > "$HOME/approved" - fi - exit 0 - """.write(to: fakeGit, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeGit.path) - - let password = "p@ss:wo%rd/sla|sh;$(printf nope)" - let input = "protocol=https\nhost=h.example\nusername=u\npassword=\(password)\n\n" - let path = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin" - let result = try FoundationProcessRunner().run( - executable: "/bin/sh", - arguments: ["-c", GuestGitCredentialSeed.seedScript()], - environment: ["HOME": inheritedHome.path, "PATH": "\(bin.path):\(path)"], - currentDirectory: nil, - stdinData: Data(input.utf8) - ) - try MiniTest.expect(result.succeeded, "seed script approves stdin") - try MiniTest.expect(!result.stdoutString.contains(password)) - try MiniTest.expect(!result.stderrString.contains(password)) - try MiniTest.expectEqual( - try String(contentsOf: home.appendingPathComponent("approved"), encoding: .utf8), - input - ) - try MiniTest.expect( - !FileManager.default.fileExists(atPath: inheritedHome.appendingPathComponent(".adevcontainer").path), - "seed ignores inherited HOME" - ) - }), - // ══════════ Phase 8: credential helper executed via sh (Linux test env) ══════════ - - ("guestSeedHelperGetUsernameMismatchReturnsQueriedUsername", { - let helper = GuestGitCredentialSeed.helperScript() - let dir = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: dir) } - let helperPath = dir.appendingPathComponent("git-credential-adev") - try Data(helper.utf8).write(to: helperPath) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helperPath.path) - let home = dir.path - // Store (https, dev.azure.com, X), then query with username=Y (X != Y). - let store = try runCredentialHelper( - helperPath: helperPath.path, action: "store", home: home, - input: "protocol=https\nhost=dev.azure.com\nusername=X\npassword=sec:ret@1\n\n" - ) - try MiniTest.expect(store.succeeded, "store exits 0") - try MiniTest.expect(store.stdoutString.isEmpty, "store writes nothing to stdout") - try MiniTest.expect(store.stderrString.isEmpty, "store writes nothing to stderr") - let fill = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, - input: "protocol=https\nhost=dev.azure.com\nusername=Y\n\n" - ) - try MiniTest.expect(fill.succeeded, "get exits 0") - try MiniTest.expect(fill.stderrString.isEmpty, "get writes nothing to stderr") - let out = fill.stdoutString - try MiniTest.expect(out.contains("protocol=https")) - try MiniTest.expect(out.contains("host=dev.azure.com")) - try MiniTest.expect(out.contains("username=Y"), "queried username wins over stored") - try MiniTest.expect(!out.contains("username=X"), "stored username not emitted when the query carries one") - try MiniTest.expect(out.contains("password=sec:ret@1"), "stored password returned on username-agnostic match") - // useHttpPath queries include path; the imported credential must still match. - let withPath = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, - input: "protocol=https\nhost=dev.azure.com\npath=plantsuite/PlantSuite/_git/PlantSuite\n\n" - ) - try MiniTest.expect(withPath.succeeded, "path-scoped get exits 0") - try MiniTest.expect(withPath.stdoutString.contains("password=sec:ret@1"), "path is ignored so useHttpPath queries match") - try MiniTest.expect(withPath.stdoutString.contains("username=X"), "stored username returned when the path query has none") - }), - ("guestSeedHelperGetExactMatchAndStoredUsernameFallback", { - let helper = GuestGitCredentialSeed.helperScript() - let dir = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: dir) } - let helperPath = dir.appendingPathComponent("git-credential-adev") - try Data(helper.utf8).write(to: helperPath) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helperPath.path) - let home = dir.path - let store = try runCredentialHelper( - helperPath: helperPath.path, action: "store", home: home, - input: "protocol=https\nhost=github.com\nusername=alice\npassword=tok/enc%ded\n\n" - ) - try MiniTest.expect(store.succeeded) - let exact = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, - input: "protocol=https\nhost=github.com\nusername=alice\n\n" - ) - try MiniTest.expect(exact.succeeded) - try MiniTest.expect(exact.stdoutString.contains("username=alice")) - try MiniTest.expect(exact.stdoutString.contains("password=tok/enc%ded")) - // Query with no username → the stored username is returned. - let bare = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, - input: "protocol=https\nhost=github.com\n\n" - ) - try MiniTest.expect(bare.succeeded) - try MiniTest.expect(bare.stdoutString.contains("username=alice"), "stored username returned when the query carries none") - try MiniTest.expect(bare.stdoutString.contains("password=tok/enc%ded")) - }), - ("guestSeedHelperGetNoMatchExitsZeroWithoutOutput", { - let helper = GuestGitCredentialSeed.helperScript() - let dir = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: dir) } - let helperPath = dir.appendingPathComponent("git-credential-adev") - try Data(helper.utf8).write(to: helperPath) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helperPath.path) - let home = dir.path - let store = try runCredentialHelper( - helperPath: helperPath.path, action: "store", home: home, - input: "protocol=https\nhost=dev.azure.com\nusername=X\npassword=p\n\n" - ) - try MiniTest.expect(store.succeeded) - // Unseeded host → silent; protocol mismatch on the seeded host → silent. - for query in [ - "protocol=https\nhost=example.com\nusername=Y\n\n", - "protocol=http\nhost=dev.azure.com\nusername=X\n\n" - ] { - let fill = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, input: query - ) - try MiniTest.expect(fill.succeeded, "no-match get exits 0") - try MiniTest.expect(fill.stdoutString.isEmpty, "no-match get emits nothing") - try MiniTest.expect(fill.stderrString.isEmpty) - } - // Scoping: the seeded host still answers after the no-match probes. - let seeded = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, - input: "protocol=https\nhost=dev.azure.com\nusername=X\n\n" - ) - try MiniTest.expect(seeded.stdoutString.contains("password=p"), "seeded host unaffected by no-match probes") - }), - ("guestSeedHelperStoreDedupesByProtocolHostUsername", { - let helper = GuestGitCredentialSeed.helperScript() - let dir = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: dir) } - let helperPath = dir.appendingPathComponent("git-credential-adev") - try Data(helper.utf8).write(to: helperPath) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helperPath.path) - let home = dir.path - let storePath = dir.appendingPathComponent(".adevcontainer/git-credentials") - for input in [ - "protocol=https\nhost=h.example\nusername=u\npassword=p1\n\n", - "protocol=https\nhost=h.example\nusername=u\npassword=p2\n\n", - "protocol=https\nhost=h.example\nusername=other\npassword=p3\n\n" - ] { - let r = try runCredentialHelper( - helperPath: helperPath.path, action: "store", home: home, input: input - ) - try MiniTest.expect(r.succeeded) - } - let entries = try String(contentsOf: storePath, encoding: .utf8) - .split(whereSeparator: \.isNewline) - .filter { $0.hasPrefix("protocol=") } - try MiniTest.expectEqual(entries.count, 2, "same (protocol, host, username) triple dedupes to one entry") - let getU = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, - input: "protocol=https\nhost=h.example\nusername=u\n\n" - ) - try MiniTest.expect(getU.stdoutString.contains("password=p2"), "re-store of the same triple keeps the latest password") - // A second entry for the same (protocol, host) is still matched ignoring the - // queried username; the first stored entry wins. - let getOther = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, - input: "protocol=https\nhost=h.example\nusername=other\n\n" - ) - try MiniTest.expect(getOther.stdoutString.contains("username=other"), "queried username echoed on a (protocol, host) match") - try MiniTest.expect(getOther.stdoutString.contains("password=p2"), "first (protocol, host) match wins") - }), - ("guestSeedHelperStoreRoundTripsSpecialCharacters", { - let helper = GuestGitCredentialSeed.helperScript() - let dir = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: dir) } - let helperPath = dir.appendingPathComponent("git-credential-adev") - try Data(helper.utf8).write(to: helperPath) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helperPath.path) - let home = dir.path - let storePath = dir.appendingPathComponent(".adevcontainer/git-credentials") - let username = "us@er:na%me/slash|pipe" - let password = "p@ss:wo%rd/sla|sh%25" - let r = try runCredentialHelper( - helperPath: helperPath.path, action: "store", home: home, - input: "protocol=https\nhost=h.example\nusername=\(username)\npassword=\(password)\n\n" - ) - try MiniTest.expect(r.succeeded) - // Store file uses git's own key-block shape: raw values, no escaping. - let stored = try String(contentsOf: storePath, encoding: .utf8) - try MiniTest.expect(stored.contains("protocol=https\nhost=h.example\nusername=\(username)\npassword=\(password)\n"), "store block round-trips raw values verbatim") - let fill = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, - input: "protocol=https\nhost=h.example\nusername=\(username)\n\n" - ) - try MiniTest.expect(fill.succeeded) - try MiniTest.expect(fill.stdoutString.contains("username=\(username)"), "raw username round-trips") - try MiniTest.expect(fill.stdoutString.contains("password=\(password)"), "raw password round-trips") - }), - ("guestSeedHelperAndStoreFileModesPrivate", { - let helper = GuestGitCredentialSeed.helperScript() - let dir = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: dir) } - let helperPath = dir.appendingPathComponent("git-credential-adev") - try Data(helper.utf8).write(to: helperPath) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helperPath.path) - let home = dir.path - let storePath = dir.appendingPathComponent(".adevcontainer/git-credentials") - let store = try runCredentialHelper( - helperPath: helperPath.path, action: "store", home: home, - input: "protocol=https\nhost=h.example\nusername=u\npassword=p\n\n" - ) - try MiniTest.expect(store.succeeded) - let helperAttrs = try FileManager.default.attributesOfItem(atPath: helperPath.path) - try MiniTest.expectEqual((helperAttrs[.posixPermissions] as? NSNumber)?.intValue, 0o700, "helper script is 0700") - let storeAttrs = try FileManager.default.attributesOfItem(atPath: storePath.path) - try MiniTest.expectEqual((storeAttrs[.posixPermissions] as? NSNumber)?.intValue, 0o600, "store file is 0600") - }), - ("guestSeedHelperKeepsSecretsOutOfArgvAndLogs", { - let helper = GuestGitCredentialSeed.helperScript() - try MiniTest.expect(!helper.contains("ADEV_SEED"), "helper is standalone; entries never couple to seed env") - let dir = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: dir) } - let helperPath = dir.appendingPathComponent("git-credential-adev") - try Data(helper.utf8).write(to: helperPath) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helperPath.path) - let home = dir.path - // Store: secrets travel via stdin only; stdout and stderr stay empty. - let store = try runCredentialHelper( - helperPath: helperPath.path, action: "store", home: home, - input: "protocol=https\nhost=h.example\nusername=u\npassword=hunter2@secret\n\n" - ) - try MiniTest.expect(store.succeeded) - try MiniTest.expect(store.stdoutString.isEmpty, "store never echoes secrets to stdout") - try MiniTest.expect(store.stderrString.isEmpty, "store never writes to stderr") - // Get: stdout carries only credential-protocol lines. - let fill = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, - input: "protocol=https\nhost=h.example\nusername=u\n\n" - ) - try MiniTest.expect(fill.succeeded) - try MiniTest.expect(fill.stderrString.isEmpty, "get never writes to stderr") - for line in fill.stdoutString.split(whereSeparator: \.isNewline) { - try MiniTest.expect( - line.isEmpty || line.hasPrefix("protocol=") || line.hasPrefix("host=") - || line.hasPrefix("username=") || line.hasPrefix("password="), - "stdout carries only credential-protocol lines: \(line)" - ) - } - // Erase removes the matching protocol+host entry and stays silent. - let erase = try runCredentialHelper( - helperPath: helperPath.path, action: "erase", home: home, - input: "protocol=https\nhost=h.example\nusername=other\npath=ignored\n\n" - ) - try MiniTest.expect(erase.succeeded) - try MiniTest.expect(erase.stdoutString.isEmpty) - try MiniTest.expect(erase.stderrString.isEmpty) - try MiniTest.expect(!erase.stdoutString.contains("hunter2@secret")) - let after = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, - input: "protocol=https\nhost=h.example\n\n" - ) - try MiniTest.expect(after.succeeded) - try MiniTest.expect(after.stdoutString.isEmpty, "erase removed the matching store entry") - try MiniTest.expect(!((try? String(contentsOf: dir.appendingPathComponent(".adevcontainer/git-credentials"), encoding: .utf8)) ?? "").contains("hunter2@secret")) - }), - ("guestSeedScriptConfigAppendsHelperAndDropsStore", { - let script = GuestGitCredentialSeed.seedScript() - try MiniTest.expect(script.contains("git config --global --add credential.helper"), "non-Azure helper is appended") - try MiniTest.expect(!script.contains("git config --global --replace-all credential.helper"), "global helper list is not replaced") - try MiniTest.expect(!script.contains("useHttpPath"), "non-Azure script does not set Azure useHttpPath") - try MiniTest.expect(script.contains("$HOME/.adevcontainer/git-credential-adev"), "absolute helper path") - try MiniTest.expect(script.contains("chmod 700 \"$HELPER\""), "helper written 0700") - try MiniTest.expect(script.contains("#!/bin/sh"), "helper has a shebang") - try MiniTest.expect(script.contains("<<'ADEV_HELPER_EOF'") && script.contains("ADEV_HELPER_EOF"), "helper embedded in a quoted heredoc") - try MiniTest.expect(!script.contains("credential.helper store"), "store-helper config removed") - try MiniTest.expect(script.contains("git credential approve"), "approve loop kept") - try MiniTest.expect(script.contains("while IFS= read -r line")) - }), - ("guestSeedScriptKeepsGlobalHelperAndScopesAzure", { - let fixture = try makeSeedScriptFixture() - defer { try? FileManager.default.removeItem(at: fixture.root) } - let configPath = fixture.home.appendingPathComponent("git-config") - try "credential.helper=pre-existing\ncore.askpass=keep\n".write( - to: configPath, atomically: true, encoding: .utf8 - ) - let result = try FoundationProcessRunner().run( - executable: "/bin/sh", - arguments: ["-c", GuestGitCredentialSeed.seedScript()], - environment: fixture.environment, - currentDirectory: nil, - stdinData: Data("protocol=https\nhost=preserve.example\nusername=u\npassword=p\n\n".utf8) - ) - try MiniTest.expect(result.succeeded, "seed script configures the helper") - let config = try String(contentsOf: configPath, encoding: .utf8) - let lines = config.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - guard let existing = lines.firstIndex(of: "credential.helper=pre-existing"), - let seeded = lines.firstIndex(of: "credential.helper=\(fixture.home.path)/.adevcontainer/git-credential-adev") - else { - throw MiniTest.Failure(message: "global git config lost or omitted a credential helper") - } - try MiniTest.expect(existing < seeded, "--add preserves the pre-existing helper") - try MiniTest.expect(!config.contains("useHttpPath")) - try MiniTest.expect(lines.contains("core.askpass=keep"), "unrelated global config remains") - - let azure = try FoundationProcessRunner().run( - executable: "/bin/sh", - arguments: ["-c", GuestGitCredentialSeed.seedScript(appendGlobalHelper: false, configureAzureHost: true)], - environment: fixture.environment, - currentDirectory: nil, - stdinData: Data("protocol=https\nhost=dev.azure.com\nusername=u\npassword=p\n\n".utf8) - ) - try MiniTest.expect(azure.succeeded, "Azure seed script configures the URL-scoped helper") - let azureLines = try String(contentsOf: configPath, encoding: .utf8) - .split(separator: "\n", omittingEmptySubsequences: false) - .map(String.init) - try MiniTest.expect(azureLines.contains("credential.helper=pre-existing"), "Azure seed leaves the global helper") - try MiniTest.expect(!azureLines.contains("credential.https://dev.azure.com.helper="), "Azure seed does not write an empty helper") - let productHelper = "credential.https://dev.azure.com.helper=\(fixture.home.path)/.adevcontainer/git-credential-adev" - try MiniTest.expectEqual(azureLines.filter { $0 == productHelper }.count, 1, "product helper is present once") - try MiniTest.expect(azureLines.contains("credential.https://dev.azure.com.useHttpPath=true")) - try MiniTest.expect(!azureLines.contains("credential.helper="), "Azure seed does not empty the global helper") - }), - ("guestSeedHelperReadsTrailingBlockAndEraseKeepsIt", { - let helper = GuestGitCredentialSeed.helperScript() - let dir = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: dir) } - let helperPath = dir.appendingPathComponent("git-credential-adev") - let storePath = dir.appendingPathComponent(".adevcontainer/git-credentials") - try Data(helper.utf8).write(to: helperPath) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helperPath.path) - try FileManager.default.createDirectory( - at: storePath.deletingLastPathComponent(), withIntermediateDirectories: true - ) - let stored = "protocol=https\nhost=trailing.example\nusername=u\npassword=p@ss:raw" - try stored.write(to: storePath, atomically: true, encoding: .utf8) - let get = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: dir.path, - input: "protocol=https\nhost=trailing.example\nusername=other\n\n" - ) - try MiniTest.expect(get.succeeded, "trailing block get exits 0") - try MiniTest.expect(get.stdoutString.contains("username=other")) - try MiniTest.expect(get.stdoutString.contains("password=p@ss:raw")) - let erase = try runCredentialHelper( - helperPath: helperPath.path, action: "erase", home: dir.path, - input: "protocol=https\nhost=trailing.example\n\n" - ) - try MiniTest.expect(erase.succeeded, "erase exits 0") - try MiniTest.expect(erase.stdoutString.isEmpty) - try MiniTest.expect(erase.stderrString.isEmpty) - let after = try String(contentsOf: storePath, encoding: .utf8) - try MiniTest.expect(!after.contains("p@ss:raw"), "erase removes the trailing protocol+host block") - let mode = try FileManager.default.attributesOfItem(atPath: storePath.path) - try MiniTest.expectEqual((mode[.posixPermissions] as? NSNumber)?.intValue, 0o600, "erase rewrite is 0600") - }), - ("guestSeedHelperExpiredJWTIsMissAndRemoved", { - let helper = GuestGitCredentialSeed.helperScript() - let dir = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: dir) } - let helperPath = dir.appendingPathComponent("git-credential-adev") - try Data(helper.utf8).write(to: helperPath) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helperPath.path) - let home = dir.path - let expired = credentialJWT(payloadJSON: #"{"exp":1000,"sub":"x"}"#) - let skew = credentialJWT(payloadJSON: "{\"exp\":\(Int(Date().timeIntervalSince1970) + 30)}") - let valid = credentialJWT(payloadJSON: "{\"exp\":\(Int(Date().timeIntervalSince1970) + 3600)}") - let noExp = credentialJWT(payloadJSON: #"{"sub":"x"}"#) - _ = try runCredentialHelper( - helperPath: helperPath.path, action: "store", home: home, - input: "protocol=https\nhost=dev.azure.com\nusername=X\npassword=\(expired)\n\n" - ) - _ = try runCredentialHelper( - helperPath: helperPath.path, action: "store", home: home, - input: "protocol=https\nhost=github.com\nusername=alice\npassword=keep-me\n\n" - ) - let miss = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, - input: "protocol=https\nhost=dev.azure.com\nusername=Y\npath=org/proj/_git/repo\n\n" - ) - try MiniTest.expect(miss.succeeded) - try MiniTest.expect(miss.stdoutString.isEmpty, "expired JWT is a miss") - try MiniTest.expect(miss.stderrString.isEmpty) - try MiniTest.expect(!miss.stdoutString.contains(expired)) - let store = try String(contentsOf: dir.appendingPathComponent(".adevcontainer/git-credentials"), encoding: .utf8) - try MiniTest.expect(!store.contains(expired), "expired entry is removed") - try MiniTest.expect(store.contains("password=keep-me"), "other host remains") - _ = try runCredentialHelper( - helperPath: helperPath.path, action: "store", home: home, - input: "protocol=https\nhost=dev.azure.com\nusername=X\npassword=\(skew)\n\n" - ) - let skewGet = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, - input: "protocol=https\nhost=dev.azure.com\n\n" - ) - try MiniTest.expect(skewGet.stdoutString.isEmpty, "exp within 60 seconds is expired") - try MiniTest.expect(skewGet.stderrString.isEmpty) - _ = try runCredentialHelper( - helperPath: helperPath.path, action: "store", home: home, - input: "protocol=https\nhost=dev.azure.com\nusername=X\npassword=\(valid)\n\n" - ) - let kept = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, - input: "protocol=https\nhost=dev.azure.com\nusername=Y\n\n" - ) - try MiniTest.expect(kept.stdoutString.contains("username=Y")) - try MiniTest.expect(kept.stdoutString.contains("password=\(valid)"), "future exp is returned") - try MiniTest.expect(kept.stderrString.isEmpty) - for line in kept.stdoutString.split(whereSeparator: \.isNewline) where !line.isEmpty { - try MiniTest.expect( - line.hasPrefix("protocol=") || line.hasPrefix("host=") - || line.hasPrefix("username=") || line.hasPrefix("password="), - "password stays inside the credential protocol" - ) - } - _ = try runCredentialHelper( - helperPath: helperPath.path, action: "store", home: home, - input: "protocol=https\nhost=other.example\nusername=u\npassword=\(noExp)\n\n" - ) - let missingExp = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, - input: "protocol=https\nhost=other.example\n\n" - ) - try MiniTest.expect(missingExp.stdoutString.contains("password=\(noExp)"), "missing exp returns the password") - for password in ["plain-token", "foo.bar", "a.b.c.d", "aaa.!!!.bbb"] { - _ = try runCredentialHelper( - helperPath: helperPath.path, action: "store", home: home, - input: "protocol=https\nhost=literal.example\nusername=u\npassword=\(password)\n\n" - ) - let literal = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, - input: "protocol=https\nhost=literal.example\nusername=queried\n\n" - ) - try MiniTest.expect(literal.stdoutString.contains("password=\(password)"), "non-JWT \(password) is returned") - try MiniTest.expect(literal.stderrString.isEmpty) - } - _ = try runCredentialHelper( - helperPath: helperPath.path, action: "store", home: home, - input: "protocol=https\nhost=nob64.example\nusername=u\npassword=\(expired)\n\n" - ) - let noBase64 = try runCredentialHelper( - helperPath: helperPath.path, action: "get", home: home, - input: "protocol=https\nhost=nob64.example\n\n", - path: "/bin" - ) - try MiniTest.expect(noBase64.stdoutString.contains("password=\(expired)"), "unavailable base64 returns the password") - try MiniTest.expect(noBase64.stderrString.isEmpty) - }), - ("guestSeedHelperEraseRemovesHostAndKeepsOthers", { - let helper = GuestGitCredentialSeed.helperScript() - let dir = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) - defer { try? FileManager.default.removeItem(at: dir) } - let helperPath = dir.appendingPathComponent("git-credential-adev") - let storePath = dir.appendingPathComponent(".adevcontainer/git-credentials") - try Data(helper.utf8).write(to: helperPath) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helperPath.path) - let home = dir.path - for input in [ - "protocol=https\nhost=dev.azure.com\nusername=one\npassword=p1\n\n", - "protocol=https\nhost=dev.azure.com\nusername=two\npassword=p2\n\n", - "protocol=https\nhost=github.com\nusername=alice\npassword=keep\n\n" - ] { - let stored = try runCredentialHelper( - helperPath: helperPath.path, action: "store", home: home, input: input - ) - try MiniTest.expect(stored.succeeded) - } - let erase = try runCredentialHelper( - helperPath: helperPath.path, action: "erase", home: home, - input: "protocol=https\nhost=dev.azure.com\nusername=nobody\npath=org/proj/_git/repo\n\n" - ) - try MiniTest.expect(erase.succeeded) - try MiniTest.expect(erase.stdoutString.isEmpty) - try MiniTest.expect(erase.stderrString.isEmpty) - let left = try String(contentsOf: storePath, encoding: .utf8) - try MiniTest.expect(!left.contains("dev.azure.com")) - try MiniTest.expect(!left.contains("password=p1")) - try MiniTest.expect(!left.contains("password=p2")) - try MiniTest.expect(left.contains("host=github.com")) - try MiniTest.expect(left.contains("password=keep")) - let mode = try FileManager.default.attributesOfItem(atPath: storePath.path) - try MiniTest.expectEqual((mode[.posixPermissions] as? NSNumber)?.intValue, 0o600) - let noMatch = try runCredentialHelper( - helperPath: helperPath.path, action: "erase", home: home, - input: "protocol=https\nhost=example.com\n\n" - ) - try MiniTest.expect(noMatch.succeeded) - try MiniTest.expectEqual(try String(contentsOf: storePath, encoding: .utf8), left, "no-match erase leaves the store") - }), - ("guestSeedAzureChainDropsEmptyHelperAndKeepsOneProductHelper", { - let fixture = try makeSeedScriptFixture() - defer { try? FileManager.default.removeItem(at: fixture.root) } - let configPath = fixture.home.appendingPathComponent("git-config") - let helper = "\(fixture.home.path)/.adevcontainer/git-credential-adev" - try """ - credential.helper=osxkeychain - credential.https://dev.azure.com.helper= - credential.https://dev.azure.com.helper=/opt/vscode/git-credential - credential.https://dev.azure.com.helper=\(helper) - credential.https://dev.azure.com.helper=\(helper) - core.askpass=keep - """.write(to: configPath, atomically: true, encoding: .utf8) - let script = GuestGitCredentialSeed.seedScript(appendGlobalHelper: false, configureAzureHost: true) - for _ in 0..<2 { - let result = try FoundationProcessRunner().run( - executable: "/bin/sh", - arguments: ["-c", script], - environment: fixture.environment, - currentDirectory: nil, - stdinData: Data("protocol=https\nhost=dev.azure.com\nusername=u\npassword=p\n\n".utf8) - ) - try MiniTest.expect(result.succeeded, "Azure chain cleanup succeeds") - try MiniTest.expect(!result.stdoutString.contains("password=p")) - try MiniTest.expect(!result.stderrString.contains("password=p")) - } - let lines = try String(contentsOf: configPath, encoding: .utf8) - .split(separator: "\n", omittingEmptySubsequences: false) - .map(String.init) - try MiniTest.expect(lines.contains("credential.helper=osxkeychain"), "global helper list is not reset") - try MiniTest.expect(!lines.contains("credential.helper=")) - try MiniTest.expect(!lines.contains("credential.https://dev.azure.com.helper="), "empty helper reset is removed") - try MiniTest.expect(lines.contains("credential.https://dev.azure.com.helper=/opt/vscode/git-credential")) - try MiniTest.expectEqual( - lines.filter { $0 == "credential.https://dev.azure.com.helper=\(helper)" }.count, - 1, - "product helper is present exactly once" - ) - try MiniTest.expect(lines.contains("credential.https://dev.azure.com.useHttpPath=true")) - try MiniTest.expect(lines.contains("core.askpass=keep")) - try MiniTest.expect( - !FileManager.default.fileExists(atPath: fixture.home.appendingPathComponent(".adevcontainer/.azure-helper-values").path), - "helper-list temp file is removed" - ) - }), - ("guestCredentialSourcesBindVsVolume", { - let (bindWorkspace, bindURL) = GuestGitCredentialSeed.credentialSources(from: [ - ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeBind, - ContainerIdentity.labelLocalFolder: "/work/app", - ContainerIdentity.labelGitURL: "https://example.com/ignored.git" - ]) - try MiniTest.expectEqual(bindWorkspace, "/work/app") - try MiniTest.expectEqual(bindURL, nil) - let (volumeWorkspace, volumeURL) = GuestGitCredentialSeed.credentialSources(from: [ - ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeVolume, - ContainerIdentity.labelLocalFolder: "volume://adev-app-ws", - ContainerIdentity.labelGitURL: "https://dev.azure.com/org/proj/_git/repo" - ]) - try MiniTest.expectEqual(volumeWorkspace, nil) - try MiniTest.expectEqual(volumeURL, "https://dev.azure.com/org/proj/_git/repo") - }) -] - nonisolated(unsafe) let compatibilityContractTests: [(String, () throws -> Void)] = [ ("compatibilityIssueCodesAndDeterministicOrder", { var report = CompatibilityReport() diff --git a/Tests/adevcontainerTests/AutomaticGitCredentialsCommandTests.swift b/Tests/adevcontainerTests/AutomaticGitCredentialsCommandTests.swift new file mode 100644 index 0000000..da3fc25 --- /dev/null +++ b/Tests/adevcontainerTests/AutomaticGitCredentialsCommandTests.swift @@ -0,0 +1,135 @@ +import Foundation +@testable import ADevContainerLib + +final class AutomaticCredentialCommandPort: GitCredentialConnecting, @unchecked Sendable { + private let lock = NSLock() + private var recorded: [String] = [] + private var shouldFail = false + struct Request: Equatable { + let id: String + let hostWorkspace: String? + let gitURL: String? + let refresh: Bool + } + private var requests: [Request] = [] + var setups: [Request] { lock.lock(); defer { lock.unlock() }; return requests } + var fails: Bool { get { lock.lock(); defer { lock.unlock() }; return shouldFail } set { lock.lock(); shouldFail = newValue; lock.unlock() } } + private var reportsCredential = true + var acquired: Bool { get { lock.lock(); defer { lock.unlock() }; return reportsCredential } set { lock.lock(); reportsCredential = newValue; lock.unlock() } } + var onEvent: ((String) -> Void)? + func record(_ value: String) { lock.lock(); recorded.append(value); lock.unlock(); onEvent?(value) } + var events: [String] { lock.lock(); defer { lock.unlock() }; return recorded } + func setup(id: String, hostWorkspace: String?, gitURL: String?, refresh: Bool) throws -> Bool { + lock.lock(); requests.append(.init(id: id, hostWorkspace: hostWorkspace, gitURL: gitURL, refresh: refresh)); lock.unlock() + record(refresh ? "refresh" : "setup") + if fails { throw GitCredentialFailure.transport }; return reportsCredential + } + func unregister(id: String) throws { record("unregister"); if fails { throw GitCredentialFailure.transport } } +} +@MainActor let automaticGitCredentialsCommandTests: [(String, () throws -> Void)] = [ + ("credentials.commands.hooksDisableAuthenticationPrompts", { + let workspace = try TestRepo.makeTempWorkspace(configJSON: #"{"image":"alpine:3.20","remoteUser":"root","containerEnv":{"GIT_TERMINAL_PROMPT":"1","GCM_INTERACTIVE":"Always"}}"#) + defer { try? FileManager.default.removeItem(at: workspace) } + let config = try ConfigResolver.resolve(workspacePath: workspace.path, localEnv: [:]).config + let runner = MockProcessRunner() + try LifecycleRunner.runIfPresent(property: "postCreateCommand", command: .shell("git fetch"), containerId: "synthetic", config: config, runtime: .init(executablePath: "/synthetic/container", runner: runner), failurePolicy: .failKeepContainer) + let arguments = runner.calls.first?.arguments ?? [] + for value in ["GIT_TERMINAL_PROMPT=0", "GCM_INTERACTIVE=Never", "GCM_GUI_PROMPT=0", "GIT_ASKPASS=/bin/false", "SSH_ASKPASS=/bin/false"] { + try MiniTest.expect(arguments.contains(value), "hook must disable authentication prompts: \(value)") + } + }), + ("credentials.commands.guestHooksAndCloneDropCredentialTraces", { + let workspace = try TestRepo.makeTempWorkspace(configJSON: #"{"image":"alpine:3.20","remoteUser":"root","containerEnv":{"GIT_TRACE":"1","GIT_TRACE_PACKET":"/tmp/trace","GCM_TRACE":"1","GIT_CURL_VERBOSE":"1","GCM_DEBUG":"1","PATH":"/usr/bin"}}"#) + defer { try? FileManager.default.removeItem(at: workspace) } + let config = try ConfigResolver.resolve(workspacePath: workspace.path, localEnv: [:]).config + let runner = MockProcessRunner() + try LifecycleRunner.runIfPresent(property: "postCreateCommand", command: .shell("git fetch"), containerId: "synthetic", config: config, runtime: .init(executablePath: "/synthetic/container", runner: runner), failurePolicy: .failKeepContainer) + let hookArguments = runner.calls.first?.arguments ?? [] + for forbidden in ["GIT_TRACE=1", "GIT_TRACE_PACKET=/tmp/trace", "GCM_TRACE=1", "GIT_CURL_VERBOSE=1", "GCM_DEBUG=1"] { + try MiniTest.expect(!hookArguments.contains(forbidden), "hook forwarded credential trace: \(forbidden)") + } + try MiniTest.expect(hookArguments.contains("PATH=/usr/bin")) + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let connection = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } + let git = MockGitClient() + git.configJSONToWrite = #"{"image":"alpine:3.20","containerEnv":{"GIT_TRACE":"1","GCM_TRACE":"1","GIT_CURL_VERBOSE":"1","GH_DEBUG":"1"}}"# + let creds = MockGitCredential() + creds.fillResult = .success(nil) + let mock = MockProcessRunner() + mock.handlers = CloneRuntimeMock.handlers() + _ = try CloneCommand.run( + options: CloneOptions(gitURL: "https://github.com/org/public.git", skipPull: true), + runtime: .init(executablePath: "/synthetic/container", runner: mock), + git: git, + credentials: creds, + localEnv: [:] + ) + let cloneCall = mock.calls.first { $0.arguments.contains { $0.contains("git clone --") } } + let cloneScript = cloneCall?.arguments.last ?? "" + try MiniTest.expect(cloneScript.contains("unset GIT_TRACE")) + try MiniTest.expect(cloneScript.contains("GIT_CURL_VERBOSE")) + try MiniTest.expect(cloneScript.contains("GCM_TRACE")) + let cloneArguments = cloneCall?.arguments ?? [] + for forbidden in ["GIT_TRACE=1", "GCM_TRACE=1", "GIT_CURL_VERBOSE=1", "GH_DEBUG=1"] { + try MiniTest.expect(!cloneArguments.contains(forbidden), "clone forwarded credential trace: \(forbidden)") + } + }), + ("credentials.commands.failedCreateHookUnregistersBeforeDelete", { + let port = AutomaticCredentialCommandPort(); GitCredentialLifecycle.setTestConnection(port); defer { GitCredentialLifecycle.setTestConnection(nil) } + let workspace = try TestRepo.makeTempWorkspace(configJSON: #"{"image":"alpine:3.20","remoteUser":"root"}"#) + defer { try? FileManager.default.removeItem(at: workspace) } + let config = try ConfigResolver.resolve(workspacePath: workspace.path, localEnv: [:]).config + let runner = MockProcessRunner() + runner.handlers = [{ args in + if args.first == "delete" { port.record("runtime.delete") } + return .init(exitCode: args.first == "exec" ? 1 : 0, stdout: Data(), stderr: Data()) + }] + try MiniTest.expectThrows({ + try LifecycleRunner.runIfPresent(property: "postCreateCommand", command: .shell("false"), containerId: "synthetic", config: config, runtime: .init(executablePath: "/synthetic/container", runner: runner), failurePolicy: .deleteContainerThenFail) + }, validate: { _ in }) + try MiniTest.expectEqual(port.events, ["unregister", "runtime.delete"]) + }), + ("credentials.commands.runningReuseSetupAndTeardown", { + let port = AutomaticCredentialCommandPort(); GitCredentialLifecycle.setTestConnection(port); defer { GitCredentialLifecycle.setTestConnection(nil) } + let runner = MockProcessRunner() + let object: [String: Any] = ["configuration": ["id": "synthetic", "labels": ["devcontainer.managed": "adevcontainer", "devcontainer.workspace_mode": "volume", "devcontainer.git_url": "https://example.invalid/repo"]], "status": ["state": "running"]] + let inspect = try JSONSerialization.data(withJSONObject: object), list = try JSONSerialization.data(withJSONObject: [object]) + runner.handlers = [{ args in + if args.first == "inspect" { return .init(exitCode: 0, stdout: inspect, stderr: Data()) } + if args.first == "list" { return .init(exitCode: 0, stdout: list, stderr: Data()) } + if ["stop", "delete"].contains(args.first ?? "") { port.record("runtime." + args[0]) } + return .init(exitCode: 0, stdout: Data(), stderr: Data()) + }] + let runtime = AppleContainerRuntime(executablePath: "/synthetic/container", runner: runner) + try StartCommand.run(options: .init(name: "synthetic"), runtime: runtime) + try MiniTest.expectEqual(port.events, ["setup"]) + try MiniTest.expectEqual(port.setups.first?.refresh, false) + try StopCommand.run(name: "synthetic", runtime: runtime) + try DeleteCommand.run(name: "synthetic", runtime: runtime) + try MiniTest.expectEqual(Array(port.events.suffix(4)), ["unregister", "runtime.stop", "unregister", "runtime.delete"]) + }), + ("credentials.commands.purgeUnregistersBeforeDeleteWithoutVolumePolicyChange", { + let port = AutomaticCredentialCommandPort(); GitCredentialLifecycle.setTestConnection(port); defer { GitCredentialLifecycle.setTestConnection(nil) } + let runner = MockProcessRunner() + let labels = [ContainerIdentity.labelManaged: ContainerIdentity.managedValue, ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeVolume, ContainerIdentity.labelWorkspaceVolume: "synthetic-ws", ContainerIdentity.labelConfigVolumes: "synthetic-config"] + let entry = MockProcessRunner.containerListJSON(id: "synthetic", state: "running", labels: labels) + let list = try JSONSerialization.data(withJSONObject: [entry]) + let volumes = try JSONSerialization.data(withJSONObject: [["id": "synthetic-config"], ["id": "synthetic-ws"]]) + var deleted = false + runner.handlers = [{ args in + if args.first == "list" { return .init(exitCode: 0, stdout: deleted ? Data("[]".utf8) : list, stderr: Data()) } + if args.first == "delete" { deleted = true; port.record("runtime.delete") } + if args == ["volume", "list", "--format", "json"] { return .init(exitCode: 0, stdout: volumes, stderr: Data()) } + if args.first == "image" { port.record("runtime.image") } + return .init(exitCode: 0, stdout: Data(), stderr: Data()) + }] + let code = try PurgeCommand.run(name: "synthetic", runtime: .init(executablePath: "/synthetic/container", runner: runner), isInteractive: false) + try MiniTest.expectEqual(code, 0) + try MiniTest.expectEqual(Array(port.events.prefix(2)), ["unregister", "runtime.delete"]) + try MiniTest.expect(runner.calls.contains { $0.arguments.starts(with: ["volume", "delete", "synthetic-ws"]) }) + try MiniTest.expect(!runner.calls.contains { $0.arguments.joined().contains("git-credential-adev") || $0.arguments.joined().contains("credential approve") }) + }) +] diff --git a/Tests/adevcontainerTests/AutomaticHostGitCredentialTests.swift b/Tests/adevcontainerTests/AutomaticHostGitCredentialTests.swift new file mode 100644 index 0000000..9d092b6 --- /dev/null +++ b/Tests/adevcontainerTests/AutomaticHostGitCredentialTests.swift @@ -0,0 +1,64 @@ +import Foundation +@testable import ADevContainerLib + +@MainActor let automaticHostGitCredentialTests: [(String, () throws -> Void)] = [ + ("credentials.provider.schemePortAzureAndPrompts", { + let runner = MockProcessRunner() + runner.stdinHandlers = [{ _, _ in .init(exitCode: 0, stdout: Data("username=host-account\npassword=synthetic\n\n".utf8), stderr: Data()) }] + let provider = HostGitCredential(runner: runner, gitPathOverride: .some("/synthetic/git"), environment: ["GIT_TERMINAL_PROMPT": "1", "GCM_INTERACTIVE": "always"], ghPathOverride: .some(nil)) + _ = try provider.fillHTTPS(url: "http://dev.azure.com:8080/sibling/repository") + guard let call = runner.calls.first else { throw MiniTest.Failure(message: "no provider call") } + try MiniTest.expectEqual(call.arguments, ["credential", "fill"]) + try MiniTest.expectEqual(String(decoding: call.stdinData ?? Data(), as: UTF8.self), "protocol=http\nhost=dev.azure.com:8080\npath=sibling/repository\n\n") + try MiniTest.expectEqual(call.environment?["GIT_TERMINAL_PROMPT"], "0") + try MiniTest.expectEqual(call.environment?["GCM_INTERACTIVE"], "Never") + try MiniTest.expectEqual(call.environment?["GCM_GUI_PROMPT"], "0") + try MiniTest.expect(call.environment?["ADEVCONTAINER_GIT_TOKEN"] == nil) + let before = runner.calls.count + for url in ["https://dev.azure.com", "https://dev.azure.com/org%0Ainjected=x/repo", "https://example.invalid:99999/path", "ssh://example.invalid/path"] { + try MiniTest.expect(try provider.fillHTTPS(url: url) == nil) + } + try MiniTest.expectEqual(runner.calls.count, before) + let fields = GitURLClassifier.httpsCredentialFields(for: "https://EXAMPLE.invalid:8443/org/repo") + try MiniTest.expectEqual(fields?.host, "example.invalid:8443") + }), + ("credentials.provider.precedenceAndMalformedOutput", { + let runner = MockProcessRunner() + let explicit = HostGitCredential(runner: runner, gitPathOverride: .some("/synthetic/git"), environment: ["ADEVCONTAINER_GIT_TOKEN": "synthetic-override"], ghPathOverride: .some(nil)) + try MiniTest.expectEqual(try explicit.fillHTTPS(url: "https://example.invalid/org/repo")?.password, "synthetic-override") + try MiniTest.expect(runner.calls.isEmpty) + runner.stdinHandlers = [{ _, _ in .init(exitCode: 0, stdout: Data("username=host\npassword=one\npassword=conflict\n".utf8), stderr: Data()) }] + let provider = HostGitCredential(runner: runner, gitPathOverride: .some("/synthetic/git"), environment: [:], ghPathOverride: .some(nil)) + try MiniTest.expect(try provider.fillHTTPS(url: "https://example.invalid/org/repo") == nil) + try MiniTest.expect(runner.calls.allSatisfy { $0.arguments == ["credential", "fill"] }) + }), + ("credentials.provider.gitMissAllowsExistingGHFallback", { + let runner = MockProcessRunner() + runner.handlers = [{ arguments in + if arguments == ["credential", "fill"] { return .init(exitCode: 1, stdout: Data(), stderr: Data("synthetic failure".utf8)) } + if arguments == ["auth", "token"] { return .init(exitCode: 0, stdout: Data("synthetic-gh\n".utf8), stderr: Data()) } + return nil + }] + let provider = HostGitCredential(runner: runner, gitPathOverride: .some("/synthetic/git"), environment: [:], ghPathOverride: .some("/synthetic/gh")) + try MiniTest.expectEqual(try provider.fillHTTPS(url: "https://github.com/org/repo")?.password, "synthetic-gh") + try MiniTest.expectEqual(runner.calls.map(\.arguments), [["credential", "fill"], ["auth", "token"]]) + try MiniTest.expect(runner.calls.allSatisfy { $0.environment?["GCM_INTERACTIVE"] == "Never" && $0.environment?["ADEVCONTAINER_GIT_TOKEN"] == nil }) + }), + ("credentials.provider.processBoundsDeadlineAndEnvironment", { + let runner = CredentialProviderProcessRunner(timeout: 0.2) + try MiniTest.expectThrows({ _ = try runner.run(executable: "/bin/sh", arguments: ["-c", "sleep 60 & wait"], environment: ["PATH": "/usr/bin:/bin"], currentDirectory: nil, stdinData: nil) }, validate: { error in try MiniTest.expectEqual(error as? GitCredentialFailure, .deadline) }) + let output = try CredentialProviderProcessRunner().run(executable: "/usr/bin/env", arguments: [], environment: ["SYNTHETIC": "ok", "ADEVCONTAINER_GIT_TOKEN": "must-not-inherit"], currentDirectory: nil, stdinData: nil) + try MiniTest.expectEqual(output.stdoutString, "SYNTHETIC=ok\n") + try MiniTest.expectThrows({ _ = try CredentialProviderProcessRunner().run(executable: "/bin/sh", arguments: ["-c", "dd if=/dev/zero bs=1024 count=128 2>/dev/null"], environment: [:], currentDirectory: nil, stdinData: nil) }, validate: { error in try MiniTest.expectEqual(error as? GitCredentialFailure, .overloaded) }) + let root = try credentialTestRoot() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + defer { try? FileManager.default.removeItem(at: root) } + let fixture = root.appendingPathComponent("synthetic-git") + try Data("#!/bin/sh\n/bin/sleep 60 &\nwait\n".utf8).write(to: fixture) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.path) + let provider = HostGitCredential(runner: CredentialProviderProcessRunner(timeout: 0.1), gitPathOverride: .some(fixture.path), environment: [:], ghPathOverride: .some(nil)) + let before = ProcessInfo.processInfo.systemUptime + try MiniTest.expect(try provider.fillHTTPS(url: "https://example.invalid/org/repo") == nil) + try MiniTest.expect(ProcessInfo.processInfo.systemUptime - before < 2) + }) +] diff --git a/Tests/adevcontainerTests/CloneInVolumeTests.swift b/Tests/adevcontainerTests/CloneInVolumeTests.swift index 351bf17..425d12c 100644 --- a/Tests/adevcontainerTests/CloneInVolumeTests.swift +++ b/Tests/adevcontainerTests/CloneInVolumeTests.swift @@ -1367,6 +1367,8 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ try MiniTest.expect(git.fullCloneCalls.isEmpty) }), ("cloneHappyPathVolumeMountLabelsInContainerCloneJSON", { + let connection = AutomaticCredentialCommandPort(); GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let restore = CloneGitFeatureTestSupport.installOverrides() defer { restore() } let git = MockGitClient() @@ -1486,8 +1488,9 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ try MiniTest.expect(mock.calls.contains { $0.arguments.first == "exec" && $0.arguments.contains("test") && $0.arguments.contains("-e") }) - // HTTPS credentials filled once - try MiniTest.expectEqual(creds.fillCalls.count, 1) + // Full clone uses the shared connection, not a one-shot token seed. + try MiniTest.expectEqual(creds.fillCalls.count, 0) + try MiniTest.expectEqual(connection.events, ["setup"]) // Hooks: postCreate exec (sh -lc) try MiniTest.expect(mock.calls.contains { $0.arguments.first == "exec" && $0.arguments.contains("-lc") @@ -1599,6 +1602,8 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ ) }), ("cloneSSHInjectsCreateSSHFlag", { + let connection = AutomaticCredentialCommandPort(); GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let restore = CloneGitFeatureTestSupport.installOverrides() defer { restore() } let git = MockGitClient() @@ -1621,11 +1626,14 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ $0.arguments.last?.contains("git-credential-adev") == true }, "SSH clone must not seed HTTPS credentials") try MiniTest.expect(git.fullCloneCalls.isEmpty) + try MiniTest.expect(connection.events.isEmpty) try MiniTest.expect(mock.calls.contains { $0.arguments.first == "exec" && $0.arguments.contains("-c") }) }), ("cloneHTTPSPublicWorksWithoutHostCredentials", { + let connection = AutomaticCredentialCommandPort(); GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let restore = CloneGitFeatureTestSupport.installOverrides() defer { restore() } let git = MockGitClient() @@ -1643,13 +1651,16 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ localEnv: [:] ) try MiniTest.expectEqual(result.outcome, "success") - try MiniTest.expectEqual(creds.fillCalls.count, 1) + try MiniTest.expectEqual(creds.fillCalls.count, 0) + try MiniTest.expectEqual(connection.events, ["setup"]) try MiniTest.expect(!mock.calls.contains { $0.arguments.last?.contains("git-credential-adev") == true }, "nil fill must not seed") try MiniTest.expect(git.fullCloneCalls.isEmpty) }), ("cloneHTTPSCloneFailWithoutCredsHintsCredentials", { + let connection = AutomaticCredentialCommandPort(); GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let restore = CloneGitFeatureTestSupport.installOverrides() defer { restore() } let git = MockGitClient() @@ -1658,6 +1669,10 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ creds.fillResult = .success(nil) let mock = MockProcessRunner() mock.handlers = CloneRuntimeMock.handlers(cloneExecSucceeds: false) + mock.handlers.insert({ args in + if args.first == "delete" { connection.record("runtime.delete") } + return nil + }, at: 0) let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) try MiniTest.expectThrows({ _ = try CloneCommand.run( @@ -1677,6 +1692,7 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ } try MiniTest.expect(mock.calls.contains { $0.arguments.first == "delete" }) try MiniTest.expect(mock.calls.contains { $0.arguments.starts(with: ["volume", "delete"]) }) + try MiniTest.expectEqual(connection.events, ["setup", "unregister", "runtime.delete"]) try MiniTest.expect(git.fullCloneCalls.isEmpty) }), ("cloneRootConfigFallback", { @@ -2234,6 +2250,9 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ }) }), ("cloneGlobalAuthorFailureWarnsAndRunsHook", { + let connection = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let restore = CloneGitFeatureTestSupport.installOverrides() defer { restore() } let git = MockGitClient() @@ -2271,6 +2290,9 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ try MiniTest.expect(mock.calls.contains { $0.arguments.last?.contains("author-hook") == true }) }), ("cloneLocalAuthorFailureSkipsGlobalAndRunsHook", { + let connection = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let restore = CloneGitFeatureTestSupport.installOverrides() defer { restore() } let git = MockGitClient() @@ -2763,14 +2785,33 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "create" }) try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) }), - ("cloneHTTPSNonAzureKeepsLocalStore", { + ("cloneLegacyHTTPPreservesDiscoveredScheme", { + let restore = CloneGitFeatureTestSupport.installOverrides(); defer { restore() } + let connection = AutomaticCredentialCommandPort(); GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } + let git = MockGitClient(); git.configJSONToWrite = #"{"image":"alpine:3.20"}"# + let mock = MockProcessRunner(); mock.handlers = CloneRuntimeMock.handlers() + let credentials = MockGitCredential() + let url = "http://dev.azure.com/org/project/_git/repo" + _ = try CloneCommand.run(options: .init(gitURL: url, skipPull: true), runtime: .init(executablePath: "/synthetic/container", runner: mock), git: git, credentials: credentials, localEnv: [:], identityPrompt: quietIdentityPrompt(), isTTY: false) + try MiniTest.expectEqual(connection.setups.first?.gitURL, url) + try MiniTest.expectEqual(connection.events, ["setup"]) + try MiniTest.expect(credentials.fillCalls.isEmpty) + try MiniTest.expect(!mock.calls.contains { $0.arguments.joined().contains("ADEV_CLONE_PASS") || $0.arguments.joined().contains("credential approve") }) + try MiniTest.expectEqual(gitCloneCalls(mock).count, 1) + }), + ("cloneHTTPSNonAzureUsesAutomaticConnectionBeforePopulate", { let restore = CloneGitFeatureTestSupport.installOverrides() defer { restore() } let git = MockGitClient() git.configJSONToWrite = #"{"name":"CloneApp","image":"alpine:3.20"}"# let creds = MockGitCredential() + let connection = AutomaticCredentialCommandPort(); GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let mock = MockProcessRunner() - mock.handlers = CloneRuntimeMock.handlers() + mock.handlers = [{ args in + if args.first == "exec", args.last?.contains("git clone") == true { connection.record("populate") }; return nil + }] + CloneRuntimeMock.handlers() let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) let result = try CloneCommand.run( options: CloneOptions(gitURL: "https://github.com/org/clone-app.git", skipPull: true), @@ -2780,10 +2821,11 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ localEnv: [:] ) try MiniTest.expectEqual(result.outcome, "success") - try MiniTest.expectEqual(creds.fillCalls.count, 1, "clone does not fill twice") + try MiniTest.expectEqual(creds.fillCalls.count, 0, "on-demand connection does not pre-acquire credentials") + try MiniTest.expectEqual(Array(connection.events.prefix(2)), ["setup", "populate"]) try MiniTest.expect(!mock.calls.contains { $0.arguments.last?.contains("git-credential-adev") == true - }, "non-Azure clone must not install the product helper") + }, "the injected connection owns helper provisioning") try MiniTest.expect(!mock.calls.contains { $0.arguments.last?.contains("useHttpPath") == true }) @@ -2795,8 +2837,9 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ } try MiniTest.expectEqual(cloneScripts.count, 1) let script = cloneScripts[0].arguments.last ?? "" - try MiniTest.expect(script.contains("git -C \"$WS\" config credential.helper store")) - try MiniTest.expect(script.contains("git -C \"$WS\" credential approve")) + try MiniTest.expect(!script.contains("credential.helper store")) + try MiniTest.expect(!script.contains("credential approve")) + try MiniTest.expect(!mock.calls.contains { $0.arguments.joined().contains("ADEV_CLONE_PASS") || $0.arguments.joined().contains("ADEV_CLONE_USER") }) try MiniTest.expect(!script.contains("secret-token"), "password stays off the exec command") }), ("cloneHTTPSAzureUsesURLScopedHelper", { @@ -2805,8 +2848,12 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ let git = MockGitClient() git.configJSONToWrite = #"{"name":"CloneApp","image":"alpine:3.20"}"# let creds = MockGitCredential() + let connection = AutomaticCredentialCommandPort(); GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let mock = MockProcessRunner() - mock.handlers = CloneRuntimeMock.handlers() + mock.handlers = [{ args in + if args.first == "exec", args.last?.contains("git clone") == true { connection.record("populate") }; return nil + }] + CloneRuntimeMock.handlers() let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) let result = try CloneCommand.run( options: CloneOptions( @@ -2819,23 +2866,12 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ localEnv: [:] ) try MiniTest.expectEqual(result.outcome, "success") - try MiniTest.expectEqual(creds.fillCalls.count, 1, "Azure clone does not fill twice") + try MiniTest.expectEqual(creds.fillCalls.count, 0) + try MiniTest.expectEqual(Array(connection.events.prefix(2)), ["setup", "populate"]) let seeds = mock.calls.filter { $0.arguments.first == "exec" && $0.arguments.last?.contains("git-credential-adev") == true } - try MiniTest.expectEqual(seeds.count, 1, "Azure clone seeds the URL-scoped helper once") - let script = seeds[0].arguments.last ?? "" - try MiniTest.expect(script.contains("credential.https://dev.azure.com.helper")) - try MiniTest.expect(script.contains("credential.https://dev.azure.com.useHttpPath true")) - try MiniTest.expect(!script.contains("credential.https://dev.azure.com.helper \"\""), "Azure clone must not install an empty helper reset") - try MiniTest.expect(!script.contains("git config --global --replace-all credential.helper")) - try MiniTest.expect(!script.contains("git config --global --add credential.helper")) - try MiniTest.expect(!script.contains("credential.helper store")) - try MiniTest.expect(!script.contains("secret-token")) - try MiniTest.expectEqual( - String(data: seeds[0].stdinData ?? Data(), encoding: .utf8) ?? "", - "protocol=https\nhost=dev.azure.com\nusername=user\npassword=secret-token\n\n" - ) + try MiniTest.expectEqual(seeds.count, 0, "the shared connection, not clone, owns helper setup") let cloneScripts = mock.calls.filter { $0.arguments.first == "exec" && $0.arguments.last?.contains("git clone") == true } @@ -3700,6 +3736,8 @@ nonisolated(unsafe) let managedLifecycleTests: [(String, () throws -> Void)] = [ try MiniTest.expect(deleteIdx < createIdx) }), ("cloneReuseVolumePromptYesKeepsTree", { + let connection = AutomaticCredentialCommandPort(); GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let restore = CloneGitFeatureTestSupport.installOverrides() defer { restore() } let gitURL = "https://github.com/org/reuse-yes.git" @@ -3710,9 +3748,13 @@ nonisolated(unsafe) let managedLifecycleTests: [(String, () throws -> Void)] = [ ) let wsVol = identity.workspaceVolumeName let (runtime, mock) = volumeDecisionRuntime(volumeName: wsVol, present: true, entriesExit: 0) + mock.handlers.insert({ args in + if args.first == "exec", args.last?.contains("echo reuse-credential-hook") == true { connection.record("hook") } + return nil + }, at: 0) let prompt = VolumePromptCapture(answers: ["Y"]) let git = MockGitClient() - git.configJSONToWrite = #"{ "image": "alpine:3.20" }"# + git.configJSONToWrite = #"{ "image": "alpine:3.20", "postCreateCommand": "echo reuse-credential-hook" }"# let result = try CloneCommand.run( options: CloneOptions(gitURL: gitURL, skipPull: true), runtime: runtime, @@ -3730,6 +3772,8 @@ nonisolated(unsafe) let managedLifecycleTests: [(String, () throws -> Void)] = [ ) try MiniTest.expect(volumeDeleteCalls(mock, name: wsVol).isEmpty, "reuse must not delete the volume") try MiniTest.expect(gitCloneCalls(mock).isEmpty, "reuse must not git-clone over an existing tree") + try MiniTest.expectEqual(connection.events, ["setup", "hook"]) + try MiniTest.expectEqual(connection.setups.first?.gitURL, gitURL) try MiniTest.expect(mock.calls.contains { $0.arguments.contains("user.name") && $0.arguments.contains("Test User") }, "reuse still applies author identity when .git exists") diff --git a/Tests/adevcontainerTests/CredentialDuplexProcessTests.swift b/Tests/adevcontainerTests/CredentialDuplexProcessTests.swift new file mode 100644 index 0000000..180c9fd --- /dev/null +++ b/Tests/adevcontainerTests/CredentialDuplexProcessTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Darwin +@testable import ADevContainerLib + +@MainActor let credentialDuplexProcessTests: [(String, () throws -> Void)] = [ + ("credentials.process.duplexAndStderr", { + let process = try CredentialDuplexProcess(executable: "/bin/sh", arguments: ["-c", "dd if=/dev/zero bs=1024 count=128 >&2 2>/dev/null; cat"], environment: ["PATH": "/usr/bin:/bin"]) + defer { process.cancel() } + let input = Data(repeating: 65, count: 32000) + try process.send(input); process.closeInput() + var output = Data() + while let chunk = try process.read(deadline: ProcessInfo.processInfo.systemUptime + 3) { output.append(chunk) } + try MiniTest.expectEqual(output, input) + try MiniTest.expectEqual(process.cancel(), 0) + let inverted = try CredentialDuplexProcess(executable: "/bin/sh", arguments: ["-c", "IFS= read -r a; IFS= read -r b; printf '%s\\n%s\\n' \"$b\" \"$a\""], environment: [:]) + defer { inverted.cancel() } + try inverted.send(Data("one\ntwo\n".utf8)); inverted.closeInput() + var replies = Data() + while let chunk = try inverted.read(deadline: ProcessInfo.processInfo.systemUptime + 3) { replies.append(chunk) } + try MiniTest.expectEqual(String(decoding: replies, as: UTF8.self), "two\none\n") + }), + ("credentials.process.hungDescendantCancellation", { + let process = try CredentialDuplexProcess(executable: "/bin/sh", arguments: ["-c", "sleep 60 & printf '%s\\n' $!; wait"], environment: ["PATH": "/usr/bin:/bin"]) + defer { process.cancel() } + guard let data = try process.read(deadline: ProcessInfo.processInfo.systemUptime + 2), let pid = Int32(String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)) else { throw MiniTest.Failure(message: "synthetic child did not report PID") } + try MiniTest.expectThrows({ _ = try process.read(deadline: ProcessInfo.processInfo.systemUptime + 0.1) }, validate: { error in try MiniTest.expectEqual(error as? GitCredentialFailure, .deadline) }) + process.cancel() + // A child may briefly be a reparented zombie, but must not remain a running sleeper. + let check = try FoundationProcessRunner().run(executable: "/bin/ps", arguments: ["-p", "\(pid)", "-o", "stat="], environment: [:], currentDirectory: nil) + try MiniTest.expect(!check.succeeded || check.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("Z") || check.stdoutString.isEmpty) + }), + ("credentials.process.cancelHonorsDeadline", { + let process = try CredentialDuplexProcess(executable: "/bin/sleep", arguments: ["30"], environment: ["PATH": "/usr/bin:/bin"]) + let started = ProcessInfo.processInfo.systemUptime + // Deliberately expired: the group is still signalled, but the caller + // must never block waiting for an exit it may not observe. + let code = process.cancel(deadline: started - 1) + let elapsed = ProcessInfo.processInfo.systemUptime - started + try MiniTest.expectEqual(code, 137) + try MiniTest.expect(elapsed < 1, "a bounded cancel blocked for \(elapsed)s") + // A later call may retry the non-blocking reap without changing the outcome. + try MiniTest.expectEqual(process.cancel(), 137) + }), + ("credentials.process.spawnClearsBlockedSignalMask", { + // Production gets acquire on a Dispatch worker, which blocks signals. + // GCM inherits that mask and never returns. The child must start unblocked. + let ready = DispatchSemaphore(value: 0) + var reported = "" + var caught: Error? + DispatchQueue.global(qos: .userInitiated).async { + var blocked = sigset_t(), previous = sigset_t() + sigfillset(&blocked) + sigprocmask(SIG_SETMASK, &blocked, &previous) + defer { sigprocmask(SIG_SETMASK, &previous, nil) } + do { + let process = try CredentialDuplexProcess(executable: "/usr/bin/python3", arguments: ["-c", """ +import ctypes +libc = ctypes.CDLL("/usr/lib/libSystem.B.dylib") +current = ctypes.c_uint32() +libc.sigprocmask(0, None, ctypes.byref(current)) +print(current.value) +"""], environment: ["PATH": "/usr/bin:/bin"], guarded: true) + defer { process.cancel() } + process.closeInput() + var output = Data() + while let chunk = try process.read(deadline: ProcessInfo.processInfo.systemUptime + 3) { output.append(chunk) } + reported = String(decoding: output, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + } catch { caught = error } + ready.signal() + } + try MiniTest.expect(ready.wait(timeout: .now() + 5) == .success) + if let caught { throw caught } + try MiniTest.expectEqual(reported, "0") + }), + ("credentials.process.boundsAndSafeFailure", { + try MiniTest.expectThrows({ _ = try CredentialDuplexProcess(executable: "/does-not-exist-synthetic", arguments: [], environment: [:]) }, validate: { error in + try MiniTest.expectEqual(error as? GitCredentialFailure, .transport) + try MiniTest.expect(!String(describing: error).contains("synthetic")) + }) + let process = try CredentialDuplexProcess(executable: "/bin/cat", arguments: [], environment: [:]); defer { process.cancel() } + try MiniTest.expectThrows({ try process.send(Data(repeating: 0, count: 1024 * 1024 + 1)) }, validate: { _ in }) + }) +] diff --git a/Tests/adevcontainerTests/CredentialProviderCrashTests.swift b/Tests/adevcontainerTests/CredentialProviderCrashTests.swift new file mode 100644 index 0000000..9a5f4fc --- /dev/null +++ b/Tests/adevcontainerTests/CredentialProviderCrashTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Darwin +@testable import ADevContainerLib + +enum CredentialProviderCrashProbe { + static func run() -> Int32 { + guard let file = ProcessInfo.processInfo.environment["ADEVCONTAINER_PROVIDER_PID_FILE"] else { return 2 } + do { + _ = try CredentialProviderProcessRunner(timeout: 60).run(executable: "/bin/sh", arguments: ["-c", "sleep 60 & printf '%s %s\\n' $$ $! > \"$1\"; wait", "synthetic-provider", file], environment: ["PATH": "/usr/bin:/bin"], currentDirectory: nil, stdinData: nil) + return 0 + } catch { return 1 } + } +} + +@MainActor let credentialProviderCrashTests: [(String, () throws -> Void)] = [ + ("credentials.provider.brokerCrashKillsOwnedProviderGroup", { + let root = try credentialTestRoot() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + defer { try? FileManager.default.removeItem(at: root) } + let file = root.appendingPathComponent("provider-pids") + let parent = Process(); parent.executableURL = Bundle.main.executableURL + parent.environment = ["ADEVCONTAINER_PROVIDER_CRASH_PROBE": "1", "ADEVCONTAINER_PROVIDER_PID_FILE": file.path, "PATH": "/usr/bin:/bin"] + parent.standardInput = FileHandle.nullDevice; parent.standardOutput = FileHandle.nullDevice; parent.standardError = FileHandle.nullDevice + try parent.run() + defer { if parent.isRunning { kill(parent.processIdentifier, SIGKILL); parent.waitUntilExit() } } + let end = Date().addingTimeInterval(3) + while !FileManager.default.fileExists(atPath: file.path) && Date() < end { Thread.sleep(forTimeInterval: 0.01) } + guard let text = try? String(contentsOf: file, encoding: .utf8) else { throw MiniTest.Failure(message: "synthetic provider did not start") } + let pids = text.split(whereSeparator: \.isWhitespace).compactMap { Int32($0) } + try MiniTest.expectEqual(pids.count, 2) + let group = getpgid(pids[0]); try MiniTest.expect(group > 1 && group != getpgrp()) + defer { if getpgid(pids[0]) == group { kill(-group, SIGKILL) } } + kill(parent.processIdentifier, SIGKILL); parent.waitUntilExit() + func running() throws -> Bool { + let result = try FoundationProcessRunner().run(executable: "/bin/ps", arguments: ["-p", pids.map(String.init).joined(separator: ","), "-o", "stat="], environment: [:], currentDirectory: nil) + return result.stdoutString.split(whereSeparator: \.isNewline).contains { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("Z") } + } + let cleanupDeadline = Date().addingTimeInterval(2) + while try running(), Date() < cleanupDeadline { Thread.sleep(forTimeInterval: 0.025) } + try MiniTest.expect(try !running(), "provider/descendant survived synthetic broker SIGKILL") + }) +] diff --git a/Tests/adevcontainerTests/GitCredentialBrokerDetachedTests.swift b/Tests/adevcontainerTests/GitCredentialBrokerDetachedTests.swift new file mode 100644 index 0000000..5ef6f31 --- /dev/null +++ b/Tests/adevcontainerTests/GitCredentialBrokerDetachedTests.swift @@ -0,0 +1,442 @@ +import Foundation +import Darwin +@testable import ADevContainerLib + +/// Test-owned detached broker. Runs the production service loop and control +/// socket with synthetic runtime/provider ports, from a private copy of this +/// executable, under the approved temporary root. No Apple runtime, provider, +/// network, keychain, launchd domain, or production path is reachable here. +enum GitCredentialDetachedProbe { + private static let lock = NSLock() + static func context(_ id: String) -> GitCredentialRuntimeContext { + .init(id: id, incarnation: "synthetic:1", platform: "linux/arm64", user: "root", labels: ["devcontainer.managed": "adevcontainer"]) + } + static func observation(_ id: String, root: String) -> GitCredentialObservation { + guard let bytes = try? Data(contentsOf: URL(fileURLWithPath: root + "/observations.json")), + let states = try? JSONDecoder().decode([String: String].self, from: bytes) else { return .unknown } + switch states[id] { + case "running": return .running(context(id)) + case "stopped": return .inactive + default: return .unknown + } + } + static func integer(_ name: String, root: String) -> Int { + Int(String(decoding: (try? Data(contentsOf: URL(fileURLWithPath: root + "/" + name))) ?? Data(), as: UTF8.self)) ?? 0 + } + private static func increment(_ name: String, root: String) { + lock.lock(); defer { lock.unlock() } + let next = integer(name, root: root) + 1 + try? Data("\(next)".utf8).write(to: URL(fileURLWithPath: root + "/" + name)) + } + static func decode(_ path: String) throws -> GitCredentialLaunchContext { + guard let directory = try? GitCredentialPrivateFiles.directory((path as NSString).deletingLastPathComponent, create: false) else { throw GitCredentialFailure.invalidContext } + defer { close(directory) } + guard (path as NSString).lastPathComponent == "launch-context.json", + let data = try GitCredentialPrivateFiles.read("launch-context.json", directory: directory) else { throw GitCredentialFailure.invalidContext } + let context = try JSONDecoder().decode(GitCredentialLaunchContext.self, from: data) + guard !context.isProduction else { throw GitCredentialFailure.invalidContext } + try context.validate() + return context + } + /// CLI stand-in: one activation transaction, then exit like the invoking CLI. + static func activate(_ path: String, id: String?) -> Int32 { + do { + let context = try decode(path) + let service = GitCredentialBrokerService(context: context) + try service.ensure() + if let id, case .running(let identity) = GitCredentialDetachedProbe.observation(id, root: context.endpoints) { + let registration = GitCredentialRegistration(context: identity, destinations: [try .init(url: "https://example.invalid/repo")], + directory: "/synthetic/credential", overrideRequired: false) + _ = try service.request(.init(operation: .register, registration: registration)) + } + return 0 + } catch { return 1 } + } + static func run(_ path: String) -> Int32 { + do { + let context = try decode(path) + let root = context.endpoints + let output = try GitCredentialPrivateFiles.directory(root, create: false); defer { close(output) } + try GitCredentialPrivateFiles.write(Data("\(getpid())".utf8), name: "pid", directory: output) + increment("starts", root: root) + let stop = GitCredentialDetachedStop() + let term = signal(SIGTERM, SIG_IGN), interrupt = signal(SIGINT, SIG_IGN), hangup = signal(SIGHUP, SIG_IGN) + let sources = [SIGTERM, SIGINT].map { number -> DispatchSourceSignal in + let source = DispatchSource.makeSignalSource(signal: number, queue: .global()) + source.setEventHandler { stop.stop() }; source.resume(); return source + } + defer { for source in sources { source.cancel() }; signal(SIGTERM, term); signal(SIGINT, interrupt); signal(SIGHUP, hangup) } + let ports = GitCredentialBrokerPorts(context: { id in + guard case .running(let context) = observation(id, root: root) else { throw GitCredentialFailure.invalidContext } + return context + }, connect: { _, generation, receive, _ in + increment("connects", root: root) + let channel = CredentialTestChannel() + // A consumer is simulated only when the fixture asks for one, so + // the crash/reuse tests can still prove zero provider lookups. + DispatchQueue.global(qos: .utility).async { + let trigger = root + "/lookup", end = ProcessInfo.processInfo.systemUptime + 5 + while !FileManager.default.fileExists(atPath: trigger), ProcessInfo.processInfo.systemUptime < end { + Thread.sleep(forTimeInterval: 0.025) + } + guard FileManager.default.fileExists(atPath: trigger) else { return } + receive(.init(operation: .get, request: 1, generation: generation, + fields: [.scheme: "https", .host: "example.invalid", .path: "repo"])) + guard let reply = try? channel.take() else { return } + increment("lookups", root: root) + try? Data((reply.fields[.password] ?? "").utf8).write(to: URL(fileURLWithPath: root + "/lookup-reply")) + } + return channel + }, acquire: { _, _, _ in + increment("acquires", root: root) + guard FileManager.default.fileExists(atPath: root + "/lookup") else { return nil } + return .init(username: "host-account", password: "synthetic-upgrade") + }, observe: { id, _ in observation(id, root: root) }) + try GitCredentialBrokerService(context: context).run(ports: ports, shouldStop: { stop.shouldStop() }) + return 0 + } catch { return 1 } + } +} + +private final class GitCredentialDetachedStop: @unchecked Sendable { + private let lock = NSLock() + private var stopped = false + func stop() { lock.lock(); stopped = true; lock.unlock() } + func shouldStop() -> Bool { lock.lock(); defer { lock.unlock() }; return stopped } +} + +/// One isolated fixture: private root, test-owned executable copies, private +/// synthetic context. Cleanup terminates only pids this fixture recorded. +final class GitCredentialDetachedHarness: @unchecked Sendable { + let root: URL + let context: GitCredentialLaunchContext + private let runner = FoundationProcessRunner() + private let lock = NSLock() + private var owned: Set = [] + init(root: URL, executable: String) { + self.root = root + self.context = .init(label: "com.adevcontainer.git-credentials.test.\(UUID().uuidString)", state: root.path + "/state", + endpoints: root.path, executable: executable, + providerEnvironment: ["PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "HOME": root.path + "/state", + "GIT_CONFIG_GLOBAL": root.path + "/state/synthetic.gitconfig", "GIT_CONFIG_SYSTEM": "/dev/null", "GIT_CONFIG_NOSYSTEM": "1"]) + } + func prepare() throws { + try FileManager.default.createDirectory(atPath: context.state, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + let state = try GitCredentialPrivateFiles.directory(context.state, create: false); defer { close(state) } + try GitCredentialPrivateFiles.write(JSONEncoder().encode(context), name: "launch-context.json", directory: state) + announce() + } + /// Secret-free recovery manifest. A parent SIGKILL cannot run cleanup, so + /// the root/pid lines already written to stderr identify only this fixture. + private func announce() { + let line = "detached-broker fixture root=\(context.endpoints) state=\(context.state) pid=\(pid ?? 0)\n" + FileHandle.standardError.write(Data(line.utf8)) + } + var contextPath: String { context.state + "/launch-context.json" } + func states(_ values: [String: String]) throws { + try Data(JSONEncoder().encode(values)).write(to: URL(fileURLWithPath: context.endpoints + "/observations.json")) + } + func integer(_ name: String) -> Int { GitCredentialDetachedProbe.integer(name, root: context.endpoints) } + var pid: Int32? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: context.endpoints + "/pid")) else { return nil } + return Int32(String(decoding: data, as: UTF8.self)) + } + func socketExists() -> Bool { FileManager.default.fileExists(atPath: context.socket) } + func registrations() throws -> [GitCredentialRegistration] { try GitCredentialBrokerRegistry(path: context.state).load() } + @discardableResult func activate(_ id: String? = nil) throws -> Int32 { + let result = try runner.run(executable: context.executable, arguments: ["__adevcontainer_git_activate", contextPath, id ?? "-"], + environment: ["PATH": "/usr/bin:/bin:/usr/sbin:/sbin"], currentDirectory: nil) + guard result.succeeded else { throw MiniTest.Failure(message: "isolated activation \(id ?? "-") failed: \(result.stderrString)") } + if let pid { lock.lock(); owned.insert(pid); lock.unlock() } + announce() + return result.exitCode + } + func activateConcurrently(_ ids: [String]) throws { + final class Flag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + func raise() { lock.lock(); value = true; lock.unlock() } + var raised: Bool { lock.lock(); defer { lock.unlock() }; return value } + } + let group = DispatchGroup(), failed = Flag() + for id in ids { + group.enter() + DispatchQueue.global().async { [self] in + defer { group.leave() } + do { try activate(id) } catch { failed.raise() } + } + } + guard group.wait(timeout: .now() + 30) == .success, !failed.raised else { throw MiniTest.Failure(message: "concurrent isolated activation failed") } + } + func probe() throws -> GitCredentialBrokerStatus { + try GitCredentialControlClient().probe(context, deadline: ProcessInfo.processInfo.systemUptime + 3) + } + func isOwned(_ expected: ExecutableIdentity) throws -> Bool { + if case .owner(let running) = try probe() { return running.matches(expected) } + return false + } + func expectConnection() throws { try wait(3, "no synthetic channel was connected") { integer("connects") >= 1 } } + func alive(_ pid: Int32) -> Bool { kill(pid, 0) == 0 } + func wait(_ seconds: TimeInterval, _ message: String, until body: () throws -> Bool) throws { + let end = ProcessInfo.processInfo.systemUptime + seconds + while try !body() { + guard ProcessInfo.processInfo.systemUptime < end else { throw MiniTest.Failure(message: message) } + Thread.sleep(forTimeInterval: 0.025) + } + } + func pause(_ seconds: TimeInterval) throws { + let end = ProcessInfo.processInfo.systemUptime + seconds + try wait(seconds + 1, "timed observation interrupted") { ProcessInfo.processInfo.systemUptime >= end } + } + func waitExit(_ pid: Int32, _ seconds: TimeInterval) throws { + try wait(seconds, "test-owned broker \(pid) did not exit") { !alive(pid) } + } + /// Terminates only pids recorded from this fixture's own private root. + func cleanup() { + lock.lock(); var pids = owned; if let current = pid { pids.insert(current) }; lock.unlock() + for pid in pids where alive(pid) { + _ = kill(pid, SIGTERM) + let end = ProcessInfo.processInfo.systemUptime + 3 + while alive(pid), ProcessInfo.processInfo.systemUptime < end { Thread.sleep(forTimeInterval: 0.025) } + if alive(pid) { _ = kill(pid, SIGKILL) } + } + try? FileManager.default.removeItem(at: root) + } +} + +private func detachedExecutableCopy(_ root: URL, _ name: String) throws -> String { + let source = try ExecutableIdentity.currentExecutablePath() + let target = root.appendingPathComponent(name).path + try FileManager.default.copyItem(atPath: source, toPath: target) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: target) + return target +} + +@MainActor let gitCredentialBrokerDetachedTests: [(String, () throws -> Void)] = ProcessInfo.processInfo.environment["ADEVCONTAINER_DETACHED_BROKER_TEST"] == "1" ? [ + ("credentials.detached.startBeforeConsumerAndSurviveCLI", { + let root = try credentialShortRoot() + let harness = GitCredentialDetachedHarness(root: root, executable: try detachedExecutableCopy(root, "probe-a")) + defer { harness.cleanup() } + try harness.prepare(); try harness.states(["first": "running"]) + let started = ProcessInfo.processInfo.systemUptime + try harness.activate("first") + guard let pid = harness.pid else { throw MiniTest.Failure(message: "detached broker did not record its pid") } + try MiniTest.expect(pid != getpid() && pid > 1, "broker must be a different process from the CLI") + try MiniTest.expect(harness.alive(pid), "broker must survive the invoking CLI") + try harness.wait(3, "detached broker never answered hello after CLI exit") { try harness.isOwned(try credentialIdentity(harness.context.executable)) } + print("detached start+surviveCLI: \(String(format: "%.3f", ProcessInfo.processInfo.systemUptime - started))s") + try MiniTest.expectEqual(try harness.registrations().map(\.context.id), ["first"]) + // The same process serves a consumer that arrives after the CLI is gone. + try harness.activate("first") + try MiniTest.expectEqual(harness.pid, pid) + try MiniTest.expectEqual(harness.integer("starts"), 1) + try MiniTest.expectEqual(harness.integer("connects"), 1) + }), + ("credentials.detached.twoCLIsConverge", { + let root = try credentialShortRoot() + let harness = GitCredentialDetachedHarness(root: root, executable: try detachedExecutableCopy(root, "probe-a")) + defer { harness.cleanup() } + try harness.prepare(); try harness.states(["first": "running", "second": "running"]) + try harness.activateConcurrently(["first", "second"]) + guard let pid = harness.pid else { throw MiniTest.Failure(message: "no owner after concurrent activation") } + try MiniTest.expectEqual(harness.integer("starts"), 1, "concurrent CLIs must start exactly one detached broker") + try MiniTest.expectEqual(Set(try harness.registrations().map(\.context.id)), ["first", "second"]) + try MiniTest.expect(try harness.isOwned(try credentialIdentity(harness.context.executable)), "the expected executable is not the owner") + // A later activation reuses the same owner without spawning another. + try harness.activate("first") + try MiniTest.expectEqual(harness.pid, pid); try MiniTest.expectEqual(harness.integer("starts"), 1) + }), + ("credentials.detached.idleExitAndSuccessor", { + let root = try credentialShortRoot() + let harness = GitCredentialDetachedHarness(root: root, executable: try detachedExecutableCopy(root, "probe-a")) + defer { harness.cleanup() } + try harness.prepare(); try harness.states(["first": "running"]) + try harness.activate("first") + guard let first = harness.pid else { throw MiniTest.Failure(message: "no owner") } + let service = GitCredentialBrokerService(context: harness.context) + _ = try service.request(.init(operation: .unregister, id: "first"), activate: false) + let started = ProcessInfo.processInfo.systemUptime + try harness.waitExit(first, 7) + let elapsed = ProcessInfo.processInfo.systemUptime - started + try MiniTest.expect(elapsed >= 4.5 && elapsed <= 7, "idle exit took \(elapsed)s") + print("detached idle exit: \(String(format: "%.3f", elapsed))s") + try harness.wait(2, "idle exit left a stale control socket") { !harness.socketExists() } + try MiniTest.expect(FileManager.default.fileExists(atPath: harness.context.endpoints + "/broker.lock"), "lock inodes are never unlinked") + try MiniTest.expect(try harness.probe() == .absent, "no automatic replacement may start") + try harness.activate("first") + guard let second = harness.pid else { throw MiniTest.Failure(message: "later demand did not start a successor") } + try MiniTest.expect(second != first); try MiniTest.expectEqual(harness.integer("starts"), 2) + try harness.wait(3, "successor never answered hello") { try harness.isOwned(try credentialIdentity(harness.context.executable)) } + }), + ("credentials.detached.crashIsNotAutoRestarted", { + let root = try credentialShortRoot() + let harness = GitCredentialDetachedHarness(root: root, executable: try detachedExecutableCopy(root, "probe-a")) + defer { harness.cleanup() } + try harness.prepare(); try harness.states(["first": "running"]) + try harness.activate("first") + guard let crashed = harness.pid else { throw MiniTest.Failure(message: "no owner") } + try MiniTest.expectEqual(harness.integer("acquires"), 0, "idle broker must not poll a provider") + // An explicit override lives only in the broker's memory. + try harness.states(["first": "running", "second": "running"]) + let override = "synthetic-override-secret" + let overridden = GitCredentialRegistration(context: GitCredentialDetachedProbe.context("second"), + destinations: [try .init(url: "https://example.invalid/repo")], + directory: "/synthetic/credential", overrideRequired: true) + let service = GitCredentialBrokerService(context: harness.context) + _ = try service.request(.init(operation: .register, registration: overridden, token: override)) + try MiniTest.expect(kill(crashed, SIGKILL) == 0) + try harness.waitExit(crashed, 3) + try harness.pause(6.5) + try MiniTest.expectEqual(harness.integer("starts"), 1, "a crashed broker must not be replaced automatically") + try MiniTest.expectEqual(harness.pid, crashed) + try MiniTest.expect(try harness.probe() == .absent, "the stale socket is not usable") + // The next eligible activation restores the process and reconciles intent. + try harness.activate("first") + guard let successor = harness.pid else { throw MiniTest.Failure(message: "no successor") } + try MiniTest.expectEqual(harness.integer("starts"), 2) + try MiniTest.expectEqual(Set(try harness.registrations().map(\.context.id)), ["first", "second"]) + try harness.wait(3, "successor did not reconnect the running registration") { harness.integer("connects") >= 1 } + try MiniTest.expectEqual(harness.integer("acquires"), 0, "recovery must not replay provider requests") + let persisted = try String(contentsOf: URL(fileURLWithPath: harness.context.state + "/registrations.json"), encoding: .utf8) + try MiniTest.expect(!persisted.contains(override), "an explicit override is never persisted across restart") + // The successor does not silently substitute another credential source: + // the session that needed an override must be re-registered by a CLI. + try harness.pause(2.2) // Let the successor reconcile before validating a lookup. + try MiniTest.expectThrows({ + _ = try service.request(.init(operation: .refresh, registration: overridden, sources: ["https://example.invalid/repo"])) + }, validate: { try MiniTest.expectEqual($0 as? GitCredentialFailure, .invalidContext) }) + try MiniTest.expectEqual(harness.integer("acquires"), 0) + try MiniTest.expect(successor != crashed) + }), + ("credentials.detached.upgradeReplacesOwner", { + let root = try credentialShortRoot() + let old = try detachedExecutableCopy(root, "probe-a") + let harness = GitCredentialDetachedHarness(root: root, executable: old) + defer { harness.cleanup() } + try harness.prepare(); try harness.states(["first": "running"]) + try harness.activate("first") + guard let previous = harness.pid else { throw MiniTest.Failure(message: "no owner") } + let connections = harness.integer("connects") + // A second copy is a different file: device/inode differ even though the + // build bytes are identical, which is exactly the upgrade signal. + let upgraded = try detachedExecutableCopy(root, "probe-b") + let next = GitCredentialLaunchContext(label: harness.context.label, state: harness.context.state, endpoints: harness.context.endpoints, + executable: upgraded, providerEnvironment: harness.context.providerEnvironment) + let state = try GitCredentialPrivateFiles.directory(next.state, create: false); defer { close(state) } + try GitCredentialPrivateFiles.write(JSONEncoder().encode(next), name: "launch-context.json", directory: state) + let upgradedHarness = GitCredentialDetachedHarness(root: root, executable: upgraded) + let started = ProcessInfo.processInfo.systemUptime + try upgradedHarness.activate("first") + guard let successor = harness.pid else { throw MiniTest.Failure(message: "no successor") } + try MiniTest.expect(successor != previous, "the upgraded executable must become the only owner") + try harness.wait(3, "the old executable is still serving") { !harness.alive(previous) } + print("detached upgrade replacement: \(String(format: "%.3f", ProcessInfo.processInfo.systemUptime - started))s") + try MiniTest.expectEqual(harness.integer("starts"), 2) + try MiniTest.expectEqual(try harness.registrations().map(\.context.id), ["first"], "registrations survive replacement") + guard case .owner(let reported) = try GitCredentialControlClient().probe(next, deadline: ProcessInfo.processInfo.systemUptime + 3), + reported.matches(try credentialIdentity(upgraded)) else { + throw MiniTest.Failure(message: "the upgraded executable is not the only owner") + } + // E02: the successor serves a real channel lookup from the saved + // registration, without the CLI registering it again. + try harness.wait(3, "successor did not reconnect the saved registration") { harness.integer("connects") > connections } + let lookupStarted = ProcessInfo.processInfo.systemUptime + try Data().write(to: URL(fileURLWithPath: root.path + "/lookup")) + try harness.wait(5, "lookup after upgrade did not complete") { harness.integer("lookups") >= 1 } + try MiniTest.expectEqual(try String(contentsOfFile: root.path + "/lookup-reply", encoding: .utf8), "synthetic-upgrade") + print("detached post-upgrade lookup: \(String(format: "%.3f", ProcessInfo.processInfo.systemUptime - lookupStarted))s") + try MiniTest.expectEqual(harness.integer("acquires"), 1, "the successor served one provider lookup") + // Repeating the activation must not kill the healthy successor. + try upgradedHarness.activate("first") + try MiniTest.expectEqual(harness.pid, successor); try MiniTest.expectEqual(harness.integer("starts"), 2) + }), + ("credentials.detached.exitCleansOwnedSocketOnly", { + let root = try credentialShortRoot() + let harness = GitCredentialDetachedHarness(root: root, executable: try detachedExecutableCopy(root, "probe-a")) + defer { harness.cleanup() } + try harness.prepare(); try harness.states(["first": "running"]) + try harness.activate("first") + guard let pid = harness.pid else { throw MiniTest.Failure(message: "no owner") } + try harness.expectConnection() + let lockPath = harness.context.endpoints + "/broker.lock" + let lockInode = try FileManager.default.attributesOfItem(atPath: lockPath)[.systemFileNumber] as? NSNumber + try MiniTest.expect(kill(pid, SIGTERM) == 0) + try harness.waitExit(pid, 7) + try harness.wait(2, "graceful exit left its own control socket") { !harness.socketExists() } + let after = try FileManager.default.attributesOfItem(atPath: lockPath)[.systemFileNumber] as? NSNumber + try MiniTest.expectEqual(after, lockInode, "lock inodes are never unlinked or replaced") + try MiniTest.expectEqual(try harness.registrations().map(\.context.id), ["first"], "registrations are preserved for the successor") + try harness.activate("first") + guard let successor = harness.pid else { throw MiniTest.Failure(message: "no successor") } + try MiniTest.expect(successor != pid) + try MiniTest.expect(try harness.isOwned(try credentialIdentity(harness.context.executable)), "the expected executable is not the owner") + }), + ("credentials.detached.staleAndSymlinkEndpointFailClosed", { + let root = try credentialShortRoot() + let harness = GitCredentialDetachedHarness(root: root, executable: try detachedExecutableCopy(root, "probe-a")) + defer { harness.cleanup() } + try harness.prepare(); try harness.states(["first": "running"]) + try harness.activate("first") + guard let crashed = harness.pid else { throw MiniTest.Failure(message: "no owner") } + try MiniTest.expect(kill(crashed, SIGKILL) == 0); try harness.waitExit(crashed, 3) + try MiniTest.expect(harness.socketExists(), "abnormal exit leaves a stale socket for the successor") + try harness.activate("first") + guard let successor = harness.pid else { throw MiniTest.Failure(message: "successor did not replace the stale socket") } + try MiniTest.expect(successor != crashed); try MiniTest.expectEqual(harness.integer("starts"), 2) + try MiniTest.expect(try harness.isOwned(try credentialIdentity(harness.context.executable)), "the expected executable is not the owner") + // A symlinked endpoint is foreign: fail closed without deleting anything. + try MiniTest.expect(kill(successor, SIGTERM) == 0); try harness.waitExit(successor, 7) + let target = root.appendingPathComponent("not-a-socket").path + try Data("synthetic-target\n".utf8).write(to: URL(fileURLWithPath: target)) + try FileManager.default.createSymbolicLink(atPath: harness.context.socket, withDestinationPath: target) + var refused = false + do { try harness.activate("first") } catch { refused = true } + try MiniTest.expect(refused, "a symlinked endpoint must fail activation") + try MiniTest.expect(FileManager.default.fileExists(atPath: harness.context.socket), "the foreign endpoint is not unlinked") + try MiniTest.expectEqual(try String(contentsOfFile: target, encoding: .utf8), "synthetic-target\n") + try MiniTest.expectEqual(harness.integer("starts"), 2, "a foreign endpoint never spawns a competing owner") + }), + ("credentials.detached.silentEndpointFailsClosed", { + let root = try credentialShortRoot() + let harness = GitCredentialDetachedHarness(root: root, executable: try detachedExecutableCopy(root, "probe-a")) + defer { harness.cleanup() } + try harness.prepare(); try harness.states(["first": "running"]) + let listener = try CredentialMuteListener(path: harness.context.socket); defer { listener.stop() } + var before = stat(); try MiniTest.expectEqual(lstat(harness.context.socket, &before), 0) + let started = ProcessInfo.processInfo.systemUptime + var refused = false + do { _ = try harness.activate("first") } catch { refused = true } + let elapsed = ProcessInfo.processInfo.systemUptime - started + print("detached silent endpoint fail-closed: \(String(format: "%.3f", elapsed))s") + try MiniTest.expect(elapsed < 5, "a silent endpoint must fail closed inside the probe bound, took \(elapsed)s") + try MiniTest.expect(refused, "a silent same-user endpoint must fail activation") + try MiniTest.expectEqual(harness.integer("starts"), 0, "a silent endpoint must never start a competing owner") + try MiniTest.expect(harness.pid == nil, "no broker pid may be recorded") + var after = stat(); try MiniTest.expectEqual(lstat(harness.context.socket, &after), 0) + try MiniTest.expectEqual(after.st_ino, before.st_ino, "the live endpoint inode is never replaced") + try MiniTest.expectEqual(after.st_dev, before.st_dev) + // A later retry can converge once the unidentifiable endpoint is gone. + listener.stop() + try FileManager.default.removeItem(atPath: harness.context.socket) + try harness.activate("first") + try MiniTest.expectEqual(harness.integer("starts"), 1) + try harness.wait(3, "the successor never answered hello") { try harness.isOwned(try credentialIdentity(harness.context.executable)) } + }), + ("credentials.detached.sighupAndSigtermExit", { + let root = try credentialShortRoot() + let harness = GitCredentialDetachedHarness(root: root, executable: try detachedExecutableCopy(root, "probe-a")) + defer { harness.cleanup() } + try harness.prepare(); try harness.states(["first": "running"]) + try harness.activate("first") + guard let pid = harness.pid else { throw MiniTest.Failure(message: "no owner") } + try MiniTest.expect(kill(pid, SIGHUP) == 0) + Thread.sleep(forTimeInterval: 0.5) + try MiniTest.expect(harness.alive(pid), "SIGHUP must not terminate a detached broker") + try MiniTest.expectEqual(harness.pid, pid) + try MiniTest.expect(try harness.isOwned(try credentialIdentity(harness.context.executable)), "the expected executable is not the owner") + try MiniTest.expect(kill(pid, SIGTERM) == 0) + try harness.waitExit(pid, 7) + try harness.wait(2, "SIGTERM left the control socket") { !harness.socketExists() } + try MiniTest.expectEqual(harness.integer("starts"), 1, "a signal exit is not restarted") + }) +] : [] diff --git a/Tests/adevcontainerTests/GitCredentialBrokerEntryTests.swift b/Tests/adevcontainerTests/GitCredentialBrokerEntryTests.swift new file mode 100644 index 0000000..15114f7 --- /dev/null +++ b/Tests/adevcontainerTests/GitCredentialBrokerEntryTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Darwin +@testable import ADevContainerLib + +@MainActor let gitCredentialBrokerEntryTests: [(String, () throws -> Void)] = [ + ("credentials.entry.invalidIsolatedContextAndPublicHelp", { + let executable = FileManager.default.currentDirectoryPath + "/.build/debug/adevcontainer" + try MiniTest.expect(FileManager.default.isExecutableFile(atPath: executable), "build the CLI product before entry tests") + let runner = CredentialProviderProcessRunner(timeout: 3) + let invalid = try runner.run(executable: executable, arguments: ["__adevcontainer_git_broker", "/invalid-synthetic-context/launch-context.json"], environment: [:], currentDirectory: nil, stdinData: nil) + try MiniTest.expectEqual(invalid.exitCode, 1) + // Provider runner intentionally discards stderr, so capture the safe CLI + // diagnostic with the ordinary test runner; this path fails before I/O. + let diagnostic = try FoundationProcessRunner().run(executable: executable, arguments: ["__adevcontainer_git_broker", "/invalid-synthetic-context/launch-context.json"], environment: [:], currentDirectory: nil) + try MiniTest.expect(diagnostic.stderrString.contains("service could not start")) + try MiniTest.expect(!diagnostic.stderrString.contains("invalid-synthetic-context")) + let help = try runner.run(executable: executable, arguments: ["--help"], environment: [:], currentDirectory: nil, stdinData: nil) + try MiniTest.expectEqual(help.exitCode, 0) + try MiniTest.expect(!help.stdoutString.contains("__adevcontainer_git_broker")) + }), + ("credentials.entry.isolatedSessionIgnoresSIGHUPAndStopsOnSignal", { + let executable = FileManager.default.currentDirectoryPath + "/.build/debug/adevcontainer" + try MiniTest.expect(FileManager.default.isExecutableFile(atPath: executable), "build the CLI product before entry tests") + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = GitCredentialBrokerSmokeHarness.isolatedContext(root: root) + let state = try GitCredentialPrivateFiles.directory(context.state, create: true); defer { close(state) } + try GitCredentialPrivateFiles.write(JSONEncoder().encode(context), name: "launch-context.json", directory: state) + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = ["__adevcontainer_git_broker", context.state + "/launch-context.json"] + process.environment = context.providerEnvironment + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + defer { if process.isRunning { _ = kill(process.processIdentifier, SIGKILL) } } + let deadline = ProcessInfo.processInfo.systemUptime + 5 + while !FileManager.default.fileExists(atPath: context.socket), process.isRunning, ProcessInfo.processInfo.systemUptime < deadline { + Thread.sleep(forTimeInterval: 0.025) + } + try MiniTest.expect(FileManager.default.fileExists(atPath: context.socket), "isolated session did not bind its private control socket") + try MiniTest.expect(kill(process.processIdentifier, SIGHUP) == 0) + Thread.sleep(forTimeInterval: 0.5) + try MiniTest.expect(process.isRunning, "SIGHUP must not terminate the detached broker loop") + guard case .owner(let reported) = try GitCredentialControlClient().probe(context, deadline: ProcessInfo.processInfo.systemUptime + 3), + reported.matches(try credentialIdentity(executable)) else { + throw MiniTest.Failure(message: "isolated session did not report its own executable identity") + } + try MiniTest.expect(kill(process.processIdentifier, SIGTERM) == 0) + process.waitUntilExit() + try MiniTest.expectEqual(process.terminationStatus, 0, "SIGTERM must use the graceful stop path") + try MiniTest.expect(!FileManager.default.fileExists(atPath: context.socket), "graceful exit removes its owned socket") + let entries = try FileManager.default.contentsOfDirectory(atPath: context.state).sorted() + try MiniTest.expectEqual(entries, ["launch-context.json"], "a session creates no plist or service receipt") + }) +] diff --git a/Tests/adevcontainerTests/GitCredentialBrokerPolicyTests.swift b/Tests/adevcontainerTests/GitCredentialBrokerPolicyTests.swift new file mode 100644 index 0000000..7b5917a --- /dev/null +++ b/Tests/adevcontainerTests/GitCredentialBrokerPolicyTests.swift @@ -0,0 +1,61 @@ +import Foundation +@testable import ADevContainerLib + +@MainActor let gitCredentialBrokerPolicyTests: [(String, () throws -> Void)] = [ + ("credentials.policy.destinationsAndUsername", { + var policy = GitCredentialBrokerPolicy() + policy.register("a", generation: 7, destinations: [try GitCredentialDestination(url: "https://dev.azure.com/org/original"), try GitCredentialDestination(url: "http://legacy.invalid:8080/org/repo")]) + let sibling = GitCredentialQuery(scheme: "https", host: "DEV.AZURE.COM:443", path: "other/sibling", username: "guest") + try MiniTest.expectEqual(policy.authorize("a", generation: 7, query: sibling), "https://dev.azure.com/other/sibling") + try MiniTest.expectEqual(policy.deliver("a", generation: 7, query: sibling, credential: .init(username: "host-account", password: "synthetic"))?.username, "guest") + for query in [GitCredentialQuery(scheme: "http", host: "dev.azure.com", path: "org/repo"), .init(scheme: "https", host: "dev.azure.com:8443", path: "org/repo"), .init(scheme: "ssh", host: "dev.azure.com"), .init(scheme: "https", host: "unknown.invalid"), .init(scheme: "https", host: "dev.azure.com"), .init(scheme: "https", host: "dev.azure.com", path: "org\ninjected=x")] { + try MiniTest.expect(policy.authorize("a", generation: 7, query: query) == nil) + } + try MiniTest.expectEqual(policy.authorize("a", generation: 7, query: .init(scheme: "http", host: "legacy.invalid:8080", path: "sibling")), "http://legacy.invalid:8080/sibling") + try MiniTest.expect(policy.authorize("a", generation: 6, query: sibling) == nil) + for host in ["example.invalid@other.invalid", "example.invalid/path", "example.invalid:0", "example.invalid:65536", "example.invalid\n", "example%0a.invalid", "example.invalid?x"] { + try MiniTest.expectThrows({ _ = try GitCredentialDestination(scheme: "https", host: host) }, validate: { _ in }) + } + }), + ("credentials.policy.rejectionFiniteAndIsolated", { + var time: TimeInterval = 0 + var policy = GitCredentialBrokerPolicy(clock: { time }) + for session in ["a", "b"] { policy.register(session, generation: 7, destinations: [try GitCredentialDestination(url: "https://example.invalid/repo")]) } + let query = GitCredentialQuery(scheme: "https", host: "example.invalid", path: "repo", username: "guest") + let old = GitHTTPSCredentials(username: "host", password: "old-synthetic"), new = GitHTTPSCredentials(username: "host", password: "new-synthetic") + _ = policy.deliver("a", generation: 7, query: query, credential: old) + _ = policy.deliver("a", generation: 7, query: query, credential: new) + var rejection = query; rejection.password = old.password + policy.erase("a", generation: 7, query: rejection) + try MiniTest.expect(policy.deliver("a", generation: 7, query: query, credential: old) == nil) + try MiniTest.expect(policy.deliver("a", generation: 7, query: query, credential: new) != nil) + try MiniTest.expect(policy.deliver("b", generation: 7, query: query, credential: old) != nil) + time = 31 + try MiniTest.expect(policy.deliver("a", generation: 7, query: query, credential: old) != nil) + policy.erase("a", generation: 7, query: rejection); policy.reset("a") + try MiniTest.expect(policy.deliver("a", generation: 7, query: query, credential: old) != nil) + rejection.password = "never-delivered" + policy.erase("a", generation: 7, query: rejection) + try MiniTest.expect(policy.deliver("a", generation: 7, query: query, credential: .init(username: "host", password: "never-delivered")) != nil) + try MiniTest.expect(policy.begin("a", generation: 7, request: 1)) + try MiniTest.expect(!policy.begin("a", generation: 7, request: 1)) + policy.register("a", generation: 8, destinations: [try GitCredentialDestination(url: "https://example.invalid")]) + try MiniTest.expect(!policy.current("a", generation: 7)) + try MiniTest.expect(policy.deliver("a", generation: 7, query: query, credential: old) == nil) + policy.unregister("a"); try MiniTest.expect(!policy.current("a", generation: 8)) + }), + ("credentials.policy.boundedFairAdmission", { + var admission = GitCredentialAdmission(activeLimit: 1, queueLimit: 3) + let a = GitCredentialWork(session: "a", generation: 1, request: 1) + let a2 = GitCredentialWork(session: "a", generation: 1, request: 2) + let b = GitCredentialWork(session: "b", generation: 1, request: 1) + try MiniTest.expect(admission.enqueue(a)); try MiniTest.expectEqual(admission.next(), a) + try MiniTest.expect(admission.enqueue(a2)); try MiniTest.expect(admission.enqueue(b)) + try MiniTest.expect(admission.next() == nil) + admission.complete(a); try MiniTest.expectEqual(admission.next(), b) + admission.complete(b); try MiniTest.expectEqual(admission.next(), a2) + admission.cancel(session: "a") + for id in 3...5 { try MiniTest.expect(admission.enqueue(.init(session: "c", generation: 1, request: UInt64(id)))) } + try MiniTest.expect(!admission.enqueue(.init(session: "c", generation: 1, request: 6))) + }) +] diff --git a/Tests/adevcontainerTests/GitCredentialBrokerServiceTests.swift b/Tests/adevcontainerTests/GitCredentialBrokerServiceTests.swift new file mode 100644 index 0000000..b69ab88 --- /dev/null +++ b/Tests/adevcontainerTests/GitCredentialBrokerServiceTests.swift @@ -0,0 +1,730 @@ +import Foundation +import Darwin +@testable import ADevContainerLib + +func credentialTestBase() throws -> URL { + let approved = FileManager.default.temporaryDirectory.appendingPathComponent("opencode").path + if !FileManager.default.fileExists(atPath: approved) { + try FileManager.default.createDirectory(atPath: approved, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + } + guard let physical = realpath(approved, nil) else { throw MiniTest.Failure(message: "approved physical temporary root is unavailable") } + defer { free(physical) } + return URL(fileURLWithPath: String(cString: physical)) +} +func credentialTestRoot() throws -> URL { + try credentialTestBase().appendingPathComponent("credential-registry-\(UUID().uuidString)") +} +func credentialShortRoot() throws -> URL { + var template = Array((try credentialTestBase().path + "/s.XXXXXX").utf8CString) + guard let path = mkdtemp(&template) else { throw MiniTest.Failure(message: "private test root unavailable") } + return URL(fileURLWithPath: String(cString: path)) +} +final class CredentialServiceTestLifetime: @unchecked Sendable { + private let condition = NSCondition() + private var stopped = false + private var finished = false + private var successful = false + func stop() { condition.lock(); stopped = true; condition.unlock() } + func shouldStop() -> Bool { condition.lock(); defer { condition.unlock() }; return stopped } + func finish(_ successful: Bool) { condition.lock(); finished = true; self.successful = successful; condition.broadcast(); condition.unlock() } + func awaitFinish(within: TimeInterval = 3) -> Bool { + condition.lock(); defer { condition.unlock() }; let deadline = Date().addingTimeInterval(within) + while !finished { if !condition.wait(until: deadline) { break } }; return finished && successful + } +} + +/// A channel whose cancel never returns promptly. Broker shutdown must not +/// let one adapter serialize the process exit. +final class CredentialSlowCancelChannel: GitCredentialBrokerChannel, @unchecked Sendable { + private let lock = NSLock() + private let delay: TimeInterval + private var cancellations = 0 + init(delay: TimeInterval = 3) { self.delay = delay } + func send(_ frame: GitCredentialFrame) throws {} + func cancel() { lock.lock(); cancellations += 1; lock.unlock(); Thread.sleep(forTimeInterval: delay) } + var cancelCount: Int { lock.lock(); defer { lock.unlock() }; return cancellations } +} + +final class CredentialTestChannel: GitCredentialBrokerChannel, @unchecked Sendable { + private let condition = NSCondition() + private var frames: [GitCredentialFrame] = [] + private var cancelled = false + func send(_ frame: GitCredentialFrame) throws { condition.lock(); defer { condition.unlock() }; frames.append(frame); condition.broadcast() } + func cancel() { condition.lock(); cancelled = true; condition.broadcast(); condition.unlock() } + func take() throws -> GitCredentialFrame { + condition.lock(); defer { condition.unlock() }; let end = Date().addingTimeInterval(3) + while frames.isEmpty && !cancelled { if !condition.wait(until: end) { break } } + guard !frames.isEmpty else { throw MiniTest.Failure(message: "synthetic broker reply timed out") }; return frames.removeFirst() + } +} + +final class CredentialTestBrokerPorts: @unchecked Sendable { + private let lock = NSLock() + let context: GitCredentialRuntimeContext + private var receive: (@Sendable (GitCredentialFrame) -> Void)? + private var generation: UInt64 = 0 + private var channel = CredentialTestChannel() + private var providerCalls: [String] = [] + private var passwords: [String] = [] + private var disconnect: (@Sendable () -> Void)? + private var connects = 0 + private var contextInvalid = false + private var contextUnavailable = false + private var miss = false + private var reported: GitCredentialRuntimeContext + var beforeObserve: (@Sendable () -> Void)? + var beforeAcquire: (@Sendable () -> Void)? + var beforeContext: (@Sendable () -> Void)? + init(id: String = "synthetic") { + context = .init(id: id, incarnation: "reference:800000000", platform: "linux/arm64", user: "root", labels: ["devcontainer.managed": "adevcontainer"]) + reported = context + } + func replaceContext(_ context: GitCredentialRuntimeContext) { lock.lock(); reported = context; lock.unlock() } + var ports: GitCredentialBrokerPorts { .init(context: { [self] _ in + beforeContext?() + lock.lock(); defer { lock.unlock() }; if contextUnavailable { throw GitCredentialFailure.transport }; if contextInvalid { throw GitCredentialFailure.invalidContext }; return reported + }, connect: { [self] _, generation, receive, disconnected in + lock.lock(); defer { lock.unlock() }; self.generation = generation; self.receive = receive; disconnect = disconnected; connects += 1; channel = CredentialTestChannel(); return channel + }, acquire: { [self] url, override, _ in + beforeAcquire?() + lock.lock(); defer { lock.unlock() }; providerCalls.append(url); if miss { return nil } + let password = passwords.isEmpty ? (override ?? "synthetic") : passwords.removeFirst() + return .init(username: "host-account", password: password) + }, observe: { [self] _, _ in + lock.lock(); let invalid = contextInvalid, unavailable = contextUnavailable, context = reported; lock.unlock() + beforeObserve?() + if unavailable { return .unknown }; return invalid ? .inactive : .running(context) + }) } + func failAcquisition() { lock.lock(); miss = true; lock.unlock() } + func enqueuePasswords(_ values: [String]) { lock.lock(); passwords = values; lock.unlock() } + func request(_ id: UInt64, host: String = "example.invalid", generation: UInt64? = nil) throws -> GitCredentialFrame { + lock.lock(); let receive = self.receive, current = self.generation, channel = self.channel; lock.unlock() + receive?(.init(operation: .get, request: id, generation: generation ?? current, fields: [.scheme: "https", .host: host, .path: "sibling/repo", .username: "guest"])) + return try channel.take() + } + var calls: [String] { lock.lock(); defer { lock.unlock() }; return providerCalls } + var receiver: (@Sendable (GitCredentialFrame) -> Void)? { lock.lock(); defer { lock.unlock() }; return receive } + var connectionCount: Int { lock.lock(); defer { lock.unlock() }; return connects } + func failChannel() { lock.lock(); let callback = disconnect; lock.unlock(); callback?() } + func invalidateContext() { lock.lock(); contextInvalid = true; lock.unlock() } + func unavailableContext() { lock.lock(); contextUnavailable = true; lock.unlock() } + func restoreContext(_ context: GitCredentialRuntimeContext) { lock.lock(); reported = context; contextUnavailable = false; contextInvalid = false; lock.unlock() } + var currentGeneration: UInt64 { lock.lock(); defer { lock.unlock() }; return generation } + func takeReply() throws -> GitCredentialFrame { lock.lock(); let channel = channel; lock.unlock(); return try channel.take() } +} + +final class CredentialTestClock: @unchecked Sendable { + private let lock = NSLock() + private var value: TimeInterval = 0 + func now() -> TimeInterval { lock.lock(); defer { lock.unlock() }; return value } + func advance(_ interval: TimeInterval) { lock.lock(); value += interval; lock.unlock() } +} + +func credentialIdentity(_ executable: String) throws -> ExecutableIdentity { + let identity = try ExecutableIdentity.read(executable) + return .init(executable: identity.executable, device: identity.device, inode: identity.inode, + build: identity.build, protocolVersion: identity.protocolVersion, process: 999) +} + +final class CredentialDetachedLauncher: DetachedProcessLaunching, @unchecked Sendable { + struct Launch { let executable: String; let arguments: [String]; let environment: [String: String] } + private let lock = NSLock() + private var stored: [Launch] = [] + var failure: GitCredentialFailure? + var afterLaunch: (@Sendable () -> Void)? + var launches: [Launch] { lock.lock(); defer { lock.unlock() }; return stored } + func launch(executable: String, arguments: [String], environment: [String: String]) throws -> Int32 { + if let failure { throw failure } + lock.lock(); stored.append(.init(executable: executable, arguments: arguments, environment: environment)); lock.unlock() + afterLaunch?(); return 4242 + } + func reap(_ process: Int32) {} +} + +/// Same-user, owner-private listener that accepts a connection and never +/// answers. It models an endpoint that speaks no control protocol but is still +/// alive: activation must fail closed without unlinking or replacing it. +final class CredentialMuteListener: @unchecked Sendable { + private let listener: Int32 + private let release = DispatchSemaphore(value: 0) + private let accepted = DispatchSemaphore(value: 0) + init(path: String) throws { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw MiniTest.Failure(message: "mute listener socket failed") } + var address = try GitCredentialControlSocket.address(path) + guard GitCredentialControlSocket.bindPrivate(fd, address: &address) == 0, + chmod(path, 0o600) == 0, listen(fd, 4) == 0 else { + close(fd); throw MiniTest.Failure(message: "mute listener bind failed") + } + listener = fd + DispatchQueue.global().async { [self] in + defer { close(listener) } + var pending = pollfd(fd: listener, events: Int16(POLLIN), revents: 0) + guard poll(&pending, 1, 5000) > 0, pending.revents & Int16(POLLIN) != 0 else { return } + let peer = accept(listener, nil, nil) + accepted.signal() + if peer >= 0 { _ = release.wait(timeout: .now() + 10); close(peer) } + } + } + func acceptOne() -> Bool { accepted.wait(timeout: .now() + 3) == .success } + func stop() { release.signal() } +} + +/// Probe that consumes more than the caller's deadline and still reports +/// absence. It models a connect phase that outlives activation: ensure must +/// recheck the deadline before writing a context or spawning a successor. +final class CredentialDelayedAbsentProbe: GitCredentialBrokerProbing, @unchecked Sendable { + func probe(_ context: GitCredentialLaunchContext, deadline: TimeInterval) throws -> GitCredentialBrokerStatus { + Thread.sleep(forTimeInterval: 0.4) + return .absent + } + func retire(_ context: GitCredentialLaunchContext, deadline: TimeInterval) throws {} + func exchange(_ request: GitCredentialControlRequest, context: GitCredentialLaunchContext, deadline: TimeInterval) throws -> GitCredentialControlReply { + .init(succeeded: true, acquired: false) + } +} + +final class CredentialProbeFake: GitCredentialBrokerProbing, @unchecked Sendable { + private let lock = NSLock() + private var status: GitCredentialBrokerStatus + private var retirements = 0 + private var exchanges = 0 + var onRetire: (@Sendable () -> Void)? + init(_ status: GitCredentialBrokerStatus) { self.status = status } + var retireCount: Int { lock.lock(); defer { lock.unlock() }; return retirements } + var exchangeCount: Int { lock.lock(); defer { lock.unlock() }; return exchanges } + func set(_ value: GitCredentialBrokerStatus) { lock.lock(); status = value; lock.unlock() } + func probe(_ context: GitCredentialLaunchContext, deadline: TimeInterval) throws -> GitCredentialBrokerStatus { + lock.lock(); defer { lock.unlock() }; return status + } + func retire(_ context: GitCredentialLaunchContext, deadline: TimeInterval) throws { + lock.lock(); retirements += 1; lock.unlock(); onRetire?() + } + func exchange(_ request: GitCredentialControlRequest, context: GitCredentialLaunchContext, deadline: TimeInterval) throws -> GitCredentialControlReply { + lock.lock(); exchanges += 1; lock.unlock() + return .init(succeeded: true, acquired: false) + } +} + +func credentialServiceContext(_ root: URL) -> GitCredentialLaunchContext { + .init(label: "com.adevcontainer.git-credentials.test.\(UUID().uuidString)", state: root.path + "/state", endpoints: root.path, + executable: "/usr/bin/true", providerEnvironment: ["PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "HOME": root.path + "/state", + "GIT_CONFIG_GLOBAL": root.path + "/state/synthetic.gitconfig", "GIT_CONFIG_SYSTEM": "/dev/null", "GIT_CONFIG_NOSYSTEM": "1"]) +} + +@MainActor let gitCredentialBrokerServiceTests: [(String, () throws -> Void)] = [ + ("credentials.service.spawnsOneDetachedBroker", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = credentialServiceContext(root), identity = try credentialIdentity(context.executable) + let probe = CredentialProbeFake(.absent), launcher = CredentialDetachedLauncher() + launcher.afterLaunch = { probe.set(.owner(identity)) } + let service = GitCredentialBrokerService(context: context, launcher: launcher, control: probe) + try service.ensure() + try MiniTest.expectEqual(launcher.launches.count, 1) + guard let launch = launcher.launches.first else { throw MiniTest.Failure(message: "no detached launch recorded") } + try MiniTest.expectEqual(launch.executable, context.executable) + try MiniTest.expectEqual(launch.arguments, ["__adevcontainer_git_broker", context.state + "/launch-context.json"]) + try MiniTest.expect(launch.environment["ADEVCONTAINER_GIT_TOKEN"] == nil) + try MiniTest.expectEqual(launch.environment["PATH"], "/usr/bin:/bin:/usr/sbin:/sbin") + let state = try GitCredentialPrivateFiles.directory(context.state, create: false); defer { close(state) } + guard let bytes = try GitCredentialPrivateFiles.read("launch-context.json", directory: state) else { throw MiniTest.Failure(message: "isolated context was not written") } + try MiniTest.expectEqual(try JSONDecoder().decode(GitCredentialLaunchContext.self, from: bytes), context) + // Concurrent activations serialize on the activation lock and converge on one spawn. + let group = DispatchGroup(), concurrent = CredentialDetachedLauncher() + concurrent.afterLaunch = { probe.set(.owner(identity)) } + let shared = GitCredentialBrokerService(context: context, launcher: concurrent, control: probe) + for _ in 0..<8 { + group.enter(); DispatchQueue.global().async { defer { group.leave() }; _ = try? shared.ensure() } + } + try MiniTest.expect(group.wait(timeout: .now() + 10) == .success) + try MiniTest.expectEqual(concurrent.launches.count, 0, "a healthy matching owner is reused without spawning") + }), + ("credentials.service.reusesMatchingIdentity", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = credentialServiceContext(root), identity = try credentialIdentity(context.executable) + let probe = CredentialProbeFake(.owner(identity)), launcher = CredentialDetachedLauncher() + let service = GitCredentialBrokerService(context: context, launcher: launcher, control: probe) + for _ in 0..<3 { try service.ensure() } + let group = DispatchGroup() + for _ in 0..<8 { + group.enter(); DispatchQueue.global().async { defer { group.leave() }; _ = try? service.ensure() } + } + try MiniTest.expect(group.wait(timeout: .now() + 10) == .success) + try MiniTest.expectEqual(launcher.launches.count, 0, "reuse must not restart or reload") + try MiniTest.expectEqual(probe.retireCount, 0) + try MiniTest.expectEqual(probe.exchangeCount, 0, "reuse must not register or look up a provider") + }), + ("credentials.service.mismatchedIdentityRetiresOnce", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = credentialServiceContext(root), identity = try credentialIdentity(context.executable) + let old = try credentialIdentity("/usr/bin/false") + let probe = CredentialProbeFake(.owner(old)), launcher = CredentialDetachedLauncher() + probe.onRetire = { probe.set(.absent) } + launcher.afterLaunch = { probe.set(.owner(identity)) } + try GitCredentialBrokerService(context: context, launcher: launcher, control: probe).ensure() + try MiniTest.expectEqual(probe.retireCount, 1) + try MiniTest.expectEqual(launcher.launches.count, 1) + // A healthy successor from the same executable is never retired again. + probe.set(.owner(identity)) + try GitCredentialBrokerService(context: context, launcher: launcher, control: probe).ensure() + try MiniTest.expectEqual(probe.retireCount, 1); try MiniTest.expectEqual(launcher.launches.count, 1) + // A second different executable in the readiness window is retired exactly once. + let replacement = CredentialProbeFake(.owner(old)), relauncher = CredentialDetachedLauncher() + relauncher.afterLaunch = { replacement.set(.owner(old)) } + try MiniTest.expectThrows({ + try GitCredentialBrokerService(context: context, launcher: relauncher, control: replacement).ensure(deadline: ProcessInfo.processInfo.systemUptime + 1.5) + }, validate: { try MiniTest.expectEqual($0 as? GitCredentialFailure, .deadline) }) + try MiniTest.expectEqual(replacement.retireCount, 1) + }), + ("credentials.service.unregisterWithoutBrokerDoesNotSpawn", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = credentialServiceContext(root) + let probe = CredentialProbeFake(.absent), launcher = CredentialDetachedLauncher() + let service = GitCredentialBrokerService(context: context, launcher: launcher, control: probe) + let accepted = try service.request(.init(operation: .unregister, id: "gone"), activate: false) + try MiniTest.expect(!accepted) + try MiniTest.expectEqual(probe.retireCount, 0) + try MiniTest.expectEqual(launcher.launches.count, 0, "teardown must never start a broker") + try MiniTest.expect(!FileManager.default.fileExists(atPath: context.socket)) + try MiniTest.expect(!FileManager.default.fileExists(atPath: context.state)) + }), + ("credentials.service.spawnFailureIsSoft", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = credentialServiceContext(root) + let probe = CredentialProbeFake(.absent), launcher = CredentialDetachedLauncher() + launcher.failure = .transport + let service = GitCredentialBrokerService(context: context, launcher: launcher, control: probe) + try MiniTest.expectThrows({ try service.ensure() }, validate: { try MiniTest.expectEqual($0 as? GitCredentialFailure, .transport) }) + try MiniTest.expectEqual(launcher.launches.count, 0) + launcher.failure = nil + try MiniTest.expectThrows({ + try GitCredentialBrokerService(context: context, launcher: launcher, control: probe).ensure(deadline: ProcessInfo.processInfo.systemUptime + 0.5) + }, validate: { try MiniTest.expectEqual($0 as? GitCredentialFailure, .deadline) }) + try MiniTest.expectEqual(launcher.launches.count, 1) + }), + ("credentials.service.unrecognizedEndpointFailsClosed", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = credentialServiceContext(root) + let probe = CredentialProbeFake(.unrecognized), launcher = CredentialDetachedLauncher() + try MiniTest.expectThrows({ + try GitCredentialBrokerService(context: context, launcher: launcher, control: probe).ensure() + }, validate: { try MiniTest.expectEqual($0 as? GitCredentialFailure, .invalidContext) }) + try MiniTest.expectEqual(launcher.launches.count, 0, "an unauthenticatable endpoint is never replaced") + try MiniTest.expectEqual(probe.retireCount, 0, "an unauthenticatable endpoint is never killed") + // Retirement that never completes is a bounded failure, not a second owner. + let owner = CredentialProbeFake(.owner(try credentialIdentity("/usr/bin/false"))), stuck = CredentialDetachedLauncher() + let started = ProcessInfo.processInfo.systemUptime + try MiniTest.expectThrows({ + try GitCredentialBrokerService(context: context, launcher: stuck, control: owner).ensure(deadline: started + 1.5) + }, validate: { try MiniTest.expectEqual($0 as? GitCredentialFailure, .deadline) }) + try MiniTest.expect(ProcessInfo.processInfo.systemUptime - started < 3, "replacement must stay inside the activation deadline") + try MiniTest.expectEqual(stuck.launches.count, 0) + }), + ("credentials.service.silentListenerFailsClosedWithoutReplacingEndpoint", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = credentialServiceContext(root) + let listener = try CredentialMuteListener(path: context.socket); defer { listener.stop() } + var before = stat(); try MiniTest.expectEqual(lstat(context.socket, &before), 0) + let launcher = CredentialDetachedLauncher() + let started = ProcessInfo.processInfo.systemUptime + try MiniTest.expectThrows({ + try GitCredentialBrokerService(context: context, launcher: launcher).ensure() + }, validate: { try MiniTest.expectEqual($0 as? GitCredentialFailure, .invalidContext) }) + let elapsed = ProcessInfo.processInfo.systemUptime - started + // Connected but silent is an unrecognized endpoint, not absence: the + // probe must not consume the 30-second activation deadline. + try MiniTest.expect(elapsed < 5, "a silent listener must fail closed inside the probe bound, took \(elapsed)s") + try MiniTest.expect(listener.acceptOne(), "the mute listener never accepted the probe") + try MiniTest.expectEqual(launcher.launches.count, 0, "a silent endpoint must not spawn a competing owner") + var after = stat(); try MiniTest.expectEqual(lstat(context.socket, &after), 0) + print("silent listener fail-closed: \(String(format: "%.3f", elapsed))s") + try MiniTest.expectEqual(after.st_ino, before.st_ino, "a silent endpoint's inode is never replaced") + try MiniTest.expectEqual(after.st_dev, before.st_dev, "a silent endpoint is never replaced") + }), + ("credentials.service.deadlineExpiryBeforeSpawnPreventsSideEffects", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = credentialServiceContext(root), launcher = CredentialDetachedLauncher() + let started = ProcessInfo.processInfo.systemUptime + try MiniTest.expectThrows({ + try GitCredentialBrokerService(context: context, launcher: launcher, control: CredentialDelayedAbsentProbe()).ensure(deadline: started + 0.2) + }, validate: { try MiniTest.expectEqual($0 as? GitCredentialFailure, .deadline) }) + try MiniTest.expectEqual(launcher.launches.count, 0, "an expired activation must not spawn") + try MiniTest.expect(!FileManager.default.fileExists(atPath: context.state), "an expired activation must not write a launch context") + }), + ("credentials.identity.deviceInodeBuildAndNonMachOFallback", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let machO = try ExecutableIdentity.read("/usr/bin/true") + try MiniTest.expect(machO.build.count == 32 && machO.build.allSatisfy(\.isHexDigit), "a Mach-O binary must expose its build UUID") + try MiniTest.expect(machO.device > 0 && machO.inode > 0) + try MiniTest.expect(machO.matches(try ExecutableIdentity.read("/usr/bin/true"))) + // A non-Mach-O wrapper carries no build identity; device/inode decides. + let script = root.appendingPathComponent("wrapper").path + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: URL(fileURLWithPath: script)) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: script) + let wrapper = try ExecutableIdentity.read(script) + try MiniTest.expectEqual(wrapper.build, "") + try MiniTest.expect(wrapper.matches(try ExecutableIdentity.read(script))) + // Equal bytes in a different file are a replacement, not a match. + let copy = root.appendingPathComponent("wrapper-copy").path + try FileManager.default.copyItem(atPath: script, toPath: copy) + try MiniTest.expect(!wrapper.matches(try ExecutableIdentity.read(copy))) + try MiniTest.expect(!machO.matches(wrapper), "device/inode equality is required") + try MiniTest.expectThrows({ _ = try ExecutableIdentity.read(root.appendingPathComponent("missing").path) }, validate: { _ in }) + try MiniTest.expectThrows({ _ = try ExecutableIdentity.read("relative/path") }, validate: { _ in }) + }), + ("credentials.service.activationLockIsSingleton", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = credentialServiceContext(root) + var owner: GitCredentialServiceLock? = try GitCredentialServiceLock(directory: context.endpoints) + try MiniTest.expect(owner != nil) + try MiniTest.expectThrows({ _ = try GitCredentialServiceLock(directory: context.endpoints) }, validate: { _ in }) + owner = nil + let replacement = try GitCredentialServiceLock(directory: context.endpoints); _ = replacement + }), + ("credentials.service.socketIsPrivateAtCreation", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let fd = socket(AF_UNIX, SOCK_STREAM, 0); try MiniTest.expect(fd >= 0); defer { close(fd) } + var address = try GitCredentialControlSocket.address(root.path + "/creation.sock") + try MiniTest.expectEqual(GitCredentialControlSocket.bindPrivate(fd, address: &address), 0) + var attributes = stat(); try MiniTest.expectEqual(lstat(root.path + "/creation.sock", &attributes), 0) + try MiniTest.expectEqual(attributes.st_mode & 0o077, 0, "socket must already be private before listen/chmod") + }), + ("credentials.broker.twoSessionsAndRecoveredOverrideAreDemand", { + let root = try credentialTestRoot(); defer { try? FileManager.default.removeItem(at: root) } + let first = CredentialTestBrokerPorts(id: "first"), second = CredentialTestBrokerPorts(id: "second"), clock = CredentialTestClock() + let ports = GitCredentialBrokerPorts(context: { id in try (id == "first" ? first : second).ports.context(id) }, connect: { registration, generation, receive, disconnected in + try (registration.context.id == "first" ? first : second).ports.connect(registration, generation, receive, disconnected) + }, acquire: { _, _, _ in throw MiniTest.Failure(message: "idle must not acquire") }, observe: { id, end in (id == "first" ? first : second).ports.observe!(id, end) }) + let registry = GitCredentialBrokerRegistry(path: root.path) + let broker = try GitCredentialBroker(registry: registry, ports: ports, clock: { clock.now() }); defer { broker.shutdown() } + for port in [first, second] { + try broker.register(.init(context: port.context, destinations: [try .init(url: "https://example.invalid/repo")], directory: "/private/credential", overrideRequired: true), override: "synthetic") + } + try broker.unregister("first"); clock.advance(20); try MiniTest.expect(!broker.retireIfIdle()) + broker.shutdown() + let recovered = try GitCredentialBroker(registry: registry, ports: ports, clock: { clock.now() }); defer { recovered.shutdown() } + recovered.reconcile(); clock.advance(20); try MiniTest.expect(!recovered.retireIfIdle()) + try MiniTest.expectEqual(try registry.load().count, 1) + try recovered.unregister("second"); clock.advance(5); try MiniTest.expect(recovered.retireIfIdle()) + }), + ("credentials.broker.registrationLosingRetirementCannotSucceed", { + let root = try credentialTestRoot(); defer { try? FileManager.default.removeItem(at: root) } + let ports = CredentialTestBrokerPorts(), clock = CredentialTestClock() + let broker = try GitCredentialBroker(registry: .init(path: root.path), ports: ports.ports, clock: { clock.now() }); defer { broker.shutdown() } + let entered = DispatchSemaphore(value: 0), resume = DispatchSemaphore(value: 0), lost = DispatchSemaphore(value: 0) + ports.beforeContext = { entered.signal(); _ = resume.wait(timeout: .now() + 3) } + DispatchQueue.global().async { + do { try broker.register(.init(context: ports.context, destinations: [], directory: "/private/credential", overrideRequired: false)) } + catch GitCredentialFailure.transport { lost.signal() } + catch { } + } + try MiniTest.expect(entered.wait(timeout: .now() + 3) == .success) + clock.advance(5); try MiniTest.expect(broker.retireIfIdle()); resume.signal() + try MiniTest.expect(lost.wait(timeout: .now() + 3) == .success, "retirement wins before registration admission; caller must retry") + ports.beforeContext = nil + }), + ("credentials.broker.graceDemandAndRetirementBoundary", { + let root = try credentialTestRoot(); defer { try? FileManager.default.removeItem(at: root) } + let registry = GitCredentialBrokerRegistry(path: root.path), ports = CredentialTestBrokerPorts(), clock = CredentialTestClock() + let broker = try GitCredentialBroker(registry: registry, ports: ports.ports, clock: { clock.now() }); defer { broker.shutdown() } + let registration = GitCredentialRegistration(context: ports.context, destinations: [try .init(url: "https://example.invalid/repo")], directory: "/private/credential", overrideRequired: false) + clock.advance(4.999); try MiniTest.expect(!broker.retireIfIdle()) + try broker.register(registration); clock.advance(10); try MiniTest.expect(!broker.retireIfIdle()) + ports.failChannel(); clock.advance(10); try MiniTest.expect(!broker.retireIfIdle()) + try broker.unregister("synthetic"); clock.advance(4); try MiniTest.expect(!broker.retireIfIdle()) + try broker.register(registration); clock.advance(10); try MiniTest.expect(!broker.retireIfIdle()) + try broker.unregister("synthetic"); clock.advance(4.999); try MiniTest.expect(!broker.retireIfIdle()) + clock.advance(0.001); try MiniTest.expect(broker.retireIfIdle()) + try MiniTest.expectThrows({ try broker.register(registration) }, validate: { try MiniTest.expectEqual($0 as? GitCredentialFailure, .transport) }) + try MiniTest.expect(ports.calls.isEmpty) + let successor = try GitCredentialBroker(registry: registry, ports: ports.ports, clock: { clock.now() }); defer { successor.shutdown() } + try successor.register(registration); try MiniTest.expect(!successor.retireIfIdle()) + }), + ("credentials.broker.externalStopAndLateObservationFence", { + let root = try credentialTestRoot(); defer { try? FileManager.default.removeItem(at: root) } + let registry = GitCredentialBrokerRegistry(path: root.path), ports = CredentialTestBrokerPorts(), clock = CredentialTestClock() + let broker = try GitCredentialBroker(registry: registry, ports: ports.ports, clock: { clock.now() }); defer { broker.shutdown() } + let registration = GitCredentialRegistration(context: ports.context, destinations: [try .init(url: "https://example.invalid/repo")], directory: "/private/credential", overrideRequired: false) + try broker.register(registration) + let entered = DispatchSemaphore(value: 0), resume = DispatchSemaphore(value: 0), done = DispatchSemaphore(value: 0) + ports.beforeObserve = { entered.signal(); _ = resume.wait(timeout: .now() + 3) } + ports.invalidateContext() + DispatchQueue.global().async { broker.reconcile(); done.signal() } + try MiniTest.expect(entered.wait(timeout: .now() + 3) == .success) + let replacement = ports.context // Even healthy idempotent re-registration is newer evidence. + ports.restoreContext(replacement) + try broker.register(.init(context: replacement, destinations: registration.destinations, directory: registration.directory, overrideRequired: false)) + resume.signal(); try MiniTest.expect(done.wait(timeout: .now() + 3) == .success) + try MiniTest.expectEqual(try registry.load().first?.context, replacement) + ports.beforeObserve = nil; ports.invalidateContext(); clock.advance(2); broker.reconcile() + try MiniTest.expect(try registry.load().isEmpty); try MiniTest.expect(!broker.retireIfIdle()) + clock.advance(5); try MiniTest.expect(broker.retireIfIdle()); try MiniTest.expect(ports.calls.isEmpty) + }), + ("credentials.broker.pendingRequestSurvivesUnknownButCannotLeak", { + let root = try credentialTestRoot(); defer { try? FileManager.default.removeItem(at: root) } + let registry = GitCredentialBrokerRegistry(path: root.path), ports = CredentialTestBrokerPorts(), clock = CredentialTestClock() + let broker = try GitCredentialBroker(registry: registry, ports: ports.ports, clock: { clock.now() }); defer { broker.shutdown() } + try broker.register(.init(context: ports.context, destinations: [try .init(url: "https://example.invalid/repo")], directory: "/private/credential", overrideRequired: false)) + let entered = DispatchSemaphore(value: 0), resume = DispatchSemaphore(value: 0) + ports.beforeAcquire = { entered.signal(); _ = resume.wait(timeout: .now() + 3) } + ports.receiver?(.init(operation: .get, request: 1, generation: ports.currentGeneration, fields: [.scheme: "https", .host: "example.invalid", .path: "repo"])) + try MiniTest.expect(entered.wait(timeout: .now() + 3) == .success) + ports.unavailableContext(); broker.reconcile(); clock.advance(10) + try MiniTest.expect(!broker.retireIfIdle()); try MiniTest.expectEqual(try registry.load().count, 1) + resume.signal(); let reply = try ports.takeReply() + try MiniTest.expect(reply.fields.isEmpty, "unknown identity cannot receive provider secrets") + ports.beforeAcquire = nil; ports.restoreContext(ports.context); broker.reconcile() + try MiniTest.expectEqual(try ports.request(2).fields[.password], "synthetic") + }), + ("credentials.broker.runtimeUnknownPreservesRegistration", { + let root = try credentialTestRoot(); defer { try? FileManager.default.removeItem(at: root) } + let registry = GitCredentialBrokerRegistry(path: root.path), ports = CredentialTestBrokerPorts() + let broker = try GitCredentialBroker(registry: registry, ports: ports.ports); defer { broker.shutdown() } + let registration = GitCredentialRegistration(context: ports.context, destinations: [try .init(url: "https://example.invalid/repo")], directory: "/private/credential", overrideRequired: false) + try broker.register(registration) + ports.unavailableContext(); broker.reconcile() + try MiniTest.expectEqual(try registry.load(), [registration], "a failed runtime observation is not proof of stop") + try MiniTest.expect(ports.calls.isEmpty) + }), + ("credentials.service.emptyBrokerRetiresSuccessfully", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = credentialServiceContext(root) + let service = GitCredentialBrokerService(context: context), lifetime = CredentialServiceTestLifetime() + DispatchQueue.global().async { + do { try service.run(ports: CredentialTestBrokerPorts().ports, shouldStop: { lifetime.shouldStop() }); lifetime.finish(true) } + catch { lifetime.finish(false) } + } + defer { lifetime.stop(); _ = lifetime.awaitFinish() } + Thread.sleep(forTimeInterval: 5.2) + try MiniTest.expect(lifetime.awaitFinish(), "an empty service must exit successfully without a signal") + try MiniTest.expect(!FileManager.default.fileExists(atPath: context.socket)) + }), + ("credentials.service.slowChannelCancelKeepsExitBound", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = credentialServiceContext(root) + let slow = CredentialSlowCancelChannel() + let identity = CredentialTestBrokerPorts(id: "slow").context + let ports = GitCredentialBrokerPorts(context: { _ in identity }, + connect: { _, _, _, _ in slow }, + acquire: { _, _, _ in nil }, + observe: { _, _ in .running(identity) }) + let service = GitCredentialBrokerService(context: context), lifetime = CredentialServiceTestLifetime() + DispatchQueue.global().async { + do { try service.run(ports: ports, shouldStop: { lifetime.shouldStop() }); lifetime.finish(true) } + catch { lifetime.finish(false) } + } + defer { lifetime.stop(); _ = lifetime.awaitFinish(within: 4) } + let deadline = Date().addingTimeInterval(3) + while !FileManager.default.fileExists(atPath: context.socket) && Date() < deadline { Thread.sleep(forTimeInterval: 0.01) } + let registration = GitCredentialRegistration(context: identity, destinations: [try .init(url: "https://example.invalid/repo")], + directory: "/private/credential", overrideRequired: false) + _ = try service.request(.init(operation: .register, registration: registration), activate: false) + let started = ProcessInfo.processInfo.systemUptime + lifetime.stop() + try MiniTest.expect(lifetime.awaitFinish(within: 8), "a slow channel blocked broker exit") + let elapsed = ProcessInfo.processInfo.systemUptime - started + try MiniTest.expect(elapsed <= 7, "slow channel shutdown took \(elapsed)s") + try MiniTest.expectEqual(slow.cancelCount, 1, "shutdown cancels the channel once") + print("slow channel exit: \(String(format: "%.3f", elapsed))s") + }), + ("credentials.service.slowChannelIdleExitStaysWithinBound", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = credentialServiceContext(root) + let slow = CredentialSlowCancelChannel(delay: 1) + let identity = CredentialTestBrokerPorts(id: "slow-idle").context + let ports = GitCredentialBrokerPorts(context: { _ in identity }, connect: { _, _, _, _ in slow }, + acquire: { _, _, _ in nil }, observe: { _, _ in .running(identity) }) + let service = GitCredentialBrokerService(context: context), lifetime = CredentialServiceTestLifetime() + DispatchQueue.global().async { + do { try service.run(ports: ports, shouldStop: { lifetime.shouldStop() }); lifetime.finish(true) } + catch { lifetime.finish(false) } + } + defer { lifetime.stop(); _ = lifetime.awaitFinish(within: 4) } + let deadline = Date().addingTimeInterval(3) + while !FileManager.default.fileExists(atPath: context.socket) && Date() < deadline { Thread.sleep(forTimeInterval: 0.01) } + let registration = GitCredentialRegistration(context: identity, destinations: [try .init(url: "https://example.invalid/repo")], + directory: "/private/credential", overrideRequired: false) + _ = try service.request(.init(operation: .register, registration: registration), activate: false) + let started = ProcessInfo.processInfo.systemUptime + _ = try service.request(.init(operation: .unregister, id: identity.id), activate: false) + try MiniTest.expect(lifetime.awaitFinish(within: 8), "a slow channel blocked idle exit") + let elapsed = ProcessInfo.processInfo.systemUptime - started + try MiniTest.expect(elapsed >= 4.5 && elapsed <= 7, "slow-channel idle exit took \(elapsed)s") + try MiniTest.expect(!FileManager.default.fileExists(atPath: context.socket), "idle exit left its control socket") + print("slow channel idle exit: \(String(format: "%.3f", elapsed))s") + }), + ("credentials.service.privateIPCRegisterRefreshTeardown", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = credentialServiceContext(root) + let service = GitCredentialBrokerService(context: context), ports = CredentialTestBrokerPorts(), lifetime = CredentialServiceTestLifetime() + DispatchQueue.global().async { + do { try service.run(ports: ports.ports, shouldStop: { lifetime.shouldStop() }); lifetime.finish(true) } + catch { lifetime.finish(false) } + } + defer { lifetime.stop(); _ = lifetime.awaitFinish() } + let deadline = Date().addingTimeInterval(3) + while !FileManager.default.fileExists(atPath: context.socket) && Date() < deadline { Thread.sleep(forTimeInterval: 0.01) } + let registration = GitCredentialRegistration(context: ports.context, destinations: [try .init(url: "https://example.invalid/repo")], directory: "/private/credential", overrideRequired: true) + _ = try service.request(.init(operation: .register, registration: registration, token: "synthetic-service"), activate: false) + try MiniTest.expect(ports.calls.isEmpty) + try MiniTest.expect(try service.request(.init(operation: .refresh, registration: registration, token: "synthetic-service", sources: ["https://example.invalid/sibling/repo"]), activate: false)) + try MiniTest.expectEqual(ports.calls, ["https://example.invalid/sibling/repo"]) + _ = try service.request(.init(operation: .unregister, id: ports.context.id), activate: false) + lifetime.stop(); try MiniTest.expect(lifetime.awaitFinish()) + try MiniTest.expect(!FileManager.default.fileExists(atPath: context.socket)) + try MiniTest.expect(try GitCredentialBrokerRegistry(path: context.state).load().isEmpty) + }), + ("credentials.broker.refreshMissIsNotAcquired", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = credentialServiceContext(root) + let service = GitCredentialBrokerService(context: context), ports = CredentialTestBrokerPorts(), lifetime = CredentialServiceTestLifetime() + ports.failAcquisition() + DispatchQueue.global().async { + do { try service.run(ports: ports.ports, shouldStop: { lifetime.shouldStop() }); lifetime.finish(true) } + catch { lifetime.finish(false) } + } + defer { lifetime.stop(); _ = lifetime.awaitFinish() } + let deadline = Date().addingTimeInterval(3) + while !FileManager.default.fileExists(atPath: context.socket) && Date() < deadline { Thread.sleep(forTimeInterval: 0.01) } + let registration = GitCredentialRegistration(context: ports.context, destinations: [try .init(url: "https://example.invalid/repo")], directory: "/private/credential", overrideRequired: true) + _ = try service.request(.init(operation: .register, registration: registration, token: "synthetic-service"), activate: false) + let acquired = try service.request(.init(operation: .refresh, registration: registration, token: "synthetic-service", sources: ["https://example.invalid/sibling/repo"]), activate: false) + try MiniTest.expect(!acquired, "a provider miss must not be reported as a refreshed credential") + try MiniTest.expectEqual(ports.calls, ["https://example.invalid/sibling/repo"]) + _ = try service.request(.init(operation: .unregister, id: ports.context.id), activate: false) + lifetime.stop(); try MiniTest.expect(lifetime.awaitFinish()) + try MiniTest.expect(!FileManager.default.fileExists(atPath: context.socket)) + try MiniTest.expect(try GitCredentialBrokerRegistry(path: context.state).load().isEmpty) + }), + ("credentials.service.isolatedContextFileIsPrivateAndTokenFree", { + let root = try credentialTestRoot(); defer { try? FileManager.default.removeItem(at: root) } + let short = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: short) } + let context = credentialServiceContext(short), identity = try credentialIdentity(context.executable) + try context.validate() + let probe = CredentialProbeFake(.absent), launcher = CredentialDetachedLauncher() + launcher.afterLaunch = { probe.set(.owner(identity)) } + let service = GitCredentialBrokerService(context: context, launcher: launcher, control: probe) + try service.ensure() + let contextFile = URL(fileURLWithPath: context.state + "/launch-context.json") + let original = try Data(contentsOf: contextFile) + let mode = try FileManager.default.attributesOfItem(atPath: contextFile.path)[.posixPermissions] as? NSNumber + try MiniTest.expectEqual(mode?.intValue, 0o600) + var unsafe = context; unsafe.providerEnvironment?["ADEVCONTAINER_GIT_TOKEN"] = "synthetic-must-not-persist" + let unsafeLauncher = CredentialDetachedLauncher() + try MiniTest.expectThrows({ + try GitCredentialBrokerService(context: unsafe, launcher: unsafeLauncher, control: probe).ensure() + }, validate: { _ in }) + try MiniTest.expectEqual(unsafeLauncher.launches.count, 0) + try MiniTest.expectEqual(try Data(contentsOf: contextFile), original) + }), + ("credentials.broker.reconciliationBackoffNoCredentialPolling", { + let root = try credentialTestRoot(); defer { try? FileManager.default.removeItem(at: root) } + let registry = GitCredentialBrokerRegistry(path: root.path), ports = CredentialTestBrokerPorts(), clock = CredentialTestClock() + let broker = try GitCredentialBroker(registry: registry, ports: ports.ports, clock: { clock.now() }); defer { broker.shutdown() } + let registration = GitCredentialRegistration(context: ports.context, destinations: [try .init(url: "https://example.invalid/org/repo")], directory: "/private/credential", overrideRequired: false) + try broker.register(registration) + try broker.register(registration); broker.reconcile() + try MiniTest.expectEqual(ports.connectionCount, 1); try MiniTest.expect(ports.calls.isEmpty) + ports.failChannel(); broker.reconcile(); try MiniTest.expectEqual(ports.connectionCount, 1) + clock.advance(1); broker.reconcile(); try MiniTest.expectEqual(ports.connectionCount, 2) + try MiniTest.expectEqual(try ports.request(1).fields[.password], "synthetic") + ports.invalidateContext(); broker.reconcile() + try MiniTest.expect(try registry.load().isEmpty) + try MiniTest.expectEqual(ports.calls.count, 1) + }), + ("credentials.broker.authorizedFreshAndRestartOverrideFence", { + let root = try credentialTestRoot(); defer { try? FileManager.default.removeItem(at: root) } + let registry = GitCredentialBrokerRegistry(path: root.path), ports = CredentialTestBrokerPorts() + let broker = try GitCredentialBroker(registry: registry, ports: ports.ports); defer { broker.shutdown() } + let registration = GitCredentialRegistration(context: ports.context, destinations: [try .init(url: "https://example.invalid/org/repo")], directory: "/private/credential", overrideRequired: true) + try broker.register(registration, override: "synthetic-override") + let first = try ports.request(1) + try MiniTest.expectEqual(first.fields[.password], "synthetic-override"); try MiniTest.expectEqual(first.fields[.username], "guest") + let miss = try ports.request(2, host: "unknown.invalid"); try MiniTest.expect(miss.fields.isEmpty) + _ = try ports.request(3); try MiniTest.expectEqual(ports.calls.count, 2) + try MiniTest.expect(ports.calls.allSatisfy { $0 == "https://example.invalid/sibling/repo" }) + let persisted = try String(contentsOf: root.appendingPathComponent("registrations.json"), encoding: .utf8) + try MiniTest.expect(!persisted.contains("synthetic-override")) + broker.shutdown() + let recovered = try GitCredentialBroker(registry: registry, ports: ports.ports); defer { recovered.shutdown() } + recovered.reconcile(); try MiniTest.expectEqual(ports.calls.count, 2) + try MiniTest.expectThrows({ try recovered.register(registration) }, validate: { _ in }) + try recovered.register(registration, override: "replacement-synthetic") + let replacement = try ports.request(1) + try MiniTest.expectEqual(replacement.fields[.password], "replacement-synthetic") + let obsoleteChannel = ports.receiver + try recovered.register(registration, override: "newer-synthetic") + let newer = try ports.request(1) + try MiniTest.expectEqual(newer.generation, replacement.generation + 1) + obsoleteChannel?(.init(operation: .get, request: 999, generation: newer.generation, fields: [.scheme: "https", .host: "example.invalid", .path: "sibling/repo"])) + try MiniTest.expectEqual(try ports.request(2).request, 2); try MiniTest.expectEqual(ports.calls.count, 5) + try recovered.unregister("synthetic"); try MiniTest.expect(try registry.load().isEmpty) + }), + ("credentials.broker.secondGetReturnsChangedProviderPassword", { + let root = try credentialTestRoot(); defer { try? FileManager.default.removeItem(at: root) } + let registry = GitCredentialBrokerRegistry(path: root.path), ports = CredentialTestBrokerPorts() + let broker = try GitCredentialBroker(registry: registry, ports: ports.ports); defer { broker.shutdown() } + let registration = GitCredentialRegistration(context: ports.context, destinations: [try .init(url: "https://example.invalid/org/repo")], directory: "/private/credential", overrideRequired: false) + ports.enqueuePasswords(["first-synthetic", "second-synthetic"]) + try broker.register(registration) + let first = try ports.request(1) + let second = try ports.request(2) + try MiniTest.expectEqual(first.fields[.password], "first-synthetic") + try MiniTest.expectEqual(second.fields[.password], "second-synthetic", "second get must return the new provider answer, not the previous credential") + try MiniTest.expectEqual(ports.calls.count, 2) + }), + ("credentials.broker.replacedIncarnationDoesNotInheritOverride", { + let root = try credentialTestRoot(); defer { try? FileManager.default.removeItem(at: root) } + let registry = GitCredentialBrokerRegistry(path: root.path), ports = CredentialTestBrokerPorts() + let broker = try GitCredentialBroker(registry: registry, ports: ports.ports); defer { broker.shutdown() } + let registration = GitCredentialRegistration(context: ports.context, destinations: [try .init(url: "https://example.invalid/org/repo")], directory: "/private/credential", overrideRequired: true) + try broker.register(registration, override: "old-synthetic-override") + _ = try ports.request(1) + let replacement = GitCredentialRuntimeContext(id: ports.context.id, incarnation: "reference:800000001", platform: ports.context.platform, user: ports.context.user, labels: ports.context.labels) + ports.replaceContext(replacement) + broker.reconcile() + try MiniTest.expect(try registry.load().isEmpty, "a new same-named incarnation must not keep the failed session") + try MiniTest.expectEqual(ports.calls.count, 1) + let restarted = try GitCredentialBroker(registry: registry, ports: ports.ports); defer { restarted.shutdown() } + let fresh = GitCredentialRegistration(context: replacement, destinations: registration.destinations, directory: registration.directory, overrideRequired: false) + try restarted.register(fresh) + let answer = try ports.request(1) + try MiniTest.expectEqual(answer.fields[.password], "synthetic") + try MiniTest.expect(ports.calls.allSatisfy { !$0.contains("old-synthetic-override") }) + try MiniTest.expect(!((try? String(contentsOf: root.appendingPathComponent("registrations.json"), encoding: .utf8)) ?? "").contains("old-synthetic-override")) + }), + ("credentials.registry.privateAtomicNonsecret", { + let root = try credentialTestRoot() + defer { try? FileManager.default.removeItem(at: root) } + let registry = GitCredentialBrokerRegistry(path: root.path) + func step(_ name: String, _ body: () throws -> T) throws -> T { + do { return try body() } catch { throw MiniTest.Failure(message: "registry \(name): \(error)") } + } + try MiniTest.expect(try step("initial load") { try registry.load() }.isEmpty) + let context = GitCredentialRuntimeContext(id: "synthetic", incarnation: "reference:800000000", platform: "linux/arm64", user: "root", labels: ["devcontainer.managed": "adevcontainer"]) + let registration = GitCredentialRegistration(context: context, destinations: [try .init(url: "https://example.invalid/org/repo")], directory: "/home/synthetic/.adevcontainer/git", overrideRequired: true) + try step("first save") { try registry.save([registration]) }; try MiniTest.expectEqual(try step("first load") { try registry.load() }, [registration]) + let url = root.appendingPathComponent("registrations.json") + let bytes = try Data(contentsOf: url) + try MiniTest.expect(!String(decoding: bytes, as: UTF8.self).contains("password")) + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + try MiniTest.expectEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o600) + try step("empty save") { try registry.save([]) }; try MiniTest.expect(try step("empty load") { try registry.load() }.isEmpty) + try Data("corrupt".utf8).write(to: url) + try MiniTest.expectThrows({ _ = try registry.load() }, validate: { _ in }) + try FileManager.default.removeItem(at: url) + try FileManager.default.createSymbolicLink(atPath: url.path, withDestinationPath: "/does-not-exist-synthetic") + try MiniTest.expectThrows({ try registry.save([registration]) }, validate: { _ in }) + try MiniTest.expectThrows({ _ = try registry.load() }, validate: { _ in }) + }), + ("credentials.registry.unsafeDirectoryAndLabels", { + let root = try credentialTestRoot() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o755]) + defer { try? FileManager.default.removeItem(at: root) } + try MiniTest.expectThrows({ try GitCredentialBrokerRegistry(path: root.path).save([]) }, validate: { _ in }) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) + let context = GitCredentialRuntimeContext(id: "synthetic", incarnation: "reference:800000000", platform: "linux/arm64", user: "root", labels: ["private-token": "must-not-persist"]) + let registration = GitCredentialRegistration(context: context, destinations: [], directory: "/private/helper", overrideRequired: false) + try MiniTest.expectThrows({ try GitCredentialBrokerRegistry(path: root.path).save([registration]) }, validate: { _ in }) + }) +] diff --git a/Tests/adevcontainerTests/GitCredentialBrokerSmokeHarness.swift b/Tests/adevcontainerTests/GitCredentialBrokerSmokeHarness.swift new file mode 100644 index 0000000..0b9903e --- /dev/null +++ b/Tests/adevcontainerTests/GitCredentialBrokerSmokeHarness.swift @@ -0,0 +1,115 @@ +import Foundation +import Crypto +import Darwin +@testable import ADevContainerLib + +struct GitCredentialSmokeManifest: Codable, Equatable, Sendable { + var label: String + var state: String + var endpoints: String + var executable: String + var providerConfig: String + var helper: String + var containers: [String] + /// pids started by this fixture. Cleanup signals only these processes. + var processes: [Int32] + var productionState: String +} + +enum GitCredentialBrokerSmokeHarness { + static let productionLabel = GitCredentialLaunchContext.productionLabel + static func isolatedContext(root: URL, executable: String = "/usr/bin/true") -> GitCredentialLaunchContext { + let label = "com.adevcontainer.git-credentials.test.\(UUID().uuidString)" + let state = root.appendingPathComponent("state").path + return GitCredentialLaunchContext(label: label, state: state, endpoints: root.path, executable: executable, providerEnvironment: syntheticProviderEnvironment(state: state)) + } + static func syntheticProviderEnvironment(state: String) -> [String: String] { + ["PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "HOME": state, "GIT_CONFIG_GLOBAL": state + "/credential-smoke.gitconfig", "GIT_CONFIG_SYSTEM": "/dev/null", "GIT_CONFIG_NOSYSTEM": "1"] + } + static func prepare(_ context: GitCredentialLaunchContext) throws -> GitCredentialSmokeManifest { + try context.validate() + guard context.label != productionLabel else { throw GitCredentialFailure.invalidContext } + let endpoints = try GitCredentialPrivateFiles.directory(context.endpoints, create: true); close(endpoints) + let state = context.state + try FileManager.default.createDirectory(atPath: state, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + let helper = state + "/credential-smoke-helper" + let config = state + "/credential-smoke.gitconfig" + let script = "#!/bin/sh\nprintf '%s\\n' 'username=synthetic' 'password=synthetic-smoke' ''\n" + try Data(script.utf8).write(to: URL(fileURLWithPath: helper), options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helper) + let gitconfig = "[credential \"https://credential-smoke.invalid\"]\n\thelper = !\(helper)\n" + try Data(gitconfig.utf8).write(to: URL(fileURLWithPath: config), options: .atomic) + return GitCredentialSmokeManifest(label: context.label, state: state, endpoints: context.endpoints, executable: context.executable, + providerConfig: config, helper: helper, containers: [], processes: [], productionState: "") + } + static func fingerprint(_ path: String) -> String { + guard !path.isEmpty, let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let size = attributes[.size] as? NSNumber, let modified = attributes[.modificationDate] as? Date else { return "absent" } + let material = Data("\(size.intValue)\n\(modified.timeIntervalSince1970)\n".utf8) + return SHA256.hash(data: material).map { String(format: "%02x", $0) }.joined() + } + /// No launchd surface exists: cleanup terminates recorded test-owned pids + /// and removes owned paths. It never touches a production broker. + static func cleanup(_ manifest: GitCredentialSmokeManifest) throws { + try validateOwned(manifest) + for pid in manifest.processes where pid > 1 && pid != getpid() { + guard kill(pid, 0) == 0 else { continue } + _ = kill(pid, SIGTERM) + let deadline = ProcessInfo.processInfo.systemUptime + 3 + while kill(pid, 0) == 0, ProcessInfo.processInfo.systemUptime < deadline { Thread.sleep(forTimeInterval: 0.025) } + if kill(pid, 0) == 0 { _ = kill(pid, SIGKILL) } + } + for path in [manifest.helper, manifest.providerConfig, manifest.state, manifest.endpoints] where !path.isEmpty { + try removeOwned(path, root: manifest.endpoints) + } + } + static func protect(_ manifest: GitCredentialSmokeManifest, _ body: () throws -> T) throws -> T { + do { return try body() } + catch { + try cleanup(manifest) + throw error + } + } + static func unhandleableInstructions(_ manifest: GitCredentialSmokeManifest) -> String { + let containers = manifest.containers.isEmpty ? "(none)" : manifest.containers.joined(separator: " ") + let pids = manifest.processes.isEmpty ? "(none)" : manifest.processes.map(String.init).joined(separator: " ") + return """ + Targeted cleanup after unhandleable termination of \(manifest.label). + Do not signal any pid that is not listed here. + Do not touch \(productionLabel). + Do not delete \(manifest.productionState.isEmpty ? "(no production state recorded)" : manifest.productionState). + Test-owned broker pids: \(pids) + kill -TERM \(pids) + rm -f -- \(manifest.helper) \(manifest.providerConfig) + rm -rf -- \(manifest.state) \(manifest.endpoints) + Exclusively created containers: \(containers) + """ + } + static func liveRefusedReason(_ environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + guard environment["ADEVCONTAINER_CREDENTIAL_SMOKE"] == "1" else { + return "Credential smoke is opt-in. Set ADEVCONTAINER_CREDENTIAL_SMOKE=1 only after reading Tests/GuestGitCredentialRelay/SMOKE.md." + } + guard environment["ADEVCONTAINER_CREDENTIAL_SMOKE_AUTHORIZED"] == "1" else { + return "Live credential smoke requires separate authorization. No broker process, container, keychain, or provider was contacted." + } + return "Live arm64 credential smoke is not part of the automatic suite. Follow Tests/GuestGitCredentialRelay/SMOKE.md; this process starts no broker." + } + private static func validateOwned(_ manifest: GitCredentialSmokeManifest) throws { + let prefix = "com.adevcontainer.git-credentials.test." + guard manifest.label != productionLabel, manifest.label.hasPrefix(prefix), UUID(uuidString: String(manifest.label.dropFirst(prefix.count))) != nil else { throw GitCredentialFailure.invalidContext } + let root = try credentialTestBase().path + "/" + for path in [manifest.state, manifest.endpoints, manifest.providerConfig, manifest.helper] { + guard path.hasPrefix(root), !path.split(separator: "/").contains(".."), path != String(root.dropLast()) else { throw GitCredentialFailure.unsafePath } + } + if !manifest.productionState.isEmpty { + guard manifest.productionState != manifest.state else { throw GitCredentialFailure.unsafePath } + } + } + private static func removeOwned(_ path: String, root: String) throws { + var info = stat() + guard lstat(path, &info) == 0 else { return } + guard info.st_mode & S_IFMT != S_IFLNK else { throw GitCredentialFailure.unsafePath } + guard path == root || path.hasPrefix(root + "/") else { throw GitCredentialFailure.unsafePath } + try FileManager.default.removeItem(atPath: path) + } +} diff --git a/Tests/adevcontainerTests/GitCredentialBrokerSmokeTests.swift b/Tests/adevcontainerTests/GitCredentialBrokerSmokeTests.swift new file mode 100644 index 0000000..c5a0d33 --- /dev/null +++ b/Tests/adevcontainerTests/GitCredentialBrokerSmokeTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Darwin +@testable import ADevContainerLib + +@MainActor let gitCredentialBrokerSmokeTests: [(String, () throws -> Void)] = [ + ("credentials.smoke.uniqueContextLeavesProductionUntouched", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let production = root.appendingPathComponent("production-fixture") + try FileManager.default.createDirectory(at: production, withIntermediateDirectories: true) + let productionState = production.appendingPathComponent("registrations.json") + try Data("production-not-a-secret\n".utf8).write(to: productionState) + let before = GitCredentialBrokerSmokeHarness.fingerprint(productionState.path) + let context = GitCredentialBrokerSmokeHarness.isolatedContext(root: root.appendingPathComponent("job")) + try context.validate() + try MiniTest.expect(context.label.hasPrefix("com.adevcontainer.git-credentials.test.")) + try MiniTest.expect(context.label != GitCredentialBrokerSmokeHarness.productionLabel) + try MiniTest.expect(context.providerEnvironment?["ADEVCONTAINER_GIT_TOKEN"] == nil) + try MiniTest.expectEqual(context.providerEnvironment?["GIT_CONFIG_NOSYSTEM"], "1") + try MiniTest.expect(context.providerEnvironment?["GIT_CONFIG_GLOBAL"]?.hasPrefix(context.state + "/") == true) + var manifest = try GitCredentialBrokerSmokeHarness.prepare(context) + manifest.productionState = production.path + let identity = try credentialIdentity(context.executable) + let probe = CredentialProbeFake(.absent), launcher = CredentialDetachedLauncher() + launcher.afterLaunch = { probe.set(.owner(identity)) } + try GitCredentialBrokerService(context: context, launcher: launcher, control: probe).ensure() + guard let launch = launcher.launches.first else { throw MiniTest.Failure(message: "no detached launch recorded") } + try MiniTest.expectEqual(launch.executable, context.executable) + try MiniTest.expect(!launch.arguments.contains { $0.contains(".plist") || $0.contains("launchctl") }) + try MiniTest.expect(launch.environment.allSatisfy { $0.key != "ADEVCONTAINER_GIT_TOKEN" }) + try MiniTest.expect(!FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/" + context.label + ".plist"), + "no LaunchAgent plist may exist for this context") + try GitCredentialBrokerSmokeHarness.cleanup(manifest) + try MiniTest.expectEqual(GitCredentialBrokerSmokeHarness.fingerprint(productionState.path), before) + try MiniTest.expect(!FileManager.default.fileExists(atPath: manifest.state)) + try MiniTest.expect(!FileManager.default.fileExists(atPath: manifest.endpoints)) + }), + ("credentials.smoke.syntheticProviderAndFailureCleanup", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = GitCredentialBrokerSmokeHarness.isolatedContext(root: root) + var manifest = try GitCredentialBrokerSmokeHarness.prepare(context) + let config = try String(contentsOfFile: manifest.providerConfig, encoding: .utf8) + try MiniTest.expect(config.contains("https://credential-smoke.invalid")) + try MiniTest.expect(!config.contains("ADEVCONTAINER_GIT_TOKEN")) + let environment = GitCredentialBrokerSmokeHarness.syntheticProviderEnvironment(state: manifest.state) + let query = try CredentialProviderProcessRunner(timeout: 3).run(executable: "/usr/bin/git", arguments: ["-c", "credential.helper=", "-c", "credential.helper=!\(manifest.helper)", "credential", "fill"], environment: environment, currentDirectory: nil, stdinData: Data("protocol=https\nhost=credential-smoke.invalid\n\n".utf8)) + try MiniTest.expect(query.succeeded) + try MiniTest.expect(query.stdoutString.contains("password=synthetic-smoke\n")) + // A recorded test-owned process is terminated by the manifest alone. + let standIn = Process() + standIn.executableURL = URL(fileURLWithPath: "/bin/sleep") + standIn.arguments = ["30"] + try standIn.run() + manifest.processes = [standIn.processIdentifier] + try MiniTest.expectThrows({ + try GitCredentialBrokerSmokeHarness.protect(manifest) { throw GitCredentialFailure.transport } + }, validate: { _ in }) + standIn.waitUntilExit() + try MiniTest.expectEqual(standIn.terminationStatus, 15, "cleanup signals only the recorded test-owned pid") + try MiniTest.expect(!FileManager.default.fileExists(atPath: manifest.helper)) + try MiniTest.expect(!FileManager.default.fileExists(atPath: manifest.endpoints)) + }), + ("credentials.smoke.handledSignalAndUnhandleableManifest", { + let root = try credentialShortRoot(); defer { try? FileManager.default.removeItem(at: root) } + let context = GitCredentialBrokerSmokeHarness.isolatedContext(root: root) + var manifest = try GitCredentialBrokerSmokeHarness.prepare(context) + manifest.containers = ["synthetic-container-only"] + manifest.processes = [4242] + manifest.productionState = "/Users/example/Library/Application Support/adevcontainer/git-credentials" + try GitCredentialBrokerSmokeHarness.cleanup(manifest) + try MiniTest.expect(!FileManager.default.fileExists(atPath: manifest.endpoints)) + let instructions = GitCredentialBrokerSmokeHarness.unhandleableInstructions(manifest) + try MiniTest.expect(instructions.contains("kill -TERM 4242")) + try MiniTest.expect(instructions.contains("Do not touch \(GitCredentialBrokerSmokeHarness.productionLabel)")) + try MiniTest.expect(instructions.contains("synthetic-container-only")) + try MiniTest.expect(!instructions.contains("launchctl")) + try MiniTest.expect(!instructions.contains(".plist")) + try MiniTest.expect(GitCredentialBrokerSmokeHarness.liveRefusedReason(["ADEVCONTAINER_CREDENTIAL_SMOKE": "1"])?.contains("No broker process") == true) + try MiniTest.expect(GitCredentialBrokerSmokeHarness.liveRefusedReason(["ADEVCONTAINER_CREDENTIAL_SMOKE": "1", "ADEVCONTAINER_CREDENTIAL_SMOKE_AUTHORIZED": "1"])?.contains("starts no broker") == true) + var production = manifest + production.label = GitCredentialBrokerSmokeHarness.productionLabel + try MiniTest.expectThrows({ try GitCredentialBrokerSmokeHarness.cleanup(production) }, validate: { _ in }) + }) +] diff --git a/Tests/adevcontainerTests/GitCredentialConnectionTests.swift b/Tests/adevcontainerTests/GitCredentialConnectionTests.swift new file mode 100644 index 0000000..5f83245 --- /dev/null +++ b/Tests/adevcontainerTests/GitCredentialConnectionTests.swift @@ -0,0 +1,222 @@ +import Foundation +@testable import ADevContainerLib + +final class CredentialConnectionRecorder: @unchecked Sendable { + private let lock = NSLock() + private var values: [GitCredentialControlRequest] = [] + func record(_ request: GitCredentialControlRequest, _ activate: Bool) { lock.lock(); values.append(request); lock.unlock() } + var requests: [GitCredentialControlRequest] { lock.lock(); defer { lock.unlock() }; return values } +} +@MainActor let gitCredentialConnectionTests: [(String, () throws -> Void)] = [ + ("credentials.connection.migrationPreservesUserOrderAndUnmarkedStores", { + let root = try credentialTestRoot() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + defer { try? FileManager.default.removeItem(at: root) } + let home = root.appendingPathComponent("home with 'quotes"), bin = root.appendingPathComponent("bin"), base = root.appendingPathComponent("endpoints"), directory = root.appendingPathComponent("endpoints/s-test") + for path in [home, home.appendingPathComponent(".adevcontainer"), bin, base, directory] { + try FileManager.default.createDirectory(at: path, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + } + // Exercise the production migration shell on macOS with only the Linux + // stat formatting port adapted. Git reads this isolated config, never a + // user credential helper; no fill/approve/reject is invoked. + let stat = bin.appendingPathComponent("stat") + try Data("#!/bin/sh\ncase \"$2\" in %u) exec /usr/bin/stat -f %u \"$3\" ;; %a) exec /usr/bin/stat -f %Lp \"$3\" ;; *) exit 1 ;; esac\n".utf8).write(to: stat) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: stat.path) + let environment = ["HOME": home.path, "PATH": bin.path + ":/usr/bin:/bin", "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_SYSTEM": "/dev/null", "GIT_CONFIG_GLOBAL": home.appendingPathComponent(".gitconfig").path] + let runner = FoundationProcessRunner() + func git(_ arguments: [String]) throws -> ProcessResult { + try runner.run(executable: "/usr/bin/git", arguments: ["config", "--global"] + arguments, environment: environment, currentDirectory: nil, stdinData: nil) + } + let helper = home.appendingPathComponent(".adevcontainer/git-credential-adev").path + let helperCommand = "!exec '" + helper.replacingOccurrences(of: "'", with: "'\\''") + "'" + for value in ["earlier-user-helper", "later-user-helper"] { try MiniTest.expect(try git(["--add", "credential.helper", value]).succeeded) } + for value in ["", "azure-user-one", helper, "azure-user-two", helper] { + try MiniTest.expect(try git(["--add", "credential.https://dev.azure.com.helper", value]).succeeded) + } + try MiniTest.expect(try git(["--add", "credential.http://dev.azure.com.helper", "http-user-helper"]).succeeded) + let oldStore = home.appendingPathComponent(".adevcontainer/git-credentials"), userStore = home.appendingPathComponent(".git-credentials") + for path in [oldStore, userStore] { + try Data("synthetic-store-marker\n".utf8).write(to: path) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path.path) + } + func install(_ scopes: [String]) throws { + let temporary = directory.appendingPathComponent(".relay.test") + try Data("synthetic-not-executed-relay".utf8).write(to: temporary) + let result = try runner.run(executable: "/bin/sh", arguments: ["-ceu", GitCredentialConnection.installScript, "synthetic-install", home.path, directory.path, temporary.path, "1", "synthetic"] + scopes, environment: environment, currentDirectory: nil, stdinData: Data(GitCredentialConnection.wrapper.utf8)) + try MiniTest.expect(result.succeeded, "sandbox migration failed") + } + try install(["https://dev.azure.com"]) + try install(["https://dev.azure.com"]) + try MiniTest.expectEqual(try git(["--get-all", "credential.helper"]).stdoutString, "earlier-user-helper\nlater-user-helper\n\(helperCommand)\n") + try MiniTest.expectEqual(try git(["--get-all", "credential.https://dev.azure.com.helper"]).stdoutString, "azure-user-one\nazure-user-two\n\(helperCommand)\n") + try MiniTest.expectEqual(try git(["--get", "credential.https://dev.azure.com.useHttpPath"]).stdoutString, "true\n") + try MiniTest.expectEqual(try git(["--get-all", "credential.http://dev.azure.com.helper"]).stdoutString, "http-user-helper\n") + try MiniTest.expect(!FileManager.default.fileExists(atPath: oldStore.path)) + try MiniTest.expectEqual(try String(contentsOf: userStore, encoding: .utf8), "synthetic-store-marker\n") + try install(["http://dev.azure.com"]) + try MiniTest.expectEqual(try git(["--get-all", "credential.http://dev.azure.com.helper"]).stdoutString, "http-user-helper\n\(helperCommand)\n") + try MiniTest.expectEqual(try git(["--get", "credential.http://dev.azure.com.useHttpPath"]).stdoutString, "true\n") + // Execute only a fixture helper in the isolated config to prove the + // installed command survives spaces/quotes without a host lookup. + try Data("#!/bin/sh\nprintf 'username=synthetic\\npassword=synthetic\\n\\n'\n".utf8).write(to: URL(fileURLWithPath: helper)) + var queryEnvironment = environment + queryEnvironment["GIT_TERMINAL_PROMPT"] = "0"; queryEnvironment["GIT_ASKPASS"] = "/usr/bin/false" + let query = try runner.run(executable: "/usr/bin/git", arguments: ["-c", "credential.helper=", "-c", "credential.helper=" + helperCommand, "credential", "fill"], environment: queryEnvironment, currentDirectory: nil, stdinData: Data("protocol=https\nhost=credential-smoke.invalid\n\n".utf8)) + try MiniTest.expect(query.succeeded) + try MiniTest.expect(query.stdoutString.contains("password=synthetic\n")) + }), + ("credentials.connection.azureMigrationKeepsUserHelpersWhenAddFails", { + let root = try credentialTestRoot() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + defer { try? FileManager.default.removeItem(at: root) } + let home = root.appendingPathComponent("home"), bin = root.appendingPathComponent("bin"), directory = root.appendingPathComponent("endpoints/s-test") + for path in [home, home.appendingPathComponent(".adevcontainer"), bin, directory] { + try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + } + let stat = bin.appendingPathComponent("stat") + try Data("#!/bin/sh\ncase \"$2\" in %u) exec /usr/bin/stat -f %u \"$3\" ;; %a) exec /usr/bin/stat -f %Lp \"$3\" ;; *) exit 1 ;; esac\n".utf8).write(to: stat) + let gitWrapper = bin.appendingPathComponent("git") + try Data("#!/bin/sh\nfor arg in \"$@\"; do [ \"$arg\" = \"--add\" ] && exit 1; done\nexec /usr/bin/git \"$@\"\n".utf8).write(to: gitWrapper) + for path in [stat, gitWrapper] { + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: path.path) + } + let config = home.appendingPathComponent(".gitconfig") + let environment = ["HOME": home.path, "PATH": bin.path + ":/usr/bin:/bin", "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_SYSTEM": "/dev/null", "GIT_CONFIG_GLOBAL": config.path] + let runner = FoundationProcessRunner() + for value in ["user-helper-one", "user-helper-two"] { + let seeded = try runner.run(executable: "/usr/bin/git", arguments: ["config", "--global", "--add", "credential.https://dev.azure.com.helper", value], environment: environment, currentDirectory: nil, stdinData: nil) + try MiniTest.expect(seeded.succeeded) + } + let temporary = directory.appendingPathComponent(".relay.test") + try Data("synthetic-not-executed-relay".utf8).write(to: temporary) + let result = try runner.run(executable: "/bin/sh", arguments: ["-ceu", GitCredentialConnection.installScript, "synthetic-install", home.path, directory.path, temporary.path, "0", "synthetic", "https://dev.azure.com"], environment: environment, currentDirectory: nil, stdinData: Data(GitCredentialConnection.wrapper.utf8)) + let remaining = try runner.run(executable: "/usr/bin/git", arguments: ["config", "--global", "--get-all", "credential.https://dev.azure.com.helper"], environment: environment, currentDirectory: nil, stdinData: nil) + try MiniTest.expect(!result.succeeded, "a failed helper insert must fail the migration") + try MiniTest.expectEqual(remaining.stdoutString, "user-helper-one\nuser-helper-two\n") + try MiniTest.expect(!result.stderrString.contains("user-helper-one")) + try MiniTest.expect(!result.stdoutString.contains("user-helper")) + }), + ("credentials.connection.singleBudgetStopsAfterSlowContext", { + let runner = MockProcessRunner(), recorder = CredentialConnectionRecorder() + let fixture: [String: Any] = ["configuration": ["id": "synthetic", "creationDate": 800000000, "platform": ["os": "linux", "architecture": "arm64"], "labels": ["devcontainer.managed": "adevcontainer", "devcontainer.remote_user": "root"]], "status": ["state": "running"]] + let bytes = try JSONSerialization.data(withJSONObject: fixture) + runner.handlers = [{ args in + if args.first == "inspect" { Thread.sleep(forTimeInterval: 0.04); return .init(exitCode: 0, stdout: bytes, stderr: Data()) } + return .init(exitCode: 0, stdout: Data(), stderr: Data()) + }] + let connection = GitCredentialConnection(runtime: .init(executablePath: "/synthetic/container", runner: runner), control: { request, activate, _ in recorder.record(request, activate); return true }, timeout: 0.02) + try MiniTest.expectThrows({ _ = try connection.setup(id: "synthetic", hostWorkspace: nil, gitURL: "https://example.invalid/repo") }) { error in + try MiniTest.expectEqual(error as? GitCredentialFailure, .deadline) + } + try MiniTest.expectEqual(runner.calls.count, 1, "an expired context budget must not start provisioning") + try MiniTest.expect(recorder.requests.isEmpty) + }), + ("credentials.connection.sharedHomeIncarnationsHaveDifferentEndpoints", { + let first = GitCredentialRuntimeContext(id: "one", incarnation: "reference:800000000", platform: "linux/arm64", user: "root", labels: [:]) + let second = GitCredentialRuntimeContext(id: first.id, incarnation: "reference:800000001", platform: first.platform, user: first.user, labels: [:]) + try MiniTest.expectEqual(GitCredentialConnection.namespace(first).count, 45) + try MiniTest.expect(GitCredentialConnection.namespace(first) != GitCredentialConnection.namespace(second)) + try MiniTest.expect(GitCredentialConnection.wrapper.contains("/proc/sys/kernel/hostname")) + try MiniTest.expect(!GitCredentialConnection.wrapper.contains("${0%/*}/git-broker")) + }), + ("credentials.connection.skipMissingSourcesWithoutServiceOrExec", { + let runner = MockProcessRunner(), recorder = CredentialConnectionRecorder() + let connection = GitCredentialConnection(runtime: .init(executablePath: "/synthetic/container", runner: runner), control: { request, activate, _ in recorder.record(request, activate); return true }, gitPathOverride: .some(nil)) + try MiniTest.expect(try !connection.setup(id: "synthetic", hostWorkspace: nil, gitURL: nil)) + try MiniTest.expect(try !connection.setup(id: "synthetic", hostWorkspace: "/synthetic/workspace", gitURL: nil)) + try MiniTest.expect(runner.calls.isEmpty && recorder.requests.isEmpty) + }), + ("credentials.connection.assetReadBackAndRegistrationBeforeReady", { + let runner = MockProcessRunner(), recorder = CredentialConnectionRecorder() + let artifact = try GuestGitCredentialArtifact.select(platform: "linux/arm64") + let fixture: [String: Any] = ["configuration": ["id": "synthetic", "creationDate": 800000000, "platform": ["os": "linux", "architecture": "arm64"], "labels": ["devcontainer.managed": "adevcontainer", "devcontainer.remote_user": "root"]], "status": ["state": "running"]] + let bytes = try JSONSerialization.data(withJSONObject: fixture) + runner.stdinHandlers = [{ args, input in + if args.first == "inspect" { return .init(exitCode: 0, stdout: bytes, stderr: Data()) } + if args.contains("adevcontainer-git-prepare"), let namespace = args.last { + let directory = "/tmp/.adevcontainer-git-0/" + namespace + return .init(exitCode: 0, stdout: Data("/home/root\n\(directory)\n\(directory)/.relay.abcdef\n0\n".utf8), stderr: Data()) + } + if args.contains("cat") { return .init(exitCode: 0, stdout: artifact.bytes, stderr: Data()) } + return .init(exitCode: 0, stdout: Data(), stderr: Data()) + }] + let connection = GitCredentialConnection(runtime: .init(executablePath: "/synthetic/container", runner: runner), control: { request, activate, _ in recorder.record(request, activate); return true }, token: "synthetic-override") + try MiniTest.expect(try connection.setup(id: "synthetic", hostWorkspace: nil, gitURL: "https://dev.azure.com/org/repo")) + try MiniTest.expectEqual(recorder.requests.first?.registration?.destinations, [try GitCredentialDestination(url: "https://dev.azure.com/org/repo")]) + try MiniTest.expectEqual(recorder.requests.first?.token, "synthetic-override") + try MiniTest.expect(runner.calls.allSatisfy { !$0.arguments.joined().contains("synthetic-override") && $0.environment?["ADEVCONTAINER_GIT_TOKEN"] == nil }) + try MiniTest.expect(runner.calls.contains { $0.stdinData == artifact.bytes }) + try MiniTest.expect(runner.calls.contains { $0.arguments.contains("adevcontainer-git-install") }) + try MiniTest.expect(runner.calls.contains { $0.arguments.contains("-S") }) + try connection.unregister(id: "synthetic") + try MiniTest.expectEqual(recorder.requests.last?.operation, .unregister) + }), + ("credentials.connection.ownedLooseDirectoryIsTightenedWithoutFollowingSymlink", { + let root = try credentialTestRoot() + defer { try? FileManager.default.removeItem(at: root) } + let bin = root.appendingPathComponent("bin") + let home = root.appendingPathComponent("home") + let loose = home.appendingPathComponent(".adevcontainer") + let marker = loose.appendingPathComponent("vscode-customizations.applied") + let foreign = root.appendingPathComponent("foreign") + let link = root.appendingPathComponent("link") + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: loose, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o755]) + try Data("marker\n".utf8).write(to: marker) + try FileManager.default.createDirectory(at: foreign, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o755]) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: foreign) + let stat = bin.appendingPathComponent("stat") + try Data("#!/bin/sh\ncase \"$2\" in %u) exec /usr/bin/stat -f %u \"$3\" ;; %a) exec /usr/bin/stat -f %Lp \"$3\" ;; *) exit 1 ;; esac\n".utf8).write(to: stat) + let chmodLog = root.appendingPathComponent("chmod.log") + let chmod = bin.appendingPathComponent("chmod") + try Data("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"\(chmodLog.path)\"\nexec /bin/chmod \"$@\"\n".utf8).write(to: chmod) + for path in [stat, chmod] { + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: path.path) + } + let uid = String(getuid()) + let environment = ["PATH": bin.path + ":/bin:/usr/bin"] + let runner = FoundationProcessRunner() + func repair(_ path: String) throws -> ProcessResult { + try runner.run(executable: "/bin/sh", arguments: ["-c", GitCredentialConnection.privateDirectoryRepair + "ensure_private_dir \"$1\" \"$2\"", "repair", path, uid], environment: environment, currentDirectory: nil, stdinData: nil) + } + let tightened = try repair(loose.path) + try MiniTest.expect(tightened.succeeded, "owned 0755 product directory must be tightened") + let mode = try FileManager.default.attributesOfItem(atPath: loose.path)[.posixPermissions] as? NSNumber + try MiniTest.expectEqual(mode?.intValue ?? 0, 0o700) + try MiniTest.expectEqual(try String(contentsOf: marker, encoding: .utf8), "marker\n") + let refused = try repair(link.path) + try MiniTest.expect(!refused.succeeded, "symlink must be refused") + let foreignMode = try FileManager.default.attributesOfItem(atPath: foreign.path)[.posixPermissions] as? NSNumber + try MiniTest.expectEqual(foreignMode?.intValue ?? 0, 0o755) + let log = (try? String(contentsOf: chmodLog, encoding: .utf8)) ?? "" + try MiniTest.expect(!log.contains(link.path) && !log.contains(foreign.path), "refused path must not be chmod'd") + let unowned = bin.appendingPathComponent("stat-foreign") + try Data("#!/bin/sh\ncase \"$2\" in %u) printf '0\\n' ;; %a) exec /usr/bin/stat -f %Lp \"$3\" ;; *) exit 1 ;; esac\n".utf8).write(to: unowned) + try FileManager.default.removeItem(at: stat) + try FileManager.default.copyItem(at: unowned, to: stat) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: stat.path) + let other = root.appendingPathComponent("other") + try FileManager.default.createDirectory(at: other, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o755]) + let denied = try repair(other.path) + try MiniTest.expect(!denied.succeeded, "unowned directory must be refused") + let otherMode = try FileManager.default.attributesOfItem(atPath: other.path)[.posixPermissions] as? NSNumber + try MiniTest.expectEqual(otherMode?.intValue ?? 0, 0o755) + }), + ("credentials.connection.unsafePrepareDoesNotInstall", { + let runner = MockProcessRunner() + let fixture: [String: Any] = ["configuration": ["id": "synthetic", "creationDate": 800000000, "platform": ["os": "linux", "architecture": "arm64"], "labels": ["devcontainer.managed": "adevcontainer", "devcontainer.remote_user": "root"]], "status": ["state": "running"]] + let bytes = try JSONSerialization.data(withJSONObject: fixture) + runner.handlers = [{ args in + if args.first == "inspect" { return .init(exitCode: 0, stdout: bytes, stderr: Data()) } + if args.contains("adevcontainer-git-prepare") { return .init(exitCode: 4, stdout: Data(), stderr: Data("password=must-not-surface".utf8)) } + return .init(exitCode: 0, stdout: Data(), stderr: Data()) + }] + let connection = GitCredentialConnection(runtime: .init(executablePath: "/synthetic/container", runner: runner), control: { _, _, _ in true }) + try MiniTest.expectThrows({ _ = try connection.setup(id: "synthetic", hostWorkspace: nil, gitURL: "https://dev.azure.com/org/repo") }) { error in + try MiniTest.expectEqual(error as? GitCredentialFailure, .unsafePath) + try MiniTest.expectEqual((error as? GitCredentialFailure)?.diagnosticCode, "unsafe private path") + try MiniTest.expect(!String(describing: error).contains("must-not-surface")) + } + try MiniTest.expect(!runner.calls.contains { $0.arguments.contains("adevcontainer-git-install") }) + }) +] diff --git a/Tests/adevcontainerTests/GitCredentialDiscoveryTests.swift b/Tests/adevcontainerTests/GitCredentialDiscoveryTests.swift new file mode 100644 index 0000000..475a077 --- /dev/null +++ b/Tests/adevcontainerTests/GitCredentialDiscoveryTests.swift @@ -0,0 +1,19 @@ +import Foundation +@testable import ADevContainerLib + +@MainActor let gitCredentialDiscoveryTests: [(String, () throws -> Void)] = [ + ("credentials.discovery.fetchURLsDeduplicateWithoutLosingSchemes", { + let remotes = "origin\thttps://example.invalid/a.git (fetch)\norigin\thttps://example.invalid/a.git (push)\nmirror https://example.invalid/a.git (fetch)\nlegacy\thttp://dev.azure.com/org/project/_git/repo (fetch)\nssh\tgit@example.invalid:repo (fetch)\n" + try MiniTest.expectEqual(GitCredentialDiscovery.uniqueFetchURLs(from: remotes), ["https://example.invalid/a.git", "http://dev.azure.com/org/project/_git/repo", "git@example.invalid:repo"]) + try MiniTest.expectEqual(GitCredentialDiscovery.uniqueFetchURLs(from: "origin https://example.invalid/a.git (push)\n"), []) + try MiniTest.expectEqual(GitCredentialDiscovery.uniqueFetchURLs(from: ""), []) + }), + ("credentials.discovery.bindAndVolumeSources", { + let bind = GitCredentialDiscovery.credentialSources(from: [ContainerIdentity.labelLocalFolder: " /synthetic/workspace ", ContainerIdentity.labelGitURL: "https://ignored.invalid/repo"]) + try MiniTest.expectEqual(bind.hostWorkspace, "/synthetic/workspace"); try MiniTest.expectEqual(bind.gitURL, nil) + let volume = GitCredentialDiscovery.credentialSources(from: [ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeVolume, ContainerIdentity.labelLocalFolder: "volume://synthetic", ContainerIdentity.labelGitURL: " http://example.invalid/repo "]) + try MiniTest.expectEqual(volume.hostWorkspace, nil); try MiniTest.expectEqual(volume.gitURL, "http://example.invalid/repo") + let missing = GitCredentialDiscovery.credentialSources(from: [ContainerIdentity.labelLocalFolder: "volume://synthetic"]) + try MiniTest.expectEqual(missing.hostWorkspace, nil); try MiniTest.expectEqual(missing.gitURL, nil) + }) +] diff --git a/Tests/adevcontainerTests/GitCredentialProtocolTests.swift b/Tests/adevcontainerTests/GitCredentialProtocolTests.swift new file mode 100644 index 0000000..087ee93 --- /dev/null +++ b/Tests/adevcontainerTests/GitCredentialProtocolTests.swift @@ -0,0 +1,48 @@ +import Foundation +@testable import ADevContainerLib + +@MainActor let gitCredentialProtocolTests: [(String, () throws -> Void)] = [ + ("credentials.codec.nativeFixture", { + let frame = GitCredentialFrame(operation: .get, request: 1, generation: 7, fields: [.scheme: "https", .host: "example.invalid:8443", .path: "org/répô@x:y", .username: "u@:/é"]) + let bytes = try GitCredentialProtocol.encode(frame) + try MiniTest.expectEqual(Array(bytes.prefix(32)), [65,71,67,82,0,1,0,2,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,7,0,0,0,61,0,0,0,0]) + try MiniTest.expectEqual(try GitCredentialProtocol.decode(bytes), frame) + var decoder = GitCredentialFrameDecoder(), received: [GitCredentialFrame] = [] + for byte in bytes { received += try decoder.append(Data([byte])) } + try MiniTest.expectEqual(received, [frame]); try decoder.finish() + }), + ("credentials.codec.boundsInjectionAndTruncation", { + let valid = GitCredentialFrame(operation: .reply, request: 42, generation: 7, fields: [.username: "synthetic", .password: "synthetic"]) + let bytes = try GitCredentialProtocol.encode(valid) + for value in ["x\ny", "x\ry", "x\0y", String(repeating: "x", count: 65536)] { + var bad = valid; bad.fields[.password] = value + try MiniTest.expectThrows({ _ = try GitCredentialProtocol.encode(bad) }, validate: { _ in }) + } + for count in [0, 1, 31, bytes.count - 1] { + try MiniTest.expectThrows({ _ = try GitCredentialProtocol.decode(bytes.prefix(count)) }, validate: { _ in }) + } + var decoder = GitCredentialFrameDecoder(); _ = try decoder.append(bytes.prefix(33)) + try MiniTest.expectThrows({ try decoder.finish() }, validate: { _ in }) + var oversized = bytes; oversized[24] = 1 + try MiniTest.expectThrows({ _ = try decoder.append(oversized) }, validate: { _ in }) + var duplicate = bytes + duplicate.append(contentsOf: [0,4,0,1,120]); duplicate[27] += 5 + try MiniTest.expectThrows({ _ = try GitCredentialProtocol.decode(duplicate) }, validate: { _ in }) + var reserved = bytes; reserved[31] = 1 + try MiniTest.expectThrows({ _ = try GitCredentialProtocol.decode(reserved) }, validate: { _ in }) + var outOfOrder = GitCredentialFrameDecoder() + var second = valid; second.request = 1 + let frames = try outOfOrder.append(bytes + GitCredentialProtocol.encode(second)) + try MiniTest.expectEqual(frames.map(\.request), [42,1]) + }), + ("credentials.diag.existingBrokerDirectoryWritesNothing", { + let root = try credentialTestRoot(); defer { try? FileManager.default.removeItem(at: root) } + let dir = root.path + "/git-broker" + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + GitCredentialDiag.event("broker.acquire", "req=1 result=present", traceDirectory: dir) + try MiniTest.expect(!FileManager.default.fileExists(atPath: dir + "/diag.log"), "diagnostic must not write diag.log when a broker directory exists") + let homeLog = NSHomeDirectory() + "/.adevcontainer/git-broker/diag.log" + GitCredentialDiag.event("broker.reply", "req=1 delivered=yes") + try MiniTest.expect(!FileManager.default.fileExists(atPath: homeLog), "diagnostic must not create ~/.adevcontainer/git-broker/diag.log") + }) +] diff --git a/Tests/adevcontainerTests/GitCredentialRelayChannelTests.swift b/Tests/adevcontainerTests/GitCredentialRelayChannelTests.swift new file mode 100644 index 0000000..35ce273 --- /dev/null +++ b/Tests/adevcontainerTests/GitCredentialRelayChannelTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Darwin +@testable import ADevContainerLib + +/// Real native-process framing check; still no Apple runtime/broker/provider. +@MainActor let gitCredentialRelayChannelTests: [(String, () throws -> Void)] = [ + ("credentials.channel.nativeDuplex", { + let relay = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(".build/guest-credential-relay-tests/relay").path + guard FileManager.default.isExecutableFile(atPath: relay) else { throw MiniTest.Skip(message: "run sh scripts/test-guest-credential-relay.sh for optional native-channel execution") } + let approved = try credentialTestBase().path + var template = Array((approved + "/gc.XXXXXX").utf8CString) + guard let directory = mkdtemp(&template) else { throw MiniTest.Failure(message: "native channel test directory unavailable") } + let path = String(cString: directory); defer { try? FileManager.default.removeItem(atPath: path) } + let context = GitCredentialRuntimeContext(id: "synthetic", incarnation: "reference:800000000", platform: "linux/arm64", user: "root", labels: ["devcontainer.managed": "adevcontainer"]) + let ports = GitCredentialBrokerPorts(context: { _ in context }, connect: { registration, generation, receive, disconnected in + let process = try CredentialDuplexProcess(executable: relay, arguments: ["relay", path, String(generation)], environment: [:]) + let channel = try GitCredentialRelayChannel(process: process, platform: registration.context.platform, generation: generation) + channel.start(receive: receive, disconnected: disconnected); return channel + }, acquire: { url, _, _ in + guard url == "https://example.invalid/org/repo" else { throw GitCredentialFailure.invalidDestination } + return .init(username: "host-account", password: "synthetic-channel") + }) + let broker = try GitCredentialBroker(registry: GitCredentialBrokerRegistry(path: path + "/registry"), ports: ports) + defer { broker.shutdown() } + try broker.register(.init(context: context, destinations: [try .init(url: "https://example.invalid/original/repo")], directory: path, overrideRequired: false)) + let end = Date().addingTimeInterval(2) + while !FileManager.default.fileExists(atPath: path + "/credential.sock") && Date() < end { Thread.sleep(forTimeInterval: 0.01) } + let result = try CredentialProviderProcessRunner(timeout: 3).run(executable: relay, arguments: ["client", path, "get"], environment: [:], currentDirectory: nil, stdinData: Data("protocol=https\nhost=example.invalid\npath=org/repo\nusername=guest\n\n".utf8)) + try MiniTest.expect(result.succeeded) + try MiniTest.expectEqual(result.stdoutString, "username=guest\npassword=synthetic-channel\n\n") + }) +] diff --git a/Tests/adevcontainerTests/GitCredentialRuntimeTests.swift b/Tests/adevcontainerTests/GitCredentialRuntimeTests.swift new file mode 100644 index 0000000..7e51ed9 --- /dev/null +++ b/Tests/adevcontainerTests/GitCredentialRuntimeTests.swift @@ -0,0 +1,71 @@ +import Foundation +@testable import ADevContainerLib + +@MainActor let gitCredentialRuntimeTests: [(String, () throws -> Void)] = [ + ("credentials.runtime.observationRequiresDefinitiveMachineState", { + let fixture: [String: Any] = ["configuration": ["id": "synthetic", "creationDate": 800000000.125, "platform": ["os": "linux", "architecture": "arm64"], "labels": ["devcontainer.managed": "adevcontainer", "devcontainer.remote_user": "root"]], "status": ["state": "running"]] + let runner = MockProcessRunner(), runtime = AppleContainerRuntime(executablePath: "/synthetic/container", runner: runner) + let running = try JSONSerialization.data(withJSONObject: [fixture]) + runner.enqueueStdout(String(decoding: running, as: UTF8.self)) + try MiniTest.expectEqual(runtime.credentialObservation(id: "synthetic"), .running(try .parse(running))) + var stopped = fixture; stopped["status"] = ["state": "stopped"] + runner.enqueueStdout(String(decoding: try JSONSerialization.data(withJSONObject: [stopped]), as: UTF8.self)) + try MiniTest.expectEqual(runtime.credentialObservation(id: "synthetic"), .inactive) + runner.enqueueFailure(exitCode: 1, stderr: "runtime unavailable"); runner.enqueueFailure(exitCode: 1, stderr: "runtime unavailable") + try MiniTest.expectEqual(runtime.credentialObservation(id: "synthetic"), .unknown) + runner.enqueueFailure(exitCode: 1, stderr: "ambiguous inspect"); runner.enqueueStdout("[]") + try MiniTest.expectEqual(runtime.credentialObservation(id: "synthetic"), .inactive) + runner.enqueueFailure(exitCode: 1, stderr: "ambiguous inspect") + runner.enqueueStdout("[{\"configuration\":{\"id\":\"other\"}}]") + try MiniTest.expectEqual(runtime.credentialObservation(id: "synthetic"), .unknown, "malformed inventory cannot prove absence") + runner.enqueueFailure(exitCode: 1, stderr: "ambiguous inspect") + runner.enqueueStdout("[{\"configuration\":{\"id\":\"other\",\"labels\":{}},\"status\":{\"state\":\"stopped\"}}]") + try MiniTest.expectEqual(runtime.credentialObservation(id: "synthetic"), .inactive) + for text in ["garbage", "{}", "[{\"configuration\":{\"id\":\"synthetic\"}}]", "[{\"configuration\":{\"id\":\"other\"}}]"] { + runner.enqueueStdout(text) + try MiniTest.expectEqual(runtime.credentialObservation(id: "synthetic"), .unknown) + } + try MiniTest.expectEqual(runtime.credentialObservation(id: "synthetic", deadline: 0), .unknown) + try MiniTest.expect(runner.calls.allSatisfy { $0.arguments.first == "inspect" || $0.arguments == ["list", "--all", "--format", "json"] }) + }), + ("credentials.runtime.immutableMachineContext", { + let fixture: [String: Any] = ["configuration": ["id": "synthetic", "creationDate": 800000000.125, "platform": ["os": "linux", "architecture": "amd64"], "labels": ["devcontainer.managed": "adevcontainer", "devcontainer.remote_user": "vscode", "devcontainer.config_hash": "hash"]], "status": ["state": "running"]] + let data = try JSONSerialization.data(withJSONObject: [fixture]) + let context = try GitCredentialRuntimeContext.parse(data) + try MiniTest.expectEqual(context.platform, "linux/amd64"); try MiniTest.expectEqual(context.user, "vscode") + try MiniTest.expectEqual(context.id, "synthetic") + var configuration = fixture["configuration"] as! [String: Any] + for key in ["creationDate", "platform", "labels"] { + var bad = fixture; var config = configuration; config.removeValue(forKey: key); bad["configuration"] = config + try MiniTest.expectThrows({ _ = try GitCredentialRuntimeContext.parse(JSONSerialization.data(withJSONObject: bad)) }, validate: { _ in }) + } + for date: Any in [true, 0, "invalid", -978307200] { + configuration["creationDate"] = date; var bad = fixture; bad["configuration"] = configuration + try MiniTest.expectThrows({ _ = try GitCredentialRuntimeContext.parse(JSONSerialization.data(withJSONObject: bad)) }, validate: { _ in }) + } + for platform in [["os": "linux", "architecture": "riscv64"], ["os": "darwin", "architecture": "arm64"]] { + var bad = fixture; var config = fixture["configuration"] as! [String: Any]; config["platform"] = platform; bad["configuration"] = config + try MiniTest.expectThrows({ _ = try GitCredentialRuntimeContext.parse(JSONSerialization.data(withJSONObject: bad)) }, validate: { _ in }) + } + var stopped = fixture; stopped["status"] = ["state": "stopped"] + try MiniTest.expectThrows({ _ = try GitCredentialRuntimeContext.parse(JSONSerialization.data(withJSONObject: stopped)) }, validate: { _ in }) + configuration["creationDate"] = "2026-10-21T12:00:00Z"; var iso = fixture; iso["configuration"] = configuration + try MiniTest.expect(try GitCredentialRuntimeContext.parse(JSONSerialization.data(withJSONObject: iso)).incarnation != context.incarnation) + }), + ("credentials.runtime.privateExecArguments", { + let context = GitCredentialRuntimeContext(id: "synthetic", incarnation: "test", platform: "linux/arm64", user: "root", labels: [:]) + let args = try AppleContainerRuntime.credentialExecArguments(context: context, command: ["/private/helper", "relay", "/private/dir", "7"]) + try MiniTest.expectEqual(args, ["exec", "-i", "-u", "root", "synthetic", "/private/helper", "relay", "/private/dir", "7"]) + try MiniTest.expect(!args.contains("-t") && !args.contains("-e")) + let runner = MockProcessRunner() + runner.stdinHandlers = [{ _, input in .init(exitCode: 0, stdout: input ?? Data(), stderr: Data()) }] + let runtime = AppleContainerRuntime(executablePath: "/synthetic/container", runner: runner) + let artifact = try GuestGitCredentialArtifact.select(platform: context.platform) + let readBack = try runtime.credentialExec(context: context, command: ["/synthetic/write-and-read"], input: artifact.bytes) + try artifact.verify(readBack: readBack.stdout) + try MiniTest.expectEqual(runner.calls.first?.stdinData, artifact.bytes) + try MiniTest.expectEqual(runner.calls.first?.arguments, ["exec", "-i", "-u", "root", "synthetic", "/synthetic/write-and-read"]) + try MiniTest.expect(runner.calls.first?.environment?["ADEVCONTAINER_GIT_TOKEN"] == nil) + try MiniTest.expectThrows({ _ = try runtime.credentialDuplex(context: context, command: ["must-not-execute"]) }, validate: { error in try MiniTest.expectEqual(error as? GitCredentialFailure, .invalidContext) }) + }) +] diff --git a/Tests/adevcontainerTests/GuestGitCredentialArtifactTests.swift b/Tests/adevcontainerTests/GuestGitCredentialArtifactTests.swift new file mode 100644 index 0000000..2a007be --- /dev/null +++ b/Tests/adevcontainerTests/GuestGitCredentialArtifactTests.swift @@ -0,0 +1,24 @@ +import Foundation +@testable import ADevContainerLib + +@MainActor let guestGitCredentialArtifactTests: [(String, () throws -> Void)] = [ + ("credentials.assets.actualPlatformAndIntegrity", { + for platform in ["linux/arm64", "linux/amd64"] { + let artifact = try GuestGitCredentialArtifact.select(platform: platform) + try artifact.verify(); try artifact.verify(readBack: artifact.bytes) + try MiniTest.expectEqual(artifact.platform, platform) + var bad = artifact.bytes; bad[20] ^= 1 + try MiniTest.expectThrows({ try artifact.verify(readBack: bad) }, validate: { _ in }) + try MiniTest.expectThrows({ try GuestGitCredentialArtifact(platform: platform, bytes: bad, sha256: artifact.sha256).verify() }, validate: { _ in }) + var invalidELF = artifact.bytes; invalidELF[0] = 0 + try MiniTest.expectThrows({ try GuestGitCredentialArtifact(platform: platform, bytes: invalidELF, sha256: GuestGitCredentialArtifact.hash(invalidELF)).verify() }, validate: { _ in }) + let other = platform == "linux/arm64" ? "linux/amd64" : "linux/arm64" + try MiniTest.expectThrows({ try GuestGitCredentialArtifact(platform: other, bytes: artifact.bytes, sha256: artifact.sha256).verify() }, validate: { _ in }) + try MiniTest.expectThrows({ try GuestGitCredentialArtifact(platform: platform, bytes: artifact.bytes.prefix(30), sha256: artifact.sha256).verify() }, validate: { _ in }) + } + for platform in ["", "linux/riscv64", "darwin/arm64", "linux/x86_64"] { + try MiniTest.expectThrows({ _ = try GuestGitCredentialArtifact.select(platform: platform) }, validate: { _ in }) + } + try MiniTest.expectEqual(GitCredentialProtocol.version, GuestGitCredentialEmbeddedAssets.protocolVersion) + }) +] diff --git a/Tests/adevcontainerTests/RebuildCommandPhaseTests.swift b/Tests/adevcontainerTests/RebuildCommandPhaseTests.swift index 1cdc9e8..feb3e93 100644 --- a/Tests/adevcontainerTests/RebuildCommandPhaseTests.swift +++ b/Tests/adevcontainerTests/RebuildCommandPhaseTests.swift @@ -3677,7 +3677,7 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ }) }), - // ═══════════════════════ Section 6: git credential seeding ═══════════════════════ + // ═══════════════════════ Section 6: automatic credential connection ═══════════════════════ ("rebuildBindSeedsFromHostRemotesBeforeHooks", { let ws = try TestRepo.makeTempWorkspace(configJSON: """ @@ -3688,6 +3688,9 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ } """) defer { try? FileManager.default.removeItem(at: ws) } + let connection = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let s = RebuildScenario() let info = RebuildScenario.container( id: "old-bind-id", @@ -3698,17 +3701,14 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ ) s.containers = [info] s.install() + s.mock.handlers.insert({ args in + if args.first == "delete" { connection.record("runtime.delete") } + if args.first == "exec", args.last?.contains("chown") == true { connection.record("ownership") } + if args.first == "exec", args.last?.contains("postCreateCustom") == true { connection.record("hook") } + return nil + }, at: 0) let hostRunner = RecordingHostProcessRunner() - hostRunner.handler = { call in - if call.arguments == ["-C", ws.path, "remote", "-v"] { - return ProcessResult( - exitCode: 0, - stdout: Data("origin\thttps://github.com/wcgomes/repo.git (fetch)\n".utf8), - stderr: Data() - ) - } - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } + hostRunner.handler = { _ in ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } let restore = RecordingHostProcessRunner.install(hostRunner) defer { restore() } let creds = SeedMockCredential() @@ -3717,31 +3717,24 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ ) let result = try RebuildCommand.run(options: RebuildOptions(), runtime: s.runtime, credentials: creds) try MiniTest.expectEqual(result.outcome, "success") - let calls = s.mock.calls.map(\.arguments) - let deleteIdx = calls.firstIndex { $0.first == "delete" }! - let createIdx = calls.firstIndex { $0.first == "create" }! - let startIdx = calls.firstIndex { $0.first == "start" }! - let seedIdx = calls.firstIndex { $0.first == "exec" && $0.last?.contains("credential.helper") == true }! - let hookIdx = calls.firstIndex { $0.first == "exec" && $0.last?.contains("postCreateCustom") == true }! - try MiniTest.expect(deleteIdx < createIdx, "old deleted before new created") - try MiniTest.expect(createIdx < startIdx, "new created before start") - try MiniTest.expect(startIdx < seedIdx, "seeding after start") - try MiniTest.expect(seedIdx < hookIdx, "seeding before first hook exec") - let seedCall = s.mock.calls[seedIdx] - let seedArgs = seedCall.arguments - try MiniTest.expect(seedArgs.contains("-u") && seedArgs.contains("vscode"), "seeds as connection user") - try MiniTest.expectEqual( - String(data: seedCall.stdinData ?? Data(), encoding: .utf8) ?? "", - "protocol=https\nhost=github.com\nusername=x-access-token\npassword=ghp_secret\n\n" - ) - try MiniTest.expect(!seedArgs.contains(where: { $0.contains("ghp_secret") })) - try MiniTest.expect(seedCall.environment?.values.contains("ghp_secret") != true) - let script = seedArgs.last ?? "" - try MiniTest.expect(script.contains("git config --global --add credential.helper")) - try MiniTest.expect(!script.contains("ghp_secret"), "secrets never in the exec command") - try MiniTest.expect(!script.contains("github.com"), "no host/path in the script") + try MiniTest.expectEqual(connection.setups, [.init(id: s.newContainerId, hostWorkspace: ws.path, gitURL: nil, refresh: false)]) + let events = connection.events + try MiniTest.expect(events.firstIndex(of: "unregister")! < events.firstIndex(of: "runtime.delete")!) + try MiniTest.expect(events.firstIndex(of: "ownership")! < events.firstIndex(of: "setup")!) + try MiniTest.expect(events.firstIndex(of: "setup")! < events.firstIndex(of: "hook")!) + try MiniTest.expect(creds.fillCalls.isEmpty, "rebuild must not pre-acquire or persist a token") + try MiniTest.expect(hostRunner.calls.isEmpty, "bind discovery belongs to the injected connection") + try MiniTest.expect(!s.mock.calls.contains { call in + let joined = call.arguments.joined(separator: " ") + return joined.contains("credential.helper") || joined.contains("git-credential-adev") || joined.contains("credential approve") || joined.contains("ghp_secret") + || (call.stdinData ?? Data()).contains(Data("ghp_secret".utf8)) + || call.environment?.values.contains("ghp_secret") == true + }) }), ("rebuildVolumeSeedsFromStampedGitURLWithoutEnumeration", { + let connection = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let s = RebuildScenario() let info = RebuildScenario.container(id: "vol-old", labels: s.volumeLabels()) s.containers = [info] @@ -3761,21 +3754,19 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ result = try RebuildCommand.run(options: RebuildOptions(skipPull: true), runtime: s.runtime, credentials: creds) } try MiniTest.expectEqual(result?.outcome, "success") - let calls = s.mock.calls.map(\.arguments) - let seedIdx = calls.firstIndex { $0.first == "exec" && $0.last?.contains("credential.helper") == true }! - let seedCall = s.mock.calls[seedIdx] - let seedArgs = seedCall.arguments - try MiniTest.expectEqual( - String(data: seedCall.stdinData ?? Data(), encoding: .utf8) ?? "", - "protocol=https\nhost=github.com\nusername=x-access-token\npassword=ghp_secret\n\n", - "volume seeds from the stamped git_url" - ) - try MiniTest.expect(!seedArgs.contains(where: { $0.contains("ghp_secret") })) - try MiniTest.expect(seedCall.environment?.values.contains("ghp_secret") != true) - try MiniTest.expect(seedArgs.contains("-u") && seedArgs.contains("vscode")) + try MiniTest.expectEqual(connection.setups, [.init(id: s.newContainerId, hostWorkspace: nil, gitURL: "https://github.com/example/repo.git", refresh: false)]) + try MiniTest.expect(creds.fillCalls.isEmpty) try MiniTest.expect(hostRunner.calls.isEmpty, "volume mode must not enumerate host remotes") + try MiniTest.expect(!s.mock.calls.contains { call in + let joined = call.arguments.joined(separator: " ") + return joined.contains("credential.helper") || joined.contains("git-credential-adev") || joined.contains("credential approve") || joined.contains("ghp_secret") + || (call.stdinData ?? Data()).contains(Data("ghp_secret".utf8)) + }) }), ("rebuildVolumeMissingGitURLSkipsSilently", { + let connection = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let s = RebuildScenario() let info = RebuildScenario.container(id: "vol-old", labels: s.volumeLabels(gitURL: "")) s.containers = [info] @@ -3792,6 +3783,7 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ } try MiniTest.expectEqual(result?.outcome, "success") try MiniTest.expect(creds.fillCalls.isEmpty, "missing git_url never fills") + try MiniTest.expectEqual(connection.setups.last?.gitURL, "") try MiniTest.expect( !s.mock.calls.contains { $0.arguments.first == "exec" && $0.arguments.last?.contains("git config --global --add credential.helper") == true }, "no seeding exec without git_url" @@ -3807,6 +3799,10 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ } """) defer { try? FileManager.default.removeItem(at: ws) } + let connection = AutomaticCredentialCommandPort() + connection.fails = true + GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let s = RebuildScenario() let info = RebuildScenario.container( id: "old-bind-id", @@ -3816,7 +3812,6 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ ) ) s.containers = [info] - s.failingExecSubstrings = ["git-credential-adev"] s.install() let hostRunner = RecordingHostProcessRunner() hostRunner.handler = { call in @@ -3852,6 +3847,9 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ ("rebuildNeverSeedsOldContainerBeforeDelete", { let ws = try TestRepo.makeTempWorkspace(configJSON: #"{"image":"alpine:3.20","remoteUser":"vscode"}"#) defer { try? FileManager.default.removeItem(at: ws) } + let connection = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let s = RebuildScenario() let info = RebuildScenario.container( id: "old-bind-id", @@ -3887,8 +3885,9 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ !calls.prefix(deleteIdx + 1).contains { $0.first == "exec" && $0.last?.contains("git config --global --add credential.helper") == true }, "no seeding exec on the old container before delete" ) - let seeds = calls.filter { $0.first == "exec" && $0.last?.contains("git config --global --add credential.helper") == true } - try MiniTest.expectEqual(seeds.count, 1, "exactly one seeding exec, on the new container") + try MiniTest.expect(connection.events.firstIndex(of: "unregister")! < connection.events.firstIndex(of: "setup")!) + try MiniTest.expectEqual(connection.setups.map(\.id), [s.newContainerId]) + try MiniTest.expect(!calls.contains { $0.first == "exec" && $0.last?.contains("credential.helper") == true }) }), ("rebuildBindIdentityOrderAndConnectionUser", { let ws = try TestRepo.makeTempWorkspace(configJSON: """ @@ -3900,6 +3899,10 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ """) defer { try? FileManager.default.removeItem(at: ws) } var events: [String] = [] + let connection = AutomaticCredentialCommandPort() + connection.onEvent = { events.append($0) } + GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let reader = RebuildIdentityReaderFixture() reader.hostIdentity = GitAuthorIdentity(name: "Ada Lovelace", email: "ada@example.com") reader.onHostRead = { events.append("host-read") } @@ -3919,9 +3922,7 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ if args.first == "create" { events.append("replacement-create") } if args.first == "start" { events.append("replacement-start") } if args.first == "exec", let script = args.last { - if script.contains("git config --global --add credential.helper") { - events.append("credential") - } else if script.contains("git config --global --replace-all user.name") { + if script.contains("git config --global --replace-all user.name") { events.append("global") } else if script.contains("chown") { events.append("ownership") @@ -3959,7 +3960,7 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ try MiniTest.expect(reader.guestCalls.isEmpty, "bind mode uses the host local reader") try MiniTest.expectEqual( events, - ["host-read", "old-delete", "replacement-create", "replacement-start", "ownership", "credential", "global", "hook"] + ["host-read", "unregister", "old-delete", "replacement-create", "replacement-start", "ownership", "setup", "global", "hook"] ) let global = s.mock.calls.first { $0.arguments.last?.contains("git config --global --replace-all user.name") == true } try MiniTest.expect(global?.arguments.contains("-u") == true) @@ -3971,6 +3972,10 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ }), ("rebuildVolumeIdentityReadsGuestBeforeDelete", { var events: [String] = [] + let connection = AutomaticCredentialCommandPort() + connection.onEvent = { events.append($0) } + GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let reader = RebuildIdentityReaderFixture() reader.hostIdentity = GitAuthorIdentity(name: "Host", email: "host@example.com") reader.guestIdentity = GitAuthorIdentity(name: "Ada Lovelace", email: "ada@example.com") @@ -3990,9 +3995,7 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ if args.first == "create" { events.append("replacement-create") } if args.first == "start" { events.append("replacement-start") } if args.first == "exec", let script = args.last { - if script.contains("git config --global --add credential.helper") { - events.append("credential") - } else if script.contains("git config --global --replace-all user.name") { + if script.contains("git config --global --replace-all user.name") { events.append("global") } else if script.contains("chown") { events.append("ownership") @@ -4020,7 +4023,7 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ try MiniTest.expectEqual(reader.guestCalls[0].containerId, info.id) try MiniTest.expectEqual( events, - ["guest-read", "old-delete", "replacement-create", "replacement-start", "ownership", "credential", "global", "hook"] + ["guest-read", "unregister", "old-delete", "replacement-create", "replacement-start", "ownership", "setup", "global", "hook"] ) }), ("rebuildIncompleteIdentitySkipsGlobalWithoutPrompt", { @@ -4057,6 +4060,9 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ s.containers = [info] s.failingExecSubstrings = ["git config --global --replace-all user.name"] s.install() + let connection = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } var warnings: [String] = [] let previousWarning = StatusPrinter.onWarning StatusPrinter.onWarning = { warnings.append($0) } @@ -4111,6 +4117,9 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ s.volumeConfigText = #"{"image":"alpine:3.20","remoteUser":"alice","postCreateCommand":"echo identity-hook"}"# s.failingExecSubstrings = ["git config --global --replace-all user.name"] s.install() + let connection = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } var warnings: [String] = [] let previousWarning = StatusPrinter.onWarning StatusPrinter.onWarning = { warnings.append($0) } diff --git a/Tests/adevcontainerTests/RecoveryOrchestratorTests.swift b/Tests/adevcontainerTests/RecoveryOrchestratorTests.swift index ad3d0d4..321201f 100644 --- a/Tests/adevcontainerTests/RecoveryOrchestratorTests.swift +++ b/Tests/adevcontainerTests/RecoveryOrchestratorTests.swift @@ -308,6 +308,36 @@ nonisolated(unsafe) let recoveryOrchestratorTests: [(String, () throws -> Void)] } try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "volume" }) }), + ("recoveryOrchestratorUnregistersBeforeDeletingFailedContainer", { + let port = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(port) + defer { GitCredentialLifecycle.setTestConnection(nil) } + var entry = MockProcessRunner.containerListJSON(id: "failed-id", state: "running") + var configuration = entry["configuration"] as! [String: Any] + configuration["mounts"] = [] + entry["configuration"] = configuration + let listed = try JSONSerialization.data(withJSONObject: [entry]) + let mock = MockProcessRunner() + mock.handlers = [{ args in + if args.first == "list" { + return ProcessResult(exitCode: 0, stdout: listed, stderr: Data()) + } + if args.first == "delete" { port.record("runtime.delete") } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + }] + let runtime = AppleContainerRuntime(executablePath: "/synthetic/container", runner: mock) + try RecoveryOrchestrator.detachFailedContainer(containerID: "failed-id", workspaceVolume: "ws", runtime: runtime) + try RecoveryOrchestrator.detachFailedBindContainer(containerID: "failed-id", runtime: runtime) + try MiniTest.expectEqual(port.events, ["unregister", "runtime.delete", "unregister", "runtime.delete"]) + let missing = MockProcessRunner() + missing.handlers = [{ args in + if args.first == "list" { return ProcessResult(exitCode: 0, stdout: Data("[]".utf8), stderr: Data()) } + if args.first == "delete" { port.record("runtime.delete") } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + }] + try RecoveryOrchestrator.detachFailedBindContainer(containerID: "unlisted-id", runtime: AppleContainerRuntime(executablePath: "/synthetic/container", runner: missing)) + try MiniTest.expectEqual(Array(port.events.suffix(2)), ["unregister", "runtime.delete"]) + }), ("recoveryOrchestratorOpensExistingRetrySession", { let fileManager = FileManager.default let sessionID = "retry-session" diff --git a/Tests/adevcontainerTests/Support/ProcessRunnerFramingTests.swift b/Tests/adevcontainerTests/Support/ProcessRunnerFramingTests.swift index 37ebcd2..6dc84ac 100644 --- a/Tests/adevcontainerTests/Support/ProcessRunnerFramingTests.swift +++ b/Tests/adevcontainerTests/Support/ProcessRunnerFramingTests.swift @@ -30,6 +30,24 @@ private final class ProcessResultBox: @unchecked Sendable { } nonisolated(unsafe) let processRunnerFramingTests: [(String, () throws -> Void)] = [ + ("processRunnerDoesNotInheritGitTokenIntoContainerProcess", { + setenv("ADEVCONTAINER_GIT_TOKEN", "synthetic-host-token", 1) + defer { unsetenv("ADEVCONTAINER_GIT_TOKEN") } + let root = FileManager.default.temporaryDirectory.appendingPathComponent("opencode-token-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let script = root.appendingPathComponent("print-token") + try Data("#!/bin/sh\n[ -z \"${ADEVCONTAINER_GIT_TOKEN+x}\" ]\n".utf8).write(to: script) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: script.path) + let runtime = AppleContainerRuntime(executablePath: script.path, runner: FoundationProcessRunner()) + let invoked = try runtime.invoke([]) + let executed = try runtime.exec(nameOrId: "synthetic", command: ["true"]) + let streamed = try runtime.exec(nameOrId: "synthetic", command: ["true"], streamOutput: true) + for result in [invoked, executed, streamed] { + try MiniTest.expect(result.succeeded, "container process inherited ADEVCONTAINER_GIT_TOKEN") + try MiniTest.expect(result.stdoutString.isEmpty && result.stderrString.isEmpty) + } + }), ("processRunnerFramesStreamedLinesIndentPipe", { let previousColor = TerminalStyle.colorOverride TerminalStyle.colorOverride = false diff --git a/Tests/adevcontainerTests/UpCommandRecoveryTests.swift b/Tests/adevcontainerTests/UpCommandRecoveryTests.swift index c47fe5b..77c9dae 100644 --- a/Tests/adevcontainerTests/UpCommandRecoveryTests.swift +++ b/Tests/adevcontainerTests/UpCommandRecoveryTests.swift @@ -447,6 +447,63 @@ nonisolated(unsafe) let upCommandRecoveryTests: [(String, () throws -> Void)] = try MiniTest.expectEqual(result.outcome, "success") try MiniTest.expectEqual(creates, 1, "retry creates after restart postStart failure") }), + ("upRecoveryReplaceUnregistersBeforeDelete", { + let port = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(port) + defer { GitCredentialLifecycle.setTestConnection(nil) } + let workspace = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20", "postStartCommand": "echo post-start" }"#) + defer { try? FileManager.default.removeItem(at: workspace) } + let mock = MockProcessRunner() + let resolved = try ConfigResolver.resolve(workspacePath: workspace.path, localEnv: [:]) + let existing = MockProcessRunner.containerListJSON( + id: resolved.containerName, + state: "stopped", + labels: resolved.labels + ) + var creates = 0 + var execCalls = 0 + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let values: [[String: Any]] = [existing] + return ProcessResult(exitCode: 0, stdout: try! JSONSerialization.data(withJSONObject: values), stderr: Data()) + } + if args.first == "create" { + creates += 1 + return ProcessResult(exitCode: 0, stdout: Data("new-container\n".utf8), stderr: Data()) + } + if args.first == "start" { return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } + if args.first == "delete" { + port.record("runtime.delete") + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "exec" { + execCalls += 1 + return ProcessResult( + exitCode: execCalls == 1 ? 1 : 0, + stdout: Data(), + stderr: execCalls == 1 ? Data("post-start failed".utf8) : Data() + ) + } + return nil + }, + MockProcessRunner.imageInspectHandler(baseUser: nil) + ] + let editorRunner = UpRecoveryEditorRunner(payloads: [Data(#"{ "image": "alpine:3.20" }"#.utf8)]) + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let result = try UpCommand.run( + options: UpOptions(workspacePath: workspace.path, skipPull: true), + runtime: runtime, + localEnv: [:], + isTTY: true, + recoveryEditor: upRecoveryEditor(runner: editorRunner), + openEditorPrompt: upRecoveryPrompt(answers: ["y"]) + ) + try MiniTest.expectEqual(result.outcome, "success") + let unregister = port.events.firstIndex(of: "unregister") + let deleted = port.events.firstIndex(of: "runtime.delete") + try MiniTest.expect(unregister != nil && deleted != nil && unregister! < deleted!, "replacement must unregister before delete") + }), ("upLaterRetryDeletesLeftoverFromLatestFailure", { // First eligible failure is create (no leftover). A later retry then hits a diff --git a/Tests/adevcontainerTests/VSCodeOpenTests.swift b/Tests/adevcontainerTests/VSCodeOpenTests.swift index 2829734..2522b9b 100644 --- a/Tests/adevcontainerTests/VSCodeOpenTests.swift +++ b/Tests/adevcontainerTests/VSCodeOpenTests.swift @@ -892,6 +892,9 @@ nonisolated(unsafe) let vscodeOpenCommandTests: [(String, () throws -> Void)] = } """) defer { try? FileManager.default.removeItem(at: ws) } + let connection = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(connection) + defer { GitCredentialLifecycle.setTestConnection(nil) } let host = RecordingHostProcessRunner() var events: [String] = [] host.handler = { call in @@ -924,6 +927,7 @@ nonisolated(unsafe) let vscodeOpenCommandTests: [(String, () throws -> Void)] = } if args.first == "start" { events.append("start") + connection.record("runtime.start") return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } if args.first == "inspect" { @@ -942,7 +946,9 @@ nonisolated(unsafe) let vscodeOpenCommandTests: [(String, () throws -> Void)] = let initializeCalls = host.calls.filter { !$0.arguments.contains("remote") } try MiniTest.expectEqual(initializeCalls.count, 1) try MiniTest.expect(initializeCalls[0].arguments.contains("echo init-start-bind")) - try MiniTest.expect(host.calls.contains { $0.arguments.contains("remote") }, "real start refreshes host git remotes") + try MiniTest.expect(!host.calls.contains { $0.arguments.contains("remote") }, "remote discovery belongs to the connection, not a seed probe") + try MiniTest.expectEqual(connection.setups, [.init(id: "adev-app-aaaabbbbcccc", hostWorkspace: ws.path, gitURL: nil, refresh: false)]) + try MiniTest.expect(connection.events.firstIndex(of: "runtime.start")! < connection.events.firstIndex(of: "setup")!) try MiniTest.expectEqual( (initializeCalls[0].currentDirectory as NSString?)?.standardizingPath, (ws.path as NSString).standardizingPath diff --git a/Tests/adevcontainerTests/main.swift b/Tests/adevcontainerTests/main.swift index b08408b..ff07fd1 100644 --- a/Tests/adevcontainerTests/main.swift +++ b/Tests/adevcontainerTests/main.swift @@ -1,6 +1,15 @@ import Foundation @testable import ADevContainerLib +// Private isolated probes for the detached broker suite. Dispatch before +// constructing or running the normal suite; the probe never uses product entry +// routing, Apple runtime ports, or the production broker context. +if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "__adevcontainer_git_broker" { + exit(GitCredentialDetachedProbe.run(CommandLine.arguments[2])) +} +if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "__adevcontainer_git_activate" { + exit(GitCredentialDetachedProbe.activate(CommandLine.arguments[2], id: CommandLine.arguments[3] == "-" ? nil : CommandLine.arguments[3])) +} // Keep progress + warning stderr off during the suite (noise + non-deterministic output). // onWarning still fires for assertions; product QUIET only silences progress, not warnings. StatusPrinter.enabled = false @@ -10,7 +19,8 @@ StatusPrinter.resetSectionState() TerminalStyle.colorOverride = false // Force-link test translation units by referencing their register functions. -let allTests: [(String, () throws -> Void)] = [] +let credentialTests = gitCredentialBrokerPolicyTests + gitCredentialProtocolTests + guestGitCredentialArtifactTests + credentialDuplexProcessTests + automaticHostGitCredentialTests + gitCredentialRuntimeTests + gitCredentialBrokerServiceTests + gitCredentialRelayChannelTests + gitCredentialBrokerEntryTests + gitCredentialConnectionTests + credentialProviderCrashTests + automaticGitCredentialsCommandTests + gitCredentialBrokerSmokeTests + gitCredentialBrokerDetachedTests +let allTests: [(String, () throws -> Void)] = credentialTests + terminalStyleTests + statusPrinterTests + processRunnerFramingTests @@ -45,7 +55,7 @@ let allTests: [(String, () throws -> Void)] = [] + featuresCommandTests + cloneIdentityTests + gitClientTests - + guestGitCredentialSeedTests + + gitCredentialDiscoveryTests + featureGitEnsureTests + cloneCommandTests + managedLifecycleTests @@ -80,6 +90,28 @@ if ProcessInfo.processInfo.environment["ADEVCONTAINER_TTY_RESTORE_PROBE"] == "1" if ProcessInfo.processInfo.environment["ADEVCONTAINER_TTY_STDIN_PROBE"] == "1" { exit(InteractiveTTYRestoreProbe.runStdinRead()) } +if ProcessInfo.processInfo.environment["ADEVCONTAINER_PROVIDER_CRASH_PROBE"] == "1" { + exit(CredentialProviderCrashProbe.run()) +} +if ProcessInfo.processInfo.environment["ADEVCONTAINER_CREDENTIAL_SMOKE"] == "1" { + let reason = GitCredentialBrokerSmokeHarness.liveRefusedReason() ?? "Live credential smoke was not executed." + FileHandle.standardError.write(Data((reason + "\n").utf8)) + exit(2) +} -let code = MiniTest.runAll(allTests) +let legacyCredentialTests = gitClientTests.filter { ["hostGitCredentialFillParsesOutput", "hostGitCredentialTokenEnvEscapeHatch", "hostGitCredentialFillFailureReturnsNil", "parseCredentialOutputKeys"].contains($0.0) } +let automaticCloneTests = cloneCommandTests.filter { ["cloneHTTPSNonAzureUsesAutomaticConnectionBeforePopulate", "cloneHTTPSAzureUsesURLScopedHelper", "cloneHappyPathVolumeMountLabelsInContainerCloneJSON", "cloneHTTPSPublicWorksWithoutHostCredentials", "cloneHTTPSCloneFailWithoutCredsHintsCredentials", "cloneSSHInjectsCreateSSHFlag", "cloneLegacyHTTPPreservesDiscoveredScheme", "cloneReuseVolumePromptYesKeepsTree", "cloneGlobalAuthorFailureWarnsAndRunsHook", "cloneLocalAuthorFailureSkipsGlobalAndRunsHook"].contains($0.0) } +let automaticRebuildTests = rebuildPhaseTests.filter { ["rebuildBindSeedsFromHostRemotesBeforeHooks", "rebuildVolumeSeedsFromStampedGitURLWithoutEnumeration", "rebuildVolumeMissingGitURLSkipsSilently", "rebuildSeedingFailureWarnsAndContinues", "rebuildNeverSeedsOldContainerBeforeDelete", "rebuildBindIdentityOrderAndConnectionUser", "rebuildVolumeIdentityReadsGuestBeforeDelete", "rebuildGlobalIdentityFailureWarnsAndContinues", "rebuildVolumeGlobalFailurePreservesWorkspaceAndHook"].contains($0.0) } +let automaticLifecycleTests = upTests.filter { $0.0.hasPrefix("upFreshCreate") || $0.0.hasPrefix("upReuseRunning") || $0.0.hasPrefix("upStartStopped") } + credentialRefreshCommandTests + gitCredentialDiscoveryTests + automaticRebuildTests + vscodeOpenCommandTests.filter { $0.0 == "realBindStartRunsInitializeCommandFromStampedHostPath" } +let selectedTests: [(String, () throws -> Void)] +if ProcessInfo.processInfo.environment["ADEVCONTAINER_CREDENTIAL_TESTS_ONLY"] == "1" { + selectedTests = credentialTests + legacyCredentialTests + automaticCloneTests + automaticLifecycleTests +} else if ProcessInfo.processInfo.environment["ADEVCONTAINER_TESTS_SYNTHETIC_ONLY"] == "1" { + // AllIntegrationTests contains runtime mutations/network checks behind its + // availability probe. Exclude the group explicitly; never stop the runtime + // or fake its availability to make destructive integration appear to pass. + selectedTests = Array(allTests.dropLast(integrationTests.count)) + print("Excluded \(integrationTests.count) live integration tests (not executed; no target-runtime coverage claimed).") +} else { selectedTests = allTests } +let code = MiniTest.runAll(selectedTests) exit(code) diff --git a/scripts/bootstrap-guest-toolchain.sh b/scripts/bootstrap-guest-toolchain.sh new file mode 100644 index 0000000..1a492ee --- /dev/null +++ b/scripts/bootstrap-guest-toolchain.sh @@ -0,0 +1,65 @@ +#!/bin/sh +# Maintainer/source-build prerequisite only. Never called by the product at runtime. +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) +root="$repo/.build/toolchains/zig-0.15.2" +if [ "$#" = 2 ] && [ "$1" = --root ]; then root=$2 +elif [ "$#" != 0 ]; then echo 'Usage: sh scripts/bootstrap-guest-toolchain.sh [--root LOCAL_CACHE]' >&2; exit 2; fi +case "$(uname -s)-$(uname -m)" in + Darwin-arm64) + name=zig-aarch64-macos-0.15.2 + hash=3cc2bab367e185cdfb27501c4b30b1b0653c28d9f73df8dc91488e66ece5fa6b ;; + Linux-x86_64) + name=zig-x86_64-linux-0.15.2 + hash=02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239 ;; + *) echo 'Unsupported build host; prepare guest artifacts on macOS arm64 or Linux x86-64.' >&2; exit 2 ;; +esac +mkdir -p "$root" +root=$(CDPATH= cd -- "$root" && pwd -P) +archive="$root/$name.tar.xz" +download= stage= locked=0 +cleanup() { + [ -z "$download" ] || rm -f "$download" + [ -z "$stage" ] || rm -rf "$stage" + [ "$locked" = 0 ] || rmdir "$root/bootstrap.lock" +} +trap cleanup EXIT +trap 'exit 1' HUP INT TERM +attempt=0 +until mkdir "$root/bootstrap.lock" 2>/dev/null; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 120 ]; then + echo "Toolchain cache is busy; wait for the other build, or remove '$root/bootstrap.lock' only after verifying no preparation is running." >&2; exit 1 + fi + sleep 1 +done +locked=1 +digest() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d ' ' -f 1 + else shasum -a 256 "$1" | cut -d ' ' -f 1; fi +} +if [ ! -f "$archive" ]; then + download=$(mktemp "$root/download.XXXXXX") + echo 'Preparing pinned Zig 0.15.2 locally (source build only)…' >&2 + if ! curl --fail --location --proto '=https' --tlsv1.2 --retry 2 --output "$download" "https://ziglang.org/download/0.15.2/$name.tar.xz"; then + echo "Toolchain download failed; check HTTPS access to ziglang.org and rerun asset preparation." >&2; exit 1 + fi + if [ "$(digest "$download")" != "$hash" ]; then + echo "Zig archive checksum mismatch; remove the failed download and retry using the official pinned source." >&2; exit 1 + fi + mv "$download" "$archive"; download= +fi +# A version string is not a cache integrity check. Verify before every reuse. +if [ -L "$archive" ] || [ "$(digest "$archive")" != "$hash" ]; then + echo "Zig cached archive checksum mismatch; remove '$archive' and rerun asset preparation." >&2; exit 1 +fi +# Never reuse an extracted tree, including its headers/libraries. Each invocation +# owns a private verified extraction; another bootstrap cannot replace a sysroot +# while its compiler is running. Caller removes this directory after use. +stage=$(mktemp -d "$root/verified.XXXXXX") +tar -xJf "$archive" -C "$stage" +[ -x "$stage/$name/zig" ] && [ ! -L "$stage/$name/zig" ] || { + echo 'Verified archive has no safe Zig compiler; check the pinned archive layout.' >&2; exit 1 +} +printf '%s\n' "$stage/$name/zig" +stage= diff --git a/scripts/build-guest-credential-relay.sh b/scripts/build-guest-credential-relay.sh new file mode 100644 index 0000000..be7fd69 --- /dev/null +++ b/scripts/build-guest-credential-relay.sh @@ -0,0 +1,39 @@ +#!/bin/sh +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) +output="$root/.build/guest-credential-relay" +cache="$root/.build/guest-credential-relay-cache" +raw=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --raw-only) raw=1; shift ;; + --output|--cache) + [ "$#" -ge 2 ] || { echo 'Missing build directory; supply --output PATH or --cache PATH.' >&2; exit 2; } + if [ "$1" = --output ]; then output=$2; else cache=$2; fi; shift 2 ;; + *) echo 'Usage: sh scripts/build-guest-credential-relay.sh [--raw-only] [--output PATH] [--cache PATH]' >&2; exit 2 ;; + esac +done +mkdir -p "$output" "$cache" +output=$(CDPATH= cd -- "$output" && pwd -P) +cache=$(CDPATH= cd -- "$cache" && pwd -P) +zig=$(sh "$root/scripts/bootstrap-guest-toolchain.sh") +toolchain=$(dirname "$(dirname "$zig")") +case "$toolchain" in "$root/.build/toolchains/zig-0.15.2/verified."*) ;; *) echo 'Unsafe toolchain extraction path; rerun bootstrap from this checkout.' >&2; exit 1 ;; esac +trap 'rm -rf "$toolchain"' EXIT +trap 'exit 1' HUP INT TERM +for arch in arm64 amd64; do + case "$arch" in arm64) target=aarch64-linux-musl ;; amd64) target=x86_64-linux-musl ;; esac + mkdir -p "$output/linux-$arch" + # Cache locations are local and may be separated by reproducibility tests. + ZIG_LOCAL_CACHE_DIR="$cache/local" ZIG_GLOBAL_CACHE_DIR="$cache/global" \ + "$zig" cc -target "$target" -mcpu=baseline -std=c11 -O2 -static -s \ + -Wall -Wextra -Werror -fno-ident -ffile-prefix-map="$root"=. \ + -Wl,--build-id=none "$root/Guest/GitCredentialRelay/main.c" \ + -o "$output/linux-$arch/adevcontainer-git-relay" +done +if [ "$raw" = 0 ]; then + [ "$(uname -s)" = Darwin ] || { echo 'Swift asset generation requires macOS/CryptoKit; use --raw-only for Linux CI.' >&2; exit 2; } + swift "$root/scripts/prepare-guest-credential-assets.swift" "$output" \ + "$root/Sources/ADevContainerLib/Git/Generated/GuestGitCredentialEmbeddedAssets.swift" \ + "$root/Guest/GitCredentialRelay/main.c" +fi diff --git a/scripts/package-release.sh b/scripts/package-release.sh new file mode 100644 index 0000000..f752aaf --- /dev/null +++ b/scripts/package-release.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) +if [ "$#" != 2 ]; then echo 'Usage: sh scripts/package-release.sh CLI_BINARY OUTPUT_DIRECTORY' >&2; exit 2; fi +binary=$1; output=$2 +[ -x "$binary" ] || { echo 'CLI binary is missing; prepare assets and build the release product first.' >&2; exit 1; } +[ "$(uname -s)-$(uname -m)" = Darwin-arm64 ] && [ "$(lipo -archs "$binary")" = arm64 ] || { echo 'Expected a macOS arm64 CLI; rebuild on the supported release host before packaging.' >&2; exit 1; } +[ -s "$root/LICENSE" ] && [ -s "$root/THIRD_PARTY_NOTICES.md" ] || { echo 'Distribution notices are missing; restore LICENSE and THIRD_PARTY_NOTICES.md before packaging.' >&2; exit 1; } +# Execute only the internal, side-effect-free embedded verifier. No source lookup, +# runtime download, container command, provider or service activation is permitted. +env -i PATH=/usr/bin:/bin "$binary" __adevcontainer_git_broker __verify_embedded_assets >/dev/null +mkdir -p "$output" +stage=$(mktemp -d "$root/.build/release-package.XXXXXX") +trap 'rm -rf "$stage"' EXIT +trap 'exit 1' HUP INT TERM +cp "$binary" "$stage/adevcontainer"; chmod 755 "$stage/adevcontainer" +cp "$root/LICENSE" "$root/THIRD_PARTY_NOTICES.md" "$stage/"; chmod 644 "$stage/LICENSE" "$stage/THIRD_PARTY_NOTICES.md" +COPYFILE_DISABLE=1 tar -C "$stage" -czf "$output/adevcontainer-macos-arm64.tar.gz" adevcontainer LICENSE THIRD_PARTY_NOTICES.md +(cd "$output" && shasum -a 256 adevcontainer-macos-arm64.tar.gz > adevcontainer-macos-arm64.tar.gz.sha256) diff --git a/scripts/prepare-guest-credential-assets.swift b/scripts/prepare-guest-credential-assets.swift new file mode 100644 index 0000000..e36575c --- /dev/null +++ b/scripts/prepare-guest-credential-assets.swift @@ -0,0 +1,86 @@ +#!/usr/bin/env swift +// Source/release build tool; generated bytes are embedded, never downloaded at runtime. +import Foundation +import CryptoKit + +struct InvalidArtifact: Error { let reason: String } +func require(_ condition: Bool, _ reason: String) throws { + if !condition { throw InvalidArtifact(reason: reason) } +} +func hash(_ data: Data) -> String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() } +func number(_ data: Data, _ offset: Int, _ count: Int) throws -> UInt64 { + try require(offset >= 0 && count > 0 && offset <= data.count && count <= data.count - offset, "truncated ELF metadata") + return (0..= 64 && data.count <= 2 * 1024 * 1024, "invalid ELF size") + try require(Array(data.prefix(7)) == [0x7f, 69, 76, 70, 2, 1, 1], "expected ELF64 little-endian version 1") + try require(try number(data, 16, 2) == 2 && number(data, 18, 2) == machine && number(data, 20, 4) == 1, "wrong ELF executable machine/version") + try require(try number(data, 52, 2) == 64, "invalid ELF header size") + let tableValue = try number(data, 32, 8) + try require(tableValue <= UInt64(data.count), "invalid program table offset") + let table = Int(tableValue), entry = Int(try number(data, 54, 2)), count = Int(try number(data, 56, 2)) + try require(entry == 56 && count > 0 && count <= 128 && table >= 64 && count * entry <= data.count - table, "invalid program table bounds") + var executable = false + let entryPoint = try number(data, 24, 8) + for index in 0..= size && address <= UInt64.max - memory, "invalid load segment") + if try number(data, p + 4, 4) & 1 != 0 && entryPoint >= address && entryPoint - address < size { executable = true } + } + } + try require(executable, "no executable entry-point segment") + let sections = try number(data, 40, 8), sectionSize = try number(data, 58, 2), sectionCount = try number(data, 60, 2) + try require(sections <= UInt64(data.count) && sectionCount <= 4096 && (sectionCount == 0 || sectionSize == 64) + && sectionSize * sectionCount <= UInt64(data.count) - sections, "invalid section table bounds") +} +struct Artifact: Codable { + let target: String + let protocolVersion: Int + let sourceSHA256: String + let toolchain: String + let toolchainArchiveSHA256: [String: String] + let size: Int + let sha256: String +} +do { + try require(CommandLine.arguments.count == 4, "usage: swift scripts/prepare-guest-credential-assets.swift RAW_ROOT OUTPUT_SWIFT SOURCE_C") + let root = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) + let output = URL(fileURLWithPath: CommandLine.arguments[2]) + let sourceHash = hash(try Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[3]))) + let pins = ["macos-arm64": "3cc2bab367e185cdfb27501c4b30b1b0653c28d9f73df8dc91488e66ece5fa6b", + "linux-amd64": "02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239"] + var artifacts: [Artifact] = [], payloads: [String] = [] + for (directory, target, machine) in [("linux-arm64", "linux/arm64", UInt64(183)), ("linux-amd64", "linux/amd64", UInt64(62))] { + let data = try Data(contentsOf: root.appendingPathComponent("\(directory)/adevcontainer-git-relay")) + try verify(data, machine: machine) + artifacts.append(Artifact(target: target, protocolVersion: 1, sourceSHA256: sourceHash, + toolchain: "zig-0.15.2", toolchainArchiveSHA256: pins, size: data.count, sha256: hash(data))) + payloads.append(data.base64EncodedString()) + } + let encoder = JSONEncoder(); encoder.outputFormatting = [.sortedKeys, .prettyPrinted, .withoutEscapingSlashes] + let manifest = try encoder.encode(artifacts) + // Every input is verified before either output is replaced. No date/absolute source path. + var generated = "// Generated by scripts/prepare-guest-credential-assets.swift; do not edit.\n" + generated += "enum GuestGitCredentialEmbeddedAssets {\n static let protocolVersion = 1\n" + generated += " static let manifestBase64 = \"\(manifest.base64EncodedString())\"\n" + for (index, name) in ["arm64", "amd64"].enumerated() { + generated += " static let \(name)Base64 = \"\(payloads[index])\"\n" + generated += " static let \(name)SHA256 = \"\(artifacts[index].sha256)\"\n" + } + generated += "}\n" + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + try manifest.write(to: root.appendingPathComponent("manifest.json"), options: .atomic) + try Data(generated.utf8).write(to: output, options: .atomic) + print("Verified embedded static guest assets (arm64, amd64), protocol 1") +} catch { + let reason = (error as? InvalidArtifact)?.reason ?? "could not read/write guest artifacts" + FileHandle.standardError.write(Data("Guest asset preparation failed: \(reason). Fix inputs and rerun sh scripts/build-guest-credential-relay.sh.\n".utf8)) + exit(1) +} diff --git a/scripts/render-homebrew-formula.sh b/scripts/render-homebrew-formula.sh index 4061866..d2092b2 100755 --- a/scripts/render-homebrew-formula.sh +++ b/scripts/render-homebrew-formula.sh @@ -47,6 +47,7 @@ class Adevcontainer < Formula def install bin.install "adevcontainer" + doc.install "LICENSE", "THIRD_PARTY_NOTICES.md" end test do diff --git a/scripts/test-guest-credential-relay.sh b/scripts/test-guest-credential-relay.sh new file mode 100644 index 0000000..ac54f0d --- /dev/null +++ b/scripts/test-guest-credential-relay.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) +out="$root/.build/guest-credential-relay-tests" +mkdir -p "$out" +case "$(uname -s)" in + Darwin) + cc -std=c11 -Wall -Wextra -Werror -O2 "$root/Tests/GuestGitCredentialRelay/relay_tests.c" -o "$out/tests" + if [ -f "$root/Guest/GitCredentialRelay/main.c" ]; then + cc -std=c11 -Wall -Wextra -Werror -O2 "$root/Guest/GitCredentialRelay/main.c" -o "$out/relay" + fi + relay="$out/relay" + ;; + Linux) + zig=$(sh "$root/scripts/bootstrap-guest-toolchain.sh") + toolchain=$(dirname "$(dirname "$zig")") + case "$toolchain" in "$root/.build/toolchains/zig-0.15.2/verified."*) ;; *) echo 'Unsafe toolchain extraction path; rerun bootstrap from this checkout.' >&2; exit 1 ;; esac + trap 'rm -rf "$toolchain"' EXIT + trap 'exit 1' HUP INT TERM + "$zig" cc -std=c11 -Wall -Wextra -Werror -O2 "$root/Tests/GuestGitCredentialRelay/relay_tests.c" -o "$out/tests" + relay="$root/.build/guest-credential-relay/linux-amd64/adevcontainer-git-relay" + ;; + *) echo 'Unsupported native-test host; use macOS or Linux x86-64.' >&2; exit 2 ;; +esac +# Avoid macOS /var -> /private/var symlinks in the endpoint security tests. +TMPDIR=$(CDPATH= cd -- "${TMPDIR:-/tmp}" && pwd -P) "$out/tests" "$relay" diff --git a/specs/changes/archive/20261021-automatic-git-credentials/design.md b/specs/changes/archive/20261021-automatic-git-credentials/design.md new file mode 100644 index 0000000..1046f80 --- /dev/null +++ b/specs/changes/archive/20261021-automatic-git-credentials/design.md @@ -0,0 +1,78 @@ +# Design: automatic-git-credentials + +## Approach +Use the existing macOS executable for normal commands and an internal broker entry point, supervised by one per-user LaunchAgent. The same executable embeds two small static Linux variants of a C guest relay/client. Keep policy independent of launchd/process/runtime adapters. Observable guarantees and acceptance scenarios are in spec.md; this document explains implementation decisions, not additional guarantees. + +The design overflow is real: supervision, cross-platform delivery, duplex framing, host-provider lifetime, migration, and isolated verification interact across process and trust boundaries. A detached up child avoids a job definition but supplies no automatic restart and creates orphan/discovery problems; launchd is the chosen tradeoff. No foreground mode, HTTP listener, new provider, package-manager guest installation, or new public flags are added. + +## Significant decisions + +### Guest delivery decision +The user approved bundling a static Linux helper and its necessary build/distribution work. Implement one small C source at `Guest/GitCredentialRelay/main.c`, with client get/store/erase and relay server modes. Use POSIX sockets, poll, bounded buffers, and no third-party application libraries. Store is consumed locally without persistence; erase/get cross the relay. A thin private POSIX-sh Git helper can exec this binary, retaining the existing product helper path/configuration contract. Python/socat/guest compilers are not prerequisites. + +Build linux/arm64 and linux/amd64 using `zig cc -target aarch64-linux-musl` and `zig cc -target x86_64-linux-musl` respectively, with explicit static linkage, fixed optimization/target baseline, stripped symbols, disabled build IDs, normalized source paths, and no timestamps/random data in outputs. The host remains macOS arm64; two guest variants do not enable or install Rosetta. Select the guest variant from container machine JSON platform, never uname of the host. Unsupported or unparseable platforms fail credential setup using the contract's soft/explicit error distinction. + +`scripts/bootstrap-guest-toolchain.sh` explicitly bootstraps Zig 0.15.2 into `.build/toolchains/zig-0.15.2/` when needed; it does not assume zig/LLD/a musl sysroot is already installed. For the macOS arm64 source/release host, download only `https://ziglang.org/download/0.15.2/zig-aarch64-macos-0.15.2.tar.xz`, verifying SHA-256 `3cc2bab367e185cdfb27501c4b30b1b0653c28d9f73df8dc91488e66ece5fa6b` before extraction/execution. A Linux x86-64 build-only CI job uses `https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz`, pinned to `02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239`. Verify a retained cached archive against the same pin before reuse; version text alone is not an integrity check. Toolchain download failure is a source-build error, never an excuse for runtime guest downloads. This is an explicit maintainer/source-build prerequisite, not a new end-user runtime dependency. + +`scripts/build-guest-credential-relay.sh` compiles both raw artifacts using the bootstrapped compiler, then invokes a host Swift generator/verifier at `scripts/prepare-guest-credential-assets.swift`, using macOS Foundation/CryptoKit rather than assuming Python. Raw artifacts live at `.build/guest-credential-relay/linux-arm64/adevcontainer-git-relay` and `.build/guest-credential-relay/linux-amd64/adevcontainer-git-relay`. A deterministic manifest at `.build/guest-credential-relay/manifest.json` records target, protocol version, source/toolchain identity, size, and SHA-256. Parse ELF64 little-endian headers/program tables to verify the target machine (183 for AArch64, 62 for x86-64), bounds, and absence of an interpreter or dynamic-library dependencies. Rebuild in independent temporary output/cache directories and compare bytes/hashes; toolchain unavailability or nondeterminism blocks release. A build-script-only raw mode lets Linux CI compile the identical static outputs without requiring the macOS Swift/CryptoKit generator; it is not a new product flag or Linux host CLI port. + +Generate gitignored `Sources/ADevContainerLib/Git/Generated/GuestGitCredentialEmbeddedAssets.swift` containing deterministic base64 payloads and the same manifest constants. SwiftPM's existing library target automatically compiles this Swift file: no resource bundle lookup or second installed executable is needed. Clean source/CI/release builds run the preparation script before `swift run adevcontainerTests` / `swift build`; document this deliberate source-build change in README and CONTRIBUTING. Generated source/binaries/toolchain archives are not committed. A package script `scripts/package-release.sh` packages the existing single host executable plus applicable third-party notices; Homebrew still installs that binary, and retains the notices as documentation. There is no runtime dependency on source paths, generated files, Zig, or companion resource bundles. Preserve the release tarball name/checksum conventions. + +The artifact adapter verifies embedded hashes/ELF metadata before provisioning. Write the guest binary into a private temporary file with exec stdin, read it back via the existing runtime read-file boundary, and compare host-computed digest/length before atomic installation as 0700. Reject unsafe paths. Relay hello checks protocol/platform identity before any provider delivery; no runtime host fallback on wrong architecture. `scripts/test-guest-credential-relay.sh` runs native parser/socket tests on the macOS build host using the already-required CLT C compiler and executes the actual linux/amd64 static output on a Linux x86-64 CI runner using the pinned toolchain. linux/arm64 target execution is exercised in separately authorized throwaway Apple-container smoke, not assumed from a passing ELF check. The test-only native macOS executable is never shipped. + +Evidence for the selected build mechanism: the official [Zig overview](https://ziglang.org/learn/overview/) documents C cross-compilation and bundled musl without a separate sysroot; the [download index](https://ziglang.org/download/index.json) supplied the fixed 0.15.2 archive/hash during planning. Zig was not installed locally when inspected. No download, native compilation, or target execution has been performed in this spec phase. Verify compiler flags/build reproducibility in the first implementation phase instead of claiming that architecture is already tested. + +### Supervision and session lifecycle +Register a user-private LaunchAgent targeting a stable absolute path to the installed CLI, with an internal broker mode, no sudo, and a launchd restart policy. An advisory per-user lock plus idempotent bootstrap prevents competing registry owners. Commands communicate over a user-private host-local control socket with same-UID peer validation; that socket is never exposed to the guest. The production job/state are distinct from smoke contexts. Job registration checks the resolved executable path so a later lifecycle command can repair an ordinary binary upgrade; moving/uninstalling an arbitrary manually installed binary is not promised transparent recovery. + +Use production label `com.adevcontainer.git-credentials`, plist `~/Library/LaunchAgents/com.adevcontainer.git-credentials.plist`, nonsecret registry under `~/Library/Application Support/adevcontainer/git-credentials/`, and lock/control endpoint under owner-private `~/.adevcontainer/git-broker/`. Validate Unix-socket path length before creation; do not truncate identifiers or fall back to a public endpoint. Tests substitute a `com.adevcontainer.git-credentials.test.` label and exclusively temporary paths through an internal launch-context value; no production plist is overwritten. + +Persist only nonsecret registrations: runtime incarnation/context, connection user, source provenance, authorized destination set, protocol version, and an ephemeral-override-required bit. Preserve no tokens or rejection values on disk. Read the actual managed container's identity, state, `configuration.creationDate`, and platform from machine JSON before reconnecting. Apple [ContainerConfiguration](https://github.com/apple/container/blob/main/Sources/ContainerResource/Container/ContainerConfiguration.swift) defines creationDate/platform; use an optional credential-context parser so older non-credential runtime paths do not acquire new prerequisites. A missing/invalid incarnation fails closed for unattended recovery rather than treating a reusable name/config hash as an incarnation. Fixtures and real smoke verify the runtime shape before enabling delivery. A new service generation and fresh relay handshake fence pending requests even for a valid same-container restart. + +Keep the broker idle when no containers are connected; no idle-shutdown policy is needed. Reconcile runtime state at a finite interval, reconnect live containers with bounded backoff, and invalidate stopped/deleted or identity-mismatched registrations. Recovery checks use runtime metadata, not credential polling. Stop/delete/purge and failed-clone cleanup explicitly unregister their sessions; process loss is also handled by EOF/deadlines. Rebuild establishes a new incarnation/generation before any hook can obtain a credential. + +## Flow + +```text +up/clone/start/rebuild/reuse/refresh + -> private same-user broker registration + -> runtime boundary opens container exec -i (no TTY) + -> native guest relay owns a connection-user-private Unix socket + +Git get/store/erase -> helper/native client -> guest socket -> relay stdout + -> broker validates established session + destination + request + -> existing host credential provider (get only, bounded/noninteractive) + -> exec stdin -> relay -> matching client -> Git credential response +``` + +Add a long-lived duplex-process abstraction rather than reusing buffered ProcessResult or StatusPrinter tee. Drain both streams, never tee raw relay/provider payloads or errors into logs. Use a simple versioned length-prefixed binary frame with a fixed header (operation, request ID, generation, payload length) and length-delimited credential fields; this avoids a C JSON dependency. Reject oversized/truncated/duplicate/conflicting fields and newline/NUL injection before provider input construction. Correlate per-generation IDs; disconnect cancels all pending waiters; late responses cannot satisfy another operation. Do not replay timed-out gets after reconnect. Bound queue size and provider concurrency, using fair admission across sessions; only equivalent acquisition contexts may share simultaneous provider work. + +Start with internal defaults: 15-second end-to-end helper deadline, 30-second lifecycle-setup deadline, 30-second rejection cooldown, 64-KiB frames, eight active provider jobs, and 64 queued jobs. Use monotonic injected clocks and finite reconnect/backoff/reconciliation intervals. These are implementation defaults, not user-facing flags. Provider descendants must be terminated/reaped when their deadline expires; merely canceling a waiter is insufficient. Asynchronous pipe drainage/cancellation remain outside domain policy. + +Store discards input without approve/persistence. Erase compares against bounded recent delivery records keyed by session and acquisition scope, then records a keyed in-memory fingerprint. A delayed erase cannot invalidate a newer different credential, and an unknown/uncorrelatable rejection cannot erase a host credential. During cooldown suppress the same rejected value, not a different replacement; expiration or explicit refresh permits another attempt, even if the provider still returns the same value. No internal retry loop and no persistent blacklist are needed. + +## Architectural boundaries +`GitCredentialBrokerPolicy` owns destination validation, rejection state, bounds, and generation/correlation policy without launchd/process/runtime imports. A separate broker orchestrator coordinates policy, a provider port, transport events, and the nonsecret registry. `GitCredentialBrokerService` owns launchd/control-IPC/locking and launches internal mode through the CLI entry; `GitCredentialConnection` prepares/migrates the guest and registers lifecycles. Runtime adapters alone invoke Apple container. Existing discovery, connection-user resolution, URL parsing/redaction, and provider precedence remain shared, not copied into commands. + +Registration authorizes only the discovered normalized scheme/host/port set. Preserve sibling paths and queried-username response compatibility without allowing a guest username to select another host account. Preserve legacy HTTP exactly where discovered: HTTP/HTTPS do not authorize one another, and no provider unsafe-HTTP setting is enabled. Pass actual validated Azure query paths; do not send pathless GCM calls or substitute the original repository when a sibling is queried. Scheme/port normalization must survive discovery and provider input, including explicit nondefault ports; keep normalized hostname distinct from Git's host-with-port field so Azure recognition does not break. This limits acquisition requests, not the privileges of a delivered bearer token. + +## Migration and rollback +Replace the product-owned seed helper idempotently; remove identified product-owned seeded credential files without reading/logging them. Preserve existing global helpers and their order. Retain Azure URL-scoped helper installation exactly once, remove old empty resets, and keep useHttpPath. Use the HTTPS-scoped keys for HTTPS and equivalent HTTP-scoped keys only when legacy HTTP Azure was actually discovered; otherwise Git can omit the organization path for HTTP and accidentally produce a pathless GCM request. Earlier helpers may answer first; successful Git authentication is not evidence of broker use. Tests isolate the helper chain and assert provider invocation. + +Legacy non-Azure clone's generic credential.helper store has no reliable entry provenance. Do not erase its file or unrelated user entries to manufacture a no-cache test. Preserve unidentified legacy configuration/data and document that these earlier answers are outside automatic freshness. New HTTP(S) clone prepares the automatic helper before populate/hooks, creates no store/approve path, and allows anonymous public clone when the provider misses. Never replay a legacy product seed on broker failure. Downgrading may reintroduce the older release's behavior; byte-for-byte credential/cache rollback is not promised. + +## Security considerations +The boundary is the host-established container session and resolved connection user; guest root can exercise that authority. Validate all guest messages, original identity, exact destination context, URL fields, and socket ownership before provider execution. Never expose the host control socket/runtime endpoints or execute guest-supplied command/config text. Socket cleanup uses validated private directories, lstat/no-follow checks, and only known owned endpoints; it must not delete arbitrary user paths. + +launchd does not inherit each invoking CLI's environment. Register explicit host token overrides through private IPC into session memory, not plist/argv/child environment. Persist only a needs-reregistration indicator; after broker restart such sessions fail closed until re-registered, rather than silently switching accounts/providers. Disable Git prompts and GCM GUI/TTY prompts; keep all provider output secret-safe. Real authentication, reject/erase/logout, or shared-cache manipulation is not part of automated tests. + +## Isolated smoke verification +Add a test harness using an internal dependency-injected launch context: a unique test job label, private state/socket/log roots under the approved temporary directory, an absolute development executable, synthetic providers, and exclusively created throwaway containers. This is not a public option/environment-based alternate service mode. Unit tests cover normal command integration; real smoke exercises supervision/exec/installed static relay through the same library adapters without registering the production job. Do not rely on changing HOME or overwriting the user's existing LaunchAgent to achieve isolation. + +For the separate launchd broker process, provide synthetic acquisition through the existing Git provider, not a new production provider: a test-owned Git configuration selects only a local fixture credential helper for `https://credential-smoke.invalid`. The internal test launch context supplies a sanitized provider environment with system/user Git config excluded, command-level inherited config cleared, explicit real-token overrides excluded, tracing disabled, and prompts disabled. No user credential store/config/gh fallback or network authentication is involved. Guest Git exercises `credential fill/reject` only against this isolated helper chain; reject reaches the guest erase operation, never host reject. Entry dispatch tests build the CLI explicitly and use invalid/isolated contexts, while fake-library tests cover service behavior before authorized real smoke. + +The test executable, not the product CLI, may select its deterministic credential aggregate with `ADEVCONTAINER_CREDENTIAL_TESTS_ONLY=1`; a separate test-only `ADEVCONTAINER_CREDENTIAL_SMOKE=1` opt-in runs the previously authorized isolated smoke. Mock-runtime command tests inject fake broker/provider dependencies and fail if they accidentally select a real launchctl adapter. These test gates introduce no public product options and are never interpreted by the production broker to redirect its registry. + +Capture nonsecret baseline existence/hashes of production job/plist/state, without dumping credential-related contents. On success, injected failure, and handled signals: stop accepting requests, bootout only the unique test service, reap relay/exec processes, remove only test-created containers/endpoints/state, and verify absence plus unchanged production baseline. Never stop plantsuite or another pre-existing container for smoke. SIGKILL cannot run a trap: print/preserve a secret-free test-owned cleanup manifest and a targeted cleanup procedure for a later operator/harness sweep; do not claim guaranteed cleanup after an unhandleable crash. Real providers require separate authorization, and neither synthetic success nor unchanged tokens proves OAuth renewal. + +The supplied prior spike demonstrated 66-second duplex, out-of-order concurrency across restart, interrupted pending requests, and two Azure HTTPS lookups. Its reconnection/fencing were harness behavior; stale sockets survived restart and Python supplied the relay. Product behavior with the shipped native helper still requires verification. diff --git a/specs/changes/archive/20261021-automatic-git-credentials/implementation.md b/specs/changes/archive/20261021-automatic-git-credentials/implementation.md new file mode 100644 index 0000000..7c42623 --- /dev/null +++ b/specs/changes/archive/20261021-automatic-git-credentials/implementation.md @@ -0,0 +1,358 @@ +# Partial implementation evidence + +Status: **PARTIAL / not production-ready**. The latest continuation is at the end of this file. The first block delivered the native +slice; the continuation below adds concrete Swift policy/protocol, process, +provider/runtime adapters, registry and broker/native-channel integration. +The original twenty-three-task count below is historical. Service activation, +guest provisioning, command integration, seed retirement, and fake-adapter +acceptance now exist. Live smoke, Linux target execution, and the full +integration suite remain unverified. No outcome/spec change is proposed. The +latest continuation at the end is the current evidence. + +## Test-first evidence + +- `sh scripts/test-guest-credential-relay.sh`: behavioral red before the C source + existed, exit 134 on the expected missing relay hello/read assertion. A prior + CLT declaration error was fixed and is not counted as behavioral red. +- After implementation: the same command passes. Tests execute the native relay + as a separate process, not a linked parser stub. They cover Git field/TLV + fixtures with @/:/Unicode, successful synthetic answer, empty misses, inverted + and fragmented responses, store no-op, erase, oversized/duplicate/control/NUL/ + invalid UTF-8/forged-ID/truncated requests, pending EOF, old-generation response + rejection, a real 15-second timeout, private directories, regular/symlink socket + refusal, ancestor symlink refusal, stale socket replacement and relay singleton. +- `sh Tests/GuestGitCredentialRelay/build_artifacts_tests.sh`: behavioral red + before build/generator implementation: `static guest artifact preparation is + absent`, exit 1. Final version passes with independent output/cache rebuilds, + matching bytes/manifests/generated payloads, corrupted-cache rejection, a fake + installed zig, wrong-machine/truncated/PT_INTERP/PT_DYNAMIC rejection and notices. + An intermediate test-only Swift autoclosure compilation error was corrected; + it is not counted as behavioral red. +- CLT build with `-Wall -Wextra -Werror` passes. AddressSanitizer + + UndefinedBehaviorSanitizer execution of the same harness passes. `cc --analyze` + reports no findings. `sh -n` passes for all four new shell scripts. + +## Artifact evidence + +Official macOS arm64 Zig 0.15.2 was downloaded only into the repository's ignored +`.build/toolchains/zig-0.15.2/` and SHA-256 verified against the approved pin. +No installed zig/global package or guest dependency was used. + +| Variant | Size | SHA-256 | +|---|---:|---| +| linux/arm64 | 51208 | db4cfa073d085100c62ac368029c5fdb1994f4b19571fb05dd7bd71b21720822 | +| linux/amd64 | 45600 | e8733a1922800e3a3c7413f1710e111cab057a13c0df20d3c021e9d7ca99e5a1 | + +Source SHA-256: `0a39134a1449a20169a8b29526d1b446fd383a5175ce4fe98651c114d97a6788`. +Protocol: 1; wire layout is documented in the C source and exercised by fixtures. +The verified ELF variants have no interpreter or dynamic segment. Deterministic +manifest/generated source are local ignored outputs, not checked-in binaries. + +`sh scripts/build-guest-credential-relay.sh`, `swift build --product adevcontainer` +and `swift build -c release --product adevcontainer` succeed. Swift builds report +CLT missing-search-directory linker warnings; no C/Swift source warning was +reported. A read-only `git archive HEAD` baseline was built in an isolated source +directory (without executing its tests), followed by a rebuild of the working +tree. Both succeed and report the identical three CLT linker warnings. No new +warning was introduced; temporary baseline sources were removed. + +## Scope and security review + +- Relay payloads are bounded (64 KiB frames, 64 client slots); IDs are monotonic + per generation, replies are correlated, deadlines are monotonic/end-to-end, + and a disconnect never replays requests. No provider logic exists in C. +- No secret argv/environment/log/file output. Store is local discard; erase is a + request only. No host credential operation or authentication was executed. +- Directory components use no-follow opens; final directory is owned/0700; + socket and peer UID are validated. A private lock prevents competing relays. + Cleanup checks bound socket inode/device and does not unlink unrelated files. +- No launchd job, runtime container, provider or installed CLI was touched. + No experimental relay remains after the successful tests. Wiki files retain + their exact approved baseline hashes. + +## Original native-block continuation (superseded by the progress below) + +1. Finish 1.4 package-completeness coverage with phase 6 packaging tests. Linux + x86-64 bootstrap/harness execution and arm64 guest execution are unverified. +2. Implement phases 2–5 in order, test-first: host policy/codec/artifact access, + process-group lifetime/provider/runtime adapters, registry/broker/LaunchAgent/ + private IPC/provisioning, internal dispatch, all lifecycle call sites and + migration. **No existing seeded credential behavior has been replaced yet.** +3. The generated constants currently compile but have no artifact accessor; + optimized binaries may dead-strip them. Embedded retention/selection/hash/ + read-back/hello and relocated-release completeness are not established. +4. Implement phase 6 CI/release packaging/Homebrew/docs and isolated smoke + harness. Product/source build documentation must be integrated there; native + development instructions are in `Tests/GuestGitCredentialRelay/README.md`. +5. The Swift focused credential aggregate does not exist yet (task 2.2). Neither + it nor the full real-runtime-capable suite was run. Real smoke and real + authentication remain outside this handoff's authorization. + +No claim is made that C01–C19/L01–L03/A01/A02/V01 pass at product boundaries. + +## Swift continuation: phases 2–3 and phase 4 core + +### Implemented and locally exercised + +- `GitCredentialProtocol.swift`: bounded binary encoder/stream decoder matching + the C header/TLV fixtures, safe typed failures, fragmentation, duplicate/conflict, + injection and truncation rejection. No public CLI flags. +- `GitCredentialBrokerPolicy.swift`: exact discovered scheme/hostname/effective + port authorization, sibling paths, Azure path requirement, queried username + response compatibility without provider account selection; keyed in-memory + delivery fingerprints, finite rejection cooldown/reset, generation/request + fencing and bounded fair admission. Canceled active work retains its capacity + until its worker exits, preventing unbounded replacement jobs. +- `GuestGitCredentialArtifacts.swift`: generated embedded payload selection by + explicit guest platform, digest/ELF verification and read-back checks. No host + architecture fallback. Relocated release retention remains unverified. +- `CredentialDuplexProcess.swift`: atomically creates a process group with + `posix_spawn`, exact environment, bounded write queue, nonblocking duplex pipes, + stderr discard (never tee), deadline/group cancellation and leader reaping. + Tests verify a synthetic sleeping descendant is no longer running. +- `CredentialProviderProcessRunner.swift`, `HostGitCredential.swift` and + `GitURLKind.swift`: the **existing production host provider now defaults to + the bounded runner**; explicit override precedence, nil/gh fallthrough, actual + HTTP(S) scheme/port/path, disabled Git/GCM/gh prompts and no propagated explicit + token/tracing environment. No OAuth implementation or new provider. +- Dedicated optional credential-only runtime context/exec/duplex ports in + `AppleContainerRuntime.swift`: running/managed identity, real creationDate, + guest platform and connection user are required. Existing ContainerInfo and + general buffered/TTY exec contracts stay unchanged. Fake buffered runners + cannot accidentally select real duplex/container execution. +- `GitCredentialBrokerRegistry.swift`: bounded, nonsecret, owner-private atomic + registry with no-follow component/file validation, permissions/link checks, + same-directory rename/fsync and strict registration validation. Override + values/rejection state are never serialized. +- `GitCredentialBroker.swift` and `GitCredentialRelayChannel.swift`: policy, + provider/runtime/transport ports, request correlation, ephemeral override + restart fence, immutable-context revalidation, bounded-backoff reconciliation, + generation fencing, fair workers, native hello validation and secret-safe + channel lifetime. A real native macOS relay/client is integrated with the Swift + broker/policy/codec through synthetic ports, including sibling path lookup. + +### Red/green and registration evidence + +All new groups are registered in `Tests/adevcontainerTests/main.swift`; both +default registration and the test-only focused selection are retained. The +focused selection includes four existing fake host-provider regression cases. + +| Check | Behavioral red | Green | +|---|---|---| +| Phase 2 focused group | 0 passed / 6 failed | 6 / 0 | +| Duplex process additions | 6 / 3 | 9 / 0 | +| Provider additions | 9 / 3 | 12 / 0 | +| Runtime context additions | 12 / 2 | 14 / 0 | +| Registry additions | 15 / 1 | 16 / 0 | +| Broker additions | 16 / 1 | 17 / 0 | +| Real native channel addition | 17 / 1 | 18 / 0 | +| Obsolete channel forging new generation | reply ID 999 instead of 2 | fixed; regression passes | + +Compile/harness errors encountered while introducing groups are not counted as +behavioral red. Final focused command: + +```sh +ADEVCONTAINER_CREDENTIAL_TESTS_ONLY=1 swift run adevcontainerTests +``` + +Final aggregate: **24 passed, 0 failed, 0 skipped** (synthetic/local only). +`swift build --product adevcontainer` also succeeds. Only the previously confirmed +CLT linker search-path warnings are emitted; no introduced source diagnostics. +No large native/release rebuild was repeated for this continuation. + +### Concrete remaining dependency + +The next implementation dependency is **task 4.4**, not a real-provider smoke: +implement/test the injected per-user LaunchAgent adapter, singleton lock, private +same-UID host control IPC, executable repair and internal entry dispatch (4.7/4.8). +The broker is currently driven by its library/test ports; **there is no activated +production daemon or automatically scheduled reconciliation loop yet**. + +Then implement 4.5/4.6: guest artifact provisioning/read-back/ready-socket checks, +helper migration and discovery/registration. Only afterwards move lifecycle and +clone call sites in phase 5 and retire `GuestGitCredentialSeed`. Its existing +guest-persistence behavior has **not** been replaced by this continuation. + +Phase 6 remains entirely pending: release retention/relocation tests, packaging, +CI, Homebrew, product docs and isolated smoke harness/authorized execution. Task +1.4 package completeness and 2.6 relocation coverage remain partial. Tasks 4.1 +and 4.3 remain unchecked because their full service/concurrency/cancellation +acceptance is not demonstrated, despite the implemented and tested core. + +No C/build-script change was made while the parallel phase-1 security review was +in progress. No LaunchAgent, existing container, authentication, token store, +installed CLI or wiki was changed. No final product/scenario conformance claim +is made; this record is implementation evidence, not a DONE certificate. + +## Earlier resume inventory (superseded by the continuation below) + +Both mandatory phase-1 fixes are present: bounded stdout backpressure handling +without changing caller flags, and a fresh private extraction from the verified +Zig archive for every invocation. Native tests include backpressure/saturation; +artifact tests include executable and auxiliary-tree tampering. Service/IPC, +internal entry and initial provisioning files also exist, but normal lifecycle +call sites still use legacy seeding. Earlier notes above are historical. + +Current priority is provider lifetime after broker crash, shared-HOME endpoint +isolation and serialized provisioning, followed by lifecycle/clone integration +and distribution. No real job/container/authentication is authorized. Checkboxes +remain acceptance evidence, not a count of files that exist. + +Provider-crash hardening: a synthetic subprocess regression reproduced a live +provider/sleeping descendant after its broker received SIGKILL. A same-executable +internal guardian now owns the provider's process group, watches a dedicated +parent-liveness pipe and kills the whole group on parent EOF/deadline/completion. +A separate byte-only status pipe preserves the provider exit status without +mixing metadata into credential stdout. The actual provider inherits that group; +it never inherits the guardian's liveness/status descriptors. Guarded runtime +relay processes use the same parent-death protection. No new installed binary, +credential argv/env, provider, public flag or real service was introduced. + +## Current continuation: lifecycle regression and bounded setup + +Status remains **PARTIAL / not production-ready**, not DONE. Actual source now +contains per-user LaunchAgent activation/repair, singleton and provisioning locks, +private same-UID framed control IPC, the hidden internal entry, verified guest +provisioning/migration, and lifecycle setup/teardown including clone. The obsolete +one-shot clone credential acquisition/ASKPASS/approve/store path was removed; +shared setup precedes the full clone or reused-tree hooks. These are implementation +facts, not proof that every phase-4/5 scenario is covered. + +This continuation replaced the obsolete up/start/refresh command assertions with +the injectable automatic connection boundary. The formerly crashing fresh-up +assertion now verifies ownership → setup → create-hook order and source forwarding, +not a fabricated token seed. Running reuse, soft failure versus explicit refresh +hard failure, stopped refusal, volume source selection and redaction remain tested. +The focused aggregate now includes these updated deterministic command cases. + +A new single-budget test first failed with `invalid runtime identity` after a slow +synthetic context operation instead of stopping at the expired budget (49 passed, +1 failed; `.build/credential-budget-red.log`). The implementation now propagates +one monotonic setup deadline through discovery's bounded host runner, runtime +inspect/exec, provisioning/read-back/ready checks, control activation, lock waits, +launchctl subprocesses and IPC transfer/connect. Unregister uses a separate bounded +two-second cleanup budget. The mock adapter also checks expiry after returning, +so tests cannot hide a multiplied budget. Budget green: **50/0/0**. + +Help regression first failed on the missing rejection-reset/on-demand semantics +(`.build/credential-help-red.log`, 49/1/0). Up/start/clone/refresh help now describes +automatic repair versus explicit validation/reset, HTTP(S), host login and no guest +token stores; it no longer promises the superseded seed route or OAuth renewal. + +Additional sandbox migration coverage executes the production installation shell +against an isolated Git config, adapting only Linux stat formatting to macOS. No +credential lookup/provider is called. It verifies repeated migration's helper order, +Azure exactly-once insertion/empty-reset removal/useHttpPath, untouched unregistered +HTTP scope, explicit legacy-HTTP migration, deletion of the private old product +store, and byte-preservation of the unmarked user clone store. This is additional +verification of existing code, not invented pre-implementation red evidence. + +### Latest locally executed commands + +- `swift build --product adevcontainer`: succeeds; only independently confirmed + baseline CLT linker-search-path warnings, no new source diagnostic. +- `ADEVCONTAINER_CREDENTIAL_TESTS_ONLY=1 swift run adevcontainerTests`: **51 passed, + 0 failed, 0 skipped** (`.build/credential-migration-tests.log`). No real runtime, + LaunchAgent or host credential provider selected. +- `sh scripts/test-guest-credential-relay.sh`: passes, including stdout + backpressure/saturation (`.build/credential-resume-native.log`). +- `sh Tests/GuestGitCredentialRelay/build_artifacts_tests.sh`: passes pinned + archive/fresh extraction, executable and auxiliary-tree tampering, deterministic + static target/ELF/generated identity/notices (`.build/credential-resume-artifacts.log`). +- `swift build -c release --product adevcontainer`: succeeds + (`.build/credential-resume-release.log`). +- `sh Tests/GuestGitCredentialRelay/build_artifacts_tests.sh --package .build/release/adevcontainer`: + passes notice/archive/relocated embedded-target verification + (`.build/credential-resume-package.log`). This hidden verifier has no service, + runtime or provider side effects. + +### Remaining dependencies (not a DONE certificate) + +Retire the old seed API and its superseded AllUnitTests group while retaining +discovery/compatibility coverage; extend remaining clone/rebuild/failure-cleanup +and service/concurrency tests; inspect full scenario conformance and registration. +The full suite is deliberately **not executed**: runtime integration effects lack +authorization. Its remaining obsolete seed assertions must not be hidden by focused +selection. Isolated smoke harness/tests/documentation (6.8–6.10) are still absent; +live launchd/arm64 smoke and actual Linux target execution remain unverified and +must not be marked passed. Neither user authentication nor real-resource mutation +is authorized. CodeGraph CLI was unavailable on this host, so targeted file reads +were used after the attempted indexed lookup. Wiki files were not edited. + +### Additional regressions in this continuation + +- Simultaneous activation: eight concurrent isolated service activations with a + thread-safe fake launchctl produce exactly one bootstrap and zero failures. +- Helper-path escaping: migration with a home containing spaces/single quotes + first failed the expected helper-command assertion + (`.build/credential-helper-path-red.log`, 51/1/0). Product helper values now + shell-quote non-simple absolute paths, retaining raw simple paths. Azure removes + duplicate old/new product values while retaining user helper order. A fixture + helper invoked by isolated Git proves the quoted command works; this uses only + synthetic values, a private config and a fixture executable, not host providers. +- Clone failure ordering: a new assertion reproduced `setup → runtime.delete → + unregister` instead of fencing before deletion + (`.build/credential-clone-cleanup-red.log`, 55/1/0). Clone now unregisters once + before deletion after populate/reuse/overlay failures, retaining deferred cleanup + for later failures and existing volume-retention rules. Legacy HTTP forwarding, + populated reuse before hooks, SSH unchanged, public success, private failure and + single full-clone mechanism are included in focused tests. Combined green: + **57/0/0**, `.build/credential-cleanup-green.log`. +- Hook behavior: two new regressions reproduced enabled authentication prompts + and delete-before-unregister for failed create hooks + (`.build/credential-hooks-red.log`, 57/2/0). Lifecycle hook exec now overrides + terminal/GCM/ASKPASS interaction settings with noninteractive values and + unregisters before its create-path deletion. Final verification is recorded below + after rebuilding; no success is inferred from compilation alone. + +Shell syntax, rendered Homebrew Ruby syntax and both workflow YAML syntax checks +pass. `git diff --check` passes. The three preserved wiki SHA-256 values still +exactly match the supplied baseline. Full suite/real smoke restrictions above +remain unchanged; task checkmarks distinguish implemented local distribution from +unexecuted GitHub Actions/Homebrew/target smoke. + +Final verification after all edits above: `swift build --product adevcontainer`, +the focused aggregate (**59 passed, 0 failed, 0 skipped**), +`swift build -c release --product adevcontainer`, and release-package relocation +all pass. Logs: `.build/credential-hooks-build.log`, +`.build/credential-hooks-green.log`, `.build/credential-hooks-release.log`, +`.build/credential-hooks-package.log`. Only the known baseline CLT missing-search- +directory linker warnings remain; no new source warning/error is reported. +The intermediate `.build/credential-final-*` attempt overlapped introduction of +the intentionally red hook regressions and failed; it is not claimed as green. +Final `git diff --check` and the preserved wiki hashes pass, and generated assets +remain ignored/unstaged. No commits, installed executable replacement, production +LaunchAgent, existing runtime/container mutation or real authentication occurred. + +Next concrete implementation dependency remains 5.12/5.14: replace the legacy +`guestGitCredentialSeedTests` block (AllUnitTests.swift, preserving the following +compatibilityContractTests), retain discovery tests/helpers still used by commands, +then remove the unused seed/install/store implementation. This is unfinished +implementation, not a request for runtime authorization. Separately complete +phase-4/5 scenario coverage and isolated smoke harness/docs before any production +readiness claim. Live smoke execution remains separately unauthorized. + +## Current continuation: seed retirement and fake acceptance + +Status remains **PARTIAL / not production-ready**. `GuestGitCredentialSeed.swift` +is removed. Discovery lives in `GitCredentialDiscovery`; `compatibilityContractTests` +remains. No lifecycle command writes a guest token store, `credential approve`, +or `ADEV_CLONE_PASS`. The install script deletes only the product-owned +`$HOME/.adevcontainer/git-credentials` file and does not read it. Rebuild, start, +clone, and purge tests now assert the injected connection boundary instead of a +seed exec. A same-named incarnation does not inherit an explicit override. + +Isolated smoke harness tests pass with a fake launchctl and a fixture helper for +`credential-smoke.invalid`. `ADEVCONTAINER_CREDENTIAL_SMOKE=1` exits before any +job bootstrap unless a separate authorization variable is also set, and even then +this process does not execute live smoke. `Tests/GuestGitCredentialRelay/SMOKE.md` +documents the operator procedure. Task 6.11 was not run. + +### Local checks this continuation + +- `ADEVCONTAINER_CREDENTIAL_TESTS_ONLY=1 swift run adevcontainerTests`: **78 passed, 0 failed, 0 skipped**. +- `ADEVCONTAINER_TESTS_SYNTHETIC_ONLY=1 swift run adevcontainerTests`: **1173 passed, 0 failed, 0 skipped**. The runner printed `Excluded 18 live integration tests`. Those 18 were not executed. +- `swift build --product adevcontainer`: succeeds. Only the previously known CLT missing-search-directory linker warnings. No new source diagnostic. +- `git diff --check`: passes. Wiki files were not edited. + +Not run, and not claimed: native harness, artifact/package relocation, release build, `swift run adevcontainerTests` without the synthetic exclusion, Linux amd64 execution, GitHub Actions, Homebrew install, real LaunchAgent, plantsuite, keychain, or provider authentication. Phase 1 and the release/live-smoke checkpoints stay unchecked for that reason. diff --git a/specs/changes/archive/20261021-automatic-git-credentials/proposal.md b/specs/changes/archive/20261021-automatic-git-credentials/proposal.md new file mode 100644 index 0000000..c2a028e --- /dev/null +++ b/specs/changes/archive/20261021-automatic-git-credentials/proposal.md @@ -0,0 +1,62 @@ +# Proposal: Automatic Git credentials + +## Intent +Keep managed containers connected to the host's current HTTPS Git credential provider after lifecycle commands exit, without persisting new copies of host tokens in the guest. Remove the need to restart a container or manually reseed it when the host provider changes its credential. + +## Scope +Replace the credential-seeding requirement in `../../core.md` and the HTTPS authentication portions of the full-clone-populate requirement in `../../clone.md`. Integrate automatic setup/recovery with clone, up, start, reuse, rebuild, stop, delete, purge, and refresh-credentials; retain existing SSH and unrelated lifecycle behavior. The change includes supervision, private transport, guest helper provisioning, authorization, migration, concurrency, rejection handling, and tests. + +## Non-goals +Implementing OAuth refresh, forcing GCM renewal, installing GCM or packages in the guest, changing remote URLs, adding providers or user-facing flags, exposing a TCP endpoint, changing user credential helpers, archiving the change, or implementing product code during this drafting phase. + +## Approach +Use a per-user launchd-supervised mode of the existing adevcontainer executable. Establish a private non-TTY exec channel to a guest relay serving a user-private Unix socket. The Git helper requests credentials on demand through this channel; the host reuses the existing acquisition provider. Expanded flow, alternatives, migration risks, and the unresolved guest-delivery decision are in [design.md](design.md). + +## Decision index +- **Host supervision:** one LaunchAgent per macOS user, registered automatically without sudo; no separate host executable. A detached child does not provide crash recovery. See [supervision](design.md#supervision-and-session-lifecycle). +- **Transport:** private per-container `container exec -i` streams and a guest Unix socket; no network listener or mounted host credential store. See [flow](design.md#flow). +- **Acquisition:** reuse the host provider noninteractively; no provider erase/approve, no new guest token store, and no claim that another lookup proves OAuth renewal. +- **Compatibility:** preserve host-based sibling coverage, queried-username behavior, existing helper ordering, Azure URL-scoped helper/path rules, and independent SSH forwarding. Existing earlier helpers remain outside the product's freshness guarantee. +- **Failure:** finite waits, generation fencing, bounded concurrency, and redacted failures; lifecycle setup remains best-effort except the explicit refresh command. +- **Guest relay delivery:** ship one small static C relay/client for each of linux/arm64 and linux/amd64, embedded in the existing CLI; build reproducibly with checksum-pinned Zig 0.15.2, not an assumed local cross-toolchain. See [delivery](design.md#guest-delivery-decision). +- **Legacy HTTP:** retain explicitly discovered HTTP destinations using their original scheme/host/port and existing provider policy; do not permit HTTPS-to-HTTP downgrade or introduce new HTTP destinations. Removing HTTP support is outside this change. + +## Clarifications +- **Q:** Is a background host component acceptable after considering foreground-only operation? + **A:** Yes. Use the existing executable supervised by a per-user LaunchAgent, with automatic activation and no sudo. +- **Q:** Does the previous runtime/GCM experiment prove real OAuth renewal? + **A:** No. The supplied evidence demonstrates exec duplex/concurrency and successful Azure authentication through the channel, not changed tokens or refresh-token use. plantsuite was stopped before and after that experiment. +- **Q:** How is the dependency-free Linux socket relay delivered for release and source builds? + **A:** The user approved shipping a small static Linux helper together with the CLI, including build/distribution changes, without installing guest runtime packages. Embed both architecture variants in generated Swift source to preserve a relocatable single host executable; source/CI builds explicitly prepare the checksum-pinned toolchain and assets first. +- **Q:** Does the new strict HTTPS boundary intentionally change existing plain-HTTP credential behavior? + **A:** No. The user explicitly approved preserving legacy HTTP compatibility without expanding its reach. Only destinations discovered through the existing source rules are authorized, with scheme binding; provider refusal of unsafe HTTP is not overridden. + +## Archive verification + +Archived 2026-10-21 (`specs/changes/archive/20261021-automatic-git-credentials/`) by explicit user authorization. This record does not certify production readiness and does not claim that unchecked tasks were executed. + +Inspected before archive, without re-running suites, containers, authentication, or services: + +- `GitCredentialDiag.event` does not write `diag.log`. The optional `traceDirectory` argument exists only so a temporary test can target an existing broker directory; production calls and that test must not create `diag.log`, including `~/.adevcontainer/git-broker/diag.log`. +- Each broker get calls host acquisition again. `GitCredentialBrokerPolicy.deliver` returns the password just acquired and does not substitute a previous guest token. `credentials.broker.secondGetReturnsChangedProviderPassword` requires the second get to return `second-synthetic` after two provider queries. That matches C07 at the synthetic broker boundary. + +User-reported evidence accepted for this archive and not re-executed here: 87 focused credential tests passed, and `swift build` debug passed. Earlier real evidence, also not re-run here: the helper returned a credential in plantsuite, and concurrency plus a partial restart were observed. OAuth refresh was not proven and is not required. + +Not executed, and not claimed as executed. The user authorized archive with these limits as non-blocking: + +- Isolated supervised smoke (task 6.11 / live V01), including a unique temporary LaunchAgent against an exclusively created guest. +- Linux target execution of the shipped relay, including linux/amd64 CI execution. +- The complete restart matrix: full service-crash, generation, and reconciliation coverage beyond the previously observed partial restart. + +Unchecked task and checkpoint boxes for those items remain unchecked. The author-identity non-regression in `specs/core.md` still uses historical "credential seeding" and `[DIAG]` wording scoped to that earlier change ("This change"); this archive did not rewrite it. + +## Readiness and baseline +Ready; analyze result is PASS after resolving both user decisions and completing the test-first dependency plan and scenario coverage in tasks.md. This is readiness for later implementation, not a claim that product tests, native compilation, supervision, or real authentication have passed. + +Repository baseline: `6a8dde568e5d1ada3aa1aefe7be6f323fb724163`. No active change folders or legacy nested source specs existed when inspected. Pre-existing modifications, each one line added/removed, are retained unchanged: + +| Path | Baseline SHA-256 | +|---|---| +| `wiki/architecture.md` | `a017d9e7044ffe722e3c2cefb0857e43418b9acc754be9cbd2ef431cf05a49fb` | +| `wiki/conventions/cli-runtime-boundary.md` | `0d3dfe39f30a0c328540fbd89f9356b4e19347fece0d89b9b9c31e36bb7092d8` | +| `wiki/domain/devcontainer-apple-gaps.md` | `4064d3272968e514463c401cd0a41059da4ac04ec949e91cab3d106d63afd441` | diff --git a/specs/changes/archive/20261021-automatic-git-credentials/spec.md b/specs/changes/archive/20261021-automatic-git-credentials/spec.md new file mode 100644 index 0000000..12fd08d --- /dev/null +++ b/specs/changes/archive/20261021-automatic-git-credentials/spec.md @@ -0,0 +1,182 @@ +# Change Spec: automatic-git-credentials + +This delta covers HTTPS and the existing HTTP compatibility included in the realized HTTPS authentication category. Only explicitly discovered scheme/host/port destinations are authorized. Artifact creation and readiness analysis do not themselves execute or verify implementation. + +## MODIFIED Requirements + +### Requirement: Git credential store seeded on create paths + +**Target:** `core.md`, replacing this requirement and all its seeding-specific scenarios with the automatic credential behavior below. The historical heading is retained to identify the exact replacement target; token seeding is no longer the required behavior. + +For managed containers with discovered HTTPS or legacy HTTP credential destinations, the product MUST automatically prepare the resolved connection user's Git credential helper for on-demand host acquisition before create-path hooks, after ownership preparation. A per-user service using the installed adevcontainer executable MUST survive the invoking CLI, activate automatically without sudo, and be supervised for restart after failure. Guest setup MUST NOT require Python, socat, a compiler, GCM, or additional package installation. New product-managed persistent guest token stores MUST NOT be created. + +Discovery MUST retain the unique HTTP(S) fetch destinations of the host workspace for bind mode and the stamped Git URL for volume mode; clone MUST register its requested HTTP(S) URL. Legacy HTTP MUST retain its original scheme and existing provider policy without widening to new hosts/ports or permitting scheme downgrade. The product MUST NOT enable unsafe-HTTP overrides in a host provider. Missing host Git, no repository/remotes, or absent volume URL MUST remain silent skips without making host Git a prerequisite for up. Missing credentials from a provider MUST be a silent miss. Duplicate source URLs MUST NOT create duplicate helpers or sessions. + +Setup MUST apply to fresh up, clone, rebuild replacement, real start, up start-stopped, and already-running up/start reuse. Existing healthy sessions on reuse MUST NOT be needlessly recreated or trigger provider lookup; missing or broken sessions MUST be repaired. Setup errors MUST warn with redaction and MUST NOT themselves fail up/start/rebuild, delete a container, or enter recovery. Hooks retain their own failure semantics. + +#### Scenario: C01 - create and rebuild prepare before hooks +- Given bind up, bind rebuild, or volume rebuild with discovered HTTP(S) destinations and an available provider +- When the container is running and ownership is prepared +- Then the resolved connection user has a connected credential helper before any create-path hook, including when that user is root, without a persisted token seed +- And bind discovery uses host fetch remotes while volume discovery uses the stamped URL + +#### Scenario: C02 - real start and running reuse +- Given either a stopped managed container or an already-running matching container +- When start or up selects it +- Then real start prepares the connection before restart postStart, running reuse repairs only missing/broken connections, and neither duplicates a healthy session nor queries credentials merely to reuse it + +#### Scenario: C03 - silent skips and soft setup failure +- Given missing host Git, no HTTP(S) destinations, a non-repository workspace, an absent stamped URL, or a provider miss +- When lifecycle setup or helper lookup runs +- Then missing sources/providers do not add a lifecycle failure or warning, and an actual installation/transport setup failure produces only a redacted warning without product-triggered deletion or recovery + +The product MUST preserve pre-existing global helpers and their ordering, append its non-Azure helper idempotently, and install its Azure helper exactly once at the matching registered scheme's URL-scoped helper key. HTTPS Azure MUST retain its existing helper key and `credential.https://dev.azure.com.useHttpPath=true`; explicitly discovered legacy HTTP Azure MUST receive equivalent HTTP-scoped helper/path handling without authorizing HTTP for an HTTPS-only registration. Azure configuration MUST remove legacy empty helper values, MUST NOT write an empty helper reset, and MUST NOT reset global helper lists. The automatic freshness guarantee applies when the product helper is invoked; earlier user-configured helpers MAY satisfy Git without invoking it and MUST NOT be silently disabled. + +On get, the product helper MUST authorize the destination before contacting a host provider. The authorized destination set MUST remain the HTTP or HTTPS scheme plus exact normalized host and port discovered for that container, retaining sibling repository coverage across paths rather than limiting lookup to the original repository. Unknown hosts, alternative ports, unregistered schemes, and non-HTTP(S) protocols MUST be misses without provider calls. HTTP and HTTPS MUST NOT authorize each other. Queried usernames MUST NOT select a different host account/provider; responses MUST retain the queried username when supplied, otherwise the acquired username. The host MUST receive the actual validated repository path for Azure lookups; a missing organization path MUST be a miss without a pathless GCM call. It MUST NOT silently substitute a different repository path. + +#### Scenario: C04 - Azure configuration and path compatibility +- Given an HTTPS Azure destination (or explicitly registered legacy HTTP Azure destination), a legacy empty URL-scoped helper, existing global helpers, and a lookup for a sibling repository on that host +- When setup and get run +- Then the product helper occurs exactly once at the matching scheme's Azure URL-scoped key, useHttpPath is true, empty resets are absent, global helpers/order are preserved, and the host receives that sibling's actual organization/repository path +- And a pathless Azure lookup never reaches GCM + +#### Scenario: C05 - destination and username compatibility +- Given a registered HTTPS host and an acquired username X +- When Git requests another repository on that host with username Y, or requests an unregistered host, different port, unregistered scheme, or non-HTTP(S) protocol +- Then the sibling request returns the acquired password with username Y without using Y to change host account selection, while unauthorized requests produce misses without provider calls + +#### Scenario: C06 - existing helper ordering +- Given a user-configured helper before the product helper +- When setup runs repeatedly and Git performs a lookup +- Then the original helper and ordering remain, the product entry is not duplicated, and an earlier complete answer is not represented as proof of product lookup or freshness + +#### Scenario: C19 - legacy HTTP without implicit expansion +- Given a discovered HTTP destination and a distinct HTTPS-only destination +- When lookup requests the registered HTTP destination, another port/host, or HTTP on the HTTPS-only destination +- Then only the registered HTTP destination can reach the existing provider, its scheme/path/port are retained, an unsafe-HTTP provider refusal remains a miss, and no unsafe override or downgrade is enabled + +Each product get MUST use the shared host acquisition provider rather than a product guest-token cache. Host acquisition MUST disable Git terminal prompts and provider GUI/TTY prompts, respect a finite deadline, and return a miss if interaction is required. Azure provider interaction MUST be disabled in addition to `GIT_TERMINAL_PROMPT=0`. The product MUST NOT implement OAuth renewal or claim a provider's unchanged token is evidence of renewal. Provider nil MUST permit normal Git helper fallthrough; product-managed clone/hooks MUST remain noninteractive on authentication failure and offer host-login guidance. + +Store MUST consume input without persisting credentials or approving them in the host credential store. Erase MUST NOT invoke host credential reject/erase, remove account bindings, or modify the host token cache. It MUST temporarily suppress replay of a matching credential previously delivered for that session/destination, without suppressing a newer different credential or affecting other containers. Suppression MUST expire after a finite cooldown; a subsequent get MAY consult the provider again. Helper operations MUST NOT contain an unbounded internal reacquisition/rejection loop. + +#### Scenario: C07 - lookup freshness and provider UI required +- Given two successive product-helper gets and a host provider that changes its answer between them +- When the gets run +- Then each obtains the current provider answer without a guest seed, and a provider requiring interaction instead results in a bounded miss with no GUI/TTY prompt or product OAuth-refresh claim + +#### Scenario: C08 - store and correlated erase +- Given a credential delivered in one session and a newer different credential delivered for the same destination +- When store or a delayed erase for the older delivery arrives +- Then store persists/approves nothing, erase does not suppress the newer credential, and neither operation mutates host credential storage or another session + +#### Scenario: C09 - rejection cooldown is finite +- Given a delivered credential that Git rejects and a provider that continues returning the same value +- When get occurs during suppression and after its deadline, or explicit refresh clears suppression +- Then the rejected value is not immediately replayed, the cooldown eventually permits a fresh attempt, and no single helper operation loops indefinitely + +The product MUST provide private per-container channels without network listeners, bind mounts of host credential material, or guest access to host runtime control endpoints. Session identity and destination authorization MUST derive from host-established context, not guest-asserted container identity. Guest connection endpoints MUST be accessible only to the resolved connection user, subject to guest root's existing authority. Product-owned helper/session directories MUST be private and correctly owned; stale sockets MUST be safely replaced without following attacker-controlled symlinks or deleting unrelated files. + +Secrets MUST remain in confidential pipes/socket payloads and process memory, never new persistent token files, process argv, product-created environment variables, labels, progress, success JSON, or diagnostics. Reading the existing explicit host token escape hatch is retained; the product MUST NOT propagate it through a new child environment or persist it for supervision. Any per-session explicit override MUST be lost on service restart and MUST require registration by an invoking CLI to restore it; service recovery MUST NOT silently substitute a different credential source for that session until re-registration. No provider/helper command text or local filesystem path supplied by the guest MUST be executed. + +#### Scenario: C10 - session isolation and malicious input +- Given two registered containers and an untrusted guest request claiming the other container's identity or carrying an unauthorized destination, malformed fields, oversized payload, or command text +- When the broker processes it +- Then identity remains bound to the established channel, invalid requests do not reach the provider, unrelated sessions remain usable, and no arbitrary host command runs + +#### Scenario: C11 - secret containment and private endpoints +- Given successful lookup, provider/transport errors, or endpoint provisioning against a stale or symlinked path +- When the operation finishes +- Then secrets appear only in intended credential-channel responses, endpoints have private ownership/permissions, no host credential/control endpoint is mounted or published, and unsafe paths fail without deleting unrelated files + +#### Scenario: C12 - explicit token override restart +- Given a CLI registration with the existing explicit host token override +- When the service restarts +- Then the override was not persisted or put in child environment, its session cannot silently switch credential source, and re-registration restores that override through private IPC + +Only one service instance per macOS user MUST own the session registry. Simultaneous activations MUST be idempotent. Concurrent requests MUST have bounded admission, payload size, deadlines, and independent response correlation. Service/channel failure MUST terminate pending waits within their deadlines. Recovery MUST reject messages from prior service/container session generations and MUST not authorize a new container solely because it reuses an old name. + +The supervisor MUST restart a crashed service. The restarted service MUST reconcile saved nonsecret registrations against managed container identity/state before recreating channels. Transport loss while a container remains running MUST trigger bounded-backoff reconnection; failed requests MUST NOT be replayed automatically. Stopped/removed containers MUST have no live relay or usable session; stop/delete/purge and failed-clone cleanup MUST invalidate registrations without acquiring tokens. Runtime changes outside the CLI MUST be reconciled within a finite interval. Service idleness MUST NOT cause provider polling. + +#### Scenario: C13 - activation races and process exit +- Given concurrent lifecycle invocations and a prepared container +- When the invocations finish or one service instance crashes +- Then only one service owns the registry, credential lookup remains available after the invoking CLIs exit, and launchd restarts a crashed service without sudo or another installed host executable + +#### Scenario: C14 - out-of-order concurrency and overload +- Given multiple concurrent gets whose provider replies complete out of order and requests beyond admission bounds +- When requests complete or time out +- Then every reply reaches only its own waiter, capacity is bounded, overload fails within the request deadline, and other sessions are not starved indefinitely + +#### Scenario: C15 - transport failure, restart, and generation fence +- Given pending requests and a stale guest socket +- When the relay/container or broker restarts, including a rebuild reusing the name +- Then pending requests fail within their deadline, stale endpoints are safely replaced, nonsecret registrations are revalidated, old-generation replies/identities are rejected, and a fresh lookup succeeds after connection recovery without replaying the old request + +#### Scenario: C16 - stop, removal, and external reconciliation +- Given a registered container and an idle supervised service +- When stop, delete, purge, failed-clone cleanup, or an external runtime stop/removal occurs +- Then sessions/relays and pending requests are cleaned up or invalidated within bounded waits/reconciliation, no tokens are polled, and workspace/volume deletion semantics remain unchanged + +On existing containers, setup MUST migrate the product-owned seeded helper to on-demand operation and remove the legacy product-owned token store without reading or logging its contents. It MUST NOT delete unidentified user credentials, reset user helper chains, or fall back to a legacy product seed if the host is unavailable. Legacy clone store files without trustworthy ownership/provenance MUST be preserved; user/legacy helpers that answer before the product remain outside the automatic freshness guarantee. Migration MUST be idempotent. + +#### Scenario: C17 - migration without destructive credential cleanup +- Given an existing product helper/store, user helper configuration, and an unmarked legacy clone store file +- When up/start reuse or refresh performs migration twice +- Then the product helper becomes on-demand, the product-owned store is removed without exposing its content, unmarked/user credential files and helper ordering remain, and host failure does not replay the removed product seed + +`adevcontainer refresh-credentials [--name]` MUST retain managed selection and running-only behavior. On a running target it MUST ensure/recover the service/helper/channel, update authorization from the same discovery sources, clear that session's rejection suppression, and validate acquisition for discovered credential sources without persisting tokens in the guest. No credentials available MUST remain a silent skip; setup/exec failure MUST fail this explicit command with a redacted structured error. It MUST NOT fetch/pull/push, write the workspace, start/stop the container, or promise provider token renewal. + +#### Scenario: C18 - explicit refresh and stopped target +- Given a running managed target with a broken channel and suppressed credentials, or a stopped target +- When refresh-credentials selects it +- Then the running target is repaired/revalidated without token persistence or repository operations, with setup failure reported rather than soft-failed; a stopped target is refused with a start hint and no start/stop/exec + +### Requirement: In-container full clone populate (auth by URL scheme) + +**Target:** `clone.md`. Replace only the HTTPS authentication row (including its existing HTTP compatibility), shared-acquisition paragraph, and the credential-specific scenarios named below. All other populate steps, SSH/other rows, verification, volume reuse, and failure cleanup remain unchanged. The superseded scenarios are `HTTPS uses host credential fill one-shot`, `create-path seeding reuses the shared acquisition contract`, and `clone keeps a single populate mechanism (regression)`; public/private fallback and no-GCM-in-guest scenarios remain applicable with the new helper. + +For HTTPS and legacy HTTP clone, the product MUST establish the same on-demand credential connection used by up/start before the in-container full clone and subsequent create-path hooks. The shared host acquisition contract MUST retain existing provider precedence (explicit host token, Git credential fill with protocol/host/path, optional existing GitHub CLI fallback), with host interactivity disabled and bounded waits. Clone MUST NOT configure new credential.helper store entries, approve/persist token seeds, or pass credential values through guest environment variables. All non-Azure and Azure HTTP(S) clones MUST use the same connection mechanism; Azure MUST retain the URL-scoped helper and organization path behavior defined above. No GCM installation/detection in the guest or mounting of host credential stores is permitted. + +If host credentials are unavailable, an anonymous in-container clone MUST still be possible. A private authentication failure MUST produce a structured host-login/SSH hint without a new token CLI flag. On a reused populated volume, clone MUST skip populate as before but prepare the automatic connection before hooks. Clone failure cleanup MUST invalidate its session and retain the existing created-versus-reused volume deletion rules. + +#### Scenario: L01 - one automatic HTTPS mechanism before populate +- Given a fresh HTTPS or legacy HTTP clone for Azure or a non-Azure host and an available host provider +- When clone populates the volume +- Then the resolved connection user's automatic helper is connected before the single in-container full clone and hooks, verification still checks .git, no host full-clone/tar path runs, and no token store/approve/secret environment mechanism is installed + +#### Scenario: L02 - public/private fallback and volume reuse cleanup +- Given no host credentials, or a reused populated workspace volume +- When HTTPS clone runs +- Then public anonymous clone can succeed, private authentication fails noninteractively with a redacted host-login hint, and populated reuse skips clone while preparing the connection before hooks +- And any failed-clone session is invalidated, newly created workspace volumes follow existing cleanup, and reused volumes are preserved + +#### Scenario: L03 - SSH regression +- Given an SSH clone URL and either a present or absent host SSH agent +- When clone runs +- Then existing --ssh injection/missing-agent behavior is unchanged and no HTTPS credential session is created for that SSH URL + +## ADDED Requirements + +### Requirement: Self-contained guest credential artifacts + +The distributed macOS CLI MUST contain version-matched static Linux relay/client artifacts for linux/arm64 and linux/amd64. Runtime selection MUST use the actual guest platform rather than the host architecture. The product MUST verify artifact integrity and protocol/platform compatibility before allowing credential delivery, and MUST fail closed on unsupported/missing/corrupt artifacts using existing soft lifecycle versus explicit-refresh error semantics. It MUST NOT install a translator, enable Rosetta, download executable artifacts at runtime, or fall back to Python/socat. Release and source builds MUST have a documented pinned, checksum-verified build path and reproducible guest outputs; relocation of the distributed executable MUST NOT lose its guest artifacts. Applicable linked-runtime license notices MUST accompany distribution. + +#### Scenario: A01 - source and release artifact completeness +- Given a clean source checkout with the documented build prerequisites or an unpacked release +- When the source preparation/build is repeated in independent build directories and the release executable is relocated without the source tree +- Then both Linux variants have matching source/protocol identity, deterministic per-variant hashes, no dynamic loader or shared-library dependency, and the relocated CLI retains both verified artifacts without guest package installation or runtime downloads + +#### Scenario: A02 - platform selection and tampering +- Given arm64 or amd64 guests, an unsupported guest platform, or an altered/truncated artifact +- When the product provisions and handshakes the relay +- Then supported guests receive their own variant, invalid/unsupported artifacts fail before credential delivery, read-back/handshake verifies the installed bytes and protocol, and no host-architecture substitution or translator installation occurs + +### Requirement: Verification isolates supervised installation state + +Credential-service smoke verification of a development build MUST use a unique temporary LaunchAgent identity and private state paths without replacing or booting out the user's production job, modifying its plist/registry, or permanently pointing it at a development executable. Success and failure paths MUST remove their temporary job, relay processes, endpoints, registrations, and exclusively created container resources. Tests MUST NOT authenticate with real providers or start user containers without separate authorization. + +#### Scenario: V01 - temporary LaunchAgent smoke cleanup +- Given a development build and a pre-existing user installation, with authorized throwaway runtime resources and a synthetic provider +- When isolated smoke succeeds, fails midway, or receives a handled termination signal +- Then temporary supervision demonstrates CLI-exit survival and crash recovery, production installation/state remains unchanged, and cleanup bootouts only the unique test job and removes only test-owned resources +- And the smoke verifies no matching temporary job, relay, container, or endpoints remain; abrupt unhandleable termination has a documented targeted cleanup procedure diff --git a/specs/changes/archive/20261021-automatic-git-credentials/tasks.md b/specs/changes/archive/20261021-automatic-git-credentials/tasks.md new file mode 100644 index 0000000..c350769 --- /dev/null +++ b/specs/changes/archive/20261021-automatic-git-credentials/tasks.md @@ -0,0 +1,146 @@ +# Tasks: automatic-git-credentials + +Archive note (2026-10-21): moved to `specs/changes/archive/20261021-automatic-git-credentials/`. Isolated smoke, Linux target execution, and the complete restart matrix were not executed. The user authorized archive with those limits as non-blocking. Do not read unchecked boxes below as passed. + +## Execution rules and dependencies +Spec ref: `specs/changes/archive/20261021-automatic-git-credentials/` (archived; live contract is `specs/core.md` and `specs/clone.md`). Both approval questions in proposal.md are resolved. This is the implementation handoff; no item below has been executed during spec preparation. Follow phases in order; only marked [P] items may run independently within a phase. Every product behavior change starts with its named tests and a recorded red result before implementation; toolchain/download/harness errors do not count as behavioral failures. Minimal compile scaffolding may make an absent API testable, but must not substitute a passing stub for a test. Register each newly written Swift test group in the same phase before attempting red/green verification. + +Use the documented asset-preparation step before Swift builds. The suite of record remains `swift run adevcontainerTests`; add a test-runner-only focused credential aggregate invoked with `ADEVCONTAINER_CREDENTIAL_TESTS_ONLY=1 swift run adevcontainerTests` so deterministic verification cannot accidentally run existing real-container integration cases. The focused aggregate uses fake providers/runtime/launchd and never touches the user's real job. Run the full existing suite only with its runtime side effects separately authorized, or when live integration prerequisites are absent; never stop the user's runtime just to suppress integration. Smoke uses the separate test-only `ADEVCONTAINER_CREDENTIAL_SMOKE=1` opt-in and additionally requires authorization for throwaway resources. No real GCM authentication, token-cache modification, plantsuite start, or user-container mutation is included in automatic checks. + +## 1. Native guest component and reproducible assets +- [x] 1.1 Write native framing/parser, Git input round-trip (@/:/Unicode), request bounds, socket ownership/symlink, EOF, timeout, concurrent out-of-order reply, get/erase, and store-no-op tests using synthetic credentials; record the expected absent/incorrect relay failure before 1.3 (path: Tests/GuestGitCredentialRelay/relay_tests.c). +- [x] 1.2 Add the native test harness runner using macOS CLT for host tests and the pinned Zig path on Linux; support executing the shipped static amd64 output rather than only linking its parser into a test executable (path: scripts/test-guest-credential-relay.sh). +- [x] 1.3 Implement the small bounded C relay/client, versioned binary framing, user-private socket, per-request IDs/generation, no-follow cleanup, no log/persistent secrets, and no implicit interpreter/package dependency; make 1.1 green (path: Guest/GitCredentialRelay/main.c). +- [x] 1.4 Write build/provisioning tests for bad toolchain checksums, no implicit installed zig requirement, ELF wrong machine/truncation/interpreter/dependencies, two clean-directory rebuilds, generated payload/manifest identity, and package/notice completeness; record red before 1.5-1.8 and 6.3 (path: Tests/GuestGitCredentialRelay/build_artifacts_tests.sh). +- [x] 1.5 Implement checksum-pinned Zig 0.15.2 bootstrap for macOS arm64 source/release and Linux x86-64 build-only CI using the URLs/hashes in design.md, verified cached archives, safe extraction, and repository-local build caches (path: scripts/bootstrap-guest-toolchain.sh). +- [x] 1.6 Implement the host Swift ELF verifier/manifest/base64-source generator using Foundation/CryptoKit; include source/toolchain/protocol identity and deterministic output, fail on invalid metadata, and avoid runtime/executable downloads (path: scripts/prepare-guest-credential-assets.swift). +- [x] 1.7 Implement static C builds for aarch64-linux-musl and x86_64-linux-musl with fixed flags, isolated output/cache roots for reproducibility tests, and an internal raw-only build-script mode for Linux CI; default macOS preparation emits the generated Swift asset file described in design.md (path: scripts/build-guest-credential-relay.sh). +- [x] 1.8 Add the applicable musl/compiler-runtime license/copyright notices from the pinned toolchain, checked by the artifact/package test (path: THIRD_PARTY_NOTICES.md). +- [x] 1.9 Ignore only the generated Swift asset source; raw binaries/toolchain/generated manifests already belong below the ignored build directory (path: .gitignore). + +## Checkpoint: native delivery +Implementation evidence and remaining boundaries: [implementation.md](implementation.md). Task 1.4 now includes passing release-package/relocation completeness checks as well as toolchain/reproducibility/ELF/payload/notices. No Linux executable has been run on this macOS host. Checkmarks above cover implementation plus local verification, not cross-platform CI or the final A01/A02 outcome. +- [ ] Run native harness and artifact build tests; retain red/green evidence. A01 must show both deterministic static ELF variants, correct hashes/protocol identities, no loader/dependencies, and no reliance on installed zig or guest packages. Wrong/corrupt binaries must fail verification for A02. Linux-target execution is still pending phase 6 and is not inferred from a macOS/ELF-only result. + +## 2. Pure policy, protocol, and embedded artifact selection +Depends on phase 1 generated assets. Protocol/policy tests are independent of launchd/runtime/provider execution. +- [x] 2.1 Write deterministic destination authorization, scheme/port binding, sibling/username compatibility, no guest account override, finite rejection cooldown, late erase/new credential, explicit reset, bounded fairness, and generation tests for C05/C08/C09/C10/C14/C19; record red before 2.3 (path: Tests/adevcontainerTests/GitCredentialBrokerPolicyTests.swift). +- [x] 2.2 Register this phase's written tests and add the test-only focused credential aggregate without changing default production CLI behavior or adding public flags; extend registration as later phases add groups (path: Tests/adevcontainerTests/main.swift). +- [x] 2.3 Implement policy with injected monotonic time, bounded recent-delivery fingerprints keyed in memory, no provider store/reject, fair admission and generation fencing; make 2.1 green (path: Sources/ADevContainerLib/Git/GitCredentialBrokerPolicy.swift). +- [x] 2.4 Write host codec tests matching native frame fixtures, fragmented/oversized/truncated messages, duplicate/conflicting fields, newline/NUL injection and out-of-order IDs; record red before 2.5 (path: Tests/adevcontainerTests/GitCredentialProtocolTests.swift). +- [x] 2.5 Implement the bounded binary codec and typed safe outcomes, sharing protocol identity with generated assets and matching native wire fixtures; make 2.4 green (path: Sources/ADevContainerLib/Git/GitCredentialProtocol.swift). +- [x] 2.6 Write embedded bytes/hash/ELF/protocol selection tests for arm64, amd64, unsupported guest platform, corrupted payload, relocatable release use, and read-back mismatch; record red before 2.7 (path: Tests/adevcontainerTests/GuestGitCredentialArtifactTests.swift). +- [x] 2.7 Implement verified embedded artifact access and actual-guest-platform selection, not host-architecture fallback; generated source is an output, not a hand-edited second source of truth (path: Sources/ADevContainerLib/Git/GuestGitCredentialArtifacts.swift). + +## Checkpoint: pure behavior +- [x] Run the focused aggregate after asset preparation; C05/C08/C09/C10/C14/C19/A02 pass without launchctl, container execution, secrets, sleeps, or real providers. Native/Swift framing fixtures agree. Confirm all newly written tests are registered and actually executed, not just compiled. + +## 3. Runtime duplex and bounded noninteractive host acquisition +- [x] 3.1 Write process tests for duplex fragmentation/EOF, inverted replies, stderr drainage without tee, bounded queues, hung child plus descendant cancellation/reaping, safe errors and no secret argv/environment; record red before 3.2 (path: Tests/adevcontainerTests/CredentialDuplexProcessTests.swift). +- [x] 3.2 Implement duplex child lifetime, nonblocking pipe drainage and cancellation with process-group ownership established at spawn; do not redesign unrelated buffered/TTY execution (path: Sources/ADevContainerLib/Runtime/CredentialDuplexProcess.swift). +- [x] 3.3 Write machine-JSON credential-context and exec-argv tests for incarnation creationDate, managed identity, actual guest platform, missing/invalid metadata, resolved user, no TTY, and stdin transfer/read-back; record red before 3.4 (path: Tests/adevcontainerTests/GitCredentialRuntimeTests.swift). +- [x] 3.4 Add the dedicated credential duplex/context adapter, using optional credential-only metadata parsing and existing runtime boundaries; ensure the current buffered exec/other commands retain their contracts (path: Sources/ADevContainerLib/Runtime/AppleContainerRuntime.swift). +- [x] 3.5 Write provider tests for existing precedence, actual Azure path/no pathless GCM, scheme/port, disabled Git/GCM GUI/TTY prompts, nil versus safe failure, bounded hangs, and no approve/reject/store; record red before 3.6-3.8 (path: Tests/adevcontainerTests/AutomaticHostGitCredentialTests.swift). +- [x] 3.6 Implement the deadline-aware provider process adapter with safe structured failure, bounded output, process-group cancellation and no leaked secrets; use the lifetime primitives from 3.2 rather than duplicate them (path: Sources/ADevContainerLib/Runtime/CredentialProviderProcessRunner.swift). +- [x] 3.7 Reuse shared host acquisition with deadline/cancellation and provider-interaction suppression; keep explicit token precedence without propagating it to children and retain nil fallthrough, no OAuth implementation (path: Sources/ADevContainerLib/Git/HostGitCredential.swift). +- [x] 3.8 Preserve actual HTTP(S) scheme/nondefault port in credential fields, keeping normalized hostname separate from Git's host-with-port to retain Azure recognition; forbid field/control injection and retain SSH classification while applying broker authorization separately (path: Sources/ADevContainerLib/Git/GitURLKind.swift). + +## Checkpoint: adapters +- [x] Focused tests prove C04/C05/C07/C10/C11/C14/C15/C19/A02, including cancellation of a synthetic hung provider/descendant. No raw stderr or credential payload is teed. Malformed/missing creationDate/platform fails closed only for unattended credential recovery; no guessed host-platform fallback is accepted. + +## 4. Supervision, registry, recovery, and guest migration +- [x] 4.1 Write tests with fake launchctl/process/clock/control peers for simultaneous bootstrap, singleton lock, immutable identity revalidation, private host IPC, corrupt/unsafe registry, ephemeral override restart, no token polling, fair requests, reconnect/backoff and teardown; record red before 4.2-4.4 (path: Tests/adevcontainerTests/GitCredentialBrokerServiceTests.swift). +- [x] 4.2 Implement the nonsecret, atomic owner-private registry and safe paths; retain override-required metadata but no token/rejection values; make registry cases in 4.1 green (path: Sources/ADevContainerLib/Git/GitCredentialBrokerRegistry.swift). +- [x] 4.3 Implement broker orchestration over policy/provider/runtime ports, request correlation, bounded acquisition, ephemeral overrides, identity reconciliation, generation fences, no replay and bounded-backoff reconnection (path: Sources/ADevContainerLib/Git/GitCredentialBroker.swift). +- [x] 4.4 Implement the per-user LaunchAgent adapter/control IPC/singleton lock, installed-executable path repair and supervisor restart policy; dependency-inject job label/state/executable/launchctl for tests, never expose the control socket to guests (path: Sources/ADevContainerLib/Git/GitCredentialBrokerService.swift). +- [x] 4.5 Write provisioning/migration tests for connection-user ownership, asset read-back/hello, Azure exactly-once helper/useHttpPath/empty-reset removal at HTTPS scope and at HTTP scope only for registered legacy HTTP, preserved helper order, old product-store removal, untouched unmarked legacy clone stores, no secret seed fallback, skip/soft errors, and healthy reuse; record red before 4.6 (path: Tests/adevcontainerTests/GitCredentialConnectionTests.swift). +- [x] 4.6 Implement shared setup/discovery/registration/migration and bounded teardown, including session metadata and the product-owned wrapper/native binary; only product-owned seeded files may be removed and no contents logged/read for migration (path: Sources/ADevContainerLib/Git/GitCredentialConnection.swift). +- [x] 4.7 Explicitly build the CLI product and write safe subprocess internal-entry dispatch tests using invalid/isolated contexts, ensuring default CLI/help/plugin behavior stays unchanged and no test selects the production job; record red before 4.8 and rebuild after entry changes (path: Tests/adevcontainerTests/GitCredentialBrokerEntryTests.swift). +- [x] 4.8 Route the internal broker mode through the existing executable entry with secret-free arguments and injected private test context support, without public credential-service flags (path: Sources/adevcontainer/AdevcontainerMain.swift). + +## Checkpoint: service policy and provisioning +- [x] C01/C02/C03/C04/C06/C08/C09/C10/C11/C12/C13/C14/C15/C16/C17/A02 pass at the connection/service boundary with fake adapters; command call-site ordering remains for phase 5. Validate lost explicit overrides do not switch source after restart, failed sessions cannot authorize a new same-named incarnation, and stale-socket cleanup cannot delete unrelated paths. Keep legacy seed APIs until phase 5 moves their callers/tests; this checkpoint does not claim that real launchd restart or Linux execution has passed. + +## 5. Command integration and regression coverage +- [x] 5.1 Write command tests for all lifecycle setup/teardown ordering, setup soft errors versus explicit refresh hard error, identity replacement, running reuse, sources, missing Git/remotes/volume URL, no provider lookup on healthy reuse, stopped refresh refusal and no fetch/push/workspace writes; record red before 5.3-5.9 (path: Tests/adevcontainerTests/AutomaticGitCredentialsCommandTests.swift). +- [x] 5.2 Replace obsolete seeding command assertions with automatic connection behavior while retaining managed selection, secret redaction, lifecycle recovery and existing unrelated command coverage (path: Tests/adevcontainerTests/AllCommandTests.swift). +- [x] 5.3 Connect fresh/start-stopped/running-reuse setup at the agreed before-hook boundary with soft errors (path: Sources/ADevContainerLib/Commands/UpCommand.swift). +- [x] 5.4 Connect real-start setup before postStart and healthy/broken running reuse handling without changing attach/settings behavior (path: Sources/ADevContainerLib/Commands/StartCommand.swift). +- [x] 5.5 Use new replacement identity/registration before hooks for bind and volume rebuild, invalidating old pending requests without changing volume retention/recovery semantics (path: Sources/ADevContainerLib/Commands/RebuildCommand.swift). +- [x] 5.6 Preserve running-only selection; ensure/recover/re-register, clear session suppression and validate discovered acquisition without seeding or repository operations, returning safe structured setup failure (path: Sources/ADevContainerLib/Commands/RefreshCredentialsCommand.swift). +- [x] 5.7 Unregister/cancel sessions around stop using bounded cleanup without masking the existing primary runtime error (path: Sources/ADevContainerLib/Commands/StopCommand.swift). +- [x] 5.8 Unregister/cancel sessions around container removal without changing foreign-resource protections (path: Sources/ADevContainerLib/Commands/DeleteCommand.swift). +- [x] 5.9 Unregister/cancel the selected container's sessions while retaining shared-volume preservation and deletion rules (path: Sources/ADevContainerLib/Commands/PurgeCommand.swift). +- [x] 5.10 Write clone tests for Azure/non-Azure/legacy HTTP, helper before populate/hooks, public miss/private failure, reuse-populated setup, no new store/approve/secret env, SSH unchanged, and failed-clone session/volume cleanup; record red before 5.11 (path: Tests/adevcontainerTests/CloneInVolumeTests.swift). +- [x] 5.11 Replace the HTTPS/legacy HTTP one-shot guest seed/store route with shared automatic setup, including populate/failed-clone cleanup; retain host config-only fetch and SSH behavior, with no second full-clone mechanism (path: Sources/ADevContainerLib/Commands/CloneCommand.swift). +- [x] 5.12 Replace superseded seeded-helper expectations with automatic-helper/migration expectations, preserving input escaping, queried username, provider fallthrough, private files, and redaction regressions; remove only obsolete changed behavior (path: Tests/adevcontainerTests/AllUnitTests.swift). +- [x] 5.13 Extend the focused aggregate to all new credential groups and updated relevant legacy groups; retain normal suite registration, including clone/rebuild regressions, and register the phase 6 smoke when its test file lands. Ensure mock-runtime command tests cannot accidentally reach the production broker/launchctl and all new groups execute (path: Tests/adevcontainerTests/main.swift). +- [x] 5.14 After 5.3-5.12 move callers/tests, retire obsolete token seed/write/store helper code; retain/move only still-used discovery/compatibility logic and remove references made dead by this change (path: Sources/ADevContainerLib/Git/GuestGitCredentialSeed.swift). + +## Checkpoint: command conformance +- [x] C01-C19 and L01-L03 pass through fake command/runtime/service integration, not direct policy tests alone. Confirm no new literal token files, product seed fallback, GCM-in-guest integration, public flags, or user helper resets. Full clone still verifies .git and respects reused-volume/SSH behavior. Re-run existing up/start/rebuild recovery tests under fake dependencies before authorizing a real smoke. + +## 6. Distribution, documentation, isolated smoke, and final verification +- [x] 6.1 Extend the artifact/package tests from 1.4 to fail on missing embedded targets/notices, a relocated binary requiring its source/generated tree, or archive layout drift; record red before 6.3-6.5 (path: Tests/GuestGitCredentialRelay/build_artifacts_tests.sh). +- [x] 6.2 Run preparation/native tests before the existing macOS test/build job, and add a Linux x86-64 native-only job using the pinned compiler/raw artifacts to exercise the actual static amd64 binary; Linux does not build the macOS CLI or require Apple container (path: .github/workflows/ci.yml). +- [x] 6.3 Implement repeatable release packaging of the single embedded host binary and applicable notices with COPYFILE_DISABLE, preserving tarball naming/checksum behavior; assert artifact completeness rather than downloading anything during packaging (path: scripts/package-release.sh). +- [x] 6.4 Prepare/verify native assets before tests and release Swift build, use the tested packaging script, and preserve tag/version/Homebrew publishing behavior (path: .github/workflows/release.yml). +- [x] 6.5 Keep installing the single CLI and install accompanying notices as documentation; no separately installed guest executable or sudo step (path: scripts/render-homebrew-formula.sh). +- [x] 6.6 Document source preparation/toolchain checksum/cache/network prerequisites, production automatic LaunchAgent behavior, HTTP/sibling/Azure scope, earlier-helper limitations, refresh semantics and host-login guidance without claiming OAuth renewal (path: README.md). +- [x] 6.7 Document native red/green commands, generated-source handling, supported guest architectures, reproducibility/ELF checks and the safe focused test command; distinguish full suite runtime authorization from fake tests (path: CONTRIBUTING.md). + +Distribution checkmarks record local implementation, syntax validation and passing +release-package relocation checks; they do not claim execution of GitHub Actions, +Homebrew installation, Linux target execution or live arm64/launchd smoke. Those +remain explicitly unchecked below. Phase-4/5 fake-adapter acceptance is locally +verified; it does not include real launchd, a guest container, or Linux execution. +- [x] 6.8 Write isolated smoke harness tests first: unique job/context, production hashes untouched, synthetic provider, cleanup on success/failure/handled signals, and targeted cleanup manifest for unhandleable termination; record red before enabling live smoke (path: Tests/adevcontainerTests/GitCredentialBrokerSmokeTests.swift). +- [x] 6.9 Implement the opt-in smoke harness around the tested library adapters/internal executable mode; use a unique temporary launchd label/state/socket root and exclusively created containers, with an isolated host Git config/local fixture helper for credential-smoke.invalid and sanitized provider environment. Exclude user config/credential stores/explicit tokens/traces and real network authentication; never use the production job or an existing user container (path: Tests/adevcontainerTests/GitCredentialBrokerSmokeHarness.swift). +- [x] 6.10 Document the real-smoke authorization prerequisites, approved temporary root, synthetic credential-only assertions, unique job/resource manifest, and exact targeted cleanup/absence checks, including SIGKILL recovery and unchanged production-job baseline; no broad launchctl/container cleanup commands (path: Tests/GuestGitCredentialRelay/SMOKE.md). +- [ ] 6.11 After authorization and prerequisite checks, execute the opt-in smoke on an exclusively created arm64 guest with an approved Git-containing image (no package installation), testing native get/erase, CLI-exit survival, real launchd crash restart, stale socket repair, pending cancellation and out-of-order concurrency; inject a failure to verify cleanup and record the production baseline after both runs (path: Tests/adevcontainerTests/GitCredentialBrokerSmokeTests.swift). + +## Checkpoint: release and safety +- [ ] A01/A02/V01 and C07/C10-C16 pass with real supervised synthetic-provider smoke and the shipped arm64 relay; Linux CI separately executes the shipped amd64 relay. Missing runtime/image/permission is an explicitly blocked smoke, not a passing/skipped claim of target execution. +- [ ] Run asset build/native tests, the focused credential aggregate, release build/package relocation checks, and (when separately authorized or no real-runtime prerequisites exist) `swift run adevcontainerTests`. Fix warnings/lint findings introduced by the change; suppressing them is not a fix. Confirm generated binaries/Swift source are not staged and no unrelated files/wiki changes were made. +- [ ] Review scenario-to-test conformance against the persisted spec, inspect secret-flow/IPC/generation/cancellation and supervision failure paths, and report actual evidence versus untested scope. Real provider authentication/OAuth renewal remains out of this automatic smoke. Do not archive without separate confirmation. + +## Scenario-to-task coverage + +Every scenario below maps to tests and implementation; phase checkpoints exercise the group. Repeated test registration is mandatory through 2.2/5.13. All rows are planned coverage, not a claim of passing product tests. + +| Scenario | Test-first tasks | Implementation / verification tasks | +|---|---|---| +| C01 create/rebuild before hooks | 4.5, 5.1, 5.2 | 4.6, 5.3, 5.5 | +| C02 start/reuse | 4.5, 5.1 | 4.3, 4.6, 5.3, 5.4 | +| C03 skips/soft failures | 3.5, 4.5, 5.1 | 3.7, 4.6, 5.3-5.5 | +| C04 Azure helper/path | 3.5, 4.5, 5.10 | 3.7, 3.8, 4.6, 5.11 | +| C05 scope/username | 2.1, 3.5 | 2.3, 3.7, 3.8, 4.3 | +| C06 earlier-helper order | 4.5, 5.12 | 4.6, 5.14, 6.6 | +| C07 fresh lookup/noninteractive | 3.1, 3.5, 5.10 | 3.2, 3.6, 3.7, 4.3, 5.11, 6.11 | +| C08 store/correlated erase | 1.1, 2.1 | 1.3, 2.3, 4.3 | +| C09 finite rejection/reset | 2.1, 5.1 | 2.3, 4.3, 5.6 | +| C10 hostile messages/isolation | 1.1, 2.1, 2.4, 4.1 | 1.3, 2.3, 2.5, 4.3, 4.4, 6.11 | +| C11 secrets/private endpoints | 1.1, 3.1, 4.1, 4.5 | 1.3, 3.2, 3.6, 4.2, 4.4, 4.6 | +| C12 ephemeral override restart | 3.5, 4.1 | 3.7, 4.2, 4.3 | +| C13 supervised singleton | 4.1, 4.7, 6.8 | 4.4, 4.8, 6.9, 6.11 | +| C14 correlation/overload | 1.1, 2.1, 2.4, 3.1, 4.1 | 1.3, 2.3, 2.5, 3.2, 4.3, 6.11 | +| C15 recovery/fence | 1.1, 3.1, 3.3, 4.1, 6.8 | 1.3, 3.2, 3.4, 4.3, 4.6, 6.11 | +| C16 stop/remove/reconcile | 4.1, 5.1, 5.10, 6.8 | 4.3, 4.6, 5.7-5.9, 5.11, 6.11 | +| C17 safe migration | 4.5, 5.12 | 4.6, 5.14, 5.3, 5.4, 5.6 | +| C18 refresh running/stopped | 5.1, 5.2 | 4.6, 5.6 | +| C19 legacy HTTP binding | 2.1, 3.5, 5.10 | 2.3, 3.7, 3.8, 4.6, 5.11 | +| L01 one HTTP(S) clone mechanism | 5.10 | 4.6, 5.11 | +| L02 fallback/reuse/cleanup | 5.10 | 4.3, 4.6, 5.11 | +| L03 SSH unaffected | 5.10 | 5.11 | +| A01 pinned reproducible distribution | 1.4, 2.6, 6.1 | 1.5-1.9, 2.7, 6.2-6.7 | +| A02 guest variant/integrity | 1.4, 2.6, 3.3, 4.5 | 1.6, 1.7, 2.7, 3.4, 4.6, 6.2, 6.11 | +| V01 isolated smoke cleanup | 4.1, 6.8 | 4.4, 6.9-6.11 | + +## Analyze: PASS + +- User approvals: bundled static Linux delivery/build footprint and preserved legacy HTTP are recorded in proposal.md; zero unresolved clarification markers remain. +- Live-contract routing: core's complete seeded-store requirement/scenarios are explicitly replaced; clone's HTTP-inclusive HTTPS row/shared paragraph and three named credential scenarios are replaced, preserving other populate, SSH, verification, author identity and cleanup contracts. No other active deltas were present at baseline. +- Normative coverage: both modified requirements and both added requirements have testable scenarios; all 25 scenarios map above to test-first tasks, implementation paths and phase checkpoints. Design guarantees are represented in the spec rather than introduced only as rationale. +- Executability: toolchain is not assumed installed; official fixed archive/checksums, two guest targets, source preparation, generated output, integrity checks, single-binary packaging and target execution paths are specified. Source build/CI preparation precedes Swift tests; pure tests precede adapters, service and commands; production supervision is never used as a development test fixture. +- Scope: no product implementation, artifact build/download, authentication, runtime start, wiki edit, realized-spec edit, archive or test execution occurred during this planning phase. PASS is a documentation/readiness result, not production certification or approval to mutate user installation state. +- Preservation: the three wiki modifications and baseline commit recorded in proposal.md remain unchanged; only this active change directory is edited. Implementation handoffs must retain the Spec ref and obtain runtime/auth authorization before effects beyond fake tests. diff --git a/specs/changes/archive/20261021-git-broker-idle-shutdown/proposal.md b/specs/changes/archive/20261021-git-broker-idle-shutdown/proposal.md new file mode 100644 index 0000000..7b78adc --- /dev/null +++ b/specs/changes/archive/20261021-git-broker-idle-shutdown/proposal.md @@ -0,0 +1,27 @@ +# Proposal: Bound Git broker lifetime to managed credential sessions + +Archived on 2026-10-21 after explicit user confirmation and coordinator-confirmed independent approval. The three added requirements and eleven scenarios are consolidated in [core.md](../../../core.md); execution and review evidence remain in [tasks.md](tasks.md). + +## Intent +The per-user Git credential broker currently runs indefinitely, including after its last container stops and on later logins. Retain automatic connection setup and crash recovery without leaving an idle broker permanently running. + +## Scope +Add a bounded lifetime contract to the core Git credential requirements. Cover first-session activation before clone/hooks, last-session retirement, external stop/removal, registration/shutdown races, rapid restart, and launchd crash supervision. Preserve the realized core and clone contracts except for this explicit lifetime refinement; the archived automatic-git-credentials bundle is immutable. + +## Non-goals +No refresh-credentials command, public grace/recheck flags, new dependencies or daemon, provider renewal changes, real credential access, production service mutation, or monitoring of unrelated Mac containers. No changes to token override, rejection cooldown, helper ordering, workspace/volume cleanup, or existing warning-only setup behavior. No automatic discovery/adoption of containers that have no credential registration. + +## Approach +Keep the existing installed binary, private registry/control socket, launchd adapter, and lifecycle setup sites. Distinguish a dormant loaded job from a running broker, make successful idle exit non-restarting, and explicitly activate a dormant job for new registration. Base idleness on registered/in-progress demand, not on time since the last Git lookup or whether a relay is currently connected. Revalidate before atomically committing to retirement; a registration crossing that boundary must reconnect/reactivate within its existing deadline. + +## Decision index +- **Timing:** use an internal 5-second continuous zero-demand grace and a maximum 2-second recheck cadence while probes/control handling are responsive. This retains the current reconciliation rhythm, absorbs rapid stop/start, and removes an idle process in at most 7 seconds under healthy local conditions; it is deliberately independent of the 30-second rejection cooldown. +- **Unknown is not stopped:** runtime transport failure, timeout, invalid machine output, or an ambiguous inspect failure cannot prove a container stopped. Preserve the nonsecret registration and defer idle retirement while its state is unknown; suspend unsafe delivery/reconnect until identity is validated. Definitive machine-readable stopped/absent/replaced state permits invalidation. Runtime work remains deadline-bounded; no healthy-runtime retirement guarantee is claimed during an outage. +- **Ownership and races:** make the final empty-state decision and registration admission share a serialized state boundary; retain the existing singleton lock through endpoint cleanup. Requests not admitted before retirement retry activation against the successor, never succeed against a retiring instance. Avoid waiting for runtime calls or activation locks while holding broker state locks. +- **Supervision:** normal idle exit is success; conditional launchd failure restart replaces unconditional KeepAlive. A loaded dormant job is explicitly started without killing a healthy broker. RunAtLoad may perform one bounded reconciliation at login/bootstrap, but an empty registry must end in dormant state rather than restart forever. SIGTERM/SIGINT cleanup retains existing intentional-stop semantics; abnormal process death remains recoverable. +- **Compatibility:** provisioning and registration still finish before HTTP(S) populate/hooks when setup succeeds, healthy reuse does not poll providers or duplicate sessions, unregister does not activate a stopped broker, and explicit token overrides are not recovered from disk after a crash. +- **Resumable migration:** on-disk target bytes alone do not prove the loaded job was upgraded. A private, nonsecret `loaded-definition.plist` receipt is invalidated durably before migration and atomically committed only after successful bootstrap; missing/mismatched confirmation forces safe replacement even when disk already matches. Bootout precedes target-plist replacement, failures remain failures, and confirmed healthy registrations only kickstart idempotently rather than reload every time. +- **Verification isolation:** add a test-executable-only probe using the production service loop/launchd adapter with synthetic runtime ports. A separately authorized opt-in real-launchd test can exercise PID/restart behavior without containers, network, host Git providers, or production paths. Keep the existing credential smoke safety gate intact. +- **Artifact routing:** no design.md is needed: concise decisions here and ordered tasks cover the bounded lifecycle flow. No overlapping active change exists; the earlier automatic-git-credentials bundle is archived. Archive-time integration will add this contract to core.md, not rewrite history. + +Pre-change source validation: GitCredentialBrokerService emitted RunAtLoad=true/KeepAlive=true; GitCredentialControl ran until an external signal and reconciled every two seconds; unregister removed only one registration. GitCredentialBroker.reconcile treated all context failures as absence, requiring correction before idle exit; the runtime context adapter also conflated failed inspect with invalid context. Wiki credential-seeding text is stale relative to source and the realized automatic-connection contract; it is descriptive only and is not edited here. CodeGraph was attempted first, but its CLI is unavailable and no MCP explore tool is exposed; targeted source inspection was used instead. diff --git a/specs/changes/archive/20261021-git-broker-idle-shutdown/spec.md b/specs/changes/archive/20261021-git-broker-idle-shutdown/spec.md new file mode 100644 index 0000000..ff10cf6 --- /dev/null +++ b/specs/changes/archive/20261021-git-broker-idle-shutdown/spec.md @@ -0,0 +1,85 @@ +# Change Spec: git-broker-idle-shutdown + +Target domain: core. The requirements below supplement **Git credential store seeded on create paths** in [core.md](../../../core.md) and its clone cross-reference. Existing acquisition, authorization, privacy, migration, cooldown, soft-failure, and cleanup requirements remain in force. Crash supervision remains required; successful idle retirement is not a crash. + +## ADDED Requirements + +### Requirement: Demand-bound Git broker lifetime +The per-user credential broker MUST run for registered managed containers needing the existing HTTP(S) credential connection, including temporarily disconnected sessions and registration/control work in progress. It MUST NOT retire merely because no Git lookup occurs, no credentials are available, an explicit override awaits re-registration after a crash, or a channel is reconnecting. It MUST NOT adopt unrelated containers or broaden the existing destination/discovery scope. + +The first eligible lifecycle setup MUST automatically activate the broker, without sudo or a new public command/flag, and complete the connection before HTTP(S) full clone or applicable hooks when setup succeeds. Existing setup failures MUST remain bounded, redacted, warning-only failures with the existing remediation; hook and clone failures retain their own semantics. Healthy running reuse MUST NOT duplicate the connection or acquire credentials merely to keep the broker alive. Unregister/teardown MUST NOT start a dormant broker. + +After the last registration is definitively invalidated or unregistered and no admitted control/request work remains, the broker MUST retire successfully after a continuous 5-second zero-demand grace, not immediately. Registration during grace MUST cancel retirement. Idle eligibility MUST be reconsidered at intervals no greater than 2 seconds when runtime probes and control handling are responsive. Under healthy, promptly responding local adapters, retirement MUST complete no later than 7 seconds after demand reaches zero; external stop/removal MUST be observed within 2 seconds and retirement complete within 9 seconds of that event. These are internal timing constants, not public configuration. Outstanding work MUST retain its existing finite deadlines; unregistered work MUST be fenced/cancelled rather than extend lifetime indefinitely. No provider polling or credential acquisition is permitted for reconciliation, activation, or idle detection. + +#### Scenario: I01 - first eligible container prepares before consumers +- Given no running broker and a managed container with discovered HTTP(S) destinations +- When fresh up, clone, rebuild replacement, real start, up start-stopped, or running up/start repair prepares its connection +- Then one broker is automatically activated and a successful setup connects before full HTTP(S) clone or applicable hooks, without a new public command/flag +- And no-source/SSH-only skips do not activate it, and an actual setup failure retains the existing soft-failure behavior + +#### Scenario: I02 - last unregister retires but another session keeps service alive +- Given two registered managed containers and no in-progress request +- When one unregisters and later the last unregisters +- Then the first removal leaves the remaining session usable, and the last removal starts the grace and retires the broker successfully between 5 and 7 seconds later under healthy local conditions +- And teardown against a dormant broker does not activate it + +#### Scenario: I03 - registered inactivity and reconnect are not idle +- Given a registered running container, including a disconnected channel in bounded backoff, a provider miss, or a crash-recovered explicit-override registration awaiting re-registration +- When more than 7 seconds elapse without a successful Git lookup +- Then the service does not retire for idleness, does not poll credentials, and preserves authorization/override fencing and existing recovery behavior + +#### Scenario: I04 - rapid stop/start cancels grace +- Given the last session was removed and the broker is inside its 5-second grace +- When an eligible lifecycle setup registers a running container +- Then retirement is cancelled, setup connects before its consumers, and a later last-session removal starts a new full grace + +### Requirement: Revalidated and race-safe broker retirement +Before idle retirement the broker MUST revalidate outstanding registered demand against authoritative runtime state and serialize the final no-demand decision with registration admission. A successful registration MUST never be acknowledged by an instance already committed to idle retirement. A registration that loses the retirement race MUST retry activation/IPC within its existing setup deadline, without creating a second registry owner or falsely reporting successful setup. Cleanup of an old instance MUST NOT delete a successor's endpoint or restore an obsolete registration/generation. + +Definitively stopped, removed, unmanaged, or identity-replaced registered containers MUST lose their usable sessions and pending replies within bounded reconciliation. A failed/timeout/unparseable/ambiguous runtime observation MUST NOT be treated as proof of absence or allow idle shutdown of potentially active registered demand. Such registrations MUST remain nonsecret recovery intent until definitive revalidation or explicit unregister; unvalidated identity MUST NOT be used to deliver credentials or establish a new channel. A transient runtime outage alone MUST NOT cancel an already admitted valid request or replay it. Existing request deadlines and explicit invalidation fencing still apply. When authoritative state becomes available again, recovery or invalidation MUST resume automatically. Only registered managed-container identities are candidates; name reuse MUST NOT inherit the old session. + +#### Scenario: I05 - external runtime stop or removal +- Given the last registered managed container and healthy, promptly responding runtime probes +- When it is stopped or removed outside adevcontainer +- Then its session and pending replies are invalidated within 2 seconds, and the process retires after the 5-second grace and no later than 9 seconds after the event without provider polling + +#### Scenario: I06 - runtime uncertainty preserves demand safely +- Given a registered container and an admitted request +- When runtime observation temporarily fails, times out, or returns invalid/ambiguous output +- Then the observation does not remove the registration, cancel that request solely for that reason, start idle retirement, authorize an unvalidated replacement, or replay a request +- And existing request deadlines remain in force; later valid observation reconnects the same identity or invalidates a definitively stopped/absent/replaced identity and permits a fresh idle grace + +#### Scenario: I07 - registration versus final shutdown boundary +- Given an empty broker whose grace has expired +- When registration races its final recheck and retirement commitment, including runtime-validation callbacks completing out of order +- Then registration either cancels retirement and connects on that owner, or retries against an activated successor within the existing deadline; it is never acknowledged and then discarded by idle shutdown +- And only one owner exists, late old-generation results cannot remove a newer session, and old cleanup cannot unlink the successor socket + +### Requirement: Idle exit without permanent launchd restart +The existing per-user launchd supervision MUST distinguish successful idle retirement from failure. Successful idle exit MUST leave no running broker and MUST NOT schedule an automatic restart loop. A subsequent eligible lifecycle setup MUST start a loaded-but-dormant job as well as an absent job without killing a healthy owner. A crash while registered demand exists MUST still restart automatically, reconcile saved nonsecret identities, and recover channels without another host executable or invoking lifecycle command; existing explicit-override and stale-generation fences remain in force. + +A login/bootstrap with no eligible saved sessions MAY start a bounded reconciliation process but MUST settle to no running broker after the zero-demand grace and MUST remain dormant without new demand. Saved stale registrations MUST be revalidated rather than keeping the process permanently alive once authoritative runtime state proves they are inactive. Existing installed unconditional-restart definitions MUST be replaced on the next eligible activation without requiring manual service repair; existing healthy registration intent MUST survive this update. No new daemon or public lifecycle command is permitted. + +Definition migration MUST be safely resumable after failure or interruption. Matching on-disk target bytes alone MUST NOT be treated as proof that the loaded definition was updated, and an ensure attempt MUST NOT report success while leaving the obsolete loaded definition in place. Confirmed healthy reuse MUST NOT reload the job on every registration. + +#### Scenario: I08 - clean idle exit stays dormant and later activates +- Given the last session is gone and the service exits successfully for idleness +- When more than two configured launchd throttle intervals pass without new demand and then a new eligible setup occurs +- Then no automatic restart was observed in that interval, and the setup starts the loaded dormant job and connects normally without duplicate ownership + +#### Scenario: I09 - crash with active sessions recovers +- Given saved nonsecret registrations for running managed containers and a supervised broker +- When the process crashes or is killed abnormally +- Then launchd automatically starts a replacement, which revalidates identities and reconnects eligible channels without a lifecycle invocation or provider polling +- And pending old requests fail within their deadlines, stale generations are rejected, and explicit overrides still require private re-registration + +#### Scenario: I10 - login and stale registrations settle to dormant +- Given an installed job at login/bootstrap and either an empty registry or saved registrations authoritatively shown to be inactive +- When startup reconciliation completes +- Then no session is invented, zero-demand grace leads to successful retirement within the healthy-runtime timing bounds, and no repeated idle restarts occur + +#### Scenario: I11 - upgrade unconditional supervision safely +- Given an existing installed unconditional-restart definition and nonsecret registration intent +- When the next eligible setup ensures the service +- Then the definition is updated automatically, valid session intent is retained, setup reconnects within its deadline, and subsequent idle exit stays dormant while crash recovery remains enabled +- And a failed or interrupted migration resumes safely on the next eligible attempt without falsely accepting an obsolete loaded definition or repeatedly replacing a confirmed healthy job diff --git a/specs/changes/archive/20261021-git-broker-idle-shutdown/tasks.md b/specs/changes/archive/20261021-git-broker-idle-shutdown/tasks.md new file mode 100644 index 0000000..9d742b3 --- /dev/null +++ b/specs/changes/archive/20261021-git-broker-idle-shutdown/tasks.md @@ -0,0 +1,80 @@ +# Tasks: git-broker-idle-shutdown + +Spec ref: `specs/changes/archive/20261021-git-broker-idle-shutdown/`. Execution preserved pre-existing workspace changes. Each task names one primary file. Source-spec consolidation was separately authorized after independent verification and is recorded in section 7; wiki and the prior archive remain untouched. + +## 1. Test-first contract +- [x] 1.1 Write failing service-idle, unknown-runtime, and launchd-policy assertions before production changes; add deterministic-clock demand/grace, provider-pending, retirement admission, and stale observation barrier tests. Confirm the new API tests fail to compile against the old broker before implementing those APIs. Add a behavioral red for late inactive observation versus healthy same-identity re-registration, then fix it with a separate state revision fence. (path: Tests/adevcontainerTests/GitCredentialBrokerServiceTests.swift) +- [x] 1.2 Write tests for running/stopped/absent/unknown machine observations, ambiguous nonzero inspect, malformed payloads and deadline exhaustion before adding the richer adapter API. During self-review, add and run a further behavioral red for malformed fallback inventory falsely proving absence, then require structural inventory validity before declaring an id absent. (path: Tests/adevcontainerTests/GitCredentialRuntimeTests.swift) +- [x] 1.3 Model loaded and running launchd state separately, assert non-killing dormant kickstart, singleton activation, conditional failure restart, and old-definition replacement while preserving registry intent. (path: Tests/adevcontainerTests/GitCredentialBrokerServiceTests.swift) + +## Checkpoint +- [x] Baseline safe synthetic suite: 1180 passed. Initial behavioral red: 81 passed / 3 failed, specifically unknown observation deleting a registration, empty service never returning, and the unconditional KeepAlive definition. API red: missing `retireIfIdle`, `observe`, and `credentialObservation`. Additional behavioral race red: 87 passed / 1 failed because an older inactive observation removed a healthy re-registration. Logs are named below; do not misrepresent compile-red API coverage as executed assertions against the old implementation. + +## 2. Existing lifecycle boundary only +- [x] 2.1 Add bounded running/inactive/unknown observations. A failed inspect is never absence; only successful machine inventory can confirm missing identity. Keep ordinary setup validation strict and never adopt unrelated inventory entries. (path: Sources/ADevContainerLib/Runtime/AppleContainerRuntime.swift) +- [x] 2.2 Add internal 5-second grace, registered/pending demand, lock-serialized retirement, validated-identity gating, and revision-fenced reconciliation. Unknown observations preserve recovery intent and do not cancel admitted work solely for observation failure; completed work cannot leak credentials to an unvalidated identity. Keep runtime/provider calls outside the state lock. (path: Sources/ADevContainerLib/Git/GitCredentialBroker.swift) +- [x] 2.3 Use KeepAlive.SuccessfulExit=false, retain RunAtLoad/throttle, start loaded dormant jobs with non-killing kickstart, and migrate old definitions without deleting registration state. Healthy ensure may issue idempotent kickstart but never uses `-k`. (path: Sources/ADevContainerLib/Git/GitCredentialBrokerService.swift) +- [x] 2.4 Return normally after committed idle retirement; retain singleton ownership until inode-safe socket cleanup; re-ensure activation after transport loss inside the same request deadline. Schedule rechecks from their start time, not completion, at two-second intervals. Unregister remains non-activating. A later empirical run exposed a pre-existing bind/chmod window: a racing client correctly rejected the not-yet-private socket. Create the socket owner-private at bind under a briefly serialized restrictive umask, retain final chmod, and add an immediate-creation permission assertion instead of weakening client validation. (path: Sources/ADevContainerLib/Git/GitCredentialControl.swift) +- [x] 2.5 Verify existing entry mapping: normal loop return and handled SIGTERM/SIGINT return 0, startup failures return 1, hidden entry stays absent from public help. No entry edit was needed. (path: Sources/ADevContainerLib/Git/GitCredentialBrokerEntry.swift) + +## Checkpoint +- [x] Green deterministic/IPC coverage includes exact grace boundary, reset during grace, two sessions, disconnected and crash-recovered explicit-override demand, pending acquisition during unknown observation, authoritative external stop, both retirement admission outcomes, and stale-observation versus healthy re-registration. Reconciliation and idle paths make no provider calls. Existing cooldown, auth, generation and cleanup regressions remain green. + +## 3. Consumer ordering and compatibility +- [x] 3.1 Verify existing command assertions for setup before create/start/rebuild hooks and HTTP(S) populate, running reuse repair, populated-volume reuse, SSH/no-source skips, redacted soft failure, and absence of refresh-credentials. No lifecycle call-site edits or extra command-test assertions were necessary. (path: Tests/adevcontainerTests/AutomaticGitCredentialsCommandTests.swift) +- [x] 3.2 Verify existing invalid-entry/public-help tests alongside the new successful idle-loop and empirical exit-status coverage. No extra entry test was necessary. (path: Tests/adevcontainerTests/GitCredentialBrokerEntryTests.swift) +- [x] 3.3 Build the product and run the established safe synthetic suite with live gates unset. Do not run unfiltered integration tests against local containers. Record warnings separately and introduce no new warning suppression. (path: Tests/adevcontainerTests/main.swift) + +## Checkpoint +- [x] `swift build --product adevcontainer` passed. `ADEVCONTAINER_TESTS_SYNTHETIC_ONLY=1 swift run adevcontainerTests`: 1189 passed / 0 failed / 0 skipped; the harness explicitly excluded 18 live integration tests. Existing CLT linker-path warnings and pre-existing captured-variable warnings in CredentialDuplexProcessTests.swift were observed on recompilation; no warning originates in the changed lifecycle/probe code. No claim of Apple runtime/container integration is made. + +## 4. Separately authorized empirical launchd validation +- [x] 4.1 Add a test-executable-only probe using the actual service loop and launchd adapter, synthetic identity/observation/channel ports, a forbidden acquisition sentinel, isolated temporary HOME/config, and exact UUID test context. The probe has no Apple runtime/provider code path. (path: Tests/adevcontainerTests/GitCredentialBrokerIdleLaunchdTests.swift) +- [x] 4.2 Dispatch the private test probe before normal suite construction and include empirical tests only when explicitly requested. Require the separate authorization gate before job creation; preserve the older credential smoke refusal gate. (path: Tests/adevcontainerTests/main.swift) +- [x] 4.3 Execute real launchd validation against only the owned UUID job: empty startup and dormant observation, two sessions, grace cancellation, SIGKILL/crash recovery, temporary unknown runtime, synthetic external stop, dormant reactivation, and real old-policy replacement preserving registration intent. Verify exact job and owned root absence after cleanup. Registration boundary synchronization itself is deterministic broker coverage, not inferred from a wall-clock race in launchd. (path: Tests/adevcontainerTests/GitCredentialBrokerIdleLaunchdTests.swift) +- [x] 4.4 Document precise invocation, safety gates, signal/throw cleanup, unhandleable-parent cleanup instructions, measurements and test limitations without changing the older guest smoke procedure. (path: Tests/GuestGitCredentialRelay/SMOKE.md) + +## Checkpoint +- [x] Final real macOS GUI launchd execution passed with 92 credential-focused tests: `ADEVCONTAINER_CREDENTIAL_TESTS_ONLY=1 ADEVCONTAINER_IDLE_LAUNCHD_TEST=1 ADEVCONTAINER_IDLE_LAUNCHD_AUTHORIZED=1 .build/debug/adevcontainerTests`. Job `com.adevcontainer.git-credentials.test.449D4BEA-1A61-4B20-A2F8-5728574EBC6F`: empty bootstrap 5.406s, synthetic external stop 5.777s, last unregister 5.114s; two separate 12-second no-relaunch observations, crash recovery within the 15-second watchdog, migration, no provider call, exact job absence and owned root absence all passed. Earlier complete runs also passed. An interrupted-by-command-timeout run reported failure rather than success and verified removal of its own job/root, empirically exercising handled signal cleanup. A separate intermittent socket-permission failure was reproduced and fixed without relaxing private endpoint checks. + +## 5. Conformance and evidence +- [x] 5.1 Re-run analyze after artifact evolution: all three requirements and I01–I11 have test coverage; internal decisions map to the implementation above, no outcome changed, no unresolved markers remain, and no new dependency/public surface was added. Keep realized specs, wiki, and the prior archive untouched during implementation. (path: specs/changes/archive/20261021-git-broker-idle-shutdown/tasks.md) + +## Checkpoint +- [x] Implementation self-review and safe synthetic verification passed. Independent review initially blocked I11; section 6 records its correction, and section 7 records subsequent coordinator-confirmed approval and authorized archival. + +### Scenario coverage + +| Scenarios | Named tests/evidence | +|-----------|----------------------| +| I01 | `upFreshCreateConnectsCredentialsAfterOwnershipBeforeHooks`, existing stopped-start/up/rebuild/clone ordering tests, `credentials.service.simultaneousActivationBootstrapsOnce`, connection no-source/reuse tests | +| I02, I03, I04 | `credentials.broker.graceDemandAndRetirementBoundary`, `credentials.broker.twoSessionsAndRecoveredOverrideAreDemand`, real-launchd two-session/grace checks | +| I05 | `credentials.broker.externalStopAndLateObservationFence`, `credentials.runtime.observationRequiresDefinitiveMachineState`, real-launchd external stop | +| I06 | `credentials.broker.runtimeUnknownPreservesRegistration`, `credentials.broker.pendingRequestSurvivesUnknownButCannotLeak`, runtime observation tests, real-launchd unknown-state interval | +| I07 | `credentials.broker.registrationLosingRetirementCannotSucceed`, grace registration winner, stale-observation barrier, singleton/IPC tests, dormant reactivation | +| I08 | `credentials.service.emptyBrokerRetiresSuccessfully`, `credentials.service.socketIsPrivateAtCreation`, dormant fake launchctl activation, real-launchd two 12-second dormant observations | +| I09 | existing explicit-override/generation recovery tests and real-launchd active SIGKILL/recovery | +| I10 | empty service/runtime inactive tests and real-launchd empty startup/external stop | +| I11 | `credentials.service.isolatedSingletonAndLaunchDefinition`, `credentials.service.failedMigrationResumesWithoutTrustingDisk`, `credentials.service.interruptedMigrationAndBootstrapFailureResume`, real loaded KeepAlive=true interrupted migration/failed bootout retry and post-migration retirement | + +Logs live under the approved temporary `opencode` root: `git-idle-baseline.log`, `git-idle-red.log`, `git-idle-red-api.log`, `git-idle-red-race.log`, `git-idle-red-inventory.log`, `git-idle-green1.log`, `git-idle-green2.log`, `git-idle-final-build.log`, `git-idle-final-synthetic.log`, `git-idle-launchd3.log`, `git-idle-launchd-final.log` (handled timeout/cleanup), and `git-idle-launchd-complete.log` (final complete real-launchd pass). The first two empirical attempts failed before bootstrap because the fixture validated its wrapper before creating it; setup ordering was corrected before the successful real-launchd execution. These initial failures were not environment blocks or product behavior failures. + +## 6. Review blocker I11 — resumable loaded-definition migration +- [x] 6.1 Reproduce the reviewer's failed-bootout/next-ensure false success with a fake that stores loaded bytes independently of plist bytes. Also reproduce disk-new/job-old interruption with no confirmation receipt; test failed/ambiguous bootstrap, interruption before receipt commit, preserved nonsecret registrations, private receipt readability, and healthy reuse with only print/kickstart. (path: Tests/adevcontainerTests/GitCredentialBrokerServiceTests.swift) +- [x] 6.2 Distinguish desired bytes from confirmed loaded definition using private `loaded-definition.plist`. Durably invalidate confirmation before migration, unload obsolete/unconfirmed jobs before updating the target plist, propagate failed bootstrap, and atomically commit confirmation only after successful bootstrap. Missing confirmation recovers interrupted pre-fix upgrades; confirmed healthy registration does not reload. No normative outcome changed. (path: Sources/ADevContainerLib/Git/GitCredentialBrokerService.swift) +- [x] 6.3 Extend the authorized isolated real-launchd test to load the old policy while disk already contains target bytes, remove fixture confirmation, inject one exact-job bootout refusal, require first ensure failure and successful retry, then verify real loaded idle behavior and cleanup. Injection allowlists only the exact owned-job operations and fixture bootstrap path. (path: Tests/adevcontainerTests/GitCredentialBrokerIdleLaunchdTests.swift) +- [x] 6.4 Document confirmation semantics and the isolated failure/retry evidence; retain existing live safety gates and restrictions. (path: Tests/GuestGitCredentialRelay/SMOKE.md) + +## Checkpoint +- [x] Behavioral red: 91 passed / 2 failed; retry left obsolete 827-byte loaded definition instead of target 874 bytes, and interrupted disk-new/job-old incorrectly succeeded rather than propagating failed bootstrap. Additional red: 92 passed / 1 failed because a failed migration retained prior confirmation. Green focused run: 93 passed / 0 failed. Logs: `git-idle-migration-red.log`, `git-idle-migration-receipt-red.log`, `git-idle-migration-green.log` in the approved temporary root. +- [x] Product build passed. Post-review full safe synthetic suite: 1191 passed / 0 failed / 0 skipped; 18 live integration tests deliberately excluded. Logs: `git-idle-migration-build.log`, `git-idle-migration-synthetic.log`. Only the previously recorded unrelated Swift/CLT warnings remain. +- [x] Post-review real-launchd suite: 94 passed / 0 failed / 0 skipped. Exact isolated job `com.adevcontainer.git-credentials.test.3960C111-18A9-4FB8-A871-1820E256C99F`; empty 5.472s, external-stop 5.748s, last-unregister 5.176s. Interrupted migration plus first-bootout failure resumed safely, registry intent survived, and post-migration idle exit stayed dormant for 12 seconds. Job and root absence were verified by cleanup. Log: `git-idle-migration-launchd.log`. No production job, installed CLI, credentials, or containers were accessed. +- [x] Analyze remained PASS at implementation handoff: implementation refinement only, I11's existing outcome unchanged, no new public interface/dependency, coverage added for the review reproduction, and no unresolved markers. Independent re-review was pending then; no archive or realized-spec/wiki changes were made during implementation. Subsequent approval and archival are recorded below. + +## 7. Approved archive +- [x] 7.1 Merge all three added requirements and I01–I11 into the core source specification, retaining existing credential requirements and explicitly stating the reviewed I11 resumable-migration guarantee without prescribing receipt implementation. No clone-source change is required because its existing core cross-reference remains valid. (path: specs/core.md) +- [x] 7.2 Move this bundle to the dated archive, update its source/self references, and preserve test history and verification limitations. (path: specs/changes/archive/20261021-git-broker-idle-shutdown/tasks.md) + +## Checkpoint +- [x] Coordinator handoff records explicit user confirmation, "Sim, pode arquivar," and independent code-reviewer APPROVE after I11 remediation (review session `ses_ea0073b64ffe51wh0MNX54FaQp`, 93 independent focused regressions passed). Previously recorded execution results remain 1191 synthetic and 94 isolated-launchd tests passed; the live probe used synthetic runtime state and did not validate real container authentication. No new runtime tests were run for archive. +- [x] Archive integration adds exactly three requirements and eleven scenarios; no existing requirement is replaced or removed. Source and archived delta match, bundle references resolve, the previous automatic-git-credentials archive is unchanged, and focused whitespace validation passes. This bundle is archived, not active. diff --git a/specs/changes/archive/20261022-git-broker-detached-lifecycle/design.md b/specs/changes/archive/20261022-git-broker-detached-lifecycle/design.md new file mode 100644 index 0000000..f931f9a --- /dev/null +++ b/specs/changes/archive/20261022-git-broker-detached-lifecycle/design.md @@ -0,0 +1,96 @@ +# Design: git-broker-detached-lifecycle + +This document is non-normative. Guarantees live in [spec.md](spec.md); this file explains the selected mechanism, alternatives, and how the pieces interact. + +## Approach +The CLI becomes the broker's only launcher. `GitCredentialBrokerService.ensure()` runs a serialized activation transaction and starts the same binary as a detached child in a new session when no acceptable owner exists. The broker keeps today's responsibilities unchanged: exclusive `broker.lock`, private registry, control socket, reconciliation, generation fences, and 5-second idle retirement. The difference is that retirement means process exit, not a dormant launchd job, and the next activation starts a fresh process. + +``` +CLI setup ──► activation.lock ──► control.hello ──► identity match? ──yes──► reuse + │ │ + │ no + │ ▼ + │ control.retire + wait for exit + │ │ + └──────────────► spawn(setsid, /dev/null) ──► wait for hello ──► register +``` + +## Alternatives + +| Model | Advantage | Why rejected | +| --- | --- | --- | +| Previous LaunchAgent (`RunAtLoad`, conditional `KeepAlive`) | login activation, crash restart without a CLI | persistent plist/job outlives the keg, requires upgrade receipts and teardown machinery; homebrew draft dead-ends at uninstall | +| `SMAppService` agent | supported registration API | still a persistent login item with a plist-like registration and the same uninstall/upgrade problem; more platform surface, not less | +| XPC/Mach service | on-demand launchd activation | still launchd-managed and requires a bundled service target; overkill for one per-user socket | +| `brew services` | packaging convention | explicitly rejected; adds persistent state and a packaging dependency | +| Detached process from the same executable (chosen) | no installed state, no plist, trivial uninstall, one lifecycle owner | no login-time activation and no automatic crash restart; both are accepted product decisions | +| Fork+exec without `setsid` | simpler | child remains in the CLI's process group/session and can receive terminal signals; not reliably detached | + +The chosen model trades automatic login/crash continuity for the absence of persistent host state. That is the user's decision: demand-driven availability, recovery by the next `up`/`start`, and an unchanged Formula. + +## Identity handshake +The control socket is the authority for liveness because it already enforces same-user peer credentials and private inode ownership. Two private operations extend the host-only control protocol: + +- `hello`: returns the broker's process identity, resolved executable path, executable device/inode captured at startup, and protocol/build identity. +- `retire`: asks the authenticated owner to stop gracefully; the reply is sent before the loop exits. + +The CLI computes its own identity from the executable selected by the launch context (production: the invoking CLI; tests: the test-owned wrapper), using `realpath` plus `stat` device/inode, its Mach-O build UUID when present (or equivalent build identifier), and its protocol version, then compares. Matching device and inode means the same file, even through a different symlink path. The build UUID catches an in-place relink that keeps the inode; reading it costs a small header parse, not a full executable hash. Non-Mach-O test wrappers fall back to device/inode plus protocol. A Homebrew opt repoint or replacement keg resolves to a new inode, and a versioned development rebuild normally changes both; either forces replacement. Path equality, identical plist/definition bytes, or a connectable socket do not participate in the decision. + +Why not a pid/identity file? A file is stale after SIGKILL and invites pid-reuse and TOCTOU bugs. The `hello` handshake is authenticated by the socket itself and carries the process identity of the responder. Why not SIGTERM directly? The CLI would need a trustworthy pid; the authenticated `retire` reply is simpler and testable, and same-user processes can already signal each other, so no security boundary is weakened. + +## Activation flow +1. Validate the context; no plist path remains in the production context. +2. Take `activation.lock` with the caller's deadline. This is the same serialization point used today, so concurrent CLIs cannot race into two spawns. +3. Send `hello` with a 2-second bound for this pre-spawn identity check, so a connected but silent endpoint fails closed instead of consuming the deadline and inviting a spontaneous successor spawn. Readiness polling after a spawn keeps the remaining activation deadline: a new broker may still be reconciling before it answers. + - Match: reuse, release lock, proceed to register. + - Mismatch: send `retire`, wait for the socket to disappear or the owner to become unreachable within the replacement bound, then continue. + - No listener (absent or stale socket): continue. + - Connected but unrecognized/unsafe: fail closed with redacted setup warning. A connect failure is absence; after a successful connect, a `hello` timeout is `unrecognized`, while an immediate close remains the owner-exit race (`absent`). +4. Spawn the detached broker. The child receives the production context implicitly; a test context is passed through the existing private `launch-context.json` mechanism under the temporary root. The child inherits no token environment and no CLI file descriptors beyond the private context. +5. Poll `hello` until the identity matches or the deadline expires. If a mismatched owner appears during the wait, retire it once and continue polling rather than accepting it. A losing spawn that cannot take `broker.lock` exits successfully without touching the socket, so the poll converges on the winner. +6. Release the lock and issue the original registration/refresh request. + +Failure at any step maps to today's soft setup warning except spawn/readiness errors that indicate a broken installation; the command still does not fail, delete a container, or enter recovery. + +## Detached spawn +`posix_spawn` with `POSIX_SPAWN_SETSID` gives the broker its own session with no controlling terminal. File actions map stdin/stdout/stderr to `/dev/null`; `POSIX_SPAWN_CLOEXEC_DEFAULT` keeps unrelated descriptors closed; the environment is filtered of `ADEVCONTAINER_GIT_TOKEN`. The parent does not wait for the child and does not retain a pipe, so CLI exit cannot terminate or signal it. It opportunistically reaps a spawn that exits during the readiness window; a live broker is re-parented to launchd when the CLI exits. The broker ignores SIGHUP in addition to handling SIGTERM/SIGINT gracefully. + +## Single ownership and races +- `activation.lock` serializes CLI activation. It is held only for the probe/replace/spawn/readiness window, never for the broker lifetime. +- `broker.lock` remains the lifetime singleton. A spawned child waits briefly for it (bounded by its startup deadline); if another owner holds it, the child exits 0. This covers a successor spawned while a retiring broker is still cleaning up. +- Idle retirement and registration admission already share the broker state lock (`retireIfIdle`). A registration that loses that race gets a transport failure and retries activation against the successor, which is the existing tested behavior. +- Old cleanup unlinks `control.sock` only if the current inode still matches the one it bound. Lock inodes are never unlinked: removing a lock file while a waiter holds it creates two independent locks. +- A crash leaves the lock released by the kernel and the socket stale. The successor's existing safe-replace logic validates type, owner, mode, and symlink status before unlinking, so an attacker-controlled path fails closed. + +## Crash and idle behavior +No process watches the broker. After SIGKILL nothing runs, the socket is stale, and in-flight helper gets fall through as misses. The next eligible setup starts a successor; the registry makes recovery possible without credentials on disk. `hello` is not attempted by `unregister` (`activate: false`), so stop/delete/purge cannot start a broker; if none is running they are no-ops and any saved intent is revalidated by a future successor before use. Idle exit keeps the 5-second grace and 2-second recheck already implemented; because there is no loaded job, exit has no restart tail. Channel teardown is bounded: process reaping uses a non-blocking deadline, and shutdown cancels all channels concurrently with an overall 1.5-second cap, so one slow local adapter cannot extend the 7-second healthy exit bound. + +The replacement paragraph for the supervisor-restart span also restates the archived `git-broker-idle-shutdown` I05–I07 guarantees: external stop/removal invalidation within 2 seconds and retirement within 9 seconds, unknown runtime observations preserving demand, and the registration-versus-retirement race. This is traceability only: before this change was archived, the realized `specs/core.md` requirement had not received those archived behaviors. The earlier archive stays immutable; this delta carries the behaviors into the source spec on archive. + +## Supersession of the Homebrew draft +| Draft content | Disposition | +| --- | --- | +| Stable opt launch path and loaded-target identity | Obsolete as a launch path; the durable idea survives as executable device/inode + build UUID + protocol identity comparison at activation | +| Loaded-definition receipt and interrupted bootstrap migration | Obsolete; no loaded definition exists to migrate | +| Automatic teardown of plist/job/state | Obsolete; no plist or job is created, so uninstall has nothing to unload | +| Cask migration, `zap`, account/coexistence analysis | Obsolete; no distribution change is required or authorized | +| Manual uninstall command rejection | Retained as a product constraint, now trivially satisfied | +| No login-time broker demand | Retained and strengthened: no login item at all | +| Synthetic temporary-label verification | Superseded by the detached-process probe; no launchd label exists | + +The draft was deleted from the active set after explicit confirmation, as recorded in [tasks.md](tasks.md). Earlier archives remain immutable and untouched. The Formula generator and rendered Formula remain unchanged by this detached-lifecycle change; no `post_install`, `services`, or uninstall hook is added. + +## Test strategy +The planned test-first strategy was not captured as red-before-green: the detached implementation had replaced launchd before the new assertions first ran. The following coverage passed after review remediation; [tasks.md](tasks.md) records the runs and separately authorized real smoke: + +- `Tests/adevcontainerTests/GitCredentialBrokerServiceTests.swift`: fake detached launcher and fake broker status to prove one spawn, identity reuse, mismatch retirement, bounded failure, and unregister-without-spawn. The launchctl mocks, plist byte assertions, and receipt/migration tests are deleted. +- `Tests/adevcontainerTests/GitCredentialBrokerDetachedTests.swift` (new): real test-owned executable under `$TMPDIR/opencode`, synthetic ports, and the production service/control/entry code. Covers CLI-exit survival, two-CLI convergence, idle exit, crash without restart, upgrade replacement using two executable copies, and owned-socket cleanup. +- `Tests/adevcontainerTests/GitCredentialBrokerIdleLaunchdTests.swift`: deleted with its probe entry. +- `Tests/adevcontainerTests/GitCredentialBrokerSmokeHarness.swift` and `SmokeTests.swift`: process/socket manifest, termination cleanup, no `launchctl` calls, no plist paths. +- `Tests/adevcontainerTests/main.swift`: probe dispatch moved to the detached suite; launchd suite removed. +- `Tests/GuestGitCredentialRelay/SMOKE.md`, `Tests/GuestGitCredentialRelay/README.md`, `README.md`, `CONTRIBUTING.md`: document detached verification and remove launchd gates. + +The synthetic suite remains the primary regression suite. Timing assertions reuse the existing 5–7 second retirement window with tolerance for CI scheduling; deterministic barrier tests cover the registration/retirement boundary. No test selects production state, runs `launchctl`, boots a job, uses a real provider, or starts a user container. + +## Security considerations +The control socket stays owner-private and peer-checked. `hello` exposes only nonsecret identity metadata. `retire` is available to the same user who could already send SIGTERM; it does not add a privilege boundary. Spawn arguments contain no secrets, and the child environment excludes the explicit token override so it cannot be inherited across the process boundary. The broker never runs as root or adopts another user's registry. Replacement refuses to act on an endpoint it cannot authenticate rather than guessing from a pid or path. diff --git a/specs/changes/archive/20261022-git-broker-detached-lifecycle/proposal.md b/specs/changes/archive/20261022-git-broker-detached-lifecycle/proposal.md new file mode 100644 index 0000000..1215cfa --- /dev/null +++ b/specs/changes/archive/20261022-git-broker-detached-lifecycle/proposal.md @@ -0,0 +1,66 @@ +# Proposal: Detached Git credential broker lifecycle + +## Intent +At proposal time, the per-user Git credential broker implementation used a LaunchAgent with `RunAtLoad`/`KeepAlive` (not a released installation). That tied credential availability to a persistent launchd job and a plist that outlives the binary, invited upgrade/cleanup machinery, and kept crash recovery coupled to a supervisor. This change replaces it with a demand-started broker process spawned by the same `adevcontainer` executable, so the product owns its lifetime without any installed host service. + +## Scope +Change the core credential-supervision contract only: on-demand detached start before clone/hooks, one instance per user, reuse of a matching running broker, graceful replacement when a different executable must take over, preserved 5-second idle retirement, no automatic crash restart with recovery by the next eligible lifecycle activation, private socket/state cleanup on exit, and no LaunchAgent/plist/launchd/`brew services` surface. Managed containers only. The superseded `homebrew-git-broker-lifecycle` draft was deleted after explicit confirmation during execution. Distribution behavior is explicitly unchanged: the Formula stays as-is because there is no plist or job to remove. + +## Non-goals +The proposal-only dispatch did not implement code or edit root specs, archives, or wiki; subsequent implementation and archive are recorded in [tasks.md](tasks.md). No Formula, Cask, tap, release, or packaging change; no uninstall hook; no `brew services`; no manual broker-uninstall command; no `zap`-based cleanup; no migration or deletion of pre-existing LaunchAgent artifacts (this broker has no released installation, and the user confirmed no agent was created). No cross-user cleanup, privileged installation machinery, login item, XPC service, or auxiliary resident helper. No change to credential acquisition precedence, authorization scope, helper installation, guest relay protocol, rejection cooldown, soft-failure behavior, container/workspace ownership, or public CLI surface. Earlier archives remain immutable; wiki changes are outside this change. + +## Approach +`ensure()` becomes a serialized activation transaction: take the existing activation lock, probe the private control socket for the broker's authenticated executable identity, reuse it when the identity matches the invoking executable, otherwise retire it gracefully and spawn a successor with `posix_spawn` in a new session, detached stdio, and a sanitized environment. The broker owns the same singleton lock, registry, control socket, reconciliation loop, and 5-second idle exit; it simply exits the process instead of settling into a dormant loaded job. Crash recovery follows from the next activation, not from a supervisor. Replacement is identity-based (executable device/inode and protocol/build identity), never from plist bytes or socket liveness alone. The Homebrew draft's durable upgrade insight survives as identity-based replacement; its launch-path, receipt, teardown, Cask, and account-scope machinery becomes obsolete. + +## Decision index +- **Execution model:** a detached process from the same executable replaces launchd supervision. No plist, job, `brew services`, login item, or installed launcher is created. This is the only model that needs neither migration nor uninstall teardown under an unchanged Formula. See [design.md](design.md) **Alternatives**. +- **Start trigger:** only credential-dependent managed lifecycle setup (fresh up, clone, rebuild replacement, real start, up start-stopped, running up/start repair) may start the broker. Install, login, list, inspect, exec, stop, delete, purge, unregister, and no-destination/SSH-only paths never start it. +- **Single instance:** the existing activation lock serializes CLI activation; the broker keeps the exclusive `broker.lock`. A spawned loser exits successfully without touching the winner's endpoint, and setup converges on the single owner. +- **Identity and upgrade:** the broker reports an authenticated, nonsecret identity over the private control socket (process plus executable device/inode and protocol/build identity). Matching identity reuses the process with no restart; differing identity triggers one bounded graceful retirement followed by a successor spawn. Registrations survive replacement and are revalidated. See [design.md](design.md) **Identity handshake**. +- **Crash semantics:** no supervisor and no self-restart. A crash leaves credential lookups failing normally and a stale socket; the next eligible setup starts a successor that reconciles saved nonsecret registrations. Explicit overrides and pending requests are not recovered or replayed. +- **Idle and exit:** retain the implemented 5-second continuous zero-demand grace with a recheck interval no greater than 2 seconds. Healthy local retirement completes within 7 seconds. Graceful exit removes only a still-owned control socket, cancels channels/pending work, and preserves registrations and lock inodes. +- **Unregister without a broker:** must be a no-op success, never an activation. Stop/delete/purge therefore cannot resurrect the service; a later broker reconciles any stale saved intent before use. +- **Uninstall/packaging:** the product creates no persistent service registration, so ordinary package removal leaves no job or plist and needs no hook. The Formula source and rendered output remain unchanged. A detached process alive at removal exits under the 5-second grace once its last demand ends and cannot be restarted. +- **Supersession:** the `homebrew-git-broker-lifecycle` draft was deleted after explicit confirmation; earlier archives were untouched. Durable upgrade behavior moved to the new spec as identity-based replacement; stable-opt launch paths, loaded-definition receipts, automatic teardown, and Cask migration become obsolete because no host service is installed. The manual-uninstall command remains rejected; there is simply nothing for it to clean. +- **Core integration:** the delta replaces only the listed spans of **Git credential store seeded on create paths** (supervision sentence, token-persistence sentence, supervisor-restart paragraph, C16 Given line, scenario C13), removes the LaunchAgent-based verification requirement, and adds detached-process verification plus the new lifecycle requirements. At proposal time, the realized `specs/core.md` lacked the archived `git-broker-idle-shutdown` requirements; this delta therefore restates the durable 5-second demand/idle semantics without reintroducing launchd supervision. After the merge, `specs/core.md` contains no launchd supervision requirement for this broker; launchd references prohibit service installation or use during verification. See [spec.md](spec.md). +- **Verification:** delete the separately authorized real-launchd probe and its 94-test surface; verify detached behavior with a test-owned executable under a private context. No `launchctl`, plist, production state, real container, or real provider. The synthetic suite remains the main regression net. + +## Clarifications +- **Q:** Is an automatic uninstall transaction still required? + **A:** No. The user rejected a manual uninstall command and a Cask migration. With a detached process there is no plist/job to clean, so the Formula stays unchanged; the superseded draft was removed after confirmation. +- **Q:** Must an existing LaunchAgent be migrated or removed? + **A:** No. The user confirmed no agent was created; the broker has no released installation. Migration and post-uninstall job cleanup are out of scope. +- **Q:** Should crash recovery remain automatic? + **A:** No. The user chose recovery by the next `up`/`start` (or other eligible activation), which also removes the need for `KeepAlive` and any supervisor. + +## Inventory of LaunchAgent-dependent surfaces + +The following inventory records the proposal-time baseline and planned actions, not outstanding work; completion is recorded in [tasks.md](tasks.md). + +| Path | Proposal-time LaunchAgent dependency | Planned action | +| --- | --- | --- | +| `Sources/ADevContainerLib/Git/GitCredentialBrokerService.swift` | plist generation, `RunAtLoad`, `KeepAlive`, `launchctl print/kickstart/bootout/bootstrap`, loaded-definition receipt, migration | Replace with detached spawn, identity handshake, reuse/retire logic | +| `Sources/ADevContainerLib/Git/GitCredentialBrokerEntry.swift` | launch-context file for launchd; signal handling only terminates the loop | Keep private entry, add detached-session behavior; no plist | +| `Sources/ADevContainerLib/Git/GitCredentialControl.swift` | control loop has no identity/retire operation | Add private `hello`/`retire` operations and owned-socket exit | +| `Sources/ADevContainerLib/Runtime/CredentialProcessGuardian.swift` | comment says launchd/init reaps orphans | Wording review only; reaping by launchd as PID 1 remains true | +| `Tests/adevcontainerTests/GitCredentialBrokerServiceTests.swift` | `CredentialConcurrentLaunchctl`, `CredentialMigrationLaunchctl`, plist bytes, receipts, bootout/bootstrap failures | Replace with fake launcher/identity tests; delete migration tests | +| `Tests/adevcontainerTests/GitCredentialBrokerIdleLaunchdTests.swift` | real isolated launchd job, plist, bootout, KeepAlive migration | Delete; replace with detached-process probe | +| `Tests/adevcontainerTests/GitCredentialBrokerSmokeHarness.swift` | plist path, `launchctl bootout`, production-plist guard, launchd cleanup instructions | Replace with process/socket manifest and termination cleanup | +| `Tests/adevcontainerTests/GitCredentialBrokerSmokeTests.swift` | `SmokeLaunchctl`, plist assertions, "No LaunchAgent" refusal text | Rewrite around detached process and no-launchctl assertions | +| `Tests/adevcontainerTests/main.swift` | launchd probe dispatch and suite registration | Point probe at detached broker; register replacement suite | +| `Tests/GuestGitCredentialRelay/SMOKE.md` | LaunchAgent label, plist, `launchctl bootout`, launchd lifetime gates | Rewrite for detached process verification; no launchd | +| `Tests/GuestGitCredentialRelay/README.md` | states smoke does not run launchd; mentions isolated launchd smoke | Update wording | +| `Tests/GuestGitCredentialRelay/build_artifacts_tests.sh` | comment only | Comment update or no functional change | +| `Tests/adevcontainerTests/GitCredentialRelayChannelTests.swift` | comment only | Comment update or no functional change | +| `README.md` | documents the per-user LaunchAgent and supervised restart | Document demand-started detached broker | +| `CONTRIBUTING.md` | launchd smoke instructions | Replace with detached-process instructions | +| `specs/core.md` | supervision and launchd restart contract; LaunchAgent verification requirement | Apply the delta spans on archive | +| `specs/changes/homebrew-git-broker-lifecycle/` | draft built on plist/job/Cask teardown | Delete after confirmation; archive untouched | +| `scripts/render-homebrew-formula.sh` | no broker lifecycle content | No change; assert unchanged | +| `.github/workflows/ci.yml` | runs the synthetic suite only; launchd test is env-gated | No change expected | + +## Baseline +Recorded on 2026-10-21 before creating these artifacts, branch `main`, HEAD `6a8dde5`: 79 porcelain entries (35 modified, 2 deleted, 42 untracked entries), 53 untracked files, 90 distinct changed paths, tracked diff 37 files with `+838 / -2824`. Porcelain SHA-256: `c70145901d9b2d65cf0bcdf9251900d35dfa87bbd3fdfce1eb4c2697ef1ccfda`. This dispatch created only this change folder. The superseded draft recorded 318 files / 87 entries; that figure does not match the current tree and was not reconciled or altered here. + +## Readiness +Analyze and post-remediation verification passed with no unresolved clarification marker; implementation and the confirmed Homebrew-draft deletion are complete. The user explicitly authorized the final documentation updates and archive on 2026-10-22. [tasks.md](tasks.md) records the synthetic verification, separately authorized plantsuite smoke, evidence limits, and archive completion. No release publication or Homebrew change is claimed. diff --git a/specs/changes/archive/20261022-git-broker-detached-lifecycle/spec.md b/specs/changes/archive/20261022-git-broker-detached-lifecycle/spec.md new file mode 100644 index 0000000..2876b44 --- /dev/null +++ b/specs/changes/archive/20261022-git-broker-detached-lifecycle/spec.md @@ -0,0 +1,133 @@ +# Change Spec: git-broker-detached-lifecycle + +Target domain: core. The requirements below replace LaunchAgent supervision for the per-user Git credential broker with a demand-started detached process from the same executable. They supplement **Git credential store seeded on create paths** in [core.md](../../../core.md); existing acquisition, authorization, privacy, guest-helper migration, cooldown, soft-failure, discovery, and managed-container cleanup requirements remain in force except where explicitly replaced below. The superseded `homebrew-git-broker-lifecycle` draft was deleted after explicit confirmation and is not part of the live contract. + +## ADDED Requirements + +### Requirement: Demand-started detached Git credential broker + +For a managed container with discovered HTTP(S) destinations, the first eligible credential lifecycle setup MUST ensure exactly one per-user broker process is serving, starting it from the currently invoking `adevcontainer` executable when none is acceptable, without sudo, a public command, a LaunchAgent plist, a launchd job, a `brew services` entry, or a login item. The process MUST be detached from the invoking CLI so it survives CLI exit, and it MUST complete the connection before HTTP(S) full clone or applicable create-path hooks when setup succeeds. Existing silent-skip, redacted soft-failure, and hook/clone failure semantics remain unchanged. + +Broker demand MUST exist only for credential-dependent managed-container lifecycle work: fresh up, clone, rebuild replacement, real start, up start-stopped, and running up/start repair. Installation, login, list, inspect, exec, stop, delete, purge, unregister, and setup with no HTTP(S) destination or SSH-only sources MUST NOT start a process or create an endpoint. When a broker is running, unregister MUST remove the registration without provider access. Unregister without a running broker MUST succeed as a no-op without starting one; saved intent is not usable until a successor revalidates it. + +The broker MUST be a single instance per macOS user. Concurrent CLI activations MUST serialize and MUST start at most one process; every successful registration MUST reach that owner. A losing spawn attempt MUST exit without replacing or unlinking the owner's endpoint. No second process may serve the same registry or control socket. + +After the last registration is definitively invalidated or unregistered and no admitted work remains, the broker MUST exit successfully after a continuous 5-second zero-demand grace, rechecked at intervals no greater than 2 seconds. Under healthy, promptly responding local adapters, exit MUST complete no later than 7 seconds after demand reaches zero and MUST leave no running broker and no stale control socket. A later eligible setup MUST start a successor. SIGTERM and SIGINT MUST perform the same bounded cleanup as idle exit. + +A crash MUST NOT be restarted automatically, and the product MUST NOT install or rely on any supervisor that restarts it. Until the next eligible lifecycle activation, credential lookups fail as ordinary misses and any stale control socket is not usable. The next activation MUST start a successor that loads saved nonsecret registrations, reconciles them against authoritative managed-container identity and state before recreating channels, and reconnects eligible running containers. A crash MUST NOT resurrect an explicit token override or replay pending requests. + +#### Scenario: D01 - first demand starts a detached broker before consumers +- Given no broker process exists and a managed container has discovered HTTPS destinations +- When fresh up, clone, rebuild replacement, real start, or up start-stopped prepares the connection +- Then exactly one broker process from the invoking executable is serving before HTTP(S) full clone or applicable hooks, the setup connects, and that same process still serves lookups after the invoking CLI exits +- And no product-created LaunchAgent plist, launchd job, brew service, login item, or separate installed launcher exists + +#### Scenario: D02 - no lifecycle demand creates no broker +- Given no broker process exists +- When install, login, list, inspect, exec, stop, delete, purge, unregister, or SSH-only/no-destination setup runs +- Then no broker process, control socket, or new registration intent is created, and stop/delete/purge still report success +- And no launchd or `brew services` operation is attempted, and saved intent is not usable until a successor revalidates it + +#### Scenario: D03 - concurrent CLIs converge on one instance +- Given no broker process exists and two CLIs run eligible setup concurrently +- When both activations and registrations complete +- Then only one broker process and one control socket exist, both registrations reach that owner, neither registration is acknowledged by a losing instance, and no duplicate process survives +- And a later eligible activation reuses the same owner without spawning another + +#### Scenario: D04 - idle exit removes the process and a later demand starts a successor +- Given a broker whose last registration was invalidated or unregistered and which has no admitted work +- When the 5-second grace elapses without new demand +- Then the process exits within the healthy bound, its owned control socket is removed, no automatic replacement starts, and a later eligible setup starts a successor before its consumers + +#### Scenario: D05 - crash is recovered only by the next activation +- Given a broker with a running managed-container registration +- When the broker dies abnormally +- Then no replacement process starts during an observation window longer than the idle bound, the stale socket is not usable, and the next up/start or other eligible activation starts a successor that preserves the nonsecret registration, revalidates the running container, and reconnects before its consumers +- And the successor does not restore an explicit token override or replay a pending request + +### Requirement: Executable-identity broker reuse and replacement + +A running broker MUST expose a private, same-user-authenticated identity to CLI activation containing at least its process identity, the executable device and inode captured when it started, and its build/protocol identity. A broker whose identity matches the invoking executable MUST be reused without restart, without a reload, and without provider lookup. A broker whose executable or protocol identity differs MUST be retired gracefully before a successor from the invoking executable serves registrations. Replacement MUST preserve nonsecret registrations, remain bounded by the existing activation deadline, and MUST NOT leave two owners, a second registry writer, or a successor-unlinked endpoint. + +Identical on-disk definition bytes, equal path strings, a live socket, or a successful liveness probe MUST NOT alone establish identity match. Concurrent activations observing the same stale instance MUST replace it at most once and MUST all converge on the successor. A broker that cannot be identified, or that accepts retirement but does not exit within the bound, MUST fail activation with the existing redacted soft-failure behavior rather than kill a process or unlink an endpoint it cannot authenticate, or start a competing owner. + +#### Scenario: E01 - matching executable is reused +- Given a running broker started from the invoking executable and a healthy registration +- When repeated setup/reuse runs, including concurrent activations +- Then the same process remains, no new process is spawned, no restart or reload occurs, and provider lookup is not triggered merely by reuse + +#### Scenario: E02 - upgraded executable replaces the running broker +- Given a broker running from an older executable, for example a replaced Homebrew keg or a rebuilt development binary, with saved nonsecret registrations +- When a CLI from the new executable activates credentials +- Then the old process exits gracefully, the new executable becomes the only owner, the registrations survive and are revalidated, and a subsequent lookup succeeds without re-registration or credential persistence +- And repeating the activation does not repeatedly kill the healthy successor + +#### Scenario: E03 - replacement failure is bounded and recoverable +- Given a running broker that reports an unrecognized identity, refuses to exit, or becomes unreachable after accepting retirement +- When activation attempts reuse or replacement +- Then setup fails within its existing deadline using the existing redacted warning semantics, no second registry owner is created, no process or endpoint that cannot be authenticated is killed or unlinked, and a later retry can converge + +### Requirement: Broker exit cleanup and package removal without service registration + +On graceful exit the broker MUST cancel its channels and pending work, remove its control socket only when it still owns the same private socket inode, and MUST NOT unlink lock inodes or delete saved nonsecret registrations. Old-instance cleanup MUST NOT remove or replace a successor's endpoint. A successor MUST safely replace a stale, privately owned socket left by an abnormal exit without following symlinks or deleting unrelated files; a foreign or unsafe endpoint MUST fail closed. + +The product MUST NOT install, load, or depend on a LaunchAgent plist, launchd job, `brew services` entry, login item, or other persistent host service. Ordinary package removal MUST therefore leave no product-created broker job or plist and MUST NOT require an uninstall hook, `zap`, a manual cleanup command, or a later login. Formula packaging, install, test, and caveats remain unchanged. A detached broker still running when the package is removed MUST exit under the existing 5-second grace once its last demand ends, MUST NOT be restarted, and MUST NOT recreate broker state after it exits. + +#### Scenario: F01 - graceful exit cleans only owned runtime state +- Given a broker with active channels, pending work, and saved registrations +- When it exits for idleness or receives SIGTERM/SIGINT while a successor is binding +- Then channels and pending work terminate within their deadlines, the old cleanup removes only its owned socket, never a successor's socket or a lock inode, and any remaining saved registrations are preserved for successor reconciliation + +#### Scenario: F02 - package removal leaves no job or plist +- Given a normal Formula install, upgrade, or removal, with or without a running detached broker +- When the operation completes +- Then no product-created LaunchAgent plist, launchd job, brew service, or login item exists, no uninstall hook or manual cleanup is required, and a running broker exits under the normal grace once its last demand ends and is not restarted + +#### Scenario: F03 - stale or unsafe endpoint is handled safely +- Given an abnormal exit left a privately owned stale socket, or the expected socket path is a symlink or foreign object +- When the next activation starts a successor +- Then the owned stale socket is replaced safely, while a symlink or foreign endpoint fails closed without deleting unrelated files + +### Requirement: Verification isolates detached broker state + +Verification of a development build MUST use a unique private broker context and test-owned executable or executables under the approved temporary root. It MUST NOT touch production state or production broker identity, execute `launchctl`, write a plist, use a real launchd domain, authenticate with a real provider, run installed CLI code, or start user containers without separate authorization. Success, failure, and handled interruption MUST terminate only test-owned detached processes and remove their control sockets, registrations, endpoint directories, helper configuration, and exclusively created container resources. Abrupt unhandleable termination MUST have a narrowly targeted residual-cleanup procedure using only recorded test paths and process identity. + +#### Scenario: V01 - isolated detached broker verification and cleanup +- Given a development build, a pre-existing user installation/state, authorized throwaway runtime resources, and a synthetic provider +- When isolated verification starts, replaces, crashes, retires, fails midway, and receives a handled termination signal +- Then CLI-exit survival, single-owner concurrency, idle exit, identity replacement, and no-automatic-crash-restart are demonstrated without any launchctl command, plist, production state, or real credential acquisition +- And production installation/state remains unchanged, cleanup removes only test-owned processes and paths, and abrupt termination has a documented targeted cleanup procedure + +## MODIFIED Requirements + +### Requirement: Git credential store seeded on create paths + +**Delta-only:** the realized requirement in [core.md](../../../core.md) keeps its heading, all other paragraphs, and all other scenarios. This delta replaces only the spans below; on archive they replace exactly those spans in the current requirement. + +**Replacement for the supervision sentence in the opening paragraph:** + +A per-user broker process using the currently invoking adevcontainer executable MUST survive the invoking CLI and activate on demand without sudo. It MUST NOT install, load, or depend on a LaunchAgent plist, launchd job, brew service, or login item, and MUST NOT be restarted automatically after a crash (see **Demand-started detached Git credential broker**). + +**Replacement for the token-persistence and override-recovery sentence in the secret-containment paragraph:** + +Reading the existing explicit host token escape hatch is retained; the product MUST NOT propagate it through a new child environment or persist it across processes. Any per-session explicit override MUST be lost on broker restart and MUST require registration by an invoking CLI to restore it; successor recovery MUST NOT silently substitute a different credential source for that session until re-registration. + +**Replacement for the paragraph beginning "The supervisor MUST restart a crashed service.":** + +A crashed broker MUST NOT be restarted automatically. The successor started by the next credential-dependent lifecycle activation MUST reconcile saved nonsecret registrations against managed container identity/state before recreating channels. Transport loss while a container remains running MUST trigger bounded-backoff reconnection; failed requests MUST NOT be replayed automatically. Stopped/removed containers MUST have no live relay or usable session; stop/delete/purge and failed-clone cleanup MUST invalidate registrations without acquiring tokens. Runtime changes outside the CLI MUST be reconciled within a finite interval: an external stop or removal MUST invalidate that registration's session and pending replies within 2 seconds and retirement MUST complete no later than 9 seconds after the event, comprising the 2-second invalidation, the 5-second zero-demand grace, and at most one recheck interval, without provider polling. A failed, timed-out, unparseable, or ambiguous runtime observation MUST NOT be treated as absence: it MUST preserve the nonsecret registration and its recovery intent, MUST NOT cancel an admitted request solely for that reason, MUST NOT start idle retirement or deliver credentials to an unvalidated identity, and MUST NOT replay a request; existing request deadlines remain in force, and a later valid observation MUST either reconnect the same identity or definitively invalidate it and start a fresh grace. Registration and the final no-demand retirement decision MUST be serialized: a registration MUST either cancel retirement and connect on that owner or retry against an activated successor within its existing setup deadline, and it MUST never be acknowledged and then discarded by idle shutdown. Only one owner may exist, late old-generation results MUST NOT remove a newer session, and old-instance cleanup MUST NOT unlink a successor's endpoint. Broker idleness MUST NOT cause provider polling. + +**Replacement for the Given line of scenario C16:** + +- Given a registered container and an idle broker process + +**Replacement for scenario C13:** + +#### Scenario: C13 - activation races and process exit +- Given concurrent lifecycle invocations and a prepared container +- When the invocations finish or one broker instance crashes +- Then only one process owns the registry, credential lookup remains available after the invoking CLIs exit, no LaunchAgent or other persistent service is installed, a crash is not restarted automatically, and the next credential-dependent invocation starts a successor without sudo or another installed host executable + +## REMOVED Requirements + +### Requirement: Verification isolates supervised installation state +**Rationale:** The requirement mandates a temporary LaunchAgent identity, plist isolation, job bootout, and launchd-based survival/crash-recovery evidence. No installed service remains, so the verification contract is replaced by **Verification isolates detached broker state**. diff --git a/specs/changes/archive/20261022-git-broker-detached-lifecycle/tasks.md b/specs/changes/archive/20261022-git-broker-detached-lifecycle/tasks.md new file mode 100644 index 0000000..cb4616b --- /dev/null +++ b/specs/changes/archive/20261022-git-broker-detached-lifecycle/tasks.md @@ -0,0 +1,94 @@ +# Tasks: git-broker-detached-lifecycle + +Execution notes (2026-10-21, branch `main`, HEAD `6a8dde5`): + +- 0.1 is complete: the superseded `specs/changes/homebrew-git-broker-lifecycle/` draft was deleted after explicit user confirmation. Archives and root specs were not touched. The durable upgrade behavior moved to **Executable-identity broker reuse and replacement**; no Formula change is required because no plist or job exists to remove. +- The tests in section 1 were authored and run as the acceptance gate for this dispatch. A red-before-green capture is not claimed: the detached implementation replaced the launchd transaction in the working tree before the new assertions first ran. Every scenario below is backed by a green run recorded in section 5. +- The initial implementation verification and review-remediation runs used only test-owned paths, test-owned executable copies, synthetic observations, and synthetic provider misses. No `launchctl` invocation, plist, production broker state, real container, real provider, keychain, or network access occurred in those runs. The separately authorized real plantsuite smoke on 2026-10-22 is recorded in section 7 and is not included in that synthetic-only claim. + +## 0. Supersession (gated on explicit user confirmation) + +- [x] 0.1 Delete the superseded draft files `specs/changes/homebrew-git-broker-lifecycle/proposal.md`, `spec.md`, and `design.md`; remove the now-empty directory. Do not touch `specs/changes/archive/` or any other active change. Record in the execution notes that the durable upgrade behavior moved to **Executable-identity broker reuse and replacement** and that no Formula change is required. (path: specs/changes/homebrew-git-broker-lifecycle/proposal.md) +- [x] 0.2 [P] Confirm the Homebrew surface stays unchanged: `scripts/render-homebrew-formula.sh` output contains no `service`, `post_install`, or uninstall hook, and `.github/workflows/ci.yml` still runs only the synthetic suite. (path: scripts/render-homebrew-formula.sh) + +## Checkpoint +- [x] The active change set contains no `homebrew-git-broker-lifecycle` folder; `specs/changes/archive/` is unchanged; rendered Formula equals the pre-change output. (`scripts/render-homebrew-formula.sh` was not edited in this dispatch.) + +## 1. Failing tests first + +- [x] 1.1 Replace launchd fakes and plist/receipt tests in `GitCredentialBrokerServiceTests` with a fake detached launcher and fake broker status. Add `credentials.service.spawnsOneDetachedBroker`, `credentials.service.reusesMatchingIdentity`, `credentials.service.mismatchedIdentityRetiresOnce`, `credentials.service.unregisterWithoutBrokerDoesNotSpawn`, and `credentials.service.spawnFailureIsSoft`. Remove `CredentialConcurrentLaunchctl`, `CredentialMigrationLaunchctl`, `credentials.service.failedMigrationResumesWithoutTrustingDisk`, `credentials.service.interruptedMigrationAndBootstrapFailureResume`, and `credentials.service.isolatedSingletonAndLaunchDefinition`; the last is replaced by `credentials.service.activationLockIsSingleton` plus `credentials.service.isolatedContextFileIsPrivateAndTokenFree`, and `credentials.service.unrecognizedEndpointFailsClosed` covers E03. (path: Tests/adevcontainerTests/GitCredentialBrokerServiceTests.swift) +- [x] 1.2 Add a detached-process probe suite with a private synthetic context and real test-owned executable copies: `credentials.detached.startBeforeConsumerAndSurviveCLI`, `credentials.detached.twoCLIsConverge`, `credentials.detached.idleExitAndSuccessor`, `credentials.detached.crashIsNotAutoRestarted` (including explicit-override loss and no request replay), `credentials.detached.upgradeReplacesOwner`, `credentials.detached.exitCleansOwnedSocketOnly` (including pending-work deadlines), `credentials.detached.staleAndSymlinkEndpointFailClosed`, and `credentials.detached.sighupAndSigtermExit`. Synthetic ports only; no Apple runtime, provider, or production path. (path: Tests/adevcontainerTests/GitCredentialBrokerDetachedTests.swift) +- [x] 1.3 Delete the isolated real-launchd probe and its suite (94 test surface). (path: Tests/adevcontainerTests/GitCredentialBrokerIdleLaunchdTests.swift) +- [x] 1.4 Replace the launchd probe dispatch with the detached probe dispatch and register the new suite instead of `gitCredentialBrokerIdleLaunchdTests`. (path: Tests/adevcontainerTests/main.swift) +- [x] 1.5 Replace plist/launchctl fields and cleanup instructions in the smoke harness with a process/socket manifest, test-owned pid/start records, and termination-based cleanup; remove the production-plist guard. (path: Tests/adevcontainerTests/GitCredentialBrokerSmokeHarness.swift) +- [x] 1.6 Rewrite smoke tests around the detached harness: assert no `/bin/launchctl` call, no plist path, surviving CLI exit, crash-without-restart, and only owned paths removed. (path: Tests/adevcontainerTests/GitCredentialBrokerSmokeTests.swift) +- [x] 1.7 Extend the private entry test to cover the isolated context path and a SIGHUP that does not terminate the broker loop; keep the existing invalid-context/public-help assertions. (path: Tests/adevcontainerTests/GitCredentialBrokerEntryTests.swift) + +## Checkpoint +- [x] No test invokes `launchctl` or creates a plist. The remaining `launchd`/`plist`/`launchctl` strings in tests are negative assertions. Red-before-green was not captured (see execution notes). + +## 2. Detached launcher and broker startup + +- [x] 2.1 Add a `DetachedProcessLaunching` port with a `posix_spawn` implementation: `POSIX_SPAWN_SETSID`, `POSIX_SPAWN_CLOEXEC_DEFAULT`, stdin/stdout/stderr to `/dev/null`, filtered environment (no `ADEVCONTAINER_GIT_TOKEN`), no parent wait, and a fake for unit tests. (path: Sources/ADevContainerLib/Runtime/DetachedProcessLauncher.swift) +- [x] 2.2 Remove `plist` from `GitCredentialLaunchContext`, validation, and `ensure()`; replace the launchctl/receipt/migration transaction with activation lock, `hello`, reuse decision, detached spawn, and readiness polling. (path: Sources/ADevContainerLib/Git/GitCredentialBrokerService.swift) +- [x] 2.3 Add private `hello` and `retire` control operations and an identity payload (pid, resolved context executable, executable device/inode, build UUID, protocol version); let `retire` reply before the loop stops. (path: Sources/ADevContainerLib/Git/GitCredentialControl.swift) +- [x] 2.4 Update the private entry for a detached session: ignore SIGHUP, keep SIGTERM/SIGINT graceful stop, keep the temporary `launch-context.json` for isolated contexts only, and remove launchd wording. (path: Sources/ADevContainerLib/Git/GitCredentialBrokerEntry.swift) +- [x] 2.5 Add a bounded `broker.lock` acquisition outcome so a spawned loser can exit 0 without touching the endpoint while a retiring owner finishes cleanup; keep the existing exclusive lifetime semantics. (path: Sources/ADevContainerLib/Git/GitCredentialBrokerService.swift) +- [x] 2.6 Update the broker run loop to treat lock contention as a successful loser exit, without unlinking the owner's socket or registry. (path: Sources/ADevContainerLib/Git/GitCredentialControl.swift) +- [x] 2.7 [P] Review the orphan-reaping comment for accuracy with a detached process; change wording only if it still implies a launchd job. (path: Sources/ADevContainerLib/Runtime/CredentialProcessGuardian.swift) +- [x] 2.8 Add a small executable-identity reader for a provided executable path (the launch-context executable) returning resolved path, device/inode, and the Mach-O build UUID when present; non-Mach-O test wrappers fall back to device/inode and protocol, while unreadable or unsafe files fail closed. (path: Sources/ADevContainerLib/Support/ExecutableIdentity.swift) + +## Checkpoint +- [x] D01, D02, D03, D04, D05 pass. `grep -rn "launchctl\|LaunchAgent\|launchd\|\.plist" Sources` returns no broker lifecycle reference; the only hit was the historical guardian comment reviewed in 2.7. + +## 3. Replacement, cleanup, and unregister semantics + +- [x] 3.1 Implement identity-based reuse and one bounded graceful replacement: compare device/inode, build UUID, and protocol version, send `retire`, wait for the old socket to become unusable, spawn and wait for the matching successor, and never treat liveness or path equality as identity. (path: Sources/ADevContainerLib/Git/GitCredentialBrokerService.swift) +- [x] 3.2 Make exit cleanup inode-safe: unlink `control.sock` only when it is still the socket this process bound, never unlink lock inodes, cancel channels/pending work, and never remove a successor's endpoint. (path: Sources/ADevContainerLib/Git/GitCredentialControl.swift) +- [x] 3.3 Make `unregister` with `activate: false` a success no-op when no broker is listening, without creating directories, spawning, or reconciling. (path: Sources/ADevContainerLib/Git/GitCredentialConnection.swift) +- [x] 3.4 Keep the stale-socket replacement path safe: verify type, owner, mode, and no-symlink before unlinking; fail closed on foreign or unsafe endpoints. (path: Sources/ADevContainerLib/Git/GitCredentialControl.swift) + +## Checkpoint +- [x] E01, E02, E03, F01, F03 pass. Crash probe shows no replacement process for longer than one idle window, and the next activation reconnects the running registration. + +## 4. Documentation and packaging consistency + +- [x] 4.1 Replace the per-user LaunchAgent description and the "installed-executable service path" sentence with the demand-started detached broker: survive CLI exit, no login item, 5-second idle exit, no automatic crash restart, next `up`/`start` recovers. (path: README.md) +- [x] 4.2 Replace launchd smoke guidance with detached-process verification guidance and remove the launchd gate variables. (path: CONTRIBUTING.md) +- [x] 4.3 Rewrite the isolated smoke procedure for a private detached context: no job label, no plist, no `launchctl`, process termination and socket cleanup, targeted SIGKILL manifest. (path: Tests/GuestGitCredentialRelay/SMOKE.md) +- [x] 4.4 [P] Update the relay test README wording that currently excludes launchd and lists isolated launchd smoke as pending. (path: Tests/GuestGitCredentialRelay/README.md) +- [x] 4.5 [P] Update the comment-only launchd reference in the artifact test script if it no longer applies. (path: Tests/GuestGitCredentialRelay/build_artifacts_tests.sh) +- [x] 4.6 [P] Update the comment-only LaunchAgent reference in the relay channel tests. (path: Tests/adevcontainerTests/GitCredentialRelayChannelTests.swift) + +## Checkpoint +- [x] F02 passes and `grep -rn "LaunchAgent\|launchctl\|launchd" README.md CONTRIBUTING.md Sources Tests scripts` has no remaining broker-lifecycle reference; the remaining hits are negative assertions and "never run launchctl" instructions. Formula renderer and CI are unchanged. + +## 5. Verification and archive + +- [x] 5.1 Run the full synthetic credential suite: `ADEVCONTAINER_TESTS_SYNTHETIC_ONLY=1 swift run adevcontainerTests` (all live gates unset). Result after review remediation: 1202 passed, 0 failed, 0 skipped. (path: Tests/adevcontainerTests/main.swift) +- [x] 5.2 Run the detached probe suite explicitly and record measured start/reuse/idle/crash/upgrade timings; confirm no launchd, production state, container, provider, or network access. Result after review remediation: 113 credential-focused cases passed, 0 failed with `ADEVCONTAINER_DETACHED_BROKER_TEST=1 ADEVCONTAINER_CREDENTIAL_TESTS_ONLY=1`; start + survive CLI exit 0.282s, idle exit 5.070s, upgrade replacement 0.208s, post-upgrade channel lookup 0.070s, silent same-user endpoint fail-closed 2.238s, slow-channel shutdown 1.606s, slow-channel idle exit 5.004s, crash observation 6.5s with `starts == 1`, successor reconnect and zero provider acquisitions outside the explicit lookup. (path: Tests/adevcontainerTests/GitCredentialBrokerDetachedTests.swift) +- [x] 5.3 On archive, apply the delta to `specs/core.md`: replace the supervision sentence, replace the token-persistence/override-recovery sentence, replace the "supervisor MUST restart" paragraph (including the reasserted I05–I07 external-stop, unknown-observation, and registration/retirement-race text), replace the C16 Given line, replace C13, remove **Verification isolates supervised installation state**, add **Verification isolates detached broker state** with V01, and append the three ADDED lifecycle requirements. (path: specs/core.md) — completed 2026-10-22 after explicit authorization; exact-text validation confirmed all four ADDED requirement blocks and all five replacement spans were merged, and the old verification requirement was removed. +- [x] 5.4 After verification passes and the user confirms archive, move `specs/changes/git-broker-detached-lifecycle/` to `specs/changes/archive/20261022-git-broker-detached-lifecycle/`; earlier archives stay immutable. (path: specs/changes/archive/20261022-git-broker-detached-lifecycle/proposal.md) — completed 2026-10-22; the destination did not previously exist, all four documents are present, the active bundle is absent, and relative source-spec links were adjusted for the archive layout. + +## Checkpoint +- [x] Every scenario D01-D05, E01-E03, F01-F03, V01, and the updated C13 maps to a passing test or a completed verification step; F02 is covered by the unchanged Formula surface plus the smoke assertions. Archive completion is recorded in 5.3/5.4 after merge and move. + +## 6. Review remediation (2026-10-21) + +The independent review reproduced six findings against the first implementation. Each fix is test-backed; the debug product, debug test binary, and release product built successfully after the changes. Those remediation verification runs used no production state, installed CLI, launchd job/plist, real container, provider, keychain, or network resource; the later authorized real smoke is recorded separately below. + +- [x] 6.1 E03/F03 — distinguish connect failure from a post-connect hello timeout. `GitCredentialControlClient.probe` now bounds `hello` to 2 seconds and returns `.unrecognized` for a connected-but-silent endpoint (a post-connect transport loss remains `.absent` for the owner-exit race). `ensure` rechecks the caller deadline after the probe, before writing the isolated launch context, and before `launcher.launch`; `retireOwner` rechecks before retiring. Regressions: `credentials.service.silentListenerFailsClosedWithoutReplacingEndpoint` (a same-user 0600 listener accepts and stays mute: fails in 2.003s, zero launches, same inode), `credentials.service.deadlineExpiryBeforeSpawnPreventsSideEffects` (a probe that consumes past a 0.2-second deadline and reports absence: no launch, no `launch-context.json`), and `credentials.detached.silentEndpointFailsClosed` (real detached activation: exit 1 in 2.238s, `starts == 0`, no pid, same inode; after removal a later retry starts one successor). The reviewer's 30.19s spawn-and-inode-swap reproduction no longer occurs. (paths: Sources/ADevContainerLib/Git/GitCredentialControl.swift, Sources/ADevContainerLib/Git/GitCredentialBrokerService.swift, Tests/adevcontainerTests/GitCredentialBrokerServiceTests.swift, Tests/adevcontainerTests/GitCredentialBrokerDetachedTests.swift) +- [x] 6.2 F01 — bound channel cancellation. `CredentialDuplexProcess.cancel(deadline:)` signals the group and reaps with `WNOHANG` plus a 2-second default bound instead of a blocking `waitpid`; a timeout returns without caching a result so a later cancel can retry the reap. `GitCredentialRelayChannel.cancel()` uses a 1.5-second bound, and `GitCredentialBroker.shutdown()` cancels channels concurrently and waits at most 1.5 seconds overall. Regressions: `credentials.process.cancelHonorsDeadline` (expired deadline returns 137 in <1s), `credentials.service.slowChannelCancelKeepsExitBound` (channel whose cancel sleeps 3s; shutdown exits in 1.606s, under the 7-second bound), and `credentials.service.slowChannelIdleExitStaysWithinBound` (a 1-second channel cancel plus the 5-second grace and retirement recheck complete in 5.004s). (paths: Sources/ADevContainerLib/Runtime/CredentialDuplexProcess.swift, Sources/ADevContainerLib/Git/GitCredentialRelayChannel.swift, Sources/ADevContainerLib/Git/GitCredentialBroker.swift, Tests/adevcontainerTests/CredentialDuplexProcessTests.swift, Tests/adevcontainerTests/GitCredentialBrokerServiceTests.swift) +- [x] 6.3 SMOKE manifest — the harness now actually prints a secret-free line with the fixture root, state path, and pid at preparation and after each activation that records a broker pid; the broker also writes `/pid`. `SMOKE.md` describes that mechanism, the targeted SIGKILL sweep, and records that CI runs only the synthetic suite and does not set the detached gate. (paths: Tests/adevcontainerTests/GitCredentialBrokerDetachedTests.swift, Tests/GuestGitCredentialRelay/SMOKE.md) +- [x] 6.4 E02 — prove a lookup after upgrade, not only reconnection. The detached probe now serves one synthetic `get` through the successor's reconnected channel when the fixture requests it; `credentials.detached.upgradeReplacesOwner` asserts the delivered password, one provider acquisition, and no re-registration or extra spawn. Measured post-upgrade lookup: 0.070s. (path: Tests/adevcontainerTests/GitCredentialBrokerDetachedTests.swift) +- [x] 6.5 Contract — the active delta's replacement for the "supervisor MUST restart" paragraph now restates the archived I05–I07 behaviors that `specs/core.md` never received: external stop/removal invalidates session and pending replies within 2 seconds and retirement completes no later than 9 seconds after the event (2-second invalidation plus the 5-second grace plus at most one recheck interval); failed/timeout/unparseable/ambiguous runtime observation preserves nonsecret demand, does not cancel admitted work, retire, authorize an unvalidated identity, or replay; and registration versus final retirement is serialized so a registration either cancels retirement or retries against a successor within its deadline, never acknowledged then discarded. The archive remains untouched. The reasserted behaviors map to `credentials.broker.externalStopAndLateObservationFence` (I05: inactive observation at +2 seconds invalidates, +5 more permits retirement), `credentials.broker.runtimeUnknownPreservesRegistration` and `credentials.broker.pendingRequestSurvivesUnknownButCannotLeak` (I06), and `credentials.broker.registrationLosingRetirementCannotSucceed` plus `credentials.broker.graceDemandAndRetirementBoundary` (I07). Analyze rerun PASS after the final hygiene pass: every spec requirement and scenario (D01–D05, E01–E03, F01–F03, V01, modified C13/C16, reasserted I05–I07) maps to a task/scenario row and a named test; no unresolved clarification marker, no new public surface/dependency, and no task outside the review scope was added. The I05–I07 rationale/traceability note now lives in `design.md`, so `spec.md` carries only normative text. (paths: specs/changes/git-broker-detached-lifecycle/spec.md, specs/changes/git-broker-detached-lifecycle/design.md) +- [x] 6.6 README — replaced the obsolete "installed-executable service path" sentence with identity-based replacement (device/inode plus build identity; the next lifecycle invocation retires a mismatched owner and starts a successor from the current executable). (path: README.md) +- [x] 6.7 Verification — `swift build --product adevcontainer`, `swift build --product adevcontainerTests`, and `swift build -c release --product adevcontainer` passed. `ADEVCONTAINER_TESTS_SYNTHETIC_ONLY=1 .build/debug/adevcontainerTests`: 1202 passed, 0 failed, 0 skipped. `ADEVCONTAINER_DETACHED_BROKER_TEST=1 ADEVCONTAINER_CREDENTIAL_TESTS_ONLY=1 .build/debug/adevcontainerTests`: 113 passed, 0 failed, 0 skipped. Only the pre-existing CLT linker search-path and captured-variable warnings remain. The detached gate is still not run by CI; that limitation is now documented rather than implied. (paths: .github/workflows/ci.yml (observed, unchanged), Tests/GuestGitCredentialRelay/SMOKE.md) +- [x] 6.8 Scope gaps — the reviewer's 1205 full-suite count was not rerun under this dispatch's no-container constraint: with the Apple container runtime available, that suite executes real fixture E2E cases that create containers. The synthetic suite explicitly excludes all 18 live integration cases and passes 1202; the credential-focused run (104 non-detached plus 9 detached probes) passes 113 and covers every changed source path. Release was built with `--product adevcontainer`; a bare `swift build -c release` retains the pre-existing test-target failure noted by the reviewer and was not changed. + +## 7. Separately authorized plantsuite smoke and final closure (2026-10-22) + +- [x] 7.1 Real workflow evidence, reviewed independently: 7/7 `git ls-remote` operations and 8/8 credential gets succeeded. A concurrent batch of 8 gets plus 4 Git operations completed in 1.838s. The broker survived CLI exit and retained the same PID. These results supplement, not replace, the synthetic scenario coverage above. +- [x] 7.2 Stop followed by idle exit completed in 5.642s total. This measures the combined stop-and-idle workflow, not isolated idle latency. After an injected crash, an 8.104s observation showed no automatic respawn, a credential lookup produced the expected miss, and the next start recovered the connection. The real smoke did not certify the external-stop retirement bound of 9 seconds and did not force or prove OAuth renewal. +- [x] 7.3 Cleanup evidence: restoration of six files was reported; credential-store metadata was unchanged. Independent live verification confirmed plantsuite stopped and no broker remained; no LaunchAgent was present. Four temporary reports and the owned smoke root were removed after evidence review. No product code changed during the smoke; recovery backups were left intact. These are reviewed prior-run results, not runtime checks rerun during archive. +- [x] 7.4 The user explicitly authorized the final documentation adjustments and archive. The relay README now distinguishes implemented/verified lifecycle behavior from release/platform evidence limits; proposal/design wording no longer claims an existing Homebrew draft or a captured red-before-green run. The detached CI gate remains opt-in and absent from CI, a documented existing limitation rather than a new closure requirement. No Formula, tap, Cask, release, product code, tests, wiki, or earlier archive was changed by this closure dispatch. diff --git a/specs/clone.md b/specs/clone.md index e750f3f..66d23b1 100644 --- a/specs/clone.md +++ b/specs/clone.md @@ -264,7 +264,7 @@ Additional existing labels MAY be set. Discovery of managed containers for `list ### Requirement: In-container full clone populate (auth by URL scheme) -After the container is created and started (and Features have ensured in-container git), `clone` MUST populate the workspace volume with a **full git clone inside the container** into `workspaceFolder` (volume mount), **before** create-path lifecycle hooks — except when reusing a workspace volume that already has object entries (see **Workspace volume freshness**). That reuse path MUST skip in-container git clone and keep the tree. An empty reused volume (no object entries other than `lost+found`) MUST still be populated. +After the container is created and started (and Features have ensured in-container git), `clone` MUST populate the workspace volume with a **full git clone inside the container** into `workspaceFolder` (volume mount), **before** create-path lifecycle hooks — except when reusing a workspace volume that already has object entries (see **Workspace volume freshness**). That reuse path MUST skip in-container git clone and keep the tree, and MUST still prepare the automatic credential connection before hooks. An empty reused volume (no object entries other than `lost+found`) MUST still be populated. **Populate steps — MUST** @@ -280,12 +280,12 @@ After the container is created and started (and Features have ensured in-contain | Scheme | Behavior | |--------|----------| | **SSH** (`git@host:path`, `ssh://…`) | On volume-mode create, inject `AllowlistedRunArg.ssh` (`container create --ssh`) when host `SSH_AUTH_SOCK` is set and non-empty (if not already in runArgs). If SSH URL and `SSH_AUTH_SOCK` unset/empty → fail structured with hint to start ssh-agent / `ssh-add` / use HTTPS. Later push works while the container retains `--ssh` forward from create. | -| **HTTPS** (`http://`, `https://`) | On the **host**, attempt credentials via `git credential fill` (protocol/host/path from URL) using host env (works with GCM/osxkeychain without product GCM integration). Optional fallbacks: `ADEVCONTAINER_GIT_TOKEN`; if `gh` available and host is github.com, `gh auth token`. When credentials are available, pass them into **one** in-container `git clone` via GIT_ASKPASS / env one-shot (prefer approach that avoids logging secrets; redact errors), then configure in-container `credential.helper store` and `git credential approve` once so later push/pull work without re-prompt. Store MAY persist on volume/home layer. When fill returns nothing, still attempt an anonymous in-container clone (public repos). If that clone fails → structured error hinting to configure git credential on Mac or use SSH URL. The host-side acquisition (fill with `GIT_TERMINAL_PROMPT=0`, `ADEVCONTAINER_GIT_TOKEN`, `gh auth token` fallback) is the **shared acquisition contract**; clone's in-container store+approve pattern is one use site (create-path seeding uses its own username-agnostic helper — see below). | +| **HTTPS** (`http://`, `https://`) | Before the in-container full clone and subsequent create-path hooks, establish the same on-demand credential connection used by up/start, as defined in [core.md](core.md) **Git credential store seeded on create paths** (heading retained; token seeding is no longer the behavior). The shared host acquisition contract retains existing provider precedence: explicit host token, `git credential fill` with protocol/host/path, and the optional existing GitHub CLI fallback, with host interactivity disabled and bounded waits. Clone MUST NOT configure new `credential.helper store` entries, approve or persist token seeds, or pass credential values through guest environment variables. Azure and non-Azure HTTP(S) clones use that same connection; Azure retains the URL-scoped helper and organization path behavior defined in core.md. No GCM installation or detection in the guest, and no mounting of host credential stores. When host credentials are unavailable, an anonymous in-container clone MUST still be possible. A private authentication failure MUST produce a structured host-login or SSH hint without a new token CLI flag. | | **Other** | Fail clear or best-effort passthrough with structured errors on failure. | **Shared acquisition contract — MUST** -The host-side HTTPS acquisition (`git credential fill` with `GIT_TERMINAL_PROMPT=0`, optional `ADEVCONTAINER_GIT_TOKEN`, `gh auth token` fallback) constitutes the shared acquisition contract. Clone's populate MUST keep the in-container `credential.helper store` + `git credential approve` pattern; create-path seeding MUST reuse the same host-side acquisition and persist credentials in-container via the username-agnostic credential helper defined in [core.md](core.md) **Git credential store seeded on create paths** — the seeding mechanism does not use `credential.helper store`. Both use sites MUST redact secrets identically (URL userinfo and credential material must never appear in success JSON, labels, progress lines, or logged errors). +The host-side HTTPS and legacy HTTP acquisition — explicit host token, `git credential fill` with protocol/host/path and `GIT_TERMINAL_PROMPT=0`, and the optional `gh auth token` fallback — constitutes the shared acquisition contract, with host interactivity disabled and bounded waits. Clone and create-path setup MUST use the on-demand connection defined in [core.md](core.md) **Git credential store seeded on create paths**. Clone MUST NOT configure new `credential.helper store` entries, approve or persist token seeds, or pass credential values through guest environment variables. Both use sites MUST redact secrets identically (URL userinfo and credential material must never appear in success JSON, labels, progress lines, logged errors, or diagnostics). **Clone cleanup on failure (after workspace volume / container create) — MUST** @@ -294,7 +294,7 @@ If start, populate, or create-path lifecycle hooks fail after the workspace volu 1. Delete the managed dev container (force as needed), and 2. Delete the workspace volume (`*-ws`) **only when this invocation created it** (it did not exist, or the operator chose replace and this invocation created the replacement), -before returning the structured failure. A reused pre-existing workspace volume MUST NOT be deleted on these failures. (Create-path hook runners that already delete the container still require workspace volume deletion on this path when this invocation created that volume.) Temp dirs remain subject to always-clean rules below. Tests MUST assert no successful outcome and no leftover clone container on these failures, and no leftover workspace volume when this invocation created it. A reused volume MUST still exist. +before returning the structured failure. A reused pre-existing workspace volume MUST NOT be deleted on these failures. Clone failure cleanup MUST also invalidate its credential session without acquiring tokens, and MUST retain these created-versus-reused volume deletion rules. (Create-path hook runners that already delete the container still require workspace volume deletion on this path when this invocation created that volume.) Temp dirs remain subject to always-clean rules below. Tests MUST assert no successful outcome and no leftover clone container on these failures, and no leftover workspace volume when this invocation created it. A reused volume MUST still exist. #### Scenario: Populate uses in-container git clone with verify - Given a resolved clone create with container started and in-container git available @@ -311,11 +311,6 @@ before returning the structured failure. A reused pre-existing workspace volume - When the user runs `adevcontainer clone ` - Then the CLI fails structured with a hint to start ssh-agent / use HTTPS and MUST NOT create a container or volume -#### Scenario: HTTPS uses host credential fill one-shot -- Given an HTTPS git URL and host `git credential fill` returns username/password -- When populate runs -- Then credentials are passed into one in-container clone without appearing in success JSON/labels, and after clone the guest configures `credential.helper store`. - #### Scenario: HTTPS public works without host credentials - Given an HTTPS git URL to a public repository and host credential fill returns nothing - When clone populate runs @@ -342,17 +337,21 @@ before returning the structured failure. A reused pre-existing workspace volume - When clone runs - Then the product does not install/detect GCM inside the container and does not mount host `~/.git-credentials` -#### Scenario: create-path seeding reuses the shared acquisition contract +#### Scenario: L01 - one automatic HTTPS mechanism before populate +- Given a fresh HTTPS or legacy HTTP clone for Azure or a non-Azure host and an available host provider +- When clone populates the volume +- Then the resolved connection user's automatic helper is connected before the single in-container full clone and hooks, verification still checks .git, no host full-clone/tar path runs, and no token store/approve/secret environment mechanism is installed -- Given a bind-mode `up` fresh create (or a `rebuild` replacement create) whose host workspace has an HTTPS remote, and clone's populate already exercised the same host fill and store+approve semantics -- When create-path seeding acquires credentials for that remote -- Then seeding uses the same acquisition contract as the clone HTTPS row (fill with `GIT_TERMINAL_PROMPT=0`, `ADEVCONTAINER_GIT_TOKEN` escape hatch, `gh auth token` fallback) and redacts secrets identically +#### Scenario: L02 - public/private fallback and volume reuse cleanup +- Given no host credentials, or a reused populated workspace volume +- When HTTPS clone runs +- Then public anonymous clone can succeed, private authentication fails noninteractively with a redacted host-login hint, and populated reuse skips clone while preparing the connection before hooks +- And any failed-clone session is invalidated, newly created workspace volumes follow existing cleanup, and reused volumes are preserved -#### Scenario: clone keeps a single populate mechanism (regression) - -- Given a volume-mode `clone` create with an HTTPS git URL and host credentials available -- When clone runs populate -- Then the existing in-clone store+approve delivers the store outcome before hooks and NO seeding call runs on `clone` +#### Scenario: L03 - SSH regression +- Given an SSH clone URL and either a present or absent host SSH agent +- When clone runs +- Then existing --ssh injection/missing-agent behavior is unchanged and no HTTPS credential session is created for that SSH URL --- diff --git a/specs/core.md b/specs/core.md index ed7fee0..b4bf9e6 100644 --- a/specs/core.md +++ b/specs/core.md @@ -761,184 +761,149 @@ See also: [lifecycle-hooks.md](lifecycle-hooks.md) for hook surface details; [vs ### Requirement: Git credential store seeded on create paths -On the create paths — `up` fresh create (bind mode) and `rebuild` replacement create (bind and volume mode) — the CLI MUST seed the resolved remote connection user's git credential store in the container BEFORE any create-path lifecycle hook (onCreateCommand, updateContentCommand, postCreateCommand, postStartCommand) runs. Seeding MUST run after the create-path ownership steps. +For managed containers with discovered HTTPS or legacy HTTP credential destinations, the product MUST automatically prepare the resolved connection user's Git credential helper for on-demand host acquisition before create-path hooks, after ownership preparation. A per-user broker process using the currently invoking adevcontainer executable MUST survive the invoking CLI and activate on demand without sudo. It MUST NOT install, load, or depend on a LaunchAgent plist, launchd job, brew service, or login item, and MUST NOT be restarted automatically after a crash (see **Demand-started detached Git credential broker**). Guest setup MUST NOT require Python, socat, a compiler, GCM, or additional package installation. New product-managed persistent guest token stores MUST NOT be created. -Seeding MUST write a POSIX-sh credential helper script to the connection user's home directory in the container (e.g. `$HOME/.adevcontainer/git-credential-adev`) with mode 0700, owned by the connection user. For non-Azure HTTPS hosts, seeding MUST configure the helper at `--global` scope via `git config --global --add credential.helper `; the configuration MUST append and MUST NOT replace pre-existing credential helpers. Seeding MUST add one credential entry per unique (protocol, host, username) triple via `git credential approve`, which routes through the configured helper whose store mode persists the entry. Entries MUST NOT include a path component, so sibling repositories on the same host are covered without knowing their paths. Matching uses the URL scheme (protocol) and host; the queried username and path are ignored on get. SSH remotes MUST NOT be seeded. +Discovery MUST retain the unique HTTP(S) fetch destinations of the host workspace for bind mode and the stamped Git URL for volume mode; clone MUST register its requested HTTP(S) URL. Legacy HTTP MUST retain its original scheme and existing provider policy without widening to new hosts/ports or permitting scheme downgrade. The product MUST NOT enable unsafe-HTTP overrides in a host provider. Missing host Git, no repository/remotes, or absent volume URL MUST remain silent skips without making host Git a prerequisite for up. Missing credentials from a provider MUST be a silent miss. Duplicate source URLs MUST NOT create duplicate helpers or sessions. -For `dev.azure.com`, every seed and refresh MUST NOT write an empty `credential.https://dev.azure.com.helper` value and MUST NOT reset the global `credential.helper` list. It MUST remove an existing empty value for that URL-scoped key so an old empty reset stops blocking fallthrough. It MUST ensure the product helper path is present exactly once, and MUST set `credential.https://dev.azure.com.useHttpPath` true. A pathless query proxied to host Git Credential Manager fatals with "Cannot determine the organization name" because the organization is in the URL path; the CLI MUST NOT copy host `credential.helper` (`osxkeychain` / GCM) into the guest and MUST NOT proxy pathless queries to GCM. +Setup MUST apply to fresh up, clone, rebuild replacement, real start, up start-stopped, and already-running up/start reuse. Existing healthy sessions on reuse MUST NOT be needlessly recreated or trigger provider lookup; missing or broken sessions MUST be repaired. Setup errors MUST warn with redaction and MUST NOT themselves fail up/start/rebuild, delete a container, or enter recovery. Hooks retain their own failure semantics. -The helper MUST implement `get`, `store`, and `erase`. On `get`, the helper MUST match the persisted store by (protocol, host) IGNORING the queried username and path, and MUST return the queried username (or the stored username when the query carries none) together with the stored password; when no entry matches, the helper MUST exit 0 with no output so git falls through to remaining helpers, askpass, or prompt. If the matched password is a JWT (exactly two dots, three segments) and the payload decodes and contains a numeric `exp` less than or equal to now plus 60 seconds, `get` MUST exit 0 with no output and MUST remove that store entry so the expired token is not replayed. If the password is not a JWT, `exp` is missing, decode fails, or base64 is unavailable, `get` MUST return the stored password. The helper MUST NOT print the password outside the credential protocol. On `store`, the helper MUST persist the entry, deduping by (protocol, host, username), to its own store file (e.g. `$HOME/.adevcontainer/git-credentials`) with mode 0600, owned by the connection user; the helper MAY use git's store file format with percent-encoding or its own simple line format, and persisted values MUST round-trip raw username and password values (including `@` and `:` characters) without corruption. On `erase`, the helper MUST remove every store entry matching protocol and host, ignoring username and path, by an atomic rewrite that leaves the store mode 0600. No match MUST exit 0. `erase` MUST NOT be a no-op. Secrets MUST NOT be echoed to stdout or stderr outside the credential protocol and MUST NOT appear in argv or the environment. +#### Scenario: C01 - create and rebuild prepare before hooks +- Given bind up, bind rebuild, or volume rebuild with discovered HTTP(S) destinations and an available provider +- When the container is running and ownership is prepared +- Then the resolved connection user has a connected credential helper before any create-path hook, including when that user is root, without a persisted token seed +- And bind discovery uses host fetch remotes while volume discovery uses the stamped URL -Credentials MUST be acquired on the HOST through the shared acquisition contract declared by **In-container full clone populate (auth by URL scheme)** ([clone.md](clone.md)): `git credential fill` (protocol/host/path from URL) with `GIT_TERMINAL_PROMPT=0`, optional `ADEVCONTAINER_GIT_TOKEN`, and the `gh auth token` fallback — the existing `HostGitCredential.fillHTTPS` path. When fill returns nil for a URL, the CLI MUST skip that URL silently. +#### Scenario: C02 - real start and running reuse +- Given either a stopped managed container or an already-running matching container +- When start or up selects it +- Then real start prepares the connection before restart postStart, running reuse repairs only missing/broken connections, and neither duplicates a healthy session nor queries credentials merely to reuse it -Remote discovery MUST be: +#### Scenario: C03 - silent skips and soft setup failure +- Given missing host Git, no HTTP(S) destinations, a non-repository workspace, an absent stamped URL, or a provider miss +- When lifecycle setup or helper lookup runs +- Then missing sources/providers do not add a lifecycle failure or warning, and an actual installation/transport setup failure produces only a redacted warning without product-triggered deletion or recovery -- Bind mode (`up` fresh create, `rebuild` bind): the unique fetch remote URLs of the host workspace via `git -C remote -v`. -- Rebuild volume mode: the stamped `devcontainer.git_url` label of the rebuilt container; a missing or empty label MUST skip seeding silently. +The product MUST preserve pre-existing global helpers and their ordering, append its non-Azure helper idempotently, and install its Azure helper exactly once at the matching registered scheme's URL-scoped helper key. HTTPS Azure MUST retain its existing helper key and `credential.https://dev.azure.com.useHttpPath=true`; explicitly discovered legacy HTTP Azure MUST receive equivalent HTTP-scoped helper/path handling without authorizing HTTP for an HTTPS-only registration. Azure configuration MUST remove legacy empty helper values, MUST NOT write an empty helper reset, and MUST NOT reset global helper lists. The automatic freshness guarantee applies when the product helper is invoked; earlier user-configured helpers MAY satisfy Git without invoking it and MUST NOT be silently disabled. -Silent skip — no warning and no failure — MUST apply when host git is missing, when the host workspace is not a git repository or has no remotes, or when fill returns nil. `up` MUST NOT gain a host-git prerequisite. +On get, the product helper MUST authorize the destination before contacting a host provider. The authorized destination set MUST remain the HTTP or HTTPS scheme plus exact normalized host and port discovered for that container, retaining sibling repository coverage across paths rather than limiting lookup to the original repository. Unknown hosts, alternative ports, unregistered schemes, and non-HTTP(S) protocols MUST be misses without provider calls. HTTP and HTTPS MUST NOT authorize each other. Queried usernames MUST NOT select a different host account/provider; responses MUST retain the queried username when supplied, otherwise the acquired username. The host MUST receive the actual validated repository path for Azure lookups; a missing organization path MUST be a miss without a pathless GCM call. It MUST NOT silently substitute a different repository path. -Seeding failures (for example missing in-container git or an exec failure) MUST soft-fail: a warning on stderr and the create path continues. Seeding MUST NOT delete the container and MUST NOT enter bring-up recovery. Hook failure remains the hook's own failure under existing create-path policy. +#### Scenario: C04 - Azure configuration and path compatibility +- Given an HTTPS Azure destination (or explicitly registered legacy HTTP Azure destination), a legacy empty URL-scoped helper, existing global helpers, and a lookup for a sibling repository on that host +- When setup and get run +- Then the product helper occurs exactly once at the matching scheme's Azure URL-scoped key, useHttpPath is true, empty resets are absent, global helpers/order are preserved, and the host receives that sibling's actual organization/repository path +- And a pathless Azure lookup never reaches GCM -Secrets MUST NEVER appear in success JSON, labels, StatusPrinter progress lines, or logged errors; errors MUST redact URL userinfo and credential material (same redaction as the clone flow). +#### Scenario: C05 - destination and username compatibility +- Given a registered HTTPS host and an acquired username X +- When Git requests another repository on that host with username Y, or requests an unregistered host, different port, unregistered scheme, or non-HTTP(S) protocol +- Then the sibling request returns the acquired password with username Y without using Y to change host account selection, while unauthorized requests produce misses without provider calls -A real `start` (stopped → running) and `up` start-stopped MUST re-seed the same way after the container is running and before restart postStart. Bind mode uses the host workspace's HTTPS fetch remotes; volume mode uses the stamped `devcontainer.git_url`. Silent skip — no warning and no failure — MUST apply when host git is missing, when there are no HTTPS remotes, or when fill returns nil. Refresh failure MUST soft-fail with a redacted stderr warning and MUST NOT fail start or up, delete the container, or enter recovery. Already-running `start` and `up` reuse of an already-running container MUST NOT re-seed. +#### Scenario: C06 - existing helper ordering +- Given a user-configured helper before the product helper +- When setup runs repeatedly and Git performs a lookup +- Then the original helper and ordering remain, the product entry is not duplicated, and an earlier complete answer is not represented as proof of product lookup or freshness -`adevcontainer refresh-credentials [--name]` MUST use the same managed selection as `start` (interactive picker or `--name`). The target MUST already be running. If it is stopped, the command MUST fail with a structured error whose hint names `start --name ` and MUST NOT start or stop the container. An exec failure MUST fail the command with a redacted error. The command MUST NOT run git fetch, pull, or push, and MUST NOT write the workspace. Credential material MUST enter the guest via stdin only, never argv or environment. +#### Scenario: C19 - legacy HTTP without implicit expansion +- Given a discovered HTTP destination and a distinct HTTPS-only destination +- When lookup requests the registered HTTP destination, another port/host, or HTTP on the HTTPS-only destination +- Then only the registered HTTP destination can reach the existing provider, its scheme/path/port are retained, an unsafe-HTTP provider refusal remains a miss, and no unsafe override or downgrade is enabled -`clone` keeps its existing populate path. Non-Azure clone configures local `credential.helper store` plus pathless approve and MUST NOT install the product helper. Azure clone MUST install the same URL-scoped product helper via `installHTTPSCredential` (no second mechanism, no empty helper reset, `useHttpPath` true) and MUST NOT use pathless `credential.helper store` for that host. +Each product get MUST use the shared host acquisition provider rather than a product guest-token cache. Host acquisition MUST disable Git terminal prompts and provider GUI/TTY prompts, respect a finite deadline, and return a miss if interaction is required. Azure provider interaction MUST be disabled in addition to `GIT_TERMINAL_PROMPT=0`. The product MUST NOT implement OAuth renewal or claim a provider's unchanged token is evidence of renewal. Provider nil MUST permit normal Git helper fallthrough; product-managed clone/hooks MUST remain noninteractive on authentication failure and offer host-login guidance. The shared acquisition precedence remains the contract in [clone.md](clone.md) **In-container full clone populate (auth by URL scheme)**. -#### Scenario: up bind fresh create seeds the store before hooks +Store MUST consume input without persisting credentials or approving them in the host credential store. Erase MUST NOT invoke host credential reject/erase, remove account bindings, or modify the host token cache. It MUST temporarily suppress replay of a matching credential previously delivered for that session/destination, without suppressing a newer different credential or affecting other containers. Suppression MUST expire after a finite cooldown; a subsequent get MAY consult the provider again. Helper operations MUST NOT contain an unbounded internal reacquisition/rejection loop. -- Given a bind-mode `up` fresh create whose host workspace has an HTTPS fetch remote `https://dev.azure.com/plantsuite/PlantSuite/_git/GitOps` and host `git credential fill` returns credentials -- When the create path runs after start and before create-path hooks -- Then the CLI acquires credentials on the host and runs an in-container exec as the connection user that writes the credential helper script, configures the `dev.azure.com` URL-scoped helper (no empty helper value, product helper present once, `useHttpPath` true) without resetting the global `credential.helper` list, and approves an entry for (https, dev.azure.com, plantsuite) BEFORE the first create-path hook exec +#### Scenario: C07 - lookup freshness and provider UI required +- Given two successive product-helper gets and a host provider that changes its answer between them +- When the gets run +- Then each obtains the current provider answer without a guest seed, and a provider requiring interaction instead results in a bounded miss with no GUI/TTY prompt or product OAuth-refresh claim -#### Scenario: sibling repositories on the same host are covered +#### Scenario: C08 - store and correlated erase +- Given a credential delivered in one session and a newer different credential delivered for the same destination +- When store or a delayed erase for the older delivery arrives +- Then store persists/approves nothing, erase does not suppress the newer credential, and neither operation mutates host credential storage or another session -- Given the same bind-mode `up` and a workspace whose remotes include `https://dev.azure.com/plantsuite/PlantSuite/_git/GitOps` and `https://dev.azure.com/plantsuite/PlantSuite/_git/Other` -- When seeding runs -- Then one approve entry per unique (protocol, host, username) is applied and the entries contain no path component +#### Scenario: C09 - rejection cooldown is finite +- Given a delivered credential that Git rejects and a provider that continues returning the same value +- When get occurs during suppression and after its deadline, or stop followed by start clears that session's suppression +- Then the rejected value is not immediately replayed, the cooldown eventually permits a fresh attempt, and no single helper operation loops indefinitely -#### Scenario: duplicate remote lines dedupe to one entry +The product MUST provide private per-container channels without network listeners, bind mounts of host credential material, or guest access to host runtime control endpoints. Session identity and destination authorization MUST derive from host-established context, not guest-asserted container identity. Guest connection endpoints MUST be accessible only to the resolved connection user, subject to guest root's existing authority. Product-owned helper/session directories MUST be private and correctly owned; stale sockets MUST be safely replaced without following attacker-controlled symlinks or deleting unrelated files. -- Given `git -C remote -v` lists the same fetch URL more than once -- When seeding runs -- Then only one approve entry per (protocol, host, username) is applied +Secrets MUST remain in confidential pipes/socket payloads and process memory, never new persistent token files, process argv, product-created environment variables, labels, progress, success JSON, or diagnostics. Reading the existing explicit host token escape hatch is retained; the product MUST NOT propagate it through a new child environment or persist it across processes. Any per-session explicit override MUST be lost on broker restart and MUST require registration by an invoking CLI to restore it; successor recovery MUST NOT silently substitute a different credential source for that session until re-registration. No provider/helper command text or local filesystem path supplied by the guest MUST be executed. -#### Scenario: no host credentials skip silently +#### Scenario: C10 - session isolation and malicious input +- Given two registered containers and an untrusted guest request claiming the other container's identity or carrying an unauthorized destination, malformed fields, oversized payload, or command text +- When the broker processes it +- Then identity remains bound to the established channel, invalid requests do not reach the provider, unrelated sessions remain usable, and no arbitrary host command runs -- Given a public HTTPS remote and host credential fill returns nothing -- When seeding runs -- Then the CLI skips silently (no warning, no failure) and the create path continues +#### Scenario: C11 - secret containment and private endpoints +- Given successful lookup, provider/transport errors, or endpoint provisioning against a stale or symlinked path +- When the operation finishes +- Then secrets appear only in intended credential-channel responses, endpoints have private ownership/permissions, no host credential/control endpoint is mounted or published, and unsafe paths fail without deleting unrelated files -#### Scenario: workspace without a git repo or remotes skips silently +#### Scenario: C12 - explicit token override restart +- Given a CLI registration with the existing explicit host token override +- When the service restarts +- Then the override was not persisted or put in child environment, its session cannot silently switch credential source, and re-registration restores that override through private IPC -- Given a bind workspace that is not a git repository, or a git repository with no remotes -- When `up` fresh create runs -- Then no seeding exec runs and `up` succeeds +Only one service instance per macOS user MUST own the session registry. Simultaneous activations MUST be idempotent. Concurrent requests MUST have bounded admission, payload size, deadlines, and independent response correlation. Service/channel failure MUST terminate pending waits within their deadlines. Recovery MUST reject messages from prior service/container session generations and MUST not authorize a new container solely because it reuses an old name. -#### Scenario: missing host git skips silently +A crashed broker MUST NOT be restarted automatically. The successor started by the next credential-dependent lifecycle activation MUST reconcile saved nonsecret registrations against managed container identity/state before recreating channels. Transport loss while a container remains running MUST trigger bounded-backoff reconnection; failed requests MUST NOT be replayed automatically. Stopped/removed containers MUST have no live relay or usable session; stop/delete/purge and failed-clone cleanup MUST invalidate registrations without acquiring tokens. Runtime changes outside the CLI MUST be reconciled within a finite interval: an external stop or removal MUST invalidate that registration's session and pending replies within 2 seconds and retirement MUST complete no later than 9 seconds after the event, comprising the 2-second invalidation, the 5-second zero-demand grace, and at most one recheck interval, without provider polling. A failed, timed-out, unparseable, or ambiguous runtime observation MUST NOT be treated as absence: it MUST preserve the nonsecret registration and its recovery intent, MUST NOT cancel an admitted request solely for that reason, MUST NOT start idle retirement or deliver credentials to an unvalidated identity, and MUST NOT replay a request; existing request deadlines remain in force, and a later valid observation MUST either reconnect the same identity or definitively invalidate it and start a fresh grace. Registration and the final no-demand retirement decision MUST be serialized: a registration MUST either cancel retirement and connect on that owner or retry against an activated successor within its existing setup deadline, and it MUST never be acknowledged and then discarded by idle shutdown. Only one owner may exist, late old-generation results MUST NOT remove a newer session, and old-instance cleanup MUST NOT unlink a successor's endpoint. Broker idleness MUST NOT cause provider polling. -- Given host git is not installed -- When bind-mode `up` fresh create runs -- Then no seeding runs, no warning is required, and `up` succeeds without a host-git prerequisite +#### Scenario: C13 - activation races and process exit +- Given concurrent lifecycle invocations and a prepared container +- When the invocations finish or one broker instance crashes +- Then only one process owns the registry, credential lookup remains available after the invoking CLIs exit, no LaunchAgent or other persistent service is installed, a crash is not restarted automatically, and the next credential-dependent invocation starts a successor without sudo or another installed host executable -#### Scenario: rebuild bind seeds from host remotes +#### Scenario: C14 - out-of-order concurrency and overload +- Given multiple concurrent gets whose provider replies complete out of order and requests beyond admission bounds +- When requests complete or time out +- Then every reply reaches only its own waiter, capacity is bounded, overload fails within the request deadline, and other sessions are not starved indefinitely -- Given a bind-mode `rebuild` replacement create whose host workspace has an HTTPS remote and host fill returns credentials -- When the rebuild create path runs after start and before create-path hooks -- Then seeding runs before the first create-path hook exec and the rebuild succeeds - -#### Scenario: rebuild volume seeds from the stamped git URL - -- Given a volume-mode `rebuild` of a managed container whose `devcontainer.git_url` label is `https://github.com/org/repo` -- When the rebuild create path runs (no host workspace remote enumeration) -- Then seeding acquires credentials for that URL and approves an entry before the first create-path hook exec - -#### Scenario: rebuild volume without a stamped git URL skips silently - -- Given a volume-mode rebuild whose stamped `devcontainer.git_url` label is missing or empty -- When the rebuild create path runs -- Then no seeding runs and the rebuild succeeds - -#### Scenario: seeding failure soft-fails - -- Given a create path whose seeding exec fails (for example in-container git is missing or the exec errors) -- When seeding runs -- Then stderr carries a warning, the create path continues through hooks and succeeds absent other failures, the container is NOT deleted, and bring-up recovery is NOT entered - -#### Scenario: expired JWT get is a miss and drops the entry - -- Given a store entry whose password is a JWT (exactly two dots, three segments) and whose payload decodes to a numeric `exp` less than or equal to now plus 60 seconds -- When the helper handles `get` for that protocol and host -- Then the helper exits 0 with no output, removes that store entry, and does not print the password outside the credential protocol - -#### Scenario: undecodable or non-JWT passwords are still returned - -- Given a store entry whose password is not a JWT, or is a JWT whose `exp` is missing, whose payload does not decode, or for which base64 is unavailable -- When the helper handles `get` for that protocol and host -- Then the helper returns the stored password with the queried username (or the stored username when the query has none) - -#### Scenario: erase removes protocol and host matches - -- Given a store with two entries for the same protocol and host and one entry for another host -- When the helper handles `erase` for that protocol and host, including a query that names a different username or a path -- Then both matching entries are removed by an atomic rewrite, the other host remains, the store mode is 0600, and stdout and stderr are empty -- And an erase with no match exits 0 and leaves the store unchanged +#### Scenario: C15 - transport failure, restart, and generation fence +- Given pending requests and a stale guest socket +- When the relay/container or broker restarts, including a rebuild reusing the name +- Then pending requests fail within their deadline, stale endpoints are safely replaced, nonsecret registrations are revalidated, old-generation replies/identities are rejected, and a fresh lookup succeeds after connection recovery without replaying the old request -#### Scenario: Azure seed removes an empty helper and does not write one +#### Scenario: C16 - stop, removal, and external reconciliation +- Given a registered container and an idle broker process +- When stop, delete, purge, failed-clone cleanup, or an external runtime stop/removal occurs +- Then sessions/relays and pending requests are cleaned up or invalidated within bounded waits/reconciliation, no tokens are polled, and workspace/volume deletion semantics remain unchanged -- Given a global git config that already has an empty `credential.https://dev.azure.com.helper` value and another non-empty helper for that URL -- When Azure seeding or refresh configures the URL-scoped helper -- Then the empty value is gone, the product helper path is present exactly once, `credential.https://dev.azure.com.useHttpPath` is true, pre-existing global `credential.helper` values remain, and no empty helper value is written +On existing containers, setup MUST migrate the product-owned seeded helper to on-demand operation and remove the legacy product-owned token store without reading or logging its contents. It MUST NOT delete unidentified user credentials, reset user helper chains, or fall back to a legacy product seed if the host is unavailable. Legacy clone store files without trustworthy ownership/provenance MUST be preserved; user/legacy helpers that answer before the product remain outside the automatic freshness guarantee. Migration MUST be idempotent. -#### Scenario: refresh-credentials refreshes a running container +#### Scenario: C17 - migration without destructive credential cleanup +- Given an existing product helper/store, user helper configuration, and an unmarked legacy clone store file +- When up/start reuse performs migration twice +- Then the product helper becomes on-demand, the product-owned store is removed without exposing its content, unmarked/user credential files and helper ordering remain, and host failure does not replay the removed product seed -- Given a running managed container and host `git credential fill` returns a credential for its bind remotes or stamped git URL -- When the user runs `adevcontainer refresh-credentials --name ` -- Then the CLI execs the seed script in that container with the credential on stdin only, does not start or stop the container, and does not fetch, pull, push, or write the workspace +Already-running `start` and `up` MUST repair a missing or broken automatic connection from the same discovery sources without persisting tokens, fetching, pulling, pushing, writing the workspace, clearing that session's rejection suppression, or failing the command when setup fails. Setup failure remains a redacted warning. Rejection suppression is cleared by unregister on `stop`; clearing a rejection block before the cooldown expires requires `stop` followed by `start`. Neither path promises provider token renewal or resolves an expired host login. -#### Scenario: refresh-credentials refuses a stopped container +#### Scenario: Running recovery does not clear rejection suppression +- Given a running managed target with a broken channel and rejection suppression +- When start or up selects it +- Then the channel is repaired without clearing rejection suppression, without token persistence, and without repository operations; setup failure warns and does not fail the command -- Given a stopped managed container -- When the user runs `adevcontainer refresh-credentials --name ` -- Then the command fails with a structured error whose hint names `start --name `, and the CLI does not start, stop, or exec the container +#### Scenario: Stop then start clears rejection suppression +- Given a managed target whose session has rejection suppression +- When stop is followed by start +- Then stop unregisters the session and clears that suppression, and the following start prepares the connection again without promising provider token renewal -#### Scenario: real start and up start-stopped refresh and soft-fail - -- Given a stopped managed container -- When `adevcontainer start` or `up` starts it and the credential exec fails -- Then stderr carries a redacted warning, the command still succeeds absent other failures, the container is not deleted, and bring-up or start recovery is not entered -- And the refresh runs after the container is running and before restart postStart - -#### Scenario: already-running start and up reuse do not refresh - -- Given a matching running container -- When `up` reuses it, or `start` finds it already running -- Then no credential refresh exec runs - -#### Scenario: secrets are redacted on the seeding error path - -- Given host fill returned username/password for a remote and the seeding exec fails -- When the warning or error is emitted -- Then the warning or error contains no credential material and no URL userinfo - -#### Scenario: SSH remotes are not seeded - -- Given a bind workspace whose remotes are SSH URLs (`git@host:path`) -- When `up` fresh create runs -- Then no seeding exec runs - -#### Scenario: seeding runs as the resolved connection user - -- Given a create path whose resolved connection user is `alice` (non-root) or `root` -- When the seeding exec runs -- Then the exec runs as that user and the store lands in that user's home directory - -#### Scenario: S15: username-agnostic get serves URL-forced usernames - -- Given a seeded store entry for (https, dev.azure.com, X) and a create-path hook whose URL forces username Y (X ≠ Y) on the same protocol and host -- When the hook's git performs credential lookup in the non-interactive hook environment -- Then the helper's get returns the stored password with the QUERIED username Y and the hook's git authenticates - -#### Scenario: S16: unseeded hosts receive no credentials - -- Given a seeded store containing entries only for one (protocol, host, username) triple -- When git queries the helper for a host absent from the store -- Then the helper exits 0 with no output, no credentials are returned, and git falls through to remaining helpers, askpass, or prompt +--- -#### Scenario: S17: helper and store files are private and secrets stay out of argv and logs +### Requirement: Self-contained guest credential artifacts -- Given a create path that seeded the helper -- When seeding completes and the hook environment runs -- Then the helper script (0700) and the store file (0600) are owned by the connection user, are not world-readable, and no password material appears in argv, success JSON, labels, progress lines, or logged errors +The distributed macOS CLI MUST contain version-matched static Linux relay/client artifacts for linux/arm64 and linux/amd64. Runtime selection MUST use the actual guest platform rather than the host architecture. The product MUST verify artifact integrity and protocol/platform compatibility before allowing credential delivery, and MUST fail closed on unsupported/missing/corrupt artifacts using existing soft lifecycle error semantics. It MUST NOT install a translator, enable Rosetta, download executable artifacts at runtime, or fall back to Python/socat. Release and source builds MUST have a documented pinned, checksum-verified build path and reproducible guest outputs; relocation of the distributed executable MUST NOT lose its guest artifacts. Applicable linked-runtime license notices MUST accompany distribution. -#### Scenario: S18: pre-existing global credential helpers are preserved +#### Scenario: A01 - source and release artifact completeness +- Given a clean source checkout with the documented build prerequisites or an unpacked release +- When the source preparation/build is repeated in independent build directories and the release executable is relocated without the source tree +- Then both Linux variants have matching source/protocol identity, deterministic per-variant hashes, no dynamic loader or shared-library dependency, and the relocated CLI retains both verified artifacts without guest package installation or runtime downloads -- Given a container whose global git config already configures one or more credential helpers -- When seeding appends its helper via `git config --global --add credential.helper ` -- Then the pre-existing helpers remain configured and are consulted before the seeded helper +#### Scenario: A02 - platform selection and tampering +- Given arm64 or amd64 guests, an unsupported guest platform, or an altered/truncated artifact +- When the product provisions and handshakes the relay +- Then supported guests receive their own variant, invalid/unsupported artifacts fail before credential delivery, read-back/handshake verifies the installed bytes and protocol, and no host-architecture substitution or translator installation occurs --- @@ -2043,3 +2008,99 @@ The repository MUST provide `references/dockerfile/` containing `.devcontainer.j - Given `references/dockerfile/.devcontainer.json` and its sibling `Dockerfile` - When the example is admitted and resolved - Then nested `build` admits, no Compose or privileged/DinD requirement is present, and validation does not fail solely because `image` is omitted + +--- + +### Requirement: Demand-started detached Git credential broker + +For a managed container with discovered HTTP(S) destinations, the first eligible credential lifecycle setup MUST ensure exactly one per-user broker process is serving, starting it from the currently invoking `adevcontainer` executable when none is acceptable, without sudo, a public command, a LaunchAgent plist, a launchd job, a `brew services` entry, or a login item. The process MUST be detached from the invoking CLI so it survives CLI exit, and it MUST complete the connection before HTTP(S) full clone or applicable create-path hooks when setup succeeds. Existing silent-skip, redacted soft-failure, and hook/clone failure semantics remain unchanged. + +Broker demand MUST exist only for credential-dependent managed-container lifecycle work: fresh up, clone, rebuild replacement, real start, up start-stopped, and running up/start repair. Installation, login, list, inspect, exec, stop, delete, purge, unregister, and setup with no HTTP(S) destination or SSH-only sources MUST NOT start a process or create an endpoint. When a broker is running, unregister MUST remove the registration without provider access. Unregister without a running broker MUST succeed as a no-op without starting one; saved intent is not usable until a successor revalidates it. + +The broker MUST be a single instance per macOS user. Concurrent CLI activations MUST serialize and MUST start at most one process; every successful registration MUST reach that owner. A losing spawn attempt MUST exit without replacing or unlinking the owner's endpoint. No second process may serve the same registry or control socket. + +After the last registration is definitively invalidated or unregistered and no admitted work remains, the broker MUST exit successfully after a continuous 5-second zero-demand grace, rechecked at intervals no greater than 2 seconds. Under healthy, promptly responding local adapters, exit MUST complete no later than 7 seconds after demand reaches zero and MUST leave no running broker and no stale control socket. A later eligible setup MUST start a successor. SIGTERM and SIGINT MUST perform the same bounded cleanup as idle exit. + +A crash MUST NOT be restarted automatically, and the product MUST NOT install or rely on any supervisor that restarts it. Until the next eligible lifecycle activation, credential lookups fail as ordinary misses and any stale control socket is not usable. The next activation MUST start a successor that loads saved nonsecret registrations, reconciles them against authoritative managed-container identity and state before recreating channels, and reconnects eligible running containers. A crash MUST NOT resurrect an explicit token override or replay pending requests. + +#### Scenario: D01 - first demand starts a detached broker before consumers +- Given no broker process exists and a managed container has discovered HTTPS destinations +- When fresh up, clone, rebuild replacement, real start, or up start-stopped prepares the connection +- Then exactly one broker process from the invoking executable is serving before HTTP(S) full clone or applicable hooks, the setup connects, and that same process still serves lookups after the invoking CLI exits +- And no product-created LaunchAgent plist, launchd job, brew service, login item, or separate installed launcher exists + +#### Scenario: D02 - no lifecycle demand creates no broker +- Given no broker process exists +- When install, login, list, inspect, exec, stop, delete, purge, unregister, or SSH-only/no-destination setup runs +- Then no broker process, control socket, or new registration intent is created, and stop/delete/purge still report success +- And no launchd or `brew services` operation is attempted, and saved intent is not usable until a successor revalidates it + +#### Scenario: D03 - concurrent CLIs converge on one instance +- Given no broker process exists and two CLIs run eligible setup concurrently +- When both activations and registrations complete +- Then only one broker process and one control socket exist, both registrations reach that owner, neither registration is acknowledged by a losing instance, and no duplicate process survives +- And a later eligible activation reuses the same owner without spawning another + +#### Scenario: D04 - idle exit removes the process and a later demand starts a successor +- Given a broker whose last registration was invalidated or unregistered and which has no admitted work +- When the 5-second grace elapses without new demand +- Then the process exits within the healthy bound, its owned control socket is removed, no automatic replacement starts, and a later eligible setup starts a successor before its consumers + +#### Scenario: D05 - crash is recovered only by the next activation +- Given a broker with a running managed-container registration +- When the broker dies abnormally +- Then no replacement process starts during an observation window longer than the idle bound, the stale socket is not usable, and the next up/start or other eligible activation starts a successor that preserves the nonsecret registration, revalidates the running container, and reconnects before its consumers +- And the successor does not restore an explicit token override or replay a pending request + +### Requirement: Executable-identity broker reuse and replacement + +A running broker MUST expose a private, same-user-authenticated identity to CLI activation containing at least its process identity, the executable device and inode captured when it started, and its build/protocol identity. A broker whose identity matches the invoking executable MUST be reused without restart, without a reload, and without provider lookup. A broker whose executable or protocol identity differs MUST be retired gracefully before a successor from the invoking executable serves registrations. Replacement MUST preserve nonsecret registrations, remain bounded by the existing activation deadline, and MUST NOT leave two owners, a second registry writer, or a successor-unlinked endpoint. + +Identical on-disk definition bytes, equal path strings, a live socket, or a successful liveness probe MUST NOT alone establish identity match. Concurrent activations observing the same stale instance MUST replace it at most once and MUST all converge on the successor. A broker that cannot be identified, or that accepts retirement but does not exit within the bound, MUST fail activation with the existing redacted soft-failure behavior rather than kill a process or unlink an endpoint it cannot authenticate, or start a competing owner. + +#### Scenario: E01 - matching executable is reused +- Given a running broker started from the invoking executable and a healthy registration +- When repeated setup/reuse runs, including concurrent activations +- Then the same process remains, no new process is spawned, no restart or reload occurs, and provider lookup is not triggered merely by reuse + +#### Scenario: E02 - upgraded executable replaces the running broker +- Given a broker running from an older executable, for example a replaced Homebrew keg or a rebuilt development binary, with saved nonsecret registrations +- When a CLI from the new executable activates credentials +- Then the old process exits gracefully, the new executable becomes the only owner, the registrations survive and are revalidated, and a subsequent lookup succeeds without re-registration or credential persistence +- And repeating the activation does not repeatedly kill the healthy successor + +#### Scenario: E03 - replacement failure is bounded and recoverable +- Given a running broker that reports an unrecognized identity, refuses to exit, or becomes unreachable after accepting retirement +- When activation attempts reuse or replacement +- Then setup fails within its existing deadline using the existing redacted warning semantics, no second registry owner is created, no process or endpoint that cannot be authenticated is killed or unlinked, and a later retry can converge + +### Requirement: Broker exit cleanup and package removal without service registration + +On graceful exit the broker MUST cancel its channels and pending work, remove its control socket only when it still owns the same private socket inode, and MUST NOT unlink lock inodes or delete saved nonsecret registrations. Old-instance cleanup MUST NOT remove or replace a successor's endpoint. A successor MUST safely replace a stale, privately owned socket left by an abnormal exit without following symlinks or deleting unrelated files; a foreign or unsafe endpoint MUST fail closed. + +The product MUST NOT install, load, or depend on a LaunchAgent plist, launchd job, `brew services` entry, login item, or other persistent host service. Ordinary package removal MUST therefore leave no product-created broker job or plist and MUST NOT require an uninstall hook, `zap`, a manual cleanup command, or a later login. Formula packaging, install, test, and caveats remain unchanged. A detached broker still running when the package is removed MUST exit under the existing 5-second grace once its last demand ends, MUST NOT be restarted, and MUST NOT recreate broker state after it exits. + +#### Scenario: F01 - graceful exit cleans only owned runtime state +- Given a broker with active channels, pending work, and saved registrations +- When it exits for idleness or receives SIGTERM/SIGINT while a successor is binding +- Then channels and pending work terminate within their deadlines, the old cleanup removes only its owned socket, never a successor's socket or a lock inode, and any remaining saved registrations are preserved for successor reconciliation + +#### Scenario: F02 - package removal leaves no job or plist +- Given a normal Formula install, upgrade, or removal, with or without a running detached broker +- When the operation completes +- Then no product-created LaunchAgent plist, launchd job, brew service, or login item exists, no uninstall hook or manual cleanup is required, and a running broker exits under the normal grace once its last demand ends and is not restarted + +#### Scenario: F03 - stale or unsafe endpoint is handled safely +- Given an abnormal exit left a privately owned stale socket, or the expected socket path is a symlink or foreign object +- When the next activation starts a successor +- Then the owned stale socket is replaced safely, while a symlink or foreign endpoint fails closed without deleting unrelated files + +### Requirement: Verification isolates detached broker state + +Verification of a development build MUST use a unique private broker context and test-owned executable or executables under the approved temporary root. It MUST NOT touch production state or production broker identity, execute `launchctl`, write a plist, use a real launchd domain, authenticate with a real provider, run installed CLI code, or start user containers without separate authorization. Success, failure, and handled interruption MUST terminate only test-owned detached processes and remove their control sockets, registrations, endpoint directories, helper configuration, and exclusively created container resources. Abrupt unhandleable termination MUST have a narrowly targeted residual-cleanup procedure using only recorded test paths and process identity. + +#### Scenario: V01 - isolated detached broker verification and cleanup +- Given a development build, a pre-existing user installation/state, authorized throwaway runtime resources, and a synthetic provider +- When isolated verification starts, replaces, crashes, retires, fails midway, and receives a handled termination signal +- Then CLI-exit survival, single-owner concurrency, idle exit, identity replacement, and no-automatic-crash-restart are demonstrated without any launchctl command, plist, production state, or real credential acquisition +- And production installation/state remains unchanged, cleanup removes only test-owned processes and paths, and abrupt termination has a documented targeted cleanup procedure diff --git a/wiki/architecture.md b/wiki/architecture.md index 431c57b..443201c 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -59,7 +59,7 @@ devcontainer.json → Config resolver → [Dockerfile image] → [Features runne | **Bind** | `up` from host workspace | Host dir via virtiofs (APFS) | Real path on Mac | | **Volume** | `clone ` | Named volume via virtio-blk (`volume.img` raw ext4) | No durable host checkout; label `local_folder=volume://…` | -Volume mode exists for better metadata I/O (git status, node_modules, many small files) vs virtiofs binds. Apple named-volume `volume.img` is raw ext4 (magic `0xEF53` at offset 1080) and can be `e2fsck`'d in place only while unmounted; do not copy the sparse image. `Structure needs cleaning` on chown is ext4 `EUCLEAN`, not a chown-logic bug. Detail: [gaps — named volume ownership](domain/devcontainer-apple-gaps.md#named-volume-ownership-rootroot). Contract: [`specs/clone.md`](../specs/clone.md) (archived change `20260808-clone-in-volume`). +Volume mode exists for better metadata I/O (git status, node_modules, many small files) vs virtiofs binds. Apple named-volume `volume.img` is raw ext4 (magic `0xEF53` at offset 1080); in-place `e2fsck` is valid only while unmounted; a same-volume APFS `cp -c` (unmounted, outside the container volume store) is the backup, and a naive copy of the sparse image is still forbidden. `Structure needs cleaning` on chown is ext4 `EUCLEAN`, not a chown-logic bug. Detail: [gaps — named volume ownership](domain/devcontainer-apple-gaps.md#named-volume-ownership-rootroot). Contract: [`specs/clone.md`](../specs/clone.md) (archived change `20260808-clone-in-volume`). ## Commands (product surface) diff --git a/wiki/conventions/cli-runtime-boundary.md b/wiki/conventions/cli-runtime-boundary.md index 3fbd897..cfabd8d 100644 --- a/wiki/conventions/cli-runtime-boundary.md +++ b/wiki/conventions/cli-runtime-boundary.md @@ -216,7 +216,7 @@ Helper shape: same image/platform; eligible RW named volumes at private targets; **Parents:** some Apple rootfs inodes (`/workspaces`) reject `chown` with EPERM even as root. Unmodifiable but traversable ancestors do not fail `up`. Recursive target `chown -R` stays strict. The helper cannot repair the main private rootfs; on the helper/`--read-only` path, parents are probed after start as the connection user (`test -x`). -**Filesystem:** Apple named-volume `volume.img` is raw ext4 (magic `0xEF53` at offset 1080) and can be `e2fsck`'d in place only while unmounted; do not copy the sparse image. `Structure needs cleaning` on chown is ext4 `EUCLEAN`, not a chown-logic bug. +**Filesystem:** Apple named-volume `volume.img` is raw ext4 (magic `0xEF53` at offset 1080); in-place `e2fsck` is valid only while unmounted; a same-volume APFS `cp -c` (unmounted, outside the container volume store) is the backup, and a naive copy of the sparse image is still forbidden. `Structure needs cleaning` on chown is ext4 `EUCLEAN`, not a chown-logic bug. Detail: [gaps — named volume ownership](../domain/devcontainer-apple-gaps.md#named-volume-ownership-rootroot). Create `-u` for a non-root connection user (typical official image: OCI USER root + metadata `remoteUser` vscode) stays; ownership exec stays `-u root`. @@ -230,6 +230,8 @@ Contract: [`specs/core.md`](../../specs/core.md) (workspace parents + read-only Contract: [`specs/core.md`](../../specs/core.md) **Git credential store seeded on create paths**. Implementation symbol: `GuestGitCredentialSeed`. +In plantsuite, the vscode user's `$HOME/.adevcontainer` stays `0755` because the VS Code marker uses `mkdir -p`. Credential prepare must not treat that mode as fatal — refusal blocks setup before any guest endpoint exists. Tighten to `0700` only for an own non-symlink directory; refuse symlinks and foreign-owned directories without `chmod`. Symbol: `GitCredentialConnection.privateDirectoryRepair`. + **Test seam:** command tests driving `up` fresh-create or `rebuild` replacement that install `RecordingHostProcessRunner` via `LifecycleRunner.hostProcessRunnerOverride` must answer or tolerate the seeding probe call (`git -C remote -v`), same as the adapted initialize-command tests. ## `clone` flow (volume-mode) diff --git a/wiki/domain/devcontainer-apple-gaps.md b/wiki/domain/devcontainer-apple-gaps.md index a7c3cbc..0bd04de 100644 --- a/wiki/domain/devcontainer-apple-gaps.md +++ b/wiki/domain/devcontainer-apple-gaps.md @@ -52,7 +52,7 @@ Apple named volumes present as **root:root** at the mount target. Without a prod **Product:** `WorkspaceOwnership` chowns in the main container after start when `CAP_CHOWN` is available and `runArgs` has no `--read-only`. `--read-only` is a rootfs flag (not volume `:ro`) and selects the same pre-start scoped helper as dropped `CAP_CHOWN`; the helper does not inherit `--read-only` and still chowns writable named volumes. Some Apple rootfs inodes (`/workspaces`) reject `chown` with EPERM even as root; unmodifiable but traversable ancestors do not fail `up`. Recursive target `chown -R` stays strict. The helper cannot repair the main private rootfs; on the helper path, parents are probed as the connection user. Create `-u` for metadata `remoteUser` (typical OCI USER root + metadata vscode) stays; ownership exec stays `-u root`. Real Apple-runtime validation of the ownership helper path remains pending. Detail: [cli-runtime-boundary — Named volumes / ownership](../conventions/cli-runtime-boundary.md#named-volumes-ensure--reuse--ownership). Contract: [`specs/core.md`](../../specs/core.md). -Apple named-volume `volume.img` is raw ext4 (magic `0xEF53` at offset 1080) and can be `e2fsck`'d in place only while unmounted; do not copy the sparse image. `Structure needs cleaning` on chown is ext4 `EUCLEAN`, not a chown-logic bug. +Apple named-volume `volume.img` is raw ext4 (magic `0xEF53` at offset 1080). In-place `e2fsck` is valid only while unmounted. The backup is a same-volume APFS `cp -c` taken only while unmounted and outside the container volume store: it clones the sparse image without copying allocated extents. A naive copy of the sparse image is still forbidden. `e2fsck -n` aborts at the first refused directory salvage, so a non-zero `-n` is not a full pass 3–5 scan. Repair only while unmounted, after that clone exists. `errors=Continue` does not remount read-only and keeps a corrupt named volume read-write, so further rm/chown/install adds errors. `Structure needs cleaning` on chown is ext4 `EUCLEAN`, not a chown-logic bug. ### File bind mounts