Skip to content
Merged
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
119 changes: 119 additions & 0 deletions system7-tests/gitGitHubTokenAuthTests.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//
// 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 - GIT_CONFIG_* env builder -

- (void)testReturnsNilWhenUserNil {
XCTAssertNil([GitRepository gitHubTokenAuthTaskEnvironmentForUser:nil token:@"abc" processEnvironment:@{}]);
}

- (void)testReturnsNilWhenUserEmpty {
XCTAssertNil([GitRepository gitHubTokenAuthTaskEnvironmentForUser:@"" token:@"abc" processEnvironment:@{}]);
}

- (void)testReturnsNilWhenTokenNil {
XCTAssertNil([GitRepository gitHubTokenAuthTaskEnvironmentForUser:@"alice" token:nil processEnvironment:@{}]);
}

- (void)testReturnsNilWhenTokenEmpty {
XCTAssertNil([GitRepository gitHubTokenAuthTaskEnvironmentForUser:@"alice" token:@"" processEnvironment:@{}]);
}

- (void)testBuildsHeaderAuthEntriesFromZero {
NSDictionary<NSString *, NSString *> *const env =
[GitRepository gitHubTokenAuthTaskEnvironmentForUser:@"alice" token:@"abc123" processEnvironment:@{}];

// base64("alice:abc123") == "YWxpY2U6YWJjMTIz"
NSDictionary<NSString *, NSString *> *const expected = @{
@"GIT_CONFIG_COUNT": @"3",
@"GIT_CONFIG_KEY_0": @"url.https://github.com/.insteadOf",
@"GIT_CONFIG_VALUE_0": @"git@github.com:",
@"GIT_CONFIG_KEY_1": @"url.https://github.com/.insteadOf",
@"GIT_CONFIG_VALUE_1": @"ssh://git@github.com/",
@"GIT_CONFIG_KEY_2": @"http.https://github.com/.extraheader",
@"GIT_CONFIG_VALUE_2": @"Authorization: Basic YWxpY2U6YWJjMTIz",
};
XCTAssertEqualObjects(expected, env);
}

- (void)testAppendsPastExistingConfigCount {
// A nested s7 inherits the parent's injected GIT_CONFIG_COUNT=3 and must not
// clobber entries 0..2.
NSDictionary<NSString *, NSString *> *const env =
[GitRepository gitHubTokenAuthTaskEnvironmentForUser:@"alice" token:@"abc" processEnvironment:@{@"GIT_CONFIG_COUNT": @"3"}];

XCTAssertEqualObjects(@"6", env[@"GIT_CONFIG_COUNT"]);
XCTAssertEqualObjects(@"url.https://github.com/.insteadOf", env[@"GIT_CONFIG_KEY_3"]);
XCTAssertEqualObjects(@"git@github.com:", env[@"GIT_CONFIG_VALUE_3"]);
XCTAssertEqualObjects(@"ssh://git@github.com/", env[@"GIT_CONFIG_VALUE_4"]);
XCTAssertEqualObjects(@"http.https://github.com/.extraheader", env[@"GIT_CONFIG_KEY_5"]);
// base64("alice:abc") == "YWxpY2U6YWJj"
XCTAssertEqualObjects(@"Authorization: Basic YWxpY2U6YWJj", env[@"GIT_CONFIG_VALUE_5"]);
// Must not clobber the caller's existing entries (indices 0..2).
XCTAssertNil(env[@"GIT_CONFIG_KEY_0"]);
XCTAssertNil(env[@"GIT_CONFIG_VALUE_2"]);
}

- (void)testNegativeOrGarbageExistingCountClampsToZero {
NSDictionary<NSString *, NSString *> *const env =
[GitRepository gitHubTokenAuthTaskEnvironmentForUser:@"alice" token:@"abc" processEnvironment:@{@"GIT_CONFIG_COUNT": @"-5"}];

XCTAssertEqualObjects(@"3", env[@"GIT_CONFIG_COUNT"]);
XCTAssertEqualObjects(@"url.https://github.com/.insteadOf", env[@"GIT_CONFIG_KEY_0"]);
}

- (void)testInheritedEnvironmentPassesThrough {
// The returned dictionary is the COMPLETE child environment: everything the
// process already had, plus the auth entries.
NSDictionary<NSString *, NSString *> *const env =
[GitRepository gitHubTokenAuthTaskEnvironmentForUser:@"alice"
token:@"abc"
processEnvironment:@{@"HOME": @"/Users/alice", @"GIT_CONFIG_COUNT": @"1"}];

XCTAssertEqualObjects(@"/Users/alice", env[@"HOME"]);
XCTAssertEqualObjects(@"4", env[@"GIT_CONFIG_COUNT"]);
XCTAssertEqualObjects(@"url.https://github.com/.insteadOf", env[@"GIT_CONFIG_KEY_1"]);
}

