Skip to content

Commit d20f857

Browse files
MarcStdtcursoragent
andcommitted
fix: preserve file modes in OCI layers
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b2a57cc commit d20f857

2 files changed

Lines changed: 143 additions & 3 deletions

File tree

internal/core/services/registry/oci.go

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"path/filepath"
1111
"regexp"
12+
"strconv"
1213
"strings"
1314
"sync/atomic"
1415
"time"
@@ -28,6 +29,9 @@ import (
2829
"oras.land/oras-go/v2/registry/remote/retry"
2930
)
3031

32+
const annotationDruidFileMode = "gg.druid.file.mode"
33+
const chmodModeMask = os.ModePerm | os.ModeSetuid | os.ModeSetgid | os.ModeSticky
34+
3135
type OciClient struct {
3236
credentialStore *CredentialStore
3337
// httpClient optionally overrides the HTTP client used by GetRepo.
@@ -240,6 +244,9 @@ func (c *OciClient) PullSelective(dir string, artifact string, includeData bool,
240244
zap.String("digest", desc.Digest.String()),
241245
zap.String("progress", fmt.Sprintf("%d/%d", done, total)),
242246
)
247+
if err := applyDescriptorMode(dir, desc); err != nil {
248+
return err
249+
}
243250
return nil
244251
},
245252
OnCopySkipped: func(ctx context.Context, desc v1.Descriptor) error {
@@ -337,7 +344,7 @@ func (c *OciClient) CanUpdateTag(current v1.Descriptor, r string, tag string) (b
337344
return false, nil
338345
}
339346

340-
func (c *OciClient) packFolders(fs *file.Store, dirs []string, artifactType domain.ArtifactType, path string) ([]v1.Descriptor, error) {
347+
func (c *OciClient) packFolders(fs *file.Store, folder string, dirs []string, artifactType domain.ArtifactType, path string) ([]v1.Descriptor, error) {
341348
ctx := context.Background()
342349

343350
fileDescriptors := make([]v1.Descriptor, 0, len(dirs))
@@ -352,6 +359,7 @@ func (c *OciClient) packFolders(fs *file.Store, dirs []string, artifactType doma
352359
if err != nil {
353360
return []v1.Descriptor{}, err
354361
}
362+
annotateDescriptorMode(folder, fullPath, &fileDescriptor)
355363
fileDescriptors = append(fileDescriptors, fileDescriptor)
356364
logger.Log().Info("Packed layer",
357365
zap.String("path", fullPath),
@@ -362,6 +370,41 @@ func (c *OciClient) packFolders(fs *file.Store, dirs []string, artifactType doma
362370
return fileDescriptors, nil
363371
}
364372

373+
func annotateDescriptorMode(folder string, layerPath string, desc *v1.Descriptor) {
374+
info, err := os.Stat(filepath.Join(folder, filepath.FromSlash(layerPath)))
375+
if err != nil {
376+
return
377+
}
378+
if desc.Annotations == nil {
379+
desc.Annotations = map[string]string{}
380+
}
381+
desc.Annotations[annotationDruidFileMode] = strconv.FormatUint(uint64(info.Mode()&chmodModeMask), 8)
382+
}
383+
384+
func applyDescriptorMode(root string, desc v1.Descriptor) error {
385+
modeValue := desc.Annotations[annotationDruidFileMode]
386+
if modeValue == "" {
387+
return nil
388+
}
389+
390+
path := desc.Annotations["org.opencontainers.image.path"]
391+
if path == "" {
392+
path = desc.Annotations["org.opencontainers.image.title"]
393+
}
394+
if path == "" {
395+
return nil
396+
}
397+
398+
mode, err := strconv.ParseUint(modeValue, 8, 32)
399+
if err != nil {
400+
return fmt.Errorf("invalid descriptor file mode %q for %s: %w", modeValue, path, err)
401+
}
402+
if err := os.Chmod(filepath.Join(root, filepath.FromSlash(path)), os.FileMode(mode)); err != nil {
403+
return fmt.Errorf("failed to chmod %s to %s: %w", path, modeValue, err)
404+
}
405+
return nil
406+
}
407+
365408
// DefaultCategoryMarkdownPattern matches locale markdown basenames such as de-DE.md (used by PushCategory).
366409
const DefaultCategoryMarkdownPattern = `^[a-z]{2}-[A-Z]{2}\.md$`
367410

@@ -413,7 +456,7 @@ func (c *OciClient) createMetaDescriptors(fs *file.Store, folder string, fsPath
413456
return nil, fmt.Errorf("no files matching pattern %q in %s", namePattern, metaPath)
414457
}
415458

416-
return c.packFolders(fs, names, domain.ArtifactTypeScrollMeta, fsPath)
459+
return c.packFolders(fs, folder, names, domain.ArtifactTypeScrollMeta, fsPath)
417460
}
418461

419462
// pushBlobWithRetry pushes a single blob from src to dst, retrying up to
@@ -584,7 +627,7 @@ func (c *OciClient) Push(folder string, repo string, tag string, overrides map[s
584627
defer fs.Close()
585628

586629
// Pack scroll FS layers.
587-
descriptorsForRoot, err := c.packFolders(fs, fsFileNames, domain.ArtifactTypeScrollFs, "")
630+
descriptorsForRoot, err := c.packFolders(fs, folder, fsFileNames, domain.ArtifactTypeScrollFs, "")
588631
if err != nil {
589632
return v1.Descriptor{}, err
590633
}
@@ -641,6 +684,7 @@ func (c *OciClient) Push(folder string, repo string, tag string, overrides map[s
641684
if err != nil {
642685
return v1.Descriptor{}, fmt.Errorf("failed to pack data chunk %s: %w", chunk.Name, err)
643686
}
687+
annotateDescriptorMode(folder, layerPath, &desc)
644688
logger.Log().Info("Packed layer",
645689
zap.String("path", layerPath),
646690
zap.String("digest", desc.Digest.String()),

internal/core/services/registry/oci_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ import (
1010
"testing"
1111

1212
"github.com/highcard-dev/daemon/internal/core/domain"
13+
ocidigest "github.com/opencontainers/go-digest"
1314
)
1415

1516
// fakeRegistry returns a plain-HTTP httptest server that implements the bare
1617
// minimum of the OCI Distribution spec so that oras.Copy can complete a push.
1718
func fakeRegistry(t *testing.T) *httptest.Server {
1819
t.Helper()
1920
blobs := map[string][]byte{}
21+
manifests := map[string][]byte{}
22+
manifestTypes := map[string]string{}
2023
mux := http.NewServeMux()
2124

2225
mux.HandleFunc("GET /v2/", func(w http.ResponseWriter, r *http.Request) {
@@ -25,6 +28,29 @@ func fakeRegistry(t *testing.T) *httptest.Server {
2528
w.Write([]byte(`{"tags":[]}`))
2629
return
2730
}
31+
if strings.Contains(r.URL.Path, "/blobs/") {
32+
digest := strings.Split(r.URL.Path, "/blobs/")[1]
33+
if data, ok := blobs[digest]; ok {
34+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
35+
w.Header().Set("Docker-Content-Digest", digest)
36+
w.WriteHeader(http.StatusOK)
37+
w.Write(data)
38+
return
39+
}
40+
w.WriteHeader(http.StatusNotFound)
41+
return
42+
}
43+
if strings.Contains(r.URL.Path, "/manifests/") {
44+
ref := strings.Split(r.URL.Path, "/manifests/")[1]
45+
if data, ok := manifests[ref]; ok {
46+
w.Header().Set("Content-Type", manifestTypes[ref])
47+
w.WriteHeader(http.StatusOK)
48+
w.Write(data)
49+
return
50+
}
51+
w.WriteHeader(http.StatusNotFound)
52+
return
53+
}
2854
w.WriteHeader(http.StatusOK)
2955
})
3056

@@ -39,6 +65,16 @@ func fakeRegistry(t *testing.T) *httptest.Server {
3965
return
4066
}
4167
}
68+
parts = strings.Split(r.URL.Path, "/manifests/")
69+
if len(parts) == 2 {
70+
ref := parts[1]
71+
if data, ok := manifests[ref]; ok {
72+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
73+
w.Header().Set("Content-Type", manifestTypes[ref])
74+
w.WriteHeader(http.StatusOK)
75+
return
76+
}
77+
}
4278
w.WriteHeader(http.StatusNotFound)
4379
})
4480

@@ -57,6 +93,18 @@ func fakeRegistry(t *testing.T) *httptest.Server {
5793
w.WriteHeader(http.StatusCreated)
5894
return
5995
}
96+
if strings.Contains(r.URL.Path, "/manifests/") {
97+
ref := strings.Split(r.URL.Path, "/manifests/")[1]
98+
body := make([]byte, r.ContentLength)
99+
r.Body.Read(body)
100+
bodyDigest := ocidigest.FromBytes(body).String()
101+
manifests[ref] = body
102+
manifests[bodyDigest] = body
103+
manifestTypes[ref] = r.Header.Get("Content-Type")
104+
manifestTypes[bodyDigest] = r.Header.Get("Content-Type")
105+
w.WriteHeader(http.StatusCreated)
106+
return
107+
}
60108
body := make([]byte, r.ContentLength)
61109
r.Body.Read(body)
62110
w.WriteHeader(http.StatusCreated)
@@ -121,3 +169,51 @@ func TestPushDataChunkPathNotDoubled(t *testing.T) {
121169
t.Fatalf("Push failed unexpectedly: %v", err)
122170
}
123171
}
172+
173+
func TestPushPullExecutableDataChunkPreservesMode(t *testing.T) {
174+
tmpDir := t.TempDir()
175+
t.Chdir(tmpDir)
176+
177+
srv := fakeRegistry(t)
178+
registryHost := strings.TrimPrefix(srv.URL, "http://")
179+
180+
relFolder := filepath.Join("scrolls", "lgsm", "arkserver")
181+
dataDir := filepath.Join(relFolder, "data")
182+
if err := os.MkdirAll(dataDir, 0755); err != nil {
183+
t.Fatal(err)
184+
}
185+
if err := os.WriteFile(filepath.Join(relFolder, "scroll.yaml"), []byte("name: test\nversion: 0.1.0\napp_version: arkserver\n"), 0644); err != nil {
186+
t.Fatal(err)
187+
}
188+
if err := os.WriteFile(filepath.Join(dataDir, "arkserver"), []byte("#!/bin/sh\n"), 0755); err != nil {
189+
t.Fatal(err)
190+
}
191+
192+
client := &OciClient{
193+
credentialStore: NewCredentialStore([]domain.RegistryCredential{}),
194+
plainHTTP: true,
195+
}
196+
197+
repoRef := registryHost + "/test/scroll"
198+
scrollFile := &domain.File{
199+
Chunks: []*domain.Chunks{
200+
{Name: "lgsm-launcher", Path: "arkserver"},
201+
},
202+
}
203+
if _, err := client.Push(relFolder, repoRef, "arkserver", map[string]string{}, false, scrollFile); err != nil {
204+
t.Fatalf("Push failed unexpectedly: %v", err)
205+
}
206+
207+
pullDir := filepath.Join("pull", "arkserver")
208+
if err := client.PullSelective(pullDir, repoRef+":arkserver", true, nil); err != nil {
209+
t.Fatalf("Pull failed unexpectedly: %v", err)
210+
}
211+
212+
info, err := os.Stat(filepath.Join(pullDir, "data", "arkserver"))
213+
if err != nil {
214+
t.Fatal(err)
215+
}
216+
if got := info.Mode().Perm(); got != 0755 {
217+
t.Fatalf("data/arkserver mode = %v, want 0755", got)
218+
}
219+
}

0 commit comments

Comments
 (0)