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 }