Skip to content

Commit

Permalink
examples: verified file fetch
Browse files Browse the repository at this point in the history
  • Loading branch information
hacdias committed May 7, 2024
1 parent 0f223aa commit 5dd05ed
Show file tree
Hide file tree
Showing 6 changed files with 363 additions and 3 deletions.
6 changes: 3 additions & 3 deletions examples/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ module github.com/ipfs/boxo/examples
go 1.21

require (
github.com/ipfs/boxo v0.13.1
github.com/ipfs/boxo v0.18.0
github.com/ipfs/go-block-format v0.2.0
github.com/ipfs/go-cid v0.4.1
github.com/ipfs/go-datastore v0.6.0
github.com/ipfs/go-unixfsnode v1.9.0
github.com/ipld/go-car/v2 v2.13.1
github.com/ipld/go-codec-dagpb v1.6.0
github.com/ipld/go-ipld-prime v0.21.0
github.com/libp2p/go-libp2p v0.33.2
github.com/libp2p/go-libp2p-routing-helpers v0.7.3
Expand Down Expand Up @@ -75,10 +77,8 @@ require (
github.com/ipfs/go-merkledag v0.11.0 // indirect
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
github.com/ipfs/go-peertaskqueue v0.8.1 // indirect
github.com/ipfs/go-unixfsnode v1.9.0 // indirect
github.com/ipfs/go-verifcid v0.0.3 // indirect
github.com/ipld/go-car v0.6.2 // indirect
github.com/ipld/go-codec-dagpb v1.6.0 // indirect
github.com/jackpal/go-nat-pmp v1.0.2 // indirect
github.com/jbenet/go-temp-err-catcher v0.1.0 // indirect
github.com/jbenet/goprocess v0.1.4 // indirect
Expand Down
27 changes: 27 additions & 0 deletions examples/verified-fetch/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Verified File Fetch

This example shows how to download a UnixFS file from a gateway that implements
the [Trustless Gateway](https://specs.ipfs.tech/http-gateways/trustless-gateway/)
specification, in a trustless, verifiable manner.

This example does not yet support downloading UnixFS directories, since that becomes
more complex. For now, we would suggest reading the [`extract.go`](https://github.com/ipld/go-car/blob/master/cmd/car/extract.go)
file from `go-car` in order to understand how to convert a directory into a file system.

## Build

```bash
> go build -o verified-fetch
```

## Usage

First, you need a gateway that complies with the Trustless Gateway specification.
In our specific case, we need that the gateway supports both the CAR file format,
as well as verifiable IPNS records, in the case we fetch from an `/ipns` URL.

As an example, you can verifiably fetch a `hello.txt` file:

```
./verified-fetch -o hello.txt /ipfs/bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e
```
242 changes: 242 additions & 0 deletions examples/verified-fetch/fetcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
package main

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"

"github.com/ipfs/boxo/blockservice"
"github.com/ipfs/boxo/blockstore"
"github.com/ipfs/boxo/exchange/offline"
bsfetcher "github.com/ipfs/boxo/fetcher/impl/blockservice"
files "github.com/ipfs/boxo/files"
"github.com/ipfs/boxo/gateway"
"github.com/ipfs/boxo/ipld/merkledag"
unixfile "github.com/ipfs/boxo/ipld/unixfs/file"
"github.com/ipfs/boxo/namesys"
"github.com/ipfs/boxo/path"
"github.com/ipfs/boxo/path/resolver"
"github.com/ipfs/go-datastore"
dssync "github.com/ipfs/go-datastore/sync"
"github.com/ipfs/go-unixfsnode"
gocarv2 "github.com/ipld/go-car/v2"
dagpb "github.com/ipld/go-codec-dagpb"
)

// fetcher fetches files over HTTP using verifiable CAR archives.
type fetcher struct {
gateway string
limit int64
userAgent string
ns namesys.NameSystem
}

type fetcherOption func(f *fetcher) error

// withUserAgent sets the user agent for the [Fetcher].
func withUserAgent(userAgent string) fetcherOption {
return func(f *fetcher) error {
f.userAgent = userAgent
return nil
}
}

// withLimit sets the limit for the [Fetcher].
func withLimit(limit int64) fetcherOption {
return func(f *fetcher) error {
f.limit = limit
return nil
}
}

// newFetcher creates a new [Fetcher]. Setting the gateway is mandatory.
func newFetcher(gatewayURL string, options ...fetcherOption) (*fetcher, error) {
if gatewayURL == "" {
return nil, errors.New("a gateway must be set")
}

Check warning on line 60 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L59-L60

Added lines #L59 - L60 were not covered by tests

vs, err := gateway.NewRemoteValueStore([]string{gatewayURL}, nil)
if err != nil {
return nil, err
}

Check warning on line 65 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L64-L65

Added lines #L64 - L65 were not covered by tests

ns, err := namesys.NewNameSystem(vs)
if err != nil {
return nil, err
}

Check warning on line 70 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L69-L70

Added lines #L69 - L70 were not covered by tests

f := &fetcher{
gateway: strings.TrimRight(gatewayURL, "/"),
ns: ns,
}

for _, option := range options {
if err := option(f); err != nil {
return nil, err
}

Check warning on line 80 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L79-L80

Added lines #L79 - L80 were not covered by tests
}

return f, nil
}

// fetch attempts to fetch the file at the given path, from the distribution
// site configured for this HttpFetcher.
func (f *fetcher) fetch(ctx context.Context, p path.Path, output string) error {
imPath, err := f.resolvePath(ctx, p)
if err != nil {
return fmt.Errorf("path could not be resolved: %w", err)
}

Check warning on line 92 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L91-L92

Added lines #L91 - L92 were not covered by tests

rc, err := f.httpRequest(ctx, imPath, "application/vnd.ipld.car")
if err != nil {
return fmt.Errorf("failed to fetch CAR: %w", err)
}

Check warning on line 97 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L96-L97

Added lines #L96 - L97 were not covered by tests
defer rc.Close()

rc, err = carToFileStream(ctx, rc, imPath)
if err != nil {
return fmt.Errorf("failed to read car stream: %w", err)
}
defer rc.Close()

fd, err := os.OpenFile(output, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
return fmt.Errorf("failed to open output file: %w", err)
}

Check warning on line 109 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L108-L109

Added lines #L108 - L109 were not covered by tests
defer fd.Close()

_, err = io.Copy(fd, rc)
return err
}

func (f *fetcher) resolvePath(ctx context.Context, p path.Path) (path.ImmutablePath, error) {
res, err := f.ns.Resolve(ctx, p)
if err != nil {
return path.ImmutablePath{}, err
}

Check warning on line 120 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L119-L120

Added lines #L119 - L120 were not covered by tests

imPath, err := path.NewImmutablePath(res.Path)
if err != nil {
return path.ImmutablePath{}, fmt.Errorf("could not resolve to immutable path: %w", err)
}

Check warning on line 125 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L124-L125

Added lines #L124 - L125 were not covered by tests

return imPath, nil
}

func (f *fetcher) httpRequest(ctx context.Context, p path.Path, accept string) (io.ReadCloser, error) {
url := f.gateway + p.String()
fmt.Printf("Fetching with HTTP: %q\n", url)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("http.NewRequest error: %w", err)
}

Check warning on line 136 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L135-L136

Added lines #L135 - L136 were not covered by tests
req.Header.Set("Accept", accept)

if f.userAgent != "" {
req.Header.Set("User-Agent", f.userAgent)
}

Check warning on line 141 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L140-L141

Added lines #L140 - L141 were not covered by tests

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("http.DefaultClient.Do error: %w", err)
}

Check warning on line 146 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L145-L146

Added lines #L145 - L146 were not covered by tests

if resp.StatusCode >= 400 {
defer resp.Body.Close()
mes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading error body: %w", err)
}
return nil, fmt.Errorf("GET %s error: %s: %s", url, resp.Status, string(mes))

Check warning on line 154 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L149-L154

Added lines #L149 - L154 were not covered by tests
}

var rc io.ReadCloser
if f.limit > 0 {
rc = newLimitReadCloser(resp.Body, f.limit)

Check warning on line 159 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L159

Added line #L159 was not covered by tests
} else {
rc = resp.Body
}

return rc, nil
}

