Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0.0
0.1.1.0
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -56,7 +57,7 @@ export async function loadConfig(): Promise<BackupConfig | undefined> {
value.repositoryId <= 0 ||
!('githubRepository' in value) ||
typeof value.githubRepository !== 'string' ||
!/^[\w.-]+\/[\w.-]+$/.test(value.githubRepository)
!isGitHubName(value.githubRepository)
) {
throw new Error('Invalid configuration fields.')
}
Expand Down
15 changes: 7 additions & 8 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.',
)
Expand Down Expand Up @@ -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.',
Expand All @@ -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.')
}
Expand All @@ -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 }
Expand Down
22 changes: 22 additions & 0 deletions src/utils/github-name.ts
Original file line number Diff line number Diff line change
@@ -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('/..')
)
}
2 changes: 1 addition & 1 deletion test/backup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
83 changes: 83 additions & 0 deletions test/edge-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/gh.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand Down
Loading