- (void)testTokenNeverAppearsRawOnlyInBase64Header {
NSString *const user = @"alice";
NSString *const token = @"ghp_SuperSecret/@:%123"; // chars that would have needed URL-escaping
NSDictionary<NSString *, NSString *> *const env =
[GitRepository gitHubTokenAuthTaskEnvironmentForUser:user token:token processEnvironment:@{}];

NSString *const expectedBasic =
[[[NSString stringWithFormat:@"%@:%@", user, token] dataUsingEncoding:NSUTF8StringEncoding]
base64EncodedStringWithOptions:0];

// The raw token must not appear in any entry — it rides only in the header,
// base64-encoded. (base64 needs no percent-encoding for arbitrary bytes.)
NSString *const joined = [env.allValues componentsJoinedByString:@"\n"];
XCTAssertFalse([joined containsString:token], @"raw token leaked into config: %@", joined);
NSString *const expectedHeader = [NSString stringWithFormat:@"Authorization: Basic %@", expectedBasic];
XCTAssertEqualObjects(expectedHeader, env[@"GIT_CONFIG_VALUE_2"]);
}

- (void)testGithubDotComOnly {
NSDictionary<NSString *, NSString *> *const env =
[GitRepository gitHubTokenAuthTaskEnvironmentForUser:@"alice" token:@"abc" processEnvironment:@{}];

NSString *const joined = [[env.allKeys arrayByAddingObjectsFromArray:env.allValues] componentsJoinedByString:@" "];
XCTAssertFalse([joined containsString:@"gitlab"]);
XCTAssertFalse([joined containsString:@"bitbucket"]);
}

@end
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
4 changes: 4 additions & 0 deletions system7/git/Git+Tests.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, class) void (^testRepoConfigureOnInitBlock)(GitRepository *repo);
@property (nonatomic, readonly) BOOL hasMergeConflict;

+ (nullable NSDictionary<NSString *, NSString *> *)gitHubTokenAuthTaskEnvironmentForUser:(nullable NSString *)user
token:(nullable NSString *)token
processEnvironment:(NSDictionary<NSString *, NSString *> *)processEnvironment;

@end

NS_ASSUME_NONNULL_END
99 changes: 99 additions & 0 deletions system7/git/Git.m
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,52 @@ + (BOOL)envGitTraceEnabled {
return traceEnabled;
}

+ (nullable NSString *)envGitAuthUser {
NSDictionary<NSString *, NSString *> *const env = NSProcessInfo.processInfo.environment;
NSString *const user = env[@"S7_GIT_USER"];
Comment thread
pastey marked this conversation as resolved.
if (user.length > 0) {
return user;
}
NSString *const ghUser = env[@"GH_USER"];
return ghUser.length > 0 ? ghUser : nil;
}

+ (nullable NSString *)envGitAuthToken {
NSDictionary<NSString *, NSString *> *const env = NSProcessInfo.processInfo.environment;
NSString *const token = env[@"S7_GIT_TOKEN"];
if (token.length > 0) {
return token;
}
NSString *const ghToken = env[@"GH_TOKEN"];
return ghToken.length > 0 ? ghToken : nil;
}

// Cached-once front for +gitHubTokenAuthTaskEnvironmentForUser:token:processEnvironment:,
// resolved against this process's credentials and environment. nil when token
// auth is off (either credential missing).
+ (nullable NSDictionary<NSString *, NSString *> *)gitHubTokenAuthTaskEnvironment {
static dispatch_once_t onceToken;
static NSDictionary<NSString *, NSString *> *taskEnvironment = nil;
dispatch_once(&onceToken, ^{
taskEnvironment = [self gitHubTokenAuthTaskEnvironmentForUser:[self envGitAuthUser]
token:[self envGitAuthToken]
processEnvironment:NSProcessInfo.processInfo.environment];
});
return taskEnvironment;
}

+ (void)logSelectedAuthPathOnce {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
if (nil != [self gitHubTokenAuthTaskEnvironment]) {
logInfo("s7: subrepo network auth: HTTPS via token\n");
}
else {
s7TraceGit(@"s7: subrepo network auth: SSH (default)\n");
}
});
}

#pragma mark - Initialization

- (nullable instancetype)initWithRepoPath:(NSString *)repoPath {
Expand Down Expand Up @@ -332,6 +378,16 @@ + (int)runGitWithArguments:(NSArray<NSString *> *)arguments
task.currentDirectoryURL = [NSURL fileURLWithPath:currentDirectoryPath];
}

[self logSelectedAuthPathOnce];

// Inject the HTTPS auth config via the child's environment (see
Comment thread
pastey marked this conversation as resolved.
// +gitHubTokenAuthTaskEnvironment). nil on the SSH path, so dev machines and
// any non-token use are completely unaffected (no task.environment override).
NSDictionary<NSString *, NSString *> *const environmentWithAuth = [self gitHubTokenAuthTaskEnvironment];
if (nil != environmentWithAuth) {
task.environment = environmentWithAuth;
}

