From ecf4359e254b4bccba58851f909dbd5b8cd8cc52 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sat, 3 Oct 2026 14:48:15 +0900 Subject: [PATCH] v0.1.1.0 fix: point Actions-disabled rejection at the repository settings page - Embed https://github.com///settings/actions with the disable steps in the rejection, so the next action is one click away. - Share one strict owner/repo grammar across remote URLs, API metadata, and stored configuration (dot-segment and overlong rejection, underscore owners for Enterprise Managed Users). - Reject enterprise-internal repositories, which report private:true yet remain readable by every enterprise member. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++ VERSION | 2 +- package.json | 2 +- src/config.ts | 3 +- src/git.ts | 15 ++++---- src/utils/github-name.ts | 22 +++++++++++ test/backup.test.ts | 2 +- test/edge-cases.test.ts | 83 ++++++++++++++++++++++++++++++++++++++++ test/fixtures/gh.mjs | 1 + 9 files changed, 130 insertions(+), 12 deletions(-) create mode 100644 src/utils/github-name.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b75d42b..e0ad5d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [0.1.1.0] - 2026-10-03 + +### Fixed + +- Include the repository's GitHub Actions settings URL and disable steps in the rejection shown when destination Actions are enabled. +- Accept underscore owner names so Enterprise Managed User accounts can register their private repositories. + +### Security + +- Reject enterprise-internal repositories, which report `private: true` yet remain readable by every enterprise member. +- Apply one strict `owner/repo` grammar to remote URLs, API metadata, and stored configuration, rejecting dot-segment and overlong names. + ## [0.1.0.0] - 2026-09-09 ### Added diff --git a/VERSION b/VERSION index 482e997..5cff5f1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0.0 +0.1.1.0 diff --git a/package.json b/package.json index 7750339..1567b9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@laststance/backup", - "version": "0.1.0", + "version": "0.1.1", "description": "Back up files and directories to an existing private GitHub repository.", "license": "MIT", "type": "module", diff --git a/src/config.ts b/src/config.ts index 92bc000..78ab223 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,7 @@ import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE, } from './constants' +import { isGitHubName } from './utils/github-name' import { maybeLstat } from './utils/maybe-lstat' export type BackupConfig = { @@ -56,7 +57,7 @@ export async function loadConfig(): Promise { value.repositoryId <= 0 || !('githubRepository' in value) || typeof value.githubRepository !== 'string' || - !/^[\w.-]+\/[\w.-]+$/.test(value.githubRepository) + !isGitHubName(value.githubRepository) ) { throw new Error('Invalid configuration fields.') } diff --git a/src/git.ts b/src/git.ts index 5678027..d963032 100644 --- a/src/git.ts +++ b/src/git.ts @@ -2,6 +2,7 @@ import { realpath } from 'node:fs/promises' import { createInterface } from 'node:readline' import { MIN_GIT_MAJOR_VERSION, MIN_GIT_MINOR_VERSION } from './constants' import type { BackupConfig } from './config' +import { isGitHubName } from './utils/github-name' import { resolveLocalPath } from './utils/resolve-local-path' import { readNulRecords } from './utils/read-nul-records' import { runCommand } from './utils/run-command' @@ -220,11 +221,7 @@ export function githubName(remote: string): string { pathname = url.pathname.replace(/^\//, '') } const name = pathname.replace(/\.git\/?$/, '').replace(/\/$/, '') - if ( - !/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9_.-]+$/.test(name) || - name.endsWith('/.') || - name.endsWith('/..') - ) { + if (!isGitHubName(name)) { throw new Error( 'The effective remote does not identify a GitHub repository.', ) @@ -284,7 +281,9 @@ export async function verifyGitHub( typeof metadata !== 'object' || metadata === null || !('private' in metadata) || - metadata.private !== true + metadata.private !== true || + // Internal repositories report private:true yet are readable by every enterprise member. + (('visibility' in metadata) && metadata.visibility !== 'private') ) { throw new Error( 'The destination must be a verified private GitHub repository.', @@ -297,7 +296,7 @@ export async function verifyGitHub( metadata.id <= 0 || !('full_name' in metadata) || typeof metadata.full_name !== 'string' || - !/^[\w.-]+\/[\w.-]+$/.test(metadata.full_name) + !isGitHubName(metadata.full_name) ) { throw new Error('GitHub returned invalid repository identity metadata.') } @@ -314,7 +313,7 @@ export async function verifyGitHub( actions.enabled !== false ) { throw new Error( - 'GitHub Actions must be disabled on the destination. Disable it in repository Settings > Actions > General; backup does not change this setting.', + `GitHub Actions must be disabled on the destination. Disable it in repository: https://github.com/${metadata.full_name}/settings/actions → Disable actions → Save`, ) } return { repositoryId: metadata.id, githubRepository: metadata.full_name } diff --git a/src/utils/github-name.ts b/src/utils/github-name.ts new file mode 100644 index 0000000..8616d41 --- /dev/null +++ b/src/utils/github-name.ts @@ -0,0 +1,22 @@ +/** Maximum GitHub `owner/repo` length: 39-char owner + '/' + 100-char repository. */ +const MAX_GITHUB_NAME_LENGTH = 140 + +const GITHUB_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*\/[A-Za-z0-9_.-]+$/ + +/** Whether a string is a well-formed GitHub `owner/repo` name. + * Shared grammar for remote URLs, API metadata, and stored configuration so + * every trust boundary rejects dot-only segments and overlong names the same + * way. The owner charset allows `_` for Enterprise Managed User accounts, + * which can only create private repositories. + * @param value - Candidate `owner/repo` string. + * @returns True when the value names a GitHub repository. + * @example isGitHubName("owner/repo") // => true + */ +export function isGitHubName(value: string): boolean { + return ( + value.length <= MAX_GITHUB_NAME_LENGTH && + GITHUB_NAME_PATTERN.test(value) && + !value.endsWith('/.') && + !value.endsWith('/..') + ) +} diff --git a/test/backup.test.ts b/test/backup.test.ts index 6dae0a5..db3c076 100644 --- a/test/backup.test.ts +++ b/test/backup.test.ts @@ -133,7 +133,7 @@ test.each([ [ 'enabled Actions', { BACKUP_TEST_ACTIONS: 'enabled' }, - 'Actions must be disabled', + 'Disable it in repository: https://github.com/test-owner/private-backup/settings/actions', ], ['unverifiable API', { BACKUP_TEST_GH_ERROR: '1' }, 'GitHub API denied'], ])('rejects %s before copying', async (_label, environment, message) => { diff --git a/test/edge-cases.test.ts b/test/edge-cases.test.ts index d9bb03c..7682b72 100644 --- a/test/edge-cases.test.ts +++ b/test/edge-cases.test.ts @@ -180,6 +180,21 @@ test.each([ '{"private":true,"id":"123456","full_name":"test-owner/private-backup"}', 'invalid repository identity', ], + [ + 'dot-segment full_name', + '{"private":true,"id":123456,"full_name":"test-owner/.."}', + 'invalid repository identity', + ], + [ + 'overlong full_name', + `{"private":true,"id":123456,"full_name":"${'o'.repeat(40)}/${'r'.repeat(101)}"}`, + 'invalid repository identity', + ], + [ + 'internal visibility', + '{"private":true,"visibility":"internal","id":123456,"full_name":"test-owner/private-backup"}', + 'verified private GitHub repository', + ], ])('rejects %s GitHub metadata', async (_label, metadata, message) => { // Arrange const world = await createWorld() @@ -189,6 +204,74 @@ test.each([ ).rejects.toThrow(message) }) +test.each([ + ['https', 'https://github.com/test-owner/private-backup'], + ['https with .git', 'https://github.com/test-owner/private-backup.git'], + ['ssh URL', 'ssh://git@github.com/test-owner/private-backup.git'], + ['ssh URL port 22', 'ssh://git@github.com:22/test-owner/private-backup.git'], + [ + 'ssh.github.com port 443', + 'ssh://git@ssh.github.com:443/test-owner/private-backup.git', + ], + [ + 'underscore owner (managed user)', + 'git@github.com:test_owner/private-backup.git', + ], +])('accepts %s remote URL during registration', async (_label, url) => { + // Arrange + const world = await createWorld() + await world.git(['remote', 'set-url', 'origin', url]) + // Act / Assert + await world.cli(['--repo', world.repo]) +}) + +test.each([ + [ + 'dot-segment repository', + 'git@github.com:test-owner/..', + 'does not identify a GitHub repository', + ], + [ + 'dot repository', + 'git@github.com:test-owner/.', + 'does not identify a GitHub repository', + ], + [ + 'missing repository segment', + 'https://github.com/test-owner', + 'does not identify a GitHub repository', + ], + [ + 'spaced repository name', + 'git@github.com:test-owner/re po.git', + 'does not identify a GitHub repository', + ], + [ + 'underscore-prefixed owner', + 'git@github.com:_owner/private-backup.git', + 'does not identify a GitHub repository', + ], + [ + 'overlong name', + `git@github.com:${'o'.repeat(40)}/${'r'.repeat(101)}`, + 'does not identify a GitHub repository', + ], + [ + 'non-GitHub host', + 'https://gitlab.com/test-owner/private-backup', + 'GitHub.com HTTPS or SSH endpoint', + ], +])( + 'rejects %s remote URL during registration', + async (_label, url, message) => { + // Arrange + const world = await createWorld() + await world.git(['remote', 'set-url', 'origin', url]) + // Act / Assert + await expect(world.cli(['--repo', world.repo])).rejects.toThrow(message) + }, +) + test('rejects a registered branch missing from a nonempty remote', async () => { // Arrange const world = await createWorld() diff --git a/test/fixtures/gh.mjs b/test/fixtures/gh.mjs index ac0118a..9788203 100644 --- a/test/fixtures/gh.mjs +++ b/test/fixtures/gh.mjs @@ -32,6 +32,7 @@ if (endpoint?.endsWith('/actions/permissions')) { JSON.stringify({ id: Number(process.env.BACKUP_TEST_REPOSITORY_ID ?? 123456), private: process.env.BACKUP_TEST_PUBLIC !== '1', + visibility: process.env.BACKUP_TEST_PUBLIC === '1' ? 'public' : 'private', full_name: 'test-owner/private-backup', archived: false, }),