@cryptotaxi247 / kubo / commits / 77ed3dd0e

feat(rpc): Content-Type headers and IPNS record get/put (#11067)

* fix http header when compress enabled for get command Closes #2376 * fix(rpc): set Content-Type for ipfs get based on output format - set application/x-tar when outputting tar (default and --archive) - set application/gzip when compression is enabled (--compress) - update go-ipfs-cmds with Tar encoding type and RFC 6713 compliant MIME types (application/gzip instead of application/x-gzip) * test(rpc): add Content-Type header tests for ipfs get * feat(rpc): add Content-Type headers for binary responses set proper Content-Type headers for RPC endpoints that return binary data: - `dag export`: application/vnd.ipld.car - `block get`: application/vnd.ipld.raw - `diag profile`: application/zip - `get`: application/x-tar or application/gzip (already worked, migrated to new API) uses the new OctetStream encoding type and SetContentType() method from go-ipfs-cmds to specify custom MIME types for binary responses. refs: https://github.com/ipfs/kubo/issues/2376 * feat(rpc): add `ipfs name get` command for IPNS record retrieval add dedicated command to retrieve raw signed IPNS records from the routing system. returns protobuf-encoded IPNS record with Content-Type `application/vnd.ipfs.ipns-record`. this provides a more convenient alternative to `ipfs routing get /ipns/<name>` which returns JSON with base64-encoded data. the raw output can be piped directly to `ipfs name inspect`: ipfs name get <name> | ipfs name inspect spec: https://specs.ipfs.tech/ipns/ipns-record/ * feat(rpc): add `ipfs name put` command for IPNS record storage adds `ipfs name put` to complement `ipfs name get`, allowing users to store IPNS records obtained from external sources without needing the private key. useful for backup, restore, and debugging workflows. the command validates records by default (signature, sequence number). use `--force` to bypass validation for testing how routing handles malformed or outdated records. also reorganizes test/cli files: - rename http_rpc_* -> rpc_* to match existing convention - merge name_get_put_test.go into name_test.go - add file header comments documenting test purposes * chore(deps): update go-ipfs-cmds to latest master includes SetContentType() for dynamic Content-Type headers --------- Co-authored-by: Marcin Rataj <lidel@lidel.org>

Andrew Gillis committed Jan 30, 2026 at 14:41 UTC 77ed3dd0ef485bc421f6975c43f6d745717a48f1
18 files changed +1144 -21
core/commands/block.go
+5
@@ -98,6 +98,9 @@ var blockGetCmd = &cmds.Command{
98 'ipfs block get' is a plumbing command for retrieving raw IPFS blocks.
99 It takes a <cid>, and outputs the block to stdout.
100 `,
101 + HTTP: &cmds.HTTPHelpText{
102 + ResponseContentType: "application/vnd.ipld.raw",
103 + },
104 },
105
106 Arguments: []cmds.Argument{
@@ -119,6 +122,8 @@ It takes a <cid>, and outputs the block to stdout.
122 return err
123 }
124
125 + res.SetEncodingType(cmds.OctetStream)
126 + res.SetContentType("application/vnd.ipld.raw")
127 return res.Emit(r)
128 },
129 }
core/commands/commands_test.go
+2
@@ -124,12 +124,14 @@ func TestCommands(t *testing.T) {
124 "/multibase/transcode",
125 "/multibase/list",
126 "/name",
127 + "/name/get",
128 "/name/inspect",
129 "/name/publish",
130 "/name/pubsub",
131 "/name/pubsub/cancel",
132 "/name/pubsub/state",
133 "/name/pubsub/subs",
134 + "/name/put",
135 "/name/resolve",
136 "/object",
137 "/object/data",
core/commands/dag/dag.go
+3
@@ -276,6 +276,9 @@ Note that at present only single root selections / .car files are supported.
276 The output of blocks happens in strict DAG-traversal, first-seen, order.
277 CAR file follows the CARv1 format: https://ipld.io/specs/transport/car/carv1/
278 `,
279 + HTTP: &cmds.HTTPHelpText{
280 + ResponseContentType: "application/vnd.ipld.car",
281 + },
282 },
283 Arguments: []cmds.Argument{
284 cmds.StringArg("root", true, false, "CID of a root to recursively export").EnableStdin(),
core/commands/dag/export.go
+2
@@ -73,6 +73,8 @@ func dagExport(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment
73
74 }()
75
76 + res.SetEncodingType(cmds.OctetStream)
77 + res.SetContentType("application/vnd.ipld.car")
78 if err := res.Emit(pipeR); err != nil {
79 pipeR.Close() // ignore the error if any
80 return err
core/commands/extra.go
+3 -1
@@ -1,6 +1,8 @@
1 package commands
2
3 -import cmds "github.com/ipfs/go-ipfs-cmds"
3 +import (
4 + cmds "github.com/ipfs/go-ipfs-cmds"
5 +)
6
7 func CreateCmdExtras(opts ...func(e *cmds.Extra)) *cmds.Extra {
8 e := new(cmds.Extra)
core/commands/get.go
+13
@@ -45,6 +45,9 @@ To output a TAR archive instead of unpacked files, use '--archive' or '-a'.
45 To compress the output with GZIP compression, use '--compress' or '-C'. You
46 may also specify the level of compression by specifying '-l=<1-9>'.
47 `,
48 + HTTP: &cmds.HTTPHelpText{
49 + ResponseContentType: "application/x-tar, or application/gzip when compress=true",
50 + },
51 },
52
53 Arguments: []cmds.Argument{
@@ -103,6 +106,16 @@ may also specify the level of compression by specifying '-l=<1-9>'.
106 reader.Close()
107 }()
108
109 + // Set Content-Type based on output format.
110 + // When compression is enabled, output is gzip (or tar.gz for directories).
111 + // Otherwise, tar is used as the transport format.
112 + res.SetEncodingType(cmds.OctetStream)
113 + if cmplvl != gzip.NoCompression {
114 + res.SetContentType("application/gzip")
115 + } else {
116 + res.SetContentType("application/x-tar")
117 + }
118 +
119 return res.Emit(reader)
120 },
121 PostRun: cmds.PostRunMap{
core/commands/name/name.go
+283 -11
@@ -3,15 +3,18 @@ package name
3 import (
4 "bytes"
5 "encoding/hex"
6 + "errors"
7 "fmt"
8 "io"
9 + "strings"
10 "text/tabwriter"
11 "time"
12
13 "github.com/ipfs/boxo/ipns"
14 ipns_pb "github.com/ipfs/boxo/ipns/pb"
15 cmds "github.com/ipfs/go-ipfs-cmds"
14 - cmdenv "github.com/ipfs/kubo/core/commands/cmdenv"
16 + "github.com/ipfs/kubo/core/commands/cmdenv"
17 + "github.com/ipfs/kubo/core/coreiface/options"
18 "google.golang.org/protobuf/proto"
19 )
20
@@ -42,29 +45,30 @@ Examples:
45
46 Publish an <ipfs-path> with your default name:
47
45 - > ipfs name publish /ipfs/QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy
46 - Published to QmbCMUZw6JFeZ7Wp9jkzbye3Fzp2GGcPgC3nmeUjfVF87n: /ipfs/QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy
48 + > ipfs name publish /ipfs/bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4
49 + Published to k51qzi5uqu5dgklc20hksmmzhoy5lfrn5xcnryq6xp4r50b5yc0vnivpywfu9p: /ipfs/bafk...
50
51 Publish an <ipfs-path> with another name, added by an 'ipfs key' command:
52
50 - > ipfs key gen --type=rsa --size=2048 mykey
51 - > ipfs name publish --key=mykey /ipfs/QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy
52 - Published to QmSrPmbaUKA3ZodhzPWZnpFgcPMFWF4QsxXbkWfEptTBJd: /ipfs/QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy
53 + > ipfs key gen --type=ed25519 mykey
54 + k51qzi5uqu5dlz49qkb657myg6f1buu6rauv8c6b489a9i1e4dkt7a3yo9j2wr
55 + > ipfs name publish --key=mykey /ipfs/bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4
56 + Published to k51qzi5uqu5dlz49qkb657myg6f1buu6rauv8c6b489a9i1e4dkt7a3yo9j2wr: /ipfs/bafk...
57
58 Resolve the value of your name:
59
60 > ipfs name resolve
57 - /ipfs/QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy
61 + /ipfs/bafk...
62
63 Resolve the value of another name:
64
61 - > ipfs name resolve QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ
62 - /ipfs/QmSiTko9JZyabH56y2fussEt1A5oDqsFXB3CkvAqraFryz
65 + > ipfs name resolve k51qzi5uqu5dlz49qkb657myg6f1buu6rauv8c6b489a9i1e4dkt7a3yo9j2wr
66 + /ipfs/bafk...
67
68 Resolve the value of a dnslink:
69
66 - > ipfs name resolve ipfs.io
67 - /ipfs/QmaBvfZooxWkrv7D3r8LS9moNjzD2o525XMZze69hhoxf5
70 + > ipfs name resolve specs.ipfs.tech
71 + /ipfs/bafy...
72
73 `,
74 },
@@ -74,6 +78,8 @@ Resolve the value of a dnslink:
78 "resolve": IpnsCmd,
79 "pubsub": IpnsPubsubCmd,
80 "inspect": IpnsInspectCmd,
81 + "get": IpnsGetCmd,
82 + "put": IpnsPutCmd,
83 },
84 }
85
@@ -123,6 +129,9 @@ in Multibase. The Data field is DAG-CBOR represented as DAG-JSON.
129 Passing --verify will verify signature against provided public key.
130
131 `,
132 + HTTP: &cmds.HTTPHelpText{
133 + Description: "Request body should be `multipart/form-data` with the IPNS record bytes.",
134 + },
135 },
136 Arguments: []cmds.Argument{
137 cmds.FileArg("record", true, false, "The IPNS record payload to be verified.").EnableStdin(),
@@ -267,3 +276,266 @@ Passing --verify will verify signature against provided public key.
276 }),
277 },
278 }
279 +
280 +var IpnsGetCmd = &cmds.Command{
281 + Status: cmds.Experimental,
282 + Helptext: cmds.HelpText{
283 + Tagline: "Retrieve a signed IPNS record.",
284 + ShortDescription: `
285 +Retrieves the signed IPNS record for a given name from the routing system.
286 +
287 +The output is the raw IPNS record (protobuf) as defined in the IPNS spec:
288 +https://specs.ipfs.tech/ipns/ipns-record/
289 +
290 +The record can be inspected with 'ipfs name inspect':
291 +
292 + ipfs name get <name> | ipfs name inspect
293 +
294 +This is equivalent to 'ipfs routing get /ipns/<name>' but only accepts
295 +IPNS names (not arbitrary routing keys).
296 +
297 +Note: The routing system returns the "best" IPNS record it knows about.
298 +For IPNS, "best" means the record with the highest sequence number.
299 +If multiple records exist (e.g., after using 'ipfs name put'), this command
300 +returns the one the routing system considers most current.
301 +`,
302 + HTTP: &cmds.HTTPHelpText{
303 + ResponseContentType: "application/vnd.ipfs.ipns-record",
304 + },
305 + },
306 + Arguments: []cmds.Argument{
307 + cmds.StringArg("name", true, false, "The IPNS name to look up."),
308 + },
309 + Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
310 + api, err := cmdenv.GetApi(env, req)
311 + if err != nil {
312 + return err
313 + }
314 +
315 + // Normalize the argument: accept both "k51..." and "/ipns/k51..."
316 + name := req.Arguments[0]
317 + if !strings.HasPrefix(name, "/ipns/") {
318 + name = "/ipns/" + name
319 + }
320 +
321 + data, err := api.Routing().Get(req.Context, name)
322 + if err != nil {
323 + return err
324 + }
325 +
326 + res.SetEncodingType(cmds.OctetStream)
327 + res.SetContentType("application/vnd.ipfs.ipns-record")
328 + return res.Emit(bytes.NewReader(data))
329 + },
330 +}
331 +
332 +const (
333 + forceOptionName = "force"
334 + putAllowOfflineOption = "allow-offline"
335 + allowDelegatedOption = "allow-delegated"
336 + maxIPNSRecordSize = 10 << 10 // 10 KiB per IPNS spec
337 +)
338 +
339 +var errPutAllowOffline = errors.New("can't put while offline: pass `--allow-offline` to store locally or `--allow-delegated` if Ipns.DelegatedPublishers are set up")
340 +
341 +var IpnsPutCmd = &cmds.Command{
342 + Status: cmds.Experimental,
343 + Helptext: cmds.HelpText{
344 + Tagline: "Store a pre-signed IPNS record in the routing system.",
345 + ShortDescription: `
346 +Stores a pre-signed IPNS record in the routing system.
347 +
348 +This command accepts a raw IPNS record (protobuf) as defined in the IPNS spec:
349 +https://specs.ipfs.tech/ipns/ipns-record/
350 +
351 +The record must be signed by the private key corresponding to the IPNS name.
352 +Use 'ipfs name get' to retrieve records and 'ipfs name inspect' to examine.
353 +`,
354 + LongDescription: `
355 +Stores a pre-signed IPNS record in the routing system.
356 +
357 +This command accepts a raw IPNS record (protobuf) as defined in the IPNS spec:
358 +https://specs.ipfs.tech/ipns/ipns-record/
359 +
360 +The record must be signed by the private key corresponding to the IPNS name.
361 +Use 'ipfs name get' to retrieve records and 'ipfs name inspect' to examine.
362 +
363 +Use Cases:
364 +
365 + - Re-publishing third-party records: store someone else's signed record
366 + - Cross-node sync: import records exported from another node
367 + - Backup/restore: export with 'name get', restore with 'name put'
368 +
369 +Validation:
370 +
371 +By default, the command validates that:
372 +
373 + - The record is a valid IPNS record (protobuf)
374 + - The record size is within 10 KiB limit
375 + - The signature matches the provided IPNS name
376 + - The record's sequence number is higher than any existing record
377 +
378 +The --force flag skips this command's validation and passes the record
379 +directly to the routing system. Note that --force only affects this command;
380 +it does not control how the routing system handles the record. The routing
381 +system may still reject invalid records or prefer records with higher sequence
382 +numbers. Use --force primarily for testing (e.g., to observe how the routing
383 +system reacts to incorrectly signed or malformed records).
384 +
385 +Important: Even after a successful 'name put', a subsequent 'name get' may
386 +return a different record if one with a higher sequence number exists.
387 +This is expected IPNS behavior, not a bug.
388 +
389 +Publishing Modes:
390 +
391 +By default, IPNS records are published to both the DHT and any configured
392 +HTTP delegated publishers. You can control this behavior with:
393 +
394 + --allow-offline Store locally without requiring network connectivity
395 + --allow-delegated Publish via HTTP delegated publishers only (no DHT)
396 +
397 +Examples:
398 +
399 +Export and re-import a record:
400 +
401 + > ipfs name get k51... > record.bin
402 + > ipfs name put k51... record.bin
403 +
404 +Store a record received from someone else:
405 +
406 + > ipfs name put k51... third-party-record.bin
407 +
408 +Force store a record to test routing validation:
409 +
410 + > ipfs name put --force k51... possibly-invalid-record.bin
411 +`,
412 + HTTP: &cmds.HTTPHelpText{
413 + Description: "Request body should be `multipart/form-data` with the IPNS record bytes.",
414 + },
415 + },
416 + Arguments: []cmds.Argument{
417 + cmds.StringArg("name", true, false, "The IPNS name to store the record for (e.g., k51... or /ipns/k51...)."),
418 + cmds.FileArg("record", true, false, "Path to file containing the signed IPNS record.").EnableStdin(),
419 + },
420 + Options: []cmds.Option{
421 + cmds.BoolOption(forceOptionName, "f", "Skip validation (signature, sequence, size)."),
422 + cmds.BoolOption(putAllowOfflineOption, "Store locally without broadcasting to the network."),
423 + cmds.BoolOption(allowDelegatedOption, "Publish via HTTP delegated publishers only (no DHT)."),
424 + },
425 + Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
426 + nd, err := cmdenv.GetNode(env)
427 + if err != nil {
428 + return err
429 + }
430 +
431 + api, err := cmdenv.GetApi(env, req)
432 + if err != nil {
433 + return err
434 + }
435 +
436 + // Parse options
437 + force, _ := req.Options[forceOptionName].(bool)
438 + allowOffline, _ := req.Options[putAllowOfflineOption].(bool)
439 + allowDelegated, _ := req.Options[allowDelegatedOption].(bool)
440 +
441 + // Validate flag combinations
442 + if allowOffline && allowDelegated {
443 + return errors.New("cannot use both --allow-offline and --allow-delegated flags")
444 + }
445 +
446 + // Handle different publishing modes
447 + if allowDelegated {
448 + // AllowDelegated mode: check if delegated publishers are configured
449 + cfg, err := nd.Repo.Config()
450 + if err != nil {
451 + return fmt.Errorf("failed to read config: %w", err)
452 + }
453 + delegatedPublishers := cfg.DelegatedPublishersWithAutoConf()
454 + if len(delegatedPublishers) == 0 {
455 + return errors.New("no delegated publishers configured: add Ipns.DelegatedPublishers or use --allow-offline for local-only publishing")
456 + }
457 + // For allow-delegated mode, we proceed even if offline
458 + // since we're using HTTP publishing via delegated publishers
459 + }
460 +
461 + // Parse the IPNS name argument
462 + nameArg := req.Arguments[0]
463 + if !strings.HasPrefix(nameArg, "/ipns/") {
464 + nameArg = "/ipns/" + nameArg
465 + }
466 + // Extract the name part after /ipns/
467 + namePart := strings.TrimPrefix(nameArg, "/ipns/")
468 + name, err := ipns.NameFromString(namePart)
469 + if err != nil {
470 + return fmt.Errorf("invalid IPNS name: %w", err)
471 + }
472 +
473 + // Read raw record bytes from file/stdin
474 + file, err := cmdenv.GetFileArg(req.Files.Entries())
475 + if err != nil {
476 + return err
477 + }
478 + defer file.Close()
479 +
480 + // Read record data (limit to 1 MiB for memory safety)
481 + data, err := io.ReadAll(io.LimitReader(file, 1<<20))
482 + if err != nil {
483 + return fmt.Errorf("failed to read record: %w", err)
484 + }
485 + if len(data) == 0 {
486 + return errors.New("record is empty")
487 + }
488 +
489 + // Validate unless --force
490 + if !force {
491 + // Check size limit per IPNS spec
492 + if len(data) > maxIPNSRecordSize {
493 + return fmt.Errorf("record exceeds maximum size of %d bytes, use --force to skip size check", maxIPNSRecordSize)
494 + }
495 + rec, err := ipns.UnmarshalRecord(data)
496 + if err != nil {
497 + return fmt.Errorf("invalid IPNS record: %w", err)
498 + }
499 +
500 + // Validate signature against provided name
501 + err = ipns.ValidateWithName(rec, name)
502 + if err != nil {
503 + return fmt.Errorf("record validation failed: %w", err)
504 + }
505 +
506 + // Check for sequence conflicts with existing record
507 + existingData, err := api.Routing().Get(req.Context, nameArg)
508 + 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)
517 + }
518 + }
519 + }
520 + }
521 + // If Get fails (no existing record), that's fine - proceed with put
522 + }
523 +
524 + // Publish the original bytes as-is
525 + // When allowDelegated is true, we set allowOffline to allow the operation
526 + // even without DHT connectivity (delegated publishers use HTTP)
527 + opts := []options.RoutingPutOption{
528 + options.Routing.AllowOffline(allowOffline || allowDelegated),
529 + }
530 +
531 + err = api.Routing().Put(req.Context, nameArg, data, opts...)
532 + if err != nil {
533 + if err.Error() == "can't put while offline" {
534 + return errPutAllowOffline
535 + }
536 + return err
537 + }
538 +
539 + return nil
540 + },
541 +}
core/commands/profile.go
+5
@@ -70,6 +70,9 @@ However, it could reveal:
70 - Memory offsets of various data structures.
71 - Any modifications you've made to go-ipfs.
72 `,
73 + HTTP: &cmds.HTTPHelpText{
74 + ResponseContentType: "application/zip",
75 + },
76 },
77 NoLocal: true,
78 Options: []cmds.Option{
@@ -121,6 +124,8 @@ However, it could reveal:
124 archive.Close()
125 _ = w.CloseWithError(err)
126 }()
127 + res.SetEncodingType(cmds.OctetStream)
128 + res.SetContentType("application/zip")
129 return res.Emit(r)
130 },
131 PostRun: cmds.PostRunMap{
docs/changelogs/v0.40.md
+28
@@ -22,6 +22,8 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team.
22 - [🌐 No unnecessary DNS lookups for AutoTLS addresses](#-no-unnecessary-dns-lookups-for-autotls-addresses)
23 - [⏱️ Configurable gateway request duration limit](#️-configurable-gateway-request-duration-limit)
24 - [🔧 Recovery from corrupted MFS root](#-recovery-from-corrupted-mfs-root)
25 + - [📡 RPC `Content-Type` headers for binary responses](#-rpc-content-type-headers-for-binary-responses)
26 + - [🔖 New `ipfs name get|put` commands](#-new-ipfs-name-getput-commands)
27 - [📋 Long listing format for `ipfs ls`](#-long-listing-format-for-ipfs-ls)
28 - [📦️ Dependency updates](#-dependency-updates)
29 - [📝 Changelog](#-changelog)
@@ -149,6 +151,32 @@ $ ipfs files chroot --confirm QmYourBackupCID
151
152 See `ipfs files chroot --help` for details.
153
154 +#### 📡 RPC `Content-Type` headers for binary responses
155 +
156 +HTTP RPC endpoints that return binary data now set appropriate `Content-Type` headers, making it easier to integrate with HTTP clients and tooling that rely on MIME types. On CLI these commands behave the same as before, but over HTTP RPC you now get proper headers:
157 +
158 +| Endpoint | Content-Type |
159 +|------------------------|-------------------------------------------|
160 +| `/api/v0/get` | `application/x-tar` or `application/gzip` |
161 +| `/api/v0/dag/export` | `application/vnd.ipld.car` |
162 +| `/api/v0/block/get` | `application/vnd.ipld.raw` |
163 +| `/api/v0/name/get` | `application/vnd.ipfs.ipns-record` |
164 +| `/api/v0/diag/profile` | `application/zip` |
165 +
166 +#### 🔖 New `ipfs name get|put` commands
167 +
168 +You can now backup, restore, and share IPNS records without needing the private key.
169 +
170 +```console
171 +$ ipfs name get /ipns/k51... > record.bin
172 +$ ipfs name get /ipns/k51... | ipfs name inspect
173 +$ ipfs name put k51... record.bin
174 +```
175 +
176 +These are low-level tools primarily for debugging and testing IPNS.
177 +
178 +The `put` command validates records by default. Use `--force` to skip validation and test how routing systems handle malformed or outdated records. Note that `--force` only bypasses this command's checks; the routing system may still reject invalid records.
179 +
180 #### 📋 Long listing format for `ipfs ls`
181
182 The `ipfs ls` command now supports `--long` (`-l`) flag for displaying Unix-style file permissions and modification times. This works with files added using `--preserve-mode` and `--preserve-mtime`. See `ipfs ls --help` for format details and examples.
docs/examples/kubo-as-a-library/go.mod
+1 -1
@@ -85,7 +85,7 @@ require (
85 github.com/ipfs/go-ds-pebble v0.5.9 // indirect
86 github.com/ipfs/go-dsqueue v0.1.2 // indirect
87 github.com/ipfs/go-fs-lock v0.1.1 // indirect
88 - github.com/ipfs/go-ipfs-cmds v0.15.0 // indirect
88 + github.com/ipfs/go-ipfs-cmds v0.15.1-0.20260130221847-44581e1f62e1 // indirect
89 github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect
90 github.com/ipfs/go-ipfs-pq v0.0.4 // indirect
91 github.com/ipfs/go-ipfs-redirects-file v0.1.2 // indirect
docs/examples/kubo-as-a-library/go.sum
+2 -2
@@ -303,8 +303,8 @@ github.com/ipfs/go-dsqueue v0.1.2 h1:jBMsgvT9Pj9l3cqI0m5jYpW/aWDYkW4Us6EuzrcSGbs
303 github.com/ipfs/go-dsqueue v0.1.2/go.mod h1:OU94YuMVUIF/ctR7Ysov9PI4gOa2XjPGN9nd8imSv78=
304 github.com/ipfs/go-fs-lock v0.1.1 h1:TecsP/Uc7WqYYatasreZQiP9EGRy4ZnKoG4yXxR33nw=
305 github.com/ipfs/go-fs-lock v0.1.1/go.mod h1:2goSXMCw7QfscHmSe09oXiR34DQeUdm+ei+dhonqly0=
306 -github.com/ipfs/go-ipfs-cmds v0.15.0 h1:nQDgKadrzyiFyYoZMARMIoVoSwe3gGTAfGvrWLeAQbQ=
307 -github.com/ipfs/go-ipfs-cmds v0.15.0/go.mod h1:VABf/mv/wqvYX6hLG6Z+40eNAEw3FQO0bSm370Or3Wk=
306 +github.com/ipfs/go-ipfs-cmds v0.15.1-0.20260130221847-44581e1f62e1 h1:l1DaJI5/+uOKdmvYrXwN3j/zOApLr8EBB0IGMTB7UaM=
307 +github.com/ipfs/go-ipfs-cmds v0.15.1-0.20260130221847-44581e1f62e1/go.mod h1:YmhRbpaLKg40i9Ogj2+L41tJ+8x50fF8u1FJJD/WNhc=
308 github.com/ipfs/go-ipfs-delay v0.0.0-20181109222059-70721b86a9a8/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw=
309 github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ=
310 github.com/ipfs/go-ipfs-delay v0.0.1/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw=
go.mod
+1 -1
@@ -33,7 +33,7 @@ require (
33 github.com/ipfs/go-ds-measure v0.2.2
34 github.com/ipfs/go-ds-pebble v0.5.9
35 github.com/ipfs/go-fs-lock v0.1.1
36 - github.com/ipfs/go-ipfs-cmds v0.15.0
36 + github.com/ipfs/go-ipfs-cmds v0.15.1-0.20260130221847-44581e1f62e1
37 github.com/ipfs/go-ipld-cbor v0.2.1
38 github.com/ipfs/go-ipld-format v0.6.3
39 github.com/ipfs/go-ipld-git v0.1.1
go.sum
+2 -2
@@ -374,8 +374,8 @@ github.com/ipfs/go-dsqueue v0.1.2 h1:jBMsgvT9Pj9l3cqI0m5jYpW/aWDYkW4Us6EuzrcSGbs
374 github.com/ipfs/go-dsqueue v0.1.2/go.mod h1:OU94YuMVUIF/ctR7Ysov9PI4gOa2XjPGN9nd8imSv78=
375 github.com/ipfs/go-fs-lock v0.1.1 h1:TecsP/Uc7WqYYatasreZQiP9EGRy4ZnKoG4yXxR33nw=
376 github.com/ipfs/go-fs-lock v0.1.1/go.mod h1:2goSXMCw7QfscHmSe09oXiR34DQeUdm+ei+dhonqly0=
377 -github.com/ipfs/go-ipfs-cmds v0.15.0 h1:nQDgKadrzyiFyYoZMARMIoVoSwe3gGTAfGvrWLeAQbQ=
378 -github.com/ipfs/go-ipfs-cmds v0.15.0/go.mod h1:VABf/mv/wqvYX6hLG6Z+40eNAEw3FQO0bSm370Or3Wk=
377 +github.com/ipfs/go-ipfs-cmds v0.15.1-0.20260130221847-44581e1f62e1 h1:l1DaJI5/+uOKdmvYrXwN3j/zOApLr8EBB0IGMTB7UaM=
378 +github.com/ipfs/go-ipfs-cmds v0.15.1-0.20260130221847-44581e1f62e1/go.mod h1:YmhRbpaLKg40i9Ogj2+L41tJ+8x50fF8u1FJJD/WNhc=
379 github.com/ipfs/go-ipfs-delay v0.0.0-20181109222059-70721b86a9a8/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw=
380 github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ=
381 github.com/ipfs/go-ipfs-delay v0.0.1/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw=
test/cli/name_test.go
+550
@@ -1,3 +1,7 @@
1 +// Tests for `ipfs name` CLI commands.
2 +// - TestName: tests name publish, resolve, and inspect
3 +// - TestNameGetPut: tests name get and put for raw IPNS record handling
4 +
5 package cli
6
7 import (
@@ -5,6 +9,7 @@ import (
9 "encoding/json"
10 "fmt"
11 "os"
12 + "path/filepath"
13 "strings"
14 "testing"
15
@@ -337,3 +342,548 @@ func TestName(t *testing.T) {
342 require.Contains(t, res.Stdout.String(), publishPath2, "New content should now be published")
343 })
344 }
345 +
346 +func TestNameGetPut(t *testing.T) {
347 + t.Parallel()
348 +
349 + const (
350 + fixturePath = "fixtures/TestName.car"
351 + fixtureCid = "bafybeidg3uxibfrt7uqh7zd5yaodetik7wjwi4u7rwv2ndbgj6ec7lsv2a"
352 + )
353 +
354 + makeDaemon := func(t *testing.T, daemonArgs ...string) *harness.Node {
355 + node := harness.NewT(t).NewNode().Init("--profile=test")
356 + r, err := os.Open(fixturePath)
357 + require.NoError(t, err)
358 + defer r.Close()
359 + err = node.IPFSDagImport(r, fixtureCid)
360 + require.NoError(t, err)
361 + return node.StartDaemon(daemonArgs...)
362 + }
363 +
364 + // makeKey creates a unique IPNS key for a test and returns the IPNS name
365 + makeKey := func(t *testing.T, node *harness.Node, keyName string) ipns.Name {
366 + res := node.IPFS("key", "gen", "--type=ed25519", keyName)
367 + keyID := strings.TrimSpace(res.Stdout.String())
368 + name, err := ipns.NameFromString(keyID)
369 + require.NoError(t, err)
370 + return name
371 + }
372 +
373 + // makeExternalRecord creates an IPNS record on an ephemeral node that is
374 + // shut down before returning. This ensures the test node has no local
375 + // knowledge of the record, properly testing put/get functionality.
376 + // We use short --lifetime so if IPNS records from tests get published on
377 + // the public DHT, they won't waste storage for long.
378 + makeExternalRecord := func(t *testing.T, h *harness.Harness, publishPath string, publishArgs ...string) (ipns.Name, []byte) {
379 + node := h.NewNode().Init("--profile=test")
380 +
381 + r, err := os.Open(fixturePath)
382 + require.NoError(t, err)
383 + defer r.Close()
384 + err = node.IPFSDagImport(r, fixtureCid)
385 + require.NoError(t, err)
386 +
387 + node.StartDaemon()
388 +
389 + res := node.IPFS("key", "gen", "--type=ed25519", "ephemeral-key")
390 + keyID := strings.TrimSpace(res.Stdout.String())
391 + ipnsName, err := ipns.NameFromString(keyID)
392 + require.NoError(t, err)
393 +
394 + args := []string{"name", "publish", "--key=ephemeral-key", "--lifetime=5m"}
395 + args = append(args, publishArgs...)
396 + args = append(args, publishPath)
397 + node.IPFS(args...)
398 +
399 + res = node.IPFS("name", "get", ipnsName.String())
400 + record := res.Stdout.Bytes()
401 + require.NotEmpty(t, record)
402 +
403 + node.StopDaemon()
404 +
405 + return ipnsName, record
406 + }
407 +
408 + t.Run("name get retrieves IPNS record", func(t *testing.T) {
409 + t.Parallel()
410 + node := makeDaemon(t)
411 + defer node.StopDaemon()
412 +
413 + publishPath := "/ipfs/" + fixtureCid
414 + ipnsName := makeKey(t, node, "testkey")
415 +
416 + // publish a record first
417 + node.IPFS("name", "publish", "--key=testkey", "--lifetime=5m", publishPath)
418 +
419 + // retrieve the record using name get
420 + res := node.IPFS("name", "get", ipnsName.String())
421 + record := res.Stdout.Bytes()
422 + require.NotEmpty(t, record, "expected non-empty IPNS record")
423 +
424 + // verify the record is valid by inspecting it
425 + res = node.PipeToIPFS(bytes.NewReader(record), "name", "inspect", "--verify="+ipnsName.String())
426 + require.Contains(t, res.Stdout.String(), "Valid: true")
427 + require.Contains(t, res.Stdout.String(), publishPath)
428 + })
429 +
430 + t.Run("name get accepts /ipns/ prefix", func(t *testing.T) {
431 + t.Parallel()
432 + node := makeDaemon(t)
433 + defer node.StopDaemon()
434 +
435 + publishPath := "/ipfs/" + fixtureCid
436 + ipnsName := makeKey(t, node, "testkey")
437 +
438 + node.IPFS("name", "publish", "--key=testkey", "--lifetime=5m", publishPath)
439 +
440 + // retrieve with /ipns/ prefix
441 + res := node.IPFS("name", "get", "/ipns/"+ipnsName.String())
442 + record := res.Stdout.Bytes()
443 + require.NotEmpty(t, record)
444 +
445 + // verify the record
446 + res = node.PipeToIPFS(bytes.NewReader(record), "name", "inspect", "--verify="+ipnsName.String())
447 + require.Contains(t, res.Stdout.String(), "Valid: true")
448 + })
449 +
450 + t.Run("name get fails for non-existent name", func(t *testing.T) {
451 + t.Parallel()
452 + node := makeDaemon(t)
453 + defer node.StopDaemon()
454 +
455 + // try to get a record for a random peer ID that doesn't exist
456 + res := node.RunIPFS("name", "get", "12D3KooWRirYjmmQATx2kgHBfky6DADsLP7ex1t7BRxJ6nqLs9WH")
457 + require.Error(t, res.Err)
458 + require.NotEqual(t, 0, res.ExitCode())
459 + })
460 +
461 + t.Run("name get fails for invalid name format", func(t *testing.T) {
462 + t.Parallel()
463 + node := makeDaemon(t)
464 + defer node.StopDaemon()
465 +
466 + res := node.RunIPFS("name", "get", "not-a-valid-ipns-name")
467 + require.Error(t, res.Err)
468 + require.NotEqual(t, 0, res.ExitCode())
469 + })
470 +
471 + t.Run("name put accepts /ipns/ prefix", func(t *testing.T) {
472 + t.Parallel()
473 + node := makeDaemon(t)
474 + defer node.StopDaemon()
475 +
476 + publishPath := "/ipfs/" + fixtureCid
477 + ipnsName := makeKey(t, node, "testkey")
478 +
479 + node.IPFS("name", "publish", "--key=testkey", "--lifetime=5m", publishPath)
480 +
481 + res := node.IPFS("name", "get", ipnsName.String())
482 + record := res.Stdout.Bytes()
483 +
484 + // put with /ipns/ prefix
485 + res = node.PipeToIPFS(bytes.NewReader(record), "name", "put", "--force", "/ipns/"+ipnsName.String())
486 + require.NoError(t, res.Err)
487 + })
488 +
489 + t.Run("name put fails for invalid name format", func(t *testing.T) {
490 + t.Parallel()
491 + node := makeDaemon(t)
492 + defer node.StopDaemon()
493 +
494 + // create a dummy file
495 + recordFile := filepath.Join(node.Dir, "dummy.bin")
496 + err := os.WriteFile(recordFile, []byte("dummy"), 0644)
497 + require.NoError(t, err)
498 +
499 + res := node.RunIPFS("name", "put", "not-a-valid-ipns-name", recordFile)
500 + require.Error(t, res.Err)
501 + require.Contains(t, res.Stderr.String(), "invalid IPNS name")
502 + })
503 +
504 + t.Run("name put rejects oversized record", func(t *testing.T) {
505 + t.Parallel()
506 + node := makeDaemon(t)
507 + defer node.StopDaemon()
508 +
509 + ipnsName := makeKey(t, node, "testkey")
510 +
511 + // create a file larger than 10 KiB
512 + oversizedRecord := make([]byte, 11*1024)
513 + recordFile := filepath.Join(node.Dir, "oversized.bin")
514 + err := os.WriteFile(recordFile, oversizedRecord, 0644)
515 + require.NoError(t, err)
516 +
517 + res := node.RunIPFS("name", "put", ipnsName.String(), recordFile)
518 + require.Error(t, res.Err)
519 + require.Contains(t, res.Stderr.String(), "exceeds maximum size")
520 + })
521 +
522 + t.Run("name put --force skips size check", func(t *testing.T) {
523 + t.Parallel()
524 + node := makeDaemon(t)
525 + defer node.StopDaemon()
526 +
527 + ipnsName := makeKey(t, node, "testkey")
528 +
529 + // create a file larger than 10 KiB
530 + oversizedRecord := make([]byte, 11*1024)
531 + recordFile := filepath.Join(node.Dir, "oversized.bin")
532 + err := os.WriteFile(recordFile, oversizedRecord, 0644)
533 + require.NoError(t, err)
534 +
535 + // with --force, size check is skipped (but routing will likely reject it)
536 + res := node.RunIPFS("name", "put", "--force", ipnsName.String(), recordFile)
537 + // the command itself should not fail on size, but routing may reject
538 + // we just verify it doesn't fail with "exceeds maximum size"
539 + if res.Err != nil {
540 + require.NotContains(t, res.Stderr.String(), "exceeds maximum size")
541 + }
542 + })
543 +
544 + t.Run("name put stores IPNS record", func(t *testing.T) {
545 + t.Parallel()
546 + h := harness.NewT(t)
547 + publishPath := "/ipfs/" + fixtureCid
548 +
549 + // create a record on an ephemeral node (shut down before test node starts)
550 + ipnsName, record := makeExternalRecord(t, h, publishPath)
551 +
552 + // start test node (has no local knowledge of the record)
553 + node := makeDaemon(t)
554 + defer node.StopDaemon()
555 +
556 + // put the record (should succeed since no existing record)
557 + recordFile := filepath.Join(node.Dir, "record.bin")
558 + err := os.WriteFile(recordFile, record, 0644)
559 + require.NoError(t, err)
560 +
561 + res := node.RunIPFS("name", "put", ipnsName.String(), recordFile)
562 + require.NoError(t, res.Err)
563 +
564 + // verify the record was stored by getting it back
565 + res = node.IPFS("name", "get", ipnsName.String())
566 + retrievedRecord := res.Stdout.Bytes()
567 + require.Equal(t, record, retrievedRecord, "stored record should match original")
568 + })
569 +
570 + t.Run("name put with --force overwrites existing record", func(t *testing.T) {
571 + t.Parallel()
572 + h := harness.NewT(t)
573 + publishPath := "/ipfs/" + fixtureCid
574 +
575 + // create a record on an ephemeral node
576 + ipnsName, record := makeExternalRecord(t, h, publishPath)
577 +
578 + // start test node
579 + node := makeDaemon(t)
580 + defer node.StopDaemon()
581 +
582 + // first put the record normally
583 + recordFile := filepath.Join(node.Dir, "record.bin")
584 + err := os.WriteFile(recordFile, record, 0644)
585 + require.NoError(t, err)
586 +
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)
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")
594 +
595 + // put the record with --force (should succeed)
596 + res = node.RunIPFS("name", "put", "--force", ipnsName.String(), recordFile)
597 + require.NoError(t, res.Err)
598 + })
599 +
600 + t.Run("name put validates signature against name", func(t *testing.T) {
601 + t.Parallel()
602 + h := harness.NewT(t)
603 + publishPath := "/ipfs/" + fixtureCid
604 +
605 + // create a record on an ephemeral node
606 + _, record := makeExternalRecord(t, h, publishPath)
607 +
608 + // start test node
609 + node := makeDaemon(t)
610 + defer node.StopDaemon()
611 +
612 + // write the record to a file
613 + recordFile := filepath.Join(node.Dir, "record.bin")
614 + err := os.WriteFile(recordFile, record, 0644)
615 + require.NoError(t, err)
616 +
617 + // try to put with a wrong name (should fail validation)
618 + wrongName := "12D3KooWRirYjmmQATx2kgHBfky6DADsLP7ex1t7BRxJ6nqLs9WH"
619 + res := node.RunIPFS("name", "put", wrongName, recordFile)
620 + require.Error(t, res.Err)
621 + require.Contains(t, res.Stderr.String(), "record validation failed")
622 + })
623 +
624 + t.Run("name put with --force skips command validation", func(t *testing.T) {
625 + t.Parallel()
626 + h := harness.NewT(t)
627 + publishPath := "/ipfs/" + fixtureCid
628 +
629 + // create a record on an ephemeral node
630 + ipnsName, record := makeExternalRecord(t, h, publishPath)
631 +
632 + // start test node
633 + node := makeDaemon(t)
634 + defer node.StopDaemon()
635 +
636 + // with --force the command skips its own validation (signature, sequence check)
637 + // and passes the record directly to the routing layer
638 + res := node.PipeToIPFS(bytes.NewReader(record), "name", "put", "--force", ipnsName.String())
639 + require.NoError(t, res.Err)
640 + })
641 +
642 + t.Run("name put rejects empty record", func(t *testing.T) {
643 + t.Parallel()
644 + node := makeDaemon(t)
645 + defer node.StopDaemon()
646 +
647 + ipnsName := makeKey(t, node, "testkey")
648 +
649 + // create an empty file
650 + recordFile := filepath.Join(node.Dir, "empty.bin")
651 + err := os.WriteFile(recordFile, []byte{}, 0644)
652 + require.NoError(t, err)
653 +
654 + res := node.RunIPFS("name", "put", ipnsName.String(), recordFile)
655 + require.Error(t, res.Err)
656 + require.Contains(t, res.Stderr.String(), "record is empty")
657 + })
658 +
659 + t.Run("name put rejects invalid record", func(t *testing.T) {
660 + t.Parallel()
661 + node := makeDaemon(t)
662 + defer node.StopDaemon()
663 +
664 + ipnsName := makeKey(t, node, "testkey")
665 +
666 + // create a file with garbage data
667 + recordFile := filepath.Join(node.Dir, "garbage.bin")
668 + err := os.WriteFile(recordFile, []byte("not a valid ipns record"), 0644)
669 + require.NoError(t, err)
670 +
671 + res := node.RunIPFS("name", "put", ipnsName.String(), recordFile)
672 + require.Error(t, res.Err)
673 + require.Contains(t, res.Stderr.String(), "invalid IPNS record")
674 + })
675 +
676 + t.Run("name put accepts stdin", func(t *testing.T) {
677 + t.Parallel()
678 + h := harness.NewT(t)
679 + publishPath := "/ipfs/" + fixtureCid
680 +
681 + // create a record on an ephemeral node
682 + ipnsName, record := makeExternalRecord(t, h, publishPath)
683 +
684 + // start test node (has no local knowledge of the record)
685 + node := makeDaemon(t)
686 + defer node.StopDaemon()
687 +
688 + // put via stdin (no --force needed since no existing record)
689 + res := node.PipeToIPFS(bytes.NewReader(record), "name", "put", ipnsName.String())
690 + require.NoError(t, res.Err)
691 + })
692 +
693 + t.Run("name put fails when offline without --allow-offline", func(t *testing.T) {
694 + t.Parallel()
695 + h := harness.NewT(t)
696 + publishPath := "/ipfs/" + fixtureCid
697 +
698 + // create a record on an ephemeral node
699 + ipnsName, record := makeExternalRecord(t, h, publishPath)
700 +
701 + // write the record to a file
702 + recordFile := filepath.Join(h.Dir, "record.bin")
703 + err := os.WriteFile(recordFile, record, 0644)
704 + require.NoError(t, err)
705 +
706 + // start test node in offline mode
707 + node := h.NewNode().Init("--profile=test")
708 + node.StartDaemon("--offline")
709 + defer node.StopDaemon()
710 +
711 + // try to put without --allow-offline (should fail)
712 + res := node.RunIPFS("name", "put", ipnsName.String(), recordFile)
713 + require.Error(t, res.Err)
714 + // error can come from our command or from the routing layer
715 + stderr := res.Stderr.String()
716 + require.True(t, strings.Contains(stderr, "offline") || strings.Contains(stderr, "online mode"),
717 + "expected offline-related error, got: %s", stderr)
718 + })
719 +
720 + t.Run("name put succeeds with --allow-offline", func(t *testing.T) {
721 + t.Parallel()
722 + h := harness.NewT(t)
723 + publishPath := "/ipfs/" + fixtureCid
724 +
725 + // create a record on an ephemeral node
726 + ipnsName, record := makeExternalRecord(t, h, publishPath)
727 +
728 + // write the record to a file
729 + recordFile := filepath.Join(h.Dir, "record.bin")
730 + err := os.WriteFile(recordFile, record, 0644)
731 + require.NoError(t, err)
732 +
733 + // start test node in offline mode
734 + node := h.NewNode().Init("--profile=test")
735 + node.StartDaemon("--offline")
736 + defer node.StopDaemon()
737 +
738 + // put with --allow-offline (should succeed, no --force needed since no existing record)
739 + res := node.RunIPFS("name", "put", "--allow-offline", ipnsName.String(), recordFile)
740 + require.NoError(t, res.Err)
741 + })
742 +
743 + t.Run("name get/put round trip preserves record bytes", func(t *testing.T) {
744 + t.Parallel()
745 + h := harness.NewT(t)
746 + publishPath := "/ipfs/" + fixtureCid
747 +
748 + // create a record on an ephemeral node
749 + ipnsName, originalRecord := makeExternalRecord(t, h, publishPath)
750 +
751 + // start test node (has no local knowledge of the record)
752 + node := makeDaemon(t)
753 + defer node.StopDaemon()
754 +
755 + // put the record
756 + res := node.PipeToIPFS(bytes.NewReader(originalRecord), "name", "put", ipnsName.String())
757 + require.NoError(t, res.Err)
758 +
759 + // get the record back
760 + res = node.IPFS("name", "get", ipnsName.String())
761 + retrievedRecord := res.Stdout.Bytes()
762 +
763 + // the records should be byte-for-byte identical
764 + require.Equal(t, originalRecord, retrievedRecord, "record bytes should be preserved after get/put round trip")
765 + })
766 +
767 + t.Run("name put --force allows storing lower sequence record", func(t *testing.T) {
768 + t.Parallel()
769 + h := harness.NewT(t)
770 + publishPath := "/ipfs/" + fixtureCid
771 +
772 + // create an ephemeral node to generate two records with different sequences
773 + ephNode := h.NewNode().Init("--profile=test")
774 +
775 + r, err := os.Open(fixturePath)
776 + require.NoError(t, err)
777 + err = ephNode.IPFSDagImport(r, fixtureCid)
778 + r.Close()
779 + require.NoError(t, err)
780 +
781 + ephNode.StartDaemon()
782 +
783 + res := ephNode.IPFS("key", "gen", "--type=ed25519", "ephemeral-key")
784 + keyID := strings.TrimSpace(res.Stdout.String())
785 + ipnsName, err := ipns.NameFromString(keyID)
786 + require.NoError(t, err)
787 +
788 + // publish record with sequence 100
789 + ephNode.IPFS("name", "publish", "--key=ephemeral-key", "--lifetime=5m", "--sequence=100", publishPath)
790 + res = ephNode.IPFS("name", "get", ipnsName.String())
791 + record100 := res.Stdout.Bytes()
792 +
793 + // publish record with sequence 200
794 + ephNode.IPFS("name", "publish", "--key=ephemeral-key", "--lifetime=5m", "--sequence=200", publishPath)
795 + res = ephNode.IPFS("name", "get", ipnsName.String())
796 + record200 := res.Stdout.Bytes()
797 +
798 + ephNode.StopDaemon()
799 +
800 + // start test node (has no local knowledge of the records)
801 + node := makeDaemon(t)
802 + defer node.StopDaemon()
803 +
804 + // helper to get sequence from record
805 + getSequence := func(record []byte) uint64 {
806 + res := node.PipeToIPFS(bytes.NewReader(record), "name", "inspect", "--enc=json")
807 + var result name.IpnsInspectResult
808 + err := json.Unmarshal(res.Stdout.Bytes(), &result)
809 + require.NoError(t, err)
810 + require.NotNil(t, result.Entry.Sequence)
811 + return *result.Entry.Sequence
812 + }
813 +
814 + // verify we have the right records
815 + require.Equal(t, uint64(100), getSequence(record100))
816 + require.Equal(t, uint64(200), getSequence(record200))
817 +
818 + // put record with sequence 200 first
819 + res = node.PipeToIPFS(bytes.NewReader(record200), "name", "put", ipnsName.String())
820 + require.NoError(t, res.Err)
821 +
822 + // verify current record has sequence 200
823 + res = node.IPFS("name", "get", ipnsName.String())
824 + require.Equal(t, uint64(200), getSequence(res.Stdout.Bytes()))
825 +
826 + // now put the lower sequence record (100) with --force
827 + // this should succeed (--force bypasses our sequence check)
828 + res = node.PipeToIPFS(bytes.NewReader(record100), "name", "put", "--force", ipnsName.String())
829 + require.NoError(t, res.Err, "putting lower sequence record with --force should succeed")
830 +
831 + // note: when we get the record, IPNS resolution returns the "best" record
832 + // (highest sequence), so we'll get the sequence 200 record back
833 + // this is expected IPNS behavior - the put succeeded, but get returns the best record
834 + res = node.IPFS("name", "get", ipnsName.String())
835 + retrievedSeq := getSequence(res.Stdout.Bytes())
836 + require.Equal(t, uint64(200), retrievedSeq, "IPNS get returns the best (highest sequence) record")
837 + })
838 +
839 + t.Run("name put sequence conflict detection", func(t *testing.T) {
840 + t.Parallel()
841 + h := harness.NewT(t)
842 + publishPath := "/ipfs/" + fixtureCid
843 +
844 + // create an ephemeral node to generate two records with different sequences
845 + ephNode := h.NewNode().Init("--profile=test")
846 +
847 + r, err := os.Open(fixturePath)
848 + require.NoError(t, err)
849 + err = ephNode.IPFSDagImport(r, fixtureCid)
850 + r.Close()
851 + require.NoError(t, err)
852 +
853 + ephNode.StartDaemon()
854 +
855 + res := ephNode.IPFS("key", "gen", "--type=ed25519", "ephemeral-key")
856 + keyID := strings.TrimSpace(res.Stdout.String())
857 + ipnsName, err := ipns.NameFromString(keyID)
858 + require.NoError(t, err)
859 +
860 + // publish record with sequence 100
861 + ephNode.IPFS("name", "publish", "--key=ephemeral-key", "--lifetime=5m", "--sequence=100", publishPath)
862 + res = ephNode.IPFS("name", "get", ipnsName.String())
863 + record100 := res.Stdout.Bytes()
864 +
865 + // publish record with sequence 200
866 + ephNode.IPFS("name", "publish", "--key=ephemeral-key", "--lifetime=5m", "--sequence=200", publishPath)
867 + res = ephNode.IPFS("name", "get", ipnsName.String())
868 + record200 := res.Stdout.Bytes()
869 +
870 + ephNode.StopDaemon()
871 +
872 + // start test node (has no local knowledge of the records)
873 + node := makeDaemon(t)
874 + defer node.StopDaemon()
875 +
876 + // put record with sequence 200 first
877 + res = node.PipeToIPFS(bytes.NewReader(record200), "name", "put", ipnsName.String())
878 + require.NoError(t, res.Err)
879 +
880 + // try to put record with sequence 100 (lower than current 200)
881 + recordFile := filepath.Join(node.Dir, "record100.bin")
882 + err = os.WriteFile(recordFile, record100, 0644)
883 + require.NoError(t, err)
884 +
885 + res = node.RunIPFS("name", "put", ipnsName.String(), recordFile)
886 + require.Error(t, res.Err)
887 + require.Contains(t, res.Stderr.String(), "existing record has sequence 200 >= new record sequence 100")
888 + })
889 +}
test/cli/rpc_content_type_test.go new
+167
@@ -0,0 +1,167 @@
1 +// Tests HTTP RPC Content-Type headers.
2 +// These tests verify that RPC endpoints return correct Content-Type headers
3 +// for binary responses (CAR, tar, gzip, raw blocks, IPNS records).
4 +
5 +package cli
6 +
7 +import (
8 + "bytes"
9 + "encoding/base64"
10 + "encoding/json"
11 + "io"
12 + "net/http"
13 + "testing"
14 +
15 + "github.com/ipfs/kubo/test/cli/harness"
16 + "github.com/stretchr/testify/assert"
17 + "github.com/stretchr/testify/require"
18 +)
19 +
20 +// TestRPCDagExportContentType verifies that the RPC endpoint for `ipfs dag export`
21 +// returns the correct Content-Type header for CAR output.
22 +func TestRPCDagExportContentType(t *testing.T) {
23 + t.Parallel()
24 +
25 + node := harness.NewT(t).NewNode().Init()
26 + node.StartDaemon("--offline")
27 +
28 + // add test content
29 + cid := node.IPFSAddStr("test content for dag export")
30 +
31 + url := node.APIURL() + "/api/v0/dag/export?arg=" + cid
32 +
33 + req, err := http.NewRequest(http.MethodPost, url, nil)
34 + require.NoError(t, err)
35 +
36 + resp, err := http.DefaultClient.Do(req)
37 + require.NoError(t, err)
38 + defer resp.Body.Close()
39 +
40 + assert.Equal(t, http.StatusOK, resp.StatusCode)
41 + assert.Equal(t, "application/vnd.ipld.car", resp.Header.Get("Content-Type"),
42 + "dag export should return application/vnd.ipld.car")
43 +}
44 +
45 +// TestRPCBlockGetContentType verifies that the RPC endpoint for `ipfs block get`
46 +// returns the correct Content-Type header for raw block data.
47 +func TestRPCBlockGetContentType(t *testing.T) {
48 + t.Parallel()
49 +
50 + node := harness.NewT(t).NewNode().Init()
51 + node.StartDaemon("--offline")
52 +
53 + // add test content
54 + cid := node.IPFSAddStr("test content for block get")
55 +
56 + url := node.APIURL() + "/api/v0/block/get?arg=" + cid
57 +
58 + req, err := http.NewRequest(http.MethodPost, url, nil)
59 + require.NoError(t, err)
60 +
61 + resp, err := http.DefaultClient.Do(req)
62 + require.NoError(t, err)
63 + defer resp.Body.Close()
64 +
65 + assert.Equal(t, http.StatusOK, resp.StatusCode)
66 + assert.Equal(t, "application/vnd.ipld.raw", resp.Header.Get("Content-Type"),
67 + "block get should return application/vnd.ipld.raw")
68 +}
69 +
70 +// TestRPCProfileContentType verifies that the RPC endpoint for `ipfs diag profile`
71 +// returns the correct Content-Type header for ZIP output.
72 +func TestRPCProfileContentType(t *testing.T) {
73 + t.Parallel()
74 +
75 + node := harness.NewT(t).NewNode().Init()
76 + node.StartDaemon("--offline")
77 +
78 + // use profile-time=0 to skip sampling profiles and return quickly
79 + url := node.APIURL() + "/api/v0/diag/profile?profile-time=0"
80 +
81 + req, err := http.NewRequest(http.MethodPost, url, nil)
82 + require.NoError(t, err)
83 +
84 + resp, err := http.DefaultClient.Do(req)
85 + require.NoError(t, err)
86 + defer resp.Body.Close()
87 +
88 + assert.Equal(t, http.StatusOK, resp.StatusCode)
89 + assert.Equal(t, "application/zip", resp.Header.Get("Content-Type"),
90 + "diag profile should return application/zip")
91 +}
92 +
93 +// TestHTTPRPCNameGet verifies the behavior of `ipfs name get` vs `ipfs routing get`:
94 +//
95 +// `ipfs name get <name>`:
96 +// - Purpose: dedicated command for retrieving IPNS records
97 +// - Returns: raw IPNS record bytes (protobuf)
98 +// - Content-Type: application/vnd.ipfs.ipns-record
99 +//
100 +// `ipfs routing get /ipns/<name>`:
101 +// - Purpose: generic routing get for any key type
102 +// - Returns: JSON with base64-encoded record in "Extra" field
103 +// - Content-Type: application/json
104 +//
105 +// Both commands retrieve the same underlying IPNS record data.
106 +func TestHTTPRPCNameGet(t *testing.T) {
107 + t.Parallel()
108 +
109 + node := harness.NewT(t).NewNode().Init()
110 + node.StartDaemon() // must be online to use routing
111 +
112 + // add test content and publish IPNS record
113 + cid := node.IPFSAddStr("test content for name get")
114 + node.IPFS("name", "publish", cid)
115 +
116 + // get the node's peer ID (which is also the IPNS name)
117 + peerID := node.PeerID().String()
118 +
119 + // Test ipfs name get - returns raw IPNS record bytes with specific Content-Type
120 + nameGetURL := node.APIURL() + "/api/v0/name/get?arg=" + peerID
121 + nameGetReq, err := http.NewRequest(http.MethodPost, nameGetURL, nil)
122 + require.NoError(t, err)
123 +
124 + nameGetResp, err := http.DefaultClient.Do(nameGetReq)
125 + require.NoError(t, err)
126 + defer nameGetResp.Body.Close()
127 +
128 + assert.Equal(t, http.StatusOK, nameGetResp.StatusCode)
129 + assert.Equal(t, "application/vnd.ipfs.ipns-record", nameGetResp.Header.Get("Content-Type"),
130 + "name get should return application/vnd.ipfs.ipns-record")
131 +
132 + nameGetBytes, err := io.ReadAll(nameGetResp.Body)
133 + require.NoError(t, err)
134 +
135 + // Test ipfs routing get /ipns/... - returns JSON with base64-encoded record
136 + routingGetURL := node.APIURL() + "/api/v0/routing/get?arg=/ipns/" + peerID
137 + routingGetReq, err := http.NewRequest(http.MethodPost, routingGetURL, nil)
138 + require.NoError(t, err)
139 +
140 + routingGetResp, err := http.DefaultClient.Do(routingGetReq)
141 + require.NoError(t, err)
142 + defer routingGetResp.Body.Close()
143 +
144 + assert.Equal(t, http.StatusOK, routingGetResp.StatusCode)
145 + assert.Equal(t, "application/json", routingGetResp.Header.Get("Content-Type"),
146 + "routing get should return application/json")
147 +
148 + // Parse JSON response and decode base64 record from "Extra" field
149 + var routingResp struct {
150 + Extra string `json:"Extra"`
151 + Type int `json:"Type"`
152 + }
153 + err = json.NewDecoder(routingGetResp.Body).Decode(&routingResp)
154 + require.NoError(t, err)
155 +
156 + routingGetBytes, err := base64.StdEncoding.DecodeString(routingResp.Extra)
157 + require.NoError(t, err)
158 +
159 + // Verify both commands return identical IPNS record bytes
160 + assert.Equal(t, nameGetBytes, routingGetBytes,
161 + "name get and routing get should return identical IPNS record bytes")
162 +
163 + // Verify the record can be inspected and contains the published CID
164 + inspectOutput := node.PipeToIPFS(bytes.NewReader(nameGetBytes), "name", "inspect")
165 + assert.Contains(t, inspectOutput.Stdout.String(), cid,
166 + "ipfs name inspect should show the published CID")
167 +}
test/cli/rpc_get_output_test.go new
+74
@@ -0,0 +1,74 @@
1 +package cli
2 +
3 +import (
4 + "net/http"
5 + "testing"
6 +
7 + "github.com/ipfs/kubo/test/cli/harness"
8 + "github.com/stretchr/testify/assert"
9 + "github.com/stretchr/testify/require"
10 +)
11 +
12 +// TestRPCGetContentType verifies that the RPC endpoint for `ipfs get` returns
13 +// the correct Content-Type header based on output format options.
14 +//
15 +// Output formats and expected Content-Type:
16 +// - default (no flags): tar (transport format) -> application/x-tar
17 +// - --archive: tar archive -> application/x-tar
18 +// - --compress: gzip -> application/gzip
19 +// - --archive --compress: tar.gz -> application/gzip
20 +//
21 +// Fixes: https://github.com/ipfs/kubo/issues/2376
22 +func TestRPCGetContentType(t *testing.T) {
23 + t.Parallel()
24 +
25 + node := harness.NewT(t).NewNode().Init()
26 + node.StartDaemon("--offline")
27 +
28 + // add test content
29 + cid := node.IPFSAddStr("test content for Content-Type header verification")
30 +
31 + tests := []struct {
32 + name string
33 + query string
34 + expectedContentType string
35 + }{
36 + {
37 + name: "default returns application/x-tar",
38 + query: "?arg=" + cid,
39 + expectedContentType: "application/x-tar",
40 + },
41 + {
42 + name: "archive=true returns application/x-tar",
43 + query: "?arg=" + cid + "&archive=true",
44 + expectedContentType: "application/x-tar",
45 + },
46 + {
47 + name: "compress=true returns application/gzip",
48 + query: "?arg=" + cid + "&compress=true",
49 + expectedContentType: "application/gzip",
50 + },
51 + {
52 + name: "archive=true&compress=true returns application/gzip",
53 + query: "?arg=" + cid + "&archive=true&compress=true",
54 + expectedContentType: "application/gzip",
55 + },
56 + }
57 +
58 + for _, tt := range tests {
59 + t.Run(tt.name, func(t *testing.T) {
60 + url := node.APIURL() + "/api/v0/get" + tt.query
61 +
62 + req, err := http.NewRequest(http.MethodPost, url, nil)
63 + require.NoError(t, err)
64 +
65 + resp, err := http.DefaultClient.Do(req)
66 + require.NoError(t, err)
67 + defer resp.Body.Close()
68 +
69 + assert.Equal(t, http.StatusOK, resp.StatusCode)
70 + assert.Equal(t, tt.expectedContentType, resp.Header.Get("Content-Type"),
71 + "Content-Type header mismatch for %s", tt.name)
72 + })
73 + }
74 +}
test/dependencies/go.mod
+1 -1
@@ -141,7 +141,7 @@ require (
141 github.com/ipfs/go-cid v0.6.0 // indirect
142 github.com/ipfs/go-datastore v0.9.0 // indirect
143 github.com/ipfs/go-dsqueue v0.1.2 // indirect
144 - github.com/ipfs/go-ipfs-cmds v0.15.0 // indirect
144 + github.com/ipfs/go-ipfs-cmds v0.15.1-0.20260130221847-44581e1f62e1 // indirect
145 github.com/ipfs/go-ipfs-redirects-file v0.1.2 // indirect
146 github.com/ipfs/go-ipld-cbor v0.2.1 // indirect
147 github.com/ipfs/go-ipld-format v0.6.3 // indirect
test/dependencies/go.sum
+2 -2
@@ -314,8 +314,8 @@ github.com/ipfs/go-ds-leveldb v0.5.2 h1:6nmxlQ2zbp4LCNdJVsmHfs9GP0eylfBNxpmY1csp
314 github.com/ipfs/go-ds-leveldb v0.5.2/go.mod h1:2fAwmcvD3WoRT72PzEekHBkQmBDhc39DJGoREiuGmYo=
315 github.com/ipfs/go-dsqueue v0.1.2 h1:jBMsgvT9Pj9l3cqI0m5jYpW/aWDYkW4Us6EuzrcSGbs=
316 github.com/ipfs/go-dsqueue v0.1.2/go.mod h1:OU94YuMVUIF/ctR7Ysov9PI4gOa2XjPGN9nd8imSv78=
317 -github.com/ipfs/go-ipfs-cmds v0.15.0 h1:nQDgKadrzyiFyYoZMARMIoVoSwe3gGTAfGvrWLeAQbQ=
318 -github.com/ipfs/go-ipfs-cmds v0.15.0/go.mod h1:VABf/mv/wqvYX6hLG6Z+40eNAEw3FQO0bSm370Or3Wk=
317 +github.com/ipfs/go-ipfs-cmds v0.15.1-0.20260130221847-44581e1f62e1 h1:l1DaJI5/+uOKdmvYrXwN3j/zOApLr8EBB0IGMTB7UaM=
318 +github.com/ipfs/go-ipfs-cmds v0.15.1-0.20260130221847-44581e1f62e1/go.mod h1:YmhRbpaLKg40i9Ogj2+L41tJ+8x50fF8u1FJJD/WNhc=
319 github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ=
320 github.com/ipfs/go-ipfs-delay v0.0.1/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw=
321 github.com/ipfs/go-ipfs-pq v0.0.4 h1:U7jjENWJd1jhcrR8X/xHTaph14PTAK9O+yaLJbjqgOw=