This commit is contained in:
2026-08-20 23:17:52 +07:00
parent 6d37610348
commit 7656100fe6
19 changed files with 2808 additions and 455 deletions
@@ -0,0 +1,340 @@
// Пакет scenario_archive — сборка и разбор ZIP-архива сценария.
// Архив: scenario.json (описание и история) + images/<имя> (файлы изображений).
// Все пути внутри архива относительные.
package scenario_archive
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"path"
"sort"
"strings"
"time"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos"
)
// Имя файла с описанием сценария внутри архива.
const FileName = "scenario.json"
// Текущая версия формата scenario.json.
const Version = 1
// Лимиты на входящие архивы (защита от zip-bomb). Суммарный объём считается
// по фактически распакованным байтам — размеры в заголовках ZIP подделываются.
var (
maxArchiveSize = 32 << 20 // 32 МБ — сырой размер архива
maxUnpackedSize = 128 << 20 // 128 МБ — суммарный объём распакованного
maxEntries = 500 // число записей
maxEntrySize = 32 << 20 // 32 МБ — размер одной записи
maxPackSize = 256 << 20 // 256 МБ — суммарный объём изображений при сборке
)
// MaxArchiveSize — максимальный размер входящего архива (для проверок
// в хендлере и на HTTP-слое).
func MaxArchiveSize() int {
return maxArchiveSize
}
// ScenarioJSON — описание сценария в архиве; Story — в формате колонки
// scenarios.scenario в БД.
type ScenarioJSON struct {
Version int `json:"version"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Image string `json:"image,omitempty"`
Story *storytelling.Story `json:"story"`
}
// Bundle — результат разбора входящего архива.
type Bundle struct {
Scenario *ScenarioJSON
Files map[string][]byte
}
// ImagePath возвращает путь изображения внутри архива (images/<имя>) и true
// для ссылок нашего хранилища: относительное имя или URL с доменом (legacy).
// Внешние URL и пустые ссылки — ("", false): в архив не кладутся.
func ImagePath(ref, domain string) (string, bool) {
ref = strings.TrimPrefix(ref, domain)
if ref == "" {
return "", false
}
low := strings.ToLower(ref)
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
return "", false
}
return "images/" + path.Base(ref), true
}
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
// переписываются в images/<имя>, файлы запрашиваются через getFile; внешние
// URL остаются без изменений. Архив детерминирован (порядок записей и метки).
func Pack(
ctx context.Context,
scenario *repos.Scenario,
domain string,
getFile func(ctx context.Context, name string) ([]byte, error),
) ([]byte, error) {
if scenario == nil {
return nil, errors.New("сценарий не задан")
}
story := &storytelling.Story{}
if scenario.Scenario != "" {
if err := json.Unmarshal([]byte(scenario.Scenario), story); err != nil {
return nil, fmt.Errorf("не удалось разобрать историю сценария: %w", err)
}
}
// Имя в хранилище → путь в архиве; конфликт базовых имён — ошибка.
archivePathOf := map[string]string{}
addRef := func(ref string) error {
if ref == "" {
return nil
}
archivePath, ok := ImagePath(ref, domain)
if !ok {
return nil
}
if _, exists := archivePathOf[ref]; exists {
return nil
}
for storageName, existingPath := range archivePathOf {
if existingPath == archivePath {
return fmt.Errorf("конфликт имён изображений в архиве: %q и %q", storageName, ref)
}
}
archivePathOf[ref] = archivePath
return nil
}
if scenario.Image != nil {
if err := addRef(*scenario.Image); err != nil {
return nil, err
}
}
for _, place := range story.Places {
if err := addRef(place.Image); err != nil {
return nil, err
}
for _, application := range place.Applications {
if err := addRef(application.Image); err != nil {
return nil, err
}
}
}
// Загружаем файлы из хранилища (имя = ссылка без домена), с суммарным лимитом.
images := make(map[string][]byte, len(archivePathOf))
var totalPacked int64
for ref, archivePath := range archivePathOf {
storageName := strings.TrimPrefix(ref, domain)
data, err := getFile(ctx, storageName)
if err != nil {
return nil, fmt.Errorf("не удалось получить изображение %q: %w", storageName, err)
}
totalPacked += int64(len(data))
if totalPacked > int64(maxPackSize) {
return nil, fmt.Errorf("суммарный объём изображений больше %d байт", maxPackSize)
}
images[archivePath] = data
}
for _, place := range story.Places {
place.Image = rewriteRef(place.Image, archivePathOf)
for _, application := range place.Applications {
application.Image = rewriteRef(application.Image, archivePathOf)
}
}
doc := ScenarioJSON{
Version: Version,
Name: scenario.Name,
Description: strPtrValue(scenario.Description),
Story: story,
}
if scenario.Image != nil {
doc.Image = rewriteRef(*scenario.Image, archivePathOf)
}
docJSON, err := json.Marshal(doc)
if err != nil {
return nil, fmt.Errorf("не удалось собрать %s: %w", FileName, err)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
if err := writeZipEntry(zw, FileName, docJSON); err != nil {
return nil, err
}
names := make([]string, 0, len(images))
for name := range images {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
if err := writeZipEntry(zw, name, images[name]); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("не удалось закрыть архив: %w", err)
}
return buf.Bytes(), nil
}
func writeZipEntry(zw *zip.Writer, name string, data []byte) error {
header := &zip.FileHeader{
Name: name,
Method: zip.Deflate,
}
// Нулевое время — детерминизм архива между запусками.
header.Modified = time.Time{}
w, err := zw.CreateHeader(header)
if err != nil {
return fmt.Errorf("не удалось создать запись %q: %w", name, err)
}
if _, err := w.Write(data); err != nil {
return fmt.Errorf("не удалось записать %q: %w", name, err)
}
return nil
}
func rewriteRef(ref string, archivePathOf map[string]string) string {
if ref == "" {
return ""
}
if archivePath, ok := archivePathOf[ref]; ok {
return archivePath
}
return ref
}
func strPtrValue(p *string) string {
if p == nil {
return ""
}
return *p
}
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
func Unpack(data []byte) (*Bundle, error) {
if len(data) == 0 {
return nil, errors.New("архив пуст")
}
if len(data) > maxArchiveSize {
return nil, fmt.Errorf("архив больше максимального размера (%d байт)", maxArchiveSize)
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("не удалось открыть архив: %w", err)
}
if len(zr.File) > maxEntries {
return nil, fmt.Errorf("в архиве больше %d записей", maxEntries)
}
bundle := &Bundle{Files: make(map[string][]byte, len(zr.File))}
var totalUnpacked int64
var scenarioRaw []byte
for _, f := range zr.File {
name := f.Name
if f.FileInfo().IsDir() {
continue
}
if err := validateEntryPath(name); err != nil {
return nil, err
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("не удалось открыть запись %q: %w", name, err)
}
content, err := readEntry(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("запись %q: %w", name, err)
}
totalUnpacked += int64(len(content))
if totalUnpacked > int64(maxUnpackedSize) {
return nil, fmt.Errorf("суммарный размер распакованного архива больше %d байт", maxUnpackedSize)
}
// Дубли записей отвергаем — «последний победил» портит данные.
if name == FileName {
if scenarioRaw != nil {
return nil, fmt.Errorf("в архиве несколько записей %s", FileName)
}
scenarioRaw = content
continue
}
if _, exists := bundle.Files[name]; exists {
return nil, fmt.Errorf("в архиве несколько записей %q", name)
}
bundle.Files[name] = content
}
if scenarioRaw == nil {
return nil, fmt.Errorf("в архиве нет файла %s", FileName)
}
scenario := &ScenarioJSON{}
if err := json.Unmarshal(scenarioRaw, scenario); err != nil {
return nil, fmt.Errorf("не удалось разобрать %s: %w", FileName, err)
}
if scenario.Version != Version {
return nil, fmt.Errorf("неподдерживаемая версия архива: %d", scenario.Version)
}
if scenario.Name == "" {
return nil, errors.New("в сценарии не указано название")
}
if scenario.Story == nil {
return nil, errors.New("в сценарии не указана история")
}
bundle.Scenario = scenario
return bundle, nil
}
// readEntry читает запись с лимитом по фактическим байтам (заголовки ZIP
// подделываются тривиально).
func readEntry(r io.Reader) ([]byte, error) {
lr := io.LimitReader(r, int64(maxEntrySize)+1)
content, err := io.ReadAll(lr)
if err != nil {
return nil, fmt.Errorf("не удалось прочитать: %w", err)
}
if int64(len(content)) > int64(maxEntrySize) {
return nil, fmt.Errorf("размер больше максимального (%d байт)", maxEntrySize)
}
return content, nil
}
// validateEntryPath отклоняет небезопасные пути записей.
func validateEntryPath(name string) error {
if name == "" {
return errors.New("пустое имя записи в архиве")
}
if strings.Contains(name, "\\") {
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
}
if strings.HasPrefix(name, "/") {
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
}
for _, part := range strings.Split(name, "/") {
if part == ".." {
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
}
}
if clean := path.Clean(name); clean != name {
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
}
return nil
}
@@ -0,0 +1,475 @@
package scenario_archive
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos"
)
const testDomain = "http://storage.test/api/files/"
// buildScenario собирает сценарий с историей и ссылками на изображения.
func buildScenario() *repos.Scenario {
description := "Детективная история"
image := "cover.png"
return &repos.Scenario{
ID: 1,
Name: "Ночной клуб",
Description: &description,
Image: &image,
Scenario: `{"places":[
{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png","applications":[{"name":"Билет","image":"ticket.jpg"}]},
{"code":"parking","name":"Парковка","text":"Пусто","image":"club.png"}
]}`,
}
}
// memoryFiles — замена хранилища для Pack: имя → содержимое.
type memoryFiles map[string][]byte
func (m memoryFiles) get(_ context.Context, name string) ([]byte, error) {
data, ok := m[name]
if !ok {
return nil, fmt.Errorf("file not found: %s", name)
}
return data, nil
}
func newMemoryFiles() memoryFiles {
return memoryFiles{
"cover.png": []byte("cover-bytes"),
"club.png": []byte("club-bytes"),
"ticket.jpg": []byte("ticket-bytes"),
}
}
func TestPackUnpackRoundTrip(t *testing.T) {
files := newMemoryFiles()
data, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
if err != nil {
t.Fatalf("Pack: %v", err)
}
bundle, err := Unpack(data)
if err != nil {
t.Fatalf("Unpack: %v", err)
}
if bundle.Scenario.Version != Version {
t.Errorf("Version = %d, want %d", bundle.Scenario.Version, Version)
}
if bundle.Scenario.Name != "Ночной клуб" {
t.Errorf("Name = %q, want %q", bundle.Scenario.Name, "Ночной клуб")
}
if bundle.Scenario.Description != "Детективная история" {
t.Errorf("Description = %q", bundle.Scenario.Description)
}
if bundle.Scenario.Image != "images/cover.png" {
t.Errorf("Image = %q, want %q", bundle.Scenario.Image, "images/cover.png")
}
if bundle.Scenario.Story == nil || len(bundle.Scenario.Story.Places) != 2 {
t.Fatalf("Story.Places = %+v, want 2 places", bundle.Scenario.Story)
}
club := bundle.Scenario.Story.Places[0]
if club.Image != "images/club.png" {
t.Errorf("place image = %q, want %q", club.Image, "images/club.png")
}
if len(club.Applications) != 1 || club.Applications[0].Image != "images/ticket.jpg" {
t.Errorf("application image = %+v, want images/ticket.jpg", club.Applications)
}
for _, want := range []string{"images/cover.png", "images/club.png", "images/ticket.jpg"} {
if _, ok := bundle.Files[want]; !ok {
t.Errorf("в архиве нет файла %q", want)
}
}
// Дублирующаяся ссылка (club.png в двух точках) кладётся в архив один раз.
if len(bundle.Files) != 3 {
t.Errorf("Files = %v, want 3 files", bundle.Files)
}
}
func TestPackExternalURLsKeptAndNotFetched(t *testing.T) {
description := "desc"
scenario := &repos.Scenario{
ID: 1,
Name: "Внешний",
Description: &description,
Image: ptr("https://example.com/cover.png"),
Scenario: `{"places":[
{"code":"p","name":"P","text":"t","image":"http://other.example/x.png"}
]}`,
}
files := newMemoryFiles()
var fetched []string
getFile := func(ctx context.Context, name string) ([]byte, error) {
fetched = append(fetched, name)
return files.get(ctx, name)
}
data, err := Pack(context.Background(), scenario, testDomain, getFile)
if err != nil {
t.Fatalf("Pack: %v", err)
}
if len(fetched) != 0 {
t.Errorf("getFile вызван для внешних URL: %v", fetched)
}
bundle, err := Unpack(data)
if err != nil {
t.Fatalf("Unpack: %v", err)
}
if bundle.Scenario.Image != "https://example.com/cover.png" {
t.Errorf("Image = %q, внешний URL должен остаться без изменений", bundle.Scenario.Image)
}
if bundle.Scenario.Story.Places[0].Image != "http://other.example/x.png" {
t.Errorf("place image = %q, внешний URL должен остаться без изменений", bundle.Scenario.Story.Places[0].Image)
}
}
func TestPackLegacyFullURLImage(t *testing.T) {
// Legacy-данные: полный URL хранилища срезается до базового имени файла.
description := "desc"
scenario := &repos.Scenario{
ID: 1,
Name: "Legacy",
Description: &description,
Image: ptr(testDomain + "cover.png"),
Scenario: `{"places":[]}`,
}
data, err := Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
if err != nil {
t.Fatalf("Pack: %v", err)
}
bundle, err := Unpack(data)
if err != nil {
t.Fatalf("Unpack: %v", err)
}
if bundle.Scenario.Image != "images/cover.png" {
t.Errorf("Image = %q, want %q", bundle.Scenario.Image, "images/cover.png")
}
}
func TestPackMissingImage(t *testing.T) {
files := newMemoryFiles()
delete(files, "ticket.jpg")
_, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
if err == nil {
t.Fatal("Pack должен вернуть ошибку при недоступном изображении")
}
if !strings.Contains(err.Error(), "ticket.jpg") {
t.Errorf("ошибка должна содержать имя файла: %v", err)
}
}
func TestPackDeterministic(t *testing.T) {
files := newMemoryFiles()
first, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
if err != nil {
t.Fatalf("Pack: %v", err)
}
time.Sleep(10 * time.Millisecond)
second, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
if err != nil {
t.Fatalf("Pack: %v", err)
}
if !bytes.Equal(first, second) {
t.Error("архив недетерминирован: повторный Pack дал другие байты")
}
}
func TestPackNilScenario(t *testing.T) {
_, err := Pack(context.Background(), nil, testDomain, nil)
if err == nil {
t.Fatal("Pack(nil) должен вернуть ошибку")
}
}
func TestPackImageNameCollision(t *testing.T) {
description := "desc"
scenario := &repos.Scenario{
ID: 1,
Name: "Конфликт",
Description: &description,
Scenario: `{"places":[
{"code":"a","name":"A","text":"t","image":"a.png"},
{"code":"b","name":"B","text":"t","image":"dir/a.png"}
]}`,
}
_, err := Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
if err == nil {
t.Fatal("Pack должен вернуть ошибку при конфликте имён изображений")
}
}
// writeZip собирает архив с заданными записями (для негативных тестов Unpack).
func writeZip(t *testing.T, entries map[string][]byte) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, content := range entries {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("Create(%q): %v", name, err)
}
if _, err := w.Write(content); err != nil {
t.Fatalf("Write(%q): %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
return buf.Bytes()
}
// validScenarioJSON — минимальный корректный scenario.json.
func validScenarioJSON() []byte {
b, _ := json.Marshal(ScenarioJSON{
Version: Version,
Name: "Тест",
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
})
return b
}
func TestUnpackRejectsBadPaths(t *testing.T) {
for _, name := range []string{
"../evil",
"a/../../evil",
"/abs",
"dir\\evil",
"a/../b",
} {
t.Run(name, func(t *testing.T) {
data := writeZip(t, map[string][]byte{
FileName: validScenarioJSON(),
"images/x.png": []byte("x"),
name: []byte("evil"),
})
if _, err := Unpack(data); err == nil {
t.Fatalf("Unpack должен отклонить путь %q", name)
}
})
}
}
func TestUnpackEmpty(t *testing.T) {
if _, err := Unpack(nil); err == nil {
t.Fatal("Unpack(nil) должен вернуть ошибку")
}
if _, err := Unpack([]byte{}); err == nil {
t.Fatal("Unpack(пусто) должен вернуть ошибку")
}
}
func TestUnpackNotAZip(t *testing.T) {
if _, err := Unpack([]byte("this is not a zip")); err == nil {
t.Fatal("Unpack должен отклонить не-zip данные")
}
}
func TestUnpackMissingScenarioJSON(t *testing.T) {
data := writeZip(t, map[string][]byte{"images/x.png": []byte("x")})
if _, err := Unpack(data); err == nil {
t.Fatal("Unpack должен требовать scenario.json")
}
}
func TestUnpackInvalidScenarioJSON(t *testing.T) {
data := writeZip(t, map[string][]byte{FileName: []byte("{not json")})
if _, err := Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить битый scenario.json")
}
}
func TestUnpackBadVersion(t *testing.T) {
b, _ := json.Marshal(ScenarioJSON{Version: 99, Name: "X", Story: &storytelling.Story{}})
data := writeZip(t, map[string][]byte{FileName: b})
if _, err := Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить неизвестную версию")
}
}
func TestUnpackEmptyName(t *testing.T) {
b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "", Story: &storytelling.Story{}})
data := writeZip(t, map[string][]byte{FileName: b})
if _, err := Unpack(data); err == nil {
t.Fatal("Unpack должен требовать название")
}
}
func TestUnpackNilStory(t *testing.T) {
b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "X"})
data := writeZip(t, map[string][]byte{FileName: b})
if _, err := Unpack(data); err == nil {
t.Fatal("Unpack должен требовать story")
}
}
func TestUnpackEntrySizeLimit(t *testing.T) {
prev := maxEntrySize
maxEntrySize = 4
defer func() { maxEntrySize = prev }()
data := writeZip(t, map[string][]byte{
FileName: validScenarioJSON(),
"images/big.png": bytes.Repeat([]byte("x"), 8),
})
if _, err := Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить запись больше maxEntrySize")
}
}
func TestUnpackTotalSizeLimit(t *testing.T) {
prev := maxUnpackedSize
maxUnpackedSize = 6
defer func() { maxUnpackedSize = prev }()
data := writeZip(t, map[string][]byte{
FileName: validScenarioJSON(),
"images/a.png": []byte("aaa"),
"images/b.png": []byte("bbb"),
})
if _, err := Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить суммарный объём больше maxUnpackedSize")
}
}
func TestUnpackArchiveSizeLimit(t *testing.T) {
prev := maxArchiveSize
maxArchiveSize = 4
defer func() { maxArchiveSize = prev }()
data := writeZip(t, map[string][]byte{FileName: validScenarioJSON()})
if _, err := Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить архив больше maxArchiveSize")
}
}
func TestUnpackEntriesLimit(t *testing.T) {
prev := maxEntries
maxEntries = 2
defer func() { maxEntries = prev }()
data := writeZip(t, map[string][]byte{
FileName: validScenarioJSON(),
"images/a.png": []byte("a"),
"images/b.png": []byte("b"),
})
if _, err := Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить архив с числом записей больше maxEntries")
}
}
func TestUnpackRejectsDuplicateScenarioJSON(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for i := 0; i < 2; i++ {
w, err := zw.Create(FileName)
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := w.Write(validScenarioJSON()); err != nil {
t.Fatalf("Write: %v", err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if _, err := Unpack(buf.Bytes()); err == nil {
t.Fatal("Unpack должен отклонить дубли scenario.json")
}
}
func TestUnpackRejectsDuplicateFiles(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for i := 0; i < 3; i++ {
name := FileName
content := validScenarioJSON()
switch i {
case 1:
name = "images/x.png"
content = []byte("x")
case 2:
// Дубль записи images/x.png.
name = "images/x.png"
content = []byte("y")
}
w, err := zw.Create(name)
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := w.Write(content); err != nil {
t.Fatalf("Write: %v", err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if _, err := Unpack(buf.Bytes()); err == nil {
t.Fatal("Unpack должен отклонить дубли файлов")
}
}
func TestPackTotalSizeLimit(t *testing.T) {
prev := maxPackSize
maxPackSize = 8
defer func() { maxPackSize = prev }()
description := "desc"
scenario := &repos.Scenario{
ID: 1,
Name: "Большой",
Description: &description,
Scenario: `{"places":[
{"code":"a","name":"A","text":"t","image":"a.png"},
{"code":"b","name":"B","text":"t","image":"b.png"}
]}`,
}
files := memoryFiles{
"a.png": bytes.Repeat([]byte("x"), 6),
"b.png": bytes.Repeat([]byte("y"), 6),
}
if _, err := Pack(context.Background(), scenario, testDomain, files.get); err == nil {
t.Fatal("Pack должен отклонить суммарный объём больше maxPackSize")
}
}
func TestImagePath(t *testing.T) {
cases := []struct {
ref string
domain string
want string
ok bool
}{
{"cover.png", "", "images/cover.png", true},
{"dir/cover.png", "", "images/cover.png", true},
{testDomain + "cover.png", testDomain, "images/cover.png", true},
{"", "", "", false},
{"http://example.com/x.png", "", "", false},
{"https://example.com/x.png", testDomain, "", false},
}
for _, tc := range cases {
got, ok := ImagePath(tc.ref, tc.domain)
if got != tc.want || ok != tc.ok {
t.Errorf("ImagePath(%q, %q) = (%q, %v), want (%q, %v)",
tc.ref, tc.domain, got, ok, tc.want, tc.ok)
}
}
}
func ptr(s string) *string { return &s }