From 27c29a578e152123f195ecae5e8cc079b58855ce Mon Sep 17 00:00:00 2001 From: Dawei Wei Date: Wed, 17 Jun 2026 17:12:08 -0700 Subject: [PATCH] Add WCOW support for RUN --mount=type=secret Secret mounts previously failed on Windows with invalid windows mount type: 'tmpfs' because the secret mount setup hardcoded a tmpfs scratch dir, which the Windows containerd mount layer rejects (only 'windows-layer' is allowed). Split secretMountInstance.Mount() into platform files. The Unix path is unchanged. The Windows path writes the secret to a temp file and returns a single-file, read-only bind mount (no tmpfs), and applies a protected DACL granting access only to SYSTEM, Administrators and the daemon account - the Windows analog of the Unix chmod(0600). The dockerfile frontend now requires an explicit target on Windows, as there is no POSIX-style /run/secrets default. UID/GID/mode are unsupported on Windows and ignored. Adds unit and integration tests and documents the Windows behavior in the Dockerfile reference. Signed-off-by: Dawei Wei --- .../dockerfile2llb/convert_secrets.go | 28 ++++- .../dockerfile2llb/convert_secrets_test.go | 75 +++++++++++ .../dockerfile/dockerfile_outline_test.go | 4 +- .../dockerfile/dockerfile_secrets_test.go | 58 ++++++++- frontend/dockerfile/docs/reference.md | 17 +++ solver/llbsolver/mounts/mount.go | 81 ------------ solver/llbsolver/mounts/secretmount_unix.go | 91 ++++++++++++++ .../llbsolver/mounts/secretmount_windows.go | 116 ++++++++++++++++++ .../mounts/secretmount_windows_test.go | 76 ++++++++++++ 9 files changed, 453 insertions(+), 93 deletions(-) create mode 100644 frontend/dockerfile/dockerfile2llb/convert_secrets_test.go create mode 100644 solver/llbsolver/mounts/secretmount_unix.go create mode 100644 solver/llbsolver/mounts/secretmount_windows.go create mode 100644 solver/llbsolver/mounts/secretmount_windows_test.go diff --git a/frontend/dockerfile/dockerfile2llb/convert_secrets.go b/frontend/dockerfile/dockerfile2llb/convert_secrets.go index 66e28e93c517..0d8f509d557a 100644 --- a/frontend/dockerfile/dockerfile2llb/convert_secrets.go +++ b/frontend/dockerfile/dockerfile2llb/convert_secrets.go @@ -7,30 +7,48 @@ import ( "github.com/moby/buildkit/frontend/dockerfile/instructions" "github.com/moby/buildkit/frontend/dockerfile/parser" "github.com/moby/buildkit/frontend/dockerfile/shell" + "github.com/moby/buildkit/util/system" "github.com/pkg/errors" ) func dispatchSecret(d *dispatchState, m *instructions.Mount, loc []parser.Range) (llb.RunOption, error) { + isWindows := d.platform != nil && d.platform.OS == "windows" + targetPath := m.Target + if isWindows { + // Normalize backslashes so C:\path\to\secret resolves to an absolute path. + targetPath = system.ToSlash(targetPath, "windows") + } + id := m.CacheID if m.Source != "" { id = m.Source } if id == "" { - if m.Target == "" { + if targetPath == "" { return nil, errors.New("one of source, target required") } - id = path.Base(m.Target) + id = path.Base(targetPath) + } + + // Reject a non-absolute target (e.g. drive-relative "C:secret.txt") that would + // otherwise mount to an unexpected path. + if isWindows && targetPath != "" && !system.IsAbs(targetPath, "windows") { + return nil, errors.Errorf("secret target %q must be an absolute path with forward slashes on Windows, e.g. --mount=type=secret,id=%s,target=C:/path/to/secret", targetPath, id) } var target *string - if m.Target != "" { - target = &m.Target + if targetPath != "" { + target = &targetPath } if m.Env == nil { - dest := m.Target + dest := targetPath if dest == "" { + // Windows has no default secret location like POSIX /run/secrets. + if isWindows { + return nil, errors.Errorf("secret target is required on Windows, e.g. --mount=type=secret,id=%s,target=C:/path/to/secret", id) + } dest = "/run/secrets/" + path.Base(id) } target = &dest diff --git a/frontend/dockerfile/dockerfile2llb/convert_secrets_test.go b/frontend/dockerfile/dockerfile2llb/convert_secrets_test.go new file mode 100644 index 000000000000..d589b3e8cd7d --- /dev/null +++ b/frontend/dockerfile/dockerfile2llb/convert_secrets_test.go @@ -0,0 +1,75 @@ +package dockerfile2llb + +import ( + "testing" + + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/frontend/dockerfile/instructions" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" +) + +func TestDispatchSecretTarget(t *testing.T) { + newState := func(os string) *dispatchState { + return &dispatchState{ + platform: &ocispecs.Platform{OS: os}, + outline: newOutlineCapture(), + } + } + + secretDest := func(t *testing.T, opt llb.RunOption) string { + t.Helper() + ei := &llb.ExecInfo{} + opt.SetRunOption(ei) + require.Len(t, ei.Secrets, 1) + require.NotNil(t, ei.Secrets[0].Target) + return *ei.Secrets[0].Target + } + + t.Run("windows requires explicit target", func(t *testing.T) { + d := newState("windows") + m := &instructions.Mount{Type: instructions.MountTypeSecret, CacheID: "mysecret"} + _, err := dispatchSecret(d, m, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "secret target is required on Windows") + }) + + t.Run("windows explicit target ok", func(t *testing.T) { + d := newState("windows") + m := &instructions.Mount{Type: instructions.MountTypeSecret, CacheID: "mysecret", Target: "C:/secret.txt"} + _, err := dispatchSecret(d, m, nil) + require.NoError(t, err) + }) + + t.Run("windows normalizes backslash target", func(t *testing.T) { + d := newState("windows") + m := &instructions.Mount{Type: instructions.MountTypeSecret, CacheID: "mysecret", Target: "C:\\dir\\secret.txt"} + opt, err := dispatchSecret(d, m, nil) + require.NoError(t, err) + require.Equal(t, "C:/dir/secret.txt", secretDest(t, opt)) + require.Equal(t, "C:\\dir\\secret.txt", m.Target, "m.Target must not be mutated") + }) + + t.Run("windows rejects drive-relative target", func(t *testing.T) { + d := newState("windows") + m := &instructions.Mount{Type: instructions.MountTypeSecret, CacheID: "mysecret", Target: "C:secret.txt"} + _, err := dispatchSecret(d, m, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "must be an absolute path") + }) + + t.Run("linux preserves backslash target", func(t *testing.T) { + d := newState("linux") + m := &instructions.Mount{Type: instructions.MountTypeSecret, CacheID: "mysecret", Target: "/a\\b"} + opt, err := dispatchSecret(d, m, nil) + require.NoError(t, err) + require.Equal(t, "/a\\b", secretDest(t, opt)) + }) + + t.Run("linux defaults target", func(t *testing.T) { + d := newState("linux") + m := &instructions.Mount{Type: instructions.MountTypeSecret, CacheID: "mysecret"} + _, err := dispatchSecret(d, m, nil) + require.NoError(t, err) + }) +} diff --git a/frontend/dockerfile/dockerfile_outline_test.go b/frontend/dockerfile/dockerfile_outline_test.go index f2a60d9bec3a..d055f3769e42 100644 --- a/frontend/dockerfile/dockerfile_outline_test.go +++ b/frontend/dockerfile/dockerfile_outline_test.go @@ -234,11 +234,11 @@ FROM nanoserver AS first RUN --mount=type=secret,target=/etc/passwd,required=true --mount=type=ssh exit 0 FROM nanoserver AS second -RUN --mount=type=secret,id=unused --mount=type=ssh,id=ssh2 exit 0 +RUN --mount=type=secret,id=unused,target=C:/unused --mount=type=ssh,id=ssh2 exit 0 FROM nanoserver AS third ARG BAR -RUN --mount=type=secret,id=second${BAR} exit 0 +RUN --mount=type=secret,id=second${BAR},target=C:/second exit 0 FROM third AS target COPY --from=first /License.txt / diff --git a/frontend/dockerfile/dockerfile_secrets_test.go b/frontend/dockerfile/dockerfile_secrets_test.go index 167fefc52fd8..5bf55f302af0 100644 --- a/frontend/dockerfile/dockerfile_secrets_test.go +++ b/frontend/dockerfile/dockerfile_secrets_test.go @@ -21,6 +21,7 @@ var secretsTests = integration.TestFuncs( testSecretRequiredWithoutValue, testSecretAsEnviron, testSecretAsEnvironWithFileMount, + testSecretFileMount, ) func init() { @@ -73,7 +74,7 @@ func testSecretRequiredWithoutValue(t *testing.T, sb integration.Sandbox) { `FROM nanoserver USER ContainerAdministrator - RUN --mount=type=secret,required,id=mysecret foo`, + RUN --mount=type=secret,required,id=mysecret,target=C:/mysecret foo`, )) dir := integration.Tmpdir( @@ -170,14 +171,61 @@ RUN --mount=type=secret,id=mysecret,env=SECRET_ENV if %SECRET_ENV% NEQ pw (exit // testSecretAsEnvironWithFileMount verifies that a secret with both env= and // target= is accessible as an environment variable and as a file. func testSecretAsEnvironWithFileMount(t *testing.T, sb integration.Sandbox) { - // target= triggers a tmpfs-backed file mount; Windows only accepts "windows-layer" mounts. - integration.SkipOnPlatform(t, "windows", "secret file mounts use tmpfs which is unsupported on Windows") f := getFrontend(t, sb) - dockerfile := []byte(` + // Forward slashes in the Windows path because the Dockerfile parser + // consumes backslashes as escapes. + dockerfile := []byte(integration.UnixOrWindows( + ` FROM busybox RUN --mount=type=secret,id=mysecret,target=/run/secrets/secret,env=SECRET_ENV [ "$SECRET_ENV" == "pw" ] && [ -f /run/secrets/secret ] || false -`) +`, + ` +FROM nanoserver +USER ContainerAdministrator +RUN --mount=type=secret,id=mysecret,target=C:/run/secrets/secret,env=SECRET_ENV if %SECRET_ENV% NEQ pw (exit 1) & if not exist C:/run/secrets/secret (exit 1) +`, + )) + + dir := integration.Tmpdir( + t, + fstest.CreateFile("Dockerfile", dockerfile, 0600), + ) + + c, err := client.New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + _, err = f.Solve(sb.Context(), c, client.SolveOpt{ + LocalMounts: map[string]fsutil.FS{ + dockerui.DefaultLocalNameDockerfile: dir, + dockerui.DefaultLocalNameContext: dir, + }, + Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{ + "mysecret": []byte("pw"), + })}, + }, nil) + require.NoError(t, err) +} + +// testSecretFileMount verifies a secret mounted as a file (target=) is readable +// inside the RUN step on both Linux and Windows. +func testSecretFileMount(t *testing.T, sb integration.Sandbox) { + f := getFrontend(t, sb) + + // Forward slashes in the Windows path because the Dockerfile parser + // consumes backslashes as escapes. + dockerfile := []byte(integration.UnixOrWindows( + ` +FROM busybox +RUN --mount=type=secret,id=mysecret,target=/secret.txt [ "$(cat /secret.txt)" = "pw" ] || false +`, + ` +FROM nanoserver +USER ContainerAdministrator +RUN --mount=type=secret,id=mysecret,target=C:/secret.txt findstr pw C:\secret.txt +`, + )) dir := integration.Tmpdir( t, diff --git a/frontend/dockerfile/docs/reference.md b/frontend/dockerfile/docs/reference.md index 5a366a1b90e9..822412b043e0 100644 --- a/frontend/dockerfile/docs/reference.md +++ b/frontend/dockerfile/docs/reference.md @@ -938,6 +938,23 @@ an environment variable by setting the `env` option. | `uid` | User ID for secret file. Default `0`. | | `gid` | Group ID for secret file. Default `0`. | +> [!NOTE] +> On Windows containers, the secret is delivered as a single-file, read-only +> bind mount (there is no `tmpfs`). An explicit `target` is required because +> there is no default `/run/secrets/` location, e.g. +> `--mount=type=secret,id=mysecret,target=C:/path/to/secret`. Use forward +> slashes in the target: the Dockerfile escape character (default `\`) otherwise +> consumes the backslashes during parsing. The `mode`, `uid`, and `gid` options +> are not supported on Windows and are ignored. The secret is written to a +> temporary file restricted (via an explicit ACL) to SYSTEM, the Administrators +> group, and the BuildKit daemon account, then removed after the step; it is +> written in clear text, so consider BitLocker for at-rest encryption. Because +> the file is not granted to the container's default user, the `RUN` step must +> execute as an administrator (e.g. `USER ContainerAdministrator`) to read the +> secret. The secret value is not persisted in the image; however, an empty +> placeholder directory may remain at the `target` path in the resulting +> layer, which is an inherent property of Windows bind mounts. + #### Example: access to S3 ```dockerfile diff --git a/solver/llbsolver/mounts/mount.go b/solver/llbsolver/mounts/mount.go index 58a962a8081d..d773642f4244 100644 --- a/solver/llbsolver/mounts/mount.go +++ b/solver/llbsolver/mounts/mount.go @@ -3,8 +3,6 @@ package mounts import ( "context" "fmt" - "os" - "path/filepath" "strings" "sync" "time" @@ -12,7 +10,6 @@ import ( "github.com/containerd/containerd/v2/core/mount" "github.com/moby/buildkit/cache" "github.com/moby/buildkit/client" - "github.com/moby/buildkit/identity" "github.com/moby/buildkit/session" "github.com/moby/buildkit/session/secrets" "github.com/moby/buildkit/session/sshforward" @@ -22,7 +19,6 @@ import ( "github.com/moby/buildkit/util/grpcerrors" "github.com/moby/locker" "github.com/moby/sys/user" - "github.com/moby/sys/userns" "github.com/pkg/errors" "google.golang.org/grpc/codes" ) @@ -286,83 +282,6 @@ type secretMountInstance struct { idmap *user.IdentityMapping } -func (sm *secretMountInstance) Mount() ([]mount.Mount, func() error, error) { - dir, err := os.MkdirTemp("", "buildkit-secrets") - if err != nil { - return nil, nil, errors.Wrap(err, "failed to create temp dir") - } - cleanupDir := func() error { - return os.RemoveAll(dir) - } - - if err := os.Chmod(dir, 0711); err != nil { - cleanupDir() - return nil, nil, err - } - - var mountOpts []string - if sm.sm.mount.SecretOpt.Mode&0o111 == 0 { - mountOpts = append(mountOpts, "noexec") - } - - tmpMount := mount.Mount{ - Type: "tmpfs", - Source: "tmpfs", - Options: append([]string{"nodev", "nosuid", fmt.Sprintf("uid=%d,gid=%d", os.Geteuid(), os.Getegid())}, mountOpts...), - } - - if userns.RunningInUserNS() { - tmpMount.Options = nil - } - - if err := mount.All([]mount.Mount{tmpMount}, dir); err != nil { - cleanupDir() - return nil, nil, errors.Wrap(err, "unable to setup secret mount") - } - sm.root = dir - - cleanup := func() error { - if err := mount.Unmount(dir, 0); err != nil { - return err - } - return cleanupDir() - } - - randID := identity.NewID() - fp := filepath.Join(dir, randID) - if err := os.WriteFile(fp, sm.sm.data, 0600); err != nil { - cleanup() - return nil, nil, err - } - - uid := int(sm.sm.mount.SecretOpt.Uid) - gid := int(sm.sm.mount.SecretOpt.Gid) - - if sm.idmap != nil { - uid, gid, err = sm.idmap.ToHost(uid, gid) - if err != nil { - cleanup() - return nil, nil, err - } - } - - if err := os.Chown(fp, uid, gid); err != nil { - cleanup() - return nil, nil, err - } - - if err := os.Chmod(fp, os.FileMode(sm.sm.mount.SecretOpt.Mode&0777)); err != nil { - cleanup() - return nil, nil, err - } - - return []mount.Mount{{ - Type: "bind", - Source: fp, - Options: append([]string{"ro", "rbind", "nodev", "nosuid"}, mountOpts...), - }}, cleanup, nil -} - func (sm *secretMountInstance) IdentityMapping() *user.IdentityMapping { return sm.idmap } diff --git a/solver/llbsolver/mounts/secretmount_unix.go b/solver/llbsolver/mounts/secretmount_unix.go new file mode 100644 index 000000000000..d2fca898fe2d --- /dev/null +++ b/solver/llbsolver/mounts/secretmount_unix.go @@ -0,0 +1,91 @@ +//go:build !windows + +package mounts + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/containerd/containerd/v2/core/mount" + "github.com/moby/buildkit/identity" + "github.com/moby/sys/userns" + "github.com/pkg/errors" +) + +func (sm *secretMountInstance) Mount() ([]mount.Mount, func() error, error) { + dir, err := os.MkdirTemp("", "buildkit-secrets") + if err != nil { + return nil, nil, errors.Wrap(err, "failed to create temp dir") + } + cleanupDir := func() error { + return os.RemoveAll(dir) + } + + if err := os.Chmod(dir, 0711); err != nil { + cleanupDir() + return nil, nil, err + } + + var mountOpts []string + if sm.sm.mount.SecretOpt.Mode&0o111 == 0 { + mountOpts = append(mountOpts, "noexec") + } + + tmpMount := mount.Mount{ + Type: "tmpfs", + Source: "tmpfs", + Options: append([]string{"nodev", "nosuid", fmt.Sprintf("uid=%d,gid=%d", os.Geteuid(), os.Getegid())}, mountOpts...), + } + + if userns.RunningInUserNS() { + tmpMount.Options = nil + } + + if err := mount.All([]mount.Mount{tmpMount}, dir); err != nil { + cleanupDir() + return nil, nil, errors.Wrap(err, "unable to setup secret mount") + } + sm.root = dir + + cleanup := func() error { + if err := mount.Unmount(dir, 0); err != nil { + return err + } + return cleanupDir() + } + + randID := identity.NewID() + fp := filepath.Join(dir, randID) + if err := os.WriteFile(fp, sm.sm.data, 0600); err != nil { + cleanup() + return nil, nil, err + } + + uid := int(sm.sm.mount.SecretOpt.Uid) + gid := int(sm.sm.mount.SecretOpt.Gid) + + if sm.idmap != nil { + uid, gid, err = sm.idmap.ToHost(uid, gid) + if err != nil { + cleanup() + return nil, nil, err + } + } + + if err := os.Chown(fp, uid, gid); err != nil { + cleanup() + return nil, nil, err + } + + if err := os.Chmod(fp, os.FileMode(sm.sm.mount.SecretOpt.Mode&0777)); err != nil { + cleanup() + return nil, nil, err + } + + return []mount.Mount{{ + Type: "bind", + Source: fp, + Options: append([]string{"ro", "rbind", "nodev", "nosuid"}, mountOpts...), + }}, cleanup, nil +} diff --git a/solver/llbsolver/mounts/secretmount_windows.go b/solver/llbsolver/mounts/secretmount_windows.go new file mode 100644 index 000000000000..b39093d6e6b6 --- /dev/null +++ b/solver/llbsolver/mounts/secretmount_windows.go @@ -0,0 +1,116 @@ +//go:build windows + +package mounts + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/containerd/containerd/v2/core/mount" + "github.com/moby/buildkit/identity" + "github.com/pkg/errors" + "golang.org/x/sys/windows" +) + +// Mount writes the secret to a temp file and returns it as a single-file, +// read-only bind mount. Windows has no tmpfs, so it is stored on disk; UID/GID/ +// mode are unsupported and ignored. +func (sm *secretMountInstance) Mount() ([]mount.Mount, func() error, error) { + dir, err := os.MkdirTemp("", "buildkit-secrets") + if err != nil { + return nil, nil, errors.Wrap(err, "failed to create temp dir") + } + cleanup := func() error { + return os.RemoveAll(dir) + } + sm.root = dir + + // Restrict the temp dir before writing the secret. The inheritable ACL + // ensures the secret file is born restricted (no window where it carries + // the broad inherited %TEMP% permissions) and removes any reliance on the + // inherited permissions of the parent temp directory. + if err := restrictPathACL(dir, true); err != nil { + cleanup() + return nil, nil, errors.Wrap(err, "failed to restrict secret dir permissions") + } + + fp := filepath.Join(dir, identity.NewID()) + // O_EXCL refuses to open an existing path, so a pre-planted file or symlink + // at fp can never be followed or overwritten. On Windows the 0600 mode only + // toggles the read-only attribute; access is governed by the inherited ACL. + f, err := os.OpenFile(fp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + cleanup() + return nil, nil, err + } + if _, err := f.Write(sm.sm.data); err != nil { + f.Close() + cleanup() + return nil, nil, err + } + if err := f.Close(); err != nil { + cleanup() + return nil, nil, err + } + + // Belt-and-suspenders: set an explicit protected DACL on the file too, in + // case directory ACL inheritance is disabled on the host. + if err := restrictPathACL(fp, false); err != nil { + cleanup() + return nil, nil, errors.Wrap(err, "failed to restrict secret file permissions") + } + + return []mount.Mount{{ + Type: "bind", + Source: fp, + Options: []string{"ro"}, + }}, cleanup, nil +} + +// restrictPathACL sets a protected DACL granting full control only to SYSTEM, +// Administrators and the daemon user. When inheritable, the ACEs also propagate +// to entries created inside a directory. +func restrictPathACL(path string, inheritable bool) error { + sid, err := currentUserSID() + if err != nil { + return err + } + flags := "" + if inheritable { + flags = "OICI" + } + // Build an SDDL DACL string granting full control to SYSTEM, the + // Administrators group and the daemon user, and nobody else: + // D: - this is a DACL + // P - protected: do not inherit ACEs from the parent object + // (A;flags;FA;;;trustee) - an Access-Allowed ACE, where: + // flags - "OICI" (object+container inherit) for dirs so children + // inherit the ACL, empty for the leaf secret file + // FA - File All access (full control) + // trustee - SY = Local System, BA = Builtin Administrators, + // sid = the current (daemon) user's SID + sddl := fmt.Sprintf("D:P(A;%[1]s;FA;;;SY)(A;%[1]s;FA;;;BA)(A;%[1]s;FA;;;%[2]s)", flags, sid) + sd, err := windows.SecurityDescriptorFromString(sddl) + if err != nil { + return err + } + dacl, _, err := sd.DACL() + if err != nil { + return err + } + return windows.SetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, nil, dacl, nil, + ) +} + +func currentUserSID() (string, error) { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return "", err + } + return user.User.Sid.String(), nil +} diff --git a/solver/llbsolver/mounts/secretmount_windows_test.go b/solver/llbsolver/mounts/secretmount_windows_test.go new file mode 100644 index 000000000000..2d332e8ded0a --- /dev/null +++ b/solver/llbsolver/mounts/secretmount_windows_test.go @@ -0,0 +1,76 @@ +//go:build windows + +package mounts + +import ( + "os" + "strings" + "testing" + + "github.com/moby/buildkit/solver/pb" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// TestSecretMountWindows verifies the secret is returned as a single-file, +// read-only bind mount with a restrictive DACL, and cleanup removes it. +func TestSecretMountWindows(t *testing.T) { + data := []byte("super-secret-value") + inst := &secretMountInstance{ + sm: &secretMount{ + mount: &pb.Mount{SecretOpt: &pb.SecretOpt{ID: "mysecret"}}, + data: data, + }, + } + + mounts, cleanup, err := inst.Mount() + require.NoError(t, err) + require.NotNil(t, cleanup) + require.Len(t, mounts, 1) + + m := mounts[0] + require.Equal(t, "bind", m.Type) + require.Contains(t, m.Options, "ro") + require.NotContains(t, m.Options, "tmpfs") + + got, err := os.ReadFile(m.Source) + require.NoError(t, err) + require.Equal(t, data, got) + + // DACL must be protected (D:P) and grant SYSTEM but no broad principals + // (Everyone WD/S-1-1-0, Users BU/S-1-5-32-545). + sd, err := windows.GetNamedSecurityInfo(m.Source, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + require.NoError(t, err) + sddl := sd.String() + require.Contains(t, sddl, "(A;;FA;;;SY)") + require.NotContains(t, sddl, ";WD)") + require.NotContains(t, sddl, "S-1-1-0") + require.NotContains(t, sddl, ";BU)") + require.NotContains(t, sddl, "S-1-5-32-545") + require.True(t, strings.HasPrefix(sddl, "D:P"), "DACL should be protected, got %q", sddl) + + // The parent temp dir must also carry a protected DACL so the secret file + // is born restricted rather than relying on inherited %TEMP% permissions. + dsd, err := windows.GetNamedSecurityInfo(inst.root, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + require.NoError(t, err) + dsddl := dsd.String() + require.True(t, strings.HasPrefix(dsddl, "D:P"), "dir DACL should be protected, got %q", dsddl) + require.NotContains(t, dsddl, "S-1-1-0") + require.NotContains(t, dsddl, "S-1-5-32-545") + + require.NoError(t, cleanup()) + + _, err = os.Stat(m.Source) + require.True(t, os.IsNotExist(err), "secret file should be removed after cleanup") +} + +// TestRestrictPathACLInheritable verifies a directory ACL is written with +// inheritance flags so files created inside inherit the restriction. +func TestRestrictPathACLInheritable(t *testing.T) { + dir := t.TempDir() + require.NoError(t, restrictPathACL(dir, true)) + sd, err := windows.GetNamedSecurityInfo(dir, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + require.NoError(t, err) + // OICI => "OICI" flag field renders in the SDDL ACE strings. + require.Contains(t, sd.String(), "OICI", "dir ACEs should be object/container inheritable") +}