Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 114 additions & 2 deletions cmd/hardhat/targets.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"maps"
Expand All @@ -31,6 +32,7 @@ import (
"go.amutable.dev/quarry/internal/keystore"
"go.amutable.dev/quarry/internal/linux"
"go.amutable.dev/quarry/internal/pathrsext"
"go.amutable.dev/quarry/internal/transferlayout"
"go.amutable.dev/quarry/internal/tufext"
)

Expand Down Expand Up @@ -101,6 +103,81 @@ func hashToTargets(ctx context.Context, builder *tufext.TargetsBuilder, logicalP
return nil
}

// maxInlineSize bounds the contents the --quarry-* options embed into
// targets.json. Inline data is meant for tiny metadata files, not payloads.
const maxInlineSize = 64 << 10

// addInlineBytes adds data as a target file with its contents inlined.
func addInlineBytes(builder *tufext.TargetsBuilder, logicalPath string, data []byte) (*tufmetadata.TargetFiles, error) {
if int64(len(data)) > maxInlineSize {
return nil, fmt.Errorf("target %s is too large (%d bytes) to be inlined (limit %d bytes)", logicalPath, len(data), maxInlineSize)
}
target, err := builder.AddTargetFile(logicalPath, int64(len(data)), hashAlgorithm.FromBytes(data))
if err != nil {
return nil, fmt.Errorf("add file %s to targets data: %w", logicalPath, err)
}
tufext.TargetFilesExt(target).WithInlineData(data)
return target, nil
}

// applyCustomFrom merges the per-target "custom" objects from the given JSON
// file ({"<target path>": {<custom fields>}}) into the targets. Every path
// must name a target that was already added, so that a typo cannot silently
// drop metadata. No path may be under .quarry/ because the
// --quarry-* options own that metadata. Existing custom fields are kept unless
// overridden by key.
func applyCustomFrom(builder *tufext.TargetsBuilder, path string) error {
data, err := os.ReadFile(path) //nolint:forbidigo // user-controlled host path
if err != nil {
return fmt.Errorf("--custom-from=%q file is invalid: %w", path, err)
}
customs, err := jsonutils.Parse[map[string]map[string]json.RawMessage](data)
if err != nil {
return fmt.Errorf("--custom-from=%q file is invalid json: %w", path, err)
}
targets := builder.TargetsType().Targets
for targetPath, custom := range customs {
if strings.HasPrefix(targetPath, transferlayout.Prefix) {
return fmt.Errorf("--custom-from=%q: target %q is quarry metadata, which --custom-from must not change", path, targetPath)
}
target, ok := targets[targetPath]
if !ok {
return fmt.Errorf("--custom-from=%q: target %q does not exist", path, targetPath)
}
err := updateCustom(target, func(fields map[string]json.RawMessage) error {
maps.Copy(fields, custom)
return nil
})
if err != nil {
return fmt.Errorf("--custom-from=%q: target %q: %w", path, targetPath, err)
}
}
return nil
}

// updateCustom lets update change the fields of a target's custom object and
// stores the result. A missing or null custom object is an empty one.
func updateCustom(target *tufmetadata.TargetFiles, update func(fields map[string]json.RawMessage) error) error {
fields := map[string]json.RawMessage{}
if target.Custom != nil {
if err := json.Unmarshal(*target.Custom, &fields); err != nil {
return fmt.Errorf("existing custom data is not an object: %w", err)
}
}
if fields == nil { // "custom": null
fields = map[string]json.RawMessage{}
}
if err := update(fields); err != nil {
return err
}
encoded, err := jsonutils.MarshalNoEscapeHTML(fields)
if err != nil {
return fmt.Errorf("encode custom data: %w", err)
}
target.Custom = (*json.RawMessage)(&encoded)
return nil
}

const dataRootURLCtxKey ctxKey = "--data-root-url"

