diff --git a/changelog/publish-command.dd b/changelog/publish-command.dd new file mode 100644 index 000000000..1938f673b --- /dev/null +++ b/changelog/publish-command.dd @@ -0,0 +1,44 @@ +Added `dub publish` command. + +Registers a package repository with the DUB registry from the command line +(default action), checks status, triggers a metadata refresh, or manages owner +settings (logo, docs URL, categories, webhooks, permissions, remove). + +Previously registration and owner settings required the website after logging +in. `dub publish` automates those flows using session login (`POST /login`) +and the corresponding My packages endpoints. + +Actions: `register`/`publish`, `status`, `update`, `login`, `logout`, +`remove` (requires `--yes`), `logo`, `logo-delete`, `docs-url`, `categories`, +`hooks`, `hooks-disable`, `repo`, `perms-add`, `leave`. + +Registration is idempotent when the repository is already registered (then +refreshes metadata). Ingest polling uses `/api/packages/:name/info` so packages +with no versions yet still count as present. + +------- +# credentials via environment (preferred in CI) +export DUB_REGISTRY_USER=myname +export DUB_REGISTRY_PASSWORD=secret + +# or agent-friendly drop file under the DUB settings directory: +# ~/.dub/password.incoming or %APPDATA%\dub\password.incoming +dub publish login --user myname --save-credentials + +# register the current package's git origin remote +dub publish + +# check whether a package exists (works with zero versions) +dub publish status -n vibe-d + +# queue a metadata refresh (after pushing a SemVer tag) +dub publish update -n mypkg --secret YOUR_PACKAGE_SECRET + +# regenerate webhook secret + clean URLs (avoids dub-registry #614) +dub publish hooks -n mypkg +------- + +Use `--annotate` for a dry run. Credentials are stored as `credentials.v1` +(DPAPI on Windows; Base64 + mode 0600 elsewhere). Prefer `password.incoming` or +`--password-file` over `-p` (visible in shell history / process lists). `logout` +clears the store and any leftover drop file. diff --git a/source/dub/commandline.d b/source/dub/commandline.d index a7c8caa44..420d09d5d 100644 --- a/source/dub/commandline.d +++ b/source/dub/commandline.d @@ -12,6 +12,7 @@ import dub.dependency; import dub.dub; import dub.generators.generator; import dub.internal.logging; +import dub.internal.io.realfs : RealFS; import dub.internal.utils : getClosestMatch, getDUBVersion, getTempFile; import dub.internal.vibecompat.core.file; import dub.internal.vibecompat.data.json; @@ -20,6 +21,8 @@ import dub.package_; import dub.packagemanager; import dub.packagesuppliers; import dub.project; +import dub.registry_auth; +import dub.registry_secrets; import dub.internal.dyaml.stdsumtype; @@ -68,6 +71,7 @@ CommandGroup[] getCommands() @safe pure nothrow new RemoveLocalCommand, new ListCommand, new SearchCommand, + new PublishCommand, new AddOverrideCommand, new RemoveOverrideCommand, new ListOverridesCommand, @@ -2957,6 +2961,625 @@ class DustmiteCommand : PackageBuildCommand { /******************************************************************************/ /* CONVERT command */ +/******************************************************************************/ +/* PUBLISH */ +/******************************************************************************/ + +class PublishCommand : Command { + private { + CommonOptions m_options; + string m_user; + string m_password; + string m_passwordFile; + string m_url; + string m_packageName; + string m_secret; + string m_logoPath; + string m_docsUrl; + string m_hooksOut; + string m_secretOut; + string m_repoKind; + string m_repoOwner; + string m_repoProject; + string m_sharedUsername; + string[] m_categories; + string[] m_permFlags; + bool m_ignoreFork; + bool m_saveCredentials; + bool m_promptPassword; + bool m_yes; + } + + this() @safe pure nothrow + { + this.name = "publish"; + this.argumentsPattern = "[register|status|update|login|logout|remove|logo|logo-delete|docs-url|categories|hooks|hooks-disable|repo|perms-add|leave]"; + this.description = "Register a package with the DUB registry or manage owner settings"; + this.helpText = [ + "Registers the current package's Git repository with a DUB registry (default: code.dlang.org),", + "checks registration status, triggers a metadata refresh, or manages owner settings", + "(logo, docs URL, categories, webhooks, permissions, remove).", + "", + "The default action is `register`, which logs into the registry and submits the repository URL", + "(from `git remote origin`, or `--url`). New versions appear when SemVer tags are pushed;", + "the registry polls them periodically. Use `update` to queue an immediate refresh.", + "", + "Credentials: prefer writing the password to `password.incoming` under the DUB settings", + "directory, then `dub publish login --user NAME --save-credentials` (stores DPAPI on", + "Windows / mode 0600 elsewhere and deletes the drop file). Or use `--password-file`,", + "environment variables, or `--prompt-password`. `--password` / `-p` works but is", + "visible in shell history and process lists.", + "", + "Use `--annotate` to print what would happen without contacting the registry." + ]; + } + + override void prepare(scope CommandArgs args) + { + args.getopt("user|u", &m_user, ["Registry username or email (or DUB_REGISTRY_USER)"]); + args.getopt("password|p", &m_password, [ + "Registry password (or DUB_REGISTRY_PASSWORD). Visible in shell history / process list" + ]); + args.getopt("password-file", &m_passwordFile, [ + "Read password from file (first line); preferred for scripts/agents" + ]); + args.getopt("prompt-password", &m_promptPassword, [ + "Interactively prompt for password (TTY, no echo)" + ]); + args.getopt("url", &m_url, ["Repository URL (default: git remote origin)"]); + args.getopt("package|n", &m_packageName, ["Package name (default: from the local recipe)"]); + args.getopt("secret", &m_secret, ["Package update webhook secret (for unauthenticated update)"]); + args.getopt("logo-file", &m_logoPath, ["Path to logo image (png/jpeg/gif/bmp, max 1 MiB)"]); + args.getopt("docs-url", &m_docsUrl, ["Documentation URL (http/https)"]); + args.getopt("category", &m_categories, ["Category id (repeatable, max 4)"]); + args.getopt("hooks-out", &m_hooksOut, ["Write webhook URLs to this file"]); + args.getopt("secret-out", &m_secretOut, [ + "Write webhook secret to this file (default: /hooks/.secret)" + ]); + args.getopt("kind", &m_repoKind, ["Repository kind (github|gitlab|bitbucket|gitea|forgejo)"]); + args.getopt("owner", &m_repoOwner, ["Repository owner"]); + args.getopt("project", &m_repoProject, ["Repository project/name"]); + args.getopt("username", &m_sharedUsername, ["code.dlang.org username for perms-add"]); + args.getopt("perm", &m_permFlags, [ + "Permission flag for perms-add: update|metadata|source|admin (repeatable)" + ]); + args.getopt("ignore-fork", &m_ignoreFork, ["Register even if the repository is a fork"]); + args.getopt("save-credentials", &m_saveCredentials, [ + "Store username/password locally (DPAPI on Windows; mode 0600 elsewhere); consumes password.incoming" + ]); + args.getopt("yes|y", &m_yes, ["Confirm destructive actions (remove)"]); + } + + override Dub prepareDub(CommonOptions options) + { + m_options = options; + return super.prepareDub(options); + } + + override int execute(Dub dub, string[] free_args, string[] app_args) + { + enforceUsage(app_args.length == 0, "Unexpected application arguments."); + enforceUsage(free_args.length <= 1, "Unexpected arguments: " ~ free_args.join(" ")); + + enum knownActions = [ + "register", "publish", "status", "update", "login", "logout", + "remove", "logo", "logo-delete", "docs-url", "categories", + "hooks", "hooks-disable", "repo", "perms-add", "leave" + ]; + + string action = free_args.length ? free_args[0] : "register"; + if (action == "publish") + action = "register"; + enforceUsage(knownActions.canFind(action), + "Unknown publish action '" ~ action ~ "'. Expected register, status, update, login, " + ~ "logout, remove, logo, logo-delete, docs-url, categories, hooks, hooks-disable, " + ~ "repo, perms-add, or leave."); + + bool passwordFromExplicit; + if (m_passwordFile.length) + { + enforceUsage(!m_password.length, "Do not combine --password with --password-file"); + m_password = readPasswordFile(m_passwordFile); + passwordFromExplicit = true; + } + else if (m_password.length) + passwordFromExplicit = true; + + string registryOverride; + if (m_options.registry_urls.length) + registryOverride = m_options.registry_urls[0]; + + auto cfg = loadRegistryAuthConfig(registryOverride, m_user, m_password); + + if (action == "logout") + { + if (dub.dryRun) + { + logInfo("Would clear stored credentials under %s", + registryUserSettingsDir().toNativeString()); + return 0; + } + if (clearRegistryCredentials()) + logInfo("Cleared stored credentials under %s", + registryUserSettingsDir().toNativeString()); + else + logInfo("No stored credentials found under %s", + registryUserSettingsDir().toNativeString()); + return 0; + } + + // Default drop file: consumed with --save-credentials when no CLI/env password. + // Prefer drop over an already-stored credential so agents can rotate. + bool fromEnv = !passwordFromExplicit + && environment.get("DUB_REGISTRY_PASSWORD", "").length > 0; + if (m_saveCredentials && !passwordFromExplicit && !fromEnv + && std.file.exists(passwordDropPath())) + { + cfg.password = readPasswordFile(passwordDropPath()); + } + + if (!cfg.password.length && m_promptPassword) + { + cfg.password = promptPassword("DUB registry password: "); + enforceUsage(cfg.password.length > 0, "Empty password"); + } + + if (m_saveCredentials || action == "login") + { + enforceUsage(cfg.user.length > 0, "Pass --user (or DUB_REGISTRY_USER)"); + if (!cfg.password.length) + { + logError("Password required — write it to:"); + logError(" %s", passwordDropPath()); + logError(" then re-run with --save-credentials"); + logError(" (or use --password-file / -p / env / --prompt-password)"); + return 2; + } + } + + if (m_saveCredentials) + { + auto saveRc = persistCredentialsVerified(cfg, dub.dryRun); + if (saveRc != 0) + return saveRc; + if (action == "login") + return 0; + } + + switch (action) + { + case "login": + return publishLogin(cfg, dub.dryRun); + case "register": + return publishRegister(dub, cfg); + case "update": + return publishUpdate(dub, cfg); + case "status": + return publishStatus(dub, cfg); + case "remove": + return publishRemove(dub, cfg); + case "logo": + return publishLogo(dub, cfg); + case "logo-delete": + return publishLogoDelete(dub, cfg); + case "docs-url": + return publishDocsUrl(dub, cfg); + case "categories": + return publishCategories(dub, cfg); + case "hooks": + return publishHooks(dub, cfg); + case "hooks-disable": + return publishHooksDisable(dub, cfg); + case "repo": + return publishRepo(dub, cfg); + case "perms-add": + return publishPermsAdd(dub, cfg); + case "leave": + return publishLeave(dub, cfg); + default: + enforceUsage(false, "Unknown publish action '" ~ action ~ "'"); + return 2; + } + } + + /// Verify registry login, then write the protected store and delete password.incoming. + private int persistCredentialsVerified(RegistryAuthConfig cfg, bool dryRun) + { + if (dryRun) + { + logInfo("Would verify login as %s on %s", cfg.user, cfg.registryUrl); + logInfo("Would save credentials under %s", registryUserSettingsDir().toNativeString()); + if (std.file.exists(passwordDropPath())) + logInfo("Would remove password drop file %s", passwordDropPath()); + return 0; + } + try + { + auto client = new RegistryAuthClient(cfg); + client.login(); + } + catch (Exception e) + { + logError("%s", e.msg); + logError("Not saving credentials; password drop file left in place if present."); + return 1; + } + ensureUserSettingsDir(); + saveRegistryCredentials(cfg.user, cfg.password); + version (Windows) + logInfo("Saved credentials (Windows DPAPI) under %s", + registryUserSettingsDir().toNativeString()); + else + logInfo("Saved credentials (mode 0600) under %s", + registryUserSettingsDir().toNativeString()); + if (clearPasswordDrop()) + logInfo("Removed password drop file %s", passwordDropPath()); + logInfo("Logged in to %s as %s", cfg.registryUrl, cfg.user); + return 0; + } + + private int publishLogin(RegistryAuthConfig cfg, bool dryRun) + { + if (dryRun) + { + logInfo("Would log in to %s as %s", cfg.registryUrl, cfg.user); + return 0; + } + auto client = new RegistryAuthClient(cfg); + client.login(); + logInfo("Logged in to %s as %s", cfg.registryUrl, cfg.user); + return 0; + } + + private int publishRegister(Dub dub, RegistryAuthConfig cfg) + { + auto url = m_url; + if (!url.length) + url = detectGitRemoteUrl("origin", dub.rootPath.toNativeString()); + enforce(url.length, + "No repository URL — pass --url or run inside a Git repo with an origin remote"); + + string name = m_packageName; + if (!name.length && loadCwdPackage(dub, false)) + name = dub.projectName; + + logInfo("Registry: %s", cfg.registryUrl); + logInfo("Repository: %s", url); + if (name.length) + logInfo("Package: %s", name); + + if (dub.dryRun) + { + logInfo("Dry run — not submitting (--annotate)."); + return 0; + } + + auto client = new RegistryAuthClient(cfg); + client.login(); + + bool already = false; + try + client.registerPackage(url, m_ignoreFork); + catch (AlreadyRegisteredException e) + { + already = true; + logInfo("Already registered: %s", e.msg); + } + + if (name.length) + { + import core.thread : Thread; + import core.time : seconds; + if (!already) + Thread.sleep(5.seconds); + foreach (attempt; 0 .. 12) + { + if (client.packageExists(name)) + { + auto upd = client.triggerUpdate(name); + logInfo(already ? "Refreshing existing package." : "Registered."); + logInfo("Update queued (HTTP %s)", upd.status); + auto ver = client.latestVersion(name); + logInfo("Latest: %s", ver.length ? ver : "(pending)"); + logInfo("%s/packages/%s", cfg.registryUrl, name); + return upd.ok ? 0 : 1; + } + if (attempt < 11) + { + logInfo("Waiting for registry to ingest package… (%s/12)", attempt + 1); + Thread.sleep(10.seconds); + } + } + logInfo(already + ? "Already registered, but package name was not found — check the root recipe name." + : "Submitted, but package never appeared. Check My packages and recipe name at repo root."); + logInfo("%s/packages/%s", cfg.registryUrl, name); + logInfo("%s/my_packages", cfg.registryUrl); + return 1; + } + + logInfo(already + ? "Already registered. Check " ~ cfg.registryUrl ~ "/my_packages" + : "Submitted. Check " ~ cfg.registryUrl ~ "/my_packages"); + return 0; + } + + private int publishUpdate(Dub dub, RegistryAuthConfig cfg) + { + auto name = resolvePackageName(dub); + if (dub.dryRun) + { + logInfo("Would trigger update for %s on %s", name, cfg.registryUrl); + return 0; + } + + auto client = new RegistryAuthClient(cfg); + RegistryAuthResult res; + if (m_secret.length) + res = client.triggerUpdateWithSecret(name, m_secret); + else + { + client.login(); + res = client.triggerUpdate(name); + } + logInfo("Update queued for %s (HTTP %s)", name, res.status); + return res.ok ? 0 : 1; + } + + private int publishStatus(Dub dub, RegistryAuthConfig cfg) + { + auto name = resolvePackageName(dub); + if (dub.dryRun) + { + logInfo("Would check status of %s on %s", name, cfg.registryUrl); + return 0; + } + + auto client = new RegistryAuthClient(cfg); + if (!client.packageExists(name)) + { + logError("%s: not found on %s", name, cfg.registryUrl); + return 2; + } + auto ver = client.latestVersion(name); + logInfo("%s: %s", name, ver.length ? ver : "(registered, no versions yet)"); + logInfo("%s/packages/%s", cfg.registryUrl, name); + return 0; + } + + private int publishRemove(Dub dub, RegistryAuthConfig cfg) + { + auto name = resolvePackageName(dub); + if (!m_yes && !dub.dryRun) + { + logError("Refusing to remove '%s' without --yes", name); + return 2; + } + if (dub.dryRun) + { + logInfo("Would remove package %s from %s", name, cfg.registryUrl); + return 0; + } + auto client = new RegistryAuthClient(cfg); + client.login(); + auto res = client.removePackage(name); + logInfo("Removed %s (HTTP %s)", name, res.status); + return res.ok ? 0 : 1; + } + + private int publishLogo(Dub dub, RegistryAuthConfig cfg) + { + auto name = resolvePackageName(dub); + enforceUsage(m_logoPath.length > 0, "--logo-file PATH is required"); + if (dub.dryRun) + { + logInfo("Would upload logo %s for %s", m_logoPath, name); + return 0; + } + auto client = new RegistryAuthClient(cfg); + client.login(); + auto res = client.setLogo(name, m_logoPath); + logInfo("Logo uploaded for %s (HTTP %s)", name, res.status); + return res.ok ? 0 : 1; + } + + private int publishLogoDelete(Dub dub, RegistryAuthConfig cfg) + { + auto name = resolvePackageName(dub); + if (dub.dryRun) + { + logInfo("Would delete logo for %s", name); + return 0; + } + auto client = new RegistryAuthClient(cfg); + client.login(); + auto res = client.deleteLogo(name); + logInfo("Logo reset for %s (HTTP %s)", name, res.status); + return res.ok ? 0 : 1; + } + + private int publishDocsUrl(Dub dub, RegistryAuthConfig cfg) + { + auto name = resolvePackageName(dub); + // getopt leaves null when unset; empty string when --docs-url= (clears). + enforceUsage(m_docsUrl !is null, "--docs-url URL is required (use empty string to clear via \"\")"); + if (dub.dryRun) + { + logInfo("Would set documentation URL for %s to %s", name, m_docsUrl); + return 0; + } + auto client = new RegistryAuthClient(cfg); + client.login(); + auto res = client.setDocumentationUrl(name, m_docsUrl); + logInfo("Documentation URL updated for %s (HTTP %s)", name, res.status); + return res.ok ? 0 : 1; + } + + private int publishCategories(Dub dub, RegistryAuthConfig cfg) + { + auto name = resolvePackageName(dub); + enforceUsage(m_categories.length > 0, "Pass one or more --category ID (max 4)"); + enforceUsage(m_categories.length <= 4, "At most 4 categories"); + if (dub.dryRun) + { + logInfo("Would set categories for %s: %s", name, m_categories.join(", ")); + return 0; + } + auto client = new RegistryAuthClient(cfg); + client.login(); + auto res = client.setCategories(name, m_categories); + logInfo("Categories updated for %s (HTTP %s)", name, res.status); + return res.ok ? 0 : 1; + } + + private int publishHooks(Dub dub, RegistryAuthConfig cfg) + { + auto name = resolvePackageName(dub); + if (dub.dryRun) + { + logInfo("Would enable/regenerate webhooks for %s", name); + return 0; + } + auto client = new RegistryAuthClient(cfg); + client.login(); + auto secret = client.regenSecret(name); + auto hooks = buildWebhookUrls(cfg.registryUrl, name, secret); + + logInfo("Webhook secret (shown once):"); + logInfo("%s", secret); + logInfo("Generic POST: %s", hooks.generic); + logInfo("GitHub webhook: %s", hooks.github); + logInfo("GitLab webhook: %s", hooks.gitlab); + logInfo("(GitLab: set X-Gitlab-Token to the secret in the GitLab webhook UI)"); + + auto home = registryUserSettingsDir(); + string secretOut = m_secretOut; + if (!secretOut.length) + secretOut = (home ~ "hooks" ~ (name ~ ".secret")).toNativeString(); + auto secretDir = NativePath(secretOut).parentPath; + if (!secretDir.empty) + std.file.mkdirRecurse(secretDir.toNativeString()); + std.file.write(secretOut, secret ~ "\n"); + logInfo("Saved secret to %s", secretOut); + + string hooksOut = m_hooksOut; + if (!hooksOut.length) + hooksOut = (home ~ "hooks" ~ (name ~ ".hooks.txt")).toNativeString(); + auto hooksDir = NativePath(hooksOut).parentPath; + if (!hooksDir.empty) + std.file.mkdirRecurse(hooksDir.toNativeString()); + auto text = "generic=" ~ hooks.generic ~ "\n" + ~ "github=" ~ hooks.github ~ "\n" + ~ "gitlab=" ~ hooks.gitlab ~ "\n" + ~ "secret_file=" ~ secretOut ~ "\n"; + std.file.write(hooksOut, text); + logInfo("Saved webhook URLs to %s", hooksOut); + return 0; + } + + private int publishHooksDisable(Dub dub, RegistryAuthConfig cfg) + { + auto name = resolvePackageName(dub); + if (dub.dryRun) + { + logInfo("Would disable webhooks for %s", name); + return 0; + } + auto client = new RegistryAuthClient(cfg); + client.login(); + auto res = client.unsetSecret(name); + logInfo("Webhooks disabled for %s (HTTP %s)", name, res.status); + return res.ok ? 0 : 1; + } + + private int publishRepo(Dub dub, RegistryAuthConfig cfg) + { + auto name = resolvePackageName(dub); + enforceUsage(m_repoKind.length && m_repoOwner.length && m_repoProject.length, + "--kind, --owner, and --project are required"); + if (dub.dryRun) + { + logInfo("Would set repository for %s to %s/%s/%s", + name, m_repoKind, m_repoOwner, m_repoProject); + return 0; + } + auto client = new RegistryAuthClient(cfg); + client.login(); + auto res = client.setRepository(name, m_repoKind, m_repoOwner, m_repoProject); + logInfo("Repository updated for %s (HTTP %s)", name, res.status); + return res.ok ? 0 : 1; + } + + private uint parsePermFlags(string[] flags) + { + uint perms; + foreach (f; flags) + { + auto key = f.strip.toLower; + switch (key) + { + case "update": + perms |= 1u << 0; + break; + case "metadata", "meta": + perms |= 1u << 1; + break; + case "source", "repo", "repository": + perms |= 1u << 2; + break; + case "admin": + perms |= (1u << 3) | 0b111; + break; + default: + throw new Exception("Unknown --perm value: " ~ f + ~ " (use update|metadata|source|admin)"); + } + } + return perms; + } + + private int publishPermsAdd(Dub dub, RegistryAuthConfig cfg) + { + auto name = resolvePackageName(dub); + enforceUsage(m_sharedUsername.length > 0, "--username is required"); + auto perms = parsePermFlags(m_permFlags); + enforceUsage(perms != 0, "Pass at least one --perm update|metadata|source|admin"); + if (dub.dryRun) + { + logInfo("Would add %s to %s with perms %s", m_sharedUsername, name, perms); + return 0; + } + auto client = new RegistryAuthClient(cfg); + client.login(); + auto res = client.addSharedUser(name, m_sharedUsername, perms); + logInfo("Shared user added for %s (HTTP %s)", name, res.status); + return res.ok ? 0 : 1; + } + + private int publishLeave(Dub dub, RegistryAuthConfig cfg) + { + auto name = resolvePackageName(dub); + if (dub.dryRun) + { + logInfo("Would leave package %s", name); + return 0; + } + auto client = new RegistryAuthClient(cfg); + client.login(); + auto res = client.leavePackage(name); + logInfo("Left package %s (HTTP %s)", name, res.status); + return res.ok ? 0 : 1; + } + + private string resolvePackageName(Dub dub) + { + if (m_packageName.length) + return m_packageName; + enforce(loadCwdPackage(dub, true), "Package name required (--package or a local recipe)"); + return dub.projectName; + } +} + /******************************************************************************/ class ConvertCommand : Command { diff --git a/source/dub/registry_auth.d b/source/dub/registry_auth.d new file mode 100644 index 000000000..5bcb92759 --- /dev/null +++ b/source/dub/registry_auth.d @@ -0,0 +1,773 @@ +/** + Authenticated access to a DUB registry for package registration and owner + settings (logo, docs URL, categories, webhooks, permissions, remove). + + The public registry (code.dlang.org) registers packages via a browser form + (`POST /register_package` after session login). This module implements that + flow for the `dub publish` command, plus the My packages owner actions. + + Copyright: © 2026 DUB contributors + License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file. +*/ +module dub.registry_auth; + +import dub.dub : SpecialDirs, defaultRegistryURLs; +import dub.internal.io.realfs : RealFS; +import dub.internal.logging; +import dub.internal.utils : getDUBVersion; +import dub.internal.vibecompat.inet.path; +import dub.registry_secrets; + +import std.algorithm : canFind, endsWith, startsWith; +import std.array : appender, split; +import std.conv : to; +import std.exception : enforce; +import std.file : exists, mkdirRecurse, read, readText, remove, write; +import std.process : Config, environment, execute; +import std.string : chomp, indexOf, representation, strip, toLower; +import std.uri : encodeComponent; + +version (DubUseCurl) { + import std.net.curl : CurlException, HTTP; +} + +/// Credentials / endpoint for registry authentication. +struct RegistryAuthConfig +{ + string registryUrl = "https://code.dlang.org"; + string user; + string password; + string cookieJar; +} + +/// Result of a registry HTTP interaction. +struct RegistryAuthResult +{ + int status; + string body_; + bool ok() const @safe pure nothrow { return status >= 200 && status < 400; } +} + +/// Webhook endpoint URLs (always built clean — avoids dub-registry #614 malformation). +struct WebhookUrls +{ + string generic; + string github; + string gitlab; + string secret; +} + +/// Build clean webhook URLs for a package (generic / GitHub / GitLab). +WebhookUrls buildWebhookUrls(string registryUrl, string packageName, string secret) +{ + normalizeRegistryUrl(registryUrl); + auto base = registryUrl ~ "/api/packages/" ~ encodeComponent(packageName); + WebhookUrls u; + u.secret = secret; + u.generic = base ~ "/update"; + u.github = base ~ "/update/github?secret=" ~ encodeComponent(secret); + u.gitlab = base ~ "/update/gitlab"; + return u; +} + +/// Thrown when register_package reports the repository is already registered. +class AlreadyRegisteredException : Exception +{ + this(string msg, string file = __FILE__, size_t line = __LINE__) + { + super(msg, file, line); + } +} + +bool isAlreadyRegisteredMessage(string alert) +{ + auto lower = alert.toLower; + return lower.canFind("already registered") + || lower.canFind("already exists") + || lower.canFind("is already registered"); +} + +/// Path to the DUB user settings directory (`~/.dub` / `%APPDATA%\dub`). +NativePath registryUserSettingsDir() +{ + scope fs = new RealFS(); + return SpecialDirs.make(fs).userSettings; +} + +string credentialsPath() +{ + return (registryUserSettingsDir() ~ "credentials.v1").toNativeString(); +} + +/** + Default path for a one-shot plaintext password drop file. + + Agents/scripts write the password here (first line), then run + `dub publish login --user … --save-credentials`. On success the app stores + it with DPAPI (Windows) / mode 0600 (elsewhere) and deletes this file. +*/ +string passwordDropPath() +{ + return (registryUserSettingsDir() ~ "password.incoming").toNativeString(); +} + +/// Create the user settings directory if needed (so an agent can write password.incoming). +string ensureUserSettingsDir() +{ + auto home = registryUserSettingsDir(); + mkdirRecurse(home.toNativeString()); + return home.toNativeString(); +} + +/// Delete the password drop file if present. Returns true when a file was removed. +bool clearPasswordDrop() +{ + auto path = passwordDropPath(); + if (!exists(path)) + return false; + remove(path); + return true; +} + +/// Resolve config from overrides, environment, then DUB user settings credentials. +RegistryAuthConfig loadRegistryAuthConfig(string registryOverride = null, + string userOverride = null, string passwordOverride = null) +{ + RegistryAuthConfig cfg; + + if (registryOverride.length) + cfg.registryUrl = registryOverride; + else if (auto r = environment.get("DUB_REGISTRY_URL")) + cfg.registryUrl = r; + else + cfg.registryUrl = defaultRegistryURLs[0]; + + if (userOverride.length) + cfg.user = userOverride; + else if (auto u = environment.get("DUB_REGISTRY_USER")) + cfg.user = u; + + if (passwordOverride.length) + cfg.password = passwordOverride; + else if (auto p = environment.get("DUB_REGISTRY_PASSWORD")) + cfg.password = p; + + auto home = registryUserSettingsDir(); + cfg.cookieJar = (home ~ "cookies.txt").toNativeString(); + + if (!cfg.user.length || !cfg.password.length) + { + string loadedUser; + string loadedPassword; + bool fromLegacy; + if (loadStoredCredentials(loadedUser, loadedPassword, fromLegacy)) + { + if (!cfg.user.length) + cfg.user = loadedUser; + if (!cfg.password.length) + cfg.password = loadedPassword; + // Upgrade legacy plaintext files on first successful read. + if (fromLegacy && cfg.user.length && cfg.password.length + && cfg.user == loadedUser && cfg.password == loadedPassword) + { + try + saveRegistryCredentials(cfg.user, cfg.password); + catch (Exception) + { + // Keep using the in-memory password; leave legacy file alone. + } + } + } + } + + normalizeRegistryUrl(cfg.registryUrl); + return cfg; +} + +/** + Persist username/password under the DUB user settings directory. + + The password is not hashed: a hash cannot be sent to the registry on later + logins. On Windows the secret is protected with DPAPI (bound to the current + user). Elsewhere it is stored Base64-encoded under mode 0600 (OS file ACLs). +*/ +void saveRegistryCredentials(string user, string password) +{ + auto home = registryUserSettingsDir(); + mkdirRecurse(home.toNativeString()); + auto path = credentialsPath(); + auto body_ = "version=1\n" + ~ "user=" ~ user ~ "\n" + ~ "password=" ~ protectSecret(password) ~ "\n"; + write(path, body_); + version (Posix) + { + import core.sys.posix.sys.stat : chmod; + import std.conv : octal; + import std.string : toStringz; + chmod(path.toStringz, octal!600); + } + // Remove legacy plaintext file if present alongside the new format. + auto legacy = (home ~ "credentials").toNativeString(); + if (legacy != path && exists(legacy)) + { + try + remove(legacy); + catch (Exception) + { + } + } +} + +/// Delete stored credentials (new + legacy filenames) and any leftover drop file. +bool clearRegistryCredentials() +{ + bool removed; + auto home = registryUserSettingsDir(); + foreach (name; ["credentials.v1", "credentials", "password.incoming"]) + { + auto path = (home ~ name).toNativeString(); + if (exists(path)) + { + remove(path); + removed = true; + } + } + return removed; +} + +void normalizeRegistryUrl(ref string url) +{ + if (!url.length) + url = "https://code.dlang.org"; + while (url.endsWith("/")) + url = url[0 .. $ - 1]; + // Strip dub+ / mvn+ scheme prefixes used by --registry package suppliers. + if (url.startsWith("dub+")) + url = url["dub+".length .. $]; + else if (url.startsWith("mvn+")) + url = url["mvn+".length .. $]; +} + +/// Return the URL for a git remote (default `origin`), or `null` if unavailable. +string detectGitRemoteUrl(string remote = "origin", string cwd = ".") +{ + auto r = execute(["git", "-C", cwd, "remote", "get-url", remote], null, + Config.stderrPassThrough); + if (r.status != 0) + return null; + auto url = r.output.strip; + if (!url.length) + return null; + return normalizeRepoUrl(url); +} + +/// Turn common git remote forms into an https URL the registry accepts. +string normalizeRepoUrl(string url) +{ + url = url.strip; + url = url.chomp(".git"); + + // git@github.com:owner/repo + if (url.startsWith("git@")) + { + auto rest = url[4 .. $]; // host:path + auto colon = rest.indexOf(':'); + enforce(colon >= 0, "Unrecognized SSH remote: " ~ url); + auto host = rest[0 .. colon]; + auto path = rest[colon + 1 .. $]; + return "https://" ~ host ~ "/" ~ path; + } + + // ssh://git@host/owner/repo + if (url.startsWith("ssh://")) + { + auto without = url["ssh://".length .. $]; + if (without.canFind("@")) + without = without.split("@")[1]; + return "https://" ~ without; + } + + if (!url.startsWith("http://") && !url.startsWith("https://")) + { + if (url.canFind("/")) + { + if (url.canFind(".")) + return "https://" ~ url; + return "https://github.com/" ~ url; + } + } + + return url; +} + +version (DubUseCurl) +{ + /// HTTP client for registry login / register / update / owner settings. + final class RegistryAuthClient + { + private RegistryAuthConfig cfg; + + this(RegistryAuthConfig cfg) + { + this.cfg = cfg; + auto jarDir = NativePath(cfg.cookieJar).parentPath; + if (!jarDir.empty && !exists(jarDir.toNativeString())) + mkdirRecurse(jarDir.toNativeString()); + } + + /// Log in and store the session in the cookie jar. + void login() + { + enforce(cfg.user.length, "Registry username required (--user or DUB_REGISTRY_USER)"); + enforce(cfg.password.length, "Registry password required (--password or DUB_REGISTRY_PASSWORD)"); + + auto form = "name=" ~ encodeComponent(cfg.user) + ~ "&password=" ~ encodeComponent(cfg.password); + + auto res = request(HTTP.Method.post, cfg.registryUrl ~ "/login", form, + "application/x-www-form-urlencoded"); + + auto lower = res.body_.toLower; + enforce(!lower.canFind("invalid user name or password") + && !lower.canFind("invalid username or password") + && !(res.status == 200 && lower.canFind("please enter your user name and password")), + "Login failed — check username/password (account must be activated)"); + } + + /// Register a repository URL. Throws AlreadyRegisteredException when already present. + RegistryAuthResult registerPackage(string repoUrl, bool ignoreFork = false) + { + enforce(repoUrl.length, "Repository URL is required"); + auto form = "url=" ~ encodeComponent(repoUrl); + if (ignoreFork) + form ~= "&ignore_fork=true"; + + auto res = request(HTTP.Method.post, cfg.registryUrl ~ "/register_package", form, + "application/x-www-form-urlencoded"); + + auto lower = res.body_.toLower; + if (lower.canFind("warn_fork") || lower.canFind("this repository is a fork") + || lower.canFind("is a fork")) + { + throw new Exception( + "Repository looks like a fork. Re-run with --ignore-fork if that is intentional."); + } + if (lower.canFind("redalert") || (res.status == 200 && lower.canFind("add new package") + && (lower.canFind("error") || lower.canFind("failed")))) + { + auto alert = extractAlert(res.body_); + if (isAlreadyRegisteredMessage(alert)) + throw new AlreadyRegisteredException(alert); + throw new Exception("Registration failed:\n" ~ alert); + } + // Successful registration usually redirects away from the add form. + if (res.status == 200 && lower.canFind("add new package") + && lower.canFind("register package")) + { + throw new Exception( + "Registration did not complete (still on add-package form). " + ~ "Check credentials and repository URL.\n" ~ extractAlert(res.body_)); + } + enforceAuth(res, lower); + return res; + } + + /// Trigger a package metadata refresh (authenticated owner action). + RegistryAuthResult triggerUpdate(string packageName) + { + enforce(packageName.length, "Package name required"); + return request(HTTP.Method.post, pkgPath(packageName) ~ "/update", null, null); + } + + /// Trigger update via package webhook secret (no login). + RegistryAuthResult triggerUpdateWithSecret(string packageName, string secret) + { + enforce(packageName.length, "Package name required"); + enforce(secret.length, "Package secret required"); + auto url = cfg.registryUrl ~ "/api/packages/" ~ encodeComponent(packageName) + ~ "/update?secret=" ~ encodeComponent(secret); + return request(HTTP.Method.post, url, null, null); + } + + /// Enable or regenerate webhook secret. Returns plaintext secret (Accept: text/plain). + string regenSecret(string packageName) + { + enforce(packageName.length, "Package name required"); + auto res = request(HTTP.Method.post, pkgPath(packageName) ~ "/regen_secret", + null, null, "text/plain"); + enforce(res.ok, "regen_secret failed HTTP " ~ res.status.to!string ~ ": " ~ res.body_); + auto secret = res.body_.strip; + enforce(secret.length > 0, "Registry returned an empty webhook secret"); + return secret; + } + + RegistryAuthResult unsetSecret(string packageName) + { + enforce(packageName.length, "Package name required"); + return request(HTTP.Method.post, pkgPath(packageName) ~ "/unset_secret", null, null); + } + + RegistryAuthResult setDocumentationUrl(string packageName, string documentationUrl) + { + enforce(packageName.length, "Package name required"); + auto form = "documentation_url=" ~ encodeComponent(documentationUrl); + return request(HTTP.Method.post, pkgPath(packageName) ~ "/set_documentation_url", + form, "application/x-www-form-urlencoded"); + } + + RegistryAuthResult setCategories(string packageName, string[] categories) + { + enforce(packageName.length, "Package name required"); + enforce(categories.length <= 4, "At most 4 categories allowed"); + string form; + foreach (i, cat; categories) + { + if (form.length) + form ~= "&"; + form ~= "categories_" ~ i.to!string ~ "=" ~ encodeComponent(cat); + } + // Pad to 4 slots like the web UI (empty clears unused). + foreach (i; categories.length .. 4) + { + if (form.length) + form ~= "&"; + form ~= "categories_" ~ i.to!string ~ "="; + } + return request(HTTP.Method.post, pkgPath(packageName) ~ "/set_categories", + form, "application/x-www-form-urlencoded"); + } + + RegistryAuthResult setLogo(string packageName, string logoPath) + { + enforce(packageName.length, "Package name required"); + enforce(exists(logoPath), "Logo file not found: " ~ logoPath); + auto bytes = cast(const(ubyte)[]) read(logoPath); + enforce(bytes.length < 1024 * 1024, "Logo too big (max 1 MiB)"); + enforce(bytes.length > 0, "Logo file is empty"); + + import std.path : baseName; + auto boundary = "----dubpublishBoundary7d4a6e"; + auto filename = baseName(logoPath); + auto preamble = "--" ~ boundary ~ "\r\n" + ~ "Content-Disposition: form-data; name=\"logo\"; filename=\"" ~ filename ~ "\"\r\n" + ~ "Content-Type: application/octet-stream\r\n\r\n"; + auto epilogue = "\r\n--" ~ boundary ~ "--\r\n"; + auto bodyBytes = cast(ubyte[])(preamble.representation.dup) + ~ bytes + ~ cast(ubyte[])(epilogue.representation); + + return requestRaw(HTTP.Method.post, pkgPath(packageName) ~ "/set_logo", + bodyBytes, "multipart/form-data; boundary=" ~ boundary); + } + + RegistryAuthResult deleteLogo(string packageName) + { + enforce(packageName.length, "Package name required"); + return request(HTTP.Method.post, pkgPath(packageName) ~ "/delete_logo", null, null); + } + + RegistryAuthResult setRepository(string packageName, string kind, string owner, string project) + { + enforce(packageName.length, "Package name required"); + auto form = "kind=" ~ encodeComponent(kind) + ~ "&owner=" ~ encodeComponent(owner) + ~ "&project=" ~ encodeComponent(project); + return request(HTTP.Method.post, pkgPath(packageName) ~ "/set_repository", + form, "application/x-www-form-urlencoded"); + } + + RegistryAuthResult addSharedUser(string packageName, string username, uint permissions) + { + enforce(packageName.length, "Package name required"); + enforce(username.length, "Username required"); + // Multiple permissions fields with same name; encode as repeated keys. + string form = "username=" ~ encodeComponent(username); + foreach (bit; [1u, 2u, 4u, 15u]) + { + if (permissions & bit) + form ~= "&permissions=" ~ bit.to!string; + } + return request(HTTP.Method.post, pkgPath(packageName) ~ "/add_shared_user", + form, "application/x-www-form-urlencoded"); + } + + /// Step 1 of owner delete — shows confirm page; we immediately follow with remove_confirm. + RegistryAuthResult removePackage(string packageName) + { + enforce(packageName.length, "Package name required"); + auto step1 = request(HTTP.Method.post, pkgPath(packageName) ~ "/remove", null, null); + enforce(step1.ok || step1.status == 200, + "remove failed HTTP " ~ step1.status.to!string ~ ": " ~ extractAlert(step1.body_)); + return request(HTTP.Method.post, pkgPath(packageName) ~ "/remove_confirm", null, null); + } + + RegistryAuthResult leavePackage(string packageName) + { + enforce(packageName.length, "Package name required"); + return request(HTTP.Method.post, pkgPath(packageName) ~ "/leave", null, null); + } + + /** + True when the package document exists on the registry. + + `/latest` 404s when the package is registered but has no versions yet. + `/info` returns the package document in that case. + */ + bool packageExists(string packageName) + { + auto info = request(HTTP.Method.get, + cfg.registryUrl ~ "/api/packages/" ~ encodeComponent(packageName) ~ "/info", + null, null); + if (info.status == 404) + return false; + if (info.ok) + { + auto body_ = info.body_.strip; + if (!body_.length || body_.canFind("\"statusMessage\"") && body_.canFind("not found")) + return false; + return body_.canFind("\"name\""); + } + auto res = request(HTTP.Method.get, + cfg.registryUrl ~ "/api/packages/" ~ encodeComponent(packageName) ~ "/latest", + null, null); + if (res.status == 404) + return false; + if (!res.ok) + throw new Exception("Status check failed HTTP " ~ res.status.to!string ~ ": " ~ res.body_); + return res.body_.strip.length > 0 && !res.body_.canFind("Package not found"); + } + + /// Fetch latest version string, or `null` if the package is missing / has no versions. + string latestVersion(string packageName) + { + auto res = request(HTTP.Method.get, + cfg.registryUrl ~ "/api/packages/" ~ encodeComponent(packageName) ~ "/latest", + null, null); + if (res.status == 404) + return null; + enforce(res.ok, "Lookup failed HTTP " ~ res.status.to!string); + return unwrapJsonString(res.body_.strip); + } + + private: + string pkgPath(string packageName) + { + return cfg.registryUrl ~ "/my_packages/" ~ encodeComponent(packageName); + } + + void enforceAuth(RegistryAuthResult res, string lower) + { + if (res.status == 401 || res.status == 403 + || (res.status == 200 && lower.canFind("please enter your user name and password"))) + { + throw new Exception("Not authenticated — login first"); + } + } + + RegistryAuthResult request(HTTP.Method method, string url, string body_, string contentType, + string accept = "text/html,application/json,*/*") + { + const(ubyte)[] raw; + if (body_ !is null) + raw = cast(const(ubyte)[]) body_.representation; + return requestRaw(method, url, raw, contentType, accept); + } + + RegistryAuthResult requestRaw(HTTP.Method method, string url, const(ubyte)[] bodyBytes, + string contentType, string accept = "text/html,application/json,*/*") + { + auto http = HTTP(); + http.url = url; + http.method = method; + http.setCookieJar(cfg.cookieJar); + http.maxRedirects = 10; + http.addRequestHeader("User-Agent", + "dub/" ~ getDUBVersion() ~ " (+https://github.com/dlang/dub)"); + http.addRequestHeader("Accept", accept); + + if (bodyBytes !is null) + { + if (contentType.length) + http.setPostData(cast(void[]) bodyBytes.dup, contentType); + else + http.postData = cast(void[]) bodyBytes.dup; + } + + auto buf = appender!string(); + http.onReceive = (ubyte[] data) { + buf.put(cast(char[]) data); + return data.length; + }; + + int status = 0; + http.onReceiveStatusLine = (HTTP.StatusLine line) { + status = cast(int) line.code; + }; + + try + http.perform(); + catch (CurlException e) + throw new Exception("HTTP request failed: " ~ e.msg); + + RegistryAuthResult res; + res.status = status; + res.body_ = buf.data; + return res; + } + } +} +else +{ + final class RegistryAuthClient + { + this(RegistryAuthConfig) {} + void login() { throw new Exception("dub publish requires curl support"); } + RegistryAuthResult registerPackage(string, bool = false) + { + throw new Exception("dub publish requires curl support"); + } + RegistryAuthResult triggerUpdate(string) + { + throw new Exception("dub publish requires curl support"); + } + RegistryAuthResult triggerUpdateWithSecret(string, string) + { + throw new Exception("dub publish requires curl support"); + } + string regenSecret(string) + { + throw new Exception("dub publish requires curl support"); + } + RegistryAuthResult unsetSecret(string) + { + throw new Exception("dub publish requires curl support"); + } + RegistryAuthResult setDocumentationUrl(string, string) + { + throw new Exception("dub publish requires curl support"); + } + RegistryAuthResult setCategories(string, string[]) + { + throw new Exception("dub publish requires curl support"); + } + RegistryAuthResult setLogo(string, string) + { + throw new Exception("dub publish requires curl support"); + } + RegistryAuthResult deleteLogo(string) + { + throw new Exception("dub publish requires curl support"); + } + RegistryAuthResult setRepository(string, string, string, string) + { + throw new Exception("dub publish requires curl support"); + } + RegistryAuthResult addSharedUser(string, string, uint) + { + throw new Exception("dub publish requires curl support"); + } + RegistryAuthResult removePackage(string) + { + throw new Exception("dub publish requires curl support"); + } + RegistryAuthResult leavePackage(string) + { + throw new Exception("dub publish requires curl support"); + } + bool packageExists(string) + { + throw new Exception("dub publish requires curl support"); + } + string latestVersion(string) + { + throw new Exception("dub publish requires curl support"); + } + } +} + +/// Load from credentials.v1 or legacy plaintext `credentials`. +private bool loadStoredCredentials(out string user, out string password, out bool fromLegacy) +{ + auto home = registryUserSettingsDir(); + auto v1 = credentialsPath(); + if (exists(v1)) + { + parseCredentialFile(readText(v1), user, password); + fromLegacy = false; + return user.length > 0 || password.length > 0; + } + + auto legacy = (home ~ "credentials").toNativeString(); + if (exists(legacy)) + { + auto lines = splitLinesSafe(readText(legacy)); + if (lines.length >= 1) + user = lines[0].strip; + if (lines.length >= 2) + password = lines[1].strip; + fromLegacy = true; + return user.length > 0 || password.length > 0; + } + return false; +} + +private void parseCredentialFile(string text, out string user, out string password) +{ + foreach (line; splitLinesSafe(text)) + { + auto s = line.strip; + if (!s.length || s.startsWith("#")) + continue; + if (s.startsWith("user=")) + user = s["user=".length .. $]; + else if (s.startsWith("password=")) + password = unprotectSecret(s["password=".length .. $]); + } +} + +private string[] splitLinesSafe(string text) +{ + import std.array : array; + import std.string : lineSplitter; + return text.lineSplitter.array; +} + +private string unwrapJsonString(string s) +{ + if (s.length >= 2 && s[0] == '"' && s[$ - 1] == '"') + return s[1 .. $ - 1]; + return s; +} + +private string extractAlert(string html) +{ + import std.regex : ctRegex, matchFirst, regex, replaceAll; + static re = ctRegex!(`]*class="[^"]*redAlert[^"]*"[^>]*>([\s\S]*?)

