├── .all-contributorsrc ├── .github ├── FUNDING.yml └── workflows │ └── go.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── benchmarks ├── .gitignore └── benchmarks_test.go ├── entry.go ├── errors.go ├── example_http_file_server_test.go ├── example_stat_test.go ├── file.go ├── fs.go ├── fs_test.go ├── go.mod ├── go.sum ├── io.go ├── test-no-directory-entries.tar ├── test-sparse.tar ├── test-with-dot-dir.tar ├── test-with-global-header.tar ├── test.tar └── test ├── bar ├── dir1 ├── dir11 │ └── file111 ├── file11 └── file12 ├── dir2 └── dir21 │ ├── file211 │ └── file212 └── foo /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": true, 7 | "commitConvention": "gitmoji", 8 | "contributors": [ 9 | { 10 | "login": "nlepage", 11 | "name": "Nicolas Lepage", 12 | "avatar_url": "https://avatars.githubusercontent.com/u/19571875?v=4", 13 | "profile": "https://github.com/nlepage", 14 | "contributions": [ 15 | "code", 16 | "test", 17 | "example", 18 | "maintenance", 19 | "review" 20 | ] 21 | }, 22 | { 23 | "login": "cugu", 24 | "name": "Jonas Plum", 25 | "avatar_url": "https://avatars.githubusercontent.com/u/653777?v=4", 26 | "profile": "https://blog.cugu.eu/", 27 | "contributions": [ 28 | "test", 29 | "code" 30 | ] 31 | }, 32 | { 33 | "login": "ix64", 34 | "name": "MengYX", 35 | "avatar_url": "https://avatars.githubusercontent.com/u/13902388?v=4", 36 | "profile": "https://github.com/ix64", 37 | "contributions": [ 38 | "bug", 39 | "code" 40 | ] 41 | }, 42 | { 43 | "login": "adyatlov", 44 | "name": "Andrey Dyatlov", 45 | "avatar_url": "https://avatars.githubusercontent.com/u/1386270?v=4", 46 | "profile": "https://github.com/adyatlov", 47 | "contributions": [ 48 | "bug", 49 | "code", 50 | "test" 51 | ] 52 | }, 53 | { 54 | "login": "joelanford", 55 | "name": "Joe Lanford", 56 | "avatar_url": "https://avatars.githubusercontent.com/u/580047?v=4", 57 | "profile": "https://github.com/joelanford", 58 | "contributions": [ 59 | "code", 60 | "review", 61 | "bug" 62 | ] 63 | } 64 | ], 65 | "contributorsPerLine": 7, 66 | "projectName": "go-tarfs", 67 | "projectOwner": "nlepage", 68 | "repoType": "github", 69 | "repoHost": "https://github.com", 70 | "skipCi": true 71 | } 72 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [nlepage] 2 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | strategy: 11 | matrix: 12 | go: ["1.17", "1.18", "1.19", "1.20", "1.21"] 13 | os: ["ubuntu-latest", "windows-latest", "macos-latest"] 14 | runs-on: ${{matrix.os}} 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v4 20 | with: 21 | go-version: ${{matrix.go}} 22 | 23 | - name: Build 24 | run: go build -v ./... 25 | 26 | - name: Test 27 | run: go test -v ./... 28 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at go-tarfs@lepage.dev. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-tarfs 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/nlepage/go-tarfs.svg)](https://pkg.go.dev/github.com/nlepage/go-tarfs) 4 | ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/nlepage/go-tarfs?sort=semver) 5 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/nlepage/go-tarfs/go.yml?branch=main) 6 | [![License Unlicense](https://img.shields.io/github/license/nlepage/go-tarfs)](https://github.com/nlepage/go-tarfs/blob/master/LICENSE) 7 | 8 | > Read a tar file contents using go1.16 io/fs abstraction 9 | 10 | ## Usage 11 | 12 | ⚠️ go-tarfs needs go>=1.17 13 | 14 | Install: 15 | 16 | ```sh 17 | go get github.com/nlepage/go-tarfs 18 | ``` 19 | 20 | Use: 21 | 22 | ```go 23 | package main 24 | 25 | import ( 26 | "os" 27 | 28 | tarfs "github.com/nlepage/go-tarfs" 29 | ) 30 | 31 | func main() { 32 | tf, err := os.Open("path/to/archive.tar") 33 | if err != nil { 34 | panic(err) 35 | } 36 | defer tf.Close() 37 | 38 | tfs, err := tarfs.New(tf) 39 | if err != nil { 40 | panic(err) 41 | } 42 | 43 | f, err := tfs.Open("path/to/some/file") 44 | if err != nil { 45 | panic(err) 46 | } 47 | defer f.Close() // frees the associated reader 48 | 49 | // use f... 50 | } 51 | ``` 52 | 53 | More information at [pkg.go.dev/github.com/nlepage/go-tarfs](https://pkg.go.dev/github.com/nlepage/go-tarfs#section-documentation) 54 | 55 | ### Long living `fs.FS` 56 | 57 | The `io.Reader` given to `tarfs.New` must stay opened while using the returned `fs.FS` (this is true only if the `io.Reader` implements `io.ReaderAt`). 58 | 59 | ### Memory usage 60 | 61 | Since [v1.2.0](https://github.com/nlepage/go-tarfs/releases/tag/v1.2.0) files content are not stored in memory anymore if the `io.Reader` given to `tarfs.New` implements `io.ReaderAt`. 62 | 63 | ### Symbolic links 64 | 65 | For now, no effort is done to support symbolic links. 66 | 67 | ## Show your support 68 | 69 | Give a ⭐️ if this project helped you! 70 | 71 | ## Contributors ✨ 72 | 73 | 74 | [![All Contributors](https://img.shields.io/badge/all_contributors-5-orange.svg?style=flat-square)](#contributors-) 75 | 76 | 77 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 |
Nicolas Lepage
Nicolas Lepage

💻 ⚠️ 💡 🚧 👀
Jonas Plum
Jonas Plum

⚠️ 💻
MengYX
MengYX

🐛 💻
Andrey Dyatlov
Andrey Dyatlov

🐛 💻 ⚠️
Joe Lanford
Joe Lanford

