import 'dart:io'; import 'package:path/path.dart' as p; /// Operator-managed gRPC bearer token used to authenticate /// Studio against a hub that has `auth.tokens:` configured /// (RBAC Level 2). Stored at `~/.fai/hub-auth-token` (mode /// 0600 on Unix). Sister of [RegistryToken] — same on-disk /// hygiene, different secret. /// /// When the file is present and non-empty Studio constructs /// `HubClient(authToken: ...)`, which attaches /// `Authorization: Bearer ` to every gRPC and /// gRPC-Web call. When the file is missing Studio stays in /// anonymous mode; the hub then either accepts or rejects /// based on its own `auth.tokens:` config. class HubAuthToken { static String _faiHome() { final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; if (home == null || home.isEmpty) { throw StateError( 'Cannot resolve home directory (no HOME / USERPROFILE)', ); } return p.join(home, '.fai'); } /// Absolute path to the token file. static String get path => p.join(_faiHome(), 'hub-auth-token'); /// True iff the file exists with a non-empty trimmed body. static Future isConfigured() async { final f = File(path); if (!await f.exists()) return false; final content = await f.readAsString(); return content.trim().isNotEmpty; } /// Length of the trimmed token, or null when the file is /// missing / empty. Used for status display ("Configured /// (40 chars)") without ever surfacing the secret. static Future charCount() async { final f = File(path); if (!await f.exists()) return null; final content = await f.readAsString(); final trimmed = content.trim(); return trimmed.isEmpty ? null : trimmed.length; } /// Read the trimmed token, or `null` when missing/empty. /// Studio calls this at startup so [HubService] can supply /// it to the underlying gRPC client. static Future read() async { final f = File(path); if (!await f.exists()) return null; final content = (await f.readAsString()).trim(); return content.isEmpty ? null : content; } /// Persist [token] to disk, creating `~/.fai/` if needed. /// /// Atomic write: writes to `.tmp`, chmods 0600 on /// Unix BEFORE moving into place, then renames. This /// closes the TOCTOU window where the historical /// `writeAsString` + later `chmod 600` sequence left the /// file world-readable for the duration of the chmod call. /// /// Also sets `~/.fai/` itself to 0700 on Unix on first /// creation so other users on a shared host can't even /// enumerate the directory (the 0600 token-file mode is /// fine in isolation; combining it with a 0755 parent /// directory leaks the file's existence). static Future write(String token) async { final f = File(path); final tmp = File('${f.path}.tmp'); final parent = f.parent; final parentExisted = await parent.exists(); await parent.create(recursive: true); if ((Platform.isLinux || Platform.isMacOS) && !parentExisted) { try { await Process.run('chmod', ['700', parent.path]); } catch (_) {/* best-effort */} } // Write to the temp path with restrictive mode set up // BEFORE the rename — the final file is never visible // to other processes with the default umask mode. await tmp.writeAsString(token.trim(), flush: true); if (Platform.isLinux || Platform.isMacOS) { try { await Process.run('chmod', ['600', tmp.path]); } catch (_) {/* best-effort */} } // Atomic rename: on POSIX, replaces the destination // in one syscall. On Windows, Dart's File.rename uses // MoveFileEx with MOVEFILE_REPLACE_EXISTING which is // equally atomic for this use case. try { await tmp.rename(f.path); } catch (_) { // On rare cross-mount setups, rename may fail; fall // back to copy + delete. The token is then briefly // visible at the .tmp path with 0600 mode — still // safe, but not atomic. await tmp.copy(f.path); if (Platform.isLinux || Platform.isMacOS) { try { await Process.run('chmod', ['600', f.path]); } catch (_) {/* best-effort */} } await tmp.delete(); } } /// Remove the token file. Returns true iff the file existed /// before deletion. static Future clear() async { final f = File(path); if (!await f.exists()) return false; await f.delete(); return true; } }