diff --git a/.agents/skills/werf-to-delivery-kit/SKILL.md b/.agents/skills/werf-to-delivery-kit/SKILL.md index 18e0aa3dd1..4fcefc04e8 100644 --- a/.agents/skills/werf-to-delivery-kit/SKILL.md +++ b/.agents/skills/werf-to-delivery-kit/SKILL.md @@ -332,7 +332,7 @@ import: - Формат подписи фиксирован: отсоединённый PKCS#7 (CMS) `SignedData`, SHA-256, RSASSA-PKCS1-v1_5, без подписываемых атрибутов, со встроенным leaf-сертификатом (intermediates не встраиваются) — как у `systemd-keyutil pkcs7` / `openssl smime -sign -binary -noattr -outform der`. Ключ только RSA. Если старая сборка делала то же самое — переписывать на `sign:`; иначе (в т.ч. потребителю нужна встроенная цепочка) — стоп-условие. - Все `sign:` одного запуска delivery-kit используют **одну** пару ключ/сертификат. Сверить, каким ключом подписывается каждый файл; если в конфиге сейчас разные ключи для разных файлов — остановиться и согласовать с пользователем, не объединять ключи и не менять CI. -- `path` — абсолютный путь к обычному файлу (не каталог), каждый `path` один раз. Позиция: без `before`/`after` — в конце образа (по умолчанию, предпочтительно); `before|after: install|setup` — на границе стадии, тогда `.p7s` виден следующим стадиям. `before` и `after` вместе — ошибка. +- `path` — абсолютный путь к обычному файлу (не каталог) либо, если содержит `*`, абсолютный glob-шаблон (`*` в пределах сегмента, `**` через сегменты; остальные символы, включая `?[{`, буквальные — экранирование не нужно; delivery-kit > v3.12.0-dk.2), раскрываемый по файловой системе образа на стадии подписи: шаблон начинается с буквального каталога ниже `/`, каждый подошедший обычный файл получает свой `.p7s`, ниже базового каталога симлинки не сопоставляются и не разыменовываются, пустое раскрытие — ошибка сборки. Шаблон — единственный способ подписать набор файлов, известный только после сборки (модули ядра после `make modules_install`: `path: /out/lib/modules/**/*.ko`). Каждый файл — один раз (литерал и шаблон, покрывающие один файл на одной позиции, — ошибка). Позиция: без `before`/`after` — в конце образа (по умолчанию, предпочтительно); `before|after: install|setup` — на границе стадии, тогда `.p7s` виден следующим стадиям. `before` и `after` вместе — ошибка. - Проверку подписи, которая раньше шла сразу после подписания, не удалять — перенести: стадия, которая производит файл, своей подписи не видит. Файл из `install` → `after: install`, проверка в `beforeSetup`/`setup`; файл из `beforeSetup` → `before: setup`, проверка в `setup`; файл из `setup` (последняя shell-стадия) → подпись без `before`/`after`, проверка в отдельном образе, который импортирует файл и `.p7s`. Если сохранить проверку так не получается — стоп-условие. Если проверке нужен `openssl` — пакет в `spec:` os-pm. - Зависимые образы, которые импортировали подписанный файл, импортируют и `.p7s`. - Удалить вход в Vault и вызовы подписи из shell. `secrets:` с `VAULT_*`/JWT, `mount:` с клиентом Vault (`d8`, `vault`), запрос сертификата из shell и соответствующие `secrets.allowEnvVariables`/`mount.allowFromPaths` в `werf-giterminism.yaml` удалять только после проверки всех их использований: если они нужны другому образу или проверке подписи (например, CA-сертификат) — оставить. @@ -427,4 +427,4 @@ head -1 <путь-к-каталогу> - Нет нужного OS-пакета/образа в файле базовых образов → **прекратить** переписывание, сообщить пользователю: запросить пакет у команды container-base. Для менеджеров языковых экосистем (yarn, pnpm, uv, poetry) сначала применить `manager:` (§3). - Не удаётся заменить сетевую shell-команду ни одной директивой (`git:`/`packages:`/`vex:`/`sign:`) → не оставлять её в shell "как есть" и не изобретать обходы, а зафиксировать проблему и спросить пользователя (типовые случаи — fuzz и svace, см. §4). - Пакет из pm не работает (`symbol lookup error`, нет манифеста в registry) → это дефект container-base, не конфига: сообщить пользователю, не тащить бинарь/библиотеку из другого базового образа через `import`. -- Подпись в shell не сводится к отсоединённому PKCS#7 RSA/SHA-256 рядом с файлом (подпись встраивается в сам файл — Authenticode/`pesign`, `sbsign`, подпись модулей ядра; голая подпись без CMS; не-RSA ключ; подписываемые атрибуты) → `sign:` её не заменяет: не переписывать этот образ и не изобретать обходы, зафиксировать и сообщить пользователю. +- Подпись в shell не сводится к отсоединённому PKCS#7 RSA/SHA-256 рядом с файлом (подпись встраивается в сам файл — Authenticode/`pesign`, `sbsign`; голая подпись без CMS; не-RSA ключ; подписываемые атрибуты) → `sign:` её не заменяет: не переписывать этот образ и не изобретать обходы, зафиксировать и сообщить пользователю. Подпись модулей ядра — исключение: `scripts/sign-file -s sha256 ` дописывает к модулю именно отсоединённый PKCS#7 (`-s` берёт готовую подпись, `` при этом не читается), поэтому она сводится к `sign:` с glob (`after: install`) и `sign-file -s` + `rm *.p7s` в `beforeSetup`. diff --git a/docs/_data/werf_yaml.yml b/docs/_data/werf_yaml.yml index d9243cad7e..5206ce746c 100644 --- a/docs/_data/werf_yaml.yml +++ b/docs/_data/werf_yaml.yml @@ -941,8 +941,8 @@ sections: - name: path value: "string" description: - en: "The absolute path of the file to sign; the detached PKCS#7 signature is written next to it as `.p7s`" - ru: "Абсолютный путь к подписываемому файлу; отсоединённая подпись PKCS#7 записывается рядом как `.p7s`" + en: "The absolute path of the file to sign, or an absolute glob pattern matching the files to sign; the detached PKCS#7 signature of each file is written next to it as `.p7s`" + ru: "Абсолютный путь к подписываемому файлу либо абсолютный glob-шаблон подписываемых файлов; отсоединённая подпись PKCS#7 каждого файла записывается рядом как `.p7s`" - name: before value: "string" description: diff --git a/docs/pages_en/usage/build/stapel/imports.md b/docs/pages_en/usage/build/stapel/imports.md index 4d1ae8304b..2a0ec55b01 100644 --- a/docs/pages_en/usage/build/stapel/imports.md +++ b/docs/pages_en/usage/build/stapel/imports.md @@ -113,7 +113,15 @@ sign: after: install ``` -Entries at the same signing position must not use another entry's `.p7s` as an input. For example, `path: /out/x` and `path: /out/x.p7s` at the same position are a configuration error, regardless of their order in the list. To sign a generated signature, put the second entry at a later signing position. +A `path` containing `*` is a glob pattern, expanded against the image filesystem when the signing stage runs: every regular file it matches gets its own `.p7s`. `*` matches within one path segment and `**` across segments; every other character, `?`, `[` and `{` included, stands for itself, so a literal path such as `/out/file[1].bin` needs no escaping (a file with `*` in its name cannot be signed). The pattern must start with a literal directory below `/` (`/out/**/*.ko`, not `/**/*.ko`). Below that directory symlinks are neither matched nor followed, so a file is signed under its real path only; a hard link is a regular file under another name and is signed under each. A pattern that matches nothing fails the build — a declared signature is a requirement of the image, not a hint. This is how a set of files known only after the build is signed, such as the kernel modules `make modules_install` produces: + +```yaml +sign: +- path: /out/lib/modules/**/*.ko + after: install +``` + +Entries at the same signing position must not use another entry's `.p7s` as an input. For example, `path: /out/x` and `path: /out/x.p7s` at the same position are a configuration error, regardless of their order in the list. The same holds for the files a pattern expands to, and a file reached through two entries is an error as well; both are detected when the pattern is expanded. To sign a generated signature, put the second entry at a later signing position. werf reads the file out of the image built so far, signs it on the build host and adds `.p7s` as a new layer. The signature is a detached PKCS#7 (CMS) `SignedData` over the file content — SHA-256, RSASSA-PKCS1-v1_5, no authenticated attributes, the signing certificate embedded — the same shape `systemd-keyutil pkcs7` produces, so systemd and the Linux kernel accept it as a dm-verity root hash signature. The key therefore has to be RSA. diff --git a/docs/pages_ru/usage/build/stapel/imports.md b/docs/pages_ru/usage/build/stapel/imports.md index ff563a444e..2d28f7175f 100644 --- a/docs/pages_ru/usage/build/stapel/imports.md +++ b/docs/pages_ru/usage/build/stapel/imports.md @@ -113,7 +113,15 @@ sign: after: install ``` -Записи с одинаковой позицией подписания не могут использовать `.p7s` другой записи как входной файл. Например, `path: /out/x` и `path: /out/x.p7s` на одной позиции — ошибка конфигурации независимо от их порядка в списке. Чтобы подписать созданную подпись, задайте второй записи более позднюю позицию подписания. +`path`, содержащий `*`, — glob-шаблон: он раскрывается по файловой системе образа в момент выполнения стадии подписания, и каждый подошедший обычный файл получает свой `.p7s`. `*` соответствует части одного сегмента пути, `**` — любому числу сегментов; все остальные символы, включая `?`, `[` и `{`, обозначают сами себя, поэтому буквальный путь вроде `/out/file[1].bin` не требует экранирования (файл с `*` в имени подписать нельзя). Шаблон должен начинаться с буквального каталога ниже `/` (`/out/**/*.ko`, но не `/**/*.ko`). Ниже этого каталога символические ссылки не сопоставляются и не разыменовываются, поэтому файл подписывается только под своим настоящим путём; жёсткая ссылка — это обычный файл под другим именем, он подписывается под каждым. Шаблон, которому не соответствует ни один файл, останавливает сборку — объявленная подпись является требованием к образу, а не пожеланием. Так подписывается набор файлов, который известен только после сборки, например модули ядра, которые создаёт `make modules_install`: + +```yaml +sign: +- path: /out/lib/modules/**/*.ko + after: install +``` + +Записи с одинаковой позицией подписания не могут использовать `.p7s` другой записи как входной файл. Например, `path: /out/x` и `path: /out/x.p7s` на одной позиции — ошибка конфигурации независимо от их порядка в списке. То же относится к файлам, в которые раскрывается шаблон; файл, достижимый через две записи, — тоже ошибка. И то и другое обнаруживается при раскрытии шаблона. Чтобы подписать созданную подпись, задайте второй записи более позднюю позицию подписания. werf читает файл из собранного к этому моменту образа, подписывает его на сборочном хосте и добавляет `.p7s` новым слоем. Подпись — отсоединённый PKCS#7 (CMS) `SignedData` над содержимым файла: SHA-256, RSASSA-PKCS1-v1_5, без аутентифицированных атрибутов, со встроенным сертификатом подписанта — ровно та форма, которую производит `systemd-keyutil pkcs7`, поэтому systemd и ядро Linux принимают её как подпись dm-verity root hash. Ключ, соответственно, должен быть RSA. diff --git a/pkg/build/stage/sign_files.go b/pkg/build/stage/sign_files.go index 2eb7da2356..e000abe4a0 100644 --- a/pkg/build/stage/sign_files.go +++ b/pkg/build/stage/sign_files.go @@ -132,59 +132,115 @@ func (s *SignFilesStage) PrepareImage(ctx context.Context, c Conveyor, cb contai return fmt.Errorf("create signatures dir %s: %w", signaturesDir, err) } + reader, err := cb.OpenImageReader(ctx, prevBuiltImage.Image.Name(), container_backend.ReadFileFromImageOpts{TargetPlatform: s.targetPlatform}) + if err != nil { + return fmt.Errorf("open image %q to sign files of image %q: %w", prevBuiltImage.Image.Name(), s.imageName, err) + } + defer func() { + if err := reader.Close(ctx); err != nil { + logboek.Context(ctx).Warn().LogF("WARNING: %s\n", err) + } + }() + + files, err := s.expandSigns(ctx, reader) + if err != nil { + return err + } + legacy := c.UseLegacyStapelBuilder(cb) - var commands []string - for i, sign := range s.signs { - content, err := cb.ReadFileFromImage(ctx, prevBuiltImage.Image.Name(), sign.Path, container_backend.ReadFileFromImageOpts{TargetPlatform: s.targetPlatform}) + var installCommands []string + for i, filePath := range files { + content, err := reader.ReadFile(ctx, filePath) if err != nil { - return fmt.Errorf("read %s to sign from image %q: %w", sign.Path, s.imageName, err) + return fmt.Errorf("read %s to sign from image %q: %w", filePath, s.imageName, err) } var signature []byte - if err := logboek.Context(ctx).Default().LogProcess("Signing %s", sign.Path).DoError(func() error { + if err := logboek.Context(ctx).Default().LogProcess("Signing %s", filePath).DoError(func() error { var signErr error signature, signErr = signing.SignDetachedPKCS7(ctx, content, s.options.Signer()) return signErr }); err != nil { - return fmt.Errorf("sign %s of image %q: %w", sign.Path, s.imageName, err) + return fmt.Errorf("sign %s of image %q: %w", filePath, s.imageName, err) } - outputName := path.Base(sign.OutputPath()) + outputPath := config.SignatureOutputPath(filePath) + outputName := path.Base(outputPath) if legacy { // Two signed files can share a base name: keep every signature in its own // directory, or the later one overwrites the earlier and both destinations // receive the same signature. entryDir := strconv.Itoa(i) if err := os.MkdirAll(filepath.Join(signaturesDir, entryDir), os.ModePerm); err != nil { - return fmt.Errorf("create signature dir for %s: %w", sign.Path, err) + return fmt.Errorf("create signature dir for %s: %w", filePath, err) } hostPath := filepath.Join(signaturesDir, entryDir, outputName) if err := os.WriteFile(hostPath, signature, 0o644); err != nil { return fmt.Errorf("write signature %s: %w", hostPath, err) } containerPath := path.Join(s.containerWerfDir, "sign", string(s.Name()), entryDir, outputName) - commands = append(commands, - fmt.Sprintf("%s -p %s", stapel.MkdirBinPath(), shellescape.Quote(path.Dir(sign.OutputPath()))), - fmt.Sprintf("%s -m 0644 %s %s", stapel.InstallBinPath(), shellescape.Quote(containerPath), shellescape.Quote(sign.OutputPath())), + installCommands = append(installCommands, + fmt.Sprintf("%s -p %s", stapel.MkdirBinPath(), shellescape.Quote(path.Dir(outputPath))), + fmt.Sprintf("%s -m 0644 %s %s", stapel.InstallBinPath(), shellescape.Quote(containerPath), shellescape.Quote(outputPath)), ) continue } archive, err := singleFileTar(outputName, signature) if err != nil { - return fmt.Errorf("pack signature of %s: %w", sign.Path, err) + return fmt.Errorf("pack signature of %s: %w", filePath, err) } - stageImage.Builder.StapelStageBuilder().AddDataArchive(io.NopCloser(archive), container_backend.FileArchive, sign.OutputPath(), container_backend.AddDataArchiveOptions{}) + stageImage.Builder.StapelStageBuilder().AddDataArchive(io.NopCloser(archive), container_backend.FileArchive, outputPath, container_backend.AddDataArchiveOptions{}) } if legacy { - stageImage.Builder.LegacyStapelStageBuilder().Container().RunOptions().AddVolume(fmt.Sprintf("%s:%s:ro", signaturesDir, path.Join(s.containerWerfDir, "sign", string(s.Name())))) - stageImage.Builder.LegacyStapelStageBuilder().Container().AddServiceRunCommands(commands...) + // A pattern can expand to hundreds of files; a script keeps their install + // commands out of the container's command line, which has a fixed size limit. + const scriptName = "install.sh" + if err := stapel.CreateScript(filepath.Join(signaturesDir, scriptName), installCommands); err != nil { + return fmt.Errorf("create signatures install script: %w", err) + } + containerSignaturesDir := path.Join(s.containerWerfDir, "sign", string(s.Name())) + stageImage.Builder.LegacyStapelStageBuilder().Container().RunOptions().AddVolume(fmt.Sprintf("%s:%s:ro", signaturesDir, containerSignaturesDir)) + stageImage.Builder.LegacyStapelStageBuilder().Container().AddServiceRunCommands(path.Join(containerSignaturesDir, scriptName)) } return nil } +// expandSigns resolves the entries to the files to sign, in entry order, and applies to +// the expanded set the conflict checks the config parser applies to literal paths. +func (s *SignFilesStage) expandSigns(ctx context.Context, reader container_backend.ImageReader) ([]string, error) { + var files []string + signByFile := map[string]*config.Sign{} + for _, sign := range s.signs { + matches := []string{sign.Path} + if sign.IsPattern() { + var err error + matches, err = reader.Glob(ctx, sign.Pattern()) + if err != nil { + return nil, fmt.Errorf("expand `path: %s` of sign in image %q: %w", sign.Path, s.imageName, err) + } + if len(matches) == 0 { + return nil, fmt.Errorf("`path: %s` of sign in image %q matches no file", sign.Path, s.imageName) + } + } + for _, filePath := range matches { + if other, exist := signByFile[filePath]; exist { + return nil, fmt.Errorf("file %s of image %q is signed by both `path: %s` and `path: %s` of sign", filePath, s.imageName, other.Path, sign.Path) + } + signByFile[filePath] = sign + files = append(files, filePath) + } + } + for _, filePath := range files { + if other, exist := signByFile[config.SignatureOutputPath(filePath)]; exist { + return nil, fmt.Errorf("file %s of image %q matched by `path: %s` of sign is the signature of %s signed at the same stage", config.SignatureOutputPath(filePath), s.imageName, other.Path, filePath) + } + } + return files, nil +} + func singleFileTar(name string, content []byte) (*bytes.Buffer, error) { var buf bytes.Buffer tw := tar.NewWriter(&buf) diff --git a/pkg/build/stage/sign_files_test.go b/pkg/build/stage/sign_files_test.go index e33937c07c..df88309a9e 100644 --- a/pkg/build/stage/sign_files_test.go +++ b/pkg/build/stage/sign_files_test.go @@ -9,13 +9,16 @@ import ( "encoding/base64" "encoding/pem" "fmt" + "io/fs" "math/big" "os" "path" "path/filepath" + "sort" "strings" "time" + "github.com/bmatcuk/doublestar/v4" "github.com/deckhouse/delivery-kit-sdk/pkg/signver" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -27,24 +30,62 @@ import ( "github.com/werf/werf/v3/pkg/config" "github.com/werf/werf/v3/pkg/container_backend" "github.com/werf/werf/v3/pkg/image" + "github.com/werf/werf/v3/pkg/slug" ) type signFilesContainerBackendStub struct { *ContainerBackendStub - contentByTargetPlatform map[string][]byte + filesByTargetPlatform map[string]map[string][]byte } var _ container_backend.ContainerBackend = (*signFilesContainerBackendStub)(nil) -func (backend *signFilesContainerBackendStub) ReadFileFromImage(_ context.Context, _, filePath string, opts container_backend.ReadFileFromImageOpts) ([]byte, error) { - content, found := backend.contentByTargetPlatform[opts.TargetPlatform] +func (backend *signFilesContainerBackendStub) OpenImageReader(_ context.Context, _ string, opts container_backend.ReadFileFromImageOpts) (container_backend.ImageReader, error) { + files, found := backend.filesByTargetPlatform[opts.TargetPlatform] if !found { - return nil, fmt.Errorf("read %s: no image built for platform %q", filePath, opts.TargetPlatform) + return nil, fmt.Errorf("no image built for platform %q", opts.TargetPlatform) + } + return &signFilesImageReaderStub{files: files}, nil +} + +type signFilesImageReaderStub struct { + files map[string][]byte +} + +var _ container_backend.ImageReader = (*signFilesImageReaderStub)(nil) + +func (r *signFilesImageReaderStub) ReadFile(_ context.Context, filePath string) ([]byte, error) { + content, found := r.files[filePath] + if !found { + return nil, fmt.Errorf("read %s: %w", filePath, fs.ErrNotExist) } return content, nil } +func (r *signFilesImageReaderStub) Glob(_ context.Context, pattern string) ([]string, error) { + var matches []string + for filePath := range r.files { + matched, err := doublestar.Match(pattern, filePath) + if err != nil { + return nil, err + } + if matched { + matches = append(matches, filePath) + } + } + sort.Strings(matches) + return matches, nil +} + +func (r *signFilesImageReaderStub) ReadDir(_ context.Context, _, _ string, _ container_backend.ReadDirOpts) error { + return fmt.Errorf("not implemented") +} + +func (r *signFilesImageReaderStub) Close(_ context.Context) error { + return nil +} + var _ = Describe("SignFilesStage", func() { newSigner := func(cn string) *signing.Signer { key, err := rsa.GenerateKey(rand.Reader, 2048) @@ -134,8 +175,11 @@ var _ = Describe("SignFilesStage", func() { "linux/arm64": []byte("arm64 root hash"), } backend := &signFilesContainerBackendStub{ - ContainerBackendStub: NewContainerBackendStub(), - contentByTargetPlatform: contentByTargetPlatform, + ContainerBackendStub: NewContainerBackendStub(), + filesByTargetPlatform: map[string]map[string][]byte{ + "linux/amd64": {signedPath: contentByTargetPlatform["linux/amd64"]}, + "linux/arm64": {signedPath: contentByTargetPlatform["linux/arm64"]}, + }, } conveyor := NewConveyorStub(NewGiterminismManagerStub(NewLocalGitRepoStub("9d8059842b6fde712c58315ca0ab4713d90761c0"), NewGiterminismInspectorStub()), nil, nil) imageConfig := &config.StapelImageBase{Name: "sysext", Sign: []*config.Sign{{Path: signedPath}}} @@ -192,6 +236,117 @@ var _ = Describe("SignFilesStage", func() { "preparing another platform must not overwrite the signature the first platform still has to install") }) + Describe("glob patterns", func() { + const targetPlatform = "linux/amd64" + + prepare := func(ctx context.Context, files map[string][]byte, signs ...*config.Sign) (string, error) { + backend := &signFilesContainerBackendStub{ + ContainerBackendStub: NewContainerBackendStub(), + filesByTargetPlatform: map[string]map[string][]byte{targetPlatform: files}, + } + conveyor := NewConveyorStub(NewGiterminismManagerStub(NewLocalGitRepoStub("9d8059842b6fde712c58315ca0ab4713d90761c0"), NewGiterminismInspectorStub()), nil, nil) + imageConfig := &config.StapelImageBase{Name: "kernel", Sign: signs} + imageTmpDir := filepath.Join(GinkgoT().TempDir(), "image", imageConfig.Name) + stage := GenerateSignFilesAfterInstallStage(imageConfig, enabledOptions(newSigner("a")), &BaseStageOptions{ + TargetPlatform: targetPlatform, + ImageName: imageConfig.Name, + ImageTmpDir: imageTmpDir, + ContainerWerfDir: "/.werf", + }) + Expect(stage).NotTo(BeNil()) + + prevBuiltImage := container_backend.NewLegacyStageImage(nil, "previous", backend, targetPlatform) + prevBuiltImage.SetStageDesc(&image.StageDesc{Info: &image.Info{}}) + stageImage := NewStageImage(backend, prevBuiltImage.Name(), container_backend.NewLegacyStageImage(prevBuiltImage, "result", backend, targetPlatform)) + + if err := stage.PrepareImage(ctx, conveyor, backend, &StageImage{Image: prevBuiltImage}, stageImage, nil); err != nil { + return "", err + } + return filepath.Join(imageTmpDir, "sign", slug.Slug(targetPlatform), string(stage.Name())), nil + } + + verifies := func(signaturePath string, content []byte) error { + der, err := os.ReadFile(signaturePath) + if err != nil { + return err + } + p7, err := pkcs7.Parse(der) + if err != nil { + return err + } + p7.Content = content + return p7.Verify() + } + + It("signs every regular file the pattern matches, in sorted order, and nothing else", func(ctx SpecContext) { + files := map[string][]byte{ + "/out/lib/modules/6.12/kernel/net/b.ko": []byte("module b"), + "/out/lib/modules/6.12/kernel/a.ko": []byte("module a"), + "/out/lib/modules/6.12/modules.order": []byte("not a module"), + "/out/lib/modules/6.12/kernel/net/b.ko.d": []byte("not a module either"), + } + + signaturesDir, err := prepare(ctx, files, &config.Sign{Path: "/out/lib/modules/**/*.ko", After: "install"}) + Expect(err).NotTo(HaveOccurred()) + + script, err := os.ReadFile(filepath.Join(signaturesDir, "install.sh")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(script)).To(ContainSubstring(" /out/lib/modules/6.12/kernel/a.ko.p7s\n")) + Expect(string(script)).To(ContainSubstring(" /out/lib/modules/6.12/kernel/net/b.ko.p7s\n")) + Expect(string(script)).NotTo(ContainSubstring("modules.order")) + Expect(string(script)).NotTo(ContainSubstring("b.ko.d")) + + Expect(verifies(filepath.Join(signaturesDir, "0", "a.ko.p7s"), files["/out/lib/modules/6.12/kernel/a.ko"])).To(Succeed()) + Expect(verifies(filepath.Join(signaturesDir, "1", "b.ko.p7s"), files["/out/lib/modules/6.12/kernel/net/b.ko"])).To(Succeed()) + }) + + It("treats every character but * literally, so file names with ?, [ or { need no escaping", func(ctx SpecContext) { + files := map[string][]byte{ + "/out/lib[64]/a.ko": []byte("a"), + "/out/lib6/a.ko": []byte("not under lib[64]"), + "/out/file[1].bin": []byte("literal path"), + "/out/file1.bin": []byte("would match [1] as a class"), + "/out/{x}/what?.bin": []byte("braces and question mark"), + "/out/{x}/what!.bin": []byte("would match ? as a wildcard"), + "/out/lib[64]/b.ko.bak": []byte("not a .ko"), + } + + signaturesDir, err := prepare(ctx, files, + &config.Sign{Path: "/out/lib[64]/*.ko", After: "install"}, + &config.Sign{Path: "/out/file[1].bin", After: "install"}, + &config.Sign{Path: "/out/{x}/what?.b*", After: "install"}, + ) + Expect(err).NotTo(HaveOccurred()) + + script, err := os.ReadFile(filepath.Join(signaturesDir, "install.sh")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(script)).To(ContainSubstring("/out/lib[64]/a.ko.p7s")) + Expect(string(script)).To(ContainSubstring("/out/file[1].bin.p7s")) + Expect(string(script)).To(ContainSubstring("/out/{x}/what?.bin.p7s")) + Expect(string(script)).NotTo(ContainSubstring("lib6/")) + Expect(string(script)).NotTo(ContainSubstring("file1.bin")) + Expect(string(script)).NotTo(ContainSubstring("what!.bin")) + }) + + It("fails when the pattern matches no file, since a declared signature is a requirement of the image", func(ctx SpecContext) { + _, err := prepare(ctx, map[string][]byte{"/out/notes.txt": []byte("x")}, &config.Sign{Path: "/out/**/*.ko", After: "install"}) + Expect(err).To(MatchError(ContainSubstring("`path: /out/**/*.ko` of sign in image \"kernel\" matches no file"))) + }) + + It("rejects a file reached through two entries", func(ctx SpecContext) { + _, err := prepare(ctx, map[string][]byte{"/out/a.ko": []byte("a")}, + &config.Sign{Path: "/out/*.ko", After: "install"}, + &config.Sign{Path: "/out/a.ko", After: "install"}, + ) + Expect(err).To(MatchError(ContainSubstring("file /out/a.ko of image \"kernel\" is signed by both `path: /out/*.ko` and `path: /out/a.ko` of sign"))) + }) + + It("rejects a matched file that is the signature of another file signed at the same stage", func(ctx SpecContext) { + _, err := prepare(ctx, map[string][]byte{"/out/a": []byte("a"), "/out/a.p7s": []byte("old signature")}, &config.Sign{Path: "/out/*", After: "install"}) + Expect(err).To(MatchError(ContainSubstring("file /out/a.p7s of image \"kernel\" matched by `path: /out/*` of sign is the signature of /out/a signed at the same stage"))) + }) + }) + It("is generated only for the position the directive names and only when enabled", func() { imageConfig := &config.StapelImageBase{Sign: []*config.Sign{{Path: "/out/x", Before: "install"}}} options := enabledOptions(newSigner("a")) diff --git a/pkg/config/raw_stapel_image.go b/pkg/config/raw_stapel_image.go index 3eabe098c7..c4c6a2a595 100644 --- a/pkg/config/raw_stapel_image.go +++ b/pkg/config/raw_stapel_image.go @@ -206,18 +206,22 @@ func (c *rawStapelImage) toStapelImageBaseDirective(ctx context.Context, gitermi if _, exist := signByPath[signDirective.Path]; exist { return nil, newDetailedConfigError(fmt.Sprintf("duplicated `path: %s` for sign!", signDirective.Path), rawSign, c.doc) } + signByPath[signDirective.Path] = signDirective + imageBase.Sign = append(imageBase.Sign, signDirective) + // The files of a pattern are known only at signing time, where the same + // conflicts are checked against the expanded set. + if signDirective.IsPattern() { + continue + } // All entries read the previous image, so an output cannot also be a signed // input in the same stage. - if other, exist := signByPath[signDirective.OutputPath()]; exist && other.Before == signDirective.Before && other.After == signDirective.After { + if other, exist := signByPath[signDirective.OutputPath()]; exist && !other.IsPattern() && other.Before == signDirective.Before && other.After == signDirective.After { return nil, newDetailedConfigError(fmt.Sprintf("conflicting `path: %s` for sign: its signature `%s` is signed at the same stage!", signDirective.Path, signDirective.OutputPath()), rawSign, c.doc) } if other, exist := signByOutputPath[signDirective.Path]; exist && other.Before == signDirective.Before && other.After == signDirective.After { return nil, newDetailedConfigError(fmt.Sprintf("conflicting `path: %s` for sign: it is the signature of `path: %s` signed at the same stage!", signDirective.Path, other.Path), rawSign, c.doc) } - signByPath[signDirective.Path] = signDirective signByOutputPath[signDirective.OutputPath()] = signDirective - - imageBase.Sign = append(imageBase.Sign, signDirective) } for _, rawDep := range c.RawDependencies { diff --git a/pkg/config/raw_stapel_image_test.go b/pkg/config/raw_stapel_image_test.go index b2e278012a..a570631b24 100644 --- a/pkg/config/raw_stapel_image_test.go +++ b/pkg/config/raw_stapel_image_test.go @@ -117,6 +117,34 @@ var _ = Describe("rawStapelImage", func() { {Path: "/out/a.roothash.p7s"}, }, + platform: []string{}, + final: true, + }, + }, + ), + Entry( + "should handle sign with glob patterns, whose conflicts are checked against the files they expand to", + map[string]interface{}{ + "image": "image1", + "from": "alpine:latest", + "sign": []map[string]interface{}{ + {"path": "/out/lib/modules/**/*.ko", "after": "install"}, + {"path": "/out/*.ko.p7s", "after": "install"}, + {"path": "/out/lib/modules/**/*.ko.p7s", "after": "install"}, + }, + }, + &StapelImage{ + StapelImageBase: &StapelImageBase{ + Name: "image1", + From: "alpine:latest", + Git: &GitManager{}, + Secrets: []Secret{}, + Sign: []*Sign{ + {Path: "/out/lib/modules/**/*.ko", After: "install"}, + {Path: "/out/*.ko.p7s", After: "install"}, + {Path: "/out/lib/modules/**/*.ko.p7s", After: "install"}, + }, + platform: []string{}, final: true, }, @@ -452,6 +480,14 @@ var _ = Describe("rawStapelImage", func() { }, }, ), + Entry( + "with sign glob pattern rooted at /", + map[string]interface{}{ + "image": "image1", + "from": "alpine", + "sign": []map[string]interface{}{{"path": "/**/*.ko", "after": "install"}}, + }, + ), Entry( "with sign path and its signature at the end of the image", map[string]interface{}{ diff --git a/pkg/config/sign.go b/pkg/config/sign.go index 278b74fa26..ccdb592f6c 100644 --- a/pkg/config/sign.go +++ b/pkg/config/sign.go @@ -3,11 +3,17 @@ package config import ( "fmt" "path" + "strings" + + "github.com/bmatcuk/doublestar/v4" ) -// Sign declares a file of the image whose detached PKCS#7 signature werf writes -// next to it as .p7s, right after the stage named by Before or After, or at -// the end of the image when neither is set. +// Sign declares files of the image whose detached PKCS#7 signatures werf writes next +// to them as .p7s, right after the stage named by Before or After, or at the end +// of the image when neither is set. Path is either one absolute file path or, when it +// contains `*`, an absolute glob pattern expanded against the image filesystem when the +// signing stage runs. `*` (and `**`) is the only metacharacter; every other character, +// `?`, `[` and `{` included, stands for itself. type Sign struct { Path string Before string @@ -20,8 +26,34 @@ func (c *Sign) GetRaw() interface{} { return c.raw } +// IsPattern reports whether Path is a glob pattern rather than one file path. +func (c *Sign) IsPattern() bool { + return strings.Contains(c.Path, "*") +} + +// Pattern is Path as a doublestar pattern: the characters doublestar would otherwise +// read as syntax are escaped, so that only `*` matches anything but itself. +func (c *Sign) Pattern() string { + return doublestarEscaper.Replace(c.Path) +} + +var doublestarEscaper = strings.NewReplacer(`\`, `\\`, `?`, `\?`, `[`, `\[`, `]`, `\]`, `{`, `\{`, `}`, `\}`) + +// OutputPath is the signature path of a single-file entry; it is meaningless for a +// pattern, whose files are known only at signing time. func (c *Sign) OutputPath() string { - return c.Path + ".p7s" + return SignatureOutputPath(c.Path) +} + +func SignatureOutputPath(filePath string) string { + return filePath + ".p7s" +} + +// patternBase is the literal directory a pattern is expanded under; the whole image +// filesystem is too much to stream out for one signing stage. +func patternBase(pattern string) string { + base, _ := doublestar.SplitPattern(pattern) + return base } func (c *Sign) validate() error { @@ -29,7 +61,9 @@ func (c *Sign) validate() error { case c.Path == "": return newDetailedConfigError("`path: /PATH/TO/FILE` required for sign!", c.raw, c.raw.rawStapelImage.doc) case !path.IsAbs(c.Path) || path.Clean(c.Path) != c.Path || c.Path == "/": - return newDetailedConfigError(fmt.Sprintf("invalid `path: %s` for sign: expected an absolute clean file path!", c.Path), c.raw, c.raw.rawStapelImage.doc) + return newDetailedConfigError(fmt.Sprintf("invalid `path: %s` for sign: expected an absolute clean file path or glob pattern!", c.Path), c.raw, c.raw.rawStapelImage.doc) + case c.IsPattern() && patternBase(c.Pattern()) == "/": + return newDetailedConfigError(fmt.Sprintf("invalid `path: %s` for sign: a glob pattern must start with a literal directory below /!", c.Path), c.raw, c.raw.rawStapelImage.doc) case c.Before != "" && c.After != "": return newDetailedConfigError("specify only one stage using `before: install|setup` or `after: install|setup` for sign!", c.raw, c.raw.rawStapelImage.doc) case c.Before != "" && checkInvalidRelation(c.Before): diff --git a/pkg/container_backend/buildah_image_reader.go b/pkg/container_backend/buildah_image_reader.go index d5216acf1a..ab7b8744d4 100644 --- a/pkg/container_backend/buildah_image_reader.go +++ b/pkg/container_backend/buildah_image_reader.go @@ -2,41 +2,129 @@ package container_backend import ( "context" + "errors" "fmt" + "io/fs" "os" + "path" + "path/filepath" + "sort" + + "github.com/bmatcuk/doublestar/v4" "github.com/werf/logboek" ) func (backend *BuildahBackend) ReadFileFromImage(ctx context.Context, imageRef, path string, opts ReadFileFromImageOpts) ([]byte, error) { - containers, err := backend.createContainers(ctx, []string{imageRef}, CommonOpts(opts)) + reader, err := backend.OpenImageReader(ctx, imageRef, opts) if err != nil { return nil, err } - container := containers[0] defer func() { - if err := backend.removeContainers(ctx, []*containerDesc{container}, CommonOpts(opts)); err != nil { - logboek.Context(ctx).Error().LogF("ERROR: unable to remove temporal container %q: %s\n", container.Name, err) + if err := reader.Close(ctx); err != nil { + logboek.Context(ctx).Error().LogF("ERROR: %s\n", err) } }() + return reader.ReadFile(ctx, path) +} + +var _ ImageReader = (*buildahImageReader)(nil) + +// buildahImageReader reads paths out of one container of the image, mounted once in +// OpenImageReader and released in Close. +type buildahImageReader struct { + backend *BuildahBackend + imageRef string + container *containerDesc + opts CommonOpts +} + +func (backend *BuildahBackend) OpenImageReader(ctx context.Context, imageRef string, opts ReadFileFromImageOpts) (ImageReader, error) { + containers, err := backend.createContainers(ctx, []string{imageRef}, CommonOpts(opts)) + if err != nil { + return nil, err + } + container := containers[0] + if err := backend.mountContainers(ctx, []*containerDesc{container}, CommonOpts(opts)); err != nil { + if rmErr := backend.removeContainers(ctx, []*containerDesc{container}, CommonOpts(opts)); rmErr != nil { + logboek.Context(ctx).Error().LogF("ERROR: unable to remove temporal container %q: %s\n", container.Name, rmErr) + } return nil, fmt.Errorf("mount container %q: %w", container.Name, err) } - defer func() { - if err := backend.unmountContainers(ctx, []*containerDesc{container}, CommonOpts(opts)); err != nil { - logboek.Context(ctx).Error().LogF("ERROR: unable to unmount container %q: %s\n", container.Name, err) - } - }() - data, err := readRegularFileFromRoot(container.RootMount, path) - if err != nil { - return nil, fmt.Errorf("read %s from image %q: %w", path, imageRef, err) + return &buildahImageReader{backend: backend, imageRef: imageRef, container: container, opts: CommonOpts(opts)}, nil +} + +// Close removes the container even when unmounting it fails: the caller only logs the +// error, so this is the last chance to release the container. +func (r *buildahImageReader) Close(ctx context.Context) error { + var errs []error + if err := r.backend.unmountContainers(ctx, []*containerDesc{r.container}, r.opts); err != nil { + errs = append(errs, fmt.Errorf("unmount container %q: %w", r.container.Name, err)) } + if err := r.backend.removeContainers(ctx, []*containerDesc{r.container}, r.opts); err != nil { + errs = append(errs, fmt.Errorf("remove container %q: %w", r.container.Name, err)) + } + return errors.Join(errs...) +} +func (r *buildahImageReader) ReadFile(_ context.Context, path string) ([]byte, error) { + data, err := readRegularFileFromRoot(r.container.RootMount, path) + if err != nil { + return nil, fmt.Errorf("read %s from image %q: %w", path, r.imageRef, err) + } return data, nil } +// Glob walks the pattern's literal base directory inside the mounted root. The base is +// resolved against the container root, so a symlink there never leads to a host path; +// below it the walk does not descend into symlinks at all. A missing base is no match. +func (r *buildahImageReader) Glob(_ context.Context, pattern string) ([]string, error) { + base, _ := doublestar.SplitPattern(pattern) + resolvedBase, err := resolveContainerRootPath(r.container.RootMount, base) + if err != nil { + return nil, err + } + + var matches []string + err = filepath.WalkDir(resolvedBase, func(hostPath string, d fs.DirEntry, err error) error { + if err != nil { + if hostPath == resolvedBase && os.IsNotExist(err) { + return filepath.SkipAll + } + return err + } + if !d.Type().IsRegular() { + return nil + } + rel, err := filepath.Rel(resolvedBase, hostPath) + if err != nil { + return err + } + filePath := path.Join(base, filepath.ToSlash(rel)) + matched, err := doublestar.Match(pattern, filePath) + if err != nil { + return fmt.Errorf("match %s against %q: %w", filePath, pattern, err) + } + if matched { + matches = append(matches, filePath) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("glob %q in image %q: %w", pattern, r.imageRef, err) + } + sort.Strings(matches) + return matches, nil +} + +// ReadDir's only caller, the SBOM directory scan, rejects the Buildah backend upfront. +func (r *buildahImageReader) ReadDir(_ context.Context, _, _ string, _ ReadDirOpts) error { + return fmt.Errorf("ReadDir is not supported with the Buildah backend") +} + // readRegularFileFromRoot reads path inside the mounted container root, following // symlinks against that root rather than the host, and accepts regular files only, // matching what the Docker reader yields for the same path. @@ -55,15 +143,8 @@ func readRegularFileFromRoot(rootMount, path string) ([]byte, error) { return os.ReadFile(resolvedPath) } -// OpenImageReader is intentionally unsupported on the Buildah backend: the only caller is -// the SBOM directory-scan flow, which rejects Buildah upfront (see the SBOM guard in the -// conveyor), so a ContainerBackend backed by Buildah never reaches an image reader. -func (backend *BuildahBackend) OpenImageReader(ctx context.Context, imageRef string, opts ReadFileFromImageOpts) (ImageReader, error) { - return nil, fmt.Errorf("OpenImageReader is not supported with the Buildah backend") -} - -// RunCommandInImage is unsupported on the Buildah backend for the same reason as -// OpenImageReader: its only caller is the SBOM flow, which rejects Buildah upfront. +// RunCommandInImage is unsupported on the Buildah backend: its only caller is the SBOM +// flow, which rejects Buildah upfront. func (backend *BuildahBackend) RunCommandInImage(ctx context.Context, imageRef string, opts RunCommandInImageOpts) ([]byte, error) { return nil, fmt.Errorf("RunCommandInImage is not supported with the Buildah backend") } diff --git a/pkg/container_backend/buildah_image_reader_test.go b/pkg/container_backend/buildah_image_reader_test.go index 74bfb47a79..71d1053090 100644 --- a/pkg/container_backend/buildah_image_reader_test.go +++ b/pkg/container_backend/buildah_image_reader_test.go @@ -44,4 +44,34 @@ var _ = Describe("BuildahBackend ReadFileFromImage path resolution", func() { _, err = readRegularFileFromRoot(root, "/out/dangling") Expect(err).To(MatchError(os.ErrNotExist)) }) + + Describe("Glob", func() { + It("lists the regular files under the resolved base directory that the pattern matches, without following symlinks", func(ctx SpecContext) { + base := GinkgoT().TempDir() + root := filepath.Join(base, "root") + modules := filepath.Join(root, "usr", "lib", "modules", "6.12", "kernel") + Expect(os.MkdirAll(filepath.Join(modules, "net"), 0o755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(modules, "a.ko"), []byte("a"), 0o644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(modules, "net", "b.ko"), []byte("b"), 0o644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(modules, "net", "b.ko.d"), []byte("d"), 0o644)).To(Succeed()) + Expect(os.Symlink("a.ko", filepath.Join(modules, "link.ko"))).To(Succeed()) + Expect(os.MkdirAll(filepath.Join(base, "host"), 0o755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(base, "host", "c.ko"), []byte("host"), 0o644)).To(Succeed()) + Expect(os.Symlink("../../../../../host", filepath.Join(modules, "escape"))).To(Succeed()) + Expect(os.Symlink("/usr/lib", filepath.Join(root, "lib"))).To(Succeed()) + + reader := &buildahImageReader{container: &containerDesc{RootMount: root}} + + Expect(reader.Glob(ctx, "/lib/modules/**/*.ko")).To(Equal([]string{ + "/lib/modules/6.12/kernel/a.ko", + "/lib/modules/6.12/kernel/net/b.ko", + }), "the base directory is resolved through the container's own symlink, below it symlinks are not followed") + }) + + It("matches nothing when the base directory does not exist", func(ctx SpecContext) { + reader := &buildahImageReader{container: &containerDesc{RootMount: GinkgoT().TempDir()}} + + Expect(reader.Glob(ctx, "/out/**/*.ko")).To(BeEmpty()) + }) + }) }) diff --git a/pkg/container_backend/docker_image_reader.go b/pkg/container_backend/docker_image_reader.go index f0fa3b71bd..80af1460cc 100644 --- a/pkg/container_backend/docker_image_reader.go +++ b/pkg/container_backend/docker_image_reader.go @@ -4,13 +4,17 @@ import ( "archive/tar" "bytes" "context" + "errors" "fmt" "io" "io/fs" "os" + "path" "path/filepath" + "sort" "strings" + "github.com/bmatcuk/doublestar/v4" "github.com/docker/docker/client" "github.com/google/uuid" @@ -73,6 +77,20 @@ func (r *dockerImageReader) ReadFile(ctx context.Context, path string) ([]byte, return collector.result() } +// Glob streams the pattern's literal base directory out of the container and keeps the +// regular-file entries the pattern matches. A missing base directory is no match. +func (r *dockerImageReader) Glob(ctx context.Context, pattern string) ([]string, error) { + collector := newGlobTarCollector(pattern) + err := r.copy(ctx, collector.base, collector.collect) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + return collector.result(), nil +} + // fileTarCollector takes the content of the object a `docker cp ` stream was // asked for. That object is the stream's first entry and whatever it contains follows // it, so a directory has to be refused there: otherwise an arbitrary file inside it @@ -112,6 +130,52 @@ func (c *fileTarCollector) result() ([]byte, error) { return c.data, nil } +// globTarCollector matches the regular-file entries of a `docker cp ` stream +// against a doublestar pattern whose literal prefix is base. The stream names its +// entries from the base name of the copied directory on (`out/a.ko` for base `/out`), +// so joining them onto the base's parent gives the in-image path the pattern is written +// for. A base of `/` never reaches here: the config rejects it. +type globTarCollector struct { + base string + pattern string + regular map[string]struct{} + matches []string +} + +func newGlobTarCollector(pattern string) *globTarCollector { + base, _ := doublestar.SplitPattern(pattern) + return &globTarCollector{base: base, pattern: pattern, regular: map[string]struct{}{}} +} + +// collect keeps the regular files. The second and later names of one inode arrive as +// hard links to the first name, so those count when the first was a regular file. +func (c *globTarCollector) collect(_ *tar.Reader, hdr *tar.Header) error { + switch hdr.Typeflag { + case tar.TypeReg: + c.regular[hdr.Name] = struct{}{} + case tar.TypeLink: + if _, ok := c.regular[hdr.Linkname]; !ok { + return nil + } + default: + return nil + } + filePath := path.Join(path.Dir(c.base), hdr.Name) + matched, err := doublestar.Match(c.pattern, filePath) + if err != nil { + return fmt.Errorf("match %s against %q: %w", filePath, c.pattern, err) + } + if matched { + c.matches = append(c.matches, filePath) + } + return nil +} + +func (c *globTarCollector) result() []string { + sort.Strings(c.matches) + return c.matches +} + func (r *dockerImageReader) ReadDir(ctx context.Context, path, destDir string, opts ReadDirOpts) error { extractor := newDirTarExtractor(path, destDir, opts.FileNamePatterns) if err := r.copy(ctx, path, extractor.extract); err != nil { diff --git a/pkg/container_backend/docker_image_reader_test.go b/pkg/container_backend/docker_image_reader_test.go index 9f0569357b..5e095c869e 100644 --- a/pkg/container_backend/docker_image_reader_test.go +++ b/pkg/container_backend/docker_image_reader_test.go @@ -20,7 +20,7 @@ var _ = Describe("fileTarCollector", func() { // feed mimics what `docker cp ` streams: the requested object first, named // by its base name, followed by whatever it contains. - feed := func(collector *fileTarCollector, entries []entry) error { + feed := func(collect func(*tar.Reader, *tar.Header) error, entries []entry) error { var buf bytes.Buffer tw := tar.NewWriter(&buf) for _, e := range entries { @@ -43,7 +43,7 @@ var _ = Describe("fileTarCollector", func() { return nil } Expect(err).To(Succeed()) - if err := collector.collect(tr, hdr); err != nil { + if err := collect(tr, hdr); err != nil { return err } } @@ -52,7 +52,7 @@ var _ = Describe("fileTarCollector", func() { It("reads the requested regular file", func() { collector := &fileTarCollector{path: "/out/ext.roothash", imageRef: "image"} - Expect(feed(collector, []entry{ + Expect(feed(collector.collect, []entry{ {name: "ext.roothash", typeflag: tar.TypeReg, content: "roothash"}, })).To(Succeed()) Expect(collector.result()).To(Equal([]byte("roothash"))) @@ -61,7 +61,7 @@ var _ = Describe("fileTarCollector", func() { It("refuses a directory instead of returning a file it contains", func() { collector := &fileTarCollector{path: "/out", imageRef: "image"} - err := feed(collector, []entry{ + err := feed(collector.collect, []entry{ {name: "out/", typeflag: tar.TypeDir}, {name: "out/ext.roothash", typeflag: tar.TypeReg, content: "roothash"}, }) @@ -71,11 +71,47 @@ var _ = Describe("fileTarCollector", func() { It("reports a path that holds no regular file as not existing", func() { collector := &fileTarCollector{path: "/out/link", imageRef: "image"} - Expect(feed(collector, []entry{ + Expect(feed(collector.collect, []entry{ {name: "link", typeflag: tar.TypeSymlink, linkname: "/elsewhere"}, })).To(Succeed()) _, err := collector.result() Expect(err).To(MatchError(fs.ErrNotExist)) }) + + Describe("globTarCollector", func() { + It("rebases the entries of the copied base directory onto the image path and keeps the regular files the pattern matches, hard links included, sorted", func() { + collector := newGlobTarCollector("/out/lib/modules/**/*.ko") + Expect(collector.base).To(Equal("/out/lib/modules")) + + Expect(feed(collector.collect, []entry{ + {name: "modules", typeflag: tar.TypeDir}, + {name: "modules/6.12", typeflag: tar.TypeDir}, + {name: "modules/6.12/kernel/net/b.ko", typeflag: tar.TypeReg, content: "b"}, + {name: "modules/6.12/kernel/a.ko", typeflag: tar.TypeReg, content: "a"}, + {name: "modules/6.12/modules.order", typeflag: tar.TypeReg, content: "order"}, + {name: "modules/6.12/kernel/link.ko", typeflag: tar.TypeSymlink, linkname: "a.ko"}, + {name: "modules/6.12/kernel/hard.ko", typeflag: tar.TypeLink, linkname: "modules/6.12/kernel/a.ko"}, + {name: "modules/6.12/kernel/hard-to-link.ko", typeflag: tar.TypeLink, linkname: "modules/6.12/kernel/link.ko"}, + })).To(Succeed()) + + Expect(collector.result()).To(Equal([]string{ + "/out/lib/modules/6.12/kernel/a.ko", + "/out/lib/modules/6.12/kernel/hard.ko", + "/out/lib/modules/6.12/kernel/net/b.ko", + }), "a hard link to a regular file is that file under another name; a hard link to a symlink is not") + }) + + It("matches a file directly under the base directory when the pattern allows it", func() { + collector := newGlobTarCollector("/out/*.roothash") + + Expect(feed(collector.collect, []entry{ + {name: "out", typeflag: tar.TypeDir}, + {name: "out/ext.roothash", typeflag: tar.TypeReg, content: "x"}, + {name: "out/sub/ext.roothash", typeflag: tar.TypeReg, content: "y"}, + })).To(Succeed()) + + Expect(collector.result()).To(Equal([]string{"/out/ext.roothash"})) + }) + }) }) diff --git a/pkg/container_backend/interface.go b/pkg/container_backend/interface.go index ecb61a56ff..6ad010eb66 100644 --- a/pkg/container_backend/interface.go +++ b/pkg/container_backend/interface.go @@ -53,6 +53,10 @@ type ImageReader interface { // ReadFile returns the content of the regular file at path. When there is no // regular file at path, the returned error wraps fs.ErrNotExist. ReadFile(ctx context.Context, path string) ([]byte, error) + // Glob returns the sorted absolute paths of the regular files matching pattern, an + // absolute doublestar pattern. Symlinks are neither matched nor followed, so a file + // is listed under its real path only. No match is not an error. + Glob(ctx context.Context, pattern string) ([]string, error) // ReadDir copies the directory tree at path into destDir on the host, preserving // the layout relative to path. Only regular files are written; a symlink to a // directory inside the tree is materialized as a copy of its target. When there is diff --git a/schemas/werf.json b/schemas/werf.json index f9d7878db9..88306f3f60 100644 --- a/schemas/werf.json +++ b/schemas/werf.json @@ -1515,7 +1515,7 @@ ], "properties": { "path": { - "description": "The absolute path of the file to sign.", + "description": "The absolute path of the file to sign, or an absolute glob pattern expanded against the image filesystem when the signing stage runs.", "type": "string", "pattern": "^/" }, diff --git a/test/e2e/build/_fixtures/sign_files/state_glob/werf.yaml b/test/e2e/build/_fixtures/sign_files/state_glob/werf.yaml new file mode 100644 index 0000000000..729f8e5c0f --- /dev/null +++ b/test/e2e/build/_fixtures/sign_files/state_glob/werf.yaml @@ -0,0 +1,23 @@ +project: werf-test-e2e-sign-files-glob +configVersion: 1 +--- +image: kernel +from: alpine:3.20 +shell: + install: + - mkdir -p /out/lib/modules/6.12/kernel/net + - printf '%s' 3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b > /out/lib/modules/6.12/kernel/a.ko + - printf '%s' 9f2c04a1b8e3d6570ac41b2e8d93f5061c7ab38e4d2f90b5c6137ae8d40f2b9c > /out/lib/modules/6.12/kernel/net/b.ko + - printf '%s' 'kernel/a.ko' > /out/lib/modules/6.12/modules.order + - ln -s a.ko /out/lib/modules/6.12/kernel/link.ko + - ln /out/lib/modules/6.12/kernel/a.ko /out/lib/modules/6.12/kernel/hard.ko + setup: + - test -s /out/lib/modules/6.12/kernel/a.ko.p7s + - test -s /out/lib/modules/6.12/kernel/net/b.ko.p7s + - test -s /out/lib/modules/6.12/kernel/hard.ko.p7s + - "! cmp -s /out/lib/modules/6.12/kernel/a.ko.p7s /out/lib/modules/6.12/kernel/net/b.ko.p7s" + - "! test -e /out/lib/modules/6.12/modules.order.p7s" + - "! test -e /out/lib/modules/6.12/kernel/link.ko.p7s" +sign: +- path: /out/lib/modules/**/*.ko + after: install diff --git a/test/e2e/build/sign_files_test.go b/test/e2e/build/sign_files_test.go index 365e6284d3..94350cb39c 100644 --- a/test/e2e/build/sign_files_test.go +++ b/test/e2e/build/sign_files_test.go @@ -1,6 +1,7 @@ package e2e_build_test import ( + "archive/tar" "crypto" "crypto/rsa" "crypto/sha256" @@ -170,6 +171,56 @@ var _ = Describe("sign directive", Label("e2e", "sign-files", "simple"), func() }}), ) + DescribeTable("signs every regular file a glob pattern matches", + func(ctx SpecContext, testOpts signFilesTestOptions) { + setupEnv(testOpts.setupEnvOptions) + + SuiteData.InitTestRepo(ctx, "repo0", "sign_files/state_glob") + werfProject := report.NewProjectWithReport(werf.NewProject(SuiteData.WerfBinPath, SuiteData.GetTestRepoPath("repo0"))) + + buildOut, buildReport := werfProject.BuildWithReport(ctx, SuiteData.GetBuildReportPath("report0.json"), &werf.WithReportOptions{CommonOptions: werf.CommonOptions{ExtraArgs: signArgs}}) + Expect(buildOut).To(ContainSubstring("Signing /out/lib/modules/6.12/kernel/a.ko")) + Expect(buildOut).To(ContainSubstring("Signing /out/lib/modules/6.12/kernel/net/b.ko")) + Expect(buildOut).NotTo(ContainSubstring("Signing /out/lib/modules/6.12/modules.order")) + Expect(buildOut).To(ContainSubstring("Building stage kernel/setup")) + + files := map[string][]byte{} + hardLinks := map[string]string{} + img := loadImageFromRegistry(buildReport.Images["kernel"].DockerImageName) + utils.ForEachImageFile(SuiteData.TmpDir, img, func(header *elfTar.Header, tmpLocalPath string) { + name := "/" + strings.TrimPrefix(header.Name, "/") + if !strings.HasPrefix(name, "/out/lib/modules/") { + return + } + if header.Typeflag == tar.TypeLink { + hardLinks[name] = "/" + strings.TrimPrefix(header.Linkname, "/") + return + } + if tmpLocalPath == "" { + return + } + data, err := os.ReadFile(tmpLocalPath) + Expect(err).To(Succeed()) + files[name] = data + }) + for name, target := range hardLinks { + files[name] = files[target] + } + for _, module := range []string{"/out/lib/modules/6.12/kernel/a.ko", "/out/lib/modules/6.12/kernel/hard.ko", "/out/lib/modules/6.12/kernel/net/b.ko"} { + Expect(files).To(HaveKey(module + ".p7s")) + expectSystemdProfile(files[module], files[module+".p7s"]) + } + }, + backendEntry("with repo using Docker", signFilesTestOptions{setupEnvOptions: setupEnvOptions{ + ContainerBackendMode: "docker", + WithLocalRepo: true, + }}), + backendEntry("with repo using Native Rootless", signFilesTestOptions{setupEnvOptions: setupEnvOptions{ + ContainerBackendMode: "native-rootless", + WithLocalRepo: true, + }}), + ) + DescribeTable("keeps the build offline under build.sbom.enable and still signs", func(ctx SpecContext, testOpts signFilesTestOptions) { setupEnv(testOpts.setupEnvOptions) diff --git a/test/mock/container_backend.go b/test/mock/container_backend.go index b1fb3bfa4f..4b234fd230 100644 --- a/test/mock/container_backend.go +++ b/test/mock/container_backend.go @@ -60,6 +60,21 @@ func (mr *MockImageReaderMockRecorder) Close(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockImageReader)(nil).Close), ctx) } +// Glob mocks base method. +func (m *MockImageReader) Glob(ctx context.Context, pattern string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Glob", ctx, pattern) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Glob indicates an expected call of Glob. +func (mr *MockImageReaderMockRecorder) Glob(ctx, pattern any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Glob", reflect.TypeOf((*MockImageReader)(nil).Glob), ctx, pattern) +} + // ReadDir mocks base method. func (m *MockImageReader) ReadDir(ctx context.Context, path, destDir string, opts container_backend.ReadDirOpts) error { m.ctrl.T.Helper()