💻 👀 🐛
93 | 94 | 95 | 96 | 97 | 98 | 99 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 100 | 101 | ## 📝 License 102 | 103 | This project is [unlicensed](https://github.com/nlepage/go-tarfs/blob/master/LICENSE), it is free and unencumbered software released into the public domain. 104 | -------------------------------------------------------------------------------- /benchmarks/.gitignore: -------------------------------------------------------------------------------- 1 | *.tar 2 | -------------------------------------------------------------------------------- /benchmarks/benchmarks_test.go: -------------------------------------------------------------------------------- 1 | package tarfs 2 | 3 | import ( 4 | "archive/tar" 5 | "io" 6 | "io/fs" 7 | "math/rand" 8 | "os" 9 | "testing" 10 | 11 | "github.com/nlepage/go-tarfs" 12 | ) 13 | 14 | const chars = "abcdefghijklmnopqrstuvxyzABCDEFGHIJKLMNOPQRSTUVXYZ0123456789" 15 | 16 | var ( 17 | randomFileName = make(map[string]string) 18 | ) 19 | 20 | func TestMain(m *testing.M) { 21 | rand.Seed(3827653748965) 22 | generateTarFile("many-small-files.tar", 10000, 1, 10000) 23 | generateTarFile("few-large-files.tar", 10, 10000000, 100000000) 24 | os.Exit(m.Run()) 25 | } 26 | 27 | func BenchmarkOpenTarThenReadFile_ManySmallFiles(b *testing.B) { 28 | fileName := randomFileName["many-small-files.tar"] 29 | 30 | b.ResetTimer() 31 | 32 | for i := 0; i < b.N; i++ { 33 | openTarThenReadFile("many-small-files.tar", fileName) 34 | } 35 | } 36 | 37 | func BenchmarkOpenTarThenReadFile_FewLargeFiles(b *testing.B) { 38 | fileName := randomFileName["few-large-files.tar"] 39 | 40 | b.ResetTimer() 41 | 42 | for i := 0; i < b.N; i++ { 43 | openTarThenReadFile("few-large-files.tar", fileName) 44 | } 45 | } 46 | 47 | func BenchmarkReadFile_ManySmallFiles(b *testing.B) { 48 | benchmarkReadFile(b, "many-small-files.tar") 49 | } 50 | 51 | func BenchmarkReadFile_FewLargeFiles(b *testing.B) { 52 | benchmarkReadFile(b, "few-large-files.tar") 53 | } 54 | 55 | func BenchmarkOpenAndCloseFile_ManySmallFiles(b *testing.B) { 56 | benchmarkOpenAndCloseFile(b, "many-small-files.tar") 57 | } 58 | 59 | func BenchmarkOpenAndCloseFile_FewLargeFiles(b *testing.B) { 60 | benchmarkOpenAndCloseFile(b, "few-large-files.tar") 61 | } 62 | 63 | func BenchmarkOpenReadAndCloseFile_ManySmallFiles(b *testing.B) { 64 | benchmarkOpenReadAndCloseFile(b, "many-small-files.tar") 65 | } 66 | 67 | func BenchmarkOpenReadAndCloseFile_FewLargeFiles(b *testing.B) { 68 | benchmarkOpenReadAndCloseFile(b, "few-large-files.tar") 69 | } 70 | 71 | func benchmarkReadFile(b *testing.B, tarFileName string) { 72 | tf, err := os.Open(tarFileName) 73 | if err != nil { 74 | panic(err) 75 | } 76 | defer tf.Close() 77 | 78 | tfs, err := tarfs.New(tf) 79 | if err != nil { 80 | panic(err) 81 | } 82 | 83 | fileName := randomFileName[tarFileName] 84 | 85 | b.ResetTimer() 86 | 87 | for i := 0; i < b.N; i++ { 88 | if _, err := fs.ReadFile(tfs, fileName); err != nil { 89 | panic(err) 90 | } 91 | } 92 | } 93 | 94 | func openTarThenReadFile(tarName, fileName string) { 95 | tf, err := os.Open(tarName) 96 | if err != nil { 97 | panic(err) 98 | } 99 | defer tf.Close() 100 | 101 | var tfs fs.FS 102 | 103 | tfs, err = tarfs.New(tf) 104 | if err != nil { 105 | panic(err) 106 | } 107 | 108 | if _, err := fs.ReadFile(tfs, fileName); err != nil { 109 | panic(err) 110 | } 111 | } 112 | 113 | func benchmarkOpenAndCloseFile(b *testing.B, tarFileName string) { 114 | tf, err := os.Open(tarFileName) 115 | if err != nil { 116 | panic(err) 117 | } 118 | defer tf.Close() 119 | 120 | tfs, err := tarfs.New(tf) 121 | if err != nil { 122 | panic(err) 123 | } 124 | 125 | fileName := randomFileName[tarFileName] 126 | 127 | b.ResetTimer() 128 | 129 | for i := 0; i < b.N; i++ { 130 | f, err := tfs.Open(fileName) 131 | if err != nil { 132 | panic(err) 133 | } 134 | if err := f.Close(); err != nil { 135 | panic(err) 136 | } 137 | } 138 | } 139 | 140 | func benchmarkOpenReadAndCloseFile(b *testing.B, tarFileName string) { 141 | tf, err := os.Open(tarFileName) 142 | if err != nil { 143 | panic(err) 144 | } 145 | defer tf.Close() 146 | 147 | tfs, err := tarfs.New(tf) 148 | if err != nil { 149 | panic(err) 150 | } 151 | 152 | fileName := randomFileName[tarFileName] 153 | 154 | b.ResetTimer() 155 | 156 | for i := 0; i < b.N; i++ { 157 | f, err := tfs.Open(fileName) 158 | if err != nil { 159 | panic(err) 160 | } 161 | st, err := f.Stat() 162 | if err != nil { 163 | panic(err) 164 | } 165 | buf := make([]byte, st.Size()) 166 | if _, err := io.ReadFull(f, buf); err != nil { 167 | panic(err) 168 | } 169 | if err := f.Close(); err != nil { 170 | panic(err) 171 | } 172 | } 173 | } 174 | 175 | func generateTarFile(tarName string, numFiles int, minSize, maxSize int) { 176 | f, err := os.Create(tarName) 177 | if err != nil { 178 | panic(err) 179 | } 180 | defer f.Close() 181 | 182 | w := tar.NewWriter(f) 183 | buf := make([]byte, 1024) 184 | randomFileIndex := rand.Intn(numFiles) 185 | defer w.Close() 186 | 187 | for i := 0; i < numFiles; i++ { 188 | nameLength := rand.Intn(100) + 10 189 | fileName := "" 190 | for j := 0; j < nameLength; j++ { 191 | fileName += string(chars[rand.Intn(len(chars))]) 192 | } 193 | 194 | if i == randomFileIndex { 195 | randomFileName[tarName] = fileName 196 | } 197 | 198 | bytesToWrite := rand.Intn(maxSize-minSize) + minSize 199 | 200 | if err := w.WriteHeader(&tar.Header{ 201 | Name: fileName, 202 | Typeflag: tar.TypeReg, 203 | Size: int64(bytesToWrite), 204 | }); err != nil { 205 | panic(err) 206 | } 207 | 208 | for bytesToWrite != 0 { 209 | if _, err := rand.Read(buf); err != nil { 210 | panic(err) 211 | } 212 | 213 | if bytesToWrite < 1024 { 214 | if _, err := w.Write(buf[:bytesToWrite]); err != nil { 215 | panic(err) 216 | } 217 | bytesToWrite = 0 218 | } else { 219 | if _, err := w.Write(buf); err != nil { 220 | panic(err) 221 | } 222 | bytesToWrite -= 1024 223 | } 224 | } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /entry.go: -------------------------------------------------------------------------------- 1 | package tarfs 2 | 3 | import ( 4 | "archive/tar" 5 | "bytes" 6 | "io" 7 | "io/fs" 8 | "sort" 9 | "time" 10 | ) 11 | 12 | type entry interface { 13 | fs.DirEntry 14 | size() int64 15 | readdir(path string) ([]fs.DirEntry, error) 16 | readfile(path string) ([]byte, error) 17 | entries(op, path string) ([]fs.DirEntry, error) 18 | open() (fs.File, error) 19 | } 20 | 21 | type regEntry struct { 22 | fs.DirEntry 23 | name string 24 | ra io.ReaderAt 25 | offset int64 26 | } 27 | 28 | var _ entry = ®Entry{} 29 | 30 | func (e *regEntry) size() int64 { 31 | info, _ := e.Info() // err is necessarily nil 32 | return info.Size() 33 | } 34 | 35 | func (e *regEntry) readdir(path string) ([]fs.DirEntry, error) { 36 | return nil, newErrNotDir("readdir", path) 37 | } 38 | 39 | func (e *regEntry) readfile(path string) ([]byte, error) { 40 | r, err := e.reader() 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | b := bytes.NewBuffer(make([]byte, 0, e.size())) 46 | 47 | if _, err := io.Copy(b, r); err != nil { 48 | return nil, err 49 | } 50 | 51 | return b.Bytes(), nil 52 | } 53 | 54 | func (e *regEntry) entries(op, path string) ([]fs.DirEntry, error) { 55 | return nil, newErrNotDir(op, path) 56 | } 57 | 58 | func (e *regEntry) open() (fs.File, error) { 59 | r, err := e.reader() 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | return &file{e, &readSeeker{&readCounter{r, 0}, e}, -1, false}, nil 65 | } 66 | 67 | func (e *regEntry) reader() (io.Reader, error) { 68 | tr := tar.NewReader(io.NewSectionReader(e.ra, e.offset, 1<<63-1-e.offset)) 69 | 70 | if _, err := tr.Next(); err != nil { 71 | return nil, err 72 | } 73 | 74 | return tr, nil 75 | } 76 | 77 | type dirEntry struct { 78 | fs.DirEntry 79 | _entries []fs.DirEntry 80 | sorted bool 81 | } 82 | 83 | func newDirEntry(e fs.DirEntry) *dirEntry { 84 | return &dirEntry{e, make([]fs.DirEntry, 0, 10), false} 85 | } 86 | 87 | func (e *dirEntry) append(c fs.DirEntry) { 88 | e._entries = append(e._entries, c) 89 | } 90 | 91 | var _ entry = &dirEntry{} 92 | 93 | func (e *dirEntry) size() int64 { 94 | return 0 95 | } 96 | 97 | func (e *dirEntry) readdir(path string) ([]fs.DirEntry, error) { 98 | if !e.sorted { 99 | sort.Sort(entriesByName(e._entries)) 100 | } 101 | 102 | entries := make([]fs.DirEntry, len(e._entries)) 103 | 104 | copy(entries, e._entries) 105 | 106 | return entries, nil 107 | } 108 | 109 | func (e *dirEntry) readfile(path string) ([]byte, error) { 110 | return nil, newErrDir("readfile", path) 111 | } 112 | 113 | func (e *dirEntry) entries(op, path string) ([]fs.DirEntry, error) { 114 | if !e.sorted { 115 | sort.Sort(entriesByName(e._entries)) 116 | } 117 | 118 | return e._entries, nil 119 | } 120 | 121 | func (e *dirEntry) open() (fs.File, error) { 122 | return &file{e, nil, 0, false}, nil 123 | } 124 | 125 | type fakeDirFileInfo string 126 | 127 | var _ fs.FileInfo = fakeDirFileInfo("") 128 | 129 | func (e fakeDirFileInfo) Name() string { 130 | return string(e) 131 | } 132 | 133 | func (fakeDirFileInfo) Size() int64 { 134 | return 0 135 | } 136 | 137 | func (fakeDirFileInfo) Mode() fs.FileMode { 138 | return fs.ModeDir 139 | } 140 | 141 | func (fakeDirFileInfo) ModTime() time.Time { 142 | return time.Time{} 143 | } 144 | 145 | func (fakeDirFileInfo) IsDir() bool { 146 | return true 147 | } 148 | 149 | func (fakeDirFileInfo) Sys() interface{} { 150 | return nil 151 | } 152 | 153 | type entriesByName []fs.DirEntry 154 | 155 | var _ sort.Interface = entriesByName{} 156 | 157 | func (entries entriesByName) Less(i, j int) bool { 158 | return entries[i].Name() < entries[j].Name() 159 | } 160 | 161 | func (entries entriesByName) Len() int { 162 | return len(entries) 163 | } 164 | 165 | func (entries entriesByName) Swap(i, j int) { 166 | entries[i], entries[j] = entries[j], entries[i] 167 | } 168 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package tarfs 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | ) 7 | 8 | // Generic errors 9 | var ( 10 | ErrNotDir = errors.New("not a directory") 11 | ErrDir = errors.New("is a directory") 12 | ) 13 | 14 | func newErrNotDir(op, path string) error { 15 | return newErr(op, path, ErrNotDir) 16 | } 17 | 18 | func newErrDir(op, path string) error { 19 | return newErr(op, path, ErrDir) 20 | } 21 | 22 | func newErrClosed(op, path string) error { 23 | return newErr(op, path, fs.ErrClosed) 24 | } 25 | 26 | func newErrNotExist(op, path string) error { 27 | return newErr(op, path, fs.ErrNotExist) 28 | } 29 | 30 | func newErr(op, path string, err error) error { 31 | return &fs.PathError{Op: op, Path: path, Err: err} 32 | } 33 | -------------------------------------------------------------------------------- /example_http_file_server_test.go: -------------------------------------------------------------------------------- 1 | package tarfs_test 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | 9 | "github.com/nlepage/go-tarfs" 10 | ) 11 | 12 | // ExampleHTTPFileServer demonstrates how to serve the contents of a tar file using HTTP 13 | func Example_httpFileServer() { 14 | tf, err := os.Open("test.tar") 15 | if err != nil { 16 | panic(err) 17 | } 18 | defer tf.Close() 19 | 20 | tfs, err := tarfs.New(tf) 21 | if err != nil { 22 | panic(err) 23 | } 24 | 25 | srv := httptest.NewServer(http.FileServer(http.FS(tfs))) 26 | defer srv.Close() 27 | 28 | res, err := srv.Client().Get(srv.URL + "/dir1/dir11/file111") 29 | if err != nil { 30 | panic(err) 31 | } 32 | 33 | if _, err := io.Copy(os.Stdout, res.Body); err != nil { 34 | panic(err) 35 | } 36 | res.Body.Close() 37 | 38 | // Output: 39 | // file111 40 | } 41 | -------------------------------------------------------------------------------- /example_stat_test.go: -------------------------------------------------------------------------------- 1 | package tarfs_test 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "os" 7 | 8 | "github.com/nlepage/go-tarfs" 9 | ) 10 | 11 | // Example_stat demonstrates how to read a file info from within a tar 12 | func Example_stat() { 13 | tf, err := os.Open("test.tar") 14 | if err != nil { 15 | panic(err) 16 | } 17 | defer tf.Close() 18 | 19 | tfs, err := tarfs.New(tf) 20 | if err != nil { 21 | panic(err) 22 | } 23 | 24 | fi, err := fs.Stat(tfs, "dir1/dir11/file111") 25 | if err != nil { 26 | panic(err) 27 | } 28 | 29 | fmt.Println(fi.Name()) 30 | fmt.Println(fi.IsDir()) 31 | fmt.Println(fi.Size()) 32 | 33 | // Output: 34 | // file111 35 | // false 36 | // 7 37 | } 38 | -------------------------------------------------------------------------------- /file.go: -------------------------------------------------------------------------------- 1 | package tarfs 2 | 3 | import ( 4 | "io" 5 | "io/fs" 6 | ) 7 | 8 | type file struct { 9 | entry 10 | r io.ReadSeeker 11 | readDirPos int 12 | closed bool 13 | } 14 | 15 | var _ fs.File = &file{} 16 | 17 | func (f *file) Stat() (fs.FileInfo, error) { 18 | const op = "stat" 19 | 20 | if f.closed { 21 | return nil, newErrClosed(op, f.Name()) 22 | } 23 | 24 | return f.Info() 25 | } 26 | 27 | func (f *file) Read(b []byte) (int, error) { 28 | const op = "read" 29 | 30 | if f.closed { 31 | return 0, newErrClosed(op, f.Name()) 32 | } 33 | 34 | if f.IsDir() { 35 | return 0, newErrDir(op, f.Name()) 36 | } 37 | 38 | return f.r.Read(b) 39 | } 40 | 41 | func (f *file) Close() error { 42 | const op = "close" 43 | 44 | if f.closed { 45 | return newErrClosed(op, f.Name()) 46 | } 47 | 48 | f.r = nil 49 | f.closed = true 50 | 51 | return nil 52 | } 53 | 54 | var _ io.Seeker = &file{} 55 | 56 | func (f *file) Seek(offset int64, whence int) (int64, error) { 57 | const op = "seek" 58 | 59 | if f.closed { 60 | return 0, newErrClosed(op, f.Name()) 61 | } 62 | 63 | if f.IsDir() { 64 | return 0, newErrDir(op, f.Name()) 65 | } 66 | 67 | return f.r.Seek(offset, whence) 68 | } 69 | 70 | var _ fs.ReadDirFile = &file{} 71 | 72 | func (f *file) ReadDir(n int) ([]fs.DirEntry, error) { 73 | const op = "readdir" 74 | 75 | if f.closed { 76 | return nil, newErrClosed(op, f.Name()) 77 | } 78 | 79 | allEntries, err := f.entry.entries(op, f.Name()) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | if f.readDirPos >= len(allEntries) { 85 | if n <= 0 { 86 | return nil, nil 87 | } 88 | return nil, io.EOF 89 | } 90 | 91 | if n <= 0 || f.readDirPos+n > len(allEntries) { 92 | n = len(allEntries) - f.readDirPos 93 | } 94 | 95 | entries := make([]fs.DirEntry, n) 96 | 97 | copy(entries, allEntries[f.readDirPos:]) 98 | 99 | f.readDirPos += n 100 | 101 | return entries, nil 102 | } 103 | -------------------------------------------------------------------------------- /fs.go: -------------------------------------------------------------------------------- 1 | package tarfs 2 | 3 | import ( 4 | "archive/tar" 5 | "bytes" 6 | "io" 7 | "io/fs" 8 | "path" 9 | "strings" 10 | ) 11 | 12 | const ( 13 | blockSize = 512 // Size of each block in a tar stream 14 | ) 15 | 16 | type tarfs struct { 17 | entries map[string]fs.DirEntry 18 | } 19 | 20 | var _ fs.FS = &tarfs{} 21 | 22 | // New creates a new tar fs.FS from r. 23 | // If r implements io.ReaderAt: 24 | // - files content are not stored in memory 25 | // - r must stay opened while using the fs.FS 26 | func New(r io.Reader) (fs.FS, error) { 27 | tfs := &tarfs{make(map[string]fs.DirEntry)} 28 | tfs.entries["."] = newDirEntry(fs.FileInfoToDirEntry(fakeDirFileInfo("."))) 29 | 30 | ra, isReaderAt := r.(readReaderAt) 31 | if !isReaderAt { 32 | buf, err := io.ReadAll(r) 33 | if err != nil { 34 | return nil, err 35 | } 36 | ra = bytes.NewReader(buf) 37 | } 38 | 39 | var cr readCounterIface 40 | if rs, isReadSeeker := ra.(io.ReadSeeker); isReadSeeker { 41 | cr = &readSeekCounter{ReadSeeker: rs} 42 | } else { 43 | cr = &readCounter{Reader: ra} 44 | } 45 | 46 | tr := tar.NewReader(cr) 47 | 48 | for { 49 | h, err := tr.Next() 50 | if err == io.EOF { 51 | break 52 | } 53 | if err != nil { 54 | return nil, err 55 | } 56 | if h.Typeflag == tar.TypeXGlobalHeader { 57 | continue 58 | } 59 | 60 | name := path.Clean(h.Name) 61 | if name == "." { 62 | continue 63 | } 64 | 65 | de := fs.FileInfoToDirEntry(h.FileInfo()) 66 | 67 | if h.FileInfo().IsDir() { 68 | tfs.append(name, newDirEntry(de)) 69 | } else { 70 | tfs.append(name, ®Entry{de, name, ra, cr.Count() - blockSize}) 71 | } 72 | } 73 | 74 | return tfs, nil 75 | } 76 | 77 | func (tfs *tarfs) append(name string, e fs.DirEntry) { 78 | tfs.entries[name] = e 79 | 80 | dir := path.Dir(name) 81 | 82 | if parent, ok := tfs.entries[dir]; ok { 83 | parent := parent.(*dirEntry) 84 | parent.append(e) 85 | return 86 | } 87 | 88 | parent := newDirEntry(fs.FileInfoToDirEntry(fakeDirFileInfo(path.Base(dir)))) 89 | 90 | tfs.append(dir, parent) 91 | 92 | parent.append(e) 93 | } 94 | 95 | func (tfs *tarfs) Open(name string) (fs.File, error) { 96 | const op = "open" 97 | 98 | e, err := tfs.get(op, name) 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | return e.open() 104 | } 105 | 106 | var _ fs.ReadDirFS = &tarfs{} 107 | 108 | func (tfs *tarfs) ReadDir(name string) ([]fs.DirEntry, error) { 109 | e, err := tfs.get("readdir", name) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | return e.readdir(name) 115 | } 116 | 117 | var _ fs.ReadFileFS = &tarfs{} 118 | 119 | func (tfs *tarfs) ReadFile(name string) ([]byte, error) { 120 | e, err := tfs.get("readfile", name) 121 | if err != nil { 122 | return nil, err 123 | } 124 | 125 | return e.readfile(name) 126 | } 127 | 128 | var _ fs.StatFS = &tarfs{} 129 | 130 | func (tfs *tarfs) Stat(name string) (fs.FileInfo, error) { 131 | e, err := tfs.get("stat", name) 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | return e.Info() 137 | } 138 | 139 | var _ fs.GlobFS = &tarfs{} 140 | 141 | func (tfs *tarfs) Glob(pattern string) (matches []string, _ error) { 142 | for name := range tfs.entries { 143 | match, err := path.Match(pattern, name) 144 | if err != nil { 145 | return nil, err 146 | } 147 | if match { 148 | matches = append(matches, name) 149 | } 150 | } 151 | return 152 | } 153 | 154 | var _ fs.SubFS = &tarfs{} 155 | 156 | func (tfs *tarfs) Sub(dir string) (fs.FS, error) { 157 | const op = "sub" 158 | 159 | if dir == "." { 160 | return tfs, nil 161 | } 162 | 163 | e, err := tfs.get(op, dir) 164 | if err != nil { 165 | return nil, err 166 | } 167 | 168 | subfs := &tarfs{make(map[string]fs.DirEntry)} 169 | 170 | subfs.entries["."] = e 171 | 172 | prefix := dir + "/" 173 | for name, file := range tfs.entries { 174 | if strings.HasPrefix(name, prefix) { 175 | subfs.entries[strings.TrimPrefix(name, prefix)] = file 176 | } 177 | } 178 | 179 | return subfs, nil 180 | } 181 | 182 | func (tfs *tarfs) get(op, path string) (entry, error) { 183 | if !fs.ValidPath(path) { 184 | return nil, newErr(op, path, fs.ErrInvalid) 185 | } 186 | 187 | e, ok := tfs.entries[path] 188 | if !ok { 189 | return nil, newErrNotExist(op, path) 190 | } 191 | 192 | return e.(entry), nil 193 | } 194 | -------------------------------------------------------------------------------- /fs_test.go: -------------------------------------------------------------------------------- 1 | package tarfs 2 | 3 | import ( 4 | "io" 5 | "io/fs" 6 | "os" 7 | "testing" 8 | "testing/fstest" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestFS(t *testing.T) { 15 | require := require.New(t) 16 | 17 | f, err := os.Open("test.tar") 18 | require.NoError(err) 19 | defer f.Close() 20 | 21 | tfs, err := New(f) 22 | require.NoError(err) 23 | 24 | err = fstest.TestFS(tfs, "bar", "foo", "dir1", "dir1/dir11", "dir1/dir11/file111", "dir1/file11", "dir1/file12", "dir2", "dir2/dir21", "dir2/dir21/file211", "dir2/dir21/file212") 25 | require.NoError(err) 26 | } 27 | 28 | func TestOpenInvalid(t *testing.T) { 29 | require, assert := require.New(t), assert.New(t) 30 | 31 | f, err := os.Open("test.tar") 32 | require.NoError(err) 33 | defer f.Close() 34 | 35 | tfs, err := New(f) 36 | require.NoError(err) 37 | 38 | for _, name := range []string{"/foo", "./foo", "foo/", "foo/../foo", "foo//bar"} { 39 | _, err := tfs.Open(name) 40 | assert.ErrorIsf(err, fs.ErrInvalid, "when tarfs.Open(%#v)", name) 41 | } 42 | } 43 | 44 | func TestOpenNotExist(t *testing.T) { 45 | require, assert := require.New(t), assert.New(t) 46 | 47 | f, err := os.Open("test.tar") 48 | require.NoError(err) 49 | defer f.Close() 50 | 51 | tfs, err := New(f) 52 | require.NoError(err) 53 | 54 | for _, name := range []string{"baz", "qwe", "foo/bar", "file11"} { 55 | _, err := tfs.Open(name) 56 | assert.ErrorIsf(err, fs.ErrNotExist, "when tarfs.Open(%#v)", name) 57 | } 58 | } 59 | 60 | func TestOpenThenStat(t *testing.T) { 61 | require, assert := require.New(t), assert.New(t) 62 | 63 | f, err := os.Open("test.tar") 64 | require.NoError(err) 65 | defer f.Close() 66 | 67 | tfs, err := New(f) 68 | require.NoError(err) 69 | 70 | for _, file := range []struct { 71 | path string 72 | name string 73 | isDir bool 74 | }{ 75 | {"foo", "foo", false}, 76 | {"bar", "bar", false}, 77 | {"dir1", "dir1", true}, 78 | {"dir1/file11", "file11", false}, 79 | {".", ".", true}, 80 | } { 81 | f, err := tfs.Open(file.path) 82 | if !assert.NoErrorf(err, "when tarfs.Open(%#v)", file.path) { 83 | continue 84 | } 85 | 86 | fi, err := f.Stat() 87 | if !assert.NoErrorf(err, "when file{%#v}.Stat()", file.path) { 88 | continue 89 | } 90 | 91 | assert.Equalf(file.name, fi.Name(), "file{%#v}.Stat().Name()", file.path) 92 | assert.Equalf(file.isDir, fi.IsDir(), "file{%#v}.Stat().IsDir()", file.path) 93 | } 94 | } 95 | 96 | func TestOpenThenReadAll(t *testing.T) { 97 | require, assert := require.New(t), assert.New(t) 98 | 99 | f, err := os.Open("test.tar") 100 | require.NoError(err) 101 | defer f.Close() 102 | 103 | tfs, err := New(f) 104 | require.NoError(err) 105 | 106 | for _, file := range []struct { 107 | path string 108 | content []byte 109 | }{ 110 | {"foo", []byte("foo")}, 111 | {"bar", []byte("bar")}, 112 | {"dir1/file11", []byte("file11")}, 113 | } { 114 | f, err := tfs.Open(file.path) 115 | if !assert.NoErrorf(err, "when tarfs.Open(%#v)", file.path) { 116 | continue 117 | } 118 | 119 | content, err := io.ReadAll(f) 120 | if !assert.NoErrorf(err, "when io.ReadAll(file{%#v})", file.path) { 121 | continue 122 | } 123 | 124 | assert.Equalf(file.content, content, "content of %#v", file.path) 125 | } 126 | } 127 | 128 | func TestOpenThenSeekAfterEnd(t *testing.T) { 129 | require := require.New(t) 130 | 131 | f, err := os.Open("test.tar") 132 | require.NoError(err) 133 | defer f.Close() 134 | 135 | tfs, err := New(f) 136 | require.NoError(err) 137 | 138 | r, err := tfs.Open("foo") 139 | require.NoError(err, "when tarfs.Open(foo)") 140 | 141 | rs := r.(io.ReadSeeker) 142 | 143 | abs, err := rs.Seek(10, io.SeekStart) 144 | require.NoError(err, "when ReadSeeker.Seek(10, io.SeekStart)") 145 | require.Equal(int64(10), abs, "when ReadSeeker.Seek(10, io.SeekStart)") 146 | 147 | b := make([]byte, 0, 1) 148 | _, err = rs.Read(b) 149 | require.ErrorIs(err, io.EOF, "when ReadSeeker.Read([]byte)") 150 | } 151 | 152 | func TestReadDir(t *testing.T) { 153 | require, assert := require.New(t), assert.New(t) 154 | 155 | f, err := os.Open("test.tar") 156 | require.NoError(err) 157 | defer f.Close() 158 | 159 | tfs, err := New(f) 160 | require.NoError(err) 161 | 162 | for _, dir := range []struct { 163 | name string 164 | entriesLen int 165 | }{ 166 | {".", 4}, 167 | {"dir1", 3}, 168 | {"dir2/dir21", 2}, 169 | } { 170 | entries, err := fs.ReadDir(tfs, dir.name) 171 | if !assert.NoErrorf(err, "when fs.ReadDir(tfs, %#v)", dir.name) { 172 | continue 173 | } 174 | 175 | assert.Equalf(dir.entriesLen, len(entries), "len(entries) for %#v", dir.name) 176 | } 177 | } 178 | 179 | func TestReadDirNotDir(t *testing.T) { 180 | require, assert := require.New(t), assert.New(t) 181 | 182 | f, err := os.Open("test.tar") 183 | require.NoError(err) 184 | defer f.Close() 185 | 186 | tfs, err := New(f) 187 | require.NoError(err) 188 | 189 | for _, name := range []string{"foo", "dir1/file12"} { 190 | _, err := fs.ReadDir(tfs, name) 191 | assert.ErrorIsf(err, ErrNotDir, "when tarfs.ReadDir(tfs, %#v)", name) 192 | } 193 | } 194 | 195 | func TestReadFile(t *testing.T) { 196 | require, assert := require.New(t), assert.New(t) 197 | 198 | f, err := os.Open("test.tar") 199 | require.NoError(err) 200 | defer f.Close() 201 | 202 | tfs, err := New(f) 203 | require.NoError(err) 204 | 205 | for _, file := range []struct { 206 | path string 207 | content string 208 | }{ 209 | {"bar", "bar"}, 210 | {"dir1/dir11/file111", "file111"}, 211 | {"dir1/file11", "file11"}, 212 | {"dir1/file12", "file12"}, 213 | {"dir2/dir21/file211", "file211"}, 214 | {"dir2/dir21/file212", "file212"}, 215 | {"foo", "foo"}, 216 | } { 217 | b, err := fs.ReadFile(tfs, file.path) 218 | if !assert.NoErrorf(err, "when fs.ReadFile(tfs, %#v)", file.path) { 219 | continue 220 | } 221 | 222 | assert.Equalf(file.content, string(b), "in %#v", file.path) 223 | } 224 | } 225 | 226 | func TestStat(t *testing.T) { 227 | require, assert := require.New(t), assert.New(t) 228 | 229 | f, err := os.Open("test.tar") 230 | require.NoError(err) 231 | defer f.Close() 232 | 233 | tfs, err := New(f) 234 | require.NoError(err) 235 | 236 | for _, file := range []struct { 237 | path string 238 | name string 239 | isDir bool 240 | }{ 241 | {"dir1/dir11/file111", "file111", false}, 242 | {"foo", "foo", false}, 243 | {"dir2/dir21", "dir21", true}, 244 | {".", ".", true}, 245 | } { 246 | fi, err := fs.Stat(tfs, file.path) 247 | if !assert.NoErrorf(err, "when fs.Stat(tfs, %#v)", file.path) { 248 | continue 249 | } 250 | 251 | assert.Equalf(file.name, fi.Name(), "FileInfo{%#v}.Name()", file.path) 252 | 253 | assert.Equalf(file.isDir, fi.IsDir(), "FileInfo{%#v}.IsDir()", file.path) 254 | } 255 | } 256 | 257 | func TestGlob(t *testing.T) { 258 | require, assert := require.New(t), assert.New(t) 259 | 260 | f, err := os.Open("test.tar") 261 | require.NoError(err) 262 | defer f.Close() 263 | 264 | tfs, err := New(f) 265 | require.NoError(err) 266 | 267 | for pattern, expected := range map[string][]string{ 268 | "*/*2*": {"dir1/file12", "dir2/dir21"}, 269 | "*": {"bar", "dir1", "dir2", "foo", "."}, 270 | "*/*/*": {"dir1/dir11/file111", "dir2/dir21/file211", "dir2/dir21/file212"}, 271 | "*/*/*/*": nil, 272 | } { 273 | actual, err := fs.Glob(tfs, pattern) 274 | if !assert.NoErrorf(err, "when fs.Glob(tfs, %#v)", pattern) { 275 | continue 276 | } 277 | 278 | assert.ElementsMatchf(expected, actual, "matches for pattern %#v", pattern) 279 | } 280 | } 281 | 282 | func TestSubThenReadDir(t *testing.T) { 283 | require, assert := require.New(t), assert.New(t) 284 | 285 | f, err := os.Open("test.tar") 286 | require.NoError(err) 287 | defer f.Close() 288 | 289 | tfs, err := New(f) 290 | require.NoError(err) 291 | 292 | for _, dir := range []struct { 293 | name string 294 | entriesLen int 295 | }{ 296 | {".", 4}, 297 | {"dir1", 3}, 298 | {"dir2/dir21", 2}, 299 | } { 300 | subfs, err := fs.Sub(tfs, dir.name) 301 | if !assert.NoErrorf(err, "when fs.Sub(tfs, %#v)", dir.name) { 302 | continue 303 | } 304 | 305 | entries, err := fs.ReadDir(subfs, ".") 306 | if !assert.NoErrorf(err, "when fs.ReadDir(subfs, %#v)", dir.name) { 307 | continue 308 | } 309 | 310 | assert.Equalf(dir.entriesLen, len(entries), "len(entries) for %#v", dir.name) 311 | } 312 | } 313 | 314 | func TestSubThenReadFile(t *testing.T) { 315 | require := require.New(t) 316 | 317 | f, err := os.Open("test.tar") 318 | require.NoError(err) 319 | defer f.Close() 320 | 321 | tfs, err := New(f) 322 | require.NoError(err) 323 | 324 | name := "dir2" 325 | 326 | subfs, err := fs.Sub(tfs, name) 327 | require.NoErrorf(err, "when fs.Sub(tfs, %#v)", name) 328 | 329 | name = "dir21/file211" 330 | content := "file211" 331 | 332 | b, err := fs.ReadFile(subfs, name) 333 | require.NoErrorf(err, "when fs.ReadFile(subfs, %#v)", name) 334 | 335 | require.Equalf(content, string(b), "in %#v", name) 336 | } 337 | 338 | func TestReadOnDir(t *testing.T) { 339 | require, assert := require.New(t), assert.New(t) 340 | 341 | tf, err := os.Open("test.tar") 342 | require.NoError(err) 343 | defer tf.Close() 344 | 345 | tfs, err := New(tf) 346 | require.NoError(err) 347 | 348 | var dirs = []string{"dir1", "dir2/dir21", "."} 349 | 350 | for _, name := range dirs { 351 | f, err := tfs.Open(name) 352 | if !assert.NoErrorf(err, "when fs.ReadFile(subfs, %#v)", name) { 353 | continue 354 | } 355 | 356 | _, err = f.Read(make([]byte, 1)) 357 | assert.ErrorIsf(err, ErrDir, "when file{%#v}.Read()", name) 358 | 359 | _, err = fs.ReadFile(tfs, name) 360 | assert.ErrorIsf(err, ErrDir, "fs.ReadFile(tfs, %#v)", name) 361 | } 362 | } 363 | 364 | func TestWithDotDirInArchive(t *testing.T) { 365 | require := require.New(t) 366 | 367 | f, err := os.Open("test-with-dot-dir.tar") 368 | require.NoError(err) 369 | defer f.Close() 370 | 371 | tfs, err := New(f) 372 | require.NoError(err) 373 | 374 | err = fstest.TestFS(tfs, "bar", "foo", "dir1", "dir1/dir11", "dir1/dir11/file111", "dir1/file11", "dir1/file12", "dir2", "dir2/dir21", "dir2/dir21/file211", "dir2/dir21/file212") 375 | require.NoError(err) 376 | } 377 | 378 | func TestWithNoDirEntriesInArchive(t *testing.T) { 379 | require := require.New(t) 380 | 381 | f, err := os.Open("test-no-directory-entries.tar") 382 | require.NoError(err) 383 | defer f.Close() 384 | 385 | tfs, err := New(f) 386 | require.NoError(err) 387 | 388 | err = fstest.TestFS(tfs, "bar", "foo", "dir1", "dir1/dir11", "dir1/dir11/file111", "dir1/file11", "dir1/file12", "dir2", "dir2/dir21", "dir2/dir21/file211", "dir2/dir21/file212") 389 | require.NoError(err) 390 | } 391 | 392 | func TestSparse(t *testing.T) { 393 | require, assert := require.New(t), assert.New(t) 394 | 395 | f, err := os.Open("test-sparse.tar") 396 | require.NoError(err) 397 | defer f.Close() 398 | 399 | tfs, err := New(f) 400 | require.NoError(err) 401 | 402 | err = fstest.TestFS(tfs, "file1", "file2") 403 | assert.NoError(err) 404 | 405 | if file1Actual, err := fs.ReadFile(tfs, "file1"); assert.NoError(err, "fs.ReadFile(tfs, \"file1\")") { 406 | file1Expected := make([]byte, 1000000) 407 | copy(file1Expected, []byte{1, 1, 1, 1, 1, 1, 1, 1, 1, 1}) 408 | copy(file1Expected[999990:], []byte{1, 1, 1, 1, 1, 1, 1, 1, 1, 1}) 409 | assert.Equal(file1Expected, file1Actual, "fs.ReadFile(tfs, \"file1\")") 410 | } 411 | 412 | if file2Actual, err := fs.ReadFile(tfs, "file2"); assert.NoError(err, "fs.ReadFile(tfs, \"file2\")") { 413 | assert.Equal([]byte("file2"), file2Actual, "fs.ReadFile(tfs, \"file2\")") 414 | } 415 | } 416 | 417 | func TestIgnoreGlobalHeader(t *testing.T) { 418 | require := require.New(t) 419 | 420 | // This file was created by initializing a git repository, 421 | // committing a few files, and running: `git archive HEAD` 422 | f, err := os.Open("test-with-global-header.tar") 423 | require.NoError(err) 424 | defer f.Close() 425 | 426 | tfs, err := New(f) 427 | require.NoError(err) 428 | 429 | err = fstest.TestFS(tfs, "bar", "dir1", "dir1/file11") 430 | require.NoError(err) 431 | } 432 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nlepage/go-tarfs 2 | 3 | go 1.17 4 | 5 | require github.com/stretchr/testify v1.7.1 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.0 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 6 | github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= 7 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 10 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 11 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 12 | -------------------------------------------------------------------------------- /io.go: -------------------------------------------------------------------------------- 1 | package tarfs 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | ) 7 | 8 | type readReaderAt interface { 9 | io.Reader 10 | io.ReaderAt 11 | } 12 | 13 | type readCounterIface interface { 14 | io.Reader 15 | Count() int64 16 | } 17 | 18 | type readCounter struct { 19 | io.Reader 20 | off int64 21 | } 22 | 23 | func (cr *readCounter) Read(p []byte) (n int, err error) { 24 | n, err = cr.Reader.Read(p) 25 | cr.off += int64(n) 26 | return 27 | } 28 | 29 | func (cr *readCounter) Count() int64 { 30 | return cr.off 31 | } 32 | 33 | type readSeekCounter struct { 34 | io.ReadSeeker 35 | off int64 36 | } 37 | 38 | func (cr *readSeekCounter) Read(p []byte) (n int, err error) { 39 | n, err = cr.ReadSeeker.Read(p) 40 | cr.off += int64(n) 41 | return 42 | } 43 | 44 | func (cr *readSeekCounter) Seek(offset int64, whence int) (abs int64, err error) { 45 | abs, err = cr.ReadSeeker.Seek(offset, whence) 46 | cr.off = abs 47 | return 48 | } 49 | 50 | func (cr *readSeekCounter) Count() int64 { 51 | return cr.off 52 | } 53 | 54 | type readSeeker struct { 55 | *readCounter 56 | e *regEntry 57 | } 58 | 59 | var _ io.ReadSeeker = &readSeeker{} 60 | 61 | func (rs *readSeeker) Seek(offset int64, whence int) (int64, error) { 62 | const op = "seek" 63 | 64 | var abs int64 65 | switch whence { 66 | case io.SeekStart: 67 | abs = offset 68 | case io.SeekCurrent: 69 | abs = rs.off + offset 70 | case io.SeekEnd: 71 | abs = rs.e.size() + offset 72 | default: 73 | return 0, newErr(op, rs.e.name, errors.New("invalid whence")) 74 | } 75 | if abs < 0 { 76 | return 0, newErr(op, rs.e.name, errors.New("negative position")) 77 | } 78 | 79 | if abs < rs.off { 80 | r, err := rs.e.reader() 81 | if err != nil { 82 | return 0, err 83 | } 84 | 85 | rs.readCounter = &readCounter{r, 0} 86 | } 87 | 88 | if abs > rs.off { 89 | if _, err := io.CopyN(io.Discard, rs.readCounter, abs-rs.off); err != nil && err != io.EOF { 90 | return 0, err 91 | } 92 | } 93 | 94 | return abs, nil 95 | } 96 | -------------------------------------------------------------------------------- /test-no-directory-entries.tar: -------------------------------------------------------------------------------- 1 | bar0000644000175000017500000000000314153366153011507 0ustar00niconico00000000000000bardir1/dir11/file1110000644000175000017500000000000714153366153013670 0ustar00niconico00000000000000file111dir1/file110000644000175000017500000000000614153366153012666 0ustar00niconico00000000000000file11dir1/file120000644000175000017500000000000614153366153012667 0ustar00niconico00000000000000file12dir2/dir21/file2110000644000175000017500000000000714153366153013673 0ustar00niconico00000000000000file211dir2/dir21/file2120000644000175000017500000000000714153366153013674 0ustar00niconico00000000000000file212foo0000644000175000017500000000000314153366153011526 0ustar00niconico00000000000000foo -------------------------------------------------------------------------------- /test-sparse.tar: -------------------------------------------------------------------------------- 1 | file10000755000175000017500000001110014457701707017754 Sustar niconico00000000000000000100000000364000000000001100000036411000000000000000003641100file20000644000175000017500000000000514457675025010456 0ustar niconicofile2 -------------------------------------------------------------------------------- /test-with-dot-dir.tar: -------------------------------------------------------------------------------- 1 | ./0000755000175000017500000000000014153366153007664 5ustar niconico./bar0000644000175000017500000000000314153366153010344 0ustar niconicobar./foo0000644000175000017500000000000314153366153010363 0ustar niconicofoo./dir1/0000755000175000017500000000000014153366153010523 5ustar niconico./dir1/file110000644000175000017500000000000614153366153011523 0ustar niconicofile11./dir1/file120000644000175000017500000000000614153366153011524 0ustar niconicofile12./dir1/dir11/0000755000175000017500000000000014153366153011443 5ustar niconico./dir1/dir11/file1110000644000175000017500000000000714153366153012525 0ustar niconicofile111./dir2/0000755000175000017500000000000014153366153010524 5ustar niconico./dir2/dir21/0000755000175000017500000000000014153366153011445 5ustar niconico./dir2/dir21/file2110000644000175000017500000000000714153366153012530 0ustar niconicofile211./dir2/dir21/file2120000644000175000017500000000000714153366153012531 0ustar niconicofile212 -------------------------------------------------------------------------------- /test-with-global-header.tar: -------------------------------------------------------------------------------- 1 | pax_global_header00006660000000000000000000000064144665423050014523gustar00rootroot0000000000000052 comment=81e64bba73a45c0a4a85ee2dbcfc7bb06bfca296 2 | bar000066400000000000000000000000031446654230500115470ustar00rootroot00000000000000bardir1/000077500000000000000000000000001446654230500117265ustar00rootroot00000000000000dir1/file11000066400000000000000000000000061446654230500127260ustar00rootroot00000000000000file11 -------------------------------------------------------------------------------- /test.tar: -------------------------------------------------------------------------------- 1 | bar0000644000175000017500000000000314006627363010207 0ustar niconicobardir1/0000755000175000017500000000000014006630077010362 5ustar niconicodir1/file110000644000175000017500000000000614006627372011366 0ustar niconicofile11dir1/file120000644000175000017500000000000614006630060011353 0ustar niconicofile12dir1/dir11/0000755000175000017500000000000014006630107011274 5ustar niconicodir1/dir11/file1110000644000175000017500000000000714006630112012352 0ustar niconicofile111dir2/0000755000175000017500000000000014006630135010356 5ustar niconicodir2/dir21/0000755000175000017500000000000014006630151011275 5ustar niconicodir2/dir21/file2110000644000175000017500000000000714006630160012360 0ustar niconicofile211dir2/dir21/file2120000644000175000017500000000000714006630154012364 0ustar niconicofile212foo0000644000175000017500000000000314006627347010230 0ustar niconicofoo -------------------------------------------------------------------------------- /test/bar: -------------------------------------------------------------------------------- 1 | bar -------------------------------------------------------------------------------- /test/dir1/dir11/file111: -------------------------------------------------------------------------------- 1 | file111 -------------------------------------------------------------------------------- /test/dir1/file11: -------------------------------------------------------------------------------- 1 | file11 -------------------------------------------------------------------------------- /test/dir1/file12: -------------------------------------------------------------------------------- 1 | file12 -------------------------------------------------------------------------------- /test/dir2/dir21/file211: -------------------------------------------------------------------------------- 1 | file211 -------------------------------------------------------------------------------- /test/dir2/dir21/file212: -------------------------------------------------------------------------------- 1 | file212 -------------------------------------------------------------------------------- /test/foo: -------------------------------------------------------------------------------- 1 | foo --------------------------------------------------------------------------------