Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions system7-tests/gitGitHubTokenAuthTests.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//
// gitGitHubTokenAuthTests.m
// system7-tests
//
// Copyright © 2026 Readdle. All rights reserved.
//

#import <XCTest/XCTest.h>

#import "Git.h"
#import "Git+Tests.h"

@interface gitGitHubTokenAuthTests : XCTestCase
@end

@implementation gitGitHubTokenAuthTests

#pragma mark - argv builder -

- (void)testReturnsEmptyWhenUserNil {
XCTAssertEqualObjects(@[],
[GitRepository gitHubTokenInsteadOfArgumentsForUser:nil token:@"abc"]);
}

- (void)testReturnsEmptyWhenUserEmpty {
XCTAssertEqualObjects(@[],
[GitRepository gitHubTokenInsteadOfArgumentsForUser:@"" token:@"abc"]);
}

- (void)testReturnsEmptyWhenTokenNil {
XCTAssertEqualObjects(@[],
[GitRepository gitHubTokenInsteadOfArgumentsForUser:@"alice" token:nil]);
}

- (void)testReturnsEmptyWhenTokenEmpty {
XCTAssertEqualObjects(@[],
[GitRepository gitHubTokenInsteadOfArgumentsForUser:@"alice" token:@""]);
}

- (void)testBuildsTwoInsteadOfPairsForSimpleCreds {
NSArray<NSString *> *const args = [GitRepository gitHubTokenInsteadOfArgumentsForUser:@"alice"
token:@"abc123"];

NSArray<NSString *> *const expected = @[
@"-c", @"url.https://alice:abc123@github.com/.insteadOf=git@github.com:",
@"-c", @"url.https://alice:abc123@github.com/.insteadOf=ssh://git@github.com/",
];
XCTAssertEqualObjects(expected, args);
}

- (void)testPercentEncodesSpecialCharsInToken {
// Token with every char that would break URL parsing if left raw.
NSString *const token = @"a/b@c:d%e#f g";
NSArray<NSString *> *const args = [GitRepository gitHubTokenInsteadOfArgumentsForUser:@"alice"
token:token];

XCTAssertEqual((NSUInteger)4, args.count);

NSString *const joined = [args componentsJoinedByString:@" "];
// Each special character must be percent-encoded inside the userinfo.
XCTAssertTrue([joined containsString:@"a%2Fb%40c%3Ad%25e%23f%20g"],
@"token chars must be percent-encoded; joined argv was: %@", joined);

// And the raw forms with the original separators must NOT leak through —
// a raw `@` after the token would terminate the userinfo prematurely.
XCTAssertFalse([joined containsString:@"a/b@c:d%e#f g"]);
}

- (void)testPercentEncodesSpecialCharsInUser {
NSArray<NSString *> *const args = [GitRepository gitHubTokenInsteadOfArgumentsForUser:@"al ice@org"
token:@"abc"];

NSString *const joined = [args componentsJoinedByString:@" "];
XCTAssertTrue([joined containsString:@"al%20ice%40org:abc@github.com/"],
@"user chars must be percent-encoded; joined argv was: %@", joined);
}

- (void)testInsteadOfBaseUsesHTTPSAndGithubDotComOnly {
NSArray<NSString *> *const args = [GitRepository gitHubTokenInsteadOfArgumentsForUser:@"alice"
token:@"abc"];

NSString *const joined = [args componentsJoinedByString:@" "];
XCTAssertTrue([joined containsString:@".insteadOf=git@github.com:"]);
XCTAssertTrue([joined containsString:@".insteadOf=ssh://git@github.com/"]);
XCTAssertTrue([joined containsString:@"url.https://alice:abc@github.com/"]);

// No other hosts referenced — every "github.com" should be the only host.
XCTAssertFalse([joined containsString:@"gitlab"]);
XCTAssertFalse([joined containsString:@"bitbucket"]);
}

#pragma mark - trace masking -

- (void)testTraceMaskReplacesEncodedTokenSubstring {
NSString *const line = @"-c url.https://alice:abc123@github.com/.insteadOf=git@github.com: "
"clone git@github.com:readdle/RDPDFKit.git dest";

NSString *const masked = [GitRepository maskedTraceLine:line forToken:@"abc123"];

XCTAssertFalse([masked containsString:@"abc123"], @"raw token leaked: %@", masked);
XCTAssertTrue([masked containsString:@"https://alice:***@github.com/"]);
// The non-credential parts of the command line must be preserved.
XCTAssertTrue([masked containsString:@"git@github.com:readdle/RDPDFKit.git"]);
XCTAssertTrue([masked containsString:@"clone"]);
}