func carToFileStream(ctx context.Context, r io.ReadCloser, imPath path.ImmutablePath) (io.ReadCloser, error) {
defer r.Close()

// Create temporary block datastore and dag service.
dataStore := dssync.MutexWrap(datastore.NewMapDatastore())
blockStore := blockstore.NewBlockstore(dataStore)
blockService := blockservice.New(blockStore, offline.Exchange(blockStore))
dagService := merkledag.NewDAGService(blockService)

defer dagService.Blocks.Close()
defer dataStore.Close()

// Create CAR reader
car, err := gocarv2.NewBlockReader(r)
if err != nil {
fmt.Println(err)
return nil, fmt.Errorf("error creating car reader: %s", err)
}

// Add all blocks to the blockstore.
for {
block, err := car.Next()
if err != nil && err != io.EOF {
return nil, fmt.Errorf("error reading block from car: %s", err)

Check warning on line 190 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L190

Added line #L190 was not covered by tests
} else if block == nil {
break
}

err = blockStore.Put(ctx, block)
if err != nil {
return nil, fmt.Errorf("error putting block in blockstore: %s", err)
}

Check warning on line 198 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L197-L198

Added lines #L197 - L198 were not covered by tests
}

fetcherCfg := bsfetcher.NewFetcherConfig(blockService)
fetcherCfg.PrototypeChooser = dagpb.AddSupportToChooser(bsfetcher.DefaultPrototypeChooser)
fetcher := fetcherCfg.WithReifier(unixfsnode.Reify)
resolver := resolver.NewBasicResolver(fetcher)

cid, _, err := resolver.ResolveToLastNode(ctx, imPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve: %w", err)
}

Check warning on line 209 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L208-L209

Added lines #L208 - L209 were not covered by tests

nd, err := dagService.Get(ctx, cid)
if err != nil {
return nil, fmt.Errorf("failed to resolve: %w", err)
}

Check warning on line 214 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L213-L214

Added lines #L213 - L214 were not covered by tests

// Make UnixFS file out of the node.
uf, err := unixfile.NewUnixfsFile(ctx, dagService, nd)
if err != nil {
return nil, fmt.Errorf("error building unixfs file: %s", err)
}

Check warning on line 220 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L219-L220

Added lines #L219 - L220 were not covered by tests

// Check if it's a file and return.
if f, ok := uf.(files.File); ok {
return f, nil
}

return nil, errors.New("unexpected unixfs node type")

Check warning on line 227 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L227

Added line #L227 was not covered by tests
}

