Skip to content

Commit 3d6cb25

Browse files
authored
Support unprotected file patterns (#16395)
Fixes #16381 Note that changes to unprotected files via the web editor still cannot be pushed directly to the protected branch. I could easily add such support for edits and deletes if needed. But for adding, uploading or renaming unprotected files, it is not trivial. * Extract & Move GetAffectedFiles to modules/git
1 parent eb03e81 commit 3d6cb25

File tree

17 files changed

+254
-126
lines changed

17 files changed

+254
-126
lines changed

integrations/git_test.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ func doBranchProtectPRMerge(baseCtx *APITestContext, dstPath string) func(t *tes
365365
t.Run("PushProtectedBranch", doGitPushTestRepository(dstPath, "origin", "protected"))
366366

367367
ctx := NewAPITestContext(t, baseCtx.Username, baseCtx.Reponame)
368-
t.Run("ProtectProtectedBranchNoWhitelist", doProtectBranch(ctx, "protected", ""))
368+
t.Run("ProtectProtectedBranchNoWhitelist", doProtectBranch(ctx, "protected", "", ""))
369369
t.Run("GenerateCommit", func(t *testing.T) {
370370
_, err := generateCommitWithNewData(littleSize, dstPath, "[email protected]", "User Two", "branch-data-file-")
371371
assert.NoError(t, err)
@@ -391,7 +391,15 @@ func doBranchProtectPRMerge(baseCtx *APITestContext, dstPath string) func(t *tes
391391
t.Run("MergePR2", doAPIMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr2.Index))
392392
t.Run("MergePR", doAPIMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))
393393
t.Run("PullProtected", doGitPull(dstPath, "origin", "protected"))
394-
t.Run("ProtectProtectedBranchWhitelist", doProtectBranch(ctx, "protected", baseCtx.Username))
394+
395+
t.Run("ProtectProtectedBranchUnprotectedFilePaths", doProtectBranch(ctx, "protected", "", "unprotected-file-*"))
396+
t.Run("GenerateCommit", func(t *testing.T) {
397+
_, err := generateCommitWithNewData(littleSize, dstPath, "[email protected]", "User Two", "unprotected-file-")
398+
assert.NoError(t, err)
399+
})
400+
t.Run("PushUnprotectedFilesToProtectedBranch", doGitPushTestRepository(dstPath, "origin", "protected"))
401+
402+
t.Run("ProtectProtectedBranchWhitelist", doProtectBranch(ctx, "protected", baseCtx.Username, ""))
395403

396404
t.Run("CheckoutMaster", doGitCheckoutBranch(dstPath, "master"))
397405
t.Run("CreateBranchForced", doGitCreateBranch(dstPath, "toforce"))
@@ -406,28 +414,30 @@ func doBranchProtectPRMerge(baseCtx *APITestContext, dstPath string) func(t *tes
406414
}
407415
}
408416

409-
func doProtectBranch(ctx APITestContext, branch string, userToWhitelist string) func(t *testing.T) {
417+
func doProtectBranch(ctx APITestContext, branch string, userToWhitelist string, unprotectedFilePatterns string) func(t *testing.T) {
410418
// We are going to just use the owner to set the protection.
411419
return func(t *testing.T) {
412420
csrf := GetCSRF(t, ctx.Session, fmt.Sprintf("/%s/%s/settings/branches", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)))
413421

414422
if userToWhitelist == "" {
415423
// Change branch to protected
416424
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/%s", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), url.PathEscape(branch)), map[string]string{
417-
"_csrf": csrf,
418-
"protected": "on",
425+
"_csrf": csrf,
426+
"protected": "on",
427+
"unprotected_file_patterns": unprotectedFilePatterns,
419428
})
420429
ctx.Session.MakeRequest(t, req, http.StatusFound)
421430
} else {
422431
user, err := models.GetUserByName(userToWhitelist)
423432
assert.NoError(t, err)
424433
// Change branch to protected
425434
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/%s", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), url.PathEscape(branch)), map[string]string{
426-
"_csrf": csrf,
427-
"protected": "on",
428-
"enable_push": "whitelist",
429-
"enable_whitelist": "on",
430-
"whitelist_users": strconv.FormatInt(user.ID, 10),
435+
"_csrf": csrf,
436+
"protected": "on",
437+
"enable_push": "whitelist",
438+
"enable_whitelist": "on",
439+
"whitelist_users": strconv.FormatInt(user.ID, 10),
440+
"unprotected_file_patterns": unprotectedFilePatterns,
431441
})
432442
ctx.Session.MakeRequest(t, req, http.StatusFound)
433443
}

