From 6467aa7bc0e974547ee8fe9562c79672d2a41107 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 7 Aug 2024 13:53:08 +0200 Subject: [PATCH 01/15] docs(changelog): reference related PRs --- CHANGELOG.md | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d52df4d9..5b8ba989e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,21 +29,17 @@ The following emojis are used to highlight certain changes: ### Changed - `go-libp2p` dependency updated to [v0.36 (release notes)](https://github.com/libp2p/go-libp2p/releases/tag/v0.36.1) -- `bitswap/server` minor memory use and performance improvements -- `bitswap` unify logger names to use uniform format bitswap/path/pkgname -- `gateway` now always returns meaningful cache-control headers for generated HTML listings of UnixFS directories -- generate random test data using `ipfs/go-test` instead of internal util code - -### Removed - -- `util` logic for generating random test data moved to [`ipfs/go-test/random`](https://github.com/ipfs/go-test) +- `bitswap/server` minor memory use and performance improvements [#634](https://github.com/ipfs/boxo/pull/634) +- `bitswap` unify logger names to use uniform format bitswap/path/pkgname [#637](https://github.com/ipfs/boxo/pull/637) +- `gateway` now always returns meaningful cache-control headers for generated HTML listings of UnixFS directories [#643](https://github.com/ipfs/boxo/pull/643) +- `util` generate random test data using `ipfs/go-test` instead of internal util code [#638](https://github.com/ipfs/boxo/pull/638) ### Fixed -- `boxo/gateway` now correctly returns 404 Status Not Found instead of 500 when the requested content cannot be found due to offline exchange, gateway running in no-fetch (non-recursive) mode, or a similar restriction that only serves a specific set of CIDs. -- `bitswap/client` fix memory leak in BlockPresenceManager due to unlimited map growth. -- `bitswap/network` fixed race condition when a timeout occurred before hole punching completed while establishing a first-time stream to a peer behind a NAT -- `bitswap`: wantlist overflow handling now cancels existing entries to make room for newer entries. This fix prevents the wantlist from filling up with CIDs that the server does not have. +- `boxo/gateway` now correctly returns 404 Status Not Found instead of 500 when the requested content cannot be found due to offline exchange, gateway running in no-fetch (non-recursive) mode, or a similar restriction that only serves a specific set of CIDs. [#630](https://github.com/ipfs/boxo/pull/630) +- `bitswap/client` fix memory leak in BlockPresenceManager due to unlimited map growth. [#636](https://github.com/ipfs/boxo/pull/636) +- `bitswap/network` fixed race condition when a timeout occurred before hole punching completed while establishing a first-time stream to a peer behind a NAT [#651](https://github.com/ipfs/boxo/pull/651) +- `bitswap`: wantlist overflow handling now cancels existing entries to make room for newer entries. This fix prevents the wantlist from filling up with CIDs that the server does not have. [#629](https://github.com/ipfs/boxo/pull/629) ## [v0.21.0] From 635e1754f8eb888518f211d2e7253f384b0441d4 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 7 Aug 2024 16:35:27 +0200 Subject: [PATCH 02/15] docs(changelog): Wants interface change --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b8ba989e..0657c1a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ The following emojis are used to highlight certain changes: - `bitswap` unify logger names to use uniform format bitswap/path/pkgname [#637](https://github.com/ipfs/boxo/pull/637) - `gateway` now always returns meaningful cache-control headers for generated HTML listings of UnixFS directories [#643](https://github.com/ipfs/boxo/pull/643) - `util` generate random test data using `ipfs/go-test` instead of internal util code [#638](https://github.com/ipfs/boxo/pull/638) +- `bitswap/server` `PeerLedger.Wants` now returns `bool` (interface change from `Wants(p peer.ID, e wl.Entry)` to `Wants(p peer.ID, e wl.Entry) bool`) [#629](https://github.com/ipfs/boxo/pull/629) ### Fixed From 88beadf0804607dedc7a4aa12cfef47381a74f89 Mon Sep 17 00:00:00 2001 From: Andrew Gillis <11790789+gammazero@users.noreply.github.com> Date: Thu, 8 Aug 2024 07:02:03 -0700 Subject: [PATCH 03/15] Stop using go-ipfs-blocksutil (#656) - Replace use of go-ipfs-blocksutil with go-test/random - Remove remaining references to go-blockservice --- bitswap/bitswap_test.go | 38 ++++++++----------- bitswap/client/bitswap_with_sessions_test.go | 31 ++++++--------- bitswap/client/client.go | 2 +- .../notifications/notifications_test.go | 7 ++-- .../client/internal/session/session_test.go | 21 ++++------ bitswap/message/message_test.go | 9 ++--- bitswap/network/ipfs_impl_test.go | 12 +++--- blockservice/blockservice_test.go | 33 ++++++++-------- blockservice/internal/tracing.go | 2 +- exchange/offline/offline_test.go | 7 ++-- go.mod | 1 - go.sum | 12 ------ provider/internal/queue/queue_test.go | 10 ++--- 13 files changed, 75 insertions(+), 110 deletions(-) diff --git a/bitswap/bitswap_test.go b/bitswap/bitswap_test.go index 505871d6e..85055879c 100644 --- a/bitswap/bitswap_test.go +++ b/bitswap/bitswap_test.go @@ -19,14 +19,16 @@ import ( blocks "github.com/ipfs/go-block-format" cid "github.com/ipfs/go-cid" detectrace "github.com/ipfs/go-detect-race" - blocksutil "github.com/ipfs/go-ipfs-blocksutil" delay "github.com/ipfs/go-ipfs-delay" ipld "github.com/ipfs/go-ipld-format" + "github.com/ipfs/go-test/random" tu "github.com/libp2p/go-libp2p-testing/etc" p2ptestutil "github.com/libp2p/go-libp2p-testing/netutil" peer "github.com/libp2p/go-libp2p/core/peer" ) +const blockSize = 4 + func isCI() bool { // https://github.blog/changelog/2020-04-15-github-actions-sets-the-ci-environment-variable-to-true/ return os.Getenv("CI") != "" @@ -52,9 +54,7 @@ func TestClose(t *testing.T) { vnet := tn.VirtualNetwork(mockrouting.NewServer(), delay.Fixed(kNetworkDelay)) ig := testinstance.NewTestInstanceGenerator(vnet, nil, nil) defer ig.Close() - bgen := blocksutil.NewBlockGenerator() - - block := bgen.Next() + block := random.BlocksOfSize(1, blockSize)[0] bitswap := ig.Next() bitswap.Exchange.Close() @@ -187,7 +187,6 @@ func TestUnwantedBlockNotAdded(t *testing.T) { func TestPendingBlockAdded(t *testing.T) { ctx := context.Background() net := tn.VirtualNetwork(mockrouting.NewServer(), delay.Fixed(kNetworkDelay)) - bg := blocksutil.NewBlockGenerator() sessionBroadcastWantCapacity := 4 ig := testinstance.NewTestInstanceGenerator(net, nil, nil) @@ -202,7 +201,7 @@ func TestPendingBlockAdded(t *testing.T) { // Request enough blocks to exceed the session's broadcast want list // capacity (by one block). The session will put the remaining block // into the "tofetch" queue - blks := bg.Blocks(sessionBroadcastWantCapacity + 1) + blks := random.BlocksOfSize(sessionBroadcastWantCapacity+1, blockSize) ks := make([]cid.Cid, 0, len(blks)) for _, b := range blks { ks = append(ks, b.Cid()) @@ -285,10 +284,9 @@ func PerformDistributionTest(t *testing.T, numInstances, numBlocks int) { bitswap.MaxOutstandingBytesPerPeer(1 << 20), }) defer ig.Close() - bg := blocksutil.NewBlockGenerator() instances := ig.Instances(numInstances) - blocks := bg.Blocks(numBlocks) + blocks := random.BlocksOfSize(numBlocks, blockSize) t.Log("Give the blocks to the first instance") @@ -338,7 +336,6 @@ func TestSendToWantingPeer(t *testing.T) { net := tn.VirtualNetwork(mockrouting.NewServer(), delay.Fixed(kNetworkDelay)) ig := testinstance.NewTestInstanceGenerator(net, nil, nil) defer ig.Close() - bg := blocksutil.NewBlockGenerator() peers := ig.Instances(2) peerA := peers[0] @@ -349,7 +346,7 @@ func TestSendToWantingPeer(t *testing.T) { waitTime := time.Second * 5 - alpha := bg.Next() + alpha := random.BlocksOfSize(1, blockSize)[0] // peerA requests and waits for block alpha ctx, cancel := context.WithTimeout(context.Background(), waitTime) defer cancel() @@ -409,12 +406,11 @@ func TestBasicBitswap(t *testing.T) { net := tn.VirtualNetwork(mockrouting.NewServer(), delay.Fixed(kNetworkDelay)) ig := testinstance.NewTestInstanceGenerator(net, nil, nil) defer ig.Close() - bg := blocksutil.NewBlockGenerator() t.Log("Test a one node trying to get one block from another") instances := ig.Instances(3) - blocks := bg.Blocks(1) + blocks := random.BlocksOfSize(1, blockSize) // First peer has block addBlock(t, context.Background(), instances[0], blocks[0]) @@ -481,12 +477,11 @@ func TestDoubleGet(t *testing.T) { net := tn.VirtualNetwork(mockrouting.NewServer(), delay.Fixed(kNetworkDelay)) ig := testinstance.NewTestInstanceGenerator(net, nil, nil) defer ig.Close() - bg := blocksutil.NewBlockGenerator() t.Log("Test a one node trying to get one block from another") instances := ig.Instances(2) - blocks := bg.Blocks(1) + blocks := random.BlocksOfSize(1, blockSize) // NOTE: A race condition can happen here where these GetBlocks requests go // through before the peers even get connected. This is okay, bitswap @@ -546,12 +541,11 @@ func TestWantlistCleanup(t *testing.T) { net := tn.VirtualNetwork(mockrouting.NewServer(), delay.Fixed(kNetworkDelay)) ig := testinstance.NewTestInstanceGenerator(net, nil, nil) defer ig.Close() - bg := blocksutil.NewBlockGenerator() instances := ig.Instances(2) instance := instances[0] bswap := instance.Exchange - blocks := bg.Blocks(20) + blocks := random.BlocksOfSize(20, blockSize) var keys []cid.Cid for _, b := range blocks { @@ -668,12 +662,11 @@ func TestBitswapLedgerOneWay(t *testing.T) { net := tn.VirtualNetwork(mockrouting.NewServer(), delay.Fixed(kNetworkDelay)) ig := testinstance.NewTestInstanceGenerator(net, nil, nil) defer ig.Close() - bg := blocksutil.NewBlockGenerator() t.Log("Test ledgers match when one peer sends block to another") instances := ig.Instances(2) - blocks := bg.Blocks(1) + blocks := random.BlocksOfSize(1, blockSize) addBlock(t, context.Background(), instances[0], blocks[0]) ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) @@ -693,12 +686,12 @@ func TestBitswapLedgerOneWay(t *testing.T) { } // check that receipts have intended values - ratest := newReceipt(1, 0, 1) + ratest := newReceipt(blockSize, 0, 1) err = assertLedgerEqual(ratest, ra) if err != nil { t.Fatal(err) } - rbtest := newReceipt(0, 1, 1) + rbtest := newReceipt(0, blockSize, 1) err = assertLedgerEqual(rbtest, rb) if err != nil { t.Fatal(err) @@ -717,12 +710,11 @@ func TestBitswapLedgerTwoWay(t *testing.T) { net := tn.VirtualNetwork(mockrouting.NewServer(), delay.Fixed(kNetworkDelay)) ig := testinstance.NewTestInstanceGenerator(net, nil, nil) defer ig.Close() - bg := blocksutil.NewBlockGenerator() t.Log("Test ledgers match when two peers send one block to each other") instances := ig.Instances(2) - blocks := bg.Blocks(2) + blocks := random.BlocksOfSize(2, blockSize) addBlock(t, context.Background(), instances[0], blocks[0]) addBlock(t, context.Background(), instances[1], blocks[1]) @@ -750,7 +742,7 @@ func TestBitswapLedgerTwoWay(t *testing.T) { } // check that receipts have intended values - rtest := newReceipt(1, 1, 2) + rtest := newReceipt(blockSize, blockSize, 2) err = assertLedgerEqual(rtest, ra) if err != nil { t.Fatal(err) diff --git a/bitswap/client/bitswap_with_sessions_test.go b/bitswap/client/bitswap_with_sessions_test.go index 0baede658..6241865ef 100644 --- a/bitswap/client/bitswap_with_sessions_test.go +++ b/bitswap/client/bitswap_with_sessions_test.go @@ -15,12 +15,14 @@ import ( mockrouting "github.com/ipfs/boxo/routing/mock" blocks "github.com/ipfs/go-block-format" cid "github.com/ipfs/go-cid" - blocksutil "github.com/ipfs/go-ipfs-blocksutil" delay "github.com/ipfs/go-ipfs-delay" + "github.com/ipfs/go-test/random" tu "github.com/libp2p/go-libp2p-testing/etc" "github.com/libp2p/go-libp2p/core/peer" ) +const blockSize = 4 + func getVirtualNetwork() tn.Network { // FIXME: the tests are really sensitive to the network delay. fix them to work // well under varying conditions @@ -46,9 +48,8 @@ func TestBasicSessions(t *testing.T) { vnet := getVirtualNetwork() ig := testinstance.NewTestInstanceGenerator(vnet, nil, nil) defer ig.Close() - bgen := blocksutil.NewBlockGenerator() - block := bgen.Next() + block := random.BlocksOfSize(1, blockSize)[0] inst := ig.Instances(2) a := inst[0] @@ -113,12 +114,11 @@ func TestSessionBetweenPeers(t *testing.T) { vnet := tn.VirtualNetwork(mockrouting.NewServer(), delay.Fixed(time.Millisecond)) ig := testinstance.NewTestInstanceGenerator(vnet, nil, []bitswap.Option{bitswap.SetSimulateDontHavesOnTimeout(false)}) defer ig.Close() - bgen := blocksutil.NewBlockGenerator() inst := ig.Instances(10) // Add 101 blocks to Peer A - blks := bgen.Blocks(101) + blks := random.BlocksOfSize(101, blockSize) if err := inst[0].Blockstore().PutMany(ctx, blks); err != nil { t.Fatal(err) } @@ -173,12 +173,11 @@ func TestSessionSplitFetch(t *testing.T) { vnet := getVirtualNetwork() ig := testinstance.NewTestInstanceGenerator(vnet, nil, nil) defer ig.Close() - bgen := blocksutil.NewBlockGenerator() inst := ig.Instances(11) // Add 10 distinct blocks to each of 10 peers - blks := bgen.Blocks(100) + blks := random.BlocksOfSize(100, blockSize) for i := 0; i < 10; i++ { if err := inst[i].Blockstore().PutMany(ctx, blks[i*10:(i+1)*10]); err != nil { t.Fatal(err) @@ -217,12 +216,11 @@ func TestFetchNotConnected(t *testing.T) { vnet := getVirtualNetwork() ig := testinstance.NewTestInstanceGenerator(vnet, nil, []bitswap.Option{bitswap.ProviderSearchDelay(10 * time.Millisecond)}) defer ig.Close() - bgen := blocksutil.NewBlockGenerator() other := ig.Next() // Provide 10 blocks on Peer A - blks := bgen.Blocks(10) + blks := random.BlocksOfSize(10, blockSize) for _, block := range blks { addBlock(t, ctx, other, block) } @@ -263,14 +261,13 @@ func TestFetchAfterDisconnect(t *testing.T) { bitswap.RebroadcastDelay(delay.Fixed(15 * time.Millisecond)), }) defer ig.Close() - bgen := blocksutil.NewBlockGenerator() inst := ig.Instances(2) peerA := inst[0] peerB := inst[1] // Provide 5 blocks on Peer A - blks := bgen.Blocks(10) + blks := random.BlocksOfSize(10, blockSize) var cids []cid.Cid for _, blk := range blks { cids = append(cids, blk.Cid()) @@ -338,9 +335,8 @@ func TestInterestCacheOverflow(t *testing.T) { vnet := getVirtualNetwork() ig := testinstance.NewTestInstanceGenerator(vnet, nil, nil) defer ig.Close() - bgen := blocksutil.NewBlockGenerator() - blks := bgen.Blocks(2049) + blks := random.BlocksOfSize(2049, blockSize) inst := ig.Instances(2) a := inst[0] @@ -388,9 +384,8 @@ func TestPutAfterSessionCacheEvict(t *testing.T) { vnet := getVirtualNetwork() ig := testinstance.NewTestInstanceGenerator(vnet, nil, nil) defer ig.Close() - bgen := blocksutil.NewBlockGenerator() - blks := bgen.Blocks(2500) + blks := random.BlocksOfSize(2500, blockSize) inst := ig.Instances(1) a := inst[0] @@ -426,9 +421,8 @@ func TestMultipleSessions(t *testing.T) { vnet := getVirtualNetwork() ig := testinstance.NewTestInstanceGenerator(vnet, nil, nil) defer ig.Close() - bgen := blocksutil.NewBlockGenerator() - blk := bgen.Blocks(1)[0] + blk := random.BlocksOfSize(1, blockSize)[0] inst := ig.Instances(2) a := inst[0] @@ -467,9 +461,8 @@ func TestWantlistClearsOnCancel(t *testing.T) { vnet := getVirtualNetwork() ig := testinstance.NewTestInstanceGenerator(vnet, nil, nil) defer ig.Close() - bgen := blocksutil.NewBlockGenerator() - blks := bgen.Blocks(10) + blks := random.BlocksOfSize(10, blockSize) var cids []cid.Cid for _, blk := range blks { cids = append(cids, blk.Cid()) diff --git a/bitswap/client/client.go b/bitswap/client/client.go index 0a5bdeb9e..e0f952d82 100644 --- a/bitswap/client/client.go +++ b/bitswap/client/client.go @@ -499,7 +499,7 @@ func (bs *Client) IsOnline() bool { // block requests in a row. The session returned will have it's own GetBlocks // method, but the session will use the fact that the requests are related to // be more efficient in its requests to peers. If you are using a session -// from go-blockservice, it will create a bitswap session automatically. +// from blockservice, it will create a bitswap session automatically. func (bs *Client) NewSession(ctx context.Context) exchange.Fetcher { ctx, span := internal.StartSpan(ctx, "NewSession") defer span.End() diff --git a/bitswap/client/internal/notifications/notifications_test.go b/bitswap/client/internal/notifications/notifications_test.go index 68b4da1ec..26378320b 100644 --- a/bitswap/client/internal/notifications/notifications_test.go +++ b/bitswap/client/internal/notifications/notifications_test.go @@ -8,10 +8,12 @@ import ( blocks "github.com/ipfs/go-block-format" cid "github.com/ipfs/go-cid" - blocksutil "github.com/ipfs/go-ipfs-blocksutil" + "github.com/ipfs/go-test/random" "github.com/libp2p/go-libp2p/core/peer" ) +const blockSize = 4 + func TestDuplicates(t *testing.T) { var zero peer.ID // this test doesn't check the peer id @@ -152,13 +154,12 @@ func TestCarryOnWhenDeadlineExpires(t *testing.T) { func TestDoesNotDeadLockIfContextCancelledBeforePublish(t *testing.T) { var zero peer.ID // this test doesn't check the peer id - g := blocksutil.NewBlockGenerator() ctx, cancel := context.WithCancel(context.Background()) n := New() defer n.Shutdown() t.Log("generate a large number of blocks. exceed default buffer") - bs := g.Blocks(1000) + bs := random.BlocksOfSize(1000, blockSize) ks := func() []cid.Cid { var keys []cid.Cid for _, b := range bs { diff --git a/bitswap/client/internal/session/session_test.go b/bitswap/client/internal/session/session_test.go index 1c40b64e1..a14fdffd0 100644 --- a/bitswap/client/internal/session/session_test.go +++ b/bitswap/client/internal/session/session_test.go @@ -15,13 +15,14 @@ import ( "github.com/ipfs/boxo/internal/test" blocks "github.com/ipfs/go-block-format" cid "github.com/ipfs/go-cid" - blocksutil "github.com/ipfs/go-ipfs-blocksutil" delay "github.com/ipfs/go-ipfs-delay" "github.com/ipfs/go-test/random" peer "github.com/libp2p/go-libp2p/core/peer" "github.com/stretchr/testify/require" ) +const blockSize = 4 + type mockSessionMgr struct { lk sync.Mutex removeSession bool @@ -165,8 +166,7 @@ func TestSessionGetBlocks(t *testing.T) { id := random.SequenceNext() sm := newMockSessionMgr() session := New(ctx, sm, id, fspm, fpf, sim, fpm, bpm, notif, time.Second, delay.Fixed(time.Minute), "") - blockGenerator := blocksutil.NewBlockGenerator() - blks := blockGenerator.Blocks(broadcastLiveWantsLimit * 2) + blks := random.BlocksOfSize(broadcastLiveWantsLimit*2, blockSize) var cids []cid.Cid for _, block := range blks { cids = append(cids, block.Cid()) @@ -249,8 +249,7 @@ func TestSessionFindMorePeers(t *testing.T) { sm := newMockSessionMgr() session := New(ctx, sm, id, fspm, fpf, sim, fpm, bpm, notif, time.Second, delay.Fixed(time.Minute), "") session.SetBaseTickDelay(200 * time.Microsecond) - blockGenerator := blocksutil.NewBlockGenerator() - blks := blockGenerator.Blocks(broadcastLiveWantsLimit * 2) + blks := random.BlocksOfSize(broadcastLiveWantsLimit*2, blockSize) var cids []cid.Cid for _, block := range blks { cids = append(cids, block.Cid()) @@ -319,8 +318,7 @@ func TestSessionOnPeersExhausted(t *testing.T) { id := random.SequenceNext() sm := newMockSessionMgr() session := New(ctx, sm, id, fspm, fpf, sim, fpm, bpm, notif, time.Second, delay.Fixed(time.Minute), "") - blockGenerator := blocksutil.NewBlockGenerator() - blks := blockGenerator.Blocks(broadcastLiveWantsLimit + 5) + blks := random.BlocksOfSize(broadcastLiveWantsLimit+5, blockSize) var cids []cid.Cid for _, block := range blks { cids = append(cids, block.Cid()) @@ -359,8 +357,7 @@ func TestSessionFailingToGetFirstBlock(t *testing.T) { id := random.SequenceNext() sm := newMockSessionMgr() session := New(ctx, sm, id, fspm, fpf, sim, fpm, bpm, notif, 10*time.Millisecond, delay.Fixed(100*time.Millisecond), "") - blockGenerator := blocksutil.NewBlockGenerator() - blks := blockGenerator.Blocks(4) + blks := random.BlocksOfSize(4, blockSize) var cids []cid.Cid for _, block := range blks { cids = append(cids, block.Cid()) @@ -482,8 +479,7 @@ func TestSessionCtxCancelClosesGetBlocksChannel(t *testing.T) { defer timerCancel() // Request a block with a new context - blockGenerator := blocksutil.NewBlockGenerator() - blks := blockGenerator.Blocks(1) + blks := random.BlocksOfSize(1, blockSize) getctx, getcancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer getcancel() @@ -545,8 +541,7 @@ func TestSessionReceiveMessageAfterCtxCancel(t *testing.T) { id := random.SequenceNext() sm := newMockSessionMgr() session := New(ctx, sm, id, fspm, fpf, sim, fpm, bpm, notif, time.Second, delay.Fixed(time.Minute), "") - blockGenerator := blocksutil.NewBlockGenerator() - blks := blockGenerator.Blocks(2) + blks := random.BlocksOfSize(2, blockSize) cids := []cid.Cid{blks[0].Cid(), blks[1].Cid()} _, err := session.GetBlocks(ctx, cids) diff --git a/bitswap/message/message_test.go b/bitswap/message/message_test.go index ec07cbcff..2b943aeb1 100644 --- a/bitswap/message/message_test.go +++ b/bitswap/message/message_test.go @@ -6,15 +6,13 @@ import ( "github.com/ipfs/boxo/bitswap/client/wantlist" pb "github.com/ipfs/boxo/bitswap/message/pb" - blocksutil "github.com/ipfs/go-ipfs-blocksutil" - - u "github.com/ipfs/boxo/util" blocks "github.com/ipfs/go-block-format" cid "github.com/ipfs/go-cid" + "github.com/ipfs/go-test/random" ) func mkFakeCid(s string) cid.Cid { - return cid.NewCidV0(u.Hash([]byte(s))) + return random.Cids(1)[0] } func TestAppendWanted(t *testing.T) { @@ -290,8 +288,7 @@ func TestAddWantlistEntry(t *testing.T) { } func TestEntrySize(t *testing.T) { - blockGenerator := blocksutil.NewBlockGenerator() - c := blockGenerator.Next().Cid() + c := random.BlocksOfSize(1, 4)[0].Cid() e := Entry{ Entry: wantlist.Entry{ Cid: c, diff --git a/bitswap/network/ipfs_impl_test.go b/bitswap/network/ipfs_impl_test.go index af76e20d6..91e998846 100644 --- a/bitswap/network/ipfs_impl_test.go +++ b/bitswap/network/ipfs_impl_test.go @@ -15,7 +15,7 @@ import ( tn "github.com/ipfs/boxo/bitswap/testnet" mockrouting "github.com/ipfs/boxo/routing/mock" ds "github.com/ipfs/go-datastore" - blocksutil "github.com/ipfs/go-ipfs-blocksutil" + "github.com/ipfs/go-test/random" tnet "github.com/libp2p/go-libp2p-testing/net" "github.com/libp2p/go-libp2p/core/host" "github.com/libp2p/go-libp2p/core/network" @@ -215,9 +215,10 @@ func TestMessageSendAndReceive(t *testing.T) { if _, ok := r2.peers[p1.ID()]; !ok { t.Fatal("did to connect to correct peer") } - blockGenerator := blocksutil.NewBlockGenerator() - block1 := blockGenerator.Next() - block2 := blockGenerator.Next() + randBlocks := random.BlocksOfSize(2, 4) + block1 := randBlocks[0] + block2 := randBlocks[1] + sent := bsmsg.New(false) sent.AddEntry(block1.Cid(), 1, pb.Message_Wantlist_Block, true) sent.AddBlock(block2) @@ -323,8 +324,7 @@ func prepareNetwork(t *testing.T, ctx context.Context, p1 tnet.Identity, r1 *rec t.Fatal(err) } - blockGenerator := blocksutil.NewBlockGenerator() - block1 := blockGenerator.Next() + block1 := random.BlocksOfSize(1, 4)[0] msg := bsmsg.New(false) msg.AddEntry(block1.Cid(), 1, pb.Message_Wantlist_Block, true) diff --git a/blockservice/blockservice_test.go b/blockservice/blockservice_test.go index 53fd725f3..29350ff37 100644 --- a/blockservice/blockservice_test.go +++ b/blockservice/blockservice_test.go @@ -12,12 +12,14 @@ import ( cid "github.com/ipfs/go-cid" ds "github.com/ipfs/go-datastore" dssync "github.com/ipfs/go-datastore/sync" - butil "github.com/ipfs/go-ipfs-blocksutil" ipld "github.com/ipfs/go-ipld-format" + "github.com/ipfs/go-test/random" "github.com/multiformats/go-multihash" "github.com/stretchr/testify/assert" ) +const blockSize = 4 + func TestWriteThroughWorks(t *testing.T) { t.Parallel() @@ -28,9 +30,8 @@ func TestWriteThroughWorks(t *testing.T) { exchbstore := blockstore.NewBlockstore(dssync.MutexWrap(ds.NewMapDatastore())) exch := offline.Exchange(exchbstore) bserv := New(bstore, exch, WriteThrough()) - bgen := butil.NewBlockGenerator() - block := bgen.Next() + block := random.BlocksOfSize(1, blockSize)[0] t.Logf("PutCounter: %d", bstore.PutCounter) err := bserv.AddBlock(context.Background(), block) @@ -63,7 +64,6 @@ func TestExchangeWrite(t *testing.T) { 0, } bserv := New(bstore, exch, WriteThrough()) - bgen := butil.NewBlockGenerator() for name, fetcher := range map[string]BlockGetter{ "blockservice": bserv, @@ -71,7 +71,8 @@ func TestExchangeWrite(t *testing.T) { } { t.Run(name, func(t *testing.T) { // GetBlock - block := bgen.Next() + blks := random.BlocksOfSize(3, blockSize) + block := blks[0] err := exchbstore.Put(context.Background(), block) if err != nil { t.Fatal(err) @@ -91,12 +92,12 @@ func TestExchangeWrite(t *testing.T) { } // GetBlocks - b1 := bgen.Next() + b1 := blks[1] err = exchbstore.Put(context.Background(), b1) if err != nil { t.Fatal(err) } - b2 := bgen.Next() + b2 := blks[2] err = exchbstore.Put(context.Background(), b2) if err != nil { t.Fatal(err) @@ -137,14 +138,14 @@ func TestLazySessionInitialization(t *testing.T) { exch := offline.Exchange(bstore3) sessionExch := &fakeSessionExchange{Interface: exch, session: session} bservSessEx := New(bstore, sessionExch, WriteThrough()) - bgen := butil.NewBlockGenerator() + blks := random.BlocksOfSize(2, blockSize) - block := bgen.Next() + block := blks[0] err := bstore.Put(ctx, block) if err != nil { t.Fatal(err) } - block2 := bgen.Next() + block2 := blks[1] err = bstore2.Put(ctx, block2) if err != nil { t.Fatal(err) @@ -230,8 +231,7 @@ func TestNilExchange(t *testing.T) { ctx, cancel := context.WithCancel(ctx) defer cancel() - bgen := butil.NewBlockGenerator() - block := bgen.Next() + block := random.BlocksOfSize(1, blockSize)[0] bs := blockstore.NewBlockstore(dssync.MutexWrap(ds.NewMapDatastore())) bserv := New(bs, nil, WriteThrough()) @@ -261,8 +261,7 @@ func TestAllowlist(t *testing.T) { ctx, cancel := context.WithCancel(ctx) defer cancel() - bgen := butil.NewBlockGenerator() - block := bgen.Next() + block := random.BlocksOfSize(1, blockSize)[0] data := []byte("this is some blake3 block") mh, err := multihash.Sum(data, multihash.BLAKE3, -1) @@ -324,9 +323,9 @@ func TestContextSession(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - bgen := butil.NewBlockGenerator() - block1 := bgen.Next() - block2 := bgen.Next() + blks := random.BlocksOfSize(2, blockSize) + block1 := blks[0] + block2 := blks[1] bs := blockstore.NewBlockstore(ds.NewMapDatastore()) a.NoError(bs.Put(ctx, block1)) diff --git a/blockservice/internal/tracing.go b/blockservice/internal/tracing.go index ee04673f4..742641484 100644 --- a/blockservice/internal/tracing.go +++ b/blockservice/internal/tracing.go @@ -13,5 +13,5 @@ func StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) // outline logic so the string concatenation can be inlined and executed at compile time func startSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { - return otel.Tracer("go-blockservice").Start(ctx, name, opts...) + return otel.Tracer("blockservice").Start(ctx, name, opts...) } diff --git a/exchange/offline/offline_test.go b/exchange/offline/offline_test.go index 2167f3e2e..cc344a0f0 100644 --- a/exchange/offline/offline_test.go +++ b/exchange/offline/offline_test.go @@ -9,9 +9,11 @@ import ( cid "github.com/ipfs/go-cid" ds "github.com/ipfs/go-datastore" ds_sync "github.com/ipfs/go-datastore/sync" - blocksutil "github.com/ipfs/go-ipfs-blocksutil" + "github.com/ipfs/go-test/random" ) +const blockSize = 4 + func TestBlockReturnsErr(t *testing.T) { off := Exchange(bstore()) c := cid.NewCidV0(u.Hash([]byte("foo"))) @@ -25,9 +27,8 @@ func TestBlockReturnsErr(t *testing.T) { func TestGetBlocks(t *testing.T) { store := bstore() ex := Exchange(store) - g := blocksutil.NewBlockGenerator() - expected := g.Blocks(2) + expected := random.BlocksOfSize(2, blockSize) for _, b := range expected { if err := store.Put(context.Background(), b); err != nil { diff --git a/go.mod b/go.mod index ed882bbee..3ccf62b41 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,6 @@ require ( github.com/ipfs/go-cidutil v0.1.0 github.com/ipfs/go-datastore v0.6.0 github.com/ipfs/go-detect-race v0.0.1 - github.com/ipfs/go-ipfs-blocksutil v0.0.1 github.com/ipfs/go-ipfs-delay v0.0.1 github.com/ipfs/go-ipfs-redirects-file v0.1.1 github.com/ipfs/go-ipld-format v0.6.0 diff --git a/go.sum b/go.sum index 59626e503..354e5378c 100644 --- a/go.sum +++ b/go.sum @@ -149,8 +149,6 @@ github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:Fecb github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= -github.com/gxed/hashland/keccakpg v0.0.1/go.mod h1:kRzw3HkwxFU1mpmPP8v1WyQzwdGfmKFJ6tItnhQ67kU= -github.com/gxed/hashland/murmur3 v0.0.1/go.mod h1:KjXop02n4/ckmZSnY2+HKcLud/tcmvhST0bie/0lS48= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -168,12 +166,10 @@ github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbG github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU= github.com/ipfs/go-bitswap v0.11.0 h1:j1WVvhDX1yhG32NTC9xfxnqycqYIlhzEzLXG/cU1HyQ= github.com/ipfs/go-bitswap v0.11.0/go.mod h1:05aE8H3XOU+LXpTedeAS0OZpcO1WFsj5niYQH9a1Tmk= -github.com/ipfs/go-block-format v0.0.2/go.mod h1:AWR46JfpcObNfg3ok2JHDUfdiHRgWhJgCQF+KIgOPJY= github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= github.com/ipfs/go-blockservice v0.5.2 h1:in9Bc+QcXwd1apOVM7Un9t8tixPKdaHQFdLSUM1Xgk8= github.com/ipfs/go-blockservice v0.5.2/go.mod h1:VpMblFEqG67A/H2sHKAemeH9vlURVavlysbdUI632yk= -github.com/ipfs/go-cid v0.0.1/go.mod h1:GHWU/WuQdMPmIosc4Yn1bcCT7dSeX4lBafM7iqUPQvM= github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= github.com/ipfs/go-cidutil v0.1.0 h1:RW5hO7Vcf16dplUU60Hs0AKDkQAVPVplr7lk97CFL+Q= @@ -202,7 +198,6 @@ github.com/ipfs/go-ipfs-redirects-file v0.1.1 h1:Io++k0Vf/wK+tfnhEh63Yte1oQK5VGT github.com/ipfs/go-ipfs-redirects-file v0.1.1/go.mod h1:tAwRjCV0RjLTjH8DR/AU7VYvfQECg+lpUy2Mdzv7gyk= github.com/ipfs/go-ipfs-routing v0.3.0 h1:9W/W3N+g+y4ZDeffSgqhgo7BsBSJwPMcyssET9OWevc= github.com/ipfs/go-ipfs-routing v0.3.0/go.mod h1:dKqtTFIql7e1zYsEuWLyuOU+E0WJWW8JjbTPLParDWo= -github.com/ipfs/go-ipfs-util v0.0.1/go.mod h1:spsl5z8KUnrve+73pOhSVZND1SIxPW5RyBCNzQxlJBc= github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= @@ -322,17 +317,14 @@ github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b/go.mod h1:lxPUiZwKo github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc h1:PTfri+PuQmWDqERdnNMiD9ZejrlswWrCpBEZgWOiTrc= github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc/go.mod h1:cGKTAVKx4SxOuR/czcZ/E2RSJ3sfHs8FpHhQ5CWMf9s= github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ= -github.com/minio/sha256-simd v0.0.0-20190131020904-2d45a736cd16/go.mod h1:2FMWW+8GMoPweT6+pI63m9YE3Lmw4J71hV56Chs1E/U= github.com/minio/sha256-simd v0.1.1-0.20190913151208-6de447530771/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/mr-tron/base58 v1.1.0/go.mod h1:xcD2VGqlgYjBdcBLw+TuYLr8afG+Hj8g2eTVqeSzSU8= github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= -github.com/multiformats/go-base32 v0.0.3/go.mod h1:pLiuGC8y0QR3Ue4Zug5UzK9LjgbkL8NSQj0zQ5Nz/AA= github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= @@ -346,12 +338,10 @@ github.com/multiformats/go-multiaddr-dns v0.3.1 h1:QgQgR+LQVt3NPTjbrLLpsaT2ufAA2 github.com/multiformats/go-multiaddr-dns v0.3.1/go.mod h1:G/245BRQ6FJGmryJCrOuTdB37AMA5AMOVuO6NY3JwTk= github.com/multiformats/go-multiaddr-fmt v0.1.0 h1:WLEFClPycPkp4fnIzoFoV9FVd49/eQsuaL3/CWe167E= github.com/multiformats/go-multiaddr-fmt v0.1.0/go.mod h1:hGtDIW4PU4BqJ50gW2quDuPVjyWNZxToGUh/HwTZYJo= -github.com/multiformats/go-multibase v0.0.1/go.mod h1:bja2MqRZ3ggyXtZSEDKpl0uO/gviWFaSteVbWT51qgs= github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg= github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k= -github.com/multiformats/go-multihash v0.0.1/go.mod h1:w/5tugSrLEbWqlcgJabL3oHFKTwfvkofsjW2Qa1ct4U= github.com/multiformats/go-multihash v0.0.8/go.mod h1:YSLudS+Pi8NHE7o6tb3D8vrpKa63epEDmG8nTduyAew= github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= @@ -586,7 +576,6 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -668,7 +657,6 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190219092855-153ac476189d/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/provider/internal/queue/queue_test.go b/provider/internal/queue/queue_test.go index a2d9f0be4..a9a49cc66 100644 --- a/provider/internal/queue/queue_test.go +++ b/provider/internal/queue/queue_test.go @@ -9,16 +9,16 @@ import ( "github.com/ipfs/go-cid" "github.com/ipfs/go-datastore" "github.com/ipfs/go-datastore/sync" - blocksutil "github.com/ipfs/go-ipfs-blocksutil" + "github.com/ipfs/go-test/random" ) -var blockGenerator = blocksutil.NewBlockGenerator() +const blockSize = 4 func makeCids(n int) []cid.Cid { - cids := make([]cid.Cid, 0, n) + blks := random.BlocksOfSize(n, blockSize) + cids := make([]cid.Cid, n) for i := 0; i < n; i++ { - c := blockGenerator.Next().Cid() - cids = append(cids, c) + cids[i] = blks[i].Cid() } return cids } From aa27cd2f80536b53fe6c1dd8c94b1a8ceec837d8 Mon Sep 17 00:00:00 2001 From: Andrew Gillis <11790789+gammazero@users.noreply.github.com> Date: Tue, 20 Aug 2024 16:44:46 -0700 Subject: [PATCH 04/15] feat: support UnixFS 1.5 file mode and modification times (#653) --- CHANGELOG.md | 2 + examples/go.mod | 10 +- examples/go.sum | 17 +- files/file.go | 9 + files/file_test.go | 141 ++++++++ files/filter_test.go | 18 +- files/helpers_test.go | 12 + files/linkfile.go | 24 +- files/meta.go | 46 +++ files/meta_other.go | 23 ++ files/meta_posix.go | 41 +++ files/meta_windows.go | 30 ++ files/multifilereader.go | 44 ++- files/multifilereader_test.go | 53 ++- files/multipartfile.go | 85 ++++- files/readerfile.go | 20 +- files/serialfile.go | 9 + files/slicedirectory.go | 48 ++- files/tarwriter.go | 65 ++-- files/tarwriter_test.go | 47 ++- files/util.go | 37 ++ files/util_test.go | 24 ++ files/webfile.go | 34 ++ files/webfile_test.go | 13 + gateway/backend_car_files.go | 18 + go.mod | 10 +- go.sum | 17 +- ipld/unixfs/file/unixfile.go | 34 +- .../unixfs/importer/balanced/balanced_test.go | 48 +++ ipld/unixfs/importer/balanced/builder.go | 29 +- ipld/unixfs/importer/helpers/dagbuilder.go | 97 +++++- ipld/unixfs/importer/trickle/trickle_test.go | 116 ++++++- ipld/unixfs/importer/trickle/trickledag.go | 22 +- ipld/unixfs/io/dagreader.go | 25 +- ipld/unixfs/mod/dagmodifier.go | 17 +- ipld/unixfs/pb/unixfs.pb.go | 112 +++++- ipld/unixfs/pb/unixfs.proto | 17 + ipld/unixfs/unixfs.go | 144 +++++++- ipld/unixfs/unixfs_test.go | 250 +++++++++++++ mfs/dir.go | 77 ++++- mfs/file.go | 100 ++++++ mfs/mfs_test.go | 248 ++++++++++++- mfs/ops.go | 23 +- mfs/root.go | 3 + out | 0 tar/extractor.go | 244 +++++++++---- tar/extractor_test.go | 327 +++++++++++++++--- 47 files changed, 2533 insertions(+), 297 deletions(-) create mode 100644 files/meta.go create mode 100644 files/meta_other.go create mode 100644 files/meta_posix.go create mode 100644 files/meta_windows.go create mode 100644 files/util_test.go delete mode 100644 out diff --git a/CHANGELOG.md b/CHANGELOG.md index 0657c1a21..25f652999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ The following emojis are used to highlight certain changes: ### Added +- `files`, `ipld/unixfs`, `mfs` and `tar` now support optional UnixFS 1.5 mode and modification time metadata + ### Changed ### Removed diff --git a/examples/go.mod b/examples/go.mod index 615b39356..40d3c3696 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -3,13 +3,13 @@ module github.com/ipfs/boxo/examples go 1.21 require ( - github.com/ipfs/boxo v0.19.0 + github.com/ipfs/boxo v0.22.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/ipld/go-car/v2 v2.13.1 github.com/ipld/go-ipld-prime v0.21.0 - github.com/libp2p/go-libp2p v0.36.1 + github.com/libp2p/go-libp2p v0.36.2 github.com/libp2p/go-libp2p-routing-helpers v0.7.3 github.com/multiformats/go-multiaddr v0.13.0 github.com/multiformats/go-multicodec v0.9.0 @@ -122,7 +122,7 @@ require ( github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 // indirect github.com/pion/datachannel v1.5.8 // indirect github.com/pion/dtls/v2 v2.2.12 // indirect - github.com/pion/ice/v2 v2.3.32 // indirect + github.com/pion/ice/v2 v2.3.34 // indirect github.com/pion/interceptor v0.1.29 // indirect github.com/pion/logging v0.2.2 // indirect github.com/pion/mdns v0.0.12 // indirect @@ -133,9 +133,9 @@ require ( github.com/pion/sdp/v3 v3.0.9 // indirect github.com/pion/srtp/v2 v2.0.20 // indirect github.com/pion/stun v0.6.1 // indirect - github.com/pion/transport/v2 v2.2.9 // indirect + github.com/pion/transport/v2 v2.2.10 // indirect github.com/pion/turn/v2 v2.1.6 // indirect - github.com/pion/webrtc/v3 v3.2.50 // indirect + github.com/pion/webrtc/v3 v3.3.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/polydawn/refmt v0.89.0 // indirect diff --git a/examples/go.sum b/examples/go.sum index 67b7b18ea..e247582dd 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -271,8 +271,8 @@ github.com/libp2p/go-doh-resolver v0.4.0 h1:gUBa1f1XsPwtpE1du0O+nnZCUqtG7oYi7Bb+ github.com/libp2p/go-doh-resolver v0.4.0/go.mod h1:v1/jwsFusgsWIGX/c6vCRrnJ60x7bhTiq/fs2qt0cAg= github.com/libp2p/go-flow-metrics v0.1.0 h1:0iPhMI8PskQwzh57jB9WxIuIOQ0r+15PChFGkx3Q3WM= github.com/libp2p/go-flow-metrics v0.1.0/go.mod h1:4Xi8MX8wj5aWNDAZttg6UPmc0ZrnFNsMtpsYUClFtro= -github.com/libp2p/go-libp2p v0.36.1 h1:piAHesy0/8ifBEBUS8HF2m7ywR5vnktUFv00dTsVKcs= -github.com/libp2p/go-libp2p v0.36.1/go.mod h1:vHzel3CpRB+vS11fIjZSJAU4ALvieKV9VZHC9VerHj8= +github.com/libp2p/go-libp2p v0.36.2 h1:BbqRkDaGC3/5xfaJakLV/BrpjlAuYqSB0lRvtzL3B/U= +github.com/libp2p/go-libp2p v0.36.2/go.mod h1:XO3joasRE4Eup8yCTTP/+kX+g92mOgRaadk46LmPhHY= github.com/libp2p/go-libp2p-asn-util v0.4.1 h1:xqL7++IKD9TBFMgnLPZR6/6iYhawHKHl950SO9L6n94= github.com/libp2p/go-libp2p-asn-util v0.4.1/go.mod h1:d/NI6XZ9qxw67b4e+NgpQexCIiFYJjErASrYW4PFDN8= github.com/libp2p/go-libp2p-kad-dht v0.25.2 h1:FOIk9gHoe4YRWXTu8SY9Z1d0RILol0TrtApsMDPjAVQ= @@ -372,8 +372,8 @@ github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= -github.com/pion/ice/v2 v2.3.32 h1:VwE/uEeqiMm0zUWpdt1DJtnqEkj3UjEbhX92/CurtWI= -github.com/pion/ice/v2 v2.3.32/go.mod h1:8fac0+qftclGy1tYd/nfwfHC729BLaxtVqMdMVCAVPU= +github.com/pion/ice/v2 v2.3.34 h1:Ic1ppYCj4tUOcPAp76U6F3fVrlSw8A9JtRXLqw6BbUM= +github.com/pion/ice/v2 v2.3.34/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ= github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M= github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4= github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= @@ -399,17 +399,16 @@ github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/ github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= -github.com/pion/transport/v2 v2.2.8/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= -github.com/pion/transport/v2 v2.2.9 h1:WEDygVovkJlV2CCunM9KS2kds+kcl7zdIefQA5y/nkE= -github.com/pion/transport/v2 v2.2.9/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= +github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= +github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= github.com/pion/transport/v3 v3.0.6 h1:k1mQU06bmmX143qSWgXFqSH1KUJceQvIUuVH/K5ELWw= github.com/pion/transport/v3 v3.0.6/go.mod h1:HvJr2N/JwNJAfipsRleqwFoR3t/pWyHeZUs89v3+t5s= github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc= github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= -github.com/pion/webrtc/v3 v3.2.50 h1:C/rwL2mBfCxHv6tlLzDAO3krJpQXfVx8A8WHnGJ2j34= -github.com/pion/webrtc/v3 v3.2.50/go.mod h1:dytYYoSBy7ZUWhJMbndx9UckgYvzNAfL7xgVnrIKxqo= +github.com/pion/webrtc/v3 v3.3.0 h1:Rf4u6n6U5t5sUxhYPQk/samzU/oDv7jk6BA5hyO2F9I= +github.com/pion/webrtc/v3 v3.3.0/go.mod h1:hVmrDJvwhEertRWObeb1xzulzHGeVUoPlWvxdGzcfU0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/files/file.go b/files/file.go index 7ac1fc98a..e2ece2862 100644 --- a/files/file.go +++ b/files/file.go @@ -4,6 +4,7 @@ import ( "errors" "io" "os" + "time" ) var ( @@ -17,6 +18,14 @@ var ( type Node interface { io.Closer + // Mode returns the mode. + // Optional, if unknown/unspecified returns zero. + Mode() os.FileMode + + // ModTime returns the last modification time. If the last + // modification time is unknown/unspecified ModTime returns zero. + ModTime() (mtime time.Time) + // Size returns size of this file (if this file is a directory, total size of // all files stored in the tree should be returned). Some implementations may // choose not to implement this diff --git a/files/file_test.go b/files/file_test.go index 3edecf107..07d9f04db 100644 --- a/files/file_test.go +++ b/files/file_test.go @@ -3,8 +3,10 @@ package files import ( "io" "mime/multipart" + "os" "strings" "testing" + "time" ) func TestSliceFiles(t *testing.T) { @@ -49,6 +51,21 @@ func TestReaderFiles(t *testing.T) { } } +func TestReaderFileStat(t *testing.T) { + reader := strings.NewReader("beep boop") + mode := os.FileMode(0754) + mtime := time.Date(2020, 11, 2, 12, 27, 35, 55555, time.UTC) + stat := &mockFileInfo{name: "test", mode: mode, mtime: mtime} + + rf := NewReaderStatFile(reader, stat) + if rf.Mode() != mode { + t.Fatalf("Expected file mode to be [%v] but got [%v]", mode, rf.Mode()) + } + if rf.ModTime() != mtime { + t.Fatalf("Expected file modified time to be [%v] but got [%v]", mtime, rf.ModTime()) + } +} + func TestMultipartFiles(t *testing.T) { data := ` --Boundary! @@ -141,3 +158,127 @@ implicit file2 }, }) } + +func TestMultipartFilesWithMode(t *testing.T) { + data := ` +--Boundary! +Content-Type: text/plain +Content-Disposition: form-data; name="file-0?mode=0754&mtime=1604320500&mtime-nsecs=55555"; filename="%C2%A3%E1%BA%9E%C7%91%C7%93%C3%86+%C3%A6+%E2%99%AB%E2%99%AC" +Some-Header: beep + +beep +--Boundary! +Content-Type: application/x-directory +Content-Disposition: form-data; name="dir-0?mode=755&mtime=1604320500"; ans=42; filename="dir1" + +--Boundary! +Content-Type: text/plain +Content-Disposition: form-data; name="file"; filename="dir1/nested" + +some content +--Boundary! +Content-Type: text/plain +Content-Disposition: form-data; name="file?mode=600"; filename="dir1/nested2"; ans=42 + +some content +--Boundary! +Content-Type: application/symlink +Content-Disposition: form-data; name="file-5"; filename="dir1/simlynk" + +anotherfile +--Boundary! +Content-Type: application/symlink +Content-Disposition: form-data; name="file?mtime=1604320500"; filename="dir1/simlynk2" + +anotherfile +--Boundary! +Content-Type: text/plain +Content-Disposition: form-data; name="dir?mode=0644"; filename="implicit1/implicit2/deep_implicit" + +implicit file1 +--Boundary! +Content-Type: text/plain +Content-Disposition: form-data; name="dir?mode=755&mtime=1604320500"; filename="implicit1/shallow_implicit" + +implicit file2 +--Boundary!-- + +` + + reader := strings.NewReader(data) + mpReader := multipart.NewReader(reader, "Boundary!") + dir, err := NewFileFromPartReader(mpReader, multipartFormdataType) + if err != nil { + t.Fatal(err) + } + + CheckDir(t, dir, []Event{ + { + kind: TFile, + name: "£ẞǑǓÆ æ ♫♬", + value: "beep", + mode: 0754, + mtime: time.Unix(1604320500, 55555), + }, + { + kind: TDirStart, + name: "dir1", + mode: 0755, + mtime: time.Unix(1604320500, 0), + }, + { + kind: TFile, + name: "nested", + value: "some content", + }, + { + kind: TFile, + name: "nested2", + value: "some content", + mode: 0600, + }, + { + kind: TSymlink, + name: "simlynk", + value: "anotherfile", + mode: 0777, + }, + { + kind: TSymlink, + name: "simlynk2", + value: "anotherfile", + mode: 0777, + mtime: time.Unix(1604320500, 0), + }, + { + kind: TDirEnd, + }, + { + kind: TDirStart, + name: "implicit1", + }, + { + kind: TDirStart, + name: "implicit2", + }, + { + kind: TFile, + name: "deep_implicit", + value: "implicit file1", + mode: 0644, + }, + { + kind: TDirEnd, + }, + { + kind: TFile, + name: "shallow_implicit", + value: "implicit file2", + mode: 0755, + mtime: time.Unix(1604320500, 0), + }, + { + kind: TDirEnd, + }, + }) +} diff --git a/files/filter_test.go b/files/filter_test.go index 00b2e8baf..f2de61168 100644 --- a/files/filter_test.go +++ b/files/filter_test.go @@ -4,17 +4,33 @@ import ( "os" "path/filepath" "testing" + "time" ) type mockFileInfo struct { os.FileInfo - name string + name string + mode os.FileMode + mtime time.Time + size int64 } func (m *mockFileInfo) Name() string { return m.name } +func (m *mockFileInfo) Mode() os.FileMode { + return m.mode +} + +func (m *mockFileInfo) ModTime() time.Time { + return m.mtime +} + +func (m *mockFileInfo) Size() int64 { + return m.size +} + func (m *mockFileInfo) Sys() interface{} { return nil } diff --git a/files/helpers_test.go b/files/helpers_test.go index 0180b8f27..32e54544e 100644 --- a/files/helpers_test.go +++ b/files/helpers_test.go @@ -2,7 +2,9 @@ package files import ( "io" + "os" "testing" + "time" ) type Kind int @@ -18,6 +20,8 @@ type Event struct { kind Kind name string value string + mode os.FileMode + mtime time.Time } func CheckDir(t *testing.T, dir Directory, expected []Event) { @@ -50,6 +54,14 @@ func CheckDir(t *testing.T, dir Directory, expected []Event) { t.Fatalf("[%d] expected filename to be %q", i, next.name) } + if next.mode != 0 && it.Node().Mode()&os.ModePerm != next.mode { + t.Fatalf("[%d] expected mode for '%s' to be %O, got %O", i, it.Name(), next.mode, it.Node().Mode()) + } + + if !next.mtime.IsZero() && !it.Node().ModTime().Equal(next.mtime) { + t.Fatalf("[%d] expected modification time for '%s' to be %q", i, it.Name(), next.mtime) + } + switch next.kind { case TFile: mf, ok := it.Node().(File) diff --git a/files/linkfile.go b/files/linkfile.go index 526998652..6881068f7 100644 --- a/files/linkfile.go +++ b/files/linkfile.go @@ -3,21 +3,41 @@ package files import ( "os" "strings" + "time" ) type Symlink struct { Target string - stat os.FileInfo + mtime time.Time reader strings.Reader } func NewLinkFile(target string, stat os.FileInfo) File { - lf := &Symlink{Target: target, stat: stat} + mtime := time.Time{} + if stat != nil { + mtime = stat.ModTime() + } + return NewSymlinkFile(target, mtime) +} + +func NewSymlinkFile(target string, mtime time.Time) File { + lf := &Symlink{ + Target: target, + mtime: mtime, + } lf.reader.Reset(lf.Target) return lf } +func (lf *Symlink) Mode() os.FileMode { + return os.ModeSymlink | os.ModePerm +} + +func (lf *Symlink) ModTime() time.Time { + return lf.mtime +} + func (lf *Symlink) Close() error { return nil } diff --git a/files/meta.go b/files/meta.go new file mode 100644 index 000000000..4ae38a991 --- /dev/null +++ b/files/meta.go @@ -0,0 +1,46 @@ +package files + +import ( + "fmt" + "os" + "time" +) + +// UpdateMeta sets the unix mode and modification time of the filesystem object +// referenced by path. +func UpdateMeta(path string, mode os.FileMode, mtime time.Time) error { + if err := UpdateModTime(path, mtime); err != nil { + return err + } + return UpdateFileMode(path, mode) +} + +// UpdateUnix sets the unix mode and modification time of the filesystem object +// referenced by path. The mode is in the form of a unix mode. +func UpdateMetaUnix(path string, mode uint32, mtime time.Time) error { + return UpdateMeta(path, UnixPermsToModePerms(mode), mtime) +} + +// UpdateFileMode sets the unix mode of the filesystem object referenced by path. +func UpdateFileMode(path string, mode os.FileMode) error { + if err := updateMode(path, mode); err != nil { + return fmt.Errorf("[%v] failed to update file mode on '%s'", err, path) + } + return nil +} + +// UpdateFileModeUnix sets the unix mode of the filesystem object referenced by +// path. It takes the mode in the form of a unix mode. +func UpdateFileModeUnix(path string, mode uint32) error { + return UpdateFileMode(path, UnixPermsToModePerms(mode)) +} + +// UpdateModTime sets the last access and modification time of the target +// filesystem object to the given time. When the given path references a +// symlink, if supported, the symlink is updated. +func UpdateModTime(path string, mtime time.Time) error { + if err := updateMtime(path, mtime); err != nil { + return fmt.Errorf("[%v] failed to update last modification time on '%s'", err, path) + } + return nil +} diff --git a/files/meta_other.go b/files/meta_other.go new file mode 100644 index 000000000..2c4645049 --- /dev/null +++ b/files/meta_other.go @@ -0,0 +1,23 @@ +//go:build !linux && !freebsd && !netbsd && !openbsd && !dragonfly && !windows +// +build !linux,!freebsd,!netbsd,!openbsd,!dragonfly,!windows + +package files + +import ( + "os" + "time" +) + +func updateMode(path string, mode os.FileMode) error { + if mode == 0 { + return nil + } + return os.Chmod(path, mode) +} + +func updateMtime(path string, mtime time.Time) error { + if mtime.IsZero() { + return nil + } + return os.Chtimes(path, mtime, mtime) +} diff --git a/files/meta_posix.go b/files/meta_posix.go new file mode 100644 index 000000000..808cbb997 --- /dev/null +++ b/files/meta_posix.go @@ -0,0 +1,41 @@ +//go:build linux || freebsd || netbsd || openbsd || dragonfly +// +build linux freebsd netbsd openbsd dragonfly + +package files + +import ( + "os" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/unix" +) + +func updateMode(path string, mode os.FileMode) error { + if mode == 0 { + return nil + } + return os.Chmod(path, mode) +} + +func updateMtime(path string, mtime time.Time) error { + if mtime.IsZero() { + return nil + } + var AtFdCwd = -100 + pathname, err := syscall.BytePtrFromString(path) + if err != nil { + return err + } + + tm := syscall.NsecToTimespec(mtime.UnixNano()) + ts := [2]syscall.Timespec{tm, tm} + _, _, e := syscall.Syscall6(syscall.SYS_UTIMENSAT, uintptr(AtFdCwd), + uintptr(unsafe.Pointer(pathname)), uintptr(unsafe.Pointer(&ts)), + uintptr(unix.AT_SYMLINK_NOFOLLOW), 0, 0) + if e != 0 { + return error(e) + } + return nil +} diff --git a/files/meta_windows.go b/files/meta_windows.go new file mode 100644 index 000000000..e060ec02f --- /dev/null +++ b/files/meta_windows.go @@ -0,0 +1,30 @@ +package files + +import ( + "os" + "time" +) + +// os.Chmod - On Windows, only the 0200 bit (owner writable) of mode is used; It +// controls whether the file's read-only attribute is set or cleared. The other +// bits are currently unused. +// +// Use mode 0400 for a read-only file and 0600 for a readable+writable file. +func updateMode(path string, mode os.FileMode) error { + if mode == 0 { + return nil + } + // read+write if owner, group or world writeable + if mode&0222 != 0 { + return os.Chmod(path, 0600) + } + // otherwise read-only + return os.Chmod(path, 0400) +} + +func updateMtime(path string, mtime time.Time) error { + if mtime.IsZero() { + return nil + } + return os.Chtimes(path, mtime, mtime) +} diff --git a/files/multifilereader.go b/files/multifilereader.go index 1a5d4ac1a..33fde2889 100644 --- a/files/multifilereader.go +++ b/files/multifilereader.go @@ -8,6 +8,8 @@ import ( "net/textproto" "net/url" "path" + "strconv" + "strings" "sync" ) @@ -89,18 +91,12 @@ func (mfr *MultiFileReader) Read(buf []byte) (written int, err error) { // handle starting a new file part if !mfr.closed { - mfr.currentFile = entry.Node() // write the boundary and headers header := make(textproto.MIMEHeader) - filename := url.QueryEscape(path.Join(path.Join(mfr.path...), entry.Name())) - dispositionPrefix := "attachment" - if mfr.form { - dispositionPrefix = "form-data; name=\"file\"" - } - - header.Set("Content-Disposition", fmt.Sprintf("%s; filename=\"%s\"", dispositionPrefix, filename)) + filename := path.Join(path.Join(mfr.path...), entry.Name()) + mfr.addContentDisposition(header, filename) var contentType string @@ -119,7 +115,7 @@ func (mfr *MultiFileReader) Read(buf []byte) (written int, err error) { return 0, ErrNotSupported } - header.Set("Content-Type", contentType) + header.Set(contentTypeHeader, contentType) if rf, ok := entry.Node().(FileInfo); ok { if mfr.rawAbsPath { // Legacy compatibility with old servers. @@ -157,6 +153,36 @@ func (mfr *MultiFileReader) Read(buf []byte) (written int, err error) { return written, nil } +func (mfr *MultiFileReader) addContentDisposition(header textproto.MIMEHeader, filename string) { + sb := &strings.Builder{} + params := url.Values{} + + if mode := mfr.currentFile.Mode(); mode != 0 { + params.Add("mode", "0"+strconv.FormatUint(uint64(mode), 8)) + } + if mtime := mfr.currentFile.ModTime(); !mtime.IsZero() { + params.Add("mtime", strconv.FormatInt(mtime.Unix(), 10)) + if n := mtime.Nanosecond(); n > 0 { + params.Add("mtime-nsecs", strconv.FormatInt(int64(n), 10)) + } + } + + sb.Grow(120) + if mfr.form { + sb.WriteString("form-data; name=\"file") + if len(params) > 0 { + fmt.Fprintf(sb, "?%s", params.Encode()) + } + sb.WriteString("\"") + } else { + sb.WriteString("attachment") + } + + fmt.Fprintf(sb, "; filename=\"%s\"", url.QueryEscape(filename)) + + header.Set(contentDispositionHeader, sb.String()) +} + // Boundary returns the boundary string to be used to separate files in the multipart data func (mfr *MultiFileReader) Boundary() string { return mfr.mpWriter.Boundary() diff --git a/files/multifilereader_test.go b/files/multifilereader_test.go index b39217037..623c5404a 100644 --- a/files/multifilereader_test.go +++ b/files/multifilereader_test.go @@ -3,8 +3,13 @@ package files import ( "bytes" "io" + "io/fs" "mime/multipart" + "net/textproto" + "path" + "strings" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -12,7 +17,12 @@ import ( var text = "Some text! :)" func newBytesFileWithPath(abspath string, b []byte) File { - return &ReaderFile{abspath, bytesReaderCloser{bytes.NewReader(b)}, nil, int64(len(b))} + return &ReaderFile{ + abspath: abspath, + reader: bytesReaderCloser{bytes.NewReader(b)}, + stat: &mockFileInfo{name: path.Base(abspath), mode: 0754, mtime: time.Unix(1604320500, 55555)}, + fsize: int64(len(b)), + } } func makeMultiFileReader(t *testing.T, binaryFileName, rawAbsPath bool) (string, *MultiFileReader) { @@ -53,6 +63,9 @@ func runMultiFileReaderToMultiFileTest(t *testing.T, binaryFileName, rawAbsPath, require.True(t, it.Next()) require.Equal(t, "beep.txt", it.Name()) + n := it.Node() + require.Equal(t, fs.FileMode(0754), n.Mode(), "unexpected file mode") + require.Equal(t, time.Unix(1604320500, 55555), n.ModTime(), "unexpected last modification time") require.True(t, it.Next()) require.Equal(t, "boop", it.Name()) require.NotNil(t, DirFromEntry(it)) @@ -103,12 +116,20 @@ func TestMultiFileReaderToMultiFile(t *testing.T) { func getTestMultiFileReader(t *testing.T) *MultiFileReader { sf := NewMapDirectory(map[string]Node{ - "file.txt": NewBytesFile([]byte(text)), + "file.txt": NewReaderStatFile( + strings.NewReader(text), + &mockFileInfo{name: "file.txt", mode: 0, mtime: time.Time{}}), "boop": NewMapDirectory(map[string]Node{ - "a.txt": NewBytesFile([]byte("bleep")), - "b.txt": NewBytesFile([]byte("bloop")), + "a.txt": NewReaderStatFile( + strings.NewReader("bleep"), + &mockFileInfo{name: "a.txt", mode: 0744, mtime: time.Time{}}), + "b.txt": NewReaderStatFile( + strings.NewReader("bloop"), + &mockFileInfo{name: "b.txt", mode: 0666, mtime: time.Unix(1604320500, 0)}), }), - "beep.txt": NewBytesFile([]byte("beep")), + "beep.txt": NewReaderStatFile( + strings.NewReader("beep"), + &mockFileInfo{name: "beep.txt", mode: 0754, mtime: time.Unix(1604320500, 55555)}), }) // testing output by reading it with the go stdlib "mime/multipart" Reader @@ -242,3 +263,25 @@ func TestCommonPrefix(t *testing.T) { }, }) } + +func TestContentDispositonEncoding(t *testing.T) { + testContentDispositionEncoding(t, false, "£ẞǑǓÆ æ ♫♬", + "attachment; filename=\"%C2%A3%E1%BA%9E%C7%91%C7%93%C3%86+%C3%A6+%E2%99%AB%E2%99%AC\"") + testContentDispositionEncoding(t, true, "£ẞǑǓÆ æ ♫♬", + "form-data; name=\"file\"; filename=\"%C2%A3%E1%BA%9E%C7%91%C7%93%C3%86+%C3%A6+%E2%99%AB%E2%99%AC\"") +} + +func testContentDispositionEncoding(t *testing.T, form bool, filename string, expected string) { + sf := NewMapDirectory(map[string]Node{"": NewBytesFile([]byte(""))}) + mfr := NewMultiFileReader(sf, form, false) + if _, err := mfr.Read(nil); err != nil { + t.Fatal("MultiFileReader.Read failed") + } + + header := make(textproto.MIMEHeader) + mfr.addContentDisposition(header, filename) + v := header.Get(contentDispositionHeader) + if v != expected { + t.Fatalf("content-disposition did not match:\nExpected: %s\nActual : %s", expected, v) + } +} diff --git a/files/multipartfile.go b/files/multipartfile.go index b5aab9620..3f0a5b3fa 100644 --- a/files/multipartfile.go +++ b/files/multipartfile.go @@ -5,8 +5,12 @@ import ( "mime" "mime/multipart" "net/url" + "os" "path" + "path/filepath" + "strconv" "strings" + "time" ) const ( @@ -17,17 +21,48 @@ const ( applicationSymlink = "application/symlink" applicationFile = "application/octet-stream" - contentTypeHeader = "Content-Type" + contentTypeHeader = "Content-Type" + contentDispositionHeader = "Content-Disposition" ) +// multiPartFileInfo implements the `fs.FileInfo` interface for a file or +// directory received in a `multipart.part`. +type multiPartFileInfo struct { + name string + mode os.FileMode + mtime time.Time +} + +func (fi *multiPartFileInfo) Name() string { return fi.name } +func (fi *multiPartFileInfo) Mode() os.FileMode { return fi.mode } +func (fi *multiPartFileInfo) ModTime() time.Time { return fi.mtime } +func (fi *multiPartFileInfo) IsDir() bool { return fi.mode.IsDir() } +func (fi *multiPartFileInfo) Sys() interface{} { return nil } +func (fi *multiPartFileInfo) Size() int64 { panic("size for multipart file info is not supported") } + type multipartDirectory struct { path string walker *multipartWalker + stat os.FileInfo // part is the part describing the directory. It's nil when implicit. part *multipart.Part } +func (f *multipartDirectory) Mode() os.FileMode { + if f.stat == nil { + return 0 + } + return f.stat.Mode() +} + +func (f *multipartDirectory) ModTime() time.Time { + if f.stat == nil { + return time.Time{} + } + return f.stat.ModTime() +} + type multipartWalker struct { part *multipart.Part reader *multipart.Reader @@ -85,12 +120,15 @@ func (w *multipartWalker) nextFile() (Node, error) { } } + name := fileName(part) + switch contentType { case multipartFormdataType, applicationDirectory: return &multipartDirectory{ part: part, - path: fileName(part), + path: name, walker: w, + stat: fileInfo(name, part), }, nil case applicationSymlink: out, err := io.ReadAll(part) @@ -98,7 +136,7 @@ func (w *multipartWalker) nextFile() (Node, error) { return nil, err } - return NewLinkFile(string(out), nil), nil + return NewLinkFile(string(out), fileInfo(name, part)), nil default: var absPath string if absPathEncoded := part.Header.Get("abspath-encoded"); absPathEncoded != "" { @@ -113,6 +151,7 @@ func (w *multipartWalker) nextFile() (Node, error) { return &ReaderFile{ reader: part, abspath: absPath, + stat: fileInfo(name, part), }, nil } } @@ -169,6 +208,44 @@ func (it *multipartIterator) Node() Node { return it.curFile } +// fileInfo constructs an `os.FileInfo` from a `multipart.part` serving +// a file or directory. +func fileInfo(name string, part *multipart.Part) os.FileInfo { + fi := multiPartFileInfo{name: filepath.Base(name)} + formName := part.FormName() + + i := strings.IndexByte(formName, '?') + if i == -1 { + return &fi + } + + params, err := url.ParseQuery(formName[i+1:]) + if err != nil { + return nil + } + + if v := params["mode"]; v != nil { + mode, err := strconv.ParseUint(v[0], 8, 32) + if err == nil { + fi.mode = os.FileMode(mode) + } + } + + var secs, nsecs int64 + if v := params["mtime"]; v != nil { + secs, err = strconv.ParseInt(v[0], 10, 64) + if err != nil { + return &fi + } + } + if v := params["mtime-nsecs"]; v != nil { + nsecs, _ = strconv.ParseInt(v[0], 10, 64) + } + fi.mtime = time.Unix(secs, nsecs) + + return &fi +} + func (it *multipartIterator) Next() bool { if it.f.walker.reader == nil || it.err != nil { return false @@ -206,9 +283,9 @@ func (it *multipartIterator) Next() bool { } return true } - it.curName = name // Finally, advance to the next file. + it.curName = name it.curFile, it.err = it.f.walker.nextFile() return it.err == nil diff --git a/files/readerfile.go b/files/readerfile.go index bf3fa1c9e..8b9e4069c 100644 --- a/files/readerfile.go +++ b/files/readerfile.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "time" ) // ReaderFile is a implementation of File created from an `io.Reader`. @@ -13,8 +14,21 @@ type ReaderFile struct { abspath string reader io.ReadCloser stat os.FileInfo + fsize int64 +} - fsize int64 +func (f *ReaderFile) Mode() os.FileMode { + if f.stat == nil { + return 0 + } + return f.stat.Mode() +} + +func (f *ReaderFile) ModTime() time.Time { + if f.stat == nil { + return time.Time{} + } + return f.stat.ModTime() } func NewBytesFile(b []byte) File { @@ -32,6 +46,10 @@ func (b bytesReaderCloser) Close() error { return nil } +func NewBytesStatFile(b []byte, stat os.FileInfo) File { + return NewReaderStatFile(bytes.NewReader(b), stat) +} + func NewReaderFile(reader io.Reader) File { return NewReaderStatFile(reader, nil) } diff --git a/files/serialfile.go b/files/serialfile.go index bd25bd1bc..cf4d44be3 100644 --- a/files/serialfile.go +++ b/files/serialfile.go @@ -6,6 +6,7 @@ import ( "io/fs" "os" "path/filepath" + "time" ) // serialFile implements Node, and reads from a path on the OS filesystem. @@ -164,6 +165,14 @@ func (f *serialFile) Size() (int64, error) { return du, err } +func (f *serialFile) Mode() os.FileMode { + return f.stat.Mode() +} + +func (f *serialFile) ModTime() time.Time { + return f.stat.ModTime() +} + var ( _ Directory = &serialFile{} _ DirIterator = &serialIterator{} diff --git a/files/slicedirectory.go b/files/slicedirectory.go index 9cf910c6a..7a444b65a 100644 --- a/files/slicedirectory.go +++ b/files/slicedirectory.go @@ -1,6 +1,11 @@ package files -import "sort" +import ( + "cmp" + "os" + "slices" + "time" +) type fileEntry struct { name string @@ -49,22 +54,51 @@ func (it *sliceIterator) Err() error { // SliceFiles are always directories, and can't be read from or closed. type SliceFile struct { files []DirEntry + stat os.FileInfo +} + +func (f *SliceFile) Mode() os.FileMode { + if f.stat != nil { + return f.stat.Mode() + } + return 0 +} + +func (f *SliceFile) ModTime() time.Time { + if f.stat != nil { + return f.stat.ModTime() + } + return time.Time{} } func NewMapDirectory(f map[string]Node) Directory { - ents := make([]DirEntry, 0, len(f)) + return NewSliceDirectory(sortDirEntries(f)) +} + +func NewMapStatDirectory(f map[string]Node, stat os.FileInfo) Directory { + return NewSliceStatDirectory(sortDirEntries(f), stat) +} + +func sortDirEntries(f map[string]Node) []DirEntry { + ents := make([]DirEntry, len(f)) + var i int for name, nd := range f { - ents = append(ents, FileEntry(name, nd)) + ents[i] = FileEntry(name, nd) + i++ } - sort.Slice(ents, func(i, j int) bool { - return ents[i].Name() < ents[j].Name() + slices.SortFunc(ents, func(a, b DirEntry) int { + return cmp.Compare(a.Name(), b.Name()) }) - return NewSliceDirectory(ents) + return ents } func NewSliceDirectory(files []DirEntry) Directory { - return &SliceFile{files} + return &SliceFile{files: files} +} + +func NewSliceStatDirectory(files []DirEntry, stat os.FileInfo) Directory { + return &SliceFile{files: files, stat: stat} } func (f *SliceFile) Entries() DirIterator { diff --git a/files/tarwriter.go b/files/tarwriter.go index e5d857116..c982abf63 100644 --- a/files/tarwriter.go +++ b/files/tarwriter.go @@ -10,23 +10,25 @@ import ( "time" ) -var ErrUnixFSPathOutsideRoot = errors.New("relative UnixFS paths outside the root are now allowed, use CAR instead") +var ErrUnixFSPathOutsideRoot = errors.New("relative UnixFS paths outside the root are not allowed, use CAR instead") type TarWriter struct { TarW *tar.Writer baseDirSet bool baseDir string + format tar.Format } // NewTarWriter wraps given io.Writer into a new tar writer func NewTarWriter(w io.Writer) (*TarWriter, error) { return &TarWriter{ - TarW: tar.NewWriter(w), + TarW: tar.NewWriter(w), + format: tar.FormatUnknown, }, nil } func (w *TarWriter) writeDir(f Directory, fpath string) error { - if err := writeDirHeader(w.TarW, fpath); err != nil { + if err := w.writeHeader(f, fpath, 0); err != nil { return err } @@ -45,7 +47,7 @@ func (w *TarWriter) writeFile(f File, fpath string) error { return err } - if err := writeFileHeader(w.TarW, fpath, uint64(size)); err != nil { + if err = w.writeHeader(f, fpath, size); err != nil { return err } @@ -76,7 +78,7 @@ func validateTarFilePath(baseDir, fpath string) bool { return true } -// WriteNode adds a node to the archive. +// WriteFile adds a node to the archive. func (w *TarWriter) WriteFile(nd Node, fpath string) error { if !w.baseDirSet { w.baseDirSet = true // Use a variable for this as baseDir may be an empty string. @@ -89,7 +91,7 @@ func (w *TarWriter) WriteFile(nd Node, fpath string) error { switch nd := nd.(type) { case *Symlink: - return writeSymlinkHeader(w.TarW, nd.Target, fpath) + return w.writeHeader(nd, fpath, 0) case File: return w.writeFile(nd, fpath) case Directory: @@ -104,32 +106,33 @@ func (w *TarWriter) Close() error { return w.TarW.Close() } -func writeDirHeader(w *tar.Writer, fpath string) error { - return w.WriteHeader(&tar.Header{ - Name: fpath, - Typeflag: tar.TypeDir, - Mode: 0o777, - ModTime: time.Now().Truncate(time.Second), - // TODO: set mode, dates, etc. when added to unixFS - }) -} +func (w *TarWriter) writeHeader(n Node, fpath string, size int64) error { + hdr := &tar.Header{ + Format: w.format, + Name: fpath, + Size: size, + Mode: int64(UnixPermsOrDefault(n)), + } + + switch nd := n.(type) { + case *Symlink: + hdr.Typeflag = tar.TypeSymlink + hdr.Linkname = nd.Target + case Directory: + hdr.Typeflag = tar.TypeDir + default: + hdr.Typeflag = tar.TypeReg + } + + if m := n.ModTime(); m.IsZero() { + hdr.ModTime = time.Now() + } else { + hdr.ModTime = m + } -func writeFileHeader(w *tar.Writer, fpath string, size uint64) error { - return w.WriteHeader(&tar.Header{ - Name: fpath, - Size: int64(size), - Typeflag: tar.TypeReg, - Mode: 0o644, - ModTime: time.Now().Truncate(time.Second), - // TODO: set mode, dates, etc. when added to unixFS - }) + return w.TarW.WriteHeader(hdr) } -func writeSymlinkHeader(w *tar.Writer, target, fpath string) error { - return w.WriteHeader(&tar.Header{ - Name: fpath, - Linkname: target, - Mode: 0o777, - Typeflag: tar.TypeSymlink, - }) +func (w *TarWriter) SetFormat(format tar.Format) { + w.format = format } diff --git a/files/tarwriter_test.go b/files/tarwriter_test.go index 0e1488e7f..559d77f3d 100644 --- a/files/tarwriter_test.go +++ b/files/tarwriter_test.go @@ -11,11 +11,13 @@ import ( func TestTarWriter(t *testing.T) { tf := NewMapDirectory(map[string]Node{ "file.txt": NewBytesFile([]byte(text)), - "boop": NewMapDirectory(map[string]Node{ + "boop": NewMapStatDirectory(map[string]Node{ "a.txt": NewBytesFile([]byte("bleep")), "b.txt": NewBytesFile([]byte("bloop")), - }), - "beep.txt": NewBytesFile([]byte("beep")), + }, &mockFileInfo{name: "", mode: 0750, mtime: time.Unix(6600000000, 0)}), + "beep.txt": NewBytesStatFile([]byte("beep"), + &mockFileInfo{name: "beep.txt", size: 4, mode: 0766, mtime: time.Unix(1604320500, 54321)}), + "boop-sl": NewSymlinkFile("boop", time.Unix(6600050000, 0)), }) pr, pw := io.Pipe() @@ -23,6 +25,7 @@ func TestTarWriter(t *testing.T) { if err != nil { t.Fatal(err) } + tw.SetFormat(tar.FormatPAX) tr := tar.NewReader(pr) go func() { @@ -33,8 +36,9 @@ func TestTarWriter(t *testing.T) { }() var cur *tar.Header + const delta = 4 * time.Second - checkHeader := func(name string, typ byte, size int64) { + checkHeader := func(name string, typ byte, size int64, mode int64, mtime time.Time) { if cur.Name != name { t.Errorf("got wrong name: %s != %s", cur.Name, name) } @@ -44,41 +48,52 @@ func TestTarWriter(t *testing.T) { if cur.Size != size { t.Errorf("got wrong size: %d != %d", cur.Size, size) } - now := time.Now() - if cur.ModTime.After(now) { - t.Errorf("wrote timestamp in the future: %s (now) < %s", now, cur.ModTime) + if cur.Mode != mode { + t.Errorf("got wrong mode: %d != %d", cur.Mode, mode) + } + if mtime.IsZero() { + interval := time.Since(cur.ModTime) + if interval < -delta || interval > delta { + t.Errorf("expected timestamp to be current: %s", cur.ModTime) + } + } else if cur.ModTime.UnixNano() != mtime.UnixNano() { + t.Errorf("got wrong timestamp: %s != %s", cur.ModTime, mtime) } } if cur, err = tr.Next(); err != nil { t.Fatal(err) } - checkHeader("", tar.TypeDir, 0) + checkHeader("", tar.TypeDir, 0, 0755, time.Time{}) if cur, err = tr.Next(); err != nil { t.Fatal(err) } - checkHeader("beep.txt", tar.TypeReg, 4) + checkHeader("beep.txt", tar.TypeReg, 4, 0766, time.Unix(1604320500, 54321)) if cur, err = tr.Next(); err != nil { t.Fatal(err) } - checkHeader("boop", tar.TypeDir, 0) + checkHeader("boop", tar.TypeDir, 0, 0750, time.Unix(6600000000, 0)) if cur, err = tr.Next(); err != nil { t.Fatal(err) } - checkHeader("boop/a.txt", tar.TypeReg, 5) + checkHeader("boop/a.txt", tar.TypeReg, 5, 0644, time.Time{}) if cur, err = tr.Next(); err != nil { t.Fatal(err) } - checkHeader("boop/b.txt", tar.TypeReg, 5) + checkHeader("boop/b.txt", tar.TypeReg, 5, 0644, time.Time{}) if cur, err = tr.Next(); err != nil { t.Fatal(err) } - checkHeader("file.txt", tar.TypeReg, 13) + checkHeader("boop-sl", tar.TypeSymlink, 0, 0777, time.Unix(6600050000, 0)) + if cur, err = tr.Next(); err != nil { + t.Fatal(err) + } + checkHeader("file.txt", tar.TypeReg, 13, 0644, time.Time{}) if cur, err = tr.Next(); err != io.EOF { t.Fatal(err) @@ -101,7 +116,7 @@ func TestTarWriterRelativePathInsideRoot(t *testing.T) { } defer tw.Close() - if err := tw.WriteFile(tf, ""); err != nil { + if err = tw.WriteFile(tf, ""); err != nil { t.Error(err) } } @@ -122,7 +137,7 @@ func TestTarWriterFailsFileOutsideRoot(t *testing.T) { } defer tw.Close() - if err := tw.WriteFile(tf, ""); !errors.Is(err, ErrUnixFSPathOutsideRoot) { + if err = tw.WriteFile(tf, ""); !errors.Is(err, ErrUnixFSPathOutsideRoot) { t.Errorf("unexpected error, wanted: %v; got: %v", ErrUnixFSPathOutsideRoot, err) } } @@ -143,7 +158,7 @@ func TestTarWriterFailsFileOutsideRootWithBaseDir(t *testing.T) { } defer tw.Close() - if err := tw.WriteFile(tf, "test.tar"); !errors.Is(err, ErrUnixFSPathOutsideRoot) { + if err = tw.WriteFile(tf, "test.tar"); !errors.Is(err, ErrUnixFSPathOutsideRoot) { t.Errorf("unexpected error, wanted: %v; got: %v", ErrUnixFSPathOutsideRoot, err) } } diff --git a/files/util.go b/files/util.go index e727e7ae6..1ac673b0e 100644 --- a/files/util.go +++ b/files/util.go @@ -1,5 +1,7 @@ package files +import "os" + // ToFile is an alias for n.(File). If the file isn't a regular file, nil value // will be returned func ToFile(n Node) File { @@ -23,3 +25,38 @@ func FileFromEntry(e DirEntry) File { func DirFromEntry(e DirEntry) Directory { return ToDir(e.Node()) } + +// UnixPermsOrDefault returns the unix style permissions stored for the given +// Node, or default unix permissions for the Node type. +func UnixPermsOrDefault(n Node) uint32 { + perms := ModePermsToUnixPerms(n.Mode()) + if perms != 0 { + return perms + } + + switch n.(type) { + case *Symlink: + return 0777 + case Directory: + return 0755 + default: + return 0644 + } +} + +// ModePermsToUnixPerms converts the permission bits of an os.FileMode to unix +// style mode permissions. +func ModePermsToUnixPerms(fileMode os.FileMode) uint32 { + return uint32((fileMode & 0xC00000 >> 12) | (fileMode & os.ModeSticky >> 11) | (fileMode & 0x1FF)) +} + +// UnixPermsToModePerms converts unix style mode permissions to os.FileMode +// permissions, as it only operates on permission bits it does not set the +// underlying type (fs.ModeDir, fs.ModeSymlink, etc.) in the returned +// os.FileMode. +func UnixPermsToModePerms(unixPerms uint32) os.FileMode { + if unixPerms == 0 { + return 0 + } + return os.FileMode((unixPerms & 0x1FF) | (unixPerms & 0xC00 << 12) | (unixPerms & 0x200 << 11)) +} diff --git a/files/util_test.go b/files/util_test.go new file mode 100644 index 000000000..396abec99 --- /dev/null +++ b/files/util_test.go @@ -0,0 +1,24 @@ +package files + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestModePermsToUnixPerms(t *testing.T) { + assert.Equal(t, uint32(0777), ModePermsToUnixPerms(os.FileMode(0777))) + assert.Equal(t, uint32(04755), ModePermsToUnixPerms(0755|os.ModeSetuid)) + assert.Equal(t, uint32(02777), ModePermsToUnixPerms(0777|os.ModeSetgid)) + assert.Equal(t, uint32(01377), ModePermsToUnixPerms(0377|os.ModeSticky)) + assert.Equal(t, uint32(05300), ModePermsToUnixPerms(0300|os.ModeSetuid|os.ModeSticky)) +} + +func TestUnixPermsToModePerms(t *testing.T) { + assert.Equal(t, os.FileMode(0777), UnixPermsToModePerms(0777)) + assert.Equal(t, 0755|os.ModeSetuid, UnixPermsToModePerms(04755)) + assert.Equal(t, 0777|os.ModeSetgid, UnixPermsToModePerms(02777)) + assert.Equal(t, 0377|os.ModeSticky, UnixPermsToModePerms(01377)) + assert.Equal(t, 0300|os.ModeSetuid|os.ModeSticky, UnixPermsToModePerms(05300)) +} diff --git a/files/webfile.go b/files/webfile.go index 4586eab63..8791fce8a 100644 --- a/files/webfile.go +++ b/files/webfile.go @@ -7,8 +7,16 @@ import ( "net/http" "net/url" "os" + "strconv" + "time" ) +// the HTTP Response header that provides the last modified timestamp +const LastModifiedHeaderName = "Last-Modified" + +// the HTTP Response header that provides the unix file mode +const FileModeHeaderName = "File-Mode" + // WebFile is an implementation of File which reads it // from a Web URL (http). A GET request will be performed // against the source when calling Read(). @@ -16,6 +24,16 @@ type WebFile struct { body io.ReadCloser url *url.URL contentLength int64 + mode os.FileMode + mtime time.Time +} + +func (wf *WebFile) Mode() os.FileMode { + return wf.mode +} + +func (wf *WebFile) ModTime() time.Time { + return wf.mtime } // NewWebFile creates a WebFile with the given URL, which @@ -38,10 +56,26 @@ func (wf *WebFile) start() error { } wf.body = resp.Body wf.contentLength = resp.ContentLength + wf.getResponseMetaData(resp) } return nil } +func (wf *WebFile) getResponseMetaData(resp *http.Response) { + ts := resp.Header.Get(LastModifiedHeaderName) + if ts != "" { + if mtime, err := time.Parse(time.RFC1123, ts); err == nil { + wf.mtime = mtime + } + } + md := resp.Header.Get(FileModeHeaderName) + if md != "" { + if mode, err := strconv.ParseInt(md, 8, 32); err == nil { + wf.mode = os.FileMode(mode) + } + } +} + // Read reads the File from it's web location. On the first // call to Read, a GET request will be performed against the // WebFile's URL, using Go's default HTTP client. Any further diff --git a/files/webfile_test.go b/files/webfile_test.go index 94cddb5d2..b2a7238ab 100644 --- a/files/webfile_test.go +++ b/files/webfile_test.go @@ -6,12 +6,19 @@ import ( "net/http" "net/http/httptest" "net/url" + "strconv" "testing" + "time" ) func TestWebFile(t *testing.T) { const content = "Hello world!" + const mode = 0644 + mtime := time.Unix(16043205005, 0) + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add(LastModifiedHeaderName, mtime.Format(time.RFC1123)) + w.Header().Add(FileModeHeaderName, strconv.FormatUint(uint64(mode), 8)) fmt.Fprint(w, content) })) defer s.Close() @@ -28,6 +35,12 @@ func TestWebFile(t *testing.T) { if string(body) != content { t.Fatalf("expected %q but got %q", content, string(body)) } + if actual := wf.Mode(); actual != mode { + t.Fatalf("expected file mode %q but got 0%q", mode, strconv.FormatUint(uint64(actual), 8)) + } + if actual := wf.ModTime(); !actual.Equal(mtime) { + t.Fatalf("expected last modified time %q but got %q", mtime, actual) + } } func TestWebFile_notFound(t *testing.T) { diff --git a/gateway/backend_car_files.go b/gateway/backend_car_files.go index c384bbe2c..50c298a38 100644 --- a/gateway/backend_car_files.go +++ b/gateway/backend_car_files.go @@ -5,6 +5,8 @@ import ( "context" "fmt" "io" + "os" + "time" "github.com/ipfs/boxo/files" "github.com/ipfs/boxo/ipld/unixfs" @@ -50,6 +52,14 @@ func (b *backpressuredFile) Close() error { return nil } +func (b *backpressuredFile) Mode() os.FileMode { + panic("not implemented") +} + +func (b *backpressuredFile) ModTime() time.Time { + panic("not implemented") +} + func (b *backpressuredFile) Size() (int64, error) { return b.size, nil } @@ -126,6 +136,14 @@ func (b *singleUseDirectory) Close() error { return nil } +func (b *singleUseDirectory) Mode() os.FileMode { + return 0 +} + +func (b *singleUseDirectory) ModTime() time.Time { + return time.Time{} +} + func (b *singleUseDirectory) Size() (int64, error) { //TODO implement me panic("implement me") diff --git a/go.mod b/go.mod index 3ccf62b41..a625a8b81 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( github.com/jbenet/goprocess v0.1.4 github.com/libp2p/go-buffer-pool v0.1.0 github.com/libp2p/go-doh-resolver v0.4.0 - github.com/libp2p/go-libp2p v0.36.1 + github.com/libp2p/go-libp2p v0.36.2 github.com/libp2p/go-libp2p-kad-dht v0.25.2 github.com/libp2p/go-libp2p-record v0.2.0 github.com/libp2p/go-libp2p-routing-helpers v0.7.3 @@ -147,7 +147,7 @@ require ( github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 // indirect github.com/pion/datachannel v1.5.8 // indirect github.com/pion/dtls/v2 v2.2.12 // indirect - github.com/pion/ice/v2 v2.3.32 // indirect + github.com/pion/ice/v2 v2.3.34 // indirect github.com/pion/interceptor v0.1.29 // indirect github.com/pion/logging v0.2.2 // indirect github.com/pion/mdns v0.0.12 // indirect @@ -158,9 +158,9 @@ require ( github.com/pion/sdp/v3 v3.0.9 // indirect github.com/pion/srtp/v2 v2.0.20 // indirect github.com/pion/stun v0.6.1 // indirect - github.com/pion/transport/v2 v2.2.9 // indirect + github.com/pion/transport/v2 v2.2.10 // indirect github.com/pion/turn/v2 v2.1.6 // indirect - github.com/pion/webrtc/v3 v3.2.50 // indirect + github.com/pion/webrtc/v3 v3.3.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect @@ -195,5 +195,3 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.3.0 // indirect ) - -replace github.com/libp2p/go-libp2p => github.com/libp2p/go-libp2p v0.35.1-0.20240804142423-e2e0d2917f55 diff --git a/go.sum b/go.sum index 354e5378c..e539af643 100644 --- a/go.sum +++ b/go.sum @@ -274,8 +274,8 @@ github.com/libp2p/go-doh-resolver v0.4.0 h1:gUBa1f1XsPwtpE1du0O+nnZCUqtG7oYi7Bb+ github.com/libp2p/go-doh-resolver v0.4.0/go.mod h1:v1/jwsFusgsWIGX/c6vCRrnJ60x7bhTiq/fs2qt0cAg= github.com/libp2p/go-flow-metrics v0.1.0 h1:0iPhMI8PskQwzh57jB9WxIuIOQ0r+15PChFGkx3Q3WM= github.com/libp2p/go-flow-metrics v0.1.0/go.mod h1:4Xi8MX8wj5aWNDAZttg6UPmc0ZrnFNsMtpsYUClFtro= -github.com/libp2p/go-libp2p v0.35.1-0.20240804142423-e2e0d2917f55 h1:/iBsYYCzlVCiMMUfXWiHzgWpTFzZwes3cTlamdzXv6g= -github.com/libp2p/go-libp2p v0.35.1-0.20240804142423-e2e0d2917f55/go.mod h1:mdtNGqy0AQuiYJuO1bXPdFOyFeyMTMSVZ03OBi/XLS4= +github.com/libp2p/go-libp2p v0.36.2 h1:BbqRkDaGC3/5xfaJakLV/BrpjlAuYqSB0lRvtzL3B/U= +github.com/libp2p/go-libp2p v0.36.2/go.mod h1:XO3joasRE4Eup8yCTTP/+kX+g92mOgRaadk46LmPhHY= github.com/libp2p/go-libp2p-asn-util v0.4.1 h1:xqL7++IKD9TBFMgnLPZR6/6iYhawHKHl950SO9L6n94= github.com/libp2p/go-libp2p-asn-util v0.4.1/go.mod h1:d/NI6XZ9qxw67b4e+NgpQexCIiFYJjErASrYW4PFDN8= github.com/libp2p/go-libp2p-kad-dht v0.25.2 h1:FOIk9gHoe4YRWXTu8SY9Z1d0RILol0TrtApsMDPjAVQ= @@ -375,8 +375,8 @@ github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= -github.com/pion/ice/v2 v2.3.32 h1:VwE/uEeqiMm0zUWpdt1DJtnqEkj3UjEbhX92/CurtWI= -github.com/pion/ice/v2 v2.3.32/go.mod h1:8fac0+qftclGy1tYd/nfwfHC729BLaxtVqMdMVCAVPU= +github.com/pion/ice/v2 v2.3.34 h1:Ic1ppYCj4tUOcPAp76U6F3fVrlSw8A9JtRXLqw6BbUM= +github.com/pion/ice/v2 v2.3.34/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ= github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M= github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4= github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= @@ -402,17 +402,16 @@ github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/ github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= -github.com/pion/transport/v2 v2.2.8/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= -github.com/pion/transport/v2 v2.2.9 h1:WEDygVovkJlV2CCunM9KS2kds+kcl7zdIefQA5y/nkE= -github.com/pion/transport/v2 v2.2.9/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= +github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= +github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= github.com/pion/transport/v3 v3.0.6 h1:k1mQU06bmmX143qSWgXFqSH1KUJceQvIUuVH/K5ELWw= github.com/pion/transport/v3 v3.0.6/go.mod h1:HvJr2N/JwNJAfipsRleqwFoR3t/pWyHeZUs89v3+t5s= github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc= github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= -github.com/pion/webrtc/v3 v3.2.50 h1:C/rwL2mBfCxHv6tlLzDAO3krJpQXfVx8A8WHnGJ2j34= -github.com/pion/webrtc/v3 v3.2.50/go.mod h1:dytYYoSBy7ZUWhJMbndx9UckgYvzNAfL7xgVnrIKxqo= +github.com/pion/webrtc/v3 v3.3.0 h1:Rf4u6n6U5t5sUxhYPQk/samzU/oDv7jk6BA5hyO2F9I= +github.com/pion/webrtc/v3 v3.3.0/go.mod h1:hVmrDJvwhEertRWObeb1xzulzHGeVUoPlWvxdGzcfU0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/ipld/unixfs/file/unixfile.go b/ipld/unixfs/file/unixfile.go index 5ef968d1b..0cf7616c1 100644 --- a/ipld/unixfs/file/unixfile.go +++ b/ipld/unixfs/file/unixfile.go @@ -3,6 +3,8 @@ package unixfile import ( "context" "errors" + "os" + "time" ft "github.com/ipfs/boxo/ipld/unixfs" uio "github.com/ipfs/boxo/ipld/unixfs/io" @@ -21,6 +23,8 @@ type ufsDirectory struct { dserv ipld.DAGService dir uio.Directory size int64 + mode os.FileMode + mtime time.Time } type ufsIterator struct { @@ -118,6 +122,14 @@ func (d *ufsDirectory) Entries() files.DirIterator { } } +func (d *ufsDirectory) Mode() os.FileMode { + return d.mode +} + +func (d *ufsDirectory) ModTime() time.Time { + return d.mtime +} + func (d *ufsDirectory) Size() (int64, error) { return d.size, nil } @@ -126,6 +138,14 @@ type ufsFile struct { uio.DagReader } +func (f *ufsFile) Mode() os.FileMode { + return f.DagReader.Mode() +} + +func (f *ufsFile) ModTime() time.Time { + return f.DagReader.ModTime() +} + func (f *ufsFile) Size() (int64, error) { return int64(f.DagReader.Size()), nil } @@ -141,12 +161,19 @@ func newUnixfsDir(ctx context.Context, dserv ipld.DAGService, nd *dag.ProtoNode) return nil, err } + fsn, err := ft.FSNodeFromBytes(nd.Data()) + if err != nil { + return nil, err + } + return &ufsDirectory{ ctx: ctx, dserv: dserv, - dir: dir, - size: int64(size), + dir: dir, + size: int64(size), + mode: fsn.Mode(), + mtime: fsn.ModTime(), }, nil } @@ -157,11 +184,12 @@ func NewUnixfsFile(ctx context.Context, dserv ipld.DAGService, nd ipld.Node) (fi if err != nil { return nil, err } + if fsn.IsDir() { return newUnixfsDir(ctx, dserv, dn) } if fsn.Type() == ft.TSymlink { - return files.NewLinkFile(string(fsn.Data()), nil), nil + return files.NewSymlinkFile(string(fsn.Data()), fsn.ModTime()), nil } case *dag.RawNode: diff --git a/ipld/unixfs/importer/balanced/balanced_test.go b/ipld/unixfs/importer/balanced/balanced_test.go index 5a5dcf9ad..4ea4cb8a9 100644 --- a/ipld/unixfs/importer/balanced/balanced_test.go +++ b/ipld/unixfs/importer/balanced/balanced_test.go @@ -7,6 +7,7 @@ import ( "io" mrand "math/rand" "testing" + "time" h "github.com/ipfs/boxo/ipld/unixfs/importer/helpers" uio "github.com/ipfs/boxo/ipld/unixfs/io" @@ -26,6 +27,10 @@ func buildTestDag(ds ipld.DAGService, spl chunker.Splitter) (*dag.ProtoNode, err Maxlinks: h.DefaultLinksPerBlock, } + return buildTestDagWithParams(spl, dbp) +} + +func buildTestDagWithParams(spl chunker.Splitter, dbp h.DagBuilderParams) (*dag.ProtoNode, error) { db, err := dbp.New(spl) if err != nil { return nil, err @@ -335,3 +340,46 @@ func TestSeekingConsistency(t *testing.T) { t.Fatal(err) } } + +func TestMetadataNoData(t *testing.T) { + testMetadata(t, new(bytes.Buffer)) +} + +func TestMetadata(t *testing.T) { + nbytes := 3 * chunker.DefaultBlockSize + buf := new(bytes.Buffer) + _, err := io.CopyN(buf, random.NewRand(), nbytes) + if err != nil { + t.Fatal(err) + } + + testMetadata(t, buf) +} + +func testMetadata(t *testing.T, buf *bytes.Buffer) { + dagserv := mdtest.Mock() + dbp := h.DagBuilderParams{ + Dagserv: dagserv, + Maxlinks: h.DefaultLinksPerBlock, + FileMode: 0522, + FileModTime: time.Unix(1638111600, 76552), + } + + nd, err := buildTestDagWithParams(chunker.DefaultSplitter(buf), dbp) + if err != nil { + t.Fatal(err) + } + + dr, err := uio.NewDagReader(context.Background(), nd, dagserv) + if err != nil { + t.Fatal(err) + } + + if !dr.ModTime().Equal(dbp.FileModTime) { + t.Errorf("got modtime %v, wanted %v", dr.ModTime(), dbp.FileModTime) + } + + if dr.Mode() != dbp.FileMode { + t.Errorf("got filemode %o, wanted %o", dr.Mode(), dbp.FileMode) + } +} diff --git a/ipld/unixfs/importer/balanced/builder.go b/ipld/unixfs/importer/balanced/builder.go index 0fdb0fd28..915d0a439 100644 --- a/ipld/unixfs/importer/balanced/builder.go +++ b/ipld/unixfs/importer/balanced/builder.go @@ -130,18 +130,33 @@ import ( // | Chunk 1 | | Chunk 2 | | Chunk 3 | // +=========+ +=========+ + - - - - + func Layout(db *h.DagBuilderHelper) (ipld.Node, error) { + var root ipld.Node + var err error + if db.Done() { - // No data, return just an empty node. - root, err := db.NewLeafNode(nil, ft.TFile) - if err != nil { - return nil, err - } + // No data, just create an empty node. + root, err = db.NewLeafNode(nil, ft.TFile) // This works without Filestore support (`ProcessFileStore`). // TODO: Why? Is there a test case missing? + } else { + root, err = layoutData(db) + } + + if err != nil { + return nil, err + } - return root, db.Add(root) + if db.HasFileAttributes() { + err = db.SetFileAttributes(root) + if err != nil { + return nil, err + } } + return root, db.Add(root) +} + +func layoutData(db *h.DagBuilderHelper) (ipld.Node, error) { // The first `root` will be a single leaf node with data // (corner case), after that subsequent `root` nodes will // always be internal nodes (with a depth > 0) that can @@ -172,7 +187,7 @@ func Layout(db *h.DagBuilderHelper) (ipld.Node, error) { } } - return root, db.Add(root) + return root, nil } // fillNodeRec will "fill" the given internal (non-leaf) `node` with data by diff --git a/ipld/unixfs/importer/helpers/dagbuilder.go b/ipld/unixfs/importer/helpers/dagbuilder.go index 25514d795..aefffad15 100644 --- a/ipld/unixfs/importer/helpers/dagbuilder.go +++ b/ipld/unixfs/importer/helpers/dagbuilder.go @@ -5,6 +5,7 @@ import ( "errors" "io" "os" + "time" dag "github.com/ipfs/boxo/ipld/merkledag" @@ -23,13 +24,15 @@ var ErrMissingFsRef = errors.New("missing file path or URL, can't create filesto // DagBuilderHelper wraps together a bunch of objects needed to // efficiently create unixfs dag trees type DagBuilderHelper struct { - dserv ipld.DAGService - spl chunker.Splitter - recvdErr error - rawLeaves bool - nextData []byte // the next item to return. - maxlinks int - cidBuilder cid.Builder + dserv ipld.DAGService + spl chunker.Splitter + recvdErr error + rawLeaves bool + nextData []byte // the next item to return. + maxlinks int + cidBuilder cid.Builder + fileMode os.FileMode + fileModTime time.Time // Filestore support variables. // ---------------------------- @@ -62,6 +65,12 @@ type DagBuilderParams struct { // DAGService to write blocks to (required) Dagserv ipld.DAGService + // The unixfs file mode + FileMode os.FileMode + + // The unixfs last modified time + FileModTime time.Time + // NoCopy signals to the chunker that it should track fileinfo for // filestore adds NoCopy bool @@ -71,11 +80,13 @@ type DagBuilderParams struct { // chunker.Splitter as data source. func (dbp *DagBuilderParams) New(spl chunker.Splitter) (*DagBuilderHelper, error) { db := &DagBuilderHelper{ - dserv: dbp.Dagserv, - spl: spl, - rawLeaves: dbp.RawLeaves, - cidBuilder: dbp.CidBuilder, - maxlinks: dbp.Maxlinks, + dserv: dbp.Dagserv, + spl: spl, + rawLeaves: dbp.RawLeaves, + cidBuilder: dbp.CidBuilder, + maxlinks: dbp.Maxlinks, + fileMode: dbp.FileMode, + fileModTime: dbp.FileModTime, } if fi, ok := spl.Reader().(files.FileInfo); dbp.NoCopy && ok { db.fullPath = fi.AbsPath() @@ -138,9 +149,9 @@ func (db *DagBuilderHelper) GetCidBuilder() cid.Builder { return db.cidBuilder } -// NewLeafNode creates a leaf node filled with data. If rawLeaves is -// defined then a raw leaf will be returned. Otherwise, it will create -// and return `FSNodeOverDag` with `fsNodeType`. +// NewLeafNode creates a leaf node filled with data. If rawLeaves is defined +// then a raw leaf will be returned. Otherwise, it will create and return +// `FSNodeOverDag` with `fsNodeType`. func (db *DagBuilderHelper) NewLeafNode(data []byte, fsNodeType pb.Data_DataType) (ipld.Node, error) { if len(data) > BlockSizeLimit { return nil, ErrSizeLimitExceeded @@ -161,6 +172,7 @@ func (db *DagBuilderHelper) NewLeafNode(data []byte, fsNodeType pb.Data_DataType // Encapsulate the data in UnixFS node (instead of a raw node). fsNodeOverDag := db.NewFSNodeOverDag(fsNodeType) fsNodeOverDag.SetFileData(data) + node, err := fsNodeOverDag.Commit() if err != nil { return nil, err @@ -172,9 +184,10 @@ func (db *DagBuilderHelper) NewLeafNode(data []byte, fsNodeType pb.Data_DataType return node, nil } -// FillNodeLayer will add datanodes as children to the give node until +// FillNodeLayer will add data-nodes as children to the given node until // it is full in this layer or no more data. -// NOTE: This function creates raw data nodes so it only works +// +// NOTE: This function creates raw data nodes, so it only works // for the `trickle.Layout`. func (db *DagBuilderHelper) FillNodeLayer(node *FSNodeOverDag) error { // while we have room AND we're not done @@ -265,6 +278,34 @@ func (db *DagBuilderHelper) Maxlinks() int { return db.maxlinks } +// HasFileAttributes will return false if Filestore is being used, +// otherwise returns true if a file mode or last modification time is set. +func (db *DagBuilderHelper) HasFileAttributes() bool { + return db.fullPath == "" && (db.fileMode != 0 || !db.fileModTime.IsZero()) +} + +// SetFileAttributes stores file attributes present in the `DagBuilderHelper` +// into the associated `ft.FSNode`. +func (db *DagBuilderHelper) SetFileAttributes(n ipld.Node) error { + if pn, ok := n.(*dag.ProtoNode); ok { + fsn, err := ft.FSNodeFromBytes(pn.Data()) + if err != nil { + return err + } + fsn.SetModTime(db.fileModTime) + fsn.SetMode(db.fileMode) + + d, err := fsn.GetBytes() + if err != nil { + return err + } + + pn.SetData(d) + } + + return nil +} + // FSNodeOverDag encapsulates an `unixfs.FSNode` that will be stored in a // `dag.ProtoNode`. Instead of just having a single `ipld.Node` that // would need to be constantly (un)packed to access and modify its @@ -288,7 +329,7 @@ type FSNodeOverDag struct { } // NewFSNodeOverDag creates a new `dag.ProtoNode` and `ft.FSNode` -// decoupled from one onther (and will continue in that way until +// decoupled from one anonther (and will continue in that way until // `Commit` is called), with `fsNodeType` specifying the type of // the UnixFS layer node (either `File` or `Raw`). func (db *DagBuilderHelper) NewFSNodeOverDag(fsNodeType pb.Data_DataType) *FSNodeOverDag { @@ -374,6 +415,26 @@ func (n *FSNodeOverDag) SetFileData(fileData []byte) { n.file.SetData(fileData) } +// SetMode sets the file mode of the associated `ft.FSNode`. +func (n *FSNodeOverDag) SetMode(mode os.FileMode) { + n.file.SetMode(mode) +} + +// SetModTime sets the file modification time of the associated `ft.FSNode`. +func (n *FSNodeOverDag) SetModTime(ts time.Time) { + n.file.SetModTime(ts) +} + +// Mode returns the file mode of the associated `ft.FSNode` +func (n *FSNodeOverDag) Mode() os.FileMode { + return n.file.Mode() +} + +// ModTime returns the last modification time of the associated `ft.FSNode` +func (n *FSNodeOverDag) ModTime() time.Time { + return n.file.ModTime() +} + // GetDagNode fills out the proper formatting for the FSNodeOverDag node // inside of a DAG node and returns the dag node. // TODO: Check if we have committed (passed the UnixFS information diff --git a/ipld/unixfs/importer/trickle/trickle_test.go b/ipld/unixfs/importer/trickle/trickle_test.go index 9078fdc02..d495fd208 100644 --- a/ipld/unixfs/importer/trickle/trickle_test.go +++ b/ipld/unixfs/importer/trickle/trickle_test.go @@ -6,7 +6,9 @@ import ( "fmt" "io" mrand "math/rand" + "runtime" "testing" + "time" ft "github.com/ipfs/boxo/ipld/unixfs" h "github.com/ipfs/boxo/ipld/unixfs/importer/helpers" @@ -40,6 +42,10 @@ func buildTestDag(ds ipld.DAGService, spl chunker.Splitter, rawLeaves UseRawLeav RawLeaves: bool(rawLeaves), } + return buildTestDagWithParams(ds, spl, dbp) +} + +func buildTestDagWithParams(ds ipld.DAGService, spl chunker.Splitter, dbp h.DagBuilderParams) (*merkledag.ProtoNode, error) { db, err := dbp.New(spl) if err != nil { return nil, err @@ -59,7 +65,7 @@ func buildTestDag(ds ipld.DAGService, spl chunker.Splitter, rawLeaves UseRawLeav Getter: ds, Direct: dbp.Maxlinks, LayerRepeat: depthRepeat, - RawLeaves: bool(rawLeaves), + RawLeaves: dbp.RawLeaves, }) } @@ -668,3 +674,111 @@ func TestAppendSingleBytesToEmpty(t *testing.T) { t.Fatal(err) } } + +func TestAppendWithModTime(t *testing.T) { + const nbytes = 128 * 1024 + + timestamp := time.Now() + buf := random.Bytes(nbytes) + + nd := new(merkledag.ProtoNode) + nd.SetData(ft.FilePBDataWithStat(buf[:nbytes/2], nbytes/2, 0, timestamp)) + + if runtime.GOOS == "windows" { + time.Sleep(3 * time.Second) // for os with low-res mod time. + } + + dbp := &h.DagBuilderParams{ + Dagserv: mdtest.Mock(), + Maxlinks: h.DefaultLinksPerBlock, + } + + r := bytes.NewReader(buf[nbytes/2:]) + db, err := dbp.New(chunker.NewSizeSplitter(r, 500)) + if err != nil { + t.Fatal(err) + } + + nd2, err := Append(context.Background(), nd, db) + if err != nil { + t.Fatal(err) + } + + fsn, _ := ft.ExtractFSNode(nd2) + + if !fsn.ModTime().After(timestamp) { + t.Errorf("expected modification time to be updated") + } + +} + +func TestAppendToEmptyWithModTime(t *testing.T) { + timestamp := time.Now() + nd := new(merkledag.ProtoNode) + nd.SetData(ft.FilePBDataWithStat(nil, 0, 0, timestamp)) + + if runtime.GOOS == "windows" { + time.Sleep(3 * time.Second) // for os with low-res mod time. + } + + dbp := &h.DagBuilderParams{ + Dagserv: mdtest.Mock(), + Maxlinks: h.DefaultLinksPerBlock, + } + + db, err := dbp.New(chunker.DefaultSplitter(bytes.NewReader([]byte("test")))) + if err != nil { + t.Fatal(err) + } + + nd2, err := Append(context.Background(), nd, db) + if err != nil { + t.Fatal(err) + } + + fsn, _ := ft.ExtractFSNode(nd2) + + if !fsn.ModTime().After(timestamp) { + t.Errorf("expected modification time to be updated") + } +} + +func TestMetadata(t *testing.T) { + runBothSubtests(t, testMetadata) +} + +func testMetadata(t *testing.T, rawLeaves UseRawLeaves) { + const nbytes = 3 * chunker.DefaultBlockSize + buf := new(bytes.Buffer) + _, err := io.CopyN(buf, random.NewRand(), nbytes) + if err != nil { + t.Fatal(err) + } + + dagserv := mdtest.Mock() + dbp := h.DagBuilderParams{ + Dagserv: dagserv, + Maxlinks: h.DefaultLinksPerBlock, + RawLeaves: bool(rawLeaves), + FileMode: 0522, + FileModTime: time.Unix(1638111600, 76552), + } + + nd, err := buildTestDagWithParams(dagserv, chunker.DefaultSplitter(buf), dbp) + if err != nil { + t.Fatal(err) + } + + dr, err := uio.NewDagReader(context.Background(), nd, dagserv) + if err != nil { + t.Fatal(err) + } + + if !dr.ModTime().Equal(dbp.FileModTime) { + t.Errorf("got modtime %v, wanted %v", dr.ModTime(), dbp.FileModTime) + } + + if dr.Mode() != dbp.FileMode { + t.Errorf("got filemode %o, wanted %o", dr.Mode(), dbp.FileMode) + } +} diff --git a/ipld/unixfs/importer/trickle/trickledag.go b/ipld/unixfs/importer/trickle/trickledag.go index 09a8b8672..2b9d31dfa 100644 --- a/ipld/unixfs/importer/trickle/trickledag.go +++ b/ipld/unixfs/importer/trickle/trickledag.go @@ -19,6 +19,7 @@ import ( "context" "errors" "fmt" + "time" ft "github.com/ipfs/boxo/ipld/unixfs" h "github.com/ipfs/boxo/ipld/unixfs/importer/helpers" @@ -43,6 +44,13 @@ func Layout(db *h.DagBuilderHelper) (ipld.Node, error) { return nil, err } + if db.HasFileAttributes() { + err = db.SetFileAttributes(root) + } + + if err != nil { + return nil, err + } return root, db.Add(root) } @@ -94,7 +102,6 @@ func Append(ctx context.Context, basen ipld.Node, db *h.DagBuilderHelper) (out i } // Convert to unixfs node for working with easily - fsn, err := h.NewFSNFromDag(base) if err != nil { return nil, err @@ -109,9 +116,10 @@ func Append(ctx context.Context, basen ipld.Node, db *h.DagBuilderHelper) (out i } if db.Done() { - // TODO: If `FillNodeLayer` stop `Commit`ing this should be - // the place (besides the function end) to call it. - return fsn.GetDagNode() + if !fsn.ModTime().IsZero() { + fsn.SetModTime(time.Now()) + } + return fsn.Commit() } // If continuing, our depth has increased by one @@ -142,11 +150,7 @@ func Append(ctx context.Context, basen ipld.Node, db *h.DagBuilderHelper) (out i } } } - _, err = fsn.Commit() - if err != nil { - return nil, err - } - return fsn.GetDagNode() + return fsn.Commit() } func appendFillLastChild(ctx context.Context, fsn *h.FSNodeOverDag, depth int, repeatNumber int, db *h.DagBuilderHelper) error { diff --git a/ipld/unixfs/io/dagreader.go b/ipld/unixfs/io/dagreader.go index 77dc8d921..bb1c83800 100644 --- a/ipld/unixfs/io/dagreader.go +++ b/ipld/unixfs/io/dagreader.go @@ -5,6 +5,8 @@ import ( "context" "errors" "io" + "os" + "time" mdag "github.com/ipfs/boxo/ipld/merkledag" unixfs "github.com/ipfs/boxo/ipld/unixfs" @@ -29,6 +31,8 @@ var ( type DagReader interface { ReadSeekCloser Size() uint64 + Mode() os.FileMode + ModTime() time.Time CtxReadFull(context.Context, []byte) (int, error) } @@ -44,6 +48,8 @@ type ReadSeekCloser interface { // the given node, using the passed in DAGService for data retrieval. func NewDagReader(ctx context.Context, n ipld.Node, serv ipld.NodeGetter) (DagReader, error) { var size uint64 + var mode os.FileMode + var modTime time.Time switch n := n.(type) { case *mdag.RawNode: @@ -55,6 +61,9 @@ func NewDagReader(ctx context.Context, n ipld.Node, serv ipld.NodeGetter) (DagRe return nil, err } + mode = fsNode.Mode() + modTime = fsNode.ModTime() + switch fsNode.Type() { case unixfs.TFile, unixfs.TRaw: size = fsNode.FileSize() @@ -93,6 +102,8 @@ func NewDagReader(ctx context.Context, n ipld.Node, serv ipld.NodeGetter) (DagRe cancel: cancel, serv: serv, size: size, + mode: mode, + modTime: modTime, rootNode: n, dagWalker: ipld.NewWalker(ctxWithCancel, ipld.NewNavigableIPLDNode(n, serv)), }, nil @@ -129,7 +140,19 @@ type dagReader struct { // Passed to the `dagWalker` that will use it to request nodes. // TODO: Revisit name. - serv ipld.NodeGetter + serv ipld.NodeGetter + mode os.FileMode + modTime time.Time +} + +// Mode returns the UnixFS file mode or 0 if not set. +func (dr *dagReader) Mode() os.FileMode { + return dr.mode +} + +// ModTime returns the UnixFS file last modification time if set. +func (dr *dagReader) ModTime() time.Time { + return dr.modTime } // Size returns the total size of the data from the DAG structured file. diff --git a/ipld/unixfs/mod/dagmodifier.go b/ipld/unixfs/mod/dagmodifier.go index f662a0a71..c075523f8 100644 --- a/ipld/unixfs/mod/dagmodifier.go +++ b/ipld/unixfs/mod/dagmodifier.go @@ -7,6 +7,7 @@ import ( "context" "errors" "io" + "time" ft "github.com/ipfs/boxo/ipld/unixfs" help "github.com/ipfs/boxo/ipld/unixfs/importer/helpers" @@ -258,6 +259,9 @@ func (dm *DagModifier) modifyDag(n ipld.Node, offset uint64) (cid.Cid, error) { } // Update newly written node.. + if !fsn.ModTime().IsZero() { + fsn.SetModTime(time.Now()) + } b, err := fsn.GetBytes() if err != nil { return cid.Cid{}, err @@ -527,8 +531,17 @@ func (dm *DagModifier) dagTruncate(ctx context.Context, n ipld.Node, size uint64 if err != nil { return nil, err } - nd.SetData(ft.WrapData(fsn.Data()[:size])) - return nd, nil + + fsn.SetData(fsn.Data()[:size]) + if !fsn.ModTime().IsZero() { + fsn.SetModTime(time.Now()) + } + data, err := fsn.GetBytes() + if err != nil { + return nil, err + } + + return mdag.NodeWithData(data), nil case *mdag.RawNode: return mdag.NewRawNodeWPrefix(nd.RawData()[:size], nd.Cid().Prefix()) } diff --git a/ipld/unixfs/pb/unixfs.pb.go b/ipld/unixfs/pb/unixfs.pb.go index 805c11289..d02e110f2 100644 --- a/ipld/unixfs/pb/unixfs.pb.go +++ b/ipld/unixfs/pb/unixfs.pb.go @@ -82,6 +82,8 @@ type Data struct { Blocksizes []uint64 `protobuf:"varint,4,rep,name=blocksizes" json:"blocksizes,omitempty"` HashType *uint64 `protobuf:"varint,5,opt,name=hashType" json:"hashType,omitempty"` Fanout *uint64 `protobuf:"varint,6,opt,name=fanout" json:"fanout,omitempty"` + Mode *uint32 `protobuf:"varint,7,opt,name=mode" json:"mode,omitempty"` + Mtime *IPFSTimestamp `protobuf:"bytes,8,opt,name=mtime" json:"mtime,omitempty"` XXX_NoUnkeyedLiteral struct{} `json:"-"` XXX_unrecognized []byte `json:"-"` XXX_sizecache int32 `json:"-"` @@ -158,6 +160,20 @@ func (m *Data) GetFanout() uint64 { return 0 } +func (m *Data) GetMode() uint32 { + if m != nil && m.Mode != nil { + return *m.Mode + } + return 0 +} + +func (m *Data) GetMtime() *IPFSTimestamp { + if m != nil { + return m.Mtime + } + return nil +} + type Metadata struct { MimeType *string `protobuf:"bytes,1,opt,name=MimeType" json:"MimeType,omitempty"` XXX_NoUnkeyedLiteral struct{} `json:"-"` @@ -201,31 +217,91 @@ func (m *Metadata) GetMimeType() string { return "" } +// mostly copied from proto 3 - with int32 nanos changed to fixed32 for js-ipfs compatibility +// https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/timestamp.proto +type IPFSTimestamp struct { + // Represents seconds of UTC time since Unix epoch + // 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to + // 9999-12-31T23:59:59Z inclusive. + Seconds *int64 `protobuf:"varint,1,req,name=seconds" json:"seconds,omitempty"` + // Non-negative fractions of a second at nanosecond resolution. Negative + // second values with fractions must still have non-negative nanos values + // that count forward in time. Must be from 0 to 999,999,999 + // inclusive. + Nanos *uint32 `protobuf:"fixed32,2,opt,name=nanos" json:"nanos,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *IPFSTimestamp) Reset() { *m = IPFSTimestamp{} } +func (m *IPFSTimestamp) String() string { return proto.CompactTextString(m) } +func (*IPFSTimestamp) ProtoMessage() {} +func (*IPFSTimestamp) Descriptor() ([]byte, []int) { + return fileDescriptor_e2fd76cc44dfc7c3, []int{2} +} +func (m *IPFSTimestamp) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_IPFSTimestamp.Unmarshal(m, b) +} +func (m *IPFSTimestamp) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_IPFSTimestamp.Marshal(b, m, deterministic) +} +func (m *IPFSTimestamp) XXX_Merge(src proto.Message) { + xxx_messageInfo_IPFSTimestamp.Merge(m, src) +} +func (m *IPFSTimestamp) XXX_Size() int { + return xxx_messageInfo_IPFSTimestamp.Size(m) +} +func (m *IPFSTimestamp) XXX_DiscardUnknown() { + xxx_messageInfo_IPFSTimestamp.DiscardUnknown(m) +} + +var xxx_messageInfo_IPFSTimestamp proto.InternalMessageInfo + +func (m *IPFSTimestamp) GetSeconds() int64 { + if m != nil && m.Seconds != nil { + return *m.Seconds + } + return 0 +} + +func (m *IPFSTimestamp) GetNanos() uint32 { + if m != nil && m.Nanos != nil { + return *m.Nanos + } + return 0 +} + func init() { proto.RegisterEnum("unixfs.v1.pb.Data_DataType", Data_DataType_name, Data_DataType_value) proto.RegisterType((*Data)(nil), "unixfs.v1.pb.Data") proto.RegisterType((*Metadata)(nil), "unixfs.v1.pb.Metadata") + proto.RegisterType((*IPFSTimestamp)(nil), "unixfs.pb.IPFSTimestamp") } func init() { proto.RegisterFile("unixfs.proto", fileDescriptor_e2fd76cc44dfc7c3) } var fileDescriptor_e2fd76cc44dfc7c3 = []byte{ - // 267 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x4c, 0x90, 0x41, 0x4f, 0x83, 0x30, - 0x18, 0x86, 0x05, 0xba, 0x0d, 0xbe, 0xa1, 0x69, 0xbe, 0x83, 0x21, 0x9a, 0x18, 0xc2, 0xc1, 0x70, - 0xc2, 0xe8, 0x3f, 0xd0, 0x2c, 0xc6, 0x0b, 0x97, 0x6e, 0xf1, 0xe0, 0xc5, 0x94, 0xad, 0x84, 0x66, - 0x8c, 0x12, 0xe8, 0x54, 0xfc, 0x1b, 0xfe, 0x61, 0x53, 0x18, 0xdb, 0x2e, 0x4d, 0x9e, 0xf6, 0x79, - 0x9b, 0x37, 0x2f, 0xf8, 0xfb, 0x4a, 0xfe, 0xe4, 0x6d, 0x52, 0x37, 0x4a, 0x2b, 0x1c, 0xe9, 0xeb, - 0x31, 0xa9, 0xb3, 0xe8, 0xcf, 0x06, 0xb2, 0xe0, 0x9a, 0xe3, 0x03, 0x90, 0x55, 0x57, 0x8b, 0xc0, - 0x0a, 0xed, 0xf8, 0xea, 0xe9, 0x36, 0x39, 0xb7, 0x12, 0x63, 0xf4, 0x87, 0x51, 0x58, 0x2f, 0x22, - 0x0e, 0xc1, 0xc0, 0x0e, 0xad, 0xd8, 0x67, 0xc3, 0x27, 0x37, 0xe0, 0xe6, 0xb2, 0x14, 0xad, 0xfc, - 0x15, 0x81, 0x13, 0x5a, 0x31, 0x61, 0x47, 0xc6, 0x3b, 0x80, 0xac, 0x54, 0xeb, 0xad, 0x81, 0x36, - 0x20, 0xa1, 0x13, 0x13, 0x76, 0x76, 0x63, 0xb2, 0x05, 0x6f, 0x8b, 0xbe, 0xc4, 0x64, 0xc8, 0x8e, - 0x8c, 0xd7, 0x30, 0xcd, 0x79, 0xa5, 0xf6, 0x3a, 0x98, 0xf6, 0x2f, 0x07, 0x8a, 0xde, 0xc1, 0x1d, - 0x5b, 0xe1, 0x0c, 0x1c, 0xc6, 0xbf, 0xe9, 0x05, 0x5e, 0x82, 0xb7, 0x90, 0x8d, 0x58, 0x6b, 0xd5, - 0x74, 0xd4, 0x42, 0x17, 0xc8, 0xab, 0x2c, 0x05, 0xb5, 0xd1, 0x07, 0x37, 0x15, 0x9a, 0x6f, 0xb8, - 0xe6, 0xd4, 0xc1, 0x39, 0xcc, 0x96, 0xdd, 0xae, 0x94, 0xd5, 0x96, 0x12, 0x93, 0x79, 0x7b, 0x4e, - 0x57, 0xcb, 0x82, 0x37, 0x1b, 0x3a, 0x89, 0xee, 0x4f, 0xa6, 0xe9, 0x95, 0xca, 0x9d, 0x38, 0x8c, - 0x63, 0xc5, 0x1e, 0x3b, 0xf2, 0xcb, 0xfc, 0xc3, 0x1b, 0x76, 0xfa, 0xac, 0xb3, 0xff, 0x00, 0x00, - 0x00, 0xff, 0xff, 0xbd, 0x16, 0xf8, 0x45, 0x67, 0x01, 0x00, 0x00, + // 322 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x54, 0x91, 0x5f, 0x4b, 0xc3, 0x30, + 0x14, 0xc5, 0xed, 0xbf, 0xb5, 0xbb, 0xdb, 0xa4, 0x5c, 0x44, 0x82, 0x0f, 0x52, 0xfa, 0x20, 0x7d, + 0x90, 0x3e, 0xf8, 0x05, 0x44, 0x18, 0x43, 0x1f, 0x06, 0x92, 0x0d, 0xdf, 0xb3, 0x35, 0x63, 0x61, + 0x4d, 0x33, 0x9a, 0x0c, 0x9d, 0x9f, 0xd3, 0x0f, 0x24, 0x49, 0xd7, 0xe9, 0x5e, 0x4a, 0x7f, 0xb9, + 0xe7, 0x84, 0x73, 0x6e, 0x60, 0x7c, 0x68, 0xc4, 0xd7, 0x46, 0x97, 0xfb, 0x56, 0x19, 0x85, 0xc3, + 0x9e, 0x56, 0xf9, 0x8f, 0x0f, 0xe1, 0x94, 0x19, 0x86, 0x8f, 0x10, 0x2e, 0x8f, 0x7b, 0x4e, 0xbc, + 0xcc, 0x2f, 0xae, 0x9f, 0x48, 0x79, 0x96, 0x94, 0x76, 0xec, 0x3e, 0x76, 0x4e, 0x9d, 0x0a, 0xb1, + 0x73, 0x11, 0x3f, 0xf3, 0x8a, 0x31, 0xed, 0x6e, 0xb8, 0x83, 0x64, 0x23, 0x6a, 0xae, 0xc5, 0x37, + 0x27, 0x41, 0xe6, 0x15, 0x21, 0x3d, 0x33, 0xde, 0x03, 0xac, 0x6a, 0xb5, 0xde, 0x59, 0xd0, 0x24, + 0xcc, 0x82, 0x22, 0xa4, 0xff, 0x4e, 0xac, 0x77, 0xcb, 0xf4, 0xd6, 0x25, 0x88, 0x3a, 0x6f, 0xcf, + 0x78, 0x0b, 0x83, 0x0d, 0x6b, 0xd4, 0xc1, 0x90, 0x81, 0x9b, 0x9c, 0xc8, 0x66, 0x90, 0xaa, 0xe2, + 0x24, 0xce, 0xbc, 0x62, 0x42, 0xdd, 0x3f, 0x96, 0x10, 0x49, 0x23, 0x24, 0x27, 0x49, 0xe6, 0x15, + 0xa3, 0x8b, 0x1a, 0x6f, 0xef, 0xb3, 0xc5, 0x52, 0x48, 0xae, 0x0d, 0x93, 0x7b, 0xda, 0xc9, 0xf2, + 0x0f, 0x48, 0xfa, 0x66, 0x18, 0x43, 0x40, 0xd9, 0x67, 0x7a, 0x85, 0x13, 0x18, 0x4e, 0x45, 0xcb, + 0xd7, 0x46, 0xb5, 0xc7, 0xd4, 0xc3, 0x04, 0xc2, 0x99, 0xa8, 0x79, 0xea, 0xe3, 0x18, 0x92, 0x39, + 0x37, 0xac, 0x62, 0x86, 0xa5, 0x01, 0x8e, 0x20, 0x5e, 0x1c, 0x65, 0x2d, 0x9a, 0x5d, 0x1a, 0x5a, + 0xcf, 0xeb, 0xcb, 0x7c, 0xb9, 0xd8, 0xb2, 0xb6, 0x4a, 0xa3, 0xfc, 0xe1, 0x4f, 0x69, 0xbb, 0xcd, + 0x85, 0xe4, 0xa7, 0xed, 0x7a, 0xc5, 0x90, 0x9e, 0x39, 0x7f, 0x86, 0xc9, 0x45, 0x2e, 0x24, 0x10, + 0x6b, 0xbe, 0x56, 0x4d, 0xa5, 0xdd, 0x4b, 0x04, 0xb4, 0x47, 0xbc, 0x81, 0xa8, 0x61, 0x8d, 0xd2, + 0x6e, 0xe7, 0x31, 0xed, 0xe0, 0x37, 0x00, 0x00, 0xff, 0xff, 0x36, 0xaf, 0xfa, 0x7c, 0xd9, 0x01, + 0x00, 0x00, } diff --git a/ipld/unixfs/pb/unixfs.proto b/ipld/unixfs/pb/unixfs.proto index f65673f54..bd02aa410 100644 --- a/ipld/unixfs/pb/unixfs.proto +++ b/ipld/unixfs/pb/unixfs.proto @@ -21,8 +21,25 @@ message Data { optional uint64 hashType = 5; optional uint64 fanout = 6; + optional uint32 mode = 7; + optional IPFSTimestamp mtime = 8; } message Metadata { optional string MimeType = 1; } + +// mostly copied from proto 3 - with int32 nanos changed to fixed32 for js-ipfs compatibility +// https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/timestamp.proto +message IPFSTimestamp { + // Represents seconds of UTC time since Unix epoch + // 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to + // 9999-12-31T23:59:59Z inclusive. + required int64 seconds = 1; + + // Non-negative fractions of a second at nanosecond resolution. Negative + // second values with fractions must still have non-negative nanos values + // that count forward in time. Must be from 0 to 999,999,999 + // inclusive. + optional fixed32 nanos = 2; +} diff --git a/ipld/unixfs/unixfs.go b/ipld/unixfs/unixfs.go index 4131df837..fb2c9bbf2 100644 --- a/ipld/unixfs/unixfs.go +++ b/ipld/unixfs/unixfs.go @@ -6,10 +6,12 @@ package unixfs import ( "errors" "fmt" + "os" + "time" proto "github.com/gogo/protobuf/proto" + files "github.com/ipfs/boxo/files" dag "github.com/ipfs/boxo/ipld/merkledag" - pb "github.com/ipfs/boxo/ipld/unixfs/pb" ipld "github.com/ipfs/go-ipld-format" ) @@ -34,6 +36,7 @@ const ( // Common errors var ( ErrMalformedFileFormat = errors.New("malformed data in file format") + ErrNotProtoNode = errors.New("expected a ProtoNode as internal node") ErrUnrecognizedType = errors.New("unrecognized node type") ) @@ -69,6 +72,22 @@ func FilePBData(data []byte, totalsize uint64) []byte { return data } +func FilePBDataWithStat(data []byte, totalsize uint64, mode os.FileMode, mtime time.Time) []byte { + pbfile := new(pb.Data) + typ := pb.Data_File + pbfile.Type = &typ + pbfile.Data = data + pbfile.Filesize = proto.Uint64(totalsize) + + pbDataAddStat(pbfile, mode, mtime) + + data, err := proto.Marshal(pbfile) + if err != nil { + panic(err) + } + return data +} + // FolderPBData returns Bytes that represent a Directory. func FolderPBData() []byte { pbfile := new(pb.Data) @@ -83,6 +102,36 @@ func FolderPBData() []byte { return data } +func FolderPBDataWithStat(mode os.FileMode, mtime time.Time) []byte { + pbfile := new(pb.Data) + typ := pb.Data_Directory + pbfile.Type = &typ + + pbDataAddStat(pbfile, mode, mtime) + + data, err := proto.Marshal(pbfile) + if err != nil { + //this really shouldnt happen, i promise + panic(err) + } + return data +} + +func pbDataAddStat(data *pb.Data, mode os.FileMode, mtime time.Time) { + if mode != 0 { + data.Mode = proto.Uint32(files.ModePermsToUnixPerms(mode)) + } + if !mtime.IsZero() { + data.Mtime = &pb.IPFSTimestamp{ + Seconds: proto.Int64(mtime.Unix()), + } + + if nanos := uint32(mtime.Nanosecond()); nanos > 0 { + data.Mtime.Nanos = &nanos + } + } +} + // WrapData marshals raw bytes into a `Data_Raw` type protobuf message. func WrapData(b []byte) []byte { pbdata := new(pb.Data) @@ -303,6 +352,93 @@ func (n *FSNode) IsDir() bool { } } +// Mode returns the optionally stored file permissions +func (n *FSNode) Mode() (m os.FileMode) { + perms := n.format.GetMode() & 0xFFF + if perms != 0 { + m = files.UnixPermsToModePerms(perms) + switch n.Type() { + case pb.Data_Directory, pb.Data_HAMTShard: + m |= os.ModeDir + case pb.Data_Symlink: + m |= os.ModeSymlink + } + } + return m +} + +// SetMode stores the given mode permissions, or nullifies stored permissions +// if none were provided and there are no extended bits set. +func (n *FSNode) SetMode(m os.FileMode) { + n.SetModeFromUnixPermissions(files.ModePermsToUnixPerms(m)) +} + +// SetModeFromUnixPermissions stores the given unix permissions, or nullifies stored permissions +// if none were provided and there are no extended bits set. +func (n *FSNode) SetModeFromUnixPermissions(unixPerms uint32) { + // preserve existing most significant 20 bits + newMode := (n.format.GetMode() & 0xFFFFF000) | (unixPerms & 0xFFF) + + if unixPerms == 0 { + if newMode&0xFFFFF000 == 0 { + n.format.Mode = nil + return + } + } + n.format.Mode = &newMode +} + +// ExtendedMode returns the 20 bits of extended file mode +func (n *FSNode) ExtendedMode() uint32 { + return (n.format.GetMode() & 0xFFFFF000) >> 12 +} + +// SetExtendedMode stores the 20 bits of extended file mode, only the first +// 20 bits of the `mode` argument are used, the remaining 12 bits are ignored. +func (n *FSNode) SetExtendedMode(mode uint32) { + newMode := (mode << 12) | (0xFFF & n.format.GetMode()) + if newMode == 0 { + n.format.Mode = nil + } else { + n.format.Mode = &newMode + } +} + +// ModTime returns the stored last modified timestamp if available. +func (n *FSNode) ModTime() time.Time { + ts := n.format.GetMtime() + if ts == nil || ts.Seconds == nil { + return time.Time{} + } + if ts.Nanos == nil { + return time.Unix(*ts.Seconds, 0) + } + if *ts.Nanos < 1 || *ts.Nanos > 999999999 { + return time.Time{} + } + + return time.Unix(*ts.Seconds, int64(*ts.Nanos)) +} + +// SetModTime stores the given last modified timestamp, otherwise nullifies stored timestamp. +func (n *FSNode) SetModTime(ts time.Time) { + if ts.IsZero() { + n.format.Mtime = nil + return + } + + if n.format.Mtime == nil { + n.format.Mtime = &pb.IPFSTimestamp{} + } + + n.format.Mtime.Seconds = proto.Int64(ts.Unix()) + if ts.Nanosecond() > 0 { + n.format.Mtime.Nanos = proto.Uint32(uint32(ts.Nanosecond())) + } else { + n.format.Mtime.Nanos = nil + } +} + // Metadata is used to store additional FSNode information. type Metadata struct { MimeType string @@ -360,6 +496,10 @@ func EmptyDirNode() *dag.ProtoNode { return dag.NodeWithData(FolderPBData()) } +func EmptyDirNodeWithStat(mode os.FileMode, mtime time.Time) *dag.ProtoNode { + return dag.NodeWithData(FolderPBDataWithStat(mode, mtime)) +} + // EmptyFileNode creates an empty file Protonode. func EmptyFileNode() *dag.ProtoNode { return dag.NodeWithData(FilePBData(nil, 0)) @@ -405,7 +545,7 @@ func ReadUnixFSNodeData(node ipld.Node) (data []byte, err error) { func ExtractFSNode(node ipld.Node) (*FSNode, error) { protoNode, ok := node.(*dag.ProtoNode) if !ok { - return nil, errors.New("expected a ProtoNode as internal node") + return nil, ErrNotProtoNode } fsNode, err := FSNodeFromBytes(protoNode.Data()) diff --git a/ipld/unixfs/unixfs_test.go b/ipld/unixfs/unixfs_test.go index b785be8ad..4cbc22ca8 100644 --- a/ipld/unixfs/unixfs_test.go +++ b/ipld/unixfs/unixfs_test.go @@ -2,7 +2,9 @@ package unixfs import ( "bytes" + "os" "testing" + "time" proto "github.com/gogo/protobuf/proto" @@ -183,3 +185,251 @@ func TestIsDir(t *testing.T) { } } } + +func (n *FSNode) getPbData(t *testing.T) *pb.Data { + b, err := n.GetBytes() + if err != nil { + t.Fatal(err) + } + + pbn := new(pb.Data) + err = proto.Unmarshal(b, pbn) + if err != nil { + t.Fatal(err) + } + return pbn +} + +func TestMode(t *testing.T) { + fsn := NewFSNode(TDirectory) + fsn.SetMode(1) + if !fsn.Mode().IsDir() { + t.Fatal("expected mode for directory") + } + + fsn = NewFSNode(TSymlink) + fsn.SetMode(1) + if fsn.Mode()&os.ModeSymlink != os.ModeSymlink { + t.Fatal("expected mode for symlink") + } + + fsn = NewFSNode(TFile) + + // not stored + if fsn.Mode() != 0 { + t.Fatal("expected mode not to be set") + } + + fileMode := os.FileMode(0640) + fsn.SetMode(fileMode) + if !fsn.Mode().IsRegular() { + t.Fatal("expected a regular file mode") + } + mode := fsn.Mode() + + if mode&os.ModePerm != fileMode { + t.Fatalf("expected permissions to be %O but got %O", fileMode, mode&os.ModePerm) + } + if mode&0xFFFFF000 != 0 { + t.Fatalf("expected high-order 20 bits of mode to be clear but got %b", (mode&0xFFFFF000)>>12) + } + + fsn.SetMode(fileMode | os.ModeSticky) + mode = fsn.Mode() + if mode&os.ModePerm != fileMode&os.ModePerm { + t.Fatalf("expected permissions to be %O but got %O", fileMode, mode&os.ModePerm) + } + if mode&os.ModeSticky == 0 { + t.Fatal("expected permissions to have sticky bit set") + } + if mode&os.ModeSetuid != 0 { + t.Fatal("expected permissions to have setuid bit unset") + } + if mode&os.ModeSetgid != 0 { + t.Fatal("expected permissions to have setgid bit unset") + } + + fsn.SetMode(fileMode | os.ModeSticky | os.ModeSetuid) + mode = fsn.Mode() + if mode&os.ModePerm != fileMode&os.ModePerm { + t.Fatalf("expected permissions to be %O but got %O", fileMode, mode&os.ModePerm) + } + if mode&os.ModeSticky == 0 { + t.Fatal("expected permissions to have sticky bit set") + } + if mode&os.ModeSetuid == 0 { + t.Fatal("expected permissions to have setuid bit set") + } + if mode&os.ModeSetgid != 0 { + t.Fatal("expected permissions to have setgid bit unset") + } + + fsn.SetMode(fileMode | os.ModeSetuid | os.ModeSetgid) + mode = fsn.Mode() + if mode&os.ModePerm != fileMode&os.ModePerm { + t.Fatalf("expected permissions to be %O but got %O", fileMode, mode&os.ModePerm) + } + if mode&os.ModeSticky != 0 { + t.Fatal("expected permissions to have sticky bit unset") + } + if mode&os.ModeSetuid == 0 { + t.Fatal("expected permissions to have setuid bit set") + } + if mode&os.ModeSetgid == 0 { + t.Fatal("expected permissions to have setgid bit set") + } + + // check the internal format (unix permissions) + fsn.SetMode(fileMode | os.ModeSetuid | os.ModeSticky) + pbn := fsn.getPbData(t) + // unix perms setuid and sticky bits should also be set + expected := uint32(05000 | (fileMode & os.ModePerm)) + if *pbn.Mode != expected { + t.Fatalf("expected stored permissions to be %O but got %O", expected, *pbn.Mode) + } + + fsn.SetMode(0) + pbn = fsn.getPbData(t) + if pbn.Mode != nil { + t.Fatal("expected file mode to be unset") + } + + fsn.SetExtendedMode(1) + fsn.SetMode(0) + pbn = fsn.getPbData(t) + if pbn.Mode == nil { + t.Fatal("expected extended mode to be preserved") + } +} + +func TestExtendedMode(t *testing.T) { + fsn := NewFSNode(TFile) + fsn.SetMode(os.ModePerm | os.ModeSetuid | os.ModeSticky) + const expectedUnixMode = uint32(05777) + + expectedExtMode := uint32(0xAAAAA) + fsn.SetExtendedMode(expectedExtMode) + extMode := fsn.ExtendedMode() + if extMode != expectedExtMode { + t.Fatalf("expected extended mode to be %X but got %X", expectedExtMode, extMode) + } + pbn := fsn.getPbData(t) + expectedPbMode := (expectedExtMode << 12) | (expectedUnixMode & 0xFFF) + if *pbn.Mode != expectedPbMode { + t.Fatalf("expected stored mode to be %b but got %b", expectedPbMode, *pbn.Mode) + } + + expectedExtMode = uint32(0x55555) + fsn.SetExtendedMode(expectedExtMode) + extMode = fsn.ExtendedMode() + if extMode != expectedExtMode { + t.Fatalf("expected extended mode to be %X but got %X", expectedExtMode, extMode) + } + pbn = fsn.getPbData(t) + expectedPbMode = (expectedExtMode << 12) | (expectedUnixMode & 0xFFF) + if *pbn.Mode != expectedPbMode { + t.Fatalf("expected stored mode to be %b but got %b", expectedPbMode, *pbn.Mode) + } + + // ignore bits 21..32 + expectedExtMode = uint32(0xFFFFF) + fsn.SetExtendedMode(0xAAAFFFFF) + extMode = fsn.ExtendedMode() + if extMode != expectedExtMode { + t.Fatalf("expected extended mode to be %X but got %X", expectedExtMode, extMode) + } + pbn = fsn.getPbData(t) + expectedPbMode = (expectedExtMode << 12) | (expectedUnixMode & 0xFFF) + if *pbn.Mode != expectedPbMode { + t.Fatalf("expected raw mode to be %b but got %b", expectedPbMode, *pbn.Mode) + } + + fsn.SetMode(0) + fsn.SetExtendedMode(0) + pbn = fsn.getPbData(t) + if pbn.Mode != nil { + t.Fatal("expected file mode to be unset") + } +} + +func (n *FSNode) setPbModTime(seconds *int64, nanos *uint32) { + if n.format.Mtime == nil { + n.format.Mtime = &pb.IPFSTimestamp{} + } + + n.format.Mtime.Seconds = seconds + n.format.Mtime.Nanos = nanos +} + +func TestModTime(t *testing.T) { + tm := time.Now() + expectedUnix := tm.Unix() + n := NewFSNode(TFile) + + // not stored + mt := n.ModTime() + if !mt.IsZero() { + t.Fatal("expected modification time not to be set") + } + + // valid timestamps + n.SetModTime(tm) + mt = n.ModTime() + if !mt.Equal(tm) { + t.Fatalf("expected modification time to be %v but got %v", tm, mt) + } + + tm = time.Unix(expectedUnix, 0) + n.SetModTime(tm) + pbn := n.getPbData(t) + if pbn.Mtime.Nanos != nil { + t.Fatal("expected nanoseconds to be nil") + } + mt = n.ModTime() + if !mt.Equal(tm) { + t.Fatalf("expected modification time to be %v but got %v", tm, mt) + } + + tm = time.Unix(expectedUnix, 3489753) + n.SetModTime(tm) + mt = n.ModTime() + if !mt.Equal(tm) { + t.Fatalf("expected modification time to be %v but got %v", tm, mt) + } + + tm = time.Time{} + n.SetModTime(tm) + pbn = n.getPbData(t) + if pbn.Mtime != nil { + t.Fatal("expected modification time to be unset") + } + mt = n.ModTime() + if !mt.Equal(tm) { + t.Fatalf("expected modification time to be %v but got %v", tm, mt) + } + + n.setPbModTime(&expectedUnix, nil) + mt = n.ModTime() + if !mt.Equal(time.Unix(expectedUnix, 0)) { + t.Fatalf("expected modification time to be %v but got %v", time.Unix(expectedUnix, 0), mt) + } + + // invalid timestamps + n.setPbModTime(nil, proto.Uint32(1000)) + mt = n.ModTime() + if !mt.IsZero() { + t.Fatal("expected modification time not to be set") + } + + n.setPbModTime(&expectedUnix, proto.Uint32(0)) + mt = n.ModTime() + if !mt.IsZero() { + t.Fatal("expected modification time not to be set") + } + + n.setPbModTime(&expectedUnix, proto.Uint32(1000000000)) + mt = n.ModTime() + if !mt.IsZero() { + t.Fatal("expected modification time not to be set") + } +} diff --git a/mfs/dir.go b/mfs/dir.go index 86c85d1c5..38302ac39 100644 --- a/mfs/dir.go +++ b/mfs/dir.go @@ -41,8 +41,6 @@ type Directory struct { // UnixFS directory implementation used for creating, // reading and editing directories. unixfsDir uio.Directory - - modTime time.Time } // NewDirectory constructs a new MFS directory. @@ -64,7 +62,6 @@ func NewDirectory(ctx context.Context, name string, node ipld.Node, parent paren ctx: ctx, unixfsDir: db, entriesCache: make(map[string]FSNode), - modTime: time.Now(), }, nil } @@ -135,8 +132,6 @@ func (d *Directory) updateChild(c child) error { return err } - d.modTime = time.Now() - return nil } @@ -292,6 +287,10 @@ func (d *Directory) ForEachEntry(ctx context.Context, f func(NodeListing) error) } func (d *Directory) Mkdir(name string) (*Directory, error) { + return d.MkdirWithOpts(name, MkdirOpts{}) +} + +func (d *Directory) MkdirWithOpts(name string, opts MkdirOpts) (*Directory, error) { d.lock.Lock() defer d.lock.Unlock() @@ -307,7 +306,7 @@ func (d *Directory) Mkdir(name string) (*Directory, error) { } } - ndir := ft.EmptyDirNode() + ndir := ft.EmptyDirNodeWithStat(opts.Mode, opts.ModTime) ndir.SetCidBuilder(d.GetCidBuilder()) err = d.dagService.Add(d.ctx, ndir) @@ -367,7 +366,6 @@ func (d *Directory) AddChild(name string, nd ipld.Node) error { return err } - d.modTime = time.Now() return nil } @@ -427,3 +425,68 @@ func (d *Directory) GetNode() (ipld.Node, error) { return nd.Copy(), err } + +func (d *Directory) SetMode(mode os.FileMode) error { + nd, err := d.GetNode() + if err != nil { + return err + } + + fsn, err := ft.ExtractFSNode(nd) + if err != nil { + return err + } + + fsn.SetMode(mode) + data, err := fsn.GetBytes() + if err != nil { + return err + } + + return d.setNodeData(data, nd.Links()) +} + +func (d *Directory) SetModTime(ts time.Time) error { + nd, err := d.GetNode() + if err != nil { + return err + } + + fsn, err := ft.ExtractFSNode(nd) + if err != nil { + return err + } + + fsn.SetModTime(ts) + data, err := fsn.GetBytes() + if err != nil { + return err + } + + return d.setNodeData(data, nd.Links()) +} + +func (d *Directory) setNodeData(data []byte, links []*ipld.Link) error { + nd := dag.NodeWithData(data) + nd.SetLinks(links) + + err := d.dagService.Add(d.ctx, nd) + if err != nil { + return err + } + + err = d.parent.updateChildEntry(child{d.name, nd}) + if err != nil { + return err + } + + d.lock.Lock() + defer d.lock.Unlock() + db, err := uio.NewDirectoryFromNode(d.dagService, nd) + if err != nil { + return err + } + d.unixfsDir = db + + return nil +} diff --git a/mfs/file.go b/mfs/file.go index 56c2b0046..aff025db6 100644 --- a/mfs/file.go +++ b/mfs/file.go @@ -3,7 +3,9 @@ package mfs import ( "context" "errors" + "os" "sync" + "time" dag "github.com/ipfs/boxo/ipld/merkledag" ft "github.com/ipfs/boxo/ipld/unixfs" @@ -177,3 +179,101 @@ func (fi *File) Sync() error { func (fi *File) Type() NodeType { return TFile } + +func (fi *File) Mode() (os.FileMode, error) { + fi.nodeLock.RLock() + defer fi.nodeLock.RUnlock() + + nd, err := fi.GetNode() + if err != nil { + return 0, err + } + fsn, err := ft.ExtractFSNode(nd) + if err != nil { + return 0, err + } + return fsn.Mode() & 0xFFF, nil +} + +func (fi *File) SetMode(mode os.FileMode) error { + nd, err := fi.GetNode() + if err != nil { + return err + } + + fsn, err := ft.ExtractFSNode(nd) + if err != nil { + if errors.Is(err, ft.ErrNotProtoNode) { + // Wrap raw node in protonode. + data := nd.RawData() + return fi.setNodeData(ft.FilePBDataWithStat(data, uint64(len(data)), mode, time.Time{})) + } + return err + } + + fsn.SetMode(mode) + data, err := fsn.GetBytes() + if err != nil { + return err + } + + return fi.setNodeData(data) +} + +// ModTime returns the files' last modification time +func (fi *File) ModTime() (time.Time, error) { + fi.nodeLock.RLock() + defer fi.nodeLock.RUnlock() + + nd, err := fi.GetNode() + if err != nil { + return time.Time{}, err + } + fsn, err := ft.ExtractFSNode(nd) + if err != nil { + return time.Time{}, err + } + return fsn.ModTime(), nil +} + +// SetModTime sets the files' last modification time +func (fi *File) SetModTime(ts time.Time) error { + nd, err := fi.GetNode() + if err != nil { + return err + } + + fsn, err := ft.ExtractFSNode(nd) + if err != nil { + if errors.Is(err, ft.ErrNotProtoNode) { + // Wrap raw node in protonode. + data := nd.RawData() + return fi.setNodeData(ft.FilePBDataWithStat(data, uint64(len(data)), 0, ts)) + } + return err + } + + fsn.SetModTime(ts) + data, err := fsn.GetBytes() + if err != nil { + return err + } + + return fi.setNodeData(data) +} + +func (fi *File) setNodeData(data []byte) error { + nd := dag.NodeWithData(data) + err := fi.inode.dagService.Add(context.TODO(), nd) + if err != nil { + return err + } + + fi.nodeLock.Lock() + defer fi.nodeLock.Unlock() + fi.node = nd + parent := fi.inode.parent + name := fi.inode.name + + return parent.updateChildEntry(child{name, fi.node}) +} diff --git a/mfs/mfs_test.go b/mfs/mfs_test.go index eb5585a64..ee2726160 100644 --- a/mfs/mfs_test.go +++ b/mfs/mfs_test.go @@ -11,6 +11,7 @@ import ( "math/rand" "os" gopath "path" + "runtime" "sort" "strings" "sync" @@ -511,7 +512,19 @@ func TestMfsFile(t *testing.T) { fi := fsn.(*File) if fi.Type() != TFile { - t.Fatal("some is seriously wrong here") + t.Fatal("something is seriously wrong here") + } + + if m, err := fi.Mode(); err != nil { + t.Fatal("failed to get file mode: ", err) + } else if m != 0 { + t.Fatal("mode should not be set on a new file") + } + + if ts, err := fi.ModTime(); err != nil { + t.Fatal("failed to get file mtime: ", err) + } else if !ts.IsZero() { + t.Fatal("modification time should not be set on a new file") } wfd, err := fi.Open(Flags{Read: true, Write: true, Sync: true}) @@ -615,6 +628,12 @@ func TestMfsFile(t *testing.T) { t.Fatal(err) } + if ts, err := fi.ModTime(); err != nil { + t.Fatal("failed to get file mtime: ", err) + } else if !ts.IsZero() { + t.Fatal("file with unset modification time should not update modification time") + } + // make sure we can get node. TODO: verify it later _, err = fi.GetNode() if err != nil { @@ -622,6 +641,233 @@ func TestMfsFile(t *testing.T) { } } +func TestMfsModeAndModTime(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ds, rt := setupRoot(ctx, t) + rootdir := rt.GetDirectory() + nd := getRandFile(t, ds, 1000) + + err := rootdir.AddChild("file", nd) + if err != nil { + t.Fatal(err) + } + + fsn, err := rootdir.Child("file") + if err != nil { + t.Fatal(err) + } + + fi := fsn.(*File) + + if fi.Type() != TFile { + t.Fatal("something is seriously wrong here") + } + + var mode os.FileMode + ts, _ := time.Now(), time.Time{} + + // can set mode + if err = fi.SetMode(0644); err == nil { + if mode, err = fi.Mode(); mode != 0644 { + t.Fatal("failed to get correct mode of file") + } + } + if err != nil { + t.Fatal("failed to check file mode: ", err) + } + + // can set last modification time + if err = fi.SetModTime(ts); err == nil { + ts2, err := fi.ModTime() + if err != nil { + t.Fatal(err) + } + if !ts2.Equal(ts) { + t.Fatal("failed to get correct modification time of file") + } + } + if err != nil { + t.Fatal("failed to check file modification time: ", err) + } + + // test modification time update after write (on closing file) + if runtime.GOOS == "windows" { + time.Sleep(3 * time.Second) // for os with low-res mod time. + } + wfd, err := fi.Open(Flags{Read: false, Write: true, Sync: true}) + if err != nil { + t.Fatal(err) + } + _, err = wfd.Write([]byte("test")) + if err != nil { + t.Fatal(err) + } + err = wfd.Close() + if err != nil { + t.Fatal(err) + } + ts2, err := fi.ModTime() + if err != nil { + t.Fatal(err) + } + if !ts2.After(ts) { + t.Fatal("modification time should be updated after file write") + } + + // writeAt + ts = ts2 + if runtime.GOOS == "windows" { + time.Sleep(3 * time.Second) // for os with low-res mod time. + } + wfd, err = fi.Open(Flags{Read: false, Write: true, Sync: true}) + if err != nil { + t.Fatal(err) + } + _, err = wfd.WriteAt([]byte("test"), 42) + if err != nil { + t.Fatal(err) + } + err = wfd.Close() + if err != nil { + t.Fatal(err) + } + ts2, err = fi.ModTime() + if err != nil { + t.Fatal(err) + } + if !ts2.After(ts) { + t.Fatal("modification time should be updated after file writeAt") + } + + // truncate (shrink) + ts = ts2 + if runtime.GOOS == "windows" { + time.Sleep(3 * time.Second) // for os with low-res mod time. + } + wfd, err = fi.Open(Flags{Read: false, Write: true, Sync: true}) + if err != nil { + t.Fatal(err) + } + err = wfd.Truncate(100) + if err != nil { + t.Fatal(err) + } + err = wfd.Close() + if err != nil { + t.Fatal(err) + } + ts2, err = fi.ModTime() + if err != nil { + t.Fatal(err) + } + if !ts2.After(ts) { + t.Fatal("modification time should be updated after file truncate (shrink)") + } + + // truncate (expand) + ts = ts2 + if runtime.GOOS == "windows" { + time.Sleep(3 * time.Second) // for os with low-res mod time. + } + wfd, err = fi.Open(Flags{Read: false, Write: true, Sync: true}) + if err != nil { + t.Fatal(err) + } + err = wfd.Truncate(1500) + if err != nil { + t.Fatal(err) + } + err = wfd.Close() + if err != nil { + t.Fatal(err) + } + ts2, err = fi.ModTime() + if err != nil { + t.Fatal(err) + } + if !ts2.After(ts) { + t.Fatal("modification time should be updated after file truncate (expand)") + } +} + +func TestMfsRawNodeSetModeAndMtime(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + _, rt := setupRoot(ctx, t) + rootdir := rt.GetDirectory() + + // Create raw-node file. + nd := dag.NewRawNode(random.Bytes(256)) + _, err := ft.ExtractFSNode(nd) + if !errors.Is(err, ft.ErrNotProtoNode) { + t.Fatal("Expected non-proto node") + } + + err = rootdir.AddChild("file", nd) + if err != nil { + t.Fatal(err) + } + + fsn, err := rootdir.Child("file") + if err != nil { + t.Fatal(err) + } + + fi := fsn.(*File) + if fi.Type() != TFile { + t.Fatal("something is seriously wrong here") + } + + // Check for expected error when getting mode and mtime. + _, err = fi.Mode() + if !errors.Is(err, ft.ErrNotProtoNode) { + t.Fatal("Expected non-proto node") + } + _, err = fi.ModTime() + if !errors.Is(err, ft.ErrNotProtoNode) { + t.Fatal("Expected non-proto node") + } + + // Set and check mode. + err = fi.SetMode(0644) + if err != nil { + t.Fatalf("failed to set file mode: %s", err) + } + mode, err := fi.Mode() + if err != nil { + t.Fatalf("failed to check file mode: %s", err) + } + if mode != 0644 { + t.Fatal("failed to get correct mode of file, got", mode.String()) + } + + // Mtime should still be unset. + mtime, err := fi.ModTime() + if err != nil { + t.Fatalf("failed to get file modification time: %s", err) + } + if !mtime.IsZero() { + t.Fatalf("expected mtime to be unset") + } + + // Set and check mtime. + now := time.Now() + err = fi.SetModTime(now) + if err != nil { + t.Fatalf("failed to set file modification time: %s", err) + } + mtime, err = fi.ModTime() + if err != nil { + t.Fatalf("failed to get file modification time: %s", err) + } + if !mtime.Equal(now) { + t.Fatal("failed to get correct modification time of file") + } +} + func TestMfsDirListNames(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/mfs/ops.go b/mfs/ops.go index 693264704..09dbab00f 100644 --- a/mfs/ops.go +++ b/mfs/ops.go @@ -7,6 +7,7 @@ import ( "os" gopath "path" "strings" + "time" cid "github.com/ipfs/go-cid" ipld "github.com/ipfs/go-ipld-format" @@ -122,6 +123,8 @@ type MkdirOpts struct { Mkparents bool Flush bool CidBuilder cid.Builder + Mode os.FileMode + ModTime time.Time } // Mkdir creates a directory at 'path' under the directory 'd', creating @@ -171,7 +174,7 @@ func Mkdir(r *Root, pth string, opts MkdirOpts) error { cur = next } - final, err := cur.Mkdir(parts[len(parts)-1]) + final, err := cur.MkdirWithOpts(parts[len(parts)-1], opts) if err != nil { if !opts.Mkparents || err != os.ErrExist || final == nil { return err @@ -243,3 +246,21 @@ func FlushPath(ctx context.Context, rt *Root, pth string) (ipld.Node, error) { rt.repub.WaitPub(ctx) return nd.GetNode() } + +func Chmod(rt *Root, pth string, mode os.FileMode) error { + nd, err := Lookup(rt, pth) + if err != nil { + return err + } + + return nd.SetMode(mode) +} + +func Touch(rt *Root, pth string, ts time.Time) error { + nd, err := Lookup(rt, pth) + if err != nil { + return err + } + + return nd.SetModTime(ts) +} diff --git a/mfs/root.go b/mfs/root.go index c08d2d053..5a7cb7ed1 100644 --- a/mfs/root.go +++ b/mfs/root.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "os" "time" dag "github.com/ipfs/boxo/ipld/merkledag" @@ -73,6 +74,8 @@ type FSNode interface { Flush() error Type() NodeType + SetModTime(ts time.Time) error + SetMode(mode os.FileMode) error } // IsDir checks whether the FSNode is dir type diff --git a/out b/out deleted file mode 100644 index e69de29bb..000000000 diff --git a/tar/extractor.go b/tar/extractor.go index 8b9dfce6d..550fb895c 100644 --- a/tar/extractor.go +++ b/tar/extractor.go @@ -6,8 +6,12 @@ import ( "fmt" "io" "os" - fp "path/filepath" + "path/filepath" + "runtime" "strings" + "time" + + "github.com/ipfs/boxo/files" ) var ( @@ -18,23 +22,31 @@ var ( // Extractor is used for extracting tar files to a filesystem. // -// The Extractor can only extract tar files containing files, directories and symlinks. Additionally, the tar files must -// either have a single file, or symlink in them, or must have all of its objects inside of a single root directory -// object. +// The Extractor can only extract tar files containing files, directories and +// symlinks. Additionally, the tar files must either have a single file, or +// symlink in them, or must have all of its objects inside of a single root +// directory object. +// +// If the tar file contains a single file/symlink then it will try and extract +// it with semantics similar to Linux's `cp`. In particular, the name of the +// extracted file/symlink will match the extraction path. If the extraction +// path is a directory then it will extract into the directory using its +// original name. // -// If the tar file contains a single file/symlink then it will try and extract it with semantics similar to Linux's -// `cp`. In particular, the name of the extracted file/symlink will match the extraction path. If the extraction path -// is a directory then it will extract into the directory using its original name. +// If an associated mode and last modification time was stored in the archive +// it is restored. // -// Overwriting: Extraction of files and symlinks will result in overwriting the existing objects with the same name -// when possible (i.e. other files, symlinks, and empty directories). +// Overwriting: Extraction of files and symlinks will result in overwriting the +// existing objects with the same name when possible (i.e. other files, +// symlinks, and empty directories). type Extractor struct { - Path string - Progress func(int64) int64 + Path string + Progress func(int64) int64 + deferredUpdates []deferredUpdate } -// Extract extracts a tar file to the file system. See the Extractor for more information on the limitations on the -// tar files that can be extracted. +// Extract extracts a tar file to the file system. See the Extractor for more +// information on the limitations on the tar files that can be extracted. func (te *Extractor) Extract(reader io.Reader) error { if isNullDevice(te.Path) { return nil @@ -42,8 +54,6 @@ func (te *Extractor) Extract(reader io.Reader) error { tarReader := tar.NewReader(reader) - var firstObjectWasDir bool - header, err := tarReader.Next() if err != nil && err != io.EOF { return err @@ -52,10 +62,25 @@ func (te *Extractor) Extract(reader io.Reader) error { return errors.New("empty tar file") } - // Specially handle the first entry assuming it is a single root object (e.g. root directory, single file, - // or single symlink) + te.deferredUpdates = make([]deferredUpdate, 0, 80) + doUpdates := func() error { + for i := len(te.deferredUpdates) - 1; i >= 0; i-- { + m := te.deferredUpdates[i] + err := files.UpdateMetaUnix(m.path, uint32(m.mode), m.mtime) + if err != nil { + return err + } + } + te.deferredUpdates = nil + return nil + } + defer func() { err = doUpdates() }() + + // Specially handle the first entry assuming it is a single root object + // (e.g. root directory, single file, or single symlink). - // track what the root tar path is so we can ensure that all other entries are below the root + // track what the root tar path is so we can ensure that all other entries + // are below the root. if strings.Contains(header.Name, "/") { return fmt.Errorf("root name contains multiple components : %q : %w", header.Name, errInvalidRoot) } @@ -65,30 +90,39 @@ func (te *Extractor) Extract(reader io.Reader) error { } rootName := header.Name - // Get the platform-specific output path - rootOutputPath := fp.Clean(te.Path) + // Get the platform-specific output path. + rootOutputPath := filepath.Clean(te.Path) if err := validatePlatformPath(rootOutputPath); err != nil { return err } - // If the last element in the rootOutputPath (which is passed by the user) is a symlink do not follow it - // this makes it easier for users to reason about where files are getting extracted to even when the tar is not - // from a trusted source + var firstObjectWasDir bool + + // If the last element in the rootOutputPath (which is passed by the user) + // is a symlink do not follow it this makes it easier for users to reason + // about where files are getting extracted to even when the tar is not from + // a trusted source // - // For example, if the user extracts a mutable link to a tar file (http://sometimesbad.tld/t.tar) and situationally - // it contains a folder, file, or symlink the outputs could hop around the user's file system. This is especially - // annoying since we allow symlinks to point anywhere a user might want them to. + // For example, if the user extracts a mutable link to a tar file + // (http://sometimesbad.tld/t.tar) and situationally it contains a folder, + // file, or symlink the outputs could hop around the user's file system. + // This is especially annoying since we allow symlinks to point anywhere a + // user might want them to. switch header.Typeflag { case tar.TypeDir: - // if this is the root directory, use it as the output path for remaining files + // if this is the root directory, use it as the output path for + // remaining files. firstObjectWasDir = true if err := te.extractDir(rootOutputPath); err != nil { return err } + if err := te.deferUpdate(rootOutputPath, header); err != nil { + return err + } case tar.TypeReg, tar.TypeSymlink: - // Check if the output path already exists, so we know whether we should - // create our output with that name, or if we should put the output inside - // a preexisting directory + // Check if the output path already exists, so we know whether we + // should create our output with that name, or if we should put the + // output inside a preexisting directory. rootIsExistingDirectory := false // We do not follow links here @@ -101,30 +135,35 @@ func (te *Extractor) Extract(reader io.Reader) error { } outputPath := rootOutputPath - // If the root is a directory which already exists then put the file/symlink in the directory + // If the root is a directory which already exists then put the + // file/symlink in the directory. if rootIsExistingDirectory { - // make sure the root has a valid name + // make sure the root has a valid name. if err := validatePathComponent(rootName); err != nil { return err } - // If the output path directory exists then put the file/symlink into the directory. - outputPath = fp.Join(rootOutputPath, rootName) + // If the output path directory exists then put the file/symlink + // into the directory. + outputPath = filepath.Join(rootOutputPath, rootName) } - // If an object with the target name already exists overwrite it + // If an object with the target name already exists overwrite it. if header.Typeflag == tar.TypeReg { if err := te.extractFile(outputPath, tarReader); err != nil { return err } - } else if err := te.extractSymlink(outputPath, header); err != nil { + if err := files.UpdateMetaUnix(outputPath, uint32(header.Mode), header.ModTime); err != nil { + return err + } + } else if err := te.extractSymlink(outputPath, rootOutputPath, header); err != nil { return err } default: return fmt.Errorf("unrecognized tar header type: %d", header.Typeflag) } - // files come recursively in order + // files come recursively in order. for { header, err := tarReader.Next() if err != nil && err != io.EOF { @@ -134,12 +173,13 @@ func (te *Extractor) Extract(reader io.Reader) error { break } - // Make sure that we only have a single root element + // Make sure that we only have a single root element. if !firstObjectWasDir { return fmt.Errorf("the root was not a directory and the tar has multiple entries: %w", errInvalidRoot) } - // validate the path to remove paths we refuse to work with and make it easier to reason about + // validate the path in order to remove paths we refuse to work with + // and make it easier to reason about. if err := validateTarPath(header.Name); err != nil { return err } @@ -155,14 +195,15 @@ func (te *Extractor) Extract(reader io.Reader) error { return err } - // This check should already be covered by previous validation, but may catch bugs that slip through. - // Checks if the relative path matches or exceeds the root - // We check for matching because the outputPath function strips the original root - rel, err := fp.Rel(rootOutputPath, outputPath) + // This check should already be covered by previous validation, but may + // catch bugs that slip through. Checks if the relative path matches or + // exceeds the root We check for matching because the outputPath + // function strips the original root + rel, err := filepath.Rel(rootOutputPath, outputPath) if err != nil || rel == "." { return errInvalidRootMultipleRoots } - for _, e := range strings.Split(fp.ToSlash(rel), "/") { + for _, e := range strings.Split(filepath.ToSlash(rel), "/") { if e == ".." { return errors.New("relative path contains '..'") } @@ -173,12 +214,18 @@ func (te *Extractor) Extract(reader io.Reader) error { if err := te.extractDir(outputPath); err != nil { return err } + if err := te.deferUpdate(outputPath, header); err != nil { + return err + } case tar.TypeReg: if err := te.extractFile(outputPath, tarReader); err != nil { return err } + if err := files.UpdateMetaUnix(outputPath, uint32(header.Mode), header.ModTime); err != nil { + return err + } case tar.TypeSymlink: - if err := te.extractSymlink(outputPath, header); err != nil { + if err := te.extractSymlink(outputPath, rootOutputPath, header); err != nil { return err } default: @@ -188,7 +235,7 @@ func (te *Extractor) Extract(reader io.Reader) error { return nil } -// validateTarPath returns an error if the path has problematic characters +// validateTarPath returns an error if the path has problematic characters. func validateTarPath(tarPath string) error { if len(tarPath) == 0 { return errors.New("path is empty") @@ -208,8 +255,9 @@ func validateTarPath(tarPath string) error { return nil } -// getRelativePath returns the relative path between rootTarPath and tarPath. Assumes both paths have been cleaned. -// Will error if the tarPath is not below the rootTarPath. +// getRelativePath returns the relative path between rootTarPath and tarPath. +// Assumes both paths have been cleaned. Will error if the tarPath is not below +// the rootTarPath. func getRelativePath(rootName, tarPath string) (string, error) { if !strings.HasPrefix(tarPath, rootName+"/") { return "", errInvalidRootMultipleRoots @@ -217,7 +265,8 @@ func getRelativePath(rootName, tarPath string) (string, error) { return tarPath[len(rootName)+1:], nil } -// outputPath returns the directory path at which to place the file relativeTarPath. Assumes relativeTarPath is cleaned. +// outputPath returns the directory path at which to place the file +// relativeTarPath. Assumes relativeTarPath is cleaned. func (te *Extractor) outputPath(basePlatformPath, relativeTarPath string) (string, error) { elems := strings.Split(relativeTarPath, "/") @@ -226,10 +275,10 @@ func (te *Extractor) outputPath(basePlatformPath, relativeTarPath string) (strin if err := validatePathComponent(e); err != nil { return "", err } - platformPath = fp.Join(platformPath, e) + platformPath = filepath.Join(platformPath, e) - // Last element is not checked since it will be removed (if it exists) by any of the extraction functions. - // For more details see: + // Last element is not checked since it will be removed (if it exists) + // by any of the extraction functions. For more details see: // https://github.com/libarchive/libarchive/blob/0fd2ed25d78e9f4505de5dcb6208c6c0ff8d2edb/libarchive/archive_write_disk_posix.c#L2810 if i == len(elems)-1 { break @@ -259,46 +308,69 @@ func (te *Extractor) extractDir(path string) error { return err } - if stat, err := os.Lstat(path); err != nil { + stat, err := os.Lstat(path) + if err != nil { return err - } else if !stat.IsDir() { + } + if !stat.IsDir() { return errExtractedDirToSymlink } return nil } -func (te *Extractor) extractSymlink(path string, h *tar.Header) error { - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { +func (te *Extractor) extractSymlink(path, rootPath string, h *tar.Header) error { + err := os.Remove(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + // Before extracting a file or other symlink, the old path is removed to + // prevent a simlink being created that causes a subsequent extraction to + // escape the root. + // + // Each element of the path of the symlink being extracted is evaluated to + // ensure that there is not a symlink at any point in the path. This is + // done in outputPath. + err = os.Symlink(h.Linkname, path) + if err != nil { return err } - return os.Symlink(h.Linkname, path) + switch runtime.GOOS { + case "linux", "freebsd", "netbsd", "openbsd", "dragonfly": + return files.UpdateModTime(path, h.ModTime) + default: + return nil + } } func (te *Extractor) extractFile(path string, r *tar.Reader) error { - // Attempt removing the target so we can overwrite files, symlinks and empty directories - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + // Attempt removing the target so we can overwrite files, symlinks and + // empty directories. + err := os.Remove(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { return err } - // Create a temporary file in the target directory and then rename the temporary file to the target to better deal - // with races on the file system. - base := fp.Dir(path) + // Create a temporary file in the target directory and then rename the + // temporary file to the target to better deal with races on the file + // system. + base := filepath.Dir(path) tmpfile, err := os.CreateTemp(base, "") if err != nil { return err } - if err := copyWithProgress(tmpfile, r, te.Progress); err != nil { + if err = copyWithProgress(tmpfile, r, te.Progress); err != nil { _ = tmpfile.Close() _ = os.Remove(tmpfile.Name()) return err } - if err := tmpfile.Close(); err != nil { + if err = tmpfile.Close(); err != nil { _ = os.Remove(tmpfile.Name()) return err } - if err := os.Rename(tmpfile.Name(), path); err != nil { + if err = os.Rename(tmpfile.Name(), path); err != nil { _ = os.Remove(tmpfile.Name()) return err } @@ -327,3 +399,45 @@ func copyWithProgress(to io.Writer, from io.Reader, cb func(int64) int64) error } } } + +type deferredUpdate struct { + path string + mode int64 + mtime time.Time +} + +func (te *Extractor) deferUpdate(path string, header *tar.Header) error { + if header.Mode == 0 && header.ModTime.IsZero() { + return nil + } + + prefix := func() string { + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '/' { + return path[:i] + } + } + return path + } + + n := len(te.deferredUpdates) + if n > 0 && len(path) < len(te.deferredUpdates[n-1].path) { + // if possible, apply the previous deferral. + m := te.deferredUpdates[n-1] + if strings.HasPrefix(m.path, prefix()) { + err := files.UpdateMetaUnix(m.path, uint32(m.mode), m.mtime) + if err != nil { + return err + } + te.deferredUpdates = te.deferredUpdates[:n-1] + } + } + + te.deferredUpdates = append(te.deferredUpdates, deferredUpdate{ + path: path, + mode: header.Mode, + mtime: header.ModTime, + }) + + return nil +} diff --git a/tar/extractor_test.go b/tar/extractor_test.go index d2b4e00fc..7e31fbea5 100644 --- a/tar/extractor_test.go +++ b/tar/extractor_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/ipfs/boxo/files" "github.com/stretchr/testify/assert" ) @@ -50,7 +51,7 @@ func TestSingleFile(t *testing.T) { fileData := "file data" testTarExtraction(t, nil, []tarEntry{ - &fileTarEntry{fileName, []byte(fileData)}, + &fileTarEntry{path: fileName, buf: []byte(fileData)}, }, func(t *testing.T, extractDir string) { f, err := os.Open(fp.Join(extractDir, fileName)) @@ -64,11 +65,34 @@ func TestSingleFile(t *testing.T) { ) } +func TestSingleFileWithMeta(t *testing.T) { + fileName := "file2..ext" + fileData := "file2 data" + mode := 0654 + mtime := time.Now().Round(time.Second) + + testTarExtraction(t, nil, []tarEntry{ + &fileTarEntry{path: fileName, buf: []byte(fileData), mode: mode, mtime: mtime}, + }, + func(t *testing.T, extractDir string) { + path := fp.Join(extractDir, fileName) + testMeta(t, path, mode, mtime) + f, err := os.Open(path) + assert.NoError(t, err) + data, err := io.ReadAll(f) + assert.NoError(t, err) + assert.Equal(t, fileData, string(data)) + assert.NoError(t, f.Close()) + }, + nil, + ) +} + func TestSingleDirectory(t *testing.T) { dirName := "dir..sfx" testTarExtraction(t, nil, []tarEntry{ - &dirTarEntry{dirName}, + &dirTarEntry{path: dirName}, }, func(t *testing.T, extractDir string) { f, err := os.Open(extractDir) @@ -85,13 +109,37 @@ func TestSingleDirectory(t *testing.T) { ) } +func TestSingleDirectoryWithMeta(t *testing.T) { + dirName := "dir2..sfx" + mode := 0765 + mtime := time.Now().Round(time.Second) + + testTarExtraction(t, nil, []tarEntry{ + &dirTarEntry{path: dirName, mode: mode, mtime: mtime}, + }, + func(t *testing.T, extractDir string) { + testMeta(t, extractDir, mode, mtime) + f, err := os.Open(extractDir) + if err != nil { + t.Fatal(err) + } + objs, err := f.Readdir(1) + if err == io.EOF && len(objs) == 0 { + return + } + t.Fatalf("expected an empty directory") + }, + nil, + ) +} + func TestDirectoryFollowSymlinkToNothing(t *testing.T) { dirName := "dir" childName := "child" entries := []tarEntry{ - &dirTarEntry{dirName}, - &dirTarEntry{dirName + "/" + childName}, + &dirTarEntry{path: dirName}, + &dirTarEntry{path: dirName + "/" + childName}, } testTarExtraction(t, func(t *testing.T, rootDir string) { @@ -109,8 +157,8 @@ func TestDirectoryFollowSymlinkToFile(t *testing.T) { childName := "child" entries := []tarEntry{ - &dirTarEntry{dirName}, - &dirTarEntry{dirName + "/" + childName}, + &dirTarEntry{path: dirName}, + &dirTarEntry{path: dirName + "/" + childName}, } testTarExtraction(t, func(t *testing.T, rootDir string) { @@ -132,8 +180,8 @@ func TestDirectoryFollowSymlinkToDirectory(t *testing.T) { childName := "child" entries := []tarEntry{ - &dirTarEntry{dirName}, - &dirTarEntry{dirName + "/" + childName}, + &dirTarEntry{path: dirName}, + &dirTarEntry{path: dirName + "/" + childName}, } testTarExtraction(t, func(t *testing.T, rootDir string) { @@ -159,7 +207,7 @@ func TestSingleSymlink(t *testing.T) { symlinkName := "symlink" testTarExtraction(t, nil, []tarEntry{ - &symlinkTarEntry{targetName, symlinkName}, + &symlinkTarEntry{target: targetName, path: symlinkName}, }, func(t *testing.T, extractDir string) { symlinkPath := fp.Join(extractDir, symlinkName) fi, err := os.Lstat(symlinkPath) @@ -177,37 +225,37 @@ func TestSingleSymlink(t *testing.T) { func TestMultipleRoots(t *testing.T) { testTarExtraction(t, nil, []tarEntry{ - &dirTarEntry{"root"}, - &dirTarEntry{"sibling"}, + &dirTarEntry{path: "root"}, + &dirTarEntry{path: "sibling"}, }, nil, errInvalidRoot) } func TestMultipleRootsNested(t *testing.T) { testTarExtraction(t, nil, []tarEntry{ - &dirTarEntry{"root/child1"}, - &dirTarEntry{"root/child2"}, + &dirTarEntry{path: "root/child1"}, + &dirTarEntry{path: "root/child2"}, }, nil, errInvalidRoot) } func TestOutOfOrderRoot(t *testing.T) { testTarExtraction(t, nil, []tarEntry{ - &dirTarEntry{"root/child"}, - &dirTarEntry{"root"}, + &dirTarEntry{path: "root/child"}, + &dirTarEntry{path: "root"}, }, nil, errInvalidRoot) } func TestOutOfOrder(t *testing.T) { testTarExtraction(t, nil, []tarEntry{ - &dirTarEntry{"root/child/grandchild"}, - &dirTarEntry{"root/child"}, + &dirTarEntry{path: "root/child/grandchild"}, + &dirTarEntry{path: "root/child"}, }, nil, errInvalidRoot) } func TestNestedDirectories(t *testing.T) { testTarExtraction(t, nil, []tarEntry{ - &dirTarEntry{"root"}, - &dirTarEntry{"root/child"}, - &dirTarEntry{"root/child/grandchild"}, + &dirTarEntry{path: "root"}, + &dirTarEntry{path: "root/child"}, + &dirTarEntry{path: "root/child/grandchild"}, }, func(t *testing.T, extractDir string) { walkIndex := 0 err := fp.Walk(extractDir, @@ -234,19 +282,167 @@ func TestNestedDirectories(t *testing.T) { func TestRootDirectoryHasSubpath(t *testing.T) { testTarExtraction(t, nil, []tarEntry{ - &dirTarEntry{"root/child"}, - &dirTarEntry{"root/child/grandchild"}, + &dirTarEntry{path: "root/child"}, + &dirTarEntry{path: "root/child/grandchild"}, }, nil, errInvalidRoot) } func TestFilesAndFolders(t *testing.T) { testTarExtraction(t, nil, []tarEntry{ - &dirTarEntry{"root"}, - &dirTarEntry{"root/childdir"}, - &fileTarEntry{"root/childdir/file1", []byte("some data")}, + &dirTarEntry{path: "root"}, + &dirTarEntry{path: "root/childdir"}, + &fileTarEntry{path: "root/childdir/file1", buf: []byte("some data")}, }, nil, nil) } +func TestFilesAndFoldersWithMetadata(t *testing.T) { + tm := time.Unix(660000000, 0) + + entries := []tarEntry{ + &dirTarEntry{path: "root", mtime: tm.Add(5 * time.Second)}, + &dirTarEntry{path: "root/childdir", mode: 03775}, + &fileTarEntry{path: "root/childdir/file1", buf: []byte("some data"), mode: 04744, + mtime: tm.Add(10 * time.Second)}, + &fileTarEntry{path: "root/childdir/file2", buf: []byte("some data"), mode: 0560, + mtime: tm.Add(10 * time.Second)}, + &fileTarEntry{path: "root/childdir/file3", buf: []byte("some data"), mode: 06540, + mtime: tm.Add(10 * time.Second)}, + } + + testTarExtraction(t, nil, entries, func(t *testing.T, extractDir string) { + walkIndex := 0 + err := fp.Walk(extractDir, + func(path string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + switch walkIndex { + case 0: // root + assert.Equal(t, tm.Add(5*time.Second), fi.ModTime()) + case 1: // childdir + if runtime.GOOS != "windows" { + assert.Equal(t, 0775, int(fi.Mode()&0xFFF)) + assert.Equal(t, os.ModeSetgid, fi.Mode()&os.ModeSetgid) + assert.Equal(t, os.ModeSticky, fi.Mode()&os.ModeSticky) + } else { + assert.Equal(t, 0777, int(fi.Mode()&0xFFF)) + } + case 2: // file1 + assert.Equal(t, tm.Add(10*time.Second), fi.ModTime()) + if runtime.GOOS != "windows" { + assert.Equal(t, 0744, int(fi.Mode()&0xFFF)) + assert.Equal(t, os.ModeSetuid, fi.Mode()&os.ModeSetuid) + } else { + assert.Equal(t, 0666, int(fi.Mode()&0xFFF)) + } + case 3: // file2 + assert.Equal(t, tm.Add(10*time.Second), fi.ModTime()) + if runtime.GOOS != "windows" { + assert.Equal(t, 0560, int(fi.Mode()&0xFFF)) + assert.Equal(t, 0, int(fi.Mode()&os.ModeSetuid)) + } else { + assert.Equal(t, 0666, int(fi.Mode()&0xFFF)) + } + case 4: // file3 + assert.Equal(t, tm.Add(10*time.Second), fi.ModTime()) + if runtime.GOOS != "windows" { + assert.Equal(t, 0540, int(fi.Mode()&0xFFF)) + assert.Equal(t, os.ModeSetgid, fi.Mode()&os.ModeSetgid) + assert.Equal(t, os.ModeSetuid, fi.Mode()&os.ModeSetuid) + } else { + assert.Equal(t, 0444, int(fi.Mode()&0xFFF)) + } + default: + assert.Fail(t, "has more than 5 entries", path) + } + walkIndex++ + return nil + }) + assert.NoError(t, err) + }, + nil) +} + +func TestSymlinkWithModTime(t *testing.T) { + if !symlinksEnabled { + t.Skip("symlinks disabled on this platform", symlinksEnabledErr) + } + if runtime.GOOS == "darwin" { + t.Skip("changing symlink modification time is not currently supported on darwin") + } + tm := time.Unix(660000000, 0) + add5 := func() time.Time { + tm = tm.Add(5 * time.Second) + return tm + } + + entries := []tarEntry{ + &dirTarEntry{path: "root"}, + &symlinkTarEntry{target: "child", path: "root/a", mtime: add5()}, + &dirTarEntry{path: "root/child"}, + &fileTarEntry{path: "root/child/file1", buf: []byte("data")}, + &symlinkTarEntry{target: "child/file1", path: "root/file1-sl", mtime: add5()}, + } + + testTarExtraction(t, nil, entries, func(t *testing.T, extractDir string) { + tm = time.Unix(660000000, 0) + + fi, err := os.Lstat(fp.Join(extractDir, "a")) + assert.NoError(t, err) + add5() + if runtime.GOOS != "windows" { + assert.Equal(t, tm, fi.ModTime()) + } + + fi, err = os.Lstat(fp.Join(extractDir, "file1-sl")) + assert.NoError(t, err) + add5() + if runtime.GOOS != "windows" { + assert.Equal(t, tm, fi.ModTime()) + } + }, + nil) +} + +func TestDeferredUpdate(t *testing.T) { + tm := time.Unix(660000000, 0) + add5 := func() time.Time { + tm = tm.Add(5 * time.Second) + return tm + } + + // must be in lexical order + entries := []tarEntry{ + &dirTarEntry{path: "root", mtime: add5()}, + &dirTarEntry{path: "root/a", mtime: add5()}, + &dirTarEntry{path: "root/a/beta", mtime: add5(), mode: 0500}, + &dirTarEntry{path: "root/a/beta/centauri", mtime: add5()}, + &dirTarEntry{path: "root/a/beta/lima", mtime: add5()}, + &dirTarEntry{path: "root/a/beta/papa", mtime: add5()}, + &dirTarEntry{path: "root/a/beta/xanadu", mtime: add5()}, + &dirTarEntry{path: "root/a/beta/z", mtime: add5()}, + &dirTarEntry{path: "root/a/delta", mtime: add5()}, + &dirTarEntry{path: "root/iota", mtime: add5()}, + &dirTarEntry{path: "root/q", mtime: add5()}, + } + + testTarExtraction(t, nil, entries, func(t *testing.T, extractDir string) { + tm = time.Unix(660000000, 0) + err := fp.Walk(extractDir, + func(path string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + + assert.Equal(t, add5(), fi.ModTime()) + return nil + }) + assert.NoError(t, err) + }, + nil) + +} + func TestInternalSymlinkTraverse(t *testing.T) { if !symlinksEnabled { t.Skip("symlinks disabled on this platform", symlinksEnabledErr) @@ -254,10 +450,10 @@ func TestInternalSymlinkTraverse(t *testing.T) { testTarExtraction(t, nil, []tarEntry{ // FIXME: We are ignoring the first element in the path check so // we add a directory at the start to bypass this. - &dirTarEntry{"root"}, - &dirTarEntry{"root/child"}, - &symlinkTarEntry{"child", "root/symlink-dir"}, - &fileTarEntry{"root/symlink-dir/file", []byte("file")}, + &dirTarEntry{path: "root"}, + &dirTarEntry{path: "root/child"}, + &symlinkTarEntry{target: "child", path: "root/symlink-dir"}, + &fileTarEntry{path: "root/symlink-dir/file", buf: []byte("file")}, }, nil, errTraverseSymlink, @@ -271,9 +467,9 @@ func TestExternalSymlinkTraverse(t *testing.T) { testTarExtraction(t, nil, []tarEntry{ // FIXME: We are ignoring the first element in the path check so // we add a directory at the start to bypass this. - &dirTarEntry{"inner"}, - &symlinkTarEntry{"..", "inner/symlink-dir"}, - &fileTarEntry{"inner/symlink-dir/file", []byte("overwrite content")}, + &dirTarEntry{path: "inner"}, + &symlinkTarEntry{target: "..", path: "inner/symlink-dir"}, + &fileTarEntry{path: "inner/symlink-dir/file", buf: []byte("overwrite content")}, }, nil, errTraverseSymlink, @@ -295,13 +491,14 @@ func TestLastElementOverwrite(t *testing.T) { assert.Equal(t, len(originalData), n) }, []tarEntry{ - &dirTarEntry{"root"}, - &symlinkTarEntry{"../outside-ref", "root/symlink"}, - &fileTarEntry{"root/symlink", []byte("overwrite content")}, + &dirTarEntry{path: "root"}, + &symlinkTarEntry{target: "../outside-ref", path: "root/symlink"}, + &fileTarEntry{path: "root/symlink", buf: []byte("overwrite content")}, }, func(t *testing.T, extractDir string) { - // Check that outside-ref still exists but has not been - // overwritten or truncated (still size the same). + // Check that outside-ref still exists but has not been overwritten + // or truncated (still size the same). The symlink itself have been + // overwritten by the extracted file. info, err := os.Stat(fp.Join(extractDir, "..", "outside-ref")) assert.NoError(t, err) @@ -325,18 +522,12 @@ func testTarExtraction(t *testing.T, setup func(t *testing.T, rootDir string), t err = os.MkdirAll(extractDir, 0o755) assert.NoError(t, err) - // Generated TAR file. - tarFilename := fp.Join(rootDir, "generated.tar") - tarFile, err := os.Create(tarFilename) - assert.NoError(t, err) - defer tarFile.Close() - tw := tar.NewWriter(tarFile) - defer tw.Close() - if setup != nil { setup(t, rootDir) } + // Generated TAR file. + tarFilename := fp.Join(rootDir, "generated.tar") writeTarFile(t, tarFilename, tarEntries) testExtract(t, tarFilename, extractDir, extractError) @@ -358,6 +549,23 @@ func testExtract(t *testing.T, tarFile string, extractDir string, expectedError assert.ErrorIs(t, err, expectedError) } +func testMeta(t *testing.T, path string, mode int, now time.Time) { + fi, err := os.Lstat(path) + assert.NoError(t, err) + m := files.ModePermsToUnixPerms(fi.Mode()) + if runtime.GOOS == "windows" { + if fi.IsDir() { + mode = 0777 + } else if mode&0220 != 0 { + mode = 0666 + } else if mode&0440 != 0 { + mode = 0444 + } + } + assert.Equal(t, mode, int(m)) + assert.Equal(t, now.Unix(), fi.ModTime().Unix()) +} + // Based on the `writeXXXHeader` family of functions in // github.com/ipfs/go-ipfs-files@v0.0.8/tarwriter.go. func writeTarFile(t *testing.T, path string, entries []tarEntry) { @@ -385,16 +593,19 @@ var ( ) type fileTarEntry struct { - path string - buf []byte + path string + buf []byte + mode int + mtime time.Time } func (e *fileTarEntry) write(tw *tar.Writer) error { - if err := writeFileHeader(tw, e.path, uint64(len(e.buf))); err != nil { + err := writeFileHeader(tw, e.path, uint64(len(e.buf)), e.mode, e.mtime) + if err != nil { return err } - if _, err := io.Copy(tw, bytes.NewReader(e.buf)); err != nil { + if _, err = io.Copy(tw, bytes.NewReader(e.buf)); err != nil { return err } @@ -402,34 +613,35 @@ func (e *fileTarEntry) write(tw *tar.Writer) error { return nil } -func writeFileHeader(w *tar.Writer, fpath string, size uint64) error { +func writeFileHeader(w *tar.Writer, fpath string, size uint64, mode int, mtime time.Time) error { return w.WriteHeader(&tar.Header{ Name: fpath, Size: int64(size), Typeflag: tar.TypeReg, - Mode: 0o644, - ModTime: time.Now(), - // TODO: set mode, dates, etc. when added to unixFS + Mode: int64(mode), + ModTime: mtime, }) } type dirTarEntry struct { - path string + path string + mode int + mtime time.Time } func (e *dirTarEntry) write(tw *tar.Writer) error { return tw.WriteHeader(&tar.Header{ Name: e.path, Typeflag: tar.TypeDir, - Mode: 0o777, - ModTime: time.Now(), - // TODO: set mode, dates, etc. when added to unixFS + Mode: int64(e.mode), + ModTime: e.mtime, }) } type symlinkTarEntry struct { target string path string + mtime time.Time } func (e *symlinkTarEntry) write(w *tar.Writer) error { @@ -437,6 +649,7 @@ func (e *symlinkTarEntry) write(w *tar.Writer) error { Name: e.path, Linkname: e.target, Mode: 0o777, + ModTime: e.mtime, Typeflag: tar.TypeSymlink, }) } From 3cd3857b046c3d26494c6e9a34dbcee707413648 Mon Sep 17 00:00:00 2001 From: Andrew Gillis <11790789+gammazero@users.noreply.github.com> Date: Tue, 20 Aug 2024 17:19:02 -0700 Subject: [PATCH 05/15] refactor(chunker): reduce overall memory use (#649) --- CHANGELOG.md | 2 ++ chunker/benchmark_test.go | 36 +++++++++++++++++++++++ chunker/splitting.go | 48 +++++++++++++++++++++++------- chunker/splitting_test.go | 61 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 135 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f652999..07527a74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ The following emojis are used to highlight certain changes: ### Changed +- `chunker` refactored to reduce overall memory use by reducing heap fragmentation [#649](https://github.com/ipfs/boxo/pull/649) + ### Removed ### Fixed diff --git a/chunker/benchmark_test.go b/chunker/benchmark_test.go index 5069b0653..9374da45e 100644 --- a/chunker/benchmark_test.go +++ b/chunker/benchmark_test.go @@ -57,3 +57,39 @@ func benchmarkChunkerSize(b *testing.B, ns newSplitter, size int) { } Res = Res + res } + +func benchmarkFilesAlloc(b *testing.B, ns newSplitter) { + const ( + chunkSize = 4096 + minDataSize = 20000 + maxDataSize = 60000 + fileCount = 10000 + ) + rng := rand.New(rand.NewSource(1)) + data := make([]byte, maxDataSize) + rng.Read(data) + + b.SetBytes(maxDataSize) + b.ReportAllocs() + b.ResetTimer() + + var res uint64 + + for i := 0; i < b.N; i++ { + for j := 0; j < fileCount; j++ { + fileSize := rng.Intn(maxDataSize-minDataSize) + minDataSize + r := ns(bytes.NewReader(data[:fileSize])) + for { + chunk, err := r.NextBytes() + if err != nil { + if err == io.EOF { + break + } + b.Fatal(err) + } + res = res + uint64(len(chunk)) + } + } + } + Res = Res + res +} diff --git a/chunker/splitting.go b/chunker/splitting.go index 64306943b..6340c0f50 100644 --- a/chunker/splitting.go +++ b/chunker/splitting.go @@ -5,7 +5,9 @@ package chunk import ( + "errors" "io" + "math/bits" logging "github.com/ipfs/go-log/v2" pool "github.com/libp2p/go-buffer-pool" @@ -13,6 +15,10 @@ import ( var log = logging.Logger("chunk") +// maxOverAllocBytes is the maximum unused space a chunk can have without being +// reallocated to a smaller size to fit the data. +const maxOverAllocBytes = 1024 + // A Splitter reads bytes from a Reader and creates "chunks" (byte slices) // that can be used to build DAG nodes. type Splitter interface { @@ -81,19 +87,41 @@ func (ss *sizeSplitterv2) NextBytes() ([]byte, error) { full := pool.Get(int(ss.size)) n, err := io.ReadFull(ss.r, full) - switch err { - case io.ErrUnexpectedEOF: - ss.err = io.EOF - small := make([]byte, n) - copy(small, full) - pool.Put(full) - return small, nil - case nil: - return full, nil - default: + if err != nil { + if errors.Is(err, io.ErrUnexpectedEOF) { + ss.err = io.EOF + return reallocChunk(full, n), nil + } pool.Put(full) return nil, err } + return full, nil +} + +func reallocChunk(full []byte, n int) []byte { + // Do not return an empty buffer. + if n == 0 { + pool.Put(full) + return nil + } + + // If chunk is close enough to fully used. + if cap(full)-n <= maxOverAllocBytes { + return full[:n] + } + + var small []byte + // If reallocating to the nearest power of two saves space without leaving + // too much unused space. + powTwoSize := 1 << bits.Len32(uint32(n-1)) + if powTwoSize-n <= maxOverAllocBytes { + small = make([]byte, n, powTwoSize) + } else { + small = make([]byte, n) + } + copy(small, full) + pool.Put(full) + return small } // Reader returns the io.Reader associated to this Splitter. diff --git a/chunker/splitting_test.go b/chunker/splitting_test.go index 23170ee37..d21faf512 100644 --- a/chunker/splitting_test.go +++ b/chunker/splitting_test.go @@ -2,6 +2,7 @@ package chunk import ( "bytes" + "errors" "io" "testing" @@ -33,7 +34,7 @@ func TestSizeSplitterOverAllocate(t *testing.T) { if err != nil { t.Fatal(err) } - if cap(chunk) > len(chunk) { + if cap(chunk)-len(chunk) > maxOverAllocBytes { t.Fatal("chunk capacity too large") } } @@ -89,7 +90,6 @@ func TestSizeSplitterFillsChunks(t *testing.T) { sofar := 0 whole := make([]byte, max) for chunk := range c { - bc := b[sofar : sofar+len(chunk)] if !bytes.Equal(bc, chunk) { t.Fatalf("chunk not correct: (sofar: %d) %d != %d, %v != %v", sofar, len(bc), len(chunk), bc[:100], chunk[:100]) @@ -127,3 +127,60 @@ func BenchmarkDefault(b *testing.B) { return DefaultSplitter(r) }) } + +// BenchmarkFilesAllocPool benchmarks splitter that uses go-buffer-pool, +// simulating use in unixfs with many small files. +func BenchmarkFilesAllocPool(b *testing.B) { + const fileBlockSize = 4096 + + benchmarkFilesAlloc(b, func(r io.Reader) Splitter { + return NewSizeSplitter(r, fileBlockSize) + }) +} + +// BenchmarkFilesAllocPool benchmarks splitter that does not use +// go-buffer-pool, simulating use in unixfs with many small files. +func BenchmarkFilesAllocNoPool(b *testing.B) { + const fileBlockSize = 4096 + + benchmarkFilesAlloc(b, func(r io.Reader) Splitter { + return &sizeSplitterNoPool{ + r: r, + size: uint32(fileBlockSize), + } + }) +} + +// sizeSplitterNoPool implements Splitter that allocates without pool. Provided +// for benchmarking against implementation with pool. +type sizeSplitterNoPool struct { + r io.Reader + size uint32 + err error +} + +func (ss *sizeSplitterNoPool) NextBytes() ([]byte, error) { + if ss.err != nil { + return nil, ss.err + } + + full := make([]byte, ss.size) + n, err := io.ReadFull(ss.r, full) + if err != nil { + if errors.Is(err, io.ErrUnexpectedEOF) { + ss.err = io.EOF + if n == 0 { + return nil, nil + } + small := make([]byte, n) + copy(small, full) + return small, nil + } + return nil, err + } + return full, nil +} + +func (ss *sizeSplitterNoPool) Reader() io.Reader { + return ss.r +} From 08f200aa8eef7d227c7dee14fa8d1ed2012d26a0 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 28 Aug 2024 02:07:43 +0200 Subject: [PATCH 06/15] feat(gw): support UnixFS 1.5 on deserialized responses as Last-Modified (#659) bare minimum support for Last-Modified and If-Modified-Since if mtime present in dag-pb --- CHANGELOG.md | 1 + gateway/backend_blocks.go | 12 ++ gateway/gateway.go | 3 +- gateway/gateway_test.go | 160 ++++++++++++++++++ gateway/handler.go | 79 ++++++++- gateway/handler_defaults.go | 8 + .../testdata/unixfs-dir-with-mode-mtime.car | Bin 0 -> 1037 bytes 7 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 gateway/testdata/unixfs-dir-with-mode-mtime.car diff --git a/CHANGELOG.md b/CHANGELOG.md index 07527a74e..d0c5f4f67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The following emojis are used to highlight certain changes: ### Changed - `chunker` refactored to reduce overall memory use by reducing heap fragmentation [#649](https://github.com/ipfs/boxo/pull/649) +- `gateway` deserialized responses will have `Last-Modified` set to value from optional UnixFS 1.5 modification time field (if present in DAG) and a matching `If-Modified-Since` will return `304 Not Modified` (UnixFS 1.5 files only) [#659](https://github.com/ipfs/boxo/pull/659) ### Removed diff --git a/gateway/backend_blocks.go b/gateway/backend_blocks.go index 42440dfcd..d62d3d876 100644 --- a/gateway/backend_blocks.go +++ b/gateway/backend_blocks.go @@ -156,6 +156,12 @@ func (bb *BlocksBackend) Get(ctx context.Context, path path.ImmutablePath, range return md, nil, err } + // Set modification time in ContentPathMetadata if found in dag-pb's optional mtime field (UnixFS 1.5) + mtime := f.ModTime() + if !mtime.IsZero() { + md.ModTime = mtime + } + if d, ok := f.(files.Directory); ok { dir, err := uio.NewDirectoryFromNode(bb.dagService, nd) if err != nil { @@ -231,6 +237,12 @@ func (bb *BlocksBackend) Head(ctx context.Context, path path.ImmutablePath) (Con return ContentPathMetadata{}, nil, err } + // Set modification time in ContentPathMetadata if found in dag-pb's optional mtime field (UnixFS 1.5) + mtime := fileNode.ModTime() + if !mtime.IsZero() { + md.ModTime = mtime + } + sz, err := fileNode.Size() if err != nil { return ContentPathMetadata{}, nil, err diff --git a/gateway/gateway.go b/gateway/gateway.go index dccdaf792..d79689925 100644 --- a/gateway/gateway.go +++ b/gateway/gateway.go @@ -209,7 +209,8 @@ type ContentPathMetadata struct { PathSegmentRoots []cid.Cid LastSegment path.ImmutablePath LastSegmentRemainder []string - ContentType string // Only used for UnixFS requests + ContentType string // Only used for UnixFS requests + ModTime time.Time // Optional, non-zero values may be present in UnixFS 1.5 DAGs } // ByteRange describes a range request within a UnixFS file. "From" and "To" mostly diff --git a/gateway/gateway_test.go b/gateway/gateway_test.go index 3c1ab3f72..e4a9935ac 100644 --- a/gateway/gateway_test.go +++ b/gateway/gateway_test.go @@ -256,6 +256,60 @@ func TestHeaders(t *testing.T) { test(dagCborResponseFormat, dagCborPath) }) + // We have UnixFS1.5 tests in TestHeadersUnixFSModeModTime, here we test default behavior (DAG without modtime) + t.Run("If-Modified-Since is noop against DAG without optional UnixFS 1.5 mtime", func(t *testing.T) { + test := func(responseFormat string, path string) { + t.Run(responseFormat, func(t *testing.T) { + // Make regular request and read Last-Modified + url := ts.URL + path + req := mustNewRequest(t, http.MethodGet, url, nil) + req.Header.Add("Accept", responseFormat) + res := mustDoWithoutRedirect(t, req) + _, err := io.Copy(io.Discard, res.Body) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + lastModified := res.Header.Get("Last-Modified") + require.Empty(t, lastModified) + + // Make second request with If-Modified-Since far in past and expect normal response + req = mustNewRequest(t, http.MethodGet, url, nil) + req.Header.Add("Accept", responseFormat) + req.Header.Add("If-Modified-Since", "Mon, 13 Jun 2000 22:18:32 GMT") + res = mustDoWithoutRedirect(t, req) + _, err = io.Copy(io.Discard, res.Body) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + }) + } + + test("", dirPath) + test("text/html", dirPath) + test(carResponseFormat, dirPath) + test(rawResponseFormat, dirPath) + test(tarResponseFormat, dirPath) + + test("", hamtFilePath) + test("text/html", hamtFilePath) + test(carResponseFormat, hamtFilePath) + test(rawResponseFormat, hamtFilePath) + test(tarResponseFormat, hamtFilePath) + + test("", filePath) + test("text/html", filePath) + test(carResponseFormat, filePath) + test(rawResponseFormat, filePath) + test(tarResponseFormat, filePath) + + test("", dagCborPath) + test("text/html", dagCborPath+"/") + test(carResponseFormat, dagCborPath) + test(rawResponseFormat, dagCborPath) + test(dagJsonResponseFormat, dagCborPath) + test(dagCborResponseFormat, dagCborPath) + }) + t.Run("X-Ipfs-Roots contains expected values", func(t *testing.T) { test := func(responseFormat string, path string, roots string) { t.Run(responseFormat, func(t *testing.T) { @@ -495,6 +549,112 @@ func TestHeaders(t *testing.T) { }) } +// Testing a DAG with (optional) UnixFS1.5 modification time +func TestHeadersUnixFSModeModTime(t *testing.T) { + t.Parallel() + + ts, _, root := newTestServerAndNode(t, "unixfs-dir-with-mode-mtime.car") + var ( + rootCID = root.String() // "bafybeidbcy4u6y55gsemlubd64zk53xoxs73ifd6rieejxcr7xy46mjvky" + filePath = "/ipfs/" + rootCID + "/file1" + dirPath = "/ipfs/" + rootCID + "/dir1/" + ) + + t.Run("If-Modified-Since matching UnixFS 1.5 modtime returns Not Modified", func(t *testing.T) { + test := func(responseFormat string, path string, entityType string, supported bool) { + t.Run(fmt.Sprintf("%s/%s support=%t", responseFormat, entityType, supported), func(t *testing.T) { + // Make regular request and read Last-Modified + url := ts.URL + path + req := mustNewRequest(t, http.MethodGet, url, nil) + req.Header.Add("Accept", responseFormat) + res := mustDoWithoutRedirect(t, req) + _, err := io.Copy(io.Discard, res.Body) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + lastModified := res.Header.Get("Last-Modified") + if supported { + assert.NotEmpty(t, lastModified) + } else { + assert.Empty(t, lastModified) + lastModified = "Mon, 13 Jun 2022 22:18:32 GMT" // manually set value for use in next steps + } + + ifModifiedSinceTime, err := time.Parse(time.RFC1123, lastModified) + require.NoError(t, err) + oneHourBefore := ifModifiedSinceTime.Add(-1 * time.Hour).Truncate(time.Second) + oneHourAfter := ifModifiedSinceTime.Add(1 * time.Hour).Truncate(time.Second) + oneHourBeforeStr := oneHourBefore.Format(time.RFC1123) + oneHourAfterStr := oneHourAfter.Format(time.RFC1123) + lastModifiedStr := ifModifiedSinceTime.Format(time.RFC1123) + + // Make second request with If-Modified-Since and value read from response to first request + req = mustNewRequest(t, http.MethodGet, url, nil) + req.Header.Add("Accept", responseFormat) + req.Header.Add("If-Modified-Since", lastModifiedStr) + res = mustDoWithoutRedirect(t, req) + _, err = io.Copy(io.Discard, res.Body) + require.NoError(t, err) + defer res.Body.Close() + if supported { + // 304 on exact match, can skip body + assert.Equal(t, http.StatusNotModified, res.StatusCode) + } else { + assert.Equal(t, http.StatusOK, res.StatusCode) + } + + // Make third request with If-Modified-Since 1h before value read from response to first request + // and expect HTTP 200 + req = mustNewRequest(t, http.MethodGet, url, nil) + req.Header.Add("Accept", responseFormat) + req.Header.Add("If-Modified-Since", oneHourBeforeStr) + res = mustDoWithoutRedirect(t, req) + _, err = io.Copy(io.Discard, res.Body) + require.NoError(t, err) + defer res.Body.Close() + // always return 200 with body because mtime from unixfs is after value from If-Modified-Since + // so it counts as an update + assert.Equal(t, http.StatusOK, res.StatusCode) + + // Make third request with If-Modified-Since 1h after value read from response to first request + // and expect HTTP 200 + req = mustNewRequest(t, http.MethodGet, url, nil) + req.Header.Add("Accept", responseFormat) + req.Header.Add("If-Modified-Since", oneHourAfterStr) + res = mustDoWithoutRedirect(t, req) + _, err = io.Copy(io.Discard, res.Body) + require.NoError(t, err) + defer res.Body.Close() + if supported { + // 304 because mtime from unixfs is before value from If-Modified-Since + // so no update, can skip body + assert.Equal(t, http.StatusNotModified, res.StatusCode) + } else { + assert.Equal(t, http.StatusOK, res.StatusCode) + } + }) + } + + file, dir := "file", "directory" + // supported on file-based web responses + test("", filePath, file, true) + test("text/html", filePath, file, true) + + // not supported on other formats + // we may implement support for If-Modified-Since for below request types + // if users raise the need, but If-None-Match is way better + test(carResponseFormat, filePath, file, false) + test(rawResponseFormat, filePath, file, false) + test(tarResponseFormat, filePath, file, false) + + test("", dirPath, dir, false) + test("text/html", dirPath, dir, false) + test(carResponseFormat, dirPath, dir, false) + test(rawResponseFormat, dirPath, dir, false) + test(tarResponseFormat, dirPath, dir, false) + }) +} + func TestGoGetSupport(t *testing.T) { ts, _, root := newTestServerAndNode(t, "fixtures.car") diff --git a/gateway/handler.go b/gateway/handler.go index 4360d2163..d6e1b7054 100644 --- a/gateway/handler.go +++ b/gateway/handler.go @@ -300,6 +300,11 @@ func (i *handler) getOrHeadHandler(w http.ResponseWriter, r *http.Request) { return } + // Detect when If-Modified-Since HTTP header + UnixFS 1.5 allow returning HTTP 304 Not Modified. + if i.handleIfModifiedSince(w, r, rq) { + return + } + // Support custom response formats passed via ?format or Accept HTTP header switch responseFormat { case "", jsonResponseFormat, cborResponseFormat: @@ -410,18 +415,25 @@ func addCacheControlHeaders(w http.ResponseWriter, r *http.Request, contentPath } if lastMod.IsZero() { - // Otherwise, we set Last-Modified to the current time to leverage caching heuristics + // If no lastMod, set Last-Modified to the current time to leverage caching heuristics // built into modern browsers: https://github.com/ipfs/kubo/pull/8074#pullrequestreview-645196768 modtime = time.Now() } else { + // set Last-Modified to a meaningful value e.g. one read from dag-pb (UnixFS 1.5, mtime field) + // or the last time DNSLink / IPNS Record was modified / resoved or cache modtime = lastMod } + } else { w.Header().Set("Cache-Control", immutableCacheControl) - modtime = noModtime // disable Last-Modified - // TODO: consider setting Last-Modified if UnixFS V1.5 ever gets released - // with metadata: https://github.com/ipfs/kubo/issues/6920 + if lastMod.IsZero() { + // (noop) skip Last-Modified on immutable response + modtime = noModtime + } else { + // set Last-Modified to value read from dag-pb (UnixFS 1.5, mtime field) + modtime = lastMod + } } return modtime @@ -507,6 +519,21 @@ func setIpfsRootsHeader(w http.ResponseWriter, rq *requestData, md *ContentPathM w.Header().Set("X-Ipfs-Roots", rootCidList) } +// lastModifiedMatch returns true if we can respond with HTTP 304 Not Modified +// It compares If-Modified-Since with logical modification time read from DAG +// (e.g. UnixFS 1.5 modtime, if present) +func lastModifiedMatch(ifModifiedSinceHeader string, lastModified time.Time) bool { + if ifModifiedSinceHeader == "" || lastModified.IsZero() { + return false + } + ifModifiedSinceTime, err := time.Parse(time.RFC1123, ifModifiedSinceHeader) + if err != nil { + return false + } + // ignoring fractional seconds (as HTTP dates don't include fractional seconds) + return !lastModified.Truncate(time.Second).After(ifModifiedSinceTime) +} + // etagMatch evaluates if we can respond with HTTP 304 Not Modified // It supports multiple weak and strong etags passed in If-None-Match string // including the wildcard one. @@ -745,6 +772,50 @@ func (i *handler) handleIfNoneMatch(w http.ResponseWriter, r *http.Request, rq * return false } +func (i *handler) handleIfModifiedSince(w http.ResponseWriter, r *http.Request, rq *requestData) bool { + // Detect when If-Modified-Since HTTP header allows returning HTTP 304 Not Modified + ifModifiedSince := r.Header.Get("If-Modified-Since") + if ifModifiedSince == "" { + return false + } + + // Resolve path to be able to read pathMetadata.ModTime + pathMetadata, err := i.backend.ResolvePath(r.Context(), rq.immutablePath) + if err != nil { + var forwardedPath path.ImmutablePath + var continueProcessing bool + if isWebRequest(rq.responseFormat) { + forwardedPath, continueProcessing = i.handleWebRequestErrors(w, r, rq.mostlyResolvedPath(), rq.immutablePath, rq.contentPath, err, rq.logger) + if continueProcessing { + pathMetadata, err = i.backend.ResolvePath(r.Context(), forwardedPath) + } + } + if !continueProcessing || err != nil { + err = fmt.Errorf("failed to resolve %s: %w", debugStr(rq.contentPath.String()), err) + i.webError(w, r, err, http.StatusInternalServerError) + return true + } + } + + // Currently we only care about optional mtime from UnixFS 1.5 (dag-pb) + // but other sources of this metadata could be added in the future + lastModified := pathMetadata.ModTime + if lastModifiedMatch(ifModifiedSince, lastModified) { + w.WriteHeader(http.StatusNotModified) + return true + } + + // Check if the resolvedPath is an immutable path. + _, err = path.NewImmutablePath(pathMetadata.LastSegment) + if err != nil { + i.webError(w, r, err, http.StatusInternalServerError) + return true + } + + rq.pathMetadata = &pathMetadata + return false +} + // check if request was for one of known explicit formats, // or should use the default, implicit Web+UnixFS behaviors. func isWebRequest(responseFormat string) bool { diff --git a/gateway/handler_defaults.go b/gateway/handler_defaults.go index 78e5af952..ee4b130ee 100644 --- a/gateway/handler_defaults.go +++ b/gateway/handler_defaults.go @@ -94,6 +94,14 @@ func (i *handler) serveDefaults(ctx context.Context, w http.ResponseWriter, r *h setIpfsRootsHeader(w, rq, &pathMetadata) + // On deserialized responses, we prefer Last-Modified from pathMetadata + // (mtime in UnixFS 1.5 DAG). This also applies to /ipns/, because value + // from dag-pb, if present, is more meaningful than lastMod inferred from + // namesys. + if !pathMetadata.ModTime.IsZero() { + rq.lastMod = pathMetadata.ModTime + } + resolvedPath := pathMetadata.LastSegment switch mc.Code(resolvedPath.RootCid().Prefix().Codec) { case mc.Json, mc.DagJson, mc.Cbor, mc.DagCbor: diff --git a/gateway/testdata/unixfs-dir-with-mode-mtime.car b/gateway/testdata/unixfs-dir-with-mode-mtime.car new file mode 100644 index 0000000000000000000000000000000000000000..feeb66ad2b2b6bfdacebc7298dd80c0eaf1d7f71 GIT binary patch literal 1037 zcmcColvgveUmC@qhJJ zrbSK@VoAv?GL+yK(ub%&EK+%Nf=a!1(#Oy9Z?1V8?>JrGRN3EQ!uAVcX>7ViF<|vZ z5_6bf7Q9?tr@Zmk60heE0(51H0^dddQ155ff4I)b{Oi~MDH^N58jK|ZVHRlRoY*Td z`6bu07q2@w&L59lE9@X$x!wMo@AEU)I`&+T5@Jou%t*rt7q2Nr?a`U9&Py0 z?Acc?#8On3J~IyT-|%f#sCD>p4rWA4p&i|_F- z>Hs^-NFos7stF)h*@L`J$TBWT4i+I^Jw1J(&y4gz;cg5Jx<8=c1_vEMyP=-?#=8AR zPOhlc1@SXJJukY}9&405KQ%G+^L@YkfXx$^9EHTGF*HuW@zVx!q$$Xegfw&Ub1+FT zSS$g>>L<9<2qrtI(~3=Vg}j1GB$k9-;N|LUSTwi0S9HnRnv&l?HN)c_Qzk*2W`yjt Xb#SK<(v0l1C2(DYQaG}%9Uxr*RLZ56 literal 0 HcmV?d00001 From 317eb7d8223b3a3372fdcc3074fdbdaf3f93c0ef Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Tue, 3 Sep 2024 01:29:09 +0200 Subject: [PATCH 07/15] chore: rename bitswap-transfer example (#662) --- examples/README.md | 2 +- examples/bitswap-transfer/.gitignore | 1 + .../{unixfs-file-cid => bitswap-transfer}/README.md | 10 +++++----- examples/{unixfs-file-cid => bitswap-transfer}/main.go | 2 +- .../{unixfs-file-cid => bitswap-transfer}/main_test.go | 0 5 files changed, 8 insertions(+), 7 deletions(-) create mode 100644 examples/bitswap-transfer/.gitignore rename examples/{unixfs-file-cid => bitswap-transfer}/README.md (91%) rename examples/{unixfs-file-cid => bitswap-transfer}/main.go (99%) rename examples/{unixfs-file-cid => bitswap-transfer}/main_test.go (100%) diff --git a/examples/README.md b/examples/README.md index d1d0021d8..19313caf4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,7 +26,7 @@ Once you have your example finished, do not forget to run `go mod tidy` and addi ## Examples and Tutorials -- [Fetching a UnixFS file by CID](./unixfs-file-cid) +- [Transfering UnixFS file data with Bitswap](./bitswap-transfer) - [Gateway backed by a local blockstore in form of a CAR file](./gateway/car-file) - [Gateway backed by a remote (HTTP) blockstore and IPNS resolver](./gateway/proxy-blocks) - [Gateway backed by a remote (HTTP) CAR Gateway](./gateway/proxy-car) diff --git a/examples/bitswap-transfer/.gitignore b/examples/bitswap-transfer/.gitignore new file mode 100644 index 000000000..90bb16049 --- /dev/null +++ b/examples/bitswap-transfer/.gitignore @@ -0,0 +1 @@ +bitswap-transfer diff --git a/examples/unixfs-file-cid/README.md b/examples/bitswap-transfer/README.md similarity index 91% rename from examples/unixfs-file-cid/README.md rename to examples/bitswap-transfer/README.md index eeea0d7bd..55d69e9d6 100644 --- a/examples/unixfs-file-cid/README.md +++ b/examples/bitswap-transfer/README.md @@ -1,4 +1,4 @@ -# Downloading a UnixFS file +# Transfering UnixFS file with Bitswap This is an example that quickly shows how to use IPFS tooling to move around a file. @@ -13,18 +13,18 @@ In client mode, it will start up, connect to the server, request the data needed From the `boxo/examples` directory run the following: ``` -> cd unixfs-file-cid/ +> cd bitswap-transfer/ > go build ``` ## Usage ``` -> ./unixfs-file-cid +> ./bitswap-transfer 2023/01/30 21:34:11 I am /ip4/127.0.0.1/tcp/53935/p2p/QmUtp8xEVgWC5dNPthF2g37eVvCdrqY1FPxLxXZoKkPbdp 2023/01/30 21:34:11 hosting UnixFS file with CID: bafybeiecq2irw4fl5vunnxo6cegoutv4de63h7n27tekkjtak3jrvrzzhe 2023/01/30 21:34:11 listening for inbound connections and Bitswap requests -2023/01/30 21:34:11 Now run "./unixfs-file-cid -d /ip4/127.0.0.1/tcp/53935/p2p/QmUtp8xEVgWC5dNPthF2g37eVvCdrqY1FPxLxXZoKkPbdp" on a different terminal +2023/01/30 21:34:11 Now run "./bitswap-transfer -d /ip4/127.0.0.1/tcp/53935/p2p/QmUtp8xEVgWC5dNPthF2g37eVvCdrqY1FPxLxXZoKkPbdp" on a different terminal ``` The IPFS server hosting the data over libp2p will print out its `Multiaddress`, which indicates how it can be reached (ip4+tcp) and its randomly generated ID (`QmUtp8xEV...`) @@ -32,7 +32,7 @@ The IPFS server hosting the data over libp2p will print out its `Multiaddress`, Now, launch another node that talks to the hosting node: ``` -> ./unixfs-file-cid -d /ip4/127.0.0.1/tcp/53935/p2p/QmUtp8xEVgWC5dNPthF2g37eVvCdrqY1FPxLxXZoKkPbdp +> ./bitswap-transfer -d /ip4/127.0.0.1/tcp/53935/p2p/QmUtp8xEVgWC5dNPthF2g37eVvCdrqY1FPxLxXZoKkPbdp ``` The IPFS client will then download the file from the server peer and let you know that it's been received. diff --git a/examples/unixfs-file-cid/main.go b/examples/bitswap-transfer/main.go similarity index 99% rename from examples/unixfs-file-cid/main.go rename to examples/bitswap-transfer/main.go index e1adad350..921dca3fa 100644 --- a/examples/unixfs-file-cid/main.go +++ b/examples/bitswap-transfer/main.go @@ -40,7 +40,7 @@ import ( "github.com/ipfs/boxo/files" ) -const exampleBinaryName = "unixfs-file-cid" +const exampleBinaryName = "bitswap-transfer" func main() { ctx, cancel := context.WithCancel(context.Background()) diff --git a/examples/unixfs-file-cid/main_test.go b/examples/bitswap-transfer/main_test.go similarity index 100% rename from examples/unixfs-file-cid/main_test.go rename to examples/bitswap-transfer/main_test.go From 8726a2af2384b44a63814921962dfa4d0728ce7b Mon Sep 17 00:00:00 2001 From: Andrew Gillis <11790789+gammazero@users.noreply.github.com> Date: Thu, 5 Sep 2024 09:52:26 -0700 Subject: [PATCH 08/15] chore: minor performance improvements in bitswap (#666) Reduce lock contention, gc load, and other minor performance improvements. --- CHANGELOG.md | 1 + .../internal/decision/blockstoremanager.go | 72 ++++++++++++++----- bitswap/server/internal/decision/engine.go | 14 ++-- 3 files changed, 59 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0c5f4f67..0331e42c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ The following emojis are used to highlight certain changes: - `chunker` refactored to reduce overall memory use by reducing heap fragmentation [#649](https://github.com/ipfs/boxo/pull/649) - `gateway` deserialized responses will have `Last-Modified` set to value from optional UnixFS 1.5 modification time field (if present in DAG) and a matching `If-Modified-Since` will return `304 Not Modified` (UnixFS 1.5 files only) [#659](https://github.com/ipfs/boxo/pull/659) +- `bitswap/server` minor performance improvements in concurrent operations ### Removed diff --git a/bitswap/server/internal/decision/blockstoremanager.go b/bitswap/server/internal/decision/blockstoremanager.go index cace5a0b9..aa16b3126 100644 --- a/bitswap/server/internal/decision/blockstoremanager.go +++ b/bitswap/server/internal/decision/blockstoremanager.go @@ -4,6 +4,7 @@ import ( "context" "errors" "sync" + "sync/atomic" bstore "github.com/ipfs/boxo/blockstore" blocks "github.com/ipfs/go-block-format" @@ -85,35 +86,49 @@ func (bsm *blockstoreManager) addJob(ctx context.Context, job func()) error { } func (bsm *blockstoreManager) getBlockSizes(ctx context.Context, ks []cid.Cid) (map[cid.Cid]int, error) { - res := make(map[cid.Cid]int) if len(ks) == 0 { - return res, nil + return nil, nil } + sizes := make([]int, len(ks)) - var lk sync.Mutex - return res, bsm.jobPerKey(ctx, ks, func(c cid.Cid) { + var count atomic.Int32 + err := bsm.jobPerKey(ctx, ks, func(i int, c cid.Cid) { size, err := bsm.bs.GetSize(ctx, c) if err != nil { if !ipld.IsNotFound(err) { // Note: this isn't a fatal error. We shouldn't abort the request log.Errorf("blockstore.GetSize(%s) error: %s", c, err) } - } else { - lk.Lock() - res[c] = size - lk.Unlock() + return } + sizes[i] = size + count.Add(1) }) + if err != nil { + return nil, err + } + results := count.Load() + if results == 0 { + return nil, nil + } + + res := make(map[cid.Cid]int, results) + for i, n := range sizes { + if n != 0 { + res[ks[i]] = n + } + } + return res, nil } func (bsm *blockstoreManager) getBlocks(ctx context.Context, ks []cid.Cid) (map[cid.Cid]blocks.Block, error) { - res := make(map[cid.Cid]blocks.Block, len(ks)) if len(ks) == 0 { - return res, nil + return nil, nil } + blks := make([]blocks.Block, len(ks)) - var lk sync.Mutex - return res, bsm.jobPerKey(ctx, ks, func(c cid.Cid) { + var count atomic.Int32 + err := bsm.jobPerKey(ctx, ks, func(i int, c cid.Cid) { blk, err := bsm.bs.Get(ctx, c) if err != nil { if !ipld.IsNotFound(err) { @@ -122,21 +137,40 @@ func (bsm *blockstoreManager) getBlocks(ctx context.Context, ks []cid.Cid) (map[ } return } - - lk.Lock() - res[c] = blk - lk.Unlock() + blks[i] = blk + count.Add(1) }) + if err != nil { + return nil, err + } + results := count.Load() + if results == 0 { + return nil, nil + } + + res := make(map[cid.Cid]blocks.Block, results) + for i, blk := range blks { + if blk != nil { + res[ks[i]] = blk + } + } + return res, nil } -func (bsm *blockstoreManager) jobPerKey(ctx context.Context, ks []cid.Cid, jobFn func(c cid.Cid)) error { +func (bsm *blockstoreManager) jobPerKey(ctx context.Context, ks []cid.Cid, jobFn func(i int, c cid.Cid)) error { + if len(ks) == 1 { + jobFn(0, ks[0]) + return nil + } + var err error var wg sync.WaitGroup - for _, k := range ks { + for i, k := range ks { c := k + idx := i wg.Add(1) err = bsm.addJob(ctx, func() { - jobFn(c) + jobFn(idx, c) wg.Done() }) if err != nil { diff --git a/bitswap/server/internal/decision/engine.go b/bitswap/server/internal/decision/engine.go index a40345d8f..1174c94c0 100644 --- a/bitswap/server/internal/decision/engine.go +++ b/bitswap/server/internal/decision/engine.go @@ -8,6 +8,7 @@ import ( "fmt" "slices" "sync" + "sync/atomic" "time" "github.com/google/uuid" @@ -216,8 +217,7 @@ type Engine struct { activeGauge metrics.Gauge // used to ensure metrics are reported each fixed number of operation - metricsLock sync.Mutex - metricUpdateCounter int + metricUpdateCounter atomic.Uint32 taskComparator TaskComparator @@ -449,11 +449,7 @@ func newEngine( } func (e *Engine) updateMetrics() { - e.metricsLock.Lock() - c := e.metricUpdateCounter - e.metricUpdateCounter++ - e.metricsLock.Unlock() - + c := e.metricUpdateCounter.Add(1) if c%100 == 0 { stats := e.peerRequestQueue.Stats() e.activeGauge.Set(float64(stats.NumActive)) @@ -693,7 +689,7 @@ func (e *Engine) MessageReceived(ctx context.Context, p peer.ID, m bsmsg.BitSwap return true } - // Get block sizes + // Get block sizes for unique CIDs. wantKs := cid.NewSet() for _, entry := range wants { wantKs.Add(entry.Cid) @@ -975,6 +971,7 @@ func (e *Engine) NotifyNewBlocks(blks []blocks.Block) { var work bool for _, b := range blks { k := b.Cid() + blockSize := blockSizes[k] e.lock.RLock() peers := e.peerLedger.Peers(k) @@ -983,7 +980,6 @@ func (e *Engine) NotifyNewBlocks(blks []blocks.Block) { for _, entry := range peers { work = true - blockSize := blockSizes[k] isWantBlock := e.sendAsBlock(entry.WantType, blockSize) entrySize := blockSize From 51da02f0884836c9dc6de5e917bf0c64d6ebd13e Mon Sep 17 00:00:00 2001 From: web3-bot Date: Thu, 5 Sep 2024 18:08:12 +0100 Subject: [PATCH 09/15] chore: uci/update-go to go 1.22 (#661) Co-authored-by: Piotr Galar Co-authored-by: Marcin Rataj --- CHANGELOG.md | 1 + cmd/boxo-migrate/go.mod | 2 +- cmd/deprecator/go.mod | 2 +- examples/go.mod | 2 +- files/meta_other.go | 1 - files/meta_posix.go | 1 - go.mod | 2 +- 7 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0331e42c9..9b982d322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The following emojis are used to highlight certain changes: ### Changed +- updated Go in `go.mod` to 1.22 - `chunker` refactored to reduce overall memory use by reducing heap fragmentation [#649](https://github.com/ipfs/boxo/pull/649) - `gateway` deserialized responses will have `Last-Modified` set to value from optional UnixFS 1.5 modification time field (if present in DAG) and a matching `If-Modified-Since` will return `304 Not Modified` (UnixFS 1.5 files only) [#659](https://github.com/ipfs/boxo/pull/659) - `bitswap/server` minor performance improvements in concurrent operations diff --git a/cmd/boxo-migrate/go.mod b/cmd/boxo-migrate/go.mod index 69808ef1d..69ecad6e5 100644 --- a/cmd/boxo-migrate/go.mod +++ b/cmd/boxo-migrate/go.mod @@ -1,6 +1,6 @@ module github.com/ipfs/boxo/cmd/boxo-migrate -go 1.21 +go 1.22 require github.com/urfave/cli/v2 v2.25.1 diff --git a/cmd/deprecator/go.mod b/cmd/deprecator/go.mod index 666865084..01efe8339 100644 --- a/cmd/deprecator/go.mod +++ b/cmd/deprecator/go.mod @@ -1,6 +1,6 @@ module github.com/ipfs/boxo/cmd/deprecator -go 1.21 +go 1.22 require ( github.com/dave/dst v0.27.2 diff --git a/examples/go.mod b/examples/go.mod index 40d3c3696..c74b9c8e8 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -1,6 +1,6 @@ module github.com/ipfs/boxo/examples -go 1.21 +go 1.22 require ( github.com/ipfs/boxo v0.22.0 diff --git a/files/meta_other.go b/files/meta_other.go index 2c4645049..baf57499a 100644 --- a/files/meta_other.go +++ b/files/meta_other.go @@ -1,5 +1,4 @@ //go:build !linux && !freebsd && !netbsd && !openbsd && !dragonfly && !windows -// +build !linux,!freebsd,!netbsd,!openbsd,!dragonfly,!windows package files diff --git a/files/meta_posix.go b/files/meta_posix.go index 808cbb997..d3d961593 100644 --- a/files/meta_posix.go +++ b/files/meta_posix.go @@ -1,5 +1,4 @@ //go:build linux || freebsd || netbsd || openbsd || dragonfly -// +build linux freebsd netbsd openbsd dragonfly package files diff --git a/go.mod b/go.mod index a625a8b81..d3e40712a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ipfs/boxo -go 1.21 +go 1.22 require ( github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 From 81850d0f0496b5ea8608a00c7503c16894d24558 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Thu, 5 Sep 2024 20:57:24 +0200 Subject: [PATCH 10/15] chore(readme): update old links --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ed8ac109f..4dcb9c270 100644 --- a/README.md +++ b/README.md @@ -156,11 +156,13 @@ The code coverage of this repo is not currently representative of the actual tes ### Help -If you have questions, feel free to open an issue. You can also find the Boxo maintainers in [Filecoin Slack](https://filecoin.io/slack/) at #Boxo-maintainers. (If you would like to engage via IPFS Discord or ipfs.io Matrix, please drop into the #ipfs-implementers channel/room or file an issue, and we'll get bridging from #Boxo-maintainers to these other chat platforms.) +If you suspect a bug or have technical questions, feel free to open an issue. + +For regular support, try [Community chat](https://docs.ipfs.tech/community/#chat)'s `#ipfs-implementers` room or [help/boxo at IPFS discussion forums](https://discuss.ipfs.tech/c/help/boxo/51). ### What is the response time for issues or PRs filed? -New issues and PRs to this repo are usually looked at on a weekly basis as part of [Kubo triage](https://pl-strflt.notion.site/Kubo-Issue-Triage-Notes-7d4983e8cf294e07b3cc51b0c60ede9a). However, the response time may vary. +New issues and PRs to this repo are usually looked at on a weekly basis as part of [Shipyard's GO Triage triage](https://ipshipyard.notion.site/IPFS-Go-Triage-Boxo-Kubo-Rainbow-0ddee6b7f28d412da7dabe4f9107c29a). However, the response time may vary. ### What are some projects that depend on this project? @@ -168,10 +170,9 @@ The exhaustive list is https://github.com/ipfs/boxo/network/dependents. Some not 1. [Kubo](https://github.com/ipfs/kubo), an IPFS implementation in Go 2. [Lotus](https://github.com/filecoin-project/lotus), a Filecoin implementation in Go -6. [rainbow](https://github.com/ipfs/rainbow), a specialized IPFS gateway -4. [ipfs-check](https://github.com/ipfs-shipyard/ipfs-check), checks IPFS data availability -5. [someguy](https://github.com/ipfs-shipyard/someguy), a dedicated Delegated Routing V1 server and client -3. [Bifrost Gateway](https://github.com/ipfs/bifrost-gateway), a dedicated IPFS Gateway daemon backed by a remote datastore +3. [rainbow](https://github.com/ipfs/rainbow), a specialized IPFS gateway +4. [ipfs-check](https://github.com/ipfs/ipfs-check), checks IPFS data availability +5. [someguy](https://github.com/ipfs/someguy), a dedicated Delegated Routing V1 server and client ### Governance and Access From 3b8a74195825a10a3cc6894d96aac109a8df76ed Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Thu, 5 Sep 2024 21:54:36 +0200 Subject: [PATCH 11/15] docs: release v0.23.0 --- CHANGELOG.md | 21 ++++++++++++++------- RELEASE.md | 2 +- version.json | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b982d322..6ba1d8434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,21 +16,28 @@ The following emojis are used to highlight certain changes: ### Added -- `files`, `ipld/unixfs`, `mfs` and `tar` now support optional UnixFS 1.5 mode and modification time metadata - ### Changed -- updated Go in `go.mod` to 1.22 -- `chunker` refactored to reduce overall memory use by reducing heap fragmentation [#649](https://github.com/ipfs/boxo/pull/649) -- `gateway` deserialized responses will have `Last-Modified` set to value from optional UnixFS 1.5 modification time field (if present in DAG) and a matching `If-Modified-Since` will return `304 Not Modified` (UnixFS 1.5 files only) [#659](https://github.com/ipfs/boxo/pull/659) -- `bitswap/server` minor performance improvements in concurrent operations - ### Removed ### Fixed ### Security +## [v0.23.0] + +### Added + +- `files`, `ipld/unixfs`, `mfs` and `tar` now support optional UnixFS 1.5 mode and modification time metadata [#653](https://github.com/ipfs/boxo/pull/653) +- `gateway` deserialized responses will have `Last-Modified` set to value from optional UnixFS 1.5 modification time field (if present in DAG) and a matching `If-Modified-Since` will return `304 Not Modified` (UnixFS 1.5 files only) [#659](https://github.com/ipfs/boxo/pull/659) + +### Changed + +- updated Go in `go.mod` to 1.22 [#661](https://github.com/ipfs/boxo/pull/661) +- `chunker` refactored to reduce overall memory use by reducing heap fragmentation [#649](https://github.com/ipfs/boxo/pull/649) +- `bitswap/server` minor performance improvements in concurrent operations [#666](https://github.com/ipfs/boxo/pull/666) +- removed dependency on go-ipfs-blocksutil [#656](https://github.com/ipfs/boxo/pull/656) + ## [v0.22.0] ### Changed diff --git a/RELEASE.md b/RELEASE.md index cecd4dad5..a7f3bb0b0 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -52,7 +52,7 @@ At least as of 2023-06-08, changelog test is manually copied from [the changelog ### Related Work Below are links of related/adjacent work that has informed some of the decisions in this document: 1. https://github.com/ipfs/boxo/issues/170 -2. https://pl-strflt.notion.site/Kubo-Release-Process-5a5d066264704009a28a79cff93062c4 +2. https://ipshipyard.notion.site/Kubo-Release-Process-6dba4f5755c9458ab5685eeb28173778 3. https://github.com/ipfs/kubo/blob/master/docs/RELEASE_ISSUE_TEMPLATE.md ## Release Process diff --git a/version.json b/version.json index 6578f1967..93d6ca712 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "v0.22.0" + "version": "v0.23.0" } From 2107b5d004120401908f835970149a57e55091e1 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Thu, 5 Sep 2024 22:05:29 +0200 Subject: [PATCH 12/15] docs(release): improved mod tidy in kubo --- RELEASE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index a7f3bb0b0..13a558cd5 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -73,7 +73,7 @@ Below are links of related/adjacent work that has informed some of the decisions - [ ] Ensure Boxo tests are passing - [ ] Ensure Kubo tests are passing - [ ] Go to Kubo dir and run `go get github.com/ipfs/boxo@` using the commit hash of the `release-vX.Y.Z` branch - - [ ] Run `go mod tidy` in repo root and in `docs/examples/kubo-as-a-library` + - [ ] Run `make mod_tidy` in repo root (to apply `go mod tidy` to code, tests, and examples) - [ ] Commit the changes and open a draft PR in Kubo - [ ] Name the PR "Upgrade to Boxo vX.Y.Z" - [ ] Paste a link to the Kubo PR in the Boxo PR, so reviewers can verify the Kubo test run From 23d7ea5b41bbdf84f057dfa1bf2d7cde88253e62 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Thu, 5 Sep 2024 22:06:05 +0200 Subject: [PATCH 13/15] chore: go-libp2p v0.36.3 https://github.com/libp2p/go-libp2p/releases/tag/v0.36.3 --- examples/go.mod | 2 +- examples/go.sum | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/go.mod b/examples/go.mod index c74b9c8e8..fd77bc07e 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -9,7 +9,7 @@ require ( github.com/ipfs/go-datastore v0.6.0 github.com/ipld/go-car/v2 v2.13.1 github.com/ipld/go-ipld-prime v0.21.0 - github.com/libp2p/go-libp2p v0.36.2 + github.com/libp2p/go-libp2p v0.36.3 github.com/libp2p/go-libp2p-routing-helpers v0.7.3 github.com/multiformats/go-multiaddr v0.13.0 github.com/multiformats/go-multicodec v0.9.0 diff --git a/examples/go.sum b/examples/go.sum index e247582dd..f8d2600ad 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -271,8 +271,8 @@ github.com/libp2p/go-doh-resolver v0.4.0 h1:gUBa1f1XsPwtpE1du0O+nnZCUqtG7oYi7Bb+ github.com/libp2p/go-doh-resolver v0.4.0/go.mod h1:v1/jwsFusgsWIGX/c6vCRrnJ60x7bhTiq/fs2qt0cAg= github.com/libp2p/go-flow-metrics v0.1.0 h1:0iPhMI8PskQwzh57jB9WxIuIOQ0r+15PChFGkx3Q3WM= github.com/libp2p/go-flow-metrics v0.1.0/go.mod h1:4Xi8MX8wj5aWNDAZttg6UPmc0ZrnFNsMtpsYUClFtro= -github.com/libp2p/go-libp2p v0.36.2 h1:BbqRkDaGC3/5xfaJakLV/BrpjlAuYqSB0lRvtzL3B/U= -github.com/libp2p/go-libp2p v0.36.2/go.mod h1:XO3joasRE4Eup8yCTTP/+kX+g92mOgRaadk46LmPhHY= +github.com/libp2p/go-libp2p v0.36.3 h1:NHz30+G7D8Y8YmznrVZZla0ofVANrvBl2c+oARfMeDQ= +github.com/libp2p/go-libp2p v0.36.3/go.mod h1:4Y5vFyCUiJuluEPmpnKYf6WFx5ViKPUYs/ixe9ANFZ8= github.com/libp2p/go-libp2p-asn-util v0.4.1 h1:xqL7++IKD9TBFMgnLPZR6/6iYhawHKHl950SO9L6n94= github.com/libp2p/go-libp2p-asn-util v0.4.1/go.mod h1:d/NI6XZ9qxw67b4e+NgpQexCIiFYJjErASrYW4PFDN8= github.com/libp2p/go-libp2p-kad-dht v0.25.2 h1:FOIk9gHoe4YRWXTu8SY9Z1d0RILol0TrtApsMDPjAVQ= diff --git a/go.mod b/go.mod index d3e40712a..a324e8b71 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( github.com/jbenet/goprocess v0.1.4 github.com/libp2p/go-buffer-pool v0.1.0 github.com/libp2p/go-doh-resolver v0.4.0 - github.com/libp2p/go-libp2p v0.36.2 + github.com/libp2p/go-libp2p v0.36.3 github.com/libp2p/go-libp2p-kad-dht v0.25.2 github.com/libp2p/go-libp2p-record v0.2.0 github.com/libp2p/go-libp2p-routing-helpers v0.7.3 diff --git a/go.sum b/go.sum index e539af643..b68267e3f 100644 --- a/go.sum +++ b/go.sum @@ -274,8 +274,8 @@ github.com/libp2p/go-doh-resolver v0.4.0 h1:gUBa1f1XsPwtpE1du0O+nnZCUqtG7oYi7Bb+ github.com/libp2p/go-doh-resolver v0.4.0/go.mod h1:v1/jwsFusgsWIGX/c6vCRrnJ60x7bhTiq/fs2qt0cAg= github.com/libp2p/go-flow-metrics v0.1.0 h1:0iPhMI8PskQwzh57jB9WxIuIOQ0r+15PChFGkx3Q3WM= github.com/libp2p/go-flow-metrics v0.1.0/go.mod h1:4Xi8MX8wj5aWNDAZttg6UPmc0ZrnFNsMtpsYUClFtro= -github.com/libp2p/go-libp2p v0.36.2 h1:BbqRkDaGC3/5xfaJakLV/BrpjlAuYqSB0lRvtzL3B/U= -github.com/libp2p/go-libp2p v0.36.2/go.mod h1:XO3joasRE4Eup8yCTTP/+kX+g92mOgRaadk46LmPhHY= +github.com/libp2p/go-libp2p v0.36.3 h1:NHz30+G7D8Y8YmznrVZZla0ofVANrvBl2c+oARfMeDQ= +github.com/libp2p/go-libp2p v0.36.3/go.mod h1:4Y5vFyCUiJuluEPmpnKYf6WFx5ViKPUYs/ixe9ANFZ8= github.com/libp2p/go-libp2p-asn-util v0.4.1 h1:xqL7++IKD9TBFMgnLPZR6/6iYhawHKHl950SO9L6n94= github.com/libp2p/go-libp2p-asn-util v0.4.1/go.mod h1:d/NI6XZ9qxw67b4e+NgpQexCIiFYJjErASrYW4PFDN8= github.com/libp2p/go-libp2p-kad-dht v0.25.2 h1:FOIk9gHoe4YRWXTu8SY9Z1d0RILol0TrtApsMDPjAVQ= From 93a7d5eaa19c8992c7155ad08a4ec6f217566706 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Thu, 5 Sep 2024 22:52:40 +0200 Subject: [PATCH 14/15] fix(ci): recursive mod tidy in kubo --- .github/workflows/gateway-sharness.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gateway-sharness.yml b/.github/workflows/gateway-sharness.yml index 2bd38cb05..9980779d1 100644 --- a/.github/workflows/gateway-sharness.yml +++ b/.github/workflows/gateway-sharness.yml @@ -33,7 +33,7 @@ jobs: - name: Replace boxo in Kubo go.mod run: | go mod edit -replace=github.com/ipfs/boxo=../boxo - go mod tidy + make mod_tidy cat go.mod working-directory: kubo - name: Install sharness dependencies From d29e0f8dbbcaffdd364f4887f129931812d29b55 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Thu, 5 Sep 2024 23:08:20 +0200 Subject: [PATCH 15/15] docs(changelog): go-libp2p v0.36.3 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ba1d8434..7081f01cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ The following emojis are used to highlight certain changes: ### Changed - updated Go in `go.mod` to 1.22 [#661](https://github.com/ipfs/boxo/pull/661) +- updated go-libp2p to [v0.36.3](https://github.com/libp2p/go-libp2p/releases/tag/v0.36.3) - `chunker` refactored to reduce overall memory use by reducing heap fragmentation [#649](https://github.com/ipfs/boxo/pull/649) - `bitswap/server` minor performance improvements in concurrent operations [#666](https://github.com/ipfs/boxo/pull/666) - removed dependency on go-ipfs-blocksutil [#656](https://github.com/ipfs/boxo/pull/656)