type limitReadCloser struct {
io.Reader
io.Closer
}

// newLimitReadCloser returns a new [io.ReadCloser] with the reader wrapped in a
// [io.LimitedReader], limiting the reading to the specified amount.
func newLimitReadCloser(rc io.ReadCloser, limit int64) io.ReadCloser {
return limitReadCloser{
Reader: io.LimitReader(rc, limit),
Closer: rc,
}

Check warning on line 241 in examples/verified-fetch/fetcher.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/fetcher.go#L237-L241

Added lines #L237 - L241 were not covered by tests
}
Binary file added examples/verified-fetch/hello.car
Binary file not shown.
53 changes: 53 additions & 0 deletions examples/verified-fetch/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package main

import (
"context"
"flag"
"fmt"
"log"
"os"

"github.com/ipfs/boxo/path"
)

func main() {
flag.Usage = func() {
fmt.Println("Usage: verified-fetch [flags] <path>")
flag.PrintDefaults()
}

Check warning on line 17 in examples/verified-fetch/main.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/main.go#L13-L17

Added lines #L13 - L17 were not covered by tests

gatewayUrlPtr := flag.String("g", "https://trustless-gateway.link", "trustless gateway to download the CAR file from")
userAgentPtr := flag.String("u", "", "user agent to use during the HTTP requests")
outputPtr := flag.String("o", "out", "output path to store the fetched path")
limitPtr := flag.Int64("l", 0, "file size limit for the gateway download")
flag.Parse()

ipfsPath := flag.Arg(0)
if len(ipfsPath) == 0 {
flag.Usage()
os.Exit(1)
}

Check warning on line 29 in examples/verified-fetch/main.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/main.go#L19-L29

Added lines #L19 - L29 were not covered by tests

if err := run(*gatewayUrlPtr, ipfsPath, *outputPtr, *userAgentPtr, *limitPtr); err != nil {
log.Fatal(err)
}

Check warning on line 33 in examples/verified-fetch/main.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/main.go#L31-L33

Added lines #L31 - L33 were not covered by tests
}

func run(gatewayURL, ipfsPath, output, userAgent string, limit int64) error {
p, err := path.NewPath(ipfsPath)
if err != nil {
return err
}

Check warning on line 40 in examples/verified-fetch/main.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/main.go#L39-L40

Added lines #L39 - L40 were not covered by tests

options := []fetcherOption{
withUserAgent(userAgent),
withLimit(limit),
}

f, err := newFetcher(gatewayURL, options...)
if err != nil {
return err
}

Check warning on line 50 in examples/verified-fetch/main.go

View check run for this annotation

Codecov / codecov/patch

examples/verified-fetch/main.go#L49-L50

Added lines #L49 - L50 were not covered by tests

return f.fetch(context.Background(), p, output)
}
38 changes: 38 additions & 0 deletions examples/verified-fetch/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package main

import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
)

const (
HelloWorldCID = "bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e"
)

func TestErrorOnInvalidContent(t *testing.T) {
rs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("wrong data"))
}))
t.Cleanup(rs.Close)

err := run(rs.URL, "/ipfs/"+HelloWorldCID, "hello.txt", "", 0)
require.Error(t, err)
}

func TestSuccessOnValidContent(t *testing.T) {
data, err := os.ReadFile("./hello.car")
require.NoError(t, err)

rs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(data)
}))
t.Cleanup(rs.Close)

err = run(rs.URL, "/ipfs/"+HelloWorldCID, filepath.Join(t.TempDir(), "hello.txt"), "", 0)
require.NoError(t, err)
}

0 comments on commit 5dd05ed

Please sign in to comment.