diff --git a/go.mod b/go.mod index 643578c..bc69c20 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/magefile/mage v1.17.1 github.com/mark3labs/mcp-go v0.46.0 github.com/mattn/go-isatty v0.0.20 + github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/muesli/termenv v0.16.0 github.com/nxadm/tail v1.4.11 github.com/opencontainers/selinux v1.13.1 diff --git a/go.sum b/go.sum index 0dd9c81..8cb9a9a 100644 --- a/go.sum +++ b/go.sum @@ -231,6 +231,8 @@ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= diff --git a/internal/fingerprint/doc.go b/internal/fingerprint/doc.go new file mode 100644 index 0000000..fc655af --- /dev/null +++ b/internal/fingerprint/doc.go @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package fingerprint computes deterministic identity fingerprints for components. +// A fingerprint captures all resolved build inputs so that changes to any input +// (config fields, spec content, overlay files, distro context, upstream refs, or +// ManualBump) produce a different fingerprint. +// +// The primary entry point is [ComputeIdentity], which takes a resolved +// [projectconfig.ComponentConfig] and additional context, and returns a +// [ComponentIdentity] containing the overall fingerprint hash plus a breakdown +// of individual input hashes for debugging. +package fingerprint diff --git a/internal/fingerprint/fingerprint.go b/internal/fingerprint/fingerprint.go new file mode 100644 index 0000000..c702c0d --- /dev/null +++ b/internal/fingerprint/fingerprint.go @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package fingerprint + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "sort" + "strconv" + + "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/mitchellh/hashstructure/v2" +) + +// hashstructureTagName is the struct tag name used by hashstructure to determine +// field inclusion. Fields tagged with `fingerprint:"-"` are excluded. +const hashstructureTagName = "fingerprint" + +// ComponentIdentity holds the computed fingerprint for a single component plus +// a breakdown of individual input hashes for debugging. +type ComponentIdentity struct { + // Fingerprint is the overall SHA256 hash combining all inputs. + Fingerprint string `json:"fingerprint"` + // Inputs provides the individual input hashes that were combined. + Inputs ComponentInputs `json:"inputs"` +} + +// ComponentInputs contains the individual input hashes that comprise a component's +// fingerprint. +type ComponentInputs struct { + // ConfigHash is the hash of the resolved component config fields (uint64 from hashstructure). + ConfigHash uint64 `json:"configHash"` + // SourceIdentity is the opaque identity string for the component's source. + // For local specs this is a content hash; for upstream specs this is a commit hash. + SourceIdentity string `json:"sourceIdentity,omitempty"` + // OverlayFileHashes maps overlay index (as string) to a combined hash of the + // source file's basename and content. Keyed by index rather than path to avoid + // checkout-location dependence. + OverlayFileHashes map[string]string `json:"overlayFileHashes,omitempty"` + // ManualBump is the manual rebuild counter from the lock file. Almost always 0; + // used for mass-rebuild scenarios. + ManualBump int `json:"manualBump"` + // ReleaseVer is the distro's formal releasever (e.g., "4.0"), which feeds into + // RPM macros like %{dist}. Different release versions produce different package + // NEVRAs even with identical specs. + ReleaseVer string `json:"releaseVer"` +} + +// IdentityOptions holds additional inputs for computing a component's identity +// that are not part of the component config itself. +type IdentityOptions struct { + // ManualBump is the manual rebuild counter from the component's lock file. + ManualBump int + // SourceIdentity is the opaque identity string from a [sourceproviders.SourceIdentityProvider]. + // For upstream components this is the resolved commit hash; for local components this is a + // content hash of the spec directory. + // + // This is caller-provided because resolving it requires network access (upstream clone) or + // filesystem traversal (local content hash). [ComputeIdentity] is a pure combiner — it does + // not perform I/O beyond reading overlay files. Callers should resolve source identity via + // SourceManager.ResolveSourceIdentity before calling [ComputeIdentity]. + SourceIdentity string +} + +// ComputeIdentity computes the fingerprint for a component from its resolved config +// and additional context. The fs parameter is used to read overlay source file +// contents for hashing; spec content identity is provided via [IdentityOptions.SourceIdentity]. +// +// This function is a deterministic combiner: given the same resolved inputs it always +// produces the same fingerprint. It does not resolve source identity or count commits — +// those are expected to be pre-resolved by the caller and passed via opts. +func ComputeIdentity( + fs opctx.FS, + component projectconfig.ComponentConfig, + releaseVer string, + opts IdentityOptions, +) (*ComponentIdentity, error) { + inputs := ComponentInputs{ + ManualBump: opts.ManualBump, + SourceIdentity: opts.SourceIdentity, + ReleaseVer: releaseVer, + } + + // 1. Require source identity when the component has a spec source that + // contributes content. Without it the fingerprint cannot detect spec + // content changes (Spec.Path is excluded from the config hash). + if opts.SourceIdentity == "" && component.Spec.SourceType != "" { + return nil, fmt.Errorf( + "source identity is required for component with source type %#q; "+ + "resolve it via SourceManager.ResolveSourceIdentity before calling ComputeIdentity", + component.Spec.SourceType) + } + + // 2. Verify all source files have a hash. Without a hash the fingerprint + // cannot detect content changes, so we refuse to compute one. + for i := range component.SourceFiles { + if component.SourceFiles[i].Hash == "" { + return nil, fmt.Errorf( + "source file %#q has no hash; cannot compute a deterministic fingerprint", + component.SourceFiles[i].Filename, + ) + } + } + + // 3. Hash the resolved config struct (excluding fingerprint:"-" fields). + configHash, err := hashstructure.Hash(component, hashstructure.FormatV2, &hashstructure.HashOptions{ + TagName: hashstructureTagName, + }) + if err != nil { + return nil, fmt.Errorf("hashing component config:\n%w", err) + } + + inputs.ConfigHash = configHash + + // 4. Hash overlay source file contents. Each overlay owns its identity + // computation via [projectconfig.ComponentOverlay.SourceContentIdentity]. + overlayHashes := make(map[string]string) + + for idx := range component.Overlays { + identity, overlayErr := component.Overlays[idx].SourceContentIdentity(fs) + if overlayErr != nil { + return nil, fmt.Errorf("hashing overlay %d source:\n%w", idx, overlayErr) + } + + if identity != "" { + overlayHashes[strconv.Itoa(idx)] = identity + } + } + + inputs.OverlayFileHashes = overlayHashes + + // 5. Combine all inputs into the overall fingerprint. + return &ComponentIdentity{ + Fingerprint: combineInputs(inputs), + Inputs: inputs, + }, nil +} + +// combineInputs deterministically combines all input hashes into a single SHA256 fingerprint. +func combineInputs(inputs ComponentInputs) string { + hasher := sha256.New() + + // Write each input in a fixed order with field labels for domain separation. + writeField(hasher, "config_hash", strconv.FormatUint(inputs.ConfigHash, 10)) + writeField(hasher, "source_identity", inputs.SourceIdentity) + writeField(hasher, "manual_bump", strconv.Itoa(inputs.ManualBump)) + writeField(hasher, "release_ver", inputs.ReleaseVer) + + // Overlay file hashes in sorted key order for determinism. + if len(inputs.OverlayFileHashes) > 0 { + keys := make([]string, 0, len(inputs.OverlayFileHashes)) + for key := range inputs.OverlayFileHashes { + keys = append(keys, key) + } + + sort.Strings(keys) + + for _, key := range keys { + writeField(hasher, "overlay:"+key, inputs.OverlayFileHashes[key]) + } + } + + return "sha256:" + hex.EncodeToString(hasher.Sum(nil)) +} + +// writeField writes a labeled value to the hasher for domain separation. +func writeField(writer io.Writer, label string, value string) { + // Length-prefix both label and value to prevent injection of fake field records + // via values containing newlines. + fmt.Fprintf(writer, "%d:%s=%d:%s\n", len(label), label, len(value), value) +} diff --git a/internal/fingerprint/fingerprint_test.go b/internal/fingerprint/fingerprint_test.go new file mode 100644 index 0000000..d8986d2 --- /dev/null +++ b/internal/fingerprint/fingerprint_test.go @@ -0,0 +1,675 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package fingerprint_test + +import ( + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/fingerprint" + "github.com/microsoft/azure-linux-dev-tools/internal/global/testctx" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestFS(t *testing.T, files map[string]string) *testctx.TestCtx { + t.Helper() + + ctx := testctx.NewCtx() + + for path, content := range files { + err := fileutils.WriteFile(ctx.FS(), path, []byte(content), fileperms.PublicFile) + require.NoError(t, err) + } + + return ctx +} + +const testReleaseVer = "4.0" + +func baseComponent() projectconfig.ComponentConfig { + return projectconfig.ComponentConfig{ + Name: "testpkg", + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + Path: "/specs/test.spec", + }, + } +} + +func computeFingerprint( + t *testing.T, + ctx *testctx.TestCtx, + comp projectconfig.ComponentConfig, + releaseVer string, + affects int, +) string { + t.Helper() + + identity, err := fingerprint.ComputeIdentity(ctx.FS(), comp, releaseVer, fingerprint.IdentityOptions{ + ManualBump: affects, + SourceIdentity: "test-source-identity", + }) + require.NoError(t, err) + + return identity.Fingerprint +} + +func TestComputeIdentity_Deterministic(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + + comp := baseComponent() + releaseVer := testReleaseVer + + fp1 := computeFingerprint(t, ctx, comp, releaseVer, 0) + fp2 := computeFingerprint(t, ctx, comp, releaseVer, 0) + + assert.Equal(t, fp1, fp2, "identical inputs must produce identical fingerprints") + assert.Contains(t, fp1, "sha256:", "fingerprint should have sha256: prefix") +} + +func TestComputeIdentity_SourceIdentityChange(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + + comp := baseComponent() + releaseVer := testReleaseVer + + identity1, err := fingerprint.ComputeIdentity(ctx.FS(), comp, releaseVer, fingerprint.IdentityOptions{ + SourceIdentity: "abc123", + }) + require.NoError(t, err) + + identity2, err := fingerprint.ComputeIdentity(ctx.FS(), comp, releaseVer, fingerprint.IdentityOptions{ + SourceIdentity: "def456", + }) + require.NoError(t, err) + + assert.NotEqual(t, identity1.Fingerprint, identity2.Fingerprint, + "different source identity must produce different fingerprints") +} + +func TestComputeIdentity_BuildWithChange(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + + comp1 := baseComponent() + comp2 := baseComponent() + comp2.Build.With = []string{"feature_x"} + + releaseVer := testReleaseVer + + fp1 := computeFingerprint(t, ctx, comp1, releaseVer, 0) + fp2 := computeFingerprint(t, ctx, comp2, releaseVer, 0) + + assert.NotEqual(t, fp1, fp2, "adding build.with must change fingerprint") +} + +func TestComputeIdentity_BuildWithoutChange(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + + comp1 := baseComponent() + comp2 := baseComponent() + comp2.Build.Without = []string{"docs"} + + releaseVer := testReleaseVer + + fp1 := computeFingerprint(t, ctx, comp1, releaseVer, 0) + fp2 := computeFingerprint(t, ctx, comp2, releaseVer, 0) + + assert.NotEqual(t, fp1, fp2, "adding build.without must change fingerprint") +} + +func TestComputeIdentity_BuildDefinesChange(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + + comp1 := baseComponent() + comp2 := baseComponent() + comp2.Build.Defines = map[string]string{"debug": "1"} + + releaseVer := testReleaseVer + + fp1 := computeFingerprint(t, ctx, comp1, releaseVer, 0) + fp2 := computeFingerprint(t, ctx, comp2, releaseVer, 0) + + assert.NotEqual(t, fp1, fp2, "adding build.defines must change fingerprint") +} + +func TestComputeIdentity_CheckSkipChange(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + + comp1 := baseComponent() + comp2 := baseComponent() + comp2.Build.Check.Skip = true + + releaseVer := testReleaseVer + + fp1 := computeFingerprint(t, ctx, comp1, releaseVer, 0) + fp2 := computeFingerprint(t, ctx, comp2, releaseVer, 0) + + assert.NotEqual(t, fp1, fp2, "changing check.skip must change fingerprint") +} + +func TestComputeIdentity_ExcludedFieldsDoNotChange(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + releaseVer := testReleaseVer + + // Base component. + comp := baseComponent() + fpBase := computeFingerprint(t, ctx, comp, releaseVer, 0) + + // Changing Name (fingerprint:"-") should NOT change fingerprint. + compName := baseComponent() + compName.Name = "different-name" + fpName := computeFingerprint(t, ctx, compName, releaseVer, 0) + assert.Equal(t, fpBase, fpName, "changing Name must NOT change fingerprint") + + // Changing Build.Failure.Expected (fingerprint:"-") should NOT change fingerprint. + compFailure := baseComponent() + compFailure.Build.Failure.Expected = true + compFailure.Build.Failure.ExpectedReason = "known issue" + fpFailure := computeFingerprint(t, ctx, compFailure, releaseVer, 0) + assert.Equal(t, fpBase, fpFailure, "changing failure.expected must NOT change fingerprint") + + // Changing Build.Hints.Expensive (fingerprint:"-") should NOT change fingerprint. + compHints := baseComponent() + compHints.Build.Hints.Expensive = true + fpHints := computeFingerprint(t, ctx, compHints, releaseVer, 0) + assert.Equal(t, fpBase, fpHints, "changing hints.expensive must NOT change fingerprint") + + // Changing Build.Check.SkipReason (fingerprint:"-") should NOT change fingerprint. + compReason := baseComponent() + compReason.Build.Check.SkipReason = "tests require network" + fpReason := computeFingerprint(t, ctx, compReason, releaseVer, 0) + assert.Equal(t, fpBase, fpReason, "changing check.skip_reason must NOT change fingerprint") + + // Changing RenderedSpecDir (fingerprint:"-") should NOT change fingerprint. + // This is a derived output path that varies by checkout location. + compRendered := baseComponent() + compRendered.RenderedSpecDir = "/some/checkout/path/SPECS/t/testpkg" + fpRendered := computeFingerprint(t, ctx, compRendered, releaseVer, 0) + assert.Equal(t, fpBase, fpRendered, "changing RenderedSpecDir must NOT change fingerprint") +} + +func TestComputeIdentity_OverlayDescriptionExcluded(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + releaseVer := testReleaseVer + + comp1 := baseComponent() + comp1.Overlays = []projectconfig.ComponentOverlay{ + {Type: "spec-set-tag", Tag: "Release", Value: "2%{?dist}"}, + } + + comp2 := baseComponent() + comp2.Overlays = []projectconfig.ComponentOverlay{ + {Type: "spec-set-tag", Tag: "Release", Value: "2%{?dist}", Description: "bumped release"}, + } + + fp1 := computeFingerprint(t, ctx, comp1, releaseVer, 0) + fp2 := computeFingerprint(t, ctx, comp2, releaseVer, 0) + + assert.Equal(t, fp1, fp2, "overlay description must NOT change fingerprint") +} + +func TestComputeIdentity_OverlaySourceFileChange(t *testing.T) { + ctx1 := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + "/patches/fix.patch": "--- a/file\n+++ b/file\n@@ original @@", + }) + ctx2 := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + "/patches/fix.patch": "--- a/file\n+++ b/file\n@@ modified @@", + }) + releaseVer := testReleaseVer + + comp := baseComponent() + comp.Overlays = []projectconfig.ComponentOverlay{ + {Type: "patch-add", Source: "/patches/fix.patch"}, + } + + fp1 := computeFingerprint(t, ctx1, comp, releaseVer, 0) + fp2 := computeFingerprint(t, ctx2, comp, releaseVer, 0) + + assert.NotEqual(t, fp1, fp2, "different overlay source content must produce different fingerprints") +} + +func TestComputeIdentity_PatchAddRenameChangesFP(t *testing.T) { + // When patch-add omits 'file', the destination filename is derived from + // filepath.Base(Source). Renaming the source file changes the rendered + // spec output (PatchN: tag + copied file), so the fingerprint must change + // even if the file content is identical. + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + "/patches/fix.patch": "identical patch content", + "/patches/cve-2026.patch": "identical patch content", + }) + releaseVer := testReleaseVer + + comp1 := baseComponent() + comp1.Overlays = []projectconfig.ComponentOverlay{ + {Type: "patch-add", Source: "/patches/fix.patch"}, + } + + comp2 := baseComponent() + comp2.Overlays = []projectconfig.ComponentOverlay{ + {Type: "patch-add", Source: "/patches/cve-2026.patch"}, + } + + fp1 := computeFingerprint(t, ctx, comp1, releaseVer, 0) + fp2 := computeFingerprint(t, ctx, comp2, releaseVer, 0) + + assert.NotEqual(t, fp1, fp2, + "renaming overlay source file must change fingerprint (same content, different basename)") +} + +func TestComputeIdentity_DistroChange(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + + comp := baseComponent() + + fp1 := computeFingerprint(t, ctx, comp, "3.0", 0) + fp2 := computeFingerprint(t, ctx, comp, "4.0", 0) + + assert.NotEqual(t, fp1, fp2, "different release version must produce different fingerprints") +} + +func TestComputeIdentity_AffectsCountChange(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + + comp := baseComponent() + releaseVer := testReleaseVer + + fp1 := computeFingerprint(t, ctx, comp, releaseVer, 0) + fp2 := computeFingerprint(t, ctx, comp, releaseVer, 1) + + assert.NotEqual(t, fp1, fp2, "different affects commit count must produce different fingerprints") +} + +func TestComputeIdentity_UpstreamCommitChange(t *testing.T) { + ctx := newTestFS(t, nil) + + comp1 := projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeUpstream, + UpstreamName: "curl", + UpstreamCommit: "abc1234", + UpstreamDistro: projectconfig.DistroReference{Name: "fedora", Version: "41"}, + }, + } + comp2 := projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeUpstream, + UpstreamName: "curl", + UpstreamCommit: "def5678", + UpstreamDistro: projectconfig.DistroReference{Name: "fedora", Version: "41"}, + }, + } + releaseVer := testReleaseVer + + fp1 := computeFingerprint(t, ctx, comp1, releaseVer, 0) + fp2 := computeFingerprint(t, ctx, comp2, releaseVer, 0) + + assert.NotEqual(t, fp1, fp2, "different upstream commit must produce different fingerprints") +} + +func TestComputeIdentity_SourceFilesChange(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + + comp1 := baseComponent() + comp1.SourceFiles = []projectconfig.SourceFileReference{ + {Filename: "source.tar.gz", Hash: "aaa111", HashType: fileutils.HashTypeSHA256}, + } + + comp2 := baseComponent() + comp2.SourceFiles = []projectconfig.SourceFileReference{ + {Filename: "source.tar.gz", Hash: "bbb222", HashType: fileutils.HashTypeSHA256}, + } + releaseVer := testReleaseVer + + fp1 := computeFingerprint(t, ctx, comp1, releaseVer, 0) + fp2 := computeFingerprint(t, ctx, comp2, releaseVer, 0) + + assert.NotEqual(t, fp1, fp2, "different source file hash must produce different fingerprints") +} + +func TestComputeIdentity_SourceFileOriginExcluded(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + + comp1 := baseComponent() + comp1.SourceFiles = []projectconfig.SourceFileReference{ + { + Filename: "source.tar.gz", + Hash: "aaa111", + HashType: fileutils.HashTypeSHA256, + Origin: projectconfig.Origin{Type: "download", Uri: "https://old-cdn.example.com/source.tar.gz"}, + }, + } + + comp2 := baseComponent() + comp2.SourceFiles = []projectconfig.SourceFileReference{ + { + Filename: "source.tar.gz", + Hash: "aaa111", + HashType: fileutils.HashTypeSHA256, + Origin: projectconfig.Origin{Type: "download", Uri: "https://new-cdn.example.com/source.tar.gz"}, + }, + } + releaseVer := testReleaseVer + + fp1 := computeFingerprint(t, ctx, comp1, releaseVer, 0) + fp2 := computeFingerprint(t, ctx, comp2, releaseVer, 0) + + assert.Equal(t, fp1, fp2, "changing source file origin URL must NOT change fingerprint") +} + +func TestComputeIdentity_SourceFileNoHash_Error(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + + comp := baseComponent() + comp.SourceFiles = []projectconfig.SourceFileReference{ + { + Filename: "source.tar.gz", + Origin: projectconfig.Origin{Type: "download", Uri: "https://example.com/source.tar.gz"}, + }, + } + releaseVer := testReleaseVer + + _, err := fingerprint.ComputeIdentity(ctx.FS(), comp, releaseVer, fingerprint.IdentityOptions{ + SourceIdentity: "test-source-identity", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "source.tar.gz") + assert.Contains(t, err.Error(), "no hash") +} + +func TestComputeIdentity_InputsBreakdown(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + "/patches/fix.patch": "patch content here", + }) + + comp := baseComponent() + comp.Overlays = []projectconfig.ComponentOverlay{ + {Type: "patch-add", Source: "/patches/fix.patch"}, + } + releaseVer := testReleaseVer + + identity, err := fingerprint.ComputeIdentity(ctx.FS(), comp, releaseVer, fingerprint.IdentityOptions{ + ManualBump: 3, + SourceIdentity: "test-source-identity-hash", + }) + require.NoError(t, err) + + assert.NotEmpty(t, identity.Fingerprint) + assert.NotZero(t, identity.Inputs.ConfigHash) + assert.Equal(t, "test-source-identity-hash", identity.Inputs.SourceIdentity) + assert.Equal(t, 3, identity.Inputs.ManualBump) + assert.Equal(t, testReleaseVer, identity.Inputs.ReleaseVer) + assert.Contains(t, identity.Inputs.OverlayFileHashes, "0") +} + +func TestComputeIdentity_MissingSourceIdentity_Error(t *testing.T) { + ctx := newTestFS(t, nil) + + comp := projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + }, + } + releaseVer := testReleaseVer + + _, err := fingerprint.ComputeIdentity(ctx.FS(), comp, releaseVer, fingerprint.IdentityOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "source identity is required") +} + +func TestComputeIdentity_OverlayFunctionalFieldChange(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + releaseVer := testReleaseVer + + comp1 := baseComponent() + comp1.Overlays = []projectconfig.ComponentOverlay{ + {Type: "spec-set-tag", Tag: "Release", Value: "2%{?dist}"}, + } + + comp2 := baseComponent() + comp2.Overlays = []projectconfig.ComponentOverlay{ + {Type: "spec-set-tag", Tag: "Release", Value: "3%{?dist}"}, + } + + fp1 := computeFingerprint(t, ctx, comp1, releaseVer, 0) + fp2 := computeFingerprint(t, ctx, comp2, releaseVer, 0) + + assert.NotEqual(t, fp1, fp2, "changing overlay value must change fingerprint") +} + +func TestComputeIdentity_AddingOverlay(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + releaseVer := testReleaseVer + + comp1 := baseComponent() + + comp2 := baseComponent() + comp2.Overlays = []projectconfig.ComponentOverlay{ + {Type: "spec-set-tag", Tag: "Release", Value: "2%{?dist}"}, + } + + fp1 := computeFingerprint(t, ctx, comp1, releaseVer, 0) + fp2 := computeFingerprint(t, ctx, comp2, releaseVer, 0) + + assert.NotEqual(t, fp1, fp2, "adding an overlay must change fingerprint") +} + +func TestComputeIdentity_BuildUndefinesChange(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + releaseVer := testReleaseVer + + comp1 := baseComponent() + comp2 := baseComponent() + comp2.Build.Undefines = []string{"_debuginfo"} + + fp1 := computeFingerprint(t, ctx, comp1, releaseVer, 0) + fp2 := computeFingerprint(t, ctx, comp2, releaseVer, 0) + + assert.NotEqual(t, fp1, fp2, "adding build.undefines must change fingerprint") +} + +// Tests below verify global change propagation: changes to shared config +// (distro defaults, group defaults) must fan out to all inheriting components. + +func TestComputeIdentity_DistroDefaultPropagation(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/curl.spec": "Name: curl\nVersion: 1.0", + "/specs/openssl.spec": "Name: openssl\nVersion: 3.0", + }) + + // Simulate two components that both inherit from a distro default. + // First, compute fingerprints with no distro-level build options. + curl := projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{SourceType: projectconfig.SpecSourceTypeLocal, Path: "/specs/curl.spec"}, + } + openssl := projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{SourceType: projectconfig.SpecSourceTypeLocal, Path: "/specs/openssl.spec"}, + } + releaseVer := testReleaseVer + + fpCurl1 := computeFingerprint(t, ctx, curl, releaseVer, 0) + fpOpenssl1 := computeFingerprint(t, ctx, openssl, releaseVer, 0) + + // Now simulate a distro default adding build.with — after config merging, + // both components would have this option in their resolved config. + curl.Build.With = []string{"distro_feature"} + openssl.Build.With = []string{"distro_feature"} + + fpCurl2 := computeFingerprint(t, ctx, curl, releaseVer, 0) + fpOpenssl2 := computeFingerprint(t, ctx, openssl, releaseVer, 0) + + assert.NotEqual(t, fpCurl1, fpCurl2, + "distro default change must propagate to curl's fingerprint") + assert.NotEqual(t, fpOpenssl1, fpOpenssl2, + "distro default change must propagate to openssl's fingerprint") +} + +func TestComputeIdentity_GroupDefaultPropagation(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/a.spec": "Name: a\nVersion: 1.0", + "/specs/b.spec": "Name: b\nVersion: 1.0", + "/specs/c.spec": "Name: c\nVersion: 1.0", + }) + + releaseVer := testReleaseVer + + // Three components: a and b are in a group, c is not. + compA := projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{SourceType: projectconfig.SpecSourceTypeLocal, Path: "/specs/a.spec"}, + } + compB := projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{SourceType: projectconfig.SpecSourceTypeLocal, Path: "/specs/b.spec"}, + } + compC := projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{SourceType: projectconfig.SpecSourceTypeLocal, Path: "/specs/c.spec"}, + } + + fpA1 := computeFingerprint(t, ctx, compA, releaseVer, 0) + fpB1 := computeFingerprint(t, ctx, compB, releaseVer, 0) + fpC1 := computeFingerprint(t, ctx, compC, releaseVer, 0) + + // Simulate a group default adding check.skip — after merging, only a and b have it. + compA.Build.Check.Skip = true + compB.Build.Check.Skip = true + // compC is not in the group, remains unchanged. + + fpA2 := computeFingerprint(t, ctx, compA, releaseVer, 0) + fpB2 := computeFingerprint(t, ctx, compB, releaseVer, 0) + fpC2 := computeFingerprint(t, ctx, compC, releaseVer, 0) + + assert.NotEqual(t, fpA1, fpA2, "group default must propagate to member A") + assert.NotEqual(t, fpB1, fpB2, "group default must propagate to member B") + assert.Equal(t, fpC1, fpC2, "non-group member C must NOT be affected") +} + +func TestComputeIdentity_MergeUpdatesFromPropagation(t *testing.T) { + ctx := newTestFS(t, map[string]string{ + "/specs/test.spec": "Name: testpkg\nVersion: 1.0", + }) + releaseVer := testReleaseVer + + // Start with a base component. + comp := baseComponent() + fpBefore := computeFingerprint(t, ctx, comp, releaseVer, 0) + + // Simulate applying a distro default via MergeUpdatesFrom. + distroDefault := &projectconfig.ComponentConfig{ + Build: projectconfig.ComponentBuildConfig{ + Defines: map[string]string{"vendor": "azl"}, + }, + } + + err := comp.MergeUpdatesFrom(distroDefault) + require.NoError(t, err) + + fpAfter := computeFingerprint(t, ctx, comp, releaseVer, 0) + + assert.NotEqual(t, fpBefore, fpAfter, + "merged distro default must change the fingerprint") +} + +func TestComputeIdentity_SnapshotChangeDoesNotAffectFingerprint(t *testing.T) { + ctx := newTestFS(t, nil) + + comp := projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeUpstream, + UpstreamName: "curl", + UpstreamCommit: "abc1234", + UpstreamDistro: projectconfig.DistroReference{ + Name: "fedora", + Version: "41", + Snapshot: "2025-01-01T00:00:00Z", + }, + }, + } + releaseVer := testReleaseVer + + fp1 := computeFingerprint(t, ctx, comp, releaseVer, 0) + + // Change only the snapshot timestamp. + comp.Spec.UpstreamDistro.Snapshot = "2026-06-15T00:00:00Z" + fp2 := computeFingerprint(t, ctx, comp, releaseVer, 0) + + assert.Equal(t, fp1, fp2, + "changing upstream distro snapshot must NOT change fingerprint "+ + "(snapshot is excluded; resolved commit hash is what matters)") +} + +func TestComputeIdentity_DifferentCheckoutPaths(t *testing.T) { + // Simulate the same component checked out in two different directories. + // After WithAbsolutePaths, Spec.Path and overlay Source paths will differ. + // The fingerprint must be identical — it should not depend on checkout location. + ctx := newTestFS(t, map[string]string{ + "/home/user1/repo/specs/test.spec": "Name: testpkg\nVersion: 1.0", + "/home/user2/repo/specs/test.spec": "Name: testpkg\nVersion: 1.0", + "/home/user1/repo/patches/fix.patch": "patch content", + "/home/user2/repo/patches/fix.patch": "patch content", + }) + releaseVer := testReleaseVer + + comp1 := projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + Path: "/home/user1/repo/specs/test.spec", + }, + Overlays: []projectconfig.ComponentOverlay{ + {Type: "patch-add", Source: "/home/user1/repo/patches/fix.patch"}, + }, + } + + comp2 := projectconfig.ComponentConfig{ + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + Path: "/home/user2/repo/specs/test.spec", + }, + Overlays: []projectconfig.ComponentOverlay{ + {Type: "patch-add", Source: "/home/user2/repo/patches/fix.patch"}, + }, + } + + fp1 := computeFingerprint(t, ctx, comp1, releaseVer, 0) + fp2 := computeFingerprint(t, ctx, comp2, releaseVer, 0) + + assert.Equal(t, fp1, fp2, + "same component in different checkout directories must produce identical fingerprints") +} diff --git a/internal/projectconfig/component.go b/internal/projectconfig/component.go index 6803b69..92ab3b9 100644 --- a/internal/projectconfig/component.go +++ b/internal/projectconfig/component.go @@ -120,7 +120,7 @@ type ComponentConfig struct { // RenderedSpecDir is the output directory for this component's rendered spec files. // Derived at resolve time from the project's rendered-specs-dir setting; not present // in serialized files. Empty when rendered-specs-dir is not configured. - RenderedSpecDir string `toml:"-" json:"renderedSpecDir,omitempty" table:"-"` + RenderedSpecDir string `toml:"-" json:"renderedSpecDir,omitempty" table:"-" fingerprint:"-"` // Where to get its spec and adjacent files from. Spec SpecSource `toml:"spec,omitempty" json:"spec,omitempty" jsonschema:"title=Spec,description=Identifies where to find the spec for this component"` diff --git a/internal/projectconfig/fingerprint_test.go b/internal/projectconfig/fingerprint_test.go index b3bcb86..0ffe13d 100644 --- a/internal/projectconfig/fingerprint_test.go +++ b/internal/projectconfig/fingerprint_test.go @@ -43,6 +43,8 @@ func TestAllFingerprintedFieldsHaveDecision(t *testing.T) { "ComponentConfig.Name": true, // ComponentConfig.SourceConfigFile — internal bookkeeping reference, not a build input. "ComponentConfig.SourceConfigFile": true, + // ComponentConfig.RenderedSpecDir — derived output path that varies by checkout location. + "ComponentConfig.RenderedSpecDir": true, // ComponentBuildConfig.Failure — CI policy (expected failure tracking), not a build input. "ComponentBuildConfig.Failure": true, @@ -57,6 +59,9 @@ func TestAllFingerprintedFieldsHaveDecision(t *testing.T) { // ComponentOverlay.Description — human-readable documentation for the overlay. "ComponentOverlay.Description": true, + // ComponentOverlay.Source — absolute path that varies by checkout location. + // Overlay content is hashed separately by ComputeIdentity. + "ComponentOverlay.Source": true, // SourceFileReference.Component — back-reference to parent, not a build input. "SourceFileReference.Component": true, @@ -70,6 +75,10 @@ func TestAllFingerprintedFieldsHaveDecision(t *testing.T) { // The file content is already captured by Filename + Hash; changing a CDN URL should not // trigger a rebuild. "SourceFileReference.Origin": true, + + // SpecSource.Path — absolute path that varies by checkout location. + // Spec content identity is captured separately via SourceIdentity. + "SpecSource.Path": true, } // Collect all actual exclusions found via reflection, and flag invalid tag values. diff --git a/internal/projectconfig/overlay.go b/internal/projectconfig/overlay.go index 2c603b9..e4f53f5 100644 --- a/internal/projectconfig/overlay.go +++ b/internal/projectconfig/overlay.go @@ -10,6 +10,8 @@ import ( "github.com/bmatcuk/doublestar/v4" "github.com/brunoga/deep" + "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" ) // ComponentOverlay represents an overlay that may be applied to a component's spec and/or its sources. @@ -38,7 +40,47 @@ type ComponentOverlay struct { Lines []string `toml:"lines,omitempty" json:"lines,omitempty" jsonschema:"title=Lines,description=The lines of text to use"` // For overlays that require a source file as input, indicates a path to that file; relative paths are relative to // the config file that defines the overlay. - Source string `toml:"source,omitempty" json:"source,omitempty" jsonschema:"title=Source,description=For overlays that require a source file as input, indicates a path to that file; relative paths are relative to the config file that defines the overlay"` + // Excluded from fingerprint because it contains an absolute path that varies by checkout + // location. Overlay content is hashed separately by [fingerprint.ComputeIdentity]. + Source string `toml:"source,omitempty" json:"source,omitempty" jsonschema:"title=Source,description=For overlays that require a source file as input, indicates a path to that file; relative paths are relative to the config file that defines the overlay" fingerprint:"-"` +} + +// EffectiveSourceName returns the checkout-independent identity of the overlay's +// source file. When [ComponentOverlay.Filename] is set it takes precedence; +// otherwise the basename of [ComponentOverlay.Source] is used (this matches the +// runtime behavior in the overlay application layer, e.g., patch-add derives its +// destination filename from the source basename). +// +// Returns empty string if the overlay has no source file. +func (c *ComponentOverlay) EffectiveSourceName() string { + if c.Source == "" { + return "" + } + + if c.Filename != "" { + return c.Filename + } + + return filepath.Base(c.Source) +} + +// SourceContentIdentity returns an opaque identity string for the overlay's source +// file, combining the effective destination filename and a SHA256 content hash. +// Returns empty string and nil error if the overlay has no source file. +// Used by [fingerprint.ComputeIdentity] so that fingerprint logic does not need +// overlay-specific knowledge. +func (c *ComponentOverlay) SourceContentIdentity(fs opctx.FS) (string, error) { + name := c.EffectiveSourceName() + if name == "" { + return "", nil + } + + contentHash, err := fileutils.ComputeFileHash(fs, fileutils.HashTypeSHA256, c.Source) + if err != nil { + return "", fmt.Errorf("hashing overlay source %#q:\n%w", c.Source, err) + } + + return name + ":" + contentHash, nil } // WithAbsolutePaths returns a copy of the overlay with config-relative file paths converted to absolute diff --git a/internal/projectconfig/specsource.go b/internal/projectconfig/specsource.go index b12b5a0..4811663 100644 --- a/internal/projectconfig/specsource.go +++ b/internal/projectconfig/specsource.go @@ -9,7 +9,9 @@ type SpecSource struct { SourceType SpecSourceType `toml:"type" json:"type,omitempty" validate:"omitempty,oneof=local upstream" jsonschema:"required,enum=local,enum=upstream,enum=,title=Source Type,description=The type of the spec source"` // Path indicates the path to the spec file; only relevant for local specs. - Path string `toml:"path,omitempty" json:"path,omitempty" validate:"excluded_unless=SourceType local,required_if=SourceType local" jsonschema:"title=Path,description=Path to the spec (if available locally),example=specs/mycomponent.spec"` + // Excluded from fingerprint because it contains an absolute path that varies by checkout + // location. Spec content identity is captured separately via [fingerprint.IdentityOptions.SourceIdentity]. + Path string `toml:"path,omitempty" json:"path,omitempty" validate:"excluded_unless=SourceType local,required_if=SourceType local" jsonschema:"title=Path,description=Path to the spec (if available locally),example=specs/mycomponent.spec" fingerprint:"-"` // UpstreamDistro indicates the upstream distro providing the spec; only relevant for upstream specs. UpstreamDistro DistroReference `toml:"upstream-distro,omitempty" json:"upstreamDistro,omitempty" jsonschema:"title=Upstream distro,description=Reference to the upstream distro providing the spec"`