Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3047443
ci(tests): run go test on windows-latest alongside ubuntu
lexfrei Apr 17, 2026
ba12fe9
build(release): package windows binaries as zip
lexfrei Apr 17, 2026
791556f
feat(secureperm): add cross-platform sensitive-file helper
lexfrei Apr 17, 2026
aecd9de
refactor(security): route sensitive writes through secureperm
lexfrei Apr 17, 2026
83a30a6
test(commands): cover backslash template paths on windows
lexfrei Apr 17, 2026
e739fea
fix(kubeconfig): remove vacuous 'if err == nil' wrapper
lexfrei Apr 17, 2026
a2d649b
refactor(init): add writeSecureToDestination for secrets
lexfrei Apr 17, 2026
a67fd39
test(commands): make windows path test drive-independent
lexfrei Apr 17, 2026
1d8c122
test(secureperm): assert windows DACL is protected and owner-only
lexfrei Apr 17, 2026
3052812
docs(readme): document windows support
lexfrei Apr 17, 2026
d39564b
fix(secureperm): downgrade mode when overwriting existing lax file
lexfrei Apr 17, 2026
bcdc03e
fix(secureperm): create windows files with protected DACL from the start
lexfrei Apr 17, 2026
0d56640
fix(init): don't print 'Created' when the write failed
lexfrei Apr 17, 2026
dd7fafc
fix(secureperm): tighten DACL on overwrite of existing windows file
lexfrei Apr 17, 2026
22acf5c
test(init): use filepath.Join for OS-portable path assertion
lexfrei Apr 17, 2026
1242217
fix(secureperm): atomic write via tmp + rename preserves original on …
lexfrei Apr 17, 2026
74f7c94
fix(template): route --inplace write through secureperm
lexfrei Apr 17, 2026
fe20b3c
docs(secureperm): rewrite package doc to match atomic write strategy
lexfrei Apr 17, 2026
c0f3ac4
test(secureperm): preserve-original-on-failure test for windows
lexfrei Apr 17, 2026
2b34d06
refactor(init): drop redundant validateFileExists from writeSecretsBu…
lexfrei Apr 17, 2026
e6244cd
docs(test): reword apply_windows_test comment per project convention
lexfrei Apr 17, 2026
fcddba7
test(template): cover backslash -t input on windows end-to-end
lexfrei Apr 17, 2026
6d39264
test(age): pin talm.key mode 0600 on unix
lexfrei Apr 17, 2026
8d4d0a0
docs(readme): narrow windows path-separator claim to -t/--template
lexfrei Apr 17, 2026
855b13b
fix(tests): adapt tests for windows CI runner
lexfrei Apr 17, 2026
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
6 changes: 5 additions & 1 deletion .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ on:

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v6
Expand Down
3 changes: 3 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ archives:
{{- if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
format_overrides:
- goos: windows
formats: [zip]

checksum:
name_template: "{{ .ProjectName }}-checksums.txt"
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ Or use simple script to install it:
curl -sSL https://github.com/cozystack/talm/raw/refs/heads/main/hack/install.sh | sh -s
```

### Windows

Windows is supported. Download the `talm-windows-*.zip` archive from the
[releases page](https://github.com/cozystack/talm/releases/latest) and
extract `talm.exe`. On Windows, template paths passed to the `-t` /
`--template` flag accept either `\` or `/` separators, so
`-t templates\controlplane.yaml` and `-t templates/controlplane.yaml`
are equivalent. Other path flags (`--talosconfig`, `-f` / `--file`)
are delegated to the underlying OS file loader and follow standard
Windows path rules.

## Getting Started

Create new project
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ require (
golang.org/x/net v0.53.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/sys v0.43.0
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect
Expand Down
8 changes: 5 additions & 3 deletions pkg/age/age.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import (

"filippo.io/age"
"gopkg.in/yaml.v3"

"github.com/cozystack/talm/pkg/secureperm"
)

const (
Expand Down Expand Up @@ -65,7 +67,7 @@ func GenerateKey(rootDir string) (*age.X25519Identity, bool, error) {
keyData += fmt.Sprintf("# public key: %s\n", publicKey)
keyData += identity.String() + "\n"

if err := os.WriteFile(keyFile, []byte(keyData), 0o600); err != nil {
if err := secureperm.WriteFile(keyFile, []byte(keyData)); err != nil {
return nil, false, fmt.Errorf("failed to write key file: %w", err)
}

Expand Down Expand Up @@ -263,7 +265,7 @@ func DecryptSecretsFile(rootDir string) error {
}

// Write decrypted file with secure permissions
if err := os.WriteFile(secretsFile, decryptedData, 0o600); err != nil {
if err := secureperm.WriteFile(secretsFile, decryptedData); err != nil {
return fmt.Errorf("failed to write decrypted file: %w", err)
}

Expand Down Expand Up @@ -652,7 +654,7 @@ func DecryptYAMLFile(rootDir, encryptedFile, plainFile string) error {
}

// Write decrypted file with secure permissions
if err := os.WriteFile(plainFilePath, decryptedData, 0o600); err != nil {
if err := secureperm.WriteFile(plainFilePath, decryptedData); err != nil {
return fmt.Errorf("failed to write decrypted file: %w", err)
}

Expand Down
54 changes: 54 additions & 0 deletions pkg/age/age_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//go:build !windows

// Copyright Cozystack Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package age_test

import (
"os"
"path/filepath"
"testing"

"github.com/cozystack/talm/pkg/age"
)

// TestGenerateKey_Mode0600_Unix pins that the age private key file
// is written with owner-only permissions. The file contains the raw
// X25519 private key that protects every encrypted secret in the
// project — if a future refactor ever swaps secureperm.WriteFile
// back to os.WriteFile with a different mode, this test fails.
func TestGenerateKey_Mode0600_Unix(t *testing.T) {
dir := t.TempDir()

identity, created, err := age.GenerateKey(dir)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
if !created {
t.Fatal("expected GenerateKey to create a new key in an empty dir")
}
if identity == nil {
t.Fatal("nil identity from GenerateKey")
}

keyPath := filepath.Join(dir, "talm.key")
info, err := os.Stat(keyPath)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Errorf("talm.key mode = %o, want 0600", got)
}
}
14 changes: 12 additions & 2 deletions pkg/commands/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ func TestResolveTemplatePaths(t *testing.T) {
t.Fatalf("failed to create templates dir: %v", err)
}

// Build a platform-portable absolute path outside tmpRoot.
// filepath.VolumeName is "" on POSIX (yielding e.g. "/other/...") and
// "C:" on Windows (yielding "C:\other\..."). Both are absolute and
// definitely outside tmpRoot (which lives under the user temp dir).
absOutside := filepath.Join(filepath.VolumeName(tmpRoot), string(filepath.Separator), "other", "project", "templates", "controlplane.yaml")

tests := []struct {
name string
templates []string
Expand Down Expand Up @@ -127,10 +133,14 @@ func TestResolveTemplatePaths(t *testing.T) {
want: []string{"templates/controlplane.yaml"},
},
{
// Constructed to be absolute on both POSIX and Windows so the
// filepath.IsAbs branch is exercised on both CI runners. The
// resolver normalizes outside-root paths via filepath.ToSlash,
// so the expected output is the forward-slash form.
name: "path outside rootDir is kept as-is",
templates: []string{"/other/project/templates/controlplane.yaml"},
templates: []string{absOutside},
rootDir: tmpRoot,
want: []string{"/other/project/templates/controlplane.yaml"},
want: []string{filepath.ToSlash(absOutside)},
},
}

Expand Down
97 changes: 97 additions & 0 deletions pkg/commands/apply_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//go:build windows

// Copyright Cozystack Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package commands

import (
"path/filepath"
"strings"
"testing"
)

// TestResolveTemplatePaths_BackslashInput pins that users running
// `talm apply` from PowerShell with template arguments that use
// backslash separators (e.g. "templates\worker.yaml") end up with
// forward-slash paths. The downstream helm engine only looks up
// templates by forward-slash map keys, so anything else fails with
// "template not found".
func TestResolveTemplatePaths_BackslashInput(t *testing.T) {
rootDir := t.TempDir()
absRoot, err := filepath.Abs(rootDir)
if err != nil {
t.Fatalf("abs root: %v", err)
}

tests := []struct {
name string
input string
want string
}{
{
name: "relative with backslash",
input: `templates\controlplane.yaml`,
want: "templates/controlplane.yaml",
},
{
name: "relative nested backslashes",
input: `templates\nested\worker.yaml`,
want: "templates/nested/worker.yaml",
},
{
name: "mixed separators",
input: `templates\nested/worker.yaml`,
want: "templates/nested/worker.yaml",
},
{
name: "absolute path inside root",
input: filepath.Join(absRoot, "templates", "controlplane.yaml"),
want: "templates/controlplane.yaml",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveTemplatePaths([]string{tt.input}, rootDir)
if len(got) != 1 {
t.Fatalf("expected 1 result, got %d", len(got))
}
if got[0] != tt.want {
t.Errorf("resolveTemplatePaths(%q) = %q, want %q", tt.input, got[0], tt.want)
}
})
}
}

// TestResolveTemplatePaths_OutsideRoot_Backslash asserts that a
// backslash path resolving outside rootDir still emerges without any
// backslashes — the helm engine only looks up templates by forward-
// slash map keys, so regardless of which internal branch the function
// takes (Rel-success, Rel-failure, prefix-checks), the result must be
// backslash-free. Constructing `outside` via filepath.Join on rootDir
// keeps the test on the same drive as t.TempDir() and works on any
// GitHub Actions runner image.
func TestResolveTemplatePaths_OutsideRoot_Backslash(t *testing.T) {
rootDir := t.TempDir()
outside := filepath.Join(rootDir, "..", "..", "..", "elsewhere", "templates", "foo.yaml")

got := resolveTemplatePaths([]string{outside}, rootDir)
if len(got) != 1 {
t.Fatalf("expected 1 result, got %d", len(got))
}
if strings.ContainsRune(got[0], '\\') {
t.Errorf("result still contains backslash: %q", got[0])
}
}
40 changes: 33 additions & 7 deletions pkg/commands/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package commands
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"slices"
Expand All @@ -25,6 +26,7 @@ import (

"github.com/cozystack/talm/pkg/age"
"github.com/cozystack/talm/pkg/generated"
"github.com/cozystack/talm/pkg/secureperm"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"

Expand Down Expand Up @@ -344,7 +346,7 @@ var initCmd = &cobra.Command{
return fmt.Errorf("failed to marshal config: %+v", err)
}

if err = writeToDestination(data, talosconfigFile, 0o600); err != nil {
if err = writeSecureToDestination(data, talosconfigFile); err != nil {
return err
}
}
Expand Down Expand Up @@ -454,11 +456,9 @@ func writeSecretsBundleToFile(bundle *secrets.Bundle) error {
}

secretsFile := filepath.Join(Config.RootDir, "secrets.yaml")
if err = validateFileExists(secretsFile); err != nil {
return err
}

return writeToDestination(bundleBytes, secretsFile, 0o600)
// validateFileExists is invoked inside writeSecureToDestination;
// no need to duplicate the --force / existing-file gate here.
return writeSecureToDestination(bundleBytes, secretsFile)
}

// readChartYamlPreset reads Chart.yaml and determines the preset name from dependencies
Expand Down Expand Up @@ -861,6 +861,10 @@ func handleTalosconfigEncryption(requireKeyForDecrypt bool) (bool, error) {
return keyWasCreated, nil
}

// createdSink is where "Created <path>" messages go after a successful
// write. Swappable in tests to assert no message is emitted on failure.
var createdSink io.Writer = os.Stderr

func writeToDestination(data []byte, destination string, permissions os.FileMode) error {
if err := validateFileExists(destination); err != nil {
return err
Expand All @@ -874,8 +878,30 @@ func writeToDestination(data []byte, destination string, permissions os.FileMode
}

err := os.WriteFile(destination, data, permissions)
if err == nil {
_, _ = fmt.Fprintf(createdSink, "Created %s\n", destination)
}
return err
}

fmt.Fprintf(os.Stderr, "Created %s\n", destination)
// writeSecureToDestination writes a secret (talosconfig, secrets.yaml,
// talm.key) with owner-only permissions. On Windows the NTFS DACL is
// installed via secureperm so os.WriteFile's ignored mode bits aren't
// the only defense.
func writeSecureToDestination(data []byte, destination string) error {
if err := validateFileExists(destination); err != nil {
return err
}

parentDir := filepath.Dir(destination)

if err := os.MkdirAll(parentDir, os.ModePerm); err != nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Since writeSecureToDestination is specifically designed for sensitive files (like talm.key and secrets.yaml), the parent directory should be created with restrictive permissions (0700) on Unix-like systems. Using os.ModePerm (0777) relies entirely on the system umask and may leave the directory world-readable or world-writable in some environments, which is inconsistent with the goal of this security-focused helper.

Suggested change
if err := os.MkdirAll(parentDir, os.ModePerm); err != nil {
if err := os.MkdirAll(parentDir, 0700); err != nil {

return fmt.Errorf("failed to create output dir: %w", err)
}

err := secureperm.WriteFile(destination, data)
if err == nil {
_, _ = fmt.Fprintf(createdSink, "Created %s\n", destination)
}
return err
}
Loading