From 4a7472f1ace6cd344713924e27eeba2ccebc48fe Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Mon, 16 Feb 2026 19:52:30 +0100 Subject: [PATCH] fix: improve `ipfs name put` for IPNS record republishing (#11199) `name put` rejected republishing the exact same record because the sequence check used `>=` which blocked the common use case of fetching a third-party record and putting it back to refresh DHT availability. allow putting identical records (same bytes) while still rejecting different records with the same or lower sequence number. also add a success message on put (suppressible with `--quiet`), and clarify the error message to say "IPNS record" and reference `ipfs name put --force`. Closes #11197 (cherry picked from commit 3ba73501fe91e1d4a4cff6960c822a891c573de8) --- core/commands/name/name.go | 44 +++++++++++++++---- test/cli/name_test.go | 88 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 119 insertions(+), 13 deletions(-) diff --git a/core/commands/name/name.go b/core/commands/name/name.go index 0e1c4b0e1..4e1f99d92 100644 --- a/core/commands/name/name.go +++ b/core/commands/name/name.go @@ -333,6 +333,7 @@ const ( forceOptionName = "force" putAllowOfflineOption = "allow-offline" allowDelegatedOption = "allow-delegated" + putQuietOptionName = "quiet" maxIPNSRecordSize = 10 << 10 // 10 KiB per IPNS spec ) @@ -374,6 +375,7 @@ By default, the command validates that: - The record size is within 10 KiB limit - The signature matches the provided IPNS name - The record's sequence number is higher than any existing record + (identical records are allowed for republishing) The --force flag skips this command's validation and passes the record directly to the routing system. Note that --force only affects this command; @@ -421,6 +423,7 @@ Force store a record to test routing validation: cmds.BoolOption(forceOptionName, "f", "Skip validation (signature, sequence, size)."), cmds.BoolOption(putAllowOfflineOption, "Store locally without broadcasting to the network."), cmds.BoolOption(allowDelegatedOption, "Publish via HTTP delegated publishers only (no DHT)."), + cmds.BoolOption(putQuietOptionName, "q", "Write no output."), }, Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error { nd, err := cmdenv.GetNode(env) @@ -506,14 +509,15 @@ Force store a record to test routing validation: // Check for sequence conflicts with existing record existingData, err := api.Routing().Get(req.Context, nameArg) if err == nil { - // We have an existing record, check sequence - existingRec, parseErr := ipns.UnmarshalRecord(existingData) - if parseErr == nil { - existingSeq, seqErr := existingRec.Sequence() - newSeq, newSeqErr := rec.Sequence() - if seqErr == nil && newSeqErr == nil { - if existingSeq >= newSeq { - return fmt.Errorf("existing record has sequence %d >= new record sequence %d, use --force to overwrite", existingSeq, newSeq) + // Allow republishing the exact same record (common use case: + // get a third-party record and put it back to refresh DHT) + if !bytes.Equal(existingData, data) { + existingRec, parseErr := ipns.UnmarshalRecord(existingData) + if parseErr == nil { + existingSeq, seqErr := existingRec.Sequence() + newSeq, newSeqErr := rec.Sequence() + if seqErr == nil && newSeqErr == nil && existingSeq >= newSeq { + return fmt.Errorf("existing IPNS record has sequence %d >= new record sequence %d, use 'ipfs name put --force' to skip this check", existingSeq, newSeq) } } } @@ -536,6 +540,28 @@ Force store a record to test routing validation: return err } - return nil + // Extract value from the record for the response + value := "" + if rec, err := ipns.UnmarshalRecord(data); err == nil { + if v, err := rec.Value(); err == nil { + value = v.String() + } + } + + return cmds.EmitOnce(res, &IpnsEntry{ + Name: name.String(), + Value: value, + }) }, + Encoders: cmds.EncoderMap{ + cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, ie *IpnsEntry) error { + quiet, _ := req.Options[putQuietOptionName].(bool) + if quiet { + return nil + } + _, err := fmt.Fprintln(w, cmdenv.EscNonPrint(ie.Name)) + return err + }), + }, + Type: IpnsEntry{}, } diff --git a/test/cli/name_test.go b/test/cli/name_test.go index c9ce0ac26..2659be837 100644 --- a/test/cli/name_test.go +++ b/test/cli/name_test.go @@ -587,10 +587,9 @@ func TestNameGetPut(t *testing.T) { res := node.RunIPFS("name", "put", ipnsName.String(), recordFile) require.NoError(t, res.Err) - // now try to put the same record again (should fail - same sequence) + // put the same record again (identical record republishing is allowed) res = node.RunIPFS("name", "put", ipnsName.String(), recordFile) - require.Error(t, res.Err) - require.Contains(t, res.Stderr.String(), "existing record has sequence") + require.NoError(t, res.Err) // put the record with --force (should succeed) res = node.RunIPFS("name", "put", "--force", ipnsName.String(), recordFile) @@ -884,6 +883,87 @@ func TestNameGetPut(t *testing.T) { res = node.RunIPFS("name", "put", ipnsName.String(), recordFile) require.Error(t, res.Err) - require.Contains(t, res.Stderr.String(), "existing record has sequence 200 >= new record sequence 100") + require.Contains(t, res.Stderr.String(), "existing IPNS record has sequence 200 >= new record sequence 100") + }) + + t.Run("name put allows identical record republishing", func(t *testing.T) { + t.Parallel() + h := harness.NewT(t) + publishPath := "/ipfs/" + fixtureCid + + ipnsName, record := makeExternalRecord(t, h, publishPath, "--sequence=100") + + node := makeDaemon(t) + defer node.StopDaemon() + + // put the record + res := node.PipeToIPFS(bytes.NewReader(record), "name", "put", ipnsName.String()) + require.NoError(t, res.Err) + + // put the exact same record again (same bytes, same sequence) + // this should succeed: republishing an identical record is a valid use case + res = node.PipeToIPFS(bytes.NewReader(record), "name", "put", ipnsName.String()) + require.NoError(t, res.Err) + require.Contains(t, res.Stdout.String(), ipnsName.String()) + }) + + t.Run("name put rejects different record with same sequence", func(t *testing.T) { + t.Parallel() + h := harness.NewT(t) + + // create two different records signed by the same key with the same + // sequence number by using two ephemeral nodes that share a key + ephNode1 := h.NewNode().Init("--profile=test") + r, err := os.Open(fixturePath) + require.NoError(t, err) + err = ephNode1.IPFSDagImport(r, fixtureCid) + r.Close() + require.NoError(t, err) + ephNode1.StartDaemon() + + res := ephNode1.IPFS("key", "gen", "--type=ed25519", "shared-key") + keyID := strings.TrimSpace(res.Stdout.String()) + ipnsName, err := ipns.NameFromString(keyID) + require.NoError(t, err) + + // publish record A (sequence=100, value=fixtureCid) + ephNode1.IPFS("name", "publish", "--key=shared-key", "--lifetime=5m", "--sequence=100", "/ipfs/"+fixtureCid) + res = ephNode1.IPFS("name", "get", ipnsName.String()) + recordA := res.Stdout.Bytes() + + // export key and import into second ephemeral node + keyFile := filepath.Join(ephNode1.Dir, "shared-key.key") + ephNode1.IPFS("key", "export", "--output="+keyFile, "shared-key") + ephNode1.StopDaemon() + + ephNode2 := h.NewNode().Init("--profile=test") + ephNode2.StartDaemon() + ephNode2.IPFS("key", "import", "shared-key", keyFile) + + // publish record B (sequence=100, different value) + ephNode2.IPFS("name", "publish", "--key=shared-key", "--lifetime=5m", "--sequence=100", "/ipfs/bafkqaaa") + res = ephNode2.IPFS("name", "get", ipnsName.String()) + recordB := res.Stdout.Bytes() + ephNode2.StopDaemon() + + // verify records have same sequence but different bytes + require.NotEqual(t, recordA, recordB, "records should have different bytes") + + // start test node and try the put scenario + node := makeDaemon(t) + defer node.StopDaemon() + + // put record A + res = node.PipeToIPFS(bytes.NewReader(recordA), "name", "put", ipnsName.String()) + require.NoError(t, res.Err) + + // try to put record B (different bytes, same sequence=100) + recordFile := filepath.Join(node.Dir, "recordB.bin") + err = os.WriteFile(recordFile, recordB, 0644) + require.NoError(t, err) + + res = node.RunIPFS("name", "put", ipnsName.String(), recordFile) + require.Error(t, res.Err) + require.Contains(t, res.Stderr.String(), "existing IPNS record has sequence 100 >= new record sequence 100") }) }