diff --git a/file.go b/file.go index 7ac1fc9..501619c 100644 --- a/file.go +++ b/file.go @@ -4,6 +4,7 @@ import ( "errors" "io" "os" + "time" ) var ( @@ -17,6 +18,13 @@ var ( type Node interface { io.Closer + // Mode returns the file's mode + Mode() os.FileMode + + // ModTime returns the files last modification time. + // If the last modification time is unknown/unspecified `mtime` is 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/file_test.go b/file_test.go index 8c6c622..0a4552f 100644 --- a/file_test.go +++ b/file_test.go @@ -3,8 +3,10 @@ package files import ( "io" "mime/multipart" + "os" "strings" "testing" + "time" ) func TestSliceFiles(t *testing.T) { @@ -48,36 +50,62 @@ func TestReaderFiles(t *testing.T) { t.Fatal("Expected EOF when reading after close") } } + +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! Content-Type: text/plain -Content-Disposition: file; filename="name" +Content-Disposition: form-data; name="file-0?mode=0754&mtime=1604320500&mtime-nsecs=55555"; filename="file1" Some-Header: beep beep --Boundary! Content-Type: application/x-directory -Content-Disposition: file; filename="dir" +Content-Disposition: form-data; name="dir-0?mode=755&mtime=1604320500"; ans=42; filename="dir1" --Boundary! Content-Type: text/plain -Content-Disposition: file; filename="dir/nested" +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: file; filename="dir/simlynk" +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: file; filename="implicit1/implicit2/deep_implicit" +Content-Disposition: form-data; name="dir?mode=0644"; filename="implicit1/implicit2/deep_implicit" implicit file1 --Boundary! Content-Type: text/plain -Content-Disposition: file; filename="implicit1/shallow_implicit" +Content-Disposition: form-data; name="dir?mode=755&mtime=1604320500"; filename="implicit1/shallow_implicit" implicit file2 --Boundary!-- @@ -94,22 +122,40 @@ implicit file2 CheckDir(t, dir, []Event{ { kind: TFile, - name: "name", + name: "file1", value: "beep", + mode: 0754, + mtime: time.Unix(1604320500, 55555), }, { - kind: TDirStart, - name: "dir", + 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, @@ -126,6 +172,7 @@ implicit file2 kind: TFile, name: "deep_implicit", value: "implicit file1", + mode: 0644, }, { kind: TDirEnd, @@ -134,6 +181,8 @@ implicit file2 kind: TFile, name: "shallow_implicit", value: "implicit file2", + mode: 0755, + mtime: time.Unix(1604320500, 0), }, { kind: TDirEnd, diff --git a/filter_test.go b/filter_test.go index 8ce25ee..06e2e49 100644 --- a/filter_test.go +++ b/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/helpers_test.go b/helpers_test.go index 0180b8f..8ae7cd8 100644 --- a/helpers_test.go +++ b/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 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/linkfile.go b/linkfile.go index 5269986..2016483 100644 --- a/linkfile.go +++ b/linkfile.go @@ -3,21 +3,37 @@ 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/multifilereader.go b/multifilereader.go index f6f225a..233e3e9 100644 --- a/multifilereader.go +++ b/multifilereader.go @@ -8,6 +8,8 @@ import ( "net/textproto" "net/url" "path" + "strconv" + "strings" "sync" ) @@ -88,12 +90,7 @@ func (mfr *MultiFileReader) Read(buf []byte) (written int, err error) { // 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)) + mfr.addContentDisposition(header, filename) var contentType string @@ -112,7 +109,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 { header.Set("abspath", rf.AbsPath()) } @@ -146,6 +143,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\"", 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/multifilereader_test.go b/multifilereader_test.go index e36788a..cff9d19 100644 --- a/multifilereader_test.go +++ b/multifilereader_test.go @@ -3,19 +3,29 @@ package files import ( "io" "mime/multipart" + "strings" "testing" + "time" ) var text = "Some text! :)" 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 @@ -36,6 +46,14 @@ func TestMultiFileReaderToMultiFile(t *testing.T) { t.Fatal("iterator didn't work as expected") } + n := it.Node() + if n.Mode() != 0754 { + t.Fatal("unexpected file mode") + } + if n.ModTime() != time.Unix(1604320500, 55555) { + t.Fatal("unexpected last modification time") + } + if !it.Next() || it.Name() != "boop" || DirFromEntry(it) == nil { t.Fatal("iterator didn't work as expected") } diff --git a/multipartfile.go b/multipartfile.go index 2765398..560283b 100644 --- a/multipartfile.go +++ b/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 f.stat.Mode() + } + return 0 +} + +func (f *multipartDirectory) ModTime() time.Time { + if f.stat != nil { + return f.stat.ModTime() + } + return time.Time{} +} + 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,11 +136,12 @@ func (w *multipartWalker) nextFile() (Node, error) { return nil, err } - return NewLinkFile(string(out), nil), nil + return NewLinkFile(string(out), fileInfo(name, part)), nil default: return &ReaderFile{ reader: part, abspath: part.Header.Get("abspath"), + stat: fileInfo(name, part), }, nil } } @@ -159,6 +198,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 @@ -196,9 +273,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/readerfile.go b/readerfile.go index a03dae2..57ca9f5 100644 --- a/readerfile.go +++ b/readerfile.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "time" ) // ReaderFile is a implementation of File created from an `io.Reader`. @@ -13,14 +14,31 @@ 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 { return &ReaderFile{"", NewReaderFile(bytes.NewReader(b)), nil, int64(len(b))} } +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/serialfile.go b/serialfile.go index 176038c..11f5c3d 100644 --- a/serialfile.go +++ b/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,5 +165,13 @@ 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{} var _ DirIterator = &serialIterator{} diff --git a/slicedirectory.go b/slicedirectory.go index d116562..b4ad081 100644 --- a/slicedirectory.go +++ b/slicedirectory.go @@ -1,6 +1,10 @@ package files -import "sort" +import ( + "os" + "sort" + "time" +) type fileEntry struct { name string @@ -49,9 +53,32 @@ 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 { + 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, 0, len(f)) for name, nd := range f { ents = append(ents, FileEntry(name, nd)) @@ -59,12 +86,15 @@ func NewMapDirectory(f map[string]Node) Directory { sort.Slice(ents, func(i, j int) bool { return ents[i].Name() < ents[j].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/tarwriter.go b/tarwriter.go index 4f4ee4e..f67bffd 100644 --- a/tarwriter.go +++ b/tarwriter.go @@ -9,24 +9,26 @@ import ( ) type TarWriter struct { - TarW *tar.Writer + TarW *tar.Writer + 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 } it := f.Entries() for it.Next() { - if err := w.WriteFile(it.Node(), path.Join(fpath, it.Name())); err != nil { + if err := w.WriteNode(it.Node(), path.Join(fpath, it.Name())); err != nil { return err } } @@ -39,7 +41,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 } @@ -51,10 +53,10 @@ func (w *TarWriter) writeFile(f File, fpath string) error { } // WriteNode adds a node to the archive. -func (w *TarWriter) WriteFile(nd Node, fpath string) error { +func (w *TarWriter) WriteNode(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: @@ -69,32 +71,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: 0777, - 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: 0644, - 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: 0777, - Typeflag: tar.TypeSymlink, - }) +func (w *TarWriter) SetFormat(format tar.Format) { + w.format = format } diff --git a/tarwriter_test.go b/tarwriter_test.go index f66d035..0759c7d 100644 --- a/tarwriter_test.go +++ b/tarwriter_test.go @@ -10,11 +10,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() @@ -22,18 +24,20 @@ func TestTarWriter(t *testing.T) { if err != nil { t.Fatal(err) } + tw.SetFormat(tar.FormatPAX) tr := tar.NewReader(pr) go func() { defer tw.Close() - if err := tw.WriteFile(tf, ""); err != nil { + if err := tw.WriteNode(tf, ""); err != nil { t.Error(err) } }() var cur *tar.Header + 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) } @@ -43,41 +47,53 @@ 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) diff --git a/util.go b/util.go index e727e7a..18c5900 100644 --- a/util.go +++ b/util.go @@ -1,5 +1,9 @@ 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 +27,36 @@ 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) (m os.FileMode) { + if unixPerms != 0 { + m = os.FileMode((unixPerms & 0x1FF) | (unixPerms & 0xC00 << 12) | (unixPerms & 0x200 << 11)) + } + return m +} diff --git a/util_test.go b/util_test.go new file mode 100644 index 0000000..71e1715 --- /dev/null +++ b/util_test.go @@ -0,0 +1,23 @@ +package files + +import ( + "github.com/stretchr/testify/assert" + "os" + "testing" +) + +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/webfile.go b/webfile.go index 594b81c..fc966f5 100644 --- a/webfile.go +++ b/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/webfile_test.go b/webfile_test.go index 94cddb5..b2a7238 100644 --- a/webfile_test.go +++ b/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) {