Skip to content

Commit fb48b32

Browse files
committed
Fix repair logic
1 parent 8b93bac commit fb48b32

2 files changed

Lines changed: 115 additions & 32 deletions

File tree

internal/dependencymanager/dependencyinstaller.go

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -716,11 +716,16 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency,
716716
}
717717
}
718718

719-
// Install or update the dependency
720-
// This is the shared installation path for:
721-
// - New dependencies (no hash mismatch)
722-
// - Hash mismatch with --skip-update-prompts and no local file
723-
// - Hash mismatch with --update flag
719+
// Check if file exists and needs repair (out of sync with flow.json)
720+
fileExists := di.contractFileExists(contractAddr, contractName)
721+
fileModified := false
722+
if fileExists {
723+
if err := di.verifyLocalFileIntegrity(contractAddr, contractName, contractDataHash); err != nil {
724+
fileModified = true
725+
}
726+
}
727+
728+
// Install or update: new deps, out-of-sync files, or network updates with --update/--skip-update-prompts
724729
isNewDep := di.State.Dependencies().ByName(dependency.Name) == nil
725730

726731
err := di.updateDependencyState(dependency, contractDataHash)
@@ -729,26 +734,27 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency,
729734
return err
730735
}
731736

732-
// Log if this was an auto-update
733-
if hashMismatch && di.Update {
737+
// Log if this was an auto-update (with --update flag) or file repair
738+
if (hashMismatch || fileModified) && di.Update {
734739
msg := util.MessageWithEmojiPrefix("✅", fmt.Sprintf("%s updated to latest version", dependency.Name))
735740
di.logs.stateUpdates = append(di.logs.stateUpdates, msg)
741+
} else if fileModified {
742+
// File repair without --update flag (common after git clone)
743+
msg := util.MessageWithEmojiPrefix("✅", fmt.Sprintf("%s synced", dependency.Name))
744+
di.logs.stateUpdates = append(di.logs.stateUpdates, msg)
736745
}
737746

738747
// Handle additional tasks for new dependencies or when contract file doesn't exist
739-
if isNewDep || !di.contractFileExists(contractAddr, contractName) {
748+
if isNewDep || !fileExists {
740749
err := di.handleAdditionalDependencyTasks(networkName, dependency.Name)
741750
if err != nil {
742751
di.Logger.Error(fmt.Sprintf("Error handling additional dependency tasks: %v", err))
743752
return err
744753
}
745754
}
746755

747-
// Handle file creation/update
748-
fileExists := di.contractFileExists(contractAddr, contractName)
749-
forceOverwrite := hashMismatch && di.Update
750-
751-
if !fileExists || forceOverwrite {
756+
// Create or overwrite file
757+
if !fileExists || fileModified || (hashMismatch && di.Update) {
752758
err = di.createContractFile(contractAddr, contractName, contractData)
753759
if err != nil {
754760
return fmt.Errorf("error creating contract file: %w", err)
@@ -760,14 +766,6 @@ func (di *DependencyInstaller) handleFoundContract(dependency config.Dependency,
760766
}
761767
}
762768

763-
// Verify local file integrity matches stored hash (if file exists and we didn't just overwrite it)
764-
if fileExists && !forceOverwrite {
765-
err = di.verifyLocalFileIntegrity(contractAddr, contractName, contractDataHash)
766-
if err != nil {
767-
return err
768-
}
769-
}
770-
771769
return nil
772770
}
773771

internal/dependencymanager/dependencyinstaller_test.go

Lines changed: 96 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -750,9 +750,9 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) {
750750
assert.Empty(t, di.logs.stateUpdates, "Should have no state updates")
751751
})
752752

753-
t.Run("Already installed, up-to-date hash BUT modified local file", func(t *testing.T) {
754-
// This is the CRITICAL test case: network hash matches flow.json hash,
755-
// but local file has been tampered with. This should FAIL.
753+
t.Run("Already installed, up-to-date hash BUT modified local file - user repairs", func(t *testing.T) {
754+
// Network hash matches flow.json hash, but local file has been tampered with
755+
// Should auto-repair WITHOUT prompting (flow.json is source of truth)
756756
_, state, _ := util.TestMocks(t)
757757

758758
contractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World!" } }`)
@@ -796,7 +796,7 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) {
796796
gw.GetAccount.Return(acc, nil)
797797
})
798798

799-
// Mock prompter - should NOT be called (no update prompt since hashes match)
799+
// No prompter needed - auto-repairs when network agrees with flow.json
800800
mockPrompter := &mockPrompter{responses: []bool{}}
801801

802802
di := &DependencyInstaller{
@@ -819,14 +819,99 @@ func TestDependencyInstallerInstallFromFreshClone(t *testing.T) {
819819
}
820820

821821
err = di.Install()
822-
// Should FAIL because local file has been modified (tampering detected)
823-
assert.Error(t, err, "Should fail when local file is modified even if network hash matches")
824-
assert.Contains(t, err.Error(), "local file has been modified", "Error should mention file modification")
825-
assert.Contains(t, err.Error(), "hash mismatch", "Error should mention hash mismatch")
826-
assert.Contains(t, err.Error(), "Hello", "Error should mention the dependency name")
822+
// Should SUCCEED - auto-repaired without prompting
823+
assert.NoError(t, err, "Should auto-repair when network agrees with flow.json")
827824

828-
// Verify no prompts occurred (integrity check happens before any prompts)
829-
assert.Equal(t, 0, mockPrompter.index, "No prompts should have been shown")
825+
// Verify file WAS repaired
826+
fileContent, err := state.ReaderWriter().ReadFile(filePath)
827+
assert.NoError(t, err)
828+
assert.Contains(t, string(fileContent), "Hello, World!", "Should have correct version")
829+
assert.NotContains(t, string(fileContent), "HACKED", "Should not have hacked version")
830+
831+
// Verify NO prompt was shown (auto-repair because network agrees with flow.json)
832+
assert.Equal(t, 0, mockPrompter.index, "Should not prompt when network agrees with flow.json")
833+
})
834+
835+
t.Run("Already installed, up-to-date hash BUT modified local file - skip prompts mode", func(t *testing.T) {
836+
// Network hash matches flow.json hash, but local file has been tampered with
837+
// Should auto-repair even with --skip-update-prompts (no network change)
838+
_, state, _ := util.TestMocks(t)
839+
840+
contractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, World!" } }`)
841+
modifiedContractCode := []byte(`access(all) contract Hello { access(all) fun sayHello(): String { return "Hello, HACKED!" } }`)
842+
843+
// Calculate the hash of the correct contract
844+
hash := sha256.New()
845+
hash.Write(contractCode)
846+
contractHash := hex.EncodeToString(hash.Sum(nil))
847+
848+
// Simulate a dependency with matching hash in flow.json
849+
dep := config.Dependency{
850+
Name: "Hello",
851+
Source: config.Source{
852+
NetworkName: "emulator",
853+
Address: serviceAddress,
854+
ContractName: "Hello",
855+
},
856+
Hash: contractHash, // Hash matches what's on network
857+
}
858+
859+
state.Dependencies().AddOrUpdate(dep)
860+
861+
// Create a MODIFIED file (different from what hash says it should be)
862+
filePath := fmt.Sprintf("imports/%s/Hello.cdc", serviceAddress.String())
863+
err := state.ReaderWriter().MkdirAll(filepath.Dir(filePath), 0755)
864+
assert.NoError(t, err)
865+
err = state.ReaderWriter().WriteFile(filePath, modifiedContractCode, 0644)
866+
assert.NoError(t, err)
867+
868+
gw := mocks.DefaultMockGateway()
869+
870+
gw.GetAccount.Run(func(args mock.Arguments) {
871+
addr := args.Get(1).(flow.Address)
872+
assert.Equal(t, addr.String(), serviceAddress.String())
873+
acc := tests.NewAccountWithAddress(addr.String())
874+
acc.Contracts = map[string][]byte{
875+
"Hello": contractCode, // Network has the correct version
876+
}
877+
878+
gw.GetAccount.Return(acc, nil)
879+
})
880+
881+
// No prompter needed - auto-repairs regardless of flags
882+
mockPrompter := &mockPrompter{responses: []bool{}}
883+
884+
di := &DependencyInstaller{
885+
Gateways: map[string]gateway.Gateway{
886+
config.EmulatorNetwork.Name: gw.Mock,
887+
config.TestnetNetwork.Name: gw.Mock,
888+
config.MainnetNetwork.Name: gw.Mock,
889+
},
890+
Logger: logger,
891+
State: state,
892+
SaveState: true,
893+
TargetDir: "",
894+
SkipDeployments: true,
895+
SkipAlias: true,
896+
SkipUpdatePrompts: true, // Should still auto-repair (no network change)
897+
dependencies: make(map[string]config.Dependency),
898+
accountAliases: make(map[string]map[string]flow.Address),
899+
pendingPrompts: make([]pendingPrompt, 0),
900+
prompter: mockPrompter,
901+
}
902+
903+
err = di.Install()
904+
// Should SUCCEED - auto-repaired even with --skip-update-prompts
905+
assert.NoError(t, err, "Should succeed even with --skip-update-prompts (no network change)")
906+
907+
// Verify file WAS repaired
908+
fileContent, err := state.ReaderWriter().ReadFile(filePath)
909+
assert.NoError(t, err)
910+
assert.Contains(t, string(fileContent), "Hello, World!", "Should have correct version")
911+
assert.NotContains(t, string(fileContent), "HACKED", "Should not have hacked version")
912+
913+
// Verify no prompts (auto-repair because network agrees with flow.json)
914+
assert.Equal(t, 0, mockPrompter.index, "Should not prompt when network agrees with flow.json")
830915
})
831916

832917
t.Run("Already installed, outdated hash - user accepts", func(t *testing.T) {

0 commit comments

Comments
 (0)