Skip to content

Commit c0b450e

Browse files
authored
Merge pull request #1040 from No-bodyq/feat/issue-976-logout-token-invalidation
Add logout and token invalidation with Redis blacklist
2 parents da23d38 + c072fbc commit c0b450e

169 files changed

Lines changed: 881 additions & 590 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/src/auth/auth.controller.spec.ts

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ describe('AuthController', () => {
2828

2929
const mockRequest = (ip?: string) => ({ ip }) as unknown as Request;
3030

31+
const validWallet =
32+
'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW';
33+
3134
beforeEach(async () => {
3235
const module: TestingModule = await Test.createTestingModule({
3336
controllers: [AuthController],
@@ -81,11 +84,7 @@ describe('AuthController', () => {
8184

8285
await expect(
8386
controller.login(
84-
{
85-
walletAddress:
86-
'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW',
87-
nonce: 'sig',
88-
},
87+
{ walletAddress: validWallet, nonce: 'sig' },
8988
mockRequest('127.0.0.1'),
9089
),
9190
).rejects.toThrow(
@@ -106,24 +105,14 @@ describe('AuthController', () => {
106105
});
107106

108107
const result = await controller.login(
109-
{
110-
walletAddress:
111-
'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW',
112-
nonce: 'sig',
113-
},
108+
{ walletAddress: validWallet, nonce: 'sig' },
114109
mockRequest('127.0.0.1'),
115110
);
116111

117-
expect(mockAuthService.login).toHaveBeenCalledWith(
118-
'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW',
119-
'sig',
120-
);
112+
expect(mockAuthService.login).toHaveBeenCalledWith(validWallet, 'sig');
121113
expect(
122114
mockSuspiciousLoginService.recordSuccessfulLogin,
123-
).toHaveBeenCalledWith(
124-
'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW',
125-
'127.0.0.1',
126-
);
115+
).toHaveBeenCalledWith(validWallet, '127.0.0.1');
127116
expect(result).toEqual({
128117
accessToken: 'access',
129118
refreshToken: 'refresh',
@@ -133,10 +122,14 @@ describe('AuthController', () => {
133122
});
134123

135124
describe('logout', () => {
136-
it('should revoke the current access token and confirm', () => {
137-
const result = controller.logout({ user: { jti: 'jti-1' } });
125+
it('should revoke the current access token and confirm', async () => {
126+
mockAuthService.logout.mockResolvedValue(undefined);
127+
128+
const result = await controller.logout({
129+
user: { jti: 'jti-1', exp: 1234 },
130+
});
138131

139-
expect(mockAuthService.logout).toHaveBeenCalledWith('jti-1');
132+
expect(mockAuthService.logout).toHaveBeenCalledWith('jti-1', 1234);
140133
expect(result).toEqual({ message: 'Logged out successfully' });
141134
});
142135
});

backend/src/auth/auth.controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,8 @@ export class AuthController {
153153
})
154154
@ApiResponse({ status: 200, description: 'Logged out successfully' })
155155
@ApiResponse({ status: 401, description: 'Authentication required' })
156-
logout(@Request() req: { user?: { jti?: string } }) {
157-
this.authService.logout(req.user?.jti || '');
156+
async logout(@Request() req: { user?: { jti?: string; exp?: number } }) {
157+
await this.authService.logout(req.user?.jti || '', req.user?.exp);
158158
return { message: 'Logged out successfully' };
159159
}
160160

backend/src/auth/auth.module.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { JwtStrategy } from './strategies/jwt.strategy.js';
77
import { WalletStrategy } from './strategies/wallet.strategy.js';
88
import { JwtAuthGuard } from './guards/jwt-auth.guard.js';
99
import { RolesGuard } from './guards/roles.guard.js';
10+
import { TokenBlacklistService } from './services/token-blacklist.service.js';
1011
import { jwtModuleConfig } from '../config/jwt.config.js';
1112
import { UsersModule } from '../users/users.module.js';
1213

@@ -29,7 +30,14 @@ import { UsersModule } from '../users/users.module.js';
2930
WalletStrategy,
3031
JwtAuthGuard,
3132
RolesGuard,
33+
TokenBlacklistService,
34+
],
35+
exports: [
36+
AuthService,
37+
JwtAuthGuard,
38+
RolesGuard,
39+
JwtModule,
40+
TokenBlacklistService,
3241
],
33-
exports: [AuthService, JwtAuthGuard, RolesGuard, JwtModule],
3442
})
3543
export class AuthModule {}

backend/src/auth/auth.service.spec.ts

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
44
import { UnauthorizedException } from '@nestjs/common';
55
import { AuthService } from './auth.service.js';
66
import { WalletStrategy } from './strategies/wallet.strategy.js';
7+
import { TokenBlacklistService } from './services/token-blacklist.service.js';
78
import { v4 as uuidv4 } from 'uuid';
89

910
jest.mock('uuid', () => {
@@ -28,13 +29,19 @@ describe('AuthService', () => {
2829
verifyAsync: jest.fn(),
2930
};
3031

32+
const mockTokenBlacklistService = {
33+
blacklist: jest.fn(),
34+
isBlacklisted: jest.fn(),
35+
};
36+
3137
beforeEach(async () => {
3238
const module: TestingModule = await Test.createTestingModule({
3339
providers: [
3440
AuthService,
3541
{ provide: WalletStrategy, useValue: mockWalletStrategy },
3642
{ provide: ConfigService, useValue: mockConfigService },
3743
{ provide: JwtService, useValue: mockJwtService },
44+
{ provide: TokenBlacklistService, useValue: mockTokenBlacklistService },
3845
],
3946
}).compile();
4047

@@ -58,17 +65,6 @@ describe('AuthService', () => {
5865
});
5966
});
6067

61-
describe('logout', () => {
62-
it('should not throw when called with a jti', () => {
63-
expect(() => service.logout('some-jti')).not.toThrow();
64-
});
65-
66-
it('isTokenRevoked should return true after logout', () => {
67-
service.logout('jti-abc');
68-
expect(service.isTokenRevoked('jti-abc')).toBe(true);
69-
});
70-
});
71-
7268
describe('login', () => {
7369
it('should issue an access and refresh token pair for a valid signature', async () => {
7470
mockWalletStrategy.generateNonce.mockReturnValue({
@@ -157,4 +153,63 @@ describe('AuthService', () => {
157153
);
158154
});
159155
});
156+
157+
describe('logout', () => {
158+
it('should blacklist the access token jti for its remaining lifetime', async () => {
159+
const nowSeconds = Math.floor(Date.now() / 1000);
160+
161+
await service.logout('access-jti', nowSeconds + 120);
162+
163+
expect(mockTokenBlacklistService.blacklist).toHaveBeenCalledWith(
164+
'access-jti',
165+
expect.any(Number),
166+
);
167+
const [, ttl] = mockTokenBlacklistService.blacklist.mock.calls[0] as [
168+
string,
169+
number,
170+
];
171+
expect(ttl).toBeGreaterThan(0);
172+
expect(ttl).toBeLessThanOrEqual(120);
173+
});
174+
175+
it('should fall back to the configured access TTL when no expiry is given', async () => {
176+
await service.logout('access-jti');
177+
178+
expect(mockTokenBlacklistService.blacklist).toHaveBeenCalledWith(
179+
'access-jti',
180+
900,
181+
);
182+
});
183+
184+
it('isTokenRevoked should return true after logout', async () => {
185+
await service.logout('jti-abc');
186+
expect(service.isTokenRevoked('jti-abc')).toBe(true);
187+
});
188+
189+
it('should mark a stored refresh token as revoked', async () => {
190+
mockWalletStrategy.generateNonce.mockReturnValue({
191+
nonce: 'nonce-1',
192+
expiresAt: Date.now() + 60_000,
193+
});
194+
service.requestNonce('test-wallet');
195+
mockWalletStrategy.verify.mockReturnValue(true);
196+
mockJwtService.signAsync
197+
.mockResolvedValueOnce('access-token')
198+
.mockResolvedValueOnce('refresh-token');
199+
await service.login('test-wallet', 'nonce-1');
200+
const issuedRefreshJti = (uuidv4 as jest.Mock).mock.results.at(-1)
201+
?.value as string;
202+
203+
await service.logout('access-jti', undefined, issuedRefreshJti);
204+
205+
mockJwtService.verifyAsync.mockResolvedValue({
206+
sub: 'test-wallet',
207+
jti: issuedRefreshJti,
208+
type: 'refresh',
209+
});
210+
await expect(service.refresh('refresh-token')).rejects.toThrow(
211+
'Refresh token has been revoked',
212+
);
213+
});
214+
});
160215
});

backend/src/auth/auth.service.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
44
import { v4 as uuidv4 } from 'uuid';
55
import { JwtAccessTokenPayload } from './interfaces/jwt-payload.interface.js';
66
import { WalletStrategy } from './strategies/wallet.strategy.js';
7+
import { TokenBlacklistService } from './services/token-blacklist.service.js';
78
import { UsersService } from '../users/users.service.js';
89
import { AuthRole } from '../common/enums/auth-role.enum.js';
910
import { ROLE_PERMISSIONS } from '../common/constants/role-permissions.constant.js';
@@ -44,6 +45,7 @@ export class AuthService {
4445
private readonly jwtService: JwtService,
4546
private readonly configService: ConfigService,
4647
private readonly walletStrategy: WalletStrategy,
48+
private readonly tokenBlacklistService: TokenBlacklistService,
4749
private readonly usersService: UsersService,
4850
) {}
4951

@@ -196,10 +198,18 @@ export class AuthService {
196198

197199
/**
198200
* #976: Logout — revoke both access and refresh tokens.
201+
*
202+
* The access token jti is blacklisted in Redis (rather than only the
203+
* in-process Set) so revocation survives restarts and applies across
204+
* every server instance.
199205
*/
200-
logout(accessTokenJti: string, refreshJti?: string): void {
206+
async logout(accessTokenJti: string, accessTokenExp?: number, refreshJti?: string): Promise<void> {
201207
if (accessTokenJti) {
202208
this.revokedAccessTokens.add(accessTokenJti);
209+
const ttlSeconds = accessTokenExp
210+
? accessTokenExp - Math.floor(Date.now() / 1000)
211+
: parseInt(this.configService.get('JWT_ACCESS_TTL', '900'), 10);
212+
await this.tokenBlacklistService.blacklist(accessTokenJti, ttlSeconds);
203213
}
204214
if (refreshJti) {
205215
const record = this.refreshTokens.get(refreshJti);

backend/src/auth/guards/jwt-auth.guard.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ConfigService } from '@nestjs/config';
88
import { JwtService } from '@nestjs/jwt';
99
import { Request } from 'express';
1010
import { JwtAccessTokenPayload } from '../interfaces/jwt-payload.interface.js';
11+
import { TokenBlacklistService } from '../services/token-blacklist.service.js';
1112
import { UserStatus } from '../../users/enums/user-status.enum.js';
1213

1314
/**
@@ -27,6 +28,7 @@ export class JwtAuthGuard implements CanActivate {
2728
constructor(
2829
private readonly jwtService: JwtService,
2930
private readonly configService: ConfigService,
31+
private readonly tokenBlacklistService: TokenBlacklistService,
3032
) {}
3133

3234
async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -48,6 +50,19 @@ export class JwtAuthGuard implements CanActivate {
4850
},
4951
);
5052

53+
// #981: Check Redis blacklist for revoked tokens
54+
if (payload.jti) {
55+
const isRevoked = await this.tokenBlacklistService.isBlacklisted(
56+
payload.jti,
57+
);
58+
if (isRevoked) {
59+
throw new UnauthorizedException({
60+
message: 'Token has been revoked',
61+
code: 'token_revoked',
62+
});
63+
}
64+
}
65+
5166
if (payload.status && payload.status !== UserStatus.ACTIVE) {
5267
throw new UnauthorizedException({
5368
message: 'Account is not active',
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { TokenBlacklistService } from './token-blacklist.service.js';
3+
import { RedisService } from '../../config/redis.module.js';
4+
5+
describe('TokenBlacklistService', () => {
6+
let service: TokenBlacklistService;
7+
8+
const mockRedisService = {
9+
set: jest.fn(),
10+
exists: jest.fn(),
11+
};
12+
13+
beforeEach(async () => {
14+
const module: TestingModule = await Test.createTestingModule({
15+
providers: [
16+
TokenBlacklistService,
17+
{ provide: RedisService, useValue: mockRedisService },
18+
],
19+
}).compile();
20+
21+
service = module.get<TokenBlacklistService>(TokenBlacklistService);
22+
});
23+
24+
afterEach(() => {
25+
jest.clearAllMocks();
26+
});
27+
28+
describe('blacklist', () => {
29+
it('should store the jti with the given TTL', async () => {
30+
await service.blacklist('jti-1', 900);
31+
expect(mockRedisService.set).toHaveBeenCalledWith('blacklist:jti-1', '1', 900);
32+
});
33+
34+
it('should not store anything for a non-positive TTL', async () => {
35+
await service.blacklist('jti-1', 0);
36+
expect(mockRedisService.set).not.toHaveBeenCalled();
37+
});
38+
39+
it('should not store anything for an empty jti', async () => {
40+
await service.blacklist('', 900);
41+
expect(mockRedisService.set).not.toHaveBeenCalled();
42+
});
43+
});
44+
45+
describe('isBlacklisted', () => {
46+
it('should return true when the key exists', async () => {
47+
mockRedisService.exists.mockResolvedValue(true);
48+
const result = await service.isBlacklisted('jti-1');
49+
expect(result).toBe(true);
50+
expect(mockRedisService.exists).toHaveBeenCalledWith('blacklist:jti-1');
51+
});
52+
53+
it('should return false when the key does not exist', async () => {
54+
mockRedisService.exists.mockResolvedValue(false);
55+
const result = await service.isBlacklisted('jti-1');
56+
expect(result).toBe(false);
57+
});
58+
59+
it('should return false for an empty jti without querying Redis', async () => {
60+
const result = await service.isBlacklisted('');
61+
expect(result).toBe(false);
62+
expect(mockRedisService.exists).not.toHaveBeenCalled();
63+
});
64+
});
65+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { RedisService } from '../../config/redis.module.js';
3+
4+
/**
5+
* #976: Redis-backed access token blacklist.
6+
*
7+
* Persists revoked jti's beyond process memory so logout invalidation
8+
* survives restarts and works across multiple server instances.
9+
*/
10+
@Injectable()
11+
export class TokenBlacklistService {
12+
constructor(private readonly redisService: RedisService) {}
13+
14+
async blacklist(jti: string, ttlSeconds: number): Promise<void> {
15+
if (!jti || ttlSeconds <= 0) return;
16+
await this.redisService.set(`blacklist:${jti}`, '1', ttlSeconds);
17+
}
18+
19+
async isBlacklisted(jti: string): Promise<boolean> {
20+
if (!jti) return false;
21+
return this.redisService.exists(`blacklist:${jti}`);
22+
}
23+
}

backend/src/users/users.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { MenteeProfile } from './entities/mentee-profile.entity.js';
1515
import { PortfolioLink } from './entities/portfolio-link.entity.js';
1616
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard.js';
1717
import { RolesGuard } from '../auth/guards/roles.guard.js';
18+
import { TokenBlacklistService } from '../auth/services/token-blacklist.service.js';
1819
import { jwtModuleConfig } from '../config/jwt.config.js';
1920
import { StorageModule } from '../storage/storage.module.js';
2021
import { AvailabilityModule } from '../availability/availability.module.js';
@@ -44,6 +45,7 @@ import { ProfileCompletenessService } from './profile-completeness.service.js';
4445
UsersService,
4546
JwtAuthGuard,
4647
RolesGuard,
48+
TokenBlacklistService,
4749
AvatarService,
4850
ProfileCompletenessService,
4951
],

contract/target/.rustc_info.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"rustc_fingerprint":678832330695229064,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\thefo\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_has_atomic_primitive_alignment=\"128\"\ntarget_has_atomic_primitive_alignment=\"16\"\ntarget_has_atomic_primitive_alignment=\"32\"\ntarget_has_atomic_primitive_alignment=\"64\"\ntarget_has_atomic_primitive_alignment=\"8\"\ntarget_has_atomic_primitive_alignment=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"12004014463585500860":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\thefo\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_has_atomic_primitive_alignment=\"128\"\ntarget_has_atomic_primitive_alignment=\"16\"\ntarget_has_atomic_primitive_alignment=\"32\"\ntarget_has_atomic_primitive_alignment=\"64\"\ntarget_has_atomic_primitive_alignment=\"8\"\ntarget_has_atomic_primitive_alignment=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"12522964844413219576":{"success":true,"status":"","code":0,"stdout":"rustc 1.97.1 (8bab26f4f 2026-07-14)\nbinary: rustc\ncommit-hash: 8bab26f4f68e0e26f0bb7960be334d5b520ea452\ncommit-date: 2026-07-14\nhost: x86_64-pc-windows-msvc\nrelease: 1.97.1\nLLVM version: 22.1.6\n","stderr":""}},"successes":{}}
1+
{"rustc_fingerprint":678832330695229064,"outputs":{"12522964844413219576":{"success":true,"status":"","code":0,"stdout":"rustc 1.97.1 (8bab26f4f 2026-07-14)\nbinary: rustc\ncommit-hash: 8bab26f4f68e0e26f0bb7960be334d5b520ea452\ncommit-date: 2026-07-14\nhost: x86_64-pc-windows-msvc\nrelease: 1.97.1\nLLVM version: 22.1.6\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\thefo\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_has_atomic_primitive_alignment=\"128\"\ntarget_has_atomic_primitive_alignment=\"16\"\ntarget_has_atomic_primitive_alignment=\"32\"\ntarget_has_atomic_primitive_alignment=\"64\"\ntarget_has_atomic_primitive_alignment=\"8\"\ntarget_has_atomic_primitive_alignment=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"12004014463585500860":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\thefo\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_has_atomic_primitive_alignment=\"128\"\ntarget_has_atomic_primitive_alignment=\"16\"\ntarget_has_atomic_primitive_alignment=\"32\"\ntarget_has_atomic_primitive_alignment=\"64\"\ntarget_has_atomic_primitive_alignment=\"8\"\ntarget_has_atomic_primitive_alignment=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""}},"successes":{}}

0 commit comments

Comments
 (0)