- (void)testTraceMaskAlsoRedactsTokenWithSpecialChars {
// The encoded form is what actually appears in the argv; the raw form
// is masked as defense-in-depth in case git echoes it back somewhere.
NSString *const token = @"a/b@c";
NSString *const line = @"-c url.https://alice:a%2Fb%40c@github.com/.insteadOf=git@github.com:";

NSString *const masked = [GitRepository maskedTraceLine:line forToken:token];

XCTAssertFalse([masked containsString:@"a%2Fb%40c"], @"encoded token leaked: %@", masked);
XCTAssertTrue([masked containsString:@"https://alice:***@github.com/"]);
}

- (void)testTraceMaskNoOpWhenTokenEmpty {
NSString *const line = @"clone git@github.com:readdle/RDPDFKit.git dest";

XCTAssertEqualObjects(line, [GitRepository maskedTraceLine:line forToken:nil]);
XCTAssertEqualObjects(line, [GitRepository maskedTraceLine:line forToken:@""]);
}

- (void)testTraceMaskAlsoRedactsLongToken {
// Verifies that masking doesn't depend on token length — `***` is applied
// regardless. (Earlier iterations partial-revealed long tokens; this guards
// against that regression.)
NSString *const token = @"ghp_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789"; // 40 chars
NSString *const line = [NSString stringWithFormat:@"https://alice:%@@github.com/", token];

NSString *const masked = [GitRepository maskedTraceLine:line forToken:token];

XCTAssertTrue([masked containsString:@"https://alice:***@github.com/"], @"got: %@", masked);
XCTAssertFalse([masked containsString:token], @"raw token leaked: %@", masked);
}

@end
93 changes: 93 additions & 0 deletions system7-tests/integration/case-cloneViaGitHubTokenAuth.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/bin/sh

# Integration test for the GH_USER/GH_TOKEN PAT auth mode.
#
# What the production change does: when GH_USER and GH_TOKEN are both set,
# s7 prepends `-c url.https://USER:TOKEN@github.com/.insteadOf=git@github.com:`
# (and the ssh:// variant) to every git invocation. This rewrites SSH GitHub
# URLs to HTTPS-with-token at network-resolution time, without touching
# .s7substate or persisting the token to any cloned subrepo's .git/config.
#
# Verified here, end-to-end:
# A. Without the env vars: no `-c url…insteadOf…` flags are injected.
# B. With the env vars: the flags appear on every git call, and the
# token is masked (`***`) in S7_TRACE_GIT output.
# C. The token does not leak to any file under the cloned subrepo's .git/.
# D. Real subrepo URLs (non-github.com) are untouched — the clone still
# succeeds and the original URL is stored verbatim in .git/config.
#
# Note: we don't drive a real `git@github.com:` clone to a fake remote here
# because git applies `insteadOf` rules with longest-prefix matching, not
# recursively, so a two-level rewrite chain can't be expressed cleanly.
# Trace inspection plus credential-leak guards prove the s7-layer behaves
# correctly; real github.com use is exercised in CI against the real PAT.

GH_TEST_USER=testuser
GH_TEST_TOKEN=mySecretToken_xyz123


# --- Set up pastey/rd2 with one subrepo (real local URL) ---

git clone github/rd2 pastey/rd2

cd pastey/rd2

assert s7 init
assert git add .
assert git commit -m '"init s7"'

assert s7 add --stage Dependencies/ReaddleLib '"$S7_ROOT/github/ReaddleLib"'
assert git commit -m '"add ReaddleLib subrepo"'
assert git push


# --- Scenario A: env unset → no insteadOf injection ---

cd "$S7_ROOT/nik"

unset GH_USER
unset GH_TOKEN

S7_TRACE_GIT=1 git clone "$S7_ROOT/github/rd2" rd2-noauth 2> traceA.log

assert test -d rd2-noauth/Dependencies/ReaddleLib
assert ! grep -q insteadOf= traceA.log
assert ! grep -q 'url\.https://' traceA.log


# --- Scenario B: env set → flags injected, token masked ---

cd "$S7_ROOT/nik"

export GH_USER="$GH_TEST_USER"
export GH_TOKEN="$GH_TEST_TOKEN"

S7_TRACE_GIT=1 git clone "$S7_ROOT/github/rd2" rd2-auth 2> traceB.log

# Subrepo clone must still succeed — local URLs in .s7substate are unaffected
# by the github.com-only insteadOf rule.
assert test -d rd2-auth/Dependencies/ReaddleLib

# Both rewrite pairs must appear (covers `git@github.com:` and `ssh://git@github.com/`).
assert grep -q 'insteadOf=git@github.com:' traceB.log
assert grep -q 'insteadOf=ssh://git@github.com/' traceB.log

