diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 128daa09..144d86c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,3 +51,25 @@ jobs: run: |- go version go test -ldflags '-extldflags "-static"' -tags static,system_libgit2 -v ./... + + cross-compile: + name: Cross Compile (windows/amd64) + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4.3.1 + + - name: Install Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Build and vet + env: + GOOS: windows + GOARCH: amd64 + CGO_ENABLED: "0" + run: |- + go build ./... + go vet ./... diff --git a/cmd/login.go b/cmd/login.go index 0bd82dec..336cb5cc 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -19,8 +19,8 @@ package cmd import ( "context" "fmt" + "os" "strings" - "syscall" "golang.org/x/crypto/ssh/terminal" @@ -84,7 +84,7 @@ func runLogin(ctx context.Context, registry string) error { } } else if loginConfig.PasswordStdin && loginConfig.Password == "" { fmt.Print("Enter password: ") - password, err := terminal.ReadPassword(syscall.Stdin) + password, err := terminal.ReadPassword(int(os.Stdin.Fd())) if err != nil { return err } diff --git a/pkg/backend/build/builder.go b/pkg/backend/build/builder.go index 51fdcbb0..12f5c3d0 100644 --- a/pkg/backend/build/builder.go +++ b/pkg/backend/build/builder.go @@ -25,7 +25,6 @@ import ( "io" "os" "path/filepath" - "syscall" "time" sha256 "github.com/minio/sha256-simd" @@ -417,10 +416,7 @@ func getFileMetadata(path string) (modelspec.FileMetadata, error) { } // UID and GID (Unix-specific). - if stat, ok := info.Sys().(*syscall.Stat_t); ok { - metadata.Uid = stat.Uid - metadata.Gid = stat.Gid - } + metadata.Uid, metadata.Gid = fileOwner(info) return metadata, nil } diff --git a/pkg/backend/build/builder_test.go b/pkg/backend/build/builder_test.go index 9d43727b..57518370 100644 --- a/pkg/backend/build/builder_test.go +++ b/pkg/backend/build/builder_test.go @@ -22,9 +22,7 @@ import ( "io" "os" "path/filepath" - "runtime" "strings" - "syscall" "testing" "time" @@ -499,18 +497,10 @@ func TestGetFileMetadata(t *testing.T) { assert.Equal(t, byte(0), metadata.Typeflag, "Typeflag should be 0 for regular file") assert.WithinDuration(t, fileInfo.ModTime(), metadata.ModTime, time.Second) - // Check UID/GID only on Unix-like systems - if stat, ok := fileInfo.Sys().(*syscall.Stat_t); ok { - assert.Equal(t, stat.Uid, metadata.Uid, "UID mismatch") - assert.Equal(t, stat.Gid, metadata.Gid, "GID mismatch") - } else if runtime.GOOS != "windows" { - // If not windows and not syscall.Stat_t, something is unexpected - t.Logf("Warning: Could not get syscall.Stat_t on non-Windows OS (%s)", runtime.GOOS) - } else { - // On Windows, expect 0 as syscall.Stat_t assertion fails - assert.Equal(t, uint32(0), metadata.Uid, "UID should be 0 on Windows") - assert.Equal(t, uint32(0), metadata.Gid, "GID should be 0 on Windows") - } + // UID and GID are only available on Unix-like systems and are 0 elsewhere. + wantUID, wantGID := expectedFileOwner(t, fileInfo) + assert.Equal(t, wantUID, metadata.Uid, "UID mismatch") + assert.Equal(t, wantGID, metadata.Gid, "GID mismatch") }) // --- Test Case 2: Directory --- @@ -528,15 +518,9 @@ func TestGetFileMetadata(t *testing.T) { assert.Equal(t, byte(5), metadata.Typeflag, "Typeflag should be 5 for directory") assert.WithinDuration(t, dirInfo.ModTime(), metadata.ModTime, time.Second) - // Check UID/GID only on Unix-like systems - if stat, ok := dirInfo.Sys().(*syscall.Stat_t); ok { - assert.Equal(t, stat.Uid, metadata.Uid, "UID mismatch") - assert.Equal(t, stat.Gid, metadata.Gid, "GID mismatch") - } else if runtime.GOOS != "windows" { - t.Logf("Warning: Could not get syscall.Stat_t on non-Windows OS (%s)", runtime.GOOS) - } else { - assert.Equal(t, uint32(0), metadata.Uid, "UID should be 0 on Windows") - assert.Equal(t, uint32(0), metadata.Gid, "GID should be 0 on Windows") - } + // UID and GID are only available on Unix-like systems and are 0 elsewhere. + wantUID, wantGID := expectedFileOwner(t, dirInfo) + assert.Equal(t, wantUID, metadata.Uid, "UID mismatch") + assert.Equal(t, wantGID, metadata.Gid, "GID mismatch") }) } diff --git a/pkg/version/platform_darwin.go b/pkg/backend/build/owner_other.go similarity index 70% rename from pkg/version/platform_darwin.go rename to pkg/backend/build/owner_other.go index e5179596..088bd7db 100644 --- a/pkg/version/platform_darwin.go +++ b/pkg/backend/build/owner_other.go @@ -1,5 +1,7 @@ +//go:build !unix + /* - * Copyright 2025 The ModelPack Authors + * Copyright 2026 The ModelPack Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +16,11 @@ * limitations under the License. */ -package version +package build + +import "os" -const platform = "darwin" +// fileOwner returns zero IDs because the platform has no numeric file owners. +func fileOwner(info os.FileInfo) (uid, gid uint32) { + return 0, 0 +} diff --git a/pkg/version/platform_linux.go b/pkg/backend/build/owner_other_test.go similarity index 65% rename from pkg/version/platform_linux.go rename to pkg/backend/build/owner_other_test.go index 31824832..3fa941ca 100644 --- a/pkg/version/platform_linux.go +++ b/pkg/backend/build/owner_other_test.go @@ -1,5 +1,7 @@ +//go:build !unix + /* - * Copyright 2025 The ModelPack Authors + * Copyright 2026 The ModelPack Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +16,17 @@ * limitations under the License. */ -package version +package build + +import ( + "os" + "testing" +) + +// expectedFileOwner returns zero IDs because the platform has no numeric file +// owners. +func expectedFileOwner(t *testing.T, info os.FileInfo) (uid, gid uint32) { + t.Helper() -const platform = "linux" + return 0, 0 +} diff --git a/pkg/backend/build/owner_unix.go b/pkg/backend/build/owner_unix.go new file mode 100644 index 00000000..e1c7752e --- /dev/null +++ b/pkg/backend/build/owner_unix.go @@ -0,0 +1,33 @@ +//go:build unix + +/* + * Copyright 2026 The ModelPack 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 build + +import ( + "os" + "syscall" +) + +// fileOwner returns the numeric user and group IDs that own the file. +func fileOwner(info os.FileInfo) (uid, gid uint32) { + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + return stat.Uid, stat.Gid + } + + return 0, 0 +} diff --git a/pkg/backend/build/owner_unix_test.go b/pkg/backend/build/owner_unix_test.go new file mode 100644 index 00000000..4292a8cf --- /dev/null +++ b/pkg/backend/build/owner_unix_test.go @@ -0,0 +1,37 @@ +//go:build unix + +/* + * Copyright 2026 The ModelPack 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 build + +import ( + "os" + "syscall" + "testing" + + "github.com/stretchr/testify/require" +) + +// expectedFileOwner returns the numeric owner IDs reported by the OS. +func expectedFileOwner(t *testing.T, info os.FileInfo) (uid, gid uint32) { + t.Helper() + + stat, ok := info.Sys().(*syscall.Stat_t) + require.True(t, ok, "expected *syscall.Stat_t, got %T", info.Sys()) + + return stat.Uid, stat.Gid +} diff --git a/pkg/diskspace/check.go b/pkg/diskspace/check.go index 633c7ac5..3ac7baa7 100644 --- a/pkg/diskspace/check.go +++ b/pkg/diskspace/check.go @@ -1,3 +1,5 @@ +//go:build linux || darwin || windows + /* * Copyright 2025 The CNAI Authors * @@ -14,17 +16,12 @@ * limitations under the License. */ -//go:build linux || darwin - package diskspace import ( "fmt" - "math" "os" "path/filepath" - - "golang.org/x/sys/unix" ) const ( @@ -42,7 +39,7 @@ func Check(dir string, requiredBytes int64) error { return nil } - // Ensure the directory exists for statfs; walk up to find an existing parent. + // Ensure the directory exists for the disk space query; walk up to find an existing parent. checkDir := dir for { if _, err := os.Stat(checkDir); err == nil { @@ -56,22 +53,11 @@ func Check(dir string, requiredBytes int64) error { checkDir = parent } - var stat unix.Statfs_t - if err := unix.Statfs(checkDir, &stat); err != nil { + availableBytes, err := availableSpace(checkDir) + if err != nil { return fmt.Errorf("failed to check disk space for %s: %w", dir, err) } - // Available space for non-root users. - // Guard against overflow: on Linux Bavail is uint64, and values exceeding - // math.MaxInt64 would wrap negative when cast to int64. Cap at MaxInt64. - bavail := stat.Bavail - bsize := uint64(stat.Bsize) - var availableBytes int64 - if bavail > 0 && bsize > uint64(math.MaxInt64)/bavail { - availableBytes = math.MaxInt64 - } else { - availableBytes = int64(bavail * bsize) - } requiredWithMargin := int64(float64(requiredBytes) * safetyMargin) if availableBytes < requiredWithMargin { diff --git a/pkg/diskspace/check_other.go b/pkg/diskspace/check_other.go index 7b3112e2..48f02029 100644 --- a/pkg/diskspace/check_other.go +++ b/pkg/diskspace/check_other.go @@ -1,3 +1,5 @@ +//go:build !linux && !darwin && !windows + /* * Copyright 2025 The CNAI Authors * @@ -14,16 +16,13 @@ * limitations under the License. */ -//go:build !linux && !darwin - package diskspace import "github.com/sirupsen/logrus" // Check is a no-op stub for platforms that do not yet have a disk-space // implementation. It always returns nil so callers can compile and run on -// Windows and other non-Unix systems, at the cost of losing the pre-check -// warning. Native Windows support is tracked upstream. +// those systems, at the cost of losing the pre-check warning. func Check(dir string, requiredBytes int64) error { logrus.Debugf("diskspace: pre-check skipped on this platform (dir=%s, required=%d)", dir, requiredBytes) return nil diff --git a/pkg/diskspace/check_test.go b/pkg/diskspace/check_test.go index df86b0ae..897f4526 100644 --- a/pkg/diskspace/check_test.go +++ b/pkg/diskspace/check_test.go @@ -1,3 +1,5 @@ +//go:build linux || darwin || windows + /* * Copyright 2025 The CNAI Authors * @@ -14,8 +16,6 @@ * limitations under the License. */ -//go:build linux || darwin - package diskspace import ( diff --git a/pkg/diskspace/space_unix.go b/pkg/diskspace/space_unix.go new file mode 100644 index 00000000..dbf65688 --- /dev/null +++ b/pkg/diskspace/space_unix.go @@ -0,0 +1,44 @@ +//go:build linux || darwin + +/* + * Copyright 2026 The ModelPack 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 diskspace + +import ( + "math" + + "golang.org/x/sys/unix" +) + +// availableSpace returns the number of bytes available to non-root users on +// the filesystem containing dir. +func availableSpace(dir string) (int64, error) { + var stat unix.Statfs_t + if err := unix.Statfs(dir, &stat); err != nil { + return 0, err + } + + // Guard against overflow: on Linux Bavail is uint64, and values exceeding + // math.MaxInt64 would wrap negative when cast to int64. Cap at MaxInt64. + bavail := stat.Bavail + bsize := uint64(stat.Bsize) + if bavail > 0 && bsize > uint64(math.MaxInt64)/bavail { + return math.MaxInt64, nil + } + + return int64(bavail * bsize), nil +} diff --git a/pkg/diskspace/space_windows.go b/pkg/diskspace/space_windows.go new file mode 100644 index 00000000..4f6fcb8e --- /dev/null +++ b/pkg/diskspace/space_windows.go @@ -0,0 +1,46 @@ +//go:build windows + +/* + * Copyright 2026 The ModelPack 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 diskspace + +import ( + "math" + + "golang.org/x/sys/windows" +) + +// availableSpace returns the number of bytes available to the calling user on +// the volume containing dir, taking per-user disk quotas into account. +func availableSpace(dir string) (int64, error) { + path, err := windows.UTF16PtrFromString(dir) + if err != nil { + return 0, err + } + + var freeBytesAvailable uint64 + if err := windows.GetDiskFreeSpaceEx(path, &freeBytesAvailable, nil, nil); err != nil { + return 0, err + } + + // Cap at MaxInt64 to avoid wrapping negative when cast to int64. + if freeBytesAvailable > math.MaxInt64 { + return math.MaxInt64, nil + } + + return int64(freeBytesAvailable), nil +} diff --git a/pkg/version/version.go b/pkg/version/version.go index 62516e3a..ba63beb4 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -16,9 +16,11 @@ package version +import "runtime" + var ( GitVersion = "v0.0.1" GitCommit = "unknown" - Platform = platform + Platform = runtime.GOOS BuildTime = "unknown" ) diff --git a/pkg/xattr/xattr.go b/pkg/xattr/xattr.go index c1ad85a8..1ddbd5ed 100644 --- a/pkg/xattr/xattr.go +++ b/pkg/xattr/xattr.go @@ -16,11 +16,7 @@ package xattr -import ( - "strings" - - "golang.org/x/sys/unix" -) +import "strings" const ( // Prefix for all xattr keys to ensure compatibility across platforms. @@ -33,28 +29,6 @@ const ( KeySha256 = "modctl.sha256" ) -// Get retrieves an xattr value for a given key. -func Get(path, key string) ([]byte, error) { - var value []byte - sz, err := unix.Getxattr(path, key, value) - if err != nil { - return nil, err - } - - value = make([]byte, sz) - _, err = unix.Getxattr(path, key, value) - if err != nil { - return nil, err - } - - return value, nil -} - -// Set sets an xattr value for a given key. -func Set(path, key string, value []byte) error { - return unix.Setxattr(path, key, value, 0) -} - // MakeKey creates a fully-qualified xattr key with the user prefix. func MakeKey(parts ...string) string { return Prefix + strings.Join(parts, ".") diff --git a/pkg/xattr/xattr_other.go b/pkg/xattr/xattr_other.go new file mode 100644 index 00000000..409823fd --- /dev/null +++ b/pkg/xattr/xattr_other.go @@ -0,0 +1,39 @@ +//go:build !linux && !darwin + +/* + * Copyright 2026 The ModelPack 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 xattr + +import ( + "errors" + "fmt" +) + +// errNotSupported is returned on platforms without extended attribute support. +var errNotSupported = fmt.Errorf("xattr: %w on this platform", errors.ErrUnsupported) + +// Get always fails on this platform because extended attributes are not +// supported. Callers treat the error as a cache miss. +func Get(path, key string) ([]byte, error) { + return nil, errNotSupported +} + +// Set always fails on this platform because extended attributes are not +// supported. Callers treat the error as a best-effort failure. +func Set(path, key string, value []byte) error { + return errNotSupported +} diff --git a/pkg/xattr/xattr_unix.go b/pkg/xattr/xattr_unix.go new file mode 100644 index 00000000..9c5f5f92 --- /dev/null +++ b/pkg/xattr/xattr_unix.go @@ -0,0 +1,43 @@ +//go:build linux || darwin + +/* + * Copyright 2026 The ModelPack 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 xattr + +import "golang.org/x/sys/unix" + +// Get retrieves an xattr value for a given key. +func Get(path, key string) ([]byte, error) { + var value []byte + sz, err := unix.Getxattr(path, key, value) + if err != nil { + return nil, err + } + + value = make([]byte, sz) + _, err = unix.Getxattr(path, key, value) + if err != nil { + return nil, err + } + + return value, nil +} + +// Set sets an xattr value for a given key. +func Set(path, key string, value []byte) error { + return unix.Setxattr(path, key, value, 0) +}