Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
7acb558
ci(tests): run go test on windows-latest alongside ubuntu
lexfrei Apr 17, 2026
037499a
build(release): package windows binaries as zip
lexfrei Apr 17, 2026
ff4df6f
feat(secureperm): add cross-platform sensitive-file helper
lexfrei Apr 17, 2026
c67a32c
refactor(security): route sensitive writes through secureperm
lexfrei Apr 17, 2026
8c1d621
test(commands): cover backslash template paths on windows
lexfrei Apr 17, 2026
8e220ee
fix(kubeconfig): remove vacuous 'if err == nil' wrapper
lexfrei Apr 17, 2026
8b168fa
refactor(init): add writeSecureToDestination for secrets
lexfrei Apr 17, 2026
6935f77
test(commands): make windows path test drive-independent
lexfrei Apr 17, 2026
8df7209
test(secureperm): assert windows DACL is protected and owner-only
lexfrei Apr 17, 2026
3c20d27
docs(readme): document windows support
lexfrei Apr 17, 2026
5976347
fix(secureperm): downgrade mode when overwriting existing lax file
lexfrei Apr 17, 2026
e97ae14
fix(secureperm): create windows files with protected DACL from the start
lexfrei Apr 17, 2026
1f15b91
fix(init): don't print 'Created' when the write failed
lexfrei Apr 17, 2026
43b7939
fix(secureperm): tighten DACL on overwrite of existing windows file
lexfrei Apr 17, 2026
ba45c9c
test(init): use filepath.Join for OS-portable path assertion
lexfrei Apr 17, 2026
9cb3adb
fix(secureperm): atomic write via tmp + rename preserves original on …
lexfrei Apr 17, 2026
f84e129
fix(template): route --inplace write through secureperm
lexfrei Apr 17, 2026
59dfc5c
docs(secureperm): rewrite package doc to match atomic write strategy
lexfrei Apr 17, 2026
4b7fe08
test(secureperm): preserve-original-on-failure test for windows
lexfrei Apr 17, 2026
7b6a9c8
refactor(init): drop redundant validateFileExists from writeSecretsBu…
lexfrei Apr 17, 2026
3a95079
docs(test): reword apply_windows_test comment per project convention
lexfrei Apr 17, 2026
22d2ce3
test(template): cover backslash -t input on windows end-to-end
lexfrei Apr 17, 2026
45a455c
test(age): pin talm.key mode 0600 on unix
lexfrei Apr 17, 2026
6b1d1f1
docs(readme): narrow windows path-separator claim to -t/--template
lexfrei Apr 17, 2026
c2d4469
fix(tests): adapt tests for windows CI runner
lexfrei Apr 17, 2026
9554f92
fix(commands): tighten outside-root path check to match path element
lexfrei Apr 24, 2026
eb55b62
fix(init): create secrets parent dir with 0o700
lexfrei Apr 24, 2026
54694fb
fix(secureperm): fsync tmp file and parent dir on unix
lexfrei Apr 24, 2026
7565632
test(template): force seed mode independent of umask
lexfrei Apr 24, 2026
9c4d746
test(secureperm): force seed mode independent of umask
lexfrei Apr 24, 2026
7187260
test(commands): pin isOutsideRoot contract directly
lexfrei Apr 27, 2026
570171f
fix(kubeconfig): use isOutsideRoot helper for path classification
lexfrei Apr 27, 2026
e595a1b
fix(secureperm): fsync tmp before rename on Windows
lexfrei Apr 27, 2026
ce4b9fa
docs(secureperm): note owner-change side-effect of tmp+rename
lexfrei Apr 27, 2026
f5b584b
docs(test): warn that os.Chdir tests are not t.Parallel-safe
lexfrei Apr 27, 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 }}
Comment on lines +10 to +14

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.

fail-fast: false is correct, just confirming intent

    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
    runs-on: ${{ matrix.os }}