func ctxDataRoolURL(ctx context.Context) *url.URL {
Expand Down Expand Up @@ -255,7 +332,7 @@ func addPrehashedToTargets(ctx context.Context, builder *tufext.TargetsBuilder,
var targetsCommand = withKeystoreFlag(&cli.Command{
Name: "targets",
Usage: "generate a TUF targets.json (or delegated target) file",
Flags: []cli.Flag{
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Expand All @@ -271,6 +348,11 @@ var targetsCommand = withKeystoreFlag(&cli.Command{
Name: "include-from",
Usage: "include targets defined in the given targets.json files (later files take precedence, with <files> arguments taking highest precedence)",
},
&cli.StringSliceFlag{
Name: "custom-from",
Usage: "merge per-target \"custom\" objects from the given JSON file ({\"<target path>\": {...}}), where every path must name a target",
TakesFile: true,
},
&cli.BoolFlag{
Name: "ext-override-url",
Usage: "when using --data-root-url --pre-hashed, add an override URL to each target file to make clients fetch from --data-root-url",
Expand All @@ -289,7 +371,7 @@ var targetsCommand = withKeystoreFlag(&cli.Command{
Name: "expire-after",
Usage: "configure the expiry of the targets file (duration relative to --ref-time)",
},
},
}, quarryTargetsFlags()...),
Arguments: []cli.Argument{
// TODO: This doesn't work if you have pre-hashed values.
// TODO: This won't work if you have enough files to hit the
Expand Down Expand Up @@ -344,6 +426,13 @@ var targetsCommand = withKeystoreFlag(&cli.Command{
Action: func(ctx context.Context, cmd *cli.Command) error {
store := ctxKeystore(ctx)

// This happens before creating --output so that bad options leave it
// alone.
quarry, err := quarrySpecFromFlags(cmd)
if err != nil {
return err
}

var output io.Writer
if outPath := cmd.String("output"); outPath != "-" {
outFile, err := os.Create(outPath) //nolint:forbidigo // user-controlled host path
Expand Down Expand Up @@ -438,6 +527,29 @@ var targetsCommand = withKeystoreFlag(&cli.Command{
}
}

if err := addQuarryTargets(builder, quarry); err != nil {
return err
}

for _, customPath := range cmd.StringSlice("custom-from") {
if err := applyCustomFrom(builder, customPath); err != nil {
return err
}
}
// This comes after --custom-from because that replaces a target's
// whole "quarry" object. The .quarry/ targets are skipped
// because they are quarry metadata and belong to no version.
if version := cmd.String("custom-version"); version != "" {
for path, target := range builder.TargetsType().Targets {
if strings.HasPrefix(path, transferlayout.Prefix) {
continue
}
if err := setQuarryCustomVersion(target, version); err != nil {
return fmt.Errorf("--custom-version: %s: %w", path, err)
}
}
}

signed, err := builder.SignWith(ctx, store, keys...)
if err != nil {
return fmt.Errorf("failed to sign targets data: %w", err)
Expand Down
72 changes: 72 additions & 0 deletions cmd/hardhat/targets_custom_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright (C) 2026 Amutable GmbH

package main

import (
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"go.amutable.dev/quarry/internal/tufext"
)

func customFile(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "custom.json") //nolint:forbidigo // test code
require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) //nolint:forbidigo // test code
return path
}

func TestApplyCustomFrom(t *testing.T) {
builder := tufext.NewTargetsBuilder()
_, err := builder.AddTargetFile("a.raw", 1, hashAlgorithm.FromBytes([]byte("a")))
require.NoError(t, err)
existing, err := builder.AddTargetFile("b.raw", 1, hashAlgorithm.FromBytes([]byte("b")))
require.NoError(t, err)
old := json.RawMessage(`{"quarry":{"old":true},"keep":1}`)
existing.Custom = &old

null := json.RawMessage(`null`)
_, err = builder.AddTargetFile("c.raw", 1, hashAlgorithm.FromBytes([]byte("c")))
require.NoError(t, err)
builder.TargetsType().Targets["c.raw"].Custom = &null

path := customFile(t, `{
"a.raw": {"quarry": {"version": "1", "update": true}},
"b.raw": {"quarry": {"version": "1"}},
"c.raw": {"quarry": {"version": "1"}}
}`)
require.NoError(t, applyCustomFrom(builder, path))

targets := builder.TargetsType().Targets
var a map[string]any
require.NoError(t, json.Unmarshal(*targets["a.raw"].Custom, &a))
assert.Equal(t, map[string]any{"quarry": map[string]any{"version": "1", "update": true}}, a)

// Keys are merged at the top level. "quarry" is replaced and "keep" survives.
var b map[string]any
require.NoError(t, json.Unmarshal(*targets["b.raw"].Custom, &b))
assert.Equal(t, map[string]any{"quarry": map[string]any{"version": "1"}, "keep": float64(1)}, b)

// An existing "custom": null is replaced.
var c map[string]any
require.NoError(t, json.Unmarshal(*targets["c.raw"].Custom, &c))
assert.Equal(t, map[string]any{"quarry": map[string]any{"version": "1"}}, c)
}

func TestApplyCustomFromRejects(t *testing.T) {
builder := tufext.NewTargetsBuilder()
_, err := builder.AddTargetFile(".quarry/sysupdate.d/x.transfer", 1, hashAlgorithm.FromBytes([]byte("a")))
require.NoError(t, err)
for content, want := range map[string]string{
`{"missing.raw": {"quarry": {}}}`: `target "missing.raw" does not exist`,
`["not", "an", "object"]`: "invalid json",
`{".quarry/sysupdate.d/x.transfer": {"quarry": {}}}`: "is quarry metadata",
} {
require.ErrorContains(t, applyCustomFrom(builder, customFile(t, content)), want, content)
}
}
Loading
Loading