// https://stackoverflow.com/questions/49184623/nstask-race-condition-with-readabilityhandler-block
// we must use semaphore to make sure we finish reading from pipes properly once task finished it's execution.
dispatch_semaphore_t pipeCloseSemaphore = dispatch_semaphore_create(0);
Expand Down Expand Up @@ -1464,6 +1520,49 @@ + (void)setTestRepoConfigureOnInitBlock:(void (^)(GitRepository * _Nonnull))test
_testRepoConfigureOnInitBlock = testRepoConfigureOnInitBlock;
}

// Pure builder for +gitHubTokenAuthTaskEnvironment. The process environment
// comes in as a parameter so tests can probe arbitrary inputs. Returns @{}
// when either credential is missing.
//
// The PAT travels in an HTTP Authorization header, never in a URL:
// * insteadOf rewrites every SSH github.com shape to the CLEAN (credential-
// free) https URL, so nothing git echoes (errors, GIT_TRACE_CURL,
// remote.origin.url) can ever carry the token;
// * a github.com-scoped http.<url>.extraheader supplies "Basic base64(user:
// token)" — the same mechanism actions/checkout uses.
// base64 swallows arbitrary token bytes, so no percent-encoding is needed.
// Delivered via GIT_CONFIG_* env: off-disk, off-argv. New entries append past
// any existing GIT_CONFIG_COUNT so a nested s7 (which inherits the parent's
// injected GIT_CONFIG_COUNT) doesn't clobber it.
+ (nullable NSDictionary<NSString *, NSString *> *)gitHubTokenAuthTaskEnvironmentForUser:(nullable NSString *)user
token:(nullable NSString *)token
processEnvironment:(NSDictionary<NSString *, NSString *> *)processEnvironment
{
if (0 == user.length || 0 == token.length) {
return nil;
}

NSMutableDictionary<NSString *, NSString *> *const result = [processEnvironment mutableCopy];
__block NSUInteger nextConfigPairIndex = (NSUInteger)MAX(0, [processEnvironment[@"GIT_CONFIG_COUNT"] integerValue]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
__block NSUInteger nextConfigPairIndex = (NSUInteger)MAX(0, [processEnvironment[@"GIT_CONFIG_COUNT"] integerValue]);
__block NSUInteger nextConfigPairIndex = MAX(0, [processEnvironment[@"GIT_CONFIG_COUNT"] unsignedIntegerValue]);

@oleksandrhleba oleksandrhleba Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

processEnvironment[@"GIT_CONFIG_COUNT"] returns string, not NSNumber, so I won't be able to use unsignedIntegerValue here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad. Thanks 👍

__auto_type addConfigKV = ^ void (NSString *key, NSString *value) {
result[[NSString stringWithFormat:@"GIT_CONFIG_KEY_%lu", (unsigned long)nextConfigPairIndex]] = key;
result[[NSString stringWithFormat:@"GIT_CONFIG_VALUE_%lu", (unsigned long)nextConfigPairIndex]] = value;
++nextConfigPairIndex;
};

// url.<base>.insteadOf is multi-valued: each index contributes one rule.
addConfigKV(@"url.https://github.com/.insteadOf", @"git@github.com:");
addConfigKV(@"url.https://github.com/.insteadOf", @"ssh://git@github.com/");

NSString *const userColonToken = [NSString stringWithFormat:@"%@:%@", user, token];
NSString *const basic = [[userColonToken dataUsingEncoding:NSUTF8StringEncoding] base64EncodedStringWithOptions:0];
// Scoped to github.com so the header is never attached to any other host.
addConfigKV(@"http.https://github.com/.extraheader", [NSString stringWithFormat:@"Authorization: Basic %@", basic]);

result[@"GIT_CONFIG_COUNT"] = [NSString stringWithFormat:@"%lu", (unsigned long)nextConfigPairIndex];
return result;
}

- (BOOL)hasMergeConflict {
NSString *stdOutOutput = nil;
const int unmergedFilesStatus = [self runGitCommand:@"ls-files -u"
Expand Down
10 changes: 10 additions & 0 deletions system7/main.m
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ void printHelp(void) {
help_puts(" When provided, forces the merge driver to use a strategy that resolves conflicts in subrepo");
help_puts(" references by favoring the branch specified in the variable, as well as retargeting \"their\"");
help_puts(" added subrepos to the branch in question.");
help_puts("");
help_puts(" S7_GIT_USER / S7_GIT_TOKEN");
help_puts(" Make s7 authenticate github.com subrepo network operations (clone, fetch,");
help_puts(" push) over HTTPS with the given username and Personal Access Token instead");
help_puts(" of SSH. When both variables are set, s7 rewrites SSH github.com subrepo");
help_puts(" URLs to HTTPS and passes the token to git in an HTTP header; the token is");
help_puts(" never written to disk and never appears in process arguments. When either");
help_puts(" variable is missing, s7 leaves git alone (SSH keys as usual).");
help_puts(" GH_USER / GH_TOKEN are honored as a fallback when the S7_* variables are");
help_puts(" not set.");
}

Class commandClassByName(NSString *commandName) {
Expand Down
Loading