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)
Marcin Rataj committed
Feb 16, 2026 at 19:52 UTC
4a7472f1ace6cd344713924e27eeba2ccebc48fe
2 files changed
+119
-13
core/commands/name/name.go
+35
-9
@@ -333,6 +333,7 @@ const (
333
forceOptionName = "force"
334
putAllowOfflineOption = "allow-offline"
335
allowDelegatedOption = "allow-delegated"
336
+ putQuietOptionName = "quiet"
337
maxIPNSRecordSize = 10 << 10 // 10 KiB per IPNS spec
338
)
339
@@ -374,6 +375,7 @@ By default, the command validates that:
375
- The record size is within 10 KiB limit
376
- The signature matches the provided IPNS name
377
- The record's sequence number is higher than any existing record
378
+ (identical records are allowed for republishing)
379
380
The --force flag skips this command's validation and passes the record
381
directly to the routing system. Note that --force only affects this command;
@@ -421,6 +423,7 @@ Force store a record to test routing validation:
423
cmds.BoolOption(forceOptionName, "f", "Skip validation (signature, sequence, size)."),
424
cmds.BoolOption(putAllowOfflineOption, "Store locally without broadcasting to the network."),
425
cmds.BoolOption(allowDelegatedOption, "Publish via HTTP delegated publishers only (no DHT)."),
426
+ cmds.BoolOption(putQuietOptionName, "q", "Write no output."),
427
},
428
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
429
nd, err := cmdenv.GetNode(env)
@@ -506,14 +509,15 @@ Force store a record to test routing validation:
509
// Check for sequence conflicts with existing record
510
existingData, err := api.Routing().Get(req.Context, nameArg)
511
if err == nil {
509
- // We have an existing record, check sequence
510
- existingRec, parseErr := ipns.UnmarshalRecord(existingData)
511
- if parseErr == nil {
512
- existingSeq, seqErr := existingRec.Sequence()
513
- newSeq, newSeqErr := rec.Sequence()
514
- if seqErr == nil && newSeqErr == nil {
515
- if existingSeq >= newSeq {
516
- return fmt.Errorf("existing record has sequence %d >= new record sequence %d, use --force to overwrite", existingSeq, newSeq)
512
+ // Allow republishing the exact same record (common use case:
513
+ // get a third-party record and put it back to refresh DHT)
514
+ if !bytes.Equal(existingData, data) {
515
+ existingRec, parseErr := ipns.UnmarshalRecord(existingData)
516
+ if parseErr == nil {
517
+ existingSeq, seqErr := existingRec.Sequence()
518
+ newSeq, newSeqErr := rec.Sequence()
519
+ if seqErr == nil && newSeqErr == nil && existingSeq >= newSeq {
520
+ return fmt.Errorf("existing IPNS record has sequence %d >= new record sequence %d, use 'ipfs name put --force' to skip this check", existingSeq, newSeq)
521
}
522
}
523
}
@@ -536,6 +540,28 @@ Force store a record to test routing validation:
540
return err
541
}
542
539
- return nil
543
+ // Extract value from the record for the response
544
+ value := ""
545
+ if rec, err := ipns.UnmarshalRecord(data); err == nil {
546
+ if v, err := rec.Value(); err == nil {
547
+ value = v.String()
548
+ }
549
+ }
550
+
551
+ return cmds.EmitOnce(res, &IpnsEntry{
552
+ Name: name.String(),
553
+ Value: value,
554
+ })
555
+ },
556
+ Encoders: cmds.EncoderMap{
557
+ cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, ie *IpnsEntry) error {
558
+ quiet, _ := req.Options[putQuietOptionName].(bool)
559
+ if quiet {
560
+ return nil
561
+ }
562
+ _, err := fmt.Fprintln(w, cmdenv.EscNonPrint(ie.Name))
563
+ return err
564
+ }),
565
},
566
+ Type: IpnsEntry{},
567
}
test/cli/name_test.go
+84
-4
@@ -587,10 +587,9 @@ func TestNameGetPut(t *testing.T) {
587
res := node.RunIPFS("name", "put", ipnsName.String(), recordFile)
588
require.NoError(t, res.Err)
589
590
- // now try to put the same record again (should fail - same sequence)
590
+ // put the same record again (identical record republishing is allowed)
591
res = node.RunIPFS("name", "put", ipnsName.String(), recordFile)
592
- require.Error(t, res.Err)
593
- require.Contains(t, res.Stderr.String(), "existing record has sequence")
592
+ require.NoError(t, res.Err)
593
594
// put the record with --force (should succeed)
595
res = node.RunIPFS("name", "put", "--force", ipnsName.String(), recordFile)
@@ -884,6 +883,87 @@ func TestNameGetPut(t *testing.T) {
883
884
res = node.RunIPFS("name", "put", ipnsName.String(), recordFile)
885
require.Error(t, res.Err)
887
- require.Contains(t, res.Stderr.String(), "existing record has sequence 200 >= new record sequence 100")
886
+ require.Contains(t, res.Stderr.String(), "existing IPNS record has sequence 200 >= new record sequence 100")
887
+ })
888
+
889
+ t.Run("name put allows identical record republishing", func(t *testing.T) {
890
+ t.Parallel()
891
+ h := harness.NewT(t)
892
+ publishPath := "/ipfs/" + fixtureCid
893
+
894
+ ipnsName, record := makeExternalRecord(t, h, publishPath, "--sequence=100")
895
+
896
+ node := makeDaemon(t)
897
+ defer node.StopDaemon()
898
+
899
+ // put the record
900
+ res := node.PipeToIPFS(bytes.NewReader(record), "name", "put", ipnsName.String())
901
+ require.NoError(t, res.Err)
902
+
903
+ // put the exact same record again (same bytes, same sequence)
904
+ // this should succeed: republishing an identical record is a valid use case
905
+ res = node.PipeToIPFS(bytes.NewReader(record), "name", "put", ipnsName.String())
906
+ require.NoError(t, res.Err)
907
+ require.Contains(t, res.Stdout.String(), ipnsName.String())
908
+ })
909
+
910
+ t.Run("name put rejects different record with same sequence", func(t *testing.T) {
911
+ t.Parallel()
912
+ h := harness.NewT(t)
913
+
914
+ // create two different records signed by the same key with the same
915
+ // sequence number by using two ephemeral nodes that share a key
916
+ ephNode1 := h.NewNode().Init("--profile=test")
917
+ r, err := os.Open(fixturePath)
918
+ require.NoError(t, err)
919
+ err = ephNode1.IPFSDagImport(r, fixtureCid)
920
+ r.Close()
921
+ require.NoError(t, err)
922
+ ephNode1.StartDaemon()
923
+
924
+ res := ephNode1.IPFS("key", "gen", "--type=ed25519", "shared-key")
925
+ keyID := strings.TrimSpace(res.Stdout.String())
926
+ ipnsName, err := ipns.NameFromString(keyID)
927
+ require.NoError(t, err)
928
+
929
+ // publish record A (sequence=100, value=fixtureCid)
930
+ ephNode1.IPFS("name", "publish", "--key=shared-key", "--lifetime=5m", "--sequence=100", "/ipfs/"+fixtureCid)
931
+ res = ephNode1.IPFS("name", "get", ipnsName.String())
932
+ recordA := res.Stdout.Bytes()
933
+
934
+ // export key and import into second ephemeral node
935
+ keyFile := filepath.Join(ephNode1.Dir, "shared-key.key")
936
+ ephNode1.IPFS("key", "export", "--output="+keyFile, "shared-key")
937
+ ephNode1.StopDaemon()
938
+
939
+ ephNode2 := h.NewNode().Init("--profile=test")
940
+ ephNode2.StartDaemon()
941
+ ephNode2.IPFS("key", "import", "shared-key", keyFile)
942
+
943
+ // publish record B (sequence=100, different value)
944
+ ephNode2.IPFS("name", "publish", "--key=shared-key", "--lifetime=5m", "--sequence=100", "/ipfs/bafkqaaa")
945
+ res = ephNode2.IPFS("name", "get", ipnsName.String())
946
+ recordB := res.Stdout.Bytes()
947
+ ephNode2.StopDaemon()
948
+
949
+ // verify records have same sequence but different bytes
950
+ require.NotEqual(t, recordA, recordB, "records should have different bytes")
951
+
952
+ // start test node and try the put scenario
953
+ node := makeDaemon(t)
954
+ defer node.StopDaemon()
955
+
956
+ // put record A
957
+ res = node.PipeToIPFS(bytes.NewReader(recordA), "name", "put", ipnsName.String())
958
+ require.NoError(t, res.Err)
959
+
960
+ // try to put record B (different bytes, same sequence=100)
961
+ recordFile := filepath.Join(node.Dir, "recordB.bin")
962
+ err = os.WriteFile(recordFile, recordB, 0644)
963
+ require.NoError(t, err)
964
+
965
+ res = node.RunIPFS("name", "put", ipnsName.String(), recordFile)
966
+ require.Error(t, res.Err)
967
+ require.Contains(t, res.Stderr.String(), "existing IPNS record has sequence 100 >= new record sequence 100")
968
})
969
}