Skip to content

Commit 4343a3b

Browse files
matejvasekclaude
andcommitted
fix: normalize file modes in python image layer
The python OCI builder tarred the build directory into the image layer using the on-disk file modes verbatim (via tar.FileInfoHeader), only overriding Uid/Gid. The build directory is created with 0774 (builder.go), which under the default umask 022 becomes 0754 - stripping the traverse bit for group/other. Baked into the image, /func/.func/build is then only accessible to the image's configured UID (1000). This works under podman locally (runs as UID 1000) but fails on platforms that run containers with an arbitrary UID, e.g. OpenShift's restricted SCC, with: python: can't open file '/func/.func/build/service/main.py': [Errno 13] Permission denied The go builder already normalizes the mode to 0755 (go_builder.go:190); the python builder, added later, never carried this over. Normalize dirs and executables to 0755 and regular files to 0644 so the image works under any UID (including group 0). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 692383b commit 4343a3b

2 files changed

Lines changed: 112 additions & 0 deletions

File tree

pkg/oci/python_builder.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,18 @@ func newPythonLibTarball(job buildJob, root, target string) error {
184184
header.Name = slashpath.Join("/func/", filepath.ToSlash(relPath))
185185
header.Uid = DefaultUid
186186
header.Gid = DefaultGid
187+
// Normalize permissions so the image works on platforms that run
188+
// containers with an arbitrary UID (e.g. OpenShift's restricted SCC).
189+
// The on-disk mode is not portable: e.g. the build directory is created
190+
// with 0774 which, under the default umask 022, becomes 0754 - stripping
191+
// the traverse bit for group/other and making /func/.func/build/... only
192+
// accessible to UID DefaultUid. Directories and executables get 0755,
193+
// regular files 0644, so any UID (in group 0 or otherwise) can read them.
194+
if info.IsDir() || info.Mode()&0o111 != 0 {
195+
header.Mode = (header.Mode & ^int64(fs.ModePerm)) | 0o755
196+
} else {
197+
header.Mode = (header.Mode & ^int64(fs.ModePerm)) | 0o644
198+
}
187199
if err := tw.WriteHeader(header); err != nil {
188200
return err
189201
}

pkg/oci/python_builder_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package oci
2+
3+
import (
4+
"archive/tar"
5+
"compress/gzip"
6+
"errors"
7+
"io"
8+
"io/fs"
9+
"os"
10+
"path/filepath"
11+
"testing"
12+
13+
fn "knative.dev/func/pkg/functions"
14+
)
15+
16+
// TestNewPythonLibTarball_NormalizesModes ensures that the python lib layer
17+
// tarball is written with portable permissions regardless of the on-disk modes
18+
// of the build directory.
19+
//
20+
// Regression test: the build directory is created with 0774 which, under the
21+
// default umask 022, becomes 0754 - stripping the traverse/read bit for
22+
// group/other. When such a mode is copied verbatim into the image layer, the
23+
// resulting container only works for the image's configured UID and fails with
24+
// "[Errno 13] Permission denied" when run under an arbitrary UID (e.g. on
25+
// OpenShift's restricted SCC). Directories and executables must be 0755 and
26+
// regular files 0644 so any UID can traverse and read them.
27+
func TestNewPythonLibTarball_NormalizesModes(t *testing.T) {
28+
root := t.TempDir()
29+
30+
// Recreate the on-disk layout the python builder tars up:
31+
// <root>/.func/build/service/main.py (regular file, restrictive parent)
32+
// <root>/.func/build/run.sh (executable)
33+
buildDir := filepath.Join(root, fn.RunDataDir, fn.BuildDir)
34+
svcDir := filepath.Join(buildDir, "service")
35+
if err := os.MkdirAll(svcDir, 0o755); err != nil {
36+
t.Fatal(err)
37+
}
38+
// Force the problematic non-portable modes.
39+
if err := os.WriteFile(filepath.Join(svcDir, "main.py"), []byte("print('hi')\n"), 0o600); err != nil {
40+
t.Fatal(err)
41+
}
42+
if err := os.WriteFile(filepath.Join(buildDir, "run.sh"), []byte("#!/bin/sh\n"), 0o700); err != nil {
43+
t.Fatal(err)
44+
}
45+
// chmod parent dirs to 0754 (what 0774 & ~umask 022 yields).
46+
if err := os.Chmod(svcDir, 0o754); err != nil {
47+
t.Fatal(err)
48+
}
49+
if err := os.Chmod(buildDir, 0o754); err != nil {
50+
t.Fatal(err)
51+
}
52+
53+
job := buildJob{function: fn.Function{Root: root}}
54+
target := filepath.Join(root, "lib.tar.gz")
55+
if err := newPythonLibTarball(job, buildDir, target); err != nil {
56+
t.Fatal(err)
57+
}
58+
59+
modes := map[string]int64{}
60+
f, err := os.Open(target)
61+
if err != nil {
62+
t.Fatal(err)
63+
}
64+
defer f.Close()
65+
gr, err := gzip.NewReader(f)
66+
if err != nil {
67+
t.Fatal(err)
68+
}
69+
defer gr.Close()
70+
tr := tar.NewReader(gr)
71+
for {
72+
hdr, err := tr.Next()
73+
if err != nil {
74+
if errors.Is(err, io.EOF) {
75+
break
76+
}
77+
t.Fatal(err)
78+
}
79+
modes[hdr.Name] = hdr.Mode & int64(fs.ModePerm)
80+
}
81+
82+
assertMode := func(name string, want int64) {
83+
t.Helper()
84+
got, ok := modes[name]
85+
if !ok {
86+
t.Fatalf("entry %q not found in tarball; entries: %v", name, modes)
87+
}
88+
if got != want {
89+
t.Errorf("entry %q has mode %#o, want %#o", name, got, want)
90+
}
91+
}
92+
93+
// Directories must be traversable by any UID.
94+
assertMode("/func/.func/build", 0o755)
95+
assertMode("/func/.func/build/service", 0o755)
96+
// Regular files must be readable by any UID.
97+
assertMode("/func/.func/build/service/main.py", 0o644)
98+
// Executables keep the execute bit for any UID.
99+
assertMode("/func/.func/build/run.sh", 0o755)
100+
}

0 commit comments

Comments
 (0)