Verify prepared inputs from manifest evidence
This commit is contained in:
191
internal/artifacts/prepared_input.go
Normal file
191
internal/artifacts/prepared_input.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
// ErrPreparedInputAbsent reports that the current manifest has no record for a
|
||||
// supported prepared stable input.
|
||||
var ErrPreparedInputAbsent = errors.New("prepared input absent")
|
||||
|
||||
// PreparedInputAbsentError identifies the prepared source absent from the
|
||||
// current manifest.
|
||||
type PreparedInputAbsentError struct {
|
||||
SourceID string
|
||||
}
|
||||
|
||||
func (e *PreparedInputAbsentError) Error() string {
|
||||
return fmt.Sprintf("%s: %q", ErrPreparedInputAbsent, e.SourceID)
|
||||
}
|
||||
|
||||
func (e *PreparedInputAbsentError) Unwrap() error {
|
||||
return ErrPreparedInputAbsent
|
||||
}
|
||||
|
||||
// PreparedInputIdentity is the verified identity of one canonical prepared
|
||||
// session input.
|
||||
type PreparedInputIdentity struct {
|
||||
SourceID string
|
||||
ManifestKind string
|
||||
Path string
|
||||
RelativePath string
|
||||
Checksum string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// ResolvePreparedInput resolves a prepared stable source exclusively from its
|
||||
// current manifest record and verifies the canonical file's identity.
|
||||
func ResolvePreparedInput(paths SessionPaths, m *manifest.Manifest, sourceID string) (PreparedInputIdentity, error) {
|
||||
descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID)
|
||||
if !ok {
|
||||
return PreparedInputIdentity{}, fmt.Errorf("unsupported prepared input source %q", sourceID)
|
||||
}
|
||||
|
||||
rootPath, canonicalPath, relativePath, err := preparedInputCanonicalPaths(paths, descriptor)
|
||||
if err != nil {
|
||||
return PreparedInputIdentity{}, err
|
||||
}
|
||||
|
||||
matching := make([]manifest.InputRecord, 0, 1)
|
||||
if m != nil {
|
||||
for _, record := range m.Inputs {
|
||||
if strings.TrimSpace(record.Kind) == descriptor.ManifestKind {
|
||||
matching = append(matching, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(matching) == 0 {
|
||||
if m != nil {
|
||||
for _, record := range m.Inputs {
|
||||
recordedPath, pathErr := resolvePreparedManifestPath(paths, record.Path, rootPath)
|
||||
if pathErr == nil && recordedPath == canonicalPath {
|
||||
return PreparedInputIdentity{}, fmt.Errorf(
|
||||
"prepared input source %q canonical path is recorded with manifest kind %q, want %q",
|
||||
descriptor.SourceID,
|
||||
strings.TrimSpace(record.Kind),
|
||||
descriptor.ManifestKind,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return PreparedInputIdentity{}, &PreparedInputAbsentError{SourceID: descriptor.SourceID}
|
||||
}
|
||||
if len(matching) != 1 {
|
||||
return PreparedInputIdentity{}, fmt.Errorf(
|
||||
"prepared input source %q has %d manifest records for kind %q; want exactly one",
|
||||
descriptor.SourceID,
|
||||
len(matching),
|
||||
descriptor.ManifestKind,
|
||||
)
|
||||
}
|
||||
|
||||
record := matching[0]
|
||||
recordedPath, err := resolvePreparedManifestPath(paths, record.Path, rootPath)
|
||||
if err != nil {
|
||||
return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q manifest path: %w", descriptor.SourceID, err)
|
||||
}
|
||||
if recordedPath != canonicalPath {
|
||||
return PreparedInputIdentity{}, fmt.Errorf(
|
||||
"prepared input source %q manifest path %q does not match canonical path %q",
|
||||
descriptor.SourceID,
|
||||
recordedPath,
|
||||
canonicalPath,
|
||||
)
|
||||
}
|
||||
declaredChecksum := strings.TrimSpace(record.Checksum)
|
||||
if declaredChecksum == "" {
|
||||
return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q manifest checksum is required", descriptor.SourceID)
|
||||
}
|
||||
|
||||
file, err := fileops.OpenConfinedRegularFile(rootPath, relativePath)
|
||||
if err != nil {
|
||||
return PreparedInputIdentity{}, fmt.Errorf("open prepared input source %q: %w", descriptor.SourceID, err)
|
||||
}
|
||||
digest := sha256.New()
|
||||
size, readErr := io.Copy(digest, file)
|
||||
closeErr := file.Close()
|
||||
if readErr != nil {
|
||||
return PreparedInputIdentity{}, fmt.Errorf("checksum prepared input source %q: %w", descriptor.SourceID, readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return PreparedInputIdentity{}, fmt.Errorf("close prepared input source %q: %w", descriptor.SourceID, closeErr)
|
||||
}
|
||||
if size == 0 {
|
||||
return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q is empty", descriptor.SourceID)
|
||||
}
|
||||
checksum := hex.EncodeToString(digest.Sum(nil))
|
||||
if !strings.EqualFold(checksum, declaredChecksum) {
|
||||
return PreparedInputIdentity{}, fmt.Errorf(
|
||||
"prepared input source %q checksum mismatch: manifest=%q actual=%q",
|
||||
descriptor.SourceID,
|
||||
declaredChecksum,
|
||||
checksum,
|
||||
)
|
||||
}
|
||||
|
||||
return PreparedInputIdentity{
|
||||
SourceID: descriptor.SourceID,
|
||||
ManifestKind: descriptor.ManifestKind,
|
||||
Path: canonicalPath,
|
||||
RelativePath: filepath.ToSlash(relativePath),
|
||||
Checksum: checksum,
|
||||
Size: size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func preparedInputCanonicalPaths(
|
||||
paths SessionPaths,
|
||||
descriptor artifactpolicy.PreparedInputSourceDescriptor,
|
||||
) (rootPath, canonicalPath, relativePath string, err error) {
|
||||
rootPath, err = filepath.Abs(strings.TrimSpace(paths.Root))
|
||||
if err != nil || strings.TrimSpace(paths.Root) == "" {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("session root is required")
|
||||
}
|
||||
return "", "", "", err
|
||||
}
|
||||
canonicalPath, err = filepath.Abs(filepath.Join(paths.InputsDir, descriptor.Filename))
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("resolve prepared input canonical path: %w", err)
|
||||
}
|
||||
relativePath, err = filepath.Rel(rootPath, canonicalPath)
|
||||
if err != nil || relativePath == "." || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) {
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("resolve prepared input below session root: %w", err)
|
||||
}
|
||||
return "", "", "", fmt.Errorf("prepared input canonical path %q is outside session root %q", canonicalPath, rootPath)
|
||||
}
|
||||
return filepath.Clean(rootPath), filepath.Clean(canonicalPath), filepath.Clean(relativePath), nil
|
||||
}
|
||||
|
||||
func resolvePreparedManifestPath(paths SessionPaths, recordedPath, rootPath string) (string, error) {
|
||||
if strings.TrimSpace(recordedPath) == "" {
|
||||
return "", fmt.Errorf("recorded path is required")
|
||||
}
|
||||
resolved := ResolveSessionLocalPathForRead(paths, recordedPath)
|
||||
if strings.TrimSpace(resolved) == "" {
|
||||
return "", fmt.Errorf("recorded path is required")
|
||||
}
|
||||
absolute, err := filepath.Abs(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve recorded path: %w", err)
|
||||
}
|
||||
relative, err := filepath.Rel(rootPath, absolute)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve recorded path below session root: %w", err)
|
||||
}
|
||||
if relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("recorded path %q is outside session root %q", absolute, rootPath)
|
||||
}
|
||||
return filepath.Clean(absolute), nil
|
||||
}
|
||||
Reference in New Issue
Block a user