Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
106 changes: 106 additions & 0 deletions system7-tests/gitGitHubTokenAuthTests.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//
// 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)testReturnsEmptyWhenUserNil {
XCTAssertEqualObjects(@{}, [GitRepository gitHubTokenConfigEnvironmentForUser:nil token:@"abc" processEnvironment:@{}]);
}

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

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

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

- (void)testBuildsHeaderAuthEntriesFromZero {
NSDictionary<NSString *, NSString *> *const env =
[GitRepository gitHubTokenConfigEnvironmentForUser:@"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 gitHubTokenConfigEnvironmentForUser:@"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 gitHubTokenConfigEnvironmentForUser:@"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)testTokenNeverAppearsRawOnlyInBase64Header {
NSString *const user = @"alice";
NSString *const token = @"ghp_SuperSecret/@:%123"; // chars that would have needed URL-escaping
NSDictionary<NSString *, NSString *> *const env =
[GitRepository gitHubTokenConfigEnvironmentForUser: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 gitHubTokenConfigEnvironmentForUser:@"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;

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

@end

NS_ASSUME_NONNULL_END
113 changes: 113 additions & 0 deletions system7/git/Git.m
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,66 @@ + (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;
}

// Full environment for the spawned git process when GitHub HTTPS token auth
// is enabled (both credentials present); nil when it's off. Computed once: the
// process environment plus GIT_CONFIG_* entries that authenticate github.com
// over HTTPS — insteadOf rewrites SSH URLs to the clean https URL and an
// http.<url>.extraheader carries the PAT as a Basic-auth header (see
// +gitHubTokenConfigEnvironmentForUser:token:processEnvironment:).
// Delivered through the child's environment rather than `-c` argv, so the token
// never appears in the process argument list (`ps`, /proc, crash reporters), and
// — riding in a header, not a URL — nothing git echoes can leak it. `.git/config`
// keeps the original SSH URL, so the token never lands on disk either.
+ (nullable NSDictionary<NSString *, NSString *> *)gitHubTokenAuthTaskEnvironment {
static dispatch_once_t onceToken;
static NSDictionary<NSString *, NSString *> *taskEnvironment;

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.

I'm always scared by uninitialised variable. Even the static ones.

Suggested change
static NSDictionary<NSString *, NSString *> *taskEnvironment;
static NSDictionary<NSString *, NSString *> *taskEnvironment = nil;

dispatch_once(&onceToken, ^{
NSDictionary<NSString *, NSString *> *const env = NSProcessInfo.processInfo.environment;
NSDictionary<NSString *, NSString *> *const configEnvironment =
[self gitHubTokenConfigEnvironmentForUser:[self envGitAuthUser]
token:[self envGitAuthToken]
processEnvironment:env];
if (configEnvironment.count > 0) {
NSMutableDictionary<NSString *, NSString *> *const merged = [env mutableCopy];
[merged addEntriesFromDictionary:configEnvironment];
taskEnvironment = [merged copy];

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.

I would suggest to put all the merging logic into the gitHubTokenConfigEnvironmentForUser itself.
Now the return value of the gitHubTokenConfigEnvironmentForUser is inconsistent: at one hand, it contains just the values we want to add, but the GIT_CONFIG_COUNT is the sum of our custom values and the keys from the original environment. If we try to use gitHubTokenConfigEnvironmentForUser as it's written now in some other place, we'd have to add the same merge there too.

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.

Plus, I would suggest to remove the call for copy.
- taskEnvironment = [merged copy];
+ taskEnvironment = merged;

I don't think we have some unscrupulous people casting NSDictionary to NSMutableDictionary around here.

}
});
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 +392,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 +1534,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.
+ (NSDictionary<NSString *, NSString *> *)gitHubTokenConfigEnvironmentForUser:(nullable NSString *)user
token:(nullable NSString *)token
processEnvironment:(NSDictionary<NSString *, NSString *> *)processEnvironment
{
if (0 == user.length || 0 == token.length) {
return @{};
}

NSMutableDictionary<NSString *, NSString *> *const result = [NSMutableDictionary new];
__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