Reads as "let both runners finish even if one fails." That's the right call for a platform-parity matrix — you want to see Windows-specific failures even if Linux passed (or vice versa). Just calling it out so the PR description's "watch for --- PASS on both runners" claim is verified by behavior, not just name.


Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed — fail-fast: false is intentional precisely so a Windows-specific failure surfaces even when Linux is green (and vice versa). I'll make the behavioural claim explicit in future PR descriptions rather than letting it ride on the reader recognising the strategy block.

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)
}
}
11 changes: 10 additions & 1 deletion pkg/commands/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,15 @@ func wrapWithNodeContext(f func(ctx context.Context, c *client.Client) error) fu
}
}

// isOutsideRoot reports whether a cleaned relative path escapes the
// project root. A HasPrefix(".." ) test would misclassify sibling
// directories whose first path element merely starts with "..", such
// as "..templates/controlplane.yaml"; we match a full path element
// instead.
func isOutsideRoot(relPath string) bool {
return relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator))
}

// resolveTemplatePaths resolves template file paths relative to the project root,
// normalizing them for the Helm engine (forward slashes).
// Relative paths from the modeline are resolved against rootDir, not CWD.
Expand Down Expand Up @@ -461,7 +470,7 @@ func resolveTemplatePaths(templates []string, rootDir string) []string {
continue
}
relPath = filepath.Clean(relPath)
if strings.HasPrefix(relPath, "..") {
if isOutsideRoot(relPath) {
// Path goes outside project root — use original path as-is
resolved[i] = engine.NormalizeTemplatePath(templatePath)
continue
Expand Down
59 changes: 57 additions & 2 deletions pkg/commands/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ func TestResolveTemplatePaths(t *testing.T) {
if err := os.MkdirAll(filepath.Join(tmpRoot, "templates"), 0o755); err != nil {
t.Fatalf("failed to create templates dir: %v", err)
}
// A sibling directory whose name literally starts with "..". A naive
// HasPrefix(relPath, "..") check would misclassify it as outside-root;
// the resolver must treat ".." as a full path element, not a prefix.
if err := os.MkdirAll(filepath.Join(tmpRoot, "..templates"), 0o755); err != nil {
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
Expand Down Expand Up @@ -146,10 +158,23 @@ 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{filepath.ToSlash(absOutside)},
},
{
// Directory name literally starting with "..". If the
// outside-root check used HasPrefix("..") it would wrongly
// drop this path back to the original input.
name: "sibling dir whose name starts with .. is inside rootDir",
templates: []string{"..templates/controlplane.yaml"},
rootDir: tmpRoot,
want: []string{"/other/project/templates/controlplane.yaml"},
want: []string{"..templates/controlplane.yaml"},
},
}

Expand Down Expand Up @@ -715,3 +740,33 @@ machine:
// itself. The modeline round-trip tests in pkg/modeline surface a
// regression that would wire MergeFileAsPatch into generateOutput.
}

// TestIsOutsideRoot pins the contract that distinguishes a path that
// truly escapes the project root (".." or a first element of "..")
// from a path whose first element merely *starts* with ".." but is
// itself a valid sibling-directory name (e.g. "..templates"). The
// distinction matters wherever a caller routes inside-root paths
// differently from outside-root ones; a HasPrefix("..") test
// silently misclassifies the latter as outside-root.
func TestIsOutsideRoot(t *testing.T) {
cases := []struct {
relPath string
want bool
}{
{"..", true},
{".." + string(filepath.Separator) + "foo", true},
{".." + string(filepath.Separator) + "foo" + string(filepath.Separator) + "bar", true},
{"..foo", false},
{"..foo" + string(filepath.Separator) + "bar", false},
{"..templates" + string(filepath.Separator) + "controlplane.yaml", false},
{"..mykube", false},
{"foo", false},
{"foo" + string(filepath.Separator) + "..bar", false},
{".", false},
}
for _, c := range cases {
if got := isOutsideRoot(c.relPath); got != c.want {
t.Errorf("isOutsideRoot(%q) = %v, want %v", c.relPath, got, c.want)
}
}
}
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])
}
}
Loading