models/branches.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ type ProtectedBranch struct {
4343
DismissStaleApprovals bool `xorm:"NOT NULL DEFAULT false"`
4444
RequireSignedCommits bool `xorm:"NOT NULL DEFAULT false"`
4545
ProtectedFilePatterns string `xorm:"TEXT"`
46+
UnprotectedFilePatterns string `xorm:"TEXT"`
4647

4748
CreatedUnix timeutil.TimeStamp `xorm:"created"`
4849
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
@@ -214,8 +215,17 @@ func (protectBranch *ProtectedBranch) MergeBlockedByOutdatedBranch(pr *PullReque
214215

215216
// GetProtectedFilePatterns parses a semicolon separated list of protected file patterns and returns a glob.Glob slice
216217
func (protectBranch *ProtectedBranch) GetProtectedFilePatterns() []glob.Glob {
218+
return getFilePatterns(protectBranch.ProtectedFilePatterns)
219+
}
220+
221+
// GetUnprotectedFilePatterns parses a semicolon separated list of unprotected file patterns and returns a glob.Glob slice
222+
func (protectBranch *ProtectedBranch) GetUnprotectedFilePatterns() []glob.Glob {
223+
return getFilePatterns(protectBranch.UnprotectedFilePatterns)
224+
}
225+
226+
func getFilePatterns(filePatterns string) []glob.Glob {
217227
extarr := make([]glob.Glob, 0, 10)
218-
for _, expr := range strings.Split(strings.ToLower(protectBranch.ProtectedFilePatterns), ";") {
228+
for _, expr := range strings.Split(strings.ToLower(filePatterns), ";") {
219229
expr = strings.TrimSpace(expr)
220230
if expr != "" {
221231
if g, err := glob.Compile(expr, '.', '/'); err != nil {
@@ -260,6 +270,28 @@ func (protectBranch *ProtectedBranch) IsProtectedFile(patterns []glob.Glob, path
260270
return r
261271
}
262272

273+
// IsUnprotectedFile return if path is unprotected
274+
func (protectBranch *ProtectedBranch) IsUnprotectedFile(patterns []glob.Glob, path string) bool {
275+
if len(patterns) == 0 {
276+
patterns = protectBranch.GetUnprotectedFilePatterns()
277+
if len(patterns) == 0 {
278+
return false
279+
}
280+
}
281+
282+
lpath := strings.ToLower(strings.TrimSpace(path))
283+
284+
r := false
285+
for _, pat := range patterns {
286+
if pat.Match(lpath) {
287+
r = true
288+
break
289+
}
290+
}
291+
292+
return r
293+
}
294+
263295
// GetProtectedBranchBy getting protected branch by ID/Name
264296
func GetProtectedBranchBy(repoID int64, branchName string) (*ProtectedBranch, error) {
265297
return getProtectedBranchBy(x, repoID, branchName)

models/migrations/migrations.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,8 @@ var migrations = []Migration{
340340
NewMigration("RecreateIssueResourceIndexTable to have a primary key instead of an unique index", recreateIssueResourceIndexTable),
341341
// v193 -> v194
342342
NewMigration("Add repo id column for attachment table", addRepoIDForAttachment),
343+
// v194 -> v195
344+
NewMigration("Add Branch Protection Unprotected Files Column", addBranchProtectionUnprotectedFilesColumn),
343345
}
344346

345347
// GetCurrentDBVersion returns the current db version

models/migrations/v194.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright 2021 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package migrations
6+
7+
import (
8+
"fmt"
9+
10+
"xorm.io/xorm"
11+
)
12+
13+
func addBranchProtectionUnprotectedFilesColumn(x *xorm.Engine) error {
14+
type ProtectedBranch struct {
15+
UnprotectedFilePatterns string `xorm:"TEXT"`
16+
}
17+
18+
if err := x.Sync2(new(ProtectedBranch)); err != nil {
19+
return fmt.Errorf("Sync2: %v", err)
20+
}
21+
return nil
22+
}

modules/convert/convert.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ func ToBranchProtection(bp *models.ProtectedBranch) *api.BranchProtection {
127127
DismissStaleApprovals: bp.DismissStaleApprovals,
128128
RequireSignedCommits: bp.RequireSignedCommits,
129129
ProtectedFilePatterns: bp.ProtectedFilePatterns,
130+
UnprotectedFilePatterns: bp.UnprotectedFilePatterns,
130131
Created: bp.CreatedUnix.AsTime(),
131132
Updated: bp.UpdatedUnix.AsTime(),
132133
}

modules/git/diff.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"context"
1111
"fmt"
1212
"io"
13+
"os"
1314
"os/exec"
1415
"regexp"
1516
"strconv"
@@ -273,3 +274,46 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
273274
oldBegin, oldNumOfLines, newBegin, newNumOfLines)
274275
return strings.Join(newHunk, "\n"), nil
275276
}
277+
278+
// GetAffectedFiles returns the affected files between two commits
279+
func GetAffectedFiles(oldCommitID, newCommitID string, env []string, repo *Repository) ([]string, error) {
280+
stdoutReader, stdoutWriter, err := os.Pipe()
281+
if err != nil {
282+
log.Error("Unable to create os.Pipe for %s", repo.Path)
283+
return nil, err
284+
}
285+
defer func() {
286+
_ = stdoutReader.Close()
287+
_ = stdoutWriter.Close()
288+
}()
289+
290+
affectedFiles := make([]string, 0, 32)
291+
292+
// Run `git diff --name-only` to get the names of the changed files
293+
err = NewCommand("diff", "--name-only", oldCommitID, newCommitID).
294+
RunInDirTimeoutEnvFullPipelineFunc(env, -1, repo.Path,
295+
stdoutWriter, nil, nil,
296+
func(ctx context.Context, cancel context.CancelFunc) error {
297+
// Close the writer end of the pipe to begin processing
298+
_ = stdoutWriter.Close()
299+
defer func() {
300+
// Close the reader on return to terminate the git command if necessary
301+
_ = stdoutReader.Close()
302+
}()
303+
// Now scan the output from the command
304+
scanner := bufio.NewScanner(stdoutReader)
305+
for scanner.Scan() {
306+
path := strings.TrimSpace(scanner.Text())
307+
if len(path) == 0 {
308+
continue
309+
}
310+
affectedFiles = append(affectedFiles, path)
311+
}
312+
return scanner.Err()
313+
})
314+
if err != nil {
315+
log.Error("Unable to get affected files for commits from %s to %s in %s: %v", oldCommitID, newCommitID, repo.Path, err)
316+
}
317+
318+
return affectedFiles, err
319+
}

modules/repofiles/delete.go

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -56,37 +56,8 @@ func DeleteRepoFile(repo *models.Repository, doer *models.User, opts *DeleteRepo
5656
BranchName: opts.NewBranch,
5757
}
5858
}
59-
} else {
60-
protectedBranch, err := repo.GetBranchProtection(opts.OldBranch)
61-
if err != nil {
62-
return nil, err
63-
}
64-
if protectedBranch != nil {
65-
if !protectedBranch.CanUserPush(doer.ID) {
66-
return nil, models.ErrUserCannotCommit{
67-
UserName: doer.LowerName,
68-
}
69-
}
70-
if protectedBranch.RequireSignedCommits {
71-
_, _, _, err := repo.SignCRUDAction(doer, repo.RepoPath(), opts.OldBranch)
72-
if err != nil {
73-
if !models.IsErrWontSign(err) {
74-
return nil, err
75-
}
76-
return nil, models.ErrUserCannotCommit{
77-
UserName: doer.LowerName,
78-
}
79-
}
80-
}
81-
patterns := protectedBranch.GetProtectedFilePatterns()
82-
for _, pat := range patterns {
83-
if pat.Match(strings.ToLower(opts.TreePath)) {
84-
return nil, models.ErrFilePathProtected{
85-
Path: opts.TreePath,
86-
}
87-
}
88-
}
89-
}
59+
} else if err := VerifyBranchProtection(repo, doer, opts.OldBranch, opts.TreePath); err != nil {
60+
return nil, err
9061
}
9162

9263
// Check that the path given in opts.treeName is valid (not a git path)

modules/repofiles/update.go

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -148,37 +148,8 @@ func CreateOrUpdateRepoFile(repo *models.Repository, doer *models.User, opts *Up
148148
if err != nil && !git.IsErrBranchNotExist(err) {
149149
return nil, err
150150
}
151-
} else {
152-
protectedBranch, err := repo.GetBranchProtection(opts.OldBranch)
153-
if err != nil {
154-
return nil, err
155-
}
156-
if protectedBranch != nil {
157-
if !protectedBranch.CanUserPush(doer.ID) {
158-
return nil, models.ErrUserCannotCommit{
159-
UserName: doer.LowerName,
160-
}
161-
}
162-
if protectedBranch.RequireSignedCommits {
163-
_, _, _, err := repo.SignCRUDAction(doer, repo.RepoPath(), opts.OldBranch)
164-
if err != nil {
165-
if !models.IsErrWontSign(err) {
166-
return nil, err
167-
}
168-
return nil, models.ErrUserCannotCommit{
169-
UserName: doer.LowerName,
170-
}
171-
}
172-
}
173-
patterns := protectedBranch.GetProtectedFilePatterns()
174-
for _, pat := range patterns {
175-
if pat.Match(strings.ToLower(opts.TreePath)) {
176-
return nil, models.ErrFilePathProtected{
177-
Path: opts.TreePath,
178-
}
179-
}
180-
}
181-
}
151+
} else if err := VerifyBranchProtection(repo, doer, opts.OldBranch, opts.TreePath); err != nil {
152+
return nil, err
182153
}
183154

184155
// If FromTreePath is not set, set it to the opts.TreePath
@@ -465,3 +436,43 @@ func CreateOrUpdateRepoFile(repo *models.Repository, doer *models.User, opts *Up
465436
}
466437
return file, nil
467438
}
439+
440+
// VerifyBranchProtection verify the branch protection for modifying the given treePath on the given branch
441+
func VerifyBranchProtection(repo *models.Repository, doer *models.User, branchName string, treePath string) error {
442+
protectedBranch, err := repo.GetBranchProtection(branchName)
443+
if err != nil {
444+
return err
445+
}
446+
if protectedBranch != nil {
447+
isUnprotectedFile := false
448+
glob := protectedBranch.GetUnprotectedFilePatterns()
449+
if len(glob) != 0 {
450+
isUnprotectedFile = protectedBranch.IsUnprotectedFile(glob, treePath)
451+
}
452+
if !protectedBranch.CanUserPush(doer.ID) && !isUnprotectedFile {
453+
return models.ErrUserCannotCommit{
454+
UserName: doer.LowerName,
455+
}
456+
}
457+
if protectedBranch.RequireSignedCommits {
458+
_, _, _, err := repo.SignCRUDAction(doer, repo.RepoPath(), branchName)
459+
if err != nil {
460+
if !models.IsErrWontSign(err) {
461+
return err
462+
}
463+
return models.ErrUserCannotCommit{
464+
UserName: doer.LowerName,
465+
}
466+
}
467+
}
468+
patterns := protectedBranch.GetProtectedFilePatterns()
469+
for _, pat := range patterns {
470+
if pat.Match(strings.ToLower(treePath)) {
471+
return models.ErrFilePathProtected{
472+
Path: treePath,
473+
}
474+
}
475+
}
476+
}
477+
return nil
478+
}

modules/structs/repo_branch.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ type BranchProtection struct {
4444
DismissStaleApprovals bool `json:"dismiss_stale_approvals"`
4545
RequireSignedCommits bool `json:"require_signed_commits"`
4646
ProtectedFilePatterns string `json:"protected_file_patterns"`
47+
UnprotectedFilePatterns string `json:"unprotected_file_patterns"`
4748
// swagger:strfmt date-time
4849
Created time.Time `json:"created_at"`
4950
// swagger:strfmt date-time
@@ -73,6 +74,7 @@ type CreateBranchProtectionOption struct {
7374
DismissStaleApprovals bool `json:"dismiss_stale_approvals"`
7475
RequireSignedCommits bool `json:"require_signed_commits"`
7576
ProtectedFilePatterns string `json:"protected_file_patterns"`
77+
UnprotectedFilePatterns string `json:"unprotected_file_patterns"`
7678
}
7779

7880
// EditBranchProtectionOption options for editing a branch protection
@@ -97,4 +99,5 @@ type EditBranchProtectionOption struct {
9799
DismissStaleApprovals *bool `json:"dismiss_stale_approvals"`
98100
RequireSignedCommits *bool `json:"require_signed_commits"`
99101
ProtectedFilePatterns *string `json:"protected_file_patterns"`
102+
UnprotectedFilePatterns *string `json:"unprotected_file_patterns"`
100103
}

options/locale/locale_en-US.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1908,6 +1908,8 @@ settings.require_signed_commits = Require Signed Commits
19081908
settings.require_signed_commits_desc = Reject pushes to this branch if they are unsigned or unverifiable.
19091909
settings.protect_protected_file_patterns = Protected file patterns (separated using semicolon '\;'):
19101910
settings.protect_protected_file_patterns_desc = Protected files that are not allowed to be changed directly even if user has rights to add, edit, or delete files in this branch. Multiple patterns can be separated using semicolon ('\;'). See <a href="https://pkg.go.dev/github.com/gobwas/glob#Compile">github.com/gobwas/glob</a> documentation for pattern syntax. Examples: <code>.drone.yml</code>, <code>/docs/**/*.txt</code>.
1911+
settings.protect_unprotected_file_patterns = Unprotected file patterns (separated using semicolon '\;'):
1912+
settings.protect_unprotected_file_patterns_desc = Unprotected files that are allowed to be changed directly if user has write access, bypassing push restriction. Multiple patterns can be separated using semicolon ('\;'). See <a href="https://pkg.go.dev/github.com/gobwas/glob#Compile">github.com/gobwas/glob</a> documentation for pattern syntax. Examples: <code>.drone.yml</code>, <code>/docs/**/*.txt</code>.
19111913
settings.add_protected_branch = Enable protection
19121914
settings.delete_protected_branch = Disable protection
19131915
settings.update_protect_branch_success = Branch protection for branch '%s' has been updated.

routers/api/v1/repo/branch.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ func CreateBranchProtection(ctx *context.APIContext) {
498498
DismissStaleApprovals: form.DismissStaleApprovals,
499499
RequireSignedCommits: form.RequireSignedCommits,
500500
ProtectedFilePatterns: form.ProtectedFilePatterns,
501+
UnprotectedFilePatterns: form.UnprotectedFilePatterns,
501502
BlockOnOutdatedBranch: form.BlockOnOutdatedBranch,
502503
}
503504

@@ -643,6 +644,10 @@ func EditBranchProtection(ctx *context.APIContext) {
643644
protectBranch.ProtectedFilePatterns = *form.ProtectedFilePatterns
644645
}
645646

647+
if form.UnprotectedFilePatterns != nil {
648+
protectBranch.UnprotectedFilePatterns = *form.UnprotectedFilePatterns
649+
}
650+
646651
if form.BlockOnOutdatedBranch != nil {
647652
protectBranch.BlockOnOutdatedBranch = *form.BlockOnOutdatedBranch
648653
}

0 commit comments

Comments
 (0)