`, "i"); + auto m = matchFirst(html, re); + if (!m) + return html.length > 500 ? html[0 .. 500] ~ "…" : html; + auto text = m[1].replaceAll(regex(`<[^>]+>`), " ").strip; + return text.length ? text : m[1].strip; +} + +unittest +{ + assert(normalizeRepoUrl("git@github.com:org/repo.git") == "https://github.com/org/repo"); + assert(normalizeRepoUrl("https://github.com/org/repo.git") == "https://github.com/org/repo"); + assert(normalizeRepoUrl("ssh://git@gitlab.com/org/repo") == "https://gitlab.com/org/repo"); + assert(normalizeRepoUrl("org/repo") == "https://github.com/org/repo"); + + string u = "https://code.dlang.org/"; + normalizeRegistryUrl(u); + assert(u == "https://code.dlang.org"); + u = "dub+https://code.dlang.org"; + normalizeRegistryUrl(u); + assert(u == "https://code.dlang.org"); + + auto hooks = buildWebhookUrls("https://code.dlang.org/", "mypkg", "sec"); + assert(hooks.generic == "https://code.dlang.org/api/packages/mypkg/update"); + assert(hooks.github == "https://code.dlang.org/api/packages/mypkg/update/github?secret=sec"); + assert(hooks.gitlab == "https://code.dlang.org/api/packages/mypkg/update/gitlab"); + assert(isAlreadyRegisteredMessage("Package is already registered")); +} diff --git a/source/dub/registry_secrets.d b/source/dub/registry_secrets.d new file mode 100644 index 000000000..8e6e12ddf --- /dev/null +++ b/source/dub/registry_secrets.d @@ -0,0 +1,206 @@ +/** + Local at-rest protection helpers for registry credentials. + + Passwords cannot be stored as one-way hashes if they must be sent to the + registry later. On Windows the secret is protected with DPAPI (bound to the + current user). Elsewhere it is Base64-encoded for file storage (callers + should use mode 0600). + + Copyright: © 2026 DUB contributors + License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file. +*/ +module dub.registry_secrets; + +import std.base64; +import std.exception : enforce; +import std.string : representation; + +/** + Protect a secret for local at-rest storage. + + Passwords cannot be stored as one-way hashes if they must be sent to the + registry later. Industry practice for reusable login secrets is OS-backed + reversible protection (Windows DPAPI) or a vault/keyring. +*/ +string protectSecret(string plaintext) +{ + version (Windows) + return "dpapi:" ~ cast(string) Base64.encode(dpapiProtect(plaintext.representation)); + else + return "file:" ~ cast(string) Base64.encode(cast(immutable(ubyte)[]) plaintext.representation); +} + +/// Reverse of protectSecret. Throws if the blob cannot be unlocked on this machine/user. +string unprotectSecret(string stored) +{ + import std.algorithm : startsWith; + if (stored.startsWith("dpapi:")) + { + version (Windows) + return cast(string) dpapiUnprotect(Base64.decode(stored[6 .. $])); + else + throw new Exception("Credential was protected with Windows DPAPI; cannot unlock here"); + } + if (stored.startsWith("file:")) + return cast(string) Base64.decode(stored[5 .. $]); + // Legacy plaintext line (pre-protected store) + return stored; +} + +bool isProtectedSecret(string stored) +{ + import std.algorithm : startsWith; + return stored.startsWith("dpapi:") || stored.startsWith("file:"); +} + +version (Windows) +{ + pragma(lib, "crypt32"); + + import core.sys.windows.windows; + + private struct DATA_BLOB + { + DWORD cbData; + BYTE* pbData; + } + + private extern (Windows) @nogc nothrow + { + BOOL CryptProtectData(DATA_BLOB* pDataIn, LPCWSTR szDataDescr, DATA_BLOB* pOptionalEntropy, + PVOID pvReserved, void* pPromptStruct, DWORD dwFlags, DATA_BLOB* pDataOut); + BOOL CryptUnprotectData(DATA_BLOB* pDataIn, LPWSTR* ppszDataDescr, DATA_BLOB* pOptionalEntropy, + PVOID pvReserved, void* pPromptStruct, DWORD dwFlags, DATA_BLOB* pDataOut); + } + + private enum CRYPTPROTECT_UI_FORBIDDEN = 0x1; + + private ubyte[] dpapiProtect(const(ubyte)[] data) + { + DATA_BLOB input; + input.cbData = cast(DWORD) data.length; + input.pbData = cast(BYTE*) data.ptr; + + DATA_BLOB output; + auto ok = CryptProtectData(&input, null, null, null, null, CRYPTPROTECT_UI_FORBIDDEN, &output); + enforce(ok != FALSE, "CryptProtectData failed (Windows DPAPI)"); + scope (exit) LocalFree(cast(HLOCAL) output.pbData); + + auto copy = new ubyte[](output.cbData); + copy[] = (cast(ubyte*) output.pbData)[0 .. output.cbData]; + return copy; + } + + private ubyte[] dpapiUnprotect(const(ubyte)[] data) + { + DATA_BLOB input; + input.cbData = cast(DWORD) data.length; + input.pbData = cast(BYTE*) data.ptr; + + DATA_BLOB output; + auto ok = CryptUnprotectData(&input, null, null, null, null, + CRYPTPROTECT_UI_FORBIDDEN, &output); + enforce(ok != FALSE, "CryptUnprotectData failed — credential may belong to another Windows user"); + scope (exit) LocalFree(cast(HLOCAL) output.pbData); + + auto copy = new ubyte[](output.cbData); + copy[] = (cast(ubyte*) output.pbData)[0 .. output.cbData]; + return copy; + } +} + +/// Read a password from a file (first line, trimmed). Prefer this over `-p` for agents/scripts. +string readPasswordFile(string path) +{ + import std.file : exists, readText; + import std.range : empty, front; + import std.string : chomp, lineSplitter, strip; + enforce(path.length, "password file path is empty"); + enforce(exists(path), "password file not found: " ~ path); + auto text = readText(path); + auto lines = text.lineSplitter; + enforce(!lines.empty, "password file is empty: " ~ path); + auto pw = lines.front.chomp.strip; + enforce(pw.length, "password file first line is empty: " ~ path); + return pw; +} + +/// Read a password from the console without echoing (TTY). Opt-in via --prompt-password. +string promptPassword(string prompt = "Password: ") +{ + import std.stdio : stderr, stdin; + import std.string : chomp; + + stderr.write(prompt); + stderr.flush(); + + version (Windows) + { + import core.sys.windows.winbase : GetStdHandle, INVALID_HANDLE_VALUE, STD_INPUT_HANDLE; + import core.sys.windows.wincon : ENABLE_ECHO_INPUT, GetConsoleMode, SetConsoleMode; + import core.sys.windows.windef : DWORD; + + auto hIn = GetStdHandle(STD_INPUT_HANDLE); + DWORD mode = 0; + bool toggled; + if (hIn !is null && hIn !is INVALID_HANDLE_VALUE && GetConsoleMode(hIn, &mode)) + { + auto newMode = mode & ~ENABLE_ECHO_INPUT; + if (SetConsoleMode(hIn, newMode)) + toggled = true; + } + scope (exit) + { + if (toggled) + SetConsoleMode(hIn, mode); + stderr.writeln(); + } + return stdin.readln().chomp; + } + else + { + import core.sys.posix.termios; + import core.sys.posix.unistd : STDIN_FILENO, isatty; + + termios oldt; + bool toggled; + if (isatty(STDIN_FILENO)) + { + if (tcgetattr(STDIN_FILENO, &oldt) == 0) + { + auto newt = oldt; + newt.c_lflag &= ~(ECHO); + if (tcsetattr(STDIN_FILENO, TCSANOW, &newt) == 0) + toggled = true; + } + } + scope (exit) + { + if (toggled) + tcsetattr(STDIN_FILENO, TCSANOW, &oldt); + stderr.writeln(); + } + return stdin.readln().chomp; + } +} + +unittest +{ + import std.algorithm : startsWith; + import std.file : remove, tempDir, write; + import std.path : buildPath; + + auto stored = protectSecret("unit-test-secret"); + version (Windows) + assert(stored.startsWith("dpapi:")); + else + assert(stored.startsWith("file:")); + assert(unprotectSecret(stored) == "unit-test-secret"); + assert(unprotectSecret("legacy-plain") == "legacy-plain"); + + auto path = buildPath(tempDir(), "dub-registry-pw-test.txt"); + write(path, "file-secret\nignored\n"); + scope (exit) + remove(path); + assert(readPasswordFile(path) == "file-secret"); +}