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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Fixed
- `mate status` and `mate apply` now detect permission mismatches — files with correct content but wrong permissions show as modified and get fixed on apply
- Bitwarden provider now shows clear error messages when vault is locked or session is invalid instead of "unexpected end of JSON input"
- `mate encrypt` and `mate decrypt` now work with files outside the source tree (e.g. var_files in `.matedata/`)
- Encrypted var_files (`#encrypted` suffix) are now transparently decrypted during template rendering
Expand Down
11 changes: 10 additions & 1 deletion internal/target/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ func (a *Applier) checkChange(entry *source.Entry) (*FileChange, error) {
if targetHash != compareHash {
change.Status = StatusConflict
change.OldHash = targetHash
} else if permMismatch(entry, info) {
change.Status = StatusModified
} else {
// Content matches but no state entry - need to record state
change.Status = StatusStateOnly
Expand Down Expand Up @@ -238,7 +240,11 @@ func (a *Applier) checkChange(entry *source.Entry) (*FileChange, error) {

if existing.SourceHash == sourceHash {
if targetHash == existing.AppliedHash {
change.Status = StatusUnchanged
if permMismatch(entry, info) {
change.Status = StatusModified
} else {
change.Status = StatusUnchanged
}
} else {
change.Status = StatusConflict
}
Expand Down Expand Up @@ -334,6 +340,9 @@ func (a *Applier) applyFile(entry *source.Entry, sourceHash string) error {
if err := os.WriteFile(entry.TargetPath, content, mode); err != nil {
return err
}
if err := os.Chmod(entry.TargetPath, mode); err != nil {
return err
}
}

if entry.Attrs.Owner != "" || entry.Attrs.Group != "" {
Expand Down
142 changes: 142 additions & 0 deletions internal/target/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,145 @@ func TestComputeChanges(t *testing.T) {
t.Errorf("expected StatusNew, got %v", changes[0].Status)
}
}

func TestComputeChanges_PermMismatch(t *testing.T) {
tmpDir := t.TempDir()
sourceDir := filepath.Join(tmpDir, "source")
targetDir := filepath.Join(tmpDir, "target")
dbPath := filepath.Join(tmpDir, "state.db")

_ = os.MkdirAll(filepath.Join(sourceDir, "app"), 0755)
_ = os.MkdirAll(targetDir, 0755)

content := []byte("script content")
srcFile := filepath.Join(sourceDir, "app", "run.sh#perm:755")
if err := os.WriteFile(srcFile, content, 0755); err != nil {
t.Fatal(err)
}

// Target exists with matching content but wrong permissions
targetFile := filepath.Join(targetDir, "run.sh")
if err := os.WriteFile(targetFile, content, 0644); err != nil {
t.Fatal(err)
}

db, err := state.Open(dbPath)
if err != nil {
t.Fatalf("opening db: %v", err)
}
defer func() { _ = db.Close() }()

scanner := source.NewScanner(targetDir, "")
tree, err := scanner.Scan([]string{filepath.Join(sourceDir, "app")})
if err != nil {
t.Fatalf("scanning: %v", err)
}

changes, err := ComputeChanges(tree, db)
if err != nil {
t.Fatalf("ComputeChanges: %v", err)
}

if len(changes) != 1 {
t.Fatalf("expected 1 change, got %d", len(changes))
}

if changes[0].Status != StatusModified {
t.Errorf("expected StatusModified for perm mismatch, got %v", changes[0].Status)
}
}

func TestComputeChanges_PermMatch(t *testing.T) {
tmpDir := t.TempDir()
sourceDir := filepath.Join(tmpDir, "source")
targetDir := filepath.Join(tmpDir, "target")
dbPath := filepath.Join(tmpDir, "state.db")

_ = os.MkdirAll(filepath.Join(sourceDir, "app"), 0755)
_ = os.MkdirAll(targetDir, 0755)

content := []byte("script content")
srcFile := filepath.Join(sourceDir, "app", "run.sh#perm:755")
if err := os.WriteFile(srcFile, content, 0755); err != nil {
t.Fatal(err)
}

// Target with correct permissions — should be unchanged
targetFile := filepath.Join(targetDir, "run.sh")
if err := os.WriteFile(targetFile, content, 0755); err != nil {
t.Fatal(err)
}

db, err := state.Open(dbPath)
if err != nil {
t.Fatalf("opening db: %v", err)
}
defer func() { _ = db.Close() }()

scanner := source.NewScanner(targetDir, "")
tree, err := scanner.Scan([]string{filepath.Join(sourceDir, "app")})
if err != nil {
t.Fatalf("scanning: %v", err)
}

changes, err := ComputeChanges(tree, db)
if err != nil {
t.Fatalf("ComputeChanges: %v", err)
}

if len(changes) != 0 {
t.Errorf("expected 0 changes (perms match), got %d with status %v", len(changes), changes[0].Status)
}
}

func TestApplier_PermFixOnApply(t *testing.T) {
tmpDir := t.TempDir()
sourceDir := filepath.Join(tmpDir, "source")
targetDir := filepath.Join(tmpDir, "target")
dbPath := filepath.Join(tmpDir, "state.db")

_ = os.MkdirAll(filepath.Join(sourceDir, "app"), 0755)
_ = os.MkdirAll(targetDir, 0755)

content := []byte("#!/bin/sh\necho hi")
srcFile := filepath.Join(sourceDir, "app", "run.sh#perm:755")
if err := os.WriteFile(srcFile, content, 0755); err != nil {
t.Fatal(err)
}

// Target exists with same content but 644
targetFile := filepath.Join(targetDir, "run.sh")
if err := os.WriteFile(targetFile, content, 0644); err != nil {
t.Fatal(err)
}

db, err := state.Open(dbPath)
if err != nil {
t.Fatalf("opening db: %v", err)
}
defer func() { _ = db.Close() }()

scanner := source.NewScanner(targetDir, "")
tree, err := scanner.Scan([]string{filepath.Join(sourceDir, "app")})
if err != nil {
t.Fatalf("scanning: %v", err)
}

applier := NewApplier(db, nil, nil, false, false, 0)
result, err := applier.Apply(tree)
if err != nil {
t.Fatalf("apply failed: %v", err)
}

if result.Applied != 1 {
t.Errorf("expected 1 applied (perm fix), got %d applied, %d skipped", result.Applied, result.Skipped)
}

info, err := os.Stat(targetFile)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0755 {
t.Errorf("expected 0755, got %o", info.Mode().Perm())
}
}
22 changes: 21 additions & 1 deletion internal/target/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ type Change struct {
NewHash string
}

func desiredMode(entry *source.Entry) os.FileMode {
if entry.Attrs.Perm != 0 {
return os.FileMode(entry.Attrs.Perm)
}
return entry.Mode.Perm()
}

func permMismatch(entry *source.Entry, info os.FileInfo) bool {
if entry.Attrs.Perm == 0 {
return false
}
return info.Mode().Perm() != desiredMode(entry)
}

func ComputeChanges(tree *source.Tree, db *state.DB) ([]*Change, error) {
var changes []*Change

Expand Down Expand Up @@ -76,6 +90,8 @@ func computeChange(entry *source.Entry, db *state.DB) (*Change, error) {
if targetHash != sourceHash {
change.Status = StatusConflict
change.OldHash = targetHash
} else if permMismatch(entry, info) {
change.Status = StatusModified
} else {
change.Status = StatusUnchanged
}
Expand Down Expand Up @@ -110,7 +126,11 @@ func computeChange(entry *source.Entry, db *state.DB) (*Change, error) {

if existing.SourceHash == sourceHash {
if targetHash == existing.AppliedHash {
change.Status = StatusUnchanged
if permMismatch(entry, info) {
change.Status = StatusModified
} else {
change.Status = StatusUnchanged
}
} else {
change.Status = StatusConflict
}
Expand Down
Loading