# The HTTPS-with-userinfo URL must appear with the user but with the token
# replaced by `***`.
assert grep -qF 'url.https://testuser:***@github.com/' traceB.log

# Raw token must NEVER appear in trace output.
assert ! grep -qF "$GH_TEST_TOKEN" traceB.log


# --- Scenario C: token does not land in any .git/config or .git/ file ---

assert ! grep -rq "$GH_TEST_TOKEN" rd2-auth/.git/
assert ! grep -rq "$GH_TEST_TOKEN" rd2-auth/Dependencies/ReaddleLib/.git/


# --- Scenario D: subrepo's stored origin URL is the original local path ---

SUBREPO_URL=$(git -C rd2-auth/Dependencies/ReaddleLib config remote.origin.url)
EXPECTED_URL="$S7_ROOT/github/ReaddleLib"
assert test "$SUBREPO_URL" = "$EXPECTED_URL"
4 changes: 4 additions & 0 deletions system7.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
BEE928A12456DA6500BD6B86 /* Git.m in Sources */ = {isa = PBXBuildFile; fileRef = BEE928A02456DA6500BD6B86 /* Git.m */; };
BEFAF7A125718AB7000D90C3 /* bootstrapTests.m in Sources */ = {isa = PBXBuildFile; fileRef = BEFAF7A025718AB7000D90C3 /* bootstrapTests.m */; };
CE5BB61F25FA63A8002596B9 /* gitPackedRefsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CE5BB61E25FA63A8002596B9 /* gitPackedRefsTests.m */; };
A11C0CE526052600A0010001 /* gitGitHubTokenAuthTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A11C0CE526052600A0010002 /* gitGitHubTokenAuthTests.m */; };
/* End PBXBuildFile section */

/* Begin PBXCopyFilesBuildPhase section */
Expand Down Expand Up @@ -215,6 +216,7 @@
BEE928A02456DA6500BD6B86 /* Git.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Git.m; sourceTree = "<group>"; };
BEFAF7A025718AB7000D90C3 /* bootstrapTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = bootstrapTests.m; sourceTree = "<group>"; };
CE5BB61E25FA63A8002596B9 /* gitPackedRefsTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = gitPackedRefsTests.m; sourceTree = "<group>"; };
A11C0CE526052600A0010002 /* gitGitHubTokenAuthTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = gitGitHubTokenAuthTests.m; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -351,6 +353,7 @@
BEE672CE2523B76500DB09BC /* iniParserTests.m */,
BEBE40C32576D04D00E39755 /* deinitTests.m */,
CE5BB61E25FA63A8002596B9 /* gitPackedRefsTests.m */,
A11C0CE526052600A0010002 /* gitGitHubTokenAuthTests.m */,
);
path = "system7-tests";
sourceTree = "<group>";
Expand Down Expand Up @@ -623,6 +626,7 @@
BEE1CD03246A9F6F00E2CC3B /* controlTests.m in Sources */,
BE394D3D2486ADB500ED6E05 /* S7CheckoutCommand.m in Sources */,
CE5BB61F25FA63A8002596B9 /* gitPackedRefsTests.m in Sources */,
A11C0CE526052600A0010001 /* gitGitHubTokenAuthTests.m in Sources */,
BE3A29E524604C10004A2B31 /* statusTests.m in Sources */,
6DD4A8392750E8A40050F3FD /* S7IniConfigOptions.m in Sources */,
BE8218892452C1DE00E878A8 /* configParserTests.m in Sources */,
Expand Down
15 changes: 15 additions & 0 deletions system7/git/Git+Tests.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, class) void (^testRepoConfigureOnInitBlock)(GitRepository *repo);
@property (nonatomic, readonly) BOOL hasMergeConflict;

// Percent-encode every character that isn't RFC 3986 "unreserved".
// `%` itself is included in the escape set, so a literal `%` in a token
// becomes `%25` (avoids garbling the userinfo component of the URL).
+ (NSString *)urlEscapeUserinfo:(NSString *)input;

// Pure builder for the `-c url.<HTTPS+token>.insteadOf=<SSH>` argv prefix.
// Bypasses dispatch_once and process env so tests can probe arbitrary inputs.
+ (NSArray<NSString *> *)gitHubTokenInsteadOfArgumentsForUser:(nullable NSString *)user
token:(nullable NSString *)token;

// Pure trace-mask helper: redacts any occurrence of `token` (both URL-encoded
// and raw) in `line` with `***`.
+ (NSString *)maskedTraceLine:(NSString *)line
forToken:(nullable NSString *)token;

@end

NS_ASSUME_NONNULL_END
Loading
Loading